diff options
Diffstat (limited to 'src/passkey.js')
| -rw-r--r-- | src/passkey.js | 70 |
1 files changed, 65 insertions, 5 deletions
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) || '')); |
