mirror of
https://github.com/Routstr/routstrd.git
synced 2026-10-06 04:38:23 +00:00
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:
@@ -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`:
|
||||
|
||||
@@ -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
@@ -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}`,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user