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