@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.
- package/README.md +30 -29
- package/dist/agent-record.d.ts +62 -0
- package/dist/agent-record.js +527 -0
- package/dist/agent-schemas.generated.d.ts +15 -0
- package/dist/agent-schemas.generated.js +5759 -0
- package/dist/case-file.d.ts +32 -89
- package/dist/case-file.js +41 -93
- package/dist/case.d.ts +339 -822
- package/dist/case.js +667 -1160
- package/dist/chain.d.ts +19 -47
- package/dist/chain.js +78 -205
- package/dist/claims.d.ts +211 -0
- package/dist/claims.js +1081 -0
- package/dist/cli.d.ts +45 -68
- package/dist/cli.js +618 -1022
- package/dist/compose.js +23 -65
- package/dist/crypto.d.ts +7 -8
- package/dist/crypto.js +60 -147
- package/dist/declaration.d.ts +68 -194
- package/dist/declaration.js +175 -518
- package/dist/environment.d.ts +9 -30
- package/dist/environment.js +17 -66
- package/dist/evidence.d.ts +119 -160
- package/dist/evidence.js +744 -1103
- package/dist/exit-codes.d.ts +11 -27
- package/dist/exit-codes.js +24 -59
- package/dist/file-match.d.ts +33 -79
- package/dist/file-match.js +33 -69
- package/dist/grading.d.ts +98 -135
- package/dist/grading.js +224 -380
- package/dist/handoff.d.ts +62 -160
- package/dist/handoff.js +153 -393
- package/dist/index.d.ts +14 -10
- package/dist/index.js +60 -167
- package/dist/json-schema.d.ts +29 -0
- package/dist/json-schema.js +298 -0
- package/dist/merkle.js +16 -28
- package/dist/message-parts.d.ts +56 -212
- package/dist/message-parts.js +142 -502
- package/dist/side-content.d.ts +11 -22
- package/dist/side-content.js +22 -65
- package/dist/types.d.ts +17 -0
- package/dist/types.js +19 -5
- package/dist/verifier.d.ts +40 -114
- package/dist/verifier.js +196 -535
- package/dist/witness-cache.d.ts +24 -45
- package/dist/witness-cache.js +38 -81
- package/dist/witness-lookup.d.ts +36 -57
- package/dist/witness-lookup.js +89 -139
- package/dist/zip-reader.d.ts +18 -33
- package/dist/zip-reader.js +19 -34
- package/package.json +11 -3
- package/src/agent-record.ts +608 -0
- package/src/agent-schemas.generated.ts +5764 -0
- package/src/case-file.ts +45 -109
- package/src/case.ts +814 -1328
- package/src/chain.ts +85 -224
- package/src/claims.ts +1253 -0
- package/src/cli.ts +714 -1176
- package/src/compose.ts +33 -79
- package/src/crypto.ts +60 -147
- package/src/declaration.ts +219 -656
- package/src/environment.ts +17 -65
- package/src/evidence.ts +958 -1381
- package/src/exit-codes.ts +24 -63
- package/src/file-match.ts +40 -89
- package/src/grading.ts +287 -421
- package/src/handoff.ts +175 -427
- package/src/index.ts +131 -177
- package/src/json-schema.ts +295 -0
- package/src/merkle.ts +16 -28
- package/src/message-parts.ts +155 -533
- package/src/side-content.ts +25 -68
- package/src/types.ts +55 -36
- package/src/verifier.ts +202 -526
- package/src/witness-cache.ts +45 -94
- package/src/witness-lookup.ts +107 -177
- package/src/zip-reader.ts +20 -38
- package/vectors/README.md +52 -34
- package/vectors/agent-record.json +2175 -0
- package/vectors/case-file-index.json +1 -1
- package/vectors/case-split.json +2 -2
- package/vectors/chain-reading.json +3 -3
- package/vectors/file-match.json +285 -148
- package/vectors/jcs-reader.json +6 -6
- package/vectors/pack-grading.json +102 -25
- package/vectors/sandbox-evidence.json +573 -581
- package/vectors/stamp-on-chain.json +18 -18
- package/vectors/witness-cache.json +49 -49
- package/dist/agent-proof.d.ts +0 -28
- package/dist/agent-proof.js +0 -226
- package/dist/run-adapters.d.ts +0 -10
- package/dist/run-adapters.js +0 -35
- package/dist/run.d.ts +0 -209
- package/dist/run.js +0 -1405
- package/src/agent-proof.ts +0 -264
- package/src/run-adapters.ts +0 -44
- package/src/run.ts +0 -1598
package/dist/compose.js
CHANGED
|
@@ -1,48 +1,27 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
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
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
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
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
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
|
-
//
|
|
43
|
-
//
|
|
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
|
-
//
|
|
55
|
-
//
|
|
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
|
-
//
|
|
76
|
-
//
|
|
77
|
-
//
|
|
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
|
|
98
|
-
//
|
|
99
|
-
//
|
|
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
|
|
110
|
-
//
|
|
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
|
-
//
|
|
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
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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`
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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
|
-
//
|
|
2
|
-
//
|
|
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
|
-
//
|
|
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
|
|
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
|
-
//
|
|
26
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
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
|
-
//
|
|
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`:
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
//
|
|
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
|
-
//
|
|
117
|
-
//
|
|
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
|
|
129
|
-
*
|
|
130
|
-
*
|
|
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`
|
|
155
|
-
*
|
|
156
|
-
*
|
|
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
|
-
//
|
|
183
|
-
//
|
|
184
|
-
//
|
|
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
|
-
//
|
|
194
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
218
|
-
//
|
|
219
|
-
//
|
|
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
|
|
226
|
-
//
|
|
227
|
-
//
|
|
228
|
-
//
|
|
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
|
-
//
|
|
233
|
-
//
|
|
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
|
-
//
|
|
237
|
-
//
|
|
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
|
-
//
|
|
252
|
-
//
|
|
253
|
-
//
|
|
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
|
-
//
|
|
267
|
-
//
|
|
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
|
|
288
|
-
//
|
|
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
|
-
//
|
|
294
|
-
//
|
|
295
|
-
//
|
|
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,
|
|
309
|
-
//
|
|
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
|
|
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)
|