@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/src/index.ts ADDED
@@ -0,0 +1,105 @@
1
+ // @sanning/proof — TypeScript kernel of the Sanning verification stack.
2
+ //
3
+ // The verification primitives for the Verifiable Event Envelope family
4
+ // (specs/envelope-spec.md), sanning.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 `sanning-proof` kernel and the Go `pkg/proof` reference; conformance
8
+ // is byte-for-byte against the shared `test-vectors/` 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
+ // ACCEPTED_SPEC_MAJORS is exported for parity with the sibling kernels'
15
+ // registries (Python ACCEPTED_SPEC_VERSIONS, Go SupportedSpecMajor): a consumer
16
+ // can see which profiles this build admits without inferring it from probe
17
+ // calls. It is frozen at its definition (`Object.freeze`, typed
18
+ // `readonly string[]`) because the verifier reads that same array — an exported
19
+ // mutable accept-set would be a runtime-widenable kernel. Additive — no
20
+ // existing export changes.
21
+ export {
22
+ ACCEPTED_SPEC_MAJORS,
23
+ contentHashes,
24
+ jcs,
25
+ specVersionSupported,
26
+ verifyEnvelope,
27
+ } from "./verifier.js";
28
+ export {
29
+ bytesToHex,
30
+ deriveOperatorAddress,
31
+ ed25519Verify,
32
+ hexToBytes,
33
+ sha256Bytes,
34
+ sha256Hex,
35
+ utf8,
36
+ verifyRsaPssSha256,
37
+ } from "./crypto.js";
38
+ export {
39
+ EMPTY_TREE_ROOT_HEX,
40
+ auditPath,
41
+ leafHash,
42
+ merkleRoot,
43
+ nodeHash,
44
+ verifyInclusion,
45
+ } from "./merkle.js";
46
+ export type { ContentRole, Envelope, Subject, VerificationResult, VerifyOptions } from "./types.js";
47
+
48
+ // Evidence-bundle verification (specs/evidence-bundle.md): the signed
49
+ // sanning.evidence/v1 wrapper carrying the sanning.anchor.trace/v1 body. The CLI
50
+ // (`npx @sanning/proof verify`) is built on this. The same path dispatches on the
51
+ // issuer-composed sanning.evidence.export/v1 body (specs/evidence-export.md) to
52
+ // verifyExportBody (embedded RSA-PSS operator attestations + a recomputed §4
53
+ // verdict object with per-gateway on-chain outcomes).
54
+ export {
55
+ ANCHOR_TRACE_BODY_TYPE,
56
+ EXPORT_BODY_TYPE,
57
+ recomputeExportVerdict,
58
+ verifyEvidenceBundle,
59
+ } from "./evidence.js";
60
+ export type {
61
+ AnchorTraceBody,
62
+ AttestationPayload,
63
+ AttestationRecord,
64
+ AttestationResult,
65
+ CheckpointResult,
66
+ ComposeVerdictResult,
67
+ EvidenceBundle,
68
+ EvidenceBundleResult,
69
+ EvidenceIssuer,
70
+ EvidenceStatus,
71
+ EvidenceVerdict,
72
+ EventResult,
73
+ ExportBody,
74
+ ExportResult,
75
+ OnChainOutcome,
76
+ OnChainResult,
77
+ PerGatewayOutcome,
78
+ TraceCheckpoint,
79
+ TraceEvent,
80
+ TraceInclusion,
81
+ VerdictAttestation,
82
+ VerdictCheckpoint,
83
+ VerdictEvent,
84
+ VerdictObject,
85
+ VerdictOnChain,
86
+ VerdictOnChainGateway,
87
+ VerifyEvidenceOptions,
88
+ } from "./evidence.js";
89
+
90
+ // The `proof export` composer (specs/evidence-export.md §5, phase 2): produce a
91
+ // signed, offline-verifiable sanning.evidence.export/v1 artifact from a source
92
+ // anchor-trace bundle + operator attestations + an exporter Ed25519 key. The
93
+ // inverse of verifyEvidenceBundle's verifyExportBody dispatch.
94
+ export { attestationSignatureToHex, composeExport } from "./compose.js";
95
+ export type { ComposeExportOptions, ComposeExportResult, ExporterKey } from "./compose.js";
96
+
97
+ // Agent inclusion-proof bundle verification: the
98
+ // sanning.agent.proof/v1 bundle. The CLI sniffs spec_version and routes here so
99
+ // one verifier covers both agent and anchor bundles.
100
+ export { AGENT_PROOF_SPEC_PREFIX, isAgentProofSpec, verifyAgentProofBundle } from "./agent-proof.js";
101
+ export type {
102
+ AgentProofBundle,
103
+ AgentProofResult,
104
+ VerifyAgentProofOptions,
105
+ } from "./agent-proof.js";
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 (the agent producer's pkg/merkle), in lockstep
4
+ // with the Python kernel's sanning_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.js";
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,72 @@
1
+ // Wire types for Sanning Verifiable Event Envelopes (sanning-io/proof
2
+ // specs/envelope-spec.md). We model only the fields the verifier consumes;
3
+ // unknown fields are preserved by structural typing (envelopes are verified
4
+ // over their canonical bytes, not this interface).
5
+
6
+ export interface Subject {
7
+ type: string;
8
+ tenant_id?: string;
9
+ agent_id?: string;
10
+ }
11
+
12
+ export interface Envelope {
13
+ spec_version: string;
14
+ event_id: string;
15
+ payload_hash: string;
16
+ signed_at: string;
17
+ public_key: string;
18
+ signature: string;
19
+ // Inline-binding profiles (envelope-spec §3) carry the committed payload in
20
+ // the envelope; external-commitment profiles do not — the caller supplies
21
+ // the committed bytes to bind (verifyEnvelope's payloadBytes option).
22
+ payload?: Record<string, unknown>;
23
+ // Disclosure fields (§3.1): present on Promoted profiles, absent on Minimal
24
+ // ones (they live inside the committed payload instead).
25
+ event_type?: string;
26
+ subject?: Subject;
27
+ previous_hash?: string;
28
+ // Optional v1.1+ fields: dev/production marking inside the signed scope,
29
+ // and the external-commitment locator.
30
+ environment?: string;
31
+ payload_ref?: string;
32
+ // Reserved, default-absent (envelope-spec §7.1): countersignatures over the
33
+ // same skeleton. OUTSIDE the signed scope — the primary signature covers the
34
+ // envelope minus `signature` AND minus `co_signatures`, so a countersignature
35
+ // can be added without invalidating the primary. Absence implies a single
36
+ // signer and MUST NOT be treated as a failure.
37
+ co_signatures?: unknown[];
38
+ }
39
+
40
+ // Which payload field a matched content hash came from. tamper_detected commits
41
+ // to both the tampered ("observed") bytes and the known-good ("baseline") bytes.
42
+ export type ContentRole = "asset" | "baseline" | "observed";
43
+
44
+ export interface VerifyOptions {
45
+ // External commitment (§3): the committed canonical payload bytes. When
46
+ // supplied, SHA-256(payloadBytes) must equal the envelope's payload_hash.
47
+ payloadBytes?: Uint8Array;
48
+ // Content bind: the caller's own hash of an artifact (e.g. the in-browser
49
+ // hash of a user's file) to match against the hashes the envelope commits to.
50
+ expectedContentHash?: string;
51
+ }
52
+
53
+ export interface VerificationResult {
54
+ // Cryptographic validity: spec_version ok, Ed25519 signature ok, and the
55
+ // payload binding did not FAIL. payloadHashOk === null (binding not
56
+ // checkable: external-commitment envelope, no payloadBytes supplied) does
57
+ // not fail ok — that is the "signature-valid, semantics-undetermined"
58
+ // outcome of envelope-spec §3.1/§6.2.
59
+ ok: boolean;
60
+ specVersionOk: boolean;
61
+ // true: binding checked and matches (inline recompute, external bytes, or
62
+ // both when both were available). false: a check failed, or payload_hash is
63
+ // missing/malformed. null: no binding material to check against.
64
+ payloadHashOk: boolean | null;
65
+ signatureOk: boolean;
66
+ // Content bind: did the hash the caller supplied match a hash this envelope
67
+ // commits to? null when no hash was supplied. This is the check that defeats
68
+ // a lying gateway — the tag only got us to the candidate.
69
+ contentHashOk: boolean | null;
70
+ contentRole: ContentRole | null;
71
+ errors: string[];
72
+ }
@@ -0,0 +1,340 @@
1
+ // Independent client-side implementation of the envelope verification
2
+ // algorithm (specs/envelope-spec.md). This is deliberately a second
3
+ // implementation of the same algorithm the Go kernel runs — a second,
4
+ // conformance-tested verifier is what demonstrates the product's claim:
5
+ // verification needs no Sanning code in the trust path.
6
+ //
7
+ // Conformance is enforced by test/conformance.test.ts, which runs every
8
+ // corpus 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.js";
13
+ import type { ContentRole, Envelope, VerificationResult, VerifyOptions } from "./types.js";
14
+
15
+ // Fail-closed accepted-profile registry (envelope-spec §2): exactly the
16
+ // accepted profile majors, nothing else. Minors within an accepted
17
+ // major ("sanning.agent/v1.<minor>") are additive and tolerated — matching the Go
18
+ // reference kernel's semantics (pkg/proof isSupportedSpec) so the kernels do
19
+ // not diverge on minors. Accepting a new profile is a deliberate one-entry
20
+ // addition HERE and only here. sanning.events/v1 (external commitment + Minimal
21
+ // disclosure; envelope-spec §4) is verified through the SAME primitives — the
22
+ // signed scope is still envelope-minus-signature-minus-co_signatures, with no
23
+ // profile-specific dialect (no underscore strip, that is mlflow-only).
24
+ //
25
+ // Exported for parity with the sibling kernels' registries — Python
26
+ // `ACCEPTED_SPEC_VERSIONS`, Go `SupportedSpecMajor` — so the accept-set is
27
+ // assertable rather than inferable: test/accept-set.test.ts holds it to the
28
+ // ratified §4/§5 set transcribed in cross-kernel/accept-set.json, and the
29
+ // cross-kernel gate probes one signed envelope per identifier. This entry list
30
+ // is NOT the ratified accept-set today: sanning.mlflow/v1 is in the ratified
31
+ // set and absent here, an escalated divergence (cross-kernel/README.md
32
+ // § Known divergences) whose resolution is a founder decision, not an edit.
33
+ //
34
+ // FROZEN, and that is load-bearing, not hygiene: `specVersionSupported` (and
35
+ // therefore `verifyEnvelope`) reads THIS array, so an exported mutable one
36
+ // would let any code sharing the module instance widen the accept-set of a
37
+ // running verifier — `ACCEPTED_SPEC_MAJORS.push("x/v1")` and the kernel starts
38
+ // admitting `x/v1`. `Object.freeze` makes that throw (the package is ESM, so
39
+ // always strict mode) and the `readonly string[]` type rejects it at compile
40
+ // time. The elements are strings, so a shallow freeze is a deep one. Pinned by
41
+ // test/accept-set.test.ts "the exported registry cannot be widened at runtime".
42
+ export const ACCEPTED_SPEC_MAJORS: readonly string[] = Object.freeze([
43
+ "sanning.agent/v1",
44
+ "sanning.events/v1",
45
+ ]);
46
+
47
+ // Maximum JSON container-nesting depth a verifier will canonicalize. Inputs
48
+ // nested deeper are rejected as malformed BEFORE canonicalization runs. This is
49
+ // a NORMATIVE cross-kernel invariant (envelope-spec §2 shared-invariant 7): the
50
+ // bound must be the same fixed constant in every kernel. Without it, a
51
+ // deeply-nested body one kernel canonicalizes on its large native stack while
52
+ // another hits its recursion limit (CPython defaults to ~1000 frames) yields a
53
+ // split verdict — the same bytes reported `verified`/`failed` by one kernel and
54
+ // crash/`malformed` by another, on a depth the producer of the bytes fully
55
+ // controls. 128 is >10x any legitimate evidence structure (which nests <15) and
56
+ // safely under the tightest kernel recursion limit, with headroom for the JCS
57
+ // library's own frames-per-level. Mirrors the Python kernel's MAX_CANONICAL_DEPTH.
58
+ export const MAX_CANONICAL_DEPTH = 128;
59
+
60
+ // Iteratively test whether `value` nests JSON containers deeper than `max`
61
+ // levels; short-circuits `true` on the first over-deep path. Deliberately NOT
62
+ // recursive: this guard runs on already-parsed, possibly-hostile input, so it
63
+ // must never itself overflow the call stack — it walks an explicit heap stack
64
+ // instead. The root container is depth 1; each nested array/object is one
65
+ // deeper; scalars have no children and never increase depth.
66
+ export function exceedsDepth(value: unknown, max: number): boolean {
67
+ const stack: Array<[unknown, number]> = [[value, 1]];
68
+ while (stack.length > 0) {
69
+ const [node, depth] = stack.pop()!;
70
+ if (node === null || typeof node !== "object") continue; // scalar — always fine
71
+ if (depth > max) return true; // a container nested past the bound
72
+ const children = Array.isArray(node) ? node : Object.values(node);
73
+ for (const child of children) stack.push([child, depth + 1]);
74
+ }
75
+ return false;
76
+ }
77
+
78
+ // RFC 8785 (JCS) canonicalization. The `canonicalize` package is the reference
79
+ // JS implementation; correctness is pinned by the conformance vectors.
80
+ //
81
+ // Nesting is bounded FIRST (MAX_CANONICAL_DEPTH): the depth guard throws before
82
+ // `rejectLoneSurrogates` (recursive) or `canonicalize` (recursive) ever see the
83
+ // input, so neither can overflow, and a too-deep input is a caller/input error
84
+ // that the callers bucket as malformed (same contract as a malformed key).
85
+ //
86
+ // Lone (unpaired) UTF-16 surrogates are REJECTED, never passed through: RFC
87
+ // 8785 requires well-formed UTF-8 output, JS strings can carry lone
88
+ // surrogates that `canonicalize` would emit as-is, and the sibling kernels
89
+ // cannot represent them (Python raises on encode; Go's JSON decoder replaces
90
+ // them). Reject-only is the one behavior all three kernels can share —
91
+ // pinned here and by the corpus lone-surrogate negative.
92
+ export function jcs(value: unknown): string {
93
+ if (exceedsDepth(value, MAX_CANONICAL_DEPTH)) {
94
+ throw new Error(
95
+ `jcs: input nesting exceeds the ${MAX_CANONICAL_DEPTH}-level canonicalization depth bound`,
96
+ );
97
+ }
98
+ rejectLoneSurrogates(value);
99
+ const canonical = canonicalize(value);
100
+ if (typeof canonical !== "string") {
101
+ throw new Error("jcs: canonicalize returned a non-string (input not JSON-serializable?)");
102
+ }
103
+ return canonical;
104
+ }
105
+
106
+ // Walk every string in the value (keys and values). The check must run on
107
+ // the INPUT: `canonicalize` escapes a lone surrogate as `\udXXX` text in its
108
+ // output, so the malformed code unit is invisible after serialization.
109
+ function rejectLoneSurrogates(value: unknown): void {
110
+ if (typeof value === "string") {
111
+ if (hasLoneSurrogate(value)) {
112
+ throw new Error(
113
+ "jcs: input contains a lone UTF-16 surrogate (not representable as UTF-8)",
114
+ );
115
+ }
116
+ return;
117
+ }
118
+ if (Array.isArray(value)) {
119
+ for (const v of value) rejectLoneSurrogates(v);
120
+ return;
121
+ }
122
+ if (value !== null && typeof value === "object") {
123
+ for (const [k, v] of Object.entries(value)) {
124
+ rejectLoneSurrogates(k);
125
+ rejectLoneSurrogates(v);
126
+ }
127
+ }
128
+ }
129
+
130
+ function hasLoneSurrogate(s: string): boolean {
131
+ for (let i = 0; i < s.length; i++) {
132
+ const c = s.charCodeAt(i);
133
+ if (c >= 0xd800 && c <= 0xdbff) {
134
+ const next = i + 1 < s.length ? s.charCodeAt(i + 1) : 0;
135
+ if (next >= 0xdc00 && next <= 0xdfff) {
136
+ i++; // valid pair
137
+ continue;
138
+ }
139
+ return true; // high surrogate without a low
140
+ }
141
+ if (c >= 0xdc00 && c <= 0xdfff) return true; // low surrogate without a high
142
+ }
143
+ return false;
144
+ }
145
+
146
+ // Grammar-strict accept check (envelope-spec §2): `<major>` exactly, or
147
+ // `<major>.<minor>` where minor is `[0-9]+`. A non-numeric minor suffix is
148
+ // malformed, not a tolerated future version — matching the Python kernel's
149
+ // 0.1.1 semantics (the TS/Go fail-open lenience was closed deliberately).
150
+ export function specVersionSupported(specVersion: string): boolean {
151
+ if (typeof specVersion !== "string" || specVersion === "") return false;
152
+ return ACCEPTED_SPEC_MAJORS.some((m) => {
153
+ if (specVersion === m) return true;
154
+ if (!specVersion.startsWith(`${m}.`)) return false;
155
+ const minor = specVersion.slice(m.length + 1);
156
+ return minor.length > 0 && /^[0-9]+$/.test(minor);
157
+ });
158
+ }
159
+
160
+ // The content hash(es) an envelope commits to, by event type — the values a
161
+ // reverse lookup can match the user's in-browser file hash against.
162
+ // asset_registered -> payload.hash (the registered, known-good bytes)
163
+ // asset_missing -> payload.baseline.hash (last known-good bytes that vanished)
164
+ // tamper_detected -> payload.observed.hash (the tampered bytes that were flagged)
165
+ // -> payload.baseline.hash (the known-good bytes it diverged from)
166
+ // Other event types commit to no asset content hash.
167
+ export function contentHashes(env: Envelope): { role: ContentRole; hash: string }[] {
168
+ // Minimal-disclosure / external-commitment envelopes have no inline payload
169
+ // (and no event_type) — they commit to no envelope-readable content hash.
170
+ const p = (env.payload ?? {}) as {
171
+ hash?: unknown;
172
+ baseline?: { hash?: unknown };
173
+ observed?: { hash?: unknown };
174
+ };
175
+ const asStr = (v: unknown): string | undefined => (typeof v === "string" && v ? v : undefined);
176
+
177
+ const out: { role: ContentRole; hash: string }[] = [];
178
+ switch (env.event_type) {
179
+ case "asset_registered": {
180
+ const h = asStr(p.hash);
181
+ if (h) out.push({ role: "asset", hash: h });
182
+ break;
183
+ }
184
+ case "asset_missing": {
185
+ const h = asStr(p.baseline?.hash);
186
+ if (h) out.push({ role: "baseline", hash: h });
187
+ break;
188
+ }
189
+ case "tamper_detected": {
190
+ const obs = asStr(p.observed?.hash);
191
+ if (obs) out.push({ role: "observed", hash: obs });
192
+ const base = asStr(p.baseline?.hash);
193
+ if (base) out.push({ role: "baseline", hash: base });
194
+ break;
195
+ }
196
+ }
197
+ return out;
198
+ }
199
+
200
+ // Verify an envelope — both binding modes (envelope-spec §3), one rule,
201
+ // mirroring the Python reference kernel exactly:
202
+ //
203
+ // - envelope carries `payload` → inline check: recompute
204
+ // SHA-256(JCS(payload)) and compare to payload_hash
205
+ // - caller supplies `payloadBytes` → external check:
206
+ // SHA-256(payloadBytes) must equal payload_hash
207
+ // - both available → both must pass
208
+ // - neither → payloadHashOk = null and the
209
+ // verdict does NOT fail: "signature-valid, semantics-undetermined"
210
+ // (§3.1/§6.2). The signature alone still proves who signed what bytes.
211
+ //
212
+ // payload_hash itself MUST be present (a string) in every mode — its
213
+ // absence is rejected per envelope-spec §2, independent of whether there is
214
+ // material to compare it against.
215
+ //
216
+ // Binding-mode detection is structural, not registry-driven: mode confusion
217
+ // is closed by the signed scope itself (an inline payload is INSIDE the
218
+ // signature, so stripping it to fake "external" — or injecting one to fake
219
+ // "inline" — breaks the signature).
220
+ //
221
+ // The second argument accepts the legacy `expectedContentHash` string form
222
+ // or a VerifyOptions object ({ payloadBytes, expectedContentHash }).
223
+ export async function verifyEnvelope(
224
+ env: Envelope,
225
+ optionsOrContentHash?: string | VerifyOptions,
226
+ ): Promise<VerificationResult> {
227
+ const opts: VerifyOptions =
228
+ typeof optionsOrContentHash === "string"
229
+ ? { expectedContentHash: optionsOrContentHash }
230
+ : (optionsOrContentHash ?? {});
231
+ const expectedContentHash = opts.expectedContentHash;
232
+ const errors: string[] = [];
233
+
234
+ // Guard a malformed input (null / non-object / array) up front — every field
235
+ // access below assumes an object. A hostile gateway or a hand-edited report
236
+ // can supply anything; treat it as "not verified," never a thrown exception.
237
+ if (env === null || typeof env !== "object" || Array.isArray(env)) {
238
+ return {
239
+ ok: false,
240
+ specVersionOk: false,
241
+ payloadHashOk: false,
242
+ signatureOk: false,
243
+ contentHashOk: expectedContentHash === undefined ? null : false,
244
+ contentRole: null,
245
+ errors: ["envelope is not a JSON object"],
246
+ };
247
+ }
248
+
249
+ const specVersionOk = specVersionSupported(env.spec_version);
250
+ if (!specVersionOk) errors.push(`unsupported spec_version: ${JSON.stringify(env.spec_version)}`);
251
+
252
+ // Check 1 — Payload binding (both modes; see header).
253
+ //
254
+ // payload_hash MUST be present and a string in every mode — envelope-spec
255
+ // §2: "a verifier cannot proceed without spec_version / public_key /
256
+ // signature / payload_hash and MUST reject their absence." All three
257
+ // kernels enforce this (the Python reference's prior compare-only-if-present
258
+ // lenience was the bug; §2 is binding).
259
+ //
260
+ // When present, it is COMPARED only against material that exists — an
261
+ // inline payload, supplied committed bytes, or both. With neither, the
262
+ // binding is undetermined (null): "signature-valid, semantics-undetermined"
263
+ // (§3.1/§6.2). null does not fail the verdict; absence does.
264
+ let payloadHashOk: boolean | null;
265
+ if (typeof env.payload_hash !== "string") {
266
+ payloadHashOk = false;
267
+ errors.push("missing payload_hash (envelope-spec §2: required in every profile)");
268
+ } else {
269
+ const checks: boolean[] = [];
270
+ if ("payload" in env && env.payload !== undefined) {
271
+ try {
272
+ const recomputed = await sha256Hex(utf8(jcs(env.payload)));
273
+ checks.push(recomputed === env.payload_hash);
274
+ if (recomputed !== env.payload_hash) {
275
+ errors.push(
276
+ `payload_hash mismatch: envelope=${env.payload_hash} recomputed=${recomputed}`,
277
+ );
278
+ }
279
+ } catch (e) {
280
+ checks.push(false);
281
+ errors.push(`payload canonicalization failed: ${stringifyErr(e)}`);
282
+ }
283
+ }
284
+ if (opts.payloadBytes !== undefined) {
285
+ const external = await sha256Hex(opts.payloadBytes);
286
+ checks.push(external === env.payload_hash);
287
+ if (external !== env.payload_hash) {
288
+ errors.push(
289
+ `payload_hash does not match the committed bytes: envelope=${env.payload_hash} bytes=${external}`,
290
+ );
291
+ }
292
+ }
293
+ payloadHashOk = checks.length > 0 ? checks.every(Boolean) : null;
294
+ }
295
+
296
+ // Check 2 — Signature Confirmed: Ed25519 over the signed scope, which is
297
+ // JCS(envelope minus `signature` minus `co_signatures`) per envelope-spec §2.
298
+ // The co_signatures carve-out (§7.1) lets a countersignature be added without
299
+ // invalidating the primary signature; the field is reserved/default-absent.
300
+ // The corpus has no co-signed vectors, so this strip is pinned by an explicit
301
+ // unit test rather than by conformance.
302
+ let signatureOk = false;
303
+ try {
304
+ const { signature: _signature, co_signatures: _coSignatures, ...envelopeForSig } = env;
305
+ signatureOk = await ed25519Verify(env.signature, utf8(jcs(envelopeForSig)), env.public_key);
306
+ if (!signatureOk) errors.push("Ed25519 signature verification failed");
307
+ } catch (e) {
308
+ errors.push(`signature verification error: ${stringifyErr(e)}`);
309
+ }
310
+
311
+ // Check 3 (optional) — Content Bind: the user's bytes match a hash this
312
+ // envelope commits to. The tag got us here; only this proves the bytes match.
313
+ let contentHashOk: boolean | null = null;
314
+ let contentRole: ContentRole | null = null;
315
+ if (expectedContentHash !== undefined) {
316
+ const want = expectedContentHash.toLowerCase();
317
+ const match = contentHashes(env).find((c) => c.hash.toLowerCase() === want);
318
+ contentHashOk = match !== undefined;
319
+ contentRole = match ? match.role : null;
320
+ if (!contentHashOk) {
321
+ errors.push("provided content hash does not match any hash this envelope commits to");
322
+ }
323
+ }
324
+
325
+ return {
326
+ // null (binding not checkable) does not fail the verdict — Python-kernel
327
+ // parity: ok = spec && sig && payloadHashOk is not False.
328
+ ok: specVersionOk && signatureOk && payloadHashOk !== false,
329
+ specVersionOk,
330
+ payloadHashOk,
331
+ signatureOk,
332
+ contentHashOk,
333
+ contentRole,
334
+ errors,
335
+ };
336
+ }
337
+
338
+ function stringifyErr(e: unknown): string {
339
+ return e instanceof Error ? e.message : String(e);
340
+ }