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, ao mesmo tempo, garantem que elas tenham o formato certo. A especificação que define essa solicitação de rede é o Web Push Protocol.

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

Nesta seção, descrevemos como o servidor pode se identificar com as chaves do servidor de aplicativos e como o payload criptografado e os dados associados são enviados.

Não é muito importante enviar push na Web e não sou especialista em criptografia, mas vamos conferir cada elemento, porque é útil saber o que essas bibliotecas fazem nos bastidores.

Chaves do servidor de aplicativos

Quando inscrevemos um usuário, transmitimos um applicationServerKey. Essa chave é transmitida ao serviço push e usada para verificar se o aplicativo que inscreveu o usuário também é o aplicativo que está acionando mensagens push.

Quando acionamos uma mensagem push, há um conjunto de cabeçalhos que enviamos para permitir que o serviço de push autentique o aplicativo. Isso é definido pela especificação VAPID.

O que tudo isso realmente significa e o que exatamente acontece? Estas são as etapas para a autenticação do servidor de aplicativos:

  1. O servidor de aplicativos assina algumas informações JSON com sua chave de aplicativo privada.
  2. Essas informações assinadas são enviadas ao serviço push como um cabeçalho em uma solicitação POST.
  3. O serviço de push usa a chave pública armazenada recebida de pushManager.subscribe() para verificar se as informações recebidas são assinadas pela chave privada relacionada à chave pública. Lembre-se: a chave pública é a applicationServerKey transmitida para a chamada de assinatura.
  4. 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 no canto inferior esquerdo para indicar 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 é um JSON Web Token.

Token da Web JSON

Um token JSON da Web (ou JWT, na sigla em inglês) é uma maneira 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 conseguir 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, portanto, ele precisa ser do remetente esperado.

Há várias bibliotecas em https://jwt.io/ (link em inglês) que podem executar a assinatura para você, e recomendamos que você faça isso onde possível. Para ver a integridade, vamos analisar como criar manualmente um JWT assinado.

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

A primeira e a segunda strings (informações do JWT e dados do JWT) são partes de JSON que foram codificadas em base64, o que significa que são publicamente legíveis.

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 da Web precisam conter as seguintes informações:

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

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

Para push na 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 é. Para push na Web, o público-alvo é o serviço push, então nós o definimos como a origem do serviço de push.

O valor exp é a expiração do JWT. Isso impede que os biscoitos reutilizem um JWT caso o interceptem. A expiração é um carimbo de data/hora em segundos e não pode ser 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 de push.

Por fim, o valor sub precisa ser um URL ou um endereço de e-mail mailto. Assim, se um serviço de push precisar entrar em contato com o remetente, ele poderá encontrar os dados 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 da junção das duas primeiras strings (informações do JWT e dados do JWT) com um caractere de ponto, que chamaremos de "token não assinado".

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 Web crypto, você pode criar a assinatura da seguinte maneira:

// 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 a mesma do "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 no início, da seguinte forma:

Authorization: 'WebPush [JWT Info].[JWT Data].[Signature]';

O protocolo de push da Web também declara 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 e com p256ecdsa= como prefixo.

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

A criptografia do payload

Agora, vamos conferir 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 qualquer um que já usou outros serviços de push é por que o payload de push da Web precisa ser criptografado? Com aplicativos 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 é esse serviço. É possível fazer uma solicitação no formato certo e esperar uma mensagem push ser enviada. A desvantagem disso é que os desenvolvedores podem enviar mensagens para um serviço de push não confiável. Ao criptografar o payload, o 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 conhecer as etapas específicas para criptografar um payload de mensagens push, precisamos abordar algumas técnicas que serão usadas durante o processo de criptografia. (Gorjeta para Mat Scales pelo excelente artigo sobre criptografia push.)

ECDH e HKDF

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

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

Imagine que você tem duas pessoas que querem compartilhar informações, Alice e Bob. 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 e a chave pública de Bob para criar o valor secreto "X". Bob pode fazer o mesmo, usando a chave privada e a chave pública da Alice para criar de forma independente o mesmo valor "X". Isso faz de "X" 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.

Até onde sei, ECDH define as propriedades das curvas que permitem esse "recurso" de criar uma chave secreta compartilhada "X".

Esta é uma explicação de alto nível sobre EDCA. Se quiser saber mais, recomendamos 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ó, 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 com base em HMAC

A Wikipédia tem uma descrição sucinta de 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 com criptografia forte. Ela pode ser usada, por exemplo, para converter Diffie Hellman trocados secrets compartilhados em chaves apropriadas para uso em criptografia, verificação de integridade ou autenticação.

Essencialmente, o HKDF recebe entradas que não são particularmente seguras e as torna mais seguras.

A especificação que define essa criptografia requer o uso de SHA-256 como nosso algoritmo de hash, e as chaves resultantes para HKDF no push da Web não podem ser maiores que 256 bits (32 bytes).

No nó, isso poderia 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 da Mat Scale para este código de exemplo (em inglês).

Isso abrange vagamente ECDH e HKDF.

O ECDH é uma maneira segura de compartilhar chaves públicas e gerar uma senha secreta. HKDF é uma maneira de pegar material não seguro e torná-lo seguro.

Ele será usado durante a criptografia do payload. Agora vamos analisar o que consideramos 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.

Os valores auth e p256dh foram recuperados de um PushSubscription. Mas, para um lembrete rápido, 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 seu aplicativo.

A chave p256dh é uma chave pública, às vezes chamada de chave pública do cliente. Aqui, chamaremos p256dh de chave pública de assinatura. A chave pública da assinatura é gerada pelo navegador. O navegador mantém a chave privada em segredo e a usa 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 usados apenas para criptografar os dados.

Sal

O sal precisa ter 16 bytes de dados aleatórios. No NodeJS, faremos o seguinte para criar um sal:

const salt = crypto.randomBytes(16);

Chaves públicas / privadas

As chaves pública e privada devem ser geradas usando uma curva elíptica P-256, o que faria no Node assim:

const localKeysCurve = crypto.createECDH('prime256v1');
localKeysCurve.generateKeys();

const localPublicKey = localKeysCurve.getPublicKey();
const localPrivateKey = localKeysCurve.getPrivateKey();

Chamaremos essas chaves de "chaves locais". Elas são usadas apenas para criptografia e não têm relação com as chaves do servidor de aplicativos.

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

Chave secreta compartilhada

A primeira etapa é criar uma chave secreta compartilhada usando a chave pública de assinatura e nossa nova chave privada. Não se esqueça da explicação de ECDH com Alice e Bob? Simples assim).

const sharedSecret = localKeysCurve.computeSecret(
  subscription.keys.p256dh,
  'base64',
);

Ela é usada na próxima etapa para calcular a pseudochave aleatória (PRK).

Chave pseudoaleatória

A pseudochave aleatória (PRK, na sigla em inglês) é a combinação da chave secreta de autenticação da assinatura de 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ê 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 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 porque os navegadores descriptografam a mensagem que vai esperar tantos bytes para a codificação de conteúdo, seguido por um byte com valor 0, seguido pelos dados criptografados.

Nossa chave pseudoaleatória simplesmente executa a autenticação, o segredo compartilhado e uma parte da codificação de informações por HKDF (ou seja, tornando-a criptograficamente mais forte).

O contexto

O "contexto" é um conjunto de bytes usado para calcular dois valores posteriormente 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 de assinatura, seguido pela própria chave, pelo 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, na sigla em inglês).

Chave de criptografia de conteúdo e valor de uso único

Um valor de uso único é um valor que impede ataques de repetição, porque precisa ser usado apenas 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 valor de uso único e o 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 a 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á nossa chave de criptografia de conteúdo e valor de uso único.

Realizar a criptografia

Agora que temos a chave de criptografia de conteúdo, podemos criptografar o payload.

Criamos uma criptografia AES128 usando a chave de criptografia de conteúdo como a 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 nosso payload, precisamos definir quanto padding queremos adicionar à frente do payload. Queremos adicionar padding para evitar o risco de intrusos determinarem "tipos" de mensagens com base no tamanho do payload.

É necessário adicionar dois bytes de padding para indicar o comprimento de qualquer preenchimento adicional.

Por exemplo, se você não adicionou padding, teria 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 lerá mais 5 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 com 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,

Tudo o que resta é determinar como essa carga será enviada ao serviço de push.

Corpo e cabeçalhos do payload criptografado

Para enviar esse payload criptografado ao serviço de push, precisamos definir alguns cabeçalhos diferentes na solicitação POST.

Cabeçalho de criptografia

O cabeçalho "Criptografia" precisa conter o sal usado para criptografar o payload.

O sal de 16 bytes precisa ser codificado seguro para URL base64 e adicionado ao cabeçalho de criptografia, da seguinte maneira:

Encryption: salt=[URL Safe Base64 Encoded Salt]

Cabeçalho Crypto-Key

Notamos 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 ficará 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, comprimento 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'

Depois de definir esses cabeçalhos, é preciso enviar o payload criptografado como o corpo da 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.

Em NodeJS, faríamos 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 (por exemplo, como identificar o aplicativo com o serviço de push) e os cabeçalhos usados para enviar um payload criptografado.

Há cabeçalhos adicionais 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 (TTL), é um número inteiro que especifica o número de segundos que você quer que sua mensagem push fique no serviço de push antes de ser entregue. 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 tentará entregar a mensagem imediatamente, mas se não for possível acessar o dispositivo, a mensagem será removida imediatamente da fila do serviço push.

Tecnicamente, um serviço de 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, desde que tenham nomes de tópico correspondentes.

Isso é útil quando várias mensagens são enviadas enquanto um dispositivo está off-line e você quer que o usuário só 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. Ele pode ser usado pelo serviço de push para ajudar a conservar a duração da bateria do dispositivo de um usuário, ativando apenas mensagens importantes quando a bateria está fraca.

O valor do cabeçalho é definido conforme mostrado abaixo. O valor padrão é normal.

Urgency: [very-low | low | normal | high]

Tudo em um só lugar

Se você tiver mais dúvidas sobre como tudo isso funciona, confira como as bibliotecas acionam mensagens push em the web-push-libs org.

Depois que você tiver um payload criptografado e os cabeçalhos acima, basta fazer uma solicitação POST para o endpoint em um PushSubscription.

O que podemos fazer 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, é necessário verificar o código de status da resposta, porque ele dirá 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. Indica que o servidor de aplicativos atingiu o 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 falta para 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 "PushSubscription" e aguarde a inscrição do usuário novamente.
410 Sair. 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 payload mínimo de suporte de um serviço de push é de 4.096 bytes (ou 4 KB).

A seguir

Laboratórios de códigos