File Coverage

blib/lib/Crypt/Age/Primitives.pm
Criterion Covered Total %
statement 117 117 100.0
branch 17 24 70.8
condition 2 3 66.6
subroutine 22 22 100.0
pod 11 11 100.0
total 169 177 95.4


line stmt bran cond sub pod time code
1             package Crypt::Age::Primitives;
2             our $VERSION = '0.001';
3             our $AUTHORITY = 'cpan:GETTY';
4             # ABSTRACT: Low-level cryptographic primitives for age encryption
5              
6 4     4   33 use Moo;
  4         8  
  4         34  
7 4     4   1549 use Carp qw(croak);
  4         6  
  4         230  
8 4     4   497 use Crypt::PK::X25519;
  4         14490  
  4         233  
9 4     4   1993 use Crypt::AuthEnc::ChaCha20Poly1305;
  4         1726  
  4         203  
10 4     4   1688 use Crypt::KeyDerivation qw(hkdf);
  4         2130  
  4         338  
11 4     4   1689 use Crypt::Mac::HMAC qw(hmac);
  4         6369  
  4         273  
12 4     4   28 use Crypt::PRNG qw(random_bytes);
  4         6  
  4         198  
13 4     4   396 use namespace::clean;
  4         13448  
  4         28  
14              
15              
16             # Constants from age spec
17             use constant {
18 4         458 FILE_KEY_SIZE => 16,
19             X25519_KEY_SIZE => 32,
20             CHUNK_SIZE => 64 * 1024, # 64 KiB
21             NONCE_SIZE => 12,
22             TAG_SIZE => 16,
23 4     4   1400 };
  4         9  
24              
25             # HKDF labels from age spec
26             use constant {
27 4         5167 LABEL_X25519 => "age-encryption.org/v1/X25519",
28             LABEL_HEADER => "header",
29             LABEL_PAYLOAD => "payload",
30 4     4   22 };
  4         5  
31              
32             sub generate_file_key {
33 14     14 1 159 return random_bytes(FILE_KEY_SIZE);
34             }
35              
36              
37             sub x25519_generate_keypair {
38 15     15 1 39 my ($class) = @_;
39 15         97 my $pk = Crypt::PK::X25519->new;
40 15         34219 $pk->generate_key;
41 15         333 return ($pk->export_key_raw('public'), $pk->export_key_raw('private'));
42             }
43              
44              
45             sub x25519_shared_secret {
46 28     28 1 78 my ($class, $our_private, $their_public) = @_;
47              
48 28         96 my $our_pk = Crypt::PK::X25519->new;
49 28         1531 $our_pk->import_key_raw($our_private, 'private');
50              
51 28         57918 my $their_pk = Crypt::PK::X25519->new;
52 28         1775 $their_pk->import_key_raw($their_public, 'public');
53              
54 28         57747 return $our_pk->shared_secret($their_pk);
55             }
56              
57              
58             sub derive_wrap_key {
59 28     28 1 102 my ($class, $shared_secret, $ephemeral_public, $recipient_public) = @_;
60              
61             # salt = ephemeral_public || recipient_public
62 28         70 my $salt = $ephemeral_public . $recipient_public;
63              
64             # hkdf($secret, $salt, $hash, $length, $info)
65 28         573 return hkdf($shared_secret, $salt, 'SHA256', 32, LABEL_X25519);
66             }
67              
68              
69             sub wrap_file_key {
70 15     15 1 46 my ($class, $wrap_key, $file_key) = @_;
71              
72 15 50       60 croak "Wrap key must be 32 bytes" unless length($wrap_key) == 32;
73 15 50       43 croak "File key must be 16 bytes" unless length($file_key) == FILE_KEY_SIZE;
74              
75             # ChaCha20-Poly1305 with zero nonce
76 15         33 my $nonce = "\x00" x NONCE_SIZE;
77 15         164 my $ae = Crypt::AuthEnc::ChaCha20Poly1305->new($wrap_key, $nonce);
78 15         105 my $ciphertext = $ae->encrypt_add($file_key);
79 15         76 my $tag = $ae->encrypt_done;
80              
81 15         93 return $ciphertext . $tag;
82             }
83              
84              
85             sub unwrap_file_key {
86 13     13 1 40 my ($class, $wrap_key, $wrapped_key) = @_;
87              
88 13 50       127 croak "Wrap key must be 32 bytes" unless length($wrap_key) == 32;
89 13 50       41 croak "Wrapped key must be 32 bytes" unless length($wrapped_key) == FILE_KEY_SIZE + TAG_SIZE;
90              
91 13         32 my $ciphertext = substr($wrapped_key, 0, FILE_KEY_SIZE);
92 13         33 my $tag = substr($wrapped_key, FILE_KEY_SIZE, TAG_SIZE);
93              
94 13         20 my $nonce = "\x00" x NONCE_SIZE;
95 13         118 my $ae = Crypt::AuthEnc::ChaCha20Poly1305->new($wrap_key, $nonce);
96 13         79 my $file_key = $ae->decrypt_add($ciphertext);
97              
98 13 100       1236 croak "Authentication failed" unless $ae->decrypt_done($tag);
99              
100 10         61 return $file_key;
101             }
102              
103              
104             sub derive_payload_key {
105 14     14 1 33 my ($class, $file_key, $nonce) = @_;
106              
107 14 50       87 croak "nonce required" unless defined $nonce;
108 14 50       32 croak "nonce must be 16 bytes" unless length($nonce) == 16;
109              
110             # Derive payload key using HKDF
111             # hkdf($secret, $salt, $hash, $length, $info)
112             # The nonce is used as salt, and "payload" is the info string
113 14         151 return hkdf($file_key, $nonce, 'SHA256', 32, LABEL_PAYLOAD);
114             }
115              
116             sub generate_payload_nonce {
117 7     7 1 30 return random_bytes(16);
118             }
119              
120              
121             sub compute_header_mac {
122 25     25 1 65 my ($class, $file_key, $header_bytes) = @_;
123              
124             # Derive MAC key using HKDF
125             # hkdf($secret, $salt, $hash, $length, $info)
126 25         374 my $mac_key = hkdf($file_key, '', 'SHA256', 32, LABEL_HEADER);
127              
128 25         269 return hmac('SHA256', $mac_key, $header_bytes);
129             }
130              
131              
132             sub encrypt_payload {
133 7     7 1 17 my ($class, $payload_key, $plaintext) = @_;
134              
135 7         11 my @chunks;
136 7         8 my $offset = 0;
137 7         9 my $counter = 0;
138 7         13 my $remaining = length($plaintext);
139              
140 7   66     54 while ($remaining > 0 || $counter == 0) {
141 10 100       26 my $chunk_size = $remaining > CHUNK_SIZE ? CHUNK_SIZE : $remaining;
142 10         149 my $chunk = substr($plaintext, $offset, $chunk_size);
143 10         21 my $is_final = ($remaining <= CHUNK_SIZE);
144              
145 10         31 my $nonce = $class->_make_nonce($counter, $is_final);
146 10         52 my $ae = Crypt::AuthEnc::ChaCha20Poly1305->new($payload_key, $nonce);
147              
148 10         1597 my $ciphertext = $ae->encrypt_add($chunk);
149 10         37 my $tag = $ae->encrypt_done;
150              
151 10         336 push @chunks, $ciphertext . $tag;
152              
153 10         13 $offset += $chunk_size;
154 10         15 $remaining -= $chunk_size;
155 10         13 $counter++;
156              
157 10 100       56 last if $is_final;
158             }
159              
160 7         762 return join('', @chunks);
161             }
162              
163              
164             sub decrypt_payload {
165 7     7 1 18 my ($class, $payload_key, $ciphertext) = @_;
166              
167 7         11 my @plaintext_chunks;
168 7         10 my $offset = 0;
169 7         21 my $counter = 0;
170 7         13 my $remaining = length($ciphertext);
171              
172 7         18 while ($remaining > 0) {
173             # Each encrypted chunk is plaintext + 16 byte tag
174 10         11 my $max_encrypted_chunk = CHUNK_SIZE + TAG_SIZE;
175 10 100       20 my $chunk_size = $remaining > $max_encrypted_chunk ? $max_encrypted_chunk : $remaining;
176              
177 10         179 my $encrypted_chunk = substr($ciphertext, $offset, $chunk_size);
178 10         18 my $is_final = ($remaining <= $max_encrypted_chunk);
179              
180 10         43 my $ct = substr($encrypted_chunk, 0, -TAG_SIZE);
181 10         14 my $tag = substr($encrypted_chunk, -TAG_SIZE);
182              
183 10         28 my $nonce = $class->_make_nonce($counter, $is_final);
184 10         47 my $ae = Crypt::AuthEnc::ChaCha20Poly1305->new($payload_key, $nonce);
185              
186 10         1516 my $plaintext = $ae->decrypt_add($ct);
187 10 50       38 croak "Payload authentication failed at chunk $counter"
188             unless $ae->decrypt_done($tag);
189              
190 10         18 push @plaintext_chunks, $plaintext;
191              
192 10         13 $offset += $chunk_size;
193 10         13 $remaining -= $chunk_size;
194 10         43 $counter++;
195             }
196              
197 7         671 return join('', @plaintext_chunks);
198             }
199              
200              
201             sub _make_nonce {
202 20     20   35 my ($class, $counter, $is_final) = @_;
203              
204             # 11 bytes counter (big-endian) + 1 byte final flag
205 20         99 my $nonce = pack('x3 N N', ($counter >> 32) & 0xFFFFFFFF, $counter & 0xFFFFFFFF);
206             # Actually, the nonce is: 11-byte big-endian counter || 1-byte last-block flag
207             # Let's be more precise:
208 20         31 $nonce = "\x00" x 3; # First 3 bytes zero
209 20         49 $nonce .= pack('N', ($counter >> 32) & 0xFFFFFFFF); # Next 4 bytes
210 20         43 $nonce .= pack('N', $counter & 0xFFFFFFFF); # Next 4 bytes
211 20 100       56 $nonce .= pack('C', $is_final ? 1 : 0); # Last byte: final flag
212              
213 20         42 return $nonce;
214             }
215              
216              
217             1;
218              
219             __END__