mirror of
https://github.com/Routstr/routstrd.git
synced 2026-10-05 20:38:22 +00:00
feat: ensure wallet exists before deriving auth identity in remote flow
routstrd remote now runs the same ensure-wallet sequence as init (stop legacy cocod, migrate a legacy cocod wallet so its mnemonic wins, then initialize a fresh wallet) before deriving the NIP-98 identity, so the operator nsec is mnemonic-derived and recoverable from the wallet backup regardless of which command runs first. This avoids shadowing a legacy wallet with a fresh mnemonic, which would have made later migration fail on divergent wallets. An already-configured nsec is never replaced. - extract ensureLocalWallet() shared by init and remote - move initializeWallet into wallet-config.ts, return created status, handle concurrent-initializer EEXIST races - warn (but still derive) when the wallet mnemonic fails BIP-39 checksum validation, keeping auth/NPC/wallet identities self-consistent - relocate initializeWallet tests next to wallet-config, add ensure-wallet integration tests (fresh, idempotent, legacy migration, divergent-wallets refusal)
This commit is contained in:
@@ -177,6 +177,13 @@ 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.
|
||||
|
||||
Both `routstrd init` and `routstrd remote <url>` create the wallet (migrating a
|
||||
legacy cocod wallet first when one exists) whenever no wallet and no nsec are
|
||||
configured yet, so the identity is mnemonic-derived regardless of which command
|
||||
runs first. An already-configured nsec is never replaced — installations that
|
||||
generated a random nsec before this behavior existed keep their existing
|
||||
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. 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.
|
||||
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). A local wallet is created on the spot when none exists (migrating a legacy cocod wallet first, so its mnemonic wins); a random identity is generated only when the wallet mnemonic is unavailable (e.g. an encrypted wallet).
|
||||
|
||||
```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 (derived from wallet mnemonic via NIP-06 account 0 on first init) |
|
||||
| `nsec` | string\|null | null | Nostr secret key for NIP-98 auth (derived from the wallet mnemonic via NIP-06 account 0 whenever no nsec is configured yet — on init or when configuring a remote) |
|
||||
| `cocodPath` | string\|null | null | Custom path to cocod executable |
|
||||
| `mode` | string | `"apikeys"` | Client mode (`apikeys` or `xcashu`) |
|
||||
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { mkdtempSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { initializeWallet } from "./cli";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
function makeTempDir(): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), "routstrd-wallet-test-"));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
function permissions(path: string): number {
|
||||
return statSync(path).mode & 0o777;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("initializeWallet", () => {
|
||||
test("creates the wallet directory and config with restrictive permissions", () => {
|
||||
const walletDir = join(makeTempDir(), "wallet");
|
||||
|
||||
initializeWallet(walletDir);
|
||||
|
||||
const walletConfig = join(walletDir, "config.json");
|
||||
expect(permissions(walletDir)).toBe(0o700);
|
||||
expect(permissions(walletConfig)).toBe(0o600);
|
||||
|
||||
const config = JSON.parse(readFileSync(walletConfig, "utf8"));
|
||||
expect(config.encrypted).toBe(false);
|
||||
expect(typeof config.mnemonic).toBe("string");
|
||||
expect(config.mnemonic.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("repairs permissions without replacing an existing wallet", () => {
|
||||
const walletDir = join(makeTempDir(), "wallet");
|
||||
const walletConfig = join(walletDir, "config.json");
|
||||
const existingConfig = JSON.stringify({ mnemonic: "existing seed" });
|
||||
|
||||
mkdirSync(walletDir, { mode: 0o755 });
|
||||
writeFileSync(walletConfig, existingConfig, { mode: 0o644 });
|
||||
|
||||
initializeWallet(walletDir);
|
||||
|
||||
expect(readFileSync(walletConfig, "utf8")).toBe(existingConfig);
|
||||
expect(permissions(walletDir)).toBe(0o700);
|
||||
expect(permissions(walletConfig)).toBe(0o600);
|
||||
});
|
||||
});
|
||||
+31
-76
@@ -15,9 +15,8 @@ import {
|
||||
deleteClientAction,
|
||||
addClientAction,
|
||||
} from "./utils/clients";
|
||||
import { chmodSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "fs";
|
||||
import { existsSync, mkdirSync, readFileSync, statSync } from "fs";
|
||||
import { execSync } from "child_process";
|
||||
import { dirname, join } from "path";
|
||||
import {
|
||||
CONFIG_DIR,
|
||||
DB_PATH,
|
||||
@@ -28,24 +27,15 @@ import {
|
||||
} from "./utils/config";
|
||||
import { COCO_LOGS_DIR, logger } from "./utils/logger";
|
||||
import { setupIntegration, runIntegrationsForClients } from "./integrations";
|
||||
import {
|
||||
assertLegacyCocodNotRunning,
|
||||
claimLegacyCocodPidFile,
|
||||
stopLegacyCocod,
|
||||
} from "./daemon/wallet/coco-client";
|
||||
import { migrateLegacyWallet } from "./daemon/wallet/migration";
|
||||
import { stopLegacyCocod } from "./daemon/wallet/coco-client";
|
||||
import { readWalletMnemonic } from "./daemon/wallet/wallet-config";
|
||||
import {
|
||||
legacyCocodPidPath,
|
||||
legacyCocodSocketPath,
|
||||
walletDir as defaultWalletDir,
|
||||
walletPidPath,
|
||||
} from "./daemon/wallet/paths";
|
||||
import { ensureLocalWallet } from "./daemon/wallet/ensure-wallet";
|
||||
import { walletPidPath } from "./daemon/wallet/paths";
|
||||
import { getClientsList } from "./utils/clients";
|
||||
import * as QRCode from "qrcode";
|
||||
import { normalizeNostrPubkey, npubFromPubkey, npubFromSecretKey, nsecFromMnemonic } from "./utils/nip98";
|
||||
import { generateSecretKey, nip19 } from "nostr-tools";
|
||||
import { generateMnemonic } from "@scure/bip39";
|
||||
import { validateMnemonic } from "@scure/bip39";
|
||||
import { wordlist } from "@scure/bip39/wordlists/english.js";
|
||||
import packageJson from "../package.json" with { type: "json" };
|
||||
import {
|
||||
@@ -98,35 +88,6 @@ async function printLightningInvoice(invoice: string): Promise<void> {
|
||||
console.log(`${qr}\nInvoice:\n${invoice}`);
|
||||
}
|
||||
|
||||
export function initializeWallet(walletDir = defaultWalletDir()): void {
|
||||
const walletConfig = join(walletDir, "config.json");
|
||||
|
||||
// The wallet directory and config contain the plaintext seed phrase. Correct
|
||||
// permissions on existing installations as well as newly created ones.
|
||||
mkdirSync(walletDir, { recursive: true, mode: 0o700 });
|
||||
chmodSync(walletDir, 0o700);
|
||||
|
||||
if (existsSync(walletConfig)) {
|
||||
chmodSync(walletConfig, 0o600);
|
||||
console.log("Wallet already initialized.");
|
||||
return;
|
||||
}
|
||||
|
||||
const mnemonic = generateMnemonic(wordlist);
|
||||
const config = {
|
||||
version: 1,
|
||||
mnemonic,
|
||||
encrypted: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
writeFileSync(walletConfig, JSON.stringify(config, null, 2), {
|
||||
mode: 0o600,
|
||||
flag: "wx",
|
||||
});
|
||||
console.log("Initialized. Mnemonic:", mnemonic);
|
||||
console.log("IMPORTANT: Write down this mnemonic and keep it safe!");
|
||||
}
|
||||
|
||||
/**
|
||||
* Restart the routstrd daemon after an update so the new binary takes
|
||||
* effect immediately. Failures are collected and reported but never
|
||||
@@ -215,12 +176,24 @@ function deriveNostrIdentity(): {
|
||||
} {
|
||||
const mnemonic = readWalletMnemonic();
|
||||
if (mnemonic) {
|
||||
// The wallet and NPC plugin derive their keys from this same raw string
|
||||
// without checksum validation, so deriving anyway keeps all identities
|
||||
// consistent even when the mnemonic is corrupted. Warn loudly instead of
|
||||
// silently falling back to a divergent random identity.
|
||||
if (!validateMnemonic(mnemonic, wordlist)) {
|
||||
console.warn(
|
||||
"Warning: the wallet mnemonic fails BIP-39 checksum validation and may be corrupted. " +
|
||||
"Deriving the authentication identity from it anyway so it stays consistent with the wallet/NPC identity — " +
|
||||
"verify your wallet backup.",
|
||||
);
|
||||
}
|
||||
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).
|
||||
// Fallback for wallets whose mnemonic cannot be read (encrypted or malformed
|
||||
// wallet config). Callers ensure a wallet exists before deriving, so a
|
||||
// missing wallet should no longer reach this path.
|
||||
const secretKey = generateSecretKey();
|
||||
return {
|
||||
nsec: nip19.nsecEncode(secretKey),
|
||||
@@ -251,36 +224,7 @@ async function initDaemon(): Promise<void> {
|
||||
const config = await loadConfig();
|
||||
|
||||
console.log(`Database will be stored at: ${DB_PATH}`);
|
||||
await stopLegacyCocod();
|
||||
|
||||
let migrationLockOwner: number | undefined;
|
||||
const migration = await migrateLegacyWallet({
|
||||
assertLegacyStopped: () =>
|
||||
assertLegacyCocodNotRunning({
|
||||
socketPath: legacyCocodSocketPath(),
|
||||
pidFilePath: legacyCocodPidPath(),
|
||||
ignorePid: migrationLockOwner,
|
||||
}),
|
||||
acquireLegacyLock: () => {
|
||||
mkdirSync(dirname(legacyCocodPidPath()), {
|
||||
recursive: true,
|
||||
mode: 0o700,
|
||||
});
|
||||
const release = claimLegacyCocodPidFile({
|
||||
pidFilePath: legacyCocodPidPath(),
|
||||
});
|
||||
migrationLockOwner = process.pid;
|
||||
return () => {
|
||||
migrationLockOwner = undefined;
|
||||
release();
|
||||
};
|
||||
},
|
||||
});
|
||||
if (migration.status === "migrated") {
|
||||
console.log(`Migrated wallet from ${migration.from} to ${migration.to}.`);
|
||||
for (const warning of migration.cleanupWarnings) console.warn(warning);
|
||||
}
|
||||
initializeWallet();
|
||||
await ensureLocalWallet();
|
||||
|
||||
if (!config.nsec) {
|
||||
const { nsec, npub, fromMnemonic } = deriveNostrIdentity();
|
||||
@@ -512,6 +456,17 @@ program
|
||||
let identityDerivedFromMnemonic = false;
|
||||
|
||||
if (!config.nsec) {
|
||||
// The auth identity is derived from the wallet mnemonic, so make sure a
|
||||
// wallet exists first — migrating a legacy cocod wallet when present so
|
||||
// its mnemonic wins over a fresh one. This makes the derived nsec
|
||||
// recoverable from the wallet backup even for users who never run local
|
||||
// wallet commands.
|
||||
const { created } = await ensureLocalWallet();
|
||||
if (created) {
|
||||
console.log(
|
||||
"A local wallet was created so the Nostr identity can be derived from its mnemonic.",
|
||||
);
|
||||
}
|
||||
const { nsec, npub, fromMnemonic } = deriveNostrIdentity();
|
||||
updates.nsec = nsec;
|
||||
generatedNpub = npub;
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { mkdirSync } from "fs";
|
||||
import { dirname } from "path";
|
||||
import {
|
||||
assertLegacyCocodNotRunning,
|
||||
claimLegacyCocodPidFile,
|
||||
stopLegacyCocod,
|
||||
} from "./coco-client";
|
||||
import { migrateLegacyWallet } from "./migration";
|
||||
import { legacyCocodPidPath, legacyCocodSocketPath } from "./paths";
|
||||
import { initializeWallet } from "./wallet-config";
|
||||
|
||||
export interface EnsureLocalWalletResult {
|
||||
/** True when a legacy cocod wallet was migrated into the canonical directory. */
|
||||
migrated: boolean;
|
||||
/** True when this call created a fresh wallet with a new mnemonic. */
|
||||
created: boolean;
|
||||
}
|
||||
|
||||
export interface EnsureLocalWalletOptions {
|
||||
log?: (message: string) => void;
|
||||
warn?: (message: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Guarantee that the canonical routstrd wallet exists, preferring an existing
|
||||
* mnemonic over generating a new one:
|
||||
*
|
||||
* 1. Stop/confine any legacy external cocod process.
|
||||
* 2. Migrate a legacy cocod wallet when present — its mnemonic always wins
|
||||
* over a freshly generated one, and a divergent pre-existing canonical
|
||||
* wallet aborts the migration loudly instead of shadowing funds.
|
||||
* 3. Otherwise create a fresh wallet with a new BIP-39 mnemonic.
|
||||
*
|
||||
* Both `init` and `remote` run this before deriving the operator Nostr
|
||||
* identity, so the nsec is recoverable from the wallet backup regardless of
|
||||
* which command ran first.
|
||||
*/
|
||||
export async function ensureLocalWallet(
|
||||
options: EnsureLocalWalletOptions = {},
|
||||
): Promise<EnsureLocalWalletResult> {
|
||||
const log = options.log ?? console.log;
|
||||
const warn = options.warn ?? console.warn;
|
||||
|
||||
await stopLegacyCocod();
|
||||
|
||||
let migrationLockOwner: number | undefined;
|
||||
const migration = await migrateLegacyWallet({
|
||||
assertLegacyStopped: () =>
|
||||
assertLegacyCocodNotRunning({
|
||||
socketPath: legacyCocodSocketPath(),
|
||||
pidFilePath: legacyCocodPidPath(),
|
||||
ignorePid: migrationLockOwner,
|
||||
}),
|
||||
acquireLegacyLock: () => {
|
||||
mkdirSync(dirname(legacyCocodPidPath()), {
|
||||
recursive: true,
|
||||
mode: 0o700,
|
||||
});
|
||||
const release = claimLegacyCocodPidFile({
|
||||
pidFilePath: legacyCocodPidPath(),
|
||||
});
|
||||
migrationLockOwner = process.pid;
|
||||
return () => {
|
||||
migrationLockOwner = undefined;
|
||||
release();
|
||||
};
|
||||
},
|
||||
});
|
||||
if (migration.status === "migrated") {
|
||||
log(`Migrated wallet from ${migration.from} to ${migration.to}.`);
|
||||
for (const warning of migration.cleanupWarnings) warn(warning);
|
||||
}
|
||||
|
||||
const created = initializeWallet(undefined, log);
|
||||
return { migrated: migration.status === "migrated", created };
|
||||
}
|
||||
@@ -1,5 +1,13 @@
|
||||
import { existsSync, readFileSync } from "fs";
|
||||
import {
|
||||
chmodSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
} from "fs";
|
||||
import { join } from "path";
|
||||
import { generateMnemonic } from "@scure/bip39";
|
||||
import { wordlist } from "@scure/bip39/wordlists/english.js";
|
||||
import { walletDir as defaultWalletDir } from "./paths";
|
||||
|
||||
export interface WalletConfigFile {
|
||||
@@ -33,3 +41,57 @@ export function readWalletMnemonic(walletDir = defaultWalletDir()): string | nul
|
||||
? config.mnemonic.trim()
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the canonical wallet (a config.json holding a fresh BIP-39 mnemonic)
|
||||
* when it does not exist yet. An existing wallet is never modified.
|
||||
*
|
||||
* Concurrent initializers are safe: the process that loses the probe/write
|
||||
* race gets EEXIST from the exclusive create and treats it as "already
|
||||
* initialized", keeping the winner's mnemonic.
|
||||
*
|
||||
* Returns true when this call created the wallet.
|
||||
*/
|
||||
export function initializeWallet(
|
||||
walletDir = defaultWalletDir(),
|
||||
log: (message: string) => void = console.log,
|
||||
): boolean {
|
||||
const walletConfig = join(walletDir, "config.json");
|
||||
|
||||
// The wallet directory and config contain the plaintext seed phrase. Correct
|
||||
// permissions on existing installations as well as newly created ones.
|
||||
mkdirSync(walletDir, { recursive: true, mode: 0o700 });
|
||||
chmodSync(walletDir, 0o700);
|
||||
|
||||
if (existsSync(walletConfig)) {
|
||||
chmodSync(walletConfig, 0o600);
|
||||
log("Wallet already initialized.");
|
||||
return false;
|
||||
}
|
||||
|
||||
const mnemonic = generateMnemonic(wordlist);
|
||||
const config = {
|
||||
version: 1,
|
||||
mnemonic,
|
||||
encrypted: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
try {
|
||||
writeFileSync(walletConfig, JSON.stringify(config, null, 2), {
|
||||
mode: 0o600,
|
||||
flag: "wx",
|
||||
});
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "EEXIST") {
|
||||
// Lost the race with a concurrent initializer; its mnemonic is
|
||||
// authoritative, so this is not an error.
|
||||
chmodSync(walletConfig, 0o600);
|
||||
log("Wallet already initialized.");
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
log(`Initialized. Mnemonic: ${mnemonic}`);
|
||||
log("IMPORTANT: Write down this mnemonic and keep it safe!");
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { getPublicKey, nip19 } from "nostr-tools";
|
||||
import { validateMnemonic } from "@scure/bip39";
|
||||
import { wordlist } from "@scure/bip39/wordlists/english.js";
|
||||
import {
|
||||
AUTH_NOSTR_ACCOUNT_INDEX,
|
||||
nsecFromMnemonic,
|
||||
@@ -64,4 +66,18 @@ describe("nsecFromMnemonic", () => {
|
||||
expect(decoded.type).toBe("npub");
|
||||
expect(decoded.data).toBe(getPublicKey(secretKey));
|
||||
});
|
||||
|
||||
test("derives deterministically even from a checksum-invalid mnemonic", () => {
|
||||
// Same words, last word swapped so the BIP-39 checksum fails. The wallet
|
||||
// and the NPC plugin derive their keys from the raw string without
|
||||
// checksum validation, so the auth identity must do the same to stay
|
||||
// consistent with them rather than erroring out.
|
||||
const corrupted = MNEMONIC.replace(/yellow$/, "zebra");
|
||||
expect(validateMnemonic(corrupted, wordlist)).toBe(false);
|
||||
|
||||
const identity = nsecFromMnemonic(corrupted);
|
||||
expect(identity.secretKey).toHaveLength(32);
|
||||
expect(nsecFromMnemonic(corrupted)).toEqual(identity);
|
||||
expect(identity.nsec).not.toBe(ACCOUNT_0.nsec);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { TEST_ROOT } from "./test-env";
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs";
|
||||
import { join } from "path";
|
||||
import { tmpdir } from "os";
|
||||
import { validateMnemonic } from "@scure/bip39";
|
||||
import { wordlist } from "@scure/bip39/wordlists/english.js";
|
||||
import { ensureLocalWallet } from "../../src/daemon/wallet/ensure-wallet";
|
||||
import { readWalletMnemonic } from "../../src/daemon/wallet/wallet-config";
|
||||
import { nsecFromMnemonic } from "../../src/utils/nip98";
|
||||
|
||||
// BIP-39 test mnemonic (same vector as tests/utils/nip98.test.ts).
|
||||
const LEGACY_MNEMONIC =
|
||||
"legal winner thank year wave sausage worth useful legal winner thank yellow";
|
||||
const LEGACY_DERIVED_NPUB =
|
||||
"npub1mx07p7jvpdf4g5lgatea9sgk6mjyfrld947k2nvmwmas94q6sjhssl4jwc";
|
||||
|
||||
// A different, checksum-valid BIP-39 mnemonic.
|
||||
const OTHER_MNEMONIC = "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong";
|
||||
|
||||
const ENV_KEYS = [
|
||||
"ROUTSTRD_WALLET_DIR",
|
||||
"COCOD_DIR",
|
||||
"COCOD_SOCKET",
|
||||
"COCOD_PID",
|
||||
] as const;
|
||||
|
||||
const silent = { log: () => {}, warn: () => {} };
|
||||
|
||||
const savedEnv = new Map<string, string | undefined>();
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
const root = mkdtempSync(join(tmpdir(), "routstrd-ensure-wallet-"));
|
||||
tempDirs.push(root);
|
||||
for (const key of ENV_KEYS) savedEnv.set(key, process.env[key]);
|
||||
// paths.ts evaluates env lazily, so per-test overrides take effect at call
|
||||
// time. COCOD_SOCKET/COCOD_PID default under COCOD_DIR.
|
||||
process.env.ROUTSTRD_WALLET_DIR = join(root, "wallet");
|
||||
process.env.COCOD_DIR = join(root, "cocod");
|
||||
delete process.env.COCOD_SOCKET;
|
||||
delete process.env.COCOD_PID;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const key of ENV_KEYS) {
|
||||
const value = savedEnv.get(key);
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
}
|
||||
savedEnv.clear();
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
rmSync(TEST_ROOT, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function writeLegacyWallet(mnemonic: string): void {
|
||||
const legacyDir = process.env.COCOD_DIR!;
|
||||
mkdirSync(legacyDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(legacyDir, "config.json"),
|
||||
JSON.stringify({ version: 1, mnemonic, encrypted: false }),
|
||||
);
|
||||
}
|
||||
|
||||
describe("ensureLocalWallet", () => {
|
||||
test("creates a fresh wallet when neither canonical nor legacy wallets exist", async () => {
|
||||
const result = await ensureLocalWallet(silent);
|
||||
expect(result).toEqual({ migrated: false, created: true });
|
||||
|
||||
const mnemonic = readWalletMnemonic();
|
||||
expect(mnemonic).not.toBeNull();
|
||||
expect(validateMnemonic(mnemonic!, wordlist)).toBe(true);
|
||||
});
|
||||
|
||||
test("is idempotent: a second run keeps the same mnemonic", async () => {
|
||||
const first = await ensureLocalWallet(silent);
|
||||
expect(first.created).toBe(true);
|
||||
const mnemonic = readWalletMnemonic();
|
||||
|
||||
const second = await ensureLocalWallet(silent);
|
||||
expect(second).toEqual({ migrated: false, created: false });
|
||||
expect(readWalletMnemonic()).toBe(mnemonic);
|
||||
});
|
||||
|
||||
test("migrates a legacy cocod wallet and keeps its mnemonic", async () => {
|
||||
writeLegacyWallet(LEGACY_MNEMONIC);
|
||||
|
||||
const result = await ensureLocalWallet(silent);
|
||||
expect(result).toEqual({ migrated: true, created: false });
|
||||
|
||||
// The auth identity derives from the migrated wallet's mnemonic, not a
|
||||
// freshly generated one.
|
||||
expect(readWalletMnemonic()).toBe(LEGACY_MNEMONIC);
|
||||
expect(nsecFromMnemonic(readWalletMnemonic()!).npub).toBe(
|
||||
LEGACY_DERIVED_NPUB,
|
||||
);
|
||||
});
|
||||
|
||||
test("refuses to shadow a legacy wallet with a divergent canonical wallet", async () => {
|
||||
const walletDir = process.env.ROUTSTRD_WALLET_DIR!;
|
||||
mkdirSync(walletDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(walletDir, "config.json"),
|
||||
JSON.stringify({ version: 1, mnemonic: OTHER_MNEMONIC, encrypted: false }),
|
||||
);
|
||||
writeLegacyWallet(LEGACY_MNEMONIC);
|
||||
|
||||
await expect(ensureLocalWallet(silent)).rejects.toThrow(
|
||||
/Refusing to choose/,
|
||||
);
|
||||
|
||||
// The pre-existing canonical wallet is left untouched.
|
||||
expect(readWalletMnemonic()).toBe(OTHER_MNEMONIC);
|
||||
expect(existsSync(join(walletDir, "config.json"))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { mkdtempSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
|
||||
/**
|
||||
* Test-wide environment root. Must be imported before any src module that
|
||||
* captures env vars at load time (src/utils/logger reads ROUTSTRD_DIR when
|
||||
* the module is first evaluated), so log output lands in a temp directory
|
||||
* instead of the real ~/.routstrd.
|
||||
*/
|
||||
export const TEST_ROOT = mkdtempSync(join(tmpdir(), "routstrd-test-env-"));
|
||||
process.env.ROUTSTRD_DIR = join(TEST_ROOT, "routstrd");
|
||||
@@ -1,8 +1,13 @@
|
||||
import { describe, expect, test, afterEach } from "bun:test";
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "fs";
|
||||
import { mkdtempSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "fs";
|
||||
import { join } from "path";
|
||||
import { tmpdir } from "os";
|
||||
import { readWalletMnemonic } from "../../src/daemon/wallet/wallet-config";
|
||||
import { validateMnemonic } from "@scure/bip39";
|
||||
import { wordlist } from "@scure/bip39/wordlists/english.js";
|
||||
import {
|
||||
initializeWallet,
|
||||
readWalletMnemonic,
|
||||
} from "../../src/daemon/wallet/wallet-config";
|
||||
|
||||
const MNEMONIC =
|
||||
"legal winner thank year wave sausage worth useful legal winner thank yellow";
|
||||
@@ -25,8 +30,7 @@ afterEach(() => {
|
||||
}
|
||||
});
|
||||
|
||||
describe("readWalletMnemonic", () => {
|
||||
test("returns the mnemonic from a valid config", () => {
|
||||
describe("readWalletMnemonic", () => { test("returns the mnemonic from a valid config", () => {
|
||||
const dir = makeWalletDir();
|
||||
writeConfig(dir, { version: 1, mnemonic: MNEMONIC, encrypted: false });
|
||||
|
||||
@@ -71,3 +75,51 @@ describe("readWalletMnemonic", () => {
|
||||
expect(readWalletMnemonic(dir)).toBe(MNEMONIC);
|
||||
});
|
||||
});
|
||||
|
||||
describe("initializeWallet", () => {
|
||||
const silent = () => {};
|
||||
|
||||
function permissions(path: string): number {
|
||||
return statSync(path).mode & 0o777;
|
||||
}
|
||||
|
||||
test("creates the wallet with a valid BIP-39 mnemonic and restrictive permissions", () => {
|
||||
const dir = join(makeWalletDir(), "wallet");
|
||||
|
||||
expect(initializeWallet(dir, silent)).toBe(true);
|
||||
|
||||
expect(permissions(dir)).toBe(0o700);
|
||||
const configPath = join(dir, "config.json");
|
||||
expect(permissions(configPath)).toBe(0o600);
|
||||
|
||||
const config = JSON.parse(readFileSync(configPath, "utf8"));
|
||||
expect(config.version).toBe(1);
|
||||
expect(config.encrypted).toBe(false);
|
||||
expect(validateMnemonic(config.mnemonic, wordlist)).toBe(true);
|
||||
expect(readWalletMnemonic(dir)).toBe(config.mnemonic);
|
||||
});
|
||||
|
||||
test("keeps the existing mnemonic on repeat calls", () => {
|
||||
const dir = join(makeWalletDir(), "wallet");
|
||||
initializeWallet(dir, silent);
|
||||
const first = readWalletMnemonic(dir);
|
||||
|
||||
expect(initializeWallet(dir, silent)).toBe(false);
|
||||
expect(readWalletMnemonic(dir)).toBe(first);
|
||||
});
|
||||
|
||||
test("repairs permissions without replacing an existing wallet", () => {
|
||||
const dir = join(makeWalletDir(), "wallet");
|
||||
const configPath = join(dir, "config.json");
|
||||
const existingConfig = JSON.stringify({ mnemonic: "existing seed" });
|
||||
|
||||
mkdirSync(dir, { mode: 0o755 });
|
||||
writeFileSync(configPath, existingConfig, { mode: 0o644 });
|
||||
|
||||
expect(initializeWallet(dir, silent)).toBe(false);
|
||||
|
||||
expect(readFileSync(configPath, "utf8")).toBe(existingConfig);
|
||||
expect(permissions(dir)).toBe(0o700);
|
||||
expect(permissions(configPath)).toBe(0o600);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user