《Web 推送通知》系列翻譯 | 第五篇:使用 Web 推送庫發送消息 && 第六篇:Web 推送協議

第五篇:使用 Web 推送庫發送消息

原文地址:sending messages with web push librarieshtml

譯文地址:使用 Web 推送庫發送消息前端

譯者:楊芯芯node

校對者:劉鵬劉文濤git

實現 Web 推送的痛點之一就是觸發一個推送消息是極其「繁瑣」的,應用程序須要按照 Web 推送協議向推送服務發送 POST 請求。爲了使推送可以跨瀏覽器使用,你還須要使用 VAPID (即應用服務器密鑰)——須要在 header 中設置一個值來證實你的應用可以向用戶發送消息。發送推送消息數據時,須要對數據進行加密並添加特定的 headers,以便瀏覽器可以正確地解密消息。github

觸發推送的主要問題是,若是遇到問題,很難進行診斷。隨着時間的推移和更多瀏覽器的支持,這一點正在獲得改善,但仍然不容易。所以,我強烈推薦使用庫來處理推送的加密、格式化、觸發這一系列流程。web

若是你想要深刻學習這些庫,咱們會在下一個章節中介紹。如今,咱們將着眼於管理訂閱,而且使用現有的 Web 推送庫來發送推送請求。算法

在這個章節,咱們將使用 web-push Node library。其餘語言會有差別,但不會差太多。之因此用 node 是由於它是 JavaScript,應該是讀者最容易理解的。數據庫

注:若是你想要其餘語言的庫,能夠查看 web-push-libs organization on Githubexpress

咱們將完成如下步驟:npm

  1. 向咱們的後端發送訂閱並保存。
  2. 檢索保存的訂閱並觸發推送消息。

保存訂閱

實現從數據庫中保存並檢索 PushSubscriptions 的操做取決於你的服務端語言和數據庫選擇,不過查看如何實現這一步的示例應該是有些幫助的。

在 demo 頁面中,經過發送簡單的 POST 請求,PushSubscription 被髮送到咱們的後端:

function sendSubscriptionToBackEnd(subscription) {
  return fetch('/api/save-subscription/', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json'
    },
    body: JSON.stringify(subscription)
  })
  .then(function(response) {
    if (!response.ok) {
      throw new Error('Bad status code from server.');
    }

    return response.json();
  })
  .then(function(responseData) {
    if (!(responseData.data && responseData.data.success)) {
      throw new Error('Bad response from server.');
    }
  });
}
複製代碼

demo 中的 Express 服務器會監聽 /api/save-subscription/ endpoint:

app.post('/api/save-subscription/', function (req, res) {
複製代碼

在這個路由下,咱們會驗證訂閱以確保請求正確而且內容有效:

const isValidSaveRequest = (req, res) => {
  // 檢查請求請求的 body, 且至少要檢查是否含有 endpoint
  if (!req.body || !req.body.endpoint) {
    // 不是有效的訂閱
    res.status(400);
    res.setHeader('Content-Type', 'application/json');
    res.send(JSON.stringify({
      error: {
        id: 'no-endpoint',
        message: 'Subscription must have an endpoint.'
      }
    }));
    return false;
  }
  return true;
};
複製代碼

注:在這個路由中,咱們只檢查 endpoint,若是你須要支持 payload,確保你也檢查了 auth 和 p256dh 密鑰。

若是這個訂閱是有效的,咱們須要將其保存並返回一個合適的 JSON 響應:

return saveSubscriptionToDatabase(req.body)
  .then(function(subscriptionId) {
    res.setHeader('Content-Type', 'application/json');
    res.send(JSON.stringify({ data: { success: true } }));
  })
  .catch(function(err) {
    res.status(500);
    res.setHeader('Content-Type', 'application/json');
    res.send(JSON.stringify({
      error: {
        id: 'unable-to-save-subscription',
        message: 'The subscription was received but we were unable to save it to our database.'
      }
    }));
  });
複製代碼

這個 demo 使用了 nedb 存儲訂閱數據,這是一個簡單的基於文件的數據庫,你能夠選擇其餘數據庫,咱們使用它僅是由於它支持零配置使用。在生產環境,你應該使用可靠性更高的數據庫(我傾向使用老牌好用的 MySQL)。

function saveSubscriptionToDatabase(subscription) {
  return new Promise(function(resolve, reject) {
    db.insert(subscription, function(err, newDoc) {
      if (err) {
        reject(err);
        return;
      }

      resolve(newDoc._id);
    });
  });
};
複製代碼

發送推送請求

當發送推送消息時,咱們最終須要一些事件來觸發推送消息的流程。經常使用的方法是建立一個管理員頁面,讓你配置並觸發消息推送。你也能夠建立一個跑在本地的程序或者其餘任何方法來訪問 PushSubscriptions 列表、觸發消息推送。

咱們的演示 demo 有一個"類管理系統"的頁面可以觸發一個推送,由於是演示版本,因此這個頁面是公開的。

我將演示開發這個 demo 所涉及的每一個步驟,這些步驟對全部人來講都很容易跟上,包括那些剛接觸 Node 的人。

在前文討論訂閱用戶時,咱們介紹了在 subscribe() 選項中添加 applicationServerKey,後端會須要這個私鑰。

在 demo 中,這些值會被添加到咱們的 Node 應用中,以下(我知道這段代碼很無聊,但只是想讓你知道,這裏沒有魔法):

const vapidKeys = {
  publicKey:
'BEl62iUYgUivxIkv69yViEuiBIa-Ib9-SkvMeAtA3LFgDzkrxZJjSgSnfckjBJuBkr3qBUYIHBQFLXYp5Nksh8U',
  privateKey: 'UUxI4O8-FbRouAevSmBQ6o18hgE4nSG3qwvJTfKc-ls'
};
複製代碼

下一步,咱們須要在 Node 服務器中安裝 web-push 模塊:

npm install web-push --save
複製代碼

而後在咱們的 Node 腳本中引用 web-push 模塊,以下:

const webpush = require('web-push');
複製代碼

如今咱們可使用 web-push 模塊了。首先咱們須要將應用服務器的密鑰(記住它們同時也是 VAPID 密鑰,這纔是規範的命名)傳給 web-push 模塊。

const vapidKeys = {
  publicKey:
'BEl62iUYgUivxIkv69yViEuiBIa-Ib9-SkvMeAtA3LFgDzkrxZJjSgSnfckjBJuBkr3qBUYIHBQFLXYp5Nksh8U',
  privateKey: 'UUxI4O8-FbRouAevSmBQ6o18hgE4nSG3qwvJTfKc-ls'
};

webpush.setVapidDetails(
  'mailto:web-push-book@gauntface.com',
  vapidKeys.publicKey,
  vapidKeys.privateKey
);
複製代碼

咱們還添加了一個 "mailto:" 字符串,這個字符串須要是一個 URL 或郵箱地址。這部分信息實際上會被做爲觸發推送請求的一部分發送給推送服務器。這麼作的緣由是,若是網絡推送服務須要與消息發送者聯繫,這些信息就能派上用場。

經過上述步驟,web-push 模塊就可使用了,下一步是觸發一個消息推送。

這個 demo 使用了一個僞管理面板來觸發消息推送。

管理頁面截圖

點擊「觸發消息推送」將會給 /api/trigger-push-msg/ 接口發送一個 POST 請求,至關於給後端一個信號去推送消息。因此咱們須要在 express 中建立這個路徑:

app.post('/api/trigger-push-msg/', function (req, res) {
複製代碼

當這個請求被收到時,咱們會從數據庫當中抓取出訂閱信息,而後爲每個訂閱信息觸發推送消息。

return getSubscriptionsFromDatabase()
  .then(function(subscriptions) {
    let promiseChain = Promise.resolve();

    for (let i = 0; i < subscriptions.length; i++) {
      const subscription = subscriptions[i];
      promiseChain = promiseChain.then(() => {
        return triggerPushMsg(subscription, dataToSend);
      });
    }

    return promiseChain;
  })
複製代碼

方法 triggerPushMsg() 可以使用 web-push 庫來給訂閱者發送消息。

const triggerPushMsg = function(subscription, dataToSend) {
  return webpush.sendNotification(subscription, dataToSend)
  .catch((err) => {
    if (err.statusCode === 410) {
      return deleteSubscriptionFromDatabase(subscription._id);
    } else {
      console.log('Subscription is no longer valid: ', err);
    }
  });
};
複製代碼

調用 webpush.sendNotification() 方法會返回一個 promise 對象。 若是這個消息發送成功,promise 會回調 resolve 函數,這時咱們不用作其餘事情。但當 promise 的回調 reject,你須要檢驗錯誤信息,它會告訴你 PushSubscription 是否仍然有效。

要肯定推送服務的錯誤類型,最好的方法是查看狀態碼。錯誤消息因推送服務而異,不必定都有幫助。

在這個例子中,咱們檢驗了狀態碼 "404" 和 "410",分別是 HTTP 狀態碼中的 "Not Fount(資源未找到)"和 "Gone(資源再也不可用)",收到這兩個狀態碼意味着訂閱過時或失效,咱們須要將訂閱信息從數據庫中移除。

下個章節中咱們將會更仔細地介紹 Web 推送協議以及其餘狀態碼。

注:若是你在這個步驟遇到了問題,推薦去 Firefox 上查看錯誤日誌而不是 Chrome。由於相比於 Chrome / FCM (Firebase Cloud Messaging),Mozilla 的推送服務提供的錯誤信息更加有用。

遍歷完訂閱數據後,咱們須要返回一個 JSON 響應。

.then(() => {
    res.setHeader('Content-Type', 'application/json');
      res.send(JSON.stringify({ data: { success: true } }));
  })
  .catch(function(err) {
    res.status(500);
    res.setHeader('Content-Type', 'application/json');
    res.send(JSON.stringify({
      error: {
        id: 'unable-to-send-messages',
        message: `We were unable to send messages to all subscriptions : ` +
          `'${err.message}'`
      }
    }));
  });
複製代碼

至此,咱們已經完成了主要的實現步驟。

  1. 建立一個訂閱請求 API,讓前端可以向後端發送一個訂閱請求,並將訂閱信息保存在數據庫中。
  2. 建立一個發送推送消息的 API(在這個示例中,請求須要從管理面板發出)。
  3. 後端讀取全部的訂閱,並選擇一個 web-push 庫給每個訂閱發送消息.

不管你的後端使用什麼語言 (Node、PHP、Python、...),實現推送的步驟都是同樣的。

接下來,這些 web-push 庫實際上都爲咱們作了什麼呢?請閱讀下一章。

第六篇:Web 推送協議

原文地址:web push protocol

譯文地址:Web 推送協議

譯者:張卓

校對者:劉鵬任家樂

咱們已經看到了如何使用一個庫來觸發消息推送,但這些庫究竟作了什麼呢?

嗯,他們發送了網絡請求,同時確保這些請求符合 Web 推送協議的規範。

Diagram of sending a push message from your server to a push service.

這一部分大體會描述服務器如何使用應用程序服務器密鑰(Application server keys)來識別自身,以及如何發送加密的有效負載(payload)和關聯數據。

這不是 Web 推送容易理解的一個方面,而我(做者)也不是加密專家,但仍是讓咱們看看每一部分,由於它讓咱們更容易理解這些庫的底層原理。

應用程序服務器密鑰

當咱們訂閱一個用戶時,咱們會傳入一個applicationServerKey。這個 key 會被傳遞給推送服務(push service),並被用於檢查訂閱用戶的和推送消息的應用程序是否是同一個。

當咱們觸發推送消息時,咱們將會發送一組 headers 用於推送服務驗證應用。(headers 在 VAPID 規範中進行了定義)

這一切究竟意味着什麼以及究竟發生了什麼? 下面是應用程序服務器身份驗證所採起的步驟:

  1. 應用程序服務器使用它的 私有應用程序密鑰(私鑰) 來對一些 JSON 信息進行簽名。
  2. 這些簽名的信息做爲 POST 請求中的 header 發送給推送服務。
  3. 推送服務用以前保存下來的公鑰(用戶在調用pushManager.subscribe()進行訂閱推送服務時,推送服務會將傳遞的公鑰進行保存)來校驗接收到的消息是由與之匹配的私鑰進行簽名的。注意: 公鑰是傳遞給 subscribe 方法的 applicationServerKey
  4. 若是簽名的信息合法,則推送服務將推送消息發送給用戶。

下面是以上信息流的一個例子。(請注意左下角的圖例表示公鑰和私鑰。)

Illustration of how the private application server key is used when sending a message.

添加到請求頭的「簽名信息」是 JSON Web 令牌(JSON web token)。

JSON Web 令牌

JSON Web 令牌 (縮寫爲 JWT) 是一種向第三方發送消息的方式,接收方能夠驗證誰發送了它。

當第三方收到消息時,他們須要獲取發送者的公鑰並使用它來驗證 JWT 的簽名。若是簽名有效的話,那 JWT 必定是使用了匹配的私鑰進行簽名,必定是來自預期的發送者。

jwt.io/ 上有許多庫能夠用來簽名,而且我(做者)也建議你使用這些庫。可是爲了完整起見,咱們來看看如何手動建立簽名的 JWT。

Web 推送和簽名的 JWT

一個簽名的 JWT 只是一串字符串,也能夠被認爲是由點號鏈接的三個字符串。

A illustration of the strings in a JSON Web Token

第一個和第二個字符串(JWT Info 和 JWT Data)是已經使用 base64 進行編碼的 JSON 片斷,這意味着它是公開可讀的。

第一個字符串是有關 JWT 自己的信息,指出使用了哪種算法來建立簽名。

Web 推送的 JWT Info 必須包含如下信息:

{  
  "typ": "JWT",  
  "alg": "ES256"  
}
複製代碼

第二個字符串是 JWT Data。 它提供了有關 JWT 的發送者,目的人以及有效期等信息。

對於 Web 推送,數據將具備如下字段:

{  
  "aud": "https://some-push-service.org",
  "exp": "1469618703",
  "sub": "mailto:example@web-push-book.org"  
}
複製代碼

aud 表明 「觀衆(audience)」,即JWT的用戶。對於網絡推送,「觀衆」是推送服務,所以咱們將其設置爲推送服務的源。

exp 表明 JWT 的期限,這能夠防止黑客在攔截後從新使用 JWT。 到期時間是以秒爲單位的時間戳,必須不大於24小時。

在 Node.js 中,使用如下命令設置到期時間:

Math.floor(Date.now() / 1000) + (12 * 60 * 60)
複製代碼

這裏用了12小時而不是24小時,來避免發送應用程序和推送服務之間的時鐘差別產生的任何問題。

最後, sub 必須是 URL 或 mailto 郵件地址。這樣,若是推送服務須要聯繫發件人,它能夠從 JWT 找到聯繫信息。(這也是網絡推送庫須要一個電子郵件地址的緣由)。

就像 JWT Info 同樣,JWT Data 被編碼爲 URL 安全的 base64 字符串。

第三個字符串簽名,是取前兩個字符串的結果(JWT Info和JWT Data)並用點號鏈接(稱爲「未簽名的令牌」),而後簽名生成的。

簽名過程須要使用 ES256 加密 「未簽名的令牌」。根據 JWT 規範,ES256 是「橢圓曲線數字簽名算法(ECDSA)使用 P-256 曲線和 SHA-256 哈希算法」的縮寫。使用 web 加密技術,你能夠像這樣建立簽名:

// 將 UTF-8 編碼的 string 轉化爲 ArrayBuffer 的工具庫
const utf8Encoder = new TextEncoder('utf-8');

// 「未簽名的令牌」是由 URL 安全的 base64 算法進行編碼的 header 和 body 的組合。
const unsignedToken = .....;

// 使用 ES256 (SHA-256 over ECDSA) 簽名 |unsignedToken|
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),
};

// 使用服務器的私鑰簽名 |unsignedToken|,來生成簽名
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(即經過點鏈接的全部三個字符串)將在前面拼接上 WebPush 做爲 header 中 Authorization 的值發送給 Web 推送服務,以下所示:

Authorization: 'WebPush <JWT Info>.<JWT Data>.<Signature>'
複製代碼

Web 推送協議還規定公共應用程序服務器密鑰在 Crypto-key header 中進行發送時,必須使用 URL 安全的 base64 算法進行編碼,並加上 p256ecdsa= 的前綴。

Crypto-Key: p256ecdsa=<URL Safe Base64 Public Application Server Key>
複製代碼

有效負載加密

接下來讓咱們看一下如何使用推送消息發送有效負載,以便當咱們的Web應用程序收到推送消息時,它能夠訪問接收的數據。

任何使用過其餘推送服務的人都會提出一個相同的問題,那就是爲何網絡推送有效負載須要加密?使用原生應用,推送消息能夠以純文本形式發送數據。

Web推送的一部分優勢在於,由於全部推送服務都使用相同的 API(Web 推送協議),因此開發人員沒必要關心推送服務是誰。咱們只要以正確的格式發出請求,就能夠發送推送消息。這樣作的缺點是,開發人員可能會將消息發送到不值得信任的推送服務。經過加密有效負載,推送服務沒法讀取發送的數據,只有瀏覽器才能解密信息,這能夠保護用戶的數據。

有效負載的加密在消息加密規範中進行了定義。

在咱們查看加密推送消息有效負載的具體步驟以前,咱們應該先介紹一些在加密過程當中將使用的技術。(感謝 Mat Scales 很是優秀的關於推送加密的文章)

ECDH 和 HKDF

ECDH 和 HKDF 在整個加密過程當中都會被使用,也爲加密信息提供了不少好處。

ECDH: 橢圓曲線迪菲-赫爾曼金鑰交換

想象一下,你有兩個想要分享信息的人,Alice 和 Bob,他們都有本身的公鑰和私鑰,而且互相分享他們的公鑰。

使用 ECDH 生成的密鑰的有用之處是,Alice 可使用她的私鑰和 Bob 的公鑰來建立一個祕密值「X」,Bob 也能夠這樣作,利用他的私鑰和 Alice 的公鑰獨立建立相同的值'X',這使得'X'成爲共享祕密。而 Alice 和 Bob 只須要共享他們的公鑰,他們就可使用'X'來加密和解密他們之間的消息。

據我所知,ECDH 定義了曲線的屬性,它保證了能夠同時生成一個相同的共享祕密「X」。

這是對 ECDH 的一個抽象的解釋,若是想了解更多,我建議觀看此視頻

在代碼方面,大多數語言/平臺都帶有庫來輕鬆的生成這些密鑰。

在 node 中,咱們執行如下操做:

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 做爲咱們的哈希算法,而且 Web 推送中 HKDF 的結果密鑰不該超過256位(32字節)。

在 node 中,能夠像這樣實現:

// 簡化的 HKDF,返回32個字節長度的密鑰
function hkdf(salt, ikm, info, length) {
  // 提取
  const keyHmac = crypto.createHmac('sha256', salt);
  keyHmac.update(ikm);
  const key = keyHmac.digest();

  // 擴展
  const infoHmac = crypto.createHmac('sha256', key);
  infoHmac.update(info);

  // 一個只有 0x01 的一個字節長的緩衝區
  const ONE_BUFFER = new Buffer(1).fill(1);
  infoHmac.update(ONE_BUFFER);

  return infoHmac.digest().slice(0, length);
}
複製代碼

對於此示例代碼,請參閱 Mat Scale 的文章

這一部分粗略地概況了 ECDHHKDF

ECDH 是一種共享公鑰並生成共享密鑰的安全方式,HKDF是一種採用不安全的來源並使其安全的方法。

這些將在加密咱們的有效負載期間使用,接下來讓咱們看看採起什麼做爲輸入以及如何加密。

輸入(Inputs)

當咱們想要經過有效負載向用戶發送推送消息時,咱們須要三個輸入:

  1. 有效負載自身。
  2. 來自 PushSubscriptionauth secret。
  3. 來自 PushSubscriptionp256dh 密鑰。

咱們已經看到 authp256dh 值是從 PushSubscription 中返回的,可是再次提醒,給定一個訂閱,咱們將從中得到這些值:

subscription.joJSON().keys.auth
subscription.joJSON().keys.p256dh

subscription.getKey('auth')
subscription.getKey('p256dh')
複製代碼

auth 值應視爲機密,不在應用程序外部共享。

p256dh 密鑰是公鑰,有時也稱爲客戶端公鑰。這裏咱們將p256dh稱爲訂閱公鑰。訂閱公鑰由瀏覽器生成,瀏覽器將保密私鑰並將其用於解密有效負載。

須要 authp256dhpayload 這三個值做爲輸入進行加密,其結果就是有效負載,而 salt 和公鑰僅用於加密數據。

鹽(Salt)

Salt 須要16字節的隨機數據,在 NodeJS 中,咱們將執行如下操做來建立 salt:

const salt = crypto.randomBytes(16);
複製代碼

公/私鑰

公鑰和私鑰應該使用 P-256 橢圓曲線生成,咱們在 Node 中這樣作:

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

const localPublicKey = localKeysCurve.getPublicKey();
const localPrivateKey = localKeysCurve.getPrivateKey();
複製代碼

咱們將這些密鑰稱爲「本地密鑰」,它們用於加密,與應用程序服務器密鑰無關

使用有效負載,auth secret 和訂閱公鑰做爲輸入以及新生成的 salt 和一系列本地密鑰,咱們已經準備好來作一些加密了。

共享的 secret

第一步是使用訂閱公鑰和咱們的新私鑰建立共享密鑰(還記得 ECDH 中關於 Alice 和 Bob 的解釋嗎?就像那樣)。

const sharedSecret = localKeysCurve.computeSecret(
  subscription.keys.p256dh, 'base64');
複製代碼

這用於下一步計算僞隨機密鑰(PRK)。

僞隨機密鑰

僞隨機密鑰(PRK)是推送訂閱的 auth secret 和咱們剛剛建立的共享密匙的組合。

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 運行 auth,shared secret 和一段編碼信息(即便其加密更強)。

Context

「Context」是一組字節,用於稍後在加密瀏覽器中計算兩個值,它本質上是一個包含訂閱公鑰和本地公鑰的字節數組。

const keyLabel = new Buffer('P-256\0', 'utf8');

// 將訂閱公鑰轉換爲 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,
]);
複製代碼

最終的 context buffer 是一個數組,包括一個標籤、訂閱公鑰的字節長度、訂閱公鑰,而後是本地公鑰的字節長度和本地公鑰。

咱們可使用 context 值來建立隨機數和內容加密密鑰 (CEK)。

內容加密密鑰和 nonce

Nonce 是一個能夠防止重放攻擊的值,由於它只能使用一次。

內容加密密鑰(CEK)是最終用於加密咱們的有效負載的密鑰。

首先,咱們須要爲 nonce 和 CEK 建立數據字節,這只是一個內容編碼字符串,後面是咱們剛剛計算的 context buffer:

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 結合使用:

// Nonce 應該是12個字節長
const nonce = hkdf(salt, prk, nonceInfo, 12);

// CEK 應該是16個字節長
const contentEncryptionKey = hkdf(salt, prk, cekInfo, 16);
複製代碼

這爲咱們提供了 nonce 和 content 加密密鑰。

執行加密

如今咱們有了內容加密密鑰,咱們能夠加密有效負載了。

咱們使用內容加密密鑰做爲密鑰建立了 AES128 密碼,nonce 是其初始化向量。

在 Node 中這樣完成:

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

在咱們加密有效負載以前,咱們須要定義咱們但願添加到有效負載的填充量,之因此添加填充,是覺得它能夠防止竊聽者根據有效負載的大小來肯定消息「類型」。

必須添加兩個填充字節以指示任何額外的填充長度。

例如,假如你沒有添加填充,你也得有兩個字節值爲0,即沒有填充存在,在這兩個字節後將讀取有效負載。 若是添加了5個字節的填充,前兩個字節的值爲5,那麼消費者將再讀取5個字節,而後再開始讀取有效負載。

const padding = new Buffer(2 + paddingLength);
// 除長度外,Buffer 必須爲0
padding.fill(0);
padding.writeUInt16BE(paddingLength, 0);
複製代碼

而後咱們經過這個密碼運行填充和有效負載。

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

// 添加 auth tag 到 result 的後面 -
// https://nodejs.org/api/crypto.html#crypto_cipher_getauthtag
const encryptedPayload = Buffer.concat([result, cipher.getAuthTag()]);
複製代碼

咱們如今有加密的有效負載了,️耶!

剩下的就是肯定如何將此有效負載發送到推送服務。

加密有效負載的 headers 和 body

要將此加密的有效負載發送到推送服務,咱們須要在 POST 請求中定義幾個不一樣的 header。

Encryption header

Encryption header 必須包含用於加密有效負載的 salt。

Salt 應該是16字節的,而且通過 base64 URL 安全編碼後添加到 Encryption header 中,以下所示:

Encryption: salt=<URL Safe Base64 Encoded Salt>
複製代碼

Crypto-Key header

在「應用程序服務器密鑰」章節中,咱們已經知道如何使用 Crypto-Key header 來包含公共應用程序服務器密鑰。

此 header 還用做共享用於加密有效負載的本地公鑰。

header 的結果以下所示:

Crypto-Key: dh=<URL Safe Base64 Encoded Local Public Key String>; p256ecdsa=<URL Safe Base64
Encoded Public Application Server Key>
複製代碼

Content type, length & encoding headers

Content-Length header 是加密有效負載中的字節數。 「Content-Type」和「Content-Encoding」標頭是固定值,以下所示。

Content-Length: <Number of Bytes in Encrypted Payload>
Content-Type: 'application/octet-stream'
Content-Encoding: 'aesgcm'
複製代碼

設置這些 header 後,咱們須要將加密的有效負載做爲請求的 body 發送。 請注意將 Content-Type 設置爲application/octet-stream,這是由於加密的有效負載必須做爲字節流發送。

在 NodeJS 中咱們會這樣作:

const pushRequest = https.request(httpsOptions, function(pushResponse) {
pushRequest.write(encryptedPayload);
pushRequest.end();
複製代碼

更多的 header?

咱們已經介紹了用於 JWT / 應用程序服務器密鑰的 headers(即如何使用推送服務認證應用程序),也介紹了用於發送加密有效負載的 headers。

推送服務還有其餘的 headers 來用於改變發送消息的行爲。其中一些 headers 是必需的,一些是可選的。

TTL header

必選

「TTL」(time to live,生存時間)是一個整數,指定推送消息在推送服務發佈以前存活的時間。當 「TTL」 到期時,該消息將從推送服務隊列中刪除,而且不會被傳遞。

TTL: <Time to live in seconds>
複製代碼

若是將 「TTL」 設置爲 0,則推送服務將嘗試當即傳遞消息,可是若是沒法訪問設備,則會當即從推送服務隊列中刪除該消息。

從技術上講,推送服務能夠在須要時減小推送消息的 「TTL」。你能夠經過檢查推送服務響應中的「TTL」標頭來判斷是否發生了這種狀況。

主題(Topic)

可選

當一條的消息與待處理的消息有相同的主題(字符串形式)時,新的消息就會替換舊的。

這在設備離線時發送多條消息的狀況下很是有用,用戶在設備打開時只會收到最新消息。

緊急度(Urgency)

可選

緊急度向推送服務說明消息對用戶的重要性。推送服務可使用此字段來幫助節省用戶設備的電量,當電池電量低的時候,只有當重要消息來臨的時候才進行喚醒。

Header 的值定義以下。 默認值是 normal.

Urgency: <very-low | low | normal | high>
複製代碼

將一切整合在一塊兒

若是你對這一切的工做方式有進一步的疑問,能夠隨時在 web-push-libs 上查看一些庫如何觸發推送消息。

一旦你有了加密的有效負載和上面提到的 header,你只須要在 PushSubscription 中向 endpoint 發出POST請求。

那麼咱們如何處理 POST 請求的響應呢?

推送服務的響應

一旦向推送服務發出請求,你須要檢查響應的狀態碼,它會告訴請求是否成功。

狀態碼 描述
201 建立。收到並接受發送推送消息的請求。
429 請求過多。意味着應用程序服務器已經達到了推送服務的速率限制。推送服務會包括 「Retry-After」 標頭,來指示在下一個請求發出以前等多長時間。
400 無效的請求。這一般意味着存在無效的 header 或格式不正確。
404 未找到。這表示訂閱已過時且沒法使用。在這種狀況下,你應該刪除 `PushSubscription` 並等待客戶端從新訂閱用戶。
410 被移除。訂閱再也不有效,應從應用程序服務器中刪除。能夠經過在 `PushSubscription` 上調用 `unsubscribe()` 來重現。
413 有效負載過大。一個推送服務支持的最小的有效負載大小是 4096 bytes (或者 4kb)。

更多分享,請關注YFE:

上一篇:《Web 推送通知》系列翻譯 | 第四篇:請求權限的交互

下一篇:《Web 推送通知》系列翻譯 | 第七篇:推送事件 && 第八篇:顯示一個通知

相關文章
相關標籤/搜索