diff --git a/README.md b/README.md index f776e15..52e6e92 100644 --- a/README.md +++ b/README.md @@ -172,6 +172,11 @@ Set `ROUTSTRD_WALLET_DIR` to override the canonical wallet directory. The `COCOD_DIR`, `COCOD_SOCKET`, and `COCOD_PID` variables are retained only for locating and excluding a legacy external cocod process. +The operator Nostr identity (nsec/npub) used for NIP-98 authentication is also +derived from this mnemonic (NIP-06, account index 0), so it matches the NPC +(npubx.cash) npub. Back up the mnemonic to recover both the wallet and the auth +identity. + ## Configuration Configuration is stored in `~/.routstrd/config.json`: diff --git a/SKILL.md b/SKILL.md index 8d78da8..012d87c 100644 --- a/SKILL.md +++ b/SKILL.md @@ -148,7 +148,7 @@ Manage admin npubs (subcommand required). ### `routstrd remote ` -Configure a remote daemon URL. Generates a Nostr identity (nsec/npub) for NIP-98 authentication automatically. +Configure a remote daemon URL. Creates a Nostr identity (nsec/npub) for NIP-98 authentication automatically — derived from the local wallet mnemonic (NIP-06 account 0) when a wallet exists, otherwise generated randomly. ```sh routstrd remote https://your-remote-daemon.com @@ -163,7 +163,7 @@ Refresh routstr21 models from Nostr and re-run integrations for all registered c | `port` | number | 8008 | Daemon HTTP port | | `provider` | string\|null | null | Default provider URL | | `daemonUrl` | string\|null | null | Remote daemon URL | -| `nsec` | string\|null | null | Nostr secret key for NIP-98 auth | +| `nsec` | string\|null | null | Nostr secret key for NIP-98 auth (derived from wallet mnemonic via NIP-06 account 0 on first init) | | `cocodPath` | string\|null | null | Custom path to cocod executable | | `mode` | string | `"apikeys"` | Client mode (`apikeys` or `xcashu`) | diff --git a/src/cli.ts b/src/cli.ts index 7775b1f..c0bf6df 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -34,6 +34,7 @@ import { stopLegacyCocod, } from "./daemon/wallet/coco-client"; import { migrateLegacyWallet } from "./daemon/wallet/migration"; +import { readWalletMnemonic } from "./daemon/wallet/wallet-config"; import { legacyCocodPidPath, legacyCocodSocketPath, @@ -42,7 +43,7 @@ import { } from "./daemon/wallet/paths"; import { getClientsList } from "./utils/clients"; import * as QRCode from "qrcode"; -import { normalizeNostrPubkey, npubFromPubkey, npubFromSecretKey } from "./utils/nip98"; +import { normalizeNostrPubkey, npubFromPubkey, npubFromSecretKey, nsecFromMnemonic } from "./utils/nip98"; import { generateSecretKey, nip19 } from "nostr-tools"; import { generateMnemonic } from "@scure/bip39"; import { wordlist } from "@scure/bip39/wordlists/english.js"; @@ -207,6 +208,27 @@ async function requireLocalDaemon(): Promise { } } +function deriveNostrIdentity(): { + nsec: string; + npub: string; + fromMnemonic: boolean; +} { + const mnemonic = readWalletMnemonic(); + if (mnemonic) { + const { nsec, npub } = nsecFromMnemonic(mnemonic); + return { nsec, npub, fromMnemonic: true }; + } + + // Fallback for paths where no wallet exists yet (e.g. configuring a remote + // daemon before a local wallet has been created). + const secretKey = generateSecretKey(); + return { + nsec: nip19.nsecEncode(secretKey), + npub: npubFromSecretKey(secretKey), + fromMnemonic: false, + }; +} + async function initDaemon(): Promise { console.log("Initializing routstrd..."); @@ -228,17 +250,6 @@ async function initDaemon(): Promise { const config = await loadConfig(); - if (!config.nsec) { - const secretKey = generateSecretKey(); - const nsec = nip19.nsecEncode(secretKey); - const npub = npubFromSecretKey(secretKey); - config.nsec = nsec; - await Bun.write(CONFIG_FILE, JSON.stringify(config, null, 2)); - console.log("\nA new Nostr identity has been generated for authentication."); - console.log(`Your npub: ${npub}`); - console.log(`You can view it in the config file at: ${CONFIG_FILE}\n`); - } - console.log(`Database will be stored at: ${DB_PATH}`); await stopLegacyCocod(); @@ -271,6 +282,21 @@ async function initDaemon(): Promise { } initializeWallet(); + if (!config.nsec) { + const { nsec, npub, fromMnemonic } = deriveNostrIdentity(); + config.nsec = nsec; + await Bun.write(CONFIG_FILE, JSON.stringify(config, null, 2)); + if (fromMnemonic) { + console.log( + "\nA Nostr identity has been derived from your wallet mnemonic for authentication.", + ); + } else { + console.log("\nA new Nostr identity has been generated for authentication."); + } + console.log(`Your npub: ${npub}`); + console.log(`You can view it in the config file at: ${CONFIG_FILE}\n`); + } + await startDaemon({ port: String(config.port || 8008), host: config.host || undefined }); await setupIntegration(config); @@ -483,13 +509,13 @@ program updates.authUrl = options.authUrl; } let generatedNpub: string | undefined; + let identityDerivedFromMnemonic = false; if (!config.nsec) { - const secretKey = generateSecretKey(); - const nsec = nip19.nsecEncode(secretKey); - const npub = npubFromSecretKey(secretKey); + const { nsec, npub, fromMnemonic } = deriveNostrIdentity(); updates.nsec = nsec; generatedNpub = npub; + identityDerivedFromMnemonic = fromMnemonic; } const updatedConfig: RoutstrdConfig = { @@ -504,9 +530,15 @@ program console.log(`Auth proxy URL set to: ${options.authUrl}`); } if (generatedNpub) { - console.log( - `\nA new Nostr identity has been generated for remote authentication.`, - ); + if (identityDerivedFromMnemonic) { + console.log( + `\nA Nostr identity has been derived from your wallet mnemonic for remote authentication.`, + ); + } else { + console.log( + `\nA new Nostr identity has been generated for remote authentication.`, + ); + } console.log(`Your npub: ${generatedNpub}`); console.log( `You can view it in the config file at: ${CONFIG_FILE}`, diff --git a/src/daemon/wallet/wallet-config.ts b/src/daemon/wallet/wallet-config.ts new file mode 100644 index 0000000..7cf1035 --- /dev/null +++ b/src/daemon/wallet/wallet-config.ts @@ -0,0 +1,35 @@ +import { existsSync, readFileSync } from "fs"; +import { join } from "path"; +import { walletDir as defaultWalletDir } from "./paths"; + +export interface WalletConfigFile { + version?: number; + mnemonic?: string; + encrypted?: boolean; + createdAt?: string; + defaultMintUrl?: string; +} + +/** + * Read the plaintext BIP-39 mnemonic from the wallet config file. + * + * Returns null when the wallet is not initialized yet or when the config is + * malformed/encrypted. Encrypted wallets are not supported, so their mnemonic + * must not be used for key derivation. + */ +export function readWalletMnemonic(walletDir = defaultWalletDir()): string | null { + const configFile = join(walletDir, "config.json"); + if (!existsSync(configFile)) return null; + + let config: WalletConfigFile; + try { + config = JSON.parse(readFileSync(configFile, "utf-8")) as WalletConfigFile; + } catch { + return null; + } + + if (config.encrypted) return null; + return typeof config.mnemonic === "string" && config.mnemonic.trim() + ? config.mnemonic.trim() + : null; +} diff --git a/src/utils/nip98.ts b/src/utils/nip98.ts index 24b5a17..f44b961 100644 --- a/src/utils/nip98.ts +++ b/src/utils/nip98.ts @@ -1,4 +1,5 @@ import { finalizeEvent, getPublicKey, nip19, type EventTemplate } from "nostr-tools"; +import { privateKeyFromSeedWords } from "nostr-tools/nip06"; const NIP98_KIND = 27235; @@ -75,6 +76,26 @@ export function npubFromSecretKey(secretKey: Uint8Array): string { return npubFromPubkey(getPublicKey(secretKey)); } +/** + * The operator Nostr identity is derived from the wallet mnemonic using NIP-06 + * (BIP-32) at account index 0 — the same key the NPC (npubx.cash) plugin + * derives, so the operator has a single npub across authentication and the + * NPC address service. + */ +export const AUTH_NOSTR_ACCOUNT_INDEX = 0; + +export function nsecFromMnemonic( + mnemonic: string, + accountIndex = AUTH_NOSTR_ACCOUNT_INDEX, +): { secretKey: Uint8Array; nsec: string; npub: string } { + const secretKey = privateKeyFromSeedWords(mnemonic, undefined, accountIndex); + return { + secretKey, + nsec: nip19.nsecEncode(secretKey), + npub: npubFromSecretKey(secretKey), + }; +} + export async function createNIP98Authorization( secretKey: Uint8Array, url: string, diff --git a/tests/utils/nip98.test.ts b/tests/utils/nip98.test.ts new file mode 100644 index 0000000..4a4e6c9 --- /dev/null +++ b/tests/utils/nip98.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, test } from "bun:test"; +import { getPublicKey, nip19 } from "nostr-tools"; +import { + AUTH_NOSTR_ACCOUNT_INDEX, + nsecFromMnemonic, + parseSecretKey, +} from "../../src/utils/nip98"; + +// BIP-39 test mnemonic from the spec. Reference values derived with the same +// nostr-tools / @scure/bip39 stack the project uses. +const MNEMONIC = + "legal winner thank year wave sausage worth useful legal winner thank yellow"; + +const ACCOUNT_0 = { + nsec: "nsec1pcxghvh7hgr6dery5eampqdgjwplnuqh4gu470v6hk7ugcgcty3q26maau", + npub: "npub1mx07p7jvpdf4g5lgatea9sgk6mjyfrld947k2nvmwmas94q6sjhssl4jwc", + pubkey: "d99fe0fa4c0b535453e8eaf3d2c116d6e4448fed2d7d654d9b76fb02d41a84af", +}; + +const ACCOUNT_1 = { + nsec: "nsec12wejnr55hhl7wds0f0lzgmvad6s3r786hekrwnf7vt9yyt9vu4gsfe59ve", + npub: "npub1lj3rmlalqw3kuj5swhj9mpqyw3d4lgcs65ndmd4ee8h20azpvgwqjqkd6x", + pubkey: "fca23dffbf03a36e4a9075e45d8404745b5fa310d526ddb6b9c9eea7f441621c", +}; + +describe("nsecFromMnemonic", () => { + test("uses NIP-06 account index 0 by default", () => { + expect(AUTH_NOSTR_ACCOUNT_INDEX).toBe(0); + + const { nsec, npub } = nsecFromMnemonic(MNEMONIC); + expect(nsec).toBe(ACCOUNT_0.nsec); + expect(npub).toBe(ACCOUNT_0.npub); + }); + + test("matches the reference account index 0 vector", () => { + const identity = nsecFromMnemonic(MNEMONIC, 0); + expect(identity.secretKey).toHaveLength(32); + expect(identity.nsec).toBe(ACCOUNT_0.nsec); + expect(identity.npub).toBe(ACCOUNT_0.npub); + expect(getPublicKey(identity.secretKey)).toBe(ACCOUNT_0.pubkey); + }); + + test("derives a distinct key for a different account index", () => { + const identity = nsecFromMnemonic(MNEMONIC, 1); + expect(identity.nsec).toBe(ACCOUNT_1.nsec); + expect(identity.npub).toBe(ACCOUNT_1.npub); + expect(getPublicKey(identity.secretKey)).toBe(ACCOUNT_1.pubkey); + + expect(identity.nsec).not.toBe(ACCOUNT_0.nsec); + expect(identity.npub).not.toBe(ACCOUNT_0.npub); + }); + + test("is deterministic", () => { + expect(nsecFromMnemonic(MNEMONIC)).toEqual(nsecFromMnemonic(MNEMONIC)); + }); + + test("round-trips through parseSecretKey and nip19", () => { + const { secretKey, nsec, npub } = nsecFromMnemonic(MNEMONIC); + + expect(parseSecretKey(nsec)).toEqual(secretKey); + expect(nip19.nsecEncode(secretKey)).toBe(nsec); + + const decoded = nip19.decode(npub); + expect(decoded.type).toBe("npub"); + expect(decoded.data).toBe(getPublicKey(secretKey)); + }); +}); diff --git a/tests/wallet/wallet-config.test.ts b/tests/wallet/wallet-config.test.ts new file mode 100644 index 0000000..d93948b --- /dev/null +++ b/tests/wallet/wallet-config.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, test, afterEach } from "bun:test"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "fs"; +import { join } from "path"; +import { tmpdir } from "os"; +import { readWalletMnemonic } from "../../src/daemon/wallet/wallet-config"; + +const MNEMONIC = + "legal winner thank year wave sausage worth useful legal winner thank yellow"; + +const tempDirs: string[] = []; + +function makeWalletDir(): string { + const dir = mkdtempSync(join(tmpdir(), "routstrd-wallet-config-")); + tempDirs.push(dir); + return dir; +} + +function writeConfig(dir: string, config: unknown): void { + writeFileSync(join(dir, "config.json"), JSON.stringify(config, null, 2)); +} + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("readWalletMnemonic", () => { + test("returns the mnemonic from a valid config", () => { + const dir = makeWalletDir(); + writeConfig(dir, { version: 1, mnemonic: MNEMONIC, encrypted: false }); + + expect(readWalletMnemonic(dir)).toBe(MNEMONIC); + }); + + test("returns null when the wallet is not initialized", () => { + const dir = makeWalletDir(); + expect(readWalletMnemonic(dir)).toBeNull(); + }); + + test("returns null for an encrypted wallet", () => { + const dir = makeWalletDir(); + writeConfig(dir, { version: 1, mnemonic: MNEMONIC, encrypted: true }); + + expect(readWalletMnemonic(dir)).toBeNull(); + }); + + test("returns null when the mnemonic field is missing", () => { + const dir = makeWalletDir(); + writeConfig(dir, { version: 1, encrypted: false }); + + expect(readWalletMnemonic(dir)).toBeNull(); + }); + + test("returns null for a malformed config file", () => { + const dir = makeWalletDir(); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "config.json"), "{not valid json"); + + expect(readWalletMnemonic(dir)).toBeNull(); + }); + + test("trims surrounding whitespace from the mnemonic", () => { + const dir = makeWalletDir(); + writeConfig(dir, { + version: 1, + mnemonic: ` ${MNEMONIC} `, + encrypted: false, + }); + + expect(readWalletMnemonic(dir)).toBe(MNEMONIC); + }); +});