Ya vimos cómo se puede usar una biblioteca para activar mensajes de envío ¿están haciendo exactamente estas bibliotecas?
Hacen solicitudes de red y, a la vez, se aseguran de que estas el formato correcto. La especificación que define esta solicitud de red es Protocolo de envío web.
En esta sección, se describe cómo el servidor puede identificarse con la aplicación las claves de servidor y cómo se envían la carga útil encriptada y los datos asociados.
Este no es el lado más bello de web push y yo no soy experto en encriptación, pero veamos cada pieza, ya que es útil saber qué hacen estas bibliotecas en niveles más profundos.
Claves de servidor de aplicaciones
Cuando suscribemos a un usuario, pasamos un applicationServerKey
. Esta clave es
pasan al servicio push y se usan para comprobar que la aplicación que se suscribía
el usuario es también la aplicación que activa los mensajes de envío.
Cuando activamos un mensaje push, se envía un conjunto de encabezados permitir que el servicio push autentique la aplicación. (Esto se define según la especificación de VAPID).
¿Qué significa todo esto en realidad y qué sucede exactamente? Bueno, estos son los pasos que se siguieron para autenticación del servidor de aplicaciones:
- El servidor de aplicaciones firma información JSON con su clave de aplicación privada.
- Esta información firmada se envía al servicio de envío como un encabezado en una solicitud POST.
- El servicio push utiliza la clave pública almacenada de la que recibió
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 elapplicationServerKey
que se pasa a la llamada de suscripción. - Si la información firmada es válida, el servicio push envía el mensaje mensaje al usuario.
A continuación, se muestra un ejemplo de este flujo de información. (Observa la leyenda en la esquina inferior izquierda para indicar claves públicas y privadas).
La “información firmada” agregado al encabezado de 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 la envió.
Cuando un tercero recibe un mensaje, debe obtener los remitentes clave pública y usarla para validar la firma del JWT. Si el botón firma es válida, el JWT debe haberse firmado con el nombre de por lo que debe ser del remitente esperado.
https://jwt.io/ tiene una gran cantidad de bibliotecas puede realizar la firma por ti. Te recomiendo que lo hagas donde puedes hacerlo. Para completarlo, veamos cómo crear manualmente un JWT firmado.
Envío web y JWT firmados
Un JWT firmado es solo una cadena, aunque puede considerarse como tres cadenas unidas por puntos.
La primera y la segunda cadena (información y datos de JWT) son partes JSON codificado en base64, lo que significa que es de lectura pública.
La primera cadena es información sobre el JWT, que indica qué algoritmo se usó para crear la firma.
La información de JWT para el envío web debe contener la siguiente información:
{
"typ": "JWT",
"alg": "ES256"
}
La segunda cadena corresponde a los datos de JWT. Esto proporciona información sobre el remitente del JWT, que su duración y el tiempo de validez.
Para el envío web, los datos tendrían este 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 la Web, envía el
El público es el servicio push, así que lo configuramos en el origen del
servicio.
El valor exp
es el vencimiento del JWT. Esto evita que los espías
volver a usar un JWT si lo interceptan. El vencimiento es una marca de tiempo en
segundos y no debe superar las 24 horas.
En Node.js, el vencimiento se configura de la siguiente manera:
Math.floor(Date.now() / 1000) + 12 * 60 * 60;
Se deben evitar 12 horas en lugar de 24 horas. problemas con las diferencias de reloj entre la aplicación emisora y el servicio push.
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 push necesita comunicarse con el remitente, pueda
la información de contacto del JWT. (Por eso la biblioteca web-push necesitaba una
dirección de correo electrónico).
Al igual que la información de JWT, los datos de JWT se codifican como una base64 segura para URL una cadena vacía.
La tercera cadena, la firma, es el resultado de tomar las dos primeras cadenas (información de JWT y datos de JWT), uniéndolos con un carácter de punto, que llamar al “token sin firmar” y firmarlo.
El proceso de firma requiere encriptar el "token sin firmar" con ES256. De acuerdo con la columna JWT spec, ES256 es la sigla en inglés 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 push puede validar un JWT usando la clave de servidor de la aplicación pública para desencriptar la firma y asegurarse de que la cadena desencriptada sea la misma como el "token sin firmar" (es decir, las dos primeras cadenas en el JWT).
El JWT firmado (es decir, las tres cadenas unidas por puntos) se envía a la Web
servicio de envío 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 establece que la clave de servidor de la aplicación pública debe
enviado 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 carga útil
Ahora, veamos cómo podemos enviar una carga útil con un mensaje push para que cuando nuestra app web recibe un mensaje de envío, puede acceder a los datos que recibe.
Una pregunta común que surge de quienes han usado otros servicios push es ¿por qué la web envía carga útil deben encriptarse? Con las apps nativas, los mensajes push pueden enviar datos como texto sin formato.
Parte de la ventaja de web push es que debido a que todos los servicios push utilizan el misma API (el protocolo web push), los desarrolladores no tienen que importar el servicio push. Podemos enviar una solicitud con el formato correcto y esperar un el mensaje push que se enviará. La desventaja de esto es que los desarrolladores enviar mensajes a un servicio push que no sea confiable. De encriptando la carga útil, un servicio push no puede leer los datos que se envían. Solo el navegador puede desencriptar la información. Esto protege la privacidad del usuario de datos no estructurados.
La encriptación de la carga útil se define en el campo Encriptación de mensajes spec.
Antes de ver los pasos específicos para encriptar la carga útil de un mensaje de envío, deberíamos hablar de algunas técnicas que se usarán durante el proceso el proceso de administración de recursos. (Gran sugerencia de sombrero a Mat Scales por su excelente artículo sobre el empuje encryption.)
ECDH y HKDF
Tanto ECDH como HKDF se usan en todo el proceso de encriptación y ofrecen beneficios para la de 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 Alice como Bob tienen sus propias claves públicas y privadas. Alicia y Roberto 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". Roberto puede hacer mismo, tomando su clave privada y la pública de Alice para crear de forma independiente el mismo valor 'X'. Esto hace que 'X' un secreto compartido y Alice y Bob solo tuvieron que compartir su clave pública. Ahora Bob y Alice puedes usar 'X' para encriptar y desencriptar mensajes entre ellas.
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 que mires este video.
En términos de código, la mayoría de los lenguajes / plataformas incluyen bibliotecas para hacerlo generar estas claves fácilmente.
En el 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 del HKDF:
HKDF es una función de derivación de claves basada en HMAC que transforma cualquier clave débil en material de claves criptográficamente seguro. Puede usarse para Por ejemplo, para convertir a Diffie Hellman en un intercambio de secretos compartidos en material clave, adecuados para su uso en 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 de hash y las claves resultantes para el HKDF en notificaciones push web no deben superar los 256 bits (32 bytes).
En el nodo, 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);
}
Sugerencia sobre el artículo de Mat Scale para este código de ejemplo.
Esto cubre parcialmente el ECDH y el 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 hacer que sea seguro.
que se usará durante la encriptación de nuestra carga útil. A continuación, veamos lo que tomamos como y cómo se encripta.
Entradas
Cuando queremos enviar un mensaje push a un usuario con una carga útil, necesitamos tres entradas:
- La carga útil en sí.
- El secreto
auth
dePushSubscription
. - La clave
p256dh
dePushSubscription
Vimos que los valores auth
y p256dh
se recuperan de un PushSubscription
, pero para un
Un recordatorio rápido. En el caso de una suscripción, necesitaremos estos valores:
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 pública y, a veces, se denomina clave pública del cliente. Aquí
nos referiremos a p256dh
como la clave pública de la suscripción. Se genera la clave pública de la suscripción
el navegador. El navegador mantendrá la clave privada en secreto y la usará para desencriptar el
la carga útil útil.
Estos tres valores, auth
, p256dh
y payload
, son necesarios como entradas y el resultado de la
de encriptación será la carga útil encriptada, un valor de sal y una clave pública que se usará
la encriptación de los datos.
Sal
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 o privadas
Las claves públicas y privadas se deben generar usando una curva elíptica P-256, lo que haremos en Node:
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 encriptación y tienen no tiene nada que ver con las claves del servidor de la aplicación.
Con la carga útil, el secreto de autenticación y la clave pública de suscripción como entradas y con un archivo y un conjunto de claves locales, estamos listos para hacer un poco de encriptación.
Secreto compartido
El primer paso es crear un secreto compartido usando la clave pública de suscripción y nuestra nueva (¿recuerdas la explicación de ECDH con Alice y Bob? Así de simple).
const sharedSecret = localKeysCurve.computeSecret(
subscription.keys.p256dh,
'base64',
);
Se utiliza en el siguiente paso para calcular la clave seudoaleatoria (PRK).
Clave pseudoaleatoria
La clave seudoaleatoria (PRK) es la combinación de la autenticación de la suscripción de envío secreto 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);
Es posible que 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. Este es
de lo que esperan los navegadores que desencriptan el mensaje, que esperará
para la codificación de contenido, seguido de un byte con el valor 0 y del
y mantener los datos encriptados.
Nuestra clave seudoaleatoria solo ejecuta la autenticación, el secreto compartido y una parte de la información de codificación a través del HKDF (es decir, fortalecerlo a nivel criptográfico).
Contexto
El “contexto” es un conjunto de bytes que se usa para calcular dos valores más adelante en la encriptación navegador. En esencia, se trata de un array de bytes que contiene la clave pública de la suscripción y la con una 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 la suscripción, seguido de la clave en sí, la cantidad de bytes de la clave pública local y a sí mismo.
Con este valor de contexto, podemos usarlo en la creación de un nonce y una clave de encriptación de contenido. (CEK).
Clave y nonce de encriptación de contenido
Un nonce es un valor que impide la repetición. ataques, ya que solo deben usarse una vez.
La clave de encriptación de contenido (CEK) es la clave que finalmente se usará para encriptar nuestra carga útil.
Primero, debemos crear los bytes de datos para el nonce y el CEK, que es simplemente una cadena de codificación 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 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 el nonce y la clave de encriptación de contenido.
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 la carga útil, debemos definir cuánto relleno deseamos para agregar al principio 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 relleno para indicar la longitud de cualquier relleno adicional.
Por ejemplo, si no agregaras padding, tendrás dos bytes con el valor 0, es decir, si no hay padding. Después de esos dos bytes, leerás la carga útil. Si agregaste 5 bytes de relleno, los primeros dos bytes tendrán un valor de 5, por lo que el consumidor leerá cinco bytes adicionales y 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 relleno 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!
Lo único que resta es determinar cómo se envía esta carga útil al servicio push.
Encabezados de carga útil encriptados y cuerpo
Para enviar esta carga útil encriptada al servicio push, debemos definir algunos encabezados diferentes en nuestra solicitud POST.
Encabezado de encriptación
La clave de encriptación debe contener la sal que se usó para encriptar la carga útil.
La sal de 16 bytes debe tener una URL base64 codificada y debe agregarse al encabezado de encriptación de la siguiente manera:
Encryption: salt=[URL Safe Base64 Encoded Salt]
Encabezado de la clave criptográfica
Vimos que el encabezado Crypto-Key
se usa en “Claves de servidor de aplicaciones”
para que contenga 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]
Tipo de contenido, longitud y codificar encabezados
El encabezado Content-Length
es la cantidad de bytes en la
la carga útil útil. Content-Type y "Content-Encoding" encabezados 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
está configurado como
application/octet-stream
Esto se debe a que la carga útil encriptada
se envía como un flujo de bytes.
En Node.js haremos esto de la siguiente manera:
const pushRequest = https.request(httpsOptions, function(pushResponse) {
pushRequest.write(encryptedPayload);
pushRequest.end();
¿Más encabezados?
Hemos hablado de los encabezados usados para las claves JWT / servidor de aplicaciones (es decir, cómo identificar con el servicio push), y abordamos los encabezados que se usan para enviar un archivo la carga útil útil.
Existen encabezados adicionales que los servicios de envío usan para alterar el comportamiento de mensajes enviados. Algunos de estos encabezados son obligatorios y otros opcionales.
Encabezado del TTL
Obligatorio
TTL
(o tiempo de actividad) es un número entero que especifica la cantidad de segundos.
es preferible que tu mensaje push
esté en el servicio push antes de
entregado. Cuando venza el TTL
, el mensaje se quitará de la
la lista del servicio de envío
y no se entregará.
TTL: [Time to live in seconds]
Si estableces TTL
en cero, el servicio push intentará entregar
inmediatamente, pero si no se puede establecer la conexión con el dispositivo,
se eliminarán de inmediato de la cola de servicios de aplicaciones.
Técnicamente, un servicio push puede reducir el TTL
de un mensaje push si
desea. Para saber si esto sucedió, examina el encabezado TTL
en
la respuesta de un servicio push.
Tema
Opcional
Los temas son cadenas que se pueden usar para reemplazar un mensaje pendiente por una mensaje nuevo 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 cuando el dispositivo esté encendido.
Urgencia
Opcional
La urgencia le indica al servicio push qué tan importante es un mensaje para el usuario. Esta pueden ser utilizados por el servicio push para ayudar a conservar la duración de batería del dispositivo de un usuario Despierta 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 en un solo lugar
Si tienes más preguntas sobre cómo funciona todo esto, puedes ver cómo se activan las bibliotecas enviar mensajes en la organización web-push-libs
Una vez que cuentes con una carga útil encriptada y con los encabezados anteriores, solo necesitas realizar una solicitud POST.
al endpoint
en un PushSubscription
.
¿Qué hacemos con la respuesta a esta solicitud POST?
Respuesta del servicio push
Una vez que hayas realizado una solicitud a un servicio push, 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 el servidor de tu aplicación alcanzó una tasa límite con un servicio push. El servicio push debe incluir un valor "Retry-After" para indicar cuánto tiempo antes de que se pueda realizar otra solicitud. |
400 | Solicitud no válida. Por lo general, esto significa que uno de los encabezados no es válido. o con un formato incorrecto. |
404 | No se encontró. Esta es una indicación de que la suscripción venció y no se pueden usar. En este caso, debes borrar la `PushSubscription` y esperar a que el cliente vuelva a suscribirlo. |
410 | Se fue. La suscripción ya no es válida y debería quitarse del servidor de aplicaciones. Esto se puede reproducir llamando `unsubscribe()` en una `PushSubscription`. |
413 | El tamaño de la carga útil es demasiado grande. El tamaño mínimo de carga útil que debe tener un servicio de envío la compatibilidad es de 4,096 bytes (o 4 KB). |
Próximos pasos
- Descripción general de las notificaciones push web
- Cómo funciona el envío
- Cómo suscribir a un usuario
- UX de permisos
- Envía mensajes con bibliotecas push web
- Protocolo de envío web
- Maneja eventos de envío
- Cómo mostrar una notificación
- Comportamiento de las notificaciones
- Patrones de notificación comunes
- Preguntas frecuentes sobre las notificaciones push
- Problemas comunes e informar errores