Files
routstrd/tests/daemon/config-store.scenario.ts
T
8a69816029 fix(security): daemon config written world-readable — enforce 0600/0700, atomic writes (#86)
* 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>
2026-08-24 20:58:36 +00:00

114 lines
3.4 KiB
TypeScript

// 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");