Já 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 essas no formato certo. A especificação que define essa solicitação de rede é o Protocolo push na Web.
Esta seção descreve como o servidor pode se identificar com o de servidor e como o payload criptografado e os dados associados são enviados.
Esse não é bem o 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 do aplicativo
Quando inscrevemos um usuário, transmitimos um applicationServerKey
. Essa chave é
passados para o serviço de push e usados para verificar se o aplicativo que assinou
o usuário também é o aplicativo que aciona as mensagens push.
Quando acionamos uma mensagem push, enviamos um conjunto de cabeçalhos que enviamos que permitir que o serviço de push autentique o aplicativo. Isso é definido conforme as especificações VAPID.
O que tudo isso realmente significa e o que acontece exatamente? Bem, estas são as etapas seguidas para Autenticação do servidor de aplicativos:
- O servidor do aplicativo assina algumas informações JSON com a chave privada do aplicativo.
- Essas informações assinadas são enviadas ao serviço de push como um cabeçalho em uma solicitação POST.
- O serviço de push usa a chave pública armazenada da qual recebeu
pushManager.subscribe()
para verificar se as informações recebidas estão assinadas por a chave privada relacionada à chave pública. Lembre-se: a chave pública é oapplicationServerKey
transmitido para a chamada de inscrição. - Se as informações assinadas forem válidas, o serviço push enviará o push para o usuário.
Veja 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" adicionado a um cabeçalho na solicitação é um JSON Web Token.
Token da Web JSON
Um token JSON da Web (ou JWT) é uma forma de enviar uma mensagem a terceiros para que o destinatário os valide quem o enviou.
Quando um terceiro recebe uma mensagem, ele precisa solicitar os remetentes chave pública e usá-la para validar a assinatura do JWT. Se o a assinatura é válida, então o JWT deve ter sido assinado com o chave privada, portanto, precisa ser do remetente esperado.
Há várias bibliotecas em https://jwt.io/ (em inglês) que fazer a assinatura por você, e é recomendável conseguem. 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 entendida como três strings unidas por pontos.
A primeira e a segunda strings (as informações do JWT e os dados do JWT) são partes do JSON codificado em base64, o que significa que é publicamente legível.
A primeira string são informações 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 é a dos dados do JWT. Isso fornece informações sobre o remetente do JWT, que ela está destinada e por quanto tempo ela é válida.
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 de aud
é o "público-alvo", ou seja, o público-alvo do JWT. Para a Web, envie o
público é o serviço push, então nós o configuramos para a origem do
serviço.
O valor exp
é a expiração do JWT, isso evita que invasões sejam
reutilizar um JWT se interceptá-lo. A expiração é um carimbo de data e hora
segundos e não deve ser mais de 24 horas.
Em 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 quaisquer 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
.
Dessa forma, se um serviço push precisar entrar em contato com o remetente, ele poderá encontrar
dados de contato do JWT. Por isso, a biblioteca web-push precisava de uma
endereço de e-mail).
Assim como as informações do JWT, os dados do JWT são codificados como uma base64 segura de URL fio.
A terceira string, a assinatura, é o resultado de pegar as duas primeiras strings (as informações do JWT e os dados do JWT), juntando-os com um caractere de ponto, que chamar o "token não assinado" e assiná-lo.
O processo de assinatura exige a criptografia do "token não assinado" usando o ES256. De acordo com o JWT spec, ES256 é a abreviação de "ECDSA usando a curva P-256 e o algoritmo de hash SHA-256". Com a criptografia da Web, é possível 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 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 como o "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 para a Web
push como o cabeçalho Authorization
com WebPush
como prefixo:
Authorization: 'WebPush [JWT Info].[JWT Data].[Signature]';
O protocolo de push da Web também declara que a chave do servidor de aplicativos pública precisa ser
enviada no cabeçalho Crypto-Key
como uma string codificada em base64 segura para URL com
p256ecdsa=
no início.
Crypto-Key: p256ecdsa=[URL Safe Base64 Public Application Server Key]
A criptografia do payload
A seguir, vamos analisar como podemos enviar um payload com uma mensagem push para que, quando nosso aplicativo da Web recebe uma mensagem push, ele pode acessar os dados recebidos.
Uma dúvida comum de qualquer pessoa que tenha usado outros serviços de push é por que a Web payload precisam ser criptografados? Com aplicativos 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 o mesma API (o protocolo de push da Web), os desenvolvedores não precisam se preocupar serviço push é. Podemos fazer uma solicitação no formato certo e esperar a mensagem push seja enviada. A desvantagem disso é que os desenvolvedores podem enviar mensagens para um serviço de push não confiável. De criptografar o payload, o serviço de push não poderá ler os dados enviados. Apenas o navegador pode descriptografar as informações. Isso protege os dados do usuário dados.
A criptografia do payload é definida no pacote Message Encryption spec.
Antes de conferir as etapas específicas para criptografar um payload de mensagens push, vamos abordar algumas técnicas que serão usadas na de desenvolvimento de software. (Grande ponta do chapéu para Mat Scales por seu excelente artigo sobre push encryption.)
ECDH e HKDF
A ECDH e a HKDF são usadas em todo o processo de criptografia e oferecem benefícios para a 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 entre si.
A propriedade útil das chaves geradas com ECDH é que Alice pode usar chave privada e a chave pública de Bob para criar o valor secreto "X". Bob pode fazer pegando a chave privada dele e a pública de Alice para criam o mesmo valor "X" de forma independente. Isso faz com que "X" uma senha secreta e Alice e Bob só precisaram compartilhar a chave pública deles. Agora Bob e Alice é possível usar "X" para criptografar e descriptografar as mensagens entre eles.
Até onde sei, o ECDH define as propriedades das curvas que permitem esse "atributo" de criar uma senha secreta "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 fácil de gerar essas 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 de HKDF:
HKDF é uma função de derivação de chaves baseada em HMAC que transforma qualquer chave fraca em materiais de chave com criptografia forte. Ele pode ser usado para exemplo, para converter os segredos compartilhados de Diffie Hellman adequado para uso em criptografia, verificação de integridade ou autenticação.
Essencialmente, a HKDF recebe entradas que não são especialmente seguras e as torna mais seguras.
A especificação que define essa criptografia exige o uso do SHA-256 como algoritmo de hash. e as chaves resultantes de 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 cobre vagamente o ECDH. e HKDF.
O ECDH é uma maneira segura de compartilhar chaves públicas e gerar uma senha secreta. HKDF é uma maneira de levar material inseguro e torná-lo seguro.
Isso será usado durante a criptografia do nosso payload. A seguir, vamos conferir o que consideramos e como eles são criptografados.
Entradas
Para 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
, mas para uma
Lembre-se, para 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 ser compartilhado fora do 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 manterá a chave privada em segredo e a usará para descriptografar a
payload.
Esses três valores, auth
, p256dh
e payload
, são necessários como entradas e o resultado da
de criptografia será a carga criptografada, um valor de sal e uma chave pública usada apenas para
e criptografam os dados.
Sal
O sal precisa ter 16 bytes de dados aleatórios. Em NodeJS, fazemos 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();
Chamaremos essas chaves de "chaves locais". Elas são usadas apenas para criptografia e têm não há nada a ver com chaves do servidor de aplicativos.
Com o payload, o secret de autenticação e a chave pública de assinatura como entradas e um novo sal e conjunto de chaves locais, estamos prontos para realizar a criptografia.
Chave secreta compartilhada
A primeira etapa é criar um secret compartilhado usando a chave pública da assinatura e o novo chave privada (lembre a explicação 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 do código de autenticação da assinatura de push e a senha 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 pelos navegadores que descriptografam a mensagem e esperam tantos bytes
para a codificação de conteúdo, seguido de um byte com valor 0, seguido pelo
dados criptografados.
Nossa chave pseudoaleatória simplesmente executa a autenticação, o secret compartilhado e uma informação de codificação usando o HKDF, ou seja, reforçando a criptografia.
Contexto
O "contexto" é um conjunto de bytes usado para calcular dois valores posteriormente na criptografia navegador. Essencialmente, é uma matriz de bytes contendo a chave pública de assinatura e o 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, seguida pela própria chave, pelo número de bytes da chave pública local e pela chave por conta própria.
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, 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 a repetição porque ele só deve 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 CEK, que são simplesmente um conteúdo de codificação 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]);
Essa informação é executada pelo HKDF combinando o sal e PRK com o 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á a chave de criptografia de conteúdo e de valor de uso único.
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 a chave, e o valor de uso único é um vetor de inicialização.
No Node, isso é feito da seguinte maneira:
const cipher = crypto.createCipheriv(
'id-aes128-GCM',
contentEncryptionKey,
nonce,
);
Antes de criptografar nosso payload, precisamos definir quanto padding queremos. para adicionar à frente do payload. O motivo para adicionarmos padding é que impede o risco de que os bisbilhoteiros determinem "tipos" de mensagens com base no tamanho do payload.
Você precisa adicionar dois bytes de padding para indicar o comprimento de qualquer padding adicional.
Por exemplo, se você não tiver adicionado padding, terá dois bytes com valor 0, ou seja, não haverá padding, depois deles você lerá o payload. Se você tiver adicionado 5 bytes de preenchimento, os dois primeiros bytes terão um valor de 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 preenchimento e o payload por meio dessa 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!
Tudo o que resta é determinar como essa carga é enviada para o serviço de push.
Cabeçalhos de payload criptografados e corpo
Para enviar este payload criptografado ao serviço push, precisamos definir alguns cabeçalhos diferentes em nossa solicitação POST.
Cabeçalho de criptografia
A "criptografia" O cabeçalho precisa conter o salt usado para criptografar o payload.
O sal de 16 bytes deve ser codificado com segurança de URL 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 em "Application Server Keys"
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á parecido com este:
Crypto-Key: dh=[URL Safe Base64 Encoded Local Public Key String]; p256ecdsa=[URL Safe Base64 Encoded Public Application Server Key]
Tipo de conteúdo, duração e cabeçalhos de codificação
O cabeçalho Content-Length
é o número de bytes no bloco
payload. "Content-Type" e "Content-Encoding" os cabeçalhos 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 solicitação. O Content-Type
está definido como
application/octet-stream
. Isso ocorre porque o payload criptografado precisa ser
enviados 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?
Cobrimos os cabeçalhos usados para JWT / chaves de servidor de aplicativos (ou seja, como identificar a aplicativo com o serviço de push), e abordamos os cabeçalhos usados para enviar um payload.
Há cabeçalhos adicionais que os serviços de push usam para alterar o comportamento de mensagens enviadas. Alguns desses cabeçalhos são obrigatórios, 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.
você quer que a mensagem push fique no serviço de push antes de ser
entregues. Quando o TTL
expirar, a mensagem será removida do
fila do serviço push e ele não será entregue.
TTL: [Time to live in seconds]
Se você definir um TTL
como zero, o serviço de push tentará entregar o
enviar uma mensagem imediatamente, mas se não for possível acessar o dispositivo, ela
será imediatamente removido da fila do serviço push.
Tecnicamente, um serviço de push pode reduzir o TTL
de uma mensagem push se
deseja. É possível saber se isso aconteceu examinando o cabeçalho TTL
na
a resposta de um serviço push.
Tópico
Opcional
Tópicos são strings que podem ser usadas para substituir mensagens pendentes por uma nova mensagem se tiverem nomes de tópicos correspondentes.
Isso é útil nos casos em que várias mensagens são enviadas enquanto uma dispositivo está off-line e você realmente quer que um usuário veja apenas o último quando o dispositivo estiver 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 conservar a vida útil da bateria do dispositivo de um usuário apenas acordar 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 em um só lugar
Se tiver outras dúvidas sobre como tudo isso funciona, confira como as bibliotecas são acionadas mensagens push na organização web-push-libs.
Quando você tiver um payload criptografado e os cabeçalhos acima, basta fazer uma solicitação POST.
ao 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 a um serviço push, verifique o código de status da resposta, que informa 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 uma taxa com um serviço de push. O serviço de push deve incluir uma regra de "Tentar novamente depois" para indicar quanto tempo falta para que outra solicitação seja feita. |
400 | Solicitação inválida. Isso geralmente significa que um dos seus cabeçalhos é inválido ou formatados incorretamente. |
404 | Não encontrado Essa é uma indicação de que a assinatura expirou e não pode ser usado. Nesse caso, exclua "PushSubscription" e aguarde o cliente reinscrever o usuário. |
410 | Pronto. 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 payload de tamanho mínimo que um serviço de push precisa O suporte é de 4.096 bytes (ou 4 kb). |
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 exibir 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