Protocole Web push

Nous avons vu comment une bibliothèque peut être utilisée pour déclencher des messages push, mais que ce que font exactement ces bibliothèques ?

Eh bien, ils effectuent des requêtes réseau tout en s'assurant que ces demandes sont au bon format. La spécification qui définit cette requête réseau est la Protocole Web Push

Schéma de l'envoi d'un message push depuis votre serveur vers une méthode push
service

Cette section décrit comment le serveur peut s'identifier à l'aide des applications les clés de serveur et comment la charge utile chiffrée et les données associées sont envoyées.

Ce n’est pas un joli côté du web push et je ne suis pas un expert en chiffrement, mais voyons chaque élément car c'est pratique de savoir ce que font ces bibliothèques en arrière-plan.

Clés du serveur d'applications

Lorsque nous abonneons un utilisateur, nous transmettons un applicationServerKey. Cette clé est transmis au service push et utilisé pour vérifier que l'application qui s'est abonnée l'utilisateur est aussi l'application qui déclenche des messages push.

Lorsque nous déclenchons un message push, nous envoyons un ensemble d'en-têtes pour autoriser le service push à authentifier l'application. (Il s'agit par la spécification VAPID.)

Qu'est-ce que cela signifie concrètement et que se passe-t-il exactement ? Eh bien, ce sont les mesures prises pour authentification du serveur d'application:

  1. Le serveur d'applications signe des informations JSON avec sa clé d'application privée.
  2. Ces informations signées sont envoyées au service push sous la forme d'un en-tête dans une requête POST.
  3. Le service push utilise la clé publique stockée qu'il a reçue depuis pushManager.subscribe() pour vérifier que les informations reçues sont signées par la clé privée associée à la clé publique. Rappel: La clé publique est applicationServerKey transmis dans l'appel d'abonnement.
  4. Si les informations signées sont valides, le service push envoie à l'utilisateur.

Vous trouverez ci-dessous un exemple de ce flux d'informations. (Notez la légende en bas à gauche pour indiquer clés publiques et privées.)

Illustration de l'utilisation de la clé de serveur d'application privée lors de l'envoi d'un
message

Les "informations signées" ajouté à un en-tête de la requête est un jeton Web JSON.

Jeton Web JSON

Un jeton Web JSON (ou JWT, en abrégé) permet de l’envoi d’un message à un tiers afin que le destinataire puisse valider qui l'a envoyé.

Lorsqu'un tiers reçoit un message, il doit faire en sorte que les expéditeurs et utilisez-la pour valider la signature du jeton JWT. Si le la signature est valide, alors le jeton JWT doit avoir été signé avec le jeton d'accès la clé privée doit donc provenir de l'expéditeur attendu.

Sur https://jwt.io/, de nombreuses bibliothèques sont disponibles. la signature pour vous. Je vous recommande de le faire que possible. Pour garantir l'exhaustivité, voyons comment créer manuellement un jeton JWT signé.

Déploiement Web et jetons JWT signés

Un jeton JWT signé n'est qu'une chaîne, bien qu'il puisse être considéré comme trois chaînes jointes par points.

Illustration des chaînes dans un fichier Web JSON
Jeton

Les première et deuxième chaînes (informations et données JWT) sont des éléments JSON encodé en base64, ce qui signifie qu'il est lisible publiquement.

La première chaîne contient des informations sur le jeton JWT lui-même, indiquant l'algorithme a été utilisé pour créer la signature.

Les informations JWT pour le service Web push doivent contenir les informations suivantes:

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

La deuxième chaîne correspond aux données JWT. Elle fournit des informations sur l'expéditeur du jeton JWT, qui pour laquelle il est destiné et sa durée de validité.

Pour le transfert Web, les données doivent respecter le format suivant:

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

La valeur aud correspond à "l'audience", c'est-à-dire à qui le JWT est destiné. Pour le Web, envoyez audience étant le service push, nous le définissons sur l'origine de la transmission service.

La valeur exp correspond à l'expiration du jeton JWT, ce qui empêche les pirates d'être pouvoir réutiliser un jeton JWT s'il l'intercepte. L'expiration est un code temporel secondes et ne doit pas dépasser 24 heures.

Dans Node.js, le délai d'expiration est défini à l'aide de la commande suivante:

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

Il est de 12 heures au lieu de 24 heures en cas de problème de différence d'horloge entre l'application émettrice et le service push.

Enfin, la valeur sub doit être une URL ou une adresse e-mail mailto. Ainsi, si un service push a besoin de contacter l'expéditeur, il peut trouver du jeton JWT. (C'est pourquoi la bibliothèque web-push avait besoin d'un adresse e-mail).

Tout comme les informations JWT, les données JWT sont encodées au format base64 sécurisé pour les URL. .

La troisième chaîne, la signature, est le résultat de la prise des deux premières chaînes (les informations et les données JWT), en les associant à l'aide d'un point appeler le « jeton non signé » et le signer.

Le processus de signature nécessite le chiffrement du "jeton non signé" avec ES256. Selon le jeton JWT spécification, ES256 est l'abréviation de "ECDSA utilisant la courbe P-256 et l'algorithme de hachage SHA-256". À l'aide de web crypto, vous pouvez créer la signature comme suit:

// 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 service push peut valider un jeton JWT à l'aide de la clé publique du serveur d'applications pour déchiffrer la signature et s'assurer que la chaîne déchiffrée est la même en tant que "jeton non signé" (c'est-à-dire les deux premières chaînes du jeton JWT).

Le jeton JWT signé (c'est-à-dire les trois chaînes reliées par des points) est envoyé sur le Web push comme en-tête Authorization avec le préfixe WebPush, comme suit:

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

Le protocole Web Push indique également que la clé du serveur d'application publique doit être envoyée dans l'en-tête Crypto-Key en tant que chaîne encodée en base64 sécurisée pour les URL avec p256ecdsa= a été ajouté au début.

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

Chiffrement de la charge utile

Voyons maintenant comment envoyer une charge utile avec un message push. Ainsi, lorsque notre application Web reçoit un message push, il peut accéder aux données qu'il reçoit.

Les utilisateurs d'autres services push se demandent souvent pourquoi le Web pousse doivent-elles être chiffrées ? Avec les applications natives, les messages push peuvent envoyer des données en texte brut.

L'avantage du Web push est que tous les services push utilisent même API (protocole Web push), les développeurs n'ont pas à se soucier de l'identité le service push. Nous pouvons formuler une demande dans le bon format et nous attendre à recevoir push à envoyer. L'inconvénient est que les développeurs peuvent d’envoyer des messages à un service push non fiable. Par chiffrer la charge utile, un service push ne peut pas lire les données envoyées. Seul le navigateur peut déchiffrer les informations. Cela protège l'expérience utilisateur données.

Le chiffrement de la charge utile est défini dans la section Message Encryption caractéristiques.

Avant de nous pencher sur les étapes spécifiques de chiffrement d'une charge utile de messages push, nous devrions aborder certaines techniques qui seront utilisées lors du chiffrement processus. (Le fameux chapeau s'est donné encryption.)

ECDH et HKDF

ECDH et HKDF sont tous deux utilisés tout au long du processus de chiffrement et offrent des avantages du chiffrement des informations.

ECDH: Échange de clés Diffie-Hellman sur courbe elliptique

Imaginez que deux personnes souhaitent partager des informations : Alice et Bob. Alice et Bob possèdent leurs propres clés publique et privée. Alice et Bob partagent leurs clés publiques entre eux.

L'utilité des clés générées avec ECDH est qu'Alice peut utiliser son la clé privée et la clé publique de Bob pour créer la valeur secrète « X ». Bob peut faire de même, en prenant sa clé privée et la clé publique d'Alice pour créent indépendamment la même valeur "X". Cela rend « X » un secret partagé et Alice et Bob n'avaient qu'à partager leur clé publique. Bob et Alice peut utiliser "X" pour chiffrer et déchiffrer les messages entre eux.

À ma connaissance, l'ECDH définit les propriétés des courbes permettant cette "caractéristique" de créer un secret partagé « X ».

Ceci est une explication générale de l'ECDH. Si vous souhaitez en savoir plus, je vous recommande de regarder cette vidéo.

En termes de code : la plupart des langages / plates-formes sont livrés avec des bibliothèques pour qu'il est facile de générer ces clés.

Dans le nœud, procédez comme suit:

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

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

HKDF: fonction de dérivation de clé basée sur HMAC

Wikipédia propose une brève description de HKDF:

HKDF est une fonction de dérivation de clé basée sur HMAC qui transforme les clés faibles en matériel de clé cryptographique. Il peut être utilisé, pour par exemple, pour convertir les secrets partagés de Diffie Hellman en matériel de clé adaptée au chiffrement, à la vérification de l'intégrité ou à l'authentification.

En bref, HKDF prend des entrées qui ne sont pas particulièrement sécurisées et les rend plus sûres.

La spécification définissant ce chiffrement nécessite l'utilisation de SHA-256 comme algorithme de hachage. Les clés résultantes pour HKDF en mode Web push ne doivent pas dépasser 256 bits. (32 octets).

Dans le nœud, cela peut être implémenté comme suit:

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

Pointez le chapeau à l'article de Mat Scale pour cet exemple de code.

Cela couvre globalement les événements ECDH et HKDF.

ECDH est un moyen sécurisé de partager des clés publiques et de générer un secret partagé. HKDF est un moyen d’emmener matériel non sécurisé et le sécuriser.

Il sera utilisé lors du chiffrement de notre charge utile. Voyons maintenant ce que nous prenons en tant que l'entrée et la façon dont elle est chiffrée.

Entrées

Pour envoyer un message push à un utilisateur avec une charge utile, nous avons besoin de trois entrées:

  1. La charge utile elle-même.
  2. Le secret auth de PushSubscription.
  3. La clé p256dh de PushSubscription.

Nous avons constaté que les valeurs auth et p256dh étaient récupérées à partir d'un PushSubscription, mais pour une Petit rappel : pour un abonnement, nous aurions besoin des valeurs suivantes :

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

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

La valeur auth doit être traitée comme un secret et ne doit pas être partagée en dehors de votre application.

La clé p256dh est une clé publique, parfois appelée clé publique du client. Ici, nous désignerons p256dh comme clé publique d'abonnement. La clé publique d'abonnement est générée par le navigateur. Le navigateur conservera la clé privée secrète et l'utilisera pour déchiffrer la charge utile.

Ces trois valeurs (auth, p256dh et payload) sont nécessaires en tant qu'entrées. Le résultat de la processus de chiffrement sera la charge utile chiffrée, une valeur de salage et une clé publique utilisée uniquement pour le chiffrement des données.

Sel

La valeur de salage doit être constituée de 16 octets de données aléatoires. Dans NodeJS, nous procéderons comme suit pour créer une valeur salt:

const salt = crypto.randomBytes(16);

Clés publiques / privées

Les clés publiques et privées doivent être générées à l'aide d'une courbe elliptique P-256 ce que nous ferions dans Node comme ceci:

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

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

Ces clés sont appelées "clés locales". Ils sont utilisés uniquement pour le chiffrement et ont rien à voir avec les clés du serveur d'applications.

Avec la charge utile, le secret d'authentification et la clé publique d'abonnement en tant qu'entrées, et avec un nouveau code et un ensemble de clés locales, nous sommes prêts à effectuer un chiffrement.

Clé secrète partagée

La première étape consiste à créer un secret partagé à l'aide de la clé publique d'abonnement et de notre nouvelle (vous vous souvenez de l'explication ECDH avec Alice et Bob ? Juste comme ça).

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

Elle sera utilisée à l'étape suivante pour calculer la clé pseudo-aléatoire (PRK).

Clé pseudo-aléatoire

La clé pseudo-aléatoire (PRK) est la combinaison des identifiants d'authentification de l'abonnement push secret et le secret partagé que nous venons de créer.

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

Vous vous demandez peut-être à quoi sert la chaîne Content-Encoding: auth\0. En bref, son objectif n'est pas clair, même si les navigateurs peuvent déchiffrer un message entrant et rechercher l’encodage de contenu attendu. \0 ajoute un octet avec une valeur de 0 à la fin du tampon. C'est attendu par les navigateurs déchiffrant le message, qui attendra autant d'octets pour l'encodage du contenu, suivi d'un octet de valeur 0, suivi du données chiffrées.

Notre clé pseudo-aléatoire exécute simplement l'authentification, le secret partagé et une information d'encodage via HKDF (autrement dit, en renforçant le chiffrement).

Contexte

Le « contexte » est un ensemble d'octets utilisé pour calculer deux valeurs ultérieurement dans le chiffrement navigateur. Il s'agit essentiellement d'un tableau d'octets contenant la clé publique de l'abonnement et le clé publique locale.

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

Le tampon de contexte final est une étiquette, le nombre d'octets de la clé publique de l'abonnement, suivi de la clé elle-même, puis du nombre d'octets de la clé publique locale et de la clé. lui-même.

Cette valeur de contexte nous permet de l'utiliser pour créer un nonce et une clé de chiffrement de contenu (CEK).

Clé de chiffrement du contenu et nonce

Un nonce est une valeur qui empêche la relecture. car il ne doit être utilisé qu’une seule fois.

La clé de chiffrement de contenu (CEK) est la clé qui sera utilisée à terme pour chiffrer la charge utile.

Nous devons d'abord créer les octets de données pour le nonce et la CEK, qui sont simplement un contenu d'encodage suivie du tampon de contexte que nous venons de calculer:

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

Ces informations sont transmises via HKDF en combinant le salage et la PRK avec nonceInfo et 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);

Cela nous donne notre nonce et notre clé de chiffrement du contenu.

Effectuer le chiffrement

Maintenant que nous avons notre clé de chiffrement du contenu, nous pouvons chiffrer la charge utile.

Nous créons un chiffrement AES128 à l'aide de la clé de chiffrement du contenu comme clé et le nonce est un vecteur d'initialisation.

Dans Node.js, procédez comme suit:

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

Avant de chiffrer notre charge utile, nous devons définir la marge intérieure souhaitée. à ajouter au début de la charge utile. La raison pour laquelle nous voulons ajouter une marge intérieure est qu'elle empêche le risque que des écoutes clandestines puissent déterminer "types" en fonction de la taille de la charge utile.

Vous devez ajouter deux octets de marge intérieure pour indiquer la longueur de toute marge intérieure supplémentaire.

Par exemple, si vous n'avez pas ajouté de marge intérieure, vous disposez de deux octets avec la valeur 0, c'est-à-dire qu'il n'existe aucune marge intérieure. Après ces deux octets, vous lirez la charge utile. Si vous avez ajouté cinq octets de marge intérieure, les deux premiers octets auront une valeur de 5. Le consommateur lira alors cinq octets supplémentaires, puis commencera à lire la charge utile.

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

Nous exécutons ensuite notre remplissage et notre charge utile via ce chiffrement.

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

Nous avons maintenant notre charge utile chiffrée. Super !

Il ne reste plus qu'à déterminer comment cette charge utile est envoyée au service push.

En-têtes de charge utile chiffrés corps

Pour envoyer cette charge utile chiffrée au service push, nous devons définir quelques différents en-têtes dans notre requête POST.

En-tête de chiffrement

La couche "Chiffrement" doit contenir le salt utilisé pour chiffrer la charge utile.

La valeur salt de 16 octets doit être encodée au format URL base64 et ajoutée à l'en-tête de chiffrement, comme suit:

Encryption: salt=[URL Safe Base64 Encoded Salt]

En-tête de clé cryptographique

Nous avons vu que l'en-tête Crypto-Key est utilisé sous "Application Server Keys" (Clés du serveur d'applications). contenant la clé publique du serveur d'applications.

Cet en-tête est également utilisé pour partager la clé publique locale utilisée pour chiffrer la charge utile.

L'en-tête obtenu ressemble à ceci:

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

Type, durée et en-têtes d'encodage

L'en-tête Content-Length correspond au nombre d'octets de l'en-tête chiffré charge utile. "Content-Type" et "Content-Encoding" sont des valeurs fixes. Ce processus est illustré ci-dessous.

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

Avec ces en-têtes définis, nous devons envoyer la charge utile chiffrée en tant que corps de notre demande. Notez que Content-Type est défini sur application/octet-stream En effet, la charge utile chiffrée doit être envoyées sous forme de flux d'octets.

Dans NodeJS, nous procéderons comme suit:

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

Plus d'en-têtes ?

Nous avons vu les en-têtes utilisés pour les clés JWT et de serveur d'applications (c'est-à-dire comment identifier les application avec le service push) et nous avons vu les en-têtes utilisés pour envoyer un message chiffré charge utile.

Les services push utilisent des en-têtes supplémentaires pour modifier le comportement messages envoyés. Certains de ces en-têtes sont obligatoires, d'autres sont facultatifs.

En-tête TTL

Obligatoire

TTL (ou valeur TTL) est un entier spécifiant le nombre de secondes. vous souhaitez que votre message push reste actif sur le service push avant d'être livrés. Lorsque le TTL expire, le message est supprimé du en file d’attente du service d’envoi et il ne sera pas distribué.

TTL: [Time to live in seconds]

Si vous définissez un TTL sur zéro, le service push tente de distribuer le s'il s'affiche immédiatement, mais si l'appareil n'est pas accessible, votre message est immédiatement supprimé de la file d'attente du service d'envoi.

Techniquement, un service push peut réduire la TTL d'un message push s'il veut. Pour savoir si c'est le cas, examinez l'en-tête TTL dans la réponse d'un service push.

Thème

Optional

Les sujets sont des chaînes pouvant être utilisées pour remplacer un message en attente par un nouveau message s'ils ont des noms de sujet correspondants.

Cela peut s'avérer utile lorsqu'un utilisateur envoie plusieurs messages n'est pas connecté à Internet, et vous voulez que seuls les utilisateurs s'affiche lorsque l'appareil est allumé.

Urgence

Optional

Le niveau d'urgence indique au service push l'importance d'un message pour l'utilisateur. Ce peut être utilisé par le service push pour préserver l'autonomie de la batterie de l'appareil d'un utilisateur en se réveiller pour recevoir des messages importants lorsque la batterie est faible.

La valeur de l'en-tête est définie comme indiqué ci-dessous. La valeur par défaut est normal.

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

Une union unique

Si vous avez d'autres questions sur le fonctionnement de tout cela, vous pouvez toujours voir comment les bibliothèques se déclenchent push sur l'organisation web-push-libs.

Une fois que vous disposez d'une charge utile chiffrée et des en-têtes ci-dessus, il vous suffit d'effectuer une requête POST à endpoint dans un PushSubscription.

Que faire de la réponse à cette requête POST ?

Réponse du service push

Une fois que vous avez envoyé une requête à un service push, vous devez vérifier le code d'état de la réponse, car cela vous indique si la requête a abouti ou non.

Code d'état Description
201 Création terminée La demande d'envoi d'un message push a été reçue et acceptée.
429 Trop de requêtes. Cela signifie que votre serveur d'applications a atteint un taux à l'aide d'un service push. Le service push doit inclure un champ "Retry-After" (Réessayer) en-tête pour indiquer le temps avant qu'une autre requête puisse être effectuée.
400 Demande incorrecte. Cela signifie généralement que l'un de vos en-têtes n'est pas valide ou un format incorrect.
404 Introuvable. Cela indique que l'abonnement a expiré et ne peuvent pas être utilisées. Dans ce cas, vous devez supprimer "PushSubscription" et attendre que le client se réabonne à l'utilisateur.
410 Supprimé. L'abonnement n'est plus valide et doit être supprimé du serveur d'applications. Vous pouvez le reproduire en appelant "unsubscribe()" sur un "PushSubscription".
413 Charge utile trop grande. Taille minimale de la charge utile qu'un service push doit est de 4 096 octets. (ou 4 Ko).

Étapes suivantes

Ateliers de programmation