diff --git a/package.json b/package.json index 9075f3b..e086215 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/gcm/index.js b/src/gcm/index.js index 9260129..940082f 100644 --- a/src/gcm/index.js +++ b/src/gcm/index.js @@ -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)); diff --git a/src/utils/decrypt/index.js b/src/utils/decrypt/index.js index b06a219..2edf29a 100644 --- a/src/utils/decrypt/index.js +++ b/src/utils/decrypt/index.js @@ -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=;p256ecdsa=`). +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); } diff --git a/test/decrypt.test.js b/test/decrypt.test.js new file mode 100644 index 0000000..d50664a --- /dev/null +++ b/test/decrypt.test.js @@ -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'); + }); +});