aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-02-26 16:07:52 -0800
committerAdam Soltys <[email protected]>2026-02-26 16:07:52 -0800
commit18df062a099811355cafc2024e30a7c2b68c7984 (patch)
tree1de4c341b3111f7ccc6c0e74824b2bc6115d3401 /src
parent2a59047dc8fc10560a7fde614238ee821cfb6b1b (diff)
downloadcoinos-ui-18df062a099811355cafc2024e30a7c2b68c7984.tar.gz
coinos-ui-18df062a099811355cafc2024e30a7c2b68c7984.zip
client side password auth
Diffstat (limited to 'src')
-rw-r--r--src/lib/deriveAuthKey.ts23
-rw-r--r--src/lib/utils.ts12
-rw-r--r--src/routes/(app)/settings/+layout.svelte15
-rw-r--r--src/routes/(static)/login/+page.server.ts54
-rw-r--r--src/routes/(static)/login/+page.svelte95
-rw-r--r--src/routes/(static)/register/+page.server.ts39
-rw-r--r--src/routes/(static)/register/+page.svelte276
7 files changed, 331 insertions, 183 deletions
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<ArrayBuffer> {
+ 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<string, string> = {};
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<string, any> = 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);
+ 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 activateResponse = await fetch("/register?/activate", {
- method: "POST",
- body: activateData,
- });
+ 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 @@
<div class="text-red-600 text-center" in:fly>{form?.message}{form?.error}</div>
{/if}
- {#if needsPasskey}
- <div class="space-y-5 text-center">
- <p>{$t("login.accountCreatedNeedsPasskey")}</p>
- <button class="btn btn-accent" onclick={retryPasskey} disabled={loading}>
- {#if loading}
- <Spinner />
- {:else}
- {$t("login.setupPasskey")}
- {/if}
- </button>
- </div>
- {:else}
- <form class="space-y-5" onsubmit={handleSubmit} method="POST">
+ <form class="space-y-5" onsubmit={handleSubmit} method="POST">
+ <input
+ type="hidden"
+ name="loginRedirect"
+ value={$loginRedirect || $page.url.searchParams.get("redirect")}
+ />
+ <input type="hidden" name="token" value={token} />
+ <input type="hidden" name="challenge" value={challenge} />
+
+ <label for="username" class="input flex items-center justify-center gap-2 w-full">
<input
- type="hidden"
- name="loginRedirect"
- value={$loginRedirect || $page.url.searchParams.get("redirect")}
+ class="clean"
+ use:focus
+ name="username"
+ type="text"
+ required
+ bind:value={username}
+ onfocus={clear}
+ autocapitalize="none"
+ placeholder={$t("login.username")}
/>
- <input type="hidden" name="token" value={token} />
- <input type="hidden" name="challenge" value={challenge} />
+ <button type="button" tabindex="-1" onclick={refresh} aria-label="Randomize" class="contents">
+ <iconify-icon noobserver icon="ph:dice-three-bold" width="32"></iconify-icon>
+ </button>
+ </label>
- <label for="username" class="input flex items-center justify-center gap-2 w-full">
- <input
- class="clean"
- use:focus
- name="username"
- type="text"
- required
- bind:value={username}
- onfocus={clear}
- autocapitalize="none"
- placeholder={$t("login.username")}
- />
- <button type="button" tabindex="-1" onclick={refresh} aria-label="Randomize" class="contents">
- <iconify-icon noobserver icon="ph:dice-three-bold" width="32"></iconify-icon>
- </button>
- </label>
+ <PasswordInput bind:value={$password} placeholder={$t("login.password")} />
- <button type="submit" class="btn btn-accent" disabled={loading} bind:this={btn}>
- {#if loading}
- <Spinner />
- {:else}
- {$t("login.register")}
- {/if}
- </button>
+ <button type="submit" class="btn btn-accent" disabled={loading} bind:this={btn}>
+ {#if loading}
+ <Spinner />
+ {:else}
+ {$t("login.register")}
+ {/if}
+ </button>
- <button type="button" class="btn" onclick={nostrLogin}>
- {#if $signer?.ready}
- <div class="shrink">
- <Spinner />
- </div>
- {:else}
- <img src="/images/nostr.png" class="w-8" alt="Nostr" />
- {/if}
- <div class="my-auto">{$t("login.nostr")}</div>
- </button>
+ <button type="button" class="btn" onclick={passkeyRegister} disabled={passkeyLoading}>
+ {#if passkeyLoading}
+ <Spinner />
+ {:else}
+ <iconify-icon noobserver icon="ph:fingerprint-bold" width="24"></iconify-icon>
+ {/if}
+ <div class="my-auto">Sign in with Passkey</div>
+ </button>
- <p class="text-secondary text-center font-medium">
- {$t("login.haveAccount")}
- <a
- href={"/login" + $page.url.search}
- class="block md:inline text-secondary underline underline-offset-4 hover:opacity-80"
- >
- {$t("login.signIn")}
- </a>
- </p>
- </form>
- {/if}
+ <button type="button" class="btn" onclick={nostrLogin}>
+ {#if $signer?.ready}
+ <div class="shrink">
+ <Spinner />
+ </div>
+ {:else}
+ <img src="/images/nostr.png" class="w-8" alt="Nostr" />
+ {/if}
+ <div class="my-auto">{$t("login.nostr")}</div>
+ </button>
+
+ <p class="text-secondary text-center font-medium">
+ {$t("login.haveAccount")}
+ <a
+ href={"/login" + $page.url.search}
+ class="block md:inline text-secondary underline underline-offset-4 hover:opacity-80"
+ >
+ {$t("login.signIn")}
+ </a>
+ </p>
+ </form>
</div>