aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-02-16 20:56:39 -0800
committerAdam Soltys <[email protected]>2026-02-16 20:56:39 -0800
commitb22c813f9fb3b4d69dade75d22e90e6779919f07 (patch)
tree173fe377e83ad65ac6721dd567d8e90d9d204be8 /src
parent8bb150db1f73164030f9e8085c13765120363644 (diff)
downloadcoinos-ui-b22c813f9fb3b4d69dade75d22e90e6779919f07.tar.gz
coinos-ui-b22c813f9fb3b4d69dade75d22e90e6779919f07.zip
nostr key derivation
Diffstat (limited to 'src')
-rw-r--r--src/components/Account.svelte48
-rw-r--r--src/lib/passwordCache.ts12
-rw-r--r--src/lib/walletEntropy.ts67
-rw-r--r--src/locales/en.json39
-rw-r--r--src/routes/(app)/+layout.svelte38
-rw-r--r--src/routes/(app)/[username]/(profile)/+page.svelte2
-rw-r--r--src/routes/(app)/account/[id]/+page.svelte103
-rw-r--r--src/routes/(app)/account/ark/+page.svelte92
-rw-r--r--src/routes/(app)/account/bitcoin/+page.server.ts5
-rw-r--r--src/routes/(app)/account/import/+page.svelte2
-rw-r--r--src/routes/(app)/account/new/+page.server.ts8
-rw-r--r--src/routes/(app)/account/pass/+page.svelte15
-rw-r--r--src/routes/(app)/account/seed/+page.svelte16
-rw-r--r--src/routes/(app)/account/setup/+page.svelte2
-rw-r--r--src/routes/(app)/send/bitcoin/[address]/[amount]/[...feeRate]/+page.svelte13
-rw-r--r--src/routes/(app)/settings/security/+page.svelte4
-rw-r--r--src/routes/(static)/login/+page.svelte10
-rw-r--r--src/routes/(static)/register/+page.svelte47
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>