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ć. Trzeba było wysłać dodatkowe żądanie do serwera i uzyskać szczegóły powiadomienia, które mogły się wyświetlić, a przy słabych warunkach sieci mógł wystąpić błąd.

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ż duża odpowiedzialność, dlatego wszystkie dane ładunku muszą być zaszyfrowane.

Szyfrowanie ładunków to ważna część historii zabezpieczeń związanej z technologią 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 gwarantuje jedynie, że nikt nie będzie mógł uzyskać dostępu do wiadomości przesyłanej 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ć. W celu ochrony przed takimi sytuacjami używamy szyfrowania, aby mieć pewność, że usługi push nie mogą odczytać ani zmodyfikować ładunków w ruchu.

Zmiany po stronie klienta

Jeśli masz już wdrożone powiadomienia push bez ładunków, musisz wprowadzić tylko 2 drobne zmiany po stronie klienta.

Po pierwsze, gdy wysyłasz informacje o subskrypcji do serwera, musisz zebrać dodatkowe informacje. Jeśli używasz już polecenia JSON.stringify() w obiekcie PushSubscription do serializowania go 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)

Druga zmiana to nowa właściwość data po uruchomieniu zdarzenia push. Ma różne metody synchroniczne do analizowania odebranych danych, np. .text(), .json(), .arrayBuffer() i .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 innych 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 w przypadku wielu popularnych języków nie ma jeszcze 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 użyto polecenia JSON.stringify() i przesłano je na serwer, klucz publiczny klienta jest przechowywany w polu keys.p256dh, a udostępnione hasło uwierzytelniające – w polu keys.auth. Jak wspomnieliśmy wcześniej, kod w obu przypadkach jest zgodny z adresem URL zakodowanym w standardzie Base64. 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. Nic dziwnego, że dane te należy zachować w tajemnicy i udostępnić wyłącznie serwerowi aplikacji, na który mają być wysyłane wiadomości, i traktować je jak hasło.

Musimy też wygenerować nowe dane. Potrzebujemy kryptograficznie bezpiecznych 16-bajtowych kluczy sól losowych i publicznych/prywatnych kluczy krzywych eliptycznych. 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 uzgadniania klucza krzywej eliptycznej Diffie-Hellman (ECDH), który umożliwia obu stronom ten sam wspólny obiekt tajny, mimo że wymieniały tylko klucze publiczne. 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

Już czas na kolejną. 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. Oparta na HMAC funkcji derivacji kluczy (HKDF) możesz przekształcić obiekt tajny o niskim bezpieczeństwie w taki o wysokim poziomie zabezpieczeń.

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 razów 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).

Zdajemy sobie sprawę, że wystarczy wygenerować klucze o rozmiarze 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.

Kod wersji węzła znajduje się poniżej. Informację o jego faktycznej działaniu znajdziesz w dokumencie RFC 5869.

Dane wejściowe do HKDF to ciąg zaburzający, początkowy materiał do obsługi klucza (ikm), opcjonalny fragment uporządkowanych danych specyficznych dla bieżącego przypadku użycia (informacje) oraz długość w bajtach żą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);
}

Uzyskiwanie parametrów szyfrowania

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

Najpierw używamy funkcji HKDF, aby połączyć tajny klucz klienta i tajny klucz wspólny w dłuższy, bezpieczniejszy pod względem kryptograficznym 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 wartość jednorazową, która będzie przekazywana do mechanizmu 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 śmiesznym 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 ich 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 socjalnym” są dłuższe niż 32-bitowa cena akcji, każdy podejrzany pracownik może stwierdzić, kiedy pączki przychodzą, bez odszyfrowywania wiadomości, tylko na podstawie ich długości.

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żliwi 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 zaszyfrowana 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 automatycznie usunie 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 są ciągiem 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()]);

Push z internetu

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

W przypadku korzystania z protokołu Web Push treść żądania POST to tylko nieprzetworzone bajty zaszyfrowanej wiadomości. Dopóki jednak Chrome i Firebase Cloud Messages nie będą obsługiwać tego protokołu, możesz w łatwy sposób uwzględnić dane w istniejącym ładunku JSON.

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

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

Debugowanie / weryfikator

Peter Beverloo, jeden z inżynierów Chrome, który wdrożył tę funkcję (i jeden z osób, które nad nią pracował), utworzył weryfikatora.

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

.