Мы рассмотрели, как можно использовать библиотеку для запуска push-уведомлений, но что именно делают эти библиотеки?
Они отправляют сетевые запросы, одновременно проверяя их правильный формат. Спецификация, определяющая этот сетевой запрос, — это протокол Web Push Protocol .
В этом разделе описывается, как сервер может идентифицировать себя с помощью ключей сервера приложений, а также как отправляется зашифрованная полезная нагрузка и связанные с ней данные.
Это не самая приятная сторона веб-push-уведомлений, и я не эксперт в области шифрования, но давайте рассмотрим каждый компонент, поскольку полезно знать, что эти библиотеки делают «под капотом».
ключи сервера приложений
При подписке пользователя мы передаем applicationServerKey . Этот ключ передается в службу push-уведомлений и используется для проверки того, что приложение, подписавшее пользователя, также является приложением, запускающим отправку push-уведомлений.
При отправке push-уведомления мы передаем набор заголовков, позволяющих службе push-уведомлений аутентифицировать приложение. (Это определено спецификацией VAPID .)
Что всё это значит на самом деле и что именно происходит? Вот шаги, которые выполняются для аутентификации на сервере приложений:
- Сервер приложений подписывает некоторую информацию в формате JSON своим закрытым ключом приложения .
- Эта подписанная информация отправляется в службу push-уведомлений в качестве заголовка POST-запроса.
- Сервис push-уведомлений использует сохраненный открытый ключ, полученный из
pushManager.subscribe()для проверки того, что полученная информация подписана закрытым ключом, связанным с открытым ключом. Помните : открытый ключ — этоapplicationServerKeyпереданный в вызов функции подписки. - Если подписанная информация действительна, служба push-уведомлений отправляет сообщение пользователю.
Пример такого потока информации приведен ниже. (Обратите внимание на пояснения в левом нижнем углу, указывающие на открытые и закрытые ключи.)
"Подписанная информация", добавляемая в заголовок запроса, представляет собой JSON Web Token.
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;
Вместо 24 часов используется 12-часовой интервал, чтобы избежать проблем, связанных с разницей во времени между отправляющим приложением и службой push-уведомлений.
Наконец, в качестве значения sub необходимо указать либо URL-адрес, либо адрес электронной почты mailto . Это необходимо для того, чтобы служба push-уведомлений могла связаться с отправителем и получить контактную информацию из JWT. (Именно поэтому библиотеке web-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-уведомлений заключается в том, что, поскольку все сервисы используют один и тот же API (протокол веб-push), разработчикам не нужно беспокоиться о том, какой именно сервис отправляет уведомления. Мы можем отправить запрос в правильном формате и ожидать получения push-сообщения. Недостаток этого подхода заключается в том, что разработчики потенциально могут отправлять сообщения сервису, которому нельзя доверять. Шифрование полезной нагрузки не позволяет сервису прочитать отправляемые данные. Расшифровать информацию может только браузер. Это защищает данные пользователя.
Шифрование полезной нагрузки определяется в спецификации шифрования сообщений .
Прежде чем рассматривать конкретные шаги по шифрованию содержимого push-сообщений, следует рассмотреть некоторые методы, которые будут использоваться в процессе шифрования. (Огромная благодарность Мэту Скейлзу за его превосходную статью о шифровании push-сообщений.)
ECDH и HKDF
Как 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 в веб-приложениях не должны быть длиннее 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);
}
Благодарим Mat Scale за предоставленный пример кода в своей статье .
Это в общих чертах охватывает 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 необходимы в качестве входных данных, а результатом процесса шифрования будет зашифрованная полезная нагрузка, значение соли и открытый ключ, используемый только для шифрования данных.
Соль
Соль должна представлять собой 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) — это комбинация секретного ключа аутентификации подписки на 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,
]);
В итоговом контекстном буфере содержится метка, количество байтов в открытом ключе подписки, за которым следует сам ключ, затем количество байтов в локальном открытом ключе, а затем сам ключ.
Используя это значение контекста, мы можем создать одноразовый код (nonce) и ключ шифрования содержимого (CEK).
Ключ шифрования содержимого и одноразовый код (NONC)
Значение 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-запросе.
Заголовок шифрования
Заголовок 'Encryption' должен содержать соль, использованную для шифрования полезной нагрузки.
16-байтовая соль должна быть закодирована в формате base64 для обеспечения безопасности URL-адресов и добавлена в заголовок Encryption следующим образом:
Encryption: salt=[URL Safe Base64 Encoded Salt]
Заголовок Crypto-Key
Мы обнаружили, что заголовок 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-уведомлений), а также заголовки, используемые для отправки зашифрованных данных.
Существуют дополнительные заголовки, которые используют службы отправки для изменения поведения отправляемых сообщений. Некоторые из этих заголовков являются обязательными, а другие — необязательными.
заголовок 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
- Регистрация пользователя
- Пользовательский интерфейс разрешений
- Отправка сообщений с помощью библиотек веб-push-уведомлений
- Протокол веб-push
- Обработка событий Push
- Отображение уведомления
- Поведение при получении уведомлений
- Типичные шаблоны уведомлений
- Часто задаваемые вопросы о push-уведомлениях
- Распространенные проблемы и сообщения об ошибках