@mcp-audit-gateway/core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/npm-publish.yml +21 -0
- package/CONTRIBUTING.md +41 -0
- package/LICENSE +21 -0
- package/README.md +156 -0
- package/dist/attestation/audit-log.d.ts +25 -0
- package/dist/attestation/audit-log.d.ts.map +1 -0
- package/dist/attestation/audit-log.js +95 -0
- package/dist/attestation/audit-log.js.map +1 -0
- package/dist/attestation/bugfix.test.d.ts +2 -0
- package/dist/attestation/bugfix.test.d.ts.map +1 -0
- package/dist/attestation/bugfix.test.js +103 -0
- package/dist/attestation/bugfix.test.js.map +1 -0
- package/dist/attestation/chain.test.d.ts +2 -0
- package/dist/attestation/chain.test.d.ts.map +1 -0
- package/dist/attestation/chain.test.js +153 -0
- package/dist/attestation/chain.test.js.map +1 -0
- package/dist/attestation/keygen.d.ts +5 -0
- package/dist/attestation/keygen.d.ts.map +1 -0
- package/dist/attestation/keygen.js +12 -0
- package/dist/attestation/keygen.js.map +1 -0
- package/dist/attestation/signer.d.ts +25 -0
- package/dist/attestation/signer.d.ts.map +1 -0
- package/dist/attestation/signer.js +93 -0
- package/dist/attestation/signer.js.map +1 -0
- package/dist/attestation/signer.test.d.ts +2 -0
- package/dist/attestation/signer.test.d.ts.map +1 -0
- package/dist/attestation/signer.test.js +62 -0
- package/dist/attestation/signer.test.js.map +1 -0
- package/dist/attestation/verify.d.ts +26 -0
- package/dist/attestation/verify.d.ts.map +1 -0
- package/dist/attestation/verify.js +104 -0
- package/dist/attestation/verify.js.map +1 -0
- package/dist/attestation/verify.test.d.ts +2 -0
- package/dist/attestation/verify.test.d.ts.map +1 -0
- package/dist/attestation/verify.test.js +79 -0
- package/dist/attestation/verify.test.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +364 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/dist/integration.test.d.ts +2 -0
- package/dist/integration.test.d.ts.map +1 -0
- package/dist/integration.test.js +88 -0
- package/dist/integration.test.js.map +1 -0
- package/dist/policy/engine.d.ts +24 -0
- package/dist/policy/engine.d.ts.map +1 -0
- package/dist/policy/engine.js +130 -0
- package/dist/policy/engine.js.map +1 -0
- package/dist/policy/engine.test.d.ts +2 -0
- package/dist/policy/engine.test.d.ts.map +1 -0
- package/dist/policy/engine.test.js +80 -0
- package/dist/policy/engine.test.js.map +1 -0
- package/dist/proxy/gateway.d.ts +54 -0
- package/dist/proxy/gateway.d.ts.map +1 -0
- package/dist/proxy/gateway.js +245 -0
- package/dist/proxy/gateway.js.map +1 -0
- package/dist/proxy/gateway.test.d.ts +2 -0
- package/dist/proxy/gateway.test.d.ts.map +1 -0
- package/dist/proxy/gateway.test.js +93 -0
- package/dist/proxy/gateway.test.js.map +1 -0
- package/dist/proxy/mcp-server-adapter.d.ts +21 -0
- package/dist/proxy/mcp-server-adapter.d.ts.map +1 -0
- package/dist/proxy/mcp-server-adapter.js +98 -0
- package/dist/proxy/mcp-server-adapter.js.map +1 -0
- package/dist/proxy/mcp-server-adapter.test.d.ts +2 -0
- package/dist/proxy/mcp-server-adapter.test.d.ts.map +1 -0
- package/dist/proxy/mcp-server-adapter.test.js +160 -0
- package/dist/proxy/mcp-server-adapter.test.js.map +1 -0
- package/dist/proxy/upstream-manager.d.ts +49 -0
- package/dist/proxy/upstream-manager.d.ts.map +1 -0
- package/dist/proxy/upstream-manager.js +246 -0
- package/dist/proxy/upstream-manager.js.map +1 -0
- package/dist/proxy/upstream-manager.test.d.ts +2 -0
- package/dist/proxy/upstream-manager.test.d.ts.map +1 -0
- package/dist/proxy/upstream-manager.test.js +413 -0
- package/dist/proxy/upstream-manager.test.js.map +1 -0
- package/dist/telemetry/logger.d.ts +26 -0
- package/dist/telemetry/logger.d.ts.map +1 -0
- package/dist/telemetry/logger.js +48 -0
- package/dist/telemetry/logger.js.map +1 -0
- package/dist/telemetry/logger.test.d.ts +2 -0
- package/dist/telemetry/logger.test.d.ts.map +1 -0
- package/dist/telemetry/logger.test.js +124 -0
- package/dist/telemetry/logger.test.js.map +1 -0
- package/dist/telemetry/metrics.d.ts +41 -0
- package/dist/telemetry/metrics.d.ts.map +1 -0
- package/dist/telemetry/metrics.js +64 -0
- package/dist/telemetry/metrics.js.map +1 -0
- package/dist/telemetry/metrics.test.d.ts +2 -0
- package/dist/telemetry/metrics.test.d.ts.map +1 -0
- package/dist/telemetry/metrics.test.js +93 -0
- package/dist/telemetry/metrics.test.js.map +1 -0
- package/dist/telemetry/tracer.d.ts +16 -0
- package/dist/telemetry/tracer.d.ts.map +1 -0
- package/dist/telemetry/tracer.js +71 -0
- package/dist/telemetry/tracer.js.map +1 -0
- package/dist/types.d.ts +477 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +68 -0
- package/dist/types.js.map +1 -0
- package/dist/wrap/proxy.d.ts +2 -0
- package/dist/wrap/proxy.d.ts.map +1 -0
- package/dist/wrap/proxy.js +152 -0
- package/dist/wrap/proxy.js.map +1 -0
- package/dist/wrap/proxy.test.d.ts +2 -0
- package/dist/wrap/proxy.test.d.ts.map +1 -0
- package/dist/wrap/proxy.test.js +155 -0
- package/dist/wrap/proxy.test.js.map +1 -0
- package/examples/README.md +33 -0
- package/examples/claude-desktop.json +50 -0
- package/examples/multi-agent-production.json +72 -0
- package/gateway.config.example.json +61 -0
- package/package.json +49 -0
- package/src/attestation/audit-log.ts +126 -0
- package/src/attestation/bugfix.test.ts +113 -0
- package/src/attestation/chain.test.ts +188 -0
- package/src/attestation/keygen.ts +18 -0
- package/src/attestation/signer.test.ts +75 -0
- package/src/attestation/signer.ts +105 -0
- package/src/attestation/verify.test.ts +86 -0
- package/src/attestation/verify.ts +131 -0
- package/src/cli.ts +407 -0
- package/src/index.ts +21 -0
- package/src/integration.test.ts +108 -0
- package/src/policy/engine.test.ts +95 -0
- package/src/policy/engine.ts +162 -0
- package/src/proxy/gateway.test.ts +108 -0
- package/src/proxy/gateway.ts +328 -0
- package/src/proxy/mcp-server-adapter.test.ts +197 -0
- package/src/proxy/mcp-server-adapter.ts +140 -0
- package/src/proxy/upstream-manager.test.ts +534 -0
- package/src/proxy/upstream-manager.ts +326 -0
- package/src/telemetry/logger.test.ts +139 -0
- package/src/telemetry/logger.ts +72 -0
- package/src/telemetry/metrics.test.ts +125 -0
- package/src/telemetry/metrics.ts +83 -0
- package/src/telemetry/tracer.ts +90 -0
- package/src/types.ts +113 -0
- package/src/wrap/proxy.test.ts +175 -0
- package/src/wrap/proxy.ts +192 -0
- package/tsconfig.json +18 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { appendFile, stat, rename, open } from "node:fs/promises";
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
+
import type { AuditRecord } from "../types.js";
|
|
4
|
+
import type { Signer } from "./signer.js";
|
|
5
|
+
|
|
6
|
+
export function hashRecord(record: AuditRecord): string {
|
|
7
|
+
const json = JSON.stringify(record);
|
|
8
|
+
return createHash("sha256").update(json).digest("hex");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export class AuditLog {
|
|
12
|
+
private currentSize = 0;
|
|
13
|
+
private lastHash: string = "genesis";
|
|
14
|
+
private writeQueue: Promise<void> = Promise.resolve();
|
|
15
|
+
|
|
16
|
+
constructor(
|
|
17
|
+
private path: string,
|
|
18
|
+
private signer: Signer,
|
|
19
|
+
private rotateAfterBytes: number,
|
|
20
|
+
) {}
|
|
21
|
+
|
|
22
|
+
async record(
|
|
23
|
+
method: string,
|
|
24
|
+
opts: {
|
|
25
|
+
toolName?: string;
|
|
26
|
+
namespace?: string;
|
|
27
|
+
upstream?: string;
|
|
28
|
+
principal?: string;
|
|
29
|
+
durationMs: number;
|
|
30
|
+
success: boolean;
|
|
31
|
+
errorCode?: number;
|
|
32
|
+
},
|
|
33
|
+
): Promise<AuditRecord> {
|
|
34
|
+
return new Promise((resolve, reject) => {
|
|
35
|
+
this.writeQueue = this.writeQueue.then(async () => {
|
|
36
|
+
try {
|
|
37
|
+
const result = await this.writeRecord(method, opts);
|
|
38
|
+
resolve(result);
|
|
39
|
+
} catch (err) {
|
|
40
|
+
reject(err);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
private async writeRecord(
|
|
47
|
+
method: string,
|
|
48
|
+
opts: {
|
|
49
|
+
toolName?: string;
|
|
50
|
+
namespace?: string;
|
|
51
|
+
upstream?: string;
|
|
52
|
+
principal?: string;
|
|
53
|
+
durationMs: number;
|
|
54
|
+
success: boolean;
|
|
55
|
+
errorCode?: number;
|
|
56
|
+
},
|
|
57
|
+
): Promise<AuditRecord> {
|
|
58
|
+
const record: AuditRecord = {
|
|
59
|
+
id: randomUUID(),
|
|
60
|
+
timestamp: new Date().toISOString(),
|
|
61
|
+
method,
|
|
62
|
+
...opts,
|
|
63
|
+
previousHash: this.lastHash,
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
record.attestation = await this.signer.sign(record);
|
|
67
|
+
const line = JSON.stringify(record) + "\n";
|
|
68
|
+
|
|
69
|
+
await appendFile(this.path, line);
|
|
70
|
+
this.currentSize += Buffer.byteLength(line);
|
|
71
|
+
|
|
72
|
+
this.lastHash = hashRecord(record);
|
|
73
|
+
|
|
74
|
+
if (this.currentSize >= this.rotateAfterBytes) {
|
|
75
|
+
await this.rotate();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return record;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
private async rotate(): Promise<void> {
|
|
82
|
+
const ts = new Date().toISOString().replace(/[:.]/g, "-");
|
|
83
|
+
const rotatedPath = this.path.replace(/\.jsonl$/, `-${ts}.jsonl`);
|
|
84
|
+
try {
|
|
85
|
+
await rename(this.path, rotatedPath);
|
|
86
|
+
this.currentSize = 0;
|
|
87
|
+
this.lastHash = "genesis";
|
|
88
|
+
} catch {}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async init(): Promise<void> {
|
|
92
|
+
let s;
|
|
93
|
+
try {
|
|
94
|
+
s = await stat(this.path);
|
|
95
|
+
} catch {
|
|
96
|
+
this.currentSize = 0;
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
this.currentSize = s.size;
|
|
101
|
+
|
|
102
|
+
if (s.size > 0) {
|
|
103
|
+
const tailSize = Math.min(s.size, 8192);
|
|
104
|
+
const fh = await open(this.path, "r");
|
|
105
|
+
try {
|
|
106
|
+
const buf = Buffer.alloc(tailSize);
|
|
107
|
+
await fh.read(buf, 0, tailSize, s.size - tailSize);
|
|
108
|
+
const tail = buf.toString("utf-8");
|
|
109
|
+
const lines = tail.trimEnd().split("\n");
|
|
110
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
111
|
+
try {
|
|
112
|
+
const record: AuditRecord = JSON.parse(lines[i]);
|
|
113
|
+
this.lastHash = hashRecord(record);
|
|
114
|
+
return;
|
|
115
|
+
} catch {
|
|
116
|
+
process.stderr.write(
|
|
117
|
+
`[audit-log] warning: corrupt record in tail, skipping\n`,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
} finally {
|
|
122
|
+
await fh.close();
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { writeFile, unlink, readFile } from "node:fs/promises";
|
|
3
|
+
import { HmacSigner } from "./signer.js";
|
|
4
|
+
import { AuditLog, hashRecord } from "./audit-log.js";
|
|
5
|
+
import { verifyAuditLog } from "./verify.js";
|
|
6
|
+
import type { AuditRecord } from "../types.js";
|
|
7
|
+
|
|
8
|
+
const TEST_LOG = "/tmp/bugfix-audit.jsonl";
|
|
9
|
+
const SECRET = "d".repeat(64);
|
|
10
|
+
|
|
11
|
+
describe("Bug fix: pipe injection in canonicalization", () => {
|
|
12
|
+
const signer = new HmacSigner(SECRET);
|
|
13
|
+
|
|
14
|
+
it("different records with pipe chars produce different signatures", async () => {
|
|
15
|
+
const record1: AuditRecord = {
|
|
16
|
+
id: "uuid-1",
|
|
17
|
+
timestamp: "2026-08-16T10:00:00Z",
|
|
18
|
+
method: "tools/call",
|
|
19
|
+
toolName: "db/read|admin",
|
|
20
|
+
namespace: "db",
|
|
21
|
+
upstream: "evil",
|
|
22
|
+
principal: "alice",
|
|
23
|
+
durationMs: 50,
|
|
24
|
+
success: true,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const record2: AuditRecord = {
|
|
28
|
+
id: "uuid-1",
|
|
29
|
+
timestamp: "2026-08-16T10:00:00Z",
|
|
30
|
+
method: "tools/call",
|
|
31
|
+
toolName: "db/read",
|
|
32
|
+
namespace: "admin",
|
|
33
|
+
upstream: "db",
|
|
34
|
+
principal: "evil|alice",
|
|
35
|
+
durationMs: 50,
|
|
36
|
+
success: true,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const sig1 = await signer.sign(record1);
|
|
40
|
+
const sig2 = await signer.sign(record2);
|
|
41
|
+
expect(sig1).not.toBe(sig2);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("Bug fix: hash chain survives restart", () => {
|
|
46
|
+
const signer = new HmacSigner(SECRET);
|
|
47
|
+
|
|
48
|
+
beforeEach(async () => { try { await unlink(TEST_LOG); } catch {} });
|
|
49
|
+
afterEach(async () => { try { await unlink(TEST_LOG); } catch {} });
|
|
50
|
+
|
|
51
|
+
it("restores lastHash from existing log on init", async () => {
|
|
52
|
+
const log1 = new AuditLog(TEST_LOG, signer, 100 * 1024 * 1024);
|
|
53
|
+
await log1.init();
|
|
54
|
+
await log1.record("tools/call", { toolName: "test/a", durationMs: 10, success: true });
|
|
55
|
+
await log1.record("tools/call", { toolName: "test/b", durationMs: 10, success: true });
|
|
56
|
+
|
|
57
|
+
const log2 = new AuditLog(TEST_LOG, signer, 100 * 1024 * 1024);
|
|
58
|
+
await log2.init();
|
|
59
|
+
await log2.record("tools/call", { toolName: "test/c", durationMs: 10, success: true });
|
|
60
|
+
|
|
61
|
+
const result = await verifyAuditLog(TEST_LOG, signer, { verifyChain: true });
|
|
62
|
+
expect(result.total).toBe(3);
|
|
63
|
+
expect(result.valid).toBe(3);
|
|
64
|
+
expect(result.invalid).toBe(0);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe("Bug fix: concurrent writes maintain chain integrity", () => {
|
|
69
|
+
const signer = new HmacSigner(SECRET);
|
|
70
|
+
|
|
71
|
+
beforeEach(async () => { try { await unlink(TEST_LOG); } catch {} });
|
|
72
|
+
afterEach(async () => { try { await unlink(TEST_LOG); } catch {} });
|
|
73
|
+
|
|
74
|
+
it("serializes concurrent writes correctly", async () => {
|
|
75
|
+
const log = new AuditLog(TEST_LOG, signer, 100 * 1024 * 1024);
|
|
76
|
+
await log.init();
|
|
77
|
+
|
|
78
|
+
await log.record("tools/call", { toolName: "test/setup", durationMs: 10, success: true });
|
|
79
|
+
|
|
80
|
+
const concurrent = Array.from({ length: 10 }, (_, i) =>
|
|
81
|
+
log.record("tools/call", { toolName: `test/tool_${i}`, durationMs: i, success: true })
|
|
82
|
+
);
|
|
83
|
+
await Promise.all(concurrent);
|
|
84
|
+
|
|
85
|
+
const content = await readFile(TEST_LOG, "utf-8");
|
|
86
|
+
const records: AuditRecord[] = content.trimEnd().split("\n").map((l) => JSON.parse(l));
|
|
87
|
+
expect(records).toHaveLength(11);
|
|
88
|
+
|
|
89
|
+
const previousHashes = records.map((r) => r.previousHash);
|
|
90
|
+
const unique = new Set(previousHashes);
|
|
91
|
+
expect(unique.size).toBe(11);
|
|
92
|
+
|
|
93
|
+
for (let i = 1; i < records.length; i++) {
|
|
94
|
+
const expected = hashRecord(records[i - 1]);
|
|
95
|
+
expect(records[i].previousHash).toBe(expected);
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("Bug fix: glob matcher prefix/suffix overlap", () => {
|
|
101
|
+
it("does not match when prefix and suffix overlap", async () => {
|
|
102
|
+
const { PolicyEngine } = await import("../policy/engine.js");
|
|
103
|
+
const engine = new PolicyEngine("allow", []);
|
|
104
|
+
const match = (engine as any).globMatch.bind(engine);
|
|
105
|
+
|
|
106
|
+
expect(match("ab*ab", "ab")).toBe(false);
|
|
107
|
+
expect(match("admin*admin", "admin")).toBe(false);
|
|
108
|
+
expect(match("ab*ab", "abab")).toBe(true);
|
|
109
|
+
expect(match("ab*ab", "abXab")).toBe(true);
|
|
110
|
+
expect(match("*/write_*", "fs/write_file")).toBe(true);
|
|
111
|
+
expect(match("agent:readonly-*", "agent:readonly-bot")).toBe(true);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { unlink, readFile } from "node:fs/promises";
|
|
3
|
+
import { HmacSigner } from "./signer.js";
|
|
4
|
+
import { AuditLog, hashRecord } from "./audit-log.js";
|
|
5
|
+
import { verifyChain } from "./verify.js";
|
|
6
|
+
import type { AuditRecord } from "../types.js";
|
|
7
|
+
|
|
8
|
+
const TEST_LOG = "/tmp/test-chain-audit.jsonl";
|
|
9
|
+
const SECRET = "c".repeat(64);
|
|
10
|
+
|
|
11
|
+
async function readRecords(path: string): Promise<AuditRecord[]> {
|
|
12
|
+
const content = await readFile(path, "utf-8");
|
|
13
|
+
return content
|
|
14
|
+
.split("\n")
|
|
15
|
+
.filter((line) => line.trim().length > 0)
|
|
16
|
+
.map((line) => JSON.parse(line));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe("hash chain integrity", () => {
|
|
20
|
+
const signer = new HmacSigner(SECRET);
|
|
21
|
+
|
|
22
|
+
beforeEach(async () => {
|
|
23
|
+
try {
|
|
24
|
+
await unlink(TEST_LOG);
|
|
25
|
+
} catch {}
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
afterEach(async () => {
|
|
29
|
+
try {
|
|
30
|
+
await unlink(TEST_LOG);
|
|
31
|
+
} catch {}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("valid chain passes verification", async () => {
|
|
35
|
+
const log = new AuditLog(TEST_LOG, signer, 100 * 1024 * 1024);
|
|
36
|
+
await log.init();
|
|
37
|
+
|
|
38
|
+
await log.record("tools/call", {
|
|
39
|
+
toolName: "test/tool_a",
|
|
40
|
+
namespace: "test",
|
|
41
|
+
upstream: "test-server",
|
|
42
|
+
principal: "agent:bot",
|
|
43
|
+
durationMs: 100,
|
|
44
|
+
success: true,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
await log.record("tools/call", {
|
|
48
|
+
toolName: "test/tool_b",
|
|
49
|
+
namespace: "test",
|
|
50
|
+
upstream: "test-server",
|
|
51
|
+
principal: "agent:bot",
|
|
52
|
+
durationMs: 50,
|
|
53
|
+
success: true,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
await log.record("tools/call", {
|
|
57
|
+
toolName: "test/tool_c",
|
|
58
|
+
namespace: "test",
|
|
59
|
+
upstream: "test-server",
|
|
60
|
+
principal: "agent:bot",
|
|
61
|
+
durationMs: 75,
|
|
62
|
+
success: true,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const records = await readRecords(TEST_LOG);
|
|
66
|
+
const result = await verifyChain(records);
|
|
67
|
+
|
|
68
|
+
expect(result.valid).toBe(true);
|
|
69
|
+
expect(result.total).toBe(3);
|
|
70
|
+
expect(result.errors).toHaveLength(0);
|
|
71
|
+
|
|
72
|
+
// Verify first record has genesis
|
|
73
|
+
expect(records[0].previousHash).toBe("genesis");
|
|
74
|
+
|
|
75
|
+
// Verify second record references hash of first
|
|
76
|
+
expect(records[1].previousHash).toBe(hashRecord(records[0]));
|
|
77
|
+
|
|
78
|
+
// Verify third record references hash of second
|
|
79
|
+
expect(records[2].previousHash).toBe(hashRecord(records[1]));
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("detects deleted record", async () => {
|
|
83
|
+
const log = new AuditLog(TEST_LOG, signer, 100 * 1024 * 1024);
|
|
84
|
+
await log.init();
|
|
85
|
+
|
|
86
|
+
await log.record("tools/call", {
|
|
87
|
+
toolName: "test/tool_a",
|
|
88
|
+
durationMs: 100,
|
|
89
|
+
success: true,
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
await log.record("tools/call", {
|
|
93
|
+
toolName: "test/tool_b",
|
|
94
|
+
durationMs: 50,
|
|
95
|
+
success: true,
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
await log.record("tools/call", {
|
|
99
|
+
toolName: "test/tool_c",
|
|
100
|
+
durationMs: 75,
|
|
101
|
+
success: true,
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
const records = await readRecords(TEST_LOG);
|
|
105
|
+
// Remove the middle record
|
|
106
|
+
const tampered = [records[0], records[2]];
|
|
107
|
+
|
|
108
|
+
const result = await verifyChain(tampered);
|
|
109
|
+
expect(result.valid).toBe(false);
|
|
110
|
+
expect(result.errors.length).toBeGreaterThan(0);
|
|
111
|
+
expect(result.errors[0].reason).toBe(
|
|
112
|
+
"previousHash does not match hash of prior record",
|
|
113
|
+
);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("detects reordered records", async () => {
|
|
117
|
+
const log = new AuditLog(TEST_LOG, signer, 100 * 1024 * 1024);
|
|
118
|
+
await log.init();
|
|
119
|
+
|
|
120
|
+
await log.record("tools/call", {
|
|
121
|
+
toolName: "test/tool_a",
|
|
122
|
+
durationMs: 100,
|
|
123
|
+
success: true,
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
await log.record("tools/call", {
|
|
127
|
+
toolName: "test/tool_b",
|
|
128
|
+
durationMs: 50,
|
|
129
|
+
success: true,
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
await log.record("tools/call", {
|
|
133
|
+
toolName: "test/tool_c",
|
|
134
|
+
durationMs: 75,
|
|
135
|
+
success: true,
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const records = await readRecords(TEST_LOG);
|
|
139
|
+
// Swap the second and third records
|
|
140
|
+
const reordered = [records[0], records[2], records[1]];
|
|
141
|
+
|
|
142
|
+
const result = await verifyChain(reordered);
|
|
143
|
+
expect(result.valid).toBe(false);
|
|
144
|
+
expect(result.errors.length).toBeGreaterThan(0);
|
|
145
|
+
expect(result.errors[0].reason).toBe(
|
|
146
|
+
"previousHash does not match hash of prior record",
|
|
147
|
+
);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("detects inserted record (previousHash won't match)", async () => {
|
|
151
|
+
const log = new AuditLog(TEST_LOG, signer, 100 * 1024 * 1024);
|
|
152
|
+
await log.init();
|
|
153
|
+
|
|
154
|
+
await log.record("tools/call", {
|
|
155
|
+
toolName: "test/tool_a",
|
|
156
|
+
durationMs: 100,
|
|
157
|
+
success: true,
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
await log.record("tools/call", {
|
|
161
|
+
toolName: "test/tool_b",
|
|
162
|
+
durationMs: 50,
|
|
163
|
+
success: true,
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const records = await readRecords(TEST_LOG);
|
|
167
|
+
|
|
168
|
+
// Insert a fake record between the two
|
|
169
|
+
const inserted: AuditRecord = {
|
|
170
|
+
id: "fake-inserted-id",
|
|
171
|
+
timestamp: new Date().toISOString(),
|
|
172
|
+
method: "tools/call",
|
|
173
|
+
toolName: "test/malicious_tool",
|
|
174
|
+
durationMs: 1,
|
|
175
|
+
success: true,
|
|
176
|
+
previousHash: hashRecord(records[0]),
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const tampered = [records[0], inserted, records[1]];
|
|
180
|
+
|
|
181
|
+
const result = await verifyChain(tampered);
|
|
182
|
+
expect(result.valid).toBe(false);
|
|
183
|
+
// The record after the inserted one will fail because its previousHash
|
|
184
|
+
// was computed against the original record[0], not the inserted record
|
|
185
|
+
expect(result.errors.length).toBeGreaterThan(0);
|
|
186
|
+
expect(result.errors.some((e) => e.reason === "previousHash does not match hash of prior record")).toBe(true);
|
|
187
|
+
});
|
|
188
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { writeFile } from "node:fs/promises";
|
|
2
|
+
|
|
3
|
+
export async function generateKeyPair(outputDir: string): Promise<{
|
|
4
|
+
publicKeyPath: string;
|
|
5
|
+
privateKeyPath: string;
|
|
6
|
+
}> {
|
|
7
|
+
const ed = await import("@noble/ed25519");
|
|
8
|
+
const privateKey = ed.utils.randomPrivateKey();
|
|
9
|
+
const publicKey = await ed.getPublicKeyAsync(privateKey);
|
|
10
|
+
|
|
11
|
+
const privateKeyPath = `${outputDir}/gateway.key`;
|
|
12
|
+
const publicKeyPath = `${outputDir}/gateway.pub`;
|
|
13
|
+
|
|
14
|
+
await writeFile(privateKeyPath, Buffer.from(privateKey), { mode: 0o600 });
|
|
15
|
+
await writeFile(publicKeyPath, Buffer.from(publicKey), { mode: 0o644 });
|
|
16
|
+
|
|
17
|
+
return { publicKeyPath, privateKeyPath };
|
|
18
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { HmacSigner, Ed25519Signer } from "./signer.js";
|
|
3
|
+
import type { AuditRecord } from "../types.js";
|
|
4
|
+
|
|
5
|
+
const mockRecord: AuditRecord = {
|
|
6
|
+
id: "test-uuid-001",
|
|
7
|
+
timestamp: "2026-08-16T10:00:00.000Z",
|
|
8
|
+
method: "tools/call",
|
|
9
|
+
toolName: "github/create_pr",
|
|
10
|
+
namespace: "github",
|
|
11
|
+
upstream: "github-server",
|
|
12
|
+
principal: "agent:dev-bot",
|
|
13
|
+
durationMs: 234,
|
|
14
|
+
success: true,
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
describe("HmacSigner", () => {
|
|
18
|
+
const secret = "a".repeat(64);
|
|
19
|
+
const signer = new HmacSigner(secret);
|
|
20
|
+
|
|
21
|
+
it("produces a hex signature", async () => {
|
|
22
|
+
const sig = await signer.sign(mockRecord);
|
|
23
|
+
expect(sig).toMatch(/^[0-9a-f]{64}$/);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("verifies a valid signature", async () => {
|
|
27
|
+
const sig = await signer.sign(mockRecord);
|
|
28
|
+
const valid = await signer.verify(mockRecord, sig);
|
|
29
|
+
expect(valid).toBe(true);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("rejects a tampered record", async () => {
|
|
33
|
+
const sig = await signer.sign(mockRecord);
|
|
34
|
+
const tampered = { ...mockRecord, durationMs: 999 };
|
|
35
|
+
const valid = await signer.verify(tampered, sig);
|
|
36
|
+
expect(valid).toBe(false);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("rejects a wrong signature", async () => {
|
|
40
|
+
const valid = await signer.verify(mockRecord, "bad".repeat(21) + "b");
|
|
41
|
+
expect(valid).toBe(false);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("Ed25519Signer", () => {
|
|
46
|
+
it("signs and verifies a record", async () => {
|
|
47
|
+
const signer = new Ed25519Signer();
|
|
48
|
+
await signer.init();
|
|
49
|
+
|
|
50
|
+
const sig = await signer.sign(mockRecord);
|
|
51
|
+
expect(sig.length).toBeGreaterThan(100);
|
|
52
|
+
|
|
53
|
+
const valid = await signer.verify(mockRecord, sig);
|
|
54
|
+
expect(valid).toBe(true);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("rejects a tampered record", async () => {
|
|
58
|
+
const signer = new Ed25519Signer();
|
|
59
|
+
await signer.init();
|
|
60
|
+
|
|
61
|
+
const sig = await signer.sign(mockRecord);
|
|
62
|
+
const tampered = { ...mockRecord, success: false };
|
|
63
|
+
const valid = await signer.verify(tampered, sig);
|
|
64
|
+
expect(valid).toBe(false);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("exposes the public key for external verification", async () => {
|
|
68
|
+
const signer = new Ed25519Signer();
|
|
69
|
+
await signer.init();
|
|
70
|
+
|
|
71
|
+
const pubKey = signer.getPublicKey();
|
|
72
|
+
expect(pubKey).not.toBeNull();
|
|
73
|
+
expect(pubKey!.length).toBe(32);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import type { AttestationConfig, AuditRecord } from "../types.js";
|
|
3
|
+
|
|
4
|
+
export interface Signer {
|
|
5
|
+
sign(record: AuditRecord): Promise<string>;
|
|
6
|
+
verify(record: AuditRecord, signature: string): Promise<boolean>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export class HmacSigner implements Signer {
|
|
10
|
+
private secret: Buffer;
|
|
11
|
+
|
|
12
|
+
constructor(secret: string) {
|
|
13
|
+
this.secret = Buffer.from(secret, "hex");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async sign(record: AuditRecord): Promise<string> {
|
|
17
|
+
const payload = this.canonicalize(record);
|
|
18
|
+
const hmac = createHmac("sha256", this.secret);
|
|
19
|
+
hmac.update(payload);
|
|
20
|
+
return hmac.digest("hex");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async verify(record: AuditRecord, signature: string): Promise<boolean> {
|
|
24
|
+
const expected = await this.sign(record);
|
|
25
|
+
const expectedBuf = Buffer.from(expected, "hex");
|
|
26
|
+
const signatureBuf = Buffer.from(signature, "hex");
|
|
27
|
+
if (expectedBuf.length !== signatureBuf.length) return false;
|
|
28
|
+
return timingSafeEqual(expectedBuf, signatureBuf);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
private canonicalize(record: AuditRecord): string {
|
|
32
|
+
return canonicalizeRecord(record);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class Ed25519Signer implements Signer {
|
|
37
|
+
private privateKey: Uint8Array | null = null;
|
|
38
|
+
private publicKey: Uint8Array | null = null;
|
|
39
|
+
|
|
40
|
+
constructor(private keyPath?: string) {}
|
|
41
|
+
|
|
42
|
+
async init(): Promise<void> {
|
|
43
|
+
const ed = await import("@noble/ed25519");
|
|
44
|
+
if (this.keyPath) {
|
|
45
|
+
const { readFile } = await import("node:fs/promises");
|
|
46
|
+
const keyData = await readFile(this.keyPath);
|
|
47
|
+
this.privateKey = new Uint8Array(keyData);
|
|
48
|
+
} else {
|
|
49
|
+
this.privateKey = ed.utils.randomPrivateKey();
|
|
50
|
+
}
|
|
51
|
+
this.publicKey = await ed.getPublicKeyAsync(this.privateKey);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async sign(record: AuditRecord): Promise<string> {
|
|
55
|
+
if (!this.privateKey) await this.init();
|
|
56
|
+
const ed = await import("@noble/ed25519");
|
|
57
|
+
const payload = new TextEncoder().encode(this.canonicalize(record));
|
|
58
|
+
const sig = await ed.signAsync(payload, this.privateKey!);
|
|
59
|
+
return Buffer.from(sig).toString("hex");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async verify(record: AuditRecord, signature: string): Promise<boolean> {
|
|
63
|
+
if (!this.publicKey) await this.init();
|
|
64
|
+
const ed = await import("@noble/ed25519");
|
|
65
|
+
const payload = new TextEncoder().encode(this.canonicalize(record));
|
|
66
|
+
const sig = Buffer.from(signature, "hex");
|
|
67
|
+
return ed.verifyAsync(sig, payload, this.publicKey!);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
getPublicKey(): Uint8Array | null {
|
|
71
|
+
return this.publicKey;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
private canonicalize(record: AuditRecord): string {
|
|
75
|
+
return canonicalizeRecord(record);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function canonicalizeRecord(record: AuditRecord): string {
|
|
80
|
+
const ordered: [string, string | number | boolean | null][] = [
|
|
81
|
+
["id", record.id],
|
|
82
|
+
["timestamp", record.timestamp],
|
|
83
|
+
["method", record.method],
|
|
84
|
+
["toolName", record.toolName ?? null],
|
|
85
|
+
["namespace", record.namespace ?? null],
|
|
86
|
+
["upstream", record.upstream ?? null],
|
|
87
|
+
["principal", record.principal ?? null],
|
|
88
|
+
["durationMs", record.durationMs],
|
|
89
|
+
["success", record.success],
|
|
90
|
+
["errorCode", record.errorCode ?? null],
|
|
91
|
+
["previousHash", record.previousHash ?? null],
|
|
92
|
+
];
|
|
93
|
+
return JSON.stringify(ordered);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function createSigner(config: AttestationConfig): Signer {
|
|
97
|
+
if (!config.enabled) {
|
|
98
|
+
return { sign: async () => "", verify: async () => true };
|
|
99
|
+
}
|
|
100
|
+
if (config.algorithm === "hmac-sha256") {
|
|
101
|
+
const secret = config.secret ?? randomBytes(32).toString("hex");
|
|
102
|
+
return new HmacSigner(secret);
|
|
103
|
+
}
|
|
104
|
+
return new Ed25519Signer(config.keyPath);
|
|
105
|
+
}
|