我們已瞭解如何使用程式庫觸發推送訊息,但 這些程式庫究竟有什麼作用?
他們正在發出網路要求,同時確保這類要求 合適的廣告格式負責定義這項網路要求的規格 網路推送通訊協定。
本節將概述伺服器如何利用應用程式識別本身 以及加密酬載和相關資料的傳送方式。
這不算是網路推送的一環,而且我不是加密專家,但我們來看看 因為瞭解這些程式庫的實際運作原理就很方便。
應用程式伺服器金鑰
訂閱使用者時,我們會傳入 applicationServerKey
。這把鑰匙
傳遞至推送服務,並用來檢查訂閱的應用程式
使用者也是觸發推送訊息的應用程式。
觸發推送訊息時,我們會傳送一組標頭 允許推送服務驗證應用程式(定義 VAPID 規格)。
這些有何意義?實際情形為何?這些步驟就是 應用程式伺服器驗證:
- 應用程式伺服器會以私人應用程式金鑰簽署部分 JSON 資訊。
- 此簽署資訊會以 POST 要求中的標頭的形式傳送至推送服務。
- 推送服務會使用從該來源接收的已儲存公開金鑰
pushManager.subscribe()
可檢查接收的資訊是否由 與公開金鑰相關的私密金鑰。注意事項:公開金鑰是 傳入訂閱呼叫的applicationServerKey
。 - 如果已簽署的資訊有效,推送服務會傳送 傳送文字訊息給使用者
以下是此資訊流向的範例。(請注意左下角的圖例, 公開和私密金鑰)。
「已簽署的資訊」則是 JSON Web Token
JSON 網路權杖
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;
為了避免 傳送應用程式與推送服務之間的時鐘差異。
最後,sub
值必須是網址或 mailto
電子郵件地址。
因此,如果需要推送服務來聯絡寄件者,該程式可以
聯絡人資訊(因此,網頁推送程式庫需要
電子郵件地址)。
與 JWT 資訊一樣,JWT 資料會編碼為網址安全 base64 字串。
第三個字串,簽名,是擷取前兩個字串的結果 (JWT 資訊及 JWT 資料) 會以半形句號合併兩者, 呼叫、簽署「未簽署的權杖」
簽署程序需要對「未簽署的權杖」進行加密使用 ES256。依據 JWT spec,ES256 是「使用 P-256 曲線的 ECDSA 的簡稱, SHA-256 雜湊演算法」。您可以使用網頁加密編譯建立簽章,如下所示:
// 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]';
Web Push Protocol 也指出公開應用程式伺服器金鑰
透過 Crypto-Key
標頭傳送,做為網址安全 Base64 編碼字串,
前面加上 p256ecdsa=
。
Crypto-Key: p256ecdsa=[URL Safe Base64 Public Application Server Key]
酬載加密
接下來要介紹如何利用推送訊息傳送酬載,以便在網頁應用程式時 收到推送訊息後,便可以存取收到的資料。
曾經使用其他推播服務的使用者,常聽到的問題就是網路推播 酬載是否需要加密?有了原生應用程式,推送訊息就能以純文字傳送資料。
網路推播的一大優點是,所有推送服務都會使用 相同的 API (網路推送通訊協定),開發人員不必擔心 推送服務我們可以用正確的格式提出要求, 推送訊息。缺點是開發人員可以 也就是將訊息傳送至不可信任的推送服務。變更者: 將酬載加密,推送服務無法讀取已傳送的資料。 只有瀏覽器可以解密資訊。這可保護使用者的 資料。
酬載的加密定義請參閱 Message Encryption spec。
我們先來看看加密推送訊息酬載的具體步驟之前, 我們應介紹加密過程中會用到的 上傳資料集之後,您可以運用 AutoML 自動完成部分資料準備工作(他向 Mat Scales 寫了一本極具說服力的實用文章 encryption.)
ECDH 和 HKDF
加密過程中會使用 ECDH 和 HKDF, 提供加密防護
ECDH:橢圓曲線 Diffie-Hellman 金鑰交換
假設有兩位使用者願意分享資訊,她和小柏。 莉莉和 Bob 都有自己的公開和私密金鑰。Alice 和 Bob 並互相共用公開金鑰
ECDH 產生的金鑰很實用,那就是 Alice 可以在 私密金鑰和 Bob 的公開金鑰,藉此建立密鑰值「X」。志明 使用這組金鑰和 Alice 的公開金鑰 獨立建立相同的值「X」。這會讓「X」變為「X」共用密鑰 與 Alice 和 Bob 隻共用公開金鑰。現在 Bob 和 Alice 可以使用「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 橢圓曲線產生 這在節點中會像這樣
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、共用密鑰和編碼資訊 以加密編譯技術。
背景資訊
「背景資訊」是一組位元組,會在加密程序結束後用來計算兩個值 。基本上,這是包含訂閱公開金鑰的位元組陣列,以及 本機公開金鑰
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 和內容加密金鑰。
執行加密
現在有了內容加密金鑰,我們可以加密酬載。
我們使用內容加密金鑰建立 AES128 加密 而 Nonce 是初始化向量。
在節點中,作業方式如下:
const cipher = crypto.createCipheriv(
'id-aes128-GCM',
contentEncryptionKey,
nonce,
);
加密酬載之前,我們需要定義所需的邊框間距 新增至酬載前方需要加上邊框間距的原因 它可以避免有心人士竊取資料 「類型」讀取及寫入訊息
您必須加入 2 個位元組的邊框間距,藉此指出任何其他邊框間距的長度。
舉例來說,如果您未加入任何邊框間距,您就會有兩個值為 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 要求中使用不同的標頭
加密標頭
「加密」標頭必須包含用於加密酬載的 salt。
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 中推送訊息。
在您擁有加密酬載與上述標頭之後,您只需要提出 POST 要求即可
至 PushSubscription
中的 endpoint
。
那麼該如何回應這個 POST 要求?
來自推送服務的回應
向推送服務發出要求後,您必須檢查狀態碼 有助您瞭解請求是否成功 不一定。
狀態碼 | 說明 |
---|---|
201 | 已建立。已收到並接受傳送推送訊息的要求。 |
429 | 傳送的要求過多,這表示應用程式伺服器已達到 以推送服務達到極限的目的推送服務應包含「重試後」 標頭,指出要等待多久才會提出另一項要求。 |
400 | 要求無效,這通常表示其中一個標頭無效 或格式不正確 |
404 | 找不到。這表示訂閱已過期 無法使用這個按鈕在這種情況下,您應刪除 `PushSubscription` 並等待用戶端重新訂閱使用者。 |
410 | 已不存在。訂閱項目已失效,應予以移除 應用程式伺服器只要呼叫 `PushSubscription` 上的「unsubscribe()」。 |
413 | 酬載過大。推送服務必須具有的酬載大小下限 支援 4096 個位元組 (或 4 KB)。 |