網路推送通訊協定

Matt Gaunt

我們已經瞭解如何使用程式庫觸發推送訊息,但這些程式庫的用途是什麼?

那麼,這些 API 會傳送網路要求,同時確保這類要求的格式正確無誤。定義這個網路要求的規格是網路推送通訊協定

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

本節概述伺服器如何透過應用程式伺服器金鑰識別自身身分,以及已加密酬載與相關資料的傳送方式。

這並不屬於網頁推送過程,而且我不太擅長加密技術,但我們會逐一探討每個部分,瞭解這些程式庫實際上在運作的方式如何。

應用程式伺服器金鑰

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

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

這些影響實際上是什麼意思?確切情況又有什麼影響?以下是應用程式伺服器驗證的步驟:

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

以下是此流程資訊流程的範例。(請注意左下方的圖例,表示公開與私密金鑰)。

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

新增至要求標頭的「簽署資訊」是 JSON Web Token。

JSON 網頁權杖

JSON 網頁權杖 (簡稱 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 資料」) 結合使用前兩個字串 (我們將稱為「未簽署的權杖」並進行簽署) 的結果。

簽署程序需要使用 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 (網路推送通訊協定),開發人員不必負責處理推送服務的身分,因此能以正確的格式發出要求,並預期系統會傳送推送訊息。但這樣做的缺點是,開發人員可刻意將訊息傳送至不可靠的推送服務。只要將酬載加密,推送服務就無法讀取已傳送的資料。只有瀏覽器才能解密資訊。這樣做可以保護使用者的資料。

酬載的加密定義請參閱 Message 加密規格

在探討加密推送訊息酬載的具體步驟之前,我們應先探討在加密過程中會用到的一些技術。(我們向 Mat Scales 發表的一篇優質帽子,是發表了關於推送加密的優秀文章)。

ECDH 和 HKDF

整個加密程序都會使用 ECDH 和 HKDF,並提供加密資訊的優點。

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

假設您有兩位使用者想要分享資訊,小莉和志明。Alice 和 Bob 均有專屬的公開和私密金鑰。Alice 和 Bob 之間會互相共用公開金鑰。

使用 ECDH 產生的金鑰實用屬性是 Alice 可以使用其私密金鑰和 Bob 的公開金鑰來建立密鑰值「X」。也可以這麼做,將自己的私密金鑰和 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);
}

請參考這個範例程式碼的相關提示。

本文概略說明 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) 做為輸入內容,而加密程序的結果會是加密酬載、鹽值,以及僅用於加密資料的公開金鑰。

鹽必須是 16 位元組的隨機資料。在 NodeJS 中,我們會執行下列步驟來建立鹽:

const salt = crypto.randomBytes(16);

公開 / 私密金鑰

公開和私密金鑰應該使用 P-256 橢圓曲線產生,在節點中如此:

const localKeysCurve = crypto.createECDH('prime256v1');
localKeysCurve.generateKeys();

const localPublicKey = localKeysCurve.getPublicKey();
const localPrivateKey = localKeysCurve.getPrivateKey();

我們將這些金鑰稱為「本機金鑰」。這類金鑰「只會」用於加密,而且「無法」處理應用程式伺服器金鑰。

透過酬載、驗證密鑰和訂閱公開金鑰做為輸入內容,以及使用新產生的鹽和一組本機金鑰,我們就可以實際執行部分加密作業。

共用密鑰

首先,請使用訂閱公開金鑰和我們的新私密金鑰 (還記得 ECDH 說明給 Alice 和 Bob?就是這麼簡單)。

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 是初始化向量。

在節點中,運作方式如下:

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」標頭必須包含用於加密酬載的 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 設為 0,推送服務會嘗試立即傳送訊息,但「但是」如果無法與裝置連線,您的訊息則會立即從發送服務佇列中遭到捨棄。

技術上來說,推送服務可視需求減少推送訊息的 TTL。只要檢查推送服務回應中的 TTL 標頭,即可判斷是否發生此情況。

主題

選用

主題是字串,可用於在有相符主題名稱的待處理訊息替換為新訊息。

如果裝置離線時傳送多則訊息,而且您只想讓使用者在裝置開啟時查看最新訊息,這項功能就非常實用。

急迫性

選用

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

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

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

一切整合

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

擁有加密酬載和以上標頭之後,您只需要在 PushSubscription 中向 endpoint 發出 POST 要求即可。

那麼,Google 會如何處理這個 POST 要求的回應?

推送服務的回應

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

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

後續步驟

程式碼研究室