File Coverage

blib/lib/Mail/SpamAssassin/Plugin/Hashcash.pm
Criterion Covered Total %
statement 69 145 47.5
branch 4 46 8.7
condition 2 12 16.6
subroutine 17 20 85.0
pod 1 5 20.0
total 93 228 40.7


line stmt bran cond sub pod time code
1             # <@LICENSE>
2             # Licensed to the Apache Software Foundation (ASF) under one or more
3             # contributor license agreements. See the NOTICE file distributed with
4             # this work for additional information regarding copyright ownership.
5             # The ASF licenses this file to you under the Apache License, Version 2.0
6             # (the "License"); you may not use this file except in compliance with
7             # the License. You may obtain a copy of the License at:
8             #
9             # http://www.apache.org/licenses/LICENSE-2.0
10             #
11             # Unless required by applicable law or agreed to in writing, software
12             # distributed under the License is distributed on an "AS IS" BASIS,
13             # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14             # See the License for the specific language governing permissions and
15             # limitations under the License.
16             # </@LICENSE>
17              
18             =head1 NAME
19              
20             Mail::SpamAssassin::Plugin::Hashcash - perform hashcash verification tests
21              
22             =head1 SYNOPSIS
23              
24             loadplugin Mail::SpamAssassin::Plugin::Hashcash
25              
26             =head1 DESCRIPTION
27              
28             Hashcash is a payment system for email where CPU cycles used as the
29             basis for an e-cash system. This plugin makes it possible to use valid
30             hashcash tokens added by mail programs as a bonus for messages.
31              
32             =cut
33              
34             =head1 USER SETTINGS
35              
36             =over 4
37              
38             =item use_hashcash { 1 | 0 } (default: 1)
39              
40             Whether to use hashcash, if it is available.
41              
42             =cut
43              
44             =item hashcash_accept user@example.com ...
45              
46             Used to specify addresses that we accept HashCash tokens for. You should set
47             it to match all the addresses that you may receive mail at.
48              
49             Like whitelist and blacklist entries, the addresses are file-glob-style
50             patterns, so C<friend@somewhere.com>, C<*@isp.com>, or C<*.domain.net> will all
51             work. Specifically, C<*> and C<?> are allowed, but all other metacharacters
52             are not. Regular expressions are not used for security reasons.
53              
54             The sequence C<%u> is replaced with the current user's username, which
55             is useful for ISPs or multi-user domains.
56              
57             Multiple addresses per line, separated by spaces, is OK. Multiple
58             C<hashcash_accept> lines is also OK.
59              
60             =cut
61              
62             =item hashcash_doublespend_path /path/to/file (default: ~/.spamassassin/hashcash_seen)
63              
64             Path for HashCash double-spend database. HashCash tokens are only usable once,
65             so their use is tracked in this database to avoid providing a loophole.
66              
67             By default, each user has their own, in their C<~/.spamassassin> directory with
68             mode 0700/0600. Note that once a token is 'spent' it is written to this file,
69             and double-spending of a hashcash token makes it invalid, so this is not
70             suitable for sharing between multiple users.
71              
72             =cut
73              
74             =item hashcash_doublespend_file_mode (default: 0700)
75              
76             The file mode bits used for the HashCash double-spend database file.
77              
78             Make sure you specify this using the 'x' mode bits set, as it may also be used
79             to create directories. However, if a file is created, the resulting file will
80             not have any execute bits set (the umask is set to 111).
81              
82             =cut
83              
84              
85             use strict;
86 20     20   138 use warnings;
  20         44  
  20         566  
87 20     20   97 # use bytes;
  20         42  
  20         553  
88             use re 'taint';
89 20     20   97  
  20         40  
  20         568  
90             use Mail::SpamAssassin::Plugin;
91 20     20   98 use Mail::SpamAssassin::Logger;
  20         39  
  20         354  
92 20     20   97 use Mail::SpamAssassin::Util qw(untaint_var);
  20         36  
  20         1082  
93 20     20   119  
  20         46  
  20         808  
94             use Errno qw(ENOENT EACCES);
95 20     20   113 use Fcntl;
  20         39  
  20         933  
96 20     20   124 use File::Path;
  20         61  
  20         5227  
97 20     20   138 use File::Basename;
  20         47  
  20         1233  
98 20     20   134  
  20         40  
  20         2078  
99             BEGIN {
100             eval { require Digest::SHA; import Digest::SHA qw(sha1); 1 }
101 20         105 or do { require Digest::SHA1; import Digest::SHA1 qw(sha1) }
  20         495  
  20         1055  
102 20 50   20   64 }
  0         0  
  0         0  
103              
104             our @ISA = qw(Mail::SpamAssassin::Plugin);
105              
106             use constant HAS_DB_FILE => eval { require DB_File; };
107 20     20   128  
  20         68  
  20         51  
  20         33362  
108             # constructor: register the eval rule
109             my $class = shift;
110             my $mailsaobject = shift;
111 61     61 1 188  
112 61         169 # some boilerplate...
113             $class = ref($class) || $class;
114             my $self = $class->SUPER::new($mailsaobject);
115 61   33     445 bless ($self, $class);
116 61         5280  
117 61         137 $self->register_eval_rule ("check_hashcash_value");
118             $self->register_eval_rule ("check_hashcash_double_spend");
119 61         249  
120 61         197 $self->set_config($mailsaobject->{conf});
121              
122 61         272 return $self;
123             }
124 61         559  
125             ###########################################################################
126              
127             my($self, $conf) = @_;
128             my @cmds;
129              
130 61     61 0 146 push(@cmds, {
131 61         126 setting => 'use_hashcash',
132             default => 1,
133 61         293 type => $Mail::SpamAssassin::Conf::CONF_TYPE_NUMERIC,
134             });
135              
136             push(@cmds, {
137             setting => 'hashcash_doublespend_path',
138             default => '__userstate__/hashcash_seen',
139 61         267 type => $Mail::SpamAssassin::Conf::CONF_TYPE_STRING,
140             });
141              
142             push(@cmds, {
143             setting => 'hashcash_doublespend_file_mode',
144             default => "0700",
145 61         215 type => $Mail::SpamAssassin::Conf::CONF_TYPE_NUMERIC,
146             });
147              
148             push(@cmds, {
149             setting => 'hashcash_accept',
150             default => {},
151 61         264 type => $Mail::SpamAssassin::Conf::CONF_TYPE_ADDRLIST,
152             });
153              
154             $conf->{parser}->register_commands(\@cmds);
155             }
156              
157 61         291 ###########################################################################
158              
159             my ($self, $scanner, $valmin, $valmax) = @_;
160             my $val = $self->_run_hashcash($scanner);
161             return ($val >= $valmin && $val < $valmax);
162             }
163 567     567 0 2376  
164 567         1401 my ($self, $scanner) = @_;
165 567   33     8865 $self->_run_hashcash($scanner);
166             return ($scanner->{hashcash_double_spent});
167             }
168              
169 81     81 0 203 ############################################################################
170 81         225  
171 81         1120 my ($self, $scanner) = @_;
172              
173             if (defined $scanner->{hashcash_value}) { return $scanner->{hashcash_value}; }
174              
175             $scanner->{hashcash_value} = 0;
176              
177 648     648   933 # X-Hashcash: 0:031118:camram-spam@camram.org:c068b58ade6dcbaf
178             # or:
179 648 100       1376 # X-hashcash: 1:20:040803:hashcash@freelists.org::6dcdb3a3ad4e1b86:1519d
  567         1026  
180             # X-hashcash: 1:20:040803:jm@jmason.org::6b484d06469ccb28:8838a
181 81         186 # X-hashcash: 1:20:040803:adam@cypherspace.org::a1cbc54bf0182ea8:5d6a0
182              
183             # call down to {msg} so that we can get it as an array of
184             # individual headers
185             my @hdrs = $scanner->{msg}->get_header ("X-Hashcash");
186             if (scalar @hdrs == 0) {
187             @hdrs = $scanner->{msg}->get_header ("Hashcash");
188             }
189              
190             foreach my $hc (@hdrs) {
191 81         321 my $value = $self->_run_hashcash_for_one_string($scanner, $hc);
192 81 50       335 if ($value) {
193 81         248 # remove the "double-spend" bool if we did find a usable string;
194             # this happens when one string is already spent, but another
195             # string has not yet been.
196 81         335 delete $scanner->{hashcash_double_spent};
197 0         0 return $value;
198 0 0       0 }
199             }
200             return 0;
201             }
202 0         0  
203 0         0 my ($self, $scanner, $hc) = @_;
204              
205             if (!$hc) { return 0; }
206 81         189 $hc =~ s/\s+//gs; # remove whitespace from multiline, folded tokens
207              
208             # untaint the string for paranoia, making sure not to allow \n \0 \' \"
209             if ($hc =~ /^[-A-Za-z0-9\xA0-\xFF:_\/\%\@\.\,\= \*\+\;]+$/) {
210 0     0     $hc = untaint_var($hc);
211             }
212 0 0         if (!$hc) { return 0; }
  0            
213 0            
214             my ($ver, $bits, $date, $rsrc, $exts, $rand, $trial);
215             if ($hc =~ /^0:/) {
216 0 0         ($ver, $date, $rsrc, $trial) = split (/:/, $hc, 4);
217 0           }
218             elsif ($hc =~ /^1:/) {
219 0 0         ($ver, $bits, $date, $rsrc, $exts, $rand, $trial) =
  0            
220             split (/:/, $hc, 7);
221 0           # extensions are, as yet, unused by SpamAssassin
222 0 0         }
    0          
223 0           else {
224             dbg("hashcash: version $ver stamps not yet supported");
225             return 0;
226 0           }
227              
228             if (!$trial) {
229             dbg("hashcash: no trial in stamp '$hc'");
230             return 0;
231 0           }
232 0            
233             my $accept = $scanner->{conf}->{hashcash_accept};
234             if (!$self->_check_hashcash_resource ($scanner, $accept, $rsrc)) {
235 0 0         dbg("hashcash: resource $rsrc not accepted here");
236 0           return 0;
237 0           }
238              
239             # get the hash collision from the token. Computing the hash collision
240 0           # is very easy (great!) -- just get SHA1(token) and count the 0 bits at
241 0 0         # the start of the SHA1 hash, according to the draft at
242 0           # http://www.hashcash.org/draft-hashcash.txt .
243 0           my $value = 0;
244             my $bitstring = unpack ("B*", sha1($hc));
245             $bitstring =~ /^(0+)/ and $value = length $1;
246              
247             # hashcash v1 tokens: if the "claimed value" of the token is less than
248             # what the token actually contains (ie. token was accidentally generated
249             # with 24 bits instead of the claimed 20), then cut it down to just the
250 0           # claimed value. that way it's a bit tidier and more deterministic.
251 0           if ($bits && $value > $bits) {
252 0 0         $value = $bits;
253             }
254              
255             dbg("hashcash: token value: $value");
256              
257             if ($self->was_hashcash_token_double_spent ($scanner, $hc)) {
258 0 0 0       $scanner->{hashcash_double_spent} = 1;
259 0           return 0;
260             }
261              
262 0           $scanner->{hashcash_value} = $value;
263             return $value;
264 0 0         }
265 0            
266 0           my ($self, $scanner, $token) = @_;
267              
268             my $main = $self->{main};
269 0           if (!$main->{conf}->{hashcash_doublespend_path}) {
270 0           dbg("hashcash: hashcash_doublespend_path not defined or empty");
271             return 0;
272             }
273             if (!HAS_DB_FILE) {
274 0     0 0   dbg("hashcash: DB_File module not installed, cannot use double-spend db");
275             return 0;
276 0           }
277 0 0          
278 0           my $path = $main->sed_path ($main->{conf}->{hashcash_doublespend_path});
279 0           my $parentdir = dirname ($path);
280             my $stat_errn = stat($parentdir) ? 0 : 0+$!;
281 0 0         if ($stat_errn == 0 && !-d _) {
282 0           dbg("hashcash: parent dir $parentdir exists but is not a directory");
283 0           } elsif ($stat_errn == ENOENT) {
284             # run in an eval(); if mkpath has no perms, it calls die()
285             eval {
286 0           mkpath ($parentdir, 0, (oct ($main->{conf}->{hashcash_doublespend_file_mode}) & 0777));
287 0           };
288 0 0         }
289 0 0 0        
    0          
290 0           my %spenddb;
291             if (!tie %spenddb, "DB_File", $path, O_RDWR|O_CREAT,
292             (oct ($main->{conf}->{hashcash_doublespend_file_mode}) & 0666))
293 0           {
294 0           dbg("hashcash: failed to tie to $path: $@ $!");
295             # not a serious error. TODO?
296             return 0;
297             }
298 0            
299 0 0         if (exists $spenddb{$token}) {
300             untie %spenddb;
301             dbg("hashcash: token '$token' spent already");
302 0           return 1;
303             }
304 0            
305             $spenddb{$token} = time;
306             dbg("hashcash: marking token '$token' as spent");
307 0 0          
308 0           # TODO: expiry?
309 0            
310 0           untie %spenddb;
311              
312             return 0;
313 0           }
314 0            
315             my ($self, $scanner, $list, $addr) = @_;
316             $addr = lc $addr;
317             if (defined ($list->{$addr})) { return 1; }
318 0           study $addr; # study is a no-op since perl 5.16.0, eliminating related bugs
319              
320 0           foreach my $regexp (values %{$list})
321             {
322             # allow %u == current username
323             # \\ is added by $conf->add_to_addrlist()
324 0     0     $regexp =~ s/\\\%u/$scanner->{main}->{username}/gs;
325 0            
326 0 0         if ($addr =~ /$regexp/i) {
  0            
327 0           return 1;
328             }
329 0           }
  0            
330              
331             # TODO: use "To" and "Cc" addresses gleaned from the mails in the Bayes
332             # database trained as ham, as well.
333 0            
334             return 0;
335 0 0         }
336 0            
337             ############################################################################
338              
339             1;
340              
341             =back
342              
343 0           =cut