burnledger 0.2.2 → 0.3.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 +1 -1
- package/dist/cjs/index.browser.d.ts +2 -1
- package/dist/cjs/index.browser.d.ts.map +1 -1
- package/dist/cjs/index.browser.js +7 -1
- package/dist/cjs/index.browser.js.map +1 -1
- package/dist/cjs/index.d.ts +9 -1
- package/dist/cjs/index.d.ts.map +1 -1
- package/dist/cjs/index.js +17 -1
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/models.d.ts +28 -1
- package/dist/cjs/models.d.ts.map +1 -1
- package/dist/cjs/models.js +8 -0
- package/dist/cjs/models.js.map +1 -1
- package/dist/cjs/verify.d.ts +55 -1
- package/dist/cjs/verify.d.ts.map +1 -1
- package/dist/cjs/verify.js +414 -104
- package/dist/cjs/verify.js.map +1 -1
- package/dist/cjs/web-verifier.d.ts +29 -0
- package/dist/cjs/web-verifier.d.ts.map +1 -0
- package/dist/cjs/web-verifier.js +70 -0
- package/dist/cjs/web-verifier.js.map +1 -0
- package/dist/esm/cli.d.ts.map +1 -1
- package/dist/esm/cli.js +12 -5
- package/dist/esm/cli.js.map +1 -1
- package/dist/esm/index.browser.d.ts +2 -1
- package/dist/esm/index.browser.d.ts.map +1 -1
- package/dist/esm/index.browser.js +3 -0
- package/dist/esm/index.browser.js.map +1 -1
- package/dist/esm/index.d.ts +9 -1
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +13 -1
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/models.d.ts +28 -1
- package/dist/esm/models.d.ts.map +1 -1
- package/dist/esm/models.js +8 -0
- package/dist/esm/models.js.map +1 -1
- package/dist/esm/verify.d.ts +55 -1
- package/dist/esm/verify.d.ts.map +1 -1
- package/dist/esm/verify.js +411 -105
- package/dist/esm/verify.js.map +1 -1
- package/dist/esm/web-verifier.d.ts +29 -0
- package/dist/esm/web-verifier.d.ts.map +1 -0
- package/dist/esm/web-verifier.js +63 -0
- package/dist/esm/web-verifier.js.map +1 -0
- package/package.json +12 -11
- package/src/cli.ts +207 -0
- package/src/client.ts +555 -0
- package/src/crypto-browser.ts +49 -0
- package/src/crypto-node.ts +40 -0
- package/src/crypto.ts +10 -0
- package/src/errors.ts +154 -0
- package/src/http.ts +209 -0
- package/src/index.browser.ts +110 -0
- package/src/index.ts +134 -0
- package/src/keys.ts +18 -0
- package/src/models.ts +558 -0
- package/src/pagination.ts +64 -0
- package/src/verify.ts +956 -0
- package/src/web-verifier.ts +89 -0
- package/src/webhooks.ts +76 -0
package/src/verify.ts
ADDED
|
@@ -0,0 +1,956 @@
|
|
|
1
|
+
/** Offline certificate and transparency verification.
|
|
2
|
+
*
|
|
3
|
+
* Ports the Go signing payload builders (core/payload.go) and Merkle tree
|
|
4
|
+
* verification (core/merkle.go) to TypeScript. All crypto is delegated to
|
|
5
|
+
* a CryptoOps provider (Node or browser).
|
|
6
|
+
*
|
|
7
|
+
* Critical encoding details:
|
|
8
|
+
* - Go's json.Marshal encodes []byte as base64, [N]byte as number arrays.
|
|
9
|
+
* - The payload builders normalize both formats to lowercase hex strings.
|
|
10
|
+
* - Timestamps in payloads use second-precision UTC: "YYYY-MM-DDTHH:MM:SSZ".
|
|
11
|
+
* - RFC 8785 canonical JSON: sorted keys recursively, no whitespace.
|
|
12
|
+
* - Transparency leaf = SHA-256(0x00 || logLeafPayload), where the payload
|
|
13
|
+
* commits to entry_type, certificate_id, SHA-256(issuanceBytes(cert)) and
|
|
14
|
+
* appended_at (ADR-016 §4). NOT the
|
|
15
|
+
* certificate signing payload. issuanceBytes reconstructs json.Marshal
|
|
16
|
+
* output at issuance time (transparency_status=PENDING, no transparency).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { CryptoOps } from "./crypto.js";
|
|
20
|
+
import { VerificationError } from "./errors.js";
|
|
21
|
+
import type { TransparencyResult, VerificationResult } from "./models.js";
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Pure byte helpers (no Buffer, no node:crypto)
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
const HEX_CHARS = "0123456789abcdef";
|
|
28
|
+
|
|
29
|
+
// Signing-format v2 domain-separation tags (CR-H01). Must match the
|
|
30
|
+
// source-of-truth const block in Go's core/payload.go byte-for-byte.
|
|
31
|
+
const PAYLOAD_TYPE_ATTESTATION = "burnledger.attestation.v3";
|
|
32
|
+
const PAYLOAD_TYPE_CERTIFICATE = "burnledger.certificate.v3";
|
|
33
|
+
const PAYLOAD_TYPE_TREE_HEAD = "burnledger.sth.v3";
|
|
34
|
+
const PAYLOAD_TYPE_LOG_LEAF = "burnledger.log_leaf.v3";
|
|
35
|
+
// There is deliberately no verification payload type: v3 produces those facts
|
|
36
|
+
// in the same enclave call that signs the certificate (ADR-016 §2).
|
|
37
|
+
const PAYLOAD_TYPE_CERTIFICATE_STATUS = "burnledger.certificate_status.v3";
|
|
38
|
+
|
|
39
|
+
export function hexToBytes(hex: string): Uint8Array {
|
|
40
|
+
const len = hex.length >>> 1;
|
|
41
|
+
const out = new Uint8Array(len);
|
|
42
|
+
for (let i = 0; i < len; i++) {
|
|
43
|
+
const hi = hex.charCodeAt(i * 2);
|
|
44
|
+
const lo = hex.charCodeAt(i * 2 + 1);
|
|
45
|
+
out[i] = (unhex(hi) << 4) | unhex(lo);
|
|
46
|
+
}
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function unhex(c: number): number {
|
|
51
|
+
// 0-9
|
|
52
|
+
if (c >= 48 && c <= 57) return c - 48;
|
|
53
|
+
// a-f
|
|
54
|
+
if (c >= 97 && c <= 102) return c - 87;
|
|
55
|
+
// A-F
|
|
56
|
+
if (c >= 65 && c <= 70) return c - 55;
|
|
57
|
+
return 0;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function bytesToHex(bytes: Uint8Array): string {
|
|
61
|
+
let out = "";
|
|
62
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
63
|
+
const b = bytes[i]!;
|
|
64
|
+
out += HEX_CHARS[b >>> 4];
|
|
65
|
+
out += HEX_CHARS[b & 0x0f];
|
|
66
|
+
}
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function concatBytes(a: Uint8Array, b: Uint8Array): Uint8Array {
|
|
71
|
+
const out = new Uint8Array(a.length + b.length);
|
|
72
|
+
out.set(a, 0);
|
|
73
|
+
out.set(b, a.length);
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
|
|
78
|
+
if (a.length !== b.length) return false;
|
|
79
|
+
for (let i = 0; i < a.length; i++) {
|
|
80
|
+
if (a[i] !== b[i]) return false;
|
|
81
|
+
}
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function base64ToBytes(b64: string): Uint8Array {
|
|
86
|
+
// Use atob for universal browser+node compatibility.
|
|
87
|
+
// Node 18+ has atob globally; browsers always have it.
|
|
88
|
+
const bin = atob(b64);
|
|
89
|
+
const out = new Uint8Array(bin.length);
|
|
90
|
+
for (let i = 0; i < bin.length; i++) {
|
|
91
|
+
out[i] = bin.charCodeAt(i);
|
|
92
|
+
}
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function textToBytes(s: string): Uint8Array {
|
|
97
|
+
return new TextEncoder().encode(s);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
// Public key info
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
export interface PublicKeyInfo {
|
|
105
|
+
readonly keyBytes: Uint8Array;
|
|
106
|
+
readonly keyId: string;
|
|
107
|
+
readonly revoked: boolean;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export async function publicKeyFromHex(
|
|
111
|
+
crypto: CryptoOps,
|
|
112
|
+
hexKey: string,
|
|
113
|
+
opts?: { revoked?: boolean },
|
|
114
|
+
): Promise<PublicKeyInfo> {
|
|
115
|
+
const raw = hexToBytes(hexKey);
|
|
116
|
+
if (raw.length !== 32) {
|
|
117
|
+
throw new VerificationError(`public key must be 32 bytes, got ${raw.length}`);
|
|
118
|
+
}
|
|
119
|
+
const hash = await crypto.sha256(raw);
|
|
120
|
+
const keyId = "dp_k_" + bytesToHex(hash);
|
|
121
|
+
return { keyBytes: raw, keyId, revoked: opts?.revoked ?? false };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
// Public API
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
type Cert = Record<string, unknown>;
|
|
129
|
+
|
|
130
|
+
/** Verify all signatures on a deletion certificate offline. */
|
|
131
|
+
export async function verifyCertificate(
|
|
132
|
+
crypto: CryptoOps,
|
|
133
|
+
certificate: Cert,
|
|
134
|
+
publicKeys: Map<string, PublicKeyInfo>,
|
|
135
|
+
): Promise<VerificationResult> {
|
|
136
|
+
const issuer = certificate.issuer as Record<string, unknown>;
|
|
137
|
+
const keyId = issuer.key_id as string;
|
|
138
|
+
|
|
139
|
+
const pki = publicKeys.get(keyId);
|
|
140
|
+
if (pki === undefined) throw new VerificationError(`unknown issuer key: ${keyId}`);
|
|
141
|
+
if (pki.revoked) throw new VerificationError(`issuer key is revoked: ${keyId}`);
|
|
142
|
+
|
|
143
|
+
// 1. Certificate signature FIRST — nothing below may trust a field until the
|
|
144
|
+
// bytes carrying it are covered by a verified signature.
|
|
145
|
+
const certPayload = buildCertificatePayload(certificate);
|
|
146
|
+
const certSig = decodeSignature(certificate.certificate_signature as string);
|
|
147
|
+
if (!(await crypto.ed25519Verify(pki.keyBytes, certPayload, certSig))) {
|
|
148
|
+
throw new VerificationError("certificate signature is invalid");
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// 2. Attestation signature, rebuilt from the merged list. There is no
|
|
152
|
+
// verification signature: those facts are produced in the same enclave call
|
|
153
|
+
// that signs the certificate, so certificate_signature already covers them
|
|
154
|
+
// (ADR-016 §2).
|
|
155
|
+
const att = certificate.attestation as Record<string, unknown>;
|
|
156
|
+
const attPayload = buildAttestationPayload(
|
|
157
|
+
certificate.subject as Record<string, unknown>,
|
|
158
|
+
{
|
|
159
|
+
proof_mode: att.proof_mode,
|
|
160
|
+
attested_at: att.attested_at,
|
|
161
|
+
systems: attestationSystems(certificate),
|
|
162
|
+
},
|
|
163
|
+
);
|
|
164
|
+
const attSig = decodeSignature(att.attestation_signature as string);
|
|
165
|
+
if (!(await crypto.ed25519Verify(pki.keyBytes, attPayload, attSig))) {
|
|
166
|
+
throw new VerificationError("attestation signature is invalid");
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// 3. Structural rules on the system set.
|
|
170
|
+
//
|
|
171
|
+
// v2 carried two lists joined on the human-editable system_name, so "attested
|
|
172
|
+
// but not verified" was a representable state that had to be caught by a
|
|
173
|
+
// check — and for a long time was not (#451). v3 carries one list keyed by
|
|
174
|
+
// system_id, which makes that state unexpressible. What remains is
|
|
175
|
+
// structural, and mirrors core/verify.go step 4.
|
|
176
|
+
const systems = (certificate.systems as Record<string, unknown>[]) ?? [];
|
|
177
|
+
if (systems.length === 0) {
|
|
178
|
+
throw new VerificationError("certificate attests no systems, so it asserts nothing");
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const seen = new Set<string>();
|
|
182
|
+
let anyAttested = false;
|
|
183
|
+
for (const s of systems) {
|
|
184
|
+
const systemId = s.system_id as string | undefined;
|
|
185
|
+
if (!systemId || systemId === NIL_UUID) {
|
|
186
|
+
throw new VerificationError("malformed systems: a system carries no system_id");
|
|
187
|
+
}
|
|
188
|
+
if (seen.has(systemId)) {
|
|
189
|
+
// Duplicate ids would let one verification stand in for several systems,
|
|
190
|
+
// which is the v2 hole in a new costume.
|
|
191
|
+
throw new VerificationError(`malformed systems: duplicate system_id ${systemId}`);
|
|
192
|
+
}
|
|
193
|
+
seen.add(systemId);
|
|
194
|
+
|
|
195
|
+
const attestedAt = parseRfc3339(s.attested_at as string);
|
|
196
|
+
const verifiedAt = parseRfc3339(s.verified_at as string);
|
|
197
|
+
if (verifiedAt < attestedAt) {
|
|
198
|
+
throw new VerificationError(
|
|
199
|
+
`malformed systems: system ${systemId} was verified before it was attested`,
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if ((s.attested_count as number) > 0) anyAttested = true;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// 4. Nothing may remain anywhere.
|
|
207
|
+
for (const s of systems) {
|
|
208
|
+
const remaining = s.verified_count as number;
|
|
209
|
+
if (remaining !== 0) {
|
|
210
|
+
throw new VerificationError(
|
|
211
|
+
`data still present: system "${s.system_name as string}" reports ${remaining} record(s)`,
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// 5. At least one system must have held records at attest time — across the
|
|
217
|
+
// set, not per system. Every system reporting zero means the certificate
|
|
218
|
+
// documents the deletion of nothing.
|
|
219
|
+
if (!anyAttested) {
|
|
220
|
+
throw new VerificationError(
|
|
221
|
+
"incomplete verification: no system held any records at attestation time",
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return "VALID" as VerificationResult;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const NIL_UUID = "00000000-0000-0000-0000-000000000000";
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
/** Verify the transparency proof embedded in a certificate. */
|
|
232
|
+
export async function verifyTransparency(
|
|
233
|
+
crypto: CryptoOps,
|
|
234
|
+
certificate: Cert,
|
|
235
|
+
publicKeys: Map<string, PublicKeyInfo>,
|
|
236
|
+
): Promise<TransparencyResult> {
|
|
237
|
+
const transparency = certificate.transparency as
|
|
238
|
+
| Record<string, unknown>
|
|
239
|
+
| undefined;
|
|
240
|
+
if (transparency == null) {
|
|
241
|
+
return "NOT_AVAILABLE" as TransparencyResult;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const sth = transparency.signed_tree_head as Record<string, unknown>;
|
|
245
|
+
const issuer = certificate.issuer as Record<string, unknown>;
|
|
246
|
+
const keyId = issuer.key_id as string;
|
|
247
|
+
|
|
248
|
+
const pki = publicKeys.get(keyId);
|
|
249
|
+
if (pki === undefined) throw new VerificationError(`unknown issuer key: ${keyId}`);
|
|
250
|
+
if (pki.revoked) throw new VerificationError(`issuer key is revoked: ${keyId}`);
|
|
251
|
+
|
|
252
|
+
// 1. Tree head signature
|
|
253
|
+
const headPayload = buildTreeHeadPayload(sth);
|
|
254
|
+
const headSig = decodeSignature(sth.signature as string);
|
|
255
|
+
if (!(await crypto.ed25519Verify(pki.keyBytes, headPayload, headSig))) {
|
|
256
|
+
throw new VerificationError("tree head signature is invalid");
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// 2. Merkle inclusion proof.
|
|
260
|
+
//
|
|
261
|
+
// The leaf is NOT the certificate. It is the canonical log_leaf.v3 payload
|
|
262
|
+
// over entry_type, certificate_id, certificate_hash and appended_at, where
|
|
263
|
+
// certificate_hash is SHA-256 of the issuance-time certificate JSON
|
|
264
|
+
// (ADR-016 §4). v2 hashed the certificate directly, so an issuance and a
|
|
265
|
+
// revocation of the same certificate produced identical leaves and the tree
|
|
266
|
+
// committed to neither the entry type nor when it happened.
|
|
267
|
+
const issuanceData = issuanceBytes(certificate);
|
|
268
|
+
const leafPayload = buildLogLeafPayload(
|
|
269
|
+
transparency.entry_type,
|
|
270
|
+
certificate.certificate_id as string,
|
|
271
|
+
await crypto.sha256(issuanceData),
|
|
272
|
+
transparency.appended_at,
|
|
273
|
+
);
|
|
274
|
+
const leaf = await hashLeaf(crypto, leafPayload);
|
|
275
|
+
|
|
276
|
+
const proofHashes = (
|
|
277
|
+
(transparency.inclusion_proof as unknown[] | undefined) ?? []
|
|
278
|
+
).map((h) => decodeBytes(h));
|
|
279
|
+
const root = decodeBytes(sth.root_hash);
|
|
280
|
+
// `?? 0` is not a convenience: encoding/json leaves 0 in Go's uint64 fields
|
|
281
|
+
// for an explicit null and for an absent key rather than failing, so refusing
|
|
282
|
+
// either would reject documents the reference accepts — the same class of
|
|
283
|
+
// divergence as #452. An offline verifier is only useful while it agrees.
|
|
284
|
+
const index = requireUint(transparency.entry_index ?? 0, "entry_index");
|
|
285
|
+
const treeSize = requireUint(sth.tree_size ?? 0, "signed_tree_head.tree_size");
|
|
286
|
+
|
|
287
|
+
// transparency.tree_size duplicates the signed one but carries no signature:
|
|
288
|
+
// BuildTreeHeadPayload covers only the copy inside signed_tree_head. Go used
|
|
289
|
+
// to verify against the unsigned copy while this SDK used the signed one, so
|
|
290
|
+
// the same document got two verdicts (#456). Both now require the two to agree
|
|
291
|
+
// and then verify against the signed copy — the server always writes them
|
|
292
|
+
// equal, so this rejects only edited documents.
|
|
293
|
+
const unsignedSize = requireUint(
|
|
294
|
+
transparency.tree_size ?? 0,
|
|
295
|
+
"transparency.tree_size",
|
|
296
|
+
);
|
|
297
|
+
if (unsignedSize !== treeSize) {
|
|
298
|
+
throw new VerificationError(
|
|
299
|
+
`transparency.tree_size (${unsignedSize}) does not match the signed tree head ` +
|
|
300
|
+
`(${treeSize}); it is not covered by any signature`,
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (!(await verifyInclusion(crypto, leaf, index, treeSize, proofHashes, root))) {
|
|
305
|
+
throw new VerificationError("merkle inclusion proof is invalid");
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return "INCLUDED" as TransparencyResult;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// ---------------------------------------------------------------------------
|
|
312
|
+
// Issuance bytes — matches Go's core.IssuanceBytes(cert)
|
|
313
|
+
// ---------------------------------------------------------------------------
|
|
314
|
+
|
|
315
|
+
/** Reconstruct the certificate JSON as it existed at issuance time.
|
|
316
|
+
*
|
|
317
|
+
* At issuance: transparency_status = "PENDING", transparency = nil (omitted).
|
|
318
|
+
* The leaf preimage is the RFC 8785 canonical JSON of that form — exactly
|
|
319
|
+
* Go's `CanonicalJSON(json.Marshal(cert))` in core.IssuanceBytes. It MUST be
|
|
320
|
+
* canonicalized (sorted keys, recursively), not serialized in insertion order:
|
|
321
|
+
* Go's struct field order is not alphabetical, so insertion-order stringify
|
|
322
|
+
* produces a different leaf hash and every genuine certificate fails inclusion
|
|
323
|
+
* verification (#C-01).
|
|
324
|
+
*/
|
|
325
|
+
export function issuanceBytes(certificate: Cert): Uint8Array {
|
|
326
|
+
const clone = JSON.parse(JSON.stringify(certificate)) as Record<string, unknown>;
|
|
327
|
+
clone.transparency_status = "PENDING";
|
|
328
|
+
delete clone.transparency;
|
|
329
|
+
return canonicalJson(clone);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// ---------------------------------------------------------------------------
|
|
333
|
+
// RFC 8785 Canonical JSON
|
|
334
|
+
// ---------------------------------------------------------------------------
|
|
335
|
+
|
|
336
|
+
function canonicalJson(obj: unknown): Uint8Array {
|
|
337
|
+
return textToBytes(canonicalStringify(obj));
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function canonicalStringify(val: unknown): string {
|
|
341
|
+
if (val === null || val === undefined) return "null";
|
|
342
|
+
if (typeof val === "boolean") return val ? "true" : "false";
|
|
343
|
+
if (typeof val === "number") return JSON.stringify(val);
|
|
344
|
+
if (typeof val === "string") return JSON.stringify(val);
|
|
345
|
+
if (Array.isArray(val)) {
|
|
346
|
+
return "[" + val.map(canonicalStringify).join(",") + "]";
|
|
347
|
+
}
|
|
348
|
+
if (typeof val === "object") {
|
|
349
|
+
const obj = val as Record<string, unknown>;
|
|
350
|
+
const keys = Object.keys(obj).sort();
|
|
351
|
+
const pairs = keys.map(
|
|
352
|
+
(k) => JSON.stringify(k) + ":" + canonicalStringify(obj[k]),
|
|
353
|
+
);
|
|
354
|
+
return "{" + pairs.join(",") + "}";
|
|
355
|
+
}
|
|
356
|
+
return JSON.stringify(val);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// ---------------------------------------------------------------------------
|
|
360
|
+
// Byte encoding helpers
|
|
361
|
+
// ---------------------------------------------------------------------------
|
|
362
|
+
|
|
363
|
+
/** Convert a byte field to lowercase hex. Handles:
|
|
364
|
+
* - hex string (from hand-crafted test data)
|
|
365
|
+
* - base64 string (from Go's json.Marshal of []byte slices)
|
|
366
|
+
* - number[] (from Go's json.Marshal of [N]byte fixed arrays)
|
|
367
|
+
*/
|
|
368
|
+
function toHex(value: unknown): string {
|
|
369
|
+
if (Array.isArray(value)) {
|
|
370
|
+
return bytesToHex(new Uint8Array(value as number[]));
|
|
371
|
+
}
|
|
372
|
+
const str = value as string;
|
|
373
|
+
if (/^[0-9a-fA-F]+$/.test(str) && str.length % 2 === 0) {
|
|
374
|
+
return str.toLowerCase();
|
|
375
|
+
}
|
|
376
|
+
return bytesToHex(base64ToBytes(str));
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** Decode a signature field to raw bytes. Same format handling as toHex. */
|
|
380
|
+
function decodeSignature(value: unknown): Uint8Array {
|
|
381
|
+
if (Array.isArray(value)) {
|
|
382
|
+
return new Uint8Array(value as number[]);
|
|
383
|
+
}
|
|
384
|
+
const str = value as string;
|
|
385
|
+
if (/^[0-9a-fA-F]+$/.test(str)) {
|
|
386
|
+
const raw = hexToBytes(str);
|
|
387
|
+
if (raw.length === 64) return raw;
|
|
388
|
+
}
|
|
389
|
+
return base64ToBytes(str);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** Decode a hash/bytes field to raw bytes. Same format handling as toHex. */
|
|
393
|
+
function decodeBytes(value: unknown): Uint8Array {
|
|
394
|
+
if (Array.isArray(value)) {
|
|
395
|
+
return new Uint8Array(value as number[]);
|
|
396
|
+
}
|
|
397
|
+
const str = value as string;
|
|
398
|
+
if (/^[0-9a-fA-F]+$/.test(str) && str.length % 2 === 0) {
|
|
399
|
+
return hexToBytes(str);
|
|
400
|
+
}
|
|
401
|
+
return base64ToBytes(str);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** Go's zero time.Time — what encoding/json leaves in a non-pointer time.Time
|
|
405
|
+
* field for an explicit JSON null or an absent key. */
|
|
406
|
+
const ZERO_INSTANT = "0001-01-01T00:00:00Z";
|
|
407
|
+
|
|
408
|
+
/** The grammar encoding/json accepts for time.Time: strict RFC 3339, uppercase
|
|
409
|
+
* "T" and "Z" only, fixed field widths, at least one fractional digit if a "."
|
|
410
|
+
* is present. Anything else makes Go fail to parse the certificate at all. */
|
|
411
|
+
const RFC3339_RE =
|
|
412
|
+
/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?:Z|([+-])(\d{2}):(\d{2}))$/;
|
|
413
|
+
|
|
414
|
+
/** Normalize a certificate timestamp to the UTC form Go signs.
|
|
415
|
+
*
|
|
416
|
+
* Go builds every payload timestamp as `t.UTC().Format("2006-01-02T15:04:05Z")`
|
|
417
|
+
* (core/payload.go) on a value encoding/json already parsed as strict RFC 3339.
|
|
418
|
+
* This reproduces both halves — the same acceptance rules and the same
|
|
419
|
+
* conversion — because the two are one contract, not two.
|
|
420
|
+
*
|
|
421
|
+
* It must convert, never truncate. `12:00:00+05:00` and `12:00:00Z` are six
|
|
422
|
+
* hours apart; treating them as the same string made this verifier accept a
|
|
423
|
+
* certificate whose attestation window had been re-labelled to a different
|
|
424
|
+
* instant, and reject genuine certificates issued by a server on local time
|
|
425
|
+
* (#452). Input Go would refuse to parse is refused here rather than
|
|
426
|
+
* string-surgered into something that verifies.
|
|
427
|
+
*
|
|
428
|
+
* `null`/`undefined` — an explicit JSON null, or a key Go's struct has and the
|
|
429
|
+
* document does not — is Go's zero time, not an error.
|
|
430
|
+
*
|
|
431
|
+
* Exported from this module so the timestamp_vectors.json suite can diff it
|
|
432
|
+
* against Go directly. It is deliberately not re-exported from index.ts, so it
|
|
433
|
+
* is not part of the published package surface.
|
|
434
|
+
*/
|
|
435
|
+
export function formatTimestamp(ts: unknown): string {
|
|
436
|
+
if (ts === null || ts === undefined) return ZERO_INSTANT;
|
|
437
|
+
if (typeof ts !== "string") {
|
|
438
|
+
throw new VerificationError(`timestamp is not a string: ${JSON.stringify(ts)}`);
|
|
439
|
+
}
|
|
440
|
+
return formatEpochSeconds(parseRfc3339(ts));
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/** Parse a strict RFC 3339 timestamp to seconds since 1970-01-01T00:00:00Z.
|
|
444
|
+
*
|
|
445
|
+
* Rejects exactly what Go's encoding/json rejects, including the ranges its
|
|
446
|
+
* parser enforces once the shape matches. Those ranges were measured against
|
|
447
|
+
* Go 1.26, not inferred from RFC 3339, and they are not the obvious ones: the
|
|
448
|
+
* time of day is 23:59:59 at most, but a zone offset runs to +24:60 — both
|
|
449
|
+
* `+24:00` and `+00:60` parse, while `+25:00` and `+00:61` do not. Sub-second
|
|
450
|
+
* digits are dropped, matching Format's truncation.
|
|
451
|
+
*/
|
|
452
|
+
function parseRfc3339(ts: string): number {
|
|
453
|
+
const m = RFC3339_RE.exec(ts);
|
|
454
|
+
if (m === null) throw new VerificationError(`timestamp is not RFC 3339: ${ts}`);
|
|
455
|
+
const [year, month, day, hour, minute, second] = m
|
|
456
|
+
.slice(1, 7)
|
|
457
|
+
.map((v) => Number.parseInt(v!, 10)) as [number, number, number, number, number, number];
|
|
458
|
+
|
|
459
|
+
if (month < 1 || month > 12) throw new VerificationError(`timestamp month out of range: ${ts}`);
|
|
460
|
+
if (day < 1 || day > daysInMonth(year, month)) {
|
|
461
|
+
throw new VerificationError(`timestamp day out of range: ${ts}`);
|
|
462
|
+
}
|
|
463
|
+
if (hour > 23 || minute > 59 || second > 59) {
|
|
464
|
+
throw new VerificationError(`timestamp time of day out of range: ${ts}`);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
let offset = 0;
|
|
468
|
+
const sign = m[7];
|
|
469
|
+
if (sign !== undefined) {
|
|
470
|
+
const offHour = Number.parseInt(m[8]!, 10);
|
|
471
|
+
const offMin = Number.parseInt(m[9]!, 10);
|
|
472
|
+
if (offHour > 24) throw new VerificationError(`timestamp zone offset hour out of range: ${ts}`);
|
|
473
|
+
if (offMin > 60) throw new VerificationError(`timestamp zone offset minute out of range: ${ts}`);
|
|
474
|
+
offset = (offHour * 3600 + offMin * 60) * (sign === "-" ? -1 : 1);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
return daysFromCivil(year, month, day) * 86400 + hour * 3600 + minute * 60 + second - offset;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/** Render seconds since the Unix epoch as `YYYY-MM-DDTHH:MM:SSZ`.
|
|
481
|
+
*
|
|
482
|
+
* Matches Go's Format for the reachable extremes: a year 0000 timestamp with a
|
|
483
|
+
* `+24:00` offset lands in year -1, which Go prints as `-0001`, and 9999-12-31
|
|
484
|
+
* with `-24:00` lands in year 10000, which Go prints unpadded.
|
|
485
|
+
*/
|
|
486
|
+
function formatEpochSeconds(total: number): string {
|
|
487
|
+
const days = Math.floor(total / 86400);
|
|
488
|
+
const secs = total - days * 86400;
|
|
489
|
+
const [year, month, day] = civilFromDays(days);
|
|
490
|
+
const hour = Math.floor(secs / 3600);
|
|
491
|
+
const minute = Math.floor((secs - hour * 3600) / 60);
|
|
492
|
+
const second = secs - hour * 3600 - minute * 60;
|
|
493
|
+
const printedYear = year < 0 ? "-" + pad(-year, 4) : pad(year, 4);
|
|
494
|
+
return `${printedYear}-${pad(month, 2)}-${pad(day, 2)}T${pad(hour, 2)}:${pad(minute, 2)}:${pad(second, 2)}Z`;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function pad(value: number, width: number): string {
|
|
498
|
+
return String(value).padStart(width, "0");
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/** Length of a proleptic Gregorian month. Year 0 is a leap year; 1900 is not. */
|
|
502
|
+
function daysInMonth(year: number, month: number): number {
|
|
503
|
+
if (month === 2) {
|
|
504
|
+
const isLeap = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
|
|
505
|
+
return isLeap ? 29 : 28;
|
|
506
|
+
}
|
|
507
|
+
return month === 4 || month === 6 || month === 9 || month === 11 ? 30 : 31;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/** Days from 1970-01-01 to a proleptic Gregorian date (Howard Hinnant's algorithm).
|
|
511
|
+
*
|
|
512
|
+
* Deliberately not `Date`: Go accepts year 0000, and a zone offset can push the
|
|
513
|
+
* UTC instant to year -1 or 10000, where `Date`'s parsing and formatting stop
|
|
514
|
+
* agreeing with Go. A verifier that disagrees with the reference on any input is
|
|
515
|
+
* a verifier a relying party cannot use to second-guess the reference.
|
|
516
|
+
*/
|
|
517
|
+
function daysFromCivil(year: number, month: number, day: number): number {
|
|
518
|
+
const y = month <= 2 ? year - 1 : year;
|
|
519
|
+
const era = Math.floor(y / 400);
|
|
520
|
+
const yearOfEra = y - era * 400; // [0, 399]
|
|
521
|
+
const shiftedMonth = month > 2 ? month - 3 : month + 9; // [0, 11]
|
|
522
|
+
const dayOfYear = Math.floor((153 * shiftedMonth + 2) / 5) + day - 1; // [0, 365]
|
|
523
|
+
const dayOfEra =
|
|
524
|
+
yearOfEra * 365 +
|
|
525
|
+
Math.floor(yearOfEra / 4) -
|
|
526
|
+
Math.floor(yearOfEra / 100) +
|
|
527
|
+
dayOfYear;
|
|
528
|
+
return era * 146097 + dayOfEra - 719468;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/** Inverse of daysFromCivil. */
|
|
532
|
+
function civilFromDays(days: number): [number, number, number] {
|
|
533
|
+
const z = days + 719468;
|
|
534
|
+
const era = Math.floor(z / 146097);
|
|
535
|
+
const dayOfEra = z - era * 146097; // [0, 146096]
|
|
536
|
+
const yearOfEra = Math.floor(
|
|
537
|
+
(dayOfEra -
|
|
538
|
+
Math.floor(dayOfEra / 1460) +
|
|
539
|
+
Math.floor(dayOfEra / 36524) -
|
|
540
|
+
Math.floor(dayOfEra / 146096)) /
|
|
541
|
+
365,
|
|
542
|
+
); // [0, 399]
|
|
543
|
+
const dayOfYear =
|
|
544
|
+
dayOfEra -
|
|
545
|
+
(365 * yearOfEra + Math.floor(yearOfEra / 4) - Math.floor(yearOfEra / 100)); // [0, 365]
|
|
546
|
+
const shiftedMonth = Math.floor((5 * dayOfYear + 2) / 153); // [0, 11]
|
|
547
|
+
const day = dayOfYear - Math.floor((153 * shiftedMonth + 2) / 5) + 1;
|
|
548
|
+
const month = shiftedMonth < 10 ? shiftedMonth + 3 : shiftedMonth - 9;
|
|
549
|
+
const year = yearOfEra + era * 400 + (month <= 2 ? 1 : 0);
|
|
550
|
+
return [year, month, day];
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// ---------------------------------------------------------------------------
|
|
554
|
+
// Payload builders — exact ports of core/payload.go
|
|
555
|
+
// ---------------------------------------------------------------------------
|
|
556
|
+
|
|
557
|
+
function buildAttestationPayload(
|
|
558
|
+
subject: Record<string, unknown>,
|
|
559
|
+
att: Record<string, unknown>,
|
|
560
|
+
): Uint8Array {
|
|
561
|
+
const attestedAt = formatTimestamp(att.attested_at);
|
|
562
|
+
const systems = (att.systems as Record<string, unknown>[]).map((s) => ({
|
|
563
|
+
canonical_version: (s.canonical_version as string | null) ?? null,
|
|
564
|
+
connector_type: s.connector_type as string,
|
|
565
|
+
hash_scope: s.hash_scope as string,
|
|
566
|
+
merkle_root: s.merkle_root
|
|
567
|
+
? toHex(s.merkle_root as string)
|
|
568
|
+
: null,
|
|
569
|
+
// The system's own observation time, not the envelope's.
|
|
570
|
+
observed_at: formatTimestamp(s.observed_at),
|
|
571
|
+
query_hash: toHex(s.query_hash as string),
|
|
572
|
+
record_count: s.record_count as number,
|
|
573
|
+
system_id: s.system_id as string,
|
|
574
|
+
system_name: s.system_name as string,
|
|
575
|
+
}));
|
|
576
|
+
|
|
577
|
+
const payload = {
|
|
578
|
+
attested_at: attestedAt,
|
|
579
|
+
payload_type: PAYLOAD_TYPE_ATTESTATION,
|
|
580
|
+
proof_mode: att.proof_mode as string,
|
|
581
|
+
subject_hash: toHex(subject.identifier_hash as string),
|
|
582
|
+
systems,
|
|
583
|
+
};
|
|
584
|
+
return canonicalJson(payload);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function buildCertificatePayload(cert: Record<string, unknown>): Uint8Array {
|
|
588
|
+
const att = cert.attestation as Record<string, unknown>;
|
|
589
|
+
const issuer = cert.issuer as Record<string, unknown>;
|
|
590
|
+
const subject = cert.subject as Record<string, unknown>;
|
|
591
|
+
|
|
592
|
+
// One list, keyed by system_id (ADR-016 §2). v2 signed an attestation list
|
|
593
|
+
// and a verification list joined only on the human-editable system_name,
|
|
594
|
+
// which made a partial deletion indistinguishable from a complete one.
|
|
595
|
+
const systems = ((cert.systems as Record<string, unknown>[]) ?? []).map((s) => ({
|
|
596
|
+
attested_at: formatTimestamp(s.attested_at),
|
|
597
|
+
attested_count: s.attested_count as number,
|
|
598
|
+
canonical_version: (s.canonical_version as string | null) ?? null,
|
|
599
|
+
connector_type: s.connector_type as string,
|
|
600
|
+
hash_scope: s.hash_scope as string,
|
|
601
|
+
merkle_root: s.merkle_root ? toHex(s.merkle_root as string) : null,
|
|
602
|
+
query_hash: toHex(s.query_hash as string),
|
|
603
|
+
system_id: s.system_id as string,
|
|
604
|
+
system_name: s.system_name as string,
|
|
605
|
+
verified_at: formatTimestamp(s.verified_at),
|
|
606
|
+
verified_count: s.verified_count as number,
|
|
607
|
+
}));
|
|
608
|
+
|
|
609
|
+
// The attestation block carries no system list of its own: the merged list
|
|
610
|
+
// above is a superset of it. verification_signature is gone entirely.
|
|
611
|
+
const attObj = {
|
|
612
|
+
attestation_signature: toHex(att.attestation_signature as string),
|
|
613
|
+
attested_at: formatTimestamp(att.attested_at),
|
|
614
|
+
proof_mode: att.proof_mode as string,
|
|
615
|
+
};
|
|
616
|
+
|
|
617
|
+
const issuerObj = {
|
|
618
|
+
key_id: issuer.key_id as string,
|
|
619
|
+
name: issuer.name as string,
|
|
620
|
+
public_key: toHex(issuer.public_key as string),
|
|
621
|
+
};
|
|
622
|
+
|
|
623
|
+
const subjectObj = {
|
|
624
|
+
identifier_hash: toHex(subject.identifier_hash as string),
|
|
625
|
+
identifier_type_hint: subject.identifier_type_hint as string,
|
|
626
|
+
};
|
|
627
|
+
|
|
628
|
+
// status and revocation are deliberately absent (ADR-016 §3): a signature
|
|
629
|
+
// commits to bytes at an instant, revocation is discovered later, so it
|
|
630
|
+
// travels as a separate short-lived signed status statement.
|
|
631
|
+
const payload = {
|
|
632
|
+
attestation: attObj,
|
|
633
|
+
attestation_id: cert.attestation_id as string,
|
|
634
|
+
certificate_format_version: cert.certificate_format_version as string,
|
|
635
|
+
certificate_id: cert.certificate_id as string,
|
|
636
|
+
issued_at: formatTimestamp(cert.issued_at),
|
|
637
|
+
issuer: issuerObj,
|
|
638
|
+
payload_type: PAYLOAD_TYPE_CERTIFICATE,
|
|
639
|
+
subject: subjectObj,
|
|
640
|
+
systems,
|
|
641
|
+
};
|
|
642
|
+
return canonicalJson(payload);
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/** Port of Go's BuildLogLeafPayload (ADR-016 §4). */
|
|
646
|
+
function buildLogLeafPayload(
|
|
647
|
+
entryType: unknown,
|
|
648
|
+
certificateId: string,
|
|
649
|
+
certificateHash: Uint8Array,
|
|
650
|
+
appendedAt: unknown,
|
|
651
|
+
): Uint8Array {
|
|
652
|
+
if (entryType !== "CERTIFICATE" && entryType !== "REVOCATION") {
|
|
653
|
+
throw new VerificationError(`invalid log entry_type: ${String(entryType)}`);
|
|
654
|
+
}
|
|
655
|
+
const payload = {
|
|
656
|
+
appended_at: formatTimestamp(appendedAt),
|
|
657
|
+
certificate_hash: bytesToHex(certificateHash),
|
|
658
|
+
certificate_id: certificateId,
|
|
659
|
+
entry_type: entryType,
|
|
660
|
+
payload_type: PAYLOAD_TYPE_LOG_LEAF,
|
|
661
|
+
};
|
|
662
|
+
return canonicalJson(payload);
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/**
|
|
666
|
+
* Project the merged system list back onto the attest-time shape, so the
|
|
667
|
+
* attestation signature — produced days before the certificate existed — can be
|
|
668
|
+
* checked. Mirrors DeletionCertificate.AttestationSystems() in Go.
|
|
669
|
+
*/
|
|
670
|
+
function attestationSystems(cert: Record<string, unknown>): Record<string, unknown>[] {
|
|
671
|
+
return ((cert.systems as Record<string, unknown>[]) ?? []).map((s) => ({
|
|
672
|
+
system_id: s.system_id,
|
|
673
|
+
system_name: s.system_name,
|
|
674
|
+
connector_type: s.connector_type,
|
|
675
|
+
hash_scope: s.hash_scope,
|
|
676
|
+
query_hash: s.query_hash,
|
|
677
|
+
record_count: s.attested_count,
|
|
678
|
+
observed_at: s.attested_at,
|
|
679
|
+
merkle_root: s.merkle_root,
|
|
680
|
+
canonical_version: s.canonical_version,
|
|
681
|
+
}));
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
function buildTreeHeadPayload(head: Record<string, unknown>): Uint8Array {
|
|
685
|
+
const payload = {
|
|
686
|
+
payload_type: PAYLOAD_TYPE_TREE_HEAD,
|
|
687
|
+
root_hash: toHex(head.root_hash as string),
|
|
688
|
+
timestamp: formatTimestamp(head.timestamp),
|
|
689
|
+
tree_size: head.tree_size as number,
|
|
690
|
+
};
|
|
691
|
+
return canonicalJson(payload);
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// ---------------------------------------------------------------------------
|
|
695
|
+
// Merkle tree (RFC 6962) — ports of core/merkle.go
|
|
696
|
+
// ---------------------------------------------------------------------------
|
|
697
|
+
|
|
698
|
+
async function hashLeaf(crypto: CryptoOps, data: Uint8Array): Promise<Uint8Array> {
|
|
699
|
+
return crypto.sha256(concatBytes(new Uint8Array([0x00]), data));
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
async function hashNode(crypto: CryptoOps, left: Uint8Array, right: Uint8Array): Promise<Uint8Array> {
|
|
703
|
+
const prefix = new Uint8Array([0x01]);
|
|
704
|
+
return crypto.sha256(concatBytes(prefix, concatBytes(left, right)));
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/** Read a tree index or size the way Go's uint64 unmarshalling does.
|
|
708
|
+
*
|
|
709
|
+
* Go's struct fields are uint64, so a negative or fractional JSON number makes
|
|
710
|
+
* the whole certificate fail to parse, and the `index >= size` guard in
|
|
711
|
+
* verifyInclusion never sees one. This SDK gets a JavaScript number, where that
|
|
712
|
+
* guard passes a negative index straight through and any value past 2^53 has
|
|
713
|
+
* already lost the precision the Merkle arithmetic depends on (#456). Both are
|
|
714
|
+
* rejected here, at the document boundary, rather than trusted downstream.
|
|
715
|
+
*/
|
|
716
|
+
function requireUint(value: unknown, field: string): number {
|
|
717
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value)) {
|
|
718
|
+
throw new VerificationError(
|
|
719
|
+
`${field} is not an exact non-negative integer: ${JSON.stringify(value)}`,
|
|
720
|
+
);
|
|
721
|
+
}
|
|
722
|
+
if (value < 0) throw new VerificationError(`${field} is negative: ${value}`);
|
|
723
|
+
return value;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
/** Largest power of 2 strictly less than n. Port of Go's splitPoint.
|
|
727
|
+
*
|
|
728
|
+
* The loop terminates because requireUint has already bounded n to a safe
|
|
729
|
+
* integer; Go, which has no such bound, computes the same value with
|
|
730
|
+
* `1 << (bits.Len64(n-1) - 1)` because its loop form overflowed (#456).
|
|
731
|
+
*/
|
|
732
|
+
function splitPoint(n: number): number {
|
|
733
|
+
let k = 1;
|
|
734
|
+
while (k * 2 < n) {
|
|
735
|
+
k *= 2;
|
|
736
|
+
}
|
|
737
|
+
return k;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
async function verifyInclusion(
|
|
741
|
+
crypto: CryptoOps,
|
|
742
|
+
leaf: Uint8Array,
|
|
743
|
+
index: number,
|
|
744
|
+
size: number,
|
|
745
|
+
proof: Uint8Array[],
|
|
746
|
+
root: Uint8Array,
|
|
747
|
+
): Promise<boolean> {
|
|
748
|
+
if (size === 0 || index >= size) return false;
|
|
749
|
+
if (size === 1) return proof.length === 0 && bytesEqual(leaf, root);
|
|
750
|
+
const [computed, consumed] = await chainInclusion(crypto, leaf, index, size, proof);
|
|
751
|
+
return consumed === proof.length && bytesEqual(computed, root);
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
async function chainInclusion(
|
|
755
|
+
crypto: CryptoOps,
|
|
756
|
+
leaf: Uint8Array,
|
|
757
|
+
index: number,
|
|
758
|
+
n: number,
|
|
759
|
+
proof: Uint8Array[],
|
|
760
|
+
): Promise<[Uint8Array, number]> {
|
|
761
|
+
if (n === 1) return [leaf, 0];
|
|
762
|
+
const k = splitPoint(n);
|
|
763
|
+
if (index < k) {
|
|
764
|
+
const [inner, used] = await chainInclusion(crypto, leaf, index, k, proof);
|
|
765
|
+
if (used >= proof.length) return [inner, used + 1];
|
|
766
|
+
return [await hashNode(crypto, inner, proof[used]!), used + 1];
|
|
767
|
+
}
|
|
768
|
+
const [inner, used] = await chainInclusion(crypto, leaf, index - k, n - k, proof);
|
|
769
|
+
if (used >= proof.length) return [inner, used + 1];
|
|
770
|
+
return [await hashNode(crypto, proof[used]!, inner), used + 1];
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
function isPow2(n: number): boolean {
|
|
774
|
+
return n > 0 && (n & (n - 1)) === 0;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
/** Verify a Merkle consistency proof (RFC 6962 Section 2.1.4).
|
|
778
|
+
*
|
|
779
|
+
* Port of Go's VerifyConsistency / Python's _verify_consistency.
|
|
780
|
+
*/
|
|
781
|
+
export async function verifyConsistency(
|
|
782
|
+
crypto: CryptoOps,
|
|
783
|
+
oldSize: number,
|
|
784
|
+
newSize: number,
|
|
785
|
+
oldRoot: Uint8Array,
|
|
786
|
+
newRoot: Uint8Array,
|
|
787
|
+
proof: Uint8Array[],
|
|
788
|
+
): Promise<boolean> {
|
|
789
|
+
if (oldSize === 0 || newSize === 0 || oldSize > newSize) return false;
|
|
790
|
+
if (oldSize === newSize) {
|
|
791
|
+
return proof.length === 0 && bytesEqual(oldRoot, newRoot);
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
let pIdx = 0;
|
|
795
|
+
let fr: Uint8Array;
|
|
796
|
+
let sr: Uint8Array;
|
|
797
|
+
|
|
798
|
+
if (isPow2(oldSize)) {
|
|
799
|
+
fr = oldRoot;
|
|
800
|
+
sr = oldRoot;
|
|
801
|
+
} else {
|
|
802
|
+
if (pIdx >= proof.length) return false;
|
|
803
|
+
fr = proof[pIdx]!;
|
|
804
|
+
sr = proof[pIdx]!;
|
|
805
|
+
pIdx++;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
let fn = oldSize - 1;
|
|
809
|
+
let sn = newSize - 1;
|
|
810
|
+
|
|
811
|
+
while ((fn & 1) === 1) {
|
|
812
|
+
fn >>= 1;
|
|
813
|
+
sn >>= 1;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
// Drive the walk from the tree, not from the proof's length. Looping on
|
|
817
|
+
// `pIdx < proof.length` let the prover choose how many steps ran, so a log
|
|
818
|
+
// could understate its own size and present the genuine proof for the size it
|
|
819
|
+
// really had — STH{size: 6} carrying the root of 7 leaves verified against the
|
|
820
|
+
// real 7 -> 8 proof. The only consumer is a witness detecting exactly that
|
|
821
|
+
// equivocation. Length is part of the claim: too few elements runs out here,
|
|
822
|
+
// too many is caught by the pIdx check below.
|
|
823
|
+
while (sn !== 0) {
|
|
824
|
+
if (pIdx >= proof.length) return false;
|
|
825
|
+
const c = proof[pIdx]!;
|
|
826
|
+
pIdx++;
|
|
827
|
+
|
|
828
|
+
if ((fn & 1) === 1 || fn === sn) {
|
|
829
|
+
fr = await hashNode(crypto, c, fr);
|
|
830
|
+
sr = await hashNode(crypto, c, sr);
|
|
831
|
+
while (fn !== 0 && (fn & 1) === 0) {
|
|
832
|
+
fn >>= 1;
|
|
833
|
+
sn >>= 1;
|
|
834
|
+
}
|
|
835
|
+
} else {
|
|
836
|
+
sr = await hashNode(crypto, sr, c);
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
fn >>= 1;
|
|
840
|
+
sn >>= 1;
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
return pIdx === proof.length && bytesEqual(fr, oldRoot) && bytesEqual(sr, newRoot);
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
/**
|
|
848
|
+
* Port of Go's BuildCertificateStatusPayload.
|
|
849
|
+
*
|
|
850
|
+
* Optional fields are omitted, never emitted as null: the issuer omits them, so
|
|
851
|
+
* a verifier that emitted nulls would rebuild different bytes and reject a
|
|
852
|
+
* genuine statement.
|
|
853
|
+
*/
|
|
854
|
+
export function buildCertificateStatusPayload(
|
|
855
|
+
stmt: Record<string, unknown>,
|
|
856
|
+
): Uint8Array {
|
|
857
|
+
const payload: Record<string, unknown> = {
|
|
858
|
+
payload_type: PAYLOAD_TYPE_CERTIFICATE_STATUS,
|
|
859
|
+
certificate_id: stmt.certificate_id as string,
|
|
860
|
+
statement_expires_at: formatTimestamp(stmt.statement_expires_at),
|
|
861
|
+
statement_issued_at: formatTimestamp(stmt.statement_issued_at),
|
|
862
|
+
status: stmt.status as string,
|
|
863
|
+
sth_root_hash: toHex(stmt.sth_root_hash as string),
|
|
864
|
+
sth_tree_size: stmt.sth_tree_size as number,
|
|
865
|
+
};
|
|
866
|
+
if (stmt.replacement_certificate_id != null) {
|
|
867
|
+
payload.replacement_certificate_id = stmt.replacement_certificate_id as string;
|
|
868
|
+
}
|
|
869
|
+
if (stmt.revocation_log_index != null) {
|
|
870
|
+
payload.revocation_log_index = stmt.revocation_log_index as number;
|
|
871
|
+
}
|
|
872
|
+
if (stmt.revoked_at != null) {
|
|
873
|
+
payload.revoked_at = formatTimestamp(stmt.revoked_at);
|
|
874
|
+
}
|
|
875
|
+
return canonicalJson(payload);
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
/** The verdict when a certificate verified but nothing said whether it was revoked. */
|
|
879
|
+
export const VALID_REVOCATION_UNKNOWN = "VALID_REVOCATION_UNKNOWN";
|
|
880
|
+
|
|
881
|
+
/**
|
|
882
|
+
* Verify a certificate and, separately, what a statement says about its
|
|
883
|
+
* revocation.
|
|
884
|
+
*
|
|
885
|
+
* A signature commits to bytes at an instant; revocation is discovered later,
|
|
886
|
+
* so no edit to the signed certificate can express it. The statement is a
|
|
887
|
+
* separate short-lived document — fetch it from
|
|
888
|
+
* `GET /v1/certificates/{id}/status`, or read `status_statement` from the
|
|
889
|
+
* certificate response, where it is stapled for exactly this purpose.
|
|
890
|
+
*
|
|
891
|
+
* | input | result |
|
|
892
|
+
* |---|---|
|
|
893
|
+
* | fresh statement, REVOKED | throws VerificationError |
|
|
894
|
+
* | fresh statement, ACTIVE | `"VALID"` |
|
|
895
|
+
* | absent or expired statement | `"VALID_REVOCATION_UNKNOWN"` |
|
|
896
|
+
*
|
|
897
|
+
* The last row is the point: `verifyCertificate` answers VALID there, which
|
|
898
|
+
* reads as "not revoked" and is not something it checked.
|
|
899
|
+
*/
|
|
900
|
+
export async function verifyCertificateWithStatus(
|
|
901
|
+
crypto: CryptoOps,
|
|
902
|
+
certificate: Cert,
|
|
903
|
+
publicKeys: Map<string, PublicKeyInfo>,
|
|
904
|
+
status?: Record<string, unknown> | null,
|
|
905
|
+
now?: Date,
|
|
906
|
+
): Promise<string> {
|
|
907
|
+
const base = await verifyCertificate(crypto, certificate, publicKeys);
|
|
908
|
+
if (base !== "VALID") {
|
|
909
|
+
return base as string;
|
|
910
|
+
}
|
|
911
|
+
if (status == null) {
|
|
912
|
+
return VALID_REVOCATION_UNKNOWN;
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
const keyId = status.key_id as string | undefined;
|
|
916
|
+
const info = keyId ? publicKeys.get(keyId) : undefined;
|
|
917
|
+
if (!info) {
|
|
918
|
+
throw new VerificationError(`status statement signed by unknown key: ${keyId}`);
|
|
919
|
+
}
|
|
920
|
+
if (info.revoked) {
|
|
921
|
+
throw new VerificationError("status statement signed by a revoked key");
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
const payload = buildCertificateStatusPayload(status);
|
|
925
|
+
// decodeSignature, not hexToBytes: signatures arrive as hex, base64 or a byte
|
|
926
|
+
// array depending on the producer, and every other signature on this path
|
|
927
|
+
// goes through the same decoder.
|
|
928
|
+
const sig = decodeSignature(status.signature);
|
|
929
|
+
if (!(await crypto.ed25519Verify(info.keyBytes, payload, sig))) {
|
|
930
|
+
throw new VerificationError("status statement signature is invalid");
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
// A validly signed statement about a different certificate must not be
|
|
934
|
+
// allowed to speak for this one.
|
|
935
|
+
const certId = (certificate as Record<string, unknown>).id ??
|
|
936
|
+
(certificate as Record<string, unknown>).certificate_id;
|
|
937
|
+
if (status.certificate_id !== certId) {
|
|
938
|
+
throw new VerificationError("status statement is about a different certificate");
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
// Compared in the normalized form the payload signs, so the freshness check
|
|
942
|
+
// cannot disagree with what was signed about the same two instants.
|
|
943
|
+
const at = formatTimestamp((now ?? new Date()).toISOString());
|
|
944
|
+
const issued = formatTimestamp(status.statement_issued_at);
|
|
945
|
+
const expires = formatTimestamp(status.statement_expires_at);
|
|
946
|
+
if (at < issued || at >= expires) {
|
|
947
|
+
// Stale is not a weaker answer, it is no answer — including for a REVOKED
|
|
948
|
+
// statement, which must never decay into VALID.
|
|
949
|
+
return VALID_REVOCATION_UNKNOWN;
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
if (status.status === "REVOKED") {
|
|
953
|
+
throw new VerificationError("certificate has been revoked");
|
|
954
|
+
}
|
|
955
|
+
return "VALID";
|
|
956
|
+
}
|