Releases: blamejs/pki
Release list
v0.5.16
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.
Fixed
pki.cmp.verifyreduces every option to a value the caller no longer reaches, before verification begins.transactionIDandexpectRecipNonceare copied, so overwriting the buffer that was passed in changes nothing;trustAnchorsandintermediateshave their array copied, so appending an anchor mid-call cannot widen the set the chain is built against; and atimethat is a realDateis re-made at the same instant, so callingsetTimeon it cannot move the point the signer's certificate is validated at. Atimethat is not aDatepasses through untouched, so the input error it already earns is the one it still gets.pki.cmp.verifynow takes its options through the same door every other verb uses, which it had been skipping in favor of two checks of its own. The door refuses an options object whose fields are accessors, and one that changes which fields it carries while they are read. An accessor is caller code running inside the call before the verb has done anything, and from there it can rewrite the very predicates and constructors the verb is about to use, so refusing it closes that whole class rather than one member at a time. What is asked of a caller is only that the options themselves be values.- A
sharedSecretgiven as a string is converted to bytes with aBuffer.fromcaptured when the module loads.Buffer.fromis a writable property of theBufferglobal and the secret is the first option read, so the accessor supplying it could install a replacement, receive the plaintext as that replacement's argument, and have whatever it returned become the PBMAC1 key -- a message authenticated under a secret the caller never held would then verify. The captured reference is fixed before any caller code has run. - A
sharedSecretgiven as a string is converted to bytes once, at the door, and that copy is cleared with the rest. The conversion is what produces a wipeable plaintext copy, and it used to happen deeper inside the MAC path where nothing owned it, so the toolkit left a copy of the secret behind on every call. The string itself is immutable and cannot be cleared, which is still the reason to hand over bytes when the secret must not outlive the call. - Whether a byte option is copied, and whether that copy is later wiped, is decided by asking the value's internal slot rather than its prototype.
Buffer.isBufferis a writable property andinstanceof Uint8ArrayconsultsUint8Array[Symbol.hasInstance], and both are reachable from an option accessor that has already run by the time the test is evaluated. A caller who made those answerfalsefor their own buffer got the copy skipped, which left their live buffer where the copy belongs -- and the wipe then cleared memory the toolkit does not own. - The copies go through
guard.bytes.snapshot, this toolkit's door for caller-supplied bytes, so a source the door refuses stays refused. A view backed by aSharedArrayBufferis the case that matters: another thread can rewrite it at any moment, and copying it withBuffer.fromwould have turned it into an ordinaryBufferthat every later check accepts without the door's rules ever having run. - Each option is read from the caller's object exactly once and copied in the same step, before the next option is read. A property can be an accessor: asked twice it may answer differently, so the value that passed the check would not be the value that was used; and an accessor invoked for a later option can reach back and rewrite the buffer an earlier one handed over, so reading the whole bag before copying any of it would copy whatever the last accessor left behind.
opts.trustAnchorsandopts.intermediatesmust be a plain dense array (or a single certificate). A list with holes, or one whose elements are reachable only through its prototype, is refused withcmp/bad-inputnaming the problem. Reproducing what an ordinary array operation would consume means handling holes, inherited and non-enumerable elements, and the uint32 index boundary, and those pull against each other: reading every position covers inheritance but scans a sparse list's whole length, while enumerating present keys is bounded but silently drops an inherited anchor -- and dropping an anchor turns a trusted verification into an untrusted one. Refusing is the answer this verb gives instead of choosing quietly. No ceiling is imposed on how many anchors a dense list may hold.- Two things stay caller-owned by design, and the release is explicit about them. A parsed certificate passed as an anchor or an intermediate is held by reference, because
pki.schema.x509.parseresults carry their provenance against the object's identity and a copy would stop being recognized as parser output; editing one mid-call changes nothing, since path validation re-derives a parsed certificate from the bytes it recorded. And arevocationCheckeris the caller's own callback, so what it answers is theirs to decide whenever it is called. pki.cmp.verifyrefuses a Proxy-wrapped certificate list before it reads anything from it. A Proxy over an array reports itself as an array while answering from traps that need not agree with one another, so a count taken through them describes nothing that is still true by the time the anchors are used. Refusing a Proxy was already this toolkit's answer for a value it cannot ask honestly; that refusal now runs ahead of the length read and the descriptor walk, which would otherwise be the first thing to hand control back to the caller.- Every guard that performs its work with a method captured at load now invokes that method without reading any property of it. Capturing freezes which function runs, so replacing the prototype method afterwards changes nothing; invoking it as
captured.call(receiver, ...)then readcalloff the captured function, and that function is the one still sitting on the prototype. Assigning an owncallto it shadowsFunction.prototype.calland hands the guard back to the caller with every check around it still passing: the secret wipe clears nothing, two unrelated distinguished names compare equal, a decode returns a string the caller chose, the instant a certificate is validated at becomes whatever the caller says, and a byte view reports a length and a backing store it does not have. - Every operation the guard family performs while it is deciding something is taken from the runtime when the module loads, rather than at the moment it runs. That covers the reflective operations a guard inspects a value with, the predicates that answer what a value IS, the
BufferandPromiseconstructors, the numeric and array tests bounds are built on, and the string operations names and encodings are compared with. It also covers the array operations a guard walks its own lists with, where aforEachreplaced by a no-op made a scan over real keys report nothing and every rule keyed on that scan then passed without examining anything. The whole family is driven with twenty-three of those globals replaced at once and each guard still reaches the answer it would have reached untouched. A check over the source refuses any guard that reads one of these at call time, so the rule holds for a guard written later without anyone remembering it. - Canonicality takes both halves of its round trip at load, and the size cap measures a Buffer through the byte guard's captured length getter. Canonical form is decided by decoding the text and re-encoding the result, so either half settles the answer: a
toStringreturning the input madeABcompare equal to its own re-encoding and pass as canonical base64url, where its canonical form isAA. On a typed arraylengthis a configurable accessor on the prototype, and the cap comparison read it, so one returning 0 admitted a buffer of any size and then decoded it in full. - The
Bufferconstructor that turns a backing store into something the toolkit can read or clear is taken at load. It sits behind the re-view every byte boundary performs, so one returning a buffer over different memory handed each later step a decoy: a wipe zeroed the decoy and returned normally while the plaintext it was aimed at stayed readable, with every check in between satisfied. The same reference now backs the canonical-encoding round trip, where a replacement decides what that comparison sees, and the provenance copy, where it decides whether recorded bytes can still change afterwards. - The index scan behind the accessor refusal asks the array question through a reference taken at load. A replacement answering
falsemade the scan report no indices, so the refusal had nothing to refuse and an accessor under index 0 was reached by the copy that follows it -- the caller's ownErrorescaping a boundary whose contract is a typed refusal, with the accessor free to run before anything it returned was snapshotted. The same reference now backs the array questions in the byte-source, parsed-structure and identifier guards. - The two predicates that decide whether a
pki.cmp.verifyoption gets copied are taken at load rather than at the call. Both are ordinary writable properties, so reading either at call time handed that decision to the caller: anisUint8Arrayansweringfalseleft a live caller buffer where the copy belongs and left that buffer out of the list the call wipes, and anArray.isArrayansweringfalserouted a trust list past the list door and back out by reference, ready to be emptied or appended to while verification was suspended. - The slot predicates a guard asks about a value are snapshotted at load, and so is the check that a
Dateholds a real instant.util.typesis an ordinary object on an ordinary module export, so `util.types.isDat...
v0.5.15
pki.attrcert.verify checks an attribute certificate against the RFC 5755 validation rules, so a consumer reading its privilege attributes is reading ones an issuer actually granted.
Added
pki.attrcert.verify(ac, issuer, opts)performs the checks of RFC 5755 sec. 5 that an attribute certificate and a named issuer can settle between them: the signature over the exactAttributeCertificateInfobytes, through the one path-validation signature engine with its algorithm-confusion and EdDSA low-order-point gates; the AC naming the issuer this verifier trusts; the evaluation instant lying within the validity, where equality with either bound succeeds as the section states; the sec. 4.3.2 targeting rule, where an AC that names targets is refused at a verifier it does not name; and rejection of any critical extension this verb does not process. Section 5 defines support as parsing the value AND rejecting where the value would reject, so an extension parsed but never evaluated is not supported: targeting is processed and an audit identity states no rule that rejects, whileaaControlsandacProxyingcarry constraints this verb does not evaluate, so a critical one is refused, and a critical extension defined in future defaults to refused. Targeting compares each GeneralName form under its own matching rule: a dNSName folds case across the whole name (RFC 5280 sec. 7.2), a mailbox matches its local-part exactly and its host-part case-insensitively (sec. 7.5), and a directoryName goes through the same distinguished-name comparison the rest of the toolkit uses. A URI compares as encoded, since sec. 7.4 makes URI equality a full RFC 3987 normalization that a partial version could get wrong in the accepting direction; a form with no comparison here reports the check as not performed rather than as a pass. The third Target alternative, targetCert, is one sec. 4.3.2 says "MUST NOT be used", so an attribute certificate carrying one is refused before any match is considered, and a matching entry beside it does not rescue it -- the issuer broke a MUST NOT of its own profile, and letting the verdict turn on which entries rode alongside the forbidden one would leave the outcome to whoever assembled the certificate.issueris{ name, publicKey }and is required. Section 5 item 4 makes trusting an AC issuer the verifier's own configuration, so it is an argument this verb refuses to infer from the certificate that wants to be trusted.issuer.nametakes every formpki.attrcert.signaccepts for the issuing authority -- a string, an array of RDNs, or rawNameDER -- and is compared as a distinguished name under RFC 5280 section 7.1, so an attribute certificate issued under a multi-RDN authority name is verifiable under that same name. An empty distinguished name names no issuer and is refused, whichever form it arrives in.- Revocation is RFC 5755 section 6, outside section 5's seven rules, and it gates every pass. This verb implements the section's "never revoke" scheme, which AC users MUST support: it holds no revocation evidence and follows no pointer out of the certificate. The section states what that obliges -- "If only the 'never revoke' scheme is supported, then all ACs that do not contain a noRevAvail extension, MUST be rejected" -- because an issuer that omits
noRevAvailis stating that revocation status checks are supported, so a verdict that skipped one would grant privileges the issuer expected to be able to withdraw. An attribute certificate carryingnoRevAvailverifies and reportsrevocationChecked: truewithnoRevAvail: true. One without it is refused unless the caller passesopts.revocationStatus("notRevoked"or"revoked"), the status they established through the section's "pointer in AC" scheme; the certificate's owncrlDistributionPointsandauthorityInfoAccessreach them on the verdict'sextensionsto follow. The two schemes are alternatives, and section 6 closes by saying so -- "An AC MUST NOT contain both a noRevAvail extension and a 'pointer in AC'" -- so an attribute certificate carrying both tells a verifier two different things about its own revocation and is refused however the caller answers. opts.timeandopts.targetare read at the call, before signature verification suspends it. A verdict assembled from options re-read after the signature settles would answer for whatever the caller last wrote, so a caller could hand in an expired instant, replace it while the signature was being checked, and be told the attribute certificate was timely. Mutating theDatein place has the same effect and is closed the same way: the instant is taken at the call, and the target is decoded there.issuer.publicKeyis copied at the call for the same reason. The signature engine holds that buffer across the suspension, and a composite arm keeps slices of it and imports them only once its digest settles, so a caller could otherwise hand in an unrelated key of the same length, overwrite it with the real issuer's while the digest ran, and be told the certificate verified under the key they named.- The verdict is
{ verified, signatureValid, validityChecked, targetingChecked, revocationChecked, noRevAvail, holderBindingChecked, issuerPathChecked, holder, issuer, attributes, extensions, notBefore, notAfter, serialNumberHex, reason }, its fields re-derived from the signed bytes. The two checks needing certificates this verb is not given -- the holder's own certificate and chain, and the AC issuer's chain and sec. 4.5 profile -- reportfalsein their own slots rather than leaving their absence to read as a pass; run those throughpki.path.validatewith the certificates you hold. Omittingopts.timeleaves the validity question unasked, which reportsvalidityChecked: falseand neververified: true.
Changed
pki.attrcert.signrefuses to issue an attribute certificate carrying both anoRevAvailextension and acrlDistributionPointsorauthorityInfoAccessrevocation pointer, which RFC 5755 section 6 says an AC MUST NOT do. The rule is held over the extensions the certificate ends up with, so it binds the named-extension form and the pre-encoded DER form alike.pki.schema.attrcert.parsenow records the bytes it read, joining the certificate, CRL, CMS, certification-request and certificate-request-message parsers, andpki.attrcert.verifyaccepts only a result carrying that record. A parsed attribute certificate presents the signed byte range and the attributes granting the privileges as separate properties, so a rebuilt one can hold a genuine issuer's signed bytes beside substituted attributes. As with those parsers, the raw byte views on a parsed result now read from the parser's own copy of the input; passing DER or PEM is unaffected.
v0.5.14
pki.crmf.verifyPop checks the proof of possession on an inbound certificate request message, the CRMF counterpart to the PKCS#10 check that shipped in 0.5.13.
Added
pki.crmf.verifyPop(messages)verifies the RFC 4211 proof of possession on eachCertReqMsg, returning one verdict per message plus a top-levelverifiedthat is true only when every message carried a proof that held. For thesignatureproof the covered bytes are the ones the RFC names: the DER ofpoposkInputwhen that field is present, and the DER ofcertReqwhen it is absent (sec. 4.1, and the ASN.1 module, which is where the two readings of that sentence are settled). Verification composes the one path-validation signature engine, with the same algorithm-confusion (RFC 9814 sec. 4) and EdDSA low-order-point gates as the certificate and CRL paths.- The proofs that cannot be checked from the message are reported as such.
raVerifiedis an RA's assertion that it confirmed possession out of band, so it yieldsverified: falsewithmethod: "raVerified", and a caller who trusts that RA opts in by readingmethod.keyEnciphermentandkeyAgreementcomplete over a later protocol exchange, or need the CA's decryption key, so they yieldverified: falsenaming the arm. Each verdict carries only what its preimage covers, re-derived from the message's own bytes, so a CA issues from what was checked.publicKeyis the key possession was proven for.subjectis the requested name when the signature was overcertReq, which covers the whole template; apoposkInputpreimage covers the key and the sender alone, so a subject sitting beside it in the message is unsigned and is withheld withsubjectBound: falserather than reported next to a passing verdict.
Changed
pki.schema.crmf.parsenow records the bytes it read, joining the certificate, CRL, CMS and certification-request parsers, andpki.crmf.verifyPopaccepts only a result carrying that record. A parsed message set presents the byte range a proof covers and the template a certificate would be issued from as separate properties, so a rebuilt one can hold a genuine requester's signed range beside a substituted subject. As with those parsers, the raw byte views on a parsed result now read from the parser's own copy of the input; passing DER is unaffected, and a parse result used as-is still works.
Fixed
- The
pki.csr.signexample passedsubject: "CN=device-42", which asks for a commonName whose value is the stringCN=device-42and so issuesCN=CN=device-42. A bare string is the commonName value throughout the toolkit; the example now says so and passes"device-42".
v0.5.13
pki.csr.verify checks an inbound certification request's proof of possession, so a CA built on this toolkit can tell that the requester holds the key they are asking it to certify.
Added
pki.csr.verify(request)verifies a certification request's signature over its exactcertificationRequestInfobytes under thesubjectPKInfoinside them (RFC 2986 sec. 4.2), the checkopenssl req -verifyperforms.requestis DER, PEM, or a parsed request. It composes the one path-validation signature engine, with the same algorithm-confusion (RFC 9814 sec. 4) and EdDSA low-order-point gates, in place of the producing side's self-check, which waives that gate because it runs over a key the caller already controls. It fails closed on any import or verification fault, and malformed input throws a typedCsrError.- The answer is
{ verified, subject, subjectPublicKeyInfo, attributes, certificationRequestInfoBytes }, every field re-derived from the bytes the signature covers. Issue from those. A CA that normalizes a request before verifying it holds an object carrying its own edits, and a bare boolean would report on the signed bytes while the certificate got built from the edits; the fields travel with the verdict so the two cannot come apart. - What
verified: trueestablishes is stated in full, because the bound is the point: the producer held the private half of the key inside the request, and the subject and every requested extension are the ones covered by that signature. A CSR carries no issuer and its key is self-asserted, so a requester free to choose both can prove possession of a key they generated a moment ago under any name they like. Binding that name to an identity stays with the enrollment protocol.
Changed
pki.schema.csr.parsenow records the bytes it read, the way the certificate, CRL and CMS parsers do, andpki.csr.verifyaccepts only a result carrying that record. A parsed request presents the signed byte range and the fields that range encodes as separate properties, so a rebuilt one (Object.assign, a spread, a JSON round-trip) can hold a genuine requester's signed bytes beside a substituted subject: the proof of possession verifies from the recorded range while the certificate a CA issues is for a name nobody signed. Passing DER or PEM is unaffected, and a parse result used as-is still works.- One visible consequence of that record: the raw byte views on a parsed request (
certificationRequestInfoBytes,tbsBytes,subject.bytesand the rest) now read from the parser's own copy of the input rather than from your buffer, matching whatpki.schema.x509.parsehas always done. Writing into the buffer you passed no longer changes what an already-parsed request reports, and writing through one of those views no longer reaches your buffer. Code that read the fields is unaffected; code that relied on either aliasing needs to re-parse instead.
v0.5.12
pki.crl.isRevoked can be asked at an instant, and a CRL that stopped speaking for that instant is refused rather than read as a clean bill of health.
Added
pki.crl.isRevoked(crl, serialNumber, { time })asks the question at an instant. A CRL whosenextUpdatehas passed, whosethisUpdateis later, or which carries nonextUpdateat all is refused withcrl/not-currentinstead of answered from: outside the window a CRL states, an absent serial says nothing about the certificate, and a list stating no window cannot be told from a replayed copy. This joins the refusals already made for a delta, indirect or narrowed-scope CRL, on the same reasoning -- a serial means something only within the set, and the span, a CRL speaks for.pki.path.crlCheckeris unchanged and remains the verb that decides currency against material it fetched itself.opts.historicalModereads a revocation entry against that same instant, the waypki.path.crlCheckerreads one. By default a listed serial is revoked whatever itsrevocationDatesays, since a date in the future is post-dating or clock skew and must not read good; sethistoricalMode-- validating as of a past instant, a timestamped signature say -- and an entry dated after that instant has not yet applied. Given withouttimeit names no instant to read against and is refused.
Changed
pki.crl.isRevokedtakes a third argument. It is optional and every existing call keeps its behavior and its answer: withouttimethe verb is the structural lookup it has always been, and its documentation now names the question that then goes unasked, sonullreads as "not listed on this CRL" rather than "not revoked". An option it does not read is refused rather than ignored.
v0.5.11
An option a verb never reads is now refused, so a misspelled password on key export can no longer leave a private key unprotected.
Changed
pki.key(encrypt, decrypt, export, import, generate, publicFromPrivate),pki.path.validate,pki.path.build,pki.lint.certificateandpki.ocsp(buildRequest, sign, verify) throw<domain>/bad-inputon an option they do not read, instead of ignoring it. A call passing an option that did nothing before will now fail; the message names the unknown key, so the fix is to correct or drop it.- The same verbs require an options object whose options are values. One supplied through a getter is refused, naming the option, because a getter is asked afresh on every read and the value the check saw is not necessarily the one the verb uses. A caller computing an option can read it into a plain object at the call:
pki.key.export(key, { format: computeFormat() }). Methods are unaffected, so a class instance carrying them is still an options bag. - Where two verbs spell the same idea differently, the refusal says so.
pki.path.validatetakestrustAnchorandpki.path.buildtakestrustAnchors, and each names the other, because carrying the wrong spelling between them previously bought no anchoring and no error.pki.key.encryptchoosesiterationswhilepki.key.decryptcapsmaxIterations;pki.ocspspells the nonce three ways across buildRequest, sign and verify because it means three different things. pki.path.buildaccepts everypki.path.validateoption, since it forwards them to each internal validation. That union is derived from validate's own list rather than restated, so the two cannot drift apart.
Fixed
pki.sigstore.verifyBundle(bundle, { time })validates the Fulcio chain at the instant the Date holds.timeis accepted by its internal slot, so a Date subclass reaches the check; the instant was then read back throughgetTime, which a subclass answers. A caller-supplied Date reporting the log time made the ephemeral signing certificate, whose validity is about ten minutes wide, look current whenever the caller chose to check it. Every read of a Date across the toolkit now goes through the intrinsic, so what a comparison uses is the instant a Date holds rather than the one it reports.pki.path.validateandpki.ocsp.verifydecide the validity window on the instant a Date holds. Both compared the caller'stimeagainst a certificate's, CRL's or response's Date as objects, and comparing two Dates coerces each one throughSymbol.toPrimitiveorvalueOf, which a caller's subclass answers.path.validatecheckedopts.timefor a valid instant at entry and then compared it through a door that check never used, so a Date holding one moment and reporting an earlier one made an expired certificate validate. Both now narrow every operand to its held instant before comparing.- A field inherited from an object a caller built over the prototype every typed array shares is reported as the unknown option it is. The kinds were recognized from that shared parent, so any level minted the same way read as one the language installs members on, and a bag inheriting
BYTES_PER_ELEMENTfrom it reachedpki.key.generateunremarked. The concrete kinds are now found by asking the runtime for them and matched by identity, which keeps a kind added later covered the day it lands without admitting anything that merely sits above the same prototype. - A key from another WebCrypto implementation reaches the verb even where that implementation keeps its internals under a symbol. Every verb reads its options by name, so nothing under a symbol key can be read as one, and refusing a handle for carrying one protected nothing while turning a documented input away. The options door is unchanged and still reports a symbol on a bag passed as options, which is the case that matters: a caller who wrote one there meant it as an option and no verb will read it. A name a verb could read is still refused on a key handle.
- A
node:cryptoKeyObject reaches the verb it was passed to.pki.hpkedocuments one as a key input, and its material lives behind an internal slot, so what a copy holds is the shape of a key and none of the key: the object the verb received could not export, sign or derive. It is now recognized from that slot and handed on as itself, which is how a WebCrypto CryptoKey was already treated. A value wearingKeyObject.prototypewith no key behind it is still copied, and the rawTypeErroritstypegetter raises is now the calling module's typed refusal with that fault as its cause. - The tables naming what each uncopyable kind carries no longer answer for names they inherit. Built as ordinary objects, a table answered
toStringwith the function every object in the language inherits, sotoString,constructor,valueOf,hasOwnPropertyand__proto__read as published surface on aRegExp, anError, aCryptoKeyand a thenable alike. A caller could write any of the five onto such an object and have it handed to a verb by reference. Membership is now a fact about the table. - A field written with
Object.definePropertyis seen by the check that decides whether a handle may be passed on by reference. ARegExp, anError, a key handle and a thenable cannot be copied, so a verb receives the caller's own object and a field on it stays the caller's to change after every check has read it. That check passed over non-enumerable properties on the reasoning that a caller adds fields by assignment;Object.definePropertybelongs to a caller as much as to a platform, so aRegExpfrom another realm carrying a hiddendetachedreachedpki.cms.sign, and flipping it after the call moved the content out of the message the call had already been asked to sign. What is asked now is whether the property can still move: a field that is writable, configurable, or backed by a getter is refused, and so is one whose value can move even where the binding cannot, since a frozen slot holding aDateis an instant anybody can still change. A field that is settled through to its value rides along, which is how an implementation writes the internal state a key handle carries. - A
Datecopy answers everyDatemethod the way aDateholding that instant answers. A method written onto the value itself shadows the language's, and the copy carried it: an owngetUTCFullYearthat throws came across onto the copy and threw out ofpki.cms.signwhile the signing time was being encoded. The names the language supplies are read offDate.prototype, and what decides is the value rather than the name: a function under one of those names is behavior and is left behind, while a plain value under the same name is a field and comes across, so an option misspelled asgetTimestill reaches the check that refuses an option no verb reads. - An element supplied through a getter is refused where a field already was. The deep copy taken at a verb's entry reads each element once and stores the result, so a getter that answers differently on the next read left the check nothing to find. The refusal covered an options bag's named fields and passed over its indices, which is where it pays most: a list element could answer as a trusted signer to the check and as something else afterwards.
- A signer, anchor or policy list whose elements come from its prototype reaches the verb intact. An array resolves a hole through its prototype chain, so
Object.setPrototypeOf(list, {0: signer})reads as a one-element list to any consumer while reporting no element of its own. The copy taken at the door enumerated only the array's own keys, so the verb received a hole where the caller passed a value:pki.cms.signrefused a list it had been given a signer for. Elements at or pastlengthare still left out, since no length-bounded read reaches them. pki.key.export(key, { password })no longer writes an unprotected private key. Export serializes; it has never encrypted. The option was ignored, so the file on disk was a plaintext PKCS#8 while the call site named a password. It now throwskey/bad-inputnamingpki.key.encrypt(key, password), whose result is what to export.- An option carried on a prototype, defined non-enumerably, supplied through a getter, or planted on
Object.prototypeis seen by the check that refuses unknown options. It read own enumerable names only. Each of those four shapes answeredopts.passwordwhile showing the check nothing, andpki.key.exportreturned the private key in the clear, which is the case the refusal exists to prevent. A pollutedObject.prototypeis covered too, where{}carries no option of its own; the check reports the planted name and stays silent on the built-ins it was seeded with at load. One thing is still skipped: a method, meaning a data property holding a function that the prototype chain also supplies under that name. Soconstructorand the methods a class defines stay machinery, and an instance of a caller's own class remains a valid options bag whether the verb inspects it directly or after copying it. - A
Symbol-named option is reported rather than passed over. The check enumerated withObject.getOwnPropertyNames, which never returns a Symbol key, so one was accepted in silence. Such a name answers noopts.passwordand reaches no verb, but it is still an option supplied and never read, which is what the refusal is for. The message names it asSymbol(name). - A name planted on
Object.prototypebefore this package loads is reported, whether it holds a value or a function. The built-ins were recognized by readingObject.prototypeat load, which is the polluted runtime answering the question, so a name already present was taken for one andpki.key.exportreturned a plaintext private key. They are now the twelve members the language specifies, each still required to have the shape a real one has....
v0.5.10
The documentation and the package's own source comments settle on one spelling of the words they use in both, and a gate keeps them there.
Added
npm run check:spellingreports any word in the repository that has a second accepted spelling. It runs innpm run gates, on every pull request, and again before the published tarball is packed. The check is whole-word and case-insensitive, both to avoid a failure mode: a substring match reportspublicEncryptas a misspelling, and a case-sensitive one walks past the same word capitalized or upper-cased. It self-tests on planted forms before reporting, so a word list that has stopped matching cannot pass as a clean tree.
Changed
- Documentation and source comments now use the US spellings behavior, recognize, unrecognized, labeled, honored, license, defense, neighbor, authorize, initialization, enrollment, signaled, modeled, favor and fulfill. 267 occurrences across 98 files no longer carry a second spelling: the README, the security policy, thirteen release notes and the changelog generated from them, the status-lifecycle record, the comments and error text in lib/, the test suite, and the release and wiki tooling. That figure counts words whose spelling changed, so a line edited for another reason that happened to contain one of these words is not counted twice. One word settles the other way: catalogue, which this repository already used by 185 uses to 32, so the checked form is that one and the US spelling is what now reports. Simple Certificate Enrolment Protocol is RFC 8894's title, quoted as published and allowed only on a line carrying that title in full, so the exception cannot spread to the word.
v0.5.9
A certificate can now carry an internationalized email address, which this toolkit could read and never write.
Added
- pki.x509.sign accepts an otherName entry in a subjectAltName, given as { typeId, value } where typeId is an OID string and value is a Buffer holding one DER element. It encodes RFC 5280 section 4.2.1.6's otherName ::= SEQUENCE { type-id OBJECT IDENTIFIER, value [0] EXPLICIT ANY }, tagged [0] IMPLICIT. The value wrapper is EXPLICIT because ANY carries no tag of its own, which is what makes the encoding unambiguous and is the shape the decoder already required. This is what an SmtpUTF8Mailbox address needs, and it is equally the carrier for any other otherName a profile defines.
- The value is validated before it is wrapped and signed, because a signer that emits a malformed encoding under a real signature has produced something strict relying parties reject. It must be exactly one element with no trailing bytes, and its contents must satisfy the rules for its type: a BOOLEAN whose octet is not 0x00 or 0xFF is refused, as is a SET whose members sit in no canonical order, and so on recursively through a constructed value. The accepted universal types are BOOLEAN, INTEGER, ENUMERATED, BIT STRING, OCTET STRING, NULL, OBJECT IDENTIFIER, UTCTime, GeneralizedTime, NumericString, and the DirectoryString family (UTF8String, PrintableString, IA5String, TeletexString, VisibleString, BMPString, UniversalString), plus SEQUENCE and SET. A universal type outside that set, such as REAL or RELATIVE-OID, has no content validator here and is refused rather than accepted on its framing alone. A context- or application-tagged value passes on its framing, since no content rule is knowable for it, and its children are still walked. One known limitation: a GeneralizedTime carrying fractional seconds, such as 20260101000000.5Z, is refused here even though X.690 section 11.7 permits it. That relaxation is scoped to the codec and to RFC 3161 timestamping on purpose, and this validator does not widen it. A constructed wrapper does not evade the rule, since the walk recurses into its children; a profile needing a fractional time must carry it under an implicit primitive context tag, which passes on framing because no content rule is knowable for it.
Fixed
- pki.smime.verify's sender binding is now exercised against certificates carrying an otherName. Two behaviours that previously had no conformance vector are pinned: a certificate whose subjectAltName carries an SmtpUTF8Mailbox does not let a legacy subject distinguished-name emailAddress speak for it, and an otherName unrelated to email, such as a Microsoft user principal name, neither erases a matching rfc822Name nor turns a definite non-match into an undecidable one.
v0.5.8
Four verdicts that answered a question nobody had asked now say what they checked, and an email domain comparison no longer folds two registrable domains into one identity.
Added
- pki.cms.decrypt reports originAuthenticated, authenticatedBy and originatorInfo. authenticated is a claim about the content and the key that opened it; it never described who sent the message. originAuthenticated is false for every recipient type the toolkit supports: a ktri or ephemeral-static kari message is minted by anyone holding the recipient's public key, and a pwri or kekri message by any co-recipient sharing the secret. authenticatedBy names what the integrity rests on. originatorInfo is now surfaced rather than decoded and discarded, and is documented as unauthenticated: it sits outside the AEAD's authenticated data, so it is a hint the sender chose, and any certificate it carries must be validated before use. To bind a sender, verify a signature over the plaintext.
- pki.smime.verify accepts expectedSender and reports a sender block of { checked, expected, source, identities, match }. A signature proves a key signed; it does not prove the message came from the mailbox the reader sees. match is true only when the signer certificate asserts the address, compared under RFC 5280 section 7.5: the local-part exactly, the host-part case-insensitively. The address is read from the subjectAltName rfc822Name entries (RFC 8550 section 4.4.3), and where the extension carries none, from the subject distinguished name's PKCS #9 emailAddress attribute, which RFC 8550 section 3 requires a receiving agent to recognise. Where both are present the extension is authoritative, so a stale subject value cannot satisfy expectedSender while the extension names a different mailbox. It is three-valued: false when every identity was comparable and none matched, null when the question went unanswered, and null is not a pass, so a caller enforcing sender binding tests match === true. identities lists what the certificate actually asserts. With no expectedSender a single outer From is used and reported as source: "from", which is advisory, because on a message without header protection that header is attacker-controlled.
Changed
- pki.smime.verify's headerProtection.fromMismatch is now null when there was no protected From to compare against, where it was previously false. It reported false on every message without RFC 9788 header protection, which is nearly all mail, so testing not fromMismatch read as a passed sender check on messages where no comparison had run. It is now true when the outer From differs from the protected one, false when they agree, and null when nothing was compared. null is falsy, so an existing not fromMismatch test keeps working and keeps accepting the unchecked case: compare against false explicitly. For a sender binding that does not depend on the composer having protected the headers, use expectedSender and test sender.match === true. See MIGRATING.md.
Fixed
- An rfc822Name identity comparison no longer folds the host-part with a Unicode-aware lowercase. U+212A KELVIN SIGN lowercases to ASCII k, so ban<U+212A>.com and bank.com are different byte strings, separately registrable, that compared equal and read as one email identity. The host-part is now folded across A-Z only, which is the case-insensitive ASCII comparison RFC 5280 section 7.5 authorizes and no more. The local-part was already compared exactly and stays that way: RFC 8398 section 5 requires that it not be transformed in any way, including by case folding.
- pki.merkle.verifyConsistency refuses a proof whose older tree is empty and whose newer tree is not, as merkle/no-consistency-claim. RFC 6962 section 2.1.2 defines a consistency proof for 0 < oldSize < newSize. An empty tree is a prefix of every tree by definition, so there was no proof to check and nothing bound the newRoot that was passed: any value returned true, including a root from a different log. Two empty trees are unchanged and still check each root against the empty root hash.
- pki.cmc.verify refuses a Full PKI Response that carries nothing tying it to a request, as cmc/unbound-response. Every binding the module could check was previously conditional on the caller having supplied the matching value, and nothing in the verdict reported whether any of them ran, so a response captured from an earlier successful enrollment against the same CA verified identically. Pass what the request retained (transactionId, senderNonce, whose echo is the replay defence of RFC 5272 section 6.6, or dataReturn), or allowUnbound: true to interpret a response that could be a replay of any earlier exchange.
v0.5.7
A CMS signature made over signed attributes can no longer be re-presented as one made over content.
Added
- The pki.cms.verify verdict carries eContentType, and each signers[i] carries signedAttributesPresent. Signing with attributes and signing the content directly are different claims -- attributes bind a content type and a signing time alongside the digest, content-only binds nothing but the bytes -- and one message may carry a signer of each. A caller whose profile is stricter than RFC 5652's, such as RFC 8551 S/MIME which requires signed attributes, can now enforce that from the verdict instead of parsing the message a second time. A check that needs a second parse is a check most callers will not write.
Changed
- Because the producing verbs now copy their arguments at entry, each property of a spec or options object -- own or inherited -- is read exactly once, when the verb is called. A field defined as a getter is therefore evaluated at that point even if the verb has no use for it, and a getter that throws surfaces as that module's bad-input fault before its own validation of any other field. Reading each property once is the point rather than a side effect: a getter consulted twice can answer differently the second time, which is the same problem the copy exists to remove. Plain data specs are unaffected.
- One argument shape is refused rather than copied: an object whose state this toolkit cannot read -- a WeakMap or WeakSet, a promise, a CryptoKey -- carrying its own named fields alongside. There is no safe handling for it, because it cannot be copied and passing it through would leave those fields changeable after the checks had read them, so it fails with the module's bad-input code and says to pass the fields as a plain object. The same objects are accepted as before when they carry only what their kind defines, which is what a real key, a real promise and a real WeakMap do.
- SECURITY.md previously said an attacker could "neither swap the content out from under a set of signed attributes, nor strip the attributes and present a signature made over them as one made over the content". The first half was true; the second was not, and had not been since the claim was written. The entry now describes what is actually defended and how, and names the case it costs: content which genuinely is an encoded SignedAttributes block must be signed WITH signed attributes. The v0.5.6 notes described the parsed-object re-derivation as closing this forgery; it closed the half reachable through a caller-assembled object, and this release closes the half reachable from bytes.
Fixed
- pki.cms.verify refuses a SignerInfo with no signed attributes whose content is itself an encoded SignedAttributes block, as cms/ambiguous-content. This is Attack Type 1 of draft-vangeest-lamps-cms-euf-cma-signeddata: take a message signed with attributes present, drop the signedAttrs field, set the encapsulated content to the DER of those attributes, keep the signature. The signature genuinely verifies over those bytes -- the refusal is the shape, not a failed signature check, which is why it has its own code rather than reading as cms/bad-signature. The condition is necessary to the attack rather than a guess at anything SET OF shaped: RFC 5652 section 5.3 requires signed attributes to carry both a content-type and a message-digest attribute, so every message the attack produces has content carrying both, and content that is a set of attributes missing either one is not refused. Ordinary content -- a certificate, a JSON payload, arbitrary bytes -- does not have the shape at all. Verified against the shipped verb before and after, and the standards fixes for this are protocol changes (signing under a context string that names the mode) which no verifier can apply on its own.
- pki.cms.sign refuses to sign content that is itself an encoded SignedAttributes block when signedAttributes is false. That is the other direction of the same problem (Attack Type 2): such a signature can afterwards be promoted into an attributes-present message, because the signature does not commit to which mode was used -- the attacker attaches the signed bytes AS the SignedAttributes and swaps in whatever content their message-digest attribute names. Refusing to mint the ambiguous signature is the only point at which that direction can be stopped. Sign the same content WITH signed attributes and it is unambiguous again.
- A byte argument whose backing store has been transferred away is refused instead of read as empty. Transferring an ArrayBuffer -- a structuredClone with transfer, a worker hand-off, a stream that adopts the buffer -- leaves every view of it reading zero-length rather than throwing, so a boundary that passed the caller's object straight on operated on nothing and succeeded: pki.cms.sign produced a sound, verifiable signature covering no content at all, pki.cms.compress the same, and pki.pkcs12.build derived its MAC and encryption keys from the empty password. Every boundary that takes caller bytes now re-views the input first and refuses a detached one with that module's own bad-input code. Where the empty read already failed further down -- an empty certificate does not parse, an empty private key does not import -- the refusal now carries the calling module's code and names the argument, rather than surfacing whatever the later failure raised.
- A producing verb reads its arguments once, at entry. Every one of them does work across more than one promise turn, so a caller still holding a spec, an options object or a signer could change a field after the call returned and have a later turn read the new value -- the checks ran against one input and the artifact was built from another. Every argument of pki.cms.sign, pki.cms.countersign, pki.x509.sign, pki.csr.sign, pki.crl.sign, pki.attrcert.sign, pki.crmf.build, pki.cmc.build, pki.cmp.build, pki.ocsp.buildRequest, pki.ocsp.sign, pki.tsp.sign and pki.pkcs12.build is now copied whole at entry, at every depth, and each copy is cleared when the call settles. Reachable cases included flipping signedAttributes from true to false to skip the content check the entry above describes, rewriting a certificate's key identifier or a CRL's authority key identifier between the check and the encoding, changing the encoding pki.x509.sign returns after the signature came back, rewriting the PKCS#12 password partway through so the file's MAC and its bag encryption were keyed to two different values, and rewriting the nested pki.cmp.build MAC secret so the message went out authenticated under a value the caller never supplied. Copying at one level does not cover the last of those and copying without clearing duplicates the secret, so both halves are the rule. A parsed structure passed inside a spec keeps its identity rather than being copied, so it still satisfies the verbs that require parser provenance, and a CryptoKey is used rather than cloned.
- The verbs documented as returning a Promise now run their body at the call, not a turn later. Ten of them deferred everything -- including reading the caller's arguments -- until after the call had already returned, which left the window above open even for a verb that copies its input on the first line. They still report a fault by rejecting rather than throwing; only the timing of the work changed.