diff options
| author | Adam Soltys <[email protected]> | 2026-02-27 14:46:27 -0800 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-02-27 14:46:27 -0800 |
| commit | 8ffcf769052461e03a2c69220392cb37a623f3f4 (patch) | |
| tree | 5b0b16269a729fbe9db44c65b3a0da3d95c11b78 /src | |
| parent | 6ba952f16fc60fc56f0834e5b1d08671885618c5 (diff) | |
| download | coinos-ui-8ffcf769052461e03a2c69220392cb37a623f3f4.tar.gz coinos-ui-8ffcf769052461e03a2c69220392cb37a623f3f4.zip | |
2 step register
Diffstat (limited to 'src')
| -rw-r--r-- | src/lib/deriveAuthKey.ts | 5 | ||||
| -rw-r--r-- | src/lib/keyWrapping.ts | 61 | ||||
| -rw-r--r-- | src/lib/passkey.ts | 7 | ||||
| -rw-r--r-- | src/lib/utils.ts | 2 | ||||
| -rw-r--r-- | src/lib/walletEntropy.ts | 10 | ||||
| -rw-r--r-- | src/routes/(app)/settings/+layout.svelte | 20 | ||||
| -rw-r--r-- | src/routes/(app)/settings/security/+page.svelte | 19 | ||||
| -rw-r--r-- | src/routes/(static)/login/+page.server.ts | 24 | ||||
| -rw-r--r-- | src/routes/(static)/login/+page.svelte | 91 | ||||
| -rw-r--r-- | src/routes/(static)/register/+page.server.ts | 107 | ||||
| -rw-r--r-- | src/routes/(static)/register/+page.svelte | 374 | ||||
| -rw-r--r-- | src/routes/(static)/register/auth/+page.server.ts | 115 | ||||
| -rw-r--r-- | src/routes/(static)/register/auth/+page.svelte | 393 |
13 files changed, 724 insertions, 504 deletions
diff --git a/src/lib/deriveAuthKey.ts b/src/lib/deriveAuthKey.ts index 9215b515..eb498e0a 100644 --- a/src/lib/deriveAuthKey.ts +++ b/src/lib/deriveAuthKey.ts @@ -1,7 +1,6 @@ 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, "")}`; @@ -17,7 +16,5 @@ export async function deriveWalletEntropy(sk: Uint8Array): Promise<ArrayBuffer> 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; + return crypto.subtle.digest("SHA-256", sigBytes); } diff --git a/src/lib/keyWrapping.ts b/src/lib/keyWrapping.ts new file mode 100644 index 00000000..b176e741 --- /dev/null +++ b/src/lib/keyWrapping.ts @@ -0,0 +1,61 @@ +import { prfEncrypt, prfDecrypt } from "$lib/crypto"; +import { rememberPrfKey, defaultRememberForMs, getCachedPrfKey } from "$lib/passwordCache"; +import { post } from "$lib/utils"; + +export function generateCanonicalKey(): ArrayBuffer { + return crypto.getRandomValues(new Uint8Array(32)).buffer as ArrayBuffer; +} + +export async function wrapCanonicalKey( + wrappingKey: ArrayBuffer, + canonicalKey: ArrayBuffer, +): Promise<string> { + return prfEncrypt(wrappingKey, new Uint8Array(canonicalKey)); +} + +export async function unwrapCanonicalKey( + wrappingKey: ArrayBuffer, + encrypted: string, +): Promise<ArrayBuffer> { + const bytes = await prfDecrypt(wrappingKey, encrypted); + return bytes.buffer as ArrayBuffer; +} + +export async function resolveCanonicalKey( + methodId: string, + wrappingKey: ArrayBuffer, + encryptedKeys: Record<string, string> | null | undefined, + oldPrfKey: ArrayBuffer, +): Promise<{ canonicalKey: ArrayBuffer; encryptedKeys: Record<string, string> }> { + if (encryptedKeys && encryptedKeys[methodId]) { + const canonicalKey = await unwrapCanonicalKey(wrappingKey, encryptedKeys[methodId]); + rememberPrfKey(canonicalKey, defaultRememberForMs); + return { canonicalKey, encryptedKeys }; + } + + // Migration: old PRF key becomes canonical key + const canonicalKey = oldPrfKey; + const wrapped = await wrapCanonicalKey(wrappingKey, canonicalKey); + const updated = { ...(encryptedKeys || {}), [methodId]: wrapped }; + await saveEncryptedKeys(updated); + rememberPrfKey(canonicalKey, defaultRememberForMs); + return { canonicalKey, encryptedKeys: updated }; +} + +export async function saveEncryptedKeys( + encryptedKeys: Record<string, string>, +): Promise<void> { + await post("/post/user", { encryptedKeys }); +} + +export function passwordMethodId(): string { + return "password"; +} + +export function passkeyMethodId(credentialId: string): string { + return `passkey:${credentialId}`; +} + +export function nostrMethodId(pubkey: string): string { + return `nostr:${pubkey}`; +} diff --git a/src/lib/passkey.ts b/src/lib/passkey.ts index 053eb06a..7c9e98cc 100644 --- a/src/lib/passkey.ts +++ b/src/lib/passkey.ts @@ -10,12 +10,15 @@ function authHeader(token: string) { return { authorization: `Bearer ${token}` }; } -export async function registerPasskey(token?: string): Promise<ArrayBuffer> { +export async function registerPasskey( + token?: string, +): Promise<{ prfKey: ArrayBuffer; credentialId: string }> { 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, headers); - return deriveKey(registration.id); + const prfKey = await deriveKey(registration.id); + return { prfKey, credentialId: registration.id }; } export async function loginWithPasskey(): Promise<{ diff --git a/src/lib/utils.ts b/src/lib/utils.ts index eee6903e..e37556e3 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -214,6 +214,8 @@ export const login = async ( }); } catch {} } + + return u; }; export const auth = (cookies) => ({ diff --git a/src/lib/walletEntropy.ts b/src/lib/walletEntropy.ts index 25bd49b8..95b3e903 100644 --- a/src/lib/walletEntropy.ts +++ b/src/lib/walletEntropy.ts @@ -1,5 +1,5 @@ import { browser } from "$app/environment"; -import { getCachedPrfKey, rememberPrfKey, defaultRememberForMs } from "$lib/passwordCache"; +import { getCachedPrfKey } from "$lib/passwordCache"; const DERIVATION_EVENT = { kind: 1, @@ -27,9 +27,7 @@ export async function tryDeriveFromNsec(): Promise<ArrayBuffer | null> { const { nip19, finalizeEvent } = await import("nostr-tools"); const sk = nip19.decode(nsec).data as Uint8Array; const signed = finalizeEvent({ ...DERIVATION_EVENT } as any, sk); - const entropy = await hashSignature(signed.sig); - rememberPrfKey(entropy, defaultRememberForMs); - return entropy; + return await hashSignature(signed.sig); } catch (e) { console.log("nsec entropy derivation failed", e); return null; @@ -60,7 +58,5 @@ export async function deriveNostrEntropy(): Promise<ArrayBuffer> { const { sign } = await import("$lib/nostr"); const signed = await sign({ ...DERIVATION_EVENT }); - const entropy = await hashSignature(signed.sig); - rememberPrfKey(entropy, defaultRememberForMs); - return entropy; + return hashSignature(signed.sig); } diff --git a/src/routes/(app)/settings/+layout.svelte b/src/routes/(app)/settings/+layout.svelte index ce45f915..99bdc4ce 100644 --- a/src/routes/(app)/settings/+layout.svelte +++ b/src/routes/(app)/settings/+layout.svelte @@ -13,6 +13,8 @@ import { sign, send, getPrivateKey } from "$lib/nostr"; import { invalidateAll } from "$app/navigation"; import { bytesToHex } from "@noble/hashes/utils.js"; + import { getCachedPrfKey } from "$lib/passwordCache"; + import { wrapCanonicalKey, saveEncryptedKeys, passwordMethodId } from "$lib/keyWrapping"; import { SimplePool } from "nostr-tools/pool"; import { finalizeEvent } from "nostr-tools/pure"; @@ -201,14 +203,21 @@ } } - // If password is changing, derive new authPubkey + // If password is changing, derive new authPubkey and re-wrap canonical key const pw = body.get("password") as string; const un = (body.get("username") as string) || user.username; + let pendingEncryptedKeys: Record<string, string> | null = null; if (pw && pw === body.get("confirm")) { try { const { deriveAuthKeypair } = await import("$lib/deriveAuthKey"); - const { pubkey: authPubkey } = await deriveAuthKeypair(un, pw); + const { sk, pubkey: authPubkey } = await deriveAuthKeypair(un, pw); body.set("authPubkey", authPubkey); + + const canonicalKey = getCachedPrfKey(); + if (canonicalKey) { + const wrapped = await wrapCanonicalKey(sk.buffer as ArrayBuffer, canonicalKey); + pendingEncryptedKeys = { ...(user.encryptedKeys || {}), [passwordMethodId()]: wrapped }; + } } catch (e) { console.log("Failed to derive authPubkey for password change", e); } @@ -222,6 +231,13 @@ const result = deserialize(await response.text()); if (result.type === "success") { + if (pendingEncryptedKeys) { + try { + await saveEncryptedKeys(pendingEncryptedKeys); + } catch (e) { + console.log("Failed to save re-wrapped encryptedKeys", e); + } + } await invalidateAll(); if (pw) $password = pw; } diff --git a/src/routes/(app)/settings/security/+page.svelte b/src/routes/(app)/settings/security/+page.svelte index 0991c679..9ab16ded 100644 --- a/src/routes/(app)/settings/security/+page.svelte +++ b/src/routes/(app)/settings/security/+page.svelte @@ -9,7 +9,12 @@ import { save, pin as current } from "$lib/store"; import { getNsec } from "$lib/nostr"; import { registerPasskey } from "$lib/passkey"; - import { rememberPrfKey, defaultRememberForMs } from "$lib/passwordCache"; + import { getCachedPrfKey, rememberPrfKey, defaultRememberForMs } from "$lib/passwordCache"; + import { + wrapCanonicalKey, + saveEncryptedKeys, + passkeyMethodId, + } from "$lib/keyWrapping"; import { invalidate } from "$app/navigation"; import { page } from "$app/stores"; @@ -155,8 +160,16 @@ let addPasskey = async () => { passkeyLoading = true; try { - const prfKey = await registerPasskey(); - rememberPrfKey(prfKey, defaultRememberForMs); + const { prfKey, credentialId } = await registerPasskey(); + const canonicalKey = getCachedPrfKey(); + if (canonicalKey) { + const wrapped = await wrapCanonicalKey(prfKey, canonicalKey); + const updated = { ...(user.encryptedKeys || {}), [passkeyMethodId(credentialId)]: wrapped }; + await saveEncryptedKeys(updated); + user.encryptedKeys = updated; + } else { + rememberPrfKey(prfKey, defaultRememberForMs); + } success("Passkey added"); } catch (e: any) { if (e.name !== "NotAllowedError") { diff --git a/src/routes/(static)/login/+page.server.ts b/src/routes/(static)/login/+page.server.ts index eb0bf079..68143d83 100644 --- a/src/routes/(static)/login/+page.server.ts +++ b/src/routes/(static)/login/+page.server.ts @@ -26,18 +26,21 @@ export const actions = { if (loginRedirect === "undefined") loginRedirect = undefined; try { - await login( + const u = await login( user, cookies, request.headers.get("cf-connecting-ip") ?? "", request.headers.get("host") ?? "", ); + + return { + encryptedKeys: u?.encryptedKeys || null, + redirectUrl: loginRedirect || `/${user.username}`, + }; } catch (e) { const { message } = e as Error; return fail(400, { error: "Login failed", message, ...form }); } - - redirect(307, loginRedirect || `/${user.username}`); }, passwordAuth: async ({ cookies, fetch, request }) => { @@ -88,7 +91,10 @@ export const actions = { cookies.set("username", uname, opts); cookies.set("token", token, opts); - redirect(307, loginRedirect || `/${uname}`); + return { + encryptedKeys: user.encryptedKeys || null, + redirectUrl: loginRedirect || `/${uname}`, + }; }, passkey: async ({ cookies, fetch, request }) => { @@ -134,7 +140,10 @@ export const actions = { cookies.set("username", username, opts); cookies.set("token", token, opts); - redirect(307, loginRedirect || `/${username}`); + return { + encryptedKeys: user.encryptedKeys || null, + redirectUrl: loginRedirect || `/${username}`, + }; }, nostr: async ({ cookies, fetch, request }) => { @@ -175,6 +184,9 @@ export const actions = { cookies.set("username", username, opts); cookies.set("token", token, opts); - redirect(307, loginRedirect || `/${username}`); + return { + encryptedKeys: user.encryptedKeys || null, + redirectUrl: loginRedirect || `/${username}`, + }; }, }; diff --git a/src/routes/(static)/login/+page.svelte b/src/routes/(static)/login/+page.svelte index 6d7a6e19..96a03ef5 100644 --- a/src/routes/(static)/login/+page.svelte +++ b/src/routes/(static)/login/+page.svelte @@ -11,15 +11,17 @@ 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 { + resolveCanonicalKey, + passwordMethodId, + passkeyMethodId, + nostrMethodId, + } from "$lib/keyWrapping"; import { t } from "$lib/translations"; import { page } from "$app/stores"; import { sign } from "$lib/nostr"; - import { invalidateAll } from "$app/navigation"; - import { sha256 } from "@noble/hashes/sha2.js"; - import { bytesToHex } from "@noble/hashes/utils.js"; - + import { goto, invalidateAll } from "$app/navigation"; let { data, form }: any = $props(); onMount(() => { @@ -134,18 +136,25 @@ body: fd, }); - const result = deserialize(await response.text()); + const result: any = deserialize(await response.text()); - if (result.type === "success" || result.type === "redirect") { + if (result.type === "success") { try { - await deriveWalletEntropy(sk); + const oldPrfKey = await deriveWalletEntropy(sk); + await resolveCanonicalKey( + passwordMethodId(), + sk.buffer as ArrayBuffer, + result.data?.encryptedKeys, + oldPrfKey, + ); } catch (e) { - console.log("Wallet entropy derivation failed", e); + console.log("Canonical key resolution failed", e); } await invalidateAll(); + goto(result.data?.redirectUrl || `/${uname}`); + } else { + applyAction(result); } - - applyAction(result); return; } @@ -153,24 +162,33 @@ formData.set("authPubkey", pubkey); return async ({ result }: any) => { - if (result.type === "success" || result.type === "redirect") { + if (result.type === "success") { try { - await deriveWalletEntropy(sk); + const oldPrfKey = await deriveWalletEntropy(sk); + await resolveCanonicalKey( + passwordMethodId(), + sk.buffer as ArrayBuffer, + result.data?.encryptedKeys, + oldPrfKey, + ); } catch (e) { - console.log("Wallet entropy derivation failed", e); + console.log("Canonical key resolution failed", e); } await invalidateAll(); + goto(result.data?.redirectUrl || `/${uname}`); + } else { + applyAction(result); } - 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(); + goto(result.data?.redirectUrl || "/"); + } else { + applyAction(result); } - applyAction(result); }; } }; @@ -201,14 +219,24 @@ body: formData, }); - const result = deserialize(await response.text()); + const result: any = deserialize(await response.text()); - if (result.type === "success" || result.type === "redirect") { - rememberPrfKey(prfKey, defaultRememberForMs); + if (result.type === "success") { + try { + await resolveCanonicalKey( + passkeyMethodId(credential.id), + prfKey, + result.data?.encryptedKeys, + prfKey, + ); + } catch (e) { + console.log("Canonical key resolution failed", e); + } await invalidateAll(); + goto(result.data?.redirectUrl || "/"); + } else { + applyAction(result); } - - applyAction(result); } catch (e: any) { if (e.name === "NotAllowedError") { // user cancelled the browser prompt @@ -248,19 +276,26 @@ body: formData, }); - const result = deserialize(await response.text()); + const result: any = deserialize(await response.text()); - if (result.type === "success" || result.type === "redirect") { + if (result.type === "success") { try { const { deriveNostrEntropy } = await import("$lib/walletEntropy"); - await deriveNostrEntropy(); + const oldPrfKey = await deriveNostrEntropy(); + await resolveCanonicalKey( + nostrMethodId(signedEvent.pubkey), + oldPrfKey, + result.data?.encryptedKeys, + oldPrfKey, + ); } catch (e) { - console.log("Nostr entropy derivation failed", e); + console.log("Nostr canonical key resolution failed", e); } await invalidateAll(); + goto(result.data?.redirectUrl || "/"); + } else { + applyAction(result); } - - applyAction(result); } catch (e: any) { fail(e.message); } diff --git a/src/routes/(static)/register/+page.server.ts b/src/routes/(static)/register/+page.server.ts index 55331c5a..e56f4e14 100644 --- a/src/routes/(static)/register/+page.server.ts +++ b/src/routes/(static)/register/+page.server.ts @@ -1,113 +1,10 @@ -import { fd, get, login, post } from "$lib/utils"; -import { fail, redirect } from "@sveltejs/kit"; +import { redirect } from "@sveltejs/kit"; export const load = async ({ parent }) => { const { user } = await parent(); if (user) redirect(307, `/${user.username}`); const index = Math.floor(Math.random() * 64) + 1; - const { challenge } = await get("/challenge"); - return { index, challenge }; -}; - -export const actions = { - 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, password, challenge, recaptcha, authPubkey } = form; - let { loginRedirect } = form; - if (loginRedirect === "undefined") loginRedirect = undefined; - - const user = { - picture, - username, - password, - challenge, - recaptcha, - authPubkey: authPubkey || undefined, - }; - - const headers: Record<string, string> = {}; - if (ip) headers["cf-connecting-ip"] = ip; - - try { - const { sk, token } = await post("/register", { user }, headers); - - // 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}`); - }, - - passkeyCreate: async ({ request }) => { - const ip = request.headers.get("cf-connecting-ip") ?? ""; - const form = await fd(request); - const { username, password, challenge, recaptcha, picture } = form; - - const user = { picture, username, password, 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, username, sk }; - } 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, pubkey } = form; - let { loginRedirect } = form; - if (loginRedirect === "undefined") loginRedirect = undefined; - - 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", - }); - } - - if (pubkey) { - try { - await post("/user", { pubkey }, { authorization: `Bearer ${token}` }); - } catch (e) { - console.log("Failed to update pubkey:", e); - } - } - - redirect(303, loginRedirect || `/${username}`); - }, + return { index }; }; diff --git a/src/routes/(static)/register/+page.svelte b/src/routes/(static)/register/+page.svelte index e2d53fd0..0d32cf21 100644 --- a/src/routes/(static)/register/+page.svelte +++ b/src/routes/(static)/register/+page.svelte @@ -1,57 +1,18 @@ <script lang="ts"> - import { PUBLIC_RECAPTCHA_SITE_KEY } from "$env/static/public"; - import { onDestroy, onMount } from "svelte"; - import { browser } from "$app/environment"; + import { goto } from "$app/navigation"; + import { page } from "$app/stores"; import punks from "$lib/punks"; - import { upload } from "$lib/upload"; - import { afterNavigate, invalidateAll } from "$app/navigation"; - import { applyAction, deserialize } from "$app/forms"; - import { tick } from "svelte"; - import { fly } from "svelte/transition"; - 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"; - import { registerPasskey } from "$lib/passkey"; - import { rememberPrfKey, defaultRememberForMs } from "$lib/passwordCache"; - import { avatar, signer, password, pin, loginRedirect } from "$lib/store"; + import { avatar } from "$lib/store"; + import { focus } from "$lib/utils"; import { t } from "$lib/translations"; - import { page } from "$app/stores"; import { NumberDictionary, uniqueNamesGenerator, animals } from "unique-names-generator"; - import { sign } from "$lib/nostr"; - - let { form, data }: any = $props(); - let { challenge } = $derived(data); - let recaptchaSiteKey = PUBLIC_RECAPTCHA_SITE_KEY; - let isTor = browser && location.hostname.endsWith(".onion"); - onMount(() => { - if (!isTor && recaptchaSiteKey) { - let s = document.createElement("script"); - s.src = "https://www.google.com/recaptcha/api.js?render=" + recaptchaSiteKey; - document.head.appendChild(s); - } - }); - - onMount(() => { - if (browser) { - setTimeout(() => { - password.set(undefined); - pin.set(undefined); - signer.set(undefined); - localStorage.clear(); - sessionStorage.clear(); - }, 50); - } - }); + let { data }: any = $props(); let username: string | undefined = $state(); let index = $state(data.index); - let cleared; + let cleared: boolean; let clear = () => { if (!cleared) { cleared = true; @@ -70,197 +31,18 @@ }); }; - afterNavigate(async () => { - try { - await invalidateAll(); - } catch (e) { - console.log(e); - } - }); - - let token: string = $state(""), - formElement: any; - let redirect: any; - - let cancel: any = () => { - need2fa = false; - }; - - let btn: HTMLButtonElement = $state() as any; - - let loading = $state(false); - let passkeyLoading = $state(false); - - const getRecaptchaToken = () => - new Promise((resolve, reject) => { - if (isTor) return resolve(""); - if (!browser || !grecaptcha) return reject(new Error("captcha unavailable")); - grecaptcha.ready(() => { - grecaptcha.execute(recaptchaSiteKey, { action: "register" }).then(resolve).catch(reject); - }); - }); - - async function handleSubmit(e: SubmitEvent) { - e.preventDefault(); - - loading = true; - - let data = new FormData(e.target as HTMLFormElement); - 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]); - } - - try { - const recaptcha = await getRecaptchaToken(); - data.set("recaptcha", recaptcha as string); - } catch (err: any) { - fail(err.message || "captcha failed"); - loading = false; - return; - } - - data.set("picture", `${$page.url.origin}/api/public/${punks[index]}.webp`); - if ($avatar) { - try { - let { hash } = JSON.parse( - (await upload( - ($avatar as any).file, - ($avatar as any).type, - ($avatar as any).progress, - )) as string, - ); - - let url = `${$page.url.origin}/api/public/${hash}.webp`; - data.set("picture", url); - await fetch(url, { - cache: "reload", - mode: "no-cors", - }); - } catch (e) { - console.log("problem uploading avatar", e); - } - } - - // 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, - }); - - const result = deserialize(await response.text()); - - if (result.type === "success") { - // Derive wallet entropy from password - try { - 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); - } - - await invalidateAll(); - } - - applyAction(result); - loading = false; - } - - let passkeyRegister = async () => { - passkeyLoading = true; - try { - let name = - username || - uniqueNamesGenerator({ - dictionaries: [animals, NumberDictionary.generate({ min: 10, max: 99 })], - length: 2, - separator: "", - }); - - let pw = Array.from(crypto.getRandomValues(new Uint8Array(32)), (b) => - b.toString(16).padStart(2, "0"), - ).join(""); - - const recaptcha = await getRecaptchaToken(); - - const formData = new FormData(); - formData.append("username", name); - formData.append("password", pw); - formData.append("challenge", challenge); - formData.append("recaptcha", recaptcha as string); - formData.append("picture", `${$page.url.origin}/api/public/${punks[index]}.webp`); - - const response = await fetch("/register?/passkeyCreate", { - method: "POST", - body: formData, - }); - - const result: any = deserialize(await response.text()); - if (result.type !== "success") { - fail(result.data?.error || "Account creation failed"); - passkeyLoading = false; - return; - } - - const { token, sk } = result.data; - const prfKey = await registerPasskey(token); - - const activateData = new FormData(); - activateData.append("token", token); - activateData.append("username", name); - if (sk) activateData.append("sk", sk); - activateData.append("loginRedirect", (redirect ?? "") as string); - - const activateResponse = await fetch("/register?/activate", { - method: "POST", - body: activateData, - }); - - const activateResult = deserialize(await activateResponse.text()); - - if (activateResult.type === "success" || activateResult.type === "redirect") { - rememberPrfKey(prfKey, defaultRememberForMs); - await invalidateAll(); - } - - applyAction(activateResult); - } catch (e: any) { - if (e.name !== "NotAllowedError") { - fail(e.message || "Passkey registration failed"); - } - } - passkeyLoading = false; - }; - let avatarInput: HTMLInputElement = $state() as any; let decr = () => (index = index <= 0 ? 63 : index - 1); let incr = () => (index = index >= 63 ? 0 : index + 1); let selectAvatar = () => avatarInput.click(); - let progress; + let progress: any; let handleFile = async ({ target }: { target: HTMLInputElement }) => { let type = "picture"; let file = target.files![0]; if (!file) return; - if (file.size > 10000000) form!.error = "File too large"; + if (file.size > 10000000) return; $avatar = { file, type, progress }; var reader = new FileReader(); @@ -271,87 +53,18 @@ reader.readAsDataURL(file); }; - let need2fa: any = $derived(form?.message === "2fa"); let src: string = $derived(`/api/public/${punks[index]}.webp`); - $effect(() => { - if (need2fa && form?.token === token) token = ""; - }); - - $effect(() => { - token && token.length === 6 && tick().then(() => btn.click()); - }); - - let nostrLogin = async () => { - let event = { - kind: 27235, - created_at: Date.now(), - content: "", - tags: [ - ["u", `${$page.url.origin}/api/nostrAuth`], - ["method", "POST"], - ["challenge", challenge], - ], - }; - - let signedEvent = await sign(event); - - const formData = new FormData(); - - try { - const recaptcha = await getRecaptchaToken(); - formData.append("loginRedirect", redirect ?? ""); - formData.append("token", token ?? ""); - formData.append("event", JSON.stringify(signedEvent)); - formData.append("challenge", challenge); - formData.append("recaptcha", recaptcha as string); - - let response = await fetch("/login?/nostr", { - method: "POST", - body: formData, - }); - - const result = deserialize(await response.text()); - - if (result.type === "success" || result.type === "redirect") { - try { - const { deriveNostrEntropy } = await import("$lib/walletEntropy"); - await deriveNostrEntropy(); - } catch (e) { - console.log("Nostr entropy derivation failed", e); - } - await invalidateAll(); - } - - applyAction(result); - } catch (e: any) { - fail(e.message); - } + let continueToAuth = () => { + let name = (username || "").replace(/\s*/g, ""); + if (!name) return; + goto(`/register/auth?username=${encodeURIComponent(name)}&index=${index}`); }; - - onDestroy(() => { - if (!browser) return; - const nodeBadge = document.querySelector(".grecaptcha-badge"); - if (nodeBadge) { - document.body.removeChild(nodeBadge.parentNode!); - } - - const scriptSelector = - "script[src='https://www.google.com/recaptcha/api.js?render=" + recaptchaSiteKey + "']"; - const script = document.querySelector(scriptSelector); - if (script) { - script.remove(); - } - }); </script> -{#if need2fa} - <Pin bind:value={token} title={$t("login.enter2faCode")} {cancel} notify={false} /> -{/if} - <div class="mx-auto md:shadow-xl rounded-3xl max-w-xl w-full md:w-[480px] md:p-8 space-y-5 mb-20"> <h1 class="text-2xl font-bold text-center">{$t("login.createAccount")}</h1> - <input type="file" class="hidden!" bind:this={avatarInput} onchange={(e: any) => handleFile(e)} /> + <input type="file" accept="image/*" class="hidden!" bind:this={avatarInput} onchange={(e: any) => handleFile(e)} /> <div class="relative"> <button @@ -371,8 +84,9 @@ alt={username} /> </div> - <div class="absolute bg-base-100 rounded-full p-2 mx-auto right-0 bottom-0 z-10 w-12"> - <iconify-icon noobserver icon="ph:upload-simple-bold" width="24"></iconify-icon> + <div class="absolute bg-base-100 rounded-full px-3 py-1 mx-auto -right-6 -bottom-2 z-10 flex items-center gap-1 text-base"> + <iconify-icon noobserver icon="ph:camera-bold" width="24"></iconify-icon> + Upload </div> </button> <button @@ -384,22 +98,10 @@ </button> </div> - {#if form?.error || form?.message} - <div class="text-red-600 text-center" in:fly>{form?.message}{form?.error}</div> - {/if} - - <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"> + <div class="space-y-3"> + <label for="username" class="input flex items-center gap-2 w-full pr-0! overflow-hidden"> <input - class="clean" + class="clean min-w-0 shrink" use:focus name="username" type="text" @@ -408,40 +110,18 @@ onfocus={clear} autocapitalize="none" placeholder={$t("login.username")} + onkeydown={(e: KeyboardEvent) => e.key === "Enter" && continueToAuth()} /> - <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> + <span class="bg-base-300 whitespace-nowrap select-none self-stretch flex items-center px-3">@coinos.io</span> </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="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">Register with Passkey</div> + <button type="button" class="btn gap-2" onclick={refresh}> + <iconify-icon noobserver icon="ph:dice-three-bold" width="32"></iconify-icon> + Random username </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 class="btn btn-accent" onclick={continueToAuth}> + Continue </button> <p class="text-secondary text-center font-medium"> @@ -453,5 +133,5 @@ {$t("login.signIn")} </a> </p> - </form> + </div> </div> diff --git a/src/routes/(static)/register/auth/+page.server.ts b/src/routes/(static)/register/auth/+page.server.ts new file mode 100644 index 00000000..06735396 --- /dev/null +++ b/src/routes/(static)/register/auth/+page.server.ts @@ -0,0 +1,115 @@ +import { fd, get, login, post } from "$lib/utils"; +import { fail, redirect } from "@sveltejs/kit"; + +export const load = async ({ parent, url }) => { + const { user } = await parent(); + if (user) redirect(307, `/${user.username}`); + + const username = url.searchParams.get("username"); + if (!username) redirect(307, "/register"); + + const { challenge } = await get("/challenge"); + + return { username, challenge }; +}; + +export const actions = { + 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, password, challenge, recaptcha, authPubkey } = form; + let { loginRedirect } = form; + if (loginRedirect === "undefined") loginRedirect = undefined; + + const user = { + picture, + username, + password, + challenge, + recaptcha, + authPubkey: authPubkey || undefined, + }; + + const headers: Record<string, string> = {}; + if (ip) headers["cf-connecting-ip"] = ip; + + try { + const { sk, token } = await post("/register", { user }, headers); + + // 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}`); + }, + + passkeyCreate: async ({ request }) => { + const ip = request.headers.get("cf-connecting-ip") ?? ""; + const form = await fd(request); + const { username, password, challenge, recaptcha, picture } = form; + + const user = { picture, username, password, 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, username, sk }; + } 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, pubkey } = form; + let { loginRedirect } = form; + if (loginRedirect === "undefined") loginRedirect = undefined; + + 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", + }); + } + + if (pubkey) { + try { + await post("/user", { pubkey }, { authorization: `Bearer ${token}` }); + } catch (e) { + console.log("Failed to update pubkey:", e); + } + } + + redirect(303, loginRedirect || `/${username}`); + }, +}; diff --git a/src/routes/(static)/register/auth/+page.svelte b/src/routes/(static)/register/auth/+page.svelte new file mode 100644 index 00000000..d285e0af --- /dev/null +++ b/src/routes/(static)/register/auth/+page.svelte @@ -0,0 +1,393 @@ +<script lang="ts"> + import { PUBLIC_RECAPTCHA_SITE_KEY } from "$env/static/public"; + import { onDestroy, onMount } from "svelte"; + import { browser } from "$app/environment"; + import punks from "$lib/punks"; + import { upload } from "$lib/upload"; + import { invalidateAll } from "$app/navigation"; + import { applyAction, deserialize } from "$app/forms"; + import { tick } from "svelte"; + import { fly } from "svelte/transition"; + + import Pin from "$comp/Pin.svelte"; + import PasswordInput from "$comp/PasswordInput.svelte"; + import Spinner from "$comp/Spinner.svelte"; + + import { fail, focus } from "$lib/utils"; + import { registerPasskey } from "$lib/passkey"; + import { rememberPrfKey, defaultRememberForMs } from "$lib/passwordCache"; + import { avatar, signer, password, pin, loginRedirect } from "$lib/store"; + import { + generateCanonicalKey, + wrapCanonicalKey, + saveEncryptedKeys, + resolveCanonicalKey, + passwordMethodId, + passkeyMethodId, + nostrMethodId, + } from "$lib/keyWrapping"; + import { t } from "$lib/translations"; + import { page } from "$app/stores"; + import { sign } from "$lib/nostr"; + + let { form, data }: any = $props(); + let { challenge, username } = $derived(data); + let index = $derived(Number($page.url.searchParams.get("index") || "1")); + let recaptchaSiteKey = PUBLIC_RECAPTCHA_SITE_KEY; + let isTor = browser && location.hostname.endsWith(".onion"); + + onMount(() => { + if (!isTor && recaptchaSiteKey) { + let s = document.createElement("script"); + s.src = "https://www.google.com/recaptcha/api.js?render=" + recaptchaSiteKey; + document.head.appendChild(s); + } + }); + + onMount(() => { + if (browser) { + setTimeout(() => { + password.set(undefined); + pin.set(undefined); + signer.set(undefined); + localStorage.clear(); + sessionStorage.clear(); + }, 50); + } + }); + + let token: string = $state(""), + formElement: any; + let redirect: any = $derived($loginRedirect || $page.url.searchParams.get("redirect")); + + let cancel: any = () => { + need2fa = false; + }; + + let btn: HTMLButtonElement = $state() as any; + + let loading = $state(false); + let passkeyLoading = $state(false); + + const getRecaptchaToken = () => + new Promise((resolve, reject) => { + if (isTor) return resolve(""); + if (!browser || !grecaptcha) return reject(new Error("captcha unavailable")); + grecaptcha.ready(() => { + grecaptcha.execute(recaptchaSiteKey, { action: "register" }).then(resolve).catch(reject); + }); + }); + + async function handleSubmit(e: SubmitEvent) { + e.preventDefault(); + + loading = true; + + let data = new FormData(e.target as HTMLFormElement); + let user: Record<string, any> = Object.fromEntries(data); + user.username = username.replace(/\s*/g, ""); + + if (!user.password) { + fail("Password is required"); + loading = false; + return; + } + + for (let k in user) { + data.set(k, user[k]); + } + + try { + const recaptcha = await getRecaptchaToken(); + data.set("recaptcha", recaptcha as string); + } catch (err: any) { + fail(err.message || "captcha failed"); + loading = false; + return; + } + + data.set("picture", `${$page.url.origin}/api/public/${punks[index]}.webp`); + if ($avatar) { + try { + let { hash } = JSON.parse( + (await upload( + ($avatar as any).file, + ($avatar as any).type, + ($avatar as any).progress, + )) as string, + ); + + let url = `${$page.url.origin}/api/public/${hash}.webp`; + data.set("picture", url); + await fetch(url, { + cache: "reload", + mode: "no-cors", + }); + } catch (e) { + console.log("problem uploading avatar", e); + } + } + + // 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/auth?/register", { + method: "POST", + body: data, + }); + + const result = deserialize(await response.text()); + + if (result.type === "success") { + try { + const { deriveAuthKeypair } = await import("$lib/deriveAuthKey"); + const { sk } = await deriveAuthKeypair(user.username, user.password); + const canonicalKey = generateCanonicalKey(); + const wrapped = await wrapCanonicalKey(sk.buffer as ArrayBuffer, canonicalKey); + await saveEncryptedKeys({ [passwordMethodId()]: wrapped }); + rememberPrfKey(canonicalKey, defaultRememberForMs); + } catch (e) { + console.log("Canonical key generation failed", e); + } + + await invalidateAll(); + } + + applyAction(result); + loading = false; + } + + let passkeyRegister = async () => { + passkeyLoading = true; + try { + let pw = Array.from(crypto.getRandomValues(new Uint8Array(32)), (b) => + b.toString(16).padStart(2, "0"), + ).join(""); + + const recaptcha = await getRecaptchaToken(); + + const formData = new FormData(); + formData.append("username", username); + formData.append("password", pw); + formData.append("challenge", challenge); + formData.append("recaptcha", recaptcha as string); + formData.append("picture", `${$page.url.origin}/api/public/${punks[index]}.webp`); + + const response = await fetch("/register/auth?/passkeyCreate", { + method: "POST", + body: formData, + }); + + const result: any = deserialize(await response.text()); + if (result.type !== "success") { + fail(result.data?.error || "Account creation failed"); + passkeyLoading = false; + return; + } + + const { token, sk } = result.data; + const { prfKey, credentialId } = await registerPasskey(token); + + const activateData = new FormData(); + activateData.append("token", token); + activateData.append("username", username); + if (sk) activateData.append("sk", sk); + activateData.append("loginRedirect", (redirect ?? "") as string); + + const activateResponse = await fetch("/register/auth?/activate", { + method: "POST", + body: activateData, + }); + + const activateResult = deserialize(await activateResponse.text()); + + if (activateResult.type === "success" || activateResult.type === "redirect") { + try { + const canonicalKey = generateCanonicalKey(); + const wrapped = await wrapCanonicalKey(prfKey, canonicalKey); + await saveEncryptedKeys({ [passkeyMethodId(credentialId)]: wrapped }); + rememberPrfKey(canonicalKey, defaultRememberForMs); + } catch (e) { + console.log("Canonical key generation failed", e); + } + await invalidateAll(); + } + + applyAction(activateResult); + } catch (e: any) { + if (e.name !== "NotAllowedError") { + fail(e.message || "Passkey registration failed"); + } + } + passkeyLoading = false; + }; + + let need2fa: any = $derived(form?.message === "2fa"); + + let src: string = $derived( + $avatar ? URL.createObjectURL(($avatar as any).file) : `/api/public/${punks[index]}.webp`, + ); + + $effect(() => { + if (need2fa && form?.token === token) token = ""; + }); + + $effect(() => { + token && token.length === 6 && tick().then(() => btn.click()); + }); + + let nostrLogin = async () => { + let event = { + kind: 27235, + created_at: Date.now(), + content: "", + tags: [ + ["u", `${$page.url.origin}/api/nostrAuth`], + ["method", "POST"], + ["challenge", challenge], + ], + }; + + let signedEvent = await sign(event); + + const formData = new FormData(); + + try { + const recaptcha = await getRecaptchaToken(); + formData.append("loginRedirect", redirect ?? ""); + formData.append("token", token ?? ""); + formData.append("event", JSON.stringify(signedEvent)); + formData.append("challenge", challenge); + formData.append("recaptcha", recaptcha as string); + + let response = await fetch("/login?/nostr", { + method: "POST", + body: formData, + }); + + const result: any = deserialize(await response.text()); + + if (result.type === "success" || result.type === "redirect") { + try { + const { deriveNostrEntropy } = await import("$lib/walletEntropy"); + const oldPrfKey = await deriveNostrEntropy(); + await resolveCanonicalKey( + nostrMethodId(signedEvent.pubkey), + oldPrfKey, + result.data?.encryptedKeys, + oldPrfKey, + ); + } catch (e) { + console.log("Nostr canonical key resolution failed", e); + } + await invalidateAll(); + } + + applyAction(result); + } catch (e: any) { + fail(e.message); + } + }; + + onDestroy(() => { + if (!browser) return; + const nodeBadge = document.querySelector(".grecaptcha-badge"); + if (nodeBadge) { + document.body.removeChild(nodeBadge.parentNode!); + } + + const scriptSelector = + "script[src='https://www.google.com/recaptcha/api.js?render=" + recaptchaSiteKey + "']"; + const script = document.querySelector(scriptSelector); + if (script) { + script.remove(); + } + }); +</script> + +{#if need2fa} + <Pin bind:value={token} title={$t("login.enter2faCode")} {cancel} notify={false} /> +{/if} + +<div class="mx-auto md:shadow-xl rounded-3xl max-w-xl w-full md:w-[480px] md:p-8 space-y-5 mb-20"> + <h1 class="text-2xl font-bold text-center">{$t("login.createAccount")}</h1> + + <div class="flex flex-col items-center gap-2"> + <div class="w-24 h-24 rounded-full overflow-hidden"> + <img {src} class="w-full h-full object-cover" alt={username} /> + </div> + <span class="text-xl font-medium">{username}</span> + </div> + + {#if form?.error || form?.message} + <div class="text-red-600 text-center" in:fly>{form?.message}{form?.error}</div> + {/if} + + <form class="space-y-5" onsubmit={handleSubmit} method="POST"> + <input + type="hidden" + name="loginRedirect" + value={redirect} + /> + <input type="hidden" name="token" value={token} /> + <input type="hidden" name="challenge" value={challenge} /> + <input type="hidden" name="username" value={username} /> + + <PasswordInput bind:value={$password} placeholder={$t("login.password")} {focus} /> + + <button type="submit" class="btn btn-accent" disabled={loading} bind:this={btn}> + {#if loading} + <Spinner /> + {:else} + {$t("login.register")} + {/if} + </button> + + <div class="flex items-center gap-3"> + <div class="h-px bg-base-300 flex-1"></div> + <span class="text-secondary text-lg">or</span> + <div class="h-px bg-base-300 flex-1"></div> + </div> + + <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">Register with Passkey</div> + </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">Register with 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> + + <a href="/register" class="btn btn-ghost gap-2"> + <iconify-icon noobserver icon="ph:arrow-left-bold" width="20"></iconify-icon> + Back + </a> + </form> +</div> |
