File Coverage

blib/lib/Mail/Milter/Authentication/Handler/SMIME.pm
Criterion Covered Total %
statement 22 24 91.6
branch n/a
condition n/a
subroutine 8 8 100.0
pod n/a
total 30 32 93.7


line stmt bran cond sub pod time code
1             package Mail::Milter::Authentication::Handler::SMIME;
2 1     1   13212 use strict;
  1         1  
  1         22  
3 1     1   3 use warnings;
  1         1  
  1         21  
4 1     1   3 use base 'Mail::Milter::Authentication::Handler';
  1         6  
  1         519  
5 1     1   299948 use version; our $VERSION = version->declare('v1.1.1');
  1         2  
  1         5  
6              
7 1     1   66 use English qw{ -no_match_vars };
  1         1  
  1         6  
8 1     1   297 use Sys::Syslog qw{:standard :macros};
  1         1  
  1         219  
9              
10 1     1   467 use Convert::X509;
  1         49485  
  1         21  
11 1     1   221 use Crypt::SMIME;
  0            
  0            
12             use Email::MIME;
13              
14             sub default_config {
15             return {
16             'hide_none' => 0,
17             'pki_store' => '/etc/ssl/certs',
18             };
19             }
20              
21             sub register_metrics {
22             return {
23             'smime_total' => 'The number of emails processed for SMIME',
24             };
25             }
26              
27             sub envfrom_callback {
28             my ($self) = @_;
29             $self->{'data'} = [];
30             $self->{'found'} = 0;
31             $self->{'added'} = 0;
32             $self->{'metric_result'} = 'unknown';
33             return;
34             }
35              
36             sub header_callback {
37             my ( $self, $header, $value ) = @_;
38             push @{$self->{'data'}} , $header . ': ' . $value . "\n";
39             return;
40             }
41              
42             sub eoh_callback {
43             my ( $self ) = @_;
44             push @{$self->{'data'}} , "\n";
45             return;
46             }
47              
48             sub body_callback {
49             my ( $self, $chunk ) = @_;
50             push @{$self->{'data'}} , $chunk;
51             return;
52             }
53              
54             sub eom_callback {
55             my ( $self ) = @_;
56              
57             my $config = $self->handler_config();
58              
59             my $data = join( q{}, @{ $self->{'data'} } );
60             $data =~ s/\r//g;
61             # my $EOL = "\015\012";
62             # $data =~ s/\015?\012/$EOL/g;
63              
64             eval {
65             my $parsed = Email::MIME->new( $data );
66             $self->_parse_mime( $parsed, q{} );
67              
68             if ( $self->{'found'} == 0 ) {
69             if ( !( $config->{'hide_none'} ) ) {
70             $self->add_auth_header(
71             $self->format_header_entry( 'smime', 'none' ),
72             );
73             }
74             $self->{'metric_result'} = 'none';
75             }
76             elsif ( $self->{'added'} == 0 ) {
77             $self->add_auth_header(
78             $self->format_header_entry( 'smime', 'temperror' ),
79             );
80             $self->{'metric_result'} = 'error';
81             }
82             };
83             if ( my $error = $@ ) {
84             $self->log_error( 'SMIME Execution Error ' . $error );
85             $self->add_auth_header(
86             $self->format_header_entry( 'smime', 'temperror' ),
87             );
88             $self->{'metric_result'} = 'error';
89             }
90              
91             $self->metric_count( 'smime_total', { 'result' => $self->{'metric_result'} } );
92              
93             return;
94             }
95              
96              
97             sub _parse_mime {
98             my ( $self, $mime, $part_id ) = @_;
99              
100             $part_id =~ s/TEXT\.// ;
101              
102             my $content_type = $mime->content_type() || q{};
103             #$self->{'thischild'}->loginfo( 'SMIME Parse Type ' . $content_type );
104              
105             my $protocol = q{};
106             if ( $content_type . ';' =~ /protocol=.*;/ ) {
107             ( $protocol ) = $content_type =~ /protocol=([^;]*);/;
108             $protocol =~ s/"//g if $protocol;
109             }
110              
111             my $smime_type = q{};
112             if ( $content_type . ';' =~ /smime-type=.*;/ ) {
113             ( $smime_type ) = $content_type =~ /smime-type=([^;]*);/;
114             $smime_type =~ s/"//g if $smime_type;
115             }
116              
117             $content_type =~ s/;.*//;
118              
119             if ( $content_type eq 'message/rfc822' ) {
120             my $new_part = $part_id;
121             if ( $new_part ne q{} ) {
122             $new_part .= '.';
123             }
124             my $parsed = Email::MIME->new( $mime->body_raw() );
125             $self->_parse_mime( $parsed, $new_part . 'TEXT' );
126             }
127              
128             if ( $content_type eq 'multipart/signed' ) {
129             $self->{'thischild'}->loginfo( 'SMIME found ' . $content_type );
130             $self->{'thischild'}->loginfo( 'SMIME Protocol ' . $protocol );
131             if ( $protocol eq 'application/pkcs7-signature' || $protocol eq 'application/x-pkcs7-signature' || $protocol eq q{} ) {
132             my $header = $mime->{'header'}->as_string();
133             my $body = $mime->body_raw();
134             $self->_check_mime( $header . "\r\n" . $body, $part_id );
135             }
136             }
137              
138             if ( $content_type eq 'application/pkcs7-mime' ) {
139             $self->{'thischild'}->loginfo( 'SMIME found ' . $content_type );
140             $self->{'thischild'}->loginfo( 'SMIME Type ' . $smime_type );
141             if ( $smime_type eq 'signed-data' || $smime_type eq q{} ) {
142             # See rfc5751 3.4
143             my $header = $mime->{'header'}->as_string();
144             my $body = $mime->body_raw();
145             $self->_check_mime( $header . "\r\n" . $body, $part_id );
146             }
147             }
148              
149             my @parts = $mime->subparts();
150             #$self->{'thischild'}->loginfo( 'SMIME Has Subparts ' . scalar @parts );
151              
152             my $i = 1;
153             my $new_part = $part_id;
154             if ( $new_part ne q{} ) {
155             $new_part .= '.';
156             }
157             foreach my $part ( @parts ) {
158             $self->_parse_mime( $part, $new_part . $i++ );
159             }
160              
161             return;
162             }
163              
164             sub close_callback {
165             my ( $self ) = @_;
166             delete $self->{'metric_result'};
167             delete $self->{'added'};
168             delete $self->{'found'};
169             delete $self->{'data'};
170             return;
171             }
172              
173             sub _check_mime {
174             my ( $self, $data, $part_id ) = @_;
175              
176             if ( $part_id eq q{} ) {
177             $part_id = 'TEXT';
178             }
179              
180             $self->{'found'} = 1;
181              
182             my $smime = Crypt::SMIME->new();
183             my $config = $self->handler_config();
184             $smime->setPublicKeyStore( $config->{'pki_store'} );
185              
186             my $is_signed;
187             $is_signed = eval{ $smime->isSigned( $data ); };
188             if ( my $error = $@ ) {
189             $self->log_error( 'SMIME isSigned Error ' . $error );
190             }
191              
192             if ( $is_signed ) {
193              
194             my $source;
195             eval {
196             $source = $smime->check( $data );
197             };
198             if ( my $error = $@ ) {
199             $self->log_error( 'SMIME check Error ' . $error );
200             my $signatures = Crypt::SMIME::getSigners( $data );
201             my $all_certs = Crypt::SMIME::extractCertificates( $data );
202             $self->_decode_certs( 'fail', $signatures, $all_certs, $part_id );
203             ## ToDo extract the reason for failure and add as header comment
204             if ( $self->{'added'} == 0 ) {
205             $self->add_auth_header(
206             $self->format_header_entry( 'smime', 'fail' ),
207             );
208             $self->{'metric_result'} = 'fail';
209             $self->{'added'} = 1;
210             }
211             }
212             else {
213             my $signatures = Crypt::SMIME::getSigners( $data );
214             my $all_certs = Crypt::SMIME::extractCertificates( $data );
215             $self->_decode_certs( 'pass', $signatures, $all_certs, $part_id );
216             }
217             }
218              
219             return;
220             }
221              
222             sub _decode_certs {
223             my ( $self, $passfail, $signatures, $all_certs, $part_id ) = @_;
224              
225             my $seen = {};
226              
227              
228             SIGNATURE:
229             foreach my $cert ( @{$signatures} ) {
230              
231              
232             my $cert_info = Convert::X509::Certificate->new( $cert );
233              
234             my $subject = $cert_info->subject();
235             my $issuer = $cert_info->issuer();
236             my $from = $cert_info->from();
237             my $to = $cert_info->to();
238             my $eku = $cert_info->eku();
239             my $serial = $cert_info->serial();
240             my @aia = $cert_info->aia();
241              
242             next SIGNATURE if $seen->{ $serial };
243             $seen->{ $serial } = 1;
244              
245             my @results;
246             push @results, $self->format_header_entry( 'body.smime-identifier', $subject->{'E'}[0] )
247             . '(' . $self->format_header_comment( $subject->{'CN'}[0] ) . ')';
248             push @results, $self->format_header_entry( 'body.smime-part', $part_id );
249             push @results, $self->format_header_entry( 'body.smime-serial', $serial );
250             my $issuer_text = join( ',', map{ $_ . '=' . $issuer->{$_}[0] } sort keys (%{$issuer}) );
251             $issuer_text =~ s/\"/ /g;
252             push @results, 'body.smime-issuer="' . $self->format_ctext( $issuer_text ) . '"' ;
253             push @results, 'x-smime-valid-from="' . $self->format_ctext( $from ) . '"';
254             push @results, 'x-smime-valid-to="' . $self->format_ctext( $to ) . '"';
255             $self->add_auth_header(
256             join( "\n ",
257             $self->format_header_entry( 'smime', $passfail ),
258             @results,
259             )
260             );
261             $self->{'metric_result'} = $passfail;
262             $self->{'added'} = 1;
263             }
264              
265             # Non standard
266             CERT:
267             foreach my $cert ( @{$all_certs} ) {
268              
269             my $cert_info = Convert::X509::Certificate->new( $cert );
270              
271             my $subject = $cert_info->subject();
272             my $issuer = $cert_info->issuer();
273             my $from = $cert_info->from();
274             my $to = $cert_info->to();
275             my $eku = $cert_info->eku();
276             my $serial = $cert_info->serial();
277             my @aia = $cert_info->aia();
278              
279             next CERT if $seen->{ $serial };
280             $seen->{ $serial } = 1;
281              
282             my @results;
283             push @results, $self->format_header_entry( 'body.smime-part', $part_id );
284             push @results, $self->format_header_entry( 'x-smime-chain-identifier', ( $subject->{'E'}[0] || 'null' ) )
285             . ' (' . $self->format_header_comment( $subject->{'CN'}[0] ) . ')';
286             push @results, $self->format_header_entry( 'x-smime-chain-serial', $serial );
287             my $issuer_text = join( ',', map{ $_ . '=' . $issuer->{$_}[0] } sort keys (%{$issuer}) );
288             $issuer_text =~ s/\"/ /g;
289             push @results, 'x-smime-chain-issuer="' . $self->format_ctext( $issuer_text ) . '"' ;
290             push @results, 'x-smime-chain-valid-from="' . $self->format_ctext( $from ) . '"';
291             push @results, 'x-smime-chain-valid-to="' . $self->format_ctext( $to ) . '"';
292             $self->add_auth_header(
293             join( "\n ",
294             $self->format_header_entry( 'x-smime-chain', 'info' ),
295             @results,
296             )
297             );
298             $self->{'added'} = 1;
299             }
300              
301             return;
302             }
303              
304             1;
305              
306             __END__
307              
308             =head1 NAME
309              
310             Authentication Milter - SMIME Module
311              
312             =head1 DESCRIPTION
313              
314             Check SMIME signed email for validity.
315              
316             =head1 CONFIGURATION
317              
318             "SMIME" : {
319             "hide_none" : 0,
320             "pki_store" : "/etc/ssl/certs"
321             },
322              
323             =head1 SYNOPSIS
324              
325             =head2 CONFIG
326              
327             Add a block to the handlers section of your config as follows.
328              
329             "SMIME" : {
330             "hide_none" : 0, | Hide auth line if the result is 'none'
331             "pki_store" : "/etc/ssl/certs" | The location of your trusted root certs
332             },
333              
334             =head1 AUTHORS
335              
336             Marc Bradshaw E<lt>marc@marcbradshaw.netE<gt>
337              
338             =head1 COPYRIGHT
339              
340             Copyright 2016
341              
342             This library is free software; you may redistribute it and/or
343             modify it under the same terms as Perl itself.
344              
345              
346