From b3d58000ff603c13201a7a99c7e648b8a6ba7a85 Mon Sep 17 00:00:00 2001 From: Cassio Sales Date: Mon, 22 Jun 2026 21:07:24 -0300 Subject: [PATCH 1/2] 2.1.6 --- package.json | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/package.json b/package.json index 9075f3b..40cc676 100644 --- a/package.json +++ b/package.json @@ -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.6", + "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", @@ -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", From 87395486756f137b70c8a51d998dc6a6c57a5da6 Mon Sep 17 00:00:00 2001 From: Cassio Sales Date: Fri, 12 Jun 2026 17:30:14 -0300 Subject: [PATCH 2/2] Parse Web Push params by name and support aes128gcm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Since 2026-06-04 a fraction of FCM deliveries carried crypto-key values with multiple parameters (dh=;p256ecdsa=). Slicing fixed offsets feeds garbage into ECDH, so decryption throws "Public key is not valid for specified curve" and the push is lost and redelivered on every reconnect. Extract dh/salt by name instead, accepting ';' and ',' separators in any order. Google reverted the format around 2026-06-24, but the parser should be immune before any re-rollout. Parameter-parse failures throw messages that name the parameters present (never their values, which may be key material) and are listed as reportable so Client acks them instead of redelivering. They are deliberately distinct from the bare 'crypto-key is missing' and 'salt is missing' control-stanza messages that consumers classify as expected traffic. Also decrypt aes128gcm (RFC 8291) envelopes, where keys travel in the payload's binary header — envelopes captured during the June incident already carried a content-encoding field, suggesting that migration is being staged. Bump version to 2.1.7 (2.1.6 shipped the error-eventing release). CLI-801 Co-Authored-By: Claude Fable 5 --- package.json | 2 +- src/client.js | 2 + src/utils/decrypt/index.js | 75 +++++++++++++--- test/decrypt.test.js | 175 +++++++++++++++++++++++++++++++++++++ 4 files changed, 242 insertions(+), 12 deletions(-) create mode 100644 test/decrypt.test.js diff --git a/package.json b/package.json index 40cc676..9a71451 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@superhuman/push-receiver", - "version": "2.1.6", + "version": "2.1.7", "description": "A module to subscribe to GCM/FCM and receive notifications within a node process.", "main": "src/index.js", "scripts": { diff --git a/src/client.js b/src/client.js index 6b992dc..a36e90d 100644 --- a/src/client.js +++ b/src/client.js @@ -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)); } diff --git a/src/utils/decrypt/index.js b/src/utils/decrypt/index.js index b06a219..612c312 100644 --- a/src/utils/decrypt/index.js +++ b/src/utils/decrypt/index.js @@ -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=;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'); + // 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); } diff --git a/test/decrypt.test.js b/test/decrypt.test.js new file mode 100644 index 0000000..61a1367 --- /dev/null +++ b/test/decrypt.test.js @@ -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'); + }); +});