Crittografia del payload web push

Bilance per tappetini

Prima di Chrome 50, i messaggi push non potevano contenere dati di payload. Quando l'evento "push" è stato attivato nel service worker, sapeva solo che il server stava cercando di dirti qualcosa, ma non cosa potrebbe essere. Devi poi effettuare una richiesta di follow-up al server e ottenere i dettagli della notifica da visualizzare, che potrebbe non riuscire in condizioni di rete scadenti.

Ora in Chrome 50 (e nella versione attuale di Firefox su computer) puoi inviare alcuni dati arbitrari insieme al push in modo che il client possa evitare di effettuare la richiesta aggiuntiva. Tuttavia, una grande potenza comporta una grande responsabilità, quindi tutti i dati payload devono essere criptati.

La crittografia dei payload è una parte importante della storia relativa alla sicurezza per il web push. HTTPS garantisce sicurezza durante la comunicazione tra il browser e il tuo server, in quanto il server è affidabile. Tuttavia, il browser sceglie quale provider push utilizzare per consegnare effettivamente il payload, perciò tu, in qualità di sviluppatore dell'app, non ne hai alcun controllo.

In questo caso, HTTPS può solo garantire che nessuno possa spiare il messaggio in transito verso il fornitore di servizi push. Una volta ricevuto, l'utente è libero di fare ciò che vuole, ad esempio ritrasmettere il payload a terze parti o modificarlo maliziosamente con qualcos'altro. Per proteggerti da questo, utilizziamo la crittografia per garantire che i servizi push non possano leggere o manomettere i payload in transito.

Modifiche lato client

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

Innanzitutto, quando invii le informazioni sull'abbonamento al tuo server di backend, devi raccogliere alcune informazioni aggiuntive. Se utilizzi già JSON.stringify() nell'oggetto PushSubscription per serializzarlo per l'invio al tuo server, non devi modificare nulla. L'abbonamento avrà ora alcuni dati aggiuntivi nella proprietà delle 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 chiamerò Base64 sicura per URL.

Se invece vuoi ottenere esattamente i byte, puoi utilizzare il nuovo metodo getKey() nell'abbonamento 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 una nuova proprietà dati quando viene attivato l'evento push. Dispone di vari metodi sincroni per l'analisi dei dati ricevuti, come .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 consiste nell'utilizzare le informazioni sulla chiave di crittografia che hai ricevuto dal client per criptare il payload, quindi le invii come corpo di una richiesta POST all'endpoint nell'abbonamento, aggiungendo altre intestazioni HTTP.

I dettagli sono relativamente complessi e, come per qualsiasi cosa relativa alla crittografia, è meglio utilizzare una libreria sviluppata attivamente piuttosto che implementarne una propria. Il team di Chrome ha pubblicato una libreria per Node.js, con altri linguaggi e piattaforme in arrivo. In questo modo viene gestita sia la crittografia sia il protocollo push web, in modo che l'invio di un messaggio push da un server Node.js sia semplice come webpush.sendWebPush(message, subscription).

Anche se consigliamo vivamente di usare una libreria, si tratta di una nuova funzionalità e molti linguaggi diffusi non dispongono ancora di librerie. Se devi implementarlo personalmente, ecco i dettagli.

Illustrerò gli algoritmi utilizzando JavaScript a livello di nodo, ma i principi di base dovrebbero essere gli stessi in qualsiasi linguaggio.

Input

Per criptare un messaggio, dobbiamo prima recuperare due cose dall'oggetto abbonamento che abbiamo ricevuto dal client. Se hai utilizzato JSON.stringify() sul client e lo hai trasmesso al tuo server, la chiave pubblica del client viene archiviata nel campo keys.p256dh, mentre il secret di autenticazione condiviso si trova nel campo keys.auth. Entrambi saranno codificati in formato Base64 sicuri per URL, come indicato sopra. Il formato binario della chiave pubblica del client è un punto della 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 solo utilizzando la chiave privata del client.

In genere le chiavi pubbliche sono considerate pubbliche, quindi per consentire al client di autenticare che il messaggio è stato inviato da un server attendibile utilizziamo anche il secret di autenticazione. Non sorprende che questa informazione debba essere mantenuta segreta, condivisa solo con il server delle applicazioni a cui intendi inviare i messaggi e trattata come una password.

Dobbiamo anche generare alcuni nuovi dati. Abbiamo bisogno di un sale casuale con sicurezza crittografica a 16 byte e di una coppia pubblica/privata di chiavi a curva ellittica. La particolare curva utilizzata dalla specifica di crittografia push si chiama P-256 o prime256v1. Per la massima sicurezza, la coppia di chiavi dovrebbe essere generata da scratch ogni volta che cripti un messaggio e non dovresti mai riutilizzare un salt.

ECDH

Vediamo qualche parte per parlare di un'interessante proprietà della crittografia a curva ellittica. Esiste un processo relativamente semplice che combina la tua chiave privata con la chiave pubblica di un'altra persona per ricavare un valore. E quindi? Se l'altra parte prende la propria chiave privata e la tua chiave pubblica, otterrai esattamente lo stesso valore.

Questa è la base del protocollo dell'accordo chiave Diffie-Hellman (ECDH) a curva ellittica, che consente a entrambe le parti di avere lo stesso secret condiviso anche se si scambiano solo chiavi pubbliche. Utilizzeremo questo secret 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

Ho già tempo per un'altra parte. Supponiamo che tu voglia utilizzare alcuni dati segreti come chiave di crittografia, ma che non siano sufficientemente sicuri dalla crittografia. Puoi utilizzare la funzione di derivazione delle chiavi (HKDF) basata su HMAC per trasformare un secret con bassa sicurezza in uno con sicurezza elevata.

Una conseguenza del funzionamento è che consente di prendere un secret di un numero qualsiasi di bit e di produrre un altro secret di qualsiasi dimensione fino a 255 volte più di un hash prodotto da qualsiasi algoritmo di hashing che utilizzi. Per la modalità push, le specifiche richiedono l'utilizzo di SHA-256, che ha una lunghezza hash di 32 byte (256 bit).

Sappiamo che basta generare chiavi con una dimensione massima di 32 byte. Ciò significa che possiamo utilizzare una versione semplificata dell'algoritmo che non è in grado di gestire dimensioni di output più grandi.

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

Gli input per HKDF sono un sale, alcuni materiali di keying iniziali (ikm), un elemento facoltativo di dati strutturati specifici per il caso d'uso attuale (informazioni) e la lunghezza 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 utilizziamo HKDF per trasformare i dati in nostro possesso nei parametri per l'effettiva crittografia.

La prima cosa che facciamo è utilizzare HKDF per combinare il secret di autenticazione del client e il secret condiviso in un secret più lungo e con maggiore sicurezza crittografica. Nella specifica, questa è definita chiave pseudo-casuale (PRK), quindi la chiamerò qui, anche se i puristi della crittografia potrebbero 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 elementi vengono creati creando per ciascuno una semplice struttura di dati, indicata nella specifica come informazioni, contenente informazioni specifiche sulla curva ellittica, sul mittente e sul destinatario delle informazioni al fine di verificare ulteriormente la fonte del messaggio. Quindi utilizziamo HKDF con PRK, il nostro sale e le informazioni per ricavare la chiave e il nonce della dimensione corretta.

Il tipo di informazioni per la crittografia dei contenuti è "aesgcm", ovvero il nome della crittografia utilizzata 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'altra parte, e il tempo di un esempio ridicolo e artificioso. Supponiamo che il tuo capo abbia un server che le invia un messaggio push a intervalli di pochi minuti con le quotazioni dell'azienda. Il messaggio normale sarà sempre un numero intero a 32 bit con il valore in centesimi. Ha anche un subdolo accordo con il personale del catering, il che significa che possono inviarle lo spago "ciambelle nella sala del riposo" 5 minuti prima che vengano effettivamente consegnati, in modo che possa "coincidere" essere lì quando arrivano e prendere il migliore.

La crittografia utilizzata da Web Push crea valori criptati che sono esattamente 16 byte più lunghi dell'input non criptato. Poiché il termine "ciambelle nella sala relax" è più lungo di un prezzo delle azioni a 32 bit, qualsiasi dipendente di spionaggio sarà in grado di capire quando arrivano le ciambelle senza decriptare i messaggi, semplicemente dalla lunghezza dei dati.

Per questo motivo, il protocollo push web consente di aggiungere una spaziatura interna all'inizio dei dati. L'utilizzo dipende dall'applicazione, ma nell'esempio precedente potresti comprimere tutti i messaggi esattamente per 32 byte, rendendo impossibile distinguere i messaggi solo in base alla lunghezza.

Il valore di spaziatura è un numero intero big-endian a 16 bit che specifica la lunghezza della spaziatura interna seguita da quel numero di NUL byte di spaziatura. Quindi la spaziatura interna minima è di due 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 rimuovere automaticamente la spaziatura interna, in modo che il codice client riceva solo il messaggio senza spaziatura interna.

Crittografia

Ora abbiamo finalmente tutto ciò che serve per la crittografia. La crittografia richiesta per Web Push è AES128 che utilizza GCM. Utilizziamo la nostra chiave di crittografia dei contenuti come chiave e il nonce come vettore di inizializzazione (IV).

In questo esempio i dati sono una stringa, ma potrebbero trattarsi di qualsiasi dato binario. Puoi inviare payload di dimensioni comprese tra 4078 byte e 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 web

Finalmente. Ora che hai un payload criptato, devi solo effettuare una richiesta HTTP POST relativamente semplice all'endpoint specificato dall'abbonamento dell'utente.

Devi impostare tre intestazioni.

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

<SALT> e <PUBLICKEY> sono la chiave pubblica del sale e del server utilizzata nella crittografia, codificata come Base64 sicura per URL.

Quando utilizzi il protocollo web push, il corpo del POST corrisponde ai byte non elaborati del messaggio criptato. Tuttavia, finché Chrome e Firebase Cloud Messaggiging non supportano il protocollo, puoi includere facilmente i dati nel payload JSON esistente come descritto di seguito.

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

Il valore della proprietà rawData deve essere la rappresentazione con codifica Base64 del messaggio criptato.

Debug / verifica

Peter Beverloo, uno degli ingegneri di Chrome che hanno implementato la funzionalità (oltre a essere una delle persone che si sono occupate delle specifiche), ha creato una verifica.

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