File Coverage

blib/lib/Mail/SpamAssassin/Plugin/OpenPGP.pm
Criterion Covered Total %
statement 7 9 77.7
branch n/a
condition n/a
subroutine 3 3 100.0
pod n/a
total 10 12 83.3


line stmt bran cond sub pod time code
1             # <@LICENSE>
2             # Licensed under the Apache License, Version 2.0 (the "License");
3             # you may not use this file except in compliance with the License.
4             # You may obtain a copy of the License at
5             #
6             # http://www.apache.org/licenses/LICENSE-2.0
7             #
8             # Unless required by applicable law or agreed to in writing, software
9             # distributed under the License is distributed on an "AS IS" BASIS,
10             # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11             # See the License for the specific language governing permissions and
12             # limitations under the License.
13             #
14              
15             package Mail::SpamAssassin::Plugin::OpenPGP;
16              
17             =head1 NAME
18              
19             Mail::SpamAssassin::Plugin::OpenPGP - A SpamAssassin plugin that validates OpenPGP signed email.
20              
21             =head1 VERSION
22              
23             Version 1.0.4
24              
25             =cut
26              
27             our $VERSION = '1.0.4';
28              
29             #TODO maybe use OpenPGP.pm.PL to generate this file (see perldoc Module::Build "code" section) and include etc/26_openpgp.cf automatically
30              
31             =head1 SYNOPSIS
32              
33             Install this module by running:
34              
35             cpan Mail::SpamAssassin::Plugin::OpenPGP
36              
37             Tell SpamAssassin to use it by putting the following (from this module's F) in a configuration file
38              
39             loadplugin Mail::SpamAssassin::Plugin::OpenPGP
40              
41             Configure the plugin by putting the following (from this module's F) in a configuration file (see L)
42              
43             ifplugin Mail::SpamAssassin::Plugin::OpenPGP
44            
45             rawbody OPENPGP_SIGNED eval:check_openpgp_signed()
46             describe OPENPGP_SIGNED OpenPGP: message body is signed
47            
48             rawbody OPENPGP_ENCRYPTED eval:check_openpgp_encrypted()
49             describe OPENPGP_ENCRYPTED OpenPGP: message body is encrypted
50            
51             rawbody OPENPGP_SIGNED_GOOD eval:check_openpgp_signed_good()
52             describe OPENPGP_SIGNED_GOOD OpenPGP: message body is signed with a valid signature
53             tflags OPENPGP_SIGNED_GOOD nice
54            
55             rawbody OPENPGP_SIGNED_BAD eval:check_openpgp_signed_bad()
56             describe OPENPGP_SIGNED_BAD OpenPGP: message body is signed but the signature is invalid, or doesn't match with email's date or sender
57            
58             endif # Mail::SpamAssassin::Plugin::OpenPGP
59              
60             Set up some rules to your liking, for example:
61              
62             score OPENPGP_SIGNED -1
63             # this would total to -2
64             score OPENPGP_SIGNED_GOOD -1
65             # this would total to 0
66             score OPENPGP_SIGNED_BAD 1
67              
68             =head1 DESCRIPTION
69              
70             This uses Mail::GPG which uses GnuPG::Interface which uses Gnu Privacy Guard via IPC.
71              
72             Make sure the homedir you use for gnupg has a gpg.conf with something like the following in it, so that it will automatically fetch public keys. And make sure that the directory & files are only readable by owner (a gpg security requirement).
73              
74             keyserver-options auto-key-retrieve timeout=5
75             # any keyserver will do
76             keyserver x-hkp://random.sks.keyserver.penguin.de
77              
78             If a public key cannot be retrieved, the email will be marked as SIGNED but neither GOOD nor BAD. To ensure that your local public keys don't get out of date, you should probably set up a scheduled job to delete pubring.gpg regularly
79              
80             For project information, see L
81              
82             =head1 USER SETTINGS
83              
84             gpg_executable /path/to/gpg
85             gpg_homedir /var/foo/gpg-homedir-for-spamassassin
86             openpgp_add_header_fingerprint 1 # default 1 (true)
87             openpgp_add_header_failure_info 0 # default 1 (true)
88              
89             The OpenPGP headers are never added to emails without a signature.
90              
91             =cut
92              
93             =head1 TAGS
94              
95             The following per-message SpamAssassin "tags" are set.
96              
97             =head2 openpgp_checked
98              
99             Set to 1 after the email has been checked for an OpenPGP signature
100              
101             =head2 openpgp_signed
102              
103             Set to 1 if the email has an OpenPGP signature
104              
105             =head2 openpgp_signed_good
106              
107             Set to 1 if the email has a "good" OpenPGP signature
108              
109             =head2 openpgp_signed_bad
110              
111             Set to 1 if the email has a "bad" OpenPGP signature
112              
113             =head2 openpgp_encrypted
114              
115             Set to 1 if the email is encrypted with OpenPGP
116              
117             =head2 openpgp_fingerprint
118              
119             Set to the OpenPGP fingerprint from the signature
120              
121             =cut
122              
123 1     1   26386 use warnings;
  1         2  
  1         39  
124 1     1   7 use strict;
  1         2  
  1         37  
125 1     1   490 use Mail::SpamAssassin::Plugin;
  0            
  0            
126             use Mail::SpamAssassin::Logger;
127             use Mail::SpamAssassin::Timeout;
128             use Mail::GPG;
129              
130             use vars qw(@ISA);
131             @ISA = qw(Mail::SpamAssassin::Plugin);
132              
133             sub new {
134             my $class = shift;
135             my $mailsaobject = shift;
136              
137             # some boilerplate...
138             $class = ref($class) || $class;
139             my $self = $class->SUPER::new($mailsaobject);
140             bless ($self, $class);
141              
142             dbg "openpgp: created";
143            
144             $self->register_eval_rule ("check_openpgp_signed");
145             $self->register_eval_rule ("check_openpgp_signed_good");
146             $self->register_eval_rule ("check_openpgp_signed_bad");
147             $self->register_eval_rule ("check_openpgp_encrypted");
148             # TODO: trusted none, marginal, full, ultimate
149              
150             $self->set_config($mailsaobject->{conf});
151            
152             return $self;
153             }
154              
155             # SA 3.1 style of parsing config options
156             sub set_config {
157             my($self, $conf) = @_;
158             my @cmds = ();
159              
160             # see Mail::SpamAssassin::Conf::Parser for expected format of the "config blocks" stored in @cmds
161              
162             push(@cmds, {
163             setting => 'gpg_homedir',
164             # FIXME: default => 1,
165             type => $Mail::SpamAssassin::Conf::CONF_TYPE_STRING,
166             });
167             push(@cmds, {
168             setting => 'gpg_executable',
169             type => $Mail::SpamAssassin::Conf::CONF_TYPE_STRING,
170             });
171             push(@cmds, {
172             setting => 'openpgp_add_header_fingerprint',
173             default => 1,
174             type => $Mail::SpamAssassin::Conf::CONF_TYPE_BOOLEAN,
175             });
176             push(@cmds, {
177             setting => 'openpgp_add_header_failure_info',
178             default => 1,
179             type => $Mail::SpamAssassin::Conf::CONF_TYPE_BOOLEAN,
180             });
181             # FIXME do we even need this
182             # FIXME use fingerprints, not email address
183             push (@cmds, {
184             setting => 'whitelist_from_openpgp',
185             code => sub {
186             my ($self, $key, $value, $line) = @_;
187             dbg "openpgp: handling whitelist_from_openpgp";
188             unless (defined $value && $value !~ /^$/) {
189             return $Mail::SpamAssassin::Conf::MISSING_REQUIRED_VALUE;
190             }
191             dbg "openpgp: value: $value";
192             unless ($value =~ /^(\S+)(?:\s+(\S+))?$/) {
193             return $Mail::SpamAssassin::Conf::INVALID_VALUE;
194             }
195             my $address = $1;
196             dbg "openpgp: address: $address";
197             my $signer = (defined $2 ? $2 : $1);
198             dbg "openpgp: signer: $signer";
199              
200             unless (defined $2) {
201             $signer =~ s/^.*@(.*)$/$1/;
202             }
203             dbg "openpgp: signer: $signer";
204             # FIXME use fingerprint
205             $self->{parser}->add_to_addrlist_rcvd ('whitelist_from_openpgp', $address, $signer);
206             }
207             });
208            
209             # grr, why isn't register_commands documented?
210             $conf->{parser}->register_commands(\@cmds);
211             }
212              
213             sub check_openpgp_signed_good {
214             my ($self, $scan) = @_;
215             dbg "openpgp: running check_openpgp_signed_good";
216             $self->_check_openpgp($scan);
217             return $scan->{openpgp_signed_good};
218             }
219             sub check_openpgp_signed_bad {
220             my ($self, $scan) = @_;
221             dbg "openpgp: running check_openpgp_signed_bad";
222             $self->_check_openpgp($scan);
223             return $scan->{openpgp_signed_bad};
224             }
225             sub check_openpgp_signed {
226             my ($self, $scan) = @_;
227             dbg "openpgp: running check_openpgp_signed";
228             $self->_check_openpgp($scan);
229             return $scan->{openpgp_signed};
230             }
231             sub check_openpgp_encrypted {
232             my ($self, $scan) = @_;
233             dbg "openpgp: running check_openpgp_encrypted";
234             $self->_check_openpgp($scan);
235             return $scan->{openpgp_encrypted};
236             }
237              
238             # taken from Mail::SpamAssassin::PerMsgStatus's _get
239             sub _just_email {
240             my $result = shift;
241             $result =~ s/\s+/ /g; # reduce whitespace
242             $result =~ s/^\s+//; # leading whitespace
243             $result =~ s/\s+$//; # trailing whitespace
244              
245             # Get the email address out of the header
246             # All of these should result in "jm@foo":
247             # jm@foo
248             # jm@foo (Foo Blah)
249             # jm@foo, jm@bar
250             # display: jm@foo (Foo Blah), jm@bar ;
251             # Foo Blah
252             # "Foo Blah"
253             # "'Foo Blah'"
254             # "_$B!z8=6b$=$N>l$GEv$?$j!*!zEv_(B_$B$?$k!*!)$/$8!z7|>^%\%s%P! (bug 3979)
255             #
256             # strip out the (comments)
257             $result =~ s/\s*\(.*?\)//g;
258             # strip out the "quoted text"
259             $result =~ s/(?
260             # Foo Blah or
261             $result =~ s/^[^<]*?<(.*?)>.*$/$1/;
262             # multiple addresses on one line? remove all but first
263             $result =~ s/,.*$//;
264             return $result;
265             }
266              
267             # TODO contribute back to Mail::GPG::Result
268             sub _gpg_result_date {
269             my $result = shift;
270             my $gpg_status = $result->get_gpg_status;
271             ## dbg "openpgp: status: " . $$gpg_status;
272             # based on Mail::GPG::Result's analyze_result
273             pos($$gpg_status) = undef; # reset /g modifier since this module uses the following regex multiple times
274             while ( $$gpg_status && $$gpg_status =~ m{^\[GNUPG:\]\s+(.*)$}mg ) {
275             my $line = $1;
276             ## dbg "openpgp: line: " . $line;
277             # 3rd field after VALIDSIG
278             if ( $line =~ /^VALIDSIG\s+([^\s]+)\s+([^\s]+)\s+([^\s]+)/ ) {
279             #$sign_fingerprint = $1;
280             return $3;
281             }
282             }
283             }
284              
285             # TODO contribute back to Mail::GPG::Result
286             # it's get_sign_fingerprint does signing key, not primary key if signing key is a subkey
287             sub _gpg_result_primary_key_fingerprint {
288             my $result = shift;
289             my $gpg_status = $result->get_gpg_status;
290             pos($$gpg_status) = undef; # reset /g modifier since this module uses the following regex multiple times
291             # based on Mail::GPG::Result's analyze_result
292             while ( $$gpg_status && $$gpg_status =~ m{^\[GNUPG:\]\s+(.*)$}mg ) {
293             my $line = $1;
294             # if signed with a subkey, subkey comes first and primary key comes later
295             # [GNUPG:] VALIDSIG D1892B5C772E643EBB97397E6737EA5562EFBB73 2008-01-21 1200891462 0 3 0 1 10 01 EAB0FABEDEA81AD4086902FE56F0526F9BB3CE70
296             # some gnupg versions may only output 3 fields after VALIDSIG
297             # get last 40hex-digit sequence
298             if ( $line =~ /^VALIDSIG.+([0-9A-F]{40})/ ) {
299             return $1;
300             }
301             }
302             }
303              
304             sub _check_openpgp {
305             my ($self, $scan) = @_;
306             return if $scan->{openpgp_checked};
307            
308             $scan->{openpgp_checked} = 0;
309             $scan->{openpgp_signed} = 0;
310             $scan->{openpgp_signed_good} = 0;
311             $scan->{openpgp_signed_bad} = 0;
312            
313             my %opts;
314             if (defined $scan->{conf}->{gpg_executable}) {
315             $opts{gpg_call} = $scan->{conf}->{gpg_executable};
316             }
317             # see GnuPG::Interface's hash_init (correlates to gpg commandline arguments)
318             $opts{gnupg_hash_init} = {
319             homedir => $scan->{conf}->{gpg_homedir}
320             };
321            
322             my $gpg = Mail::GPG->new(%opts);
323             # TODO: use SA-parsed entity instead of having Mail::GPG reparse it into a MIME::Entity?
324             my $entity = Mail::GPG->parse(mail_sref => \$scan->{msg}->get_pristine());
325             # TODO: configurable option to use is_signed_quick
326             if ($gpg->is_signed(entity => $entity)) {
327             $scan->{openpgp_signed} = 1;
328             dbg "openpgp: is signed";
329             }
330             if ($gpg->is_encrypted(entity => $entity)) {
331             $scan->{openpgp_encrypted} = 1;
332             dbg "openpgp: is encrypted";
333             }
334            
335             if ($scan->{openpgp_signed}) {
336             my $result = $gpg->verify(entity => $entity);
337             if (!$result->get_is_signed) {
338             warn "openpgp: \$gpg->is_signed != \$result->get_is_signed";
339             $scan->{openpgp_signed} = 1;
340             } else {
341             #dbg "openpgp: " . $result->as_string();
342             if (${$result->get_gpg_stdout}) {
343             dbg "openpgp: gpg stdout:" . ${$result->get_gpg_stdout};
344             }
345             if (${$result->get_gpg_stderr}) {
346             dbg "openpgp: gpg stderr:" . ${$result->get_gpg_stderr};
347             }
348             if ($result->get_gpg_rc != 0) {
349             my $err = "Error running gpg: " . ${$result->get_gpg_stdout} . ${$result->get_gpg_stderr};
350             dbg "openpgp: $err";
351             if ($scan->{conf}->{openpgp_add_header_fingerprint}) {
352             $scan->{conf}->{headers_spam}->{'OpenPGP-Failure'} = $err;
353             $scan->{conf}->{headers_ham}->{'OpenPGP-Failure'} = $err;
354             }
355             } else {
356             $scan->{openpgp_fingerprint} = _gpg_result_primary_key_fingerprint($result);
357             $scan->{openpgp_signed_good} = $result->get_sign_ok;
358             $scan->{openpgp_signed_bad} = !$result->get_sign_ok;
359            
360             if ($scan->{conf}->{openpgp_add_header_fingerprint}) {
361             $scan->{conf}->{headers_spam}->{'OpenPGP-Fingerprint'} = $scan->{openpgp_fingerprint};
362             $scan->{conf}->{headers_ham}->{'OpenPGP-Fingerprint'} = $scan->{openpgp_fingerprint};
363             }
364             }
365            
366             if ($scan->{openpgp_signed_bad}) {
367             my $err = "bad signature: " . ${$result->get_gpg_stderr};
368             dbg "openpgp: $err";
369             if ($scan->{conf}->{openpgp_add_header_fingerprint}) {
370             $scan->{conf}->{headers_spam}->{'OpenPGP-Failure'} = $err;
371             $scan->{conf}->{headers_ham}->{'OpenPGP-Failure'} = $err;
372             }
373             }
374            
375             # additional checks if good
376             if ($scan->{openpgp_signed_good}) {
377             # From address must match one in the public key
378             # TODO check 'Sender:' ?
379             my $from_email_address = $scan->get('From:addr');
380             my $from_ok = 0;
381             if ($from_email_address eq _just_email($result->get_sign_mail)) {
382             $from_ok = 1;
383             } else {
384             foreach my $key_alias (@{$result->get_sign_mail_aliases}) {
385             if ($from_email_address eq _just_email($key_alias)) {
386             $from_ok = 1;
387             last;
388             }
389             }
390             }
391             if (!$from_ok) {
392             my $err = 'from address ' . $from_email_address . ' not in list of email addresses on public key ' . $scan->{openpgp_fingerprint};
393             dbg "openpgp: $err";
394             if ($scan->{conf}->{openpgp_add_header_fingerprint}) {
395             $scan->{conf}->{headers_spam}->{'OpenPGP-Failure'} = $err;
396             $scan->{conf}->{headers_ham}->{'OpenPGP-Failure'} = $err;
397             }
398             $scan->{openpgp_signed_good} = 0;
399             $scan->{openpgp_signed_bad} = 1;
400             } else {
401             dbg "openpgp: fingerprint: " . $scan->{openpgp_fingerprint};
402             }
403             }
404             if ($scan->{openpgp_signed_good}) {
405             # date of email must be close to that of the signature
406             my $sent_date = Mail::SpamAssassin::Util::parse_rfc822_date($scan->get('Date'));
407             my $signature_date = _gpg_result_date($result);
408              
409            
410             # TODO configurable threshold
411             my $threshold = 60*60;
412             if (abs($sent_date - $signature_date) > $threshold) {
413             my $err = "mail sent date and signature data are more than $threshold seconds apart: $sent_date vs $signature_date";
414             dbg "openpgp: $err";
415             if ($scan->{conf}->{openpgp_add_header_fingerprint}) {
416             $scan->{conf}->{headers_spam}->{'OpenPGP-Failure'} = $err;
417             $scan->{conf}->{headers_ham}->{'OpenPGP-Failure'} = $err;
418             }
419             $scan->{openpgp_signed_good} = 0;
420             $scan->{openpgp_signed_bad} = 1;
421             }
422             }
423             }
424             }
425            
426             $scan->{openpgp_checked} = 1;
427             }
428              
429             1; # End of Mail::SpamAssassin::Plugin::OpenPGP
430             __END__