Skip to content
Open
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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@superhuman/push-receiver",
"version": "2.1.5",
"version": "2.1.6",
"description":
"A module to subscribe to GCM/FCM and receive notifications within a node process.",
"main": "src/index.js",
Expand Down
4 changes: 2 additions & 2 deletions src/gcm/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ const { toBase64 } = require('../utils/base64');

// Hack to fix PHONE_REGISTRATION_ERROR #17 when bundled with webpack
// https://github.com/dcodeIO/protobuf.js#browserify-integration
protobuf.util.Long = Long
protobuf.configure()
protobuf.util.Long = Long;
protobuf.configure();

const serverKey = toBase64(Buffer.from(fcmKey));

Expand Down
76 changes: 65 additions & 11 deletions src/utils/decrypt/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,75 @@ 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');
// The 'crypto-key is missing'/'salt is missing' prefixes keep these errors
// on the caller's drop-and-ack path instead of an unhandled throw.
if (!dh) {
throw new Error(
`crypto-key is missing its dh parameter (params: ${paramNames(
cryptoKey
)})`
);
}
const saltValue = namedParam(salt, 'salt');
if (!saltValue) {
throw new Error(
`salt is missing from the encryption value (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);
}
173 changes: 173 additions & 0 deletions test/decrypt.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
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 is missing its dh parameter (params: p256ecdsa)'
);
expect(error.message).not.toContain(vapidKey);
// Matches the substring Client uses to drop undecryptable messages.
expect(error.message).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(
'salt is missing from the encryption value (params: ?)'
);
expect(error.message).toContain('salt is missing');
});
});