El protocolo de envío web

Ya vimos cómo se puede usar una biblioteca para activar mensajes push, pero ¿qué hacen exactamente estas bibliotecas?

Realizan solicitudes de red y garantizan que dichas solicitudes tengan el formato correcto. La especificación que define esta solicitud de red es el protocolo de envío web.

Diagrama del envío de un mensaje push desde tu servidor a un servicio de envío

En esta sección, se describe cómo el servidor puede identificarse con las claves del servidor de aplicaciones y cómo se envían la carga útil encriptada y los datos asociados.

Este no es un aspecto atractivo del web push y no soy experto en la encriptación, pero revisemos cada parte, ya que es útil saber qué hacen estas bibliotecas de forma interna.

Claves del servidor de aplicaciones

Cuando suscribemos un usuario, pasamos un applicationServerKey. Esta clave se pasa al servicio de envío y se usa para verificar que la aplicación que suscribió al usuario también sea la que activa los mensajes push.

Cuando activamos un mensaje push, enviamos un conjunto de encabezados que permiten que el servicio de envío autentique la aplicación. (Esto se define según la especificación de VAPID).

¿Qué significa todo esto y qué sucede exactamente? Estos son los pasos que se deben seguir para la autenticación del servidor de la aplicación:

  1. El servidor de aplicaciones firma cierta información JSON con su clave de aplicación privada.
  2. Esta información firmada se envía al servicio de envío como un encabezado en una solicitud POST.
  3. El servicio de envío usa la clave pública almacenada que recibió de pushManager.subscribe() para verificar que la información recibida esté firmada por la clave privada relacionada con la clave pública. Recuerda: La clave pública es el applicationServerKey que se pasa a la llamada de suscripción.
  4. Si la información firmada es válida, el servicio de envío envía el mensaje de envío al usuario.

A continuación, se muestra un ejemplo de este flujo de información. (ten en cuenta la leyenda en la parte inferior izquierda para indicar las claves públicas y privadas).

Ilustración de cómo se usa la clave de servidor de la aplicación privada cuando se envía un mensaje

La "información firmada" que se agrega a un encabezado en la solicitud es un token web JSON.

Token web JSON

Un token web JSON (o JWT) es una forma de enviar un mensaje a un tercero para que el receptor pueda validar quién lo envió.

Cuando un tercero recibe un mensaje, debe obtener la clave pública del remitente y usarla para validar la firma del JWT. Si la firma es válida, el JWT debe haberse firmado con la clave privada coincidente, por lo que debe ser del remitente esperado.

Hay una gran cantidad de bibliotecas en https://jwt.io/ que pueden realizar la firma por ti, y te recomendamos que lo hagas siempre que sea posible. Para completarlo, veamos cómo crear manualmente un JWT firmado.

Envío web y JWT firmados

Un JWT firmado es solo una cadena, aunque se puede considerar como tres cadenas unidas por puntos.

Ilustración de las cadenas en un token web JSON

La primera y la segunda cadenas (la información de JWT y los datos de JWT) son fragmentos de JSON que se codificaron en base64, es decir, que se pueden leer de forma pública.

La primera string es información sobre el JWT en sí, que indica qué algoritmo se usó para crear la firma.

La información de JWT para el envío web debe contener los siguientes datos:

{
  "typ": "JWT",
  "alg": "ES256"
}

La segunda cadena corresponde a los datos de JWT. Esto proporciona información sobre el remitente del JWT, a quién está destinado y durante cuánto tiempo es válido.

Para el envío web, los datos tendrían el siguiente formato:

{
  "aud": "https://some-push-service.org",
  "exp": "1469618703",
  "sub": "mailto:example@web-push-book.org"
}

El valor aud es el “público”, es decir, a quién está destinado el JWT. Para el envío web, el público es el servicio de envío, por lo que lo configuramos en el origen del servicio de envío.

El valor exp es el vencimiento del JWT, lo que evita que los espías no puedan volver a usar un JWT si lo interceptan. El vencimiento es una marca de tiempo en segundos y no debe ser de 24 horas más.

En Node.js, el vencimiento se configura mediante lo siguiente:

Math.floor(Date.now() / 1000) + 12 * 60 * 60;

Se trata de 12 horas en lugar de 24 horas para evitar cualquier problema con las diferencias de reloj entre la aplicación emisora y el servicio de envío.

Por último, el valor sub debe ser una URL o una dirección de correo electrónico mailto. Esto es para que, si un servicio de envío necesita comunicarse con el remitente, puede encontrar la información de contacto del JWT. (Es por eso que la biblioteca web-push necesitaba una dirección de correo electrónico).

Al igual que la información de JWT, los datos de JWT están codificados como una cadena base64 segura de URL.

La tercera string, la firma, es el resultado de tomar las dos primeras cadenas (la información de JWT y los datos de JWT), unirlas con un carácter de punto, que llamaremos "token sin firmar", y firmarlo.

El proceso de firma requiere la encriptación del "token sin firmar" con ES256. Según las especificaciones de JWT, ES256 es la abreviatura de "ECDSA con la curva P-256 y el algoritmo de hash SHA-256". Con las criptomonedas web, puedes crear la firma de la siguiente manera:

// Utility function for UTF-8 encoding a string to an ArrayBuffer.
const utf8Encoder = new TextEncoder('utf-8');

// The unsigned token is the concatenation of the URL-safe base64 encoded
// header and body.
const unsignedToken = .....;

// Sign the |unsignedToken| using ES256 (SHA-256 over ECDSA).
const key = {
  kty: 'EC',
  crv: 'P-256',
  x: window.uint8ArrayToBase64Url(
    applicationServerKeys.publicKey.subarray(1, 33)),
  y: window.uint8ArrayToBase64Url(
    applicationServerKeys.publicKey.subarray(33, 65)),
  d: window.uint8ArrayToBase64Url(applicationServerKeys.privateKey),
};

// Sign the |unsignedToken| with the server's private key to generate
// the signature.
return crypto.subtle.importKey('jwk', key, {
  name: 'ECDSA', namedCurve: 'P-256',
}, true, ['sign'])
.then((key) => {
  return crypto.subtle.sign({
    name: 'ECDSA',
    hash: {
      name: 'SHA-256',
    },
  }, key, utf8Encoder.encode(unsignedToken));
})
.then((signature) => {
  console.log('Signature: ', signature);
});

Un servicio de envío puede validar un JWT con la clave de servidor de la aplicación pública para desencriptar la firma y asegurarse de que la string desencriptada sea la misma que el “token sin firmar” (es decir, las dos primeras strings en el JWT).

El JWT firmado (es decir, las tres cadenas unidas por puntos) se envía al servicio push web como el encabezado Authorization con WebPush antepuesto, de la siguiente manera:

Authorization: 'WebPush [JWT Info].[JWT Data].[Signature]';

El protocolo de envío web también indica que la clave de servidor de aplicaciones pública debe enviarse en el encabezado Crypto-Key como una string segura de URL codificada en base64 con p256ecdsa= antepuesto.

Crypto-Key: p256ecdsa=[URL Safe Base64 Public Application Server Key]

La encriptación de la carga útil

Ahora, veamos cómo podemos enviar una carga útil con un mensaje push para que cuando nuestra app web reciba un mensaje push, pueda acceder a los datos que recibe.

Una pregunta común que surge de quienes han usado otros servicios push es por qué la carga útil web debe estar encriptada. Con las aplicaciones nativas, los mensajes push pueden enviar datos como texto sin formato.

Parte del atractivo del servicio web es que todos los servicios push usan la misma API (el protocolo de envío web), por lo que a los desarrolladores no les importa quién es el servicio de envío. Podemos realizar una solicitud en el formato correcto y esperar que se envíe un mensaje push. La desventaja de esto es que los desarrolladores podrían enviar mensajes a un servicio push que no sea confiable. Cuando se encripta la carga útil, un servicio de envío no puede leer los datos enviados. Solo el navegador puede desencriptar la información. Esto protege los datos del usuario.

La encriptación de la carga útil se define en las especificaciones de encriptación de mensajes.

Antes de ver los pasos específicos para encriptar una carga útil de mensajes de envío, deberíamos cubrir algunas técnicas que se usarán durante el proceso de encriptación. (Enorme consejo para Mat Scales por su excelente artículo sobre encriptación push).

ECDH y HKDF

Tanto ECDH como HKDF se usan durante el proceso de encriptación y ofrecen beneficios para encriptar la información.

ECDH: intercambio de claves de curva elíptica de Diffie-Hellman

Imagina que dos personas quieren compartir información, Alicia y Roberto. Tanto Alicia como Roberto tienen sus propias claves públicas y privadas. Alicia y Bob comparten sus claves públicas entre sí.

La propiedad útil de las claves generadas con ECDH es que Alice puede usar su clave privada y la clave pública de Roberto para crear el valor secreto “X”. Bob puede hacer lo mismo: toma su clave privada y la de Alice para crear de forma independiente el mismo valor “X”. Esto hace que "X" sea un secreto compartido, y Alice y Bob solo tuvieron que compartir su clave pública. Ahora Bob y Alice pueden usar “X” para encriptar y desencriptar mensajes entre ellos.

ECDH, a mi leal saber y entender, define las propiedades de las curvas que permiten esta "función" de hacer un secreto compartido "X".

Esta es una explicación de alto nivel de ECDH, si quieres obtener más información, te recomiendo mirar este video.

En términos de código, la mayoría de los lenguajes y las plataformas vienen con bibliotecas para facilitar la generación de estas claves.

En un nodo, haríamos lo siguiente:

const keyCurve = crypto.createECDH('prime256v1');
keyCurve.generateKeys();

const publicKey = keyCurve.getPublicKey();
const privateKey = keyCurve.getPrivateKey();

HKDF: función de derivación de claves basada en HMAC

Wikipedia tiene una descripción breve de HKDF:

HKDF es una función de derivación de claves basada en HMAC que transforma cualquier material de clave débil en material de clave seguro a nivel criptográfico. Se puede usar, por ejemplo, para convertir los secretos compartidos de Diffie Hellman en material de clave adecuado para la encriptación, verificación de integridad o autenticación.

Básicamente, el HKDF tomará entradas que no sean particularmente seguras y las hará más seguras.

La especificación que define esta encriptación requiere el uso de SHA-256 como nuestro algoritmo hash, y las claves resultantes para HKDF en notificaciones push web no deben tener más de 256 bits (32 bytes).

En el nodo, esto se puede implementar de la siguiente manera:

// Simplified HKDF, returning keys up to 32 bytes long
function hkdf(salt, ikm, info, length) {
  // 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);
}

Sugerencia del artículo de Mat Scale para este código de ejemplo.

Esto abarca de manera general ECDH y HKDF.

ECDH es una forma segura de compartir claves públicas y generar un secreto compartido. El HKDF es una forma de tomar material inseguro y de hacer que sea seguro.

Se usará durante la encriptación de nuestra carga útil. Ahora veamos lo que tomamos como entrada y cómo se encripta.

Entradas

Cuando queremos enviar un mensaje push a un usuario con una carga útil, hay tres entradas que necesitamos:

  1. La carga útil en sí misma
  2. El secreto auth de PushSubscription.
  3. La clave p256dh de PushSubscription.

Notamos que se recuperan los valores auth y p256dh de una PushSubscription, pero a modo de recordatorio rápido, necesitamos los siguientes valores para una suscripción:

subscription.toJSON().keys.auth;
subscription.toJSON().keys.p256dh;

subscription.getKey('auth');
subscription.getKey('p256dh');

El valor auth debe tratarse como un secreto y no debe compartirse fuera de tu aplicación.

La clave p256dh es una clave pública, que a veces se denomina clave pública de cliente. En este caso, nos referiremos a p256dh como la clave pública de suscripción. El navegador genera la clave pública de la suscripción. El navegador mantendrá la clave privada en secreto y la usará para desencriptar la carga útil.

Estos tres valores, auth, p256dh y payload, son necesarios como entradas y el resultado del proceso de encriptación será la carga útil encriptada, un valor de sal y una clave pública que se usa solo para encriptar los datos.

Sal

La sal debe ser de 16 bytes de datos aleatorios. En NodeJS, haríamos lo siguiente para crear una sal:

const salt = crypto.randomBytes(16);

Claves públicas / privadas

Las claves públicas y privadas se deben generar mediante una curva elíptica P-256, algo que haríamos en Node.js de esta manera:

const localKeysCurve = crypto.createECDH('prime256v1');
localKeysCurve.generateKeys();

const localPublicKey = localKeysCurve.getPublicKey();
const localPrivateKey = localKeysCurve.getPrivateKey();

Nos referiremos a estas claves como “claves locales”. Se usan solo con fines de encriptación y no tienen que ver con las claves del servidor de aplicaciones.

Con la carga útil, el secreto de autenticación y la clave pública de suscripción como entradas, y con una sal y un conjunto de claves locales recién generados, estamos listos para realizar una encriptación.

Secreto compartido

El primer paso es crear un secreto compartido mediante la clave pública de suscripción y nuestra clave privada nueva (¿recuerdas la explicación de ECDH con Alice y Bob? así de fácil).

const sharedSecret = localKeysCurve.computeSecret(
  subscription.keys.p256dh,
  'base64',
);

Esto se usa en el siguiente paso para calcular la clave seudoaleatoria (PRK).

Clave seudoaleatoria

La clave seudoaleatoria (PRK) es la combinación del secreto de autenticación de la suscripción de envío y el secreto compartido que acabamos de crear.

const authEncBuff = new Buffer('Content-Encoding: auth\0', 'utf8');
const prk = hkdf(subscription.keys.auth, sharedSecret, authEncBuff, 32);

Quizás te preguntes para qué sirve la cadena Content-Encoding: auth\0. En resumen, no tiene un propósito claro, aunque los navegadores podrían desencriptar un mensaje entrante y buscar la codificación de contenido esperada. \0 agrega un byte con un valor de 0 al final del búfer. Esto es lo que esperan los navegadores que desencriptan el mensaje, y esperarán muchos bytes para la codificación de contenido, seguidos de un byte con valor 0 y, luego, de los datos encriptados.

Nuestra clave seudoaleatoria simplemente ejecuta la autenticación, el secreto compartido y una parte de la información de codificación a través de HKDF (es decir, lo hace más fuerte a nivel criptográfico).

La importancia

El “contexto” es un conjunto de bytes que se usa para calcular dos valores posteriormente en el navegador de encriptación. En esencia, es un array de bytes que contiene la clave pública de suscripción y la clave pública local.

const keyLabel = new Buffer('P-256\0', 'utf8');

// Convert subscription public key into a buffer.
const subscriptionPubKey = new Buffer(subscription.keys.p256dh, 'base64');

const subscriptionPubKeyLength = new Uint8Array(2);
subscriptionPubKeyLength[0] = 0;
subscriptionPubKeyLength[1] = subscriptionPubKey.length;

const localPublicKeyLength = new Uint8Array(2);
subscriptionPubKeyLength[0] = 0;
subscriptionPubKeyLength[1] = localPublicKey.length;

const contextBuffer = Buffer.concat([
  keyLabel,
  subscriptionPubKeyLength.buffer,
  subscriptionPubKey,
  localPublicKeyLength.buffer,
  localPublicKey,
]);

El búfer de contexto final es una etiqueta, la cantidad de bytes en la clave pública de suscripción, seguida de la clave en sí, luego la cantidad de bytes de la clave pública local y la clave en sí.

Con este valor de contexto, podemos usarlo en la creación de un nonce y una clave de encriptación de contenido (CEK).

Clave de encriptación de contenido y nonce

Un nonce es un valor que evita ataques de repetición, ya que solo debe usarse una vez.

La clave de encriptación de contenido (CEK) es la clave que finalmente se usará para encriptar nuestra carga útil.

Primero, necesitamos crear los bytes de datos para el nonce y el CEK, que es una string de codificación de contenido seguida del búfer de contexto que acabamos de calcular:

const nonceEncBuffer = new Buffer('Content-Encoding: nonce\0', 'utf8');
const nonceInfo = Buffer.concat([nonceEncBuffer, contextBuffer]);

const cekEncBuffer = new Buffer('Content-Encoding: aesgcm\0');
const cekInfo = Buffer.concat([cekEncBuffer, contextBuffer]);

Esta información se procesa a través del HKDF combinando la sal y la PRK con nonceInfo y cekInfo:

// The nonce should be 12 bytes long
const nonce = hkdf(salt, prk, nonceInfo, 12);

// The CEK should be 16 bytes long
const contentEncryptionKey = hkdf(salt, prk, cekInfo, 16);

Esto nos da nuestra clave de encriptación de contenido y nonce.

Realiza la encriptación

Ahora que tenemos nuestra clave de encriptación de contenido, podemos encriptar la carga útil.

Creamos un algoritmo de cifrado AES128 con la clave de encriptación de contenido como clave y el nonce es un vector de inicialización.

En Node, esto se hace de la siguiente manera:

const cipher = crypto.createCipheriv(
  'id-aes128-GCM',
  contentEncryptionKey,
  nonce,
);

Antes de encriptar nuestra carga útil, debemos definir cuánto padding queremos agregar al frente de la carga útil. La razón por la que queremos agregar padding es que evita el riesgo de que los espías puedan determinar "tipos" de mensajes según el tamaño de la carga útil.

Debes agregar dos bytes de padding para indicar la longitud de cualquier padding adicional.

Por ejemplo, si no agregaste padding, tendrías dos bytes con el valor 0, es decir, no existe padding. Después de estos dos bytes leerás la carga útil. Si agregaste 5 bytes de relleno, los dos primeros bytes tendrán un valor de 5, por lo que el consumidor leerá cinco bytes adicionales y, luego, comenzará a leer la carga útil.

const padding = new Buffer(2 + paddingLength);
// The buffer must be only zeros, except the length
padding.fill(0);
padding.writeUInt16BE(paddingLength, 0);

Luego, ejecutamos el padding y la carga útil a través de este algoritmo de cifrado.

const result = cipher.update(Buffer.concat(padding, payload));
cipher.final();

// Append the auth tag to the result -
// https://nodejs.org/api/crypto.html#crypto_cipher_getauthtag
const encryptedPayload = Buffer.concat([result, cipher.getAuthTag()]);

Ahora tenemos nuestra carga útil encriptada. ¡Bien!

Solo falta determinar cómo se envía esta carga útil al servicio de envío.

Cuerpo y encabezados de carga útil encriptados

Para enviar esta carga útil encriptada al servicio push, tenemos que definir algunos encabezados diferentes en nuestra solicitud POST.

Encabezado de encriptación

El encabezado "Encriptación" debe contener la sal que se usó para encriptar la carga útil.

La sal de 16 bytes debe estar codificada de forma segura en base64 y agregarse al encabezado de encriptación de la siguiente manera:

Encryption: salt=[URL Safe Base64 Encoded Salt]

Encabezado de la Crypto-Key

Vimos que el encabezado Crypto-Key se usa en la sección "Claves del servidor de aplicaciones" para contener la clave de servidor de la aplicación pública.

Este encabezado también se usa para compartir la clave pública local que se usa para encriptar la carga útil.

El encabezado resultante se ve así:

Crypto-Key: dh=[URL Safe Base64 Encoded Local Public Key String]; p256ecdsa=[URL Safe Base64 Encoded Public Application Server Key]

Encabezados de tipo, longitud y codificación de contenido

El encabezado Content-Length es la cantidad de bytes en la carga útil encriptada. Los encabezados 'Content-Type' y 'Content-Encoding' son valores fijos. Esto se muestra a continuación.

Content-Length: [Number of Bytes in Encrypted Payload]
Content-Type: 'application/octet-stream'
Content-Encoding: 'aesgcm'

Con estos encabezados configurados, necesitamos enviar la carga útil encriptada como el cuerpo de nuestra solicitud. Ten en cuenta que Content-Type está configurado como application/octet-stream. Esto se debe a que la carga útil encriptada debe enviarse como un flujo de bytes.

En NodeJS, haríamos esto de la siguiente manera:

const pushRequest = https.request(httpsOptions, function(pushResponse) {
pushRequest.write(encryptedPayload);
pushRequest.end();

¿Más encabezados?

Hemos cubierto los encabezados que se usan para las claves del servidor de aplicaciones y JWT (es decir, cómo identificar la aplicación con el servicio de envío) y los encabezados que se usan para enviar una carga útil encriptada.

Hay encabezados adicionales que los servicios push usan para alterar el comportamiento de los mensajes enviados. Algunos de estos encabezados son obligatorios, mientras que otros son opcionales.

Encabezado TTL

Obligatorio

TTL (o tiempo de actividad) es un número entero que especifica la cantidad de segundos que deseas que tu mensaje push esté activo en el servicio push antes de que se entregue. Cuando venza el TTL, el mensaje se quitará de la cola del servicio de envío y no se entregará.

TTL: [Time to live in seconds]

Si configuras un TTL igual a cero, el servicio de envío intentará entregar el mensaje de inmediato, pero si no se puede acceder al dispositivo, el mensaje se descartará de inmediato de la cola del servicio de envío.

Técnicamente, un servicio de envío puede reducir el TTL de un mensaje push si lo desea. Para saber si esto sucedió, examina el encabezado TTL en la respuesta de un servicio de envío.

Tema

Opcional

Los temas son cadenas que se pueden usar para reemplazar un mensaje pendiente con uno nuevo si tienen nombres de tema que coinciden.

Esto es útil en situaciones en las que se envían varios mensajes mientras un dispositivo está sin conexión y en realidad solo quieres que un usuario vea el último mensaje cuando el dispositivo está encendido.

Urgencia

Opcional

La urgencia le indica al servicio push la importancia de un mensaje para el usuario. El servicio push puede usarlo para ayudar a conservar la duración de la batería del dispositivo de un usuario, ya que solo se activa para recibir mensajes importantes cuando la batería está baja.

El valor del encabezado se define como se muestra a continuación. El valor predeterminado es normal.

Urgency: [very-low | low | normal | high]

Todo junto

Si tienes más preguntas sobre cómo funciona todo esto, siempre puedes ver cómo las bibliotecas activan los mensajes push en la organización web-push-libs.

Una vez que tengas una carga útil encriptada y los encabezados anteriores, solo debes realizar una solicitud POST a endpoint en un PushSubscription.

Entonces, ¿qué hacemos con la respuesta a esta solicitud POST?

Respuesta del servicio de envío

Una vez que hayas realizado una solicitud a un servicio de envío, debes verificar el código de estado de la respuesta, ya que te indicará si la solicitud fue exitosa o no.

Código de estado Descripción
201 Fecha de creación. Se recibió y aceptó la solicitud para enviar un mensaje push.
429 Demasiadas solicitudes. Esto significa que tu servidor de aplicaciones alcanzó un límite de frecuencia con un servicio de envío. El servicio de envío debe incluir un encabezado "Retry-After" para indicar cuánto tiempo falta para que se pueda realizar otra solicitud.
400 Solicitud no válida. Por lo general, esto significa que uno de tus encabezados no es válido o tiene un formato incorrecto.
404 No se encontró. Esto indica que la suscripción venció y no se puede usar. En este caso, debes borrar la "PushSubscription" y esperar a que el cliente vuelva a suscribir al usuario.
410 Atrás. La suscripción ya no es válida y debería quitarse del servidor de aplicaciones. Esto se puede reproducir llamando a `unsubscribe()` en una `PushSubscription`.
413 La carga útil es demasiado grande. El tamaño mínimo de carga útil que debe admitir un servicio de envío es de 4,096 bytes (o 4 KB).

Próximos pasos

Code labs