@sanning/proof 0.4.0

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