File Coverage

blib/lib/Crypt/RFC8188.pm
Criterion Covered Total %
statement 102 102 100.0
branch 33 36 91.6
condition 18 24 75.0
subroutine 13 13 100.0
pod 2 3 66.6
total 168 178 94.3


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