@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.
- package/CHANGELOG.md +34 -0
- package/README.md +4 -4
- package/index.js +5 -1
- package/lib/cms-verify.js +240 -6
- package/lib/jose.js +49 -6
- package/lib/path-validate.js +72 -36
- package/lib/sigstore.js +58 -6
- package/lib/smime.js +41 -6
- package/lib/tsp-sign.js +10 -0
- package/lib/validator-cose.js +16 -3
- package/lib/webauthn.js +84 -7
- package/lib/webcrypto.js +35 -2
- package/package.json +2 -2
- package/sbom.cdx.json +6 -6
package/lib/path-validate.js
CHANGED
|
@@ -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
|
|
1191
|
-
|
|
1192
|
-
|
|
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 = (
|
|
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
|
-
|
|
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
|
|
565
|
-
* (never a re-serialization), and the caller confirms a
|
|
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
|
|
581
|
-
* entity bytes), and the `micalg`.
|
|
582
|
-
*
|
|
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
|
}
|
package/lib/validator-cose.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|
|
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
|
-
|
|
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 (
|
|
2005
|
-
|
|
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
|
-
|
|
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")
|
|
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
|
|