Мы видели, как библиотека может использоваться для запуска push-сообщений, но что именно делают эти библиотеки?
Они отправляют сетевые запросы, обеспечивая их правильный формат. Спецификация, определяющая этот сетевой запрос, – это протокол Web Push.
В этом разделе рассказывается, как сервер может идентифицировать себя с помощью ключей сервера приложений и как отправляются зашифрованные полезные данные и связанные с ними данные.
Это не самая приятная сторона веб-пушей, и я не эксперт в шифровании, но давайте рассмотрим каждую часть, поскольку полезно знать, что эти библиотеки делают под капотом.
Ключи сервера приложений
При оформлении подписки мы передаем applicationServerKey. Этот ключ передается в сервис push-уведомлений и используется для проверки того, что приложение, которое подписало пользователя, также является приложением, которое запускает push-сообщения.
Когда мы инициируем push-сообщение, мы отправляем набор заголовков, которые позволяют службе push-уведомлений аутентифицировать приложение. (определяется спецификацией VAPID).
Что это значит и что именно происходит? Вот шаги, предпринятые для аутентификации сервера приложений:
- Сервер приложений подписывает некоторые данные JSON с помощью закрытого ключа приложения.
- Эта подписанная информация отправляется в сервис push-уведомлений в виде заголовка в запросе POST.
- Сервис push-уведомлений использует сохраненный открытый ключ, полученный от
pushManager.subscribe(), чтобы проверить, подписана ли полученная информация закрытым ключом, связанным с открытым ключом. Помните, что открытый ключ – этоapplicationServerKey, передаваемый в вызов subscribe. - Если подписанная информация действительна, сервис отправляет push-уведомление пользователю.
Ниже приведен пример такого потока информации. (Обратите внимание на легенду в левом нижнем углу, указывающую на открытые и закрытые ключи.)
"Подписанная информация", добавленная в заголовок запроса, – это веб-токен JSON.
Веб-токен JSON
Веб-токен JSON (JWT) – это способ отправки сообщения третьей стороне, позволяющий получателю проверить, кто его отправил.
Когда третья сторона получает сообщение, ей необходимо получить открытый ключ отправителя и использовать его для проверки подписи JWT. Если подпись действительна, то JWT был подписан соответствующим закрытым ключом и, следовательно, должен быть от ожидаемого отправителя.
На сайте jwt.io/ есть множество библиотек, которые могут выполнить подписание за вас. Рекомендуем использовать их, когда это возможно. Для полноты картины давайте посмотрим, как вручную создать подписанный JWT.
Веб-push-уведомления и подписанные JWT
Подписанный 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;
Это позволяет избежать проблем, связанных с разницей во времени между приложением-отправителем и сервисом push-уведомлений.
Значение sub должно быть URL или адресом электронной почты mailto.
Это необходимо, чтобы сервис push-уведомлений мог связаться с отправителем, используя контактную информацию из JWT. (именно поэтому библиотеке web-push нужен адрес электронной почты).
Как и информация JWT, данные JWT закодированы в виде строки base64, безопасной для URL.
Третья строка – это подпись, которая получается в результате объединения первых двух строк (информации и данных JWT) с помощью точки. Полученную строку мы будем называть "неподписанным токеном".
Для подписания токена необходимо зашифровать его с помощью алгоритма ES256. Согласно спецификации JWT, ES256 – это сокращение от "ECDSA с использованием кривой P-256 и алгоритма хеширования SHA-256". С помощью Web Crypto API можно создать подпись следующим образом:
// 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 в виде строки, закодированной по стандарту Base64, с префиксом p256ecdsa=.
Crypto-Key: p256ecdsa=[URL Safe Base64 Public Application Server Key]
Шифрование полезной нагрузки
Теперь давайте посмотрим, как мы можем отправить полезную нагрузку с push-сообщением, чтобы, когда наше веб-приложение получает push-сообщение, оно могло получить доступ к получаемым данным.
У тех, кто пользовался другими сервисами push-уведомлений, часто возникает вопрос: зачем шифровать полезную нагрузку веб-уведомлений? В нативных приложениях push-сообщения могут отправлять данные в виде обычного текста.
Одно из преимуществ веб-push-уведомлений заключается в том, что все push-сервисы используют один и тот же API (протокол веб-push-уведомлений), поэтому разработчикам не нужно беспокоиться о том, какой именно push-сервис используется. Мы можем сделать запрос в правильном формате и ожидать отправки push-сообщения. Недостаток такого подхода в том, что разработчики могут отправлять сообщения в ненадежный сервис push-уведомлений. Благодаря шифрованию полезной нагрузки сервис push-уведомлений не может прочитать отправленные данные. Расшифровать информацию может только браузер. Это защищает данные пользователя.
Шифрование полезной нагрузки определяется спецификацией шифрования сообщений.
Прежде чем мы рассмотрим конкретные шаги по шифрованию полезной нагрузки push-сообщений, мы должны рассмотреть некоторые методы, которые будут использоваться в процессе шифрования. (Огромное спасибо Мэту Скейлсу за его отличную статью о шифровании push-уведомлений.)
ECDH и HKDF
Оба протокола используются в процессе шифрования и имеют свои преимущества.
ECDH: обмен ключами по протоколу Диффи – Хеллмана на эллиптических кривых.
Представьте, что у вас есть два человека, которые хотят поделиться информацией, Алиса и Боб. У Алисы и Боба есть свои открытые и закрытые ключи. Алиса и Борис обмениваются открытыми ключами.
Полезное свойство ключей, сгенерированных с помощью ECDH, заключается в том, что Алиса может использовать свой закрытый ключ и открытый ключ Боба для создания секретного значения X. Боб может сделать то же самое, взяв свой закрытый ключ и открытый ключ Алисы, чтобы независимо создать то же значение "X". Это делает X общим секретом, и Алисе и Бобу нужно было только поделиться своим открытым ключом. Теперь Борис и Алиса могут использовать X для шифрования и расшифровки сообщений.
Насколько я знаю, ECDH определяет свойства кривых, которые позволяют создавать общий секретный ключ X.
Это общее описание ECDH. Если вы хотите узнать больше, рекомендую посмотреть видео с подробным обзором ECDH.
Что касается кода, то в большинстве языков и платформ есть библиотеки, которые упрощают создание таких ключей.
В Node.js мы бы сделали следующее:
const keyCurve = crypto.createECDH('prime256v1');
keyCurve.generateKeys();
const publicKey = keyCurve.getPublicKey();
const privateKey = keyCurve.getPrivateKey();
HKDF: функция формирования ключа на основе HMAC
В Википедии есть краткое описание HKDF:
HKDF – это функция формирования ключа на основе HMAC, которая преобразует любой слабый ключевой материал в криптографически сильный. Например, с ее помощью можно преобразовать общие секреты, которыми обмениваются по протоколу Диффи – Хеллмана, в ключевой материал, подходящий для шифрования, проверки целостности или аутентификации.
По сути, HKDF берет не очень безопасные входные данные и делает их более безопасными.
Спецификация, определяющая это шифрование, требует использования SHA-256 в качестве алгоритма хеширования, а результирующие ключи для HKDF в веб-push-уведомлениях должны быть не длиннее 256 бит (32 байта).
В Node.js это можно реализовать следующим образом:
// 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-уведомление с полезной нагрузкой, нам нужны три входных параметра:
- Сама полезная нагрузка.
- Секрет
authизPushSubscription. - Ключ
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) необходимы в качестве входных данных, а результатом процесса шифрования будет зашифрованная полезная нагрузка, значение соли и открытый ключ, используемый только для шифрования данных.
Salt
Соль должна состоять из 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();
Мы будем называть их локальными ключами. Они используются только для шифрования и не имеют отношения к ключам сервера приложений.
Имея полезную нагрузку, секрет аутентификации и открытый ключ подписки в качестве входных данных, а также вновь сгенерированную соль и набор локальных ключей, мы готовы выполнить шифрование.
Общий секрет
Сначала нужно создать общий секретный ключ, используя открытый ключ подписки и наш новый закрытый ключ (помните пример с Алисой и Бобом?). Вот так).
const sharedSecret = localKeysCurve.computeSecret(
subscription.keys.p256dh,
'base64',
);
Он используется на следующем шаге для вычисления псевдослучайного ключа (PRK).
Псевдослучайный ключ
Псевдослучайный ключ (PRK) – это комбинация секрета аутентификации подписки на push-уведомления и общего секрета, который мы только что создали.
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).
Ключ шифрования контента и однократно используемое число
Одноразовый код – это значение, которое используется только один раз и позволяет предотвратить атаки повторного воспроизведения.
Ключ шифрования контента (CEK) – это ключ, который в конечном итоге будет использоваться для шифрования полезной нагрузки.
Сначала нужно создать байты данных для nonce и ключа шифрования контента. Это просто строка кодирования контента, за которой следует буфер контекста, который мы только что рассчитали:
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, используя ключ шифрования контента в качестве ключа, а однократно используемое число – в качестве вектора инициализации.
В 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.
Заголовок шифрования
Заголовок Encryption должен содержать соль, используемую для шифрования полезной нагрузки.
16-байтовая соль должна быть закодирована в формате Base64 URL Safe и добавлена в заголовок Encryption следующим образом:
Encryption: salt=[URL Safe Base64 Encoded Salt]
Заголовок Crypto-Key
Мы видели, что заголовок Crypto-Key используется в разделе Application Server Keys (Ключи сервера приложений) и содержит открытый ключ сервера приложений.
Этот заголовок также используется для передачи локального открытого ключа, используемого для шифрования полезной нагрузки.
Заголовок будет выглядеть так:
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, если это необходимо. Вы можете узнать, произошло ли это, проверив заголовок 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 КБ). |
Дополнительную информацию о кодах статуса HTTP можно найти в стандарте Web Push (RFC8030).
Куда отправиться дальше
- Общие сведения о push-уведомлениях из веб-приложений
- Как работает push-уведомление
- Как подписать пользователя
- Permission UX
- Отправка сообщений с помощью библиотек Web Push
- Протокол веб-пуш-уведомлений
- Обработка событий push-уведомлений
- Показ уведомления
- Поведение уведомлений
- Распространенные шаблоны уведомлений
- Часто задаваемые вопросы о push-уведомлениях
- Распространенные проблемы и сообщения об ошибках