feat(sirius-x/signin): PIN escrow — encrypted-at-rest quick unlock, 3-strike password fallback

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.
This commit is contained in:
Local Dev 2026-09-09 01:31:18 +02:00
parent 0a86b012d6
commit 495054ca8a
10 changed files with 229 additions and 48 deletions

View file

@ -372,6 +372,8 @@ $("mint-btn").addEventListener("click", async () => {
}
});
</script>
<script src="../js/pin-escrow.js?v=20260909pin"></script>
<script src="../js/pricing.js?v=20260909tiers"></script>
<script defer src="../js/site-footer.js?v=20260907rel"></script>
<script defer src="../js/profile-menu.js?v=20260908tabs"></script>
</body>

View file

@ -218,6 +218,8 @@
<footer id="site-footer"></footer>
<script src="../js/pin-escrow.js?v=20260909pin"></script>
<script src="../js/pricing.js?v=20260909tiers"></script>
<script defer src="../js/site-footer.js?v=20260907rel"></script>
<script defer src="../js/profile-menu.js?v=20260908tabs"></script>
</body>

View file

@ -424,6 +424,8 @@ process.exit(0);"</pre>
<footer id="site-footer"></footer>
<script src="../js/pin-escrow.js?v=20260909pin"></script>
<script src="../js/pricing.js?v=20260909tiers"></script>
<script defer src="../js/site-footer.js?v=20260907rel"></script>
<script defer src="../js/profile-menu.js?v=20260908tabs"></script>
</body>

View file

@ -449,6 +449,7 @@ process.exit(0);"</pre>
<script type="module" src="./js/register-flow.js?v=20260908tabs"></script>
<script src="./js/pricing.js?v=20260909tiers"></script>
<script src="./js/pin-escrow.js?v=20260909pin"></script>
<script defer src="./js/site-footer.js?v=20260907rel"></script>
<script defer src="./js/profile-menu.js?v=20260908tabs"></script>
</body>

118
js/pin-escrow.js Normal file
View file

@ -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 46 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,
};
})();

View file

@ -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.

View file

@ -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(`<h3><span class="ok">✓</span> Signed in</h3>
@ -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")}
<h3>Enter your PIN</h3>
<p class="sub">46 digits. Set on this device to skip typing your full password.
${remaining < 3 ? `<b class="err" style="color:var(--taken,#f6768a)">${esc(String(remaining))} attempt${remaining === 1 ? "" : "s"} left</b> before the PIN resets and you must use your password.` : ""}</p>
<label for="pin">PIN</label>
<input type="password" id="pin" inputmode="numeric" pattern="[0-9]*" maxlength="6" autocomplete="off">
<div class="row end">
<button class="btn ghost" id="use-password" type="button">Use password instead</button>
<button class="btn acid" id="next" type="button">Unlock </button>
</div>
`);
$("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")}
<h3>PIN reset</h3>
<p class="sub">3 wrong attempts the PIN has been cleared. Enter your password to unlock and set a new PIN if you like.</p>
<div class="row end"><button class="btn acid" id="go-pw">Continue with password </button></div>
`); $("go-pw").onclick = stepUnlock; return; }
err(e);
}
};
return;
}
render(`
${renderTabs("unlock")}
<h3>Unlock your wallet</h3>
<p class="sub">Decrypts the wallet stored in this browser.</p>
<p class="sub">Decrypts the wallet stored in this browser. After unlocking you can set a
PIN so future sessions ask for 46 digits instead of the full password.</p>
<label for="pw">Password</label>
<input type="password" id="pw" autocomplete="current-password">
<div class="row end"><button class="btn ghost" id="back">Back</button>
@ -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")}
<h3>Set a PIN <span class="pill">optional</span></h3>
<p class="sub">46 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.</p>
<label for="pin1">PIN</label>
<input type="password" id="pin1" inputmode="numeric" pattern="[0-9]*" maxlength="6" autocomplete="off">
<label for="pin2" style="margin-top:12px">Repeat PIN</label>
<input type="password" id="pin2" inputmode="numeric" pattern="[0-9]*" maxlength="6" autocomplete="off">
<div class="row end">
<button class="btn ghost" id="skip" type="button">Not now</button>
<button class="btn acid" id="save-pin" type="button">Save PIN </button>
</div>
`);
$("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 46 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() {

View file

@ -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();
</script>
<script src="./js/pin-escrow.js?v=20260909pin"></script>
<script src="./js/pricing.js?v=20260909tiers"></script>
<script defer src="./js/site-footer.js?v=20260907rel"></script>
<script type="module" src="./js/register-flow.js?v=20260908tabs"></script>
<script defer src="./js/profile-menu.js?v=20260908tabs"></script>

View file

@ -169,6 +169,8 @@ sha256sum &lt;downloaded-file&gt;.exe</pre>
<footer id="site-footer"></footer>
<script src="../js/pin-escrow.js?v=20260909pin"></script>
<script src="../js/pricing.js?v=20260909tiers"></script>
<script defer src="../js/site-footer.js?v=20260907rel"></script>
<script defer src="../js/profile-menu.js?v=20260908tabs"></script>
</body>

View file

@ -283,6 +283,7 @@
<script type="module" src="./js/register-flow.js?v=20260908tabs"></script>
<script src="./js/pricing.js?v=20260909tiers"></script>
<script src="./js/pin-escrow.js?v=20260909pin"></script>
<script defer src="./js/site-footer.js?v=20260907rel"></script>
<script defer src="./js/profile-menu.js?v=20260908tabs"></script>
</body>