網路推送通訊協定

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 必須使用相符的私密金鑰簽署,因此必須來自預期的寄件者。

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 後重複使用。到期時間是以秒為單位的時間戳記,不得超過 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);
}

感謝 Mat Scale 的文章提供這個程式碼範例。

這大致涵蓋 ECDH 和 HKDF。

ECDH 是一種安全的方法,可用於分享公開金鑰及產生共用密鑰。HKDF 是一種將不安全資料轉為安全資料的方法。

這項資訊會用於加密酬載。接下來,我們來看看輸入內容和加密方式。

輸入內容

如要傳送含有酬載的推播訊息給使用者,我們需要提供三個輸入內容:

  1. 酬載本身。
  2. PushSubscription 中的 auth 密鑰。
  3. 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 狀態碼。

後續步驟

程式碼研究室