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

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

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

,

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

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

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

,

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

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

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

,

Mat Scales

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

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

Шифрование полезных нагрузок является важной частью истории безопасности для Web Push. HTTPS обеспечивает вам безопасность при общении между браузером и вашим собственным сервером, потому что вы доверяете серверу. Тем не менее, браузер выбирает, какой поставщик Push будет использоваться для фактического обеспечения полезной нагрузки, поэтому вы, как разработчик приложений, не контролируете ее.

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

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

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

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

> 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, в ближайшее время появится больше языков и платформ. Это обрабатывает как шифрование, так и протокол Web Push, так что отправка push -сообщения с сервера Node.js так же просто, как webpush.sendWebPush(message, subscription) .

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

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

Входные данные

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

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

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

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

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

ECDH

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

Это является основой протокола соглашения о ключе от эллиптической кривой Diffie-Hellman (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);

HKDF

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

Одним из следствий того, как он работает, является то, что он позволяет вам взять секрет любого количества битов и производить еще один секрет любого размера в 255 раз, чем хэш, созданный любым алгоритмом хеширования, который вы используете. Для толчка спецификация требует, чтобы мы использовали SHA-256, который имеет длину хэша 32 байта (256 бит).

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

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

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

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

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

Тип информации для шифрования контента - «aesgcm», который является названием шифра, используемого для шифрования Push.

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

Прокладка

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

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

По этой причине протокол 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> являются общедоступным ключом Salt and Server, используемым в шифровании, закодированном как Base64, защищаемый URL.

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

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

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

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

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

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