From c0354989066815066a1e4d4101d9267cfb9a2789 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C5=8Dan?= Date: Thu, 19 Mar 2026 00:07:59 -0600 Subject: [PATCH 1/2] feat: add get_private_key_pkcs8_string() for PKCS#8 PEM export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Export private keys in PKCS#8 format (-----BEGIN PRIVATE KEY-----), mirroring the get_public_key_x509_string() / get_public_key_pkcs1_string() split on the public key side. Uses PEM_write_bio_PrivateKey() which natively produces PKCS#8. On pre-3.x OpenSSL, wraps the RSA* in a temporary EVP_PKEY* since PEM_write_bio_PrivateKey() requires EVP_PKEY. Supports optional passphrase + cipher parameters (same interface as get_private_key_string), producing encrypted PKCS#8 format (-----BEGIN ENCRYPTED PRIVATE KEY-----). Tests: 9 new tests covering format validation, round-trips (PKCS#8 and cross-format PKCS#8↔PKCS#1), encrypted PKCS#8, and error paths. Co-Authored-By: Claude Opus 4.6 --- RSA.pm | 14 +++++++++++++ RSA.xs | 58 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ t/format.t | 40 ++++++++++++++++++++++++++++++++++++- 3 files changed, 111 insertions(+), 1 deletion(-) diff --git a/RSA.pm b/RSA.pm index 838cb02..9db96b5 100644 --- a/RSA.pm +++ b/RSA.pm @@ -242,6 +242,20 @@ The cipher algorithm used to protect the private key. Default to =back +=item get_private_key_pkcs8_string + +Return the Base64/DER-encoded PKCS#8 representation of the private +key. This string has header and footer lines: + + -----BEGIN PRIVATE KEY------ + -----END PRIVATE KEY------ + +This is the format produced by C, and is +the private-key counterpart of C. + +Accepts the same optional passphrase and cipher-name parameters as +C. + =item encrypt Encrypt a binary "string" using the public (portion of the) key. diff --git a/RSA.xs b/RSA.xs index ed71755..ac9255e 100644 --- a/RSA.xs +++ b/RSA.xs @@ -500,6 +500,64 @@ get_private_key_string(p_rsa, passphase_SV=&PL_sv_undef, cipher_name_SV=&PL_sv_u OUTPUT: RETVAL +SV* +get_private_key_pkcs8_string(p_rsa, passphase_SV=&PL_sv_undef, cipher_name_SV=&PL_sv_undef) + rsaData* p_rsa; + SV* passphase_SV; + SV* cipher_name_SV; + PREINIT: + BIO* stringBIO; + char* passphase = NULL; + STRLEN passphaseLength = 0; + char* cipher_name; + const EVP_CIPHER* enc = NULL; +#if OPENSSL_VERSION_NUMBER < 0x30000000L + EVP_PKEY* pkey = NULL; + int error = 0; +#endif + CODE: + if (SvPOK(cipher_name_SV) && !SvPOK(passphase_SV)) { + croak("Passphrase is required for cipher"); + } + if (SvPOK(passphase_SV)) { + passphase = SvPV(passphase_SV, passphaseLength); + if (SvPOK(cipher_name_SV)) { + cipher_name = SvPV_nolen(cipher_name_SV); + } + else { + cipher_name = "des3"; + } + enc = EVP_get_cipherbyname(cipher_name); + if (enc == NULL) { + croak("Unsupported cipher: %s", cipher_name); + } + } + + CHECK_OPEN_SSL(stringBIO = BIO_new(BIO_s_mem())); +#if OPENSSL_VERSION_NUMBER >= 0x30000000L + CHECK_OPEN_SSL_BIO(PEM_write_bio_PrivateKey( + stringBIO, p_rsa->rsa, enc, (unsigned char*) passphase, passphaseLength, NULL, NULL), stringBIO); +#else + pkey = EVP_PKEY_new(); + THROW(pkey != NULL); + THROW(EVP_PKEY_set1_RSA(pkey, p_rsa->rsa)); + THROW(PEM_write_bio_PrivateKey( + stringBIO, pkey, enc, (unsigned char*) passphase, passphaseLength, NULL, NULL)); + EVP_PKEY_free(pkey); + pkey = NULL; + + goto pkcs8_done; + err: + if (pkey) { EVP_PKEY_free(pkey); pkey = NULL; } + BIO_free(stringBIO); + CHECK_OPEN_SSL(0); + pkcs8_done: +#endif + RETVAL = extractBioString(stringBIO); + + OUTPUT: + RETVAL + SV* get_public_key_string(p_rsa) rsaData* p_rsa; diff --git a/t/format.t b/t/format.t index 0f4f516..ce64cde 100644 --- a/t/format.t +++ b/t/format.t @@ -3,7 +3,7 @@ use Test::More; use Crypt::OpenSSL::RSA; -BEGIN { plan tests => 39 } +BEGIN { plan tests => 48 } my $PRIVATE_KEY_STRING = <new_public_key("not a PEM key at all") }; like($@, qr/unrecognized key format/, "new_public_key croaks on non-PEM input"); +# --- PKCS#8 private key export --- + +{ + my $rsa = Crypt::OpenSSL::RSA->new_private_key($DECRYPT_PRIVATE_KEY_STRING); + my $pkcs8_pem = $rsa->get_private_key_pkcs8_string(); + like($pkcs8_pem, qr/^-----BEGIN PRIVATE KEY-----/m, "PKCS#8 output has correct header"); + like($pkcs8_pem, qr/-----END PRIVATE KEY-----\s*$/m, "PKCS#8 output has correct footer"); + unlike($pkcs8_pem, qr/BEGIN RSA PRIVATE KEY/, "PKCS#8 output is not PKCS#1 format"); + + # round-trip: import PKCS#8, re-export as PKCS#1, compare + my $reimported = Crypt::OpenSSL::RSA->new_private_key($pkcs8_pem); + is($reimported->get_private_key_string(), $DECRYPT_PRIVATE_KEY_STRING, + "PKCS#8 round-trip: re-import then export as PKCS#1 matches original"); + + # PKCS#8 round-trip + is($reimported->get_private_key_pkcs8_string(), $pkcs8_pem, + "PKCS#8 round-trip: re-export as PKCS#8 matches"); + + # encrypted PKCS#8 + my $pass = 'test_pkcs8_pass'; + my $enc_pem = $rsa->get_private_key_pkcs8_string($pass, 'aes-128-cbc'); + like($enc_pem, qr/^-----BEGIN ENCRYPTED PRIVATE KEY-----/m, + "encrypted PKCS#8 has correct header"); + my $dec_rsa = Crypt::OpenSSL::RSA->new_private_key($enc_pem, $pass); + is($dec_rsa->get_private_key_string(), $DECRYPT_PRIVATE_KEY_STRING, + "encrypted PKCS#8 round-trip decrypts to original key"); + + # error: cipher without passphrase + eval { $rsa->get_private_key_pkcs8_string(undef, 'des3') }; + like($@, qr/Passphrase is required for cipher/, + "get_private_key_pkcs8_string croaks when cipher given without passphrase"); + + # error: unsupported cipher + eval { $rsa->get_private_key_pkcs8_string($pass, 'bogus-cipher-xyz') }; + like($@, qr/Unsupported cipher/, + "get_private_key_pkcs8_string croaks on unsupported cipher"); +} + # --- X509 public key from private key matches PKCS1 --- my $priv_for_x509 = Crypt::OpenSSL::RSA->new_private_key($PRIVATE_KEY_STRING); From 11d2db58785cfff59497a7c42f815906ae78a484 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C5=8Dan?= Date: Fri, 20 Mar 2026 21:27:25 -0600 Subject: [PATCH 2/2] rebase: apply review feedback on #124 --- RSA.pm | 4 ++-- RSA.xs | 41 +++++++++++++++++++++++------------------ t/format.t | 35 ++++++++++++++++++++++------------- 3 files changed, 47 insertions(+), 33 deletions(-) diff --git a/RSA.pm b/RSA.pm index 9db96b5..f4488a6 100644 --- a/RSA.pm +++ b/RSA.pm @@ -247,8 +247,8 @@ The cipher algorithm used to protect the private key. Default to Return the Base64/DER-encoded PKCS#8 representation of the private key. This string has header and footer lines: - -----BEGIN PRIVATE KEY------ - -----END PRIVATE KEY------ + -----BEGIN PRIVATE KEY----- + -----END PRIVATE KEY----- This is the format produced by C, and is the private-key counterpart of C. diff --git a/RSA.xs b/RSA.xs index ac9255e..e082956 100644 --- a/RSA.xs +++ b/RSA.xs @@ -27,6 +27,27 @@ #include #endif +/* Pre-3.x helper for PKCS#8 export: wraps RSA* in a real EVP_PKEY and + writes PKCS#8 PEM. Defined BEFORE the EVP_PKEY->RSA compatibility + macros so that EVP_PKEY, EVP_PKEY_new, EVP_PKEY_free, and + PEM_write_bio_PrivateKey resolve to their real OpenSSL symbols. */ +#if OPENSSL_VERSION_NUMBER < 0x30000000L +static int _write_pkcs8_pem(BIO* bio, RSA* rsa, const EVP_CIPHER* enc, + unsigned char* pass, int passlen) +{ + EVP_PKEY* pkey = EVP_PKEY_new(); + int ok; + if (!pkey) return 0; + if (!EVP_PKEY_set1_RSA(pkey, rsa)) { + EVP_PKEY_free(pkey); + return 0; + } + ok = PEM_write_bio_PrivateKey(bio, pkey, enc, pass, passlen, NULL, NULL); + EVP_PKEY_free(pkey); + return ok; +} +#endif + #if OPENSSL_VERSION_NUMBER >= 0x30000000L #define UNSIGNED_CHAR unsigned char #define SIZE_T_INT size_t @@ -511,10 +532,6 @@ get_private_key_pkcs8_string(p_rsa, passphase_SV=&PL_sv_undef, cipher_name_SV=&P STRLEN passphaseLength = 0; char* cipher_name; const EVP_CIPHER* enc = NULL; -#if OPENSSL_VERSION_NUMBER < 0x30000000L - EVP_PKEY* pkey = NULL; - int error = 0; -#endif CODE: if (SvPOK(cipher_name_SV) && !SvPOK(passphase_SV)) { croak("Passphrase is required for cipher"); @@ -538,20 +555,8 @@ get_private_key_pkcs8_string(p_rsa, passphase_SV=&PL_sv_undef, cipher_name_SV=&P CHECK_OPEN_SSL_BIO(PEM_write_bio_PrivateKey( stringBIO, p_rsa->rsa, enc, (unsigned char*) passphase, passphaseLength, NULL, NULL), stringBIO); #else - pkey = EVP_PKEY_new(); - THROW(pkey != NULL); - THROW(EVP_PKEY_set1_RSA(pkey, p_rsa->rsa)); - THROW(PEM_write_bio_PrivateKey( - stringBIO, pkey, enc, (unsigned char*) passphase, passphaseLength, NULL, NULL)); - EVP_PKEY_free(pkey); - pkey = NULL; - - goto pkcs8_done; - err: - if (pkey) { EVP_PKEY_free(pkey); pkey = NULL; } - BIO_free(stringBIO); - CHECK_OPEN_SSL(0); - pkcs8_done: + CHECK_OPEN_SSL_BIO(_write_pkcs8_pem( + stringBIO, p_rsa->rsa, enc, (unsigned char*) passphase, passphaseLength), stringBIO); #endif RETVAL = extractBioString(stringBIO); diff --git a/t/format.t b/t/format.t index ce64cde..e7cb5cf 100644 --- a/t/format.t +++ b/t/format.t @@ -2,6 +2,9 @@ use strict; use Test::More; use Crypt::OpenSSL::RSA; +use Crypt::OpenSSL::Guess qw(openssl_version); + +my ($major, $minor, $patch) = openssl_version(); BEGIN { plan tests => 48 } @@ -157,23 +160,29 @@ like($@, qr/unrecognized key format/, "new_public_key croaks on non-PEM input"); like($pkcs8_pem, qr/-----END PRIVATE KEY-----\s*$/m, "PKCS#8 output has correct footer"); unlike($pkcs8_pem, qr/BEGIN RSA PRIVATE KEY/, "PKCS#8 output is not PKCS#1 format"); - # round-trip: import PKCS#8, re-export as PKCS#1, compare - my $reimported = Crypt::OpenSSL::RSA->new_private_key($pkcs8_pem); - is($reimported->get_private_key_string(), $DECRYPT_PRIVATE_KEY_STRING, - "PKCS#8 round-trip: re-import then export as PKCS#1 matches original"); - - # PKCS#8 round-trip - is($reimported->get_private_key_pkcs8_string(), $pkcs8_pem, - "PKCS#8 round-trip: re-export as PKCS#8 matches"); - - # encrypted PKCS#8 + # encrypted PKCS#8 export my $pass = 'test_pkcs8_pass'; my $enc_pem = $rsa->get_private_key_pkcs8_string($pass, 'aes-128-cbc'); like($enc_pem, qr/^-----BEGIN ENCRYPTED PRIVATE KEY-----/m, "encrypted PKCS#8 has correct header"); - my $dec_rsa = Crypt::OpenSSL::RSA->new_private_key($enc_pem, $pass); - is($dec_rsa->get_private_key_string(), $DECRYPT_PRIVATE_KEY_STRING, - "encrypted PKCS#8 round-trip decrypts to original key"); + + # Round-trip tests require new_private_key to read PKCS#8. On pre-3.x + # PEM_read_bio_PrivateKey is macro'd to PEM_read_bio_RSAPrivateKey which + # only reads PKCS#1, so these must be skipped. + SKIP: { + skip "new_private_key cannot read PKCS#8 on OpenSSL < 3.x", 3 + if $major < 3; + + my $reimported = Crypt::OpenSSL::RSA->new_private_key($pkcs8_pem); + is($reimported->get_private_key_string(), $DECRYPT_PRIVATE_KEY_STRING, + "PKCS#8 round-trip: re-import then export as PKCS#1 matches original"); + is($reimported->get_private_key_pkcs8_string(), $pkcs8_pem, + "PKCS#8 round-trip: re-export as PKCS#8 matches"); + + my $dec_rsa = Crypt::OpenSSL::RSA->new_private_key($enc_pem, $pass); + is($dec_rsa->get_private_key_string(), $DECRYPT_PRIVATE_KEY_STRING, + "encrypted PKCS#8 round-trip decrypts to original key"); + } # error: cipher without passphrase eval { $rsa->get_private_key_pkcs8_string(undef, 'des3') };