Chiffrement de la charge utile Web Push

Mat Scales

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é dans votre service worker, vous saviez simplement que le serveur essayait de vous dire quelque chose, mais pas ce que cela pourrait être. Vous deviez ensuite envoyer une requête de suivi au serveur et obtenir les détails de la notification à afficher, qui peut échouer si le réseau est mauvais.

Dans Chrome 50 (et dans la version actuelle de Firefox sur ordinateur) vous pouvez désormais envoyer des données arbitraires avec le push afin que le client puisse éviter d'effectuer la requête supplémentaire. Toutefois, un grand pouvoir implique de grandes responsabilités. Par conséquent, toutes les données de charge utile doivent être chiffrées.

Le chiffrement des charges utiles est un élément important de la sécurité des notifications push Web. Le protocole HTTPS vous offre une sécurité lorsque vous communiquez entre le navigateur et votre propre serveur, car vous faites confiance au serveur. Toutefois, le navigateur choisit le fournisseur de push qui sera utilisé pour diffuser la charge utile. Vous, en tant que développeur de l'application, n'avez donc aucun contrôle à ce niveau.

Ici, HTTPS ne peut garantir que personne ne peut espionner le message en transit vers le fournisseur de services de transfert. Une fois qu'il l'a reçue, il est libre de faire ce qu'il veut, y compris de la retransmettre à des tiers ou de la modifier de manière malveillante. Pour nous protéger contre cela, nous utilisons le chiffrement pour nous assurer que les services push ne peuvent pas lire ni falsifier les charges utiles en transit.

Modifications côté client

Si vous avez déjà implémenté des notifications push sans charges utiles, vous n'avez qu'à apporter deux petites modifications côté client.

Tout d'abord, lorsque vous envoyez les informations d'abonnement à votre serveur backend, vous devez collecter des informations supplémentaires. Si vous utilisez déjà JSON.stringify() sur l'objet PushSubscription pour le sérialiser avant de l'envoyer à votre serveur, vous n'avez rien à changer. L'abonnement contient désormais des données supplémentaires dans la propriété "keys".

> 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 j'appellerai Base64 sécurisé pour les 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. Il dispose de diverses méthodes synchrones pour analyser les 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 clé de chiffrement que vous avez obtenues auprès du client pour chiffrer la charge utile, puis à l'envoyer 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 en cours de développement plutôt que de créer la vôtre. L'équipe Chrome a publié une bibliothèque pour Node.js. D'autres langages et plates-formes seront bientôt disponibles. Cela gère à la fois le chiffrement et le protocole Web Push, de sorte qu'envoyer un message 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é et de nombreux langages populaires ne disposent pas encore de bibliothèques. Si vous devez l'implémenter par vous-même, voici les détails.

Je vais illustrer les algorithmes à l'aide de code JavaScript par nœud, mais les principes de base doivent être les mêmes dans n'importe quel langage.

Entrées

Pour chiffrer un message, nous devons d'abord obtenir 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 que vous l'avez transmis à votre serveur, la clé publique du client est stockée dans le champ keys.p256dh, tandis que le secret d'authentification partagé se trouve dans le champ keys.auth. Ces deux éléments seront encodés en base64 et sécurisés pour les URL, comme indiqué ci-dessus. Le format binaire de la clé publique du client est un point de 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. Pour permettre au client de s'authentifier que le message a été envoyé par un serveur de confiance, nous utilisons également le secret d'authentification. Sans surprise, il doit rester secret, partagé uniquement avec le serveur d'applications auquel vous souhaitez envoyer des messages et traité 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 sécurisé par chiffrement et d'une paire de clés courbe elliptique publique/privée. 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 générée à partir de zéro chaque fois que vous chiffrez un message, et vous ne devez jamais réutiliser un sel.

ECDH

Prenons un peu de temps pour parler d’une propriété attenante de la cryptographie sur les courbes elliptiques. Il existe un processus relativement simple qui combine votre clé privée avec la clé publique d'une autre personne pour en déduire 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.

C'est la base du protocole d'accord de clé Diffie-Hellman (ECDH) à courbe elliptique, qui permet aux deux parties de disposer du même secret partagé, même si elles n'ont échangé que des clés publiques. Nous utiliserons ce secret partagé comme base de 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

Il est déjà temps de faire une autre parenthèse. Supposons que vous disposiez de données secrètes que vous souhaitez utiliser comme clé de chiffrement, mais qu'elles ne soient pas suffisamment sécurisées du point de vue cryptographique. Vous pouvez utiliser la fonction de dérivation de clé (HKDF) basée sur HMAC pour transformer un secret à faible sécurité en secret à haute sécurité.

Une conséquence de son 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 l'algorithme de hachage que vous utilisez. Pour la méthode push, la spécification nous oblige à utiliser SHA-256, qui a une longueur de hachage de 32 octets (256 bits).

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

J'ai inclus le code d'une version Node ci-dessous, mais vous pouvez découvrir son fonctionnement réel dans la RFC 5869.

Les entrées de HKDF sont un sel, un matériel de clé initial (ikm), un élément facultatif 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éduire les paramètres de chiffrement

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

Nous utilisons d'abord HKDF pour mélanger le secret d'authentification du client et le secret partagé dans un secret plus long et plus sécurisé par chiffrement. Dans la spécification, il est appelé "clé pseudo-aléatoire" (PRK, pseudo-random key). C'est donc ce que je vais appeler ici, bien que les puristes de la cryptographie puissent noter qu'il ne s'agit pas strictement d'une PRK.

Nous créons maintenant la clé de chiffrement du contenu finale et un nonce qui sera transmis au chiffrement. Ils sont créés en créant une structure de données simple pour chacun d'eux, appelée dans la spécification "info", qui contient des informations spécifiques à la courbe elliptique, à l'expéditeur et au destinataire des informations afin de vérifier plus en détail la source du message. Nous utilisons ensuite HKDF avec la PRK, notre sel et les informations pour dériver la clé et le nonce de la taille appropriée.

Le type d'informations pour le chiffrement du contenu est "aesgcm", qui correspond au nom du 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

Autre aparté : il est temps d'illustrer ce point par un exemple idiot et artificiel. Supposons que votre patron dispose d'un serveur qui lui envoie un message push à quelques minutes d'intervalle avec le cours de l'action de l'entreprise. Le message clair pour cela sera toujours un entier 32 bits avec la valeur en centimes. Elle conclut également un accord sournois avec le personnel de traiteur, ce qui signifie qu'il peut lui envoyer la chaîne "beignets dans la salle de pause" cinq minutes avant qu'ils ne soient livrés afin qu'elle puisse être "par coïncidence" là-bas à son arrivée et prendre le meilleur.

L'algorithme de chiffrement utilisé par Web Push crée des valeurs chiffrées dont la longueur est exactement de 16 octets par rapport à l'entrée non chiffrée. Étant donné que "beignets dans la salle de pause" est plus long qu'un cours boursier 32 bits, tout employé indiscret pourra savoir quand les beignets arriveront sans déchiffrer les messages, simplement en fonction de la longueur des données.

C'est pourquoi le protocole Web Push vous permet d'ajouter un remplissage au début des données. La manière dont vous l'utilisez dépend de votre application, mais dans l'exemple ci-dessus, vous pouvez ajouter un remplissage de 32 octets à tous les messages, ce qui rend impossible de les distinguer uniquement en fonction de leur longueur.

La valeur de remplissage est un entier big-endian de 16 bits spécifiant la longueur de remplissage, suivie de ce nombre d'octets de remplissage NUL. La marge intérieure minimale est donc de deux octets, soit le nombre 0 encodé sur 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 votre message push arrive au client, le navigateur peut supprimer automatiquement toute marge intérieure. Votre code client ne reçoit donc que le message sans marge intérieure.

Chiffrement

Nous disposons maintenant de tous les éléments nécessaires pour effectuer le chiffrement. Le chiffrement requis pour le mode Web Push est 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 elles peuvent être n'importe quelles données binaires. Vous pouvez envoyer des charges utiles d'une taille maximale 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()]);

Notification push 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 le sel et la clé publique du serveur utilisés dans le chiffrement, encodés en base64 URL-safe.

Avec le protocole Web Push, le corps de la requête POST se compose uniquement des octets bruts du message chiffré. Toutefois, tant que Chrome et Firebase Cloud Messaging n'acceptent pas 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 être la représentation encodée en base64 du message chiffré.

Débogage / Vérificateur

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

En obtenant que votre code affiche chacune des valeurs intermédiaires du chiffrement, vous pouvez les coller dans le vérificateur et vérifier que vous êtes sur la bonne voie.