Antes do Chrome 50, as mensagens push não podiam conter dados de payload. Quando o evento "push" foi 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 para push da Web. O HTTPS oferece segurança ao se comunicar 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. Portanto, você, como desenvolvedor do app, não tem controle sobre ele.
Aqui, o HTTPS só pode garantir que ninguém possa bisbilhotar a mensagem em trânsito para o provedor de serviço 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 do 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 tudo relacionado à criptografia,
é melhor usar uma biblioteca desenvolvida ativamente do que criar a sua própria. A
equipe do Chrome publicou uma biblioteca
para Node.js, e mais idiomas e plataformas serão lançados em breve. Isso processa a
criptografia e o protocolo de push da Web, para que o envio de uma mensagem push de um
servidor do Node.js seja 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, confira os detalhes.
Vou ilustrar os algoritmos usando JavaScript com sabor de Node, mas os princípios básicos são 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,
de forma segura 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 é surpresa 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 de 16 bytes
criptograficamente seguro
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 um segredo de qualquer número de bits e produza outro segredo 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).
Sabemos que só precisamos gerar chaves de até 32 bytes de tamanho. Isso significa que podemos usar uma versão simplificada do algoritmo que não pode processar tamanhos de saída maiores.
Incluímos o código de uma versão do Node abaixo, mas você pode descobrir como ele funciona no RFC 5869 (link 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 pseudoaleatória (PRK, na sigla em inglês). No entanto, puristas de criptografia podem 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 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ção para a criptografia de conteúdo é "aesgcm", que é o nome da cifragem usada para a 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
Agora, um exemplo e artificial. Digamos que seu chefe tenha um servidor que envia uma mensagem push a cada 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 string "donuts na sala de descanso" cinco minutos antes da entrega para que ela possa estar "por acaso" lá quando eles chegarem e pegar o 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 pelo 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é 4078 bytes (4096 bytes no máximo por postagem), com 16 bytes para informações de criptografia e pelo menos 2 bytes para preenchimento.
// 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 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.
.