diff --git a/RSA.pm b/RSA.pm index 838cb02..f4488a6 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..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 @@ -500,6 +521,48 @@ 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; + 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 + CHECK_OPEN_SSL_BIO(_write_pkcs8_pem( + stringBIO, p_rsa->rsa, enc, (unsigned char*) passphase, passphaseLength), stringBIO); +#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..e7cb5cf 100644 --- a/t/format.t +++ b/t/format.t @@ -2,8 +2,11 @@ use strict; use Test::More; use Crypt::OpenSSL::RSA; +use Crypt::OpenSSL::Guess qw(openssl_version); -BEGIN { plan tests => 39 } +my ($major, $minor, $patch) = openssl_version(); + +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"); + + # 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"); + + # 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') }; + 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);