@sanning/proof 0.19.1 → 0.20.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.
Files changed (98) hide show
  1. package/README.md +30 -29
  2. package/dist/agent-record.d.ts +62 -0
  3. package/dist/agent-record.js +527 -0
  4. package/dist/agent-schemas.generated.d.ts +15 -0
  5. package/dist/agent-schemas.generated.js +5759 -0
  6. package/dist/case-file.d.ts +32 -89
  7. package/dist/case-file.js +41 -93
  8. package/dist/case.d.ts +339 -822
  9. package/dist/case.js +667 -1160
  10. package/dist/chain.d.ts +19 -47
  11. package/dist/chain.js +78 -205
  12. package/dist/claims.d.ts +211 -0
  13. package/dist/claims.js +1081 -0
  14. package/dist/cli.d.ts +45 -68
  15. package/dist/cli.js +618 -1022
  16. package/dist/compose.js +23 -65
  17. package/dist/crypto.d.ts +7 -8
  18. package/dist/crypto.js +60 -147
  19. package/dist/declaration.d.ts +68 -194
  20. package/dist/declaration.js +175 -518
  21. package/dist/environment.d.ts +9 -30
  22. package/dist/environment.js +17 -66
  23. package/dist/evidence.d.ts +119 -160
  24. package/dist/evidence.js +744 -1103
  25. package/dist/exit-codes.d.ts +11 -27
  26. package/dist/exit-codes.js +24 -59
  27. package/dist/file-match.d.ts +33 -79
  28. package/dist/file-match.js +33 -69
  29. package/dist/grading.d.ts +98 -135
  30. package/dist/grading.js +224 -380
  31. package/dist/handoff.d.ts +62 -160
  32. package/dist/handoff.js +153 -393
  33. package/dist/index.d.ts +14 -10
  34. package/dist/index.js +60 -167
  35. package/dist/json-schema.d.ts +29 -0
  36. package/dist/json-schema.js +298 -0
  37. package/dist/merkle.js +16 -28
  38. package/dist/message-parts.d.ts +56 -212
  39. package/dist/message-parts.js +142 -502
  40. package/dist/side-content.d.ts +11 -22
  41. package/dist/side-content.js +22 -65
  42. package/dist/types.d.ts +17 -0
  43. package/dist/types.js +19 -5
  44. package/dist/verifier.d.ts +40 -114
  45. package/dist/verifier.js +196 -535
  46. package/dist/witness-cache.d.ts +24 -45
  47. package/dist/witness-cache.js +38 -81
  48. package/dist/witness-lookup.d.ts +36 -57
  49. package/dist/witness-lookup.js +89 -139
  50. package/dist/zip-reader.d.ts +18 -33
  51. package/dist/zip-reader.js +19 -34
  52. package/package.json +11 -3
  53. package/src/agent-record.ts +608 -0
  54. package/src/agent-schemas.generated.ts +5764 -0
  55. package/src/case-file.ts +45 -109
  56. package/src/case.ts +814 -1328
  57. package/src/chain.ts +85 -224
  58. package/src/claims.ts +1253 -0
  59. package/src/cli.ts +714 -1176
  60. package/src/compose.ts +33 -79
  61. package/src/crypto.ts +60 -147
  62. package/src/declaration.ts +219 -656
  63. package/src/environment.ts +17 -65
  64. package/src/evidence.ts +958 -1381
  65. package/src/exit-codes.ts +24 -63
  66. package/src/file-match.ts +40 -89
  67. package/src/grading.ts +287 -421
  68. package/src/handoff.ts +175 -427
  69. package/src/index.ts +131 -177
  70. package/src/json-schema.ts +295 -0
  71. package/src/merkle.ts +16 -28
  72. package/src/message-parts.ts +155 -533
  73. package/src/side-content.ts +25 -68
  74. package/src/types.ts +55 -36
  75. package/src/verifier.ts +202 -526
  76. package/src/witness-cache.ts +45 -94
  77. package/src/witness-lookup.ts +107 -177
  78. package/src/zip-reader.ts +20 -38
  79. package/vectors/README.md +52 -34
  80. package/vectors/agent-record.json +2175 -0
  81. package/vectors/case-file-index.json +1 -1
  82. package/vectors/case-split.json +2 -2
  83. package/vectors/chain-reading.json +3 -3
  84. package/vectors/file-match.json +285 -148
  85. package/vectors/jcs-reader.json +6 -6
  86. package/vectors/pack-grading.json +102 -25
  87. package/vectors/sandbox-evidence.json +573 -581
  88. package/vectors/stamp-on-chain.json +18 -18
  89. package/vectors/witness-cache.json +49 -49
  90. package/dist/agent-proof.d.ts +0 -28
  91. package/dist/agent-proof.js +0 -226
  92. package/dist/run-adapters.d.ts +0 -10
  93. package/dist/run-adapters.js +0 -35
  94. package/dist/run.d.ts +0 -209
  95. package/dist/run.js +0 -1405
  96. package/src/agent-proof.ts +0 -264
  97. package/src/run-adapters.ts +0 -44
  98. package/src/run.ts +0 -1598
package/src/compose.ts CHANGED
@@ -1,25 +1,10 @@
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/v3` bundle + a set of
4
- // operator attestation records + an exporter Ed25519 key, and PRODUCE one signed,
5
- // offline-verifiable `sanning.evidence.export/v2` artifact, exactly the bytes the
6
- // kernel's verifyExportBody round-trip-verifies.
1
+ // Composes a signed, offline-verifiable `sanning.evidence.export/v2` artifact
2
+ // from a source trace bundle, operator attestation records and an exporter
3
+ // Ed25519 key (evidence-export.md §5).
7
4
  //
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.
5
+ // The exporter signs only the wrapper. Each operator attestation keeps its own
6
+ // RSA-PSS signature; the composer re-encodes only the `signature` field to hex,
7
+ // which sits outside the signed payload, so the operator signature still holds.
23
8
 
24
9
  import {
25
10
  EXPORT_BODY_TYPE,
@@ -41,50 +26,42 @@ import {
41
26
  } from "./crypto.js";
42
27
  import { isRfc3339UtcInstant, jcs } from "./verifier.js";
43
28
 
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.
29
+ // The exporter's wrapper-signing key: `privateKey` is a 32-byte Ed25519 seed as
30
+ // lowercase hex. `publicKey` is derived when omitted.
47
31
  export interface ExporterKey {
48
32
  privateKey: string;
49
33
  publicKey?: string;
50
34
  }
51
35
 
52
36
  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.
37
+ // Wrapper issuer.issuer_id (§2.1). Identity context only; the wrapper
38
+ // public_key is what binds.
55
39
  issuerId?: string;
56
- // Named delivery surface for the wrapper (§2.1). Not trusted. Default null.
40
+ // Delivery surface for the wrapper (§2.1). Not trusted.
57
41
  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.
42
+ // Wrapper previous_hash (§2.3): GENESIS for a one-off export, or
43
+ // SHA-256(JCS(prior wrapper without signature)) for a chain link.
60
44
  previousHash?: string;
61
- // RFC 3339 compose time → wrapper generated_at + verdict as_of (§2.1). Default
62
- // new Date().toISOString().
45
+ // RFC 3339 compose time, used for generated_at and verdict as_of.
63
46
  generatedAt?: string;
64
47
  }
65
48
 
66
49
  export interface ComposeExportResult {
67
- // The signed, offline-verifiable sanning.evidence.export/v2 artifact.
68
50
  bundle: EvidenceBundle;
69
- // The export rollup the embedded kernel_verdict carries (verified|partial|failed).
70
51
  status: EvidenceStatus;
71
- // SHA-256(JCS(source_bundle)) linkage commitment (§2.2), for logging.
52
+ // SHA-256(JCS(source_bundle)) (§2.2).
72
53
  sourceBundleHash: string;
73
- // Count of embedded attestations that fully bound (sig + operator + data_hash).
54
+ // Embedded attestations that fully bound (signature, operator and data_hash).
74
55
  boundAttestations: number;
75
56
  }
76
57
 
77
58
  const GENESIS = "GENESIS";
78
59
  const EXPORT_SCHEMA = EXPORT_BODY_TYPE;
79
60
 
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.
61
+ // Transcodes an attestation `signature` to the lowercase hex §2.2 stores. An
62
+ // even-length all-hex string is already hex; anything else is decoded as
63
+ // base64url. A 256-byte RSA signature is 512 hex chars against about 342
64
+ // base64url chars, so the two forms do not collide. Throws on neither.
88
65
  export function attestationSignatureToHex(signature: string): string {
89
66
  if (signature.length > 0 && signature.length % 2 === 0 && /^[0-9a-fA-F]+$/.test(signature)) {
90
67
  return signature.toLowerCase();
@@ -92,10 +69,8 @@ export function attestationSignatureToHex(signature: string): string {
92
69
  return bytesToHex(base64UrlToBytes(signature));
93
70
  }
94
71
 
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_ref — rides verbatim so the
98
- // operator's RSA-PSS signature over JCS(payload) remains valid.
72
+ // Deep-clones the record and transcodes its signature. Every other field rides
73
+ // verbatim so the operator's signature over JCS(payload) stays valid.
99
74
  function normalizeAttestation(rec: AttestationRecord): AttestationRecord {
100
75
  const cloned = JSON.parse(JSON.stringify(rec)) as AttestationRecord;
101
76
  if (typeof cloned.signature !== "string") {
@@ -105,8 +80,8 @@ function normalizeAttestation(rec: AttestationRecord): AttestationRecord {
105
80
  return cloned;
106
81
  }
107
82
 
108
- // True when `value` holds a number that is NaN or +-Infinity. Iterative, so a
109
- // deeply nested input cannot overflow the stack before `jcs` bounds it.
83
+ // Iterative, so a deeply nested input cannot overflow the stack before `jcs`
84
+ // bounds it.
110
85
  function hasNonFiniteNumber(value: unknown): boolean {
111
86
  const stack: unknown[] = [value];
112
87
  while (stack.length > 0) {
@@ -122,18 +97,9 @@ function hasNonFiniteNumber(value: unknown): boolean {
122
97
  return false;
123
98
  }
124
99
 
125
- // Compose an attested evidence export (evidence-export.md §5).
126
- //
127
- // 1. Normalize + transcode the attestation records (boundary hex, above).
128
- // 2. Recompute the §4 kernel_verdict over the source bundle + attestations,
129
- // reusing the kernel's own verify path (recomputeExportVerdict) so the cached
130
- // copy byte-agrees with the verifier's later recompute (§5 step 5).
131
- // 3. Assemble the export body: kernel_verdict + inline source_bundle +
132
- // source_bundle_hash = SHA-256(JCS(source_bundle)) + attestations[] + schema.
133
- // 4. Build the sanning.evidence/v1 wrapper (body_type export, Ed25519), strip
134
- // signature, JCS, Ed25519-sign with the exporter key, attach.
135
- //
136
- // Returns the signed artifact + a small compose summary.
100
+ // Composes an attested evidence export (evidence-export.md §5). The embedded
101
+ // kernel_verdict comes from the verifier's own recompute path, so it cannot
102
+ // drift from what a verifier later recomputes.
137
103
  export async function composeExport(
138
104
  sourceBundle: EvidenceBundle,
139
105
  attestations: AttestationRecord[],
@@ -150,9 +116,9 @@ export async function composeExport(
150
116
  throw new Error("composeExport: exporterKey.privateKey (32-byte hex seed) is required");
151
117
  }
152
118
 
153
- // An over-long integer reads as Infinity (proof#217), which `jcs` refuses in
154
- // the runtime's own words. Say it once, in the kernel's, before any
155
- // verification: both kernels write this sentence, with no function-name prefix, so the CLI line is byte-identical.
119
+ // An over-long integer reads as Infinity. Reject it here with a fixed
120
+ // sentence and no function-name prefix: the CLI error line is part of the
121
+ // cross-implementation contract.
156
122
  if (hasNonFiniteNumber(sourceBundle)) {
157
123
  throw new Error(
158
124
  "the source bundle carries a number that is not a finite JSON number " +
@@ -167,8 +133,8 @@ export async function composeExport(
167
133
  }
168
134
 
169
135
  if (options.generatedAt !== undefined && !isRfc3339UtcInstant(options.generatedAt)) {
170
- // The verifier reads this as `malformed generated_at` (evidence-bundle §2,
171
- // A31), so composing one would sign an export this kernel rejects.
136
+ // The verifier rejects this as `malformed generated_at` (evidence-bundle section 2),
137
+ // so composing it would sign an export that cannot verify.
172
138
  throw new Error(
173
139
  "composeExport: generatedAt is not an RFC 3339 UTC date-time with Z naming a real date and time",
174
140
  );
@@ -178,14 +144,11 @@ export async function composeExport(
178
144
  const issuerId = options.issuerId ?? "sanning-verify";
179
145
  const gateway = options.gateway ?? null;
180
146
 
181
- // (1) boundary-transcode the attestation signatures to hex.
182
147
  const records = attestations.map(normalizeAttestation);
183
148
 
184
- // (2) recompute the §4 verdict via the KERNEL's own verify path (no reimpl).
185
149
  const composed = await recomputeExportVerdict(sourceBundle, records, generatedAt);
186
150
 
187
- // (3) assemble the export body. source_bundle_hash is SHA-256(JCS(source)) —
188
- // the linkage commitment the wrapper signature transitively covers (§2.4).
151
+ // The wrapper signature covers source_bundle_hash, and through it the source (§2.4).
189
152
  const sourceBundleHash = await sha256Hex(utf8(jcs(sourceBundle)));
190
153
  const body: ExportBody = {
191
154
  kernel_verdict: composed.verdict,
@@ -195,9 +158,6 @@ export async function composeExport(
195
158
  export_schema: EXPORT_SCHEMA,
196
159
  };
197
160
 
198
- // (4) build + sign the sanning.evidence/v1 wrapper. The wrapper `verdict` is the
199
- // coarse family rollup DERIVED from body.kernel_verdict (display context, never
200
- // trusted — the verifier recomputes). body_hash = SHA-256(JCS(body)).
201
161
  const publicKey = exporterKey.publicKey ?? (await ed25519PublicKey(exporterKey.privateKey));
202
162
  const wrapperPre = {
203
163
  spec_version: "sanning.evidence/v1",
@@ -205,12 +165,6 @@ export async function composeExport(
205
165
  issuer: { kind: "issuer", issuer_id: issuerId },
206
166
  generated_at: generatedAt,
207
167
  gateway,
208
- verdict: {
209
- status: composed.verdict.status,
210
- ...(composed.verdict.summary !== undefined ? { summary: composed.verdict.summary } : {}),
211
- ...(composed.verdict.counts !== undefined ? { counts: composed.verdict.counts } : {}),
212
- as_of: generatedAt,
213
- },
214
168
  body,
215
169
  body_hash: await sha256Hex(utf8(jcs(body))),
216
170
  previous_hash: previousHash,
package/src/crypto.ts CHANGED
@@ -1,21 +1,13 @@
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.
1
+ // Cryptographic primitives for the verifier. The only third-party crypto
2
+ // dependency is @noble/ed25519; SHA-256 and SHA-512 come from WebCrypto.
5
3
 
6
4
  import * as ed from "@noble/ed25519";
7
5
 
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
- // noble 3 ships this provider as its default; it is set here so the kernel
12
- // says what it hashes with rather than inheriting it.
6
+ // Set explicitly so the kernel states what Ed25519 hashes with.
13
7
  ed.hashes.sha512Async = async (message) =>
14
8
  new Uint8Array(await crypto.subtle.digest("SHA-512", asBufferSource(message)));
15
9
 
16
- // WebCrypto's digest() wants a BufferSource backed by a (non-shared) ArrayBuffer.
17
- // Our byte arrays always are; this keeps TS 5.7's stricter Uint8Array<ArrayBufferLike>
18
- // typing satisfied without a runtime copy.
10
+ // Satisfies the typing of WebCrypto's BufferSource without a runtime copy.
19
11
  function asBufferSource(bytes: Uint8Array): BufferSource {
20
12
  return bytes as unknown as BufferSource;
21
13
  }
@@ -27,17 +19,11 @@ export function bytesToHex(bytes: Uint8Array): string {
27
19
  }
28
20
 
29
21
  export function hexToBytes(hex: string): Uint8Array {
30
- // The type says `string` and a bundle is not typed: every caller here reads
31
- // a field a party wrote, so `undefined` and `null` arrive at runtime. Until
32
- // gate 4 on proof#140 this line threw the HOST's `Cannot read properties of
33
- // undefined (reading 'length')` where the Python kernel raises
34
- // `hexToBytes: not a string`, and a finding that wraps it (`inclusion verify
35
- // error: ...`) then read two ways. This kernel's own words, in the other
36
- // kernel's wording, so a finding may quote them.
22
+ // Callers pass fields a party wrote, so a non-string arrives at runtime.
23
+ // Throw this message rather than the host's, so a finding quotes stable words.
37
24
  if (typeof hex !== "string") throw new Error("hexToBytes: not a string");
38
25
  if (hex.length % 2 !== 0) throw new Error("hexToBytes: odd-length string");
39
- // Full validation: parseInt() partially parses ("1g" -> 1), which would let
40
- // malformed hex through. A strict charset check closes that.
26
+ // parseInt() accepts "1g" as 1, so check the charset strictly first.
41
27
  if (!/^[0-9a-fA-F]*$/.test(hex)) throw new Error("hexToBytes: non-hex characters");
42
28
  const out = new Uint8Array(hex.length / 2);
43
29
  for (let i = 0; i < out.length; i++) {
@@ -46,21 +32,10 @@ export function hexToBytes(hex: string): Uint8Array {
46
32
  return out;
47
33
  }
48
34
 
49
- // The one hex-field test, beside the one hex reader above. The Python twin is
50
- // `is_hex_field` in `src/sanning_proof/hex.py`, and every site in either kernel
51
- // that asks whether a producer's hex is the shape a spec fixes goes through one
52
- // of the two.
53
- //
54
- // The test runs BEFORE any decoder sees the value, so the failure a reader is
55
- // handed names the field's FORM rather than whatever the decoder happened to
56
- // say about it: `hexToBytes: non-hex characters` on a respelled key sends an
57
- // auditor looking for a broken tool, and `Ed25519 signature verification
58
- // failed` sends them looking for a forgery. Neither is what happened.
59
- //
60
- // CASE IS A PARAMETER AND NOT THE DEFAULT. proof#135 (option B, Phil
61
- // 2026-09-22) pinned the hex charset and left the case open, so `lower` is
62
- // passed only where a value is compared against a RECOMPUTED lowercase digest
63
- // and no other spelling of it could ever be that digest.
35
+ // Checks a producer's hex field for its spec'd form before any decoder sees
36
+ // it, so a failure names the field's form instead of a misleading decode or
37
+ // signature error. Pass `lower` only where the value is compared with a
38
+ // recomputed lowercase digest; elsewhere the spec leaves case open.
64
39
  export function isHexField(value: unknown, nibbles: number, lower = false): value is string {
65
40
  if (typeof value !== "string" || value.length !== nibbles) return false;
66
41
  return (lower ? /^[0-9a-f]*$/ : /^[0-9a-fA-F]*$/).test(value);
@@ -78,31 +53,20 @@ export async function sha256Hex(bytes: Uint8Array): Promise<string> {
78
53
  return bytesToHex(await sha256Bytes(bytes));
79
54
  }
80
55
 
81
- // ---------------------------------------------------------------------------
82
- // The Ed25519 rule (envelope-spec §7, A44, proof#230)
83
- //
84
- // Strict RFC 8032 (`zip215: false`, a frozen invariant), and more: the public
85
- // key A and the signature's R must each be the canonical encoding of a point
86
- // in the prime-order subgroup other than the identity, and S below the group
87
- // order L. Only then is the equation checked.
56
+ // The Ed25519 rule (envelope-spec §7): strict RFC 8032 (`zip215: false`), plus
57
+ // the public key A and signature R must each canonically encode a point in the
58
+ // prime-order subgroup other than the identity, and S must be below L.
88
59
  //
89
- // Why more than `zip215: false`: noble checks the equation with the cofactor,
90
- // 8·S·B = 8·R + 8·k·A, and Go's crypto/ed25519 and libsodium without it, so on
91
- // their own they disagree when A or R has a small-order component. noble 2.3
92
- // also could not encode an identity R and hashed nothing for k, so it accepted
93
- // one signature from a key holder over every message. With A and R in the
94
- // prime-order subgroup the two equations are the same equation, and every
95
- // kernel accepts exactly the same signatures.
96
- //
97
- // Each clause is checked here, in this order, and named in these words: the
98
- // Python twin is `ed25519_rule_reason` in `src/sanning_proof/ed25519_rule.py`
99
- // and the Go kernel's `verifyEd25519Strict` in sanning-io/agent `pkg/proof`.
100
- // Several clauses are ones noble 3 refuses on its own; they are checked here
101
- // anyway so the refusal names the clause, which a library's `false` cannot.
60
+ // Why more than `zip215: false`: cofactored and cofactorless verification
61
+ // disagree when A or R has a small-order component. With A and R in the
62
+ // prime-order subgroup the two equations coincide, so every implementation
63
+ // accepts exactly the same signatures. Clauses noble already refuses are still
64
+ // checked here so the refusal names the clause.
102
65
 
103
66
  /** The sentence a refused signature reads in an envelope result. */
104
67
  export const SIGNATURE_FAILED = "Ed25519 signature verification failed";
105
68
 
69
+ /** The clauses of the rule, in the order they are checked. */
106
70
  export const ED25519_RULE_REASONS = [
107
71
  "the public key is not the canonical encoding of a point",
108
72
  "the public key is a point of small order",
@@ -133,9 +97,8 @@ function bytesToNumberLE(bytes: Uint8Array): bigint {
133
97
  return v;
134
98
  }
135
99
 
136
- // `Point.fromBytes(..., false)` is RFC 8032 decoding with its two strict
137
- // branches: it throws on y at or above p and on x zero with the sign bit set,
138
- // the two non-canonical encodings of a point, and on bytes that are no point.
100
+ // RFC 8032 strict decoding: rejects y >= p, x = 0 with the sign bit set, and
101
+ // bytes that are no point.
139
102
  function decodeStrict(bytes: Uint8Array): ed.Point | null {
140
103
  try {
141
104
  return ed.Point.fromBytes(bytes, false);
@@ -145,9 +108,9 @@ function decodeStrict(bytes: Uint8Array): ed.Point | null {
145
108
  }
146
109
 
147
110
  /**
148
- * The clause of the rule that (`publicKey`, `signature`) breaks, or `null`
149
- * when it breaks none and the equation is what is left to check. Lengths are
150
- * the caller's (32 and 64 bytes). Never throws.
111
+ * The clause of the rule (`publicKey`, `signature`) breaks, or `null` if none
112
+ * and only the equation is left. The caller checks lengths (32 and 64 bytes).
113
+ * Never throws.
151
114
  */
152
115
  export function ed25519RuleReason(signature: Uint8Array, publicKey: Uint8Array): string | null {
153
116
  const A = decodeStrict(publicKey);
@@ -164,11 +127,9 @@ export function ed25519RuleReason(signature: Uint8Array, publicKey: Uint8Array):
164
127
  }
165
128
 
166
129
  /**
167
- * `null` iff `signatureHex` is a signature of `message` under `publicKeyHex`
168
- * by the rule; otherwise the sentence the refusal reads: `SIGNATURE_FAILED`,
169
- * with `": "` and the clause of the rule it broke when it broke one. A
170
- * malformed field, a wrong length and an equation that does not hold read
171
- * the bare sentence. Never throws. The Python twin is `ed25519_refusal`.
130
+ * `null` if `signatureHex` verifies `message` under `publicKeyHex` by the rule;
131
+ * otherwise `SIGNATURE_FAILED`, followed by `": "` and the broken clause when
132
+ * one was broken. Never throws.
172
133
  */
173
134
  export async function ed25519Refusal(
174
135
  message: Uint8Array,
@@ -193,14 +154,9 @@ export async function ed25519Refusal(
193
154
  }
194
155
  }
195
156
 
196
- // Verify an Ed25519 signature by the rule above. Inputs are hex (signature,
197
- // public key) as they appear in the envelope; message is raw bytes. Never
198
- // throws — a malformed signature or key is "not verified," not an exception,
199
- // so a hostile envelope can't crash the checker.
200
- // Arguments in the Python twin's order, `ed25519_verify(message,
201
- // signature_hex, public_key_hex)` (P-03, 0.13.0). The message came second
202
- // until 0.13.0; a caller that still passes the signature first gets a compile
203
- // error, because a string is not a Uint8Array.
157
+ // Verifies an Ed25519 signature by the rule above. Never throws: a malformed
158
+ // signature or key is "not verified", so a hostile envelope cannot crash the
159
+ // checker.
204
160
  export async function ed25519Verify(
205
161
  message: Uint8Array,
206
162
  signatureHex: string,
@@ -209,60 +165,36 @@ export async function ed25519Verify(
209
165
  return (await ed25519Refusal(message, signatureHex, publicKeyHex)) === null;
210
166
  }
211
167
 
212
- // Ed25519 SIGN the raw `message` bytes with a 32-byte private-key seed (lowercase
213
- // hex). Returns the signature as lowercase hex. The signing counterpart to
214
- // ed25519Verify — the ONE signing primitive the composer (`proof export`,
215
- // evidence-export.md §5) needs to Ed25519-sign the export wrapper with the
216
- // exporter's key ("anyone can export", P-4). The @noble sha512Async provider wired at
217
- // module load serves signing and verification alike, so this stays WebCrypto-only.
168
+ // Signs `message` with a 32-byte seed (hex) and returns the signature as hex.
169
+ // Used to sign the export wrapper (evidence-export.md §5).
218
170
  export async function ed25519Sign(message: Uint8Array, privateKeyHex: string): Promise<string> {
219
171
  return bytesToHex(await ed.signAsync(message, hexToBytes(privateKeyHex)));
220
172
  }
221
173
 
222
- // Derive the Ed25519 public key (lowercase hex) from a 32-byte private-key seed
223
- // (lowercase hex) — the wrapper's embedded `public_key` when composing an export.
174
+ // The Ed25519 public key (hex) for a 32-byte seed (hex).
224
175
  export async function ed25519PublicKey(privateKeyHex: string): Promise<string> {
225
176
  return bytesToHex(await ed.getPublicKeyAsync(hexToBytes(privateKeyHex)));
226
177
  }
227
178
 
228
- // ---------------------------------------------------------------------------
229
- // RSA-PSS operator-attestation crypto (sanning.evidence.export/v2)
230
- //
231
- // The attested-evidence-export body embeds operator attestations signed with
232
- // RSA-PSS-SHA-256 over the JCS-canonical attestation payload; the operator key
233
- // is the operator's Arweave RSA wallet. These two pure functions are the only
234
- // new primitive that body needs and the only RSA in the kernel: verify one
235
- // attestation signature, and derive the operator's Arweave address from the
236
- // embedded modulus (the self-describing key→wallet binding).
179
+ // RSA-PSS verification for operator attestations (sanning.evidence.export/v2),
180
+ // signed over JCS(payload) with the operator's Arweave RSA wallet.
237
181
  //
238
- // RSA-PSS parameters are PINNED for cross-kernel byte-agreement between the
239
- // issuer, the TS kernel, and the Python kernel: hash = SHA-256; MGF1 with
240
- // SHA-256; salt length = 32 (the SHA-256 digest length, i.e. OpenSSL's
241
- // RSA_PSS_SALTLEN_DIGEST); padding = PSS. saltLength=32 is load-bearing — the
242
- // shipped issuer's former RSA_PSS_SALTLEN_AUTO (which resolves to the maximum,
243
- // key-size-dependent salt on signing) is NOT verifiable by WebCrypto, which
244
- // does not auto-detect salt on verify. The pin is what makes these round-trip.
182
+ // Parameters are pinned: SHA-256, MGF1-SHA-256, salt length 32 (the digest
183
+ // length). WebCrypto does not auto-detect salt on verify, so a signature made
184
+ // with a maximum-length salt would not verify.
245
185
  const RSA_PSS_SALT_LENGTH = 32;
246
186
 
247
- // The ONLY RSA public exponent a legitimate operator (Arweave wallet) key uses.
248
- // The operator-address binding SHA-256(n) commits to the modulus ALONE and
249
- // assumes this exponent; a verifier MUST reject any other. Without it, e=1 makes
250
- // RSA verification the identity (s^1 mod n = s), letting an attacker forge a
251
- // valid-looking attestation from a victim operator's PUBLIC modulus with NO
252
- // private key. See the exponent guard in verifyRsaPssSha256.
187
+ // The only exponent an Arweave wallet key uses. The operator address commits to
188
+ // the modulus alone, so any other exponent must be rejected: with e=1, RSA
189
+ // verification is the identity and anyone could forge an attestation from a
190
+ // victim's public modulus.
253
191
  const RSA_PUBLIC_EXPONENT = 65537n;
254
192
 
255
- // Verify an RSA-PSS-SHA-256 signature over `payloadBytes` (the raw JCS bytes of
256
- // the attestation payload) with the JWK RSA public key `{kty:"RSA", n, e}`
257
- // (`n`/`e` base64url) and a lowercase-hex `signatureHex`.
193
+ // Verifies an RSA-PSS-SHA-256 signature over `payloadBytes` with a JWK RSA key
194
+ // (`n`, `e` base64url) and a hex signature.
258
195
  //
259
- // Malformed-vs-failed follows the kernel's split: a malformed key (JWK that
260
- // won't import) or malformed signature hex is a caller/input error and THROWS a
261
- // prefixed Error — the same "malformed" signal hexToBytes raises and the CLI
262
- // buckets as exit-2. A well-formed key + signature that simply does not verify
263
- // (wrong signer, tampered payload, wrong salt length) returns `false` and never
264
- // throws, so a hostile attestation can't crash the checker (same contract as
265
- // ed25519Verify above).
196
+ // A malformed key or signature hex throws (exit 2 at the CLI). A well-formed
197
+ // signature that does not verify returns `false` and never throws.
266
198
  export async function verifyRsaPssSha256(
267
199
  payloadBytes: Uint8Array,
268
200
  signatureHex: string,
@@ -275,23 +207,17 @@ export async function verifyRsaPssSha256(
275
207
  throw new Error(`verifyRsaPssSha256: malformed signature hex: ${stringifyErr(e)}`);
276
208
  }
277
209
 
278
- // Enforce the RSA public exponent (see RSA_PUBLIC_EXPONENT). A structurally
279
- // valid key with a non-65537 exponent is not a legitimate operator key, so
280
- // its signature does not validly verify — return false (a FAILED attestation,
281
- // exit 1), never accept it. A malformed `e` falls through to importKey below,
282
- // which throws the malformed-key error (exit 2). This is the guard that closes
283
- // the e=1 identity-forgery: e=1 imports fine in WebCrypto and would otherwise
284
- // "verify" an attacker-built PSS encoded message with no private key.
285
- // Decode `e`. A non-base64url `e` is malformed — importKey raises it below.
210
+ // A non-65537 exponent is not an operator key, so it fails (exit 1). This
211
+ // closes the e=1 forgery, which WebCrypto would otherwise import and
212
+ // "verify". A non-base64url `e` falls through to importKey, which throws.
286
213
  let eBytes: Uint8Array | null = null;
287
214
  try {
288
215
  eBytes = base64UrlToBytes(publicKey.e);
289
216
  } catch {
290
217
  eBytes = null;
291
218
  }
292
- // An empty `e` is malformed too: WebCrypto imports it as e=0 (which would
293
- // return false), while the Python kernel's key import raises. Throw here so
294
- // an empty exponent is malformed/exit-2 in BOTH kernels, not a divergence.
219
+ // WebCrypto imports an empty `e` as 0 and returns false; treat it as a
220
+ // malformed key instead, so the exit code is the same in every implementation.
295
221
  if (eBytes !== null && eBytes.length === 0) {
296
222
  throw new Error("verifyRsaPssSha256: malformed RSA public key: empty exponent");
297
223
  }
@@ -321,34 +247,23 @@ export async function verifyRsaPssSha256(
321
247
  asBufferSource(payloadBytes),
322
248
  );
323
249
  } catch {
324
- // Some engines throw (rather than return false) on a wrong-length RSA
325
- // signature. That is still just "not verified," not a malformed-input
326
- // condition — the signature parsed as hex fine; it's the wrong signature.
250
+ // Some engines throw on a wrong-length signature. The hex parsed, so this
251
+ // is a failed signature, not malformed input.
327
252
  return false;
328
253
  }
329
254
  }
330
255
 
331
- // Derive an operator's Arweave wallet address from the base64url JWK modulus
332
- // `n`: address = base64url(SHA-256(rawModulusBytes)), where rawModulusBytes is
333
- // the decoded base64url `n`. This is the same owner→address derivation
334
- // arweave-js uses (SHA-256 over the modulus octets, base64url without padding),
335
- // so it binds an embedded RSA key to a specific wallet with no roster lookup.
336
- //
337
- // Async because the kernel takes SHA-256 from WebCrypto (crypto.subtle.digest),
338
- // which is Promise-based — the module is deliberately WebCrypto-only with no
339
- // second hashing library and no node:crypto, so it stays browser-portable.
340
- // Throws a prefixed Error on a malformed (non-base64url) `n`.
256
+ // The operator's Arweave address from the JWK modulus `n`:
257
+ // base64url(SHA-256(n bytes)), as arweave-js derives it. Binds an embedded RSA
258
+ // key to a wallet with no roster lookup. Throws on a non-base64url `n`.
341
259
  export async function deriveOperatorAddress(nBase64url: string): Promise<string> {
342
260
  const modulus = base64UrlToBytes(nBase64url);
343
261
  const digest = await sha256Bytes(modulus);
344
262
  return bytesToBase64Url(digest);
345
263
  }
346
264
 
347
- // base64url (RFC 4648 §5, unpadded) codecs. Kept WebCrypto/browser-portable —
348
- // atob/btoa exist in browsers and in Node >= 16 — so the kernel stays free of
349
- // node:Buffer. Arweave and JWK both use unpadded base64url. Exported because the
350
- // composer transcodes an issuer-emitted base64url attestation signature to the
351
- // lowercase hex §2.2 stores (evidence-export.md §2.2 — the composer's boundary job).
265
+ // Unpadded base64url (RFC 4648 §5) via atob/btoa, so the kernel needs no
266
+ // node:Buffer.
352
267
  export function base64UrlToBytes(b64url: string): Uint8Array {
353
268
  if (!/^[A-Za-z0-9_-]*$/.test(b64url)) {
354
269
  throw new Error("base64url: non-base64url characters");
@@ -370,9 +285,7 @@ export function bytesToBase64Url(bytes: Uint8Array): string {
370
285
  return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
371
286
  }
372
287
 
373
- // Big-endian unsigned integer from bytes — used only for the RSA public-exponent
374
- // guard, so alternate encodings of 65537 (e.g. with leading zero octets) still
375
- // compare equal.
288
+ // Big-endian, so leading zero octets in `e` still compare equal to 65537.
376
289
  function bytesToBigInt(bytes: Uint8Array): bigint {
377
290
  let v = 0n;
378
291
  for (const b of bytes) v = (v << 8n) | BigInt(b);