@motebit/crypto-webauthn 1.0.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 +206 -0
- package/NOTICE +19 -0
- package/README.md +49 -0
- package/dist/cbor.d.ts +48 -0
- package/dist/cbor.d.ts.map +1 -0
- package/dist/cbor.js +99 -0
- package/dist/cbor.js.map +1 -0
- package/dist/fido-roots.d.ts +81 -0
- package/dist/fido-roots.d.ts.map +1 -0
- package/dist/fido-roots.js +152 -0
- package/dist/fido-roots.js.map +1 -0
- package/dist/index.d.ts +91 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +72 -0
- package/dist/index.js.map +1 -0
- package/dist/verify.d.ts +120 -0
- package/dist/verify.d.ts.map +1 -0
- package/dist/verify.js +693 -0
- package/dist/verify.js.map +1 -0
- package/package.json +79 -0
package/dist/verify.js
ADDED
|
@@ -0,0 +1,693 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebAuthn platform-authenticator attestation verifier — the core
|
|
3
|
+
* judgment function this package exports.
|
|
4
|
+
*
|
|
5
|
+
* Flow (matches the W3C WebAuthn `packed` attestation verification
|
|
6
|
+
* recipe, plus the motebit-specific identity-key binding step):
|
|
7
|
+
*
|
|
8
|
+
* 1. Split the receipt into (attestationObjectBase64, clientDataJSONBase64).
|
|
9
|
+
* 2. CBOR-decode the attestation object to {fmt, attStmt:{alg, sig, x5c?},
|
|
10
|
+
* authData}. Assert fmt === "packed" in v1. Other fmts are named-not-
|
|
11
|
+
* supported errors (tpm / android-key / android-safetynet / fido-u2f /
|
|
12
|
+
* apple / none) — each is a separate additive arm future passes can add.
|
|
13
|
+
* 3a. Full attestation (x5c present): parse leaf, walk the chain against
|
|
14
|
+
* the pinned FIDO roots. Every non-leaf must carry basicConstraints.cA
|
|
15
|
+
* === true; every signature must verify; every cert must be within
|
|
16
|
+
* its validity window; the terminal cert's DER must equal one of the
|
|
17
|
+
* pinned roots byte-for-byte. Then verify `attStmt.sig` over
|
|
18
|
+
* `authData || clientDataHash` using the leaf's public key and alg.
|
|
19
|
+
* 3b. Self attestation (no x5c): extract the credential public key from
|
|
20
|
+
* `authData.attestedCredentialData`. Verify `attStmt.sig` over
|
|
21
|
+
* `authData || clientDataHash` using that key. Self-attested
|
|
22
|
+
* credentials carry no vendor chain and score as hardware-exported-
|
|
23
|
+
* equivalent (0.5) in the semiring — still better than software
|
|
24
|
+
* because the binding is still hardware-held, just not chain-proven.
|
|
25
|
+
* 4. Parse `clientDataJSON` (UTF-8 JSON): assert its `challenge` field
|
|
26
|
+
* (base64url-decoded) byte-equals the reconstructed
|
|
27
|
+
* SHA256(canonical body) the caller threads in via
|
|
28
|
+
* (motebit_id, device_id, identity_public_key, attested_at). The
|
|
29
|
+
* identity-binding step — byte-identical contract to App Attest.
|
|
30
|
+
* 5. Parse `authData` minimally: `rpIdHash` is the first 32 bytes; the
|
|
31
|
+
* caller's RP ID (e.g. "motebit.com") hashed with SHA-256 must match
|
|
32
|
+
* byte-for-byte. Bundle/RP binding.
|
|
33
|
+
*
|
|
34
|
+
* FIDO Metadata Service (MDS) dynamic fetch is explicitly out of scope —
|
|
35
|
+
* the pinned-roots model keeps the verifier sovereign and offline.
|
|
36
|
+
* Rotations land as additive constants in `fido-roots.ts`.
|
|
37
|
+
*/
|
|
38
|
+
import * as x509 from "@peculiar/x509";
|
|
39
|
+
import { decode as cborDecode } from "cbor2";
|
|
40
|
+
import { parseWebAuthnAttestationObjectCbor } from "./cbor.js";
|
|
41
|
+
import { DEFAULT_FIDO_ROOTS, WEBAUTHN_FMT_PACKED } from "./fido-roots.js";
|
|
42
|
+
/** COSE algorithm identifiers we support in v1. */
|
|
43
|
+
const COSE_ALG_ES256 = -7; // ECDSA w/ SHA-256 on P-256
|
|
44
|
+
/** OID for X.509 basic-constraints extension. */
|
|
45
|
+
const BASIC_CONSTRAINTS_OID = "2.5.29.19";
|
|
46
|
+
/**
|
|
47
|
+
* WebAuthn platform-authenticator attestation verifier.
|
|
48
|
+
*
|
|
49
|
+
* Pure. No network. No filesystem. Deterministic given `now()`.
|
|
50
|
+
*
|
|
51
|
+
* `claim` is the `HardwareAttestationClaim` as carried inside the motebit
|
|
52
|
+
* AgentTrustCredential. For WebAuthn, the `attestation_receipt` field is
|
|
53
|
+
* two base64url segments separated by `.`:
|
|
54
|
+
*
|
|
55
|
+
* `{attestationObjectB64}.{clientDataJSONB64}`
|
|
56
|
+
*
|
|
57
|
+
* The web mint path constructs this shape; see
|
|
58
|
+
* `apps/web/src/mint-hardware-credential.ts`.
|
|
59
|
+
*/
|
|
60
|
+
export async function verifyWebAuthnAttestation(claim, opts) {
|
|
61
|
+
const errors = [];
|
|
62
|
+
let cert_chain_valid = false;
|
|
63
|
+
let signature_valid = false;
|
|
64
|
+
let rp_bound = false;
|
|
65
|
+
let identity_bound = false;
|
|
66
|
+
let attestation_kind = null;
|
|
67
|
+
if (!claim.attestation_receipt) {
|
|
68
|
+
errors.push({ message: "webauthn claim missing `attestation_receipt`" });
|
|
69
|
+
return fail(errors, {
|
|
70
|
+
cert_chain_valid,
|
|
71
|
+
signature_valid,
|
|
72
|
+
rp_bound,
|
|
73
|
+
identity_bound,
|
|
74
|
+
attestation_kind,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
const parts = claim.attestation_receipt.split(".");
|
|
78
|
+
if (parts.length !== 2) {
|
|
79
|
+
errors.push({
|
|
80
|
+
message: `attestation_receipt must be 2 base64url parts (attObj.clientDataJSON); got ${parts.length}`,
|
|
81
|
+
});
|
|
82
|
+
return fail(errors, {
|
|
83
|
+
cert_chain_valid,
|
|
84
|
+
signature_valid,
|
|
85
|
+
rp_bound,
|
|
86
|
+
identity_bound,
|
|
87
|
+
attestation_kind,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
const [attObjB64, clientDataJsonB64] = parts;
|
|
91
|
+
let attestationObjectBytes;
|
|
92
|
+
let clientDataJsonBytes;
|
|
93
|
+
try {
|
|
94
|
+
attestationObjectBytes = fromBase64Url(attObjB64);
|
|
95
|
+
clientDataJsonBytes = fromBase64Url(clientDataJsonB64);
|
|
96
|
+
}
|
|
97
|
+
catch (err) {
|
|
98
|
+
errors.push({ message: `base64url decode failed: ${messageOf(err)}` });
|
|
99
|
+
return fail(errors, {
|
|
100
|
+
cert_chain_valid,
|
|
101
|
+
signature_valid,
|
|
102
|
+
rp_bound,
|
|
103
|
+
identity_bound,
|
|
104
|
+
attestation_kind,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
let cbor;
|
|
108
|
+
try {
|
|
109
|
+
cbor = parseWebAuthnAttestationObjectCbor(attestationObjectBytes);
|
|
110
|
+
}
|
|
111
|
+
catch (err) {
|
|
112
|
+
errors.push({ message: `CBOR decode: ${messageOf(err)}` });
|
|
113
|
+
return fail(errors, {
|
|
114
|
+
cert_chain_valid,
|
|
115
|
+
signature_valid,
|
|
116
|
+
rp_bound,
|
|
117
|
+
identity_bound,
|
|
118
|
+
attestation_kind,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
const expectedFmt = opts.expectedFmt ?? WEBAUTHN_FMT_PACKED;
|
|
122
|
+
if (cbor.fmt !== expectedFmt) {
|
|
123
|
+
errors.push({
|
|
124
|
+
message: `attestation fmt is \`${cbor.fmt}\`; only \`${expectedFmt}\` is supported in v1 (tpm / android-key / android-safetynet / fido-u2f / apple / none are additive future arms)`,
|
|
125
|
+
});
|
|
126
|
+
return fail(errors, {
|
|
127
|
+
cert_chain_valid,
|
|
128
|
+
signature_valid,
|
|
129
|
+
rp_bound,
|
|
130
|
+
identity_bound,
|
|
131
|
+
attestation_kind,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
if (cbor.sig === null) {
|
|
135
|
+
errors.push({ message: "attStmt.sig missing — packed attestation requires a signature" });
|
|
136
|
+
return fail(errors, {
|
|
137
|
+
cert_chain_valid,
|
|
138
|
+
signature_valid,
|
|
139
|
+
rp_bound,
|
|
140
|
+
identity_bound,
|
|
141
|
+
attestation_kind,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
if (cbor.alg === null) {
|
|
145
|
+
errors.push({ message: "attStmt.alg missing — packed attestation requires an algorithm" });
|
|
146
|
+
return fail(errors, {
|
|
147
|
+
cert_chain_valid,
|
|
148
|
+
signature_valid,
|
|
149
|
+
rp_bound,
|
|
150
|
+
identity_bound,
|
|
151
|
+
attestation_kind,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
if (cbor.alg !== COSE_ALG_ES256) {
|
|
155
|
+
errors.push({
|
|
156
|
+
message: `attStmt.alg ${cbor.alg} not supported in v1 (only ES256 / -7; RS256 and EdDSA are additive future arms)`,
|
|
157
|
+
});
|
|
158
|
+
return fail(errors, {
|
|
159
|
+
cert_chain_valid,
|
|
160
|
+
signature_valid,
|
|
161
|
+
rp_bound,
|
|
162
|
+
identity_bound,
|
|
163
|
+
attestation_kind,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
const nowDate = new Date(opts.now ? opts.now() : Date.now());
|
|
167
|
+
// Compute the signed bytes common to both attestation kinds: the
|
|
168
|
+
// WebAuthn spec prescribes verifying the packed signature over
|
|
169
|
+
// `authData || clientDataHash` where clientDataHash = SHA256(clientDataJSON).
|
|
170
|
+
const clientDataHash = await sha256Bytes(clientDataJsonBytes);
|
|
171
|
+
const signedBytes = concatBytes(cbor.authData, clientDataHash);
|
|
172
|
+
attestation_kind = cbor.x5c.length > 0 ? "full" : "self";
|
|
173
|
+
// ── Signature + chain verification ────────────────────────────────
|
|
174
|
+
if (attestation_kind === "full") {
|
|
175
|
+
// Full attestation — verify chain against pinned FIDO roots, then
|
|
176
|
+
// verify signature under the leaf's public key.
|
|
177
|
+
try {
|
|
178
|
+
const leaf = new x509.X509Certificate(toArrayBuffer(cbor.x5c[0]));
|
|
179
|
+
const chainCerts = [leaf];
|
|
180
|
+
for (let i = 1; i < cbor.x5c.length; i++) {
|
|
181
|
+
chainCerts.push(new x509.X509Certificate(toArrayBuffer(cbor.x5c[i])));
|
|
182
|
+
}
|
|
183
|
+
const rootPems = opts.rootPems ?? DEFAULT_FIDO_ROOTS;
|
|
184
|
+
if (rootPems.length === 0) {
|
|
185
|
+
errors.push({ message: "no pinned FIDO roots configured" });
|
|
186
|
+
return fail(errors, {
|
|
187
|
+
cert_chain_valid,
|
|
188
|
+
signature_valid,
|
|
189
|
+
rp_bound,
|
|
190
|
+
identity_bound,
|
|
191
|
+
attestation_kind,
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
let parsedRoots;
|
|
195
|
+
try {
|
|
196
|
+
parsedRoots = rootPems.map((pem) => new x509.X509Certificate(pem));
|
|
197
|
+
}
|
|
198
|
+
catch (err) {
|
|
199
|
+
errors.push({ message: `x509 root parse: ${messageOf(err)}` });
|
|
200
|
+
return fail(errors, {
|
|
201
|
+
cert_chain_valid,
|
|
202
|
+
signature_valid,
|
|
203
|
+
rp_bound,
|
|
204
|
+
identity_bound,
|
|
205
|
+
attestation_kind,
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
const chainResult = await verifyCertChain({
|
|
209
|
+
supplied: chainCerts,
|
|
210
|
+
roots: parsedRoots,
|
|
211
|
+
nowDate,
|
|
212
|
+
});
|
|
213
|
+
cert_chain_valid = chainResult.valid;
|
|
214
|
+
if (!cert_chain_valid) {
|
|
215
|
+
errors.push({ message: chainResult.reason });
|
|
216
|
+
}
|
|
217
|
+
else {
|
|
218
|
+
// Chain verified — now verify the packed signature under leaf.
|
|
219
|
+
try {
|
|
220
|
+
const ok = await verifyP256Signature(leaf.publicKey, cbor.sig, signedBytes);
|
|
221
|
+
signature_valid = ok;
|
|
222
|
+
if (!ok) {
|
|
223
|
+
errors.push({
|
|
224
|
+
message: "packed signature did not verify under leaf public key",
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
catch (err) {
|
|
229
|
+
errors.push({ message: `packed signature verify crashed: ${messageOf(err)}` });
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
catch (err) {
|
|
234
|
+
errors.push({ message: `x509 leaf parse: ${messageOf(err)}` });
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
else {
|
|
238
|
+
// Self attestation — no chain; verify signature against the credential
|
|
239
|
+
// public key embedded in authData.attestedCredentialData. The credential
|
|
240
|
+
// key is the `one` key the authenticator is asserting ownership of —
|
|
241
|
+
// proving it signed the challenge is the only claim self-attestation
|
|
242
|
+
// makes.
|
|
243
|
+
try {
|
|
244
|
+
const credPubKey = extractCredentialPublicKeyFromAuthData(cbor.authData);
|
|
245
|
+
const ok = await verifyP256SignatureFromCoseKey(credPubKey, cbor.sig, signedBytes);
|
|
246
|
+
// Chain-validity is trivially true for self attestation — there is
|
|
247
|
+
// no chain, so we report `cert_chain_valid: true` to mean "no chain
|
|
248
|
+
// was required for this kind". The kind field is the discriminator
|
|
249
|
+
// the scorer reads.
|
|
250
|
+
cert_chain_valid = true;
|
|
251
|
+
signature_valid = ok;
|
|
252
|
+
if (!ok) {
|
|
253
|
+
errors.push({
|
|
254
|
+
message: "self-attestation signature did not verify under credential public key",
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
catch (err) {
|
|
259
|
+
errors.push({ message: `self-attestation verify crashed: ${messageOf(err)}` });
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
// ── RP binding ────────────────────────────────────────────────────
|
|
263
|
+
try {
|
|
264
|
+
if (cbor.authData.length < 32) {
|
|
265
|
+
errors.push({ message: `authData shorter than 32 bytes (got ${cbor.authData.length})` });
|
|
266
|
+
}
|
|
267
|
+
else {
|
|
268
|
+
const rpIdHash = cbor.authData.subarray(0, 32);
|
|
269
|
+
const expected = await sha256Bytes(new TextEncoder().encode(opts.expectedRpId));
|
|
270
|
+
if (bytesEq(rpIdHash, expected)) {
|
|
271
|
+
rp_bound = true;
|
|
272
|
+
}
|
|
273
|
+
else {
|
|
274
|
+
errors.push({
|
|
275
|
+
message: `authData.rpIdHash does not equal SHA256("${opts.expectedRpId}")`,
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
catch (err) {
|
|
281
|
+
errors.push({ message: `rp binding crashed: ${messageOf(err)}` });
|
|
282
|
+
}
|
|
283
|
+
// ── Identity binding ──────────────────────────────────────────────
|
|
284
|
+
// Parse clientDataJSON, decode its `challenge` (base64url), and
|
|
285
|
+
// byte-compare against the SHA256 of the reconstructed canonical body
|
|
286
|
+
// naming the caller's identity. This is the cross-stack binding —
|
|
287
|
+
// without it, every other step would prove only that SOME WebAuthn
|
|
288
|
+
// platform authenticator did something, not that the Ed25519 key the
|
|
289
|
+
// credential subject claims is actually on this device.
|
|
290
|
+
try {
|
|
291
|
+
if (typeof opts.expectedIdentityPublicKeyHex !== "string" ||
|
|
292
|
+
opts.expectedIdentityPublicKeyHex.length === 0) {
|
|
293
|
+
errors.push({
|
|
294
|
+
message: "identity_bound: expectedIdentityPublicKeyHex not supplied",
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
else if (typeof opts.expectedMotebitId !== "string" || opts.expectedMotebitId.length === 0) {
|
|
298
|
+
errors.push({
|
|
299
|
+
message: "identity_bound: expectedMotebitId not supplied (required for body re-derivation)",
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
else if (typeof opts.expectedDeviceId !== "string" || opts.expectedDeviceId.length === 0) {
|
|
303
|
+
errors.push({
|
|
304
|
+
message: "identity_bound: expectedDeviceId not supplied (required for body re-derivation)",
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
else if (typeof opts.expectedAttestedAt !== "number" ||
|
|
308
|
+
!Number.isFinite(opts.expectedAttestedAt)) {
|
|
309
|
+
errors.push({
|
|
310
|
+
message: "identity_bound: expectedAttestedAt not supplied (required for body re-derivation)",
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
else {
|
|
314
|
+
const clientData = JSON.parse(new TextDecoder().decode(clientDataJsonBytes));
|
|
315
|
+
if (clientData === null || typeof clientData !== "object") {
|
|
316
|
+
errors.push({ message: "identity_bound: clientDataJSON is not a JSON object" });
|
|
317
|
+
}
|
|
318
|
+
else {
|
|
319
|
+
const challengeB64 = clientData.challenge;
|
|
320
|
+
if (typeof challengeB64 !== "string") {
|
|
321
|
+
errors.push({
|
|
322
|
+
message: "identity_bound: clientDataJSON.challenge missing or not a string",
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
else {
|
|
326
|
+
let challengeBytes;
|
|
327
|
+
try {
|
|
328
|
+
challengeBytes = fromBase64Url(challengeB64);
|
|
329
|
+
}
|
|
330
|
+
catch (err) {
|
|
331
|
+
errors.push({
|
|
332
|
+
message: `identity_bound: clientDataJSON.challenge base64url decode failed: ${messageOf(err)}`,
|
|
333
|
+
});
|
|
334
|
+
return buildResult();
|
|
335
|
+
}
|
|
336
|
+
const canonicalBody = buildCanonicalAttestationBody({
|
|
337
|
+
attested_at: opts.expectedAttestedAt,
|
|
338
|
+
device_id: opts.expectedDeviceId,
|
|
339
|
+
identity_public_key: opts.expectedIdentityPublicKeyHex.toLowerCase(),
|
|
340
|
+
motebit_id: opts.expectedMotebitId,
|
|
341
|
+
});
|
|
342
|
+
const derived = await sha256Bytes(new TextEncoder().encode(canonicalBody));
|
|
343
|
+
if (bytesEq(derived, challengeBytes)) {
|
|
344
|
+
identity_bound = true;
|
|
345
|
+
}
|
|
346
|
+
else {
|
|
347
|
+
errors.push({
|
|
348
|
+
message: "identity_bound: reconstructed SHA256(canonical body) does not equal clientDataJSON.challenge — body naming the caller's identity was not the body the browser signed over",
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
catch (err) {
|
|
356
|
+
errors.push({ message: `identity binding crashed: ${messageOf(err)}` });
|
|
357
|
+
}
|
|
358
|
+
return buildResult();
|
|
359
|
+
function buildResult() {
|
|
360
|
+
return {
|
|
361
|
+
valid: cert_chain_valid && signature_valid && rp_bound && identity_bound,
|
|
362
|
+
cert_chain_valid,
|
|
363
|
+
signature_valid,
|
|
364
|
+
rp_bound,
|
|
365
|
+
identity_bound,
|
|
366
|
+
attestation_kind,
|
|
367
|
+
errors,
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
// ── Helpers ──────────────────────────────────────────────────────────
|
|
372
|
+
function fail(errors, partial) {
|
|
373
|
+
return {
|
|
374
|
+
valid: false,
|
|
375
|
+
cert_chain_valid: partial.cert_chain_valid,
|
|
376
|
+
signature_valid: partial.signature_valid,
|
|
377
|
+
rp_bound: partial.rp_bound,
|
|
378
|
+
identity_bound: partial.identity_bound,
|
|
379
|
+
attestation_kind: partial.attestation_kind,
|
|
380
|
+
errors,
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
function messageOf(err) {
|
|
384
|
+
return err instanceof Error ? err.message : String(err);
|
|
385
|
+
}
|
|
386
|
+
async function sha256Bytes(data) {
|
|
387
|
+
const buf = await globalThis.crypto.subtle.digest("SHA-256", data);
|
|
388
|
+
return new Uint8Array(buf);
|
|
389
|
+
}
|
|
390
|
+
function concatBytes(a, b) {
|
|
391
|
+
const out = new Uint8Array(a.length + b.length);
|
|
392
|
+
out.set(a, 0);
|
|
393
|
+
out.set(b, a.length);
|
|
394
|
+
return out;
|
|
395
|
+
}
|
|
396
|
+
function bytesEq(a, b) {
|
|
397
|
+
if (a.length !== b.length)
|
|
398
|
+
return false;
|
|
399
|
+
for (let i = 0; i < a.length; i++)
|
|
400
|
+
if (a[i] !== b[i])
|
|
401
|
+
return false;
|
|
402
|
+
return true;
|
|
403
|
+
}
|
|
404
|
+
function toArrayBuffer(bytes) {
|
|
405
|
+
const copy = new Uint8Array(bytes.length);
|
|
406
|
+
copy.set(bytes);
|
|
407
|
+
return copy.buffer;
|
|
408
|
+
}
|
|
409
|
+
function fromBase64Url(str) {
|
|
410
|
+
let b64 = str.replace(/-/g, "+").replace(/_/g, "/");
|
|
411
|
+
const pad = b64.length % 4;
|
|
412
|
+
if (pad === 2)
|
|
413
|
+
b64 += "==";
|
|
414
|
+
else if (pad === 3)
|
|
415
|
+
b64 += "=";
|
|
416
|
+
else if (pad === 1)
|
|
417
|
+
throw new Error("invalid base64url length");
|
|
418
|
+
const binary = atob(b64);
|
|
419
|
+
const out = new Uint8Array(binary.length);
|
|
420
|
+
for (let i = 0; i < binary.length; i++)
|
|
421
|
+
out[i] = binary.charCodeAt(i);
|
|
422
|
+
return out;
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* Reconstruct the byte-identical canonical body the web mint path
|
|
426
|
+
* composes at WebAuthn creation time. Must stay byte-equal to
|
|
427
|
+
* `buildCanonicalAttestationBody` in
|
|
428
|
+
* `apps/web/src/mint-hardware-credential.ts`.
|
|
429
|
+
*
|
|
430
|
+
* Ordering: alphabetical (JCS), which is what the browser-side mint emits:
|
|
431
|
+
* attested_at, device_id, identity_public_key, motebit_id, platform,
|
|
432
|
+
* version.
|
|
433
|
+
*
|
|
434
|
+
* `platform` is always `"webauthn"` and `version` is always `"1"` — both
|
|
435
|
+
* constants live in the web mint path and must match exactly.
|
|
436
|
+
*/
|
|
437
|
+
function buildCanonicalAttestationBody(input) {
|
|
438
|
+
return (`{"attested_at":${input.attested_at}` +
|
|
439
|
+
`,"device_id":${jsonEscapeString(input.device_id)}` +
|
|
440
|
+
`,"identity_public_key":${jsonEscapeString(input.identity_public_key)}` +
|
|
441
|
+
`,"motebit_id":${jsonEscapeString(input.motebit_id)}` +
|
|
442
|
+
`,"platform":"webauthn"` +
|
|
443
|
+
`,"version":"1"}`);
|
|
444
|
+
}
|
|
445
|
+
function jsonEscapeString(s) {
|
|
446
|
+
let out = '"';
|
|
447
|
+
for (const ch of s) {
|
|
448
|
+
const code = ch.codePointAt(0);
|
|
449
|
+
if (ch === '"')
|
|
450
|
+
out += '\\"';
|
|
451
|
+
else if (ch === "\\")
|
|
452
|
+
out += "\\\\";
|
|
453
|
+
else if (ch === "\n")
|
|
454
|
+
out += "\\n";
|
|
455
|
+
else if (ch === "\r")
|
|
456
|
+
out += "\\r";
|
|
457
|
+
else if (ch === "\t")
|
|
458
|
+
out += "\\t";
|
|
459
|
+
else if (code < 0x20)
|
|
460
|
+
out += `\\u${code.toString(16).padStart(4, "0")}`;
|
|
461
|
+
else
|
|
462
|
+
out += ch;
|
|
463
|
+
}
|
|
464
|
+
out += '"';
|
|
465
|
+
return out;
|
|
466
|
+
}
|
|
467
|
+
/**
|
|
468
|
+
* Build and verify a FIDO attestation chain. The supplied certs begin
|
|
469
|
+
* with the leaf and walk toward the issuer; the caller supplies the
|
|
470
|
+
* pinned-root accept-set. We match one of the roots by subject DN and
|
|
471
|
+
* byte-equal DER after chain traversal. Mirrors the App Attest chain
|
|
472
|
+
* verifier's invariants:
|
|
473
|
+
*
|
|
474
|
+
* 1. `X509ChainBuilder.build(leaf)` returns a chain terminating at a
|
|
475
|
+
* self-signed cert reachable from the pool of (supplied, roots).
|
|
476
|
+
* 2. The terminal cert's DER byte-equals one of the pinned roots.
|
|
477
|
+
* 3. Every non-leaf cert carries `basicConstraints.cA === true`.
|
|
478
|
+
* 4. Every signature verifies under its issuer's public key.
|
|
479
|
+
* 5. Every cert is within its validity window at `nowDate`.
|
|
480
|
+
*/
|
|
481
|
+
async function verifyCertChain(input) {
|
|
482
|
+
const { supplied, roots, nowDate } = input;
|
|
483
|
+
const leaf = supplied[0];
|
|
484
|
+
const builder = new x509.X509ChainBuilder({
|
|
485
|
+
certificates: [...supplied, ...roots],
|
|
486
|
+
});
|
|
487
|
+
const chain = await builder.build(leaf);
|
|
488
|
+
const terminal = chain[chain.length - 1];
|
|
489
|
+
const terminalSelfSigned = await terminal.isSelfSigned();
|
|
490
|
+
if (!terminalSelfSigned) {
|
|
491
|
+
return { valid: false, reason: "chain does not terminate at a self-signed root" };
|
|
492
|
+
}
|
|
493
|
+
// Byte-equal against the pinned accept-set — any of the supplied roots
|
|
494
|
+
// is acceptable, but the terminal MUST match one.
|
|
495
|
+
const terminalDer = new Uint8Array(terminal.rawData);
|
|
496
|
+
const matchesPinned = roots.some((root) => bytesEq(terminalDer, new Uint8Array(root.rawData)));
|
|
497
|
+
if (!matchesPinned) {
|
|
498
|
+
return {
|
|
499
|
+
valid: false,
|
|
500
|
+
reason: "chain terminal cert DER does not match any pinned FIDO root",
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
for (let i = 0; i < chain.length; i++) {
|
|
504
|
+
const cert = chain[i];
|
|
505
|
+
if (nowDate < cert.notBefore || nowDate > cert.notAfter) {
|
|
506
|
+
return {
|
|
507
|
+
valid: false,
|
|
508
|
+
reason: `cert at chain position ${i} is outside its validity window at ${nowDate.toISOString()}`,
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
const isLeaf = i === 0;
|
|
512
|
+
if (!isLeaf && !certHasCaTrue(cert)) {
|
|
513
|
+
return {
|
|
514
|
+
valid: false,
|
|
515
|
+
reason: `cert at chain position ${i} lacks basicConstraints.cA=true (CA constraint not enforced)`,
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
const issuer = i === chain.length - 1 ? cert : chain[i + 1];
|
|
519
|
+
const sigOk = await cert.verify({ publicKey: issuer.publicKey, date: nowDate });
|
|
520
|
+
if (!sigOk) {
|
|
521
|
+
return {
|
|
522
|
+
valid: false,
|
|
523
|
+
reason: `cert at chain position ${i} signature did not verify under its issuer's public key`,
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
return { valid: true, reason: "ok" };
|
|
528
|
+
}
|
|
529
|
+
function certHasCaTrue(cert) {
|
|
530
|
+
const ext = cert.getExtension(BASIC_CONSTRAINTS_OID);
|
|
531
|
+
if (!ext)
|
|
532
|
+
return false;
|
|
533
|
+
return ext.ca === true;
|
|
534
|
+
}
|
|
535
|
+
/**
|
|
536
|
+
* Verify an ECDSA-P256 signature using a `@peculiar/x509` public-key
|
|
537
|
+
* handle. The leaf's `publicKey` is already a `PublicKey` wrapper; we
|
|
538
|
+
* import into WebCrypto with SHA-256 and verify the DER-encoded
|
|
539
|
+
* signature (packed fmt ships the signature in ASN.1 DER).
|
|
540
|
+
*/
|
|
541
|
+
async function verifyP256Signature(leafPublicKey, signatureDer, signedBytes) {
|
|
542
|
+
// Import the leaf's public key into the same WebCrypto provider
|
|
543
|
+
// `globalThis.crypto` exposes — `leafPublicKey.rawData` is the
|
|
544
|
+
// SubjectPublicKeyInfo DER bytes the cert carries; WebCrypto accepts
|
|
545
|
+
// it via the `spki` import format. This keeps the CryptoKey handle
|
|
546
|
+
// bound to the caller-installed `crypto` provider so the subsequent
|
|
547
|
+
// `subtle.verify` call doesn't hit the "2nd argument is not of type
|
|
548
|
+
// CryptoKey" cross-provider mismatch.
|
|
549
|
+
const cryptoKey = await globalThis.crypto.subtle.importKey("spki", leafPublicKey.rawData, { name: "ECDSA", namedCurve: "P-256" }, false, ["verify"]);
|
|
550
|
+
const rawSig = derToRawP256(signatureDer);
|
|
551
|
+
const ok = await globalThis.crypto.subtle.verify({ name: "ECDSA", hash: "SHA-256" }, cryptoKey, rawSig, signedBytes);
|
|
552
|
+
return ok;
|
|
553
|
+
}
|
|
554
|
+
/**
|
|
555
|
+
* Verify an ECDSA-P256 signature using a COSE_Key-encoded public key
|
|
556
|
+
* extracted from `authData.attestedCredentialData` (self-attestation
|
|
557
|
+
* path).
|
|
558
|
+
*/
|
|
559
|
+
async function verifyP256SignatureFromCoseKey(coseKeyBytes, signatureDer, signedBytes) {
|
|
560
|
+
const { x, y } = parseCoseEc2P256(coseKeyBytes);
|
|
561
|
+
// Assemble an uncompressed SEC1 public key: 0x04 || x(32) || y(32)
|
|
562
|
+
const raw = new Uint8Array(65);
|
|
563
|
+
raw[0] = 0x04;
|
|
564
|
+
raw.set(x, 1);
|
|
565
|
+
raw.set(y, 33);
|
|
566
|
+
const key = await globalThis.crypto.subtle.importKey("raw", raw, { name: "ECDSA", namedCurve: "P-256" }, false, ["verify"]);
|
|
567
|
+
const rawSig = derToRawP256(signatureDer);
|
|
568
|
+
return globalThis.crypto.subtle.verify({ name: "ECDSA", hash: "SHA-256" }, key, rawSig, signedBytes);
|
|
569
|
+
}
|
|
570
|
+
/**
|
|
571
|
+
* WebAuthn `authData` layout (bytes):
|
|
572
|
+
* rpIdHash(32) || flags(1) || counter(4) || [attestedCredentialData] || [extensions]
|
|
573
|
+
*
|
|
574
|
+
* `attestedCredentialData` (when flags.AT set):
|
|
575
|
+
* aaguid(16) || credentialIdLen(2) || credentialId(credentialIdLen) || credentialPublicKey (COSE_Key CBOR)
|
|
576
|
+
*
|
|
577
|
+
* Returns the raw COSE_Key CBOR bytes. The COSE_Key parser does the
|
|
578
|
+
* field-level extraction in `parseCoseEc2P256`.
|
|
579
|
+
*/
|
|
580
|
+
function extractCredentialPublicKeyFromAuthData(authData) {
|
|
581
|
+
if (authData.length < 37) {
|
|
582
|
+
throw new Error(`authData too short for attestedCredentialData (${authData.length} < 37)`);
|
|
583
|
+
}
|
|
584
|
+
const flags = authData[32];
|
|
585
|
+
const atFlag = (flags & 0x40) !== 0;
|
|
586
|
+
if (!atFlag) {
|
|
587
|
+
throw new Error("authData.flags.AT is not set — attestedCredentialData missing");
|
|
588
|
+
}
|
|
589
|
+
// Skip rpIdHash(32) + flags(1) + counter(4) = 37, then aaguid(16) = 53
|
|
590
|
+
if (authData.length < 55) {
|
|
591
|
+
throw new Error("authData too short to carry aaguid + credentialIdLen");
|
|
592
|
+
}
|
|
593
|
+
const credIdLen = (authData[53] << 8) | authData[54];
|
|
594
|
+
const credIdEnd = 55 + credIdLen;
|
|
595
|
+
if (authData.length < credIdEnd) {
|
|
596
|
+
throw new Error("authData too short to carry credentialId");
|
|
597
|
+
}
|
|
598
|
+
// The remainder of authData (minus any trailing extensions) is the
|
|
599
|
+
// COSE_Key CBOR. For v1 we do not support extensions, so we consume
|
|
600
|
+
// to the end.
|
|
601
|
+
return authData.subarray(credIdEnd);
|
|
602
|
+
}
|
|
603
|
+
/**
|
|
604
|
+
* Parse the subset of COSE_Key fields needed to reconstruct an ES256
|
|
605
|
+
* public key: kty=2 (EC2), alg=-7 (ES256), crv=1 (P-256), x, y.
|
|
606
|
+
*
|
|
607
|
+
* COSE_Key is a CBOR map with integer keys. The shape is well-defined
|
|
608
|
+
* and fixed for ES256 credentials — we delegate the CBOR parse to
|
|
609
|
+
* `cbor2` and validate the int-keyed field presence here.
|
|
610
|
+
*/
|
|
611
|
+
function parseCoseEc2P256(coseBytes) {
|
|
612
|
+
const decoded = cborDecode(coseBytes);
|
|
613
|
+
if (!(decoded instanceof Map)) {
|
|
614
|
+
throw new Error("COSE_Key is not a CBOR map");
|
|
615
|
+
}
|
|
616
|
+
// cbor2 types its Map as `Map<any, any>`; narrow via `unknown` reads
|
|
617
|
+
// so the COSE_Key field-presence checks below are type-safe.
|
|
618
|
+
const map = decoded;
|
|
619
|
+
const kty = map.get(1);
|
|
620
|
+
const alg = map.get(3);
|
|
621
|
+
const crv = map.get(-1);
|
|
622
|
+
const x = map.get(-2);
|
|
623
|
+
const y = map.get(-3);
|
|
624
|
+
if (kty !== 2)
|
|
625
|
+
throw new Error(`COSE_Key.kty ${String(kty)} is not EC2 (2)`);
|
|
626
|
+
if (alg !== -7)
|
|
627
|
+
throw new Error(`COSE_Key.alg ${String(alg)} is not ES256 (-7)`);
|
|
628
|
+
if (crv !== 1)
|
|
629
|
+
throw new Error(`COSE_Key.crv ${String(crv)} is not P-256 (1)`);
|
|
630
|
+
if (!(x instanceof Uint8Array) || x.length !== 32) {
|
|
631
|
+
throw new Error(`COSE_Key.x missing or wrong length`);
|
|
632
|
+
}
|
|
633
|
+
if (!(y instanceof Uint8Array) || y.length !== 32) {
|
|
634
|
+
throw new Error(`COSE_Key.y missing or wrong length`);
|
|
635
|
+
}
|
|
636
|
+
return {
|
|
637
|
+
x: new Uint8Array(x.buffer, x.byteOffset, x.byteLength).slice(),
|
|
638
|
+
y: new Uint8Array(y.buffer, y.byteOffset, y.byteLength).slice(),
|
|
639
|
+
};
|
|
640
|
+
}
|
|
641
|
+
/**
|
|
642
|
+
* Convert an ASN.1 DER-encoded ECDSA signature to the raw r||s form
|
|
643
|
+
* WebCrypto expects (64 bytes for P-256).
|
|
644
|
+
*
|
|
645
|
+
* SEQUENCE { INTEGER r, INTEGER s }
|
|
646
|
+
*
|
|
647
|
+
* DER integers are signed big-endian with a leading 0x00 if the MSB of
|
|
648
|
+
* the unsigned value is set; we strip leading zeros and left-pad to 32.
|
|
649
|
+
*/
|
|
650
|
+
function derToRawP256(derSig) {
|
|
651
|
+
let i = 0;
|
|
652
|
+
if (derSig[i++] !== 0x30)
|
|
653
|
+
throw new Error("ECDSA signature not a DER SEQUENCE");
|
|
654
|
+
let seqLen = derSig[i++];
|
|
655
|
+
if (seqLen & 0x80) {
|
|
656
|
+
const nBytes = seqLen & 0x7f;
|
|
657
|
+
seqLen = 0;
|
|
658
|
+
for (let j = 0; j < nBytes; j++)
|
|
659
|
+
seqLen = (seqLen << 8) | derSig[i++];
|
|
660
|
+
}
|
|
661
|
+
void seqLen;
|
|
662
|
+
if (derSig[i++] !== 0x02)
|
|
663
|
+
throw new Error("ECDSA signature.r not a DER INTEGER");
|
|
664
|
+
const rLen = derSig[i++];
|
|
665
|
+
const r = derSig.subarray(i, i + rLen);
|
|
666
|
+
i += rLen;
|
|
667
|
+
if (derSig[i++] !== 0x02)
|
|
668
|
+
throw new Error("ECDSA signature.s not a DER INTEGER");
|
|
669
|
+
const sLen = derSig[i++];
|
|
670
|
+
const s = derSig.subarray(i, i + sLen);
|
|
671
|
+
const rPadded = leftPadTo32(stripLeadingZero(r));
|
|
672
|
+
const sPadded = leftPadTo32(stripLeadingZero(s));
|
|
673
|
+
const out = new Uint8Array(64);
|
|
674
|
+
out.set(rPadded, 0);
|
|
675
|
+
out.set(sPadded, 32);
|
|
676
|
+
return out;
|
|
677
|
+
}
|
|
678
|
+
function stripLeadingZero(bytes) {
|
|
679
|
+
let i = 0;
|
|
680
|
+
while (i < bytes.length - 1 && bytes[i] === 0x00)
|
|
681
|
+
i++;
|
|
682
|
+
return bytes.subarray(i);
|
|
683
|
+
}
|
|
684
|
+
function leftPadTo32(bytes) {
|
|
685
|
+
if (bytes.length === 32)
|
|
686
|
+
return bytes;
|
|
687
|
+
if (bytes.length > 32)
|
|
688
|
+
throw new Error(`ECDSA integer longer than 32 bytes (got ${bytes.length})`);
|
|
689
|
+
const out = new Uint8Array(32);
|
|
690
|
+
out.set(bytes, 32 - bytes.length);
|
|
691
|
+
return out;
|
|
692
|
+
}
|
|
693
|
+
//# sourceMappingURL=verify.js.map
|