├── frontend
├── styles
│ ├── utils
│ │ └── u-hidden.css
│ ├── components
│ │ ├── c-error-msg.css
│ │ ├── c-button.css
│ │ ├── c-header.css
│ │ ├── c-snippet-code.css
│ │ ├── c-toggle.css
│ │ └── c-text-input.css
│ ├── html
│ │ ├── pre.css
│ │ ├── table.css
│ │ ├── code.css
│ │ └── body.css
│ ├── layouts
│ │ ├── l-main.css
│ │ ├── l-push-payload.css
│ │ └── l-enable.css
│ ├── variables
│ │ ├── _colors.css
│ │ └── _dimens.css
│ └── main.css
├── robots.txt
├── images
│ ├── logo-32x32.png
│ ├── logo-72x72.png
│ ├── badge-72x72.png
│ ├── logo-192x192.png
│ ├── logo-512x512.png
│ ├── maskable-logo-180.png
│ ├── plane.svg
│ └── logo.svg
├── scripts
│ ├── encryption
│ │ ├── hmac.js
│ │ ├── hkdf.js
│ │ ├── encryption-factory.js
│ │ ├── vapid-helper-2.js
│ │ ├── vapid-helper-1.js
│ │ ├── helpers.js
│ │ ├── encryption-aes-128-gcm.js
│ │ └── encryption-aes-gcm.js
│ ├── constants.js
│ ├── js-snippet-code.js
│ ├── push-client.js
│ └── app-controller.js
├── manifest.json
├── service-worker.js
└── index.html
├── .gitignore
├── .eslintignore
├── vercel.json
├── default-social.png
├── renovate.json
├── server.js
├── .eslintrc
├── test
├── .eslintrc
├── utils
│ └── dev-server.js
├── helpers
│ └── download-browsers.js
├── browser-tests
│ ├── hmac.js
│ ├── hkdf.js
│ ├── permissions.js
│ ├── encryption-factory.js
│ ├── vapid-1.js
│ ├── vapid-2.js
│ ├── index.html
│ ├── encryption-aes-128-gcm.js
│ └── encryption-aes-gcm.js
├── browser-tests.js
└── TODO
│ └── end-to-end.js
├── .github
└── workflows
│ └── build-and-test.yml
├── package.json
├── README.md
├── api
└── index.js
└── LICENSE
/frontend/styles/utils/u-hidden.css:
--------------------------------------------------------------------------------
1 | .u-hidden {
2 | display: none;
3 | }
4 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 | .tmp/
3 | test/output/
4 | npm-debug.log
5 | .vercel/
--------------------------------------------------------------------------------
/.eslintignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 | .tmp/
3 | build/
4 | test/output/
5 | npm-debug.log
6 |
--------------------------------------------------------------------------------
/vercel.json:
--------------------------------------------------------------------------------
1 | {
2 | "rewrites": [{ "source": "/api/(.*)", "destination": "/api" }]
3 | }
--------------------------------------------------------------------------------
/default-social.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gauntface/simple-push-demo/HEAD/default-social.png
--------------------------------------------------------------------------------
/frontend/robots.txt:
--------------------------------------------------------------------------------
1 | # www.robotstxt.org/
2 |
3 | # Allow crawling of all content
4 | User-agent: *
5 | Disallow:
6 |
--------------------------------------------------------------------------------
/frontend/images/logo-32x32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gauntface/simple-push-demo/HEAD/frontend/images/logo-32x32.png
--------------------------------------------------------------------------------
/frontend/images/logo-72x72.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gauntface/simple-push-demo/HEAD/frontend/images/logo-72x72.png
--------------------------------------------------------------------------------
/frontend/styles/components/c-error-msg.css:
--------------------------------------------------------------------------------
1 | .c-error-msg--title {
2 | font-size: 28px;
3 | margin-bottom: 4px;
4 | }
5 |
--------------------------------------------------------------------------------
/frontend/images/badge-72x72.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gauntface/simple-push-demo/HEAD/frontend/images/badge-72x72.png
--------------------------------------------------------------------------------
/frontend/images/logo-192x192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gauntface/simple-push-demo/HEAD/frontend/images/logo-192x192.png
--------------------------------------------------------------------------------
/frontend/images/logo-512x512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gauntface/simple-push-demo/HEAD/frontend/images/logo-512x512.png
--------------------------------------------------------------------------------
/frontend/images/maskable-logo-180.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gauntface/simple-push-demo/HEAD/frontend/images/maskable-logo-180.png
--------------------------------------------------------------------------------
/renovate.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json",
3 | "extends": [
4 | "local>gauntface/.github:renovate-config"
5 | ]
6 | }
--------------------------------------------------------------------------------
/frontend/styles/html/pre.css:
--------------------------------------------------------------------------------
1 | pre > code {
2 | display: block;
3 | width: 100%;
4 | padding: var(--m-padding);
5 | box-sizing: border-box;
6 | overflow: auto;
7 | }
--------------------------------------------------------------------------------
/frontend/styles/layouts/l-main.css:
--------------------------------------------------------------------------------
1 | .l-main {
2 | max-width: 1080px;
3 | width: 100%;
4 |
5 | padding: 32px;
6 | margin: 0 auto;
7 |
8 | box-sizing: border-box;
9 | }
10 |
--------------------------------------------------------------------------------
/frontend/styles/layouts/l-push-payload.css:
--------------------------------------------------------------------------------
1 | .l-push-payload {
2 | display: flex;
3 | flex-direction: column;
4 | gap: var(--m-padding);
5 | max-width: 320px;
6 | margin: 0 auto;
7 | text-align: center;
8 | }
--------------------------------------------------------------------------------
/frontend/styles/html/table.css:
--------------------------------------------------------------------------------
1 | th {
2 | background: var(--light-grey);
3 | text-align: center;
4 | }
5 |
6 | th, td {
7 | padding: var(--s-padding);
8 | }
9 |
10 | td {
11 | word-break: break-all;
12 | }
--------------------------------------------------------------------------------
/frontend/styles/layouts/l-enable.css:
--------------------------------------------------------------------------------
1 | .l-enable {
2 | display: flex;
3 | flex-direction: row;
4 | justify-content: center;
5 | align-items: center;
6 | margin: var(--l-padding);
7 | gap: var(--s-padding);
8 | }
--------------------------------------------------------------------------------
/frontend/styles/html/code.css:
--------------------------------------------------------------------------------
1 | code {
2 | padding: 0 2px;
3 |
4 | background-color: var(--light-grey);
5 | color: var(--black);
6 |
7 | font-family: Menlo, Consolas, Monaco, Liberation Mono, Lucida Console, monospace;
8 | }
--------------------------------------------------------------------------------
/server.js:
--------------------------------------------------------------------------------
1 | import app from './api/index.js';
2 |
3 | // This is a tiny wrapper so that Vercel can configure and run api/index.js
4 | // however it wants and we can run it locally on a specified port.
5 | app.listen(process.env.PORT);
6 |
--------------------------------------------------------------------------------
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["eslint:recommended"],
3 | "parserOptions": {
4 | "sourceType": "module"
5 | },
6 | "env": {
7 | "node": true,
8 | "es2021": true
9 | },
10 | "rules": {
11 | "indent": ["error", "tab"],
12 | "no-tabs": "off"
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/frontend/styles/variables/_colors.css:
--------------------------------------------------------------------------------
1 | :root {
2 | --white: #f9f9f8;
3 | --orange: #ffb629;
4 | --blue: #75c9e3;
5 | --mute: #a5c5c2;
6 | --green: #288990;
7 | --dark-blue: #45769e;
8 | --grey: #888888;
9 | --light-grey: #eeeeee;
10 | --black: #222222;
11 | }
--------------------------------------------------------------------------------
/test/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "env": {
3 | "node": true,
4 | "mocha": true
5 | },
6 | "rules": {
7 | "max-len": 0,
8 | "require-jsdoc": 0,
9 | "no-console": 0,
10 | "padded-blocks": 0,
11 | "max-nested-callbacks": 0,
12 | "no-invalid-this": 0,
13 | "no-unused-vars": 0
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/frontend/styles/variables/_dimens.css:
--------------------------------------------------------------------------------
1 | :root {
2 | --s-padding: 8px;
3 | --m-padding: 14px;
4 | --l-padding: 32px;
5 | --xl-padding: 48px;
6 |
7 | --checkbox-width: 48px;
8 | --checkbox-height: 24px;
9 | --checkbox-inner-gap: 4px;
10 | --checkbox-inner: calc(var(--checkbox-height) - calc(2 * var(--checkbox-inner-gap)));
11 | }
--------------------------------------------------------------------------------
/frontend/styles/components/c-button.css:
--------------------------------------------------------------------------------
1 | .c-button {
2 | background: var(--green);
3 | border: none;
4 | padding: var(--s-padding);
5 | border-radius: 3px;
6 | color: var(--white);
7 | cursor: pointer;
8 | }
9 |
10 | .c-button:disabled {
11 | background: var(--grey);
12 | }
13 |
14 | .c-button:active {
15 | background: var(--mute);
16 | }
--------------------------------------------------------------------------------
/frontend/styles/html/body.css:
--------------------------------------------------------------------------------
1 | html,
2 | body {
3 | width: 100%;
4 | height: 100%;
5 | margin: 0;
6 | padding: 0;
7 | }
8 |
9 | body {
10 | font-family: -apple-system, BlinkMacSystemFont, avenir next, avenir, segoe ui, helvetica neue, helvetica, Cantarell, Ubuntu, roboto, noto, arial, sans-serif;
11 | font-weight: 300;
12 | box-sizing: border-box;
13 | min-height: 100%;
14 | }
--------------------------------------------------------------------------------
/frontend/scripts/encryption/hmac.js:
--------------------------------------------------------------------------------
1 | /* eslint-env browser */
2 |
3 | 'use strict';
4 |
5 | export class HMAC {
6 | constructor(ikm) {
7 | this._ikm = ikm;
8 | }
9 |
10 | async sign(input) {
11 | const key = await crypto.subtle.importKey('raw', this._ikm,
12 | {name: 'HMAC', hash: 'SHA-256'}, false, ['sign']);
13 | return crypto.subtle.sign('HMAC', key, input);
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/test/utils/dev-server.js:
--------------------------------------------------------------------------------
1 | import path from 'path';
2 | import StaticServer from 'static-server';
3 |
4 | const server = new StaticServer({
5 | rootPath: path.resolve(),
6 | port: 9999,
7 | });
8 |
9 | export function startServer() {
10 | return new Promise((resolve) => {
11 | server.start(() => {
12 | console.log(`Using http://localhost:${server.port}`);
13 | resolve(`http://localhost:${server.port}`);
14 | });
15 | });
16 | }
17 |
18 | export function stopServer() {
19 | server.stop();
20 | }
21 |
--------------------------------------------------------------------------------
/.github/workflows/build-and-test.yml:
--------------------------------------------------------------------------------
1 | name: Build and Test
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 | pull_request:
8 |
9 | concurrency:
10 | group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
11 | cancel-in-progress: true
12 |
13 | jobs:
14 | build-and-test:
15 | runs-on: ubuntu-latest
16 | steps:
17 | - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4
18 | - name: Install Deps
19 | run: |
20 | npm install
21 | sudo apt-get install xvfb
22 | - name: Test
23 | run: xvfb-run --auto-servernum npm run test
24 |
--------------------------------------------------------------------------------
/frontend/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "gcm_sender_id": "653317226796",
3 | "name": "Simple Push Demo",
4 | "short_name": "Push Demo",
5 | "start_url": "./?utm_source=homescreen",
6 | "display": "standalone",
7 | "theme_color": "#288990",
8 | "background_color": "#f9f9f8",
9 | "icons": [
10 | {
11 | "src": "./images/logo-192x192.png",
12 | "sizes": "192x192",
13 | "type": "image/png"
14 | },
15 | {
16 | "src": "./images/logo-512x512.png",
17 | "sizes": "512x512",
18 | "type": "image/png"
19 | },
20 | {
21 | "src": "./images/logo.svg",
22 | "type": "image/svg+xml"
23 | }
24 | ]
25 | }
26 |
--------------------------------------------------------------------------------
/frontend/styles/components/c-header.css:
--------------------------------------------------------------------------------
1 | .c-header {
2 | display: flex;
3 | flex-direction: row;
4 | align-items: center;
5 | background: var(--green);
6 | gap: var(--s-padding);
7 | color: var(--white);
8 | }
9 |
10 | .c-header--title {
11 | display: inline-block;
12 | font-size: 1rem;
13 | flex: 1;
14 | padding: var(--m-padding) var(--l-padding);
15 | font-weight: normal;
16 | }
17 |
18 | .c-header--links {
19 | list-style: none;
20 | padding: 0;
21 | margin: 0;
22 | }
23 |
24 | .c-header--links a {
25 | display: inline-block;
26 | padding: var(--m-padding) var(--l-padding);
27 | text-decoration: none;
28 | color: var(--white);
29 | }
30 |
--------------------------------------------------------------------------------
/test/helpers/download-browsers.js:
--------------------------------------------------------------------------------
1 | import seleniumAssistant from 'selenium-assistant';
2 |
3 | async function run() {
4 | const promises = [
5 | seleniumAssistant.downloadLocalBrowser('firefox', 'stable'),
6 | seleniumAssistant.downloadLocalBrowser('firefox', 'beta'),
7 | seleniumAssistant.downloadLocalBrowser('firefox', 'unstable'),
8 | seleniumAssistant.downloadLocalBrowser('chrome', 'stable'),
9 | seleniumAssistant.downloadLocalBrowser('chrome', 'beta'),
10 | // seleniumAssistant.downloadLocalBrowser('chrome', 'unstable'),
11 | ];
12 |
13 | console.log('Starting to download browsers.');
14 | await Promise.all(promises);
15 | console.log('Download complete.');
16 | }
17 |
18 | run();
19 |
--------------------------------------------------------------------------------
/test/browser-tests/hmac.js:
--------------------------------------------------------------------------------
1 | /* eslint-env browser */
2 |
3 | import {uint8ArrayToBase64Url, base64UrlToUint8Array} from '/frontend/scripts/encryption/helpers.js';
4 | import {HMAC} from '/frontend/scripts/encryption/hmac.js';
5 |
6 | describe('HMAC', () => {
7 | it('should have a working HMAC implementation', async () => {
8 | const hmac = new HMAC(base64UrlToUint8Array('AAAAAAAAAAAAAAAAAAAAAA'));
9 | const prk = await hmac.sign(base64UrlToUint8Array('AAAAAAAAAAAAAAAAAAAAAA'));
10 |
11 | (prk instanceof ArrayBuffer).should.equal(true);
12 | const base64Prk = uint8ArrayToBase64Url(new Uint8Array(prk));
13 | base64Prk.should.equal('hTx0A5N9i2I5VpsYTreZP8X3Ua786ijyyGOFji0pxQs');
14 | });
15 | });
16 |
--------------------------------------------------------------------------------
/frontend/scripts/encryption/hkdf.js:
--------------------------------------------------------------------------------
1 | /* eslint-env browser */
2 |
3 | 'use strict';
4 |
5 | import {HMAC} from './hmac.js';
6 |
7 | export class HKDF {
8 | constructor(ikm, salt) {
9 | this._ikm = ikm;
10 | this._salt = salt;
11 |
12 | this._hmac = new HMAC(salt);
13 | }
14 |
15 | async generate(info, byteLength) {
16 | const fullInfoBuffer = new Uint8Array(info.byteLength + 1);
17 | fullInfoBuffer.set(info, 0);
18 | fullInfoBuffer.set(new Uint8Array(1).fill(1), info.byteLength);
19 |
20 | const prk = await this._hmac.sign(this._ikm);
21 | const nextHmac = new HMAC(prk);
22 | const nextPrk = await nextHmac.sign(fullInfoBuffer);
23 | return nextPrk.slice(0, byteLength);
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/frontend/scripts/constants.js:
--------------------------------------------------------------------------------
1 | /* eslint-env browser */
2 |
3 | export const GCM_API_KEY = 'AIzaSyBBh4ddPa96rQQNxqiq_qQj7sq1JdsNQUQ';
4 | export const APPLICATION_KEYS = {
5 | publicKey: 'BDd3_hVL9fZi9Ybo2UUzA284WG5FZR30_95YeZJsiA' +
6 | 'pwXKpNcF1rRPF3foIiBHXRdJI2Qhumhf6_LFTeZaNndIo',
7 | privateKey: 'xKZKYRNdFFn8iQIF2MH54KTfUHwH105zBdzMR7SI3xI',
8 | };
9 |
10 | // Hosting on vercel will have the API and frontend served from the
11 | // same origin, so '' is fine.
12 | // For local development the backend url param can be used.
13 | const urlParams = new URLSearchParams(window.location.search);
14 | const backendParam = urlParams.get('backend');
15 | export const BACKEND_ORIGIN = backendParam ? backendParam : '';
16 |
--------------------------------------------------------------------------------
/frontend/images/plane.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/frontend/styles/components/c-snippet-code.css:
--------------------------------------------------------------------------------
1 | .c-snippet-code {
2 | position: relative;
3 | }
4 |
5 | .c-snippet-code code:hover::before {
6 | display: inline-block;
7 | content: 'click to copy';
8 | color: rgba(0,0,0,.5);
9 | font-size: 13px;
10 | background-color: rgba(0,0,0,.1);
11 | border-top-left-radius: 5px;
12 | position: absolute;
13 | right: 0;
14 | bottom: 0;
15 | padding: 3px 10px;
16 | }
17 |
18 | .c-snippet-code code.copied::before {
19 | content: 'copied';
20 | color: rgba(255,255,255,.5);
21 | background-color: rgba(0,0,0,.6);
22 | }
23 |
24 | .c-snippet-code code.nosupport::before {
25 | content: "browser not supported :'(";
26 | color: rgba(255,255,255,.5);
27 | background-color: rgba(0,0,0,.6);
28 | }
--------------------------------------------------------------------------------
/frontend/styles/main.css:
--------------------------------------------------------------------------------
1 | /* TODO: Use a build process to optimize loading of styles */
2 | @import url("./variables/_colors.css");
3 | @import url("./variables/_dimens.css");
4 |
5 | @import url("./html/body.css");
6 | @import url("./html/pre.css");
7 | @import url("./html/code.css");
8 | @import url("./html/table.css");
9 |
10 | @import url("./components/c-header.css");
11 | @import url("./components/c-toggle.css");
12 | @import url("./components/c-button.css");
13 | @import url("./components/c-text-input.css");
14 | @import url("./components/c-snippet-code.css");
15 | @import url("./components/c-error-msg.css");
16 |
17 | @import url("./layouts/l-main.css");
18 | @import url("./layouts/l-enable.css");
19 | @import url("./layouts/l-push-payload.css");
20 |
21 | @import url("./utils/u-hidden.css");
22 |
--------------------------------------------------------------------------------
/test/browser-tests/hkdf.js:
--------------------------------------------------------------------------------
1 | /* eslint-env browser */
2 |
3 | import {uint8ArrayToBase64Url, base64UrlToUint8Array} from '/frontend/scripts/encryption/helpers.js';
4 | import {HKDF} from '/frontend/scripts/encryption/hkdf.js';
5 |
6 | describe('HKDF', function() {
7 | it('should have a working HKDF implementation', async () => {
8 | const hkdf = new HKDF(
9 | base64UrlToUint8Array('AAAAAAAAAAAAAAAAAAAAAA'),
10 | base64UrlToUint8Array('AAAAAAAAAAAAAAAAAAAAAA'),
11 | );
12 | const hkdfOutput = await hkdf.generate(base64UrlToUint8Array('AAAAAAAAAAAAAAAAAAAAAA'), 16);
13 |
14 | (hkdfOutput instanceof ArrayBuffer).should.equal(true);
15 | const base64HKDFOutput = uint8ArrayToBase64Url(new Uint8Array(hkdfOutput));
16 | base64HKDFOutput.should.equal('cS9spnQtVwB3AuvBt3wglw');
17 | });
18 | });
19 |
--------------------------------------------------------------------------------
/frontend/scripts/js-snippet-code.js:
--------------------------------------------------------------------------------
1 | /* eslint-env browser */
2 |
3 | const classNames = {
4 | COPIED: 'copied',
5 | NOT_SUPPORTED: 'nosupport',
6 | };
7 |
8 | const snippets = document.querySelectorAll('.js-snippet-code code');
9 | for (const s of snippets) {
10 | s.addEventListener('click', () => onMouseClickHandler(s));
11 | s.addEventListener('mouseout', () => onMouseOutHandler(s));
12 | }
13 |
14 | async function onMouseClickHandler(snippet) {
15 | const successful = await copyToClipboard(snippet);
16 | snippet.classList.add(successful ?
17 | classNames.COPIED :
18 | classNames.NOT_SUPPORTED);
19 | }
20 |
21 | function onMouseOutHandler(snippet) {
22 | snippet.classList.remove(classNames.COPIED);
23 | }
24 |
25 | async function copyToClipboard(snippet) {
26 | try {
27 | await window.navigator.clipboard.writeText(snippet.textContent);
28 | return true;
29 | } catch (err) {
30 | console.error('Failed to copy text to clipboard: ', err);
31 | return false;
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "type": "module",
3 | "devDependencies": {
4 | "ava": "6.1.2",
5 | "chai": "5.1.0",
6 | "eslint": "8.57.0",
7 | "eslint-config-google": "0.14.0",
8 | "mocha": "10.4.0",
9 | "puppeteer": "22.6.2",
10 | "static-server": "2.2.1"
11 | },
12 | "private": true,
13 | "scripts": {
14 | "ava": "npx ava ./test/*.js",
15 | "dev": "npm run dev-frontend | npm run dev-backend",
16 | "dev-frontend": "npx http-server ./frontend/ -a=localhost --port=8080 -o /?backend=http://localhost:8081",
17 | "dev-frontend-open": "npx http-server ./frontend/ -a=0.0.0.0 --port=8080 -o /?backend=http://localhost:8081",
18 | "dev-backend": "npx cross-env PORT=8081 ACCESS_CONTROL=http://localhost:8080 npx nodemon ./server.js",
19 | "dev-backend-open": "npx cross-env PORT=8081 ACCESS_CONTROL=* npx nodemon ./server.js",
20 | "lint": "eslint --fix '.'",
21 | "test": "npm run lint && npm run ava",
22 | "vercel": "npx vercel dev"
23 | },
24 | "dependencies": {
25 | "express": "4.20.0"
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/frontend/styles/components/c-toggle.css:
--------------------------------------------------------------------------------
1 | .c-toggle--checkbox {
2 | display: none;
3 | height: 0;
4 | width: 0;
5 | visibility: hidden;
6 | }
7 |
8 | .c-toggle--label {
9 | cursor: pointer;
10 | text-indent: -9999px;
11 | width: var(--checkbox-width);
12 | height: var(--checkbox-height);
13 | background: var(--mute);
14 | display: block;
15 | border-radius: 100px;
16 | position: relative;
17 | }
18 |
19 | .c-toggle--label:after {
20 | content: '';
21 | position: absolute;
22 | top: var(--checkbox-inner-gap);
23 | left: var(--checkbox-inner-gap);
24 | width: var(--checkbox-inner);
25 | height: var(--checkbox-inner);
26 | background: var(--white);
27 | border-radius: var(--checkbox-inner);
28 | transition: 0.3s;
29 | }
30 |
31 | .c-toggle--checkbox:disabled + .c-toggle--label:after {
32 | background: var(--grey);
33 | }
34 |
35 | .c-toggle--checkbox:disabled + .c-toggle--label {
36 | opacity: 30%;
37 | }
38 |
39 | .c-toggle--checkbox:checked + .c-toggle--label {
40 | background: var(--green);
41 | }
42 |
43 | .c-toggle--checkbox:checked + .c-toggle--label:after {
44 | left: calc(100% - var(--checkbox-inner-gap));
45 | transform: translateX(-100%);
46 | }
--------------------------------------------------------------------------------
/test/browser-tests/permissions.js:
--------------------------------------------------------------------------------
1 | /*
2 | Copyright 2016 Google Inc. All Rights Reserved.
3 |
4 | Licensed under the Apache License, Version 2.0 (the "License");
5 | you may not use this file except in compliance with the License.
6 | You may obtain a copy of the License at
7 |
8 | http://www.apache.org/licenses/LICENSE-2.0
9 |
10 | Unless required by applicable law or agreed to in writing, software
11 | distributed under the License is distributed on an "AS IS" BASIS,
12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | See the License for the specific language governing permissions and
14 | limitations under the License.
15 | */
16 |
17 | // This is a test and we want descriptions to be useful, if this
18 | // breaks the max-length, it's ok.
19 |
20 | /* eslint-disable max-len, no-unused-expressions */
21 | /* eslint-env browser, mocha */
22 |
23 | 'use strict';
24 |
25 | describe('Init Current Browser State', () => {
26 | it('should have Notification permission', function(done) {
27 | this.timeout(10000);
28 |
29 | Notification.requestPermission(() => {
30 | Notification.permission.should.equal('granted');
31 | done();
32 | });
33 | });
34 | });
35 |
--------------------------------------------------------------------------------
/frontend/scripts/encryption/encryption-factory.js:
--------------------------------------------------------------------------------
1 | /**
2 | * PLEASE NOTE: This is in no way complete. This is just enabling
3 | * some testing in the browser / on github pages.
4 | *
5 | * Massive H/T to Peter Beverloo for this.
6 | */
7 |
8 | import {EncryptionAESGCM} from './encryption-aes-gcm.js';
9 | import {EncryptionAES128GCM}
10 | from './encryption-aes-128-gcm.js';
11 |
12 | /* eslint-env browser */
13 |
14 | export class EncryptionFactory {
15 | static supportedEncodings() {
16 | if (PushManager.supportedContentEncodings) {
17 | return PushManager.supportedContentEncodings;
18 | }
19 | // All push providers are required to support aes128gcm.
20 | // https://w3c.github.io/push-api/#dom-pushmanager-supportedcontentencodings
21 | return ['aes128gcm'];
22 | }
23 | static generateHelper() {
24 | const encodings = this.supportedEncodings();
25 | for (const e of encodings) {
26 | switch (e) {
27 | case 'aes128gcm':
28 | return new EncryptionAES128GCM();
29 | case 'aesgcm':
30 | return new EncryptionAESGCM();
31 | default:
32 | console.warn(`Unknown content encoding: ${e}`);
33 | }
34 | }
35 |
36 | console.error(`Failed to find a known encoding: `, encodings);
37 | throw new Error('Unable to find a known encoding');
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/frontend/service-worker.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | /* eslint-env browser, serviceworker */
4 |
5 | self.addEventListener('install', () => {
6 | self.skipWaiting();
7 | });
8 |
9 | self.addEventListener('push', function(event) {
10 | console.log('Push message received.');
11 | let notificationTitle = 'Hello';
12 | const notificationOptions = {
13 | body: 'Thanks for sending this push msg.',
14 | icon: './images/logo-192x192.png',
15 | badge: './images/badge-72x72.png',
16 | data: {
17 | url: 'https://web.dev/push-notifications-overview/',
18 | },
19 | };
20 |
21 | if (event.data) {
22 | const dataText = event.data.text();
23 | notificationTitle = 'Received Payload';
24 | notificationOptions.body = `Push data: '${dataText}'`;
25 | }
26 |
27 | event.waitUntil(
28 | self.registration.showNotification(
29 | notificationTitle,
30 | notificationOptions,
31 | ),
32 | );
33 | });
34 |
35 | self.addEventListener('notificationclick', function(event) {
36 | console.log('Notification clicked.');
37 | event.notification.close();
38 |
39 | let clickResponsePromise = Promise.resolve();
40 | if (event.notification.data && event.notification.data.url) {
41 | clickResponsePromise = clients.openWindow(event.notification.data.url);
42 | }
43 |
44 | event.waitUntil(clickResponsePromise);
45 | });
46 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | 
2 |
3 | # Simple Push Demo
4 |
5 | The goal of this repo is to demonstrate how to implement push
6 | notifications into your web app.
7 |
8 | ## Relevant Docs Information
9 |
10 | - [Server Side Libraries to Help Send Push Messages ](https://github.com/web-push-libs/)
11 | - [Blog Post on Encrypting Payload Data](https://developers.google.com/web/updates/2016/03/web-push-encryption)
12 | - [Blog Post on VAPID](https://developers.google.com/web/updates/2016/07/web-push-interop-wins)
13 | - [Web Push Book](https://web-push-book.gauntface.com)
14 |
15 | ## Demo
16 |
17 | Visit [the demo here](https://simple-push-demo.vercel.app/).
18 |
19 | ## Development
20 |
21 | You can develop this project locally by running the following:
22 |
23 | ```shell
24 | npm install
25 | npm run dev
26 | ```
27 |
28 | ## Testing
29 |
30 | Tests can be run with `npm run test` which will run tests using puppeteer.
31 |
32 | If you want to view and run the browser tests in your own browser, which
33 | is useful for debugging, start a server in the root of this project and
34 | navigate to the `/test/browser-tests/index.html` page.
35 |
36 | ## Hosting
37 |
38 | This project is hosted on vercel and can be tested locally using the vercel
39 | CLI by running:
40 |
41 | ```shell
42 | npm run vercel
43 | ```
44 |
--------------------------------------------------------------------------------
/api/index.js:
--------------------------------------------------------------------------------
1 | import express from 'express';
2 | import https from 'https';
3 |
4 | const app = express();
5 |
6 | // Parse body as json when content-type: application/json
7 | app.use(express.json());
8 |
9 | // Set-up for CORs
10 | app.use(function(req, res, next) {
11 | res.setHeader('Access-Control-Allow-Origin', process.env['ACCESS_CONTROL']);
12 | res.setHeader('Access-Control-Allow-Methods',
13 | 'GET, POST, OPTIONS, PUT, PATCH, DELETE');
14 | res.setHeader('Access-Control-Allow-Headers', 'content-type');
15 | next();
16 | });
17 |
18 | app.post('/api/v3/sendpush', async function(request, res) {
19 | try {
20 | const requestData = request.body;
21 | const url = requestData.endpoint;
22 | const options = {
23 | headers: requestData.headers,
24 | method: 'POST',
25 | };
26 |
27 | const pushRequest = https.request(url, options, function(pushResponse) {
28 | let responseText = '';
29 |
30 | pushResponse.on('data', function(chunk) {
31 | responseText += chunk;
32 | });
33 |
34 | pushResponse.on('end', function() {
35 | res.status(pushResponse.statusCode);
36 | res.send(responseText);
37 | if (pushResponse.statusCode &&
38 | (pushResponse.statusCode < 200 || pushResponse.statusCode > 299)) {
39 | console.log(`Error: ${responseText}`);
40 | }
41 | });
42 | });
43 |
44 | pushRequest.on('error', function(e) {
45 | console.log(`Error: ${e}`);
46 | res.status(500);
47 | res.send(e);
48 | });
49 |
50 | if (requestData.body) {
51 | pushRequest.write(Buffer.from(requestData.body, 'base64'));
52 | }
53 |
54 | pushRequest.end();
55 | } catch (err) {
56 | console.error('Failed to process request', err);
57 | res.status(500);
58 | res.send('Failed to process request');
59 | }
60 | });
61 |
62 | export default app;
63 |
--------------------------------------------------------------------------------
/frontend/styles/components/c-text-input.css:
--------------------------------------------------------------------------------
1 | .c-text-input {
2 | display: block;
3 | position: relative;
4 | margin: auto;
5 | width: 100%;
6 | border-radius: 3px;
7 | overflow: hidden;
8 | }
9 |
10 | .c-text-input--label {
11 | position: absolute;
12 | top: 20px;
13 | left: 12px;
14 | font-size: 16px;
15 | color: rgba(0, 0, 0,.5);
16 | font-weight: 500;
17 | transform-origin: 0 0;
18 | transform: translate3d(0,0,0);
19 | transition: all .2s ease;
20 | pointer-events: none;
21 | }
22 |
23 | .c-text-input--bg {
24 | position: absolute;
25 | top: 0;
26 | left: 0;
27 | width: 100%;
28 | height: 100%;
29 | background: rgba(0, 0, 0,.05);
30 | z-index: -1;
31 | transform: scaleX(0);
32 | transform-origin: left;
33 | }
34 |
35 | .c-text-input input {
36 | appearance: none;
37 | width: 100%;
38 | border: 0;
39 | font-family: inherit;
40 | padding: 16px 12px 0 12px;
41 | box-sizing: border-box;
42 | height: 56px;
43 | font-size: 16px;
44 | font-weight: 400;
45 | background: rgba(0, 0, 0,.02);
46 | box-shadow: inset 0 -1px 0 rgba(0, 0, 0,.3);
47 | color: var(--black);
48 | transition: all .15s ease;
49 | }
50 |
51 | .c-text-input input:hover {
52 | background: rgba(0, 0, 0,.04);
53 | box-shadow: inset 0 -1px 0 rgba(0, 0, 0,.5);
54 | }
55 |
56 | .c-text-input input:not(:placeholder-shown) + .c-text-input--label {
57 | color: rgba(0, 0, 0, .5);
58 | transform: translate3d(0,-12px,0) scale(.75);
59 | }
60 |
61 | .c-text-input input:focus {
62 | background: rgba(0, 0, 0,.03);
63 | outline: none;
64 | box-shadow: inset 0 -2px 0 var(--green);
65 | }
66 |
67 | .c-text-input input:focus + .c-text-input--label {
68 | color: var(--green);
69 | transform: translate3d(0,-12px,0) scale(.75);
70 | }
71 |
72 | .c-text-input input:focus + .c-text-input--label + .c-text-input--bg {
73 | transform: scaleX(1);
74 | transition: all .1s ease;
75 | }
--------------------------------------------------------------------------------
/test/browser-tests/encryption-factory.js:
--------------------------------------------------------------------------------
1 | /* eslint-env browser */
2 |
3 | import {EncryptionFactory} from '/frontend/scripts/encryption/encryption-factory.js';
4 | import {EncryptionAESGCM} from '/frontend/scripts/encryption/encryption-aes-gcm.js';
5 | import {EncryptionAES128GCM} from '/frontend/scripts/encryption/encryption-aes-128-gcm.js';
6 | import * as chai from '/node_modules/chai/chai.js';
7 |
8 | describe('EncryptionFactory', function() {
9 | let initialContentEncoding;
10 |
11 | before(function() {
12 | initialContentEncoding = window.PushManager.supportedContentEncodings;
13 | });
14 |
15 | after(function() {
16 | window.PushManager.supportedContentEncodings = initialContentEncoding;
17 | });
18 |
19 | // Test no content encoding
20 | it('should default to aes128gcm if no content encoding', function() {
21 | delete window.PushManager.supportedContentEncodings;
22 | const helper = EncryptionFactory.generateHelper();
23 | (helper instanceof EncryptionAES128GCM).should.equal(true);
24 | });
25 |
26 | // Test with content encoding of just aesgcm
27 | it('should use aesgcm if first encoding', function() {
28 | window.PushManager.supportedContentEncodings = ['aesgcm', 'aes128gcm'];
29 | const helper = EncryptionFactory.generateHelper();
30 | (helper instanceof EncryptionAESGCM).should.equal(true);
31 | });
32 |
33 | // Test with content encoding with aes128gcm
34 | it('should use aes128gcm if first encoding', function() {
35 | window.PushManager.supportedContentEncodings = ['aes128gcm', 'aesgcm'];
36 |
37 | const helper = EncryptionFactory.generateHelper();
38 | (helper instanceof EncryptionAES128GCM).should.equal(true);
39 | });
40 |
41 | // Test with unknown encoding
42 | it('should throw for unknown encodings', function() {
43 | window.PushManager.supportedContentEncodings = ['unknown', 'other'];
44 | chai.expect(() => {
45 | EncryptionFactory.generateHelper();
46 | }).to.throw('Unable to find a known encoding');
47 | });
48 | });
49 |
--------------------------------------------------------------------------------
/frontend/images/logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
--------------------------------------------------------------------------------
/frontend/scripts/encryption/vapid-helper-2.js:
--------------------------------------------------------------------------------
1 | /**
2 | * PLEASE NOTE: This is in no way complete. This is just enabling
3 | * some testing in the browser / on github pages.
4 | *
5 | * Massive H/T to Peter Beverloo for this.
6 | */
7 |
8 | /* eslint-env browser */
9 |
10 | import {
11 | uint8ArrayToBase64Url,
12 | base64UrlToUint8Array} from './helpers.js';
13 |
14 | export class VapidHelper2 {
15 | static async createVapidAuthHeader(vapidKeys, audience, subject, exp) {
16 | if (!audience) {
17 | return Promise.reject(new Error('Audience must be the origin of the ' +
18 | 'server'));
19 | }
20 |
21 | if (!subject) {
22 | return Promise.reject(new Error('Subject must be either a mailto or ' +
23 | 'http link'));
24 | }
25 |
26 | if (typeof exp !== 'number') {
27 | // The `exp` field will contain the current timestamp in UTC plus
28 | // twelve hours.
29 | exp = Math.floor((Date.now() / 1000) + 12 * 60 * 60);
30 | }
31 |
32 | const publicApplicationServerKey = base64UrlToUint8Array(
33 | vapidKeys.publicKey);
34 | const privateApplicationServerKey = base64UrlToUint8Array(
35 | vapidKeys.privateKey);
36 |
37 | // Ensure the audience is just the origin
38 | audience = new URL(audience).origin;
39 |
40 | const tokenHeader = {
41 | typ: 'JWT',
42 | alg: 'ES256',
43 | };
44 |
45 | const tokenBody = {
46 | aud: audience,
47 | exp: exp,
48 | sub: subject,
49 | };
50 |
51 | // Utility function for UTF-8 encoding a string to an ArrayBuffer.
52 | const utf8Encoder = new TextEncoder('utf-8');
53 |
54 | // The unsigned token is the concatenation of the URL-safe base64 encoded
55 | // header and body.
56 | const unsignedToken =
57 | uint8ArrayToBase64Url(
58 | utf8Encoder.encode(JSON.stringify(tokenHeader)),
59 | ) + '.' + uint8ArrayToBase64Url(
60 | utf8Encoder.encode(JSON.stringify(tokenBody)),
61 | );
62 |
63 | // Sign the |unsignedToken| using ES256 (SHA-256 over ECDSA).
64 | const keyData = {
65 | kty: 'EC',
66 | crv: 'P-256',
67 | x: uint8ArrayToBase64Url(
68 | publicApplicationServerKey.subarray(1, 33)),
69 | y: uint8ArrayToBase64Url(
70 | publicApplicationServerKey.subarray(33, 65)),
71 | d: uint8ArrayToBase64Url(privateApplicationServerKey),
72 | };
73 |
74 | // Sign the |unsignedToken| with the server's private key to generate
75 | // the signature.
76 | const key = await crypto.subtle.importKey('jwk', keyData, {
77 | name: 'ECDSA', namedCurve: 'P-256',
78 | }, true, ['sign']);
79 |
80 | const signature = await crypto.subtle.sign({
81 | name: 'ECDSA',
82 | hash: {
83 | name: 'SHA-256',
84 | },
85 | }, key, utf8Encoder.encode(unsignedToken));
86 |
87 | const jsonWebToken = unsignedToken + '.' +
88 | uint8ArrayToBase64Url(new Uint8Array(signature));
89 | const p256ecdsa = uint8ArrayToBase64Url(publicApplicationServerKey);
90 |
91 | return {
92 | Authorization: `vapid t=${jsonWebToken}, k=${p256ecdsa}`,
93 | };
94 | }
95 | }
96 |
--------------------------------------------------------------------------------
/test/browser-tests/vapid-1.js:
--------------------------------------------------------------------------------
1 | /* eslint-env browser,mocha */
2 |
3 | 'use strict';
4 |
5 | import {uint8ArrayToBase64Url, cryptoKeysToUint8Array} from '/frontend/scripts/encryption/helpers.js';
6 | import {VapidHelper1} from '/frontend/scripts/encryption/vapid-helper-1.js';
7 |
8 | describe('VAPID 1', function() {
9 | const VALID_VAPID_KEYS = {
10 | publicKey: 'BG3OGHrl3YJ5PHpl0GSqtAAlUPnx1LvwQvFMIc68vhJU6nIkRzPEqtCduQz8wQj0r71NVPzr7ZRk2f-fhsQ5pK8',
11 | privateKey: 'Dt1CLgQlkiaA-tmCkATyKZeoF1-Gtw1-gdEP6pOCqj4',
12 | };
13 | const VALID_AUDIENCE = 'https://fcm.googleapis.com';
14 | const VALID_SUBJECT = 'mailto:simple-push-demo@gauntface.co.uk';
15 | const VALID_EXPIRATION = 1464326106;
16 | const VALID_OUTPUT = {
17 | expiration: VALID_EXPIRATION,
18 | unsignedToken: 'eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJhdWQiOiJodHRwczovL2ZjbS5nb29nbGVhcGlzLmNvbSIsImV4cCI6MTQ2NDMyNjEwNiwic3ViIjoibWFpbHRvOnNpbXBsZS1wdXNoLWRlbW9AZ2F1bnRmYWNlLmNvLnVrIn0',
19 | p256ecdsa: 'BG3OGHrl3YJ5PHpl0GSqtAAlUPnx1LvwQvFMIc68vhJU6nIkRzPEqtCduQz8wQj0r71NVPzr7ZRk2f-fhsQ5pK8',
20 | };
21 |
22 | const generateVapidKeys = async () => {
23 | const keys = await crypto.subtle.generateKey({name: 'ECDH', namedCurve: 'P-256'},
24 | true, ['deriveBits']);
25 |
26 | return cryptoKeysToUint8Array(keys.publicKey, keys.privateKey);
27 | };
28 |
29 | it('should be able to generate vapid keys', async () => {
30 | const keys = await generateVapidKeys();
31 |
32 | keys.should.not.equal('undefined');
33 | keys.should.have.property('publicKey');
34 | keys.should.have.property('privateKey');
35 | });
36 |
37 | it('should be able to generate VAPID authentication headers', async () => {
38 | const keys = await generateVapidKeys();
39 |
40 | const authHeaders = await VapidHelper1.createVapidAuthHeader(
41 | {
42 | publicKey: uint8ArrayToBase64Url(keys.publicKey),
43 | privateKey: uint8ArrayToBase64Url(keys.privateKey),
44 | },
45 | 'http://localhost',
46 | 'mailto:simple-push-demo@gauntface.co.uk');
47 |
48 | (authHeaders instanceof Object).should.equal(true);
49 | (typeof authHeaders['Authorization'] === 'string').should.equal(true);
50 | (typeof authHeaders['Crypto-Key'] === 'string').should.equal(true);
51 |
52 | (authHeaders['Authorization'].length).should.equal(254);
53 | (authHeaders['Crypto-Key'].length).should.equal(97);
54 | });
55 |
56 | it('should generate specific VAPID authentication headers', async () => {
57 | const authHeaders = await VapidHelper1.createVapidAuthHeader(
58 | VALID_VAPID_KEYS,
59 | VALID_AUDIENCE,
60 | VALID_SUBJECT,
61 | VALID_EXPIRATION,
62 | );
63 |
64 | (authHeaders instanceof Object).should.equal(true);
65 | (typeof authHeaders['Authorization'] === 'string').should.equal(true);
66 | (typeof authHeaders['Crypto-Key'] === 'string').should.equal(true);
67 |
68 | authHeaders['Authorization'].indexOf(`WebPush ${VALID_OUTPUT.unsignedToken}`).should.equal(0);
69 | authHeaders['Crypto-Key'].should.equal(`p256ecdsa=${VALID_OUTPUT.p256ecdsa}`);
70 | });
71 | });
72 |
--------------------------------------------------------------------------------
/test/browser-tests.js:
--------------------------------------------------------------------------------
1 | import test from 'ava';
2 | import puppeteer from 'puppeteer';
3 | import {startServer, stopServer} from './utils/dev-server.js';
4 |
5 | let addr;
6 | let browser;
7 |
8 | test.before(async (t) => {
9 | // Server for project
10 | addr = await startServer();
11 | });
12 | test.before(async (t) => {
13 | // Start browser
14 | browser = await puppeteer.launch({headless: false});
15 | const context = browser.defaultBrowserContext();
16 | context.overridePermissions(addr, ['notifications']);
17 | });
18 |
19 | test.after('cleanup', async (t) => {
20 | // This runs before all tests
21 | stopServer();
22 |
23 | await browser.close();
24 | });
25 |
26 | test.beforeEach(async (t) => {
27 | // Create new page for test
28 | t.context.page = await browser.newPage();
29 |
30 | // Ensure we get 200 responses from the server
31 | t.context.page.on('response', (response) => {
32 | const url = response.url();
33 | if (url.endsWith("/favicon.ico")) {
34 | // We don't care about favicons in tests
35 | return;
36 | }
37 |
38 | if (response) {
39 | if (response.status() !== 200) {
40 | console.error(`Non-200 response: (${response.status()}) ${url}`);
41 | }
42 | t.deepEqual(response.status(), 200);
43 | }
44 | });
45 | });
46 |
47 | test.afterEach(async (t) => {
48 | await t.context.page.close();
49 | });
50 |
51 | test('browser tests', async (t) => {
52 | const page = t.context.page;
53 |
54 | await page.goto(`${addr}/test/browser-tests/index.html`, {
55 | waitUntil: 'networkidle0',
56 | });
57 |
58 | await page.waitForFunction(() => {
59 | // eslint-disable-next-line
60 | return 'test-results' in window;
61 | });
62 |
63 | const results = await page.evaluate(() => {
64 | // eslint-disable-next-line
65 | return window['test-results'];
66 | });
67 | console.log(prettyPrintResults(results));
68 | t.deepEqual(results.failed, [], `There were ${results.failed.length} test failures`);
69 | });
70 |
71 | function prettyPrintResults(testResults) {
72 | let prettyResultsString = ``;
73 | testResults.passed.forEach((testResult) => {
74 | let testResultString = ``;
75 | switch (testResult.state) {
76 | case 'passed':
77 | testResultString += '✔️ [Passed] ';
78 | break;
79 | case 'failed':
80 | testResultString += '❌ [Failed] ';
81 | break;
82 | default:
83 | testResultString += '❓ [Unknown] ';
84 | break;
85 | }
86 |
87 | testResultString += `${testResult.parentTitle} > ` +
88 | `${testResult.title}\n`;
89 |
90 | if (testResult.state === 'failed') {
91 | const pad = ' ';
92 | const indentedStack = testResult.stack.split('\n').join(`\n${pad}`);
93 |
94 | testResultString += `\n${pad}${testResult.errMessage}\n\n`;
95 | testResultString += `${pad}[Stack Trace]\n`;
96 | testResultString += `${pad}${indentedStack}\n`;
97 | }
98 |
99 | prettyResultsString += testResultString + '\n';
100 | });
101 | return prettyResultsString;
102 | }
103 |
--------------------------------------------------------------------------------
/test/browser-tests/vapid-2.js:
--------------------------------------------------------------------------------
1 | /* eslint-env browser,mocha */
2 |
3 | 'use strict';
4 |
5 | import {uint8ArrayToBase64Url, cryptoKeysToUint8Array} from '/frontend/scripts/encryption/helpers.js';
6 | import {VapidHelper2} from '/frontend/scripts/encryption/vapid-helper-2.js';
7 |
8 | describe('VAPID 2', function() {
9 | const VALID_VAPID_KEYS = {
10 | publicKey: 'BG3OGHrl3YJ5PHpl0GSqtAAlUPnx1LvwQvFMIc68vhJU6nIkRzPEqtCduQz8wQj0r71NVPzr7ZRk2f-fhsQ5pK8',
11 | privateKey: 'Dt1CLgQlkiaA-tmCkATyKZeoF1-Gtw1-gdEP6pOCqj4',
12 | };
13 | const VALID_AUDIENCE = 'https://fcm.googleapis.com';
14 | const VALID_SUBJECT = 'mailto:simple-push-demo@gauntface.co.uk';
15 | const VALID_EXPIRATION = 1464326106;
16 | const VALID_OUTPUT = {
17 | expiration: VALID_EXPIRATION,
18 | unsignedToken: 'eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJhdWQiOiJodHRwczovL2ZjbS5nb29nbGVhcGlzLmNvbSIsImV4cCI6MTQ2NDMyNjEwNiwic3ViIjoibWFpbHRvOnNpbXBsZS1wdXNoLWRlbW9AZ2F1bnRmYWNlLmNvLnVrIn0',
19 | p256ecdsa: 'BG3OGHrl3YJ5PHpl0GSqtAAlUPnx1LvwQvFMIc68vhJU6nIkRzPEqtCduQz8wQj0r71NVPzr7ZRk2f-fhsQ5pK8',
20 | };
21 |
22 | const generateVapidKeys = async () => {
23 | const keys = await crypto.subtle.generateKey(
24 | {name: 'ECDH', namedCurve: 'P-256'},
25 | true, ['deriveBits'],
26 | );
27 |
28 | return cryptoKeysToUint8Array(keys.publicKey, keys.privateKey);
29 | };
30 |
31 | it('should be able to generate vapid keys', async () => {
32 | const keys = await generateVapidKeys();
33 | keys.should.not.equal('undefined');
34 | keys.should.have.property('publicKey');
35 | keys.should.have.property('privateKey');
36 | });
37 |
38 | it('should be able to generate VAPID authentication headers', async () => {
39 | const keys = await generateVapidKeys();
40 | const authHeaders = await VapidHelper2.createVapidAuthHeader(
41 | {
42 | publicKey: uint8ArrayToBase64Url(keys.publicKey),
43 | privateKey: uint8ArrayToBase64Url(keys.privateKey),
44 | },
45 | 'http://localhost',
46 | 'mailto:simple-push-demo@gauntface.co.uk');
47 |
48 | (authHeaders instanceof Object).should.equal(true);
49 | (typeof authHeaders['Authorization'] === 'string').should.equal(true);
50 | (typeof authHeaders['Crypto-Key'] === 'undefined').should.equal(true);
51 |
52 | const regex = /vapid t=(.*), k=(.*)/g;
53 | const matches = regex.exec(authHeaders['Authorization']);
54 | matches.length.should.equal(3);
55 |
56 | const jwt = matches[1];
57 | const publicKey = matches[2];
58 |
59 | (jwt.length).should.equal(246);
60 | (publicKey.length).should.equal(87);
61 | });
62 |
63 | it('should generate specific VAPID authentication headers', async () => {
64 | const authHeaders = await VapidHelper2.createVapidAuthHeader(
65 | VALID_VAPID_KEYS,
66 | VALID_AUDIENCE,
67 | VALID_SUBJECT,
68 | VALID_EXPIRATION,
69 | );
70 | (authHeaders instanceof Object).should.equal(true);
71 | (typeof authHeaders['Authorization'] === 'string').should.equal(true);
72 | (typeof authHeaders['Crypto-Key'] === 'undefined').should.equal(true);
73 |
74 | const regex = /vapid t=(.*), k=(.*)/g;
75 | const matches = regex.exec(authHeaders['Authorization']);
76 | matches.length.should.equal(3);
77 |
78 | const jwt = matches[1];
79 | const publicKey = matches[2];
80 |
81 | publicKey.should.equal(VALID_OUTPUT.p256ecdsa);
82 | jwt.indexOf(VALID_OUTPUT.unsignedToken).should.equal(0);
83 | });
84 | });
85 |
--------------------------------------------------------------------------------
/frontend/scripts/encryption/vapid-helper-1.js:
--------------------------------------------------------------------------------
1 | /**
2 | * PLEASE NOTE: This is in no way complete. This is just enabling
3 | * some testing in the browser / on github pages.
4 | *
5 | * Massive H/T to Peter Beverloo for this.
6 | */
7 |
8 | /**
9 | * The main difference between vapid-helper- 1 and 2 is
10 | * the headers returned.
11 | *
12 | * Helpers 2 is the latest spec and works in the latest
13 | * versions of Chrome and Firefox and should be safe to
14 | * rely on for now.
15 | *
16 | * There was no feature detect for which headers are supported
17 | * so previously this demo used the subscription endpoint and
18 | * switched for the fcm.googleapis.com origin.
19 | *
20 | * https://github.com/mozilla-services/autopush/issues/879
21 | */
22 |
23 | /* eslint-env browser */
24 |
25 | import {
26 | uint8ArrayToBase64Url,
27 | base64UrlToUint8Array} from './helpers.js';
28 |
29 | export class VapidHelper1 {
30 | static async createVapidAuthHeader(vapidKeys, audience, subject, exp) {
31 | if (!audience) {
32 | return Promise.reject(new Error('Audience must be the origin of the ' +
33 | 'server'));
34 | }
35 |
36 | if (!subject) {
37 | return Promise.reject(new Error('Subject must be either a mailto or ' +
38 | 'http link'));
39 | }
40 |
41 | if (typeof exp !== 'number') {
42 | // The `exp` field will contain the current timestamp in UTC plus
43 | // twelve hours.
44 | exp = Math.floor((Date.now() / 1000) + 12 * 60 * 60);
45 | }
46 |
47 | const publicApplicationServerKey = base64UrlToUint8Array(
48 | vapidKeys.publicKey);
49 | const privateApplicationServerKey = base64UrlToUint8Array(
50 | vapidKeys.privateKey);
51 |
52 | // Ensure the audience is just the origin
53 | audience = new URL(audience).origin;
54 |
55 | const tokenHeader = {
56 | typ: 'JWT',
57 | alg: 'ES256',
58 | };
59 |
60 | const tokenBody = {
61 | aud: audience,
62 | exp: exp,
63 | sub: subject,
64 | };
65 |
66 | // Utility function for UTF-8 encoding a string to an ArrayBuffer.
67 | const utf8Encoder = new TextEncoder('utf-8');
68 |
69 | // The unsigned token is the concatenation of the URL-safe base64 encoded
70 | // header and body.
71 | const unsignedToken =
72 | uint8ArrayToBase64Url(
73 | utf8Encoder.encode(JSON.stringify(tokenHeader)),
74 | ) + '.' + uint8ArrayToBase64Url(
75 | utf8Encoder.encode(JSON.stringify(tokenBody)),
76 | );
77 |
78 | // Sign the |unsignedToken| using ES256 (SHA-256 over ECDSA).
79 | const keyData = {
80 | kty: 'EC',
81 | crv: 'P-256',
82 | x: uint8ArrayToBase64Url(
83 | publicApplicationServerKey.subarray(1, 33)),
84 | y: uint8ArrayToBase64Url(
85 | publicApplicationServerKey.subarray(33, 65)),
86 | d: uint8ArrayToBase64Url(privateApplicationServerKey),
87 | };
88 |
89 | // Sign the |unsignedToken| with the server's private key to generate
90 | // the signature.
91 | const key = await crypto.subtle.importKey('jwk', keyData, {
92 | name: 'ECDSA', namedCurve: 'P-256',
93 | }, true, ['sign']);
94 | const signature = await crypto.subtle.sign({
95 | name: 'ECDSA',
96 | hash: {
97 | name: 'SHA-256',
98 | },
99 | }, key, utf8Encoder.encode(unsignedToken));
100 | const jsonWebToken = unsignedToken + '.' +
101 | uint8ArrayToBase64Url(new Uint8Array(signature));
102 | const p256ecdsa = uint8ArrayToBase64Url(publicApplicationServerKey);
103 |
104 | return {
105 | 'Authorization': `WebPush ${jsonWebToken}`,
106 | 'Crypto-Key': `p256ecdsa=${p256ecdsa}`,
107 | };
108 | }
109 | }
110 |
--------------------------------------------------------------------------------
/test/browser-tests/index.html:
--------------------------------------------------------------------------------
1 |
13 |
14 |
15 |
16 | Browser Tests
17 |
18 |
19 |
23 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
39 |
40 |
41 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
104 |
105 |
106 |
--------------------------------------------------------------------------------
/frontend/scripts/encryption/helpers.js:
--------------------------------------------------------------------------------
1 | /* eslint-env browser */
2 |
3 | export function uint8ArrayToBase64Url(uint8Array, start, end) {
4 | start = start || 0;
5 | end = end || uint8Array.byteLength;
6 |
7 | const base64 = window.btoa(
8 | String.fromCharCode.apply(null, uint8Array.subarray(start, end)));
9 | return base64
10 | .replace(/\=/g, '') // eslint-disable-line no-useless-escape
11 | .replace(/\+/g, '-')
12 | .replace(/\//g, '_');
13 | }
14 |
15 | // Converts the URL-safe base64 encoded |base64UrlData| to an Uint8Array buffer.
16 | export function base64UrlToUint8Array(base64UrlData) {
17 | const padding = '='.repeat((4 - base64UrlData.length % 4) % 4);
18 | const base64 = (base64UrlData + padding)
19 | .replace(/-/g, '+')
20 | .replace(/_/g, '/');
21 |
22 | const rawData = window.atob(base64);
23 | const buffer = new Uint8Array(rawData.length);
24 |
25 | for (let i = 0; i < rawData.length; ++i) {
26 | buffer[i] = rawData.charCodeAt(i);
27 | }
28 | return buffer;
29 | }
30 |
31 | // Super inefficient. But easier to follow than allocating the
32 | // array with the correct size and position values in that array
33 | // as required.
34 | export function joinUint8Arrays(allUint8Arrays) {
35 | return allUint8Arrays.reduce(function(cumulativeValue, nextValue) {
36 | if (!(nextValue instanceof Uint8Array)) {
37 | throw new Error('Received an non-Uint8Array value.');
38 | }
39 |
40 | const joinedArray = new Uint8Array(
41 | cumulativeValue.byteLength + nextValue.byteLength,
42 | );
43 | joinedArray.set(cumulativeValue, 0);
44 | joinedArray.set(nextValue, cumulativeValue.byteLength);
45 | return joinedArray;
46 | }, new Uint8Array());
47 | }
48 |
49 | export async function arrayBuffersToCryptoKeys(publicKey, privateKey) {
50 | // Length, in bytes, of a P-256 field element. Expected format of the private
51 | // key.
52 | const PRIVATE_KEY_BYTES = 32;
53 |
54 | // Length, in bytes, of a P-256 public key in uncompressed EC form per SEC
55 | // 2.3.3. This sequence must start with 0x04. Expected format of the
56 | // public key.
57 | const PUBLIC_KEY_BYTES = 65;
58 |
59 | if (publicKey.byteLength !== PUBLIC_KEY_BYTES) {
60 | throw new Error('The publicKey is expected to be ' +
61 | PUBLIC_KEY_BYTES + ' bytes.');
62 | }
63 |
64 | // Cast ArrayBuffer to Uint8Array
65 | const publicBuffer = new Uint8Array(publicKey);
66 | if (publicBuffer[0] !== 0x04) {
67 | throw new Error('The publicKey is expected to start with an ' +
68 | '0x04 byte.');
69 | }
70 |
71 | const jwk = {
72 | kty: 'EC',
73 | crv: 'P-256',
74 | x: uint8ArrayToBase64Url(publicBuffer, 1, 33),
75 | y: uint8ArrayToBase64Url(publicBuffer, 33, 65),
76 | ext: true,
77 | };
78 |
79 | const keyPromises = [];
80 | keyPromises.push(crypto.subtle.importKey('jwk', jwk,
81 | {name: 'ECDH', namedCurve: 'P-256'}, true, []));
82 |
83 | if (privateKey) {
84 | if (privateKey.byteLength !== PRIVATE_KEY_BYTES) {
85 | throw new Error('The privateKey is expected to be ' +
86 | PRIVATE_KEY_BYTES + ' bytes.');
87 | }
88 |
89 | // d must be defined after the importKey call for public
90 | jwk.d = uint8ArrayToBase64Url(privateKey);
91 | keyPromises.push(crypto.subtle.importKey('jwk', jwk,
92 | {name: 'ECDH', namedCurve: 'P-256'}, true, ['deriveBits']));
93 | }
94 |
95 | const keys = await Promise.all(keyPromises);
96 |
97 | const keyPair = {
98 | publicKey: keys[0],
99 | };
100 | if (keys.length > 1) {
101 | keyPair.privateKey = keys[1];
102 | }
103 | return keyPair;
104 | }
105 |
106 | export async function cryptoKeysToUint8Array(publicKey, privateKey) {
107 | const promises = [];
108 | const jwk = await crypto.subtle.exportKey('jwk', publicKey);
109 | const x = base64UrlToUint8Array(jwk.x);
110 | const y = base64UrlToUint8Array(jwk.y);
111 |
112 | const pubJwk = new Uint8Array(65);
113 | pubJwk.set([0x04], 0);
114 | pubJwk.set(x, 1);
115 | pubJwk.set(y, 33);
116 |
117 | promises.push(pubJwk);
118 |
119 | if (privateKey) {
120 | const jwk = await crypto.subtle.exportKey('jwk', privateKey);
121 | promises.push(
122 | base64UrlToUint8Array(jwk.d),
123 | );
124 | }
125 |
126 | const exportedKeys = await Promise.all(promises);
127 |
128 | const result = {
129 | publicKey: exportedKeys[0],
130 | };
131 |
132 | if (exportedKeys.length > 1) {
133 | result.privateKey = exportedKeys[1];
134 | }
135 |
136 | return result;
137 | }
138 |
139 | export function generateSalt() {
140 | const SALT_BYTES = 16;
141 | return crypto.getRandomValues(new Uint8Array(SALT_BYTES));
142 | }
143 |
144 |
--------------------------------------------------------------------------------
/frontend/scripts/push-client.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | /* eslint-env browser */
4 |
5 | import {base64UrlToUint8Array} from './encryption/helpers.js';
6 |
7 | export class PushClient {
8 | constructor(stateChangeCb, subscriptionUpdate, publicAppKey) {
9 | this._stateChangeCb = stateChangeCb;
10 | this._subscriptionUpdate = subscriptionUpdate;
11 |
12 | this._publicApplicationKey = base64UrlToUint8Array(publicAppKey);
13 |
14 | this._state = {
15 | UNSUPPORTED: {
16 | id: 'UNSUPPORTED',
17 | interactive: false,
18 | pushEnabled: false,
19 | },
20 | INITIALISING: {
21 | id: 'INITIALISING',
22 | interactive: false,
23 | pushEnabled: false,
24 | },
25 | PERMISSION_DENIED: {
26 | id: 'PERMISSION_DENIED',
27 | interactive: false,
28 | pushEnabled: false,
29 | },
30 | PERMISSION_GRANTED: {
31 | id: 'PERMISSION_GRANTED',
32 | interactive: true,
33 | },
34 | PERMISSION_PROMPT: {
35 | id: 'PERMISSION_PROMPT',
36 | interactive: true,
37 | pushEnabled: false,
38 | },
39 | ERROR: {
40 | id: 'ERROR',
41 | interactive: false,
42 | pushEnabled: false,
43 | },
44 | STARTING_SUBSCRIBE: {
45 | id: 'STARTING_SUBSCRIBE',
46 | interactive: false,
47 | pushEnabled: true,
48 | },
49 | SUBSCRIBED: {
50 | id: 'SUBSCRIBED',
51 | interactive: true,
52 | pushEnabled: true,
53 | },
54 | STARTING_UNSUBSCRIBE: {
55 | id: 'STARTING_UNSUBSCRIBE',
56 | interactive: false,
57 | pushEnabled: false,
58 | },
59 | UNSUBSCRIBED: {
60 | id: 'UNSUBSCRIBED',
61 | interactive: true,
62 | pushEnabled: false,
63 | },
64 | };
65 |
66 | if (!('serviceWorker' in navigator)) {
67 | this._stateChangeCb(this._state.UNSUPPORTED, 'Service worker not ' +
68 | 'available on this browser');
69 | return;
70 | }
71 |
72 | if (!('PushManager' in window)) {
73 | this._stateChangeCb(this._state.UNSUPPORTED, 'PushManager not ' +
74 | 'available on this browser');
75 | return;
76 | }
77 |
78 | if (!('showNotification' in ServiceWorkerRegistration.prototype)) {
79 | this._stateChangeCb(this._state.UNSUPPORTED, 'Showing Notifications ' +
80 | 'from a service worker is not available on this browser');
81 | return;
82 | }
83 |
84 | this.init();
85 | }
86 |
87 | async init() {
88 | await navigator.serviceWorker.ready;
89 | this._stateChangeCb(this._state.INITIALISING);
90 | this.setUpPushPermission();
91 | }
92 |
93 | _permissionStateChange(permissionState) {
94 | // If the notification permission is denied, it's a permanent block
95 | switch (permissionState) {
96 | case 'denied':
97 | this._stateChangeCb(this._state.PERMISSION_DENIED);
98 | break;
99 | case 'granted':
100 | this._stateChangeCb(this._state.PERMISSION_GRANTED);
101 | break;
102 | case 'default':
103 | this._stateChangeCb(this._state.PERMISSION_PROMPT);
104 | break;
105 | default:
106 | console.error('Unexpected permission state: ', permissionState);
107 | break;
108 | }
109 | }
110 |
111 | async setUpPushPermission() {
112 | try {
113 | this._permissionStateChange(Notification.permission);
114 |
115 | const reg = await navigator.serviceWorker.ready;
116 | // Let's see if we have a subscription already
117 | const subscription = await reg.pushManager.getSubscription();
118 | // Update the current state with the
119 | // subscriptionid and endpoint
120 | this._subscriptionUpdate(subscription);
121 | if (!subscription) {
122 | // NOOP since we have no subscription and the permission state
123 | // will inform whether to enable or disable the push UI
124 | return;
125 | }
126 |
127 | this._stateChangeCb(this._state.SUBSCRIBED);
128 | } catch (err) {
129 | console.error('setUpPushPermission() ', err);
130 | this._stateChangeCb(this._state.ERROR, err);
131 | }
132 | }
133 |
134 | async subscribeDevice() {
135 | this._stateChangeCb(this._state.STARTING_SUBSCRIBE);
136 |
137 | try {
138 | switch (Notification.permission) {
139 | case 'denied':
140 | throw new Error('Push messages are blocked.');
141 | case 'granted':
142 | break;
143 | default:
144 | await new Promise((resolve, reject) => {
145 | Notification.requestPermission((result) => {
146 | if (result !== 'granted') {
147 | reject(new Error('Bad permission result'));
148 | }
149 |
150 | resolve();
151 | });
152 | });
153 | }
154 |
155 | // We need the service worker registration to access the push manager
156 | try {
157 | const reg = await navigator.serviceWorker.ready;
158 | const subscription = await reg.pushManager.subscribe(
159 | {
160 | userVisibleOnly: true,
161 | applicationServerKey: this._publicApplicationKey,
162 | },
163 | );
164 | this._stateChangeCb(this._state.SUBSCRIBED);
165 | this._subscriptionUpdate(subscription);
166 | } catch (err) {
167 | this._stateChangeCb(this._state.ERROR, err);
168 | }
169 | } catch (err) {
170 | console.error('subscribeDevice() ', err);
171 | // Check for a permission prompt issue
172 | this._permissionStateChange(Notification.permission);
173 | }
174 | }
175 |
176 | async unsubscribeDevice() {
177 | // Disable the switch so it can't be changed while
178 | // we process permissions
179 | // window.PushDemo.ui.setPushSwitchDisabled(true);
180 |
181 | this._stateChangeCb(this._state.STARTING_UNSUBSCRIBE);
182 |
183 | try {
184 | const reg = await navigator.serviceWorker.ready;
185 | const subscription = await reg.pushManager.getSubscription();
186 |
187 | // Check we have everything we need to unsubscribe
188 | if (!subscription) {
189 | this._stateChangeCb(this._state.UNSUBSCRIBED);
190 | this._subscriptionUpdate(null);
191 | return;
192 | }
193 |
194 | // You should remove the device details from the server
195 | // i.e. the pushSubscription.endpoint
196 | const successful = await subscription.unsubscribe();
197 | if (!successful) {
198 | // The unsubscribe was unsuccessful, but we can
199 | // remove the subscriptionId from our server
200 | // and notifications will stop
201 | // This just may be in a bad state when the user returns
202 | console.warn('We were unable to unregister from push');
203 | }
204 |
205 | this._stateChangeCb(this._state.UNSUBSCRIBED);
206 | this._subscriptionUpdate(null);
207 | } catch (err) {
208 | console.error('Error thrown while revoking push notifications. ' +
209 | 'Most likely because push was never registered', err);
210 | }
211 | }
212 | }
213 |
--------------------------------------------------------------------------------
/frontend/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Simple Push Demo
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
28 |
29 |
30 |
31 |
32 |
33 | Enable push toggle
34 |
35 |
Enable push notifications
36 |
37 |
38 |
39 |
48 |
49 |
This Browsers Subscription
50 |
Below is your current subscription.
51 |
52 |
53 |
54 |
55 |
Supported Content Encodings
56 |
A change to the web push spec moves browsers from aesgcm
57 | to aes128gcm content encoding.
58 |
To determine which is supported in the current browser you can view
59 | PushManager.supportedContentEncodings.
60 |
61 |
Below is the list of supported encodings.
62 |
63 |
64 |
65 |
66 |
67 |
CURL Command
68 |
69 |
70 | Download the binary payload file.
71 | Run the terminal from the folder where the payload.bin
72 | file has been downloaded. Copy and paste the following CURL
73 | command into your terminal to send a push message to this browser.
74 |
75 |
76 |
77 |
78 |
79 |
80 |
Push from a Server
81 |
82 |
Some push services don't allow CORS. So you can't make a network
83 | request directly from the browser. That's why this demo uses a CORS
84 | proxy server under the hood to forward push message to push endpoint.
85 | To send a push message to this browser, you need to make a network
86 | request from your server with the following pieces of info (this
87 | is essentially a breakdown of the CURL command above):
88 |
89 |
Endpoint URL
90 |
91 |
92 |
93 |
Request Headers
94 |
95 |
96 |
97 |
Request Body
98 |
99 |
100 |
101 |
102 |
103 |
104 |
105 |
106 |
107 |
108 |
109 |
110 |
111 |
112 |
113 |
Browser Notes:
114 |
115 |
116 | On iOS and iPadOS, to request permission to receive push notifications, web apps must first be added to the Home Screen. The user can manage those permissions per web app in Notifications Settings.
117 | On Meta Quest, to request permission to receive push notifications, web apps must be packaged .
118 |
119 |
120 |
Further reading:
121 |
122 |
128 |
129 |
Tools/Samples:
130 |
131 |
139 |
140 |
141 |
142 |
143 |
144 |
145 |
146 |
147 |
--------------------------------------------------------------------------------
/frontend/scripts/encryption/encryption-aes-128-gcm.js:
--------------------------------------------------------------------------------
1 | /* eslint-env browser */
2 |
3 | import {
4 | uint8ArrayToBase64Url,
5 | base64UrlToUint8Array,
6 | joinUint8Arrays,
7 | arrayBuffersToCryptoKeys,
8 | cryptoKeysToUint8Array,
9 | generateSalt} from './helpers.js';
10 | import {HKDF} from './hkdf.js';
11 | import {APPLICATION_KEYS} from '../constants.js';
12 | import {VapidHelper2} from './vapid-helper-2.js';
13 |
14 | export class EncryptionAES128GCM {
15 | constructor(options = {}) {
16 | this._b64ServerKeys = options.serverKeys;
17 | this._b64Salt = options.salt;
18 | this._b4VapidKeys = options.vapidKeys;
19 | }
20 |
21 | getServerKeys() {
22 | if (this._b64ServerKeys) {
23 | return arrayBuffersToCryptoKeys(
24 | base64UrlToUint8Array(this._b64ServerKeys.publicKey),
25 | base64UrlToUint8Array(this._b64ServerKeys.privateKey),
26 | );
27 | }
28 |
29 | return EncryptionAES128GCM.generateServerKeys();
30 | }
31 |
32 | getSalt() {
33 | if (this._b64Salt) {
34 | return base64UrlToUint8Array(this._b64Salt);
35 | }
36 |
37 | return generateSalt();
38 | }
39 |
40 | getVapidKeys() {
41 | if (this._b4VapidKeys) {
42 | return this._b4VapidKeys;
43 | }
44 |
45 | return APPLICATION_KEYS;
46 | }
47 |
48 | async getRequestDetails(subscription, payloadText) {
49 | const vapidHelper = VapidHelper2;
50 |
51 | const endpoint = subscription.endpoint;
52 |
53 | const vapidHeaders = await vapidHelper.createVapidAuthHeader(
54 | this.getVapidKeys(),
55 | subscription.endpoint,
56 | 'mailto:simple-push-demo@gauntface.co.uk');
57 | const encryptedPayloadDetails = await this.encryptPayload(
58 | subscription, payloadText);
59 |
60 | let body = null;
61 | const headers = {};
62 | headers.TTL = 60;
63 |
64 | if (encryptedPayloadDetails) {
65 | body = encryptedPayloadDetails.cipherText;
66 | headers['Content-Encoding'] = 'aes128gcm';
67 | } else {
68 | headers['Content-Length'] = 0;
69 | }
70 |
71 | if (vapidHeaders) {
72 | Object.keys(vapidHeaders).forEach((headerName) => {
73 | headers[headerName] = vapidHeaders[headerName];
74 | });
75 | }
76 |
77 | const response = {
78 | headers: headers,
79 | endpoint,
80 | };
81 |
82 | if (body) {
83 | response.body = body;
84 | }
85 |
86 | return response;
87 | }
88 |
89 | async encryptPayload(subscription, payloadText) {
90 | if (!payloadText || payloadText.trim().length === 0) {
91 | return Promise.resolve(null);
92 | }
93 |
94 | const salt = this.getSalt();
95 |
96 | const serverKeys = await this.getServerKeys();
97 | const exportedServerKeys = await cryptoKeysToUint8Array(
98 | serverKeys.publicKey);
99 | const encryptionKeys = await this._generateEncryptionKeys(
100 | subscription, salt, serverKeys);
101 |
102 | const contentEncryptionCryptoKey = await crypto.subtle.importKey('raw',
103 | encryptionKeys.contentEncryptionKey, 'AES-GCM', true,
104 | ['decrypt', 'encrypt']);
105 | encryptionKeys.contentEncryptionCryptoKey = contentEncryptionCryptoKey;
106 |
107 | const utf8Encoder = new TextEncoder('utf-8');
108 | const payloadUint8Array = utf8Encoder.encode(payloadText);
109 |
110 | const paddingBytes = 0;
111 | const paddingUnit8Array = new Uint8Array(1 + paddingBytes);
112 | paddingUnit8Array.fill(0);
113 | paddingUnit8Array[0] = 0x02;
114 |
115 | const recordUint8Array = joinUint8Arrays([
116 | payloadUint8Array,
117 | paddingUnit8Array,
118 | ]);
119 |
120 | const algorithm = {
121 | name: 'AES-GCM',
122 | tagLength: 128,
123 | iv: encryptionKeys.nonce,
124 | };
125 |
126 | const encryptedPayloadArrayBuffer = await crypto.subtle.encrypt(
127 | algorithm, encryptionKeys.contentEncryptionCryptoKey,
128 | recordUint8Array,
129 | );
130 | const payloadWithHeaders = await this._addEncryptionContentCodingHeader(
131 | encryptedPayloadArrayBuffer,
132 | serverKeys,
133 | salt);
134 | return {
135 | cipherText: payloadWithHeaders,
136 | salt: uint8ArrayToBase64Url(salt),
137 | publicServerKey: uint8ArrayToBase64Url(
138 | exportedServerKeys.publicKey),
139 | };
140 | }
141 |
142 | static generateServerKeys() {
143 | // 'true' is to make the keys extractable
144 | return crypto.subtle.generateKey({name: 'ECDH', namedCurve: 'P-256'},
145 | true, ['deriveBits']);
146 | }
147 |
148 | async _addEncryptionContentCodingHeader(
149 | encryptedPayloadArrayBuffer, serverKeys, salt) {
150 | const keys = await cryptoKeysToUint8Array(serverKeys.publicKey);
151 | // Maximum record size.
152 | const recordSizeUint8Array = new Uint8Array([0x00, 0x00, 0x10, 0x00]);
153 |
154 | const serverPublicKeyLengthBuffer = new Uint8Array(1);
155 | serverPublicKeyLengthBuffer[0] = keys.publicKey.byteLength;
156 |
157 | const uint8arrays = [
158 | salt,
159 | // Record Size
160 | recordSizeUint8Array,
161 | // Service Public Key Length
162 | serverPublicKeyLengthBuffer,
163 | // Server Public Key
164 | keys.publicKey,
165 | new Uint8Array(encryptedPayloadArrayBuffer),
166 | ];
167 |
168 | const joinedUint8Array = joinUint8Arrays(uint8arrays);
169 | return joinedUint8Array.buffer;
170 | }
171 |
172 | async _generateEncryptionKeys(subscription, salt, serverKeys) {
173 | const infoResults = await Promise.all([
174 | this._generatePRK(subscription, serverKeys),
175 | this._generateCEKInfo(subscription, serverKeys),
176 | this._generateNonceInfo(subscription, serverKeys),
177 | ]);
178 |
179 | const prk = infoResults[0];
180 | const cekInfo = infoResults[1];
181 | const nonceInfo = infoResults[2];
182 |
183 | const cekHKDF = new HKDF(prk, salt);
184 | const nonceHKDF = new HKDF(prk, salt);
185 | const keyResults = await Promise.all([
186 | cekHKDF.generate(cekInfo, 16),
187 | nonceHKDF.generate(nonceInfo, 12),
188 | ]);
189 | return {
190 | contentEncryptionKey: keyResults[0],
191 | nonce: keyResults[1],
192 | };
193 | }
194 |
195 | _generateCEKInfo() {
196 | const utf8Encoder = new TextEncoder('utf-8');
197 | const contentEncoding8Array = utf8Encoder
198 | .encode('Content-Encoding: aes128gcm');
199 | const paddingUnit8Array = new Uint8Array(1).fill(0);
200 | return joinUint8Arrays([
201 | contentEncoding8Array,
202 | paddingUnit8Array,
203 | ]);
204 | }
205 |
206 | _generateNonceInfo() {
207 | const utf8Encoder = new TextEncoder('utf-8');
208 | const contentEncoding8Array = utf8Encoder
209 | .encode('Content-Encoding: nonce');
210 | const paddingUnit8Array = new Uint8Array(1).fill(0);
211 | return joinUint8Arrays([
212 | contentEncoding8Array,
213 | paddingUnit8Array,
214 | ]);
215 | }
216 |
217 | async _generatePRK(subscription, serverKeys) {
218 | const sharedSecret = await this._getSharedSecret(subscription, serverKeys);
219 |
220 | const keyInfoUint8Array = await this._getKeyInfo(subscription, serverKeys);
221 | const hkdf = new HKDF(
222 | sharedSecret,
223 | subscription.getKey('auth'),
224 | );
225 | return hkdf.generate(keyInfoUint8Array, 32);
226 | }
227 |
228 | async _getSharedSecret(subscription, serverKeys) {
229 | const keys = await arrayBuffersToCryptoKeys(
230 | subscription.getKey('p256dh'));
231 | if (!(keys.publicKey instanceof CryptoKey)) {
232 | throw new Error('The publicKey must be a CryptoKey.');
233 | }
234 |
235 | const algorithm = {
236 | name: 'ECDH',
237 | namedCurve: 'P-256',
238 | public: keys.publicKey,
239 | };
240 |
241 | return crypto.subtle.deriveBits(
242 | algorithm, serverKeys.privateKey, 256);
243 | }
244 |
245 | async _getKeyInfo(subscription, serverKeys) {
246 | const utf8Encoder = new TextEncoder('utf-8');
247 |
248 | const keyInfo = await cryptoKeysToUint8Array(serverKeys.publicKey);
249 | return joinUint8Arrays([
250 | utf8Encoder.encode('WebPush: info'),
251 | new Uint8Array(1).fill(0),
252 | new Uint8Array(subscription.getKey('p256dh')),
253 | keyInfo.publicKey,
254 | ]);
255 | }
256 | }
257 |
--------------------------------------------------------------------------------
/frontend/scripts/encryption/encryption-aes-gcm.js:
--------------------------------------------------------------------------------
1 | /* eslint-env browser */
2 |
3 | import {
4 | uint8ArrayToBase64Url,
5 | base64UrlToUint8Array,
6 | joinUint8Arrays,
7 | arrayBuffersToCryptoKeys,
8 | cryptoKeysToUint8Array,
9 | generateSalt} from './helpers.js';
10 | import {APPLICATION_KEYS} from '../constants.js';
11 | import {VapidHelper1} from './vapid-helper-1.js';
12 | import {HKDF} from './hkdf.js';
13 |
14 | export class EncryptionAESGCM {
15 | constructor(options = {}) {
16 | this._b64ServerKeys = options.serverKeys;
17 | this._b64Salt = options.salt;
18 | this._b4VapidKeys = options.vapidKeys;
19 | }
20 |
21 | getServerKeys() {
22 | if (this._b64ServerKeys) {
23 | return arrayBuffersToCryptoKeys(
24 | base64UrlToUint8Array(this._b64ServerKeys.publicKey),
25 | base64UrlToUint8Array(this._b64ServerKeys.privateKey),
26 | );
27 | }
28 |
29 | return EncryptionAESGCM.generateServerKeys();
30 | }
31 |
32 | getSalt() {
33 | if (this._b64Salt) {
34 | return base64UrlToUint8Array(this._b64Salt);
35 | }
36 |
37 | return generateSalt();
38 | }
39 |
40 | getVapidKeys() {
41 | if (this._b4VapidKeys) {
42 | return this._b4VapidKeys;
43 | }
44 |
45 | return APPLICATION_KEYS;
46 | }
47 |
48 | async getRequestDetails(subscription, payloadText) {
49 | const vapidHeaders = await VapidHelper1
50 | .createVapidAuthHeader(
51 | this.getVapidKeys(),
52 | subscription.endpoint,
53 | 'mailto:simple-push-demo@gauntface.co.uk');
54 | const encryptedPayloadDetails = await this.encryptPayload(
55 | subscription, payloadText);
56 |
57 | let body = null;
58 | const headers = {};
59 | headers.TTL = 60;
60 |
61 | if (encryptedPayloadDetails) {
62 | body = encryptedPayloadDetails.cipherText;
63 |
64 | headers.Encryption = `salt=${encryptedPayloadDetails.salt}`;
65 | headers['Crypto-Key'] =
66 | `dh=${encryptedPayloadDetails.publicServerKey}`;
67 | headers['Content-Encoding'] = 'aesgcm';
68 | } else {
69 | headers['Content-Length'] = 0;
70 | }
71 |
72 | if (vapidHeaders) {
73 | Object.keys(vapidHeaders).forEach((headerName) => {
74 | if (headers[headerName]) {
75 | headers[headerName] =
76 | `${headers[headerName]}; ${vapidHeaders[headerName]}`;
77 | } else {
78 | headers[headerName] = vapidHeaders[headerName];
79 | }
80 | });
81 | }
82 |
83 | const response = {
84 | headers: headers,
85 | endpoint: subscription.endpoint,
86 | };
87 |
88 | if (body) {
89 | response.body = body;
90 | }
91 |
92 | return response;
93 | }
94 |
95 | async encryptPayload(subscription, payloadText) {
96 | if (!payloadText || payloadText.trim().length === 0) {
97 | return Promise.resolve(null);
98 | }
99 |
100 | const salt = this.getSalt();
101 |
102 | const serverKeys = await this.getServerKeys();
103 |
104 | const exportedServerKeys = await cryptoKeysToUint8Array(
105 | serverKeys.publicKey);
106 | const encryptionKeys = await this._generateEncryptionKeys(
107 | subscription, salt, serverKeys);
108 | const contentEncryptionCryptoKey = await crypto.subtle.importKey('raw',
109 | encryptionKeys.contentEncryptionKey, 'AES-GCM', true,
110 | ['decrypt', 'encrypt']);
111 | encryptionKeys.contentEncryptionCryptoKey = contentEncryptionCryptoKey;
112 |
113 | const paddingBytes = 0;
114 | const paddingUnit8Array = new Uint8Array(2 + paddingBytes);
115 | const utf8Encoder = new TextEncoder('utf-8');
116 | const payloadUint8Array = utf8Encoder.encode(payloadText);
117 | const recordUint8Array = new Uint8Array(
118 | paddingUnit8Array.byteLength + payloadUint8Array.byteLength);
119 | recordUint8Array.set(paddingUnit8Array, 0);
120 | recordUint8Array.set(payloadUint8Array, paddingUnit8Array.byteLength);
121 |
122 | const algorithm = {
123 | name: 'AES-GCM',
124 | tagLength: 128,
125 | iv: encryptionKeys.nonce,
126 | };
127 |
128 | const encryptedPayloadArrayBuffer = await crypto.subtle.encrypt(
129 | algorithm, encryptionKeys.contentEncryptionCryptoKey,
130 | recordUint8Array,
131 | );
132 |
133 | return {
134 | cipherText: encryptedPayloadArrayBuffer,
135 | salt: uint8ArrayToBase64Url(salt),
136 | publicServerKey: uint8ArrayToBase64Url(
137 | exportedServerKeys.publicKey),
138 | };
139 | }
140 |
141 | static generateServerKeys() {
142 | // 'true' is to make the keys extractable
143 | return crypto.subtle.generateKey({name: 'ECDH', namedCurve: 'P-256'},
144 | true, ['deriveBits']);
145 | }
146 |
147 | async _generateEncryptionKeys(subscription, salt, serverKeys) {
148 | const results = await Promise.all([
149 | this._generatePRK(subscription, serverKeys),
150 | this._generateCEKInfo(subscription, serverKeys),
151 | this._generateNonceInfo(subscription, serverKeys),
152 | ]);
153 |
154 | const prk = results[0];
155 | const cekInfo = results[1];
156 | const nonceInfo = results[2];
157 |
158 | const cekHKDF = new HKDF(prk, salt);
159 | const nonceHKDF = new HKDF(prk, salt);
160 |
161 | const finalKeys = await Promise.all([
162 | cekHKDF.generate(cekInfo, 16),
163 | nonceHKDF.generate(nonceInfo, 12),
164 | ]);
165 |
166 | return {
167 | contentEncryptionKey: finalKeys[0],
168 | nonce: finalKeys[1],
169 | };
170 | }
171 |
172 | async _generateContext(subscription, serverKeys) {
173 | const cryptoKeys = await arrayBuffersToCryptoKeys(
174 | subscription.getKey('p256dh'));
175 | const keysAsCryptoKeys = {
176 | clientPublicKey: cryptoKeys.publicKey,
177 | serverPublicKey: serverKeys.publicKey,
178 | };
179 | const keysAsUint8 = await Promise.all([
180 | cryptoKeysToUint8Array(keysAsCryptoKeys.clientPublicKey),
181 | cryptoKeysToUint8Array(keysAsCryptoKeys.serverPublicKey),
182 | ]);
183 | const keys = {
184 | clientPublicKey: keysAsUint8[0].publicKey,
185 | serverPublicKey: keysAsUint8[1].publicKey,
186 | };
187 |
188 | const utf8Encoder = new TextEncoder('utf-8');
189 | const labelUnit8Array = utf8Encoder.encode('P-256');
190 | const paddingUnit8Array = new Uint8Array(1).fill(0);
191 |
192 | const clientPublicKeyLengthUnit8Array = new Uint8Array(2);
193 | clientPublicKeyLengthUnit8Array[0] = 0x00;
194 | clientPublicKeyLengthUnit8Array[1] = keys.clientPublicKey.byteLength;
195 |
196 | const serverPublicKeyLengthBuffer = new Uint8Array(2);
197 | serverPublicKeyLengthBuffer[0] = 0x00;
198 | serverPublicKeyLengthBuffer[1] = keys.serverPublicKey.byteLength;
199 |
200 | return joinUint8Arrays([
201 | labelUnit8Array,
202 | paddingUnit8Array,
203 | clientPublicKeyLengthUnit8Array,
204 | keys.clientPublicKey,
205 | serverPublicKeyLengthBuffer,
206 | keys.serverPublicKey,
207 | ]);
208 | }
209 |
210 | async _generateCEKInfo(subscription, serverKeys) {
211 | const utf8Encoder = new TextEncoder('utf-8');
212 | const contentEncoding8Array = utf8Encoder
213 | .encode('Content-Encoding: aesgcm');
214 | const paddingUnit8Array = new Uint8Array(1).fill(0);
215 | const contextBuffer = await this._generateContext(subscription, serverKeys);
216 | return joinUint8Arrays([
217 | contentEncoding8Array,
218 | paddingUnit8Array,
219 | contextBuffer,
220 | ]);
221 | }
222 |
223 | async _generateNonceInfo(subscription, serverKeys) {
224 | const utf8Encoder = new TextEncoder('utf-8');
225 | const contentEncoding8Array = utf8Encoder
226 | .encode('Content-Encoding: nonce');
227 | const paddingUnit8Array = new Uint8Array(1).fill(0);
228 | const contextBuffer = await this._generateContext(subscription, serverKeys);
229 | return joinUint8Arrays([
230 | contentEncoding8Array,
231 | paddingUnit8Array,
232 | contextBuffer,
233 | ]);
234 | }
235 |
236 | async _generatePRK(subscription, serverKeys) {
237 | const sharedSecret = await this._getSharedSecret(subscription, serverKeys);
238 | const utf8Encoder = new TextEncoder('utf-8');
239 | const authInfoUint8Array = utf8Encoder
240 | .encode('Content-Encoding: auth\0');
241 |
242 | const hkdf = new HKDF(
243 | sharedSecret,
244 | subscription.getKey('auth'));
245 | return hkdf.generate(authInfoUint8Array, 32);
246 | }
247 |
248 | async _getSharedSecret(subscription, serverKeys) {
249 | const keys = await arrayBuffersToCryptoKeys(
250 | subscription.getKey('p256dh'));
251 | if (!(keys.publicKey instanceof CryptoKey)) {
252 | throw new Error('The publicKey must be a CryptoKey.');
253 | }
254 |
255 | const algorithm = {
256 | name: 'ECDH',
257 | namedCurve: 'P-256',
258 | public: keys.publicKey,
259 | };
260 |
261 | return crypto.subtle.deriveBits(
262 | algorithm, serverKeys.privateKey, 256);
263 | }
264 | }
265 |
--------------------------------------------------------------------------------
/frontend/scripts/app-controller.js:
--------------------------------------------------------------------------------
1 | /* eslint-env browser */
2 |
3 | import {EncryptionFactory} from './encryption/encryption-factory.js';
4 | import {APPLICATION_KEYS, BACKEND_ORIGIN} from './constants.js';
5 | import {PushClient} from './push-client.js';
6 |
7 | class AppController {
8 | constructor() {
9 | this._encryptionHelper = EncryptionFactory.generateHelper();
10 | this._stateChangeListener = this._stateChangeListener.bind(this);
11 | this._subscriptionUpdate = this._subscriptionUpdate.bind(this);
12 |
13 | this._pushClient = new PushClient(
14 | this._stateChangeListener,
15 | this._subscriptionUpdate,
16 | APPLICATION_KEYS.publicKey,
17 | );
18 |
19 | // This div contains the UI for CURL commands to trigger a push
20 | this._sendPushOptions = getElement('.js-send-push-options');
21 | this._subscriptionJSONCode = getElement('.js-subscription-json');
22 | this._payloadContainer = getElement('.js-payload-textfield-container');
23 | this._infoPayload = getElement('.js-endpoint');
24 | this._infoHeadersTable = getElement('.js-headers-table');
25 | this._infoBodyTable = getElement('.js-request-body-table');
26 | this._curlElement = getElement('.js-curl-code');
27 | this._payloadDownload = getElement('.js-payload-download');
28 | this._payloadLink = getElement('.js-payload-link');
29 | this._errorContainer = getElement('.js-error-message-container');
30 | this._errorTitle = getElement('.js-error-title');
31 | this._errorMessage = getElement('.js-error-message');
32 |
33 | this._encodingElement = getElement('.js-supported-content-encodings');
34 | this.setupEncoding();
35 |
36 | this._payloadTextField = getElement('.js-payload-textfield');
37 | this._payloadTextField.oninput = () => this.updatePushInfo();
38 |
39 | this._toggleSwitch = getElement('.js-enable-checkbox');
40 | this._toggleSwitch.addEventListener('click', () => this.togglePush());
41 |
42 | this._sendPush = getElement('.js-send-push-button');
43 | this._sendPush.addEventListener('click', () => this.sendPushMessage());
44 | }
45 |
46 | setupEncoding() {
47 | const encodings = EncryptionFactory.supportedEncodings();
48 | this._encodingElement.textContent = JSON.stringify(encodings, null, 2);
49 | }
50 |
51 | togglePush() {
52 | if (this._toggleSwitch.checked) {
53 | this._pushClient.subscribeDevice();
54 | } else {
55 | this._pushClient.unsubscribeDevice();
56 | }
57 | }
58 |
59 | registerServiceWorker() {
60 | // Check that service workers are supported
61 | if ('serviceWorker' in navigator) {
62 | navigator.serviceWorker.register('./service-worker.js')
63 | .catch((err) => {
64 | console.error(err);
65 | this.showErrorMessage(
66 | 'Unable to Register SW',
67 | 'Sorry this demo requires a service worker to work and it ' +
68 | 'failed to install - sorry :(',
69 | );
70 | });
71 | } else {
72 | this.showErrorMessage(
73 | 'Service Worker Not Supported',
74 | 'Sorry this demo requires service worker support in your browser. ' +
75 | 'Please try this demo in Chrome or Firefox Nightly.',
76 | );
77 | }
78 | }
79 |
80 | _stateChangeListener(state, data) {
81 | if (typeof state.interactive !== 'undefined') {
82 | if (state.interactive) {
83 | this._toggleSwitch.disabled = false;
84 | } else {
85 | this._toggleSwitch.disabled = true;
86 | }
87 | }
88 |
89 | if (typeof state.pushEnabled !== 'undefined') {
90 | if (state.pushEnabled) {
91 | this._toggleSwitch.checked = true;
92 | } else {
93 | this._toggleSwitch.checked = false;
94 | }
95 | }
96 |
97 | switch (state.id) {
98 | case 'UNSUPPORTED':
99 | this.showErrorMessage(
100 | 'Push Not Supported',
101 | data,
102 | );
103 | break;
104 | case 'ERROR':
105 | this.showErrorMessage(
106 | 'Ooops a Problem Occurred',
107 | data,
108 | );
109 | break;
110 | default:
111 | break;
112 | }
113 | }
114 |
115 | _subscriptionUpdate(subscription) {
116 | this._currentSubscription = subscription;
117 | if (!subscription) {
118 | // Remove any subscription from your servers if you have
119 | // set it up.
120 | this._sendPushOptions.classList.add('u-hidden');
121 | return;
122 | }
123 |
124 | this._subscriptionJSONCode.textContent =
125 | JSON.stringify(subscription, null, 2);
126 |
127 | // This is too handle old versions of Firefox where keys would exist
128 | // but auth wouldn't
129 | const subscriptionObject = JSON.parse(JSON.stringify(subscription));
130 | if (
131 | subscriptionObject &&
132 | subscriptionObject.keys &&
133 | subscriptionObject.keys.auth &&
134 | subscriptionObject.keys.p256dh) {
135 | this._payloadContainer.classList.remove('u-hidden');
136 | } else {
137 | this._payloadContainer.classList.add('u-hidden');
138 | }
139 |
140 | this.updatePushInfo();
141 |
142 | // Display the UI
143 | this._sendPushOptions.classList.remove('u-hidden');
144 | }
145 |
146 | async updatePushInfo() {
147 | // Let's look at payload
148 | const pt = this._payloadTextField.value;
149 | const s = this._currentSubscription;
150 | const reqDetails = await this._encryptionHelper.getRequestDetails(s, pt);
151 |
152 | const curlCommandParts = [
153 | 'curl',
154 | `"${reqDetails.endpoint}"`,
155 | '--request POST',
156 | ];
157 |
158 | this._infoPayload.textContent = reqDetails.endpoint;
159 |
160 | this._infoHeadersTable.innerHTML = '';
161 |
162 | Object.keys(reqDetails.headers).forEach((header) => {
163 | const value = reqDetails.headers[header];
164 |
165 | const row = document.createElement('tr');
166 | row.innerHTML = `${header} ${value} `;
167 | this._infoHeadersTable.appendChild(row);
168 |
169 | curlCommandParts.push(`--header "${header}: ${value}"`);
170 | });
171 |
172 | const bodyDetails = {
173 | Type: 'No Body',
174 | Content: 'N/A',
175 | };
176 | if (reqDetails.body && reqDetails.body instanceof ArrayBuffer) {
177 | bodyDetails.Type =
178 | 'Encrypted binary (see hexadecimal representation below)';
179 | bodyDetails.Content = this.toHex(reqDetails.body);
180 |
181 | curlCommandParts.push('--data-binary @payload.bin');
182 |
183 | this._payloadDownload.classList.remove('u-hidden');
184 |
185 | const blob = new Blob([reqDetails.body]);
186 | this._payloadLink.href = URL.createObjectURL(blob);
187 | this._payloadLink.download = 'payload.bin';
188 | } else if (reqDetails.body) {
189 | bodyDetails.Type = 'String';
190 | bodyDetails.Content = reqDetails.body;
191 |
192 | curlCommandParts.push(`-d ${JSON.stringify(reqDetails.body)}`);
193 |
194 | this._payloadDownload.classList.add('u-hidden');
195 | } else {
196 | this._payloadDownload.classList.add('u-hidden');
197 | }
198 |
199 | this._infoBodyTable.innerHTML = '';
200 | for (const k of Object.keys(bodyDetails)) {
201 | const value = bodyDetails[k];
202 | const row = document.createElement('tr');
203 | row.innerHTML = `${k} ${value} `;
204 | this._infoBodyTable.appendChild(row);
205 | }
206 |
207 | this._curlElement.textContent = curlCommandParts.join(' \\' + '\n ');
208 | }
209 |
210 | getGCMInfo(subscription, payload, apiKey) {
211 | const headers = {};
212 |
213 | headers.Authorization = `key=${apiKey}`;
214 | headers['Content-Type'] = `application/json`;
215 |
216 | const endpointSections = subscription.endpoint.split('/');
217 | const subscriptionId = endpointSections[endpointSections.length - 1];
218 | const gcmAPIData = {
219 | to: subscriptionId,
220 | };
221 |
222 | if (payload) {
223 | gcmAPIData['raw_data'] = this.toBase64(payload.cipherText); // eslint-disable-line
224 | headers.Encryption = `salt=${payload.salt}`;
225 | headers['Crypto-Key'] = `dh=${payload.publicServerKey}`;
226 | headers['Content-Encoding'] = payload.contentEncoding;
227 | }
228 |
229 | return {
230 | headers: headers,
231 | body: JSON.stringify(gcmAPIData),
232 | endpoint: 'https://android.googleapis.com/gcm/send',
233 | };
234 | }
235 |
236 | async sendPushMessage() {
237 | if (!this._currentSubscription) {
238 | console.error('Cannot send push because there is no subscription.');
239 | return;
240 | }
241 |
242 | const pt = this._payloadTextField.value;
243 | const s = this._currentSubscription;
244 | const reqDetails = await this._encryptionHelper.getRequestDetails(s, pt);
245 | // Some push services don't allow CORS so have to forward
246 | // it to a different server to make the request which does support
247 | // CORs
248 | return this.sendRequestToProxyServer(reqDetails);
249 | }
250 |
251 | async sendRequestToProxyServer(requestInfo) {
252 | console.groupCollapsed('Sending push message via proxy server');
253 | console.log(requestInfo);
254 | console.groupEnd();
255 |
256 | const fopts = {
257 | method: 'post',
258 | headers: {
259 | 'Content-Type': 'application/json',
260 | },
261 | };
262 |
263 | // Can't send a stream like is needed for web push protocol,
264 | // so needs to convert it to base 64 here and the server will
265 | // convert back and pass as a stream
266 | if (requestInfo.body && requestInfo.body instanceof ArrayBuffer) {
267 | requestInfo.body = this.toBase64(requestInfo.body);
268 | fopts.body = requestInfo;
269 | }
270 |
271 | fopts.body = JSON.stringify(requestInfo);
272 |
273 | try {
274 | const response = await fetch(`${BACKEND_ORIGIN}/api/v3/sendpush`, fopts);
275 | if (response.status >= 400 && response.status < 500) {
276 | const text = await response.text();
277 | console.error('Failed web push response: ',
278 | response.status, response.statusText, text);
279 | throw new Error(
280 | `Failed to send push message via web push protocol: ` +
281 | `${encodeURI(text)} `);
282 | }
283 | } catch (err) {
284 | console.error(err);
285 | this.showErrorMessage(
286 | 'Ooops Unable to Send a Push',
287 | err,
288 | );
289 | }
290 | }
291 |
292 | toBase64(arrayBuffer, start, end) {
293 | start = start || 0;
294 | end = end || arrayBuffer.byteLength;
295 |
296 | const partialBuffer = new Uint8Array(arrayBuffer.slice(start, end));
297 | return window.btoa(String.fromCharCode.apply(null, partialBuffer));
298 | }
299 |
300 | toHex(arrayBuffer) {
301 | return [...new Uint8Array(arrayBuffer)]
302 | .map((x) => x.toString(16).padStart(2, '0'))
303 | .join(' ');
304 | }
305 |
306 | showErrorMessage(title, message) {
307 | this._errorTitle.textContent = title;
308 | this._errorMessage.innerHTML = message;
309 | this._errorContainer.classList.remove('u-hidden');
310 | this._sendPushOptions.classList.add('u-hidden');
311 | }
312 | }
313 |
314 | // This is a helper method so we get an error and log in case we delete or
315 | // rename an element we expect to be in the DOM.
316 | function getElement(selector) {
317 | const e = document.querySelector(selector);
318 | if (!e) {
319 | console.error(`Failed to find element: '${selector}'`);
320 | throw new Error(`Failed to find element: '${selector}'`);
321 | }
322 | return e;
323 | }
324 |
325 | if (window) {
326 | window.onload = function() {
327 | if (!navigator.serviceWorker) {
328 | console.warn('Service workers are not supported in this browser.');
329 | return;
330 | }
331 |
332 | if (!('PushManager' in window)) {
333 | console.warn('Push is not supported in this browser.');
334 | return;
335 | }
336 |
337 | console.debug('Setting up demo.');
338 | const appController = new AppController();
339 | appController.registerServiceWorker();
340 | };
341 | }
342 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Apache License
2 | Version 2.0, January 2004
3 | http://www.apache.org/licenses/
4 |
5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6 |
7 | 1. Definitions.
8 |
9 | "License" shall mean the terms and conditions for use, reproduction,
10 | and distribution as defined by Sections 1 through 9 of this document.
11 |
12 | "Licensor" shall mean the copyright owner or entity authorized by
13 | the copyright owner that is granting the License.
14 |
15 | "Legal Entity" shall mean the union of the acting entity and all
16 | other entities that control, are controlled by, or are under common
17 | control with that entity. For the purposes of this definition,
18 | "control" means (i) the power, direct or indirect, to cause the
19 | direction or management of such entity, whether by contract or
20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
21 | outstanding shares, or (iii) beneficial ownership of such entity.
22 |
23 | "You" (or "Your") shall mean an individual or Legal Entity
24 | exercising permissions granted by this License.
25 |
26 | "Source" form shall mean the preferred form for making modifications,
27 | including but not limited to software source code, documentation
28 | source, and configuration files.
29 |
30 | "Object" form shall mean any form resulting from mechanical
31 | transformation or translation of a Source form, including but
32 | not limited to compiled object code, generated documentation,
33 | and conversions to other media types.
34 |
35 | "Work" shall mean the work of authorship, whether in Source or
36 | Object form, made available under the License, as indicated by a
37 | copyright notice that is included in or attached to the work
38 | (an example is provided in the Appendix below).
39 |
40 | "Derivative Works" shall mean any work, whether in Source or Object
41 | form, that is based on (or derived from) the Work and for which the
42 | editorial revisions, annotations, elaborations, or other modifications
43 | represent, as a whole, an original work of authorship. For the purposes
44 | of this License, Derivative Works shall not include works that remain
45 | separable from, or merely link (or bind by name) to the interfaces of,
46 | the Work and Derivative Works thereof.
47 |
48 | "Contribution" shall mean any work of authorship, including
49 | the original version of the Work and any modifications or additions
50 | to that Work or Derivative Works thereof, that is intentionally
51 | submitted to Licensor for inclusion in the Work by the copyright owner
52 | or by an individual or Legal Entity authorized to submit on behalf of
53 | the copyright owner. For the purposes of this definition, "submitted"
54 | means any form of electronic, verbal, or written communication sent
55 | to the Licensor or its representatives, including but not limited to
56 | communication on electronic mailing lists, source code control systems,
57 | and issue tracking systems that are managed by, or on behalf of, the
58 | Licensor for the purpose of discussing and improving the Work, but
59 | excluding communication that is conspicuously marked or otherwise
60 | designated in writing by the copyright owner as "Not a Contribution."
61 |
62 | "Contributor" shall mean Licensor and any individual or Legal Entity
63 | on behalf of whom a Contribution has been received by Licensor and
64 | subsequently incorporated within the Work.
65 |
66 | 2. Grant of Copyright License. Subject to the terms and conditions of
67 | this License, each Contributor hereby grants to You a perpetual,
68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69 | copyright license to reproduce, prepare Derivative Works of,
70 | publicly display, publicly perform, sublicense, and distribute the
71 | Work and such Derivative Works in Source or Object form.
72 |
73 | 3. Grant of Patent License. Subject to the terms and conditions of
74 | this License, each Contributor hereby grants to You a perpetual,
75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76 | (except as stated in this section) patent license to make, have made,
77 | use, offer to sell, sell, import, and otherwise transfer the Work,
78 | where such license applies only to those patent claims licensable
79 | by such Contributor that are necessarily infringed by their
80 | Contribution(s) alone or by combination of their Contribution(s)
81 | with the Work to which such Contribution(s) was submitted. If You
82 | institute patent litigation against any entity (including a
83 | cross-claim or counterclaim in a lawsuit) alleging that the Work
84 | or a Contribution incorporated within the Work constitutes direct
85 | or contributory patent infringement, then any patent licenses
86 | granted to You under this License for that Work shall terminate
87 | as of the date such litigation is filed.
88 |
89 | 4. Redistribution. You may reproduce and distribute copies of the
90 | Work or Derivative Works thereof in any medium, with or without
91 | modifications, and in Source or Object form, provided that You
92 | meet the following conditions:
93 |
94 | (a) You must give any other recipients of the Work or
95 | Derivative Works a copy of this License; and
96 |
97 | (b) You must cause any modified files to carry prominent notices
98 | stating that You changed the files; and
99 |
100 | (c) You must retain, in the Source form of any Derivative Works
101 | that You distribute, all copyright, patent, trademark, and
102 | attribution notices from the Source form of the Work,
103 | excluding those notices that do not pertain to any part of
104 | the Derivative Works; and
105 |
106 | (d) If the Work includes a "NOTICE" text file as part of its
107 | distribution, then any Derivative Works that You distribute must
108 | include a readable copy of the attribution notices contained
109 | within such NOTICE file, excluding those notices that do not
110 | pertain to any part of the Derivative Works, in at least one
111 | of the following places: within a NOTICE text file distributed
112 | as part of the Derivative Works; within the Source form or
113 | documentation, if provided along with the Derivative Works; or,
114 | within a display generated by the Derivative Works, if and
115 | wherever such third-party notices normally appear. The contents
116 | of the NOTICE file are for informational purposes only and
117 | do not modify the License. You may add Your own attribution
118 | notices within Derivative Works that You distribute, alongside
119 | or as an addendum to the NOTICE text from the Work, provided
120 | that such additional attribution notices cannot be construed
121 | as modifying the License.
122 |
123 | You may add Your own copyright statement to Your modifications and
124 | may provide additional or different license terms and conditions
125 | for use, reproduction, or distribution of Your modifications, or
126 | for any such Derivative Works as a whole, provided Your use,
127 | reproduction, and distribution of the Work otherwise complies with
128 | the conditions stated in this License.
129 |
130 | 5. Submission of Contributions. Unless You explicitly state otherwise,
131 | any Contribution intentionally submitted for inclusion in the Work
132 | by You to the Licensor shall be under the terms and conditions of
133 | this License, without any additional terms or conditions.
134 | Notwithstanding the above, nothing herein shall supersede or modify
135 | the terms of any separate license agreement you may have executed
136 | with Licensor regarding such Contributions.
137 |
138 | 6. Trademarks. This License does not grant permission to use the trade
139 | names, trademarks, service marks, or product names of the Licensor,
140 | except as required for reasonable and customary use in describing the
141 | origin of the Work and reproducing the content of the NOTICE file.
142 |
143 | 7. Disclaimer of Warranty. Unless required by applicable law or
144 | agreed to in writing, Licensor provides the Work (and each
145 | Contributor provides its Contributions) on an "AS IS" BASIS,
146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147 | implied, including, without limitation, any warranties or conditions
148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149 | PARTICULAR PURPOSE. You are solely responsible for determining the
150 | appropriateness of using or redistributing the Work and assume any
151 | risks associated with Your exercise of permissions under this License.
152 |
153 | 8. Limitation of Liability. In no event and under no legal theory,
154 | whether in tort (including negligence), contract, or otherwise,
155 | unless required by applicable law (such as deliberate and grossly
156 | negligent acts) or agreed to in writing, shall any Contributor be
157 | liable to You for damages, including any direct, indirect, special,
158 | incidental, or consequential damages of any character arising as a
159 | result of this License or out of the use or inability to use the
160 | Work (including but not limited to damages for loss of goodwill,
161 | work stoppage, computer failure or malfunction, or any and all
162 | other commercial damages or losses), even if such Contributor
163 | has been advised of the possibility of such damages.
164 |
165 | 9. Accepting Warranty or Additional Liability. While redistributing
166 | the Work or Derivative Works thereof, You may choose to offer,
167 | and charge a fee for, acceptance of support, warranty, indemnity,
168 | or other liability obligations and/or rights consistent with this
169 | License. However, in accepting such obligations, You may act only
170 | on Your own behalf and on Your sole responsibility, not on behalf
171 | of any other Contributor, and only if You agree to indemnify,
172 | defend, and hold each Contributor harmless for any liability
173 | incurred by, or claims asserted against, such Contributor by reason
174 | of your accepting any such warranty or additional liability.
175 |
176 | END OF TERMS AND CONDITIONS
177 |
178 | APPENDIX: How to apply the Apache License to your work.
179 |
180 | To apply the Apache License to your work, attach the following
181 | boilerplate notice, with the fields enclosed by brackets "{}"
182 | replaced with your own identifying information. (Don't include
183 | the brackets!) The text should be enclosed in the appropriate
184 | comment syntax for the file format. We also recommend that a
185 | file or class name and description of purpose be included on the
186 | same "printed page" as the copyright notice for easier
187 | identification within third-party archives.
188 |
189 | Copyright 2017 Matthew Gaunt
190 |
191 | Licensed under the Apache License, Version 2.0 (the "License");
192 | you may not use this file except in compliance with the License.
193 | You may obtain a copy of the License at
194 |
195 | http://www.apache.org/licenses/LICENSE-2.0
196 |
197 | Unless required by applicable law or agreed to in writing, software
198 | distributed under the License is distributed on an "AS IS" BASIS,
199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200 | See the License for the specific language governing permissions and
201 | limitations under the License.
202 |
--------------------------------------------------------------------------------
/test/browser-tests/encryption-aes-128-gcm.js:
--------------------------------------------------------------------------------
1 | /* eslint-env browser,mocha */
2 |
3 | 'use strict';
4 |
5 | import {uint8ArrayToBase64Url, base64UrlToUint8Array, cryptoKeysToUint8Array} from '/frontend/scripts/encryption/helpers.js';
6 | import {EncryptionAES128GCM} from '/frontend/scripts/encryption/encryption-aes-128-gcm.js';
7 | import {APPLICATION_KEYS} from '/frontend/scripts/constants.js';
8 |
9 | describe('EncryptionAES128GCM', () => {
10 | const PAYLOAD = 'Hello, world!';
11 | const VALID_SERVER_KEYS = {
12 | publicKey: 'BG3OGHrl3YJ5PHpl0GSqtAAlUPnx1LvwQvFMIc68vhJU6nIkRzPEqtCduQz8wQj0r71NVPzr7ZRk2f-fhsQ5pK8',
13 | privateKey: 'Dt1CLgQlkiaA-tmCkATyKZeoF1-Gtw1-gdEP6pOCqj4',
14 | };
15 | const VALID_SALT = 'AAAAAAAAAAAAAAAAAAAAAA';
16 |
17 | const VALID_SUBSCRIPTION = {
18 | endpoint: 'https://android.googleapis.com/gcm/send/FAKE_GCM_REGISTRATION_ID',
19 | getKey: (keyId) => {
20 | switch (keyId) {
21 | case 'p256dh':
22 | return base64UrlToUint8Array('BCIWgsnyXDv1VkhqL2P7YRBvdeuDnlwAPT2guNhdIoW3IP7GmHh1SMKPLxRf7x8vJy6ZFK3ol2ohgn_-0yP7QQA=');
23 | case 'auth':
24 | return base64UrlToUint8Array('8eDyX_uCN0XRhSbY5hs7Hg==');
25 | default:
26 | throw new Error('Oh dear. An unknown subscription key was requested: ', keyId);
27 | }
28 | },
29 | };
30 |
31 | const VALID_OUTPUT = {
32 | sharedSecret: 'GOr9wG2bF4vCrnE_sOnwM7k-ZguFYyPMbtd5ESmT0gs',
33 | context: 'UC0yNTYAAEEEIhaCyfJcO_VWSGovY_thEG9164OeXAA9PaC42F0ihbcg_saYeHVIwo8vFF_vHy8nLpkUreiXaiGCf_7TI_tBAABBBG3OGHrl3YJ5PHpl0GSqtAAlUPnx1LvwQvFMIc68vhJU6nIkRzPEqtCduQz8wQj0r71NVPzr7ZRk2f-fhsQ5pK8',
34 | cekInfo: 'Q29udGVudC1FbmNvZGluZzogYWVzMTI4Z2NtAA',
35 | nonceInfo: 'Q29udGVudC1FbmNvZGluZzogbm9uY2UA',
36 | keyInfo: 'V2ViUHVzaDogaW5mbwAEIhaCyfJcO_VWSGovY_thEG9164OeXAA9PaC42F0ihbcg_saYeHVIwo8vFF_vHy8nLpkUreiXaiGCf_7TI_tBAARtzhh65d2CeTx6ZdBkqrQAJVD58dS78ELxTCHOvL4SVOpyJEczxKrQnbkM_MEI9K-9TVT86-2UZNn_n4bEOaSv',
37 | prk: 'YXQOi9WVYZRvGk9pdoq-u_zr15HGsuzU7sPVSTb70Xk',
38 | contentEncryptionKey: 'qIuzYacKKN1q4hIxqOCJrw',
39 | nonce: 'QvcILucv_Mh5t9ff',
40 | payload: 'AAAAAAAAAAAAAAAAAAAAAAAAEABBBG3OGHrl3YJ5PHpl0GSqtAAlUPnx1LvwQvFMIc68vhJU6nIkRzPEqtCduQz8wQj0r71NVPzr7ZRk2f-fhsQ5pK96Kh5TnTaUZZypdS4uO2SzLwNL6N-KfyTk59Qu3hw',
41 | };
42 |
43 |
44 | const VALID_VAPID_KEYS = {
45 | publicKey: 'BG3OGHrl3YJ5PHpl0GSqtAAlUPnx1LvwQvFMIc68vhJU6nIkRzPEqtCduQz8wQj0r71NVPzr7ZRk2f-fhsQ5pK8',
46 | privateKey: 'Dt1CLgQlkiaA-tmCkATyKZeoF1-Gtw1-gdEP6pOCqj4',
47 | };
48 |
49 | it('should be able to generate server keys', async () => {
50 | const keys = await EncryptionAES128GCM.generateServerKeys();
51 | keys.should.not.equal('undefined');
52 | keys.should.have.property('publicKey');
53 | keys.should.have.property('privateKey');
54 | });
55 |
56 | it('should create new certificates if nothing is passed in', async () => {
57 | const encryptionHelper = new EncryptionAES128GCM();
58 | const serverKeys = await encryptionHelper.getServerKeys();
59 |
60 | (serverKeys.publicKey instanceof CryptoKey).should.equal(true);
61 | (serverKeys.privateKey instanceof CryptoKey).should.equal(true);
62 | const keys = await cryptoKeysToUint8Array(
63 | serverKeys.publicKey,
64 | serverKeys.privateKey,
65 | );
66 |
67 | (keys.publicKey instanceof Uint8Array).should.equal(true);
68 | (keys.privateKey instanceof Uint8Array).should.equal(true);
69 |
70 | (keys.publicKey.length).should.equal(65);
71 | (keys.privateKey.length).should.equal(32);
72 | });
73 |
74 | it('should accept valid input certificates', async () => {
75 | const encryptionHelper = new EncryptionAES128GCM({
76 | serverKeys: VALID_SERVER_KEYS,
77 | });
78 | const serverKeys = await encryptionHelper.getServerKeys();
79 | (serverKeys.publicKey instanceof CryptoKey).should.equal(true);
80 | (serverKeys.privateKey instanceof CryptoKey).should.equal(true);
81 | const keys = await cryptoKeysToUint8Array(
82 | serverKeys.publicKey,
83 | serverKeys.privateKey,
84 | );
85 |
86 | (keys.publicKey instanceof Uint8Array).should.equal(true);
87 | (keys.privateKey instanceof Uint8Array).should.equal(true);
88 |
89 | (keys.publicKey.length).should.equal(65);
90 | (keys.privateKey.length).should.equal(32);
91 |
92 | const publicKey = uint8ArrayToBase64Url(keys.publicKey);
93 | const privateKey = uint8ArrayToBase64Url(keys.privateKey);
94 | publicKey.should.equal(VALID_SERVER_KEYS.publicKey);
95 | privateKey.should.equal(VALID_SERVER_KEYS.privateKey);
96 | });
97 |
98 | it('should calculate a shared secret', async () => {
99 | /**
100 | * Referred to as IKM on https://tests.peter.sh/push-encryption-verifier/
101 | */
102 | const encryptionHelper = new EncryptionAES128GCM({
103 | serverKeys: VALID_SERVER_KEYS,
104 | });
105 | const serverKeys = await encryptionHelper.getServerKeys();
106 | const sharedSecret = await encryptionHelper._getSharedSecret(VALID_SUBSCRIPTION, serverKeys);
107 |
108 | (sharedSecret instanceof ArrayBuffer).should.equal(true);
109 | const base64Secret = uint8ArrayToBase64Url(new Uint8Array(sharedSecret));
110 | base64Secret.should.equal(VALID_OUTPUT.sharedSecret);
111 | });
112 |
113 | it('should generate a random salt', async () => {
114 | const encryptionHelper = new EncryptionAES128GCM();
115 | (encryptionHelper.getSalt() instanceof Uint8Array).should.equal(true);
116 | });
117 |
118 | it('should use defined salt', async () => {
119 | const encryptionHelper = new EncryptionAES128GCM({
120 | salt: VALID_SALT,
121 | });
122 | (encryptionHelper.getSalt() instanceof Uint8Array).should.equal(true);
123 | const base64Salt = uint8ArrayToBase64Url(encryptionHelper.getSalt());
124 | base64Salt.should.equal(VALID_SALT);
125 | });
126 |
127 | it('should generate a cekInfo for aesgcm', async () => {
128 | const encryptionHelper = new EncryptionAES128GCM();
129 |
130 | const serverKeys = await encryptionHelper.getServerKeys();
131 | const cekInfo = await encryptionHelper._generateCEKInfo(VALID_SUBSCRIPTION, serverKeys);
132 | (cekInfo instanceof Uint8Array).should.equal(true);
133 | cekInfo.byteLength.should.equal(28);
134 | });
135 |
136 | it('should generate the specific cekInfo', async () => {
137 | const encryptionHelper = new EncryptionAES128GCM({
138 | serverKeys: VALID_SERVER_KEYS,
139 | salt: VALID_SALT,
140 | });
141 |
142 | const serverKeys = await encryptionHelper.getServerKeys();
143 | const cekInfo = await encryptionHelper._generateCEKInfo(VALID_SUBSCRIPTION, serverKeys);
144 |
145 | (cekInfo instanceof Uint8Array).should.equal(true);
146 | cekInfo.byteLength.should.equal(28);
147 |
148 | // See: https://martinthomson.github.io/http-encryption/#rfc.section.4.2
149 | const base64CekInfo = uint8ArrayToBase64Url(cekInfo);
150 | base64CekInfo.should.equal(VALID_OUTPUT.cekInfo);
151 | });
152 |
153 | it('should generate a nonceInfo with a context', async () => {
154 | const encryptionHelper = new EncryptionAES128GCM();
155 |
156 | const serverKeys = await encryptionHelper.getServerKeys();
157 | const nonceInfo = await encryptionHelper._generateNonceInfo(VALID_SUBSCRIPTION, serverKeys);
158 |
159 | (nonceInfo instanceof Uint8Array).should.equal(true);
160 | nonceInfo.byteLength.should.equal(24);
161 | });
162 |
163 | it('should generate the specific nonceInfo', async () => {
164 | const encryptionHelper = new EncryptionAES128GCM({
165 | serverKeys: VALID_SERVER_KEYS,
166 | salt: VALID_SALT,
167 | });
168 |
169 | const serverKeys = await encryptionHelper.getServerKeys();
170 | const nonceInfo = await encryptionHelper._generateNonceInfo(VALID_SUBSCRIPTION, serverKeys);
171 | (nonceInfo instanceof Uint8Array).should.equal(true);
172 | nonceInfo.byteLength.should.equal(24);
173 |
174 | // See: https://martinthomson.github.io/http-encryption/#rfc.section.4.2
175 | const base64NonceInfo = uint8ArrayToBase64Url(nonceInfo);
176 | base64NonceInfo.should.equal(VALID_OUTPUT.nonceInfo);
177 | });
178 |
179 | it('should generate key info', async () => {
180 | const encryptionHelper = new EncryptionAES128GCM();
181 |
182 | const serverKeys = await encryptionHelper.getServerKeys();
183 | const keyInfo = await encryptionHelper._getKeyInfo(VALID_SUBSCRIPTION, serverKeys);
184 | (keyInfo instanceof Uint8Array).should.equal(true);
185 | keyInfo.byteLength.should.equal(144);
186 | });
187 |
188 | it('should generate specific key info', async () => {
189 | const encryptionHelper = new EncryptionAES128GCM({
190 | serverKeys: VALID_SERVER_KEYS,
191 | });
192 |
193 | const serverKeys = await encryptionHelper.getServerKeys();
194 | const keyInfo = await encryptionHelper._getKeyInfo(VALID_SUBSCRIPTION, serverKeys);
195 | (keyInfo instanceof Uint8Array).should.equal(true);
196 | keyInfo.byteLength.should.equal(144);
197 |
198 | uint8ArrayToBase64Url(keyInfo).should.equal(VALID_OUTPUT.keyInfo);
199 | });
200 |
201 | it('should generate a pseudo random key', async () => {
202 | const encryptionHelper = new EncryptionAES128GCM();
203 |
204 | const serverKeys = await encryptionHelper.getServerKeys();
205 | const prk = await encryptionHelper._generatePRK(VALID_SUBSCRIPTION, serverKeys);
206 | (prk instanceof ArrayBuffer).should.equal(true);
207 | });
208 |
209 | it('should generate the specific pseudo random key', async () => {
210 | const encryptionHelper = new EncryptionAES128GCM({
211 | serverKeys: VALID_SERVER_KEYS,
212 | salt: VALID_SALT,
213 | });
214 |
215 | const serverKeys = await encryptionHelper.getServerKeys();
216 | const prk = await encryptionHelper._generatePRK(VALID_SUBSCRIPTION, serverKeys);
217 |
218 | (prk instanceof ArrayBuffer).should.equal(true);
219 |
220 | const base64prk = uint8ArrayToBase64Url(new Uint8Array(prk));
221 | base64prk.should.equal(VALID_OUTPUT.prk);
222 | });
223 |
224 | it('should generate encryption keys', async () => {
225 | const encryptionHelper = new EncryptionAES128GCM();
226 |
227 | const serverKeys = await encryptionHelper.getServerKeys();
228 | const keys = await encryptionHelper._generateEncryptionKeys(VALID_SUBSCRIPTION, encryptionHelper.getSalt(), serverKeys);
229 |
230 | (keys.contentEncryptionKey instanceof ArrayBuffer).should.equal(true);
231 | (keys.nonce instanceof ArrayBuffer).should.equal(true);
232 |
233 | new Uint8Array(keys.contentEncryptionKey).byteLength.should.equal(16);
234 | new Uint8Array(keys.nonce).byteLength.should.equal(12);
235 | });
236 |
237 | it('should generate the specific encryption keys', async () => {
238 | const encryptionHelper = new EncryptionAES128GCM({
239 | serverKeys: VALID_SERVER_KEYS,
240 | salt: VALID_SALT,
241 | });
242 |
243 | const serverKeys = await encryptionHelper.getServerKeys();
244 | const keys = await encryptionHelper._generateEncryptionKeys(VALID_SUBSCRIPTION, encryptionHelper.getSalt(), serverKeys);
245 | (keys.contentEncryptionKey instanceof ArrayBuffer).should.equal(true);
246 | (keys.nonce instanceof ArrayBuffer).should.equal(true);
247 |
248 | new Uint8Array(keys.contentEncryptionKey).byteLength.should.equal(16);
249 | new Uint8Array(keys.nonce).byteLength.should.equal(12);
250 |
251 | const base64cek = uint8ArrayToBase64Url(new Uint8Array(keys.contentEncryptionKey));
252 | base64cek.should.equal(VALID_OUTPUT.contentEncryptionKey);
253 |
254 | const base64nonce = uint8ArrayToBase64Url(new Uint8Array(keys.nonce));
255 | base64nonce.should.equal(VALID_OUTPUT.nonce);
256 | });
257 |
258 | it('should encrypt message', async () => {
259 | const encryptionHelper = new EncryptionAES128GCM();
260 | const encryptedPayload = await encryptionHelper.encryptPayload(VALID_SUBSCRIPTION, PAYLOAD);
261 | (encryptedPayload instanceof Object).should.equal(true);
262 | (encryptedPayload.cipherText instanceof ArrayBuffer).should.equal(true);
263 | (typeof encryptedPayload.publicServerKey === 'string').should.equal(true);
264 | (typeof encryptedPayload.salt === 'string').should.equal(true);
265 | });
266 |
267 | it('should encrypt message to a specific value', async () => {
268 | const encryptionHelper = new EncryptionAES128GCM({
269 | serverKeys: VALID_SERVER_KEYS,
270 | salt: VALID_SALT,
271 | });
272 |
273 | const encryptedPayload = await encryptionHelper.encryptPayload(VALID_SUBSCRIPTION, PAYLOAD);
274 | (encryptedPayload instanceof Object).should.equal(true);
275 | (encryptedPayload.cipherText instanceof ArrayBuffer).should.equal(true);
276 | (typeof encryptedPayload.publicServerKey === 'string').should.equal(true);
277 | (typeof encryptedPayload.salt === 'string').should.equal(true);
278 |
279 | const base64EncryptedPayload = uint8ArrayToBase64Url(new Uint8Array(encryptedPayload.cipherText));
280 | base64EncryptedPayload.should.equal(VALID_OUTPUT.payload);
281 | });
282 |
283 | it('should use default vapid certs', async () => {
284 | const encryptionHelper = new EncryptionAES128GCM();
285 | const vapidKeys = encryptionHelper.getVapidKeys();
286 | vapidKeys.publicKey.should.equal(APPLICATION_KEYS.publicKey);
287 | vapidKeys.privateKey.should.equal(APPLICATION_KEYS.privateKey);
288 | });
289 |
290 | it('should accept valid input VAPID certificates', async () => {
291 | const encryptionHelper = new EncryptionAES128GCM({
292 | vapidKeys: VALID_VAPID_KEYS,
293 | });
294 | const vapidKeys = encryptionHelper.getVapidKeys();
295 | vapidKeys.publicKey.should.equal(VALID_VAPID_KEYS.publicKey);
296 | vapidKeys.privateKey.should.equal(VALID_VAPID_KEYS.privateKey);
297 | });
298 | });
299 |
--------------------------------------------------------------------------------
/test/browser-tests/encryption-aes-gcm.js:
--------------------------------------------------------------------------------
1 | /* eslint-env browser,mocha */
2 |
3 | 'use strict';
4 |
5 | import {uint8ArrayToBase64Url, base64UrlToUint8Array, cryptoKeysToUint8Array} from '/frontend/scripts/encryption/helpers.js';
6 | import {EncryptionAESGCM} from '/frontend/scripts/encryption/encryption-aes-gcm.js';
7 | import {APPLICATION_KEYS} from '/frontend/scripts/constants.js';
8 |
9 | describe('EncryptionAESGCM', function() {
10 | const PAYLOAD = 'Hello, world!';
11 | const VALID_SERVER_KEYS = {
12 | publicKey: 'BG3OGHrl3YJ5PHpl0GSqtAAlUPnx1LvwQvFMIc68vhJU6nIkRzPEqtCduQz8wQj0r71NVPzr7ZRk2f-fhsQ5pK8',
13 | privateKey: 'Dt1CLgQlkiaA-tmCkATyKZeoF1-Gtw1-gdEP6pOCqj4',
14 | };
15 | const VALID_SALT = 'AAAAAAAAAAAAAAAAAAAAAA';
16 |
17 | const VALID_SUBSCRIPTION = {
18 | endpoint: 'https://android.googleapis.com/gcm/send/FAKE_GCM_REGISTRATION_ID',
19 | getKey: (keyId) => {
20 | switch (keyId) {
21 | case 'p256dh':
22 | return base64UrlToUint8Array('BCIWgsnyXDv1VkhqL2P7YRBvdeuDnlwAPT2guNhdIoW3IP7GmHh1SMKPLxRf7x8vJy6ZFK3ol2ohgn_-0yP7QQA=');
23 | case 'auth':
24 | return base64UrlToUint8Array('8eDyX_uCN0XRhSbY5hs7Hg==');
25 | default:
26 | throw new Error('Oh dear. An unknown subscription key was requested: ', keyId);
27 | }
28 | },
29 | };
30 |
31 | const VALID_OUTPUT = {
32 | sharedSecret: 'GOr9wG2bF4vCrnE_sOnwM7k-ZguFYyPMbtd5ESmT0gs',
33 | context: 'UC0yNTYAAEEEIhaCyfJcO_VWSGovY_thEG9164OeXAA9PaC42F0ihbcg_saYeHVIwo8vFF_vHy8nLpkUreiXaiGCf_7TI_tBAABBBG3OGHrl3YJ5PHpl0GSqtAAlUPnx1LvwQvFMIc68vhJU6nIkRzPEqtCduQz8wQj0r71NVPzr7ZRk2f-fhsQ5pK8',
34 | cekInfo: 'Q29udGVudC1FbmNvZGluZzogYWVzZ2NtAFAtMjU2AABBBCIWgsnyXDv1VkhqL2P7YRBvdeuDnlwAPT2guNhdIoW3IP7GmHh1SMKPLxRf7x8vJy6ZFK3ol2ohgn_-0yP7QQAAQQRtzhh65d2CeTx6ZdBkqrQAJVD58dS78ELxTCHOvL4SVOpyJEczxKrQnbkM_MEI9K-9TVT86-2UZNn_n4bEOaSv',
35 | nonceInfo: 'Q29udGVudC1FbmNvZGluZzogbm9uY2UAUC0yNTYAAEEEIhaCyfJcO_VWSGovY_thEG9164OeXAA9PaC42F0ihbcg_saYeHVIwo8vFF_vHy8nLpkUreiXaiGCf_7TI_tBAABBBG3OGHrl3YJ5PHpl0GSqtAAlUPnx1LvwQvFMIc68vhJU6nIkRzPEqtCduQz8wQj0r71NVPzr7ZRk2f-fhsQ5pK8',
36 | prk: 'SfahPAaEhUazMRsu7H00NG1F_pHSm0wynhpkEPmn4mE',
37 | contentEncryptionKey: 'DvXDFb5AxYrVJHCcYS6LkA',
38 | nonce: '9lpH1RH1uUoNJ8yh',
39 | payload: 'WhrsIm-1bGLEyKIaQjhfgMZVGd3wbMsVtvxobcH62Q',
40 | };
41 |
42 |
43 | const VALID_VAPID_KEYS = {
44 | publicKey: 'BG3OGHrl3YJ5PHpl0GSqtAAlUPnx1LvwQvFMIc68vhJU6nIkRzPEqtCduQz8wQj0r71NVPzr7ZRk2f-fhsQ5pK8',
45 | privateKey: 'Dt1CLgQlkiaA-tmCkATyKZeoF1-Gtw1-gdEP6pOCqj4',
46 | };
47 |
48 | it('should be able to generate server keys', async () => {
49 | const keys = await EncryptionAESGCM.generateServerKeys();
50 | keys.should.not.equal('undefined');
51 | keys.should.have.property('publicKey');
52 | keys.should.have.property('privateKey');
53 | });
54 |
55 | it('should create new certificates if nothing is passed in', async () => {
56 | const encryptionHelper = new EncryptionAESGCM();
57 | const serverKeys = await encryptionHelper.getServerKeys();
58 | (serverKeys.publicKey instanceof CryptoKey).should.equal(true);
59 | (serverKeys.privateKey instanceof CryptoKey).should.equal(true);
60 |
61 | const keys = await cryptoKeysToUint8Array(
62 | serverKeys.publicKey,
63 | serverKeys.privateKey,
64 | );
65 |
66 | (keys.publicKey instanceof Uint8Array).should.equal(true);
67 | (keys.privateKey instanceof Uint8Array).should.equal(true);
68 |
69 | (keys.publicKey.length).should.equal(65);
70 | (keys.privateKey.length).should.equal(32);
71 | });
72 |
73 | it('should accept valid input certificates', async () => {
74 | const encryptionHelper = new EncryptionAESGCM({
75 | serverKeys: VALID_SERVER_KEYS,
76 | });
77 | const serverKeys = await encryptionHelper.getServerKeys();
78 | (serverKeys.publicKey instanceof CryptoKey).should.equal(true);
79 | (serverKeys.privateKey instanceof CryptoKey).should.equal(true);
80 |
81 | const keys = await cryptoKeysToUint8Array(
82 | serverKeys.publicKey,
83 | serverKeys.privateKey,
84 | );
85 | (keys.publicKey instanceof Uint8Array).should.equal(true);
86 | (keys.privateKey instanceof Uint8Array).should.equal(true);
87 |
88 | (keys.publicKey.length).should.equal(65);
89 | (keys.privateKey.length).should.equal(32);
90 |
91 | const publicKey = uint8ArrayToBase64Url(keys.publicKey);
92 | const privateKey = uint8ArrayToBase64Url(keys.privateKey);
93 | publicKey.should.equal(VALID_SERVER_KEYS.publicKey);
94 | privateKey.should.equal(VALID_SERVER_KEYS.privateKey);
95 | });
96 |
97 | it('should calculate a shared secret', async () => {
98 | /**
99 | * Referred to as IKM on https://tests.peter.sh/push-encryption-verifier/
100 | */
101 | const encryptionHelper = new EncryptionAESGCM({
102 | serverKeys: VALID_SERVER_KEYS,
103 | });
104 | const serverKeys = await encryptionHelper.getServerKeys();
105 | const sharedSecret = await encryptionHelper._getSharedSecret(VALID_SUBSCRIPTION, serverKeys);
106 | (sharedSecret instanceof ArrayBuffer).should.equal(true);
107 | const base64Secret = uint8ArrayToBase64Url(new Uint8Array(sharedSecret));
108 | base64Secret.should.equal(VALID_OUTPUT.sharedSecret);
109 | });
110 |
111 | it('should generate a random salt', async () => {
112 | const encryptionHelper = new EncryptionAESGCM();
113 | (encryptionHelper.getSalt() instanceof Uint8Array).should.equal(true);
114 | });
115 |
116 | it('should use defined salt', async () => {
117 | const encryptionHelper = new EncryptionAESGCM({
118 | salt: VALID_SALT,
119 | });
120 | (encryptionHelper.getSalt() instanceof Uint8Array).should.equal(true);
121 | const base64Salt = uint8ArrayToBase64Url(encryptionHelper.getSalt());
122 | base64Salt.should.equal(VALID_SALT);
123 | });
124 |
125 | // See: https://martinthomson.github.io/http-encrypt
126 | it('should generate a context', async () => {
127 | const encryptionHelper = new EncryptionAESGCM();
128 | const serverKeys = await encryptionHelper.getServerKeys();
129 | const context = await encryptionHelper._generateContext(VALID_SUBSCRIPTION, serverKeys);
130 | (context instanceof Uint8Array).should.equal(true);
131 | context.byteLength.should.equal(5 + 1 + 2 + 65 + 2 + 65);
132 | });
133 |
134 | it('should generate a context with the expected output', async () => {
135 | const encryptionHelper = new EncryptionAESGCM({
136 | serverKeys: VALID_SERVER_KEYS,
137 | salt: VALID_SALT,
138 | });
139 |
140 | const serverKeys = await encryptionHelper.getServerKeys();
141 | const context = await encryptionHelper._generateContext(VALID_SUBSCRIPTION, serverKeys);
142 |
143 | (context instanceof Uint8Array).should.equal(true);
144 | context.byteLength.should.equal(5 + 1 + 2 + 65 + 2 + 65);
145 | const base64Context = uint8ArrayToBase64Url(context);
146 | base64Context.should.equal(VALID_OUTPUT.context);
147 | });
148 |
149 | it('should generate a cekInfo for aesgcm', async () => {
150 | const encryptionHelper = new EncryptionAESGCM();
151 |
152 | const serverKeys = await encryptionHelper.getServerKeys();
153 | const cekInfo = await encryptionHelper._generateCEKInfo(VALID_SUBSCRIPTION, serverKeys);
154 |
155 | (cekInfo instanceof Uint8Array).should.equal(true);
156 | cekInfo.byteLength.should.equal(24 + 1 + 5 + 1 + 2 + 65 + 2 + 65);
157 | });
158 |
159 | it('should generate the specific cekInfo', async () => {
160 | const encryptionHelper = new EncryptionAESGCM({
161 | serverKeys: VALID_SERVER_KEYS,
162 | salt: VALID_SALT,
163 | });
164 |
165 | const serverKeys = await encryptionHelper.getServerKeys();
166 | const cekInfo = await encryptionHelper._generateCEKInfo(VALID_SUBSCRIPTION, serverKeys);
167 |
168 | (cekInfo instanceof Uint8Array).should.equal(true);
169 | cekInfo.byteLength.should.equal(24 + 1 + 5 + 1 + 2 + 65 + 2 + 65);
170 |
171 | // See: https://martinthomson.github.io/http-encryption/#rfc.section.4.2
172 | const base64CekInfo = uint8ArrayToBase64Url(cekInfo);
173 | base64CekInfo.should.equal(VALID_OUTPUT.cekInfo);
174 | });
175 |
176 | it('should generate a nonceInfo with a context', async () => {
177 | const encryptionHelper = new EncryptionAESGCM();
178 |
179 | const serverKeys = await encryptionHelper.getServerKeys();
180 | const nonceInfo = await encryptionHelper._generateNonceInfo(VALID_SUBSCRIPTION, serverKeys);
181 | (nonceInfo instanceof Uint8Array).should.equal(true);
182 | nonceInfo.byteLength.should.equal(23 + 1 + 5 + 1 + 2 + 65 + 2 + 65);
183 | });
184 |
185 | it('should generate the specific nonceInfo', async () => {
186 | const encryptionHelper = new EncryptionAESGCM({
187 | serverKeys: VALID_SERVER_KEYS,
188 | salt: VALID_SALT,
189 | });
190 |
191 | const serverKeys = await encryptionHelper.getServerKeys();
192 | const nonceInfo = await encryptionHelper._generateNonceInfo(VALID_SUBSCRIPTION, serverKeys);
193 | (nonceInfo instanceof Uint8Array).should.equal(true);
194 | nonceInfo.byteLength.should.equal(23 + 1 + 5 + 1 + 2 + 65 + 2 + 65);
195 |
196 | // See: https://martinthomson.github.io/http-encryption/#rfc.section.4.2
197 | const base64NonceInfo = uint8ArrayToBase64Url(nonceInfo);
198 | base64NonceInfo.should.equal(VALID_OUTPUT.nonceInfo);
199 | });
200 |
201 | it('should generate a pseudo random key for aesgcm', async () => {
202 | const encryptionHelper = new EncryptionAESGCM();
203 |
204 | const serverKeys = await encryptionHelper.getServerKeys();
205 | const prk = await encryptionHelper._generatePRK(VALID_SUBSCRIPTION, serverKeys);
206 | (prk instanceof ArrayBuffer).should.equal(true);
207 | });
208 |
209 | it('should generate the specific pseudo random key', async () => {
210 | const encryptionHelper = new EncryptionAESGCM({
211 | serverKeys: VALID_SERVER_KEYS,
212 | salt: VALID_SALT,
213 | });
214 |
215 | const serverKeys = await encryptionHelper.getServerKeys();
216 | const prk = await encryptionHelper._generatePRK(VALID_SUBSCRIPTION, serverKeys);
217 | (prk instanceof ArrayBuffer).should.equal(true);
218 |
219 | const base64prk = uint8ArrayToBase64Url(new Uint8Array(prk));
220 | base64prk.should.equal(VALID_OUTPUT.prk);
221 | });
222 |
223 | it('should generate encryption keys', async () => {
224 | const encryptionHelper = new EncryptionAESGCM();
225 |
226 | const serverKeys = await encryptionHelper.getServerKeys();
227 | const keys = await encryptionHelper._generateEncryptionKeys(VALID_SUBSCRIPTION, encryptionHelper.getSalt(), serverKeys);
228 | (keys.contentEncryptionKey instanceof ArrayBuffer).should.equal(true);
229 | (keys.nonce instanceof ArrayBuffer).should.equal(true);
230 |
231 | new Uint8Array(keys.contentEncryptionKey).byteLength.should.equal(16);
232 | new Uint8Array(keys.nonce).byteLength.should.equal(12);
233 | });
234 |
235 | it('should generate the specific encryption keys', async () => {
236 | const encryptionHelper = new EncryptionAESGCM({
237 | serverKeys: VALID_SERVER_KEYS,
238 | salt: VALID_SALT,
239 | });
240 |
241 | const serverKeys = await encryptionHelper.getServerKeys();
242 | const keys = await encryptionHelper._generateEncryptionKeys(VALID_SUBSCRIPTION, encryptionHelper.getSalt(), serverKeys);
243 | (keys.contentEncryptionKey instanceof ArrayBuffer).should.equal(true);
244 | (keys.nonce instanceof ArrayBuffer).should.equal(true);
245 |
246 | new Uint8Array(keys.contentEncryptionKey).byteLength.should.equal(16);
247 | new Uint8Array(keys.nonce).byteLength.should.equal(12);
248 |
249 | const base64cek = uint8ArrayToBase64Url(new Uint8Array(keys.contentEncryptionKey));
250 | base64cek.should.equal(VALID_OUTPUT.contentEncryptionKey);
251 |
252 | const base64nonce = uint8ArrayToBase64Url(new Uint8Array(keys.nonce));
253 | base64nonce.should.equal(VALID_OUTPUT.nonce);
254 | });
255 |
256 | it('should encrypt message', async () => {
257 | const encryptionHelper = new EncryptionAESGCM();
258 | const encryptedPayload = await encryptionHelper.encryptPayload(VALID_SUBSCRIPTION, PAYLOAD);
259 | (encryptedPayload instanceof Object).should.equal(true);
260 | (encryptedPayload.cipherText instanceof ArrayBuffer).should.equal(true);
261 | (typeof encryptedPayload.publicServerKey === 'string').should.equal(true);
262 | (typeof encryptedPayload.salt === 'string').should.equal(true);
263 | });
264 |
265 | it('should encrypt message to a specific value', async () => {
266 | const encryptionHelper = new EncryptionAESGCM({
267 | serverKeys: VALID_SERVER_KEYS,
268 | salt: VALID_SALT,
269 | });
270 |
271 | const encryptedPayload = await encryptionHelper.encryptPayload(VALID_SUBSCRIPTION, PAYLOAD);
272 |
273 | (encryptedPayload instanceof Object).should.equal(true);
274 | (encryptedPayload.cipherText instanceof ArrayBuffer).should.equal(true);
275 | (typeof encryptedPayload.publicServerKey === 'string').should.equal(true);
276 | (typeof encryptedPayload.salt === 'string').should.equal(true);
277 |
278 | const base64EncryptedPayload = uint8ArrayToBase64Url(new Uint8Array(encryptedPayload.cipherText));
279 | base64EncryptedPayload.should.equal(VALID_OUTPUT.payload);
280 | });
281 |
282 | it('should use default vapid certs', async () => {
283 | const encryptionHelper = new EncryptionAESGCM();
284 | const vapidKeys = encryptionHelper.getVapidKeys();
285 | vapidKeys.publicKey.should.equal(APPLICATION_KEYS.publicKey);
286 | vapidKeys.privateKey.should.equal(APPLICATION_KEYS.privateKey);
287 | });
288 |
289 | it('should accept valid input VAPID certificates', async () => {
290 | const encryptionHelper = new EncryptionAESGCM({
291 | vapidKeys: VALID_VAPID_KEYS,
292 | });
293 | const vapidKeys = encryptionHelper.getVapidKeys();
294 | vapidKeys.publicKey.should.equal(VALID_VAPID_KEYS.publicKey);
295 | vapidKeys.privateKey.should.equal(VALID_VAPID_KEYS.privateKey);
296 | });
297 | });
298 |
--------------------------------------------------------------------------------
/test/TODO/end-to-end.js:
--------------------------------------------------------------------------------
1 | /*
2 | Copyright 2016 Google Inc. All Rights Reserved.
3 |
4 | Licensed under the Apache License, Version 2.0 (the "License");
5 | you may not use this file except in compliance with the License.
6 | You may obtain a copy of the License at
7 |
8 | http://www.apache.org/licenses/LICENSE-2.0
9 |
10 | Unless required by applicable law or agreed to in writing, software
11 | distributed under the License is distributed on an "AS IS" BASIS,
12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | See the License for the specific language governing permissions and
14 | limitations under the License.
15 | */
16 |
17 | // TODO: Figure out if this can be salvaged.
18 |
19 | 'use strict';
20 |
21 | // These tests make use of selenium-webdriver. You can find the relevant
22 | // documentation here: http://selenium.googlecode.com/git/docs/api/javascript/index.html
23 |
24 | require('chai').should();
25 | const fs = require('fs');
26 | const del = require('del');
27 | const path = require('path');
28 | const mkdirp = require('mkdirp');
29 | const exec = require('child_process').exec;
30 | const seleniumAssistant = require('selenium-assistant');
31 | const SWTestingHelpers = require('sw-testing-helpers');
32 | const TestServer = SWTestingHelpers.TestServer;
33 | const mochaUtils = SWTestingHelpers.mochaUtils;
34 | const seleniumFirefox = require('selenium-webdriver/firefox');
35 |
36 | async function getNotificationInfo(driver) {
37 | await driver.wait(function() {
38 | return driver.executeAsyncScript(async (...args) => {
39 | const registration = await navigator.serviceWorker.getRegistration();
40 | const notifications = await registration.getNotifications();
41 |
42 | const cb = args[args.length - 1];
43 | cb(notifications.length > 0);
44 | }, 2000);
45 | });
46 |
47 | return driver.executeAsyncScript(async (...args) => {
48 | const cb = args[args.length - 1];
49 | const registration = await navigator.serviceWorker.getRegistration();
50 | const notifications = await registration.getNotifications();
51 | const notificationInfo = [];
52 | notifications.forEach((notification) => {
53 | notificationInfo.push({
54 | title: notification.title,
55 | body: notification.body,
56 | icon: notification.icon,
57 | tag: notification.tag,
58 | });
59 |
60 | notification.close();
61 | });
62 | cb(notificationInfo);
63 | }, 2000);
64 | }
65 |
66 | describe('Test simple-push-demo', function() {
67 | // Browser tests can be slow
68 | this.timeout(60000);
69 | // Add retries as end to end tests are error prone
70 | if (process.env.TRAVIS) {
71 | this.retries(3);
72 | } else {
73 | this.retries(0);
74 | }
75 |
76 | let testServer;
77 | let testServerURL;
78 |
79 | before(async () => {
80 | testServer = new TestServer();
81 | const portNumber = await testServer.startServer(path.join(__dirname, '..'));
82 | testServerURL = `http://localhost:${portNumber}`;
83 | });
84 |
85 | after(function() {
86 | testServer.killServer();
87 | });
88 |
89 | const queueUnitTest = (browserInfo) => {
90 | describe(`Perform Tests in ${browserInfo.getPrettyName()}`, function() {
91 | // Driver is initialised to null to handle scenarios
92 | // where the desired browser isn't installed / fails to load
93 | // Null allows afterEach a safe way to skip quiting the driver
94 | let globalDriverReference = null;
95 | const PAYLOAD_TEST = 'Hello, world!';
96 |
97 | async function initDriver() {
98 | // Enable Notifications
99 | switch (browserInfo.getId()) {
100 | case 'firefox': {
101 | // This is based off of: https://bugzilla.mozilla.org/show_bug.cgi?id=1275521
102 | // Unfortunately it doesn't seem to work :(
103 | const ffOpts = new seleniumFirefox.Options();
104 | ffOpts.setPreference('security.turn_off_all_security_so_that_' +
105 | 'viruses_can_take_over_this_computer', true);
106 | ffOpts.setPreference('dom.push.testing.ignorePermission', true);
107 | ffOpts.setPreference('notification.prompt.testing', true);
108 | ffOpts.setPreference('notification.prompt.testing.allow', true);
109 | const builder = await browserInfo.getSeleniumDriverBuilder();
110 | builder.setFirefoxOptions(ffOpts);
111 | // browserInfo.setSeleniumOptions(ffOpts);
112 | break;
113 | }
114 | case 'opera': {
115 | /* eslint-disable camelcase */
116 | const operaPreferences = {
117 | profile: {
118 | content_settings: {
119 | exceptions: {
120 | notifications: {},
121 | },
122 | },
123 | },
124 | };
125 | operaPreferences.profile.content_settings.exceptions
126 | .notifications[testServerURL + ',*'] = {
127 | last_used: 1464967088.793686,
128 | setting: [1, 1464967088.793686],
129 | };
130 |
131 | // Write to file
132 | const tempPreferenceFile = './test/output/temp/opera';
133 | mkdirp.sync(tempPreferenceFile);
134 |
135 | fs.writeFileSync(`${tempPreferenceFile}/Preferences`, JSON.stringify(operaPreferences));
136 | /* eslint-enable camelcase */
137 | const options = browserInfo.getSeleniumOptions();
138 | options.addArguments(`user-data-dir=${tempPreferenceFile}/`);
139 | break;
140 | }
141 | case 'chrome': {
142 | /* eslint-disable camelcase */
143 | const chromePreferences = {
144 | profile: {
145 | content_settings: {
146 | exceptions: {
147 | notifications: {},
148 | },
149 | },
150 | },
151 | };
152 | chromePreferences.profile.content_settings.
153 | exceptions.notifications[testServerURL + ',*'] = {
154 | setting: 1,
155 | };
156 | browserInfo.getSeleniumOptions().setUserPreferences(chromePreferences);
157 | /* eslint-enable camelcase */
158 | break;
159 | }
160 | }
161 |
162 | const driver = await browserInfo.getSeleniumDriver();
163 | try {
164 | if (driver.manager && driver.manager().timeouts) {
165 | await driver.manage().timeouts().setScriptTimeout(2000);
166 | }
167 | } catch (err) {
168 | if (browserInfo.getId() === 'firefox' && browserInfo.getVersionNumber() === 56) {
169 | // See: https://github.com/mozilla/geckodriver/issues/800
170 | console.warn('Swallowing setScriptTimeoutError() <- Geckodriver issue.');
171 | } else {
172 | throw err;
173 | }
174 | }
175 | globalDriverReference = driver;
176 | }
177 |
178 | afterEach(async () => {
179 | this.timeout(10000);
180 |
181 | await seleniumAssistant.killWebDriver(globalDriverReference);
182 | await del('./test/output/');
183 | });
184 |
185 | it(`should pass all browser tests`, async () => {
186 | await initDriver();
187 |
188 | const testResults = await mochaUtils.startWebDriverMochaTests(
189 | browserInfo.getPrettyName(),
190 | globalDriverReference,
191 | `${testServerURL}/test/browser-tests/`,
192 | );
193 |
194 | if (testResults.failed.length > 0) {
195 | const errorMessage = mochaUtils.prettyPrintErrors(
196 | browserInfo.prettyName,
197 | testResults,
198 | );
199 |
200 | throw new Error(errorMessage);
201 | }
202 | });
203 |
204 | it(`should pass sanity checks and be able to trigger and receive a tickle`, async () => {
205 | // Load simple push demo page
206 | await initDriver();
207 |
208 | await globalDriverReference.get(`${testServerURL}/build/`);
209 |
210 | await globalDriverReference.wait(function() {
211 | return globalDriverReference.executeScript(function() {
212 | return document.body.dataset.simplePushDemoLoaded;
213 | });
214 | });
215 |
216 | await globalDriverReference.wait(function() {
217 | return globalDriverReference.executeScript(function() {
218 | const toggleSwitch = document.querySelector('.js-push-toggle-switch > input');
219 | return toggleSwitch.disabled === false;
220 | });
221 | });
222 |
223 | // Check for network errors
224 | const performanceEntriesString = await globalDriverReference.executeScript(function() {
225 | /* eslint-env browser */
226 | if (!window.performance) {
227 | return null;
228 | }
229 |
230 | return JSON.stringify(window.performance.getEntries());
231 | });
232 |
233 | const requiredFiles = [
234 | '/scripts/app-controller.js',
235 | '/styles/main.css',
236 | ];
237 | const performanceEntries = JSON.parse(performanceEntriesString);
238 | performanceEntries.forEach((entry) => {
239 | requiredFiles.forEach((requiredFile) => {
240 | if (entry.name.indexOf(requiredFile) === (entry.name.length - requiredFile.length)) {
241 | requiredFiles.splice(requiredFiles.indexOf(requiredFile), 1);
242 | }
243 | });
244 | });
245 |
246 | if (requiredFiles.length !== 0) {
247 | throw new Error('Missing required files in the final page', requiredFiles);
248 | }
249 |
250 | await globalDriverReference.wait(function() {
251 | return globalDriverReference.executeScript(function() {
252 | const toggleSwitch = document.querySelector('.js-push-toggle-switch > input');
253 | return toggleSwitch.disabled === false;
254 | });
255 | });
256 |
257 | await globalDriverReference.wait(function() {
258 | return globalDriverReference.executeScript(function() {
259 | const toggleSwitch = document.querySelector('.js-push-toggle-switch > input');
260 | if (toggleSwitch.disabled === false && toggleSwitch.checked) {
261 | return true;
262 | }
263 | toggleSwitch.click();
264 | return false;
265 | });
266 | });
267 |
268 | // Click XHR Button
269 | await globalDriverReference.executeScript(function() {
270 | const pushButton = document.querySelector('.js-send-push-button');
271 | pushButton.click();
272 | });
273 |
274 | const notificationInfo = await getNotificationInfo(globalDriverReference);
275 |
276 | notificationInfo.length.should.equal(1);
277 | notificationInfo[0].title.should.equal('Hello');
278 | notificationInfo[0].body.should.equal('Thanks for sending this push msg.');
279 | notificationInfo[0].tag.should.equal('simple-push-demo-notification');
280 |
281 | // Chrome adds the origin, FF doesn't
282 | const notifcationImg = '/images/logo-192x192.png';
283 | notificationInfo[0].icon.indexOf(notifcationImg).should.equal(notificationInfo[0].icon.length - notifcationImg.length);
284 | });
285 |
286 | it(`should be able to trigger and receive a tickle via CURL`, async () => {
287 | // Load simple push demo page
288 | await initDriver();
289 |
290 | await globalDriverReference.get(`${testServerURL}/build/`);
291 |
292 | await globalDriverReference.wait(function() {
293 | return globalDriverReference.executeScript(function() {
294 | return document.body.dataset.simplePushDemoLoaded;
295 | });
296 | });
297 |
298 | await globalDriverReference.wait(function() {
299 | return globalDriverReference.executeScript(function() {
300 | const toggleSwitch = document.querySelector('.js-push-toggle-switch > input');
301 | return toggleSwitch.disabled === false;
302 | });
303 | });
304 |
305 | await globalDriverReference.wait(function() {
306 | return globalDriverReference.executeScript(function() {
307 | const toggleSwitch = document.querySelector('.js-push-toggle-switch > input');
308 | if (toggleSwitch.disabled === false && toggleSwitch.checked) {
309 | return true;
310 | }
311 | toggleSwitch.click();
312 | return false;
313 | });
314 | });
315 |
316 | await globalDriverReference.wait(function() {
317 | return globalDriverReference.executeScript(function() {
318 | const curlCodeElement = document.querySelector('.js-curl-code');
319 | return curlCodeElement.textContent.length > 0;
320 | });
321 | });
322 |
323 | // Check curl command exists
324 | const curlCommand = await globalDriverReference.executeScript(function() {
325 | const curlCodeElement = document.querySelector('.js-curl-code');
326 | return curlCodeElement.textContent;
327 | });
328 |
329 | curlCommand.length.should.be.above(0);
330 |
331 | // Need to use the curl command
332 | await new Promise((resolve, reject) => {
333 | exec(curlCommand, (error, stdout) => {
334 | if (error !== null) {
335 | return reject(error);
336 | }
337 |
338 | if (stdout) {
339 | const gcmResponse = JSON.parse(stdout);
340 | if (gcmResponse.failure === 0) {
341 | resolve();
342 | } else {
343 | reject(new Error('Bad GCM Response: ' + stdout));
344 | }
345 | } else {
346 | resolve();
347 | }
348 | });
349 | });
350 |
351 | const notificationInfo = await getNotificationInfo(globalDriverReference);
352 |
353 | notificationInfo.length.should.equal(1);
354 | notificationInfo[0].title.should.equal('Hello');
355 | notificationInfo[0].body.should.equal('Thanks for sending this push msg.');
356 | notificationInfo[0].tag.should.equal('simple-push-demo-notification');
357 |
358 | // Chrome adds the origin, FF doesn't
359 | const notifcationImg = '/images/logo-192x192.png';
360 | notificationInfo[0].icon.indexOf(notifcationImg).should.equal(notificationInfo[0].icon.length - notifcationImg.length);
361 | });
362 |
363 | it(`should be able to enter payload text and receive a push message in ${browserInfo.getPrettyName()}`, async () => {
364 | // Load simple push demo page
365 | await initDriver();
366 |
367 | await globalDriverReference.get(`${testServerURL}/build/`);
368 |
369 | await globalDriverReference.wait(function() {
370 | return globalDriverReference.executeScript(function() {
371 | return document.body.dataset.simplePushDemoLoaded;
372 | });
373 | });
374 |
375 | await globalDriverReference.wait(function() {
376 | return globalDriverReference.executeScript(function() {
377 | const toggleSwitch = document.querySelector('.js-push-toggle-switch > input');
378 | return toggleSwitch.disabled === false;
379 | });
380 | });
381 |
382 | await globalDriverReference.wait(function() {
383 | return globalDriverReference.executeScript(function() {
384 | const toggleSwitch = document.querySelector('.js-push-toggle-switch > input');
385 | if (toggleSwitch.disabled === false && toggleSwitch.checked) {
386 | return true;
387 | }
388 | toggleSwitch.click();
389 | return false;
390 | });
391 | });
392 |
393 | // Add Payload text
394 | await globalDriverReference.executeScript(function(payloadText) {
395 | const textfield = document.querySelector('.js-payload-textfield');
396 | textfield.value = payloadText;
397 |
398 | // This triggers the logic to hide / display options for
399 | // triggering push messages
400 | textfield.oninput();
401 | }, PAYLOAD_TEST);
402 |
403 | // Attempt to trigger push via fetch button
404 | await globalDriverReference.executeScript(function() {
405 | const pushButton = document.querySelector('.js-send-push-button');
406 | pushButton.click();
407 | });
408 |
409 | const notificationInfo = await getNotificationInfo(globalDriverReference);
410 |
411 | notificationInfo.length.should.equal(1);
412 | notificationInfo[0].title.should.equal('Received Payload');
413 | notificationInfo[0].body.should.equal(`Push data: '${PAYLOAD_TEST}'`);
414 | notificationInfo[0].tag.should.equal('simple-push-demo-notification');
415 |
416 | // Chrome adds the origin, FF doesn't
417 | const notifcationImg = '/images/logo-192x192.png';
418 | notificationInfo[0].icon.indexOf(notifcationImg).should.equal(notificationInfo[0].icon.length - notifcationImg.length);
419 | });
420 |
421 | it(`should be able to trigger and receive a message with payload via CURL or unless no CURL command is shown`, async () => {
422 | // Load simple push demo page
423 | await initDriver();
424 |
425 | await globalDriverReference.get(`${testServerURL}/build/`);
426 |
427 | await globalDriverReference.wait(function() {
428 | return globalDriverReference.executeScript(function() {
429 | return document.body.dataset.simplePushDemoLoaded;
430 | });
431 | });
432 |
433 | await globalDriverReference.wait(function() {
434 | return globalDriverReference.executeScript(function() {
435 | const toggleSwitch = document.querySelector('.js-push-toggle-switch > input');
436 | return toggleSwitch.disabled === false;
437 | });
438 | });
439 |
440 | // Toggle subscription switch
441 | await globalDriverReference.executeScript(function() {
442 | /* eslint-env browser */
443 | const toggleSwitch = document.querySelector('.js-push-toggle-switch > input');
444 | if (!toggleSwitch.checked) {
445 | toggleSwitch.click();
446 | }
447 | });
448 |
449 | await globalDriverReference.wait(function() {
450 | return globalDriverReference.executeScript(function() {
451 | const toggleSwitch = document.querySelector('.js-push-toggle-switch > input');
452 | return toggleSwitch.disabled === false && toggleSwitch.checked;
453 | });
454 | });
455 |
456 | // Add Payload text
457 | await globalDriverReference.executeScript(function(payloadText) {
458 | const textfield = document.querySelector('.js-payload-textfield');
459 | textfield.value = payloadText;
460 |
461 | // This triggers the logic to hide / display options for
462 | // triggering push messages
463 | textfield.oninput();
464 | }, PAYLOAD_TEST);
465 |
466 | await new Promise((resolve) => {
467 | // Slight timeout to ensure the payload is updated on Travis
468 | setTimeout(resolve, 500);
469 | });
470 |
471 | await globalDriverReference.wait(function() {
472 | return globalDriverReference.executeScript(function() {
473 | const curlCodeElement = document.querySelector('.js-curl-code');
474 | return curlCodeElement.textContent.length > 0;
475 | });
476 | });
477 |
478 | // Check curl command exists
479 | const curlCommand = await globalDriverReference.executeScript(function() {
480 | const curlCodeElement = document.querySelector('.js-curl-code');
481 | if (curlCodeElement.style.display === 'none') {
482 | return '';
483 | }
484 |
485 | return curlCodeElement.textContent;
486 | });
487 |
488 | if (curlCommand.length > 0) {
489 | // Need to use the curl command
490 | await new Promise((resolve, reject) => {
491 | exec(curlCommand, (error, stdout) => {
492 | if (error !== null) {
493 | return reject(error);
494 | }
495 |
496 | if (stdout) {
497 | const gcmResponse = JSON.parse(stdout);
498 | if (gcmResponse.failure === 0) {
499 | resolve();
500 | } else {
501 | reject(new Error('Bad GCM Response: ' + stdout));
502 | }
503 | } else {
504 | resolve();
505 | }
506 | });
507 | });
508 |
509 | const notificationInfo = await getNotificationInfo(globalDriverReference);
510 |
511 | notificationInfo.length.should.equal(1);
512 | notificationInfo[0].title.should.equal('Received Payload');
513 | notificationInfo[0].body.should.equal(`Push data: '${PAYLOAD_TEST}'`);
514 | notificationInfo[0].tag.should.equal('simple-push-demo-notification');
515 |
516 | // Chrome adds the origin, FF doesn't
517 | const notifcationImg = '/images/logo-192x192.png';
518 | notificationInfo[0].icon.indexOf(notifcationImg).should.equal(notificationInfo[0].icon.length - notifcationImg.length);
519 | }
520 | });
521 | });
522 | };
523 |
524 | seleniumAssistant.printAvailableBrowserInfo();
525 | const browsers = seleniumAssistant.getLocalBrowsers();
526 | browsers.forEach((browserInfo) => {
527 | if (browserInfo.getId() === 'opera') {
528 | // Opera has no feature detect for push support, so bail
529 | return;
530 | }
531 |
532 | if (browserInfo.getId() === 'safari') {
533 | // Safari not supported at the moment
534 | return;
535 | }
536 |
537 | if (browserInfo.getId() === 'firefox') {
538 | // Firefox returns the following:
539 | // The notification permission may only be requested in a secure context.
540 | return;
541 | }
542 |
543 | queueUnitTest(browserInfo);
544 | });
545 | });
546 |
--------------------------------------------------------------------------------