我們已瞭解如何使用程式庫觸發推播訊息,但這些程式庫究竟在做什麼?
他們會提出網路要求,同時確保這些要求格式正確。定義這項網路要求的規格是網路推播通訊協定。
本節概述伺服器如何使用應用程式伺服器金鑰識別自身,以及如何傳送加密酬載和相關資料。
這不是網頁推送的優點,我也不是加密專家,但我們還是來看看每個部分,因為瞭解這些程式庫在幕後執行的作業很有幫助。
應用程式伺服器金鑰
訂閱使用者時,我們會傳入 applicationServerKey。這個金鑰會傳遞至推送服務,用於確認訂閱使用者的應用程式,也是觸發推送訊息的應用程式。
觸發推送訊息時,我們會傳送一組標頭,讓推送服務驗證應用程式。(由 VAPID 規格定義)。
這代表什麼意思?實際情況又是如何?應用程式伺服器驗證的步驟如下:
- 應用程式伺服器會使用私密應用程式金鑰簽署部分 JSON 資訊。
- 這項已簽署的資訊會以 POST 要求標頭的形式傳送至推播服務。
- 推送服務會使用從
pushManager.subscribe()接收並儲存的公開金鑰,檢查收到的資訊是否由與公開金鑰相關的私密金鑰簽署。注意:公開金鑰是傳入訂閱呼叫的applicationServerKey。 - 如果簽署資訊有效,推送服務就會將推送訊息傳送給使用者。
以下是這類資訊流程的範例。(請注意左下角的圖例,其中指出公開和私密金鑰)。
新增至要求標頭的「已簽署資訊」是 JSON Web Token。
JSON Web Token
JSON Web Token (簡稱 JWT) 是一種將訊息傳送給第三方的方式,可讓接收者驗證傳送者身分。
第三方收到訊息後,必須取得傳送者的公開金鑰,並使用該金鑰驗證 JWT 的簽章。如果簽章有效,則 JWT 必須使用相符的私密金鑰簽署,因此必須來自預期的寄件者。
jwt.io/ 上有許多程式庫可以為您執行簽署作業,建議您盡可能使用。為求完整,我們來看看如何手動建立已簽署的 JWT。
網頁推播和已簽署的 JWT
已簽署的 JWT 只是字串,但可以視為三個以點號連接的字串。
第一和第二個字串 (JWT 資訊和 JWT 資料) 是經過 Base64 編碼的 JSON 片段,也就是說,這些片段可公開讀取。
第一個字串是 JWT 本身的資訊,指出用於建立簽章的演算法。
網頁推播的 JWT 資訊必須包含下列資訊:
{
"typ": "JWT",
"alg": "ES256"
}
第二個字串是 JWT 資料。這會提供 JWT 寄件者、預定收件者和有效期限的相關資訊。
如果是網頁推播,資料格式如下:
{
"aud": "https://some-push-service.org",
"exp": "1469618703",
"sub": "mailto:example@web-push-book.org"
}
aud 值是「目標對象」,也就是 JWT 的對象。如果是網頁推播,目標對象就是推播服務,因此我們會將其設為推播服務的來源。
exp 值是 JWT 的到期時間,可防止窺探者攔截 JWT 後重複使用。到期時間是以秒為單位的時間戳記,不得超過 24 小時。
在 Node.js 中,到期時間是使用以下方式設定:
Math.floor(Date.now() / 1000) + 12 * 60 * 60;
之所以設定 12 小時而非 24 小時,是為了避免傳送應用程式和推播服務之間的時間差造成問題。
最後,sub 值必須是網址或 mailto 電子郵件地址。這樣一來,如果推送服務需要與傳送者聯絡,就能從 JWT 找到聯絡資訊。(這就是為什麼 web-push 程式庫需要電子郵件地址)。
與 JWT 資訊一樣,JWT 資料會編碼為網址安全的 base64 字串。
第三個字串是簽章,是將前兩個字串 (JWT 資訊和 JWT 資料) 以點字元連結 (稱為「未簽署權杖」),然後簽署而得。
簽署程序需要使用 ES256 加密「未簽署的權杖」。根據 JWT 規格,ES256 是「使用 P-256 曲線和 SHA-256 雜湊演算法的 ECDSA」的簡稱。您可以使用 Web Crypto 建立簽章,如下所示:
// Utility function for UTF-8 encoding a string to an ArrayBuffer.
const utf8Encoder = new TextEncoder('utf-8');
// The unsigned token is the concatenation of the URL-safe base64 encoded
// header and body.
const unsignedToken = .....;
// Sign the |unsignedToken| using ES256 (SHA-256 over ECDSA).
const key = {
kty: 'EC',
crv: 'P-256',
x: window.uint8ArrayToBase64Url(
applicationServerKeys.publicKey.subarray(1, 33)),
y: window.uint8ArrayToBase64Url(
applicationServerKeys.publicKey.subarray(33, 65)),
d: window.uint8ArrayToBase64Url(applicationServerKeys.privateKey),
};
// Sign the |unsignedToken| with the server's private key to generate
// the signature.
return crypto.subtle.importKey('jwk', key, {
name: 'ECDSA', namedCurve: 'P-256',
}, true, ['sign'])
.then((key) => {
return crypto.subtle.sign({
name: 'ECDSA',
hash: {
name: 'SHA-256',
},
}, key, utf8Encoder.encode(unsignedToken));
})
.then((signature) => {
console.log('Signature: ', signature);
});
推送服務可以使用公開應用程式伺服器金鑰驗證 JWT,解密簽章並確保解密後的字串與「未簽署的權杖」(即 JWT 中的前兩個字串) 相同。
已簽署的 JWT (即以點號連接的三個字串) 會傳送至網路推播服務,做為 Authorization 標頭,並在前面加上 WebPush,如下所示:
Authorization: 'WebPush [JWT Info].[JWT Data].[Signature]';
網路推播通訊協定也規定,公開應用程式伺服器金鑰必須在 Crypto-Key 標頭中傳送,並以網址安全的 base64 編碼字串形式呈現,且前面加上 p256ecdsa=。
Crypto-Key: p256ecdsa=[URL Safe Base64 Public Application Server Key]
酬載加密
接下來,我們來看看如何透過推播訊息傳送酬載,讓網頁應用程式在收到推播訊息時,可以存取收到的資料。
使用過其他推播服務的人常會問:為什麼網頁推播酬載需要加密?如果是原生應用程式,推播訊息可以傳送純文字資料。
網路推播的美妙之處在於,所有推播服務都使用相同的 API (網路推播通訊協定),因此開發人員不必在意推播服務是誰。我們可以以正確的格式提出要求,並預期傳送推播訊息。缺點是開發人員可能會將訊息傳送至不值得信任的推播服務。加密酬載後,推送服務就無法讀取傳送的資料。只有瀏覽器可以解密資訊。這項功能可保護使用者資料。
酬載的加密方式定義於訊息加密規格。
在探討加密推送訊息酬載的具體步驟之前,我們應先介紹加密過程中會用到的幾項技術。(特別感謝 Mat Scales 撰寫的推送加密文章,內容非常精彩。)
ECDH 和 HKDF
ECDH 和 HKDF 都會用於整個加密程序,並提供加密資訊的好處。
ECDH:橢圓曲線 Diffie-Hellman 金鑰交換
假設有兩個人想分享資訊,分別是小莉和志明。Alice 和 Bob 都有自己的公開金鑰和私密金鑰。Alice 和 Bob 彼此分享公開金鑰。
使用 ECDH 產生金鑰的好處是,Alice 可以使用自己的私密金鑰和 Bob 的公開金鑰建立密鑰值「X」。Bob 也可以執行相同操作,使用自己的私密金鑰和 Alice 的公開金鑰,獨立建立相同的值「X」。這樣一來,「X」就成了共用密鑰,而 Alice 和 Bob 只需要分享公開金鑰。現在,Bob 和 Alice 可以使用「X」加密及解密彼此之間的訊息。
據我所知,ECDH 定義了曲線的屬性,允許建立共用密鑰「X」的「功能」。
以上是 ECDH 的概略說明。如要進一步瞭解,建議觀看更詳細的 ECDH 總覽影片。
在程式碼方面,大多數語言/平台都提供程式庫,可輕鬆產生這些金鑰。
在節點中,我們會執行下列操作:
const keyCurve = crypto.createECDH('prime256v1');
keyCurve.generateKeys();
const publicKey = keyCurve.getPublicKey();
const privateKey = keyCurve.getPrivateKey();
HKDF:以 HMAC 為基礎的金鑰衍生函式
維基百科對 HKDF 有簡潔的說明:
HKDF 是以 HMAC 為基礎的金鑰衍生函式,可將任何弱式金鑰內容轉換為加密強式金鑰內容。例如,這項功能可用於將 Diffie Hellman 交換的共用密鑰,轉換為適合用於加密、完整性檢查或驗證的金鑰內容。
基本上,HKDF 會將不特別安全的輸入內容變得更安全。
定義這項加密的規格要求使用 SHA-256 做為雜湊演算法,且網頁推播中 HKDF 的結果金鑰長度不得超過 256 位元 (32 位元組)。
在節點中,這可以像這樣實作:
// Simplified HKDF, returning keys up to 32 bytes long
function hkdf(salt, ikm, info, length) {
// 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);
}
ECDH 是一種安全的方法,可用於分享公開金鑰及產生共用密鑰。HKDF 是一種將不安全資料轉為安全資料的方法。
這項資訊會用於加密酬載。接下來,我們來看看輸入內容和加密方式。
輸入內容
如要傳送含有酬載的推播訊息給使用者,我們需要提供三個輸入內容:
- 酬載本身。
PushSubscription中的auth密鑰。PushSubscription的p256dh鍵。
我們已瞭解如何從 PushSubscription 擷取 auth 和 p256dh 值,但在此快速提醒您,訂閱項目需要下列值:
subscription.toJSON().keys.auth;
subscription.toJSON().keys.p256dh;
subscription.getKey('auth');
subscription.getKey('p256dh');
auth 值應視為密鑰,不得在應用程式外共用。
p256dh 金鑰是公開金鑰,有時稱為用戶端公開金鑰。在本文中,我們將 p256dh 稱為訂閱公開金鑰。訂閱公開金鑰是由瀏覽器產生。瀏覽器會將私密金鑰保密,並用來解密酬載。
這三個值 (auth、p256dh 和 payload) 必須做為輸入內容,加密程序的結果會是加密的酬載、鹽值和僅用於加密資料的公開金鑰。
Salt
鹽字串必須是 16 個位元組的隨機資料。在 NodeJS 中,我們會執行下列操作來建立鹽:
const salt = crypto.randomBytes(16);
公開/私密金鑰
公開和私密金鑰應使用 P-256 橢圓曲線產生,在 Node 中可以這樣做:
const localKeysCurve = crypto.createECDH('prime256v1');
localKeysCurve.generateKeys();
const localPublicKey = localKeysCurve.getPublicKey();
const localPrivateKey = localKeysCurve.getPrivateKey();
我們將這些金鑰稱為「本機金鑰」。這些金鑰僅用於加密,與應用程式伺服器金鑰無關。
以酬載、驗證密鑰和訂閱公開金鑰做為輸入內容,並使用新產生的鹽和一組本機金鑰,我們就可以實際進行加密。
共用密鑰
第一步是使用訂閱公開金鑰和我們的新私密金鑰建立共用密鑰 (還記得 Alice 和 Bob 的 ECDH 說明嗎?就是這麼簡單)。
const sharedSecret = localKeysCurve.computeSecret(
subscription.keys.p256dh,
'base64',
);
下一步會用到這個值,用來計算偽隨機金鑰 (PRK)。
偽隨機金鑰
偽隨機金鑰 (PRK) 是推送訂閱項目的驗證密鑰,以及我們剛才建立的共用密鑰的組合。
const authEncBuff = new Buffer('Content-Encoding: auth\0', 'utf8');
const prk = hkdf(subscription.keys.auth, sharedSecret, authEncBuff, 32);
您可能會想知道 Content-Encoding: auth\0 字串的用途。簡而言之,雖然瀏覽器可以解密收到的訊息,並尋找預期的內容編碼,但這項功能沒有明確的目的。\0 會在緩衝區結尾加上值為 0 的位元組。這是解密訊息的瀏覽器預期行為,瀏覽器會預期內容編碼有這麼多位元組,後面接著值為 0 的位元組,再接著加密資料。
偽隨機金鑰只是透過 HKDF 執行驗證、共用密鑰和編碼資訊 (也就是提高加密強度)。
背景資訊
「內容」是一組位元組,用於在加密瀏覽器中計算兩個值。這基本上是包含訂閱公開金鑰和本機公開金鑰的位元組陣列。
const keyLabel = new Buffer('P-256\0', 'utf8');
// Convert subscription public key into a buffer.
const subscriptionPubKey = new Buffer(subscription.keys.p256dh, 'base64');
const subscriptionPubKeyLength = new Uint8Array(2);
subscriptionPubKeyLength[0] = 0;
subscriptionPubKeyLength[1] = subscriptionPubKey.length;
const localPublicKeyLength = new Uint8Array(2);
subscriptionPubKeyLength[0] = 0;
subscriptionPubKeyLength[1] = localPublicKey.length;
const contextBuffer = Buffer.concat([
keyLabel,
subscriptionPubKeyLength.buffer,
subscriptionPubKey,
localPublicKeyLength.buffer,
localPublicKey,
]);
最終內容緩衝區是標籤、訂閱公開金鑰的位元組數,後面接著金鑰本身,然後是本機公開金鑰的位元組數,後面接著金鑰本身。
有了這個情境值,我們就能用來建立 Nonce 和內容加密金鑰 (CEK)。
內容加密金鑰和 Nonce
Nonce 是用來防止重送攻擊的值,只能使用一次。
內容加密金鑰 (CEK) 是最終用來加密酬載的金鑰。
首先,我們需要為 Nonce 和 CEK 建立資料位元組,這只是內容編碼字串,後面接著我們剛才計算的內容緩衝區:
const nonceEncBuffer = new Buffer('Content-Encoding: nonce\0', 'utf8');
const nonceInfo = Buffer.concat([nonceEncBuffer, contextBuffer]);
const cekEncBuffer = new Buffer('Content-Encoding: aesgcm\0');
const cekInfo = Buffer.concat([cekEncBuffer, contextBuffer]);
這項資訊會透過 HKDF 執行,並將鹽和 PRK 與 nonceInfo 和 cekInfo 結合:
// The nonce should be 12 bytes long
const nonce = hkdf(salt, prk, nonceInfo, 12);
// The CEK should be 16 bytes long
const contentEncryptionKey = hkdf(salt, prk, cekInfo, 16);
這樣就能取得 Nonce 和內容加密金鑰。
執行加密
取得內容加密金鑰後,即可加密酬載。
我們使用內容加密金鑰做為金鑰,並使用 Nonce 做為初始化向量,建立 AES128 加密。
在 Node 中,這項操作的執行方式如下:
const cipher = crypto.createCipheriv(
'id-aes128-GCM',
contentEncryptionKey,
nonce,
);
在加密酬載之前,我們需要定義要在酬載前端加入多少填補。之所以要新增填補,是為了防止竊聽者根據酬載大小判斷訊息「類型」。
您必須新增兩個位元組的邊框間距,指出任何額外邊框間距的長度。
舉例來說,如果未新增任何邊框間距,則會有兩個值為 0 的位元組,也就是沒有邊框間距,在這兩個位元組之後,您將讀取酬載。如果您新增了 5 個位元組的填補,前兩個位元組的值會是 5,因此消費者會再讀取 5 個位元組,然後開始讀取酬載。
const padding = new Buffer(2 + paddingLength);
// The buffer must be only zeros, except the length
padding.fill(0);
padding.writeUInt16BE(paddingLength, 0);
接著,我們透過這個加密編碼執行填補和酬載。
const result = cipher.update(Buffer.concat(padding, payload));
cipher.final();
// Append the auth tag to the result -
// https://nodejs.org/api/crypto.html#crypto_cipher_getauthtag
const encryptedPayload = Buffer.concat([result, cipher.getAuthTag()]);
現在我們有了加密酬載。太好了!
接下來,只要決定如何將這個酬載傳送至推播服務即可。
加密酬載標頭和內文
如要將這個加密酬載傳送至推播服務,我們需要在 POST 要求中定義幾個不同的標頭。
加密標頭
「Encryption」標頭必須包含用於加密酬載的鹽。
16 位元組的鹽應採用 Base64 網址安全編碼,並新增至加密標頭,如下所示:
Encryption: salt=[URL Safe Base64 Encoded Salt]
Crypto-Key 標頭
我們發現「應用程式伺服器金鑰」部分下方使用 Crypto-Key 標頭,其中包含公開應用程式伺服器金鑰。
這個標頭也用於共用本機公開金鑰,以便加密酬載。
產生的標頭如下所示:
Crypto-Key: dh=[URL Safe Base64 Encoded Local Public Key String]; p256ecdsa=[URL Safe Base64 Encoded Public Application Server Key]
內容類型、長度和編碼標頭
Content-Length 標頭是加密酬載中的位元組數。「Content-Type」和「Content-Encoding」標頭為固定值。如下所示。
Content-Length: [Number of Bytes in Encrypted Payload]
Content-Type: 'application/octet-stream'
Content-Encoding: 'aesgcm'
設定這些標頭後,我們需要將加密酬載做為要求的主體傳送。請注意,Content-Type 已設為 application/octet-stream。這是因為加密的酬載必須以位元組串流的形式傳送。
在 NodeJS 中,我們會這樣做:
const pushRequest = https.request(httpsOptions, function(pushResponse) {
pushRequest.write(encryptedPayload);
pushRequest.end();
需要更多標頭嗎?
我們已介紹 JWT/應用程式伺服器金鑰使用的標頭 (即如何透過推送服務識別應用程式),以及用於傳送加密酬載的標頭。
推播服務會使用其他標頭來變更傳送訊息的行為。其中有些標頭為必要,有些則為選用。
TTL 標頭
必要
TTL (或存留時間) 是整數,指定推送訊息在推送服務中存留的秒數,超過這個時間就會傳送。TTL 到期後,訊息會從推送服務佇列中移除,且不會傳送。
TTL: [Time to live in seconds]
如果將 TTL 設為零,推播服務會嘗試立即傳送訊息,但如果無法連上裝置,訊息會立即從推播服務佇列中移除。
嚴格來說,推播服務可以視需要縮短推播訊息的 TTL。您可以檢查推送服務回應中的 TTL 標頭,判斷是否發生這種情況。
主題
選用
主題是字串,如果主題名稱相符,可用來將待處理訊息替換為新訊息。
如果裝置離線時傳送了多則訊息,而您只希望使用者在裝置開機時看到最新訊息,這個方法就非常實用。
急迫性
選用
緊急程度會向推播服務指出訊息對使用者的重要性。推播服務可利用這項功能,在電池電量不足時,只在有重要訊息時喚醒裝置,協助節省使用者裝置的電池電力。
標頭值的定義如下所示。預設值為 normal。
Urgency: [very-low | low | normal | high]
整合所有功能
如果對運作方式還有其他疑問,可以隨時前往 web-push-libs 組織,瞭解程式庫如何觸發推播訊息。
取得加密酬載和上述標頭後,只要向 PushSubscription 中的 endpoint 發出 POST 要求
即可。
那麼,我們該如何處理這項 POST 要求的應答?
推送服務的回應
向推送服務提出要求後,您需要檢查回應的狀態碼,瞭解要求是否成功。
| 狀態碼 | 說明 |
|---|---|
| 201 | 已建立。已收到並接受傳送推播訊息的要求。 |
| 429 | 要求次數過多,這表示應用程式伺服器已達到推播服務的頻率限制。推送服務應包含「Retry-After」標頭,指出要過多久才能提出其他要求。 |
| 400 | 要求無效,這通常表示其中一個標頭無效或格式不正確。 |
| 404 | 找不到。這表示訂閱項目已過期,無法使用。在這種情況下,您應刪除 `PushSubscription`,並等待用戶端重新訂閱使用者。 |
| 410 | 消失。訂閱項目已失效,應從應用程式伺服器中移除。只要在 `PushSubscription` 上呼叫 `unsubscribe()`,即可重現這個問題。 |
| 413 | 酬載過大。推送服務必須支援的酬載大小下限為 4096 位元組 (或 4 KB)。 |
您也可以參閱網路推播標準 (RFC8030),進一步瞭解 HTTP 狀態碼。