@sanning/proof 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +168 -0
- package/dist/agent-proof.d.ts +28 -0
- package/dist/agent-proof.js +188 -0
- package/dist/cli.d.ts +8 -0
- package/dist/cli.js +569 -0
- package/dist/compose.d.ts +19 -0
- package/dist/compose.js +124 -0
- package/dist/crypto.d.ts +16 -0
- package/dist/crypto.js +215 -0
- package/dist/evidence.d.ts +214 -0
- package/dist/evidence.js +1049 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +38 -0
- package/dist/merkle.d.ts +6 -0
- package/dist/merkle.js +114 -0
- package/dist/types.d.ts +34 -0
- package/dist/types.js +5 -0
- package/dist/verifier.d.ts +11 -0
- package/dist/verifier.js +321 -0
- package/package.json +59 -0
- package/src/agent-proof.ts +229 -0
- package/src/cli.ts +619 -0
- package/src/compose.ts +189 -0
- package/src/crypto.ts +243 -0
- package/src/evidence.ts +1491 -0
- package/src/index.ts +105 -0
- package/src/merkle.ts +125 -0
- package/src/types.ts +72 -0
- package/src/verifier.ts +340 -0
package/dist/index.d.ts
ADDED
|
@@ -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";
|
package/dist/merkle.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -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>;
|
package/dist/verifier.js
ADDED
|
@@ -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
|
+
}
|