burnledger 0.8.1 → 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 +73 -1
- package/dist/cjs/customer-keys.d.ts.map +1 -1
- package/dist/cjs/customer-keys.js +329 -3
- package/dist/cjs/customer-keys.js.map +1 -1
- 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 +22 -10
- package/dist/cjs/index.d.ts.map +1 -1
- package/dist/cjs/index.js +30 -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 +108 -3
- package/dist/cjs/verify.d.ts.map +1 -1
- package/dist/cjs/verify.js +389 -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 +88 -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 +73 -1
- package/dist/esm/customer-keys.d.ts.map +1 -1
- package/dist/esm/customer-keys.js +323 -3
- package/dist/esm/customer-keys.js.map +1 -1
- 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 +22 -10
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +18 -8
- 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 +108 -3
- package/dist/esm/verify.d.ts.map +1 -1
- package/dist/esm/verify.js +386 -146
- 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 +96 -79
- package/src/client.ts +198 -6
- package/src/customer-keys.ts +373 -3
- 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 +35 -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 +499 -151
- package/src/web-verifier.ts +36 -3
package/src/verify.ts
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
|
|
19
19
|
import type { CryptoOps } from "./crypto.js";
|
|
20
20
|
import {
|
|
21
|
+
AUTHORIZATION_POLICY_FAILED,
|
|
21
22
|
CERTIFICATE_REVOKED,
|
|
22
23
|
UNSUPPORTED_ALGORITHM,
|
|
23
24
|
UNSUPPORTED_FORMAT_VERSION,
|
|
@@ -74,8 +75,9 @@ const PAYLOAD_TYPE_ATTESTATION_V8 = "burnledger.attestation.v8";
|
|
|
74
75
|
const PAYLOAD_TYPE_VERIFICATION_RECORD_V8 = "burnledger.verification_record.v8";
|
|
75
76
|
const FORMAT_VERSION_V8 = "8.0";
|
|
76
77
|
// v9 adds two per-system fields, authorization and customer_key_id: whether the
|
|
77
|
-
// enclave checked this system against a
|
|
78
|
-
//
|
|
78
|
+
// enclave checked this system against a registration certificate (ADR-025 §4),
|
|
79
|
+
// and under which customer key group if it did. That the key group SIGNED the
|
|
80
|
+
// registration holds only from enclave protocol 12: see PRE_PROTOCOL_12_IMAGES.
|
|
79
81
|
//
|
|
80
82
|
// The attestation gains NO field at v9. Its tag still moves, because the tag
|
|
81
83
|
// follows the record's version and the record's bytes changed — a v9
|
|
@@ -246,25 +248,28 @@ const PAYLOAD_TYPE_LOG_LEAF = "burnledger.log_leaf.v3";
|
|
|
246
248
|
const PAYLOAD_TYPE_CERTIFICATE_STATUS = "burnledger.certificate_status.v3";
|
|
247
249
|
const PAYLOAD_TYPE_KEY_LIST = "burnledger.key_list.v3";
|
|
248
250
|
|
|
251
|
+
/** Decode hex: whole bytes, digits in either case, nothing else. Anything else
|
|
252
|
+
* throws TypeError. It is exported, so its input is a boundary: reading a stray
|
|
253
|
+
* character as 0, as it once did, turned a typo into different bytes. */
|
|
249
254
|
export function hexToBytes(hex: string): Uint8Array {
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
out[i] = (unhex(hi) << 4) | unhex(lo);
|
|
255
|
+
if (typeof hex !== "string") throw new TypeError(`hex is not a string: ${typeof hex}`);
|
|
256
|
+
if (hex.length % 2 !== 0) throw new TypeError(`hex has an odd number of digits: ${hex.length}`);
|
|
257
|
+
const out = new Uint8Array(hex.length / 2);
|
|
258
|
+
for (let i = 0; i < out.length; i++) {
|
|
259
|
+
out[i] = (unhex(hex, i * 2) << 4) | unhex(hex, i * 2 + 1);
|
|
256
260
|
}
|
|
257
261
|
return out;
|
|
258
262
|
}
|
|
259
263
|
|
|
260
|
-
function unhex(
|
|
264
|
+
function unhex(hex: string, at: number): number {
|
|
265
|
+
const c = hex.charCodeAt(at);
|
|
261
266
|
// 0-9
|
|
262
267
|
if (c >= 48 && c <= 57) return c - 48;
|
|
263
268
|
// a-f
|
|
264
269
|
if (c >= 97 && c <= 102) return c - 87;
|
|
265
270
|
// A-F
|
|
266
271
|
if (c >= 65 && c <= 70) return c - 55;
|
|
267
|
-
|
|
272
|
+
throw new TypeError(`hex has a non-hex character ${JSON.stringify(hex[at])} at ${at}`);
|
|
268
273
|
}
|
|
269
274
|
|
|
270
275
|
export function bytesToHex(bytes: Uint8Array): string {
|
|
@@ -362,6 +367,13 @@ export async function publicKeyFromHex(
|
|
|
362
367
|
hexKey: string,
|
|
363
368
|
opts?: PublicKeyOptions,
|
|
364
369
|
): Promise<PublicKeyInfo> {
|
|
370
|
+
// Refused here, as the VerificationError callers are told to catch, rather
|
|
371
|
+
// than left to hexToBytes's TypeError. The alphabet is the one
|
|
372
|
+
// core.KeyEntry.PublicKeyInfo takes through hex.DecodeString: either case,
|
|
373
|
+
// whole bytes, nothing else.
|
|
374
|
+
if (typeof hexKey !== "string" || !HEX_TEXT_RE.test(hexKey)) {
|
|
375
|
+
throw new VerificationError(`public key is not hex: ${JSON.stringify(hexKey)}`);
|
|
376
|
+
}
|
|
365
377
|
const raw = hexToBytes(hexKey);
|
|
366
378
|
if (raw.length !== 32) {
|
|
367
379
|
throw new VerificationError(`public key must be 32 bytes, got ${raw.length}`);
|
|
@@ -469,8 +481,8 @@ export function evaluateKey(pki: PublicKeyInfo, anchor: number | null): string {
|
|
|
469
481
|
export function certificateAnchor(certificate: Record<string, unknown>): number | null {
|
|
470
482
|
const transparency = certificate.transparency as Record<string, unknown> | null | undefined;
|
|
471
483
|
if (transparency == null) return null;
|
|
472
|
-
const sth = transparency.signed_tree_head as Record<string, unknown> | undefined;
|
|
473
|
-
if (sth
|
|
484
|
+
const sth = transparency.signed_tree_head as Record<string, unknown> | null | undefined;
|
|
485
|
+
if (sth == null || typeof sth.timestamp !== "string") return null;
|
|
474
486
|
try {
|
|
475
487
|
return parseRfc3339(sth.timestamp);
|
|
476
488
|
} catch {
|
|
@@ -503,13 +515,19 @@ async function verifiedCertificateAnchor(
|
|
|
503
515
|
|
|
504
516
|
const transparency = certificate.transparency as Record<string, unknown>;
|
|
505
517
|
const sth = transparency.signed_tree_head as Record<string, unknown>;
|
|
518
|
+
// Only a refusal of the head's own fields means "no anchor". A bare catch
|
|
519
|
+
// here gave a defect in this library — or a crypto provider that threw — the
|
|
520
|
+
// same quiet answer as a malformed document.
|
|
521
|
+
let headPayload: Uint8Array;
|
|
522
|
+
let headSig: Uint8Array;
|
|
506
523
|
try {
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
524
|
+
headPayload = buildTreeHeadPayload(sth);
|
|
525
|
+
headSig = decodeFixed(sth.signature, "signed_tree_head.signature", 64);
|
|
526
|
+
} catch (e) {
|
|
527
|
+
if (e instanceof VerificationError) return null;
|
|
528
|
+
throw e;
|
|
512
529
|
}
|
|
530
|
+
if (!(await crypto.ed25519Verify(pki.keyBytes, headPayload, headSig))) return null;
|
|
513
531
|
return anchor;
|
|
514
532
|
}
|
|
515
533
|
|
|
@@ -559,21 +577,246 @@ function requireUsableKey(
|
|
|
559
577
|
|
|
560
578
|
type Cert = Record<string, unknown>;
|
|
561
579
|
|
|
580
|
+
/**
|
|
581
|
+
* Require what ADR-025 lets a record prove: that a key group you name signed the
|
|
582
|
+
* registration the enclave checked each system against before measuring it.
|
|
583
|
+
*
|
|
584
|
+
* A pass means each required system is `certified` under one of
|
|
585
|
+
* `customerKeyIds`, in a record from an enclave image of wire protocol 12 or
|
|
586
|
+
* later — the first that registers a system only with that key group's
|
|
587
|
+
* signatures. A record from an earlier image (PRE_PROTOCOL_12_IMAGES), or one
|
|
588
|
+
* that names no image, fails: those images could say `certified` with no
|
|
589
|
+
* customer signature at all.
|
|
590
|
+
*
|
|
591
|
+
* Off unless asked for, because a record that says `authorization: none` is
|
|
592
|
+
* still a genuine record. Whoever relays requests to the enclave can always
|
|
593
|
+
* leave a registration out (ADR-025 §5, §8), and the enclave cannot refuse that
|
|
594
|
+
* for want of durable state. So "my systems must be certified" can be enforced
|
|
595
|
+
* in one place only: here, by the party relying on the record.
|
|
596
|
+
*/
|
|
597
|
+
export interface AuthorizationPolicy {
|
|
598
|
+
/** The system_ids that must be certified. Omitted: every system the record covers. */
|
|
599
|
+
readonly systems?: readonly string[];
|
|
600
|
+
/**
|
|
601
|
+
* The key groups you accept. Required: `certified` under a key id you did not
|
|
602
|
+
* name proves nothing to you, because the relay can enroll a key group of its
|
|
603
|
+
* own and the record would name that one.
|
|
604
|
+
*/
|
|
605
|
+
readonly customerKeyIds: readonly string[];
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
export interface VerifyOptions {
|
|
609
|
+
/**
|
|
610
|
+
* Refuse the record, with a VerificationError whose code is
|
|
611
|
+
* AUTHORIZATION_POLICY_FAILED, unless it meets this policy. Omitted: no
|
|
612
|
+
* requirement, and `authorization: none` verifies as it always has.
|
|
613
|
+
*/
|
|
614
|
+
readonly requireAuthorization?: AuthorizationPolicy;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
function policySet(values: readonly string[] | undefined, field: string, lower: boolean): Set<string> | undefined {
|
|
618
|
+
if (values === undefined) return undefined;
|
|
619
|
+
if (!Array.isArray(values) || !values.every((v) => typeof v === "string" && v !== "")) {
|
|
620
|
+
throw new TypeError(`requireAuthorization.${field} must be an array of non-empty strings`);
|
|
621
|
+
}
|
|
622
|
+
if (values.length === 0) {
|
|
623
|
+
// An empty requirement requires nothing, which is never what a caller who
|
|
624
|
+
// built a policy meant.
|
|
625
|
+
throw new Error(`an empty requireAuthorization.${field} requires nothing; omit it instead`);
|
|
626
|
+
}
|
|
627
|
+
return new Set(values.map((v) => (lower ? v.toLowerCase() : v)));
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
interface ParsedPolicy {
|
|
631
|
+
readonly systems: Set<string> | undefined;
|
|
632
|
+
readonly customerKeyIds: Set<string>;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/** Check a policy before anything is verified, so a caller's mistake surfaces as
|
|
636
|
+
* one whatever the record turns out to be. */
|
|
637
|
+
function parsePolicy(policy: AuthorizationPolicy): ParsedPolicy {
|
|
638
|
+
const keys = policy.customerKeyIds;
|
|
639
|
+
if (keys === undefined || (Array.isArray(keys) && keys.length === 0)) {
|
|
640
|
+
throw new TypeError(
|
|
641
|
+
"requireAuthorization.customerKeyIds is required, with at least one key id: `certified` under " +
|
|
642
|
+
"a key id you did not name proves nothing to you, because the relay can enroll a key group of its own",
|
|
643
|
+
);
|
|
644
|
+
}
|
|
645
|
+
return {
|
|
646
|
+
systems: policySet(policy.systems, "systems", true),
|
|
647
|
+
customerKeyIds: policySet(policy.customerKeyIds, "customerKeyIds", false)!,
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* The enclave images that issued format 9.0 while running wire protocol 11 or
|
|
653
|
+
* earlier, by PCR0, as the published measurement history records them
|
|
654
|
+
* (website/docs/enclave-measurements/, docs/CONNECTOR-STATUS.md).
|
|
655
|
+
*
|
|
656
|
+
* Their `certified` is not evidence of a customer signature. Before protocol 12
|
|
657
|
+
* the enclave enrolled a key group with no proof that anyone held its keys, and
|
|
658
|
+
* registered a system for whoever relayed the authorization — so the host could
|
|
659
|
+
* make a genuine 9.0 record say `certified`, under a key id it chose, with no
|
|
660
|
+
* customer signature anywhere (reproduced by an independent re-review,
|
|
661
|
+
* 2026-09-13).
|
|
662
|
+
*
|
|
663
|
+
* THE SET IS CLOSED BY POLICY, NOT BY ENFORCEMENT: the release runbook forbids
|
|
664
|
+
* admitting or rolling back to an image of protocol 11 or earlier once any
|
|
665
|
+
* customer key is enrolled, and admitting one takes a KMS re-pin, which ADR-027
|
|
666
|
+
* leaves to the owner. The protocol-12 cutover's KMS tighten drops every
|
|
667
|
+
* measurement here from the signing key's policy, after which none of them can
|
|
668
|
+
* sign. v42 ran for seven minutes and is recorded as issuing nothing; it is here
|
|
669
|
+
* because it held the signing key while it ran.
|
|
670
|
+
*
|
|
671
|
+
* The Python SDK carries the same list, in _verify.py.
|
|
672
|
+
*/
|
|
673
|
+
export const PRE_PROTOCOL_12_IMAGES: ReadonlyMap<string, string> = new Map([
|
|
674
|
+
["0e79f985c287fbad3ed9aa1d5443189c45428929086dc4d4312ba71c4039742a8bca02f4ea15ab318d82e1d86ff883d3", "EIF v39"],
|
|
675
|
+
["665864551aeb57705c3b3721018112cbd0e2be98de2e0ff9142a6c0abf366c72c6ba6cdcd31cd6ed8471862e1b9187ce", "EIF v40"],
|
|
676
|
+
["7a61327a0ee11508764a8ad03d68e81e3b374e4e605a284e9e815ec9cf57de8497e59d57048977441f9c5c98bee1d14b", "EIF v41"],
|
|
677
|
+
["f1e02ace0011bfc0d60d6dd76da0078b7706f3165eff6904a36e97e5d2029246099ef20bf15f87d0263dd843f878731c", "EIF v42"],
|
|
678
|
+
["10ed72207ffd6a14f5913130eca69e9f4f9de0447b360e9cfd8aa8e349681c6fe98c515499a38a347d67932a6201a9b4", "EIF v43"],
|
|
679
|
+
["0bfbd2251cb9e138491c6ce424ad132b28be152e1486aa7100acabce0201f3f5d500eb47dc00184de4eebc93fe4dc695", "EIF v44"],
|
|
680
|
+
["4d68b3ef6b77418f2827964400241ee9db7e101ce53666b75cc6cf7aa17d7c9f3a1b9055bafec4f799beee6e00227546", "EIF v45 and v46"],
|
|
681
|
+
]);
|
|
682
|
+
|
|
683
|
+
/**
|
|
684
|
+
* What a record's signed issuer.enclave_pcr0 makes of `certified`: whether the
|
|
685
|
+
* image that signed it could mark a system certified without the customer's
|
|
686
|
+
* signature. "protocol-12-or-later" is the inference the closed list licenses:
|
|
687
|
+
* a genuine 9.0 record under any other measurement was signed by a later image.
|
|
688
|
+
*/
|
|
689
|
+
export type IssuingImage =
|
|
690
|
+
| { readonly kind: "unnamed" }
|
|
691
|
+
| { readonly kind: "before-protocol-12"; readonly name: string; readonly pcr0: string }
|
|
692
|
+
| { readonly kind: "protocol-12-or-later"; readonly pcr0: string };
|
|
693
|
+
|
|
694
|
+
export function issuingImage(certificate: Cert): IssuingImage {
|
|
695
|
+
const issuer = certificate.issuer as Cert | undefined;
|
|
696
|
+
const pcr0 = String(issuer?.enclave_pcr0 ?? "").toLowerCase();
|
|
697
|
+
if (pcr0 === "") return { kind: "unnamed" };
|
|
698
|
+
const name = PRE_PROTOCOL_12_IMAGES.get(pcr0);
|
|
699
|
+
return name === undefined
|
|
700
|
+
? { kind: "protocol-12-or-later", pcr0 }
|
|
701
|
+
: { kind: "before-protocol-12", name, pcr0 };
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
/**
|
|
705
|
+
* What `certified` is evidence of, given the image that signed the record: the
|
|
706
|
+
* words every verifier prints after "certified under <key id> —". The Go CLI
|
|
707
|
+
* (cmd/cli/registration_image.go), both SDK CLIs and the /verify page state it
|
|
708
|
+
* identically, and testdata/registration_line_cases.json holds them to it.
|
|
709
|
+
*/
|
|
710
|
+
export function registrationQualifier(image: IssuingImage): string {
|
|
711
|
+
switch (image.kind) {
|
|
712
|
+
case "unnamed":
|
|
713
|
+
return (
|
|
714
|
+
"not evidence here: this record names no enclave image, so it may come from one " +
|
|
715
|
+
"that could mark a system certified without the customer's signature."
|
|
716
|
+
);
|
|
717
|
+
case "before-protocol-12":
|
|
718
|
+
return (
|
|
719
|
+
`not evidence here: this record comes from ${image.name}, an enclave image of ` +
|
|
720
|
+
"protocol 11 or earlier, which could mark a system certified without the customer's signature."
|
|
721
|
+
);
|
|
722
|
+
case "protocol-12-or-later":
|
|
723
|
+
return (
|
|
724
|
+
"the key group with that id signed this system's registration, if that key id is yours: " +
|
|
725
|
+
"this record comes from an enclave image of protocol 12 or later. If that key id is not yours, " +
|
|
726
|
+
"someone else authorized this verification."
|
|
727
|
+
);
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/**
|
|
732
|
+
* Throw unless a record that has already verified meets `policy`.
|
|
733
|
+
*
|
|
734
|
+
* From 9.0 every field read here is under the certificate signature, the
|
|
735
|
+
* issuer's enclave_pcr0 included. Below 9.0
|
|
736
|
+
* nothing signs `authorization` — those records carry the field outside the
|
|
737
|
+
* signed bytes — so one that says `certified` says only what its last holder
|
|
738
|
+
* typed. It fails on the format, before the field is read.
|
|
739
|
+
*/
|
|
740
|
+
function enforceAuthorization(certificate: Cert, formatVersion: string, policy: ParsedPolicy): void {
|
|
741
|
+
const systemsWanted = policy.systems;
|
|
742
|
+
if (!signatureCoversAuthorization(formatVersion)) {
|
|
743
|
+
throw new VerificationError(
|
|
744
|
+
`authorization required, but this record is format ${formatVersion}, which predates ` +
|
|
745
|
+
`customer-signed registration (${FORMAT_VERSION_V9}); it cannot show that any system was certified`,
|
|
746
|
+
AUTHORIZATION_POLICY_FAILED,
|
|
747
|
+
);
|
|
748
|
+
}
|
|
749
|
+
const systems = (certificate.systems as Record<string, unknown>[] | null | undefined) ?? [];
|
|
750
|
+
let required = systems;
|
|
751
|
+
if (systemsWanted !== undefined) {
|
|
752
|
+
const byId = new Map(systems.map((s) => [String(s.system_id).toLowerCase(), s]));
|
|
753
|
+
const missing = [...systemsWanted].filter((id) => !byId.has(id)).sort();
|
|
754
|
+
if (missing.length > 0) {
|
|
755
|
+
throw new VerificationError(
|
|
756
|
+
`authorization required for system ${missing[0]}, which this record does not cover`,
|
|
757
|
+
AUTHORIZATION_POLICY_FAILED,
|
|
758
|
+
);
|
|
759
|
+
}
|
|
760
|
+
required = [...systemsWanted].sort().map((id) => byId.get(id)!);
|
|
761
|
+
}
|
|
762
|
+
const labelOf = (s: Record<string, unknown>): string =>
|
|
763
|
+
`system ${JSON.stringify(s.system_name)} (${String(s.system_id)})`;
|
|
764
|
+
for (const s of required) {
|
|
765
|
+
if (s.authorization !== AUTHORIZATION_CERTIFIED) {
|
|
766
|
+
throw new VerificationError(
|
|
767
|
+
`authorization required, but ${labelOf(s)} is ${JSON.stringify(s.authorization ?? null)}: ` +
|
|
768
|
+
"the enclave did not check it against a registration",
|
|
769
|
+
AUTHORIZATION_POLICY_FAILED,
|
|
770
|
+
);
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
// Asked once, for the record, after every required system has said certified:
|
|
774
|
+
// which image said it decides whether `certified` means a customer signed.
|
|
775
|
+
const image = issuingImage(certificate);
|
|
776
|
+
if (image.kind === "unnamed") {
|
|
777
|
+
throw new VerificationError(
|
|
778
|
+
"authorization required, but this record names no enclave image (issuer.enclave_pcr0 is " +
|
|
779
|
+
"absent), so nothing shows it came from one that registers a system only with the " +
|
|
780
|
+
"customer's signature",
|
|
781
|
+
AUTHORIZATION_POLICY_FAILED,
|
|
782
|
+
);
|
|
783
|
+
}
|
|
784
|
+
if (image.kind === "before-protocol-12") {
|
|
785
|
+
throw new VerificationError(
|
|
786
|
+
`authorization required, but this record was signed by ${image.name} (PCR0 ${image.pcr0.slice(0, 16)}…), ` +
|
|
787
|
+
"an enclave image of wire protocol 11 or earlier, which could register a system without " +
|
|
788
|
+
"the customer's signature; its `certified` does not show that any key group signed anything",
|
|
789
|
+
AUTHORIZATION_POLICY_FAILED,
|
|
790
|
+
);
|
|
791
|
+
}
|
|
792
|
+
for (const s of required) {
|
|
793
|
+
if (!policy.customerKeyIds.has(s.customer_key_id as string)) {
|
|
794
|
+
throw new VerificationError(
|
|
795
|
+
`${labelOf(s)} is certified under ${String(s.customer_key_id)}, which is not a key you ` +
|
|
796
|
+
"accept; if it is not yours, someone else authorized this verification",
|
|
797
|
+
AUTHORIZATION_POLICY_FAILED,
|
|
798
|
+
);
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
|
|
562
803
|
/** Verify all signatures on a deletion certificate offline. */
|
|
563
804
|
export async function verifyCertificate(
|
|
564
805
|
crypto: CryptoOps,
|
|
565
806
|
certificate: Cert,
|
|
566
807
|
publicKeys: Map<string, PublicKeyInfo>,
|
|
808
|
+
options?: VerifyOptions,
|
|
567
809
|
): Promise<VerificationResult> {
|
|
568
810
|
// Step 0: can this SDK read the format at all? Every check below rebuilds
|
|
569
811
|
// signed bytes from the version, so an unreadable version makes all of them
|
|
570
812
|
// meaningless - and "unknown issuer key" would be the wrong thing to tell
|
|
571
813
|
// someone holding a record that is merely newer than this library.
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
814
|
+
requireObject(certificate, "certificate");
|
|
815
|
+
const policy =
|
|
816
|
+
options?.requireAuthorization === undefined ? undefined : parsePolicy(options.requireAuthorization);
|
|
817
|
+
const formatVersion = checkFormatVersion(certificate.certificate_format_version);
|
|
575
818
|
|
|
576
|
-
const issuer = certificate
|
|
819
|
+
const issuer = objectAt(certificate, "issuer", "issuer");
|
|
577
820
|
// Step 0b: can this SDK check the algorithm the record names? Asked before
|
|
578
821
|
// any signature, because verifying an Ed25519 signature over a record that
|
|
579
822
|
// says it was signed with something else answers a question nobody asked.
|
|
@@ -587,7 +830,7 @@ export async function verifyCertificate(
|
|
|
587
830
|
UNSUPPORTED_ALGORITHM,
|
|
588
831
|
);
|
|
589
832
|
}
|
|
590
|
-
const keyId = issuer.key_id
|
|
833
|
+
const keyId = stringAt(issuer, "key_id", "issuer.key_id");
|
|
591
834
|
|
|
592
835
|
const pki = publicKeys.get(keyId);
|
|
593
836
|
if (pki === undefined) throw new VerificationError(`unknown issuer key: ${keyId}`);
|
|
@@ -602,7 +845,7 @@ export async function verifyCertificate(
|
|
|
602
845
|
// 1. Certificate signature FIRST — nothing below may trust a field until the
|
|
603
846
|
// bytes carrying it are covered by a verified signature.
|
|
604
847
|
const certPayload = buildCertificatePayload(certificate);
|
|
605
|
-
const certSig =
|
|
848
|
+
const certSig = decodeFixed(certificate.certificate_signature, "certificate_signature", 64);
|
|
606
849
|
if (!(await crypto.ed25519Verify(pki.keyBytes, certPayload, certSig))) {
|
|
607
850
|
throw new VerificationError("certificate signature is invalid");
|
|
608
851
|
}
|
|
@@ -621,7 +864,7 @@ export async function verifyCertificate(
|
|
|
621
864
|
},
|
|
622
865
|
certificate.certificate_format_version as string,
|
|
623
866
|
);
|
|
624
|
-
const attSig =
|
|
867
|
+
const attSig = decodeFixed(att.attestation_signature, "attestation.attestation_signature", 64);
|
|
625
868
|
if (!(await crypto.ed25519Verify(pki.keyBytes, attPayload, attSig))) {
|
|
626
869
|
throw new VerificationError("attestation signature is invalid");
|
|
627
870
|
}
|
|
@@ -682,6 +925,10 @@ export async function verifyCertificate(
|
|
|
682
925
|
);
|
|
683
926
|
}
|
|
684
927
|
|
|
928
|
+
if (policy !== undefined) {
|
|
929
|
+
enforceAuthorization(certificate, formatVersion, policy);
|
|
930
|
+
}
|
|
931
|
+
|
|
685
932
|
// A soft key verdict is the result, not a footnote: VALID_KEY_WINDOW_UNKNOWN
|
|
686
933
|
// and VALID_KEY_COMPROMISED_LATER mean this document still needs a human, and
|
|
687
934
|
// reporting VALID here would be the lying by omission ADR-017 exists to stop.
|
|
@@ -697,16 +944,15 @@ export async function verifyTransparency(
|
|
|
697
944
|
certificate: Cert,
|
|
698
945
|
publicKeys: Map<string, PublicKeyInfo>,
|
|
699
946
|
): Promise<TransparencyResult> {
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
| undefined;
|
|
703
|
-
if (transparency == null) {
|
|
947
|
+
requireObject(certificate, "certificate");
|
|
948
|
+
if (certificate.transparency == null) {
|
|
704
949
|
return "NOT_AVAILABLE" as TransparencyResult;
|
|
705
950
|
}
|
|
951
|
+
const transparency = objectAt(certificate, "transparency", "transparency");
|
|
706
952
|
|
|
707
|
-
const sth = transparency
|
|
708
|
-
const issuer = certificate
|
|
709
|
-
const keyId = issuer.key_id
|
|
953
|
+
const sth = objectAt(transparency, "signed_tree_head", "transparency.signed_tree_head");
|
|
954
|
+
const issuer = objectAt(certificate, "issuer", "issuer");
|
|
955
|
+
const keyId = stringAt(issuer, "key_id", "issuer.key_id");
|
|
710
956
|
|
|
711
957
|
const pki = publicKeys.get(keyId);
|
|
712
958
|
if (pki === undefined) throw new VerificationError(`unknown issuer key: ${keyId}`);
|
|
@@ -715,7 +961,7 @@ export async function verifyTransparency(
|
|
|
715
961
|
|
|
716
962
|
// 1. Tree head signature
|
|
717
963
|
const headPayload = buildTreeHeadPayload(sth);
|
|
718
|
-
const headSig =
|
|
964
|
+
const headSig = decodeFixed(sth.signature, "signed_tree_head.signature", 64);
|
|
719
965
|
if (!(await crypto.ed25519Verify(pki.keyBytes, headPayload, headSig))) {
|
|
720
966
|
throw new VerificationError("tree head signature is invalid");
|
|
721
967
|
}
|
|
@@ -731,16 +977,16 @@ export async function verifyTransparency(
|
|
|
731
977
|
const issuanceData = issuanceBytes(certificate);
|
|
732
978
|
const leafPayload = buildLogLeafPayload(
|
|
733
979
|
transparency.entry_type,
|
|
734
|
-
certificate
|
|
980
|
+
stringAt(certificate, "certificate_id", "certificate_id"),
|
|
735
981
|
await crypto.sha256(issuanceData),
|
|
736
982
|
transparency.appended_at,
|
|
737
983
|
);
|
|
738
984
|
const leaf = await hashLeaf(crypto, leafPayload);
|
|
739
985
|
|
|
740
|
-
const proofHashes = (
|
|
741
|
-
(transparency.inclusion_proof
|
|
742
|
-
)
|
|
743
|
-
const root =
|
|
986
|
+
const proofHashes = arrayAt(transparency, "inclusion_proof", "transparency.inclusion_proof").map(
|
|
987
|
+
(h, i) => decodeFixed(h, `transparency.inclusion_proof[${i}]`, 32),
|
|
988
|
+
);
|
|
989
|
+
const root = decodeFixed(sth.root_hash, "signed_tree_head.root_hash", 32);
|
|
744
990
|
// `?? 0` is not a convenience: encoding/json leaves 0 in Go's uint64 fields
|
|
745
991
|
// for an explicit null and for an absent key rather than failing, so refusing
|
|
746
992
|
// either would reject documents the reference accepts — the same class of
|
|
@@ -838,48 +1084,96 @@ function canonicalStringify(val: unknown): string {
|
|
|
838
1084
|
}
|
|
839
1085
|
|
|
840
1086
|
// ---------------------------------------------------------------------------
|
|
841
|
-
//
|
|
1087
|
+
// Reading the document
|
|
842
1088
|
// ---------------------------------------------------------------------------
|
|
1089
|
+
//
|
|
1090
|
+
// A certificate or status statement handed to this verifier is untrusted JSON.
|
|
1091
|
+
// A key that is missing, a value of the wrong JSON type or bytes that will not
|
|
1092
|
+
// decode is a fact about the DOCUMENT, so every field is read through one of
|
|
1093
|
+
// these and refused with a VerificationError naming it. Casting and reading
|
|
1094
|
+
// instead threw TypeError from `undefined.x` and a DOMException from atob,
|
|
1095
|
+
// which a caller cannot tell apart from a bug in this library (BL-4-001-B).
|
|
1096
|
+
//
|
|
1097
|
+
// Absent and null are the same thing, as they are to Go's encoding/json.
|
|
843
1098
|
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
if (Array.isArray(value)) {
|
|
851
|
-
return bytesToHex(new Uint8Array(value as number[]));
|
|
852
|
-
}
|
|
853
|
-
const str = value as string;
|
|
854
|
-
if (/^[0-9a-fA-F]+$/.test(str) && str.length % 2 === 0) {
|
|
855
|
-
return str.toLowerCase();
|
|
1099
|
+
type Doc = Record<string, unknown>;
|
|
1100
|
+
|
|
1101
|
+
function requireObject(value: unknown, path: string): Doc {
|
|
1102
|
+
if (value === undefined || value === null) throw new VerificationError(`${path} is missing`);
|
|
1103
|
+
if (typeof value !== "object" || Array.isArray(value)) {
|
|
1104
|
+
throw new VerificationError(`${path} is not an object`);
|
|
856
1105
|
}
|
|
857
|
-
return
|
|
1106
|
+
return value as Doc;
|
|
858
1107
|
}
|
|
859
1108
|
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
const
|
|
866
|
-
if (
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
}
|
|
870
|
-
return base64ToBytes(str);
|
|
1109
|
+
function objectAt(parent: Doc, key: string, path: string): Doc {
|
|
1110
|
+
return requireObject(parent[key], path);
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
function stringAt(parent: Doc, key: string, path: string): string {
|
|
1114
|
+
const value = parent[key];
|
|
1115
|
+
if (value === undefined || value === null) throw new VerificationError(`${path} is missing`);
|
|
1116
|
+
if (typeof value !== "string") throw new VerificationError(`${path} is not a string`);
|
|
1117
|
+
return value;
|
|
871
1118
|
}
|
|
872
1119
|
|
|
873
|
-
|
|
874
|
-
|
|
1120
|
+
function optionalStringAt(parent: Doc, key: string, path: string): string | undefined {
|
|
1121
|
+
const value = parent[key];
|
|
1122
|
+
if (value === undefined || value === null) return undefined;
|
|
1123
|
+
if (typeof value !== "string") throw new VerificationError(`${path} is not a string`);
|
|
1124
|
+
return value;
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
/**
|
|
1128
|
+
* An array field. Absent and null read as empty: Go leaves a nil slice.
|
|
1129
|
+
*
|
|
1130
|
+
* Returned dense. JSON cannot express a hole, but a caller building the object
|
|
1131
|
+
* in JavaScript can, and `.map` skips holes — so one reached the Merkle code as
|
|
1132
|
+
* `undefined` and threw a TypeError. Array.from reads a hole as undefined,
|
|
1133
|
+
* which the element's own reader then refuses as missing.
|
|
1134
|
+
*/
|
|
1135
|
+
function arrayAt(parent: Doc, key: string, path: string): unknown[] {
|
|
1136
|
+
const value = parent[key];
|
|
1137
|
+
if (value === undefined || value === null) return [];
|
|
1138
|
+
if (!Array.isArray(value)) throw new VerificationError(`${path} is not an array`);
|
|
1139
|
+
return Array.from(value);
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
const HEX_TEXT_RE = /^(?:[0-9a-fA-F]{2})*$/;
|
|
1143
|
+
const BASE64_TEXT_RE = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
|
|
1144
|
+
|
|
1145
|
+
/**
|
|
1146
|
+
* Decode a fixed-size byte field — a hash, a key, a signature.
|
|
1147
|
+
*
|
|
1148
|
+
* Go marshals `[N]byte` as an array of numbers; hand-written documents carry
|
|
1149
|
+
* hex or standard base64. A well-formed value of the right size is never
|
|
1150
|
+
* ambiguous between the two text forms, because base64 of 32 or 64 bytes ends
|
|
1151
|
+
* in padding hex cannot contain.
|
|
1152
|
+
*
|
|
1153
|
+
* Everything else is refused here, by name: a number, an array element that is
|
|
1154
|
+
* not a byte, text that is neither encoding, the wrong length. Decoding it
|
|
1155
|
+
* leniently instead wrapped 300 to 44 inside a Uint8Array, produced bytes no
|
|
1156
|
+
* signature covers and blamed the signature — or threw from inside atob.
|
|
1157
|
+
*/
|
|
1158
|
+
function decodeFixed(value: unknown, path: string, length: number): Uint8Array {
|
|
1159
|
+
if (value === undefined || value === null) throw new VerificationError(`${path} is missing`);
|
|
1160
|
+
let raw: Uint8Array;
|
|
875
1161
|
if (Array.isArray(value)) {
|
|
876
|
-
|
|
1162
|
+
if (!value.every((b) => Number.isInteger(b) && b >= 0 && b <= 255)) {
|
|
1163
|
+
throw new VerificationError(`${path} is not an array of bytes`);
|
|
1164
|
+
}
|
|
1165
|
+
raw = new Uint8Array(value as number[]);
|
|
1166
|
+
} else if (typeof value === "string") {
|
|
1167
|
+
if (HEX_TEXT_RE.test(value)) raw = hexToBytes(value);
|
|
1168
|
+
else if (BASE64_TEXT_RE.test(value)) raw = base64ToBytes(value);
|
|
1169
|
+
else throw new VerificationError(`${path} is neither hex nor base64`);
|
|
1170
|
+
} else {
|
|
1171
|
+
throw new VerificationError(`${path} is not a byte string`);
|
|
877
1172
|
}
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
return hexToBytes(str);
|
|
1173
|
+
if (raw.length !== length) {
|
|
1174
|
+
throw new VerificationError(`${path} is ${raw.length} bytes, expected ${length}`);
|
|
881
1175
|
}
|
|
882
|
-
return
|
|
1176
|
+
return raw;
|
|
883
1177
|
}
|
|
884
1178
|
|
|
885
1179
|
/** Go's zero time.Time — what encoding/json leaves in a non-pointer time.Time
|
|
@@ -912,13 +1206,16 @@ const RFC3339_RE =
|
|
|
912
1206
|
* Exported from this module so the timestamp_vectors.json suite can diff it
|
|
913
1207
|
* against Go directly. It is deliberately not re-exported from index.ts, so it
|
|
914
1208
|
* is not part of the published package surface.
|
|
1209
|
+
*
|
|
1210
|
+
* `field` names the value in a refusal. It defaults to the word these messages
|
|
1211
|
+
* always used, for callers normalizing a time that is not a field of a document.
|
|
915
1212
|
*/
|
|
916
|
-
export function formatTimestamp(ts: unknown): string {
|
|
1213
|
+
export function formatTimestamp(ts: unknown, field = "timestamp"): string {
|
|
917
1214
|
if (ts === null || ts === undefined) return ZERO_INSTANT;
|
|
918
1215
|
if (typeof ts !== "string") {
|
|
919
|
-
throw new VerificationError(
|
|
1216
|
+
throw new VerificationError(`${field} is not a string: ${JSON.stringify(ts)}`);
|
|
920
1217
|
}
|
|
921
|
-
return formatEpochSeconds(parseRfc3339(ts));
|
|
1218
|
+
return formatEpochSeconds(parseRfc3339(ts, field));
|
|
922
1219
|
}
|
|
923
1220
|
|
|
924
1221
|
/** Parse a strict RFC 3339 timestamp to seconds since 1970-01-01T00:00:00Z.
|
|
@@ -930,19 +1227,19 @@ export function formatTimestamp(ts: unknown): string {
|
|
|
930
1227
|
* `+24:00` and `+00:60` parse, while `+25:00` and `+00:61` do not. Sub-second
|
|
931
1228
|
* digits are dropped, matching Format's truncation.
|
|
932
1229
|
*/
|
|
933
|
-
function parseRfc3339(ts: string): number {
|
|
1230
|
+
function parseRfc3339(ts: string, field = "timestamp"): number {
|
|
934
1231
|
const m = RFC3339_RE.exec(ts);
|
|
935
|
-
if (m === null) throw new VerificationError(
|
|
1232
|
+
if (m === null) throw new VerificationError(`${field} is not RFC 3339: ${ts}`);
|
|
936
1233
|
const [year, month, day, hour, minute, second] = m
|
|
937
1234
|
.slice(1, 7)
|
|
938
1235
|
.map((v) => Number.parseInt(v!, 10)) as [number, number, number, number, number, number];
|
|
939
1236
|
|
|
940
|
-
if (month < 1 || month > 12) throw new VerificationError(
|
|
1237
|
+
if (month < 1 || month > 12) throw new VerificationError(`${field} month out of range: ${ts}`);
|
|
941
1238
|
if (day < 1 || day > daysInMonth(year, month)) {
|
|
942
|
-
throw new VerificationError(
|
|
1239
|
+
throw new VerificationError(`${field} day out of range: ${ts}`);
|
|
943
1240
|
}
|
|
944
1241
|
if (hour > 23 || minute > 59 || second > 59) {
|
|
945
|
-
throw new VerificationError(
|
|
1242
|
+
throw new VerificationError(`${field} time of day out of range: ${ts}`);
|
|
946
1243
|
}
|
|
947
1244
|
|
|
948
1245
|
let offset = 0;
|
|
@@ -950,8 +1247,8 @@ function parseRfc3339(ts: string): number {
|
|
|
950
1247
|
if (sign !== undefined) {
|
|
951
1248
|
const offHour = Number.parseInt(m[8]!, 10);
|
|
952
1249
|
const offMin = Number.parseInt(m[9]!, 10);
|
|
953
|
-
if (offHour > 24) throw new VerificationError(
|
|
954
|
-
if (offMin > 60) throw new VerificationError(
|
|
1250
|
+
if (offHour > 24) throw new VerificationError(`${field} zone offset hour out of range: ${ts}`);
|
|
1251
|
+
if (offMin > 60) throw new VerificationError(`${field} zone offset minute out of range: ${ts}`);
|
|
955
1252
|
offset = (offHour * 3600 + offMin * 60) * (sign === "-" ? -1 : 1);
|
|
956
1253
|
}
|
|
957
1254
|
|
|
@@ -1051,17 +1348,18 @@ function buildAttestationPayload(
|
|
|
1051
1348
|
// payload and call every genuine v8 record a forgery.
|
|
1052
1349
|
const coversMeasured = signatureCoversMeasuredTransport(certFormatVersion);
|
|
1053
1350
|
const coversRecoverable = signatureCoversRecoverableState(certFormatVersion);
|
|
1054
|
-
const systems = (att.systems as Record<string, unknown>[]).map((s) => {
|
|
1351
|
+
const systems = (att.systems as Record<string, unknown>[]).map((s, i) => {
|
|
1055
1352
|
const sys: Record<string, unknown> = {
|
|
1056
1353
|
canonical_version: (s.canonical_version as string | null) ?? null,
|
|
1057
1354
|
connector_type: s.connector_type as string,
|
|
1058
1355
|
hash_scope: s.hash_scope as string,
|
|
1059
|
-
merkle_root:
|
|
1060
|
-
|
|
1061
|
-
|
|
1356
|
+
merkle_root:
|
|
1357
|
+
s.merkle_root == null
|
|
1358
|
+
? null
|
|
1359
|
+
: bytesToHex(decodeFixed(s.merkle_root, `systems[${i}].merkle_root`, 32)),
|
|
1062
1360
|
// The system's own observation time, not the envelope's.
|
|
1063
|
-
observed_at: formatTimestamp(s.observed_at),
|
|
1064
|
-
query_hash:
|
|
1361
|
+
observed_at: formatTimestamp(s.observed_at, `systems[${i}].attested_at`),
|
|
1362
|
+
query_hash: bytesToHex(decodeFixed(s.query_hash, `systems[${i}].query_hash`, 32)),
|
|
1065
1363
|
record_count: s.record_count as number,
|
|
1066
1364
|
system_id: s.system_id as string,
|
|
1067
1365
|
system_name: s.system_name as string,
|
|
@@ -1095,7 +1393,7 @@ function buildAttestationPayload(
|
|
|
1095
1393
|
attested_at: attestedAt,
|
|
1096
1394
|
payload_type: attestationPayloadType(certFormatVersion),
|
|
1097
1395
|
proof_mode: att.proof_mode as string,
|
|
1098
|
-
subject_hash:
|
|
1396
|
+
subject_hash: bytesToHex(decodeFixed(subject.identifier_hash, "subject.identifier_hash", 32)),
|
|
1099
1397
|
systems,
|
|
1100
1398
|
};
|
|
1101
1399
|
return canonicalJson(payload);
|
|
@@ -1161,9 +1459,9 @@ function attestationPayloadType(version: string): string {
|
|
|
1161
1459
|
}
|
|
1162
1460
|
|
|
1163
1461
|
function buildCertificatePayload(cert: Record<string, unknown>): Uint8Array {
|
|
1164
|
-
const att = cert
|
|
1165
|
-
const issuer = cert
|
|
1166
|
-
const subject = cert
|
|
1462
|
+
const att = objectAt(cert, "attestation", "attestation");
|
|
1463
|
+
const issuer = objectAt(cert, "issuer", "issuer");
|
|
1464
|
+
const subject = objectAt(cert, "subject", "subject");
|
|
1167
1465
|
|
|
1168
1466
|
// One list, keyed by system_id (ADR-016 §2). v2 signed an attestation list
|
|
1169
1467
|
// and a verification list joined only on the human-editable system_name,
|
|
@@ -1172,19 +1470,27 @@ function buildCertificatePayload(cert: Record<string, unknown>): Uint8Array {
|
|
|
1172
1470
|
const coversMeasured = signatureCoversMeasuredTransport(version);
|
|
1173
1471
|
const coversRecoverable = signatureCoversRecoverableState(version);
|
|
1174
1472
|
const coversAuthorization = signatureCoversAuthorization(version);
|
|
1175
|
-
const systems = (
|
|
1473
|
+
const systems = arrayAt(cert, "systems", "systems").map((entry, i) => {
|
|
1474
|
+
const where = `systems[${i}]`;
|
|
1475
|
+
const s = requireObject(entry, where);
|
|
1176
1476
|
const sys: Record<string, unknown> = {
|
|
1177
|
-
attested_at: formatTimestamp(s.attested_at),
|
|
1178
|
-
attested_count: s.attested_count
|
|
1179
|
-
canonical_version:
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1477
|
+
attested_at: formatTimestamp(s.attested_at, `${where}.attested_at`),
|
|
1478
|
+
attested_count: requireUint(s.attested_count, `${where}.attested_count`),
|
|
1479
|
+
canonical_version:
|
|
1480
|
+
optionalStringAt(s, "canonical_version", `${where}.canonical_version`) ?? null,
|
|
1481
|
+
connector_type: stringAt(s, "connector_type", `${where}.connector_type`),
|
|
1482
|
+
hash_scope: stringAt(s, "hash_scope", `${where}.hash_scope`),
|
|
1483
|
+
// Absent is a system with no Merkle root; anything present must be one.
|
|
1484
|
+
// Go's *[32]byte is nil or 32 bytes, never "" or [].
|
|
1485
|
+
merkle_root:
|
|
1486
|
+
s.merkle_root == null
|
|
1487
|
+
? null
|
|
1488
|
+
: bytesToHex(decodeFixed(s.merkle_root, `${where}.merkle_root`, 32)),
|
|
1489
|
+
query_hash: bytesToHex(decodeFixed(s.query_hash, `${where}.query_hash`, 32)),
|
|
1490
|
+
system_id: stringAt(s, "system_id", `${where}.system_id`),
|
|
1491
|
+
system_name: stringAt(s, "system_name", `${where}.system_name`),
|
|
1492
|
+
verified_at: formatTimestamp(s.verified_at, `${where}.verified_at`),
|
|
1493
|
+
verified_count: requireUint(s.verified_count, `${where}.verified_count`),
|
|
1188
1494
|
};
|
|
1189
1495
|
if (coversMeasured) {
|
|
1190
1496
|
sys.read_only_enforcement = requireMeasured(
|
|
@@ -1219,9 +1525,11 @@ function buildCertificatePayload(cert: Record<string, unknown>): Uint8Array {
|
|
|
1219
1525
|
// The attestation block carries no system list of its own: the merged list
|
|
1220
1526
|
// above is a superset of it. verification_signature is gone entirely.
|
|
1221
1527
|
const attObj = {
|
|
1222
|
-
attestation_signature:
|
|
1223
|
-
|
|
1224
|
-
|
|
1528
|
+
attestation_signature: bytesToHex(
|
|
1529
|
+
decodeFixed(att.attestation_signature, "attestation.attestation_signature", 64),
|
|
1530
|
+
),
|
|
1531
|
+
attested_at: formatTimestamp(att.attested_at, "attestation.attested_at"),
|
|
1532
|
+
proof_mode: stringAt(att, "proof_mode", "attestation.proof_mode"),
|
|
1225
1533
|
};
|
|
1226
1534
|
|
|
1227
1535
|
// Fields added after v3 are gated on the certificate's OWN version. A v3
|
|
@@ -1235,9 +1543,9 @@ function buildCertificatePayload(cert: Record<string, unknown>): Uint8Array {
|
|
|
1235
1543
|
const isV4Plus = formatAtLeast(version, FORMAT_VERSION_V4);
|
|
1236
1544
|
|
|
1237
1545
|
const issuerObj: Record<string, unknown> = {
|
|
1238
|
-
key_id: issuer.key_id
|
|
1239
|
-
name: issuer.name
|
|
1240
|
-
public_key:
|
|
1546
|
+
key_id: stringAt(issuer, "key_id", "issuer.key_id"),
|
|
1547
|
+
name: stringAt(issuer, "name", "issuer.name"),
|
|
1548
|
+
public_key: bytesToHex(decodeFixed(issuer.public_key, "issuer.public_key", 32)),
|
|
1241
1549
|
};
|
|
1242
1550
|
// v7 signs the algorithm, and does so unconditionally: unlike legal_entity
|
|
1243
1551
|
// and enclave_pcr0, "which scheme signed this" is never unknown to a signer,
|
|
@@ -1250,26 +1558,31 @@ function buildCertificatePayload(cert: Record<string, unknown>): Uint8Array {
|
|
|
1250
1558
|
version,
|
|
1251
1559
|
);
|
|
1252
1560
|
}
|
|
1253
|
-
|
|
1254
|
-
|
|
1561
|
+
// A non-string here used to be dropped silently, so a junk value added to a
|
|
1562
|
+
// record that never carried one still verified. Python refused it; so does
|
|
1563
|
+
// Go, at parse. Now this does too.
|
|
1564
|
+
if (isV4Plus) {
|
|
1565
|
+
const legalEntity = optionalStringAt(issuer, "legal_entity", "issuer.legal_entity");
|
|
1566
|
+
if (legalEntity) issuerObj.legal_entity = legalEntity;
|
|
1255
1567
|
}
|
|
1256
1568
|
// Signed from v5, and omitted when absent or empty: a build with no
|
|
1257
1569
|
// measurement signs none, and "" is a value a reader could mistake for one.
|
|
1258
|
-
if (
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
issuer.enclave_pcr0 !== ""
|
|
1262
|
-
) {
|
|
1263
|
-
issuerObj.enclave_pcr0 = issuer.enclave_pcr0;
|
|
1570
|
+
if (signatureCoversEnclavePcr0(version)) {
|
|
1571
|
+
const pcr0 = optionalStringAt(issuer, "enclave_pcr0", "issuer.enclave_pcr0");
|
|
1572
|
+
if (pcr0) issuerObj.enclave_pcr0 = pcr0;
|
|
1264
1573
|
}
|
|
1265
1574
|
|
|
1266
1575
|
// v5 drops identifier_type_hint: signed, but always the constant "custom",
|
|
1267
1576
|
// so it was never evidence. v3 and v4 keep it or they stop verifying.
|
|
1268
1577
|
const subjectObj: Record<string, unknown> = {
|
|
1269
|
-
identifier_hash:
|
|
1578
|
+
identifier_hash: bytesToHex(decodeFixed(subject.identifier_hash, "subject.identifier_hash", 32)),
|
|
1270
1579
|
};
|
|
1271
1580
|
if (!isV5Plus) {
|
|
1272
|
-
subjectObj.identifier_type_hint =
|
|
1581
|
+
subjectObj.identifier_type_hint = stringAt(
|
|
1582
|
+
subject,
|
|
1583
|
+
"identifier_type_hint",
|
|
1584
|
+
"subject.identifier_type_hint",
|
|
1585
|
+
);
|
|
1273
1586
|
}
|
|
1274
1587
|
|
|
1275
1588
|
// status and revocation are deliberately absent (ADR-016 §3): a signature
|
|
@@ -1277,20 +1590,20 @@ function buildCertificatePayload(cert: Record<string, unknown>): Uint8Array {
|
|
|
1277
1590
|
// travels as a separate short-lived signed status statement.
|
|
1278
1591
|
const payload: Record<string, unknown> = {
|
|
1279
1592
|
attestation: attObj,
|
|
1280
|
-
attestation_id: cert
|
|
1281
|
-
certificate_format_version:
|
|
1282
|
-
certificate_id: cert
|
|
1283
|
-
issued_at: formatTimestamp(cert.issued_at),
|
|
1593
|
+
attestation_id: stringAt(cert, "attestation_id", "attestation_id"),
|
|
1594
|
+
certificate_format_version: version,
|
|
1595
|
+
certificate_id: stringAt(cert, "certificate_id", "certificate_id"),
|
|
1596
|
+
issued_at: formatTimestamp(cert.issued_at, "issued_at"),
|
|
1284
1597
|
issuer: issuerObj,
|
|
1285
1598
|
payload_type: certificatePayloadType(version),
|
|
1286
1599
|
subject: subjectObj,
|
|
1287
1600
|
systems,
|
|
1288
1601
|
};
|
|
1289
1602
|
if (isV4Plus && cert.scope != null) {
|
|
1290
|
-
const scope = cert
|
|
1603
|
+
const scope = objectAt(cert, "scope", "scope");
|
|
1291
1604
|
payload.scope = {
|
|
1292
|
-
text_sha256:
|
|
1293
|
-
version: scope.version
|
|
1605
|
+
text_sha256: bytesToHex(decodeFixed(scope.text_sha256, "scope.text_sha256", 32)),
|
|
1606
|
+
version: stringAt(scope, "version", "scope.version"),
|
|
1294
1607
|
};
|
|
1295
1608
|
}
|
|
1296
1609
|
return canonicalJson(payload);
|
|
@@ -1307,7 +1620,7 @@ function buildLogLeafPayload(
|
|
|
1307
1620
|
throw new VerificationError(`invalid log entry_type: ${String(entryType)}`);
|
|
1308
1621
|
}
|
|
1309
1622
|
const payload = {
|
|
1310
|
-
appended_at: formatTimestamp(appendedAt),
|
|
1623
|
+
appended_at: formatTimestamp(appendedAt, "transparency.appended_at"),
|
|
1311
1624
|
certificate_hash: bytesToHex(certificateHash),
|
|
1312
1625
|
certificate_id: certificateId,
|
|
1313
1626
|
entry_type: entryType,
|
|
@@ -1358,13 +1671,13 @@ function attestationSystems(cert: Record<string, unknown>): Record<string, unkno
|
|
|
1358
1671
|
* between two modules of this package, not published surface.
|
|
1359
1672
|
*/
|
|
1360
1673
|
export function buildTreeHeadPayload(head: Record<string, unknown>): Uint8Array {
|
|
1361
|
-
const logId = head.log_id;
|
|
1362
|
-
const named =
|
|
1674
|
+
const logId = optionalStringAt(head, "log_id", "signed_tree_head.log_id");
|
|
1675
|
+
const named = logId !== undefined && logId !== "";
|
|
1363
1676
|
const payload: Record<string, unknown> = {
|
|
1364
1677
|
payload_type: named ? PAYLOAD_TYPE_TREE_HEAD_V7 : PAYLOAD_TYPE_TREE_HEAD,
|
|
1365
|
-
root_hash:
|
|
1366
|
-
timestamp: formatTimestamp(head.timestamp),
|
|
1367
|
-
tree_size: head.tree_size
|
|
1678
|
+
root_hash: bytesToHex(decodeFixed(head.root_hash, "signed_tree_head.root_hash", 32)),
|
|
1679
|
+
timestamp: formatTimestamp(head.timestamp, "signed_tree_head.timestamp"),
|
|
1680
|
+
tree_size: requireUint(head.tree_size ?? 0, "signed_tree_head.tree_size"),
|
|
1368
1681
|
};
|
|
1369
1682
|
if (named) payload.log_id = logId;
|
|
1370
1683
|
return canonicalJson(payload);
|
|
@@ -1535,21 +1848,24 @@ export function buildCertificateStatusPayload(
|
|
|
1535
1848
|
): Uint8Array {
|
|
1536
1849
|
const payload: Record<string, unknown> = {
|
|
1537
1850
|
payload_type: PAYLOAD_TYPE_CERTIFICATE_STATUS,
|
|
1538
|
-
certificate_id: stmt.certificate_id
|
|
1539
|
-
statement_expires_at: formatTimestamp(stmt.statement_expires_at),
|
|
1540
|
-
statement_issued_at: formatTimestamp(stmt.statement_issued_at),
|
|
1541
|
-
status: stmt.status
|
|
1542
|
-
sth_root_hash:
|
|
1543
|
-
sth_tree_size: stmt.sth_tree_size
|
|
1851
|
+
certificate_id: stringAt(stmt, "certificate_id", "status.certificate_id"),
|
|
1852
|
+
statement_expires_at: formatTimestamp(stmt.statement_expires_at, "status.statement_expires_at"),
|
|
1853
|
+
statement_issued_at: formatTimestamp(stmt.statement_issued_at, "status.statement_issued_at"),
|
|
1854
|
+
status: stringAt(stmt, "status", "status.status"),
|
|
1855
|
+
sth_root_hash: bytesToHex(decodeFixed(stmt.sth_root_hash, "status.sth_root_hash", 32)),
|
|
1856
|
+
sth_tree_size: requireUint(stmt.sth_tree_size ?? 0, "status.sth_tree_size"),
|
|
1544
1857
|
};
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1858
|
+
const replacement = optionalStringAt(
|
|
1859
|
+
stmt,
|
|
1860
|
+
"replacement_certificate_id",
|
|
1861
|
+
"status.replacement_certificate_id",
|
|
1862
|
+
);
|
|
1863
|
+
if (replacement !== undefined) payload.replacement_certificate_id = replacement;
|
|
1548
1864
|
if (stmt.revocation_log_index != null) {
|
|
1549
|
-
payload.revocation_log_index = stmt.revocation_log_index
|
|
1865
|
+
payload.revocation_log_index = requireUint(stmt.revocation_log_index, "status.revocation_log_index");
|
|
1550
1866
|
}
|
|
1551
1867
|
if (stmt.revoked_at != null) {
|
|
1552
|
-
payload.revoked_at = formatTimestamp(stmt.revoked_at);
|
|
1868
|
+
payload.revoked_at = formatTimestamp(stmt.revoked_at, "status.revoked_at");
|
|
1553
1869
|
}
|
|
1554
1870
|
return canonicalJson(payload);
|
|
1555
1871
|
}
|
|
@@ -1582,7 +1898,7 @@ export function buildKeyListPayload(doc: Record<string, unknown>): Uint8Array {
|
|
|
1582
1898
|
keys: entries,
|
|
1583
1899
|
statement_expires_at: formatTimestamp(doc.statement_expires_at),
|
|
1584
1900
|
statement_issued_at: formatTimestamp(doc.statement_issued_at),
|
|
1585
|
-
sth_root_hash:
|
|
1901
|
+
sth_root_hash: bytesToHex(decodeFixed(doc.sth_root_hash, "sth_root_hash", 32)),
|
|
1586
1902
|
sth_tree_size: doc.sth_tree_size as number,
|
|
1587
1903
|
};
|
|
1588
1904
|
return canonicalJson(payload);
|
|
@@ -1610,6 +1926,8 @@ export const VALID_REVOCATION_UNKNOWN = "VALID_REVOCATION_UNKNOWN";
|
|
|
1610
1926
|
*
|
|
1611
1927
|
* The third row is the point: `verifyCertificate` answers VALID there, which
|
|
1612
1928
|
* reads as "not revoked" and is not something it checked.
|
|
1929
|
+
*
|
|
1930
|
+
* `options` is passed to {@link verifyCertificate} unchanged.
|
|
1613
1931
|
*/
|
|
1614
1932
|
export async function verifyCertificateWithStatus(
|
|
1615
1933
|
crypto: CryptoOps,
|
|
@@ -1617,18 +1935,49 @@ export async function verifyCertificateWithStatus(
|
|
|
1617
1935
|
publicKeys: Map<string, PublicKeyInfo>,
|
|
1618
1936
|
status?: Record<string, unknown> | null,
|
|
1619
1937
|
now?: Date,
|
|
1938
|
+
options?: VerifyOptions,
|
|
1620
1939
|
): Promise<string> {
|
|
1621
1940
|
// A refusal throws out of verifyCertificate, so `base` is VALID or one of the
|
|
1622
1941
|
// two soft verdicts. Returning a soft verdict here skipped every check below
|
|
1623
1942
|
// it: a revoked certificate signed by a compromised-later key answered
|
|
1624
1943
|
// VALID_KEY_COMPROMISED_LATER and its statement was never authenticated. The
|
|
1625
1944
|
// verdict is held instead, and resolved against what the statement says.
|
|
1626
|
-
const base = await verifyCertificate(crypto, certificate, publicKeys);
|
|
1945
|
+
const base = await verifyCertificate(crypto, certificate, publicKeys, options);
|
|
1627
1946
|
if (status == null) {
|
|
1628
1947
|
return resolveWithBase(base, VALID_REVOCATION_UNKNOWN);
|
|
1629
1948
|
}
|
|
1630
1949
|
|
|
1631
|
-
|
|
1950
|
+
return resolveWithBase(
|
|
1951
|
+
base,
|
|
1952
|
+
await verifyStatusStatement(
|
|
1953
|
+
crypto, status, publicKeys, (certificate as Record<string, unknown>).certificate_id, now,
|
|
1954
|
+
),
|
|
1955
|
+
);
|
|
1956
|
+
}
|
|
1957
|
+
|
|
1958
|
+
/**
|
|
1959
|
+
* The statement half of {@link verifyCertificateWithStatus}: authenticate a
|
|
1960
|
+
* status statement about `certificateId` against the published keys, and read
|
|
1961
|
+
* it at `now`. The /verify page's QR lookup has a record id and no record, and
|
|
1962
|
+
* uses this so it shows what a signed, fresh statement says, never what the
|
|
1963
|
+
* status endpoint claims.
|
|
1964
|
+
*
|
|
1965
|
+
* | input | result |
|
|
1966
|
+
* |---|---|
|
|
1967
|
+
* | fresh, REVOKED | throws VerificationError, code CERTIFICATE_REVOKED |
|
|
1968
|
+
* | fresh, ACTIVE | the statement key's verdict: `"VALID"`, or a soft one |
|
|
1969
|
+
* | stale, or a status this SDK does not know | `"VALID_REVOCATION_UNKNOWN"` |
|
|
1970
|
+
* | unknown or unusable key, bad signature, another certificate | throws VerificationError |
|
|
1971
|
+
*/
|
|
1972
|
+
export async function verifyStatusStatement(
|
|
1973
|
+
crypto: CryptoOps,
|
|
1974
|
+
status: Record<string, unknown>,
|
|
1975
|
+
publicKeys: Map<string, PublicKeyInfo>,
|
|
1976
|
+
certificateId: unknown,
|
|
1977
|
+
now?: Date,
|
|
1978
|
+
): Promise<string> {
|
|
1979
|
+
requireObject(status, "status");
|
|
1980
|
+
const keyId = optionalStringAt(status, "key_id", "status.key_id");
|
|
1632
1981
|
const info = keyId ? publicKeys.get(keyId) : undefined;
|
|
1633
1982
|
if (!info) {
|
|
1634
1983
|
throw new VerificationError(`status statement signed by unknown key: ${keyId}`);
|
|
@@ -1656,10 +2005,10 @@ export async function verifyCertificateWithStatus(
|
|
|
1656
2005
|
);
|
|
1657
2006
|
|
|
1658
2007
|
const payload = buildCertificateStatusPayload(status);
|
|
1659
|
-
//
|
|
2008
|
+
// decodeFixed, not hexToBytes: signatures arrive as hex, base64 or a byte
|
|
1660
2009
|
// array depending on the producer, and every other signature on this path
|
|
1661
2010
|
// goes through the same decoder.
|
|
1662
|
-
const sig =
|
|
2011
|
+
const sig = decodeFixed(status.signature, "status.signature", 64);
|
|
1663
2012
|
if (!(await crypto.ed25519Verify(info.keyBytes, payload, sig))) {
|
|
1664
2013
|
throw new VerificationError("status statement signature is invalid");
|
|
1665
2014
|
}
|
|
@@ -1677,8 +2026,7 @@ export async function verifyCertificateWithStatus(
|
|
|
1677
2026
|
// Every signature still verified; the binding was the only forged part.
|
|
1678
2027
|
// core/verify.go never had the fallback, so Go said STATUS_STATEMENT_MISMATCH
|
|
1679
2028
|
// while both SDKs and the browser bundle said VALID.
|
|
1680
|
-
|
|
1681
|
-
if (status.certificate_id !== certId) {
|
|
2029
|
+
if (status.certificate_id !== certificateId) {
|
|
1682
2030
|
throw new VerificationError("status statement is about a different certificate");
|
|
1683
2031
|
}
|
|
1684
2032
|
|
|
@@ -1689,7 +2037,7 @@ export async function verifyCertificateWithStatus(
|
|
|
1689
2037
|
if (at < issued || at >= expires) {
|
|
1690
2038
|
// Stale is not a weaker answer, it is no answer — including for a REVOKED
|
|
1691
2039
|
// statement, which must never decay into VALID.
|
|
1692
|
-
return
|
|
2040
|
+
return VALID_REVOCATION_UNKNOWN;
|
|
1693
2041
|
}
|
|
1694
2042
|
|
|
1695
2043
|
if (status.status === "REVOKED") {
|
|
@@ -1705,12 +2053,12 @@ export async function verifyCertificateWithStatus(
|
|
|
1705
2053
|
// case — was reported as revocation CHECKED AND PASSED. Mirrors
|
|
1706
2054
|
// core.ValidCertificateStatusValue.
|
|
1707
2055
|
if (!KNOWN_STATUS_VALUES.has(status.status as string)) {
|
|
1708
|
-
return
|
|
2056
|
+
return VALID_REVOCATION_UNKNOWN;
|
|
1709
2057
|
}
|
|
1710
2058
|
// A soft verdict on the status key is the result, not a footnote — but it is
|
|
1711
2059
|
// reported only once the statement has authenticated and been read, so it can
|
|
1712
2060
|
// neither speak for an unverified statement nor suppress a revocation.
|
|
1713
|
-
return
|
|
2061
|
+
return statusKeyVerdict;
|
|
1714
2062
|
}
|
|
1715
2063
|
|
|
1716
2064
|
/**
|