diff options
| author | Adam Soltys <[email protected]> | 2026-02-16 20:56:39 -0800 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-02-16 20:56:39 -0800 |
| commit | b22c813f9fb3b4d69dade75d22e90e6779919f07 (patch) | |
| tree | 173fe377e83ad65ac6721dd567d8e90d9d204be8 /src | |
| parent | 8bb150db1f73164030f9e8085c13765120363644 (diff) | |
| download | coinos-ui-b22c813f9fb3b4d69dade75d22e90e6779919f07.tar.gz coinos-ui-b22c813f9fb3b4d69dade75d22e90e6779919f07.zip | |
nostr key derivation
Diffstat (limited to 'src')
| -rw-r--r-- | src/components/Account.svelte | 48 | ||||
| -rw-r--r-- | src/lib/passwordCache.ts | 12 | ||||
| -rw-r--r-- | src/lib/walletEntropy.ts | 67 | ||||
| -rw-r--r-- | src/locales/en.json | 39 | ||||
| -rw-r--r-- | src/routes/(app)/+layout.svelte | 38 | ||||
| -rw-r--r-- | src/routes/(app)/[username]/(profile)/+page.svelte | 2 | ||||
| -rw-r--r-- | src/routes/(app)/account/[id]/+page.svelte | 103 | ||||
| -rw-r--r-- | src/routes/(app)/account/ark/+page.svelte | 92 | ||||
| -rw-r--r-- | src/routes/(app)/account/bitcoin/+page.server.ts | 5 | ||||
| -rw-r--r-- | src/routes/(app)/account/import/+page.svelte | 2 | ||||
| -rw-r--r-- | src/routes/(app)/account/new/+page.server.ts | 8 | ||||
| -rw-r--r-- | src/routes/(app)/account/pass/+page.svelte | 15 | ||||
| -rw-r--r-- | src/routes/(app)/account/seed/+page.svelte | 16 | ||||
| -rw-r--r-- | src/routes/(app)/account/setup/+page.svelte | 2 | ||||
| -rw-r--r-- | src/routes/(app)/send/bitcoin/[address]/[amount]/[...feeRate]/+page.svelte | 13 | ||||
| -rw-r--r-- | src/routes/(app)/settings/security/+page.svelte | 4 | ||||
| -rw-r--r-- | src/routes/(static)/login/+page.svelte | 10 | ||||
| -rw-r--r-- | src/routes/(static)/register/+page.svelte | 47 |
18 files changed, 392 insertions, 131 deletions
diff --git a/src/components/Account.svelte b/src/components/Account.svelte index 953e44ab..70bded13 100644 --- a/src/components/Account.svelte +++ b/src/components/Account.svelte @@ -50,6 +50,31 @@ } } + // Try silent Nostr derivation + if (!prfKey) { + const { getWalletEntropy } = await import("$lib/walletEntropy"); + const nostrKey = await getWalletEntropy(); + if (nostrKey) { + try { + const [{ HDKey }, { entropyToMnemonic, mnemonicToSeed }, { wordlist }] = await Promise.all([ + import("@scure/bip32"), + import("@scure/bip39"), + import("@scure/bip39/wordlists/english.js"), + ]); + const entropy = new Uint8Array(nostrKey); + 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("Nostr entropy unlock failed", e); + } + } + } + // Then try cached wallet password const cached = getRememberedWalletPassword(); if (!cached) return false; @@ -131,7 +156,7 @@ ? $t("accounts.ark") : seed || fingerprint ? $t("accounts.bitcoin") - : $t("accounts.custodial"), + : $t("accounts.spending"), ); let displayName = $derived(name || displayType); let isArk = $derived(accountType === "ark"); @@ -165,20 +190,11 @@ <Balance {balance} {user} {rate} {id} {currency} /> {#if pending} <div class="text-lg text-gray-600"> - +{$fiat && rate ? f(toFiat(pending, rate), currency, loc(user)) : s(pending)} pending + +{$fiat && rate ? f(toFiat(pending, rate), currency, loc(user)) : s(pending)} {$t("accounts.pending")} </div> {/if} </div> <div class="flex items-start gap-4 shrink-0 text-black"> - <div class="flex items-center justify-center w-8 h-8 leading-[0]"> - {#if isArk} - <img src="/images/ark.png" class="w-8 h-8 rounded-full object-cover" alt="Ark" /> - {:else if seed || fingerprint} - <iconify-icon noobserver icon="cryptocurrency-color:btc" width="32"></iconify-icon> - {:else} - <img src="/images/icon.png" class="w-8 h-8" alt="Coinos" /> - {/if} - </div> <a href="/payments" class="hover:text-gray-800" @@ -189,11 +205,17 @@ </a> <a href={`/account/${id}`} - class="hover:text-gray-800" + class="hover:opacity-80" aria-label="Account settings" onclick={goSettings} > - <iconify-icon noobserver icon="ph:gear-bold" width="32"></iconify-icon> + {#if isArk} + <img src="/images/ark.png" class="w-8 h-8 rounded-full object-cover" alt="Ark" /> + {:else if seed || fingerprint} + <iconify-icon noobserver icon="cryptocurrency-color:btc" width="32"></iconify-icon> + {:else} + <img src="/images/icon.png" class="w-8 h-8" alt="Coinos" /> + {/if} </a> </div> </div> diff --git a/src/lib/passwordCache.ts b/src/lib/passwordCache.ts index 98272e0f..0ed36af1 100644 --- a/src/lib/passwordCache.ts +++ b/src/lib/passwordCache.ts @@ -5,12 +5,12 @@ const PASSWORD_KEY = "walletPassword"; const EXPIRES_KEY = "walletPasswordExpiresAt"; export const rememberForOptions = [ - { label: "Don't remember", ms: 0 }, - { label: "8 hours", ms: 8 * 60 * 60 * 1000 }, - { label: "1 day", ms: 24 * 60 * 60 * 1000 }, - { label: "1 week", ms: 7 * 24 * 60 * 60 * 1000 }, - { label: "1 month", ms: 30 * 24 * 60 * 60 * 1000 }, - { label: "1 year", ms: 365 * 24 * 60 * 60 * 1000 }, + { label: "user.settings.dontRemember", ms: 0 }, + { label: "user.settings.8hours", ms: 8 * 60 * 60 * 1000 }, + { label: "user.settings.1day", ms: 24 * 60 * 60 * 1000 }, + { label: "user.settings.1week", ms: 7 * 24 * 60 * 60 * 1000 }, + { label: "user.settings.1month", ms: 30 * 24 * 60 * 60 * 1000 }, + { label: "user.settings.1year", ms: 365 * 24 * 60 * 60 * 1000 }, ]; export const defaultRememberForMs = 30 * 24 * 60 * 60 * 1000; diff --git a/src/lib/walletEntropy.ts b/src/lib/walletEntropy.ts new file mode 100644 index 00000000..f022a2cc --- /dev/null +++ b/src/lib/walletEntropy.ts @@ -0,0 +1,67 @@ +import { browser } from "$app/environment"; +import { getCachedPrfKey, rememberPrfKey, defaultRememberForMs } from "$lib/passwordCache"; + +const DERIVATION_EVENT = { + kind: 1, + created_at: 0, + content: "coinos-wallet-key-derivation", + tags: [], +}; + +async function hashSignature(sig: string): Promise<ArrayBuffer> { + const sigBytes = new Uint8Array(sig.length / 2); + for (let i = 0; i < sig.length; i += 2) + sigBytes[i / 2] = parseInt(sig.slice(i, i + 2), 16); + return crypto.subtle.digest("SHA-256", sigBytes); +} + +/** + * Silent helper. Checks localStorage for nsec, signs fixed event locally, + * caches and returns entropy. Returns null if no nsec stored. + */ +export async function tryDeriveFromNsec(): Promise<ArrayBuffer | null> { + if (!browser) return null; + const nsec = localStorage.getItem("nsec"); + if (!nsec) return null; + + try { + 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; + } catch (e) { + console.log("nsec entropy derivation failed", e); + return null; + } +} + +/** + * Non-interactive. Checks cached prfKey, then tries silent nsec derivation. + * Returns null if neither available. Use in onMount and auto-provisioning. + */ +export async function getWalletEntropy(): Promise<ArrayBuffer | null> { + const cached = getCachedPrfKey(); + if (cached) return cached; + return tryDeriveFromNsec(); +} + +/** + * Interactive. Tries silent nsec first, then calls sign() from $lib/nostr + * which shows Nostr.svelte modal if signer isn't ready. Caches result. + * Use for "Unlock" buttons. + */ +export async function deriveNostrEntropy(): Promise<ArrayBuffer> { + const cached = getCachedPrfKey(); + if (cached) return cached; + + const fromNsec = await tryDeriveFromNsec(); + if (fromNsec) return fromNsec; + + const { sign } = await import("$lib/nostr"); + const signed = await sign({ ...DERIVATION_EVENT }); + const entropy = await hashSignature(signed.sig); + rememberPrfKey(entropy, defaultRememberForMs); + return entropy; +} diff --git a/src/locales/en.json b/src/locales/en.json index cf5132e7..70840ab0 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -69,6 +69,30 @@ "ark": "Ark", "bitcoin": "Bitcoin", "custodial": "Custodial", + "spending": "Spending", + "vault": "Vault", + "pending": "pending", + "changeSeedPhrase": "Change seed phrase", + "invalidSeedPhrase": "Invalid seed phrase", + "importFailed": "Import failed", + "invalidPassword": "Invalid password", + "invalidPasswordTryAgain": "Invalid password, try again", + "passwordRequired": "Password required", + "failedToDecryptSeed": "Failed to decrypt seed", + "passkeyNoPrf": "Passkey does not support PRF", + "passkeyUnlockFailed": "Passkey unlock failed", + "pleaseBackupFirst": "Please backup your key first", + "createSavingsAccount": "Create Savings Account", + "createWallet": "Create Wallet", + "unlockWithPasskey": "Unlock with Passkey", + "unlockWithNostr": "Unlock with Nostr", + "arkDerivedFromPasskey": "Your Ark wallet will be derived from your passkey-protected master seed.", + "arkUnlockWithPasskey": "Your Ark wallet will be derived from your master seed. Unlock with your passkey to continue.", + "arkEnterPassword": "Your Ark wallet will be derived from your master seed. Enter your wallet password to continue.", + "arkSecuredByKey": "Your Ark wallet is secured by a private key. Please copy it down somewhere safe.", + "setPasswordToEncrypt": "Set a password to encrypt your key. You'll need this password to send funds.", + "backedItUp": "I've backed it up", + "revealBackupKey": "Reveal backup key", "submit": "Submit", "type": "Type", "accountType": "Account type" @@ -238,7 +262,11 @@ "signUp": "Sign Up", "failed": "Login failed", "nostr": "Sign in with Nostr", - "nostrConnect": "Nostr connect" + "nostrConnect": "Nostr connect", + "accountCreatedNeedsPasskey": "Account created! You need to set up a passkey to continue.", + "setupPasskey": "Set up Passkey", + "passkeyCreationFailed": "Passkey creation failed. Please try again.", + "enter2faCode": "Enter 2FA Code" }, "nav": { "home": "Home", @@ -339,6 +367,12 @@ "memoPrompt": "Checkout instructions", "memoPromptPlaceholder": "e.g. Please enter your name for the order", "rememberFor": "Remember for", + "dontRemember": "Don't remember", + "8hours": "8 hours", + "1day": "1 day", + "1week": "1 week", + "1month": "1 month", + "1year": "1 year", "import": "Import secret key", "destination": "Auto withdrawal address", "destinationDesc": "The address that withdrawals will be sent to", @@ -556,7 +590,8 @@ "fundsClaimed": "Funds claimed!", "print": "Print", "onlyLbtc": "We only support", - "dontDeposit": "Please don't deposit other Liquid assets" + "dontDeposit": "Please don't deposit other Liquid assets", + "failedToSend": "Failed to send" }, "items": { "add": "Add item", diff --git a/src/routes/(app)/+layout.svelte b/src/routes/(app)/+layout.svelte index 6f2e061d..0fde24fa 100644 --- a/src/routes/(app)/+layout.svelte +++ b/src/routes/(app)/+layout.svelte @@ -130,7 +130,11 @@ let autoProvisionAccounts = async () => { if (!user) return; if (sessionStorage.getItem("accounts:provisioned")) return; - const prfKey = getCachedPrfKey(); + let prfKey = getCachedPrfKey(); + if (!prfKey) { + const { getWalletEntropy } = await import("$lib/walletEntropy"); + prfKey = await getWalletEntropy(); + } if (!prfKey) return; sessionStorage.setItem("accounts:provisioned", "1"); @@ -139,9 +143,8 @@ const res = await fetch("/account", { headers: { accept: "application/json" } }); const accounts = await res.json(); - const hasBitcoin = accounts.some((a: any) => a.type === "bitcoin"); const hasArk = accounts.some((a: any) => a.type === "ark"); - if (hasBitcoin && hasArk) return; + if (hasArk) return; const [{ HDKey }, { entropyToMnemonic, mnemonicToSeed }, { wordlist }] = await Promise.all([ import("@scure/bip32"), @@ -154,27 +157,14 @@ const seed = await mnemonicToSeed(mnemonic); const master = HDKey.fromMasterSeed(seed, versions); - if (!hasArk) { - const { SingleKey, Wallet } = await import("@arkade-os/sdk"); - const { bytesToHex } = await import("@noble/hashes/utils.js"); - 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 }); - const arkAddress = await wallet.getAddress(); - await post("/account", { name: "Savings", type: "ark", arkAddress }); - } - - if (!hasBitcoin) { - const child = master.derive("m/84'/0'/0'"); - await post("/account", { - fingerprint: child.fingerprint.toString(16).padStart(8, "0"), - pubkey: child.publicExtendedKey, - name: "Vault", - type: "bitcoin", - accountIndex: 0, - }); - } + const { SingleKey, Wallet } = await import("@arkade-os/sdk"); + const { bytesToHex } = await import("@noble/hashes/utils.js"); + 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 }); + const arkAddress = await wallet.getAddress(); + await post("/account", { name: $t("accounts.savings"), type: "ark", arkAddress }); invalidate("app:payments"); } catch (e) { diff --git a/src/routes/(app)/[username]/(profile)/+page.svelte b/src/routes/(app)/[username]/(profile)/+page.svelte index 4885189b..b9af8756 100644 --- a/src/routes/(app)/[username]/(profile)/+page.svelte +++ b/src/routes/(app)/[username]/(profile)/+page.svelte @@ -64,7 +64,7 @@ {#each accounts as account} <Account {user} {rates} {account} /> {/each} - <a href="/account/new" class="contents"> + <a href="/account/seed" class="contents"> <button class="btn btn-accent w-full"> <iconify-icon noobserver icon="ph:plus-bold" width="24"></iconify-icon> {$t("accounts.addAccount")} diff --git a/src/routes/(app)/account/[id]/+page.svelte b/src/routes/(app)/account/[id]/+page.svelte index 0bab9c1b..674cb067 100644 --- a/src/routes/(app)/account/[id]/+page.svelte +++ b/src/routes/(app)/account/[id]/+page.svelte @@ -9,9 +9,12 @@ import { enhance } from "$app/forms"; import { t } from "$lib/translations"; import { tick } from "svelte"; - import { entropyToMnemonic, mnemonicToSeed } from "@scure/bip39"; + import { entropyToMnemonic, mnemonicToSeed, validateMnemonic, mnemonicToEntropy } from "@scure/bip39"; import { wordlist } from "@scure/bip39/wordlists/english.js"; + import { HDKey } from "@scure/bip32"; + import { versions } from "$lib/utils"; import { getRememberedWalletPassword, forgetWalletPassword, getCachedPrfKey } from "$lib/passwordCache"; + import Spinner from "$comp/Spinner.svelte"; let { data } = $props(); let account = $derived(data.account); @@ -70,8 +73,45 @@ let isCustodial = $derived(!seed && account.type !== "ark"); + let importing = $state(false); + let importText = $state(""); + let importSubmitting = $state(false); + + let importSeed = async () => { + let text = importText.replace(/,/g, " ").trim(); + if (!validateMnemonic(text, wordlist)) { + fail($t("accounts.invalidSeedPhrase")); + return; + } + + importSubmitting = true; + try { + let s = await mnemonicToSeed(text); + let master = HDKey.fromMasterSeed(s, versions); + let child = master.derive(`m/84'/0'/${account.accountIndex ?? 0}'`); + let pubkey = child.publicExtendedKey; + let fp = child.fingerprint.toString(16).padStart(8, "0"); + + await post(`/post/account/${id}`, { + fingerprint: fp, + pubkey, + }); + + importing = false; + importText = ""; + goto(`/${user.username}`, { invalidateAll: true }); + } catch (e: any) { + fail(e.message || $t("accounts.importFailed")); + } + importSubmitting = false; + }; + let revealWithPrfKey = async () => { - const prfKey = getCachedPrfKey(); + let prfKey = getCachedPrfKey(); + if (!prfKey) { + const { getWalletEntropy } = await import("$lib/walletEntropy"); + prfKey = await getWalletEntropy(); + } if (!prfKey) return false; try { const entropy = new Uint8Array(prfKey); @@ -110,7 +150,7 @@ let entropy = await decrypt(seed, password as string); mnemonic = entropyToMnemonic(entropy, wordlist); } catch (e: any) { - fail("Invalid password, try again"); + fail($t("accounts.invalidPasswordTryAgain")); passwordPrompt = true; } }); @@ -158,6 +198,7 @@ </select> </div> + {#if isCustodial} <div> <div class="flex justify-between items-center"> @@ -192,23 +233,13 @@ </button> <p class="text-secondary mt-1">{$t("user.settings.thresholdDesc")}</p> </div> - - <div> - <label for="reserve" class="font-bold mb-1 block">{$t("user.settings.reserve")}</label> - <button type="button" class="flex w-full" onclick={editReserve}> - <div class="p-4 border rounded-2xl rounded-r-none border-r-0 bg-base-200"> - <iconify-icon noobserver icon="ph:lightning-fill" class="text-yellow-300"></iconify-icon> - </div> - <div class="border-l-0 rounded-l-none pl-2 w-full p-4 border rounded-2xl text-left"> - {reserve} - </div> - <input type="hidden" name="reserve" bind:value={reserve} /> - </button> - <p class="text-secondary mt-1">{$t("user.settings.reserveDesc")}</p> - </div> </div> {/if} + <button type="submit" class="btn btn-accent !w-full"> + <div class="my-auto">{$t("accounts.submit")}</div> + </button> + <div class="space-y-2"> {#if mnemonic} <Mnemonic {mnemonic} /> @@ -224,17 +255,45 @@ </button> {/if} + {#if account.type === "bitcoin"} + {#if importing} + <div class="space-y-2"> + <textarea + placeholder={$t("accounts.enterSeed")} + class="w-full p-4 border rounded-xl h-36 text-lg" + bind:value={importText} + autocapitalize="none" + ></textarea> + <div class="flex gap-2"> + <button type="button" class="btn !w-auto grow" onclick={() => (importing = false)}> + {$t("accounts.back")} + </button> + <button type="button" class="btn btn-accent !w-auto grow" onclick={importSeed} disabled={importSubmitting}> + {#if importSubmitting} + <Spinner /> + {:else} + {$t("accounts.import")} + {/if} + </button> + </div> + </div> + {:else} + <button type="button" class="btn" onclick={() => (importing = true)}> + <iconify-icon noobserver icon="ph:warning-bold" width="32"></iconify-icon> + <div class="my-auto">{$t("accounts.changeSeedPhrase")}</div> + </button> + {/if} + {/if} + {#if seed || account.type === "ark"} <button onclick={del} type="button" class="btn"> <iconify-icon noobserver icon="ph:trash-bold" width="32"></iconify-icon> <div class="my-auto">{$t("accounts.deleteAccount")}</div> </button> {/if} + </div> - <button type="submit" class="btn btn-accent !w-full"> - <div class="my-auto">{$t("accounts.submit")}</div> - </button> </form> </div> </div> @@ -254,7 +313,7 @@ bind:submit={doneReserve} bind:element={thresholdEl} /> - <button bind:this={doneReserve} type="button" onclick={doneEditing} class="btn">Ok</button> + <button bind:this={doneReserve} type="button" onclick={doneEditing} class="btn">{$t("payments.ok")}</button> </div> </div> {/if} @@ -270,7 +329,7 @@ bind:submit={doneReserve} bind:element={reserveEl} /> - <button bind:this={doneReserve} type="button" onclick={doneEditing} class="btn">Ok</button> + <button bind:this={doneReserve} type="button" onclick={doneEditing} class="btn">{$t("payments.ok")}</button> </div> </div> {/if} diff --git a/src/routes/(app)/account/ark/+page.svelte b/src/routes/(app)/account/ark/+page.svelte index f2bc8991..b6bec5b7 100644 --- a/src/routes/(app)/account/ark/+page.svelte +++ b/src/routes/(app)/account/ark/+page.svelte @@ -26,7 +26,8 @@ let user = $derived(data.user); let hasMasterSeed = $derived(!!user.seed); - let hasPrfSeed = $derived(!!getCachedPrfKey()); + let hasPrfSeed = $state(!!getCachedPrfKey()); + let hasSigner = $state(false); let privateKey = $state(""); let arkAddress = $state(""); @@ -42,7 +43,7 @@ let revealConfirm = $state(false); let rememberForMs = $state(defaultRememberForMs); - let name = "Savings"; + let name = $t("accounts.savings"); let type = "ark"; let deriveFromPrfKey = async (prfKey: ArrayBuffer) => { @@ -60,7 +61,19 @@ confirmed = true; } catch (e: any) { console.log("PRF seed derivation failed", e); - fail("Failed to decrypt seed"); + fail($t("accounts.failedToDecryptSeed")); + } + }; + + let unlockWithNostr = async () => { + try { + const { deriveNostrEntropy } = await import("$lib/walletEntropy"); + const entropy = await deriveNostrEntropy(); + hasPrfSeed = true; + await deriveFromPrfKey(entropy); + needsPasskeyUnlock = false; + } catch (e: any) { + fail(e.message || $t("accounts.passkeyUnlockFailed")); } }; @@ -72,16 +85,29 @@ await deriveFromPrfKey(prfKey); needsPasskeyUnlock = false; } else { - fail("Passkey does not support PRF"); + fail($t("accounts.passkeyNoPrf")); } } catch (e: any) { if (e.name !== "NotAllowedError") { - fail(e.message || "Passkey unlock failed"); + fail(e.message || $t("accounts.passkeyUnlockFailed")); } } }; onMount(async () => { + hasSigner = !!localStorage.getItem("signer"); + + // Try wallet entropy (covers both cached prfKey and silent nsec derivation) + if (!hasPrfSeed) { + const { getWalletEntropy } = await import("$lib/walletEntropy"); + const entropy = await getWalletEntropy(); + if (entropy) { + hasPrfSeed = true; + await deriveFromPrfKey(entropy); + return; + } + } + if (hasPrfSeed) { const prfKey = getCachedPrfKey(); if (prfKey) { @@ -117,7 +143,7 @@ confirmed = true; } catch (e: any) { console.log("master seed derivation failed", e); - fail("Invalid password"); + fail($t("accounts.invalidPassword")); } }; @@ -164,7 +190,7 @@ if (hasMasterSeed) { if (!password) { - fail("Password required"); + fail($t("accounts.passwordRequired")); return; } @@ -190,7 +216,7 @@ } if (!confirmed) { - fail("Please backup your key first"); + fail($t("accounts.pleaseBackupFirst")); return; } @@ -224,7 +250,7 @@ <div class="space-y-5"> <div class="flex items-center justify-center gap-2"> - <h1 class="text-3xl font-semibold">Create Savings Account</h1> + <h1 class="text-3xl font-semibold">{$t("accounts.createSavingsAccount")}</h1> </div> <div class="container w-full mx-auto text-lg px-4 max-w-xl space-y-5"> @@ -232,27 +258,33 @@ {#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. + {$t("accounts.arkUnlockWithPasskey")} </p> + {#if hasSigner} + <button type="button" class="btn btn-accent" onclick={unlockWithNostr}> + <img src="/images/nostr.png" class="w-6" alt="Nostr" /> + {$t("accounts.unlockWithNostr")} + </button> + {/if} <button type="button" class="btn btn-accent" onclick={unlockWithPasskey}> <iconify-icon noobserver icon="ph:fingerprint-bold" width="24"></iconify-icon> - Unlock with Passkey + {$t("accounts.unlockWithPasskey")} </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. + {$t("accounts.arkDerivedFromPasskey")} </p> <div class="flex gap-2"> <a href={`/${user.username}`} class="contents"> - <button type="button" class="btn !w-auto grow">Back</button> + <button type="button" class="btn !w-auto grow">{$t("accounts.back")}</button> </a> <button disabled={submitting} type="submit" class="btn btn-accent !w-auto grow"> {#if submitting} <Spinner /> {:else} - Create Wallet + {$t("accounts.createWallet")} {/if} </button> </div> @@ -261,7 +293,7 @@ {: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. + {$t("accounts.arkEnterPassword")} </p> <label for="password" class="input flex items-center justify-center gap-2 w-full"> @@ -273,14 +305,14 @@ bind:value={password} autocapitalize="none" class="clean" - placeholder="Wallet password" + placeholder={$t("payments.enterWalletPass")} /> {:else} <input use:focus name="password" type="password" - placeholder="Wallet password" + placeholder={$t("payments.enterWalletPass")} required bind:value={password} autocapitalize="none" @@ -303,7 +335,7 @@ </label> <div class="space-y-2"> - <label for="rememberFor" class="text-sm text-secondary">Remember for</label> + <label for="rememberFor" class="text-sm text-secondary">{$t("user.settings.rememberFor")}</label> <select id="rememberFor" class="w-full" @@ -311,7 +343,7 @@ onchange={(e) => (rememberForMs = Number((e.target as HTMLSelectElement).value))} > {#each rememberForOptions as option} - <option value={option.ms}>{option.label}</option> + <option value={option.ms}>{$t(option.label)}</option> {/each} </select> </div> @@ -332,7 +364,7 @@ {:else if !confirmed} <div class="space-y-4"> <p class="text-secondary"> - Your Ark wallet is secured by a private key. Please copy it down somewhere safe. + {$t("accounts.arkSecuredByKey")} </p> {#if showNsec} @@ -350,20 +382,20 @@ <div class="flex gap-2"> <button type="button" class="btn btn-accent flex-grow" onclick={confirmBackup}> <iconify-icon noobserver icon="ph:check-bold" width="24"></iconify-icon> - I've backed it up + {$t("accounts.backedItUp")} </button> </div> {:else} <button type="button" class="btn btn-warning w-full" onclick={revealNsec}> <iconify-icon noobserver icon="ph:eye-bold" width="24"></iconify-icon> - Reveal backup key + {$t("accounts.revealBackupKey")} </button> {/if} </div> {:else} <form onsubmit={preventDefault(submit)} class="space-y-5"> <p class="text-secondary"> - Set a password to encrypt your key. You'll need this password to send funds. + {$t("accounts.setPasswordToEncrypt")} </p> <label for="password" class="input flex items-center justify-center gap-2 w-full"> @@ -375,14 +407,14 @@ bind:value={password} autocapitalize="none" class="clean" - placeholder="Password" + placeholder={$t("accounts.password")} /> {:else} <input use:focus name="password" type="password" - placeholder="Password" + placeholder={$t("accounts.password")} required bind:value={password} autocapitalize="none" @@ -409,7 +441,7 @@ <input name="confirm" type="text" - placeholder="Confirm password" + placeholder={$t("accounts.confirmPassword")} required bind:value={confirm} autocapitalize="none" @@ -418,7 +450,7 @@ {:else} <input type="password" - placeholder="Confirm password" + placeholder={$t("accounts.confirmPassword")} required bind:value={confirm} autocapitalize="none" @@ -440,7 +472,7 @@ </label> <div class="space-y-2"> - <label for="rememberFor" class="text-sm text-secondary">Remember for</label> + <label for="rememberFor" class="text-sm text-secondary">{$t("user.settings.rememberFor")}</label> <select id="rememberFor" class="w-full" @@ -448,14 +480,14 @@ onchange={(e) => (rememberForMs = Number((e.target as HTMLSelectElement).value))} > {#each rememberForOptions as option} - <option value={option.ms}>{option.label}</option> + <option value={option.ms}>{$t(option.label)}</option> {/each} </select> </div> <div class="flex gap-2"> <button type="button" class="btn !w-auto grow" onclick={() => (confirmed = false)}> - Back + {$t("accounts.back")} </button> <button disabled={submitting} type="submit" class="btn btn-accent !w-auto grow"> {#if submitting} diff --git a/src/routes/(app)/account/bitcoin/+page.server.ts b/src/routes/(app)/account/bitcoin/+page.server.ts new file mode 100644 index 00000000..d4a0d0cb --- /dev/null +++ b/src/routes/(app)/account/bitcoin/+page.server.ts @@ -0,0 +1,5 @@ +import { redirect } from "@sveltejs/kit"; + +export async function load() { + redirect(307, "/account/seed"); +} diff --git a/src/routes/(app)/account/import/+page.svelte b/src/routes/(app)/account/import/+page.svelte index 1ae0a932..e26f1858 100644 --- a/src/routes/(app)/account/import/+page.svelte +++ b/src/routes/(app)/account/import/+page.svelte @@ -48,7 +48,7 @@ autocapitalize="none" ></textarea> <div class="flex gap-2"> - <a href={`/account/bitcoin`} class="contents"> + <a href={`/account/seed`} class="contents"> <button type="button" class="btn !w-auto grow">{$t("accounts.back")}</button> </a> <button bind:this={el} type="submit" class="btn btn-accent !w-auto grow"> diff --git a/src/routes/(app)/account/new/+page.server.ts b/src/routes/(app)/account/new/+page.server.ts index 8ecbb61c..d4a0d0cb 100644 --- a/src/routes/(app)/account/new/+page.server.ts +++ b/src/routes/(app)/account/new/+page.server.ts @@ -1,7 +1,5 @@ -import { auth, get } from "$lib/utils"; +import { redirect } from "@sveltejs/kit"; -export async function load({ cookies }) { - const accounts = await get("/accounts", auth(cookies)); - const hasArk = accounts.some((a) => a.type === "ark"); - return { hasArk }; +export async function load() { + redirect(307, "/account/seed"); } diff --git a/src/routes/(app)/account/pass/+page.svelte b/src/routes/(app)/account/pass/+page.svelte index 23e54ac7..4e754489 100644 --- a/src/routes/(app)/account/pass/+page.svelte +++ b/src/routes/(app)/account/pass/+page.svelte @@ -31,12 +31,13 @@ onMount(async () => { if (!$mnemonic) { - goto("/account/new"); + goto("/account/seed"); return; } - // PRF users: derive from PRF key directly, skip password - const prfKey = getCachedPrfKey(); + // PRF/Nostr users: derive from entropy directly, skip password + const { getWalletEntropy } = await import("$lib/walletEntropy"); + const prfKey = await getWalletEntropy(); if (prfKey) { submitting = true; await tick(); @@ -53,7 +54,7 @@ await post("/account", { fingerprint, pubkey, - name: "Vault", + name: $t("accounts.vault"), type: "bitcoin", accountIndex: 0, }); @@ -171,7 +172,7 @@ <input name="confirm" type="text" - placeholder="Confirm password" + placeholder={$t("accounts.confirmPassword")} required bind:value={confirm} autocapitalize="none" @@ -202,7 +203,7 @@ </label> <div class="space-y-2"> - <label for="rememberFor" class="text-sm text-secondary">Remember for</label> + <label for="rememberFor" class="text-sm text-secondary">{$t("user.settings.rememberFor")}</label> <select id="rememberFor" class="w-full" @@ -210,7 +211,7 @@ onchange={(e) => (rememberForMs = Number((e.target as HTMLSelectElement).value))} > {#each rememberForOptions as option} - <option value={option.ms}>{option.label}</option> + <option value={option.ms}>{$t(option.label)}</option> {/each} </select> </div> diff --git a/src/routes/(app)/account/seed/+page.svelte b/src/routes/(app)/account/seed/+page.svelte index adfd5573..43b690b3 100644 --- a/src/routes/(app)/account/seed/+page.svelte +++ b/src/routes/(app)/account/seed/+page.svelte @@ -18,7 +18,8 @@ onMount(async () => { if (!browser) return; - const prfKey = getCachedPrfKey(); + const { getWalletEntropy } = await import("$lib/walletEntropy"); + const prfKey = await getWalletEntropy(); if (prfKey) { creating = true; try { @@ -32,7 +33,7 @@ await post("/account", { fingerprint, pubkey, - name: "Vault", + name: $t("accounts.vault"), type: "bitcoin", accountIndex: 0, }); @@ -70,14 +71,9 @@ <div class="my-auto">{$t("payments.copy")}</div> </button> - <div class="flex gap-2"> - <a href={`/account/bitcoin`} class="contents"> - <button type="button" class="btn !w-auto grow">{$t("accounts.back")}</button> - </a> - <a href={`/account/pass`} class="contents"> - <button type="button" class="btn btn-accent !w-auto grow">{$t("accounts.next")}</button> - </a> - </div> + <a href={`/account/pass`} class="contents"> + <button type="button" class="btn btn-accent w-full">{$t("accounts.next")}</button> + </a> </div> </div> {/if} diff --git a/src/routes/(app)/account/setup/+page.svelte b/src/routes/(app)/account/setup/+page.svelte index faab0938..35c0c9d9 100644 --- a/src/routes/(app)/account/setup/+page.svelte +++ b/src/routes/(app)/account/setup/+page.svelte @@ -17,7 +17,7 @@ </div> </div> </a> - <a href="/account/new" class="block"> + <a href="/account/seed" class="block"> <div class="shadow p-8 rounded-xl flex gap-4 hover:bg-primary"> <img src="/images/keys.png" class="w-20 my-auto" alt="Savings account" /> <div> 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 e14d41ba..1a0df2f2 100644 --- a/src/routes/(app)/send/bitcoin/[address]/[amount]/[...feeRate]/+page.svelte +++ b/src/routes/(app)/send/bitcoin/[address]/[amount]/[...feeRate]/+page.svelte @@ -38,6 +38,17 @@ return false; } } + // Try silent Nostr derivation + const { getWalletEntropy } = await import("$lib/walletEntropy"); + const entropy = await getWalletEntropy(); + if (entropy) { + try { + await signTxWithPrf(entropy); + return true; + } catch (e: any) { + return false; + } + } const cached = getRememberedWalletPassword(); if (!cached) return false; try { @@ -76,7 +87,7 @@ goto(`/sent/${p.id}`, { invalidateAll: true }); } catch (e: any) { loading = false; - error = e.message || "Failed to send"; + error = e.message || $t("payments.failedToSend"); } })(); diff --git a/src/routes/(app)/settings/security/+page.svelte b/src/routes/(app)/settings/security/+page.svelte index 9666b193..33981645 100644 --- a/src/routes/(app)/settings/security/+page.svelte +++ b/src/routes/(app)/settings/security/+page.svelte @@ -215,12 +215,12 @@ {/if} {#if confirming2fa || disabling2fa} - <Pin bind:value={token} title="Enter 2FA Code" {cancel} notify={false} /> + <Pin bind:value={token} title={$t("login.enter2faCode")} {cancel} notify={false} /> {/if} </div> <div> - <a href={`/account/new`} class="contents"> + <a href={`/account/seed`} class="contents"> <button class="btn btn-lg w-full rounded-2xl whitespace-nowrap"> <iconify-icon noobserver icon="ph:plus-circle-bold" width="32"></iconify-icon> {$t("accounts.addAccount")} diff --git a/src/routes/(static)/login/+page.svelte b/src/routes/(static)/login/+page.svelte index fc1a6921..2c4df81e 100644 --- a/src/routes/(static)/login/+page.svelte +++ b/src/routes/(static)/login/+page.svelte @@ -173,6 +173,16 @@ 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); diff --git a/src/routes/(static)/register/+page.svelte b/src/routes/(static)/register/+page.svelte index 273d319a..858b9695 100644 --- a/src/routes/(static)/register/+page.svelte +++ b/src/routes/(static)/register/+page.svelte @@ -14,7 +14,7 @@ import Spinner from "$comp/Spinner.svelte"; import { focus, fail, versions, post } from "$lib/utils"; - import { registerPasskey } from "$lib/passkey"; + import { registerPasskey, loginWithPasskey } from "$lib/passkey"; import { rememberPrfKey, defaultRememberForMs } from "$lib/passwordCache"; import { avatar, signer, password, pin, loginRedirect } from "$lib/store"; import { t } from "$lib/translations"; @@ -171,6 +171,35 @@ await activateSession(prfKey); } else { + // Registration failed (e.g. username taken) — try passkey sign-in + 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); + } + } + + // Passkey cancelled or failed — show original registration error applyAction(result); } @@ -233,7 +262,7 @@ needsPasskey = false; await activateSession(prfKey); } catch (e: any) { - fail("Passkey creation failed. Please try again."); + fail($t("login.passkeyCreationFailed")); } loading = false; } @@ -302,7 +331,13 @@ const result = deserialize(await response.text()); - if (result.type === "success") { + 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(); } @@ -329,7 +364,7 @@ </script> {#if need2fa} - <Pin bind:value={token} title="Enter 2FA Code" {cancel} notify={false} /> + <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"> @@ -373,12 +408,12 @@ {#if needsPasskey} <div class="space-y-5 text-center"> - <p>Account created! You need to set up a passkey to continue.</p> + <p>{$t("login.accountCreatedNeedsPasskey")}</p> <button class="btn btn-accent" onclick={retryPasskey} disabled={loading}> {#if loading} <Spinner /> {:else} - Set up Passkey + {$t("login.setupPasskey")} {/if} </button> </div> |
