Tolerate PKCS#8 v2 OneAsymmetricKey with optional public keys - #10
Tolerate PKCS#8 v2 OneAsymmetricKey with optional public keys#10robobun wants to merge 3 commits into
Conversation
RFC 5958 extends PrivateKeyInfo to OneAsymmetricKey: version v2(1) plus an optional publicKey [1] BIT STRING after the attributes. OpenSSL accepts these since 3.5 (openssl/openssl@064bb1645), so keys emitted by other tooling with the v2 fields parse under Node but failed here: d2i_PKCS8_PRIV_KEY_INFO errored with SEQUENCE_LENGTH_MISMATCH and EVP_parse_private_key rejected any version other than 0. Bun's node:crypto and WebCrypto pkcs8 imports therefore rejected keys Node and Deno accept (oven-sh/bun#35432). Add the kpub field to PKCS8_PRIV_KEY_INFO with a D2I_POST callback enforcing version 0 or 1 and rejecting v1 keys that carry the field, mirroring OpenSSL's semantics, and teach EVP_PKEY_from_private_key_info to accept v2 and skip the optional trailing fields. The publicKey contents are parsed structurally but not interpreted.
|
Warning Review limit reached
Next review available in: 2 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (6)
Comment |
|
I found a validation discrepancy between the two decoding paths. RFC 5958 §2 defines
The A blanket Please add the same cases to both parser paths: malformed |
Address review on oven-sh#10: the CBS path now validates the publicKey [1] field as a BIT STRING body and rejects any trailing bytes in the SEQUENCE after the optional fields, so duplicate, reordered, or unknown fields are refused on both parsers. This matches OpenSSL's template, which does not honour the RFC 5958 extension marker either. Attribute contents remain unvalidated on this path as they always were; fully parsing X509_ATTRIBUTE here would pull x509 into libcrypto's evp layer. The negative test cases are folded into a table covering both parsers.
|
Thanks for the careful read. Pushed fdfa07e to address this:
On the two points where I didn't follow the suggestion exactly:
Full |
The build() helper is now tied to the hand-written kV2PrivateKey vector so the reject cases cannot pass vacuously, and the table carries accept rows for v2 with neither optional field, attributes only, and publicKey only, each checked on both parsers. Code comments trimmed.
With oven-sh/boringssl#10 the PKCS8_PRIV_KEY_INFO template and EVP_parse_private_key both accept RFC 5958 v2 OneAsymmetricKey, so the hand-rolled DER walkers in CryptoKeyOKP::importPkcs8/importSpki can go through d2i_PKCS8_PRIV_KEY_INFO + EVP_PKCS82PKEY and EVP_parse_public_key instead, matching the EC/RSA/AKP importers. This keeps Node's attribute-body validation and the existing key-type-mismatch reporting. The BoringSSL commit is bumped to the PR head. A createPrivateKey accept/reject matrix in crypto.key-objects.test.ts covers the node:crypto path (d2i + EVP_PKCS82PKEY), verified against Node 26.3.
kjanat
left a comment
There was a problem hiding this comment.
The full-consumption check also affects v1 keys, but the current trailing-field tests only cover v2. These suggestions add direct coverage for v1 trailing data.
I confirmed the test passes on ac93f35. The suggestions can/should be batched.
| static const uint8_t kAttrsPubExt[] = {0xa0, 0x00, 0x81, 0x01, | ||
| 0x00, 0x82, 0x01, 0x00}; |
There was a problem hiding this comment.
| static const uint8_t kAttrsPubExt[] = {0xa0, 0x00, 0x81, 0x01, | |
| 0x00, 0x82, 0x01, 0x00}; | |
| static const uint8_t kAttrsPubExt[] = {0xa0, 0x00, 0x81, 0x01, | |
| 0x00, 0x82, 0x01, 0x00}; | |
| static const uint8_t kTrailing[] = {0x04, 0x01, 0x00}; | |
| static const uint8_t kBadAttrs[] = {0xa0, 0x01, 0xff}; |
| // template and OpenSSL do not honour the RFC 5958 extension marker. | ||
| {"duplicate-pub", false, build(1, kAttrsThenPubTwice)}, | ||
| {"reordered", false, build(1, kPubThenAttrs)}, | ||
| {"unknown-extension", false, build(1, kAttrsPubExt)}, |
There was a problem hiding this comment.
| {"unknown-extension", false, build(1, kAttrsPubExt)}, | |
| {"unknown-extension", false, build(1, kAttrsPubExt)}, | |
| // Nothing may follow the optional fields, in v1 as well as v2. | |
| {"v1-trailing", false, build(0, kTrailing)}, |
| } | ||
| } |
There was a problem hiding this comment.
No case covers a malformed body inside attributes [0]. The template parses each element as an X509_ATTRIBUTE and rejects a0 01 ff, while EVP_parse_private_key consumes the field by tag alone, so the two entry points differ on this input.
Only the rejection is asserted. Whether the CBS path accepts it is deliberately left open, so tightening that path later would not break this test.
| } | |
| } | |
| } | |
| // The template parses attributes [0] as X509_ATTRIBUTE and rejects a malformed body. | |
| // EVP_parse_private_key checks only the outer tag, so its result is left unasserted. | |
| const std::vector<uint8_t> bad_attrs = build(1, kBadAttrs); | |
| ptr = bad_attrs.data(); | |
| UniquePtr<PKCS8_PRIV_KEY_INFO> bad_attrs_info(d2i_PKCS8_PRIV_KEY_INFO( | |
| nullptr, &ptr, static_cast<long>(bad_attrs.size()))); | |
| EXPECT_FALSE(bad_attrs_info); | |
| ERR_clear_error(); | |
| } |
Context: oven-sh/bun#35432 and the discussion on oven-sh/bun#35433.
RFC 5958 extends PrivateKeyInfo to OneAsymmetricKey: version v2(1) plus an optional
publicKey [1] IMPLICIT BIT STRINGafter the attributes. OpenSSL tolerates these since 3.5 (openssl/openssl@064bb1645), so Node accepts such keys, while both parsers here rejected them: thePKCS8_PRIV_KEY_INFOtemplate has no[1]field (SEQUENCE_LENGTH_MISMATCH), andEVP_PKEY_from_private_key_inforequires version 0. Upstream BoringSSL is intentionally v1-only with no change in sight, so this is carried as a fork patch.Changes, mirroring OpenSSL's semantics:
PKCS8_PRIV_KEY_INFOgains akpubfield (ASN1_BIT_STRING,[1] IMPLICIT OPTIONAL) and aD2I_POSTcallback enforcing version 0 or 1 and rejecting a v1 key that carries the field. The contents are re-serialized oni2dbut otherwise unused.EVP_PKEY_from_private_key_info(and thereforeEVP_parse_private_keyandEVP_PKCS82PKEY) accepts version 0 or 1, consumes the optionalattributes [0]andpublicKey [1]fields, validates[1]as a BIT STRING body, rejects[1]in a v1 structure, and requires the SEQUENCE to be fully consumed. The last point is a deliberate tightening for v1 keys too: the previous code ignored every byte after the private key, so arbitrary trailing TLVs were tolerated. That now fails, matching the template path and OpenSSL, neither of which honours the RFC 5958 extension marker. Attribute contents remain unvalidated on this path, as they always were.Verification:
EVPExtraTest.Ed25519OneAsymmetricKeyV2exercises both parsers on a ten-case accept/reject table (v2 with each combination of the optional fields accepted; v1 with[1], version > v2, empty[1], bad[1]padding octet, duplicate[1],[1]-before-[0], and unknown[2]rejected), plus a byte-exactd2i/i2dround-trip of the full v2 shape. The table builder is anchored against the hand-written v2 vector.crypto_testsuite: 1817 pass, 0 fail.node:crypto.createPrivateKeymatch Node 26.3 on a 10-case accept/reject matrix (v1/v2, field combinations, malformed attributes, empty BIT STRING, v1+publicKey, version > 2) where it previously rejected all v2 inputs.Note:
crypto_testdoes not currently link in this fork independent of this change (get_cipher.ccin libcrypto referencesEVP_bf_ecbfrom libdecrepit, butlibdecrepit.aprecedeslibcrypto.aon the link line). I verified by relinking with the decrepit library repeated after libcrypto.