Enkripsi Payload Web Push

Timbangan Mat

Sebelum Chrome 50, pesan push tidak boleh berisi data payload apa pun. Ketika peristiwa'push' diaktifkan pada pekerja layanan, yang Anda tahu hanyalah bahwa server mencoba memberi tahu Anda sesuatu, tetapi bukan hal itu. Anda kemudian harus membuat permintaan tindak lanjut ke server dan mendapatkan detail notifikasi untuk ditampilkan, yang mungkin gagal dalam kondisi jaringan yang buruk.

Sekarang di Chrome 50 (dan pada versi Firefox saat ini di desktop), Anda dapat mengirim beberapa data arbitrer bersamaan dengan push sehingga klien dapat menghindari pembuatan permintaan tambahan. Namun, kekuatan besar harus disertai tanggung jawab yang besar pula, sehingga semua data payload harus dienkripsi.

Enkripsi payload adalah bagian penting dari cerita keamanan untuk web push. HTTPS memberikan keamanan saat berkomunikasi antara browser dan server Anda sendiri, karena Anda memercayai server. Namun, browser memilih penyedia push yang akan digunakan untuk benar-benar mengirimkan payload, sehingga Anda, sebagai developer aplikasi, tidak memiliki kontrol atas penyedia tersebut.

Di sini, HTTPS hanya dapat menjamin bahwa tidak ada yang dapat mengintip pesan saat dalam pengiriman ke penyedia layanan push. Setelah menerimanya, mereka bebas melakukan apa yang mereka inginkan, termasuk mengirim ulang payload ke pihak ketiga atau mengubahnya dengan berbahaya ke hal lain. Untuk melindungi dari hal ini, kami menggunakan enkripsi untuk memastikan bahwa layanan push tidak dapat membaca atau mengutak-atik payload dalam pengiriman.

Perubahan sisi klien

Jika Anda telah mengimplementasikan notifikasi push tanpa payload, maka hanya ada dua perubahan kecil yang perlu Anda lakukan di sisi klien.

Yang pertama, saat Anda mengirim informasi langganan ke server backend, Anda perlu mengumpulkan beberapa informasi tambahan. Jika sudah menggunakan JSON.stringify() pada objek PushSubscription untuk melakukan serialisasi agar dikirimkan ke server, Anda tidak perlu mengubah apa pun. Langganan sekarang akan memiliki beberapa data tambahan di properti kunci.

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

Dua nilai p256dh dan auth dienkode dalam varian Base64 yang akan saya panggil URL-Safe Base64.

Jika ingin langsung mengetahui byte, Anda dapat menggunakan metode getKey() baru pada langganan yang menampilkan parameter sebagai ArrayBuffer. Dua parameter yang Anda perlukan adalah auth dan p256dh.

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

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

Perubahan kedua adalah properti data baru saat peristiwa push diaktifkan. Class ini memiliki berbagai metode sinkron untuk mengurai data yang diterima, seperti .text(), .json(), .arrayBuffer(), dan .blob().

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

Perubahan sisi server

Di sisi server, segalanya sedikit berubah. Proses dasarnya adalah Anda menggunakan informasi kunci enkripsi yang didapatkan dari klien untuk mengenkripsi payload, lalu mengirimkannya sebagai isi permintaan POST ke endpoint di langganan, dengan menambahkan beberapa header HTTP tambahan.

Detailnya relatif kompleks, dan seperti semua yang terkait dengan enkripsi, sebaiknya gunakan library yang dikembangkan secara aktif daripada membuat library Anda sendiri. Tim Chrome telah memublikasikan library untuk Node.js, yang akan segera hadir untuk bahasa dan platform lainnya. API ini menangani enkripsi dan protokol push web, sehingga mengirim pesan push dari server Node.js semudah webpush.sendWebPush(message, subscription).

Meskipun kami sangat merekomendasikan penggunaan library, ini adalah fitur baru dan ada banyak bahasa populer yang belum memiliki library. Jika Anda perlu mengimplementasikannya sendiri, berikut detailnya.

Saya akan menggambarkan algoritma menggunakan JavaScript rasa Node, tetapi prinsip dasarnya harus sama dalam bahasa apa pun.

Input

Untuk mengenkripsi pesan, pertama-tama kami harus mendapatkan dua hal dari objek langganan yang kami terima dari klien. Jika Anda menggunakan JSON.stringify() pada klien dan mentransmisikannya ke server Anda, kunci publik klien akan disimpan di kolom keys.p256dh, sedangkan rahasia autentikasi bersama berada di kolom keys.auth. Keduanya akan dienkode ke Base64 yang aman untuk URL, seperti yang disebutkan di atas. Format biner kunci publik klien adalah titik kurva eliptis P-256 yang tidak dikompresi.

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

Kunci publik memungkinkan kita untuk mengenkripsi pesan sehingga hanya dapat didekripsi menggunakan kunci pribadi klien.

Kunci publik biasanya dianggap publik, jadi untuk memungkinkan klien mengotentikasi bahwa pesan dikirim oleh server terpercaya, kita juga menggunakan rahasia otentikasi. Tidak mengherankan, data ini harus dirahasiakan, hanya dibagikan dengan server aplikasi tempat Anda ingin mengirimkan pesan kepada Anda, dan diperlakukan seperti sandi.

Kita juga perlu membuat beberapa data baru. Kita memerlukan salt acak acak dengan 16 byte yang aman secara kriptografis dan sepasang kunci elliptic curve publik/pribadi. Kurva khusus yang digunakan oleh spesifikasi enkripsi push disebut P-256, atau prime256v1. Untuk keamanan terbaik, pasangan kunci harus dibuat dari awal setiap kali Anda mengenkripsi pesan, dan Anda tidak boleh menggunakan kembali salt.

ECDH

Mari kita singkirkan sejenak untuk berbicara tentang properti rapi dari kriptografi kurva eliptik. Terdapat proses yang relatif sederhana yang menggabungkan kunci pribadi Anda dengan kunci publik orang lain untuk mendapatkan nilai. Lalu apa? Jadi, jika pihak lain mengambil kunci pribadi mereka dan kunci publik Anda, ia akan mendapatkan nilai yang sama persis.

Ini adalah dasar dari protokol perjanjian kunci kurva eliptis Diffie-Hellman (ECDH), yang memungkinkan kedua belah pihak memiliki rahasia bersama yang sama meskipun mereka hanya bertukar kunci publik. Kita akan menggunakan rahasia bersama ini sebagai dasar untuk kunci enkripsi kita yang sebenarnya.

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

Sudah waktunya untuk hal lainnya. Anggap saja Anda memiliki beberapa data rahasia yang ingin digunakan sebagai kunci enkripsi, tetapi tidak cukup aman secara kriptografis. Anda dapat menggunakan Key Derivation Function (HKDF) berbasis HMAC untuk mengubah rahasia yang memiliki keamanan rendah menjadi rahasia dengan keamanan tinggi.

Salah satu konsekuensi dari cara kerjanya adalah cara ini memungkinkan Anda untuk merahasiakan sejumlah bit dan menghasilkan secret lain dengan ukuran apa pun hingga 255 kali lipat selama hash yang dihasilkan oleh algoritme hashing apa pun yang Anda gunakan. Untuk push, spesifikasi mengharuskan kita menggunakan SHA-256, yang memiliki panjang hash 32 byte (256 bit).

Saat ini, kita tahu bahwa kita hanya perlu menghasilkan kunci dengan ukuran hingga 32 byte. Ini berarti kita dapat menggunakan versi algoritma yang disederhanakan yang tidak dapat menangani ukuran output yang lebih besar.

Saya telah menyertakan kode untuk versi Node di bawah, tetapi Anda dapat mengetahui cara kerjanya di RFC 5869.

Input untuk HKDF adalah salt, beberapa material keying awal (ikm), bagian opsional dari data terstruktur yang khusus untuk kasus penggunaan saat ini (info), dan panjang dalam byte kunci output yang diinginkan.

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

Memperoleh parameter enkripsi

Kami sekarang menggunakan HKDF untuk mengubah data yang kita miliki menjadi parameter untuk enkripsi aktual.

Hal pertama yang kami lakukan adalah menggunakan HKDF untuk menggabungkan rahasia autentikasi klien dan rahasia bersama menjadi rahasia yang lebih panjang dan lebih aman secara kriptografis. Dalam spesifikasinya, ini disebut sebagai Kunci Pseudo-Acak (PRK), jadi itulah yang akan saya sebut di sini, meskipun puritan kriptografi mungkin mencatat bahwa ini bukan PRK.

Sekarang, kita akan membuat kunci enkripsi konten akhir dan nonce yang akan diteruskan ke cipher. Pesan ini dibuat dengan membuat struktur data sederhana untuk setiap pesan, disebut dalam spesifikasi sebagai info, yang berisi informasi khusus untuk kurva eliptis, pengirim dan penerima informasi untuk memverifikasi sumber pesan lebih lanjut. Kemudian kita menggunakan HKDF dengan PRK, salt kita, dan info untuk mendapatkan kunci dan nonce dengan ukuran yang tepat.

Jenis info untuk enkripsi konten adalah 'aesgcm' yang merupakan nama cipher yang digunakan untuk enkripsi 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);

Padding

Sisi lain, dan saatnya untuk contoh konyol dan dibuat-buat. Katakanlah bos Anda memiliki server yang mengiriminya pesan push setiap beberapa menit dengan harga saham perusahaan. Pesan biasa untuk ini akan selalu berupa bilangan bulat 32-bit dengan nilai dalam sen. Dia juga memiliki kesepakatan licik dengan staf katering yang berarti mereka dapat mengiriminya string "donat di ruang istirahat" 5 menit sebelum benar-benar dikirim sehingga dia dapat "secara kebetulan" berada di sana ketika mereka tiba dan mengambil yang terbaik.

Cipher yang digunakan oleh Web Push membuat nilai terenkripsi yang panjangnya tepat 16 byte lebih panjang daripada input yang tidak dienkripsi. Karena "donat di ruang istirahat" lebih panjang dari harga saham 32-bit, setiap karyawan pengintaian akan dapat mengetahui kapan donat itu tiba tanpa mendekripsi pesan, hanya dari panjang data yang ada.

Karena alasan ini, protokol web push memungkinkan Anda menambahkan padding ke awal data. Cara menggunakannya bergantung pada aplikasi Anda, tetapi dalam contoh di atas, Anda dapat menggabungkan semua pesan menjadi persis 32 byte, sehingga tidak mungkin untuk membedakan pesan berdasarkan panjang saja.

Nilai padding adalah bilangan bulat big-endian 16-bit yang menentukan panjang padding diikuti dengan jumlah byte NUL byte. Jadi padding minimum adalah dua byte - angka nol yang dienkode menjadi 16 bit.

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

Ketika pesan push Anda tiba di klien, browser akan dapat otomatis menghapus padding, sehingga kode klien Anda hanya menerima pesan yang tidak diberi padding.

Enkripsi

Sekarang kita akhirnya memiliki semua hal untuk melakukan enkripsi. Cipher yang diperlukan untuk Web Push adalah AES128 yang menggunakan GCM. Kami menggunakan kunci enkripsi konten sebagai kunci dan nonce sebagai initialization vector (IV).

Dalam contoh ini, data kita adalah string, tetapi bisa juga data biner. Anda dapat mengirim payload hingga ukuran 4.078 byte – maksimum 4.096 byte per postingan, dengan 16 byte untuk informasi enkripsi dan setidaknya 2 byte untuk padding.

// 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

Fiuh! Setelah memiliki payload terenkripsi, Anda hanya perlu membuat permintaan POST HTTP yang relatif sederhana ke endpoint yang ditentukan oleh langganan pengguna.

Anda perlu menyetel tiga header.

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

<SALT> dan <PUBLICKEY> adalah kunci publik salt dan server yang digunakan dalam enkripsi, yang dienkode sebagai Base64 yang aman untuk URL.

Saat menggunakan protokol Web Push, isi POST hanyalah byte mentah dari pesan terenkripsi. Namun, hingga Chrome dan Firebase Cloud Messaging mendukung protokol tersebut, Anda dapat dengan mudah menyertakan data dalam payload JSON yang ada sebagai berikut.

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

Nilai properti rawData harus berupa representasi berenkode base64 dari pesan terenkripsi.

Proses debug / pemverifikasi

Peter Beverloo, salah satu engineer Chrome yang menerapkan fitur tersebut (serta salah satu orang yang menangani spesifikasi ini), telah membuat pemverifikasi.

Dengan membuat kode untuk menghasilkan setiap nilai perantara enkripsi, Anda dapat menempelkannya ke pemverifikasi dan memastikan Anda berada di jalur yang benar.