Skip to content

Commit

Permalink
First draft of the high-level API
Browse files Browse the repository at this point in the history
  • Loading branch information
bbrtj committed Sep 17, 2024
1 parent 5ba3fae commit 2afb4da
Show file tree
Hide file tree
Showing 5 changed files with 320 additions and 43 deletions.
210 changes: 207 additions & 3 deletions lib/Bitcoin/Secp256k1.pm
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<Alien::libsecp256k1>.
=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</compress_public_key> 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</sign_message>, 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</normalize_signature> first and either
accept or reject malleable signatures explicitly.
=head3 verify_digest
$valid = $secp256k1->verify_digest($public_key, $signature, $message_digest)
Same as L</verify_message>, 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</new> and the destructor, which are
also part of the low-level API, yet public.
=back
=head1 SEE ALSO
L<Some::Module>
L<Alien::libsecp256k1>
L<Bitcoin::Crypto>
=head1 AUTHOR
Expand Down
52 changes: 52 additions & 0 deletions t/api.t
Original file line number Diff line number Diff line change
@@ -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;

65 changes: 26 additions & 39 deletions t/base.t
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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';
};

Expand Down
Loading

0 comments on commit 2afb4da

Please sign in to comment.