@ar.io/proof 0.1.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 ar.io
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,54 @@
1
+ # @ar.io/proof
2
+
3
+ > TypeScript kernel of the ar.io verification stack — verify a Verifiable Event
4
+ > Envelope with no ar.io service in the trust path.
5
+
6
+ The verification primitives for the envelope family specified in
7
+ `ar-io-agent/docs/envelope-spec.md` (ario.agent/v1 profile per `artifact.md`):
8
+
9
+ - **RFC 8785 (JCS)** canonicalization (`jcs`)
10
+ - **SHA-256** (`sha256Hex`) via WebCrypto
11
+ - **Ed25519** signature verification (`ed25519Verify`, via `@noble/ed25519` with
12
+ the SHA-512 hook wired to WebCrypto)
13
+ - **`verifyEnvelope(env, expectedContentHash?)`** — the three load-bearing checks
14
+ (spec-version registry, `payload_hash` recompute, Ed25519 over the signed
15
+ scope) plus the optional content-hash bind
16
+ - **`contentHashes(env)`** — which payload hash(es) an envelope commits to, by
17
+ event type (the reverse-provenance join keys)
18
+ - **RFC 9162 binary Merkle tree** (`leafHash`, `nodeHash`, `merkleRoot`,
19
+ `auditPath`, `verifyInclusion`, `EMPTY_TREE_ROOT_HEX`) — §2.1 domain
20
+ separation, largest-power-of-two split (not the Bitcoin duplicate-leaf
21
+ variant), conformance-gated against the corpus's 7 Merkle vectors; parity
22
+ with the Python kernel's `ario_proof.merkle`
23
+
24
+ This is the TypeScript sibling of the Python [`ar-io-proof`](https://github.com/ar-io/ar-io-proof)
25
+ kernel and the Go reference (`ar-io-agent/pkg/proof`). All three are independent
26
+ implementations of the same algorithm, each conformance-gated **byte-for-byte**
27
+ against the shared `test-vectors-v1.0` corpus — that mutual gate is the
28
+ product's core claim: verification needs no ar.io code in the trust path.
29
+
30
+ ## Signed scope
31
+
32
+ The primary signature covers `JCS(envelope minus signature minus co_signatures)`
33
+ (envelope-spec §2, §7.1). The `co_signatures` carve-out lets a countersignature
34
+ be added without invalidating the primary signature; the field is reserved and
35
+ default-absent, and its absence is never a failure.
36
+
37
+ ## Spec-version acceptance
38
+
39
+ Fail-closed registry (`specVersionSupported`): exactly the accepted majors —
40
+ `ario.agent/v1` and additive minors within it (`ario.agent/v1.<minor>`),
41
+ matching the Go reference's semantics. Unknown majors and other profiles are
42
+ rejected. The mlflow profile (`ario.mlflow/v1`) is a deliberate later one-entry
43
+ addition, **not** implemented here — mlflow-dialect behaviors (e.g. the
44
+ underscore-key strip) must not leak into the agent profile.
45
+
46
+ ## Workspace status
47
+
48
+ Lives as an npm workspace inside `ar-io-proof-checker` (v1.2 wave decision —
49
+ no separate repo yet); the checker consumes it as its verifier. **Not yet
50
+ published to npm.** Before any publish: confirm the real npm scope (`@ar.io/`
51
+ vs `@ar-io/`), flip `exports` to the built `dist/` output (`npm run build`
52
+ emits ESM + type declarations), and get the coordinator's green light.
53
+
54
+ MIT — verification must be open. See `LICENSE`.
@@ -0,0 +1,6 @@
1
+ export declare function bytesToHex(bytes: Uint8Array): string;
2
+ export declare function hexToBytes(hex: string): Uint8Array;
3
+ export declare function utf8(s: string): Uint8Array;
4
+ export declare function sha256Bytes(bytes: Uint8Array): Promise<Uint8Array>;
5
+ export declare function sha256Hex(bytes: Uint8Array): Promise<string>;
6
+ export declare function ed25519Verify(signatureHex: string, message: Uint8Array, publicKeyHex: string): Promise<boolean>;
package/dist/crypto.js ADDED
@@ -0,0 +1,59 @@
1
+ // Low-level cryptographic primitives for the verifier. Deliberately thin: the
2
+ // only third-party crypto dependency is @noble/ed25519 (single-file, audited,
3
+ // zero-dependency). SHA-256 and SHA-512 come from WebCrypto — no extra hashing
4
+ // library to trust.
5
+ import * as ed from "@noble/ed25519";
6
+ // @noble/ed25519 needs SHA-512 to verify. Wire it to WebCrypto rather than
7
+ // pulling in @noble/hashes. globalThis.crypto.subtle exists in every modern
8
+ // browser and in Node >= 19, so the same code path runs in the app and in tests.
9
+ ed.etc.sha512Async = async (...msgs) => new Uint8Array(await crypto.subtle.digest("SHA-512", asBufferSource(ed.etc.concatBytes(...msgs))));
10
+ // WebCrypto's digest() wants a BufferSource backed by a (non-shared) ArrayBuffer.
11
+ // Our byte arrays always are; this keeps TS 5.7's stricter Uint8Array<ArrayBufferLike>
12
+ // typing satisfied without a runtime copy.
13
+ function asBufferSource(bytes) {
14
+ return bytes;
15
+ }
16
+ export function bytesToHex(bytes) {
17
+ let out = "";
18
+ for (const b of bytes)
19
+ out += b.toString(16).padStart(2, "0");
20
+ return out;
21
+ }
22
+ export function hexToBytes(hex) {
23
+ if (hex.length % 2 !== 0)
24
+ throw new Error("hexToBytes: odd-length string");
25
+ // Full validation: parseInt() partially parses ("1g" -> 1), which would let
26
+ // malformed hex through. A strict charset check closes that.
27
+ if (!/^[0-9a-fA-F]*$/.test(hex))
28
+ throw new Error("hexToBytes: non-hex characters");
29
+ const out = new Uint8Array(hex.length / 2);
30
+ for (let i = 0; i < out.length; i++) {
31
+ out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
32
+ }
33
+ return out;
34
+ }
35
+ export function utf8(s) {
36
+ return new TextEncoder().encode(s);
37
+ }
38
+ export async function sha256Bytes(bytes) {
39
+ return new Uint8Array(await crypto.subtle.digest("SHA-256", asBufferSource(bytes)));
40
+ }
41
+ export async function sha256Hex(bytes) {
42
+ return bytesToHex(await sha256Bytes(bytes));
43
+ }
44
+ // Verify an Ed25519 signature. Inputs are hex (signature, public key) as they
45
+ // appear in the envelope; message is raw bytes. Never throws — a malformed
46
+ // signature or key is "not verified," not an exception, so a hostile envelope
47
+ // can't crash the checker.
48
+ export async function ed25519Verify(signatureHex, message, publicKeyHex) {
49
+ try {
50
+ // zip215:false → strict RFC-8032 verification, matching the Go agent's
51
+ // crypto/ed25519 exactly (noble defaults to the more lenient zip215:true).
52
+ return await ed.verifyAsync(hexToBytes(signatureHex), message, hexToBytes(publicKeyHex), {
53
+ zip215: false,
54
+ });
55
+ }
56
+ catch {
57
+ return false;
58
+ }
59
+ }
@@ -0,0 +1,4 @@
1
+ export { contentHashes, jcs, specVersionSupported, verifyEnvelope } from "./verifier";
2
+ export { bytesToHex, ed25519Verify, hexToBytes, sha256Bytes, sha256Hex, utf8 } from "./crypto";
3
+ export { EMPTY_TREE_ROOT_HEX, auditPath, leafHash, merkleRoot, nodeHash, verifyInclusion, } from "./merkle";
4
+ export type { ContentRole, Envelope, Subject, VerificationResult } from "./types";
package/dist/index.js ADDED
@@ -0,0 +1,15 @@
1
+ // @ar.io/proof — TypeScript kernel of the ar.io verification stack.
2
+ //
3
+ // The verification primitives for the Verifiable Event Envelope family
4
+ // (ar-io-agent docs/envelope-spec.md), ario.agent/v1 profile: RFC 8785 (JCS)
5
+ // canonicalization, SHA-256, Ed25519 envelope verification, and the
6
+ // content-hash extraction used for reverse provenance lookup. Sibling of the
7
+ // Python `ar-io-proof` kernel and the Go `pkg/proof` reference; conformance
8
+ // is byte-for-byte against the shared `test-vectors-v1.0` corpus.
9
+ //
10
+ // Scope boundary (envelope-spec §10 #13): this package verifies a SINGLE
11
+ // envelope. Chain walking (previous_hash), checkpoint reconciliation, and
12
+ // gateway/transport concerns are consumer-layer logic composed above it.
13
+ export { contentHashes, jcs, specVersionSupported, verifyEnvelope } from "./verifier";
14
+ export { bytesToHex, ed25519Verify, hexToBytes, sha256Bytes, sha256Hex, utf8 } from "./crypto";
15
+ export { EMPTY_TREE_ROOT_HEX, auditPath, leafHash, merkleRoot, nodeHash, verifyInclusion, } from "./merkle";
@@ -0,0 +1,6 @@
1
+ export declare const EMPTY_TREE_ROOT_HEX = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
2
+ export declare function leafHash(leafBytes: Uint8Array): Promise<Uint8Array>;
3
+ export declare function nodeHash(left: Uint8Array, right: Uint8Array): Promise<Uint8Array>;
4
+ export declare function merkleRoot(leafHashes: Uint8Array[]): Promise<Uint8Array>;
5
+ export declare function auditPath(m: number, leafHashes: Uint8Array[]): Promise<Uint8Array[]>;
6
+ export declare function verifyInclusion(leaf: Uint8Array, leafIndex: number, totalLeaves: number, auditPath: Uint8Array[], expectedRoot: Uint8Array): Promise<boolean>;
package/dist/merkle.js ADDED
@@ -0,0 +1,114 @@
1
+ // RFC 9162 binary Merkle tree — build, audit paths, inclusion verification.
2
+ //
3
+ // A faithful port of the Go reference (ar-io-agent pkg/merkle), in lockstep
4
+ // with the Python kernel's ario_proof.merkle. All hashing is SHA-256 with
5
+ // RFC 9162 §2.1 domain separation: leaves prefixed 0x00, interior nodes 0x01.
6
+ // A tree of n leaves splits into a left subtree of k leaves (the largest
7
+ // power of two < n) and a right subtree of n−k — NOT the Bitcoin
8
+ // duplicate-last-leaf variant, which produces different roots for
9
+ // non-power-of-two leaf counts.
10
+ //
11
+ // The empty tree (zero leaves) hashes to SHA-256("") — EMPTY_TREE_ROOT_HEX.
12
+ // Conformance is byte-for-byte against the 7 merkle-tree-* vectors of
13
+ // test-vectors-v1.0 (test/conformance.test.ts).
14
+ import { sha256Bytes } from "./crypto";
15
+ export const EMPTY_TREE_ROOT_HEX = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
16
+ // SHA-256(0x00 || leafBytes) per RFC 9162 §2.1.
17
+ export async function leafHash(leafBytes) {
18
+ return sha256Bytes(prefixed(0x00, leafBytes));
19
+ }
20
+ // SHA-256(0x01 || left || right) per RFC 9162 §2.1.
21
+ export async function nodeHash(left, right) {
22
+ const buf = new Uint8Array(1 + left.length + right.length);
23
+ buf[0] = 0x01;
24
+ buf.set(left, 1);
25
+ buf.set(right, 1 + left.length);
26
+ return sha256Bytes(buf);
27
+ }
28
+ // The RFC 9162 Merkle Tree Hash of already-hashed leaves. Callers pass leaf
29
+ // hashes — the output of leafHash on each leaf's canonical bytes. Zero leaves
30
+ // yields SHA-256("").
31
+ export async function merkleRoot(leafHashes) {
32
+ const n = leafHashes.length;
33
+ if (n === 0)
34
+ return sha256Bytes(new Uint8Array(0));
35
+ if (n === 1)
36
+ return leafHashes[0];
37
+ const k = largestPow2LessThan(n);
38
+ return nodeHash(await merkleRoot(leafHashes.slice(0, k)), await merkleRoot(leafHashes.slice(k)));
39
+ }
40
+ // The inclusion proof for the leaf at index m, per RFC 9162 §2.1.3: sibling
41
+ // hashes bottom-up; empty for a single-leaf tree (the leaf hash itself is the
42
+ // root). Throws RangeError when m is out of range.
43
+ export async function auditPath(m, leafHashes) {
44
+ if (!Number.isInteger(m) || m < 0 || m >= leafHashes.length) {
45
+ throw new RangeError("merkle: leaf index out of range");
46
+ }
47
+ return path(m, leafHashes);
48
+ }
49
+ async function path(m, leafHashes) {
50
+ const n = leafHashes.length;
51
+ if (n === 1)
52
+ return [];
53
+ const k = largestPow2LessThan(n);
54
+ if (m < k) {
55
+ return [...(await path(m, leafHashes.slice(0, k))), await merkleRoot(leafHashes.slice(k))];
56
+ }
57
+ return [...(await path(m - k, leafHashes.slice(k))), await merkleRoot(leafHashes.slice(0, k))];
58
+ }
59
+ // Verify an RFC 9162 §2.1.3 inclusion proof. `leaf` is the leaf hash
60
+ // (leafHash of the canonical leaf bytes); `path` is the audit path bottom-up;
61
+ // `expectedRoot` is the merkleRoot committed by the checkpoint envelope.
62
+ // True iff the path reconstructs the expected root. Never throws.
63
+ export async function verifyInclusion(leaf, leafIndex, totalLeaves, auditPath, expectedRoot) {
64
+ if (!Number.isInteger(leafIndex) || leafIndex < 0 || totalLeaves <= 0 || leafIndex >= totalLeaves) {
65
+ return false;
66
+ }
67
+ if (totalLeaves === 1)
68
+ return auditPath.length === 0 && bytesEqual(leaf, expectedRoot);
69
+ let fn = leafIndex;
70
+ let sn = totalLeaves - 1;
71
+ let r = leaf;
72
+ for (const p of auditPath) {
73
+ if (sn === 0)
74
+ return false; // path longer than the tree depth — malformed
75
+ if ((fn & 1) === 1 || fn === sn) {
76
+ r = await nodeHash(p, r);
77
+ if ((fn & 1) === 0) {
78
+ while ((fn & 1) === 0 && fn !== 0) {
79
+ fn >>= 1;
80
+ sn >>= 1;
81
+ }
82
+ }
83
+ }
84
+ else {
85
+ r = await nodeHash(r, p);
86
+ }
87
+ fn >>= 1;
88
+ sn >>= 1;
89
+ }
90
+ return sn === 0 && bytesEqual(r, expectedRoot);
91
+ }
92
+ function prefixed(prefix, bytes) {
93
+ const buf = new Uint8Array(1 + bytes.length);
94
+ buf[0] = prefix;
95
+ buf.set(bytes, 1);
96
+ return buf;
97
+ }
98
+ // Largest k = 2**a with k < n (0 for n < 2).
99
+ function largestPow2LessThan(n) {
100
+ if (n < 2)
101
+ return 0;
102
+ let k = 1;
103
+ while (k * 2 < n)
104
+ k *= 2;
105
+ return k;
106
+ }
107
+ function bytesEqual(a, b) {
108
+ if (a.length !== b.length)
109
+ return false;
110
+ let diff = 0;
111
+ for (let i = 0; i < a.length; i++)
112
+ diff |= a[i] ^ b[i];
113
+ return diff === 0;
114
+ }
@@ -0,0 +1,28 @@
1
+ export interface Subject {
2
+ type: string;
3
+ tenant_id: string;
4
+ agent_id: string;
5
+ }
6
+ export interface Envelope {
7
+ spec_version: string;
8
+ event_id: string;
9
+ event_type: string;
10
+ subject: Subject;
11
+ payload_hash: string;
12
+ payload: Record<string, unknown>;
13
+ previous_hash: string;
14
+ signed_at: string;
15
+ public_key: string;
16
+ signature: string;
17
+ co_signatures?: unknown[];
18
+ }
19
+ export type ContentRole = "asset" | "baseline" | "observed";
20
+ export interface VerificationResult {
21
+ ok: boolean;
22
+ specVersionOk: boolean;
23
+ payloadHashOk: boolean;
24
+ signatureOk: boolean;
25
+ contentHashOk: boolean | null;
26
+ contentRole: ContentRole | null;
27
+ errors: string[];
28
+ }
package/dist/types.js ADDED
@@ -0,0 +1,6 @@
1
+ // Wire types for ar.io Verifiable Event Envelopes (ar-io-agent
2
+ // docs/envelope-spec.md; the ario.agent/v1 profile detail is artifact.md
3
+ // §3–§4). We model only the fields the verifier consumes; unknown fields are
4
+ // preserved by structural typing (envelopes are verified over their canonical
5
+ // bytes, not this interface).
6
+ export {};
@@ -0,0 +1,8 @@
1
+ import type { ContentRole, Envelope, VerificationResult } from "./types";
2
+ export declare function jcs(value: unknown): string;
3
+ export declare function specVersionSupported(specVersion: string): boolean;
4
+ export declare function contentHashes(env: Envelope): {
5
+ role: ContentRole;
6
+ hash: string;
7
+ }[];
8
+ export declare function verifyEnvelope(env: Envelope, expectedContentHash?: string): Promise<VerificationResult>;
@@ -0,0 +1,146 @@
1
+ // Independent client-side implementation of the ar.io agent envelope
2
+ // verification algorithm (ar-io-agent docs/artifact.md §6, docs/auditor-recipe.md
3
+ // Recipe 1). This is deliberately a second implementation of the same algorithm
4
+ // the Go agent runs — a second, conformance-tested verifier is what demonstrates
5
+ // the product's claim: verification needs no ar.io code in the trust path.
6
+ //
7
+ // Conformance is enforced by test/conformance.test.ts, which runs every
8
+ // ar-io-agent test vector through this code and asserts byte-for-byte agreement.
9
+ import canonicalize from "canonicalize";
10
+ import { ed25519Verify, sha256Hex, utf8 } from "./crypto";
11
+ // Fail-closed accepted-profile registry (envelope-spec §2, artifact.md §13):
12
+ // exactly the accepted profile majors, nothing else. Minors within an accepted
13
+ // major ("ario.agent/v1.<minor>") are additive and tolerated — matching the Go
14
+ // reference kernel's semantics (pkg/proof isSupportedSpec) so the JS and
15
+ // WASM-Go verifiers agree. Accepting a new profile (e.g. ario.mlflow/v1) is a
16
+ // deliberate one-entry addition HERE and only here — mlflow-dialect behaviors
17
+ // (like its underscore-key strip) must never leak into the agent profile.
18
+ const ACCEPTED_SPEC_MAJORS = ["ario.agent/v1"];
19
+ // RFC 8785 (JCS) canonicalization. The `canonicalize` package is the reference
20
+ // JS implementation; correctness is pinned by the conformance vectors.
21
+ export function jcs(value) {
22
+ const canonical = canonicalize(value);
23
+ if (typeof canonical !== "string") {
24
+ throw new Error("jcs: canonicalize returned a non-string (input not JSON-serializable?)");
25
+ }
26
+ return canonical;
27
+ }
28
+ export function specVersionSupported(specVersion) {
29
+ if (typeof specVersion !== "string" || specVersion === "")
30
+ return false;
31
+ return ACCEPTED_SPEC_MAJORS.some((m) => specVersion === m || specVersion.startsWith(`${m}.`));
32
+ }
33
+ // The content hash(es) an envelope commits to, by event type — the values a
34
+ // reverse lookup can match the user's in-browser file hash against.
35
+ // asset_registered -> payload.hash (the registered, known-good bytes)
36
+ // asset_missing -> payload.baseline.hash (last known-good bytes that vanished)
37
+ // tamper_detected -> payload.observed.hash (the tampered bytes that were flagged)
38
+ // -> payload.baseline.hash (the known-good bytes it diverged from)
39
+ // Other event types commit to no asset content hash.
40
+ export function contentHashes(env) {
41
+ const p = env.payload;
42
+ const asStr = (v) => (typeof v === "string" && v ? v : undefined);
43
+ const out = [];
44
+ switch (env.event_type) {
45
+ case "asset_registered": {
46
+ const h = asStr(p.hash);
47
+ if (h)
48
+ out.push({ role: "asset", hash: h });
49
+ break;
50
+ }
51
+ case "asset_missing": {
52
+ const h = asStr(p.baseline?.hash);
53
+ if (h)
54
+ out.push({ role: "baseline", hash: h });
55
+ break;
56
+ }
57
+ case "tamper_detected": {
58
+ const obs = asStr(p.observed?.hash);
59
+ if (obs)
60
+ out.push({ role: "observed", hash: obs });
61
+ const base = asStr(p.baseline?.hash);
62
+ if (base)
63
+ out.push({ role: "baseline", hash: base });
64
+ break;
65
+ }
66
+ }
67
+ return out;
68
+ }
69
+ // Verify an envelope. The three load-bearing checks (spec_version, payload_hash,
70
+ // signature) establish that the envelope is authentic. When `expectedContentHash`
71
+ // is supplied (the in-browser hash of the user's file), an additional bind check
72
+ // confirms the bytes the user holds are the bytes this envelope is about — this
73
+ // is the step that makes a lying gateway tag worthless.
74
+ export async function verifyEnvelope(env, expectedContentHash) {
75
+ const errors = [];
76
+ // Guard a malformed input (null / non-object / array) up front — every field
77
+ // access below assumes an object. A hostile gateway or a hand-edited report
78
+ // can supply anything; treat it as "not verified," never a thrown exception.
79
+ if (env === null || typeof env !== "object" || Array.isArray(env)) {
80
+ return {
81
+ ok: false,
82
+ specVersionOk: false,
83
+ payloadHashOk: false,
84
+ signatureOk: false,
85
+ contentHashOk: expectedContentHash === undefined ? null : false,
86
+ contentRole: null,
87
+ errors: ["envelope is not a JSON object"],
88
+ };
89
+ }
90
+ const specVersionOk = specVersionSupported(env.spec_version);
91
+ if (!specVersionOk)
92
+ errors.push(`unsupported spec_version: ${JSON.stringify(env.spec_version)}`);
93
+ // Check 1 — Record Matches: payload_hash == SHA-256(JCS(payload)).
94
+ let payloadHashOk = false;
95
+ try {
96
+ const recomputed = await sha256Hex(utf8(jcs(env.payload)));
97
+ payloadHashOk = recomputed === env.payload_hash;
98
+ if (!payloadHashOk) {
99
+ errors.push(`payload_hash mismatch: envelope=${env.payload_hash} recomputed=${recomputed}`);
100
+ }
101
+ }
102
+ catch (e) {
103
+ errors.push(`payload canonicalization failed: ${stringifyErr(e)}`);
104
+ }
105
+ // Check 2 — Signature Confirmed: Ed25519 over the signed scope, which is
106
+ // JCS(envelope minus `signature` minus `co_signatures`) per envelope-spec §2.
107
+ // The co_signatures carve-out (§7.1) lets a countersignature be added without
108
+ // invalidating the primary signature; the field is reserved/default-absent.
109
+ // The corpus has no co-signed vectors, so this strip is pinned by an explicit
110
+ // unit test rather than by conformance.
111
+ let signatureOk = false;
112
+ try {
113
+ const { signature: _signature, co_signatures: _coSignatures, ...envelopeForSig } = env;
114
+ signatureOk = await ed25519Verify(env.signature, utf8(jcs(envelopeForSig)), env.public_key);
115
+ if (!signatureOk)
116
+ errors.push("Ed25519 signature verification failed");
117
+ }
118
+ catch (e) {
119
+ errors.push(`signature verification error: ${stringifyErr(e)}`);
120
+ }
121
+ // Check 3 (optional) — Content Bind: the user's bytes match a hash this
122
+ // envelope commits to. The tag got us here; only this proves the bytes match.
123
+ let contentHashOk = null;
124
+ let contentRole = null;
125
+ if (expectedContentHash !== undefined) {
126
+ const want = expectedContentHash.toLowerCase();
127
+ const match = contentHashes(env).find((c) => c.hash.toLowerCase() === want);
128
+ contentHashOk = match !== undefined;
129
+ contentRole = match ? match.role : null;
130
+ if (!contentHashOk) {
131
+ errors.push("provided content hash does not match any hash this envelope commits to");
132
+ }
133
+ }
134
+ return {
135
+ ok: specVersionOk && payloadHashOk && signatureOk,
136
+ specVersionOk,
137
+ payloadHashOk,
138
+ signatureOk,
139
+ contentHashOk,
140
+ contentRole,
141
+ errors,
142
+ };
143
+ }
144
+ function stringifyErr(e) {
145
+ return e instanceof Error ? e.message : String(e);
146
+ }
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@ar.io/proof",
3
+ "version": "0.1.0",
4
+ "description": "TypeScript kernel of the ar.io verification stack: RFC 8785 (JCS) canonicalization, SHA-256, and Ed25519 verification for Verifiable Event Envelopes (ario.agent/v1 profile). Conformance-gated byte-for-byte against the test-vectors-v1.0 corpus.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "sideEffects": false,
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
13
+ },
14
+ "files": [
15
+ "src",
16
+ "dist",
17
+ "README.md",
18
+ "LICENSE"
19
+ ],
20
+ "scripts": {
21
+ "build": "tsc -p tsconfig.build.json",
22
+ "prepublishOnly": "npm run build"
23
+ },
24
+ "dependencies": {
25
+ "@noble/ed25519": "^2.1.0",
26
+ "canonicalize": "^2.0.0"
27
+ },
28
+ "main": "./dist/index.js",
29
+ "types": "./dist/index.d.ts",
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "git+https://github.com/ar-io/ar-io-proof-checker.git",
33
+ "directory": "packages/proof"
34
+ },
35
+ "publishConfig": {
36
+ "access": "public"
37
+ },
38
+ "keywords": [
39
+ "ar.io",
40
+ "arweave",
41
+ "verification",
42
+ "provenance",
43
+ "jcs",
44
+ "rfc8785",
45
+ "ed25519",
46
+ "merkle",
47
+ "rfc9162"
48
+ ]
49
+ }
package/src/crypto.ts ADDED
@@ -0,0 +1,69 @@
1
+ // Low-level cryptographic primitives for the verifier. Deliberately thin: the
2
+ // only third-party crypto dependency is @noble/ed25519 (single-file, audited,
3
+ // zero-dependency). SHA-256 and SHA-512 come from WebCrypto — no extra hashing
4
+ // library to trust.
5
+
6
+ import * as ed from "@noble/ed25519";
7
+
8
+ // @noble/ed25519 needs SHA-512 to verify. Wire it to WebCrypto rather than
9
+ // pulling in @noble/hashes. globalThis.crypto.subtle exists in every modern
10
+ // browser and in Node >= 19, so the same code path runs in the app and in tests.
11
+ ed.etc.sha512Async = async (...msgs: Uint8Array[]): Promise<Uint8Array> =>
12
+ new Uint8Array(await crypto.subtle.digest("SHA-512", asBufferSource(ed.etc.concatBytes(...msgs))));
13
+
14
+ // WebCrypto's digest() wants a BufferSource backed by a (non-shared) ArrayBuffer.
15
+ // Our byte arrays always are; this keeps TS 5.7's stricter Uint8Array<ArrayBufferLike>
16
+ // typing satisfied without a runtime copy.
17
+ function asBufferSource(bytes: Uint8Array): BufferSource {
18
+ return bytes as unknown as BufferSource;
19
+ }
20
+
21
+ export function bytesToHex(bytes: Uint8Array): string {
22
+ let out = "";
23
+ for (const b of bytes) out += b.toString(16).padStart(2, "0");
24
+ return out;
25
+ }
26
+
27
+ export function hexToBytes(hex: string): Uint8Array {
28
+ if (hex.length % 2 !== 0) throw new Error("hexToBytes: odd-length string");
29
+ // Full validation: parseInt() partially parses ("1g" -> 1), which would let
30
+ // malformed hex through. A strict charset check closes that.
31
+ if (!/^[0-9a-fA-F]*$/.test(hex)) throw new Error("hexToBytes: non-hex characters");
32
+ const out = new Uint8Array(hex.length / 2);
33
+ for (let i = 0; i < out.length; i++) {
34
+ out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
35
+ }
36
+ return out;
37
+ }
38
+
39
+ export function utf8(s: string): Uint8Array {
40
+ return new TextEncoder().encode(s);
41
+ }
42
+
43
+ export async function sha256Bytes(bytes: Uint8Array): Promise<Uint8Array> {
44
+ return new Uint8Array(await crypto.subtle.digest("SHA-256", asBufferSource(bytes)));
45
+ }
46
+
47
+ export async function sha256Hex(bytes: Uint8Array): Promise<string> {
48
+ return bytesToHex(await sha256Bytes(bytes));
49
+ }
50
+
51
+ // Verify an Ed25519 signature. Inputs are hex (signature, public key) as they
52
+ // appear in the envelope; message is raw bytes. Never throws — a malformed
53
+ // signature or key is "not verified," not an exception, so a hostile envelope
54
+ // can't crash the checker.
55
+ export async function ed25519Verify(
56
+ signatureHex: string,
57
+ message: Uint8Array,
58
+ publicKeyHex: string,
59
+ ): Promise<boolean> {
60
+ try {
61
+ // zip215:false → strict RFC-8032 verification, matching the Go agent's
62
+ // crypto/ed25519 exactly (noble defaults to the more lenient zip215:true).
63
+ return await ed.verifyAsync(hexToBytes(signatureHex), message, hexToBytes(publicKeyHex), {
64
+ zip215: false,
65
+ });
66
+ } catch {
67
+ return false;
68
+ }
69
+ }
package/src/index.ts ADDED
@@ -0,0 +1,24 @@
1
+ // @ar.io/proof — TypeScript kernel of the ar.io verification stack.
2
+ //
3
+ // The verification primitives for the Verifiable Event Envelope family
4
+ // (ar-io-agent docs/envelope-spec.md), ario.agent/v1 profile: RFC 8785 (JCS)
5
+ // canonicalization, SHA-256, Ed25519 envelope verification, and the
6
+ // content-hash extraction used for reverse provenance lookup. Sibling of the
7
+ // Python `ar-io-proof` kernel and the Go `pkg/proof` reference; conformance
8
+ // is byte-for-byte against the shared `test-vectors-v1.0` corpus.
9
+ //
10
+ // Scope boundary (envelope-spec §10 #13): this package verifies a SINGLE
11
+ // envelope. Chain walking (previous_hash), checkpoint reconciliation, and
12
+ // gateway/transport concerns are consumer-layer logic composed above it.
13
+
14
+ export { contentHashes, jcs, specVersionSupported, verifyEnvelope } from "./verifier";
15
+ export { bytesToHex, ed25519Verify, hexToBytes, sha256Bytes, sha256Hex, utf8 } from "./crypto";
16
+ export {
17
+ EMPTY_TREE_ROOT_HEX,
18
+ auditPath,
19
+ leafHash,
20
+ merkleRoot,
21
+ nodeHash,
22
+ verifyInclusion,
23
+ } from "./merkle";
24
+ export type { ContentRole, Envelope, Subject, VerificationResult } from "./types";
package/src/merkle.ts ADDED
@@ -0,0 +1,125 @@
1
+ // RFC 9162 binary Merkle tree — build, audit paths, inclusion verification.
2
+ //
3
+ // A faithful port of the Go reference (ar-io-agent pkg/merkle), in lockstep
4
+ // with the Python kernel's ario_proof.merkle. All hashing is SHA-256 with
5
+ // RFC 9162 §2.1 domain separation: leaves prefixed 0x00, interior nodes 0x01.
6
+ // A tree of n leaves splits into a left subtree of k leaves (the largest
7
+ // power of two < n) and a right subtree of n−k — NOT the Bitcoin
8
+ // duplicate-last-leaf variant, which produces different roots for
9
+ // non-power-of-two leaf counts.
10
+ //
11
+ // The empty tree (zero leaves) hashes to SHA-256("") — EMPTY_TREE_ROOT_HEX.
12
+ // Conformance is byte-for-byte against the 7 merkle-tree-* vectors of
13
+ // test-vectors-v1.0 (test/conformance.test.ts).
14
+
15
+ import { sha256Bytes } from "./crypto";
16
+
17
+ export const EMPTY_TREE_ROOT_HEX =
18
+ "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
19
+
20
+ // SHA-256(0x00 || leafBytes) per RFC 9162 §2.1.
21
+ export async function leafHash(leafBytes: Uint8Array): Promise<Uint8Array> {
22
+ return sha256Bytes(prefixed(0x00, leafBytes));
23
+ }
24
+
25
+ // SHA-256(0x01 || left || right) per RFC 9162 §2.1.
26
+ export async function nodeHash(left: Uint8Array, right: Uint8Array): Promise<Uint8Array> {
27
+ const buf = new Uint8Array(1 + left.length + right.length);
28
+ buf[0] = 0x01;
29
+ buf.set(left, 1);
30
+ buf.set(right, 1 + left.length);
31
+ return sha256Bytes(buf);
32
+ }
33
+
34
+ // The RFC 9162 Merkle Tree Hash of already-hashed leaves. Callers pass leaf
35
+ // hashes — the output of leafHash on each leaf's canonical bytes. Zero leaves
36
+ // yields SHA-256("").
37
+ export async function merkleRoot(leafHashes: Uint8Array[]): Promise<Uint8Array> {
38
+ const n = leafHashes.length;
39
+ if (n === 0) return sha256Bytes(new Uint8Array(0));
40
+ if (n === 1) return leafHashes[0];
41
+ const k = largestPow2LessThan(n);
42
+ return nodeHash(await merkleRoot(leafHashes.slice(0, k)), await merkleRoot(leafHashes.slice(k)));
43
+ }
44
+
45
+ // The inclusion proof for the leaf at index m, per RFC 9162 §2.1.3: sibling
46
+ // hashes bottom-up; empty for a single-leaf tree (the leaf hash itself is the
47
+ // root). Throws RangeError when m is out of range.
48
+ export async function auditPath(m: number, leafHashes: Uint8Array[]): Promise<Uint8Array[]> {
49
+ if (!Number.isInteger(m) || m < 0 || m >= leafHashes.length) {
50
+ throw new RangeError("merkle: leaf index out of range");
51
+ }
52
+ return path(m, leafHashes);
53
+ }
54
+
55
+ async function path(m: number, leafHashes: Uint8Array[]): Promise<Uint8Array[]> {
56
+ const n = leafHashes.length;
57
+ if (n === 1) return [];
58
+ const k = largestPow2LessThan(n);
59
+ if (m < k) {
60
+ return [...(await path(m, leafHashes.slice(0, k))), await merkleRoot(leafHashes.slice(k))];
61
+ }
62
+ return [...(await path(m - k, leafHashes.slice(k))), await merkleRoot(leafHashes.slice(0, k))];
63
+ }
64
+
65
+ // Verify an RFC 9162 §2.1.3 inclusion proof. `leaf` is the leaf hash
66
+ // (leafHash of the canonical leaf bytes); `path` is the audit path bottom-up;
67
+ // `expectedRoot` is the merkleRoot committed by the checkpoint envelope.
68
+ // True iff the path reconstructs the expected root. Never throws.
69
+ export async function verifyInclusion(
70
+ leaf: Uint8Array,
71
+ leafIndex: number,
72
+ totalLeaves: number,
73
+ auditPath: Uint8Array[],
74
+ expectedRoot: Uint8Array,
75
+ ): Promise<boolean> {
76
+ if (!Number.isInteger(leafIndex) || leafIndex < 0 || totalLeaves <= 0 || leafIndex >= totalLeaves) {
77
+ return false;
78
+ }
79
+ if (totalLeaves === 1) return auditPath.length === 0 && bytesEqual(leaf, expectedRoot);
80
+
81
+ let fn = leafIndex;
82
+ let sn = totalLeaves - 1;
83
+ let r = leaf;
84
+
85
+ for (const p of auditPath) {
86
+ if (sn === 0) return false; // path longer than the tree depth — malformed
87
+ if ((fn & 1) === 1 || fn === sn) {
88
+ r = await nodeHash(p, r);
89
+ if ((fn & 1) === 0) {
90
+ while ((fn & 1) === 0 && fn !== 0) {
91
+ fn >>= 1;
92
+ sn >>= 1;
93
+ }
94
+ }
95
+ } else {
96
+ r = await nodeHash(r, p);
97
+ }
98
+ fn >>= 1;
99
+ sn >>= 1;
100
+ }
101
+
102
+ return sn === 0 && bytesEqual(r, expectedRoot);
103
+ }
104
+
105
+ function prefixed(prefix: number, bytes: Uint8Array): Uint8Array {
106
+ const buf = new Uint8Array(1 + bytes.length);
107
+ buf[0] = prefix;
108
+ buf.set(bytes, 1);
109
+ return buf;
110
+ }
111
+
112
+ // Largest k = 2**a with k < n (0 for n < 2).
113
+ function largestPow2LessThan(n: number): number {
114
+ if (n < 2) return 0;
115
+ let k = 1;
116
+ while (k * 2 < n) k *= 2;
117
+ return k;
118
+ }
119
+
120
+ function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
121
+ if (a.length !== b.length) return false;
122
+ let diff = 0;
123
+ for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];
124
+ return diff === 0;
125
+ }
package/src/types.ts ADDED
@@ -0,0 +1,50 @@
1
+ // Wire types for ar.io Verifiable Event Envelopes (ar-io-agent
2
+ // docs/envelope-spec.md; the ario.agent/v1 profile detail is artifact.md
3
+ // §3–§4). We model only the fields the verifier consumes; unknown fields are
4
+ // preserved by structural typing (envelopes are verified over their canonical
5
+ // bytes, not this interface).
6
+
7
+ export interface Subject {
8
+ type: string;
9
+ tenant_id: string;
10
+ agent_id: string;
11
+ }
12
+
13
+ export interface Envelope {
14
+ spec_version: string;
15
+ event_id: string;
16
+ event_type: string;
17
+ subject: Subject;
18
+ payload_hash: string;
19
+ payload: Record<string, unknown>;
20
+ previous_hash: string;
21
+ signed_at: string;
22
+ public_key: string;
23
+ signature: string;
24
+ // Reserved, default-absent (envelope-spec §7.1): countersignatures over the
25
+ // same skeleton. OUTSIDE the signed scope — the primary signature covers the
26
+ // envelope minus `signature` AND minus `co_signatures`, so a countersignature
27
+ // can be added without invalidating the primary. Absence implies a single
28
+ // signer and MUST NOT be treated as a failure.
29
+ co_signatures?: unknown[];
30
+ }
31
+
32
+ // Which payload field a matched content hash came from. tamper_detected commits
33
+ // to both the tampered ("observed") bytes and the known-good ("baseline") bytes.
34
+ export type ContentRole = "asset" | "baseline" | "observed";
35
+
36
+ export interface VerificationResult {
37
+ // Cryptographic validity: spec_version + payload_hash + Ed25519 signature all
38
+ // passed. This is "the envelope is authentic," independent of the user's bytes.
39
+ ok: boolean;
40
+ specVersionOk: boolean;
41
+ payloadHashOk: boolean;
42
+ signatureOk: boolean;
43
+ // Content bind: did the hash the caller supplied (e.g. the in-browser hash of
44
+ // a user's file) match a hash this envelope commits to? null when no hash was
45
+ // supplied (verifying an envelope on its own). This is the check that defeats
46
+ // a lying gateway — the tag only got us to the candidate.
47
+ contentHashOk: boolean | null;
48
+ contentRole: ContentRole | null;
49
+ errors: string[];
50
+ }
@@ -0,0 +1,160 @@
1
+ // Independent client-side implementation of the ar.io agent envelope
2
+ // verification algorithm (ar-io-agent docs/artifact.md §6, docs/auditor-recipe.md
3
+ // Recipe 1). This is deliberately a second implementation of the same algorithm
4
+ // the Go agent runs — a second, conformance-tested verifier is what demonstrates
5
+ // the product's claim: verification needs no ar.io code in the trust path.
6
+ //
7
+ // Conformance is enforced by test/conformance.test.ts, which runs every
8
+ // ar-io-agent test vector through this code and asserts byte-for-byte agreement.
9
+
10
+ import canonicalize from "canonicalize";
11
+
12
+ import { ed25519Verify, sha256Hex, utf8 } from "./crypto";
13
+ import type { ContentRole, Envelope, VerificationResult } from "./types";
14
+
15
+ // Fail-closed accepted-profile registry (envelope-spec §2, artifact.md §13):
16
+ // exactly the accepted profile majors, nothing else. Minors within an accepted
17
+ // major ("ario.agent/v1.<minor>") are additive and tolerated — matching the Go
18
+ // reference kernel's semantics (pkg/proof isSupportedSpec) so the JS and
19
+ // WASM-Go verifiers agree. Accepting a new profile (e.g. ario.mlflow/v1) is a
20
+ // deliberate one-entry addition HERE and only here — mlflow-dialect behaviors
21
+ // (like its underscore-key strip) must never leak into the agent profile.
22
+ const ACCEPTED_SPEC_MAJORS = ["ario.agent/v1"];
23
+
24
+ // RFC 8785 (JCS) canonicalization. The `canonicalize` package is the reference
25
+ // JS implementation; correctness is pinned by the conformance vectors.
26
+ export function jcs(value: unknown): string {
27
+ const canonical = canonicalize(value);
28
+ if (typeof canonical !== "string") {
29
+ throw new Error("jcs: canonicalize returned a non-string (input not JSON-serializable?)");
30
+ }
31
+ return canonical;
32
+ }
33
+
34
+ export function specVersionSupported(specVersion: string): boolean {
35
+ if (typeof specVersion !== "string" || specVersion === "") return false;
36
+ return ACCEPTED_SPEC_MAJORS.some((m) => specVersion === m || specVersion.startsWith(`${m}.`));
37
+ }
38
+
39
+ // The content hash(es) an envelope commits to, by event type — the values a
40
+ // reverse lookup can match the user's in-browser file hash against.
41
+ // asset_registered -> payload.hash (the registered, known-good bytes)
42
+ // asset_missing -> payload.baseline.hash (last known-good bytes that vanished)
43
+ // tamper_detected -> payload.observed.hash (the tampered bytes that were flagged)
44
+ // -> payload.baseline.hash (the known-good bytes it diverged from)
45
+ // Other event types commit to no asset content hash.
46
+ export function contentHashes(env: Envelope): { role: ContentRole; hash: string }[] {
47
+ const p = env.payload as {
48
+ hash?: unknown;
49
+ baseline?: { hash?: unknown };
50
+ observed?: { hash?: unknown };
51
+ };
52
+ const asStr = (v: unknown): string | undefined => (typeof v === "string" && v ? v : undefined);
53
+
54
+ const out: { role: ContentRole; hash: string }[] = [];
55
+ switch (env.event_type) {
56
+ case "asset_registered": {
57
+ const h = asStr(p.hash);
58
+ if (h) out.push({ role: "asset", hash: h });
59
+ break;
60
+ }
61
+ case "asset_missing": {
62
+ const h = asStr(p.baseline?.hash);
63
+ if (h) out.push({ role: "baseline", hash: h });
64
+ break;
65
+ }
66
+ case "tamper_detected": {
67
+ const obs = asStr(p.observed?.hash);
68
+ if (obs) out.push({ role: "observed", hash: obs });
69
+ const base = asStr(p.baseline?.hash);
70
+ if (base) out.push({ role: "baseline", hash: base });
71
+ break;
72
+ }
73
+ }
74
+ return out;
75
+ }
76
+
77
+ // Verify an envelope. The three load-bearing checks (spec_version, payload_hash,
78
+ // signature) establish that the envelope is authentic. When `expectedContentHash`
79
+ // is supplied (the in-browser hash of the user's file), an additional bind check
80
+ // confirms the bytes the user holds are the bytes this envelope is about — this
81
+ // is the step that makes a lying gateway tag worthless.
82
+ export async function verifyEnvelope(
83
+ env: Envelope,
84
+ expectedContentHash?: string,
85
+ ): Promise<VerificationResult> {
86
+ const errors: string[] = [];
87
+
88
+ // Guard a malformed input (null / non-object / array) up front — every field
89
+ // access below assumes an object. A hostile gateway or a hand-edited report
90
+ // can supply anything; treat it as "not verified," never a thrown exception.
91
+ if (env === null || typeof env !== "object" || Array.isArray(env)) {
92
+ return {
93
+ ok: false,
94
+ specVersionOk: false,
95
+ payloadHashOk: false,
96
+ signatureOk: false,
97
+ contentHashOk: expectedContentHash === undefined ? null : false,
98
+ contentRole: null,
99
+ errors: ["envelope is not a JSON object"],
100
+ };
101
+ }
102
+
103
+ const specVersionOk = specVersionSupported(env.spec_version);
104
+ if (!specVersionOk) errors.push(`unsupported spec_version: ${JSON.stringify(env.spec_version)}`);
105
+
106
+ // Check 1 — Record Matches: payload_hash == SHA-256(JCS(payload)).
107
+ let payloadHashOk = false;
108
+ try {
109
+ const recomputed = await sha256Hex(utf8(jcs(env.payload)));
110
+ payloadHashOk = recomputed === env.payload_hash;
111
+ if (!payloadHashOk) {
112
+ errors.push(`payload_hash mismatch: envelope=${env.payload_hash} recomputed=${recomputed}`);
113
+ }
114
+ } catch (e) {
115
+ errors.push(`payload canonicalization failed: ${stringifyErr(e)}`);
116
+ }
117
+
118
+ // Check 2 — Signature Confirmed: Ed25519 over the signed scope, which is
119
+ // JCS(envelope minus `signature` minus `co_signatures`) per envelope-spec §2.
120
+ // The co_signatures carve-out (§7.1) lets a countersignature be added without
121
+ // invalidating the primary signature; the field is reserved/default-absent.
122
+ // The corpus has no co-signed vectors, so this strip is pinned by an explicit
123
+ // unit test rather than by conformance.
124
+ let signatureOk = false;
125
+ try {
126
+ const { signature: _signature, co_signatures: _coSignatures, ...envelopeForSig } = env;
127
+ signatureOk = await ed25519Verify(env.signature, utf8(jcs(envelopeForSig)), env.public_key);
128
+ if (!signatureOk) errors.push("Ed25519 signature verification failed");
129
+ } catch (e) {
130
+ errors.push(`signature verification error: ${stringifyErr(e)}`);
131
+ }
132
+
133
+ // Check 3 (optional) — Content Bind: the user's bytes match a hash this
134
+ // envelope commits to. The tag got us here; only this proves the bytes match.
135
+ let contentHashOk: boolean | null = null;
136
+ let contentRole: ContentRole | null = null;
137
+ if (expectedContentHash !== undefined) {
138
+ const want = expectedContentHash.toLowerCase();
139
+ const match = contentHashes(env).find((c) => c.hash.toLowerCase() === want);
140
+ contentHashOk = match !== undefined;
141
+ contentRole = match ? match.role : null;
142
+ if (!contentHashOk) {
143
+ errors.push("provided content hash does not match any hash this envelope commits to");
144
+ }
145
+ }
146
+
147
+ return {
148
+ ok: specVersionOk && payloadHashOk && signatureOk,
149
+ specVersionOk,
150
+ payloadHashOk,
151
+ signatureOk,
152
+ contentHashOk,
153
+ contentRole,
154
+ errors,
155
+ };
156
+ }
157
+
158
+ function stringifyErr(e: unknown): string {
159
+ return e instanceof Error ? e.message : String(e);
160
+ }