aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-10 22:01:32 -0700
committerAdam Soltys <[email protected]>2026-10-10 22:01:32 -0700
commit8b6d2837716aeb04bedbfdd9c00ab9071be63f90 (patch)
tree49bd78e742929b85120b66f5813f5844437477b1
parente253cbd16d502cd382a6f7632d2851db4ae1a6ae (diff)
downloadcoinosv3-8b6d2837716aeb04bedbfdd9c00ab9071be63f90.tar.gz
coinosv3-8b6d2837716aeb04bedbfdd9c00ab9071be63f90.zip
Lock with fingerprint or face. The padlock's "Protect this device?" now offers a passkey instead of a password, and Settings → Security can turn it on or off. The saved-wallets vault is sealed under a secret only that passkey's PRF produces (behind the phone's fingerprint/face check, the passkey kept by the platform's secure hardware or password manager), so nothing in this browser's storage opens it. Opening the app on a locked device asks for the passkey by itself; a cancel leaves an Unlock button. The prompts that need the vault opened (save, forget, lock a stray wallet) take the passkey too; there's no Change password for a vault that has none; the recovery words remain the way back. The lock passkey has its own PRF salt and a marked user handle, not discoverable where the platform allows — passkey sign-in refuses it rather than derive a stranger's key from it. English + Italian strings. tools/passkey-lock-test.js (Chrome's virtual authenticator with PRF).
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Vps2H27rhbG9ZqseGfMrc3
-rw-r--r--src/app.js160
-rw-r--r--src/features/nostrlogin.js2
-rw-r--r--src/i18n.js12
-rw-r--r--src/locales/it.json16
-rw-r--r--src/passkey.js70
-rw-r--r--tools/passkey-lock-test.js112
6 files changed, 353 insertions, 19 deletions
diff --git a/src/app.js b/src/app.js
index 02c1ef1..aab07da 100644
--- a/src/app.js
+++ b/src/app.js
@@ -2077,7 +2077,7 @@ function restoreAccountsState() {
else { ui.screen = 'unlock'; render(); }
return true;
}
- ui.screen = 'vault'; render(); return true;
+ ui.screen = 'vault'; ui.passkeyAsk = true; render(); return true;
}
const watch = loadWatchAccounts();
if (watch.length) {
@@ -2096,6 +2096,17 @@ const LOCK_PW_KEY = 'btc-wallet-lockpw';
const lockPwAsked = () => { try { return !!localStorage.getItem(LOCK_PW_KEY); } catch { return true; } };
const markLockPwAsked = () => { try { localStorage.setItem(LOCK_PW_KEY, '1'); } catch {} };
let vaultPassword = null; // in memory once unlocked/set this session; cleared on lock
+// Locked with a passkey instead of a password: the vault's password is then
+// a secret only that passkey's PRF produces (passkey.js), and the blob names
+// the credential to ask for. Held beside vaultPassword while it's in use.
+let vaultPasskeyId = null;
+const passkeySecret = (pw) => typeof pw === 'string' && pw.startsWith('passkey:');
+const vaultPasskey = () => (loadVaultBlob() || {}).passkey || null;
+function sealVault(list, password) {
+ const b = encryptVault(list, password);
+ if (passkeySecret(password) && vaultPasskeyId) b.passkey = vaultPasskeyId;
+ return b;
+}
function loadVaultBlob() {
try { return JSON.parse(localStorage.getItem(VAULT_KEY) || 'null'); } catch { return null; }
@@ -2110,7 +2121,7 @@ function writeVault() {
.map((a) => (a.xprv ? { label: a.label, xprv: a.xprv, network: a.network } : { label: a.label, mnemonic: a.mnemonic, passphrase: a.passphrase || '', network: a.network }));
try {
if (!list.length) localStorage.removeItem(VAULT_KEY);
- else localStorage.setItem(VAULT_KEY, JSON.stringify(encryptVault(list, vaultPassword)));
+ else localStorage.setItem(VAULT_KEY, JSON.stringify(sealVault(list, vaultPassword)));
} catch {}
}
@@ -2255,6 +2266,15 @@ function submitPw() {
toast(t('pwChanged'));
return;
}
+ if (p.purpose === 'passkeyOn') {
+ let list;
+ try { list = decryptVault(loadVaultBlob(), p.v1 || ''); } catch { p.error = t('pwWrong'); render(); return; }
+ vaultPassword = p.v1 || '';
+ mergeVaultList(list);
+ ui.pw = null;
+ enablePasskeyLock();
+ return;
+ }
if (p.purpose === 'lock') {
if (!p.v1) { p.error = t('pwNeededToLock'); render(); return; }
if (p.v1 !== p.v2) { p.error = t('pwMismatch'); render(); return; }
@@ -2291,12 +2311,97 @@ function submitPw() {
render();
}
+// Lock with a passkey (fingerprint or face) instead of a password. The
+// vault's wallets are re-sealed under the passkey's secret; from then on
+// opening them takes the passkey, and the recovery words remain the way back
+// if it's ever lost. `lockAfter`: the padlock's offer — lock once it's set.
+async function enablePasskeyLock({ lockAfter = false } = {}) {
+ // what the vault holds now, under whatever opens it today
+ if (hasVault()) {
+ let list;
+ try { list = decryptVault(loadVaultBlob(), vaultPassword != null ? vaultPassword : ''); } catch {
+ ui.pw = { purpose: 'passkeyOn', mode: 'enter', v0: '', v1: '', v2: '', error: '' };
+ render(); return;
+ }
+ mergeVaultList(list);
+ }
+ let made;
+ try {
+ const pass = await import('./passkey.js');
+ made = await pass.lockPasskeyCreate();
+ } catch (e) {
+ const pass = await import('./passkey.js').catch(() => null);
+ if (!(pass && pass.passkeyCancelled(e))) toast(t('passkeyLockFailed', { msg: (e && e.message) || String(e) }), 4000);
+ render(); return;
+ }
+ vaultPassword = made.secret;
+ vaultPasskeyId = made.id;
+ // a wallet that only lives in this tab joins the vault, or it couldn't come back
+ const active = activeAccount();
+ if (active && active.type === 'full' && !active.provisional) active.persisted = true;
+ writeVault();
+ persistAccounts();
+ markLockPwAsked();
+ ui.pw = null;
+ if (lockAfter) { softLock(); return; }
+ render();
+ toast(t('passkeyLockOnToast'));
+}
+// Back to no lock at all (the padlock offers a password or the passkey
+// again). Needs the passkey once, unless this session already holds it.
+async function disablePasskeyLock() {
+ let secret = passkeySecret(vaultPassword) ? vaultPassword : null;
+ if (!secret) {
+ const s2 = await passkeyVaultSecret();
+ if (!s2) return;
+ secret = s2;
+ }
+ let list;
+ try { list = decryptVault(loadVaultBlob(), secret); } catch { toast(t('pwWrong')); return; }
+ mergeVaultList(list);
+ vaultPassword = '';
+ vaultPasskeyId = null;
+ writeVault();
+ persistAccounts();
+ render();
+ toast(t('passkeyLockOffToast'));
+}
+// The fingerprint or face check: the vault's secret, or null (cancelled or
+// failed — a failure is shown where the unlock was asked for).
+async function passkeyVaultSecret() {
+ const id = vaultPasskey();
+ if (!id) return null;
+ const pass = await import('./passkey.js');
+ try { return await pass.lockPasskeyUnlock(id); } catch (e) {
+ if (!pass.passkeyCancelled(e)) { ui.vaultError = (e && e.message) || String(e); toast(ui.vaultError, 4000); render(); }
+ return null;
+ }
+}
+async function unlockVaultWithPasskey() {
+ ui.vaultError = '';
+ const secret = await passkeyVaultSecret();
+ if (!secret) return;
+ ui.vaultPw = secret;
+ unlockVault();
+ ui.vaultPw = '';
+}
+// The password prompts that only need the vault opened (save, forget, lock
+// a stray wallet) take the passkey instead when that's what locks it.
+async function submitPwWithPasskey() {
+ const p = ui.pw;
+ const secret = await passkeyVaultSecret();
+ if (!secret || ui.pw !== p) return;
+ p.v1 = secret;
+ submitPw();
+}
+
// Try to decrypt the vault with the given password; on success, seed the
// accounts (watch-only directory + the unlocked full wallets). Returns success.
function attemptVaultUnlock(password) {
let list;
try { list = decryptVault(loadVaultBlob(), password); } catch { return false; }
vaultPassword = password;
+ vaultPasskeyId = passkeySecret(password) ? vaultPasskey() : null;
// Start from the durable directory (watch-only views of every known wallet),
// then upgrade the vault-saved ones to spendable — so non-saved wallets still
// appear (watch-only) instead of vanishing.
@@ -2513,6 +2618,7 @@ function lock({ offerPassword = false } = {}) {
for (const f of FEATURES) { try { f.stop && f.stop(); } catch {} }
clearAccounts();
vaultPassword = null;
+ vaultPasskeyId = null;
wallet.load({ mnemonic: '', passphrase: '', netName: getNetwork(), offline: false });
wallet.mnemonic = '';
// Wallets save themselves to this device without asking, under an empty
@@ -2652,6 +2758,7 @@ function softLock() {
_featuresInited = false;
for (const f of FEATURES) { try { f.stop && f.stop(); } catch {} }
vaultPassword = null;
+ vaultPasskeyId = null;
accounts = watch.slice();
persistAccounts();
const cur = accounts.find((a) => a.id === keepId) || accounts[0];
@@ -2674,7 +2781,7 @@ function lockBtn() {
if (attemptVaultUnlock('')) {
const cur = accounts.find((a) => a.id === activeId) || accounts[0];
if (cur) activateAccount(cur, { fresh: true });
- } else { ui.justLocked = false; ui.screen = 'vault'; render(); }
+ } else { ui.justLocked = false; ui.screen = 'vault'; ui.passkeyAsk = true; render(); }
}
: () => softLock(),
}, h('span', { style: 'font-size:15px;line-height:1;margin-right:6px' }, locked ? '\u{1F512}' : '\u{1F513}'), locked ? t('unlock') : t('lockWallet'));
@@ -3289,7 +3396,7 @@ function accountsScreen() {
h('button', { class: 'btn-block', onClick: signInAnother }, t('signInAnother')),
lockBtn(),
activeAccount() ? h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.logoutConfirm = true; render(); } }, t('logout')) : null,
- hasVault() ? h('button', { class: 'btn-ghost btn-block', onClick: startChangePw }, t('changePassword')) : null,
+ hasVault() && !vaultPasskey() ? h('button', { class: 'btn-ghost btn-block', onClick: startChangePw }, t('changePassword')) : null,
h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.confirmClear = true; render(); } }, t('clearAll'))
),
h('button', { class: 'btn-ghost btn-block', onClick: () => goBack(() => { ui.screen = 'wallet'; }) }, t('back'))
@@ -3486,10 +3593,24 @@ function securityCard() {
return h('div', { class: 'card col', style: 'gap:8px' },
h('h3', {}, t('securityTitle')),
lockBtn(),
- hasVault() ? h('button', { class: 'btn-ghost btn-block', onClick: startChangePw }, t('changePassword')) : null,
+ passkeyLockRow(),
+ hasVault() && !vaultPasskey() ? h('button', { class: 'btn-ghost btn-block', onClick: startChangePw }, t('changePassword')) : null,
anon ? h('button', { class: 'btn-block', onClick: signInAnother }, t('signInAnother')) : null,
anon ? h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.logoutConfirm = true; render(); } }, t('logout')) : null);
}
+// Settings → Security: lock with a passkey, or say that it is and offer the
+// way back. Only where the browser does passkeys at all.
+function passkeyLockRow() {
+ if (vaultPasskey()) {
+ return h('div', { class: 'col', style: 'gap:6px' },
+ h('div', { class: 'small muted' }, '\u{1F510} ' + t('passkeyLockOnNote')),
+ h('button', { class: 'btn-ghost btn-block', onClick: disablePasskeyLock }, t('passkeyLockOff')));
+ }
+ if (typeof window === 'undefined' || !window.PublicKeyCredential || !navigator.credentials) return null;
+ return h('div', { class: 'col', style: 'gap:6px' },
+ h('button', { class: 'btn-block', onClick: () => enablePasskeyLock() }, '\u{1F510} ' + t('passkeyLockBtn')),
+ h('div', { class: 'small muted' }, t('passkeyLockDesc')));
+}
function accountSettingsScreen() {
if (ui.pw) return h('div', { class: 'col', style: 'gap:16px' }, brandHeader(false), pwPromptCard());
if (ui.logoutConfirm) return logoutConfirmScreen();
@@ -3534,22 +3655,36 @@ function pwPromptCard() {
const atLock = p.purpose === 'lock';
const newish = change || p.mode === 'set'; // entering a (new) password with confirm
const atLockSave = p.purpose === 'locksave'; // locking, but the vault needs its password first
+ // opening the vault when a passkey, not a password, is what locks it
+ const byPasskey = p.mode === 'enter' && !change && p.purpose !== 'passkeyOn' && !!vaultPasskey();
return h('div', { class: 'card col' },
h('h3', {}, atLock ? t('lockPwTitle') : atLockSave ? t('lockWallet') : change ? t('changePassword') : p.mode === 'set' ? t('setPassword') : t('enterPassword')),
h('p', { class: 'small muted', style: 'margin:0' },
- atLock ? t('lockPwDesc') : change ? t('changePasswordDesc') : atLockSave ? t('lockSaveDesc') : p.mode === 'set' ? t('setPasswordDesc') : t('enterPasswordDesc')),
+ atLock ? t('lockPwDesc') : change ? t('changePasswordDesc') : byPasskey ? t('passkeyEnterDesc') : atLockSave ? t('lockSaveDesc') : p.mode === 'set' ? t('setPasswordDesc') : t('enterPasswordDesc')),
+ // the padlock's offer: a fingerprint or face instead of a password
+ atLock && window.PublicKeyCredential ? h('button', { class: 'btn-primary btn-block', onClick: () => enablePasskeyLock({ lockAfter: true }) }, '\u{1F510} ' + t('passkeyLockBtn')) : null,
+ atLock && window.PublicKeyCredential ? h('div', { class: 'small muted', style: 'text-align:center' }, t('lockPwOrPassword')) : null,
+ byPasskey ? h('button', { class: 'btn-primary btn-block', onClick: submitPwWithPasskey }, '\u{1F510} ' + t('passkeyUnlock')) : null,
change ? h('input', { type: 'password', placeholder: t('currentPassword'), value: p.v0, onInput: (e) => (p.v0 = e.target.value) }) : null,
- h('input', { type: 'password', placeholder: newish ? t('passwordOptional') : t('password'), value: p.v1, onInput: (e) => (p.v1 = e.target.value) }),
+ byPasskey ? null : h('input', { type: 'password', placeholder: newish ? t('passwordOptional') : t('password'), value: p.v1, onInput: (e) => (p.v1 = e.target.value) }),
newish ? h('input', { type: 'password', placeholder: t('confirmPassword'), value: p.v2, onInput: (e) => (p.v2 = e.target.value) }) : null,
p.error && h('div', { class: 'notice err' }, p.error),
h('div', { class: 'row gap6' },
h('button', { class: 'btn-ghost grow', onClick: cancelPw }, atLock ? t('lockPwSkip') : t('back')),
- h('button', { class: 'btn-primary grow', onClick: submitPw }, newish ? t('save') : atLockSave ? t('lockAction') : t('unlock'))
+ byPasskey ? null : h('button', { class: (atLock ? 'btn-ghost' : 'btn-primary') + ' grow', onClick: submitPw }, newish ? t('save') : atLockSave ? t('lockAction') : t('unlock'))
)
);
}
+// A passkey-locked vault asks for the fingerprint as it opens — on the app's
+// own start and on the padlock's tap, not right after you locked it; once,
+// so a cancel leaves the button rather than a loop.
function vaultScreen() {
+ const byPasskey = !!vaultPasskey();
+ if (ui.passkeyAsk) {
+ ui.passkeyAsk = false;
+ if (byPasskey) setTimeout(() => { if (ui.screen === 'vault') unlockVaultWithPasskey(); }, 0);
+ }
return h(
'div',
{ class: 'col', style: 'gap:16px' },
@@ -3558,8 +3693,8 @@ function vaultScreen() {
h('div', { class: 'row gap6', style: 'align-items:center' },
h('span', { style: 'font-size:20px;line-height:1' }, '\u{1F512}'),
h('h3', { style: 'margin:0' }, ui.justLocked ? t('lockedTitle') : t('unlockSaved'))),
- h('p', { class: 'small muted', style: 'margin:0' }, ui.justLocked ? t('lockedDesc') : t('unlockSavedDesc')),
- h('input', { type: 'password', placeholder: t('password'), value: ui.vaultPw,
+ h('p', { class: 'small muted', style: 'margin:0' }, byPasskey ? t('passkeyLockedDesc') : ui.justLocked ? t('lockedDesc') : t('unlockSavedDesc')),
+ byPasskey ? null : h('input', { type: 'password', placeholder: t('password'), value: ui.vaultPw,
onInput: (e) => (ui.vaultPw = e.target.value), onKeyDown: (e) => { if (e.key === 'Enter') unlockVault(); } }),
h('div', { class: 'row between', style: 'align-items:center;gap:10px' },
h('span', { class: 'small muted', style: 'white-space:nowrap;flex-shrink:0' }, t('unlockForLabel')),
@@ -3569,7 +3704,10 @@ function vaultScreen() {
selected: (ui.unlockFor != null ? ui.unlockFor : Number(localStorage.getItem(UNLOCK_FOR_KEY)) || 0) === o.ms,
}, t(o.label))))),
ui.vaultError && h('div', { class: 'notice err' }, ui.vaultError),
- h('button', { class: 'btn-primary btn-block', onClick: unlockVault }, t('unlock')),
+ byPasskey
+ ? h('button', { class: 'btn-primary btn-block', onClick: unlockVaultWithPasskey }, '\u{1F510} ' + t('passkeyUnlock'))
+ : h('button', { class: 'btn-primary btn-block', onClick: unlockVault }, t('unlock')),
+ byPasskey ? h('div', { class: 'small muted', style: 'text-align:center' }, t('passkeyLostHint')) : null,
h('button', { class: 'btn-ghost btn-block', onClick: skipVault },
loadWatchAccounts().length ? t('viewWithoutPassword') : t('useAnotherWallet'))
)
diff --git a/src/features/nostrlogin.js b/src/features/nostrlogin.js
index 4f1f0de..f18b075 100644
--- a/src/features/nostrlogin.js
+++ b/src/features/nostrlogin.js
@@ -357,7 +357,7 @@ export function nostrLoginFeature(ctx) {
const sk = await pass.passkeySignIn();
await run(() => keySigner(sk));
} catch (e) {
- if (/derive a wallet key/.test(e.message)) { ui.nostrLoginError = e.message; render(); return; }
+ if (/derive a wallet key/.test(e.message) || e.message === pass.LOCK_PASSKEY) { ui.nostrLoginError = e.message; render(); return; }
try {
const sk = await pass.passkeyCreate();
await run(() => keySigner(sk));
diff --git a/src/i18n.js b/src/i18n.js
index eaa3fbd..7488821 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -151,6 +151,18 @@ const STR = {
lockPwTitle: 'Protect this device?',
lockPwDesc: 'Set a password to lock your wallet. Anyone using this device will need it to spend your money.',
lockPwSkip: 'Not now',
+ passkeyLockBtn: 'Lock with fingerprint or face',
+ passkeyLockDesc: 'Your saved wallets are encrypted with a passkey kept in your phone’s secure hardware (or your password manager). Opening them takes your fingerprint or face. Your recovery words still restore them if the passkey is ever lost.',
+ passkeyLockOnNote: 'Locked with your fingerprint or face (a passkey).',
+ passkeyLockOff: 'Turn off fingerprint lock',
+ passkeyLockOnToast: 'Fingerprint lock on',
+ passkeyLockOffToast: 'Fingerprint lock off',
+ passkeyLockFailed: 'Couldn’t use a passkey here: {msg}',
+ passkeyUnlock: 'Unlock with fingerprint or face',
+ passkeyLockedDesc: 'Your wallets are locked on this device. Use your fingerprint or face to open them.',
+ passkeyEnterDesc: 'Use your fingerprint or face to open the wallets saved on this device.',
+ passkeyLostHint: 'Lost the passkey? Your recovery words still restore every wallet.',
+ lockPwOrPassword: 'or set a password:',
unlockSaved: 'Unlock saved accounts',
unlockSavedDesc: 'Enter your password to open the accounts saved on this device.',
unlock: 'Unlock',
diff --git a/src/locales/it.json b/src/locales/it.json
index 0758259..11ecd10 100644
--- a/src/locales/it.json
+++ b/src/locales/it.json
@@ -1443,5 +1443,17 @@
"copy": "Copia",
"switchUnit": "Passa BTC / sats",
"footerMadeBy": "creato da",
- "footerSourceOn": "codice sorgente su"
-} \ No newline at end of file
+ "footerSourceOn": "codice sorgente su",
+ "passkeyLockBtn": "Blocca con impronta o volto",
+ "passkeyLockDesc": "I tuoi portafogli salvati sono cifrati con una passkey custodita nell’hardware sicuro del telefono (o nel tuo gestore di password). Per aprirli serve la tua impronta o il tuo volto. Se perdi la passkey, le parole di recupero li ripristinano comunque.",
+ "passkeyLockOnNote": "Bloccato con impronta o volto (una passkey).",
+ "passkeyLockOff": "Disattiva il blocco con impronta",
+ "passkeyLockOnToast": "Blocco con impronta attivo",
+ "passkeyLockOffToast": "Blocco con impronta disattivato",
+ "passkeyLockFailed": "Impossibile usare una passkey qui: {msg}",
+ "passkeyUnlock": "Sblocca con impronta o volto",
+ "passkeyLockedDesc": "I tuoi portafogli sono bloccati su questo dispositivo. Usa l’impronta o il volto per aprirli.",
+ "passkeyEnterDesc": "Usa l’impronta o il volto per aprire i portafogli salvati su questo dispositivo.",
+ "passkeyLostHint": "Hai perso la passkey? Le parole di recupero ripristinano comunque ogni portafoglio.",
+ "lockPwOrPassword": "oppure imposta una password:"
+}
diff --git a/src/passkey.js b/src/passkey.js
index 4bbd01d..248e1e6 100644
--- a/src/passkey.js
+++ b/src/passkey.js
@@ -27,23 +27,28 @@ function skFromPrf(prf) {
const NO_PRF = 'this device’s passkeys can’t derive a wallet key (no PRF support)';
-async function assertionPrf(allowCredentials) {
+async function assertion(salt, allowCredentials) {
const cred = await navigator.credentials.get({
publicKey: {
challenge: crypto.getRandomValues(new Uint8Array(32)),
userVerification: 'required',
...(allowCredentials ? { allowCredentials } : {}),
- extensions: { prf: { eval: { first: PRF_SALT } } },
+ extensions: { prf: { eval: { first: salt } } },
},
});
const out = cred && cred.getClientExtensionResults().prf?.results?.first;
if (!out) throw new Error(NO_PRF);
- return new Uint8Array(out);
+ return { prf: new Uint8Array(out), cred };
}
+const assertionPrf = async (allowCredentials) => (await assertion(PRF_SALT, allowCredentials)).prf;
-// Sign in with an existing (discoverable) passkey.
+// Sign in with an existing (discoverable) passkey. A device's lock passkey
+// (below) is refused: its PRF would derive a stranger's key, silently.
+export const LOCK_PASSKEY = 'that passkey locks a wallet on this device — it isn’t a sign-in passkey';
export async function passkeySignIn() {
- return skFromPrf(await assertionPrf(null));
+ const { prf, cred } = await assertion(PRF_SALT, null);
+ if (isLockHandle(cred)) throw new Error(LOCK_PASSKEY);
+ return skFromPrf(prf);
}
// Create a passkey, then derive the key from it. Some platforms evaluate PRF
@@ -69,3 +74,58 @@ export async function passkeyCreate() {
if (ext.prf && ext.prf.enabled === false) throw new Error(NO_PRF);
return skFromPrf(await assertionPrf([{ type: 'public-key', id: cred.rawId }]));
}
+
+// ---------------------------------------------------------------------------
+// Lock with a passkey: the saved-wallets vault encrypted under a secret only
+// the passkey can produce (its PRF, behind a fingerprint or face), instead of
+// a password. The passkey lives in the platform's secure hardware — Google
+// Password Manager, iCloud Keychain, a security key — so a copy of this
+// browser's storage holds only ciphertext. A different salt from sign-in's,
+// and a user handle that marks it, so the two can never be mistaken.
+const LOCK_SALT = sha256(new TextEncoder().encode('coinos-vault-lock-v1'));
+const LOCK_TAG = new TextEncoder().encode('coinos-vault-lock-key:v1');
+const LOCK_HANDLE = new TextEncoder().encode('coinos-lock:');
+function isLockHandle(cred) {
+ const u = cred && cred.response && cred.response.userHandle;
+ if (!u) return false;
+ const b = new Uint8Array(u);
+ return b.length > LOCK_HANDLE.length && LOCK_HANDLE.every((x, i) => b[i] === x);
+}
+const b64u = (b) => btoa(String.fromCharCode(...new Uint8Array(b))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
+const unb64u = (s) => Uint8Array.from(atob(s.replace(/-/g, '+').replace(/_/g, '/')), (c) => c.charCodeAt(0));
+// The vault password a lock passkey stands for: never typed, never shown.
+function lockSecret(prf) {
+ const buf = new Uint8Array(LOCK_TAG.length + prf.length);
+ buf.set(LOCK_TAG, 0); buf.set(prf, LOCK_TAG.length);
+ return 'passkey:' + Array.from(sha256(buf), (x) => x.toString(16).padStart(2, '0')).join('');
+}
+
+// A new lock passkey for this device: { id, secret }. Not discoverable where
+// the platform lets us choose, so it stays out of the sign-in picker.
+export async function lockPasskeyCreate() {
+ const handle = new Uint8Array(LOCK_HANDLE.length + 16);
+ handle.set(LOCK_HANDLE, 0); handle.set(crypto.getRandomValues(new Uint8Array(16)), LOCK_HANDLE.length);
+ const cred = await navigator.credentials.create({
+ publicKey: {
+ challenge: crypto.getRandomValues(new Uint8Array(32)),
+ rp: { name: 'Coinos', id: location.hostname },
+ user: { id: handle, name: 'coinos wallet lock', displayName: 'Coinos wallet lock' },
+ pubKeyCredParams: [{ type: 'public-key', alg: -7 }, { type: 'public-key', alg: -257 }],
+ authenticatorSelection: { residentKey: 'discouraged', userVerification: 'required' },
+ extensions: { prf: { eval: { first: LOCK_SALT } } },
+ },
+ });
+ const id = b64u(cred.rawId);
+ const ext = cred.getClientExtensionResults();
+ const direct = ext.prf?.results?.first;
+ if (direct) return { id, secret: lockSecret(new Uint8Array(direct)) };
+ if (ext.prf && ext.prf.enabled === false) throw new Error(NO_PRF);
+ return { id, secret: await lockPasskeyUnlock(id) };
+}
+// The fingerprint or face check that opens the vault: its secret.
+export async function lockPasskeyUnlock(id) {
+ const { prf } = await assertion(LOCK_SALT, [{ type: 'public-key', id: unb64u(id) }]);
+ return lockSecret(prf);
+}
+// A dismissed dialog is a plain cancel, not an error worth showing.
+export const passkeyCancelled = (e) => /NotAllowed|AbortError|abort/i.test(String((e && e.name) || '') + ' ' + String((e && e.message) || ''));
diff --git a/tools/passkey-lock-test.js b/tools/passkey-lock-test.js
new file mode 100644
index 0000000..0d2346a
--- /dev/null
+++ b/tools/passkey-lock-test.js
@@ -0,0 +1,112 @@
+// Lock with a fingerprint or face: the padlock's "Protect this device?" offers
+// a passkey instead of a password. The saved-wallets vault is then sealed
+// under a secret only that passkey's PRF produces — nothing in this browser's
+// storage opens it — and unlocking is the passkey's check, asked for as the
+// locked screen opens. Turning it off returns to an unlocked vault. Chrome's
+// virtual authenticator (CDP WebAuthn, with PRF) plays the phone.
+// Run: bun tools/passkey-lock-test.js
+import puppeteer from './puppeteer.js';
+import { generateMnemonic } from '@scure/bip39';
+import { wordlist } from '@scure/bip39/wordlists/english';
+import { decryptVault } from '../src/wallet.js';
+import { buildHtml } from '../build.js';
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+let ok = true;
+const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
+const html = await buildHtml({ minify: true, pwa: false });
+const server = Bun.serve({ port: 5314, fetch: (req) => {
+ const p = new URL(req.url).pathname;
+ if (p.endsWith('.js') && p !== '/') { const f = Bun.file('dist' + p); return f.size ? new Response(f, { headers: { 'content-type': 'text/javascript' } }) : new Response('', { status: 404 }); }
+ return new Response(html, { headers: { 'content-type': 'text/html' } });
+} });
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
+const page = await browser.newPage();
+const cdp = await page.createCDPSession();
+await cdp.send('WebAuthn.enable');
+const { authenticatorId } = await cdp.send('WebAuthn.addVirtualAuthenticator', { options: {
+ protocol: 'ctap2', ctap2Version: 'ctap2_1', transport: 'internal', hasResidentKey: true,
+ hasUserVerification: true, isUserVerified: true, hasPrf: true, automaticPresenceSimulation: true } });
+await page.evaluateOnNewDocument(() => {
+ window.__gets = [];
+ const real = navigator.credentials.get.bind(navigator.credentials);
+ navigator.credentials.get = (o) => {
+ const r = { at: Date.now() }; window.__gets.push(r);
+ // the person cancels the next fingerprint dialog
+ if (localStorage.getItem('__refuseNext')) { localStorage.removeItem('__refuseNext'); r.err = 'refused'; return Promise.reject(new DOMException('The operation either timed out or was not allowed.', 'NotAllowedError')); }
+ return real(o).then((c) => { r.ok = true; return c; }, (e) => { r.err = e.name + ': ' + e.message; throw e; }); };
+});
+const errs = [];
+page.on('pageerror', (e) => errs.push(String(e)));
+const click = (sel, t) => page.evaluate((s, x) => { const e = [...document.querySelectorAll(s)].find((n) => n.textContent.trim().toLowerCase().includes(x.toLowerCase())); if (e) { e.click(); return true; } return false; }, sel, t);
+const bodyText = () => page.evaluate(() => document.body.innerText);
+const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await bodyText()).toLowerCase().includes(x.toLowerCase())) return true; await sleep(250); } return false; };
+const vault = () => page.evaluate(() => JSON.parse(localStorage.getItem('btc-wallet-vault') || 'null'));
+const opensBlank = (b) => { try { decryptVault(b, ''); return true; } catch { return false; } };
+// the padlock on the wallet's settings page: "Unlock" when locked, "Lock wallet" when open
+const padlockSays = async () => {
+ await page.evaluate(() => document.querySelector('.balance-gear')?.click());
+ await sleep(700);
+ const s = await page.evaluate(() => { const b = [...document.querySelectorAll('button')].find((e) => /^(unlock|lock wallet)$/i.test(e.getAttribute('aria-label') || '')); return b ? b.getAttribute('aria-label') : null; });
+ return s;
+};
+try {
+ await page.setViewport({ width: 390, height: 844 });
+ await page.goto('http://localhost:5314/', { waitUntil: 'domcontentloaded' });
+ await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await sleep(400);
+ const mn = generateMnemonic(wordlist);
+ await click('button', 'create a new wallet'); await sleep(500);
+ await click('button', 'Have an existing seed'); await sleep(400);
+ await page.waitForSelector('textarea');
+ await page.type('textarea', mn);
+ await click('button', 'Open wallet');
+ await waitText('receive', 20000);
+ await sleep(800);
+ check('the wallet saved itself, under no password', opensBlank(await vault()));
+
+ console.log('\n[the padlock offers a fingerprint]');
+ await page.evaluate(() => document.querySelector('.balance-gear')?.click());
+ await sleep(900);
+ await page.evaluate(() => { const b = [...document.querySelectorAll('button')].find((e) => /lock/i.test(e.getAttribute('aria-label') || '')); b && b.click(); });
+ await sleep(500);
+ check('"Protect this device?" offers fingerprint or face', await waitText('lock with fingerprint or face', 3000));
+ await click('button', 'lock with fingerprint or face');
+ let v = null;
+ for (let i = 0; i < 40; i++) { v = await vault(); if (v && v.passkey) break; await sleep(250); }
+ check('the vault names its passkey', !!(v && v.passkey), JSON.stringify(v && Object.keys(v)));
+ check('...and no longer opens without it', v && !opensBlank(v));
+ check('...and certainly not with the seed lying around in storage', !(await page.evaluate((m) => Object.keys(localStorage).some((k) => (localStorage.getItem(k) || '').includes(m)), mn)));
+ const creds = (await cdp.send('WebAuthn.getCredentials', { authenticatorId })).credentials;
+ check('one passkey made, marked as a lock (user handle)', creds.length === 1 && Buffer.from(creds[0].userHandle || '', 'base64').toString().startsWith('coinos-lock:'), JSON.stringify(creds.map((c) => c.isResidentCredential)));
+ await sleep(600);
+ check('the wallet is locked in place', /^unlock$/i.test(await padlockSays() || ''));
+
+ console.log('\n[a fresh start: the fingerprint opens it]');
+ // a new browser session: the tab's copy of the keys is gone. The first
+ // fingerprint check fails (as a cancel would) — the screen must wait.
+ await page.evaluate(() => { sessionStorage.clear(); localStorage.setItem('__refuseNext', '1'); });
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ check('the app opens on the locked screen, which speaks of the fingerprint', await waitText('use your fingerprint or face', 8000));
+ await sleep(1500);
+ check('...with no password field, and still there after a refused check', /use your fingerprint or face/i.test(await bodyText()) && !(await page.$('input[type=password]')));
+ const before = (await cdp.send('WebAuthn.getCredentials', { authenticatorId })).credentials[0].signCount;
+ await click('button', 'unlock with fingerprint or face');
+ await sleep(1500);
+ check('the first ask came by itself, on opening', (await page.evaluate(() => window.__gets)).some((g) => g.err === 'refused'));
+ const after = (await cdp.send('WebAuthn.getCredentials', { authenticatorId })).credentials[0].signCount;
+ check('the button asks the passkey', after > before, before + ' → ' + after);
+ check('...and the wallet opens, spendable', /^lock wallet$/i.test(await padlockSays() || ''));
+ console.log('\n[turning it off]');
+ check('Security says it\'s on and offers to turn it off', await waitText('turn off fingerprint lock', 3000));
+ check('...and no "Change password" for a password that doesn\'t exist', !/change password/i.test(await bodyText()));
+ await click('button', 'turn off fingerprint lock');
+ for (let i = 0; i < 20; i++) { v = await vault(); if (v && !v.passkey) break; await sleep(250); }
+ check('the vault opens without a passkey again', v && !v.passkey && opensBlank(v));
+ check('Security offers the fingerprint lock again', await waitText('lock with fingerprint or face', 3000));
+ check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
+} finally {
+ await browser.close(); server.stop(true);
+}
+console.log(ok ? '\n✅ fingerprint lock works' : '\n❌ failed');
+process.exit(ok ? 0 : 1);