@mcp-audit-gateway/core 0.2.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/README.md +31 -3
- package/dist/attestation/audit-log.d.ts +34 -3
- package/dist/attestation/audit-log.d.ts.map +1 -1
- package/dist/attestation/audit-log.js +286 -10
- 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 +145 -11
- package/dist/attestation/signer.js.map +1 -1
- package/dist/attestation/signer.test.js +11 -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/integration.test.js +1 -0
- package/dist/integration.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 +4 -1
- 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 +74 -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 +336 -15
- package/src/attestation/checkpoint.test.ts +956 -0
- package/src/attestation/signer.test.ts +14 -0
- package/src/attestation/signer.ts +152 -19
- package/src/attestation/verify.ts +270 -4
- package/src/integration.test.ts +1 -0
- package/src/proxy/gateway.test.ts +18 -0
- package/src/proxy/gateway.ts +4 -0
- package/src/proxy/mcp-server-adapter.test.ts +1 -0
- package/src/proxy/mcp-server-adapter.ts +26 -0
- package/src/types.ts +48 -0
- package/src/wrap/proxy.test.ts +2 -2
- 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 +182 -0
- package/test/vectors/checkpoint.json +450 -0
- package/test/vectors/verify-checkpoint.mjs +344 -0
- package/test/vectors/verify-checkpoint.py +358 -0
- package/test/vectors/verify.mjs +74 -1
- package/test/vectors/verify.py +78 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { appendFile, stat, rename, open } from "node:fs/promises";
|
|
1
|
+
import { appendFile, stat, rename, open, writeFile as fsWriteFile } from "node:fs/promises";
|
|
2
2
|
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
-
import type { AuditRecord, PartyAttribution } from "../types.js";
|
|
3
|
+
import type { AuditRecord, CheckpointRecord, ChainBreakRecord, PartyAttribution, ChainRecord } from "../types.js";
|
|
4
|
+
import { isCheckpoint, isChainBreak } from "../types.js";
|
|
4
5
|
import type { Signer } from "./signer.js";
|
|
5
6
|
|
|
6
7
|
const GATEWAY_WITNESSED_FIELDS = [
|
|
@@ -9,7 +10,7 @@ const GATEWAY_WITNESSED_FIELDS = [
|
|
|
9
10
|
"previousHash",
|
|
10
11
|
];
|
|
11
12
|
|
|
12
|
-
function buildParties(hasDecisionContext: boolean): PartyAttribution[] {
|
|
13
|
+
function buildParties(hasDecisionContext: boolean, hasAiInvocation: boolean): PartyAttribution[] {
|
|
13
14
|
const parties: PartyAttribution[] = [
|
|
14
15
|
{ party: "gateway", role: "witness", scope: GATEWAY_WITNESSED_FIELDS },
|
|
15
16
|
];
|
|
@@ -20,18 +21,37 @@ function buildParties(hasDecisionContext: boolean): PartyAttribution[] {
|
|
|
20
21
|
scope: ["decisionContextDigest"],
|
|
21
22
|
});
|
|
22
23
|
}
|
|
24
|
+
if (hasAiInvocation) {
|
|
25
|
+
parties.push({
|
|
26
|
+
party: "client",
|
|
27
|
+
role: "asserter",
|
|
28
|
+
scope: ["aiInvocation"],
|
|
29
|
+
});
|
|
30
|
+
}
|
|
23
31
|
return parties;
|
|
24
32
|
}
|
|
25
33
|
|
|
26
|
-
export function hashRecord(record: AuditRecord): string {
|
|
34
|
+
export function hashRecord(record: AuditRecord | CheckpointRecord | ChainBreakRecord): string {
|
|
27
35
|
const json = JSON.stringify(record);
|
|
28
36
|
return createHash("sha256").update(json).digest("hex");
|
|
29
37
|
}
|
|
30
38
|
|
|
39
|
+
export interface CheckpointConfig {
|
|
40
|
+
enabled: boolean;
|
|
41
|
+
intervalRecords: number;
|
|
42
|
+
intervalSeconds: number;
|
|
43
|
+
trigger: "records" | "time" | "whichever_first";
|
|
44
|
+
}
|
|
45
|
+
|
|
31
46
|
export class AuditLog {
|
|
32
47
|
private currentSize = 0;
|
|
33
48
|
private lastHash: string = "genesis";
|
|
34
49
|
private writeQueue: Promise<void> = Promise.resolve();
|
|
50
|
+
private recordsSinceCheckpoint = 0;
|
|
51
|
+
private lastCheckpointTime = Date.now();
|
|
52
|
+
private checkpointSequence = 0;
|
|
53
|
+
private totalRecordCount = 0;
|
|
54
|
+
private checkpointConfig: CheckpointConfig | null = null;
|
|
35
55
|
|
|
36
56
|
constructor(
|
|
37
57
|
private path: string,
|
|
@@ -39,6 +59,10 @@ export class AuditLog {
|
|
|
39
59
|
private rotateAfterBytes: number,
|
|
40
60
|
) {}
|
|
41
61
|
|
|
62
|
+
enableCheckpoints(config: CheckpointConfig): void {
|
|
63
|
+
this.checkpointConfig = config;
|
|
64
|
+
}
|
|
65
|
+
|
|
42
66
|
async record(
|
|
43
67
|
method: string,
|
|
44
68
|
opts: {
|
|
@@ -50,6 +74,7 @@ export class AuditLog {
|
|
|
50
74
|
success: boolean;
|
|
51
75
|
errorCode?: number;
|
|
52
76
|
decisionContextDigest?: string;
|
|
77
|
+
aiInvocation?: { turnId?: string; invocationReason?: string; model?: string };
|
|
53
78
|
},
|
|
54
79
|
): Promise<AuditRecord> {
|
|
55
80
|
return new Promise((resolve, reject) => {
|
|
@@ -75,14 +100,17 @@ export class AuditLog {
|
|
|
75
100
|
success: boolean;
|
|
76
101
|
errorCode?: number;
|
|
77
102
|
decisionContextDigest?: string;
|
|
103
|
+
aiInvocation?: { turnId?: string; invocationReason?: string; model?: string };
|
|
78
104
|
},
|
|
79
105
|
): Promise<AuditRecord> {
|
|
106
|
+
const { aiInvocation, ...rest } = opts;
|
|
80
107
|
const record: AuditRecord = {
|
|
81
108
|
id: randomUUID(),
|
|
82
109
|
timestamp: new Date().toISOString(),
|
|
83
110
|
method,
|
|
84
|
-
...
|
|
85
|
-
|
|
111
|
+
...rest,
|
|
112
|
+
...(aiInvocation ? { aiInvocation } : {}),
|
|
113
|
+
parties: buildParties(opts.decisionContextDigest != null, aiInvocation != null),
|
|
86
114
|
previousHash: this.lastHash,
|
|
87
115
|
};
|
|
88
116
|
|
|
@@ -93,6 +121,12 @@ export class AuditLog {
|
|
|
93
121
|
this.currentSize += Buffer.byteLength(line);
|
|
94
122
|
|
|
95
123
|
this.lastHash = hashRecord(record);
|
|
124
|
+
this.totalRecordCount++;
|
|
125
|
+
this.recordsSinceCheckpoint++;
|
|
126
|
+
|
|
127
|
+
if (this.shouldEmitCheckpoint()) {
|
|
128
|
+
await this.writeCheckpoint();
|
|
129
|
+
}
|
|
96
130
|
|
|
97
131
|
if (this.currentSize >= this.rotateAfterBytes) {
|
|
98
132
|
await this.rotate();
|
|
@@ -101,49 +135,336 @@ export class AuditLog {
|
|
|
101
135
|
return record;
|
|
102
136
|
}
|
|
103
137
|
|
|
138
|
+
private shouldEmitCheckpoint(): boolean {
|
|
139
|
+
if (!this.checkpointConfig?.enabled) return false;
|
|
140
|
+
const { trigger, intervalRecords, intervalSeconds } = this.checkpointConfig;
|
|
141
|
+
const recordsHit = this.recordsSinceCheckpoint >= intervalRecords;
|
|
142
|
+
const timeHit = (Date.now() - this.lastCheckpointTime) >= intervalSeconds * 1000;
|
|
143
|
+
|
|
144
|
+
if (trigger === "records") return recordsHit;
|
|
145
|
+
if (trigger === "time") return timeHit;
|
|
146
|
+
return recordsHit || timeHit;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private async writeCheckpoint(): Promise<CheckpointRecord> {
|
|
150
|
+
this.checkpointSequence++;
|
|
151
|
+
const checkpoint: CheckpointRecord = {
|
|
152
|
+
id: `ckpt_${randomUUID()}`,
|
|
153
|
+
type: "checkpoint",
|
|
154
|
+
timestamp: new Date().toISOString(),
|
|
155
|
+
sequence: this.checkpointSequence,
|
|
156
|
+
recordCount: this.totalRecordCount,
|
|
157
|
+
previousHash: this.lastHash,
|
|
158
|
+
parties: [{ party: "gateway", role: "witness", scope: ["sequence", "recordCount", "previousHash"] }],
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
checkpoint.attestation = await this.signer.sign(checkpoint);
|
|
162
|
+
const line = JSON.stringify(checkpoint) + "\n";
|
|
163
|
+
|
|
164
|
+
await appendFile(this.path, line);
|
|
165
|
+
this.currentSize += Buffer.byteLength(line);
|
|
166
|
+
|
|
167
|
+
this.lastHash = hashRecord(checkpoint);
|
|
168
|
+
this.recordsSinceCheckpoint = 0;
|
|
169
|
+
this.lastCheckpointTime = Date.now();
|
|
170
|
+
|
|
171
|
+
return checkpoint;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async emitCheckpoint(): Promise<CheckpointRecord> {
|
|
175
|
+
return new Promise((resolve, reject) => {
|
|
176
|
+
this.writeQueue = this.writeQueue.then(async () => {
|
|
177
|
+
try {
|
|
178
|
+
const result = await this.writeCheckpoint();
|
|
179
|
+
resolve(result);
|
|
180
|
+
} catch (err) {
|
|
181
|
+
reject(err);
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
getLastHash(): string {
|
|
188
|
+
return this.lastHash;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
getCheckpointSequence(): number {
|
|
192
|
+
return this.checkpointSequence;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
getRecordCount(): number {
|
|
196
|
+
return this.totalRecordCount;
|
|
197
|
+
}
|
|
198
|
+
|
|
104
199
|
private async rotate(): Promise<void> {
|
|
105
200
|
const ts = new Date().toISOString().replace(/[:.]/g, "-");
|
|
106
201
|
const rotatedPath = this.path.replace(/\.jsonl$/, `-${ts}.jsonl`);
|
|
107
202
|
try {
|
|
108
203
|
await rename(this.path, rotatedPath);
|
|
109
204
|
this.currentSize = 0;
|
|
110
|
-
|
|
205
|
+
// Chain-global: lastHash, sequence, recordCount survive rotation.
|
|
206
|
+
// The first record in the new file chains to the last hash of the
|
|
207
|
+
// previous file. Resetting to "genesis" would allow truncation-via-rotation.
|
|
208
|
+
await this.persistState();
|
|
111
209
|
} catch {}
|
|
112
210
|
}
|
|
113
211
|
|
|
114
|
-
|
|
212
|
+
private rotationBoundaryHash: string = "genesis";
|
|
213
|
+
|
|
214
|
+
private async persistState(): Promise<void> {
|
|
215
|
+
const statePath = this.path.replace(/\.jsonl$/, ".state.json");
|
|
216
|
+
const tmpPath = statePath + ".tmp";
|
|
217
|
+
const state = {
|
|
218
|
+
lastHash: this.lastHash,
|
|
219
|
+
rotationBoundaryHash: this.lastHash,
|
|
220
|
+
checkpointSequence: this.checkpointSequence,
|
|
221
|
+
totalRecordCount: this.totalRecordCount,
|
|
222
|
+
};
|
|
223
|
+
await fsWriteFile(tmpPath, JSON.stringify(state));
|
|
224
|
+
await rename(tmpPath, statePath);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
private async persistChainState(): Promise<void> {
|
|
228
|
+
const statePath = this.path.replace(/\.jsonl$/, ".state.json");
|
|
229
|
+
const tmpPath = statePath + ".tmp";
|
|
230
|
+
const state = {
|
|
231
|
+
lastHash: this.lastHash,
|
|
232
|
+
rotationBoundaryHash: this.rotationBoundaryHash,
|
|
233
|
+
checkpointSequence: this.checkpointSequence,
|
|
234
|
+
totalRecordCount: this.totalRecordCount,
|
|
235
|
+
};
|
|
236
|
+
await fsWriteFile(tmpPath, JSON.stringify(state));
|
|
237
|
+
await rename(tmpPath, statePath);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
private async recoverState(): Promise<"found" | "not_found" | "corrupt"> {
|
|
241
|
+
const statePath = this.path.replace(/\.jsonl$/, ".state.json");
|
|
242
|
+
try {
|
|
243
|
+
const { readFile } = await import("node:fs/promises");
|
|
244
|
+
const raw = await readFile(statePath, "utf-8");
|
|
245
|
+
const state = JSON.parse(raw);
|
|
246
|
+
if (typeof state.lastHash !== "string" || typeof state.checkpointSequence !== "number" ||
|
|
247
|
+
typeof state.rotationBoundaryHash !== "string") {
|
|
248
|
+
return "corrupt";
|
|
249
|
+
}
|
|
250
|
+
this.lastHash = state.lastHash;
|
|
251
|
+
this.rotationBoundaryHash = state.rotationBoundaryHash;
|
|
252
|
+
this.checkpointSequence = state.checkpointSequence ?? 0;
|
|
253
|
+
this.totalRecordCount = state.totalRecordCount ?? 0;
|
|
254
|
+
return "found";
|
|
255
|
+
} catch (err: unknown) {
|
|
256
|
+
const code = (err as NodeJS.ErrnoException)?.code;
|
|
257
|
+
if (code === "ENOENT") return "not_found";
|
|
258
|
+
return "corrupt";
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async init(opts?: { forceNewChain?: boolean }): Promise<void> {
|
|
263
|
+
const stateResult = await this.recoverState();
|
|
264
|
+
|
|
265
|
+
let logExists = false;
|
|
115
266
|
let s;
|
|
116
267
|
try {
|
|
117
268
|
s = await stat(this.path);
|
|
269
|
+
logExists = true;
|
|
118
270
|
} catch {
|
|
271
|
+
logExists = false;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Three-way startup:
|
|
275
|
+
// (a) No state file, no log → genuine first run
|
|
276
|
+
if (stateResult === "not_found" && !logExists) {
|
|
277
|
+
this.currentSize = 0;
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// (c) State file corrupt or exists but unparseable → refuse unless forced
|
|
282
|
+
if (stateResult === "corrupt") {
|
|
283
|
+
if (opts?.forceNewChain) {
|
|
284
|
+
await this.emitChainBreak("state_file_corrupt");
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
throw new Error(
|
|
288
|
+
"[audit-log] state file corrupt or inconsistent. " +
|
|
289
|
+
"Use --force-new-chain to start a new chain (emits a signed chain_break record)."
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// (b) State file present and parseable → base+delta resume
|
|
294
|
+
if (!logExists) {
|
|
119
295
|
this.currentSize = 0;
|
|
120
296
|
return;
|
|
121
297
|
}
|
|
122
298
|
|
|
123
|
-
this.currentSize = s
|
|
299
|
+
this.currentSize = s!.size;
|
|
124
300
|
|
|
125
|
-
if (s
|
|
126
|
-
const
|
|
301
|
+
if (s!.size > 0) {
|
|
302
|
+
const boundaryHash = stateResult === "found" ? this.rotationBoundaryHash : undefined;
|
|
127
303
|
const fh = await open(this.path, "r");
|
|
128
304
|
try {
|
|
305
|
+
let headBuf = Buffer.alloc(0);
|
|
306
|
+
let offset = 0;
|
|
307
|
+
const chunkSize = 4096;
|
|
308
|
+
const maxFirstLine = 1024 * 1024;
|
|
309
|
+
let firstLine: string | null = null;
|
|
310
|
+
while (offset < s!.size) {
|
|
311
|
+
if (headBuf.length >= maxFirstLine) {
|
|
312
|
+
if (opts?.forceNewChain) {
|
|
313
|
+
await this.emitChainBreak("log_first_record_oversize");
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
throw new Error(
|
|
317
|
+
"[audit-log] first record exceeds 1MB cap (no newline found within limit). " +
|
|
318
|
+
"Use --force-new-chain to start a new chain."
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
const readSize = Math.min(chunkSize, s!.size - offset);
|
|
322
|
+
const chunk = Buffer.alloc(readSize);
|
|
323
|
+
await fh.read(chunk, 0, readSize, offset);
|
|
324
|
+
headBuf = Buffer.concat([headBuf, chunk]);
|
|
325
|
+
const text = headBuf.toString("utf-8");
|
|
326
|
+
const nlIdx = text.indexOf("\n");
|
|
327
|
+
if (nlIdx !== -1) {
|
|
328
|
+
firstLine = text.slice(0, nlIdx);
|
|
329
|
+
break;
|
|
330
|
+
}
|
|
331
|
+
offset += readSize;
|
|
332
|
+
}
|
|
333
|
+
if (firstLine === null) {
|
|
334
|
+
const text = headBuf.toString("utf-8").trim();
|
|
335
|
+
if (text.length === 0) {
|
|
336
|
+
if (opts?.forceNewChain) {
|
|
337
|
+
await this.emitChainBreak("log_empty_no_newline");
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
throw new Error(
|
|
341
|
+
"[audit-log] log file exists but contains no parseable content. " +
|
|
342
|
+
"Use --force-new-chain to start a new chain."
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
firstLine = text;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Parse first record — if it doesn't parse, the log is corrupt
|
|
349
|
+
let firstRecordPrevHash: string | undefined;
|
|
350
|
+
let firstRecordIsBreak = false;
|
|
351
|
+
try {
|
|
352
|
+
const firstRecord = JSON.parse(firstLine);
|
|
353
|
+
if (firstRecord.type === "chain_break") {
|
|
354
|
+
firstRecordIsBreak = true;
|
|
355
|
+
firstRecordPrevHash = firstRecord.priorHead;
|
|
356
|
+
} else {
|
|
357
|
+
firstRecordPrevHash = firstRecord.previousHash;
|
|
358
|
+
}
|
|
359
|
+
} catch {
|
|
360
|
+
if (opts?.forceNewChain) {
|
|
361
|
+
await this.emitChainBreak("log_first_record_corrupt");
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
throw new Error(
|
|
365
|
+
"[audit-log] first record in log is corrupt (unparseable JSON). " +
|
|
366
|
+
"Use --force-new-chain to start a new chain."
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Read tail: recover chain head and checkpoint state
|
|
371
|
+
const tailSize = Math.min(s!.size, 8192);
|
|
129
372
|
const buf = Buffer.alloc(tailSize);
|
|
130
|
-
await fh.read(buf, 0, tailSize, s
|
|
373
|
+
await fh.read(buf, 0, tailSize, s!.size - tailSize);
|
|
131
374
|
const tail = buf.toString("utf-8");
|
|
132
375
|
const lines = tail.trimEnd().split("\n");
|
|
376
|
+
let foundLast = false;
|
|
377
|
+
let recordCountFromTail = 0;
|
|
378
|
+
let tailMaxSequence = 0;
|
|
379
|
+
|
|
133
380
|
for (let i = lines.length - 1; i >= 0; i--) {
|
|
134
381
|
try {
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
-
|
|
382
|
+
const parsed = JSON.parse(lines[i]);
|
|
383
|
+
recordCountFromTail++;
|
|
384
|
+
|
|
385
|
+
if (!foundLast) {
|
|
386
|
+
this.lastHash = hashRecord(parsed);
|
|
387
|
+
foundLast = true;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if (isCheckpoint(parsed)) {
|
|
391
|
+
if (parsed.sequence > tailMaxSequence) {
|
|
392
|
+
tailMaxSequence = parsed.sequence;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
138
395
|
} catch {
|
|
139
396
|
process.stderr.write(
|
|
140
397
|
`[audit-log] warning: corrupt record in tail, skipping\n`,
|
|
141
398
|
);
|
|
142
399
|
}
|
|
143
400
|
}
|
|
401
|
+
|
|
402
|
+
if (tailMaxSequence > this.checkpointSequence) {
|
|
403
|
+
this.checkpointSequence = tailMaxSequence;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
if (boundaryHash !== undefined && firstRecordPrevHash !== undefined &&
|
|
407
|
+
firstRecordPrevHash !== boundaryHash) {
|
|
408
|
+
if (opts?.forceNewChain) {
|
|
409
|
+
await this.emitChainBreak("state_log_inconsistent");
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
throw new Error(
|
|
413
|
+
"[audit-log] state file inconsistent with log: first record does not " +
|
|
414
|
+
"chain from rotationBoundaryHash. Use --force-new-chain to start a new chain."
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
if (stateResult === "not_found") {
|
|
419
|
+
this.totalRecordCount = recordCountFromTail;
|
|
420
|
+
}
|
|
144
421
|
} finally {
|
|
145
422
|
await fh.close();
|
|
146
423
|
}
|
|
147
424
|
}
|
|
148
425
|
}
|
|
426
|
+
|
|
427
|
+
private async emitChainBreak(reason: string): Promise<ChainBreakRecord> {
|
|
428
|
+
const record: ChainBreakRecord = {
|
|
429
|
+
id: `break_${randomUUID()}`,
|
|
430
|
+
type: "chain_break",
|
|
431
|
+
timestamp: new Date().toISOString(),
|
|
432
|
+
reason,
|
|
433
|
+
priorHead: this.lastHash !== "genesis" ? this.lastHash : undefined,
|
|
434
|
+
priorSequence: this.checkpointSequence > 0 ? this.checkpointSequence : undefined,
|
|
435
|
+
priorRecordCount: this.totalRecordCount > 0 ? this.totalRecordCount : undefined,
|
|
436
|
+
};
|
|
437
|
+
|
|
438
|
+
record.attestation = await this.signer.sign(record);
|
|
439
|
+
const line = JSON.stringify(record) + "\n";
|
|
440
|
+
|
|
441
|
+
await appendFile(this.path, line);
|
|
442
|
+
this.currentSize += Buffer.byteLength(line);
|
|
443
|
+
|
|
444
|
+
// Reset chain state — this is a new chain.
|
|
445
|
+
// rotationBoundaryHash is NOT updated: it tracks the file boundary,
|
|
446
|
+
// not the chain boundary. The linkage check compares the first record
|
|
447
|
+
// of a file against rotationBoundaryHash, so mid-file breaks must not
|
|
448
|
+
// overwrite it.
|
|
449
|
+
this.lastHash = hashRecord(record);
|
|
450
|
+
this.checkpointSequence = 0;
|
|
451
|
+
this.totalRecordCount = 0;
|
|
452
|
+
this.recordsSinceCheckpoint = 0;
|
|
453
|
+
|
|
454
|
+
await this.persistChainState();
|
|
455
|
+
return record;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
async forceNewChain(reason: string): Promise<ChainBreakRecord> {
|
|
459
|
+
return new Promise((resolve, reject) => {
|
|
460
|
+
this.writeQueue = this.writeQueue.then(async () => {
|
|
461
|
+
try {
|
|
462
|
+
const result = await this.emitChainBreak(reason);
|
|
463
|
+
resolve(result);
|
|
464
|
+
} catch (err) {
|
|
465
|
+
reject(err);
|
|
466
|
+
}
|
|
467
|
+
});
|
|
468
|
+
});
|
|
469
|
+
}
|
|
149
470
|
}
|