burnledger 0.4.0 → 0.5.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 +3 -3
- package/dist/cjs/client.d.ts +10 -0
- package/dist/cjs/client.d.ts.map +1 -1
- package/dist/cjs/client.js +3 -0
- package/dist/cjs/client.js.map +1 -1
- package/dist/cjs/errors.d.ts +28 -1
- package/dist/cjs/errors.d.ts.map +1 -1
- package/dist/cjs/errors.js +30 -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 +4 -9
- package/dist/cjs/index.d.ts.map +1 -1
- package/dist/cjs/index.js +3 -1
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/models.d.ts +22 -4
- package/dist/cjs/models.d.ts.map +1 -1
- package/dist/cjs/models.js +9 -4
- package/dist/cjs/models.js.map +1 -1
- package/dist/cjs/verify.d.ts +41 -6
- package/dist/cjs/verify.d.ts.map +1 -1
- package/dist/cjs/verify.js +213 -33
- package/dist/cjs/verify.js.map +1 -1
- package/dist/cjs/web-verifier.d.ts +10 -5
- package/dist/cjs/web-verifier.d.ts.map +1 -1
- package/dist/cjs/web-verifier.js +7 -3
- package/dist/cjs/web-verifier.js.map +1 -1
- package/dist/esm/cli.d.ts.map +1 -1
- package/dist/esm/cli.js +76 -10
- package/dist/esm/cli.js.map +1 -1
- package/dist/esm/client.d.ts +10 -0
- package/dist/esm/client.d.ts.map +1 -1
- package/dist/esm/client.js +3 -0
- package/dist/esm/client.js.map +1 -1
- package/dist/esm/errors.d.ts +28 -1
- package/dist/esm/errors.d.ts.map +1 -1
- package/dist/esm/errors.js +29 -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 +4 -9
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +1 -1
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/models.d.ts +22 -4
- package/dist/esm/models.d.ts.map +1 -1
- package/dist/esm/models.js +9 -4
- package/dist/esm/models.js.map +1 -1
- package/dist/esm/verify.d.ts +41 -6
- package/dist/esm/verify.d.ts.map +1 -1
- package/dist/esm/verify.js +213 -33
- package/dist/esm/verify.js.map +1 -1
- package/dist/esm/web-verifier.d.ts +10 -5
- package/dist/esm/web-verifier.d.ts.map +1 -1
- package/dist/esm/web-verifier.js +11 -3
- package/dist/esm/web-verifier.js.map +1 -1
- package/package.json +1 -1
- package/src/cli.ts +82 -9
- package/src/client.ts +11 -0
- package/src/errors.ts +32 -2
- package/src/http.ts +79 -12
- package/src/index.browser.ts +2 -2
- package/src/index.ts +6 -8
- package/src/models.ts +31 -8
- package/src/verify.ts +263 -41
- package/src/web-verifier.ts +14 -5
package/src/verify.ts
CHANGED
|
@@ -17,7 +17,11 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import type { CryptoOps } from "./crypto.js";
|
|
20
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
CERTIFICATE_REVOKED,
|
|
22
|
+
UNSUPPORTED_FORMAT_VERSION,
|
|
23
|
+
VerificationError,
|
|
24
|
+
} from "./errors.js";
|
|
21
25
|
import type { TransparencyResult, VerificationResult } from "./models.js";
|
|
22
26
|
|
|
23
27
|
// ---------------------------------------------------------------------------
|
|
@@ -40,6 +44,52 @@ const PAYLOAD_TYPE_ATTESTATION_V5 = "burnledger.attestation.v5";
|
|
|
40
44
|
const PAYLOAD_TYPE_CERTIFICATE_V5 = "burnledger.certificate.v5";
|
|
41
45
|
const FORMAT_VERSION_V5 = "5.0";
|
|
42
46
|
const FORMAT_VERSION_V4 = "4.0";
|
|
47
|
+
// v6 renames the artifact and nothing else: the shape is byte-for-byte v5, and
|
|
48
|
+
// only the tag and the version string differ. It still takes its own tag, for
|
|
49
|
+
// the same reason v4 and v5 each took one -- a signature over a payload calling
|
|
50
|
+
// itself a verification record must not verify as one calling itself a
|
|
51
|
+
// certificate. Two names for one shape sharing a separator is exactly what a
|
|
52
|
+
// separator prevents.
|
|
53
|
+
const PAYLOAD_TYPE_ATTESTATION_V6 = "burnledger.attestation.v6";
|
|
54
|
+
const PAYLOAD_TYPE_VERIFICATION_RECORD_V6 = "burnledger.verification_record.v6";
|
|
55
|
+
const FORMAT_VERSION_V6 = "6.0";
|
|
56
|
+
const FORMAT_VERSION_V3 = "3.0";
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Every format this build can read, oldest first. Mirrors core.FormatVersions()
|
|
60
|
+
* in the Go source; the cross-language conformance corpus carries a fixture per
|
|
61
|
+
* version, so a version present in one language and missing here fails there.
|
|
62
|
+
*
|
|
63
|
+
* Frozen because `readonly` is erased at build time: it stops a TypeScript
|
|
64
|
+
* caller from writing to the array, and does nothing at all to the JavaScript
|
|
65
|
+
* this compiles to — which is what the browser bundle actually runs, where any
|
|
66
|
+
* script on the page could otherwise push a version onto it.
|
|
67
|
+
*/
|
|
68
|
+
export const KNOWN_FORMAT_VERSIONS: readonly string[] = Object.freeze([
|
|
69
|
+
FORMAT_VERSION_V3,
|
|
70
|
+
FORMAT_VERSION_V4,
|
|
71
|
+
FORMAT_VERSION_V5,
|
|
72
|
+
FORMAT_VERSION_V6,
|
|
73
|
+
]);
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Returns the version, or throws if this SDK cannot read it.
|
|
77
|
+
*
|
|
78
|
+
* Rejects a non-string too: JSON carrying `6.0` as a NUMBER would compare
|
|
79
|
+
* unequal to every known version and silently take the oldest branch, which is
|
|
80
|
+
* the same trap as an unknown version wearing a different hat.
|
|
81
|
+
*/
|
|
82
|
+
function checkFormatVersion(version: unknown): string {
|
|
83
|
+
if (typeof version !== "string" || !KNOWN_FORMAT_VERSIONS.includes(version)) {
|
|
84
|
+
throw new VerificationError(
|
|
85
|
+
`unsupported certificate_format_version ${JSON.stringify(version)}; ` +
|
|
86
|
+
`this version of the SDK reads ${KNOWN_FORMAT_VERSIONS.join(", ")}. ` +
|
|
87
|
+
"The record may be genuine and simply newer than this library.",
|
|
88
|
+
UNSUPPORTED_FORMAT_VERSION,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
return version;
|
|
92
|
+
}
|
|
43
93
|
const PAYLOAD_TYPE_TREE_HEAD = "burnledger.sth.v3";
|
|
44
94
|
const PAYLOAD_TYPE_LOG_LEAF = "burnledger.log_leaf.v3";
|
|
45
95
|
// There is deliberately no verification payload type: v3 produces those facts
|
|
@@ -137,16 +187,30 @@ export interface PublicKeyInfo {
|
|
|
137
187
|
readonly revoked: boolean;
|
|
138
188
|
}
|
|
139
189
|
|
|
190
|
+
/**
|
|
191
|
+
* The options publicKeyFromHex accepts, mirroring one entry of the published key
|
|
192
|
+
* set at `/.well-known/burnledger-keys`.
|
|
193
|
+
*
|
|
194
|
+
* Named, and exported, because it is restated by every entry point that wraps
|
|
195
|
+
* publicKeyFromHex — index.ts, index.browser.ts, web-verifier.ts. When the
|
|
196
|
+
* ADR-017 fields were added, two of the three were widened and the browser entry
|
|
197
|
+
* was not, so the dashboard could not express a compromised key through the API
|
|
198
|
+
* it imports and every key it built looked unconditionally active. A shared type
|
|
199
|
+
* is what stops the next field from being added to two places out of three.
|
|
200
|
+
*/
|
|
201
|
+
export interface PublicKeyOptions {
|
|
202
|
+
/** @deprecated Use keyStatus. Retained: true still means "unusable". */
|
|
203
|
+
revoked?: boolean;
|
|
204
|
+
keyStatus?: string;
|
|
205
|
+
notBefore?: string;
|
|
206
|
+
notAfter?: string;
|
|
207
|
+
compromisedFrom?: string;
|
|
208
|
+
}
|
|
209
|
+
|
|
140
210
|
export async function publicKeyFromHex(
|
|
141
211
|
crypto: CryptoOps,
|
|
142
212
|
hexKey: string,
|
|
143
|
-
opts?:
|
|
144
|
-
revoked?: boolean;
|
|
145
|
-
keyStatus?: string;
|
|
146
|
-
notBefore?: string;
|
|
147
|
-
notAfter?: string;
|
|
148
|
-
compromisedFrom?: string;
|
|
149
|
-
},
|
|
213
|
+
opts?: PublicKeyOptions,
|
|
150
214
|
): Promise<PublicKeyInfo> {
|
|
151
215
|
const raw = hexToBytes(hexKey);
|
|
152
216
|
if (raw.length !== 32) {
|
|
@@ -176,6 +240,24 @@ export const KEY_COMPROMISED = "KEY_COMPROMISED";
|
|
|
176
240
|
export const VALID_KEY_COMPROMISED_LATER = "VALID_KEY_COMPROMISED_LATER";
|
|
177
241
|
export const VALID_KEY_WINDOW_UNKNOWN = "VALID_KEY_WINDOW_UNKNOWN";
|
|
178
242
|
|
|
243
|
+
/**
|
|
244
|
+
* The revocation states a statement may assert. Mirrors
|
|
245
|
+
* core.ValidCertificateStatusValue: anything else means the statement was read
|
|
246
|
+
* but not understood, which is not the same as "not revoked".
|
|
247
|
+
*/
|
|
248
|
+
const KNOWN_STATUS_VALUES: ReadonlySet<string> = new Set(["ACTIVE", "REVOKED"]);
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* The key verdicts that permit relying on a signature: VALID plus the two soft
|
|
252
|
+
* verdicts ADR-017 §2 says a reader must be SHOWN rather than refused. Anything
|
|
253
|
+
* not named here is refused, including a verdict added in a later release.
|
|
254
|
+
*/
|
|
255
|
+
const USABLE_KEY_VERDICTS: ReadonlySet<string> = new Set([
|
|
256
|
+
"VALID",
|
|
257
|
+
VALID_KEY_WINDOW_UNKNOWN,
|
|
258
|
+
VALID_KEY_COMPROMISED_LATER,
|
|
259
|
+
]);
|
|
260
|
+
|
|
179
261
|
/** Whether a key was authorized to sign at anchor time `t`.
|
|
180
262
|
*
|
|
181
263
|
* Returns `"VALID"` when the key clears. The anchor is seconds since the epoch
|
|
@@ -215,7 +297,17 @@ export function evaluateKey(pki: PublicKeyInfo, anchor: number | null): string {
|
|
|
215
297
|
return "UNKNOWN_KEY";
|
|
216
298
|
}
|
|
217
299
|
|
|
218
|
-
/**
|
|
300
|
+
/**
|
|
301
|
+
* The anchor a certificate supplies, or null when it supplies none.
|
|
302
|
+
*
|
|
303
|
+
* READS AN UNSIGNED FIELD. `transparency` is stripped before signing, so on a
|
|
304
|
+
* certificate handed to a verifier the timestamp is holder-editable. Use
|
|
305
|
+
* {@link verifiedCertificateAnchor} anywhere the anchor decides whether a key
|
|
306
|
+
* may be relied on; this is safe only where the same timestamp is inside a
|
|
307
|
+
* payload the caller goes on to verify, which is what verifyTransparency does.
|
|
308
|
+
*
|
|
309
|
+
* Exported and unchanged because it is published API.
|
|
310
|
+
*/
|
|
219
311
|
export function certificateAnchor(certificate: Record<string, unknown>): number | null {
|
|
220
312
|
const transparency = certificate.transparency as Record<string, unknown> | null | undefined;
|
|
221
313
|
if (transparency == null) return null;
|
|
@@ -228,22 +320,77 @@ export function certificateAnchor(certificate: Record<string, unknown>): number
|
|
|
228
320
|
}
|
|
229
321
|
}
|
|
230
322
|
|
|
323
|
+
/**
|
|
324
|
+
* The anchor, but only once the tree head carrying it has been checked against
|
|
325
|
+
* the key being evaluated.
|
|
326
|
+
*
|
|
327
|
+
* The bypass this closes: for a key declared compromised, evaluateKey answers
|
|
328
|
+
* KEY_COMPROMISED with no anchor and VALID_KEY_COMPROMISED_LATER when the
|
|
329
|
+
* anchor predates the compromise — and the second is a verdict requireUsableKey
|
|
330
|
+
* passes through as the RESULT of verifyCertificate. Since the timestamp is
|
|
331
|
+
* covered by no signature, a holder could backdate it and have a certificate
|
|
332
|
+
* signed with a compromised key reported as verified-with-a-caveat. Deleting
|
|
333
|
+
* `transparency` failed closed; keeping a doctored one did not.
|
|
334
|
+
*
|
|
335
|
+
* A head that does not verify yields no anchor, which is the same conservative
|
|
336
|
+
* answer as a certificate carrying no transparency block at all.
|
|
337
|
+
*/
|
|
338
|
+
async function verifiedCertificateAnchor(
|
|
339
|
+
crypto: CryptoOps,
|
|
340
|
+
certificate: Record<string, unknown>,
|
|
341
|
+
pki: PublicKeyInfo,
|
|
342
|
+
): Promise<number | null> {
|
|
343
|
+
const anchor = certificateAnchor(certificate);
|
|
344
|
+
if (anchor === null) return null;
|
|
345
|
+
|
|
346
|
+
const transparency = certificate.transparency as Record<string, unknown>;
|
|
347
|
+
const sth = transparency.signed_tree_head as Record<string, unknown>;
|
|
348
|
+
try {
|
|
349
|
+
const headPayload = buildTreeHeadPayload(sth);
|
|
350
|
+
const headSig = decodeSignature(sth.signature);
|
|
351
|
+
if (!(await crypto.ed25519Verify(pki.keyBytes, headPayload, headSig))) return null;
|
|
352
|
+
} catch {
|
|
353
|
+
return null;
|
|
354
|
+
}
|
|
355
|
+
return anchor;
|
|
356
|
+
}
|
|
357
|
+
|
|
231
358
|
/** Throws for a key that cannot be used at all; returns the verdict otherwise.
|
|
232
359
|
*
|
|
233
360
|
* VALID_KEY_COMPROMISED_LATER and VALID_KEY_WINDOW_UNKNOWN are returned rather
|
|
234
361
|
* than thrown because they are not refusals — they are results a caller must
|
|
235
362
|
* show the reader instead of collapsing into green.
|
|
236
363
|
*/
|
|
237
|
-
function requireUsableKey(
|
|
364
|
+
function requireUsableKey(
|
|
365
|
+
pki: PublicKeyInfo,
|
|
366
|
+
anchor: number | null,
|
|
367
|
+
keyId: string,
|
|
368
|
+
// Named, because this is also the key that signs a certificate-status
|
|
369
|
+
// statement, and "issuer key is compromised" would send a reader looking at
|
|
370
|
+
// the wrong key.
|
|
371
|
+
role = "issuer key",
|
|
372
|
+
): string {
|
|
238
373
|
const verdict = evaluateKey(pki, anchor);
|
|
239
374
|
if (verdict === KEY_COMPROMISED) {
|
|
240
|
-
throw new VerificationError(
|
|
375
|
+
throw new VerificationError(`${role} is compromised: ${keyId}`);
|
|
241
376
|
}
|
|
242
377
|
if (verdict === KEY_OUTSIDE_VALIDITY) {
|
|
243
|
-
throw new VerificationError(
|
|
378
|
+
throw new VerificationError(`${role} was not valid when it signed: ${keyId}`);
|
|
244
379
|
}
|
|
245
380
|
if (verdict === "UNKNOWN_KEY") {
|
|
246
|
-
throw new VerificationError(
|
|
381
|
+
throw new VerificationError(`${role} has an unusable status "${pki.keyStatus}": ${keyId}`);
|
|
382
|
+
}
|
|
383
|
+
// An ALLOW-list, matching core.KeyIsUsable, which was inverted away from
|
|
384
|
+
// exactly the shape above. Naming the refusals and returning everything else
|
|
385
|
+
// means a verdict added to evaluateKey later becomes a PASS by default — and
|
|
386
|
+
// the value returned here is propagated out of verifyCertificate as its
|
|
387
|
+
// result, so "everything else" is rendered to a reader as "verified, with a
|
|
388
|
+
// note". Go would refuse the same document. That is #684's three-verifiers-
|
|
389
|
+
// two-answers divergence, in the direction that calls a bad document good.
|
|
390
|
+
if (!USABLE_KEY_VERDICTS.has(verdict)) {
|
|
391
|
+
throw new VerificationError(
|
|
392
|
+
`${role} returned a verdict this SDK does not recognize (${verdict}): ${keyId}`,
|
|
393
|
+
);
|
|
247
394
|
}
|
|
248
395
|
return verdict;
|
|
249
396
|
}
|
|
@@ -260,6 +407,12 @@ export async function verifyCertificate(
|
|
|
260
407
|
certificate: Cert,
|
|
261
408
|
publicKeys: Map<string, PublicKeyInfo>,
|
|
262
409
|
): Promise<VerificationResult> {
|
|
410
|
+
// Step 0: can this SDK read the format at all? Every check below rebuilds
|
|
411
|
+
// signed bytes from the version, so an unreadable version makes all of them
|
|
412
|
+
// meaningless - and "unknown issuer key" would be the wrong thing to tell
|
|
413
|
+
// someone holding a record that is merely newer than this library.
|
|
414
|
+
checkFormatVersion((certificate as Record<string, unknown>).certificate_format_version);
|
|
415
|
+
|
|
263
416
|
const issuer = certificate.issuer as Record<string, unknown>;
|
|
264
417
|
const keyId = issuer.key_id as string;
|
|
265
418
|
|
|
@@ -267,7 +420,11 @@ export async function verifyCertificate(
|
|
|
267
420
|
if (pki === undefined) throw new VerificationError(`unknown issuer key: ${keyId}`);
|
|
268
421
|
// The key check runs before any signature check: a signature verified
|
|
269
422
|
// against a key nobody vouches for proves nothing about the issuer.
|
|
270
|
-
const keyVerdict = requireUsableKey(
|
|
423
|
+
const keyVerdict = requireUsableKey(
|
|
424
|
+
pki,
|
|
425
|
+
await verifiedCertificateAnchor(crypto, certificate, pki),
|
|
426
|
+
keyId,
|
|
427
|
+
);
|
|
271
428
|
|
|
272
429
|
// 1. Certificate signature FIRST — nothing below may trust a field until the
|
|
273
430
|
// bytes carrying it are covered by a verified signature.
|
|
@@ -711,10 +868,7 @@ function buildAttestationPayload(
|
|
|
711
868
|
|
|
712
869
|
const payload = {
|
|
713
870
|
attested_at: attestedAt,
|
|
714
|
-
payload_type:
|
|
715
|
-
certFormatVersion === FORMAT_VERSION_V5
|
|
716
|
-
? PAYLOAD_TYPE_ATTESTATION_V5
|
|
717
|
-
: PAYLOAD_TYPE_ATTESTATION,
|
|
871
|
+
payload_type: attestationPayloadType(certFormatVersion),
|
|
718
872
|
proof_mode: att.proof_mode as string,
|
|
719
873
|
subject_hash: toHex(subject.identifier_hash as string),
|
|
720
874
|
systems,
|
|
@@ -722,6 +876,28 @@ function buildAttestationPayload(
|
|
|
722
876
|
return canonicalJson(payload);
|
|
723
877
|
}
|
|
724
878
|
|
|
879
|
+
/** Domain separator for the record itself, by certificate format version. */
|
|
880
|
+
function certificatePayloadType(version: string): string {
|
|
881
|
+
checkFormatVersion(version);
|
|
882
|
+
if (version === FORMAT_VERSION_V6) return PAYLOAD_TYPE_VERIFICATION_RECORD_V6;
|
|
883
|
+
if (version === FORMAT_VERSION_V5) return PAYLOAD_TYPE_CERTIFICATE_V5;
|
|
884
|
+
if (version === FORMAT_VERSION_V4) return PAYLOAD_TYPE_CERTIFICATE_V4;
|
|
885
|
+
return PAYLOAD_TYPE_CERTIFICATE;
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
/**
|
|
889
|
+
* Domain separator for the attestation carried by a record of that format. It
|
|
890
|
+
* follows the record's version because a verifier rebuilds the attestation
|
|
891
|
+
* payload from the record it is checking, and must reproduce the exact bytes
|
|
892
|
+
* the enclave signed.
|
|
893
|
+
*/
|
|
894
|
+
function attestationPayloadType(version: string): string {
|
|
895
|
+
checkFormatVersion(version);
|
|
896
|
+
if (version === FORMAT_VERSION_V6) return PAYLOAD_TYPE_ATTESTATION_V6;
|
|
897
|
+
if (version === FORMAT_VERSION_V5) return PAYLOAD_TYPE_ATTESTATION_V5;
|
|
898
|
+
return PAYLOAD_TYPE_ATTESTATION;
|
|
899
|
+
}
|
|
900
|
+
|
|
725
901
|
function buildCertificatePayload(cert: Record<string, unknown>): Uint8Array {
|
|
726
902
|
const att = cert.attestation as Record<string, unknown>;
|
|
727
903
|
const issuer = cert.issuer as Record<string, unknown>;
|
|
@@ -756,8 +932,11 @@ function buildCertificatePayload(cert: Record<string, unknown>): Uint8Array {
|
|
|
756
932
|
// certificate must reconstruct to the same bytes forever; reading these
|
|
757
933
|
// unconditionally would break every certificate already issued.
|
|
758
934
|
const version = cert.certificate_format_version as string;
|
|
759
|
-
|
|
760
|
-
|
|
935
|
+
// v6 carries v5's shape exactly, so it takes every gate v5 takes. Naming
|
|
936
|
+
// these "Plus" rather than testing equality at each use is what stops a new
|
|
937
|
+
// version from silently missing one.
|
|
938
|
+
const isV5Plus = version === FORMAT_VERSION_V5 || version === FORMAT_VERSION_V6;
|
|
939
|
+
const isV4Plus = version === FORMAT_VERSION_V4 || isV5Plus;
|
|
761
940
|
|
|
762
941
|
const issuerObj: Record<string, unknown> = {
|
|
763
942
|
key_id: issuer.key_id as string,
|
|
@@ -769,7 +948,7 @@ function buildCertificatePayload(cert: Record<string, unknown>): Uint8Array {
|
|
|
769
948
|
}
|
|
770
949
|
// Signed from v5, and omitted when absent or empty: a build with no
|
|
771
950
|
// measurement signs none, and "" is a value a reader could mistake for one.
|
|
772
|
-
if (
|
|
951
|
+
if (isV5Plus && typeof issuer.enclave_pcr0 === "string" && issuer.enclave_pcr0 !== "") {
|
|
773
952
|
issuerObj.enclave_pcr0 = issuer.enclave_pcr0;
|
|
774
953
|
}
|
|
775
954
|
|
|
@@ -778,7 +957,7 @@ function buildCertificatePayload(cert: Record<string, unknown>): Uint8Array {
|
|
|
778
957
|
const subjectObj: Record<string, unknown> = {
|
|
779
958
|
identifier_hash: toHex(subject.identifier_hash as string),
|
|
780
959
|
};
|
|
781
|
-
if (!
|
|
960
|
+
if (!isV5Plus) {
|
|
782
961
|
subjectObj.identifier_type_hint = subject.identifier_type_hint as string;
|
|
783
962
|
}
|
|
784
963
|
|
|
@@ -792,11 +971,7 @@ function buildCertificatePayload(cert: Record<string, unknown>): Uint8Array {
|
|
|
792
971
|
certificate_id: cert.certificate_id as string,
|
|
793
972
|
issued_at: formatTimestamp(cert.issued_at),
|
|
794
973
|
issuer: issuerObj,
|
|
795
|
-
payload_type:
|
|
796
|
-
? PAYLOAD_TYPE_CERTIFICATE_V5
|
|
797
|
-
: isV4Plus
|
|
798
|
-
? PAYLOAD_TYPE_CERTIFICATE_V4
|
|
799
|
-
: PAYLOAD_TYPE_CERTIFICATE,
|
|
974
|
+
payload_type: certificatePayloadType(version),
|
|
800
975
|
subject: subjectObj,
|
|
801
976
|
systems,
|
|
802
977
|
};
|
|
@@ -1059,10 +1234,11 @@ export const VALID_REVOCATION_UNKNOWN = "VALID_REVOCATION_UNKNOWN";
|
|
|
1059
1234
|
* | input | result |
|
|
1060
1235
|
* |---|---|
|
|
1061
1236
|
* | fresh statement, REVOKED | throws VerificationError |
|
|
1062
|
-
* | fresh statement, ACTIVE | `"VALID"
|
|
1063
|
-
* | absent or expired statement | `"VALID_REVOCATION_UNKNOWN"
|
|
1237
|
+
* | fresh statement, ACTIVE | `"VALID"`, or a soft verdict on either key |
|
|
1238
|
+
* | absent or expired statement | `"VALID_REVOCATION_UNKNOWN"`, or the certificate key's soft verdict |
|
|
1239
|
+
* | statement key unusable (ADR-017) | throws VerificationError |
|
|
1064
1240
|
*
|
|
1065
|
-
* The
|
|
1241
|
+
* The third row is the point: `verifyCertificate` answers VALID there, which
|
|
1066
1242
|
* reads as "not revoked" and is not something it checked.
|
|
1067
1243
|
*/
|
|
1068
1244
|
export async function verifyCertificateWithStatus(
|
|
@@ -1072,12 +1248,14 @@ export async function verifyCertificateWithStatus(
|
|
|
1072
1248
|
status?: Record<string, unknown> | null,
|
|
1073
1249
|
now?: Date,
|
|
1074
1250
|
): Promise<string> {
|
|
1251
|
+
// A refusal throws out of verifyCertificate, so `base` is VALID or one of the
|
|
1252
|
+
// two soft verdicts. Returning a soft verdict here skipped every check below
|
|
1253
|
+
// it: a revoked certificate signed by a compromised-later key answered
|
|
1254
|
+
// VALID_KEY_COMPROMISED_LATER and its statement was never authenticated. The
|
|
1255
|
+
// verdict is held instead, and resolved against what the statement says.
|
|
1075
1256
|
const base = await verifyCertificate(crypto, certificate, publicKeys);
|
|
1076
|
-
if (base !== "VALID") {
|
|
1077
|
-
return base as string;
|
|
1078
|
-
}
|
|
1079
1257
|
if (status == null) {
|
|
1080
|
-
return VALID_REVOCATION_UNKNOWN;
|
|
1258
|
+
return resolveWithBase(base, VALID_REVOCATION_UNKNOWN);
|
|
1081
1259
|
}
|
|
1082
1260
|
|
|
1083
1261
|
const keyId = status.key_id as string | undefined;
|
|
@@ -1085,9 +1263,27 @@ export async function verifyCertificateWithStatus(
|
|
|
1085
1263
|
if (!info) {
|
|
1086
1264
|
throw new VerificationError(`status statement signed by unknown key: ${keyId}`);
|
|
1087
1265
|
}
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1266
|
+
|
|
1267
|
+
// The whole ADR-017 table, not just the deprecated boolean. `revoked` is
|
|
1268
|
+
// never set by a key loaded from /.well-known/burnledger-keys — that endpoint
|
|
1269
|
+
// publishes key_status — so this check read `false` for a key the issuer had
|
|
1270
|
+
// declared COMPROMISED, and whoever held that key could mint an ACTIVE
|
|
1271
|
+
// statement for a revoked certificate that every SDK and the browser bundle
|
|
1272
|
+
// rendered as a signed "not revoked". core.VerifyCertificateWithStatus has
|
|
1273
|
+
// always evaluated it.
|
|
1274
|
+
//
|
|
1275
|
+
// The anchor is the verifier's own clock, not a field inside the statement: a
|
|
1276
|
+
// statement must already be inside its own validity window to be served at
|
|
1277
|
+
// all, and `now` is a time this verifier observed rather than one the signing
|
|
1278
|
+
// key asserted about itself.
|
|
1279
|
+
const when = now ?? new Date();
|
|
1280
|
+
const at = formatTimestamp(when.toISOString());
|
|
1281
|
+
const statusKeyVerdict = requireUsableKey(
|
|
1282
|
+
info,
|
|
1283
|
+
parseRfc3339(at),
|
|
1284
|
+
keyId!,
|
|
1285
|
+
"status statement key",
|
|
1286
|
+
);
|
|
1091
1287
|
|
|
1092
1288
|
const payload = buildCertificateStatusPayload(status);
|
|
1093
1289
|
// decodeSignature, not hexToBytes: signatures arrive as hex, base64 or a byte
|
|
@@ -1118,17 +1314,43 @@ export async function verifyCertificateWithStatus(
|
|
|
1118
1314
|
|
|
1119
1315
|
// Compared in the normalized form the payload signs, so the freshness check
|
|
1120
1316
|
// cannot disagree with what was signed about the same two instants.
|
|
1121
|
-
const at = formatTimestamp((now ?? new Date()).toISOString());
|
|
1122
1317
|
const issued = formatTimestamp(status.statement_issued_at);
|
|
1123
1318
|
const expires = formatTimestamp(status.statement_expires_at);
|
|
1124
1319
|
if (at < issued || at >= expires) {
|
|
1125
1320
|
// Stale is not a weaker answer, it is no answer — including for a REVOKED
|
|
1126
1321
|
// statement, which must never decay into VALID.
|
|
1127
|
-
return VALID_REVOCATION_UNKNOWN;
|
|
1322
|
+
return resolveWithBase(base, VALID_REVOCATION_UNKNOWN);
|
|
1128
1323
|
}
|
|
1129
1324
|
|
|
1130
1325
|
if (status.status === "REVOKED") {
|
|
1131
|
-
|
|
1326
|
+
// Coded, because this is the only status-path rejection that is a fact
|
|
1327
|
+
// about the certificate rather than a failure to authenticate the
|
|
1328
|
+
// statement. A caller that cannot tell the two apart will render an
|
|
1329
|
+
// unsigned statement as a revocation.
|
|
1330
|
+
throw new VerificationError("certificate has been revoked", CERTIFICATE_REVOKED);
|
|
1132
1331
|
}
|
|
1133
|
-
|
|
1332
|
+
// A state this SDK does not recognize is NOT active. This tested only for
|
|
1333
|
+
// REVOKED and treated everything else as a pass, so a signed, fresh,
|
|
1334
|
+
// correctly-bound statement saying "SUSPENDED" — or "revoked" in the wrong
|
|
1335
|
+
// case — was reported as revocation CHECKED AND PASSED. Mirrors
|
|
1336
|
+
// core.ValidCertificateStatusValue.
|
|
1337
|
+
if (!KNOWN_STATUS_VALUES.has(status.status as string)) {
|
|
1338
|
+
return resolveWithBase(base, VALID_REVOCATION_UNKNOWN);
|
|
1339
|
+
}
|
|
1340
|
+
// A soft verdict on the status key is the result, not a footnote — but it is
|
|
1341
|
+
// reported only once the statement has authenticated and been read, so it can
|
|
1342
|
+
// neither speak for an unverified statement nor suppress a revocation.
|
|
1343
|
+
return resolveWithBase(base, statusKeyVerdict);
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
/**
|
|
1347
|
+
* Picks what to report when the certificate's own key returned a soft verdict
|
|
1348
|
+
* and the status path also has something to say. A doubt about the key that
|
|
1349
|
+
* signed the certificate questions the whole document, so it outranks a doubt
|
|
1350
|
+
* about revocation or about the statement's own key; only one verdict string
|
|
1351
|
+
* comes back, and this is the one a reader most needs. Mirrors
|
|
1352
|
+
* core.resolveWithBase.
|
|
1353
|
+
*/
|
|
1354
|
+
function resolveWithBase(base: string, verdict: string): string {
|
|
1355
|
+
return base !== "VALID" ? base : verdict;
|
|
1134
1356
|
}
|
package/src/web-verifier.ts
CHANGED
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
* Imports the leaf modules directly rather than re-exporting from
|
|
5
5
|
* `index.browser.ts`: that entry pulls in the HTTP client, which imports
|
|
6
6
|
* `node:fs/promises` and cannot be bundled for a browser at all. The page needs
|
|
7
|
-
* four functions, so this exposes
|
|
8
|
-
* browser is handed is then something you can read in one
|
|
9
|
-
* inferring from a minified artifact.
|
|
7
|
+
* four functions and one error code, so that is what this exposes — the surface
|
|
8
|
+
* a relying party's browser is handed is then something you can read in one
|
|
9
|
+
* screen instead of inferring from a minified artifact.
|
|
10
10
|
*
|
|
11
11
|
* Built by `scripts/build-web-verifier.mjs`. The bundle this replaces was built
|
|
12
12
|
* by hand once and never regenerated, and had drifted into disagreeing with the
|
|
@@ -16,13 +16,14 @@
|
|
|
16
16
|
* source file anywhere in the repository.
|
|
17
17
|
*/
|
|
18
18
|
import { browserCrypto } from "./crypto-browser.js";
|
|
19
|
+
import { CERTIFICATE_REVOKED, UNSUPPORTED_FORMAT_VERSION } from "./errors.js";
|
|
19
20
|
import {
|
|
20
21
|
verifyCertificate as _verifyCertificate,
|
|
21
22
|
verifyCertificateWithStatus as _verifyCertificateWithStatus,
|
|
22
23
|
verifyTransparency as _verifyTransparency,
|
|
23
24
|
publicKeyFromHex as _publicKeyFromHex,
|
|
24
25
|
} from "./verify.js";
|
|
25
|
-
import type { PublicKeyInfo } from "./verify.js";
|
|
26
|
+
import type { PublicKeyInfo, PublicKeyOptions } from "./verify.js";
|
|
26
27
|
import type { VerificationResult, TransparencyResult } from "./models.js";
|
|
27
28
|
|
|
28
29
|
type Cert = Record<string, unknown>;
|
|
@@ -50,6 +51,14 @@ export function verifyCertificateWithStatus(
|
|
|
50
51
|
return _verifyCertificateWithStatus(browserCrypto, certificate, publicKeys, status, now);
|
|
51
52
|
}
|
|
52
53
|
|
|
54
|
+
/**
|
|
55
|
+
* The `code` a VerificationError carries when a status statement verified and
|
|
56
|
+
* says REVOKED. The page needs it to tell that fact apart from a statement it
|
|
57
|
+
* could not authenticate, which is a different claim and must not be painted
|
|
58
|
+
* as a revocation.
|
|
59
|
+
*/
|
|
60
|
+
export { CERTIFICATE_REVOKED, UNSUPPORTED_FORMAT_VERSION };
|
|
61
|
+
|
|
53
62
|
/** Verify the transparency proof embedded in a certificate. */
|
|
54
63
|
export function verifyTransparency(
|
|
55
64
|
certificate: Cert,
|
|
@@ -61,7 +70,7 @@ export function verifyTransparency(
|
|
|
61
70
|
/** Construct a PublicKeyInfo from a hex-encoded Ed25519 public key. */
|
|
62
71
|
export function publicKeyFromHex(
|
|
63
72
|
hexKey: string,
|
|
64
|
-
opts?:
|
|
73
|
+
opts?: PublicKeyOptions,
|
|
65
74
|
): Promise<PublicKeyInfo> {
|
|
66
75
|
return _publicKeyFromHex(browserCrypto, hexKey, opts);
|
|
67
76
|
}
|