NAME

VAPID - Voluntary Application Server Identification

VERSION

Version 2.00

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
);

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 Messaging (or FCM). Instead, the application server can voluntarily identify itself to your web push provider.

EXPORT

generate_vapid_keys

Generates vapid private and public keys.

generate_vapid_header

Generates the Authorization and Crypto-Key headers that should be passed when making a request to push a notification.

generate_future_expiration_timestamp

Generates a time that is in future based upon the number of seconds if passed, the default is 12 hours.

validate_subject

Validate the subject.

validate_public_key

Validate the public key.

validate_private_key

Validate the private key.

validate_expiration

Validate the expiration key.

validate_subscription

Validate a push subscription object. Expects a hash reference with:

{
	endpoint => 'https://fcm.googleapis.com/...',
	keys => {
		p256dh => '...',
		auth => '...'
	}
}

encrypt_payload

Encrypt a message payload for web push, as RFC 8291 over the RFC 8188 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 inside the body, as an 86-octet record header, so there is nothing to lift into Encryption: and Crypto-Key: headers. Send it with 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.

This changed in 2.00. Before it, this function produced Content-Encoding: aesgcm - draft-ietf-webpush-encryption-04, which RFC 8291 replaced - and returned a hash of ciphertext, salt and 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.

salt and 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.

build_push_request

Build a complete HTTP::Request object for sending a push notification.

my $req = build_push_request(
	subscription => $subscription,
	payload => 'Hello World',
	vapid_public => $public,
	vapid_private => $private,
	subject => 'mailto:email@example.com',
	ttl => 60
);

The request carries Content-Encoding: aes128gcm and the RFC 8292 single-header Authorization: vapid t=..., k=... form.

This changed in 2.00. Before it the request was aesgcm, with the salt in an Encryption: header and the sender key in Crypto-Key:. Passing encoding => 'aesgcm' now dies rather than being ignored, because a caller asking for it has expectations about the body that this no longer meets.

send_push_notification

Send a push notification and return the result.

my $result = send_push_notification(
	subscription => $subscription,
	payload => 'Hello World',
	vapid_public => $public,
	vapid_private => $private,
	subject => 'mailto:email@example.com',
	ttl => 60
);

if ($result->{success}) {
	print "Notification sent!\n";
}

Example

The following is pseudo code but it should get you started.

STEP 1 - generate private and public keys

my ($public, $private) = generate_vapid_keys()

$c->stash({
	VAPID_USER_PUBLIC_KEY => $public
});

STEP 2 - main.js

	var publicKey = [% VAPID_USER_PUBLIC_KEY %];
        navigator.serviceWorker.getRegistrations().then(function (registrations) {
                navigator.serviceWorker.register('/service-worker.js').then(function (worker) {
                        console.log('Service Worker Registered');
			worker.pushManager.getSubscription().then(function(sub) {
				if (sub === null) {
				// Update UI to ask user to register for Push
					subscribeUser();
					console.log('Not subscribed to push service!');
				} else {
				// We have a subscription, update the database
					console.log('Subscription object: ', sub);
				}
			});
                });
        });

	function subscribeUser() {
		if ('serviceWorker' in navigator) {
			navigator.serviceWorker.ready.then(function(reg) {
				reg.pushManager.subscribe({
					userVisibleOnly: true,
					applicationServerKey: publicKey
				}).then(function(sub) {
				// We have a subscription, update the database
					console.log('Endpoint URL: ', sub.endpoint);
				}).catch(function(e) {
					if (Notification.permission === 'denied') {
						console.warn('Permission for notifications was denied');
					} else {
						console.error('Unable to subscribe to push', e);
					}
				});
			})
		}
	}

STEP 3 - service-worker.js

self.addEventListener('push', function(e) {
	var body;
	if (e.data) {
		body = e.data.text();
	} else {
		body = 'Push message no payload';
	}

	var options = {
		body: body,
		icon: 'images/notification-flat.png',
		vibrate: [100, 50, 100],
		data: {
			dateOfArrival: Date.now(),
			primaryKey: 1
		},
	};
	e.waitUntil(
		self.registration.showNotification('Push Notification', options)
	);
});

STEP 4 - manifest.json

Required for Chrome; Firefox works even without this file:

{
	"short_name" : "Push",
	"name" : "Push Dashboard",
	"icons" : [
		{
		"src" : "/icon-144x144.png",
		"type" : "image/png",
		"sizes" : "144x144"
		}
	],
	"display" : "standalone",
	"start_url" : "/",
	"background_color" : "#fff",
	"theme_color" : "#fff",
	"scope" : "/"
}	

STEP 5 - send the push notification

Using send_push_notification (recommended):

use VAPID qw/all/;

my $subscription = {
	endpoint => $subscription_url,
	keys => {
		p256dh => $user_p256dh_key,
		auth => $user_auth_key
	}
};

my $result = send_push_notification(
	subscription => $subscription,
	payload => 'Hello from VAPID!',
	vapid_public => $public,
	vapid_private => $private,
	subject => 'mailto:email@lnation.org',
	ttl => 60
);

if ($result->{success}) {
	print "Push message sent successfully.\n";
} else {
	print "Push message failed: ", $result->{message}, "\n";
}

STEP 5 (alternative) - build request manually

Using build_push_request for more control:

use VAPID qw/all/;
use LWP::UserAgent;

my $subscription = {
	endpoint => $subscription_url,
	keys => {
		p256dh => $user_p256dh_key,
		auth => $user_auth_key
	}
};

my $req = build_push_request(
	subscription => $subscription,
	payload => 'Hello from VAPID!',
	vapid_public => $public,
	vapid_private => $private,
	subject => 'mailto:email@lnation.org',
	ttl => 60
);

my $ua = LWP::UserAgent->new;
my $resp = $ua->request($req);

if ($resp->is_success) {
	print "Push message sent successfully.\n";
} else {
	print "Push message failed: ", $resp->as_string, "\n";
}

STEP 5 (legacy) - generate headers only

For backward compatibility or custom implementations:

my $notification_host = URI->new($subscription_url)->host;
my $auth_headers = generate_vapid_header(
	"https://$notification_host",
	'mailto:email@lnation.org',
	$public,
	$private,
	time + 60
);

# Then manually construct HTTP request with headers
# Note: This does not encrypt the payload

Curl from the command line (no payload):

curl "{SUBSCRIPTION_URL}" --request POST --header "TTL: 60" --header "Content-Length: 0" --header "Authorization: {AUTHORIZATION_HEADER}" --header "Crypto-Key: {CRYPTO_KEY_HEADER}"

AUTHOR

LNATION, <email at lnation.org>

BUGS

Please report any bugs or feature requests to bug-vapid at rt.cpan.org, or through the web interface at https://rt.cpan.org/NoAuth/ReportBug.html?Queue=VAPID. I will be notified, and then you'll automatically be notified of progress on your bug as I make changes.

SUPPORT

You can find documentation for this module with the perldoc command.

perldoc VAPID

You can also look for information at:

ACKNOWLEDGEMENTS

LICENSE AND COPYRIGHT

This software is Copyright (c) 2020 by LNATION.

This is free software, licensed under:

The Artistic License 2.0 (GPL Compatible)