@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 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
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env node
2
+ interface Cli {
3
+ out: (s: string) => void;
4
+ err: (s: string) => void;
5
+ fetchImpl?: typeof fetch;
6
+ }
7
+ export declare function runCli(argv: string[], io: Cli): Promise<number>;
8
+ export {};