Skip to content
Merged
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
6 changes: 3 additions & 3 deletions claims.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@
"networkMillis": 300
},
"tests": {
"count": 1505,
"assertions": 7218,
"files": 236
"count": 1507,
"assertions": 7226,
"files": 237
}
}
6 changes: 6 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -403,6 +405,10 @@ export async function main(argv: string[]): Promise<number> {
// 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":
Expand Down
4 changes: 2 additions & 2 deletions src/cli/branch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
mergeBranchOverlay,
} from "../store/branch.ts";

export async function handleBranchCommand({ positional, flags }: ParsedArgs): Promise<number> {
export async function handleOverlayCommand({ positional, flags }: ParsedArgs): Promise<number> {
const ctx = requireRoot(flags);
if (!ctx) return 1;
const sub = positional[0] ?? "show";
Expand Down Expand Up @@ -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)`);
}
}
49 changes: 49 additions & 0 deletions src/cli/enclave.ts
Original file line number Diff line number Diff line change
@@ -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<number> {
const ctx = requireRoot(flags);
if (!ctx) return 1;
const sub = positional[0];
const client = positional[1];
if (!sub || !client) return usageError("enclave requires <create|list|put> <client>");
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 <content>");
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)`);
}
}
114 changes: 114 additions & 0 deletions src/store/enclaves.ts
Original file line number Diff line number Diff line change
@@ -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;
}
49 changes: 49 additions & 0 deletions test/enclaves.test.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
});
});
Loading