Criptografia de payload de push na Web

Balanças de tapetes

Antes do Chrome 50, as mensagens push não podiam conter dados de payload. Quando o evento "push" disparado no service worker, você só sabia que o servidor estava tentando informar alguma coisa, mas não o que poderia ser. Em seguida, foi necessário fazer uma solicitação de acompanhamento ao servidor e receber os detalhes da notificação para serem exibidos, o que pode falhar em condições de rede ruins.

Agora, no Chrome 50 (e na versão atual do Firefox para computador), é possível enviar alguns dados arbitrários junto com o push para que o cliente possa evitar fazer a solicitação extra. No entanto, com grandes poderes vêm grandes responsabilidades. Portanto, todos os dados de payload precisam ser criptografados.

A criptografia de payloads é uma parte importante da história de segurança para push na Web. O HTTPS oferece segurança na comunicação entre o navegador e seu próprio servidor porque você confia no servidor. No entanto, o navegador escolhe qual provedor de push será usado para entregar o payload. Assim, você, como desenvolvedor do app, não tem controle sobre ele.

Nesse caso, o HTTPS só garante que ninguém possa espionar a mensagem em trânsito para o provedor de serviços de push. Depois de recebê-lo, eles ficam livres para fazer o que quiserem, incluindo a retransmissão do payload a terceiros ou a alteração maliciosa para outra coisa. Para evitar isso, usamos criptografia para garantir que os serviços de push não possam ler ou adulterar os payloads em trânsito.

Alterações do lado do cliente

Se você já implementou notificações push sem payloads, há apenas duas pequenas alterações a fazer no lado do cliente.

Primeiro, quando você envia as informações de assinatura para o servidor de back-end, precisa coletar algumas informações extras. Se você já usa JSON.stringify() no objeto PushSubscription para serializá-lo e enviá-lo ao seu servidor, não é necessário mudar nada. A assinatura agora terá alguns dados extras na propriedade das chaves.

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

Os dois valores p256dh e auth são codificados em uma variante de Base64, que chamaremos de Base64 seguro para URL.

Para acessar diretamente os bytes, use o novo método getKey() na assinatura que retorna um parâmetro como uma ArrayBuffer. Os dois parâmetros necessários são auth e p256dh.

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

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

A segunda mudança é uma nova propriedade data quando o evento push é disparado. Ele tem vários métodos síncronos para analisar os dados recebidos, como .text(), .json(), .arrayBuffer() e .blob().

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

Mudanças no servidor

No lado do servidor, as coisas mudam um pouco mais. O processo básico é usar as informações da chave de criptografia recebidas do cliente para criptografar o payload e enviá-las como o corpo de uma solicitação POST para o endpoint na assinatura, adicionando alguns cabeçalhos HTTP extras.

Os detalhes são relativamente complexos e, assim como em qualquer assunto relacionado à criptografia, é melhor usar uma biblioteca desenvolvida ativamente do que criar uma própria. A equipe do Chrome publicou uma biblioteca para Node.js, com mais linguagens e plataformas em breve. Ele processa a criptografia e o protocolo de push da Web para que o envio de uma mensagem push de um servidor Node.js seja tão fácil quanto webpush.sendWebPush(message, subscription).

Embora seja altamente recomendável usar uma biblioteca, esse é um recurso novo e há muitas linguagens conhecidas que ainda não têm nenhuma biblioteca. Se você precisar implementar isso por conta própria, veja os detalhes a seguir.

Vou ilustrar os algoritmos usando JavaScript com sabor de nó, mas os princípios básicos devem ser os mesmos em qualquer linguagem.

Entradas

Para criptografar uma mensagem, primeiro precisamos receber dois itens do objeto de assinatura que recebemos do cliente. Se você usou JSON.stringify() no cliente e transmitiu isso para seu servidor, a chave pública do cliente será armazenada no campo keys.p256dh, enquanto a chave secreta de autenticação compartilhada estará no campo keys.auth. Ambos serão codificados em Base64 com segurança para URL, conforme mencionado acima. O formato binário da chave pública do cliente é um ponto de curva elíptica P-256 não compactado.

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

Com a chave pública, criptografamos a mensagem de modo que ela só possa ser descriptografada usando a chave privada do cliente.

As chaves públicas geralmente são consideradas públicas. Portanto, para permitir que o cliente autentique que a mensagem foi enviada por um servidor confiável, também usamos a chave secreta de autenticação. É claro que isso precisa ser mantido em segredo, compartilhado apenas com o servidor de aplicativos para o qual você quer enviar mensagens e tratado como uma senha.

Também precisamos gerar alguns dados novos. Precisamos de um sal aleatório de 16 bytes com segurança criptográfica e um par público/privado de chaves de curva elíptica. A curva específica usada pela especificação de criptografia push é chamada de P-256 ou prime256v1. Para a melhor segurança, o par de chaves deve ser gerado do zero sempre que você criptografar uma mensagem. Nunca reutilize um sal.

ECDH

Vamos de lado um pouco para falar sobre uma propriedade interessante da criptografia de curva elíptica. Existe um processo relativamente simples que combina sua chave privada com a chave pública de outra pessoa para derivar um valor. E o que isso significa? Se a outra parte usa a chave privada e sua chave pública, ela deriva exatamente o mesmo valor.

Essa é a base do protocolo de acordo de chaves Diffie-Hellman (ECDH) de curva elíptica, que permite que ambas as partes tenham o mesmo segredo compartilhado, mesmo que só tenham trocado chaves públicas. Usaremos essa chave secreta compartilhada como base para a nossa chave de criptografia.

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

Já é hora de outra reserva. Digamos que você tenha alguns dados secretos que quer usar como chave de criptografia, mas eles não são criptograficamente seguros o suficiente. É possível usar a Função de derivação de chaves (HKDF, na sigla em inglês) baseada em HMAC para transformar um secret de baixa segurança em um de alta segurança.

Uma consequência da maneira como funciona é que ele permite que você pegue um secret de qualquer número de bits e produza outro de qualquer tamanho até 255 vezes, com um hash produzido por qualquer algoritmo de hash usado. Para push, a especificação exige o uso do SHA-256, que tem um comprimento de hash de 32 bytes (256 bits).

Sabemos que só precisamos gerar chaves de até 32 bytes. Isso significa que podemos usar uma versão simplificada do algoritmo que não consegue lidar com tamanhos de saída maiores.

Incluí o código para uma versão do Node abaixo, mas você pode descobrir como ela realmente funciona na RFC 5869 (link em inglês).

As entradas da HKDF são um sal, algum material de chave inicial (ikm), uma parte opcional de dados estruturados específicos do caso de uso atual (informações) e o tamanho em bytes da chave de saída desejada.

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

Como derivar os parâmetros de criptografia

Agora usamos o HKDF para transformar os dados que temos nos parâmetros da criptografia real.

A primeira coisa que fazemos é usar a HKDF para misturar a chave secreta de autenticação do cliente e a chave secreta compartilhada em uma chave mais longa e segura criptograficamente. Na especificação, isso é chamado de chave pseudo-aleatória (PRK, na sigla em inglês), então é o que vou chamá-la aqui, embora os puristas de criptografia possam notar que isso não é estritamente um PRK.

Agora vamos criar a chave de criptografia de conteúdo final e um valor de uso único que será transmitido para a criptografia. Eles são criados com uma estrutura de dados simples para cada um, chamado na especificação como informação, que contém informações específicas para a curva elíptica, o remetente e o destinatário das informações, a fim de verificar melhor a origem da mensagem. Em seguida, usamos o HKDF com o PRK, nosso sal e as informações para derivar a chave e o valor de uso único do tamanho correto.

O tipo de informações para a criptografia de conteúdo é "aesgcm", que é o nome da cifra usada para criptografia 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);

Padding

Outra parte, e tempo para um exemplo bobeiro e artificial. Digamos que seu chefe tenha um servidor que envia a ela uma mensagem push em intervalos de poucos minutos com o preço das ações da empresa. A mensagem simples para isso será sempre um número inteiro de 32 bits com o valor em centavos. Ela também tem um acordo não autorizado com a equipe de bufê, o que significa que eles podem enviar a ela a string "donnuts na sala de descanso" cinco minutos antes da entrega, para que ela possa estar lá "cidentalmente" quando chegar e pegar o melhor.

A criptografia usada pelo Web Push cria valores criptografados que têm exatamente 16 bytes a mais do que a entrada não criptografada. Como "donnuts na sala de descanso" é maior do que o preço de uma ação de 32 bits, qualquer funcionário de espionagem poderá saber quando os donuts estão chegando sem descriptografar as mensagens, apenas pelo comprimento dos dados.

Por esse motivo, o protocolo de push da Web permite adicionar padding ao início dos dados. A forma de uso depende do seu aplicativo, mas no exemplo acima você pode preencher todas as mensagens para que tenham exatamente 32 bytes, o que torna impossível distinguir as mensagens com base apenas no tamanho.

O valor de padding é um número inteiro big-endian de 16 bits que especifica o comprimento do padding, seguido por esse número de NUL bytes de padding. Portanto, o preenchimento mínimo é de dois bytes, o número zero codificado em 16 bits.

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

Quando a mensagem push chega ao cliente, o navegador pode remover automaticamente o preenchimento para que o código do cliente receba apenas a mensagem sem preenchimento.

Criptografia

Agora, finalmente temos tudo o que fazer para fazer a criptografia. A criptografia necessária para o envio da Web é AES128 usando o GCM. Nossa chave de criptografia de conteúdo é usada como a chave e o valor de uso único como o vetor de inicialização (IV, na sigla em inglês).

Neste exemplo, nossos dados são uma string, mas podem ser quaisquer dados binários. É possível enviar payloads de até 4.078 a 4.096 bytes por postagem, com 16 bytes para informações de criptografia e pelo menos 2 bytes para padding.

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

Push na Web

Ufa. Agora que você tem um payload criptografado, basta fazer uma solicitação HTTP POST relativamente simples para o endpoint especificado pela assinatura do usuário.

Você precisa definir três cabeçalhos.

Encryption: salt=<SALT>
Crypto-Key: dh=<PUBLICKEY>
Content-Encoding: aesgcm

<SALT> e <PUBLICKEY> são a chave pública de sal e de servidor usadas na criptografia, codificada como Base64 seguro para URL.

Ao usar o protocolo de push na Web, o corpo do POST é apenas os bytes brutos da mensagem criptografada. No entanto, até que o Chrome e o Firebase Cloud Messaging ofereçam suporte ao protocolo, você poderá incluir facilmente os dados no payload JSON atual da seguinte maneira:

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

O valor da propriedade rawData precisa ser a representação codificada em base64 da mensagem criptografada.

Depuração / verificador

Peter Beverloo, um dos engenheiros do Chrome que implementou o recurso, além de ser uma das pessoas que trabalhou na especificação, criou um verificador.

Ao fazer com que seu código produza cada um dos valores intermediários da criptografia, é possível colá-los no verificador e verificar se você está no caminho certo.

.