File Coverage

blib/lib/Crypt/RFC8188.pm
Criterion Covered Total %
statement 103 103 100.0
branch 34 36 94.4
condition 18 24 75.0
subroutine 13 13 100.0
pod 2 3 66.6
total 170 179 94.9


line stmt bran cond sub pod time code
1             package Crypt::RFC8188;
2 1     1   78640 use strict;
  1         10  
  1         24  
3 1     1   4 use warnings;
  1         2  
  1         23  
4 1     1   4 use MIME::Base64 qw(encode_base64url decode_base64url);
  1         1  
  1         40  
5 1     1   4 use Crypt::PK::ECC;
  1         2  
  1         23  
6 1     1   970 use Math::BigInt;
  1         21233  
  1         5  
7 1     1   19891 use Encode qw(encode decode);
  1         8869  
  1         71  
8 1     1   408 use Crypt::KeyDerivation qw(hkdf hkdf_extract hkdf_expand);
  1         271  
  1         58  
9 1     1   378 use Crypt::AuthEnc::GCM qw(gcm_encrypt_authenticate gcm_decrypt_verify);
  1         260  
  1         52  
10 1     1   6 use Exporter qw(import);
  1         1  
  1         22  
11 1     1   5 use Crypt::PRNG qw(random_bytes);
  1         1  
  1         870  
12              
13             our $VERSION = "0.04";
14             our @EXPORT_OK = qw(ece_encrypt_aes128gcm ece_decrypt_aes128gcm derive_key);
15              
16             my $MAX_RECORD_SIZE = (2 ** 31) - 1;
17              
18             # $dh will always be public key data - decode_base64url if necessary
19             sub derive_key {
20 24     24 0 21218 my ($mode, $salt, $key, $private_key, $dh, $auth_secret) = @_;
21 24 100 66     99 die "Salt must be 16 octets\n" unless $salt and length $salt == 16;
22 23         36 my ($context, $secret) = ("");
23 23 100       46 if ($dh) {
24 3 100       12 die "DH requires a private_key\n" unless $private_key;
25 2         10 my $pubkey = Crypt::PK::ECC->new->import_key_raw($dh, 'P-256');
26 2         4843 my $encoded = $private_key->export_key_raw('public');
27 2 100       13 my ($sender_pub_key, $receiver_pub_key) = ($mode eq "encrypt")
28             ? ($encoded, $dh) : ($dh, $encoded);
29 2         7 $context = "WebPush: info\x00" . $receiver_pub_key . $sender_pub_key;
30 2         5396 $secret = $private_key->shared_secret($pubkey);
31             } else {
32 20         22 $secret = $key;
33             }
34 22 100       48 die "Unable to determine the secret\n" unless $secret;
35 21         30 my $keyinfo = "Content-Encoding: aes128gcm\x00";
36 21         24 my $nonceinfo = "Content-Encoding: nonce\x00";
37             # Only mix the authentication secret when using DH for aes128gcm
38 21 100       35 $auth_secret = undef if !$dh;
39 21 100       32 if ($auth_secret) {
40 2         37 $secret = hkdf $secret, $auth_secret, 'SHA256', 32, $context;
41             }
42             (
43 21         445 hkdf($secret, $salt, 'SHA256', 16, $keyinfo),
44             hkdf($secret, $salt, 'SHA256', 12, $nonceinfo),
45             );
46             }
47              
48             sub ece_encrypt_aes128gcm {
49             my (
50 6     6 1 18678 $content, $salt, $key, $private_key, $dh, $auth_secret, $keyid, $rs,
51             ) = @_;
52 6   66     22 $salt ||= random_bytes(16);
53 6   100     29 $rs ||= 4096;
54 6 50       14 die "Too much content\n" if $rs > $MAX_RECORD_SIZE;
55 6         12 my ($key_, $nonce_) = derive_key(
56             'encrypt', $salt, $key, $private_key, $dh, $auth_secret,
57             );
58 6         11 my $overhead = 17;
59 6 100       18 die "Record size too small\n" if $rs <= $overhead;
60 5         7 my $end = length $content;
61 5         9 my $chunk_size = $rs - $overhead;
62 5         7 my $result = "";
63 5         6 my $counter = 0;
64 5         23 my $nonce_bigint = Math::BigInt->from_bytes($nonce_);
65             # the extra one on the loop ensures that we produce a padding only
66             # record if the data length is an exact multiple of the chunk size
67 5         2156 for (my $i = 0; $i <= $end; $i += $chunk_size) {
68 5         29 my $iv = ($nonce_bigint ^ $counter)->as_bytes;
69 5 50       4767 my ($data, $tag) = gcm_encrypt_authenticate 'AES', $key_, $iv, '',
70             substr($content, $i, $chunk_size) .
71             ((($i + $chunk_size) >= $end) ? "\x02" : "\x01")
72             ;
73 5         24 $result .= $data . $tag;
74 5         15 $counter++;
75             }
76 5 100 100     25 if (!$keyid and $private_key) {
77 1         12 $keyid = $private_key->export_key_raw('public');
78             } else {
79 4   100     22 $keyid = encode('UTF-8', $keyid || '', Encode::FB_CROAK | Encode::LEAVE_SRC);
80             }
81 5 100       352 die "keyid is too long\n" if length($keyid) > 255;
82 4         30 $salt . pack('L> C', $rs, length $keyid) . $keyid . $result;
83             }
84              
85             sub ece_decrypt_aes128gcm {
86             my (
87             # no salt, keyid, rs as encoded in header
88 9     9 1 8507 $content, $key, $private_key, $dh, $auth_secret,
89             ) = @_;
90 9         31 my $id_len = unpack 'C', substr $content, 20, 1;
91 9         21 my $salt = substr $content, 0, 16;
92 9         21 my $rs = unpack 'L>', substr $content, 16, 4;
93 9         10 my $overhead = 17;
94 9 100       26 die "Record size too small\n" if $rs <= $overhead;
95 8         15 my $keyid = substr $content, 21, $id_len;
96 8         17 $content = substr $content, 21 + $id_len;
97 8 100 66     24 if ($private_key and !$dh) {
98 1         4 $dh = $keyid;
99             } else {
100 7   50     35 $keyid = decode('UTF-8', $keyid || '', Encode::FB_CROAK | Encode::LEAVE_SRC);
101             }
102 8         324 my ($key_, $nonce_) = derive_key(
103             'decrypt', $salt, $key, $private_key, $dh, $auth_secret,
104             );
105 8         14 my $chunk_size = $rs;
106 8         11 my $result = "";
107 8         10 my $counter = 0;
108 8         12 my $end = length $content;
109 8         23 my $nonce_bigint = Math::BigInt->from_bytes($nonce_);
110 8         3181 for (my $i = 0; $i < $end; $i += $chunk_size) {
111 8         23 my $iv = ($nonce_bigint ^ $counter)->as_bytes;
112 8         5521 my $bit = substr $content, $i, $chunk_size;
113 8         19 my $ciphertext = substr $bit, 0, length($bit) - 16;
114 8         12 my $tag = substr $bit, -16;
115 8         1304 my $data = gcm_decrypt_verify 'AES', $key_, $iv, '', $ciphertext, $tag;
116 8 100       35 die "Decryption error\n" unless defined $data;
117 7         14 my $last = ($i + $chunk_size) >= $end;
118 7         56 $data =~ s/\x00*\z//;
119 7 100       23 die "all zero record plaintext\n" if !length $data;
120 6         17 my $last_byte = ord substr $data, -1, 1, '';
121 6 100 66     30 die "record delimiter($last_byte) != 1\n" if !$last and $last_byte != 1;
122 5 100 66     37 die "last record delimiter($last_byte) != 2\n" if $last and $last_byte != 2;
123 4         10 $result .= $data;
124 4         11 $counter++;
125             }
126 4         19 $result;
127             }
128              
129             =encoding utf-8
130              
131             =head1 NAME
132              
133             Crypt::RFC8188 - Implement RFC 8188 HTTP Encrypted Content Encoding
134              
135             =begin markdown
136              
137             # PROJECT STATUS
138              
139             | OS | Build status |
140             |:-------:|--------------:|
141             | Linux | [![Build Status](https://travis-ci.org/mohawk2/Crypt-RFC8188.svg?branch=master)](https://travis-ci.org/mohawk2/Crypt-RFC8188) |
142              
143             [![CPAN version](https://badge.fury.io/pl/Crypt-RFC8188.svg)](https://metacpan.org/pod/Crypt-RFC8188) [![Coverage Status](https://coveralls.io/repos/github/mohawk2/Crypt-RFC8188/badge.svg?branch=master)](https://coveralls.io/github/mohawk2/Crypt-RFC8188?branch=master)
144              
145             =end markdown
146              
147             =head1 SYNOPSIS
148              
149             use Crypt::RFC8188 qw(ece_encrypt_aes128gcm ece_decrypt_aes128gcm);
150             my $ciphertext = ece_encrypt_aes128gcm(
151             $plaintext, $salt, $key, $private_key, $dh, $auth_secret, $keyid, $rs,
152             );
153             my $plaintext = ece_decrypt_aes128gcm(
154             # no salt, keyid, rs as encoded in header
155             $ciphertext, $key, $private_key, $dh, $auth_secret,
156             );
157              
158             =head1 DESCRIPTION
159              
160             This module implements RFC 8188, the HTTP Encrypted Content Encoding
161             standard. Among other things, this is used by Web Push (RFC 8291).
162              
163             It implements only the C (Advanced Encryption Standard
164             128-bit Galois/Counter Mode) encryption, not the previous draft standards
165             envisaged for Web Push. It implements neither C nor C.
166              
167             =head1 FUNCTIONS
168              
169             Exportable (not by default) functions:
170              
171             =head2 ece_encrypt_aes128gcm
172              
173             Arguments:
174              
175             =head3 $plaintext
176              
177             The plain text.
178              
179             =head3 $salt
180              
181             A randomly-generated 16-octet sequence. If not provided, one will be
182             generated. This is still useful as the salt is included in the ciphertext.
183              
184             =head3 $key
185              
186             A secret key to be exchanged by other means.
187              
188             =head3 $private_key
189              
190             The private key of a L Prime 256 ECDSA key.
191              
192             =head3 $dh
193              
194             If the private key above is provided, this is the recipient's public
195             key of an Prime 256 ECDSA key.
196              
197             =head3 $auth_secret
198              
199             An authentication secret.
200              
201             =head3 $keyid
202              
203             If provided, the ID of a key to be looked up by other means.
204              
205             =head3 $rs
206              
207             The record size for encrypted blocks. Must be at least 18, which would
208             be very inefficient as the overhead is 17 bytes. Defaults to 4096.
209              
210             =head2 ece_decrypt_aes128gcm
211              
212             =head3 $ciphertext
213              
214             The plain text.
215              
216             =head3 $key
217              
218             =head3 $private_key
219              
220             =head3 $dh
221              
222             =head3 $auth_secret
223              
224             All as above. C<$salt>, C<$keyid>, C<$rs> are not given since they are
225             encoded in the ciphertext.
226              
227             =head1 SEE ALSO
228              
229             L
230              
231             RFC 8188 - Encrypted Content-Encoding for HTTP (using C).
232              
233             =head1 AUTHOR
234              
235             Ed J, C<< >>
236              
237             =head1 LICENSE
238              
239             Copyright (C) Ed J
240              
241             This library is free software; you can redistribute it and/or modify
242             it under the same terms as Perl itself.
243              
244             =cut
245              
246             1;