@sanning/proof 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,229 @@
1
+ // Agent inclusion-proof bundle verification.
2
+ //
3
+ // The `sanning.agent.proof/v1` bundle is the agent's ~1.4 KB self-contained
4
+ // inclusion proof: a signed checkpoint envelope (sanning.agent/v1, INLINE payload)
5
+ // plus a single leaf object and its RFC 9162 audit path. Verifying it is the
6
+ // inverse of `sanningd proof`. The CLI sniffs `spec_version` and routes here so
7
+ // ONE verifier covers both the agent (this) and the anchor SDK (evidence.ts).
8
+ //
9
+ // Algorithm:
10
+ // 1. Verify the checkpoint envelope (signature + inline payload_hash bind).
11
+ // 2. leaf_hash = SHA-256(0x00 || JCS(leaf)).
12
+ // 3. Walk audit_path with leaf_index + leaf_count (from the checkpoint
13
+ // payload) to reconstruct the root.
14
+ // 4. Confirm the reconstructed root == checkpoint_envelope.payload.merkle_root.
15
+ // 5. (optional) Re-fetch checkpoint_tx_id from a gateway and byte-compare.
16
+
17
+ import { hexToBytes, utf8 } from "./crypto.js";
18
+ import { leafHash, verifyInclusion } from "./merkle.js";
19
+ import { MAX_CANONICAL_DEPTH, exceedsDepth, jcs, verifyEnvelope } from "./verifier.js";
20
+ import type { Envelope } from "./types.js";
21
+
22
+ export const AGENT_PROOF_SPEC_PREFIX = "sanning.agent.proof/v1";
23
+
24
+ export interface AgentProofBundle {
25
+ spec_version: string;
26
+ checkpoint_envelope: Envelope;
27
+ checkpoint_tx_id: string;
28
+ leaf: Record<string, unknown>;
29
+ leaf_index: number;
30
+ audit_path: string[];
31
+ [k: string]: unknown;
32
+ }
33
+
34
+ export interface AgentProofResult {
35
+ status: "verified" | "failed" | "partial" | "malformed";
36
+ // The checkpoint envelope is authentic (signature + inline payload bind).
37
+ checkpointOk: boolean;
38
+ // The leaf's RFC 9162 audit path reconstructs the checkpoint's merkle_root.
39
+ inclusionOk: boolean;
40
+ // On-chain re-fetch matched (null when not requested).
41
+ onChainOk: boolean | null;
42
+ onChainChecked: boolean;
43
+ leafIndex: number;
44
+ leafCount: number | null;
45
+ checkpointTxId: string | null;
46
+ errors: string[];
47
+ }
48
+
49
+ export interface VerifyAgentProofOptions {
50
+ gateways?: string[];
51
+ fetchImpl?: typeof fetch;
52
+ }
53
+
54
+ function stringifyErr(e: unknown): string {
55
+ return e instanceof Error ? e.message : String(e);
56
+ }
57
+
58
+ function toHex(bytes: Uint8Array): string {
59
+ let out = "";
60
+ for (const x of bytes) out += x.toString(16).padStart(2, "0");
61
+ return out;
62
+ }
63
+
64
+ export function isAgentProofSpec(specVersion: unknown): boolean {
65
+ if (typeof specVersion !== "string") return false;
66
+ return specVersion === AGENT_PROOF_SPEC_PREFIX || specVersion.startsWith(`${AGENT_PROOF_SPEC_PREFIX}.`);
67
+ }
68
+
69
+ export async function verifyAgentProofBundle(
70
+ bundle: unknown,
71
+ options: VerifyAgentProofOptions = {},
72
+ ): Promise<AgentProofResult> {
73
+ const fail = (error: string): AgentProofResult => ({
74
+ status: "malformed",
75
+ checkpointOk: false,
76
+ inclusionOk: false,
77
+ onChainOk: null,
78
+ onChainChecked: false,
79
+ leafIndex: -1,
80
+ leafCount: null,
81
+ checkpointTxId: null,
82
+ errors: [error],
83
+ });
84
+
85
+ if (bundle === null || typeof bundle !== "object" || Array.isArray(bundle)) {
86
+ return fail("agent proof bundle is not a JSON object");
87
+ }
88
+ // Bound nesting before any canonicalization (checkpoint envelope + Merkle
89
+ // leaves are canonicalized recursively) — a fixed shared limit keeps this a
90
+ // `malformed` verdict in every kernel. See MAX_CANONICAL_DEPTH.
91
+ if (exceedsDepth(bundle, MAX_CANONICAL_DEPTH)) {
92
+ return fail(
93
+ `agent proof bundle nesting exceeds the ${MAX_CANONICAL_DEPTH}-level canonicalization depth bound`,
94
+ );
95
+ }
96
+ const b = bundle as AgentProofBundle;
97
+ if (!isAgentProofSpec(b.spec_version)) {
98
+ return fail(`unsupported agent-proof spec_version: ${JSON.stringify(b.spec_version)}`);
99
+ }
100
+ if (b.checkpoint_envelope === null || typeof b.checkpoint_envelope !== "object") {
101
+ return fail("agent proof bundle missing checkpoint_envelope");
102
+ }
103
+ if (b.leaf === null || typeof b.leaf !== "object") {
104
+ return fail("agent proof bundle missing leaf");
105
+ }
106
+ if (!Array.isArray(b.audit_path)) {
107
+ return fail("agent proof bundle missing audit_path[]");
108
+ }
109
+
110
+ const errors: string[] = [];
111
+
112
+ // Step 1: verify the checkpoint envelope (inline payload — no payloadBytes).
113
+ let checkpointOk = false;
114
+ let merkleRoot: string | undefined;
115
+ let leafCount: number | undefined;
116
+ try {
117
+ const res = await verifyEnvelope(b.checkpoint_envelope);
118
+ checkpointOk = res.ok && res.payloadHashOk === true;
119
+ if (!res.signatureOk) errors.push("checkpoint envelope signature failed");
120
+ if (res.payloadHashOk !== true) errors.push("checkpoint envelope payload binding failed");
121
+ const payload = (b.checkpoint_envelope.payload ?? {}) as {
122
+ merkle_root?: unknown;
123
+ leaf_count?: unknown;
124
+ };
125
+ if (typeof payload.merkle_root === "string") merkleRoot = payload.merkle_root;
126
+ if (typeof payload.leaf_count === "number") leafCount = payload.leaf_count;
127
+ if (merkleRoot === undefined) errors.push("checkpoint payload missing merkle_root");
128
+ if (leafCount === undefined) errors.push("checkpoint payload missing leaf_count");
129
+ } catch (e) {
130
+ errors.push(`checkpoint verify error: ${stringifyErr(e)}`);
131
+ }
132
+
133
+ // Steps 2-4: leaf hash, audit-path walk, root match.
134
+ let inclusionOk = false;
135
+ if (merkleRoot !== undefined && leafCount !== undefined) {
136
+ try {
137
+ const lh = await leafHash(utf8(jcs(b.leaf)));
138
+ inclusionOk = await verifyInclusion(
139
+ lh,
140
+ b.leaf_index,
141
+ leafCount,
142
+ b.audit_path.map((h) => hexToBytes(h)),
143
+ hexToBytes(merkleRoot),
144
+ );
145
+ if (!inclusionOk) errors.push("RFC 9162 inclusion proof did not reconstruct merkle_root");
146
+ } catch (e) {
147
+ errors.push(`inclusion verify error: ${stringifyErr(e)}`);
148
+ }
149
+ }
150
+
151
+ // Step 5: optional on-chain re-fetch.
152
+ const gateways = options.gateways ?? [];
153
+ const onChainChecked = gateways.length > 0;
154
+ let onChainOk: boolean | null = null;
155
+ let gatewayUnavailable = false;
156
+ if (onChainChecked) {
157
+ const fetchImpl = options.fetchImpl ?? (globalThis.fetch as typeof fetch | undefined);
158
+ if (typeof fetchImpl !== "function") {
159
+ gatewayUnavailable = true;
160
+ errors.push("on-chain re-fetch requested but no fetch implementation is available");
161
+ } else {
162
+ const outcome = await refetch(b.checkpoint_envelope, b.checkpoint_tx_id, gateways, fetchImpl);
163
+ onChainOk = outcome.ok;
164
+ if (outcome.unavailable) gatewayUnavailable = true;
165
+ if (outcome.error) errors.push(outcome.error);
166
+ }
167
+ }
168
+
169
+ let status: AgentProofResult["status"];
170
+ if (!checkpointOk || !inclusionOk || onChainOk === false) {
171
+ status = "failed";
172
+ } else if (gatewayUnavailable) {
173
+ status = "partial";
174
+ } else {
175
+ status = "verified";
176
+ }
177
+
178
+ return {
179
+ status,
180
+ checkpointOk,
181
+ inclusionOk,
182
+ onChainOk,
183
+ onChainChecked,
184
+ leafIndex: typeof b.leaf_index === "number" ? b.leaf_index : -1,
185
+ leafCount: leafCount ?? null,
186
+ checkpointTxId: typeof b.checkpoint_tx_id === "string" ? b.checkpoint_tx_id : null,
187
+ errors,
188
+ };
189
+ }
190
+
191
+ async function refetch(
192
+ envelope: Envelope,
193
+ txId: string,
194
+ gateways: string[],
195
+ fetchImpl: typeof fetch,
196
+ ): Promise<{ ok: boolean | null; unavailable: boolean; error?: string }> {
197
+ let expectedHex: string;
198
+ try {
199
+ expectedHex = toHex(utf8(jcs(envelope)));
200
+ } catch (e) {
201
+ return { ok: false, unavailable: false, error: `cannot canonicalize checkpoint envelope: ${stringifyErr(e)}` };
202
+ }
203
+ let anyReachable = false;
204
+ for (const gw of gateways) {
205
+ const url = `${gw.replace(/\/+$/, "")}/${txId}`;
206
+ try {
207
+ const resp = await fetchImpl(url);
208
+ if (!resp.ok) continue;
209
+ anyReachable = true;
210
+ const onChain = new Uint8Array(await resp.arrayBuffer());
211
+ if (toHex(onChain) === expectedHex) return { ok: true, unavailable: false };
212
+ try {
213
+ const parsed = JSON.parse(new TextDecoder().decode(onChain));
214
+ if (toHex(utf8(jcs(parsed))) === expectedHex) return { ok: true, unavailable: false };
215
+ } catch {
216
+ // fall through
217
+ }
218
+ return {
219
+ ok: false,
220
+ unavailable: false,
221
+ error: `on-chain bytes at ${txId} do not match the checkpoint envelope`,
222
+ };
223
+ } catch {
224
+ // try next gateway
225
+ }
226
+ }
227
+ if (anyReachable) return { ok: false, unavailable: false, error: `checkpoint ${txId} mismatched on every gateway` };
228
+ return { ok: null, unavailable: true, error: `checkpoint ${txId} unreachable on all gateways` };
229
+ }