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