在 Chrome 50 之前,推播訊息不得包含任何酬載資料。當「push」事件在服務工作者中觸發時,您只知道伺服器正在嘗試告訴您一些資訊,但不知道可能會是什麼。接著,您必須向伺服器提出後續要求,並取得要顯示的通知詳細資料,但在網路連線不佳的情況下,這項操作可能會失敗。
在 Chrome 50 (以及電腦上目前版本的 Firefox) 中,您現在可以隨意傳送一些資料,以免用戶端需要額外提出要求。不過,權力越大,責任越重,因此所有酬載資料都必須加密。
酬載加密是網路推播安全性的重要一環。由於您信任伺服器,因此 HTTPS 可在瀏覽器與您自己的伺服器之間的通訊中提供安全性。不過,瀏覽器會選擇要用於實際傳送酬載的推播供應商,因此您 (應用程式開發人員) 無法控管這項作業。
在這種情況下,HTTPS 只能保證沒有人可以窺探傳送至推播服務供應商的訊息。收到後,他們可以自由處理,包括將酬載重新傳送給第三方,或惡意將其改為其他內容。為避免這種情況發生,我們會使用加密技術,確保推播服務無法讀取或竄改傳輸中的酬載。
用戶端變更
如果您已實作不含酬載的推播通知,那麼您只需要在用戶端端進行兩項小變更。
首先,當您將訂閱資訊傳送至後端伺服器時,需要收集一些額外資訊。如果您已在 PushSubscription 物件上使用 JSON.stringify()
將其序列化,以便傳送至伺服器,則無須進行任何變更。訂閱項目現在會在 keys 屬性中包含一些額外資料。
> JSON.stringify(subscription)
{"endpoint":"https://android.googleapis.com/gcm/send/f1LsxkKphfQ:APA91bFUx7ja4BK4JVrNgVjpg1cs9lGSGI6IMNL4mQ3Xe6mDGxvt_C_gItKYJI9CAx5i_Ss6cmDxdWZoLyhS2RJhkcv7LeE6hkiOsK6oBzbyifvKCdUYU7ADIRBiYNxIVpLIYeZ8kq_A",
"keys":{"p256dh":"BLc4xRzKlKORKWlbdgFaBrrPK3ydWAHo4M0gs0i1oEKgPpWC5cW8OCzVrOQRv-1npXRWk8udnW3oYhIO4475rds=",
"auth":"5I2Bu2oKdyy9CwL8QVF0NQ=="}}
這兩個值 p256dh
和 auth
是使用 Base64 的變體編碼,我稱之為 網址安全 Base64。
如果您想直接取得位元組,可以在傳回參數為 ArrayBuffer
的訂閱項目上使用新的 getKey()
方法。您需要的兩個參數是 auth
和 p256dh
。
> new Uint8Array(subscription.getKey('auth'));
[228, 141, 129, ...] (16 bytes)
> new Uint8Array(subscription.getKey('p256dh'));
[4, 183, 56, ...] (65 bytes)
第二個變更是 push
事件觸發時,新的 data 屬性。它提供各種同步方法,可剖析收到的資料,例如 .text()
、.json()
、.arrayBuffer()
和 .blob()
。
self.addEventListener('push', function(event) {
if (event.data) {
console.log(event.data.json());
}
});
伺服器端變更
在伺服器端,情況會有所不同。基本程序是使用從用戶端取得的加密金鑰資訊來加密酬載,然後將其做為 POST 要求的內文傳送至訂閱中的端點,並新增一些額外的 HTTP 標頭。
詳細資料相對複雜,而與加密相關的任何事物都應使用積極開發的程式庫,而非自行開發。Chrome 團隊已發布 Node.js 的程式庫,我們也將很快推出更多語言和平台。這項功能會同時處理加密和網路推播通訊協定,因此從 Node.js 伺服器傳送推播訊息的難度,就跟 webpush.sendWebPush(message, subscription)
一樣簡單。
雖然我們強烈建議使用程式庫,但這項功能是新功能,而且許多常見語言都還沒有任何程式庫。如果您需要自行實作這項功能,請參閱以下詳細說明。
我將使用 Node 風格的 JavaScript 說明演算法,但任何語言都應遵循相同的基本原則。
輸入
為了加密訊息,我們首先需要從從用戶端收到的訂閱物件中取得兩項內容。如果您在用戶端上使用 JSON.stringify()
,並將其傳輸至伺服器,則用戶端的公開金鑰會儲存在 keys.p256dh
欄位中,而共用驗證機密金鑰則會儲存在 keys.auth
欄位中。如上所述,這兩者都會採用網址安全 Base64 編碼。用戶端公開金鑰的二進位格式為未壓縮的 P-256 橢圓曲線點。
const clientPublicKey = new Buffer(subscription.keys.p256dh, 'base64');
const clientAuthSecret = new Buffer(subscription.keys.auth, 'base64');
公開金鑰可讓我們加密訊息,以便使用者只能使用私密金鑰解密訊息。
公開金鑰通常被視為公開,因此我們也使用驗證機密,讓用戶端驗證郵件是否由可信任的伺服器傳送。毫無疑問,您應該將此值保密,只與您要傳送訊息的應用程式伺服器共用,並視為密碼。
我們也需要產生一些新資料。我們需要 16 個位元的加密編譯安全隨機 鹽值,以及一組公開/私密橢圓曲線金鑰。推送加密規格所使用的特定曲線稱為 P-256,或 prime256v1
。為確保最佳安全性,每次加密訊息時,都應從頭開始產生金鑰組,且絕對不要重複使用鹽值。
ECDH
讓我們稍微離題,來談談橢圓曲線密碼編譯的一個精巧特性。有一種相對簡單的程序,可將您的私密金鑰與其他人的公開金鑰結合,藉此產生值。那麼,不過,如果對方使用自己的私密金鑰和您的公用金鑰,就會產生完全相同的值!
這就是橢圓曲線 Diffie-Hellman (ECDH) 金鑰協議通訊協定的基本原則,讓雙方即使只交換公開金鑰,也能擁有相同的共用密碼。我們會使用這個共用密鑰做為實際加密金鑰的基礎。
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
是時候換個話題了。假設您有某些機密資料要用於加密金鑰,但這些資料的密碼學安全性不足。您可以使用以 HMAC 為基礎的金鑰推衍函式 (HKDF),將安全性較低的機密金鑰轉換為安全性較高的機密金鑰。
這項功能的運作方式之一,是讓您可以使用任何位元數的密鑰,並產生任何大小的密鑰 (最多 255 次),只要您使用的雜湊演算法產生雜湊即可。對於推送,規格要求我們使用 SHA-256,其雜湊長度為 32 個位元組 (256 位元)。
我們知道,只需要產生大小不超過 32 個位元的金鑰。這表示我們可以使用無法處理較大輸出大小的簡易版演算法。
我已在下方提供 Node 版本的程式碼,但您也可以在 RFC 5869 中瞭解實際運作方式。
HKDF 的輸入內容為鹽、一些初始金鑰內容 (ikm)、可選的結構化資料 (info),以及所需輸出金鑰的位元組長度。
// 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);
}
推導加密參數
我們現在使用 HKDF,將現有資料轉換為實際加密作業的參數。
我們首先會使用 HKDF,將用戶端驗證密鑰和共用密鑰混合成更長、更安全的密鑰。在規格中,這項功能稱為「擬隨機金鑰」(PRK),因此我會在這裡使用這個名稱,不過加密學純粹主義者可能會注意到,這並非嚴格意義上的 PRK。
接下來,我們將建立最終的內容加密金鑰和 nonce,並傳遞給加密編譯器。這些資訊是透過為每個資訊建立簡單的資料結構來建立,在規格中稱為資訊,其中包含橢圓曲線、資訊的傳送者和接收者專屬資訊,以便進一步驗證訊息來源。接著,我們會使用 HKDF 搭配 PRK、鹽和資訊,推導出正確大小的金鑰和 Nonce。
內容加密的資訊類型為「aesgcm」,這是用於推送加密的密碼名稱。
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);
邊框間距
另外,我們來舉一個愚蠢而刻意為之的例子。假設您的主管有一個伺服器,每隔幾分鐘就會傳送公司股價的推播訊息給她。這項資訊的純文字訊息一律會是 32 位元整數,且值以美分為單位。她還與餐飲服務人員達成秘密協議,讓他們在實際送達前 5 分鐘傳送「休息室有甜甜圈」字串給她,這樣她就能「巧遇」在甜甜圈送達時到場,並拿到最棒的甜甜圈。
Web Push 使用的密碼會建立加密值,比未加密的輸入值多出 16 個位元組。由於「休息室有甜甜圈」的長度比 32 位元股票價格長,因此任何偷窺的員工只要看資料長度,就能知道甜甜圈何時送達,而不需要解密訊息。
因此,Web Push 通訊協定可讓您在資料開頭加入填充字元。您可以根據應用程式的需求使用這項功能,但在上述範例中,您可以將所有訊息填充至 32 個位元組,這樣就無法只根據長度來區分訊息。
填充值是 16 位元大端序整數,可指定填充長度,後面接著填充的 NUL
位元組數量。因此,最小填充量為兩個位元組,也就是編碼為 16 位元的零。
const padding = new Buffer(2 + paddingLength);
// The buffer must be only zeroes, except the length
padding.fill(0);
padding.writeUInt16BE(paddingLength, 0);
當推播訊息傳送到用戶端時,瀏覽器會自動移除所有填充內容,因此用戶端程式碼只會收到未填充的訊息。
加密
我們終於準備好進行加密作業所需的所有項目。Web Push 所需的加密是使用 GCM 的 AES128。我們會使用內容加密金鑰做為金鑰,並將 Nonce 做為初始化向量 (IV)。
在本範例中,我們的資料是字串,但也可以是任何二進位資料。您可以傳送大小不超過 4078 個位元組的酬載 (每則貼文最多 4096 個位元組),其中 16 個位元組用於加密資訊,至少 2 個位元組用於填充。
// 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()]);
網路推送
大功告成!有了加密酬載後,您只需要向使用者訂閱指定的端點發出相對簡單的 HTTP POST 要求。
您必須設定三個標頭。
Encryption: salt=<SALT>
Crypto-Key: dh=<PUBLICKEY>
Content-Encoding: aesgcm
<SALT>
和 <PUBLICKEY>
是加密中使用的鹽值和伺服器公開金鑰,已以網址安全 Base64 編碼。
使用 Web Push 通訊協定時,POST 的內容就只是加密訊息的原始位元組。不過,在 Chrome 和 Firebase Cloud Messaging 支援該通訊協定之前,您可以輕鬆將資料納入現有的 JSON 酬載,如下所示。
{
"registration_ids": [ "…" ],
"raw_data": "BIXzEKOFquzVlr/1tS1bhmobZ…"
}
rawData
屬性的值必須是加密訊息的 Base64 編碼表示法。
偵錯 / 驗證工具
實作這項功能的 Chrome 工程師之一 (也是規格書的開發人員之一) Peter Beverloo 已建立驗證器。
讓程式碼輸出每個加密的中間值,您就可以將這些值貼到驗證器中,確認自己是否走在正確的路線上。