Vimos como uma biblioteca pode ser usada para acionar mensagens push, mas o que essas bibliotecas fazem exatamente?
Eles fazem solicitações de rede e garantem que elas estejam no formato correto. A especificação que define essa solicitação de rede é o protocolo Web Push.
Esta seção descreve como o servidor pode se identificar com chaves de servidor de aplicativos e como o payload criptografado e os dados associados são enviados.
Essa não é uma parte bonita do envio por push na Web, e não sou especialista em criptografia, mas vamos analisar cada parte, já que é útil saber o que essas bibliotecas estão fazendo.
Chaves do servidor de aplicativos
Quando assinamos um usuário, transmitimos um applicationServerKey. Essa chave é
transmitida ao serviço de push e usada para verificar se o aplicativo que inscreveu
o usuário também é o aplicativo que está acionando as mensagens push.
Quando acionamos uma mensagem push, enviamos um conjunto de cabeçalhos que permitem que o serviço de push autentique o aplicativo. Isso é definido pela especificação VAPID.
O que tudo isso significa e o que acontece exatamente? Estas são as etapas seguidas para a autenticação do servidor de aplicativos:
- O servidor de aplicativos assina algumas informações JSON com a chave privada do aplicativo.
- Essas informações assinadas são enviadas ao serviço de push como um cabeçalho em uma solicitação POST.
- O serviço de push usa a chave pública armazenada que recebeu de
pushManager.subscribe()para verificar se as informações recebidas foram assinadas pela chave privada relacionada à chave pública. Lembrete: a chave pública é oapplicationServerKeytransmitido para a chamada de inscrição. - Se as informações assinadas forem válidas, o serviço de push enviará a mensagem por push ao usuário.
Confira um exemplo desse fluxo de informações abaixo. (Observe a legenda no canto inferior esquerdo para indicar chaves públicas e privadas.)
As "informações assinadas" adicionadas a um cabeçalho na solicitação são um JSON Web Token.
JSON Web Token
Um JSON Web Token (ou JWT, na sigla em inglês) é uma forma de enviar uma mensagem a terceiros para que o destinatário possa validar quem a enviou.
Quando um terceiro recebe uma mensagem, ele precisa receber a chave pública do remetente e usá-la para validar a assinatura do JWT. Se a assinatura for válida, o JWT precisará ter sido assinado com a chave privada correspondente e, portanto, será do remetente esperado.
Há várias bibliotecas em jwt.io/ (em inglês) que podem fazer a assinatura para você. Recomendo que você faça isso sempre que possível. Para fins de integridade, vamos conferir como criar manualmente um JWT assinado.
Notificações push da Web e JWTs assinados
Um JWT assinado é apenas uma string, embora possa ser considerado como três strings unidas por pontos.
As duas primeiras strings (informações e dados do JWT) são partes de JSON codificadas em base64, o que significa que podem ser lidas publicamente.
A primeira string contém informações sobre o próprio JWT, indicando qual algoritmo foi usado para criar a assinatura.
As informações do JWT para notificações push na Web precisam conter o seguinte:
{
"typ": "JWT",
"alg": "ES256"
}
A segunda string são os dados do JWT. Ele fornece informações sobre o remetente do JWT, para quem ele é destinado e por quanto tempo é válido.
Para notificações push da Web, os dados teriam este formato:
{
"aud": "https://some-push-service.org",
"exp": "1469618703",
"sub": "mailto:example@web-push-book.org"
}
O valor aud é o "público-alvo", ou seja, para quem o JWT é destinado. Para notificações push da Web, o público-alvo é o serviço de push. Portanto, definimos como a origem do serviço de push.
O valor exp é o vencimento do JWT, o que impede que bisbilhoteiros
reutilizem um JWT se o interceptarem. A expiração é um carimbo de data/hora em segundos e não pode durar mais de 24 horas.
No Node.js, a expiração é definida usando:
Math.floor(Date.now() / 1000) + 12 * 60 * 60;
São 12 horas em vez de 24 para evitar problemas com diferenças de relógio entre o aplicativo de envio e o serviço push.
Por fim, o valor sub precisa ser um URL ou um endereço de e-mail mailto.
Assim, se um serviço push precisar entrar em contato com o remetente, ele poderá encontrar
informações de contato no JWT. É por isso que a biblioteca web-push precisava de um
endereço de e-mail.
Assim como as informações do JWT, os dados do JWT são codificados como uma string base64 segura para URL.
A terceira string, a assinatura, é o resultado da junção das duas primeiras strings (as informações e os dados do JWT) com um ponto, que chamaremos de "token não assinado", e da assinatura.
O processo de assinatura exige a criptografia do "token não assinado" usando ES256. De acordo com a especificação JWT, ES256 é a abreviação de "ECDSA usando a curva P-256 e o algoritmo de hash SHA-256". Usando a criptografia da Web, é possível criar a assinatura assim:
// Utility function for UTF-8 encoding a string to an ArrayBuffer.
const utf8Encoder = new TextEncoder('utf-8');
// The unsigned token is the concatenation of the URL-safe base64 encoded
// header and body.
const unsignedToken = .....;
// Sign the |unsignedToken| using ES256 (SHA-256 over ECDSA).
const key = {
kty: 'EC',
crv: 'P-256',
x: window.uint8ArrayToBase64Url(
applicationServerKeys.publicKey.subarray(1, 33)),
y: window.uint8ArrayToBase64Url(
applicationServerKeys.publicKey.subarray(33, 65)),
d: window.uint8ArrayToBase64Url(applicationServerKeys.privateKey),
};
// Sign the |unsignedToken| with the server's private key to generate
// the signature.
return crypto.subtle.importKey('jwk', key, {
name: 'ECDSA', namedCurve: 'P-256',
}, true, ['sign'])
.then((key) => {
return crypto.subtle.sign({
name: 'ECDSA',
hash: {
name: 'SHA-256',
},
}, key, utf8Encoder.encode(unsignedToken));
})
.then((signature) => {
console.log('Signature: ', signature);
});
Um serviço de push pode validar um JWT usando a chave pública do servidor de aplicativos para descriptografar a assinatura e garantir que a string descriptografada seja igual ao "token não assinado" (ou seja, as duas primeiras strings no JWT).
O JWT assinado (ou seja, todas as três strings unidas por pontos) é enviado ao serviço
de push na Web como o cabeçalho Authorization com WebPush adicionado, assim:
Authorization: 'WebPush [JWT Info].[JWT Data].[Signature]';
O protocolo Web Push também afirma que a chave pública do servidor de aplicativos precisa ser
enviada no cabeçalho Crypto-Key como uma string codificada em base64 segura para URL com
p256ecdsa= adicionado a ela.
Crypto-Key: p256ecdsa=[URL Safe Base64 Public Application Server Key]
Criptografia de payload
Em seguida, vamos ver como enviar um payload com uma mensagem push para que, quando nosso app da Web receber uma mensagem push, ele possa acessar os dados recebidos.
Uma dúvida comum de quem já usou outros serviços de push é por que a carga útil de push na Web precisa ser criptografada. Com apps nativos, as mensagens push podem enviar dados como texto simples.
Parte da beleza do push na Web é que, como todos os serviços de push usam a mesma API (o protocolo de push na Web), os desenvolvedores não precisam se preocupar com quem é o serviço de push. Podemos fazer uma solicitação no formato certo e esperar que uma mensagem push seja enviada. A desvantagem é que os desenvolvedores podem enviar mensagens para um serviço de push que não é confiável. Ao criptografar o payload, um serviço de push não consegue ler os dados enviados. Somente o navegador pode descriptografar as informações. Isso protege os dados do usuário.
A criptografia do payload é definida na especificação de criptografia de mensagens.
Antes de analisarmos as etapas específicas para criptografar um payload de mensagens push, vamos abordar algumas técnicas que serão usadas durante o processo de criptografia. (Um grande agradecimento a Mat Scales pelo excelente artigo sobre criptografia por push.)
ECDH e HKDF
O ECDH e o HKDF são usados em todo o processo de criptografia e oferecem benefícios para a finalidade de criptografar informações.
ECDH: troca de chaves Diffie-Hellman de curva elíptica
Imagine que duas pessoas, Alice e Beto, querem compartilhar informações. Alice e Bob têm chaves públicas e privadas próprias. A Alice e o Beto compartilham as chaves públicas entre si.
A propriedade útil das chaves geradas com ECDH é que Alice pode usar a chave privada dela e a chave pública de Bob para criar o valor secreto "X". Bob pode fazer o mesmo, levando a chave privada dele e a chave pública de Alice para criar de forma independente o mesmo valor "X". Isso torna "X" um segredo compartilhado, e Alice e Bob só precisaram compartilhar a chave pública deles. Agora, Beto e Alice podem usar "X" para criptografar e descriptografar mensagens entre eles.
O ECDH, até onde sei, define as propriedades das curvas que permitem esse "recurso" de criar uma senha secreta "X".
Esta é uma explicação de alto nível do ECDH. Se quiser saber mais, assista um vídeo mais detalhado sobre o ECDH.
Em termos de código, a maioria das linguagens / plataformas vem com bibliotecas para facilitar a geração dessas chaves.
Em um nó, faríamos o seguinte:
const keyCurve = crypto.createECDH('prime256v1');
keyCurve.generateKeys();
const publicKey = keyCurve.getPublicKey();
const privateKey = keyCurve.getPrivateKey();
HKDF: função de derivação de chaves baseada em HMAC
A Wikipédia tem uma descrição concisa do HKDF:
O HKDF é uma função de derivação de chaves baseada em HMAC que transforma qualquer material de chave fraca em material de chave criptograficamente forte. Por exemplo, ele pode ser usado para converter segredos compartilhados trocados por Diffie Hellman em material de chave adequado para uso em criptografia, verificação de integridade ou autenticação.
Basicamente, o HKDF pega uma entrada que não é muito segura e a torna mais segura.
A especificação que define essa criptografia exige o uso de SHA-256 como algoritmo de hash, e as chaves resultantes para HKDF em push da Web não podem ter mais de 256 bits (32 bytes).
Em um nó, isso pode ser implementado assim:
// Simplified HKDF, returning keys up to 32 bytes long
function hkdf(salt, ikm, info, length) {
// 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);
}
Agradecemos a Mat Scale pelo artigo com este exemplo de código.
O ECDH é uma maneira segura de compartilhar chaves públicas e gerar uma senha secreta compartilhada. O HKDF é uma maneira de pegar material inseguro e torná-lo seguro.
Ele será usado durante a criptografia do nosso payload. Em seguida, vamos analisar o que usamos como entrada e como isso é criptografado.
Entradas
Quando queremos enviar uma mensagem push para um usuário com um payload, precisamos de três entradas:
- O próprio payload.
- O secret
authdoPushSubscription. - A chave
p256dhdoPushSubscription.
Vimos os valores auth e p256dh sendo recuperados de um PushSubscription, mas, para
lembrar rapidamente, dada uma assinatura, precisaríamos destes valores:
subscription.toJSON().keys.auth;
subscription.toJSON().keys.p256dh;
subscription.getKey('auth');
subscription.getKey('p256dh');
O valor auth precisa ser tratado como um segredo e não pode ser compartilhado fora do aplicativo.
A chave p256dh é pública, às vezes chamada de chave pública do cliente. Aqui, vamos nos referir a p256dh como a chave pública da assinatura. A chave pública da assinatura é gerada
pelo navegador. O navegador vai manter a chave privada em segredo e usá-la para descriptografar o
payload.
Esses três valores, auth, p256dh e payload, são necessários como entradas. O resultado do processo de criptografia será o payload criptografado, um valor de salt e uma chave pública usada apenas para criptografar os dados.
Salt
O "salt" precisa ter 16 bytes de dados aleatórios. Em NodeJS, faríamos o seguinte para criar um salt:
const salt = crypto.randomBytes(16);
Chaves pública / privada
As chaves pública e privada precisam ser geradas usando uma curva elíptica P-256, o que faríamos em Node da seguinte maneira:
const localKeysCurve = crypto.createECDH('prime256v1');
localKeysCurve.generateKeys();
const localPublicKey = localKeysCurve.getPublicKey();
const localPrivateKey = localKeysCurve.getPrivateKey();
Vamos chamar essas chaves de "chaves locais". Elas são usadas apenas para criptografia e não têm nada a ver com chaves de servidor de aplicativos.
Com o payload, a chave secreta de autenticação e a chave pública de assinatura como entradas e com um novo conjunto de chaves locais e um salt gerado, estamos prontos para fazer a criptografia.
Chave secreta compartilhada
A primeira etapa é criar uma senha secreta usando a chave pública da assinatura e nossa nova chave privada (lembra da explicação do ECDH com Alice e Bob?). É isso aí.
const sharedSecret = localKeysCurve.computeSecret(
subscription.keys.p256dh,
'base64',
);
Ela é usada na próxima etapa para calcular a chave pseudoaleatória (PRK, na sigla em inglês).
Chave pseudoaleatória
A chave pseudorrandômica (PRK) é a combinação do segredo de autenticação da assinatura por push e da senha secreta compartilhada que acabamos de criar.
const authEncBuff = new Buffer('Content-Encoding: auth\0', 'utf8');
const prk = hkdf(subscription.keys.auth, sharedSecret, authEncBuff, 32);
Talvez você esteja se perguntando para que serve a string Content-Encoding: auth\0.
Em resumo, ele não tem uma finalidade clara, embora os navegadores possam
descriptografar uma mensagem recebida e procurar a codificação de conteúdo esperada.
O \0 adiciona um byte com um valor de 0 ao final do buffer. Isso é esperado pelos navegadores que descriptografam a mensagem e esperam tantos bytes para a codificação de conteúdo, seguidos por um byte com valor 0 e pelos dados criptografados.
Nossa chave pseudoaleatória simplesmente executa a autenticação, a senha secreta e uma parte das informações de codificação pelo HKDF (ou seja, tornando-o criptograficamente mais forte).
Contexto
O "contexto" é um conjunto de bytes usado para calcular dois valores mais tarde no navegador de criptografia. É essencialmente uma matriz de bytes que contém a chave pública da assinatura e a chave pública local.
const keyLabel = new Buffer('P-256\0', 'utf8');
// Convert subscription public key into a buffer.
const subscriptionPubKey = new Buffer(subscription.keys.p256dh, 'base64');
const subscriptionPubKeyLength = new Uint8Array(2);
subscriptionPubKeyLength[0] = 0;
subscriptionPubKeyLength[1] = subscriptionPubKey.length;
const localPublicKeyLength = new Uint8Array(2);
subscriptionPubKeyLength[0] = 0;
subscriptionPubKeyLength[1] = localPublicKey.length;
const contextBuffer = Buffer.concat([
keyLabel,
subscriptionPubKeyLength.buffer,
subscriptionPubKey,
localPublicKeyLength.buffer,
localPublicKey,
]);
O buffer de contexto final é um rótulo, o número de bytes na chave pública da assinatura, seguido pela chave em si, depois o número de bytes da chave pública local, seguido pela chave em si.
Com esse valor de contexto, podemos usá-lo na criação de um nonce e uma chave de criptografia de conteúdo (CEK).
Chave de criptografia de conteúdo e nonce
Um nonce é um valor que evita ataques de repetição porque só pode ser usado uma vez.
A chave de criptografia de conteúdo (CEK) é a chave que será usada para criptografar nosso payload.
Primeiro, precisamos criar os bytes de dados para o nonce e a CEK, que é simplesmente uma string de codificação de conteúdo seguida pelo buffer de contexto que acabamos de calcular:
const nonceEncBuffer = new Buffer('Content-Encoding: nonce\0', 'utf8');
const nonceInfo = Buffer.concat([nonceEncBuffer, contextBuffer]);
const cekEncBuffer = new Buffer('Content-Encoding: aesgcm\0');
const cekInfo = Buffer.concat([cekEncBuffer, contextBuffer]);
Essas informações são executadas pelo HKDF, combinando o salt e o PRK com nonceInfo e cekInfo:
// The nonce should be 12 bytes long
const nonce = hkdf(salt, prk, nonceInfo, 12);
// The CEK should be 16 bytes long
const contentEncryptionKey = hkdf(salt, prk, cekInfo, 16);
Isso nos dá o nonce e a chave de criptografia de conteúdo.
Realizar a criptografia
Agora que temos nossa chave de criptografia de conteúdo, podemos criptografar o payload.
Criamos uma criptografia AES128 usando a chave de criptografia de conteúdo como chave e o nonce como um vetor de inicialização.
Em Node, isso é feito assim:
const cipher = crypto.createCipheriv(
'id-aes128-GCM',
contentEncryptionKey,
nonce,
);
Antes de criptografar a carga útil, precisamos definir quanto padding queremos adicionar à frente dela. O motivo para adicionar padding é evitar o risco de que pessoas bisbilhoteiras consigam determinar "tipos" de mensagens com base no tamanho da carga útil.
Adicione dois bytes de padding para indicar o comprimento de qualquer padding extra.
Por exemplo, se você não adicionou padding, terá dois bytes com valor 0, ou seja, não há padding. Depois desses dois bytes, você vai ler o payload. Se você adicionar 5 bytes de padding, os dois primeiros bytes terão um valor de 5. Assim, o consumidor vai ler mais cinco bytes e começar a ler o payload.
const padding = new Buffer(2 + paddingLength);
// The buffer must be only zeros, except the length
padding.fill(0);
padding.writeUInt16BE(paddingLength, 0);
Em seguida, executamos o padding e o payload por essa cifra.
const result = cipher.update(Buffer.concat(padding, payload));
cipher.final();
// Append the auth tag to the result -
// https://nodejs.org/api/crypto.html#crypto_cipher_getauthtag
const encryptedPayload = Buffer.concat([result, cipher.getAuthTag()]);
Agora temos nosso payload criptografado. Eba!
Agora, basta determinar como esse payload é enviado ao serviço de push.
Corpo e cabeçalhos de payload criptografados
Para enviar esse payload criptografado ao serviço de push, precisamos definir alguns cabeçalhos diferentes na nossa solicitação POST.
Cabeçalho de criptografia
O cabeçalho "Encryption" precisa conter o salt usado para criptografar o payload.
O salt de 16 bytes precisa ser codificado em base64 seguro para URL e adicionado ao cabeçalho de criptografia, assim:
Encryption: salt=[URL Safe Base64 Encoded Salt]
Cabeçalho Crypto-Key
Vimos que o cabeçalho Crypto-Key é usado na seção "Chaves do servidor de aplicativos"
para conter a chave pública do servidor de aplicativos.
Esse cabeçalho também é usado para compartilhar a chave pública local usada para criptografar a carga útil.
O cabeçalho resultante será assim:
Crypto-Key: dh=[URL Safe Base64 Encoded Local Public Key String]; p256ecdsa=[URL Safe Base64 Encoded Public Application Server Key]
Cabeçalhos de tipo de conteúdo, comprimento e codificação
O cabeçalho Content-Length é o número de bytes no payload
criptografado. Os cabeçalhos "Content-Type" e "Content-Encoding" são valores fixos.
Isso é mostrado abaixo.
Content-Length: [Number of Bytes in Encrypted Payload]
Content-Type: 'application/octet-stream'
Content-Encoding: 'aesgcm'
Com esses cabeçalhos definidos, precisamos enviar o payload criptografado como o corpo
da nossa solicitação. Observe que o Content-Type está definido como
application/octet-stream. Isso porque o payload criptografado precisa ser
enviado como um fluxo de bytes.
No NodeJS, isso seria feito assim:
const pushRequest = https.request(httpsOptions, function(pushResponse) {
pushRequest.write(encryptedPayload);
pushRequest.end();
Mais cabeçalhos?
Já abordamos os cabeçalhos usados para JWT / chaves do servidor de aplicativos (ou seja, como identificar o aplicativo com o serviço de push) e os cabeçalhos usados para enviar um payload criptografado.
Há outros cabeçalhos que os serviços push usam para alterar o comportamento das mensagens enviadas. Alguns desses cabeçalhos são obrigatórios, enquanto outros são opcionais.
Cabeçalho TTL
Obrigatório
TTL (ou time to live (TTL)) é um número inteiro que especifica o número de segundos
que você quer que sua mensagem push fique no serviço push antes de ser
entregue. Quando o TTL expirar, a mensagem será removida da fila do serviço de
push e não será entregue.
TTL: [Time to live in seconds]
Se você definir um TTL de zero, o serviço de push tentará entregar a
mensagem imediatamente, mas se o dispositivo não puder ser alcançado, a mensagem
será descartada imediatamente da fila do serviço de push.
Tecnicamente, um serviço push pode reduzir o TTL de uma mensagem push se quiser. Para saber se isso aconteceu, examine o cabeçalho TTL na resposta de um serviço de push.
Tópico
Opcional
Os tópicos são strings que podem ser usadas para substituir uma mensagem pendente por uma nova mensagem se tiverem nomes de tópicos correspondentes.
Isso é útil em cenários em que várias mensagens são enviadas enquanto um dispositivo está off-line, e você quer que o usuário veja apenas a mensagem mais recente quando o dispositivo for ligado.
Urgência
Opcional
A urgência indica ao serviço push a importância de uma mensagem para o usuário. Isso pode ser usado pelo serviço push para ajudar a preservar a vida útil da bateria do dispositivo de um usuário, apenas ativando mensagens importantes quando a bateria está fraca.
O valor do cabeçalho é definido como mostrado abaixo. O valor padrão
é normal.
Urgency: [very-low | low | normal | high]
Tudo junto
Se você tiver mais dúvidas sobre como tudo isso funciona, confira como as bibliotecas acionam mensagens push na organização web-push-libs.
Depois de ter um payload criptografado e os cabeçalhos acima, basta fazer uma solicitação POST
para o endpoint em um PushSubscription.
Então, o que fazemos com a resposta a essa solicitação POST?
Resposta do serviço de push
Depois de fazer uma solicitação a um serviço de push, verifique o código de status da resposta para saber se a solicitação foi bem-sucedida ou não.
| Código de status | Descrição |
|---|---|
| 201 | Criado. A solicitação para enviar uma mensagem push foi recebida e aceita. |
| 429 | Excesso de solicitações. Isso significa que o servidor de aplicativos atingiu um limite de taxa com um serviço de push. O serviço de push precisa incluir um cabeçalho "Retry-After" para indicar quanto tempo esperar antes de fazer outra solicitação. |
| 400 | Solicitação inválida. Isso geralmente significa que um dos seus cabeçalhos é inválido ou foi formatado incorretamente. |
| 404 | Não encontrado Isso indica que a assinatura expirou e não pode ser usada. Nesse caso, exclua o `PushSubscription` e aguarde o cliente assinar novamente o usuário. |
| 410 | Indisponível. A assinatura não é mais válida e precisa ser removida do servidor de aplicativos. Isso pode ser reproduzido chamando `unsubscribe()` em um `PushSubscription`. |
| 413 | O tamanho do payload é muito grande. O tamanho mínimo do payload que um serviço de push precisa suportar é de 4.096 bytes (ou 4 kb). |
Leia também o padrão Web Push (RFC8030) para mais informações sobre os códigos de status HTTP.
A seguir
- Visão geral das notificações push na Web
- Como o push funciona
- Assinar um usuário
- UX de permissão
- Como enviar mensagens com bibliotecas push da Web
- Protocolo push da Web
- Como processar eventos de push
- Mostrar uma notificação
- Comportamento de notificação
- Padrões comuns de notificação
- Perguntas frequentes sobre notificações push
- Problemas comuns e como informar bugs