aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-04-08 22:33:43 -0700
committerAdam Soltys <[email protected]>2026-04-08 22:57:55 -0700
commit757a59c5c9c4b56120446cae1ac495773b9905c8 (patch)
tree58ae7c3e3f11fb32164dae59b6e952e54dc6ac93
parentc7136dbefa4da50297513955cf51084afa23d98c (diff)
downloadcoinos-ui-staging.tar.gz
coinos-ui-staging.zip
breez style passkey derivationstaging
-rw-r--r--src/components/Mnemonic.svelte7
-rw-r--r--src/lib/keyWrapping.ts17
-rw-r--r--src/lib/passkey.ts110
-rw-r--r--src/locales/en.json4
-rw-r--r--src/routes/(app)/settings/security/+page.svelte53
-rw-r--r--src/routes/(static)/login/+page.svelte4
6 files changed, 178 insertions, 17 deletions
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 @@
<script lang="ts">
let { mnemonic } = $props();
+ let words = $derived(mnemonic.split(" "));
</script>
<div class="card w-full">
<div
- class="card-body w-full text-secondary grid grid-flow-col grid-rows-6 sm:grid-rows-none grid-cols-2 sm:grid-cols-3 sm:grid-flow-row gap-x-8 gap-y-2 justify-center justify-items-center whitespace-nowrap text-xl"
+ class="card-body w-full text-secondary grid grid-flow-col {words.length > 12
+ ? 'grid-rows-12'
+ : 'grid-rows-6'} sm:grid-rows-none grid-cols-2 sm:grid-cols-3 sm:grid-flow-row gap-x-8 gap-y-2 justify-center justify-items-center whitespace-nowrap text-xl"
>
- {#each mnemonic.split(" ") as w, i}
+ {#each words as w, i}
<div class="flex items-center gap-2 w-full max-w-[10rem] justify-start">
<span class="select-none text-sm w-6 text-left">{i + 1}.</span>
<span class="text-left">
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<string, string> }> {
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<ArrayBuffer> {
+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<ArrayBuffer> {
const data = new TextEncoder().encode("coinos-wallet-key:" + credentialId);
return crypto.subtle.digest("SHA-256", data);
}
+async function evaluatePrf(credentialId: string): Promise<ArrayBuffer | null> {
+ 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
</button>
</div>
+
+<div>
+ <span class="font-bold mb-1">{$t("user.settings.recoveryPhrase")}</span>
+ <p class="text-secondary mb-1">{$t("user.settings.recoveryPhraseDescription")}</p>
+ <button type="button" class="btn" onclick={toggleSeed}>
+ {#if revealSeed}
+ <PhEyeSlashBold width="32" />
+ {$t("user.settings.hideRecoveryPhrase")}
+ {:else}
+ <PhEyeBold width="32" />
+ {$t("user.settings.showRecoveryPhrase")}
+ {/if}
+ </button>
+
+ {#if revealSeed && recoveryMnemonic}
+ <div class="mt-4 space-y-4">
+ <Mnemonic mnemonic={recoveryMnemonic} />
+ <button onclick={() => copy(recoveryMnemonic as string)} type="button" class="btn">
+ <PhCopyBold width="32" />
+ <div class="my-auto">{$t("accounts.copy")}</div>
+ </button>
+ </div>
+ {/if}
+</div>
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);