@algovoi/audit-verifier 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +113 -0
- package/dist/canonicalize.d.ts +5 -0
- package/dist/canonicalize.d.ts.map +1 -0
- package/dist/canonicalize.js +26 -0
- package/dist/canonicalize.js.map +1 -0
- package/dist/check-report.d.ts +18 -0
- package/dist/check-report.d.ts.map +1 -0
- package/dist/check-report.js +48 -0
- package/dist/check-report.js.map +1 -0
- package/dist/checks.d.ts +9 -0
- package/dist/checks.d.ts.map +1 -0
- package/dist/checks.js +337 -0
- package/dist/checks.js.map +1 -0
- package/dist/demo.d.ts +17 -0
- package/dist/demo.d.ts.map +1 -0
- package/dist/demo.js +186 -0
- package/dist/demo.js.map +1 -0
- package/dist/extractors.d.ts +13 -0
- package/dist/extractors.d.ts.map +1 -0
- package/dist/extractors.js +72 -0
- package/dist/extractors.js.map +1 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +17 -0
- package/dist/index.js.map +1 -0
- package/dist/types.d.ts +59 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +5 -0
- package/dist/types.js.map +1 -0
- package/dist/verify.d.ts +10 -0
- package/dist/verify.d.ts.map +1 -0
- package/dist/verify.js +44 -0
- package/dist/verify.js.map +1 -0
- package/package.json +64 -0
- package/src/canonicalize.ts +29 -0
- package/src/check-report.ts +58 -0
- package/src/checks.ts +435 -0
- package/src/demo.ts +214 -0
- package/src/extractors.ts +87 -0
- package/src/index.ts +44 -0
- package/src/types.ts +70 -0
- package/src/verify.ts +66 -0
package/src/checks.ts
ADDED
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The five verifier checks ported byte-for-byte from verify_audit_bundle.py:
|
|
3
|
+
*
|
|
4
|
+
* 1. checkPerRowContentHash -- SHA-256(JCS(canonical-fields)) per row
|
|
5
|
+
* 2. checkContinuity -- prev_hash walks unbroken across rows + bridging
|
|
6
|
+
* 3. checkBundleSignature -- HMAC-SHA256 over RFC 8785 canonical bundle
|
|
7
|
+
* 4. checkSelectionCriteria -- selected rows actually match the filter
|
|
8
|
+
* 5. checkOffVmAnchor -- off-VM Object-Lock manifest cross-check
|
|
9
|
+
* (when manifestDir provided)
|
|
10
|
+
*/
|
|
11
|
+
import { createHash, createHmac } from 'node:crypto';
|
|
12
|
+
import { promises as fs } from 'node:fs';
|
|
13
|
+
import * as path from 'node:path';
|
|
14
|
+
|
|
15
|
+
import { canonicalize, canonicaliseToBytes, sha256Hex } from './canonicalize.js';
|
|
16
|
+
import type { CheckReport } from './check-report.js';
|
|
17
|
+
import { FIELD_EXTRACTORS, KNOWN_CHAIN_NAMES } from './extractors.js';
|
|
18
|
+
import type {
|
|
19
|
+
AuditBundle,
|
|
20
|
+
AuditRow,
|
|
21
|
+
BundleSignature,
|
|
22
|
+
} from './types.js';
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// 1. Per-row content_hash
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
export function checkPerRowContentHash(bundle: AuditBundle, report: CheckReport): void {
|
|
29
|
+
const chainName = bundle.chain_name;
|
|
30
|
+
const extractor = chainName ? FIELD_EXTRACTORS[chainName] : undefined;
|
|
31
|
+
if (!extractor) {
|
|
32
|
+
report.add(
|
|
33
|
+
'per_row_content_hash',
|
|
34
|
+
false,
|
|
35
|
+
`unknown chain_name '${chainName}' (expected one of ${JSON.stringify(KNOWN_CHAIN_NAMES)})`,
|
|
36
|
+
);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
const rows = bundle.rows ?? [];
|
|
40
|
+
if (rows.length === 0) {
|
|
41
|
+
report.addSkip('per_row_content_hash', 'selection is empty (zero rows)');
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
const failures: string[] = [];
|
|
45
|
+
for (const r of rows) {
|
|
46
|
+
const expected = r['content_hash'];
|
|
47
|
+
if (typeof expected !== 'string' || expected.length !== 64) {
|
|
48
|
+
failures.push(`chain_position=${JSON.stringify(r['chain_position'])}: stored content_hash missing or wrong length`);
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
const canonical = extractor(r);
|
|
52
|
+
const actual = sha256Hex(canonicaliseToBytes(canonical));
|
|
53
|
+
if (actual !== expected) {
|
|
54
|
+
failures.push(
|
|
55
|
+
`chain_position=${JSON.stringify(r['chain_position'])}: ` +
|
|
56
|
+
`recomputed ${actual.slice(0, 16)}... != stored ${expected.slice(0, 16)}...`,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (failures.length > 0) {
|
|
61
|
+
let detail = `${failures.length} of ${rows.length} rows failed: ${failures.slice(0, 3).join('; ')}`;
|
|
62
|
+
if (failures.length > 3) detail += `; (+${failures.length - 3} more)`;
|
|
63
|
+
report.add('per_row_content_hash', false, detail);
|
|
64
|
+
} else {
|
|
65
|
+
report.add('per_row_content_hash', true, `${rows.length} rows verified`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
// 2. Continuity walk
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
interface UnionEntry {
|
|
74
|
+
chain_position: number | null;
|
|
75
|
+
content_hash: string | undefined;
|
|
76
|
+
prev_hash: string | undefined;
|
|
77
|
+
kind: 'selected' | 'bridging';
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function checkContinuity(bundle: AuditBundle, report: CheckReport): void {
|
|
81
|
+
const rows = bundle.rows ?? [];
|
|
82
|
+
const bridging = bundle.bridging_rows ?? [];
|
|
83
|
+
const bridgingMeta = bundle.bridging ?? {};
|
|
84
|
+
|
|
85
|
+
if (rows.length === 0) {
|
|
86
|
+
report.addSkip('continuity', 'selection is empty');
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const union: UnionEntry[] = [];
|
|
91
|
+
for (const r of rows) {
|
|
92
|
+
union.push({
|
|
93
|
+
chain_position: (r['chain_position'] as number | null | undefined) ?? null,
|
|
94
|
+
content_hash: r['content_hash'] as string | undefined,
|
|
95
|
+
prev_hash: r['prev_hash'] as string | undefined,
|
|
96
|
+
kind: 'selected',
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
for (const b of bridging) {
|
|
100
|
+
union.push({
|
|
101
|
+
chain_position: (b['chain_position'] as number | null | undefined) ?? null,
|
|
102
|
+
content_hash: b['content_hash'] as string | undefined,
|
|
103
|
+
prev_hash: b['prev_hash'] as string | undefined,
|
|
104
|
+
kind: 'bridging',
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
// Sort by chain_position, nulls last (Python uses (None, position) tuple).
|
|
108
|
+
union.sort((a, b) => {
|
|
109
|
+
if (a.chain_position === null && b.chain_position === null) return 0;
|
|
110
|
+
if (a.chain_position === null) return 1;
|
|
111
|
+
if (b.chain_position === null) return -1;
|
|
112
|
+
return a.chain_position - b.chain_position;
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// Detect gaps in the merged set.
|
|
116
|
+
if (union.length > 1) {
|
|
117
|
+
const gaps: Array<[number, number]> = [];
|
|
118
|
+
for (let i = 1; i < union.length; i++) {
|
|
119
|
+
const p0 = union[i - 1]!.chain_position;
|
|
120
|
+
const p1 = union[i]!.chain_position;
|
|
121
|
+
if (p0 === null || p1 === null) continue;
|
|
122
|
+
if (p1 - p0 > 1) gaps.push([p0, p1]);
|
|
123
|
+
}
|
|
124
|
+
if (gaps.length > 0 && !bridgingMeta.included) {
|
|
125
|
+
report.addSkip(
|
|
126
|
+
'continuity',
|
|
127
|
+
`gaps between selected positions ${JSON.stringify(gaps.slice(0, 2))} but bridging_rows not included; ` +
|
|
128
|
+
'request the bundle with include_bridging_rows=true to enable this check',
|
|
129
|
+
);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (gaps.length > 0) {
|
|
133
|
+
report.add(
|
|
134
|
+
'continuity',
|
|
135
|
+
false,
|
|
136
|
+
`gaps remain after bridging at positions ${JSON.stringify(gaps.slice(0, 3))} -- chain has missing rows`,
|
|
137
|
+
);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Walk
|
|
143
|
+
const failures: string[] = [];
|
|
144
|
+
for (let i = 1; i < union.length; i++) {
|
|
145
|
+
const prev = union[i - 1]!;
|
|
146
|
+
const curr = union[i]!;
|
|
147
|
+
if (curr.prev_hash !== prev.content_hash) {
|
|
148
|
+
failures.push(
|
|
149
|
+
`position ${curr.chain_position} (${curr.kind}): ` +
|
|
150
|
+
`prev_hash ${(curr.prev_hash ?? '').slice(0, 12)}... != ` +
|
|
151
|
+
`content_hash of position ${prev.chain_position} (${prev.kind}) ` +
|
|
152
|
+
`${(prev.content_hash ?? '').slice(0, 12)}...`,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (failures.length > 0) {
|
|
157
|
+
const detail = `${failures.length} broken link(s): ${failures.slice(0, 2).join('; ')}`;
|
|
158
|
+
report.add('continuity', false, detail);
|
|
159
|
+
} else {
|
|
160
|
+
report.add(
|
|
161
|
+
'continuity',
|
|
162
|
+
true,
|
|
163
|
+
`${union.length} entries (selected=${rows.length}, bridging=${bridging.length}) chain forward correctly`,
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ---------------------------------------------------------------------------
|
|
169
|
+
// 3. Bundle signature
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
export function checkBundleSignature(
|
|
173
|
+
bundle: AuditBundle,
|
|
174
|
+
signingKey: string | null | undefined,
|
|
175
|
+
report: CheckReport,
|
|
176
|
+
): void {
|
|
177
|
+
const sig = bundle.bundle_signature;
|
|
178
|
+
if (sig === null || sig === undefined) {
|
|
179
|
+
report.addSkip(
|
|
180
|
+
'bundle_signature',
|
|
181
|
+
'bundle has bundle_signature: null (signing not configured server-side)',
|
|
182
|
+
);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
if (typeof sig !== 'object' || Array.isArray(sig) || !('hex' in sig)) {
|
|
186
|
+
report.add('bundle_signature', false, `bundle_signature is not a valid signature object: ${JSON.stringify(sig)}`);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
const s = sig as BundleSignature;
|
|
190
|
+
if (!signingKey) {
|
|
191
|
+
report.addSkip(
|
|
192
|
+
'bundle_signature',
|
|
193
|
+
`bundle is signed with key_id='${s.key_id}', algorithm='${s.algorithm}' -- ` +
|
|
194
|
+
'pass --signing-key or --signing-key-env to verify',
|
|
195
|
+
);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
if (s.algorithm !== 'HMAC-SHA256' || s.canonicalisation !== 'RFC 8785') {
|
|
199
|
+
report.add(
|
|
200
|
+
'bundle_signature',
|
|
201
|
+
false,
|
|
202
|
+
`unsupported signature algorithm/canonicalisation: ${s.algorithm}/${s.canonicalisation}`,
|
|
203
|
+
);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Build inner = bundle minus bundle_signature
|
|
208
|
+
const inner: Record<string, unknown> = {};
|
|
209
|
+
for (const [k, v] of Object.entries(bundle)) {
|
|
210
|
+
if (k !== 'bundle_signature') inner[k] = v;
|
|
211
|
+
}
|
|
212
|
+
const canonical = canonicaliseToBytes(inner);
|
|
213
|
+
const expected = createHmac('sha256', signingKey).update(canonical).digest('hex');
|
|
214
|
+
if (expected === s.hex) {
|
|
215
|
+
report.add(
|
|
216
|
+
'bundle_signature',
|
|
217
|
+
true,
|
|
218
|
+
`HMAC-SHA256 verified against key_id='${s.key_id}'`,
|
|
219
|
+
);
|
|
220
|
+
} else {
|
|
221
|
+
report.add(
|
|
222
|
+
'bundle_signature',
|
|
223
|
+
false,
|
|
224
|
+
`recomputed ${expected.slice(0, 16)}... != stored ${s.hex.slice(0, 16)}... -- wrong key, or bundle tampered`,
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ---------------------------------------------------------------------------
|
|
230
|
+
// 4. Selection criteria match
|
|
231
|
+
// ---------------------------------------------------------------------------
|
|
232
|
+
|
|
233
|
+
const CRITERIA_EXACT_MATCH: Record<string, Record<string, string>> = {
|
|
234
|
+
audit_log: {
|
|
235
|
+
actor: 'actor',
|
|
236
|
+
action: 'action',
|
|
237
|
+
target_type: 'target_type',
|
|
238
|
+
tenant_id: 'tenant_id',
|
|
239
|
+
trace_id: 'trace_id',
|
|
240
|
+
},
|
|
241
|
+
screening_hits: {
|
|
242
|
+
subject_type: 'subject_type',
|
|
243
|
+
action_taken: 'action_taken',
|
|
244
|
+
screening_context: 'screening_context',
|
|
245
|
+
tenant_id: 'tenant_id',
|
|
246
|
+
wallet_address: 'wallet_address',
|
|
247
|
+
},
|
|
248
|
+
compliance_events: {
|
|
249
|
+
tenant_id: 'tenant_id',
|
|
250
|
+
rule_id: 'rule_id',
|
|
251
|
+
event_type: 'event_type',
|
|
252
|
+
payment_ledger_id: 'payment_ledger_id',
|
|
253
|
+
},
|
|
254
|
+
negotiation_trace_events: {
|
|
255
|
+
tenant_id: 'tenant_id',
|
|
256
|
+
session_id: 'session_id',
|
|
257
|
+
protocol: 'protocol',
|
|
258
|
+
payment_ledger_id: 'payment_ledger_id',
|
|
259
|
+
},
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
export function checkSelectionCriteriaMatch(bundle: AuditBundle, report: CheckReport): void {
|
|
263
|
+
const rows = bundle.rows ?? [];
|
|
264
|
+
const criteria = bundle.selection_criteria ?? {};
|
|
265
|
+
const chainName = bundle.chain_name ?? '';
|
|
266
|
+
const exactMap = CRITERIA_EXACT_MATCH[chainName] ?? {};
|
|
267
|
+
|
|
268
|
+
// Find which exact-match filters were set
|
|
269
|
+
const activeFilters: Array<[string, unknown]> = [];
|
|
270
|
+
for (const [criterionKey, rowField] of Object.entries(exactMap)) {
|
|
271
|
+
if (criterionKey in criteria && criteria[criterionKey] !== null && criteria[criterionKey] !== undefined) {
|
|
272
|
+
activeFilters.push([rowField, criteria[criterionKey]]);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
if (activeFilters.length === 0) {
|
|
276
|
+
report.addSkip('selection_criteria_match', 'no exact-match filters set in selection_criteria');
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
if (rows.length === 0) {
|
|
280
|
+
report.addSkip('selection_criteria_match', 'no rows to check');
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const violations: string[] = [];
|
|
285
|
+
for (const r of rows) {
|
|
286
|
+
for (const [field, expected] of activeFilters) {
|
|
287
|
+
// Negotiation_trace counterparty is a disjunctive filter handled separately;
|
|
288
|
+
// exactMap above does not include it.
|
|
289
|
+
const actual = r[field];
|
|
290
|
+
if (actual !== expected) {
|
|
291
|
+
violations.push(
|
|
292
|
+
`chain_position=${JSON.stringify(r['chain_position'])}: ` +
|
|
293
|
+
`row.${field}=${JSON.stringify(actual)} != criteria.${field}=${JSON.stringify(expected)}`,
|
|
294
|
+
);
|
|
295
|
+
break; // one violation per row is enough
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
if (violations.length > 0) {
|
|
300
|
+
let detail = `${violations.length} of ${rows.length} rows do not match selection_criteria: ${violations.slice(0, 3).join('; ')}`;
|
|
301
|
+
if (violations.length > 3) detail += `; (+${violations.length - 3} more)`;
|
|
302
|
+
report.add('selection_criteria_match', false, detail);
|
|
303
|
+
} else {
|
|
304
|
+
report.add(
|
|
305
|
+
'selection_criteria_match',
|
|
306
|
+
true,
|
|
307
|
+
`${rows.length} rows match ${activeFilters.length} exact-match filter(s)`,
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// ---------------------------------------------------------------------------
|
|
313
|
+
// 5. Off-VM anchor
|
|
314
|
+
// ---------------------------------------------------------------------------
|
|
315
|
+
|
|
316
|
+
export function parseObjectKeyShaPrefix(objectKey: string | undefined): string | null {
|
|
317
|
+
if (!objectKey) return null;
|
|
318
|
+
const basename = objectKey.includes('/') ? objectKey.slice(objectKey.lastIndexOf('/') + 1) : objectKey;
|
|
319
|
+
if (!basename.endsWith('.ndjson')) return null;
|
|
320
|
+
const stem = basename.slice(0, -'.ndjson'.length);
|
|
321
|
+
const dashIdx = stem.lastIndexOf('-');
|
|
322
|
+
if (dashIdx < 0) return null;
|
|
323
|
+
const shaPrefix = stem.slice(dashIdx + 1);
|
|
324
|
+
if (shaPrefix.length !== 16) return null;
|
|
325
|
+
if (!/^[0-9a-f]+$/.test(shaPrefix)) return null;
|
|
326
|
+
return shaPrefix;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export async function checkOffVmAnchor(
|
|
330
|
+
bundle: AuditBundle,
|
|
331
|
+
manifestDir: string | null | undefined,
|
|
332
|
+
report: CheckReport,
|
|
333
|
+
): Promise<void> {
|
|
334
|
+
const anchor = bundle.off_vm_anchor;
|
|
335
|
+
if (!anchor || !anchor.object_key) {
|
|
336
|
+
report.addSkip(
|
|
337
|
+
'off_vm_anchor',
|
|
338
|
+
'no off_vm_anchor in bundle (chain has not been shipped yet, or bundle is from before shipping started)',
|
|
339
|
+
);
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
if (!manifestDir) {
|
|
343
|
+
report.addSkip(
|
|
344
|
+
'off_vm_anchor',
|
|
345
|
+
`manifestDir not provided -- run \`aws s3api head-object --bucket ${anchor.bucket_name} --key ${anchor.object_key}\` and confirm ObjectLockMode=COMPLIANCE`,
|
|
346
|
+
);
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const expectedShaPrefix = parseObjectKeyShaPrefix(anchor.object_key);
|
|
351
|
+
if (!expectedShaPrefix) {
|
|
352
|
+
report.add('off_vm_anchor', false, `object_key has no parseable sha256 prefix: ${anchor.object_key}`);
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Try to locate the manifest file: by full key path, by basename.
|
|
357
|
+
const candidates = [
|
|
358
|
+
path.join(manifestDir, anchor.object_key),
|
|
359
|
+
path.join(manifestDir, path.basename(anchor.object_key)),
|
|
360
|
+
];
|
|
361
|
+
let manifestPath: string | null = null;
|
|
362
|
+
for (const c of candidates) {
|
|
363
|
+
try {
|
|
364
|
+
await fs.access(c);
|
|
365
|
+
manifestPath = c;
|
|
366
|
+
break;
|
|
367
|
+
} catch {
|
|
368
|
+
// continue
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
if (!manifestPath) {
|
|
372
|
+
report.add(
|
|
373
|
+
'off_vm_anchor',
|
|
374
|
+
false,
|
|
375
|
+
`manifest file not found in ${manifestDir} for object_key=${anchor.object_key}`,
|
|
376
|
+
);
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Compute SHA-256 of the file bytes.
|
|
381
|
+
const buf = await fs.readFile(manifestPath);
|
|
382
|
+
const fullSha = createHash('sha256').update(buf).digest('hex');
|
|
383
|
+
if (fullSha.slice(0, 16) !== expectedShaPrefix) {
|
|
384
|
+
report.add(
|
|
385
|
+
'off_vm_anchor',
|
|
386
|
+
false,
|
|
387
|
+
`manifest sha prefix ${fullSha.slice(0, 16)} != object_key prefix ${expectedShaPrefix}`,
|
|
388
|
+
);
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Parse last NDJSON line and confirm chain_position + content_hash match bundle.chain_anchor.current_head.
|
|
393
|
+
const text = buf.toString('utf-8');
|
|
394
|
+
const lines = text.split('\n').filter((l) => l.trim().length > 0);
|
|
395
|
+
if (lines.length === 0) {
|
|
396
|
+
report.add('off_vm_anchor', false, 'manifest file is empty');
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
let lastEntry: Record<string, unknown>;
|
|
400
|
+
try {
|
|
401
|
+
lastEntry = JSON.parse(lines[lines.length - 1]!);
|
|
402
|
+
} catch (e) {
|
|
403
|
+
report.add('off_vm_anchor', false, `last NDJSON line is not valid JSON: ${(e as Error).message}`);
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
const head = bundle.chain_anchor?.current_head;
|
|
407
|
+
if (!head) {
|
|
408
|
+
report.add('off_vm_anchor', false, 'bundle.chain_anchor.current_head missing -- cannot cross-check manifest tail');
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
const headPos = head.chain_position;
|
|
412
|
+
const headHash = head.content_hash;
|
|
413
|
+
if (lastEntry['chain_position'] !== headPos) {
|
|
414
|
+
report.add(
|
|
415
|
+
'off_vm_anchor',
|
|
416
|
+
false,
|
|
417
|
+
`manifest tail chain_position=${lastEntry['chain_position']} != bundle chain_anchor.current_head.chain_position=${headPos}`,
|
|
418
|
+
);
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
if (lastEntry['content_hash'] !== headHash) {
|
|
422
|
+
report.add(
|
|
423
|
+
'off_vm_anchor',
|
|
424
|
+
false,
|
|
425
|
+
`manifest tail content_hash=${String(lastEntry['content_hash']).slice(0, 16)}... != bundle chain_anchor.current_head.content_hash=${String(headHash).slice(0, 16)}...`,
|
|
426
|
+
);
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
report.add(
|
|
431
|
+
'off_vm_anchor',
|
|
432
|
+
true,
|
|
433
|
+
`manifest sha verified + tail entry at chain_position=${headPos} matches chain_anchor.current_head`,
|
|
434
|
+
);
|
|
435
|
+
}
|
package/src/demo.ts
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Demo bundle generator -- builds a small synthetic signed AlgoVoi audit
|
|
3
|
+
* bundle for testing the verifier.
|
|
4
|
+
*
|
|
5
|
+
* Byte-for-byte equivalent to the Python sibling demo_audit_bundle.py
|
|
6
|
+
* when called with bundle_emitted_at fixed (the Python default uses
|
|
7
|
+
* datetime.now() so timestamps will differ; passing a fixed emittedAt
|
|
8
|
+
* makes the two implementations produce identical bytes for the same
|
|
9
|
+
* row_count + chain_name + signing_key + key_id).
|
|
10
|
+
*/
|
|
11
|
+
import { createHash, createHmac } from 'node:crypto';
|
|
12
|
+
import { canonicaliseToBytes } from './canonicalize.js';
|
|
13
|
+
import type { AuditBundle, AuditRow } from './types.js';
|
|
14
|
+
|
|
15
|
+
export const GENESIS_PREV_HASH = '0'.repeat(64);
|
|
16
|
+
export const DEFAULT_KEY = 'demo-key-not-for-production-use';
|
|
17
|
+
|
|
18
|
+
function pad(n: number, width: number): string {
|
|
19
|
+
return String(n).padStart(width, '0');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function sha256OfJcs(obj: unknown): string {
|
|
23
|
+
return createHash('sha256').update(canonicaliseToBytes(obj)).digest('hex');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function auditLogRow(chainPosition: number, prevHash: string): AuditRow {
|
|
27
|
+
const canonical: Record<string, unknown> = {
|
|
28
|
+
trace_id: `00000000-0000-0000-0000-${pad(chainPosition, 12)}`,
|
|
29
|
+
actor: 'demo-admin@example.com',
|
|
30
|
+
action: 'tenant.create',
|
|
31
|
+
target_type: 'tenant',
|
|
32
|
+
target_id: `tnt-${chainPosition}`,
|
|
33
|
+
tenant_id: null,
|
|
34
|
+
before_state: null,
|
|
35
|
+
after_state: { created: true, seq: chainPosition },
|
|
36
|
+
ip_address: '203.0.113.7',
|
|
37
|
+
user_agent: 'demo-agent/1.0',
|
|
38
|
+
created_at: `2026-05-06T20:00:${pad(chainPosition, 2)}+00:00`,
|
|
39
|
+
chain_position: chainPosition,
|
|
40
|
+
prev_hash: prevHash,
|
|
41
|
+
};
|
|
42
|
+
const contentHash = sha256OfJcs(canonical);
|
|
43
|
+
return { id: chainPosition * 100, content_hash: contentHash, ...canonical };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function screeningHitRow(chainPosition: number, prevHash: string): AuditRow {
|
|
47
|
+
const canonical: Record<string, unknown> = {
|
|
48
|
+
screened_at: `2026-05-06T20:00:${pad(chainPosition, 2)}+00:00`,
|
|
49
|
+
subject_type: 'payer',
|
|
50
|
+
wallet_address: `0xdemo${String(chainPosition).padStart(36, '0')}`,
|
|
51
|
+
tenant_id: null,
|
|
52
|
+
payment_ledger_id: null,
|
|
53
|
+
sanctions_entry_id: chainPosition,
|
|
54
|
+
action_taken: 'flagged',
|
|
55
|
+
screening_context: 'realtime',
|
|
56
|
+
chain_position: chainPosition,
|
|
57
|
+
prev_hash: prevHash,
|
|
58
|
+
};
|
|
59
|
+
const contentHash = sha256OfJcs(canonical);
|
|
60
|
+
return { id: chainPosition * 100, content_hash: contentHash, ...canonical };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function complianceEventRow(chainPosition: number, prevHash: string): AuditRow {
|
|
64
|
+
const fixedUuid = `11111111-1111-1111-1111-${pad(chainPosition, 12)}`;
|
|
65
|
+
const tenantUuid = `22222222-2222-2222-2222-${pad(chainPosition, 12)}`;
|
|
66
|
+
const ruleUuid = `33333333-3333-3333-3333-${pad(chainPosition, 12)}`;
|
|
67
|
+
const canonical: Record<string, unknown> = {
|
|
68
|
+
id: fixedUuid,
|
|
69
|
+
tenant_id: tenantUuid,
|
|
70
|
+
rule_id: ruleUuid,
|
|
71
|
+
payment_ledger_id: null,
|
|
72
|
+
payer_address_snapshot: null,
|
|
73
|
+
review_of_event_id: null,
|
|
74
|
+
event_type: 'alert',
|
|
75
|
+
metric_value: '100.0000',
|
|
76
|
+
threshold_value: '50.0000',
|
|
77
|
+
created_at: `2026-05-06T20:00:${pad(chainPosition, 2)}+00:00`,
|
|
78
|
+
chain_position: chainPosition,
|
|
79
|
+
prev_hash: prevHash,
|
|
80
|
+
};
|
|
81
|
+
const contentHash = sha256OfJcs(canonical);
|
|
82
|
+
return { ...canonical, content_hash: contentHash };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function negotiationTraceRow(chainPosition: number, prevHash: string): AuditRow {
|
|
86
|
+
const canonical: Record<string, unknown> = {
|
|
87
|
+
trace_id: '44444444-4444-4444-4444-444444444444',
|
|
88
|
+
session_id: null,
|
|
89
|
+
tenant_id: null,
|
|
90
|
+
counterparty_a: 'did:example:demo_agent_a',
|
|
91
|
+
counterparty_b: 'did:example:demo_agent_b',
|
|
92
|
+
protocol: 'x402',
|
|
93
|
+
message_seq: chainPosition,
|
|
94
|
+
message_role: chainPosition === 1 ? 'offer' : 'counter',
|
|
95
|
+
message_payload: { step: chainPosition, amount: '10000', asset: 'USDC' },
|
|
96
|
+
payment_ledger_id: null,
|
|
97
|
+
created_at: `2026-05-06T20:00:${pad(chainPosition, 2)}+00:00`,
|
|
98
|
+
chain_position: chainPosition,
|
|
99
|
+
prev_hash: prevHash,
|
|
100
|
+
};
|
|
101
|
+
const contentHash = sha256OfJcs(canonical);
|
|
102
|
+
const fixedUuid = `55555555-5555-5555-5555-${pad(chainPosition, 12)}`;
|
|
103
|
+
return { id: fixedUuid, content_hash: contentHash, ...canonical };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
type ChainBuilder = (chainPosition: number, prevHash: string) => AuditRow;
|
|
107
|
+
|
|
108
|
+
const CHAIN_BUILDERS: Record<string, ChainBuilder> = {
|
|
109
|
+
audit_log: auditLogRow,
|
|
110
|
+
screening_hits: screeningHitRow,
|
|
111
|
+
compliance_events: complianceEventRow,
|
|
112
|
+
negotiation_trace_events: negotiationTraceRow,
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const CHAIN_EMPTY_CRITERIA: Record<string, Record<string, unknown>> = {
|
|
116
|
+
audit_log: {
|
|
117
|
+
actor: null, action: null, target_type: null,
|
|
118
|
+
tenant_id: null, trace_id: null, since: null, until: null,
|
|
119
|
+
},
|
|
120
|
+
screening_hits: {
|
|
121
|
+
subject_type: null, action_taken: null, screening_context: null,
|
|
122
|
+
tenant_id: null, wallet_address: null,
|
|
123
|
+
since: null, until: null,
|
|
124
|
+
},
|
|
125
|
+
compliance_events: {
|
|
126
|
+
tenant_id: null, rule_id: null, event_type: null,
|
|
127
|
+
payment_ledger_id: null, since: null, until: null,
|
|
128
|
+
},
|
|
129
|
+
negotiation_trace_events: {
|
|
130
|
+
trace_id: null, tenant_id: null, protocol: null,
|
|
131
|
+
counterparty: null, message_role: null,
|
|
132
|
+
payment_ledger_id: null, since: null, until: null,
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
export interface BuildDemoBundleOptions {
|
|
137
|
+
chainName?: string;
|
|
138
|
+
rowCount?: number;
|
|
139
|
+
signingKey?: string;
|
|
140
|
+
keyId?: string;
|
|
141
|
+
/**
|
|
142
|
+
* Override the bundle_emitted_at timestamp; defaults to current time.
|
|
143
|
+
* Pass a fixed ISO string when you need byte-for-byte determinism (e.g.
|
|
144
|
+
* cross-impl tests against the Python sibling).
|
|
145
|
+
*/
|
|
146
|
+
bundleEmittedAt?: string;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function buildDemoBundle(options: BuildDemoBundleOptions = {}): AuditBundle {
|
|
150
|
+
const chainName = options.chainName ?? 'audit_log';
|
|
151
|
+
const rowCount = options.rowCount ?? 3;
|
|
152
|
+
const signingKey = options.signingKey ?? DEFAULT_KEY;
|
|
153
|
+
const keyId = options.keyId ?? 'demo-v1';
|
|
154
|
+
|
|
155
|
+
if (rowCount < 1) throw new Error('row_count must be >= 1');
|
|
156
|
+
const builder = CHAIN_BUILDERS[chainName];
|
|
157
|
+
if (!builder) {
|
|
158
|
+
const known = Object.keys(CHAIN_BUILDERS).sort();
|
|
159
|
+
throw new Error(`chain_name '${chainName}' is not recognised. Expected one of: ${JSON.stringify(known)}`);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const rows: AuditRow[] = [];
|
|
163
|
+
let prev = GENESIS_PREV_HASH;
|
|
164
|
+
for (let pos = 1; pos <= rowCount; pos++) {
|
|
165
|
+
const row = builder(pos, prev);
|
|
166
|
+
rows.push(row);
|
|
167
|
+
prev = row['content_hash'] as string;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const head = rows[rows.length - 1]!;
|
|
171
|
+
const bundle: AuditBundle = {
|
|
172
|
+
chain_format_version: 1,
|
|
173
|
+
chain_name: chainName,
|
|
174
|
+
bundle_emitted_at: options.bundleEmittedAt ?? new Date().toISOString(),
|
|
175
|
+
selection_criteria: CHAIN_EMPTY_CRITERIA[chainName]!,
|
|
176
|
+
selection: {
|
|
177
|
+
row_count: rows.length,
|
|
178
|
+
min_chain_position: rows[0]!['chain_position'],
|
|
179
|
+
max_chain_position: head['chain_position'],
|
|
180
|
+
truncated: false,
|
|
181
|
+
max_rows_cap: 10000,
|
|
182
|
+
},
|
|
183
|
+
rows: rows,
|
|
184
|
+
bridging_rows: [],
|
|
185
|
+
bridging: { included: true, row_count: 0, truncated: false } as unknown as AuditBundle['bridging'],
|
|
186
|
+
chain_anchor: {
|
|
187
|
+
chain_name: chainName,
|
|
188
|
+
genesis_chain_position: 1,
|
|
189
|
+
genesis_prev_hash: GENESIS_PREV_HASH,
|
|
190
|
+
current_head: {
|
|
191
|
+
chain_position: head['chain_position'] as number,
|
|
192
|
+
content_hash: head['content_hash'] as string,
|
|
193
|
+
},
|
|
194
|
+
} as unknown as AuditBundle['chain_anchor'],
|
|
195
|
+
off_vm_anchor: null as unknown as AuditBundle['off_vm_anchor'],
|
|
196
|
+
verification_instructions:
|
|
197
|
+
`Demo bundle for chain '${chainName}'. Run the AlgoVoi audit ` +
|
|
198
|
+
`verifier against this file with --signing-key '${signingKey}' to verify.`,
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
// Sign: HMAC-SHA256 over JCS canonical of bundle minus signature.
|
|
202
|
+
const inner: Record<string, unknown> = {};
|
|
203
|
+
for (const [k, v] of Object.entries(bundle)) {
|
|
204
|
+
if (k !== 'bundle_signature') inner[k] = v;
|
|
205
|
+
}
|
|
206
|
+
const digest = createHmac('sha256', signingKey).update(canonicaliseToBytes(inner)).digest('hex');
|
|
207
|
+
bundle.bundle_signature = {
|
|
208
|
+
algorithm: 'HMAC-SHA256',
|
|
209
|
+
canonicalisation: 'RFC 8785',
|
|
210
|
+
key_id: keyId,
|
|
211
|
+
hex: digest,
|
|
212
|
+
};
|
|
213
|
+
return bundle;
|
|
214
|
+
}
|