@blamejs/pki 0.5.4 → 0.5.5

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/lib/trust.js CHANGED
@@ -289,7 +289,7 @@ function _trustEntry(obj) {
289
289
 
290
290
  function _mkAnchor(cert, meta) {
291
291
  var spki = cert.subjectPublicKeyInfo;
292
- return {
292
+ var entry = {
293
293
  name: cert.subject, // the object with .rdns (name chaining)
294
294
  publicKey: spki.bytes, // the full SPKI SEQUENCE TLV
295
295
  algorithm: spki.algorithm.oid, // the SPKI public-key algorithm OID
@@ -300,6 +300,76 @@ function _mkAnchor(cert, meta) {
300
300
  label: meta.label,
301
301
  mozillaCaPolicy: meta.mozillaCaPolicy,
302
302
  };
303
+ // A trust anchor IS the pair (name, key) -- RFC 5280 sec. 6.1.1 -- so those two fields are one
304
+ // fact about one certificate, and they were derived here from one. `anchor()` re-derives them
305
+ // rather than reading them back, so an entry rebuilt with a substituted key cannot carry the
306
+ // store's NAME and its per-purpose trust metadata over to a key the store never vouched for.
307
+ // Recorded off the object, so rebuilding the entry loses the record along with the binding.
308
+ // COPIED, not aliased. The record and the entry would otherwise hold the same Buffer and the same
309
+ // name object, so overwriting the entry's publicKey in place -- otherSpki.copy(entry.publicKey)
310
+ // for an equal-length key -- would overwrite the record with it, and re-deriving would hand back
311
+ // exactly the substituted key. A record that changes with the thing it is meant to pin is not one.
312
+ //
313
+ // The store's METADATA is recorded with them, and for the stronger reason: `purposes` IS the
314
+ // authorization -- it is what the purpose gate reads to decide whether this root may vouch for
315
+ // TLS -- and `distrustAfter` is the date that authorization ends. Pinning the key while reading
316
+ // the authorization off the mutable entry pins the less important half: `entry.purposes.serverAuth
317
+ // = true` would then produce a server-auth anchor from a root the store marked for e-mail only,
318
+ // and deleting a distrust date would outlast the store's own policy. Everything the anchor asserts
319
+ // now comes from what the store read.
320
+ _DERIVED_FROM.set(entry, {
321
+ // The RDN entries are copied too, not just the arrays holding them: a shallow slice shares
322
+ // every attribute object, so editing one in place would still reach the record.
323
+ name: _copyName(cert.subject),
324
+ publicKey: Buffer.from(spki.bytes),
325
+ algorithm: spki.algorithm.oid,
326
+ parameters: spki.algorithm.parameters == null ? spki.algorithm.parameters : Buffer.from(spki.algorithm.parameters),
327
+ purposes: _copyPurposes(meta.purposes),
328
+ distrustAfter: _copyDistrustAfter(meta.distrustAfter),
329
+ });
330
+ return entry;
331
+ }
332
+ var _DERIVED_FROM = new WeakMap();
333
+
334
+ // The per-purpose distrust dates, with fresh Date objects. A Date is mutable, so handing back the
335
+ // entry's own would let a consumer move the date this anchor is judged against.
336
+ // The three trust bits, normalized to booleans. Read by the purpose gate and handed back on every
337
+ // anchor, so it is built fresh from whichever source is authoritative rather than shared.
338
+ function _copyPurposes(src) {
339
+ return {
340
+ serverAuth: !!src && src.serverAuth === true,
341
+ emailProtection: !!src && src.emailProtection === true,
342
+ codeSigning: !!src && src.codeSigning === true,
343
+ };
344
+ }
345
+
346
+ function _copyDistrustAfter(src) {
347
+ var out = {};
348
+ if (!src || typeof src !== "object") return out;
349
+ Object.keys(src).forEach(function (k) {
350
+ out[k] = src[k] instanceof Date ? new Date(src[k].getTime()) : src[k];
351
+ });
352
+ return out;
353
+ }
354
+
355
+ // A parsed Name, copied FAITHFULLY: every field the parser assigns, at every level, with nothing
356
+ // mutable shared. Used on both sides of the record -- writing it in and handing it out -- so
357
+ // neither direction shares an object with the caller.
358
+ //
359
+ // Faithful rather than "the fields the path validator reads", because this Name is handed back on
360
+ // pki.trust.anchor and is the same structure pki.schema.x509.parse produces. A copy that keeps the
361
+ // subset one consumer needs silently degrades it for every other reader -- an attribute would lose
362
+ // its registry `name`, so a caller walking anchor.name.rdns would see a different shape depending
363
+ // on whether the anchor came from a store entry or straight from the parser. Each attribute value
364
+ // is a string and so needs no copy of its own; the DER does.
365
+ function _copyName(name) {
366
+ if (!name || !Array.isArray(name.rdns)) return name;
367
+ var out = Object.assign({}, name);
368
+ out.rdns = name.rdns.map(function (rdn) {
369
+ return Array.isArray(rdn) ? rdn.map(function (atv) { return Object.assign({}, atv); }) : rdn;
370
+ });
371
+ out.bytes = Buffer.isBuffer(name.bytes) ? Buffer.from(name.bytes) : name.bytes;
372
+ return out;
303
373
  }
304
374
 
305
375
  function _datesEqual(x, y) {
@@ -700,26 +770,67 @@ function parseCcadbCsv(text) {
700
770
  * { time: new Date("2026-06-01T00:00:00Z"), trustAnchor: anchor, checkPurpose: "serverAuth" });
701
771
  */
702
772
  function anchor(entry, opts) {
703
- if (!entry || typeof entry !== "object" || !Buffer.isBuffer(entry.publicKey) ||
704
- typeof entry.algorithm !== "string" || !entry.name || !Array.isArray(entry.name.rdns)) {
773
+ if (!entry || typeof entry !== "object") {
705
774
  throw E("trust/bad-input", "anchor expects a trust-store entry ({ name, publicKey, algorithm, ... })");
706
775
  }
707
776
  opts = opts || {};
777
+ // Where this entry came from a store, everything the anchor asserts is read from the record the
778
+ // store minted, not from the entry -- the entry is a plain object the caller holds and can write
779
+ // to. `purposes` is the authorization itself, so an entry whose serverAuth bit was flipped after
780
+ // parsing must not open the gate, and a distrust date deleted from it must not outlive the
781
+ // store's policy.
782
+ var derived = _DERIVED_FROM.get(entry);
783
+ var meta = derived || entry;
784
+ // The tuple-shape check runs on the ENTRY only when there is no record, because only then are the
785
+ // entry's own fields what the anchor is built from. With a record, an entry a caller has since
786
+ // emptied still anchors to exactly what the store read: the answer does not depend on the object
787
+ // the caller is holding, in either direction. Without one, the caller is asserting a bare
788
+ // (name, key) anchor of their own and its shape is what there is to check.
789
+ if (!derived && (!Buffer.isBuffer(entry.publicKey) || typeof entry.algorithm !== "string" ||
790
+ !entry.name || !Array.isArray(entry.name.rdns))) {
791
+ throw E("trust/bad-input", "anchor expects a trust-store entry ({ name, publicKey, algorithm, ... })");
792
+ }
708
793
  if (opts.purpose !== undefined) {
709
794
  if (PURPOSES.indexOf(opts.purpose) === -1) {
710
795
  throw E("trust/bad-input", "anchor: opts.purpose must be one of " + PURPOSES.join(" | "));
711
796
  }
712
- if (!entry.purposes || entry.purposes[opts.purpose] !== true) {
797
+ if (!meta.purposes || meta.purposes[opts.purpose] !== true) {
713
798
  throw E("trust/purpose-not-trusted", "this root is not a trusted delegator for " + opts.purpose);
714
799
  }
715
800
  }
801
+ // Where this entry came from a store, the (name, key) pair is re-derived from the certificate it
802
+ // was read out of rather than read back off the entry. Both halves are one fact about one
803
+ // certificate, so an entry rebuilt with a substituted publicKey would otherwise carry the store's
804
+ // name and its per-purpose trust metadata onto a key the store never vouched for. An entry a
805
+ // caller built themselves has no such record and is their own assertion, which is what a bare
806
+ // trust-anchor tuple is.
807
+ //
808
+ // An entry carrying the store's METADATA is claiming to be a store entry. The metadata is the
809
+ // root program's statement -- these purposes, until this date -- and it is a statement about a
810
+ // KEY. Without the record there is nothing binding it to the key the entry now names, so a copy
811
+ // with a substituted publicKey would carry the program's word onto a key it never saw. A caller
812
+ // asserting a bare (name, key) anchor of their own carries no metadata and is unaffected.
813
+ if (!derived && (entry.purposes != null || (entry.distrustAfter && Object.keys(entry.distrustAfter).length))) {
814
+ throw E("trust/bad-input", "this entry carries a trust store's per-purpose metadata but is not the entry the store produced -- it has been rebuilt, and the metadata is a statement about the key the store read, not about whichever key the copy now names. Pass pki.trust.parseCertdata / parseCcadbCsv output unmodified");
815
+ }
816
+ // Copies OUT as well as in. Handing back the record's own Buffer lets a consumer write through
817
+ // the returned anchor into the record -- copy an equal-length SPKI over anchor.publicKey and the
818
+ // next anchor() call re-derives the substituted key while still carrying the store's purposes and
819
+ // distrust dates. Guarding only the write INTO the record leaves the same door open in the other
820
+ // direction, so every call hands back fresh values.
716
821
  return {
717
- name: entry.name,
718
- publicKey: entry.publicKey,
719
- algorithm: entry.algorithm,
720
- parameters: entry.parameters !== undefined ? entry.parameters : null,
721
- distrustAfter: entry.distrustAfter || {},
722
- purposes: entry.purposes || { serverAuth: false, emailProtection: false, codeSigning: false },
822
+ name: _copyName(meta.name),
823
+ publicKey: Buffer.isBuffer(meta.publicKey) ? Buffer.from(meta.publicKey) : meta.publicKey,
824
+ algorithm: meta.algorithm,
825
+ parameters: Buffer.isBuffer(meta.parameters) ? Buffer.from(meta.parameters)
826
+ : (meta.parameters !== undefined ? meta.parameters : null),
827
+ // The METADATA comes from the same source as the key, and is copied for the same reason on the
828
+ // way out: handing back the record's own object let a consumer write
829
+ // `anchor(entry).purposes.serverAuth = true` and have the next call pass a gate the store never
830
+ // opened. `distrustAfter` holds Dates, which are mutable in the same way. Everything on this
831
+ // object is fresh, so two anchors from one entry share nothing.
832
+ distrustAfter: _copyDistrustAfter(meta.distrustAfter),
833
+ purposes: _copyPurposes(meta.purposes),
723
834
  };
724
835
  }
725
836
 
@@ -156,6 +156,90 @@ function credentialKey(node, E, code, unsupportedCode) {
156
156
  // CANONICAL CTAP2 COSE_Key: exactly the type's parameters, nothing more.
157
157
  var expectedParams = kty === 2n ? 5 : 4;
158
158
  if (node.children.length !== expectedParams) throw bad("the COSE_Key carries parameters beyond the canonical set for its key type (WebAuthn sec. 6.5.1)");
159
+ return assertKeyMaterial(key, E, code, unsupportedCode);
160
+ }
161
+
162
+ // assertKeyMaterial(key, E, code, unsupportedCode) -> key | throws
163
+ //
164
+ // The rules about the KEY, split from the rules about its CBOR encoding, because the
165
+ // toolkit accepts a stored credential key in two forms and both reach a signature
166
+ // verification. `pki.webauthn.verifyAssertion` takes the COSE bytes or the object
167
+ // `pki.webauthn.verify` returned; the bytes went through every check below and the
168
+ // object went through none, so the same 1-byte modulus was refused as a credential key
169
+ // in one form and imported for verification in the other. A caller stores whichever
170
+ // form their datastore round-trips, which is not a choice about how carefully their
171
+ // credential is checked.
172
+ //
173
+ // Everything above this line is about the ENCODING -- a CBOR map, integer labels, byte
174
+ // strings, the canonical parameter count -- and can only be asked of bytes. Everything
175
+ // here is about the key, and is asked of both.
176
+ //
177
+ // @enforced-by behavioral -- key-material rules have no rename-proof code shape distinct
178
+ // from ordinary length and byte comparisons; the RED vectors that drive BOTH accepted
179
+ // forms of a stored credential key (the COSE bytes and the object) through
180
+ // pki.webauthn.verifyAssertion with an undersized modulus, e = 1, a curve/length
181
+ // mismatch and a short OKP x are the guard.
182
+ function assertKeyMaterial(key, E, code, unsupportedCode) {
183
+ function bad(msg, cause) { return new E(code, msg, cause); }
184
+ if (!key || typeof key !== "object") throw bad("a credential key must be a decoded COSE_Key object");
185
+ // ONE read of each field, into a plain object, before anything is checked or used.
186
+ //
187
+ // The object form comes from the caller, so any of these can be an accessor. One that THROWS
188
+ // turns a validation into a raw fault -- the thing this function exists to prevent -- and one
189
+ // that returns DIFFERENT values on successive reads makes the field that was checked and the
190
+ // field that is used two different values, which is the check defeated rather than merely
191
+ // reported badly. Reading each exactly once settles both, and settles them for every field
192
+ // rather than for the ones a particular branch happens to reach.
193
+ try {
194
+ key = { kty: key.kty, alg: key.alg, crv: key.crv, x: key.x, y: key.y, n: key.n, e: key.e };
195
+ } catch (e) { throw bad("a credential key field could not be read", e); }
196
+ // Then the TYPE and the VALUE. BigInt() throws a raw TypeError on a Symbol and on undefined, and
197
+ // a raw RangeError on a fractional or non-finite number, so "it is a number" is not the check --
198
+ // "it is an integer" is. EVERY integer label the branches below read, not the two the dispatch
199
+ // happens to need first:
200
+ // crv indexes a lookup table, and a Symbol thrown at a property read is the same raw fault as a
201
+ // Symbol thrown at BigInt(). The decoded form gets these from the CBOR reader, which has already
202
+ // established them; the object form gets them from the caller, so this is where they are settled.
203
+ // A BigInt is bounded too. COSE labels are small registry integers, and an unbounded one converts
204
+ // to Infinity, which then throws a raw RangeError at the next conversion -- the same defeat as a
205
+ // fractional number, reached by a value that IS an integer. "It is an integer" is not the whole
206
+ // check either; "it is an integer this code can carry" is.
207
+ var MAX = BigInt(Number.MAX_SAFE_INTEGER);
208
+ function _isInt(v) {
209
+ if (typeof v === "bigint") return v <= MAX && v >= -MAX;
210
+ return typeof v === "number" && Number.isSafeInteger(v);
211
+ }
212
+ if (!_isInt(key.kty)) throw bad("a COSE_Key kty (label 1) must be an integer");
213
+ if (!_isInt(key.alg)) throw bad("a COSE_Key alg (label 3) must be an integer");
214
+ if (key.crv !== undefined && key.crv !== null && !_isInt(key.crv)) throw bad("a COSE_Key crv (label -1) must be an integer");
215
+ // ONE representation from here down. A label may arrive as a Number or a BigInt -- a CBOR reader
216
+ // hands out BigInt, an object built in JavaScript is likelier to hold Number -- and everything
217
+ // below compares with === against the Number-keyed profile table and the curve tables. Accepting
218
+ // both forms at the gate and then comparing only one is a check that answers by how the caller
219
+ // happened to spell the value; the decoded arm normalizes here too, for the same reason.
220
+ key.kty = Number(key.kty);
221
+ key.alg = Number(key.alg);
222
+ if (key.crv !== undefined && key.crv !== null) key.crv = Number(key.crv);
223
+ var kty = BigInt(key.kty);
224
+ if (kty === 2n) {
225
+ var el2 = EC2_CRV_LEN[key.crv];
226
+ if (!Buffer.isBuffer(key.x) || !Buffer.isBuffer(key.y)) throw bad("an EC2 COSE_Key must carry crv (-1), x (-2), and y (-3)");
227
+ if (!el2 || key.x.length !== el2 || key.y.length !== el2) throw bad("an EC2 COSE_Key x/y length is inconsistent with its curve");
228
+ } else if (kty === 1n) {
229
+ var okp2 = OKP_CRV[key.crv];
230
+ if (!okp2 || !Buffer.isBuffer(key.x) || key.x.length !== okp2.len) throw bad("an OKP COSE_Key must be Ed25519 (crv 6) or Ed448 (crv 7) with a matching-length x (-2)");
231
+ } else if (kty === 3n) {
232
+ if (!Buffer.isBuffer(key.n) || !key.n.length || !Buffer.isBuffer(key.e) || !key.e.length) throw bad("an RSA COSE_Key must carry n (-1) and e (-2)");
233
+ if (key.n[0] === 0) throw bad("an RSA COSE_Key modulus (-1) must be minimally encoded, with no leading zero byte (RFC 8230 sec. 4)");
234
+ if (key.e[0] === 0) throw bad("an RSA COSE_Key exponent (-2) must be minimally encoded, with no leading zero byte (RFC 8230 sec. 4)");
235
+ var bits = _modulusBits(key.n);
236
+ if (bits < RSA_MIN_MODULUS_BITS) throw bad("an RSA COSE_Key modulus (-1) is " + bits + " bits, below the " + RSA_MIN_MODULUS_BITS + "-bit minimum");
237
+ if (key.e.length > RSA_MAX_EXPONENT_BYTES) throw bad("an RSA COSE_Key exponent (-2) is longer than " + RSA_MAX_EXPONENT_BYTES + " bytes");
238
+ if ((key.e[key.e.length - 1] & 1) === 0) throw bad("an RSA COSE_Key exponent (-2) must be odd");
239
+ if (key.e.length === 1 && key.e[0] <= 1) throw bad("an RSA COSE_Key exponent (-2) must be greater than 1 -- e = 1 makes RSA the identity function");
240
+ } else {
241
+ throw bad("unsupported COSE_Key kty " + Number(key.kty));
242
+ }
159
243
  // PROFILE: the declared alg must match the key type (and, for EC2, the curve).
160
244
  var prof = ALG_PROFILE[String(key.alg)];
161
245
  // An algorithm this verifier does not implement is NOT a malformed key. The key can be perfectly
@@ -173,7 +257,7 @@ function credentialKey(node, E, code, unsupportedCode) {
173
257
  // OpenSSL does NOT validate an OKP (Ed25519/Ed448) point on import -- an all-zeroes key
174
258
  // parses, and even verifies a trivial signature -- so an OKP point needs an explicit
175
259
  // on-curve + full-order (non-low-order) check (RFC 8032 decode + the cofactor check).
176
- if (kty === 1n && !edwardsPoint.validate(key.x, key.crv)) throw bad("the OKP credential public key is not a valid, full-order Edwards point");
260
+ if (Number(key.kty) === 1 && !edwardsPoint.validate(key.x, key.crv)) throw bad("the OKP credential public key is not a valid, full-order Edwards point");
177
261
  return key;
178
262
  }
179
263
 
@@ -216,6 +300,7 @@ function toSpki(key, E, code) {
216
300
 
217
301
  module.exports = {
218
302
  credentialKey: credentialKey,
303
+ assertKeyMaterial: assertKeyMaterial,
219
304
  toSpki: toSpki,
220
305
  EC2_CRV_LEN: EC2_CRV_LEN,
221
306
  EC2_CRV_OID: EC2_CRV_OID,
@@ -221,12 +221,17 @@ function normalizeObjectAttributePolicy(policy, E, code) {
221
221
  // either of which could match a key this policy was written to exclude, including the
222
222
  // Empty Policy. An entry that is not a Buffer or a canonical even-length hex string is a
223
223
  // caller error, and it fails here rather than becoming a digest nobody intended.
224
+ // Through guard.encoding.hex, which owns the alphabet, the even-length rule and the
225
+ // canonical round-trip -- the same three checks written here by hand, and now written
226
+ // once. It also decodes, so the hex path cannot validate one string and decode another.
224
227
  allow = ap.allow.map(function (entry, i) {
225
228
  if (Buffer.isBuffer(entry)) return entry;
226
- if (typeof entry !== "string" || entry.length === 0 || entry.length % 2 !== 0 || !/^[0-9a-fA-F]+$/.test(entry)) {
227
- throw new E(code, "opts.tpmPolicy.authPolicy.allow[" + i + "] must be a Buffer or an even-length hex string");
229
+ var label = "opts.tpmPolicy.authPolicy.allow[" + i + "]";
230
+ if (typeof entry !== "string" || entry.length === 0) {
231
+ throw new E(code, label + " must be a Buffer or an even-length hex string");
228
232
  }
229
- return Buffer.from(entry, "hex");
233
+ return guard.encoding.hex(entry, null, function (c, m) { return new E(c, m); }, code,
234
+ label + " must be a Buffer or an even-length hex string --");
230
235
  });
231
236
  }
232
237
  }
@@ -212,24 +212,16 @@ function _isAnchorItself(cert, anchor) {
212
212
  // a hand-built object literal satisfies it too and then raises a raw TypeError from deep inside the
213
213
  // path validator, which is an untyped throw escaping a public verb.
214
214
  function _asCert(v, label) {
215
- // A certificate arriving as BYTES arrives in whichever byte form the caller holds -- the same set
216
- // every other byte argument in this namespace takes. A DataView or an ArrayBuffer over identical
217
- // DER is the identical certificate, and refusing one of them makes the accepted set depend on how
218
- // the caller happened to receive the file rather than on what it contains.
219
- if (ArrayBuffer.isView(v) || v instanceof ArrayBuffer) {
220
- v = guard.bytes.source(v, WebauthnError, "webauthn/bad-input", label);
221
- }
222
- if (v && typeof v === "object" && !Buffer.isBuffer(v) && !(v instanceof Uint8Array) &&
223
- v.subject && v.subjectPublicKeyInfo && Buffer.isBuffer(v.subjectPublicKeyInfo.bytes) &&
224
- v.signatureAlgorithm && typeof v.signatureAlgorithm.oid === "string" &&
225
- // `validity` is what separates a certificate from the other signed structures that carry a
226
- // subject, a public key and a signature algorithm: a parsed certification request has all
227
- // three and would otherwise be installed as a trust anchor.
228
- v.validity && v.validity.notBefore !== undefined) {
229
- return v;
230
- }
231
- try { return x509.parse(v); }
232
- catch (e) { throw _err("webauthn/bad-input", label + " is not a decodable certificate", e); }
215
+ // Through the shared certificate door: these become the anchors a metadata BLOB's signer chain is
216
+ // judged against, and an anchor's identity is its subject and its key. A caller-assembled object
217
+ // could carry a real root's subject beside a substituted key -- every field well-formed, nothing
218
+ // for a shape test to catch -- so the object is re-derived from the bytes its parser read instead.
219
+ // Bytes in any form (a Buffer, a typed-array view, a DataView, an ArrayBuffer) are parsed here;
220
+ // refusing one of those would make the accepted set depend on how the caller received the file.
221
+ return guard.parsed.acceptDerived(v, "certificate", function (bytes) {
222
+ try { return x509.parse(bytes); }
223
+ catch (e) { throw _err("webauthn/bad-input", label + " is not a decodable certificate", e); }
224
+ }, _err, "webauthn/bad-input", label);
233
225
  }
234
226
 
235
227
  // Verify a FIDO Metadata Service BLOB and return its entries indexed for lookup. `blob` is the
package/lib/webauthn.js CHANGED
@@ -572,8 +572,13 @@ function _snapshotRoots(supplied) {
572
572
  if (_isBufferSource(root)) {
573
573
  return guard.bytes.snapshotSource(root, WebauthnError, "webauthn/bad-input", "opts.rootCertificates[]");
574
574
  }
575
- if (root && typeof root === "object") return _cloneParsed(root, 0);
576
- return root; // a PEM string is immutable
575
+ // A PARSED certificate is kept AS IT IS, not deep-copied. The copy was this function's way of
576
+ // stopping a caller mutating a root after it was accepted, and it solved that by making a
577
+ // detached twin -- which loses the parser's record, so the door below could no longer re-derive
578
+ // the anchor from the bytes it was read from. The record is the stronger form of the same
579
+ // protection: the anchor is re-parsed from those bytes, so an edit made afterwards, at any depth,
580
+ // is discarded rather than copied. Cloning would trade that for a snapshot of a mutable object.
581
+ return root; // a PEM string is immutable; a parsed certificate carries its own provenance
577
582
  });
578
583
  }
579
584
 
@@ -592,13 +597,16 @@ function _applyCallerRoots(res, supplied, vopts, onlyPaths) {
592
597
  // would fault on a field it does not have rather than naming the caller's mistake.
593
598
  // The same three forms opts.safetyNetRoots takes, since it is the same question.
594
599
  var roots = supplied.map(function (root, i) {
595
- var cert;
596
- try { cert = _isBufferSource(root) ? x509.parse(guard.bytes.source(root, WebauthnError, "webauthn/bad-input", "opts.rootCertificates[" + i + "]")) : (typeof root === "string" ? x509.parse(root) : root); }
597
- catch (e) { throw _err("webauthn/bad-input", "opts.rootCertificates[" + i + "] is not a decodable certificate", e); }
598
- if (!cert || !cert.subject || !cert.subjectPublicKeyInfo) {
599
- throw _err("webauthn/bad-input", "opts.rootCertificates[" + i + "] is not a certificate");
600
- }
601
- return cert;
600
+ var label = "opts.rootCertificates[" + i + "]";
601
+ // These BECOME trust anchors, which is the sharpest form of the rule: the anchor's key is what
602
+ // the whole attestation chain is judged against, so a caller-assembled certificate carrying a
603
+ // real root's name beside a substituted key would anchor an attacker's chain. Re-derived from
604
+ // the bytes its parser read, exactly as at every other certificate door.
605
+ return guard.parsed.acceptDerived(root, "certificate", function (bytes) {
606
+ try {
607
+ return x509.parse(_isBufferSource(bytes) ? guard.bytes.source(bytes, WebauthnError, "webauthn/bad-input", label) : bytes);
608
+ } catch (e) { throw _err("webauthn/bad-input", label + " is not a decodable certificate", e); }
609
+ }, _err, "webauthn/bad-input", label);
602
610
  });
603
611
  // Same rule the metadata route applies: a compound element carrying no
604
612
  // certificates makes no claim there is anything to anchor, so it is not a reason to
@@ -1174,14 +1182,15 @@ function _safetyNetHostnameOk(leaf) {
1174
1182
  function _safetyNetChainTrusted(chain, roots, time) {
1175
1183
  var anchors;
1176
1184
  try {
1185
+ // The same door opts.rootCertificates goes through, for the same reason: these become trust
1186
+ // anchors, and an anchor's key is what the chain is judged against.
1177
1187
  anchors = roots.map(function (root, i) {
1178
- var anchorCert;
1179
- try { anchorCert = _isBufferSource(root) ? x509.parse(guard.bytes.source(root, WebauthnError, "webauthn/bad-input", "opts.safetyNetRoots[" + i + "]")) : (typeof root === "string" ? x509.parse(root) : root); }
1180
- catch (e) { throw _err("webauthn/bad-input", "opts.safetyNetRoots[" + i + "] is not a decodable certificate", e); }
1181
- if (!anchorCert || !anchorCert.subject || !anchorCert.subjectPublicKeyInfo) {
1182
- throw _err("webauthn/bad-input", "opts.safetyNetRoots[" + i + "] is not a certificate");
1183
- }
1184
- return anchorCert;
1188
+ var label = "opts.safetyNetRoots[" + i + "]";
1189
+ return guard.parsed.acceptDerived(root, "certificate", function (bytes) {
1190
+ try {
1191
+ return x509.parse(_isBufferSource(bytes) ? guard.bytes.source(bytes, WebauthnError, "webauthn/bad-input", label) : bytes);
1192
+ } catch (e) { throw _err("webauthn/bad-input", label + " is not a decodable certificate", e); }
1193
+ }, _err, "webauthn/bad-input", label);
1185
1194
  });
1186
1195
  } catch (e) { return Promise.reject(e); }
1187
1196
  return mds.chainToAnchor(chain, anchors, time === undefined ? new Date() : time,
@@ -2228,7 +2237,13 @@ function _snapshotAssertion(input) {
2228
2237
  var key = {}, kk;
2229
2238
  for (kk in out.credentialPublicKey) {
2230
2239
  if (!Object.prototype.hasOwnProperty.call(out.credentialPublicKey, kk)) continue;
2231
- var v = out.credentialPublicKey[kk];
2240
+ // The read itself can fault: a stored key is a caller-supplied object, so a field may be an
2241
+ // accessor, and one that THROWS would escape as a raw error from the very copy whose job is
2242
+ // to make the descriptor stop being the caller's. The copy is the boundary, so the fault is
2243
+ // named here rather than surfacing from wherever the field was later used.
2244
+ var v;
2245
+ try { v = out.credentialPublicKey[kk]; }
2246
+ catch (e) { throw _err("webauthn/bad-cose-key", "credentialPublicKey." + kk + " could not be read", e); }
2232
2247
  key[kk] = _isBufferSource(v) ? guard.bytes.snapshotSource(v, WebauthnError, "webauthn/bad-input", "credentialPublicKey." + kk) : v;
2233
2248
  }
2234
2249
  out.credentialPublicKey = key;
@@ -2295,7 +2310,14 @@ function verifyAssertion(input) {
2295
2310
  var coseKey = input.credentialPublicKey;
2296
2311
  if (Buffer.isBuffer(coseKey) || ArrayBuffer.isView(coseKey) || coseKey instanceof ArrayBuffer) {
2297
2312
  coseKey = parseCoseKey(coseKey);
2298
- } else if (!_isPlainObject(coseKey)) {
2313
+ } else if (_isPlainObject(coseKey)) {
2314
+ // The OBJECT form is held to the same rules about the KEY as the bytes form. The bytes went
2315
+ // through the curve/length, RSA modulus-floor and exponent checks; the object went through
2316
+ // none, so one stored credential was refused in one form and imported for signature
2317
+ // verification in the other. Which form a relying party stores is a question about what their
2318
+ // datastore round-trips, not about how carefully their credential is checked.
2319
+ coseKey = validator.cose.assertKeyMaterial(coseKey, WebauthnError, "webauthn/bad-cose-key", "webauthn/unsupported-algorithm");
2320
+ } else {
2299
2321
  throw _err("webauthn/bad-input", "credentialPublicKey must be the stored COSE key -- the object pki.webauthn.verify returned, or its COSE bytes");
2300
2322
  }
2301
2323
  var bindingChecked = _applyBindings(authData, coseKey, input);
package/lib/x509-sign.js CHANGED
@@ -286,8 +286,12 @@ function _sign(spec, issuer, opts) {
286
286
  issuerDer = subjectDer;
287
287
  issuerSpki = spki;
288
288
  } else if (issuer.cert != null) {
289
- issuerCert = (Buffer.isBuffer(issuer.cert) || typeof issuer.cert === "string") ? x509.parse(issuer.cert) : issuer.cert;
290
- if (!issuerCert || !issuerCert.tbsBytes) throw _err("x509/bad-input", "issuer.cert must be a certificate DER/PEM or a parsed certificate");
289
+ // The CA-ness gate, the issuer name and the signing key are all read off this object, so a
290
+ // partial one decides them on fields that are not there.
291
+ // Re-derived from the bytes its parser read. The issuer certificate's subject and key identifier
292
+ // are copied into the certificate being signed, so they are a claim about who issued it -- an
293
+ // assembled object could name an issuer whose bytes the signer never saw.
294
+ issuerCert = guard.parsed.acceptDerived(issuer.cert, "certificate", x509.parse, _err, "x509/bad-input", "issuer.cert");
291
295
  issuerPathLen = _assertIssuerIsCa(issuerCert);
292
296
  issuerDer = pkiBuild.tbsNameField(issuerCert, "subject");
293
297
  issuerSpki = issuerCert.subjectPublicKeyInfo.bytes;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/pki",
3
- "version": "0.5.4",
3
+ "version": "0.5.5",
4
4
  "description": "Pure-JavaScript PKI toolkit that owns its stack — X.509, ASN.1/DER, CMS, PQC-first.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
@@ -13,6 +13,10 @@
13
13
  "url": "https://github.com/blamejs/pki/issues"
14
14
  },
15
15
  "main": "index.js",
16
+ "exports": {
17
+ ".": "./index.js",
18
+ "./package.json": "./package.json"
19
+ },
16
20
  "bin": {
17
21
  "pki": "bin/pki.js"
18
22
  },
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:38c27a59-75b3-42df-a97a-b59d75f2a0e9",
5
+ "serialNumber": "urn:uuid:e4afed62-75b0-4b79-b65c-4d16c17ee70b",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-08-15T08:44:34.920Z",
8
+ "timestamp": "2026-08-15T23:54:15.184Z",
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.5.4",
22
+ "bom-ref": "@blamejs/pki@0.5.5",
23
23
  "type": "application",
24
24
  "name": "pki",
25
- "version": "0.5.4",
25
+ "version": "0.5.5",
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.5.4",
29
+ "purl": "pkg:npm/%40blamejs/pki@0.5.5",
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.5.4",
57
+ "ref": "@blamejs/pki@0.5.5",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]