我們已經瞭解如何使用程式庫觸發推播訊息,但這些程式庫究竟在做什麼?
他們會發出網路要求,並確保這類要求採用正確的格式。定義此網路要求的規格是 Web Push 通訊協定。
本節將概略說明伺服器如何透過應用程式伺服器金鑰識別自身,以及如何傳送加密酬載和相關資料。
這不算是網路推送的一環,而且我不是加密專家,但我們來看看 因為瞭解這些程式庫的實際運作原理就很方便。
應用程式伺服器金鑰
當我們為使用者訂閱時,會傳入 applicationServerKey
。這把鑰匙
傳遞至推送服務,並用來檢查訂閱的應用程式
使用者也是觸發推送訊息的應用程式。
觸發推送訊息時,我們會傳送一組標頭 允許推送服務驗證應用程式(定義 VAPID 規格)。
這一切究竟代表什麼意思,以及實際上會發生什麼事?以下是應用程式伺服器驗證的步驟:
- 應用程式伺服器會使用私密應用程式金鑰簽署部分 JSON 資訊。
- 系統會將這項已簽署的資訊做為 POST 要求中的標頭,傳送至推播服務。
- 推送服務會使用從該來源接收的已儲存公開金鑰
pushManager.subscribe()
可檢查接收的資訊是否由 與公開金鑰相關的私密金鑰。注意事項:公開金鑰是 傳入訂閱呼叫的applicationServerKey
。 - 如果簽署資訊有效,推送服務就會將推送訊息傳送給使用者。
以下是這項資訊流程的示例。(請注意左下角的圖例, 公開和私密金鑰)。
「已簽署的資訊」則是 JSON Web Token
JSON Web Token
JSON Web Token (簡稱 JWT) 是一種 傳送訊息給第三方,以便接收端驗證 傳送者的身分
第三方收到郵件後,需要將寄件者傳送給寄件者 公開金鑰,並使用金鑰驗證 JWT 的簽名。如果簽名有效,則 JWT 必須使用相符的私密金鑰簽署,因此必須來自預期的寄件者。
https://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 中找到聯絡資訊。(因此,網頁推送程式庫需要
電子郵件地址)。
就像 JWT 資訊一樣,JWT 資料會以網址安全 Base64 字串編碼。
第三個字串 (簽名) 是將前兩個字串 (JWT 資訊和 JWT 資料) 取出,並以點字元連接,我們稱之為「未簽署的權杖」,然後簽署。
簽署程序需要使用 ES256 加密「未簽署的權杖」。根據 JWT 規格,ES256 是「使用 P-256 曲線和 SHA-256 雜湊演算法的 ECDSA」的縮寫。您可以使用網路加密編譯建立簽名,如下所示:
// 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 (網路推送通訊協定),開發人員不必擔心 推送服務我們可以用正確的格式提出要求, 推送訊息。這項做法的缺點是,開發人員可能會將訊息傳送至不可靠的推播服務。變更者: 將酬載加密,推送服務無法讀取已傳送的資料。 只有瀏覽器可以解密資訊。這可保護使用者的資料。
酬載的加密定義請參閱 Message Encryption spec。
在我們查看用於加密推播訊息酬載的具體步驟之前,我們應先介紹在加密程序中會用到的幾種技巧。(他向 Mat Scales 寫了一本極具說服力的實用文章 encryption.)
ECDH 和 HKDF
ECDH 和 HKDF 都會在整個加密程序中使用,並提供加密資訊的優點。
ECDH:橢圓曲線 Diffie-Hellman 金鑰交換
假設有兩位使用者願意分享資訊,她和小柏。 莉莉和 Bob 都有自己的公開和私密金鑰。Alice 和 Bob 並互相共用公開金鑰
使用 ECDH 產生的金鑰有個實用特性,就是 Alice 可以使用自己的私密金鑰和 Bob 的公開金鑰,建立密值「X」。志明 使用這組金鑰和 Alice 的公開金鑰 獨立建立相同的「X」值。這會讓「X」變為「X」共用密鑰 與 Alice 和 Bob 隻共用公開金鑰。現在,志明和小莉可以使用「X」來加密及解密彼此之間的訊息。
就我所知所學,ECDH 定義了讓這個「特徵」的曲線特性 就需要建立共用密鑰「X」
這是 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);
}
Mat Scale 的這篇文章以此範例程式碼的帽子提示。
ECDH 以安全的方式共用公開金鑰及產生共用密鑰。簡稱 HKDF 並確保其安全。
會在加密酬載時使用。接下來,我們來看看我們會將哪些內容視為輸入內容,以及如何加密這些內容。
輸入
如果我們想要傳送推送訊息給擁有酬載的使用者,需要輸入三個輸入內容:
- 酬載本身。
- 來自
PushSubscription
的auth
密鑰。 PushSubscription
中的p256dh
鍵。
我們看到 auth
和 p256dh
值是從 PushSubscription
擷取,但如果是
快速提醒,請針對訂閱項目提供以下值:
subscription.toJSON().keys.auth;
subscription.toJSON().keys.p256dh;
subscription.getKey('auth');
subscription.getKey('p256dh');
auth
值應視為機密資訊,請勿在應用程式外部分享。
p256dh
金鑰是公開金鑰,有時也稱為用戶端公開金鑰。這裡
我們將 p256dh
稱為訂閱項目公開金鑰。訂閱公開金鑰是由瀏覽器產生。瀏覽器會將私密金鑰保密,並用於解密酬載。
這三個值需要 auth
、p256dh
和 payload
做為輸入內容,
加密程序將是加密酬載、鹽值以及僅用於處理資料的公開金鑰
將資料加密
鹽
鹽長度必須是 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 的位元組,後面接著
加密資料。
我們的虛擬隨機金鑰只是執行 Auth、共用密鑰和編碼資訊 以加密編譯技術。
背景資訊
「context」是一組位元組,可用於稍後在加密瀏覽器中計算兩個值。它基本上是包含訂閱公開金鑰和本機公開金鑰的位元組陣列。
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 與 NoceInfo 和 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 密碼。
在節點中,作業方式如下:
const cipher = crypto.createCipheriv(
'id-aes128-GCM',
contentEncryptionKey,
nonce,
);
在加密酬載之前,我們需要定義要將多少填充字元加到酬載前端。我們之所以要加入填充字元,是為了避免竊聽者根據酬載大小判斷訊息的「類型」。
您必須加入兩個位元組的邊框間距,以表示任何額外邊框間距的長度。
舉例來說,如果您未新增填充字元,則會有兩個值為 0 的位元組,也就是沒有填充字元,在這些位元組之後,您會讀取酬載。如果您新增了 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: dh=[URL Safe Base64 Encoded Local Public Key String]; p256ecdsa=[URL Safe Base64 Encoded Public Application Server Key]
內容類型、長度與編碼標頭
Content-Length
標頭是加密中的位元組數
酬載。內容類型和「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: [Time to live in seconds]
如果將 TTL
設為 0,推送服務會嘗試
訊息,但如果無法連線至裝置,則訊息會立刻顯示
就會立即從推送服務佇列中捨棄
從技術層面來說,推送服務可以減少推送訊息的 TTL
(如有)
需求。只要查看 TTL
標頭
來自推送服務的回應
主題
選用
「主題」是字串,可用來將待處理訊息替換為 則新的訊息。
在一趟 裝置離線,而且你真的只想讓使用者看到 訊息。
急迫性
選用
緊急程度會向推播服務指出訊息對使用者的重要性。這個 可由推送服務使用,以節省使用者裝置的電池壽命, 在電量不足時喚醒重要訊息。
標頭值的定義如下。預設值為 normal
。
Urgency: [very-low | low | normal | high]
全部整合
如果對這一切的運作方式有其他疑問,歡迎隨時查看程式庫的觸發方式 在 web-push-libs org 中推送訊息。
取得加密酬載和上述標頭後,您只需要對 PushSubscription
中的 endpoint
提出 POST 要求。
那麼該如何回應這個 POST 要求?
推播服務的回應
向推送服務發出要求後,您必須檢查狀態碼 有助您瞭解請求是否成功 不一定。
狀態碼 | 說明 |
---|---|
201 | 已建立。已收到並接受傳送推送訊息的要求。 |
429 | 傳送的要求過多,這表示應用程式伺服器已達到 以推送服務達到極限的目的推播服務應包含「Retry-After」標頭,指出何時可以提出另一項要求。 |
400 | 要求無效,這通常表示其中一個標頭無效 或格式不正確 |
404 | 找不到。這表示訂閱已過期 無法使用這個按鈕在這種情況下,您應刪除 `PushSubscription`,並等待用戶端重新訂閱。 |
410 | 已不存在。訂閱項目已失效,應從應用程式伺服器中移除。您只要呼叫 `PushSubscription` 上的「unsubscribe()」。 |
413 | 酬載過大。推播服務必須支援的酬載大小下限為 4096 位元組 (或 4kb)。 |
如要進一步瞭解 HTTP 狀態碼,您也可以參閱 Web Push Standard (RFC8030)。