From 8a69816029d83c9adf9e56bc9cdc29a4341e0136 Mon Sep 17 00:00:00 2001 From: redshift <213178690+sh1ftred@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:58:36 +0000 Subject: [PATCH] =?UTF-8?q?fix(security):=20daemon=20config=20written=20wo?= =?UTF-8?q?rld-readable=20=E2=80=94=20enforce=200600/0700,=20atomic=20writ?= =?UTF-8?q?es=20(#86)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * pi integration * feat: wire routstrModelsPubkey through daemon Add RoutstrdConfig.routstrModelsPubkey and pass it to ModelManager and the HTTP handler deps, forwarding it into routeRequests so the models allowlist pubkey (kind 38423) can be set independently of the audit pubkey (kind 38425). * chore: link @routstr/sdk as file dep; ignore findings Switch @routstr/sdk to file:../routstr-sdk for local development, refresh bun.lock, and ignore findings artifacts. * fix(security): write daemon config with 0600/0700 perms, atomically The daemon config stores spend-capable credentials (operator nsec and the NWC connection string), but saveDaemonConfig wrote it with Bun.write() and no mode, and ensureDirs created the config dir with no mode — yielding a 0755 dir and 0644 file under the standard 022 umask, readable by any local user. The wallet seed path already gets this right (0700/0600), so this was an inconsistency, not a trade-off. - saveDaemonConfig now writes via temp-file + rename with mode 0600 (mirroring saveConfig in wallet/coco-client.ts), is synchronous so write errors propagate instead of being silently dropped, and chmods the target so previously over-permissive files are repaired on every save. - ensureDirs creates CONFIG_DIR/REQUESTS_DIR with mode 0700 and chmods existing dirs, correcting older installs on every daemon start. - loadDaemonConfig/loadDaemonConfigSync chmod the config file 0600 on read, so even a never-saved install gets repaired. - cli.ts routes its raw Bun.write(CONFIG_FILE) calls (init, nsec generation, remote/local mode switch, mode set) through saveDaemonConfig and uses ensureDirsSync for the initial directory creation. Also fixes the crash-mid-write hazard: a torn JSON write previously made loadDaemonConfig silently revert to DEFAULT_CONFIG, dropping nsec/NWC/ provider settings; the atomic rename prevents that. Adds subprocess-isolated regression tests (tests/daemon/) asserting 0600/0700 on fresh installs, repair of 0644/0755 installs, synchronous error propagation, and corrupt-JSON fallback. --------- Co-authored-by: redshift <213178690+1ftredsh@users.noreply.github.com> --- src/cli.ts | 17 ++-- src/daemon/config-store.ts | 85 ++++++++++++++++-- tests/daemon/config-store.perms.test.ts | 55 ++++++++++++ tests/daemon/config-store.scenario.ts | 113 ++++++++++++++++++++++++ 4 files changed, 257 insertions(+), 13 deletions(-) create mode 100644 tests/daemon/config-store.perms.test.ts create mode 100644 tests/daemon/config-store.scenario.ts 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");