Antes de Chrome 50, los mensajes push no podían contener datos de carga útil. Cuando se activó el evento “push” en tu service worker, todo lo que sabías era que el servidor intentaba decirte algo, pero no lo que podría ser. Luego, debes realizar una solicitud de seguimiento al servidor y obtener los detalles de la notificación para mostrarla, lo que podría fallar en condiciones de red deficientes.
Ahora, en Chrome 50 (y en la versión actual de Firefox para computadoras), puedes enviar algunos datos arbitrarios junto con el envío inmediato para que el cliente pueda evitar realizar la solicitud adicional. Sin embargo, un gran poder conlleva una gran responsabilidad, por lo que todos los datos de la carga útil deben estar encriptados.
La encriptación de las cargas útiles es una parte importante de la historia de seguridad de las notificaciones push web. HTTPS te brinda seguridad cuando te comunicas entre el navegador y tu propio servidor, ya que confías en él. Sin embargo, el navegador elige qué proveedor de notificaciones push se usará para entregar la carga útil, por lo que, como desarrollador de la app, no tienes control sobre ella.
Aquí, HTTPS solo puede garantizar que nadie pueda espiar el mensaje en tránsito al proveedor de servicios push. Una vez que la reciben, pueden hacer lo que quieran, lo que incluye volver a transmitir la carga útil a terceros o alterarla de forma maliciosa. Para protegernos contra esto, usamos encriptación para garantizar que los servicios push no puedan leer ni manipular las cargas útiles en tránsito.
Cambios del cliente
Si ya implementaste notificaciones push sin cargas útiles, solo debes realizar dos pequeños cambios en el cliente.
En primer lugar, cuando envías la información de suscripción a tu servidor de backend, debes recopilar información adicional. Si ya usas JSON.stringify()
en el objeto PushSubscription para serializarlo y enviarlo a tu servidor, no es necesario que cambies nada. La suscripción ahora tendrá algunos datos adicionales en la propiedad keys.
> JSON.stringify(subscription)
{"endpoint":"https://android.googleapis.com/gcm/send/f1LsxkKphfQ:APA91bFUx7ja4BK4JVrNgVjpg1cs9lGSGI6IMNL4mQ3Xe6mDGxvt_C_gItKYJI9CAx5i_Ss6cmDxdWZoLyhS2RJhkcv7LeE6hkiOsK6oBzbyifvKCdUYU7ADIRBiYNxIVpLIYeZ8kq_A",
"keys":{"p256dh":"BLc4xRzKlKORKWlbdgFaBrrPK3ydWAHo4M0gs0i1oEKgPpWC5cW8OCzVrOQRv-1npXRWk8udnW3oYhIO4475rds=",
"auth":"5I2Bu2oKdyy9CwL8QVF0NQ=="}}
Los dos valores p256dh
y auth
están codificados en una variante de Base64 a la que llamaré Base64 seguro para URL.
Si, en cambio, quieres obtener los bytes directamente, puedes usar el nuevo método getKey()
en la suscripción que muestra un parámetro como ArrayBuffer
.
Los dos parámetros que necesitas son auth
y p256dh
.
> new Uint8Array(subscription.getKey('auth'));
[228, 141, 129, ...] (16 bytes)
> new Uint8Array(subscription.getKey('p256dh'));
[4, 183, 56, ...] (65 bytes)
El segundo cambio es una nueva propiedad data cuando se activa el evento push
. Tiene varios métodos síncronos para
analíticos los datos recibidos, como .text()
, .json()
, .arrayBuffer()
y
.blob()
.
self.addEventListener('push', function(event) {
if (event.data) {
console.log(event.data.json());
}
});
Cambios del servidor
En el lado del servidor, las cosas cambian un poco más. El proceso básico consiste en usar la información de la clave de encriptación que obtuviste del cliente para encriptar la carga útil y, luego, enviarla como cuerpo de una solicitud POST al extremo de la suscripción, y agregar algunos encabezados HTTP adicionales.
Los detalles son relativamente complejos y, al igual que con todo lo relacionado con la encriptación, es mejor usar una biblioteca desarrollada de forma activa en lugar de implementar la propia. El equipo de Chrome publicó una biblioteca para Node.js, y pronto se agregarán más lenguajes y plataformas. Esto controla la encriptación y el protocolo de notificaciones push web, de modo que enviar un mensaje push desde un servidor de Node.js sea tan fácil como webpush.sendWebPush(message, subscription)
.
Si bien recomendamos usar una biblioteca, esta es una función nueva y hay muchos lenguajes populares que aún no tienen ninguna biblioteca. Si necesitas implementarlo por tu cuenta, aquí tienes los detalles.
Mostraré los algoritmos con JavaScript con sabor a nodo, pero los principios básicos deben ser los mismos en cualquier lenguaje.
Entradas
Para encriptar un mensaje, primero debemos obtener dos elementos del objeto de suscripción que recibimos del cliente. Si usaste JSON.stringify()
en el cliente y lo transmitiste a tu servidor, la clave pública del cliente se almacena en el campo keys.p256dh
, mientras que el secreto de autenticación compartido está en el campo keys.auth
. Ambos estarán codificados en Base64 seguros para URL, como se mencionó antes. El formato binario de la clave pública del cliente es un punto de curva elíptica P-256 sin comprimir.
const clientPublicKey = new Buffer(subscription.keys.p256dh, 'base64');
const clientAuthSecret = new Buffer(subscription.keys.auth, 'base64');
La clave pública nos permite encriptar el mensaje de modo que solo se pueda desencriptar con la clave privada del cliente.
Por lo general, las claves públicas se consideran, bueno, públicas, por lo que, para permitir que el cliente autentique que el mensaje fue enviado por un servidor de confianza, también usamos el secreto de autenticación. No es de extrañar que se deba mantener en secreto, compartir solo con el servidor de aplicaciones al que deseas que te envíen mensajes y tratarlo como una contraseña.
También necesitamos generar algunos datos nuevos. Necesitamos una sal aleatoria segura criptográficamente de 16 bytes y un par de claves públicas y privadas de curva elíptica. La curva particular que usa la especificación de encriptación push se denomina P-256 o prime256v1
. Para obtener la mejor seguridad, el par de claves debe generarse desde cero cada vez que encriptas un mensaje y nunca debes volver a usar una sal.
ECDH
Hagamos un pequeño alto para hablar sobre una propiedad interesante de la criptografía de las curvas elípticas. Existe un proceso relativamente simple que combina tu clave privada con la clave pública de alguien más para obtener un valor. ¿Y qué? Bueno, si la otra parte toma su clave privada y tu clave pública, obtendrá exactamente el mismo valor.
Esta es la base del protocolo de acuerdo de claves de la curva elíptica Diffie-Hellman (ECDH), que permite que ambas partes tengan el mismo secreto compartido aunque solo hayan intercambiado claves públicas. Usaremos este secreto compartido como base para nuestra clave de encriptación real.
const crypto = require('crypto');
const salt = crypto.randomBytes(16);
// Node has ECDH built-in to the standard crypto library. For some languages
// you may need to use a third-party library.
const serverECDH = crypto.createECDH('prime256v1');
const serverPublicKey = serverECDH.generateKeys();
const sharedSecret = serverECDH.computeSecret(clientPublicKey);
HKDF
Es hora de otro comentario al margen. Supongamos que tienes algunos datos secretos que quieres usar como clave de encriptación, pero no son lo suficientemente seguros desde el punto de vista criptográfico. Puedes usar la función de derivación de claves (HKDF) basada en HMAC para convertir un secreto con baja seguridad en uno con alta seguridad.
Una consecuencia de su funcionamiento es que te permite tomar un secreto de cualquier cantidad de bits y producir otro secreto de cualquier tamaño hasta 255 veces más que un hash generado por cualquier algoritmo de hash que uses. Para el envío, las especificaciones requieren que usemos SHA-256, que tiene una longitud de hash de 32 bytes (256 bits).
En realidad, sabemos que solo necesitamos generar claves de hasta 32 bytes de tamaño. Esto significa que podemos usar una versión simplificada del algoritmo que no pueda manejar tamaños de salida más grandes.
A continuación, incluí el código de una versión de Node, pero puedes descubrir cómo funciona en la RFC 5869.
Las entradas de HKDF son una sal, un material de clave inicial (ikm), un dato estructurado opcional específico del caso de uso actual (info) y la longitud en bytes de la clave de salida deseada.
// Simplified HKDF, returning keys up to 32 bytes long
function hkdf(salt, ikm, info, length) {
if (length > 32) {
throw new Error('Cannot return keys of more than 32 bytes, ${length} requested');
}
// Extract
const keyHmac = crypto.createHmac('sha256', salt);
keyHmac.update(ikm);
const key = keyHmac.digest();
// Expand
const infoHmac = crypto.createHmac('sha256', key);
infoHmac.update(info);
// A one byte long buffer containing only 0x01
const ONE_BUFFER = new Buffer(1).fill(1);
infoHmac.update(ONE_BUFFER);
return infoHmac.digest().slice(0, length);
}
Derivación de los parámetros de encriptación
Ahora usamos HKDF para convertir los datos que tenemos en los parámetros de la encriptación real.
Lo primero que hacemos es usar HKDF para combinar el secreto de autenticación del cliente y el secreto compartido en un secreto más largo y más seguro criptográficamente. En la especificación, se hace referencia a esta como una clave pseudoaleatoria (PRK), por lo que la llamaré así aquí, aunque los puristas de la criptografía pueden notar que no es estrictamente una PRK.
Ahora, creamos la clave de encriptación de contenido final y un nonce que se pasará al algoritmo de cifrado. Para crearlos, se crea una estructura de datos simple para cada uno, a la que se hace referencia en la especificación como información, que contiene información específica de la curva elíptica, el remitente y el receptor de la información para verificar mejor la fuente del mensaje. Luego, usamos HKDF con la PRK, nuestra sal y la información para derivar la clave y el nonce del tamaño correcto.
El tipo de información para la encriptación de contenido es "aesgcm", que es el nombre del algoritmo de cifrado que se usa para la encriptación push.
const authInfo = new Buffer('Content-Encoding: auth\0', 'utf8');
const prk = hkdf(clientAuthSecret, sharedSecret, authInfo, 32);
function createInfo(type, clientPublicKey, serverPublicKey) {
const len = type.length;
// The start index for each element within the buffer is:
// value | length | start |
// -----------------------------------------
// 'Content-Encoding: '| 18 | 0 |
// type | len | 18 |
// nul byte | 1 | 18 + len |
// 'P-256' | 5 | 19 + len |
// nul byte | 1 | 24 + len |
// client key length | 2 | 25 + len |
// client key | 65 | 27 + len |
// server key length | 2 | 92 + len |
// server key | 65 | 94 + len |
// For the purposes of push encryption the length of the keys will
// always be 65 bytes.
const info = new Buffer(18 + len + 1 + 5 + 1 + 2 + 65 + 2 + 65);
// The string 'Content-Encoding: ', as utf-8
info.write('Content-Encoding: ');
// The 'type' of the record, a utf-8 string
info.write(type, 18);
// A single null-byte
info.write('\0', 18 + len);
// The string 'P-256', declaring the elliptic curve being used
info.write('P-256', 19 + len);
// A single null-byte
info.write('\0', 24 + len);
// The length of the client's public key as a 16-bit integer
info.writeUInt16BE(clientPublicKey.length, 25 + len);
// Now the actual client public key
clientPublicKey.copy(info, 27 + len);
// Length of our public key
info.writeUInt16BE(serverPublicKey.length, 92 + len);
// The key itself
serverPublicKey.copy(info, 94 + len);
return info;
}
// Derive the Content Encryption Key
const contentEncryptionKeyInfo = createInfo('aesgcm', clientPublicKey, serverPublicKey);
const contentEncryptionKey = hkdf(salt, prk, contentEncryptionKeyInfo, 16);
// Derive the Nonce
const nonceInfo = createInfo('nonce', clientPublicKey, serverPublicKey);
const nonce = hkdf(salt, prk, nonceInfo, 12);
Relleno
Otro aparte, y es hora de un ejemplo y artificial. Supongamos que tu jefe tiene un servidor que le envía un mensaje push cada pocos minutos con el precio de las acciones de la empresa. El mensaje sin formato para esto siempre será un número entero de 32 bits con el valor en centavos. También tiene un acuerdo secreto con el personal de catering, lo que significa que pueden enviarle “rosquillas en la sala de descanso” 5 minutos antes de que se entreguen para que pueda estar allí “casualmente” cuando lleguen y tomar la mejor.
El algoritmo de cifrado que usa Web Push crea valores encriptados que son exactamente 16 bytes más largos que la entrada no encriptada. Dado que “Donas en la sala de descanso” es más largo que un precio de acciones de 32 bits, cualquier empleado entrometido podrá saber cuándo llegarán las donas sin desencriptar los mensajes, solo por la longitud de los datos.
Por este motivo, el protocolo de notificaciones push web te permite agregar padding al comienzo de los datos. La forma en que uses esto depende de tu aplicación, pero en el ejemplo anterior, podrías rellenar todos los mensajes para que sean exactamente de 32 bytes, lo que imposibilita distinguir los mensajes solo en función de la longitud.
El valor de padding es un número entero de 16 bits con formato big-endian que especifica la longitud del padding, seguido de esa cantidad de bytes de padding NUL
. Por lo tanto, el padding mínimo es de dos bytes: el número cero codificado en 16 bits.
const padding = new Buffer(2 + paddingLength);
// The buffer must be only zeroes, except the length
padding.fill(0);
padding.writeUInt16BE(paddingLength, 0);
Cuando tu mensaje push llegue al cliente, el navegador podrá quitar automáticamente cualquier padding, de modo que tu código de cliente solo reciba el mensaje sin padding.
Encriptación
Ahora, por fin, tenemos todo lo necesario para realizar la encriptación. El algoritmo de cifrado requerido para los mensajes push web es AES128 con GCM. Usamos nuestra clave de encriptación de contenido como la clave y el nonce como el vector de inicialización (IV).
En este ejemplo, nuestros datos son una cadena, pero podrían ser cualquier dato binario. Puedes enviar cargas útiles de hasta 4,078 bytes (4,096 bytes como máximo por publicación), con 16 bytes para la información de encriptación y, al menos, 2 bytes para el padding.
// Create a buffer from our data, in this case a UTF-8 encoded string
const plaintext = new Buffer('Push notification payload!', 'utf8');
const cipher = crypto.createCipheriv('id-aes128-GCM', contentEncryptionKey,
nonce);
const result = cipher.update(Buffer.concat(padding, plaintext));
cipher.final();
// Append the auth tag to the result - https://nodejs.org/api/crypto.html#crypto_cipher_getauthtag
return Buffer.concat([result, cipher.getAuthTag()]);
Envío web
¡Vaya! Ahora que tienes una carga útil encriptada, solo debes realizar una solicitud HTTP POST relativamente simple al extremo que especifica la suscripción del usuario.
Debes establecer tres encabezados.
Encryption: salt=<SALT>
Crypto-Key: dh=<PUBLICKEY>
Content-Encoding: aesgcm
<SALT>
y <PUBLICKEY>
son la sal y la clave pública del servidor que se usan en la encriptación, codificadas como Base64 seguro para URL.
Cuando se usa el protocolo Web Push, el cuerpo de la POST es solo los bytes sin procesar del mensaje encriptado. Sin embargo, hasta que Chrome y Firebase Cloud Messaging admitan el protocolo, puedes incluir fácilmente los datos en tu carga útil JSON existente de la siguiente manera.
{
"registration_ids": [ "…" ],
"raw_data": "BIXzEKOFquzVlr/1tS1bhmobZ…"
}
El valor de la propiedad rawData
debe ser la representación codificada en Base64 del mensaje encriptado.
Depuración o verificador
Peter Beverloo, uno de los ingenieros de Chrome que implementó la función (además de ser una de las personas que trabajó en la especificación), creó un verificador.
Si haces que tu código genere cada uno de los valores intermedios de la encriptación, puedes pegarlos en el verificador y verificar que estés en el camino correcto.