網路推送通訊協定

Matt Gaunt

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

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

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

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

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

應用程式伺服器金鑰

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

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

這一切究竟代表什麼意思?以下是執行應用程式伺服器驗證的步驟:

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

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

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

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

JSON 網路權杖

JSON Web 權杖 (簡稱 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 後重複使用。到期時間是秒數格式的時間戳記,且不得超過 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]';

Web Push Protocol 也指出,在 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」是共用的密鑰 小艾和 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. 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();

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

使用酬載、驗證密鑰和訂閱公開金鑰做為輸入內容,並使用新產生的鹽值和本機金鑰組,我們就可以開始進行加密作業。

共用密鑰

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

背景資訊

「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,將鹽和 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 和內容加密金鑰。

執行加密

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

我們會使用內容加密金鑰做為金鑰,並將 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 標頭

我們發現「Application Server Keys」區段下方使用了 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]

所有內容

如果您對這一切的運作方式還有其他疑問,可以前往 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 狀態碼。

後續步驟

程式碼研究室