Vimos cómo se puede usar una biblioteca para activar mensajes push, pero ¿qué hacen exactamente estas bibliotecas?
Bueno, realizan solicitudes de red y se aseguran de que tengan el formato correcto. La especificación que define esta solicitud de red es el protocolo Web Push.
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 agradable de las notificaciones push web, y no soy un experto en encriptación, pero analicemos cada parte, ya que es útil saber qué hacen estas bibliotecas en segundo plano.
Claves del servidor de aplicaciones
Cuando suscribimos a un usuario, pasamos un applicationServerKey. Esta clave se pasa al servicio push 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 push autentique la aplicación. (Esto se define en la especificación de VAPID).
¿Qué significa todo esto y qué sucede exactamente? Estos son los pasos que se siguen para la autenticación del servidor de aplicaciones:
- El servidor de aplicaciones firma cierta información en formato JSON con su clave privada de la aplicación.
- Esta información firmada se envía al servicio de envío como un encabezado en una solicitud POST.
- 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 con la clave privada relacionada con la clave pública. Recuerda: La clave pública es elapplicationServerKeyque se pasa a la llamada de suscripción. - Si la información firmada es válida, el servicio de envío envía el mensaje push 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).
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, para abreviar) es una forma de enviar un mensaje a un tercero de modo 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 provenir del remitente esperado.
En jwt.io/, hay una gran cantidad de bibliotecas que pueden realizar la firma por ti, y te recomiendo que lo hagas siempre que puedas. Para completar la información, veamos cómo crear manualmente un JWT firmado.
Notificaciones push web y JWT firmados
Un JWT firmado es solo una cadena, aunque se puede considerar como tres cadenas unidas por puntos.
La primera y la segunda cadena (la información del JWT y los datos del JWT) son fragmentos de JSON que se codificaron en base64, lo que significa que se pueden leer públicamente.
La primera cadena es información sobre el JWT en sí, que indica qué algoritmo se usó para crear la firma.
La información del JWT para las notificaciones push web debe contener la siguiente información:
{
"typ": "JWT",
"alg": "ES256"
}
La segunda cadena es la información del JWT. Proporciona información sobre el remitente del JWT, para quién está destinado y durante cuánto tiempo es válido.
En el caso de las notificaciones push web, los datos tendrían este formato:
{
"aud": "https://some-push-service.org",
"exp": "1469618703",
"sub": "mailto:example@web-push-book.org"
}
El valor de aud es el "público", es decir, para quién es el JWT. Para las notificaciones push web, el público es el servicio push, por lo que lo configuramos en el origen del servicio push.
El valor exp es el vencimiento del JWT, lo que evita que los fisgones puedan reutilizar un JWT si lo interceptan. La fecha de vencimiento es una marca de tiempo en segundos y no debe ser superior a 24 horas.
En Node.js, la fecha de vencimiento se establece con el siguiente código:
Math.floor(Date.now() / 1000) + 12 * 60 * 60;
Son 12 horas en lugar de 24 para evitar problemas con las diferencias de reloj entre la aplicación de envío y el servicio de envío de notificaciones push.
Por último, el valor de sub debe ser una URL o una dirección de correo electrónico de mailto.
Esto es para que, si un servicio push necesita comunicarse con el remitente, pueda encontrar la información de contacto en el JWT. (Por eso, la biblioteca de notificaciones push web necesitaba una dirección de correo electrónico).
Al igual que la información del JWT, los datos del JWT se codifican como una cadena base64 segura para URL.
La tercera cadena, la firma, es el resultado de tomar las dos primeras cadenas (la información del JWT y los datos del JWT), unirlas con un carácter de punto, al que llamaremos "token sin firmar", y firmarlo.
El proceso de firma requiere encriptar el "token sin firmar" con ES256. Según la especificación de JWT, ES256 es la abreviatura de "ECDSA que usa la curva P-256 y el algoritmo de hash SHA-256". Con Web Crypto, 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 pública del servidor de aplicaciones para desencriptar la firma y asegurarse de que la cadena desencriptada sea la misma que el "token sin firmar" (es decir, las dos primeras cadenas del JWT).
El JWT firmado (es decir, las tres cadenas unidas por puntos) se envía al servicio de envío de notificaciones push web como el encabezado Authorization con WebPush antepuesto, de la siguiente manera:
Authorization: 'WebPush [JWT Info].[JWT Data].[Signature]';
El protocolo de Web Push también indica que la clave pública del servidor de aplicaciones se debe enviar en el encabezado Crypto-Key como una cadena codificada en Base64 segura para URL con p256ecdsa= antepuesto.
Crypto-Key: p256ecdsa=[URL Safe Base64 Public Application Server Key]
La encriptación de la carga útil
A continuación, 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 entre quienes usaron otros servicios de envío de notificaciones push es por qué la carga útil de las notificaciones push web debe estar encriptada. Con las apps nativas, los mensajes push pueden enviar datos como texto sin formato.
Parte de la belleza de las notificaciones push web es que, como todos los servicios de notificaciones push usan la misma API (el protocolo de notificaciones push web), los desarrolladores no tienen que preocuparse por quién es el servicio de notificaciones push. Podemos hacer una solicitud en el formato correcto y esperar que se envíe un mensaje push. La desventaja es que los desarrolladores podrían enviar mensajes a un servicio de envío que no sea confiable. Al encriptar la carga útil, un servicio de envío no puede leer los datos que se envían. Solo el navegador puede desencriptar la información. Esto protege los datos del usuario.
La encriptación de la carga útil se define en la especificación de encriptación de mensajes.
Antes de analizar los pasos específicos para encriptar la carga útil de un mensaje push, debemos abordar algunas técnicas que se usarán durante el proceso de encriptación. (Agradecemos a Mat Scales por su excelente artículo sobre la encriptación push).
ECDH y HKDF
Tanto ECDH como HKDF se usan durante todo el proceso de encriptación y ofrecen beneficios para encriptar información.
ECDH: Intercambio de claves de curva elíptica de Diffie-Hellman
Imagina que tienes dos personas que quieren compartir información, Alicia y Roberto. Tanto Alicia como Roberto tienen sus propias claves públicas y privadas. Alice y Bob comparten sus claves públicas entre sí.
La propiedad útil de las claves generadas con ECDH es que Alicia puede usar su clave privada y la clave pública de Bob para crear el valor secreto "X". Bob puede hacer lo mismo, tomar su clave privada y la clave pública de Alice para crear de forma independiente el mismo valor "X". Esto convierte a "X" en un secreto compartido, y Alice y Bob solo tuvieron que compartir su clave pública. Ahora, Bob y Alice pueden usar la letra "X" para encriptar y desencriptar mensajes entre ellos.
Según mi conocimiento, ECDH define las propiedades de las curvas que permiten esta "función" de crear un secreto compartido "X".
Esta es una explicación general del ECDH. Si quieres obtener más información, te recomiendo que mires un video con una descripción general más detallada del ECDH.
En términos de código, la mayoría de los lenguajes y las plataformas incluyen bibliotecas para facilitar la generación de estas claves.
En Node, 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 sucinta 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 criptográficamente sólido. Se puede usar, por ejemplo, para convertir secretos compartidos intercambiados con Diffie-Hellman en material de clave adecuado para usar en la encriptación, la verificación de integridad o la autenticación.
Básicamente, HKDF tomará una entrada que no es particularmente segura y la hará más segura.
La especificación que define esta encriptación requiere el uso de SHA-256 como nuestro algoritmo de hash, y las claves resultantes para HKDF en las notificaciones push web no deben tener más de 256 bits (32 bytes).
En Node, esto se podría 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);
}
Agradecemos el artículo de Mat Scale por este código de ejemplo.
Esto abarca de forma general ECDH y HKDF.
ECDH es una forma segura de compartir claves públicas y generar un secreto compartido. HKDF es una forma de tomar material no seguro y hacerlo seguro.
Se usará durante el proceso de encriptación de nuestra carga útil. A continuación, veamos qué tomamos como entrada y cómo se encripta.
Entradas
Cuando queremos enviar un mensaje push a un usuario con una carga útil, necesitamos tres entradas:
- Es la carga útil en sí.
- Es el secreto
authdePushSubscription. - Es la clave
p256dhdel objetoPushSubscription.
Vimos que los valores de auth y p256dh se recuperan de un PushSubscription, pero, para un recordatorio rápido, dado un suscripción, necesitaríamos estos valores:
subscription.toJSON().keys.auth;
subscription.toJSON().keys.p256dh;
subscription.getKey('auth');
subscription.getKey('p256dh');
El valor de auth debe tratarse como secreto y no compartirse fuera de tu aplicación.
La clave p256dh es una clave pública, a veces denominada clave pública del cliente. Aquí nos referiremos a p256dh como la clave pública de la 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.
Salt
La sal debe tener 16 bytes de datos aleatorios. En NodeJS, haríamos lo siguiente para crear una sal:
const salt = crypto.randomBytes(16);
Claves públicas y privadas
Las claves públicas y privadas se deben generar con una curva elíptica P-256, que haríamos en Node de la siguiente 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 para la encriptación y no tienen relación con las claves del servidor de aplicaciones.
Con la carga útil, el secreto de autenticación y la clave pública de la suscripción como entradas, y con un nuevo conjunto de claves locales y sal generados, estamos listos para realizar la encriptación.
Secret compartido
El primer paso es crear un secreto compartido con la clave pública de la suscripción y nuestra nueva clave privada (¿recuerdas la explicación de ECDH con Alicia y Bob? Así de sencillo.
const sharedSecret = localKeysCurve.computeSecret(
subscription.keys.p256dh,
'base64',
);
Se usa en el siguiente paso para calcular la clave pseudoaleatoria (PRK).
Clave pseudoaleatoria
La clave pseudoaleatoria (PRK) es la combinación del secreto de autorización de la suscripción push 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 descifrar 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. Los navegadores que descifran el mensaje esperan esta cantidad de bytes para la codificación de contenido, seguidos de un byte con el valor 0 y, luego, los datos encriptados.
Nuestra clave pseudoaleatoria 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, la hace más segura a nivel criptográfico).
Contexto
El "contexto" es un conjunto de bytes que se usa para calcular dos valores más adelante en el navegador de encriptación. Es básicamente un array de bytes que contiene la clave pública de la 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 de la clave pública de la suscripción, seguida de la clave en sí, luego la cantidad de bytes de la clave pública local, seguida de 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 los ataques de repetición, ya que solo se debe usar una vez.
La clave de encriptación de contenido (CEK) es la clave que, en última instancia, se usará para encriptar nuestra carga útil.
Primero, debemos crear los bytes de datos para el nonce y la CEK, que son simplemente una cadena 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 ejecuta a través de 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 proporciona la clave de encriptación de contenido y el nonce.
Realiza la encriptación
Ahora que tenemos nuestra clave de encriptación de contenido, podemos encriptar la carga útil.
Creamos un cifrado AES128 con la clave de encriptación de contenido como clave y el nonce como vector de inicialización.
En Node, 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 relleno queremos agregar al principio de la carga útil. El motivo por el que querríamos agregar relleno es que evita el riesgo de que los intrusos puedan determinar los "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 relleno, tendrás dos bytes con el valor 0, es decir, no existe relleno. Después de estos dos bytes, leerás la carga útil. Si agregaste 5 bytes de padding, los primeros dos 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 nuestro padding y carga útil a través de este 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 queda determinar cómo se envía esta carga útil al servicio de envío de notificaciones push.
Cuerpo y encabezados de carga útil encriptados
Para enviar esta carga útil encriptada al servicio de envío de notificaciones push, debemos definir algunos encabezados diferentes en nuestra solicitud POST.
Encabezado de encriptación
El encabezado "Encryption" debe contener la sal que se usó para encriptar la carga útil.
La sal de 16 bytes debe codificarse en base64 segura para URL y agregarse al encabezado de Encryption, de la siguiente manera:
Encryption: salt=[URL Safe Base64 Encoded Salt]
Encabezado Crypto-Key
Vimos que el encabezado Crypto-Key se usa en la sección "Claves del servidor de aplicaciones" para contener la clave pública del servidor de aplicaciones.
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 de la siguiente manera:
Crypto-Key: dh=[URL Safe Base64 Encoded Local Public Key String]; p256ecdsa=[URL Safe Base64 Encoded Public Application Server Key]
Encabezados de tipo de contenido, longitud y codificación
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, debemos enviar la carga útil encriptada como el cuerpo de nuestra solicitud. Observa que Content-Type se configura como application/octet-stream. Esto se debe a que la carga útil encriptada se debe enviar como un flujo de bytes.
En NodeJS, haríamos lo siguiente:
const pushRequest = https.request(httpsOptions, function(pushResponse) {
pushRequest.write(encryptedPayload);
pushRequest.end();
¿Más encabezados?
Ya vimos 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 push) 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 permanezca en el servicio push antes de que se entregue. Cuando vence el TTL, el mensaje se quita de la cola del servicio de envío y no se entrega.
TTL: [Time to live in seconds]
Si estableces un valor de 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 quitará 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. Puedes saber si esto ocurrió examinando el encabezado TTL en la respuesta de un servicio de envío.
Tema
Opcional
Los temas son cadenas que se pueden usar para reemplazar mensajes pendientes por mensajes nuevos si tienen nombres de temas coincidentes.
Esto es útil en situaciones en las que se envían varios mensajes mientras un dispositivo está sin conexión y solo quieres que el usuario vea el mensaje más reciente cuando se enciende el dispositivo.
Urgencia
Opcional
La urgencia indica al servicio push qué tan importante es un mensaje para el usuario. El servicio de envío de notificaciones push puede usar este valor para ayudar a conservar la duración de batería del dispositivo de un usuario, ya que solo se activa para los 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 consultar 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 deberás 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 de notificaciones push
Una vez que hayas realizado una solicitud a un servicio de envío, deberás verificar el código de estado de la respuesta, ya que te indicará si la solicitud se realizó correctamente 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 | Hay demasiadas solicitudes. Esto significa que tu servidor de aplicaciones alcanzó un límite de frecuencia con un servicio de envío de notificaciones push. El servicio de envío debe incluir un encabezado "Retry-After" para indicar cuánto tiempo debe transcurrir antes de 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 no tiene el formato correcto. |
| 404 | No se encontró. Esto indica que la suscripción venció y no se puede usar. En este caso, debes borrar el objeto `PushSubscription` y esperar a que el cliente vuelva a suscribir al usuario. |
| 410 | Se fue. La suscripción ya no es válida y se debe quitar del servidor de aplicaciones. Esto se puede reproducir llamando a `unsubscribe()` en un objeto `PushSubscription`. |
| 413 | El tamaño de la carga útil es demasiado grande. La carga útil de tamaño mínimo que debe admitir un servicio de envío es de 4,096 bytes (o 4 KB). |
También puedes consultar el estándar de Web Push (RFC8030) para obtener más información sobre los códigos de estado HTTP.
Próximos pasos
- Descripción general de las notificaciones push web
- Cómo funciona Push
- Cómo suscribir a un usuario
- UX de permisos
- Envía mensajes con las bibliotecas de Web Push
- Protocolo de Web Push
- Cómo controlar eventos push
- Cómo mostrar una notificación
- Comportamiento de las notificaciones
- Patrones de notificación comunes
- Preguntas frecuentes sobre las notificaciones push
- Problemas habituales y cómo informar errores