@blamejs/pki 0.5.16 → 0.5.18
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 +57 -1
- package/lib/acme.js +7 -4
- package/lib/asn1-der.js +1 -0
- package/lib/attrcert-sign.js +1 -0
- package/lib/cbor-det.js +1 -0
- package/lib/cmc-verify.js +1 -0
- package/lib/cmp-build.js +16 -1
- package/lib/cms-decrypt.js +58 -29
- package/lib/cms-encrypt.js +200 -38
- package/lib/cms-sign.js +18 -4
- package/lib/cms-verify.js +5 -1
- package/lib/constants.js +1 -0
- package/lib/crl-sign.js +62 -2
- package/lib/crmf-sign.js +1 -0
- package/lib/csr-sign.js +1 -0
- package/lib/ct.js +1 -0
- package/lib/est.js +1 -0
- package/lib/framework-error.js +1 -0
- package/lib/guard-secret.js +45 -1
- package/lib/hpke.js +6 -2
- package/lib/http-transport.js +1 -0
- package/lib/inspect.js +1 -0
- package/lib/jose.js +31 -3
- package/lib/key.js +2 -1
- package/lib/lint.js +1 -0
- package/lib/merkle.js +1 -0
- package/lib/ocsp.js +1 -0
- package/lib/oid.js +1 -0
- package/lib/path-validate.js +1 -0
- package/lib/pbes2.js +16 -8
- package/lib/pkcs12-build.js +203 -12
- package/lib/schema-all.js +1 -0
- package/lib/schema-c509.js +1 -0
- package/lib/schema-csrattrs.js +1 -0
- package/lib/schema-engine.js +1 -0
- package/lib/schema-pkcs8.js +1 -0
- package/lib/schema-smime.js +1 -0
- package/lib/schema-tsp.js +1 -0
- package/lib/schema-x509.js +1 -0
- package/lib/shbs.js +1 -0
- package/lib/sigstore.js +1 -0
- package/lib/smime.js +1 -0
- package/lib/tls-cert-compress.js +1 -0
- package/lib/trust.js +1 -0
- package/lib/tsp-sign.js +65 -0
- package/lib/webauthn.js +1 -0
- package/lib/webcrypto.js +11 -8
- package/lib/x509-sign.js +38 -0
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -4,7 +4,63 @@ All notable changes to `@blamejs/pki` are documented here. The format
|
|
|
4
4
|
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this
|
|
5
5
|
project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
-
## v0.5.
|
|
7
|
+
## v0.5.18 — 2026-08-20
|
|
8
|
+
|
|
9
|
+
Every producing verb refuses an authoring field it does not read, so a misspelling is a refusal at the call instead of a signed artifact that quietly omits what was asked for.
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- The issuer argument of `pki.x509.sign` and `pki.crl.sign` accepts only the fields the form it selects reads: `{ key, cert }` when an issuing certificate is supplied, `{ key, name, publicKey }` otherwise. A caller who hands over a key-store object carrying anything else must narrow it to one of the two. The check is there because a misspelled `cert` is not otherwise refused on this argument -- the issuing certificate is optional, so a dropped one falls through to self-signing, and a cross-certificate issued that way names its own subject as its issuer. Supplying both forms is refused for the same reason: the certificate branch wins unconditionally and reads neither `name` nor `publicKey`, so a caller who named a different issuer got one issued under the certificate's own distinguished name.
|
|
14
|
+
- The TSA argument of `pki.tsp.sign` accepts only the certificate and key it reads. A misspelling of either was always refused for what it left missing, but that is half the class: a name belonging on the third argument, written on the TSA instead, is read by nothing there. `ordering` placed on the TSA emitted a token the same size as one that never asked for it, with the flag unset, while the caller believed they had set it. A caller passing a key-store object whole must narrow it to `{ cert, key }`.
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- `pki.x509.sign` refuses an unrecognized field on the certificate spec, the issuer and the options. `extension` in place of `extensions` signed a certificate carrying NONE of them: a caller asking for `basicConstraints: { cA: true }` and `keyUsage` got neither, and nothing in the returned certificate said so. An `issuer` nested inside the spec is refused by name as well -- the issuer is the second argument, and nesting it produced a self-signed certificate with no error, which reads as a broken signer rather than a mis-shaped call.
|
|
19
|
+
- `pki.crl.sign` refuses an unrecognized field on the CRL spec, the issuer and the options. The revoked list is `revoked`; the parser reports the same list as `revokedCertificates`, so a caller round-tripping a parsed CRL back into signing reached for that name and got a correctly signed, structurally valid CRL asserting that nothing is revoked. The refusal names the field the producer reads.
|
|
20
|
+
- `pki.pkcs12.build` refuses an unrecognized field on the store spec and the options, and no longer accepts an option belonging to `open` or `verifyMac`. A store that silently omits a certificate or a key looks well formed and opens cleanly; the omission surfaces wherever it is later imported.
|
|
21
|
+
- `pki.pkcs12.build` refuses an unrecognized field on a `safeContents` entry, on a bag, and on the PBE descriptor either of them carries. The safeContents entry is where the privacy directive lives, so a misspelling there was the costliest in the format: `encrypt` misspelled is neither present nor falsy, so it passed the guard that rejects a present-but-falsy directive, no privacy was selected, and that safe went out as plaintext `id-data` -- an unshrouded private-key bag in the clear, inside a PFX whose MAC still verified and which opened without complaint. A misspelled PBE parameter, on either the safe or the bag, silently reverted to the built-in default, so a caller who asked for a stronger KDF or a different cipher got neither. `opts.mac` is checked the same way, and every one of its fields has a default too: a misspelled `iterations` produced a store MAC'd at the shipped iteration count while the caller believed they had raised it, and an array passed as `opts.mac` carried no field any table lists, so it reached the builder as an empty descriptor and produced the default MAC.
|
|
22
|
+
- `pki.pkcs12.build` refuses a spec that mixes its two forms. `safeContents` and the `{ key, cert, ca }` shorthand are alternatives, and the builder returns `safeContents` the moment it is present without ever reading the shorthand fields -- so `{ safeContents: [...], key }` produced a store with the key silently absent.
|
|
23
|
+
- A `safeContents` entry is checked against the privacy branch it selects. `contentEncryptionAlgorithm` is the public-key branch's content cipher and is read by nothing on a password safe, whose cipher comes from `encrypt.cipher`, so an explicit `aes-128-cbc` request on a password safe was accepted and the default used anyway.
|
|
24
|
+
- A certificate recipient of `pki.cms.encrypt` is checked against the fields ITS CERTIFICATE'S KEY ALGORITHM reads. The arms differ: RSA reads `oaepHash` and never `ukm`; ECDH, X25519, X448 and ML-KEM read `ukm` and never `oaepHash`. One table across all three accepted `oaepHash` on an elliptic-curve recipient, where nothing reads it, and wrapped the content key under the arm's own defaults.
|
|
25
|
+
- A bag is checked against the fields ITS OWN TYPE reads, rather than against one table covering every type. `encrypt` is real on a `shroudedKey` bag and read by nothing on a plaintext `key` bag, so a union accepted the combination and emitted the private key as an unencrypted key bag while the caller had supplied an explicit encryption directive and the surrounding MAC gave integrity only.
|
|
26
|
+
- `pki.crl.sign` refuses an unrecognized field on a revoked-certificate entry. Every optional field on an entry is encoded only when present, so `reson` in place of `reason` listed the certificate as revoked carrying no reason code at all -- in a CRL that verifies, where a relying party cannot distinguish it from a revocation published without a reason.
|
|
27
|
+
- `pki.cms.encrypt` refuses an unrecognized field on a recipient descriptor, against a table per recipient type. The fields beyond the selector all have defaults, so `oaepHsh` encrypted the content key under the default SHA-256 OAEP, and a misspelled `iterations`, `salt` or `prf` on a password recipient derived the key at the default work factor. The single descriptor an `EncryptedData` takes is checked per arm as well: its `cek` branch derives nothing, so a work factor handed to it was discarded exactly as a misspelling would be.
|
|
28
|
+
- `pki.tsp.sign` refuses an unrecognized field on the `accuracy` descriptor. Its three fields are all optional, so `milis` emitted an empty Accuracy that reads back as zero seconds, millis and micros, and the token understated the precision the caller asked it to claim.
|
|
29
|
+
- `pki.cms.encrypt` and `pki.cms.authenticate` each refuse an unrecognized option, against separate tables. The two verbs have different option sets, so a single shared table would have accepted either verb's keys and defeated the check.
|
|
30
|
+
- `pki.tsp.sign` refuses an unrecognized field on the message imprint and the options. A one-letter slip -- `odering` for `ordering` -- emitted a token with the ordering flag unset, which is a claim about the timestamp the caller believed they had made.
|
|
31
|
+
- `pki.pkcs12.open` and `pki.pkcs12.verifyMac` each refuse an unrecognized option, against their own tables. `maxIterations` caps the work a hostile store can demand of the key derivation, so a misspelling restored the built-in ceiling and the tighter bound the caller set was never applied.
|
|
32
|
+
- `pki.tsp.request` refuses an unrecognized field on the message imprint as well as on the options -- the same table `pki.tsp.sign` holds its imprint to. The request's own options are the second argument, so `nonce` written on the imprint reached nothing and the request went out with no nonce.
|
|
33
|
+
- `pki.tsp.request` refuses an unrecognized option. Every field it encodes is optional in the request structure, so a misspelled one was dropped without complaint: `nonce` misspelled put a request on the wire carrying no nonce at all, and the client then matched the reply against a replay defense it had never actually asked for.
|
|
34
|
+
- `pki.tsp.response` refuses an unrecognized option. `status` carries the verdict and defaults to granted, so a misspelling did not lose a detail -- it inverted the answer, and a TSA declining a request emitted a granted response over a token it meant to withhold.
|
|
35
|
+
- The options of `pki.pkcs12.build` are checked against the spec form in force. `recipientCerts` selects public-key privacy only where the builder assembles the safes itself, so under the `safeContents` form it selected nothing: a plaintext key bag went into an `id-data` safe with the private key in the clear, while the caller had asked for recipient-enveloped privacy. The full form carries privacy per entry, as `safeContents[i].recipients`.
|
|
36
|
+
- `pki.cms.encrypt` and `pki.cms.authenticate` refuse a per-recipient default that no recipient of the message reads. `oaepHash` is read only by an RSA certificate recipient, `ukm` only by a key-agreement or KEM certificate recipient, and `keyIdentifier` only by a certificate recipient -- so a password-only message given an `oaepHash` described a wrapping it never performed. A recipient that supplies its own value reads that one and never the default, so a default every recipient overrides is refused too; one recipient that does read it is enough. An `EncryptedData` carries no recipients at all and so reads none of the three.
|
|
37
|
+
- `pki.cmp.build` refuses a signature parameter under MAC protection. `pss` and `digestAlgorithm` are read only by the `{ key, cert }` form; supplied alongside `mac`, they emitted a message MAC'd under the default PRF, byte for byte identical to the call that never named them.
|
|
38
|
+
- `pki.jose.sign` refuses `opts.jwk` when the protected header already embeds one. The embedded key wins, so the option is read in kid mode only, and a caller who named a different public JWK beside an embedded one had it discarded.
|
|
39
|
+
- `pki.jose.sign` serializes the protected header once and every check reads back those exact bytes, through the same bounded reader a received JWS goes through. The header is a caller-owned object, so a `toJSON` or an accessor could answer the public-only check and the serializer differently: a JWK whose own members carried no private half but which serialized `d` passed the check and shipped the private key in the header. Serializing runs that code, so the option check now runs before it -- a `toJSON` reaching into the options bag could otherwise delete a misspelled option before the door looked at it. A header that cannot be serialized is refused rather than reaching the signer.
|
|
40
|
+
- `pki.pkcs12.build` decides which of the two spec forms it has once, and the field check and the builder both act on that decision. Held separately the two tests disagreed on a `safeContents` that is present but not a list, so the refusal named a field that was correct instead of the one that was wrong.
|
|
41
|
+
- `pki.cms.encrypt` refuses `authAttrs` under a CBC content algorithm. Authenticated attributes are carried by an `AuthEnvelopedData`, which an AEAD algorithm produces; a CBC algorithm selects `EnvelopedData`, which has no field for them, so the attributes a caller asked to authenticate were dropped and the message went out without them.
|
|
42
|
+
- `pki.cms.authenticate` refuses `digestAlgorithm` and `authAttrs` when `authenticatedAttributes` is `false`. That branch MACs the content octets directly and builds no attribute set, so the digest algorithm that names its hash, and any attribute added to it, are read by nothing.
|
|
43
|
+
- `pem` on an `EncryptedData` descriptor is refused; it is an option, the third argument, as on every other verb here. A second spelling on the descriptor was read by the password arm alone, so a `{ cek, pem: true }` descriptor was accepted and returned DER.
|
|
44
|
+
- `pki.crl.sign` refuses `spec.issuer` when the issuer argument already names one. The distinguished name has three possible sources and `issuer.cert` and `issuer.name` both win outright, so a spec naming one issuer beside a certificate for another produced a CRL, which verifies, issued under the certificate's name. `spec.issuer` is still the name when the issuer argument supplies none.
|
|
45
|
+
- `opts.mac.keyLength` on a `pki.pkcs12.build` store is refused under the classic `hmac` algorithm, which is the default. The Appendix B derivation produces a key at the hash's own output length and reads no key length, so an explicit one was discarded and the store carried the same MAC as a call that never named it. `keyLength` remains a PBMAC1 parameter (RFC 9579).
|
|
46
|
+
- A `pki.pkcs12.build` public-key integrity signer is checked against the form it selects, `{ cert, key }` or `{ spki, keyIdentifier, key }`, plus the signature parameters. Every field beyond the identity has a default, so a misspelled `pss` or `digestAlgorithm` signed the store under PKCS#1 with SHA-256 while the caller had asked for something else, and a store records only what was used. `opts.integrity.signer` and `opts.integrity.signers` are the same setting spelled two ways and the list won outright, so supplying both signed the store under the list alone; exactly one is now required.
|
|
47
|
+
|
|
48
|
+
## v0.5.17 — 2026-08-20
|
|
49
|
+
|
|
50
|
+
A failed integrity check destroys the plaintext it recovered, AuthEnvelopedData validates the attributes it is asked to authenticate, and every refusal names the rule it is actually applying.
|
|
51
|
+
|
|
52
|
+
### Changed
|
|
53
|
+
|
|
54
|
+
- The refusals for a content cipher in the wrong container now cite the rule they apply. RFC 5083 sec. 2 defines `AuthEnvelopedData` in terms of a content-authenticated-encryption algorithm; it never names GCM, and RFC 5084 sec. 3.1 defines AES-CCM for that content type. Saying the standard required GCM sent an operator to argue with a specification that says the opposite. The check now draws the line the RFC draws -- authenticated versus not -- and an AES-CCM message is declined separately, by name, as an algorithm this toolkit does not implement rather than as an unrecognized OID. Error codes are unchanged.
|
|
55
|
+
- Documentation pages at pkijs.com carry the expanded name of the format they describe in the title and heading, so a page about CMS says Cryptographic Message Syntax rather than three letters that mean a content management system elsewhere. The sitemap now reports each page's real last-changed date, taken from the commit that last touched the source it is generated from, and omits the date where it is unknown instead of reporting the build time. The home page gained runnable examples for the six things people most often arrive wanting to do.
|
|
56
|
+
|
|
57
|
+
### Fixed
|
|
58
|
+
|
|
59
|
+
- A failed AEAD integrity check now destroys the plaintext it recovered. `update` returns the complete recovered plaintext and `final` is what decides whether the message was authentic at all, so on a forged `AuthEnvelopedData` the plaintext existed in full before anything had judged it, and the buffer was then dropped unreferenced and unzeroed. RFC 5083 sec. 1 requires it to be destroyed. The success path leaked too, less visibly: joining the two halves copies them, leaving the first buffer as a second complete copy of the plaintext that the caller never receives and nothing else would ever clear. Both exits are now cleared, at every cipher the toolkit runs -- CMS content decryption and the RFC 3211 password-recipient unwrap, PBES2, HPKE `open`, PKCS#12 safe decryption, and the AES-GCM, AES-CBC, AES-CTR and AES-KW paths of the WebCrypto engine.
|
|
60
|
+
- `pki.cms.encrypt` validates the authenticated attributes a caller supplies for an `AuthEnvelopedData` before it MACs them. Each must be a well-formed `Attribute SEQUENCE { type, non-empty SET OF value }` with no repeated type (RFC 5652 sec. 5.3). `AuthenticatedData` had enforced this all along; `AuthEnvelopedData` accepted whatever it was handed and emitted it, so a malformed attribute reached the wire with the MAC already computed over it and the operator learned of it from a peer's parser rather than from the builder.
|
|
61
|
+
- `pki.cms.encrypt` refuses a `message-digest` attribute in an `AuthEnvelopedData`'s `authAttrs`. Its value is the unencrypted one-way hash of the plaintext, so disclosing it alongside the ciphertext enables content tracking and confirms a guessed plaintext against a message that was encrypted to prevent exactly that (RFC 5083 sec. 2.1 and sec. 5). Decryption still accepts one, since another implementation may legitimately emit it; the toolkit will not produce one. `AuthenticatedData` is unaffected -- it builds and MACs its own `message-digest` by design (RFC 5652 sec. 9.2).
|
|
62
|
+
|
|
63
|
+
## v0.5.16 — 2026-08-19
|
|
8
64
|
|
|
9
65
|
`pki.cmp.verify` reads every option at the call, and every guard takes what it needs from the runtime when it loads, so code a caller runs afterwards cannot change what a verification decides.
|
|
10
66
|
|
package/lib/acme.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* @module pki.acme
|
|
6
6
|
* @nav Protocols
|
|
7
7
|
* @title ACME
|
|
8
|
+
* @fullname ACME (RFC 8555) certificate issuance
|
|
8
9
|
* @order 20
|
|
9
10
|
* @slug acme
|
|
10
11
|
*
|
|
@@ -693,7 +694,9 @@ var _promised = guard.async.deferred;
|
|
|
693
694
|
function _signOuter(o, payloadObj) {
|
|
694
695
|
if (!_isObject(o)) throw E("acme/bad-input", "a request options object is required");
|
|
695
696
|
if (!o.key) throw E("acme/bad-input", "a signing key (opts.key) is required");
|
|
696
|
-
|
|
697
|
+
// The header carries the account jwk in jwk mode and the kid in kid mode, so the public JWK is
|
|
698
|
+
// already there whenever there is one to name; jose.sign reads its own opts.jwk in kid mode only.
|
|
699
|
+
return jose.sign({ protected: _outerHeader(o), payload: _payloadBuf(payloadObj), key: o.key, profile: "acme-outer" });
|
|
697
700
|
}
|
|
698
701
|
|
|
699
702
|
/**
|
|
@@ -777,7 +780,7 @@ function newAccount(o) {
|
|
|
777
780
|
if (!_isObject(o.externalAccountBinding)) throw E("acme/bad-input", "externalAccountBinding must be an EAB inner JWS object");
|
|
778
781
|
payload.externalAccountBinding = o.externalAccountBinding;
|
|
779
782
|
}
|
|
780
|
-
return jose.sign({ protected: { alg: o.alg, nonce: o.nonce, url: o.url, jwk: o.jwk }, payload: _payloadBuf(payload), key: o.key,
|
|
783
|
+
return jose.sign({ protected: { alg: o.alg, nonce: o.nonce, url: o.url, jwk: o.jwk }, payload: _payloadBuf(payload), key: o.key, profile: "acme-outer" });
|
|
781
784
|
});
|
|
782
785
|
}
|
|
783
786
|
|
|
@@ -1122,7 +1125,7 @@ function revokeCert(o) {
|
|
|
1122
1125
|
}
|
|
1123
1126
|
var header = { alg: o.alg, nonce: o.nonce, url: o.url };
|
|
1124
1127
|
if (hasKid) header.kid = o.kid; else header.jwk = o.jwk;
|
|
1125
|
-
return jose.sign({ protected: header, payload: _payloadBuf(payload), key: o.key,
|
|
1128
|
+
return jose.sign({ protected: header, payload: _payloadBuf(payload), key: o.key, profile: "acme-outer" });
|
|
1126
1129
|
});
|
|
1127
1130
|
}
|
|
1128
1131
|
|
|
@@ -1163,7 +1166,7 @@ async function keyChange(o) {
|
|
|
1163
1166
|
var inner = await jose.sign({
|
|
1164
1167
|
protected: { alg: o.newAlg, url: o.url, jwk: o.newJwk },
|
|
1165
1168
|
payload: _payloadBuf({ account: o.account, oldKey: o.oldKey }),
|
|
1166
|
-
key: o.newKey,
|
|
1169
|
+
key: o.newKey, profile: "keychange-inner",
|
|
1167
1170
|
});
|
|
1168
1171
|
return jose.sign({ protected: { alg: o.alg, nonce: o.nonce, url: o.url, kid: o.kid }, payload: _payloadBuf(inner), key: o.key, profile: "acme-outer" });
|
|
1169
1172
|
}
|
package/lib/asn1-der.js
CHANGED
package/lib/attrcert-sign.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* @module pki.attrcert
|
|
7
7
|
* @nav Signing
|
|
8
8
|
* @title Attribute certificates
|
|
9
|
+
* @fullname X.509 attribute certificates (RFC 5755)
|
|
9
10
|
* @intro The RFC 5755 attribute-certificate producing side. `pki.attrcert.sign` builds an
|
|
10
11
|
* `AttributeCertificateInfo` binding a Holder to a set of privilege attributes (role, clearance,
|
|
11
12
|
* group, ...) over a validity window, signs it with an Attribute Authority's private key, and emits an
|
package/lib/cbor-det.js
CHANGED
package/lib/cmc-verify.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* @module pki.cmc
|
|
6
6
|
* @nav Enrollment
|
|
7
7
|
* @title CMC
|
|
8
|
+
* @fullname CMC (Certificate Management over CMS, RFC 5272)
|
|
8
9
|
* @intro Interpret an RFC 5272 Full PKI Response. `verify(response, sent)` takes the response
|
|
9
10
|
* a CA returned and the state the client retained from its request, binds the two together
|
|
10
11
|
* (transaction identifier, the Sender/Recipient Nonce echo, the Data Return echo), reads the
|
package/lib/cmp-build.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* @module pki.cmp
|
|
7
7
|
* @nav Signing
|
|
8
8
|
* @title Certificate management protocol messages
|
|
9
|
+
* @fullname CMP (Certificate Management Protocol, RFC 4210)
|
|
9
10
|
* @intro The RFC 9810 Certificate Management Protocol producing side. `pki.cmp.build` assembles a
|
|
10
11
|
* `PKIMessage` -- a `PKIHeader` (sender / recipient / transaction metadata), a `PKIBody` carrying one
|
|
11
12
|
* request or transaction (an `ir` / `cr` / `kur` certificate request via `pki.crmf.build`, a `p10cr`
|
|
@@ -74,7 +75,13 @@ var BODY_TAG = {
|
|
|
74
75
|
};
|
|
75
76
|
var CRMF_BODY = { ir: 1, cr: 1, kur: 1 }; // arms whose content is a CertReqMessages via pki.crmf.build
|
|
76
77
|
var CERT_REP_ARM = { ip: 1, cp: 1, kup: 1, ccp: 1 }; // arms carrying a CertRepMessage
|
|
78
|
+
// Protection has two mutually exclusive forms, and the signature parameters are read only by the
|
|
79
|
+
// signature one: under PBMAC1 a requested pss or digestAlgorithm selects nothing and the message
|
|
80
|
+
// goes out MAC'd under the default PRF, byte-identical to the call that never named them. The union
|
|
81
|
+
// stays in force where the form is ambiguous, so _resolveProtection keeps its both/neither verdicts.
|
|
77
82
|
var KNOWN_OPTS_KEYS = { key: 1, cert: 1, mac: 1, extraCerts: 1, pem: 1, pss: 1, digestAlgorithm: 1 };
|
|
83
|
+
var KNOWN_OPTS_SIG_KEYS = { key: 1, cert: 1, extraCerts: 1, pem: 1, pss: 1, digestAlgorithm: 1 };
|
|
84
|
+
var KNOWN_OPTS_MAC_KEYS = { mac: 1, extraCerts: 1, pem: 1 };
|
|
78
85
|
var KNOWN_MESSAGE_KEYS = { header: 1, body: 1 };
|
|
79
86
|
var KNOWN_MAC_KEYS = { secret: 1, salt: 1, iterationCount: 1, prf: 1, keyLength: 1, algorithm: 1 };
|
|
80
87
|
|
|
@@ -564,7 +571,15 @@ function _build(message, opts) {
|
|
|
564
571
|
guard.identifier.assertKnownKeys(message, KNOWN_MESSAGE_KEYS, _err, "cmp/bad-input", function (k) {
|
|
565
572
|
return "unknown message field " + JSON.stringify(k) + " (a message carries only header + body)";
|
|
566
573
|
});
|
|
567
|
-
|
|
574
|
+
var sigForm = opts.key != null || opts.cert != null, macForm = opts.mac != null;
|
|
575
|
+
guard.identifier.assertKnownKeys(opts,
|
|
576
|
+
sigForm === macForm ? KNOWN_OPTS_KEYS : sigForm ? KNOWN_OPTS_SIG_KEYS : KNOWN_OPTS_MAC_KEYS,
|
|
577
|
+
_err, "cmp/bad-input", function (k) {
|
|
578
|
+
return "unknown opts field " + JSON.stringify(k) +
|
|
579
|
+
(macForm && (k === "pss" || k === "digestAlgorithm")
|
|
580
|
+
? " for MAC protection; the signature parameters are read only by the { key, cert } form"
|
|
581
|
+
: "");
|
|
582
|
+
});
|
|
568
583
|
if (message.header == null) throw _err("cmp/bad-input", "message.header is required");
|
|
569
584
|
if (message.body == null) throw _err("cmp/bad-input", "message.body is required");
|
|
570
585
|
|
package/lib/cms-decrypt.js
CHANGED
|
@@ -29,6 +29,10 @@ var subtle = webcrypto.webcrypto.subtle;
|
|
|
29
29
|
var CmsError = frameworkError.CmsError;
|
|
30
30
|
var WRAP_KEK_LENGTHS = schemaCms.WRAP_KEK_LENGTHS;
|
|
31
31
|
var KEM_CT_LENGTHS = schemaCms.KEM_CT_LENGTHS;
|
|
32
|
+
// The parser's own AEAD table: the single place that decides whether a content-encryption OID names
|
|
33
|
+
// an authenticated encryption algorithm, so the container-vs-mode rule below cannot drift from the
|
|
34
|
+
// parameter validation that accepted the message.
|
|
35
|
+
var AEAD_ALGS = schemaCms.AEAD_ALGS;
|
|
32
36
|
|
|
33
37
|
function O(n) { return oid.byName(n); }
|
|
34
38
|
function _err(code, message, cause) { return new CmsError(code, message, cause); }
|
|
@@ -397,26 +401,45 @@ async function _kemriCek(ri, km) {
|
|
|
397
401
|
}
|
|
398
402
|
|
|
399
403
|
// The declared cipher MODE must match the container's authentication model: an AEAD cipher belongs in
|
|
400
|
-
// AuthEnvelopedData and a plain CBC cipher in EnvelopedData (RFC 5083 sec. 2.
|
|
401
|
-
//
|
|
402
|
-
//
|
|
403
|
-
//
|
|
404
|
-
//
|
|
405
|
-
//
|
|
404
|
+
// AuthEnvelopedData and a plain CBC cipher in EnvelopedData (RFC 5083 sec. 2). Key length alone does
|
|
405
|
+
// not separate them, so without this an EnvelopedData naming AES-GCM was opened as unauthenticated CBC
|
|
406
|
+
// while the result still reported the AEAD algorithm, telling the caller the content was authenticated
|
|
407
|
+
// when nothing had authenticated it. The mirror case dereferenced AEAD parameters the parser leaves
|
|
408
|
+
// null for a non-AEAD algorithm, faulting instead of failing closed.
|
|
409
|
+
//
|
|
410
|
+
// The line drawn here is AEAD-vs-not, which is the line RFC 5083 draws. GCM-vs-CBC is a narrower
|
|
411
|
+
// line this toolkit draws, because AES-GCM is the only AEAD it implements, and stating the narrower
|
|
412
|
+
// one as though the RFC required it sends an operator to argue with a specification that says the
|
|
413
|
+
// opposite: RFC 5084 sec. 3.1 defines AES-CCM for this content type. So an AEAD reaching the right
|
|
414
|
+
// container passes here even when decryption will later decline it, and it is _openContent that says
|
|
415
|
+
// why, by name. An unresolvable OID is left to _openContent too, as the unsupported algorithm it is.
|
|
406
416
|
function _assertContentCipherMode(eci, ct) {
|
|
407
417
|
var oidStr = eci.contentEncryptionAlgorithm.oid;
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
if (
|
|
411
|
-
|
|
418
|
+
var isAead = guard.intrinsic.hasOwn(AEAD_ALGS, oidStr);
|
|
419
|
+
if (!isAead && !guard.intrinsic.hasOwn(CONTENT_MODE, oidStr)) return;
|
|
420
|
+
if (ct === "authEnvelopedData") {
|
|
421
|
+
if (isAead) return;
|
|
422
|
+
throw _err("cms/unsupported-algorithm", "contentEncryptionAlgorithm " + oidStr + " is not an authenticated encryption algorithm, which authEnvelopedData requires (RFC 5083 sec. 2)");
|
|
412
423
|
}
|
|
424
|
+
if (!isAead) return;
|
|
425
|
+
throw _err("cms/unsupported-algorithm", "contentEncryptionAlgorithm " + oidStr + " is an authenticated encryption algorithm, which belongs in an authEnvelopedData rather than a " + ct + " (RFC 5083 sec. 2)");
|
|
413
426
|
}
|
|
414
427
|
|
|
415
428
|
// ---- stage 3: open the content (uniform failure) ---------------------------
|
|
416
429
|
async function _openContent(parsed, eci, cek, ct) {
|
|
417
430
|
var alg = eci.contentEncryptionAlgorithm;
|
|
418
|
-
var keyBits = CONTENT_KEYBITS[alg.oid];
|
|
419
|
-
if (!keyBits)
|
|
431
|
+
var keyBits = guard.intrinsic.hasOwn(CONTENT_KEYBITS, alg.oid) ? CONTENT_KEYBITS[alg.oid] : 0;
|
|
432
|
+
if (!keyBits) {
|
|
433
|
+
// AES-CCM parses completely here -- registered OIDs, the sec. 3.1 nonce bound, the seven legal
|
|
434
|
+
// ICV lengths -- and is then declined, because AES-GCM is the only AEAD this toolkit implements
|
|
435
|
+
// for content encryption. Saying only "unsupported <oid>" is what an operator also sees for an
|
|
436
|
+
// OID nothing in the toolkit recognizes, and the two call for different things: one is a
|
|
437
|
+
// capability gap to route around, the other is a malformed or exotic message.
|
|
438
|
+
if (guard.intrinsic.hasOwn(AEAD_ALGS, alg.oid) && AEAD_ALGS[alg.oid] === "ccm") {
|
|
439
|
+
throw _err("cms/unsupported-algorithm", "contentEncryptionAlgorithm " + alg.oid + " is AES-CCM. RFC 5084 sec. 3.1 permits it for this content type; this toolkit implements AES-GCM only for content encryption");
|
|
440
|
+
}
|
|
441
|
+
throw _err("cms/unsupported-algorithm", "unsupported contentEncryptionAlgorithm " + alg.oid);
|
|
442
|
+
}
|
|
420
443
|
if (eci.encryptedContent == null) throw _err("cms/no-encrypted-content", "the message has no encryptedContent (detached; supply it out of band)");
|
|
421
444
|
// A null CEK (v1.5 implicit rejection) or a wrong-length CEK -> a fresh random key of the right
|
|
422
445
|
// length, so the failure surfaces here as the uniform verdict, never earlier.
|
|
@@ -435,7 +458,7 @@ async function _openContent(parsed, eci, cek, ct) {
|
|
|
435
458
|
}
|
|
436
459
|
var iv = asn1.read.octetString(asn1.decode(alg.parameters));
|
|
437
460
|
if (iv.length !== 16) throw _fail();
|
|
438
|
-
return pbes2.cbcDecrypt(cek, iv, eci.encryptedContent, keyBits);
|
|
461
|
+
return pbes2.cbcDecrypt(cek, iv, eci.encryptedContent, keyBits, CmsError, "cms/decrypt-failed");
|
|
439
462
|
} catch (e) {
|
|
440
463
|
if (e instanceof CmsError && e.code !== "cms/decrypt-failed") throw e;
|
|
441
464
|
throw _fail();
|
|
@@ -450,7 +473,10 @@ function _gcmOpen(cek, nonce, ct, tag, aad, keyBits, icvLen) {
|
|
|
450
473
|
var d = nodeCrypto.createDecipheriv("aes-" + keyBits + "-gcm", cek, nonce, { authTagLength: icvLen });
|
|
451
474
|
d.setAuthTag(tag);
|
|
452
475
|
if (aad && aad.length) d.setAAD(aad);
|
|
453
|
-
|
|
476
|
+
// final() throws on a tag mismatch, and update() has already produced the whole plaintext by
|
|
477
|
+
// then. RFC 5083 sec. 1 requires a receiver whose integrity check fails to DESTROY the plaintext,
|
|
478
|
+
// not merely withhold it, so the intermediate is cleared on both exits.
|
|
479
|
+
return guard.secret.cipherFinish(d, ct, CmsError, "cms/decrypt-failed", "the recovered content");
|
|
454
480
|
}
|
|
455
481
|
|
|
456
482
|
// ---- EncryptedData (sec. 8) ------------------------------------------------
|
|
@@ -465,7 +491,7 @@ async function _decryptEncryptedData(parsed, km, opts) {
|
|
|
465
491
|
var cek = guard.bytes.view(km.cek, CmsError, "cms/bad-input", "cek");
|
|
466
492
|
if (cek.length !== keyBits / 8) throw _err("cms/bad-input", "the supplied cek length does not match the content algorithm");
|
|
467
493
|
var iv = asn1.read.octetString(asn1.decode(alg.parameters));
|
|
468
|
-
try { return _originFields({ content: pbes2.cbcDecrypt(cek, iv, eci.encryptedContent, keyBits), contentType: eci.contentType, contentTypeName: oid.name(eci.contentType) || eci.contentType, recipientType: "cek", recipientIndex: -1, contentEncryptionAlgorithm: alg.name || alg.oid, authenticated: false }, null, null); }
|
|
494
|
+
try { return _originFields({ content: pbes2.cbcDecrypt(cek, iv, eci.encryptedContent, keyBits, CmsError, "cms/decrypt-failed"), contentType: eci.contentType, contentTypeName: oid.name(eci.contentType) || eci.contentType, recipientType: "cek", recipientIndex: -1, contentEncryptionAlgorithm: alg.name || alg.oid, authenticated: false }, null, null); }
|
|
469
495
|
catch (_e) { throw _fail(); }
|
|
470
496
|
}
|
|
471
497
|
async function _decryptPbes2(parsed, eci, km, opts) {
|
|
@@ -497,7 +523,7 @@ async function _decryptPbes2(parsed, eci, km, opts) {
|
|
|
497
523
|
var key;
|
|
498
524
|
try { key = nodeCrypto.pbkdf2Sync(pwE.bytes, pb.salt, pb.iterations, keyBits / 8, pb.prfNode); }
|
|
499
525
|
finally { if (pwE.owned) guard.secret.zeroize(pwE.bytes, CmsError, "cms/bad-input", "the password encoding"); }
|
|
500
|
-
try { return _originFields({ content: pbes2.cbcDecrypt(key, iv, eci.encryptedContent, keyBits), contentType: eci.contentType, contentTypeName: oid.name(eci.contentType) || eci.contentType, recipientType: "password", recipientIndex: -1, contentEncryptionAlgorithm: oid.name(encOid) || encOid, authenticated: false }, null, null); }
|
|
526
|
+
try { return _originFields({ content: pbes2.cbcDecrypt(key, iv, eci.encryptedContent, keyBits, CmsError, "cms/decrypt-failed"), contentType: eci.contentType, contentTypeName: oid.name(eci.contentType) || eci.contentType, recipientType: "password", recipientIndex: -1, contentEncryptionAlgorithm: oid.name(encOid) || encOid, authenticated: false }, null, null); }
|
|
501
527
|
catch (_e) { throw _fail(); }
|
|
502
528
|
finally { guard.secret.zeroize(key, CmsError, "cms/bad-input", "the password-derived content-encryption key"); }
|
|
503
529
|
}
|
|
@@ -548,27 +574,30 @@ function _pwriUnwrap(kek, wrapped, iv, keyBits) {
|
|
|
548
574
|
// pass2 = CBC(kek, iv2 = pass1[last block], pass1). Recover pass1's last block by decrypting the
|
|
549
575
|
// last ciphertext block (ECB) and XORing the previous ciphertext block, a standard CBC last-
|
|
550
576
|
// block decrypt that needs no IV, then CBC-decrypt pass2 under that recovered iv2 to get pass1.
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
var pass1 =
|
|
557
|
-
var d2 = nodeCrypto.createDecipheriv(alg, kek, iv); d2.setAutoPadding(false);
|
|
558
|
-
// body is the recovered plaintext block: it holds the CEK. The caller receives an independent
|
|
559
|
-
// copy, so the block is cleared on every exit, including the two validation rejects, which are
|
|
560
|
-
// the paths an attacker induces by tampering with the wrapped key.
|
|
561
|
-
var body = Buffer.concat([d2.update(pass1), d2.final()]);
|
|
577
|
+
// Every intermediate below is derived from the KEK and reveals key material, so all three are
|
|
578
|
+
// named before the try and cleared in one finally. pass1 and body hold the wrapped key in the
|
|
579
|
+
// clear; lastDec is pass1's final block, recovered ahead of it. The two validation rejects are
|
|
580
|
+
// inside the same scope because they are the paths an attacker induces by tampering with the
|
|
581
|
+
// wrapped key, and they are where an unswept intermediate would otherwise be abandoned.
|
|
582
|
+
var lastDec = null, pass1 = null, body = null;
|
|
562
583
|
try {
|
|
584
|
+
var ecb = nodeCrypto.createDecipheriv("aes-" + keyBits + "-ecb", kek, Buffer.alloc(0)); ecb.setAutoPadding(false);
|
|
585
|
+
lastDec = guard.secret.cipherFinish(ecb, wrapped.subarray(n - blk), CmsError, "cms/decrypt-failed", "the recovered CBC last block");
|
|
586
|
+
var iv2 = Buffer.alloc(blk);
|
|
587
|
+
for (var i = 0; i < blk; i++) iv2[i] = lastDec[i] ^ wrapped[n - 2 * blk + i];
|
|
588
|
+
var d1 = nodeCrypto.createDecipheriv(alg, kek, iv2); d1.setAutoPadding(false);
|
|
589
|
+
pass1 = guard.secret.cipherFinish(d1, wrapped, CmsError, "cms/decrypt-failed", "the PWRI first-pass plaintext");
|
|
590
|
+
var d2 = nodeCrypto.createDecipheriv(alg, kek, iv); d2.setAutoPadding(false);
|
|
591
|
+
body = guard.secret.cipherFinish(d2, pass1, CmsError, "cms/decrypt-failed", "the PWRI plaintext block");
|
|
563
592
|
var count = body[0];
|
|
564
593
|
if (count < 1 || count + 4 > body.length) throw _fail();
|
|
565
594
|
var cek = body.subarray(4, 4 + count);
|
|
566
595
|
var bad = 0;
|
|
567
596
|
for (var j = 0; j < 3; j++) bad |= (body[1 + j] ^ 0xff) ^ cek[j]; // complement check bytes
|
|
568
597
|
if (bad !== 0) throw _fail();
|
|
569
|
-
return Buffer.from(cek);
|
|
598
|
+
return Buffer.from(cek); // the caller's copy, independent of the block cleared below
|
|
570
599
|
} finally {
|
|
571
|
-
guard.secret.zeroizeAll([body, pass1], CmsError, "cms/bad-input", "the PWRI plaintext block");
|
|
600
|
+
guard.secret.zeroizeAll([body, pass1, lastDec], CmsError, "cms/bad-input", "the PWRI plaintext block");
|
|
572
601
|
}
|
|
573
602
|
}
|
|
574
603
|
// Coverage residual (the unsupported-algorithm throw arm of each lookup below: _hashW3c,
|