@blamejs/pki 0.5.0 → 0.5.2

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.
@@ -41,6 +41,7 @@ var ocsp = require("./schema-ocsp");
41
41
  var ocspVerify = require("./ocsp-verify");
42
42
  var crlVerify = require("./crl-verify");
43
43
  var cmpVerify = require("./cmp-verify");
44
+ var cmsVerify = require("./cms-verify");
44
45
  var cmpSession = require("./cmp-session");
45
46
  var guard = require("./guard-all");
46
47
  var constants = require("./constants");
@@ -1187,40 +1188,9 @@ async function validate(path, opts) {
1187
1188
  // opts.requiredEku -- the key purposes the TARGET certificate must be good
1188
1189
  // for, each a registered OID name or a dotted OID string. Resolved (and
1189
1190
  // typo-checked) here at the entry point.
1190
- var requiredEku = null;
1191
- if (opts.requiredEku !== undefined) {
1192
- if (!Array.isArray(opts.requiredEku) || opts.requiredEku.length === 0) {
1193
- throw E("path/bad-input", "validate: opts.requiredEku must be a non-empty array of key-purpose OID names or dotted OID strings");
1194
- }
1195
- requiredEku = opts.requiredEku.map(function (p) {
1196
- if (typeof p !== "string" || p.length === 0) throw E("path/bad-input", "validate: opts.requiredEku entries must be non-empty strings");
1197
- // A dotted-form attempt (leads with a digit) must be a canonical OID -- a
1198
- // loose regex accepted a leading-zero / out-of-bounds key that would never
1199
- // match the canonical EKU the target advertises; anything else is a name.
1200
- if (/^[0-9]/.test(p)) return guard.identifier.assertCanonicalOid(p, E, "path/bad-input", "validate: opts.requiredEku entry " + JSON.stringify(p));
1201
- var dotted = oid.byName(p);
1202
- if (typeof dotted !== "string") throw E("path/bad-input", "validate: opts.requiredEku entry " + JSON.stringify(p) + " is not a registered OID name");
1203
- return dotted;
1204
- });
1205
- }
1206
- // opts.checkPurpose -- the single key purpose the ANCHOR's NSS trust metadata
1207
- // (distrustAfter / purposes) is consulted for. Independent of requiredEku
1208
- // (which gates the leaf's own EKU extension): this selects the per-purpose
1209
- // key in the trust-anchor constraint contract. A purpose OID name (or a
1210
- // canonical dotted OID normalized to its name); a bad value throws here.
1211
- var checkPurpose = null;
1212
- if (opts.checkPurpose !== undefined) {
1213
- if (typeof opts.checkPurpose !== "string" || opts.checkPurpose.length === 0) {
1214
- throw E("path/bad-input", "validate: opts.checkPurpose must be a key-purpose OID name or dotted OID string");
1215
- }
1216
- if (/^[0-9]/.test(opts.checkPurpose)) {
1217
- var cpDotted = guard.identifier.assertCanonicalOid(opts.checkPurpose, E, "path/bad-input", "validate: opts.checkPurpose");
1218
- checkPurpose = oid.name(cpDotted) || cpDotted; // normalize a dotted purpose OID to its name for the anchor map
1219
- } else {
1220
- if (typeof oid.byName(opts.checkPurpose) !== "string") throw E("path/bad-input", "validate: opts.checkPurpose " + JSON.stringify(opts.checkPurpose) + " is not a registered OID name");
1221
- checkPurpose = opts.checkPurpose;
1222
- }
1223
- }
1191
+ var purposeOpts = resolvePurposeOpts(opts);
1192
+ var requiredEku = purposeOpts.requiredEku;
1193
+ var checkPurpose = purposeOpts.checkPurpose;
1224
1194
 
1225
1195
  var state = initialize(certs, opts, seeds);
1226
1196
  state._n = n;
@@ -1385,9 +1355,8 @@ async function validate(path, opts) {
1385
1355
  // NaN time) would make `notBefore > it` NaN-false and SILENTLY drop the distrust
1386
1356
  // restriction -- the NaN-Date fail-open. Validate a present date fail-closed
1387
1357
  // before the comparison; an absent (undefined/null) date is no restriction.
1388
- var distrustDate = (checkPurpose && ta.distrustAfter) ? ta.distrustAfter[checkPurpose] : null;
1358
+ var distrustDate = assertAnchorConstraints(ta, checkPurpose);
1389
1359
  if (distrustDate != null) {
1390
- distrustDate = guard.time.assertValid(distrustDate, E, "path/bad-input", "trustAnchor.distrustAfter." + checkPurpose);
1391
1360
  // STRICTLY > : a leaf whose notBefore == the distrust date stays trusted
1392
1361
  // (Mozilla certverifier isDistrustedCertificateChain: endEntityNotBefore
1393
1362
  // <= distrustAfterTime -> not distrusted; the end-of-day ...235959Z
@@ -2211,6 +2180,16 @@ var ocspCore = ocspVerify.makeOcspVerify({
2211
2180
  cmpVerify.setEngine({ verifyWithSpki: _verifyWithSpki, build: build, validate: validate });
2212
2181
  // pki.cmp.session validates the ISSUED leaf certificate (its signature + chain) through the same engine.
2213
2182
  cmpSession.setEngine({ build: build, validate: validate, toAnchor: toAnchor, coerceCert: coerceCert });
2183
+ // pki.cms.verify chains a SignedData's signer certificate to the anchors the CALLER named, so its
2184
+ // `trusted` is decided by this one path engine rather than a second, weaker walk of its own.
2185
+ // `toAnchor` so cms.verify can validate the caller's anchors ONCE at entry, before any signer is
2186
+ // walked -- otherwise a message whose signers all failed would never reach a build call and a
2187
+ // malformed anchor would pass unnoticed.
2188
+ // `resolvePurposeOpts` so cms.verify can reject a malformed requiredEku / checkPurpose at ITS entry
2189
+ // point, through the SAME definition the walk uses -- a message whose signers all failed never
2190
+ // reaches a build call, and a caller's configuration must not be judged by the message's quality.
2191
+ cmsVerify.setEngine({ build: build, validate: validate, toAnchor: toAnchor,
2192
+ resolvePurposeOpts: resolvePurposeOpts, assertAnchorConstraints: assertAnchorConstraints });
2214
2193
 
2215
2194
  /**
2216
2195
  * @primitive pki.path.ocspChecker
@@ -2440,6 +2419,63 @@ function coerceCert(input) {
2440
2419
  // tuple. The algorithm is the SPKI KEY-algorithm OID (the sec. 6.1.4(f)
2441
2420
  // parameter-inheritance value), mirroring trust.js _mkAnchor -- NOT the
2442
2421
  // signature OID. The anchor is an input to validate, never one of the path certs.
2422
+ // The two key-purpose options, resolved and typo-checked. Extracted so a CALLER can validate them
2423
+ // at ITS entry point rather than only when a path is actually walked: a format verb that skips the
2424
+ // walk -- pki.cms.verify does when no signer verified -- would otherwise accept a malformed
2425
+ // purpose in silence, making configuration validity depend on the message. One definition, so the
2426
+ // answer cannot drift between the caller's early check and the walk's own.
2427
+ //
2428
+ // `requiredEku` gates the TARGET certificate's own EKU extension; `checkPurpose` selects which
2429
+ // per-purpose key the ANCHOR's NSS trust metadata (purposes / distrustAfter) is consulted under.
2430
+ // They are independent, and each is a registered OID name or a canonical dotted OID.
2431
+ // An anchor's CONSTRAINT metadata for one purpose, validated fail-closed and returned normalized.
2432
+ // A PRESENT-but-malformed distrustAfter (an Invalid Date: instanceof Date yet a NaN time) would
2433
+ // make `notBefore > it` NaN-false and SILENTLY drop the distrust restriction -- the NaN-Date
2434
+ // fail-open. Absent metadata is no restriction and returns null.
2435
+ //
2436
+ // Separate from resolvePurposeOpts because it validates the ANCHOR rather than the options, and
2437
+ // exposed for the same reason: a caller that may never reach the walk -- pki.cms.verify when no
2438
+ // signer verified -- has to be able to reject a malformed anchor at ITS entry point, through this
2439
+ // same definition, so configuration validity never depends on the message.
2440
+ function assertAnchorConstraints(ta, checkPurpose) {
2441
+ var d = (checkPurpose && ta && ta.distrustAfter) ? ta.distrustAfter[checkPurpose] : null;
2442
+ if (d == null) return null;
2443
+ return guard.time.assertValid(d, E, "path/bad-input", "trustAnchor.distrustAfter." + checkPurpose);
2444
+ }
2445
+
2446
+ function resolvePurposeOpts(opts) {
2447
+ var requiredEku = null;
2448
+ if (opts.requiredEku !== undefined) {
2449
+ if (!Array.isArray(opts.requiredEku) || opts.requiredEku.length === 0) {
2450
+ throw E("path/bad-input", "validate: opts.requiredEku must be a non-empty array of key-purpose OID names or dotted OID strings");
2451
+ }
2452
+ requiredEku = opts.requiredEku.map(function (p) {
2453
+ if (typeof p !== "string" || p.length === 0) throw E("path/bad-input", "validate: opts.requiredEku entries must be non-empty strings");
2454
+ // A dotted-form attempt (leads with a digit) must be a canonical OID -- a
2455
+ // loose regex accepted a leading-zero / out-of-bounds key that would never
2456
+ // match the canonical EKU the target advertises; anything else is a name.
2457
+ if (/^[0-9]/.test(p)) return guard.identifier.assertCanonicalOid(p, E, "path/bad-input", "validate: opts.requiredEku entry " + JSON.stringify(p));
2458
+ var dotted = oid.byName(p);
2459
+ if (typeof dotted !== "string") throw E("path/bad-input", "validate: opts.requiredEku entry " + JSON.stringify(p) + " is not a registered OID name");
2460
+ return dotted;
2461
+ });
2462
+ }
2463
+ var checkPurpose = null;
2464
+ if (opts.checkPurpose !== undefined) {
2465
+ if (typeof opts.checkPurpose !== "string" || opts.checkPurpose.length === 0) {
2466
+ throw E("path/bad-input", "validate: opts.checkPurpose must be a key-purpose OID name or dotted OID string");
2467
+ }
2468
+ if (/^[0-9]/.test(opts.checkPurpose)) {
2469
+ var cpDotted = guard.identifier.assertCanonicalOid(opts.checkPurpose, E, "path/bad-input", "validate: opts.checkPurpose");
2470
+ checkPurpose = oid.name(cpDotted) || cpDotted; // normalize a dotted purpose OID to its name for the anchor map
2471
+ } else {
2472
+ if (typeof oid.byName(opts.checkPurpose) !== "string") throw E("path/bad-input", "validate: opts.checkPurpose " + JSON.stringify(opts.checkPurpose) + " is not a registered OID name");
2473
+ checkPurpose = opts.checkPurpose;
2474
+ }
2475
+ }
2476
+ return { requiredEku: requiredEku, checkPurpose: checkPurpose };
2477
+ }
2478
+
2443
2479
  function toAnchor(entry) {
2444
2480
  if (entry && typeof entry === "object" && !Buffer.isBuffer(entry) && entry.name && entry.publicKey && entry.algorithm) {
2445
2481
  // A ready anchor tuple: validate the shape build + validate consume -- name.rdns
package/lib/sigstore.js CHANGED
@@ -516,14 +516,54 @@ function _fulcioExtValue(ext, leafArc) {
516
516
  return asn1.read.string(asn1.decode(ext.value));
517
517
  }
518
518
 
519
+ // The fields an identity policy may constrain. Named once so the check, the refusal of an
520
+ // unknown key, and the report of what ran cannot drift apart.
521
+ var IDENTITY_FIELDS = ["san", "issuer", "sourceRepositoryURI"];
522
+ // The guard tests membership with hasOwnProperty, so the permitted set is a lookup object --
523
+ // an array would treat "0"/"1" as the known keys and reject every real field name.
524
+ var IDENTITY_KEYS = { san: 1, issuer: 1, sourceRepositoryURI: 1 };
525
+
526
+ // Returns WHICH fields were actually compared. A bundle verifies its own signature and log
527
+ // inclusion whoever signed it -- Fulcio issues to anyone who completes an OIDC flow -- so
528
+ // `verified: true` without an identity policy says the artifact was signed and logged, not that
529
+ // a trusted party signed it. The caller cannot tell those apart from a bare boolean.
519
530
  function _checkIdentity(id, policy) {
520
- if (!policy) return;
531
+ var ran = { san: false, issuer: false, sourceRepositoryURI: false };
532
+ if (policy === undefined || policy === null) return ran;
533
+ if (typeof policy !== "object" || Array.isArray(policy)) {
534
+ throw _err("sigstore/bad-input", "opts.identity must be an object naming the identity fields to pin (" + IDENTITY_FIELDS.join(", ") + ")");
535
+ }
536
+ // An unknown key is refused, not ignored: cosign spells this `certificateIdentity`, and a
537
+ // swallowed spelling checks nothing under a name the operator believes pins the signer.
538
+ // The guard rejects through a (code, message) FACTORY. Handing it the error CLASS raises
539
+ // "class constructor cannot be invoked without new" -- a raw, untyped throw escaping a public
540
+ // verb, on the branch a valid-input test never takes.
541
+ guard.identifier.assertKnownKeys(policy, IDENTITY_KEYS, _err, "sigstore/bad-input", "opts.identity has an unknown key ");
542
+ // A policy that constrains NOTHING is a configuration mistake, and the most dangerous input on
543
+ // this surface: every guard below is falsy, so it accepts every signer while reading as though
544
+ // an identity policy is in force. Refused at the boundary rather than answered.
545
+ var asked = IDENTITY_FIELDS.filter(function (f) { return policy[f] !== undefined; });
546
+ if (!asked.length) {
547
+ throw _err("sigstore/bad-input", "opts.identity constrains nothing -- name at least one of " + IDENTITY_FIELDS.join(", ") + ", or omit it to state that the signer is not being checked");
548
+ }
549
+ // A named field must carry a value that can actually be compared. The comparisons below are
550
+ // truthiness-guarded, so an empty string or a null would be skipped while the field had been
551
+ // named -- reporting a signer check that never ran, which is the exact confusion this report
552
+ // exists to remove. Deciding "asked" and deciding "compared" must be the SAME test, so a value
553
+ // that cannot be compared is refused here rather than quietly becoming "not asked".
554
+ asked.forEach(function (f) {
555
+ if (typeof policy[f] !== "string" || policy[f] === "") {
556
+ throw _err("sigstore/bad-input", "opts.identity." + f + " must be a non-empty string -- a value that cannot be compared would leave the signer unchecked under a policy that names it");
557
+ }
558
+ ran[f] = true;
559
+ });
521
560
  var sanValue = id.san && id.san.value;
522
561
  if (policy.san && sanValue !== policy.san) throw _err("sigstore/identity-mismatch", "the certificate SAN " + JSON.stringify(sanValue) + " does not match the expected identity");
523
562
  // The OIDC issuer is carried by the current Issuer V2 (.1.8) or, on older certs,
524
563
  // only by the deprecated raw-string issuer (.1.1); match against either.
525
564
  if (policy.issuer && policy.issuer !== id.extensions.issuer && policy.issuer !== id.extensions.issuerLegacy) throw _err("sigstore/identity-mismatch", "the certificate OIDC issuer does not match the expected issuer");
526
565
  if (policy.sourceRepositoryURI && id.extensions.sourceRepositoryURI !== policy.sourceRepositoryURI) throw _err("sigstore/identity-mismatch", "the certificate source-repository URI does not match");
566
+ return ran;
527
567
  }
528
568
 
529
569
  // ---- in-toto Statement leg ---------------------------------------------------
@@ -561,14 +601,23 @@ function _statement(payload, payloadType, expectedPredicate) {
561
601
  * the log entry bound to this exact signature; and the in-toto SLSA statement.
562
602
  * Any leg failing throws a typed `sigstore/*` error. On success returns
563
603
  * `{ verified: true, payload, statement, subjects, predicateType, predicate,
564
- * identity, integratedTime }` -- `payload` is the RAW verified envelope bytes
565
- * (never a re-serialization), and the caller confirms a `subjects[].digest`
566
- * matches the published artifact.
604
+ * identity, identityChecked, integratedTime }` -- `payload` is the RAW verified
605
+ * envelope bytes (never a re-serialization), and the caller confirms a
606
+ * `subjects[].digest` matches the published artifact.
607
+ *
608
+ * `verified: true` says the artifact was signed and logged -- not that a party you
609
+ * trust signed it. Fulcio issues a certificate to anyone who completes an OIDC
610
+ * flow, so WHO signed is decided only by `opts.identity`, and `identityChecked`
611
+ * reports which of its fields were compared (`{ san, issuer, sourceRepositoryURI }`,
612
+ * each a boolean). An `identity` naming none of them is refused rather than
613
+ * satisfied, since it would accept every signer while reading as a policy; so is an
614
+ * unrecognized field name, which would otherwise pin nothing under a spelling the
615
+ * operator believes constrains the signer.
567
616
  *
568
617
  * @opts
569
618
  * fulcioRoots: Array, // the Fulcio CA anchors: a DER Buffer or { der, validFor } each
570
619
  * rekorKeys: Array, // [{ keyId, spki, validFor? }] the Rekor log public keys
571
- * identity: object, // optional policy: { san, issuer, sourceRepositoryURI }
620
+ * identity: object, // optional policy: { san, issuer, sourceRepositoryURI }; at least one required when present
572
621
  * predicateType: string, // optional: require this in-toto predicateType (e.g. the SLSA URI)
573
622
  * time: Date, // optional check-date override (default: the Rekor integratedTime)
574
623
  *
@@ -616,7 +665,7 @@ async function verifyBundle(bundle, opts) {
616
665
  var checkTime = (opts.time instanceof Date) ? opts.time.getTime() : C.TIME.seconds(integratedTime);
617
666
  await _verifyChain(leaf, _chainDers(vm), fulcioRoots, checkTime);
618
667
  var identity = _identity(leaf);
619
- _checkIdentity(identity, opts.identity);
668
+ var identityChecked = _checkIdentity(identity, opts.identity);
620
669
 
621
670
  // Leg 4 -- the in-toto SLSA statement + subject binding (with an optional
622
671
  // caller-pinned predicateType).
@@ -630,6 +679,9 @@ async function verifyBundle(bundle, opts) {
630
679
  predicateType: st.predicateType,
631
680
  predicate: st.predicate,
632
681
  identity: identity,
682
+ // Which identity fields were actually compared. `verified` says the artifact was signed and
683
+ // logged; only these say a signer the caller named was the one who signed it.
684
+ identityChecked: identityChecked,
633
685
  integratedTime: integratedTime,
634
686
  };
635
687
  }
package/lib/smime.js CHANGED
@@ -567,7 +567,7 @@ function _capped(msg) {
567
567
 
568
568
  /**
569
569
  * @primitive pki.smime.verify
570
- * @signature pki.smime.verify(message, opts?) -> Promise<{ valid, signers, form, content, micalg, protectedHeaders, headerProtection }>
570
+ * @signature pki.smime.verify(message, opts?) -> Promise<{ valid, trusted, signers, form, content, micalg, protectedHeaders, headerProtection }>
571
571
  * @since 0.2.25
572
572
  * @status stable
573
573
  * @spec RFC 8551, RFC 5652, RFC 9788
@@ -577,9 +577,19 @@ function _capped(msg) {
577
577
  * `application/pkcs7-mime; smime-type=signed-data`. For `multipart/signed` the detached CMS signature
578
578
  * is recomputed over the first part's RFC 8551 sec. 3.1.1 canonical form (the SAME canonicalizer the
579
579
  * signer used); for `application/pkcs7-mime` the base64 body is the attached CMS SignedData. Returns
580
- * `pki.cms.verify`'s `{ valid, signers }` verdict PLUS `form`, the recovered `content` (the signed MIME
581
- * entity bytes), and the `micalg`. Like `cms.verify`, this returns the cryptographic verdict only --
582
- * chaining a signer certificate to a trust anchor is the caller's `pki.path.validate` step. A `micalg`
580
+ * `pki.cms.verify`'s `{ valid, trusted, signers }` verdict PLUS `form`, the recovered `content` (the
581
+ * signed MIME entity bytes), and the `micalg`.
582
+ *
583
+ * `valid` and `trusted` are separate claims, exactly as in `cms.verify`: a SignedData carries its own
584
+ * certificates, so `valid` says the signature is sound under one of them and nothing about who signed.
585
+ * Name the roots you accept in `opts.trustAnchors` and `trusted` says every signer chained to one --
586
+ * validated for EMAIL, at both ends of the chain. The signer certificate must carry the
587
+ * `emailProtection` key purpose (RFC 8551 sec. 4.4.4), because a certificate restricted to `serverAuth`
588
+ * chains to its root perfectly well and is still the wrong key to have signed a message; and the anchor's
589
+ * own trust metadata must permit that purpose, because a root distributed with NSS trust bits can be
590
+ * marked untrusted for email while remaining a good TLS root. Override either with `opts.requiredEku`
591
+ * and `opts.checkPurpose`. Supply no anchors and `trusted` is `false` -- there was nothing to chain to.
592
+ * A `micalg`
583
593
  * that disagrees with the actual digest is advisory unless `opts.strictMicalg` (then `smime/micalg-mismatch`).
584
594
  * If the message is header-protected (RFC 9788), `protectedHeaders` is the AUTHENTICATED inner header set (a
585
595
  * tampered outer header cannot alter it) and `headerProtection` is `{ present, mode, fromMismatch, confidential, legacy }`
@@ -596,6 +606,11 @@ function _capped(msg) {
596
606
  * `protectedHeaders` cannot mistake the opt-in heuristic for authenticated headers.
597
607
  *
598
608
  * @opts certs extra signer certificates (DER `Buffer`s) to match, forwarded to `cms.verify`.
609
+ * @opts trustAnchors the roots you accept, forwarded to `cms.verify`; supplying them is what makes
610
+ * `trusted` answerable. Certificate DER or anchor tuples.
611
+ * @opts time the instant the signer's chain is judged at (default now). Only read with `trustAnchors`.
612
+ * @opts requiredEku key purposes the SIGNER certificate must carry. Defaults to `["emailProtection"]`.
613
+ * @opts checkPurpose the purpose the ANCHOR's own trust metadata must permit. Defaults to `"emailProtection"`.
599
614
  * @opts strictMicalg reject a `multipart/signed` whose `micalg` disagrees with the SignerInfo digest.
600
615
  * @opts legacyHeaderProtection opt in to detecting a LEGACY RFC 8551 header-protected message (RFC 9788 sec. 4.10): a Cryptographic Payload that is a bare `message/rfc822` wrap with no `hp=` parameter. When set, a precisely-identified legacy message surfaces the inner message's headers under `headerProtection.legacy = { headers, mode, fromMismatch, confidential }` -- `headers` an ordered `[{ name, value }]` array (retaining legally-repeated fields such as `Received`), the mode inferred from the envelope (`clear` here) -- NOT under `protectedHeaders`, and `present` stays `false`. Consuming `headerProtection.legacy.headers` is an explicit choice: a legacy message is structurally indistinguishable from an ordinary forwarded `message/rfc822`, so this is a heuristic (RFC 9788 sec. 4.10.2: "not based on any strong end-to-end guarantees") -- cross-check `legacy.fromMismatch`. Anything not precisely identified (a nested crypto layer, an `hp=` on the inner message, a non-`message/rfc822` payload, a duplicate of a singleton field, or a duplicate Content-Type) reports `legacy: null`. Off by default. The signed-and-encrypted form (RFC 9788 Appendix C.3.17) is a documented gap (`legacy: null` at `decrypt`; surfaces as `clear` only via the caller's re-`verify` step) -- the non-recursive layered API exposes no single seam holding both the inner signature verdict and the outer header section.
601
616
  * @example
@@ -611,8 +626,28 @@ async function verify(message, opts) {
611
626
  opts = opts || {};
612
627
  var ent = mime.parse(message, SmimeError, "smime/bad-mime");
613
628
  var ct = ent.contentType;
629
+ // Forwarded, not re-decided here. This verb documents itself as pki.cms.verify's verdict plus the
630
+ // MIME surface, so the trust seam that verb offers has to reach it: building the options from
631
+ // scratch and passing only `certs` would leave a caller naming trust anchors with no way to have
632
+ // them applied, and a `trusted` that read false for want of ever being asked.
614
633
  var vOpts = {};
615
634
  if (opts.certs) vOpts.certs = opts.certs;
635
+ if (opts.trustAnchors != null) {
636
+ vOpts.trustAnchors = opts.trustAnchors;
637
+ // Trusted FOR THIS PURPOSE. A chain alone does not make a signer right for email: a
638
+ // certificate restricted to serverAuth chains to its root perfectly well and is still the
639
+ // wrong key to have signed a message. RFC 8551 sec. 4.4.4 names emailProtection as the purpose
640
+ // an S/MIME signer's certificate must carry, so this verb asks for it rather than accepting the
641
+ // purpose-neutral answer. A caller who means something else says so with `requiredEku`.
642
+ vOpts.requiredEku = opts.requiredEku != null ? opts.requiredEku : ["emailProtection"];
643
+ // Both ends of the chain. The EKU above constrains the LEAF; this selects the anchor's own
644
+ // trust metadata, which pki.path consults only when a purpose is named. A root distributed
645
+ // with NSS trust bits can be marked untrusted for email while remaining a good TLS root, so
646
+ // asking only the leaf would let a root explicitly distrusted for email still answer
647
+ // "trusted" for an email message.
648
+ vOpts.checkPurpose = opts.checkPurpose != null ? opts.checkPurpose : "emailProtection";
649
+ }
650
+ if (opts.time !== undefined) vOpts.time = opts.time;
616
651
  if (_isPkcs7(ct.type, "mime")) {
617
652
  if (ct.params["smime-type"] && ct.params["smime-type"] !== "signed-data") throw _err("smime/unsupported-type", "unsupported smime-type " + JSON.stringify(ct.params["smime-type"]) + " (only signed-data)");
618
653
  var p7m = _decodeCms(ent);
@@ -620,7 +655,7 @@ async function verify(message, opts) {
620
655
  var inner;
621
656
  try { inner = _toBuf(schemaCms.parse(p7m).encapContentInfo.eContent); }
622
657
  catch (e) { throw _err("smime/bad-mime", "the pkcs7-mime SignedData has no encapsulated content", e); }
623
- return Object.assign({ valid: res.valid, signers: res.signers, form: "pkcs7-mime", content: inner, micalg: null }, _hpSurface(inner, ent, "clear", res.valid, opts.legacyHeaderProtection === true));
658
+ return Object.assign({ valid: res.valid, trusted: res.trusted, signers: res.signers, form: "pkcs7-mime", content: inner, micalg: null }, _hpSurface(inner, ent, "clear", res.valid, opts.legacyHeaderProtection === true));
624
659
  }
625
660
  if (ct.type === "multipart/signed") {
626
661
  if (ct.params.protocol && !_isPkcs7(ct.params.protocol, "signature")) throw _err("smime/bad-multipart", "multipart/signed protocol must be application/pkcs7-signature");
@@ -647,7 +682,7 @@ async function verify(message, opts) {
647
682
  if (opts.strictMicalg && micalg && _micalgSet(micalg) !== (_micalgOf(p7s) || "")) {
648
683
  throw _err("smime/micalg-mismatch", "the multipart/signed micalg " + JSON.stringify(micalg) + " disagrees with the SignerInfo digests");
649
684
  }
650
- return Object.assign({ valid: res2.valid, signers: res2.signers, form: "multipart/signed", content: parts[0], micalg: micalg }, _hpSurface(parts[0], ent, "clear", res2.valid, opts.legacyHeaderProtection === true));
685
+ return Object.assign({ valid: res2.valid, trusted: res2.trusted, signers: res2.signers, form: "multipart/signed", content: parts[0], micalg: micalg }, _hpSurface(parts[0], ent, "clear", res2.valid, opts.legacyHeaderProtection === true));
651
686
  }
652
687
  throw _err("smime/unsupported-type", "not a signed S/MIME message (Content-Type " + JSON.stringify(ct.type) + ")");
653
688
  }
package/lib/tsp-sign.js CHANGED
@@ -604,9 +604,19 @@ function _buildTsaChains(leaf, pool) {
604
604
  * res.valid; // boolean; pass opts.trustAnchor to also chain the TSA cert to a root
605
605
  * res.genTime; // Date, read from the verified eContent
606
606
  */
607
+ // Every option pki.tsp.verify reads. Adding one here is the only way to make it accepted.
608
+ var _VERIFY_OPTS = { certs: 1, trustAnchor: 1, nonce: 1, reqPolicy: 1, revocationChecker: 1 };
609
+
607
610
  async function verify(token, data, opts) {
608
611
  opts = opts || {};
609
612
  if (typeof opts !== "object" || Buffer.isBuffer(opts)) throw _err("tsp/bad-input", "pki.tsp.verify options must be an object");
613
+ // An unrecognized option is refused rather than ignored. This verb spells its anchor option
614
+ // SINGULAR -- `trustAnchor`, an anchor tuple -- while pki.cms.verify and pki.cmp.verify spell it
615
+ // `trustAnchors` and take certificate DER. A caller carrying the plural spelling here would
616
+ // otherwise get no anchoring and no error: the TSA certificate unchained, `valid: true`, and
617
+ // nothing to notice it by. Naming the difference at the boundary is the only place it is cheap.
618
+ guard.identifier.assertKnownKeys(opts, _VERIFY_OPTS, _err, "tsp/bad-input",
619
+ "pki.tsp.verify has an unknown option (note the anchor option here is `trustAnchor`, singular, an anchor tuple -- not the `trustAnchors` certificate list pki.cms.verify takes) ");
610
620
  if (opts.certs != null && (!Array.isArray(opts.certs) || !opts.certs.every(function (c) { return Buffer.isBuffer(c) || c instanceof Uint8Array; }))) {
611
621
  throw _err("tsp/bad-input", "pki.tsp.verify opts.certs must be an array of DER certificate Buffers"); // a bad element is a caller error, never silently dropped
612
622
  }
@@ -61,7 +61,12 @@ var ALG_PROFILE = {
61
61
  "-7": { kty: 2, crv: 1 }, "-35": { kty: 2, crv: 2 }, "-36": { kty: 2, crv: 3 },
62
62
  "-9": { kty: 2, crv: 1 }, "-51": { kty: 2, crv: 2 }, "-52": { kty: 2, crv: 3 },
63
63
  "-8": { kty: 1, crv: 6 }, "-19": { kty: 1, crv: 6 }, "-53": { kty: 1, crv: 7 },
64
- "-257": { kty: 3 }, "-258": { kty: 3 }, "-259": { kty: 3 }, "-37": { kty: 3 }, "-65535": { kty: 3 },
64
+ // RSASSA-PSS at all three strengths. PS256 alone left PS384/PS512 refused at PARSE time on a key
65
+ // that is perfectly well-formed -- the same bytes accepted under -37 -- so the refusal blamed the
66
+ // key rather than the algorithm, and a relying party migrating credential rows written by another
67
+ // implementation could not tell which of its stored keys this verifier would decline, or why.
68
+ "-257": { kty: 3 }, "-258": { kty: 3 }, "-259": { kty: 3 },
69
+ "-37": { kty: 3 }, "-38": { kty: 3 }, "-39": { kty: 3 }, "-65535": { kty: 3 },
65
70
  };
66
71
 
67
72
  // credentialKey(node, E, code) -> the decoded + validated credential public key
@@ -72,7 +77,10 @@ var ALG_PROFILE = {
72
77
  // @enforced-by validator-shape-reinlined
73
78
  // @validator-shape kty\s*===\s*2n
74
79
  // @validator-shape EC2_CRV_LEN|ALG_PROFILE
75
- function credentialKey(node, E, code) {
80
+ // `unsupportedCode` is OPTIONAL and names the code raised when the key is well-formed but its
81
+ // algorithm is not one this verifier implements -- a different fact from a malformed key. Omit it
82
+ // and that case keeps raising `code`, so an existing caller sees no change.
83
+ function credentialKey(node, E, code, unsupportedCode) {
76
84
  function bad(msg, cause) { return new E(code, msg, cause); }
77
85
  if (!node || node.majorType !== 5) throw bad("a COSE_Key must be a CBOR map (RFC 9052 sec. 7)");
78
86
  // Every parameter read maps a wrong-type cbor/* fault to the caller's domain -- a
@@ -111,7 +119,12 @@ function credentialKey(node, E, code) {
111
119
  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)");
112
120
  // PROFILE: the declared alg must match the key type (and, for EC2, the curve).
113
121
  var prof = ALG_PROFILE[String(key.alg)];
114
- if (!prof) throw bad("unsupported credential key algorithm " + key.alg);
122
+ // An algorithm this verifier does not implement is NOT a malformed key. The key can be perfectly
123
+ // well-formed -- the same bytes may parse under a neighbouring algorithm id -- and a relying
124
+ // party migrating credential rows written elsewhere needs to tell "I cannot check this
125
+ // algorithm" from "these bytes are wrong", since only one of those is fixable by re-registering.
126
+ // Callers that do not distinguish the two pass one code and keep the previous behaviour.
127
+ if (!prof) throw new E(unsupportedCode || code, "unsupported credential key algorithm " + key.alg);
115
128
  if (prof.kty !== key.kty) throw bad("credential key algorithm " + key.alg + " is inconsistent with key type " + key.kty);
116
129
  if (prof.crv != null && prof.crv !== key.crv) throw bad("credential key algorithm " + key.alg + " requires a different curve");
117
130
  // ON-CURVE: import the SPKI so OpenSSL validates the EC point on its curve. An off-curve
package/lib/webauthn.js CHANGED
@@ -73,7 +73,7 @@ function _isInteger(node) { return !!node && !node.constructed && node.tagClass
73
73
  // step rejects the attestation before the signature is evaluated. EdDSA (-8/-19/-53) is
74
74
  // absent by design: a TPM 2.0 AIK never signs with EdDSA, so such an attestation is
75
75
  // correctly refused.
76
- var COSE_ALG_HASH = { "-7": "sha256", "-9": "sha256", "-257": "sha256", "-37": "sha256", "-35": "sha384", "-51": "sha384", "-258": "sha384", "-36": "sha512", "-52": "sha512", "-259": "sha512", "-65535": "sha1" };
76
+ var COSE_ALG_HASH = { "-7": "sha256", "-9": "sha256", "-257": "sha256", "-37": "sha256", "-35": "sha384", "-51": "sha384", "-258": "sha384", "-38": "sha384", "-36": "sha512", "-52": "sha512", "-259": "sha512", "-39": "sha512", "-65535": "sha1" };
77
77
  function _coseAlgHash(alg, E) {
78
78
  var h = COSE_ALG_HASH[String(alg)];
79
79
  if (!h) throw E("webauthn/unsupported-algorithm", "no hash mapping for COSE algorithm " + alg);
@@ -141,7 +141,54 @@ function _parseAuthData(buf, E) {
141
141
  // The complete COSE credential-key conformance rule set (kty/alg/crv/length/canonical/
142
142
  // profile/on-curve) lives in validator-cose, composed here so every credential key
143
143
  // routes through the one home -- never a per-format re-derivation of a partial subset.
144
- function _decodeCoseKey(node) { return validator.cose.credentialKey(node, WebauthnError, "webauthn/bad-cose-key"); }
144
+ function _decodeCoseKey(node) {
145
+ return validator.cose.credentialKey(node, WebauthnError, "webauthn/bad-cose-key", "webauthn/unsupported-algorithm");
146
+ }
147
+
148
+ /**
149
+ * @primitive pki.webauthn.parseCoseKey
150
+ * @signature pki.webauthn.parseCoseKey(bytes) -> object
151
+ * @since 0.5.2
152
+ * @status stable
153
+ * @spec RFC 9052, W3C WebAuthn Level 3 sec. 6.5.1
154
+ * @related pki.webauthn.verify, pki.webauthn.verifyAssertion
155
+ *
156
+ * Decode a bare COSE_Key -- the credential public key a relying party stored at
157
+ * registration -- back into the object `verifyAssertion` takes. `pki.webauthn.verify`
158
+ * returns that object, but the durable form is bytes: the object carries `Buffer`
159
+ * values, so a JSON round trip through a datastore yields
160
+ * `{"type":"Buffer","data":[...]}` rather than the object that went in, and existing
161
+ * credential stores already hold COSE bytes whoever wrote them. Without this the only
162
+ * routes into the decoder were `parseAttestationObject` and `parseAuthenticatorData`,
163
+ * both of which parse a CONTAINING structure -- so recovering a stored key meant
164
+ * fabricating an authenticatorData that never existed.
165
+ *
166
+ * The same validation the attestation path applies: the key type, the algorithm, the
167
+ * curve, and the coordinates are checked, and anything that is not a credential COSE
168
+ * key is refused with `webauthn/bad-cose-key`. `verifyAssertion` accepts either form
169
+ * for `credentialPublicKey`, so calling this first is a convenience rather than a step.
170
+ *
171
+ * @example
172
+ * // requires: `attestationObject` / `clientDataHash` -- what a browser returns from a
173
+ * // registration ceremony
174
+ * var reg = await pki.webauthn.verify(attestationObject, clientDataHash, {});
175
+ * var stored = reg.credentialPublicKeyBytes; // the form a credential row holds
176
+ * // ... at a login months later, read it back:
177
+ * var key = pki.webauthn.parseCoseKey(stored);
178
+ * key.alg; // -> -7 for ES256
179
+ * // verifyAssertion takes either form, so this parse is a convenience, not a step:
180
+ * // pass `stored` straight as its credentialPublicKey.
181
+ */
182
+ function parseCoseKey(bytes) {
183
+ var buf = _snapshotBytes(bytes, "the COSE key");
184
+ if (!Buffer.isBuffer(buf)) {
185
+ throw _err("webauthn/bad-input", "parseCoseKey takes the stored COSE key bytes (a Buffer, TypedArray or ArrayBuffer)");
186
+ }
187
+ var node;
188
+ try { node = cbor.decode(buf); }
189
+ catch (e) { throw _err("webauthn/bad-cose-key", "the stored credential key is not decodable CBOR", e); }
190
+ return _decodeCoseKey(node);
191
+ }
145
192
 
146
193
  // ---- signature verification bridge ------------------------------------------
147
194
 
@@ -165,7 +212,11 @@ var COSE_ALG = {
165
212
  "-257": { imp: { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, verify: { name: "RSASSA-PKCS1-v1_5" }, ecdsa: 0 },
166
213
  "-258": { imp: { name: "RSASSA-PKCS1-v1_5", hash: "SHA-384" }, verify: { name: "RSASSA-PKCS1-v1_5" }, ecdsa: 0 },
167
214
  "-259": { imp: { name: "RSASSA-PKCS1-v1_5", hash: "SHA-512" }, verify: { name: "RSASSA-PKCS1-v1_5" }, ecdsa: 0 },
215
+ // RSASSA-PSS. The salt length is the hash length, the profile WebCrypto verifies and the one
216
+ // RFC 8230 sec. 2 fixes for the COSE PS* identifiers -- 32 / 48 / 64 bytes for SHA-256/384/512.
168
217
  "-37": { imp: { name: "RSA-PSS", hash: "SHA-256" }, verify: { name: "RSA-PSS", saltLength: 32 }, ecdsa: 0 },
218
+ "-38": { imp: { name: "RSA-PSS", hash: "SHA-384" }, verify: { name: "RSA-PSS", saltLength: 48 }, ecdsa: 0 },
219
+ "-39": { imp: { name: "RSA-PSS", hash: "SHA-512" }, verify: { name: "RSA-PSS", saltLength: 64 }, ecdsa: 0 },
169
220
  // RS1 (RSASSA-PKCS1-v1_5 / SHA-1): a legacy COSE algorithm real Windows Hello TPM
170
221
  // authenticators emit in their attestation statement. VERIFY-only support -- the
171
222
  // toolkit never signs with SHA-1; it must still evaluate the attestations that
@@ -1139,6 +1190,11 @@ function _result(fmt, attestationType, chain, att) {
1139
1190
  aaguid: att.authData.aaguid,
1140
1191
  credentialId: att.authData.credentialId,
1141
1192
  credentialPublicKey: att.authData.credentialPublicKey,
1193
+ // The same key in the form that SURVIVES STORAGE. The decoded object carries Buffers, so a JSON
1194
+ // round trip through a datastore returns {"type":"Buffer","data":[...]} rather than what went
1195
+ // in; the COSE bytes are what a credential row actually holds. Returning only the object left
1196
+ // the caller re-parsing the attestation object to recover bytes this call had already isolated.
1197
+ credentialPublicKeyBytes: att.authData.credentialPublicKeyBytes,
1142
1198
  signCount: att.authData.signCount,
1143
1199
  flags: att.authData.flags,
1144
1200
  };
@@ -1176,7 +1232,7 @@ function _checkAndroidKeyDescription(cert, clientDataHash) {
1176
1232
 
1177
1233
  /**
1178
1234
  * @primitive pki.webauthn.verify
1179
- * @signature pki.webauthn.verify(attestationObject, clientDataHash, opts) -> Promise<{ attestationVerified, fmt, attestationType, trustPath, anchoredTo, anchoredElements, aaguid, credentialId, credentialPublicKey, signCount, flags, bindingChecked }>
1235
+ * @signature pki.webauthn.verify(attestationObject, clientDataHash, opts) -> Promise<{ attestationVerified, fmt, attestationType, trustPath, anchoredTo, anchoredElements, aaguid, credentialId, credentialPublicKey, credentialPublicKeyBytes, signCount, flags, bindingChecked }>
1180
1236
  * @since 0.2.5
1181
1237
  * @status stable
1182
1238
  * @spec W3C WebAuthn Level 3 sec. 8 / sec. 7.1
@@ -1201,7 +1257,12 @@ function _checkAndroidKeyDescription(cert, clientDataHash) {
1201
1257
  * them against the state only the relying party has.
1202
1258
  *
1203
1259
  * The verdict also carries what a relying party must STORE to run a later login:
1204
- * `credentialId`, `credentialPublicKey` and the initial `signCount`.
1260
+ * `credentialId`, `credentialPublicKey` and the initial `signCount`. The credential key
1261
+ * comes back in both forms: the decoded object, and `credentialPublicKeyBytes`, which is
1262
+ * what a credential row should hold -- the object carries `Buffer` values, so a JSON round
1263
+ * trip through a datastore returns `{"type":"Buffer","data":[...]}` rather than the object
1264
+ * that went in. `pki.webauthn.parseCoseKey` reads those bytes back, and
1265
+ * `verifyAssertion` accepts either form.
1205
1266
  *
1206
1267
  * @intro This verifies the attestation STATEMENT -- the signature and the format's
1207
1268
  * structural bindings (the x5c leaf key == credential key, the apple nonce, the tpm
@@ -1937,7 +1998,15 @@ function _snapshotAssertion(input) {
1937
1998
  out[f] = guard.bytes.snapshotSource(out[f], WebauthnError, "webauthn/bad-input", f);
1938
1999
  }
1939
2000
  });
1940
- if (_isPlainObject(out.credentialPublicKey)) {
2001
+ // The BYTES form first. A Buffer satisfies the plain-object test below, so leaving it to that
2002
+ // branch copies its numeric indices into a `{0:.., 1:..}` object that is no longer a key at all
2003
+ // -- the stored credential silently becoming something the SPKI builder cannot read. It is
2004
+ // snapshotted here for the same reason the other byte inputs are: it is read after a yield.
2005
+ if (Buffer.isBuffer(out.credentialPublicKey) || ArrayBuffer.isView(out.credentialPublicKey) ||
2006
+ out.credentialPublicKey instanceof ArrayBuffer) {
2007
+ out.credentialPublicKey = guard.bytes.snapshotSource(out.credentialPublicKey, WebauthnError,
2008
+ "webauthn/bad-input", "credentialPublicKey");
2009
+ } else if (_isPlainObject(out.credentialPublicKey)) {
1941
2010
  var key = {}, kk;
1942
2011
  for (kk in out.credentialPublicKey) {
1943
2012
  if (!Object.prototype.hasOwnProperty.call(out.credentialPublicKey, kk)) continue;
@@ -2000,9 +2069,16 @@ function verifyAssertion(input) {
2000
2069
  }
2001
2070
  clientDataHash = Buffer.from(input.clientDataHash);
2002
2071
  }
2072
+ // Either form a relying party can be holding. `verify` hands back the parsed object, but the
2073
+ // durable form is BYTES: the object carries Buffers, so a JSON round trip through a datastore
2074
+ // returns {"type":"Buffer","data":[...]} rather than what went in, and every existing credential
2075
+ // store already holds the COSE bytes. Accepting only the object made a caller fabricate an
2076
+ // authenticatorData that never existed just to reach their own key.
2003
2077
  var coseKey = input.credentialPublicKey;
2004
- if (!_isPlainObject(coseKey)) {
2005
- throw _err("webauthn/bad-input", "credentialPublicKey must be the stored COSE key object");
2078
+ if (Buffer.isBuffer(coseKey) || ArrayBuffer.isView(coseKey) || coseKey instanceof ArrayBuffer) {
2079
+ coseKey = parseCoseKey(coseKey);
2080
+ } else if (!_isPlainObject(coseKey)) {
2081
+ throw _err("webauthn/bad-input", "credentialPublicKey must be the stored COSE key -- the object pki.webauthn.verify returned, or its COSE bytes");
2006
2082
  }
2007
2083
  var bindingChecked = _applyBindings(authData, coseKey, input);
2008
2084
  // The counter's SHAPE is a config-time question and is answered here; whether it
@@ -2057,6 +2133,7 @@ module.exports = {
2057
2133
  parseAttestationObject: parseAttestationObject,
2058
2134
  parseAuthenticatorData: parseAuthenticatorData,
2059
2135
  parseClientData: parseClientData,
2136
+ parseCoseKey: parseCoseKey,
2060
2137
  verify: verify,
2061
2138
  verifyAssertion: verifyAssertion,
2062
2139
  verifyMetadataBlob: mds.verifyMetadataBlob,
package/lib/webcrypto.js CHANGED
@@ -1097,7 +1097,17 @@ SubtleCrypto.prototype.importKey = async function importKey(format, keyData, alg
1097
1097
  var a2 = (name === "HMAC") ? { name: name, hash: _hashObj(alg.hash, "importKey jwk HMAC"), length: kbuf.length * 8 } : { name: name, length: kbuf.length * 8 };
1098
1098
  return new CryptoKey("secret", extractable, a2, usages, s2);
1099
1099
  }
1100
- var isPrivate = Object.prototype.hasOwnProperty.call(jwk, "d");
1100
+ // The private half is named by the KEY TYPE, not by one spelling. EC and OKP carry it in `d`;
1101
+ // an AKP JWK -- how ML-DSA, ML-KEM and SLH-DSA are represented -- carries it in `priv`.
1102
+ // Testing `d` alone reads every PQC private JWK as public, so a re-import yields a public key
1103
+ // that still announces `usages: ["sign"]` and forces `extractable` true whatever the caller
1104
+ // asked, silently dropping the half that signs.
1105
+ // `priv` is read ONLY for an AKP key, the type that defines it. RFC 7517 sec. 4 requires an
1106
+ // unrecognized member to be ignored, so a member of that name on an EC or OKP JWK is an
1107
+ // extension this implementation has no meaning for -- reading it as private material there
1108
+ // would turn a valid public-key import into a failure.
1109
+ var isPrivate = Object.prototype.hasOwnProperty.call(jwk, "d") ||
1110
+ (jwk.kty === "AKP" && Object.prototype.hasOwnProperty.call(jwk, "priv"));
1101
1111
  var ko = _nodeKey(function () { return isPrivate ? nodeCrypto.createPrivateKey({ key: jwk, format: "jwk" }) : nodeCrypto.createPublicKey({ key: jwk, format: "jwk" }); }, "importKey jwk");
1102
1112
  return new CryptoKey(isPrivate ? "private" : "public", isPrivate ? extractable : true, _algFromImport(name, alg, ko), usages, ko);
1103
1113
  }
@@ -1187,6 +1197,17 @@ function _curveFromKey(ko) {
1187
1197
  * (either), or `raw` (symmetric, or an uncompressed EC / OKP public
1188
1198
  * point). Throws unless the key was created `extractable`.
1189
1199
  *
1200
+ * `raw` is defined for public and secret keys only -- asking for it on a private
1201
+ * key throws `webcrypto/not-supported` rather than answering with the public half.
1202
+ * This matters through `wrapKey`, which forwards the caller's format here: wrapping
1203
+ * a private key as `raw` would otherwise escrow the public key, and unwrapping it
1204
+ * returns a handle announcing `usages: ["sign"]` that cannot sign, with the private
1205
+ * key gone. Use `pkcs8` or `jwk` to serialize a private key.
1206
+ *
1207
+ * A private `jwk` round-trips as a private key for every algorithm, ML-DSA, ML-KEM
1208
+ * and SLH-DSA included: those are `kty: "AKP"` and carry the private half in `priv`
1209
+ * rather than the `d` an EC or OKP key uses.
1210
+ *
1190
1211
  * @example
1191
1212
  * var keyPair = await pki.webcrypto.subtle.generateKey({ name: "Ed25519" }, true, ["sign", "verify"]);
1192
1213
  * var spki = await pki.webcrypto.subtle.exportKey("spki", keyPair.publicKey);
@@ -1206,7 +1227,19 @@ SubtleCrypto.prototype.exportKey = async function exportKey(format, key) {
1206
1227
  }
1207
1228
  if (format === "spki") return _toArrayBuffer(key._handle.export({ format: "der", type: "spki" }));
1208
1229
  if (format === "pkcs8") return _toArrayBuffer(key._handle.export({ format: "der", type: "pkcs8" }));
1209
- if (format === "raw") return _toArrayBuffer(_rawPublic(key));
1230
+ if (format === "raw") {
1231
+ // "raw" is defined for PUBLIC and secret keys; there is no raw private-key serialization for
1232
+ // EC or OKP. Answering a private-key request with the public half hands back the opposite of
1233
+ // what was asked for, with nothing to notice it by -- and `wrapKey` forwards the caller's
1234
+ // format straight here, so a private key wrapped as "raw" escrows the PUBLIC key. Unwrapping
1235
+ // that yields a handle announcing it can sign, which cannot, and the private key is gone.
1236
+ if (key.type !== "public") {
1237
+ throw new WebCryptoError("webcrypto/not-supported",
1238
+ "exportKey: 'raw' is defined for public and secret keys only -- a " + key.type +
1239
+ " key has no raw serialization; use 'pkcs8' or 'jwk'");
1240
+ }
1241
+ return _toArrayBuffer(_rawPublic(key));
1242
+ }
1210
1243
  throw new WebCryptoError("webcrypto/not-supported", "exportKey: unsupported format " + JSON.stringify(format));
1211
1244
  };
1212
1245