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/cjs/verify.js
CHANGED
|
@@ -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 ||
|
|
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.
|
|
33
|
-
const
|
|
34
|
-
const
|
|
35
|
-
const
|
|
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.
|
|
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,
|
|
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
|
-
//
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
|
169
|
-
//
|
|
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
|
|
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
|
-
|
|
175
|
-
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
338
|
-
|
|
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
|
-
|
|
344
|
-
|
|
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
|
-
//
|
|
375
|
-
|
|
376
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|