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/dist/esm/verify.js
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* certificate signing payload. issuanceBytes reconstructs json.Marshal
|
|
16
16
|
* output at issuance time (transparency_status=PENDING, no transparency).
|
|
17
17
|
*/
|
|
18
|
-
import { VerificationError } from "./errors.js";
|
|
18
|
+
import { CERTIFICATE_REVOKED, UNSUPPORTED_ALGORITHM, UNSUPPORTED_FORMAT_VERSION, VerificationError, } from "./errors.js";
|
|
19
19
|
// ---------------------------------------------------------------------------
|
|
20
20
|
// Pure byte helpers (no Buffer, no node:crypto)
|
|
21
21
|
// ---------------------------------------------------------------------------
|
|
@@ -34,11 +34,69 @@ const PAYLOAD_TYPE_ATTESTATION_V5 = "burnledger.attestation.v5";
|
|
|
34
34
|
const PAYLOAD_TYPE_CERTIFICATE_V5 = "burnledger.certificate.v5";
|
|
35
35
|
const FORMAT_VERSION_V5 = "5.0";
|
|
36
36
|
const FORMAT_VERSION_V4 = "4.0";
|
|
37
|
+
// v6 renames the artifact and nothing else: the shape is byte-for-byte v5, and
|
|
38
|
+
// only the tag and the version string differ. It still takes its own tag, for
|
|
39
|
+
// the same reason v4 and v5 each took one -- a signature over a payload calling
|
|
40
|
+
// itself a verification record must not verify as one calling itself a
|
|
41
|
+
// certificate. Two names for one shape sharing a separator is exactly what a
|
|
42
|
+
// separator prevents.
|
|
43
|
+
const PAYLOAD_TYPE_ATTESTATION_V6 = "burnledger.attestation.v6";
|
|
44
|
+
const PAYLOAD_TYPE_VERIFICATION_RECORD_V6 = "burnledger.verification_record.v6";
|
|
45
|
+
const FORMAT_VERSION_V6 = "6.0";
|
|
46
|
+
// v7 changes the SHAPE, not just the name: every system entry gains
|
|
47
|
+
// read_only_enforcement and transport_security, and the issuer gains the
|
|
48
|
+
// algorithm identifier. Its tags move for the reason v4, v5 and v6 each moved
|
|
49
|
+
// theirs, and this time the bytes under them genuinely differ.
|
|
50
|
+
const PAYLOAD_TYPE_ATTESTATION_V7 = "burnledger.attestation.v7";
|
|
51
|
+
const PAYLOAD_TYPE_VERIFICATION_RECORD_V7 = "burnledger.verification_record.v7";
|
|
52
|
+
const FORMAT_VERSION_V7 = "7.0";
|
|
53
|
+
const FORMAT_VERSION_V3 = "3.0";
|
|
54
|
+
/**
|
|
55
|
+
* The only signature scheme this SDK can check. A v7 record names its own
|
|
56
|
+
* algorithm, so a record naming anything else must be refused with its own
|
|
57
|
+
* code rather than fed to ed25519Verify and reported as a bad signature.
|
|
58
|
+
*/
|
|
59
|
+
const ALGORITHM_ED25519 = "ed25519";
|
|
60
|
+
/**
|
|
61
|
+
* Every format this build can read, oldest first. Mirrors core.FormatVersions()
|
|
62
|
+
* in the Go source; the cross-language conformance corpus carries a fixture per
|
|
63
|
+
* version, so a version present in one language and missing here fails there.
|
|
64
|
+
*
|
|
65
|
+
* Frozen because `readonly` is erased at build time: it stops a TypeScript
|
|
66
|
+
* caller from writing to the array, and does nothing at all to the JavaScript
|
|
67
|
+
* this compiles to — which is what the browser bundle actually runs, where any
|
|
68
|
+
* script on the page could otherwise push a version onto it.
|
|
69
|
+
*/
|
|
70
|
+
export const KNOWN_FORMAT_VERSIONS = Object.freeze([
|
|
71
|
+
FORMAT_VERSION_V3,
|
|
72
|
+
FORMAT_VERSION_V4,
|
|
73
|
+
FORMAT_VERSION_V5,
|
|
74
|
+
FORMAT_VERSION_V6,
|
|
75
|
+
FORMAT_VERSION_V7,
|
|
76
|
+
]);
|
|
77
|
+
/**
|
|
78
|
+
* Returns the version, or throws if this SDK cannot read it.
|
|
79
|
+
*
|
|
80
|
+
* Rejects a non-string too: JSON carrying `6.0` as a NUMBER would compare
|
|
81
|
+
* unequal to every known version and silently take the oldest branch, which is
|
|
82
|
+
* the same trap as an unknown version wearing a different hat.
|
|
83
|
+
*/
|
|
84
|
+
function checkFormatVersion(version) {
|
|
85
|
+
if (typeof version !== "string" || !KNOWN_FORMAT_VERSIONS.includes(version)) {
|
|
86
|
+
throw new VerificationError(`unsupported certificate_format_version ${JSON.stringify(version)}; ` +
|
|
87
|
+
`this version of the SDK reads ${KNOWN_FORMAT_VERSIONS.join(", ")}. ` +
|
|
88
|
+
"The record may be genuine and simply newer than this library.", UNSUPPORTED_FORMAT_VERSION);
|
|
89
|
+
}
|
|
90
|
+
return version;
|
|
91
|
+
}
|
|
37
92
|
const PAYLOAD_TYPE_TREE_HEAD = "burnledger.sth.v3";
|
|
93
|
+
// The domain for a tree head that names its log. See buildTreeHeadPayload.
|
|
94
|
+
const PAYLOAD_TYPE_TREE_HEAD_V7 = "burnledger.sth.v7";
|
|
38
95
|
const PAYLOAD_TYPE_LOG_LEAF = "burnledger.log_leaf.v3";
|
|
39
96
|
// There is deliberately no verification payload type: v3 produces those facts
|
|
40
97
|
// in the same enclave call that signs the certificate (ADR-016 §2).
|
|
41
98
|
const PAYLOAD_TYPE_CERTIFICATE_STATUS = "burnledger.certificate_status.v3";
|
|
99
|
+
const PAYLOAD_TYPE_KEY_LIST = "burnledger.key_list.v3";
|
|
42
100
|
export function hexToBytes(hex) {
|
|
43
101
|
const len = hex.length >>> 1;
|
|
44
102
|
const out = new Uint8Array(len);
|
|
@@ -125,6 +183,29 @@ export const KEY_OUTSIDE_VALIDITY = "KEY_OUTSIDE_VALIDITY";
|
|
|
125
183
|
export const KEY_COMPROMISED = "KEY_COMPROMISED";
|
|
126
184
|
export const VALID_KEY_COMPROMISED_LATER = "VALID_KEY_COMPROMISED_LATER";
|
|
127
185
|
export const VALID_KEY_WINDOW_UNKNOWN = "VALID_KEY_WINDOW_UNKNOWN";
|
|
186
|
+
/**
|
|
187
|
+
* The revocation states a statement may assert. Mirrors
|
|
188
|
+
* core.ValidCertificateStatusValue: anything else means the statement was read
|
|
189
|
+
* but not understood, which is not the same as "not revoked".
|
|
190
|
+
*/
|
|
191
|
+
const KNOWN_STATUS_VALUES = new Set(["ACTIVE", "REVOKED"]);
|
|
192
|
+
/**
|
|
193
|
+
* The key verdicts that permit relying on a signature: VALID plus the two soft
|
|
194
|
+
* verdicts ADR-017 §2 says a reader must be SHOWN rather than refused. Anything
|
|
195
|
+
* not named here is refused, including a verdict added in a later release.
|
|
196
|
+
*/
|
|
197
|
+
const USABLE_KEY_VERDICTS = new Set([
|
|
198
|
+
"VALID",
|
|
199
|
+
VALID_KEY_WINDOW_UNKNOWN,
|
|
200
|
+
VALID_KEY_COMPROMISED_LATER,
|
|
201
|
+
]);
|
|
202
|
+
/** Whether a key verdict permits relying on a signature. Mirrors
|
|
203
|
+
* core.KeyIsUsable; exported so the key-list verifier (keys.ts) refuses the
|
|
204
|
+
* same set requireUsableKey refuses rather than keeping a second copy of it —
|
|
205
|
+
* a second copy is exactly how #684's fourth answer came to exist. */
|
|
206
|
+
export function keyIsUsable(verdict) {
|
|
207
|
+
return USABLE_KEY_VERDICTS.has(verdict);
|
|
208
|
+
}
|
|
128
209
|
/** Whether a key was authorized to sign at anchor time `t`.
|
|
129
210
|
*
|
|
130
211
|
* Returns `"VALID"` when the key clears. The anchor is seconds since the epoch
|
|
@@ -167,7 +248,17 @@ export function evaluateKey(pki, anchor) {
|
|
|
167
248
|
// for. Fail closed, as the boolean it replaces did.
|
|
168
249
|
return "UNKNOWN_KEY";
|
|
169
250
|
}
|
|
170
|
-
/**
|
|
251
|
+
/**
|
|
252
|
+
* The anchor a certificate supplies, or null when it supplies none.
|
|
253
|
+
*
|
|
254
|
+
* READS AN UNSIGNED FIELD. `transparency` is stripped before signing, so on a
|
|
255
|
+
* certificate handed to a verifier the timestamp is holder-editable. Use
|
|
256
|
+
* {@link verifiedCertificateAnchor} anywhere the anchor decides whether a key
|
|
257
|
+
* may be relied on; this is safe only where the same timestamp is inside a
|
|
258
|
+
* payload the caller goes on to verify, which is what verifyTransparency does.
|
|
259
|
+
*
|
|
260
|
+
* Exported and unchanged because it is published API.
|
|
261
|
+
*/
|
|
171
262
|
export function certificateAnchor(certificate) {
|
|
172
263
|
const transparency = certificate.transparency;
|
|
173
264
|
if (transparency == null)
|
|
@@ -182,35 +273,95 @@ export function certificateAnchor(certificate) {
|
|
|
182
273
|
return null;
|
|
183
274
|
}
|
|
184
275
|
}
|
|
276
|
+
/**
|
|
277
|
+
* The anchor, but only once the tree head carrying it has been checked against
|
|
278
|
+
* the key being evaluated.
|
|
279
|
+
*
|
|
280
|
+
* The bypass this closes: for a key declared compromised, evaluateKey answers
|
|
281
|
+
* KEY_COMPROMISED with no anchor and VALID_KEY_COMPROMISED_LATER when the
|
|
282
|
+
* anchor predates the compromise — and the second is a verdict requireUsableKey
|
|
283
|
+
* passes through as the RESULT of verifyCertificate. Since the timestamp is
|
|
284
|
+
* covered by no signature, a holder could backdate it and have a certificate
|
|
285
|
+
* signed with a compromised key reported as verified-with-a-caveat. Deleting
|
|
286
|
+
* `transparency` failed closed; keeping a doctored one did not.
|
|
287
|
+
*
|
|
288
|
+
* A head that does not verify yields no anchor, which is the same conservative
|
|
289
|
+
* answer as a certificate carrying no transparency block at all.
|
|
290
|
+
*/
|
|
291
|
+
async function verifiedCertificateAnchor(crypto, certificate, pki) {
|
|
292
|
+
const anchor = certificateAnchor(certificate);
|
|
293
|
+
if (anchor === null)
|
|
294
|
+
return null;
|
|
295
|
+
const transparency = certificate.transparency;
|
|
296
|
+
const sth = transparency.signed_tree_head;
|
|
297
|
+
try {
|
|
298
|
+
const headPayload = buildTreeHeadPayload(sth);
|
|
299
|
+
const headSig = decodeSignature(sth.signature);
|
|
300
|
+
if (!(await crypto.ed25519Verify(pki.keyBytes, headPayload, headSig)))
|
|
301
|
+
return null;
|
|
302
|
+
}
|
|
303
|
+
catch {
|
|
304
|
+
return null;
|
|
305
|
+
}
|
|
306
|
+
return anchor;
|
|
307
|
+
}
|
|
185
308
|
/** Throws for a key that cannot be used at all; returns the verdict otherwise.
|
|
186
309
|
*
|
|
187
310
|
* VALID_KEY_COMPROMISED_LATER and VALID_KEY_WINDOW_UNKNOWN are returned rather
|
|
188
311
|
* than thrown because they are not refusals — they are results a caller must
|
|
189
312
|
* show the reader instead of collapsing into green.
|
|
190
313
|
*/
|
|
191
|
-
function requireUsableKey(pki, anchor, keyId
|
|
314
|
+
function requireUsableKey(pki, anchor, keyId,
|
|
315
|
+
// Named, because this is also the key that signs a certificate-status
|
|
316
|
+
// statement, and "issuer key is compromised" would send a reader looking at
|
|
317
|
+
// the wrong key.
|
|
318
|
+
role = "issuer key") {
|
|
192
319
|
const verdict = evaluateKey(pki, anchor);
|
|
193
320
|
if (verdict === KEY_COMPROMISED) {
|
|
194
|
-
throw new VerificationError(
|
|
321
|
+
throw new VerificationError(`${role} is compromised: ${keyId}`);
|
|
195
322
|
}
|
|
196
323
|
if (verdict === KEY_OUTSIDE_VALIDITY) {
|
|
197
|
-
throw new VerificationError(
|
|
324
|
+
throw new VerificationError(`${role} was not valid when it signed: ${keyId}`);
|
|
198
325
|
}
|
|
199
326
|
if (verdict === "UNKNOWN_KEY") {
|
|
200
|
-
throw new VerificationError(
|
|
327
|
+
throw new VerificationError(`${role} has an unusable status "${pki.keyStatus}": ${keyId}`);
|
|
328
|
+
}
|
|
329
|
+
// An ALLOW-list, matching core.KeyIsUsable, which was inverted away from
|
|
330
|
+
// exactly the shape above. Naming the refusals and returning everything else
|
|
331
|
+
// means a verdict added to evaluateKey later becomes a PASS by default — and
|
|
332
|
+
// the value returned here is propagated out of verifyCertificate as its
|
|
333
|
+
// result, so "everything else" is rendered to a reader as "verified, with a
|
|
334
|
+
// note". Go would refuse the same document. That is #684's three-verifiers-
|
|
335
|
+
// two-answers divergence, in the direction that calls a bad document good.
|
|
336
|
+
if (!USABLE_KEY_VERDICTS.has(verdict)) {
|
|
337
|
+
throw new VerificationError(`${role} returned a verdict this SDK does not recognize (${verdict}): ${keyId}`);
|
|
201
338
|
}
|
|
202
339
|
return verdict;
|
|
203
340
|
}
|
|
204
341
|
/** Verify all signatures on a deletion certificate offline. */
|
|
205
342
|
export async function verifyCertificate(crypto, certificate, publicKeys) {
|
|
343
|
+
// Step 0: can this SDK read the format at all? Every check below rebuilds
|
|
344
|
+
// signed bytes from the version, so an unreadable version makes all of them
|
|
345
|
+
// meaningless - and "unknown issuer key" would be the wrong thing to tell
|
|
346
|
+
// someone holding a record that is merely newer than this library.
|
|
347
|
+
const formatVersion = checkFormatVersion(certificate.certificate_format_version);
|
|
206
348
|
const issuer = certificate.issuer;
|
|
349
|
+
// Step 0b: can this SDK check the algorithm the record names? Asked before
|
|
350
|
+
// any signature, because verifying an Ed25519 signature over a record that
|
|
351
|
+
// says it was signed with something else answers a question nobody asked.
|
|
352
|
+
// Below v7 no record names one, so there is nothing to check.
|
|
353
|
+
if (formatVersion === FORMAT_VERSION_V7 && issuer.algorithm !== ALGORITHM_ED25519) {
|
|
354
|
+
throw new VerificationError(`record names signature algorithm ${JSON.stringify(issuer.algorithm)}; ` +
|
|
355
|
+
`this version of the SDK verifies ${ALGORITHM_ED25519}. ` +
|
|
356
|
+
"The record may be genuine and simply signed with a scheme this library does not implement.", UNSUPPORTED_ALGORITHM);
|
|
357
|
+
}
|
|
207
358
|
const keyId = issuer.key_id;
|
|
208
359
|
const pki = publicKeys.get(keyId);
|
|
209
360
|
if (pki === undefined)
|
|
210
361
|
throw new VerificationError(`unknown issuer key: ${keyId}`);
|
|
211
362
|
// The key check runs before any signature check: a signature verified
|
|
212
363
|
// against a key nobody vouches for proves nothing about the issuer.
|
|
213
|
-
const keyVerdict = requireUsableKey(pki,
|
|
364
|
+
const keyVerdict = requireUsableKey(pki, await verifiedCertificateAnchor(crypto, certificate, pki), keyId);
|
|
214
365
|
// 1. Certificate signature FIRST — nothing below may trust a field until the
|
|
215
366
|
// bytes carrying it are covered by a verified signature.
|
|
216
367
|
const certPayload = buildCertificatePayload(certificate);
|
|
@@ -333,6 +484,17 @@ export async function verifyTransparency(crypto, certificate, publicKeys) {
|
|
|
333
484
|
throw new VerificationError(`transparency.tree_size (${unsignedSize}) does not match the signed tree head ` +
|
|
334
485
|
`(${treeSize}); it is not covered by any signature`);
|
|
335
486
|
}
|
|
487
|
+
// Same rule for the log id (Q31). transparency.log_id sits beside log_url and
|
|
488
|
+
// is the field a reader looks at to answer "which log is this?", and nothing
|
|
489
|
+
// signs it — so a holder could relabel a genuine proof as belonging to a
|
|
490
|
+
// different log while every signature still checked out. A head predating log
|
|
491
|
+
// ids has neither side set and passes.
|
|
492
|
+
const signedLogId = sth.log_id ?? "";
|
|
493
|
+
const unsignedLogId = transparency.log_id ?? "";
|
|
494
|
+
if (unsignedLogId !== signedLogId) {
|
|
495
|
+
throw new VerificationError(`transparency.log_id (${JSON.stringify(unsignedLogId)}) does not match the signed ` +
|
|
496
|
+
`tree head (${JSON.stringify(signedLogId)}); it is not covered by any signature`);
|
|
497
|
+
}
|
|
336
498
|
if (!(await verifyInclusion(crypto, leaf, index, treeSize, proofHashes, root))) {
|
|
337
499
|
throw new VerificationError("merkle inclusion proof is invalid");
|
|
338
500
|
}
|
|
@@ -567,31 +729,85 @@ function civilFromDays(days) {
|
|
|
567
729
|
// ---------------------------------------------------------------------------
|
|
568
730
|
function buildAttestationPayload(subject, att, certFormatVersion) {
|
|
569
731
|
const attestedAt = formatTimestamp(att.attested_at);
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
: null,
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
732
|
+
// v7 signs two per-system measurements v6 leaves on a mutable row. Gated on
|
|
733
|
+
// the record's OWN version: emitting them for an older format would rebuild
|
|
734
|
+
// bytes no signer ever produced and reject every record already issued.
|
|
735
|
+
const isV7 = certFormatVersion === FORMAT_VERSION_V7;
|
|
736
|
+
const systems = att.systems.map((s) => {
|
|
737
|
+
const sys = {
|
|
738
|
+
canonical_version: s.canonical_version ?? null,
|
|
739
|
+
connector_type: s.connector_type,
|
|
740
|
+
hash_scope: s.hash_scope,
|
|
741
|
+
merkle_root: s.merkle_root
|
|
742
|
+
? toHex(s.merkle_root)
|
|
743
|
+
: null,
|
|
744
|
+
// The system's own observation time, not the envelope's.
|
|
745
|
+
observed_at: formatTimestamp(s.observed_at),
|
|
746
|
+
query_hash: toHex(s.query_hash),
|
|
747
|
+
record_count: s.record_count,
|
|
748
|
+
system_id: s.system_id,
|
|
749
|
+
system_name: s.system_name,
|
|
750
|
+
};
|
|
751
|
+
if (isV7) {
|
|
752
|
+
sys.read_only_enforcement = requireMeasured(s.read_only_enforcement, "read_only_enforcement", s.system_name);
|
|
753
|
+
sys.transport_security = requireMeasured(s.transport_security, "transport_security", s.system_name);
|
|
754
|
+
}
|
|
755
|
+
return sys;
|
|
756
|
+
});
|
|
584
757
|
const payload = {
|
|
585
758
|
attested_at: attestedAt,
|
|
586
|
-
payload_type: certFormatVersion
|
|
587
|
-
? PAYLOAD_TYPE_ATTESTATION_V5
|
|
588
|
-
: PAYLOAD_TYPE_ATTESTATION,
|
|
759
|
+
payload_type: attestationPayloadType(certFormatVersion),
|
|
589
760
|
proof_mode: att.proof_mode,
|
|
590
761
|
subject_hash: toHex(subject.identifier_hash),
|
|
591
762
|
systems,
|
|
592
763
|
};
|
|
593
764
|
return canonicalJson(payload);
|
|
594
765
|
}
|
|
766
|
+
/**
|
|
767
|
+
* Read a v7 measured field that MUST be a non-empty string.
|
|
768
|
+
*
|
|
769
|
+
* A missing or non-string value cannot be turned into `""` and canonicalized:
|
|
770
|
+
* the signer never emits an empty measurement, so an empty one here would
|
|
771
|
+
* rebuild bytes no signature covers and be reported as forgery. Refusing with a
|
|
772
|
+
* document-shape message says the true thing — this record is malformed, not
|
|
773
|
+
* this record is fake.
|
|
774
|
+
*/
|
|
775
|
+
function requireMeasured(value, field, systemName) {
|
|
776
|
+
if (typeof value !== "string" || value === "") {
|
|
777
|
+
throw new VerificationError(`system ${JSON.stringify(systemName)}: ${field} is missing from a 7.0 record, ` +
|
|
778
|
+
"which signs it; the document is incomplete rather than unverifiable");
|
|
779
|
+
}
|
|
780
|
+
return value;
|
|
781
|
+
}
|
|
782
|
+
/** Domain separator for the record itself, by certificate format version. */
|
|
783
|
+
function certificatePayloadType(version) {
|
|
784
|
+
checkFormatVersion(version);
|
|
785
|
+
if (version === FORMAT_VERSION_V7)
|
|
786
|
+
return PAYLOAD_TYPE_VERIFICATION_RECORD_V7;
|
|
787
|
+
if (version === FORMAT_VERSION_V6)
|
|
788
|
+
return PAYLOAD_TYPE_VERIFICATION_RECORD_V6;
|
|
789
|
+
if (version === FORMAT_VERSION_V5)
|
|
790
|
+
return PAYLOAD_TYPE_CERTIFICATE_V5;
|
|
791
|
+
if (version === FORMAT_VERSION_V4)
|
|
792
|
+
return PAYLOAD_TYPE_CERTIFICATE_V4;
|
|
793
|
+
return PAYLOAD_TYPE_CERTIFICATE;
|
|
794
|
+
}
|
|
795
|
+
/**
|
|
796
|
+
* Domain separator for the attestation carried by a record of that format. It
|
|
797
|
+
* follows the record's version because a verifier rebuilds the attestation
|
|
798
|
+
* payload from the record it is checking, and must reproduce the exact bytes
|
|
799
|
+
* the enclave signed.
|
|
800
|
+
*/
|
|
801
|
+
function attestationPayloadType(version) {
|
|
802
|
+
checkFormatVersion(version);
|
|
803
|
+
if (version === FORMAT_VERSION_V7)
|
|
804
|
+
return PAYLOAD_TYPE_ATTESTATION_V7;
|
|
805
|
+
if (version === FORMAT_VERSION_V6)
|
|
806
|
+
return PAYLOAD_TYPE_ATTESTATION_V6;
|
|
807
|
+
if (version === FORMAT_VERSION_V5)
|
|
808
|
+
return PAYLOAD_TYPE_ATTESTATION_V5;
|
|
809
|
+
return PAYLOAD_TYPE_ATTESTATION;
|
|
810
|
+
}
|
|
595
811
|
function buildCertificatePayload(cert) {
|
|
596
812
|
const att = cert.attestation;
|
|
597
813
|
const issuer = cert.issuer;
|
|
@@ -599,19 +815,28 @@ function buildCertificatePayload(cert) {
|
|
|
599
815
|
// One list, keyed by system_id (ADR-016 §2). v2 signed an attestation list
|
|
600
816
|
// and a verification list joined only on the human-editable system_name,
|
|
601
817
|
// which made a partial deletion indistinguishable from a complete one.
|
|
602
|
-
const
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
818
|
+
const version = cert.certificate_format_version;
|
|
819
|
+
const isV7 = version === FORMAT_VERSION_V7;
|
|
820
|
+
const systems = (cert.systems ?? []).map((s) => {
|
|
821
|
+
const sys = {
|
|
822
|
+
attested_at: formatTimestamp(s.attested_at),
|
|
823
|
+
attested_count: s.attested_count,
|
|
824
|
+
canonical_version: s.canonical_version ?? null,
|
|
825
|
+
connector_type: s.connector_type,
|
|
826
|
+
hash_scope: s.hash_scope,
|
|
827
|
+
merkle_root: s.merkle_root ? toHex(s.merkle_root) : null,
|
|
828
|
+
query_hash: toHex(s.query_hash),
|
|
829
|
+
system_id: s.system_id,
|
|
830
|
+
system_name: s.system_name,
|
|
831
|
+
verified_at: formatTimestamp(s.verified_at),
|
|
832
|
+
verified_count: s.verified_count,
|
|
833
|
+
};
|
|
834
|
+
if (isV7) {
|
|
835
|
+
sys.read_only_enforcement = requireMeasured(s.read_only_enforcement, "read_only_enforcement", s.system_name);
|
|
836
|
+
sys.transport_security = requireMeasured(s.transport_security, "transport_security", s.system_name);
|
|
837
|
+
}
|
|
838
|
+
return sys;
|
|
839
|
+
});
|
|
615
840
|
// The attestation block carries no system list of its own: the merged list
|
|
616
841
|
// above is a superset of it. verification_signature is gone entirely.
|
|
617
842
|
const attObj = {
|
|
@@ -622,20 +847,28 @@ function buildCertificatePayload(cert) {
|
|
|
622
847
|
// Fields added after v3 are gated on the certificate's OWN version. A v3
|
|
623
848
|
// certificate must reconstruct to the same bytes forever; reading these
|
|
624
849
|
// unconditionally would break every certificate already issued.
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
850
|
+
// v6 carries v5's shape exactly, so it takes every gate v5 takes. Naming
|
|
851
|
+
// these "Plus" rather than testing equality at each use is what stops a new
|
|
852
|
+
// version from silently missing one.
|
|
853
|
+
const isV5Plus = version === FORMAT_VERSION_V5 || version === FORMAT_VERSION_V6 || isV7;
|
|
854
|
+
const isV4Plus = version === FORMAT_VERSION_V4 || isV5Plus;
|
|
628
855
|
const issuerObj = {
|
|
629
856
|
key_id: issuer.key_id,
|
|
630
857
|
name: issuer.name,
|
|
631
858
|
public_key: toHex(issuer.public_key),
|
|
632
859
|
};
|
|
860
|
+
// v7 signs the algorithm, and does so unconditionally: unlike legal_entity
|
|
861
|
+
// and enclave_pcr0, "which scheme signed this" is never unknown to a signer,
|
|
862
|
+
// so a v7 record missing it is malformed rather than merely sparse.
|
|
863
|
+
if (isV7) {
|
|
864
|
+
issuerObj.algorithm = requireMeasured(issuer.algorithm, "issuer.algorithm", "issuer");
|
|
865
|
+
}
|
|
633
866
|
if (isV4Plus && typeof issuer.legal_entity === "string" && issuer.legal_entity !== "") {
|
|
634
867
|
issuerObj.legal_entity = issuer.legal_entity;
|
|
635
868
|
}
|
|
636
869
|
// Signed from v5, and omitted when absent or empty: a build with no
|
|
637
870
|
// measurement signs none, and "" is a value a reader could mistake for one.
|
|
638
|
-
if (
|
|
871
|
+
if (isV5Plus && typeof issuer.enclave_pcr0 === "string" && issuer.enclave_pcr0 !== "") {
|
|
639
872
|
issuerObj.enclave_pcr0 = issuer.enclave_pcr0;
|
|
640
873
|
}
|
|
641
874
|
// v5 drops identifier_type_hint: signed, but always the constant "custom",
|
|
@@ -643,7 +876,7 @@ function buildCertificatePayload(cert) {
|
|
|
643
876
|
const subjectObj = {
|
|
644
877
|
identifier_hash: toHex(subject.identifier_hash),
|
|
645
878
|
};
|
|
646
|
-
if (!
|
|
879
|
+
if (!isV5Plus) {
|
|
647
880
|
subjectObj.identifier_type_hint = subject.identifier_type_hint;
|
|
648
881
|
}
|
|
649
882
|
// status and revocation are deliberately absent (ADR-016 §3): a signature
|
|
@@ -656,11 +889,7 @@ function buildCertificatePayload(cert) {
|
|
|
656
889
|
certificate_id: cert.certificate_id,
|
|
657
890
|
issued_at: formatTimestamp(cert.issued_at),
|
|
658
891
|
issuer: issuerObj,
|
|
659
|
-
payload_type:
|
|
660
|
-
? PAYLOAD_TYPE_CERTIFICATE_V5
|
|
661
|
-
: isV4Plus
|
|
662
|
-
? PAYLOAD_TYPE_CERTIFICATE_V4
|
|
663
|
-
: PAYLOAD_TYPE_CERTIFICATE,
|
|
892
|
+
payload_type: certificatePayloadType(version),
|
|
664
893
|
subject: subjectObj,
|
|
665
894
|
systems,
|
|
666
895
|
};
|
|
@@ -703,15 +932,37 @@ function attestationSystems(cert) {
|
|
|
703
932
|
observed_at: s.attested_at,
|
|
704
933
|
merkle_root: s.merkle_root,
|
|
705
934
|
canonical_version: s.canonical_version,
|
|
935
|
+
// Carried through so the v7 attestation payload can be rebuilt from the
|
|
936
|
+
// record. Undefined on older formats, where buildAttestationPayload never
|
|
937
|
+
// reads them.
|
|
938
|
+
read_only_enforcement: s.read_only_enforcement,
|
|
939
|
+
transport_security: s.transport_security,
|
|
706
940
|
}));
|
|
707
941
|
}
|
|
708
|
-
|
|
942
|
+
/**
|
|
943
|
+
* Port of Go's BuildTreeHeadPayload.
|
|
944
|
+
*
|
|
945
|
+
* A head that names its log is signed in a DIFFERENT DOMAIN from one that does
|
|
946
|
+
* not (Q31). That is what makes log_id unstrippable: removing it moves the
|
|
947
|
+
* rebuild into burnledger.sth.v3 and adding one moves it into
|
|
948
|
+
* burnledger.sth.v7, and the signature fails either way. Heads signed before
|
|
949
|
+
* log ids existed carry none and reproduce exactly the bytes they always did.
|
|
950
|
+
*
|
|
951
|
+
* Exported for anchor.ts, which rebuilds the preimage of a head fetched from
|
|
952
|
+
* GET /v1/log/head. Not re-exported from index.ts: it is an internal seam
|
|
953
|
+
* between two modules of this package, not published surface.
|
|
954
|
+
*/
|
|
955
|
+
export function buildTreeHeadPayload(head) {
|
|
956
|
+
const logId = head.log_id;
|
|
957
|
+
const named = typeof logId === "string" && logId !== "";
|
|
709
958
|
const payload = {
|
|
710
|
-
payload_type: PAYLOAD_TYPE_TREE_HEAD,
|
|
959
|
+
payload_type: named ? PAYLOAD_TYPE_TREE_HEAD_V7 : PAYLOAD_TYPE_TREE_HEAD,
|
|
711
960
|
root_hash: toHex(head.root_hash),
|
|
712
961
|
timestamp: formatTimestamp(head.timestamp),
|
|
713
962
|
tree_size: head.tree_size,
|
|
714
963
|
};
|
|
964
|
+
if (named)
|
|
965
|
+
payload.log_id = logId;
|
|
715
966
|
return canonicalJson(payload);
|
|
716
967
|
}
|
|
717
968
|
// ---------------------------------------------------------------------------
|
|
@@ -866,6 +1117,42 @@ export function buildCertificateStatusPayload(stmt) {
|
|
|
866
1117
|
}
|
|
867
1118
|
return canonicalJson(payload);
|
|
868
1119
|
}
|
|
1120
|
+
/**
|
|
1121
|
+
* Port of Go's BuildKeyListPayload (core/key_list.go).
|
|
1122
|
+
*
|
|
1123
|
+
* The key array is sorted by key_id before signing: canonical JSON sorts
|
|
1124
|
+
* object keys but does nothing to array order, and the server has no reason to
|
|
1125
|
+
* preserve any particular order across a rotation. Optional entry fields are
|
|
1126
|
+
* omitted, never emitted as null, for the reason buildCertificateStatusPayload
|
|
1127
|
+
* omits them. `keys` is the array exactly as HANDED to the verifier — the
|
|
1128
|
+
* signature covers the published entries, not a parsed reinterpretation of
|
|
1129
|
+
* them, which is what makes an edited key_status detectable.
|
|
1130
|
+
*/
|
|
1131
|
+
export function buildKeyListPayload(doc) {
|
|
1132
|
+
const entries = doc.keys.map((e) => {
|
|
1133
|
+
const k = {};
|
|
1134
|
+
if (e.compromised_from != null)
|
|
1135
|
+
k.compromised_from = e.compromised_from;
|
|
1136
|
+
k.key_id = e.key_id;
|
|
1137
|
+
k.key_status = e.key_status;
|
|
1138
|
+
if (e.not_after != null)
|
|
1139
|
+
k.not_after = e.not_after;
|
|
1140
|
+
if (e.not_before != null && e.not_before !== "")
|
|
1141
|
+
k.not_before = e.not_before;
|
|
1142
|
+
k.public_key = e.public_key;
|
|
1143
|
+
return k;
|
|
1144
|
+
});
|
|
1145
|
+
entries.sort((a, b) => (a.key_id < b.key_id ? -1 : a.key_id === b.key_id ? 0 : 1));
|
|
1146
|
+
const payload = {
|
|
1147
|
+
payload_type: PAYLOAD_TYPE_KEY_LIST,
|
|
1148
|
+
keys: entries,
|
|
1149
|
+
statement_expires_at: formatTimestamp(doc.statement_expires_at),
|
|
1150
|
+
statement_issued_at: formatTimestamp(doc.statement_issued_at),
|
|
1151
|
+
sth_root_hash: toHex(doc.sth_root_hash),
|
|
1152
|
+
sth_tree_size: doc.sth_tree_size,
|
|
1153
|
+
};
|
|
1154
|
+
return canonicalJson(payload);
|
|
1155
|
+
}
|
|
869
1156
|
/** The verdict when a certificate verified but nothing said whether it was revoked. */
|
|
870
1157
|
export const VALID_REVOCATION_UNKNOWN = "VALID_REVOCATION_UNKNOWN";
|
|
871
1158
|
/**
|
|
@@ -881,28 +1168,43 @@ export const VALID_REVOCATION_UNKNOWN = "VALID_REVOCATION_UNKNOWN";
|
|
|
881
1168
|
* | input | result |
|
|
882
1169
|
* |---|---|
|
|
883
1170
|
* | fresh statement, REVOKED | throws VerificationError |
|
|
884
|
-
* | fresh statement, ACTIVE | `"VALID"
|
|
885
|
-
* | absent or expired statement | `"VALID_REVOCATION_UNKNOWN"
|
|
1171
|
+
* | fresh statement, ACTIVE | `"VALID"`, or a soft verdict on either key |
|
|
1172
|
+
* | absent or expired statement | `"VALID_REVOCATION_UNKNOWN"`, or the certificate key's soft verdict |
|
|
1173
|
+
* | statement key unusable (ADR-017) | throws VerificationError |
|
|
886
1174
|
*
|
|
887
|
-
* The
|
|
1175
|
+
* The third row is the point: `verifyCertificate` answers VALID there, which
|
|
888
1176
|
* reads as "not revoked" and is not something it checked.
|
|
889
1177
|
*/
|
|
890
1178
|
export async function verifyCertificateWithStatus(crypto, certificate, publicKeys, status, now) {
|
|
1179
|
+
// A refusal throws out of verifyCertificate, so `base` is VALID or one of the
|
|
1180
|
+
// two soft verdicts. Returning a soft verdict here skipped every check below
|
|
1181
|
+
// it: a revoked certificate signed by a compromised-later key answered
|
|
1182
|
+
// VALID_KEY_COMPROMISED_LATER and its statement was never authenticated. The
|
|
1183
|
+
// verdict is held instead, and resolved against what the statement says.
|
|
891
1184
|
const base = await verifyCertificate(crypto, certificate, publicKeys);
|
|
892
|
-
if (base !== "VALID") {
|
|
893
|
-
return base;
|
|
894
|
-
}
|
|
895
1185
|
if (status == null) {
|
|
896
|
-
return VALID_REVOCATION_UNKNOWN;
|
|
1186
|
+
return resolveWithBase(base, VALID_REVOCATION_UNKNOWN);
|
|
897
1187
|
}
|
|
898
1188
|
const keyId = status.key_id;
|
|
899
1189
|
const info = keyId ? publicKeys.get(keyId) : undefined;
|
|
900
1190
|
if (!info) {
|
|
901
1191
|
throw new VerificationError(`status statement signed by unknown key: ${keyId}`);
|
|
902
1192
|
}
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
1193
|
+
// The whole ADR-017 table, not just the deprecated boolean. `revoked` is
|
|
1194
|
+
// never set by a key loaded from /.well-known/burnledger-keys — that endpoint
|
|
1195
|
+
// publishes key_status — so this check read `false` for a key the issuer had
|
|
1196
|
+
// declared COMPROMISED, and whoever held that key could mint an ACTIVE
|
|
1197
|
+
// statement for a revoked certificate that every SDK and the browser bundle
|
|
1198
|
+
// rendered as a signed "not revoked". core.VerifyCertificateWithStatus has
|
|
1199
|
+
// always evaluated it.
|
|
1200
|
+
//
|
|
1201
|
+
// The anchor is the verifier's own clock, not a field inside the statement: a
|
|
1202
|
+
// statement must already be inside its own validity window to be served at
|
|
1203
|
+
// all, and `now` is a time this verifier observed rather than one the signing
|
|
1204
|
+
// key asserted about itself.
|
|
1205
|
+
const when = now ?? new Date();
|
|
1206
|
+
const at = formatTimestamp(when.toISOString());
|
|
1207
|
+
const statusKeyVerdict = requireUsableKey(info, parseRfc3339(at), keyId, "status statement key");
|
|
906
1208
|
const payload = buildCertificateStatusPayload(status);
|
|
907
1209
|
// decodeSignature, not hexToBytes: signatures arrive as hex, base64 or a byte
|
|
908
1210
|
// array depending on the producer, and every other signature on this path
|
|
@@ -930,17 +1232,42 @@ export async function verifyCertificateWithStatus(crypto, certificate, publicKey
|
|
|
930
1232
|
}
|
|
931
1233
|
// Compared in the normalized form the payload signs, so the freshness check
|
|
932
1234
|
// cannot disagree with what was signed about the same two instants.
|
|
933
|
-
const at = formatTimestamp((now ?? new Date()).toISOString());
|
|
934
1235
|
const issued = formatTimestamp(status.statement_issued_at);
|
|
935
1236
|
const expires = formatTimestamp(status.statement_expires_at);
|
|
936
1237
|
if (at < issued || at >= expires) {
|
|
937
1238
|
// Stale is not a weaker answer, it is no answer — including for a REVOKED
|
|
938
1239
|
// statement, which must never decay into VALID.
|
|
939
|
-
return VALID_REVOCATION_UNKNOWN;
|
|
1240
|
+
return resolveWithBase(base, VALID_REVOCATION_UNKNOWN);
|
|
940
1241
|
}
|
|
941
1242
|
if (status.status === "REVOKED") {
|
|
942
|
-
|
|
1243
|
+
// Coded, because this is the only status-path rejection that is a fact
|
|
1244
|
+
// about the certificate rather than a failure to authenticate the
|
|
1245
|
+
// statement. A caller that cannot tell the two apart will render an
|
|
1246
|
+
// unsigned statement as a revocation.
|
|
1247
|
+
throw new VerificationError("certificate has been revoked", CERTIFICATE_REVOKED);
|
|
1248
|
+
}
|
|
1249
|
+
// A state this SDK does not recognize is NOT active. This tested only for
|
|
1250
|
+
// REVOKED and treated everything else as a pass, so a signed, fresh,
|
|
1251
|
+
// correctly-bound statement saying "SUSPENDED" — or "revoked" in the wrong
|
|
1252
|
+
// case — was reported as revocation CHECKED AND PASSED. Mirrors
|
|
1253
|
+
// core.ValidCertificateStatusValue.
|
|
1254
|
+
if (!KNOWN_STATUS_VALUES.has(status.status)) {
|
|
1255
|
+
return resolveWithBase(base, VALID_REVOCATION_UNKNOWN);
|
|
943
1256
|
}
|
|
944
|
-
|
|
1257
|
+
// A soft verdict on the status key is the result, not a footnote — but it is
|
|
1258
|
+
// reported only once the statement has authenticated and been read, so it can
|
|
1259
|
+
// neither speak for an unverified statement nor suppress a revocation.
|
|
1260
|
+
return resolveWithBase(base, statusKeyVerdict);
|
|
1261
|
+
}
|
|
1262
|
+
/**
|
|
1263
|
+
* Picks what to report when the certificate's own key returned a soft verdict
|
|
1264
|
+
* and the status path also has something to say. A doubt about the key that
|
|
1265
|
+
* signed the certificate questions the whole document, so it outranks a doubt
|
|
1266
|
+
* about revocation or about the statement's own key; only one verdict string
|
|
1267
|
+
* comes back, and this is the one a reader most needs. Mirrors
|
|
1268
|
+
* core.resolveWithBase.
|
|
1269
|
+
*/
|
|
1270
|
+
function resolveWithBase(base, verdict) {
|
|
1271
|
+
return base !== "VALID" ? base : verdict;
|
|
945
1272
|
}
|
|
946
1273
|
//# sourceMappingURL=verify.js.map
|