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