burnledger 0.8.0 → 0.9.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/README.md +139 -2
- package/dist/cjs/anchor.d.ts +5 -3
- package/dist/cjs/anchor.d.ts.map +1 -1
- package/dist/cjs/anchor.js +10 -4
- package/dist/cjs/anchor.js.map +1 -1
- package/dist/cjs/client.d.ts +95 -0
- package/dist/cjs/client.d.ts.map +1 -1
- package/dist/cjs/client.js +133 -2
- package/dist/cjs/client.js.map +1 -1
- package/dist/cjs/customer-keys.d.ts +146 -0
- package/dist/cjs/customer-keys.d.ts.map +1 -0
- package/dist/cjs/customer-keys.js +465 -0
- package/dist/cjs/customer-keys.js.map +1 -0
- package/dist/cjs/enclave-registration.d.ts +153 -0
- package/dist/cjs/enclave-registration.d.ts.map +1 -0
- package/dist/cjs/enclave-registration.js +275 -0
- package/dist/cjs/enclave-registration.js.map +1 -0
- package/dist/cjs/enclave-seal.d.ts +43 -0
- package/dist/cjs/enclave-seal.d.ts.map +1 -1
- package/dist/cjs/enclave-seal.js +62 -1
- package/dist/cjs/enclave-seal.js.map +1 -1
- package/dist/cjs/errors.d.ts +10 -0
- package/dist/cjs/errors.d.ts.map +1 -1
- package/dist/cjs/errors.js +11 -1
- package/dist/cjs/errors.js.map +1 -1
- package/dist/cjs/index.browser.d.ts +7 -3
- package/dist/cjs/index.browser.d.ts.map +1 -1
- package/dist/cjs/index.browser.js +6 -3
- package/dist/cjs/index.browser.js.map +1 -1
- package/dist/cjs/index.d.ts +23 -9
- package/dist/cjs/index.d.ts.map +1 -1
- package/dist/cjs/index.js +40 -6
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/key-group.d.ts +80 -0
- package/dist/cjs/key-group.d.ts.map +1 -0
- package/dist/cjs/key-group.js +136 -0
- package/dist/cjs/key-group.js.map +1 -0
- package/dist/cjs/models.d.ts +60 -0
- package/dist/cjs/models.d.ts.map +1 -1
- package/dist/cjs/models.js +63 -0
- package/dist/cjs/models.js.map +1 -1
- package/dist/cjs/status-document.d.ts +25 -0
- package/dist/cjs/status-document.d.ts.map +1 -0
- package/dist/cjs/status-document.js +62 -0
- package/dist/cjs/status-document.js.map +1 -0
- package/dist/cjs/verify.d.ts +112 -3
- package/dist/cjs/verify.d.ts.map +1 -1
- package/dist/cjs/verify.js +393 -146
- package/dist/cjs/verify.js.map +1 -1
- package/dist/cjs/web-verifier.d.ts +23 -3
- package/dist/cjs/web-verifier.d.ts.map +1 -1
- package/dist/cjs/web-verifier.js +31 -5
- package/dist/cjs/web-verifier.js.map +1 -1
- package/dist/esm/anchor.d.ts +5 -3
- package/dist/esm/anchor.d.ts.map +1 -1
- package/dist/esm/anchor.js +10 -4
- package/dist/esm/anchor.js.map +1 -1
- package/dist/esm/cli.d.ts +13 -19
- package/dist/esm/cli.d.ts.map +1 -1
- package/dist/esm/cli.js +109 -81
- package/dist/esm/cli.js.map +1 -1
- package/dist/esm/client.d.ts +95 -0
- package/dist/esm/client.d.ts.map +1 -1
- package/dist/esm/client.js +133 -2
- package/dist/esm/client.js.map +1 -1
- package/dist/esm/customer-keys.d.ts +146 -0
- package/dist/esm/customer-keys.d.ts.map +1 -0
- package/dist/esm/customer-keys.js +450 -0
- package/dist/esm/customer-keys.js.map +1 -0
- package/dist/esm/enclave-registration.d.ts +153 -0
- package/dist/esm/enclave-registration.d.ts.map +1 -0
- package/dist/esm/enclave-registration.js +265 -0
- package/dist/esm/enclave-registration.js.map +1 -0
- package/dist/esm/enclave-seal.d.ts +43 -0
- package/dist/esm/enclave-seal.d.ts.map +1 -1
- package/dist/esm/enclave-seal.js +61 -1
- package/dist/esm/enclave-seal.js.map +1 -1
- package/dist/esm/errors.d.ts +10 -0
- package/dist/esm/errors.d.ts.map +1 -1
- package/dist/esm/errors.js +10 -0
- package/dist/esm/errors.js.map +1 -1
- package/dist/esm/index.browser.d.ts +7 -3
- package/dist/esm/index.browser.d.ts.map +1 -1
- package/dist/esm/index.browser.js +6 -3
- package/dist/esm/index.browser.js.map +1 -1
- package/dist/esm/index.d.ts +23 -9
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +18 -7
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/key-group.d.ts +80 -0
- package/dist/esm/key-group.d.ts.map +1 -0
- package/dist/esm/key-group.js +130 -0
- package/dist/esm/key-group.js.map +1 -0
- package/dist/esm/models.d.ts +60 -0
- package/dist/esm/models.d.ts.map +1 -1
- package/dist/esm/models.js +59 -0
- package/dist/esm/models.js.map +1 -1
- package/dist/esm/status-document.d.ts +25 -0
- package/dist/esm/status-document.d.ts.map +1 -0
- package/dist/esm/status-document.js +59 -0
- package/dist/esm/status-document.js.map +1 -0
- package/dist/esm/verify.d.ts +112 -3
- package/dist/esm/verify.d.ts.map +1 -1
- package/dist/esm/verify.js +390 -147
- package/dist/esm/verify.js.map +1 -1
- package/dist/esm/web-verifier.d.ts +23 -3
- package/dist/esm/web-verifier.d.ts.map +1 -1
- package/dist/esm/web-verifier.js +27 -5
- package/dist/esm/web-verifier.js.map +1 -1
- package/package.json +1 -1
- package/src/anchor.ts +10 -4
- package/src/cli.ts +122 -79
- package/src/client.ts +198 -6
- package/src/customer-keys.ts +550 -0
- package/src/enclave-registration.ts +390 -0
- package/src/enclave-seal.ts +108 -1
- package/src/errors.ts +11 -0
- package/src/index.browser.ts +9 -3
- package/src/index.ts +48 -6
- package/src/key-group.ts +181 -0
- package/src/models.ts +131 -0
- package/src/status-document.ts +59 -0
- package/src/verify.ts +503 -152
- package/src/web-verifier.ts +36 -3
package/dist/esm/verify.js
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* certificate signing payload. issuanceBytes reconstructs json.Marshal
|
|
16
16
|
* output at issuance time (transparency_status=PENDING, no transparency).
|
|
17
17
|
*/
|
|
18
|
-
import { CERTIFICATE_REVOKED, UNSUPPORTED_ALGORITHM, UNSUPPORTED_FORMAT_VERSION, VerificationError, } from "./errors.js";
|
|
18
|
+
import { AUTHORIZATION_POLICY_FAILED, CERTIFICATE_REVOKED, UNSUPPORTED_ALGORITHM, UNSUPPORTED_FORMAT_VERSION, VerificationError, } from "./errors.js";
|
|
19
19
|
// ---------------------------------------------------------------------------
|
|
20
20
|
// Pure byte helpers (no Buffer, no node:crypto)
|
|
21
21
|
// ---------------------------------------------------------------------------
|
|
@@ -63,8 +63,9 @@ const PAYLOAD_TYPE_ATTESTATION_V8 = "burnledger.attestation.v8";
|
|
|
63
63
|
const PAYLOAD_TYPE_VERIFICATION_RECORD_V8 = "burnledger.verification_record.v8";
|
|
64
64
|
const FORMAT_VERSION_V8 = "8.0";
|
|
65
65
|
// v9 adds two per-system fields, authorization and customer_key_id: whether the
|
|
66
|
-
// enclave checked this system against a
|
|
67
|
-
//
|
|
66
|
+
// enclave checked this system against a registration certificate (ADR-025 §4),
|
|
67
|
+
// and under which customer key group if it did. That the key group SIGNED the
|
|
68
|
+
// registration holds only from enclave protocol 12: see PRE_PROTOCOL_12_IMAGES.
|
|
68
69
|
//
|
|
69
70
|
// The attestation gains NO field at v9. Its tag still moves, because the tag
|
|
70
71
|
// follows the record's version and the record's bytes changed — a v9
|
|
@@ -208,17 +209,22 @@ const PAYLOAD_TYPE_LOG_LEAF = "burnledger.log_leaf.v3";
|
|
|
208
209
|
// in the same enclave call that signs the certificate (ADR-016 §2).
|
|
209
210
|
const PAYLOAD_TYPE_CERTIFICATE_STATUS = "burnledger.certificate_status.v3";
|
|
210
211
|
const PAYLOAD_TYPE_KEY_LIST = "burnledger.key_list.v3";
|
|
212
|
+
/** Decode hex: whole bytes, digits in either case, nothing else. Anything else
|
|
213
|
+
* throws TypeError. It is exported, so its input is a boundary: reading a stray
|
|
214
|
+
* character as 0, as it once did, turned a typo into different bytes. */
|
|
211
215
|
export function hexToBytes(hex) {
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
216
|
+
if (typeof hex !== "string")
|
|
217
|
+
throw new TypeError(`hex is not a string: ${typeof hex}`);
|
|
218
|
+
if (hex.length % 2 !== 0)
|
|
219
|
+
throw new TypeError(`hex has an odd number of digits: ${hex.length}`);
|
|
220
|
+
const out = new Uint8Array(hex.length / 2);
|
|
221
|
+
for (let i = 0; i < out.length; i++) {
|
|
222
|
+
out[i] = (unhex(hex, i * 2) << 4) | unhex(hex, i * 2 + 1);
|
|
218
223
|
}
|
|
219
224
|
return out;
|
|
220
225
|
}
|
|
221
|
-
function unhex(
|
|
226
|
+
function unhex(hex, at) {
|
|
227
|
+
const c = hex.charCodeAt(at);
|
|
222
228
|
// 0-9
|
|
223
229
|
if (c >= 48 && c <= 57)
|
|
224
230
|
return c - 48;
|
|
@@ -228,7 +234,7 @@ function unhex(c) {
|
|
|
228
234
|
// A-F
|
|
229
235
|
if (c >= 65 && c <= 70)
|
|
230
236
|
return c - 55;
|
|
231
|
-
|
|
237
|
+
throw new TypeError(`hex has a non-hex character ${JSON.stringify(hex[at])} at ${at}`);
|
|
232
238
|
}
|
|
233
239
|
export function bytesToHex(bytes) {
|
|
234
240
|
let out = "";
|
|
@@ -268,6 +274,13 @@ function textToBytes(s) {
|
|
|
268
274
|
return new TextEncoder().encode(s);
|
|
269
275
|
}
|
|
270
276
|
export async function publicKeyFromHex(crypto, hexKey, opts) {
|
|
277
|
+
// Refused here, as the VerificationError callers are told to catch, rather
|
|
278
|
+
// than left to hexToBytes's TypeError. The alphabet is the one
|
|
279
|
+
// core.KeyEntry.PublicKeyInfo takes through hex.DecodeString: either case,
|
|
280
|
+
// whole bytes, nothing else.
|
|
281
|
+
if (typeof hexKey !== "string" || !HEX_TEXT_RE.test(hexKey)) {
|
|
282
|
+
throw new VerificationError(`public key is not hex: ${JSON.stringify(hexKey)}`);
|
|
283
|
+
}
|
|
271
284
|
const raw = hexToBytes(hexKey);
|
|
272
285
|
if (raw.length !== 32) {
|
|
273
286
|
throw new VerificationError(`public key must be 32 bytes, got ${raw.length}`);
|
|
@@ -375,7 +388,7 @@ export function certificateAnchor(certificate) {
|
|
|
375
388
|
if (transparency == null)
|
|
376
389
|
return null;
|
|
377
390
|
const sth = transparency.signed_tree_head;
|
|
378
|
-
if (sth
|
|
391
|
+
if (sth == null || typeof sth.timestamp !== "string")
|
|
379
392
|
return null;
|
|
380
393
|
try {
|
|
381
394
|
return parseRfc3339(sth.timestamp);
|
|
@@ -405,15 +418,22 @@ async function verifiedCertificateAnchor(crypto, certificate, pki) {
|
|
|
405
418
|
return null;
|
|
406
419
|
const transparency = certificate.transparency;
|
|
407
420
|
const sth = transparency.signed_tree_head;
|
|
421
|
+
// Only a refusal of the head's own fields means "no anchor". A bare catch
|
|
422
|
+
// here gave a defect in this library — or a crypto provider that threw — the
|
|
423
|
+
// same quiet answer as a malformed document.
|
|
424
|
+
let headPayload;
|
|
425
|
+
let headSig;
|
|
408
426
|
try {
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
427
|
+
headPayload = buildTreeHeadPayload(sth);
|
|
428
|
+
headSig = decodeFixed(sth.signature, "signed_tree_head.signature", 64);
|
|
429
|
+
}
|
|
430
|
+
catch (e) {
|
|
431
|
+
if (e instanceof VerificationError)
|
|
412
432
|
return null;
|
|
433
|
+
throw e;
|
|
413
434
|
}
|
|
414
|
-
|
|
435
|
+
if (!(await crypto.ed25519Verify(pki.keyBytes, headPayload, headSig)))
|
|
415
436
|
return null;
|
|
416
|
-
}
|
|
417
437
|
return anchor;
|
|
418
438
|
}
|
|
419
439
|
/** Throws for a key that cannot be used at all; returns the verdict otherwise.
|
|
@@ -449,14 +469,155 @@ role = "issuer key") {
|
|
|
449
469
|
}
|
|
450
470
|
return verdict;
|
|
451
471
|
}
|
|
472
|
+
function policySet(values, field, lower) {
|
|
473
|
+
if (values === undefined)
|
|
474
|
+
return undefined;
|
|
475
|
+
if (!Array.isArray(values) || !values.every((v) => typeof v === "string" && v !== "")) {
|
|
476
|
+
throw new TypeError(`requireAuthorization.${field} must be an array of non-empty strings`);
|
|
477
|
+
}
|
|
478
|
+
if (values.length === 0) {
|
|
479
|
+
// An empty requirement requires nothing, which is never what a caller who
|
|
480
|
+
// built a policy meant.
|
|
481
|
+
throw new Error(`an empty requireAuthorization.${field} requires nothing; omit it instead`);
|
|
482
|
+
}
|
|
483
|
+
return new Set(values.map((v) => (lower ? v.toLowerCase() : v)));
|
|
484
|
+
}
|
|
485
|
+
/** Check a policy before anything is verified, so a caller's mistake surfaces as
|
|
486
|
+
* one whatever the record turns out to be. */
|
|
487
|
+
function parsePolicy(policy) {
|
|
488
|
+
const keys = policy.customerKeyIds;
|
|
489
|
+
if (keys === undefined || (Array.isArray(keys) && keys.length === 0)) {
|
|
490
|
+
throw new TypeError("requireAuthorization.customerKeyIds is required, with at least one key id: `certified` under " +
|
|
491
|
+
"a key id you did not name proves nothing to you, because the relay can enroll a key group of its own");
|
|
492
|
+
}
|
|
493
|
+
return {
|
|
494
|
+
systems: policySet(policy.systems, "systems", true),
|
|
495
|
+
customerKeyIds: policySet(policy.customerKeyIds, "customerKeyIds", false),
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
/**
|
|
499
|
+
* The enclave images that issued format 9.0 while running wire protocol 11 or
|
|
500
|
+
* earlier, by PCR0, as the published measurement history records them
|
|
501
|
+
* (website/docs/enclave-measurements/, docs/CONNECTOR-STATUS.md).
|
|
502
|
+
*
|
|
503
|
+
* Their `certified` is not evidence of a customer signature. Before protocol 12
|
|
504
|
+
* the enclave enrolled a key group with no proof that anyone held its keys, and
|
|
505
|
+
* registered a system for whoever relayed the authorization — so the host could
|
|
506
|
+
* make a genuine 9.0 record say `certified`, under a key id it chose, with no
|
|
507
|
+
* customer signature anywhere (reproduced by an independent re-review,
|
|
508
|
+
* 2026-09-13).
|
|
509
|
+
*
|
|
510
|
+
* THE SET IS CLOSED BY POLICY, NOT BY ENFORCEMENT: the release runbook forbids
|
|
511
|
+
* admitting or rolling back to an image of protocol 11 or earlier once any
|
|
512
|
+
* customer key is enrolled, and admitting one takes a KMS re-pin, which ADR-027
|
|
513
|
+
* leaves to the owner. The protocol-12 cutover's KMS tighten drops every
|
|
514
|
+
* measurement here from the signing key's policy, after which none of them can
|
|
515
|
+
* sign. v42 ran for seven minutes and is recorded as issuing nothing; it is here
|
|
516
|
+
* because it held the signing key while it ran.
|
|
517
|
+
*
|
|
518
|
+
* The Python SDK carries the same list, in _verify.py.
|
|
519
|
+
*/
|
|
520
|
+
export const PRE_PROTOCOL_12_IMAGES = new Map([
|
|
521
|
+
["0e79f985c287fbad3ed9aa1d5443189c45428929086dc4d4312ba71c4039742a8bca02f4ea15ab318d82e1d86ff883d3", "EIF v39"],
|
|
522
|
+
["665864551aeb57705c3b3721018112cbd0e2be98de2e0ff9142a6c0abf366c72c6ba6cdcd31cd6ed8471862e1b9187ce", "EIF v40"],
|
|
523
|
+
["7a61327a0ee11508764a8ad03d68e81e3b374e4e605a284e9e815ec9cf57de8497e59d57048977441f9c5c98bee1d14b", "EIF v41"],
|
|
524
|
+
["f1e02ace0011bfc0d60d6dd76da0078b7706f3165eff6904a36e97e5d2029246099ef20bf15f87d0263dd843f878731c", "EIF v42"],
|
|
525
|
+
["10ed72207ffd6a14f5913130eca69e9f4f9de0447b360e9cfd8aa8e349681c6fe98c515499a38a347d67932a6201a9b4", "EIF v43"],
|
|
526
|
+
["0bfbd2251cb9e138491c6ce424ad132b28be152e1486aa7100acabce0201f3f5d500eb47dc00184de4eebc93fe4dc695", "EIF v44"],
|
|
527
|
+
["4d68b3ef6b77418f2827964400241ee9db7e101ce53666b75cc6cf7aa17d7c9f3a1b9055bafec4f799beee6e00227546", "EIF v45 and v46"],
|
|
528
|
+
]);
|
|
529
|
+
export function issuingImage(certificate) {
|
|
530
|
+
const issuer = certificate.issuer;
|
|
531
|
+
const pcr0 = String(issuer?.enclave_pcr0 ?? "").toLowerCase();
|
|
532
|
+
if (pcr0 === "")
|
|
533
|
+
return { kind: "unnamed" };
|
|
534
|
+
const name = PRE_PROTOCOL_12_IMAGES.get(pcr0);
|
|
535
|
+
return name === undefined
|
|
536
|
+
? { kind: "protocol-12-or-later", pcr0 }
|
|
537
|
+
: { kind: "before-protocol-12", name, pcr0 };
|
|
538
|
+
}
|
|
539
|
+
/**
|
|
540
|
+
* What `certified` is evidence of, given the image that signed the record: the
|
|
541
|
+
* words every verifier prints after "certified under <key id> —". The Go CLI
|
|
542
|
+
* (cmd/cli/registration_image.go), both SDK CLIs and the /verify page state it
|
|
543
|
+
* identically, and testdata/registration_line_cases.json holds them to it.
|
|
544
|
+
*/
|
|
545
|
+
export function registrationQualifier(image) {
|
|
546
|
+
switch (image.kind) {
|
|
547
|
+
case "unnamed":
|
|
548
|
+
return ("not evidence here: this record names no enclave image, so it may come from one " +
|
|
549
|
+
"that could mark a system certified without the customer's signature.");
|
|
550
|
+
case "before-protocol-12":
|
|
551
|
+
return (`not evidence here: this record comes from ${image.name}, an enclave image of ` +
|
|
552
|
+
"protocol 11 or earlier, which could mark a system certified without the customer's signature.");
|
|
553
|
+
case "protocol-12-or-later":
|
|
554
|
+
return ("the key group with that id signed this system's registration, if that key id is yours: " +
|
|
555
|
+
"this record comes from an enclave image of protocol 12 or later. If that key id is not yours, " +
|
|
556
|
+
"someone else authorized this verification.");
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
/**
|
|
560
|
+
* Throw unless a record that has already verified meets `policy`.
|
|
561
|
+
*
|
|
562
|
+
* From 9.0 every field read here is under the certificate signature, the
|
|
563
|
+
* issuer's enclave_pcr0 included. Below 9.0
|
|
564
|
+
* nothing signs `authorization` — those records carry the field outside the
|
|
565
|
+
* signed bytes — so one that says `certified` says only what its last holder
|
|
566
|
+
* typed. It fails on the format, before the field is read.
|
|
567
|
+
*/
|
|
568
|
+
function enforceAuthorization(certificate, formatVersion, policy) {
|
|
569
|
+
const systemsWanted = policy.systems;
|
|
570
|
+
if (!signatureCoversAuthorization(formatVersion)) {
|
|
571
|
+
throw new VerificationError(`authorization required, but this record is format ${formatVersion}, which predates ` +
|
|
572
|
+
`customer-signed registration (${FORMAT_VERSION_V9}); it cannot show that any system was certified`, AUTHORIZATION_POLICY_FAILED);
|
|
573
|
+
}
|
|
574
|
+
const systems = certificate.systems ?? [];
|
|
575
|
+
let required = systems;
|
|
576
|
+
if (systemsWanted !== undefined) {
|
|
577
|
+
const byId = new Map(systems.map((s) => [String(s.system_id).toLowerCase(), s]));
|
|
578
|
+
const missing = [...systemsWanted].filter((id) => !byId.has(id)).sort();
|
|
579
|
+
if (missing.length > 0) {
|
|
580
|
+
throw new VerificationError(`authorization required for system ${missing[0]}, which this record does not cover`, AUTHORIZATION_POLICY_FAILED);
|
|
581
|
+
}
|
|
582
|
+
required = [...systemsWanted].sort().map((id) => byId.get(id));
|
|
583
|
+
}
|
|
584
|
+
const labelOf = (s) => `system ${JSON.stringify(s.system_name)} (${String(s.system_id)})`;
|
|
585
|
+
for (const s of required) {
|
|
586
|
+
if (s.authorization !== AUTHORIZATION_CERTIFIED) {
|
|
587
|
+
throw new VerificationError(`authorization required, but ${labelOf(s)} is ${JSON.stringify(s.authorization ?? null)}: ` +
|
|
588
|
+
"the enclave did not check it against a registration", AUTHORIZATION_POLICY_FAILED);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
// Asked once, for the record, after every required system has said certified:
|
|
592
|
+
// which image said it decides whether `certified` means a customer signed.
|
|
593
|
+
const image = issuingImage(certificate);
|
|
594
|
+
if (image.kind === "unnamed") {
|
|
595
|
+
throw new VerificationError("authorization required, but this record names no enclave image (issuer.enclave_pcr0 is " +
|
|
596
|
+
"absent), so nothing shows it came from one that registers a system only with the " +
|
|
597
|
+
"customer's signature", AUTHORIZATION_POLICY_FAILED);
|
|
598
|
+
}
|
|
599
|
+
if (image.kind === "before-protocol-12") {
|
|
600
|
+
throw new VerificationError(`authorization required, but this record was signed by ${image.name} (PCR0 ${image.pcr0.slice(0, 16)}…), ` +
|
|
601
|
+
"an enclave image of wire protocol 11 or earlier, which could register a system without " +
|
|
602
|
+
"the customer's signature; its `certified` does not show that any key group signed anything", AUTHORIZATION_POLICY_FAILED);
|
|
603
|
+
}
|
|
604
|
+
for (const s of required) {
|
|
605
|
+
if (!policy.customerKeyIds.has(s.customer_key_id)) {
|
|
606
|
+
throw new VerificationError(`${labelOf(s)} is certified under ${String(s.customer_key_id)}, which is not a key you ` +
|
|
607
|
+
"accept; if it is not yours, someone else authorized this verification", AUTHORIZATION_POLICY_FAILED);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
}
|
|
452
611
|
/** Verify all signatures on a deletion certificate offline. */
|
|
453
|
-
export async function verifyCertificate(crypto, certificate, publicKeys) {
|
|
612
|
+
export async function verifyCertificate(crypto, certificate, publicKeys, options) {
|
|
454
613
|
// Step 0: can this SDK read the format at all? Every check below rebuilds
|
|
455
614
|
// signed bytes from the version, so an unreadable version makes all of them
|
|
456
615
|
// meaningless - and "unknown issuer key" would be the wrong thing to tell
|
|
457
616
|
// someone holding a record that is merely newer than this library.
|
|
617
|
+
requireObject(certificate, "certificate");
|
|
618
|
+
const policy = options?.requireAuthorization === undefined ? undefined : parsePolicy(options.requireAuthorization);
|
|
458
619
|
const formatVersion = checkFormatVersion(certificate.certificate_format_version);
|
|
459
|
-
const issuer = certificate
|
|
620
|
+
const issuer = objectAt(certificate, "issuer", "issuer");
|
|
460
621
|
// Step 0b: can this SDK check the algorithm the record names? Asked before
|
|
461
622
|
// any signature, because verifying an Ed25519 signature over a record that
|
|
462
623
|
// says it was signed with something else answers a question nobody asked.
|
|
@@ -467,7 +628,7 @@ export async function verifyCertificate(crypto, certificate, publicKeys) {
|
|
|
467
628
|
`this version of the SDK verifies ${ALGORITHM_ED25519}. ` +
|
|
468
629
|
"The record may be genuine and simply signed with a scheme this library does not implement.", UNSUPPORTED_ALGORITHM);
|
|
469
630
|
}
|
|
470
|
-
const keyId = issuer.key_id;
|
|
631
|
+
const keyId = stringAt(issuer, "key_id", "issuer.key_id");
|
|
471
632
|
const pki = publicKeys.get(keyId);
|
|
472
633
|
if (pki === undefined)
|
|
473
634
|
throw new VerificationError(`unknown issuer key: ${keyId}`);
|
|
@@ -477,7 +638,7 @@ export async function verifyCertificate(crypto, certificate, publicKeys) {
|
|
|
477
638
|
// 1. Certificate signature FIRST — nothing below may trust a field until the
|
|
478
639
|
// bytes carrying it are covered by a verified signature.
|
|
479
640
|
const certPayload = buildCertificatePayload(certificate);
|
|
480
|
-
const certSig =
|
|
641
|
+
const certSig = decodeFixed(certificate.certificate_signature, "certificate_signature", 64);
|
|
481
642
|
if (!(await crypto.ed25519Verify(pki.keyBytes, certPayload, certSig))) {
|
|
482
643
|
throw new VerificationError("certificate signature is invalid");
|
|
483
644
|
}
|
|
@@ -491,7 +652,7 @@ export async function verifyCertificate(crypto, certificate, publicKeys) {
|
|
|
491
652
|
attested_at: att.attested_at,
|
|
492
653
|
systems: attestationSystems(certificate),
|
|
493
654
|
}, certificate.certificate_format_version);
|
|
494
|
-
const attSig =
|
|
655
|
+
const attSig = decodeFixed(att.attestation_signature, "attestation.attestation_signature", 64);
|
|
495
656
|
if (!(await crypto.ed25519Verify(pki.keyBytes, attPayload, attSig))) {
|
|
496
657
|
throw new VerificationError("attestation signature is invalid");
|
|
497
658
|
}
|
|
@@ -540,6 +701,9 @@ export async function verifyCertificate(crypto, certificate, publicKeys) {
|
|
|
540
701
|
if (!anyAttested) {
|
|
541
702
|
throw new VerificationError("incomplete verification: no system held any records at attestation time");
|
|
542
703
|
}
|
|
704
|
+
if (policy !== undefined) {
|
|
705
|
+
enforceAuthorization(certificate, formatVersion, policy);
|
|
706
|
+
}
|
|
543
707
|
// A soft key verdict is the result, not a footnote: VALID_KEY_WINDOW_UNKNOWN
|
|
544
708
|
// and VALID_KEY_COMPROMISED_LATER mean this document still needs a human, and
|
|
545
709
|
// reporting VALID here would be the lying by omission ADR-017 exists to stop.
|
|
@@ -548,13 +712,14 @@ export async function verifyCertificate(crypto, certificate, publicKeys) {
|
|
|
548
712
|
const NIL_UUID = "00000000-0000-0000-0000-000000000000";
|
|
549
713
|
/** Verify the transparency proof embedded in a certificate. */
|
|
550
714
|
export async function verifyTransparency(crypto, certificate, publicKeys) {
|
|
551
|
-
|
|
552
|
-
if (transparency == null) {
|
|
715
|
+
requireObject(certificate, "certificate");
|
|
716
|
+
if (certificate.transparency == null) {
|
|
553
717
|
return "NOT_AVAILABLE";
|
|
554
718
|
}
|
|
555
|
-
const
|
|
556
|
-
const
|
|
557
|
-
const
|
|
719
|
+
const transparency = objectAt(certificate, "transparency", "transparency");
|
|
720
|
+
const sth = objectAt(transparency, "signed_tree_head", "transparency.signed_tree_head");
|
|
721
|
+
const issuer = objectAt(certificate, "issuer", "issuer");
|
|
722
|
+
const keyId = stringAt(issuer, "key_id", "issuer.key_id");
|
|
558
723
|
const pki = publicKeys.get(keyId);
|
|
559
724
|
if (pki === undefined)
|
|
560
725
|
throw new VerificationError(`unknown issuer key: ${keyId}`);
|
|
@@ -562,7 +727,7 @@ export async function verifyTransparency(crypto, certificate, publicKeys) {
|
|
|
562
727
|
requireUsableKey(pki, certificateAnchor(certificate), keyId);
|
|
563
728
|
// 1. Tree head signature
|
|
564
729
|
const headPayload = buildTreeHeadPayload(sth);
|
|
565
|
-
const headSig =
|
|
730
|
+
const headSig = decodeFixed(sth.signature, "signed_tree_head.signature", 64);
|
|
566
731
|
if (!(await crypto.ed25519Verify(pki.keyBytes, headPayload, headSig))) {
|
|
567
732
|
throw new VerificationError("tree head signature is invalid");
|
|
568
733
|
}
|
|
@@ -575,10 +740,10 @@ export async function verifyTransparency(crypto, certificate, publicKeys) {
|
|
|
575
740
|
// revocation of the same certificate produced identical leaves and the tree
|
|
576
741
|
// committed to neither the entry type nor when it happened.
|
|
577
742
|
const issuanceData = issuanceBytes(certificate);
|
|
578
|
-
const leafPayload = buildLogLeafPayload(transparency.entry_type, certificate
|
|
743
|
+
const leafPayload = buildLogLeafPayload(transparency.entry_type, stringAt(certificate, "certificate_id", "certificate_id"), await crypto.sha256(issuanceData), transparency.appended_at);
|
|
579
744
|
const leaf = await hashLeaf(crypto, leafPayload);
|
|
580
|
-
const proofHashes = (transparency.inclusion_proof
|
|
581
|
-
const root =
|
|
745
|
+
const proofHashes = arrayAt(transparency, "inclusion_proof", "transparency.inclusion_proof").map((h, i) => decodeFixed(h, `transparency.inclusion_proof[${i}]`, 32));
|
|
746
|
+
const root = decodeFixed(sth.root_hash, "signed_tree_head.root_hash", 32);
|
|
582
747
|
// `?? 0` is not a convenience: encoding/json leaves 0 in Go's uint64 fields
|
|
583
748
|
// for an explicit null and for an absent key rather than failing, so refusing
|
|
584
749
|
// either would reject documents the reference accepts — the same class of
|
|
@@ -634,7 +799,10 @@ export function issuanceBytes(certificate) {
|
|
|
634
799
|
// ---------------------------------------------------------------------------
|
|
635
800
|
// RFC 8785 Canonical JSON
|
|
636
801
|
// ---------------------------------------------------------------------------
|
|
637
|
-
|
|
802
|
+
/** Exported for tests/verify.test.ts, which keeps a second implementation of
|
|
803
|
+
* this and must be able to prove the two agree. Not re-exported from index.ts,
|
|
804
|
+
* so it stays off the published API. */
|
|
805
|
+
export function canonicalJson(obj) {
|
|
638
806
|
return textToBytes(canonicalStringify(obj));
|
|
639
807
|
}
|
|
640
808
|
function canonicalStringify(val) {
|
|
@@ -657,47 +825,89 @@ function canonicalStringify(val) {
|
|
|
657
825
|
}
|
|
658
826
|
return JSON.stringify(val);
|
|
659
827
|
}
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
* - base64 string (from Go's json.Marshal of []byte slices)
|
|
666
|
-
* - number[] (from Go's json.Marshal of [N]byte fixed arrays)
|
|
667
|
-
*/
|
|
668
|
-
function toHex(value) {
|
|
669
|
-
if (Array.isArray(value)) {
|
|
670
|
-
return bytesToHex(new Uint8Array(value));
|
|
828
|
+
function requireObject(value, path) {
|
|
829
|
+
if (value === undefined || value === null)
|
|
830
|
+
throw new VerificationError(`${path} is missing`);
|
|
831
|
+
if (typeof value !== "object" || Array.isArray(value)) {
|
|
832
|
+
throw new VerificationError(`${path} is not an object`);
|
|
671
833
|
}
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
834
|
+
return value;
|
|
835
|
+
}
|
|
836
|
+
function objectAt(parent, key, path) {
|
|
837
|
+
return requireObject(parent[key], path);
|
|
838
|
+
}
|
|
839
|
+
function stringAt(parent, key, path) {
|
|
840
|
+
const value = parent[key];
|
|
841
|
+
if (value === undefined || value === null)
|
|
842
|
+
throw new VerificationError(`${path} is missing`);
|
|
843
|
+
if (typeof value !== "string")
|
|
844
|
+
throw new VerificationError(`${path} is not a string`);
|
|
845
|
+
return value;
|
|
846
|
+
}
|
|
847
|
+
function optionalStringAt(parent, key, path) {
|
|
848
|
+
const value = parent[key];
|
|
849
|
+
if (value === undefined || value === null)
|
|
850
|
+
return undefined;
|
|
851
|
+
if (typeof value !== "string")
|
|
852
|
+
throw new VerificationError(`${path} is not a string`);
|
|
853
|
+
return value;
|
|
677
854
|
}
|
|
678
|
-
/**
|
|
679
|
-
|
|
855
|
+
/**
|
|
856
|
+
* An array field. Absent and null read as empty: Go leaves a nil slice.
|
|
857
|
+
*
|
|
858
|
+
* Returned dense. JSON cannot express a hole, but a caller building the object
|
|
859
|
+
* in JavaScript can, and `.map` skips holes — so one reached the Merkle code as
|
|
860
|
+
* `undefined` and threw a TypeError. Array.from reads a hole as undefined,
|
|
861
|
+
* which the element's own reader then refuses as missing.
|
|
862
|
+
*/
|
|
863
|
+
function arrayAt(parent, key, path) {
|
|
864
|
+
const value = parent[key];
|
|
865
|
+
if (value === undefined || value === null)
|
|
866
|
+
return [];
|
|
867
|
+
if (!Array.isArray(value))
|
|
868
|
+
throw new VerificationError(`${path} is not an array`);
|
|
869
|
+
return Array.from(value);
|
|
870
|
+
}
|
|
871
|
+
const HEX_TEXT_RE = /^(?:[0-9a-fA-F]{2})*$/;
|
|
872
|
+
const BASE64_TEXT_RE = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
|
|
873
|
+
/**
|
|
874
|
+
* Decode a fixed-size byte field — a hash, a key, a signature.
|
|
875
|
+
*
|
|
876
|
+
* Go marshals `[N]byte` as an array of numbers; hand-written documents carry
|
|
877
|
+
* hex or standard base64. A well-formed value of the right size is never
|
|
878
|
+
* ambiguous between the two text forms, because base64 of 32 or 64 bytes ends
|
|
879
|
+
* in padding hex cannot contain.
|
|
880
|
+
*
|
|
881
|
+
* Everything else is refused here, by name: a number, an array element that is
|
|
882
|
+
* not a byte, text that is neither encoding, the wrong length. Decoding it
|
|
883
|
+
* leniently instead wrapped 300 to 44 inside a Uint8Array, produced bytes no
|
|
884
|
+
* signature covers and blamed the signature — or threw from inside atob.
|
|
885
|
+
*/
|
|
886
|
+
function decodeFixed(value, path, length) {
|
|
887
|
+
if (value === undefined || value === null)
|
|
888
|
+
throw new VerificationError(`${path} is missing`);
|
|
889
|
+
let raw;
|
|
680
890
|
if (Array.isArray(value)) {
|
|
681
|
-
|
|
891
|
+
if (!value.every((b) => Number.isInteger(b) && b >= 0 && b <= 255)) {
|
|
892
|
+
throw new VerificationError(`${path} is not an array of bytes`);
|
|
893
|
+
}
|
|
894
|
+
raw = new Uint8Array(value);
|
|
682
895
|
}
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
if (
|
|
687
|
-
|
|
896
|
+
else if (typeof value === "string") {
|
|
897
|
+
if (HEX_TEXT_RE.test(value))
|
|
898
|
+
raw = hexToBytes(value);
|
|
899
|
+
else if (BASE64_TEXT_RE.test(value))
|
|
900
|
+
raw = base64ToBytes(value);
|
|
901
|
+
else
|
|
902
|
+
throw new VerificationError(`${path} is neither hex nor base64`);
|
|
688
903
|
}
|
|
689
|
-
|
|
690
|
-
}
|
|
691
|
-
/** Decode a hash/bytes field to raw bytes. Same format handling as toHex. */
|
|
692
|
-
function decodeBytes(value) {
|
|
693
|
-
if (Array.isArray(value)) {
|
|
694
|
-
return new Uint8Array(value);
|
|
904
|
+
else {
|
|
905
|
+
throw new VerificationError(`${path} is not a byte string`);
|
|
695
906
|
}
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
return hexToBytes(str);
|
|
907
|
+
if (raw.length !== length) {
|
|
908
|
+
throw new VerificationError(`${path} is ${raw.length} bytes, expected ${length}`);
|
|
699
909
|
}
|
|
700
|
-
return
|
|
910
|
+
return raw;
|
|
701
911
|
}
|
|
702
912
|
/** Go's zero time.Time — what encoding/json leaves in a non-pointer time.Time
|
|
703
913
|
* field for an explicit JSON null or an absent key. */
|
|
@@ -726,14 +936,17 @@ const RFC3339_RE = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?
|
|
|
726
936
|
* Exported from this module so the timestamp_vectors.json suite can diff it
|
|
727
937
|
* against Go directly. It is deliberately not re-exported from index.ts, so it
|
|
728
938
|
* is not part of the published package surface.
|
|
939
|
+
*
|
|
940
|
+
* `field` names the value in a refusal. It defaults to the word these messages
|
|
941
|
+
* always used, for callers normalizing a time that is not a field of a document.
|
|
729
942
|
*/
|
|
730
|
-
export function formatTimestamp(ts) {
|
|
943
|
+
export function formatTimestamp(ts, field = "timestamp") {
|
|
731
944
|
if (ts === null || ts === undefined)
|
|
732
945
|
return ZERO_INSTANT;
|
|
733
946
|
if (typeof ts !== "string") {
|
|
734
|
-
throw new VerificationError(
|
|
947
|
+
throw new VerificationError(`${field} is not a string: ${JSON.stringify(ts)}`);
|
|
735
948
|
}
|
|
736
|
-
return formatEpochSeconds(parseRfc3339(ts));
|
|
949
|
+
return formatEpochSeconds(parseRfc3339(ts, field));
|
|
737
950
|
}
|
|
738
951
|
/** Parse a strict RFC 3339 timestamp to seconds since 1970-01-01T00:00:00Z.
|
|
739
952
|
*
|
|
@@ -744,20 +957,20 @@ export function formatTimestamp(ts) {
|
|
|
744
957
|
* `+24:00` and `+00:60` parse, while `+25:00` and `+00:61` do not. Sub-second
|
|
745
958
|
* digits are dropped, matching Format's truncation.
|
|
746
959
|
*/
|
|
747
|
-
function parseRfc3339(ts) {
|
|
960
|
+
function parseRfc3339(ts, field = "timestamp") {
|
|
748
961
|
const m = RFC3339_RE.exec(ts);
|
|
749
962
|
if (m === null)
|
|
750
|
-
throw new VerificationError(
|
|
963
|
+
throw new VerificationError(`${field} is not RFC 3339: ${ts}`);
|
|
751
964
|
const [year, month, day, hour, minute, second] = m
|
|
752
965
|
.slice(1, 7)
|
|
753
966
|
.map((v) => Number.parseInt(v, 10));
|
|
754
967
|
if (month < 1 || month > 12)
|
|
755
|
-
throw new VerificationError(
|
|
968
|
+
throw new VerificationError(`${field} month out of range: ${ts}`);
|
|
756
969
|
if (day < 1 || day > daysInMonth(year, month)) {
|
|
757
|
-
throw new VerificationError(
|
|
970
|
+
throw new VerificationError(`${field} day out of range: ${ts}`);
|
|
758
971
|
}
|
|
759
972
|
if (hour > 23 || minute > 59 || second > 59) {
|
|
760
|
-
throw new VerificationError(
|
|
973
|
+
throw new VerificationError(`${field} time of day out of range: ${ts}`);
|
|
761
974
|
}
|
|
762
975
|
let offset = 0;
|
|
763
976
|
const sign = m[7];
|
|
@@ -765,9 +978,9 @@ function parseRfc3339(ts) {
|
|
|
765
978
|
const offHour = Number.parseInt(m[8], 10);
|
|
766
979
|
const offMin = Number.parseInt(m[9], 10);
|
|
767
980
|
if (offHour > 24)
|
|
768
|
-
throw new VerificationError(
|
|
981
|
+
throw new VerificationError(`${field} zone offset hour out of range: ${ts}`);
|
|
769
982
|
if (offMin > 60)
|
|
770
|
-
throw new VerificationError(
|
|
983
|
+
throw new VerificationError(`${field} zone offset minute out of range: ${ts}`);
|
|
771
984
|
offset = (offHour * 3600 + offMin * 60) * (sign === "-" ? -1 : 1);
|
|
772
985
|
}
|
|
773
986
|
return daysFromCivil(year, month, day) * 86400 + hour * 3600 + minute * 60 + second - offset;
|
|
@@ -851,17 +1064,17 @@ function buildAttestationPayload(subject, att, certFormatVersion) {
|
|
|
851
1064
|
// payload and call every genuine v8 record a forgery.
|
|
852
1065
|
const coversMeasured = signatureCoversMeasuredTransport(certFormatVersion);
|
|
853
1066
|
const coversRecoverable = signatureCoversRecoverableState(certFormatVersion);
|
|
854
|
-
const systems = att.systems.map((s) => {
|
|
1067
|
+
const systems = att.systems.map((s, i) => {
|
|
855
1068
|
const sys = {
|
|
856
1069
|
canonical_version: s.canonical_version ?? null,
|
|
857
1070
|
connector_type: s.connector_type,
|
|
858
1071
|
hash_scope: s.hash_scope,
|
|
859
|
-
merkle_root: s.merkle_root
|
|
860
|
-
?
|
|
861
|
-
:
|
|
1072
|
+
merkle_root: s.merkle_root == null
|
|
1073
|
+
? null
|
|
1074
|
+
: bytesToHex(decodeFixed(s.merkle_root, `systems[${i}].merkle_root`, 32)),
|
|
862
1075
|
// The system's own observation time, not the envelope's.
|
|
863
|
-
observed_at: formatTimestamp(s.observed_at),
|
|
864
|
-
query_hash:
|
|
1076
|
+
observed_at: formatTimestamp(s.observed_at, `systems[${i}].attested_at`),
|
|
1077
|
+
query_hash: bytesToHex(decodeFixed(s.query_hash, `systems[${i}].query_hash`, 32)),
|
|
865
1078
|
record_count: s.record_count,
|
|
866
1079
|
system_id: s.system_id,
|
|
867
1080
|
system_name: s.system_name,
|
|
@@ -879,7 +1092,7 @@ function buildAttestationPayload(subject, att, certFormatVersion) {
|
|
|
879
1092
|
attested_at: attestedAt,
|
|
880
1093
|
payload_type: attestationPayloadType(certFormatVersion),
|
|
881
1094
|
proof_mode: att.proof_mode,
|
|
882
|
-
subject_hash:
|
|
1095
|
+
subject_hash: bytesToHex(decodeFixed(subject.identifier_hash, "subject.identifier_hash", 32)),
|
|
883
1096
|
systems,
|
|
884
1097
|
};
|
|
885
1098
|
return canonicalJson(payload);
|
|
@@ -945,9 +1158,9 @@ function attestationPayloadType(version) {
|
|
|
945
1158
|
return PAYLOAD_TYPE_ATTESTATION;
|
|
946
1159
|
}
|
|
947
1160
|
function buildCertificatePayload(cert) {
|
|
948
|
-
const att = cert
|
|
949
|
-
const issuer = cert
|
|
950
|
-
const subject = cert
|
|
1161
|
+
const att = objectAt(cert, "attestation", "attestation");
|
|
1162
|
+
const issuer = objectAt(cert, "issuer", "issuer");
|
|
1163
|
+
const subject = objectAt(cert, "subject", "subject");
|
|
951
1164
|
// One list, keyed by system_id (ADR-016 §2). v2 signed an attestation list
|
|
952
1165
|
// and a verification list joined only on the human-editable system_name,
|
|
953
1166
|
// which made a partial deletion indistinguishable from a complete one.
|
|
@@ -955,19 +1168,25 @@ function buildCertificatePayload(cert) {
|
|
|
955
1168
|
const coversMeasured = signatureCoversMeasuredTransport(version);
|
|
956
1169
|
const coversRecoverable = signatureCoversRecoverableState(version);
|
|
957
1170
|
const coversAuthorization = signatureCoversAuthorization(version);
|
|
958
|
-
const systems = (cert
|
|
1171
|
+
const systems = arrayAt(cert, "systems", "systems").map((entry, i) => {
|
|
1172
|
+
const where = `systems[${i}]`;
|
|
1173
|
+
const s = requireObject(entry, where);
|
|
959
1174
|
const sys = {
|
|
960
|
-
attested_at: formatTimestamp(s.attested_at),
|
|
961
|
-
attested_count: s.attested_count,
|
|
962
|
-
canonical_version: s.canonical_version ?? null,
|
|
963
|
-
connector_type: s.connector_type,
|
|
964
|
-
hash_scope: s.hash_scope,
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
1175
|
+
attested_at: formatTimestamp(s.attested_at, `${where}.attested_at`),
|
|
1176
|
+
attested_count: requireUint(s.attested_count, `${where}.attested_count`),
|
|
1177
|
+
canonical_version: optionalStringAt(s, "canonical_version", `${where}.canonical_version`) ?? null,
|
|
1178
|
+
connector_type: stringAt(s, "connector_type", `${where}.connector_type`),
|
|
1179
|
+
hash_scope: stringAt(s, "hash_scope", `${where}.hash_scope`),
|
|
1180
|
+
// Absent is a system with no Merkle root; anything present must be one.
|
|
1181
|
+
// Go's *[32]byte is nil or 32 bytes, never "" or [].
|
|
1182
|
+
merkle_root: s.merkle_root == null
|
|
1183
|
+
? null
|
|
1184
|
+
: bytesToHex(decodeFixed(s.merkle_root, `${where}.merkle_root`, 32)),
|
|
1185
|
+
query_hash: bytesToHex(decodeFixed(s.query_hash, `${where}.query_hash`, 32)),
|
|
1186
|
+
system_id: stringAt(s, "system_id", `${where}.system_id`),
|
|
1187
|
+
system_name: stringAt(s, "system_name", `${where}.system_name`),
|
|
1188
|
+
verified_at: formatTimestamp(s.verified_at, `${where}.verified_at`),
|
|
1189
|
+
verified_count: requireUint(s.verified_count, `${where}.verified_count`),
|
|
971
1190
|
};
|
|
972
1191
|
if (coversMeasured) {
|
|
973
1192
|
sys.read_only_enforcement = requireMeasured(s.read_only_enforcement, "read_only_enforcement", s.system_name, version);
|
|
@@ -987,9 +1206,9 @@ function buildCertificatePayload(cert) {
|
|
|
987
1206
|
// The attestation block carries no system list of its own: the merged list
|
|
988
1207
|
// above is a superset of it. verification_signature is gone entirely.
|
|
989
1208
|
const attObj = {
|
|
990
|
-
attestation_signature:
|
|
991
|
-
attested_at: formatTimestamp(att.attested_at),
|
|
992
|
-
proof_mode: att.proof_mode,
|
|
1209
|
+
attestation_signature: bytesToHex(decodeFixed(att.attestation_signature, "attestation.attestation_signature", 64)),
|
|
1210
|
+
attested_at: formatTimestamp(att.attested_at, "attestation.attested_at"),
|
|
1211
|
+
proof_mode: stringAt(att, "proof_mode", "attestation.proof_mode"),
|
|
993
1212
|
};
|
|
994
1213
|
// Fields added after v3 are gated on the certificate's OWN version. A v3
|
|
995
1214
|
// certificate must reconstruct to the same bytes forever; reading these
|
|
@@ -1001,9 +1220,9 @@ function buildCertificatePayload(cert) {
|
|
|
1001
1220
|
const isV5Plus = formatAtLeast(version, FORMAT_VERSION_V5);
|
|
1002
1221
|
const isV4Plus = formatAtLeast(version, FORMAT_VERSION_V4);
|
|
1003
1222
|
const issuerObj = {
|
|
1004
|
-
key_id: issuer.key_id,
|
|
1005
|
-
name: issuer.name,
|
|
1006
|
-
public_key:
|
|
1223
|
+
key_id: stringAt(issuer, "key_id", "issuer.key_id"),
|
|
1224
|
+
name: stringAt(issuer, "name", "issuer.name"),
|
|
1225
|
+
public_key: bytesToHex(decodeFixed(issuer.public_key, "issuer.public_key", 32)),
|
|
1007
1226
|
};
|
|
1008
1227
|
// v7 signs the algorithm, and does so unconditionally: unlike legal_entity
|
|
1009
1228
|
// and enclave_pcr0, "which scheme signed this" is never unknown to a signer,
|
|
@@ -1011,43 +1230,48 @@ function buildCertificatePayload(cert) {
|
|
|
1011
1230
|
if (signatureCoversAlgorithm(version)) {
|
|
1012
1231
|
issuerObj.algorithm = requireMeasured(issuer.algorithm, "issuer.algorithm", "issuer", version);
|
|
1013
1232
|
}
|
|
1014
|
-
|
|
1015
|
-
|
|
1233
|
+
// A non-string here used to be dropped silently, so a junk value added to a
|
|
1234
|
+
// record that never carried one still verified. Python refused it; so does
|
|
1235
|
+
// Go, at parse. Now this does too.
|
|
1236
|
+
if (isV4Plus) {
|
|
1237
|
+
const legalEntity = optionalStringAt(issuer, "legal_entity", "issuer.legal_entity");
|
|
1238
|
+
if (legalEntity)
|
|
1239
|
+
issuerObj.legal_entity = legalEntity;
|
|
1016
1240
|
}
|
|
1017
1241
|
// Signed from v5, and omitted when absent or empty: a build with no
|
|
1018
1242
|
// measurement signs none, and "" is a value a reader could mistake for one.
|
|
1019
|
-
if (signatureCoversEnclavePcr0(version)
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1243
|
+
if (signatureCoversEnclavePcr0(version)) {
|
|
1244
|
+
const pcr0 = optionalStringAt(issuer, "enclave_pcr0", "issuer.enclave_pcr0");
|
|
1245
|
+
if (pcr0)
|
|
1246
|
+
issuerObj.enclave_pcr0 = pcr0;
|
|
1023
1247
|
}
|
|
1024
1248
|
// v5 drops identifier_type_hint: signed, but always the constant "custom",
|
|
1025
1249
|
// so it was never evidence. v3 and v4 keep it or they stop verifying.
|
|
1026
1250
|
const subjectObj = {
|
|
1027
|
-
identifier_hash:
|
|
1251
|
+
identifier_hash: bytesToHex(decodeFixed(subject.identifier_hash, "subject.identifier_hash", 32)),
|
|
1028
1252
|
};
|
|
1029
1253
|
if (!isV5Plus) {
|
|
1030
|
-
subjectObj.identifier_type_hint = subject.identifier_type_hint;
|
|
1254
|
+
subjectObj.identifier_type_hint = stringAt(subject, "identifier_type_hint", "subject.identifier_type_hint");
|
|
1031
1255
|
}
|
|
1032
1256
|
// status and revocation are deliberately absent (ADR-016 §3): a signature
|
|
1033
1257
|
// commits to bytes at an instant, revocation is discovered later, so it
|
|
1034
1258
|
// travels as a separate short-lived signed status statement.
|
|
1035
1259
|
const payload = {
|
|
1036
1260
|
attestation: attObj,
|
|
1037
|
-
attestation_id: cert
|
|
1038
|
-
certificate_format_version:
|
|
1039
|
-
certificate_id: cert
|
|
1040
|
-
issued_at: formatTimestamp(cert.issued_at),
|
|
1261
|
+
attestation_id: stringAt(cert, "attestation_id", "attestation_id"),
|
|
1262
|
+
certificate_format_version: version,
|
|
1263
|
+
certificate_id: stringAt(cert, "certificate_id", "certificate_id"),
|
|
1264
|
+
issued_at: formatTimestamp(cert.issued_at, "issued_at"),
|
|
1041
1265
|
issuer: issuerObj,
|
|
1042
1266
|
payload_type: certificatePayloadType(version),
|
|
1043
1267
|
subject: subjectObj,
|
|
1044
1268
|
systems,
|
|
1045
1269
|
};
|
|
1046
1270
|
if (isV4Plus && cert.scope != null) {
|
|
1047
|
-
const scope = cert
|
|
1271
|
+
const scope = objectAt(cert, "scope", "scope");
|
|
1048
1272
|
payload.scope = {
|
|
1049
|
-
text_sha256:
|
|
1050
|
-
version: scope.version,
|
|
1273
|
+
text_sha256: bytesToHex(decodeFixed(scope.text_sha256, "scope.text_sha256", 32)),
|
|
1274
|
+
version: stringAt(scope, "version", "scope.version"),
|
|
1051
1275
|
};
|
|
1052
1276
|
}
|
|
1053
1277
|
return canonicalJson(payload);
|
|
@@ -1058,7 +1282,7 @@ function buildLogLeafPayload(entryType, certificateId, certificateHash, appended
|
|
|
1058
1282
|
throw new VerificationError(`invalid log entry_type: ${String(entryType)}`);
|
|
1059
1283
|
}
|
|
1060
1284
|
const payload = {
|
|
1061
|
-
appended_at: formatTimestamp(appendedAt),
|
|
1285
|
+
appended_at: formatTimestamp(appendedAt, "transparency.appended_at"),
|
|
1062
1286
|
certificate_hash: bytesToHex(certificateHash),
|
|
1063
1287
|
certificate_id: certificateId,
|
|
1064
1288
|
entry_type: entryType,
|
|
@@ -1107,13 +1331,13 @@ function attestationSystems(cert) {
|
|
|
1107
1331
|
* between two modules of this package, not published surface.
|
|
1108
1332
|
*/
|
|
1109
1333
|
export function buildTreeHeadPayload(head) {
|
|
1110
|
-
const logId = head.log_id;
|
|
1111
|
-
const named =
|
|
1334
|
+
const logId = optionalStringAt(head, "log_id", "signed_tree_head.log_id");
|
|
1335
|
+
const named = logId !== undefined && logId !== "";
|
|
1112
1336
|
const payload = {
|
|
1113
1337
|
payload_type: named ? PAYLOAD_TYPE_TREE_HEAD_V7 : PAYLOAD_TYPE_TREE_HEAD,
|
|
1114
|
-
root_hash:
|
|
1115
|
-
timestamp: formatTimestamp(head.timestamp),
|
|
1116
|
-
tree_size: head.tree_size,
|
|
1338
|
+
root_hash: bytesToHex(decodeFixed(head.root_hash, "signed_tree_head.root_hash", 32)),
|
|
1339
|
+
timestamp: formatTimestamp(head.timestamp, "signed_tree_head.timestamp"),
|
|
1340
|
+
tree_size: requireUint(head.tree_size ?? 0, "signed_tree_head.tree_size"),
|
|
1117
1341
|
};
|
|
1118
1342
|
if (named)
|
|
1119
1343
|
payload.log_id = logId;
|
|
@@ -1253,21 +1477,21 @@ export async function verifyConsistency(crypto, oldSize, newSize, oldRoot, newRo
|
|
|
1253
1477
|
export function buildCertificateStatusPayload(stmt) {
|
|
1254
1478
|
const payload = {
|
|
1255
1479
|
payload_type: PAYLOAD_TYPE_CERTIFICATE_STATUS,
|
|
1256
|
-
certificate_id: stmt.certificate_id,
|
|
1257
|
-
statement_expires_at: formatTimestamp(stmt.statement_expires_at),
|
|
1258
|
-
statement_issued_at: formatTimestamp(stmt.statement_issued_at),
|
|
1259
|
-
status: stmt.status,
|
|
1260
|
-
sth_root_hash:
|
|
1261
|
-
sth_tree_size: stmt.sth_tree_size,
|
|
1480
|
+
certificate_id: stringAt(stmt, "certificate_id", "status.certificate_id"),
|
|
1481
|
+
statement_expires_at: formatTimestamp(stmt.statement_expires_at, "status.statement_expires_at"),
|
|
1482
|
+
statement_issued_at: formatTimestamp(stmt.statement_issued_at, "status.statement_issued_at"),
|
|
1483
|
+
status: stringAt(stmt, "status", "status.status"),
|
|
1484
|
+
sth_root_hash: bytesToHex(decodeFixed(stmt.sth_root_hash, "status.sth_root_hash", 32)),
|
|
1485
|
+
sth_tree_size: requireUint(stmt.sth_tree_size ?? 0, "status.sth_tree_size"),
|
|
1262
1486
|
};
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1487
|
+
const replacement = optionalStringAt(stmt, "replacement_certificate_id", "status.replacement_certificate_id");
|
|
1488
|
+
if (replacement !== undefined)
|
|
1489
|
+
payload.replacement_certificate_id = replacement;
|
|
1266
1490
|
if (stmt.revocation_log_index != null) {
|
|
1267
|
-
payload.revocation_log_index = stmt.revocation_log_index;
|
|
1491
|
+
payload.revocation_log_index = requireUint(stmt.revocation_log_index, "status.revocation_log_index");
|
|
1268
1492
|
}
|
|
1269
1493
|
if (stmt.revoked_at != null) {
|
|
1270
|
-
payload.revoked_at = formatTimestamp(stmt.revoked_at);
|
|
1494
|
+
payload.revoked_at = formatTimestamp(stmt.revoked_at, "status.revoked_at");
|
|
1271
1495
|
}
|
|
1272
1496
|
return canonicalJson(payload);
|
|
1273
1497
|
}
|
|
@@ -1302,7 +1526,7 @@ export function buildKeyListPayload(doc) {
|
|
|
1302
1526
|
keys: entries,
|
|
1303
1527
|
statement_expires_at: formatTimestamp(doc.statement_expires_at),
|
|
1304
1528
|
statement_issued_at: formatTimestamp(doc.statement_issued_at),
|
|
1305
|
-
sth_root_hash:
|
|
1529
|
+
sth_root_hash: bytesToHex(decodeFixed(doc.sth_root_hash, "sth_root_hash", 32)),
|
|
1306
1530
|
sth_tree_size: doc.sth_tree_size,
|
|
1307
1531
|
};
|
|
1308
1532
|
return canonicalJson(payload);
|
|
@@ -1328,18 +1552,38 @@ export const VALID_REVOCATION_UNKNOWN = "VALID_REVOCATION_UNKNOWN";
|
|
|
1328
1552
|
*
|
|
1329
1553
|
* The third row is the point: `verifyCertificate` answers VALID there, which
|
|
1330
1554
|
* reads as "not revoked" and is not something it checked.
|
|
1555
|
+
*
|
|
1556
|
+
* `options` is passed to {@link verifyCertificate} unchanged.
|
|
1331
1557
|
*/
|
|
1332
|
-
export async function verifyCertificateWithStatus(crypto, certificate, publicKeys, status, now) {
|
|
1558
|
+
export async function verifyCertificateWithStatus(crypto, certificate, publicKeys, status, now, options) {
|
|
1333
1559
|
// A refusal throws out of verifyCertificate, so `base` is VALID or one of the
|
|
1334
1560
|
// two soft verdicts. Returning a soft verdict here skipped every check below
|
|
1335
1561
|
// it: a revoked certificate signed by a compromised-later key answered
|
|
1336
1562
|
// VALID_KEY_COMPROMISED_LATER and its statement was never authenticated. The
|
|
1337
1563
|
// verdict is held instead, and resolved against what the statement says.
|
|
1338
|
-
const base = await verifyCertificate(crypto, certificate, publicKeys);
|
|
1564
|
+
const base = await verifyCertificate(crypto, certificate, publicKeys, options);
|
|
1339
1565
|
if (status == null) {
|
|
1340
1566
|
return resolveWithBase(base, VALID_REVOCATION_UNKNOWN);
|
|
1341
1567
|
}
|
|
1342
|
-
|
|
1568
|
+
return resolveWithBase(base, await verifyStatusStatement(crypto, status, publicKeys, certificate.certificate_id, now));
|
|
1569
|
+
}
|
|
1570
|
+
/**
|
|
1571
|
+
* The statement half of {@link verifyCertificateWithStatus}: authenticate a
|
|
1572
|
+
* status statement about `certificateId` against the published keys, and read
|
|
1573
|
+
* it at `now`. The /verify page's QR lookup has a record id and no record, and
|
|
1574
|
+
* uses this so it shows what a signed, fresh statement says, never what the
|
|
1575
|
+
* status endpoint claims.
|
|
1576
|
+
*
|
|
1577
|
+
* | input | result |
|
|
1578
|
+
* |---|---|
|
|
1579
|
+
* | fresh, REVOKED | throws VerificationError, code CERTIFICATE_REVOKED |
|
|
1580
|
+
* | fresh, ACTIVE | the statement key's verdict: `"VALID"`, or a soft one |
|
|
1581
|
+
* | stale, or a status this SDK does not know | `"VALID_REVOCATION_UNKNOWN"` |
|
|
1582
|
+
* | unknown or unusable key, bad signature, another certificate | throws VerificationError |
|
|
1583
|
+
*/
|
|
1584
|
+
export async function verifyStatusStatement(crypto, status, publicKeys, certificateId, now) {
|
|
1585
|
+
requireObject(status, "status");
|
|
1586
|
+
const keyId = optionalStringAt(status, "key_id", "status.key_id");
|
|
1343
1587
|
const info = keyId ? publicKeys.get(keyId) : undefined;
|
|
1344
1588
|
if (!info) {
|
|
1345
1589
|
throw new VerificationError(`status statement signed by unknown key: ${keyId}`);
|
|
@@ -1360,10 +1604,10 @@ export async function verifyCertificateWithStatus(crypto, certificate, publicKey
|
|
|
1360
1604
|
const at = formatTimestamp(when.toISOString());
|
|
1361
1605
|
const statusKeyVerdict = requireUsableKey(info, parseRfc3339(at), keyId, "status statement key");
|
|
1362
1606
|
const payload = buildCertificateStatusPayload(status);
|
|
1363
|
-
//
|
|
1607
|
+
// decodeFixed, not hexToBytes: signatures arrive as hex, base64 or a byte
|
|
1364
1608
|
// array depending on the producer, and every other signature on this path
|
|
1365
1609
|
// goes through the same decoder.
|
|
1366
|
-
const sig =
|
|
1610
|
+
const sig = decodeFixed(status.signature, "status.signature", 64);
|
|
1367
1611
|
if (!(await crypto.ed25519Verify(info.keyBytes, payload, sig))) {
|
|
1368
1612
|
throw new VerificationError("status statement signature is invalid");
|
|
1369
1613
|
}
|
|
@@ -1380,8 +1624,7 @@ export async function verifyCertificateWithStatus(crypto, certificate, publicKey
|
|
|
1380
1624
|
// Every signature still verified; the binding was the only forged part.
|
|
1381
1625
|
// core/verify.go never had the fallback, so Go said STATUS_STATEMENT_MISMATCH
|
|
1382
1626
|
// while both SDKs and the browser bundle said VALID.
|
|
1383
|
-
|
|
1384
|
-
if (status.certificate_id !== certId) {
|
|
1627
|
+
if (status.certificate_id !== certificateId) {
|
|
1385
1628
|
throw new VerificationError("status statement is about a different certificate");
|
|
1386
1629
|
}
|
|
1387
1630
|
// Compared in the normalized form the payload signs, so the freshness check
|
|
@@ -1391,7 +1634,7 @@ export async function verifyCertificateWithStatus(crypto, certificate, publicKey
|
|
|
1391
1634
|
if (at < issued || at >= expires) {
|
|
1392
1635
|
// Stale is not a weaker answer, it is no answer — including for a REVOKED
|
|
1393
1636
|
// statement, which must never decay into VALID.
|
|
1394
|
-
return
|
|
1637
|
+
return VALID_REVOCATION_UNKNOWN;
|
|
1395
1638
|
}
|
|
1396
1639
|
if (status.status === "REVOKED") {
|
|
1397
1640
|
// Coded, because this is the only status-path rejection that is a fact
|
|
@@ -1406,12 +1649,12 @@ export async function verifyCertificateWithStatus(crypto, certificate, publicKey
|
|
|
1406
1649
|
// case — was reported as revocation CHECKED AND PASSED. Mirrors
|
|
1407
1650
|
// core.ValidCertificateStatusValue.
|
|
1408
1651
|
if (!KNOWN_STATUS_VALUES.has(status.status)) {
|
|
1409
|
-
return
|
|
1652
|
+
return VALID_REVOCATION_UNKNOWN;
|
|
1410
1653
|
}
|
|
1411
1654
|
// A soft verdict on the status key is the result, not a footnote — but it is
|
|
1412
1655
|
// reported only once the statement has authenticated and been read, so it can
|
|
1413
1656
|
// neither speak for an unverified statement nor suppress a revocation.
|
|
1414
|
-
return
|
|
1657
|
+
return statusKeyVerdict;
|
|
1415
1658
|
}
|
|
1416
1659
|
/**
|
|
1417
1660
|
* Picks what to report when the certificate's own key returned a soft verdict
|