From 495054ca8aac94cdb6adf47c84f904afff2d7252 Mon Sep 17 00:00:00 2001 From: Local Dev Date: Wed, 9 Sep 2026 01:31:18 +0200 Subject: [PATCH] =?UTF-8?q?feat(sirius-x/signin):=20PIN=20escrow=20?= =?UTF-8?q?=E2=80=94=20encrypted-at-rest=20quick=20unlock,=203-strike=20pa?= =?UTF-8?q?ssword=20fallback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes the sessionStorage plaintext-mnemonic cache (fixed under the same commit) and replaces it with a PIN-encrypted blob in localStorage. No plaintext secret ever touches disk or memory outside the live BuiltInWallet object. New js/pin-escrow.js — WebCrypto PBKDF2(50k) + AES-GCM(256). Public API on window.siriusPin: savePinBlob(mnemonic, pin), tryUnlock(pin), hasPin(), attemptsUsed(), attemptsRemaining(), clear(), MAX_ATTEMPTS. Iteration count is lighter than BuiltInWallet's 250k because a 4-6 digit PIN's key space is small anyway; the point is 'not plaintext at rest,' not brute-force resistance — the durable secret is the full password. register-flow.js: - After a fresh password unlock (Import / Create / Unlock), stepSetPin offers a 4-6 digit PIN with confirm — skippable with 'Not now'. Never overwrites an existing PIN blob. - stepUnlock now shows a numeric PIN pad when a PIN blob is present; the password field only appears when the user opts to 'Use password instead' or after the blob was wiped. - Wrong PIN → increment counter, surface 'N attempts left'. Third wrong PIN → wipe blob and route to a 'PIN reset' screen that hands off to the password form. - Correct PIN → decrypt the mnemonic in-browser, rebuild the BuiltInWallet, finishSignIn(). Attempt counter resets to 0. profile-menu.js: - Sign-out clears the PIN blob (via siriusPin.clear()) alongside the siriusProfile so the device isn't quick-unlockable with a stale PIN. All pages that host the sign-in flow now include pin-escrow.js. Same tabbed layout in stepUnlock — the PIN pad and the password field both live under 🔓 Unlock, transparent tab-switch works exactly as before. Verified live: - Fresh Import → 'Set a PIN' step → 4242 confirmed → blob written - Reload → PIN pad, 3 attempts remaining - Correct PIN 4242 → signed in, counter resets to 0 - 3 wrong PIN attempts → 'Wrong PIN — N attempts left' per attempt, then 'PIN locked — enter your full password to continue', blob wiped, next reload shows the password form - localStorage contains only ciphertext + salt + iv + counter; no plaintext mnemonic anywhere on disk or in sessionStorage. --- admin/index.html | 2 + brand/index.html | 2 + docs/index.html | 2 + index.html | 1 + js/pin-escrow.js | 118 +++++++++++++++++++++++++++++++++++++++++++ js/profile-menu.js | 10 ++-- js/register-flow.js | 119 +++++++++++++++++++++++++++++++++----------- portal.html | 20 +++----- theseus/index.html | 2 + tld.html | 1 + 10 files changed, 229 insertions(+), 48 deletions(-) create mode 100644 js/pin-escrow.js diff --git a/admin/index.html b/admin/index.html index 54f21d3..1496653 100644 --- a/admin/index.html +++ b/admin/index.html @@ -372,6 +372,8 @@ $("mint-btn").addEventListener("click", async () => { } }); + + diff --git a/brand/index.html b/brand/index.html index f65d642..93ea28a 100644 --- a/brand/index.html +++ b/brand/index.html @@ -218,6 +218,8 @@ + + diff --git a/docs/index.html b/docs/index.html index 3aced1c..73f08cf 100644 --- a/docs/index.html +++ b/docs/index.html @@ -424,6 +424,8 @@ process.exit(0);" + + diff --git a/index.html b/index.html index 65c141f..bdda457 100644 --- a/index.html +++ b/index.html @@ -449,6 +449,7 @@ process.exit(0);" + diff --git a/js/pin-escrow.js b/js/pin-escrow.js new file mode 100644 index 0000000..bb8965a --- /dev/null +++ b/js/pin-escrow.js @@ -0,0 +1,118 @@ +// PIN-escrow for the Sirius.X built-in wallet. +// +// After a user signs in with their full password, they may set a 4–6 digit +// PIN. The wallet's mnemonic is encrypted with the PIN via PBKDF2 → AES-GCM +// and stored under localStorage."siriusPinBlob" (survives browser close, so +// next visit shows the PIN pad instead of the password field). +// +// Wrong PIN increments localStorage."siriusPinAttempts". After 3 wrong tries +// the PIN blob is destroyed and the user falls back to their full password — +// same UX pattern the Digibyte.x/web mobile wallet uses. +// +// Iteration counts are lower than the BuiltInWallet's 250k because a numeric +// PIN's key space is tiny anyway. The point of PIN encryption is "not +// plaintext in localStorage", not resistance to serious brute-force. Users +// keep the real password as their durable secret. + +(() => { + const PIN_BLOB_KEY = "siriusPinBlob"; + const PIN_ATTEMPTS_KEY = "siriusPinAttempts"; + const MAX_ATTEMPTS = 3; + const PBKDF2_ITERATIONS = 50_000; + + const enc = new TextEncoder(); + const dec = new TextDecoder(); + + const b64 = (bytes) => btoa(String.fromCharCode(...new Uint8Array(bytes))); + const unb64 = (str) => Uint8Array.from(atob(str), (c) => c.charCodeAt(0)); + + async function deriveKey(pin, salt) { + const material = await crypto.subtle.importKey("raw", enc.encode(pin), "PBKDF2", false, ["deriveKey"]); + return crypto.subtle.deriveKey( + { name: "PBKDF2", salt, iterations: PBKDF2_ITERATIONS, hash: "SHA-256" }, + material, + { name: "AES-GCM", length: 256 }, + false, + ["encrypt", "decrypt"], + ); + } + + function validPin(pin) { + return typeof pin === "string" && /^\d{4,6}$/.test(pin); + } + + // Encrypt the mnemonic (or any string secret) with the PIN and stash it. + // Called right after a successful password unlock, when the user opts in + // to the PIN shortcut. + async function savePinBlob(secret, pin) { + if (!validPin(pin)) throw new Error("PIN must be 4-6 digits"); + const salt = crypto.getRandomValues(new Uint8Array(16)); + const iv = crypto.getRandomValues(new Uint8Array(12)); + const key = await deriveKey(pin, salt); + const ct = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, enc.encode(secret)); + const blob = { + v: 1, + it: PBKDF2_ITERATIONS, + s: b64(salt), + iv: b64(iv), + ct: b64(ct), + }; + localStorage.setItem(PIN_BLOB_KEY, JSON.stringify(blob)); + localStorage.setItem(PIN_ATTEMPTS_KEY, "0"); + } + + // Try decrypting with the supplied PIN. On success returns the mnemonic + // and resets the attempt counter. On failure increments attempts and + // wipes the blob when the ceiling is hit (forcing password fallback). + async function tryUnlock(pin) { + const raw = localStorage.getItem(PIN_BLOB_KEY); + if (!raw) throw new Error("no PIN set on this device"); + if (!validPin(pin)) throw new Error("PIN must be 4-6 digits"); + const blob = JSON.parse(raw); + try { + const key = await deriveKey(pin, unb64(blob.s)); + const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv: unb64(blob.iv) }, key, unb64(blob.ct)); + const secret = dec.decode(pt); + localStorage.setItem(PIN_ATTEMPTS_KEY, "0"); + return secret; + } catch { + const now = (parseInt(localStorage.getItem(PIN_ATTEMPTS_KEY) || "0", 10) || 0) + 1; + if (now >= MAX_ATTEMPTS) { + localStorage.removeItem(PIN_BLOB_KEY); + localStorage.removeItem(PIN_ATTEMPTS_KEY); + const err = new Error("PIN locked — enter your full password to continue"); + err.locked = true; + throw err; + } + localStorage.setItem(PIN_ATTEMPTS_KEY, String(now)); + const err = new Error(`Wrong PIN — ${MAX_ATTEMPTS - now} attempt${MAX_ATTEMPTS - now === 1 ? "" : "s"} left`); + err.attemptsRemaining = MAX_ATTEMPTS - now; + throw err; + } + } + + function hasPin() { + try { return !!localStorage.getItem(PIN_BLOB_KEY); } catch { return false; } + } + + function attemptsUsed() { + try { return parseInt(localStorage.getItem(PIN_ATTEMPTS_KEY) || "0", 10) || 0; } catch { return 0; } + } + function attemptsRemaining() { + return Math.max(0, MAX_ATTEMPTS - attemptsUsed()); + } + + function clear() { + try { localStorage.removeItem(PIN_BLOB_KEY); localStorage.removeItem(PIN_ATTEMPTS_KEY); } catch {} + } + + window.siriusPin = { + savePinBlob, + tryUnlock, + hasPin, + attemptsUsed, + attemptsRemaining, + clear, + MAX_ATTEMPTS, + }; +})(); diff --git a/js/profile-menu.js b/js/profile-menu.js index d2650d0..9cb0f2e 100644 --- a/js/profile-menu.js +++ b/js/profile-menu.js @@ -155,10 +155,12 @@ const action = t.dataset.action; if (action === "signout") { try { localStorage.removeItem("siriusProfile"); } catch {} - // Also drop the session-cached seed so a reload after sign-out really - // signs the user out (else the next page load would silently rebuild - // the same wallet from sessionStorage). - try { sessionStorage.removeItem("siriusSessionMnemonic"); sessionStorage.removeItem("siriusSessionSource"); } catch {} + // Also wipe the PIN blob so a signed-out device can't be quick- + // unlocked with a stale PIN. The password-encrypted BuiltInWallet + // blob (bns.wallet.v1) stays — the user's device, the user's data; + // they'd remove it explicitly via 'forget wallet' in a future + // settings screen. + try { window.siriusPin?.clear(); } catch {} try { delete window.siriusWallet; } catch { window.siriusWallet = null; } window.dispatchEvent(new Event("siriusProfileChanged")); // Reload so any signed-in section on the current page (portal, tld) resets. diff --git a/js/register-flow.js b/js/register-flow.js index 9ed1e83..49a9bff 100644 --- a/js/register-flow.js +++ b/js/register-flow.js @@ -242,19 +242,13 @@ function writeProfile(w) { // mode, mark siriusProfile and close the modal here; otherwise return // false so the caller keeps going to Fund → Confirm → Register. function finishSignIn(w) { - // Expose the live wallet globally BEFORE writeProfile so any listener of - // siriusProfileChanged (portal.html adoptWalletFromModal) sees the wallet - // and can switch to the signed-in view synchronously. + // Expose the live wallet in memory only, BEFORE writeProfile so any + // listener of siriusProfileChanged (portal.html adoptWalletFromModal) + // sees it and can switch to the signed-in view synchronously. The + // mnemonic is NEVER written to sessionStorage / localStorage in + // plaintext — the only at-rest copies are the password-encrypted + // BuiltInWallet blob and, optionally, the PIN-encrypted escrow blob. window.siriusWallet = w; - // Session-cache the seed so a page reload restores the wallet without - // asking for the password again. sessionStorage is per-tab and clears on - // browser close — same lifetime as a "keep me signed in for this session" - // toggle in a normal app. Chipnet-only convenience; mainnet gets the PIN - // escrow pattern (see finishSignIn comment history). - try { - if (w?.mnemonic) sessionStorage.setItem("siriusSessionMnemonic", w.mnemonic); - if (w?.source === "wc") sessionStorage.setItem("siriusSessionSource", "wc"); - } catch {} writeProfile({ address: w.address, tokenAddress: w.tokenAddress, source: w.source ?? "seed" }); if (state.signInOnly) { render(`

✓ Signed in

@@ -346,21 +340,13 @@ function adoptSignedInWallet() { return false; } -// Best-effort silent rebuild from sessionStorage — same session ⇒ same -// wallet, no password prompt. Returns true when the wallet is ready. +// In-memory only: return the live wallet if it was set earlier this +// session, else false. NO plaintext-mnemonic cache — a reload drops the +// user back to the Unlock tab (PIN pad if a PIN blob exists, password +// field otherwise), which decrypts locally without any secret ever +// touching disk in plaintext. async function adoptSessionWalletAsync() { - if (state.wallet) return true; - if (adoptSignedInWallet()) return true; - try { - const cached = typeof sessionStorage !== "undefined" - ? sessionStorage.getItem("siriusSessionMnemonic") - : null; - if (!cached) return false; - const w = await BNS.BuiltInWallet.fromMnemonic(cached); - window.siriusWallet = w; - state.wallet = w; - return true; - } catch { return false; } + return adoptSignedInWallet(); } async function startFlow(name, opts = {}) { @@ -653,7 +639,7 @@ function stepPhrase() { `); $("copy").onclick = () => navigator.clipboard?.writeText(state.wallet.mnemonic); $("ack").onchange = (e) => { $("next").disabled = !e.target.checked; }; - $("next").onclick = () => { if (!finishSignIn(state.wallet)) stepFund(); }; + $("next").onclick = () => stepSetPin(state.wallet, () => { if (!finishSignIn(state.wallet)) stepFund(); }); } // ---------- built-in: import / unlock ---------- @@ -677,7 +663,7 @@ function stepImport() { const wallet = await BNS.BuiltInWallet.fromMnemonic($("mn").value); await wallet.save($("pw").value); state.wallet = wallet; - if (!finishSignIn(wallet)) stepFund(); + stepSetPin(wallet, () => { if (!finishSignIn(wallet)) stepFund(); }); } catch (e) { err(e); } }; } @@ -703,10 +689,49 @@ function stepUnlock() { $("go-new").onclick = stepCreate; return; } + // PIN escrow — if the user set a PIN on a previous session, prefer that. + // 3 wrong PINs wipe the blob and this branch falls back to the password + // form on next render. + if (window.siriusPin?.hasPin?.()) { + const remaining = window.siriusPin.attemptsRemaining(); + render(` + ${renderTabs("unlock")} +

Enter your PIN

+

4–6 digits. Set on this device to skip typing your full password. + ${remaining < 3 ? `${esc(String(remaining))} attempt${remaining === 1 ? "" : "s"} left before the PIN resets and you must use your password.` : ""}

+ + +
+ + +
+ `); + $("use-password").onclick = () => { window.siriusPin.clear(); stepUnlock(); }; + $("pin").focus(); + $("pin").onkeydown = (e) => { if (e.key === "Enter") $("next").click(); }; + $("next").onclick = async () => { + try { + const mnemonic = await window.siriusPin.tryUnlock($("pin").value); + state.wallet = await BNS.BuiltInWallet.fromMnemonic(mnemonic); + if (!finishSignIn(state.wallet)) stepFund(); + } catch (e) { + if (e.locked) { render(` + ${renderTabs("unlock")} +

PIN reset

+

3 wrong attempts — the PIN has been cleared. Enter your password to unlock and set a new PIN if you like.

+
+ `); $("go-pw").onclick = stepUnlock; return; } + err(e); + } + }; + return; + } + render(` ${renderTabs("unlock")}

Unlock your wallet

-

Decrypts the wallet stored in this browser.

+

Decrypts the wallet stored in this browser. After unlocking you can set a + PIN so future sessions ask for 4–6 digits instead of the full password.

@@ -717,11 +742,45 @@ function stepUnlock() { $("next").onclick = async () => { try { state.wallet = await BNS.BuiltInWallet.load($("pw").value); - if (!finishSignIn(state.wallet)) stepFund(); + // Fresh password unlock — offer the PIN shortcut before finishing. + stepSetPin(state.wallet, () => { if (!finishSignIn(state.wallet)) stepFund(); }); } catch (e) { err(e); } }; } +// After a successful password sign-in (Import / Create / Unlock), offer to +// stash the mnemonic under a PIN so next session opens with a numeric pad. +// User can decline ("Not now"); either way we call `next` afterwards. +function stepSetPin(w, next) { + if (!window.siriusPin || !w?.mnemonic) return next(); + // Already have a PIN blob? Don't overwrite silently — skip. + if (window.siriusPin.hasPin()) return next(); + render(` + ${renderTabs("unlock")} +

Set a PIN optional

+

4–6 digits. Encrypts your recovery phrase under this PIN so future sessions on + this device unlock with just the PIN. Three wrong PINs and the PIN is cleared — you'd fall + back to your full password.

+ + + + +
+ + +
+ `); + $("skip").onclick = next; + $("pin2").onkeydown = (e) => { if (e.key === "Enter") $("save-pin").click(); }; + $("save-pin").onclick = async () => { + const a = $("pin1").value, b = $("pin2").value; + if (!/^\d{4,6}$/.test(a)) return err(new Error("PIN must be 4–6 digits")); + if (a !== b) return err(new Error("PINs do not match")); + try { await window.siriusPin.savePinBlob(w.mnemonic, a); next(); } + catch (e) { err(e); } + }; +} + // ---------- fund ---------- let fundTimer = null; async function stepFund() { diff --git a/portal.html b/portal.html index c69665f..32fef14 100644 --- a/portal.html +++ b/portal.html @@ -278,20 +278,10 @@ async function adoptWalletFromModal() { await enterPortal(); return true; } - // No live wallet in memory — but if a session mnemonic is cached from an - // earlier sign-in in this browser session, rebuild the wallet silently. - // Same tab (or a reload) keeps the user signed in; a full browser close - // clears sessionStorage and drops the user back to Unlock. - try { - const cached = sessionStorage.getItem("siriusSessionMnemonic"); - if (cached) { - const w = await BNS.BuiltInWallet.fromMnemonic(cached); - wallet = w; - window.siriusWallet = w; - await enterPortal(); - return true; - } - } catch { /* fall through to sign-in prompt */ } + // No live wallet in memory — the mnemonic is NEVER cached in plaintext. + // Fall through so the caller shows the Unlock tab; PIN-encrypted blob + // (if the user set a PIN earlier) or the full password decrypts locally + // in-browser. return false; } function readProfile() { @@ -794,6 +784,8 @@ $("tld-submit").addEventListener("click", async () => { // Initial paint on page load so the card shows the price even before sign-in. updateTldPrice(); + + diff --git a/theseus/index.html b/theseus/index.html index fa483b1..73a26de 100644 --- a/theseus/index.html +++ b/theseus/index.html @@ -169,6 +169,8 @@ sha256sum <downloaded-file>.exe
+ + diff --git a/tld.html b/tld.html index 353d5c3..fe4639f 100644 --- a/tld.html +++ b/tld.html @@ -283,6 +283,7 @@ +