File Coverage

blib/lib/DigiByte/DigiID.pm
Criterion Covered Total %
statement 119 145 82.0
branch 15 36 41.6
condition 5 20 25.0
subroutine 19 21 90.4
pod 0 3 0.0
total 158 225 70.2


line stmt bran cond sub pod time code
1             package DigiByte::DigiID;
2             $DigiByte::DigiID::VERSION = '0.002';
3 2     2   71120 use strict;
  2         12  
  2         58  
4 2     2   10 use warnings;
  2         2  
  2         51  
5 2     2   9 use base 'Exporter';
  2         4  
  2         287  
6              
7             our @EXPORT_OK = qw(
8             extract_nonce
9             get_qrcode
10             verify_signature
11             );
12              
13 2     2   955 use Crypto::ECC;
  2         247721  
  2         274  
14 2     2   1011 use Crypt::OpenPGP::Digest; ## RIPEMD160
  2         5438  
  2         54  
15 2     2   969 use Crypt::OpenSSL::Random;
  2         2298  
  2         97  
16 2     2   1163 use Digest::SHA qw(sha256);
  2         5611  
  2         180  
17 2     2   15 use Math::BigInt lib => 'GMP';
  2         5  
  2         16  
18 2     2   2011 use MIME::Base64 qw(decode_base64);
  2         1261  
  2         131  
19 2     2   1043 use String::Pad qw(pad);
  2         813  
  2         117  
20 2     2   980 use URI::Escape qw(uri_escape);
  2         3168  
  2         3686  
21              
22             my $STR_PAD_LEFT = 'l';
23             my %SECP256K1 = (
24             a => 00,
25             b => 07,
26             prime =>
27             '0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F',
28             x => '0x79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798',
29             y => '0x483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8',
30             order =>
31             '0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141',
32             );
33              
34             sub get_qrcode {
35 0     0 0 0 my ( $server_domain, %options ) = @_;
36              
37 0   0     0 my $nonce = $options{nonce} // unpack( "H*",
38             pack( "B*", Crypt::OpenSSL::Random::random_pseudo_bytes(16) ) );
39              
40 0   0     0 my $path = $options{path} // '/callback';
41              
42 0         0 my $url = "digiid://$server_domain$path?x=$nonce";
43              
44 0 0       0 if ( $options{nossl} ) {
45 0         0 $url .= '&u=1';
46             }
47              
48 0         0 my $str = uri_escape($url);
49              
50             return (
51 0         0 nonce => $nonce,
52             callback => $url,
53             image =>
54             "https://chart.googleapis.com/chart?chs=200x200&cht=qr&chl=$str",
55             );
56             }
57              
58             sub extract_nonce {
59 0     0 0 0 my ($uri) = @_;
60 0         0 my ($nonce) = ( $uri =~ m/[\?\&]x=([^\&]+)/ );
61 0         0 return $nonce;
62             }
63              
64             sub verify_signature {
65 2     2 0 911 my ( $address, $signature, $message, $testnet ) = @_;
66              
67 2         9 my $decoded_address = _base58check_decode( $address, $testnet );
68 1         9 my @decoded_address = split //, $decoded_address;
69              
70 1 50 33     18 if ( length($decoded_address) != 21
      33        
      33        
      33        
71             || ( $decoded_address[0] ne "\x1E" && !$testnet )
72             || ( $decoded_address[0] ne "\x6F" && $testnet ) )
73             {
74 0         0 die "invalid DigiByte address";
75             }
76              
77 1         6 my $decoded_signature = decode_base64($signature);
78 1         11 my @decoded_signature = split //, $decoded_signature;
79              
80 1 50       15 if ( length($decoded_signature) != 65 ) {
81 0         0 die "invalid signature";
82             }
83              
84 1         4 my $recovery_flags = ord( $decoded_signature[0] ) - 27;
85              
86 1 50 33     6 if ( $recovery_flags < 0 || $recovery_flags > 7 ) {
87 0         0 die "invalid signature type";
88             }
89              
90 1         4 my $is_compressed = ( $recovery_flags & 4 ) != 0;
91              
92 1         5 my $message_hash = sha256(
93             sha256(
94             "\x19DigiByte Signed Message:\n"
95             . _num_to_var_int_string( length($message) )
96             . $message
97             )
98             );
99              
100 1         3 my $pubkey = do {
101 1         4 my $r = _bin2gmp( substr( $decoded_signature, 1, 32 ) );
102 1         5 my $s = _bin2gmp( substr( $decoded_signature, 33, 32 ) );
103 1         8 my $e = _bin2gmp($message_hash);
104 1         17 my $g = $Point->new(%SECP256K1);
105              
106 1         6192 _recover_pubkey( $r, $s, $e, $recovery_flags, $g );
107             };
108              
109 1 50       24332471 if ( !$pubkey ) {
110 0         0 die 'unable to recover key';
111             }
112              
113 1         5 my $point = $pubkey->point;
114              
115 1         2 my $pub_bin_str;
116              
117             ## see that the key we recovered is for the address given
118 1 50       5 if ($is_compressed) {
119 1 50       6 $pub_bin_str = ( _is_bignum_even( $point->y ) ? "\x02" : "\x03" )
120             . pad( _gmp2bin( $point->x ), 32, $STR_PAD_LEFT, "\x00" );
121             }
122             else {
123 0         0 $pub_bin_str = "\x04"
124             . pad( _gmp2bin( $point->x ), 32, $STR_PAD_LEFT, "\x00" )
125             . pad( _gmp2bin( $point->y ), 32, $STR_PAD_LEFT, "\x00" );
126             }
127              
128 1         35 my $ripemd160 = Crypt::OpenPGP::Digest->new('RIPEMD160');
129              
130 1         4192 my $derived_address;
131              
132 1 50       5 if ($testnet) {
133 0         0 $derived_address = "\x6F" . $ripemd160->hash( sha256($pub_bin_str) );
134             }
135             else {
136 1         19 $derived_address = "\x1E" . $ripemd160->hash( sha256($pub_bin_str) );
137             }
138              
139 1         83 return $decoded_address eq $derived_address;
140             }
141              
142             sub _base58check_decode {
143 2     2   5 my ( $address, $testnet ) = @_;
144              
145 2         4 my $decoded_address = $address;
146              
147 2         6 $decoded_address =~
148             tr{123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz}
149             {0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuv};
150              
151 2         5 $decoded_address =~ s/^0+//;
152              
153 2         9 my $v = Math::BigInt->from_base( $decoded_address, 58 );
154              
155 2         3671 $v = _gmp2bin($v);
156              
157 2         13 foreach my $chr ( split //, $address ) {
158 2 50       6 if ( $chr ne '1' ) {
159 2         5 last;
160             }
161 0 0       0 if ($testnet) {
162 0         0 $v = "\x6F$v";
163             }
164             else {
165 0         0 $v = "\x00$v";
166             }
167             }
168              
169 2         9 my $checksum = substr $v, -4;
170              
171 2         6 $v = substr $v, 0, -4;
172              
173 2         30 my $exp_check_sum = substr sha256( sha256($v) ), 0, 4;
174              
175 2 100       8 if ( $exp_check_sum ne $checksum ) {
176 1         11 die "Invalid checksum";
177             }
178              
179 1         4 return $v;
180             }
181              
182             sub _num_to_var_int_string {
183 1     1   3 my ($i) = @_;
184              
185 1 50       3 if ( $i < 0xfd ) {
    0          
    0          
186 1         13 return chr($i);
187             }
188             elsif ( $i <= 0xffff ) {
189 0         0 return pack( 'Cv', 0xfd, $i );
190             }
191             elsif ( $i <= 0xffffffff ) {
192 0         0 return pack( 'CV', 0xfe, $i );
193             }
194             else {
195 0         0 die 'int too large';
196             }
197             }
198              
199             sub _bin2gmp {
200 3     3   9 my ($bin_str) = @_;
201              
202 3         11 my $v = Math::BigInt->new(0);
203              
204 3         311 foreach my $ch ( split //, $bin_str ) {
205 96         12088 $v *= 256;
206 96         13477 $v += ord $ch;
207             }
208              
209 3         397 return $v;
210             }
211              
212             sub _gmp2bin {
213 3     3   8 my ($v) = @_;
214              
215 3         6 my $bin_str = '';
216              
217 3         9 while ( ( $v <=> 0 ) > 0 ) {
218 62         10803 my $r;
219 62         159 ( $v, $r ) = ( $v / 256, $v % 256 );
220 62         27548 $bin_str = chr($r) . $bin_str;
221             }
222              
223 3         516 return $bin_str;
224             }
225              
226             sub _recover_pubkey {
227 1     1   5 my ( $r, $s, $e, $recovery_flags, $_g ) = @_;
228              
229 1         4 my $is_y_even = ( $recovery_flags & 1 ) != 0;
230 1         3 my $is_second_key = ( $recovery_flags & 2 ) != 0;
231              
232 1         4 my $signature = $Signature->new( r => $r->copy, s => $s->copy );
233              
234 1         1237 my $p_over_four = ( $_g->prime + 1 ) / 4;
235              
236 1         472 my $x;
237              
238 1 50       3 if ($is_second_key) {
239 0         0 $x = $r + $_g->order;
240             }
241             else {
242 1         4 $x = $r->copy;
243             }
244              
245 1         23 my $alpha = ( ( ( $x**3 ) + ( $_g->a * $x ) ) + $_g->b ) % $_g->prime;
246 1         2957 my $beta = _modular_exp( $alpha, $p_over_four, $_g->prime );
247              
248 1         466988 my $y;
249              
250 1         6 my $is_bignum_even = _is_bignum_even($beta);
251              
252 1 50       6 if ( $is_bignum_even == $is_y_even ) {
253 0         0 $y = $_g->prime - $beta;
254             }
255             else {
256 1         4 $y = $beta;
257             }
258              
259 1         26 my $_r = $_g->copy(
260             x => $x,
261             y => $y,
262             );
263              
264 1         458 my $r_inv = $r->bmodinv( $_g->order );
265              
266 1         27325 my $mul_p = $Point->mul( $e, $_g );
267              
268 1         12273874 my $e_g_neg = $mul_p->negative;
269              
270 1         595 my $_q =
271             $Point->mul( $r_inv, $Point->add( $Point->mul( $s, $_r ), $e_g_neg ) );
272              
273 1         24475631 my $q_k = $PublicKey->new( generator => $_g, point => $_q );
274              
275 1 50       3906 return $q_k->verifies( $e, $signature ) ? $q_k : 0;
276             }
277              
278             sub _modular_exp {
279 1     1   4 my ( $base, $exponent, $modulus ) = @_;
280              
281 1 50       3 if ( $exponent < 0 ) {
282 0         0 die "Negative exponents (" . $exponent . ") not allowed";
283             }
284              
285 1         169 return $base->copy->bmodpow( $exponent, $modulus );
286             }
287              
288             sub _is_bignum_even {
289 2     2   7 my ($bn_str) = @_;
290              
291 2         12 my @bn_str = split //, $bn_str;
292              
293 2         165 my $test = int( $bn_str[ length($bn_str) - 1 ] ) & 1;
294              
295 2         97 return $test == 0;
296             }
297              
298             1;
299              
300             =head1 NAME
301              
302             Digi-ID implementation in Perl5
303              
304             =head1 DESCRIPTION
305              
306             Perl5 implementation of [Digi-ID](https://www.digi-id.io/).
307              
308             =head2 Digi-ID Open Authentication Protocol
309              
310             Pure DigiByte sites and applications shouldn’t have to rely on artificial identification methods such as usernames and passwords. Digi-ID is an open authentication protocol allowing simple and secure authentication using public-key cryptography.
311              
312             Classical password authentication is an insecure process that could be solved with public key cryptography. The problem however is that it theoretically offloads a lot of complexity and responsibility on the user. Managing private keys securely is complex. However this complexity is already addressed in the DigiByte ecosystem. So doing public key authentication is practically a free lunch to DigiByte users.
313              
314             =head2 The protocol is based on the following BIP draft
315              
316             https://github.com/bitid/bitid/blob/master/BIP_draft.md
317              
318             =head1 USAGE IN WEB APPLICATION
319              
320             use Dancer2;
321             use DigiByte::DigiID qw(get_qrcode extract_nonce verify_signature);
322              
323             get '/login' => sub {
324             template 'login' => {
325             qrcode => {get_qrcode(request->host)},
326             };
327             };
328              
329             get '/callback' => sub {
330             my $credential = from_json do {
331             my $input = request->env->{'psgi.input'};
332             local $/; <$input>;
333             } or halt "credential not found";
334              
335             my $nonce = extract_nonce($credential->{uri})
336             or do {
337             status 403;
338             return "Nonce is missing";
339             };
340              
341             eval { verify_signature(@$credential{qw(address signature uri)}) }
342             or do {
343             status(403);
344             return "Invalid credential, $@";
345             };
346              
347             my $db = DB->schema; ## using dbix-lite for example
348              
349             my $user = $db->table('digiid_users')
350             ->find({digiid => $credential->{address}})
351             or do {
352             status(403);
353             return "digiid is not found: $credential->{address}";
354             };
355              
356             $db->transaction(sub {
357             $db->table('digiid_sessions')->insert({
358             nonce => $nonce,
359             digiid => $user->id,
360             created_at => \'NOW()',
361             });
362             });
363              
364             return 'OK';
365             };
366              
367             get '/ajax' => sub {
368             content_type 'application/json';
369              
370             my $nonce = params->{nonce}
371             or return to_json {ok => 0, error => 'missing nonce'};
372              
373             my $db = DB->schema; ## using dbix-lite for example
374              
375             my $session = $db->table('digiid_sessions')
376             ->find({nonce => $nonce})
377             or return to_json {ok => 0};
378              
379             my $user = $session->get_digiid_users->get_user
380             or return to_json {ok => 0, next => 'scan to login in digibyte wallet'};
381              
382             $session->delete;
383              
384             return to_json {ok => 1};
385             };
386              
387             dance;
388              
389             =head1 Demo
390              
391             https://digibyteforums.io/ (Has a custom interface on top)
392              
393             =head1 Notes
394              
395             * Pure Perl5 implementation, no need to run a DigiByte node
396              
397             =head1 Credit
398              
399             Direct Translation from PHP to Perl5 - https://github.com/DigiByte-Core/digiid-php/blob/master/DigiID.php
400              
401             =cut