// Passkey-based key wrapping via the WebAuthn PRF extension. // // A passkey with PRF returns a stable 32-byte secret for a given salt, but only // after the user verifies (Face ID, fingerprint, device PIN). We turn that into // an AES-GCM key with HKDF and use it to encrypt the nostr key before it goes // to Drive. Result: Google access alone is no longer enough to read the key. // // Passkeys are bound to the site's domain (rpId). A key protected on // goostr.example.com can only be unlocked on goostr.example.com. const enc = new TextEncoder(); const rand = (n) => crypto.getRandomValues(new Uint8Array(n)); export const passkeysAvailable = () => !!window.PublicKeyCredential; async function toAesKey(prfOutput) { const ikm = await crypto.subtle.importKey("raw", prfOutput, "HKDF", false, ["deriveKey"]); return crypto.subtle.deriveKey( { name: "HKDF", hash: "SHA-256", salt: new Uint8Array(), info: enc.encode("goostr key-wrap v1") }, ikm, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"] ); } function notSupported() { return new Error( "This passkey provider can't do key encryption (the WebAuthn PRF extension). " + "Use a recent Chrome, Edge or Safari with Google Password Manager, iCloud Keychain or a compatible security key." ); } // Create a new passkey and return { credId, salt, aesKey }. Asks for the PRF // output during creation where supported; otherwise follows up with get(). export async function createPasskeyKey({ userId, name, displayName }) { const salt = rand(32); const cred = await navigator.credentials.create({ publicKey: { rp: { name: "Goostr" }, user: { id: userId, name, displayName }, challenge: rand(32), pubKeyCredParams: [ { type: "public-key", alg: -7 }, // ES256 { type: "public-key", alg: -257 }, // RS256 ], authenticatorSelection: { residentKey: "preferred", userVerification: "required" }, extensions: { prf: { eval: { first: salt } } }, }, }); const ext = cred.getClientExtensionResults().prf; if (ext?.enabled === false) throw notSupported(); const credId = new Uint8Array(cred.rawId); const first = ext?.results?.first; const aesKey = first ? await toAesKey(first) : await getPasskeyKey({ credId, salt }); return { credId, salt, aesKey }; } // Ask the user to verify with an existing passkey and derive the AES key. export async function getPasskeyKey({ credId, salt }) { const assertion = await navigator.credentials.get({ publicKey: { challenge: rand(32), allowCredentials: [{ type: "public-key", id: credId }], userVerification: "required", extensions: { prf: { eval: { first: salt } } }, }, }); const first = assertion.getClientExtensionResults().prf?.results?.first; if (!first) throw notSupported(); return toAesKey(first); } // AES-GCM with the pubkey as associated data, so a ciphertext can't be // swapped between identities unnoticed. export async function wrapKey(aesKey, keyBytes, pubkeyHex) { const iv = rand(12); const ct = new Uint8Array( await crypto.subtle.encrypt({ name: "AES-GCM", iv, additionalData: enc.encode(pubkeyHex) }, aesKey, keyBytes) ); return { iv, ct }; } export async function unwrapKey(aesKey, { iv, ct }, pubkeyHex) { try { return new Uint8Array( await crypto.subtle.decrypt({ name: "AES-GCM", iv, additionalData: enc.encode(pubkeyHex) }, aesKey, ct) ); } catch { throw new Error("That passkey doesn't unlock this key. Use the passkey you protected it with."); } }