aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-02-16 13:17:43 -0800
committerAdam Soltys <[email protected]>2026-02-16 13:17:43 -0800
commitac06683100e089ad589661b14ebaf5dc9582a256 (patch)
treef561f90aa09f3a6a3747cd703984043f028f9067 /src
parente39bf4f977bba2952a7de4a307b14c5ec862a3a7 (diff)
downloadcoinos-ui-ac06683100e089ad589661b14ebaf5dc9582a256.tar.gz
coinos-ui-ac06683100e089ad589661b14ebaf5dc9582a256.zip
passkey tweaks
Diffstat (limited to 'src')
-rw-r--r--src/components/Account.svelte26
-rw-r--r--src/lib/crypto.ts60
-rw-r--r--src/lib/passkey.ts26
-rw-r--r--src/lib/passwordCache.ts42
-rw-r--r--src/routes/(app)/account/[id]/+page.svelte16
-rw-r--r--src/routes/(app)/account/ark/+page.svelte103
-rw-r--r--src/routes/(app)/account/pass/+page.svelte43
-rw-r--r--src/routes/(app)/account/seed/+page.svelte46
-rw-r--r--src/routes/(app)/send/bitcoin/[address]/[amount]/[...feeRate]/+page.svelte50
-rw-r--r--src/routes/(static)/login/+page.svelte4
-rw-r--r--src/routes/(static)/register/+page.server.ts48
-rw-r--r--src/routes/(static)/register/+page.svelte94
-rw-r--r--src/routes/passkey/[...path]/+server.ts11
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;