File Coverage

blib/lib/VAPID.pm
Criterion Covered Total %
statement 158 191 82.7
branch 38 60 63.3
condition 10 22 45.4
subroutine 25 26 96.1
pod 11 11 100.0
total 242 310 78.0


line stmt bran cond sub pod time code
1             package VAPID;
2 2     2   213461 use 5.006; use strict; use warnings; our $VERSION = '1.05';
  2     2   6  
  2     2   10  
  2         4  
  2         68  
  2         17  
  2         4  
  2         219  
3 2     2   1471 use Crypt::JWT qw(encode_jwt); use Crypt::PK::ECC; use URI; use MIME::Base64 qw/encode_base64url decode_base64url/;
  2     2   132766  
  2     2   154  
  2     2   23  
  2         3  
  2         66  
  2         1283  
  2         13775  
  2         95  
  2         1298  
  2         1895  
  2         190  
4 2     2   34 use Crypt::AuthEnc::GCM qw(gcm_encrypt_authenticate); use Crypt::KeyDerivation qw(hkdf); use Crypt::PRNG qw(random_bytes);
  2     2   9  
  2     2   151  
  2         15  
  2         3  
  2         108  
  2         14  
  2         4  
  2         125  
5 2     2   1248 use HTTP::Request; use LWP::UserAgent; use JSON qw(decode_json);
  2     2   38846  
  2     2   91  
  2         1853  
  2         82478  
  2         107  
  2         19  
  2         3  
  2         20  
6 2     2   287 use base 'Import::Export';
  2         4  
  2         1164  
7              
8             our (%EX, $DEFAULT_SECONDS, $MAX_DEFAULT_SECONDS);
9              
10             BEGIN {
11 2     2   31250 $DEFAULT_SECONDS = 12 * 60 * 60; # 12 hours
12 2         3 $MAX_DEFAULT_SECONDS = 24 * 60 * 60; # 24 hours
13 2         4527 %EX = (
14             generate_vapid_keys => [qw/all generate/],
15             generate_future_expiration_timestamp => [qw/all generate/],
16             generate_vapid_header => [qw/all generate/],
17             validate_subject => [qw/all validate/],
18             validate_public_key => [qw/all validate/],
19             validate_private_key => [qw/all validate/],
20             validate_expiration_key => [qw/all validate/],
21             validate_expiration => [qw/all validate/],
22             validate_subscription => [qw/all validate/],
23             encrypt_payload => [qw/all encrypt/],
24             build_push_request => [qw/all push/],
25             send_push_notification => [qw/all push/],
26             );
27             }
28              
29             sub generate_vapid_keys {
30 2     2 1 203601 my $curve = Crypt::PK::ECC->new();
31 2         8118 $curve->generate_key('prime256v1');
32 2         82 my $priv = $curve->export_key_raw('private');
33 2         16 my $pub = $curve->export_key_raw('public');
34            
35 2 50       11 if (length($priv) < 32) {
36 0         0 my $padding = 32 - length $priv;
37 0         0 $priv = (0 x $padding) . $priv;
38             }
39            
40 2 50       6 if (length($pub) < 65) {
41 0         0 my $padding = 65 - length $pub;
42 0         0 $pub = (0 x $padding) . $pub;
43             }
44              
45             return (
46 2         12 encode_base64url($pub),
47             encode_base64url($priv)
48             );
49             }
50              
51             sub generate_vapid_header {
52 4     4 1 1045 my ($aud, $subject, $pub, $priv, $expiration, $enc) = @_;
53              
54 4 50       31 if (!$aud) {
55 0         0 die "No audience could be generated for VAPID.";
56             }
57              
58 4 50       33 if (ref $aud) {
59 0         0 die "The audience value must be a string containing the origin of a push service";
60             }
61              
62 4         24 my $aud_uri = URI->new($aud);
63              
64 4 50       12275 if (!$aud_uri->host) {
65 0         0 die "VAPID audience is not a url.";
66             }
67              
68 4         296 validate_subject($subject);
69 4         14 validate_public_key($pub);
70 4         11 $priv = validate_private_key($priv);
71              
72 4 100       11 if ($expiration) {
73 2         9 validate_expiration($expiration);
74             } else {
75 2         5 $expiration = generate_future_expiration_timestamp();
76             }
77              
78 4         24 my $payload = {
79             aud => $aud,
80             exp => $expiration,
81             sub => $subject
82             };
83              
84 4         46 my $key = Crypt::PK::ECC->new
85             ->import_key_raw($priv, 'prime256v1')
86             ->export_key_pem('private');
87              
88              
89 4         32972 my $jwt_token = encode_jwt(
90             payload=>$payload,
91             extra_headers => { typ => 'JWT' },
92             alg=>'ES256',
93             key => \$key
94             );
95              
96 4 100       48094 return $enc
97             ? {
98             Authorization => "vapit t=${jwt_token}, k=${pub}"
99             }
100             : {
101             Authorization => 'WebPush ' . $jwt_token,
102             'Crypto-Key' => 'p256ecdsa=' . $pub
103             };
104             }
105              
106             sub generate_future_expiration_timestamp {
107 5     5 1 9 my ($add) = shift;
108 5   66     19 return time + ($add || $DEFAULT_SECONDS);
109             }
110              
111             sub validate_subject {
112 5     5 1 729 my ($subject) = shift;
113            
114 5 50       36 if (!$subject) {
115 0         0 die "No subject passed to validate_subject";
116             }
117              
118 5 50       16 if (ref $subject) {
119 0         0 die "The subject value must be a string containing a URL or 'mailto: address.'";
120             }
121              
122 5 50       23 unless ($subject =~ m/^mailto\:/) {
123 0         0 my $uri = URI->new($subject);
124 0 0       0 if (!$uri->host) {
125 0         0 die "VAPID subject is not a url or mailto: address";
126             }
127             }
128              
129 5         12 return $subject;
130             }
131              
132             sub validate_public_key {
133 5     5 1 11 my ($pub) = shift;
134              
135 5 50       14 if (!$pub) {
136 0         0 die "No public key passed to validate_public_key";
137             }
138              
139 5 50       14 if (ref $pub) {
140 0         0 die "Vapid public key is must be a URL safe Base 64 encoded string";
141             }
142              
143 5         19 $pub = decode_base64url($pub);
144              
145 5 50       69 if (length $pub != 65) {
146 0         0 die "VAPID public key should be 65 bytes long when decoded.";
147             }
148            
149 5         10 return $pub;
150             }
151              
152             sub validate_private_key {
153 5     5 1 9 my ($priv) = shift;
154              
155 5 50       17 if (!$priv) {
156 0         0 die "No private key passed to validate_private_key";
157             }
158              
159 5 50       12 if (ref $priv) {
160 0         0 die "VAPID private key is must be a URL safe Base 64 encoded string";
161             }
162              
163 5         13 $priv = decode_base64url($priv);
164            
165 5 50       50 if (length $priv != 32) {
166 0         0 die "VAPID private key should be 32 bytes long when decoded.";
167             }
168              
169 5         12 return $priv;
170             }
171              
172             sub validate_expiration {
173 3     3 1 7 my $expiration = shift;
174              
175 3 50 33     34 if (!$expiration || $expiration !~ m/^\d+$/) {
176 0         0 die "expiration value must be a number";
177             }
178              
179 3         12 my $max = generate_future_expiration_timestamp($MAX_DEFAULT_SECONDS);
180              
181 3 50       11 if ($expiration >= $max) {
182 0         0 die "expiration value is greater than maximum of 24 hours";
183             }
184            
185 3         9 return $expiration;
186             }
187              
188             sub validate_subscription {
189 11     11 1 1022 my ($subscription) = @_;
190              
191 11 100       23 if (!$subscription) {
192 1         8 die "No subscription passed to validate_subscription";
193             }
194              
195 10 100 66     37 if (!ref $subscription || ref $subscription ne 'HASH') {
196 1         6 die "Subscription must be a hash reference";
197             }
198              
199 9 100       21 if (!$subscription->{endpoint}) {
200 1         6 die "Subscription must have an endpoint";
201             }
202              
203 8         28 my $uri = URI->new($subscription->{endpoint});
204 8 50       557 if (!$uri->host) {
205 0         0 die "Subscription endpoint is not a valid URL";
206             }
207              
208 8 100       172 if (!$subscription->{keys}) {
209 1         7 die "Subscription must have keys";
210             }
211              
212 7 100       11 if (!$subscription->{keys}{p256dh}) {
213 1         6 die "Subscription must have a p256dh key";
214             }
215              
216 6 100       11 if (!$subscription->{keys}{auth}) {
217 1         7 die "Subscription must have an auth key";
218             }
219              
220 5         10 return $subscription;
221             }
222              
223             sub encrypt_payload {
224 3     3 1 37 my ($payload, $subscription) = @_;
225              
226 3 100       8 if (!defined $payload) {
227 1         7 die "No payload passed to encrypt_payload";
228             }
229              
230 2         9 validate_subscription($subscription);
231              
232 2         7 my $user_public_key = decode_base64url($subscription->{keys}{p256dh});
233 2         18 my $user_auth = decode_base64url($subscription->{keys}{auth});
234              
235 2         19 my $salt = random_bytes(16);
236              
237 2         116 my $local_key = Crypt::PK::ECC->new();
238 2         5537 $local_key->generate_key('prime256v1');
239 2         15 my $local_public_key = $local_key->export_key_raw('public');
240              
241 2         6 my $user_key = Crypt::PK::ECC->new();
242 2         5454 $user_key->import_key_raw($user_public_key, 'prime256v1');
243 2         5416 my $shared_secret = $local_key->shared_secret($user_key);
244              
245 2         5 my $auth_info = "Content-Encoding: auth\x00";
246 2         40 my $prk = hkdf($shared_secret, $user_auth, 'SHA256', 32, $auth_info);
247              
248 2         10 my $context = "P-256\x00"
249             . pack('n', length($user_public_key)) . $user_public_key
250             . pack('n', length($local_public_key)) . $local_public_key;
251              
252 2         5 my $cek_info = "Content-Encoding: aesgcm\x00" . $context;
253 2         19 my $content_encryption_key = hkdf($prk, $salt, 'SHA256', 16, $cek_info);
254              
255 2         26 my $nonce_info = "Content-Encoding: nonce\x00" . $context;
256 2         19 my $nonce = hkdf($prk, $salt, 'SHA256', 12, $nonce_info);
257              
258 2         3 my $padded_payload = pack('n', 0) . $payload;
259              
260 2         546 my ($ciphertext, $tag) = gcm_encrypt_authenticate(
261             'AES',
262             $content_encryption_key,
263             $nonce,
264             '',
265             $padded_payload
266             );
267              
268             return {
269 2         31 ciphertext => $ciphertext . $tag,
270             salt => $salt,
271             local_public_key => $local_public_key
272             };
273             }
274              
275             sub build_push_request {
276 2     2 1 2243 my (%args) = @_;
277              
278 2         3 my $subscription = $args{subscription};
279 2         5 my $payload = $args{payload};
280 2         2 my $vapid_public = $args{vapid_public};
281 2         2 my $vapid_private = $args{vapid_private};
282 2         4 my $subject = $args{subject};
283 2   100     8 my $ttl = $args{ttl} // 60;
284 2         3 my $expiration = $args{expiration};
285 2         4 my $enc = $args{enc};
286              
287 2         5 validate_subscription($subscription);
288              
289 2         3 my $endpoint = $subscription->{endpoint};
290 2         5 my $uri = URI->new($endpoint);
291 2         91 my $audience = $uri->scheme . '://' . $uri->host;
292              
293 2         100 my $vapid_headers = generate_vapid_header(
294             $audience,
295             $subject,
296             $vapid_public,
297             $vapid_private,
298             $expiration,
299             $enc
300             );
301              
302 2         21 my $req = HTTP::Request->new(POST => $endpoint);
303 2         389 $req->header(TTL => $ttl);
304 2         229 $req->header(Authorization => $vapid_headers->{Authorization});
305              
306 2 50       99 if ($vapid_headers->{'Crypto-Key'}) {
307 2         6 my $crypto_key = $vapid_headers->{'Crypto-Key'};
308              
309 2 100 66     27 if (defined $payload && length $payload) {
310 1         3 my $encrypted = encrypt_payload($payload, $subscription);
311            
312 1         5 $crypto_key .= ';dh=' . encode_base64url($encrypted->{local_public_key});
313 1         18 $req->header(Encryption => 'salt=' . encode_base64url($encrypted->{salt}));
314 1         64 $req->header('Content-Encoding' => 'aesgcm');
315 1         47 $req->header('Content-Type' => 'application/octet-stream');
316 1         37 $req->content($encrypted->{ciphertext});
317             }
318              
319 2         28 $req->header('Crypto-Key' => $crypto_key);
320             } else {
321 0 0 0     0 if (defined $payload && length $payload) {
322 0         0 my $encrypted = encrypt_payload($payload, $subscription);
323            
324 0         0 $req->header('Crypto-Key' => 'dh=' . encode_base64url($encrypted->{local_public_key}));
325 0         0 $req->header(Encryption => 'salt=' . encode_base64url($encrypted->{salt}));
326 0         0 $req->header('Content-Encoding' => 'aesgcm');
327 0         0 $req->header('Content-Type' => 'application/octet-stream');
328 0         0 $req->content($encrypted->{ciphertext});
329             }
330             }
331              
332 2   50     118 $req->header('Content-Length' => length($req->content // ''));
333              
334 2         143 return $req;
335             }
336              
337             sub send_push_notification {
338 0     0 1   my (%args) = @_;
339              
340 0   0       my $ua = $args{ua} // LWP::UserAgent->new(timeout => 30);
341              
342 0           my $req = build_push_request(%args);
343 0           my $resp = $ua->request($req);
344              
345             return {
346 0           success => $resp->is_success,
347             status => $resp->code,
348             message => $resp->message,
349             response => $resp
350             };
351             }
352              
353             1;
354              
355             __END__