File Coverage

blib/lib/Mail/DKIM/PublicKey.pm
Criterion Covered Total %
statement 190 233 81.5
branch 77 114 67.5
condition 27 34 79.4
subroutine 25 30 83.3
pod 2 17 11.7
total 321 428 75.0


line stmt bran cond sub pod time code
1             package Mail::DKIM::PublicKey;
2 14     14   116 use strict;
  14         30  
  14         424  
3 14     14   69 use warnings;
  14         32  
  14         675  
4             our $VERSION = '1.20230630'; # VERSION
5             # ABSTRACT: Represents a DKIM key
6              
7             # Copyright 2005 Messiah College. All rights reserved.
8             # Jason Long
9              
10             # Copyright (c) 2004 Anthony D. Urso. All rights reserved.
11             # This program is free software; you can redistribute it and/or
12             # modify it under the same terms as Perl itself.
13              
14 14     14   83 use base ( 'Mail::DKIM::KeyValueList', 'Mail::DKIM::Key' );
  14         37  
  14         6646  
15             *calculate_EM = \&Mail::DKIM::Key::calculate_EM;
16              
17 14     14   3058 use Crypt::OpenSSL::RSA;
  14         40585  
  14         439  
18 14     14   3027 use Crypt::PK::Ed25519;
  14         161760  
  14         4225  
19 14     14   6945 use MIME::Base64;
  14         9040  
  14         824  
20 14     14   6154 use Mail::DKIM::DNS;
  14         195  
  14         41381  
21              
22             sub new {
23 363     363 0 626 my $type = shift;
24 363         609 my %prms = @_;
25              
26 363         684 my $self = {};
27              
28 363         832 $self->{'GRAN'} = $prms{'Granularity'};
29 363         633 $self->{'NOTE'} = $prms{'Note'};
30 363         617 $self->{'TEST'} = $prms{'Testing'};
31 363   50     1301 $self->{'TYPE'} = ( $prms{'Type'} or 'rsa' );
32 363         638 $self->{'DATA'} = $prms{'Data'};
33              
34 363         991 bless $self, $type;
35             }
36              
37              
38             sub fetch {
39 2     2 1 957 my $class = shift;
40 2         12 my $waiter = $class->fetch_async(@_);
41 2         6 my $self = $waiter->();
42 2         90 return $self;
43             }
44              
45             # fetch_async() - asynchronously tries fetching a specific public key
46             # using a specific protocol.
47             #
48             # Usage:
49             # my $fut = Mail::DKIM::PublicKey->fetch_async(
50             # Protocol => 'dns/txt',
51             # Selector => 'selector1',
52             # Domain => 'example.org',
53             # Callbacks => { ... }, #optional
54             # );
55             #
56             # # some later time
57             # my $pubkey = $fut->(); # blocks until the public key is returned
58             #
59             sub fetch_async {
60 425     425 0 848 my $class = shift;
61 425         1644 my %prms = @_;
62              
63 425 50 33     2924 defined( $prms{Protocol} ) && $prms{Protocol} =~ m{^dns(/txt)?$}s
64             or die "invalid/missing Protocol\n";
65              
66             defined( $prms{Selector} ) && length( $prms{Selector} )
67 425 100 66     1600 or die "invalid/missing Selector\n";
68              
69             defined( $prms{Domain} ) && length( $prms{Domain} )
70 423 50 33     1272 or die "invalid/missing Domain\n";
71              
72 423 100       583 my %callbacks = %{ $prms{Callbacks} || {} };
  423         1953  
73 423   100 2   1152 my $on_success = $callbacks{Success} || sub { $_[0] };
  2         22  
74             $callbacks{Success} = sub {
75 368     368   742 my @resp = @_;
76 368 100       884 unless (@resp) {
77              
78             # no requested resource records or NXDOMAIN,
79 5         20 return $on_success->();
80             }
81              
82 363         558 my $strn;
83 363         669 foreach my $rr (@resp) {
84 363 50       798 next unless $rr->type eq 'TXT';
85              
86             # join with no intervening spaces, RFC 6376
87 363 50       6581 if ( Net::DNS->VERSION >= 0.69 ) {
88              
89             # must call txtdata() in a list context
90 363         1234 $strn = join '', $rr->txtdata;
91             }
92             else {
93             # char_str_list method is 'historical'
94 0         0 $strn = join '', $rr->char_str_list;
95             }
96 363         11443 last;
97             }
98              
99             $strn
100 363 50       806 or return $on_success->();
101              
102 363         1099 my $self = $class->parse($strn);
103 360         1158 $self->{Selector} = $prms{'Selector'};
104 360         702 $self->{Domain} = $prms{'Domain'};
105 360   100     996 $self->{TYPE} = $self->get_tag('k') || 'rsa';
106 360         1130 $self->check;
107              
108 355         938 return $on_success->($self);
109 423         1778 };
110              
111             #
112             # perform DNS query for public key...
113             #
114 423         1264 my $host = $prms{Selector} . '._domainkey.' . $prms{Domain};
115 423         1260 my $waiter =
116             Mail::DKIM::DNS::query_async( $host, 'TXT', Callbacks => \%callbacks, );
117 423         1205 return $waiter;
118             }
119              
120              
121             # check syntax of the public key
122             # throw an error if any errors are detected
123             sub check {
124 360     360 0 528 my $self = shift;
125              
126             # check public key version tag
127 360 100       705 if ( my $v = $self->get_tag('v') ) {
128 339 100       801 unless ( $v eq 'DKIM1' ) {
129 1         10 die "unsupported version\n";
130             }
131             }
132              
133             # check public key granularity
134 359         747 my $g = $self->granularity;
135              
136             # check key type
137 359 100       770 if ( my $k = $self->get_tag('k') ) {
138 334 100 100     779 unless ( $k eq 'rsa' || $k eq 'ed25519' ) {
139 1         13 die "unsupported key type\n";
140             }
141             }
142              
143             # check public-key data
144 358         778 my $p = $self->data;
145 358 50       866 if ( not defined $p ) {
146 0         0 die "missing p= tag\n";
147             }
148 358 100       678 if ( $p eq '' ) {
149 1         10 die "revoked\n";
150             }
151 357 50       1468 unless ( $p =~ /^[A-Za-z0-9\+\/\=]+$/ ) {
152 0         0 die "invalid data\n";
153             }
154              
155             # have OpenSSL load the key
156             eval {
157 357         1195 local $SIG{__DIE__};
158 357         1208 $self->cork;
159 356         1420 1
160 357 100       590 } || do {
161              
162             # see also finish_body
163 1         352 chomp( my $E = $@ );
164 1 50       14 if ( $E =~ /(OpenSSL error: .*?) at / ) {
    50          
    50          
165 0         0 $E = "$1";
166             }
167             elsif ( $E =~ /^(panic:.*?) at / ) {
168 0         0 $E = "OpenSSL $1";
169             }
170             elsif ( $E =~ /^FATAL: (.*) at / ) {
171 1         4 $E = "Ed25519 $1";
172             }
173 1         11 die "$E\n";
174             };
175              
176             # check service type
177 356 100       963 if ( my $s = $self->get_tag('s') ) {
178 22         79 my @list = split( /:/, $s );
179 22 100       51 unless ( grep { $_ eq '*' || $_ eq 'email' } @list ) {
  26 100       135  
180 1         19 die "does not support email\n";
181             }
182             }
183              
184 355         712 return 1;
185             }
186              
187             # check_granularity() - check whether this key matches signature identity
188             #
189             # a public key record can restrict what identities it may sign with,
190             # g=, granularity, restricts the local part of the identity
191             # t=s, restricts whether subdomains can be used
192             #
193             # This method returns true if the given identity is allowed by this
194             # public key; it returns false otherwise.
195             # If false is returned, you can check C<$@> for an explanation of
196             # why.
197             #
198             sub check_granularity {
199 352     352 0 569 my $self = shift;
200 352         673 my ( $identity, $empty_g_means_wildcard ) = @_;
201              
202             # check granularity
203 352         710 my $g = $self->granularity;
204              
205             # yuck- what is this $empty_g_means_wildcard parameter?
206             # well, it turns out that with DomainKeys signatures,
207             # an empty g= is the same as g=*
208 352 100 100     875 if ( $g eq '' && $empty_g_means_wildcard ) {
209 1         3 $g = '*';
210             }
211              
212             # split i= value into a "local part" and a "domain part"
213 352         615 my ( $local_part, $domain_part );
214 352 100       1107 if ( $identity =~ /^(.*)\@([^@]*)$/ ) {
215 57         141 $local_part = $1;
216 57         112 $domain_part = $2;
217             }
218             else {
219 295         421 $local_part = '';
220 295         444 $domain_part = $identity;
221             }
222              
223 352         1088 my ( $begins, $ends ) = split /\*/, $g, 2;
224 352 100       707 if ( defined $ends ) {
225              
226             # the g= tag contains an asterisk
227              
228             # the local part must be at least as long as the pattern
229 351 100 66     2079 if (
      100        
      100        
230             length($local_part) < length($begins) + length($ends)
231             or
232              
233             # the local part must begin with $begins
234             substr( $local_part, 0, length($begins) ) ne $begins
235             or
236              
237             # the local part must end with $ends
238             ( length($ends) && substr( $local_part, -length($ends) ) ne $ends )
239             )
240             {
241 3         8 $@ = "granularity mismatch\n";
242 3         48 return;
243             }
244             }
245             else {
246 1 50       5 if ( $g eq '' ) {
247 1         3 $@ = "granularity is empty\n";
248 1         5 return;
249             }
250 0 0       0 unless ( $local_part eq $begins ) {
251 0         0 $@ = "granularity mismatch\n";
252 0         0 return;
253             }
254             }
255              
256             # check subdomains
257 348 100       804 if ( $self->subdomain_flag ) {
258 2 100       16 unless ( lc( $domain_part ) eq lc( $self->{'Domain'} ) ) {
259 1         4 $@ = "does not support signing subdomains\n";
260 1         5 return;
261             }
262             }
263              
264 347         1202 return 1;
265             }
266              
267             # returns true if the actual hash algorithm used is listed by this
268             # public key; dies otherwise
269             #
270             sub check_hash_algorithm {
271 354     354 0 576 my $self = shift;
272 354         598 my ($hash_algorithm) = @_;
273              
274             # check hash algorithm
275 354 100       737 if ( my $h = $self->get_tag('h') ) {
276 14         42 my @list = split( /:/, $h );
277 14 100       32 unless ( grep { $_ eq $hash_algorithm } @list ) {
  38         97  
278 2         19 die "does not support hash algorithm '$hash_algorithm'\n";
279             }
280             }
281 352         780 return 1;
282             }
283              
284             # Create an OpenSSL public key object from the Base64-encoded data
285             # found in this public key's DNS record. The OpenSSL object is saved
286             # in the "cork" property.
287             sub _convert_rsa {
288 353     353   502 my $self = shift;
289             # have to PKCS1ify the pubkey because openssl is too finicky...
290 353         495 my $cert = "-----BEGIN PUBLIC KEY-----\n";
291              
292 353         688 for ( my $i = 0 ; $i < length $self->data ; $i += 64 ) {
293 1355         2287 $cert .= substr $self->data, $i, 64;
294 1355         3065 $cert .= "\n";
295             }
296              
297 353         636 $cert .= "-----END PUBLIC KEY-----\n";
298              
299 353 50       10383 my $cork = Crypt::OpenSSL::RSA->new_public_key($cert)
300             or die 'unable to generate public key object';
301              
302             # segfaults on my machine
303             # $cork->check_key or
304             # return;
305              
306 353         17201 $self->cork($cork);
307 353         738 return 1;
308             }
309              
310             sub _convert_ed25519 {
311 4     4   8 my $self = shift;
312 4 50       36 my $cork = Crypt::PK::Ed25519->new
313             or die 'unable to generate Ed25519 public key object';
314              
315 4         299 my $keybin = decode_base64($self->data);
316 4 50       18 $cork->import_key_raw($keybin, 'public')
317             or die 'failed to load Ed25519 public key';
318              
319 3         80 $self->cork($cork);
320 3         6 return 1;
321             }
322              
323             sub convert {
324 357     357 0 502 my $self = shift;
325              
326 357         695 my $k_tag = $self->get_tag('k');
327 357 100       815 $k_tag = 'rsa' unless defined $k_tag;
328              
329 357 50       626 $self->data
330             or return;
331              
332 357 100       1142 return $self->_convert_rsa if $k_tag eq 'rsa';
333 4 50       78 return $self->_convert_ed25519 if $k_tag eq 'ed25519';
334 0         0 die 'unsupported key type';
335             }
336              
337             sub verify {
338 0     0 0 0 my $self = shift;
339 0         0 my %prms = @_;
340              
341 0         0 my $rtrn;
342              
343             eval {
344 0         0 local $SIG{__DIE__};
345 0         0 $rtrn = $self->cork->verify( $prms{'Text'}, $prms{'Signature'} );
346 0         0 1
347 0 0       0 } || do {
348 0         0 $self->errorstr($@);
349 0         0 return;
350             };
351              
352 0         0 return $rtrn;
353             }
354              
355              
356             sub granularity {
357 711     711 1 1005 my $self = shift;
358              
359             # set new granularity if provided
360 711 50       1453 (@_)
361             and $self->set_tag( 'g', shift );
362              
363 711         1497 my $g = $self->get_tag('g');
364 711 100       1435 if ( defined $g ) {
365 44         92 return $g;
366             }
367             else {
368 667         1248 return '*';
369             }
370             }
371              
372             sub notes {
373 0     0 0 0 my $self = shift;
374              
375 0 0       0 (@_)
376             and $self->set_tag( 'n', shift );
377              
378 0         0 return $self->get_tag('n');
379             }
380              
381             sub data {
382 3782     3782 0 4991 my $self = shift;
383              
384 3782 50       6425 (@_)
385             and $self->set_tag( 'p', shift );
386              
387 3782         7027 my $p = $self->get_tag('p');
388              
389             # remove whitespace (actually only LWSP is allowed) and double quote (long string delimiter)
390 3782 50       10369 $p =~ tr/\015\012 \t"//d if defined $p;
391 3782         8929 return $p;
392             }
393              
394             sub flags {
395 348     348 0 516 my $self = shift;
396              
397 348 50       676 (@_)
398             and $self->set_tag( 't', shift );
399              
400 348   100     812 return $self->get_tag('t') || '';
401             }
402              
403             # subdomain_flag() - checks whether "s" is specified in flags
404             #
405             # returns true if "s" is found, false otherwise
406             #
407             sub subdomain_flag {
408 348     348 0 602 my $self = shift;
409 348         676 my @flags = split /:/, $self->flags;
410 348         1009 return grep { $_ eq 's' } @flags;
  20         75  
411             }
412              
413             sub revoked {
414 0     0 0 0 my $self = shift;
415              
416 0 0       0 $self->data
417             or return 1;
418              
419 0         0 return;
420             }
421              
422             sub testing {
423 0     0 0 0 my $self = shift;
424              
425 0         0 my $flags = $self->flags;
426 0         0 my @flaglist = split( /:/, $flags );
427 0 0       0 if ( grep { $_ eq 'y' } @flaglist ) {
  0         0  
428 0         0 return 1;
429             }
430 0         0 return undef;
431             }
432              
433             sub verify_sha1_digest {
434 0     0 0 0 my $self = shift;
435 0         0 my ( $digest, $signature ) = @_;
436 0         0 return $self->verify_digest( 'SHA-1', $digest, $signature );
437             }
438              
439             sub _verify_digest_rsa {
440 341     341   534 my $self = shift;
441 341         602 my ( $digest_algorithm, $digest, $signature ) = @_;
442              
443 341         720 my $rsa_pub = $self->cork;
444 341 50       680 if ( !$rsa_pub ) {
445 0 0       0 $@ = $@ ne '' ? "RSA failed: $@" : 'RSA unknown problem';
446 0         0 $@ .= ", s=$self->{Selector} d=$self->{Domain}";
447 0         0 return;
448             }
449              
450 341         963 $rsa_pub->use_no_padding;
451 341         14126 my $verify_result = $rsa_pub->encrypt($signature);
452              
453 333         1068 my $k = $rsa_pub->size;
454 333         891 my $expected = calculate_EM( $digest_algorithm, $digest, $k );
455 333 100       1331 return 1 if ( $verify_result eq $expected );
456              
457             # well, the RSA verification failed; I wonder if the RSA signing
458             # was performed on a different digest value? I think we can check...
459              
460             # basically, if the $verify_result has the same prefix as $expected,
461             # then only the digest was different
462              
463 63         111 my $digest_len = length $digest;
464 63         115 my $prefix_len = length($expected) - $digest_len;
465 63 100       200 if (
466             substr( $verify_result, 0, $prefix_len ) eq
467             substr( $expected, 0, $prefix_len ) )
468             {
469 60         99 $@ = 'message has been altered';
470 60         327 return;
471             }
472              
473 3         18 $@ = 'bad RSA signature';
474 3         20 return;
475             }
476              
477             sub _verify_digest_ed25519 {
478 3     3   5 my $self = shift;
479 3         7 my ( $digest_algorithm, $digest, $signature ) = @_;
480              
481 3         10 my $ed = $self->cork;
482 3 50       9 if ( !$ed ) {
483 0 0       0 $@ = $@ ne '' ? "Ed25519 failed: $@" : 'Ed25519 unknown problem';
484 0         0 $@ .= ", s=$self->{Selector} d=$self->{Domain}";
485 0         0 return;
486             }
487              
488 3         19320 my $verify_result = $ed->verify_message($signature, $digest);
489 3 100       41 return $verify_result if ($verify_result == 1);
490              
491 1         5 $@ = 'bad Ed25519 signature';
492 1         11 return;
493             }
494              
495             # verify_digest() - returns true if the digest verifies, false otherwise
496             #
497             # if false, $@ is set to a description of the problem
498             #
499             sub verify_digest {
500 344     344 0 561 my $self = shift;
501 344         713 my ( $digest_algorithm, $digest, $signature ) = @_;
502              
503 344   100     695 my $k_tag = $self->get_tag('k') || 'rsa';
504              
505 344 100       1131 return $self->_verify_digest_rsa($digest_algorithm, $digest, $signature) if $k_tag eq 'rsa';
506 3 50       17 return $self->_verify_digest_ed25519($digest_algorithm, $digest, $signature) if $k_tag eq 'ed25519';
507 0           $@ = 'unsupported key type';
508 0           return;
509             }
510              
511             1;
512              
513             __END__