@attested-intelligence/aga-verify 1.0.0 → 2.0.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/verify.ts CHANGED
@@ -1,240 +1,317 @@
1
1
  /**
2
- * AGA Independent Verifier
2
+ * AGA Independent Verifier (@attested-intelligence/aga-verify)
3
3
  *
4
- * Standalone verification of AGA Evidence Bundles using ONLY standard
5
- * cryptographic libraries. This verifier imports ZERO modules from the
6
- * AGA codebase (../src/).
4
+ * Standalone, dependency-FREE verification of canonical AGA SEP Evidence Bundles.
5
+ * Imports ZERO modules from the AGA codebase and ZERO third-party packages — only
6
+ * Node's built-in crypto (Ed25519 + SHA-256). The trust chain dead-ends at the
7
+ * Node runtime and the gateway public key you pin; nothing else.
7
8
  *
8
- * Implements the full 4-step verification process:
9
- * 1. Verify artifact signature (Ed25519 over RFC 8785 canonical JSON)
10
- * 2. Verify each receipt signature (Ed25519)
11
- * 3. Verify Merkle inclusion proofs (structural metadata leaf hashes vs checkpoint root)
12
- * 4. (Optional) Verify checkpoint anchor
9
+ * Normative construction: aga-receipt-spec/CANONICAL_CONSTRUCTION_v2.md.
10
+ * What a PASS proves: every PRESENT receipt is authentic, correctly chained,
11
+ * Merkle-included under a gateway-SIGNED checkpoint, and (when --pubkey is given)
12
+ * issued by that pinned key. A PASS does NOT prove non-omission it cannot show
13
+ * the signer logged every action it took.
13
14
  *
14
- * Steps 1-3 work fully offline. Step 4 is optional.
15
+ * CLI: npx @attested-intelligence/aga-verify <bundle.json> [--pubkey <64-hex>]
16
+ * Lib: import { verifyEvidenceBundle } from '@attested-intelligence/aga-verify'
15
17
  *
16
- * Attested Intelligence Holdings LLC
18
+ * Attested Intelligence Holdings LLC · MIT
17
19
  */
18
- import * as ed from '@noble/ed25519';
19
- import { sha512 } from '@noble/hashes/sha512';
20
- import { sha256 } from '@noble/hashes/sha256';
21
- import { bytesToHex, hexToBytes } from '@noble/hashes/utils';
22
-
23
- // ── Ed25519 setup ────────────────────────────────────────────
24
- ed.etc.sha512Sync = (...m: Uint8Array[]) => {
25
- const total = m.reduce((n, a) => n + a.length, 0);
26
- const buf = new Uint8Array(total);
27
- let off = 0;
28
- for (const a of m) { buf.set(a, off); off += a.length; }
29
- return sha512(buf);
30
- };
31
-
32
- const enc = new TextEncoder();
33
-
34
- // ── Types (reimplemented, no AGA imports) ────────────────────
35
-
20
+ import { createHash, createPublicKey, verify as edVerify } from 'node:crypto';
21
+
22
+ const ALGORITHM = 'Ed25519-SHA256-JCS';
23
+ const SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex'); // Ed25519 SPKI DER prefix
24
+ const MAX_CANON_DEPTH = 100; // anti-DoS: deeper nesting fails closed, never stack-overflows
25
+
26
+ // EXACT canonical field sets (single source of truth, mirrors src/sep/receipt.ts + checkpoint.ts).
27
+ // The strict-schema floor requires an object to carry EXACTLY these keys — no extra, no missing,
28
+ // no renamed, no duplicate, no "__proto__"-injected key (Object.keys counts a JSON-parsed
29
+ // "__proto__" as an own key, so a 16th/8th key fails the count). Every conformant stack rejects
30
+ // the identical bundles.
31
+ const SEP_RECEIPT_FIELDS = [
32
+ 'receipt_id', 'receipt_version', 'algorithm', 'timestamp', 'request_id',
33
+ 'method', 'tool_name', 'decision', 'reason', 'policy_reference',
34
+ 'arguments_hash', 'previous_receipt_hash', 'gateway_id', 'public_key', 'signature',
35
+ ] as const;
36
+ const SEP_CHECKPOINT_FIELDS = [
37
+ 'algorithm', 'gateway_id', 'generated_at', 'head_leaf_hash', 'leaf_count', 'merkle_root', 'signature',
38
+ ] as const;
39
+
40
+ export interface VerificationStep { name: string; ok: boolean; }
36
41
  export interface VerificationResult {
37
- step1_artifact_sig: boolean;
38
- step2_receipt_sigs: boolean;
39
- step3_merkle_proofs: boolean;
40
- step4_anchor: 'VERIFIED' | 'SKIPPED';
41
- overall: boolean;
42
+ verdict: 'VERIFIED' | 'FAILED';
43
+ /** True only when a key was pinned AND the bundle key matched it. */
44
+ issuerVerified: boolean;
45
+ /** Whether a key was supplied to check provenance. */
46
+ pinned: boolean;
47
+ steps: VerificationStep[];
42
48
  errors: string[];
43
- details: {
44
- receipt_results: boolean[];
45
- proof_results: boolean[];
46
- };
47
49
  }
48
50
 
49
- interface MerkleProof {
50
- leafHash: string;
51
- leafIndex: number;
52
- siblings: Array<{ hash: string; position: 'left' | 'right' }>;
53
- root: string;
51
+ interface SepReceipt extends Record<string, unknown> { signature: string; previous_receipt_hash?: string; request_id?: string | number; timestamp?: string; gateway_id?: string; public_key?: string; }
52
+ interface SepProof { leaf_hash: string; leaf_index: number; siblings: string[]; directions: Array<'left' | 'right'>; merkle_root: string; }
53
+ interface SepCheckpoint extends Record<string, unknown> { signature: string; merkle_root: string; leaf_count: number; head_leaf_hash: string; gateway_id?: string; algorithm?: string; }
54
+ interface SepBundle {
55
+ algorithm: string; public_key: string; gateway_id?: string; merkle_root?: string;
56
+ receipts: SepReceipt[]; merkle_proofs: SepProof[]; checkpoint: SepCheckpoint;
54
57
  }
55
58
 
56
- interface EvidenceBundle {
57
- artifact: Record<string, unknown> & { signature: string; issuer_identifier: string };
58
- receipts: Array<Record<string, unknown> & { portal_signature: string; receipt_id: string }>;
59
- merkle_proofs: MerkleProof[];
60
- checkpoint_reference: { merkle_root: string; [key: string]: unknown };
61
- public_key: string;
62
- bundle_signature: string;
63
- verification_tier?: string;
59
+ // ── primitives (CANONICAL_CONSTRUCTION_v2.md §1,§3,§5) ────────────────────────
60
+ // Lone (unpaired) UTF-16 surrogate detector: a high surrogate U+D800..U+DBFF not immediately
61
+ // followed by a low surrogate, or a low surrogate not immediately preceded by a high surrogate.
62
+ // Such a string is INVALID Unicode that Go and Python cannot UTF-8-encode (they reject the
63
+ // bundle); JS would otherwise silently map it to U+FFFD and self-consistently VERIFY — a cross-
64
+ // stack split. We throw a CONTROLLED error so the never-throw try/catch -> FAILED on all six.
65
+ // Valid surrogate PAIRS (astral chars / emoji) are NOT matched and canonicalize unchanged.
66
+ const LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
67
+
68
+ // Depth-bounded JCS-profile canon: input nested beyond MAX_CANON_DEPTH throws a CONTROLLED
69
+ // error well before a native stack overflow (anti-DoS). verifySepBundle's try/catch turns that
70
+ // into a FAILED verdict — a depth bomb can never crash the verifier. Mirrors src/sep/canonical.ts.
71
+ function canon(o: unknown): string {
72
+ const rec = (v: unknown, depth: number): string => {
73
+ if (depth > MAX_CANON_DEPTH) throw new Error(`canon: input nesting exceeds ${MAX_CANON_DEPTH} levels`);
74
+ if (typeof v === 'string' && LONE_SURROGATE.test(v)) throw new Error('canonicalize: lone surrogate');
75
+ if (v === null || typeof v !== 'object') return JSON.stringify(v);
76
+ if (Array.isArray(v)) return '[' + v.map((x) => rec(x, depth + 1)).join(',') + ']';
77
+ const m = v as Record<string, unknown>;
78
+ return '{' + Object.keys(m).sort().map((k) => JSON.stringify(k) + ':' + rec(m[k], depth + 1)).join(',') + '}';
79
+ };
80
+ return rec(o, 0);
64
81
  }
65
82
 
66
- // ── Crypto helpers (reimplemented from scratch) ──────────────
67
-
68
- function deepSortKeys(obj: unknown): unknown {
69
- if (obj === null || obj === undefined || typeof obj !== 'object') return obj;
70
- if (Array.isArray(obj)) return obj.map(deepSortKeys);
71
- if (obj instanceof Uint8Array) return obj;
72
- const sorted: Record<string, unknown> = {};
73
- for (const key of Object.keys(obj as Record<string, unknown>).sort()) {
74
- sorted[key] = deepSortKeys((obj as Record<string, unknown>)[key]);
75
- }
76
- return sorted;
83
+ /**
84
+ * Strict-schema floor: the object must carry EXACTLY the canonical fields — no extra, unknown,
85
+ * missing, or "__proto__"-injected keys. own-key-count === fields.length AND every canonical
86
+ * field present as an own property. Mirrors src/sep/verify.ts hasExactKeys exactly.
87
+ */
88
+ function hasExactKeys(o: unknown, fields: readonly string[]): boolean {
89
+ if (!o || typeof o !== 'object' || Array.isArray(o)) return false;
90
+ const keys = Object.keys(o as Record<string, unknown>);
91
+ return keys.length === fields.length && fields.every((f) => Object.prototype.hasOwnProperty.call(o, f));
77
92
  }
78
-
79
- function canonicalize(obj: unknown): string {
80
- return JSON.stringify(deepSortKeys(obj));
93
+ const sha = (b: Buffer): string => createHash('sha256').update(b).digest('hex');
94
+ const u8 = (s: string): Buffer => Buffer.from(s, 'utf8');
95
+ const leafHash = (r: unknown): string => sha(u8(canon(r))); // no-prefix, full receipt
96
+ const nodeHash = (l: string, r: string): string => sha(Buffer.concat([Buffer.from(l, 'hex'), Buffer.from(r, 'hex')])); // raw bytes
97
+ const stripField = (o: Record<string, unknown>, f: string): Record<string, unknown> =>
98
+ Object.fromEntries(Object.entries(o).filter(([k]) => k !== f));
99
+ const isHex = (h: unknown, n: number): h is string => typeof h === 'string' && new RegExp(`^[0-9a-f]{${n}}$`).test(h);
100
+
101
+ // Ed25519 points of order dividing 8 — a signature is trivially forgeable under such a key
102
+ // (e.g. the identity point admits R=A,S=0 universal forgery), so reject them. 10 canonical
103
+ // encodings; non-canonical (y >= p) caught by isCanonicalY. Mirrors src/sep/crypto.ts and the
104
+ // reference verifier so every conformant stack renders identical verdicts.
105
+ const SMALL_ORDER_KEYS = new Set<string>([
106
+ '00'.repeat(32), '00'.repeat(31) + '80',
107
+ '01' + '00'.repeat(31), '01' + '00'.repeat(30) + '80',
108
+ 'ec' + 'ff'.repeat(30) + '7f', 'ec' + 'ff'.repeat(31),
109
+ '26e8958fc2b227b045c3f489f2ef98f0d5dfac05d3c63339b13802886d53fc05',
110
+ 'c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac037a',
111
+ '26e8958fc2b227b045c3f489f2ef98f0d5dfac05d3c63339b13802886d53fc85',
112
+ 'c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac03fa',
113
+ ]);
114
+ const ED25519_P = (1n << 255n) - 19n;
115
+ function isCanonicalY(hex: string): boolean {
116
+ const b = Buffer.from(hex, 'hex');
117
+ let y = 0n;
118
+ for (let i = 0; i < 32; i++) y |= BigInt(i === 31 ? (b[i] & 0x7f) : b[i]) << BigInt(8 * i);
119
+ return y < ED25519_P;
81
120
  }
82
121
 
83
- function sha256Hex(data: string): string {
84
- return bytesToHex(sha256(enc.encode(data)));
122
+ // ── canonical SEP timestamp (CANONICAL_CONSTRUCTION_v2.md §6.3) ────────────────
123
+ // The mandated canonical form is EXACTLY what Date.prototype.toISOString() emits:
124
+ // fixed-width zero-padded UTC with exactly 3 fractional digits and a literal 'Z'.
125
+ // Validation uses NO date library — a literal [0-9] class (NOT \d, which matches
126
+ // Unicode digits) plus PURE INTEGER calendar-range arithmetic, so every verifier
127
+ // (JS/Go/Python) reaches a byte-identical verdict. Ordering is a plain lexicographic
128
+ // string compare because the form is fixed-width zero-padded UTC.
129
+ const TS_CANONICAL = /^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{3}Z$/;
130
+ const isLeap = (y: number): boolean => y % 4 === 0 && (y % 100 !== 0 || y % 400 === 0);
131
+ const daysInMonth = (y: number, m: number): number =>
132
+ [31, isLeap(y) ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][m - 1];
133
+ function isCanonicalTimestamp(ts: unknown): ts is string {
134
+ if (typeof ts !== 'string' || !TS_CANONICAL.test(ts)) return false;
135
+ const year = parseInt(ts.slice(0, 4), 10);
136
+ const month = parseInt(ts.slice(5, 7), 10);
137
+ const day = parseInt(ts.slice(8, 10), 10);
138
+ const hour = parseInt(ts.slice(11, 13), 10);
139
+ const minute = parseInt(ts.slice(14, 16), 10);
140
+ const second = parseInt(ts.slice(17, 19), 10);
141
+ if (month < 1 || month > 12) return false;
142
+ if (day < 1 || day > daysInMonth(year, month)) return false;
143
+ if (hour > 23) return false; // hour parsed from [0-9]{2} is always >= 0
144
+ if (minute > 59) return false;
145
+ if (second > 59) return false;
146
+ return true;
85
147
  }
86
148
 
87
- function verifyEd25519(sigBase64: string, message: string, publicKeyHex: string): boolean {
149
+ function wellFormedKey(hex: unknown): hex is string {
150
+ if (!isHex(hex, 64)) return false;
151
+ if (SMALL_ORDER_KEYS.has(hex) || !isCanonicalY(hex)) return false;
152
+ try { createPublicKey({ key: Buffer.concat([SPKI_PREFIX, Buffer.from(hex, 'hex')]), format: 'der', type: 'spki' }); return true; }
153
+ catch { return false; }
154
+ }
155
+ function sigOk(pubHex: string, msg: string, sigHex: unknown): boolean {
156
+ if (!wellFormedKey(pubHex) || !isHex(sigHex, 128) || /^0+$/.test(sigHex)) return false;
88
157
  try {
89
- const sig = new Uint8Array(Buffer.from(sigBase64, 'base64'));
90
- const pk = hexToBytes(publicKeyHex);
91
- return ed.verify(sig, enc.encode(message), pk);
158
+ const pk = createPublicKey({ key: Buffer.concat([SPKI_PREFIX, Buffer.from(pubHex, 'hex')]), format: 'der', type: 'spki' });
159
+ return edVerify(null, u8(msg), pk, Buffer.from(sigHex, 'hex'));
92
160
  } catch { return false; }
93
161
  }
94
162
 
95
- function merkleParentHash(left: string, right: string): string {
96
- return sha256Hex(left + right);
97
- }
98
-
99
- // ── Step 1: Verify artifact signature (Ed25519) ─────────────
100
-
101
- export function verifyArtifactSignature(artifact: EvidenceBundle['artifact']): boolean {
102
- const { signature, ...unsigned } = artifact;
103
- const canonical = canonicalize(unsigned);
104
- return verifyEd25519(signature, canonical, artifact.issuer_identifier);
105
- }
106
-
107
- // ── Step 2: Verify each receipt signature (Ed25519) ──────────
108
-
109
- export function verifyReceiptSignatures(receipts: EvidenceBundle['receipts'], portalPublicKey: string): boolean[] {
110
- return receipts.map(receipt => {
111
- const { portal_signature, ...unsigned } = receipt;
112
- const canonical = canonicalize(unsigned);
113
- return verifyEd25519(portal_signature, canonical, portalPublicKey);
114
- });
115
- }
116
-
117
- // ── Step 3: Verify Merkle inclusion proofs ───────────────────
118
-
119
- export function verifyMerkleProofs(proofs: MerkleProof[], checkpointRoot: string): boolean[] {
120
- return proofs.map(proof => {
121
- let hash = proof.leafHash;
122
- for (const sibling of proof.siblings) {
123
- hash = sibling.position === 'left'
124
- ? merkleParentHash(sibling.hash, hash)
125
- : merkleParentHash(hash, sibling.hash);
126
- }
127
- return hash === checkpointRoot;
128
- });
129
- }
130
-
131
- // ── Step 4 (optional): Verify checkpoint anchor ─────────────
132
-
133
- export function verifyCheckpointAnchor(_checkpoint: Record<string, unknown>): 'VERIFIED' | 'SKIPPED' {
134
- // Offline mode - no network access to verify on-chain anchor
135
- return 'SKIPPED';
136
- }
137
-
138
- // ── Main entry point ─────────────────────────────────────────
139
-
140
- // validateBundleShape returns a human-readable reason if the parsed value is
141
- // not a recognizable AGA evidence bundle, or null if the required fields are
142
- // present. This lets the verifier fail cleanly on a wrong-format file instead
143
- // of throwing on a missing field.
144
- function validateBundleShape(b: any): string | null {
163
+ /** Returns a human-readable reason if `b` is not a canonical SEP bundle, else null. */
164
+ function validateShape(b: any): string | null {
145
165
  if (b === null || typeof b !== 'object' || Array.isArray(b)) return 'not a JSON object';
146
- if (typeof b.artifact !== 'object' || b.artifact === null) return 'missing "artifact" object';
147
- if (typeof b.artifact.signature !== 'string') return 'missing "artifact.signature"';
148
- if (typeof b.artifact.issuer_identifier !== 'string') return 'missing "artifact.issuer_identifier"';
149
- if (!Array.isArray(b.receipts)) return 'missing "receipts" array';
166
+ if (b.algorithm !== ALGORITHM) return `algorithm must be "${ALGORITHM}"`;
150
167
  if (typeof b.public_key !== 'string') return 'missing "public_key"';
168
+ if (!Array.isArray(b.receipts)) return 'missing "receipts" array';
151
169
  if (!Array.isArray(b.merkle_proofs)) return 'missing "merkle_proofs" array';
152
- if (typeof b.checkpoint_reference !== 'object' || b.checkpoint_reference === null ||
153
- typeof b.checkpoint_reference.merkle_root !== 'string') {
154
- return 'missing "checkpoint_reference.merkle_root"';
155
- }
170
+ if (typeof b.checkpoint !== 'object' || b.checkpoint === null || typeof b.checkpoint.signature !== 'string')
171
+ return 'missing signed "checkpoint"';
156
172
  return null;
157
173
  }
158
174
 
159
- export function verifyEvidenceBundle(bundleJson: string): VerificationResult {
160
- const errors: string[] = [];
161
- let bundle: EvidenceBundle;
162
-
175
+ /** Verify a parsed canonical SEP Evidence Bundle (CANONICAL_CONSTRUCTION_v2.md §6). */
176
+ export function verifySepBundle(bundle: SepBundle, expectedPublicKey?: string): VerificationResult {
177
+ // Robust contract (D6): a malformed/hostile bundle — a depth bomb that overflows the
178
+ // depth-bounded canon, a type confusion, a missing structure, a non-string where a string
179
+ // is expected — yields a FAILED verdict, NEVER a thrown exception / crash / stack overflow.
180
+ const pinned = isHex(expectedPublicKey, 64);
163
181
  try {
164
- bundle = JSON.parse(bundleJson);
165
- } catch {
166
- return {
167
- step1_artifact_sig: false, step2_receipt_sigs: false,
168
- step3_merkle_proofs: false, step4_anchor: 'SKIPPED',
169
- overall: false, errors: ['Failed to parse bundle JSON'],
170
- details: { receipt_results: [], proof_results: [] },
171
- };
182
+ const steps: VerificationStep[] = [];
183
+ const errors: string[] = [];
184
+ const add = (name: string, ok: boolean, err?: string) => { steps.push({ name, ok }); if (!ok && err) errors.push(err); return ok; };
185
+
186
+ const receipts = Array.isArray((bundle as any)?.receipts) ? (bundle as any).receipts : [];
187
+ const proofs = Array.isArray((bundle as any)?.merkle_proofs) ? (bundle as any).merkle_proofs : [];
188
+ const pub = (bundle as any)?.public_key;
189
+
190
+ // §6.1 structural floor — incl. STRICT receipt schema (D1): every receipt must carry EXACTLY
191
+ // the 15 canonical fields (rejects extra/unknown/missing/renamed and "__proto__" injection).
192
+ add('structural',
193
+ (bundle as any)?.algorithm === ALGORITHM && wellFormedKey(pub)
194
+ && receipts.length > 0 && proofs.length === receipts.length
195
+ && receipts.every((r: unknown) => hasExactKeys(r, SEP_RECEIPT_FIELDS)),
196
+ 'structural floor failed (algorithm/key/receipt-count/receipt-schema)');
197
+
198
+ // §6.2 receipt signatures
199
+ add('receipt_signatures',
200
+ receipts.length > 0 && receipts.every((r: SepReceipt) => sigOk(pub, canon(stripField(r, 'signature')), r.signature)),
201
+ 'one or more receipt signatures invalid');
202
+
203
+ // §6.3 chain + ordering — CANONICAL timestamps (D3/T1): every receipt's timestamp must match
204
+ // the canonical .sssZ form AND have in-range calendar fields (pure-integer check, NO date
205
+ // library), and timestamps must be NON-DECREASING across the chain.
206
+ const leaves = receipts.map(leafHash);
207
+ let chain = receipts.length > 0;
208
+ let prevTs = '';
209
+ for (let i = 0; i < receipts.length; i++) {
210
+ if ((receipts[i].previous_receipt_hash || '') !== (i === 0 ? '' : leaves[i - 1])) chain = false;
211
+ // request_id is the upstream request id (string|number|null), informational, NOT a sequence
212
+ // counter. Ordering is enforced cryptographically by the chain linkage above; timestamps add
213
+ // a strict non-decreasing check. A non-canonical ts is a FAILURE, not silently skipped.
214
+ // Because the form is fixed-width zero-padded UTC, ordering is a plain lexicographic string
215
+ // compare (NOT epoch/Date conversion); EQUAL is allowed. The first receipt only needs validity.
216
+ const ts = receipts[i].timestamp;
217
+ if (!isCanonicalTimestamp(ts)) chain = false;
218
+ else { if (i > 0 && ts < prevTs) chain = false; prevTs = ts; }
172
219
  }
173
-
174
- const shapeError = validateBundleShape(bundle);
175
- if (shapeError) {
220
+ add('chain_and_ordering', chain, 'chain linkage, non-canonical timestamp, or ordering broken');
221
+
222
+ // §6.4 merkle: recompute leaf from content, walk proof, per-proof root, single root, bijection
223
+ let root: string | null = null, merkle = proofs.length === receipts.length && proofs.length > 0;
224
+ const seen = new Set<number>();
225
+ for (const p of proofs) {
226
+ seen.add(p.leaf_index);
227
+ if (receipts[p.leaf_index] === undefined || leaves[p.leaf_index] !== p.leaf_hash) merkle = false;
228
+ let cur = p.leaf_hash;
229
+ // C1: directions is UNSIGNED. Require it to be a well-formed array — same length as siblings and
230
+ // EVERY element exactly the literal "left" or "right". A rewritten token ("right"->"RIGHT") must
231
+ // FAIL the merkle step, not fall through an else/ternary to "right" and still walk to the root.
232
+ const sib: string[] = Array.isArray(p.siblings) ? p.siblings : [];
233
+ const dir: string[] = Array.isArray(p.directions) ? p.directions : [];
234
+ if (dir.length !== sib.length || !dir.every((d) => d === 'left' || d === 'right')) merkle = false;
235
+ for (let j = 0; j < sib.length; j++) cur = dir[j] === 'left' ? nodeHash(sib[j], cur) : nodeHash(cur, sib[j]);
236
+ if (p.merkle_root !== cur) merkle = false; // D4: the proof's own claimed root must equal what it walks to
237
+ if (root === null) root = cur; else if (root !== cur) merkle = false;
238
+ }
239
+ const bijection = seen.size === receipts.length && [...seen].every((n) => Number.isInteger(n) && n >= 0 && n < receipts.length);
240
+ add('merkle_and_bijection', merkle && bijection, 'Merkle proof, per-proof root, leaf recompute, or index bijection failed');
241
+
242
+ // §6.5 mandatory signed checkpoint — STRICT schema (D2): EXACTLY the 7 canonical fields AND
243
+ // checkpoint.algorithm === ALGORITHM, then signature + root/count/head binding.
244
+ const cp = bundle.checkpoint as any;
245
+ let cpOk = false;
246
+ if (hasExactKeys(cp, SEP_CHECKPOINT_FIELDS)) {
247
+ cpOk = cp.algorithm === ALGORITHM
248
+ && sigOk(pub, canon(stripField(cp as Record<string, unknown>, 'signature')), cp.signature)
249
+ && root !== null && cp.merkle_root === root
250
+ && cp.leaf_count === receipts.length
251
+ && cp.head_leaf_hash === (leaves.length ? leaves[leaves.length - 1] : '');
252
+ }
253
+ add('signed_checkpoint', cpOk, 'signed checkpoint missing, mis-schema, wrong algorithm, or does not anchor the bundle');
254
+
255
+ // §6.5b ENVELOPE CONSISTENCY (D5): the per-receipt identity and the UNSIGNED envelope must agree
256
+ // with the signed/recomputed values, so nothing outside the signed objects can mislead a consumer
257
+ // that reads the envelope.
258
+ const cpGatewayId = (cp && typeof cp === 'object') ? cp.gateway_id : undefined;
259
+ const cpGeneratedAt = (cp && typeof cp === 'object') ? cp.generated_at : undefined;
260
+ add('envelope_consistency',
261
+ receipts.length > 0
262
+ && receipts.every((r: any) => r.public_key === pub) // every receipt signed under the bundle key
263
+ && receipts.every((r: any) => r.gateway_id === (bundle as any)?.gateway_id) // receipts <-> envelope gateway_id
264
+ && cpGatewayId === (bundle as any)?.gateway_id // checkpoint <-> envelope gateway_id
265
+ && cpGeneratedAt === (bundle as any)?.generated_at // T6: envelope generated_at <-> signed checkpoint generated_at
266
+ && root !== null && (bundle as any)?.merkle_root === root, // envelope merkle_root <-> recomputed
267
+ 'envelope gateway_id/generated_at/merkle_root or a receipt public_key/gateway_id disagrees with the signed/recomputed values');
268
+
269
+ // §6.6 provenance (only when pinned)
270
+ const issuerVerified = pinned && pub === expectedPublicKey;
271
+ if (pinned) add('gateway_key_match', issuerVerified, 'bundle key does not match the pinned gateway key');
272
+
273
+ return { verdict: steps.every((s) => s.ok) ? 'VERIFIED' : 'FAILED', issuerVerified, pinned, steps, errors };
274
+ } catch (e) {
176
275
  return {
177
- step1_artifact_sig: false, step2_receipt_sigs: false,
178
- step3_merkle_proofs: false, step4_anchor: 'SKIPPED',
179
- overall: false, errors: [`unrecognized evidence-bundle format: ${shapeError}`],
180
- details: { receipt_results: [], proof_results: [] },
276
+ verdict: 'FAILED', issuerVerified: false, pinned,
277
+ steps: [{ name: 'verifier_exception', ok: false }],
278
+ errors: [`verifier rejected a malformed bundle: ${String(e)}`],
181
279
  };
182
280
  }
183
-
184
- // Step 1: Artifact signature
185
- const step1 = verifyArtifactSignature(bundle.artifact);
186
- if (!step1) errors.push('Artifact signature verification failed');
187
-
188
- // Step 2: Receipt signatures
189
- const receiptResults = verifyReceiptSignatures(bundle.receipts, bundle.public_key);
190
- const step2 = receiptResults.every(r => r);
191
- receiptResults.forEach((r, i) => {
192
- if (!r) errors.push(`Receipt ${bundle.receipts[i].receipt_id} signature failed`);
193
- });
194
-
195
- // Step 3: Merkle inclusion proofs
196
- const proofResults = verifyMerkleProofs(bundle.merkle_proofs, bundle.checkpoint_reference.merkle_root);
197
- const step3 = proofResults.length === 0 ? true : proofResults.every(r => r);
198
- proofResults.forEach((r, i) => {
199
- if (!r) errors.push(`Merkle proof ${i} failed`);
200
- });
201
-
202
- // Step 4: Checkpoint anchor
203
- const step4 = verifyCheckpointAnchor(bundle.checkpoint_reference as Record<string, unknown>);
204
-
205
- return {
206
- step1_artifact_sig: step1,
207
- step2_receipt_sigs: step2,
208
- step3_merkle_proofs: step3,
209
- step4_anchor: step4,
210
- overall: step1 && step2 && step3,
211
- errors,
212
- details: { receipt_results: receiptResults, proof_results: proofResults },
213
- };
214
281
  }
215
282
 
216
- // ── CLI mode ─────────────────────────────────────────────────
283
+ /** Parse + shape-check + verify. Fails cleanly (no throw) on bad JSON or wrong format. */
284
+ export function verifyEvidenceBundle(bundleJson: string, expectedPublicKey?: string): VerificationResult {
285
+ let parsed: unknown;
286
+ try { parsed = JSON.parse(bundleJson); }
287
+ catch { return { verdict: 'FAILED', issuerVerified: false, pinned: false, steps: [], errors: ['failed to parse bundle JSON'] }; }
288
+ const shapeErr = validateShape(parsed);
289
+ if (shapeErr) return { verdict: 'FAILED', issuerVerified: false, pinned: false, steps: [], errors: [`unrecognized evidence-bundle format: ${shapeErr}`] };
290
+ return verifySepBundle(parsed as SepBundle, expectedPublicKey);
291
+ }
217
292
 
293
+ // ── CLI ──────────────────────────────────────────────────────────────────────
218
294
  if (typeof process !== 'undefined' && process.argv[1]?.includes('verify')) {
219
295
  const { readFileSync } = await import('node:fs');
220
- const bundlePath = process.argv[2];
221
- if (!bundlePath) {
222
- console.error('Usage: npx tsx verify.ts <bundle.json>');
223
- process.exit(1);
224
- }
225
- const bundleJson = readFileSync(bundlePath, 'utf-8');
226
- const result = verifyEvidenceBundle(bundleJson);
227
-
296
+ const args = process.argv.slice(2);
297
+ const file = args.find((a) => !a.startsWith('--'));
298
+ const pk = args.includes('--pubkey') ? args[args.indexOf('--pubkey') + 1] : undefined;
299
+ if (!file) { console.error('Usage: aga-verify <bundle.json> [--pubkey <64-hex-gateway-key>]'); process.exit(2); }
300
+ // D8: any failure on the read/parse/verify path => a FAILED line + exit 1 (never an uncaught
301
+ // stack trace, never exit 2). Missing-arg usage above is the only non-0/1 exit.
302
+ let raw: string;
303
+ try { raw = readFileSync(file, 'utf-8'); }
304
+ catch (e) { console.log('\nAGA Independent Verifier\n'); console.log('OVERALL: FAILED (could not read bundle file)'); console.log(`\nErrors:\n - ${String(e)}`); process.exit(1); }
305
+ const result = verifyEvidenceBundle(raw, pk);
228
306
  console.log('\nAGA Independent Verifier\n');
229
- console.log(`Step 1 - Artifact signature: ${result.step1_artifact_sig ? 'PASS' : 'FAIL'}`);
230
- console.log(`Step 2 - Receipt signatures: ${result.step2_receipt_sigs ? 'PASS' : 'FAIL'} (${result.details.receipt_results.filter(r => r).length}/${result.details.receipt_results.length})`);
231
- console.log(`Step 3 - Merkle inclusion proofs: ${result.step3_merkle_proofs ? 'PASS' : 'FAIL'} (${result.details.proof_results.filter(r => r).length}/${result.details.proof_results.length})`);
232
- console.log(`Step 4 - Checkpoint anchor: ${result.step4_anchor}`);
233
- console.log(`\nOVERALL: ${result.overall ? 'VERIFIED' : 'FAILED'}`);
234
- if (result.errors.length) {
235
- console.log('\nErrors:');
236
- result.errors.forEach(e => console.log(` - ${e}`));
307
+ for (const s of result.steps) console.log(` ${s.ok ? 'PASS' : 'FAIL'} ${s.name}`);
308
+ // Suffix reflects the VERDICT: only a VERIFIED bundle gets a provenance/integrity tag; a FAILED
309
+ // bundle prints just "FAILED" (never "FAILED (provenance verified)").
310
+ const prov = result.verdict === 'VERIFIED' ? (result.pinned ? ' (provenance verified)' : ' (integrity only — no --pubkey given)') : '';
311
+ console.log(`\nOVERALL: ${result.verdict}${prov}`);
312
+ if (!result.pinned && result.verdict === 'VERIFIED') {
313
+ console.log('NOTE: integrity + self-consistency proven, but NOT provenance. Re-run with --pubkey <gateway-key> to prove WHO issued it.');
237
314
  }
238
-
239
- process.exit(result.overall ? 0 : 1);
315
+ if (result.errors.length) { console.log('\nErrors:'); result.errors.forEach((e) => console.log(` - ${e}`)); }
316
+ process.exit(result.verdict === 'VERIFIED' ? 0 : 1);
240
317
  }