O protocolo de push na Web

Vimos como uma biblioteca pode ser usada para acionar mensagens push, mas o que exatamente essas bibliotecas estão fazendo?

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.

Diagrama de envio de uma mensagem push do seu servidor para um serviço
push.

Nesta seção, descrevemos 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 push da Web, e não sou especialista em criptografia, mas vamos analisar cada parte, já que é útil saber o que essas bibliotecas estão fazendo nos bastidores.

Chaves do servidor de aplicativos

Quando inscrevemos 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 que está acionando as mensagens push.

Quando acionamos uma mensagem push, enviamos um conjunto de cabeçalhos que permitem que o serviço 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 para autenticação do servidor de aplicativos:

  1. O servidor de aplicativos assina algumas informações JSON com a chave privada do aplicativo.
  2. Essas informações assinadas são enviadas ao serviço de push como um cabeçalho em uma solicitação POST.
  3. 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 é o applicationServerKey transmitido na chamada de inscrição.
  4. Se as informações assinadas forem válidas, o serviço de push enviará a mensagem ao usuário.

Confira abaixo um exemplo desse fluxo de informações. Observe a legenda no canto inferior esquerdo, que indica as chaves públicas e privadas.

Ilustração de como a chave privada do servidor de aplicativos é usada ao enviar uma mensagem.

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) é uma forma de enviar uma mensagem a um terceiro 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 terá sido assinado com a chave privada correspondente e, portanto, será do remetente esperado.

Há várias bibliotecas em jwt.io/ que podem realizar a assinatura para você. Recomendamos que você faça isso sempre que possível. Para que o processo fique completo, vamos ver como criar manualmente um JWT assinado.

Push da Web e JWTs assinados

Um JWT assinado é apenas uma string, mas pode ser considerado como três strings unidas por pontos.

Uma ilustração das strings em um JSON Web Token.

As duas primeiras strings (as informações e os dados do JWT) são partes de JSON codificadas em Base64, o que significa que podem ser lidas publicamente.

A primeira string é uma informação sobre o próprio JWT, indicando qual algoritmo foi usado para criar a assinatura.

As informações do JWT para push na Web precisam conter as seguintes informações:

{
  "typ": "JWT",
  "alg": "ES256"
}

A segunda string são os dados do JWT. Isso fornece informações sobre o remetente do JWT, para quem ele se destina e por quanto tempo é válido.

Para 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 push da Web, o público-alvo é o serviço de push. Por isso, definimos como a origem do serviço de push.

O valor exp é a validade 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 ser superior a 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 horas para evitar problemas com diferenças de relógio entre o aplicativo de envio e o serviço de push.

Por fim, o valor sub precisa ser um URL ou um endereço de e-mail mailto. Isso é para que, se um serviço de push precisar entrar em contato com o remetente, ele possa encontrar as informações de contato no JWT. É por isso que a biblioteca web-push precisa 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 caractere de ponto, que chamaremos de "token não assinado", e da assinatura dele.

O processo de assinatura exige a criptografia do "token não assinado" usando o ES256. De acordo com a especificação do JWT, ES256 é a abreviação de "ECDSA usando a curva P-256 e o algoritmo de hash SHA-256". Usando a criptografia da Web, você pode criar a assinatura da seguinte forma:

// 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, as três strings unidas por pontos) é enviado ao serviço de push da Web como o cabeçalho Authorization com WebPush anexado, desta forma:

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= anexado a ela.

Crypto-Key: p256ecdsa=[URL Safe Base64 Public Application Server Key]

A criptografia de payload

Agora vamos ver como enviar um payload com uma mensagem push para que, quando o app da Web receber 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 do 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 certo e esperar que uma mensagem push seja enviada. A desvantagem é que os desenvolvedores poderiam enviar mensagens para um serviço de push que não é confiável. Ao criptografar o payload, um serviço de 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 um payload de mensagens push, vamos abordar algumas técnicas que serão usadas durante o processo de criptografia. (Agradecemos imensamente a Mat Scales pelo excelente artigo sobre criptografia de push.)

ECDH e HKDF

O ECDH e o HKDF são usados em todo o processo de criptografia e oferecem benefícios para criptografar informações.

ECDH: troca de chaves Diffie-Hellman de curva elíptica

Imagine que duas pessoas, Alice e Bob, querem compartilhar informações. Alice e Bob têm as próprias chaves públicas e privadas. Alice e Bob compartilham as chaves públicas entre si.

A propriedade útil das chaves geradas com o 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, usando 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. Agora, Bob 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 um segredo compartilhado "X".

Esta é uma explicação geral do ECDH. Para saber mais, recomendo assistir a 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.

No Node, 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 sucinta do HKDF:

O HKDF é uma função de derivação de chaves baseada em HMAC que transforma qualquer material de chave fraco em um material de chave criptograficamente forte. Ela pode ser usada, por exemplo, para converter secrets compartilhados trocados por Diffie Hellman em material de chave adequado para uso em criptografia, verificação de integridade ou autenticação.

Basicamente, o HKDF recebe uma entrada que não é particularmente segura e a torna mais segura.

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 Node, 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 ao artigo de Mat Scale por este exemplo de código.

Isso abrange de forma geral o ECDH e o HKDF.

O ECDH é uma maneira segura de compartilhar chaves públicas e gerar um segredo compartilhado. O HKDF é uma maneira de tornar seguro um material que não é.

Ele será usado durante a criptografia do payload. Em seguida, vamos analisar o que usamos como entrada e como isso é criptografado.

Entradas

Para enviar uma mensagem push com um payload para um usuário, precisamos de três entradas:

  1. O payload em si.
  2. O secret auth do PushSubscription.
  3. A chave p256dh do PushSubscription.

Vimos os valores auth e p256dh sendo recuperados de um PushSubscription, mas, para uma recapitulação rápida, considerando 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 secret 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, 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.

Salt

O salt precisa ser de 16 bytes de dados aleatórios. No NodeJS, fazemos o seguinte para criar um salt:

const salt = crypto.randomBytes(16);

Chaves públicas / privadas

As chaves públicas e privadas precisam ser geradas usando uma curva elíptica P-256, o que fazemos no 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 as chaves do servidor de aplicativos.

Com o payload, o segredo de autenticação e a chave pública de assinatura como entradas e com um salt recém-gerado e um conjunto de chaves locais, estamos prontos para fazer uma criptografia.

Chave secreta compartilhada

A primeira etapa é criar um secret compartilhado usando a chave pública da assinatura e nossa 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, na sigla em inglês).

Chave pseudoaleatória

A chave pseudoaleatória (PRK, na sigla em inglês) é a combinação da chave secreta de autenticação da assinatura por push e da chave secreta compartilhada que acabamos de criar.

const authEncBuff = new Buffer('Content-Encoding: auth\0', 'utf8');
const prk = hkdf(subscription.keys.auth, sharedSecret, authEncBuff, 32);

Você deve estar se perguntando para que serve a string Content-Encoding: auth\0. Em resumo, ele não tem um propósito claro, 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, que esperam um número específico de bytes para a codificação de conteúdo, seguido por um byte com valor 0 e, em seguida, pelos dados criptografados.

Nossa chave pseudoaleatória está simplesmente executando a autenticação, o secret compartilhado e uma parte das informações de codificação pelo HKDF (ou seja, tornando-a criptograficamente mais forte).

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 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 própria chave, depois o número de bytes da chave pública local, seguido pela própria chave.

Com esse valor de contexto, podemos usá-lo na criação de um valor de uso único e 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ó deve ser usado uma vez.

A chave de criptografia de conteúdo (CEK, na sigla em inglês) é a chave que será usada para criptografar o 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 salt e a 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 valor de uso único 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 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 preenchimento queremos adicionar à frente dele. O motivo para adicionar padding é que ele evita o risco de interceptadores determinarem os "tipos" de mensagens com base no tamanho do payload.

É preciso adicionar dois bytes de padding para indicar o tamanho de qualquer padding extra.

Por exemplo, se você não adicionar preenchimento, terá dois bytes com o valor 0, ou seja, não há preenchimento. Depois desses dois bytes, você vai ler o payload. Se você adicionar 5 bytes de preenchimento, os dois primeiros bytes terão um valor de 5. Assim, o consumidor vai ler mais cinco bytes e, em seguida, 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 criptografia.

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!

Resta apenas determinar como esse payload é enviado ao serviço de push.

Cabeçalhos e corpo do payload criptografado

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 de forma segura para URLs e adicionado ao cabeçalho de criptografia, desta forma:

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 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]

Cabeçalhos de tipo, tamanho e codificação de conteúdo

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 Content-Type está definido como application/octet-stream. Isso ocorre porque o payload criptografado precisa ser enviado como um stream de bytes.

No NodeJS, fazemos assim:

const pushRequest = https.request(httpsOptions, function(pushResponse) {
pushRequest.write(encryptedPayload);
pushRequest.end();

Mais cabeçalhos?

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 de 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) é 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 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 vai tentar entregar a mensagem imediatamente, mas, se o dispositivo não puder ser alcançado, sua mensagem será imediatamente removida da fila do serviço de push.

Tecnicamente, um serviço de 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 se os nomes dos tópicos forem 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 de push a importância de uma mensagem para o usuário. Isso pode ser usado pelo serviço de push para ajudar a economizar a bateria do dispositivo de um usuário, ativando-o apenas para mensagens importantes quando a bateria estiver baixa.

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, consulte 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 para 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 do aplicativo 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 deve ser aguardado antes de fazer outra solicitação.
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 o `PushSubscription` e aguarde o cliente renovar a assinatura do usuário.
410 Desaparecido. A assinatura não é mais válida e precisa ser removida do servidor de aplicativos. Isso pode ser reproduzido chamando `unsubscribe()` em uma `PushSubscription`.
413 O tamanho do payload é muito grande. O tamanho mínimo de payload que um serviço de push precisa aceitar é 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

Code labs