From 757a59c5c9c4b56120446cae1ac495773b9905c8 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Wed, 8 Apr 2026 22:33:43 -0700 Subject: breez style passkey derivation --- src/components/Mnemonic.svelte | 7 +- src/lib/keyWrapping.ts | 17 +++- src/lib/passkey.ts | 110 ++++++++++++++++++++++-- src/locales/en.json | 4 + src/routes/(app)/settings/security/+page.svelte | 53 +++++++++++- src/routes/(static)/login/+page.svelte | 4 +- 6 files changed, 178 insertions(+), 17 deletions(-) (limited to 'src') diff --git a/src/components/Mnemonic.svelte b/src/components/Mnemonic.svelte index e00197fc..dd1772d0 100644 --- a/src/components/Mnemonic.svelte +++ b/src/components/Mnemonic.svelte @@ -1,12 +1,15 @@
- {#each mnemonic.split(" ") as w, i} + {#each words as w, i}
{i + 1}. diff --git a/src/lib/keyWrapping.ts b/src/lib/keyWrapping.ts index 31cb1e5c..cd68947f 100644 --- a/src/lib/keyWrapping.ts +++ b/src/lib/keyWrapping.ts @@ -28,9 +28,20 @@ export async function resolveCanonicalKey( oldPrfKey: ArrayBuffer, ): Promise<{ canonicalKey: ArrayBuffer; encryptedKeys: Record }> { if (encryptedKeys && encryptedKeys[methodId]) { - const canonicalKey = await unwrapCanonicalKey(wrappingKey, encryptedKeys[methodId]); - rememberPrfKey(canonicalKey, defaultRememberForMs); - return { canonicalKey, encryptedKeys }; + try { + // Try new wrapping key first (e.g. PRF-derived) + const canonicalKey = await unwrapCanonicalKey(wrappingKey, encryptedKeys[methodId]); + rememberPrfKey(canonicalKey, defaultRememberForMs); + return { canonicalKey, encryptedKeys }; + } catch { + // Fallback: try old key (e.g. SHA-256(credentialId)) and re-wrap with new key + const canonicalKey = await unwrapCanonicalKey(oldPrfKey, encryptedKeys[methodId]); + const wrapped = await wrapCanonicalKey(wrappingKey, canonicalKey); + const updated = { ...encryptedKeys, [methodId]: wrapped }; + await saveEncryptedKeys(updated); + rememberPrfKey(canonicalKey, defaultRememberForMs); + return { canonicalKey, encryptedKeys: updated }; + } } // Migration: old PRF key becomes canonical key diff --git a/src/lib/passkey.ts b/src/lib/passkey.ts index 3d7b7272..8e73144b 100644 --- a/src/lib/passkey.ts +++ b/src/lib/passkey.ts @@ -1,35 +1,129 @@ -import { startRegistration, startAuthentication } from "@simplewebauthn/browser"; +import { startRegistration } from "@simplewebauthn/browser"; import { post } from "$lib/utils"; -async function deriveKey(credentialId: string): Promise { +const PRF_SALT = new TextEncoder().encode("coinos-wallet-key"); + +function bufferToBase64url(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer); + let binary = ""; + for (const b of bytes) binary += String.fromCharCode(b); + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +function base64urlToBuffer(base64url: string): ArrayBuffer { + const padded = + base64url.replace(/-/g, "+").replace(/_/g, "/") + + "==".slice(0, (4 - (base64url.length % 4)) % 4); + const binary = atob(padded); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + return bytes.buffer as ArrayBuffer; +} + +async function deriveKeyLegacy(credentialId: string): Promise { const data = new TextEncoder().encode("coinos-wallet-key:" + credentialId); return crypto.subtle.digest("SHA-256", data); } +async function evaluatePrf(credentialId: string): Promise { + try { + const assertion = (await navigator.credentials.get({ + publicKey: { + challenge: crypto.getRandomValues(new Uint8Array(32)), + allowCredentials: [ + { id: base64urlToBuffer(credentialId), type: "public-key" as const }, + ], + extensions: { prf: { eval: { first: PRF_SALT } } } as any, + userVerification: "required", + }, + })) as PublicKeyCredential; + + const results = (assertion.getClientExtensionResults() as any).prf?.results; + return results?.first || null; + } catch { + return null; + } +} + function authHeader(token: string) { return { authorization: `Bearer ${token}` }; } export async function registerPasskey( token?: string, -): Promise<{ prfKey: ArrayBuffer; credentialId: string }> { +): Promise<{ prfKey: ArrayBuffer; credentialId: string; prfSupported: boolean }> { const headers = token ? authHeader(token) : undefined; const origin = window.location.origin; const options = await post("/passkey/register/options", { origin }, headers); const registration = await startRegistration({ optionsJSON: options }); await post("/passkey/register/verify", { ...registration, origin }, headers); - const prfKey = await deriveKey(registration.id); - return { prfKey, credentialId: registration.id }; + + // Try PRF evaluation with an immediate get() after registration + const prfResult = await evaluatePrf(registration.id); + if (prfResult) { + return { prfKey: prfResult, credentialId: registration.id, prfSupported: true }; + } + + // Fallback to legacy SHA-256(credentialId) + const legacyKey = await deriveKeyLegacy(registration.id); + return { prfKey: legacyKey, credentialId: registration.id, prfSupported: false }; } export async function loginWithPasskey(): Promise<{ credential: any; challengeId: string; prfKey: ArrayBuffer; + legacyKey: ArrayBuffer; }> { const origin = window.location.origin; const { challengeId, ...options } = await post("/passkey/login/options", { origin }); - const authentication = await startAuthentication({ optionsJSON: options }); - const prfKey = await deriveKey(authentication.id); - return { credential: authentication, challengeId, prfKey }; + + // Convert options from JSON to WebAuthn API format + const publicKeyOptions: PublicKeyCredentialRequestOptions = { + challenge: base64urlToBuffer(options.challenge), + rpId: options.rpId, + timeout: options.timeout, + userVerification: (options.userVerification as UserVerificationRequirement) || "required", + allowCredentials: options.allowCredentials?.map((c: any) => ({ + id: base64urlToBuffer(c.id), + type: c.type as PublicKeyCredentialType, + transports: c.transports, + })), + extensions: { + prf: { eval: { first: PRF_SALT } }, + } as any, + }; + + const assertion = (await navigator.credentials.get({ + publicKey: publicKeyOptions, + })) as PublicKeyCredential; + + const response = assertion.response as AuthenticatorAssertionResponse; + + // Serialize response in the format the server expects (same as simplewebauthn) + const credential = { + id: assertion.id, + rawId: bufferToBase64url(assertion.rawId), + response: { + authenticatorData: bufferToBase64url(response.authenticatorData), + clientDataJSON: bufferToBase64url(response.clientDataJSON), + signature: bufferToBase64url(response.signature), + userHandle: response.userHandle ? bufferToBase64url(response.userHandle) : null, + }, + type: assertion.type, + clientExtensionResults: {}, + authenticatorAttachment: (assertion as any).authenticatorAttachment || null, + }; + + // Extract PRF result + const prfResults = (assertion.getClientExtensionResults() as any).prf?.results; + const prfOutput: ArrayBuffer | null = prfResults?.first || null; + + // Compute legacy key for migration + const legacyKey = await deriveKeyLegacy(assertion.id); + + // Use PRF output if available, otherwise fall back to legacy + const prfKey = prfOutput || legacyKey; + + return { credential, challengeId, prfKey, legacyKey }; } diff --git a/src/locales/en.json b/src/locales/en.json index 9130eba1..9f48911b 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -471,6 +471,10 @@ "revealNsec": "Reveal secret key", "hideSeed": "Hide seed phrase", "revealSeed": "Reveal seed phrase", + "recoveryPhrase": "Recovery Phrase", + "recoveryPhraseDescription": "Your master recovery phrase derives your nostr identity and all self-custodial wallet keys. Write it down and keep it safe.", + "showRecoveryPhrase": "Show Recovery Phrase", + "hideRecoveryPhrase": "Hide Recovery Phrase", "preferredDM": "Preferred DM Relays", "preferredDMDescription": "Direct Messages meant for you will be published to these relays. To get DMs on multiple relays, put each relay URL on its own line.", "noDMURLsWarning": "You haven't set any valid relay URLs. You will not be able to use DMs.", diff --git a/src/routes/(app)/settings/security/+page.svelte b/src/routes/(app)/settings/security/+page.svelte index 80d09ace..894536dd 100644 --- a/src/routes/(app)/settings/security/+page.svelte +++ b/src/routes/(app)/settings/security/+page.svelte @@ -4,10 +4,14 @@ import PhNumpadBold from "virtual:icons/ph/numpad-bold"; import PhDeviceMobileBold from "virtual:icons/ph/device-mobile-bold"; import PhFingerprintBold from "virtual:icons/ph/fingerprint-bold"; + import PhCopyBold from "virtual:icons/ph/copy-bold"; + import PhEyeBold from "virtual:icons/ph/eye-bold"; + import PhEyeSlashBold from "virtual:icons/ph/eye-slash-bold"; import { getContext } from "svelte"; import { tick } from "svelte"; import { t } from "$lib/translations"; import Pin from "$comp/Pin.svelte"; + import Mnemonic from "$comp/Mnemonic.svelte"; import Qr from "$comp/Qr.svelte"; import Spinner from "$comp/Spinner.svelte"; import { copy, post, success, fail, hashPin } from "$lib/utils"; @@ -35,7 +39,8 @@ nsec = $state(), otp: any = $state(), pin: any = $state(""), - revealSeed, + recoveryMnemonic: string | undefined = $state(), + revealSeed = $state(false), revealNsec = $state(), token: any = $state(""), setting2fa = $state(), @@ -58,10 +63,30 @@ }; let toggleSeed = async () => { + if (revealSeed) { + revealSeed = false; + recoveryMnemonic = undefined; + return; + } try { - revealSeed = !revealSeed; + let prfKey = getCachedPrfKey(); + if (!prfKey) { + const { getWalletEntropy } = await import("$lib/walletEntropy"); + prfKey = await getWalletEntropy(); + } + if (!prfKey) { + fail($t("accounts.failedToDecryptSeed")); + return; + } + const [{ entropyToMnemonic }, { wordlist }] = await Promise.all([ + import("@scure/bip39"), + import("@scure/bip39/wordlists/english.js"), + ]); + recoveryMnemonic = entropyToMnemonic(new Uint8Array(prfKey).slice(0, 16), wordlist); + revealSeed = true; } catch (e) { console.log(e); + fail($t("accounts.failedToDecryptSeed")); } }; @@ -268,3 +293,27 @@ Add Passkey
+ +
+ {$t("user.settings.recoveryPhrase")} +

{$t("user.settings.recoveryPhraseDescription")}

+ + + {#if revealSeed && recoveryMnemonic} +
+ + +
+ {/if} +
diff --git a/src/routes/(static)/login/+page.svelte b/src/routes/(static)/login/+page.svelte index 0132ac75..e73902ea 100644 --- a/src/routes/(static)/login/+page.svelte +++ b/src/routes/(static)/login/+page.svelte @@ -253,7 +253,7 @@ let passkeyLogin = async () => { passkeyLoading = true; try { - const { credential, challengeId, prfKey } = await loginWithPasskey(); + const { credential, challengeId, prfKey, legacyKey } = await loginWithPasskey(); const formData = new FormData(); formData.append("credential", JSON.stringify(credential)); @@ -273,7 +273,7 @@ passkeyMethodId(credential.id), prfKey, result.data?.encryptedKeys, - prfKey, + legacyKey, ); } catch (e) { console.log("Canonical key resolution failed", e); -- cgit v1.2.3