feat: derive operator Nostr identity from wallet mnemonic (NIP-06 account 0)

The nsec used for NIP-98 authentication is now derived from the local
wallet mnemonic via NIP-06 at account index 0, matching the NPC
(npubx.cash) npub so the operator has a single identity backed up by the
wallet seed. Falls back to a random key when no wallet mnemonic is
available (e.g. remote configured before a wallet exists, or an
encrypted/malformed wallet config). An already-configured nsec is never
replaced.
This commit is contained in:
redshift
2026-08-16 17:19:38 +01:00
parent 530db89677
commit fd842817de
7 changed files with 253 additions and 20 deletions
+5
View File
@@ -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`:
+2 -2
View File
@@ -148,7 +148,7 @@ Manage admin npubs (subcommand required).
### `routstrd remote <url>`
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`) |
+50 -18
View File
@@ -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<void> {
}
}
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<void> {
console.log("Initializing routstrd...");
@@ -228,17 +250,6 @@ async function initDaemon(): Promise<void> {
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<void> {
}
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}`,
+35
View File
@@ -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;
}
+21
View File
@@ -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,
+67
View File
@@ -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));
});
});
+73
View File
@@ -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);
});
});