Antes do Chrome 50, as mensagens push não podiam conter dados de payload. Quando o evento "push" era acionado no worker do serviço, você sabia apenas que o servidor estava tentando informar algo, mas não o que poderia ser. Depois, você precisa fazer uma solicitação de acompanhamento para o servidor e receber os detalhes da notificação a ser mostrada, 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 com o push para que o cliente evite fazer a solicitação extra. No entanto, com grandes poderes vêm grandes responsabilidades, então todos os dados do payload precisam ser criptografados.
A criptografia de payloads é uma parte importante da história de segurança do push na Web. O HTTPS oferece segurança na comunicação entre o navegador e seu próprio servidor, porque você confia nele. No entanto, o navegador escolhe qual provedor de push será usado para entregar o payload. Assim, você, como desenvolvedor do aplicativo, 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 receber, o invasor pode fazer o que quiser, inclusive retransmitir o payload para terceiros ou alterá-lo maliciosamente para outra coisa. Para evitar isso, usamos a criptografia para garantir que os serviços push não possam ler ou adulterar os payloads em trânsito.
Mudanças no lado do cliente
Se você já implementou notificações push sem payloads, basta fazer duas pequenas mudanças no lado do cliente.
A primeira é que, ao enviar as informações de assinatura para o servidor de back-end,
é necessário coletar algumas informações extras. Se você já usa
JSON.stringify()
no objeto
PushSubscription
para serializar e enviar ao servidor, não é necessário
mudar nada. A assinatura vai ter alguns dados extras na propriedade "keys".
> 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 segura para URL.
Se você quiser acessar os bytes diretamente, use o novo método
getKey()
na assinatura, que retorna um parâmetro como um
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
é acionado. 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 do lado do 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, em seguida, enviar isso 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, como acontece com qualquer coisa relacionada à criptografia,
é melhor usar uma biblioteca desenvolvida ativamente do que criar sua própria biblioteca. A
equipe do Chrome publicou uma biblioteca
para Node.js, com mais linguagens e plataformas que serão lançadas em breve. Isso processa a criptografia e o protocolo de push da Web, de modo que o envio de uma mensagem de push de um servidor Node.js é tão fácil quanto webpush.sendWebPush(message, subscription)
.
Embora recomendemos o uso de 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, aqui estão os detalhes.
Ilustrarei os algoritmos usando JavaScript com variação de Node, mas os princípios básicos precisam ser os mesmos em qualquer linguagem.
Entradas
Para criptografar uma mensagem, primeiro precisamos receber duas coisas do
objeto de assinatura que recebemos do cliente. Se você usou
JSON.stringify()
no cliente e transmitiu isso para o servidor, a
chave pública do cliente será armazenada no campo keys.p256dh
, enquanto o segredo de autenticação
compartilhado está no campo keys.auth
. Ambos serão codificados em base64,
seguros para URL, conforme mencionado acima. O formato binário da chave pública do cliente
é um ponto de curva elíptica P-256 descompactado.
const clientPublicKey = new Buffer(subscription.keys.p256dh, 'base64');
const clientAuthSecret = new Buffer(subscription.keys.auth, 'base64');
A chave pública permite criptografar 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. 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. Não é de surpreender que isso precise 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 novos dados. Precisamos de um sal aleatório criptograficamente
seguro de 16 bytes
e um par de chaves de curva elíptica
pública/privada. A curva específica usada pela especificação de criptografia push é chamada de P-256,
ou prime256v1
. Para ter a melhor segurança, o par de chaves precisa ser gerado do
zero sempre que você criptografar uma mensagem, e nunca reutilize um sal.
ECDH
Vamos fazer um desvio para falar sobre uma propriedade interessante da criptografia de curva elíptica. Há um processo relativamente simples que combina sua chave privada com a chave pública de outra pessoa para extrair um valor. E o que isso significa? Se a outra parte pegar a chave privada dela e a chave pública de você, ela vai gerar o mesmo valor.
Essa é a base do protocolo de acordo de chave de curva elíptica Diffie-Hellman (ECDH), que permite que ambas as partes tenham o mesmo secret compartilhado, mesmo que tenham trocado apenas chaves públicas. Usaremos esse segredo compartilhado como base para nossa chave de criptografia real.
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 pausa. Digamos que você tenha dados confidenciais que quer usar como uma 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) com base em HMAC para transformar um segredo com baixa segurança em um com alta segurança.
Uma consequência do modo como ele funciona é que ele permite que você use uma chave secreta de qualquer número de bits e produza outra chave secreta de qualquer tamanho até 255 vezes, desde que um hash seja produzido por qualquer algoritmo de hash que você use. Para push, a especificação exige que usemos SHA-256, que tem um comprimento de hash de 32 bytes (256 bits).
Como acontece, sabemos que precisamos gerar chaves com até 32 bytes. Isso significa que podemos usar uma versão simplificada do algoritmo que não pode processar tamanhos de saída maiores.
Incluí o código de uma versão do Node abaixo, mas você pode descobrir como ele funciona no RFC 5869 (em inglês).
As entradas para HKDF são um salt, algum material de chave inicial (IKM, na sigla em inglês), um pedaço opcional de dados estruturados específico para o caso de uso atual (info) e o comprimento 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 em parâmetros para a criptografia real.
A primeira coisa que fazemos é usar o HKDF para misturar a chave secreta de autenticação do cliente e a chave secreta compartilhada em uma chave secreta mais longa e criptograficamente mais segura. Na especificação, isso é chamado de chave pseudo-aleatória (PRK, na sigla em inglês), então vou chamá-lo aqui, embora os puristas da criptografia possam notar que isso não é estritamente uma PRK.
Agora criamos a chave de criptografia de conteúdo final e um nonce que será transmitido à criptografia. Eles são criados criando uma estrutura de dados simples para cada um, referido na especificação como uma informação, que contém informações específicas da curva elíptica, do remetente e do receptor das informações para verificar melhor a origem da mensagem. Em seguida, usamos 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ção para a criptografia do conteúdo é "aesgcm", que é o nome da criptografia 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
Outro exemplo e artificial. Digamos que sua chefe tenha um servidor que envie 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 secreto com a equipe de catering, o que significa que eles podem enviar a ela a mensagem "rosquinhas na sala de descanso" cinco minutos antes da entrega para que ela possa estar lá "por acaso" quando elas chegarem e pegar a melhor.
A cifra usada pelo Web Push cria valores criptografados que são exatamente 16 bytes maiores do que a entrada não criptografada. Como "doughnuts na sala de descanso" é maior que um preço de ação de 32 bits, qualquer funcionário bisbilhonte pode dizer quando os donuts estão chegando sem descriptografar as mensagens, apenas pela duração dos dados.
Por esse motivo, o protocolo de push da Web permite adicionar preenchimento 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 comprimento.
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 bytes NUL
de padding. Portanto, o padding 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 chegar ao cliente, o navegador vai poder remover automaticamente qualquer preenchimento, para que o código do cliente receba apenas a mensagem sem preenchimento.
Criptografia
Agora temos tudo para fazer a criptografia. A cifra necessária para o Web Push é AES128 usando GCM. Usamos a chave de criptografia de conteúdo como a chave e o valor de uso único como o vetor de inicialização (IV).
Neste exemplo, os dados são uma string, mas podem ser qualquer dado binário. É possível enviar payloads de até 4.078 bytes 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 POST HTTP 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 servidor usada na
criptografia, codificada como Base64 segura para URL.
Ao usar o protocolo Web Push, 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, é possível 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 implementaram o recurso (e também uma das pessoas que trabalharam na especificação), criou um verificador.
Ao fazer com que o código gere cada um dos valores intermediários da criptografia, você pode colá-los no verificador e verificar se está no caminho certo.
.