diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/app.js | 160 | ||||
| -rw-r--r-- | src/features/nostrlogin.js | 2 | ||||
| -rw-r--r-- | src/i18n.js | 12 | ||||
| -rw-r--r-- | src/locales/it.json | 16 | ||||
| -rw-r--r-- | src/passkey.js | 70 |
5 files changed, 241 insertions, 19 deletions
@@ -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) || '')); |
