網路推送通訊協定

Matt Gaunt

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

他們會發出網路要求,並確保這類要求採用正確的格式。定義此網路要求的規格是 Web Push 通訊協定

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

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

這不是網路推播的理想情況,而且我也不是加密專家,但我們還是來看看各個部分,因為瞭解這些程式庫在幕後的運作方式很方便。

應用程式伺服器金鑰

當我們為使用者訂閱時,會傳入 applicationServerKey。這個鍵會傳遞至推播服務,用於檢查訂閱使用者的應用程式是否也是觸發推播訊息的應用程式。

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

這一切究竟代表什麼意思,以及實際上會發生什麼事?以下是應用程式伺服器驗證的步驟:

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

以下是這項資訊流程的示例。(請注意左下方的說明,用於標示公開和私密金鑰)。

插圖:說明傳送訊息時如何使用應用程式私密伺服器金鑰

在要求中加入至標頭的「已簽署資訊」是 JSON Web Token。

JSON Web Token

JSON Web 權杖 (簡稱 JWT) 是一種傳送訊息給第三方的做法,可讓收件者驗證訊息的寄件者。

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

https://jwt.io/ 上有許多程式庫可為您執行簽署作業,建議您在可行情況下執行這項作業。為了完整說明,我們來看看如何手動建立已簽署的 JWT。

Web Push 和已簽署的 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」的縮寫。您可以使用網路加密編譯建立簽名,如下所示:

// 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 都有各自的公開金鑰和私密金鑰。小莉和小明彼此分享公開金鑰。

使用 ECDH 產生的金鑰有個實用特性,就是 Alice 可以使用自己的私密金鑰和 Bob 的公開金鑰,建立密值「X」。Bob 也可以採取相同做法,使用自己的私密金鑰和 Alice 的公開金鑰,獨立建立相同的值「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 提供此程式碼範例

這項功能大致涵蓋 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) 必須做為輸入值,而加密程序的結果則會是加密的酬載資料、鹽值和用於加密資料的公開金鑰。

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();

我們將這些鍵稱為「本機鍵」。這些金鑰「僅」用於加密,與應用程式伺服器金鑰「無關」。

使用酬載、驗證密鑰和訂閱公開金鑰做為輸入內容,並使用新產生的鹽值和本機金鑰組,即可實際執行加密作業。

共用密鑰

第一步是使用訂閱公開金鑰和新的私密金鑰建立共用密鑰 (還記得艾莉卡和鮑伯的 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 執行驗證、共用密鑰和部分編碼資訊 (也就是讓加密運算更強大)。

背景資訊

「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 執行,將 salt 和 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: salt=[URL Safe Base64 Encoded Salt]

Crypto-Key 標頭

我們發現 Crypto-Key 標頭會用於「Application Server Keys」部分,用於包含公開應用程式伺服器金鑰。

這個標頭也用於分享用來加密酬載的本機公開金鑰。

產生的標頭如下所示:

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]

全部整合

如果您對這項功能的運作方式有其他疑問,可以隨時查看程式庫如何在 web-push-libs org 上觸發推播訊息。

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

那麼,我們要如何處理對這項 POST 要求的回應呢?

推播服務的回應

向推播服務提出要求後,您需要檢查回應的狀態碼,因為這會告訴您要求是否成功。

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

您也可以參閱 Web Push 標準 (RFC8030),進一步瞭解 HTTP 狀態碼。

後續步驟

程式碼研究室