Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 7 additions & 8 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,17 +1,14 @@
{
"name": "@superhuman/push-receiver",
"version": "2.1.5",
"description":
"A module to subscribe to GCM/FCM and receive notifications within a node process.",
"version": "2.1.7",
"description": "A module to subscribe to GCM/FCM and receive notifications within a node process.",
"main": "src/index.js",
"scripts": {
"start": "node scripts/listen",
"register": "node scripts/register",
"send": "node scripts/send",
"pretty":
"prettier-eslint --single-quote --trailing-comma es5 --write \"**/*.js\" \"**/*.json\"",
"pretty:check":
"prettier-eslint --single-quote --trailing-comma es5 --list-different --log-level silent \"**/*.js\" \"**/*.json\"",
"pretty": "prettier-eslint --single-quote --trailing-comma es5 --write \"**/*.js\" \"**/*.json\"",
"pretty:check": "prettier-eslint --single-quote --trailing-comma es5 --list-different --log-level silent \"**/*.js\" \"**/*.json\"",
"lint": "eslint 'src/**/*.js'",
"lint:fix": "eslint 'src/**/*.js' --fix",
"test": "jest",
Expand All @@ -22,7 +19,9 @@
"eslint --fix",
"prettier-eslint --single-quote --trailing-comma es5 --write"
],
"*.json": ["prettier-eslint --single-quote --trailing-comma es5 --write"]
"*.json": [
"prettier-eslint --single-quote --trailing-comma es5 --write"
]
},
"repository": {
"type": "git",
Expand Down
2 changes: 2 additions & 0 deletions src/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -219,5 +219,7 @@ function isReportableDecryptionError(error) {
'Unsupported state or unable to authenticate data',
'crypto-key is missing',
'salt is missing',
'has no dh parameter',
'has no salt parameter',
].some(message => error.message.includes(message));
}
75 changes: 64 additions & 11 deletions src/utils/decrypt/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,74 @@ const ece = require('http_ece');

module.exports = decrypt;

// https://tools.ietf.org/html/draft-ietf-webpush-encryption-03
// Web Push header values can carry several entries separated by ',' with
// parameters separated by ';' (e.g. `dh=<key>;p256ecdsa=<key>`).
function namedParam(value, name) {
const match = value
.split(/[;,]/)
.map(param => param.trim())
.find(param => param.startsWith(`${name}=`));
return match ? match.slice(name.length + 1) : null;
}

// Parameter names only — values may be key material and must never appear
// in error messages.
function paramNames(value) {
return value
.split(/[;,]/)
.map(param => (param.includes('=') ? param.split('=')[0].trim() : '?'))
.join(';');
}

function appDataValue(object, key) {
const entry = object.appData.find(item => item.key === key);
return entry ? entry.value : null;
}

// https://tools.ietf.org/html/draft-ietf-webpush-encryption-03 (aesgcm)
// https://tools.ietf.org/html/rfc8291 (aes128gcm)
function decrypt(object, keys) {
const cryptoKey = object.appData.find(item => item.key === 'crypto-key');
const receiver = crypto.createECDH('prime256v1');
receiver.setPrivateKey(keys.privateKey, 'base64');

// In aes128gcm the salt and sender public key travel in the payload's
// binary header rather than in appData values.
if (appDataValue(object, 'content-encoding') === 'aes128gcm') {
const decrypted = ece.decrypt(object.rawData, {
version : 'aes128gcm',
authSecret : keys.authSecret,
privateKey : receiver,
});
return JSON.parse(decrypted);
}

const cryptoKey = appDataValue(object, 'crypto-key');
if (!cryptoKey) throw new Error('crypto-key is missing');
const salt = object.appData.find(item => item.key === 'encryption');
const salt = appDataValue(object, 'encryption');
if (!salt) throw new Error('salt is missing');
const dh = crypto.createECDH('prime256v1');
dh.setPrivateKey(keys.privateKey, 'base64');
const params = {

const dh = namedParam(cryptoKey, 'dh');
// Distinct from the bare 'crypto-key is missing'/'salt is missing' above:
// those mark plaintext control stanzas, while these mark encrypted messages
// whose parameters we could not parse. Client treats both as reportable.
if (!dh) {
throw new Error(
`crypto-key has no dh parameter (params: ${paramNames(cryptoKey)})`
);
}
const saltValue = namedParam(salt, 'salt');
if (!saltValue) {
throw new Error(
`encryption has no salt parameter (params: ${paramNames(salt)})`
);
}

const decrypted = ece.decrypt(object.rawData, {
version : 'aesgcm',
authSecret : keys.authSecret,
dh : cryptoKey.value.slice(3),
privateKey : dh,
salt : salt.value.slice(5),
};
const decrypted = ece.decrypt(object.rawData, params);
dh : dh,
privateKey : receiver,
salt : saltValue,
});
return JSON.parse(decrypted);
}
175 changes: 175 additions & 0 deletions test/decrypt.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
const crypto = require('crypto');
const ece = require('http_ece');
const decrypt = require('../src/utils/decrypt');

const PAYLOAD = { title : 'Hello', body : 'World' };

function base64(buffer) {
return buffer.toString('base64');
}

function base64Url(buffer) {
return base64(buffer)
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
}

function makeReceiver() {
const receiver = crypto.createECDH('prime256v1');
receiver.generateKeys();
return {
receiver,
keys : {
privateKey : base64(receiver.getPrivateKey()),
authSecret : base64(crypto.randomBytes(16)),
},
};
}

function encryptAesGcm(receiver, authSecret) {
const sender = crypto.createECDH('prime256v1');
sender.generateKeys();
const salt = crypto.randomBytes(16);
const rawData = ece.encrypt(Buffer.from(JSON.stringify(PAYLOAD)), {
version : 'aesgcm',
dh : base64Url(receiver.getPublicKey()),
privateKey : sender,
salt : base64Url(salt),
authSecret : authSecret,
});
return { rawData, senderPublicKey : sender.getPublicKey(), salt };
}

function envelope(rawData, appData) {
return { persistentId : 'persistent-id', rawData, appData };
}

describe('decrypt', () => {
it('decrypts a single-parameter aesgcm envelope', () => {
const { receiver, keys } = makeReceiver();
const message = encryptAesGcm(receiver, keys.authSecret);

const decrypted = decrypt(
envelope(message.rawData, [
{ key : 'crypto-key', value : `dh=${base64(message.senderPublicKey)}` },
{ key : 'encryption', value : `salt=${base64(message.salt)}` },
]),
keys
);

expect(decrypted).toEqual(PAYLOAD);
});

it('decrypts when crypto-key carries extra semicolon-separated parameters', () => {
const { receiver, keys } = makeReceiver();
const message = encryptAesGcm(receiver, keys.authSecret);
const vapidKey = base64Url(crypto.randomBytes(65));

const decrypted = decrypt(
envelope(message.rawData, [
{
key : 'crypto-key',
value : `dh=${base64(message.senderPublicKey)};p256ecdsa=${vapidKey}`,
},
{ key : 'encryption', value : `salt=${base64(message.salt)}` },
{ key : 'content-encoding', value : 'aesgcm' },
]),
keys
);

expect(decrypted).toEqual(PAYLOAD);
});

it('decrypts when entries are comma-separated and reordered', () => {
const { receiver, keys } = makeReceiver();
const message = encryptAesGcm(receiver, keys.authSecret);
const vapidKey = base64Url(crypto.randomBytes(65));

const decrypted = decrypt(
envelope(message.rawData, [
{
key : 'crypto-key',
value : `p256ecdsa=${vapidKey},dh=${base64(message.senderPublicKey)}`,
},
{
key : 'encryption',
value : `keyid=p256dh;salt=${base64(message.salt)}`,
},
]),
keys
);

expect(decrypted).toEqual(PAYLOAD);
});

it('decrypts an aes128gcm envelope with keys in the binary header', () => {
const { receiver, keys } = makeReceiver();
const sender = crypto.createECDH('prime256v1');
sender.generateKeys();
const rawData = ece.encrypt(Buffer.from(JSON.stringify(PAYLOAD)), {
version : 'aes128gcm',
dh : base64Url(receiver.getPublicKey()),
privateKey : sender,
salt : base64Url(crypto.randomBytes(16)),
authSecret : keys.authSecret,
});

const decrypted = decrypt(
envelope(rawData, [{ key : 'content-encoding', value : 'aes128gcm' }]),
keys
);

expect(decrypted).toEqual(PAYLOAD);
});

it('reports parameter names but never values when dh is absent', () => {
const { keys } = makeReceiver();
const vapidKey = base64Url(crypto.randomBytes(65));

let error;
try {
decrypt(
envelope(Buffer.from('00', 'hex'), [
{ key : 'crypto-key', value : `p256ecdsa=${vapidKey}` },
{ key : 'encryption', value : 'salt=c2FsdHNhbHRzYWx0c2FsdA==' },
]),
keys
);
} catch (e) {
error = e;
}

expect(error.message).toBe(
'crypto-key has no dh parameter (params: p256ecdsa)'
);
expect(error.message).not.toContain(vapidKey);
// Matches Client's isReportableDecryptionError list, so the message is
// acked instead of redelivered. Must NOT contain 'crypto-key is missing',
// which consumers treat as an expected plaintext control stanza.
expect(error.message).toContain('has no dh parameter');
expect(error.message).not.toContain('crypto-key is missing');
});

it('reports a missing salt parameter without echoing values', () => {
const { receiver, keys } = makeReceiver();
const message = encryptAesGcm(receiver, keys.authSecret);

let error;
try {
decrypt(
envelope(message.rawData, [
{ key : 'crypto-key', value : `dh=${base64(message.senderPublicKey)}` },
{ key : 'encryption', value : base64Url(message.salt) },
]),
keys
);
} catch (e) {
error = e;
}

expect(error.message).toBe('encryption has no salt parameter (params: ?)');
expect(error.message).toContain('has no salt parameter');
expect(error.message).not.toContain('salt is missing');
});
});