diff --git a/claims.json b/claims.json index d54a3d4..dc9c5c5 100644 --- a/claims.json +++ b/claims.json @@ -14,8 +14,8 @@ "networkMillis": 300 }, "tests": { - "count": 1505, - "assertions": 7218, - "files": 236 + "count": 1507, + "assertions": 7226, + "files": 237 } } diff --git a/src/cli.ts b/src/cli.ts index 5dd643c..a3cc53b 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -30,6 +30,8 @@ import { handleHarvestAutoCommand, handleReindexCommand, } from "./cli/retrieval.ts"; +import { handleEnclaveCommand } from "./cli/enclave.ts"; +import { handleOverlayCommand } from "./cli/branch.ts"; import { handleProjectCommand } from "./cli/project.ts"; import { handleCurrentCommand, handleUserCommand, handleCoreCommand } from "./cli/persona.ts"; import { handleEmbedCommand } from "./cli/embed.ts"; @@ -403,6 +405,10 @@ export async function main(argv: string[]): Promise { // R23 Versioning Commands case "branch": return handleBranchCommand(parsed); + case "enclave": + return handleEnclaveCommand(parsed); + case "overlay": + return handleOverlayCommand(parsed); case "checkout": return handleCheckoutCommand(parsed); case "blame": diff --git a/src/cli/branch.ts b/src/cli/branch.ts index c98fd1a..bb40032 100644 --- a/src/cli/branch.ts +++ b/src/cli/branch.ts @@ -9,7 +9,7 @@ import { mergeBranchOverlay, } from "../store/branch.ts"; -export async function handleBranchCommand({ positional, flags }: ParsedArgs): Promise { +export async function handleOverlayCommand({ positional, flags }: ParsedArgs): Promise { const ctx = requireRoot(flags); if (!ctx) return 1; const sub = positional[0] ?? "show"; @@ -51,6 +51,6 @@ export async function handleBranchCommand({ positional, flags }: ParsedArgs): Pr return 0; } default: - return usageError(`unknown branch subcommand '${sub}' (expected list | create | show | entries | merge | drop)`); + return usageError(`unknown overlay subcommand '${sub}' (expected list | create | show | entries | merge | drop)`); } } diff --git a/src/cli/enclave.ts b/src/cli/enclave.ts new file mode 100644 index 0000000..76ab5f4 --- /dev/null +++ b/src/cli/enclave.ts @@ -0,0 +1,49 @@ +import { randomUUID } from "node:crypto"; +import { requireRoot, usageError } from "./shared.ts"; +import type { ParsedArgs } from "./shared.ts"; +import { createEnclave, listEnclaveEntries, putEnclaveEntry } from "../store/enclaves.ts"; +import type { MemoryEntry, MemoryType } from "../types.ts"; + +export async function handleEnclaveCommand({ positional, flags }: ParsedArgs): Promise { + const ctx = requireRoot(flags); + if (!ctx) return 1; + const sub = positional[0]; + const client = positional[1]; + if (!sub || !client) return usageError("enclave requires "); + const division = flags["division"] ?? "default"; + + switch (sub) { + case "create": + createEnclave(ctx.memoryDir, client, division); + console.log(`enclave ready: ${client}/${division}`); + return 0; + case "list": { + const entries = listEnclaveEntries(ctx.memoryDir, client, division); + console.log(`${client}/${division}: ${entries.length} entr${entries.length === 1 ? "y" : "ies"}`); + for (const entry of entries) console.log(` ${entry.id} ${entry.type} ${entry.title}`); + return 0; + } + case "put": { + const content = positional[2]; + if (!content) return usageError("enclave put requires "); + const id = `m_${Date.now()}_${randomUUID().slice(0, 8)}`; + const now = new Date().toISOString(); + const entry: MemoryEntry = { + id, + title: content.slice(0, 80), + content, + project: client, + type: (flags["type"] as MemoryType) ?? "discovery", + status: "confirmed", + created_at: now, + updated_at: now, + tags: [], + } as MemoryEntry; + putEnclaveEntry(ctx.memoryDir, client, entry, division); + console.log(`stored ${id} in ${client}/${division}`); + return 0; + } + default: + return usageError(`unknown enclave subcommand '${sub}' (expected create | list | put)`); + } +} diff --git a/src/store/enclaves.ts b/src/store/enclaves.ts new file mode 100644 index 0000000..406b91b --- /dev/null +++ b/src/store/enclaves.ts @@ -0,0 +1,114 @@ +import { createCipheriv, createDecipheriv, createHash, randomBytes, scryptSync } from "node:crypto"; +import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import type { MemoryEntry } from "../types.ts"; + +const VERSION = 1; + +export interface EnclaveEnvelope { + version: number; + client: string; + division: string; + salt: string; + iv: string; + tag: string; + ciphertext: string; +} + +function safeName(value: string): string { + return value.replace(/[^A-Za-z0-9._-]/g, "_"); +} + +export function enclavePath(memoryDir: string, client: string, division: string = "default"): string { + return join(memoryDir, "enclaves", safeName(client), `${safeName(division)}.enc.json`); +} + +function loadMasterKey(memoryDir: string): Buffer { + const env = process.env.MUSE_ENCLAVE_KEY; + if (env) { + const fromEnv = /^[0-9a-fA-F]{64}$/.test(env) ? Buffer.from(env, "hex") : createHash("sha256").update(env).digest(); + return fromEnv; + } + const keyPath = join(memoryDir, "enclave.key"); + if (existsSync(keyPath)) { + const stored = readFileSync(keyPath, "utf8").trim(); + if (!/^[0-9a-fA-F]{64}$/.test(stored)) throw new Error(`Invalid enclave key at ${keyPath}`); + return Buffer.from(stored, "hex"); + } + mkdirSync(memoryDir, { recursive: true }); + const key = randomBytes(32); + writeFileSync(keyPath, key.toString("hex") + "\n", { encoding: "utf8", mode: 0o600 }); + try { chmodSync(keyPath, 0o600); } catch {} + return key; +} + +function deriveKey(master: Buffer, salt: Buffer): Buffer { + return scryptSync(master, salt, 32); +} + +function encrypt(memoryDir: string, client: string, division: string, entries: MemoryEntry[]): EnclaveEnvelope { + const salt = randomBytes(16); + const iv = randomBytes(12); + const key = deriveKey(loadMasterKey(memoryDir), salt); + const cipher = createCipheriv("aes-256-gcm", key, iv); + const ciphertext = Buffer.concat([cipher.update(JSON.stringify(entries), "utf8"), cipher.final()]); + return { + version: VERSION, + client, + division, + salt: salt.toString("base64"), + iv: iv.toString("base64"), + tag: cipher.getAuthTag().toString("base64"), + ciphertext: ciphertext.toString("base64"), + }; +} + +function decrypt(memoryDir: string, envelope: EnclaveEnvelope): MemoryEntry[] { + if (envelope.version !== VERSION) throw new Error(`Unsupported enclave version ${envelope.version}`); + const key = deriveKey(loadMasterKey(memoryDir), Buffer.from(envelope.salt, "base64")); + const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(envelope.iv, "base64")); + decipher.setAuthTag(Buffer.from(envelope.tag, "base64")); + const plaintext = Buffer.concat([decipher.update(Buffer.from(envelope.ciphertext, "base64")), decipher.final()]); + return JSON.parse(plaintext.toString("utf8")) as MemoryEntry[]; +} + +function writeEnvelope(path: string, envelope: EnclaveEnvelope): void { + mkdirSync(join(path, ".."), { recursive: true }); + const tmp = `${path}.${process.pid}.tmp`; + writeFileSync(tmp, JSON.stringify(envelope, null, 2) + "\n", "utf8"); + renameSync(tmp, path); +} + +export function createEnclave(memoryDir: string, client: string, division: string = "default"): void { + const path = enclavePath(memoryDir, client, division); + if (existsSync(path)) return; + writeEnvelope(path, encrypt(memoryDir, client, division, [])); +} + +export function listEnclaveEntries(memoryDir: string, client: string, division: string = "default"): MemoryEntry[] { + const path = enclavePath(memoryDir, client, division); + if (!existsSync(path)) return []; + const envelope = JSON.parse(readFileSync(path, "utf8")) as EnclaveEnvelope; + return decrypt(memoryDir, envelope); +} + +export function putEnclaveEntry( + memoryDir: string, + client: string, + entry: MemoryEntry, + division: string = "default", +): MemoryEntry[] { + const entries = listEnclaveEntries(memoryDir, client, division).filter((candidate) => candidate.id !== entry.id); + entries.push(entry); + writeEnvelope(enclavePath(memoryDir, client, division), encrypt(memoryDir, client, division, entries)); + return entries; +} + +export function getEnclaveEntry( + memoryDir: string, + client: string, + id: string, + division: string = "default", +): MemoryEntry | null { + return listEnclaveEntries(memoryDir, client, division).find((entry) => entry.id === id) ?? null; +} diff --git a/test/enclaves.test.ts b/test/enclaves.test.ts new file mode 100644 index 0000000..4b670f3 --- /dev/null +++ b/test/enclaves.test.ts @@ -0,0 +1,49 @@ +import { describe, test, expect } from "bun:test"; +import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createEnclave, listEnclaveEntries, putEnclaveEntry, getEnclaveEntry } from "../src/store/enclaves.ts"; +import type { MemoryEntry } from "../src/types.ts"; + +function entry(id: string, content: string): MemoryEntry { + const now = new Date().toISOString(); + return { id, title: content, content, project: "client-a", status: "confirmed", created_at: now, updated_at: now, tags: [] } as MemoryEntry; +} + +describe("T-437: encrypted client enclaves", () => { + test("round-trips per client/division and stores no plaintext", () => { + const memoryDir = mkdtempSync(join(tmpdir(), "muse-enclave-")); + try { + createEnclave(memoryDir, "client-a"); + putEnclaveEntry(memoryDir, "client-a", entry("m_1_secret", "super secret credential plan")); + expect(getEnclaveEntry(memoryDir, "client-a", "m_1_secret")?.content).toBe("super secret credential plan"); + + const file = join(memoryDir, "enclaves", "client-a", "default.enc.json"); + expect(readFileSync(file, "utf8")).not.toContain("super secret"); + expect(statSync(join(memoryDir, "enclave.key")).mode & 0o077).toBe(0); + + expect(listEnclaveEntries(memoryDir, "client-b")).toEqual([]); + putEnclaveEntry(memoryDir, "client-b", entry("m_2_other", "client b only")); + expect(listEnclaveEntries(memoryDir, "client-a").map((e) => e.id)).toEqual(["m_1_secret"]); + expect(listEnclaveEntries(memoryDir, "client-b").map((e) => e.id)).toEqual(["m_2_other"]); + } finally { + rmSync(memoryDir, { recursive: true, force: true }); + } + }); + + test("tampered ciphertext fails authentication", () => { + const memoryDir = mkdtempSync(join(tmpdir(), "muse-enclave-tamper-")); + try { + createEnclave(memoryDir, "client-a"); + putEnclaveEntry(memoryDir, "client-a", entry("m_1_secret", "secret")); + const file = join(memoryDir, "enclaves", "client-a", "default.enc.json"); + const envelope = JSON.parse(readFileSync(file, "utf8")); + envelope.ciphertext = Buffer.from("tampered").toString("base64"); + writeFileSync(file, JSON.stringify(envelope)); + expect(() => listEnclaveEntries(memoryDir, "client-a")).toThrow(); + expect(existsSync(file)).toBe(true); + } finally { + rmSync(memoryDir, { recursive: true, force: true }); + } + }); +});