在 Chrome 50 版之前,推送訊息不得包含任何酬載資料。在 Service Worker 中觸發 'push' 事件時,您知道伺服器其實是試著傳達一些訊息,但這不是什麼。然後,您必須向伺服器提出後續要求,並取得顯示通知的詳細資料 (這樣可能會在網路狀況不佳時失敗)。
現在,在 Chrome 50 版 (以及最新版電腦版 Firefox) 中,您可以將部分任意資料與推送一併傳送,讓用戶端不必提出額外的要求。不過,由於效能強大,因此必須加密 所有酬載資料都必須加密
酬載加密是網路推送作業中相當重要的一環。HTTPS 能在瀏覽器和您自己的伺服器之間進行通訊,因為您信任該伺服器。不過,瀏覽器會選擇要使用哪個推送供應商來實際提供酬載,因此應用程式開發人員無法控管該設定。
在這裡,HTTPS 只能保證沒有人可以在傳輸至推送服務供應商的訊息中遭到窺探。收到訊息後,他們即可自由執行自己喜愛的操作,包括將酬載重新傳輸至第三方,或以惡意方式將其改造成其他內容。我們使用加密技術,確保推送服務無法讀取或竄改傳輸中的酬載。
用戶端變更
如果您已實作不含酬載的推播通知,您只需要在用戶端進行兩項小幅變更。
首先,您將訂閱資訊傳送至後端伺服器時,必須收集一些額外資訊。如果您在 PushSubscription 物件中使用 JSON.stringify()
進行序列化,即可傳送至伺服器,則無須進行任何變更。現在訂閱項目的鍵屬性中會有一些額外資料。
> 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)
一樣簡單。
雖然我們強烈建議使用程式庫,但這項新功能,還有許多熱門語言尚無任何程式庫。如果您需要自行實作,請參考以下詳細資料。
我會說明使用節點變種 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 位元組經過加密編譯的隨機 salt 和橢圓曲線金鑰的公開/私密金鑰。推送加密規格使用的特定曲線為 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);
港幣
目前已有其他時間了。假設您有一些密鑰資料要做為加密金鑰使用,但資料安全強度不足。您可以使用 HMAC 的金鑰衍生函式 (HKDF),將低安全性的密鑰轉換成高安全性的密鑰。
其中一種運作原理,就是您可以擷取任意數量的位元,並以雜湊產生的雜湊值,產生一個任意大小的密鑰最多 255 倍。針對推送作業,規格要求我們使用雜湊長度為 32 個位元組 (256 位元) 的 SHA-256。
發生這種情況時,我們知道我們只需產生最大 32 個位元組的金鑰。這表示我們能使用無法處理較大輸出大小的簡化版演算法。
以下附上 Node 版本的程式碼,不過您也可以在 RFC 5869 中查看實際運作情況。
HKDF 的輸入內容是鹽、部分初始索引鍵材料 (ikm)、目前用途 (資訊) 專屬的結構化資料片段,以及所需輸出鍵的長度 (以位元組為單位)。
// 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 分鐘前,將字串「休息室的甜甜圈」寄給她,這樣她就能「誠信」找到最合適的送禮人員。
網路推送功能使用的加密值,會是比未加密輸入內容多 16 個位元組的加密值。由於「休息室中的甜甜圈」較長是 32 位元的股價,因此任何窺探的員工只要從資料的長度開始,就能判斷草坪何時抵達,而無需解密訊息。
因此,使用網路推送通訊協定可讓您在資料開頭加上邊框間距。使用方法取決於應用程式,但是在上述範例中,您可以將所有訊息填充剛好為 32 個位元組,導致無法只根據長度區分訊息。
邊框間距值是 16 位元大端序的整數,用於指定邊框間距長度,後面加上該邊框間距的 NUL
個位元組數量。因此邊框間距下限為 2 個位元組,是將數字 0 編碼為 16 位元的號碼。
const padding = new Buffer(2 + paddingLength);
// The buffer must be only zeroes, except the length
padding.fill(0);
padding.writeUInt16BE(paddingLength, 0);
當推送訊息傳送至用戶端時,瀏覽器可以自動消除任何邊框間距,因此用戶端程式碼只會接收未填充的訊息。
加密
我們終於完成了加密作業的所有準備工作。網路推送所需的加密是採用 GCM 的 AES128。我們的內容加密金鑰是金鑰,而 Nonce 則是初始化向量 (IV)。
在這個範例中,資料是字串,但可以是任何二進位資料。您最多只能傳送 4,078 個位元組到 4,096 個位元組的酬載;每則貼文最多可傳送 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 編碼。
使用網路推送通訊協定時,POST 的主體就是加密訊息的原始位元組。不過,在 Chrome 和 Firebase 雲端通訊支援通訊協定之前,您可以按照下列步驟,輕鬆在現有的 JSON 酬載中加入資料。
{
"registration_ids": [ "…" ],
"raw_data": "BIXzEKOFquzVlr/1tS1bhmobZ…"
}
rawData
屬性的值必須是以 Base64 編碼表示加密訊息。
偵錯 / 驗證器
Peter Beverloo 是 Chrome 工程師之一,他們同時又建立了驗證器,該工程師也是參與該規格的員工之一。
透過讓程式碼輸出加密的每個中間值,您可以將這些內容貼到驗證器中,確認目前狀態正確無誤。