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

Mat Scales

До Chrome 50 push-сообщения не могли содержать никаких полезных данных. Когда в вашем сервис-воркере сработало событие «push» , все, что вы знали, это то, что сервер пытается вам что-то сказать, но не то, что это может быть. Затем вам нужно было сделать последующий запрос на сервер и получить подробную информацию об уведомлении для отображения, что могло привести к сбою в плохих условиях сети.

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

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

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

Изменения на стороне клиента

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

Во-первых, когда вы отправляете информацию о подписке на свой внутренний сервер, вам необходимо собрать некоторую дополнительную информацию. Если вы уже используете JSON.stringify() для объекта PushSubscription для его сериализации для отправки на ваш сервер, вам не нужно ничего менять. Теперь подписка будет содержать некоторые дополнительные данные в свойстве ключей.

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

Два значения p256dh и auth закодированы в варианте Base64, который я назову URL-Safe Base64 .

Если вместо этого вы хотите получить право на байты, вы можете использовать новый метод getKey() в подписке, который возвращает параметр в виде ArrayBuffer . Вам понадобятся два параметра: auth и p256dh .

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

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

Второе изменение — это новое свойство данных при срабатывании события push . Он имеет различные синхронные методы для анализа полученных данных, такие как .text() , .json() , .arrayBuffer() и .blob() .

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

Изменения на стороне сервера

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

Детали относительно сложны, и, как и в случае со всем, что связано с шифрованием, лучше использовать активно разрабатываемую библиотеку, чем создавать собственную. Команда Chrome опубликовала библиотеку для Node.js, в ближайшее время появятся новые языки и платформы. Он обрабатывает как шифрование, так и протокол веб-push, поэтому отправка push-сообщения с сервера Node.js осуществляется так же просто, как webpush.sendWebPush(message, subscription) .

Хотя мы определенно рекомендуем использовать библиотеку, это новая функция, и во многих популярных языках еще нет библиотек. Если вам нужно реализовать это самостоятельно, вот подробности.

Я буду иллюстрировать алгоритмы с использованием JavaScript со вкусом Node, но основные принципы должны быть одинаковыми для любого языка.

Входы

Чтобы зашифровать сообщение, нам сначала нужно получить две вещи из объекта подписки, который мы получили от клиента. Если вы использовали JSON.stringify() на клиенте и передали его на свой сервер, то открытый ключ клиента хранится в keys.p256dh , а общий секрет аутентификации — в keys.auth . Оба они будут закодированы в формате Base64, безопасные для URL-адресов, как упоминалось выше. Двоичный формат открытого ключа клиента представляет собой несжатую точку эллиптической кривой P-256.

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

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

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

Нам также необходимо сгенерировать некоторые новые данные. Нам нужна 16-байтовая криптографически безопасная случайная соль и пара открытых/частных ключей эллиптической кривой . Конкретная кривая, используемая в спецификации push-шифрования, называется P-256 или prime256v1 . Для обеспечения максимальной безопасности пара ключей должна создаваться с нуля каждый раз, когда вы шифруете сообщение, и никогда не следует повторно использовать соль.

ECDH

Давайте отойдем немного в сторону и поговорим об одном замечательном свойстве криптографии на эллиптических кривых. Существует относительно простой процесс, который объединяет ваш закрытый ключ с чужим открытым ключом для получения значения. Ну и что? Что ж, если другая сторона заберет свой закрытый ключ и ваш открытый ключ, она получит одно и то же значение!

Это основа протокола соглашения о ключах Эллиптической кривой Диффи-Хеллмана (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 раз длиннее хэша, созданного любым используемым вами алгоритмом хеширования. Для отправки спецификация требует, чтобы мы использовали SHA-256, длина хеша которого составляет 32 байта (256 бит).

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

Ниже я включил код версии Node, но вы можете узнать, как он на самом деле работает, в RFC 5869 .

Входными данными для HKDF являются соль, некоторый исходный ключевой материал (ikm), необязательная часть структурированных данных, специфичных для текущего варианта использования (info), и длина желаемого выходного ключа в байтах.

// 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.

Теперь мы создаем окончательный ключ шифрования контента и одноразовый номер , который будет передан в шифр. Они создаются путем создания для каждого простой структуры данных, называемой в спецификации информацией, которая содержит информацию, специфичную для эллиптической кривой, отправителя и получателя информации, для дальнейшей проверки источника сообщения. Затем мы используем HKDF с PRK, нашей солью и информацией, чтобы получить ключ и одноразовый номер правильного размера.

Тип информации для шифрования контента — «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);

Заполнение

Еще одно отступление, и время для глупого и надуманного примера. Допустим, у вашего начальника есть сервер, который каждые несколько минут отправляет ему push-сообщение с ценой акций компании. Простое сообщение для этого всегда будет 32-битным целым числом со значением в центах. У нее также есть хитрая сделка с персоналом общественного питания, что означает, что они могут отправить ей строку «пончики в комнате отдыха» за 5 минут до того, как они будут доставлены, чтобы она могла «случайно» оказаться там, когда они придут, и взять лучший. .

Шифр, используемый Web Push, создает зашифрованные значения, которые ровно на 16 байт длиннее незашифрованных входных данных. Поскольку «пончики в комнате отдыха» длиннее, чем 32-битная цена акций, любой шпионящий сотрудник сможет определить, когда прибудут пончики, без расшифровки сообщений, просто по длине данных.

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

Значение заполнения представляет собой 16-битное целое число с прямым порядком байтов, определяющее длину заполнения, за которым следует это количество NUL байтов заполнения. Таким образом, минимальное заполнение составляет два байта — число ноль, закодированное в 16 бит.

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

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

Шифрование

Теперь у нас наконец-то есть все необходимое для шифрования. Для Web Push требуется шифр AES128 с использованием GCM . Мы используем наш ключ шифрования контента в качестве ключа, а nonce — в качестве вектора инициализации (IV).

В этом примере наши данные представляют собой строку, но это могут быть любые двоичные данные. Вы можете отправлять полезные данные размером до 4078 байт — максимум 4096 байт на сообщение, с 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> — это соль и открытый ключ сервера, используемые при шифровании, закодированные как безопасный для URL-адресов Base64.

При использовании протокола Web Push тело POST представляет собой просто необработанные байты зашифрованного сообщения. Однако до тех пор, пока Chrome и Firebase Cloud Messaging не поддержат этот протокол, вы можете легко включить данные в существующую полезную нагрузку JSON следующим образом.

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

Значение свойства rawData должно быть представлением зашифрованного сообщения в кодировке Base64.

Отладка/проверка

Питер Беверлоо, один из инженеров Chrome, реализовавших эту функцию (а также один из людей, работавших над спецификацией), создал верификатор .

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