diff --git a/lib/Bitcoin/Secp256k1.pm b/lib/Bitcoin/Secp256k1.pm index e53e892..0a67b6f 100644 --- a/lib/Bitcoin/Secp256k1.pm +++ b/lib/Bitcoin/Secp256k1.pm @@ -1,19 +1,93 @@ package Bitcoin::Secp256k1; use v5.10; +use strict; use warnings; use Bytes::Random::Secure; +use Digest::SHA qw(sha256); +use Carp; + +# LOW LEVEL API +# XS defines constructor, destructor and some general utility methods +# interacting directly with libsecp256k1. All of these methods are private and +# subject to change. They are used internally to deliver high level API below. require XSLoader; XSLoader::load('Bitcoin::Secp256k1', $Bitcoin::Secp256k1::VERSION); +# HIGH LEVEL API +# These methods are implemented in Perl and deliver more convenient API to +# interact with. They are stable and public. + +sub create_public_key +{ + my ($self, $private_key) = @_; + + $self->_create_pubkey($private_key); + return $self->_pubkey; +} + +sub normalize_signature +{ + my ($self, $signature) = @_; + + $self->_signature($signature); + $self->_normalize; + + return $self->_signature; +} + +sub compress_public_key +{ + my ($self, $public_key, $compressed) = @_; + $compressed //= !!1; + + return $self->_pubkey($public_key, $compressed); +} + +sub sign_message +{ + my ($self, $private_key, $message) = @_; + + return $self->sign_digest($private_key, sha256(sha256($message))); +} + +sub sign_digest +{ + my ($self, $private_key, $digest) = @_; + + $self->_sign($private_key, $digest); + return $self->_signature; +} + +sub verify_message +{ + my ($self, $public_key, $signature, $message) = @_; + + return $self->verify_digest($public_key, $signature, sha256(sha256($message))); +} + +sub verify_digest +{ + my ($self, $public_key, $signature, $digest) = @_; + + $self->_pubkey($public_key); + $self->_signature($signature); + + if ($self->_normalize) { + carp 'Caution: signature to verify is not normalized'; + } + + return $self->_verify($digest); +} + 1; __END__ =head1 NAME -Bitcoin::Secp256k1 - New module +Bitcoin::Secp256k1 - Perl interface to libsecp256k1 =head1 SYNOPSIS @@ -23,11 +97,141 @@ Bitcoin::Secp256k1 - New module =head1 DESCRIPTION -This module lets you blah blah blah. +This module implements XS routines that allow accessing common libsecp256k1 +operations using Perl code. It requires libsecp256k1 to be installed on the +system, and will try to detect and install automatically using +L. + +=head1 INTERFACE + +=head2 Attributes + +None - object is a blessed readonly scalar reference with a memory address of a +C structure. As such, it does not contain any attributes accessible directly +from Perl. + +=head2 Methods + +=head3 new + + $secp256k1 = Bitcoin::Secp256k1->new() + +Object constructor. All methods in this package require this object to work +properly. It accepts no arguments. + +=head3 create_public_key + + $public_key = $secp256k1->create_public_key($private_key) + +Creates a public key from a bytestring C<$private_key> and returns a bytestring +C<$public_key>. C<$private_key> must have exact length of C<32>. + +The public key is always returned in compressed form, use L to get uncompressed form. + +=head3 normalize_signature + + $signature = $secp256k1->normalize_signature($signature) + +Performs signature normalization of C<$signature>, which is in DER encoding (a +bytestring). Returns the normalized signature. Will return the same signature +if it was already in a normalized form. + +Signature normalization is important because of Bitcoin protocol rules. +Normally, Bitcoin will reject transactions with malleable signatures. This +module will only emit a warning if you try to verify a signature that is not +normalized. + +This method lets you both detect whether the signature was malleable and fix it +to avoid a warning if needed. + +=head3 compress_public_key + + $public_key = $secp256k1->compress_public_key($public_key, $want_compressed = !!1) + +Changes the compression form of bytestring C<$public_key>. If +C<$want_compressed> is a true value (or omitted / undef), method will return +the key in compressed (default) form. If it is a false value, C<$public_key> +will be in uncompressed form. It accepts keys in both compressed and +uncompressed forms. + +While both compressed and uncompressed keys will behave the same during +signature verification, they produce different Bitcoin addresses (because +address is a hashed public key). + +=head3 sign_message + + $signature = $secp256k1->sign_message($private_key, $message) + +Signs C<$message>, which may be a bytestring of any length, with +C<$private_key>, which must be a bytestring of length C<32>. Returns +DER-encoded C<$signature> as a bytestring. + +C<$message> is first hashed with double SHA256 (known an HASH256 in Bitcoin) +before passing it to signing algorithm (which expects length C<32> bytestrings). + +This method always produces normalized, deterministic signatures suitable to +use inside a Bitcoin transaction. + +=head3 sign_digest + + $signature = $secp256k1->sign_digest($private_key, $message_digest) + +Same as L, but it does not perform double SHA256 on its input. +Because of that, C<$message_digest> must be a bytestring of length C<32>. + +=head3 verify_message + + $valid = $secp256k1->verify_message($public_key, $signature, $message) + +Verifies C<$signature> (DER-encoded, bytestring) of C<$message> (bytestring of +any length) against C<$public_key> (compressed or uncompressed, bytestring). +Returns true if verification is successful. + +C<$message> is first hashed with double SHA256 (known an HASH256 in Bitcoin) +before passing it to verification algorithm (which expects length C<32> bytestrings). + +Raises a warning if C<$siganture> is not normalized. It is recommended to +perform signature normalization using L first and either +accept or reject malleable signatures explicitly. + +=head3 verify_digest + + $valid = $secp256k1->verify_digest($public_key, $signature, $message_digest) + +Same as L, but it does not perform double SHA256 on its input. +Because of that, C<$message_digest> must be a bytestring of length C<32>. + +=head1 IMPLEMENTATION + +The module consists of two layers: + +=over + +=item + +High-level API, which consists of public, stable methods. These methods should +deliver most of the possible use cases for the library, but some paths may not +be covered. All of these methods simply accept and return values without +storing anything inside the object. + +=item + +Low-level API, which is implemented in XS and private. It interacts directly +with libsecp256k1 and is storing some intermediate state (but never the private +key) in a blessed C structure. It covers all of library's functions which are +valuable in Perl's context. Its existence is only significant to the author and +the contributors. + +Notable exceptions are the constructor L and the destructor, which are +also part of the low-level API, yet public. + +=back =head1 SEE ALSO -L +L + +L =head1 AUTHOR diff --git a/t/api.t b/t/api.t new file mode 100644 index 0000000..e011def --- /dev/null +++ b/t/api.t @@ -0,0 +1,52 @@ +use Test2::V0; +use Bitcoin::Secp256k1; +use Digest::SHA qw(sha256); + +use lib 't/lib'; +use Secp256k1Test; + +################################################################################ +# This tests whether high level Perl API is working correctly. +################################################################################ + +my $secp = Bitcoin::Secp256k1->new; +my %t = Secp256k1Test->test_data; + +subtest 'should derive a public key' => sub { + is $secp->create_public_key($t{privkey}), $t{pubkey}, 'pubkey derived ok'; +}; + +subtest 'should compress a public key' => sub { + is $secp->compress_public_key($t{pubkey_unc}), $t{pubkey}, 'pubkey compressed ok'; + is $secp->compress_public_key($t{pubkey}), $t{pubkey}, 'compressed pubkey intact ok'; + + is $secp->compress_public_key($t{pubkey}, 0), $t{pubkey_unc}, 'pubkey uncompressed ok'; + is $secp->compress_public_key($t{pubkey_unc}, 0), $t{pubkey_unc}, 'uncompressed pubkey intact ok'; +}; + +subtest 'should normalize a signature' => sub { + is $secp->normalize_signature($t{sig_unn}), $t{sig}, 'signature normalized ok'; + is $secp->normalize_signature($t{sig}), $t{sig}, 'normalized signature intact ok'; +}; + +subtest 'should sign and verify a message' => sub { + is $secp->sign_message($t{privkey}, $t{preimage}), $t{sig}, 'message signed ok'; + ok $secp->verify_message($t{pubkey}, $t{sig}, $t{preimage}), 'message verified ok'; + + is warns { + ok $secp->verify_message($t{pubkey}, $t{sig_unn}, $t{preimage}), 'unnormalized signature verified ok'; + }, 1, 'unnormalized signature warning ok'; +}; + +subtest 'should sign and verify a digest' => sub { + is $secp->sign_digest($t{privkey}, sha256(sha256($t{preimage}))), $t{sig}, 'digest signed ok'; + ok $secp->verify_digest($t{pubkey}, $t{sig}, sha256(sha256($t{preimage}))), 'digest verified ok'; + + is warns { + ok $secp->verify_digest($t{pubkey}, $t{sig_unn}, sha256(sha256($t{preimage}))), + 'unnormalized signature verified ok'; + }, 1, 'unnormalized signature warning ok'; +}; + +done_testing; + diff --git a/t/base.t b/t/base.t index 7db0933..0deb9d6 100644 --- a/t/base.t +++ b/t/base.t @@ -2,25 +2,18 @@ use Test2::V0; use Bitcoin::Secp256k1; use Digest::SHA qw(sha256); +use lib 't/lib'; +use Secp256k1Test; + ################################################################################ -# This tests whether the base methods defined in XS are working correctly. Data -# from: -# https://github.com/bitcoin/bips/blob/master/bip-0143.mediawiki#native-p2wpkh +# This tests whether the base methods defined in XS are working correctly. ################################################################################ my $secp; +my %t = Secp256k1Test->test_data; -my $sample_privkey = pack 'H*', - '619c335025c7f4012e556c2a58b2506e30b8511b53ade95ea316fd8c3286feb9'; -my $sample_pubkey = pack 'H*', '025476c2e83188368da1ff3e292e7acafcdb3566bb0ad253f62fc70f07aeee6357'; -my $sample_pubkey_unc = pack 'H*', - '045476c2e83188368da1ff3e292e7acafcdb3566bb0ad253f62fc70f07aeee6357fd57dee6b46a6b010a3e4a70961ecf44a40e18b279ec9e9fba9c1dbc64896198'; -my $sample_preimage = pack 'H*', - '0100000096b827c8483d4e9b96712b6713a7b68d6e8003a781feba36c31143470b4efd3752b0a642eea2fb7ae638c36f6252b6750293dbe574a806984b8e4d8548339a3bef51e1b804cc89d182d279655c3aa89e815b1b309fe287d9b2b55d57b90ec68a010000001976a9141d0f172a0ecb48aee1be1f2687d2963ae33f71a188ac0046c32300000000ffffffff863ef3e1a92afbfdb97f31ad0fc7683ee943e9abcf2501590ff8f6551f47e5e51100000001000000'; -my $sample_sig = pack 'H*', - '304402203609e17b84f6a7d30c80bfa610b5b4542f32a8a0d5447a12fb1366d7f01cc44a0220573a954c4518331561406f90300e8f3358f51928d43c212a8caed02de67eebee'; -my $sample_sig_unnormalized = pack 'H*', - '304502203609e17b84f6a7d30c80bfa610b5b4542f32a8a0d5447a12fb1366d7f01cc44a022100a8c56ab3bae7ccea9ebf906fcff170cb61b9c3bddb0c7f1133238e5ee9b75553'; +my $partial_digest = sha256($t{preimage}); +my $digest = sha256($partial_digest); subtest 'should create and destroy' => sub { $secp = Bitcoin::Secp256k1->new(); @@ -29,54 +22,48 @@ subtest 'should create and destroy' => sub { subtest 'should import and export pubkey' => sub { is $secp->_pubkey, undef, 'starting pubkey ok'; - is $secp->_pubkey($sample_pubkey), $sample_pubkey, 'setter ok'; - is $secp->_pubkey, $sample_pubkey, 'getter ok'; - is $secp->_pubkey(undef, 1), $sample_pubkey, 'getter with explicit compression ok'; - is $secp->_pubkey(undef, 0), $sample_pubkey_unc, 'getter with explicit (un)compression ok'; - is $secp->_pubkey($sample_pubkey_unc), $sample_pubkey, 'setter with uncompressed input, compressed output ok'; + is $secp->_pubkey($t{pubkey}), $t{pubkey}, 'setter ok'; + is $secp->_pubkey, $t{pubkey}, 'getter ok'; + is $secp->_pubkey(undef, 1), $t{pubkey}, 'getter with explicit compression ok'; + is $secp->_pubkey(undef, 0), $t{pubkey_unc}, 'getter with explicit (un)compression ok'; + is $secp->_pubkey($t{pubkey_unc}), $t{pubkey}, 'setter with uncompressed input, compressed output ok'; }; subtest 'should import and export signature' => sub { is $secp->_signature, undef, 'starting sig ok'; - is $secp->_signature($sample_sig), $sample_sig, 'setter ok'; - is $secp->_signature, $sample_sig, 'getter ok'; + is $secp->_signature($t{sig}), $t{sig}, 'setter ok'; + is $secp->_signature, $t{sig}, 'getter ok'; }; subtest 'should generate a public key' => sub { $secp->_clear; is $secp->_pubkey, undef, 'cleared pubkey ok'; - $secp->_create_pubkey($sample_privkey); - is $secp->_pubkey, $sample_pubkey, 'pubkey ok'; + $secp->_create_pubkey($t{privkey}); + is $secp->_pubkey, $t{pubkey}, 'pubkey ok'; }; subtest 'should verify a signature' => sub { - my $sample_partial_digest = sha256($sample_preimage); - my $sample_digest = sha256($sample_partial_digest); - - $secp->_pubkey($sample_pubkey); - $secp->_signature($sample_sig); + $secp->_pubkey($t{pubkey}); + $secp->_signature($t{sig}); - ok $secp->_verify($sample_digest), 'digest verification ok'; - ok !$secp->_verify($sample_partial_digest), 'incorrect digest verification failed ok'; + ok $secp->_verify($digest), 'digest verification ok'; + ok !$secp->_verify($partial_digest), 'incorrect digest verification failed ok'; }; subtest 'should generate a signature' => sub { - my $sample_partial_digest = sha256($sample_preimage); - my $sample_digest = sha256($sample_partial_digest); - - $secp->_sign($sample_privkey, $sample_partial_digest); - isnt $secp->_signature, $sample_sig, 'incorrect digest signing failed ok'; + $secp->_sign($t{privkey}, $partial_digest); + isnt $secp->_signature, $t{sig}, 'incorrect digest signing failed ok'; - $secp->_sign($sample_privkey, $sample_digest); - is $secp->_signature, $sample_sig, 'signing ok'; + $secp->_sign($t{privkey}, $digest); + is $secp->_signature, $t{sig}, 'signing ok'; }; subtest 'should normalize a signature' => sub { - $secp->_signature($sample_sig_unnormalized); + $secp->_signature($t{sig_unn}); ok $secp->_normalize, 'signature normalized ok'; - is $secp->_signature, $sample_sig, 'signature ok'; + is $secp->_signature, $t{sig}, 'signature ok'; ok !$secp->_normalize, 'already normalized ok'; }; diff --git a/t/lib/Secp256k1Test.pm b/t/lib/Secp256k1Test.pm new file mode 100644 index 0000000..a855caa --- /dev/null +++ b/t/lib/Secp256k1Test.pm @@ -0,0 +1,34 @@ +package Secp256k1Test; + +use v5.10; +use strict; +use warnings; + +# https://github.com/bitcoin/bips/blob/master/bip-0143.mediawiki#native-p2wpkh + +my $sample_privkey = pack 'H*', + '619c335025c7f4012e556c2a58b2506e30b8511b53ade95ea316fd8c3286feb9'; +my $sample_pubkey = pack 'H*', '025476c2e83188368da1ff3e292e7acafcdb3566bb0ad253f62fc70f07aeee6357'; +my $sample_pubkey_unc = pack 'H*', + '045476c2e83188368da1ff3e292e7acafcdb3566bb0ad253f62fc70f07aeee6357fd57dee6b46a6b010a3e4a70961ecf44a40e18b279ec9e9fba9c1dbc64896198'; +my $sample_preimage = pack 'H*', + '0100000096b827c8483d4e9b96712b6713a7b68d6e8003a781feba36c31143470b4efd3752b0a642eea2fb7ae638c36f6252b6750293dbe574a806984b8e4d8548339a3bef51e1b804cc89d182d279655c3aa89e815b1b309fe287d9b2b55d57b90ec68a010000001976a9141d0f172a0ecb48aee1be1f2687d2963ae33f71a188ac0046c32300000000ffffffff863ef3e1a92afbfdb97f31ad0fc7683ee943e9abcf2501590ff8f6551f47e5e51100000001000000'; +my $sample_sig = pack 'H*', + '304402203609e17b84f6a7d30c80bfa610b5b4542f32a8a0d5447a12fb1366d7f01cc44a0220573a954c4518331561406f90300e8f3358f51928d43c212a8caed02de67eebee'; +my $sample_sig_unn = pack 'H*', + '304502203609e17b84f6a7d30c80bfa610b5b4542f32a8a0d5447a12fb1366d7f01cc44a022100a8c56ab3bae7ccea9ebf906fcff170cb61b9c3bddb0c7f1133238e5ee9b75553'; + +sub test_data +{ + return ( + privkey => $sample_privkey, + pubkey => $sample_pubkey, + pubkey_unc => $sample_pubkey_unc, + preimage => $sample_preimage, + sig => $sample_sig, + sig_unn => $sample_sig_unn, + ); +} + +1; + diff --git a/xt/author/leaks.t b/xt/author/leaks.t index 07f4623..604f90c 100644 --- a/xt/author/leaks.t +++ b/xt/author/leaks.t @@ -12,7 +12,7 @@ plan skip_all => 'This test requires Test::MemoryGrowth module' Test::MemoryGrowth::no_growth { my $secp = Bitcoin::Secp256k1->new; } -calls => 100, 'construction/destruction of Bitcoin::Secp256k1 does not leak'; +calls => 1000, 'construction/destruction of Bitcoin::Secp256k1 does not leak'; done_testing;