├── 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 | ![Simple Push Demo title card with a paper airplane](./default-social.png) 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 |
21 |

Simple Push Demo

22 | 27 |
28 | 29 |
30 |
31 |
32 | 33 | 34 |
35 | Enable push notifications 36 |
37 | 38 |
39 |
40 | 45 | 46 | 47 |
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 | 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 | --------------------------------------------------------------------------------