VAPID
view release on metacpan or search on metacpan
lib/VAPID.pm view on Meta::CPAN
package VAPID;
use 5.006; use strict; use warnings; our $VERSION = '2.00';
use Crypt::JWT qw(encode_jwt); use Crypt::PK::ECC; use URI; use MIME::Base64 qw/encode_base64url decode_base64url/;
use Crypt::AuthEnc::GCM qw(gcm_encrypt_authenticate); use Crypt::KeyDerivation qw(hkdf); use Crypt::PRNG qw(random_bytes);
use HTTP::Request; use LWP::UserAgent; use JSON qw(decode_json);
use base 'Import::Export';
our (%EX, $DEFAULT_SECONDS, $MAX_DEFAULT_SECONDS);
BEGIN {
$DEFAULT_SECONDS = 12 * 60 * 60; # 12 hours
$MAX_DEFAULT_SECONDS = 24 * 60 * 60; # 24 hours
%EX = (
generate_vapid_keys => [qw/all generate/],
generate_future_expiration_timestamp => [qw/all generate/],
generate_vapid_header => [qw/all generate/],
validate_subject => [qw/all validate/],
validate_public_key => [qw/all validate/],
validate_private_key => [qw/all validate/],
validate_expiration => [qw/all validate/],
validate_subscription => [qw/all validate/],
encrypt_payload => [qw/all encrypt/],
build_push_request => [qw/all push/],
send_push_notification => [qw/all push/],
);
}
sub generate_vapid_keys {
my $curve = Crypt::PK::ECC->new();
$curve->generate_key('prime256v1');
my $priv = $curve->export_key_raw('private');
my $pub = $curve->export_key_raw('public');
if (length($priv) < 32) {
my $padding = 32 - length $priv;
$priv = ("\0" x $padding) . $priv;
}
if (length($pub) < 65) {
my $padding = 65 - length $pub;
$pub = ("\0" x $padding) . $pub;
}
return (
encode_base64url($pub),
encode_base64url($priv)
);
}
sub generate_vapid_header {
my ($aud, $subject, $pub, $priv, $expiration, $enc) = @_;
if (!$aud) {
die "No audience could be generated for VAPID.";
}
if (ref $aud) {
die "The audience value must be a string containing the origin of a push service";
}
my $aud_uri = URI->new($aud);
if (!$aud_uri->host) {
die "VAPID audience is not a url.";
}
validate_subject($subject);
validate_public_key($pub);
$priv = validate_private_key($priv);
if ($expiration) {
validate_expiration($expiration);
} else {
$expiration = generate_future_expiration_timestamp();
}
my $payload = {
aud => $aud,
exp => $expiration,
sub => $subject
};
my $key = Crypt::PK::ECC->new
->import_key_raw($priv, 'prime256v1')
->export_key_pem('private');
my $jwt_token = encode_jwt(
payload=>$payload,
extra_headers => { typ => 'JWT' },
alg=>'ES256',
key => \$key
);
return $enc
? {
Authorization => "vapid t=${jwt_token}, k=${pub}"
}
: {
Authorization => 'WebPush ' . $jwt_token,
'Crypto-Key' => 'p256ecdsa=' . $pub
};
}
sub generate_future_expiration_timestamp {
my ($add) = shift;
return time + ($add || $DEFAULT_SECONDS);
}
sub validate_subject {
my ($subject) = shift;
if (!$subject) {
die "No subject passed to validate_subject";
}
if (ref $subject) {
die "The subject value must be a string containing a URL or 'mailto: address.'";
}
unless ($subject =~ m/^mailto\:/) {
my $uri = URI->new($subject);
if (!$uri->host) {
die "VAPID subject is not a url or mailto: address";
}
}
return $subject;
}
sub validate_public_key {
my ($pub) = shift;
if (!$pub) {
die "No public key passed to validate_public_key";
}
if (ref $pub) {
die "Vapid public key is must be a URL safe Base 64 encoded string";
}
$pub = decode_base64url($pub);
if (length $pub != 65) {
die "VAPID public key should be 65 bytes long when decoded.";
}
return $pub;
}
sub validate_private_key {
my ($priv) = shift;
if (!$priv) {
die "No private key passed to validate_private_key";
}
if (ref $priv) {
die "VAPID private key is must be a URL safe Base 64 encoded string";
}
$priv = decode_base64url($priv);
if (length $priv != 32) {
die "VAPID private key should be 32 bytes long when decoded.";
}
return $priv;
}
sub validate_expiration {
my $expiration = shift;
if (!$expiration || $expiration !~ m/^\d+$/) {
die "expiration value must be a number";
}
my $max = generate_future_expiration_timestamp($MAX_DEFAULT_SECONDS);
if ($expiration >= $max) {
die "expiration value is greater than maximum of 24 hours";
}
return $expiration;
}
sub validate_subscription {
my ($subscription) = @_;
if (!$subscription) {
die "No subscription passed to validate_subscription";
}
if (!ref $subscription || ref $subscription ne 'HASH') {
die "Subscription must be a hash reference";
}
if (!$subscription->{endpoint}) {
die "Subscription must have an endpoint";
}
my $uri = URI->new($subscription->{endpoint});
if (!$uri->host) {
die "Subscription endpoint is not a valid URL";
}
if (!$subscription->{keys}) {
die "Subscription must have keys";
}
if (!$subscription->{keys}{p256dh}) {
die "Subscription must have a p256dh key";
}
if (!$subscription->{keys}{auth}) {
lib/VAPID.pm view on Meta::CPAN
$vapid_public,
$vapid_private,
$expiration,
1
);
my $req = HTTP::Request->new(POST => $endpoint);
$req->header(TTL => $ttl);
$req->header(Authorization => $vapid_headers->{Authorization});
if (defined $payload && length $payload) {
$req->header('Content-Encoding' => 'aes128gcm');
$req->header('Content-Type' => 'application/octet-stream');
$req->content(encrypt_payload($payload, $subscription));
}
$req->header('Content-Length' => length($req->content // ''));
return $req;
}
sub send_push_notification {
my (%args) = @_;
my $ua = $args{ua} // LWP::UserAgent->new(timeout => 30);
my $req = build_push_request(%args);
my $resp = $ua->request($req);
return {
success => $resp->is_success,
status => $resp->code,
message => $resp->message,
response => $resp
};
}
1;
__END__
=head1 NAME
VAPID - Voluntary Application Server Identification
=head1 VERSION
Version 2.00
=cut
=head1 SYNOPSIS
use VAPID qw/all/;
my ($public, $private) = generate_vapid_keys();
# Validate keys
validate_public_key($public);
validate_private_key($private);
# Send a push notification
my $subscription = {
endpoint => $endpoint_from_browser,
keys => {
p256dh => $p256dh_from_browser,
auth => $auth_from_browser
}
};
my $result = send_push_notification(
subscription => $subscription,
payload => 'Hello World!',
vapid_public => $public,
vapid_private => $private,
subject => 'mailto:email@lnation.org',
ttl => 60
);
if ($result->{success}) {
print "Notification sent!\n";
}
# Or build the request manually for more control
my $req = build_push_request(
subscription => $subscription,
payload => 'Hello World!',
vapid_public => $public,
vapid_private => $private,
subject => 'mailto:email@lnation.org'
);
# Or just generate headers (legacy)
my $auth_headers = generate_vapid_header(
'https://updates.push.services.mozilla.com',
'mailto:email@lnation.org',
$public,
$private,
time + 60
);
=head1 DESCRIPTION
VAPID, which stands for Voluntary Application Server Identity, is a new way to send and receive website push notifications. Your VAPID keys allow you to send web push campaigns without having to send them through a service like Firebase Cloud Messagi...
=head1 EXPORT
=head2 generate_vapid_keys
Generates vapid private and public keys.
=head2 generate_vapid_header
Generates the Authorization and Crypto-Key headers that should be passed when making a request to push a notification.
=head2 generate_future_expiration_timestamp
Generates a time that is in future based upon the number of seconds if passed, the default is 12 hours.
=head2 validate_subject
Validate the subject.
=head2 validate_public_key
Validate the public key.
=head2 validate_private_key
Validate the private key.
=head2 validate_expiration
Validate the expiration key.
=head2 validate_subscription
Validate a push subscription object. Expects a hash reference with:
{
endpoint => 'https://fcm.googleapis.com/...',
keys => {
p256dh => '...',
auth => '...'
}
}
=head2 encrypt_payload
Encrypt a message payload for web push, as RFC 8291 over the RFC 8188
C<aes128gcm> content encoding. This is what browsers implement.
my $body = encrypt_payload($message, $subscription);
The whole body is returned as one string. RFC 8188 carries the salt and the
sender's public key B<inside> the body, as an 86-octet record header, so there
is nothing to lift into C<Encryption:> and C<Crypto-Key:> headers. Send it
with C<Content-Encoding: aes128gcm>:
POST $subscription->{endpoint}
Authorization: vapid t=..., k=...
TTL: 60
Content-Encoding: aes128gcm
Content-Type: application/octet-stream
RFC 8291 guarantees a push service will accept only 4096 octets of encrypted
payload, and the encoding spends 86 bytes on the header, one on the padding
delimiter and 16 on the authentication tag before any of your message.
B<This changed in 2.00.> Before it, this function produced
C<Content-Encoding: aesgcm> - draft-ietf-webpush-encryption-04, which RFC 8291
replaced - and returned a hash of C<ciphertext>, C<salt> and
C<local_public_key>, because that draft carried the last two in HTTP headers.
It now returns a single string and the draft is gone. If you were reading
those three keys, you no longer need to: pass the string as the body.
C<salt> and C<local_key> may be passed to reproduce the worked example in RFC
8291 section 5, and exist for that. Do not pass them in production: the
ephemeral key must be fresh for every message, or the relationship between two
messages to the same subscription leaks.
=head2 build_push_request
Build a complete HTTP::Request object for sending a push notification.
my $req = build_push_request(
subscription => $subscription,
payload => 'Hello World',
( run in 3.588 seconds using v1.01-cache-2.11-cpan-8dfa8b56332 )