burnledger 0.8.1 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +144 -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 +175 -54
- 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 +165 -0
- package/dist/cjs/enclave-registration.d.ts.map +1 -0
- package/dist/cjs/enclave-registration.js +287 -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 +75 -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/node-runtime.d.ts +40 -0
- package/dist/cjs/node-runtime.d.ts.map +1 -0
- package/dist/cjs/node-runtime.js +42 -0
- package/dist/cjs/node-runtime.js.map +1 -0
- 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 +451 -171
- 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/cjs/webhooks.d.ts +9 -2
- package/dist/cjs/webhooks.d.ts.map +1 -1
- package/dist/cjs/webhooks.js +29 -11
- package/dist/cjs/webhooks.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 +30 -17
- package/dist/esm/cli.d.ts.map +1 -1
- package/dist/esm/cli.js +142 -83
- 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 +175 -21
- 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 +165 -0
- package/dist/esm/enclave-registration.d.ts.map +1 -0
- package/dist/esm/enclave-registration.js +277 -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 +30 -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/node-runtime.d.ts +40 -0
- package/dist/esm/node-runtime.d.ts.map +1 -0
- package/dist/esm/node-runtime.js +38 -0
- package/dist/esm/node-runtime.js.map +1 -0
- 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 +448 -171
- 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/dist/esm/webhooks.d.ts +9 -2
- package/dist/esm/webhooks.d.ts.map +1 -1
- package/dist/esm/webhooks.js +29 -11
- package/dist/esm/webhooks.js.map +1 -1
- package/package.json +1 -1
- package/src/anchor.ts +10 -4
- package/src/cli.ts +145 -78
- package/src/client.ts +241 -25
- package/src/customer-keys.ts +373 -3
- package/src/enclave-registration.ts +402 -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/node-runtime.ts +57 -0
- package/src/status-document.ts +59 -0
- package/src/verify.ts +565 -177
- package/src/web-verifier.ts +36 -3
- package/src/webhooks.ts +28 -11
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}`);
|
|
@@ -435,6 +447,18 @@ export function evaluateKey(pki: PublicKeyInfo, anchor: number | null): string {
|
|
|
435
447
|
if (pki.revoked) return "UNKNOWN_KEY";
|
|
436
448
|
if (pki.keyStatus === "compromised") {
|
|
437
449
|
if (anchor === null || pki.compromisedFrom === undefined) return KEY_COMPROMISED;
|
|
450
|
+
// The validity window is tested before the compromise anchor (R03-1). A
|
|
451
|
+
// signature made outside the stated interval was never authorized, whatever
|
|
452
|
+
// the key's later disposition, so an anchor outside the window is
|
|
453
|
+
// KEY_OUTSIDE_VALIDITY even when it predates compromisedFrom. Skipping the
|
|
454
|
+
// window handed the soft, usable VALID_KEY_COMPROMISED_LATER to a signature
|
|
455
|
+
// the window alone already refuses.
|
|
456
|
+
if (pki.notBefore !== undefined && anchor < parseRfc3339(pki.notBefore)) {
|
|
457
|
+
return KEY_OUTSIDE_VALIDITY;
|
|
458
|
+
}
|
|
459
|
+
if (pki.notAfter !== undefined && anchor >= parseRfc3339(pki.notAfter)) {
|
|
460
|
+
return KEY_OUTSIDE_VALIDITY;
|
|
461
|
+
}
|
|
438
462
|
return anchor < parseRfc3339(pki.compromisedFrom)
|
|
439
463
|
? VALID_KEY_COMPROMISED_LATER
|
|
440
464
|
: KEY_COMPROMISED;
|
|
@@ -469,8 +493,8 @@ export function evaluateKey(pki: PublicKeyInfo, anchor: number | null): string {
|
|
|
469
493
|
export function certificateAnchor(certificate: Record<string, unknown>): number | null {
|
|
470
494
|
const transparency = certificate.transparency as Record<string, unknown> | null | undefined;
|
|
471
495
|
if (transparency == null) return null;
|
|
472
|
-
const sth = transparency.signed_tree_head as Record<string, unknown> | undefined;
|
|
473
|
-
if (sth
|
|
496
|
+
const sth = transparency.signed_tree_head as Record<string, unknown> | null | undefined;
|
|
497
|
+
if (sth == null || typeof sth.timestamp !== "string") return null;
|
|
474
498
|
try {
|
|
475
499
|
return parseRfc3339(sth.timestamp);
|
|
476
500
|
} catch {
|
|
@@ -479,19 +503,28 @@ export function certificateAnchor(certificate: Record<string, unknown>): number
|
|
|
479
503
|
}
|
|
480
504
|
|
|
481
505
|
/**
|
|
482
|
-
* The anchor, but only once
|
|
483
|
-
*
|
|
506
|
+
* The anchor, but only once this record has been PROVEN to be in the tree head
|
|
507
|
+
* carrying it — its inclusion proof verified against that head, not merely the
|
|
508
|
+
* head's own signature checked.
|
|
484
509
|
*
|
|
485
|
-
* The bypass this closes: for a key declared compromised,
|
|
486
|
-
* KEY_COMPROMISED with no anchor and
|
|
487
|
-
* anchor predates the compromise — and the
|
|
488
|
-
* passes through as the RESULT of
|
|
489
|
-
*
|
|
490
|
-
*
|
|
491
|
-
*
|
|
510
|
+
* The bypass this closes (R12-1/R14-1): for a key declared compromised,
|
|
511
|
+
* evaluateKey answers KEY_COMPROMISED with no anchor and
|
|
512
|
+
* VALID_KEY_COMPROMISED_LATER when the anchor predates the compromise — and the
|
|
513
|
+
* second is a verdict requireUsableKey passes through as the RESULT of
|
|
514
|
+
* verifyCertificate. A signed tree head is a public artifact: any genuine older
|
|
515
|
+
* head the issuer ever published can be stapled onto a record it never
|
|
516
|
+
* contained, and its signature still verifies. Checking only the head signature
|
|
517
|
+
* let a holder of a certificate signed after the compromise attach a
|
|
518
|
+
* pre-compromise head and have the forgery reported as verified-with-a-caveat.
|
|
519
|
+
* The head's timestamp is an honest anchor for THIS record only once THIS record
|
|
520
|
+
* is shown to be committed to under it.
|
|
492
521
|
*
|
|
493
|
-
*
|
|
494
|
-
*
|
|
522
|
+
* checkCertificateInclusion is exactly that proof; it deliberately does NOT test
|
|
523
|
+
* the key's usability at head time — that is the verdict this anchor exists to
|
|
524
|
+
* compute, so gating the anchor on it would drop the anchor for an out-of-window
|
|
525
|
+
* key and soften KEY_OUTSIDE_VALIDITY to VALID_KEY_WINDOW_UNKNOWN. Anything short
|
|
526
|
+
* of a held inclusion proof yields no anchor, the same conservative answer as a
|
|
527
|
+
* certificate with no transparency block at all.
|
|
495
528
|
*/
|
|
496
529
|
async function verifiedCertificateAnchor(
|
|
497
530
|
crypto: CryptoOps,
|
|
@@ -501,14 +534,14 @@ async function verifiedCertificateAnchor(
|
|
|
501
534
|
const anchor = certificateAnchor(certificate);
|
|
502
535
|
if (anchor === null) return null;
|
|
503
536
|
|
|
504
|
-
|
|
505
|
-
|
|
537
|
+
// Only a refusal of the proof means "no anchor". A defect in this library, or
|
|
538
|
+
// a crypto provider that threw, must not get the same quiet answer as a
|
|
539
|
+
// document whose inclusion proof does not verify.
|
|
506
540
|
try {
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
if (
|
|
510
|
-
|
|
511
|
-
return null;
|
|
541
|
+
await checkCertificateInclusion(crypto, certificate, pki);
|
|
542
|
+
} catch (e) {
|
|
543
|
+
if (e instanceof VerificationError) return null;
|
|
544
|
+
throw e;
|
|
512
545
|
}
|
|
513
546
|
return anchor;
|
|
514
547
|
}
|
|
@@ -559,21 +592,246 @@ function requireUsableKey(
|
|
|
559
592
|
|
|
560
593
|
type Cert = Record<string, unknown>;
|
|
561
594
|
|
|
595
|
+
/**
|
|
596
|
+
* Require what ADR-025 lets a record prove: that a key group you name signed the
|
|
597
|
+
* registration the enclave checked each system against before measuring it.
|
|
598
|
+
*
|
|
599
|
+
* A pass means each required system is `certified` under one of
|
|
600
|
+
* `customerKeyIds`, in a record from an enclave image of wire protocol 12 or
|
|
601
|
+
* later — the first that registers a system only with that key group's
|
|
602
|
+
* signatures. A record from an earlier image (PRE_PROTOCOL_12_IMAGES), or one
|
|
603
|
+
* that names no image, fails: those images could say `certified` with no
|
|
604
|
+
* customer signature at all.
|
|
605
|
+
*
|
|
606
|
+
* Off unless asked for, because a record that says `authorization: none` is
|
|
607
|
+
* still a genuine record. Whoever relays requests to the enclave can always
|
|
608
|
+
* leave a registration out (ADR-025 §5, §8), and the enclave cannot refuse that
|
|
609
|
+
* for want of durable state. So "my systems must be certified" can be enforced
|
|
610
|
+
* in one place only: here, by the party relying on the record.
|
|
611
|
+
*/
|
|
612
|
+
export interface AuthorizationPolicy {
|
|
613
|
+
/** The system_ids that must be certified. Omitted: every system the record covers. */
|
|
614
|
+
readonly systems?: readonly string[];
|
|
615
|
+
/**
|
|
616
|
+
* The key groups you accept. Required: `certified` under a key id you did not
|
|
617
|
+
* name proves nothing to you, because the relay can enroll a key group of its
|
|
618
|
+
* own and the record would name that one.
|
|
619
|
+
*/
|
|
620
|
+
readonly customerKeyIds: readonly string[];
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
export interface VerifyOptions {
|
|
624
|
+
/**
|
|
625
|
+
* Refuse the record, with a VerificationError whose code is
|
|
626
|
+
* AUTHORIZATION_POLICY_FAILED, unless it meets this policy. Omitted: no
|
|
627
|
+
* requirement, and `authorization: none` verifies as it always has.
|
|
628
|
+
*/
|
|
629
|
+
readonly requireAuthorization?: AuthorizationPolicy;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function policySet(values: readonly string[] | undefined, field: string, lower: boolean): Set<string> | undefined {
|
|
633
|
+
if (values === undefined) return undefined;
|
|
634
|
+
if (!Array.isArray(values) || !values.every((v) => typeof v === "string" && v !== "")) {
|
|
635
|
+
throw new TypeError(`requireAuthorization.${field} must be an array of non-empty strings`);
|
|
636
|
+
}
|
|
637
|
+
if (values.length === 0) {
|
|
638
|
+
// An empty requirement requires nothing, which is never what a caller who
|
|
639
|
+
// built a policy meant.
|
|
640
|
+
throw new Error(`an empty requireAuthorization.${field} requires nothing; omit it instead`);
|
|
641
|
+
}
|
|
642
|
+
return new Set(values.map((v) => (lower ? v.toLowerCase() : v)));
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
interface ParsedPolicy {
|
|
646
|
+
readonly systems: Set<string> | undefined;
|
|
647
|
+
readonly customerKeyIds: Set<string>;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/** Check a policy before anything is verified, so a caller's mistake surfaces as
|
|
651
|
+
* one whatever the record turns out to be. */
|
|
652
|
+
function parsePolicy(policy: AuthorizationPolicy): ParsedPolicy {
|
|
653
|
+
const keys = policy.customerKeyIds;
|
|
654
|
+
if (keys === undefined || (Array.isArray(keys) && keys.length === 0)) {
|
|
655
|
+
throw new TypeError(
|
|
656
|
+
"requireAuthorization.customerKeyIds is required, with at least one key id: `certified` under " +
|
|
657
|
+
"a key id you did not name proves nothing to you, because the relay can enroll a key group of its own",
|
|
658
|
+
);
|
|
659
|
+
}
|
|
660
|
+
return {
|
|
661
|
+
systems: policySet(policy.systems, "systems", true),
|
|
662
|
+
customerKeyIds: policySet(policy.customerKeyIds, "customerKeyIds", false)!,
|
|
663
|
+
};
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/**
|
|
667
|
+
* The enclave images that issued format 9.0 while running wire protocol 11 or
|
|
668
|
+
* earlier, by PCR0, as the published measurement history records them
|
|
669
|
+
* (website/docs/enclave-measurements/, docs/CONNECTOR-STATUS.md).
|
|
670
|
+
*
|
|
671
|
+
* Their `certified` is not evidence of a customer signature. Before protocol 12
|
|
672
|
+
* the enclave enrolled a key group with no proof that anyone held its keys, and
|
|
673
|
+
* registered a system for whoever relayed the authorization — so the host could
|
|
674
|
+
* make a genuine 9.0 record say `certified`, under a key id it chose, with no
|
|
675
|
+
* customer signature anywhere (reproduced by an independent re-review,
|
|
676
|
+
* 2026-09-13).
|
|
677
|
+
*
|
|
678
|
+
* THE SET IS CLOSED BY POLICY, NOT BY ENFORCEMENT: the release runbook forbids
|
|
679
|
+
* admitting or rolling back to an image of protocol 11 or earlier once any
|
|
680
|
+
* customer key is enrolled, and admitting one takes a KMS re-pin, which ADR-027
|
|
681
|
+
* leaves to the owner. The protocol-12 cutover's KMS tighten drops every
|
|
682
|
+
* measurement here from the signing key's policy, after which none of them can
|
|
683
|
+
* sign. v42 ran for seven minutes and is recorded as issuing nothing; it is here
|
|
684
|
+
* because it held the signing key while it ran.
|
|
685
|
+
*
|
|
686
|
+
* The Python SDK carries the same list, in _verify.py.
|
|
687
|
+
*/
|
|
688
|
+
export const PRE_PROTOCOL_12_IMAGES: ReadonlyMap<string, string> = new Map([
|
|
689
|
+
["0e79f985c287fbad3ed9aa1d5443189c45428929086dc4d4312ba71c4039742a8bca02f4ea15ab318d82e1d86ff883d3", "EIF v39"],
|
|
690
|
+
["665864551aeb57705c3b3721018112cbd0e2be98de2e0ff9142a6c0abf366c72c6ba6cdcd31cd6ed8471862e1b9187ce", "EIF v40"],
|
|
691
|
+
["7a61327a0ee11508764a8ad03d68e81e3b374e4e605a284e9e815ec9cf57de8497e59d57048977441f9c5c98bee1d14b", "EIF v41"],
|
|
692
|
+
["f1e02ace0011bfc0d60d6dd76da0078b7706f3165eff6904a36e97e5d2029246099ef20bf15f87d0263dd843f878731c", "EIF v42"],
|
|
693
|
+
["10ed72207ffd6a14f5913130eca69e9f4f9de0447b360e9cfd8aa8e349681c6fe98c515499a38a347d67932a6201a9b4", "EIF v43"],
|
|
694
|
+
["0bfbd2251cb9e138491c6ce424ad132b28be152e1486aa7100acabce0201f3f5d500eb47dc00184de4eebc93fe4dc695", "EIF v44"],
|
|
695
|
+
["4d68b3ef6b77418f2827964400241ee9db7e101ce53666b75cc6cf7aa17d7c9f3a1b9055bafec4f799beee6e00227546", "EIF v45 and v46"],
|
|
696
|
+
]);
|
|
697
|
+
|
|
698
|
+
/**
|
|
699
|
+
* What a record's signed issuer.enclave_pcr0 makes of `certified`: whether the
|
|
700
|
+
* image that signed it could mark a system certified without the customer's
|
|
701
|
+
* signature. "protocol-12-or-later" is the inference the closed list licenses:
|
|
702
|
+
* a genuine 9.0 record under any other measurement was signed by a later image.
|
|
703
|
+
*/
|
|
704
|
+
export type IssuingImage =
|
|
705
|
+
| { readonly kind: "unnamed" }
|
|
706
|
+
| { readonly kind: "before-protocol-12"; readonly name: string; readonly pcr0: string }
|
|
707
|
+
| { readonly kind: "protocol-12-or-later"; readonly pcr0: string };
|
|
708
|
+
|
|
709
|
+
export function issuingImage(certificate: Cert): IssuingImage {
|
|
710
|
+
const issuer = certificate.issuer as Cert | undefined;
|
|
711
|
+
const pcr0 = String(issuer?.enclave_pcr0 ?? "").toLowerCase();
|
|
712
|
+
if (pcr0 === "") return { kind: "unnamed" };
|
|
713
|
+
const name = PRE_PROTOCOL_12_IMAGES.get(pcr0);
|
|
714
|
+
return name === undefined
|
|
715
|
+
? { kind: "protocol-12-or-later", pcr0 }
|
|
716
|
+
: { kind: "before-protocol-12", name, pcr0 };
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/**
|
|
720
|
+
* What `certified` is evidence of, given the image that signed the record: the
|
|
721
|
+
* words every verifier prints after "certified under <key id> —". The Go CLI
|
|
722
|
+
* (cmd/cli/registration_image.go), both SDK CLIs and the /verify page state it
|
|
723
|
+
* identically, and testdata/registration_line_cases.json holds them to it.
|
|
724
|
+
*/
|
|
725
|
+
export function registrationQualifier(image: IssuingImage): string {
|
|
726
|
+
switch (image.kind) {
|
|
727
|
+
case "unnamed":
|
|
728
|
+
return (
|
|
729
|
+
"not evidence here: this record names no enclave image, so it may come from one " +
|
|
730
|
+
"that could mark a system certified without the customer's signature."
|
|
731
|
+
);
|
|
732
|
+
case "before-protocol-12":
|
|
733
|
+
return (
|
|
734
|
+
`not evidence here: this record comes from ${image.name}, an enclave image of ` +
|
|
735
|
+
"protocol 11 or earlier, which could mark a system certified without the customer's signature."
|
|
736
|
+
);
|
|
737
|
+
case "protocol-12-or-later":
|
|
738
|
+
return (
|
|
739
|
+
"the key group with that id signed this system's registration, if that key id is yours: " +
|
|
740
|
+
"this record comes from an enclave image of protocol 12 or later. If that key id is not yours, " +
|
|
741
|
+
"someone else authorized this verification."
|
|
742
|
+
);
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
/**
|
|
747
|
+
* Throw unless a record that has already verified meets `policy`.
|
|
748
|
+
*
|
|
749
|
+
* From 9.0 every field read here is under the certificate signature, the
|
|
750
|
+
* issuer's enclave_pcr0 included. Below 9.0
|
|
751
|
+
* nothing signs `authorization` — those records carry the field outside the
|
|
752
|
+
* signed bytes — so one that says `certified` says only what its last holder
|
|
753
|
+
* typed. It fails on the format, before the field is read.
|
|
754
|
+
*/
|
|
755
|
+
function enforceAuthorization(certificate: Cert, formatVersion: string, policy: ParsedPolicy): void {
|
|
756
|
+
const systemsWanted = policy.systems;
|
|
757
|
+
if (!signatureCoversAuthorization(formatVersion)) {
|
|
758
|
+
throw new VerificationError(
|
|
759
|
+
`authorization required, but this record is format ${formatVersion}, which predates ` +
|
|
760
|
+
`customer-signed registration (${FORMAT_VERSION_V9}); it cannot show that any system was certified`,
|
|
761
|
+
AUTHORIZATION_POLICY_FAILED,
|
|
762
|
+
);
|
|
763
|
+
}
|
|
764
|
+
const systems = (certificate.systems as Record<string, unknown>[] | null | undefined) ?? [];
|
|
765
|
+
let required = systems;
|
|
766
|
+
if (systemsWanted !== undefined) {
|
|
767
|
+
const byId = new Map(systems.map((s) => [String(s.system_id).toLowerCase(), s]));
|
|
768
|
+
const missing = [...systemsWanted].filter((id) => !byId.has(id)).sort();
|
|
769
|
+
if (missing.length > 0) {
|
|
770
|
+
throw new VerificationError(
|
|
771
|
+
`authorization required for system ${missing[0]}, which this record does not cover`,
|
|
772
|
+
AUTHORIZATION_POLICY_FAILED,
|
|
773
|
+
);
|
|
774
|
+
}
|
|
775
|
+
required = [...systemsWanted].sort().map((id) => byId.get(id)!);
|
|
776
|
+
}
|
|
777
|
+
const labelOf = (s: Record<string, unknown>): string =>
|
|
778
|
+
`system ${JSON.stringify(s.system_name)} (${String(s.system_id)})`;
|
|
779
|
+
for (const s of required) {
|
|
780
|
+
if (s.authorization !== AUTHORIZATION_CERTIFIED) {
|
|
781
|
+
throw new VerificationError(
|
|
782
|
+
`authorization required, but ${labelOf(s)} is ${JSON.stringify(s.authorization ?? null)}: ` +
|
|
783
|
+
"the enclave did not check it against a registration",
|
|
784
|
+
AUTHORIZATION_POLICY_FAILED,
|
|
785
|
+
);
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
// Asked once, for the record, after every required system has said certified:
|
|
789
|
+
// which image said it decides whether `certified` means a customer signed.
|
|
790
|
+
const image = issuingImage(certificate);
|
|
791
|
+
if (image.kind === "unnamed") {
|
|
792
|
+
throw new VerificationError(
|
|
793
|
+
"authorization required, but this record names no enclave image (issuer.enclave_pcr0 is " +
|
|
794
|
+
"absent), so nothing shows it came from one that registers a system only with the " +
|
|
795
|
+
"customer's signature",
|
|
796
|
+
AUTHORIZATION_POLICY_FAILED,
|
|
797
|
+
);
|
|
798
|
+
}
|
|
799
|
+
if (image.kind === "before-protocol-12") {
|
|
800
|
+
throw new VerificationError(
|
|
801
|
+
`authorization required, but this record was signed by ${image.name} (PCR0 ${image.pcr0.slice(0, 16)}…), ` +
|
|
802
|
+
"an enclave image of wire protocol 11 or earlier, which could register a system without " +
|
|
803
|
+
"the customer's signature; its `certified` does not show that any key group signed anything",
|
|
804
|
+
AUTHORIZATION_POLICY_FAILED,
|
|
805
|
+
);
|
|
806
|
+
}
|
|
807
|
+
for (const s of required) {
|
|
808
|
+
if (!policy.customerKeyIds.has(s.customer_key_id as string)) {
|
|
809
|
+
throw new VerificationError(
|
|
810
|
+
`${labelOf(s)} is certified under ${String(s.customer_key_id)}, which is not a key you ` +
|
|
811
|
+
"accept; if it is not yours, someone else authorized this verification",
|
|
812
|
+
AUTHORIZATION_POLICY_FAILED,
|
|
813
|
+
);
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
|
|
562
818
|
/** Verify all signatures on a deletion certificate offline. */
|
|
563
819
|
export async function verifyCertificate(
|
|
564
820
|
crypto: CryptoOps,
|
|
565
821
|
certificate: Cert,
|
|
566
822
|
publicKeys: Map<string, PublicKeyInfo>,
|
|
823
|
+
options?: VerifyOptions,
|
|
567
824
|
): Promise<VerificationResult> {
|
|
568
825
|
// Step 0: can this SDK read the format at all? Every check below rebuilds
|
|
569
826
|
// signed bytes from the version, so an unreadable version makes all of them
|
|
570
827
|
// meaningless - and "unknown issuer key" would be the wrong thing to tell
|
|
571
828
|
// someone holding a record that is merely newer than this library.
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
829
|
+
requireObject(certificate, "certificate");
|
|
830
|
+
const policy =
|
|
831
|
+
options?.requireAuthorization === undefined ? undefined : parsePolicy(options.requireAuthorization);
|
|
832
|
+
const formatVersion = checkFormatVersion(certificate.certificate_format_version);
|
|
575
833
|
|
|
576
|
-
const issuer = certificate
|
|
834
|
+
const issuer = objectAt(certificate, "issuer", "issuer");
|
|
577
835
|
// Step 0b: can this SDK check the algorithm the record names? Asked before
|
|
578
836
|
// any signature, because verifying an Ed25519 signature over a record that
|
|
579
837
|
// says it was signed with something else answers a question nobody asked.
|
|
@@ -587,7 +845,7 @@ export async function verifyCertificate(
|
|
|
587
845
|
UNSUPPORTED_ALGORITHM,
|
|
588
846
|
);
|
|
589
847
|
}
|
|
590
|
-
const keyId = issuer.key_id
|
|
848
|
+
const keyId = stringAt(issuer, "key_id", "issuer.key_id");
|
|
591
849
|
|
|
592
850
|
const pki = publicKeys.get(keyId);
|
|
593
851
|
if (pki === undefined) throw new VerificationError(`unknown issuer key: ${keyId}`);
|
|
@@ -602,7 +860,7 @@ export async function verifyCertificate(
|
|
|
602
860
|
// 1. Certificate signature FIRST — nothing below may trust a field until the
|
|
603
861
|
// bytes carrying it are covered by a verified signature.
|
|
604
862
|
const certPayload = buildCertificatePayload(certificate);
|
|
605
|
-
const certSig =
|
|
863
|
+
const certSig = decodeFixed(certificate.certificate_signature, "certificate_signature", 64);
|
|
606
864
|
if (!(await crypto.ed25519Verify(pki.keyBytes, certPayload, certSig))) {
|
|
607
865
|
throw new VerificationError("certificate signature is invalid");
|
|
608
866
|
}
|
|
@@ -621,7 +879,7 @@ export async function verifyCertificate(
|
|
|
621
879
|
},
|
|
622
880
|
certificate.certificate_format_version as string,
|
|
623
881
|
);
|
|
624
|
-
const attSig =
|
|
882
|
+
const attSig = decodeFixed(att.attestation_signature, "attestation.attestation_signature", 64);
|
|
625
883
|
if (!(await crypto.ed25519Verify(pki.keyBytes, attPayload, attSig))) {
|
|
626
884
|
throw new VerificationError("attestation signature is invalid");
|
|
627
885
|
}
|
|
@@ -682,6 +940,10 @@ export async function verifyCertificate(
|
|
|
682
940
|
);
|
|
683
941
|
}
|
|
684
942
|
|
|
943
|
+
if (policy !== undefined) {
|
|
944
|
+
enforceAuthorization(certificate, formatVersion, policy);
|
|
945
|
+
}
|
|
946
|
+
|
|
685
947
|
// A soft key verdict is the result, not a footnote: VALID_KEY_WINDOW_UNKNOWN
|
|
686
948
|
// and VALID_KEY_COMPROMISED_LATER mean this document still needs a human, and
|
|
687
949
|
// reporting VALID here would be the lying by omission ADR-017 exists to stop.
|
|
@@ -697,25 +959,41 @@ export async function verifyTransparency(
|
|
|
697
959
|
certificate: Cert,
|
|
698
960
|
publicKeys: Map<string, PublicKeyInfo>,
|
|
699
961
|
): Promise<TransparencyResult> {
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
| undefined;
|
|
703
|
-
if (transparency == null) {
|
|
962
|
+
requireObject(certificate, "certificate");
|
|
963
|
+
if (certificate.transparency == null) {
|
|
704
964
|
return "NOT_AVAILABLE" as TransparencyResult;
|
|
705
965
|
}
|
|
706
|
-
|
|
707
|
-
const
|
|
708
|
-
const issuer = certificate.issuer as Record<string, unknown>;
|
|
709
|
-
const keyId = issuer.key_id as string;
|
|
966
|
+
const issuer = objectAt(certificate, "issuer", "issuer");
|
|
967
|
+
const keyId = stringAt(issuer, "key_id", "issuer.key_id");
|
|
710
968
|
|
|
711
969
|
const pki = publicKeys.get(keyId);
|
|
712
970
|
if (pki === undefined) throw new VerificationError(`unknown issuer key: ${keyId}`);
|
|
713
971
|
// The tree head carries its own timestamp, so this path always has an anchor.
|
|
714
972
|
requireUsableKey(pki, certificateAnchor(certificate), keyId);
|
|
715
973
|
|
|
974
|
+
await checkCertificateInclusion(crypto, certificate, pki);
|
|
975
|
+
return "INCLUDED" as TransparencyResult;
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
/**
|
|
979
|
+
* Verify the head signature and the Merkle inclusion of a certificate's proof —
|
|
980
|
+
* everything about whether this record is committed to under its head, but NOT
|
|
981
|
+
* whether the key was usable when it signed. That last question is the caller's;
|
|
982
|
+
* certificateAnchor needs membership, not authority, and folding authority in
|
|
983
|
+
* here made the anchor for an out-of-window key collapse to "no anchor" and the
|
|
984
|
+
* verdict soften. Throws VerificationError on any inclusion failure.
|
|
985
|
+
*/
|
|
986
|
+
async function checkCertificateInclusion(
|
|
987
|
+
crypto: CryptoOps,
|
|
988
|
+
certificate: Cert,
|
|
989
|
+
pki: PublicKeyInfo,
|
|
990
|
+
): Promise<void> {
|
|
991
|
+
const transparency = objectAt(certificate, "transparency", "transparency");
|
|
992
|
+
const sth = objectAt(transparency, "signed_tree_head", "transparency.signed_tree_head");
|
|
993
|
+
|
|
716
994
|
// 1. Tree head signature
|
|
717
995
|
const headPayload = buildTreeHeadPayload(sth);
|
|
718
|
-
const headSig =
|
|
996
|
+
const headSig = decodeFixed(sth.signature, "signed_tree_head.signature", 64);
|
|
719
997
|
if (!(await crypto.ed25519Verify(pki.keyBytes, headPayload, headSig))) {
|
|
720
998
|
throw new VerificationError("tree head signature is invalid");
|
|
721
999
|
}
|
|
@@ -731,16 +1009,16 @@ export async function verifyTransparency(
|
|
|
731
1009
|
const issuanceData = issuanceBytes(certificate);
|
|
732
1010
|
const leafPayload = buildLogLeafPayload(
|
|
733
1011
|
transparency.entry_type,
|
|
734
|
-
certificate
|
|
1012
|
+
stringAt(certificate, "certificate_id", "certificate_id"),
|
|
735
1013
|
await crypto.sha256(issuanceData),
|
|
736
1014
|
transparency.appended_at,
|
|
737
1015
|
);
|
|
738
1016
|
const leaf = await hashLeaf(crypto, leafPayload);
|
|
739
1017
|
|
|
740
|
-
const proofHashes = (
|
|
741
|
-
(transparency.inclusion_proof
|
|
742
|
-
)
|
|
743
|
-
const root =
|
|
1018
|
+
const proofHashes = arrayAt(transparency, "inclusion_proof", "transparency.inclusion_proof").map(
|
|
1019
|
+
(h, i) => decodeFixed(h, `transparency.inclusion_proof[${i}]`, 32),
|
|
1020
|
+
);
|
|
1021
|
+
const root = decodeFixed(sth.root_hash, "signed_tree_head.root_hash", 32);
|
|
744
1022
|
// `?? 0` is not a convenience: encoding/json leaves 0 in Go's uint64 fields
|
|
745
1023
|
// for an explicit null and for an absent key rather than failing, so refusing
|
|
746
1024
|
// either would reject documents the reference accepts — the same class of
|
|
@@ -782,8 +1060,6 @@ export async function verifyTransparency(
|
|
|
782
1060
|
if (!(await verifyInclusion(crypto, leaf, index, treeSize, proofHashes, root))) {
|
|
783
1061
|
throw new VerificationError("merkle inclusion proof is invalid");
|
|
784
1062
|
}
|
|
785
|
-
|
|
786
|
-
return "INCLUDED" as TransparencyResult;
|
|
787
1063
|
}
|
|
788
1064
|
|
|
789
1065
|
// ---------------------------------------------------------------------------
|
|
@@ -838,48 +1114,96 @@ function canonicalStringify(val: unknown): string {
|
|
|
838
1114
|
}
|
|
839
1115
|
|
|
840
1116
|
// ---------------------------------------------------------------------------
|
|
841
|
-
//
|
|
1117
|
+
// Reading the document
|
|
842
1118
|
// ---------------------------------------------------------------------------
|
|
1119
|
+
//
|
|
1120
|
+
// A certificate or status statement handed to this verifier is untrusted JSON.
|
|
1121
|
+
// A key that is missing, a value of the wrong JSON type or bytes that will not
|
|
1122
|
+
// decode is a fact about the DOCUMENT, so every field is read through one of
|
|
1123
|
+
// these and refused with a VerificationError naming it. Casting and reading
|
|
1124
|
+
// instead threw TypeError from `undefined.x` and a DOMException from atob,
|
|
1125
|
+
// which a caller cannot tell apart from a bug in this library (BL-4-001-B).
|
|
1126
|
+
//
|
|
1127
|
+
// Absent and null are the same thing, as they are to Go's encoding/json.
|
|
843
1128
|
|
|
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();
|
|
1129
|
+
type Doc = Record<string, unknown>;
|
|
1130
|
+
|
|
1131
|
+
function requireObject(value: unknown, path: string): Doc {
|
|
1132
|
+
if (value === undefined || value === null) throw new VerificationError(`${path} is missing`);
|
|
1133
|
+
if (typeof value !== "object" || Array.isArray(value)) {
|
|
1134
|
+
throw new VerificationError(`${path} is not an object`);
|
|
856
1135
|
}
|
|
857
|
-
return
|
|
1136
|
+
return value as Doc;
|
|
858
1137
|
}
|
|
859
1138
|
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
if (Array.isArray(value)) {
|
|
863
|
-
return new Uint8Array(value as number[]);
|
|
864
|
-
}
|
|
865
|
-
const str = value as string;
|
|
866
|
-
if (/^[0-9a-fA-F]+$/.test(str)) {
|
|
867
|
-
const raw = hexToBytes(str);
|
|
868
|
-
if (raw.length === 64) return raw;
|
|
869
|
-
}
|
|
870
|
-
return base64ToBytes(str);
|
|
1139
|
+
function objectAt(parent: Doc, key: string, path: string): Doc {
|
|
1140
|
+
return requireObject(parent[key], path);
|
|
871
1141
|
}
|
|
872
1142
|
|
|
873
|
-
|
|
874
|
-
|
|
1143
|
+
function stringAt(parent: Doc, key: string, path: string): string {
|
|
1144
|
+
const value = parent[key];
|
|
1145
|
+
if (value === undefined || value === null) throw new VerificationError(`${path} is missing`);
|
|
1146
|
+
if (typeof value !== "string") throw new VerificationError(`${path} is not a string`);
|
|
1147
|
+
return value;
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
function optionalStringAt(parent: Doc, key: string, path: string): string | undefined {
|
|
1151
|
+
const value = parent[key];
|
|
1152
|
+
if (value === undefined || value === null) return undefined;
|
|
1153
|
+
if (typeof value !== "string") throw new VerificationError(`${path} is not a string`);
|
|
1154
|
+
return value;
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
/**
|
|
1158
|
+
* An array field. Absent and null read as empty: Go leaves a nil slice.
|
|
1159
|
+
*
|
|
1160
|
+
* Returned dense. JSON cannot express a hole, but a caller building the object
|
|
1161
|
+
* in JavaScript can, and `.map` skips holes — so one reached the Merkle code as
|
|
1162
|
+
* `undefined` and threw a TypeError. Array.from reads a hole as undefined,
|
|
1163
|
+
* which the element's own reader then refuses as missing.
|
|
1164
|
+
*/
|
|
1165
|
+
function arrayAt(parent: Doc, key: string, path: string): unknown[] {
|
|
1166
|
+
const value = parent[key];
|
|
1167
|
+
if (value === undefined || value === null) return [];
|
|
1168
|
+
if (!Array.isArray(value)) throw new VerificationError(`${path} is not an array`);
|
|
1169
|
+
return Array.from(value);
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
const HEX_TEXT_RE = /^(?:[0-9a-fA-F]{2})*$/;
|
|
1173
|
+
const BASE64_TEXT_RE = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
|
|
1174
|
+
|
|
1175
|
+
/**
|
|
1176
|
+
* Decode a fixed-size byte field — a hash, a key, a signature.
|
|
1177
|
+
*
|
|
1178
|
+
* Go marshals `[N]byte` as an array of numbers; hand-written documents carry
|
|
1179
|
+
* hex or standard base64. A well-formed value of the right size is never
|
|
1180
|
+
* ambiguous between the two text forms, because base64 of 32 or 64 bytes ends
|
|
1181
|
+
* in padding hex cannot contain.
|
|
1182
|
+
*
|
|
1183
|
+
* Everything else is refused here, by name: a number, an array element that is
|
|
1184
|
+
* not a byte, text that is neither encoding, the wrong length. Decoding it
|
|
1185
|
+
* leniently instead wrapped 300 to 44 inside a Uint8Array, produced bytes no
|
|
1186
|
+
* signature covers and blamed the signature — or threw from inside atob.
|
|
1187
|
+
*/
|
|
1188
|
+
function decodeFixed(value: unknown, path: string, length: number): Uint8Array {
|
|
1189
|
+
if (value === undefined || value === null) throw new VerificationError(`${path} is missing`);
|
|
1190
|
+
let raw: Uint8Array;
|
|
875
1191
|
if (Array.isArray(value)) {
|
|
876
|
-
|
|
1192
|
+
if (!value.every((b) => Number.isInteger(b) && b >= 0 && b <= 255)) {
|
|
1193
|
+
throw new VerificationError(`${path} is not an array of bytes`);
|
|
1194
|
+
}
|
|
1195
|
+
raw = new Uint8Array(value as number[]);
|
|
1196
|
+
} else if (typeof value === "string") {
|
|
1197
|
+
if (HEX_TEXT_RE.test(value)) raw = hexToBytes(value);
|
|
1198
|
+
else if (BASE64_TEXT_RE.test(value)) raw = base64ToBytes(value);
|
|
1199
|
+
else throw new VerificationError(`${path} is neither hex nor base64`);
|
|
1200
|
+
} else {
|
|
1201
|
+
throw new VerificationError(`${path} is not a byte string`);
|
|
877
1202
|
}
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
return hexToBytes(str);
|
|
1203
|
+
if (raw.length !== length) {
|
|
1204
|
+
throw new VerificationError(`${path} is ${raw.length} bytes, expected ${length}`);
|
|
881
1205
|
}
|
|
882
|
-
return
|
|
1206
|
+
return raw;
|
|
883
1207
|
}
|
|
884
1208
|
|
|
885
1209
|
/** Go's zero time.Time — what encoding/json leaves in a non-pointer time.Time
|
|
@@ -912,13 +1236,16 @@ const RFC3339_RE =
|
|
|
912
1236
|
* Exported from this module so the timestamp_vectors.json suite can diff it
|
|
913
1237
|
* against Go directly. It is deliberately not re-exported from index.ts, so it
|
|
914
1238
|
* is not part of the published package surface.
|
|
1239
|
+
*
|
|
1240
|
+
* `field` names the value in a refusal. It defaults to the word these messages
|
|
1241
|
+
* always used, for callers normalizing a time that is not a field of a document.
|
|
915
1242
|
*/
|
|
916
|
-
export function formatTimestamp(ts: unknown): string {
|
|
1243
|
+
export function formatTimestamp(ts: unknown, field = "timestamp"): string {
|
|
917
1244
|
if (ts === null || ts === undefined) return ZERO_INSTANT;
|
|
918
1245
|
if (typeof ts !== "string") {
|
|
919
|
-
throw new VerificationError(
|
|
1246
|
+
throw new VerificationError(`${field} is not a string: ${JSON.stringify(ts)}`);
|
|
920
1247
|
}
|
|
921
|
-
return formatEpochSeconds(parseRfc3339(ts));
|
|
1248
|
+
return formatEpochSeconds(parseRfc3339(ts, field));
|
|
922
1249
|
}
|
|
923
1250
|
|
|
924
1251
|
/** Parse a strict RFC 3339 timestamp to seconds since 1970-01-01T00:00:00Z.
|
|
@@ -930,19 +1257,19 @@ export function formatTimestamp(ts: unknown): string {
|
|
|
930
1257
|
* `+24:00` and `+00:60` parse, while `+25:00` and `+00:61` do not. Sub-second
|
|
931
1258
|
* digits are dropped, matching Format's truncation.
|
|
932
1259
|
*/
|
|
933
|
-
function parseRfc3339(ts: string): number {
|
|
1260
|
+
function parseRfc3339(ts: string, field = "timestamp"): number {
|
|
934
1261
|
const m = RFC3339_RE.exec(ts);
|
|
935
|
-
if (m === null) throw new VerificationError(
|
|
1262
|
+
if (m === null) throw new VerificationError(`${field} is not RFC 3339: ${ts}`);
|
|
936
1263
|
const [year, month, day, hour, minute, second] = m
|
|
937
1264
|
.slice(1, 7)
|
|
938
1265
|
.map((v) => Number.parseInt(v!, 10)) as [number, number, number, number, number, number];
|
|
939
1266
|
|
|
940
|
-
if (month < 1 || month > 12) throw new VerificationError(
|
|
1267
|
+
if (month < 1 || month > 12) throw new VerificationError(`${field} month out of range: ${ts}`);
|
|
941
1268
|
if (day < 1 || day > daysInMonth(year, month)) {
|
|
942
|
-
throw new VerificationError(
|
|
1269
|
+
throw new VerificationError(`${field} day out of range: ${ts}`);
|
|
943
1270
|
}
|
|
944
1271
|
if (hour > 23 || minute > 59 || second > 59) {
|
|
945
|
-
throw new VerificationError(
|
|
1272
|
+
throw new VerificationError(`${field} time of day out of range: ${ts}`);
|
|
946
1273
|
}
|
|
947
1274
|
|
|
948
1275
|
let offset = 0;
|
|
@@ -950,8 +1277,8 @@ function parseRfc3339(ts: string): number {
|
|
|
950
1277
|
if (sign !== undefined) {
|
|
951
1278
|
const offHour = Number.parseInt(m[8]!, 10);
|
|
952
1279
|
const offMin = Number.parseInt(m[9]!, 10);
|
|
953
|
-
if (offHour > 24) throw new VerificationError(
|
|
954
|
-
if (offMin > 60) throw new VerificationError(
|
|
1280
|
+
if (offHour > 24) throw new VerificationError(`${field} zone offset hour out of range: ${ts}`);
|
|
1281
|
+
if (offMin > 60) throw new VerificationError(`${field} zone offset minute out of range: ${ts}`);
|
|
955
1282
|
offset = (offHour * 3600 + offMin * 60) * (sign === "-" ? -1 : 1);
|
|
956
1283
|
}
|
|
957
1284
|
|
|
@@ -1051,17 +1378,18 @@ function buildAttestationPayload(
|
|
|
1051
1378
|
// payload and call every genuine v8 record a forgery.
|
|
1052
1379
|
const coversMeasured = signatureCoversMeasuredTransport(certFormatVersion);
|
|
1053
1380
|
const coversRecoverable = signatureCoversRecoverableState(certFormatVersion);
|
|
1054
|
-
const systems = (att.systems as Record<string, unknown>[]).map((s) => {
|
|
1381
|
+
const systems = (att.systems as Record<string, unknown>[]).map((s, i) => {
|
|
1055
1382
|
const sys: Record<string, unknown> = {
|
|
1056
1383
|
canonical_version: (s.canonical_version as string | null) ?? null,
|
|
1057
1384
|
connector_type: s.connector_type as string,
|
|
1058
1385
|
hash_scope: s.hash_scope as string,
|
|
1059
|
-
merkle_root:
|
|
1060
|
-
|
|
1061
|
-
|
|
1386
|
+
merkle_root:
|
|
1387
|
+
s.merkle_root == null
|
|
1388
|
+
? null
|
|
1389
|
+
: bytesToHex(decodeFixed(s.merkle_root, `systems[${i}].merkle_root`, 32)),
|
|
1062
1390
|
// The system's own observation time, not the envelope's.
|
|
1063
|
-
observed_at: formatTimestamp(s.observed_at),
|
|
1064
|
-
query_hash:
|
|
1391
|
+
observed_at: formatTimestamp(s.observed_at, `systems[${i}].attested_at`),
|
|
1392
|
+
query_hash: bytesToHex(decodeFixed(s.query_hash, `systems[${i}].query_hash`, 32)),
|
|
1065
1393
|
record_count: s.record_count as number,
|
|
1066
1394
|
system_id: s.system_id as string,
|
|
1067
1395
|
system_name: s.system_name as string,
|
|
@@ -1095,7 +1423,7 @@ function buildAttestationPayload(
|
|
|
1095
1423
|
attested_at: attestedAt,
|
|
1096
1424
|
payload_type: attestationPayloadType(certFormatVersion),
|
|
1097
1425
|
proof_mode: att.proof_mode as string,
|
|
1098
|
-
subject_hash:
|
|
1426
|
+
subject_hash: bytesToHex(decodeFixed(subject.identifier_hash, "subject.identifier_hash", 32)),
|
|
1099
1427
|
systems,
|
|
1100
1428
|
};
|
|
1101
1429
|
return canonicalJson(payload);
|
|
@@ -1161,9 +1489,9 @@ function attestationPayloadType(version: string): string {
|
|
|
1161
1489
|
}
|
|
1162
1490
|
|
|
1163
1491
|
function buildCertificatePayload(cert: Record<string, unknown>): Uint8Array {
|
|
1164
|
-
const att = cert
|
|
1165
|
-
const issuer = cert
|
|
1166
|
-
const subject = cert
|
|
1492
|
+
const att = objectAt(cert, "attestation", "attestation");
|
|
1493
|
+
const issuer = objectAt(cert, "issuer", "issuer");
|
|
1494
|
+
const subject = objectAt(cert, "subject", "subject");
|
|
1167
1495
|
|
|
1168
1496
|
// One list, keyed by system_id (ADR-016 §2). v2 signed an attestation list
|
|
1169
1497
|
// and a verification list joined only on the human-editable system_name,
|
|
@@ -1172,19 +1500,27 @@ function buildCertificatePayload(cert: Record<string, unknown>): Uint8Array {
|
|
|
1172
1500
|
const coversMeasured = signatureCoversMeasuredTransport(version);
|
|
1173
1501
|
const coversRecoverable = signatureCoversRecoverableState(version);
|
|
1174
1502
|
const coversAuthorization = signatureCoversAuthorization(version);
|
|
1175
|
-
const systems = (
|
|
1503
|
+
const systems = arrayAt(cert, "systems", "systems").map((entry, i) => {
|
|
1504
|
+
const where = `systems[${i}]`;
|
|
1505
|
+
const s = requireObject(entry, where);
|
|
1176
1506
|
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
|
-
|
|
1507
|
+
attested_at: formatTimestamp(s.attested_at, `${where}.attested_at`),
|
|
1508
|
+
attested_count: requireUint(s.attested_count, `${where}.attested_count`),
|
|
1509
|
+
canonical_version:
|
|
1510
|
+
optionalStringAt(s, "canonical_version", `${where}.canonical_version`) ?? null,
|
|
1511
|
+
connector_type: stringAt(s, "connector_type", `${where}.connector_type`),
|
|
1512
|
+
hash_scope: stringAt(s, "hash_scope", `${where}.hash_scope`),
|
|
1513
|
+
// Absent is a system with no Merkle root; anything present must be one.
|
|
1514
|
+
// Go's *[32]byte is nil or 32 bytes, never "" or [].
|
|
1515
|
+
merkle_root:
|
|
1516
|
+
s.merkle_root == null
|
|
1517
|
+
? null
|
|
1518
|
+
: bytesToHex(decodeFixed(s.merkle_root, `${where}.merkle_root`, 32)),
|
|
1519
|
+
query_hash: bytesToHex(decodeFixed(s.query_hash, `${where}.query_hash`, 32)),
|
|
1520
|
+
system_id: stringAt(s, "system_id", `${where}.system_id`),
|
|
1521
|
+
system_name: stringAt(s, "system_name", `${where}.system_name`),
|
|
1522
|
+
verified_at: formatTimestamp(s.verified_at, `${where}.verified_at`),
|
|
1523
|
+
verified_count: requireUint(s.verified_count, `${where}.verified_count`),
|
|
1188
1524
|
};
|
|
1189
1525
|
if (coversMeasured) {
|
|
1190
1526
|
sys.read_only_enforcement = requireMeasured(
|
|
@@ -1219,9 +1555,11 @@ function buildCertificatePayload(cert: Record<string, unknown>): Uint8Array {
|
|
|
1219
1555
|
// The attestation block carries no system list of its own: the merged list
|
|
1220
1556
|
// above is a superset of it. verification_signature is gone entirely.
|
|
1221
1557
|
const attObj = {
|
|
1222
|
-
attestation_signature:
|
|
1223
|
-
|
|
1224
|
-
|
|
1558
|
+
attestation_signature: bytesToHex(
|
|
1559
|
+
decodeFixed(att.attestation_signature, "attestation.attestation_signature", 64),
|
|
1560
|
+
),
|
|
1561
|
+
attested_at: formatTimestamp(att.attested_at, "attestation.attested_at"),
|
|
1562
|
+
proof_mode: stringAt(att, "proof_mode", "attestation.proof_mode"),
|
|
1225
1563
|
};
|
|
1226
1564
|
|
|
1227
1565
|
// Fields added after v3 are gated on the certificate's OWN version. A v3
|
|
@@ -1235,9 +1573,9 @@ function buildCertificatePayload(cert: Record<string, unknown>): Uint8Array {
|
|
|
1235
1573
|
const isV4Plus = formatAtLeast(version, FORMAT_VERSION_V4);
|
|
1236
1574
|
|
|
1237
1575
|
const issuerObj: Record<string, unknown> = {
|
|
1238
|
-
key_id: issuer.key_id
|
|
1239
|
-
name: issuer.name
|
|
1240
|
-
public_key:
|
|
1576
|
+
key_id: stringAt(issuer, "key_id", "issuer.key_id"),
|
|
1577
|
+
name: stringAt(issuer, "name", "issuer.name"),
|
|
1578
|
+
public_key: bytesToHex(decodeFixed(issuer.public_key, "issuer.public_key", 32)),
|
|
1241
1579
|
};
|
|
1242
1580
|
// v7 signs the algorithm, and does so unconditionally: unlike legal_entity
|
|
1243
1581
|
// and enclave_pcr0, "which scheme signed this" is never unknown to a signer,
|
|
@@ -1250,26 +1588,31 @@ function buildCertificatePayload(cert: Record<string, unknown>): Uint8Array {
|
|
|
1250
1588
|
version,
|
|
1251
1589
|
);
|
|
1252
1590
|
}
|
|
1253
|
-
|
|
1254
|
-
|
|
1591
|
+
// A non-string here used to be dropped silently, so a junk value added to a
|
|
1592
|
+
// record that never carried one still verified. Python refused it; so does
|
|
1593
|
+
// Go, at parse. Now this does too.
|
|
1594
|
+
if (isV4Plus) {
|
|
1595
|
+
const legalEntity = optionalStringAt(issuer, "legal_entity", "issuer.legal_entity");
|
|
1596
|
+
if (legalEntity) issuerObj.legal_entity = legalEntity;
|
|
1255
1597
|
}
|
|
1256
1598
|
// Signed from v5, and omitted when absent or empty: a build with no
|
|
1257
1599
|
// 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;
|
|
1600
|
+
if (signatureCoversEnclavePcr0(version)) {
|
|
1601
|
+
const pcr0 = optionalStringAt(issuer, "enclave_pcr0", "issuer.enclave_pcr0");
|
|
1602
|
+
if (pcr0) issuerObj.enclave_pcr0 = pcr0;
|
|
1264
1603
|
}
|
|
1265
1604
|
|
|
1266
1605
|
// v5 drops identifier_type_hint: signed, but always the constant "custom",
|
|
1267
1606
|
// so it was never evidence. v3 and v4 keep it or they stop verifying.
|
|
1268
1607
|
const subjectObj: Record<string, unknown> = {
|
|
1269
|
-
identifier_hash:
|
|
1608
|
+
identifier_hash: bytesToHex(decodeFixed(subject.identifier_hash, "subject.identifier_hash", 32)),
|
|
1270
1609
|
};
|
|
1271
1610
|
if (!isV5Plus) {
|
|
1272
|
-
subjectObj.identifier_type_hint =
|
|
1611
|
+
subjectObj.identifier_type_hint = stringAt(
|
|
1612
|
+
subject,
|
|
1613
|
+
"identifier_type_hint",
|
|
1614
|
+
"subject.identifier_type_hint",
|
|
1615
|
+
);
|
|
1273
1616
|
}
|
|
1274
1617
|
|
|
1275
1618
|
// status and revocation are deliberately absent (ADR-016 §3): a signature
|
|
@@ -1277,20 +1620,20 @@ function buildCertificatePayload(cert: Record<string, unknown>): Uint8Array {
|
|
|
1277
1620
|
// travels as a separate short-lived signed status statement.
|
|
1278
1621
|
const payload: Record<string, unknown> = {
|
|
1279
1622
|
attestation: attObj,
|
|
1280
|
-
attestation_id: cert
|
|
1281
|
-
certificate_format_version:
|
|
1282
|
-
certificate_id: cert
|
|
1283
|
-
issued_at: formatTimestamp(cert.issued_at),
|
|
1623
|
+
attestation_id: stringAt(cert, "attestation_id", "attestation_id"),
|
|
1624
|
+
certificate_format_version: version,
|
|
1625
|
+
certificate_id: stringAt(cert, "certificate_id", "certificate_id"),
|
|
1626
|
+
issued_at: formatTimestamp(cert.issued_at, "issued_at"),
|
|
1284
1627
|
issuer: issuerObj,
|
|
1285
1628
|
payload_type: certificatePayloadType(version),
|
|
1286
1629
|
subject: subjectObj,
|
|
1287
1630
|
systems,
|
|
1288
1631
|
};
|
|
1289
1632
|
if (isV4Plus && cert.scope != null) {
|
|
1290
|
-
const scope = cert
|
|
1633
|
+
const scope = objectAt(cert, "scope", "scope");
|
|
1291
1634
|
payload.scope = {
|
|
1292
|
-
text_sha256:
|
|
1293
|
-
version: scope.version
|
|
1635
|
+
text_sha256: bytesToHex(decodeFixed(scope.text_sha256, "scope.text_sha256", 32)),
|
|
1636
|
+
version: stringAt(scope, "version", "scope.version"),
|
|
1294
1637
|
};
|
|
1295
1638
|
}
|
|
1296
1639
|
return canonicalJson(payload);
|
|
@@ -1307,7 +1650,7 @@ function buildLogLeafPayload(
|
|
|
1307
1650
|
throw new VerificationError(`invalid log entry_type: ${String(entryType)}`);
|
|
1308
1651
|
}
|
|
1309
1652
|
const payload = {
|
|
1310
|
-
appended_at: formatTimestamp(appendedAt),
|
|
1653
|
+
appended_at: formatTimestamp(appendedAt, "transparency.appended_at"),
|
|
1311
1654
|
certificate_hash: bytesToHex(certificateHash),
|
|
1312
1655
|
certificate_id: certificateId,
|
|
1313
1656
|
entry_type: entryType,
|
|
@@ -1358,13 +1701,13 @@ function attestationSystems(cert: Record<string, unknown>): Record<string, unkno
|
|
|
1358
1701
|
* between two modules of this package, not published surface.
|
|
1359
1702
|
*/
|
|
1360
1703
|
export function buildTreeHeadPayload(head: Record<string, unknown>): Uint8Array {
|
|
1361
|
-
const logId = head.log_id;
|
|
1362
|
-
const named =
|
|
1704
|
+
const logId = optionalStringAt(head, "log_id", "signed_tree_head.log_id");
|
|
1705
|
+
const named = logId !== undefined && logId !== "";
|
|
1363
1706
|
const payload: Record<string, unknown> = {
|
|
1364
1707
|
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
|
|
1708
|
+
root_hash: bytesToHex(decodeFixed(head.root_hash, "signed_tree_head.root_hash", 32)),
|
|
1709
|
+
timestamp: formatTimestamp(head.timestamp, "signed_tree_head.timestamp"),
|
|
1710
|
+
tree_size: requireUint(head.tree_size ?? 0, "signed_tree_head.tree_size"),
|
|
1368
1711
|
};
|
|
1369
1712
|
if (named) payload.log_id = logId;
|
|
1370
1713
|
return canonicalJson(payload);
|
|
@@ -1450,7 +1793,12 @@ async function chainInclusion(
|
|
|
1450
1793
|
}
|
|
1451
1794
|
|
|
1452
1795
|
function isPow2(n: number): boolean {
|
|
1453
|
-
|
|
1796
|
+
// Arithmetic, not `n & (n - 1)` (R12-2): JS bitwise operators coerce to 32-bit
|
|
1797
|
+
// signed integers, so for a tree size >= 2^31 the bit test gives the wrong
|
|
1798
|
+
// answer. This holds for every safe integer.
|
|
1799
|
+
if (n < 1) return false;
|
|
1800
|
+
while (n % 2 === 0) n /= 2;
|
|
1801
|
+
return n === 1;
|
|
1454
1802
|
}
|
|
1455
1803
|
|
|
1456
1804
|
/** Verify a Merkle consistency proof (RFC 6962 Section 2.1.4).
|
|
@@ -1487,9 +1835,14 @@ export async function verifyConsistency(
|
|
|
1487
1835
|
let fn = oldSize - 1;
|
|
1488
1836
|
let sn = newSize - 1;
|
|
1489
1837
|
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1838
|
+
// Arithmetic throughout (R12-2). `& 1` and `>>= 1` coerce to 32-bit signed
|
|
1839
|
+
// integers, so for tree sizes >= 2^31 fn and sn are truncated and this
|
|
1840
|
+
// consistency check silently returned the wrong answer (CONFIRMED where Go
|
|
1841
|
+
// says INCONSISTENT). `% 2` and Math.floor(/2) are exact for every safe
|
|
1842
|
+
// integer, which requireUint already bounds the inputs to.
|
|
1843
|
+
while (fn % 2 === 1) {
|
|
1844
|
+
fn = Math.floor(fn / 2);
|
|
1845
|
+
sn = Math.floor(sn / 2);
|
|
1493
1846
|
}
|
|
1494
1847
|
|
|
1495
1848
|
// Drive the walk from the tree, not from the proof's length. Looping on
|
|
@@ -1504,19 +1857,19 @@ export async function verifyConsistency(
|
|
|
1504
1857
|
const c = proof[pIdx]!;
|
|
1505
1858
|
pIdx++;
|
|
1506
1859
|
|
|
1507
|
-
if (
|
|
1860
|
+
if (fn % 2 === 1 || fn === sn) {
|
|
1508
1861
|
fr = await hashNode(crypto, c, fr);
|
|
1509
1862
|
sr = await hashNode(crypto, c, sr);
|
|
1510
|
-
while (fn !== 0 &&
|
|
1511
|
-
fn
|
|
1512
|
-
sn
|
|
1863
|
+
while (fn !== 0 && fn % 2 === 0) {
|
|
1864
|
+
fn = Math.floor(fn / 2);
|
|
1865
|
+
sn = Math.floor(sn / 2);
|
|
1513
1866
|
}
|
|
1514
1867
|
} else {
|
|
1515
1868
|
sr = await hashNode(crypto, sr, c);
|
|
1516
1869
|
}
|
|
1517
1870
|
|
|
1518
|
-
fn
|
|
1519
|
-
sn
|
|
1871
|
+
fn = Math.floor(fn / 2);
|
|
1872
|
+
sn = Math.floor(sn / 2);
|
|
1520
1873
|
}
|
|
1521
1874
|
|
|
1522
1875
|
return pIdx === proof.length && bytesEqual(fr, oldRoot) && bytesEqual(sr, newRoot);
|
|
@@ -1535,21 +1888,24 @@ export function buildCertificateStatusPayload(
|
|
|
1535
1888
|
): Uint8Array {
|
|
1536
1889
|
const payload: Record<string, unknown> = {
|
|
1537
1890
|
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
|
|
1891
|
+
certificate_id: stringAt(stmt, "certificate_id", "status.certificate_id"),
|
|
1892
|
+
statement_expires_at: formatTimestamp(stmt.statement_expires_at, "status.statement_expires_at"),
|
|
1893
|
+
statement_issued_at: formatTimestamp(stmt.statement_issued_at, "status.statement_issued_at"),
|
|
1894
|
+
status: stringAt(stmt, "status", "status.status"),
|
|
1895
|
+
sth_root_hash: bytesToHex(decodeFixed(stmt.sth_root_hash, "status.sth_root_hash", 32)),
|
|
1896
|
+
sth_tree_size: requireUint(stmt.sth_tree_size ?? 0, "status.sth_tree_size"),
|
|
1544
1897
|
};
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1898
|
+
const replacement = optionalStringAt(
|
|
1899
|
+
stmt,
|
|
1900
|
+
"replacement_certificate_id",
|
|
1901
|
+
"status.replacement_certificate_id",
|
|
1902
|
+
);
|
|
1903
|
+
if (replacement !== undefined) payload.replacement_certificate_id = replacement;
|
|
1548
1904
|
if (stmt.revocation_log_index != null) {
|
|
1549
|
-
payload.revocation_log_index = stmt.revocation_log_index
|
|
1905
|
+
payload.revocation_log_index = requireUint(stmt.revocation_log_index, "status.revocation_log_index");
|
|
1550
1906
|
}
|
|
1551
1907
|
if (stmt.revoked_at != null) {
|
|
1552
|
-
payload.revoked_at = formatTimestamp(stmt.revoked_at);
|
|
1908
|
+
payload.revoked_at = formatTimestamp(stmt.revoked_at, "status.revoked_at");
|
|
1553
1909
|
}
|
|
1554
1910
|
return canonicalJson(payload);
|
|
1555
1911
|
}
|
|
@@ -1582,7 +1938,7 @@ export function buildKeyListPayload(doc: Record<string, unknown>): Uint8Array {
|
|
|
1582
1938
|
keys: entries,
|
|
1583
1939
|
statement_expires_at: formatTimestamp(doc.statement_expires_at),
|
|
1584
1940
|
statement_issued_at: formatTimestamp(doc.statement_issued_at),
|
|
1585
|
-
sth_root_hash:
|
|
1941
|
+
sth_root_hash: bytesToHex(decodeFixed(doc.sth_root_hash, "sth_root_hash", 32)),
|
|
1586
1942
|
sth_tree_size: doc.sth_tree_size as number,
|
|
1587
1943
|
};
|
|
1588
1944
|
return canonicalJson(payload);
|
|
@@ -1610,6 +1966,8 @@ export const VALID_REVOCATION_UNKNOWN = "VALID_REVOCATION_UNKNOWN";
|
|
|
1610
1966
|
*
|
|
1611
1967
|
* The third row is the point: `verifyCertificate` answers VALID there, which
|
|
1612
1968
|
* reads as "not revoked" and is not something it checked.
|
|
1969
|
+
*
|
|
1970
|
+
* `options` is passed to {@link verifyCertificate} unchanged.
|
|
1613
1971
|
*/
|
|
1614
1972
|
export async function verifyCertificateWithStatus(
|
|
1615
1973
|
crypto: CryptoOps,
|
|
@@ -1617,18 +1975,49 @@ export async function verifyCertificateWithStatus(
|
|
|
1617
1975
|
publicKeys: Map<string, PublicKeyInfo>,
|
|
1618
1976
|
status?: Record<string, unknown> | null,
|
|
1619
1977
|
now?: Date,
|
|
1978
|
+
options?: VerifyOptions,
|
|
1620
1979
|
): Promise<string> {
|
|
1621
1980
|
// A refusal throws out of verifyCertificate, so `base` is VALID or one of the
|
|
1622
1981
|
// two soft verdicts. Returning a soft verdict here skipped every check below
|
|
1623
1982
|
// it: a revoked certificate signed by a compromised-later key answered
|
|
1624
1983
|
// VALID_KEY_COMPROMISED_LATER and its statement was never authenticated. The
|
|
1625
1984
|
// verdict is held instead, and resolved against what the statement says.
|
|
1626
|
-
const base = await verifyCertificate(crypto, certificate, publicKeys);
|
|
1985
|
+
const base = await verifyCertificate(crypto, certificate, publicKeys, options);
|
|
1627
1986
|
if (status == null) {
|
|
1628
1987
|
return resolveWithBase(base, VALID_REVOCATION_UNKNOWN);
|
|
1629
1988
|
}
|
|
1630
1989
|
|
|
1631
|
-
|
|
1990
|
+
return resolveWithBase(
|
|
1991
|
+
base,
|
|
1992
|
+
await verifyStatusStatement(
|
|
1993
|
+
crypto, status, publicKeys, (certificate as Record<string, unknown>).certificate_id, now,
|
|
1994
|
+
),
|
|
1995
|
+
);
|
|
1996
|
+
}
|
|
1997
|
+
|
|
1998
|
+
/**
|
|
1999
|
+
* The statement half of {@link verifyCertificateWithStatus}: authenticate a
|
|
2000
|
+
* status statement about `certificateId` against the published keys, and read
|
|
2001
|
+
* it at `now`. The /verify page's QR lookup has a record id and no record, and
|
|
2002
|
+
* uses this so it shows what a signed, fresh statement says, never what the
|
|
2003
|
+
* status endpoint claims.
|
|
2004
|
+
*
|
|
2005
|
+
* | input | result |
|
|
2006
|
+
* |---|---|
|
|
2007
|
+
* | fresh, REVOKED | throws VerificationError, code CERTIFICATE_REVOKED |
|
|
2008
|
+
* | fresh, ACTIVE | the statement key's verdict: `"VALID"`, or a soft one |
|
|
2009
|
+
* | stale, or a status this SDK does not know | `"VALID_REVOCATION_UNKNOWN"` |
|
|
2010
|
+
* | unknown or unusable key, bad signature, another certificate | throws VerificationError |
|
|
2011
|
+
*/
|
|
2012
|
+
export async function verifyStatusStatement(
|
|
2013
|
+
crypto: CryptoOps,
|
|
2014
|
+
status: Record<string, unknown>,
|
|
2015
|
+
publicKeys: Map<string, PublicKeyInfo>,
|
|
2016
|
+
certificateId: unknown,
|
|
2017
|
+
now?: Date,
|
|
2018
|
+
): Promise<string> {
|
|
2019
|
+
requireObject(status, "status");
|
|
2020
|
+
const keyId = optionalStringAt(status, "key_id", "status.key_id");
|
|
1632
2021
|
const info = keyId ? publicKeys.get(keyId) : undefined;
|
|
1633
2022
|
if (!info) {
|
|
1634
2023
|
throw new VerificationError(`status statement signed by unknown key: ${keyId}`);
|
|
@@ -1656,10 +2045,10 @@ export async function verifyCertificateWithStatus(
|
|
|
1656
2045
|
);
|
|
1657
2046
|
|
|
1658
2047
|
const payload = buildCertificateStatusPayload(status);
|
|
1659
|
-
//
|
|
2048
|
+
// decodeFixed, not hexToBytes: signatures arrive as hex, base64 or a byte
|
|
1660
2049
|
// array depending on the producer, and every other signature on this path
|
|
1661
2050
|
// goes through the same decoder.
|
|
1662
|
-
const sig =
|
|
2051
|
+
const sig = decodeFixed(status.signature, "status.signature", 64);
|
|
1663
2052
|
if (!(await crypto.ed25519Verify(info.keyBytes, payload, sig))) {
|
|
1664
2053
|
throw new VerificationError("status statement signature is invalid");
|
|
1665
2054
|
}
|
|
@@ -1677,8 +2066,7 @@ export async function verifyCertificateWithStatus(
|
|
|
1677
2066
|
// Every signature still verified; the binding was the only forged part.
|
|
1678
2067
|
// core/verify.go never had the fallback, so Go said STATUS_STATEMENT_MISMATCH
|
|
1679
2068
|
// while both SDKs and the browser bundle said VALID.
|
|
1680
|
-
|
|
1681
|
-
if (status.certificate_id !== certId) {
|
|
2069
|
+
if (status.certificate_id !== certificateId) {
|
|
1682
2070
|
throw new VerificationError("status statement is about a different certificate");
|
|
1683
2071
|
}
|
|
1684
2072
|
|
|
@@ -1689,7 +2077,7 @@ export async function verifyCertificateWithStatus(
|
|
|
1689
2077
|
if (at < issued || at >= expires) {
|
|
1690
2078
|
// Stale is not a weaker answer, it is no answer — including for a REVOKED
|
|
1691
2079
|
// statement, which must never decay into VALID.
|
|
1692
|
-
return
|
|
2080
|
+
return VALID_REVOCATION_UNKNOWN;
|
|
1693
2081
|
}
|
|
1694
2082
|
|
|
1695
2083
|
if (status.status === "REVOKED") {
|
|
@@ -1705,12 +2093,12 @@ export async function verifyCertificateWithStatus(
|
|
|
1705
2093
|
// case — was reported as revocation CHECKED AND PASSED. Mirrors
|
|
1706
2094
|
// core.ValidCertificateStatusValue.
|
|
1707
2095
|
if (!KNOWN_STATUS_VALUES.has(status.status as string)) {
|
|
1708
|
-
return
|
|
2096
|
+
return VALID_REVOCATION_UNKNOWN;
|
|
1709
2097
|
}
|
|
1710
2098
|
// A soft verdict on the status key is the result, not a footnote — but it is
|
|
1711
2099
|
// reported only once the statement has authenticated and been read, so it can
|
|
1712
2100
|
// neither speak for an unverified statement nor suppress a revocation.
|
|
1713
|
-
return
|
|
2101
|
+
return statusKeyVerdict;
|
|
1714
2102
|
}
|
|
1715
2103
|
|
|
1716
2104
|
/**
|