Crittografia del payload web push

Mat Scales

Prima di Chrome 50, i messaggi push non poteva contenere dati di payload. Quando il pulsante 'push' evento mentre il Service worker, l'unica cosa che sapevate, era che il server dirti qualcosa, ma non quale potrebbe essere. Poi dovevi fare un follow-up richiesta al server e ottenere i dettagli della notifica da mostrare, potrebbe non funzionare in condizioni di rete insoddisfacenti.

Ora in Chrome 50 (e nell'attuale versione di Firefox per computer) puoi alcuni dati arbitrari insieme al push in modo che il client possa evitare a effettuare la richiesta aggiuntiva. Da un grande potere, però, derivano grandi responsabilità, quindi tutti i dati del payload devono essere crittografati.

La crittografia dei payload è una parte importante del processo di sicurezza per il push web. Il protocollo HTTPS offre sicurezza durante la comunicazione tra il browser e il tuo perché ritieni attendibile il server. Tuttavia, il browser sceglie il push provider verrà usato per consegnare il payload, quindi tu, come app sviluppatore non ne hanno alcun controllo.

Qui, il protocollo HTTPS può garantire solo che nessuno possa spiare il messaggio in transito al fornitore di servizi push. Una volta ricevuto, sono liberi di fare cosa che preferiscono, ad esempio la ritrasmissione del payload a terze parti o modificandole deliberatamente con qualcos'altro. Per evitare questo problema, utilizziamo per garantire che i servizi push non possano leggere o manomettere i payload in transito.

Modifiche lato client

Se hai già ha implementato notifiche push senza payload devi apportare solo due piccole modifiche sul lato client.

La prima è che quando invii le informazioni sull'abbonamento al tuo backend è necessario raccogliere alcune informazioni aggiuntive. Se utilizzi già JSON.stringify() il PushSubscription serializzarlo per l'invio al tuo server, allora non c'è bisogno cambia qualcosa. L'abbonamento ora avrà alcuni dati aggiuntivi nella proprietà Chiavi.

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

I due valori p256dh e auth sono codificati in una variante di Base64 che indicherò chiama Base64 sicuro per URL.

Se invece vuoi arrivare subito ai byte, puoi usare il nuovo getKey() nella sottoscrizione che restituisce un parametro come ArrayBuffer. I due parametri necessari sono auth e p256dh.

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

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

La seconda modifica riguarda i nuovi dati quando viene attivato l'evento push. Prevede vari metodi sincroni per analizzare i dati ricevuti, ad esempio .text(), .json(), .arrayBuffer() e .blob().

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

Modifiche lato server

Sul lato server, le cose cambiano un po' di più. La procedura di base prevede l'uso le informazioni sulla chiave di crittografia che hai ricevuto dal client per criptare il payload e quindi invialo come corpo di una richiesta POST all'endpoint nella sottoscrizione, aggiungendo alcune intestazioni HTTP aggiuntive.

I dettagli sono relativamente complessi e, come per qualsiasi cosa relativa alla crittografia è meglio utilizzare una raccolta sviluppata in modo attivo piuttosto che aggiungere una propria raccolta. La Il team di Chrome ha pubblicato una libreria per Node.js, con ulteriori linguaggi e piattaforme presto disponibili. In questo modo vengono gestiti la crittografia e il protocollo web push, in modo che l'invio di un messaggio push da Node.js è facile come webpush.sendWebPush(message, subscription).

Anche se consigliamo vivamente di utilizzare una libreria, si tratta di una nuova funzionalità sono molti linguaggi popolari che non dispongono ancora di alcuna libreria. Se hai bisogno di implementarla autonomamente, ecco i dettagli.

Illustrerò gli algoritmi utilizzando JavaScript basato sui nodi, devono essere gli stessi in qualsiasi lingua.

Input

Per crittografare un messaggio, dobbiamo prima recuperare due elementi che abbiamo ricevuto dal client. Se utilizzavi JSON.stringify() sul client e lo ha trasmesso al server, quindi la chiave pubblica del client viene archiviata nel campo keys.p256dh, mentre il secret di autenticazione è nel campo keys.auth. Entrambe le opzioni saranno sicure per l'URL Codifica Base64, come indicato sopra. Il formato binario della chiave pubblica del client è un punto di curva ellittica P-256 non compresso.

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

La chiave pubblica ci consente di criptare il messaggio in modo che possa essere decriptato utilizzando la chiave privata del client.

Le chiavi pubbliche di solito si considerano "pubbliche", per consentire al client per confermare che il messaggio è stato inviato da un server attendibile, utilizziamo anche secret di autenticazione, Come prevedibile, questo dovrebbe essere mantenuto segreto, condiviso con il server delle applicazioni a cui desideri inviarti messaggi e ad esempio una password.

Dobbiamo anche generare alcuni nuovi dati. È necessaria un'istanza casuale sale e una coppia di curva ellittica pubblica/privata chiave. La particolare curva utilizzata dalla specifica di crittografia push è chiamata P-256, o prime256v1. Per la massima sicurezza, la coppia di chiavi deve essere generata ogni volta che cripti un messaggio, senza mai riutilizzare un salt.

ECDH

Soffermiamoci un po' su una proprietà ordinata della curva ellittica crittografia. Esiste un processo relativamente semplice che combina la tua chiave privata con la chiave pubblica di qualcun altro per ricavare un valore. E quindi? Beh, se l'altra parte prende la sua parte privata chiave e la tua chiave pubblica genererà esattamente lo stesso valore.

Questa è la base dell'accordo chiave sulla curva ellittica di Diffie-Hellman (ECDH) che consente a entrambe le parti di avere lo stesso segreto condiviso anche se hanno scambiato solo chiavi pubbliche. Utilizzeremo questo segreto condiviso come base per la nostra chiave di crittografia effettiva.

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

Ci sentiamo già qui. Supponiamo che tu abbia dei dati segreti che che vuoi utilizzare come chiave di crittografia, ma non è crittograficamente sicura abbastanza. Puoi utilizzare la funzione di derivazione delle chiavi basata su HMAC (HKDF) per trasformare un secret con una bassa sicurezza in uno con un livello di sicurezza elevato.

Una conseguenza del funzionamento è che ti consente di prendere un segreto di un numero qualsiasi di bit e producono un altro secret di qualsiasi dimensione, fino a 255 volte fino a quando un hash prodotto da qualsiasi algoritmo di hashing utilizzato. Per il push, richiede l'uso di SHA-256, che ha una lunghezza hash di 32 byte (256 bit).

Quando accade, sappiamo che dobbiamo generare solo chiavi fino a 32 byte in dimensioni. Ciò significa che possiamo utilizzare una versione semplificata dell'algoritmo che non può gestire dimensioni di output più grandi.

Di seguito è incluso il codice per una versione di Node, ma puoi scoprire come funziona effettivamente in RFC 5869.

Gli input in HKDF sono un sale, un materiale di codifica iniziale (ikm), un dei dati strutturati specifici per il caso d'uso attuale (informazioni) e la durata in byte della chiave di output desiderata.

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

Ricavare i parametri di crittografia

Ora usiamo HKDF per trasformare i dati in nostro possesso in parametri per la la crittografia.

La prima cosa che facciamo è utilizzare HKDF per combinare il segreto di autenticazione del client e in un secret più lungo e più sicuro tramite crittografia. Nella specifica si tratta chiamata pseudo-casuale chiave (PRK), quindi la chiamerò qui. sebbene i puristi della crittografia possano far notare che non si tratta strettamente di una PRK.

Ora creiamo la chiave di crittografia dei contenuti finale e un nonce che verrà passato alla crittografia. Questi vengono creati creando una struttura di dati semplice per ciascuno, a cui si fa riferimento nella specifica sotto forma di informazioni, che contengono informazioni specifiche della curva ellittica, e il destinatario delle informazioni al fine di verificare ulteriormente l'identità sorgente. Quindi usiamo HKDF con il PRK, il nostro sale e le informazioni per ricavare la chiave e un nonce della dimensione corretta.

Il tipo di informazioni per la crittografia dei contenuti è "aesgcm" che è il nome del per la crittografia 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);

Spaziatura interna

Un altro a parte, è il momento di un esempio sciocco e artificioso. Supponiamo che il tuo capo ha un server che le invia un messaggio push a intervalli di pochi minuti quotazione della società. Il messaggio normale sarà sempre un numero intero a 32 bit con il valore in centesimi. Ha anche un furtivo accordo con il personale del catering e possono inviarle la stringa "ciambelle nella stanza delle pause" 5 minuti prima della consegna, in modo che possa "per caso" quando arrivano e prendono il migliore.

La crittografia utilizzata dal push web crea valori criptati che corrispondono esattamente a 16 byte più lunga dell'input non criptato. Da "ciambelle nella stanza del riposo" sono più a lungo del prezzo di un'azione a 32 bit, qualsiasi dipendente di snooping potrà dire quando arrivano le ciambelle senza decifrare i messaggi, solo dalla lunghezza dei dati.

Per questo motivo, il protocollo web push consente di aggiungere spaziatura interna al partendo dall'inizio dei dati. La modalità di utilizzo dipende dall'applicazione, ma nel dell'esempio precedente: puoi compilare tutti i messaggi in modo che abbiano esattamente 32 byte, impossibile distinguere i messaggi in base solo alla lunghezza.

Il valore di spaziatura interna è un numero intero big-endian a 16 bit che specifica la lunghezza della spaziatura interna seguito da quel numero di NUL byte di spaziatura interna. Quindi la spaziatura interna minima è pari a byte: il numero zero codificato in 16 bit.

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

Quando il messaggio push arriva al client, il browser sarà in grado di rimuove automaticamente qualsiasi spaziatura interna, in modo che il codice client riceva solo messaggio non riempito.

Crittografia

Ora finalmente abbiamo tutte le cose per fare la crittografia. Crittografia obbligatoria per il web push è AES128 utilizzando GCM. Usiamo i nostri contenuti la chiave di crittografia come chiave e il nonce come vettore di inizializzazione (IV).

In questo esempio i nostri dati sono una stringa, ma potrebbe trattarsi di qualsiasi dato binario. Puoi possono inviare payload fino a una dimensione di 4078 byte (massimo 4096 byte per post), con 16 byte per le informazioni di crittografia e almeno 2 byte per la spaziatura interna.

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

Push sul web

Finalmente. Ora che hai un payload criptato, devi solo creare richiesta POST HTTP relativamente semplice all'endpoint specificato dal abbonamento.

Devi impostare tre intestazioni.

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

<SALT> e <PUBLICKEY> sono il sale e la chiave pubblica del server utilizzati nel crittografata, codificata come Base64 con protezione dell'URL.

Quando si usa il protocollo Web Push, il corpo del POST è quindi solo il testo non elaborato byte del messaggio criptato. Tuttavia, fino a quando Chrome e Firebase Cloud I messaggi supportano il protocollo, puoi facilmente includere i dati nel tuo payload JSON esistente come segue.

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

Il valore della proprietà rawData deve essere codificato in Base64 del messaggio criptato.

Debug / verifica

Peter Beverloo, uno degli ingegneri di Chrome che ha implementato la funzionalità (come oltre a essere una delle persone che hanno lavorato alle specifiche), ha ha creato uno strumento di verifica.

Facendo in modo che il codice restituisca ciascuno dei valori intermedi del la crittografia, puoi incollarli nello strumento di verifica e controllare di essere la strada giusta.

.