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/dist/esm/verify.js
CHANGED
|
@@ -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 ||
|
|
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.
|
|
24
|
-
const
|
|
25
|
-
const
|
|
26
|
-
const
|
|
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.
|
|
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,
|
|
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
|
-
//
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
|
160
|
-
//
|
|
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
|
|
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
|
-
|
|
166
|
-
|
|
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
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
329
|
-
|
|
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
|
-
|
|
335
|
-
|
|
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
|
-
//
|
|
366
|
-
|
|
367
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|