Szyfrowanie ładunku Web push

Mat Scales

Przed wersją Chrome 50 wiadomości push nie mogły zawierać żadnych danych ładunku. Gdy w Twoim serwisie worker wystąpiło zdarzenie „push”, wiedziałeś/wiedziałaś tylko, że serwer próbuje Ci coś przekazać, ale nie wiedziałeś/wiedziałaś, co to może być. Następnie musisz wysłać do serwera kolejne żądanie i pobrać szczegóły powiadomienia, które ma się wyświetlić. W przypadku słabego połączenia z internetem może to się nie udać.

W Chrome 50 (i w bieżącej wersji Firefoxa na komputery) możesz wysyłać dowolne dane wraz z powiadomieniem push, aby klient nie musiał wysyłać dodatkowego żądania. Jednak z dużą mocą wiąże się też wielka odpowiedzialność, dlatego wszystkie dane ładunku muszą być zaszyfrowane.

Szyfrowanie ładunków jest ważnym elementem bezpieczeństwa w przypadku web push. HTTPS zapewnia bezpieczeństwo komunikacji między przeglądarką a Twoim serwerem, ponieważ ufasz temu serwerowi. Przeglądarka wybiera jednak dostawcę powiadomień push, który będzie używany do przesyłania danych, więc jako deweloper aplikacji nie masz nad tym kontroli.

W tym przypadku protokół HTTPS może tylko zagwarantować, że nikt nie będzie w stanie podglądać wiadomości podczas przesyłania do dostawcy usługi push. Po otrzymaniu danych mogą oni robić z nimi, co chcą, w tym ponownie przesyłać je do innych osób lub złośliwie je modyfikować. Aby temu zapobiec, używamy szyfrowania, dzięki któremu usługi push nie mogą odczytywać ani modyfikować danych przesyłanych.

Zmiany po stronie klienta

Jeśli masz już wdrożone powiadomienia push bez ładunku, musisz wprowadzić tylko 2 niewielkie zmiany po stronie klienta.

Po pierwsze, gdy wysyłasz informacje o subskrypcji do serwera, musisz zebrać dodatkowe informacje. Jeśli obiekt JSON.stringify() w obiekcie PushSubscription jest już używany do serializacji danych na potrzeby wysyłania na serwer, nie musisz niczego zmieniać. Subskrypcja będzie teraz zawierać dodatkowe dane w właściwości klucze.

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

Obie wartości p256dhauth są zakodowane w wersji formatu Base64, którą nazywam bezpieczny dla sieci Base64.

Jeśli chcesz uzyskać bezpośrednio bajty, możesz użyć nowej metody getKey() w subskrypcji, która zwraca parametr jako ArrayBuffer. Potrzebujesz 2 parametrów: authp256dh.

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

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

Drugą zmianą jest nowa właściwość data, która jest używana, gdy zostanie wywołane zdarzenie push. Zawiera różne synchroniczne metody analizowania otrzymanych danych, takie jak .text(), .json(), .arrayBuffer().blob().

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

Zmiany po stronie serwera

Po stronie serwera sytuacja wygląda nieco inaczej. Podstawowy proces polega na tym, że używasz informacji o kluczu szyfrowania otrzymanych od klienta, aby zaszyfrować ładunek, a następnie wysyłasz go jako treść żądania POST do punktu końcowego w ramach subskrypcji, dodając dodatkowe nagłówki HTTP.

Szczegóły są dość skomplikowane i jak w przypadku wszystkich kwestii związanych z szyfrowaniem lepiej jest użyć aktywnie rozwijanej biblioteki niż tworzyć własną. Zespół Chrome opublikował bibliotekę dla Node.js. Wkrótce udostępnimy ją w większej liczbie języków i na więcej platform. Obsługuje to zarówno szyfrowanie, jak i protokół web push, dzięki czemu wysyłanie wiadomości push z serwera Node.js jest tak proste jak webpush.sendWebPush(message, subscription).

Zdecydowanie zalecamy korzystanie z biblioteki, ale jest to nowa funkcja i wiele popularnych języków nie ma jeszcze żadnych bibliotek. Jeśli chcesz wdrożyć tę funkcję samodzielnie, znajdziesz tutaj szczegółowe informacje.

Pokażę, jak działają algorytmy, używając języka JavaScript w wersji Node, ale podstawowe zasady powinny być takie same w dowolnym języku.

Dane wejściowe

Aby zaszyfrować wiadomość, musimy najpierw pobrać 2 elementy z obiektu subscription otrzymanego od klienta. Jeśli na kliencie został użyty klucz publiczny JSON.stringify() i został on przesłany na serwer, klucz publiczny klienta jest przechowywany w polu keys.p256dh, a udostępnione hasło uwierzytelniające w polu keys.auth. Oba te ciągi tekstowe będą zakodowane w formacie Base64 bezpiecznym dla adresów URL, jak wspomniano powyżej. Format binarny klucza publicznego klienta to nieskompresowany punkt krzywej eliptycznej P-256.

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

Klucz publiczny umożliwia nam zaszyfrowanie wiadomości w taki sposób, aby można ją było odszyfrować tylko za pomocą klucza prywatnego klienta.

Klucze publiczne są zwykle uważane za publiczne, więc aby umożliwić klientowi uwierzytelnianie, że wiadomość została wysłana przez zaufany serwer, używamy też tajnego klucza uwierzytelniania. Nie powinno się go ujawniać. Należy je udostępnić tylko serwerowi aplikacji, który ma wysyłać wiadomości, i traktować jak hasło.

Musimy też wygenerować nowe dane. Potrzebujemy 16-bajtowego losowego soli zabezpieczonego kryptograficznie oraz pary kluczy publiczno-prywatnych na podstawie krzywej eliptycznej. Konkretna krzywa używana przez specyfikację szyfrowania push nosi nazwę P-256 lub prime256v1. Aby zapewnić najwyższy poziom bezpieczeństwa, parę kluczy należy generować od podstaw za każdym razem, gdy szyfrujesz wiadomość, i nigdy nie używać ponownie soli.

ECDH

Na chwilę odejdźmy od tematu i porozmawiajmy o ciekawej właściwości kryptografii krzywych eliptycznych. Aby uzyskać wartość, należy połączyć swój klucz prywatny z czyimś kluczem publicznym. Co z tego? Jeśli druga strona weźmie swój klucz prywatny i Twój klucz publiczny, uzyska dokładnie tę samą wartość.

Jest to podstawa protokołu klucza Diffie-Hellmana (ECDH) wykorzystującego krzywą eliptyczną, który pozwala obu stronom na posiadanie tego samego klucza tajnego, mimo że wymieniły się tylko kluczami publicznymi. Użyjemy tego udostępnionego hasła jako podstawy dla rzeczywistego klucza szyfrowania.

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

Czas na kolejną uwagę. Załóżmy, że masz tajne dane, których chcesz użyć jako klucza szyfrowania, ale nie są one wystarczająco bezpieczne pod względem kryptograficznym. Możesz użyć funkcji derywacji klucza (HKDF) opartej na HMAC, aby zamienić tajny klucz o niskiej ochronie na klucz o wysokiej ochronie.

Jednym z efektów działania tego algorytmu jest to, że pozwala on na przekształcenie tajnego klucza o dowolnej liczbie bitów w inny tajny klucz o dowolnej długości, który jest do 255 razy dłuższy od hasha wygenerowanego przez dowolny algorytm haszowania. W przypadku push specyfikacja wymaga użycia algorytmu SHA-256, który ma długość hasza 32 bajtów (256 bitów).

Wiemy, że wystarczy wygenerować klucze o długości do 32 bajtów. Oznacza to, że możemy użyć uproszczonej wersji algorytmu, która nie obsługuje większych rozmiarów danych wyjściowych.

Poniżej zamieściliśmy kod wersji Node, ale sposób jego działania można sprawdzić w dokumentacji RFC 5869.

Wejściami do HKDF są sól, początkowy materiał kluczowy (ikm), opcjonalne dane uporządkowane specyficzne dla bieżącego przypadku użycia (info) oraz długość w bajtach pożądanego klucza wyjściowego.

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

Wyznaczanie parametrów szyfrowania

Obecnie używamy HKDF do przekształcania posiadanych danych w parametry do faktycznego szyfrowania.

Najpierw używamy funkcji HKDF, aby zmieszać tajny klucz klienta i tajny klucz wspólny w dłuższy, bezpieczniejszy kryptograficznie klucz. W specyfikacji jest to klucz pseudolosowy (PRK), więc tak go tutaj nazwę, chociaż puryści specjalizujący się w kryptografii mogą zauważyć, że nie jest to ściśle klucz pseudolosowy.

Teraz tworzymy ostateczny klucz szyfrowania treści i nonce, który zostanie przekazany do szyfrowania. Są one tworzone przez tworzenie prostej struktury danych dla każdego z nich, o której w specyfikacji mowa jako o informacji, która zawiera informacje dotyczące krzywej eliptycznej, nadawcy i odbiorcy informacji, aby można było dokładniej zweryfikować źródło wiadomości. Następnie używamy HKDF z PRK, naszej soli i informacji, aby wyprowadzić klucz i nonce o odpowiedniej wielkości.

Typ informacji dotyczący szyfrowania treści to „aesgcm”, czyli nazwa szyfru używanego do szyfrowania 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);

Dopełnienie

Kolejna dygresja, tym razem o głupim i sztucznym przykładzie. Załóżmy, że twój szef ma serwer, który co kilka minut wysyła mu wiadomość push z ceną akcji firmy. Zwykły komunikat w tym przypadku to zawsze 32-bitowa liczba całkowita z wartością w centach. Ma też sprytną umowę z personelem cateringowym, który może wysłać jej wiadomość „pączki w pokoju relaksu” na 5 minut przed dostarczeniem, aby mogła „przypadkiem” tam być, gdy przyjdą, i wybrać najlepszy.

Szyfr używany przez Web Push tworzy zaszyfrowane wartości, które są dłuższe o 16 bajtów od nieszyfrowanych danych wejściowych. Ponieważ „pączki w pokoju relaksu” to więcej niż 32-bitowa cena akcji, każdy szpiegujący pracownik będzie w stanie określić, kiedy przyjeżdżają pączki, bez odszyfrowywania wiadomości, tylko na podstawie długości danych.

Dlatego protokół web push umożliwia dodanie wypełniacza na początku danych. Sposób użycia zależy od aplikacji, ale w przypadku przykładu powyżej można wypełnić wszystkie wiadomości dokładnie 32 bajtami, co uniemożliwia ich rozróżnienie na podstawie długości.

Wartość wypełnienia to 16-bitowa liczba całkowita w formacie big-endian określająca długość wypełnienia, po której następuje liczba bajtów NUL. Minimalny wypełniacz to więc 2 bajty – liczba 0 zakodowana w 16 bitach.

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

Gdy wiadomość push dotrze do klienta, przeglądarka będzie mogła automatycznie usunąć wypełnienie, dzięki czemu kod klienta otrzyma tylko wiadomość bez wypełnień.

Szyfrowanie

Mamy już wszystko, co potrzebne do szyfrowania. Szyfr wymagany do wysyłania powiadomień web push to AES128 z użyciem GCM. Używamy klucza szyfrowania treści jako klucza, a nonce jako wektora inicjującego (IV).

W tym przykładzie dane to ciąg znaków, ale mogą to być dowolne dane binarne. Możesz przesyłać ładunki o rozmiarze do 4078 bajtów (maksymalnie 4096 bajtów na wiadomość), z których 16 bajtów jest przeznaczone na informacje szyfrujące, a co najmniej 2 bajty na wypełnienie.

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

Web push

Uff... Teraz, gdy masz zaszyfrowany ładunek, wystarczy wysłać stosunkowo proste żądanie HTTP POST do punktu końcowego określonego przez subskrypcję użytkownika.

Musisz ustawić 3 nagłówki.

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

<SALT><PUBLICKEY> to sól i klucz publiczny serwera używane w szyfrowaniu, zakodowane w formacie Base64 przeznaczonym do bezpiecznego przesyłania w adresie URL.

Gdy używasz protokołu Web Push, treść żądania POST to tylko surowe bajty zaszyfrowanej wiadomości. Dopóki Chrome i Firebase Cloud Messaging nie zaczną obsługiwać tego protokołu, możesz łatwo dodać te dane do istniejącego ładunku JSON, wykonując te czynności.

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

Wartość właściwości rawData musi być zakodowana w formacie base64 i przedstawiać zaszyfrowaną wiadomość.

Debugowanie / weryfikator

Peter Beverloo, jeden z inżynierów Chrome, który wdrożył tę funkcję (a także jeden z osób, które pracowały nad specyfikacją), utworzył weryfikator.

Gdy kod wygeneruje wszystkie wartości pośrednie szyfrowania, możesz je wkleić w weryfikatorze i sprawdzić, czy wszystko działa prawidłowo.

.