Skip to content

Commit 4b1cad6

Browse files
feat: add API keys and encrypted secrets vault to Mamoru
API Keys (MamoruApiKeys): generate mk_-prefixed keys with SHA-256 hashing, timing-safe validation, revocation, rotation, expiry cleanup. Keys stored as Cortex triples — plaintext shown once at creation only. Secrets Vault (MamoruVault): AES-256-GCM encryption with scrypt key derivation, versioned secrets with rollback, scope-based access (global/venture/agent), exportEnv for process injection. Master key from MAYROS_VAULT_KEY env or user input. Closes the last 2 security gaps vs Paperclip: - agent_api_keys → MamoruApiKeys (with timing-safe comparison) - company_secrets → MamoruVault (with AES-256-GCM + scrypt) 6 new gateway methods. 22 new tests (65 total Mamoru).
1 parent 857f26d commit 4b1cad6

5 files changed

Lines changed: 1061 additions & 1 deletion

File tree

extensions/mamoru/api-keys.test.ts

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
import { describe, it, expect, vi, beforeEach } from "vitest";
2+
import { MamoruApiKeys } from "./api-keys.js";
3+
import type { CortexClientLike } from "../shared/cortex-client.js";
4+
5+
// ── Mock Cortex client ───────────────────────────────────────────────
6+
7+
function createMockClient(): CortexClientLike & {
8+
_triples: Array<{ id: string; subject: string; predicate: string; object: string }>;
9+
} {
10+
const triples: Array<{ id: string; subject: string; predicate: string; object: string }> = [];
11+
let nextId = 1;
12+
13+
return {
14+
_triples: triples,
15+
16+
createTriple: vi.fn(async (req) => {
17+
const id = `t-${nextId++}`;
18+
const triple = { id, subject: req.subject, predicate: req.predicate, object: String(req.object) };
19+
triples.push(triple);
20+
return triple;
21+
}),
22+
23+
listTriples: vi.fn(async (query) => {
24+
let filtered = [...triples];
25+
if (query?.subject) filtered = filtered.filter((t) => t.subject === query.subject);
26+
if (query?.predicate) filtered = filtered.filter((t) => t.predicate === query.predicate);
27+
return { triples: filtered, total: filtered.length };
28+
}),
29+
30+
patternQuery: vi.fn(async () => ({ matches: [], total: 0 })),
31+
32+
deleteTriple: vi.fn(async (id) => {
33+
const idx = triples.findIndex((t) => t.id === id);
34+
if (idx >= 0) triples.splice(idx, 1);
35+
}),
36+
};
37+
}
38+
39+
describe("MamoruApiKeys", () => {
40+
let client: ReturnType<typeof createMockClient>;
41+
let keys: MamoruApiKeys;
42+
43+
beforeEach(() => {
44+
client = createMockClient();
45+
keys = new MamoruApiKeys(client, "test");
46+
});
47+
48+
// 1
49+
it("create generates key with mk_ prefix and returns plaintext once", async () => {
50+
const result = await keys.create("agent-scanner", "CI Pipeline Key");
51+
expect(result.plaintext).toMatch(/^mk_/);
52+
expect(result.plaintext.length).toBeGreaterThanOrEqual(40);
53+
expect(result.key.prefix).toBe(result.plaintext.slice(0, 11));
54+
expect(result.key.agentId).toBe("agent-scanner");
55+
expect(result.key.name).toBe("CI Pipeline Key");
56+
});
57+
58+
// 2
59+
it("create stores hash not plaintext in Cortex", async () => {
60+
const result = await keys.create("agent-scanner", "Test Key");
61+
const storedObjects = client._triples.map((t) => t.object);
62+
// The plaintext should NOT appear in any stored triple
63+
expect(storedObjects).not.toContain(result.plaintext);
64+
// The hash should appear
65+
expect(storedObjects).toContain(result.key.keyHash);
66+
expect(result.key.keyHash).toMatch(/^sha256:/);
67+
});
68+
69+
// 3
70+
it("validate accepts correct key", async () => {
71+
const result = await keys.create("agent-a", "Key A");
72+
const validation = await keys.validate(result.plaintext);
73+
expect(validation.valid).toBe(true);
74+
expect(validation.key?.agentId).toBe("agent-a");
75+
});
76+
77+
// 4
78+
it("validate rejects wrong key", async () => {
79+
await keys.create("agent-a", "Key A");
80+
const validation = await keys.validate("mk_completely_wrong_key_value_here_abcdefghij");
81+
expect(validation.valid).toBe(false);
82+
expect(validation.key).toBeUndefined();
83+
});
84+
85+
// 5
86+
it("validate uses timing-safe comparison (buffers same length)", async () => {
87+
const result = await keys.create("agent-a", "Key A");
88+
// The validate method uses timingSafeEqual which requires equal-length buffers.
89+
// This test verifies it doesn't throw on mismatched keys.
90+
const validation = await keys.validate(result.plaintext);
91+
expect(validation.valid).toBe(true);
92+
93+
const bad = await keys.validate("mk_" + "x".repeat(result.plaintext.length - 3));
94+
expect(bad.valid).toBe(false);
95+
});
96+
97+
// 6
98+
it("revoke marks key as revoked and validate rejects it", async () => {
99+
const result = await keys.create("agent-a", "Key A");
100+
await keys.revoke(result.key.id);
101+
102+
const validation = await keys.validate(result.plaintext);
103+
expect(validation.valid).toBe(false);
104+
});
105+
106+
// 7
107+
it("list returns keys without plaintext", async () => {
108+
await keys.create("agent-b", "Key 1");
109+
await keys.create("agent-b", "Key 2");
110+
await keys.create("agent-c", "Key 3"); // different agent
111+
112+
const agentBKeys = await keys.list("agent-b");
113+
expect(agentBKeys).toHaveLength(2);
114+
for (const key of agentBKeys) {
115+
expect(key.agentId).toBe("agent-b");
116+
// Ensure no plaintext property
117+
expect((key as unknown as Record<string, unknown>).plaintext).toBeUndefined();
118+
}
119+
});
120+
121+
// 8
122+
it("rotate revokes old key and creates new with same scopes", async () => {
123+
const original = await keys.create("agent-a", "Key A", { scopes: ["read", "execute"] });
124+
const rotated = await keys.rotate(original.key.id);
125+
126+
// New key has same scopes
127+
expect(rotated.key.scopes).toEqual(["read", "execute"]);
128+
// New key has different plaintext
129+
expect(rotated.plaintext).not.toBe(original.plaintext);
130+
// Old key is revoked
131+
const oldValidation = await keys.validate(original.plaintext);
132+
expect(oldValidation.valid).toBe(false);
133+
// New key works
134+
const newValidation = await keys.validate(rotated.plaintext);
135+
expect(newValidation.valid).toBe(true);
136+
});
137+
138+
// 9
139+
it("cleanup removes expired keys", async () => {
140+
// Create a key that expired in the past
141+
const result = await keys.create("agent-a", "Expired Key", { expiresInDays: -1 });
142+
expect(result.key.expiresAt).toBeTruthy();
143+
144+
const cleaned = await keys.cleanup();
145+
expect(cleaned).toBe(1);
146+
});
147+
148+
// 10
149+
it("validate rejects expired keys", async () => {
150+
const result = await keys.create("agent-a", "Short-lived", { expiresInDays: -1 });
151+
const validation = await keys.validate(result.plaintext);
152+
expect(validation.valid).toBe(false);
153+
});
154+
155+
// 11
156+
it("create respects custom scopes", async () => {
157+
const result = await keys.create("agent-a", "Read Only", { scopes: ["read"] });
158+
expect(result.key.scopes).toEqual(["read"]);
159+
});
160+
161+
// 12
162+
it("rotate throws for nonexistent key", async () => {
163+
await expect(keys.rotate("nonexistent")).rejects.toThrow("not found");
164+
});
165+
});

0 commit comments

Comments
 (0)