From ba203d3f37f2d4fca785284f160b2d484a67c958 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A4=D0=B5=D0=B4=D0=BE=D1=81=D0=B5=D0=B5=D0=B2=20=D0=A1?= =?UTF-8?q?=D1=82=D0=B0=D1=81?= Date: Tue, 11 Aug 2026 14:15:41 +0300 Subject: [PATCH] Harden Murmur state file handling --- SECURITY.md | 14 ++++ packages/core/src/index.ts | 32 ++++++++- packages/mcp-server/src/index.ts | 41 ++++++++++- scripts/agent-config-init.mjs | 12 ++-- scripts/demo-secure-common.mjs | 12 ++-- scripts/murmur-add-peer.mjs | 9 +-- scripts/murmur-daemon.mjs | 7 +- scripts/murmur-invite.mjs | 7 +- scripts/murmur-join.mjs | 12 ++-- scripts/murmur-mcp-channel-server.mjs | 7 +- scripts/murmur-notify-init.mjs | 6 +- scripts/murmur-openclaw-init.mjs | 19 +++-- scripts/murmur-shell-send.mjs | 3 +- scripts/secure-state.mjs | 99 +++++++++++++++++++++++++++ tests/core-dedupe-store.test.mjs | 3 +- tests/secure-state.test.mjs | 78 +++++++++++++++++++++ 16 files changed, 316 insertions(+), 45 deletions(-) create mode 100644 scripts/secure-state.mjs create mode 100644 tests/secure-state.test.mjs diff --git a/SECURITY.md b/SECURITY.md index ad9b64d..a143e63 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -27,6 +27,20 @@ Mur-Mur uses **X25519** key exchange with **XChaCha20-Poly1305** authenticated encryption (via NaCl/libsodium). All inter-agent messages are encrypted end-to-end; the NATS transport never sees plaintext payloads. +## Local state + +`agent-config.json` contains long-term private keys and broker credentials. Murmur creates and +atomically replaces it as mode `0600` inside a mode `0700` state directory, rejects symlinked or +wrong-owner state paths, and starts the daemon with umask `0077`. SQLite database, WAL, and shared +memory files are also forced to `0600`. Deployments should apply the same `UMask=0077` policy in +their service manager and protect backups equivalently. + +Decrypted message bodies are currently stored as plaintext in the local SQLite database for search +and conversation history. File permissions reduce cross-user disclosure but do not protect against +the daemon identity itself being compromised. Run Murmur under a dedicated OS identity and use +full-disk/volume encryption or an explicit retention policy until application-level database +encryption is available. + ## Disclosure We follow coordinated disclosure. Once a fix is released, we will credit diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index af54641..a051e14 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,5 +1,5 @@ import { createHash, randomUUID } from "node:crypto"; -import { mkdirSync, promises as fs } from "node:fs"; +import { chmodSync, existsSync, lstatSync, mkdirSync, promises as fs } from "node:fs"; import path from "node:path"; import { DatabaseSync } from "node:sqlite"; @@ -304,8 +304,33 @@ export class JsonFileOutboxStore implements OutboxStore { } const ensureDir = (filePath: string): void => { + if (filePath === ":memory:" || filePath.startsWith("file::memory:")) return; const dir = path.dirname(filePath); - mkdirSync(dir, { recursive: true }); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 }); + const dirStats = lstatSync(dir); + if (dirStats.isSymbolicLink() || !dirStats.isDirectory()) { + throw new Error(`sqlite-state-directory-invalid:${dir}`); + } + if (typeof process.getuid === "function" && dirStats.uid !== process.getuid()) { + throw new Error(`sqlite-state-directory-owner-mismatch:${dir}`); + } + + if (existsSync(filePath)) { + const fileStats = lstatSync(filePath); + if (fileStats.isSymbolicLink() || !fileStats.isFile()) { + throw new Error(`sqlite-state-file-invalid:${filePath}`); + } + if (typeof process.getuid === "function" && fileStats.uid !== process.getuid()) { + throw new Error(`sqlite-state-file-owner-mismatch:${filePath}`); + } + } +}; + +const secureSqliteFiles = (filePath: string): void => { + if (filePath === ":memory:" || filePath.startsWith("file::memory:")) return; + for (const candidate of [filePath, `${filePath}-wal`, `${filePath}-shm`]) { + if (existsSync(candidate)) chmodSync(candidate, 0o600); + } }; export class SQLiteDedupeOutboxStore implements DedupeStore, OutboxStore { @@ -336,6 +361,7 @@ export class SQLiteDedupeOutboxStore implements DedupeStore, OutboxStore { ); CREATE INDEX IF NOT EXISTS idx_outbox_due ON outbox(status, next_attempt_at); `); + secureSqliteFiles(dbPath); } async seen(msgId: string, consumerId: string): Promise { @@ -542,6 +568,7 @@ export class SQLiteMessageStore { CREATE INDEX IF NOT EXISTS idx_local_messages_conversation ON local_messages(conversation_id, created_at DESC); CREATE INDEX IF NOT EXISTS idx_local_messages_text ON local_messages(text); `); + secureSqliteFiles(dbPath); } async append(input: Omit): Promise { @@ -1012,6 +1039,7 @@ export class SQLiteStreamReassembler { ); CREATE INDEX IF NOT EXISTS idx_stream_reassembly_chunks_stream ON stream_reassembly_chunks(stream_id); `); + secureSqliteFiles(dbPath); } acceptEnd(end: StreamEnd): StreamReassemblyResult { diff --git a/packages/mcp-server/src/index.ts b/packages/mcp-server/src/index.ts index 647c14c..32382ff 100644 --- a/packages/mcp-server/src/index.ts +++ b/packages/mcp-server/src/index.ts @@ -1,5 +1,13 @@ import { randomUUID } from "node:crypto"; -import { readFileSync } from "node:fs"; +import { + chmodSync, + closeSync, + constants, + fstatSync, + lstatSync, + openSync, + readFileSync, +} from "node:fs"; import path from "node:path"; import { createInterface } from "node:readline"; import { @@ -47,9 +55,38 @@ const configPath = path.join(dataDir, "agent-config.json"); const dbPath = process.env.MURMUR_STORE_PATH ?? path.join(dataDir, "murmur.db"); const channelRosterPath = process.env.MURMUR_CHANNEL_ROSTER_PATH ?? path.join(dataDir, "channel-roster.db"); +const readPrivateAgentConfig = (filePath: string): AgentConfig => { + process.umask(0o077); + const dirStats = lstatSync(path.dirname(filePath)); + if (dirStats.isSymbolicLink() || !dirStats.isDirectory()) throw new Error("agent-config-directory-invalid"); + if (typeof process.getuid === "function" && dirStats.uid !== process.getuid()) { + throw new Error("agent-config-directory-owner-mismatch"); + } + chmodSync(path.dirname(filePath), 0o700); + + const pathStats = lstatSync(filePath); + if (pathStats.isSymbolicLink() || !pathStats.isFile()) throw new Error("agent-config-file-invalid"); + if (typeof process.getuid === "function" && pathStats.uid !== process.getuid()) { + throw new Error("agent-config-file-owner-mismatch"); + } + chmodSync(filePath, 0o600); + + const fd = openSync(filePath, constants.O_RDONLY | constants.O_NOFOLLOW); + try { + const openedStats = fstatSync(fd); + if (!openedStats.isFile()) throw new Error("agent-config-file-invalid"); + if (typeof process.getuid === "function" && openedStats.uid !== process.getuid()) { + throw new Error("agent-config-file-owner-mismatch"); + } + return JSON.parse(readFileSync(fd, "utf8")) as AgentConfig; + } finally { + closeSync(fd); + } +}; + let agentConfig: AgentConfig | null = null; try { - agentConfig = JSON.parse(readFileSync(configPath, "utf8")) as AgentConfig; + agentConfig = readPrivateAgentConfig(configPath); } catch { // Agent config not found — send/inbox/peers tools will be unavailable } diff --git a/scripts/agent-config-init.mjs b/scripts/agent-config-init.mjs index 75f78a2..244cac3 100644 --- a/scripts/agent-config-init.mjs +++ b/scripts/agent-config-init.mjs @@ -8,9 +8,9 @@ * Env overrides: AGENT_ID, NATS_URL, NATS_TOKEN, DATA_DIR */ import { createInterface } from "node:readline/promises"; -import { mkdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import { createKeyPair, createSigningKeyPair, getCryptoProvider } from "@murmurv2/security"; +import { readPrivateJson, writePrivateJson } from "./secure-state.mjs"; const rl = createInterface({ input: process.stdin, output: process.stdout }); @@ -25,8 +25,7 @@ const run = async () => { // Check if config already exists try { - const existing = await readFile(configPath, "utf8"); - const parsed = JSON.parse(existing); + const parsed = await readPrivateJson(configPath); console.log(`[init] Config already exists at ${configPath} (agentId: ${parsed.agentId})`); const overwrite = await ask("Overwrite? (yes/no)", "no"); if (overwrite !== "yes") { @@ -34,8 +33,8 @@ const run = async () => { rl.close(); return; } - } catch { - // No existing config — proceed + } catch (err) { + if (err?.code !== "ENOENT") throw err; } const agentId = process.env.AGENT_ID || await ask("Agent ID", "my-agent"); @@ -57,8 +56,7 @@ const run = async () => { peers: {}, }; - await mkdir(dataDir, { recursive: true }); - await writeFile(configPath, JSON.stringify(config, null, 2) + "\n", "utf8"); + await writePrivateJson(configPath, config); console.log(`[init] Config written to ${configPath}`); console.log(""); diff --git a/scripts/demo-secure-common.mjs b/scripts/demo-secure-common.mjs index 0348bbb..e2cb61a 100644 --- a/scripts/demo-secure-common.mjs +++ b/scripts/demo-secure-common.mjs @@ -1,7 +1,6 @@ -import { mkdir, readFile, writeFile } from "node:fs/promises"; -import path from "node:path"; import { stableEnvelopePayload } from "@murmurv2/core"; import { createKeyPair, createSigningKeyPair, getCryptoProvider } from "@murmurv2/security"; +import { readPrivateJson, writePrivateJson } from "./secure-state.mjs"; // Re-export the canonical signing form from @murmurv2/core for the demo scripts. export { stableEnvelopePayload }; @@ -47,9 +46,9 @@ export const policyFromConfig = (cfg) => ({ export const ensureDemoKeys = async (keysPath = DEFAULT_KEYS_PATH) => { try { - const raw = await readFile(keysPath, "utf8"); - return JSON.parse(raw); - } catch { + return await readPrivateJson(keysPath); + } catch (err) { + if (err?.code !== "ENOENT") throw err; const senderEncryption = await createKeyPair(); const recipientEncryption = await createKeyPair(); const senderSigning = await createSigningKeyPair(); @@ -66,8 +65,7 @@ export const ensureDemoKeys = async (keysPath = DEFAULT_KEYS_PATH) => { }, }; - await mkdir(path.dirname(keysPath), { recursive: true }); - await writeFile(keysPath, `${JSON.stringify(keys, null, 2)}\n`, "utf8"); + await writePrivateJson(keysPath, keys); return keys; } }; diff --git a/scripts/murmur-add-peer.mjs b/scripts/murmur-add-peer.mjs index dae15b1..4beb322 100644 --- a/scripts/murmur-add-peer.mjs +++ b/scripts/murmur-add-peer.mjs @@ -6,8 +6,8 @@ * Usage: node scripts/murmur-add-peer.mjs MURMUR-REPLY:eyJ... * Env: DATA_DIR (default: .data) */ -import { readFile, writeFile } from "node:fs/promises"; import path from "node:path"; +import { readPrivateJson, writePrivateJson } from "./secure-state.mjs"; const blob = process.argv[2]; if (!blob || !blob.startsWith("MURMUR-REPLY:")) { @@ -32,8 +32,9 @@ const configPath = path.join(dataDir, "agent-config.json"); let config; try { - config = JSON.parse(await readFile(configPath, "utf8")); -} catch { + config = await readPrivateJson(configPath); +} catch (err) { + if (err?.code !== "ENOENT") throw err; console.error("[add-peer] No agent config found. Run first: node scripts/agent-config-init.mjs"); process.exit(1); } @@ -46,7 +47,7 @@ config.peers[reply.agentId] = { subject: reply.subject, }; -await writeFile(configPath, JSON.stringify(config, null, 2) + "\n", "utf8"); +await writePrivateJson(configPath, config); console.log(`[add-peer] Added: ${reply.agentId} (${reply.subject})`); console.log(""); diff --git a/scripts/murmur-daemon.mjs b/scripts/murmur-daemon.mjs index 6b2c455..bc10226 100644 --- a/scripts/murmur-daemon.mjs +++ b/scripts/murmur-daemon.mjs @@ -2,7 +2,6 @@ /** * murmur-daemon.mjs — Persistent agent-to-agent messaging daemon. */ -import { readFile } from "node:fs/promises"; import { DatabaseSync } from "node:sqlite"; import path from "node:path"; import { setTimeout as sleep } from "node:timers/promises"; @@ -14,8 +13,11 @@ import { createChannelThreadStartBindingResolver, createCodexAppServerInjector } import { startJetStreamAdvisoryDlqIfEnabled } from "./murmur-jetstream-advisory.mjs"; import { WakeMonitor, createAuditShellHook, createShellHook, normalizeWakeConfig } from "./wake-monitor.mjs"; import { SessionLeaseStore, createNativeLeaseGate } from "./lease.mjs"; +import { ensurePrivateDirectory, readPrivateJson, setPrivateUmask } from "./secure-state.mjs"; // vault-guard: optional content policy hook (not included in OSS release) +setPrivateUmask(); + const log = (level, msg, data) => { const entry = { ts: new Date().toISOString(), level, msg, ...data }; console.log(JSON.stringify(entry)); @@ -26,7 +28,8 @@ const configPath = path.join(dataDir, "agent-config.json"); let config; try { - config = JSON.parse(await readFile(configPath, "utf8")); + await ensurePrivateDirectory(dataDir); + config = await readPrivateJson(configPath); } catch (err) { log("fatal", "Cannot load agent config", { path: configPath, error: err.message }); log("info", "Run: node scripts/agent-config-init.mjs"); diff --git a/scripts/murmur-invite.mjs b/scripts/murmur-invite.mjs index 8a97b8f..6420b29 100644 --- a/scripts/murmur-invite.mjs +++ b/scripts/murmur-invite.mjs @@ -7,16 +7,17 @@ * Usage: node scripts/murmur-invite.mjs * Env: DATA_DIR (default: .data) */ -import { readFile } from "node:fs/promises"; import path from "node:path"; +import { readPrivateJson } from "./secure-state.mjs"; const dataDir = process.env.DATA_DIR || ".data"; const configPath = path.join(dataDir, "agent-config.json"); let config; try { - config = JSON.parse(await readFile(configPath, "utf8")); -} catch { + config = await readPrivateJson(configPath); +} catch (err) { + if (err?.code !== "ENOENT") throw err; console.error("[invite] No agent config found. Run first: node scripts/agent-config-init.mjs"); process.exit(1); } diff --git a/scripts/murmur-join.mjs b/scripts/murmur-join.mjs index ebfc38d..e8dab67 100644 --- a/scripts/murmur-join.mjs +++ b/scripts/murmur-join.mjs @@ -8,9 +8,9 @@ * Env: AGENT_ID (default: prompted), DATA_DIR (default: .data) */ import { createInterface } from "node:readline/promises"; -import { mkdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import { createKeyPair, createSigningKeyPair, getCryptoProvider } from "@murmurv2/security"; +import { readPrivateJson, writePrivateJson } from "./secure-state.mjs"; const blob = process.argv[2]; if (!blob || !blob.startsWith("MURMUR:")) { @@ -38,9 +38,10 @@ const configPath = path.join(dataDir, "agent-config.json"); // Check if config exists let config; try { - config = JSON.parse(await readFile(configPath, "utf8")); + config = await readPrivateJson(configPath); console.log(`[join] Using existing config: ${config.agentId}`); -} catch { +} catch (err) { + if (err?.code !== "ENOENT") throw err; // Need to create config — ask for agent ID let agentId = process.env.AGENT_ID; if (!agentId) { @@ -64,8 +65,7 @@ try { peers: {}, }; - await mkdir(dataDir, { recursive: true }); - await writeFile(configPath, JSON.stringify(config, null, 2) + "\n", "utf8"); + await writePrivateJson(configPath, config); console.log(`[join] Config created: ${configPath}`); } @@ -83,7 +83,7 @@ if (config.natsUrl !== invite.natsUrl) { console.log(`[join] Keeping yours. Edit .data/agent-config.json if needed.`); } -await writeFile(configPath, JSON.stringify(config, null, 2) + "\n", "utf8"); +await writePrivateJson(configPath, config); console.log(`[join] Added peer: ${invite.agentId}`); // Generate reply blob diff --git a/scripts/murmur-mcp-channel-server.mjs b/scripts/murmur-mcp-channel-server.mjs index 1d04cda..cf21084 100755 --- a/scripts/murmur-mcp-channel-server.mjs +++ b/scripts/murmur-mcp-channel-server.mjs @@ -1,5 +1,5 @@ #!/usr/bin/env node -import { appendFileSync, readFileSync } from "node:fs"; +import { appendFileSync } from "node:fs"; import path from "node:path"; import { createInterface } from "node:readline"; import { homedir } from "node:os"; @@ -10,6 +10,9 @@ import { decryptPayload, verifyEnvelopeSignature, } from "../packages/security/dist/src/index.js"; +import { readPrivateJson, setPrivateUmask } from "./secure-state.mjs"; + +setPrivateUmask(); const scriptDir = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(scriptDir, ".."); @@ -66,7 +69,7 @@ const stableEnvelopePayload = (envelope) => const dataDir = process.env.DATA_DIR || ".data"; const configPath = path.join(dataDir, "agent-config.json"); -const config = JSON.parse(readFileSync(configPath, "utf8")); +const config = await readPrivateJson(configPath); const dbPath = process.env.MURMUR_STORE_PATH ?? path.join(dataDir, "murmur.db"); const murmurRoot = process.env.MURMUR_ROOT || repoRoot; const leaseDbPath = process.env.MURMUR_LEASE_DB || path.join(dataDir, "lease.db"); diff --git a/scripts/murmur-notify-init.mjs b/scripts/murmur-notify-init.mjs index 2fa6020..0844ab9 100644 --- a/scripts/murmur-notify-init.mjs +++ b/scripts/murmur-notify-init.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node -import { readFile, writeFile } from "node:fs/promises"; import path from "node:path"; +import { readPrivateJson, writePrivateJson } from "./secure-state.mjs"; const dataDir = process.env.DATA_DIR || ".data"; const configPath = path.join(dataDir, "agent-config.json"); @@ -8,7 +8,7 @@ const preset = (process.argv[2] || "telegram").toLowerCase(); const requireConfig = async () => { try { - return JSON.parse(await readFile(configPath, "utf8")); + return await readPrivateJson(configPath); } catch (err) { console.error(`[notify-init] Failed to read ${configPath}: ${err.message}`); process.exit(1); @@ -61,7 +61,7 @@ const run = async () => { process.exit(1); } - await writeFile(configPath, JSON.stringify(cfg, null, 2) + "\n", "utf8"); + await writePrivateJson(configPath, cfg); console.log(`[notify-init] Updated ${configPath}`); }; diff --git a/scripts/murmur-openclaw-init.mjs b/scripts/murmur-openclaw-init.mjs index b35db71..9a29849 100644 --- a/scripts/murmur-openclaw-init.mjs +++ b/scripts/murmur-openclaw-init.mjs @@ -1,13 +1,13 @@ #!/usr/bin/env node -import { readFile, writeFile } from "node:fs/promises"; import path from "node:path"; +import { readPrivateJson, writePrivateJson } from "./secure-state.mjs"; const dataDir = process.env.DATA_DIR || ".data"; const configPath = path.join(dataDir, "agent-config.json"); const readConfig = async () => { try { - return JSON.parse(await readFile(configPath, "utf8")); + return await readPrivateJson(configPath); } catch (err) { console.error(`[openclaw-init] Failed to read ${configPath}: ${err.message}`); process.exit(1); @@ -48,10 +48,19 @@ const run = async () => { ...(command ? { command } : { helperScript }), }; - await writeFile(configPath, JSON.stringify(cfg, null, 2) + "\n", "utf8"); + await writePrivateJson(configPath, cfg); console.log(`[openclaw-init] Updated ${configPath}`); - console.log("[openclaw-init] notify.openclaw:"); - console.log(JSON.stringify(cfg.notify.openclaw, null, 2)); + console.log("[openclaw-init] notify.openclaw configured", { + channel: cfg.notify.openclaw.channel, + routeChannel: cfg.notify.openclaw.routeChannel, + hasAgent: Boolean(cfg.notify.openclaw.agent), + hasSessionId: Boolean(cfg.notify.openclaw.sessionId), + hasSessionKey: Boolean(cfg.notify.openclaw.sessionKey), + hasTarget: Boolean(cfg.notify.openclaw.to), + hasGatewayUrl: Boolean(cfg.notify.openclaw.gatewayUrl), + hasGatewayToken: Boolean(cfg.notify.openclaw.gatewayToken), + mode: cfg.notify.openclaw.command ? "command" : "helper-script", + }); }; run().catch((err) => { diff --git a/scripts/murmur-shell-send.mjs b/scripts/murmur-shell-send.mjs index d5d9cee..796e048 100755 --- a/scripts/murmur-shell-send.mjs +++ b/scripts/murmur-shell-send.mjs @@ -7,6 +7,7 @@ import { readFileSync } from "node:fs"; import path from "node:path"; import { SQLiteDedupeOutboxStore, SQLiteMessageStore, stableEnvelopePayload } from "@murmurv2/core"; import { encryptPayload, signEnvelope } from "@murmurv2/security"; +import { readPrivateJson } from "./secure-state.mjs"; const args = process.argv.slice(2); const opt = {}; @@ -44,7 +45,7 @@ const dbPath = process.env.MURMUR_STORE_PATH ?? path.join(dataDir, "murmur.db"); let cfg; try { - cfg = JSON.parse(readFileSync(configPath, "utf8")); + cfg = await readPrivateJson(configPath); } catch (err) { process.stderr.write(`error: cannot read ${configPath}: ${err.message}\n`); process.exit(2); diff --git a/scripts/secure-state.mjs b/scripts/secure-state.mjs new file mode 100644 index 0000000..417d780 --- /dev/null +++ b/scripts/secure-state.mjs @@ -0,0 +1,99 @@ +import { constants } from "node:fs"; +import { chmod, lstat, mkdir, open, rename, unlink } from "node:fs/promises"; +import path from "node:path"; +import { randomUUID } from "node:crypto"; + +const currentUid = () => typeof process.getuid === "function" ? process.getuid() : undefined; + +const assertOwned = (stats, target) => { + const uid = currentUid(); + if (uid !== undefined && stats.uid !== uid) { + throw new Error(`private-state-owner-mismatch:${target}`); + } +}; + +const assertPrivateDirectory = async (dirPath) => { + const stats = await lstat(dirPath); + if (stats.isSymbolicLink() || !stats.isDirectory()) { + throw new Error(`private-state-directory-invalid:${dirPath}`); + } + assertOwned(stats, dirPath); +}; + +const assertPrivateRegularFile = async (filePath) => { + const stats = await lstat(filePath); + if (stats.isSymbolicLink() || !stats.isFile()) { + throw new Error(`private-state-file-invalid:${filePath}`); + } + assertOwned(stats, filePath); +}; + +export const setPrivateUmask = () => { + process.umask(0o077); +}; + +export const ensurePrivateDirectory = async (dirPath) => { + setPrivateUmask(); + await mkdir(dirPath, { recursive: true, mode: 0o700 }); + await assertPrivateDirectory(dirPath); + await chmod(dirPath, 0o700); +}; + +export const readPrivateJson = async (filePath) => { + setPrivateUmask(); + await assertPrivateDirectory(path.dirname(filePath)); + await assertPrivateRegularFile(filePath); + await chmod(filePath, 0o600); + + const handle = await open(filePath, constants.O_RDONLY | constants.O_NOFOLLOW); + try { + const stats = await handle.stat(); + if (!stats.isFile()) throw new Error(`private-state-file-invalid:${filePath}`); + assertOwned(stats, filePath); + return JSON.parse(await handle.readFile("utf8")); + } finally { + await handle.close(); + } +}; + +export const writePrivateJson = async (filePath, value) => { + setPrivateUmask(); + const dirPath = path.dirname(filePath); + await ensurePrivateDirectory(dirPath); + + try { + await assertPrivateRegularFile(filePath); + } catch (err) { + if (err?.code !== "ENOENT") throw err; + } + + const tempPath = path.join( + dirPath, + `.${path.basename(filePath)}.${process.pid}.${randomUUID()}.tmp`, + ); + let handle; + try { + handle = await open( + tempPath, + constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, + 0o600, + ); + await handle.writeFile(`${JSON.stringify(value, null, 2)}\n`, "utf8"); + await handle.sync(); + await handle.close(); + handle = undefined; + await rename(tempPath, filePath); + await chmod(filePath, 0o600); + + const dirHandle = await open(dirPath, constants.O_RDONLY | constants.O_DIRECTORY); + try { + await dirHandle.sync(); + } finally { + await dirHandle.close(); + } + } catch (err) { + if (handle) await handle.close().catch(() => {}); + await unlink(tempPath).catch(() => {}); + throw err; + } +}; diff --git a/tests/core-dedupe-store.test.mjs b/tests/core-dedupe-store.test.mjs index 6c4e5ad..ec4c0fa 100644 --- a/tests/core-dedupe-store.test.mjs +++ b/tests/core-dedupe-store.test.mjs @@ -1,6 +1,6 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { mkdtempSync, rmSync } from "node:fs"; +import { mkdtempSync, rmSync, statSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { InMemoryDedupeStore, SQLiteDedupeOutboxStore } from "../packages/core/dist/src/index.js"; @@ -33,6 +33,7 @@ test("SQLiteDedupeOutboxStore markSeen/seen roundtrip", async () => { try { const store = new SQLiteDedupeOutboxStore(dbPath); + assert.equal(statSync(dbPath).mode & 0o777, 0o600); assert.equal(await store.seen("m1", "consumer-1"), false); await store.markSeen("m1", "consumer-1"); assert.equal(await store.seen("m1", "consumer-1"), true); diff --git a/tests/secure-state.test.mjs b/tests/secure-state.test.mjs new file mode 100644 index 0000000..b215d5b --- /dev/null +++ b/tests/secure-state.test.mjs @@ -0,0 +1,78 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + chmodSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + statSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { readPrivateJson, writePrivateJson } from "../scripts/secure-state.mjs"; + +const mode = (filePath) => statSync(filePath).mode & 0o777; + +test("private JSON writes use a 0700 directory and atomic 0600 file", async () => { + const root = mkdtempSync(join(tmpdir(), "murmur-private-state-")); + const dir = join(root, ".data"); + const config = join(dir, "agent-config.json"); + + try { + await writePrivateJson(config, { generation: 1, secret: "first" }); + const firstInode = statSync(config).ino; + assert.equal(mode(dir), 0o700); + assert.equal(mode(config), 0o600); + assert.deepEqual(await readPrivateJson(config), { generation: 1, secret: "first" }); + + await writePrivateJson(config, { generation: 2, secret: "second" }); + assert.notEqual(statSync(config).ino, firstInode); + assert.equal(mode(config), 0o600); + assert.deepEqual(await readPrivateJson(config), { generation: 2, secret: "second" }); + assert.deepEqual(readdirSync(dir), ["agent-config.json"]); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("private JSON reads repair permissive modes", async () => { + const root = mkdtempSync(join(tmpdir(), "murmur-private-mode-")); + const dir = join(root, ".data"); + const config = join(dir, "agent-config.json"); + + try { + await writePrivateJson(config, { secret: true }); + chmodSync(dir, 0o775); + chmodSync(config, 0o664); + + assert.deepEqual(await readPrivateJson(config), { secret: true }); + assert.equal(mode(dir), 0o775, "read does not unexpectedly rewrite an arbitrary parent mode"); + assert.equal(mode(config), 0o600); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("private JSON writes reject a symlink target without changing its destination", async () => { + const root = mkdtempSync(join(tmpdir(), "murmur-private-symlink-")); + const dir = join(root, ".data"); + const destination = join(root, "outside.json"); + const config = join(dir, "agent-config.json"); + + try { + await writePrivateJson(join(dir, "seed.json"), { seed: true }); + writeFileSync(destination, "outside", { mode: 0o600 }); + symlinkSync(destination, config); + + await assert.rejects( + writePrivateJson(config, { secret: "must-not-write" }), + /private-state-file-invalid/, + ); + assert.equal(readFileSync(destination, "utf8"), "outside"); + } finally { + rmSync(root, { recursive: true, force: true }); + } +});