{"format":"sanctum-bundle/1","html":"<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1, viewport-fit=cover\">\n<meta name=\"referrer\" content=\"no-referrer\">\n<meta name=\"theme-color\" content=\"#0a0a0a\">\n<title>Enigma OTP Vault for Sanctum</title>\n<meta name=\"description\" content=\"Time-based one-time codes (TOTP) kept in Sanctum storage and optionally on a smart card.\">\n<link rel=\"icon\" href=\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32' width='32' height='32'%3E%3Crect width='32' height='32' rx='6' fill='%230A0A0A'/%3E%3Crect x='3' y='3' width='26' height='26' rx='5' fill='none' stroke='%23FBFF00' stroke-width='1.5'/%3E%3Ctext x='16' y='20.5' text-anchor='middle' font-family='Poppins,Helvetica,Arial,sans-serif' font-size='10' font-weight='700' fill='%23FFFFFF' letter-spacing='-0.3'%3Eotp%3C/text%3E%3C/svg%3E\">\n<style>\n  :root{\n    --ink:#000000; --ink-soft:#0a0a0a; --panel:#111111; --paper:#FFFFFF; --volt:#FBFF00;\n    --ash:#8A8A8A; --ash-soft:#5E5E5E; --line:rgba(255,255,255,.12); --line-strong:rgba(255,255,255,.24);\n    --danger:#FF5A4E; --ok:#7CFF8A;\n    --display:'Poppins',system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;\n    --mono:'Space Mono',ui-monospace,SFMono-Regular,Menlo,Consolas,'Liberation Mono',monospace;\n    --emoji:'Apple Color Emoji','Segoe UI Emoji','Noto Color Emoji',sans-serif;\n    --gap:clamp(16px,4vw,48px);\n    color-scheme:dark;\n  }\n  *{box-sizing:border-box}\n  html,body{margin:0;background:var(--ink);color:var(--paper)}\n  body{font-family:var(--mono);font-size:14px;line-height:1.6;-webkit-font-smoothing:antialiased;min-height:100vh;\n    padding:env(safe-area-inset-top,0) 0 env(safe-area-inset-bottom,0)}\n  body::before{content:\"\";position:fixed;inset:0;pointer-events:none;z-index:50;\n    background-image:repeating-linear-gradient(0deg,rgba(255,255,255,.018) 0 1px,transparent 1px 3px);opacity:.6}\n  [hidden]{display:none!important}\n  a{color:inherit}\n  .wrap{max-width:980px;margin:0 auto;padding:0 var(--gap)}\n\n  /* header */\n  header.bar{position:sticky;top:0;z-index:40;background:rgba(0,0,0,.86);backdrop-filter:blur(8px);border-bottom:1px solid var(--line)}\n  .bar-in{display:flex;align-items:center;gap:16px;min-height:64px;flex-wrap:wrap;padding-block:8px}\n  .mark{display:flex;align-items:center;gap:12px;text-decoration:none}\n  .ets-badge{display:inline-flex;align-items:center;justify-content:center;width:42px;height:28px;background:var(--paper)}\n  .ets{font-family:'Inter','Helvetica Neue',Arial,sans-serif;font-weight:800;font-size:13px;color:#000;letter-spacing:.01em;line-height:1}\n  .wordmark{font-size:12px;letter-spacing:.18em;text-transform:uppercase}\n  .wordmark b{color:var(--ash);font-weight:400}\n  .bar-right{margin-left:auto;display:flex;align-items:center;gap:12px;flex-wrap:wrap}\n  .who{font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--ash)}\n  .fp-mini{font-family:var(--emoji);font-size:18px;letter-spacing:2px;border:1px solid var(--line);padding:2px 8px;white-space:nowrap}\n\n  /* hero */\n  .hero{padding:clamp(40px,8vw,88px) 0 clamp(28px,5vw,48px);border-bottom:1px solid var(--line)}\n  .eyebrow{font-size:11px;letter-spacing:.3em;text-transform:uppercase;color:var(--ash);margin:0 0 18px}\n  h1{font-family:var(--display);font-weight:800;font-size:clamp(40px,9vw,96px);line-height:.92;letter-spacing:-.02em;margin:0}\n  .dash{display:inline-block;width:.46em;height:.13em;background:var(--volt);vertical-align:.3em;margin:0 .04em}\n  .dot{display:inline-block;width:.2em;height:.2em;background:var(--volt);vertical-align:.02em;margin-left:.05em}\n  .lede{max-width:600px;margin:24px 0 0;color:var(--paper)}\n  .lede span{color:var(--ash)}\n\n  section.block{padding:clamp(28px,5vw,56px) 0;border-bottom:1px solid var(--line)}\n  .kicker{font-size:11px;letter-spacing:.28em;text-transform:uppercase;color:var(--ash);margin:0 0 12px;display:flex;gap:12px;align-items:center}\n  .kicker::before{content:\"\";width:28px;height:2px;background:var(--volt)}\n  h2{font-family:var(--display);font-weight:700;font-size:clamp(24px,4vw,40px);line-height:1.05;letter-spacing:-.02em;margin:0 0 6px}\n  .muted{color:var(--ash)}\n  .small{font-size:12px}\n\n  /* tabs */\n  .tabs{display:flex;gap:0;border:1px solid var(--line-strong);width:max-content;max-width:100%;margin:0 0 24px}\n  .tabs button{background:transparent;color:var(--ash);border:0;padding:10px 18px;font:inherit;font-size:12px;letter-spacing:.14em;text-transform:uppercase;cursor:pointer}\n  .tabs button[aria-selected=\"true\"]{background:var(--volt);color:#000}\n\n  /* forms */\n  .grid2{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:24px;align-items:start}\n  .card{border:1px solid var(--line);background:var(--ink-soft);padding:clamp(16px,3vw,28px)}\n  label.f{display:block;margin:0 0 14px}\n  label.f > span{display:block;font-size:11px;letter-spacing:.16em;text-transform:uppercase;color:var(--ash);margin-bottom:6px}\n  input[type=text],input[type=password],input[type=search],input[type=number],select{\n    width:100%;background:#000;color:var(--paper);border:1px solid var(--line-strong);border-radius:0;\n    padding:11px 12px;font:inherit;font-size:14px;outline:none}\n  input:focus,select:focus{border-color:var(--volt);box-shadow:0 0 0 1px var(--volt)}\n  input.secret{letter-spacing:.08em;text-transform:uppercase}\n  .row{display:flex;gap:10px;flex-wrap:wrap;align-items:center}\n  .row > *{flex:1 1 auto}\n  button.btn{background:var(--volt);color:#000;border:1px solid var(--volt);border-radius:0;padding:11px 18px;font:inherit;font-weight:700;\n    font-size:12px;letter-spacing:.14em;text-transform:uppercase;cursor:pointer}\n  button.btn:disabled{opacity:.4;cursor:not-allowed}\n  button.ghost{background:transparent;color:var(--paper);border:1px solid var(--line-strong)}\n  button.ghost:hover:not(:disabled){border-color:var(--volt);color:var(--volt)}\n  button.danger{background:transparent;color:var(--danger);border:1px solid rgba(255,90,78,.5)}\n  button.danger.armed{background:var(--danger);color:#000}\n  button.sm{padding:6px 10px;font-size:11px}\n  details{border:1px solid var(--line);padding:10px 12px;margin:0 0 14px}\n  details summary{cursor:pointer;font-size:11px;letter-spacing:.16em;text-transform:uppercase;color:var(--ash)}\n  details[open] summary{margin-bottom:12px}\n  .adv{display:grid;grid-template-columns:repeat(3,1fr);gap:10px}\n  @media (max-width:520px){.adv{grid-template-columns:1fr}}\n  .msg{min-height:1.6em;margin:10px 0 0;font-size:12px}\n  .msg.err{color:var(--danger)}\n  .msg.ok{color:var(--ok)}\n  .hint{font-size:12px;color:var(--ash);margin:-6px 0 14px}\n  .meter{height:4px;background:var(--line);margin:-8px 0 14px}\n  .meter i{display:block;height:100%;width:0;background:var(--danger);transition:width .2s,background .2s}\n\n  /* fingerprint */\n  .fp{border:1px solid var(--line-strong);background:#000;padding:18px}\n  .fp-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:6px;margin:12px 0}\n  .fp-grid div{aspect-ratio:1;display:flex;align-items:center;justify-content:center;font-family:var(--emoji);font-size:clamp(26px,6vw,40px);\n    background:var(--ink-soft);border:1px solid var(--line)}\n  .fp-grid.empty div{color:var(--ash-soft);font-family:var(--mono);font-size:18px}\n  .fp-grid.busy div{animation:pulse 1s infinite alternate}\n  @keyframes pulse{from{opacity:.35}to{opacity:1}}\n  .fp-hex{font-size:11px;color:var(--ash);word-break:break-all;letter-spacing:.06em}\n  .fp-label{font-size:11px;letter-spacing:.16em;text-transform:uppercase;color:var(--ash)}\n  .check{display:flex;gap:10px;align-items:flex-start;margin:12px 0 0;font-size:12px}\n  .check input{margin-top:3px;accent-color:var(--volt)}\n\n  /* entries */\n  .toolbar{display:flex;gap:10px;flex-wrap:wrap;align-items:center;margin:0 0 16px}\n  .toolbar input{flex:1 1 220px;width:auto}\n  .list{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:12px}\n  .entry{background:var(--ink-soft);border:1px solid var(--line);padding:18px;display:flex;flex-direction:column;gap:10px;min-width:0}\n  .entry-head{display:flex;justify-content:space-between;gap:10px;align-items:baseline}\n  .issuer{font-family:var(--display);font-weight:700;font-size:18px;line-height:1.2;overflow-wrap:anywhere}\n  .acct{font-size:12px;color:var(--ash);overflow-wrap:anywhere}\n  .code{font-size:clamp(30px,7vw,38px);font-weight:700;letter-spacing:.08em;cursor:pointer;background:none;border:0;color:var(--paper);\n    padding:0;text-align:left;font-family:var(--mono);line-height:1.1}\n  .code:hover,.code:focus-visible{color:var(--volt);outline:none}\n  .code.warn{color:var(--danger)}\n  .next{font-size:11px;color:var(--ash)}\n  .bar-t{height:3px;background:var(--line);position:relative;overflow:hidden}\n  .bar-t i{position:absolute;inset:0 auto 0 0;background:var(--volt)}\n  .meta{font-size:10px;letter-spacing:.14em;text-transform:uppercase;color:var(--ash-soft)}\n  .entry-actions{display:flex;gap:6px;flex-wrap:wrap}\n  .empty-state{border:1px dashed var(--line-strong);padding:32px;text-align:center;color:var(--ash)}\n  .toast{position:fixed;left:50%;bottom:calc(24px + env(safe-area-inset-bottom,0px));transform:translateX(-50%);background:var(--volt);color:#000;\n    padding:10px 16px;font-size:12px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;z-index:60;max-width:calc(100vw - 32px)}\n  .banner{border:1px solid var(--danger);color:var(--danger);padding:14px 16px;margin:24px 0 0;font-size:13px}\n  .banner.info{border-color:var(--line-strong);color:var(--ash)}\n  footer{padding:32px 0 48px;color:var(--ash);font-size:11px;letter-spacing:.06em}\n  footer p{margin:0 0 8px;max-width:760px}\n  .sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0)}\n  ul.plain{margin:8px 0 0;padding-left:18px;color:var(--ash);font-size:12px}\n  textarea{width:100%;background:#000;color:var(--paper);border:1px solid var(--line-strong);border-radius:0;padding:10px 12px;font:inherit;font-size:12px;resize:vertical;outline:none}\n  textarea:focus{border-color:var(--volt);box-shadow:0 0 0 1px var(--volt)}\n  #exportText{margin-top:12px}\n  .store-pick{border:1px solid var(--line);padding:10px 12px;margin:0 0 14px}\n  .store-pick .check{margin:6px 0 0}\n  .toolbar select{flex:1 1 220px;width:auto}\n  .tag-hw{font-size:10px;letter-spacing:.14em;text-transform:uppercase;background:var(--volt);color:#000;padding:2px 6px}\n  .code.touch{font-size:14px;letter-spacing:.12em;text-transform:uppercase;color:var(--volt)}\n  ul.plain li{margin:0 0 6px}\n</style>\n</head>\n<body>\n\n<header class=\"bar\">\n  <div class=\"wrap bar-in\">\n    <span class=\"mark\" aria-label=\"Enigma Technologies Solutions\">\n      <span class=\"ets-badge\"><span class=\"ets\">ETS</span></span>\n      <span class=\"wordmark\">Enigma <b>/ OTP Vault · Sanctum</b></span>\n    </span>\n    <div class=\"bar-right\" id=\"barSession\" hidden>\n      <span class=\"who\" id=\"whoName\"></span>\n      <span class=\"fp-mini\" id=\"fpMini\" title=\"Vault fingerprint\"></span>\n      <span class=\"who\" id=\"lockIn\" title=\"Auto-lock countdown\"></span>\n      <button class=\"btn ghost sm\" id=\"btnLock\" type=\"button\">Lock</button>\n    </div>\n  </div>\n</header>\n\n<main>\n  <section class=\"hero\">\n    <div class=\"wrap\">\n      <p class=\"eyebrow\">Enigma Technologies Solutions · TOTP for Sanctum · RFC 6238</p>\n      <h1>one<span class=\"dash\"></span>time<br>codes<span class=\"dot\"></span></h1>\n      <p class=\"lede\">Two-factor codes, generated inside Sanctum. <span>Your username and password never leave this window. They derive an AES-256 key that encrypts every secret before it is written to this tool's own storage. The tool asks for no network access. With a YubiKey and smart card access approved, secrets can live on the key instead, and codes are computed by the key.</span></p>\n      <div class=\"banner\" id=\"noCrypto\" hidden>\n        This page needs the Web Crypto API. Sanctum provides it to every tool; in a browser, open the file over https://, http://localhost or file://.\n      </div>\n      <div class=\"banner\" id=\"noStorage\" hidden>\n        Local storage is blocked in this browser (private mode or site data disabled). The vault will work for this window only and will be lost when it closes. Copy a backup to keep it.\n      </div>\n      <div class=\"banner info\" id=\"originNote\">\n        Sanctum gives this tool its own storage, tied to the tool's identity in your library. Adding a new version of the tool keeps the vault. Deleting the tool from Sanctum and adding it again starts with empty storage, so copy a backup first.\n      </div>\n    </div>\n  </section>\n\n  <!-- ============ GATE: unlock / create ============ -->\n  <section class=\"block\" id=\"gate\">\n    <div class=\"wrap\">\n      <p class=\"kicker\">01 · Access</p>\n      <h2 id=\"gateTitle\">Unlock your vault</h2>\n      <p class=\"muted small\" style=\"margin:0 0 24px;max-width:640px\" id=\"gateLede\">Enter the username and password you chose when the vault was created. The fingerprint below is computed from what you type. If it matches the one you remember, the combination is right.</p>\n\n      <div class=\"tabs\" role=\"tablist\">\n        <button type=\"button\" role=\"tab\" id=\"tabUnlock\" aria-selected=\"true\">Unlock</button>\n        <button type=\"button\" role=\"tab\" id=\"tabCreate\" aria-selected=\"false\">Create vault</button>\n      </div>\n\n      <div class=\"grid2\">\n        <form class=\"card\" id=\"gateForm\" autocomplete=\"off\" novalidate>\n          <label class=\"f\"><span>Username</span>\n            <input type=\"text\" id=\"gUser\" autocomplete=\"username\" autocapitalize=\"off\" spellcheck=\"false\" required>\n          </label>\n          <p class=\"hint\">Case-insensitive. Only used locally, together with the password, to derive the key.</p>\n          <label class=\"f\"><span>Password</span>\n            <input type=\"password\" id=\"gPass\" autocomplete=\"current-password\" required>\n          </label>\n          <div class=\"meter\" id=\"meterWrap\" hidden><i id=\"meter\"></i></div>\n          <label class=\"f\" id=\"confirmWrap\" hidden><span>Confirm password</span>\n            <input type=\"password\" id=\"gPass2\" autocomplete=\"new-password\">\n          </label>\n          <div id=\"createNotes\" hidden>\n            <ul class=\"plain\">\n              <li>There is no recovery. Forgetting the password means the stored secrets cannot be decrypted.</li>\n              <li>Use at least 12 characters. A passphrase of four or more random words works well.</li>\n              <li>The same username and password always produce the same fingerprint and key, on any device.</li>\n            </ul>\n            <label class=\"check\"><input type=\"checkbox\" id=\"ackFp\"> <span>I have noted the fingerprint shown for this username and password.</span></label>\n          </div>\n          <div class=\"row\" style=\"margin-top:16px\">\n            <button class=\"btn ghost\" type=\"button\" id=\"btnPreview\">Show fingerprint</button>\n            <button class=\"btn\" type=\"submit\" id=\"btnGate\">Unlock</button>\n          </div>\n          <p class=\"msg\" id=\"gateMsg\" role=\"status\" aria-live=\"polite\"></p>\n        </form>\n\n        <div class=\"fp\" aria-live=\"polite\">\n          <div class=\"fp-label\">Vault fingerprint</div>\n          <div class=\"fp-grid empty\" id=\"fpGrid\"></div>\n          <div class=\"fp-hex\" id=\"fpHex\">Not computed yet.</div>\n          <p class=\"small muted\" style=\"margin:12px 0 0\">A one-way value derived from the same master key as the encryption key. It reveals nothing about the password or the key; it only gives you a picture to recognise. Different input, different picture.</p>\n        </div>\n      </div>\n\n      <div class=\"card\" style=\"margin-top:24px\">\n        <p class=\"kicker\" style=\"margin-bottom:8px\">Backups</p>\n        <p class=\"small muted\" style=\"margin:0 0 12px\">Restore an encrypted backup made with Copy backup. Paste it below or choose a saved .json file, then unlock it with the original username and password.</p>\n        <label class=\"f\"><span>Backup text</span>\n          <textarea id=\"importText\" rows=\"3\" spellcheck=\"false\" placeholder='{\"format\":\"ets-otp-vault/v1-backup\", ...}'></textarea>\n        </label>\n        <div class=\"row\">\n          <input type=\"file\" id=\"importFile\" accept=\"application/json,.json,.txt\" class=\"sr\">\n          <button class=\"btn ghost\" type=\"button\" id=\"btnImportText\" style=\"flex:0 0 auto\">Restore pasted backup</button>\n          <button class=\"btn ghost\" type=\"button\" id=\"btnImport\" style=\"flex:0 0 auto\">Choose file</button>\n          <span class=\"small muted\" id=\"vaultCount\"></span>\n        </div>\n        <p class=\"msg\" id=\"importMsg\" role=\"status\" aria-live=\"polite\"></p>\n      </div>\n    </div>\n  </section>\n\n  <!-- ============ VAULT ============ -->\n  <section class=\"block\" id=\"vault\" hidden>\n    <div class=\"wrap\">\n      <p class=\"kicker\">02 · Add a key</p>\n      <h2>New authenticator key</h2>\n      <p class=\"muted small\" style=\"margin:0 0 20px;max-width:640px\">Paste the setup key a website shows next to its QR code (for example <code>aaaa bbbb cccc dddd eeee ffff gggg hhhh</code>) or a full <code>otpauth://totp/...</code> link. Spaces and case do not matter.</p>\n\n      <form class=\"card\" id=\"addForm\" autocomplete=\"off\" novalidate>\n        <div class=\"grid2\" style=\"gap:0 20px\">\n          <label class=\"f\"><span>Service</span>\n            <input type=\"text\" id=\"aIssuer\" list=\"issuers\" placeholder=\"GitHub\" maxlength=\"80\">\n          </label>\n          <label class=\"f\"><span>Account</span>\n            <input type=\"text\" id=\"aAccount\" placeholder=\"you@example.com\" maxlength=\"120\">\n          </label>\n        </div>\n        <label class=\"f\"><span>Setup key or otpauth:// link</span>\n          <input type=\"text\" id=\"aSecret\" class=\"secret\" placeholder=\"aaaa bbbb cccc dddd eeee ffff gggg hhhh\" spellcheck=\"false\" autocapitalize=\"characters\">\n        </label>\n        <details>\n          <summary>Advanced (most sites use the defaults)</summary>\n          <div class=\"adv\">\n            <label class=\"f\"><span>Digits</span>\n              <select id=\"aDigits\"><option>6</option><option>7</option><option>8</option></select>\n            </label>\n            <label class=\"f\"><span>Period (s)</span>\n              <input type=\"number\" id=\"aPeriod\" value=\"30\" min=\"10\" max=\"300\" step=\"1\">\n            </label>\n            <label class=\"f\"><span>Algorithm</span>\n              <select id=\"aAlgo\"><option value=\"SHA-1\">SHA-1</option><option value=\"SHA-256\">SHA-256</option><option value=\"SHA-512\">SHA-512</option></select>\n            </label>\n          </div>\n        </details>\n        <div class=\"store-pick\" id=\"storePick\" hidden>\n          <span class=\"fp-label\">Store on</span>\n          <label class=\"check\"><input type=\"radio\" name=\"aStore\" value=\"vault\" checked> <span>This vault (encrypted in Sanctum storage)</span></label>\n          <label class=\"check\"><input type=\"radio\" name=\"aStore\" value=\"card\"> <span>YubiKey <b id=\"storeCardName\"></b> (secret written to the key, never readable again)</span></label>\n          <label class=\"check\" id=\"touchWrap\"><input type=\"checkbox\" id=\"aTouch\"> <span>Require a touch on the key for each code</span></label>\n        </div>\n        <div class=\"row\">\n          <span class=\"small muted\" id=\"aPreview\"></span>\n          <button class=\"btn\" type=\"submit\" style=\"flex:0 0 auto\">Save key</button>\n        </div>\n        <p class=\"msg\" id=\"addMsg\" role=\"status\" aria-live=\"polite\"></p>\n        <datalist id=\"issuers\"></datalist>\n      </form>\n    </div>\n  </section>\n\n  <section class=\"block\" id=\"codes\" hidden>\n    <div class=\"wrap\">\n      <p class=\"kicker\">03 · Codes</p>\n      <h2>Your one-time codes</h2>\n      <p class=\"muted small\" style=\"margin:0 0 20px\">Click a code to copy it. Codes turn red in the last five seconds of their window.</p>\n      <div class=\"toolbar\">\n        <input type=\"search\" id=\"filter\" placeholder=\"Filter by service or account\" aria-label=\"Filter\">\n        <span class=\"small muted\" id=\"entryCount\"></span>\n      </div>\n      <div class=\"list\" id=\"list\"></div>\n      <div class=\"empty-state\" id=\"empty\" hidden>No keys yet. Add one above.</div>\n    </div>\n  </section>\n\n  <!-- ============ HARDWARE (Sanctum smart card bridge) ============ -->\n  <section class=\"block\" id=\"hw\" hidden>\n    <div class=\"wrap\">\n      <p class=\"kicker\">04 · YubiKey</p>\n      <h2>Codes computed by your key</h2>\n      <p class=\"muted small\" style=\"margin:0 0 20px;max-width:680px\">The YubiKey OATH applet keeps each secret inside the key and returns only the code. Nothing about these accounts is stored in this tool. This needs Sanctum's smart card access, approved for the <b>OATH (TOTP/HOTP)</b> applet only.</p>\n      <div class=\"banner info\" id=\"hwUnavailable\" hidden>\n        Smart card access is not available to this tool. In Sanctum, open this tool's permissions and approve <b>talk to your smart card: OATH (TOTP/HOTP) [a0000005272101]</b>, then reopen the window. Outside Sanctum this section stays off.\n      </div>\n      <div id=\"hwPanel\" hidden>\n        <div class=\"toolbar\">\n          <select id=\"hwReader\" aria-label=\"Reader\"></select>\n          <button class=\"btn ghost\" type=\"button\" id=\"hwRefreshReaders\" style=\"flex:0 0 auto\">Find keys</button>\n          <button class=\"btn\" type=\"button\" id=\"hwConnect\" style=\"flex:0 0 auto\">Connect</button>\n          <button class=\"btn ghost\" type=\"button\" id=\"hwDisconnect\" style=\"flex:0 0 auto\" hidden>Disconnect</button>\n        </div>\n        <form class=\"card\" id=\"hwPwForm\" autocomplete=\"off\" hidden>\n          <p class=\"small\" style=\"margin:0 0 12px\">This key's OATH applet is protected by a password.</p>\n          <label class=\"f\"><span>OATH password</span><input type=\"password\" id=\"hwPw\"></label>\n          <button class=\"btn\" type=\"submit\">Unlock key</button>\n        </form>\n        <p class=\"msg\" id=\"hwMsg\" role=\"status\" aria-live=\"polite\"></p>\n        <div class=\"list\" id=\"hwList\"></div>\n        <div class=\"empty-state\" id=\"hwEmpty\" hidden>No accounts on this key yet. Add one above and choose YubiKey.</div>\n      </div>\n    </div>\n  </section>\n\n  <section class=\"block\" id=\"settings\" hidden>\n    <div class=\"wrap\">\n      <p class=\"kicker\">05 · Vault</p>\n      <h2>Backup and password</h2>\n      <div class=\"grid2\" style=\"margin-top:20px\">\n        <div class=\"card\">\n          <p class=\"small\" style=\"margin:0 0 12px\">Copy the encrypted vault to the clipboard as JSON, then paste it into a file or a password manager note. It stays encrypted; you need the same username and password to open it. Accounts stored on a YubiKey are not part of the backup.</p>\n          <button class=\"btn ghost\" type=\"button\" id=\"btnExport\">Copy backup</button>\n          <textarea id=\"exportText\" rows=\"4\" readonly hidden aria-label=\"Backup text\"></textarea>\n          <p class=\"msg\" id=\"exportMsg\"></p>\n        </div>\n        <form class=\"card\" id=\"pwForm\" autocomplete=\"off\" novalidate>\n          <p class=\"small\" style=\"margin:0 0 12px\">Change the password. Everything is re-encrypted under a new key and the fingerprint changes.</p>\n          <label class=\"f\"><span>New password</span><input type=\"password\" id=\"pNew\" autocomplete=\"new-password\"></label>\n          <label class=\"f\"><span>Confirm new password</span><input type=\"password\" id=\"pNew2\" autocomplete=\"new-password\"></label>\n          <div class=\"fp-grid empty\" id=\"pwFp\" hidden></div>\n          <button class=\"btn ghost\" type=\"submit\" id=\"btnPw\">Change password</button>\n          <p class=\"msg\" id=\"pwMsg\" role=\"status\" aria-live=\"polite\"></p>\n        </form>\n        <div class=\"card\">\n          <p class=\"small\" style=\"margin:0 0 12px\">Remove this vault from this tool's storage. This cannot be undone without a backup.</p>\n          <button class=\"btn danger\" type=\"button\" id=\"btnWipe\">Delete vault</button>\n        </div>\n      </div>\n    </div>\n  </section>\n</main>\n\n<footer>\n  <div class=\"wrap\">\n    <p>Key derivation: PBKDF2-SHA-256, 600,000 iterations, salt bound to the username, then HKDF-SHA-256 into an AES-256-GCM key and a separate fingerprint value. Codes: TOTP per RFC 6238. Storage: this tool's own localStorage origin in Sanctum, one encrypted record per vault. YubiKey accounts use the OATH applet through <code>window.sanctum.smartcard</code>.</p>\n    <p>Anyone with access to this user account's Sanctum data can copy the encrypted record and try passwords offline, so choose a long one. The vault locks after 5 minutes without activity.</p>\n    <p>© Enigma Technologies Solutions Ltd</p>\n  </div>\n</footer>\n\n<div class=\"toast\" id=\"toast\" hidden></div>\n\n<!-- ======================================================================\n     Core: crypto, fingerprint, TOTP. No DOM access, so it can be tested\n     outside a browser.\n     ====================================================================== -->\n<script id=\"ets-core\">\n\"use strict\";\nconst ETSCore = (() => {\n  const APP = \"ets-otp-vault/v1\";\n  const ITERATIONS = 600000;\n  const te = new TextEncoder();\n  const td = new TextDecoder();\n  const subtle = globalThis.crypto && globalThis.crypto.subtle;\n\n  // 256 emoji, one per byte value. Order is part of the format: never reorder.\n  const EMOJI = Array.from(\"🐶🐱🐭🐰🦊🐻🐨🐯🦁🐷🐸🐔🐧🐦🦆🦅🦉🐺🐗🐴🐝🐛🐌🐞🐜🦂🐢🐍🦖🦕🦑🦐🦞🐡🐠🐟🐳🐋🦈🐅🐆🦍🐘🦛🐪🐫🦒🐃🐂🐄🐖🐏🦙🐐🦌🐩🐈🐓🦚🦜🦩🐇🦝🦡🦦🦥🐀🦔🍏🍐🍊🍌🍉🍇🍈🍒🍑🍍🥥🍅🍆🥑🥬🥒🌽🧄🧅🥔🥐🥯🥖🥨🧀🍳🧈🥞🥓🥩🍗🌭🍔🍕🥪🥙🌮🌯🥗🥫🍝🍲🍛🍣🥟🦪🍤🍚🍘🍥🥮🍢🍧🍨🍦🧁🍰🎂🍭🍬🍿🍩🍪🥜🍯🥛☕🍵🧃🍶🍺🥂🍷🥃🍹🧉🍾🌵🎄🌲🌴🌱🍀🎍🎋🍂🍁🍄🌾💐🌹🥀🌺🌼🌻🌞🌛🌜🌚🌙🌎💫⭐🌟⚡🔥🌈🌊💧☔⚽🏀⚾🥎🎾🏉🥏🎱🏸🏒🏏🥅⛳🏹🎣🥊🎽🛹🛼🥌🎿🎮🎲🧩🎨🧵🧶🎹🥁🎺🎸🪕🚗🚕🚙🚓🚑🚒🚚🚜🚲🛴🚨🛸🚁⛵🛶⚓🚢🏰🗼⛪🕌🔔🔒💡🔦📱💻💎🔮🎁🎀🧲🧪⏰⌛📚📎🔭🧸🪀🪄🎩👓🧢👠🧤\");\n\n  const toHex = (b) => Array.from(b, (x) => x.toString(16).padStart(2, \"0\")).join(\"\");\n  const toB64 = (b) => { let s = \"\"; for (const x of b) s += String.fromCharCode(x); return btoa(s); };\n  const fromB64 = (s) => Uint8Array.from(atob(s), (c) => c.charCodeAt(0));\n  const normUser = (u) => String(u).normalize(\"NFKC\").trim().toLowerCase();\n  const normPass = (p) => String(p).normalize(\"NFKC\");\n  const sha256 = async (bytes) => new Uint8Array(await subtle.digest(\"SHA-256\", bytes));\n\n  // Storage id for a username. Keeps usernames out of localStorage keys.\n  async function vaultId(username) {\n    return toHex(await sha256(te.encode(APP + \"|id|\" + normUser(username)))).slice(0, 32);\n  }\n\n  // username + password -> { key: AES-GCM CryptoKey (non-extractable), fingerprint: 32 bytes }\n  async function deriveVaultKeys(username, password, iterations = ITERATIONS) {\n    const salt = await sha256(te.encode(APP + \"|salt|\" + normUser(username)));\n    const pw = await subtle.importKey(\"raw\", te.encode(normPass(password)), \"PBKDF2\", false, [\"deriveBits\"]);\n    const master = await subtle.deriveBits({ name: \"PBKDF2\", hash: \"SHA-256\", salt, iterations }, pw, 256);\n    const ikm = await subtle.importKey(\"raw\", master, \"HKDF\", false, [\"deriveKey\", \"deriveBits\"]);\n    const zero = new Uint8Array(32);\n    const key = await subtle.deriveKey(\n      { name: \"HKDF\", hash: \"SHA-256\", salt: zero, info: te.encode(APP + \"|aes-gcm\") },\n      ikm, { name: \"AES-GCM\", length: 256 }, false, [\"encrypt\", \"decrypt\"]);\n    const fingerprint = new Uint8Array(await subtle.deriveBits(\n      { name: \"HKDF\", hash: \"SHA-256\", salt: zero, info: te.encode(APP + \"|fingerprint\") }, ikm, 256));\n    return { key, fingerprint };\n  }\n\n  const fingerprintEmoji = (fp, n = 8) => Array.from(fp.slice(0, n), (b) => EMOJI[b]);\n  const fingerprintHex = (fp, n = 8) => toHex(fp.slice(0, n)).match(/.{4}/g).join(\" \");\n\n  async function seal(key, id, obj) {\n    const iv = crypto.getRandomValues(new Uint8Array(12));\n    const ct = new Uint8Array(await subtle.encrypt(\n      { name: \"AES-GCM\", iv, additionalData: te.encode(APP + \"|\" + id) }, key, te.encode(JSON.stringify(obj))));\n    return { iv: toB64(iv), ct: toB64(ct) };\n  }\n\n  // Throws if the key is wrong or the record was altered.\n  async function open(key, id, box) {\n    const pt = await subtle.decrypt(\n      { name: \"AES-GCM\", iv: fromB64(box.iv), additionalData: te.encode(APP + \"|\" + id) }, key, fromB64(box.ct));\n    return JSON.parse(td.decode(pt));\n  }\n\n  // ---------- TOTP ----------\n  const B32 = \"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567\";\n\n  function cleanBase32(s) {\n    return String(s).toUpperCase().replace(/[\\s\\-_.]/g, \"\").replace(/=+$/, \"\");\n  }\n\n  function base32Decode(input) {\n    const s = cleanBase32(input);\n    if (!s) throw new Error(\"The key is empty.\");\n    const bad = s.match(/[^A-Z2-7]/);\n    if (bad) throw new Error('\"' + bad[0] + '\" is not a valid key character. Keys use A to Z and 2 to 7.');\n    let bits = 0, value = 0;\n    const out = [];\n    for (const c of s) {\n      value = (value << 5) | B32.indexOf(c);\n      bits += 5;\n      if (bits >= 8) { out.push((value >>> (bits - 8)) & 0xff); bits -= 8; }\n    }\n    if (out.length < 10) throw new Error(\"The key is too short (at least 16 characters expected).\");\n    return new Uint8Array(out);\n  }\n\n  function base32Encode(bytes) {\n    let bits = 0, value = 0, out = \"\";\n    for (const b of bytes) {\n      value = (value << 8) | b; bits += 8;\n      while (bits >= 5) { out += B32[(value >>> (bits - 5)) & 31]; bits -= 5; }\n    }\n    if (bits > 0) out += B32[(value << (5 - bits)) & 31];\n    return out;\n  }\n\n  const ALGOS = { \"SHA1\": \"SHA-1\", \"SHA-1\": \"SHA-1\", \"SHA256\": \"SHA-256\", \"SHA-256\": \"SHA-256\", \"SHA512\": \"SHA-512\", \"SHA-512\": \"SHA-512\" };\n\n  async function hotp(secret, counter, digits = 6, algorithm = \"SHA-1\") {\n    const key = await subtle.importKey(\"raw\", secret, { name: \"HMAC\", hash: algorithm }, false, [\"sign\"]);\n    const msg = new Uint8Array(8);\n    let c = counter;\n    for (let i = 7; i >= 0; i--) { msg[i] = c % 256; c = Math.floor(c / 256); }\n    const h = new Uint8Array(await subtle.sign(\"HMAC\", key, msg));\n    const o = h[h.length - 1] & 0x0f;\n    const bin = ((h[o] & 0x7f) << 24) | (h[o + 1] << 16) | (h[o + 2] << 8) | h[o + 3];\n    return String(bin % 10 ** digits).padStart(digits, \"0\");\n  }\n\n  async function totp(secret, { digits = 6, period = 30, algorithm = \"SHA-1\" } = {}, nowMs = Date.now()) {\n    const counter = Math.floor(nowMs / 1000 / period);\n    return hotp(secret, counter, digits, algorithm);\n  }\n\n  // Accepts a bare base32 key or an otpauth://totp/ URI. Returns normalised entry fields.\n  function parseKeyInput(text) {\n    const t = String(text).trim();\n    if (/^otpauth:\\/\\//i.test(t)) {\n      const u = new URL(t);\n      if (u.host.toLowerCase() !== \"totp\") throw new Error(\"Only time-based (totp) links are supported.\");\n      const label = decodeURIComponent(u.pathname.replace(/^\\//, \"\"));\n      const p = u.searchParams;\n      let issuer = p.get(\"issuer\") || \"\";\n      let account = label;\n      const i = label.indexOf(\":\");\n      if (i >= 0) { if (!issuer) issuer = label.slice(0, i).trim(); account = label.slice(i + 1).trim(); }\n      const algorithm = ALGOS[(p.get(\"algorithm\") || \"SHA1\").toUpperCase()];\n      if (!algorithm) throw new Error(\"Unsupported algorithm in link.\");\n      const digits = parseInt(p.get(\"digits\") || \"6\", 10);\n      const period = parseInt(p.get(\"period\") || \"30\", 10);\n      const secret = cleanBase32(p.get(\"secret\") || \"\");\n      base32Decode(secret);\n      return { issuer, account, secret, digits, period, algorithm, fromUri: true };\n    }\n    const secret = cleanBase32(t);\n    base32Decode(secret);\n    return { secret, fromUri: false };\n  }\n\n  function validateParams({ digits, period, algorithm }) {\n    if (![6, 7, 8].includes(digits)) throw new Error(\"Digits must be 6, 7 or 8.\");\n    if (!(Number.isInteger(period) && period >= 10 && period <= 300)) throw new Error(\"Period must be 10 to 300 seconds.\");\n    if (![\"SHA-1\", \"SHA-256\", \"SHA-512\"].includes(algorithm)) throw new Error(\"Unsupported algorithm.\");\n  }\n\n  // ---------- YubiKey OATH applet ----------\n  // Wire format follows Yubico's OATH protocol as implemented in yubikit\n  // (yubikit/oath.py). Every function here builds or parses bytes; talking to\n  // the card is the app's job, through window.sanctum.smartcard.\n  const OATH_AID = \"a0000005272101\";\n  const OT = { NAME: 0x71, NAME_LIST: 0x72, KEY: 0x73, CHALLENGE: 0x74, RESPONSE: 0x75,\n    TRUNCATED: 0x76, HOTP: 0x77, PROPERTY: 0x78, VERSION: 0x79, TOUCH: 0x7c };\n  const OATH_ALGO = { \"SHA-1\": 0x01, \"SHA-256\": 0x02, \"SHA-512\": 0x03 };\n  const OATH_TOTP = 0x20;\n  const fromHex = (h) => new Uint8Array((String(h).match(/../g) || []).map((x) => parseInt(x, 16)));\n\n  function tlv(tag, value) {\n    const v = Array.from(value);\n    const n = v.length;\n    const len = n < 0x80 ? [n] : n < 0x100 ? [0x81, n] : [0x82, n >> 8, n & 0xff];\n    return [tag, ...len, ...v];\n  }\n\n  function parseTlv(bytes) {\n    const out = [];\n    let i = 0;\n    while (i < bytes.length) {\n      const tag = bytes[i++];\n      let n = bytes[i++];\n      if (n === 0x81) n = bytes[i++];\n      else if (n === 0x82) { n = (bytes[i] << 8) | bytes[i + 1]; i += 2; }\n      if (n === undefined || i + n > bytes.length) throw new Error(\"Malformed response from the key.\");\n      out.push({ tag, value: bytes.slice(i, i + n) });\n      i += n;\n    }\n    return out;\n  }\n\n  function oathApdu(ins, p1, p2, data) {\n    const d = data || [];\n    if (d.length > 255) throw new Error(\"Command is too long for the key.\");\n    return toHex([0x00, ins, p1, p2, ...(d.length ? [d.length, ...d] : [])]);\n  }\n\n  function timeStep(nowMs, period) {\n    let c = Math.floor(nowMs / 1000 / period);\n    const b = new Uint8Array(8);\n    for (let i = 7; i >= 0; i--) { b[i] = c % 256; c = Math.floor(c / 256); }\n    return b;\n  }\n\n  // \"30/\" prefix is omitted for the default period, like yubikit's _format_cred_id.\n  function oathCredId({ issuer = \"\", account = \"\", period = 30 }) {\n    const name = (account || issuer).trim();\n    const iss = account ? issuer.trim() : \"\";\n    if (!name) throw new Error(\"Give the account a name.\");\n    const id = te.encode((period !== 30 ? period + \"/\" : \"\") + (iss ? iss + \":\" : \"\") + name);\n    if (id.length > 64) throw new Error(\"Service and account together are too long for the key (64 bytes).\");\n    return id;\n  }\n\n  function parseCredId(bytes, totp = true) {\n    const s = td.decode(bytes);\n    if (totp) {\n      const m = /^((\\d+)\\/)?(([^:]+):)?(.+)$/.exec(s);\n      if (m) return { issuer: m[4] || \"\", account: m[5], period: m[2] ? parseInt(m[2], 10) : 30 };\n      return { issuer: \"\", account: s, period: 30 };\n    }\n    const i = s.indexOf(\":\");\n    return i > 0 ? { issuer: s.slice(0, i), account: s.slice(i + 1), period: 0 } : { issuer: \"\", account: s, period: 0 };\n  }\n\n  function truncatedCode(v) {\n    const digits = v[0];\n    const bin = ((v[1] << 24) | (v[2] << 16) | (v[3] << 8) | v[4]) & 0x7fffffff;\n    return String(bin % 10 ** digits).padStart(digits, \"0\");\n  }\n\n  function parseSelect(bytes) {\n    const t = Object.fromEntries(parseTlv(bytes).map((x) => [x.tag, x.value]));\n    const v = t[OT.VERSION];\n    return { version: v ? Array.from(v).join(\".\") : \"\", salt: t[OT.NAME] || null, challenge: t[OT.CHALLENGE] || null };\n  }\n\n  async function oathPut(entry, secret, touch = false) {\n    const algo = OATH_ALGO[entry.algorithm];\n    if (!algo) throw new Error(\"Unsupported algorithm for the key.\");\n    let key = secret;\n    const block = entry.algorithm === \"SHA-512\" ? 128 : 64;\n    if (key.length > block) key = new Uint8Array(await subtle.digest(entry.algorithm, key));\n    const padded = new Uint8Array(Math.max(14, key.length));\n    padded.set(key);\n    const data = [...tlv(OT.NAME, oathCredId(entry)), ...tlv(OT.KEY, [OATH_TOTP | algo, entry.digits, ...padded])];\n    if (touch) data.push(OT.PROPERTY, 0x02);\n    return oathApdu(0x01, 0, 0, data);\n  }\n\n  const oathDelete = (id) => oathApdu(0x02, 0, 0, tlv(OT.NAME, id));\n  const oathList = () => oathApdu(0xa1, 0, 0);\n  const oathCalculateAll = (nowMs) => oathApdu(0xa4, 0, 1, tlv(OT.CHALLENGE, timeStep(nowMs, 30)));\n  const oathCalculate = (id, nowMs, period) =>\n    oathApdu(0xa2, 0, 1, [...tlv(OT.NAME, id), ...tlv(OT.CHALLENGE, timeStep(nowMs, period))]);\n\n  // CALCULATE ALL answers NAME followed by one of TRUNCATED, HOTP or TOUCH per credential.\n  function parseCalculateAll(bytes) {\n    const items = parseTlv(bytes);\n    const out = [];\n    for (let i = 0; i + 1 < items.length; i += 2) {\n      if (items[i].tag !== OT.NAME) throw new Error(\"Unexpected response layout from the key.\");\n      const id = items[i].value, r = items[i + 1];\n      const hotp = r.tag === OT.HOTP;\n      const c = { id, ...parseCredId(id, !hotp), type: hotp ? \"hotp\" : \"totp\", touch: r.tag === OT.TOUCH, code: null };\n      if (r.tag === OT.TRUNCATED && r.value.length === 5) c.code = truncatedCode(r.value);\n      out.push(c);\n    }\n    return out;\n  }\n\n  // LIST answers one NAME_LIST per credential: [type | algorithm, ...name]. It\n  // does not say which credentials need a touch; CALCULATE ALL does.\n  function parseList(bytes) {\n    return parseTlv(bytes).filter((x) => x.tag === OT.NAME_LIST).map((x) => {\n      const id = x.value.slice(1), hotp = (x.value[0] & 0xf0) === 0x10;\n      return { id, ...parseCredId(id, !hotp), type: hotp ? \"hotp\" : \"totp\", touch: null, code: null };\n    });\n  }\n\n  function parseCalculate(bytes) {\n    const t = parseTlv(bytes).find((x) => x.tag === OT.TRUNCATED);\n    if (!t || t.value.length !== 5) throw new Error(\"The key did not return a code.\");\n    return truncatedCode(t.value);\n  }\n\n  // Password-protected applet: prove knowledge of the password, and check the key's answer.\n  async function oathValidate(password, salt, challenge) {\n    const pw = await subtle.importKey(\"raw\", te.encode(password), \"PBKDF2\", false, [\"deriveBits\"]);\n    const raw = await subtle.deriveBits({ name: \"PBKDF2\", hash: \"SHA-1\", salt, iterations: 1000 }, pw, 128);\n    const k = await subtle.importKey(\"raw\", raw, { name: \"HMAC\", hash: \"SHA-1\" }, false, [\"sign\"]);\n    const response = new Uint8Array(await subtle.sign(\"HMAC\", k, challenge));\n    const mine = crypto.getRandomValues(new Uint8Array(8));\n    const expected = new Uint8Array(await subtle.sign(\"HMAC\", k, mine));\n    return { apdu: oathApdu(0xa3, 0, 0, [...tlv(OT.RESPONSE, response), ...tlv(OT.CHALLENGE, mine)]), expected };\n  }\n\n  function oathCheckValidate(bytes, expected) {\n    const t = parseTlv(bytes).find((x) => x.tag === OT.RESPONSE);\n    if (!t || t.value.length !== expected.length) return false;\n    let d = 0;\n    for (let i = 0; i < expected.length; i++) d |= t.value[i] ^ expected[i];\n    return d === 0;\n  }\n\n  const oath = { AID: OATH_AID, tlv, parseTlv, timeStep, credId: oathCredId, parseCredId, parseSelect, put: oathPut,\n    del: oathDelete, list: oathList, parseList, calculateAll: oathCalculateAll, calculate: oathCalculate, parseCalculateAll, parseCalculate,\n    validate: oathValidate, checkValidate: oathCheckValidate };\n\n  return { APP, ITERATIONS, EMOJI, vaultId, deriveVaultKeys, fingerprintEmoji, fingerprintHex, seal, open,\n    base32Decode, base32Encode, cleanBase32, hotp, totp, parseKeyInput, validateParams, toHex, fromHex, oath };\n})();\nif (typeof module !== \"undefined\") module.exports = ETSCore;\n</script>\n\n<!-- ======================================================================\n     App: storage + UI\n     ====================================================================== -->\n<script>\n\"use strict\";\n(() => {\n  const C = ETSCore;\n  const $ = (id) => document.getElementById(id);\n  const PREFIX = \"ets-otp-vault:v1:\";\n  const AUTOLOCK_MS = 5 * 60 * 1000;\n  const POPULAR = [\"Google\", \"Microsoft\", \"GitHub\", \"GitLab\", \"Amazon Web Services\", \"Amazon\", \"Apple\", \"Meta\", \"Facebook\", \"Instagram\",\n    \"X\", \"LinkedIn\", \"Discord\", \"Slack\", \"Dropbox\", \"Cloudflare\", \"Coinbase\", \"Binance\", \"Kraken\", \"PayPal\", \"Stripe\", \"Proton\",\n    \"Bitwarden\", \"1Password\", \"DigitalOcean\", \"Heroku\", \"Vercel\", \"Netlify\", \"Replit\", \"npm\", \"Docker Hub\", \"Atlassian\", \"Reddit\",\n    \"Twitch\", \"Steam\", \"Epic Games\", \"Nintendo\", \"Okta\", \"Zoom\", \"Salesforce\", \"Shopify\", \"Namecheap\", \"GoDaddy\", \"Hetzner\", \"OVHcloud\"];\n\n  // ---------- storage (falls back to memory if localStorage is blocked) ----------\n  const mem = new Map();\n  let storageOk = true;\n  try { const k = PREFIX + \"probe\"; localStorage.setItem(k, \"1\"); localStorage.removeItem(k); } catch { storageOk = false; }\n  const store = {\n    get(id) { try { const v = storageOk ? localStorage.getItem(PREFIX + id) : mem.get(id); return v ? JSON.parse(v) : null; } catch { return null; } },\n    set(id, rec) { const v = JSON.stringify(rec); if (storageOk) localStorage.setItem(PREFIX + id, v); else mem.set(id, v); },\n    del(id) { if (storageOk) localStorage.removeItem(PREFIX + id); else mem.delete(id); },\n    count() {\n      if (!storageOk) return mem.size;\n      let n = 0;\n      for (let i = 0; i < localStorage.length; i++) { const k = localStorage.key(i); if (k && k.startsWith(PREFIX) && k !== PREFIX + \"probe\") n++; }\n      return n;\n    },\n  };\n\n  // ---------- session state ----------\n  let session = null; // { id, user, key, fingerprint, data: { entries: [], created } }\n  const secretCache = new Map(); // entry id -> Uint8Array\n  let mode = \"unlock\";\n  let lastActivity = Date.now();\n  let lastCounterSig = \"\";\n\n  if (!C.EMOJI || C.EMOJI.length !== 256) throw new Error(\"emoji table corrupted\");\n  if (!globalThis.crypto || !crypto.subtle) {\n    $(\"noCrypto\").hidden = false;\n    $(\"gate\").hidden = true;\n    return;\n  }\n  if (!storageOk) $(\"noStorage\").hidden = false;\n\n  // ---------- helpers ----------\n  function toast(text) {\n    const t = $(\"toast\");\n    t.textContent = text; t.hidden = false;\n    clearTimeout(toast._t); toast._t = setTimeout(() => (t.hidden = true), 1600);\n  }\n  function msg(el, text, kind) { el.textContent = text || \"\"; el.className = \"msg\" + (kind ? \" \" + kind : \"\"); }\n  function setBusy(btns, busy) { btns.forEach((b) => (b.disabled = busy)); }\n  function renderFp(grid, hexEl, fp, state) {\n    grid.innerHTML = \"\";\n    grid.classList.toggle(\"empty\", !fp);\n    grid.classList.toggle(\"busy\", state === \"busy\");\n    const cells = fp ? C.fingerprintEmoji(fp) : Array(8).fill(state === \"busy\" ? \"·\" : \"?\");\n    for (const e of cells) { const d = document.createElement(\"div\"); d.textContent = e; grid.appendChild(d); }\n    if (hexEl) hexEl.textContent = fp ? C.fingerprintHex(fp) : state === \"busy\" ? \"Deriving key (about one second)...\" : \"Not computed yet.\";\n  }\n  function pwScore(p) {\n    let s = 0;\n    if (p.length >= 8) s++; if (p.length >= 12) s++; if (p.length >= 16) s++; if (p.length >= 24) s++;\n    if (/[a-z]/.test(p) && /[A-Z]/.test(p)) s++; if (/\\d/.test(p)) s++; if (/[^A-Za-z0-9]/.test(p)) s++;\n    return Math.min(s, 6);\n  }\n  const uid = () => C.toHex(crypto.getRandomValues(new Uint8Array(8)));\n  const fmtCode = (c) => (c.length === 6 ? c.slice(0, 3) + \" \" + c.slice(3) : c.length === 8 ? c.slice(0, 4) + \" \" + c.slice(4) : c);\n\n  // ---------- gate ----------\n  const gUser = $(\"gUser\"), gPass = $(\"gPass\"), gPass2 = $(\"gPass2\");\n  const btnGate = $(\"btnGate\"), btnPreview = $(\"btnPreview\"), gateMsg = $(\"gateMsg\");\n  let preview = null; // { user, pass, fp } for the values last previewed\n\n  function setMode(m) {\n    mode = m;\n    $(\"tabUnlock\").setAttribute(\"aria-selected\", m === \"unlock\");\n    $(\"tabCreate\").setAttribute(\"aria-selected\", m === \"create\");\n    $(\"confirmWrap\").hidden = m !== \"create\";\n    $(\"createNotes\").hidden = m !== \"create\";\n    $(\"meterWrap\").hidden = m !== \"create\";\n    gPass.autocomplete = m === \"create\" ? \"new-password\" : \"current-password\";\n    btnGate.textContent = m === \"create\" ? \"Create vault\" : \"Unlock\";\n    $(\"gateTitle\").textContent = m === \"create\" ? \"Create a vault\" : \"Unlock your vault\";\n    $(\"gateLede\").textContent = m === \"create\"\n      ? \"Choose a username and password. They never leave this page. Press Show fingerprint and note the eight emoji: you will see the same ones every time you enter this combination.\"\n      : \"Enter the username and password you chose when the vault was created. The fingerprint below is computed from what you type. If it matches the one you remember, the combination is right.\";\n    msg(gateMsg, \"\");\n    updateGateState();\n  }\n  $(\"tabUnlock\").onclick = () => setMode(\"unlock\");\n  $(\"tabCreate\").onclick = () => setMode(\"create\");\n\n  function previewValid() { return preview && preview.user === gUser.value && preview.pass === gPass.value; }\n  function updateGateState() {\n    if (!previewValid()) { renderFp($(\"fpGrid\"), $(\"fpHex\"), null); $(\"ackFp\").checked = false; }\n    if (mode === \"create\") {\n      const sc = pwScore(gPass.value);\n      const m = $(\"meter\");\n      m.style.width = (sc / 6) * 100 + \"%\";\n      m.style.background = sc <= 2 ? \"var(--danger)\" : sc <= 4 ? \"var(--volt)\" : \"var(--ok)\";\n      btnGate.disabled = !(previewValid() && $(\"ackFp\").checked);\n    } else btnGate.disabled = false;\n  }\n  [gUser, gPass, gPass2].forEach((el) => el.addEventListener(\"input\", updateGateState));\n  $(\"ackFp\").addEventListener(\"change\", updateGateState);\n\n  async function derive(user, pass) {\n    renderFp($(\"fpGrid\"), $(\"fpHex\"), null, \"busy\");\n    await new Promise((r) => setTimeout(r, 30)); // let the busy state paint\n    const k = await C.deriveVaultKeys(user, pass);\n    preview = { user, pass, keys: k };\n    renderFp($(\"fpGrid\"), $(\"fpHex\"), k.fingerprint);\n    return k;\n  }\n\n  function checkGateInputs() {\n    if (!gUser.value.trim()) return \"Enter a username.\";\n    if (!gPass.value) return \"Enter a password.\";\n    if (mode === \"create\") {\n      if (gPass.value.length < 10) return \"Use at least 10 characters for the password.\";\n      if (gPass.value !== gPass2.value) return \"The two passwords do not match.\";\n    }\n    return \"\";\n  }\n\n  btnPreview.onclick = async () => {\n    const err = checkGateInputs();\n    if (err) return msg(gateMsg, err, \"err\");\n    msg(gateMsg, \"\");\n    setBusy([btnPreview, btnGate], true);\n    try {\n      await derive(gUser.value, gPass.value);\n      if (mode === \"create\") {\n        const exists = store.get(await C.vaultId(gUser.value));\n        msg(gateMsg, exists ? \"A vault for this username already exists in this browser. Use Unlock instead.\" : \"Note these eight emoji, then confirm below.\", exists ? \"err\" : \"ok\");\n      }\n    } catch (e) { msg(gateMsg, \"Key derivation failed: \" + e.message, \"err\"); }\n    setBusy([btnPreview, btnGate], false);\n    updateGateState();\n  };\n\n  $(\"gateForm\").addEventListener(\"submit\", async (ev) => {\n    ev.preventDefault();\n    const err = checkGateInputs();\n    if (err) return msg(gateMsg, err, \"err\");\n    setBusy([btnPreview, btnGate], true);\n    msg(gateMsg, \"Deriving key...\");\n    try {\n      const user = gUser.value, pass = gPass.value;\n      const id = await C.vaultId(user);\n      const rec = store.get(id);\n      const k = previewValid() ? preview.keys : await derive(user, pass);\n      if (mode === \"create\") {\n        if (rec) throw new Error(\"A vault for this username already exists in this browser. Use Unlock instead.\");\n        const keys = k;\n        const data = { entries: [], created: new Date().toISOString() };\n        store.set(id, { format: C.APP, kdf: { name: \"PBKDF2-SHA-256\", iterations: C.ITERATIONS }, box: await C.seal(keys.key, id, data) });\n        startSession(id, user, keys, data);\n      } else {\n        if (!rec) throw new Error(\"No vault for this username in this browser. Check the fingerprint above, create a vault, or import a backup.\");\n        let data;\n        try { data = await C.open(k.key, id, rec.box); }\n        catch { throw new Error(\"Wrong password for this username. Compare the fingerprint above with the one you remember.\"); }\n        startSession(id, user, k, data);\n      }\n    } catch (e) {\n      msg(gateMsg, e.message, \"err\");\n    } finally {\n      setBusy([btnPreview, btnGate], false);\n      if (!session) updateGateState();\n    }\n  });\n\n  // ---------- import (gate) ----------\n  function importBackup(text) {\n    try {\n      let j;\n      try { j = JSON.parse(text); } catch { throw new Error(\"This is not an Enigma OTP Vault backup.\"); }\n      if (!j || j.format !== C.APP + \"-backup\" || !/^[0-9a-f]{32}$/.test(j.id || \"\") || !j.record || !j.record.box || !j.record.box.iv || !j.record.box.ct)\n        throw new Error(\"This is not an Enigma OTP Vault backup.\");\n      const existing = store.get(j.id);\n      if (existing && JSON.stringify(existing) !== JSON.stringify(j.record) && !importArmed) {\n        importArmed = true;\n        setTimeout(() => (importArmed = false), 8000);\n        return msg($(\"importMsg\"), \"A different vault for the same username already exists here. Restore again within 8 seconds to replace it.\", \"err\");\n      }\n      importArmed = false;\n      store.set(j.id, j.record);\n      $(\"importText\").value = \"\";\n      msg($(\"importMsg\"), \"Backup restored. Unlock it with its username and password.\", \"ok\");\n      refreshCount();\n      setMode(\"unlock\");\n    } catch (e) { msg($(\"importMsg\"), e.message, \"err\"); }\n  }\n  $(\"btnImport\").onclick = () => $(\"importFile\").click();\n  $(\"importFile\").addEventListener(\"change\", async (ev) => {\n    const f = ev.target.files[0];\n    ev.target.value = \"\";\n    if (f) importBackup(await f.text());\n  });\n  $(\"btnImportText\").onclick = () => importBackup($(\"importText\").value.trim());\n  let importArmed = false;\n  function refreshCount() {\n    const n = store.count();\n    $(\"vaultCount\").textContent = n ? n + (n === 1 ? \" vault\" : \" vaults\") + \" stored in this tool\" : \"No vaults stored in this tool yet\";\n    if (!n && mode === \"unlock\" && !session) setMode(\"create\");\n  }\n\n  // ---------- session ----------\n  function startSession(id, user, keys, data) {\n    session = { id, user: user.trim(), key: keys.key, fingerprint: keys.fingerprint, data };\n    if (!Array.isArray(session.data.entries)) session.data.entries = [];\n    gPass.value = gPass2.value = \"\"; preview = null;\n    msg(gateMsg, \"\");\n    $(\"gate\").hidden = true;\n    [\"vault\", \"codes\", \"settings\", \"barSession\"].forEach((x) => ($(x).hidden = false));\n    $(\"whoName\").textContent = session.user;\n    $(\"fpMini\").textContent = C.fingerprintEmoji(keys.fingerprint).join(\"\");\n    lastActivity = Date.now();\n    renderList();\n    tick();\n    HW.onSessionStart();\n    $(\"aSecret\").focus();\n  }\n\n  function lock(reason) {\n    session = null;\n    HW.disconnect();\n    secretCache.clear();\n    $(\"list\").innerHTML = \"\";\n    [\"vault\", \"codes\", \"settings\", \"barSession\"].forEach((x) => ($(x).hidden = true));\n    $(\"gate\").hidden = false;\n    $(\"addForm\").reset(); $(\"pwForm\").reset();\n    $(\"pwFp\").hidden = true;\n    setMode(\"unlock\");\n    refreshCount();\n    if (reason) msg(gateMsg, reason, \"ok\");\n    gPass.focus();\n  }\n  $(\"btnLock\").onclick = () => lock(\"Vault locked.\");\n\n  async function persist() {\n    const rec = store.get(session.id) || { format: C.APP, kdf: { name: \"PBKDF2-SHA-256\", iterations: C.ITERATIONS } };\n    rec.box = await C.seal(session.key, session.id, session.data);\n    store.set(session.id, rec);\n  }\n\n  [\"pointerdown\", \"keydown\", \"scroll\", \"touchstart\"].forEach((e) => addEventListener(e, () => (lastActivity = Date.now()), { passive: true }));\n\n  // ---------- add key ----------\n  $(\"issuers\").innerHTML = POPULAR.map((p) => \"<option value=\\\"\" + p + \"\\\">\").join(\"\");\n  const aSecret = $(\"aSecret\");\n  aSecret.addEventListener(\"input\", () => {\n    const v = aSecret.value.trim();\n    const pv = $(\"aPreview\");\n    if (!v) return (pv.textContent = \"\");\n    try {\n      const p = C.parseKeyInput(v);\n      if (p.fromUri) {\n        if (p.issuer) $(\"aIssuer\").value = p.issuer;\n        if (p.account) $(\"aAccount\").value = p.account;\n        $(\"aDigits\").value = String(p.digits); $(\"aPeriod\").value = String(p.period); $(\"aAlgo\").value = p.algorithm;\n        aSecret.value = p.secret.match(/.{1,4}/g).join(\" \").toLowerCase();\n        pv.textContent = \"Link read. Fields filled in.\";\n      } else pv.textContent = \"Key looks valid (\" + p.secret.length + \" characters).\";\n      pv.style.color = \"var(--ok)\";\n    } catch (e) { pv.textContent = e.message; pv.style.color = \"var(--danger)\"; }\n  });\n\n  $(\"addForm\").addEventListener(\"submit\", async (ev) => {\n    ev.preventDefault();\n    const m = $(\"addMsg\");\n    try {\n      const p = C.parseKeyInput(aSecret.value);\n      const entry = {\n        id: uid(),\n        issuer: $(\"aIssuer\").value.trim().slice(0, 80),\n        account: $(\"aAccount\").value.trim().slice(0, 120),\n        secret: p.secret,\n        digits: parseInt($(\"aDigits\").value, 10),\n        period: parseInt($(\"aPeriod\").value, 10),\n        algorithm: $(\"aAlgo\").value,\n        added: new Date().toISOString(),\n      };\n      if (!entry.issuer && !entry.account) throw new Error(\"Give the key a service name or account so you can tell it apart.\");\n      C.validateParams(entry);\n      if (HW.storeTarget() === \"card\") {\n        await HW.put(entry, C.base32Decode(entry.secret), $(\"aTouch\").checked);\n        $(\"addForm\").reset(); $(\"aPreview\").textContent = \"\";\n        HW.syncStorePick();\n        msg(m, \"Written to the YubiKey. The secret is not kept anywhere in this tool.\", \"ok\");\n        setTimeout(() => msg(m, \"\"), 3500);\n        return;\n      }\n      if (session.data.entries.some((e) => e.secret === entry.secret)) throw new Error(\"This key is already in the vault.\");\n      await C.totp(C.base32Decode(entry.secret), entry); // proves the parameters work before saving\n      session.data.entries.push(entry);\n      await persist();\n      $(\"addForm\").reset(); $(\"aPreview\").textContent = \"\";\n      msg(m, \"Saved and encrypted.\", \"ok\");\n      setTimeout(() => msg(m, \"\"), 2500);\n      lastCounterSig = \"\";\n      renderList(); tick();\n    } catch (e) { msg(m, e.message, \"err\"); }\n  });\n\n  // ---------- list ----------\n  function secretOf(e) {\n    let s = secretCache.get(e.id);\n    if (!s) { s = C.base32Decode(e.secret); secretCache.set(e.id, s); }\n    return s;\n  }\n\n  function renderList() {\n    const list = $(\"list\");\n    list.innerHTML = \"\";\n    const q = $(\"filter\").value.trim().toLowerCase();\n    const all = session.data.entries;\n    const shown = all.filter((e) => !q || (e.issuer + \" \" + e.account).toLowerCase().includes(q));\n    $(\"empty\").hidden = all.length > 0;\n    $(\"entryCount\").textContent = all.length ? shown.length + \" of \" + all.length : \"\";\n    for (const e of shown) {\n      const el = document.createElement(\"article\");\n      el.className = \"entry\"; el.dataset.id = e.id;\n      el.innerHTML =\n        '<div class=\"entry-head\"><div><div class=\"issuer\"></div><div class=\"acct\"></div></div><span class=\"meta\"></span></div>' +\n        '<button class=\"code\" type=\"button\" title=\"Copy code\">------</button>' +\n        '<div class=\"bar-t\"><i></i></div>' +\n        '<div class=\"row\" style=\"justify-content:space-between\"><span class=\"next\"></span>' +\n        '<span class=\"entry-actions\"><button class=\"btn ghost sm\" data-act=\"rename\" type=\"button\">Rename</button>' +\n        '<button class=\"btn danger sm\" data-act=\"del\" type=\"button\">Delete</button></span></div>';\n      el.querySelector(\".issuer\").textContent = e.issuer || e.account;\n      el.querySelector(\".acct\").textContent = e.issuer ? e.account : \"\";\n      el.querySelector(\".meta\").textContent = [e.algorithm !== \"SHA-1\" ? e.algorithm : \"\", e.digits !== 6 ? e.digits + \"d\" : \"\", e.period !== 30 ? e.period + \"s\" : \"\"].filter(Boolean).join(\" · \");\n      list.appendChild(el);\n    }\n    lastCounterSig = \"\";\n  }\n  $(\"filter\").addEventListener(\"input\", () => { renderList(); tick(); });\n\n  $(\"list\").addEventListener(\"click\", async (ev) => {\n    const card = ev.target.closest(\".entry\");\n    if (!card || !session) return;\n    const e = session.data.entries.find((x) => x.id === card.dataset.id);\n    if (!e) return;\n    if (ev.target.closest(\".code\")) {\n      const code = await C.totp(secretOf(e), e);\n      try { await navigator.clipboard.writeText(code); toast(\"Copied \" + fmtCode(code)); }\n      catch { toast(\"Copy blocked. Code: \" + fmtCode(code)); }\n      return;\n    }\n    const act = ev.target.dataset.act;\n    if (act === \"del\") {\n      const b = ev.target;\n      if (!b.classList.contains(\"armed\")) {\n        b.classList.add(\"armed\"); b.textContent = \"Confirm\";\n        setTimeout(() => { if (b.isConnected) { b.classList.remove(\"armed\"); b.textContent = \"Delete\"; } }, 4000);\n        return;\n      }\n      session.data.entries = session.data.entries.filter((x) => x.id !== e.id);\n      secretCache.delete(e.id);\n      await persist(); renderList(); tick(); toast(\"Key deleted\");\n    }\n    if (act === \"rename\") {\n      const head = card.querySelector(\".entry-head\");\n      if (head.querySelector(\"form\")) return;\n      const f = document.createElement(\"form\");\n      f.style.cssText = \"display:grid;gap:6px;width:100%\";\n      f.innerHTML = '<input type=\"text\" name=\"i\" maxlength=\"80\" placeholder=\"Service\"><input type=\"text\" name=\"a\" maxlength=\"120\" placeholder=\"Account\">' +\n        '<div class=\"row\"><button class=\"btn sm\" type=\"submit\">Save</button><button class=\"btn ghost sm\" type=\"button\" data-cancel>Cancel</button></div>';\n      f.i.value = e.issuer; f.a.value = e.account;\n      head.innerHTML = \"\"; head.appendChild(f); f.i.focus();\n      f.querySelector(\"[data-cancel]\").onclick = () => renderList() || tick();\n      f.onsubmit = async (se) => {\n        se.preventDefault();\n        if (!f.i.value.trim() && !f.a.value.trim()) return;\n        e.issuer = f.i.value.trim(); e.account = f.a.value.trim();\n        await persist(); renderList(); tick();\n      };\n    }\n  });\n\n  // ---------- ticking codes ----------\n  async function tick() {\n    if (!session) return;\n    const now = Date.now();\n    const cards = $(\"list\").querySelectorAll(\".entry\");\n    const sig = session.data.entries.map((e) => e.id + \":\" + Math.floor(now / 1000 / e.period)).join(\"|\") + \"#\" + cards.length;\n    const refresh = sig !== lastCounterSig;\n    lastCounterSig = sig;\n    for (const card of cards) {\n      const e = session.data.entries.find((x) => x.id === card.dataset.id);\n      if (!e) continue;\n      const secs = now / 1000;\n      const left = e.period - (secs % e.period);\n      const bar = card.querySelector(\".bar-t i\");\n      bar.style.width = (left / e.period) * 100 + \"%\";\n      const codeEl = card.querySelector(\".code\");\n      codeEl.classList.toggle(\"warn\", left <= 5);\n      if (refresh) {\n        const s = secretOf(e);\n        const [cur, nxt] = await Promise.all([C.totp(s, e, now), C.totp(s, e, now + e.period * 1000)]);\n        if (!session) return;\n        codeEl.textContent = fmtCode(cur);\n        codeEl.setAttribute(\"aria-label\", \"Code \" + cur.split(\"\").join(\" \") + \", click to copy\");\n        card.dataset.next = fmtCode(nxt);\n      }\n      card.querySelector(\".next\").textContent = Math.ceil(left) + \"s · next \" + (card.dataset.next || \"\");\n    }\n  }\n\n  setInterval(() => {\n    if (!session) return;\n    const idle = Date.now() - lastActivity;\n    if (idle >= AUTOLOCK_MS) return lock(\"Locked after 5 minutes without activity.\");\n    const rem = Math.ceil((AUTOLOCK_MS - idle) / 1000);\n    $(\"lockIn\").textContent = rem <= 60 ? \"locks in \" + rem + \"s\" : \"\";\n    tick();\n    HW.tick();\n  }, 250);\n\n  // ---------- export / password / wipe ----------\n  // Sanctum tool windows cannot start downloads (every navigation off the\n  // tool's own origin is refused, blob: included), so the backup travels\n  // through the clipboard, with a read-only text box as the fallback.\n  $(\"btnExport\").onclick = async () => {\n    await persist();\n    const payload = { format: C.APP + \"-backup\", exported: new Date().toISOString(), id: session.id, record: store.get(session.id) };\n    const text = JSON.stringify(payload);\n    const box = $(\"exportText\");\n    box.value = text; box.hidden = false;\n    try {\n      await navigator.clipboard.writeText(text);\n      msg($(\"exportMsg\"), \"Encrypted backup copied. Paste it somewhere safe.\", \"ok\");\n    } catch {\n      box.focus(); box.select();\n      msg($(\"exportMsg\"), \"Clipboard blocked. The backup is selected below; copy it by hand.\", \"err\");\n    }\n  };\n\n  $(\"pwForm\").addEventListener(\"submit\", async (ev) => {\n    ev.preventDefault();\n    const m = $(\"pwMsg\"), a = $(\"pNew\").value, b = $(\"pNew2\").value;\n    if (a.length < 10) return msg(m, \"Use at least 10 characters.\", \"err\");\n    if (a !== b) return msg(m, \"The two passwords do not match.\", \"err\");\n    const btn = $(\"btnPw\");\n    if (btn.dataset.stage !== \"confirm\") {\n      btn.disabled = true; msg(m, \"Deriving new key...\");\n      const grid = $(\"pwFp\"); grid.hidden = false; renderFp(grid, null, null, \"busy\");\n      try {\n        const k = await C.deriveVaultKeys(session.user, a);\n        renderFp(grid, null, k.fingerprint);\n        pending = { pass: a, keys: k };\n        btn.dataset.stage = \"confirm\"; btn.textContent = \"Confirm new password\";\n        msg(m, \"This is the new fingerprint. Note it, then confirm.\", \"ok\");\n      } catch (e) { msg(m, e.message, \"err\"); }\n      btn.disabled = false;\n      return;\n    }\n    if (!pending || pending.pass !== a) { btn.dataset.stage = \"\"; btn.textContent = \"Change password\"; return msg(m, \"Password changed after preview. Press again.\", \"err\"); }\n    session.key = pending.keys.key; session.fingerprint = pending.keys.fingerprint;\n    await persist();\n    pending = null; $(\"pwForm\").reset(); $(\"pwFp\").hidden = true;\n    btn.dataset.stage = \"\"; btn.textContent = \"Change password\";\n    $(\"fpMini\").textContent = C.fingerprintEmoji(session.fingerprint).join(\"\");\n    msg(m, \"Password changed. Export a fresh backup; older backups still use the old password.\", \"ok\");\n  });\n  let pending = null;\n  [\"pNew\", \"pNew2\"].forEach((id) => $(id).addEventListener(\"input\", () => {\n    const btn = $(\"btnPw\"); btn.dataset.stage = \"\"; btn.textContent = \"Change password\"; $(\"pwFp\").hidden = true; pending = null;\n  }));\n\n  $(\"btnWipe\").onclick = (ev) => {\n    const b = ev.currentTarget;\n    if (!b.classList.contains(\"armed\")) {\n      b.classList.add(\"armed\"); b.textContent = \"Click again to delete\";\n      setTimeout(() => { b.classList.remove(\"armed\"); b.textContent = \"Delete vault\"; }, 5000);\n      return;\n    }\n    store.del(session.id);\n    b.classList.remove(\"armed\"); b.textContent = \"Delete vault\";\n    lock(\"Vault deleted from this tool.\");\n  };\n\n  // ---------- YubiKey through Sanctum's smart card bridge ----------\n  // window.sanctum exists only when the user approved smart card access for\n  // this tool. Sanctum checks every call in Rust: only the approved OATH\n  // applet can be selected, and SELECT cannot be sent on the raw channel.\n  const HW = (() => {\n    const bridge = window.sanctum && window.sanctum.smartcard;\n    const O = C.oath;\n    let session = null, reader = \"\", creds = [], loading = false, lastSig = \"\", pending = null;\n    // CALCULATE ALL is INS A4 under CLA 00, the same bytes as SELECT, and\n    // Sanctum's broker refuses A4 on the raw channel whatever the applet.\n    // When that happens, fall back to LIST and compute each code on request.\n    let listMode = false;\n    const el = (id) => $(id);\n\n    const SW_TEXT = {\n      0x6982: \"The key refused: it is locked with an OATH password, or a touch was not given in time.\",\n      0x6984: \"That account is not on the key.\",\n      0x6a80: \"The key rejected the data.\",\n      0x6a84: \"The key is full.\",\n      0x6d00: \"The key does not support this command.\",\n    };\n    const swError = (sw) => new Error(SW_TEXT[sw] || \"The key answered with status \" + sw.toString(16).padStart(4, \"0\") + \".\");\n\n    async function send(apduHex) {\n      let r = await session.transmit(apduHex);\n      let data = r.data || \"\", sw = r.sw;\n      // OATH continues long answers with SEND REMAINING (A5), not GET RESPONSE.\n      // Sanctum's broker answers 61xx itself with GET RESPONSE, which the OATH\n      // applet refuses with 6D00. Handle 61xx here in case a broker passes it through.\n      for (let i = 0; (sw >> 8) === 0x61 && i < 32; i++) {\n        r = await session.transmit(\"00a50000\");\n        data += r.data || \"\"; sw = r.sw;\n      }\n      if (sw === 0x6d00 && data)\n        throw new Error(\"The key has more accounts than fit in one reply, and this Sanctum version cannot fetch the rest for the OATH applet.\");\n      if (sw !== 0x9000) throw swError(sw);\n      return C.fromHex(data);\n    }\n\n    function setMsg(t, kind) { msg(el(\"hwMsg\"), t, kind); }\n\n    async function refreshReaders() {\n      const sel = el(\"hwReader\");\n      try {\n        const readers = await bridge.listReaders();\n        sel.innerHTML = \"\";\n        for (const r of readers) {\n          const o = document.createElement(\"option\");\n          o.value = r.name;\n          o.textContent = r.name + (r.card_present ? \"\" : \" (no card)\");\n          sel.appendChild(o);\n        }\n        const withCard = readers.find((r) => r.card_present);\n        if (withCard) sel.value = withCard.name;\n        el(\"hwConnect\").disabled = !readers.length;\n        setMsg(readers.length ? \"\" : \"No smart card readers found. Insert your YubiKey and press Find keys.\");\n      } catch (e) { setMsg(e.message, \"err\"); }\n    }\n\n    async function connect() {\n      disconnect();\n      reader = el(\"hwReader\").value;\n      if (!reader) return setMsg(\"Choose a reader first.\", \"err\");\n      el(\"hwConnect\").disabled = true;\n      setMsg(\"Connecting...\");\n      try {\n        session = await bridge.open(reader);\n        const sel = await session.select(O.AID);\n        if (!sel.ok) throw new Error(\"This card has no OATH applet.\");\n        const info = O.parseSelect(C.fromHex(sel.data));\n        el(\"hwConnect\").hidden = true; el(\"hwDisconnect\").hidden = false;\n        if (info.challenge) {\n          pending = info;\n          el(\"hwPwForm\").hidden = false; el(\"hwPw\").focus();\n          setMsg(\"OATH applet \" + info.version + \". Enter its password to continue.\");\n          return;\n        }\n        setMsg(\"Connected to OATH applet \" + info.version + \".\", \"ok\");\n        await load();\n      } catch (e) {\n        setMsg(e.message, \"err\");\n        disconnect(true);\n      } finally { el(\"hwConnect\").disabled = false; syncStorePick(); }\n    }\n\n    async function unlockApplet(ev) {\n      ev.preventDefault();\n      if (!session || !pending) return;\n      try {\n        const v = await O.validate(el(\"hwPw\").value, pending.salt, pending.challenge);\n        const resp = await send(v.apdu);\n        if (!O.checkValidate(resp, v.expected)) throw new Error(\"The key's answer did not match. Disconnect and try again.\");\n        pending = null;\n        el(\"hwPwForm\").hidden = true; el(\"hwPw\").value = \"\";\n        setMsg(\"Key unlocked.\", \"ok\");\n        await load();\n      } catch (e) { setMsg(e.message === SW_TEXT[0x6a80] ? \"Wrong OATH password.\" : e.message, \"err\"); }\n      syncStorePick();\n    }\n\n    function disconnect(keepMsg) {\n      if (session) { const s = session; session = null; s.close().catch(() => {}); }\n      creds = []; pending = null; lastSig = \"\"; listMode = false;\n      el(\"hwList\").innerHTML = \"\"; el(\"hwEmpty\").hidden = true;\n      el(\"hwPwForm\").hidden = true; el(\"hwConnect\").hidden = false; el(\"hwDisconnect\").hidden = true;\n      if (!keepMsg) setMsg(\"\");\n      syncStorePick();\n    }\n\n    const ready = () => !!session && !pending;\n\n    async function load() {\n      if (!ready() || loading) return;\n      loading = true;\n      try {\n        const now = Date.now();\n        let list = null;\n        if (!listMode) {\n          try { list = O.parseCalculateAll(await send(O.calculateAll(now))); }\n          catch (e) {\n            if (!/SELECT is not allowed|not allowed on the raw channel/i.test(e.message)) throw e;\n            listMode = true;\n          }\n        }\n        if (listMode) {\n          const old = new Map(creds.map((c) => [C.toHex(c.id), c]));\n          list = O.parseList(await send(O.list()));\n          // keep codes fetched on request until their window ends\n          for (const c of list) {\n            const o = old.get(C.toHex(c.id));\n            if (o && o.code && o.step === Math.floor(now / 1000 / c.period)) { c.code = o.code; c.step = o.step; }\n          }\n        } else {\n          // CALCULATE ALL uses a 30 s challenge; recompute other periods one by one.\n          for (const c of list) {\n            if (c.type === \"totp\" && !c.touch && c.period !== 30)\n              c.code = O.parseCalculate(await send(O.calculate(c.id, now, c.period)));\n          }\n        }\n        creds = list;\n        render();\n      } catch (e) {\n        setMsg(e.message, \"err\");\n        if (/card|reader|session/i.test(e.message)) disconnect(true);\n      } finally { loading = false; }\n    }\n\n    function render() {\n      const list = el(\"hwList\");\n      list.innerHTML = \"\";\n      el(\"hwEmpty\").hidden = creds.length > 0;\n      creds.forEach((c, i) => {\n        const card = document.createElement(\"article\");\n        card.className = \"entry\"; card.dataset.i = i;\n        card.innerHTML =\n          '<div class=\"entry-head\"><div><div class=\"issuer\"></div><div class=\"acct\"></div></div><span class=\"tag-hw\">YubiKey</span></div>' +\n          '<button class=\"code\" type=\"button\"></button><div class=\"bar-t\"><i></i></div>' +\n          '<div class=\"row\" style=\"justify-content:space-between\"><span class=\"next\"></span>' +\n          '<span class=\"entry-actions\"><button class=\"btn danger sm\" data-act=\"del\" type=\"button\">Delete from key</button></span></div>';\n        card.querySelector(\".issuer\").textContent = c.issuer || c.account;\n        card.querySelector(\".acct\").textContent = c.issuer ? c.account : \"\";\n        const code = card.querySelector(\".code\");\n        if (c.type === \"hotp\") { code.textContent = \"HOTP not supported\"; code.classList.add(\"touch\"); code.disabled = true; }\n        else if (!c.code) {\n          code.textContent = c.touch === false ? \"Get code\" : \"Get code (touch if it blinks)\";\n          code.classList.add(\"touch\"); code.title = \"Ask the key for a code\";\n        }\n        else { code.textContent = fmtCode(c.code); code.title = \"Copy code\"; }\n        list.appendChild(card);\n      });\n      tick();\n    }\n\n    function tick() {\n      if (!ready()) return;\n      const now = Date.now();\n      const sig = creds.map((c) => (c.type === \"totp\" ? Math.floor(now / 1000 / c.period) : 0)).join(\",\");\n      if (lastSig && sig !== lastSig) { lastSig = sig; load(); return; }\n      lastSig = sig;\n      el(\"hwList\").querySelectorAll(\".entry\").forEach((card) => {\n        const c = creds[card.dataset.i];\n        if (!c || c.type !== \"totp\") return;\n        const left = c.period - ((now / 1000) % c.period);\n        card.querySelector(\".bar-t i\").style.width = (c.code ? (left / c.period) * 100 : 0) + \"%\";\n        card.querySelector(\".code\").classList.toggle(\"warn\", !!c.code && left <= 5);\n        card.querySelector(\".next\").textContent = c.code ? Math.ceil(left) + \"s\" : c.touch ? \"touch required\" : listMode ? \"on request\" : \"\";\n      });\n    }\n\n    async function onListClick(ev) {\n      const card = ev.target.closest(\".entry\");\n      if (!card || !ready()) return;\n      const c = creds[card.dataset.i];\n      if (!c) return;\n      if (ev.target.closest(\".code\") && c.type === \"totp\") {\n        try {\n          if (!c.code) {\n            setMsg(c.touch === false ? \"\" : \"If your YubiKey blinks, touch it now.\");\n            const now = Date.now();\n            c.code = O.parseCalculate(await send(O.calculate(c.id, now, c.period)));\n            c.step = Math.floor(now / 1000 / c.period);\n            setMsg(\"\");\n            render();\n          }\n          try { await navigator.clipboard.writeText(c.code); toast(\"Copied \" + fmtCode(c.code)); }\n          catch { toast(\"Copy blocked. Code: \" + fmtCode(c.code)); }\n        } catch (e) { setMsg(e.message, \"err\"); }\n        return;\n      }\n      if (ev.target.dataset.act === \"del\") {\n        const b = ev.target;\n        if (!b.classList.contains(\"armed\")) {\n          b.classList.add(\"armed\"); b.textContent = \"Confirm: this cannot be undone\";\n          setTimeout(() => { if (b.isConnected) { b.classList.remove(\"armed\"); b.textContent = \"Delete from key\"; } }, 4000);\n          return;\n        }\n        try { await send(O.del(c.id)); toast(\"Deleted from key\"); await load(); }\n        catch (e) { setMsg(e.message, \"err\"); }\n      }\n    }\n\n    async function put(entry, secret, touch) {\n      if (!ready()) throw new Error(\"Connect your YubiKey first.\");\n      const key = C.toHex(O.credId(entry));\n      if (creds.some((c) => C.toHex(c.id) === key)) throw new Error(\"An account with this name is already on the key.\");\n      await send(await O.put(entry, secret, touch));\n      await load();\n    }\n\n    function storeTarget() {\n      const r = document.querySelector('input[name=\"aStore\"]:checked');\n      return ready() && r ? r.value : \"vault\";\n    }\n\n    function syncStorePick() {\n      el(\"storePick\").hidden = !ready();\n      el(\"storeCardName\").textContent = ready() ? \"(\" + reader + \")\" : \"\";\n      if (!ready()) document.querySelector('input[name=\"aStore\"][value=\"vault\"]').checked = true;\n      el(\"touchWrap\").hidden = storeTarget() !== \"card\";\n    }\n\n    function onSessionStart() {\n      el(\"hw\").hidden = false;\n      if (!bridge) { el(\"hwUnavailable\").hidden = false; el(\"hwPanel\").hidden = true; return; }\n      el(\"hwUnavailable\").hidden = true; el(\"hwPanel\").hidden = false;\n      refreshReaders();\n    }\n\n    el(\"hwRefreshReaders\").onclick = refreshReaders;\n    el(\"hwConnect\").onclick = connect;\n    el(\"hwDisconnect\").onclick = () => { disconnect(); setMsg(\"Disconnected.\"); };\n    el(\"hwPwForm\").addEventListener(\"submit\", unlockApplet);\n    el(\"hwList\").addEventListener(\"click\", onListClick);\n    document.querySelectorAll('input[name=\"aStore\"]').forEach((r) => r.addEventListener(\"change\", syncStorePick));\n    addEventListener(\"pagehide\", () => { if (session) session.close().catch(() => {}); });\n\n    return { onSessionStart, disconnect: () => { disconnect(); el(\"hw\").hidden = true; }, tick, put, storeTarget, syncStorePick };\n  })();\n\n  // ---------- init ----------\n  setMode(\"unlock\");\n  refreshCount();\n  renderFp($(\"fpGrid\"), $(\"fpHex\"), null);\n})();\n</script>\n</body>\n</html>\n","signature":{"payload":"eyJzY2hlbWEiOjEsImFwcF9pZCI6InNoLmVuaWdtYS5vdHAtdmF1bHQiLCJuYW1lIjoiT1RQIFZhdWx0IiwidmVyc2lvbiI6IjEuMC4yIiwiY2hlY2tzdW0iOiJzaGEyNTY6YmQzMjVkZDk4MWVjYTIzMzg2NzU4M2U2YWYyYzMwYTBkOWQ3ZWJlMzkxMzBhNDA5ODI1ZTU1Yzk1ODEwNTE3MiIsImRlY2xhcmVkIjpbInN0b3JhZ2UiLHsic21hcnRjYXJkIjpbImEwMDAwMDA1MjcyMTAxIl19XSwiaXNzdWVkX2F0IjoxNzkwOTAwNzY3fQ","sig":"9rDfRFIgQUto4ggs-stQKJr3qi_5KcZkbnqIkeQJAa-l2G_QafYBA1yZ7RNT3Xp40ETAJKQFlHLOQoVMMgfMDA","keyid":"ed25519:2daedA0n4NSI2p2McCqLBfGiKRmR5XCm8lypbQTT6hg","chain":[{"payload":"eyJzY2hlbWEiOjEsInN1YmplY3QiOiJlZDI1NTE5OjJkYWVkQTBuNE5TSTJwMk1jQ3FMQmZHaUtSbVI1WENtOGx5cGJRVFQ2aGciLCJuYW1lIjoiRW5pZ21hIFRlY2hub2xvZ2llcyBTb2x1dGlvbnMiLCJub3RfYmVmb3JlIjoxNzkwNzk0NDE5LCJub3RfYWZ0ZXIiOjE4NTM5NTI4MTYsInNjb3BlIjpbInNoLmVuaWdtYS4iXX0","sig":"-lytziD13TmzrLBjPz4mtYuWsjJnBeVMpn7cCtglpTgd_F2tlTZG9re7AW_DGRxRS1uwEbi1kI0GszPfsg10CQ","keyid":"ed25519:GmNzEGh_57SWBOBLNV7kEnhykqoCkcCCMAnbkh0IVHY"}]}}