@blamejs/pki 0.4.7 → 0.4.9
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/CHANGELOG.md +23 -0
- package/lib/constants.js +22 -0
- package/lib/webauthn.js +278 -7
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,29 @@ All notable changes to `@blamejs/pki` are documented here. The format
|
|
|
4
4
|
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this
|
|
5
5
|
project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## v0.4.9 — 2026-08-09
|
|
8
|
+
|
|
9
|
+
A WebAuthn compound attestation now verifies -- every nested statement must pass, so a wrapper cannot launder a failed attestation behind one that succeeds -- and the certificate chains an attestation carries are bounded by count, not only by size.
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- pki.webauthn.verify verifies the compound attestation format, which it previously refused as unsupported. Every nested statement must verify for the attestation to verify -- the specification leaves the threshold to relying-party policy, and this is the fail-closed reading of it. The result reports attestation type Compound and carries each element's own verdict, attestation type and certificate chain in order, so a caller applies its own policy to the parts rather than to a merged verdict that could overstate or understate any of them. The combined trust path is empty by construction: several elements produce several independent chains, and presenting them as one ordered path would misrepresent what was validated.
|
|
14
|
+
- The nested statements are held to the format's own syntax: at least two of them, each exactly a format identifier and a statement, each identifier matched case-sensitively against the supported set, and none of them compound -- the specification spells that exclusion out, so nesting is impossible by construction rather than by a depth counter. Which CBOR shape a statement takes is now a property of the format rather than a fixed rule, so accommodating the array-shaped compound statement leaves every other format's contract unchanged, and a compound presented in the older map shape is refused.
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- The number of certificates an attestation may carry is now bounded. Both the attestation statement's certificate array and a JSON Web Signature certificate header capped the size of each certificate but not how many there were, so a statement could present thousands of small certificates and each one cost a parse and, downstream, a full path validation -- work far out of proportion to the bytes on the wire. A single bound now covers every place a chain arrives, set well above any real attestation chain.
|
|
19
|
+
|
|
20
|
+
## v0.4.8 — 2026-08-08
|
|
21
|
+
|
|
22
|
+
A stored android-safetynet WebAuthn attestation can be re-verified in full -- the signature, the registration binding, and the certificate chain -- behind an opt-in and against a root the caller supplies.
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
|
|
26
|
+
- pki.webauthn.verify verifies the android-safetynet attestation format, which it previously refused as unsupported. Enable it with opts.verifySafetyNetJws and supply the Google root(s) to anchor the chain to as opts.safetyNetRoots -- both are required, and with either missing the call is refused rather than falling back to a weaker check. The format is off by default and this library bundles no root, because the service that produced these statements is retired and choosing a trust anchor on a caller's behalf is not this library's decision to make. A caller who does not enable it sees the same result as before.
|
|
27
|
+
- Every binding the specification states is checked, and each failure names which one: the response must be a three-part JWS whose algorithm is RS256, its signature must verify under the certificate in its own header, its nonce must match this registration's authenticator data and client data, the certificate must be issued to attest.android.com, and the chain must validate to one of the supplied roots. The algorithm is pinned rather than read from the token, so a statement cannot select its own verification algorithm. The hostname is matched exactly against the certificate's subject alternative name, falling back to its common name only when it carries no alternative name at all -- a name merely ending in attest.android.com does not pass. The chain goes through full path validation, so an expired or otherwise non-conforming certificate cannot pass on a signature alone. On success the result reports attestation type Basic with the embedded chain as its trust path.
|
|
28
|
+
- Device-integrity signals in the response -- whether the device passed the compatibility test suite, the reported timestamp, the requesting package -- are deliberately not gated on, because the specification does not make them part of attestation verification. They remain relying-party policy.
|
|
29
|
+
|
|
7
30
|
## v0.4.7 — 2026-08-08
|
|
8
31
|
|
|
9
32
|
One certificate now renders one distinguished-name string whichever parser read it -- a C509 certificate's subject and issuer strings joined their components without the separating space every other parser in the toolkit uses.
|
package/lib/constants.js
CHANGED
|
@@ -297,6 +297,28 @@ var LIMITS = {
|
|
|
297
297
|
// terminates but exhausts memory. This matches PATH_MAX_CERTS: a chain longer than the path
|
|
298
298
|
// validator will ever accept has nothing to offer, so refusing it at the decoder is free.
|
|
299
299
|
TLS_CERT_MAX_ENTRIES: 100,
|
|
300
|
+
// The most nested statements a WebAuthn compound attestation (sec. 8.9) may carry. The syntax
|
|
301
|
+
// says "2*" -- unbounded -- so this is a resource bound this toolkit chooses, NOT a spec MUST.
|
|
302
|
+
// The CBOR parse caps bound the decode; they do not bound the crypto, and each element costs a
|
|
303
|
+
// signature verification plus, for some formats, a full certificate-path validation. Seven
|
|
304
|
+
// non-compound formats are registered, so a conforming statement carrying one of each is 7.
|
|
305
|
+
WEBAUTHN_COMPOUND_MAX_STATEMENTS: 16,
|
|
306
|
+
// The most certificates a WebAuthn attestation chain may carry, in an attStmt x5c array or a JWS
|
|
307
|
+
// x5c header. A real attestation chain is one to four; the byte ceiling on a single entry does
|
|
308
|
+
// not bound the COUNT, so an array of thousands of small certificates is an unbounded parse and
|
|
309
|
+
// signature-check fanout (CWE-770) that terminates but costs far more CPU than wire. This is a
|
|
310
|
+
// resource bound this toolkit chooses -- no specification states it -- and it is far tighter than
|
|
311
|
+
// PATH_MAX_CERTS because an attestation chain is not a general PKI path.
|
|
312
|
+
WEBAUTHN_X5C_MAX_CERTS: 10,
|
|
313
|
+
// A WebAuthn android-safetynet attestation statement carries the SafetyNet response as a JWS
|
|
314
|
+
// compact serialization: two small JSON objects plus a signature, with the certificate chain
|
|
315
|
+
// inline in the header. Real ones run a few kilobytes. The cap bounds the text decode before the
|
|
316
|
+
// string is materialized, so an oversized statement is refused rather than allocated (CWE-770).
|
|
317
|
+
SAFETYNET_JWS_MAX_BYTES: BYTES.kib(64),
|
|
318
|
+
// One x5c entry is a single DER certificate; PATH_MAX_CERT_BYTES is the ceiling the path
|
|
319
|
+
// validator already applies to any certificate it will accept, and an entry above it could not
|
|
320
|
+
// chain even if it decoded.
|
|
321
|
+
SAFETYNET_CERT_MAX_BYTES: BYTES.kib(64),
|
|
300
322
|
// ACME challenge token entropy floor (RFC 8555 sec. 8, errata 6950): >= 128
|
|
301
323
|
// bits of base64url is >= 22 characters. A shorter token is refused before use.
|
|
302
324
|
ACME_TOKEN_MIN_CHARS: 22,
|
package/lib/webauthn.js
CHANGED
|
@@ -8,8 +8,9 @@
|
|
|
8
8
|
* @intro Trust evaluation of a W3C WebAuthn (Level 3) / passkey attestation: parse
|
|
9
9
|
* the attestation object + authenticatorData, decode the COSE credential public
|
|
10
10
|
* key, and verify each defined attestation-statement format (packed, tpm,
|
|
11
|
-
* android-key, apple, fido-u2f, none
|
|
12
|
-
*
|
|
11
|
+
* android-key, apple, fido-u2f, none, compound, and android-safetynet behind an
|
|
12
|
+
* opt-in) -- the attestation-statement signature and each format's structural
|
|
13
|
+
* bindings. The attestation CBOR is decoded by the strict,
|
|
13
14
|
* fail-closed `pki.cbor` codec (WebAuthn keys are CTAP2-canonical), the signature by
|
|
14
15
|
* `pki.webcrypto`. Chaining the returned x5c trust path to a caller-pinned root via
|
|
15
16
|
* `pki.path.validate` is the caller's step: this module verifies the statement, not
|
|
@@ -31,6 +32,9 @@ var webcrypto = require("./webcrypto");
|
|
|
31
32
|
var constants = require("./constants");
|
|
32
33
|
var validator = require("./validator-all");
|
|
33
34
|
var edwardsPoint = require("./edwards-point");
|
|
35
|
+
var guard = require("./guard-all");
|
|
36
|
+
var jose = require("./jose");
|
|
37
|
+
var pathValidate = require("./path-validate");
|
|
34
38
|
var nodeCrypto = require("crypto");
|
|
35
39
|
|
|
36
40
|
var WebauthnError = frameworkError.WebauthnError;
|
|
@@ -324,11 +328,16 @@ function parseAttestationObject(bytes) {
|
|
|
324
328
|
// (WebAuthn 6.5.4); an extra top-level key is a non-canonical envelope, rejected.
|
|
325
329
|
if (root.children.length !== 3 || !fmtN || !attStmtN || !authDataN) throw _err("webauthn/bad-attestation-object", "the attestation object must be exactly { fmt, attStmt, authData }");
|
|
326
330
|
if (fmtN.majorType !== 3) throw _err("webauthn/bad-attestation-object", "attestation object 'fmt' must be a text string");
|
|
327
|
-
// attStmt is the attestation statement, a CBOR map keyed by field name (WebAuthn 6.5.4)
|
|
328
|
-
//
|
|
329
|
-
//
|
|
330
|
-
// nodes, not { key, value } pairs)
|
|
331
|
-
|
|
331
|
+
// attStmt is the attestation statement, a CBOR map keyed by field name (WebAuthn 6.5.4) for every
|
|
332
|
+
// format but one: sec. 8.9 gives compound an ARRAY of nested statements. Which shape a format
|
|
333
|
+
// takes is a registry row, not a branch, so adding a format cannot silently widen the envelope
|
|
334
|
+
// for the others -- a non-map value (whose children are single nodes, not { key, value } pairs)
|
|
335
|
+
// must never reach the per-field statement walk of a format that expects a map.
|
|
336
|
+
var wantMajor = ATT_STMT_MAJOR[cbor.read.textString(fmtN)];
|
|
337
|
+
if (wantMajor === undefined) wantMajor = 5;
|
|
338
|
+
if (attStmtN.majorType !== wantMajor) {
|
|
339
|
+
throw _err("webauthn/bad-attestation-object", "attestation object 'attStmt' must be a CBOR " + (wantMajor === 4 ? "array" : "map") + " for format " + JSON.stringify(cbor.read.textString(fmtN)));
|
|
340
|
+
}
|
|
332
341
|
if (authDataN.majorType !== 2) throw _err("webauthn/bad-attestation-object", "attestation object 'authData' must be a byte string");
|
|
333
342
|
var authDataBytes = cbor.read.byteString(authDataN);
|
|
334
343
|
return {
|
|
@@ -374,9 +383,24 @@ function _requireAttShape(attStmt, allowed, required) {
|
|
|
374
383
|
Object.keys(have).forEach(function (k) { if (allowed.indexOf(k) === -1) throw _err("webauthn/bad-att-stmt", "the attestation statement carries an unexpected field '" + k + "'"); });
|
|
375
384
|
required.forEach(function (k) { if (!have[k]) throw _err("webauthn/bad-att-stmt", "the attestation statement is missing the '" + k + "' field"); });
|
|
376
385
|
}
|
|
386
|
+
// The CBOR major type each format's attStmt takes. Every format uses a map (5) except compound,
|
|
387
|
+
// whose sec. 8.9 syntax is an array of nested statements. A data row rather than a branch, so a
|
|
388
|
+
// future format declares its shape here instead of loosening the shared envelope check.
|
|
389
|
+
var ATT_STMT_MAJOR = { compound: 4 };
|
|
390
|
+
|
|
391
|
+
// One bound for every attestation certificate chain, wherever it arrives from -- an attStmt x5c
|
|
392
|
+
// array or a JWS x5c header. Capping the bytes of a single entry does not bound the COUNT, and the
|
|
393
|
+
// cost of an entry is a DER parse plus, downstream, a signature check or a path validation. Kept in
|
|
394
|
+
// one place so a new chain-bearing format cannot reintroduce the unbounded fanout.
|
|
395
|
+
function _requireX5cCount(n) {
|
|
396
|
+
if (n > constants.LIMITS.WEBAUTHN_X5C_MAX_CERTS) {
|
|
397
|
+
throw _err("webauthn/bad-att-stmt", "an attestation certificate chain carries " + n + " certificates, above the " + constants.LIMITS.WEBAUTHN_X5C_MAX_CERTS + " this toolkit will parse");
|
|
398
|
+
}
|
|
399
|
+
}
|
|
377
400
|
function _readX5c(attStmt) {
|
|
378
401
|
var x5cN = cbor.read.mapGet(attStmt, "x5c");
|
|
379
402
|
if (!x5cN || x5cN.majorType !== 4 || !x5cN.children || !x5cN.children.length) throw _err("webauthn/bad-att-stmt", "x5c must be a non-empty array of certificates");
|
|
403
|
+
_requireX5cCount(x5cN.children.length);
|
|
380
404
|
return x5cN.children.map(function (c) {
|
|
381
405
|
var der;
|
|
382
406
|
try { der = cbor.read.byteString(c); } catch (e) { throw _err("webauthn/bad-att-stmt", "an x5c entry must be a byte string", e); }
|
|
@@ -517,6 +541,188 @@ var VERIFIERS = {
|
|
|
517
541
|
});
|
|
518
542
|
},
|
|
519
543
|
|
|
544
|
+
// android-safetynet (WebAuthn 8.5): the attStmt carries a SafetyNet JWS ("response") whose payload
|
|
545
|
+
// binds a nonce to this registration and whose x5c header chains to a Google root.
|
|
546
|
+
//
|
|
547
|
+
// OFF BY DEFAULT, and anchored only by the caller. Google retired the SafetyNet Attestation API, so
|
|
548
|
+
// nothing mints these any more -- the surviving use is a relying party re-checking attestations it
|
|
549
|
+
// stored years ago. Enabling a format whose producer is gone, against a root this library chose,
|
|
550
|
+
// would widen what every caller trusts for no live benefit; so the caller opts in AND supplies the
|
|
551
|
+
// root. With the opt off the verdict is byte-identical to the one this format had before the arm
|
|
552
|
+
// existed. There is no bundled root and no trust-on-first-use.
|
|
553
|
+
"android-safetynet": function (att, clientDataHash, opts) {
|
|
554
|
+
opts = opts || {};
|
|
555
|
+
if (opts.verifySafetyNetJws !== true) {
|
|
556
|
+
if (opts.verifySafetyNetJws !== undefined && typeof opts.verifySafetyNetJws !== "boolean") {
|
|
557
|
+
throw _err("webauthn/bad-input", "opts.verifySafetyNetJws must be a boolean");
|
|
558
|
+
}
|
|
559
|
+
throw _err("webauthn/unsupported-format", "attestation statement format 'android-safetynet' is not supported");
|
|
560
|
+
}
|
|
561
|
+
var roots = opts.safetyNetRoots;
|
|
562
|
+
if (!Array.isArray(roots) || roots.length === 0) {
|
|
563
|
+
throw _err("webauthn/safetynet-no-root", "verifying an android-safetynet attestation requires opts.safetyNetRoots -- the Google root(s) to anchor the x5c chain to; this library bundles none (WebAuthn 8.5)");
|
|
564
|
+
}
|
|
565
|
+
if (opts.time !== undefined) guard.time.assertValid(opts.time, WebauthnError, "webauthn/bad-input", "opts.time");
|
|
566
|
+
|
|
567
|
+
// 8.5 attStmt syntax: safetynetStmtFormat = { ver: text, response: bytes }. `ver` is READ but
|
|
568
|
+
// never gated on -- 8.5 states it is reserved for future use.
|
|
569
|
+
_requireAttShape(att.attStmt, ["ver", "response"], ["ver", "response"]);
|
|
570
|
+
_attRead(att.attStmt, "ver", cbor.read.textString, "a text string");
|
|
571
|
+
var responseBytes = _attRead(att.attStmt, "response", cbor.read.byteString, "a byte string");
|
|
572
|
+
if (!responseBytes.length) throw _err("webauthn/bad-att-stmt", "the android-safetynet response is empty");
|
|
573
|
+
|
|
574
|
+
// RFC 7515 sec. 3.1 Compact Serialization: exactly three base64url segments.
|
|
575
|
+
var segs = guard.text.decode(responseBytes, constants.LIMITS.SAFETYNET_JWS_MAX_BYTES, WebauthnError, "webauthn/bad-att-stmt", "the android-safetynet response").split(".");
|
|
576
|
+
if (segs.length !== 3) throw _err("webauthn/bad-att-stmt", "the android-safetynet response is not a three-part JWS compact serialization (RFC 7515 sec. 3.1)");
|
|
577
|
+
var header, payload, sigBytes;
|
|
578
|
+
try {
|
|
579
|
+
header = jose.parseJson(jose.base64url.decode(segs[0]));
|
|
580
|
+
payload = jose.parseJson(jose.base64url.decode(segs[1]));
|
|
581
|
+
sigBytes = Buffer.from(jose.base64url.decode(segs[2]));
|
|
582
|
+
} catch (e) { throw _err("webauthn/bad-att-stmt", "the android-safetynet response is not a decodable JWS", e); }
|
|
583
|
+
// A JWS segment must decode to a JSON OBJECT. `null`, a number, a string and an array are all
|
|
584
|
+
// valid JSON, so the parse succeeds and every later field read would be a raw TypeError escaping
|
|
585
|
+
// this module's typed contract -- the caller's error handling would never see a webauthn/* code.
|
|
586
|
+
if (!_isPlainObject(header) || !_isPlainObject(payload)) {
|
|
587
|
+
throw _err("webauthn/bad-att-stmt", "the android-safetynet JWS header and payload must each be a JSON object");
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// Pin the algorithm rather than reading it from the token: an attacker-chosen alg is the JWS
|
|
591
|
+
// algorithm-confusion class, and SafetyNet only ever signed RS256.
|
|
592
|
+
if (header.alg !== "RS256") throw _err("webauthn/unsupported-algorithm", "the android-safetynet JWS alg must be RS256, got " + JSON.stringify(header.alg));
|
|
593
|
+
if (!Array.isArray(header.x5c) || header.x5c.length === 0) {
|
|
594
|
+
throw _err("webauthn/bad-att-stmt", "the android-safetynet JWS header carries no x5c certificate chain (RFC 7515 sec. 4.1.6)");
|
|
595
|
+
}
|
|
596
|
+
_requireX5cCount(header.x5c.length);
|
|
597
|
+
// x5c entries are STANDARD base64 (RFC 7515 sec. 4.1.6), not base64url like the segments.
|
|
598
|
+
var chain = header.x5c.map(function (entry, i) {
|
|
599
|
+
if (typeof entry !== "string") throw _err("webauthn/bad-att-stmt", "the android-safetynet x5c entry " + i + " is not a string");
|
|
600
|
+
var der;
|
|
601
|
+
try { der = guard.encoding.base64(entry, constants.LIMITS.SAFETYNET_CERT_MAX_BYTES, WebauthnError, "webauthn/bad-att-stmt", "an android-safetynet x5c entry"); }
|
|
602
|
+
catch (e) { throw _err("webauthn/bad-att-stmt", "the android-safetynet x5c entry " + i + " is not canonical base64", e); }
|
|
603
|
+
try { return x509.parse(der); }
|
|
604
|
+
catch (e) { throw _err("webauthn/bad-att-cert", "the android-safetynet x5c entry " + i + " is not a decodable certificate", e); }
|
|
605
|
+
});
|
|
606
|
+
var leaf = chain[0];
|
|
607
|
+
|
|
608
|
+
// 8.5 bullet 3: nonce == STANDARD Base64 of SHA-256(authenticatorData || clientDataHash). Note
|
|
609
|
+
// standard base64 (+/=), NOT base64url -- and the digest is over the raw concatenation.
|
|
610
|
+
var wantNonce = _sha("sha256", Buffer.concat([att.authDataBytes, clientDataHash])).toString("base64");
|
|
611
|
+
if (typeof payload.nonce !== "string" || !guard.crypto.constantTimeEqual(Buffer.from(payload.nonce, "utf8"), Buffer.from(wantNonce, "utf8"))) {
|
|
612
|
+
throw _err("webauthn/safetynet-nonce-mismatch", "the android-safetynet nonce does not bind this authenticatorData and clientDataHash (WebAuthn 8.5)");
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// 8.5 bullet 4 (via the SafetyNet documentation): the response must come from the SafetyNet
|
|
616
|
+
// service, which is established by the leaf being issued to attest.android.com AND the chain
|
|
617
|
+
// validating to a Google root. The hostname alone proves nothing until the chain is anchored.
|
|
618
|
+
if (!_safetyNetHostnameOk(leaf)) {
|
|
619
|
+
throw _err("webauthn/safetynet-bad-hostname", "the android-safetynet x5c leaf is not issued to attest.android.com (WebAuthn 8.5)");
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// The device-integrity signals are NOT part of the 8.5 verification procedure -- its five bullets
|
|
623
|
+
// never mention them -- so gating the attestation verdict on them would invent a requirement the
|
|
624
|
+
// specification does not state. They are relying-party policy, so they are surfaced on the result
|
|
625
|
+
// for a caller to act on, and enforced here only when the caller explicitly asks. A caller that
|
|
626
|
+
// asks and finds them missing or false gets a refusal, never a silent pass.
|
|
627
|
+
var signals = {
|
|
628
|
+
ctsProfileMatch: payload.ctsProfileMatch, basicIntegrity: payload.basicIntegrity,
|
|
629
|
+
timestampMs: payload.timestampMs, apkPackageName: payload.apkPackageName,
|
|
630
|
+
apkCertificateDigestSha256: payload.apkCertificateDigestSha256, advice: payload.advice,
|
|
631
|
+
};
|
|
632
|
+
if (opts.requireCtsProfileMatch === true && signals.ctsProfileMatch !== true) {
|
|
633
|
+
throw _err("webauthn/safetynet-cts-profile", "the android-safetynet response reports ctsProfileMatch " + JSON.stringify(signals.ctsProfileMatch) + ", and opts.requireCtsProfileMatch demands true");
|
|
634
|
+
}
|
|
635
|
+
if (opts.requireCtsProfileMatch !== undefined && typeof opts.requireCtsProfileMatch !== "boolean") {
|
|
636
|
+
throw _err("webauthn/bad-input", "opts.requireCtsProfileMatch must be a boolean");
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
return _verifySig(-257, sigBytes, leaf.subjectPublicKeyInfo.bytes,
|
|
640
|
+
Buffer.from(segs[0] + "." + segs[1], "ascii"), _err).then(function (ok) {
|
|
641
|
+
if (!ok) throw _err("webauthn/verify-failed", "the android-safetynet JWS signature does not verify under the x5c leaf key");
|
|
642
|
+
// WHEN to judge the chain. These attestations are historical by construction -- the service is
|
|
643
|
+
// retired -- so a leaf that was valid when the response was signed is routinely expired now,
|
|
644
|
+
// and judging it against the current clock would refuse every genuine stored registration.
|
|
645
|
+
// The response carries its own signing time, and by this point that value is covered by the
|
|
646
|
+
// signature just verified under the leaf, so it is authenticated rather than caller-asserted.
|
|
647
|
+
// Precedence: an explicit opts.time (the caller knows when the registration happened) beats
|
|
648
|
+
// the signed timestamp, which beats now (a response that carries no usable timestamp).
|
|
649
|
+
var at = opts.time !== undefined ? opts.time
|
|
650
|
+
: (typeof payload.timestampMs === "number" && isFinite(payload.timestampMs) && payload.timestampMs > 0
|
|
651
|
+
? new Date(payload.timestampMs) : undefined);
|
|
652
|
+
return _safetyNetChainTrusted(chain, roots, at);
|
|
653
|
+
}).then(function () {
|
|
654
|
+
// 8.5 bullet 5: attestation type Basic, trust path x5c.
|
|
655
|
+
var res = _result("android-safetynet", "Basic", chain, att);
|
|
656
|
+
res.safetyNet = signals;
|
|
657
|
+
return res;
|
|
658
|
+
});
|
|
659
|
+
},
|
|
660
|
+
|
|
661
|
+
// compound (WebAuthn 8.9): the attStmt is an ARRAY of nested attestation statements, each
|
|
662
|
+
// verified over the SAME authenticatorData and clientDataHash as the outer object. sec. 8.9
|
|
663
|
+
// leaves the acceptance threshold to relying-party policy ("if validation fails for one or more
|
|
664
|
+
// subStmt, decide the appropriate result based on RP policy"); this toolkit's policy is
|
|
665
|
+
// fail-closed -- EVERY element must verify, because accepting a compound whose strong element
|
|
666
|
+
// failed and whose `none` element passed would let a wrapper launder a failed attestation.
|
|
667
|
+
compound: function (att, clientDataHash, opts) {
|
|
668
|
+
var kids = att.attStmt.children || [];
|
|
669
|
+
// sec. 8.9 syntax `2*`: at least two nested statements, or it is not a compound.
|
|
670
|
+
if (kids.length < 2) throw _err("webauthn/bad-att-stmt", "a compound attestation statement must carry at least two nested statements (WebAuthn 8.9)");
|
|
671
|
+
// Not a spec rule: a resource bound this toolkit chooses. The CBOR caps bound the PARSE; they
|
|
672
|
+
// do not bound the crypto, and each element costs a signature verify plus, for some formats, a
|
|
673
|
+
// certificate-chain path validation.
|
|
674
|
+
if (kids.length > constants.LIMITS.WEBAUTHN_COMPOUND_MAX_STATEMENTS) {
|
|
675
|
+
throw _err("webauthn/bad-att-stmt", "a compound attestation statement carries " + kids.length + " nested statements, above the " + constants.LIMITS.WEBAUTHN_COMPOUND_MAX_STATEMENTS + " this toolkit will verify");
|
|
676
|
+
}
|
|
677
|
+
var elements = kids.map(function (el, i) {
|
|
678
|
+
// sec. 8.9: nonCompoundAttStmt = { $$attStmtType } -- each element is exactly { fmt, attStmt }.
|
|
679
|
+
if (!el || el.majorType !== 5) throw _err("webauthn/bad-att-stmt", "compound element " + i + " must be a CBOR map { fmt, attStmt } (WebAuthn 8.9)");
|
|
680
|
+
var fN = cbor.read.mapGet(el, "fmt"), sN = cbor.read.mapGet(el, "attStmt");
|
|
681
|
+
if (el.children.length !== 2 || !fN || !sN) throw _err("webauthn/bad-att-stmt", "compound element " + i + " must be exactly { fmt, attStmt } (WebAuthn 8.9)");
|
|
682
|
+
if (fN.majorType !== 3) throw _err("webauthn/bad-att-stmt", "compound element " + i + " 'fmt' must be a text string");
|
|
683
|
+
var f = cbor.read.textString(fN);
|
|
684
|
+
// sec. 8.9 spells the element type `.ne "compound"`: nesting is forbidden by the syntax
|
|
685
|
+
// itself, which is what fixes the evaluation depth at one. No depth parameter is needed --
|
|
686
|
+
// and adding one would imply a nesting this format does not have.
|
|
687
|
+
if (f === "compound") throw _err("webauthn/bad-att-stmt", "a compound attestation statement must not nest another compound (WebAuthn 8.9)");
|
|
688
|
+
// sec. 8.1: identifiers match case-sensitively, which the registry lookup already is.
|
|
689
|
+
var v = VERIFIERS[f];
|
|
690
|
+
if (!v) throw _err("webauthn/unsupported-format", "compound element " + i + " uses unsupported attestation statement format '" + f + "'");
|
|
691
|
+
var wantMajor = ATT_STMT_MAJOR[f] === undefined ? 5 : ATT_STMT_MAJOR[f];
|
|
692
|
+
if (sN.majorType !== wantMajor) throw _err("webauthn/bad-att-stmt", "compound element " + i + " 'attStmt' has the wrong CBOR shape for format '" + f + "'");
|
|
693
|
+
// Each element verifies against the OUTER authenticatorData: sec. 8.9 passes the same
|
|
694
|
+
// verification-procedure inputs down, so an element cannot bind a different credential.
|
|
695
|
+
return { fmt: f, index: i, att: { fmt: f, attStmt: sN, authData: att.authData, authDataBytes: att.authDataBytes } };
|
|
696
|
+
});
|
|
697
|
+
// Sequential, not Promise.all: a compound may hold many elements, and each can cost a
|
|
698
|
+
// signature verify plus a full path validation. Fanning them out concurrently would turn one
|
|
699
|
+
// attestation into a burst of crypto work.
|
|
700
|
+
var out = [];
|
|
701
|
+
return elements.reduce(function (p, e) {
|
|
702
|
+
return p.then(function () {
|
|
703
|
+
// The verifier is CALLED inside the promise chain, not evaluated as an argument to
|
|
704
|
+
// Promise.resolve: most arms do their structural checks synchronously, so an argument-
|
|
705
|
+
// position call would let those throws escape the handler below and reach the caller
|
|
706
|
+
// bare -- the same failure reported with the element's context or without it, depending
|
|
707
|
+
// only on whether it happened before or after the first await.
|
|
708
|
+
return Promise.resolve().then(function () { return VERIFIERS[e.fmt](e.att, clientDataHash, opts); })
|
|
709
|
+
.then(function (r) { out.push(r); }, function (err) {
|
|
710
|
+
throw _err("webauthn/compound-element-failed", "compound element " + e.index + " (format '" + e.fmt + "') did not verify", err);
|
|
711
|
+
});
|
|
712
|
+
});
|
|
713
|
+
}, Promise.resolve()).then(function () {
|
|
714
|
+
// sec. 8.9 lists the supported attestation type as "Any" and authorises returning
|
|
715
|
+
// "implementation-specific values representing any combination of outputs". A distinct type
|
|
716
|
+
// rather than a merge: collapsing to the strongest element would let a wrapper upgrade a
|
|
717
|
+
// caller's attestationType check, and collapsing to the weakest would spuriously fail one.
|
|
718
|
+
// The trust path is empty because two elements yield two independent chains and there is no
|
|
719
|
+
// single ordered path -- each element's own path is on its entry in `compound`.
|
|
720
|
+
var res = _result("compound", "Compound", [], att);
|
|
721
|
+
res.compound = out;
|
|
722
|
+
return res;
|
|
723
|
+
});
|
|
724
|
+
},
|
|
725
|
+
|
|
520
726
|
// none (WebAuthn 8.7): the authenticator provides no attestation. attStmt MUST be
|
|
521
727
|
// an empty map; there is no statement to verify, so the result carries no trust
|
|
522
728
|
// path. The credential public key still binds via authenticatorData (AT flag).
|
|
@@ -530,6 +736,71 @@ var VERIFIERS = {
|
|
|
530
736
|
},
|
|
531
737
|
};
|
|
532
738
|
|
|
739
|
+
// A decoded JSON value that is safe to read named members off. JSON.parse yields null, numbers,
|
|
740
|
+
// strings and arrays too, and a member read on any of those would leave this module's typed error
|
|
741
|
+
// contract as a raw TypeError.
|
|
742
|
+
function _isPlainObject(v) { return !!v && typeof v === "object" && !Array.isArray(v); }
|
|
743
|
+
|
|
744
|
+
// WebAuthn 8.5 (via the SafetyNet documentation): the JWS leaf is issued to attest.android.com.
|
|
745
|
+
// Checked on the SAN dNSName entries first -- the name a TLS-style certificate is actually issued
|
|
746
|
+
// to -- falling back to the commonName only when the certificate carries no SAN at all, the way a
|
|
747
|
+
// hostname match has been specified since RFC 6125. Compared case-insensitively (a DNS name is
|
|
748
|
+
// case-insensitive) and exactly: no wildcard, no suffix match, so attest.android.com.evil.test
|
|
749
|
+
// cannot pass.
|
|
750
|
+
function _safetyNetHostnameOk(leaf) {
|
|
751
|
+
var want = "attest.android.com";
|
|
752
|
+
// Decoded through the SAME shared pkix extension decoder every other format here uses, so the
|
|
753
|
+
// general-name parse cannot drift from the rest of the toolkit.
|
|
754
|
+
var san = _decodeExt(leaf, "subjectAltName");
|
|
755
|
+
var entries = san && Array.isArray(san.value) ? san.value : [];
|
|
756
|
+
var dns = entries.filter(function (gn) { return gn && gn.type === "dNSName" && typeof gn.value === "string"; });
|
|
757
|
+
// A SAN carrying dNSName entries is authoritative: the commonName is not consulted at all.
|
|
758
|
+
if (dns.length) return dns.some(function (gn) { return gn.value.toLowerCase() === want; });
|
|
759
|
+
// rdns is a sequence of RDNs, each a set of attribute/value pairs -- hence the nested walk.
|
|
760
|
+
return leaf.subject.rdns.some(function (rdn) {
|
|
761
|
+
return rdn.some(function (atv) {
|
|
762
|
+
return atv.name === "commonName" && typeof atv.value === "string" && atv.value.toLowerCase() === want;
|
|
763
|
+
});
|
|
764
|
+
});
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
// WebAuthn 8.5 (via the SafetyNet documentation): the x5c chain must validate to a Google root the
|
|
768
|
+
// CALLER supplied. Every anchor is tried because a caller may hold several Google roots across a
|
|
769
|
+
// rotation; the first that validates wins, and if none does the attestation is refused. The chain
|
|
770
|
+
// goes through the full path validator rather than a signature-only walk, so an expired, revoked-by-
|
|
771
|
+
// policy, or otherwise non-conforming intermediate cannot slip past on a signature alone.
|
|
772
|
+
function _safetyNetChainTrusted(chain, roots, time) {
|
|
773
|
+
var ordered = chain.slice().reverse(); // path.validate takes anchor-adjacent first
|
|
774
|
+
var when = time === undefined ? new Date() : time;
|
|
775
|
+
var attempts = roots.map(function (root, i) {
|
|
776
|
+
return function () {
|
|
777
|
+
var anchorCert;
|
|
778
|
+
try { anchorCert = Buffer.isBuffer(root) || typeof root === "string" ? x509.parse(root) : root; }
|
|
779
|
+
catch (e) { throw _err("webauthn/bad-input", "opts.safetyNetRoots[" + i + "] is not a decodable certificate", e); }
|
|
780
|
+
if (!anchorCert || !anchorCert.subject || !anchorCert.subjectPublicKeyInfo) {
|
|
781
|
+
throw _err("webauthn/bad-input", "opts.safetyNetRoots[" + i + "] is not a certificate");
|
|
782
|
+
}
|
|
783
|
+
// An x5c chain conventionally carries the root as its last entry. The anchor is supplied
|
|
784
|
+
// separately and is what establishes trust, so drop a trailing self-issued certificate that
|
|
785
|
+
// IS this anchor rather than validating it against itself as a path element.
|
|
786
|
+
var path = ordered.slice();
|
|
787
|
+
if (path.length > 1 && guard.name.dnEqual(path[0].subject, anchorCert.subject) &&
|
|
788
|
+
guard.name.dnEqual(path[0].issuer, path[0].subject)) {
|
|
789
|
+
path = path.slice(1);
|
|
790
|
+
}
|
|
791
|
+
return pathValidate.validate(path, {
|
|
792
|
+
time: when,
|
|
793
|
+
trustAnchor: { name: anchorCert.subject, publicKey: anchorCert.subjectPublicKeyInfo.bytes, algorithm: anchorCert.signatureAlgorithm.oid },
|
|
794
|
+
}).then(function (r) { return !!(r && r.valid); }, function () { return false; });
|
|
795
|
+
};
|
|
796
|
+
});
|
|
797
|
+
return attempts.reduce(function (p, next) {
|
|
798
|
+
return p.then(function (done) { return done ? true : next(); });
|
|
799
|
+
}, Promise.resolve(false)).then(function (trusted) {
|
|
800
|
+
if (!trusted) throw _err("webauthn/safetynet-cert-untrusted", "the android-safetynet x5c chain does not validate to any supplied root (opts.safetyNetRoots)");
|
|
801
|
+
});
|
|
802
|
+
}
|
|
803
|
+
|
|
533
804
|
// `chain` is the x5c order (leaf-first); trustPath is surfaced in pki.path.validate
|
|
534
805
|
// order (anchor-adjacent first, target/leaf last) so the caller passes it straight
|
|
535
806
|
// to the path validator without re-ordering. The input array is not mutated.
|
package/package.json
CHANGED
package/sbom.cdx.json
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
"$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
|
|
3
3
|
"bomFormat": "CycloneDX",
|
|
4
4
|
"specVersion": "1.5",
|
|
5
|
-
"serialNumber": "urn:uuid:
|
|
5
|
+
"serialNumber": "urn:uuid:1f0429a5-9e2b-4cc8-a67f-6357968a0774",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-08-
|
|
8
|
+
"timestamp": "2026-08-09T01:33:39.618Z",
|
|
9
9
|
"lifecycles": [
|
|
10
10
|
{
|
|
11
11
|
"phase": "build"
|
|
@@ -19,14 +19,14 @@
|
|
|
19
19
|
}
|
|
20
20
|
],
|
|
21
21
|
"component": {
|
|
22
|
-
"bom-ref": "@blamejs/pki@0.4.
|
|
22
|
+
"bom-ref": "@blamejs/pki@0.4.9",
|
|
23
23
|
"type": "application",
|
|
24
24
|
"name": "pki",
|
|
25
|
-
"version": "0.4.
|
|
25
|
+
"version": "0.4.9",
|
|
26
26
|
"scope": "required",
|
|
27
27
|
"author": "blamejs contributors",
|
|
28
28
|
"description": "Pure-JavaScript PKI toolkit that owns its stack — X.509, ASN.1/DER, CMS, PQC-first.",
|
|
29
|
-
"purl": "pkg:npm/%40blamejs/pki@0.4.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/pki@0.4.9",
|
|
30
30
|
"properties": [],
|
|
31
31
|
"externalReferences": [
|
|
32
32
|
{
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"components": [],
|
|
55
55
|
"dependencies": [
|
|
56
56
|
{
|
|
57
|
-
"ref": "@blamejs/pki@0.4.
|
|
57
|
+
"ref": "@blamejs/pki@0.4.9",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|