burnledger 0.2.2 → 0.3.1

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