@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/README.md +44 -28
- package/dist/aga-verify.mjs +214 -126
- package/example-bundle.json +86 -258
- package/package.json +3 -6
- package/verify.ts +275 -198
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
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
-
*
|
|
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
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
80
|
-
|
|
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
|
-
|
|
84
|
-
|
|
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
|
|
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
|
|
90
|
-
|
|
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
|
-
|
|
96
|
-
|
|
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 (
|
|
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.
|
|
153
|
-
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
175
|
-
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
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
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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(`
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
console.log(`\nOVERALL: ${result.
|
|
234
|
-
if (result.
|
|
235
|
-
console.log('
|
|
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.
|
|
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
|
}
|