網路推送通訊協定

Matt Gaunt

我們已介紹程式庫如何使用程式庫觸發推送訊息,但這些程式庫實際上的用途是什麼?

他們正在發出網路要求,同時確保這類要求的格式正確無誤。定義這個網路要求的規格是網路推送通訊協定

從伺服器將推送訊息傳送至推送服務的圖表

本節概述伺服器如何利用應用程式伺服器金鑰識別自己,以及加密酬載和相關資料傳送方式。

這不是一門網路推播,我對加密技術不太擅長,但只要逐項說明,即可瞭解這些程式庫背後的運作原理。

應用程式伺服器金鑰

訂閱使用者時,我們會傳入 applicationServerKey。這個金鑰會傳送至推送服務,並用來檢查已訂閱使用者的應用程式是否也觸發推送訊息的應用程式。

當我們觸發推送訊息時,系統會傳送一組標頭,讓推送服務驗證應用程式。(這個問題由 VAPID 規格定義)。

這代表什麼意思?具體情況為何?以下是應用程式伺服器驗證所需採取的步驟:

  1. 應用程式伺服器會使用本身的私密應用程式金鑰簽署某些 JSON 資訊。
  2. 此簽署資訊會以 POST 要求中的標頭的形式傳送至推送服務。
  3. 推送服務會使用從 pushManager.subscribe() 接收的已儲存公開金鑰,檢查收到的資訊是否由與公開金鑰相關的私密金鑰簽署。注意:公開金鑰是傳遞至訂閱呼叫的 applicationServerKey
  4. 如果已簽署的資訊有效,推送服務就會將推送訊息傳送給使用者。

以下是這種資訊流程的範例。(請注意左下方的圖例顯示的是公開金鑰和私密金鑰)。

插圖:傳送訊息時,使用私人應用程式伺服器金鑰的方式

要求中標頭的「簽署資訊」為 JSON Web Token。

JSON Web Token

JSON Web Token (簡稱 JWT) 是一種向第三方傳送訊息的方式,可讓接收器驗證傳送者。

第三方收到訊息時,必須取得傳送者的公開金鑰,並使用該金鑰驗證 JWT 的簽名。如果簽章有效,則 JWT 必須使用相符的私密金鑰簽署,所以必須來自預期的寄件者。

https://jwt.io/ 上有許多程式庫可為您執行簽署,建議您盡可能這麼做。為了完整起見,以下說明如何手動建立已簽署的 JWT。

網路推送與簽署的 JWT

已簽署的 JWT 只是一個字串,但可視為由半形句號連接的三個字串。

插圖:JSON Web Token 中的字串

第一和第二字串 (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,就無法重複使用該 JWT。到期時間是以秒為單位的時間戳記,且不得超過 24 小時。

在 Node.js 中,到期時間的設定方式為:

Math.floor(Date.now() / 1000) + 12 * 60 * 60;

為避免傳送應用程式與推送服務之間的時鐘差異,請花 12 小時而非 24 小時。

最後,sub 值必須是網址或 mailto 電子郵件地址。這樣當推送服務與傳送者聯絡時,就可以從 JWT 找到聯絡資訊。(這就是網頁推送程式庫需要電子郵件地址的原因)。

如同 JWT 資訊,JWT 資料也會編碼為網址安全 Base64 字串。

第三個字串 (即 JWT 資料和 JWT 資料) 會使用前兩個字串 (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 (網路推送通訊協定),開發人員就不需要注意推送服務的身分。我們可以以正確的格式發出要求,並預期系統會傳送推送訊息。這個做法的缺點是,開發人員能夠明確地將訊息傳送至不可靠的推送服務。透過加密酬載,推送服務無法讀取傳送的資料。只有瀏覽器才能解密資訊。這可以保護使用者資料。

酬載的加密方式會在訊息加密規格中定義。

在瞭解加密推送訊息酬載的特定步驟之前,我們應該說明加密程序期間會使用的一些技術。(給 Mat Scales 的大帽子,是他有關推送加密作業的優質文章)。

ECDH 和 HKDF

系統會在整個加密程序中使用 ECDH 和 HKDF,並且為資訊加密帶來好處。

ECDH:橢圓曲線 Diffie-Hellman 金鑰交換

假設您有兩位想分享資訊的人,分別是小莉和志明。 小莉和 Bob 都擁有自己的公開金鑰和私密金鑰。Alice 和 Bob 會共用彼此的公開金鑰

使用 ECDH 產生金鑰的實用屬性,就是可以使用她的私密金鑰和 Bob 的公開金鑰來建立密鑰值「X」。Bob 也能執行相同操作,利用他的私密金鑰和 Alice 的公開金鑰獨立建立相同的值「X」。讓「X」成為共用的秘密 小艾和 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 文章

其中大致涵蓋 ECDHHKDF

ECDH 以安全的方式共用公開金鑰及產生共用密鑰。HKDF 可讓您使用 不安全的素材,以及確保資料安全

這會在酬載的加密過程中使用。接著來看看我們輸入的資料 以及加密方式

輸入

當我們想向含有酬載的使用者傳送推送訊息時,需要提供三種輸入內容:

  1. 酬載本身。
  2. PushSubscriptionauth 密鑰。
  3. PushSubscription 中的 p256dh 鍵。

我們發現從 PushSubscription 擷取了 authp256dh 值,但在此簡單提醒,針對訂閱項目,我們需要下列值:

subscription.toJSON().keys.auth;
subscription.toJSON().keys.p256dh;

subscription.getKey('auth');
subscription.getKey('p256dh');

請將 auth 值視為密鑰,而且不得在應用程式以外的地方分享。

p256dh 金鑰是公開金鑰,有時也稱為用戶端公開金鑰。此處將 p256dh 稱為訂閱公開金鑰。訂閱項目公開金鑰是由瀏覽器產生。瀏覽器會保留私密金鑰的密鑰,並用來解密酬載。

這三個值必須是 authp256dhpayload 做為輸入內容,而加密程序的結果為加密酬載、鹽值,以及僅用於加密資料的公開金鑰。

鹽必須是 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 和內容加密金鑰。

執行加密

現在,我們有了內容加密金鑰,可以加密酬載。

我們使用內容加密金鑰做為金鑰來建立 AES128 加密,而 Nonce 是初始化向量。

在 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」標頭必須包含用來加密酬載的 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-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: [Time to live in seconds]

如果您將 TTL 設為零,推送服務會嘗試立即傳送訊息,「但」如果無法連上裝置,您的訊息會立即從推送服務佇列中捨棄。

技術上來說,推送服務可以在需要時減少推送訊息的 TTL。您可以在推送服務的回應中檢查 TTL 標頭,藉此判斷相關情況。

Topic

選用

主題是字串,如果待處理訊息具有相符的主題名稱,就能以新訊息取代新訊息。

這種做法在裝置離線時傳送多則訊息的情況相當實用,而且您確實只想在裝置開啟時,向使用者顯示最新的訊息。

急迫性

選用

緊急程度表示訊息對使用者的重要性。這項功能可讓推送服務只在電量不足時喚醒重要訊息,藉此節省使用者裝置的電池續航力。

標頭值定義如下。預設值為 normal

Urgency: [very-low | low | normal | high]

整合所有內容

如果您對於這項運作方式仍有問題,可隨時在 web-push-libs org 上查看程式庫如何觸發推送訊息。

取得加密酬載和上述標頭後,您只需在 PushSubscription 中對 endpoint 發出 POST 要求即可。

如何處理回應此 POST 要求?

推送服務的回應

向推送服務發出要求後,請檢查回應的狀態碼,因為指出要求是否成功。

狀態碼 說明
201 已建立。接收並接受傳送推送訊息的要求。
429 要求數量過多。代表應用程式伺服器已達推送服務的頻率限制。推送服務應包含「重試後」標頭,指出可提出另一項要求之前的等待時間。
400 要求無效,這通常表示其中一個標頭無效或格式不正確。
404 找不到。這表示訂閱項目已過期,無法使用。在這種情況下,您應該刪除「PushSubscription」,並等待用戶端重新訂閱使用者。
410 消失。訂閱項目已失效,建議從應用程式伺服器中移除。只要在 PushSubscription 上呼叫「unsubscribe()」即可重現問題。
413 酬載大小上限。推送服務支援的最小酬載大小為 4096 個位元組 (或 4 KB)。

後續步驟

程式碼研究室