]> git.sesse.net Git - www-csrf/blob - lib/WWW/CSRF.pm
Change the return value to be more detailed when something fails. (API break, but...
[www-csrf] / lib / WWW / CSRF.pm
1 package WWW::CSRF;
2
3 use strict;
4 use warnings;
5 use Bytes::Random::Secure;
6 use Digest::HMAC_SHA1;
7 use constant {
8         CSRF_OK => 0,
9         CSRF_MALFORMED_TOKEN => 1,
10         CSRF_INVALID_SIGNATURE => 2,
11         CSRF_EXPIRED => 3,
12 };
13
14 require Exporter;
15 our @ISA = qw(Exporter);
16 our @EXPORT_OK = qw(generate_csrf_token check_csrf_token CSRF_OK CSRF_MALFORMED_TOKEN CSRF_INVALID_SIGNATURE CSRF_EXPIRED);
17 our $VERSION = '1.00';
18
19 sub generate_csrf_token {
20         my ($id, $secret, $options) = @_;
21
22         my $time = $options->{'Time'} // time;
23         my $random = $options->{'Random'};
24
25         my $digest = Digest::HMAC_SHA1::hmac_sha1($time . "/" . $id, $secret);
26         my @digest_bytes = _to_byte_array($digest);
27
28         # Mask the token to avoid the BREACH attack.
29         if (!defined($random)) {
30                 $random = Bytes::Random::Secure::random_bytes(scalar @digest_bytes);
31         } elsif (length($random) != length($digest)) {
32                 die "Given randomness is of the wrong length (should be " . length($digest) . " bytes)";
33         }
34         my @random_bytes = _to_byte_array($random);
35         
36         my $masked_token = "";
37         my $mask = "";
38         for my $i (0..$#digest_bytes) {
39                 $masked_token .= sprintf "%02x", ($digest_bytes[$i] ^ $random_bytes[$i]);
40                 $mask .= sprintf "%02x", $random_bytes[$i];
41         }
42
43         return sprintf("%s,%s,%d", $masked_token, $mask, $time);
44 }
45
46 sub check_csrf_token {
47         my ($id, $secret, $csrf_token, $options) = @_;
48
49         if ($csrf_token !~ /^([0-9a-f]+),([0-9a-f]+),([0-9]+)$/) {
50                 return CSRF_MALFORMED_TOKEN;
51         }
52
53         my $ref_time = $options->{'Time'} // time;
54
55         my ($masked_token, $mask, $time) = ($1, $2, $3);
56         my $max_age = $options->{'MaxAge'};
57         if (defined($max_age) && $ref_time - $time > $max_age) {
58                 return CSRF_EXPIRED;
59         }
60
61         my @masked_bytes = _to_byte_array(pack('H*', $masked_token));
62         my @mask_bytes = _to_byte_array(pack('H*', $mask));
63
64         my $correct_token = Digest::HMAC_SHA1::hmac_sha1($time . '/' . $id, $secret);
65         my @correct_bytes = _to_byte_array($correct_token);
66
67         if ($#masked_bytes != $#mask_bytes || $#masked_bytes != $#correct_bytes) {
68                 # Malformed token (wrong number of characters).
69                 return CSRF_MALFORMED_TOKEN;
70         }
71
72         # Compare in a way that should make timing attacks hard.
73         my $mismatches = 0;
74         for my $i (0..$#masked_bytes) {
75                 $mismatches += $masked_bytes[$i] ^ $mask_bytes[$i] ^ $correct_bytes[$i];
76         }
77         if ($mismatches == 0) {
78                 return CSRF_OK;
79         } else {
80                 return CSRF_INVALID_SIGNATURE;
81         }
82 }
83
84 # Converts each byte in the given string to its numeric value,
85 # e.g., "ABCabc" becomes (65, 66, 67, 97, 98, 99).
86 sub _to_byte_array {
87         return unpack("C*", $_[0]);
88 }
89
90 1;