@mikeargento/bitgraph-audit 0.1.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.
Files changed (108) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +11 -0
  3. package/dist/anchors.d.ts +31 -0
  4. package/dist/anchors.d.ts.map +1 -0
  5. package/dist/anchors.js +116 -0
  6. package/dist/anchors.js.map +1 -0
  7. package/dist/anomalies.d.ts +42 -0
  8. package/dist/anomalies.d.ts.map +1 -0
  9. package/dist/anomalies.js +642 -0
  10. package/dist/anomalies.js.map +1 -0
  11. package/dist/attestation.d.ts +20 -0
  12. package/dist/attestation.d.ts.map +1 -0
  13. package/dist/attestation.js +795 -0
  14. package/dist/attestation.js.map +1 -0
  15. package/dist/audit.d.ts +19 -0
  16. package/dist/audit.d.ts.map +1 -0
  17. package/dist/audit.js +116 -0
  18. package/dist/audit.js.map +1 -0
  19. package/dist/authority.d.ts +37 -0
  20. package/dist/authority.d.ts.map +1 -0
  21. package/dist/authority.js +126 -0
  22. package/dist/authority.js.map +1 -0
  23. package/dist/aws-nitro-root-ca.d.ts +24 -0
  24. package/dist/aws-nitro-root-ca.d.ts.map +1 -0
  25. package/dist/aws-nitro-root-ca.js +44 -0
  26. package/dist/aws-nitro-root-ca.js.map +1 -0
  27. package/dist/cli.d.ts +3 -0
  28. package/dist/cli.d.ts.map +1 -0
  29. package/dist/cli.js +257 -0
  30. package/dist/cli.js.map +1 -0
  31. package/dist/contents-hash.d.ts +30 -0
  32. package/dist/contents-hash.d.ts.map +1 -0
  33. package/dist/contents-hash.js +73 -0
  34. package/dist/contents-hash.js.map +1 -0
  35. package/dist/export.d.ts +64 -0
  36. package/dist/export.d.ts.map +1 -0
  37. package/dist/export.js +192 -0
  38. package/dist/export.js.map +1 -0
  39. package/dist/index.d.ts +31 -0
  40. package/dist/index.d.ts.map +1 -0
  41. package/dist/index.js +17 -0
  42. package/dist/index.js.map +1 -0
  43. package/dist/ingest.d.ts +36 -0
  44. package/dist/ingest.d.ts.map +1 -0
  45. package/dist/ingest.js +828 -0
  46. package/dist/ingest.js.map +1 -0
  47. package/dist/reconstruct.d.ts +40 -0
  48. package/dist/reconstruct.d.ts.map +1 -0
  49. package/dist/reconstruct.js +400 -0
  50. package/dist/reconstruct.js.map +1 -0
  51. package/dist/report-json.d.ts +3 -0
  52. package/dist/report-json.d.ts.map +1 -0
  53. package/dist/report-json.js +290 -0
  54. package/dist/report-json.js.map +1 -0
  55. package/dist/report-md.d.ts +3 -0
  56. package/dist/report-md.d.ts.map +1 -0
  57. package/dist/report-md.js +853 -0
  58. package/dist/report-md.js.map +1 -0
  59. package/dist/rlp.d.ts +37 -0
  60. package/dist/rlp.d.ts.map +1 -0
  61. package/dist/rlp.js +120 -0
  62. package/dist/rlp.js.map +1 -0
  63. package/dist/tar.d.ts +47 -0
  64. package/dist/tar.d.ts.map +1 -0
  65. package/dist/tar.js +483 -0
  66. package/dist/tar.js.map +1 -0
  67. package/dist/temporal.d.ts +55 -0
  68. package/dist/temporal.d.ts.map +1 -0
  69. package/dist/temporal.js +461 -0
  70. package/dist/temporal.js.map +1 -0
  71. package/dist/types.d.ts +1389 -0
  72. package/dist/types.d.ts.map +1 -0
  73. package/dist/types.js +3 -0
  74. package/dist/types.js.map +1 -0
  75. package/dist/validity.d.ts +21 -0
  76. package/dist/validity.d.ts.map +1 -0
  77. package/dist/validity.js +86 -0
  78. package/dist/validity.js.map +1 -0
  79. package/dist/verify-tiers.d.ts +11 -0
  80. package/dist/verify-tiers.d.ts.map +1 -0
  81. package/dist/verify-tiers.js +120 -0
  82. package/dist/verify-tiers.js.map +1 -0
  83. package/dist/witness.d.ts +25 -0
  84. package/dist/witness.d.ts.map +1 -0
  85. package/dist/witness.js +308 -0
  86. package/dist/witness.js.map +1 -0
  87. package/package.json +42 -0
  88. package/src/anchors.ts +168 -0
  89. package/src/anomalies.ts +773 -0
  90. package/src/attestation.ts +878 -0
  91. package/src/audit.ts +136 -0
  92. package/src/authority.ts +165 -0
  93. package/src/aws-nitro-root-ca.ts +47 -0
  94. package/src/cli.ts +284 -0
  95. package/src/contents-hash.ts +95 -0
  96. package/src/export.ts +278 -0
  97. package/src/index.ts +122 -0
  98. package/src/ingest.ts +967 -0
  99. package/src/reconstruct.ts +491 -0
  100. package/src/report-json.ts +331 -0
  101. package/src/report-md.ts +1067 -0
  102. package/src/rlp.ts +142 -0
  103. package/src/tar.ts +587 -0
  104. package/src/temporal.ts +629 -0
  105. package/src/types.ts +1528 -0
  106. package/src/validity.ts +90 -0
  107. package/src/verify-tiers.ts +137 -0
  108. package/src/witness.ts +407 -0
@@ -0,0 +1,878 @@
1
+ // Copyright (c) 2024-2026 Mike Argento. Licensed under the MIT License. See LICENSE.
2
+
3
+ /**
4
+ * bitgraph-audit offline attestation validation
5
+ *
6
+ * A self-contained Node port of the website's AWS Nitro attestation
7
+ * validator (website/src/lib/nitro-verify.ts), fully offline:
8
+ *
9
+ * 1. Decode the base64 COSE_Sign1 envelope (minimal CBOR reader).
10
+ * 2. Parse the attestation payload (module_id, timestamp, pcrs,
11
+ * certificate, cabundle, user_data).
12
+ * 3. Verify the ECDSA P-384 signature over the RFC 9052 Sig_structure
13
+ * against the leaf certificate's public key (node:crypto webcrypto).
14
+ * 4. Walk the certificate bundle: each certificate signed by its
15
+ * predecessor, leaf last.
16
+ * 5. Verify the top of the bundle against the trust root. The default
17
+ * trust root is the bundled AWS Nitro Enclaves Root CA G1 constant
18
+ * (aws-nitro-root-ca.ts, same string the website embeds); tests and
19
+ * non-AWS deployments may supply other trust material explicitly.
20
+ * 6. Evaluate every chain certificate's validity window at the
21
+ * attestation document's OWN timestamp. The website validator does
22
+ * not perform this check; it is added here because an audit runs
23
+ * long after the short-lived leaf certificates expire, and the
24
+ * document's own timestamp is the only offline-evaluable instant.
25
+ *
26
+ * Separately tracked facts, never conflated (G9): declared measurement
27
+ * present; attestation document present; document cryptographically
28
+ * validated; attested PCR0 matches the declared measurement; user_data
29
+ * bound to the signed body. PCR0 and user_data comparisons run only on a
30
+ * VALIDATED document: values parsed from an unvalidated document prove
31
+ * nothing.
32
+ *
33
+ * user_data binding mirrors the website and the enclave exactly: the
34
+ * enclave requests attestation with user_data = SHA-256 of the canonical
35
+ * signed body (the raw bytes whose base64 IS the canonical proof hash),
36
+ * so base64(user_data) must equal the proof hash the audit tool computes
37
+ * itself.
38
+ *
39
+ * No network calls anywhere. Never outputs "hardware verified" from
40
+ * presence alone.
41
+ */
42
+
43
+ import { webcrypto } from "node:crypto";
44
+ import { rootCaDerBytes } from "./aws-nitro-root-ca.js";
45
+ import type {
46
+ AttestationAnalysis,
47
+ AttestationCheck,
48
+ AuditFinding,
49
+ AuthorityAnalysis,
50
+ IngestResult,
51
+ NitroValidationOptions,
52
+ NitroValidationResult,
53
+ ObservedProof,
54
+ ProofAttestationRecord,
55
+ } from "./types.js";
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // Low-level document validation
59
+ // ---------------------------------------------------------------------------
60
+
61
+ /**
62
+ * Validate one base64 COSE_Sign1 attestation document offline. See the
63
+ * module doc for the check sequence. Document checks short-circuit; the
64
+ * PCR0 and user_data comparisons run only when the document validated and
65
+ * the corresponding expectation was supplied.
66
+ */
67
+ export async function validateNitroAttestationDocument(
68
+ reportB64: string,
69
+ options?: NitroValidationOptions
70
+ ): Promise<NitroValidationResult> {
71
+ const checks: AttestationCheck[] = [];
72
+ let pcrs: Record<number, string> = {};
73
+ let pcr0: string | undefined;
74
+ let moduleId: string | undefined;
75
+ let timestamp: number | undefined;
76
+ let certChainLength: number | undefined;
77
+ let userDataB64: string | undefined;
78
+
79
+ const failed = (): NitroValidationResult => ({
80
+ documentValid: false,
81
+ checks,
82
+ failure: [...checks].reverse().find((c) => !c.pass)?.detail as string,
83
+ ...(pcr0 !== undefined ? { pcr0 } : {}),
84
+ pcrs,
85
+ ...(moduleId !== undefined ? { moduleId } : {}),
86
+ ...(timestamp !== undefined ? { timestamp } : {}),
87
+ ...(certChainLength !== undefined ? { certChainLength } : {}),
88
+ ...(userDataB64 !== undefined ? { userDataB64 } : {}),
89
+ });
90
+
91
+ try {
92
+ // Step 1: decode the CBOR envelope.
93
+ const reportBytes = b64ToBytes(reportB64);
94
+ if (reportBytes === null) {
95
+ checks.push({
96
+ name: "CBOR Decode",
97
+ pass: false,
98
+ detail: "reportB64 is not valid base64",
99
+ });
100
+ return failed();
101
+ }
102
+ const cose = decodeCbor(reportBytes, 0).value;
103
+ if (!Array.isArray(cose) || cose.length < 4) {
104
+ checks.push({ name: "CBOR Decode", pass: false, detail: "Not a valid COSE_Sign1 array" });
105
+ return failed();
106
+ }
107
+ checks.push({ name: "CBOR Decode", pass: true, detail: "COSE_Sign1 envelope parsed" });
108
+
109
+ const protectedHeaderBytes = cose[0];
110
+ const payloadBytes = cose[2];
111
+ const signatureBytes = cose[3];
112
+ if (
113
+ !(protectedHeaderBytes instanceof Uint8Array) ||
114
+ !(payloadBytes instanceof Uint8Array) ||
115
+ !(signatureBytes instanceof Uint8Array)
116
+ ) {
117
+ checks.push({
118
+ name: "COSE Structure",
119
+ pass: false,
120
+ detail: "Missing protected/payload/signature",
121
+ });
122
+ return failed();
123
+ }
124
+
125
+ // Step 2: parse the attestation payload.
126
+ const attDoc = decodeCbor(payloadBytes, 0).value as Record<string, unknown>;
127
+ if (!attDoc || typeof attDoc !== "object" || Array.isArray(attDoc)) {
128
+ checks.push({
129
+ name: "Payload Decode",
130
+ pass: false,
131
+ detail: "Could not decode attestation payload",
132
+ });
133
+ return failed();
134
+ }
135
+
136
+ pcrs = extractPcrs(attDoc);
137
+ pcr0 = pcrs[0];
138
+ moduleId = typeof attDoc["module_id"] === "string" ? (attDoc["module_id"] as string) : undefined;
139
+ timestamp = typeof attDoc["timestamp"] === "number" ? (attDoc["timestamp"] as number) : undefined;
140
+ const ud = attDoc["user_data"];
141
+ userDataB64 =
142
+ ud instanceof Uint8Array && ud.length > 0 ? Buffer.from(ud).toString("base64") : undefined;
143
+ const cabundle = attDoc["cabundle"] as Uint8Array[] | undefined;
144
+ const leafCertBytes = attDoc["certificate"];
145
+ if (!(leafCertBytes instanceof Uint8Array)) {
146
+ checks.push({
147
+ name: "Leaf Certificate",
148
+ pass: false,
149
+ detail: "No leaf certificate in attestation",
150
+ });
151
+ return failed();
152
+ }
153
+ certChainLength = (Array.isArray(cabundle) ? cabundle.length : 0) + 1;
154
+
155
+ // Step 3: leaf certificate signature over the Sig_structure.
156
+ const leafCert = parseCertificate(leafCertBytes);
157
+ const sigStructure = encodeSigStructure(protectedHeaderBytes, payloadBytes);
158
+ const sigValid = await verifyP384Raw(signatureBytes, sigStructure, leafCert.publicKey);
159
+ checks.push({
160
+ name: "ECDSA P-384 Signature",
161
+ pass: sigValid,
162
+ detail: sigValid
163
+ ? "Attestation signed by leaf certificate"
164
+ : "Signature verification failed against leaf certificate public key",
165
+ });
166
+ if (!sigValid) return failed();
167
+
168
+ // Step 4: walk the certificate chain (bundle root first, leaf last).
169
+ if (!Array.isArray(cabundle) || cabundle.length === 0 || !cabundle.every((c) => c instanceof Uint8Array)) {
170
+ checks.push({ name: "Certificate Chain", pass: false, detail: "Empty cabundle" });
171
+ return failed();
172
+ }
173
+ const chain = [...cabundle, leafCertBytes].map(parseCertificate);
174
+ let chainValid = true;
175
+ let chainFailReason = "";
176
+ for (let i = 1; i < chain.length; i++) {
177
+ const child = chain[i] as ParsedCert;
178
+ const parent = chain[i - 1] as ParsedCert;
179
+ const ok = await verifyP384Raw(child.signature, child.tbsCertificate, parent.publicKey);
180
+ if (!ok) {
181
+ chainValid = false;
182
+ chainFailReason = `Certificate ${i} signature invalid (parent: ${i - 1})`;
183
+ break;
184
+ }
185
+ }
186
+ checks.push({
187
+ name: "Certificate Chain",
188
+ pass: chainValid,
189
+ detail: chainValid ? `${chain.length} certificates, each signed by parent` : chainFailReason,
190
+ });
191
+ if (!chainValid) return failed();
192
+
193
+ // Step 5: anchor the top of the bundle to the trust root.
194
+ const usingBundledRoot = options?.trustedRootCaDer === undefined;
195
+ const rootCert = parseCertificate(options?.trustedRootCaDer ?? rootCaDerBytes());
196
+ const topCert = chain[0] as ParsedCert;
197
+ const rootMatch = await verifyP384Raw(topCert.signature, topCert.tbsCertificate, rootCert.publicKey);
198
+ checks.push({
199
+ name: "AWS Nitro Root CA",
200
+ pass: rootMatch,
201
+ detail: rootMatch
202
+ ? usingBundledRoot
203
+ ? "Chain anchored to AWS Nitro Root G1 (CN=aws.nitro-enclaves)"
204
+ : "Chain anchored to the supplied trust root (non-default trust material)"
205
+ : usingBundledRoot
206
+ ? "Top of chain not signed by AWS Nitro Root CA"
207
+ : "Top of chain not signed by the supplied trust root",
208
+ });
209
+ if (!rootMatch) return failed();
210
+
211
+ // Step 6: certificate validity windows at the document's own
212
+ // timestamp. This check does not exist in the website validator; see
213
+ // the module doc for why it is added and why the document timestamp
214
+ // is the evaluation instant.
215
+ let windowValid = true;
216
+ let windowDetail = `All ${chain.length} certificate validity windows contain the document timestamp`;
217
+ if (timestamp === undefined) {
218
+ windowValid = false;
219
+ windowDetail =
220
+ "Attestation document carries no timestamp; certificate validity cannot be evaluated offline";
221
+ } else {
222
+ for (let i = 0; i < chain.length; i++) {
223
+ const cert = chain[i] as ParsedCert;
224
+ if (timestamp < cert.notBeforeMs || timestamp > cert.notAfterMs) {
225
+ windowValid = false;
226
+ windowDetail =
227
+ `Certificate ${i} validity window ` +
228
+ `[${new Date(cert.notBeforeMs).toISOString()}, ${new Date(cert.notAfterMs).toISOString()}] ` +
229
+ `does not contain the document timestamp ${new Date(timestamp).toISOString()}`;
230
+ break;
231
+ }
232
+ }
233
+ }
234
+ checks.push({ name: "Certificate Validity Window", pass: windowValid, detail: windowDetail });
235
+ if (!windowValid) return failed();
236
+
237
+ // Document validated. Now, and only now, the separate comparisons.
238
+ let pcr0Matches: boolean | undefined;
239
+ if (options?.expectedPcr0 !== undefined) {
240
+ pcr0Matches = pcr0 === options.expectedPcr0;
241
+ checks.push({
242
+ name: "PCR0 Match",
243
+ pass: pcr0Matches,
244
+ detail: pcr0Matches
245
+ ? "PCR0 inside attestation matches declared measurement"
246
+ : `PCR0 mismatch: expected ${options.expectedPcr0.slice(0, 16)}..., got ${(pcr0 ?? "").slice(0, 16)}...`,
247
+ });
248
+ }
249
+ let userDataMatches: boolean | undefined;
250
+ if (options?.expectedUserDataB64 !== undefined) {
251
+ userDataMatches = userDataB64 !== undefined && userDataB64 === options.expectedUserDataB64;
252
+ checks.push({
253
+ name: "Bound to this proof",
254
+ pass: userDataMatches,
255
+ detail: userDataMatches
256
+ ? "Attestation user_data matches this proof's canonical hash"
257
+ : userDataB64 !== undefined
258
+ ? "Attestation user_data does not match this proof"
259
+ : "Attestation carries no user_data to bind",
260
+ });
261
+ }
262
+
263
+ return {
264
+ documentValid: true,
265
+ checks,
266
+ ...(pcr0 !== undefined ? { pcr0 } : {}),
267
+ pcrs,
268
+ ...(moduleId !== undefined ? { moduleId } : {}),
269
+ ...(timestamp !== undefined ? { timestamp } : {}),
270
+ certChainLength,
271
+ ...(userDataB64 !== undefined ? { userDataB64 } : {}),
272
+ ...(pcr0Matches !== undefined ? { pcr0Matches } : {}),
273
+ ...(userDataMatches !== undefined ? { userDataMatches } : {}),
274
+ };
275
+ } catch (error) {
276
+ checks.push({
277
+ name: "Verification Error",
278
+ pass: false,
279
+ detail: error instanceof Error ? error.message : String(error),
280
+ });
281
+ return failed();
282
+ }
283
+ }
284
+
285
+ // ---------------------------------------------------------------------------
286
+ // Pipeline
287
+ // ---------------------------------------------------------------------------
288
+
289
+ /**
290
+ * Validate the attestation of every observed proof and populate
291
+ * AuthorityGroup.attested on the given authority analysis (the typed
292
+ * Phase 4c extension point). Groups without an attestation document keep
293
+ * attested undefined; a declared measurement is never treated as
294
+ * attested.
295
+ *
296
+ * The trust root defaults to the bundled AWS Nitro root; the override
297
+ * exists for tests and explicitly non-AWS trust material.
298
+ */
299
+ export async function validateAttestations(
300
+ ingest: IngestResult,
301
+ authority?: AuthorityAnalysis,
302
+ options?: Pick<NitroValidationOptions, "trustedRootCaDer">
303
+ ): Promise<AttestationAnalysis> {
304
+ const records: ProofAttestationRecord[] = [];
305
+ const findings: AuditFinding[] = [];
306
+ const counts = {
307
+ proofsWithDeclaredMeasurement: 0,
308
+ proofsWithDocument: 0,
309
+ documentsValidated: 0,
310
+ documentsFailed: 0,
311
+ pcr0Matches: 0,
312
+ pcr0Mismatches: 0,
313
+ userDataBound: 0,
314
+ userDataUnbound: 0,
315
+ };
316
+
317
+ for (const proof of ingest.proofs) {
318
+ const declaredMeasurement =
319
+ proof.measurement !== undefined && proof.measurement.length > 0
320
+ ? proof.measurement
321
+ : undefined;
322
+ if (declaredMeasurement !== undefined) counts.proofsWithDeclaredMeasurement++;
323
+
324
+ const attestation = readAttestation(proof);
325
+ const record: ProofAttestationRecord = {
326
+ proofHash: proof.proofHash,
327
+ declaredMeasurementPresent: declaredMeasurement !== undefined,
328
+ ...(declaredMeasurement !== undefined ? { declaredMeasurement } : {}),
329
+ documentPresent: attestation !== undefined,
330
+ ...(attestation?.format !== undefined ? { attestationFormat: attestation.format } : {}),
331
+ documentValidated: false,
332
+ checks: [],
333
+ };
334
+
335
+ if (attestation !== undefined) {
336
+ counts.proofsWithDocument++;
337
+ const result = await validateNitroAttestationDocument(attestation.reportB64, {
338
+ ...(declaredMeasurement !== undefined ? { expectedPcr0: declaredMeasurement } : {}),
339
+ expectedUserDataB64: proof.proofHash,
340
+ ...(options?.trustedRootCaDer !== undefined
341
+ ? { trustedRootCaDer: options.trustedRootCaDer }
342
+ : {}),
343
+ });
344
+
345
+ record.documentValidated = result.documentValid;
346
+ record.checks = result.checks;
347
+ if (result.pcr0 !== undefined) record.attestedPcr0 = result.pcr0;
348
+ record.pcrs = result.pcrs;
349
+ if (result.moduleId !== undefined) record.moduleId = result.moduleId;
350
+ if (result.timestamp !== undefined) record.timestamp = result.timestamp;
351
+ if (result.certChainLength !== undefined) record.certChainLength = result.certChainLength;
352
+ if (result.userDataB64 !== undefined) record.userDataB64 = result.userDataB64;
353
+
354
+ if (!result.documentValid) {
355
+ counts.documentsFailed++;
356
+ if (result.failure !== undefined) record.validationFailure = result.failure;
357
+ findings.push({
358
+ code: "attestation-invalid",
359
+ ...pathOf(proof),
360
+ message: `attestation document failed offline validation: ${result.failure ?? "unknown"}. Presence of a document is reported separately and proves nothing by itself.`,
361
+ details: { proofHash: proof.proofHash },
362
+ });
363
+ } else {
364
+ counts.documentsValidated++;
365
+ if (result.pcr0Matches !== undefined) {
366
+ record.pcr0MatchesDeclared = result.pcr0Matches;
367
+ if (result.pcr0Matches) counts.pcr0Matches++;
368
+ else {
369
+ counts.pcr0Mismatches++;
370
+ findings.push({
371
+ code: "attestation-measurement-mismatch",
372
+ ...pathOf(proof),
373
+ message:
374
+ "the validated attestation document's PCR0 does not equal the proof's declared " +
375
+ "environment.measurement. Declared and attested measurements are reported " +
376
+ "separately and disagree here.",
377
+ details: {
378
+ proofHash: proof.proofHash,
379
+ declared: declaredMeasurement as string,
380
+ ...(result.pcr0 !== undefined ? { attested: result.pcr0 } : {}),
381
+ },
382
+ });
383
+ }
384
+ }
385
+ if (result.userDataMatches !== undefined) {
386
+ record.userDataBoundToProof = result.userDataMatches;
387
+ if (result.userDataMatches) counts.userDataBound++;
388
+ else {
389
+ counts.userDataUnbound++;
390
+ findings.push({
391
+ code: "attestation-user-data-mismatch",
392
+ ...pathOf(proof),
393
+ message:
394
+ "the validated attestation document's user_data is not bound to this proof's " +
395
+ "canonical hash. A genuine attestation lifted from another proof would look " +
396
+ "exactly like this.",
397
+ details: {
398
+ proofHash: proof.proofHash,
399
+ ...(result.userDataB64 !== undefined ? { userDataB64: result.userDataB64 } : {}),
400
+ },
401
+ });
402
+ }
403
+ }
404
+ }
405
+ }
406
+
407
+ records.push(record);
408
+ }
409
+
410
+ if (authority !== undefined) populateAuthorityGroups(authority, records);
411
+
412
+ return { records, findings, counts };
413
+ }
414
+
415
+ function populateAuthorityGroups(
416
+ authority: AuthorityAnalysis,
417
+ records: ProofAttestationRecord[]
418
+ ): void {
419
+ const byHash = new Map<string, ProofAttestationRecord>(records.map((r) => [r.proofHash, r]));
420
+ for (const group of authority.groups) {
421
+ if (!group.attestationPresent) continue;
422
+ const memberRecords = group.proofHashes
423
+ .map((h) => byHash.get(h))
424
+ .filter((r): r is ProofAttestationRecord => r !== undefined && r.documentPresent);
425
+ if (memberRecords.length === 0) continue;
426
+
427
+ const validated = memberRecords.filter((r) => r.documentValidated);
428
+ const failedCount = memberRecords.length - validated.length;
429
+ const attestedValues = [...new Set(validated.map((r) => r.attestedPcr0).filter((v): v is string => v !== undefined))].sort();
430
+
431
+ const attestedMeasurement = attestedValues.length === 1 ? attestedValues[0] : undefined;
432
+ group.attested = {
433
+ status: failedCount === 0 && validated.length > 0 ? "validated" : "validation-failed",
434
+ ...(attestedMeasurement !== undefined ? { attestedMeasurement } : {}),
435
+ ...(attestedMeasurement !== undefined && group.measurement !== undefined
436
+ ? { matchesDeclared: attestedMeasurement === group.measurement }
437
+ : {}),
438
+ validatedProofCount: validated.length,
439
+ failedProofCount: failedCount,
440
+ ...(attestedValues.length > 1 ? { attestedMeasurements: attestedValues } : {}),
441
+ };
442
+ }
443
+ }
444
+
445
+ function readAttestation(
446
+ proof: ObservedProof
447
+ ): { reportB64: string; format?: string } | undefined {
448
+ const environment = (proof.proof as unknown as Record<string, unknown>)["environment"];
449
+ if (environment === null || typeof environment !== "object" || Array.isArray(environment)) {
450
+ return undefined;
451
+ }
452
+ const attestation = (environment as Record<string, unknown>)["attestation"];
453
+ if (attestation === null || typeof attestation !== "object" || Array.isArray(attestation)) {
454
+ return undefined;
455
+ }
456
+ const reportB64 = (attestation as Record<string, unknown>)["reportB64"];
457
+ if (typeof reportB64 !== "string" || reportB64.length === 0) return undefined;
458
+ const format = (attestation as Record<string, unknown>)["format"];
459
+ return { reportB64, ...(typeof format === "string" ? { format } : {}) };
460
+ }
461
+
462
+ function pathOf(proof: ObservedProof): { path?: string } {
463
+ const path = proof.sources[0]?.path;
464
+ return path !== undefined ? { path } : {};
465
+ }
466
+
467
+ // ---------------------------------------------------------------------------
468
+ // Base64 (mirrors the website's atob-based decoder: base64url tolerated,
469
+ // padding restored, anything else rejected via the round-trip property)
470
+ // ---------------------------------------------------------------------------
471
+
472
+ function b64ToBytes(b64: string): Uint8Array | null {
473
+ let s = b64.replace(/-/g, "+").replace(/_/g, "/");
474
+ while (s.length % 4 !== 0) s += "=";
475
+ const decoded = Buffer.from(s, "base64");
476
+ if (decoded.toString("base64") !== s) return null;
477
+ return new Uint8Array(decoded);
478
+ }
479
+
480
+ // ---------------------------------------------------------------------------
481
+ // CBOR encoder subset (Sig_structure only), ported from the website
482
+ // ---------------------------------------------------------------------------
483
+
484
+ function encodeCborInt(n: number): Uint8Array {
485
+ if (n < 24) return new Uint8Array([n]);
486
+ if (n < 256) return new Uint8Array([0x18, n]);
487
+ if (n < 65536) return new Uint8Array([0x19, n >> 8, n & 0xff]);
488
+ return new Uint8Array([0x1a, (n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff]);
489
+ }
490
+
491
+ function encodeCborBytes(bytes: Uint8Array): Uint8Array {
492
+ const lenPrefix = encodeCborInt(bytes.length);
493
+ lenPrefix[0] = ((lenPrefix[0] as number) & 0x1f) | 0x40;
494
+ const out = new Uint8Array(lenPrefix.length + bytes.length);
495
+ out.set(lenPrefix, 0);
496
+ out.set(bytes, lenPrefix.length);
497
+ return out;
498
+ }
499
+
500
+ function encodeCborText(s: string): Uint8Array {
501
+ const utf8 = new TextEncoder().encode(s);
502
+ const lenPrefix = encodeCborInt(utf8.length);
503
+ lenPrefix[0] = ((lenPrefix[0] as number) & 0x1f) | 0x60;
504
+ const out = new Uint8Array(lenPrefix.length + utf8.length);
505
+ out.set(lenPrefix, 0);
506
+ out.set(utf8, lenPrefix.length);
507
+ return out;
508
+ }
509
+
510
+ function encodeCborArray(items: Uint8Array[]): Uint8Array {
511
+ const lenPrefix = encodeCborInt(items.length);
512
+ lenPrefix[0] = ((lenPrefix[0] as number) & 0x1f) | 0x80;
513
+ const totalLen = lenPrefix.length + items.reduce((sum, x) => sum + x.length, 0);
514
+ const out = new Uint8Array(totalLen);
515
+ let off = 0;
516
+ out.set(lenPrefix, off);
517
+ off += lenPrefix.length;
518
+ for (const item of items) {
519
+ out.set(item, off);
520
+ off += item.length;
521
+ }
522
+ return out;
523
+ }
524
+
525
+ /**
526
+ * COSE_Sign1 Sig_structure per RFC 9052 section 4.4:
527
+ * ["Signature1", body_protected, external_aad (empty), payload].
528
+ */
529
+ function encodeSigStructure(protectedHeader: Uint8Array, payload: Uint8Array): Uint8Array {
530
+ return encodeCborArray([
531
+ encodeCborText("Signature1"),
532
+ encodeCborBytes(protectedHeader),
533
+ encodeCborBytes(new Uint8Array(0)),
534
+ encodeCborBytes(payload),
535
+ ]);
536
+ }
537
+
538
+ // ---------------------------------------------------------------------------
539
+ // CBOR decoder subset, ported from the website with explicit bounds checks
540
+ // (the website indexes past the end silently; here truncation throws a
541
+ // precise error and the document fails closed)
542
+ // ---------------------------------------------------------------------------
543
+
544
+ function decodeCbor(data: Uint8Array, offset = 0): { value: unknown; offset: number } {
545
+ const first = data[offset];
546
+ if (first === undefined) throw new Error("CBOR: unexpected end of input");
547
+ const major = first >> 5;
548
+ const info = first & 0x1f;
549
+ offset++;
550
+
551
+ const byteAt = (position: number): number => {
552
+ const value = data[position];
553
+ if (value === undefined) throw new Error("CBOR: unexpected end of input");
554
+ return value;
555
+ };
556
+
557
+ function readLength(): number {
558
+ if (info < 24) return info;
559
+ if (info === 24) {
560
+ const v = byteAt(offset);
561
+ offset += 1;
562
+ return v;
563
+ }
564
+ if (info === 25) {
565
+ const v = (byteAt(offset) << 8) | byteAt(offset + 1);
566
+ offset += 2;
567
+ return v;
568
+ }
569
+ if (info === 26) {
570
+ const v =
571
+ ((byteAt(offset) << 24) |
572
+ (byteAt(offset + 1) << 16) |
573
+ (byteAt(offset + 2) << 8) |
574
+ byteAt(offset + 3)) >>>
575
+ 0;
576
+ offset += 4;
577
+ return v;
578
+ }
579
+ if (info === 27) {
580
+ let v = 0;
581
+ for (let i = 0; i < 8; i++) v = v * 256 + byteAt(offset + i);
582
+ offset += 8;
583
+ return v;
584
+ }
585
+ throw new Error(`Unsupported CBOR length info: ${info}`);
586
+ }
587
+
588
+ switch (major) {
589
+ case 0:
590
+ return { value: readLength(), offset };
591
+ case 1:
592
+ return { value: -1 - readLength(), offset };
593
+ case 2: {
594
+ const len = readLength();
595
+ if (offset + len > data.length) throw new Error("CBOR: byte string is truncated");
596
+ return { value: data.slice(offset, offset + len), offset: offset + len };
597
+ }
598
+ case 3: {
599
+ const len = readLength();
600
+ if (offset + len > data.length) throw new Error("CBOR: text string is truncated");
601
+ const v = new TextDecoder().decode(data.slice(offset, offset + len));
602
+ return { value: v, offset: offset + len };
603
+ }
604
+ case 4: {
605
+ if (info === 31) {
606
+ const arr: unknown[] = [];
607
+ while (byteAt(offset) !== 0xff) {
608
+ const item = decodeCbor(data, offset);
609
+ arr.push(item.value);
610
+ offset = item.offset;
611
+ }
612
+ return { value: arr, offset: offset + 1 };
613
+ }
614
+ const len = readLength();
615
+ const arr: unknown[] = [];
616
+ for (let i = 0; i < len; i++) {
617
+ const item = decodeCbor(data, offset);
618
+ arr.push(item.value);
619
+ offset = item.offset;
620
+ }
621
+ return { value: arr, offset };
622
+ }
623
+ case 5: {
624
+ if (info === 31) {
625
+ const map: Record<string, unknown> = {};
626
+ while (byteAt(offset) !== 0xff) {
627
+ const k = decodeCbor(data, offset);
628
+ offset = k.offset;
629
+ const v = decodeCbor(data, offset);
630
+ offset = v.offset;
631
+ map[String(k.value)] = v.value;
632
+ }
633
+ return { value: map, offset: offset + 1 };
634
+ }
635
+ const len = readLength();
636
+ const map: Record<string, unknown> = {};
637
+ for (let i = 0; i < len; i++) {
638
+ const k = decodeCbor(data, offset);
639
+ offset = k.offset;
640
+ const v = decodeCbor(data, offset);
641
+ offset = v.offset;
642
+ map[String(k.value)] = v.value;
643
+ }
644
+ return { value: map, offset };
645
+ }
646
+ case 6: {
647
+ // Tag: read the tag number, then the tagged value.
648
+ readLength();
649
+ return decodeCbor(data, offset);
650
+ }
651
+ case 7: {
652
+ if (info === 20) return { value: false, offset };
653
+ if (info === 21) return { value: true, offset };
654
+ if (info === 22) return { value: null, offset };
655
+ if (info === 23) return { value: undefined, offset };
656
+ throw new Error(`Unsupported CBOR simple value: ${info}`);
657
+ }
658
+ default:
659
+ throw new Error(`Unsupported CBOR major type: ${major}`);
660
+ }
661
+ }
662
+
663
+ // ---------------------------------------------------------------------------
664
+ // Minimal X.509 DER parser, ported from the website, extended with the
665
+ // validity window (the only addition)
666
+ // ---------------------------------------------------------------------------
667
+
668
+ interface ParsedCert {
669
+ /** The full TBSCertificate TLV bytes (what the signature covers). */
670
+ tbsCertificate: Uint8Array;
671
+ /** Raw r||s (96 bytes for P-384). */
672
+ signature: Uint8Array;
673
+ /** Uncompressed EC point: 0x04 || x || y. */
674
+ publicKey: Uint8Array;
675
+ notBeforeMs: number;
676
+ notAfterMs: number;
677
+ }
678
+
679
+ function parseCertificate(der: Uint8Array): ParsedCert {
680
+ const r = readSequence(der, 0);
681
+ let off = r.contentStart;
682
+
683
+ // TBSCertificate (SEQUENCE)
684
+ const tbsHeader = readTLV(der, off);
685
+ const tbsCertificate = der.slice(off, tbsHeader.end);
686
+ off = tbsHeader.end;
687
+
688
+ // signatureAlgorithm (SEQUENCE): skipped, as in the website port.
689
+ const sigAlg = readTLV(der, off);
690
+ off = sigAlg.end;
691
+
692
+ // signatureValue (BIT STRING): first content byte is the unused-bit
693
+ // count (0 for signatures), the rest is a DER ECDSA-Sig-Value.
694
+ const sigBitString = readTLV(der, off);
695
+ const sigContent = der.slice(sigBitString.contentStart + 1, sigBitString.end);
696
+ const signature = derEcdsaSigToRaw(sigContent, 48);
697
+
698
+ const { publicKey, notBeforeMs, notAfterMs } = walkTbs(tbsCertificate);
699
+ return { tbsCertificate, signature, publicKey, notBeforeMs, notAfterMs };
700
+ }
701
+
702
+ interface TLV {
703
+ tag: number;
704
+ contentStart: number;
705
+ contentEnd: number;
706
+ end: number;
707
+ }
708
+
709
+ function readTLV(data: Uint8Array, offset: number): TLV {
710
+ const tag = data[offset];
711
+ if (tag === undefined) throw new Error("DER: unexpected end of input");
712
+ let off = offset + 1;
713
+ let length = data[off++];
714
+ if (length === undefined) throw new Error("DER: unexpected end of input");
715
+ if (length & 0x80) {
716
+ const numBytes = length & 0x7f;
717
+ length = 0;
718
+ for (let i = 0; i < numBytes; i++) {
719
+ const b = data[off++];
720
+ if (b === undefined) throw new Error("DER: unexpected end of input");
721
+ length = length * 256 + b;
722
+ }
723
+ }
724
+ const end = off + length;
725
+ if (end > data.length) throw new Error("DER: element is truncated");
726
+ return { tag, contentStart: off, contentEnd: end, end };
727
+ }
728
+
729
+ function readSequence(data: Uint8Array, offset: number): TLV {
730
+ const tlv = readTLV(data, offset);
731
+ if (tlv.tag !== 0x30) {
732
+ throw new Error(`Expected SEQUENCE at offset ${offset}, got tag 0x${tlv.tag.toString(16)}`);
733
+ }
734
+ return tlv;
735
+ }
736
+
737
+ /**
738
+ * Walk a TBSCertificate: [0] version (optional), serialNumber, signature
739
+ * AlgorithmIdentifier, issuer, validity, subject, subjectPublicKeyInfo.
740
+ * Returns the SPKI public key point and the validity window.
741
+ */
742
+ function walkTbs(tbs: Uint8Array): {
743
+ publicKey: Uint8Array;
744
+ notBeforeMs: number;
745
+ notAfterMs: number;
746
+ } {
747
+ const seq = readSequence(tbs, 0);
748
+ let off = seq.contentStart;
749
+
750
+ if (tbs[off] === 0xa0) {
751
+ off = readTLV(tbs, off).end; // [0] version
752
+ }
753
+ off = readTLV(tbs, off).end; // serialNumber
754
+ off = readTLV(tbs, off).end; // signature AlgorithmIdentifier
755
+ off = readTLV(tbs, off).end; // issuer
756
+
757
+ // validity ::= SEQUENCE { notBefore Time, notAfter Time }
758
+ const validity = readSequence(tbs, off);
759
+ let vOff = validity.contentStart;
760
+ const notBeforeTlv = readTLV(tbs, vOff);
761
+ vOff = notBeforeTlv.end;
762
+ const notAfterTlv = readTLV(tbs, vOff);
763
+ const notBeforeMs = parseDerTime(tbs, notBeforeTlv);
764
+ const notAfterMs = parseDerTime(tbs, notAfterTlv);
765
+ off = validity.end;
766
+
767
+ off = readTLV(tbs, off).end; // subject
768
+
769
+ // SubjectPublicKeyInfo ::= SEQUENCE { AlgorithmIdentifier, BIT STRING }
770
+ const spki = readSequence(tbs, off);
771
+ let spkiOff = spki.contentStart;
772
+ const algId = readTLV(tbs, spkiOff);
773
+ spkiOff = algId.end;
774
+ const bitString = readTLV(tbs, spkiOff);
775
+ const publicKey = tbs.slice(bitString.contentStart + 1, bitString.end);
776
+
777
+ return { publicKey, notBeforeMs, notAfterMs };
778
+ }
779
+
780
+ /** UTCTime (0x17, YYMMDDHHMMSSZ) or GeneralizedTime (0x18, YYYYMMDDHHMMSSZ) to epoch ms. */
781
+ function parseDerTime(data: Uint8Array, tlv: TLV): number {
782
+ const text = String.fromCharCode(...data.slice(tlv.contentStart, tlv.contentEnd));
783
+ let year: number;
784
+ let rest: string;
785
+ if (tlv.tag === 0x17) {
786
+ if (!/^\d{12}Z$/.test(text)) throw new Error(`DER: malformed UTCTime "${text}"`);
787
+ const yy = Number.parseInt(text.slice(0, 2), 10);
788
+ year = yy < 50 ? 2000 + yy : 1900 + yy;
789
+ rest = text.slice(2);
790
+ } else if (tlv.tag === 0x18) {
791
+ if (!/^\d{14}Z$/.test(text)) throw new Error(`DER: malformed GeneralizedTime "${text}"`);
792
+ year = Number.parseInt(text.slice(0, 4), 10);
793
+ rest = text.slice(4);
794
+ } else {
795
+ throw new Error(`DER: expected a Time value, got tag 0x${tlv.tag.toString(16)}`);
796
+ }
797
+ const month = Number.parseInt(rest.slice(0, 2), 10);
798
+ const day = Number.parseInt(rest.slice(2, 4), 10);
799
+ const hour = Number.parseInt(rest.slice(4, 6), 10);
800
+ const minute = Number.parseInt(rest.slice(6, 8), 10);
801
+ const second = Number.parseInt(rest.slice(8, 10), 10);
802
+ return Date.UTC(year, month - 1, day, hour, minute, second);
803
+ }
804
+
805
+ /** DER ECDSA-Sig-Value (SEQUENCE { r INTEGER, s INTEGER }) to raw r||s. */
806
+ function derEcdsaSigToRaw(der: Uint8Array, coordSize: number): Uint8Array {
807
+ const seq = readSequence(der, 0);
808
+ let off = seq.contentStart;
809
+ const rTlv = readTLV(der, off);
810
+ if (rTlv.tag !== 0x02) throw new Error("Expected INTEGER for r");
811
+ let r = der.slice(rTlv.contentStart, rTlv.end);
812
+ off = rTlv.end;
813
+ const sTlv = readTLV(der, off);
814
+ if (sTlv.tag !== 0x02) throw new Error("Expected INTEGER for s");
815
+ let s = der.slice(sTlv.contentStart, sTlv.end);
816
+
817
+ if (r[0] === 0x00 && r.length > coordSize) r = r.slice(1);
818
+ if (s[0] === 0x00 && s.length > coordSize) s = s.slice(1);
819
+ if (r.length > coordSize || s.length > coordSize) {
820
+ throw new Error("ECDSA signature component longer than the curve coordinate size");
821
+ }
822
+
823
+ const out = new Uint8Array(coordSize * 2);
824
+ out.set(r, coordSize - r.length);
825
+ out.set(s, coordSize * 2 - s.length);
826
+ return out;
827
+ }
828
+
829
+ // ---------------------------------------------------------------------------
830
+ // ECDSA P-384 via node:crypto webcrypto (no dependency). WebCrypto hashes
831
+ // the message itself (SHA-384) and does not enforce low-S form, matching
832
+ // the website's noble configuration (prehash + lowS: false).
833
+ // ---------------------------------------------------------------------------
834
+
835
+ async function verifyP384Raw(
836
+ rawSig: Uint8Array,
837
+ message: Uint8Array,
838
+ publicKeyPoint: Uint8Array
839
+ ): Promise<boolean> {
840
+ if (publicKeyPoint.length !== 97 || publicKeyPoint[0] !== 0x04) return false;
841
+ if (rawSig.length !== 96) return false;
842
+ try {
843
+ const key = await webcrypto.subtle.importKey(
844
+ "raw",
845
+ publicKeyPoint as Uint8Array<ArrayBuffer>,
846
+ { name: "ECDSA", namedCurve: "P-384" },
847
+ false,
848
+ ["verify"]
849
+ );
850
+ return await webcrypto.subtle.verify(
851
+ { name: "ECDSA", hash: "SHA-384" },
852
+ key,
853
+ rawSig as Uint8Array<ArrayBuffer>,
854
+ message as Uint8Array<ArrayBuffer>
855
+ );
856
+ } catch {
857
+ return false;
858
+ }
859
+ }
860
+
861
+ // ---------------------------------------------------------------------------
862
+ // PCR extraction, ported from the website (all-zero PCRs treated as absent)
863
+ // ---------------------------------------------------------------------------
864
+
865
+ function extractPcrs(attDoc: Record<string, unknown>): Record<number, string> {
866
+ const pcrs: Record<number, string> = {};
867
+ const map = attDoc["pcrs"] as Record<string, unknown> | undefined;
868
+ if (!map || typeof map !== "object") return pcrs;
869
+ for (const [idx, val] of Object.entries(map)) {
870
+ if (val instanceof Uint8Array) {
871
+ const hex = Buffer.from(val).toString("hex");
872
+ if (hex.replace(/0/g, "").length > 0) {
873
+ pcrs[Number(idx)] = hex;
874
+ }
875
+ }
876
+ }
877
+ return pcrs;
878
+ }