File Coverage

blib/lib/Net/SSL/ExpireDate.pm
Criterion Covered Total %
statement 63 230 27.3
branch 15 76 19.7
condition 5 13 38.4
subroutine 16 22 72.7
pod 4 4 100.0
total 103 345 29.8


line stmt bran cond sub pod time code
1             package Net::SSL::ExpireDate;
2              
3 6     6   3265 use strict;
  6         12  
  6         156  
4 6     6   26 use warnings;
  6         10  
  6         118  
5 6     6   26 use Carp;
  6         10  
  6         383  
6              
7             our $VERSION = '1.22';
8              
9 6     6   29 use base qw(Class::Accessor);
  6         10  
  6         1124  
10 6     6   5407 use Crypt::OpenSSL::X509 qw(FORMAT_ASN1);
  6         35362  
  6         486  
11 6     6   3043 use Date::Parse;
  6         41515  
  6         695  
12 6     6   5185 use DateTime;
  6         678169  
  6         314  
13 6     6   53 use DateTime::Duration;
  6         14  
  6         152  
14 6     6   2977 use Time::Duration::Parse;
  6         11009  
  6         47  
15 6     6   2896 use UNIVERSAL::require;
  6         7650  
  6         56  
16              
17             my $Socket = 'IO::Socket::INET6';
18             unless ($Socket->require) {
19             $Socket = 'IO::Socket::INET';
20             $Socket->require or die $@;
21             }
22              
23             __PACKAGE__->mk_accessors(qw(type target));
24              
25             my $SSL3_RT_CHANGE_CIPHER_SPEC = 20;
26             my $SSL3_RT_ALERT = 21;
27             my $SSL3_RT_HANDSHAKE = 22;
28             my $SSL3_RT_APPLICATION_DATA = 23;
29              
30             my $SSL3_MT_HELLO_REQUEST = 0;
31             my $SSL3_MT_CLIENT_HELLO = 1;
32             my $SSL3_MT_SERVER_HELLO = 2;
33             my $SSL3_MT_CERTIFICATE = 11;
34             my $SSL3_MT_SERVER_KEY_EXCHANGE = 12;
35             my $SSL3_MT_CERTIFICATE_REQUEST = 13;
36             my $SSL3_MT_SERVER_DONE = 14;
37             my $SSL3_MT_CERTIFICATE_VERIFY = 15;
38             my $SSL3_MT_CLIENT_KEY_EXCHANGE = 16;
39             my $SSL3_MT_FINISHED = 20;
40              
41             my $SSL3_AL_WARNING = 0x01;
42             my $SSL3_AL_FATAL = 0x02;
43              
44             my $SSL3_AD_CLOSE_NOTIFY = 0;
45             my $SSL3_AD_UNEXPECTED_MESSAGE = 10; # fatal
46             my $SSL3_AD_BAD_RECORD_MAC = 20; # fatal
47             my $SSL3_AD_DECOMPRESSION_FAILURE = 30; # fatal
48             my $SSL3_AD_HANDSHAKE_FAILURE = 40; # fatal
49             my $SSL3_AD_NO_CERTIFICATE = 41;
50             my $SSL3_AD_BAD_CERTIFICATE = 42;
51             my $SSL3_AD_UNSUPPORTED_CERTIFICATE = 43;
52             my $SSL3_AD_CERTIFICATE_REVOKED = 44;
53             my $SSL3_AD_CERTIFICATE_EXPIRED = 45;
54             my $SSL3_AD_CERTIFICATE_UNKNOWN = 46;
55             my $SSL3_AD_ILLEGAL_PARAMETER = 47; # fatal
56              
57             sub new {
58 3     3 1 28 my ($class, %opt) = @_;
59              
60 3         22 my $self = bless {
61             type => undef,
62             target => undef,
63             expire_date => undef,
64             timeout => undef,
65             }, $class;
66              
67 3 100 66     23 if ( $opt{https} or $opt{ssl} ) {
    50          
68 1         10 $self->{type} = 'ssl';
69 1   33     5 $self->{target} = $opt{https} || $opt{ssl};
70             } elsif ($opt{file}) {
71 2         19 $self->{type} = 'file';
72 2         6 $self->{target} = $opt{file};
73 2 50       88 if (! -r $self->{target}) {
74 0         0 croak "$self->{target}: $!";
75             }
76             } else {
77 0         0 croak "missing option: neither ssl nor file";
78             }
79 3 50       13 if ($opt{timeout}) {
80 0         0 $self->{timeout} = $opt{timeout};
81             }
82              
83 3         13 return $self;
84             }
85              
86             sub expire_date {
87 2     2 1 8 my $self = shift;
88              
89 2 100       10 if (! $self->{expire_date}) {
90 1 50       5 if ($self->{type} eq 'ssl') {
    50          
91 0         0 my ($host, $port) = split /:/, $self->{target}, 2;
92 0   0     0 $port ||= 443;
93             ### $host
94             ### $port
95 0         0 my $cert = eval { _peer_certificate($host, $port, $self->{timeout}); };
  0         0  
96 0 0       0 warn $@ if $@;
97 0 0       0 return unless $cert;
98 0         0 my $x509 = Crypt::OpenSSL::X509->new_from_string($cert, FORMAT_ASN1);
99 0         0 my $begin_date_str = $x509->notBefore;
100 0         0 my $expire_date_str = $x509->notAfter;
101              
102 0         0 $self->{expire_date} = DateTime->from_epoch(epoch => str2time($expire_date_str));
103 0         0 $self->{begin_date} = DateTime->from_epoch(epoch => str2time($begin_date_str));
104              
105             } elsif ($self->{type} eq 'file') {
106 1         31838 my $x509 = Crypt::OpenSSL::X509->new_from_file($self->{target});
107 1         32 $self->{expire_date} = DateTime->from_epoch(epoch => str2time($x509->notAfter));
108 1         1587 $self->{begin_date} = DateTime->from_epoch(epoch => str2time($x509->notBefore));
109             } else {
110 0         0 croak "unknown type: $self->{type}";
111             }
112             }
113              
114 2         475 return $self->{expire_date};
115             }
116              
117             sub begin_date {
118 2     2 1 3 my $self = shift;
119              
120 2 50       8 if (! $self->{begin_date}) {
121 0         0 $self->expire_date;
122             }
123              
124 2         13 return $self->{begin_date};
125             }
126              
127             *not_after = \&expire_date;
128             *not_before = \&begin_date;
129              
130             sub is_expired {
131 3     3 1 92 my ($self, $duration) = @_;
132 3   66     20 $duration ||= DateTime::Duration->new();
133              
134 3 50       160 if (! $self->{begin_date}) {
135 0         0 $self->expire_date;
136             }
137              
138 3 100       20 if (! ref($duration)) { # if scalar
139 1         15 $duration = DateTime::Duration->new(seconds => parse_duration($duration));
140             }
141              
142 3         139 my $dx = DateTime->now()->add_duration( $duration );
143             ### dx: $dx->iso8601
144              
145 3 100       2587 return DateTime->compare($dx, $self->{expire_date}) >= 0 ? 1 : ();
146             }
147              
148             sub _peer_certificate {
149 0     0     my($host, $port, $timeout) = @_;
150              
151 0           my $cert;
152              
153 6     6   6636 no warnings 'once';
  6         15  
  6         284  
154 6     6   35 no strict 'refs'; ## no critic
  6         12  
  6         14297  
155 0           *{$Socket.'::write_atomically'} = sub {
156 0     0     my($self, $data) = @_;
157              
158 0           my $length = length $data;
159 0           my $offset = 0;
160 0           my $read_byte = 0;
161              
162 0           while ($length > 0) {
163 0   0       my $r = $self->syswrite($data, $length, $offset) || last;
164 0           $offset += $r;
165 0           $length -= $r;
166 0           $read_byte += $r;
167             }
168              
169 0           return $read_byte;
170 0           };
171              
172 0           my $sock = {
173             PeerAddr => $host,
174             PeerPort => $port,
175             Proto => 'tcp',
176             Timeout => $timeout,
177             };
178              
179 0 0         $sock = $Socket->new( %$sock ) or croak "cannot create socket: $!";
180              
181 0           my $servername;
182 0 0         if ($host !~ /^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$/) {
183 0           $servername = $host;
184             }
185 0           _send_client_hello($sock, $servername);
186              
187 0           my $do_loop = 1;
188 0           while ($do_loop) {
189 0           my $record = _get_record($sock);
190 0 0         if ($record->{type} != $SSL3_RT_HANDSHAKE) {
191 0 0         if ($record->{type} == $SSL3_RT_ALERT) {
192 0           my $d1 = unpack 'C', substr $record->{data}, 0, 1;
193 0           my $d2 = unpack 'C', substr $record->{data}, 1, 1;
194 0 0         if ($d1 eq $SSL3_AL_WARNING) {
195             ; # go ahead
196             } else {
197 0           croak "record type is SSL3_AL_FATAL. [desctioption: $d2]";
198             }
199             } else {
200 0           croak "record type is not HANDSHAKE";
201             }
202             }
203              
204 0           while (my $handshake = _get_handshake($record)) {
205 0 0         croak "too many loop" if $do_loop++ >= 10;
206 0 0         if ($handshake->{type} == $SSL3_MT_HELLO_REQUEST) {
    0          
    0          
    0          
    0          
    0          
207             ;
208             } elsif ($handshake->{type} == $SSL3_MT_CERTIFICATE_REQUEST) {
209             ;
210             } elsif ($handshake->{type} == $SSL3_MT_SERVER_HELLO) {
211             ;
212             } elsif ($handshake->{type} == $SSL3_MT_CERTIFICATE) {
213 0           my $data = $handshake->{data};
214 0           my $len1 = $handshake->{length};
215 0           my $len2 = (vec($data, 0, 8)<<16)+(vec($data, 1, 8)<<8)+vec($data, 2, 8);
216 0           my $len3 = (vec($data, 3, 8)<<16)+(vec($data, 4, 8)<<8)+vec($data, 5, 8);
217 0 0         croak "X509: length error" if $len1 != $len2 + 3;
218 0           $cert = substr $data, 6; # DER format
219             } elsif ($handshake->{type} == $SSL3_MT_SERVER_KEY_EXCHANGE) {
220             ;
221             } elsif ($handshake->{type} == $SSL3_MT_SERVER_DONE) {
222 0           $do_loop = 0;
223             } else {
224             ;
225             }
226             }
227              
228             }
229              
230 0 0         _sendalert($sock, $SSL3_AL_FATAL, $SSL3_AD_HANDSHAKE_FAILURE) or croak $!;
231 0           $sock->close;
232              
233 0           return $cert;
234             }
235              
236             sub _send_client_hello {
237 0     0     my($sock, $servername) = @_;
238              
239 0           my(@buf,$len);
240             ## record
241 0           push @buf, $SSL3_RT_HANDSHAKE;
242 0           push @buf, 3, 1;
243 0           push @buf, undef, undef;
244 0           my $pos_record_len = $#buf-1;
245              
246             ## handshake
247 0           push @buf, $SSL3_MT_CLIENT_HELLO;
248 0           push @buf, undef, undef, undef;
249 0           my $pos_handshake_len = $#buf-2;
250              
251             ## ClientHello
252             # client_version
253 0           push @buf, 3, 3;
254             # random
255 0           my $time = time;
256 0           push @buf, (($time>>24) & 0xFF);
257 0           push @buf, (($time>>16) & 0xFF);
258 0           push @buf, (($time>> 8) & 0xFF);
259 0           push @buf, (($time ) & 0xFF);
260 0           for (1..28) {
261 0           push @buf, int(rand(0xFF));
262             }
263             # session_id
264 0           push @buf, 0;
265             # cipher_suites
266 0           my @decCipherSuites = (
267             49199,
268             49195,
269             49200,
270             49196,
271             158,
272             162,
273             163,
274             159,
275             49191,
276             49187,
277             49171,
278             49161,
279             49192,
280             49188,
281             49172,
282             49162,
283             103,
284             51,
285             64,
286             107,
287             56,
288             57,
289             49170,
290             49160,
291             156,
292             157,
293             60,
294             61,
295             47,
296             53,
297             49186,
298             49185,
299             49184,
300             165,
301             161,
302             106,
303             105,
304             104,
305             55,
306             54,
307             49183,
308             49182,
309             49181,
310             164,
311             160,
312             63,
313             62,
314             50,
315             49,
316             48,
317             10,
318             136,
319             135,
320             134,
321             133,
322             132,
323             69,
324             68,
325             67,
326             66,
327             65,
328             );
329 0           $len = scalar(@decCipherSuites) * 2;
330 0           push @buf, (($len >> 8) & 0xFF);
331 0           push @buf, (($len ) & 0xFF);
332 0           foreach my $i (@decCipherSuites) {
333 0           push @buf, (($i >> 8) & 0xFF);
334 0           push @buf, (($i ) & 0xFF);
335             }
336              
337             # compression
338 0           push @buf, 1;
339 0           push @buf, 0;
340              
341             # Extensions length
342 0           my @ext = (undef, undef);
343              
344             # Extension: server_name
345 0 0         if ($servername) {
346             # my $buf_len = scalar(@buf);
347             # my $buf_len_pos = $#buf+1;
348             # push @buf, undef, undef;
349              
350             # SNI (Server Name Indication)
351 0           my $sn_len = length $servername;
352             # Extension Type: Server Name
353 0           push @ext, 0, 0;
354             # Length
355 0           push @ext, ((($sn_len+5) >> 8) & 0xFF);
356 0           push @ext, ((($sn_len+5) ) & 0xFF);
357             # Server Name Indication Length
358 0           push @ext, ((($sn_len+3) >> 8) & 0xFF);
359 0           push @ext, ((($sn_len+3) ) & 0xFF);
360             # Server Name Type: host_name
361 0           push @ext, 0;
362             # Length of servername
363 0           push @ext, (($sn_len >> 8) & 0xFF);
364 0           push @ext, (($sn_len ) & 0xFF);
365             # Servername
366 0           for my $c (split //, $servername) {
367 0           push @ext, ord($c);
368             }
369             }
370              
371             # Extension: supported_groups
372 0           push @ext, 0x00, 0x0a; # supported_groups
373 0           my @supportedGroups = (
374             0x000a, # sect163r1
375             0x0017, # secp256r1
376             0x0018, # secp384r1
377             0x0019, # secp521r1
378             0x001d, # x25519
379             0x001e, # x448
380             );
381 0           $len = scalar(@supportedGroups) * 2;
382 0           push @ext, (($len >> 8) & 0xFF);
383 0           push @ext, (($len ) & 0xFF);
384 0           foreach my $i (@supportedGroups) {
385 0           push @ext, (($i >> 8) & 0xFF);
386 0           push @ext, (($i ) & 0xFF);
387             }
388              
389             # Extension: signature_algorithms (>= TLSv1.2)
390 0           push @ext, 0x00, 0x0D; # signature_algorithms
391 0           push @ext, 0, 32; # length
392 0           push @ext, 0, 30; # signature hash algorithms length
393             # enum {
394             # none(0), md5(1), sha1(2), sha224(3), sha256(4), sha384(5),
395             # sha512(6), (255)
396             # } HashAlgorithm;
397 0           for my $ha (2..6) {
398             # enum { anonymous(0), rsa(1), dsa(2), ecdsa(3), (255) }
399             # SignatureAlgorithm;
400 0           for my $sa (1..3) {
401 0           push @ext, $ha, $sa;
402             }
403             }
404              
405             # Extension: Heartbeat
406 0           push @ext, 0x00, 0x0F; # heartbeat
407 0           push @ext, 0x00, 0x01; # length
408 0           push @ext, 0x01; # peer_allowed_to_send
409              
410 0           my $ext_len = scalar(@ext) - 2;
411 0 0         if ($ext_len > 0) {
412 0           $ext[0] = (($ext_len) >> 8) & 0xFF;
413 0           $ext[1] = (($ext_len) ) & 0xFF;
414 0           push @buf, @ext;
415             }
416              
417             # record length
418 0           $len = scalar(@buf) - $pos_record_len - 2;
419 0           $buf[ $pos_record_len ] = (($len >> 8) & 0xFF);
420 0           $buf[ $pos_record_len+1 ] = (($len ) & 0xFF);
421              
422             # handshake length
423 0           $len = scalar(@buf) - $pos_handshake_len - 3;
424 0           $buf[ $pos_handshake_len ] = (($len >> 16) & 0xFF);
425 0           $buf[ $pos_handshake_len+1 ] = (($len >> 8) & 0xFF);
426 0           $buf[ $pos_handshake_len+2 ] = (($len ) & 0xFF);
427              
428 0           my $data;
429 0           for my $c (@buf) {
430 0 0         if ($c =~ /^[0-9]+$/) {
431 0           $data .= pack('C', $c);
432             } else {
433 0           $data .= $c;
434             }
435             }
436              
437 0           return $sock->write_atomically($data);
438             }
439              
440             sub _get_record {
441 0     0     my($sock) = @_;
442              
443 0           my $record = {
444             type => -1,
445             version => -1,
446             length => -1,
447             read => 0,
448             data => "",
449             };
450              
451 0 0         $sock->read($record->{type} , 1) or croak "cannot read type";
452 0           $record->{type} = unpack 'C', $record->{type};
453              
454 0 0         $sock->read($record->{version}, 2) or croak "cannot read version";
455 0           $record->{version} = unpack 'n', $record->{version};
456              
457 0 0         $sock->read($record->{length}, 2) or croak "cannot read length";
458 0           $record->{length} = unpack 'n', $record->{length};
459              
460 0 0         $sock->read($record->{data}, $record->{length}) or croak "cannot read data";
461              
462 0           return $record;
463             }
464              
465             sub _get_handshake {
466 0     0     my($record) = @_;
467              
468 0           my $handshake = {
469             type => -1,
470             length => -1,
471             data => "",
472             };
473              
474 0 0         return if $record->{read} >= $record->{length};
475              
476 0           $handshake->{type} = vec($record->{data}, $record->{read}++, 8);
477 0 0         return if $record->{read} + 3 > $record->{length};
478              
479             $handshake->{length} =
480             (vec($record->{data}, $record->{read}++, 8)<<16)
481             +(vec($record->{data}, $record->{read}++, 8)<< 8)
482 0           +(vec($record->{data}, $record->{read}++, 8) );
483              
484 0 0         if ($handshake->{length} > 0) {
485 0           $handshake->{data} = substr($record->{data}, $record->{read}, $handshake->{length});
486 0           $record->{read} += $handshake->{length};
487 0 0         return if $record->{read} > $record->{length};
488             } else {
489 0           $handshake->{data}= undef;
490             }
491              
492 0           return $handshake;
493             }
494              
495             sub _sendalert {
496 0     0     my($sock, $level, $desc) = @_;
497              
498 0           my $data = "";
499              
500 0           $data .= pack('C', $SSL3_RT_ALERT);
501 0           $data .= pack('C', 3);
502 0           $data .= pack('C', 0);
503 0           $data .= pack('C', 0);
504 0           $data .= pack('C', 2);
505 0           $data .= pack('C', $level);
506 0           $data .= pack('C', $desc);
507              
508 0           return $sock->write_atomically($data);
509             }
510              
511             1; # Magic true value required at end of module
512             __END__
513              
514             =head1 NAME
515              
516             Net::SSL::ExpireDate - obtain expiration date of certificate
517              
518             =head1 SYNOPSIS
519              
520             use Net::SSL::ExpireDate;
521              
522             $ed = Net::SSL::ExpireDate->new( https => 'example.com' );
523             $ed = Net::SSL::ExpireDate->new( https => 'example.com:10443' );
524             $ed = Net::SSL::ExpireDate->new( ssl => 'example.com:465' ); # smtps
525             $ed = Net::SSL::ExpireDate->new( ssl => 'example.com:995' ); # pop3s
526             $ed = Net::SSL::ExpireDate->new( file => '/etc/ssl/cert.pem' );
527              
528             if (defined $ed->expire_date) {
529             # do something
530             $expire_date = $ed->expire_date; # return DateTime instance
531              
532             $expired = $ed->is_expired; # examine already expired
533              
534             $expired = $ed->is_expired('2 months'); # will expire after 2 months
535             $expired = $ed->is_expired(DateTime::Duration->new(months=>2)); # ditto
536             }
537              
538             =head1 DESCRIPTION
539              
540             Net::SSL::ExpireDate get certificate from network (SSL) or local
541             file and obtain its expiration date.
542              
543             =head1 METHODS
544              
545             =head2 new
546              
547             $ed = Net::SSL::ExpireDate->new( %option )
548              
549             This method constructs a new "Net::SSL::ExpireDate" instance and
550             returns it. %option is to specify certificate.
551              
552             KEY VALUE
553             ----------------------------
554             ssl "hostname[:port]"
555             https (same as above ssl)
556             file "path/to/certificate"
557             timeout "Timeout in seconds"
558              
559             =head2 expire_date
560              
561             $expire_date = $ed->expire_date;
562              
563             Return expiration date by "DateTime" instance.
564              
565             =head2 begin_date
566              
567             $begin_date = $ed->begin_date;
568              
569             Return beginning date by "DateTime" instance.
570              
571             =head2 not_after
572              
573             Synonym for expire_date.
574              
575             =head2 not_before
576              
577             Synonym for begin_date.
578              
579             =head2 is_expired
580              
581             $expired = $ed->is_expired;
582              
583             Obtain already expired or not.
584              
585             You can specify interval to obtain will expire on the future time.
586             Acceptable intervals are human readable string (parsed by
587             "Time::Duration::Parse") and "DateTime::Duration" instance.
588              
589             # will expire after 2 months
590             $expired = $ed->is_expired('2 months');
591             $expired = $ed->is_expired(DateTime::Duration->new(months=>2));
592              
593             =head2 type
594              
595             return type of examinee certificate. "ssl" or "file".
596              
597             =head2 target
598              
599             return hostname or path of examinee certificate.
600              
601             =head1 BUGS AND LIMITATIONS
602              
603             No bugs have been reported.
604              
605             Please report any bugs or feature requests to
606             C<bug-net-ssl-expiredate@rt.cpan.org>, or through the web interface at
607             L<http://rt.cpan.org>.
608              
609             =head1 AUTHOR
610              
611             HIROSE Masaaki E<lt>hirose31 _at_ gmail.comE<gt>
612              
613             =head1 REPOSITORY
614              
615             L<http://github.com/hirose31/net-ssl-expiredate>
616              
617             git clone git://github.com/hirose31/net-ssl-expiredate.git
618              
619             patches and collaborators are welcome.
620              
621             =head1 SEE ALSO
622              
623             =head1 COPYRIGHT & LICENSE
624              
625             Copyright HIROSE Masaaki
626              
627             This library is free software; you can redistribute it and/or modify
628             it under the same terms as Perl itself.
629              
630             =cut
631