Enkripsi Payload Web Push

Mat Scales

Sebelum Chrome 50, pesan push tidak boleh berisi data payload apa pun. Saat peristiwa'push' diaktifkan di pekerja layanan, yang Anda tahu hanyalah server mencoba memberi tahu Anda sesuatu, tetapi tidak tahu apa yang mungkin terjadi. Kemudian, Anda harus membuat permintaan tindak lanjut ke server dan mendapatkan detail notifikasi yang akan ditampilkan, yang mungkin gagal dalam kondisi jaringan yang buruk.

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

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

Di sini, HTTPS hanya dapat menjamin bahwa tidak ada yang dapat memata-matai pesan dalam pengiriman ke penyedia layanan push. Setelah menerimanya, mereka bebas melakukan tindakan yang mereka sukai, termasuk mengirimkan ulang payload kepada pihak ketiga atau mengubahnya menjadi hal lain dengan niat jahat. Untuk melindungi dari hal ini, kami menggunakan enkripsi untuk memastikan bahwa layanan push tidak dapat membaca atau memodifikasi payload selama dalam pengiriman.

Perubahan sisi klien

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

Hal pertama adalah saat Anda mengirim informasi langganan ke server backend, Anda perlu mengumpulkan beberapa informasi tambahan. Jika sudah menggunakan JSON.stringify() pada objek PushSubscription untuk membuat serialisasinya agar dapat dikirim ke server, Anda tidak perlu mengubah apa pun. Langganan kini 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=="}}

Kedua nilai p256dh dan auth dienkode dalam varian Base64 yang akan saya sebut Base64 Aman untuk URL.

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, ada sedikit perubahan. Proses dasarnya adalah Anda menggunakan informasi kunci enkripsi yang Anda dapatkan dari klien untuk mengenkripsi payload, lalu mengirimkannya sebagai isi permintaan POST ke endpoint dalam langganan, dengan menambahkan beberapa header HTTP tambahan.

Detailnya relatif kompleks, dan seperti halnya hal apa pun yang terkait dengan enkripsi, sebaiknya gunakan library yang dikembangkan secara aktif daripada membuat sendiri. Tim Chrome telah memublikasikan library untuk Node.js, dengan lebih banyak bahasa dan platform yang akan segera hadir. 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 menerapkan ini sendiri, berikut detailnya.

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

Input

Untuk mengenkripsi pesan, pertama-tama kita perlu mendapatkan dua hal dari objek langganan yang kita terima dari klien. Jika Anda menggunakan JSON.stringify() di klien dan mengirimkannya ke server, kunci publik klien akan disimpan di kolom keys.p256dh, sedangkan secret autentikasi bersama berada di kolom keys.auth. Keduanya akan dienkode Base64 yang aman untuk URL, seperti yang disebutkan di atas. Format biner kunci publik klien adalah titik kurva eliptik 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 mengenkripsi pesan sehingga hanya dapat didekripsi menggunakan kunci pribadi klien.

Kunci publik biasanya dianggap publik, jadi untuk mengizinkan klien melakukan autentikasi bahwa pesan dikirim oleh server tepercaya, kita juga menggunakan secret autentikasi. Tidak mengherankan, kunci ini harus dirahasiakan, hanya dibagikan dengan server aplikasi yang ingin mengirimi Anda pesan, dan diperlakukan seperti sandi.

Kita juga perlu membuat beberapa data baru. Kita memerlukan salt acak yang aman secara kriptografis dan pasangan kunci kurva eliptik publik/pribadi. Kurva tertentu 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 bahas sedikit properti menarik dari kriptografi kurva elips. Ada proses yang relatif sederhana yang menggabungkan kunci pribadi Anda dengan kunci publik orang lain untuk mendapatkan nilai. Jadi bagaimana? Nah, jika pihak lain mengambil kunci pribadi mereka dan kunci publik Anda, nilai yang dihasilkan akan sama persis.

Ini adalah dasar dari protokol kesepakatan kunci elliptic curve Diffie-Hellman (ECDH), yang memungkinkan kedua pihak memiliki rahasia bersama yang sama meskipun mereka hanya bertukar kunci publik. Kita akan menggunakan rahasia bersama ini sebagai dasar 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 membahas hal lain. Misalnya, Anda memiliki beberapa data rahasia yang ingin digunakan sebagai kunci enkripsi, tetapi tidak cukup aman secara kriptografis. Anda dapat menggunakan Fungsi Derivasi Kunci (HKDF) berbasis HMAC untuk mengubah secret dengan keamanan rendah menjadi secret dengan keamanan tinggi.

Salah satu konsekuensi dari cara kerjanya adalah memungkinkan Anda mengambil secret dari jumlah bit berapa pun dan menghasilkan secret lain dengan ukuran berapa pun hingga 255 kali selama hash dihasilkan oleh algoritma hashing apa pun yang Anda gunakan. Untuk push, spec mengharuskan kita menggunakan SHA-256, yang memiliki panjang hash 32 byte (256 bit).

Seperti yang terjadi, kita tahu bahwa kita hanya perlu membuat kunci hingga ukuran 32 byte. Artinya, 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 ke HKDF adalah salt, beberapa bahan kunci awal (ikm), bagian data terstruktur opsional yang spesifik 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);
}

Menghasilkan parameter enkripsi

Sekarang kita menggunakan HKDF untuk mengubah data yang kita miliki menjadi parameter untuk enkripsi yang sebenarnya.

Hal pertama yang kita lakukan adalah menggunakan HKDF untuk menggabungkan secret autentikasi klien dan secret umum menjadi secret yang lebih panjang dan lebih aman secara kriptografis. Dalam spesifikasi, ini disebut sebagai Kunci Pseudo-Random (PRK), jadi itulah yang akan saya sebut di sini, meskipun para purist kriptografi mungkin mencatat bahwa ini bukan sepenuhnya PRK.

Sekarang, kita membuat kunci enkripsi konten akhir dan nonce yang akan diteruskan ke cipher. Ini dibuat dengan membuat struktur data sederhana untuk masing-masing komponen, 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, dan info untuk mendapatkan kunci dan nonce dengan ukuran yang benar.

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

Selain itu, saatnya untuk contoh yang konyol dan dibuat-buat. Misalnya, bos Anda memiliki server yang mengirim pesan push kepadanya 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 bisa "secara kebetulan" berada di sana saat mereka tiba dan mendapatkan yang terbaik.

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

Karena alasan ini, protokol push web memungkinkan Anda menambahkan padding ke awal data. Cara menggunakannya bergantung pada aplikasi Anda, tetapi dalam contoh di atas, Anda dapat menambahkan ukuran 32 byte ke semua pesan, sehingga tidak mungkin membedakan pesan hanya berdasarkan panjangnya.

Nilai padding adalah bilangan bulat big-endian 16-bit yang menentukan panjang padding diikuti dengan jumlah padding NUL byte tersebut. Jadi, padding minimumnya 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);

Saat pesan push Anda tiba di klien, browser akan dapat otomatis menghapus padding apa pun, sehingga kode klien Anda hanya menerima pesan tanpa padding.

Enkripsi

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

Dalam contoh ini, data kita adalah string, tetapi bisa berupa data biner apa pun. Anda dapat mengirim payload hingga ukuran maksimum 4.078 byte - 4.096 byte per postingan, dengan 16 byte untuk informasi enkripsi dan minimal 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 menetapkan tiga header.

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

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

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

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

Nilai properti rawData harus berupa representasi pesan terenkripsi yang dienkode base64.

Proses debug / verifier

Peter Beverloo, salah satu engineer Chrome yang menerapkan fitur ini (serta merupakan salah satu orang yang mengerjakan spesifikasinya), telah membuat verifier.

Dengan membuat kode Anda menghasilkan setiap nilai perantara enkripsi, Anda dapat menempelkannya ke verifier dan memeriksa apakah Anda berada di jalur yang benar.