diff options
| author | Adam Soltys <[email protected]> | 2026-08-26 21:03:07 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-08-26 21:03:07 -0700 |
| commit | 358ffb856892bff8e99287d3debc5937ed5183ef (patch) | |
| tree | 21cd9a381ebd1aa620d1db4ae965ca27a338ac1b /src/passkey.js | |
| parent | 181cf4d5d0ffb7ae3ccd337745a16619a35eaae1 (diff) | |
| download | coinosv3-358ffb856892bff8e99287d3debc5937ed5183ef.tar.gz coinosv3-358ffb856892bff8e99287d3debc5937ed5183ef.zip | |
Two new doors, no new locks: "Sign in with Google" (pomegranate — the nostr key FROST-sharded 2-of-3 across community operators, Google as coordinator and recovery, a plain bunker:// URI feeding the existing signer flow) and passkey sign-in (WebAuthn PRF derives the key deterministically, treated exactly like a pasted nsec, synced wherever the platform carries the passkey) — both lazy chunks, neither touching the first paint
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_019eA8JRq9Wr7z2fRQk99HLi
Diffstat (limited to 'src/passkey.js')
| -rw-r--r-- | src/passkey.js | 71 |
1 files changed, 71 insertions, 0 deletions
diff --git a/src/passkey.js b/src/passkey.js new file mode 100644 index 0000000..4bbd01d --- /dev/null +++ b/src/passkey.js @@ -0,0 +1,71 @@ +// 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 assertionPrf(allowCredentials) { + const cred = await navigator.credentials.get({ + publicKey: { + challenge: crypto.getRandomValues(new Uint8Array(32)), + userVerification: 'required', + ...(allowCredentials ? { allowCredentials } : {}), + extensions: { prf: { eval: { first: PRF_SALT } } }, + }, + }); + const out = cred && cred.getClientExtensionResults().prf?.results?.first; + if (!out) throw new Error(NO_PRF); + return new Uint8Array(out); +} + +// Sign in with an existing (discoverable) passkey. +export async function passkeySignIn() { + return skFromPrf(await assertionPrf(null)); +} + +// 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 }])); +} |
