@blamejs/pki 0.2.19 → 0.2.21
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 +30 -0
- package/README.md +2 -2
- package/lib/cbor-det.js +49 -0
- package/lib/lint.js +43 -2
- package/lib/oid.js +1 -1
- package/lib/path-validate.js +34 -0
- package/lib/schema-pkix.js +8 -0
- package/lib/validator-cose.js +4 -17
- package/lib/webauthn.js +18 -23
- package/lib/webcrypto.js +112 -10
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,36 @@ 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.2.21 — 2026-07-15
|
|
8
|
+
|
|
9
|
+
ML-KEM public keys in X.509 certificates and PKCS#8 private keys (RFC 9935 / FIPS 203) become a first-class, fail-closed surface: certification-path validation enforces the keyEncipherment-only key usage, key import validates the private-key CHOICE and rejects an inconsistent key with a typed error, and pki.lint gains the RFC 9935 certificate rows.
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- pki.path.validate enforces the RFC 9935 section 5 key-usage rule for ML-KEM (id-ml-kem-512/768/1024) certificates: a present keyUsage MUST assert keyEncipherment as the only bit. A leaf with digitalSignature, an ML-KEM key with keyCertSign / cRLSign / keyAgreement / dataEncipherment / nonRepudiation, or keyEncipherment set alongside any reserved bit, fails closed with the frozen code path/kem-key-usage -- for the target and every intermediate whose own subject key is ML-KEM. An absent keyUsage places no restriction (RFC 5280 section 4.2.1.3).
|
|
14
|
+
- pki.lint gains an rfc9935 profile: lint/rfc9935/kem-key-usage (the section 5 keyEncipherment-only rule) and lint/rfc9935/kem-key-length (the SPKI encapsulation key must be exactly 800 / 1184 / 1568 octets for the id-ml-kem-512 / 768 / 1024 OID -- the OID is the sole authority for the parameter set, so an OCTET-STRING-wrapped or wrong-set key is flagged). Both run by default and are silent on a non-ML-KEM certificate.
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- pki.webcrypto.subtle.importKey no longer surfaces a raw engine exception when handed a malformed or inconsistent key: a bad SPKI, a bad JWK, or an ML-KEM private key whose seed and expanded halves are inconsistent (FIPS 203 section 7.3) now fails closed with a typed webcrypto/data error. For an ML-KEM PKCS#8, the RFC 9935 section 6 private-key CHOICE is validated by its DER tag and exact size for the algorithm OID before the engine imports it -- the OpenSSL-legacy bare-seed, bare-expanded-key, and concatenated layouts the engine would otherwise accept are rejected, so a non-conformant private key cannot be imported under an ML-KEM name.
|
|
19
|
+
- pki.webcrypto.subtle.importKey now fails closed on two further malformed-input classes: a JWK import whose key data is not an object (null, a primitive, or an array -- including the JSON null an unwrap over non-authenticating ciphertext can yield) returns a typed webcrypto/data error instead of a raw TypeError; and an SPKI or PKCS#8 import requested under a secret-key or key-derivation algorithm name (AES-GCM/CBC/CTR/KW, HMAC, HKDF, PBKDF2) is rejected as webcrypto/not-supported rather than importing a mislabeled key handle.
|
|
20
|
+
|
|
21
|
+
## v0.2.20 — 2026-07-15
|
|
22
|
+
|
|
23
|
+
A WebAuthn attestation object whose attestation statement is not a CBOR map is now rejected with a typed webauthn/bad-attestation-object at parse instead of surfacing an untyped error from a format verifier, and the strict CBOR codec gains pki.cbor.read.mapGet -- a keyed map lookup that asserts the map's major type inside the accessor.
|
|
24
|
+
|
|
25
|
+
### Added
|
|
26
|
+
|
|
27
|
+
- pki.cbor.read.mapGet(node, key) -- the keyed lookup over a decoded CBOR map (RFC 8949 major type 5). A text-string key matches text-string map keys; an integer key (a safe-integer number or a BigInt, as COSE labels are) matches integer map keys; matching never coerces across the two. It returns the value node, or null when the map has no such entry -- decode already enforced key uniqueness, so at most one entry can match. A non-map node throws cbor/unexpected-major, and a key that is neither a text string nor an integer throws a TypeError.
|
|
28
|
+
|
|
29
|
+
### Changed
|
|
30
|
+
|
|
31
|
+
- A WebAuthn attestation object whose attStmt is not a CBOR map is now classified as a malformed attestation object -- webauthn/bad-attestation-object, thrown at parse for every attestation format -- where it previously surfaced as a per-format webauthn/bad-att-stmt or an untyped error depending on the CBOR type carried.
|
|
32
|
+
|
|
33
|
+
### Fixed
|
|
34
|
+
|
|
35
|
+
- pki.webauthn.parseAttestationObject and pki.webauthn.verify no longer throw a raw TypeError when the attestation object's attStmt is a CBOR array: the attestation-statement field walk read the array's children as key/value pairs and dereferenced undefined. The attestation object's attStmt shape is now validated at parse (WebAuthn sec. 6.5.4), and the statement walk reads its pairs through pki.cbor.read.map, which asserts the major type -- malformed hostile bytes are a typed webauthn/* verdict, never an untyped crash.
|
|
36
|
+
|
|
7
37
|
## v0.2.19 — 2026-07-14
|
|
8
38
|
|
|
9
39
|
The RFC 3161 Time-Stamp Protocol surface is complete: pki.tsp.request and pki.tsp.response build and parse the protocol's request and response messages, and pki.tsp.verify verifies a timestamp token end to end -- the CMS signature, the message imprint, the ESSCertID(V2) certificate binding, the critical timeStamping-only extendedKeyUsage, and full validation of the TSA certificate at the token's own genTime.
|
package/README.md
CHANGED
|
@@ -199,9 +199,9 @@ is callable today; nothing below is a stub.
|
|
|
199
199
|
| Namespace | What it does |
|
|
200
200
|
|---|---|
|
|
201
201
|
| `pki.asn1` | Strict, bounded DER codec — `decode` (zero-copy node tree), `encode`, `build.*` canonical-DER value builders, `read.*` typed readers, `TAGS`, OID-content encode/decode |
|
|
202
|
-
| `pki.cbor` | Strict, bounded RFC 8949 deterministic CBOR codec — `decode` (zero-copy node tree) + `read.*` typed leaf readers, fail-closed on every non-canonical shape (indefinite length, non-minimal argument, unsorted / duplicate map keys, non-shortest float, trailing bytes) |
|
|
202
|
+
| `pki.cbor` | Strict, bounded RFC 8949 deterministic CBOR codec — `decode` (zero-copy node tree) + `read.*` typed leaf readers incl. the keyed map lookup `read.mapGet` (text or COSE-label integer key, the map's major type asserted in the accessor), fail-closed on every non-canonical shape (indefinite length, non-minimal argument, unsorted / duplicate map keys, non-shortest float, trailing bytes) |
|
|
203
203
|
| `pki.oid` | Two-way OID ↔ name registry — `name`, `byName`, `register`, `toArcs`/`fromArcs`, `toDER`/`fromDER`; seeded with RFC 5280 + NIST PQC arcs |
|
|
204
|
-
| `pki.webcrypto` | A W3C WebCrypto (`SubtleCrypto`) engine over `node:crypto` — `sign`/`verify`/`encrypt`/`decrypt`/`deriveBits`/`digest`/`generateKey`/`importKey`/`exportKey` across RSA, ECDSA, ECDH, Ed25519/Ed448, AES, HMAC, HKDF, PBKDF2, SHA — **and** post-quantum ML-DSA-44/65/87 and SLH-DSA signatures, plus ML-KEM-512/768/1024 key generation (KEM encapsulation
|
|
204
|
+
| `pki.webcrypto` | A W3C WebCrypto (`SubtleCrypto`) engine over `node:crypto` — `sign`/`verify`/`encrypt`/`decrypt`/`deriveBits`/`digest`/`generateKey`/`importKey`/`exportKey` across RSA, ECDSA, ECDH, Ed25519/Ed448, AES, HMAC, HKDF, PBKDF2, SHA — **and** post-quantum ML-DSA-44/65/87 and SLH-DSA signatures, plus ML-KEM-512/768/1024 key generation and certificate/PKCS#8 import — the RFC 9935 seed / expandedKey / both private-key CHOICE is validated fail-closed, so an OpenSSL-legacy bare-seed or an internally inconsistent key is rejected with a typed error (KEM encapsulation lands with CMS KEM-decrypt). Zero-dependency, OpenSSL-interoperable |
|
|
205
205
|
| `pki.schema` | The schema family — `parse` detects which PKI format DER / PEM encodes and routes to the right parser, `all` enumerates the registered formats, and the engine + per-format members are grouped here |
|
|
206
206
|
| `pki.schema.x509` | Parse DER / PEM certificates into structured, validated fields — `parse`, `pemDecode`, `pemEncode` |
|
|
207
207
|
| `pki.schema.crl` | Parse DER / PEM X.509 CRLs per RFC 5280 §5 — revoked serials with real-`Date` revocation times, named + partly-decoded extensions, fail-closed — `parse`, `pemDecode`, `pemEncode` |
|
package/lib/cbor-det.js
CHANGED
|
@@ -415,6 +415,52 @@ function readMap(node) {
|
|
|
415
415
|
return node.children;
|
|
416
416
|
}
|
|
417
417
|
|
|
418
|
+
/**
|
|
419
|
+
* @primitive pki.cbor.read.mapGet
|
|
420
|
+
* @signature pki.cbor.read.mapGet(node, key) -> valueNode | null
|
|
421
|
+
* @since 0.2.20
|
|
422
|
+
* @status experimental
|
|
423
|
+
* @spec RFC 8949 sec. 3.1 (major type 5)
|
|
424
|
+
*
|
|
425
|
+
* The value node of the map entry whose key equals `key` -- a text string
|
|
426
|
+
* (matched against text-string keys) or an integer (a safe-integer number or a
|
|
427
|
+
* BigInt, matched against integer keys, as COSE labels are) -- or null when the
|
|
428
|
+
* map has no such entry. Key matching never coerces across types: a text key
|
|
429
|
+
* only matches a text-string key node, an integer key only an integer key node.
|
|
430
|
+
* At most one entry can match -- decode already enforced key uniqueness. This is
|
|
431
|
+
* the single keyed-lookup home over a decoded map: a consumer composes it (or
|
|
432
|
+
* `read.map`) rather than walking `children` as pairs, so a lookup can never
|
|
433
|
+
* pair-index a non-map's children (single nodes, whose pair index reads
|
|
434
|
+
* undefined). Throws `cbor/unexpected-major` when `node` is not a map -- a
|
|
435
|
+
* lookup on a non-map is malformed input, never an absent-entry verdict -- and
|
|
436
|
+
* a TypeError for a key that is neither a text string nor an integer.
|
|
437
|
+
*
|
|
438
|
+
* @example
|
|
439
|
+
* pki.cbor.read.mapGet(node, "fmt"); // -> valueNode | null
|
|
440
|
+
* pki.cbor.read.mapGet(node, 3); // a COSE alg label -> valueNode | null
|
|
441
|
+
*/
|
|
442
|
+
function readMapGet(node, key) {
|
|
443
|
+
_expectMajor(node, 5, "read.mapGet");
|
|
444
|
+
var wantText = typeof key === "string";
|
|
445
|
+
if (!wantText) {
|
|
446
|
+
if (typeof key === "number") {
|
|
447
|
+
if (!Number.isSafeInteger(key)) throw new TypeError("read.mapGet: a numeric key must be a safe integer");
|
|
448
|
+
key = BigInt(key);
|
|
449
|
+
} else if (typeof key !== "bigint") {
|
|
450
|
+
throw new TypeError("read.mapGet: key must be a text string or an integer");
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
for (var i = 0; i < node.children.length; i++) {
|
|
454
|
+
var k = node.children[i][0];
|
|
455
|
+
if (wantText) {
|
|
456
|
+
if (k.majorType === 3 && readTextString(k) === key) return node.children[i][1];
|
|
457
|
+
} else if ((k.majorType === 0 || k.majorType === 1) && readInt(k) === key) {
|
|
458
|
+
return node.children[i][1];
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
return null;
|
|
462
|
+
}
|
|
463
|
+
|
|
418
464
|
/**
|
|
419
465
|
* @primitive pki.cbor.read.boolean
|
|
420
466
|
* @signature pki.cbor.read.boolean(node) -> false
|
|
@@ -550,6 +596,8 @@ function readTime(node) {
|
|
|
550
596
|
}
|
|
551
597
|
var ns = Number(secs);
|
|
552
598
|
var d = new Date(ns < 0 ? -constants.TIME.seconds(-ns) : constants.TIME.seconds(ns));
|
|
599
|
+
// Coverage residual -- the epoch-window bound above already rejects any value the
|
|
600
|
+
// Date constructor cannot represent, so this backstop is defense-in-depth.
|
|
553
601
|
if (isNaN(d.getTime())) throw new CborError("cbor/bad-time", "epoch time out of range");
|
|
554
602
|
return d;
|
|
555
603
|
}
|
|
@@ -586,6 +634,7 @@ module.exports = {
|
|
|
586
634
|
textString: readTextString,
|
|
587
635
|
array: readArray,
|
|
588
636
|
map: readMap,
|
|
637
|
+
mapGet: readMapGet,
|
|
589
638
|
boolean: readBoolean,
|
|
590
639
|
nullValue: readNull,
|
|
591
640
|
undefinedValue: readUndefined,
|
package/lib/lint.js
CHANGED
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
|
|
26
26
|
var frameworkError = require("./framework-error");
|
|
27
27
|
var asn1 = require("./asn1-der");
|
|
28
|
+
var guard = require("./guard-all");
|
|
28
29
|
var oid = require("./oid");
|
|
29
30
|
var x509 = require("./schema-x509");
|
|
30
31
|
var pkix = require("./schema-pkix");
|
|
@@ -458,11 +459,51 @@ var CABF_TLS_RULES = [
|
|
|
458
459
|
},
|
|
459
460
|
];
|
|
460
461
|
|
|
462
|
+
// RFC 9935 -- ML-KEM public keys in X.509 certificates. The OID is the sole authority for the
|
|
463
|
+
// parameter set; the SPKI BIT STRING is the raw ek, exactly 384k+32 octets for that OID.
|
|
464
|
+
var ML_KEM_EK_LEN = {};
|
|
465
|
+
["id-ml-kem-512", "id-ml-kem-768", "id-ml-kem-1024"].forEach(function (n, i) { ML_KEM_EK_LEN[n] = [800, 1184, 1568][i]; });
|
|
466
|
+
function _isMlKem(cert) {
|
|
467
|
+
var spki = cert.subjectPublicKeyInfo;
|
|
468
|
+
return !!(spki && spki.algorithm && ML_KEM_EK_LEN[spki.algorithm.name] !== undefined);
|
|
469
|
+
}
|
|
470
|
+
var RFC9935_RULES = [
|
|
471
|
+
{
|
|
472
|
+
id: "lint/rfc9935/kem-key-usage", severity: "error", source: "rfc9935", citation: "RFC 9935 5",
|
|
473
|
+
message: "an ML-KEM certificate's keyUsage, if present, must assert keyEncipherment as the only key usage set",
|
|
474
|
+
appliesTo: _isMlKem,
|
|
475
|
+
check: function (cert, ctx) {
|
|
476
|
+
var ku = ctx.decode("keyUsage");
|
|
477
|
+
if (!ku || !ku.value) return null; // absent keyUsage is unconstrained (RFC 5280 4.2.1.3)
|
|
478
|
+
var v = ku.value;
|
|
479
|
+
var others = v.digitalSignature || v.nonRepudiation || v.dataEncipherment || v.keyAgreement ||
|
|
480
|
+
v.keyCertSign || v.cRLSign || v.encipherOnly || v.decipherOnly || v.reservedBitsSet === true;
|
|
481
|
+
return (v.keyEncipherment && !others) ? null : true;
|
|
482
|
+
},
|
|
483
|
+
},
|
|
484
|
+
{
|
|
485
|
+
id: "lint/rfc9935/kem-key-length", severity: "error", source: "rfc9935", citation: "RFC 9935 4 / FIPS 203",
|
|
486
|
+
message: "an ML-KEM subjectPublicKey must be the raw encapsulation key at its exact FIPS 203 size for the OID, in a byte-aligned BIT STRING",
|
|
487
|
+
appliesTo: _isMlKem,
|
|
488
|
+
check: function (cert) {
|
|
489
|
+
var spki = cert.subjectPublicKeyInfo;
|
|
490
|
+
var want = ML_KEM_EK_LEN[spki.algorithm.name];
|
|
491
|
+
var pub = spki.publicKey; // a parsed SPKI always surfaces the BIT STRING as { bytes, unusedBits }
|
|
492
|
+
// The ek is a raw byte string carried in the BIT STRING: it must be the exact size AND
|
|
493
|
+
// byte-aligned. A right-length but non-octet-aligned BIT STRING (unusedBits != 0) is not the
|
|
494
|
+
// raw ek (RFC 9935 sec. 4); route the alignment test through the shared guard primitive.
|
|
495
|
+
return (guard.crypto.isOctetAligned(pub) && pub.bytes.length === want) ? null
|
|
496
|
+
: { context: { expected: want, got: pub.bytes.length, unusedBits: pub.unusedBits } };
|
|
497
|
+
},
|
|
498
|
+
},
|
|
499
|
+
];
|
|
500
|
+
|
|
461
501
|
var PROFILES = {
|
|
462
502
|
"rfc5280": RFC5280_RULES,
|
|
503
|
+
"rfc9935": RFC9935_RULES,
|
|
463
504
|
"cabf-tls": CABF_TLS_RULES,
|
|
464
505
|
};
|
|
465
|
-
var ALL_RULES = RFC5280_RULES.concat(CABF_TLS_RULES);
|
|
506
|
+
var ALL_RULES = RFC5280_RULES.concat(RFC9935_RULES).concat(CABF_TLS_RULES);
|
|
466
507
|
|
|
467
508
|
function _selectRules(profile) {
|
|
468
509
|
if (profile == null || profile === "all" || profile === "default") return ALL_RULES;
|
|
@@ -557,7 +598,7 @@ function rules(profile) {
|
|
|
557
598
|
*
|
|
558
599
|
* List the known lint-profile names.
|
|
559
600
|
* @example
|
|
560
|
-
* pki.lint.profiles(); // -> ["rfc5280", "cabf-tls"]
|
|
601
|
+
* pki.lint.profiles(); // -> ["rfc5280", "rfc9935", "cabf-tls"]
|
|
561
602
|
*/
|
|
562
603
|
function profiles() { return Object.keys(PROFILES); }
|
|
563
604
|
|
package/lib/oid.js
CHANGED
|
@@ -553,7 +553,7 @@ var _PARAMS_ABSENT = new Set();
|
|
|
553
553
|
"id-hash-slh-dsa-shake-192s-with-shake256", "id-hash-slh-dsa-shake-192f-with-shake256",
|
|
554
554
|
"id-hash-slh-dsa-shake-256s-with-shake256", "id-hash-slh-dsa-shake-256f-with-shake256",
|
|
555
555
|
"Ed25519", "Ed448", "X25519", "X448",
|
|
556
|
-
// FIPS 203 ML-KEM (RFC 9936 sec. 3: PARAMS ARE absent).
|
|
556
|
+
// FIPS 203 ML-KEM (RFC 9935 sec. 3 certificates + RFC 9936 sec. 3 CMS: PARAMS ARE absent).
|
|
557
557
|
"id-ml-kem-512", "id-ml-kem-768", "id-ml-kem-1024",
|
|
558
558
|
// HKDF key-derivation identifiers (RFC 8619 sec. 2: when any of these appear
|
|
559
559
|
// within AlgorithmIdentifier, the parameters component SHALL be absent).
|
package/lib/path-validate.js
CHANGED
|
@@ -331,6 +331,32 @@ function compositeKeyUsageCheck(cert) {
|
|
|
331
331
|
return { ok: true };
|
|
332
332
|
}
|
|
333
333
|
|
|
334
|
+
// The ML-KEM SubjectPublicKeyInfo OIDs (RFC 9935 / FIPS 203). A certificate carrying one of
|
|
335
|
+
// these keys is a KEM key-establishment certificate: it can neither sign nor agree.
|
|
336
|
+
var ML_KEM_OIDS = {};
|
|
337
|
+
["id-ml-kem-512", "id-ml-kem-768", "id-ml-kem-1024"].forEach(function (n) { ML_KEM_OIDS[oid.byName(n)] = true; });
|
|
338
|
+
|
|
339
|
+
// RFC 9935 sec. 5: a certificate whose SubjectPublicKeyInfo carries an id-ml-kem-* OID, IF it
|
|
340
|
+
// has a keyUsage extension, MUST assert keyEncipherment as the ONLY key usage set -- an ML-KEM
|
|
341
|
+
// key is a key-establishment-only key (it cannot sign or agree, so no other bit is legitimate,
|
|
342
|
+
// and an unnamed/reserved bit set alongside keyEncipherment is equally forbidden). The caller
|
|
343
|
+
// invokes this only for an ML-KEM-keyed certificate; an absent keyUsage places no restriction
|
|
344
|
+
// (RFC 5280 sec. 4.2.1.3). This also makes an ML-KEM "CA" (keyCertSign) an explicit reject.
|
|
345
|
+
function kemKeyUsageCheck(cert) {
|
|
346
|
+
var ku;
|
|
347
|
+
try { ku = decodeExt(cert, OID.keyUsage); }
|
|
348
|
+
catch (e) { return { ok: false, code: "path/kem-key-usage", error: e }; }
|
|
349
|
+
if (!ku) return { ok: true };
|
|
350
|
+
var v = ku.value;
|
|
351
|
+
var others = v.digitalSignature || v.nonRepudiation || v.dataEncipherment || v.keyAgreement ||
|
|
352
|
+
v.keyCertSign || v.cRLSign || v.encipherOnly || v.decipherOnly || (v.reservedBitsSet === true);
|
|
353
|
+
if (!v.keyEncipherment || others) {
|
|
354
|
+
return { ok: false, code: "path/kem-key-usage",
|
|
355
|
+
error: E("path/kem-key-usage", "an ML-KEM key's keyUsage must assert keyEncipherment as the only bit (RFC 9935 sec. 5)") };
|
|
356
|
+
}
|
|
357
|
+
return { ok: true };
|
|
358
|
+
}
|
|
359
|
+
|
|
334
360
|
// Import a descriptor's verification key, validating an EdDSA point FIRST: node/OpenSSL import a
|
|
335
361
|
// low-order (e.g. identity or all-zeroes) Ed25519/Ed448 SPKI without complaint and such a key
|
|
336
362
|
// verifies a forged signature. This is the ONE seam both the certificate path and the revocation
|
|
@@ -1215,6 +1241,14 @@ async function validate(path, opts) {
|
|
|
1215
1241
|
if (!cku.ok) failed = true;
|
|
1216
1242
|
}
|
|
1217
1243
|
|
|
1244
|
+
// RFC 9935 sec. 5: an ML-KEM-keyed certificate's keyUsage must be keyEncipherment-only.
|
|
1245
|
+
// Runs for the target AND every intermediate whose own subject key is ML-KEM.
|
|
1246
|
+
if (ML_KEM_OIDS[cert.subjectPublicKeyInfo.algorithm.oid]) {
|
|
1247
|
+
var kku = kemKeyUsageCheck(cert);
|
|
1248
|
+
checks.push({ name: "kemKeyUsage", ok: kku.ok, code: kku.ok ? undefined : kku.code });
|
|
1249
|
+
if (!kku.ok) failed = true;
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1218
1252
|
// 6.1.3(a)(2) validity window.
|
|
1219
1253
|
var t = opts.time;
|
|
1220
1254
|
var vOk = true, vCode;
|
package/lib/schema-pkix.js
CHANGED
|
@@ -660,6 +660,14 @@ function certExtensionDecoders(ns) {
|
|
|
660
660
|
var byte = bit >> 3, mask = 0x80 >> (bit & 7);
|
|
661
661
|
out[nm] = byte < bs.bytes.length && (bs.bytes[byte] & mask) !== 0;
|
|
662
662
|
});
|
|
663
|
+
// Any bit beyond the 9 named positions is a reserved/unknown usage. RFC 5280 tolerates it,
|
|
664
|
+
// but a "MUST be the ONLY bit" rule (e.g. RFC 9935 sec. 5 ML-KEM keyEncipherment-only) needs
|
|
665
|
+
// to see it -- so surface it rather than silently drop bits >= 9.
|
|
666
|
+
var reserved = false;
|
|
667
|
+
for (var bit2 = KU_BITS.length; bit2 < bs.bytes.length * 8 && !reserved; bit2++) {
|
|
668
|
+
if ((bs.bytes[bit2 >> 3] & (0x80 >> (bit2 & 7))) !== 0) reserved = true;
|
|
669
|
+
}
|
|
670
|
+
out.reservedBitsSet = reserved;
|
|
663
671
|
return out;
|
|
664
672
|
}
|
|
665
673
|
|
package/lib/validator-cose.js
CHANGED
|
@@ -64,19 +64,6 @@ var ALG_PROFILE = {
|
|
|
64
64
|
"-257": { kty: 3 }, "-258": { kty: 3 }, "-259": { kty: 3 }, "-37": { kty: 3 }, "-65535": { kty: 3 },
|
|
65
65
|
};
|
|
66
66
|
|
|
67
|
-
// The value node of an integer-labelled COSE_Key parameter (labels are ints), or null.
|
|
68
|
-
function _mapGetInt(node, key) {
|
|
69
|
-
// Coverage residual -- credentialKey validates node is a majorType-5 map (before any
|
|
70
|
-
// _mapGetInt call), and cbor-det always gives a map a (truthy) children array, so this
|
|
71
|
-
// guard cannot fire.
|
|
72
|
-
if (!node || node.majorType !== 5 || !node.children) return null;
|
|
73
|
-
for (var i = 0; i < node.children.length; i++) {
|
|
74
|
-
var k = node.children[i][0];
|
|
75
|
-
if ((k.majorType === 0 || k.majorType === 1) && cbor.read.int(k) === BigInt(key)) return node.children[i][1];
|
|
76
|
-
}
|
|
77
|
-
return null;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
67
|
// credentialKey(node, E, code) -> the decoded + validated credential public key
|
|
81
68
|
// { kty, alg, crv?, x?, y?, n?, e? }, or throws new E(code, ...). The complete COSE_Key
|
|
82
69
|
// conformance gate for a WebAuthn credential key; a format module MUST route a credential
|
|
@@ -90,9 +77,9 @@ function credentialKey(node, E, code) {
|
|
|
90
77
|
if (!node || node.majorType !== 5) throw bad("a COSE_Key must be a CBOR map (RFC 9052 sec. 7)");
|
|
91
78
|
// Every parameter read maps a wrong-type cbor/* fault to the caller's domain -- a
|
|
92
79
|
// wrong-typed COSE label (x as an integer, kty as a string) is bad input, not a leak.
|
|
93
|
-
function ib(label) { var n =
|
|
94
|
-
function ii(label) { var n =
|
|
95
|
-
var ktyN =
|
|
80
|
+
function ib(label) { var n = cbor.read.mapGet(node, label); if (!n) return null; try { return cbor.read.byteString(n); } catch (e) { throw bad("COSE_Key parameter " + label + " must be a byte string", e); } }
|
|
81
|
+
function ii(label) { var n = cbor.read.mapGet(node, label); if (!n) return null; try { return cbor.read.int(n); } catch (e) { throw bad("COSE_Key parameter " + label + " must be an integer", e); } }
|
|
82
|
+
var ktyN = cbor.read.mapGet(node, 1), algN = cbor.read.mapGet(node, 3);
|
|
96
83
|
if (!ktyN) throw bad("a COSE_Key is missing the kty (label 1) parameter");
|
|
97
84
|
if (!algN) throw bad("a COSE_Key is missing the alg (label 3) parameter");
|
|
98
85
|
var kty, algv;
|
|
@@ -103,7 +90,7 @@ function credentialKey(node, E, code) {
|
|
|
103
90
|
key.crv = ii(-1) != null ? Number(ii(-1)) : null;
|
|
104
91
|
// WebAuthn EC2 credential keys MUST use the uncompressed point form: y (-3) is the full
|
|
105
92
|
// y-coordinate byte string, never a CBOR bool sign bit (WebAuthn sec. alg identifier).
|
|
106
|
-
var yNode =
|
|
93
|
+
var yNode = cbor.read.mapGet(node, -3);
|
|
107
94
|
if (yNode && yNode.majorType === 7) throw bad("an EC2 credential key must use the uncompressed point form (a compressed y sign-bit is not permitted for WebAuthn)");
|
|
108
95
|
key.x = ib(-2); key.y = ib(-3);
|
|
109
96
|
if (key.crv == null || !key.x || !key.y) throw bad("an EC2 COSE_Key must carry crv (-1), x (-2), and y (-3)");
|
package/lib/webauthn.js
CHANGED
|
@@ -75,20 +75,6 @@ function _coseAlgHash(alg, E) {
|
|
|
75
75
|
return h;
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
-
// ---- CBOR map access over the strict pki.cbor node tree ----------------------
|
|
79
|
-
|
|
80
|
-
// A decoded CBOR map is a node whose `children` is an array of [keyNode, valNode].
|
|
81
|
-
// Look a text-keyed entry up, or null. The strict codec already rejected a
|
|
82
|
-
// duplicate map key, so at most one match exists.
|
|
83
|
-
function _mapGet(node, key) {
|
|
84
|
-
if (!node || node.majorType !== 5 || !node.children) return null;
|
|
85
|
-
for (var i = 0; i < node.children.length; i++) {
|
|
86
|
-
var k = node.children[i][0];
|
|
87
|
-
if (k.majorType === 3 && cbor.read.textString(k) === key) return node.children[i][1];
|
|
88
|
-
}
|
|
89
|
-
return null;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
78
|
// ---- authenticatorData bounded reader (WebAuthn sec. 6.1) --------------------
|
|
93
79
|
|
|
94
80
|
// authData = rpIdHash[32] || flags[1] || signCount[4 BE] || (AT? attestedCredentialData) || (ED? extensions CBOR).
|
|
@@ -333,11 +319,16 @@ function parseAttestationObject(bytes) {
|
|
|
333
319
|
var root;
|
|
334
320
|
try { root = cbor.decode(bytes); } catch (e) { throw _err("webauthn/bad-attestation-object", "the attestation object is not well-formed CBOR", e); }
|
|
335
321
|
if (root.majorType !== 5) throw _err("webauthn/bad-attestation-object", "the attestation object must be a CBOR map { fmt, attStmt, authData }");
|
|
336
|
-
var fmtN =
|
|
322
|
+
var fmtN = cbor.read.mapGet(root, "fmt"), attStmtN = cbor.read.mapGet(root, "attStmt"), authDataN = cbor.read.mapGet(root, "authData");
|
|
337
323
|
// The attestation object is EXACTLY { fmt, attStmt, authData } -- no more, no fewer
|
|
338
324
|
// (WebAuthn 6.5.4); an extra top-level key is a non-canonical envelope, rejected.
|
|
339
325
|
if (root.children.length !== 3 || !fmtN || !attStmtN || !authDataN) throw _err("webauthn/bad-attestation-object", "the attestation object must be exactly { fmt, attStmt, authData }");
|
|
340
326
|
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
|
+
// a non-map attStmt is a malformed attestation OBJECT, rejected here rather than deferred
|
|
329
|
+
// to a format verifier -- a non-map value (e.g. a CBOR array, whose children are single
|
|
330
|
+
// nodes, not { key, value } pairs) must never reach the per-field statement walk.
|
|
331
|
+
if (attStmtN.majorType !== 5) throw _err("webauthn/bad-attestation-object", "attestation object 'attStmt' must be a CBOR map");
|
|
341
332
|
if (authDataN.majorType !== 2) throw _err("webauthn/bad-attestation-object", "attestation object 'authData' must be a byte string");
|
|
342
333
|
var authDataBytes = cbor.read.byteString(authDataN);
|
|
343
334
|
return {
|
|
@@ -351,7 +342,7 @@ function parseAttestationObject(bytes) {
|
|
|
351
342
|
// ---- attestation-format verifiers -------------------------------------------
|
|
352
343
|
|
|
353
344
|
function _reqAttr(map, key) {
|
|
354
|
-
var n =
|
|
345
|
+
var n = cbor.read.mapGet(map, key);
|
|
355
346
|
if (!n) throw _err("webauthn/bad-att-stmt", "the attestation statement is missing the '" + key + "' field");
|
|
356
347
|
return n;
|
|
357
348
|
}
|
|
@@ -370,7 +361,11 @@ function _sigOf(attStmt) { return _attRead(attStmt, "sig", cbor.read.byteString,
|
|
|
370
361
|
// non-canonical statement, rejected before any field is trusted (WebAuthn sec. 8.*).
|
|
371
362
|
function _requireAttShape(attStmt, allowed, required) {
|
|
372
363
|
var have = {};
|
|
373
|
-
|
|
364
|
+
// attStmt is the attestation-statement map (parseAttestationObject established it is a CBOR
|
|
365
|
+
// map). cbor.read.map asserts the major type and yields the { key, value } pairs -- never a
|
|
366
|
+
// raw .children walk, whose entries are single nodes (not pairs) for a non-map, so a pair
|
|
367
|
+
// index would read undefined and throw a raw TypeError instead of failing closed.
|
|
368
|
+
cbor.read.map(attStmt).forEach(function (kv) {
|
|
374
369
|
// attStmt keys are text strings; a non-text key is a malformed statement,
|
|
375
370
|
// rejected (not silently skipped, which would evade the unexpected-field check).
|
|
376
371
|
if (kv[0].majorType !== 3) throw _err("webauthn/bad-att-stmt", "the attestation statement has a non-text-string field key");
|
|
@@ -380,7 +375,7 @@ function _requireAttShape(attStmt, allowed, required) {
|
|
|
380
375
|
required.forEach(function (k) { if (!have[k]) throw _err("webauthn/bad-att-stmt", "the attestation statement is missing the '" + k + "' field"); });
|
|
381
376
|
}
|
|
382
377
|
function _readX5c(attStmt) {
|
|
383
|
-
var x5cN =
|
|
378
|
+
var x5cN = cbor.read.mapGet(attStmt, "x5c");
|
|
384
379
|
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");
|
|
385
380
|
return x5cN.children.map(function (c) {
|
|
386
381
|
var der;
|
|
@@ -401,7 +396,7 @@ function _checkAaguidExt(cert, aaguid) { validator.attcert.aaguidExt(cert, aagui
|
|
|
401
396
|
var VERIFIERS = {
|
|
402
397
|
// packed (WebAuthn 8.2): the x5c arm (Basic/AttCA) or self-attestation.
|
|
403
398
|
packed: function (att, clientDataHash) {
|
|
404
|
-
var isX5c = !!
|
|
399
|
+
var isX5c = !!cbor.read.mapGet(att.attStmt, "x5c");
|
|
405
400
|
_requireAttShape(att.attStmt, isX5c ? ["alg", "sig", "x5c"] : ["alg", "sig"], isX5c ? ["alg", "sig", "x5c"] : ["alg", "sig"]);
|
|
406
401
|
var alg = _algOf(att.attStmt), sig = _sigOf(att.attStmt);
|
|
407
402
|
var message = Buffer.concat([att.authDataBytes, clientDataHash]);
|
|
@@ -483,7 +478,7 @@ var VERIFIERS = {
|
|
|
483
478
|
// bind pubArea to the credential key, and verify sig over certInfo with the AIK.
|
|
484
479
|
tpm: function (att, clientDataHash) {
|
|
485
480
|
_requireAttShape(att.attStmt, ["ver", "alg", "sig", "certInfo", "pubArea", "x5c"], ["ver", "alg", "sig", "certInfo", "pubArea", "x5c"]);
|
|
486
|
-
var verN =
|
|
481
|
+
var verN = cbor.read.mapGet(att.attStmt, "ver");
|
|
487
482
|
if (!verN || verN.majorType !== 3 || cbor.read.textString(verN) !== "2.0") throw _err("webauthn/bad-att-stmt", "tpm attestation 'ver' MUST be \"2.0\" (WebAuthn 8.3)");
|
|
488
483
|
var alg = _algOf(att.attStmt), sig = _sigOf(att.attStmt);
|
|
489
484
|
var pubAreaBytes = _attRead(att.attStmt, "pubArea", cbor.read.byteString, "a byte string");
|
|
@@ -526,9 +521,9 @@ var VERIFIERS = {
|
|
|
526
521
|
// an empty map; there is no statement to verify, so the result carries no trust
|
|
527
522
|
// path. The credential public key still binds via authenticatorData (AT flag).
|
|
528
523
|
none: function (att) {
|
|
529
|
-
//
|
|
530
|
-
//
|
|
531
|
-
if (
|
|
524
|
+
// parseAttestationObject has already established attStmt is a CBOR map; the none format
|
|
525
|
+
// additionally requires it be EMPTY -- there is no statement to verify (WebAuthn 8.7).
|
|
526
|
+
if (att.attStmt.children.length !== 0) {
|
|
532
527
|
throw _err("webauthn/bad-att-stmt", "the none attestation statement MUST be an empty map (WebAuthn 8.7)");
|
|
533
528
|
}
|
|
534
529
|
return Promise.resolve(_result("none", "None", [], att));
|
package/lib/webcrypto.js
CHANGED
|
@@ -22,10 +22,11 @@
|
|
|
22
22
|
* still runs on -- RSASSA-PKCS1-v1_5, RSA-PSS, RSA-OAEP, ECDSA, ECDH,
|
|
23
23
|
* Ed25519 / Ed448, AES-GCM / CBC / KW, HMAC, HKDF, PBKDF2, and the SHA
|
|
24
24
|
* family (including legacy SHA-1 for old certificates and signatures).
|
|
25
|
-
* FIPS 203 ML-KEM key generation
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
25
|
+
* FIPS 203 ML-KEM key generation, encoding, and certificate/PKCS#8
|
|
26
|
+
* import are available; KEM encapsulation/decapsulation is deferred to
|
|
27
|
+
* the CMS KEM-decrypt surface by decision (the Node primitive exists).
|
|
28
|
+
* Because it is OpenSSL-backed, every key and signature it emits is
|
|
29
|
+
* interoperable with OpenSSL, NSS, and other PKI implementations.
|
|
29
30
|
*
|
|
30
31
|
* @card
|
|
31
32
|
* A zero-dep, PQC-first W3C WebCrypto (`SubtleCrypto`) engine over
|
|
@@ -37,6 +38,8 @@ var nodeCrypto = require("node:crypto");
|
|
|
37
38
|
var frameworkError = require("./framework-error");
|
|
38
39
|
var guard = require("./guard-all");
|
|
39
40
|
var constants = require("./constants");
|
|
41
|
+
var asn1 = require("./asn1-der");
|
|
42
|
+
var oid = require("./oid");
|
|
40
43
|
|
|
41
44
|
// Single-owner error class -- co-located with its module (framework-error
|
|
42
45
|
// stays the cross-module home; this is webcrypto-private). withCause: a
|
|
@@ -127,8 +130,9 @@ var ML_KEM_NODE = { "ML-KEM-512": "ml-kem-512", "ML-KEM-768": "ml-kem-768", "ML-
|
|
|
127
130
|
|
|
128
131
|
// FIPS 205 SLH-DSA -- stateless hash-based signatures. All twelve
|
|
129
132
|
// parameter sets Node exposes; signing is one-shot (null algorithm), the
|
|
130
|
-
// same shape as ML-DSA / EdDSA. KEM
|
|
131
|
-
//
|
|
133
|
+
// same shape as ML-DSA / EdDSA. ML-KEM here is key-generation / encoding /
|
|
134
|
+
// import; encapsulation/decapsulation (which Node now exposes) is deferred
|
|
135
|
+
// to the CMS KEM-decrypt surface, not blocked by the engine.
|
|
132
136
|
var SLH_DSA_NODE = {};
|
|
133
137
|
["sha2-128s", "sha2-128f", "sha2-192s", "sha2-192f", "sha2-256s", "sha2-256f",
|
|
134
138
|
"shake-128s", "shake-128f", "shake-192s", "shake-192f", "shake-256s", "shake-256f"
|
|
@@ -144,6 +148,12 @@ var SIGN_VERIFY_NAMES = {};
|
|
|
144
148
|
.forEach(function (n) { SIGN_VERIFY_NAMES[n] = true; });
|
|
145
149
|
var ENCRYPT_DECRYPT_NAMES = { "RSA-OAEP": true, "AES-GCM": true, "AES-CBC": true, "AES-CTR": true };
|
|
146
150
|
var DERIVE_NAMES = { "ECDH": true, "X25519": true, "X448": true, "HKDF": true, "PBKDF2": true };
|
|
151
|
+
// The secret-key / KDF algorithms whose key material is raw octets (imported via "raw" or a
|
|
152
|
+
// JWK "oct"), NEVER an SPKI / PKCS#8 asymmetric-key structure. importKey("spki"|"pkcs8", ...)
|
|
153
|
+
// under one of these names is unsupported (W3C: NotSupportedError) -- and, without this gate, it
|
|
154
|
+
// would mint a mislabeled CryptoKey wrapping an asymmetric handle AND dodge the algorithm-keyed
|
|
155
|
+
// pkcs8 pre-validation (e.g. the RFC 9935 ML-KEM CHOICE guard).
|
|
156
|
+
var SECRET_KEY_NAMES = { "AES-GCM": true, "AES-CBC": true, "AES-CTR": true, "AES-KW": true, "HMAC": true, "HKDF": true, "PBKDF2": true };
|
|
147
157
|
|
|
148
158
|
// ---- CryptoKey -------------------------------------------------------
|
|
149
159
|
|
|
@@ -654,14 +664,97 @@ function _assertAesImportLen(name, byteLen) {
|
|
|
654
664
|
}
|
|
655
665
|
}
|
|
656
666
|
|
|
667
|
+
// Run a node:crypto key-import call, mapping a raw engine error (a malformed DER, an
|
|
668
|
+
// inconsistent key) to a typed webcrypto/data DataError -- importKey over hostile bytes
|
|
669
|
+
// must never leak a bare OpenSSL/Node exception through the public API.
|
|
670
|
+
function _nodeKey(fn, who) {
|
|
671
|
+
try { return fn(); }
|
|
672
|
+
catch (e) {
|
|
673
|
+
// Coverage residual -- every current thunk calls node:crypto (which throws a node error),
|
|
674
|
+
// never a WebCryptoError, so this already-typed passthrough is defensive depth for a future
|
|
675
|
+
// thunk that pre-validates and throws typed; it prevents double-wrapping a typed error.
|
|
676
|
+
if (e instanceof WebCryptoError) throw e;
|
|
677
|
+
throw new WebCryptoError("webcrypto/data", who + ": the key material is malformed or internally inconsistent", e);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// RFC 9935 sec. 6 ML-KEM-*-PrivateKey CHOICE: the inner sizes, keyed by the OID -- the OID is
|
|
682
|
+
// the SOLE authority for the parameter set (never a length heuristic). ek = 384k+32, dk = the
|
|
683
|
+
// FIPS 203 decapsulation key length.
|
|
684
|
+
var ML_KEM_INNER = {};
|
|
685
|
+
ML_KEM_INNER[oid.byName("id-ml-kem-512")] = { ek: 800, dk: 1632 };
|
|
686
|
+
ML_KEM_INNER[oid.byName("id-ml-kem-768")] = { ek: 1184, dk: 2400 };
|
|
687
|
+
ML_KEM_INNER[oid.byName("id-ml-kem-1024")] = { ek: 1568, dk: 3168 };
|
|
688
|
+
|
|
689
|
+
function _isOctet(node, size) {
|
|
690
|
+
return node && node.tagClass === "universal" && node.tagNumber === asn1.TAGS.OCTET_STRING &&
|
|
691
|
+
!node.constructed && node.content && node.content.length === size;
|
|
692
|
+
}
|
|
693
|
+
// Validate the RFC 9935 sec. 6 inner CHOICE structurally, dispatching on the DER tag (0x80 seed
|
|
694
|
+
// [0] / 0x04 expandedKey / 0x30 both) -- NEVER on the length of the enclosing OCTET STRING. A
|
|
695
|
+
// bare seed, a bare dk, an oqskeypair dk||ek, a constructed [0], or a wrong-size arm is rejected
|
|
696
|
+
// fail-closed (guards never guess a plausible layout). The seed/expandedKey CRYPTOGRAPHIC
|
|
697
|
+
// consistency (sec. 8 / FIPS 203 sec. 7.3) is enforced by the engine on import (a mismatch is a
|
|
698
|
+
// raw node error _nodeKey maps to webcrypto/data).
|
|
699
|
+
function _validateMlKemInner(innerBytes, sizes) {
|
|
700
|
+
var node;
|
|
701
|
+
try { node = asn1.decode(innerBytes); }
|
|
702
|
+
catch (e) { throw new WebCryptoError("webcrypto/data", "an ML-KEM private key must be the RFC 9935 sec. 6 seed/expandedKey/both CHOICE, not a bare key", e); }
|
|
703
|
+
if (node.tagClass === "context" && node.tagNumber === 0 && !node.constructed) {
|
|
704
|
+
if (!node.content || node.content.length !== 64) throw new WebCryptoError("webcrypto/data", "an ML-KEM seed must be exactly 64 octets (RFC 9935 sec. 6)");
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
if (_isOctet(node, sizes.dk)) return;
|
|
708
|
+
if (node.tagClass === "universal" && node.tagNumber === asn1.TAGS.OCTET_STRING && !node.constructed) {
|
|
709
|
+
throw new WebCryptoError("webcrypto/data", "an ML-KEM expandedKey must be exactly " + sizes.dk + " octets for this parameter set (RFC 9935 sec. 6)");
|
|
710
|
+
}
|
|
711
|
+
if (node.tagClass === "universal" && node.tagNumber === asn1.TAGS.SEQUENCE) {
|
|
712
|
+
// Coverage residual -- a decoded universal SEQUENCE always carries a children array, so the
|
|
713
|
+
// `|| []` fallback is defensive (asn1.decode never yields a childless constructed node).
|
|
714
|
+
var kids = node.children || [];
|
|
715
|
+
if (kids.length !== 2 || !_isOctet(kids[0], 64) || !_isOctet(kids[1], sizes.dk)) {
|
|
716
|
+
throw new WebCryptoError("webcrypto/data", "an ML-KEM both-arm must be SEQUENCE { seed OCTET STRING(64), expandedKey OCTET STRING(" + sizes.dk + ") } (RFC 9935 sec. 6)");
|
|
717
|
+
}
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
throw new WebCryptoError("webcrypto/data", "an ML-KEM private key must be the seed, expandedKey, or both CHOICE (RFC 9935 sec. 6)");
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
// PKCS#8 inner-key pre-validation, keyed by the privateKeyAlgorithm OID (a registry row, not a
|
|
724
|
+
// switch). The parse surfaces the inner key as opaque octets by design; the fail-closed
|
|
725
|
+
// per-algorithm structural check lives here, at the one import boundary. Only ML-KEM needs it
|
|
726
|
+
// today: the engine ACCEPTS the OpenSSL-legacy bare-seed layout RFC 9935 sec. 6 forbids.
|
|
727
|
+
var PKCS8_INNER_VALIDATORS = {};
|
|
728
|
+
Object.keys(ML_KEM_INNER).forEach(function (o) { PKCS8_INNER_VALIDATORS[o] = function (inner) { _validateMlKemInner(inner, ML_KEM_INNER[o]); }; });
|
|
729
|
+
|
|
730
|
+
function _preValidatePkcs8(p8, name) {
|
|
731
|
+
if (!ML_KEM_NODE[name]) return; // only the ML-KEM CHOICE carries the bare-seed hazard
|
|
732
|
+
var algOid, innerBytes;
|
|
733
|
+
try {
|
|
734
|
+
var root = asn1.decode(p8);
|
|
735
|
+
algOid = asn1.read.oid(root.children[1].children[0]);
|
|
736
|
+
innerBytes = asn1.read.octetString(root.children[2]);
|
|
737
|
+
} catch (e) { throw new WebCryptoError("webcrypto/data", "importKey pkcs8: the ML-KEM PKCS#8 envelope is not well-formed", e); }
|
|
738
|
+
var validate = PKCS8_INNER_VALIDATORS[algOid];
|
|
739
|
+
if (!validate) throw new WebCryptoError("webcrypto/data", "importKey pkcs8: " + name + " does not match the key's algorithm OID");
|
|
740
|
+
validate(innerBytes);
|
|
741
|
+
}
|
|
742
|
+
|
|
657
743
|
SubtleCrypto.prototype.importKey = async function importKey(format, keyData, algorithm, extractable, keyUsages) {
|
|
658
744
|
var alg = _normalizeAlg(algorithm, "importKey");
|
|
659
745
|
var usages = keyUsages || [];
|
|
660
746
|
var name = alg.name;
|
|
661
747
|
|
|
748
|
+
// spki / pkcs8 carry an asymmetric key; a secret-key / KDF name has no such structure. Reject
|
|
749
|
+
// it up front (W3C NotSupportedError) so it can neither mint a mislabeled key nor bypass the
|
|
750
|
+
// per-algorithm pkcs8 pre-validation below.
|
|
751
|
+
if ((format === "spki" || format === "pkcs8") && SECRET_KEY_NAMES[name]) {
|
|
752
|
+
throw new WebCryptoError("webcrypto/not-supported", "importKey: " + name + " does not support the " + format + " key format");
|
|
753
|
+
}
|
|
754
|
+
|
|
662
755
|
if (format === "raw") {
|
|
663
756
|
// Symmetric raw material, or a raw public key for EC/OKP.
|
|
664
|
-
if (name
|
|
757
|
+
if (SECRET_KEY_NAMES[name]) {
|
|
665
758
|
var raw = _toBuf(keyData, "importKey raw");
|
|
666
759
|
_assertAesImportLen(name, raw.length);
|
|
667
760
|
var secret = nodeCrypto.createSecretKey(raw);
|
|
@@ -673,15 +766,24 @@ SubtleCrypto.prototype.importKey = async function importKey(format, keyData, alg
|
|
|
673
766
|
}
|
|
674
767
|
|
|
675
768
|
if (format === "spki") {
|
|
676
|
-
var
|
|
769
|
+
var spkiBuf = _toBuf(keyData, "importKey spki");
|
|
770
|
+
var pub = _nodeKey(function () { return nodeCrypto.createPublicKey({ key: spkiBuf, format: "der", type: "spki" }); }, "importKey spki");
|
|
677
771
|
return new CryptoKey("public", true, _algFromImport(name, alg, pub), usages, pub);
|
|
678
772
|
}
|
|
679
773
|
if (format === "pkcs8") {
|
|
680
|
-
var
|
|
774
|
+
var p8Buf = _toBuf(keyData, "importKey pkcs8");
|
|
775
|
+
_preValidatePkcs8(p8Buf, name);
|
|
776
|
+
var priv = _nodeKey(function () { return nodeCrypto.createPrivateKey({ key: p8Buf, format: "der", type: "pkcs8" }); }, "importKey pkcs8");
|
|
681
777
|
return new CryptoKey("private", extractable, _algFromImport(name, alg, priv), usages, priv);
|
|
682
778
|
}
|
|
683
779
|
if (format === "jwk") {
|
|
684
780
|
var jwk = keyData;
|
|
781
|
+
// A JWK is a JSON object; null / undefined / an array / a primitive (incl. the JSON token
|
|
782
|
+
// `null` from an unwrapKey over non-authenticating ciphertext) fails closed as a typed
|
|
783
|
+
// DataError, never a raw property-access TypeError.
|
|
784
|
+
if (jwk === null || typeof jwk !== "object" || Array.isArray(jwk)) {
|
|
785
|
+
throw new WebCryptoError("webcrypto/data", "importKey jwk: keyData must be a JsonWebKey object");
|
|
786
|
+
}
|
|
685
787
|
if (jwk.kty === "oct") {
|
|
686
788
|
var kbuf = _b64urlToBuf(jwk.k, "JWK oct key material");
|
|
687
789
|
_assertAesImportLen(name, kbuf.length);
|
|
@@ -690,7 +792,7 @@ SubtleCrypto.prototype.importKey = async function importKey(format, keyData, alg
|
|
|
690
792
|
return new CryptoKey("secret", extractable, a2, usages, s2);
|
|
691
793
|
}
|
|
692
794
|
var isPrivate = Object.prototype.hasOwnProperty.call(jwk, "d");
|
|
693
|
-
var ko = isPrivate ? nodeCrypto.createPrivateKey({ key: jwk, format: "jwk" }) : nodeCrypto.createPublicKey({ key: jwk, format: "jwk" });
|
|
795
|
+
var ko = _nodeKey(function () { return isPrivate ? nodeCrypto.createPrivateKey({ key: jwk, format: "jwk" }) : nodeCrypto.createPublicKey({ key: jwk, format: "jwk" }); }, "importKey jwk");
|
|
694
796
|
return new CryptoKey(isPrivate ? "private" : "public", isPrivate ? extractable : true, _algFromImport(name, alg, ko), usages, ko);
|
|
695
797
|
}
|
|
696
798
|
throw new WebCryptoError("webcrypto/not-supported", "importKey: unsupported format " + JSON.stringify(format));
|
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:77add14d-d236-4f91-aec8-607349cb6aa8",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-07-
|
|
8
|
+
"timestamp": "2026-07-16T01:35:31.295Z",
|
|
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.2.
|
|
22
|
+
"bom-ref": "@blamejs/pki@0.2.21",
|
|
23
23
|
"type": "application",
|
|
24
24
|
"name": "pki",
|
|
25
|
-
"version": "0.2.
|
|
25
|
+
"version": "0.2.21",
|
|
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.2.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/pki@0.2.21",
|
|
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.2.
|
|
57
|
+
"ref": "@blamejs/pki@0.2.21",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|