@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/src/compose.ts
CHANGED
|
@@ -1,25 +1,10 @@
|
|
|
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
|
|
|
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
|
|
45
|
-
// lowercase hex
|
|
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
|
-
//
|
|
54
|
-
//
|
|
37
|
+
// Wrapper issuer.issuer_id (§2.1). Identity context only; the wrapper
|
|
38
|
+
// public_key is what binds.
|
|
55
39
|
issuerId?: string;
|
|
56
|
-
//
|
|
40
|
+
// Delivery surface for the wrapper (§2.1). Not trusted.
|
|
57
41
|
gateway?: string | null;
|
|
58
|
-
// Wrapper
|
|
59
|
-
// SHA-256(JCS(prior wrapper without signature)) for a
|
|
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
|
|
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))
|
|
52
|
+
// SHA-256(JCS(source_bundle)) (§2.2).
|
|
72
53
|
sourceBundleHash: string;
|
|
73
|
-
//
|
|
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
|
-
//
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
//
|
|
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
|
-
//
|
|
96
|
-
//
|
|
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
|
-
//
|
|
109
|
-
//
|
|
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
|
-
//
|
|
126
|
-
//
|
|
127
|
-
//
|
|
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
|
|
154
|
-
//
|
|
155
|
-
//
|
|
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
|
|
171
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
|
|
6
4
|
import * as ed from "@noble/ed25519";
|
|
7
5
|
|
|
8
|
-
//
|
|
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
|
|
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
|
-
//
|
|
31
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
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
|
-
//
|
|
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`:
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
//
|
|
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
|
-
//
|
|
137
|
-
//
|
|
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
|
|
149
|
-
*
|
|
150
|
-
*
|
|
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`
|
|
168
|
-
*
|
|
169
|
-
*
|
|
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
|
-
//
|
|
197
|
-
//
|
|
198
|
-
//
|
|
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
|
-
//
|
|
213
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
239
|
-
//
|
|
240
|
-
//
|
|
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
|
|
248
|
-
//
|
|
249
|
-
//
|
|
250
|
-
//
|
|
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
|
-
//
|
|
256
|
-
//
|
|
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
|
-
//
|
|
260
|
-
//
|
|
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
|
-
//
|
|
279
|
-
//
|
|
280
|
-
//
|
|
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
|
-
//
|
|
293
|
-
//
|
|
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
|
|
325
|
-
//
|
|
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
|
-
//
|
|
332
|
-
//
|
|
333
|
-
//
|
|
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,
|
|
348
|
-
//
|
|
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
|
|
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);
|