diff --git a/src/cli.ts b/src/cli.ts index b6104b8..c8c373d 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,5 +1,6 @@ import { program } from "commander"; import { startDaemon } from "./start-daemon"; +import { ensureDirsSync, saveDaemonConfig } from "./daemon/config-store"; import { handleDaemonCommand, callDaemon, @@ -217,19 +218,19 @@ async function requireLocalDaemon(): Promise { async function initDaemon(): Promise { console.log("Initializing routstrd..."); - // Create config directory + // Create config directory (0700, correcting existing installs too) if (!existsSync(CONFIG_DIR)) { - mkdirSync(CONFIG_DIR, { recursive: true }); console.log(`Created config directory: ${CONFIG_DIR}`); } + ensureDirsSync(); - // Create initial config + // Create initial config (0600, atomic write) if (!existsSync(CONFIG_FILE)) { const config: RoutstrdConfig = { ...DEFAULT_CONFIG, cocodPath: null, }; - await Bun.write(CONFIG_FILE, JSON.stringify(config, null, 2)); + saveDaemonConfig(config); console.log(`Created config file: ${CONFIG_FILE}`); } @@ -240,7 +241,7 @@ async function initDaemon(): Promise { const nsec = nip19.nsecEncode(secretKey); const npub = npubFromSecretKey(secretKey); config.nsec = nsec; - await Bun.write(CONFIG_FILE, JSON.stringify(config, null, 2)); + saveDaemonConfig(config); 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`); @@ -517,7 +518,7 @@ program ...updates, }; - await Bun.write(CONFIG_FILE, JSON.stringify(updatedConfig, null, 2)); + saveDaemonConfig(updatedConfig); console.log(`Remote daemon URL set to: ${url}`); if (options.authUrl) { @@ -552,7 +553,7 @@ program const { daemonUrl: _daemonUrl, authUrl: _authUrl, ...rest } = config; const updatedConfig: RoutstrdConfig = rest; - await Bun.write(CONFIG_FILE, JSON.stringify(updatedConfig, null, 2)); + saveDaemonConfig(updatedConfig); console.log("Switched back to local daemon mode."); if (previousDaemonUrl) { @@ -2227,7 +2228,7 @@ program ...config, mode: selectedMode, }; - await Bun.write(CONFIG_FILE, JSON.stringify(updatedConfig, null, 2)); + saveDaemonConfig(updatedConfig); console.log(`Mode set to '${selectedMode}'. Restarting daemon...`); // Restart daemon diff --git a/src/daemon/config-store.ts b/src/daemon/config-store.ts index 6b0c4db..c6b155f 100644 --- a/src/daemon/config-store.ts +++ b/src/daemon/config-store.ts @@ -1,5 +1,13 @@ import { mkdir } from "fs/promises"; -import { existsSync, readFileSync } from "fs"; +import { + chmodSync, + existsSync, + mkdirSync, + readFileSync, + renameSync, + unlinkSync, + writeFileSync, +} from "fs"; import { CONFIG_DIR, CONFIG_FILE, @@ -10,18 +18,61 @@ import { logger } from "../utils/logger"; export const REQUESTS_DIR = `${CONFIG_DIR}/requests`; -export async function ensureDirs(): Promise { +// The daemon config holds spend-capable credentials (the operator `nsec` and +// the NWC connection string), so the directory and file permissions must match +// the wallet seed handling (0700/0600), not the umask default (0755/0644). + +function chmodIgnoreErrors(path: string, mode: number): void { try { - await mkdir(CONFIG_DIR, { recursive: true }); - await mkdir(REQUESTS_DIR, { recursive: true }); + chmodSync(path, mode); + } catch { + // Best effort on filesystems that do not support POSIX modes. + } +} + +/** Create the config directories (0700), correcting existing installs too. */ +export function ensureDirsSync(): void { + try { + mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 }); } catch { // Directory may already exist } + chmodSync(CONFIG_DIR, 0o700); + + try { + mkdirSync(REQUESTS_DIR, { recursive: true, mode: 0o700 }); + } catch { + // Directory may already exist + } + chmodSync(REQUESTS_DIR, 0o700); +} + +export async function ensureDirs(): Promise { + try { + await mkdir(CONFIG_DIR, { recursive: true, mode: 0o700 }); + } catch { + // Directory may already exist + } + chmodSync(CONFIG_DIR, 0o700); + + try { + await mkdir(REQUESTS_DIR, { recursive: true, mode: 0o700 }); + } catch { + // Directory may already exist + } + chmodSync(REQUESTS_DIR, 0o700); +} + +function repairConfigPermissions(): void { + if (existsSync(CONFIG_FILE)) { + chmodIgnoreErrors(CONFIG_FILE, 0o600); + } } export async function loadDaemonConfig(): Promise { try { if (existsSync(CONFIG_FILE)) { + repairConfigPermissions(); const content = await Bun.file(CONFIG_FILE).text(); return { ...DEFAULT_CONFIG, ...JSON.parse(content) }; } @@ -34,6 +85,7 @@ export async function loadDaemonConfig(): Promise { export function loadDaemonConfigSync(): RoutstrdConfig { try { if (existsSync(CONFIG_FILE)) { + repairConfigPermissions(); const content = readFileSync(CONFIG_FILE, "utf-8"); return { ...DEFAULT_CONFIG, ...JSON.parse(content) }; } @@ -43,6 +95,29 @@ export function loadDaemonConfigSync(): RoutstrdConfig { return DEFAULT_CONFIG; } +/** + * Persist the daemon config atomically with owner-only permissions, mirroring + * the wallet config writer (saveConfig in wallet/coco-client.ts): write a + * 0600 temp file, then rename over the target. Synchronous so callers get + * error propagation instead of silently dropping credential updates. + */ export function saveDaemonConfig(config: RoutstrdConfig): void { - Bun.write(CONFIG_FILE, JSON.stringify(config, null, 2)); + ensureDirsSync(); + const temporaryFile = `${CONFIG_FILE}.${process.pid}.tmp`; + try { + writeFileSync(temporaryFile, JSON.stringify(config, null, 2), { + mode: 0o600, + }); + renameSync(temporaryFile, CONFIG_FILE); + } catch (error) { + try { + unlinkSync(temporaryFile); + } catch { + // The temporary file may not have been created. + } + throw error; + } + // renameSync preserves the temp file's mode, but chmod anyway so an + // existing over-permissive file is corrected even on exotic filesystems. + chmodIgnoreErrors(CONFIG_FILE, 0o600); } diff --git a/tests/daemon/config-store.perms.test.ts b/tests/daemon/config-store.perms.test.ts new file mode 100644 index 0000000..4688af4 --- /dev/null +++ b/tests/daemon/config-store.perms.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, test } from "bun:test"; +import { spawnSync } from "child_process"; +import { mkdtempSync, rmSync } from "fs"; +import { join } from "path"; +import { tmpdir } from "os"; + +// These scenarios mutate process.env.ROUTSTRD_DIR before the config-store +// module is loaded, and other test files import the same module singleton +// first — so each scenario runs in an isolated bun subprocess where the env +// is guaranteed to be in place before module evaluation. + +const SCENARIO = join(import.meta.dir, "config-store.scenario.ts"); + +function runScenario(name: string): { code: number; out: string } { + const dir = mkdtempSync(join(tmpdir(), `routstrd-cfg-${name}-`)); + const res = spawnSync("bun", [SCENARIO, name], { + env: { + ...process.env, + NODE_ENV: "test", + ROUTSTRD_DIR: join(dir, "daemon"), + }, + stdout: "pipe", + stderr: "pipe", + }); + const out = `${res.stdout}${res.stderr}`; + rmSync(dir, { recursive: true, force: true }); + // bun's spawnSync reports the exit code in `status` (exitCode is undefined). + return { code: res.status ?? res.exitCode ?? -1, out }; +} + +describe("daemon config store permissions", () => { + test("fresh install: config dir 0700, config file 0600, atomic write", () => { + const { code, out } = runScenario("fresh-install"); + expect(out).toContain("SCENARIO-OK"); + expect(code).toBe(0); + }); + + test("existing over-permissive install (0755/0644) is repaired", () => { + const { code, out } = runScenario("repair-perms"); + expect(out).toContain("SCENARIO-OK"); + expect(code).toBe(0); + }); + + test("write failure surfaces synchronously with no temp file left", () => { + const { code, out } = runScenario("write-failure"); + expect(out).toContain("SCENARIO-OK"); + expect(code).toBe(0); + }); + + test("corrupt JSON falls back to defaults", () => { + const { code, out } = runScenario("corrupt-json"); + expect(out).toContain("SCENARIO-OK"); + expect(code).toBe(0); + }); +}); diff --git a/tests/daemon/config-store.scenario.ts b/tests/daemon/config-store.scenario.ts new file mode 100644 index 0000000..ae9b5b3 --- /dev/null +++ b/tests/daemon/config-store.scenario.ts @@ -0,0 +1,113 @@ +// Scenario runner for config-store.perms.test.ts — executed as a standalone +// bun process with ROUTSTRD_DIR set before module evaluation. Not a test file. +import { + chmodSync, + existsSync, + readdirSync, + statSync, + writeFileSync, +} from "fs"; + +const { CONFIG_DIR, CONFIG_FILE } = await import("../../src/utils/config"); +const { + ensureDirs, + ensureDirsSync, + loadDaemonConfig, + loadDaemonConfigSync, + saveDaemonConfig, + REQUESTS_DIR, +} = await import("../../src/daemon/config-store"); + +const modeOf = (p: string): number => statSync(p).mode & 0o777; + +const baseConfig = { + port: 8008, + host: "127.0.0.1", + provider: null, + cocodPath: null, +}; + +function assert(cond: unknown, msg: string): void { + if (!cond) { + console.error(`ASSERT-FAIL: ${msg}`); + process.exit(1); + } +} + +const scenario = process.argv[2]; + +switch (scenario) { + case "fresh-install": { + saveDaemonConfig({ + ...baseConfig, + nsec: "nsec1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq", + }); + assert(modeOf(CONFIG_DIR) === 0o700, `dir mode ${modeOf(CONFIG_DIR).toString(8)} != 700`); + assert(modeOf(CONFIG_FILE) === 0o600, `file mode ${modeOf(CONFIG_FILE).toString(8)} != 600`); + assert( + readdirSync(CONFIG_DIR).filter((f) => f.endsWith(".tmp")).length === 0, + "temp file left behind", + ); + const loaded = await loadDaemonConfig(); + assert( + loaded.nsec === "nsec1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq", + "nsec round-trip failed", + ); + assert(loaded.port === 8008, "port round-trip failed"); + + await ensureDirs(); + assert(modeOf(CONFIG_DIR) === 0o700, "ensureDirs dir mode"); + assert(modeOf(REQUESTS_DIR) === 0o700, "ensureDirs requests dir mode"); + break; + } + + case "repair-perms": { + // Simulate an install written by the vulnerable version. + ensureDirsSync(); + writeFileSync(CONFIG_FILE, JSON.stringify(baseConfig), { mode: 0o644 }); + chmodSync(CONFIG_DIR, 0o755); + chmodSync(CONFIG_FILE, 0o644); + assert(modeOf(CONFIG_FILE) === 0o644, "setup: file not 0644"); + + // Load repairs the file mode... + loadDaemonConfigSync(); + assert(modeOf(CONFIG_FILE) === 0o600, "load did not repair file mode"); + + // ...and the next save repairs the directory mode too. + chmodSync(CONFIG_DIR, 0o755); + saveDaemonConfig({ ...baseConfig, port: 9009 }); + assert(modeOf(CONFIG_DIR) === 0o700, "save did not repair dir mode"); + assert(modeOf(CONFIG_FILE) === 0o600, "save did not keep file mode"); + assert(loadDaemonConfigSync().port === 9009, "save did not persist port"); + break; + } + + case "write-failure": { + // Turn the config dir into a regular file: mkdir fails and no temp write + // can succeed, so saveDaemonConfig must surface the error synchronously. + writeFileSync(process.env.ROUTSTRD_DIR!, "blocked"); + let threw = false; + try { + saveDaemonConfig(baseConfig); + } catch { + threw = true; + } + assert(threw, "saveDaemonConfig did not throw on unwritable target"); + break; + } + + case "corrupt-json": { + ensureDirsSync(); + writeFileSync(CONFIG_FILE, "{ not json"); + const loaded = await loadDaemonConfig(); + assert(loaded.port === 8008, "did not fall back to defaults"); + assert(existsSync(CONFIG_FILE), "config file vanished"); + break; + } + + default: + console.error(`unknown scenario: ${scenario}`); + process.exit(2); +} + +console.log("SCENARIO-OK");