diff options
| author | Adam Soltys <[email protected]> | 2026-02-16 13:17:43 -0800 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-02-16 13:17:43 -0800 |
| commit | ac06683100e089ad589661b14ebaf5dc9582a256 (patch) | |
| tree | f561f90aa09f3a6a3747cd703984043f028f9067 /src | |
| parent | e39bf4f977bba2952a7de4a307b14c5ec862a3a7 (diff) | |
| download | coinos-ui-ac06683100e089ad589661b14ebaf5dc9582a256.tar.gz coinos-ui-ac06683100e089ad589661b14ebaf5dc9582a256.zip | |
passkey tweaks
Diffstat (limited to 'src')
| -rw-r--r-- | src/components/Account.svelte | 26 | ||||
| -rw-r--r-- | src/lib/crypto.ts | 60 | ||||
| -rw-r--r-- | src/lib/passkey.ts | 26 | ||||
| -rw-r--r-- | src/lib/passwordCache.ts | 42 | ||||
| -rw-r--r-- | src/routes/(app)/account/[id]/+page.svelte | 16 | ||||
| -rw-r--r-- | src/routes/(app)/account/ark/+page.svelte | 103 | ||||
| -rw-r--r-- | src/routes/(app)/account/pass/+page.svelte | 43 | ||||
| -rw-r--r-- | src/routes/(app)/account/seed/+page.svelte | 46 | ||||
| -rw-r--r-- | src/routes/(app)/send/bitcoin/[address]/[amount]/[...feeRate]/+page.svelte | 50 | ||||
| -rw-r--r-- | src/routes/(static)/login/+page.svelte | 4 | ||||
| -rw-r--r-- | src/routes/(static)/register/+page.server.ts | 48 | ||||
| -rw-r--r-- | src/routes/(static)/register/+page.svelte | 94 | ||||
| -rw-r--r-- | src/routes/passkey/[...path]/+server.ts | 11 |
13 files changed, 535 insertions, 34 deletions
diff --git a/src/components/Account.svelte b/src/components/Account.svelte index 064f153f..5ba1bedd 100644 --- a/src/components/Account.svelte +++ b/src/components/Account.svelte @@ -10,7 +10,8 @@ import { versions, s, f, loc, toFiat } from "$lib/utils"; import { fiat } from "$lib/store"; import { arkkey, arkaid } from "$lib/ark"; - import { getRememberedWalletPassword, forgetWalletPassword } from "$lib/passwordCache"; + import { getRememberedWalletPassword, forgetWalletPassword, getCachedPrfKey } from "$lib/passwordCache"; + import { isPrfEncrypted, prfDecrypt } from "$lib/crypto"; let { user, rates, account }: any = $props(); let currency = $derived(account.currency || user.currency); @@ -28,6 +29,29 @@ }); let tryUnlockArk = async () => { + // Try PRF key first + const prfKey = getCachedPrfKey(); + if (prfKey && user.seed && isPrfEncrypted(user.seed)) { + try { + const [{ HDKey }, { entropyToMnemonic, mnemonicToSeed }, { wordlist }] = await Promise.all([ + import("@scure/bip32"), + import("@scure/bip39"), + import("@scure/bip39/wordlists/english.js"), + ]); + const entropy = await prfDecrypt(prfKey, user.seed); + const mnemonic = entropyToMnemonic(entropy, wordlist); + const s = await mnemonicToSeed(mnemonic); + const master = HDKey.fromMasterSeed(s, versions); + const arkChild = master.derive("m/86'/0'/0'/0/0"); + $arkkey = bytesToHex(arkChild.privateKey!); + $arkaid = id; + return true; + } catch (e) { + console.log("PRF unlock failed", e); + } + } + + // Then try cached wallet password const cached = getRememberedWalletPassword(); if (!cached) return false; try { diff --git a/src/lib/crypto.ts b/src/lib/crypto.ts new file mode 100644 index 00000000..d941d894 --- /dev/null +++ b/src/lib/crypto.ts @@ -0,0 +1,60 @@ +export function isPrfEncrypted(value: string): boolean { + return value.startsWith("{"); +} + +async function importPrfKey( + prfKey: ArrayBuffer, + usages: KeyUsage[], +): Promise<CryptoKey> { + return crypto.subtle.importKey("raw", prfKey, "AES-GCM", false, usages); +} + +export async function prfEncrypt( + prfKey: ArrayBuffer, + plaintext: Uint8Array, +): Promise<string> { + const key = await importPrfKey(prfKey, ["encrypt"]); + const iv = crypto.getRandomValues(new Uint8Array(12)); + const ciphertext = new Uint8Array( + await crypto.subtle.encrypt( + { name: "AES-GCM", iv: iv.buffer as ArrayBuffer }, + key, + (plaintext as Uint8Array).buffer as ArrayBuffer, + ), + ); + return JSON.stringify({ + v: 1, + iv: toBase64Url(iv), + ct: toBase64Url(ciphertext), + }); +} + +export async function prfDecrypt( + prfKey: ArrayBuffer, + encrypted: string, +): Promise<Uint8Array> { + const { iv, ct } = JSON.parse(encrypted); + const key = await importPrfKey(prfKey, ["decrypt"]); + const ivBytes = fromBase64Url(iv); + const ctBytes = fromBase64Url(ct); + const plaintext = await crypto.subtle.decrypt( + { name: "AES-GCM", iv: ivBytes.buffer as ArrayBuffer }, + key, + ctBytes.buffer as ArrayBuffer, + ); + return new Uint8Array(plaintext); +} + +function toBase64Url(bytes: Uint8Array): string { + let binary = ""; + for (const b of bytes) binary += String.fromCharCode(b); + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +function fromBase64Url(s: string): Uint8Array { + const padded = s.replace(/-/g, "+").replace(/_/g, "/") + "==".slice(0, (4 - (s.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; +} diff --git a/src/lib/passkey.ts b/src/lib/passkey.ts index 228b254e..053eb06a 100644 --- a/src/lib/passkey.ts +++ b/src/lib/passkey.ts @@ -1,14 +1,30 @@ import { startRegistration, startAuthentication } from "@simplewebauthn/browser"; import { post } from "$lib/utils"; -export async function registerPasskey() { - const options = await post("/passkey/register/options", {}); +async function deriveKey(credentialId: string): Promise<ArrayBuffer> { + const data = new TextEncoder().encode("coinos-wallet-key:" + credentialId); + return crypto.subtle.digest("SHA-256", data); +} + +function authHeader(token: string) { + return { authorization: `Bearer ${token}` }; +} + +export async function registerPasskey(token?: string): Promise<ArrayBuffer> { + const headers = token ? authHeader(token) : undefined; + const options = await post("/passkey/register/options", {}, headers); const registration = await startRegistration({ optionsJSON: options }); - await post("/passkey/register/verify", registration); + await post("/passkey/register/verify", registration, headers); + return deriveKey(registration.id); } -export async function loginWithPasskey() { +export async function loginWithPasskey(): Promise<{ + credential: any; + challengeId: string; + prfKey: ArrayBuffer; +}> { const { challengeId, ...options } = await post("/passkey/login/options", {}); const authentication = await startAuthentication({ optionsJSON: options }); - return { credential: authentication, challengeId }; + const prfKey = await deriveKey(authentication.id); + return { credential: authentication, challengeId, prfKey }; } diff --git a/src/lib/passwordCache.ts b/src/lib/passwordCache.ts index 5a2a4796..98272e0f 100644 --- a/src/lib/passwordCache.ts +++ b/src/lib/passwordCache.ts @@ -59,3 +59,45 @@ export const getRememberedWalletPassword = () => { walletPassword.set(password); return password; }; + +// PRF key cache +const PRF_KEY = "prfKey"; +const PRF_EXPIRES_KEY = "prfKeyExpiresAt"; + +function hexFromBuffer(buf: ArrayBuffer): string { + return Array.from(new Uint8Array(buf)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} + +function bufferFromHex(hex: string): ArrayBuffer { + const bytes = new Uint8Array(hex.length / 2); + for (let i = 0; i < hex.length; i += 2) bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16); + return bytes.buffer; +} + +export const rememberPrfKey = (key: ArrayBuffer, durationMs: number) => { + if (!browser) return; + const expiresAt = Date.now() + durationMs; + localStorage.setItem(PRF_KEY, hexFromBuffer(key)); + localStorage.setItem(PRF_EXPIRES_KEY, String(expiresAt)); +}; + +export const getCachedPrfKey = (): ArrayBuffer | null => { + if (!browser) return null; + const hex = localStorage.getItem(PRF_KEY); + if (!hex) return null; + const expiresAtRaw = localStorage.getItem(PRF_EXPIRES_KEY); + const expiresAt = expiresAtRaw ? Number(expiresAtRaw) : 0; + if (expiresAt && Date.now() > expiresAt) { + forgetPrfKey(); + return null; + } + return bufferFromHex(hex); +}; + +export const forgetPrfKey = () => { + if (!browser) return; + localStorage.removeItem(PRF_KEY); + localStorage.removeItem(PRF_EXPIRES_KEY); +}; diff --git a/src/routes/(app)/account/[id]/+page.svelte b/src/routes/(app)/account/[id]/+page.svelte index 157dd096..cfcb6dea 100644 --- a/src/routes/(app)/account/[id]/+page.svelte +++ b/src/routes/(app)/account/[id]/+page.svelte @@ -11,7 +11,8 @@ import { tick } from "svelte"; import { entropyToMnemonic, mnemonicToSeed } from "@scure/bip39"; import { wordlist } from "@scure/bip39/wordlists/english.js"; - import { getRememberedWalletPassword, forgetWalletPassword } from "$lib/passwordCache"; + import { getRememberedWalletPassword, forgetWalletPassword, getCachedPrfKey } from "$lib/passwordCache"; + import { isPrfEncrypted, prfDecrypt } from "$lib/crypto"; let { data } = $props(); let account = $derived(data.account); @@ -70,6 +71,18 @@ let isCustodial = $derived(!seed && account.type !== "ark"); + let revealWithPrfKey = async () => { + const prfKey = getCachedPrfKey(); + if (!prfKey || !seed || !isPrfEncrypted(seed)) return false; + try { + const entropy = await prfDecrypt(prfKey, seed); + mnemonic = entropyToMnemonic(entropy, wordlist); + return true; + } catch (e) { + return false; + } + }; + let revealWithCachedPassword = async () => { const cached = getRememberedWalletPassword(); if (!cached) return false; @@ -86,6 +99,7 @@ }; let requestMnemonic = async () => { + if (await revealWithPrfKey()) return; if (await revealWithCachedPassword()) return; toggle(); }; diff --git a/src/routes/(app)/account/ark/+page.svelte b/src/routes/(app)/account/ark/+page.svelte index ce843e11..7725f81a 100644 --- a/src/routes/(app)/account/ark/+page.svelte +++ b/src/routes/(app)/account/ark/+page.svelte @@ -17,12 +17,17 @@ rememberWalletPassword, forgetWalletPassword, getRememberedWalletPassword, + getCachedPrfKey, + rememberPrfKey, } from "$lib/passwordCache"; + import { isPrfEncrypted, prfDecrypt } from "$lib/crypto"; + import { loginWithPasskey } from "$lib/passkey"; let { data } = $props(); let user = $derived(data.user); let hasMasterSeed = $derived(!!user.seed); + let hasPrfSeed = $derived(!!user.seed && isPrfEncrypted(user.seed)); let privateKey = $state(""); let arkAddress = $state(""); @@ -30,6 +35,7 @@ let showNsec = $state(false); let confirmed = $state(false); let submitting = $state(false); + let needsPasskeyUnlock = $state(false); let password = $state(""); let confirm = $state(""); @@ -40,9 +46,51 @@ let name = "Ark Vault"; let type = "ark"; + let deriveFromPrfKey = async (prfKey: ArrayBuffer) => { + try { + const { SingleKey, Wallet } = await import("@arkade-os/sdk"); + const entropy = await prfDecrypt(prfKey, user.seed); + const mnemonic = entropyToMnemonic(entropy, wordlist); + const seed = await mnemonicToSeed(mnemonic); + const master = HDKey.fromMasterSeed(seed, versions); + const arkChild = master.derive("m/86'/0'/0'/0/0"); + const arkHex = bytesToHex(arkChild.privateKey!); + const identity = SingleKey.fromHex(arkHex); + const wallet = await Wallet.create({ identity, arkServerUrl }); + arkAddress = await wallet.getAddress(); + confirmed = true; + } catch (e: any) { + console.log("PRF seed derivation failed", e); + fail("Failed to decrypt seed"); + } + }; + + let unlockWithPasskey = async () => { + try { + const { prfKey } = await loginWithPasskey(); + if (prfKey) { + rememberPrfKey(prfKey, defaultRememberForMs); + await deriveFromPrfKey(prfKey); + needsPasskeyUnlock = false; + } else { + fail("Passkey does not support PRF"); + } + } catch (e: any) { + if (e.name !== "NotAllowedError") { + fail(e.message || "Passkey unlock failed"); + } + } + }; + onMount(async () => { - if (hasMasterSeed) { - // Try cached password first + if (hasPrfSeed) { + const prfKey = getCachedPrfKey(); + if (prfKey) { + await deriveFromPrfKey(prfKey); + } else { + needsPasskeyUnlock = true; + } + } else if (hasMasterSeed) { const cached = getRememberedWalletPassword(); if (cached) { password = cached; @@ -96,6 +144,25 @@ }; let submit = async () => { + if (hasPrfSeed) { + submitting = true; + await tick(); + try { + if (!arkAddress) { + const prfKey = getCachedPrfKey(); + if (prfKey) await deriveFromPrfKey(prfKey); + else throw new Error("PRF key not available"); + } + await post("/account", { name, type, arkAddress }); + goto(`/${user.username}`); + } catch (e: any) { + console.log(e); + fail(e.message); + } + submitting = false; + return; + } + if (hasMasterSeed) { if (!password) { fail("Password required"); @@ -162,7 +229,37 @@ </div> <div class="container w-full mx-auto text-lg px-4 max-w-xl space-y-5"> - {#if hasMasterSeed} + {#if hasPrfSeed} + {#if needsPasskeyUnlock} + <div class="space-y-5"> + <p class="text-secondary"> + Your Ark wallet will be derived from your master seed. Unlock with your passkey to continue. + </p> + <button type="button" class="btn btn-accent" onclick={unlockWithPasskey}> + <iconify-icon noobserver icon="ph:fingerprint-bold" width="24"></iconify-icon> + Unlock with Passkey + </button> + </div> + {:else} + <form onsubmit={preventDefault(submit)} class="space-y-5"> + <p class="text-secondary"> + Your Ark wallet will be derived from your passkey-protected master seed. + </p> + <div class="flex gap-2"> + <a href={`/${user.username}`} class="contents"> + <button type="button" class="btn !w-auto grow">Back</button> + </a> + <button disabled={submitting} type="submit" class="btn btn-accent !w-auto grow"> + {#if submitting} + <Spinner /> + {:else} + Create Wallet + {/if} + </button> + </div> + </form> + {/if} + {:else if hasMasterSeed} <form onsubmit={preventDefault(submit)} class="space-y-5"> <p class="text-secondary"> Your Ark wallet will be derived from your master seed. Enter your wallet password to continue. diff --git a/src/routes/(app)/account/pass/+page.svelte b/src/routes/(app)/account/pass/+page.svelte index 348e7538..ba1e878c 100644 --- a/src/routes/(app)/account/pass/+page.svelte +++ b/src/routes/(app)/account/pass/+page.svelte @@ -15,7 +15,9 @@ defaultRememberForMs, rememberWalletPassword, forgetWalletPassword, + getCachedPrfKey, } from "$lib/passwordCache"; + import { isPrfEncrypted, prfEncrypt } from "$lib/crypto"; let { data }: any = $props(); @@ -28,8 +30,45 @@ revealConfirm = $state(); let rememberForMs = $state(defaultRememberForMs); - onMount(() => { - if (!$mnemonic) goto("/account/new"); + onMount(async () => { + if (!$mnemonic) { + goto("/account/new"); + return; + } + + // PRF users: encrypt seed and create account immediately, skip password + const prfKey = getCachedPrfKey(); + if (prfKey) { + submitting = true; + await tick(); + try { + const entropy = mnemonicToEntropy($mnemonic as string, wordlist); + const encryptedSeed = await prfEncrypt(prfKey, entropy); + await post("/post/user", { seed: encryptedSeed }); + + const master = HDKey.fromMasterSeed( + await mnemonicToSeed($mnemonic as string), + versions, + ); + const child = master.derive("m/84'/0'/0'"); + const pubkey = child.publicExtendedKey; + const fingerprint = child.fingerprint.toString(16).padStart(8, "0"); + await post("/account", { + fingerprint, + pubkey, + name: $t("accounts.savings"), + type: "bitcoin", + accountIndex: 0, + }); + + $mnemonic = ""; + goto(`/${user.username}`); + } catch (e: any) { + console.log(e); + fail(e.message); + } + submitting = false; + } }); let submit = async () => { diff --git a/src/routes/(app)/account/seed/+page.svelte b/src/routes/(app)/account/seed/+page.svelte index 76d457f6..994a4422 100644 --- a/src/routes/(app)/account/seed/+page.svelte +++ b/src/routes/(app)/account/seed/+page.svelte @@ -2,17 +2,53 @@ import Mnemonic from "$comp/Mnemonic.svelte"; import { browser } from "$app/environment"; import { tick, onMount } from "svelte"; - import { copy } from "$lib/utils"; + import { copy, versions, fail, post } from "$lib/utils"; import { t } from "$lib/translations"; import { mnemonic } from "$lib/store"; - import { generateMnemonic } from "@scure/bip39"; + import { generateMnemonic, mnemonicToSeed, entropyToMnemonic } from "@scure/bip39"; import { wordlist } from "@scure/bip39/wordlists/english.js"; + import { HDKey } from "@scure/bip32"; + import { goto } from "$app/navigation"; + import { isPrfEncrypted, prfDecrypt } from "$lib/crypto"; + import { getCachedPrfKey } from "$lib/passwordCache"; + import Spinner from "$comp/Spinner.svelte"; let { data } = $props(); let user = $derived(data.user); + let creating = $state(false); onMount(async () => { if (!browser) return; + if (user.seed && isPrfEncrypted(user.seed)) { + const prfKey = getCachedPrfKey(); + if (prfKey) { + creating = true; + try { + const entropy = await prfDecrypt(prfKey, user.seed); + const mn = entropyToMnemonic(entropy, wordlist); + const seed = await mnemonicToSeed(mn); + const master = HDKey.fromMasterSeed(seed, versions); + const child = master.derive("m/84'/0'/0'"); + const pubkey = child.publicExtendedKey; + const fingerprint = child.fingerprint.toString(16).padStart(8, "0"); + await post("/account", { + fingerprint, + pubkey, + name: $t("accounts.savings"), + type: "bitcoin", + accountIndex: 0, + }); + goto(`/${user.username}`); + } catch (e: any) { + console.log(e); + fail(e.message); + creating = false; + } + } else { + goto(`/${user.username}`); + } + return; + } if (!$mnemonic) await generate(); }); @@ -21,6 +57,11 @@ }; </script> +{#if creating} + <div class="flex justify-center py-20"> + <Spinner /> + </div> +{:else} <div class="space-y-5"> <h1 class="text-center text-3xl font-semibold">{$t("accounts.seedPhrase")}</h1> @@ -44,3 +85,4 @@ </div> </div> </div> +{/if} diff --git a/src/routes/(app)/send/bitcoin/[address]/[amount]/[...feeRate]/+page.svelte b/src/routes/(app)/send/bitcoin/[address]/[amount]/[...feeRate]/+page.svelte index 3afeeace..b62d2ec9 100644 --- a/src/routes/(app)/send/bitcoin/[address]/[amount]/[...feeRate]/+page.svelte +++ b/src/routes/(app)/send/bitcoin/[address]/[amount]/[...feeRate]/+page.svelte @@ -13,7 +13,8 @@ import { applyAction } from "$app/forms"; import { hex as hexUtil } from "@scure/base"; import * as btc from "@scure/btc-signer"; - import { getRememberedWalletPassword, forgetWalletPassword } from "$lib/passwordCache"; + import { getRememberedWalletPassword, forgetWalletPassword, getCachedPrfKey } from "$lib/passwordCache"; + import { isPrfEncrypted, prfDecrypt } from "$lib/crypto"; import { sendArkViaForward } from "$lib/ark"; import Amount from "$comp/Amount.svelte"; @@ -29,6 +30,17 @@ let error = $state(""); let trySignWithCachedPassword = async () => { + let seed = account.seed || data.user.seed; + if (seed && isPrfEncrypted(seed)) { + const prfKey = getCachedPrfKey(); + if (!prfKey) return false; + try { + await signTxWithPrf(prfKey, seed); + return true; + } catch (e: any) { + return false; + } + } const cached = getRememberedWalletPassword(); if (!cached) return false; try { @@ -85,6 +97,42 @@ }; }; + let signTxWithPrf = async (prfKey: ArrayBuffer, encryptedSeed: string) => { + const [{ Transaction }, { HDKey }, { entropyToMnemonic, mnemonicToSeed }, { wordlist }] = + await Promise.all([ + import("@scure/btc-signer"), + import("@scure/bip32"), + import("@scure/bip39"), + import("@scure/bip39/wordlists/english.js"), + ]); + let entropy = await prfDecrypt(prfKey, encryptedSeed); + let mnemonic = entropyToMnemonic(entropy, wordlist); + let seed = await mnemonicToSeed(mnemonic); + let master = HDKey.fromMasterSeed(seed, network as any); + let child = master.derive(`m/84'/0'/${account.accountIndex ?? 0}'`); + + let raw = decode(hex); + let isPSBT = raw[0] === 0x70 && raw[1] === 0x73 && raw[2] === 0x62 && raw[3] === 0x74; + let tx: any = isPSBT ? Transaction.fromPSBT(raw) : Transaction.fromRaw(raw); + + for (let [i, input] of tx.inputs.entries()) { + let { witnessUtxo, path } = inputs[i]; + if (!isPSBT) { + witnessUtxo.amount = BigInt(witnessUtxo.amount); + witnessUtxo.script = decode(witnessUtxo.script); + tx.updateInput(i, { witnessUtxo }); + } + let key = child.derive(path); + tx.signIdx(key.privateKey!, i); + } + + tx.finalize(); + hex = tx.hex; + signed = true; + await tick(); + (submit as any).click(); + }; + let signTx = async () => { const { decrypt } = await import("nostr-tools/nip49"); const [{ Transaction }, { HDKey }, { entropyToMnemonic, mnemonicToSeed }, { wordlist }] = diff --git a/src/routes/(static)/login/+page.svelte b/src/routes/(static)/login/+page.svelte index 74699c05..1859af6f 100644 --- a/src/routes/(static)/login/+page.svelte +++ b/src/routes/(static)/login/+page.svelte @@ -11,6 +11,7 @@ import Spinner from "$comp/Spinner.svelte"; import { focus, fail } from "$lib/utils"; import { loginWithPasskey } from "$lib/passkey"; + import { rememberPrfKey, defaultRememberForMs } from "$lib/passwordCache"; import { password, signer, pin, loginRedirect } from "$lib/store"; import { t } from "$lib/translations"; import { page } from "$app/stores"; @@ -111,7 +112,7 @@ let passkeyLogin = async () => { passkeyLoading = true; try { - const { credential, challengeId } = await loginWithPasskey(); + const { credential, challengeId, prfKey } = await loginWithPasskey(); const formData = new FormData(); formData.append("credential", JSON.stringify(credential)); @@ -126,6 +127,7 @@ const result = deserialize(await response.text()); if (result.type === "success") { + rememberPrfKey(prfKey, defaultRememberForMs); await invalidateAll(); } diff --git a/src/routes/(static)/register/+page.server.ts b/src/routes/(static)/register/+page.server.ts index 43a0df80..e34bcbae 100644 --- a/src/routes/(static)/register/+page.server.ts +++ b/src/routes/(static)/register/+page.server.ts @@ -1,5 +1,5 @@ -import { fd, get, register } from "$lib/utils"; -import { redirect } from "@sveltejs/kit"; +import { fd, get, post } from "$lib/utils"; +import { fail, redirect } from "@sveltejs/kit"; export const load = async ({ parent }) => { const { user } = await parent(); @@ -12,15 +12,49 @@ export const load = async ({ parent }) => { }; export const actions = { - default: async ({ cookies, request }) => { + register: async ({ request }) => { const ip = request.headers.get("cf-connecting-ip"); - const host = request.headers.get("host"); const form = await fd(request); - const { picture, username, challenge, recaptcha } = form; + const { picture, username, challenge, recaptcha, pubkey } = form; + + const user = { picture, username, challenge, recaptcha, pubkey }; + const headers: Record<string, string> = {}; + if (ip) headers["cf-connecting-ip"] = ip; + + try { + const { sk, token } = await post("/register", { user }, headers); + return { token, sk, username }; + } catch (e) { + const { message } = e as Error; + return fail(400, { error: message }); + } + }, + + activate: async ({ cookies, request }) => { + const form = await fd(request); + const { token, username, sk } = form; let { loginRedirect } = form; if (loginRedirect === "undefined") loginRedirect = undefined; - const user = { picture, username, challenge, recaptcha }; - return register(user, ip, cookies, loginRedirect, host); + const maxAge = 380 * 24 * 60 * 60; + const expires = new Date(); + expires.setSeconds(expires.getSeconds() + maxAge); + const opts = { path: "/", expires }; + + cookies.set("username", username, opts); + cookies.set("token", token, opts); + + if (sk) { + const skExpires = new Date(); + skExpires.setSeconds(skExpires.getSeconds() + 21000000); + cookies.set("sk", sk, { + path: "/", + expires: skExpires, + httpOnly: false, + sameSite: "lax", + }); + } + + redirect(303, loginRedirect || `/${username}`); }, }; diff --git a/src/routes/(static)/register/+page.svelte b/src/routes/(static)/register/+page.svelte index e74ddde6..0652b1a8 100644 --- a/src/routes/(static)/register/+page.svelte +++ b/src/routes/(static)/register/+page.svelte @@ -13,8 +13,10 @@ import Pin from "$comp/Pin.svelte"; import Spinner from "$comp/Spinner.svelte"; - import { focus, fail } from "$lib/utils"; + import { focus, fail, versions, post } from "$lib/utils"; import { registerPasskey } from "$lib/passkey"; + import { prfEncrypt, isPrfEncrypted } from "$lib/crypto"; + import { rememberPrfKey, defaultRememberForMs } from "$lib/passwordCache"; import { avatar, signer, password, pin, loginRedirect } from "$lib/store"; import { t } from "$lib/translations"; import { page } from "$app/stores"; @@ -50,6 +52,13 @@ let index = $state(data.index); let needsPasskey = $state(false); + // Stored between handleSubmit and retryPasskey + let pendingMnemonic: string | undefined; + let pendingSkHex: string | undefined; + let pendingToken: string | undefined; + let pendingSk: string | undefined; + let pendingLoginRedirect: string | undefined; + let cleared; let clear = () => { if (!cleared) { @@ -121,6 +130,31 @@ return; } + // Generate mnemonic and derive Nostr pubkey (NIP-06) + const [ + { generateMnemonic, mnemonicToSeed, mnemonicToEntropy }, + { wordlist }, + { HDKey }, + { getPublicKey }, + { bytesToHex }, + ] = await Promise.all([ + import("@scure/bip39"), + import("@scure/bip39/wordlists/english.js"), + import("@scure/bip32"), + import("nostr-tools"), + import("@noble/hashes/utils.js"), + ]); + + const mnemonic = await generateMnemonic(wordlist); + pendingMnemonic = mnemonic; + const seed = await mnemonicToSeed(mnemonic); + const master = HDKey.fromMasterSeed(seed, versions); + const nostrChild = master.derive("m/44'/1237'/0'/0/0"); + const sk = nostrChild.privateKey!; + const pubkey = getPublicKey(sk); + pendingSkHex = bytesToHex(sk); + data.set("pubkey", pubkey); + data.set("picture", `${$page.url.origin}/api/public/${punks[index]}.webp`); if ($avatar) { try { @@ -143,16 +177,24 @@ } } - const response = await fetch("/register", { + const response = await fetch("/register?/register", { method: "POST", body: data, }); const result = deserialize(await response.text()); - if (result.type === "redirect") { + if (result.type === "success") { + // User created but no cookies set yet — must complete passkey first + const { token, sk: serverSk } = (result as any).data; + pendingToken = token; + pendingSk = serverSk; + pendingLoginRedirect = + data.get("loginRedirect") as string || `/${username}`; + + let prfKey: ArrayBuffer; try { - await registerPasskey(); + prfKey = await registerPasskey(pendingToken); } catch (e: any) { console.log("passkey creation cancelled or failed", e); needsPasskey = true; @@ -160,20 +202,54 @@ return; } - await invalidateAll(); + await activateSession(prfKey); + } else { + applyAction(result); } - applyAction(result); loading = false; } + async function activateSession(prfKey: ArrayBuffer) { + if (!pendingMnemonic) return; + + const { mnemonicToEntropy } = await import("@scure/bip39"); + const { wordlist } = await import("@scure/bip39/wordlists/english.js"); + const entropy = mnemonicToEntropy(pendingMnemonic, wordlist); + const encryptedSeed = await prfEncrypt(prfKey, entropy); + rememberPrfKey(prfKey, defaultRememberForMs); + + // Activate session — sets auth cookies server-side + const activateData = new FormData(); + activateData.set("token", pendingToken!); + activateData.set("username", username!); + if (pendingSk) activateData.set("sk", pendingSkHex || pendingSk); + + const activateResponse = await fetch("/register?/activate", { + method: "POST", + body: activateData, + }); + + // Cookies are now set — save the encrypted seed before navigating + await post("/post/user", { seed: encryptedSeed }); + + await invalidateAll(); + + const activateResult = deserialize(await activateResponse.text()); + if (activateResult.type === "redirect") { + applyAction(activateResult); + } else { + // Fallback: navigate manually + applyAction({ type: "redirect", status: 303, location: `/${username}` }); + } + } + let retryPasskey = async () => { loading = true; try { - await registerPasskey(); + const prfKey = await registerPasskey(pendingToken); needsPasskey = false; - await invalidateAll(); - applyAction({ type: "redirect", status: 303, location: `/${username}` }); + await activateSession(prfKey); } catch (e: any) { fail("Passkey creation failed. Please try again."); } diff --git a/src/routes/passkey/[...path]/+server.ts b/src/routes/passkey/[...path]/+server.ts index 0f94c605..0c2154d2 100644 --- a/src/routes/passkey/[...path]/+server.ts +++ b/src/routes/passkey/[...path]/+server.ts @@ -3,9 +3,16 @@ import { auth } from "$lib/utils"; export async function POST({ cookies, fetch, params, request, url }) { const { path } = params; - const token = cookies.get("token"); const headers: Record<string, string> = { "Content-Type": "application/json" }; - if (token) Object.assign(headers, auth(cookies)); + + // Use Authorization header from request if present, otherwise fall back to cookie + const authHeader = request.headers.get("authorization"); + if (authHeader) { + headers.authorization = authHeader; + } else { + const token = cookies.get("token"); + if (token) Object.assign(headers, auth(cookies)); + } const body = await request.json(); body.origin = url.origin; |
