Szyfrowanie ładunku Web push

Wagi matowe

Przed Chrome 50 wiadomości push nie mogły zawierać żadnych danych ładunku. Gdy w skrypcie service worker uruchomiono zdarzenie „push”, było wiadomo, że serwer chciał Ci coś powiedzieć, ale nie wiedział, o co chodzi. Następnie trzeba było wysłać do serwera dodatkowe żądanie i uzyskać szczegóły powiadomienia, które się pokazało, co może spowodować błąd w przypadku słabej sieci.

Teraz w przeglądarce Chrome 50 (i obecnej wersji Firefoksa na komputery) można wraz z przekazywaniem wysyłania dowolnych danych wysyłać dowolne dane, aby klient mógł uniknąć wysyłania dodatkowych żądań. Jednakże duża moc wiąże się z dużą odpowiedzialnością, więc wszystkie dane ładunku muszą być szyfrowane.

Szyfrowanie ładunków to ważna część historii dotyczącej bezpieczeństwa w Web push. HTTPS zapewnia bezpieczeństwo podczas komunikacji między przeglądarką a Twoim serwerem, ponieważ ufasz serwerowi. Przeglądarka wybiera jednak dostawcę usług push, który będzie używany do dostarczania ładunku, dlatego jako deweloper aplikacji nie masz nad nim kontroli.

W tym przypadku protokół HTTPS gwarantuje jedynie, że nikt nie odczyta wiadomości w trakcie przesyłania do dostawcy usługi push. Po otrzymaniu go mogą oni robić to, co chcą, np. przesłać ładunek do innych podmiotów lub złośliwie przerobić go na coś innego. W celu ochrony ładunków w ruchu stosujemy szyfrowanie, dzięki czemu usługi push nie mogą odczytywać ani modyfikować ładunków w trakcie ich przesyłania.

Zmiany po stronie klienta

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

Po pierwsze, gdy wysyłasz informacje o subskrypcji do serwera backendu, musisz zebrać dodatkowe dane. Jeśli używasz już JSON.stringify() w obiekcie PushSubscription do zserializowania go na potrzeby wysyłania na serwer, nie musisz niczego zmieniać. Subskrypcja będzie teraz zawierać dodatkowe dane we właściwości kluczy.

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

Dwie wartości p256dh i auth są zakodowane w wariancie kodowania Base64, który nazwam Safe Base64.

Jeśli chcesz przejść od razu do bajtów, możesz użyć w subskrypcji nowej metody getKey(), która zwraca parametr jako ArrayBuffer. Wymagane są 2 parametry: auth i p256dh.

> 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 wywołaniu zdarzenia push. Wykorzystuje różne metody synchroniczne do analizowania otrzymywanych danych, np. .text(), .json(), .arrayBuffer() i .blob().

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

Zmiany po stronie serwera

Nieco bardziej się zmienia po stronie serwera. Podstawowy proces polega na tym, że do szyfrowania ładunku używasz klucza szyfrowania otrzymanego od klienta, a następnie przesyłasz je jako treść żądania POST do punktu końcowego w subskrypcji, dodając kilka dodatkowych nagłówków HTTP.

Szczegóły są względnie złożone, a tak jak w przypadku szyfrowania na różnych urządzeniach lepiej jest korzystać z aktywnej biblioteki niż tworzyć własną. Zespół Chrome opublikował bibliotekę dla Node.js. Wkrótce dodamy kolejne języki i platformy. Obsługuje zarówno szyfrowanie, jak i protokół web push, dzięki czemu wysyłanie wiadomości push z serwera Node.js jest tak samo proste jak webpush.sendWebPush(message, subscription).

Choć zdecydowanie zalecamy korzystanie z biblioteki, jest to nowa funkcja, a w wielu popularnych językach nie ma jeszcze żadnych bibliotek. Jeśli musisz to zrobić samodzielnie, podajesz te szczegóły.

Przedstawię algorytmy za pomocą JavaScriptu opartego na węzłach, ale podstawowe zasady powinny być takie same w każdym języku.

Dane wejściowe

Aby zaszyfrować wiadomość, musimy najpierw uzyskać 2 elementy z obiektu subskrypcji otrzymanego od klienta. Jeśli na kliencie użyto protokołu JSON.stringify() i przesłaliśmy go na serwer, klucz publiczny klienta jest przechowywany w polu keys.p256dh, a udostępniony tajny klucz uwierzytelniania znajduje się w polu keys.auth. Obydwa pliki będą zakodowane w formacie Base64 przeznaczonym do bezpiecznego przesyłania w adresie URL. 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 szyfrowanie wiadomości w taki sposób, że można ją odszyfrować tylko przy użyciu klucza prywatnego klienta.

Klucze publiczne są zwykle uznawane za publiczne, więc aby umożliwić klientowi uwierzytelnianie, że wiadomość została wysłana przez zaufany serwer, używamy również tajnego klucza uwierzytelniania. Jak można się było domyślić, informacje te należy przechowywać w tajemnicy, udostępniać tylko serwerowi aplikacji, na który mają być wysyłane wiadomości, i traktować je jak hasło.

Musimy też wygenerować nowe dane. Potrzebujemy 16-bajtowej, zabezpieczonej kryptograficznie losowej sól oraz publicznej/prywatnej pary kluczy krzywej eliptycznej. Konkretna krzywa używana przez specyfikację szyfrowania push nosi nazwę P-256, czyli prime256v1. Ze względu na bezpieczeństwo para kluczy powinna być generowana za każdym razem, gdy wiadomość jest szyfrowana. Nie należy używać soli ponownie.

ECDH

Przyjrzyjmy się teraz pewnej właściwości kryptografii krzywych eliptycznych. Istnieje względnie prosty proces, który łączy Twój klucz prywatny z kluczem publicznym innego użytkownika w celu uzyskania wartości. I co z tego? Jeśli druga strona weźmie swój klucz prywatny, a Twój klucz publiczny, uzyska dokładnie taką samą wartość.

Jest to podstawa protokołu krzywej eliptycznej Diffiego-Hellmana (ECDH), który pozwala obu stronom mieć ten sam wspólny obiekt tajny, mimo że wymieniają się tylko kluczami publicznymi. Wykorzystamy ten wspólny klucz szyfrowania jako podstawę 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);

dolarach Hongkongu

Czas na inny. 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 opartej na HMAC (HKDF), aby przekształcić obiekt tajny o niskim poziomie bezpieczeństwa w taki o wysokim zabezpieczeniu.

Jedną z konsekwencji jego działania jest to, że pozwala on uzyskać obiekt tajny dowolnej liczby bitów i wygenerować kolejny tajny klucz o dowolnym rozmiarze do 255 razy. Następnym razem wystarczy, że zostanie on wygenerowany przez inny algorytm szyfrowania. W przypadku push specyfikacja wymaga użycia algorytmu SHA-256 o długości skrótu 32 bajtów (256 bitów).

Wiemy, że musimy wygenerować klucze o rozmiarze nieprzekraczającym 32 bajtów. Oznacza to, że możemy używać uproszczonej wersji algorytmu, który nie obsługuje większych rozmiarów danych wyjściowych.

Poniżej znajdziesz kod wersji węzła, ale możesz dowiedzieć się, jak to działa, w dokumencie RFC 5869.

Dane wejściowe do HKDF to sól, początkowy materiał klucza (ikm), opcjonalny element uporządkowanych danych charakterystyczny dla bieżącego przypadku użycia (informacje) oraz długość w bajtach wybranego 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

Teraz używamy HKDF, aby przekształcać posiadane dane w parametry faktycznego szyfrowania.

Pierwszą rzeczą, jaką robimy, jest użycie HKDF, aby połączyć tajny klucz uwierzytelniania klienta i udostępniony klucz w dłuższy i bezpieczniejszy kryptograficznie klucz. W specyfikacji jest to tzw. pseudolosowy klucz (PRK), więc tak go nazywam. Chociaż puriści zajmujący się kryptografią mogą zauważyć, że nie jest to tylko PRK.

Teraz tworzymy ostateczny klucz szyfrowania treści oraz jednorazową wartość, która będzie przekazywana do mechanizmu szyfrowania. Powstają one przez uproszczenie struktury danych dla każdej z nich, nazywanej w specyfikacji informacją zawierającą informacje specyficzne dla krzywej eliptycznej oraz nadawcy i odbiorcy. Pomaga to w dalszej weryfikacji źródła wiadomości. Następnie używamy HKDF z PRK, ciągiem zaburzającym i informacjami, aby uzyskać klucz i ciąg jednorazowy o prawidłowym rozmiarze.

Typ informacji używany do szyfrowania treści to „aesgcm”, czyli nazwa mechanizmu szyfrowania 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

A teraz czas na zabawny i wymyślony przykład. Załóżmy, że Twój szef ma serwer, który co kilka minut wysyła do niej wiadomość z informacją o cenie akcji firmy. Prosty komunikat to zawsze 32-bitowa liczba całkowita z wartością w centach. Ma też niejawne umowy z pracownikami cateringu, co oznacza, że mogą wysłać jej wiadomość „pączki w pokoju socjalnym” na 5 minut przed ich dostarczeniem, by mogła „przypadkowo” być tam, gdzie przyjedzie i wybrać najlepsze.

Mechanizm szyfrowania używany przez Web Push tworzy zaszyfrowane wartości, które są o dokładnie 16 bajtów dłuższe niż niezaszyfrowane dane wejściowe. Ponieważ „pączki w pokoju socjalnym” są dłuższe niż 32-bitowe ceny akcji, każdy pracownik, który przechwyci pączki, będzie mógł określić, kiedy pączki dotrą do domu, bez odszyfrowywania wiadomości, tylko na podstawie długości danych.

Z tego powodu protokół web push pozwala dodawać dopełnienie na początku danych. Sposób wykorzystania zależy od danej aplikacji, ale w przykładzie powyżej możesz wypełnić wszystkie wiadomości tak, aby miały dokładnie 32 bajty, co uniemożliwi ich rozróżnianie tylko na podstawie ich długości.

Wartość dopełnienia to 16-bitowa liczba całkowita określająca długość dopełnienia, po której następuje ta liczba bajtów dopełnienia wynosząca NUL. Minimalne dopełnienie to 2 bajty, czyli 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);

Po otrzymaniu wiadomości push do klienta przeglądarka może automatycznie usunąć wszelkie dopełnienia, dzięki czemu kod klienta otrzyma tylko niedodaną wiadomość.

Szyfrowanie

W końcu mamy za sobą wszystko związane z szyfrowaniem. Mechanizm szyfrowania wymagany w przypadku Web Push to AES128 korzystający z GCM. Nasz klucz szyfrowania treści jest używany jako klucz, a liczba jednorazowa jako wektor inicjujący (IV).

W tym przykładzie nasze dane mają postać ciągu znaków, ale mogą to być dowolne dane binarne. Możesz wysyłać ładunki o rozmiarze do 4078 bajtów, czyli maksymalnie 4096 bajtów na posta, z 16 bajtami w przypadku informacji o szyfrowaniu i co najmniej 2 bajtami do dopełnienia.

// 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... Po zaszyfrowaniu ładunku wystarczy utworzyć względnie proste żądanie HTTP POST do punktu końcowego określonego w subskrypcji 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 podczas szyfrowania, zakodowane w formacie Base64 przeznaczonym do bezpiecznego przesyłania w adresie URL.

W przypadku protokołu Web Push treść żądania POST to tylko nieprzetworzone bajty zaszyfrowanej wiadomości. Jednak dopóki Chrome i Komunikacja w chmurze Firebase nie będą obsługiwać tego protokołu, możesz łatwo dołączyć dane do istniejącego ładunku JSON w następujący sposób.

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

Wartość właściwości rawData musi być reprezentacją zaszyfrowanej wiadomości zakodowaną w standardzie base64.

Debugowanie / weryfikator

Peter Beverloo, jeden z inżynierów Chrome, którzy wdrożyli tę funkcję (i jedno z osób pracujących nad specyfikacją), utworzył weryfikatora.

Gdy kod generuje wszystkie wartości pośrednie szyfrowania, możesz wkleić je do weryfikatora i sprawdzić, czy jesteś na właściwej ścieżce.