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