@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.
- package/LICENSE +21 -0
- package/README.md +168 -0
- package/dist/agent-proof.d.ts +28 -0
- package/dist/agent-proof.js +188 -0
- package/dist/cli.d.ts +8 -0
- package/dist/cli.js +569 -0
- package/dist/compose.d.ts +19 -0
- package/dist/compose.js +124 -0
- package/dist/crypto.d.ts +16 -0
- package/dist/crypto.js +215 -0
- package/dist/evidence.d.ts +214 -0
- package/dist/evidence.js +1049 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +38 -0
- package/dist/merkle.d.ts +6 -0
- package/dist/merkle.js +114 -0
- package/dist/types.d.ts +34 -0
- package/dist/types.js +5 -0
- package/dist/verifier.d.ts +11 -0
- package/dist/verifier.js +321 -0
- package/package.json +59 -0
- package/src/agent-proof.ts +229 -0
- package/src/cli.ts +619 -0
- package/src/compose.ts +189 -0
- package/src/crypto.ts +243 -0
- package/src/evidence.ts +1491 -0
- package/src/index.ts +105 -0
- package/src/merkle.ts +125 -0
- package/src/types.ts +72 -0
- package/src/verifier.ts +340 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sanning Inc.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# @sanning/proof
|
|
2
|
+
|
|
3
|
+
Verify Sanning evidence-plane provenance records — **with no Sanning code or service in the trust path**.
|
|
4
|
+
|
|
5
|
+
This is the TypeScript verification kernel for the *Verifiable Event Envelope* family: the signed JSON records that producers such as the verification agent daemon and the MLflow plugin anchor permanently to Arweave. Given an envelope (fetched from any Arweave gateway), this package answers: *is it authentic, and does it commit to the bytes I'm holding?*
|
|
6
|
+
|
|
7
|
+
- **RFC 8785 (JCS)** canonicalization
|
|
8
|
+
- **SHA-256** payload binding (WebCrypto)
|
|
9
|
+
- **Ed25519** signature verification ([`@noble/ed25519`](https://github.com/paulmillr/noble-ed25519))
|
|
10
|
+
- **RFC 9162** binary Merkle inclusion proofs (checkpoint leaves)
|
|
11
|
+
- Zero config, two small dependencies, ESM, browser + Node ≥ 20
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm install @sanning/proof
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Verify an envelope
|
|
20
|
+
|
|
21
|
+
Fetch the raw transaction from any Arweave gateway and verify it client-side:
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
import { verifyEnvelope } from "@sanning/proof";
|
|
25
|
+
|
|
26
|
+
const env = await (await fetch("https://arweave.net/raw/<tx_id>")).json();
|
|
27
|
+
const result = await verifyEnvelope(env);
|
|
28
|
+
|
|
29
|
+
result.ok; // spec_version + payload_hash + Ed25519 all passed
|
|
30
|
+
result.signatureOk; // Ed25519 over the signed scope, against env.public_key
|
|
31
|
+
result.payloadHashOk; // SHA-256(JCS(payload)) === env.payload_hash
|
|
32
|
+
result.errors; // [] when ok — machine-readable reasons otherwise
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
`verifyEnvelope` never throws on hostile input — a malformed envelope (or a lying gateway) yields `ok: false` with reasons, not an exception.
|
|
36
|
+
|
|
37
|
+
## Bind an envelope to bytes you hold (reverse provenance)
|
|
38
|
+
|
|
39
|
+
The check that defeats a lying gateway: Arweave *tags* are unsigned search hints, so after finding a candidate envelope, confirm it actually commits to your artifact's hash:
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
import { sha256Hex, verifyEnvelope, contentHashes } from "@sanning/proof";
|
|
43
|
+
|
|
44
|
+
const myHash = await sha256Hex(fileBytes); // 64-char lowercase hex
|
|
45
|
+
|
|
46
|
+
const result = await verifyEnvelope(env, myHash);
|
|
47
|
+
result.contentHashOk; // true ⇔ the envelope commits to exactly these bytes
|
|
48
|
+
result.contentRole; // how it commits: e.g. the registered baseline,
|
|
49
|
+
// an observed (tampered) hash, …
|
|
50
|
+
|
|
51
|
+
contentHashes(env); // all content hashes an envelope commits to, by event type
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Verify a whole trace bundle — one command
|
|
55
|
+
|
|
56
|
+
The `@ar.io/anchor` SDK hands a producer a set of `InclusionReceipt`s and can serialize them into one portable, self-verifying `sanning.evidence/v1` bundle (`body_type: sanning.anchor.trace/v1`). Verify the entire bundle — every event's signature + payload binding + Merkle inclusion, all offline — with the bundled CLI:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
npx @sanning/proof verify trace-bundle.json
|
|
60
|
+
# also re-fetch each checkpoint on-chain to confirm it's anchored:
|
|
61
|
+
npx @sanning/proof verify trace-bundle.json https://arweave.net,https://permagate.io
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
It prints a per-event + rollup verdict and exits on a pinned code — `0` verified · `1` a real failure (bad signature / tamper / broken inclusion) · `2` malformed bundle · `3` gateway-unavailable when an on-chain re-fetch was requested. The producer's asserted `verdict` is shown but **never trusted** — the displayed verdict is always recomputed from the body. The same CLI also verifies the agent's `sanning.agent.proof/v1` inclusion bundle (it sniffs `spec_version`), so one command covers anchor + agent.
|
|
65
|
+
|
|
66
|
+
> The `verify` CLI is live in `@sanning/proof` ≥ 0.2.2 — `npx @sanning/proof verify <bundle> [gateways]`. `verifyEvidenceBundle` is the programmatic API.
|
|
67
|
+
|
|
68
|
+
Programmatically:
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
import { verifyEvidenceBundle } from "@sanning/proof";
|
|
72
|
+
|
|
73
|
+
const bundle = JSON.parse(await fs.readFile("trace-bundle.json", "utf8"));
|
|
74
|
+
const result = await verifyEvidenceBundle(bundle, { gateways: ["https://arweave.net"] });
|
|
75
|
+
|
|
76
|
+
result.status; // "verified" | "partial" | "failed" | "malformed" (recomputed)
|
|
77
|
+
result.signatureOk; // wrapper Ed25519 signature
|
|
78
|
+
result.bodyHashOk; // body_hash === SHA-256(JCS(body))
|
|
79
|
+
result.checkpoints; // per-checkpoint envelope / merkle-root / on-chain results
|
|
80
|
+
result.events; // per-event signature / payload-binding / inclusion results
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
A withheld `record_bytes` (external commitment, record not disclosed) leaves that event's payload binding **undetermined** — surfaced, never failed (the bundle stays cryptographically sound).
|
|
84
|
+
|
|
85
|
+
### …including the raw logs, when the holder discloses them
|
|
86
|
+
|
|
87
|
+
The checks above prove the *stamps*. To also confirm a holder's **raw logs are the bytes whose hash was anchored**, the verifier closes the final `rawLog → content_hash` link: `SHA-256(disclosed bytes)` must equal the `content_hash` the event's record committed to.
|
|
88
|
+
|
|
89
|
+
A bundle a producer built with disclosure carries the bytes **in the signed body** (`events[].content`), so they're checked automatically — no flag, and the disclosure is itself covered by the wrapper signature:
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
npx @sanning/proof verify trace-bundle.json # in-body content verified inline
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
If the logs travel **alongside** a minimal bundle instead, pass them with `--logs` — a JSON object mapping `event_id` → bytes, where an even-length lowercase-hex string is read as hex and anything else as UTF-8 text:
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
npx @sanning/proof verify trace-bundle.json --logs logs.json
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Either way each event gains a `contentOk`: **`true`** match · **`false`** mismatch (the event *and* the rollup fail, exit `1`) · **`null`** undetermined — nothing disclosed, or no committed `content_hash` (a withheld record); never a failure, mirroring the payload-binding rule. The CLI prints a `logs ✓/✗/~` mark per event and a `logs: N/M disclosed verified` line.
|
|
102
|
+
|
|
103
|
+
Programmatically the same side input is `content`, keyed by `event_id` — a `Uint8Array` is used as-is, a `string` is hex:
|
|
104
|
+
|
|
105
|
+
```ts
|
|
106
|
+
const result = await verifyEvidenceBundle(bundle, {
|
|
107
|
+
content: { [eventId]: rawLogBytes }, // Uint8Array, or a hex string
|
|
108
|
+
});
|
|
109
|
+
result.events[0].contentOk; // true | false | null
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
In-body `events[].content` (signed) takes precedence over the side input; if both are present for an event and disagree, the event fails.
|
|
113
|
+
|
|
114
|
+
## Verify a Merkle inclusion proof
|
|
115
|
+
|
|
116
|
+
Agents close daily checkpoints over per-cycle leaves (RFC 9162, §2.1 domain separation — not the Bitcoin duplicate-leaf variant). Verify a leaf's inclusion against an anchored root:
|
|
117
|
+
|
|
118
|
+
```ts
|
|
119
|
+
import { leafHash, verifyInclusion, hexToBytes } from "@sanning/proof";
|
|
120
|
+
|
|
121
|
+
const ok = await verifyInclusion(
|
|
122
|
+
await leafHash(leafBytes), // Uint8Array leaf hash
|
|
123
|
+
leafIndex, // 0-based position
|
|
124
|
+
totalLeaves,
|
|
125
|
+
auditPath.map(hexToBytes), // sibling hashes, leaf → root
|
|
126
|
+
hexToBytes(expectedRootHex),
|
|
127
|
+
);
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## API
|
|
131
|
+
|
|
132
|
+
| Export | What it does |
|
|
133
|
+
|---|---|
|
|
134
|
+
| `verifyEnvelope(env, optionsOrContentHash?)` | The three load-bearing checks (spec-version registry, payload-hash recompute, Ed25519 over the signed scope) + optional content bind. Pass `{ payloadBytes }` for external-commitment envelopes. Returns `VerificationResult`. |
|
|
135
|
+
| `verifyEvidenceBundle(bundle, { gateways?, fetchImpl? })` | Verify a signed `sanning.evidence/v1` / `sanning.anchor.trace/v1` bundle: wrapper signature + `body_hash` + every checkpoint + every event's inclusion, offline (optional on-chain re-fetch). Returns `EvidenceBundleResult`. Powers the `npx @sanning/proof verify` CLI. |
|
|
136
|
+
| `verifyAgentProofBundle(bundle, { gateways?, fetchImpl? })` / `isAgentProofSpec(v)` | Verify the agent producer's `sanning.agent.proof/v1` inclusion bundle. |
|
|
137
|
+
| `contentHashes(env)` | The content hash(es) an envelope commits to, by event type — the reverse-provenance join keys. |
|
|
138
|
+
| `specVersionSupported(v)` | Fail-closed accept-check: `sanning.agent/v1` and additive minors (`sanning.agent/v1.<n>`). Unknown majors/profiles are rejected. |
|
|
139
|
+
| `jcs(value)` | RFC 8785 canonical JSON bytes. |
|
|
140
|
+
| `sha256Hex(bytes)` / `sha256Bytes(bytes)` | SHA-256 via WebCrypto. |
|
|
141
|
+
| `ed25519Verify(sig, msg, pubKey)` | Raw Ed25519 verification. |
|
|
142
|
+
| `leafHash` / `nodeHash` / `merkleRoot` / `auditPath` / `verifyInclusion` / `EMPTY_TREE_ROOT_HEX` | RFC 9162 binary Merkle tree primitives. |
|
|
143
|
+
| `utf8` / `bytesToHex` / `hexToBytes` | Encoding helpers. |
|
|
144
|
+
| `Envelope` / `VerificationResult` / `ContentRole` / `Subject` | Types. |
|
|
145
|
+
|
|
146
|
+
### Signed scope
|
|
147
|
+
|
|
148
|
+
The primary signature covers `JCS(envelope minus signature minus co_signatures)` ([envelope-spec](https://github.com/sanning-io/proof/blob/main/specs/envelope-spec.md) §2/§7.1). The reserved `co_signatures` carve-out lets countersignatures be added later without invalidating the primary signature.
|
|
149
|
+
|
|
150
|
+
## Why you can trust it (without trusting us)
|
|
151
|
+
|
|
152
|
+
Three **independent implementations** of this kernel exist — this package, the Python [`sanning-proof`](https://pypi.org/project/sanning-proof/) (PyPI), and the MIT-licensed Go reference, vendored into this repo under [`cross-kernel/vendor-agent/`](https://github.com/sanning-io/proof/tree/main/cross-kernel/vendor-agent) so anyone can build and run it. The TypeScript and Python kernels are conformance-gated **byte-for-byte** in CI against the shared [`test-vectors` corpus](https://github.com/sanning-io/proof/tree/main/test-vectors) (per-file SHA-256 pins; current cut tag `test-vectors-v2.0`), and the [cross-kernel agreement gate](https://github.com/sanning-io/proof/tree/main/cross-kernel) re-runs the corpus plus adversarial negatives through both, asserting identical verdicts on identical bytes. The Go reference runs as a third leg of that same gate, built at the commit its vendoring pins.
|
|
153
|
+
|
|
154
|
+
That mutual gate is the point: anyone can verify these provenance records with **no Sanning code in the trust path** — write your own verifier against the [public specs](https://github.com/sanning-io/proof/tree/main/specs) and the corpus will tell you if it's conformant.
|
|
155
|
+
|
|
156
|
+
## What this package deliberately does NOT do
|
|
157
|
+
|
|
158
|
+
- **Single-envelope scope.** Chain walking (`previous_hash`), checkpoint reconciliation, and gateway/transport logic are consumer-layer concerns composed above the kernel.
|
|
159
|
+
- **Provenance is history, not endorsement.** A verified envelope proves *these bytes have this signed, anchored history* — never "safe," "approved," or "currently in production."
|
|
160
|
+
- **Key identity is out-of-band.** The kernel proves the signature matches `env.public_key`; binding that key to a real-world identity is yours to establish.
|
|
161
|
+
|
|
162
|
+
## Requirements
|
|
163
|
+
|
|
164
|
+
ESM-only. Needs WebCrypto (`globalThis.crypto.subtle`): any modern browser, Node.js ≥ 20 (≥ 19 works), Deno, Bun, workers.
|
|
165
|
+
|
|
166
|
+
## License
|
|
167
|
+
|
|
168
|
+
MIT — verification must be open.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { Envelope } from "./types.js";
|
|
2
|
+
export declare const AGENT_PROOF_SPEC_PREFIX = "sanning.agent.proof/v1";
|
|
3
|
+
export interface AgentProofBundle {
|
|
4
|
+
spec_version: string;
|
|
5
|
+
checkpoint_envelope: Envelope;
|
|
6
|
+
checkpoint_tx_id: string;
|
|
7
|
+
leaf: Record<string, unknown>;
|
|
8
|
+
leaf_index: number;
|
|
9
|
+
audit_path: string[];
|
|
10
|
+
[k: string]: unknown;
|
|
11
|
+
}
|
|
12
|
+
export interface AgentProofResult {
|
|
13
|
+
status: "verified" | "failed" | "partial" | "malformed";
|
|
14
|
+
checkpointOk: boolean;
|
|
15
|
+
inclusionOk: boolean;
|
|
16
|
+
onChainOk: boolean | null;
|
|
17
|
+
onChainChecked: boolean;
|
|
18
|
+
leafIndex: number;
|
|
19
|
+
leafCount: number | null;
|
|
20
|
+
checkpointTxId: string | null;
|
|
21
|
+
errors: string[];
|
|
22
|
+
}
|
|
23
|
+
export interface VerifyAgentProofOptions {
|
|
24
|
+
gateways?: string[];
|
|
25
|
+
fetchImpl?: typeof fetch;
|
|
26
|
+
}
|
|
27
|
+
export declare function isAgentProofSpec(specVersion: unknown): boolean;
|
|
28
|
+
export declare function verifyAgentProofBundle(bundle: unknown, options?: VerifyAgentProofOptions): Promise<AgentProofResult>;
|
|
@@ -0,0 +1,188 @@
|
|
|
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
|
+
import { hexToBytes, utf8 } from "./crypto.js";
|
|
17
|
+
import { leafHash, verifyInclusion } from "./merkle.js";
|
|
18
|
+
import { MAX_CANONICAL_DEPTH, exceedsDepth, jcs, verifyEnvelope } from "./verifier.js";
|
|
19
|
+
export const AGENT_PROOF_SPEC_PREFIX = "sanning.agent.proof/v1";
|
|
20
|
+
function stringifyErr(e) {
|
|
21
|
+
return e instanceof Error ? e.message : String(e);
|
|
22
|
+
}
|
|
23
|
+
function toHex(bytes) {
|
|
24
|
+
let out = "";
|
|
25
|
+
for (const x of bytes)
|
|
26
|
+
out += x.toString(16).padStart(2, "0");
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
29
|
+
export function isAgentProofSpec(specVersion) {
|
|
30
|
+
if (typeof specVersion !== "string")
|
|
31
|
+
return false;
|
|
32
|
+
return specVersion === AGENT_PROOF_SPEC_PREFIX || specVersion.startsWith(`${AGENT_PROOF_SPEC_PREFIX}.`);
|
|
33
|
+
}
|
|
34
|
+
export async function verifyAgentProofBundle(bundle, options = {}) {
|
|
35
|
+
const fail = (error) => ({
|
|
36
|
+
status: "malformed",
|
|
37
|
+
checkpointOk: false,
|
|
38
|
+
inclusionOk: false,
|
|
39
|
+
onChainOk: null,
|
|
40
|
+
onChainChecked: false,
|
|
41
|
+
leafIndex: -1,
|
|
42
|
+
leafCount: null,
|
|
43
|
+
checkpointTxId: null,
|
|
44
|
+
errors: [error],
|
|
45
|
+
});
|
|
46
|
+
if (bundle === null || typeof bundle !== "object" || Array.isArray(bundle)) {
|
|
47
|
+
return fail("agent proof bundle is not a JSON object");
|
|
48
|
+
}
|
|
49
|
+
// Bound nesting before any canonicalization (checkpoint envelope + Merkle
|
|
50
|
+
// leaves are canonicalized recursively) — a fixed shared limit keeps this a
|
|
51
|
+
// `malformed` verdict in every kernel. See MAX_CANONICAL_DEPTH.
|
|
52
|
+
if (exceedsDepth(bundle, MAX_CANONICAL_DEPTH)) {
|
|
53
|
+
return fail(`agent proof bundle nesting exceeds the ${MAX_CANONICAL_DEPTH}-level canonicalization depth bound`);
|
|
54
|
+
}
|
|
55
|
+
const b = bundle;
|
|
56
|
+
if (!isAgentProofSpec(b.spec_version)) {
|
|
57
|
+
return fail(`unsupported agent-proof spec_version: ${JSON.stringify(b.spec_version)}`);
|
|
58
|
+
}
|
|
59
|
+
if (b.checkpoint_envelope === null || typeof b.checkpoint_envelope !== "object") {
|
|
60
|
+
return fail("agent proof bundle missing checkpoint_envelope");
|
|
61
|
+
}
|
|
62
|
+
if (b.leaf === null || typeof b.leaf !== "object") {
|
|
63
|
+
return fail("agent proof bundle missing leaf");
|
|
64
|
+
}
|
|
65
|
+
if (!Array.isArray(b.audit_path)) {
|
|
66
|
+
return fail("agent proof bundle missing audit_path[]");
|
|
67
|
+
}
|
|
68
|
+
const errors = [];
|
|
69
|
+
// Step 1: verify the checkpoint envelope (inline payload — no payloadBytes).
|
|
70
|
+
let checkpointOk = false;
|
|
71
|
+
let merkleRoot;
|
|
72
|
+
let leafCount;
|
|
73
|
+
try {
|
|
74
|
+
const res = await verifyEnvelope(b.checkpoint_envelope);
|
|
75
|
+
checkpointOk = res.ok && res.payloadHashOk === true;
|
|
76
|
+
if (!res.signatureOk)
|
|
77
|
+
errors.push("checkpoint envelope signature failed");
|
|
78
|
+
if (res.payloadHashOk !== true)
|
|
79
|
+
errors.push("checkpoint envelope payload binding failed");
|
|
80
|
+
const payload = (b.checkpoint_envelope.payload ?? {});
|
|
81
|
+
if (typeof payload.merkle_root === "string")
|
|
82
|
+
merkleRoot = payload.merkle_root;
|
|
83
|
+
if (typeof payload.leaf_count === "number")
|
|
84
|
+
leafCount = payload.leaf_count;
|
|
85
|
+
if (merkleRoot === undefined)
|
|
86
|
+
errors.push("checkpoint payload missing merkle_root");
|
|
87
|
+
if (leafCount === undefined)
|
|
88
|
+
errors.push("checkpoint payload missing leaf_count");
|
|
89
|
+
}
|
|
90
|
+
catch (e) {
|
|
91
|
+
errors.push(`checkpoint verify error: ${stringifyErr(e)}`);
|
|
92
|
+
}
|
|
93
|
+
// Steps 2-4: leaf hash, audit-path walk, root match.
|
|
94
|
+
let inclusionOk = false;
|
|
95
|
+
if (merkleRoot !== undefined && leafCount !== undefined) {
|
|
96
|
+
try {
|
|
97
|
+
const lh = await leafHash(utf8(jcs(b.leaf)));
|
|
98
|
+
inclusionOk = await verifyInclusion(lh, b.leaf_index, leafCount, b.audit_path.map((h) => hexToBytes(h)), hexToBytes(merkleRoot));
|
|
99
|
+
if (!inclusionOk)
|
|
100
|
+
errors.push("RFC 9162 inclusion proof did not reconstruct merkle_root");
|
|
101
|
+
}
|
|
102
|
+
catch (e) {
|
|
103
|
+
errors.push(`inclusion verify error: ${stringifyErr(e)}`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
// Step 5: optional on-chain re-fetch.
|
|
107
|
+
const gateways = options.gateways ?? [];
|
|
108
|
+
const onChainChecked = gateways.length > 0;
|
|
109
|
+
let onChainOk = null;
|
|
110
|
+
let gatewayUnavailable = false;
|
|
111
|
+
if (onChainChecked) {
|
|
112
|
+
const fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
|
113
|
+
if (typeof fetchImpl !== "function") {
|
|
114
|
+
gatewayUnavailable = true;
|
|
115
|
+
errors.push("on-chain re-fetch requested but no fetch implementation is available");
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
const outcome = await refetch(b.checkpoint_envelope, b.checkpoint_tx_id, gateways, fetchImpl);
|
|
119
|
+
onChainOk = outcome.ok;
|
|
120
|
+
if (outcome.unavailable)
|
|
121
|
+
gatewayUnavailable = true;
|
|
122
|
+
if (outcome.error)
|
|
123
|
+
errors.push(outcome.error);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
let status;
|
|
127
|
+
if (!checkpointOk || !inclusionOk || onChainOk === false) {
|
|
128
|
+
status = "failed";
|
|
129
|
+
}
|
|
130
|
+
else if (gatewayUnavailable) {
|
|
131
|
+
status = "partial";
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
status = "verified";
|
|
135
|
+
}
|
|
136
|
+
return {
|
|
137
|
+
status,
|
|
138
|
+
checkpointOk,
|
|
139
|
+
inclusionOk,
|
|
140
|
+
onChainOk,
|
|
141
|
+
onChainChecked,
|
|
142
|
+
leafIndex: typeof b.leaf_index === "number" ? b.leaf_index : -1,
|
|
143
|
+
leafCount: leafCount ?? null,
|
|
144
|
+
checkpointTxId: typeof b.checkpoint_tx_id === "string" ? b.checkpoint_tx_id : null,
|
|
145
|
+
errors,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
async function refetch(envelope, txId, gateways, fetchImpl) {
|
|
149
|
+
let expectedHex;
|
|
150
|
+
try {
|
|
151
|
+
expectedHex = toHex(utf8(jcs(envelope)));
|
|
152
|
+
}
|
|
153
|
+
catch (e) {
|
|
154
|
+
return { ok: false, unavailable: false, error: `cannot canonicalize checkpoint envelope: ${stringifyErr(e)}` };
|
|
155
|
+
}
|
|
156
|
+
let anyReachable = false;
|
|
157
|
+
for (const gw of gateways) {
|
|
158
|
+
const url = `${gw.replace(/\/+$/, "")}/${txId}`;
|
|
159
|
+
try {
|
|
160
|
+
const resp = await fetchImpl(url);
|
|
161
|
+
if (!resp.ok)
|
|
162
|
+
continue;
|
|
163
|
+
anyReachable = true;
|
|
164
|
+
const onChain = new Uint8Array(await resp.arrayBuffer());
|
|
165
|
+
if (toHex(onChain) === expectedHex)
|
|
166
|
+
return { ok: true, unavailable: false };
|
|
167
|
+
try {
|
|
168
|
+
const parsed = JSON.parse(new TextDecoder().decode(onChain));
|
|
169
|
+
if (toHex(utf8(jcs(parsed))) === expectedHex)
|
|
170
|
+
return { ok: true, unavailable: false };
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
// fall through
|
|
174
|
+
}
|
|
175
|
+
return {
|
|
176
|
+
ok: false,
|
|
177
|
+
unavailable: false,
|
|
178
|
+
error: `on-chain bytes at ${txId} do not match the checkpoint envelope`,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
// try next gateway
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (anyReachable)
|
|
186
|
+
return { ok: false, unavailable: false, error: `checkpoint ${txId} mismatched on every gateway` };
|
|
187
|
+
return { ok: null, unavailable: true, error: `checkpoint ${txId} unreachable on all gateways` };
|
|
188
|
+
}
|