Abbiamo visto come è possibile utilizzare una libreria per attivare i messaggi push, ma cosa fanno esattamente queste librerie?
Sta effettuando richieste di rete, garantendo al contempo che siano nel formato corretto. La specifica che definisce questa richiesta di rete è il protocollo Web Push.
Questa sezione illustra in che modo il server può identificarsi con le chiavi del server di applicazioni e in che modo vengono inviati il payload criptato e i dati associati.
Non è un aspetto piacevole delle notifiche push web e non sono un esperto di crittografia, ma esaminiamo ogni componente perché è utile sapere cosa fanno queste librerie sotto il cofano.
Chiavi server delle applicazioni
Quando sottoscriviamo l'iscrizione di un utente, trasmettiamo un applicationServerKey
. Questa chiave viene passata al servizio push e utilizzata per verificare che l'applicazione che ha sottoscritto l'abbonamento dell'utente sia anche l'applicazione che attiva i messaggi push.
Quando attiviamo un messaggio push, inviamo un insieme di intestazioni che consentono al servizio push di autenticare l'applicazione. (Questo valore è definito dalle specifiche VAPID).
Cosa significa tutto questo e cosa accade esattamente? Ecco i passaggi eseguiti per l'autenticazione del server dell'applicazione:
- Il server dell'applicazione firma alcune informazioni JSON con la propria chiave dell'applicazione privata.
- Queste informazioni firmate vengono inviate al servizio push come intestazione in una richiesta POST.
- Il servizio push utilizza la chiave pubblica memorizzata che ha ricevuto da
pushManager.subscribe()
per verificare che le informazioni ricevute siano firmate dalla chiave privata relativa alla chiave pubblica. Ricorda: la chiave pubblica è ilapplicationServerKey
passato alla chiamata di sottoscrizione. - Se le informazioni firmate sono valide, il servizio push invia il messaggio push all'utente.
Di seguito è riportato un esempio di questo flusso di informazioni. Tieni presente la legenda in basso a sinistra che indica le chiavi pubbliche e private.
Le "informazioni firmate" aggiunte a un'intestazione nella richiesta sono un token web JSON.
Token web JSON
Un token web JSON (o JWT per breve) è un modo per inviare un messaggio a una terza parte in modo che il destinatario possa convalidare chi lo ha inviato.
Quando una terza parte riceve un messaggio, deve ottenere la chiave pubblica del mittente e utilizzarla per convalidare la firma del JWT. Se la firma è valida, il JWT deve essere stato firmato con la chiave privata corrispondente, quindi deve provenire dal mittente previsto.
In https://jwt.io/ sono disponibili varie librerie che possono eseguire la firma al tuo posto e ti consiglio di farlo dove possibile. Per completezza, vediamo come creare manualmente un JWT firmato.
Web push e JWT firmati
Un JWT firmato è semplicemente una stringa, anche se può essere considerato come tre stringhe unite da punti.
La prima e la seconda stringa (Informazioni JWT e Dati JWT) sono frammenti di JSON con codifica base64, il che significa che sono leggibili pubblicamente.
La prima stringa contiene informazioni sul JWT stesso e indica l'algoritmo utilizzato per creare la firma.
Le informazioni JWT per le notifiche web devono contenere le seguenti informazioni:
{
"typ": "JWT",
"alg": "ES256"
}
La seconda stringa è costituita dai dati JWT. Fornisce informazioni sul mittente del JWT, a chi è destinato e per quanto tempo è valido.
Per le notifiche push web, i dati avranno il seguente formato:
{
"aud": "https://some-push-service.org",
"exp": "1469618703",
"sub": "mailto:example@web-push-book.org"
}
Il valore aud
è il "pubblico", ovvero a chi è destinato il JWT. Per le notifiche push web, il segmento di pubblico è il servizio push, quindi lo impostiamo sull'origine del servizio push.
Il valore exp
è la data di scadenza del JWT, che impedisce agli autori di intrusioni di riutilizzare un JWT se lo intercettano. La scadenza è un timestamp in secondi e non deve superare le 24 ore.
In Node.js la scadenza viene impostata utilizzando:
Math.floor(Date.now() / 1000) + 12 * 60 * 60;
Sono 12 ore anziché 24 per evitare eventuali problemi con le differenze di orologio tra l'applicazione di invio e il servizio push.
Infine, il valore sub
deve essere un URL o un indirizzo email mailto
.
In questo modo, se un servizio push ha bisogno di contattare il mittente, può trovare i dati di contatto del JWT. (Questo è il motivo per cui la libreria web-push aveva bisogno di un
indirizzo email).
Come le informazioni JWT, i dati JWT sono codificati come stringa base64 sicura per l'URL.
La terza stringa, la firma, è il risultato dell'unione delle prime due stringhe (JWT Info e JWT Data) con un carattere punto, che chiameremo "token non firmato", e della firma.
La procedura di firma richiede la crittografia del "token non firmato" utilizzando ES256. Secondo la specifica JWT, ES256 è l'abbreviazione di "ECDSA che utilizza la curva P-256 e l'algoritmo di hashing SHA-256". Utilizzando la crittografia web, puoi creare la firma nel seguente modo:
// 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);
});
Un servizio push può convalidare un JWT utilizzando la chiave pubblica del server dell'applicazione per decriptare la firma e assicurarsi che la stringa decriptata sia uguale al "token non firmato" (ovvero le prime due stringhe nel JWT).
Il JWT firmato (ovvero tutte e tre le stringhe unite da puntini) viene inviato al servizio push web come intestazione Authorization
con WebPush
anteposto, come segue:
Authorization: 'WebPush [JWT Info].[JWT Data].[Signature]';
Il protocollo Web Push stabilisce inoltre che la chiave del server di applicazioni pubblico deve essere inviata nell'intestazione Crypto-Key
come stringa con codifica base64 sicura per l'URL con p256ecdsa=
anteposto.
Crypto-Key: p256ecdsa=[URL Safe Base64 Public Application Server Key]
La crittografia del payload
Vediamo ora come inviare un payload con un messaggio push in modo che, quando la nostra app web lo riceve, possa accedere ai dati ricevuti.
Una domanda comune che sorge da chi ha utilizzato altri servizi push è perché il payload push web deve essere criptato? Con le app native, i messaggi push possono inviare dati in testo normale.
Uno dei vantaggi delle notifiche push web è che, poiché tutti i servizi push utilizzano la stessa API (il protocollo push web), gli sviluppatori non devono preoccuparsi di chi sia il servizio push. Possiamo inviare una richiesta nel formato corretto e aspettarci che venga inviato un messaggio push. Lo svantaggio è che gli sviluppatori potrebbero inviare messaggi a un servizio push non attendibile. Se il payload viene criptato, un servizio push non può leggere i dati inviati. Solo il browser può decriptare le informazioni. In questo modo i dati dell'utente vengono protetti.
La crittografia del payload è definita nella specifica della crittografia dei messaggi.
Prima di esaminare i passaggi specifici per criptare il payload dei messaggi push, dobbiamo esaminare alcune tecniche che verranno utilizzate durante il processo di crittografia. (Un grande ringraziamento a Mat Scales per il suo eccellente articolo sulla crittografia push.)
ECDH e HKDF
Sia ECDH che HKDF vengono utilizzati durante il processo di crittografia e offrono vantaggi ai fini della crittografia delle informazioni.
ECDH: scambio di chiavi Diffie-Hellman con curva ellittica
Immagina di avere due persone che desiderano condividere informazioni, Alice e Bob. Sia Alice che Bob hanno le proprie chiavi pubbliche e private. Alice e Bob condividono le loro chiavi pubbliche tra loro.
L'utile proprietà delle chiavi generate con ECDH è che Alice può utilizzare la sua chiave privata e la chiave pubblica di Roberto per creare il valore del secret "X". Bob può fare lo stesso, utilizzando la sua chiave privata e la chiave pubblica di Alice per creare in modo indipendente lo stesso valore "X". In questo modo "X" è un segreto condiviso e Alice e Bob hanno dovuto solo condividere la loro chiave pubblica. Ora Bob e Alice possono usare "X" per criptare e decriptare i messaggi tra loro.
Per quanto mi risulta, la crittografia ECDH definisce le proprietà delle curve che consentono questa "funzionalità" di creare una chiave segreta condivisa "X".
Questa è una spiegazione generale della crittografia ECDH. Per saperne di più, ti consiglio di guardare questo video.
In termini di codice, la maggior parte dei linguaggi/delle piattaforme è dotata di librerie per semplificare la generazione di queste chiavi.
In Node:
const keyCurve = crypto.createECDH('prime256v1');
keyCurve.generateKeys();
const publicKey = keyCurve.getPublicKey();
const privateKey = keyCurve.getPrivateKey();
HKDF: funzione di derivazione della chiave basata su HMAC
Wikipedia fornisce una descrizione concisa di HKDF:
HKDF è una funzione di derivazione delle chiavi basata su HMAC che trasforma qualsiasi materiale della chiave debole in materiale della chiave crittograficamente sicuro. Può essere utilizzato, ad esempio, per convertire i secret condivisi scambiati con Diffie Hellman in materiale della chiave adatto per l'utilizzo in crittografia, controllo dell'integrità o autenticazione.
In sostanza, HKDF prende input non particolarmente sicuri e li rende più sicuri.
Le specifiche che definiscono questa crittografia richiedono l'utilizzo di SHA-256 come algoritmo di hashing e le chiavi risultanti per HKDF nelle notifiche push web non devono superare i 256 bit (32 byte).
Nel nodo, questo potrebbe essere implementato nel seguente modo:
// 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);
}
Consiglio per l'articolo di Mat Scale per questo esempio di codice.
Questo riguarda in modo approssimativo ECDH e HKDF.
La crittografia ECDH è un modo sicuro per condividere le chiavi pubbliche e generare una chiave segreta condivisa. HKDF è un modo per proteggere materiali non sicuri.
Verrà utilizzato durante la crittografia del nostro payload. Vediamo ora cosa consideriamo input e come viene criptato.
Input
Per inviare un messaggio push a un utente con un payload, dobbiamo utilizzare tre input:
- Il payload stesso.
- Il secret
auth
delPushSubscription
. - La chiave
p256dh
delPushSubscription
.
Abbiamo notato che i valori auth
e p256dh
vengono recuperati da un PushSubscription
. Tuttavia, per un
breve promemoria, dato un abbonamento, avremmo bisogno di questi valori:
subscription.toJSON().keys.auth;
subscription.toJSON().keys.p256dh;
subscription.getKey('auth');
subscription.getKey('p256dh');
Il valore auth
deve essere trattato come un segreto e non deve essere condiviso al di fuori dell'applicazione.
La chiave p256dh
è una chiave pubblica, a volte indicata come chiave pubblica del client. Qui
ci riferiremo a p256dh
come alla chiave pubblica dell'abbonamento. La chiave pubblica dell'abbonamento viene generata
dal browser. Il browser manterrà la chiave privata segreta e la utilizzerà per decriptare il payload.
Questi tre valori, auth
, p256dh
e payload
, sono necessari come input e il risultato del processo di crittografia sarà il payload criptato, un valore di salt e una chiave pubblica utilizzata solo per criptare i dati.
Sale
Il sale deve essere costituito da 16 byte di dati casuali. In NodeJS, per creare un sale, eseguiremo i seguenti passaggi:
const salt = crypto.randomBytes(16);
Chiavi pubbliche/private
Le chiavi pubblica e privata devono essere generate utilizzando una curva ellittica P-256, come faresti in Node nel seguente modo:
const localKeysCurve = crypto.createECDH('prime256v1');
localKeysCurve.generateKeys();
const localPublicKey = localKeysCurve.getPublicKey();
const localPrivateKey = localKeysCurve.getPrivateKey();
Ci riferiremo a queste chiavi come "chiavi locali". Sono utilizzate solo per la crittografia e non hanno nulla a che fare con le chiavi dei server delle applicazioni.
Con il payload, il segreto di autenticazione e la chiave pubblica dell'abbonamento come input e con un nuovo generatore di salt e un insieme di chiavi locali, siamo pronti per eseguire la crittografia.
Secret condiviso
Il primo passaggio consiste nel creare un secret condiviso utilizzando la chiave pubblica dell'abbonamento e la nostra nuova chiave privata (ricordi la spiegazione dell'ECDH con Alice e Bob? è semplicissimo).
const sharedSecret = localKeysCurve.computeSecret(
subscription.keys.p256dh,
'base64',
);
che verrà utilizzata nel passaggio successivo per calcolare la pseudo chiave casuale (PRK).
Chiave pseudo casuale
La chiave pseudo casuale (PRK) è la combinazione del segreto di autenticazione dell'abbonamento push e del segreto condiviso che abbiamo appena creato.
const authEncBuff = new Buffer('Content-Encoding: auth\0', 'utf8');
const prk = hkdf(subscription.keys.auth, sharedSecret, authEncBuff, 32);
Forse ti starai chiedendo a cosa serve la stringa Content-Encoding: auth\0
.
In breve, non ha uno scopo chiaro, anche se i browser potrebbero decriptare un messaggio in arrivo e cercare la codifica dei contenuti prevista.
\0
aggiunge un byte con un valore pari a 0 alla fine del buffer. Questo è previsto dai browser che decriptano il messaggio, che si aspettano così tanti byte per la codifica dei contenuti, seguiti da un byte con valore 0 e dai dati criptati.
La nostra pseudo chiave casuale esegue semplicemente l'autenticazione, il secret condiviso e un'informazione di codifica tramite HKDF (ovvero rendendola crittograficamente più efficace).
Contesto
Il "contesto" è un insieme di byte utilizzato per calcolare due valori in un secondo momento nel browser di crittografia. Si tratta essenzialmente di un array di byte contenente la chiave pubblica dell'abbonamento e la chiave pubblica locale.
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,
]);
L'ultimo buffer di contesto è un'etichetta, il numero di byte della chiave pubblica dell'abbonamento, seguito dalla chiave stessa, quindi il numero di byte della chiave pubblica locale, seguito dalla chiave stessa.
Con questo valore del contesto possiamo utilizzarlo per creare un nonce e una chiave di crittografia dei contenuti (CEK).
Chiave di crittografia dei contenuti e nonce
Un nonce è un valore che impedisce gli attacchi di replay, in quanto deve essere utilizzato una sola volta.
La chiave di crittografia dei contenuti (CEK) è la chiave che verrà utilizzata per criptare il payload.
Per prima cosa dobbiamo creare i byte di dati per il nonce e CEK, che è semplicemente una stringa di codifica dei contenuti seguita dal buffer di contesto che abbiamo appena calcolato:
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]);
Queste informazioni vengono sottoposte ad HKDF combinando il sale e la PRK con 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);
In questo modo otteniamo la chiave di crittografia dei contenuti e il nonce.
Esegui la crittografia
Ora che abbiamo la chiave di crittografia dei contenuti, possiamo criptare il payload.
Creiamo un'algoritmo di crittografia AES128 utilizzando la chiave di crittografia dei contenuti come chiave e il nonce è un vettore di inizializzazione.
In Node, questa operazione viene eseguita nel seguente modo:
const cipher = crypto.createCipheriv(
'id-aes128-GCM',
contentEncryptionKey,
nonce,
);
Prima di criptare il payload, dobbiamo definire la quantità di spaziatura aggiuntiva da aggiungere all'inizio del payload. Il motivo per cui vogliamo aggiungere il padding è che impedisce il rischio che gli intercettatori riescano a determinare i "tipi" di messaggi in base alle dimensioni del payload.
Devi aggiungere due byte di spaziatura interna per indicare la lunghezza di eventuali spaziature interne aggiuntive.
Ad esempio, se non hai aggiunto spaziatura, avrai due byte con valore 0, ovvero non esiste spaziatura, dopo questi due byte leggerai il payload. Se hai aggiunto 5 byte di spaziatura, i primi due byte avranno un valore di 5, quindi il consumatore leggerà altri cinque byte e poi inizierà a leggere il payload.
const padding = new Buffer(2 + paddingLength);
// The buffer must be only zeros, except the length
padding.fill(0);
padding.writeUInt16BE(paddingLength, 0);
Quindi, eseguiamo il padding e il payload tramite questo cifrario.
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()]);
Ora abbiamo il payload criptato. Benissimo!
Tutto ciò che rimane da determinare è determinare come questo payload viene inviato al servizio push.
Intestazioni e corpo del payload crittografati
Per inviare questo payload criptato al servizio push, dobbiamo definire alcune intestazioni diverse nella richiesta POST.
Intestazione di crittografia
L'intestazione "Encryption" deve contenere il salt utilizzato per criptare il payload.
Il sale di 16 byte deve essere codificato in base64 sicuro per il web e aggiunto all'intestazione Encryption, come segue:
Encryption: salt=[URL Safe Base64 Encoded Salt]
Intestazione Crypto-Key
Abbiamo notato che l'intestazione Crypto-Key
viene utilizzata nella sezione "Application Server Keys" (Chiavi server applicazione) per contenere la chiave pubblica del server delle applicazioni.
Questa intestazione viene utilizzata anche per condividere la chiave pubblica locale utilizzata per criptare il payload.
L'intestazione risultante sarà simile alla seguente:
Crypto-Key: dh=[URL Safe Base64 Encoded Local Public Key String]; p256ecdsa=[URL Safe Base64 Encoded Public Application Server Key]
Intestazioni per tipo di contenuti, durata e codifica
L'intestazione Content-Length
è il numero di byte nel payload criptato. Le intestazioni "Content-Type" e "Content-Encoding" sono valori fissi.
come mostrato di seguito.
Content-Length: [Number of Bytes in Encrypted Payload]
Content-Type: 'application/octet-stream'
Content-Encoding: 'aesgcm'
Con queste intestazioni impostate, dobbiamo inviare il payload criptato come corpo della nostra richiesta. Tieni presente che Content-Type
è impostato su
application/octet-stream
. Questo perché il payload criptato deve essere
inviato come flusso di byte.
In NodeJS lo faremmo nel seguente modo:
const pushRequest = https.request(httpsOptions, function(pushResponse) {
pushRequest.write(encryptedPayload);
pushRequest.end();
Altre intestazioni?
Abbiamo trattato le intestazioni utilizzate per le chiavi JWT/Application Server (ovvero come identificare l'applicazione con il servizio push) e le intestazioni utilizzate per inviare un payload criptato.
Esistono intestazioni aggiuntive utilizzate dai servizi push per modificare il comportamento dei messaggi inviati. Alcune di queste intestazioni sono obbligatorie, mentre altre sono facoltative.
Intestazione TTL
Obbligatorio
TTL
(o TTL, time to live) è un numero intero che specifica il numero di secondi che vuoi che il messaggio push rimanga attivo nel servizio push prima di essere inviato. Alla scadenza del token TTL
, il messaggio verrà rimosso dalla coda del servizio push e non verrà recapitato.
TTL: [Time to live in seconds]
Se imposti un valore TTL
pari a zero, il servizio push tenterà di inviare immediatamente il messaggio, ma se il dispositivo non è raggiungibile, il messaggio verrà eliminato immediatamente dalla coda del servizio push.
Tecnicamente, un servizio push può ridurre il TTL
di un messaggio push se lo vuole. Puoi capire se ciò è accaduto esaminando l'intestazione TTL
nella risposta di un servizio push.
Argomento
Facoltativo
Gli argomenti sono stringhe che possono essere utilizzate per sostituire un messaggio in attesa con un nuovo messaggio se hanno nomi corrispondenti.
Questo è utile in scenari in cui vengono inviati più messaggi mentre un dispositivo è offline e vuoi che un utente veda solo l'ultimo messaggio quando il dispositivo è acceso.
Urgenza
Facoltativo
L'urgenza indica al servizio push l'importanza di un messaggio per l'utente. Questo puo essere utilizzato dal servizio push per contribuire a preservare la durata della batteria del dispositivo di un utente risvegliando il servizio solo per i messaggi importanti quando la batteria è in esaurimento.
Il valore dell'intestazione è definito come mostrato di seguito. Il valore predefinito è normal
.
Urgency: [very-low | low | normal | high]
Tutti insieme
Se hai altre domande su come funziona tutto questo, puoi sempre vedere come le librerie attivano i messaggi push su web-push-libs org.
Una volta ottenuto un payload criptato e le intestazioni riportate sopra, devi solo inviare una richiesta POST al endpoint
in un PushSubscription
.
Che cosa facciamo con la risposta a questa richiesta POST?
Risposta dal servizio push
Dopo aver inviato una richiesta a un servizio push, devi controllare il codice di stato della risposta, che ti indica se la richiesta è andata a buon fine o meno.
Codice di stato | Descrizione |
---|---|
201 | Creata. La richiesta di inviare un messaggio push è stata ricevuta e accettata. |
429 | Troppe richieste. Ciò significa che il server delle applicazioni ha raggiunto un limite di frequenza con un servizio push. Il servizio push deve includere un'intestazione "Retry-After" per indicare il tempo che deve trascorrere prima che possa essere effettuata un'altra richiesta. |
400 | Richiesta non valida. In genere, questo significa che una delle intestazioni non è valida o è formattata in modo errato. |
404 | Non trovato. Questo indica che l'abbonamento è scaduto e non può essere utilizzato. In questo caso, devi eliminare "PushSubscription" e attendere che il client riabboni l'utente. |
410 | Non più disponibile. L'abbonamento non è più valido e deve essere rimosso dall'application server. Questo problema può essere riprodotto chiamando `unsubscribe()` su un `PushSubscription`. |
413 | Dimensioni del payload troppo grandi. Le dimensioni minime del payload che un servizio push deve supportare sono 4096 byte (o 4 KB). |
Per ulteriori informazioni sui codici di stato HTTP, puoi anche leggere lo standard Web Push (RFC8030).
Passaggi successivi
- Panoramica delle notifiche push web
- Come funzionano le notifiche push
- Iscrizione di un utente
- UX per le autorizzazioni
- Invio di messaggi con le librerie Web Push
- Protocollo web push
- Gestione degli eventi push
- Visualizzazione di una notifica
- Comportamento delle notifiche
- Pattern di notifica comuni
- Domande frequenti sulle notifiche push
- Problemi comuni e bug dei report