@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.
- package/LICENSE +21 -0
- package/README.md +11 -0
- package/dist/anchors.d.ts +31 -0
- package/dist/anchors.d.ts.map +1 -0
- package/dist/anchors.js +116 -0
- package/dist/anchors.js.map +1 -0
- package/dist/anomalies.d.ts +42 -0
- package/dist/anomalies.d.ts.map +1 -0
- package/dist/anomalies.js +642 -0
- package/dist/anomalies.js.map +1 -0
- package/dist/attestation.d.ts +20 -0
- package/dist/attestation.d.ts.map +1 -0
- package/dist/attestation.js +795 -0
- package/dist/attestation.js.map +1 -0
- package/dist/audit.d.ts +19 -0
- package/dist/audit.d.ts.map +1 -0
- package/dist/audit.js +116 -0
- package/dist/audit.js.map +1 -0
- package/dist/authority.d.ts +37 -0
- package/dist/authority.d.ts.map +1 -0
- package/dist/authority.js +126 -0
- package/dist/authority.js.map +1 -0
- package/dist/aws-nitro-root-ca.d.ts +24 -0
- package/dist/aws-nitro-root-ca.d.ts.map +1 -0
- package/dist/aws-nitro-root-ca.js +44 -0
- package/dist/aws-nitro-root-ca.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +257 -0
- package/dist/cli.js.map +1 -0
- package/dist/contents-hash.d.ts +30 -0
- package/dist/contents-hash.d.ts.map +1 -0
- package/dist/contents-hash.js +73 -0
- package/dist/contents-hash.js.map +1 -0
- package/dist/export.d.ts +64 -0
- package/dist/export.d.ts.map +1 -0
- package/dist/export.js +192 -0
- package/dist/export.js.map +1 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +17 -0
- package/dist/index.js.map +1 -0
- package/dist/ingest.d.ts +36 -0
- package/dist/ingest.d.ts.map +1 -0
- package/dist/ingest.js +828 -0
- package/dist/ingest.js.map +1 -0
- package/dist/reconstruct.d.ts +40 -0
- package/dist/reconstruct.d.ts.map +1 -0
- package/dist/reconstruct.js +400 -0
- package/dist/reconstruct.js.map +1 -0
- package/dist/report-json.d.ts +3 -0
- package/dist/report-json.d.ts.map +1 -0
- package/dist/report-json.js +290 -0
- package/dist/report-json.js.map +1 -0
- package/dist/report-md.d.ts +3 -0
- package/dist/report-md.d.ts.map +1 -0
- package/dist/report-md.js +853 -0
- package/dist/report-md.js.map +1 -0
- package/dist/rlp.d.ts +37 -0
- package/dist/rlp.d.ts.map +1 -0
- package/dist/rlp.js +120 -0
- package/dist/rlp.js.map +1 -0
- package/dist/tar.d.ts +47 -0
- package/dist/tar.d.ts.map +1 -0
- package/dist/tar.js +483 -0
- package/dist/tar.js.map +1 -0
- package/dist/temporal.d.ts +55 -0
- package/dist/temporal.d.ts.map +1 -0
- package/dist/temporal.js +461 -0
- package/dist/temporal.js.map +1 -0
- package/dist/types.d.ts +1389 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/dist/validity.d.ts +21 -0
- package/dist/validity.d.ts.map +1 -0
- package/dist/validity.js +86 -0
- package/dist/validity.js.map +1 -0
- package/dist/verify-tiers.d.ts +11 -0
- package/dist/verify-tiers.d.ts.map +1 -0
- package/dist/verify-tiers.js +120 -0
- package/dist/verify-tiers.js.map +1 -0
- package/dist/witness.d.ts +25 -0
- package/dist/witness.d.ts.map +1 -0
- package/dist/witness.js +308 -0
- package/dist/witness.js.map +1 -0
- package/package.json +42 -0
- package/src/anchors.ts +168 -0
- package/src/anomalies.ts +773 -0
- package/src/attestation.ts +878 -0
- package/src/audit.ts +136 -0
- package/src/authority.ts +165 -0
- package/src/aws-nitro-root-ca.ts +47 -0
- package/src/cli.ts +284 -0
- package/src/contents-hash.ts +95 -0
- package/src/export.ts +278 -0
- package/src/index.ts +122 -0
- package/src/ingest.ts +967 -0
- package/src/reconstruct.ts +491 -0
- package/src/report-json.ts +331 -0
- package/src/report-md.ts +1067 -0
- package/src/rlp.ts +142 -0
- package/src/tar.ts +587 -0
- package/src/temporal.ts +629 -0
- package/src/types.ts +1528 -0
- package/src/validity.ts +90 -0
- package/src/verify-tiers.ts +137 -0
- 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
|
+
}
|