O protocolo de push na Web

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.

Diagrama de envio de uma mensagem push do seu servidor para um serviço
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:

  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 foram assinadas pela chave privada relacionada à chave pública. Lembrete: a chave pública é o applicationServerKey transmitido para a chamada de inscrição.
  4. 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.)

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, 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.

Ilustração das strings em um JSON Web Token.

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.

Isso abrange ECDH e HKDF.

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:

  1. O próprio payload.
  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 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

Laboratórios de programação