@mcp-audit-gateway/core 0.1.0 → 0.4.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/ci.yml +37 -0
- package/.well-known/agent-governance.json +47 -0
- package/.well-known/security-insights-snippet.yml +9 -0
- package/CHANGELOG.md +32 -0
- package/README.md +31 -3
- package/dist/attestation/audit-log.d.ts +35 -3
- package/dist/attestation/audit-log.d.ts.map +1 -1
- package/dist/attestation/audit-log.js +303 -8
- package/dist/attestation/audit-log.js.map +1 -1
- package/dist/attestation/checkpoint.test.d.ts +2 -0
- package/dist/attestation/checkpoint.test.d.ts.map +1 -0
- package/dist/attestation/checkpoint.test.js +870 -0
- package/dist/attestation/checkpoint.test.js.map +1 -0
- package/dist/attestation/signer.d.ts +24 -9
- package/dist/attestation/signer.d.ts.map +1 -1
- package/dist/attestation/signer.js +151 -10
- package/dist/attestation/signer.js.map +1 -1
- package/dist/attestation/signer.test.js +83 -0
- package/dist/attestation/signer.test.js.map +1 -1
- package/dist/attestation/verify.d.ts +23 -2
- package/dist/attestation/verify.d.ts.map +1 -1
- package/dist/attestation/verify.js +219 -2
- package/dist/attestation/verify.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/integration.test.js +1 -0
- package/dist/integration.test.js.map +1 -1
- package/dist/policy/engine.d.ts +12 -1
- package/dist/policy/engine.d.ts.map +1 -1
- package/dist/policy/engine.js +26 -4
- package/dist/policy/engine.js.map +1 -1
- package/dist/policy/engine.test.js +42 -1
- package/dist/policy/engine.test.js.map +1 -1
- package/dist/proxy/gateway.d.ts +4 -0
- package/dist/proxy/gateway.d.ts.map +1 -1
- package/dist/proxy/gateway.js +9 -2
- package/dist/proxy/gateway.js.map +1 -1
- package/dist/proxy/gateway.test.js +19 -0
- package/dist/proxy/gateway.test.js.map +1 -1
- package/dist/proxy/mcp-server-adapter.d.ts +1 -0
- package/dist/proxy/mcp-server-adapter.d.ts.map +1 -1
- package/dist/proxy/mcp-server-adapter.js +28 -1
- package/dist/proxy/mcp-server-adapter.js.map +1 -1
- package/dist/proxy/mcp-server-adapter.test.js +1 -0
- package/dist/proxy/mcp-server-adapter.test.js.map +1 -1
- package/dist/types.d.ts +81 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +13 -0
- package/dist/types.js.map +1 -1
- package/dist/wrap/proxy.test.js +2 -2
- package/dist/wrap/proxy.test.js.map +1 -1
- package/docs/BACKLOG.md +33 -0
- package/docs/SECURITY-DESIGN.md +126 -0
- package/docs/v0.4.0-patch-audit.md +115 -0
- package/package.json +1 -1
- package/src/attestation/audit-log.ts +357 -13
- package/src/attestation/checkpoint.test.ts +956 -0
- package/src/attestation/signer.test.ts +98 -0
- package/src/attestation/signer.ts +159 -19
- package/src/attestation/verify.ts +270 -4
- package/src/index.ts +1 -1
- package/src/integration.test.ts +1 -0
- package/src/policy/engine.test.ts +47 -1
- package/src/policy/engine.ts +38 -5
- package/src/proxy/gateway.test.ts +18 -0
- package/src/proxy/gateway.ts +10 -1
- package/src/proxy/mcp-server-adapter.test.ts +1 -0
- package/src/proxy/mcp-server-adapter.ts +26 -0
- package/src/types.ts +56 -0
- package/src/wrap/proxy.test.ts +2 -2
- package/test/vectors/README.md +44 -0
- package/test/vectors/aps-action-ref-v1-vectors.json +351 -0
- package/test/vectors/aps-action-ref-v1.mjs +145 -0
- package/test/vectors/canonicalization.json +764 -0
- package/test/vectors/checkpoint.json +450 -0
- package/test/vectors/generate.mjs +354 -0
- package/test/vectors/verify-checkpoint.mjs +344 -0
- package/test/vectors/verify-checkpoint.py +358 -0
- package/test/vectors/verify.mjs +346 -0
- package/test/vectors/verify.py +354 -0
|
@@ -42,6 +42,90 @@ describe("HmacSigner", () => {
|
|
|
42
42
|
});
|
|
43
43
|
});
|
|
44
44
|
|
|
45
|
+
describe("decisionContextDigest in signatures", () => {
|
|
46
|
+
const secret = "b".repeat(64);
|
|
47
|
+
const signer = new HmacSigner(secret);
|
|
48
|
+
|
|
49
|
+
const recordWithDigest: AuditRecord = {
|
|
50
|
+
...mockRecord,
|
|
51
|
+
id: "test-uuid-002",
|
|
52
|
+
decisionContextDigest: "c".repeat(64),
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
it("includes digest in signature when present", async () => {
|
|
56
|
+
const sigWith = await signer.sign(recordWithDigest);
|
|
57
|
+
const sigWithout = await signer.sign(mockRecord);
|
|
58
|
+
expect(sigWith).not.toBe(sigWithout);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("verifies record with digest", async () => {
|
|
62
|
+
const sig = await signer.sign(recordWithDigest);
|
|
63
|
+
const valid = await signer.verify(recordWithDigest, sig);
|
|
64
|
+
expect(valid).toBe(true);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("rejects tampered digest", async () => {
|
|
68
|
+
const sig = await signer.sign(recordWithDigest);
|
|
69
|
+
const tampered = { ...recordWithDigest, decisionContextDigest: "d".repeat(64) };
|
|
70
|
+
const valid = await signer.verify(tampered, sig);
|
|
71
|
+
expect(valid).toBe(false);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("old records without digest still verify", async () => {
|
|
75
|
+
const oldRecord: AuditRecord = { ...mockRecord, id: "old-record-001" };
|
|
76
|
+
const sig = await signer.sign(oldRecord);
|
|
77
|
+
const valid = await signer.verify(oldRecord, sig);
|
|
78
|
+
expect(valid).toBe(true);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe("parties in signatures", () => {
|
|
83
|
+
const secret = "c".repeat(64);
|
|
84
|
+
const signer = new HmacSigner(secret);
|
|
85
|
+
|
|
86
|
+
const recordWithParties: AuditRecord = {
|
|
87
|
+
...mockRecord,
|
|
88
|
+
id: "test-uuid-parties-001",
|
|
89
|
+
parties: [
|
|
90
|
+
{ party: "gateway", role: "witness", scope: ["id", "timestamp", "method", "toolName", "namespace", "upstream", "principal", "durationMs", "success", "errorCode", "previousHash"] },
|
|
91
|
+
],
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
it("includes parties in signature when present", async () => {
|
|
95
|
+
const sigWith = await signer.sign(recordWithParties);
|
|
96
|
+
const sigWithout = await signer.sign(mockRecord);
|
|
97
|
+
expect(sigWith).not.toBe(sigWithout);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("verifies record with parties", async () => {
|
|
101
|
+
const sig = await signer.sign(recordWithParties);
|
|
102
|
+
const valid = await signer.verify(recordWithParties, sig);
|
|
103
|
+
expect(valid).toBe(true);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("rejects tampered parties", async () => {
|
|
107
|
+
const sig = await signer.sign(recordWithParties);
|
|
108
|
+
const tampered = { ...recordWithParties, parties: [{ party: "attacker", role: "asserter" as const, scope: ["*"] }] };
|
|
109
|
+
const valid = await signer.verify(tampered, sig);
|
|
110
|
+
expect(valid).toBe(false);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("handles both decisionContextDigest and parties together", async () => {
|
|
114
|
+
const dual: AuditRecord = {
|
|
115
|
+
...recordWithParties,
|
|
116
|
+
id: "test-uuid-parties-002",
|
|
117
|
+
decisionContextDigest: "e".repeat(64),
|
|
118
|
+
parties: [
|
|
119
|
+
{ party: "gateway", role: "witness", scope: ["id", "timestamp", "method"] },
|
|
120
|
+
{ party: "policy-engine", role: "asserter", scope: ["decisionContextDigest"] },
|
|
121
|
+
],
|
|
122
|
+
};
|
|
123
|
+
const sig = await signer.sign(dual);
|
|
124
|
+
const valid = await signer.verify(dual, sig);
|
|
125
|
+
expect(valid).toBe(true);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
45
129
|
describe("Ed25519Signer", () => {
|
|
46
130
|
it("signs and verifies a record", async () => {
|
|
47
131
|
const signer = new Ed25519Signer();
|
|
@@ -72,4 +156,18 @@ describe("Ed25519Signer", () => {
|
|
|
72
156
|
expect(pubKey).not.toBeNull();
|
|
73
157
|
expect(pubKey!.length).toBe(32);
|
|
74
158
|
});
|
|
159
|
+
|
|
160
|
+
it("rejects signature from a different key (signature substitution)", async () => {
|
|
161
|
+
const signerA = new Ed25519Signer();
|
|
162
|
+
await signerA.init();
|
|
163
|
+
const signerB = new Ed25519Signer();
|
|
164
|
+
await signerB.init();
|
|
165
|
+
|
|
166
|
+
const sigFromA = await signerA.sign(mockRecord);
|
|
167
|
+
const validOnA = await signerA.verify(mockRecord, sigFromA);
|
|
168
|
+
expect(validOnA).toBe(true);
|
|
169
|
+
|
|
170
|
+
const validOnB = await signerB.verify(mockRecord, sigFromA);
|
|
171
|
+
expect(validOnB).toBe(false);
|
|
172
|
+
});
|
|
75
173
|
});
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
|
-
import type { AttestationConfig, AuditRecord } from "../types.js";
|
|
1
|
+
import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import type { AttestationConfig, AuditRecord, CheckpointRecord, ChainBreakRecord, ChainRecord } from "../types.js";
|
|
3
|
+
import { isCheckpoint, isChainBreak } from "../types.js";
|
|
3
4
|
|
|
4
5
|
export interface Signer {
|
|
5
|
-
sign(record: AuditRecord): Promise<string>;
|
|
6
|
-
verify(record: AuditRecord, signature: string): Promise<boolean>;
|
|
6
|
+
sign(record: AuditRecord | CheckpointRecord | ChainBreakRecord): Promise<string>;
|
|
7
|
+
verify(record: AuditRecord | CheckpointRecord | ChainBreakRecord, signature: string): Promise<boolean>;
|
|
7
8
|
}
|
|
8
9
|
|
|
9
10
|
export class HmacSigner implements Signer {
|
|
@@ -13,24 +14,24 @@ export class HmacSigner implements Signer {
|
|
|
13
14
|
this.secret = Buffer.from(secret, "hex");
|
|
14
15
|
}
|
|
15
16
|
|
|
16
|
-
async sign(record: AuditRecord): Promise<string> {
|
|
17
|
-
const payload =
|
|
17
|
+
async sign(record: AuditRecord | CheckpointRecord | ChainBreakRecord): Promise<string> {
|
|
18
|
+
const payload = isChainBreak(record)
|
|
19
|
+
? canonicalizeChainBreak(record)
|
|
20
|
+
: isCheckpoint(record)
|
|
21
|
+
? canonicalizeCheckpoint(record)
|
|
22
|
+
: canonicalizeRecord(record);
|
|
18
23
|
const hmac = createHmac("sha256", this.secret);
|
|
19
24
|
hmac.update(payload);
|
|
20
25
|
return hmac.digest("hex");
|
|
21
26
|
}
|
|
22
27
|
|
|
23
|
-
async verify(record: AuditRecord, signature: string): Promise<boolean> {
|
|
28
|
+
async verify(record: AuditRecord | CheckpointRecord | ChainBreakRecord, signature: string): Promise<boolean> {
|
|
24
29
|
const expected = await this.sign(record);
|
|
25
30
|
const expectedBuf = Buffer.from(expected, "hex");
|
|
26
31
|
const signatureBuf = Buffer.from(signature, "hex");
|
|
27
32
|
if (expectedBuf.length !== signatureBuf.length) return false;
|
|
28
33
|
return timingSafeEqual(expectedBuf, signatureBuf);
|
|
29
34
|
}
|
|
30
|
-
|
|
31
|
-
private canonicalize(record: AuditRecord): string {
|
|
32
|
-
return canonicalizeRecord(record);
|
|
33
|
-
}
|
|
34
35
|
}
|
|
35
36
|
|
|
36
37
|
export class Ed25519Signer implements Signer {
|
|
@@ -51,18 +52,28 @@ export class Ed25519Signer implements Signer {
|
|
|
51
52
|
this.publicKey = await ed.getPublicKeyAsync(this.privateKey);
|
|
52
53
|
}
|
|
53
54
|
|
|
54
|
-
async sign(record: AuditRecord): Promise<string> {
|
|
55
|
+
async sign(record: AuditRecord | CheckpointRecord | ChainBreakRecord): Promise<string> {
|
|
55
56
|
if (!this.privateKey) await this.init();
|
|
56
57
|
const ed = await import("@noble/ed25519");
|
|
57
|
-
const
|
|
58
|
+
const canonical = isChainBreak(record)
|
|
59
|
+
? canonicalizeChainBreak(record)
|
|
60
|
+
: isCheckpoint(record)
|
|
61
|
+
? canonicalizeCheckpoint(record)
|
|
62
|
+
: canonicalizeRecord(record);
|
|
63
|
+
const payload = new TextEncoder().encode(canonical);
|
|
58
64
|
const sig = await ed.signAsync(payload, this.privateKey!);
|
|
59
65
|
return Buffer.from(sig).toString("hex");
|
|
60
66
|
}
|
|
61
67
|
|
|
62
|
-
async verify(record: AuditRecord, signature: string): Promise<boolean> {
|
|
68
|
+
async verify(record: AuditRecord | CheckpointRecord | ChainBreakRecord, signature: string): Promise<boolean> {
|
|
63
69
|
if (!this.publicKey) await this.init();
|
|
64
70
|
const ed = await import("@noble/ed25519");
|
|
65
|
-
const
|
|
71
|
+
const canonical = isChainBreak(record)
|
|
72
|
+
? canonicalizeChainBreak(record)
|
|
73
|
+
: isCheckpoint(record)
|
|
74
|
+
? canonicalizeCheckpoint(record)
|
|
75
|
+
: canonicalizeRecord(record);
|
|
76
|
+
const payload = new TextEncoder().encode(canonical);
|
|
66
77
|
const sig = Buffer.from(signature, "hex");
|
|
67
78
|
return ed.verifyAsync(sig, payload, this.publicKey!);
|
|
68
79
|
}
|
|
@@ -70,14 +81,36 @@ export class Ed25519Signer implements Signer {
|
|
|
70
81
|
getPublicKey(): Uint8Array | null {
|
|
71
82
|
return this.publicKey;
|
|
72
83
|
}
|
|
84
|
+
}
|
|
73
85
|
|
|
74
|
-
|
|
75
|
-
|
|
86
|
+
export function assertWellFormedString(value: string, context: string): void {
|
|
87
|
+
for (let i = 0; i < value.length; i++) {
|
|
88
|
+
const code = value.charCodeAt(i);
|
|
89
|
+
if (code >= 0xD800 && code <= 0xDBFF) {
|
|
90
|
+
const next = i + 1 < value.length ? value.charCodeAt(i + 1) : 0;
|
|
91
|
+
if (next < 0xDC00 || next > 0xDFFF) {
|
|
92
|
+
throw new Error(`${context}: unpaired surrogate at index ${i}`);
|
|
93
|
+
}
|
|
94
|
+
i++;
|
|
95
|
+
} else if (code >= 0xDC00 && code <= 0xDFFF) {
|
|
96
|
+
throw new Error(`${context}: unpaired surrogate at index ${i}`);
|
|
97
|
+
}
|
|
76
98
|
}
|
|
77
99
|
}
|
|
78
100
|
|
|
79
|
-
function canonicalizeRecord(record: AuditRecord): string {
|
|
80
|
-
|
|
101
|
+
export function canonicalizeRecord(record: AuditRecord): string {
|
|
102
|
+
assertWellFormedString(record.id, "canonicalizeRecord.id");
|
|
103
|
+
assertWellFormedString(record.timestamp, "canonicalizeRecord.timestamp");
|
|
104
|
+
assertWellFormedString(record.method, "canonicalizeRecord.method");
|
|
105
|
+
if (record.toolName != null) assertWellFormedString(record.toolName, "canonicalizeRecord.toolName");
|
|
106
|
+
if (record.namespace != null) assertWellFormedString(record.namespace, "canonicalizeRecord.namespace");
|
|
107
|
+
if (record.upstream != null) assertWellFormedString(record.upstream, "canonicalizeRecord.upstream");
|
|
108
|
+
if (record.principal != null) assertWellFormedString(record.principal, "canonicalizeRecord.principal");
|
|
109
|
+
if (record.previousHash != null) assertWellFormedString(record.previousHash, "canonicalizeRecord.previousHash");
|
|
110
|
+
if (record.decisionContextDigest != null) assertWellFormedString(record.decisionContextDigest, "canonicalizeRecord.decisionContextDigest");
|
|
111
|
+
if (record.extensionsDigest != null) assertWellFormedString(record.extensionsDigest, "canonicalizeRecord.extensionsDigest");
|
|
112
|
+
|
|
113
|
+
const ordered: [string, string | number | boolean | null | unknown][] = [
|
|
81
114
|
["id", record.id],
|
|
82
115
|
["timestamp", record.timestamp],
|
|
83
116
|
["method", record.method],
|
|
@@ -90,9 +123,116 @@ function canonicalizeRecord(record: AuditRecord): string {
|
|
|
90
123
|
["errorCode", record.errorCode ?? null],
|
|
91
124
|
["previousHash", record.previousHash ?? null],
|
|
92
125
|
];
|
|
126
|
+
// Conditional fields inserted in deterministic order:
|
|
127
|
+
// 1. decisionContextDigest (position 10, before previousHash moves to 11)
|
|
128
|
+
// 2. extensionsDigest (after decisionContextDigest or at position 11)
|
|
129
|
+
// 3. aiInvocation (after extensionsDigest; M/L-tagged via canonicalizeValue)
|
|
130
|
+
// 4. parties (last)
|
|
131
|
+
let insertAt = 11;
|
|
132
|
+
if (record.decisionContextDigest != null) {
|
|
133
|
+
ordered.splice(10, 0, ["decisionContextDigest", record.decisionContextDigest]);
|
|
134
|
+
insertAt = 12;
|
|
135
|
+
}
|
|
136
|
+
if (record.extensionsDigest != null) {
|
|
137
|
+
ordered.splice(insertAt, 0, ["extensionsDigest", record.extensionsDigest]);
|
|
138
|
+
insertAt++;
|
|
139
|
+
}
|
|
140
|
+
if (record.aiInvocation != null) {
|
|
141
|
+
ordered.splice(insertAt, 0, ["aiInvocation", canonicalizeValue(record.aiInvocation)]);
|
|
142
|
+
insertAt++;
|
|
143
|
+
}
|
|
144
|
+
if (record.parties != null) {
|
|
145
|
+
ordered.splice(insertAt, 0, ["parties", record.parties]);
|
|
146
|
+
}
|
|
147
|
+
return JSON.stringify(ordered);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function canonicalizeCheckpoint(record: CheckpointRecord): string {
|
|
151
|
+
assertWellFormedString(record.id, "canonicalizeCheckpoint.id");
|
|
152
|
+
assertWellFormedString(record.timestamp, "canonicalizeCheckpoint.timestamp");
|
|
153
|
+
assertWellFormedString(record.previousHash, "canonicalizeCheckpoint.previousHash");
|
|
154
|
+
|
|
155
|
+
const ordered: [string, string | number | null | unknown][] = [
|
|
156
|
+
["id", record.id],
|
|
157
|
+
["type", "checkpoint"],
|
|
158
|
+
["timestamp", record.timestamp],
|
|
159
|
+
["sequence", record.sequence],
|
|
160
|
+
["recordCount", record.recordCount],
|
|
161
|
+
["previousHash", record.previousHash],
|
|
162
|
+
];
|
|
163
|
+
if (record.parties != null) {
|
|
164
|
+
ordered.push(["parties", record.parties]);
|
|
165
|
+
}
|
|
93
166
|
return JSON.stringify(ordered);
|
|
94
167
|
}
|
|
95
168
|
|
|
169
|
+
export function canonicalizeChainBreak(record: ChainBreakRecord): string {
|
|
170
|
+
assertWellFormedString(record.id, "canonicalizeChainBreak.id");
|
|
171
|
+
assertWellFormedString(record.timestamp, "canonicalizeChainBreak.timestamp");
|
|
172
|
+
assertWellFormedString(record.reason, "canonicalizeChainBreak.reason");
|
|
173
|
+
if (record.priorHead != null) assertWellFormedString(record.priorHead, "canonicalizeChainBreak.priorHead");
|
|
174
|
+
|
|
175
|
+
const ordered: [string, string | number | null][] = [
|
|
176
|
+
["id", record.id],
|
|
177
|
+
["type", "chain_break"],
|
|
178
|
+
["timestamp", record.timestamp],
|
|
179
|
+
["reason", record.reason],
|
|
180
|
+
["priorHead", record.priorHead ?? null],
|
|
181
|
+
["priorSequence", record.priorSequence ?? null],
|
|
182
|
+
["priorRecordCount", record.priorRecordCount ?? null],
|
|
183
|
+
];
|
|
184
|
+
return JSON.stringify(ordered);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Recursively canonicalize a value for deterministic, injective serialization.
|
|
189
|
+
*
|
|
190
|
+
* Objects become ["M", [[k,v],...]] (sorted pairs). Arrays become ["L", [...]].
|
|
191
|
+
* Type tags make the mapping injective: {a:1} and [["a",1]] produce distinct
|
|
192
|
+
* canonical forms, preventing type-confusion collisions in digests.
|
|
193
|
+
*
|
|
194
|
+
* Numbers must be safe integers — floats/unsafe integers throw (callers must
|
|
195
|
+
* pre-encode as strings). Strings, booleans, and null pass through as scalars.
|
|
196
|
+
* Keys with undefined values are dropped (matches JSON.stringify and Python).
|
|
197
|
+
*/
|
|
198
|
+
export function canonicalizeValue(value: unknown): unknown {
|
|
199
|
+
if (value === null || value === undefined) return null;
|
|
200
|
+
|
|
201
|
+
switch (typeof value) {
|
|
202
|
+
case "string":
|
|
203
|
+
assertWellFormedString(value, "canonicalizeValue");
|
|
204
|
+
return value;
|
|
205
|
+
case "boolean":
|
|
206
|
+
return value;
|
|
207
|
+
case "number":
|
|
208
|
+
if (!Number.isSafeInteger(value)) {
|
|
209
|
+
throw new Error(
|
|
210
|
+
`canonicalizeValue: unsafe number ${value}. ` +
|
|
211
|
+
`Only safe integers are allowed; encode floats/large numbers as strings.`
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
return value;
|
|
215
|
+
case "object":
|
|
216
|
+
if (Array.isArray(value)) {
|
|
217
|
+
return ["L", value.map(canonicalizeValue)];
|
|
218
|
+
}
|
|
219
|
+
const obj = value as Record<string, unknown>;
|
|
220
|
+
const keys = Object.keys(obj).sort().filter(k => obj[k] !== undefined);
|
|
221
|
+
for (const k of keys) {
|
|
222
|
+
assertWellFormedString(k, "canonicalizeValue.key");
|
|
223
|
+
}
|
|
224
|
+
return ["M", keys.map(k => [k, canonicalizeValue(obj[k])])];
|
|
225
|
+
default:
|
|
226
|
+
throw new Error(`canonicalizeValue: unsupported type ${typeof value}`);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function computeExtensionsDigest(extensions: Record<string, unknown>): string {
|
|
231
|
+
const canonicalized = canonicalizeValue(extensions);
|
|
232
|
+
const serialized = JSON.stringify(canonicalized);
|
|
233
|
+
return createHash("sha256").update(serialized).digest("hex");
|
|
234
|
+
}
|
|
235
|
+
|
|
96
236
|
export function createSigner(config: AttestationConfig): Signer {
|
|
97
237
|
if (!config.enabled) {
|
|
98
238
|
return { sign: async () => "", verify: async () => true };
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createReadStream } from "node:fs";
|
|
2
2
|
import { createInterface } from "node:readline";
|
|
3
|
-
import type { AuditRecord } from "../types.js";
|
|
3
|
+
import type { AuditRecord, CheckpointRecord, ChainBreakRecord, ChainRecord } from "../types.js";
|
|
4
|
+
import { isCheckpoint, isChainBreak } from "../types.js";
|
|
4
5
|
import { HmacSigner, Ed25519Signer, type Signer } from "./signer.js";
|
|
5
6
|
import { hashRecord } from "./audit-log.js";
|
|
6
7
|
|
|
@@ -97,15 +98,34 @@ export async function verifyAuditLog(
|
|
|
97
98
|
return result;
|
|
98
99
|
}
|
|
99
100
|
|
|
100
|
-
|
|
101
|
+
function getRecordPreviousHash(record: ChainRecord): string | undefined {
|
|
102
|
+
if (isChainBreak(record)) return undefined;
|
|
103
|
+
return (record as AuditRecord | CheckpointRecord).previousHash;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export async function verifyChain(records: ChainRecord[]): Promise<ChainVerifyResult> {
|
|
101
107
|
const result: ChainVerifyResult = { total: records.length, valid: true, errors: [] };
|
|
102
108
|
|
|
103
109
|
for (let i = 0; i < records.length; i++) {
|
|
104
110
|
const record = records[i];
|
|
105
111
|
const lineNum = i + 1;
|
|
106
112
|
|
|
113
|
+
// chain_break records are valid chain starts (no previousHash required)
|
|
114
|
+
if (isChainBreak(record)) {
|
|
115
|
+
if (i !== 0) {
|
|
116
|
+
result.valid = false;
|
|
117
|
+
result.errors.push({
|
|
118
|
+
line: lineNum,
|
|
119
|
+
id: record.id,
|
|
120
|
+
reason: "chain_break record must be at position 0",
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const prevHash = getRecordPreviousHash(record);
|
|
107
127
|
if (i === 0) {
|
|
108
|
-
if (
|
|
128
|
+
if (prevHash !== "genesis") {
|
|
109
129
|
result.valid = false;
|
|
110
130
|
result.errors.push({
|
|
111
131
|
line: lineNum,
|
|
@@ -116,7 +136,7 @@ export async function verifyChain(records: AuditRecord[]): Promise<ChainVerifyRe
|
|
|
116
136
|
} else {
|
|
117
137
|
const prevRecord = records[i - 1];
|
|
118
138
|
const expectedHash = hashRecord(prevRecord);
|
|
119
|
-
if (
|
|
139
|
+
if (prevHash !== expectedHash) {
|
|
120
140
|
result.valid = false;
|
|
121
141
|
result.errors.push({
|
|
122
142
|
line: lineNum,
|
|
@@ -129,3 +149,249 @@ export async function verifyChain(records: AuditRecord[]): Promise<ChainVerifyRe
|
|
|
129
149
|
|
|
130
150
|
return result;
|
|
131
151
|
}
|
|
152
|
+
|
|
153
|
+
export type TruncationFailureCode =
|
|
154
|
+
| "head_missing"
|
|
155
|
+
| "count_mismatch"
|
|
156
|
+
| "sequence_regression";
|
|
157
|
+
|
|
158
|
+
export interface TruncationCheckResult {
|
|
159
|
+
truncated: boolean;
|
|
160
|
+
lastCheckpoint: CheckpointRecord | null;
|
|
161
|
+
expectedRecordCount: number | null;
|
|
162
|
+
actualRecordCount: number;
|
|
163
|
+
recordCountValid?: boolean;
|
|
164
|
+
absoluteCountVerified?: boolean;
|
|
165
|
+
verificationMode?: "strict" | "relative";
|
|
166
|
+
hasChainBreak?: boolean;
|
|
167
|
+
failureCode?: TruncationFailureCode;
|
|
168
|
+
reason?: string;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export interface VerifyCompletenessOptions {
|
|
172
|
+
mode?: "strict" | "relative";
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function verifyCompleteness(
|
|
176
|
+
records: ChainRecord[],
|
|
177
|
+
externalCheckpoint: { previousHash: string; sequence: number; recordCount: number },
|
|
178
|
+
options?: VerifyCompletenessOptions,
|
|
179
|
+
): TruncationCheckResult {
|
|
180
|
+
const mode = options?.mode ?? "strict";
|
|
181
|
+
const actualRecordCount = records.length;
|
|
182
|
+
let foundCheckpoint: CheckpointRecord | null = null;
|
|
183
|
+
let checkpointIndex = -1;
|
|
184
|
+
|
|
185
|
+
for (let i = 0; i < records.length; i++) {
|
|
186
|
+
const record = records[i];
|
|
187
|
+
if (isCheckpoint(record)) {
|
|
188
|
+
if (
|
|
189
|
+
record.previousHash === externalCheckpoint.previousHash &&
|
|
190
|
+
record.sequence === externalCheckpoint.sequence &&
|
|
191
|
+
record.recordCount === externalCheckpoint.recordCount
|
|
192
|
+
) {
|
|
193
|
+
foundCheckpoint = record;
|
|
194
|
+
checkpointIndex = i;
|
|
195
|
+
break;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (!foundCheckpoint) {
|
|
201
|
+
let hasDescendant = false;
|
|
202
|
+
for (const record of records) {
|
|
203
|
+
if (isCheckpoint(record) && record.sequence > externalCheckpoint.sequence) {
|
|
204
|
+
hasDescendant = true;
|
|
205
|
+
break;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (hasDescendant) {
|
|
210
|
+
// In relative mode: descendant is acceptable, but absolute counts are unverified
|
|
211
|
+
if (mode === "relative") {
|
|
212
|
+
const deltaResult = verifyAdjacentDeltas(records, "relative");
|
|
213
|
+
if (deltaResult) return { ...deltaResult, verificationMode: "relative" };
|
|
214
|
+
return {
|
|
215
|
+
truncated: false,
|
|
216
|
+
lastCheckpoint: null,
|
|
217
|
+
expectedRecordCount: externalCheckpoint.recordCount,
|
|
218
|
+
actualRecordCount,
|
|
219
|
+
absoluteCountVerified: false,
|
|
220
|
+
verificationMode: "relative",
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
return {
|
|
224
|
+
truncated: false,
|
|
225
|
+
lastCheckpoint: null,
|
|
226
|
+
expectedRecordCount: externalCheckpoint.recordCount,
|
|
227
|
+
actualRecordCount,
|
|
228
|
+
verificationMode: "strict",
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return {
|
|
233
|
+
truncated: true,
|
|
234
|
+
lastCheckpoint: null,
|
|
235
|
+
expectedRecordCount: externalCheckpoint.recordCount,
|
|
236
|
+
actualRecordCount,
|
|
237
|
+
failureCode: "head_missing",
|
|
238
|
+
verificationMode: mode,
|
|
239
|
+
reason: "externalized checkpoint not found in chain and no descendant checkpoint exists",
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Check for sequence regression, segmented at chain_break boundaries.
|
|
244
|
+
// A chain_break legitimately resets counters, so monotonicity is only
|
|
245
|
+
// enforced within each segment (between breaks).
|
|
246
|
+
const regressionResult = checkSequenceRegression(records, externalCheckpoint, actualRecordCount, mode);
|
|
247
|
+
if (regressionResult) return regressionResult;
|
|
248
|
+
|
|
249
|
+
// Verify recordCount against prefix (strict mode: absolute; relative mode: delta only)
|
|
250
|
+
if (mode === "strict") {
|
|
251
|
+
let nonCheckpointsBefore = 0;
|
|
252
|
+
for (let i = 0; i < checkpointIndex; i++) {
|
|
253
|
+
if (!isCheckpoint(records[i])) nonCheckpointsBefore++;
|
|
254
|
+
}
|
|
255
|
+
const recordCountValid = nonCheckpointsBefore === foundCheckpoint.recordCount;
|
|
256
|
+
|
|
257
|
+
if (!recordCountValid) {
|
|
258
|
+
return {
|
|
259
|
+
truncated: true,
|
|
260
|
+
lastCheckpoint: foundCheckpoint,
|
|
261
|
+
expectedRecordCount: externalCheckpoint.recordCount,
|
|
262
|
+
actualRecordCount,
|
|
263
|
+
recordCountValid,
|
|
264
|
+
absoluteCountVerified: true,
|
|
265
|
+
failureCode: "count_mismatch",
|
|
266
|
+
verificationMode: "strict",
|
|
267
|
+
reason: `recordCount mismatch: checkpoint claims ${foundCheckpoint.recordCount} records but ${nonCheckpointsBefore} non-checkpoint records precede it`,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Adjacent-pair delta checks (both modes; segment-initial anchor only in strict)
|
|
273
|
+
const deltaResult = verifyAdjacentDeltas(records, mode);
|
|
274
|
+
if (deltaResult) return { ...deltaResult, verificationMode: mode };
|
|
275
|
+
|
|
276
|
+
const chainContainsBreak = records.some(isChainBreak);
|
|
277
|
+
|
|
278
|
+
return {
|
|
279
|
+
truncated: false,
|
|
280
|
+
lastCheckpoint: foundCheckpoint,
|
|
281
|
+
expectedRecordCount: externalCheckpoint.recordCount,
|
|
282
|
+
actualRecordCount,
|
|
283
|
+
recordCountValid: true,
|
|
284
|
+
absoluteCountVerified: mode === "strict",
|
|
285
|
+
verificationMode: mode,
|
|
286
|
+
hasChainBreak: chainContainsBreak || undefined,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Check sequence monotonicity, segmented at chain_break boundaries.
|
|
292
|
+
* A chain_break legitimately resets counters, so regression is only
|
|
293
|
+
* flagged within a contiguous segment.
|
|
294
|
+
*/
|
|
295
|
+
function checkSequenceRegression(
|
|
296
|
+
records: ChainRecord[],
|
|
297
|
+
externalCheckpoint: { previousHash: string; sequence: number; recordCount: number },
|
|
298
|
+
actualRecordCount: number,
|
|
299
|
+
mode: "strict" | "relative",
|
|
300
|
+
): TruncationCheckResult | null {
|
|
301
|
+
let segmentCheckpoints: CheckpointRecord[] = [];
|
|
302
|
+
|
|
303
|
+
for (const record of records) {
|
|
304
|
+
if (isChainBreak(record)) {
|
|
305
|
+
segmentCheckpoints = [];
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
if (isCheckpoint(record)) {
|
|
309
|
+
if (segmentCheckpoints.length > 0) {
|
|
310
|
+
const prev = segmentCheckpoints[segmentCheckpoints.length - 1];
|
|
311
|
+
if (record.sequence <= prev.sequence) {
|
|
312
|
+
return {
|
|
313
|
+
truncated: true,
|
|
314
|
+
lastCheckpoint: record,
|
|
315
|
+
expectedRecordCount: externalCheckpoint.recordCount,
|
|
316
|
+
actualRecordCount,
|
|
317
|
+
failureCode: "sequence_regression",
|
|
318
|
+
verificationMode: mode,
|
|
319
|
+
reason: `checkpoint sequence regressed: ${record.sequence} <= ${prev.sequence}`,
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
segmentCheckpoints.push(record);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
return null;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Verify that the recordCount delta between adjacent checkpoints matches
|
|
332
|
+
* the actual number of non-checkpoint records between them.
|
|
333
|
+
* Segmented at chain_break boundaries (delta resets across breaks).
|
|
334
|
+
*/
|
|
335
|
+
function verifyAdjacentDeltas(records: ChainRecord[], mode?: "strict" | "relative"): TruncationCheckResult | null {
|
|
336
|
+
const checkpoints: { checkpoint: CheckpointRecord; index: number }[] = [];
|
|
337
|
+
// Segment start: index after the most recent chain_break (or 0 if none).
|
|
338
|
+
// No chain_break can occur between same-segment checkpoints — the reset clears the list.
|
|
339
|
+
let segmentStart = 0;
|
|
340
|
+
|
|
341
|
+
for (let i = 0; i < records.length; i++) {
|
|
342
|
+
if (isChainBreak(records[i])) {
|
|
343
|
+
checkpoints.length = 0;
|
|
344
|
+
segmentStart = i + 1;
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
if (isCheckpoint(records[i])) {
|
|
348
|
+
checkpoints.push({ checkpoint: records[i] as CheckpointRecord, index: i });
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Segment-initial checkpoint absolute anchor (strict mode only — relative mode
|
|
353
|
+
// receives a suffix and can't verify absolute counts).
|
|
354
|
+
if (mode === "strict" && checkpoints.length > 0) {
|
|
355
|
+
const first = checkpoints[0];
|
|
356
|
+
let nonCheckpointsBefore = 0;
|
|
357
|
+
for (let j = segmentStart; j < first.index; j++) {
|
|
358
|
+
if (!isCheckpoint(records[j]) && !isChainBreak(records[j])) nonCheckpointsBefore++;
|
|
359
|
+
}
|
|
360
|
+
if (nonCheckpointsBefore !== first.checkpoint.recordCount) {
|
|
361
|
+
return {
|
|
362
|
+
truncated: true,
|
|
363
|
+
lastCheckpoint: first.checkpoint,
|
|
364
|
+
expectedRecordCount: null,
|
|
365
|
+
actualRecordCount: records.length,
|
|
366
|
+
recordCountValid: false,
|
|
367
|
+
failureCode: "count_mismatch",
|
|
368
|
+
reason: `segment-initial checkpoint claims recordCount ${first.checkpoint.recordCount} but ${nonCheckpointsBefore} non-checkpoint records precede it in segment`,
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
for (let i = 1; i < checkpoints.length; i++) {
|
|
374
|
+
const prev = checkpoints[i - 1];
|
|
375
|
+
const curr = checkpoints[i];
|
|
376
|
+
const expectedDelta = curr.checkpoint.recordCount - prev.checkpoint.recordCount;
|
|
377
|
+
|
|
378
|
+
let actualNonCheckpoints = 0;
|
|
379
|
+
for (let j = prev.index + 1; j < curr.index; j++) {
|
|
380
|
+
if (!isCheckpoint(records[j])) actualNonCheckpoints++;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if (actualNonCheckpoints !== expectedDelta) {
|
|
384
|
+
return {
|
|
385
|
+
truncated: true,
|
|
386
|
+
lastCheckpoint: curr.checkpoint,
|
|
387
|
+
expectedRecordCount: null,
|
|
388
|
+
actualRecordCount: records.length,
|
|
389
|
+
recordCountValid: false,
|
|
390
|
+
failureCode: "count_mismatch",
|
|
391
|
+
reason: `adjacent checkpoint delta mismatch: checkpoints ${prev.checkpoint.sequence}->${curr.checkpoint.sequence} claim delta ${expectedDelta} but ${actualNonCheckpoints} non-checkpoint records found between them`,
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
return null;
|
|
397
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -2,7 +2,7 @@ export { Gateway, ToolCallError } from "./proxy/gateway.js";
|
|
|
2
2
|
export { runWrapProxy } from "./wrap/proxy.js";
|
|
3
3
|
export { McpServerAdapter } from "./proxy/mcp-server-adapter.js";
|
|
4
4
|
export { UpstreamManager } from "./proxy/upstream-manager.js";
|
|
5
|
-
export { PolicyEngine } from "./policy/engine.js";
|
|
5
|
+
export { PolicyEngine, computeDecisionContextDigest, type DecisionContext } from "./policy/engine.js";
|
|
6
6
|
export { AuditLog } from "./attestation/audit-log.js";
|
|
7
7
|
export { createSigner, HmacSigner, Ed25519Signer } from "./attestation/signer.js";
|
|
8
8
|
export { verifyAuditLog } from "./attestation/verify.js";
|
package/src/integration.test.ts
CHANGED
|
@@ -25,6 +25,7 @@ const config: GatewayConfig = {
|
|
|
25
25
|
attestation: { enabled: true, algorithm: "hmac-sha256", secret: "c".repeat(64), includeParams: false, includeResult: false },
|
|
26
26
|
telemetry: { enabled: false, serviceName: "test", sampleRate: 0 },
|
|
27
27
|
auditLog: { enabled: true, path: AUDIT_PATH, rotateAfterMb: 10 },
|
|
28
|
+
checkpoint: { enabled: false, intervalRecords: 100, intervalSeconds: 60, trigger: "whichever_first" as const },
|
|
28
29
|
};
|
|
29
30
|
|
|
30
31
|
describe("Integration: Gateway with in-memory MCP server", () => {
|