網路推送通訊協定

Matt Gaunt

我們已瞭解如何使用程式庫觸發推播訊息,但這些程式庫究竟在做什麼?

他們會發出網路要求,同時確保要求格式正確。定義這項網路要求的規格是 Web Push Protocol

從伺服器傳送推播訊息至推播服務的示意圖。

本節將說明伺服器如何使用應用程式伺服器金鑰識別自身,以及如何傳送加密的酬載和相關資料。

這並非網頁推播的優點,而且我並非加密專家,但讓我們逐一瞭解每個部分,因為知道這些程式庫在幕後執行的作業很有幫助。

應用程式伺服器金鑰

訂閱使用者時,我們會傳入 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 找到聯絡資訊。(這就是網頁推送程式庫需要電子郵件地址的原因)。

與 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]';

Web Push Protocol 也規定,公開應用程式伺服器金鑰必須以網址安全 Base64 編碼字串的形式,透過 Crypto-Key 標頭傳送,並在前面加上 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 只需要分享公開金鑰。現在,小明和小莉可以使用「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 的文章提供這個範例程式碼

這大致涵蓋 ECDHHKDF

ECDH 是分享公開金鑰及產生共用密鑰的安全方式。HKDF 可將不安全的素材轉換為安全素材。

這項資訊會在酬載加密期間使用。接下來,我們來看看輸入內容,以及加密方式。

輸入內容

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

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

我們已看到系統從 PushSubscription 擷取 authp256dh 值,但為了快速提醒,假設有訂閱項目,我們需要下列值:

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

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

auth 值應視為密碼,請勿在應用程式外分享。

p256dh 金鑰是公開金鑰,有時也稱為用戶端公開金鑰。我們將 p256dh 稱為訂閱公開金鑰。訂閱公開金鑰是由瀏覽器產生。瀏覽器會妥善保存私密金鑰,並用於解密酬載。

這三個值 (authp256dhpayload) 必須做為輸入內容,加密程序完成後,會產生加密的酬載、鹽值,以及僅用於加密資料的公開金鑰。

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,
]);

最終內容緩衝區是標籤、訂閱公開金鑰中的位元組數、金鑰本身、本機公開金鑰中的位元組數,以及金鑰本身。

有了這個脈絡值,我們就能用於建立隨機數和內容加密金鑰 (CEK)。

內容加密金鑰和隨機數

Nonce是防止重播攻擊的值,因為 Nonce 只能使用一次。

內容加密金鑰 (CEK) 是最終用來加密酬載的金鑰。

首先,我們需要為隨機數和 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,因此消費者會再讀取五個位元組,然後開始讀取酬載。

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 標頭,如下所示:

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: [Time to live in seconds]

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

從技術上來說,推送服務可以視需要減少推送訊息的 TTL。如要判斷是否發生這種情況,請檢查推送服務回應中的 TTL 標頭。

主題

選用

主題是字串,如果與待處理訊息的主題名稱相符,即可用新訊息取代待處理訊息。

如果裝置處於離線狀態時傳送了多則訊息,但您只希望使用者在裝置開啟時看到最新訊息,這項功能就非常實用。

急迫性

選用

緊急程度會向推播服務指出訊息對使用者的重要性。推送服務可使用這項資訊,在電池電量不足時只喚醒裝置接收重要訊息,藉此延長電池續航力。

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

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

所有內容

如要進一步瞭解這一切的運作方式,請隨時查看網頁推送通知程式庫機構,瞭解程式庫如何觸發推送訊息。

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

那麼,我們該如何處理這項 POST 要求的相關回應?

推播服務的回應

向推送服務提出要求後,請檢查回應的狀態碼,確認要求是否成功。

狀態碼 說明
201 已建立。系統已收到並接受傳送推播訊息的要求。
429 要求次數過多,也就是說,您的應用程式伺服器已達到推送服務的速率限制。推送服務應包含「Retry-After」標頭,指出多久後才能再次提出要求。
400 要求無效,這通常表示其中一個標頭無效或格式不正確。
404 找不到。這表示訂閱方案已過期,無法使用。在這種情況下,您應刪除 `PushSubscription`, 並等待用戶端重新訂閱使用者。
410 已停用。訂閱項目已失效,應從應用程式伺服器中移除。只要在 `PushSubscription` 上呼叫 `unsubscribe()`,即可重現這個問題。
413 酬載大小過大,推送服務必須支援的酬載大小下限為 4096 個位元組 (或 4 KB)。

如要進一步瞭解 HTTP 狀態碼,請參閱 Web Push 標準 (RFC8030)

後續步驟

程式碼研究室