// Passkey sign-in: WebAuthn's PRF extension turns a passkey into a // deterministic 32-byte secret, which hashes into a nostr key — and the // login flow then treats it exactly like a pasted nsec, so the wallet seed // derives from it the usual way. No server anywhere: the wallet follows the // passkey wherever the platform syncs it (iCloud Keychain, Google Password // Manager), and the same passkey always opens the same wallet because the // PRF salt is a fixed app-scoped constant. import { sha256 } from '@noble/hashes/sha256'; import { secp256k1 } from '@noble/curves/secp256k1'; const PRF_SALT = sha256(new TextEncoder().encode('coinos-passkey-v1')); const DERIVE_TAG = new TextEncoder().encode('coinos-passkey-key:v1'); export const passkeySupported = () => typeof window !== 'undefined' && !!window.PublicKeyCredential && !!navigator.credentials; // Hash the PRF output into a valid secp256k1 scalar. An out-of-range hash is // a ~2^-128 event, but the loop makes the derivation total rather than lucky. function skFromPrf(prf) { let buf = new Uint8Array(DERIVE_TAG.length + prf.length); buf.set(DERIVE_TAG, 0); buf.set(prf, DERIVE_TAG.length); let sk = sha256(buf); while (!secp256k1.utils.isValidPrivateKey(sk)) sk = sha256(sk); return sk; } const NO_PRF = 'this device’s passkeys can’t derive a wallet key (no PRF support)'; 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: salt } } }, }, }); const out = cred && cred.getClientExtensionResults().prf?.results?.first; if (!out) throw new Error(NO_PRF); return { prf: new Uint8Array(out), cred }; } const assertionPrf = async (allowCredentials) => (await assertion(PRF_SALT, allowCredentials)).prf; // 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() { 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 // during creation; the rest need a follow-up assertion against the new // credential — both paths end at the same bytes. export async function passkeyCreate() { const cred = await navigator.credentials.create({ publicKey: { challenge: crypto.getRandomValues(new Uint8Array(32)), rp: { name: 'Coinos', id: location.hostname }, user: { id: crypto.getRandomValues(new Uint8Array(16)), name: 'coinos', displayName: 'Coinos wallet', }, pubKeyCredParams: [{ type: 'public-key', alg: -7 }, { type: 'public-key', alg: -257 }], authenticatorSelection: { residentKey: 'required', userVerification: 'required' }, extensions: { prf: { eval: { first: PRF_SALT } } }, }, }); const ext = cred.getClientExtensionResults(); const direct = ext.prf?.results?.first; if (direct) return skFromPrf(new Uint8Array(direct)); 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) || ''));