網路推送通訊協定

Matt Gaunt

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

他們正在發出網路要求,同時確保這類要求 合適的廣告格式負責定義這項網路要求的規格 網路推送通訊協定

從伺服器傳送推送訊息到推送項目的圖表
服務

本節將概述伺服器如何利用應用程式識別本身 以及加密酬載和相關資料的傳送方式。

這不算是網路推送的一環,而且我不是加密專家,但我們來看看 因為瞭解這些程式庫的實際運作原理就很方便。

應用程式伺服器金鑰

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

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

這些有何意義?實際情形為何?這些步驟就是 應用程式伺服器驗證:

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

以下是此資訊流向的範例。(請注意左下角的圖例, 公開和私密金鑰)。

插圖:將私人應用程式伺服器金鑰,用於傳送
訊息

「已簽署的資訊」則是 JSON Web Token

JSON 網路權杖

JSON Web Token (簡稱 JWT) 是一種 傳送訊息給第三方,以便接收端驗證 傳送者的身分

第三方收到郵件後,需要將寄件者傳送給寄件者 公開金鑰,並使用金鑰驗證 JWT 的簽名。如果 才能使 JWT 與 私密金鑰,例如來自預期的寄件者。

https://jwt.io/ 上有許多程式庫, 可以為您進行簽署,推薦您在這裡使用 。為求完整起見,我們會說明如何手動建立已簽署的 JWT。

網路推送和已簽署的 JWT

已簽署的 JWT 只是字串,但它可視為三個字串聯結 。

JSON Web 中字串的插圖
分詞

第一和第二字串 (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;

為了避免 傳送應用程式與推送服務之間的時鐘差異。

最後,sub 值必須是網址或 mailto 電子郵件地址。 因此,如果需要推送服務來聯絡寄件者,該程式可以 聯絡人資訊(因此,網頁推送程式庫需要 電子郵件地址)。

與 JWT 資訊一樣,JWT 資料會編碼為網址安全 base64 字串。

第三個字串,簽名,是擷取前兩個字串的結果 (JWT 資訊及 JWT 資料) 會以半形句號合併兩者, 呼叫、簽署「未簽署的權杖」

簽署程序需要對「未簽署的權杖」進行加密使用 ES256。依據 JWT spec,ES256 是「使用 P-256 曲線的 ECDSA 的簡稱, SHA-256 雜湊演算法」。您可以使用網頁加密編譯建立簽章,如下所示:

// 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 (網路推送通訊協定),開發人員不必擔心 推送服務我們可以用正確的格式提出要求, 推送訊息。缺點是開發人員可以 也就是將訊息傳送至不可信任的推送服務。變更者: 將酬載加密,推送服務無法讀取已傳送的資料。 只有瀏覽器可以解密資訊。這可保護使用者的 資料。

酬載的加密定義請參閱 Message Encryption spec

我們先來看看加密推送訊息酬載的具體步驟之前, 我們應介紹加密過程中會用到的 上傳資料集之後,您可以運用 AutoML 自動完成部分資料準備工作(他向 Mat Scales 寫了一本極具說服力的實用文章 encryption.)

ECDH 和 HKDF

加密過程中會使用 ECDH 和 HKDF, 提供加密防護

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

假設有兩位使用者願意分享資訊,她和小柏。 莉莉和 Bob 都有自己的公開和私密金鑰。Alice 和 Bob 並互相共用公開金鑰

ECDH 產生的金鑰很實用,那就是 Alice 可以在 私密金鑰和 Bob 的公開金鑰,藉此建立密鑰值「X」。志明 使用這組金鑰和 Alice 的公開金鑰 獨立建立相同的值「X」。這會讓「X」變為「X」共用密鑰 與 Alice 和 Bob 隻共用公開金鑰。現在 Bob 和 Alice 可以使用「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 鍵。

我們看到 authp256dh 值是從 PushSubscription 擷取,但如果是 快速提醒,我們需要下列值:

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

我們將這些金鑰稱為「本機金鑰」。用途「只」用於加密,且 與應用程式伺服器金鑰建立關聯。

使用酬載、驗證密鑰和訂閱項目公開金鑰做為輸入內容,以及新產生的 新增鹽和一組本機金鑰,我們已準備好進行部分加密。

共用密鑰

第一步是使用訂閱公開金鑰和新的 私密金鑰 (請記得與 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 的位元組,後面接著 加密資料。

我們的虛擬隨機金鑰只是執行 Auth、共用密鑰和編碼資訊 以加密編譯技術。

背景資訊

「背景資訊」是一組位元組,會在加密程序結束後用來計算兩個值 。基本上,這是包含訂閱公開金鑰的位元組陣列,以及 本機公開金鑰

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

執行加密

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

我們使用內容加密金鑰建立 AES128 加密 而 Nonce 是初始化向量。

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

const cipher = crypto.createCipheriv(
  'id-aes128-GCM',
  contentEncryptionKey,
  nonce,
);

加密酬載之前,我們需要定義所需的邊框間距 新增至酬載前方需要加上邊框間距的原因 它可以避免有心人士竊取資料 「類型」讀取及寫入訊息

您必須加入 2 個位元組的邊框間距,藉此指出任何其他邊框間距的長度。

舉例來說,如果您未加入任何邊框間距,您就會有兩個值為 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 要求中使用不同的標頭

加密標頭

「加密」標頭必須包含用於加密酬載的 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-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 org 中推送訊息。

在您擁有加密酬載與上述標頭之後,您只需要提出 POST 要求即可 至 PushSubscription 中的 endpoint

那麼該如何回應這個 POST 要求?

來自推送服務的回應

向推送服務發出要求後,您必須檢查狀態碼 有助您瞭解請求是否成功 不一定。

狀態碼 說明
201 已建立。已收到並接受傳送推送訊息的要求。
429 傳送的要求過多,這表示應用程式伺服器已達到 以推送服務達到極限的目的推送服務應包含「重試後」 標頭,指出要等待多久才會提出另一項要求。
400 要求無效,這通常表示其中一個標頭無效 或格式不正確
404 找不到。這表示訂閱已過期 無法使用這個按鈕在這種情況下,您應刪除 `PushSubscription` 並等待用戶端重新訂閱使用者。
410 已不存在。訂閱項目已失效,應予以移除 應用程式伺服器只要呼叫 `PushSubscription` 上的「unsubscribe()」。
413 酬載過大。推送服務必須具有的酬載大小下限 支援 4096 個位元組 (或 4 KB)。

後續步驟

程式碼研究室