diff --git a/RSA.pm b/RSA.pm index ed0e00f..ffa91d2 100644 --- a/RSA.pm +++ b/RSA.pm @@ -19,14 +19,54 @@ BEGIN { sub new_public_key { my ( $proto, $p_key_string ) = @_; + croak "unrecognized key format: expected PEM-encoded key (starting with '-----BEGIN') " + . "or DER-encoded key (binary ASN.1 data)" + unless defined $p_key_string && length($p_key_string) > 0; if ( $p_key_string =~ /^-----BEGIN RSA PUBLIC KEY-----/ ) { return $proto->_new_public_key_pkcs1($p_key_string); } elsif ( $p_key_string =~ /^-----BEGIN PUBLIC KEY-----/ ) { return $proto->_new_public_key_x509($p_key_string); } + elsif ( $p_key_string =~ /^-----/ ) { + croak "unrecognized key format: PEM header not recognized as RSA public key. " + . "Expected '-----BEGIN RSA PUBLIC KEY-----' (PKCS#1) or " + . "'-----BEGIN PUBLIC KEY-----' (X.509)"; + } + elsif ( substr($p_key_string, 0, 1) eq "\x30" ) { + # ASN.1 SEQUENCE tag detected — likely DER-encoded key. + # Search for the RSA OID (1.2.840.113549.1.1.1) in raw binary to distinguish + # X.509 SubjectPublicKeyInfo from PKCS#1 RSAPublicKey. + if (index($p_key_string, "\x06\x09\x2a\x86\x48\x86\xf7\x0d\x01\x01\x01") >= 0) { + # RSA encryption OID found — X.509 SubjectPublicKeyInfo + return $proto->_new_public_key_x509_der($p_key_string); + } + else { + # No OID — assume PKCS#1 RSAPublicKey, let OpenSSL reject invalid data + return $proto->_new_public_key_pkcs1_der($p_key_string); + } + } + else { + croak "unrecognized key format: expected PEM-encoded key (starting with '-----BEGIN') " + . "or DER-encoded key (binary ASN.1 data)"; + } +} + +sub new_private_key { + my ( $proto, $p_key_string, @rest ) = @_; + croak "unrecognized key format: expected PEM-encoded key (starting with '-----BEGIN') " + . "or DER-encoded key (binary ASN.1 data)" + unless defined $p_key_string && length($p_key_string) > 0; + if ( $p_key_string =~ /^-----/ ) { + return $proto->_new_private_key_pem($p_key_string, @rest); + } + elsif ( substr($p_key_string, 0, 1) eq "\x30" ) { + # ASN.1 SEQUENCE tag detected — likely DER-encoded private key. + return $proto->_new_private_key_der($p_key_string); + } else { - croak "unrecognized key format"; + croak "unrecognized key format: expected PEM-encoded key (starting with '-----BEGIN') " + . "or DER-encoded key (binary ASN.1 data)"; } } @@ -125,9 +165,15 @@ this (never documented) behavior is no longer the case. =item new_public_key Create a new C object by loading a public key in -from a string containing Base64/DER-encoding of either the PKCS1 or -X.509 representation of the key. The string should include the -C<-----BEGIN...-----> and C<-----END...-----> lines. +from a string containing either PEM or DER encoding of the PKCS#1 or +X.509 representation of the key. + +For PEM keys, the string should include the C<-----BEGIN...-----> and +C<-----END...-----> lines. Both C (PKCS#1) and +C (X.509/SubjectPublicKeyInfo) formats are supported. + +DER-encoded keys (raw binary ASN.1) are also accepted and the format +(PKCS#1 vs X.509) is auto-detected. The padding is set to PKCS1_OAEP, but can be changed with the C methods. @@ -138,18 +184,23 @@ C or C prior to signing operations. =item new_private_key Create a new C object by loading a private key in -from an string containing the Base64/DER encoding of the PKCS1 -representation of the key. The string should include the -C<-----BEGIN...-----> and C<-----END...-----> lines. The padding is set to -PKCS1_OAEP, but can be changed with C. +from a string containing either PEM or DER encoding of the key. + +For PEM keys, the string should include the C<-----BEGIN...-----> and +C<-----END...-----> lines. The padding is set to PKCS1_OAEP, but can +be changed with C. -An optional parameter can be passed for passphase protected private key: +DER-encoded keys (raw binary ASN.1) are also accepted. + +An optional parameter can be passed for passphrase-protected PEM private +keys: =over -=item passphase +=item passphrase -The passphase which protects the private key. +The passphrase which protects the private key. Note: passphrase +protection is only supported for PEM-encoded keys. =back diff --git a/RSA.xs b/RSA.xs index 10b4a4d..306bb9b 100644 --- a/RSA.xs +++ b/RSA.xs @@ -25,6 +25,7 @@ #include #include #include +#include #endif #if OPENSSL_VERSION_NUMBER >= 0x30000000L @@ -476,7 +477,7 @@ BOOT: #endif SV* -new_private_key(proto, key_string_SV, passphase_SV=&PL_sv_undef) +_new_private_key_pem(proto, key_string_SV, passphase_SV=&PL_sv_undef) SV* proto; SV* key_string_SV; SV* passphase_SV; @@ -506,6 +507,107 @@ _new_public_key_x509(proto, key_string_SV) OUTPUT: RETVAL +SV* +_new_public_key_x509_der(proto, key_string_SV) + SV* proto; + SV* key_string_SV; + PREINIT: + STRLEN keyStringLength; + char* keyString; + EVP_PKEY* pkey; + BIO* bio; + CODE: + keyString = SvPV(key_string_SV, keyStringLength); + CHECK_OPEN_SSL(bio = BIO_new_mem_buf(keyString, keyStringLength)); +#if OPENSSL_VERSION_NUMBER >= 0x30000000L + pkey = d2i_PUBKEY_bio(bio, NULL); +#else + pkey = d2i_RSA_PUBKEY_bio(bio, NULL); +#endif + BIO_free(bio); + CHECK_OPEN_SSL(pkey); + RETVAL = make_rsa_obj(proto, pkey); + OUTPUT: + RETVAL + +SV* +_new_public_key_pkcs1_der(proto, key_string_SV) + SV* proto; + SV* key_string_SV; + PREINIT: + STRLEN keyStringLength; + char* keyString; + EVP_PKEY* pkey; + BIO* bio; +#if OPENSSL_VERSION_NUMBER >= 0x30000000L + OSSL_DECODER_CTX* dctx; +#endif + CODE: + keyString = SvPV(key_string_SV, keyStringLength); + CHECK_OPEN_SSL(bio = BIO_new_mem_buf(keyString, keyStringLength)); +#if OPENSSL_VERSION_NUMBER >= 0x30000000L + pkey = NULL; + dctx = OSSL_DECODER_CTX_new_for_pkey(&pkey, "DER", "type-specific", + "RSA", OSSL_KEYMGMT_SELECT_PUBLIC_KEY, + NULL, NULL); + if (!dctx) { + BIO_free(bio); + croakSsl(__FILE__, __LINE__); + } + if (!OSSL_DECODER_from_bio(dctx, bio)) { + OSSL_DECODER_CTX_free(dctx); + BIO_free(bio); + croakSsl(__FILE__, __LINE__); + } + OSSL_DECODER_CTX_free(dctx); +#else + pkey = d2i_RSAPublicKey_bio(bio, NULL); +#endif + BIO_free(bio); + CHECK_OPEN_SSL(pkey); + RETVAL = make_rsa_obj(proto, pkey); + OUTPUT: + RETVAL + +SV* +_new_private_key_der(proto, key_string_SV) + SV* proto; + SV* key_string_SV; + PREINIT: + STRLEN keyStringLength; + char* keyString; + EVP_PKEY* pkey; + BIO* bio; +#if OPENSSL_VERSION_NUMBER >= 0x30000000L + OSSL_DECODER_CTX* dctx; +#endif + CODE: + keyString = SvPV(key_string_SV, keyStringLength); + CHECK_OPEN_SSL(bio = BIO_new_mem_buf(keyString, keyStringLength)); +#if OPENSSL_VERSION_NUMBER >= 0x30000000L + pkey = NULL; + dctx = OSSL_DECODER_CTX_new_for_pkey(&pkey, "DER", NULL, + "RSA", OSSL_KEYMGMT_SELECT_ALL, + NULL, NULL); + if (!dctx) { + BIO_free(bio); + croakSsl(__FILE__, __LINE__); + } + if (!OSSL_DECODER_from_bio(dctx, bio)) { + OSSL_DECODER_CTX_free(dctx); + BIO_free(bio); + croakSsl(__FILE__, __LINE__); + } + OSSL_DECODER_CTX_free(dctx); +#else + pkey = d2i_RSAPrivateKey_bio(bio, NULL); +#endif + BIO_free(bio); + CHECK_OPEN_SSL(pkey); + RETVAL = make_rsa_obj(proto, pkey); + OUTPUT: + RETVAL + void DESTROY(p_rsa) rsaData* p_rsa; diff --git a/t/der.t b/t/der.t new file mode 100644 index 0000000..1d919d5 --- /dev/null +++ b/t/der.t @@ -0,0 +1,129 @@ +use strict; +use warnings; +use Test::More; +use MIME::Base64; +use Crypt::OpenSSL::RSA; + +BEGIN { plan tests => 22 } + +# --- Generate a key pair for testing --- + +my $rsa = Crypt::OpenSSL::RSA->generate_key(2048); + +# --- Extract PEM public keys --- + +my $pkcs1_pem = $rsa->get_public_key_string(); # PKCS#1 (BEGIN RSA PUBLIC KEY) +my $x509_pem = $rsa->get_public_key_x509_string(); # X.509 (BEGIN PUBLIC KEY) + +# --- Convert PEM to DER by stripping headers and base64-decoding --- + +sub pem_to_der { + my ($pem) = @_; + $pem =~ s/-----BEGIN [^-]+-----//; + $pem =~ s/-----END [^-]+-----//; + $pem =~ s/\s+//g; + return decode_base64($pem); +} + +my $pkcs1_der = pem_to_der($pkcs1_pem); +my $x509_der = pem_to_der($x509_pem); + +# Sanity check: DER data starts with ASN.1 SEQUENCE tag +is( ord(substr($pkcs1_der, 0, 1)), 0x30, "PKCS#1 DER starts with SEQUENCE tag" ); +is( ord(substr($x509_der, 0, 1)), 0x30, "X.509 DER starts with SEQUENCE tag" ); + +# --- Load DER keys via new_public_key --- + +my ($pub_from_x509_der, $pub_from_pkcs1_der); + +ok( $pub_from_x509_der = Crypt::OpenSSL::RSA->new_public_key($x509_der), + "new_public_key loads X.509 DER key" ); + +ok( $pub_from_pkcs1_der = Crypt::OpenSSL::RSA->new_public_key($pkcs1_der), + "new_public_key loads PKCS#1 DER key" ); + +# --- Verify round-trip: DER-loaded keys produce the same PEM output --- + +is( $pub_from_x509_der->get_public_key_x509_string(), $x509_pem, + "X.509 DER key exports to same X.509 PEM" ); + +is( $pub_from_x509_der->get_public_key_string(), $pkcs1_pem, + "X.509 DER key exports to same PKCS#1 PEM" ); + +is( $pub_from_pkcs1_der->get_public_key_x509_string(), $x509_pem, + "PKCS#1 DER key exports to same X.509 PEM" ); + +is( $pub_from_pkcs1_der->get_public_key_string(), $pkcs1_pem, + "PKCS#1 DER key exports to same PKCS#1 PEM" ); + +# --- Verify DER-loaded keys can actually verify signatures --- + +$rsa->use_sha256_hash(); +my $plaintext = "Hello, DER world!"; +my $sig = $rsa->sign($plaintext); + +$pub_from_x509_der->use_sha256_hash(); +ok( $pub_from_x509_der->verify($plaintext, $sig), + "X.509 DER-loaded key verifies signature" ); + +$pub_from_pkcs1_der->use_sha256_hash(); +ok( $pub_from_pkcs1_der->verify($plaintext, $sig), + "PKCS#1 DER-loaded key verifies signature" ); + +# --- Private key DER support --- + +my $priv_pem = $rsa->get_private_key_string(); +my $priv_der = pem_to_der($priv_pem); + +is( ord(substr($priv_der, 0, 1)), 0x30, "Private key DER starts with SEQUENCE tag" ); + +my $priv_from_der; +ok( $priv_from_der = Crypt::OpenSSL::RSA->new_private_key($priv_der), + "new_private_key loads DER-encoded private key" ); + +ok( $priv_from_der->is_private(), + "DER-loaded private key is recognized as private" ); + +is( $priv_from_der->get_public_key_x509_string(), $x509_pem, + "DER-loaded private key exports same public key" ); + +# Verify DER-loaded private key can sign and original public key can verify +$priv_from_der->use_sha256_hash(); +my $sig2 = $priv_from_der->sign($plaintext); +ok( $pub_from_x509_der->verify($plaintext, $sig2), + "signature from DER-loaded private key verifies" ); + +# Error: DER-like data for private key +eval { Crypt::OpenSSL::RSA->new_private_key("\x30\x00") }; +ok( $@, "new_private_key croaks on truncated DER data" ); + +# Error: bogus binary data for private key +eval { Crypt::OpenSSL::RSA->new_private_key("\x01\x02\x03\x04") }; +like( $@, qr/unrecognized key format/, + "new_private_key gives helpful error on random binary data" ); + +# PEM private keys still work through the wrapper +my $priv_from_pem; +ok( $priv_from_pem = Crypt::OpenSSL::RSA->new_private_key($priv_pem), + "new_private_key still loads PEM-encoded private key" ); + +# --- Error cases --- + +# DER-like data that isn't a valid key (no RSA OID, so falls through to PKCS#1 path) +eval { Crypt::OpenSSL::RSA->new_public_key("\x30\x00") }; +ok( $@, "new_public_key croaks on truncated DER data" ); + +# Completely bogus binary data (not starting with 0x30) +eval { Crypt::OpenSSL::RSA->new_public_key("\x01\x02\x03\x04") }; +like( $@, qr/unrecognized key format/, + "new_public_key gives helpful error on random binary data" ); + +# Empty string +eval { Crypt::OpenSSL::RSA->new_public_key("") }; +like( $@, qr/unrecognized key format/, + "new_public_key gives helpful error on empty string" ); + +# PEM header for wrong type +eval { Crypt::OpenSSL::RSA->new_public_key("-----BEGIN CERTIFICATE-----\nfoo\n-----END CERTIFICATE-----\n") }; +like( $@, qr/unrecognized key format/, + "new_public_key gives helpful error on certificate PEM" ); diff --git a/t/openssl_der.t b/t/openssl_der.t new file mode 100644 index 0000000..bfeb0c4 --- /dev/null +++ b/t/openssl_der.t @@ -0,0 +1,159 @@ +use strict; +use warnings; +use Test::More; +use MIME::Base64 qw/decode_base64/; +use File::Temp qw/ tempfile /; + +use Crypt::OpenSSL::RSA; +use Crypt::OpenSSL::Bignum; + +BEGIN { + unless ($ENV{AUTHOR_TESTING}) { + print qq{1..0 # SKIP these tests are for testing by the author\n}; + exit + } +} +my ($rsa_fh, $rsa_file) = tempfile(UNLINK => 1); + +# Create a new RSA key +`openssl genrsa -out $rsa_file 2048 > /dev/null 2>&1`; + +# Get the output as text that includes the private key PEM +my $priv_output = `openssl rsa -inform PEM -in $rsa_file -text 2>&1`; + +# X.509 SubjectPublicKeyInfo format (BEGIN PUBLIC KEY) +my $pub_x509_output = `openssl rsa -inform PEM -in $rsa_file -pubout -text 2>&1`; + +# PKCS#1 RSAPublicKey format (BEGIN RSA PUBLIC KEY) +my $pub_pkcs1_output = `openssl rsa -inform PEM -in $rsa_file -RSAPublicKey_out -text 2>&1`; + +# Basic grab multi-line data between +# two tags from openssl -text output +sub get_parameter { + my $text = shift; + my $start = shift; + my $end = shift; + + # Fieldname may end in ':' + $text =~ /$start:*\s*(.*?)\s*$end:*/s; + my $parameter = $1; + return undef unless defined $parameter; + # Remove ':' and white space including newlines + $parameter =~ s/[:\s]//g; + + # The exponent data we want is the hex data + # with no 0x prefix + if($parameter =~ /\((.*?)\)/ ) { + $parameter = $1; + $parameter =~ s/0x//g; + } + return $parameter; +} + +# Extract the base64 PEM body between header/footer lines +sub extract_pem_body { + my ($text, $header_re, $footer_re) = @_; + if ($text =~ /($header_re)\s*(.*?)\s*($footer_re)/s) { + my $body = $2; + $body =~ s/\s//g; + return $body; + } + return undef; +} + +# Compare a bignum to hex data +sub compare_bignum_to_hex { + my $bn1 = shift; + my $hex = shift; + + my $bn2 = Crypt::OpenSSL::Bignum->new_from_hex($hex); + isa_ok($bn2, 'Crypt::OpenSSL::Bignum'); + return $bn2->cmp($bn1); +} + +#################### +# Check private key +#################### +diag("Check private key"); +# Extract the values from the openssl private key output +my $priv_n = get_parameter($priv_output, 'modulus', 'publicExponent'); +my $priv_e = get_parameter($priv_output, 'publicExponent', 'privateExponent'); +my $priv_d = get_parameter($priv_output, 'privateExponent', 'prime1'); +my $priv_p = get_parameter($priv_output, 'prime1', 'prime2'); +my $priv_q = get_parameter($priv_output, 'prime2', 'exponent1'); +my $priv_dmp1 = get_parameter($priv_output, 'exponent1', 'exponent2'); +my $priv_dmq1 = get_parameter($priv_output, 'exponent2', 'coefficient'); +my $priv_iqmp = get_parameter($priv_output, 'coefficient', '-----BEGIN .*PRIVATE KEY-----'); +my $priv_pem = extract_pem_body($priv_output, + '-----BEGIN .*PRIVATE KEY-----', '-----END .*PRIVATE KEY-----'); + +# Load the private key from the DER (base64 decoded PEM) +my $rsa = Crypt::OpenSSL::RSA->new_private_key(decode_base64($priv_pem)); + +# Get the private key parameters +my ($n, $e, $d, $p, $q, $dmp1, $dmq1, $iqmp) = $rsa->get_key_parameters(); + +# Check each private key parameter to the expected values +ok(compare_bignum_to_hex($n, $priv_n) == 0, "Imported DER n parameter matches expected"); +ok(compare_bignum_to_hex($e, $priv_e) == 0, "Imported DER e parameter matches expected"); +ok(compare_bignum_to_hex($d, $priv_d) == 0, "Imported DER d parameter matches expected"); +ok(compare_bignum_to_hex($p, $priv_p) == 0, "Imported DER p parameter matches expected"); +ok(compare_bignum_to_hex($q, $priv_q) == 0, "Imported DER q parameter matches expected"); +ok(compare_bignum_to_hex($dmp1, $priv_dmp1) == 0, "Imported DER dmp1 parameter matches expected"); +ok(compare_bignum_to_hex($dmq1, $priv_dmq1) == 0, "Imported DER dmq1 parameter matches expected"); +ok(compare_bignum_to_hex($iqmp, $priv_iqmp) == 0, "Imported DER iqmp parameter matches expected"); + +################################### +# Check X.509 SubjectPublicKeyInfo +################################### +diag("Check X.509 public key (from -pubout)"); +# Extract PEM body — -pubout produces X.509 SubjectPublicKeyInfo (BEGIN PUBLIC KEY) +my $pub_x509_pem = extract_pem_body($pub_x509_output, + '-----BEGIN PUBLIC KEY-----', '-----END PUBLIC KEY-----'); + +# Load the public key from the DER (base64 decoded PEM) +my $pub_x509_rsa = Crypt::OpenSSL::RSA->new_public_key(decode_base64($pub_x509_pem)); + +# Get the key parameters +my ($px_n, $px_e, $px_d, $px_p, $px_q, $px_dmp1, $px_dmq1, $px_iqmp) = $pub_x509_rsa->get_key_parameters(); + +# n and e should match the private key's values (same key) +ok(compare_bignum_to_hex($px_n, $priv_n) == 0, "X.509 public DER n matches private key n"); +ok(compare_bignum_to_hex($px_e, $priv_e) == 0, "X.509 public DER e matches private key e"); +ok(!$px_d, "X.509 public DER d parameter undef as expected"); +ok(!$px_p, "X.509 public DER p parameter undef as expected"); +ok(!$px_q, "X.509 public DER q parameter undef as expected"); +ok(!$px_dmp1, "X.509 public DER dmp1 parameter undef as expected"); +ok(!$px_dmq1, "X.509 public DER dmq1 parameter undef as expected"); +ok(!$px_iqmp, "X.509 public DER iqmp parameter undef as expected"); + +############################# +# Check PKCS#1 RSAPublicKey +############################# +diag("Check PKCS#1 public key (from -RSAPublicKey_out)"); +# Extract PEM body — -RSAPublicKey_out produces PKCS#1 (BEGIN RSA PUBLIC KEY) +my $pub_pkcs1_pem = extract_pem_body($pub_pkcs1_output, + '-----BEGIN RSA PUBLIC KEY-----', '-----END RSA PUBLIC KEY-----'); + +SKIP: { + skip "openssl does not support -RSAPublicKey_out", 8 + unless defined $pub_pkcs1_pem && length($pub_pkcs1_pem) > 0; + + # Load the public key from the DER (base64 decoded PEM) + my $pub_pkcs1_rsa = Crypt::OpenSSL::RSA->new_public_key(decode_base64($pub_pkcs1_pem)); + + # Get the key parameters + my ($pn, $pe, $pd, $pp, $pq, $pdmp1, $pdmq1, $piqmp) = $pub_pkcs1_rsa->get_key_parameters(); + + # n and e should match the private key's values (same key) + ok(compare_bignum_to_hex($pn, $priv_n) == 0, "PKCS#1 public DER n matches private key n"); + ok(compare_bignum_to_hex($pe, $priv_e) == 0, "PKCS#1 public DER e matches private key e"); + ok(!$pd, "PKCS#1 public DER d parameter undef as expected"); + ok(!$pp, "PKCS#1 public DER p parameter undef as expected"); + ok(!$pq, "PKCS#1 public DER q parameter undef as expected"); + ok(!$pdmp1, "PKCS#1 public DER dmp1 parameter undef as expected"); + ok(!$pdmq1, "PKCS#1 public DER dmq1 parameter undef as expected"); + ok(!$piqmp, "PKCS#1 public DER iqmp parameter undef as expected"); +} + +done_testing();