@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/compose.ts ADDED
@@ -0,0 +1,189 @@
1
+ // Attested-evidence-export composer (`proof export`, evidence-export.md §5,
2
+ // phase 2). The last-mile issuer/runtime job the kernel does NOT own (§5.1
3
+ // "Out of the kernel"): take a source `sanning.anchor.trace/v1` bundle + a set of
4
+ // operator attestation records + an exporter Ed25519 key, and PRODUCE one signed,
5
+ // offline-verifiable `sanning.evidence.export/v1` artifact — exactly the bytes the
6
+ // kernel's verifyExportBody round-trip-verifies.
7
+ //
8
+ // This is the inverse of evidence.ts's verifyExportBody, and it is deliberately
9
+ // thin: it never reimplements the verdict recompute (it calls the kernel's own
10
+ // recomputeExportVerdict, which reuses verifyEvidenceBundle + verifyAttestation +
11
+ // buildVerdictObject), so the cached kernel_verdict it embeds cannot drift from
12
+ // what the verifier recomputes (§5 step 5). Pure/offline — no network in this
13
+ // slice; a `--sidecar <url>` attestation fetch is a documented follow-up (§5.1),
14
+ // not built here.
15
+ //
16
+ // Trust boundary (P-4, "anyone can export"): the WRAPPER is Ed25519-signed by the
17
+ // exporter's key; the embedded operator attestations keep their own RSA-PSS
18
+ // signatures untouched (the composer never re-signs them — it is not the
19
+ // operator). The only per-record transformation is a boundary transcode of the
20
+ // attestation `signature` to the lowercase hex §2.2 stores; that field is OUTSIDE
21
+ // the signed attestation payload (JCS(payload)), so re-encoding it is safe and
22
+ // does not invalidate the operator signature.
23
+
24
+ import {
25
+ EXPORT_BODY_TYPE,
26
+ recomputeExportVerdict,
27
+ } from "./evidence.js";
28
+ import type {
29
+ AttestationRecord,
30
+ EvidenceBundle,
31
+ EvidenceStatus,
32
+ ExportBody,
33
+ } from "./evidence.js";
34
+ import {
35
+ base64UrlToBytes,
36
+ bytesToHex,
37
+ ed25519PublicKey,
38
+ ed25519Sign,
39
+ sha256Hex,
40
+ utf8,
41
+ } from "./crypto.js";
42
+ import { jcs } from "./verifier.js";
43
+
44
+ // The exporter's Ed25519 wrapper-signing key. `privateKey` is a 32-byte seed as
45
+ // lowercase hex (the @noble/ed25519 / stack convention, e.g. the fixtures'
46
+ // SEED_HEX). `publicKey` is derived from it when omitted.
47
+ export interface ExporterKey {
48
+ privateKey: string;
49
+ publicKey?: string;
50
+ }
51
+
52
+ export interface ComposeExportOptions {
53
+ // The composing issuer's instance id → wrapper issuer.issuer_id (§2.1).
54
+ // Identity CONTEXT only; the load-bearing key is the wrapper public_key.
55
+ issuerId?: string;
56
+ // Named delivery surface for the wrapper (§2.1). Not trusted. Default null.
57
+ gateway?: string | null;
58
+ // Wrapper custody pointer (§2.3): GENESIS for a one-off export (default), or
59
+ // SHA-256(JCS(prior wrapper without signature)) for a re-issued chain link.
60
+ previousHash?: string;
61
+ // RFC 3339 compose time → wrapper generated_at + verdict as_of (§2.1). Default
62
+ // new Date().toISOString().
63
+ generatedAt?: string;
64
+ }
65
+
66
+ export interface ComposeExportResult {
67
+ // The signed, offline-verifiable sanning.evidence.export/v1 artifact.
68
+ bundle: EvidenceBundle;
69
+ // The export rollup the embedded kernel_verdict carries (verified|partial|failed).
70
+ status: EvidenceStatus;
71
+ // SHA-256(JCS(source_bundle)) linkage commitment (§2.2), for logging.
72
+ sourceBundleHash: string;
73
+ // Count of embedded attestations that fully bound (sig + operator + data_hash).
74
+ boundAttestations: number;
75
+ }
76
+
77
+ const GENESIS = "GENESIS";
78
+ const EXPORT_SCHEMA = "sanning.evidence.export/v1";
79
+
80
+ // Transcode an attestation `signature` to the lowercase hex §2.2 mandates. The
81
+ // issuer emits either lowercase-hex or unpadded base64url; §2.2 stores hex, so
82
+ // this is the composer's boundary normalization. Heuristic mirrors the repo's
83
+ // existing hex-vs-text discriminator (cli.ts decodeLogValue): an even-length,
84
+ // all-hex-digit string is already hex (lowercased); anything else is decoded as
85
+ // base64url and hex-encoded. A 256-byte RSA signature is 512 hex chars vs ~342
86
+ // base64url chars, so the two never collide in practice. Throws on a value that
87
+ // is neither.
88
+ export function attestationSignatureToHex(signature: string): string {
89
+ if (signature.length > 0 && signature.length % 2 === 0 && /^[0-9a-fA-F]+$/.test(signature)) {
90
+ return signature.toLowerCase();
91
+ }
92
+ return bytesToHex(base64UrlToBytes(signature));
93
+ }
94
+
95
+ // Normalize one attestation record for embedding: deep-clone (never mutate the
96
+ // caller's input) and transcode its signature to lowercase hex. Everything else
97
+ // — payload, public_key, signature_alg, checkpoint_tx_id — rides verbatim so the
98
+ // operator's RSA-PSS signature over JCS(payload) remains valid.
99
+ function normalizeAttestation(rec: AttestationRecord): AttestationRecord {
100
+ const cloned = JSON.parse(JSON.stringify(rec)) as AttestationRecord;
101
+ if (typeof cloned.signature !== "string") {
102
+ throw new Error("attestation record missing a string `signature`");
103
+ }
104
+ cloned.signature = attestationSignatureToHex(cloned.signature);
105
+ return cloned;
106
+ }
107
+
108
+ // Compose an attested evidence export (evidence-export.md §5).
109
+ //
110
+ // 1. Normalize + transcode the attestation records (boundary hex, above).
111
+ // 2. Recompute the §4 kernel_verdict over the source bundle + attestations,
112
+ // reusing the kernel's own verify path (recomputeExportVerdict) so the cached
113
+ // copy byte-agrees with the verifier's later recompute (§5 step 5).
114
+ // 3. Assemble the export body: kernel_verdict + inline source_bundle +
115
+ // source_bundle_hash = SHA-256(JCS(source_bundle)) + attestations[] + schema.
116
+ // 4. Build the sanning.evidence/v1 wrapper (body_type export, Ed25519), strip
117
+ // signature, JCS, Ed25519-sign with the exporter key, attach.
118
+ //
119
+ // Returns the signed artifact + a small compose summary.
120
+ export async function composeExport(
121
+ sourceBundle: EvidenceBundle,
122
+ attestations: AttestationRecord[],
123
+ exporterKey: ExporterKey,
124
+ options: ComposeExportOptions = {},
125
+ ): Promise<ComposeExportResult> {
126
+ if (sourceBundle === null || typeof sourceBundle !== "object" || Array.isArray(sourceBundle)) {
127
+ throw new Error("composeExport: source bundle must be a JSON object");
128
+ }
129
+ if (!Array.isArray(attestations)) {
130
+ throw new Error("composeExport: attestations must be an array");
131
+ }
132
+ if (typeof exporterKey?.privateKey !== "string" || exporterKey.privateKey.length === 0) {
133
+ throw new Error("composeExport: exporterKey.privateKey (32-byte hex seed) is required");
134
+ }
135
+
136
+ const generatedAt = options.generatedAt ?? new Date().toISOString();
137
+ const previousHash = options.previousHash ?? GENESIS;
138
+ const issuerId = options.issuerId ?? "sanning-verify";
139
+ const gateway = options.gateway ?? null;
140
+
141
+ // (1) boundary-transcode the attestation signatures to hex.
142
+ const records = attestations.map(normalizeAttestation);
143
+
144
+ // (2) recompute the §4 verdict via the KERNEL's own verify path (no reimpl).
145
+ const composed = await recomputeExportVerdict(sourceBundle, records, generatedAt);
146
+
147
+ // (3) assemble the export body. source_bundle_hash is SHA-256(JCS(source)) —
148
+ // the linkage commitment the wrapper signature transitively covers (§2.4).
149
+ const sourceBundleHash = await sha256Hex(utf8(jcs(sourceBundle)));
150
+ const body: ExportBody = {
151
+ kernel_verdict: composed.verdict,
152
+ source_bundle: sourceBundle,
153
+ source_bundle_hash: sourceBundleHash,
154
+ attestations: records,
155
+ export_schema: EXPORT_SCHEMA,
156
+ };
157
+
158
+ // (4) build + sign the sanning.evidence/v1 wrapper. The wrapper `verdict` is the
159
+ // coarse family rollup DERIVED from body.kernel_verdict (display context, never
160
+ // trusted — the verifier recomputes). body_hash = SHA-256(JCS(body)).
161
+ const publicKey = exporterKey.publicKey ?? (await ed25519PublicKey(exporterKey.privateKey));
162
+ const wrapperPre = {
163
+ spec_version: "sanning.evidence/v1",
164
+ body_type: EXPORT_BODY_TYPE,
165
+ issuer: { kind: "issuer", issuer_id: issuerId },
166
+ generated_at: generatedAt,
167
+ gateway,
168
+ verdict: {
169
+ status: composed.verdict.status,
170
+ ...(composed.verdict.summary !== undefined ? { summary: composed.verdict.summary } : {}),
171
+ ...(composed.verdict.counts !== undefined ? { counts: composed.verdict.counts } : {}),
172
+ as_of: generatedAt,
173
+ },
174
+ body,
175
+ body_hash: await sha256Hex(utf8(jcs(body))),
176
+ previous_hash: previousHash,
177
+ signature_alg: "ed25519",
178
+ public_key: publicKey,
179
+ };
180
+ const signature = await ed25519Sign(utf8(jcs(wrapperPre)), exporterKey.privateKey);
181
+ const bundle: EvidenceBundle = { ...wrapperPre, signature };
182
+
183
+ return {
184
+ bundle,
185
+ status: composed.status,
186
+ sourceBundleHash,
187
+ boundAttestations: composed.attestations.filter((a) => a.ok).length,
188
+ };
189
+ }
package/src/crypto.ts ADDED
@@ -0,0 +1,243 @@
1
+ // Low-level cryptographic primitives for the verifier. Deliberately thin: the
2
+ // only third-party crypto dependency is @noble/ed25519 (single-file, audited,
3
+ // zero-dependency). SHA-256 and SHA-512 come from WebCrypto — no extra hashing
4
+ // library to trust.
5
+
6
+ import * as ed from "@noble/ed25519";
7
+
8
+ // @noble/ed25519 needs SHA-512 to verify. Wire it to WebCrypto rather than
9
+ // pulling in @noble/hashes. globalThis.crypto.subtle exists in every modern
10
+ // browser and in Node >= 19, so the same code path runs in the app and in tests.
11
+ ed.etc.sha512Async = async (...msgs: Uint8Array[]): Promise<Uint8Array> =>
12
+ new Uint8Array(await crypto.subtle.digest("SHA-512", asBufferSource(ed.etc.concatBytes(...msgs))));
13
+
14
+ // WebCrypto's digest() wants a BufferSource backed by a (non-shared) ArrayBuffer.
15
+ // Our byte arrays always are; this keeps TS 5.7's stricter Uint8Array<ArrayBufferLike>
16
+ // typing satisfied without a runtime copy.
17
+ function asBufferSource(bytes: Uint8Array): BufferSource {
18
+ return bytes as unknown as BufferSource;
19
+ }
20
+
21
+ export function bytesToHex(bytes: Uint8Array): string {
22
+ let out = "";
23
+ for (const b of bytes) out += b.toString(16).padStart(2, "0");
24
+ return out;
25
+ }
26
+
27
+ export function hexToBytes(hex: string): Uint8Array {
28
+ if (hex.length % 2 !== 0) throw new Error("hexToBytes: odd-length string");
29
+ // Full validation: parseInt() partially parses ("1g" -> 1), which would let
30
+ // malformed hex through. A strict charset check closes that.
31
+ if (!/^[0-9a-fA-F]*$/.test(hex)) throw new Error("hexToBytes: non-hex characters");
32
+ const out = new Uint8Array(hex.length / 2);
33
+ for (let i = 0; i < out.length; i++) {
34
+ out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
35
+ }
36
+ return out;
37
+ }
38
+
39
+ export function utf8(s: string): Uint8Array {
40
+ return new TextEncoder().encode(s);
41
+ }
42
+
43
+ export async function sha256Bytes(bytes: Uint8Array): Promise<Uint8Array> {
44
+ return new Uint8Array(await crypto.subtle.digest("SHA-256", asBufferSource(bytes)));
45
+ }
46
+
47
+ export async function sha256Hex(bytes: Uint8Array): Promise<string> {
48
+ return bytesToHex(await sha256Bytes(bytes));
49
+ }
50
+
51
+ // Verify an Ed25519 signature. Inputs are hex (signature, public key) as they
52
+ // appear in the envelope; message is raw bytes. Never throws — a malformed
53
+ // signature or key is "not verified," not an exception, so a hostile envelope
54
+ // can't crash the checker.
55
+ export async function ed25519Verify(
56
+ signatureHex: string,
57
+ message: Uint8Array,
58
+ publicKeyHex: string,
59
+ ): Promise<boolean> {
60
+ try {
61
+ // zip215:false → strict RFC-8032 verification, matching the Go agent's
62
+ // crypto/ed25519 exactly (noble defaults to the more lenient zip215:true).
63
+ return await ed.verifyAsync(hexToBytes(signatureHex), message, hexToBytes(publicKeyHex), {
64
+ zip215: false,
65
+ });
66
+ } catch {
67
+ return false;
68
+ }
69
+ }
70
+
71
+ // Ed25519 SIGN the raw `message` bytes with a 32-byte private-key seed (lowercase
72
+ // hex). Returns the signature as lowercase hex. The signing counterpart to
73
+ // ed25519Verify — the ONE signing primitive the composer (`proof export`,
74
+ // evidence-export.md §5) needs to Ed25519-sign the export wrapper with the
75
+ // exporter's key ("anyone can export", P-4). The @noble sha512Async hook wired at
76
+ // module load serves signing and verification alike, so this stays WebCrypto-only.
77
+ export async function ed25519Sign(message: Uint8Array, privateKeyHex: string): Promise<string> {
78
+ return bytesToHex(await ed.signAsync(message, hexToBytes(privateKeyHex)));
79
+ }
80
+
81
+ // Derive the Ed25519 public key (lowercase hex) from a 32-byte private-key seed
82
+ // (lowercase hex) — the wrapper's embedded `public_key` when composing an export.
83
+ export async function ed25519PublicKey(privateKeyHex: string): Promise<string> {
84
+ return bytesToHex(await ed.getPublicKeyAsync(hexToBytes(privateKeyHex)));
85
+ }
86
+
87
+ // ---------------------------------------------------------------------------
88
+ // RSA-PSS operator-attestation crypto (sanning.evidence.export/v1)
89
+ //
90
+ // The attested-evidence-export body embeds operator attestations signed with
91
+ // RSA-PSS-SHA-256 over the JCS-canonical attestation payload; the operator key
92
+ // is the operator's Arweave RSA wallet. These two pure functions are the only
93
+ // new primitive that body needs and the only RSA in the kernel: verify one
94
+ // attestation signature, and derive the operator's Arweave address from the
95
+ // embedded modulus (the self-describing key→wallet binding).
96
+ //
97
+ // RSA-PSS parameters are PINNED for cross-kernel byte-agreement between the
98
+ // issuer, the TS kernel, and the Python kernel: hash = SHA-256; MGF1 with
99
+ // SHA-256; salt length = 32 (the SHA-256 digest length, i.e. OpenSSL's
100
+ // RSA_PSS_SALTLEN_DIGEST); padding = PSS. saltLength=32 is load-bearing — the
101
+ // shipped issuer's former RSA_PSS_SALTLEN_AUTO (which resolves to the maximum,
102
+ // key-size-dependent salt on signing) is NOT verifiable by WebCrypto, which
103
+ // does not auto-detect salt on verify. The pin is what makes these round-trip.
104
+ const RSA_PSS_SALT_LENGTH = 32;
105
+
106
+ // The ONLY RSA public exponent a legitimate operator (Arweave wallet) key uses.
107
+ // The operator-address binding SHA-256(n) commits to the modulus ALONE and
108
+ // assumes this exponent; a verifier MUST reject any other. Without it, e=1 makes
109
+ // RSA verification the identity (s^1 mod n = s), letting an attacker forge a
110
+ // valid-looking attestation from a victim operator's PUBLIC modulus with NO
111
+ // private key. See the exponent guard in verifyRsaPssSha256.
112
+ const RSA_PUBLIC_EXPONENT = 65537n;
113
+
114
+ // Verify an RSA-PSS-SHA-256 signature over `payloadBytes` (the raw JCS bytes of
115
+ // the attestation payload) with the JWK RSA public key `{kty:"RSA", n, e}`
116
+ // (`n`/`e` base64url) and a lowercase-hex `signatureHex`.
117
+ //
118
+ // Malformed-vs-failed follows the kernel's split: a malformed key (JWK that
119
+ // won't import) or malformed signature hex is a caller/input error and THROWS a
120
+ // prefixed Error — the same "malformed" signal hexToBytes raises and the CLI
121
+ // buckets as exit-2. A well-formed key + signature that simply does not verify
122
+ // (wrong signer, tampered payload, wrong salt length) returns `false` and never
123
+ // throws, so a hostile attestation can't crash the checker (same contract as
124
+ // ed25519Verify above).
125
+ export async function verifyRsaPssSha256(
126
+ payloadBytes: Uint8Array,
127
+ signatureHex: string,
128
+ publicKey: { kty: "RSA"; n: string; e: string },
129
+ ): Promise<boolean> {
130
+ let signature: Uint8Array;
131
+ try {
132
+ signature = hexToBytes(signatureHex);
133
+ } catch (e) {
134
+ throw new Error(`verifyRsaPssSha256: malformed signature hex: ${stringifyErr(e)}`);
135
+ }
136
+
137
+ // Enforce the RSA public exponent (see RSA_PUBLIC_EXPONENT). A structurally
138
+ // valid key with a non-65537 exponent is not a legitimate operator key, so
139
+ // its signature does not validly verify — return false (a FAILED attestation,
140
+ // exit 1), never accept it. A malformed `e` falls through to importKey below,
141
+ // which throws the malformed-key error (exit 2). This is the guard that closes
142
+ // the e=1 identity-forgery: e=1 imports fine in WebCrypto and would otherwise
143
+ // "verify" an attacker-built PSS encoded message with no private key.
144
+ // Decode `e`. A non-base64url `e` is malformed — importKey raises it below.
145
+ let eBytes: Uint8Array | null = null;
146
+ try {
147
+ eBytes = base64UrlToBytes(publicKey.e);
148
+ } catch {
149
+ eBytes = null;
150
+ }
151
+ // An empty `e` is malformed too: WebCrypto imports it as e=0 (which would
152
+ // return false), while the Python kernel's key import raises. Throw here so
153
+ // an empty exponent is malformed/exit-2 in BOTH kernels, not a divergence.
154
+ if (eBytes !== null && eBytes.length === 0) {
155
+ throw new Error("verifyRsaPssSha256: malformed RSA public key: empty exponent");
156
+ }
157
+ const exponent = eBytes === null ? null : bytesToBigInt(eBytes);
158
+ if (exponent !== null && exponent !== RSA_PUBLIC_EXPONENT) {
159
+ return false;
160
+ }
161
+
162
+ let key: CryptoKey;
163
+ try {
164
+ key = await crypto.subtle.importKey(
165
+ "jwk",
166
+ { kty: publicKey.kty, n: publicKey.n, e: publicKey.e, ext: true },
167
+ { name: "RSA-PSS", hash: "SHA-256" },
168
+ false,
169
+ ["verify"],
170
+ );
171
+ } catch (e) {
172
+ throw new Error(`verifyRsaPssSha256: malformed RSA public key: ${stringifyErr(e)}`);
173
+ }
174
+
175
+ try {
176
+ return await crypto.subtle.verify(
177
+ { name: "RSA-PSS", saltLength: RSA_PSS_SALT_LENGTH },
178
+ key,
179
+ asBufferSource(signature),
180
+ asBufferSource(payloadBytes),
181
+ );
182
+ } catch {
183
+ // Some engines throw (rather than return false) on a wrong-length RSA
184
+ // signature. That is still just "not verified," not a malformed-input
185
+ // condition — the signature parsed as hex fine; it's the wrong signature.
186
+ return false;
187
+ }
188
+ }
189
+
190
+ // Derive an operator's Arweave wallet address from the base64url JWK modulus
191
+ // `n`: address = base64url(SHA-256(rawModulusBytes)), where rawModulusBytes is
192
+ // the decoded base64url `n`. This is the same owner→address derivation
193
+ // arweave-js uses (SHA-256 over the modulus octets, base64url without padding),
194
+ // so it binds an embedded RSA key to a specific wallet with no roster lookup.
195
+ //
196
+ // Async because the kernel takes SHA-256 from WebCrypto (crypto.subtle.digest),
197
+ // which is Promise-based — the module is deliberately WebCrypto-only with no
198
+ // second hashing library and no node:crypto, so it stays browser-portable.
199
+ // Throws a prefixed Error on a malformed (non-base64url) `n`.
200
+ export async function deriveOperatorAddress(nBase64url: string): Promise<string> {
201
+ const modulus = base64UrlToBytes(nBase64url);
202
+ const digest = await sha256Bytes(modulus);
203
+ return bytesToBase64Url(digest);
204
+ }
205
+
206
+ // base64url (RFC 4648 §5, unpadded) codecs. Kept WebCrypto/browser-portable —
207
+ // atob/btoa exist in browsers and in Node >= 16 — so the kernel stays free of
208
+ // node:Buffer. Arweave and JWK both use unpadded base64url. Exported because the
209
+ // composer transcodes an issuer-emitted base64url attestation signature to the
210
+ // lowercase hex §2.2 stores (evidence-export.md §2.2 — the composer's boundary job).
211
+ export function base64UrlToBytes(b64url: string): Uint8Array {
212
+ if (!/^[A-Za-z0-9_-]*$/.test(b64url)) {
213
+ throw new Error("base64url: non-base64url characters");
214
+ }
215
+ let b64 = b64url.replace(/-/g, "+").replace(/_/g, "/");
216
+ const rem = b64.length % 4;
217
+ if (rem === 1) throw new Error("base64url: invalid length");
218
+ if (rem === 2) b64 += "==";
219
+ else if (rem === 3) b64 += "=";
220
+ const bin = atob(b64);
221
+ const out = new Uint8Array(bin.length);
222
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
223
+ return out;
224
+ }
225
+
226
+ export function bytesToBase64Url(bytes: Uint8Array): string {
227
+ let bin = "";
228
+ for (const b of bytes) bin += String.fromCharCode(b);
229
+ return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
230
+ }
231
+
232
+ // Big-endian unsigned integer from bytes — used only for the RSA public-exponent
233
+ // guard, so alternate encodings of 65537 (e.g. with leading zero octets) still
234
+ // compare equal.
235
+ function bytesToBigInt(bytes: Uint8Array): bigint {
236
+ let v = 0n;
237
+ for (const b of bytes) v = (v << 8n) | BigInt(b);
238
+ return v;
239
+ }
240
+
241
+ function stringifyErr(e: unknown): string {
242
+ return e instanceof Error ? e.message : String(e);
243
+ }