burnledger 0.4.0 → 0.6.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 +11 -4
- package/dist/cjs/anchor.d.ts +71 -0
- package/dist/cjs/anchor.d.ts.map +1 -0
- package/dist/cjs/anchor.js +279 -0
- package/dist/cjs/anchor.js.map +1 -0
- package/dist/cjs/client.d.ts +26 -2
- package/dist/cjs/client.d.ts.map +1 -1
- package/dist/cjs/client.js +27 -5
- package/dist/cjs/client.js.map +1 -1
- package/dist/cjs/enclave-seal.d.ts +101 -0
- package/dist/cjs/enclave-seal.d.ts.map +1 -0
- package/dist/cjs/enclave-seal.js +479 -0
- package/dist/cjs/enclave-seal.js.map +1 -0
- package/dist/cjs/errors.d.ts +42 -1
- package/dist/cjs/errors.d.ts.map +1 -1
- package/dist/cjs/errors.js +44 -2
- package/dist/cjs/errors.js.map +1 -1
- package/dist/cjs/http.d.ts +21 -1
- package/dist/cjs/http.d.ts.map +1 -1
- package/dist/cjs/http.js +76 -12
- package/dist/cjs/http.js.map +1 -1
- package/dist/cjs/index.browser.d.ts +2 -4
- package/dist/cjs/index.browser.d.ts.map +1 -1
- package/dist/cjs/index.browser.js.map +1 -1
- package/dist/cjs/index.d.ts +16 -11
- package/dist/cjs/index.d.ts.map +1 -1
- package/dist/cjs/index.js +20 -1
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/keys.d.ts +66 -0
- package/dist/cjs/keys.d.ts.map +1 -1
- package/dist/cjs/keys.js +128 -3
- package/dist/cjs/keys.js.map +1 -1
- package/dist/cjs/models.d.ts +41 -3
- package/dist/cjs/models.d.ts.map +1 -1
- package/dist/cjs/models.js +25 -4
- package/dist/cjs/models.js.map +1 -1
- package/dist/cjs/verify.d.ts +72 -6
- package/dist/cjs/verify.d.ts.map +1 -1
- package/dist/cjs/verify.js +392 -62
- package/dist/cjs/verify.js.map +1 -1
- package/dist/cjs/web-verifier.d.ts +54 -5
- package/dist/cjs/web-verifier.d.ts.map +1 -1
- package/dist/cjs/web-verifier.js +36 -3
- package/dist/cjs/web-verifier.js.map +1 -1
- package/dist/esm/anchor.d.ts +71 -0
- package/dist/esm/anchor.d.ts.map +1 -0
- package/dist/esm/anchor.js +275 -0
- package/dist/esm/anchor.js.map +1 -0
- package/dist/esm/cli.d.ts +45 -14
- package/dist/esm/cli.d.ts.map +1 -1
- package/dist/esm/cli.js +357 -50
- package/dist/esm/cli.js.map +1 -1
- package/dist/esm/client.d.ts +26 -2
- package/dist/esm/client.d.ts.map +1 -1
- package/dist/esm/client.js +27 -5
- package/dist/esm/client.js.map +1 -1
- package/dist/esm/enclave-seal.d.ts +101 -0
- package/dist/esm/enclave-seal.d.ts.map +1 -0
- package/dist/esm/enclave-seal.js +472 -0
- package/dist/esm/enclave-seal.js.map +1 -0
- package/dist/esm/errors.d.ts +42 -1
- package/dist/esm/errors.d.ts.map +1 -1
- package/dist/esm/errors.js +43 -1
- package/dist/esm/errors.js.map +1 -1
- package/dist/esm/http.d.ts +21 -1
- package/dist/esm/http.d.ts.map +1 -1
- package/dist/esm/http.js +75 -12
- package/dist/esm/http.js.map +1 -1
- package/dist/esm/index.browser.d.ts +2 -4
- package/dist/esm/index.browser.d.ts.map +1 -1
- package/dist/esm/index.browser.js.map +1 -1
- package/dist/esm/index.d.ts +16 -11
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +11 -2
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/keys.d.ts +66 -0
- package/dist/esm/keys.d.ts.map +1 -1
- package/dist/esm/keys.js +125 -3
- package/dist/esm/keys.js.map +1 -1
- package/dist/esm/models.d.ts +41 -3
- package/dist/esm/models.d.ts.map +1 -1
- package/dist/esm/models.js +24 -4
- package/dist/esm/models.js.map +1 -1
- package/dist/esm/verify.d.ts +72 -6
- package/dist/esm/verify.d.ts.map +1 -1
- package/dist/esm/verify.js +390 -63
- package/dist/esm/verify.js.map +1 -1
- package/dist/esm/web-verifier.d.ts +54 -5
- package/dist/esm/web-verifier.d.ts.map +1 -1
- package/dist/esm/web-verifier.js +40 -3
- package/dist/esm/web-verifier.js.map +1 -1
- package/package.json +1 -1
- package/src/anchor.ts +330 -0
- package/src/cli.ts +389 -54
- package/src/client.ts +46 -4
- package/src/enclave-seal.ts +582 -0
- package/src/errors.ts +47 -2
- package/src/http.ts +79 -12
- package/src/index.browser.ts +2 -2
- package/src/index.ts +28 -9
- package/src/keys.ts +176 -3
- package/src/models.ts +72 -7
- package/src/verify.ts +467 -72
- package/src/web-verifier.ts +58 -5
package/src/verify.ts
CHANGED
|
@@ -17,7 +17,12 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import type { CryptoOps } from "./crypto.js";
|
|
20
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
CERTIFICATE_REVOKED,
|
|
22
|
+
UNSUPPORTED_ALGORITHM,
|
|
23
|
+
UNSUPPORTED_FORMAT_VERSION,
|
|
24
|
+
VerificationError,
|
|
25
|
+
} from "./errors.js";
|
|
21
26
|
import type { TransparencyResult, VerificationResult } from "./models.js";
|
|
22
27
|
|
|
23
28
|
// ---------------------------------------------------------------------------
|
|
@@ -40,11 +45,75 @@ const PAYLOAD_TYPE_ATTESTATION_V5 = "burnledger.attestation.v5";
|
|
|
40
45
|
const PAYLOAD_TYPE_CERTIFICATE_V5 = "burnledger.certificate.v5";
|
|
41
46
|
const FORMAT_VERSION_V5 = "5.0";
|
|
42
47
|
const FORMAT_VERSION_V4 = "4.0";
|
|
48
|
+
// v6 renames the artifact and nothing else: the shape is byte-for-byte v5, and
|
|
49
|
+
// only the tag and the version string differ. It still takes its own tag, for
|
|
50
|
+
// the same reason v4 and v5 each took one -- a signature over a payload calling
|
|
51
|
+
// itself a verification record must not verify as one calling itself a
|
|
52
|
+
// certificate. Two names for one shape sharing a separator is exactly what a
|
|
53
|
+
// separator prevents.
|
|
54
|
+
const PAYLOAD_TYPE_ATTESTATION_V6 = "burnledger.attestation.v6";
|
|
55
|
+
const PAYLOAD_TYPE_VERIFICATION_RECORD_V6 = "burnledger.verification_record.v6";
|
|
56
|
+
const FORMAT_VERSION_V6 = "6.0";
|
|
57
|
+
// v7 changes the SHAPE, not just the name: every system entry gains
|
|
58
|
+
// read_only_enforcement and transport_security, and the issuer gains the
|
|
59
|
+
// algorithm identifier. Its tags move for the reason v4, v5 and v6 each moved
|
|
60
|
+
// theirs, and this time the bytes under them genuinely differ.
|
|
61
|
+
const PAYLOAD_TYPE_ATTESTATION_V7 = "burnledger.attestation.v7";
|
|
62
|
+
const PAYLOAD_TYPE_VERIFICATION_RECORD_V7 = "burnledger.verification_record.v7";
|
|
63
|
+
const FORMAT_VERSION_V7 = "7.0";
|
|
64
|
+
const FORMAT_VERSION_V3 = "3.0";
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The only signature scheme this SDK can check. A v7 record names its own
|
|
68
|
+
* algorithm, so a record naming anything else must be refused with its own
|
|
69
|
+
* code rather than fed to ed25519Verify and reported as a bad signature.
|
|
70
|
+
*/
|
|
71
|
+
const ALGORITHM_ED25519 = "ed25519";
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Every format this build can read, oldest first. Mirrors core.FormatVersions()
|
|
75
|
+
* in the Go source; the cross-language conformance corpus carries a fixture per
|
|
76
|
+
* version, so a version present in one language and missing here fails there.
|
|
77
|
+
*
|
|
78
|
+
* Frozen because `readonly` is erased at build time: it stops a TypeScript
|
|
79
|
+
* caller from writing to the array, and does nothing at all to the JavaScript
|
|
80
|
+
* this compiles to — which is what the browser bundle actually runs, where any
|
|
81
|
+
* script on the page could otherwise push a version onto it.
|
|
82
|
+
*/
|
|
83
|
+
export const KNOWN_FORMAT_VERSIONS: readonly string[] = Object.freeze([
|
|
84
|
+
FORMAT_VERSION_V3,
|
|
85
|
+
FORMAT_VERSION_V4,
|
|
86
|
+
FORMAT_VERSION_V5,
|
|
87
|
+
FORMAT_VERSION_V6,
|
|
88
|
+
FORMAT_VERSION_V7,
|
|
89
|
+
]);
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Returns the version, or throws if this SDK cannot read it.
|
|
93
|
+
*
|
|
94
|
+
* Rejects a non-string too: JSON carrying `6.0` as a NUMBER would compare
|
|
95
|
+
* unequal to every known version and silently take the oldest branch, which is
|
|
96
|
+
* the same trap as an unknown version wearing a different hat.
|
|
97
|
+
*/
|
|
98
|
+
function checkFormatVersion(version: unknown): string {
|
|
99
|
+
if (typeof version !== "string" || !KNOWN_FORMAT_VERSIONS.includes(version)) {
|
|
100
|
+
throw new VerificationError(
|
|
101
|
+
`unsupported certificate_format_version ${JSON.stringify(version)}; ` +
|
|
102
|
+
`this version of the SDK reads ${KNOWN_FORMAT_VERSIONS.join(", ")}. ` +
|
|
103
|
+
"The record may be genuine and simply newer than this library.",
|
|
104
|
+
UNSUPPORTED_FORMAT_VERSION,
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
return version;
|
|
108
|
+
}
|
|
43
109
|
const PAYLOAD_TYPE_TREE_HEAD = "burnledger.sth.v3";
|
|
110
|
+
// The domain for a tree head that names its log. See buildTreeHeadPayload.
|
|
111
|
+
const PAYLOAD_TYPE_TREE_HEAD_V7 = "burnledger.sth.v7";
|
|
44
112
|
const PAYLOAD_TYPE_LOG_LEAF = "burnledger.log_leaf.v3";
|
|
45
113
|
// There is deliberately no verification payload type: v3 produces those facts
|
|
46
114
|
// in the same enclave call that signs the certificate (ADR-016 §2).
|
|
47
115
|
const PAYLOAD_TYPE_CERTIFICATE_STATUS = "burnledger.certificate_status.v3";
|
|
116
|
+
const PAYLOAD_TYPE_KEY_LIST = "burnledger.key_list.v3";
|
|
48
117
|
|
|
49
118
|
export function hexToBytes(hex: string): Uint8Array {
|
|
50
119
|
const len = hex.length >>> 1;
|
|
@@ -137,16 +206,30 @@ export interface PublicKeyInfo {
|
|
|
137
206
|
readonly revoked: boolean;
|
|
138
207
|
}
|
|
139
208
|
|
|
209
|
+
/**
|
|
210
|
+
* The options publicKeyFromHex accepts, mirroring one entry of the published key
|
|
211
|
+
* set at `/.well-known/burnledger-keys`.
|
|
212
|
+
*
|
|
213
|
+
* Named, and exported, because it is restated by every entry point that wraps
|
|
214
|
+
* publicKeyFromHex — index.ts, index.browser.ts, web-verifier.ts. When the
|
|
215
|
+
* ADR-017 fields were added, two of the three were widened and the browser entry
|
|
216
|
+
* was not, so the dashboard could not express a compromised key through the API
|
|
217
|
+
* it imports and every key it built looked unconditionally active. A shared type
|
|
218
|
+
* is what stops the next field from being added to two places out of three.
|
|
219
|
+
*/
|
|
220
|
+
export interface PublicKeyOptions {
|
|
221
|
+
/** @deprecated Use keyStatus. Retained: true still means "unusable". */
|
|
222
|
+
revoked?: boolean;
|
|
223
|
+
keyStatus?: string;
|
|
224
|
+
notBefore?: string;
|
|
225
|
+
notAfter?: string;
|
|
226
|
+
compromisedFrom?: string;
|
|
227
|
+
}
|
|
228
|
+
|
|
140
229
|
export async function publicKeyFromHex(
|
|
141
230
|
crypto: CryptoOps,
|
|
142
231
|
hexKey: string,
|
|
143
|
-
opts?:
|
|
144
|
-
revoked?: boolean;
|
|
145
|
-
keyStatus?: string;
|
|
146
|
-
notBefore?: string;
|
|
147
|
-
notAfter?: string;
|
|
148
|
-
compromisedFrom?: string;
|
|
149
|
-
},
|
|
232
|
+
opts?: PublicKeyOptions,
|
|
150
233
|
): Promise<PublicKeyInfo> {
|
|
151
234
|
const raw = hexToBytes(hexKey);
|
|
152
235
|
if (raw.length !== 32) {
|
|
@@ -176,6 +259,32 @@ export const KEY_COMPROMISED = "KEY_COMPROMISED";
|
|
|
176
259
|
export const VALID_KEY_COMPROMISED_LATER = "VALID_KEY_COMPROMISED_LATER";
|
|
177
260
|
export const VALID_KEY_WINDOW_UNKNOWN = "VALID_KEY_WINDOW_UNKNOWN";
|
|
178
261
|
|
|
262
|
+
/**
|
|
263
|
+
* The revocation states a statement may assert. Mirrors
|
|
264
|
+
* core.ValidCertificateStatusValue: anything else means the statement was read
|
|
265
|
+
* but not understood, which is not the same as "not revoked".
|
|
266
|
+
*/
|
|
267
|
+
const KNOWN_STATUS_VALUES: ReadonlySet<string> = new Set(["ACTIVE", "REVOKED"]);
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* The key verdicts that permit relying on a signature: VALID plus the two soft
|
|
271
|
+
* verdicts ADR-017 §2 says a reader must be SHOWN rather than refused. Anything
|
|
272
|
+
* not named here is refused, including a verdict added in a later release.
|
|
273
|
+
*/
|
|
274
|
+
const USABLE_KEY_VERDICTS: ReadonlySet<string> = new Set([
|
|
275
|
+
"VALID",
|
|
276
|
+
VALID_KEY_WINDOW_UNKNOWN,
|
|
277
|
+
VALID_KEY_COMPROMISED_LATER,
|
|
278
|
+
]);
|
|
279
|
+
|
|
280
|
+
/** Whether a key verdict permits relying on a signature. Mirrors
|
|
281
|
+
* core.KeyIsUsable; exported so the key-list verifier (keys.ts) refuses the
|
|
282
|
+
* same set requireUsableKey refuses rather than keeping a second copy of it —
|
|
283
|
+
* a second copy is exactly how #684's fourth answer came to exist. */
|
|
284
|
+
export function keyIsUsable(verdict: string): boolean {
|
|
285
|
+
return USABLE_KEY_VERDICTS.has(verdict);
|
|
286
|
+
}
|
|
287
|
+
|
|
179
288
|
/** Whether a key was authorized to sign at anchor time `t`.
|
|
180
289
|
*
|
|
181
290
|
* Returns `"VALID"` when the key clears. The anchor is seconds since the epoch
|
|
@@ -215,7 +324,17 @@ export function evaluateKey(pki: PublicKeyInfo, anchor: number | null): string {
|
|
|
215
324
|
return "UNKNOWN_KEY";
|
|
216
325
|
}
|
|
217
326
|
|
|
218
|
-
/**
|
|
327
|
+
/**
|
|
328
|
+
* The anchor a certificate supplies, or null when it supplies none.
|
|
329
|
+
*
|
|
330
|
+
* READS AN UNSIGNED FIELD. `transparency` is stripped before signing, so on a
|
|
331
|
+
* certificate handed to a verifier the timestamp is holder-editable. Use
|
|
332
|
+
* {@link verifiedCertificateAnchor} anywhere the anchor decides whether a key
|
|
333
|
+
* may be relied on; this is safe only where the same timestamp is inside a
|
|
334
|
+
* payload the caller goes on to verify, which is what verifyTransparency does.
|
|
335
|
+
*
|
|
336
|
+
* Exported and unchanged because it is published API.
|
|
337
|
+
*/
|
|
219
338
|
export function certificateAnchor(certificate: Record<string, unknown>): number | null {
|
|
220
339
|
const transparency = certificate.transparency as Record<string, unknown> | null | undefined;
|
|
221
340
|
if (transparency == null) return null;
|
|
@@ -228,22 +347,77 @@ export function certificateAnchor(certificate: Record<string, unknown>): number
|
|
|
228
347
|
}
|
|
229
348
|
}
|
|
230
349
|
|
|
350
|
+
/**
|
|
351
|
+
* The anchor, but only once the tree head carrying it has been checked against
|
|
352
|
+
* the key being evaluated.
|
|
353
|
+
*
|
|
354
|
+
* The bypass this closes: for a key declared compromised, evaluateKey answers
|
|
355
|
+
* KEY_COMPROMISED with no anchor and VALID_KEY_COMPROMISED_LATER when the
|
|
356
|
+
* anchor predates the compromise — and the second is a verdict requireUsableKey
|
|
357
|
+
* passes through as the RESULT of verifyCertificate. Since the timestamp is
|
|
358
|
+
* covered by no signature, a holder could backdate it and have a certificate
|
|
359
|
+
* signed with a compromised key reported as verified-with-a-caveat. Deleting
|
|
360
|
+
* `transparency` failed closed; keeping a doctored one did not.
|
|
361
|
+
*
|
|
362
|
+
* A head that does not verify yields no anchor, which is the same conservative
|
|
363
|
+
* answer as a certificate carrying no transparency block at all.
|
|
364
|
+
*/
|
|
365
|
+
async function verifiedCertificateAnchor(
|
|
366
|
+
crypto: CryptoOps,
|
|
367
|
+
certificate: Record<string, unknown>,
|
|
368
|
+
pki: PublicKeyInfo,
|
|
369
|
+
): Promise<number | null> {
|
|
370
|
+
const anchor = certificateAnchor(certificate);
|
|
371
|
+
if (anchor === null) return null;
|
|
372
|
+
|
|
373
|
+
const transparency = certificate.transparency as Record<string, unknown>;
|
|
374
|
+
const sth = transparency.signed_tree_head as Record<string, unknown>;
|
|
375
|
+
try {
|
|
376
|
+
const headPayload = buildTreeHeadPayload(sth);
|
|
377
|
+
const headSig = decodeSignature(sth.signature);
|
|
378
|
+
if (!(await crypto.ed25519Verify(pki.keyBytes, headPayload, headSig))) return null;
|
|
379
|
+
} catch {
|
|
380
|
+
return null;
|
|
381
|
+
}
|
|
382
|
+
return anchor;
|
|
383
|
+
}
|
|
384
|
+
|
|
231
385
|
/** Throws for a key that cannot be used at all; returns the verdict otherwise.
|
|
232
386
|
*
|
|
233
387
|
* VALID_KEY_COMPROMISED_LATER and VALID_KEY_WINDOW_UNKNOWN are returned rather
|
|
234
388
|
* than thrown because they are not refusals — they are results a caller must
|
|
235
389
|
* show the reader instead of collapsing into green.
|
|
236
390
|
*/
|
|
237
|
-
function requireUsableKey(
|
|
391
|
+
function requireUsableKey(
|
|
392
|
+
pki: PublicKeyInfo,
|
|
393
|
+
anchor: number | null,
|
|
394
|
+
keyId: string,
|
|
395
|
+
// Named, because this is also the key that signs a certificate-status
|
|
396
|
+
// statement, and "issuer key is compromised" would send a reader looking at
|
|
397
|
+
// the wrong key.
|
|
398
|
+
role = "issuer key",
|
|
399
|
+
): string {
|
|
238
400
|
const verdict = evaluateKey(pki, anchor);
|
|
239
401
|
if (verdict === KEY_COMPROMISED) {
|
|
240
|
-
throw new VerificationError(
|
|
402
|
+
throw new VerificationError(`${role} is compromised: ${keyId}`);
|
|
241
403
|
}
|
|
242
404
|
if (verdict === KEY_OUTSIDE_VALIDITY) {
|
|
243
|
-
throw new VerificationError(
|
|
405
|
+
throw new VerificationError(`${role} was not valid when it signed: ${keyId}`);
|
|
244
406
|
}
|
|
245
407
|
if (verdict === "UNKNOWN_KEY") {
|
|
246
|
-
throw new VerificationError(
|
|
408
|
+
throw new VerificationError(`${role} has an unusable status "${pki.keyStatus}": ${keyId}`);
|
|
409
|
+
}
|
|
410
|
+
// An ALLOW-list, matching core.KeyIsUsable, which was inverted away from
|
|
411
|
+
// exactly the shape above. Naming the refusals and returning everything else
|
|
412
|
+
// means a verdict added to evaluateKey later becomes a PASS by default — and
|
|
413
|
+
// the value returned here is propagated out of verifyCertificate as its
|
|
414
|
+
// result, so "everything else" is rendered to a reader as "verified, with a
|
|
415
|
+
// note". Go would refuse the same document. That is #684's three-verifiers-
|
|
416
|
+
// two-answers divergence, in the direction that calls a bad document good.
|
|
417
|
+
if (!USABLE_KEY_VERDICTS.has(verdict)) {
|
|
418
|
+
throw new VerificationError(
|
|
419
|
+
`${role} returned a verdict this SDK does not recognize (${verdict}): ${keyId}`,
|
|
420
|
+
);
|
|
247
421
|
}
|
|
248
422
|
return verdict;
|
|
249
423
|
}
|
|
@@ -260,14 +434,38 @@ export async function verifyCertificate(
|
|
|
260
434
|
certificate: Cert,
|
|
261
435
|
publicKeys: Map<string, PublicKeyInfo>,
|
|
262
436
|
): Promise<VerificationResult> {
|
|
437
|
+
// Step 0: can this SDK read the format at all? Every check below rebuilds
|
|
438
|
+
// signed bytes from the version, so an unreadable version makes all of them
|
|
439
|
+
// meaningless - and "unknown issuer key" would be the wrong thing to tell
|
|
440
|
+
// someone holding a record that is merely newer than this library.
|
|
441
|
+
const formatVersion = checkFormatVersion(
|
|
442
|
+
(certificate as Record<string, unknown>).certificate_format_version,
|
|
443
|
+
);
|
|
444
|
+
|
|
263
445
|
const issuer = certificate.issuer as Record<string, unknown>;
|
|
446
|
+
// Step 0b: can this SDK check the algorithm the record names? Asked before
|
|
447
|
+
// any signature, because verifying an Ed25519 signature over a record that
|
|
448
|
+
// says it was signed with something else answers a question nobody asked.
|
|
449
|
+
// Below v7 no record names one, so there is nothing to check.
|
|
450
|
+
if (formatVersion === FORMAT_VERSION_V7 && issuer.algorithm !== ALGORITHM_ED25519) {
|
|
451
|
+
throw new VerificationError(
|
|
452
|
+
`record names signature algorithm ${JSON.stringify(issuer.algorithm)}; ` +
|
|
453
|
+
`this version of the SDK verifies ${ALGORITHM_ED25519}. ` +
|
|
454
|
+
"The record may be genuine and simply signed with a scheme this library does not implement.",
|
|
455
|
+
UNSUPPORTED_ALGORITHM,
|
|
456
|
+
);
|
|
457
|
+
}
|
|
264
458
|
const keyId = issuer.key_id as string;
|
|
265
459
|
|
|
266
460
|
const pki = publicKeys.get(keyId);
|
|
267
461
|
if (pki === undefined) throw new VerificationError(`unknown issuer key: ${keyId}`);
|
|
268
462
|
// The key check runs before any signature check: a signature verified
|
|
269
463
|
// against a key nobody vouches for proves nothing about the issuer.
|
|
270
|
-
const keyVerdict = requireUsableKey(
|
|
464
|
+
const keyVerdict = requireUsableKey(
|
|
465
|
+
pki,
|
|
466
|
+
await verifiedCertificateAnchor(crypto, certificate, pki),
|
|
467
|
+
keyId,
|
|
468
|
+
);
|
|
271
469
|
|
|
272
470
|
// 1. Certificate signature FIRST — nothing below may trust a field until the
|
|
273
471
|
// bytes carrying it are covered by a verified signature.
|
|
@@ -435,6 +633,20 @@ export async function verifyTransparency(
|
|
|
435
633
|
);
|
|
436
634
|
}
|
|
437
635
|
|
|
636
|
+
// Same rule for the log id (Q31). transparency.log_id sits beside log_url and
|
|
637
|
+
// is the field a reader looks at to answer "which log is this?", and nothing
|
|
638
|
+
// signs it — so a holder could relabel a genuine proof as belonging to a
|
|
639
|
+
// different log while every signature still checked out. A head predating log
|
|
640
|
+
// ids has neither side set and passes.
|
|
641
|
+
const signedLogId = (sth.log_id as string | undefined) ?? "";
|
|
642
|
+
const unsignedLogId = (transparency.log_id as string | undefined) ?? "";
|
|
643
|
+
if (unsignedLogId !== signedLogId) {
|
|
644
|
+
throw new VerificationError(
|
|
645
|
+
`transparency.log_id (${JSON.stringify(unsignedLogId)}) does not match the signed ` +
|
|
646
|
+
`tree head (${JSON.stringify(signedLogId)}); it is not covered by any signature`,
|
|
647
|
+
);
|
|
648
|
+
}
|
|
649
|
+
|
|
438
650
|
if (!(await verifyInclusion(crypto, leaf, index, treeSize, proofHashes, root))) {
|
|
439
651
|
throw new VerificationError("merkle inclusion proof is invalid");
|
|
440
652
|
}
|
|
@@ -694,27 +906,43 @@ function buildAttestationPayload(
|
|
|
694
906
|
certFormatVersion: string,
|
|
695
907
|
): Uint8Array {
|
|
696
908
|
const attestedAt = formatTimestamp(att.attested_at);
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
: null,
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
909
|
+
// v7 signs two per-system measurements v6 leaves on a mutable row. Gated on
|
|
910
|
+
// the record's OWN version: emitting them for an older format would rebuild
|
|
911
|
+
// bytes no signer ever produced and reject every record already issued.
|
|
912
|
+
const isV7 = certFormatVersion === FORMAT_VERSION_V7;
|
|
913
|
+
const systems = (att.systems as Record<string, unknown>[]).map((s) => {
|
|
914
|
+
const sys: Record<string, unknown> = {
|
|
915
|
+
canonical_version: (s.canonical_version as string | null) ?? null,
|
|
916
|
+
connector_type: s.connector_type as string,
|
|
917
|
+
hash_scope: s.hash_scope as string,
|
|
918
|
+
merkle_root: s.merkle_root
|
|
919
|
+
? toHex(s.merkle_root as string)
|
|
920
|
+
: null,
|
|
921
|
+
// The system's own observation time, not the envelope's.
|
|
922
|
+
observed_at: formatTimestamp(s.observed_at),
|
|
923
|
+
query_hash: toHex(s.query_hash as string),
|
|
924
|
+
record_count: s.record_count as number,
|
|
925
|
+
system_id: s.system_id as string,
|
|
926
|
+
system_name: s.system_name as string,
|
|
927
|
+
};
|
|
928
|
+
if (isV7) {
|
|
929
|
+
sys.read_only_enforcement = requireMeasured(
|
|
930
|
+
s.read_only_enforcement,
|
|
931
|
+
"read_only_enforcement",
|
|
932
|
+
s.system_name,
|
|
933
|
+
);
|
|
934
|
+
sys.transport_security = requireMeasured(
|
|
935
|
+
s.transport_security,
|
|
936
|
+
"transport_security",
|
|
937
|
+
s.system_name,
|
|
938
|
+
);
|
|
939
|
+
}
|
|
940
|
+
return sys;
|
|
941
|
+
});
|
|
711
942
|
|
|
712
943
|
const payload = {
|
|
713
944
|
attested_at: attestedAt,
|
|
714
|
-
payload_type:
|
|
715
|
-
certFormatVersion === FORMAT_VERSION_V5
|
|
716
|
-
? PAYLOAD_TYPE_ATTESTATION_V5
|
|
717
|
-
: PAYLOAD_TYPE_ATTESTATION,
|
|
945
|
+
payload_type: attestationPayloadType(certFormatVersion),
|
|
718
946
|
proof_mode: att.proof_mode as string,
|
|
719
947
|
subject_hash: toHex(subject.identifier_hash as string),
|
|
720
948
|
systems,
|
|
@@ -722,6 +950,49 @@ function buildAttestationPayload(
|
|
|
722
950
|
return canonicalJson(payload);
|
|
723
951
|
}
|
|
724
952
|
|
|
953
|
+
/**
|
|
954
|
+
* Read a v7 measured field that MUST be a non-empty string.
|
|
955
|
+
*
|
|
956
|
+
* A missing or non-string value cannot be turned into `""` and canonicalized:
|
|
957
|
+
* the signer never emits an empty measurement, so an empty one here would
|
|
958
|
+
* rebuild bytes no signature covers and be reported as forgery. Refusing with a
|
|
959
|
+
* document-shape message says the true thing — this record is malformed, not
|
|
960
|
+
* this record is fake.
|
|
961
|
+
*/
|
|
962
|
+
function requireMeasured(value: unknown, field: string, systemName: unknown): string {
|
|
963
|
+
if (typeof value !== "string" || value === "") {
|
|
964
|
+
throw new VerificationError(
|
|
965
|
+
`system ${JSON.stringify(systemName)}: ${field} is missing from a 7.0 record, ` +
|
|
966
|
+
"which signs it; the document is incomplete rather than unverifiable",
|
|
967
|
+
);
|
|
968
|
+
}
|
|
969
|
+
return value;
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
/** Domain separator for the record itself, by certificate format version. */
|
|
973
|
+
function certificatePayloadType(version: string): string {
|
|
974
|
+
checkFormatVersion(version);
|
|
975
|
+
if (version === FORMAT_VERSION_V7) return PAYLOAD_TYPE_VERIFICATION_RECORD_V7;
|
|
976
|
+
if (version === FORMAT_VERSION_V6) return PAYLOAD_TYPE_VERIFICATION_RECORD_V6;
|
|
977
|
+
if (version === FORMAT_VERSION_V5) return PAYLOAD_TYPE_CERTIFICATE_V5;
|
|
978
|
+
if (version === FORMAT_VERSION_V4) return PAYLOAD_TYPE_CERTIFICATE_V4;
|
|
979
|
+
return PAYLOAD_TYPE_CERTIFICATE;
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
/**
|
|
983
|
+
* Domain separator for the attestation carried by a record of that format. It
|
|
984
|
+
* follows the record's version because a verifier rebuilds the attestation
|
|
985
|
+
* payload from the record it is checking, and must reproduce the exact bytes
|
|
986
|
+
* the enclave signed.
|
|
987
|
+
*/
|
|
988
|
+
function attestationPayloadType(version: string): string {
|
|
989
|
+
checkFormatVersion(version);
|
|
990
|
+
if (version === FORMAT_VERSION_V7) return PAYLOAD_TYPE_ATTESTATION_V7;
|
|
991
|
+
if (version === FORMAT_VERSION_V6) return PAYLOAD_TYPE_ATTESTATION_V6;
|
|
992
|
+
if (version === FORMAT_VERSION_V5) return PAYLOAD_TYPE_ATTESTATION_V5;
|
|
993
|
+
return PAYLOAD_TYPE_ATTESTATION;
|
|
994
|
+
}
|
|
995
|
+
|
|
725
996
|
function buildCertificatePayload(cert: Record<string, unknown>): Uint8Array {
|
|
726
997
|
const att = cert.attestation as Record<string, unknown>;
|
|
727
998
|
const issuer = cert.issuer as Record<string, unknown>;
|
|
@@ -730,19 +1001,36 @@ function buildCertificatePayload(cert: Record<string, unknown>): Uint8Array {
|
|
|
730
1001
|
// One list, keyed by system_id (ADR-016 §2). v2 signed an attestation list
|
|
731
1002
|
// and a verification list joined only on the human-editable system_name,
|
|
732
1003
|
// which made a partial deletion indistinguishable from a complete one.
|
|
733
|
-
const
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
1004
|
+
const version = cert.certificate_format_version as string;
|
|
1005
|
+
const isV7 = version === FORMAT_VERSION_V7;
|
|
1006
|
+
const systems = ((cert.systems as Record<string, unknown>[]) ?? []).map((s) => {
|
|
1007
|
+
const sys: Record<string, unknown> = {
|
|
1008
|
+
attested_at: formatTimestamp(s.attested_at),
|
|
1009
|
+
attested_count: s.attested_count as number,
|
|
1010
|
+
canonical_version: (s.canonical_version as string | null) ?? null,
|
|
1011
|
+
connector_type: s.connector_type as string,
|
|
1012
|
+
hash_scope: s.hash_scope as string,
|
|
1013
|
+
merkle_root: s.merkle_root ? toHex(s.merkle_root as string) : null,
|
|
1014
|
+
query_hash: toHex(s.query_hash as string),
|
|
1015
|
+
system_id: s.system_id as string,
|
|
1016
|
+
system_name: s.system_name as string,
|
|
1017
|
+
verified_at: formatTimestamp(s.verified_at),
|
|
1018
|
+
verified_count: s.verified_count as number,
|
|
1019
|
+
};
|
|
1020
|
+
if (isV7) {
|
|
1021
|
+
sys.read_only_enforcement = requireMeasured(
|
|
1022
|
+
s.read_only_enforcement,
|
|
1023
|
+
"read_only_enforcement",
|
|
1024
|
+
s.system_name,
|
|
1025
|
+
);
|
|
1026
|
+
sys.transport_security = requireMeasured(
|
|
1027
|
+
s.transport_security,
|
|
1028
|
+
"transport_security",
|
|
1029
|
+
s.system_name,
|
|
1030
|
+
);
|
|
1031
|
+
}
|
|
1032
|
+
return sys;
|
|
1033
|
+
});
|
|
746
1034
|
|
|
747
1035
|
// The attestation block carries no system list of its own: the merged list
|
|
748
1036
|
// above is a superset of it. verification_signature is gone entirely.
|
|
@@ -755,21 +1043,30 @@ function buildCertificatePayload(cert: Record<string, unknown>): Uint8Array {
|
|
|
755
1043
|
// Fields added after v3 are gated on the certificate's OWN version. A v3
|
|
756
1044
|
// certificate must reconstruct to the same bytes forever; reading these
|
|
757
1045
|
// unconditionally would break every certificate already issued.
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
1046
|
+
// v6 carries v5's shape exactly, so it takes every gate v5 takes. Naming
|
|
1047
|
+
// these "Plus" rather than testing equality at each use is what stops a new
|
|
1048
|
+
// version from silently missing one.
|
|
1049
|
+
const isV5Plus =
|
|
1050
|
+
version === FORMAT_VERSION_V5 || version === FORMAT_VERSION_V6 || isV7;
|
|
1051
|
+
const isV4Plus = version === FORMAT_VERSION_V4 || isV5Plus;
|
|
761
1052
|
|
|
762
1053
|
const issuerObj: Record<string, unknown> = {
|
|
763
1054
|
key_id: issuer.key_id as string,
|
|
764
1055
|
name: issuer.name as string,
|
|
765
1056
|
public_key: toHex(issuer.public_key as string),
|
|
766
1057
|
};
|
|
1058
|
+
// v7 signs the algorithm, and does so unconditionally: unlike legal_entity
|
|
1059
|
+
// and enclave_pcr0, "which scheme signed this" is never unknown to a signer,
|
|
1060
|
+
// so a v7 record missing it is malformed rather than merely sparse.
|
|
1061
|
+
if (isV7) {
|
|
1062
|
+
issuerObj.algorithm = requireMeasured(issuer.algorithm, "issuer.algorithm", "issuer");
|
|
1063
|
+
}
|
|
767
1064
|
if (isV4Plus && typeof issuer.legal_entity === "string" && issuer.legal_entity !== "") {
|
|
768
1065
|
issuerObj.legal_entity = issuer.legal_entity;
|
|
769
1066
|
}
|
|
770
1067
|
// Signed from v5, and omitted when absent or empty: a build with no
|
|
771
1068
|
// measurement signs none, and "" is a value a reader could mistake for one.
|
|
772
|
-
if (
|
|
1069
|
+
if (isV5Plus && typeof issuer.enclave_pcr0 === "string" && issuer.enclave_pcr0 !== "") {
|
|
773
1070
|
issuerObj.enclave_pcr0 = issuer.enclave_pcr0;
|
|
774
1071
|
}
|
|
775
1072
|
|
|
@@ -778,7 +1075,7 @@ function buildCertificatePayload(cert: Record<string, unknown>): Uint8Array {
|
|
|
778
1075
|
const subjectObj: Record<string, unknown> = {
|
|
779
1076
|
identifier_hash: toHex(subject.identifier_hash as string),
|
|
780
1077
|
};
|
|
781
|
-
if (!
|
|
1078
|
+
if (!isV5Plus) {
|
|
782
1079
|
subjectObj.identifier_type_hint = subject.identifier_type_hint as string;
|
|
783
1080
|
}
|
|
784
1081
|
|
|
@@ -792,11 +1089,7 @@ function buildCertificatePayload(cert: Record<string, unknown>): Uint8Array {
|
|
|
792
1089
|
certificate_id: cert.certificate_id as string,
|
|
793
1090
|
issued_at: formatTimestamp(cert.issued_at),
|
|
794
1091
|
issuer: issuerObj,
|
|
795
|
-
payload_type:
|
|
796
|
-
? PAYLOAD_TYPE_CERTIFICATE_V5
|
|
797
|
-
: isV4Plus
|
|
798
|
-
? PAYLOAD_TYPE_CERTIFICATE_V4
|
|
799
|
-
: PAYLOAD_TYPE_CERTIFICATE,
|
|
1092
|
+
payload_type: certificatePayloadType(version),
|
|
800
1093
|
subject: subjectObj,
|
|
801
1094
|
systems,
|
|
802
1095
|
};
|
|
@@ -846,16 +1139,37 @@ function attestationSystems(cert: Record<string, unknown>): Record<string, unkno
|
|
|
846
1139
|
observed_at: s.attested_at,
|
|
847
1140
|
merkle_root: s.merkle_root,
|
|
848
1141
|
canonical_version: s.canonical_version,
|
|
1142
|
+
// Carried through so the v7 attestation payload can be rebuilt from the
|
|
1143
|
+
// record. Undefined on older formats, where buildAttestationPayload never
|
|
1144
|
+
// reads them.
|
|
1145
|
+
read_only_enforcement: s.read_only_enforcement,
|
|
1146
|
+
transport_security: s.transport_security,
|
|
849
1147
|
}));
|
|
850
1148
|
}
|
|
851
1149
|
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
1150
|
+
/**
|
|
1151
|
+
* Port of Go's BuildTreeHeadPayload.
|
|
1152
|
+
*
|
|
1153
|
+
* A head that names its log is signed in a DIFFERENT DOMAIN from one that does
|
|
1154
|
+
* not (Q31). That is what makes log_id unstrippable: removing it moves the
|
|
1155
|
+
* rebuild into burnledger.sth.v3 and adding one moves it into
|
|
1156
|
+
* burnledger.sth.v7, and the signature fails either way. Heads signed before
|
|
1157
|
+
* log ids existed carry none and reproduce exactly the bytes they always did.
|
|
1158
|
+
*
|
|
1159
|
+
* Exported for anchor.ts, which rebuilds the preimage of a head fetched from
|
|
1160
|
+
* GET /v1/log/head. Not re-exported from index.ts: it is an internal seam
|
|
1161
|
+
* between two modules of this package, not published surface.
|
|
1162
|
+
*/
|
|
1163
|
+
export function buildTreeHeadPayload(head: Record<string, unknown>): Uint8Array {
|
|
1164
|
+
const logId = head.log_id;
|
|
1165
|
+
const named = typeof logId === "string" && logId !== "";
|
|
1166
|
+
const payload: Record<string, unknown> = {
|
|
1167
|
+
payload_type: named ? PAYLOAD_TYPE_TREE_HEAD_V7 : PAYLOAD_TYPE_TREE_HEAD,
|
|
855
1168
|
root_hash: toHex(head.root_hash as string),
|
|
856
1169
|
timestamp: formatTimestamp(head.timestamp),
|
|
857
1170
|
tree_size: head.tree_size as number,
|
|
858
1171
|
};
|
|
1172
|
+
if (named) payload.log_id = logId;
|
|
859
1173
|
return canonicalJson(payload);
|
|
860
1174
|
}
|
|
861
1175
|
|
|
@@ -1043,6 +1357,40 @@ export function buildCertificateStatusPayload(
|
|
|
1043
1357
|
return canonicalJson(payload);
|
|
1044
1358
|
}
|
|
1045
1359
|
|
|
1360
|
+
/**
|
|
1361
|
+
* Port of Go's BuildKeyListPayload (core/key_list.go).
|
|
1362
|
+
*
|
|
1363
|
+
* The key array is sorted by key_id before signing: canonical JSON sorts
|
|
1364
|
+
* object keys but does nothing to array order, and the server has no reason to
|
|
1365
|
+
* preserve any particular order across a rotation. Optional entry fields are
|
|
1366
|
+
* omitted, never emitted as null, for the reason buildCertificateStatusPayload
|
|
1367
|
+
* omits them. `keys` is the array exactly as HANDED to the verifier — the
|
|
1368
|
+
* signature covers the published entries, not a parsed reinterpretation of
|
|
1369
|
+
* them, which is what makes an edited key_status detectable.
|
|
1370
|
+
*/
|
|
1371
|
+
export function buildKeyListPayload(doc: Record<string, unknown>): Uint8Array {
|
|
1372
|
+
const entries = (doc.keys as Record<string, unknown>[]).map((e) => {
|
|
1373
|
+
const k: Record<string, unknown> = {};
|
|
1374
|
+
if (e.compromised_from != null) k.compromised_from = e.compromised_from;
|
|
1375
|
+
k.key_id = e.key_id;
|
|
1376
|
+
k.key_status = e.key_status;
|
|
1377
|
+
if (e.not_after != null) k.not_after = e.not_after;
|
|
1378
|
+
if (e.not_before != null && e.not_before !== "") k.not_before = e.not_before;
|
|
1379
|
+
k.public_key = e.public_key;
|
|
1380
|
+
return k;
|
|
1381
|
+
});
|
|
1382
|
+
entries.sort((a, b) => ((a.key_id as string) < (b.key_id as string) ? -1 : a.key_id === b.key_id ? 0 : 1));
|
|
1383
|
+
const payload: Record<string, unknown> = {
|
|
1384
|
+
payload_type: PAYLOAD_TYPE_KEY_LIST,
|
|
1385
|
+
keys: entries,
|
|
1386
|
+
statement_expires_at: formatTimestamp(doc.statement_expires_at),
|
|
1387
|
+
statement_issued_at: formatTimestamp(doc.statement_issued_at),
|
|
1388
|
+
sth_root_hash: toHex(doc.sth_root_hash as string),
|
|
1389
|
+
sth_tree_size: doc.sth_tree_size as number,
|
|
1390
|
+
};
|
|
1391
|
+
return canonicalJson(payload);
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1046
1394
|
/** The verdict when a certificate verified but nothing said whether it was revoked. */
|
|
1047
1395
|
export const VALID_REVOCATION_UNKNOWN = "VALID_REVOCATION_UNKNOWN";
|
|
1048
1396
|
|
|
@@ -1059,10 +1407,11 @@ export const VALID_REVOCATION_UNKNOWN = "VALID_REVOCATION_UNKNOWN";
|
|
|
1059
1407
|
* | input | result |
|
|
1060
1408
|
* |---|---|
|
|
1061
1409
|
* | fresh statement, REVOKED | throws VerificationError |
|
|
1062
|
-
* | fresh statement, ACTIVE | `"VALID"
|
|
1063
|
-
* | absent or expired statement | `"VALID_REVOCATION_UNKNOWN"
|
|
1410
|
+
* | fresh statement, ACTIVE | `"VALID"`, or a soft verdict on either key |
|
|
1411
|
+
* | absent or expired statement | `"VALID_REVOCATION_UNKNOWN"`, or the certificate key's soft verdict |
|
|
1412
|
+
* | statement key unusable (ADR-017) | throws VerificationError |
|
|
1064
1413
|
*
|
|
1065
|
-
* The
|
|
1414
|
+
* The third row is the point: `verifyCertificate` answers VALID there, which
|
|
1066
1415
|
* reads as "not revoked" and is not something it checked.
|
|
1067
1416
|
*/
|
|
1068
1417
|
export async function verifyCertificateWithStatus(
|
|
@@ -1072,12 +1421,14 @@ export async function verifyCertificateWithStatus(
|
|
|
1072
1421
|
status?: Record<string, unknown> | null,
|
|
1073
1422
|
now?: Date,
|
|
1074
1423
|
): Promise<string> {
|
|
1424
|
+
// A refusal throws out of verifyCertificate, so `base` is VALID or one of the
|
|
1425
|
+
// two soft verdicts. Returning a soft verdict here skipped every check below
|
|
1426
|
+
// it: a revoked certificate signed by a compromised-later key answered
|
|
1427
|
+
// VALID_KEY_COMPROMISED_LATER and its statement was never authenticated. The
|
|
1428
|
+
// verdict is held instead, and resolved against what the statement says.
|
|
1075
1429
|
const base = await verifyCertificate(crypto, certificate, publicKeys);
|
|
1076
|
-
if (base !== "VALID") {
|
|
1077
|
-
return base as string;
|
|
1078
|
-
}
|
|
1079
1430
|
if (status == null) {
|
|
1080
|
-
return VALID_REVOCATION_UNKNOWN;
|
|
1431
|
+
return resolveWithBase(base, VALID_REVOCATION_UNKNOWN);
|
|
1081
1432
|
}
|
|
1082
1433
|
|
|
1083
1434
|
const keyId = status.key_id as string | undefined;
|
|
@@ -1085,9 +1436,27 @@ export async function verifyCertificateWithStatus(
|
|
|
1085
1436
|
if (!info) {
|
|
1086
1437
|
throw new VerificationError(`status statement signed by unknown key: ${keyId}`);
|
|
1087
1438
|
}
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1439
|
+
|
|
1440
|
+
// The whole ADR-017 table, not just the deprecated boolean. `revoked` is
|
|
1441
|
+
// never set by a key loaded from /.well-known/burnledger-keys — that endpoint
|
|
1442
|
+
// publishes key_status — so this check read `false` for a key the issuer had
|
|
1443
|
+
// declared COMPROMISED, and whoever held that key could mint an ACTIVE
|
|
1444
|
+
// statement for a revoked certificate that every SDK and the browser bundle
|
|
1445
|
+
// rendered as a signed "not revoked". core.VerifyCertificateWithStatus has
|
|
1446
|
+
// always evaluated it.
|
|
1447
|
+
//
|
|
1448
|
+
// The anchor is the verifier's own clock, not a field inside the statement: a
|
|
1449
|
+
// statement must already be inside its own validity window to be served at
|
|
1450
|
+
// all, and `now` is a time this verifier observed rather than one the signing
|
|
1451
|
+
// key asserted about itself.
|
|
1452
|
+
const when = now ?? new Date();
|
|
1453
|
+
const at = formatTimestamp(when.toISOString());
|
|
1454
|
+
const statusKeyVerdict = requireUsableKey(
|
|
1455
|
+
info,
|
|
1456
|
+
parseRfc3339(at),
|
|
1457
|
+
keyId!,
|
|
1458
|
+
"status statement key",
|
|
1459
|
+
);
|
|
1091
1460
|
|
|
1092
1461
|
const payload = buildCertificateStatusPayload(status);
|
|
1093
1462
|
// decodeSignature, not hexToBytes: signatures arrive as hex, base64 or a byte
|
|
@@ -1118,17 +1487,43 @@ export async function verifyCertificateWithStatus(
|
|
|
1118
1487
|
|
|
1119
1488
|
// Compared in the normalized form the payload signs, so the freshness check
|
|
1120
1489
|
// cannot disagree with what was signed about the same two instants.
|
|
1121
|
-
const at = formatTimestamp((now ?? new Date()).toISOString());
|
|
1122
1490
|
const issued = formatTimestamp(status.statement_issued_at);
|
|
1123
1491
|
const expires = formatTimestamp(status.statement_expires_at);
|
|
1124
1492
|
if (at < issued || at >= expires) {
|
|
1125
1493
|
// Stale is not a weaker answer, it is no answer — including for a REVOKED
|
|
1126
1494
|
// statement, which must never decay into VALID.
|
|
1127
|
-
return VALID_REVOCATION_UNKNOWN;
|
|
1495
|
+
return resolveWithBase(base, VALID_REVOCATION_UNKNOWN);
|
|
1128
1496
|
}
|
|
1129
1497
|
|
|
1130
1498
|
if (status.status === "REVOKED") {
|
|
1131
|
-
|
|
1499
|
+
// Coded, because this is the only status-path rejection that is a fact
|
|
1500
|
+
// about the certificate rather than a failure to authenticate the
|
|
1501
|
+
// statement. A caller that cannot tell the two apart will render an
|
|
1502
|
+
// unsigned statement as a revocation.
|
|
1503
|
+
throw new VerificationError("certificate has been revoked", CERTIFICATE_REVOKED);
|
|
1504
|
+
}
|
|
1505
|
+
// A state this SDK does not recognize is NOT active. This tested only for
|
|
1506
|
+
// REVOKED and treated everything else as a pass, so a signed, fresh,
|
|
1507
|
+
// correctly-bound statement saying "SUSPENDED" — or "revoked" in the wrong
|
|
1508
|
+
// case — was reported as revocation CHECKED AND PASSED. Mirrors
|
|
1509
|
+
// core.ValidCertificateStatusValue.
|
|
1510
|
+
if (!KNOWN_STATUS_VALUES.has(status.status as string)) {
|
|
1511
|
+
return resolveWithBase(base, VALID_REVOCATION_UNKNOWN);
|
|
1132
1512
|
}
|
|
1133
|
-
|
|
1513
|
+
// A soft verdict on the status key is the result, not a footnote — but it is
|
|
1514
|
+
// reported only once the statement has authenticated and been read, so it can
|
|
1515
|
+
// neither speak for an unverified statement nor suppress a revocation.
|
|
1516
|
+
return resolveWithBase(base, statusKeyVerdict);
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
/**
|
|
1520
|
+
* Picks what to report when the certificate's own key returned a soft verdict
|
|
1521
|
+
* and the status path also has something to say. A doubt about the key that
|
|
1522
|
+
* signed the certificate questions the whole document, so it outranks a doubt
|
|
1523
|
+
* about revocation or about the statement's own key; only one verdict string
|
|
1524
|
+
* comes back, and this is the one a reader most needs. Mirrors
|
|
1525
|
+
* core.resolveWithBase.
|
|
1526
|
+
*/
|
|
1527
|
+
function resolveWithBase(base: string, verdict: string): string {
|
|
1528
|
+
return base !== "VALID" ? base : verdict;
|
|
1134
1529
|
}
|