Vimos como uma biblioteca pode ser usada para acionar mensagens push, mas o que essas bibliotecas estão fazendo exatamente?
Eles fazem solicitações de rede e, ao mesmo tempo, garantem que elas tenham o formato correto. A especificação que define essa solicitação de rede é o Web Push Protocol.
Esta seção descreve como o servidor pode se identificar com chaves do servidor de aplicativos e como o payload criptografado e os dados associados são enviados.
Isso não é um lado bonito do push da Web, e eu 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 inscrevemos um usuário, transmitimos um applicationServerKey
. Essa chave é
transmitida para o serviço push e usada para verificar se o aplicativo que inscreveu
o usuário também é o que está acionando as mensagens push.
Quando disparamos uma mensagem push, há um conjunto de cabeçalhos que enviamos para permitir que o serviço push autentique o aplicativo. Isso é definido pela especificação VAPID.
O que isso significa e o que acontece exatamente? Estas são as etapas realizadas para a autenticação do servidor do aplicativo:
- 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 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 estão assinadas pela chave privada relacionada à chave pública. Lembrete: a chave pública é oapplicationServerKey
transmitido para a chamada de inscrição. - Se as informações assinadas forem válidas, o serviço de push enviará a mensagem push ao usuário.
Confira abaixo um exemplo desse fluxo de informações. Observe a legenda na parte inferior esquerda 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.
Token JSON da Web
Um token Web JSON (ou JWT, para abreviar) é uma forma de enviar uma mensagem para 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, ou seja, precisa ser do remetente esperado.
Há uma série de bibliotecas em https://jwt.io/ (link em inglês) que podem executar a assinatura para você. Recomendo que você faça isso sempre que puder. Para maior abrangência, vamos ver como criar manualmente um JWT assinado.
Push na Web e JWTs assinados
Um JWT assinado é apenas uma string, mas pode ser considerado como três strings unidas por pontos.
A primeira e a segunda string (as informações e os dados do JWT) são partes do JSON codificadas em base64, o que significa que são legíveis publicamente.
A primeira string é informação sobre o próprio JWT, indicando qual algoritmo foi usado para criar a assinatura.
As informações do JWT para push da Web precisam conter as seguintes informações:
{
"typ": "JWT",
"alg": "ES256"
}
A segunda string são os dados JWT. Isso fornece informações sobre o remetente do JWT, a quem ele se destina e por quanto tempo ele é válido.
Para push da Web, os dados têm este formato:
{
"aud": "https://some-push-service.org",
"exp": "1469618703",
"sub": "mailto:example@web-push-book.org"
}
O valor de aud
é o "público-alvo", ou seja, o público-alvo do JWT. Para push da Web, o
público é o serviço de push, então nós o definimos como a origem do serviço
de push.
O valor exp
é a expiração do JWT. Isso evita que invasões possam
reutilizar um JWT se interceptarem. A expiração é um carimbo de data/hora em
segundos e não pode ser superior a 24 horas.
Em Node.js, a expiração é definida usando:
Math.floor(Date.now() / 1000) + 12 * 60 * 60;
O tempo é de 12 horas, e não 24 horas, 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
.
Isso é feito para que, se um serviço push precisar entrar em contato com o remetente, ele possa encontrar
informações de contato no JWT. É por isso que a biblioteca de push da Web 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 de pegar as duas primeiras strings (as informações e os dados do JWT), juntá-las com um caractere de ponto, que vamos chamar de "token não assinado", e assinar.
O processo de assinatura requer 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 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 da Web como o cabeçalho Authorization
com WebPush
adicionado no início, desta forma:
Authorization: 'WebPush [JWT Info].[JWT Data].[Signature]';
O protocolo de push da Web também determina que a chave pública do servidor do aplicativo precisa ser
enviada no cabeçalho Crypto-Key
como uma string codificada em base64 segura para URL com
p256ecdsa=
anexado.
Crypto-Key: p256ecdsa=[URL Safe Base64 Public Application Server Key]
A criptografia de payload
Em seguida, vamos analisar como enviar um payload com uma mensagem push para que, quando nosso app da Web receba uma mensagem push, ele possa acessar os dados recebidos.
Uma pergunta comum que surge de quem já usou outros serviços de push é por que o payload de push da Web precisa ser criptografado? Com apps nativos, as mensagens push podem enviar dados como texto simples.
Parte da beleza do push da Web é que, como todos os serviços de push usam a mesma API (o protocolo de push da Web), os desenvolvedores não precisam se preocupar com quem é o serviço de push. Podemos fazer uma solicitação no formato correto e esperar que uma mensagem de push seja enviada. A desvantagem disso é que os desenvolvedores podem enviar mensagens para um serviço push que não é confiável. Ao cifrar o payload, um serviço push não pode 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 o payload de uma mensagem push, vamos abordar algumas técnicas que serão usadas durante o processo de criptografia. Uma grande dica de chapéu para Mat Scales pelo excelente artigo sobre criptografia push.
ECDH e HKDF
Tanto o ECDH quanto o HKDF são usados em todo o processo de criptografia e oferecem benefícios para criptografar informações.
ECDH: troca de chaves de curva elíptica Diffie-Hellman
Imagine que você tem duas pessoas que querem compartilhar informações, Alice e Bob. Alice e Bob têm as próprias chaves pública e privada. Alice e Bob compartilham as chaves públicas um com o outro.
A propriedade útil das chaves geradas com ECDH é que Alice pode usar a chave privada e a chave pública de Bob para criar o valor secreto "X". Bob pode fazer o mesmo, usando a chave privada dele e a chave pública de Alice para criar de forma independente o mesmo valor "X". Isso faz com que "X" seja um segredo compartilhado, e Alice e Bob só precisam compartilhar a chave pública. Agora Bob e Alice podem usar "X" para criptografar e descriptografar mensagens entre eles.
O ECDH, pelo que sei, define as propriedades das curvas que permitem esse "recurso" de criar uma senha secreta compartilhada "X".
Esta é uma explicação de alto nível sobre ECDH. Se quiser saber mais, recomendo assistir este vídeo.
Em termos de código, a maioria das linguagens / plataformas vem com bibliotecas para facilitar a geração dessas chaves.
No nó, faremos 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 sucinta da HKDF:
A 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. Ele pode ser usado, por exemplo, para converter chaves secretas compartilhadas trocadas por Diffie-Hellman em material de chave adequado para uso em criptografia, verificação de integridade ou autenticação.
Em essência, o HKDF vai usar entradas que não são particularmente seguras e torná-las mais seguras.
A especificação que define essa criptografia exige o uso de SHA-256 como nosso algoritmo de hash, e as chaves resultantes para HKDF no push da Web não podem ter mais de 256 bits (32 bytes).
No 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);
}
Dica para o artigo de Mat Scale para este código de exemplo.
Isso abrange de forma vaga ECDH e HKDF.
O ECDH é uma maneira segura de compartilhar chaves públicas e gerar uma senha secreta compartilhada. HKDF é uma maneira de pegar um material não seguro e torná-lo seguro.
Ele será usado durante a criptografia do payload. A seguir, vamos analisar o que consideramos como entrada e como isso é criptografado.
Entradas
Quando queremos enviar uma mensagem push a um usuário com um payload, precisamos de três entradas:
- O payload em si.
- O secret
auth
doPushSubscription
. - A chave
p256dh
doPushSubscription
.
Vimos os valores auth
e p256dh
serem recuperados de um PushSubscription
. No entanto, para
um lembrete rápido, considerando uma assinatura, estes valores são necessários:
subscription.toJSON().keys.auth;
subscription.toJSON().keys.p256dh;
subscription.getKey('auth');
subscription.getKey('p256dh');
O valor auth
precisa ser tratado como um secret e não ser compartilhado fora do aplicativo.
A chave p256dh
é uma chave 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 de 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, e o resultado do
processo de criptografia será o payload criptografado, um valor de sal e uma chave pública usada apenas para
criptografar os dados.
Sal
O sal precisa ter 16 bytes de dados aleatórios. No NodeJS, faríamos o seguinte para criar um sal:
const salt = crypto.randomBytes(16);
Chaves públicas / privadas
As chaves pública e privada precisam ser geradas usando uma curva elíptica P-256, que faremos no Node da seguinte forma:
const localKeysCurve = crypto.createECDH('prime256v1');
localKeysCurve.generateKeys();
const localPublicKey = localKeysCurve.getPublicKey();
const localPrivateKey = localKeysCurve.getPrivateKey();
Vamos nos referir a essas chaves como "chaves locais". Elas são usadas apenas para criptografia e não têm nada a ver com as chaves do servidor do aplicativo.
Com o payload, o segredo de autenticação e a chave pública de assinatura como entradas e com um sal e um conjunto de chaves locais recém-gerados, estamos prontos para fazer a criptografia.
Chave secreta compartilhada
A primeira etapa é criar um segredo compartilhado usando a chave pública de assinatura e a nova chave privada (lembra da explicação do ECDH com Alice e Bob? Simples assim).
const sharedSecret = localKeysCurve.computeSecret(
subscription.keys.p256dh,
'base64',
);
Isso é usado na próxima etapa para calcular a chave pseudoaleatória (PRK).
Chave pseudoaleatória
A chave pseudoaleatória (PRK, na sigla em inglês) é a combinação do segredo de autenticação da assinatura de push e do segredo compartilhado que acabamos de criar.
const authEncBuff = new Buffer('Content-Encoding: auth\0', 'utf8');
const prk = hkdf(subscription.keys.auth, sharedSecret, authEncBuff, 32);
Você pode estar 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 de entrada 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, que esperam tantos bytes
para a codificação de conteúdo, seguidos de um byte com valor 0, seguido dos
dados criptografados.
Nossa chave pseudoaleatória simplesmente executa a autenticação, a senha secreta compartilhada e uma parte das informações de codificação por HKDF (ou seja, tornando-a mais forte criptograficamente).
Contexto
O "contexto" é um conjunto de bytes usado para calcular dois valores posteriormente, no navegador de criptografia. É basicamente uma matriz de bytes que contém a chave pública de 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 valor de uso único e de uma chave de criptografia de conteúdo (CEK).
Chave de criptografia de conteúdo e valor de uso único
Um nonce é um valor que impede ataques de repetição, já que só pode ser usado uma vez.
A chave de criptografia de conteúdo (CEK, na sigla em inglês) é a chave que vai ser usada para criptografar nosso payload.
Primeiro, precisamos criar os bytes de dados para o valor de uso único 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 sal e o PRK com o nonceInfo e o 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á a chave de nonce e de criptografia de conteúdo.
Executar a criptografia
Agora que temos a chave de criptografia de conteúdo, podemos criptografar o payload.
Criamos uma cifra AES128 usando a chave de criptografia de conteúdo como chave, e o valor de uso único é um vetor de inicialização.
No Node, isso é feito assim:
const cipher = crypto.createCipheriv(
'id-aes128-GCM',
contentEncryptionKey,
nonce,
);
Antes de criptografar o payload, precisamos definir quanto padding queremos adicionar à parte frontal do payload. O motivo pelo qual queremos adicionar o padding é que ele evita o risco de invasores conseguirem determinar os "tipos" de mensagens com base no tamanho do payload.
É preciso adicionar dois bytes de padding para indicar o comprimento de qualquer padding adicional.
Por exemplo, se você não adicionar padding, terá dois bytes com valor 0, ou seja, não há padding. Depois desses dois bytes, você vai ler o payload. Se você tiver adicionado 5 bytes de preenchimento, os dois primeiros bytes terão um valor 5. Assim, o consumidor lerá cinco bytes adicionais 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 nessa 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 o payload criptografado. Eba!
Só falta determinar como esse payload é enviado ao serviço push.
Cabeçalhos e corpo do payload criptografado
Para enviar esse payload criptografado ao serviço push, precisamos definir alguns cabeçalhos diferentes na solicitação POST.
Cabeçalho de criptografia
O cabeçalho "Criptografia" precisa conter o salt usado para criptografar o payload.
O sal de 16 bytes precisa ser codificado com segurança de URL em base64 e adicionado ao cabeçalho "Criptografia", desta forma:
Encryption: salt=[URL Safe Base64 Encoded Salt]
Cabeçalho Crypto-Key
Percebemos 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 utilizada para criptografar o payload.
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]
Tipo de conteúdo, tamanho e cabeçalhos de codificação
O cabeçalho Content-Length
é o número de bytes no payload
cifrado. 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 Content-Type
está definido como
application/octet-stream
. Isso ocorre porque o payload criptografado precisa ser
enviado como um fluxo de bytes.
No NodeJS, faremos isso da seguinte maneira:
const pushRequest = https.request(httpsOptions, function(pushResponse) {
pushRequest.write(encryptedPayload);
pushRequest.end();
Mais cabeçalhos?
Explicamos os cabeçalhos usados para chaves JWT / 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 de push usam para alterar o comportamento das mensagens enviadas. Alguns desses cabeçalhos são obrigatórios, outros são opcionais.
Cabeçalho TTL
Obrigatório
TTL
(ou tempo de vida) é um número inteiro que especifica o número de segundos
que você quer que sua mensagem push permaneça no serviço de push antes de ser
enviada. Quando o TTL
expirar, a mensagem será removida da
fila do serviço push e não será entregue.
TTL: [Time to live in seconds]
Se você definir um TTL
como zero, o serviço de push vai tentar entregar a
mensagem imediatamente. No entanto, se o dispositivo não puder ser alcançado, a mensagem
será removida imediatamente da fila do serviço de push.
Tecnicamente, um serviço push pode reduzir o TTL
de uma mensagem push, se
quiser. É possível saber se isso aconteceu examinando 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 elas tiverem nomes de tópicos correspondentes.
Isso é útil em situações em que várias mensagens são enviadas enquanto um dispositivo está off-line e você só quer que o usuário veja a mensagem mais recente quando o dispositivo estiver 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 de push para ajudar a conservar a duração da bateria do dispositivo de um usuário, despertando apenas para mensagens importantes quando a bateria estiver fraca.
O valor do cabeçalho é definido conforme mostrado abaixo. O valor padrão
é normal
.
Urgency: [very-low | low | normal | high]
Tudo junto
Se você tiver outras dúvidas sobre como tudo isso funciona, confira como as bibliotecas acionam mensagens push no web-push-libs org.
Depois de ter um payload criptografado e os cabeçalhos acima, você só precisa fazer uma solicitação POST
para o endpoint
em um PushSubscription
.
O que fazemos com a resposta a essa solicitação POST?
Resposta do serviço de push
Depois de fazer uma solicitação para um serviço push, você precisa verificar o código de status da resposta, que vai informar se a solicitação foi bem-sucedida ou não.
Código de status | Descrição |
---|---|
201 | Criado. A solicitação de envio de uma mensagem push foi recebida e aceita. |
429 | Excesso de solicitações. Isso significa que o servidor do aplicativo atingiu um limite de taxa com um serviço push. O serviço de push precisa incluir um cabeçalho "Retry-After" para indicar quanto tempo antes de outra solicitação ser feita. |
400 | Solicitação inválida. Isso geralmente significa que um dos cabeçalhos é inválido ou está formatado incorretamente. |
404 | Não encontrado Isso indica que a assinatura expirou e não pode ser usada. Nesse caso, exclua a PushSubscription e aguarde o cliente para assinar o usuário novamente. |
410 | Desapareceu. 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). |
Você também pode ler 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 funciona o Push
- Como inscrever um usuário
- UX de permissão
- Como enviar mensagens com bibliotecas push da Web
- Protocolo de push da Web
- Como processar eventos de push
- Como mostrar uma notificação
- Comportamento das notificações
- Padrões de notificação comuns
- Perguntas frequentes sobre notificações push
- Problemas comuns e como informar bugs