Протокол Web Push

Мы видели, как можно использовать библиотеку для запуска push-сообщений, но что именно делают эти библиотеки?

Ну, они отправляют сетевые запросы, гарантируя, что такие запросы имеют правильный формат. Спецификацией, определяющей этот сетевой запрос, является протокол Web Push .

Схема отправки push-сообщения с вашего сервера в push-сервис

В этом разделе описывается, как сервер может идентифицировать себя с помощью ключей сервера приложений и как отправляются зашифрованные полезные данные и связанные с ними данные.

Это неприятная сторона веб-пуша, и я не эксперт в шифровании, но давайте рассмотрим каждую часть, поскольку полезно знать, что эти библиотеки делают под капотом.

Ключи сервера приложений

Когда мы подписываем пользователя, мы передаем applicationServerKey . Этот ключ передается службе push-уведомлений и используется для проверки того, что приложение, подписавшееся на пользователя, также является приложением, которое запускает push-сообщения.

Когда мы запускаем push-сообщение, мы отправляем набор заголовков, которые позволяют службе push-уведомлений аутентифицировать приложение. (Это определено спецификацией VAPID .)

Что все это на самом деле означает и что именно происходит? Итак, вот шаги, предпринятые для аутентификации сервера приложений:

  1. Сервер приложений подписывает некоторую информацию JSON своим закрытым ключом приложения .
  2. Эта подписанная информация отправляется в службу push в виде заголовка в запросе POST.
  3. Служба push-уведомлений использует сохраненный открытый ключ, полученный от pushManager.subscribe() , для проверки того, что полученная информация подписана закрытым ключом, относящимся к открытому ключу. Помните : открытый ключ — это applicationServerKey , передаваемый в вызов подписки.
  4. Если подписанная информация действительна, служба push-уведомлений отправляет пользователю push-сообщение.

Пример такого потока информации приведен ниже. (Обратите внимание на легенду внизу слева, обозначающую открытый и закрытый ключи.)

Иллюстрация того, как приватный ключ сервера приложений используется при отправке сообщения.

«Подписанная информация», добавленная в заголовок запроса, представляет собой веб-токен JSON.

Веб-токен JSON

Веб-токен JSON (или сокращенно JWT) — это способ отправки сообщения третьей стороне, позволяющий получателю проверить, кто его отправил.

Когда третья сторона получает сообщение, ей необходимо получить открытый ключ отправителя и использовать его для проверки подписи JWT. Если подпись действительна, то JWT должен быть подписан соответствующим закрытым ключом, поэтому он должен быть от ожидаемого отправителя.

На https://jwt.io/ есть множество библиотек, которые могут выполнить подписание за вас, и я бы рекомендовал вам делать это там, где вы можете. Для полноты давайте посмотрим, как вручную создать подписанный JWT.

Веб-пуш и подписанные JWT

Подписанный JWT — это просто строка, хотя ее можно рассматривать как три строки, соединенные точками.

Иллюстрация строк в веб-токене JSON.

Первая и вторая строки (информация JWT и данные JWT) представляют собой фрагменты JSON, закодированные в base64, что означает, что они доступны для публичного чтения.

Первая строка — это информация о самом JWT, указывающая, какой алгоритм использовался для создания подписи.

Информация JWT для веб-push должна содержать следующую информацию:

{
  "typ": "JWT",
  "alg": "ES256"
}

Вторая строка — это данные JWT. Это предоставляет информацию об отправителе JWT, для кого он предназначен и как долго он действителен.

Для веб-push данные будут иметь следующий формат:

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

Значение aud — это «аудитория», т. е. для кого предназначен JWT. Для веб-push аудиторией является служба push-уведомлений, поэтому мы устанавливаем ее в качестве источника push-службы .

Значение exp — это истечение срока действия JWT, что не позволяет злоумышленникам повторно использовать JWT, если они его перехватят. Срок действия представляет собой временную метку в секундах и не должен превышать 24 часов.

В Node.js срок действия устанавливается с помощью:

Math.floor(Date.now() / 1000) + 12 * 60 * 60;

Это 12 часов, а не 24 часа, чтобы избежать проблем с разницей в часах между отправляющим приложением и службой push.

Наконец, sub значение должно быть либо URL-адресом, либо адресом электронной почты mailto . Это сделано для того, чтобы, если службе push-уведомлений необходимо связаться с отправителем, она могла найти контактную информацию в JWT. (Вот почему библиотеке веб-push нужен адрес электронной почты).

Как и информация JWT, данные JWT кодируются как безопасная для URL-адреса строка base64.

Третья строка, подпись, является результатом объединения первых двух строк (информации JWT и данных JWT) с помощью точки, которую мы назовем «неподписанным токеном», и ее подписания.

Процесс подписи требует шифрования «неподписанного токена» с помощью ES256. Согласно спецификации JWT , ES256 — это сокращение от «ECDSA с использованием кривой P-256 и алгоритма хеширования 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);
});

Служба push-уведомлений может проверить JWT, используя общедоступный ключ сервера приложений, чтобы расшифровать подпись и убедиться, что расшифрованная строка совпадает с «неподписанным токеном» (т. е. первыми двумя строками в JWT).

Подписанный JWT (т. е. все три строки, соединенные точками) отправляется в службу веб-push в виде заголовка Authorization с добавленным WebPush , например:

Authorization: 'WebPush [JWT Info].[JWT Data].[Signature]';

Протокол Web Push также утверждает, что общедоступный ключ сервера приложений должен быть отправлен в заголовке Crypto-Key в виде безопасной для URL-адреса строки в кодировке Base64 с добавленным к ней p256ecdsa= .

Crypto-Key: p256ecdsa=[URL Safe Base64 Public Application Server Key]

Шифрование полезной нагрузки

Далее давайте посмотрим, как мы можем отправить полезную нагрузку с помощью push-сообщения, чтобы, когда наше веб-приложение получит push-сообщение, оно могло получить доступ к полученным данным.

Общий вопрос, который возникает у любого, кто использовал другие службы push-уведомлений, заключается в том, почему полезная нагрузка веб-push должна быть зашифрована? В собственных приложениях push-сообщения могут отправлять данные в виде обычного текста.

Прелесть веб-push в том, что, поскольку все службы push-уведомлений используют один и тот же API (протокол веб-push), разработчикам не нужно заботиться о том, кто является службой push-уведомлений. Мы можем сделать запрос в нужном формате и ожидать отправки push-сообщения. Обратной стороной этого является то, что разработчики могут отправлять сообщения в ненадежную службу push-уведомлений. Зашифровав полезную нагрузку, служба push-уведомлений не может прочитать отправленные данные. Только браузер может расшифровать информацию. Это защищает данные пользователя.

Шифрование полезных данных определено в спецификации шифрования сообщений .

Прежде чем мы рассмотрим конкретные шаги по шифрованию полезных данных push-сообщений, нам следует рассмотреть некоторые методы, которые будут использоваться в процессе шифрования. (Огромный совет Мэту Скейлзу за его великолепную статью о push-шифровании.)

ECDH и HKDF

И ECDH, и HKDF используются на протяжении всего процесса шифрования и предлагают преимущества для шифрования информации.

ECDH: обмен ключами Диффи-Хеллмана на основе эллиптической кривой

Представьте, что у вас есть два человека, которые хотят поделиться информацией: Алиса и Боб. И Алиса, и Боб имеют свои собственные открытый и закрытый ключи. Алиса и Боб делятся своими открытыми ключами друг с другом.

Полезное свойство ключей, сгенерированных с помощью ECDH, заключается в том, что Алиса может использовать свой закрытый ключ и открытый ключ Боба для создания секретного значения «X». Боб может сделать то же самое, взяв свой закрытый ключ и открытый ключ Алисы, чтобы независимо создать одно и то же значение «X». Это делает «X» общим секретом, и Алисе и Бобу нужно было только поделиться своим открытым ключом. Теперь Боб и Алиса могут использовать «X» для шифрования и дешифрования сообщений между собой.

ECDH, насколько мне известно, определяет свойства кривых, которые позволяют использовать эту «особенность» создания общего секрета «X».

Это общее объяснение ECDH. Если вы хотите узнать больше , я рекомендую посмотреть это видео .

С точки зрения кода; большинство языков/платформ поставляются с библиотеками, упрощающими создание этих ключей.

В узле мы сделаем следующее:

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

const publicKey = keyCurve.getPublicKey();
const privateKey = keyCurve.getPrivateKey();

HKDF: функция получения ключей на основе HMAC.

В Википедии есть краткое описание HKDF :

HKDF — это функция получения ключей на основе HMAC, которая преобразует любой слабый ключевой материал в криптографически стойкий ключевой материал. Его можно использовать, например, для преобразования общих секретов, которыми обменивались Диффи Хеллман, в ключевой материал, пригодный для использования в шифровании, проверке целостности или аутентификации.

По сути, 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);
}

Подсказка к статье Мэта Скейла об этом примере кода .

Это в общих чертах охватывает ECDH и HKDF .

ECDH — безопасный способ обмена открытыми ключами и создания общего секрета. HKDF — это способ взять небезопасный материал и сделать его безопасным.

Это будет использоваться во время шифрования нашей полезной нагрузки. Далее давайте посмотрим, что мы принимаем в качестве входных данных и как это шифруется.

Входы

Когда мы хотим отправить пользователю push-сообщение с полезной нагрузкой, нам нужны три входа:

  1. Сама полезная нагрузка.
  2. Секрет auth из PushSubscription .
  3. Ключ p256dh из PushSubscription .

Мы видели значения auth и p256dh , полученные из PushSubscription , но для быстрого напоминания: для подписки нам потребуются эти значения:

subscription.toJSON().keys.auth;
subscription.toJSON().keys.p256dh;

subscription.getKey('auth');
subscription.getKey('p256dh');

Значение auth должно рассматриваться как секрет и не передаваться за пределы вашего приложения.

Ключ p256dh является открытым ключом, его иногда называют открытым ключом клиента. Здесь мы будем называть p256dh открытым ключом подписки. Открытый ключ подписки генерируется браузером. Браузер будет хранить закрытый ключ в секрете и использовать его для расшифровки полезных данных.

Эти три значения: auth , p256dh и payload необходимы в качестве входных данных, а результатом процесса шифрования будут зашифрованные полезные данные, значение соли и открытый ключ, используемый только для шифрования данных.

Соль

Соль должна состоять из 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();

Мы будем называть эти ключи «локальными ключами». Они используются только для шифрования и не имеют ничего общего с ключами сервера приложений.

Имея в качестве входных данных полезную нагрузку, секрет аутентификации и открытый ключ подписки, а также вновь сгенерированную соль и набор локальных ключей, мы готовы фактически выполнить некоторое шифрование.

Общий секрет

Первым шагом является создание общего секрета с использованием открытого ключа подписки и нашего нового закрытого ключа (помните объяснение 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 (т. е. делает его криптографически более надежным).

Контекст

«Контекст» — это набор байтов, который позже используется для вычисления двух значений в браузере шифрования. По сути, это массив байтов, содержащий открытый ключ подписки и локальный открытый ключ.

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,
]);

Последний буфер контекста представляет собой метку, количество байтов в открытом ключе подписки, за которым следует сам ключ, затем количество байтов в локальном открытом ключе, а затем сам ключ.

С помощью этого значения контекста мы можем использовать его при создании одноразового номера и ключа шифрования контента (CEK).

Ключ шифрования контента и одноразовый номер

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 — это вектор инициализации.

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

Теперь у нас есть зашифрованная полезная нагрузка. Ура!

Осталось только определить, как эта полезная нагрузка будет отправлена ​​в push-сервис.

Зашифрованные заголовки и тело полезной нагрузки

Чтобы отправить эту зашифрованную полезную нагрузку в службу push, нам нужно определить несколько разных заголовков в нашем запросе POST.

Заголовок шифрования

Заголовок «Шифрование» должен содержать соль , используемую для шифрования полезных данных.

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/сервера приложений (т. е. как идентифицировать приложение с помощью службы push), а также заголовки, используемые для отправки зашифрованных полезных данных.

Существуют дополнительные заголовки, которые службы push-уведомлений используют для изменения поведения отправляемых сообщений. Некоторые из этих заголовков являются обязательными, а другие необязательными.

TTL-заголовок

Необходимый

TTL (или время жизни) — это целое число, указывающее количество секунд, в течение которых ваше push-сообщение будет находиться в службе push-сообщений до его доставки. По истечении срока TTL сообщение будет удалено из очереди службы push-уведомлений и не будет доставлено.

TTL: [Time to live in seconds]

Если вы установите значение TTL равное нулю, служба push-уведомлений попытается доставить сообщение немедленно, но если устройство не может быть достигнуто, ваше сообщение будет немедленно удалено из очереди службы push-уведомлений.

Технически служба push-уведомлений может уменьшить TTL push-сообщения, если захочет. Вы можете определить, произошло ли это, проверив заголовок TTL в ответе службы push-уведомлений.

Тема

Необязательный

Темы — это строки, которые можно использовать для замены ожидающих сообщений новым сообщением, если они имеют совпадающие имена тем.

Это полезно в сценариях, когда несколько сообщений отправляются, когда устройство находится в автономном режиме, и вы действительно хотите, чтобы пользователь видел последнее сообщение только при включении устройства.

Острая необходимость

Необязательный

Срочность указывает службе push-уведомлений, насколько важно сообщение для пользователя. Это может использоваться службой push-уведомлений, чтобы продлить срок службы батареи устройства пользователя, просыпаясь только для важных сообщений при низком заряде батареи.

Значение заголовка определяется, как показано ниже. Значение по умолчанию — normal .

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

Все вместе

Если у вас есть дополнительные вопросы о том, как все это работает, вы всегда можете посмотреть, как библиотеки запускают push-сообщения на сайте web-push-libs org .

Если у вас есть зашифрованная полезная нагрузка и приведенные выше заголовки, вам просто нужно сделать запрос POST к endpoint в PushSubscription .

Итак, что нам делать с ответом на этот POST-запрос?

Ответ от push-сервиса

После того, как вы отправили запрос в службу push-уведомлений, вам необходимо проверить код состояния ответа, поскольку он скажет вам, был ли запрос успешным или нет.

Код состояния Описание
201 Созданный. Запрос на отправку push-сообщения был получен и принят.
429 Слишком много запросов. Это означает, что ваш сервер приложений достиг ограничения скорости с помощью службы push. Служба push-уведомлений должна включать заголовок Retry-After, указывающий, через какое время можно будет сделать следующий запрос.
400 Неверный запрос. Обычно это означает, что один из ваших заголовков недействителен или неправильно отформатирован.
404 Не найдено. Это указывает на то, что срок действия подписки истек и ее нельзя использовать. В этом случае вам следует удалить PushSubscription и дождаться, пока клиент повторно подпишет пользователя.
410 Ушел. Подписка больше не действительна и должна быть удалена с сервера приложений. Это можно воспроизвести, вызвав unsubscribe() для PushSubscription.
413 Размер полезной нагрузки слишком велик. Минимальный размер полезных данных, которые должна поддерживать служба push-уведомлений, составляет 4096 байт (или 4 КБ).

Куда идти дальше

Лаборатории кода