Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 30 additions & 2 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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<boolean> {
Expand Down Expand Up @@ -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<LocalMessageRecord, "id">): Promise<LocalMessageRecord> {
Expand Down Expand Up @@ -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 {
Expand Down
41 changes: 39 additions & 2 deletions packages/mcp-server/src/index.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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
}
Expand Down
12 changes: 5 additions & 7 deletions scripts/agent-config-init.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 });

Expand All @@ -25,17 +25,16 @@ 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") {
console.log("[init] Aborted.");
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");
Expand All @@ -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("");
Expand Down
12 changes: 5 additions & 7 deletions scripts/demo-secure-common.mjs
Original file line number Diff line number Diff line change
@@ -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 };
Expand Down Expand Up @@ -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();
Expand All @@ -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;
}
};
9 changes: 5 additions & 4 deletions scripts/murmur-add-peer.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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:")) {
Expand All @@ -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);
}
Expand All @@ -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("");
Expand Down
7 changes: 5 additions & 2 deletions scripts/murmur-daemon.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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));
Expand All @@ -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");
Expand Down
7 changes: 4 additions & 3 deletions scripts/murmur-invite.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
12 changes: 6 additions & 6 deletions scripts/murmur-join.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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:")) {
Expand Down Expand Up @@ -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) {
Expand All @@ -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}`);
}

Expand All @@ -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
Expand Down
7 changes: 5 additions & 2 deletions scripts/murmur-mcp-channel-server.mjs
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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, "..");
Expand Down Expand Up @@ -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");
Expand Down
6 changes: 3 additions & 3 deletions scripts/murmur-notify-init.mjs
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
#!/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 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);
Expand Down Expand Up @@ -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}`);
};

Expand Down
Loading