burnledger 0.9.0 → 0.10.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 +9 -4
- package/dist/cjs/audit-pack.d.ts +201 -0
- package/dist/cjs/audit-pack.d.ts.map +1 -0
- package/dist/cjs/audit-pack.js +867 -0
- package/dist/cjs/audit-pack.js.map +1 -0
- package/dist/cjs/client.d.ts +41 -1
- package/dist/cjs/client.d.ts.map +1 -1
- package/dist/cjs/client.js +107 -59
- package/dist/cjs/client.js.map +1 -1
- package/dist/cjs/enclave-registration.d.ts +14 -2
- package/dist/cjs/enclave-registration.d.ts.map +1 -1
- package/dist/cjs/enclave-registration.js +14 -2
- package/dist/cjs/enclave-registration.js.map +1 -1
- package/dist/cjs/index.d.ts +46 -2
- package/dist/cjs/index.d.ts.map +1 -1
- package/dist/cjs/index.js +114 -2
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/models.d.ts +28 -2
- package/dist/cjs/models.d.ts.map +1 -1
- package/dist/cjs/models.js +19 -1
- package/dist/cjs/models.js.map +1 -1
- package/dist/cjs/node-runtime.d.ts +40 -0
- package/dist/cjs/node-runtime.d.ts.map +1 -0
- package/dist/cjs/node-runtime.js +42 -0
- package/dist/cjs/node-runtime.js.map +1 -0
- package/dist/cjs/run-record.d.ts +130 -0
- package/dist/cjs/run-record.d.ts.map +1 -0
- package/dist/cjs/run-record.js +272 -0
- package/dist/cjs/run-record.js.map +1 -0
- package/dist/cjs/verify.d.ts +58 -0
- package/dist/cjs/verify.d.ts.map +1 -1
- package/dist/cjs/verify.js +277 -38
- package/dist/cjs/verify.js.map +1 -1
- package/dist/cjs/webhooks.d.ts +9 -2
- package/dist/cjs/webhooks.d.ts.map +1 -1
- package/dist/cjs/webhooks.js +29 -11
- package/dist/cjs/webhooks.js.map +1 -1
- package/dist/esm/audit-pack.d.ts +201 -0
- package/dist/esm/audit-pack.d.ts.map +1 -0
- package/dist/esm/audit-pack.js +858 -0
- package/dist/esm/audit-pack.js.map +1 -0
- package/dist/esm/cli.d.ts +52 -0
- package/dist/esm/cli.d.ts.map +1 -1
- package/dist/esm/cli.js +283 -11
- package/dist/esm/cli.js.map +1 -1
- package/dist/esm/client.d.ts +41 -1
- package/dist/esm/client.d.ts.map +1 -1
- package/dist/esm/client.js +108 -27
- package/dist/esm/client.js.map +1 -1
- package/dist/esm/enclave-registration.d.ts +14 -2
- package/dist/esm/enclave-registration.d.ts.map +1 -1
- package/dist/esm/enclave-registration.js +14 -2
- package/dist/esm/enclave-registration.js.map +1 -1
- package/dist/esm/index.d.ts +46 -2
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +64 -1
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/models.d.ts +28 -2
- package/dist/esm/models.d.ts.map +1 -1
- package/dist/esm/models.js +18 -1
- package/dist/esm/models.js.map +1 -1
- package/dist/esm/node-runtime.d.ts +40 -0
- package/dist/esm/node-runtime.d.ts.map +1 -0
- package/dist/esm/node-runtime.js +38 -0
- package/dist/esm/node-runtime.js.map +1 -0
- package/dist/esm/run-record.d.ts +130 -0
- package/dist/esm/run-record.d.ts.map +1 -0
- package/dist/esm/run-record.js +262 -0
- package/dist/esm/run-record.js.map +1 -0
- package/dist/esm/verify.d.ts +58 -0
- package/dist/esm/verify.d.ts.map +1 -1
- package/dist/esm/verify.js +270 -41
- package/dist/esm/verify.js.map +1 -1
- package/dist/esm/webhooks.d.ts +9 -2
- package/dist/esm/webhooks.d.ts.map +1 -1
- package/dist/esm/webhooks.js +29 -11
- package/dist/esm/webhooks.js.map +1 -1
- package/package.json +1 -1
- package/src/audit-pack.ts +1067 -0
- package/src/cli.ts +289 -10
- package/src/client.ts +132 -27
- package/src/enclave-registration.ts +14 -2
- package/src/index.ts +130 -1
- package/src/models.ts +47 -3
- package/src/node-runtime.ts +57 -0
- package/src/run-record.ts +371 -0
- package/src/verify.ts +301 -41
- package/src/webhooks.ts +28 -11
package/src/verify.ts
CHANGED
|
@@ -88,6 +88,17 @@ const FORMAT_VERSION_V8 = "8.0";
|
|
|
88
88
|
const PAYLOAD_TYPE_ATTESTATION_V9 = "burnledger.attestation.v9";
|
|
89
89
|
const PAYLOAD_TYPE_VERIFICATION_RECORD_V9 = "burnledger.verification_record.v9";
|
|
90
90
|
const FORMAT_VERSION_V9 = "9.0";
|
|
91
|
+
// v10 signs one top-level field, run_id: the run-level record (ADR-031) the
|
|
92
|
+
// record was issued under, when it was issued under one. The attestation bytes
|
|
93
|
+
// are v9's; its tag still moves, for the reason every version's does.
|
|
94
|
+
//
|
|
95
|
+
// READ-ONLY IN THIS BUILD, mirroring core, for the runbook-invariant-5 reason
|
|
96
|
+
// spelled out for v8 above. Until issuance moves, a run binds its records by
|
|
97
|
+
// the run record's records_root alone (run-record.ts), which is the binding
|
|
98
|
+
// the verifier checks in every case.
|
|
99
|
+
const PAYLOAD_TYPE_ATTESTATION_V10 = "burnledger.attestation.v10";
|
|
100
|
+
const PAYLOAD_TYPE_VERIFICATION_RECORD_V10 = "burnledger.verification_record.v10";
|
|
101
|
+
const FORMAT_VERSION_V10 = "10.0";
|
|
91
102
|
const FORMAT_VERSION_V3 = "3.0";
|
|
92
103
|
|
|
93
104
|
/**
|
|
@@ -115,6 +126,7 @@ export const KNOWN_FORMAT_VERSIONS: readonly string[] = Object.freeze([
|
|
|
115
126
|
FORMAT_VERSION_V7,
|
|
116
127
|
FORMAT_VERSION_V8,
|
|
117
128
|
FORMAT_VERSION_V9,
|
|
129
|
+
FORMAT_VERSION_V10,
|
|
118
130
|
]);
|
|
119
131
|
|
|
120
132
|
/**
|
|
@@ -193,6 +205,20 @@ function signatureCoversAuthorization(version: string): boolean {
|
|
|
193
205
|
return formatAtLeast(version, FORMAT_VERSION_V9);
|
|
194
206
|
}
|
|
195
207
|
|
|
208
|
+
/**
|
|
209
|
+
* Does this format sign the top-level run_id (ADR-031)?
|
|
210
|
+
*
|
|
211
|
+
* v10 onward, and optional even there: a record issued outside a run has none.
|
|
212
|
+
* A record OLDER than v10 that carries one is refused outright rather than
|
|
213
|
+
* signed without it — the field would ride in the JSON beside a signature that
|
|
214
|
+
* says nothing about it. Exported for the audit pack verifier, which asks the
|
|
215
|
+
* same question of a record that carries none: below v10 that says nothing
|
|
216
|
+
* about its run, from v10 it says the record was issued under no run.
|
|
217
|
+
*/
|
|
218
|
+
export function signatureCoversRunId(version: string): boolean {
|
|
219
|
+
return formatAtLeast(version, FORMAT_VERSION_V10);
|
|
220
|
+
}
|
|
221
|
+
|
|
196
222
|
/** The two values a v9 record's per-system authorization may take. */
|
|
197
223
|
const AUTHORIZATION_CERTIFIED = "certified";
|
|
198
224
|
const AUTHORIZATION_NONE = "none";
|
|
@@ -447,6 +473,18 @@ export function evaluateKey(pki: PublicKeyInfo, anchor: number | null): string {
|
|
|
447
473
|
if (pki.revoked) return "UNKNOWN_KEY";
|
|
448
474
|
if (pki.keyStatus === "compromised") {
|
|
449
475
|
if (anchor === null || pki.compromisedFrom === undefined) return KEY_COMPROMISED;
|
|
476
|
+
// The validity window is tested before the compromise anchor (R03-1). A
|
|
477
|
+
// signature made outside the stated interval was never authorized, whatever
|
|
478
|
+
// the key's later disposition, so an anchor outside the window is
|
|
479
|
+
// KEY_OUTSIDE_VALIDITY even when it predates compromisedFrom. Skipping the
|
|
480
|
+
// window handed the soft, usable VALID_KEY_COMPROMISED_LATER to a signature
|
|
481
|
+
// the window alone already refuses.
|
|
482
|
+
if (pki.notBefore !== undefined && anchor < parseRfc3339(pki.notBefore)) {
|
|
483
|
+
return KEY_OUTSIDE_VALIDITY;
|
|
484
|
+
}
|
|
485
|
+
if (pki.notAfter !== undefined && anchor >= parseRfc3339(pki.notAfter)) {
|
|
486
|
+
return KEY_OUTSIDE_VALIDITY;
|
|
487
|
+
}
|
|
450
488
|
return anchor < parseRfc3339(pki.compromisedFrom)
|
|
451
489
|
? VALID_KEY_COMPROMISED_LATER
|
|
452
490
|
: KEY_COMPROMISED;
|
|
@@ -491,19 +529,28 @@ export function certificateAnchor(certificate: Record<string, unknown>): number
|
|
|
491
529
|
}
|
|
492
530
|
|
|
493
531
|
/**
|
|
494
|
-
* The anchor, but only once
|
|
495
|
-
*
|
|
532
|
+
* The anchor, but only once this record has been PROVEN to be in the tree head
|
|
533
|
+
* carrying it — its inclusion proof verified against that head, not merely the
|
|
534
|
+
* head's own signature checked.
|
|
496
535
|
*
|
|
497
|
-
* The bypass this closes: for a key declared compromised,
|
|
498
|
-
* KEY_COMPROMISED with no anchor and
|
|
499
|
-
* anchor predates the compromise — and the
|
|
500
|
-
* passes through as the RESULT of
|
|
501
|
-
*
|
|
502
|
-
*
|
|
503
|
-
*
|
|
536
|
+
* The bypass this closes (R12-1/R14-1): for a key declared compromised,
|
|
537
|
+
* evaluateKey answers KEY_COMPROMISED with no anchor and
|
|
538
|
+
* VALID_KEY_COMPROMISED_LATER when the anchor predates the compromise — and the
|
|
539
|
+
* second is a verdict requireUsableKey passes through as the RESULT of
|
|
540
|
+
* verifyCertificate. A signed tree head is a public artifact: any genuine older
|
|
541
|
+
* head the issuer ever published can be stapled onto a record it never
|
|
542
|
+
* contained, and its signature still verifies. Checking only the head signature
|
|
543
|
+
* let a holder of a certificate signed after the compromise attach a
|
|
544
|
+
* pre-compromise head and have the forgery reported as verified-with-a-caveat.
|
|
545
|
+
* The head's timestamp is an honest anchor for THIS record only once THIS record
|
|
546
|
+
* is shown to be committed to under it.
|
|
504
547
|
*
|
|
505
|
-
*
|
|
506
|
-
*
|
|
548
|
+
* checkCertificateInclusion is exactly that proof; it deliberately does NOT test
|
|
549
|
+
* the key's usability at head time — that is the verdict this anchor exists to
|
|
550
|
+
* compute, so gating the anchor on it would drop the anchor for an out-of-window
|
|
551
|
+
* key and soften KEY_OUTSIDE_VALIDITY to VALID_KEY_WINDOW_UNKNOWN. Anything short
|
|
552
|
+
* of a held inclusion proof yields no anchor, the same conservative answer as a
|
|
553
|
+
* certificate with no transparency block at all.
|
|
507
554
|
*/
|
|
508
555
|
async function verifiedCertificateAnchor(
|
|
509
556
|
crypto: CryptoOps,
|
|
@@ -513,21 +560,15 @@ async function verifiedCertificateAnchor(
|
|
|
513
560
|
const anchor = certificateAnchor(certificate);
|
|
514
561
|
if (anchor === null) return null;
|
|
515
562
|
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
//
|
|
519
|
-
// here gave a defect in this library — or a crypto provider that threw — the
|
|
520
|
-
// same quiet answer as a malformed document.
|
|
521
|
-
let headPayload: Uint8Array;
|
|
522
|
-
let headSig: Uint8Array;
|
|
563
|
+
// Only a refusal of the proof means "no anchor". A defect in this library, or
|
|
564
|
+
// a crypto provider that threw, must not get the same quiet answer as a
|
|
565
|
+
// document whose inclusion proof does not verify.
|
|
523
566
|
try {
|
|
524
|
-
|
|
525
|
-
headSig = decodeFixed(sth.signature, "signed_tree_head.signature", 64);
|
|
567
|
+
await checkCertificateInclusion(crypto, certificate, pki);
|
|
526
568
|
} catch (e) {
|
|
527
569
|
if (e instanceof VerificationError) return null;
|
|
528
570
|
throw e;
|
|
529
571
|
}
|
|
530
|
-
if (!(await crypto.ed25519Verify(pki.keyBytes, headPayload, headSig))) return null;
|
|
531
572
|
return anchor;
|
|
532
573
|
}
|
|
533
574
|
|
|
@@ -949,15 +990,49 @@ export async function verifyTransparency(
|
|
|
949
990
|
return "NOT_AVAILABLE" as TransparencyResult;
|
|
950
991
|
}
|
|
951
992
|
const transparency = objectAt(certificate, "transparency", "transparency");
|
|
952
|
-
|
|
953
|
-
|
|
993
|
+
// Read before the key lookup, as it always was: a proof with no head is
|
|
994
|
+
// reported as that, not as a key the reader does not hold.
|
|
995
|
+
objectAt(transparency, "signed_tree_head", "transparency.signed_tree_head");
|
|
954
996
|
const issuer = objectAt(certificate, "issuer", "issuer");
|
|
955
997
|
const keyId = stringAt(issuer, "key_id", "issuer.key_id");
|
|
956
998
|
|
|
957
999
|
const pki = publicKeys.get(keyId);
|
|
958
1000
|
if (pki === undefined) throw new VerificationError(`unknown issuer key: ${keyId}`);
|
|
959
|
-
|
|
960
|
-
|
|
1001
|
+
await verifyTransparencyHead(crypto, transparency, pki, keyId);
|
|
1002
|
+
|
|
1003
|
+
// The leaf is NOT the certificate. It is the canonical log_leaf.v3 payload
|
|
1004
|
+
// over entry_type, certificate_id, certificate_hash and appended_at, where
|
|
1005
|
+
// certificate_hash is SHA-256 of the issuance-time certificate JSON
|
|
1006
|
+
// (ADR-016 §4). v2 hashed the certificate directly, so an issuance and a
|
|
1007
|
+
// revocation of the same certificate produced identical leaves and the tree
|
|
1008
|
+
// committed to neither the entry type nor when it happened.
|
|
1009
|
+
const issuanceData = issuanceBytes(certificate);
|
|
1010
|
+
const leafPayload = buildLogLeafPayload(
|
|
1011
|
+
transparency.entry_type,
|
|
1012
|
+
stringAt(certificate, "certificate_id", "certificate_id"),
|
|
1013
|
+
await crypto.sha256(issuanceData),
|
|
1014
|
+
transparency.appended_at,
|
|
1015
|
+
);
|
|
1016
|
+
await verifyTransparencyInclusion(crypto, transparency, await hashLeaf(crypto, leafPayload));
|
|
1017
|
+
|
|
1018
|
+
return "INCLUDED" as TransparencyResult;
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
/**
|
|
1022
|
+
* Verify the head signature and the Merkle inclusion of a certificate's proof —
|
|
1023
|
+
* everything about whether this record is committed to under its head, but NOT
|
|
1024
|
+
* whether the key was usable when it signed. That last question is the caller's;
|
|
1025
|
+
* certificateAnchor needs membership, not authority, and folding authority in
|
|
1026
|
+
* here made the anchor for an out-of-window key collapse to "no anchor" and the
|
|
1027
|
+
* verdict soften. Throws VerificationError on any inclusion failure.
|
|
1028
|
+
*/
|
|
1029
|
+
async function checkCertificateInclusion(
|
|
1030
|
+
crypto: CryptoOps,
|
|
1031
|
+
certificate: Cert,
|
|
1032
|
+
pki: PublicKeyInfo,
|
|
1033
|
+
): Promise<void> {
|
|
1034
|
+
const transparency = objectAt(certificate, "transparency", "transparency");
|
|
1035
|
+
const sth = objectAt(transparency, "signed_tree_head", "transparency.signed_tree_head");
|
|
961
1036
|
|
|
962
1037
|
// 1. Tree head signature
|
|
963
1038
|
const headPayload = buildTreeHeadPayload(sth);
|
|
@@ -1028,8 +1103,115 @@ export async function verifyTransparency(
|
|
|
1028
1103
|
if (!(await verifyInclusion(crypto, leaf, index, treeSize, proofHashes, root))) {
|
|
1029
1104
|
throw new VerificationError("merkle inclusion proof is invalid");
|
|
1030
1105
|
}
|
|
1106
|
+
}
|
|
1031
1107
|
|
|
1032
|
-
|
|
1108
|
+
/** The first half of a transparency check, shared with the run record's
|
|
1109
|
+
* (run-record.ts): the key's authority at the head's own timestamp, then the
|
|
1110
|
+
* head's signature. The tree head carries its own timestamp, so this path
|
|
1111
|
+
* always has an anchor; a timestamp that does not read leaves none, which is
|
|
1112
|
+
* the same conservative answer as no proof at all. Throws the VerificationError
|
|
1113
|
+
* the caller reports. Exported as an internal seam, not from index.ts. */
|
|
1114
|
+
export async function verifyTransparencyHead(
|
|
1115
|
+
crypto: CryptoOps,
|
|
1116
|
+
transparency: Doc,
|
|
1117
|
+
pki: PublicKeyInfo,
|
|
1118
|
+
keyId: string,
|
|
1119
|
+
): Promise<void> {
|
|
1120
|
+
const sth = objectAt(transparency, "signed_tree_head", "transparency.signed_tree_head");
|
|
1121
|
+
let anchor: number | null = null;
|
|
1122
|
+
if (typeof sth.timestamp === "string") {
|
|
1123
|
+
try {
|
|
1124
|
+
anchor = parseRfc3339(sth.timestamp);
|
|
1125
|
+
} catch {
|
|
1126
|
+
anchor = null;
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
requireUsableKey(pki, anchor, keyId);
|
|
1130
|
+
|
|
1131
|
+
const headPayload = buildTreeHeadPayload(sth);
|
|
1132
|
+
const headSig = decodeFixed(sth.signature, "signed_tree_head.signature", 64);
|
|
1133
|
+
if (!(await crypto.ed25519Verify(pki.keyBytes, headPayload, headSig))) {
|
|
1134
|
+
throw new VerificationError("tree head signature is invalid");
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
/** The second half: an already-hashed leaf against the signed head, after the
|
|
1139
|
+
* unsigned duplicates beside the proof are held to the signed copies. Throws
|
|
1140
|
+
* the VerificationError the caller reports. Exported as an internal seam. */
|
|
1141
|
+
export async function verifyTransparencyInclusion(
|
|
1142
|
+
crypto: CryptoOps,
|
|
1143
|
+
transparency: Doc,
|
|
1144
|
+
leaf: Uint8Array,
|
|
1145
|
+
): Promise<void> {
|
|
1146
|
+
const sth = objectAt(transparency, "signed_tree_head", "transparency.signed_tree_head");
|
|
1147
|
+
const proofHashes = arrayAt(transparency, "inclusion_proof", "transparency.inclusion_proof").map(
|
|
1148
|
+
(h, i) => decodeFixed(h, `transparency.inclusion_proof[${i}]`, 32),
|
|
1149
|
+
);
|
|
1150
|
+
const root = decodeFixed(sth.root_hash, "signed_tree_head.root_hash", 32);
|
|
1151
|
+
// `?? 0` is not a convenience: encoding/json leaves 0 in Go's uint64 fields
|
|
1152
|
+
// for an explicit null and for an absent key rather than failing, so refusing
|
|
1153
|
+
// either would reject documents the reference accepts — the same class of
|
|
1154
|
+
// divergence as #452. An offline verifier is only useful while it agrees.
|
|
1155
|
+
const index = requireUint(transparency.entry_index ?? 0, "entry_index");
|
|
1156
|
+
const treeSize = requireUint(sth.tree_size ?? 0, "signed_tree_head.tree_size");
|
|
1157
|
+
|
|
1158
|
+
// transparency.tree_size duplicates the signed one but carries no signature:
|
|
1159
|
+
// BuildTreeHeadPayload covers only the copy inside signed_tree_head. Go used
|
|
1160
|
+
// to verify against the unsigned copy while this SDK used the signed one, so
|
|
1161
|
+
// the same document got two verdicts (#456). Both now require the two to agree
|
|
1162
|
+
// and then verify against the signed copy — the server always writes them
|
|
1163
|
+
// equal, so this rejects only edited documents.
|
|
1164
|
+
const unsignedSize = requireUint(
|
|
1165
|
+
transparency.tree_size ?? 0,
|
|
1166
|
+
"transparency.tree_size",
|
|
1167
|
+
);
|
|
1168
|
+
if (unsignedSize !== treeSize) {
|
|
1169
|
+
throw new VerificationError(
|
|
1170
|
+
`transparency.tree_size (${unsignedSize}) does not match the signed tree head ` +
|
|
1171
|
+
`(${treeSize}); it is not covered by any signature`,
|
|
1172
|
+
);
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
// Same rule for the log id (Q31). transparency.log_id sits beside log_url and
|
|
1176
|
+
// is the field a reader looks at to answer "which log is this?", and nothing
|
|
1177
|
+
// signs it — so a holder could relabel a genuine proof as belonging to a
|
|
1178
|
+
// different log while every signature still checked out. A head predating log
|
|
1179
|
+
// ids has neither side set and passes.
|
|
1180
|
+
const signedLogId = (sth.log_id as string | undefined) ?? "";
|
|
1181
|
+
const unsignedLogId = (transparency.log_id as string | undefined) ?? "";
|
|
1182
|
+
if (unsignedLogId !== signedLogId) {
|
|
1183
|
+
throw new VerificationError(
|
|
1184
|
+
`transparency.log_id (${JSON.stringify(unsignedLogId)}) does not match the signed ` +
|
|
1185
|
+
`tree head (${JSON.stringify(signedLogId)}); it is not covered by any signature`,
|
|
1186
|
+
);
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
if (!(await verifyInclusion(crypto, leaf, index, treeSize, proofHashes, root))) {
|
|
1190
|
+
throw new VerificationError("merkle inclusion proof is invalid");
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
/** The refusals of verifyTransparencyHead and verifyTransparencyInclusion under
|
|
1195
|
+
* core.TransparencyResult's names, for the verifiers that report a pack or a
|
|
1196
|
+
* run record beside Go's verdicts rather than by throwing. The key verdicts
|
|
1197
|
+
* collapse to KEY_UNUSABLE as they do in Go, whose transparency result does not
|
|
1198
|
+
* say which key problem. Anything unmatched is a field this SDK could not read:
|
|
1199
|
+
* UNREADABLE_DOCUMENT, never a tampering claim. */
|
|
1200
|
+
const TRANSPARENCY_VERDICTS: ReadonlyArray<[prefix: string, verdict: string]> = [
|
|
1201
|
+
["issuer key is compromised:", "KEY_UNUSABLE"],
|
|
1202
|
+
["issuer key was not valid when it signed:", "KEY_UNUSABLE"],
|
|
1203
|
+
["issuer key has an unusable status", "KEY_UNUSABLE"],
|
|
1204
|
+
["tree head signature is invalid", "INVALID_TREE_HEAD_SIGNATURE"],
|
|
1205
|
+
["transparency.tree_size (", "INVALID_INCLUSION_PROOF"],
|
|
1206
|
+
["transparency.log_id (", "INVALID_INCLUSION_PROOF"],
|
|
1207
|
+
["merkle inclusion proof is invalid", "INVALID_INCLUSION_PROOF"],
|
|
1208
|
+
];
|
|
1209
|
+
|
|
1210
|
+
export function transparencyVerdictName(e: VerificationError): string {
|
|
1211
|
+
for (const [prefix, verdict] of TRANSPARENCY_VERDICTS) {
|
|
1212
|
+
if (e.message.startsWith(prefix)) return verdict;
|
|
1213
|
+
}
|
|
1214
|
+
return "UNREADABLE_DOCUMENT";
|
|
1033
1215
|
}
|
|
1034
1216
|
|
|
1035
1217
|
// ---------------------------------------------------------------------------
|
|
@@ -1139,6 +1321,43 @@ function arrayAt(parent: Doc, key: string, path: string): unknown[] {
|
|
|
1139
1321
|
return Array.from(value);
|
|
1140
1322
|
}
|
|
1141
1323
|
|
|
1324
|
+
/** A UUID in the forms Go's uuid.Parse accepts: 8-4-4-4-12 hex, the same in
|
|
1325
|
+
* braces or behind "urn:uuid:", or 32 bare hex digits. */
|
|
1326
|
+
const UUID_RE = /^(?:urn:uuid:)?\{?([0-9a-fA-F]{8})-([0-9a-fA-F]{4})-([0-9a-fA-F]{4})-([0-9a-fA-F]{4})-([0-9a-fA-F]{12})\}?$/;
|
|
1327
|
+
const UUID_BARE_RE = /^[0-9a-fA-F]{32}$/;
|
|
1328
|
+
|
|
1329
|
+
/** The 16 bytes of a UUID, in any form Go's uuid.Parse reads. Throws TypeError
|
|
1330
|
+
* for anything else: a UUID reaches a signed payload only as its canonical
|
|
1331
|
+
* string, and an unreadable one must not be signed as whatever text arrived. */
|
|
1332
|
+
export function parseUuid(text: string): Uint8Array {
|
|
1333
|
+
if (typeof text !== "string") throw new TypeError(`uuid is not a string: ${typeof text}`);
|
|
1334
|
+
const m = UUID_RE.exec(text);
|
|
1335
|
+
const hex = m !== null ? m.slice(1).join("") : UUID_BARE_RE.test(text) ? text : null;
|
|
1336
|
+
if (hex === null) throw new TypeError(`not a UUID: ${JSON.stringify(text)}`);
|
|
1337
|
+
return hexToBytes(hex);
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
/** The canonical lowercase 8-4-4-4-12 form, as Go's uuid.UUID.String prints:
|
|
1341
|
+
* the only form a UUID takes inside a signed payload. */
|
|
1342
|
+
export function formatUuid(bytes: Uint8Array): string {
|
|
1343
|
+
if (bytes.length !== 16) throw new TypeError(`uuid is ${bytes.length} bytes, expected 16`);
|
|
1344
|
+
const h = bytesToHex(bytes);
|
|
1345
|
+
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
/** Read a UUID field as Go's uuid.UUID unmarshals it, refusing with a
|
|
1349
|
+
* VerificationError what encoding/json would refuse to parse. */
|
|
1350
|
+
function uuidAt(parent: Doc, key: string, path: string): string {
|
|
1351
|
+
const v = parent[key];
|
|
1352
|
+
if (typeof v !== "string") throw new VerificationError(`${path} is not a string`);
|
|
1353
|
+
try {
|
|
1354
|
+
return formatUuid(parseUuid(v));
|
|
1355
|
+
} catch (e) {
|
|
1356
|
+
if (e instanceof TypeError) throw new VerificationError(`${path} is not a UUID: ${JSON.stringify(v)}`);
|
|
1357
|
+
throw e;
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1142
1361
|
const HEX_TEXT_RE = /^(?:[0-9a-fA-F]{2})*$/;
|
|
1143
1362
|
const BASE64_TEXT_RE = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
|
|
1144
1363
|
|
|
@@ -1155,7 +1374,9 @@ const BASE64_TEXT_RE = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/
|
|
|
1155
1374
|
* leniently instead wrapped 300 to 44 inside a Uint8Array, produced bytes no
|
|
1156
1375
|
* signature covers and blamed the signature — or threw from inside atob.
|
|
1157
1376
|
*/
|
|
1158
|
-
|
|
1377
|
+
/** Exported for run-record.ts, whose digests and roots arrive in the same
|
|
1378
|
+
* shapes; not published from index.ts. */
|
|
1379
|
+
export function decodeFixed(value: unknown, path: string, length: number): Uint8Array {
|
|
1159
1380
|
if (value === undefined || value === null) throw new VerificationError(`${path} is missing`);
|
|
1160
1381
|
let raw: Uint8Array;
|
|
1161
1382
|
if (Array.isArray(value)) {
|
|
@@ -1430,6 +1651,7 @@ function requireMeasured(
|
|
|
1430
1651
|
/** Domain separator for the record itself, by certificate format version. */
|
|
1431
1652
|
function certificatePayloadType(version: string): string {
|
|
1432
1653
|
checkFormatVersion(version);
|
|
1654
|
+
if (version === FORMAT_VERSION_V10) return PAYLOAD_TYPE_VERIFICATION_RECORD_V10;
|
|
1433
1655
|
if (version === FORMAT_VERSION_V9) return PAYLOAD_TYPE_VERIFICATION_RECORD_V9;
|
|
1434
1656
|
if (version === FORMAT_VERSION_V8) return PAYLOAD_TYPE_VERIFICATION_RECORD_V8;
|
|
1435
1657
|
if (version === FORMAT_VERSION_V7) return PAYLOAD_TYPE_VERIFICATION_RECORD_V7;
|
|
@@ -1450,6 +1672,7 @@ function attestationPayloadType(version: string): string {
|
|
|
1450
1672
|
// Equality here, deliberately: each format has its OWN separator, so this is
|
|
1451
1673
|
// a lookup rather than a "this version onward" question. v3 and v4 share one
|
|
1452
1674
|
// because their attestation bytes are identical.
|
|
1675
|
+
if (version === FORMAT_VERSION_V10) return PAYLOAD_TYPE_ATTESTATION_V10;
|
|
1453
1676
|
if (version === FORMAT_VERSION_V9) return PAYLOAD_TYPE_ATTESTATION_V9;
|
|
1454
1677
|
if (version === FORMAT_VERSION_V8) return PAYLOAD_TYPE_ATTESTATION_V8;
|
|
1455
1678
|
if (version === FORMAT_VERSION_V7) return PAYLOAD_TYPE_ATTESTATION_V7;
|
|
@@ -1606,17 +1829,33 @@ function buildCertificatePayload(cert: Record<string, unknown>): Uint8Array {
|
|
|
1606
1829
|
version: stringAt(scope, "version", "scope.version"),
|
|
1607
1830
|
};
|
|
1608
1831
|
}
|
|
1832
|
+
// Signed from v10 when present. On an older format the field is refused
|
|
1833
|
+
// rather than dropped from the bytes: core.BuildCertificatePayload returns an
|
|
1834
|
+
// error there and the verdict is INVALID_CERTIFICATE_SIGNATURE, so the same
|
|
1835
|
+
// words open this message for the audit pack verifier to read it back as
|
|
1836
|
+
// that verdict. A JSON null is an absent run, as Go's *uuid.UUID reads it.
|
|
1837
|
+
if (cert.run_id != null) {
|
|
1838
|
+
if (!signatureCoversRunId(version)) {
|
|
1839
|
+
throw new VerificationError(
|
|
1840
|
+
`certificate signature is invalid: the record carries run_id, which format ${version} does not ` +
|
|
1841
|
+
`sign (run_id is signed from ${FORMAT_VERSION_V10}), so no signature covers the document as presented`,
|
|
1842
|
+
);
|
|
1843
|
+
}
|
|
1844
|
+
payload.run_id = uuidAt(cert, "run_id", "run_id");
|
|
1845
|
+
}
|
|
1609
1846
|
return canonicalJson(payload);
|
|
1610
1847
|
}
|
|
1611
1848
|
|
|
1612
|
-
/** Port of Go's BuildLogLeafPayload (ADR-016 §4).
|
|
1613
|
-
|
|
1849
|
+
/** Port of Go's BuildLogLeafPayload (ADR-016 §4). A RUN_RECORD leaf names its
|
|
1850
|
+
* run id where the other two name a certificate id, under the same key
|
|
1851
|
+
* (ADR-031). Exported for run-record.ts; not published from index.ts. */
|
|
1852
|
+
export function buildLogLeafPayload(
|
|
1614
1853
|
entryType: unknown,
|
|
1615
1854
|
certificateId: string,
|
|
1616
1855
|
certificateHash: Uint8Array,
|
|
1617
1856
|
appendedAt: unknown,
|
|
1618
1857
|
): Uint8Array {
|
|
1619
|
-
if (entryType !== "CERTIFICATE" && entryType !== "REVOCATION") {
|
|
1858
|
+
if (entryType !== "CERTIFICATE" && entryType !== "REVOCATION" && entryType !== "RUN_RECORD") {
|
|
1620
1859
|
throw new VerificationError(`invalid log entry_type: ${String(entryType)}`);
|
|
1621
1860
|
}
|
|
1622
1861
|
const payload = {
|
|
@@ -1687,10 +1926,21 @@ export function buildTreeHeadPayload(head: Record<string, unknown>): Uint8Array
|
|
|
1687
1926
|
// Merkle tree (RFC 6962) — ports of core/merkle.go
|
|
1688
1927
|
// ---------------------------------------------------------------------------
|
|
1689
1928
|
|
|
1690
|
-
|
|
1929
|
+
/** Exported for run-record.ts, whose roots are trees over these leaves. */
|
|
1930
|
+
export async function hashLeaf(crypto: CryptoOps, data: Uint8Array): Promise<Uint8Array> {
|
|
1691
1931
|
return crypto.sha256(concatBytes(new Uint8Array([0x00]), data));
|
|
1692
1932
|
}
|
|
1693
1933
|
|
|
1934
|
+
/** The RFC 6962 root over already-hashed leaves, in the order given. Port of
|
|
1935
|
+
* Go's treeHash, which like it takes at least one leaf: the empty tree has no
|
|
1936
|
+
* root here, and a caller with nothing to commit to says so itself. */
|
|
1937
|
+
export async function merkleRoot(crypto: CryptoOps, leaves: Uint8Array[]): Promise<Uint8Array> {
|
|
1938
|
+
if (leaves.length === 0) throw new RangeError("merkleRoot: no leaves");
|
|
1939
|
+
if (leaves.length === 1) return leaves[0]!;
|
|
1940
|
+
const k = splitPoint(leaves.length);
|
|
1941
|
+
return hashNode(crypto, await merkleRoot(crypto, leaves.slice(0, k)), await merkleRoot(crypto, leaves.slice(k)));
|
|
1942
|
+
}
|
|
1943
|
+
|
|
1694
1944
|
async function hashNode(crypto: CryptoOps, left: Uint8Array, right: Uint8Array): Promise<Uint8Array> {
|
|
1695
1945
|
const prefix = new Uint8Array([0x01]);
|
|
1696
1946
|
return crypto.sha256(concatBytes(prefix, concatBytes(left, right)));
|
|
@@ -1763,7 +2013,12 @@ async function chainInclusion(
|
|
|
1763
2013
|
}
|
|
1764
2014
|
|
|
1765
2015
|
function isPow2(n: number): boolean {
|
|
1766
|
-
|
|
2016
|
+
// Arithmetic, not `n & (n - 1)` (R12-2): JS bitwise operators coerce to 32-bit
|
|
2017
|
+
// signed integers, so for a tree size >= 2^31 the bit test gives the wrong
|
|
2018
|
+
// answer. This holds for every safe integer.
|
|
2019
|
+
if (n < 1) return false;
|
|
2020
|
+
while (n % 2 === 0) n /= 2;
|
|
2021
|
+
return n === 1;
|
|
1767
2022
|
}
|
|
1768
2023
|
|
|
1769
2024
|
/** Verify a Merkle consistency proof (RFC 6962 Section 2.1.4).
|
|
@@ -1800,9 +2055,14 @@ export async function verifyConsistency(
|
|
|
1800
2055
|
let fn = oldSize - 1;
|
|
1801
2056
|
let sn = newSize - 1;
|
|
1802
2057
|
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
2058
|
+
// Arithmetic throughout (R12-2). `& 1` and `>>= 1` coerce to 32-bit signed
|
|
2059
|
+
// integers, so for tree sizes >= 2^31 fn and sn are truncated and this
|
|
2060
|
+
// consistency check silently returned the wrong answer (CONFIRMED where Go
|
|
2061
|
+
// says INCONSISTENT). `% 2` and Math.floor(/2) are exact for every safe
|
|
2062
|
+
// integer, which requireUint already bounds the inputs to.
|
|
2063
|
+
while (fn % 2 === 1) {
|
|
2064
|
+
fn = Math.floor(fn / 2);
|
|
2065
|
+
sn = Math.floor(sn / 2);
|
|
1806
2066
|
}
|
|
1807
2067
|
|
|
1808
2068
|
// Drive the walk from the tree, not from the proof's length. Looping on
|
|
@@ -1817,19 +2077,19 @@ export async function verifyConsistency(
|
|
|
1817
2077
|
const c = proof[pIdx]!;
|
|
1818
2078
|
pIdx++;
|
|
1819
2079
|
|
|
1820
|
-
if (
|
|
2080
|
+
if (fn % 2 === 1 || fn === sn) {
|
|
1821
2081
|
fr = await hashNode(crypto, c, fr);
|
|
1822
2082
|
sr = await hashNode(crypto, c, sr);
|
|
1823
|
-
while (fn !== 0 &&
|
|
1824
|
-
fn
|
|
1825
|
-
sn
|
|
2083
|
+
while (fn !== 0 && fn % 2 === 0) {
|
|
2084
|
+
fn = Math.floor(fn / 2);
|
|
2085
|
+
sn = Math.floor(sn / 2);
|
|
1826
2086
|
}
|
|
1827
2087
|
} else {
|
|
1828
2088
|
sr = await hashNode(crypto, sr, c);
|
|
1829
2089
|
}
|
|
1830
2090
|
|
|
1831
|
-
fn
|
|
1832
|
-
sn
|
|
2091
|
+
fn = Math.floor(fn / 2);
|
|
2092
|
+
sn = Math.floor(sn / 2);
|
|
1833
2093
|
}
|
|
1834
2094
|
|
|
1835
2095
|
return pIdx === proof.length && bytesEqual(fr, oldRoot) && bytesEqual(sr, newRoot);
|
package/src/webhooks.ts
CHANGED
|
@@ -16,14 +16,32 @@
|
|
|
16
16
|
|
|
17
17
|
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
18
18
|
|
|
19
|
+
/** Strictly hex-decode `value`, or undefined if it is not even-length pure hex.
|
|
20
|
+
* Buffer.from(_, "hex") silently truncates at the first non-hex character, which
|
|
21
|
+
* would decode only the first signature of a rotation header — so the input is
|
|
22
|
+
* validated as pure hex first. */
|
|
23
|
+
function decodeHexStrict(value: string): Buffer | undefined {
|
|
24
|
+
if (value.length === 0 || value.length % 2 !== 0 || !/^[0-9a-fA-F]+$/.test(value)) {
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
return Buffer.from(value, "hex");
|
|
28
|
+
}
|
|
29
|
+
|
|
19
30
|
/**
|
|
20
31
|
* Verify that a webhook payload was signed by the expected secret.
|
|
21
32
|
*
|
|
33
|
+
* During a secret rotation the server sends both signatures in one header as
|
|
34
|
+
* `<old>,<new>` for a 24 h window, so whichever secret a receiver holds matches
|
|
35
|
+
* one of them without dropping deliveries. The header is split on the comma,
|
|
36
|
+
* each part is trimmed and strictly hex-decoded, and it verifies if any part
|
|
37
|
+
* matches under `secret`. A single signature (no comma) is the ordinary case and
|
|
38
|
+
* one iteration of the same loop.
|
|
39
|
+
*
|
|
22
40
|
* @param secret The webhook secret (raw UTF-8, as returned by the API)
|
|
23
41
|
* @param timestamp The RFC3339 timestamp from the X-BurnLedger-Timestamp header
|
|
24
42
|
* @param body The raw request body bytes
|
|
25
|
-
* @param signature The hex-encoded HMAC-SHA256 signature from the X-BurnLedger-Signature header
|
|
26
|
-
* @returns true if
|
|
43
|
+
* @param signature The hex-encoded HMAC-SHA256 signature(s) from the X-BurnLedger-Signature header
|
|
44
|
+
* @returns true if any signature in the header is valid
|
|
27
45
|
*/
|
|
28
46
|
export function verifyWebhookSignature(
|
|
29
47
|
secret: string,
|
|
@@ -44,16 +62,15 @@ export function verifyWebhookSignature(
|
|
|
44
62
|
.update(bodyBytes)
|
|
45
63
|
.digest();
|
|
46
64
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
65
|
+
// Every candidate is compared in constant time; the loop does not return early
|
|
66
|
+
// on a match so the work does not reveal which position matched.
|
|
67
|
+
let matched = false;
|
|
68
|
+
for (const part of signature.split(",")) {
|
|
69
|
+
const received = decodeHexStrict(part.trim());
|
|
70
|
+
if (received === undefined || received.length !== expected.length) continue;
|
|
71
|
+
if (timingSafeEqual(expected, received)) matched = true;
|
|
52
72
|
}
|
|
53
|
-
|
|
54
|
-
if (received.length !== expected.length) return false;
|
|
55
|
-
|
|
56
|
-
return timingSafeEqual(expected, received);
|
|
73
|
+
return matched;
|
|
57
74
|
}
|
|
58
75
|
|
|
59
76
|
/**
|