From 18df062a099811355cafc2024e30a7c2b68c7984 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Thu, 26 Feb 2026 16:07:52 -0800 Subject: client side password auth --- src/lib/deriveAuthKey.ts | 23 +++ src/lib/utils.ts | 12 ++ src/routes/(app)/settings/+layout.svelte | 15 +- src/routes/(static)/login/+page.server.ts | 54 +++++- src/routes/(static)/login/+page.svelte | 95 ++++++++- src/routes/(static)/register/+page.server.ts | 39 +++- src/routes/(static)/register/+page.svelte | 280 +++++++++++---------------- 7 files changed, 333 insertions(+), 185 deletions(-) create mode 100644 src/lib/deriveAuthKey.ts (limited to 'src') diff --git a/src/lib/deriveAuthKey.ts b/src/lib/deriveAuthKey.ts new file mode 100644 index 00000000..9215b515 --- /dev/null +++ b/src/lib/deriveAuthKey.ts @@ -0,0 +1,23 @@ +import { scryptAsync } from "@noble/hashes/scrypt.js"; +import { bytesToHex } from "@noble/hashes/utils.js"; +import { getPublicKey } from "nostr-tools"; +import { rememberPrfKey, defaultRememberForMs } from "$lib/passwordCache"; + +export async function deriveAuthKeypair(username: string, password: string) { + const salt = `coinos:auth:${username.toLowerCase().replace(/\s/g, "")}`; + const sk = await scryptAsync(password, salt, { N: 2 ** 16, r: 8, p: 1, dkLen: 32 }); + const pubkey = getPublicKey(sk); + return { sk, pubkey, skHex: bytesToHex(sk) }; +} + +export async function deriveWalletEntropy(sk: Uint8Array): Promise { + const { finalizeEvent } = await import("nostr-tools"); + const event = { kind: 1, created_at: 0, content: "coinos-wallet-key-derivation", tags: [] }; + const signed = finalizeEvent(event as any, sk); + const sigBytes = new Uint8Array(signed.sig.length / 2); + for (let i = 0; i < signed.sig.length; i += 2) + sigBytes[i / 2] = parseInt(signed.sig.slice(i, i + 2), 16); + const entropy = await crypto.subtle.digest("SHA-256", sigBytes); + rememberPrfKey(entropy, defaultRememberForMs); + return entropy; +} diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 4bf96d6e..eee6903e 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -164,6 +164,7 @@ export const login = async ( token?: string; challenge?: string; recaptcha?: string; + authPubkey?: string; }, cookies, ip: string, @@ -509,6 +510,17 @@ export const register = async ( }; if (ip) headers["cf-connecting-ip"] = ip; + // Derive authPubkey if password provided + if (user.password && user.username) { + try { + const { deriveAuthKeypair } = await import("$lib/deriveAuthKey"); + const { pubkey: authPubkey } = await deriveAuthKeypair(user.username, user.password); + user.authPubkey = authPubkey; + } catch (e) { + console.log("Failed to derive authPubkey during registration", e); + } + } + let sk, token; try { ({ sk, token } = await post("/register", { user }, headers)); diff --git a/src/routes/(app)/settings/+layout.svelte b/src/routes/(app)/settings/+layout.svelte index 2090bd3a..ce45f915 100644 --- a/src/routes/(app)/settings/+layout.svelte +++ b/src/routes/(app)/settings/+layout.svelte @@ -201,6 +201,19 @@ } } + // If password is changing, derive new authPubkey + const pw = body.get("password") as string; + const un = (body.get("username") as string) || user.username; + if (pw && pw === body.get("confirm")) { + try { + const { deriveAuthKeypair } = await import("$lib/deriveAuthKey"); + const { pubkey: authPubkey } = await deriveAuthKeypair(un, pw); + body.set("authPubkey", authPubkey); + } catch (e) { + console.log("Failed to derive authPubkey for password change", e); + } + } + const response = await fetch(formElement!.action, { method: "POST", body, @@ -210,7 +223,7 @@ if (result.type === "success") { await invalidateAll(); - if (body.get("password")) $password = body.get("password") as string; + if (pw) $password = pw; } applyAction(result); diff --git a/src/routes/(static)/login/+page.server.ts b/src/routes/(static)/login/+page.server.ts index 4200bc01..eb0bf079 100644 --- a/src/routes/(static)/login/+page.server.ts +++ b/src/routes/(static)/login/+page.server.ts @@ -12,7 +12,7 @@ export const load = async ({ parent }) => { export const actions = { login: async ({ cookies, request }) => { const form = await fd(request); - let { username, password, token, loginRedirect, recaptcha } = form; + let { username, password, token, loginRedirect, recaptcha, authPubkey } = form; username = username.split("@")[0]; const user = { @@ -20,6 +20,7 @@ export const actions = { password, token, recaptcha, + authPubkey: authPubkey || undefined, }; if (loginRedirect === "undefined") loginRedirect = undefined; @@ -39,6 +40,57 @@ export const actions = { redirect(307, loginRedirect || `/${user.username}`); }, + passwordAuth: async ({ cookies, fetch, request }) => { + const form = await fd(request); + let { event, challenge, username, loginRedirect, recaptcha, token: twofa } = form; + if (loginRedirect === "null" || loginRedirect === "undefined") loginRedirect = undefined; + event = JSON.parse(event); + + const maxAge = 380 * 24 * 60 * 60; + + const res = await fetch(`${PUBLIC_COINOS_URL}/authKeyLogin`, { + method: "POST", + body: JSON.stringify({ event, challenge, username, recaptcha, twofa }), + headers: { + "content-type": "application/json", + accept: "application/json", + "cf-connecting-ip": request.headers.get("cf-connecting-ip") ?? "", + "x-forwarded-host": request.headers.get("host") ?? "", + }, + }); + + const text = await res.text(); + + if (res.status === 401) { + if (text.startsWith("2fa")) throw new Error("2fa"); + throw new Error(text); + } + + if (!res.ok) { + let message; + try { + ({ message } = JSON.parse(text)); + } catch { + message = text; + } + return fail(400, { error: message || "Login failed" }); + } + + const { user, token } = JSON.parse(text); + if (!token) return fail(400, { error: "Login failed" }); + const { username: uname, language } = user; + + const expires = new Date(); + expires.setSeconds(expires.getSeconds() + maxAge); + + const opts = { path: "/", expires }; + if (language) cookies.set("lang", language, opts); + cookies.set("username", uname, opts); + cookies.set("token", token, opts); + + redirect(307, loginRedirect || `/${uname}`); + }, + passkey: async ({ cookies, fetch, request }) => { const form = await fd(request); let { credential, challengeId, loginRedirect } = form; diff --git a/src/routes/(static)/login/+page.svelte b/src/routes/(static)/login/+page.svelte index 2c4df81e..6d7a6e19 100644 --- a/src/routes/(static)/login/+page.svelte +++ b/src/routes/(static)/login/+page.svelte @@ -79,23 +79,100 @@ }); }); - const enhanceLogin = async ({ formData, cancel }: any) => { + const enhanceLogin = async ({ formData, cancel: cancelForm }: any) => { + const uname = formData.get("username"); + const pw = formData.get("password"); + + let recaptcha: string; try { - const recaptcha = await getRecaptchaToken(); - formData.set("recaptcha", recaptcha as string); + recaptcha = (await getRecaptchaToken()) as string; + formData.set("recaptcha", recaptcha); } catch (err: any) { fail(err.message || "captcha failed"); - cancel(); + cancelForm(); return; } - return async ({ result }: any) => { - if (result.type === "success") { - await invalidateAll(); + try { + const { deriveAuthKeypair, deriveWalletEntropy } = await import("$lib/deriveAuthKey"); + const { sk, pubkey } = await deriveAuthKeypair(uname, pw); + + // Fetch challenge with username to check if authPubkey exists + const { challenge: ch, hasAuthKey } = await ( + await fetch(`/api/challenge?username=${encodeURIComponent(uname)}`) + ).json(); + + if (hasAuthKey) { + // Challenge-response path: sign event with derived key, no password sent + cancelForm(); + + const { finalizeEvent } = await import("nostr-tools"); + const event = finalizeEvent( + { + kind: 27235, + created_at: Math.floor(Date.now() / 1000), + content: "", + tags: [ + ["u", `${window.location.origin}/api/authKeyLogin`], + ["method", "POST"], + ["challenge", ch], + ], + } as any, + sk, + ); + + const fd = new FormData(); + fd.append("event", JSON.stringify(event)); + fd.append("challenge", ch); + fd.append("username", uname); + fd.append("loginRedirect", (redirect ?? "") as string); + fd.append("recaptcha", recaptcha); + fd.append("token", token ?? ""); + + const response = await fetch("/login?/passwordAuth", { + method: "POST", + body: fd, + }); + + const result = deserialize(await response.text()); + + if (result.type === "success" || result.type === "redirect") { + try { + await deriveWalletEntropy(sk); + } catch (e) { + console.log("Wallet entropy derivation failed", e); + } + await invalidateAll(); + } + + applyAction(result); + return; } - applyAction(result); - }; + // Legacy path: send password + authPubkey for migration + formData.set("authPubkey", pubkey); + + return async ({ result }: any) => { + if (result.type === "success" || result.type === "redirect") { + try { + await deriveWalletEntropy(sk); + } catch (e) { + console.log("Wallet entropy derivation failed", e); + } + await invalidateAll(); + } + applyAction(result); + }; + } catch (e: any) { + console.log("Auth key derivation failed, falling back to legacy login", e); + // Fallback: plain password login without migration + return async ({ result }: any) => { + if (result.type === "success") { + await invalidateAll(); + } + applyAction(result); + }; + } }; $effect(() => { diff --git a/src/routes/(static)/register/+page.server.ts b/src/routes/(static)/register/+page.server.ts index 6986e890..5df89541 100644 --- a/src/routes/(static)/register/+page.server.ts +++ b/src/routes/(static)/register/+page.server.ts @@ -1,4 +1,4 @@ -import { fd, get, post } from "$lib/utils"; +import { fd, get, login, post } from "$lib/utils"; import { fail, redirect } from "@sveltejs/kit"; export const load = async ({ parent }) => { @@ -12,22 +12,49 @@ export const load = async ({ parent }) => { }; export const actions = { - register: async ({ request }) => { - const ip = request.headers.get("cf-connecting-ip"); + register: async ({ cookies, 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, password, challenge, recaptcha, authPubkey } = form; + let { loginRedirect } = form; + if (loginRedirect === "undefined") loginRedirect = undefined; + + const user = { + picture, + username, + password, + challenge, + recaptcha, + authPubkey: authPubkey || undefined, + }; - const user = { picture, username, challenge, recaptcha }; const headers: Record = {}; if (ip) headers["cf-connecting-ip"] = ip; try { const { sk, token } = await post("/register", { user }, headers); - return { token, sk, username }; + + // Log the user in and set cookies + await login(user, cookies, ip, host); + + // Set sk cookie if server generated one + if (sk) { + const skExpires = new Date(); + skExpires.setSeconds(skExpires.getSeconds() + 21000000); + cookies.set("sk", sk, { + path: "/", + expires: skExpires, + httpOnly: false, + sameSite: "lax", + }); + } } catch (e) { const { message } = e as Error; return fail(400, { error: message }); } + + redirect(303, loginRedirect || `/${username}`); }, activate: async ({ cookies, request }) => { diff --git a/src/routes/(static)/register/+page.svelte b/src/routes/(static)/register/+page.svelte index dee4d0a3..689863e5 100644 --- a/src/routes/(static)/register/+page.svelte +++ b/src/routes/(static)/register/+page.svelte @@ -11,6 +11,7 @@ import { enhance } from "$app/forms"; import Pin from "$comp/Pin.svelte"; + import PasswordInput from "$comp/PasswordInput.svelte"; import Spinner from "$comp/Spinner.svelte"; import { focus, fail, versions, post } from "$lib/utils"; @@ -49,10 +50,6 @@ let username: string | undefined = $state(); let index = $state(data.index); - let needsPasskey = $state(false); - - // Stored between handleSubmit and retryPasskey - let pendingToken: string | undefined; let cleared; let clear = () => { @@ -83,17 +80,17 @@ let token: string = $state(""), formElement: any; - let code: any[] = []; let redirect: any; let cancel: any = () => { need2fa = false; }; - let email: any, - btn: HTMLButtonElement = $state() as any; + let btn: HTMLButtonElement = $state() as any; let loading = $state(false); + let passkeyLoading = $state(false); + const getRecaptchaToken = () => new Promise((resolve, reject) => { if (isTor) return resolve(""); @@ -112,6 +109,12 @@ let user: Record = Object.fromEntries(data); user.username = (user.username as string).replace(/\s*/g, ""); + if (!user.password) { + fail("Password is required"); + loading = false; + return; + } + for (let k in user) { data.set(k, user[k]); } @@ -147,6 +150,15 @@ } } + // Derive authPubkey from username + password + try { + const { deriveAuthKeypair } = await import("$lib/deriveAuthKey"); + const { pubkey: authPubkey } = await deriveAuthKeypair(user.username, user.password); + data.set("authPubkey", authPubkey); + } catch (e) { + console.log("Failed to derive authPubkey during registration", e); + } + const response = await fetch("/register?/register", { method: "POST", body: data, @@ -155,117 +167,51 @@ const result = deserialize(await response.text()); if (result.type === "success") { - // User created but no cookies set yet — must complete passkey first - const { token } = (result as any).data; - pendingToken = token; - - let prfKey: ArrayBuffer; - try { - prfKey = await registerPasskey(pendingToken); - } catch (e: any) { - console.log("passkey creation cancelled or failed", e); - needsPasskey = true; - loading = false; - return; - } - - await activateSession(prfKey); - } else { - // Registration failed (e.g. username taken) — try passkey sign-in + // Derive wallet entropy from password try { - const { credential, challengeId, prfKey } = await loginWithPasskey(); - const loginData = new FormData(); - loginData.append("credential", JSON.stringify(credential)); - loginData.append("challengeId", challengeId); - loginData.append("loginRedirect", (redirect ?? "") as string); - - const loginResponse = await fetch("/login?/passkey", { - method: "POST", - body: loginData, - }); - - const loginResult = deserialize(await loginResponse.text()); - - if (loginResult.type === "success" || loginResult.type === "redirect") { - rememberPrfKey(prfKey, defaultRememberForMs); - await invalidateAll(); - applyAction(loginResult); - loading = false; - return; - } - } catch (e: any) { - if (e.name !== "NotAllowedError") { - console.log("Passkey login failed", e); - } + const { deriveAuthKeypair, deriveWalletEntropy } = await import("$lib/deriveAuthKey"); + const { sk } = await deriveAuthKeypair(user.username, user.password); + await deriveWalletEntropy(sk); + } catch (e) { + console.log("Wallet entropy derivation failed", e); } - // Passkey cancelled or failed — show original registration error - applyAction(result); + await invalidateAll(); } + applyAction(result); loading = false; } - async function activateSession(prfKey: ArrayBuffer) { - rememberPrfKey(prfKey, defaultRememberForMs); - - // Derive Nostr key from PRF key - const [ - { entropyToMnemonic, mnemonicToSeed }, - { 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 entropy = new Uint8Array(prfKey).slice(0, 16); - const mnemonic = entropyToMnemonic(entropy, wordlist); - 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); - const skHex = bytesToHex(sk); - - // Activate session — sets auth cookies and updates pubkey server-side - const activateData = new FormData(); - activateData.set("token", pendingToken!); - activateData.set("username", username!); - activateData.set("sk", skHex); - activateData.set("pubkey", pubkey); - - const activateResponse = await fetch("/register?/activate", { - method: "POST", - body: activateData, - }); + let passkeyRegister = async () => { + passkeyLoading = true; + try { + const { credential, challengeId, prfKey } = await loginWithPasskey(); + const loginData = new FormData(); + loginData.append("credential", JSON.stringify(credential)); + loginData.append("challengeId", challengeId); + loginData.append("loginRedirect", (redirect ?? "") as string); + + const loginResponse = await fetch("/login?/passkey", { + method: "POST", + body: loginData, + }); - const activateResult = deserialize(await activateResponse.text()); - await invalidateAll(); + const loginResult = deserialize(await loginResponse.text()); - if (activateResult.type === "redirect") { - applyAction(activateResult); - } else { - applyAction({ type: "redirect", status: 303, location: `/${username}` }); - } - } + if (loginResult.type === "success" || loginResult.type === "redirect") { + rememberPrfKey(prfKey, defaultRememberForMs); + await invalidateAll(); + } - let retryPasskey = async () => { - loading = true; - try { - const prfKey = await registerPasskey(pendingToken); - needsPasskey = false; - await activateSession(prfKey); + applyAction(loginResult); } catch (e: any) { - fail($t("login.passkeyCreationFailed")); + if (e.name !== "NotAllowedError") { + fail(e.message || "Passkey login failed"); + } } - loading = false; - } + passkeyLoading = false; + }; let avatarInput: HTMLInputElement = $state() as any; let decr = () => (index = index <= 0 ? 63 : index - 1); @@ -406,72 +352,70 @@
{form?.message}{form?.error}
{/if} - {#if needsPasskey} -
-

{$t("login.accountCreatedNeedsPasskey")}

- -
- {:else} -
+ + + + + + - + -

- {$t("login.haveAccount")} - - {$t("login.signIn")} - -

- - {/if} + + + + + + +

+ {$t("login.haveAccount")} + + {$t("login.signIn")} + +

+ -- cgit v1.2.3