Encriptación de carga útil web push

Balanzas

Antes de Chrome 50, los mensajes push no podían contener ningún dato de carga útil. Cuando se activaba el evento "push" en tu service worker, solo sabías que el servidor intentaba decirte algo, pero no lo que podría ser. Luego, tuviste que realizar una solicitud de seguimiento al servidor y obtener los detalles de la notificación que se mostrará, lo que podría fallar en condiciones de red deficientes.

Ahora, en Chrome 50 (y en la versión actual de Firefox para computadoras de escritorio), puedes enviar algunos datos arbitrarios junto con el envío para que el cliente evite realizar la solicitud adicional. Sin embargo, un gran poder conlleva una gran responsabilidad, por lo que todos los datos de carga útil deben estar encriptados.

La encriptación de las cargas útiles es una parte importante del historial de seguridad de las aplicaciones 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 tú, 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, tienen la libertad de hacer lo que les guste, lo que incluye retransmitir la carga útil a terceros o modificarla de forma malintencionada para otro propósito. Para brindar protección contra esto, usamos la encriptación con el fin de garantizar que los servicios de envío no puedan leer ni alterar las cargas útiles en tránsito.

Cambios del cliente

Si ya implementaste notificaciones push sin cargas útiles, solo debes realizar dos cambios pequeños por parte del 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 necesitas cambiar nada. Ahora la suscripción tendrá algunos datos adicionales en la propiedad de claves.

> 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 que llamaré Base64 segura para URL.

En cambio, si deseas obtener directamente los bytes, 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 analizar los datos recibidos, como .text(), .json(), .arrayBuffer() y .blob().

self.addEventListener('push', function(event) {
  if (event.data) {
    console.log(event.data.json());
  }
});

Cambios en el servidor

En el servidor, las cosas cambian un poco más. El proceso básico es usar la información de la clave de encriptación que obtuviste del cliente para encriptar la carga útil y, luego, enviarla como el 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 que implementar la tuya. 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 push web, de modo que enviar un mensaje push desde un servidor Node.js es 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 implementar esto por tu cuenta, estos son los detalles.

Ilustré los algoritmos con JavaScript adaptado a Node, pero los principios básicos deben ser los mismos en cualquier lenguaje.

Entradas

A fin de encriptar un mensaje, primero necesitamos obtener dos elementos del objeto de suscripción que recibimos del cliente. Si usaste JSON.stringify() en el cliente y la transmitiste al 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 seguro para URL, como se mencionó anteriormente. El formato binario de la clave pública de 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, se considera que las claves públicas son públicas; por lo tanto, para permitir que el cliente autentique que el mensaje fue enviado por un servidor de confianza, también usamos el secreto de autenticación. Como es de esperar, esto se debe mantener en secreto, compartirse solo con el servidor de apps desde el que deseas enviarte mensajes y tratar como una contraseña.

También necesitamos generar algunos datos nuevos. Necesitamos una sal aleatoria de 16 bytes segura en términos criptográficos y un par público/privado de claves de curva elíptica. La curva particular que usa la especificación de encriptación push se llama P-256 o prime256v1. Para obtener la mejor seguridad, el par de claves se debe generar desde cero cada vez que encriptas un mensaje y nunca debes volver a usar una sal.

ECDH

Hagamos un poco de lado para hablar sobre una propiedad prolija de la criptografía de curva elíptica. Existe un proceso relativamente simple que combina tu clave privada con la clave pública de otra persona para obtener un valor. ¿Y con eso qué? Si la otra parte toma su clave privada y tu clave pública, obtendrás exactamente el mismo valor.

Esta es la base del protocolo de acuerdo de claves de curva elíptica Diffie-Hellman (ECDH), que permite que ambas partes tengan el mismo secreto compartido aunque solo intercambiaron 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);

en

Ya es hora de otra reserva. Supongamos que tienes algunos datos secretos que quieres usar como clave de encriptación, pero no son lo suficientemente seguros a nivel 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 de 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 producido por cualquier algoritmo de hash que uses. Para las notificaciones push, la especificación requiere que usemos SHA-256, que tiene una longitud de hash de 32 bytes (256 bits).

A medida que sucede, sabemos que solo necesitamos generar claves de hasta 32 bytes de tamaño. Por lo tanto, podemos usar una versión simplificada del algoritmo que no pueda manejar resultados más grandes.

Incluí el código de una versión de Node a continuación, pero puedes descubrir cómo funciona en RFC 5869.

Las entradas del HKDF son una sal, un material de claves inicial (ikm), una pieza opcional de datos estructurados específicos del caso de uso actual (información) 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);
}

Deriva 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 seguro a nivel criptográfico. En la especificación, esto se conoce como una clave seudoaleatoria (PRK), por lo que así es como la llamaré aquí, aunque los puristas de la criptografía pueden indicar 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. Estos se crean mediante la creación de una estructura de datos simple para cada uno, denominado 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 con más detalle 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 de envío.

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);

Padding

Otra diferencia y tiempo para un ejemplo tonta y forzado. 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 trato engañoso con el personal de catering, lo que significa que pueden enviarle la cadena "donas en la sala de descanso" 5 minutos antes de que se entreguen, de modo que pueda estar "coincidentemente" cuando llegue y tome la mejor.

El algoritmo de cifrado que usa el Web Push crea valores encriptados que son exactamente 16 bytes más largos que la entrada sin encriptar. Dado que las “donas en la sala de descanso” son más largas que el precio de las acciones de 32 bits, cualquier empleado espiando podrá saber cuándo llegan las donas sin desencriptar los mensajes, solo por la longitud de los datos.

Por esta razón, el protocolo de envío web te permite agregar padding al principio de los datos. La forma de usar este parámetro depende de tu aplicación, pero en el ejemplo anterior, podrías rellenar todos los mensajes para que tengan exactamente 32 bytes, lo que hace que sea imposible distinguir los mensajes solo en función de su longitud.

El valor de padding es un número entero big-endian de 16 bits que especifica la longitud del padding seguida de esa cantidad de NUL bytes de padding. 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 relleno, de modo que el código de cliente solo reciba el mensaje sin rellenar.

Encriptación

Ahora, por fin, tenemos todo para hacer la encriptación. El algoritmo de cifrado requerido para Web Push es AES128 con GCM. Usamos nuestra clave de encriptación de contenido como clave y el nonce como vector de inicialización (IV).

En este ejemplo, nuestros datos son una cadena, pero podría ser cualquier dato binario. Puedes enviar cargas útiles de hasta 4,078 bytes y un máximo de 4,096 bytes por publicación, con 16 bytes para la información de encriptación y 2 bytes para el relleno.

// 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 especificado por 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 de envío web, el cuerpo de la solicitud POST es solo los bytes sin procesar del mensaje encriptado. Sin embargo, hasta que Chrome y Firebase Cloud Messaging admitan el protocolo, puedes incluir los datos en tu carga útil de JSON existente con facilidad como se indica a continuación.

{
    "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 / verificador

Peter Beverloo, uno de los ingenieros de Chrome que implementó la función (además de ser una de las personas que trabajaron en la especificación), creó un verificador.

Si obtienes que el código genere cada uno de los valores intermedios de la encriptación, puedes pegarlos en el verificador y comprobar que estés en el camino correcto.

.