@axtary/ledger 0.1.0 → 0.2.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/README.md +57 -2
- package/dist/behavior.d.ts +81 -0
- package/dist/behavior.d.ts.map +1 -0
- package/dist/behavior.js +0 -0
- package/dist/behavior.js.map +1 -0
- package/dist/forensics.d.ts +52 -0
- package/dist/forensics.d.ts.map +1 -0
- package/dist/forensics.js +249 -0
- package/dist/forensics.js.map +1 -0
- package/dist/index.d.ts +572 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +925 -42
- package/dist/index.js.map +1 -1
- package/dist/merkle.d.ts +42 -0
- package/dist/merkle.d.ts.map +1 -0
- package/dist/merkle.js +213 -0
- package/dist/merkle.js.map +1 -0
- package/package.json +4 -2
package/dist/index.js
CHANGED
|
@@ -1,11 +1,20 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdir, open, readFile, rename, rm } from "node:fs/promises";
|
|
2
3
|
import { dirname } from "node:path";
|
|
3
|
-
import { AxtaryDecisionSchema, hashJson, LedgerExecutionOutcomeSchema, LedgerRecordSchema, recordDecision, } from "@axtary/actionpass";
|
|
4
|
+
import { AxtaryDecisionSchema, hashJson, LedgerExecutionOutcomeSchema, LedgerRecordSchema, recordActionPassRevocationEvent, recordDecision, } from "@axtary/actionpass";
|
|
5
|
+
import { decodeProtectedHeader, importJWK, jwtVerify, SignJWT, } from "jose";
|
|
6
|
+
import { lock } from "proper-lockfile";
|
|
4
7
|
import { z } from "zod";
|
|
8
|
+
import { merkleConsistencyProof, merkleInclusionProof, merkleLeafHash, merkleNodeHash, merkleTreeRoot, verifyMerkleConsistency, verifyMerkleInclusion, } from "./merkle.js";
|
|
9
|
+
export { merkleConsistencyProof, merkleInclusionProof, merkleLeafHash, merkleNodeHash, merkleTreeRoot, verifyMerkleConsistency, verifyMerkleInclusion, };
|
|
10
|
+
export { BEHAVIOR_STORE_VERSION, BehaviorStoreSchema, InMemoryBehaviorStore, FileBehaviorStore, deriveBehaviorSignals, recordBehaviorObservation, } from "./behavior.js";
|
|
11
|
+
export { LEDGER_FORENSICS_VERSION, analyzeForensics, reconstructIncident, } from "./forensics.js";
|
|
5
12
|
export const LOCAL_LEDGER_SCHEMA_VERSION = "axtary.local-ledger.v0";
|
|
13
|
+
export const LOCAL_LEDGER_HEAD_SCHEMA_VERSION = "axtary.local-ledger-head.v0";
|
|
6
14
|
export const LEDGER_EXPORT_SCHEMA_VERSION = "axtary.ledger_export.v0";
|
|
7
15
|
export const LEDGER_SYNC_SCHEMA_VERSION = "axtary.ledger_sync.v0";
|
|
8
16
|
export const LEDGER_SIEM_EVENT_SCHEMA_VERSION = "axtary.siem_event.v0";
|
|
17
|
+
export const LEDGER_OTEL_EXPORT_SCHEMA_VERSION = "axtary.otel_trace_export.v0";
|
|
9
18
|
export const LedgerAppendResultSchema = z.object({
|
|
10
19
|
record: LedgerRecordSchema,
|
|
11
20
|
lineNumber: z.number().int().positive(),
|
|
@@ -60,7 +69,18 @@ export const LedgerSiemEventSchema = z.object({
|
|
|
60
69
|
}),
|
|
61
70
|
provider: z
|
|
62
71
|
.object({
|
|
63
|
-
name: z.enum([
|
|
72
|
+
name: z.enum([
|
|
73
|
+
"github",
|
|
74
|
+
"slack",
|
|
75
|
+
"linear",
|
|
76
|
+
"docs",
|
|
77
|
+
"drive",
|
|
78
|
+
"mcp",
|
|
79
|
+
"aws",
|
|
80
|
+
"gcp",
|
|
81
|
+
"jira",
|
|
82
|
+
"postgres",
|
|
83
|
+
]),
|
|
64
84
|
tool: z.string().min(1),
|
|
65
85
|
operation: z.string().min(1),
|
|
66
86
|
resourceKind: z.string().min(1),
|
|
@@ -78,58 +98,81 @@ export const LedgerSyncResultSchema = z.object({
|
|
|
78
98
|
lastLedgerHash: z.string().regex(/^sha256:[a-f0-9]{64}$/).nullable(),
|
|
79
99
|
response: z.unknown().nullable(),
|
|
80
100
|
});
|
|
101
|
+
const LedgerHeadStateSchema = z.object({
|
|
102
|
+
schemaVersion: z.literal(LOCAL_LEDGER_HEAD_SCHEMA_VERSION),
|
|
103
|
+
fileSize: z.number().int().nonnegative(),
|
|
104
|
+
lineNumber: z.number().int().nonnegative(),
|
|
105
|
+
lastLedgerHash: z.string().regex(/^sha256:[a-f0-9]{64}$/).nullable(),
|
|
106
|
+
});
|
|
81
107
|
export class LocalJsonlLedger {
|
|
82
108
|
filePath;
|
|
83
|
-
|
|
109
|
+
headPath;
|
|
110
|
+
options;
|
|
111
|
+
constructor(filePath, options = {}) {
|
|
84
112
|
this.filePath = filePath;
|
|
113
|
+
this.headPath = `${filePath}.head.json`;
|
|
114
|
+
this.options = options;
|
|
85
115
|
}
|
|
86
116
|
async appendDecision(input) {
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
117
|
+
const result = await this.withAppendLock(async (head) => {
|
|
118
|
+
const record = recordDecision({
|
|
119
|
+
action: input.action,
|
|
120
|
+
decision: input.decision,
|
|
121
|
+
actionPassId: input.actionPassId ?? null,
|
|
122
|
+
delegation: input.delegation,
|
|
123
|
+
budget: input.budget,
|
|
124
|
+
previousLedgerHash: head.lastLedgerHash,
|
|
125
|
+
occurredAt: input.occurredAt,
|
|
126
|
+
});
|
|
127
|
+
return this.appendLocked(record, head);
|
|
95
128
|
});
|
|
96
|
-
|
|
129
|
+
await this.exportTelemetry(result.record);
|
|
130
|
+
return result;
|
|
97
131
|
}
|
|
98
132
|
async appendExecutionOutcome(input) {
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
133
|
+
const result = await this.withAppendLock(async (head) => {
|
|
134
|
+
const executionOutcome = LedgerExecutionOutcomeSchema.parse(input.executionOutcome);
|
|
135
|
+
const record = recordDecision({
|
|
136
|
+
action: input.action,
|
|
137
|
+
decision: input.decision,
|
|
138
|
+
actionPassId: input.actionPassId ?? null,
|
|
139
|
+
delegation: input.delegation,
|
|
140
|
+
budget: input.budget,
|
|
141
|
+
previousLedgerHash: head.lastLedgerHash,
|
|
142
|
+
occurredAt: input.occurredAt,
|
|
143
|
+
traceId: input.traceId,
|
|
144
|
+
executionOutcome,
|
|
145
|
+
correlationId: input.correlationId,
|
|
146
|
+
});
|
|
147
|
+
return this.appendLocked(record, head);
|
|
111
148
|
});
|
|
112
|
-
|
|
149
|
+
await this.exportTelemetry(result.record);
|
|
150
|
+
return result;
|
|
151
|
+
}
|
|
152
|
+
async appendRevocation(input) {
|
|
153
|
+
const result = await this.withAppendLock(async (head) => {
|
|
154
|
+
const record = recordActionPassRevocationEvent({
|
|
155
|
+
revokedRecord: input.revokedRecord,
|
|
156
|
+
revokedBy: input.revokedBy,
|
|
157
|
+
reason: input.reason,
|
|
158
|
+
occurredAt: input.occurredAt,
|
|
159
|
+
previousLedgerHash: head.lastLedgerHash,
|
|
160
|
+
});
|
|
161
|
+
return this.appendLocked(record, head);
|
|
162
|
+
});
|
|
163
|
+
await this.exportTelemetry(result.record);
|
|
164
|
+
return result;
|
|
113
165
|
}
|
|
114
166
|
async appendRecord(recordInput) {
|
|
115
|
-
const records = await this.readRecords();
|
|
116
|
-
const previousRecord = records.at(-1) ?? null;
|
|
117
167
|
const record = LedgerRecordSchema.parse(recordInput);
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
const nextContent = [...records, record]
|
|
124
|
-
.map((entry) => JSON.stringify(entry))
|
|
125
|
-
.join("\n");
|
|
126
|
-
const tmpPath = `${this.filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
127
|
-
await writeFile(tmpPath, `${nextContent}\n`, { encoding: "utf8", flag: "wx" });
|
|
128
|
-
await rename(tmpPath, this.filePath);
|
|
129
|
-
return LedgerAppendResultSchema.parse({
|
|
130
|
-
record,
|
|
131
|
-
lineNumber: records.length + 1,
|
|
168
|
+
const result = await this.withAppendLock(async (head) => {
|
|
169
|
+
if (record.previousLedgerHash !== head.lastLedgerHash) {
|
|
170
|
+
throw new Error("ledger_previous_hash_mismatch");
|
|
171
|
+
}
|
|
172
|
+
return this.appendLocked(record, head);
|
|
132
173
|
});
|
|
174
|
+
await this.exportTelemetry(result.record);
|
|
175
|
+
return result;
|
|
133
176
|
}
|
|
134
177
|
async readRecords() {
|
|
135
178
|
const text = await readLedgerFile(this.filePath);
|
|
@@ -187,10 +230,133 @@ export class LocalJsonlLedger {
|
|
|
187
230
|
lastLedgerHash: records.at(-1)?.ledgerHash ?? null,
|
|
188
231
|
};
|
|
189
232
|
}
|
|
233
|
+
async withAppendLock(operation) {
|
|
234
|
+
await mkdir(dirname(this.filePath), { recursive: true });
|
|
235
|
+
let compromisedReason = null;
|
|
236
|
+
const lockOptions = this.options.lock ?? {};
|
|
237
|
+
const release = await lock(this.filePath, {
|
|
238
|
+
realpath: false,
|
|
239
|
+
stale: lockOptions.staleMs ?? 10_000,
|
|
240
|
+
update: lockOptions.updateMs ?? 5_000,
|
|
241
|
+
retries: {
|
|
242
|
+
retries: lockOptions.retries ?? 100,
|
|
243
|
+
factor: 1.2,
|
|
244
|
+
minTimeout: lockOptions.minTimeoutMs ?? 10,
|
|
245
|
+
maxTimeout: lockOptions.maxTimeoutMs ?? 100,
|
|
246
|
+
},
|
|
247
|
+
onCompromised: (error) => {
|
|
248
|
+
compromisedReason = error.message;
|
|
249
|
+
},
|
|
250
|
+
});
|
|
251
|
+
try {
|
|
252
|
+
if (compromisedReason !== null) {
|
|
253
|
+
throw new Error(`ledger_lock_compromised:${compromisedReason}`);
|
|
254
|
+
}
|
|
255
|
+
const head = await loadLedgerHead(this.filePath, this.headPath);
|
|
256
|
+
const result = await operation(head);
|
|
257
|
+
if (compromisedReason !== null) {
|
|
258
|
+
throw new Error(`ledger_lock_compromised:${compromisedReason}`);
|
|
259
|
+
}
|
|
260
|
+
return result;
|
|
261
|
+
}
|
|
262
|
+
finally {
|
|
263
|
+
await release();
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
async appendLocked(recordInput, head) {
|
|
267
|
+
const record = LedgerRecordSchema.parse(recordInput);
|
|
268
|
+
if (record.previousLedgerHash !== head.lastLedgerHash) {
|
|
269
|
+
throw new Error("ledger_previous_hash_mismatch");
|
|
270
|
+
}
|
|
271
|
+
assertRecordHash(record);
|
|
272
|
+
const line = Buffer.from(`${JSON.stringify(record)}\n`, "utf8");
|
|
273
|
+
const handle = await open(this.filePath, "a+", 0o600);
|
|
274
|
+
let nextSize;
|
|
275
|
+
try {
|
|
276
|
+
await handle.chmod(0o600);
|
|
277
|
+
const before = await handle.stat();
|
|
278
|
+
if (before.size !== head.fileSize) {
|
|
279
|
+
throw new Error("ledger_concurrent_write_detected");
|
|
280
|
+
}
|
|
281
|
+
await writeAll(handle, line);
|
|
282
|
+
await handle.sync();
|
|
283
|
+
const after = await handle.stat();
|
|
284
|
+
nextSize = after.size;
|
|
285
|
+
if (nextSize !== before.size + line.byteLength) {
|
|
286
|
+
throw new Error("ledger_concurrent_write_detected");
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
finally {
|
|
290
|
+
await handle.close();
|
|
291
|
+
}
|
|
292
|
+
const nextHead = LedgerHeadStateSchema.parse({
|
|
293
|
+
schemaVersion: LOCAL_LEDGER_HEAD_SCHEMA_VERSION,
|
|
294
|
+
fileSize: nextSize,
|
|
295
|
+
lineNumber: head.lineNumber + 1,
|
|
296
|
+
lastLedgerHash: record.ledgerHash,
|
|
297
|
+
});
|
|
298
|
+
await persistLedgerHead(this.headPath, nextHead);
|
|
299
|
+
await syncDirectory(dirname(this.filePath));
|
|
300
|
+
return LedgerAppendResultSchema.parse({
|
|
301
|
+
record,
|
|
302
|
+
lineNumber: nextHead.lineNumber,
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
async exportTelemetry(record) {
|
|
306
|
+
await this.options.telemetry?.exportRecord(record);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
export class OtlpHttpTraceExporter {
|
|
310
|
+
options;
|
|
311
|
+
constructor(options) {
|
|
312
|
+
this.options = options;
|
|
313
|
+
}
|
|
314
|
+
async exportRecord(record) {
|
|
315
|
+
await exportLedgerRecordsToOtlp({
|
|
316
|
+
...this.options,
|
|
317
|
+
records: [record],
|
|
318
|
+
});
|
|
319
|
+
}
|
|
190
320
|
}
|
|
191
321
|
export async function readLedgerRecords(filePath) {
|
|
192
322
|
return new LocalJsonlLedger(filePath).readRecords();
|
|
193
323
|
}
|
|
324
|
+
/**
|
|
325
|
+
* Revoke a pass through the single honest path used by both the CLI and the
|
|
326
|
+
* dashboard: first write the durable trust-store revocation (the enforcement
|
|
327
|
+
* authority the proxy/adapters/MCP check), then append a tamper-evident
|
|
328
|
+
* revocation event to the hash-chained ledger bound to the most recent record
|
|
329
|
+
* naming that pass. Enforcement is written first so a later audit-append
|
|
330
|
+
* failure still leaves the pass actually revoked. When no ledger record names
|
|
331
|
+
* the pass (e.g. it never ran locally), the revocation is still authoritative
|
|
332
|
+
* and `ledgerEvent` is null — never faked.
|
|
333
|
+
*/
|
|
334
|
+
export async function revokeActionPassWithLedgerEvent(input) {
|
|
335
|
+
const revocation = await input.trustStore.revokeActionPass({
|
|
336
|
+
passId: input.passId,
|
|
337
|
+
revokedBy: input.revokedBy,
|
|
338
|
+
reason: input.reason,
|
|
339
|
+
revokedAt: input.occurredAt,
|
|
340
|
+
});
|
|
341
|
+
const records = await input.ledger.readRecords();
|
|
342
|
+
const revokedRecord = [...records]
|
|
343
|
+
.reverse()
|
|
344
|
+
.find((record) => record.actionPassId === input.passId);
|
|
345
|
+
let ledgerEvent = null;
|
|
346
|
+
if (revokedRecord) {
|
|
347
|
+
ledgerEvent = await input.ledger.appendRevocation({
|
|
348
|
+
revokedRecord,
|
|
349
|
+
revokedBy: input.revokedBy,
|
|
350
|
+
reason: input.reason,
|
|
351
|
+
occurredAt: input.occurredAt,
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
return {
|
|
355
|
+
revocation,
|
|
356
|
+
ledgerEvent,
|
|
357
|
+
revokedRecordId: revokedRecord?.id ?? null,
|
|
358
|
+
};
|
|
359
|
+
}
|
|
194
360
|
export async function verifyLedgerFile(filePath) {
|
|
195
361
|
return new LocalJsonlLedger(filePath).verify();
|
|
196
362
|
}
|
|
@@ -226,6 +392,42 @@ export async function exportLedgerRecords(input) {
|
|
|
226
392
|
records,
|
|
227
393
|
});
|
|
228
394
|
}
|
|
395
|
+
export async function exportLedgerRecordsToOtlp(input) {
|
|
396
|
+
if (input.records.length === 0) {
|
|
397
|
+
throw new Error("otel_export_requires_records");
|
|
398
|
+
}
|
|
399
|
+
const fetchImpl = input.fetch ?? globalThis.fetch;
|
|
400
|
+
if (!fetchImpl) {
|
|
401
|
+
throw new Error("otel_export_fetch_required");
|
|
402
|
+
}
|
|
403
|
+
const records = input.records.map((record) => LedgerRecordSchema.parse(record));
|
|
404
|
+
const body = buildOtlpTraceRequest({
|
|
405
|
+
records,
|
|
406
|
+
serviceName: input.serviceName,
|
|
407
|
+
serviceVersion: input.serviceVersion,
|
|
408
|
+
});
|
|
409
|
+
const response = await fetchWithTimeout(fetchImpl, input.endpoint, {
|
|
410
|
+
method: "POST",
|
|
411
|
+
headers: {
|
|
412
|
+
"content-type": "application/json",
|
|
413
|
+
accept: "application/json",
|
|
414
|
+
...(input.headers ?? {}),
|
|
415
|
+
},
|
|
416
|
+
body: JSON.stringify(body),
|
|
417
|
+
}, input.timeoutMs);
|
|
418
|
+
await response.text();
|
|
419
|
+
if (!response.ok) {
|
|
420
|
+
throw new Error(`otel_export_failed:${response.status}`);
|
|
421
|
+
}
|
|
422
|
+
return {
|
|
423
|
+
schemaVersion: LEDGER_OTEL_EXPORT_SCHEMA_VERSION,
|
|
424
|
+
endpoint: input.endpoint,
|
|
425
|
+
exportedAt: new Date().toISOString(),
|
|
426
|
+
records: records.length,
|
|
427
|
+
spans: records.length,
|
|
428
|
+
status: response.status,
|
|
429
|
+
};
|
|
430
|
+
}
|
|
229
431
|
export async function syncLedgerExport(input) {
|
|
230
432
|
const fetchImpl = input.fetch ?? globalThis.fetch;
|
|
231
433
|
if (!fetchImpl) {
|
|
@@ -274,6 +476,518 @@ export function formatLedgerExport(input) {
|
|
|
274
476
|
export function computeLedgerHash(record) {
|
|
275
477
|
return hashJson(record);
|
|
276
478
|
}
|
|
479
|
+
/** Recompute a record's `ledgerHash` from its own content (spec §9.1 step 2). */
|
|
480
|
+
function recomputeRecordLedgerHash(record) {
|
|
481
|
+
const { ledgerHash, ...recordWithoutHash } = record;
|
|
482
|
+
void ledgerHash;
|
|
483
|
+
return computeLedgerHash(recordWithoutHash);
|
|
484
|
+
}
|
|
485
|
+
// ---------------------------------------------------------------------------
|
|
486
|
+
// Ledger attestation (axtary.ledger_attestation.v0)
|
|
487
|
+
//
|
|
488
|
+
// A detached, signed attestation over a ledger export: a JWS whose claims pin
|
|
489
|
+
// the SHA-256 digest of the canonical (JCS) export plus the chain head/tail and
|
|
490
|
+
// record count. An independent verifier needs only this artifact, the export,
|
|
491
|
+
// and the issuer's public key (plus a JOSE library) to confirm the export was
|
|
492
|
+
// signed by the issuer and has not been altered by a single byte — with no
|
|
493
|
+
// Axtary internals. See spec/actionpass-v0.md §9.
|
|
494
|
+
// ---------------------------------------------------------------------------
|
|
495
|
+
export const LEDGER_ATTESTATION_VERSION = "axtary.ledger_attestation.v0";
|
|
496
|
+
const LEDGER_ATTESTATION_TYP = "axtary-ledger-attestation+jwt";
|
|
497
|
+
const LEDGER_ATTESTATION_ALGORITHMS = ["ES256", "EdDSA"];
|
|
498
|
+
const SHA256_HASH = z.string().regex(/^sha256:[a-f0-9]{64}$/);
|
|
499
|
+
export const LedgerAttestationClaimsSchema = z.object({
|
|
500
|
+
lav: z.literal(LEDGER_ATTESTATION_VERSION),
|
|
501
|
+
iss: z.string().min(1),
|
|
502
|
+
iat: z.number().int().positive(),
|
|
503
|
+
tenant: z.string().min(1).optional(),
|
|
504
|
+
exportDigest: SHA256_HASH,
|
|
505
|
+
recordCount: z.number().int().nonnegative(),
|
|
506
|
+
firstLedgerHash: SHA256_HASH.nullable(),
|
|
507
|
+
segmentHead: SHA256_HASH.nullable(),
|
|
508
|
+
sourceLastLedgerHash: SHA256_HASH.nullable(),
|
|
509
|
+
range: LedgerExportFilterSchema,
|
|
510
|
+
// RFC 6962 signed tree head (optional; older v0 attestations omit these and
|
|
511
|
+
// older verifiers ignore them — the exportDigest still binds the full export).
|
|
512
|
+
// `treeSize` equals `recordCount`; `merkleRoot` is the MTH over the per-record
|
|
513
|
+
// `ledgerHash` leaves, enabling inclusion/consistency proofs (spec §9.4).
|
|
514
|
+
merkleRoot: SHA256_HASH.optional(),
|
|
515
|
+
treeSize: z.number().int().nonnegative().optional(),
|
|
516
|
+
});
|
|
517
|
+
/** SHA-256 digest of the canonical (JCS) ledger export — the content binding. */
|
|
518
|
+
export function computeLedgerExportDigest(exportInput) {
|
|
519
|
+
return hashJson(LedgerExportSchema.parse(exportInput));
|
|
520
|
+
}
|
|
521
|
+
/**
|
|
522
|
+
* RFC 6962 leaves for an export: one leaf per record, committing to that
|
|
523
|
+
* record's `ledgerHash`. Because a verifier recomputes each `ledgerHash` from
|
|
524
|
+
* record content (spec §9.1), binding leaves to `ledgerHash` transitively binds
|
|
525
|
+
* the full record content — a one-byte rewrite changes the leaf and the root.
|
|
526
|
+
*/
|
|
527
|
+
export function ledgerExportLeafHashes(exportInput) {
|
|
528
|
+
const exported = LedgerExportSchema.parse(exportInput);
|
|
529
|
+
return exported.records.map((record) => merkleLeafHash(Buffer.from(record.ledgerHash, "utf8")));
|
|
530
|
+
}
|
|
531
|
+
/** Merkle Tree Hash (RFC 6962) over the export's record leaves, as `sha256:hex`. */
|
|
532
|
+
export function computeLedgerExportMerkleRoot(exportInput) {
|
|
533
|
+
const root = merkleTreeRoot(ledgerExportLeafHashes(exportInput));
|
|
534
|
+
return `sha256:${root.toString("hex")}`;
|
|
535
|
+
}
|
|
536
|
+
function hexFromSha256(value) {
|
|
537
|
+
return Buffer.from(value.replace(/^sha256:/, ""), "hex");
|
|
538
|
+
}
|
|
539
|
+
export async function attestLedgerExport(input) {
|
|
540
|
+
const exported = LedgerExportSchema.parse(input.export);
|
|
541
|
+
const now = input.now ?? new Date();
|
|
542
|
+
const claims = LedgerAttestationClaimsSchema.parse({
|
|
543
|
+
lav: LEDGER_ATTESTATION_VERSION,
|
|
544
|
+
iss: input.issuer,
|
|
545
|
+
iat: Math.floor(now.getTime() / 1000),
|
|
546
|
+
tenant: input.tenant,
|
|
547
|
+
exportDigest: hashJson(exported),
|
|
548
|
+
recordCount: exported.records.length,
|
|
549
|
+
firstLedgerHash: exported.records[0]?.ledgerHash ?? null,
|
|
550
|
+
segmentHead: exported.records.at(-1)?.ledgerHash ?? null,
|
|
551
|
+
sourceLastLedgerHash: exported.source.lastLedgerHash,
|
|
552
|
+
range: exported.filters,
|
|
553
|
+
merkleRoot: computeLedgerExportMerkleRoot(exported),
|
|
554
|
+
treeSize: exported.records.length,
|
|
555
|
+
});
|
|
556
|
+
const token = await new SignJWT(claims)
|
|
557
|
+
.setProtectedHeader({
|
|
558
|
+
alg: input.algorithm ?? "ES256",
|
|
559
|
+
typ: LEDGER_ATTESTATION_TYP,
|
|
560
|
+
kid: input.keyId,
|
|
561
|
+
})
|
|
562
|
+
.sign(input.signingKey);
|
|
563
|
+
return { schemaVersion: LEDGER_ATTESTATION_VERSION, token, claims };
|
|
564
|
+
}
|
|
565
|
+
/**
|
|
566
|
+
* Standalone verifier: confirm a ledger export carries a valid issuer signature
|
|
567
|
+
* and has not been altered. Needs only the export, the attestation token, and
|
|
568
|
+
* the issuer public key — re-derivable from the spec alone.
|
|
569
|
+
*/
|
|
570
|
+
export async function verifyLedgerAttestation(input) {
|
|
571
|
+
try {
|
|
572
|
+
const exported = LedgerExportSchema.parse(input.export);
|
|
573
|
+
const token = typeof input.attestation === "string"
|
|
574
|
+
? input.attestation
|
|
575
|
+
: input.attestation.token;
|
|
576
|
+
const algorithms = input.algorithms ?? LEDGER_ATTESTATION_ALGORITHMS;
|
|
577
|
+
const header = decodeProtectedHeader(token);
|
|
578
|
+
if (header.typ !== LEDGER_ATTESTATION_TYP) {
|
|
579
|
+
return { valid: false, reason: "attestation_type_mismatch" };
|
|
580
|
+
}
|
|
581
|
+
const verificationKey = await resolveAttestationKey(input, header.kid, algorithms);
|
|
582
|
+
const { payload } = await jwtVerify(token, verificationKey, {
|
|
583
|
+
issuer: input.issuer,
|
|
584
|
+
algorithms,
|
|
585
|
+
typ: LEDGER_ATTESTATION_TYP,
|
|
586
|
+
currentDate: input.currentDate,
|
|
587
|
+
});
|
|
588
|
+
const claims = LedgerAttestationClaimsSchema.parse(payload);
|
|
589
|
+
const chainError = verifyExportRecordChain(exported);
|
|
590
|
+
if (chainError) {
|
|
591
|
+
return { valid: false, reason: chainError };
|
|
592
|
+
}
|
|
593
|
+
if (hashJson(exported) !== claims.exportDigest) {
|
|
594
|
+
return { valid: false, reason: "export_digest_mismatch" };
|
|
595
|
+
}
|
|
596
|
+
if (claims.recordCount !== exported.records.length) {
|
|
597
|
+
return { valid: false, reason: "export_record_count_mismatch" };
|
|
598
|
+
}
|
|
599
|
+
return { valid: true, claims, recomputedDigest: claims.exportDigest };
|
|
600
|
+
}
|
|
601
|
+
catch (error) {
|
|
602
|
+
return {
|
|
603
|
+
valid: false,
|
|
604
|
+
reason: error instanceof Error ? error.message : "attestation_verification_failed",
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
export function buildOtlpTraceRequest(input) {
|
|
609
|
+
return {
|
|
610
|
+
resourceSpans: [
|
|
611
|
+
{
|
|
612
|
+
resource: {
|
|
613
|
+
attributes: otlpAttributes({
|
|
614
|
+
"service.name": input.serviceName ?? "axtary-local",
|
|
615
|
+
"service.version": input.serviceVersion ?? "0.2.0",
|
|
616
|
+
"axtary.exporter": "ledger-otlp",
|
|
617
|
+
}),
|
|
618
|
+
},
|
|
619
|
+
scopeSpans: [
|
|
620
|
+
{
|
|
621
|
+
scope: {
|
|
622
|
+
name: "@axtary/ledger",
|
|
623
|
+
version: "0.2.0",
|
|
624
|
+
},
|
|
625
|
+
spans: input.records.map(ledgerRecordToOtlpSpan),
|
|
626
|
+
},
|
|
627
|
+
],
|
|
628
|
+
},
|
|
629
|
+
],
|
|
630
|
+
};
|
|
631
|
+
}
|
|
632
|
+
function ledgerRecordToOtlpSpan(record) {
|
|
633
|
+
const startedAt = dateToUnixNanos(record.occurredAt);
|
|
634
|
+
const endedAt = startedAt + BigInt(1_000_000);
|
|
635
|
+
const toolName = record.auditContext?.tool ?? record.providerEvidence?.tool ?? "unknown";
|
|
636
|
+
const status = otlpSpanStatus(record);
|
|
637
|
+
return {
|
|
638
|
+
traceId: traceIdForRecord(record),
|
|
639
|
+
spanId: spanIdForRecord(record),
|
|
640
|
+
name: `execute_tool ${toolName}`,
|
|
641
|
+
kind: "SPAN_KIND_INTERNAL",
|
|
642
|
+
startTimeUnixNano: startedAt.toString(),
|
|
643
|
+
endTimeUnixNano: endedAt.toString(),
|
|
644
|
+
attributes: otlpAttributes({
|
|
645
|
+
"gen_ai.operation.name": "execute_tool",
|
|
646
|
+
"gen_ai.tool.name": toolName,
|
|
647
|
+
"gen_ai.tool.type": "function",
|
|
648
|
+
"axtary.schema_version": record.schemaVersion,
|
|
649
|
+
"axtary.ledger.record_id": record.id,
|
|
650
|
+
"axtary.ledger.hash": record.ledgerHash,
|
|
651
|
+
"axtary.ledger.previous_hash": record.previousLedgerHash,
|
|
652
|
+
"axtary.action.hash": record.actionHash,
|
|
653
|
+
"axtary.action.payload_hash": record.payloadHash,
|
|
654
|
+
"axtary.action.pass_id": record.actionPassId,
|
|
655
|
+
"axtary.trace.id": record.traceId ?? null,
|
|
656
|
+
"axtary.correlation_id": record.correlationId ?? null,
|
|
657
|
+
"axtary.decision": record.decision,
|
|
658
|
+
"axtary.decision.reasons": record.reasons,
|
|
659
|
+
"axtary.policy.native_rule": record.policy.nativeRule,
|
|
660
|
+
"axtary.policy.version": record.policy.version,
|
|
661
|
+
"axtary.audit.tenant": record.auditContext?.tenant ?? null,
|
|
662
|
+
"axtary.audit.agent_id": record.auditContext?.agentId ?? null,
|
|
663
|
+
"axtary.audit.human_owner": record.auditContext?.humanOwner ?? null,
|
|
664
|
+
"axtary.audit.task_id": record.auditContext?.taskId ?? null,
|
|
665
|
+
"axtary.audit.resource": record.auditContext?.resource ?? null,
|
|
666
|
+
"axtary.execution.status": record.executionOutcome?.status ?? null,
|
|
667
|
+
"axtary.execution.result_id": record.executionOutcome?.resultId ?? null,
|
|
668
|
+
"axtary.execution.failure_reason": record.executionOutcome?.failureReason ?? null,
|
|
669
|
+
}),
|
|
670
|
+
...(status ? { status } : {}),
|
|
671
|
+
};
|
|
672
|
+
}
|
|
673
|
+
function otlpSpanStatus(record) {
|
|
674
|
+
if (record.decision === "deny" ||
|
|
675
|
+
record.decision === "step_up" ||
|
|
676
|
+
record.executionOutcome?.status === "failed") {
|
|
677
|
+
return {
|
|
678
|
+
code: "STATUS_CODE_ERROR",
|
|
679
|
+
message: record.executionOutcome?.failureReason ??
|
|
680
|
+
record.reasons[0] ??
|
|
681
|
+
record.decision,
|
|
682
|
+
};
|
|
683
|
+
}
|
|
684
|
+
if (record.executionOutcome?.status === "succeeded") {
|
|
685
|
+
return { code: "STATUS_CODE_OK" };
|
|
686
|
+
}
|
|
687
|
+
return null;
|
|
688
|
+
}
|
|
689
|
+
function otlpAttributes(values) {
|
|
690
|
+
const attributes = [];
|
|
691
|
+
for (const [key, value] of Object.entries(values)) {
|
|
692
|
+
const otlpValue = toOtlpAnyValue(value);
|
|
693
|
+
if (otlpValue) {
|
|
694
|
+
attributes.push({ key, value: otlpValue });
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
return attributes;
|
|
698
|
+
}
|
|
699
|
+
function toOtlpAnyValue(value) {
|
|
700
|
+
if (value === null || value === undefined)
|
|
701
|
+
return null;
|
|
702
|
+
if (Array.isArray(value)) {
|
|
703
|
+
return { arrayValue: { values: value.map((entry) => ({ stringValue: entry })) } };
|
|
704
|
+
}
|
|
705
|
+
if (typeof value === "boolean")
|
|
706
|
+
return { boolValue: value };
|
|
707
|
+
if (typeof value === "number")
|
|
708
|
+
return { intValue: Math.trunc(value).toString() };
|
|
709
|
+
return { stringValue: value };
|
|
710
|
+
}
|
|
711
|
+
function traceIdForRecord(record) {
|
|
712
|
+
const source = record.traceId ?? record.correlationId ?? record.id;
|
|
713
|
+
return hexBytes(`trace:${source}`, 16);
|
|
714
|
+
}
|
|
715
|
+
function spanIdForRecord(record) {
|
|
716
|
+
return hexBytes(`span:${record.id}`, 8);
|
|
717
|
+
}
|
|
718
|
+
function hexBytes(input, bytes) {
|
|
719
|
+
const hex = createHash("sha256").update(input).digest("hex").slice(0, bytes * 2);
|
|
720
|
+
return /^0+$/.test(hex) ? `1${hex.slice(1)}` : hex;
|
|
721
|
+
}
|
|
722
|
+
function dateToUnixNanos(isoDate) {
|
|
723
|
+
return BigInt(new Date(isoDate).getTime()) * BigInt(1_000_000);
|
|
724
|
+
}
|
|
725
|
+
/**
|
|
726
|
+
* Re-verify the exported records from the records alone: every record's own hash
|
|
727
|
+
* must recompute, and — for a full (unfiltered) export — `previousLedgerHash` must
|
|
728
|
+
* chain. Filtered exports are not contiguous, so only the per-record hash is
|
|
729
|
+
* asserted; reorder/truncation is still caught by the signed export digest.
|
|
730
|
+
*/
|
|
731
|
+
function verifyExportRecordChain(exported) {
|
|
732
|
+
const isFull = exported.filters.from === null &&
|
|
733
|
+
exported.filters.to === null &&
|
|
734
|
+
exported.filters.decisions === null;
|
|
735
|
+
let previous = null;
|
|
736
|
+
for (let index = 0; index < exported.records.length; index += 1) {
|
|
737
|
+
const record = exported.records[index];
|
|
738
|
+
try {
|
|
739
|
+
assertRecordHash(record);
|
|
740
|
+
}
|
|
741
|
+
catch {
|
|
742
|
+
return `ledger_record_hash_mismatch:${index + 1}`;
|
|
743
|
+
}
|
|
744
|
+
if (isFull) {
|
|
745
|
+
if (record.previousLedgerHash !== previous) {
|
|
746
|
+
return `ledger_previous_hash_mismatch:${index + 1}`;
|
|
747
|
+
}
|
|
748
|
+
previous = record.ledgerHash;
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
return null;
|
|
752
|
+
}
|
|
753
|
+
async function resolveAttestationKey(input, keyId, algorithms) {
|
|
754
|
+
let key;
|
|
755
|
+
if (input.verificationKeys) {
|
|
756
|
+
key =
|
|
757
|
+
typeof input.verificationKeys === "function"
|
|
758
|
+
? await input.verificationKeys(keyId)
|
|
759
|
+
: keyId
|
|
760
|
+
? input.verificationKeys[keyId]
|
|
761
|
+
: undefined;
|
|
762
|
+
if (!key) {
|
|
763
|
+
throw new Error(keyId ? `verification_key_not_found:${keyId}` : "verification_key_id_required");
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
else {
|
|
767
|
+
if (!input.verificationKey) {
|
|
768
|
+
throw new Error("verification_key_required");
|
|
769
|
+
}
|
|
770
|
+
key = input.verificationKey;
|
|
771
|
+
}
|
|
772
|
+
return normalizeVerificationKey(key, algorithms);
|
|
773
|
+
}
|
|
774
|
+
async function normalizeVerificationKey(key, algorithms) {
|
|
775
|
+
if (isJwk(key)) {
|
|
776
|
+
const imported = await importJWK(key, key.alg ?? algorithms[0] ?? "ES256");
|
|
777
|
+
return imported;
|
|
778
|
+
}
|
|
779
|
+
return key;
|
|
780
|
+
}
|
|
781
|
+
function isJwk(key) {
|
|
782
|
+
return (typeof key === "object" &&
|
|
783
|
+
key !== null &&
|
|
784
|
+
!(key instanceof Uint8Array) &&
|
|
785
|
+
"kty" in key);
|
|
786
|
+
}
|
|
787
|
+
// ---------------------------------------------------------------------------
|
|
788
|
+
// Attestation bundle: a self-contained artifact (export + token + public key)
|
|
789
|
+
// that a third party can verify with nothing but a JOSE library and §3/§9 of
|
|
790
|
+
// the spec. This is what `axtary attest-ledger` emits and `verify-export` reads.
|
|
791
|
+
// ---------------------------------------------------------------------------
|
|
792
|
+
export const LEDGER_ATTESTATION_BUNDLE_VERSION = "axtary.ledger_attestation_bundle.v0";
|
|
793
|
+
export const LedgerAttestationBundleSchema = z.object({
|
|
794
|
+
schemaVersion: z.literal(LEDGER_ATTESTATION_BUNDLE_VERSION),
|
|
795
|
+
export: LedgerExportSchema,
|
|
796
|
+
attestation: z.object({
|
|
797
|
+
token: z.string().min(1),
|
|
798
|
+
claims: LedgerAttestationClaimsSchema,
|
|
799
|
+
}),
|
|
800
|
+
publicJwk: z.record(z.string(), z.unknown()),
|
|
801
|
+
keyId: z.string().min(1).nullable(),
|
|
802
|
+
});
|
|
803
|
+
/** Bundle an export, its attestation, and the public key into one artifact. */
|
|
804
|
+
export function buildLedgerAttestationBundle(input) {
|
|
805
|
+
return LedgerAttestationBundleSchema.parse({
|
|
806
|
+
schemaVersion: LEDGER_ATTESTATION_BUNDLE_VERSION,
|
|
807
|
+
export: input.export,
|
|
808
|
+
attestation: {
|
|
809
|
+
token: input.attestation.token,
|
|
810
|
+
claims: input.attestation.claims,
|
|
811
|
+
},
|
|
812
|
+
publicJwk: input.publicJwk,
|
|
813
|
+
keyId: input.keyId ?? null,
|
|
814
|
+
});
|
|
815
|
+
}
|
|
816
|
+
/** Verify a self-contained attestation bundle (export + token + embedded public key). */
|
|
817
|
+
export async function verifyLedgerAttestationBundle(bundleInput, options = {}) {
|
|
818
|
+
let bundle;
|
|
819
|
+
try {
|
|
820
|
+
bundle = LedgerAttestationBundleSchema.parse(bundleInput);
|
|
821
|
+
}
|
|
822
|
+
catch (error) {
|
|
823
|
+
return {
|
|
824
|
+
valid: false,
|
|
825
|
+
reason: error instanceof Error ? `invalid_bundle:${error.message}` : "invalid_bundle",
|
|
826
|
+
};
|
|
827
|
+
}
|
|
828
|
+
return verifyLedgerAttestation({
|
|
829
|
+
export: bundle.export,
|
|
830
|
+
attestation: bundle.attestation.token,
|
|
831
|
+
verificationKey: bundle.publicJwk,
|
|
832
|
+
issuer: options.issuer,
|
|
833
|
+
algorithms: options.algorithms,
|
|
834
|
+
currentDate: options.currentDate,
|
|
835
|
+
});
|
|
836
|
+
}
|
|
837
|
+
// ---------------------------------------------------------------------------
|
|
838
|
+
// RFC 6962 transparency-log proofs (spec/actionpass-v0.md §9.4)
|
|
839
|
+
//
|
|
840
|
+
// Inclusion and consistency proof artifacts over the export's Merkle tree. They
|
|
841
|
+
// are not signed themselves; trust flows from the signed tree head (the
|
|
842
|
+
// attestation's `merkleRoot`/`treeSize`). A verifier binds a proof to a signed
|
|
843
|
+
// head by passing the attestation's `merkleRoot` as the expected root.
|
|
844
|
+
// ---------------------------------------------------------------------------
|
|
845
|
+
const HEX_NODE = z.string().regex(/^[a-f0-9]{64}$/);
|
|
846
|
+
export const LEDGER_INCLUSION_PROOF_VERSION = "axtary.ledger_inclusion_proof.v0";
|
|
847
|
+
export const LedgerInclusionProofSchema = z.object({
|
|
848
|
+
schemaVersion: z.literal(LEDGER_INCLUSION_PROOF_VERSION),
|
|
849
|
+
recordId: z.string().min(1),
|
|
850
|
+
ledgerHash: SHA256_HASH,
|
|
851
|
+
leafIndex: z.number().int().nonnegative(),
|
|
852
|
+
treeSize: z.number().int().positive(),
|
|
853
|
+
merkleRoot: SHA256_HASH,
|
|
854
|
+
auditPath: z.array(HEX_NODE),
|
|
855
|
+
});
|
|
856
|
+
export const LEDGER_CONSISTENCY_PROOF_VERSION = "axtary.ledger_consistency_proof.v0";
|
|
857
|
+
export const LedgerConsistencyProofSchema = z.object({
|
|
858
|
+
schemaVersion: z.literal(LEDGER_CONSISTENCY_PROOF_VERSION),
|
|
859
|
+
firstSize: z.number().int().positive(),
|
|
860
|
+
secondSize: z.number().int().positive(),
|
|
861
|
+
firstRoot: SHA256_HASH,
|
|
862
|
+
secondRoot: SHA256_HASH,
|
|
863
|
+
proof: z.array(HEX_NODE),
|
|
864
|
+
});
|
|
865
|
+
/** Build an inclusion proof for one record (by id or 0-based index). */
|
|
866
|
+
export function proveLedgerInclusion(input) {
|
|
867
|
+
const exported = LedgerExportSchema.parse(input.export);
|
|
868
|
+
if (exported.records.length === 0) {
|
|
869
|
+
throw new Error("ledger_inclusion_empty_export");
|
|
870
|
+
}
|
|
871
|
+
const index = input.index ??
|
|
872
|
+
exported.records.findIndex((record) => record.id === input.recordId);
|
|
873
|
+
if (index < 0 || index >= exported.records.length) {
|
|
874
|
+
throw new Error("ledger_inclusion_record_not_found");
|
|
875
|
+
}
|
|
876
|
+
const record = exported.records[index];
|
|
877
|
+
const leaves = ledgerExportLeafHashes(exported);
|
|
878
|
+
const auditPath = merkleInclusionProof(leaves, index);
|
|
879
|
+
const root = merkleTreeRoot(leaves);
|
|
880
|
+
return LedgerInclusionProofSchema.parse({
|
|
881
|
+
schemaVersion: LEDGER_INCLUSION_PROOF_VERSION,
|
|
882
|
+
recordId: record.id,
|
|
883
|
+
ledgerHash: record.ledgerHash,
|
|
884
|
+
leafIndex: index,
|
|
885
|
+
treeSize: exported.records.length,
|
|
886
|
+
merkleRoot: `sha256:${root.toString("hex")}`,
|
|
887
|
+
auditPath: auditPath.map((node) => node.toString("hex")),
|
|
888
|
+
});
|
|
889
|
+
}
|
|
890
|
+
/**
|
|
891
|
+
* Verify an inclusion proof against a Merkle root. Pass `expectedRoot` (e.g. a
|
|
892
|
+
* signed attestation's `merkleRoot`) to bind the proof to a signed tree head.
|
|
893
|
+
* Pass `record` to additionally recompute the leaf from record content so a
|
|
894
|
+
* proof cannot smuggle a `ledgerHash` that does not match the record.
|
|
895
|
+
*/
|
|
896
|
+
export function verifyLedgerInclusionProof(input) {
|
|
897
|
+
let proof;
|
|
898
|
+
try {
|
|
899
|
+
proof = LedgerInclusionProofSchema.parse(input.proof);
|
|
900
|
+
}
|
|
901
|
+
catch (error) {
|
|
902
|
+
return {
|
|
903
|
+
valid: false,
|
|
904
|
+
reason: error instanceof Error ? `invalid_proof:${error.message}` : "invalid_proof",
|
|
905
|
+
};
|
|
906
|
+
}
|
|
907
|
+
if (proof.leafIndex >= proof.treeSize) {
|
|
908
|
+
return { valid: false, reason: "leaf_index_out_of_range" };
|
|
909
|
+
}
|
|
910
|
+
if (input.expectedRoot && input.expectedRoot !== proof.merkleRoot) {
|
|
911
|
+
return { valid: false, reason: "merkle_root_mismatch" };
|
|
912
|
+
}
|
|
913
|
+
if (input.record) {
|
|
914
|
+
if (recomputeRecordLedgerHash(input.record) !== proof.ledgerHash) {
|
|
915
|
+
return { valid: false, reason: "ledger_record_hash_mismatch" };
|
|
916
|
+
}
|
|
917
|
+
if (input.record.id !== proof.recordId) {
|
|
918
|
+
return { valid: false, reason: "record_id_mismatch" };
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
const ok = verifyMerkleInclusion({
|
|
922
|
+
leafHash: merkleLeafHash(Buffer.from(proof.ledgerHash, "utf8")),
|
|
923
|
+
index: proof.leafIndex,
|
|
924
|
+
treeSize: proof.treeSize,
|
|
925
|
+
proof: proof.auditPath.map((node) => Buffer.from(node, "hex")),
|
|
926
|
+
root: hexFromSha256(proof.merkleRoot),
|
|
927
|
+
});
|
|
928
|
+
return ok ? { valid: true } : { valid: false, reason: "merkle_inclusion_invalid" };
|
|
929
|
+
}
|
|
930
|
+
/**
|
|
931
|
+
* Build a consistency proof showing the `second` export append-only-extends the
|
|
932
|
+
* `first`. Roots are computed from each export independently; if `first` is not
|
|
933
|
+
* a genuine prefix, verification fails (the honest, fail-closed outcome).
|
|
934
|
+
*/
|
|
935
|
+
export function proveLedgerConsistency(input) {
|
|
936
|
+
const first = LedgerExportSchema.parse(input.first);
|
|
937
|
+
const second = LedgerExportSchema.parse(input.second);
|
|
938
|
+
const firstSize = first.records.length;
|
|
939
|
+
const secondSize = second.records.length;
|
|
940
|
+
if (firstSize === 0) {
|
|
941
|
+
throw new Error("ledger_consistency_empty_first");
|
|
942
|
+
}
|
|
943
|
+
if (firstSize > secondSize) {
|
|
944
|
+
throw new Error("ledger_consistency_first_larger");
|
|
945
|
+
}
|
|
946
|
+
const firstLeaves = ledgerExportLeafHashes(first);
|
|
947
|
+
const secondLeaves = ledgerExportLeafHashes(second);
|
|
948
|
+
const proof = merkleConsistencyProof(secondLeaves, firstSize);
|
|
949
|
+
return LedgerConsistencyProofSchema.parse({
|
|
950
|
+
schemaVersion: LEDGER_CONSISTENCY_PROOF_VERSION,
|
|
951
|
+
firstSize,
|
|
952
|
+
secondSize,
|
|
953
|
+
firstRoot: `sha256:${merkleTreeRoot(firstLeaves).toString("hex")}`,
|
|
954
|
+
secondRoot: `sha256:${merkleTreeRoot(secondLeaves).toString("hex")}`,
|
|
955
|
+
proof: proof.map((node) => node.toString("hex")),
|
|
956
|
+
});
|
|
957
|
+
}
|
|
958
|
+
/**
|
|
959
|
+
* Verify a consistency proof. Pass `firstExpectedRoot`/`secondExpectedRoot`
|
|
960
|
+
* (the two signed heads' `merkleRoot`s) to bind the proof to signed tree heads.
|
|
961
|
+
*/
|
|
962
|
+
export function verifyLedgerConsistencyProof(input) {
|
|
963
|
+
let proof;
|
|
964
|
+
try {
|
|
965
|
+
proof = LedgerConsistencyProofSchema.parse(input.proof);
|
|
966
|
+
}
|
|
967
|
+
catch (error) {
|
|
968
|
+
return {
|
|
969
|
+
valid: false,
|
|
970
|
+
reason: error instanceof Error ? `invalid_proof:${error.message}` : "invalid_proof",
|
|
971
|
+
};
|
|
972
|
+
}
|
|
973
|
+
if (proof.firstSize > proof.secondSize) {
|
|
974
|
+
return { valid: false, reason: "first_size_exceeds_second" };
|
|
975
|
+
}
|
|
976
|
+
if (input.firstExpectedRoot && input.firstExpectedRoot !== proof.firstRoot) {
|
|
977
|
+
return { valid: false, reason: "first_root_mismatch" };
|
|
978
|
+
}
|
|
979
|
+
if (input.secondExpectedRoot && input.secondExpectedRoot !== proof.secondRoot) {
|
|
980
|
+
return { valid: false, reason: "second_root_mismatch" };
|
|
981
|
+
}
|
|
982
|
+
const ok = verifyMerkleConsistency({
|
|
983
|
+
firstSize: proof.firstSize,
|
|
984
|
+
secondSize: proof.secondSize,
|
|
985
|
+
firstRoot: hexFromSha256(proof.firstRoot),
|
|
986
|
+
secondRoot: hexFromSha256(proof.secondRoot),
|
|
987
|
+
proof: proof.proof.map((node) => Buffer.from(node, "hex")),
|
|
988
|
+
});
|
|
989
|
+
return ok ? { valid: true } : { valid: false, reason: "merkle_consistency_invalid" };
|
|
990
|
+
}
|
|
277
991
|
function toSiemEvent(record, exported) {
|
|
278
992
|
return {
|
|
279
993
|
"@timestamp": record.occurredAt,
|
|
@@ -368,6 +1082,175 @@ function assertRecordHash(record) {
|
|
|
368
1082
|
throw new Error("ledger_hash_mismatch");
|
|
369
1083
|
}
|
|
370
1084
|
}
|
|
1085
|
+
async function loadLedgerHead(filePath, headPath) {
|
|
1086
|
+
const handle = await open(filePath, "a+", 0o600);
|
|
1087
|
+
try {
|
|
1088
|
+
const stats = await handle.stat();
|
|
1089
|
+
const persisted = await readLedgerHead(headPath);
|
|
1090
|
+
if (stats.size === 0) {
|
|
1091
|
+
if (persisted && persisted.fileSize > 0) {
|
|
1092
|
+
throw new Error("ledger_truncated");
|
|
1093
|
+
}
|
|
1094
|
+
return LedgerHeadStateSchema.parse({
|
|
1095
|
+
schemaVersion: LOCAL_LEDGER_HEAD_SCHEMA_VERSION,
|
|
1096
|
+
fileSize: 0,
|
|
1097
|
+
lineNumber: 0,
|
|
1098
|
+
lastLedgerHash: null,
|
|
1099
|
+
});
|
|
1100
|
+
}
|
|
1101
|
+
if (persisted && stats.size < persisted.fileSize) {
|
|
1102
|
+
throw new Error("ledger_truncated");
|
|
1103
|
+
}
|
|
1104
|
+
if (persisted && persisted.fileSize === stats.size) {
|
|
1105
|
+
const tail = await readLastLedgerRecord(handle, stats.size, persisted.lineNumber);
|
|
1106
|
+
assertRecordHash(tail);
|
|
1107
|
+
if (tail.ledgerHash !== persisted.lastLedgerHash) {
|
|
1108
|
+
throw new Error("ledger_head_hash_mismatch");
|
|
1109
|
+
}
|
|
1110
|
+
return persisted;
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
finally {
|
|
1114
|
+
await handle.close();
|
|
1115
|
+
}
|
|
1116
|
+
return recoverLedgerHead(filePath);
|
|
1117
|
+
}
|
|
1118
|
+
async function readLedgerHead(headPath) {
|
|
1119
|
+
try {
|
|
1120
|
+
return LedgerHeadStateSchema.parse(JSON.parse(await readFile(headPath, "utf8")));
|
|
1121
|
+
}
|
|
1122
|
+
catch (error) {
|
|
1123
|
+
if (isNodeError(error) && error.code === "ENOENT") {
|
|
1124
|
+
return null;
|
|
1125
|
+
}
|
|
1126
|
+
throw error;
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
async function recoverLedgerHead(filePath) {
|
|
1130
|
+
const text = await readLedgerFile(filePath);
|
|
1131
|
+
const lines = text.split("\n").filter((line) => line.trim().length > 0);
|
|
1132
|
+
let previousLedgerHash = null;
|
|
1133
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
1134
|
+
const record = parseLedgerLine(lines[index], index + 1);
|
|
1135
|
+
if (record.previousLedgerHash !== previousLedgerHash) {
|
|
1136
|
+
throw new Error(`ledger_previous_hash_mismatch:${index + 1}`);
|
|
1137
|
+
}
|
|
1138
|
+
assertRecordHash(record);
|
|
1139
|
+
previousLedgerHash = record.ledgerHash;
|
|
1140
|
+
}
|
|
1141
|
+
const stats = await open(filePath, "r").then(async (handle) => {
|
|
1142
|
+
try {
|
|
1143
|
+
return await handle.stat();
|
|
1144
|
+
}
|
|
1145
|
+
finally {
|
|
1146
|
+
await handle.close();
|
|
1147
|
+
}
|
|
1148
|
+
});
|
|
1149
|
+
return LedgerHeadStateSchema.parse({
|
|
1150
|
+
schemaVersion: LOCAL_LEDGER_HEAD_SCHEMA_VERSION,
|
|
1151
|
+
fileSize: stats.size,
|
|
1152
|
+
lineNumber: lines.length,
|
|
1153
|
+
lastLedgerHash: previousLedgerHash,
|
|
1154
|
+
});
|
|
1155
|
+
}
|
|
1156
|
+
async function readLastLedgerRecord(handle, fileSize, lineNumber) {
|
|
1157
|
+
const chunkSize = 64 * 1024;
|
|
1158
|
+
let cursor = fileSize;
|
|
1159
|
+
let suffix = Buffer.alloc(0);
|
|
1160
|
+
let trimmedEnd = fileSize;
|
|
1161
|
+
while (cursor > 0) {
|
|
1162
|
+
const length = Math.min(chunkSize, cursor);
|
|
1163
|
+
const position = cursor - length;
|
|
1164
|
+
const chunk = Buffer.allocUnsafe(length);
|
|
1165
|
+
const { bytesRead } = await handle.read(chunk, 0, length, position);
|
|
1166
|
+
const bytes = chunk.subarray(0, bytesRead);
|
|
1167
|
+
if (trimmedEnd === fileSize) {
|
|
1168
|
+
let index = bytes.length - 1;
|
|
1169
|
+
while (index >= 0 && (bytes[index] === 0x0a || bytes[index] === 0x0d)) {
|
|
1170
|
+
index -= 1;
|
|
1171
|
+
trimmedEnd -= 1;
|
|
1172
|
+
}
|
|
1173
|
+
suffix = Buffer.concat([bytes.subarray(index + 1, bytes.length), suffix]);
|
|
1174
|
+
if (index < 0) {
|
|
1175
|
+
cursor = position;
|
|
1176
|
+
continue;
|
|
1177
|
+
}
|
|
1178
|
+
const content = bytes.subarray(0, index + 1);
|
|
1179
|
+
const newline = content.lastIndexOf(0x0a);
|
|
1180
|
+
if (newline >= 0) {
|
|
1181
|
+
const line = Buffer.concat([content.subarray(newline + 1), suffix])
|
|
1182
|
+
.toString("utf8")
|
|
1183
|
+
.trim();
|
|
1184
|
+
return parseLedgerLine(line, lineNumber);
|
|
1185
|
+
}
|
|
1186
|
+
suffix = Buffer.concat([content, suffix]);
|
|
1187
|
+
}
|
|
1188
|
+
else {
|
|
1189
|
+
const newline = bytes.lastIndexOf(0x0a);
|
|
1190
|
+
if (newline >= 0) {
|
|
1191
|
+
const line = Buffer.concat([bytes.subarray(newline + 1), suffix])
|
|
1192
|
+
.toString("utf8")
|
|
1193
|
+
.trim();
|
|
1194
|
+
return parseLedgerLine(line, lineNumber);
|
|
1195
|
+
}
|
|
1196
|
+
suffix = Buffer.concat([bytes, suffix]);
|
|
1197
|
+
}
|
|
1198
|
+
cursor = position;
|
|
1199
|
+
}
|
|
1200
|
+
const line = suffix.toString("utf8").trim();
|
|
1201
|
+
if (!line) {
|
|
1202
|
+
throw new Error("ledger_tail_missing");
|
|
1203
|
+
}
|
|
1204
|
+
return parseLedgerLine(line, lineNumber);
|
|
1205
|
+
}
|
|
1206
|
+
async function writeAll(handle, data) {
|
|
1207
|
+
let offset = 0;
|
|
1208
|
+
while (offset < data.byteLength) {
|
|
1209
|
+
const { bytesWritten } = await handle.write(data, offset, data.byteLength - offset);
|
|
1210
|
+
if (bytesWritten < 1) {
|
|
1211
|
+
throw new Error("ledger_append_incomplete");
|
|
1212
|
+
}
|
|
1213
|
+
offset += bytesWritten;
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
async function persistLedgerHead(headPath, head) {
|
|
1217
|
+
const tmpPath = `${headPath}.${process.pid}.${randomUUID()}.tmp`;
|
|
1218
|
+
let renamed = false;
|
|
1219
|
+
try {
|
|
1220
|
+
const handle = await open(tmpPath, "wx", 0o600);
|
|
1221
|
+
try {
|
|
1222
|
+
await writeAll(handle, Buffer.from(`${JSON.stringify(head)}\n`, "utf8"));
|
|
1223
|
+
await handle.sync();
|
|
1224
|
+
}
|
|
1225
|
+
finally {
|
|
1226
|
+
await handle.close();
|
|
1227
|
+
}
|
|
1228
|
+
await rename(tmpPath, headPath);
|
|
1229
|
+
renamed = true;
|
|
1230
|
+
}
|
|
1231
|
+
finally {
|
|
1232
|
+
if (!renamed) {
|
|
1233
|
+
await rm(tmpPath, { force: true });
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
async function syncDirectory(directory) {
|
|
1238
|
+
const handle = await open(directory, "r");
|
|
1239
|
+
try {
|
|
1240
|
+
await handle.sync();
|
|
1241
|
+
}
|
|
1242
|
+
catch (error) {
|
|
1243
|
+
if (process.platform === "win32" &&
|
|
1244
|
+
isNodeError(error) &&
|
|
1245
|
+
["EISDIR", "EINVAL", "ENOTSUP", "EPERM"].includes(error.code ?? "")) {
|
|
1246
|
+
return;
|
|
1247
|
+
}
|
|
1248
|
+
throw error;
|
|
1249
|
+
}
|
|
1250
|
+
finally {
|
|
1251
|
+
await handle.close();
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
371
1254
|
async function readLedgerFile(filePath) {
|
|
372
1255
|
try {
|
|
373
1256
|
return await readFile(filePath, "utf8");
|