網路推送酬載加密

Mat Scale

在 Chrome 50 版之前,推送訊息不得包含任何酬載資料。在 Service Worker 中觸發 'push' 事件時,您知道伺服器其實是試著傳達一些訊息,但這不是什麼。然後,您必須向伺服器提出後續要求,並取得顯示通知的詳細資料 (這樣可能會在網路狀況不佳時失敗)。

現在,在 Chrome 50 版 (以及最新版電腦版 Firefox) 中,您可以將部分任意資料與推送一併傳送,讓用戶端不必提出額外的要求。不過,由於效能強大,因此必須加密 所有酬載資料都必須加密

酬載加密是網路推送作業中相當重要的一環。HTTPS 能在瀏覽器和您自己的伺服器之間進行通訊,因為您信任該伺服器。不過,瀏覽器會選擇要使用哪個推送供應商來實際提供酬載,因此應用程式開發人員無法控管該設定。

在這裡,HTTPS 只能保證沒有人可以在傳輸至推送服務供應商的訊息中遭到窺探。收到訊息後,他們即可自由執行自己喜愛的操作,包括將酬載重新傳輸至第三方,或以惡意方式將其改造成其他內容。我們使用加密技術,確保推送服務無法讀取或竄改傳輸中的酬載。

用戶端變更

如果您已實作不含酬載的推播通知,您只需要在用戶端進行兩項小幅變更。

首先,您將訂閱資訊傳送至後端伺服器時,必須收集一些額外資訊。如果您在 PushSubscription 物件中使用 JSON.stringify() 進行序列化,即可傳送至伺服器,則無須進行任何變更。現在訂閱項目的鍵屬性中會有一些額外資料。

> JSON.stringify(subscription)
{"endpoint":"https://android.googleapis.com/gcm/send/f1LsxkKphfQ:APA91bFUx7ja4BK4JVrNgVjpg1cs9lGSGI6IMNL4mQ3Xe6mDGxvt_C_gItKYJI9CAx5i_Ss6cmDxdWZoLyhS2RJhkcv7LeE6hkiOsK6oBzbyifvKCdUYU7ADIRBiYNxIVpLIYeZ8kq_A",
"keys":{"p256dh":"BLc4xRzKlKORKWlbdgFaBrrPK3ydWAHo4M0gs0i1oEKgPpWC5cW8OCzVrOQRv-1npXRWk8udnW3oYhIO4475rds=",
"auth":"5I2Bu2oKdyy9CwL8QVF0NQ=="}}

p256dhauth 這兩個值是以 Base64 的變化版本編碼,而我將呼叫 URL-Safe Base64

如果想改為直接以位元組處理,可以在訂閱項目中傳回新的 getKey() 方法,該方法會將參數做為 ArrayBuffer 傳回。需要的兩個參數為 authp256dh

> new Uint8Array(subscription.getKey('auth'));
[228, 141, 129, ...] (16 bytes)

> new Uint8Array(subscription.getKey('p256dh'));
[4, 183, 56, ...] (65 bytes)

第二項變更是 push 事件觸發時的新 data 屬性。它有多種同步方法可用來剖析收到的資料,例如 .text().json().arrayBuffer().blob()

self.addEventListener('push', function(event) {
  if (event.data) {
    console.log(event.data.json());
  }
});

伺服器端變更

在伺服器端,有些事情會有所改變。基本流程是使用從用戶端取得的加密金鑰資訊來加密酬載,然後將該資訊做為 POST 要求內文傳送至訂閱中的端點,並新增一些額外的 HTTP 標頭。

這類細節相對較複雜,如同任何加密相關事務一樣,使用主動開發的程式庫,會比自行建構程式庫更好。Chrome 團隊已發布 Node.js 適用的程式庫,並且即將推出更多語言和平台。這樣做可處理加密與網路推送通訊協定,讓從 Node.js 伺服器傳送推送訊息就像 webpush.sendWebPush(message, subscription) 一樣簡單。

雖然我們強烈建議使用程式庫,但這項新功能,還有許多熱門語言尚無任何程式庫。如果您需要自行實作,請參考以下詳細資料。

我會說明使用節點變種 JavaScript 的演算法,但所有語言都應遵循相同的基本原則。

輸入內容

為了加密訊息,我們需要先從用戶端收到的訂閱物件取得兩項內容。如果您在用戶端上使用 JSON.stringify() 並將其傳輸至伺服器,則用戶端的公開金鑰會儲存在 keys.p256dh 欄位中,而共用驗證密鑰則儲存在 keys.auth 欄位中。兩者都會以網址安全 Base64 編碼,如上所述。用戶端公開金鑰的二進位格式是未壓縮的 P-256 橢圓曲線點。

const clientPublicKey = new Buffer(subscription.keys.p256dh, 'base64');
const clientAuthSecret = new Buffer(subscription.keys.auth, 'base64');

公開金鑰可讓我們加密訊息,使其只能使用用戶端的私密金鑰解密。

公開金鑰通常視為公開,因此為了讓用戶端驗證訊息是否由受信任的伺服器傳送,我們也會使用驗證密鑰。為了避免發生意外,請勿外洩,請勿外洩,只應與要傳送訊息的應用程式伺服器分享,並視為密碼。

我們還需要產生新的資料。我們需要 16 位元組經過加密編譯的隨機 salt橢圓曲線金鑰的公開/私密金鑰。推送加密規格使用的特定曲線為 P-256 或 prime256v1。為獲得最佳安全性,每次加密訊息時,都應該從延伸中產生金鑰組,而且切勿重複使用鹽分。

ECDH

讓我們來談談橢圓曲線密碼編譯的優良屬性。相對而言,將「您的」私密金鑰與「其他人的」公開金鑰結合在一起,會產生值相對簡單的程序。然後呢?不過,如果另一方取得「他們」的私密金鑰和「您的」公開金鑰,則會取得完全相同的值!

此為橢圓曲線 Diffie-Hellman (ECDH) 金鑰協議通訊協定的基礎,可讓雙方共用相同的「共用密鑰」,即使只交換公開金鑰也一樣。我們會使用這個共用密鑰做為實際加密金鑰的基礎。

const crypto = require('crypto');

const salt = crypto.randomBytes(16);

// Node has ECDH built-in to the standard crypto library. For some languages
// you may need to use a third-party library.
const serverECDH = crypto.createECDH('prime256v1');
const serverPublicKey = serverECDH.generateKeys();
const sharedSecret = serverECDH.computeSecret(clientPublicKey);

港幣

目前已有其他時間了。假設您有一些密鑰資料要做為加密金鑰使用,但資料安全強度不足。您可以使用 HMAC 的金鑰衍生函式 (HKDF),將低安全性的密鑰轉換成高安全性的密鑰。

其中一種運作原理,就是您可以擷取任意數量的位元,並以雜湊產生的雜湊值,產生一個任意大小的密鑰最多 255 倍。針對推送作業,規格要求我們使用雜湊長度為 32 個位元組 (256 位元) 的 SHA-256。

發生這種情況時,我們知道我們只需產生最大 32 個位元組的金鑰。這表示我們能使用無法處理較大輸出大小的簡化版演算法。

以下附上 Node 版本的程式碼,不過您也可以在 RFC 5869 中查看實際運作情況。

HKDF 的輸入內容是鹽、部分初始索引鍵材料 (ikm)、目前用途 (資訊) 專屬的結構化資料片段,以及所需輸出鍵的長度 (以位元組為單位)。

// Simplified HKDF, returning keys up to 32 bytes long
function hkdf(salt, ikm, info, length) {
  if (length > 32) {
    throw new Error('Cannot return keys of more than 32 bytes, ${length} requested');
  }

  // 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);
}

產生加密參數

現在,我們使用 HKDF 將手邊的資料轉換為實際加密的參數。

我們的第一步是使用 HKDF 將用戶端驗證密鑰和共用密鑰混合成更完整、更安全的密鑰。在規格中,這稱為虛擬隨機金鑰 (PRK),所以在這裡稱作這個金鑰,但密碼編譯提供者可能知道這並非 PRK。

現在我們要建立最終內容加密金鑰以及將傳送至加密的 nonce。建立實驗的方法是將每種規格所稱為的簡單資料結構視為資訊,其中包含橢圓曲線、傳送者和接收者的資訊,以便進一步驗證訊息來源。接著,我們利用 HKDF 搭配 PRK、我們的鹽值和資訊,導出正確尺寸的索引鍵和 Nonce。

內容加密的資訊類型是「aesgcm」,這是用於推送加密的加密名稱。

const authInfo = new Buffer('Content-Encoding: auth\0', 'utf8');
const prk = hkdf(clientAuthSecret, sharedSecret, authInfo, 32);

function createInfo(type, clientPublicKey, serverPublicKey) {
  const len = type.length;

  // The start index for each element within the buffer is:
  // value               | length | start    |
  // -----------------------------------------
  // 'Content-Encoding: '| 18     | 0        |
  // type                | len    | 18       |
  // nul byte            | 1      | 18 + len |
  // 'P-256'             | 5      | 19 + len |
  // nul byte            | 1      | 24 + len |
  // client key length   | 2      | 25 + len |
  // client key          | 65     | 27 + len |
  // server key length   | 2      | 92 + len |
  // server key          | 65     | 94 + len |
  // For the purposes of push encryption the length of the keys will
  // always be 65 bytes.
  const info = new Buffer(18 + len + 1 + 5 + 1 + 2 + 65 + 2 + 65);

  // The string 'Content-Encoding: ', as utf-8
  info.write('Content-Encoding: ');
  // The 'type' of the record, a utf-8 string
  info.write(type, 18);
  // A single null-byte
  info.write('\0', 18 + len);
  // The string 'P-256', declaring the elliptic curve being used
  info.write('P-256', 19 + len);
  // A single null-byte
  info.write('\0', 24 + len);
  // The length of the client's public key as a 16-bit integer
  info.writeUInt16BE(clientPublicKey.length, 25 + len);
  // Now the actual client public key
  clientPublicKey.copy(info, 27 + len);
  // Length of our public key
  info.writeUInt16BE(serverPublicKey.length, 92 + len);
  // The key itself
  serverPublicKey.copy(info, 94 + len);

  return info;
}

// Derive the Content Encryption Key
const contentEncryptionKeyInfo = createInfo('aesgcm', clientPublicKey, serverPublicKey);
const contentEncryptionKey = hkdf(salt, prk, contentEncryptionKeyInfo, 16);

// Derive the Nonce
const nonceInfo = createInfo('nonce', clientPublicKey, serverPublicKey);
const nonce = hkdf(salt, prk, nonceInfo, 12);

邊框間距

相輔相成,是時候展開一個傻瓜、孤單的例子。假設老闆的伺服器每隔幾分鐘就會傳送一個推送訊息給公司股價,這的純訊息一律為 32 位元整數,其中值以美分為單位。她也和外燴人員很特別,就是可以在他們實際送達的 5 分鐘前,將字串「休息室的甜甜圈」寄給她,這樣她就能「誠信」找到最合適的送禮人員。

網路推送功能使用的加密值,會是比未加密輸入內容多 16 個位元組的加密值。由於「休息室中的甜甜圈」較長是 32 位元的股價,因此任何窺探的員工只要從資料的長度開始,就能判斷草坪何時抵達,而無需解密訊息。

因此,使用網路推送通訊協定可讓您在資料開頭加上邊框間距。使用方法取決於應用程式,但是在上述範例中,您可以將所有訊息填充剛好為 32 個位元組,導致無法只根據長度區分訊息。

邊框間距值是 16 位元大端序的整數,用於指定邊框間距長度,後面加上該邊框間距的 NUL 個位元組數量。因此邊框間距下限為 2 個位元組,是將數字 0 編碼為 16 位元的號碼。

const padding = new Buffer(2 + paddingLength);
// The buffer must be only zeroes, except the length
padding.fill(0);
padding.writeUInt16BE(paddingLength, 0);

當推送訊息傳送至用戶端時,瀏覽器可以自動消除任何邊框間距,因此用戶端程式碼只會接收未填充的訊息。

加密

我們終於完成了加密作業的所有準備工作。網路推送所需的加密是採用 GCMAES128。我們的內容加密金鑰是金鑰,而 Nonce 則是初始化向量 (IV)。

在這個範例中,資料是字串,但可以是任何二進位資料。您最多只能傳送 4,078 個位元組到 4,096 個位元組的酬載;每則貼文最多可傳送 16 個位元組,加密資訊則為 2 個位元組。

// Create a buffer from our data, in this case a UTF-8 encoded string
const plaintext = new Buffer('Push notification payload!', 'utf8');
const cipher = crypto.createCipheriv('id-aes128-GCM', contentEncryptionKey,
nonce);

const result = cipher.update(Buffer.concat(padding, plaintext));
cipher.final();

// Append the auth tag to the result - https://nodejs.org/api/crypto.html#crypto_cipher_getauthtag
return Buffer.concat([result, cipher.getAuthTag()]);

網路推送

大功告成!現在您已擁有加密酬載,接下來需要對使用者訂閱項目指定的端點提出相對簡單的 HTTP POST 要求。

您必須設定三個標頭。

Encryption: salt=<SALT>
Crypto-Key: dh=<PUBLICKEY>
Content-Encoding: aesgcm

<SALT><PUBLICKEY> 是加密中使用的鹽和伺服器公開金鑰,採用網址安全 Base64 編碼。

使用網路推送通訊協定時,POST 的主體就是加密訊息的原始位元組。不過,在 Chrome 和 Firebase 雲端通訊支援通訊協定之前,您可以按照下列步驟,輕鬆在現有的 JSON 酬載中加入資料。

{
    "registration_ids": [ "…" ],
    "raw_data": "BIXzEKOFquzVlr/1tS1bhmobZ…"
}

rawData 屬性的值必須是以 Base64 編碼表示加密訊息。

偵錯 / 驗證器

Peter Beverloo 是 Chrome 工程師之一,他們同時又建立了驗證器,該工程師也是參與該規格的員工之一。

透過讓程式碼輸出加密的每個中間值,您可以將這些內容貼到驗證器中,確認目前狀態正確無誤。