在 Chrome 50 之前,推播訊息不得包含任何酬載資料。在 Service Worker 中觸發 '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 的變化版本,我稱為「URL-Safe Base64」
如果您想直接取得位元組,可以使用訂閱項目的新 getKey()
方法,該方法會將參數傳回為 ArrayBuffer
。您需要的兩個參數為 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 位元的股價比 32 位元的久值,任何窺探者都能在不解密訊息的情況下,判斷甜甜圈何時抵達,而不需解密訊息。
因此,Web Push 通訊協定可讓您在資料開頭加入填充字元。您可以根據應用程式的需求使用這項功能,但在上述範例中,您可以將所有訊息填充至 32 個位元組,這樣就無法只根據長度來區分訊息。
填充值是 16 位元大端序整數,可指定填充長度,後面接著填充的 NUL
位元組數量。因此,邊框間距的下限是 2 個位元組
也就是為 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 雲端通訊支援通訊協定之前,您可以在現有的 JSON 酬載中輕鬆加入資料,方法如下:
{
"registration_ids": [ "…" ],
"raw_data": "BIXzEKOFquzVlr/1tS1bhmobZ…"
}
rawData
屬性的值必須是加密訊息的 Base64 編碼表示法。
偵錯 / 驗證工具
實作這項功能的 Chrome 工程師之一 (也是規格書的開發人員之一) Peter Beverloo 已建立驗證器。
取得程式碼來輸出加密的每個中繼值後,您就可以將這些值貼到驗證器中,檢查使用的通訊協定是否正確。