Chiffrement de la charge utile Web Push

Balances à palettes

Avant Chrome 50, les messages push ne pouvaient pas contenir de données de charge utile. Lorsque l'événement "push" s'est déclenché chez votre service worker, tout ce que vous saviez, c'est que le serveur essayait de vous dire quelque chose, mais pas ce que c'était. Vous deviez ensuite envoyer une requête de suivi au serveur et obtenir les détails de la notification à afficher, ce qui pouvait échouer en cas de mauvaise état du réseau.

Désormais, dans Chrome 50 (et dans la version actuelle de Firefox sur ordinateur), vous pouvez envoyer des données arbitraires en même temps que le transfert afin que le client n'ait pas à envoyer de requête supplémentaire. Cependant, avec une grande puissance, cela implique de grandes responsabilités, de sorte que toutes les données de charge utile doivent être chiffrées.

Le chiffrement des charges utiles constitue une partie importante de l'histoire de la sécurité pour le Web push. Il assure la sécurité de la communication entre le navigateur et votre propre serveur, car vous faites confiance au serveur. Toutefois, le navigateur choisit le fournisseur push qui sera utilisé pour transmettre la charge utile. En tant que développeur de l'application, vous n'avez donc aucun contrôle sur celle-ci.

Ici, HTTPS ne peut garantir que personne ne peut pirater le message en transit vers le fournisseur de services push. Une fois la charge utile reçue, elle est libre de faire ce qu'elle souhaite, y compris de la retransmettre à des tiers ou de la modifier de manière malveillante. Pour éviter cela, nous utilisons un chiffrement qui empêche les services push de lire ou de modifier les charges utiles en transit.

Modifications côté client

Si vous avez déjà implémenté les notifications push sans charge utile, vous ne devez apporter que deux petites modifications côté client.

Premièrement, lorsque vous envoyez les informations d'abonnement à votre serveur backend, vous devez recueillir des informations supplémentaires. Si vous utilisez déjà JSON.stringify() sur l'objet PushSubscription pour le sérialiser afin de l'envoyer à votre serveur, vous n'avez rien à modifier. L'abonnement comporte désormais des données supplémentaires dans la propriété des clés.

> JSON.stringify(subscription)
{"endpoint":"https://android.googleapis.com/gcm/send/f1LsxkKphfQ:APA91bFUx7ja4BK4JVrNgVjpg1cs9lGSGI6IMNL4mQ3Xe6mDGxvt_C_gItKYJI9CAx5i_Ss6cmDxdWZoLyhS2RJhkcv7LeE6hkiOsK6oBzbyifvKCdUYU7ADIRBiYNxIVpLIYeZ8kq_A",
"keys":{"p256dh":"BLc4xRzKlKORKWlbdgFaBrrPK3ydWAHo4M0gs0i1oEKgPpWC5cW8OCzVrOQRv-1npXRWk8udnW3oYhIO4475rds=",
"auth":"5I2Bu2oKdyy9CwL8QVF0NQ=="}}

Les deux valeurs p256dh et auth sont encodées dans une variante de Base64 que nous appellerons Base64 adapté aux URL.

Si vous souhaitez accéder directement aux octets, vous pouvez utiliser la nouvelle méthode getKey() sur l'abonnement qui renvoie un paramètre en tant que ArrayBuffer. Les deux paramètres dont vous avez besoin sont auth et p256dh.

> new Uint8Array(subscription.getKey('auth'));
[228, 141, 129, ...] (16 bytes)

> new Uint8Array(subscription.getKey('p256dh'));
[4, 183, 56, ...] (65 bytes)

La deuxième modification est une nouvelle propriété data lorsque l'événement push se déclenche. Elle dispose de différentes méthodes synchrones d'analyse des données reçues, telles que .text(), .json(), .arrayBuffer() et .blob().

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

Modifications côté serveur

Côté serveur, les choses changent un peu plus. Le processus de base consiste à utiliser les informations de la clé de chiffrement fournies par le client pour chiffrer la charge utile, puis à envoyer ces informations en tant que corps d'une requête POST au point de terminaison de l'abonnement, en ajoutant des en-têtes HTTP supplémentaires.

Les détails sont relativement complexes et, comme pour tout ce qui concerne le chiffrement, il est préférable d'utiliser une bibliothèque développée activement plutôt que de déployer la vôtre. L'équipe Chrome a publié une bibliothèque pour Node.js. D'autres langages et plates-formes seront bientôt disponibles. Il gère à la fois le chiffrement et le protocole Web push, de sorte que l'envoi d'un message en mode push à partir d'un serveur Node.js est aussi simple que webpush.sendWebPush(message, subscription).

Bien que nous vous recommandions vivement d'utiliser une bibliothèque, il s'agit d'une nouvelle fonctionnalité. De nombreux langages populaires n'ont pas encore de bibliothèque. Si vous devez effectuer cette opération vous-même, voici les détails de cette mise en œuvre.

Je vais illustrer les algorithmes à l'aide du code JavaScript de type Node, mais les principes de base doivent être les mêmes dans tous les langages.

Entrées

Pour chiffrer un message, nous devons d'abord récupérer deux éléments de l'objet d'abonnement que nous avons reçu du client. Si vous avez utilisé JSON.stringify() sur le client et l'avez transmis à votre serveur, la clé publique du client est stockée dans le champ keys.p256dh, tandis que la clé secrète d'authentification partagée se trouve dans le champ keys.auth. Ces deux formats sont encodés en Base64 adapté aux URL, comme indiqué ci-dessus. Le format binaire de la clé publique du client est un point à courbe elliptique P-256 non compressé.

const clientPublicKey = new Buffer(subscription.keys.p256dh, 'base64');
const clientAuthSecret = new Buffer(subscription.keys.auth, 'base64');

La clé publique nous permet de chiffrer le message de sorte qu'il ne puisse être déchiffré qu'à l'aide de la clé privée du client.

Les clés publiques sont généralement considérées comme publiques. Par conséquent, pour permettre au client d'authentifier que le message a été envoyé par un serveur de confiance, nous utilisons également le code secret d'authentification. Sans surprise, ces données doivent être tenues secrètes, partagées uniquement avec le serveur d'applications auquel vous souhaitez envoyer des messages et traitées comme un mot de passe.

Nous devons également générer de nouvelles données. Nous avons besoin d'un sel aléatoire de 16 octets et d'une paire publique/privée de clés à courbe elliptique. La courbe particulière utilisée par la spécification de chiffrement push est appelée P-256, ou prime256v1. Pour une sécurité optimale, la paire de clés doit être entièrement générée chaque fois que vous chiffrez un message et vous ne devez jamais réutiliser de valeur salt.

ECDH

Prenons un peu de recul pour parler d'une propriété intéressante de la cryptographie à courbe elliptique. Il existe un processus relativement simple qui combine votre clé privée avec la clé publique de quelqu'un d'autre pour obtenir une valeur. Quel en est l'intérêt ? Eh bien, si l'autre partie prend sa clé privée et votre clé publique, elle obtiendra exactement la même valeur.

Il s'agit de la base du protocole de contrat de clé Diffie-Hellman (ECDH) à courbe elliptique, qui permet aux deux parties d'avoir la même clé secrète partagée même si elles n'échangent que des clés publiques. Nous utiliserons ce secret partagé comme base pour notre clé de chiffrement réelle.

const crypto = require('crypto');

const salt = crypto.randomBytes(16);

// Node has ECDH built-in to the standard crypto library. For some languages
// you may need to use a third-party library.
const serverECDH = crypto.createECDH('prime256v1');
const serverPublicKey = serverECDH.generateKeys();
const sharedSecret = serverECDH.computeSecret(clientPublicKey);

HKDF

Déjà temps pour un autre côté. Supposons que vous souhaitiez utiliser des données secrètes comme clé de chiffrement, mais que leur sécurité n'est pas suffisante. Vous pouvez utiliser la fonction de dérivation de clé basée sur HMAC (HKDF) pour transformer un secret présentant un faible niveau de sécurité en un secret hautement sécurisé.

L'une des conséquences de ce fonctionnement est qu'il vous permet de prendre un secret de n'importe quel nombre de bits et de produire un autre secret de n'importe quelle taille jusqu'à 255 fois plus long qu'un hachage produit par n'importe quel algorithme de hachage. Pour la transmission, la spécification nous oblige à utiliser SHA-256, dont la longueur de hachage est de 32 octets (256 bits).

En l'occurrence, nous savons que nous n'avons besoin de générer que des clés d'une taille maximale de 32 octets. Cela signifie que nous pouvons utiliser une version simplifiée de l'algorithme, qui ne peut pas gérer de tailles de sortie plus importantes.

Vous trouverez ci-dessous le code d'une version de nœud, mais vous pouvez découvrir son fonctionnement réel dans le document RFC 5869.

Les entrées de HKDF sont du salage, du matériel de clé initial (ikm), une donnée facultative de données structurées spécifique au cas d'utilisation actuel (info) et la longueur en octets de la clé de sortie souhaitée.

// Simplified HKDF, returning keys up to 32 bytes long
function hkdf(salt, ikm, info, length) {
  if (length > 32) {
    throw new Error('Cannot return keys of more than 32 bytes, ${length} requested');
  }

  // Extract
  const keyHmac = crypto.createHmac('sha256', salt);
  keyHmac.update(ikm);
  const key = keyHmac.digest();

  // Expand
  const infoHmac = crypto.createHmac('sha256', key);
  infoHmac.update(info);
  // A one byte long buffer containing only 0x01
  const ONE_BUFFER = new Buffer(1).fill(1);
  infoHmac.update(ONE_BUFFER);
  return infoHmac.digest().slice(0, length);
}

Déterminer les paramètres de chiffrement

Nous utilisons maintenant HKDF pour transformer les données dont nous disposons en paramètres de chiffrement réel.

La première chose que nous faisons est d'utiliser HKDF pour combiner le secret d'authentification du client et le secret partagé dans un secret plus long et plus sécurisé de manière cryptographique. Dans la spécification, nous parlons de clé pseudo-aléatoire (PRK, Pseudo-Random Key). C'est donc ce que je vais appeler ici, même si les puristes de la cryptographie peuvent remarquer qu'il ne s'agit pas strictement d'une PRK.

Nous créons maintenant la clé de chiffrement de contenu finale et un nonce qui sera transmis à l'algorithme de chiffrement. Ces données sont créées en créant une structure de données simple pour chacun d'entre eux, appelée "information" dans la spécification. Celle-ci contient des informations spécifiques à la courbe elliptique, à l'expéditeur et au destinataire de ces informations, afin de vérifier la source du message. Nous utilisons ensuite HKDF avec la clé PRK, le salage et les informations pour déduire la clé et le nonce de la bonne taille.

Le type d'information pour le chiffrement de contenu est "aesgcm", qui correspond au nom de l'algorithme de chiffrement utilisé pour le chiffrement push.

const authInfo = new Buffer('Content-Encoding: auth\0', 'utf8');
const prk = hkdf(clientAuthSecret, sharedSecret, authInfo, 32);

function createInfo(type, clientPublicKey, serverPublicKey) {
  const len = type.length;

  // The start index for each element within the buffer is:
  // value               | length | start    |
  // -----------------------------------------
  // 'Content-Encoding: '| 18     | 0        |
  // type                | len    | 18       |
  // nul byte            | 1      | 18 + len |
  // 'P-256'             | 5      | 19 + len |
  // nul byte            | 1      | 24 + len |
  // client key length   | 2      | 25 + len |
  // client key          | 65     | 27 + len |
  // server key length   | 2      | 92 + len |
  // server key          | 65     | 94 + len |
  // For the purposes of push encryption the length of the keys will
  // always be 65 bytes.
  const info = new Buffer(18 + len + 1 + 5 + 1 + 2 + 65 + 2 + 65);

  // The string 'Content-Encoding: ', as utf-8
  info.write('Content-Encoding: ');
  // The 'type' of the record, a utf-8 string
  info.write(type, 18);
  // A single null-byte
  info.write('\0', 18 + len);
  // The string 'P-256', declaring the elliptic curve being used
  info.write('P-256', 19 + len);
  // A single null-byte
  info.write('\0', 24 + len);
  // The length of the client's public key as a 16-bit integer
  info.writeUInt16BE(clientPublicKey.length, 25 + len);
  // Now the actual client public key
  clientPublicKey.copy(info, 27 + len);
  // Length of our public key
  info.writeUInt16BE(serverPublicKey.length, 92 + len);
  // The key itself
  serverPublicKey.copy(info, 94 + len);

  return info;
}

// Derive the Content Encryption Key
const contentEncryptionKeyInfo = createInfo('aesgcm', clientPublicKey, serverPublicKey);
const contentEncryptionKey = hkdf(salt, prk, contentEncryptionKeyInfo, 16);

// Derive the Nonce
const nonceInfo = createInfo('nonce', clientPublicKey, serverPublicKey);
const nonce = hkdf(salt, prk, nonceInfo, 12);

Marge intérieure

Un autre côté, et du temps pour un exemple stupide et faux. Supposons que votre patron dispose d'un serveur qui lui envoie un message push toutes les deux ou trois minutes avec le cours de l'action de l'entreprise. Le message en clair sera toujours un entier de 32 bits dont la valeur est exprimée en cents. Elle a également conclu un accord sournois avec le personnel de restauration : celui-ci peut lui envoyer la chaîne "beignets dans la salle de pause" cinq minutes avant la livraison, afin qu'elle puisse "par hasard" être là quand ils arrivent et prendre la meilleure.

L'algorithme de chiffrement utilisé par Web Push crée des valeurs chiffrées qui font exactement 16 octets de plus que l'entrée non chiffrée. Étant donné que l'expression « beignets dans la salle de pause » est plus longue que le prix d'une action 32 bits, tout employé qui espionne peut savoir quand les donuts arrivent sans déchiffrer les messages, juste à partir de la longueur des données.

C'est pourquoi le protocole Web push vous permet d'ajouter une marge intérieure au début des données. La façon dont vous utilisez cette option dépend de votre application. Toutefois, dans l'exemple ci-dessus, vous pouvez remplir tous les messages pour qu'ils fassent exactement 32 octets, ce qui ne permet pas de les distinguer uniquement en fonction de leur longueur.

La valeur de la marge intérieure est un entier big-endian de 16 bits qui spécifie la longueur de la marge intérieure suivie du nombre d'octets de la marge intérieure (NUL octets). Le remplissage minimal est donc de deux octets, le nombre zéro encodé en 16 bits.

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

Lorsque le message push arrive sur le client, le navigateur peut supprimer automatiquement toute marge intérieure, de sorte que votre code client ne reçoive que le message sans remplissage.

Chiffrement

Nous avons enfin tout ce qu’il faut pour faire le chiffrement. L'algorithme de chiffrement requis pour le streaming Web est l'algorithme AES128 avec GCM. Nous utilisons notre clé de chiffrement de contenu comme clé et le nonce comme vecteur d'initialisation (IV).

Dans cet exemple, nos données sont une chaîne, mais il peut s'agir de n'importe quelle donnée binaire. Vous pouvez envoyer des charges utiles allant de 4 078 à 4 096 octets par post, avec 16 octets pour les informations de chiffrement et au moins 2 octets pour le remplissage.

// Create a buffer from our data, in this case a UTF-8 encoded string
const plaintext = new Buffer('Push notification payload!', 'utf8');
const cipher = crypto.createCipheriv('id-aes128-GCM', contentEncryptionKey,
nonce);

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

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

Diffusion Web

Ouf ! Maintenant que vous disposez d'une charge utile chiffrée, il vous suffit d'envoyer une requête HTTP POST relativement simple au point de terminaison spécifié par l'abonnement de l'utilisateur.

Vous devez définir trois en-têtes.

Encryption: salt=<SALT>
Crypto-Key: dh=<PUBLICKEY>
Content-Encoding: aesgcm

<SALT> et <PUBLICKEY> sont la clé publique de salage et de serveur utilisée pour le chiffrement, encodée au format Base64 adapté aux URL.

Avec le protocole Web Push, le corps de la requête POST contient alors uniquement les octets bruts du message chiffré. Toutefois, tant que Chrome et Firebase Cloud Messaging ne sont pas compatibles avec le protocole, vous pouvez facilement inclure les données dans votre charge utile JSON existante, comme suit.

{
    "registration_ids": [ "…" ],
    "raw_data": "BIXzEKOFquzVlr/1tS1bhmobZ…"
}

La valeur de la propriété rawData doit correspondre à la représentation du message chiffré, encodée en base64.

Débogage / Vérificateur

Peter Beverloo, l'un des ingénieurs Chrome qui a implémenté la fonctionnalité (et avoir travaillé sur la spécification), a créé un vérificateur.

En faisant en sorte que votre code génère chacune des valeurs intermédiaires du chiffrement, vous pouvez les coller dans l'outil de vérification et vérifier que vous êtes sur la bonne voie.