File Coverage

blib/lib/HTTP/Request/Webpush.pm
Criterion Covered Total %
statement 116 126 92.0
branch 13 26 50.0
condition 2 8 25.0
subroutine 19 22 86.3
pod 9 9 100.0
total 159 191 83.2


line stmt bran cond sub pod time code
1             package HTTP::Request::Webpush;
2             #===========================================================================
3             # Copyright 2021 Erich Strelow
4             #
5             # Licensed under the Apache License, Version 2.0 (the "License");
6             # you may not use this file except in compliance with the License.
7             # You may obtain a copy of the License at
8             #
9             # http://www.apache.org/licenses/LICENSE-2.0
10             #
11             # Unless required by applicable law or agreed to in writing, software
12             # distributed under the License is distributed on an "AS IS" BASIS,
13             # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14             # See the License for the specific language governing permissions and
15             # limitations under the License.
16             #============================================================================
17 3     3   360884 use strict 'vars';
  3         37  
  3         109  
18 3     3   17 use warnings;
  3         6  
  3         129  
19            
20             our $VERSION='0.15';
21            
22 3     3   1522 use parent 'HTTP::Request';
  3         1057  
  3         20  
23            
24 3     3   53665 use JSON;
  3         18592  
  3         22  
25 3     3   2053 use Crypt::JWT qw(encode_jwt);
  3         61608  
  3         297  
26 3     3   35 use MIME::Base64 qw( encode_base64url decode_base64url);
  3         7  
  3         158  
27 3     3   18 use Crypt::PRNG qw(random_bytes);
  3         7  
  3         132  
28 3     3   18 use Crypt::AuthEnc::GCM 'gcm_encrypt_authenticate';
  3         6  
  3         127  
29 3     3   17 use Crypt::PK::ECC 'ecc_shared_secret';
  3         6  
  3         119  
30 3     3   25 use Digest::SHA 'hmac_sha256';
  3         8  
  3         152  
31 3     3   20 use Carp;
  3         13  
  3         179  
32 3     3   19 use URI;
  3         6  
  3         3650  
33            
34             #================================================================
35             # hkdf()
36             #
37             # Calculates a key derivation using HMAC
38             # This is a simplified version based on Mat Scales jscript code
39             # see https://developers.google.com/web/updates/2016/03/web-push-encryption
40             #
41             # Notes: all args are expected to be binary strings, as the result
42             #================================================================
43             sub _hkdf($$$$$) {
44 9     9   18 my $self=shift();
45 9         19 my $salt=shift();
46 9         16 my $ikm=shift();
47 9         13 my $info=shift();
48 9         19 my $len=shift();
49            
50 9         94 my $key=hmac_sha256($ikm,$salt);
51 9         76 my $infoHmac= hmac_sha256($info,chr(1),$key);
52            
53 9         35 return substr($infoHmac,0,$len);
54             }
55            
56            
57             sub subscription($$) {
58            
59 3     3 1 2436 my $self=shift();
60 3         9 my $subscription=shift();
61            
62 3         6 my $agent;
63            
64 3 50       18 if (ref $subscription eq 'HASH') {
65 3         7 $agent=$subscription;
66             } else {
67 0         0 eval {$agent=from_json($subscription); };
  0         0  
68             }
69            
70 3 50       13 croak "Can't process subscription object" unless ($agent);
71 3 50       11 croak "Subscription must include endpoint" unless (exists $agent->{endpoint});
72            
73 3         37 $self->uri($agent->{endpoint});
74 3         25436 $self->{subscription}=$agent;
75 3         26 return $agent;
76             }
77            
78             sub auth($@) {
79            
80 3     3 1 9 my $self=shift();
81            
82 3 50 0     15 if (scalar @_ == 2) {
    0          
83 3         10 $self->{'app-pub'}=shift();
84 3         10 $self->{'app-key'}=shift();
85             } elsif (scalar (@_) == 1 && ref $_ eq 'Crypt::PK::ECC') {
86 0         0 $self->{'app-pub'}=$_->export_key_raw('public');
87 0         0 $self->{'app-key'}=$_->export_key_raw('private');
88             }
89            
90 3         14 return 1;
91             }
92            
93             sub authbase64($$$) {
94            
95 3     3 1 10 my $self=shift();
96 3         21 my $pub=decode_base64url(shift());
97 3         60 my $key=decode_base64url(shift());
98 3         34 return $self->auth($pub,$key);
99             }
100            
101             sub reuseecc($$) {
102            
103 1     1 1 3166 my $self=shift();
104 1         17 return $self->{'ecc'}=shift();
105             }
106            
107             sub subject($$) {
108 0     0 1 0 my $self=shift();
109 0         0 return $self->{'subject'}=shift();
110             }
111            
112             sub encode($$) {
113            
114 3     3 1 9 my $self=shift();
115 3   50     25 my $enc= shift() || 'aes128gcm';
116            
117            
118             #This method is inherited from HTTP::Message, but here only aes128gcm applies
119 3 50       15 croak 'Only aes128gcm encoding available' unless ($enc eq 'aes128gcm');
120            
121             #Check prerequisites
122 3 50       21 croak 'Endpoint must be present for message encoding' unless ($self->url());
123 3 50       299 croak 'Authentication keys must be present for message encoding' unless ($self->{'app-key'});
124 3 50 33     31 croak 'UA auth params must be present for message encoding' unless ($self->{subscription}->{'keys'}->{'p256dh'} && $self->{subscription}->{'keys'}->{'auth'});
125 3 50       11 croak 'Message payload must be 3992 bytes or less' unless (length($self->content) <= 3992);
126            
127             #This is the JWT part
128 3         58 my $origin=URI->new($self->{subscription}->{endpoint});
129 3         299 $origin->path_query('');
130 3         189 my $data={
131             'aud' => "$origin",
132             'exp'=> time() + 86400
133             };
134 3 50       38 $data->{'sub'}=$self->{'subject'} if ($self->{'subject'});
135            
136 3         33 my $appk = Crypt::PK::ECC->new();
137 3         18110 $appk->import_key_raw($self->{'app-key'},'secp256r1');
138 3         66 my $token = encode_jwt(payload => $data, key => $appk , alg=>'ES256');
139 3         9860 $self->header( 'Authorization' => "WebPush $token" );
140            
141             #Now the encription step
142 3         453 my $salt=random_bytes(16);
143            
144 3         59 my $pk; #This will be the session key for encryption
145 3 100       20 if ($self->{'ecc'}) {
146 1         4 $pk=$self->{'ecc'};
147             } else {
148 2         16 $pk=Crypt::PK::ECC->new();
149 2         6206 $pk->generate_key('prime256v1');
150             }
151            
152 3         69 my $pub_signkey=$pk->export_key_raw('public');
153 3         23 my $sec_signkey=$pk->export_key_raw('private');
154            
155 3         27 my $sk=Crypt::PK::ECC->new(); #This will be the UA endpoint key, which we know from the subscription object
156            
157             #The p256dh key is given to us in X9.62 format. Crypt::PK::ECC should be able
158             #to read it as a "raw" format. But it's important to apply the base64url variant
159 3         375 my $ua_public=decode_base64url($self->{subscription}->{'keys'}->{'p256dh'});
160 3         8845 $sk->import_key_raw($ua_public, 'secp256r1');
161            
162 3         8955 my $ecdh_secret=$pk->shared_secret($sk);
163 3         63 my $auth_secret= decode_base64url($self->{subscription}->{'keys'}->{'auth'});
164            
165             #An earlier draft established this header string as Content-Encoding: auth
166 3         80 my $key_info='WebPush: info'.chr(0).$ua_public.$pub_signkey;
167 3         22 my $prk=$self->_hkdf($auth_secret, $ecdh_secret, $key_info,32);
168            
169             #Again, an earlier RFC8291 draft used a different cek_info and nonce_info
170 3         10 my $cek_info='Content-Encoding: aes128gcm'.chr(0);
171 3         9 my $nonce_info='Content-Encoding: nonce'.chr(0);
172            
173 3         9 my $cek=$self->_hkdf($salt,$prk,$cek_info,16);
174 3         15 my $nonce= $self->_hkdf($salt, $prk,$nonce_info,12);
175            
176             #Now we have all the ingredients, it's time for encryption
177 3         39 my ($body, $tag) = gcm_encrypt_authenticate('AES', $cek, $nonce, '', $self->content."\x02\x00");
178            
179             #Some final composition
180             #0x00001000" is the coding unit of AES-GCM, and x41 is the key length
181 3         1030 $body = $salt."\x00\x00\x10\x00\x41".$pub_signkey.$body.$tag;
182            
183 3         19 $self->content($body);
184 3         147 $self->remove_header('Content-Length', 'Content-MD5','Content-Encoding','Content-Type','Encryption','Crypto-Key');
185 3         291 $self->header('Crypto-Key' => "p256ecdsa=". encode_base64url($self->{'app-pub'}) );
186 3         365 $self->header('Content-Length' => length($body));
187 3         152 $self->header('Content-Type' => 'application/octet-stream');
188 3         213 $self->header('Content-Encoding' => 'aes128gcm');
189 3         288 return 1;
190             }
191            
192             sub decoded_content
193             {
194 0     0 1 0 my($self, %opt) = @_;
195            
196             #This is included so will always fail. decoded_content is inherited from HTTP::Message
197 0         0 croak 'No decoding available';
198            
199             }
200            
201             sub decode {
202 0     0 1 0 my($self, %opt) = @_;
203            
204             #This is included so will always fail. decode is inherited from HTTP::Message
205 0         0 croak 'No decoding available';
206             }
207            
208             sub new($%) {
209            
210 3     3 1 855 my ($class, %opts)=@_;
211            
212 3         25 my $self= HTTP::Request->new();
213 3         282 $self->method('POST');
214            
215 3         33 bless $self, $class;
216            
217 3         16 my @Options= ('auth','subscription','authbase64','reuseecc','subject','content');
218 3         9 for (@Options) {
219 18 50       42 &$_($self,$opts{$_}) if (exists $opts{$_});
220             }
221            
222 3         11 return $self;
223             }
224            
225            
226             1;
227            
228             =pod
229            
230             =encoding UTF-8
231            
232             =head1 NAME
233            
234             HTTP::Request::Webpush - HTTP Request for web push notifications
235            
236             =head1 VERSION
237            
238             version 0.15
239            
240             =head1 SYNOPSIS
241            
242             use HTTP::Request::Webpush;
243            
244             #This should be the application-wide VAPID key pair
245             #The APP_PUB part must be the same used by the user UA when requesting the subscription
246             use constant APP_PUB => 'BCAI...RA8';
247             use constant APP_KEY => 'M6x...UQTow';
248            
249             #This should be previously collected from an already subscribed user UA
250             my $subscription='{"endpoint":"https://foo/fooer","expirationTime":null,"keys":{"p256dh":"BCNS...","auth":"dZ..."}}';
251            
252             my $message=HTTP::Request::Webpush->new();
253             $message->auth(APP_PUB, APP_KEY);
254             $message->subscription($subscription);
255             $message->subject('mailto:bobsbeverage@some.com');
256             $message->content('Hello world');
257            
258             #Additional headers can be applied with inherited HTTP::Response methods
259             $message->header('TTL' => '90');
260            
261             #To send a single push message
262             $message->encode();
263             my $ua = LWP::UserAgent->new;
264             my $response = $ua->request($message);
265            
266             #To send a batch of messages using the same application's end encryption key
267             my $ecc = Crypt::PK::ECC->new();
268             $ecc->generate_key('prime256v1');
269            
270             for (@cleverly_stored_subscriptions) {
271             my $message=HTTP::Request::Webpush->new(reuseecc => $ecc);
272             $message->subscription($_);
273             $message->subject('mailto:bobsbeverage@some.com');
274             $message->content('Come taste our new pale ale brand');
275             $message->encode();
276             my $response = $ua->request($message);
277             }
278            
279             =head1 DESCRIPTION
280            
281             C produces an HTTP::Request for Application-side Webpush
282             notifications as described on L.
283             Such requests can then be submitted to the push message channel so they will
284             pop-up in the corresponding end user host.
285             In this scheme, an Application is a
286             server-side component that sends push notification to previously subscribed
287             browser worker(s). This class only covers the Application role. A lot must
288             be done on the browser side to setup a full working push notification system.
289            
290             In practical terms, this class is a glue for all the encryption steps involved
291             in setting up a RFC8291 message, along with the L VAPID scheme.
292            
293             =over 4
294            
295             =item C<$r=HTTP::Request::Webpush-Enew()>
296            
297             =item C<$r=HTTP::Request::Webpush-Enew(auth =E $my_key, subscription =E $my_subs, content='New lager batch arrived')>
298            
299             The following options can be supplied in the constructor: subscription, auth, reuseecc, subject, content.
300            
301             =item C<$r-Esubscription($hash_reference)>
302            
303             =item C<$r-Esubscription('{"endpoint":"https://foo/fooer","expirationTime":null,"keys":{"p256dh":"BCNS...","auth":"dZ..."}}');>
304            
305             This sets the subscription object related to this notification service. This should be the same object
306             returned inside the browser environment using the browser's Push API C method. The argument can
307             be either a JSON string or a previously setup hash reference. The HTTP::Request uri is taken verbatim from the endpoint
308             of the subscription object.
309            
310             =item C<$r-Eauth($pk) #pk being a Crypt::PK::ECC ref>
311            
312             =item C<$r-Eauth($pub_bin, $priv_bin)>
313            
314             =item C<$r-Eauthbase64('BCAI...jARA8','M6...Tow')>
315            
316             This sets the authentication key for the VAPID authentication scheme related to this push service.
317             This can either be a (public, private) pair or an already setup L object. The public part
318             must be the same used earlier in the browser environment in the C option.
319             The key pair can be passed as URL safe base64 strings using the C variant.
320            
321             =item C<$r-Ereuseecc($ecc) #ecc being a Crypt::PK::ECC ref>
322            
323             By default, HTTP::Request::Webpush creates a new P-256 key pair for the encryption
324             step each time. In large push batches this can be time consuming. You can
325             reuse the same previously setup key pair in repeated messages using this method.
326            
327             =item C<$r-Esubject('mailto:jdoe@some.com')>
328            
329             This establish the contact information related to the origin of the push service. This method
330             isn't enforced since RFC8292 mentions this as a SHOULD practice. But, if a valid contact information
331             is not included, the browser push service is likely to bounce the message. The URI passed is
332             used as the 'sub' claim in the authentication JWT.
333            
334             =item C<$r-Econtent('Try our new draft beer')>
335            
336             This sets the unencripted message content, or the payload in terms of RFC8291.
337             This is actually inherited from L,
338             as well as other methods that can be used to set the message content.
339            
340             =item C<$r-Eencode('aes128gcm')>
341            
342             This does the encryption process, as well as setting the headers expected by the push service.
343             aes128gcm is the only acceptable argument. Before calling this, the subscription and auth must
344             be supplied. B, otherwise the encryption
345             process won't happen.
346            
347             Please note that B and B are inherited from L.
348            
349             =back
350            
351             =head1 REMARKS
352            
353             No backward decryption is provided by this class, so the decoded_content() and decode() methods will fail.
354            
355             After you encode(), the content can still be accessed through HTTP::Message standard methods and it
356             will be the binary body of the encrypted message.
357            
358             This class sets the following headers: I, I, I, I, I.
359             Additional headers might be added using the C method. Please note that the browser push
360             service will likely bounce the message if I is missing. The standard also states that an I header might apply.
361            
362            
363             =head1 REFERENCES
364            
365             This class relies on L for the HKDF derivation,
366             L for the encryption itself, L for key management and
367             L for the salt.
368            
369             RFC8291 establish the encription steps: L
370            
371             RFC8292 establish the VAPID scheme: L
372            
373             RFC8030 covers the whole HTTP push life cycle L
374            
375             The following code samples and tutorials were very useful:
376            
377             L
378            
379             L
380            
381             L
382            
383             =head1 AUTHOR
384            
385             Erich Strelow
386            
387             =head1 COPYRIGHT AND LICENSE
388            
389             Copyright 2021 Erich Strelow
390            
391             Licensed under the Apache License, Version 2.0 (the "License");
392             you may not use this file except in compliance with the License.
393             You may obtain a copy of the License at
394            
395             L
396            
397             Unless required by applicable law or agreed to in writing, software
398             distributed under the License is distributed on an "AS IS" BASIS,
399             WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
400             See the License for the specific language governing permissions and
401             limitations under the License.
402            
403             =cut
404            
405             __END__