Skip to content

Commit

Permalink
Merge pull request #218 from gauntface/new-send-notification
Browse files Browse the repository at this point in the history
New send notification
  • Loading branch information
marco-c authored Sep 20, 2016
2 parents c069a22 + 3d74337 commit 2fcd717
Show file tree
Hide file tree
Showing 15 changed files with 1,230 additions and 797 deletions.
6 changes: 4 additions & 2 deletions bin/web-push.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ webPush.setGCMAPIKey(process.env.GCM_API_KEY);

const argv = require('minimist')(process.argv.slice(2));

const usage = 'Use: web-push --endpoint=<url> --key=<browser key> [--auth=<auth secret>] [--ttl=<seconds>] [--payload=<message>] [--vapid-audience] [--vapid-subject] [--vapid-pvtkey] [--vapid-pubkey]';
const usage = 'Use: web-push --endpoint=<url> --key=<browser key> ' +
'[--auth=<auth secret>] [--ttl=<seconds>] [--payload=<message>] ' +
'[--vapid-audience] [--vapid-subject] [--vapid-pvtkey] [--vapid-pubkey]';

if (!argv.endpoint || !argv.key) {
console.log(usage);
Expand All @@ -17,6 +19,7 @@ const key = argv.key;
const ttl = argv.ttl || 0;
const payload = argv.payload || '';
const auth = argv.auth || null;

const vapidAudience = argv['vapid-audience'] || null;
const vapidSubject = argv['vapid-subject'] || null;
const vapidPubKey = argv['vapid-pubkey'] || null;
Expand Down Expand Up @@ -63,4 +66,3 @@ webPush.sendNotification(endpoint, params).then(() => {
}).then(() => {
process.exit(0);
});

10 changes: 6 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,22 +42,24 @@
},
"devDependencies": {
"chalk": "^1.1.3",
"chromedriver": "^2.23.1",
"chromedriver": "^2.24.1",
"del": "^2.2.1",
"dmg": "^0.1.0",
"eslint": "^2.10.2",
"eslint-config-airbnb": "^9.0.1",
"eslint-plugin-import": "^1.11.1",
"fs-extra": "^0.30.0",
"geckodriver": "^1.1.2",
"istanbul": "^0.4.2",
"mkdirp": "^0.5.1",
"mocha": "^2.4.5",
"portfinder": "^1.0.2",
"request": "^2.69.0",
"selenium-assistant": "0.4.0",
"selenium-webdriver": "~2.53.2",
"selenium-assistant": "0.5.3",
"selenium-webdriver": "^3.0.0-beta-2",
"semver": "^5.1.0",
"temp": "^0.8.3"
"temp": "^0.8.3",
"which": "^1.2.11"
},
"engines": {
"node": ">= v0.10.0"
Expand Down
65 changes: 65 additions & 0 deletions src/encryption-helper.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
'use strict';

const crypto = require('crypto');
const ece = require('http_ece');
const urlBase64 = require('urlsafe-base64');

const encrypt = function(userPublicKey, userAuth, payload) {
if (!userPublicKey) {
throw new Error('No user public key provided for encryption.');
}

if (typeof userPublicKey !== 'string') {
throw new Error('The subscription p256dh value must be a string.');
}

if (urlBase64.decode(userPublicKey).length !== 65) {
throw new Error('The subscription p256dh value should be 65 bytes long.');
}

if (!userAuth) {
throw new Error('No user auth provided for encryption.');
}

if (typeof userAuth !== 'string') {
throw new Error('The subscription auth key must be a string.');
}

if (urlBase64.decode(userAuth).length < 16) {
throw new Error('The subscription auth key should be at least 16 ' +
'bytes long');
}

if (typeof payload !== 'string' && !Buffer.isBuffer(payload)) {
throw new Error('Payload must be either a string or a Node Buffer.');
}

if (typeof payload === 'string' || payload instanceof String) {
payload = new Buffer(payload);
}

const localCurve = crypto.createECDH('prime256v1');
const localPublicKey = localCurve.generateKeys();

const salt = urlBase64.encode(crypto.randomBytes(16));

ece.saveKey('webpushKey', localCurve, 'P-256');

const cipherText = ece.encrypt(payload, {
keyid: 'webpushKey',
dh: userPublicKey,
salt: salt,
authSecret: userAuth,
padSize: 2
});

return {
localPublicKey: localPublicKey,
salt: salt,
cipherText: cipherText
};
};

module.exports = {
encrypt: encrypt
};
272 changes: 10 additions & 262 deletions src/index.js
Original file line number Diff line number Diff line change
@@ -1,270 +1,18 @@
'use strict';

const urlBase64 = require('urlsafe-base64');
const crypto = require('crypto');
const ece = require('http_ece');
const url = require('url');
const https = require('https');
const asn1 = require('asn1.js');
const jws = require('jws');

const WebPushError = require('./web-push-error.js');
// This loads up shims required for older versions of node.
require('./shim');

const ECPrivateKeyASN = asn1.define('ECPrivateKey', function() {
this.seq().obj(
this.key('version').int(),
this.key('privateKey').octstr(),
this.key('parameters').explicit(0).objid()
.optional(),
this.key('publicKey').explicit(1).bitstr()
.optional()
);
});

function toPEM(key) {
return ECPrivateKeyASN.encode({
version: 1,
privateKey: key,
parameters: [1, 2, 840, 10045, 3, 1, 7] // prime256v1
}, 'pem', {
label: 'EC PRIVATE KEY'
});
}

function generateVAPIDKeys() {
const curve = crypto.createECDH('prime256v1');
curve.generateKeys();

return {
publicKey: curve.getPublicKey(),
privateKey: curve.getPrivateKey()
};
}

function getVapidHeaders(vapid) {
if (!vapid.audience) {
throw new Error('No audience set in vapid.audience');
}

if (!vapid.subject) {
throw new Error('No subject set in vapid.subject');
}

if (!vapid.publicKey) {
throw new Error('No key set vapid.publicKey');
}

if (!vapid.privateKey) {
throw new Error('No key set in vapid.privateKey');
}

const header = {
typ: 'JWT',
alg: 'ES256'
};

const jwtPayload = {
aud: vapid.audience,
exp: Math.floor(Date.now() / 1000) + 86400,
sub: vapid.subject
};

const jwt = jws.sign({
header: header,
payload: jwtPayload,
privateKey: toPEM(vapid.privateKey)
});

return {
Authorization: 'Bearer ' + jwt,
'Crypto-Key': 'p256ecdsa=' + urlBase64.encode(vapid.publicKey)
};
}

let gcmAPIKey = '';

function setGCMAPIKey(apiKey) {
if (!apiKey || typeof apiKey !== 'string') {
throw new Error('The GCM API Key should be a non-emtpy string.');
}

gcmAPIKey = apiKey;
}

// New standard, Firefox 46+ and Chrome 50+.
function encrypt(userPublicKey, userAuth, payload) {
if (typeof payload === 'string' || payload instanceof String) {
payload = new Buffer(payload);
}
const localCurve = crypto.createECDH('prime256v1');
const localPublicKey = localCurve.generateKeys();

const salt = urlBase64.encode(crypto.randomBytes(16));

ece.saveKey('webpushKey', localCurve, 'P-256');

const cipherText = ece.encrypt(payload, {
keyid: 'webpushKey',
dh: userPublicKey,
salt: salt,
authSecret: userAuth,
padSize: 2
});

return {
localPublicKey: localPublicKey,
salt: salt,
cipherText: cipherText
};
}

function sendNotification(endpoint, params) {
const args = arguments;

let curGCMAPIKey;
let TTL;
let userPublicKey;
let userAuth;
let payload;
let vapid;

return new Promise(function(resolve, reject) {
try {
curGCMAPIKey = gcmAPIKey;

if (args.length === 0) {
throw new Error('sendNotification requires at least one argument, ' +
'the endpoint URL.');
} else if (params && typeof params === 'object') {
TTL = params.TTL;
userPublicKey = params.userPublicKey;
userAuth = params.userAuth;
payload = params.payload;
vapid = params.vapid;

if (params.gcmAPIKey) {
curGCMAPIKey = params.gcmAPIKey;
}
} else if (args.length !== 1) {
throw new Error('You are using the old, deprecated, interface of ' +
'the `sendNotification` function.');
}

if (userPublicKey) {
if (typeof userPublicKey !== 'string') {
throw new Error('userPublicKey should be a base64-encoded string.');
} else if (urlBase64.decode(userPublicKey).length !== 65) {
throw new Error('userPublicKey should be 65 bytes long.');
}
}

if (userAuth) {
if (typeof userAuth !== 'string') {
throw new Error('userAuth should be a base64-encoded string.');
} else if (urlBase64.decode(userAuth).length < 16) {
throw new Error('userAuth should be at least 16 bytes long');
}
}

const urlParts = url.parse(endpoint);
const options = {
hostname: urlParts.hostname,
port: urlParts.port,
path: urlParts.pathname,
method: 'POST',
headers: {
'Content-Length': 0
}
};

let requestPayload;
if (typeof payload !== 'undefined') {
const encrypted = encrypt(userPublicKey, userAuth, payload);

options.headers = {
'Content-Type': 'application/octet-stream',
'Content-Encoding': 'aesgcm',
'Encryption': 'keyid=p256dh;salt=' + encrypted.salt
};

options.headers['Crypto-Key'] = 'keyid=p256dh;dh=' +
urlBase64.encode(encrypted.localPublicKey);

requestPayload = encrypted.cipherText;
}

const isGCM = endpoint.indexOf('https://android.googleapis.com/gcm/send') === 0;
if (isGCM) {
if (!curGCMAPIKey) {
console.warn('Attempt to send push notification to GCM endpoint, ' +
'but no GCM key is defined'.bold.red);
}

options.headers.Authorization = 'key=' + curGCMAPIKey;
}

if (vapid && !isGCM) {
// VAPID isn't supported by GCM.
vapid.audience = urlParts.protocol + '//' + urlParts.hostname;

const vapidHeaders = getVapidHeaders(vapid);

options.headers.Authorization = vapidHeaders.Authorization;
if (options.headers['Crypto-Key']) {
options.headers['Crypto-Key'] += ';' + vapidHeaders['Crypto-Key'];
} else {
options.headers['Crypto-Key'] = vapidHeaders['Crypto-Key'];
}
}

if (typeof TTL !== 'undefined') {
options.headers.TTL = TTL;
} else {
options.headers.TTL = 2419200; // Default TTL is four weeks.
}

if (requestPayload) {
options.headers['Content-Length'] = requestPayload.length;
}

const pushRequest = https.request(options, function(pushResponse) {
let body = '';

pushResponse.on('data', function(chunk) {
body += chunk;
});

pushResponse.on('end', function() {
if (pushResponse.statusCode !== 201) {
reject(new WebPushError('Received unexpected response code',
pushResponse.statusCode, pushResponse.headers, body));
} else {
resolve(body);
}
});
});

if (requestPayload) {
pushRequest.write(requestPayload);
}

pushRequest.end();
const vapidHelper = require('./vapid-helper.js');
const encryptionHelper = require('./encryption-helper.js');
const WebPushLib = require('./web-push-lib.js');

pushRequest.on('error', function(e) {
console.error(e);
reject(e);
});
} catch (e) {
reject(e);
}
});
}
const webPush = new WebPushLib();

module.exports = {
encrypt: encrypt,
sendNotification: sendNotification,
setGCMAPIKey: setGCMAPIKey,
WebPushError: WebPushError,
generateVAPIDKeys: generateVAPIDKeys
encrypt: encryptionHelper.encrypt,
generateVAPIDKeys: vapidHelper.generateVAPIDKeys,
setGCMAPIKey: webPush.setGCMAPIKey,
setVapidDetails: webPush.setVapidDetails,
sendNotification: webPush.sendNotification
};
Loading

0 comments on commit 2fcd717

Please sign in to comment.