From b22c813f9fb3b4d69dade75d22e90e6779919f07 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Mon, 16 Feb 2026 20:56:39 -0800 Subject: nostr key derivation --- src/components/Account.svelte | 48 +++++++--- src/lib/passwordCache.ts | 12 +-- src/lib/walletEntropy.ts | 67 ++++++++++++++ src/locales/en.json | 39 +++++++- src/routes/(app)/+layout.svelte | 38 +++----- src/routes/(app)/[username]/(profile)/+page.svelte | 2 +- src/routes/(app)/account/[id]/+page.svelte | 103 ++++++++++++++++----- src/routes/(app)/account/ark/+page.svelte | 92 ++++++++++++------ src/routes/(app)/account/bitcoin/+page.server.ts | 5 + src/routes/(app)/account/import/+page.svelte | 2 +- src/routes/(app)/account/new/+page.server.ts | 8 +- src/routes/(app)/account/pass/+page.svelte | 15 +-- src/routes/(app)/account/seed/+page.svelte | 16 ++-- src/routes/(app)/account/setup/+page.svelte | 2 +- .../[address]/[amount]/[...feeRate]/+page.svelte | 13 ++- src/routes/(app)/settings/security/+page.svelte | 4 +- src/routes/(static)/login/+page.svelte | 10 ++ src/routes/(static)/register/+page.svelte | 47 ++++++++-- 18 files changed, 392 insertions(+), 131 deletions(-) create mode 100644 src/lib/walletEntropy.ts create mode 100644 src/routes/(app)/account/bitcoin/+page.server.ts (limited to 'src') 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 @@ {#if pending}
- +{$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")}
{/if}
-
- {#if isArk} - Ark - {:else if seed || fingerprint} - - {:else} - Coinos - {/if} -
- + {#if isArk} + Ark + {:else if seed || fingerprint} + + {:else} + Coinos + {/if}
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 { + 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 { + 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 { + 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 { + 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} {/each} - +

{$t("user.settings.thresholdDesc")}

- -
- - -

{$t("user.settings.reserveDesc")}

-
{/if} + +
{#if mnemonic} @@ -224,17 +255,45 @@ {/if} + {#if account.type === "bitcoin"} + {#if importing} +
+ +
+ + +
+
+ {:else} + + {/if} + {/if} + {#if seed || account.type === "ark"} {/if} +
- @@ -254,7 +313,7 @@ bind:submit={doneReserve} bind:element={thresholdEl} /> - + {/if} @@ -270,7 +329,7 @@ bind:submit={doneReserve} bind:element={reserveEl} /> - + {/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 @@
-

Create Savings Account

+

{$t("accounts.createSavingsAccount")}

@@ -232,27 +258,33 @@ {#if needsPasskeyUnlock}

- Your Ark wallet will be derived from your master seed. Unlock with your passkey to continue. + {$t("accounts.arkUnlockWithPasskey")}

+ {#if hasSigner} + + {/if}
{:else}

- Your Ark wallet will be derived from your passkey-protected master seed. + {$t("accounts.arkDerivedFromPasskey")}

- +
@@ -261,7 +293,7 @@ {:else if hasMasterSeed}

- Your Ark wallet will be derived from your master seed. Enter your wallet password to continue. + {$t("accounts.arkEnterPassword")}

{/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 @@
- +
Savings account
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} - + {/if}
- +
-- cgit v1.2.3