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.
Files changed (61) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1 -1
  3. package/dist/cjs/index.browser.d.ts +2 -1
  4. package/dist/cjs/index.browser.d.ts.map +1 -1
  5. package/dist/cjs/index.browser.js +7 -1
  6. package/dist/cjs/index.browser.js.map +1 -1
  7. package/dist/cjs/index.d.ts +9 -1
  8. package/dist/cjs/index.d.ts.map +1 -1
  9. package/dist/cjs/index.js +17 -1
  10. package/dist/cjs/index.js.map +1 -1
  11. package/dist/cjs/models.d.ts +28 -1
  12. package/dist/cjs/models.d.ts.map +1 -1
  13. package/dist/cjs/models.js +8 -0
  14. package/dist/cjs/models.js.map +1 -1
  15. package/dist/cjs/verify.d.ts +55 -1
  16. package/dist/cjs/verify.d.ts.map +1 -1
  17. package/dist/cjs/verify.js +414 -104
  18. package/dist/cjs/verify.js.map +1 -1
  19. package/dist/cjs/web-verifier.d.ts +29 -0
  20. package/dist/cjs/web-verifier.d.ts.map +1 -0
  21. package/dist/cjs/web-verifier.js +70 -0
  22. package/dist/cjs/web-verifier.js.map +1 -0
  23. package/dist/esm/cli.d.ts.map +1 -1
  24. package/dist/esm/cli.js +12 -5
  25. package/dist/esm/cli.js.map +1 -1
  26. package/dist/esm/index.browser.d.ts +2 -1
  27. package/dist/esm/index.browser.d.ts.map +1 -1
  28. package/dist/esm/index.browser.js +3 -0
  29. package/dist/esm/index.browser.js.map +1 -1
  30. package/dist/esm/index.d.ts +9 -1
  31. package/dist/esm/index.d.ts.map +1 -1
  32. package/dist/esm/index.js +13 -1
  33. package/dist/esm/index.js.map +1 -1
  34. package/dist/esm/models.d.ts +28 -1
  35. package/dist/esm/models.d.ts.map +1 -1
  36. package/dist/esm/models.js +8 -0
  37. package/dist/esm/models.js.map +1 -1
  38. package/dist/esm/verify.d.ts +55 -1
  39. package/dist/esm/verify.d.ts.map +1 -1
  40. package/dist/esm/verify.js +411 -105
  41. package/dist/esm/verify.js.map +1 -1
  42. package/dist/esm/web-verifier.d.ts +29 -0
  43. package/dist/esm/web-verifier.d.ts.map +1 -0
  44. package/dist/esm/web-verifier.js +63 -0
  45. package/dist/esm/web-verifier.js.map +1 -0
  46. package/package.json +12 -11
  47. package/src/cli.ts +207 -0
  48. package/src/client.ts +555 -0
  49. package/src/crypto-browser.ts +49 -0
  50. package/src/crypto-node.ts +40 -0
  51. package/src/crypto.ts +10 -0
  52. package/src/errors.ts +154 -0
  53. package/src/http.ts +209 -0
  54. package/src/index.browser.ts +110 -0
  55. package/src/index.ts +134 -0
  56. package/src/keys.ts +18 -0
  57. package/src/models.ts +558 -0
  58. package/src/pagination.ts +64 -0
  59. package/src/verify.ts +956 -0
  60. package/src/web-verifier.ts +89 -0
  61. package/src/webhooks.ts +76 -0
@@ -9,7 +9,9 @@
9
9
  * - The payload builders normalize both formats to lowercase hex strings.
10
10
  * - Timestamps in payloads use second-precision UTC: "YYYY-MM-DDTHH:MM:SSZ".
11
11
  * - RFC 8785 canonical JSON: sorted keys recursively, no whitespace.
12
- * - Transparency leaf = SHA-256(0x00 || issuanceBytes(cert)), NOT the
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
13
15
  * certificate signing payload. issuanceBytes reconstructs json.Marshal
14
16
  * output at issuance time (transparency_status=PENDING, no transparency).
15
17
  */
@@ -20,10 +22,13 @@ import { VerificationError } from "./errors.js";
20
22
  const HEX_CHARS = "0123456789abcdef";
21
23
  // Signing-format v2 domain-separation tags (CR-H01). Must match the
22
24
  // source-of-truth const block in Go's core/payload.go byte-for-byte.
23
- const PAYLOAD_TYPE_ATTESTATION = "burnledger.attestation.v2";
24
- const PAYLOAD_TYPE_VERIFICATION = "burnledger.verification.v2";
25
- const PAYLOAD_TYPE_CERTIFICATE = "burnledger.certificate.v2";
26
- const PAYLOAD_TYPE_TREE_HEAD = "burnledger.sth.v2";
25
+ const PAYLOAD_TYPE_ATTESTATION = "burnledger.attestation.v3";
26
+ const PAYLOAD_TYPE_CERTIFICATE = "burnledger.certificate.v3";
27
+ const PAYLOAD_TYPE_TREE_HEAD = "burnledger.sth.v3";
28
+ const PAYLOAD_TYPE_LOG_LEAF = "burnledger.log_leaf.v3";
29
+ // There is deliberately no verification payload type: v3 produces those facts
30
+ // in the same enclave call that signs the certificate (ADR-016 §2).
31
+ const PAYLOAD_TYPE_CERTIFICATE_STATUS = "burnledger.certificate_status.v3";
27
32
  export function hexToBytes(hex) {
28
33
  const len = hex.length >>> 1;
29
34
  const out = new Uint8Array(len);
@@ -101,41 +106,75 @@ export async function verifyCertificate(crypto, certificate, publicKeys) {
101
106
  throw new VerificationError(`unknown issuer key: ${keyId}`);
102
107
  if (pki.revoked)
103
108
  throw new VerificationError(`issuer key is revoked: ${keyId}`);
104
- // 1. Attestation signature
109
+ // 1. Certificate signature FIRST — nothing below may trust a field until the
110
+ // bytes carrying it are covered by a verified signature.
111
+ const certPayload = buildCertificatePayload(certificate);
112
+ const certSig = decodeSignature(certificate.certificate_signature);
113
+ if (!(await crypto.ed25519Verify(pki.keyBytes, certPayload, certSig))) {
114
+ throw new VerificationError("certificate signature is invalid");
115
+ }
116
+ // 2. Attestation signature, rebuilt from the merged list. There is no
117
+ // verification signature: those facts are produced in the same enclave call
118
+ // that signs the certificate, so certificate_signature already covers them
119
+ // (ADR-016 §2).
105
120
  const att = certificate.attestation;
106
- const attPayload = buildAttestationPayload(certificate.subject, att);
121
+ const attPayload = buildAttestationPayload(certificate.subject, {
122
+ proof_mode: att.proof_mode,
123
+ attested_at: att.attested_at,
124
+ systems: attestationSystems(certificate),
125
+ });
107
126
  const attSig = decodeSignature(att.attestation_signature);
108
127
  if (!(await crypto.ed25519Verify(pki.keyBytes, attPayload, attSig))) {
109
128
  throw new VerificationError("attestation signature is invalid");
110
129
  }
111
- // 2. Verification signature
112
- const ver = certificate.verification;
113
- const verPayload = buildVerificationPayload(certificate.attestation_id, ver);
114
- const verSig = decodeSignature(ver.verification_signature);
115
- if (!(await crypto.ed25519Verify(pki.keyBytes, verPayload, verSig))) {
116
- throw new VerificationError("verification signature is invalid");
130
+ // 3. Structural rules on the system set.
131
+ //
132
+ // v2 carried two lists joined on the human-editable system_name, so "attested
133
+ // but not verified" was a representable state that had to be caught by a
134
+ // check — and for a long time was not (#451). v3 carries one list keyed by
135
+ // system_id, which makes that state unexpressible. What remains is
136
+ // structural, and mirrors core/verify.go step 4.
137
+ const systems = certificate.systems ?? [];
138
+ if (systems.length === 0) {
139
+ throw new VerificationError("certificate attests no systems, so it asserts nothing");
117
140
  }
118
- // 3. Certificate signature
119
- const certPayload = buildCertificatePayload(certificate);
120
- const certSig = decodeSignature(certificate.certificate_signature);
121
- if (!(await crypto.ed25519Verify(pki.keyBytes, certPayload, certSig))) {
122
- throw new VerificationError("certificate signature is invalid");
123
- }
124
- // 4. Now that every signature is verified (so these fields are trustworthy),
125
- // reject certificates that are validly signed but are NOT proof of deletion.
126
- // Mirrors core/verify.go steps 3 and 6 without these the SDK would accept
127
- // a revoked certificate, or one attesting data that is still present, as VALID.
128
- if (certificate.status === "REVOKED") {
129
- throw new VerificationError("certificate has been revoked");
141
+ const seen = new Set();
142
+ let anyAttested = false;
143
+ for (const s of systems) {
144
+ const systemId = s.system_id;
145
+ if (!systemId || systemId === NIL_UUID) {
146
+ throw new VerificationError("malformed systems: a system carries no system_id");
147
+ }
148
+ if (seen.has(systemId)) {
149
+ // Duplicate ids would let one verification stand in for several systems,
150
+ // which is the v2 hole in a new costume.
151
+ throw new VerificationError(`malformed systems: duplicate system_id ${systemId}`);
152
+ }
153
+ seen.add(systemId);
154
+ const attestedAt = parseRfc3339(s.attested_at);
155
+ const verifiedAt = parseRfc3339(s.verified_at);
156
+ if (verifiedAt < attestedAt) {
157
+ throw new VerificationError(`malformed systems: system ${systemId} was verified before it was attested`);
158
+ }
159
+ if (s.attested_count > 0)
160
+ anyAttested = true;
130
161
  }
131
- for (const s of ver.systems ?? []) {
132
- const recordCount = s.record_count;
133
- if (recordCount !== 0) {
134
- throw new VerificationError(`data still present: system "${s.system_name}" reports ${recordCount} record(s)`);
162
+ // 4. Nothing may remain anywhere.
163
+ for (const s of systems) {
164
+ const remaining = s.verified_count;
165
+ if (remaining !== 0) {
166
+ throw new VerificationError(`data still present: system "${s.system_name}" reports ${remaining} record(s)`);
135
167
  }
136
168
  }
169
+ // 5. At least one system must have held records at attest time — across the
170
+ // set, not per system. Every system reporting zero means the certificate
171
+ // documents the deletion of nothing.
172
+ if (!anyAttested) {
173
+ throw new VerificationError("incomplete verification: no system held any records at attestation time");
174
+ }
137
175
  return "VALID";
138
176
  }
177
+ const NIL_UUID = "00000000-0000-0000-0000-000000000000";
139
178
  /** Verify the transparency proof embedded in a certificate. */
140
179
  export async function verifyTransparency(crypto, certificate, publicKeys) {
141
180
  const transparency = certificate.transparency;
@@ -156,14 +195,36 @@ export async function verifyTransparency(crypto, certificate, publicKeys) {
156
195
  if (!(await crypto.ed25519Verify(pki.keyBytes, headPayload, headSig))) {
157
196
  throw new VerificationError("tree head signature is invalid");
158
197
  }
159
- // 2. Merkle inclusion proof — leaf is hash of issuance-time certificate JSON,
160
- // NOT the canonical signing payload. Matches Go's core.IssuanceBytes(cert).
198
+ // 2. Merkle inclusion proof.
199
+ //
200
+ // The leaf is NOT the certificate. It is the canonical log_leaf.v3 payload
201
+ // over entry_type, certificate_id, certificate_hash and appended_at, where
202
+ // certificate_hash is SHA-256 of the issuance-time certificate JSON
203
+ // (ADR-016 §4). v2 hashed the certificate directly, so an issuance and a
204
+ // revocation of the same certificate produced identical leaves and the tree
205
+ // committed to neither the entry type nor when it happened.
161
206
  const issuanceData = issuanceBytes(certificate);
162
- const leaf = await hashLeaf(crypto, issuanceData);
207
+ const leafPayload = buildLogLeafPayload(transparency.entry_type, certificate.certificate_id, await crypto.sha256(issuanceData), transparency.appended_at);
208
+ const leaf = await hashLeaf(crypto, leafPayload);
163
209
  const proofHashes = (transparency.inclusion_proof ?? []).map((h) => decodeBytes(h));
164
210
  const root = decodeBytes(sth.root_hash);
165
- const index = transparency.entry_index;
166
- const treeSize = sth.tree_size;
211
+ // `?? 0` is not a convenience: encoding/json leaves 0 in Go's uint64 fields
212
+ // for an explicit null and for an absent key rather than failing, so refusing
213
+ // either would reject documents the reference accepts — the same class of
214
+ // divergence as #452. An offline verifier is only useful while it agrees.
215
+ const index = requireUint(transparency.entry_index ?? 0, "entry_index");
216
+ const treeSize = requireUint(sth.tree_size ?? 0, "signed_tree_head.tree_size");
217
+ // transparency.tree_size duplicates the signed one but carries no signature:
218
+ // BuildTreeHeadPayload covers only the copy inside signed_tree_head. Go used
219
+ // to verify against the unsigned copy while this SDK used the signed one, so
220
+ // the same document got two verdicts (#456). Both now require the two to agree
221
+ // and then verify against the signed copy — the server always writes them
222
+ // equal, so this rejects only edited documents.
223
+ const unsignedSize = requireUint(transparency.tree_size ?? 0, "transparency.tree_size");
224
+ if (unsignedSize !== treeSize) {
225
+ throw new VerificationError(`transparency.tree_size (${unsignedSize}) does not match the signed tree head ` +
226
+ `(${treeSize}); it is not covered by any signature`);
227
+ }
167
228
  if (!(await verifyInclusion(crypto, leaf, index, treeSize, proofHashes, root))) {
168
229
  throw new VerificationError("merkle inclusion proof is invalid");
169
230
  }
@@ -256,29 +317,142 @@ function decodeBytes(value) {
256
317
  }
257
318
  return base64ToBytes(str);
258
319
  }
259
- function formatTimestamp(ts) {
260
- // Strip fractional seconds, normalize to YYYY-MM-DDTHH:MM:SSZ
261
- let s = ts;
262
- const dotIdx = s.indexOf(".");
263
- if (dotIdx !== -1) {
264
- // Find the end of fractional seconds (Z, +, or end)
265
- let endIdx = s.length;
266
- for (let i = dotIdx + 1; i < s.length; i++) {
267
- if (s[i] === "Z" || s[i] === "+" || s[i] === "-") {
268
- endIdx = i;
269
- break;
270
- }
271
- }
272
- s = s.slice(0, dotIdx) + s.slice(endIdx);
320
+ /** Go's zero time.Time — what encoding/json leaves in a non-pointer time.Time
321
+ * field for an explicit JSON null or an absent key. */
322
+ const ZERO_INSTANT = "0001-01-01T00:00:00Z";
323
+ /** The grammar encoding/json accepts for time.Time: strict RFC 3339, uppercase
324
+ * "T" and "Z" only, fixed field widths, at least one fractional digit if a "."
325
+ * is present. Anything else makes Go fail to parse the certificate at all. */
326
+ const RFC3339_RE = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?:Z|([+-])(\d{2}):(\d{2}))$/;
327
+ /** Normalize a certificate timestamp to the UTC form Go signs.
328
+ *
329
+ * Go builds every payload timestamp as `t.UTC().Format("2006-01-02T15:04:05Z")`
330
+ * (core/payload.go) on a value encoding/json already parsed as strict RFC 3339.
331
+ * This reproduces both halves — the same acceptance rules and the same
332
+ * conversion — because the two are one contract, not two.
333
+ *
334
+ * It must convert, never truncate. `12:00:00+05:00` and `12:00:00Z` are six
335
+ * hours apart; treating them as the same string made this verifier accept a
336
+ * certificate whose attestation window had been re-labelled to a different
337
+ * instant, and reject genuine certificates issued by a server on local time
338
+ * (#452). Input Go would refuse to parse is refused here rather than
339
+ * string-surgered into something that verifies.
340
+ *
341
+ * `null`/`undefined` — an explicit JSON null, or a key Go's struct has and the
342
+ * document does not — is Go's zero time, not an error.
343
+ *
344
+ * Exported from this module so the timestamp_vectors.json suite can diff it
345
+ * against Go directly. It is deliberately not re-exported from index.ts, so it
346
+ * is not part of the published package surface.
347
+ */
348
+ export function formatTimestamp(ts) {
349
+ if (ts === null || ts === undefined)
350
+ return ZERO_INSTANT;
351
+ if (typeof ts !== "string") {
352
+ throw new VerificationError(`timestamp is not a string: ${JSON.stringify(ts)}`);
273
353
  }
274
- if (s.endsWith("Z"))
275
- return s;
276
- // Strip timezone offset
277
- const plusIdx = s.indexOf("+");
278
- if (plusIdx !== -1) {
279
- s = s.slice(0, plusIdx);
354
+ return formatEpochSeconds(parseRfc3339(ts));
355
+ }
356
+ /** Parse a strict RFC 3339 timestamp to seconds since 1970-01-01T00:00:00Z.
357
+ *
358
+ * Rejects exactly what Go's encoding/json rejects, including the ranges its
359
+ * parser enforces once the shape matches. Those ranges were measured against
360
+ * Go 1.26, not inferred from RFC 3339, and they are not the obvious ones: the
361
+ * time of day is 23:59:59 at most, but a zone offset runs to +24:60 — both
362
+ * `+24:00` and `+00:60` parse, while `+25:00` and `+00:61` do not. Sub-second
363
+ * digits are dropped, matching Format's truncation.
364
+ */
365
+ function parseRfc3339(ts) {
366
+ const m = RFC3339_RE.exec(ts);
367
+ if (m === null)
368
+ throw new VerificationError(`timestamp is not RFC 3339: ${ts}`);
369
+ const [year, month, day, hour, minute, second] = m
370
+ .slice(1, 7)
371
+ .map((v) => Number.parseInt(v, 10));
372
+ if (month < 1 || month > 12)
373
+ throw new VerificationError(`timestamp month out of range: ${ts}`);
374
+ if (day < 1 || day > daysInMonth(year, month)) {
375
+ throw new VerificationError(`timestamp day out of range: ${ts}`);
376
+ }
377
+ if (hour > 23 || minute > 59 || second > 59) {
378
+ throw new VerificationError(`timestamp time of day out of range: ${ts}`);
280
379
  }
281
- return s + "Z";
380
+ let offset = 0;
381
+ const sign = m[7];
382
+ if (sign !== undefined) {
383
+ const offHour = Number.parseInt(m[8], 10);
384
+ const offMin = Number.parseInt(m[9], 10);
385
+ if (offHour > 24)
386
+ throw new VerificationError(`timestamp zone offset hour out of range: ${ts}`);
387
+ if (offMin > 60)
388
+ throw new VerificationError(`timestamp zone offset minute out of range: ${ts}`);
389
+ offset = (offHour * 3600 + offMin * 60) * (sign === "-" ? -1 : 1);
390
+ }
391
+ return daysFromCivil(year, month, day) * 86400 + hour * 3600 + minute * 60 + second - offset;
392
+ }
393
+ /** Render seconds since the Unix epoch as `YYYY-MM-DDTHH:MM:SSZ`.
394
+ *
395
+ * Matches Go's Format for the reachable extremes: a year 0000 timestamp with a
396
+ * `+24:00` offset lands in year -1, which Go prints as `-0001`, and 9999-12-31
397
+ * with `-24:00` lands in year 10000, which Go prints unpadded.
398
+ */
399
+ function formatEpochSeconds(total) {
400
+ const days = Math.floor(total / 86400);
401
+ const secs = total - days * 86400;
402
+ const [year, month, day] = civilFromDays(days);
403
+ const hour = Math.floor(secs / 3600);
404
+ const minute = Math.floor((secs - hour * 3600) / 60);
405
+ const second = secs - hour * 3600 - minute * 60;
406
+ const printedYear = year < 0 ? "-" + pad(-year, 4) : pad(year, 4);
407
+ return `${printedYear}-${pad(month, 2)}-${pad(day, 2)}T${pad(hour, 2)}:${pad(minute, 2)}:${pad(second, 2)}Z`;
408
+ }
409
+ function pad(value, width) {
410
+ return String(value).padStart(width, "0");
411
+ }
412
+ /** Length of a proleptic Gregorian month. Year 0 is a leap year; 1900 is not. */
413
+ function daysInMonth(year, month) {
414
+ if (month === 2) {
415
+ const isLeap = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
416
+ return isLeap ? 29 : 28;
417
+ }
418
+ return month === 4 || month === 6 || month === 9 || month === 11 ? 30 : 31;
419
+ }
420
+ /** Days from 1970-01-01 to a proleptic Gregorian date (Howard Hinnant's algorithm).
421
+ *
422
+ * Deliberately not `Date`: Go accepts year 0000, and a zone offset can push the
423
+ * UTC instant to year -1 or 10000, where `Date`'s parsing and formatting stop
424
+ * agreeing with Go. A verifier that disagrees with the reference on any input is
425
+ * a verifier a relying party cannot use to second-guess the reference.
426
+ */
427
+ function daysFromCivil(year, month, day) {
428
+ const y = month <= 2 ? year - 1 : year;
429
+ const era = Math.floor(y / 400);
430
+ const yearOfEra = y - era * 400; // [0, 399]
431
+ const shiftedMonth = month > 2 ? month - 3 : month + 9; // [0, 11]
432
+ const dayOfYear = Math.floor((153 * shiftedMonth + 2) / 5) + day - 1; // [0, 365]
433
+ const dayOfEra = yearOfEra * 365 +
434
+ Math.floor(yearOfEra / 4) -
435
+ Math.floor(yearOfEra / 100) +
436
+ dayOfYear;
437
+ return era * 146097 + dayOfEra - 719468;
438
+ }
439
+ /** Inverse of daysFromCivil. */
440
+ function civilFromDays(days) {
441
+ const z = days + 719468;
442
+ const era = Math.floor(z / 146097);
443
+ const dayOfEra = z - era * 146097; // [0, 146096]
444
+ const yearOfEra = Math.floor((dayOfEra -
445
+ Math.floor(dayOfEra / 1460) +
446
+ Math.floor(dayOfEra / 36524) -
447
+ Math.floor(dayOfEra / 146096)) /
448
+ 365); // [0, 399]
449
+ const dayOfYear = dayOfEra -
450
+ (365 * yearOfEra + Math.floor(yearOfEra / 4) - Math.floor(yearOfEra / 100)); // [0, 365]
451
+ const shiftedMonth = Math.floor((5 * dayOfYear + 2) / 153); // [0, 11]
452
+ const day = dayOfYear - Math.floor((153 * shiftedMonth + 2) / 5) + 1;
453
+ const month = shiftedMonth < 10 ? shiftedMonth + 3 : shiftedMonth - 9;
454
+ const year = yearOfEra + era * 400 + (month <= 2 ? 1 : 0);
455
+ return [year, month, day];
282
456
  }
283
457
  // ---------------------------------------------------------------------------
284
458
  // Payload builders — exact ports of core/payload.go
@@ -292,8 +466,11 @@ function buildAttestationPayload(subject, att) {
292
466
  merkle_root: s.merkle_root
293
467
  ? toHex(s.merkle_root)
294
468
  : null,
295
- observed_at: attestedAt,
469
+ // The system's own observation time, not the envelope's.
470
+ observed_at: formatTimestamp(s.observed_at),
471
+ query_hash: toHex(s.query_hash),
296
472
  record_count: s.record_count,
473
+ system_id: s.system_id,
297
474
  system_name: s.system_name,
298
475
  }));
299
476
  const payload = {
@@ -305,53 +482,32 @@ function buildAttestationPayload(subject, att) {
305
482
  };
306
483
  return canonicalJson(payload);
307
484
  }
308
- function buildVerificationPayload(attestationId, ver) {
309
- const verifiedAt = formatTimestamp(ver.verified_at);
310
- const systems = ver.systems.map((s) => ({
311
- observed_at: verifiedAt,
312
- record_count: s.record_count,
313
- system_name: s.system_name,
314
- }));
315
- const payload = {
316
- attestation_id: attestationId,
317
- payload_type: PAYLOAD_TYPE_VERIFICATION,
318
- systems,
319
- verified_at: verifiedAt,
320
- };
321
- return canonicalJson(payload);
322
- }
323
485
  function buildCertificatePayload(cert) {
324
486
  const att = cert.attestation;
325
- const ver = cert.verification;
326
487
  const issuer = cert.issuer;
327
488
  const subject = cert.subject;
328
- // Attestation systems uses "system_name" field name
329
- const attSystems = att.systems.map((s) => ({
489
+ // One list, keyed by system_id (ADR-016 §2). v2 signed an attestation list
490
+ // and a verification list joined only on the human-editable system_name,
491
+ // which made a partial deletion indistinguishable from a complete one.
492
+ const systems = (cert.systems ?? []).map((s) => ({
493
+ attested_at: formatTimestamp(s.attested_at),
494
+ attested_count: s.attested_count,
330
495
  canonical_version: s.canonical_version ?? null,
331
496
  connector_type: s.connector_type,
332
497
  hash_scope: s.hash_scope,
333
- merkle_root: s.merkle_root
334
- ? toHex(s.merkle_root)
335
- : null,
336
- record_count: s.record_count,
498
+ merkle_root: s.merkle_root ? toHex(s.merkle_root) : null,
499
+ query_hash: toHex(s.query_hash),
500
+ system_id: s.system_id,
337
501
  system_name: s.system_name,
502
+ verified_at: formatTimestamp(s.verified_at),
503
+ verified_count: s.verified_count,
338
504
  }));
505
+ // The attestation block carries no system list of its own: the merged list
506
+ // above is a superset of it. verification_signature is gone entirely.
339
507
  const attObj = {
340
508
  attestation_signature: toHex(att.attestation_signature),
341
509
  attested_at: formatTimestamp(att.attested_at),
342
510
  proof_mode: att.proof_mode,
343
- systems: attSystems,
344
- };
345
- // Verification systems
346
- const verSystems = ver.systems.map((s) => ({
347
- connector_type: s.connector_type,
348
- record_count: s.record_count,
349
- system_name: s.system_name,
350
- }));
351
- const verObj = {
352
- systems: verSystems,
353
- verification_signature: toHex(ver.verification_signature),
354
- verified_at: formatTimestamp(ver.verified_at),
355
511
  };
356
512
  const issuerObj = {
357
513
  key_id: issuer.key_id,
@@ -362,16 +518,9 @@ function buildCertificatePayload(cert) {
362
518
  identifier_hash: toHex(subject.identifier_hash),
363
519
  identifier_type_hint: subject.identifier_type_hint,
364
520
  };
365
- // Revocation
366
- let revocation = null;
367
- const rev = cert.revocation;
368
- if (rev != null) {
369
- revocation = {
370
- reason: rev.reason,
371
- replacement_certificate_id: rev.replacement_certificate_id ?? null,
372
- revoked_at: formatTimestamp(rev.revoked_at),
373
- };
374
- }
521
+ // status and revocation are deliberately absent (ADR-016 §3): a signature
522
+ // commits to bytes at an instant, revocation is discovered later, so it
523
+ // travels as a separate short-lived signed status statement.
375
524
  const payload = {
376
525
  attestation: attObj,
377
526
  attestation_id: cert.attestation_id,
@@ -380,13 +529,43 @@ function buildCertificatePayload(cert) {
380
529
  issued_at: formatTimestamp(cert.issued_at),
381
530
  issuer: issuerObj,
382
531
  payload_type: PAYLOAD_TYPE_CERTIFICATE,
383
- revocation,
384
- status: cert.status,
385
532
  subject: subjectObj,
386
- verification: verObj,
533
+ systems,
387
534
  };
388
535
  return canonicalJson(payload);
389
536
  }
537
+ /** Port of Go's BuildLogLeafPayload (ADR-016 §4). */
538
+ function buildLogLeafPayload(entryType, certificateId, certificateHash, appendedAt) {
539
+ if (entryType !== "CERTIFICATE" && entryType !== "REVOCATION") {
540
+ throw new VerificationError(`invalid log entry_type: ${String(entryType)}`);
541
+ }
542
+ const payload = {
543
+ appended_at: formatTimestamp(appendedAt),
544
+ certificate_hash: bytesToHex(certificateHash),
545
+ certificate_id: certificateId,
546
+ entry_type: entryType,
547
+ payload_type: PAYLOAD_TYPE_LOG_LEAF,
548
+ };
549
+ return canonicalJson(payload);
550
+ }
551
+ /**
552
+ * Project the merged system list back onto the attest-time shape, so the
553
+ * attestation signature — produced days before the certificate existed — can be
554
+ * checked. Mirrors DeletionCertificate.AttestationSystems() in Go.
555
+ */
556
+ function attestationSystems(cert) {
557
+ return (cert.systems ?? []).map((s) => ({
558
+ system_id: s.system_id,
559
+ system_name: s.system_name,
560
+ connector_type: s.connector_type,
561
+ hash_scope: s.hash_scope,
562
+ query_hash: s.query_hash,
563
+ record_count: s.attested_count,
564
+ observed_at: s.attested_at,
565
+ merkle_root: s.merkle_root,
566
+ canonical_version: s.canonical_version,
567
+ }));
568
+ }
390
569
  function buildTreeHeadPayload(head) {
391
570
  const payload = {
392
571
  payload_type: PAYLOAD_TYPE_TREE_HEAD,
@@ -406,6 +585,29 @@ async function hashNode(crypto, left, right) {
406
585
  const prefix = new Uint8Array([0x01]);
407
586
  return crypto.sha256(concatBytes(prefix, concatBytes(left, right)));
408
587
  }
588
+ /** Read a tree index or size the way Go's uint64 unmarshalling does.
589
+ *
590
+ * Go's struct fields are uint64, so a negative or fractional JSON number makes
591
+ * the whole certificate fail to parse, and the `index >= size` guard in
592
+ * verifyInclusion never sees one. This SDK gets a JavaScript number, where that
593
+ * guard passes a negative index straight through and any value past 2^53 has
594
+ * already lost the precision the Merkle arithmetic depends on (#456). Both are
595
+ * rejected here, at the document boundary, rather than trusted downstream.
596
+ */
597
+ function requireUint(value, field) {
598
+ if (typeof value !== "number" || !Number.isSafeInteger(value)) {
599
+ throw new VerificationError(`${field} is not an exact non-negative integer: ${JSON.stringify(value)}`);
600
+ }
601
+ if (value < 0)
602
+ throw new VerificationError(`${field} is negative: ${value}`);
603
+ return value;
604
+ }
605
+ /** Largest power of 2 strictly less than n. Port of Go's splitPoint.
606
+ *
607
+ * The loop terminates because requireUint has already bounded n to a safe
608
+ * integer; Go, which has no such bound, computes the same value with
609
+ * `1 << (bits.Len64(n-1) - 1)` because its loop form overflowed (#456).
610
+ */
409
611
  function splitPoint(n) {
410
612
  let k = 1;
411
613
  while (k * 2 < n) {
@@ -469,7 +671,16 @@ export async function verifyConsistency(crypto, oldSize, newSize, oldRoot, newRo
469
671
  fn >>= 1;
470
672
  sn >>= 1;
471
673
  }
472
- while (pIdx < proof.length) {
674
+ // Drive the walk from the tree, not from the proof's length. Looping on
675
+ // `pIdx < proof.length` let the prover choose how many steps ran, so a log
676
+ // could understate its own size and present the genuine proof for the size it
677
+ // really had — STH{size: 6} carrying the root of 7 leaves verified against the
678
+ // real 7 -> 8 proof. The only consumer is a witness detecting exactly that
679
+ // equivocation. Length is part of the claim: too few elements runs out here,
680
+ // too many is caught by the pIdx check below.
681
+ while (sn !== 0) {
682
+ if (pIdx >= proof.length)
683
+ return false;
473
684
  const c = proof[pIdx];
474
685
  pIdx++;
475
686
  if ((fn & 1) === 1 || fn === sn) {
@@ -486,6 +697,101 @@ export async function verifyConsistency(crypto, oldSize, newSize, oldRoot, newRo
486
697
  fn >>= 1;
487
698
  sn >>= 1;
488
699
  }
489
- return sn === 0 && bytesEqual(fr, oldRoot) && bytesEqual(sr, newRoot);
700
+ return pIdx === proof.length && bytesEqual(fr, oldRoot) && bytesEqual(sr, newRoot);
701
+ }
702
+ /**
703
+ * Port of Go's BuildCertificateStatusPayload.
704
+ *
705
+ * Optional fields are omitted, never emitted as null: the issuer omits them, so
706
+ * a verifier that emitted nulls would rebuild different bytes and reject a
707
+ * genuine statement.
708
+ */
709
+ export function buildCertificateStatusPayload(stmt) {
710
+ const payload = {
711
+ payload_type: PAYLOAD_TYPE_CERTIFICATE_STATUS,
712
+ certificate_id: stmt.certificate_id,
713
+ statement_expires_at: formatTimestamp(stmt.statement_expires_at),
714
+ statement_issued_at: formatTimestamp(stmt.statement_issued_at),
715
+ status: stmt.status,
716
+ sth_root_hash: toHex(stmt.sth_root_hash),
717
+ sth_tree_size: stmt.sth_tree_size,
718
+ };
719
+ if (stmt.replacement_certificate_id != null) {
720
+ payload.replacement_certificate_id = stmt.replacement_certificate_id;
721
+ }
722
+ if (stmt.revocation_log_index != null) {
723
+ payload.revocation_log_index = stmt.revocation_log_index;
724
+ }
725
+ if (stmt.revoked_at != null) {
726
+ payload.revoked_at = formatTimestamp(stmt.revoked_at);
727
+ }
728
+ return canonicalJson(payload);
729
+ }
730
+ /** The verdict when a certificate verified but nothing said whether it was revoked. */
731
+ export const VALID_REVOCATION_UNKNOWN = "VALID_REVOCATION_UNKNOWN";
732
+ /**
733
+ * Verify a certificate and, separately, what a statement says about its
734
+ * revocation.
735
+ *
736
+ * A signature commits to bytes at an instant; revocation is discovered later,
737
+ * so no edit to the signed certificate can express it. The statement is a
738
+ * separate short-lived document — fetch it from
739
+ * `GET /v1/certificates/{id}/status`, or read `status_statement` from the
740
+ * certificate response, where it is stapled for exactly this purpose.
741
+ *
742
+ * | input | result |
743
+ * |---|---|
744
+ * | fresh statement, REVOKED | throws VerificationError |
745
+ * | fresh statement, ACTIVE | `"VALID"` |
746
+ * | absent or expired statement | `"VALID_REVOCATION_UNKNOWN"` |
747
+ *
748
+ * The last row is the point: `verifyCertificate` answers VALID there, which
749
+ * reads as "not revoked" and is not something it checked.
750
+ */
751
+ export async function verifyCertificateWithStatus(crypto, certificate, publicKeys, status, now) {
752
+ const base = await verifyCertificate(crypto, certificate, publicKeys);
753
+ if (base !== "VALID") {
754
+ return base;
755
+ }
756
+ if (status == null) {
757
+ return VALID_REVOCATION_UNKNOWN;
758
+ }
759
+ const keyId = status.key_id;
760
+ const info = keyId ? publicKeys.get(keyId) : undefined;
761
+ if (!info) {
762
+ throw new VerificationError(`status statement signed by unknown key: ${keyId}`);
763
+ }
764
+ if (info.revoked) {
765
+ throw new VerificationError("status statement signed by a revoked key");
766
+ }
767
+ const payload = buildCertificateStatusPayload(status);
768
+ // decodeSignature, not hexToBytes: signatures arrive as hex, base64 or a byte
769
+ // array depending on the producer, and every other signature on this path
770
+ // goes through the same decoder.
771
+ const sig = decodeSignature(status.signature);
772
+ if (!(await crypto.ed25519Verify(info.keyBytes, payload, sig))) {
773
+ throw new VerificationError("status statement signature is invalid");
774
+ }
775
+ // A validly signed statement about a different certificate must not be
776
+ // allowed to speak for this one.
777
+ const certId = certificate.id ??
778
+ certificate.certificate_id;
779
+ if (status.certificate_id !== certId) {
780
+ throw new VerificationError("status statement is about a different certificate");
781
+ }
782
+ // Compared in the normalized form the payload signs, so the freshness check
783
+ // cannot disagree with what was signed about the same two instants.
784
+ const at = formatTimestamp((now ?? new Date()).toISOString());
785
+ const issued = formatTimestamp(status.statement_issued_at);
786
+ const expires = formatTimestamp(status.statement_expires_at);
787
+ if (at < issued || at >= expires) {
788
+ // Stale is not a weaker answer, it is no answer — including for a REVOKED
789
+ // statement, which must never decay into VALID.
790
+ return VALID_REVOCATION_UNKNOWN;
791
+ }
792
+ if (status.status === "REVOKED") {
793
+ throw new VerificationError("certificate has been revoked");
794
+ }
795
+ return "VALID";
490
796
  }
491
797
  //# sourceMappingURL=verify.js.map