Abbiamo visto come una libreria può essere utilizzata per attivare i messaggi push, ma cosa fanno esattamente queste librerie?
In pratica, effettuano richieste di rete assicurandosi che siano nel formato corretto. La specifica che definisce questa richiesta di rete è il protocollo Web Push.
Questa sezione descrive come il server può identificarsi con le chiavi del server dell'applicazione e come vengono inviati il payload criptato e i dati associati.
Questo non è un aspetto piacevole delle notifiche push web e non sono un esperto di crittografia, ma esaminiamo ogni parte, poiché è utile sapere cosa fanno queste librerie dietro le quinte.
Chiavi del server delle applicazioni
Quando un utente si abbona, trasmettiamo un applicationServerKey. Questa chiave viene passata al servizio push e utilizzata per verificare che l'applicazione che ha iscritto l'utente sia anche quella 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 è definito dalla specifica VAPID).
Cosa significa tutto questo e cosa succede esattamente? Ecco i passaggi per l'autenticazione del server applicazioni:
- Il server dell'applicazione firma alcune informazioni JSON con la propria chiave privata dell'applicazione.
- Queste informazioni firmate vengono inviate al servizio push come intestazione in una richiesta POST.
- Il servizio push utilizza la chiave pubblica memorizzata ricevuta da
pushManager.subscribe()per verificare che le informazioni ricevute siano firmate dalla chiave privata relativa alla chiave pubblica. Ricorda: la chiave pubblica è il valoreapplicationServerKeypassato alla chiamata di iscrizione. - 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. (Nota 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) è 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.
Su jwt.io/ sono disponibili numerose librerie che possono eseguire la firma per te e ti consiglio di utilizzarle, se possibile. Per completezza, vediamo come creare manualmente un JWT firmato.
Push web e JWT firmati
Un JWT firmato è solo una stringa, anche se può essere considerato come tre stringhe unite da punti.
La prima e la seconda stringa (le informazioni e i dati JWT) sono parti di JSON con codifica Base64, il che significa che sono leggibili pubblicamente.
La prima stringa contiene informazioni sul JWT stesso, che indicano l'algoritmo utilizzato per creare la firma.
Le informazioni JWT per le notifiche push web devono contenere i seguenti dati:
{
"typ": "JWT",
"alg": "ES256"
}
La seconda stringa contiene i dati JWT. Fornisce informazioni sul mittente del JWT, sul destinatario e sulla durata della validità.
Per le notifiche push web, i dati avranno questo formato:
{
"aud": "https://some-push-service.org",
"exp": "1469618703",
"sub": "mailto:example@web-push-book.org"
}
Il valore aud è il "pubblico", ovvero il destinatario del JWT. Per il push web, il pubblico è il servizio push, quindi lo impostiamo sull'origine del servizio push.
Il valore exp indica la scadenza del JWT, per impedire a utenti malintenzionati 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;
Il periodo è di 12 ore anziché 24 per evitare problemi con le differenze di orario 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 deve contattare il mittente, può trovare i dati di contatto nel JWT. (Ecco perché 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 (le informazioni JWT e i dati JWT) con un carattere punto, che chiameremo "token non firmato", e della sua firma.
Il processo 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 Web Crypto, puoi creare la firma in questo 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 punti) viene inviato al servizio di push web come intestazione Authorization con WebPush anteposto, in questo modo:
Authorization: 'WebPush [JWT Info].[JWT Data].[Signature]';
Il protocollo Web Push stabilisce inoltre che la chiave pubblica del server dell'applicazione deve essere inviata nell'intestazione Crypto-Key come stringa con codifica base64 sicura per URL con p256ecdsa= anteposta.
Crypto-Key: p256ecdsa=[URL Safe Base64 Public Application Server Key]
La crittografia del payload
Ora vediamo come inviare un payload con un messaggio push in modo che, quando la nostra app web riceve un messaggio push, possa accedere ai dati che riceve.
Una domanda comune che sorge da chiunque abbia utilizzato altri servizi push è: perché il payload delle notifiche push web deve essere criptato? Con le app native, i messaggi push possono inviare dati come testo normale.
Parte della bellezza delle notifiche push web è che, poiché tutti i servizi push utilizzano la stessa API (il protocollo di notifiche push web), gli sviluppatori non devono preoccuparsi di chi sia il servizio push. Possiamo effettuare 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 affidabile. Se il payload viene crittografato, 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 di crittografia dei messaggi.
Prima di esaminare i passaggi specifici per criptare il payload di un messaggio push, dobbiamo illustrare alcune tecniche che verranno utilizzate durante il processo di crittografia. (Un ringraziamento speciale 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 a curva ellittica
Immagina due persone che vogliono condividere informazioni, Alice e Bob. Sia Alice che Bob hanno le proprie chiavi pubbliche e private. Alice e Bob condividono le loro chiavi pubbliche.
La proprietà utile delle chiavi generate con ECDH è che Alice può utilizzare la sua chiave privata e la chiave pubblica di Bob per creare il valore segreto "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" diventa un secret condiviso e Alice e Bob hanno dovuto condividere solo la loro chiave pubblica. Ora Bob e Alice possono utilizzare "X" per criptare e decriptare i messaggi tra di loro.
Per quanto ne so, ECDH definisce le proprietà delle curve che consentono questa "funzionalità" di creare un secret condiviso "X".
Questa è una spiegazione di alto livello di ECDH. Se vuoi saperne di più, ti consiglio di guardare un video di panoramica più dettagliato su ECDH.
In termini di codice, la maggior parte dei linguaggi/delle piattaforme include librerie per semplificare la generazione di queste chiavi.
In Node, dovremmo fare quanto segue:
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 della chiave basata su HMAC che trasforma qualsiasi materiale della chiave debole in materiale della chiave crittograficamente forte. Può essere utilizzata, ad esempio, per convertire i secret condivisi scambiati con Diffie Hellman in materiale della chiave adatto per l'utilizzo nella crittografia, nel controllo dell'integrità o nell'autenticazione.
In sostanza, HKDF prende un input non particolarmente sicuro e lo rende più sicuro.
La specifica che definisce questa crittografia richiede 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).
In Node, l'implementazione potrebbe essere la seguente:
// 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);
}
Un ringraziamento a Mat Scale per l'articolo con questo codice di esempio.
Questo copre in modo approssimativo ECDH e HKDF.
ECDH è un modo sicuro per condividere chiavi pubbliche e generare un secret condiviso. HKDF è un modo per rendere sicuro il materiale non sicuro.
che verrà utilizzata durante la crittografia del nostro payload. Ora vediamo cosa prendiamo come input e come viene criptato.
Input
Quando vogliamo inviare un messaggio push a un utente con un payload, abbiamo bisogno di tre input:
- Il payload stesso.
- Il secret
authdiPushSubscription. - La chiave
p256dhdaPushSubscription.
Abbiamo visto i valori auth e p256dh recuperati da un PushSubscription, ma per un rapido 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 secret e non deve essere condiviso al di fuori della tua applicazione.
La chiave p256dh è una chiave pubblica, a volte chiamata chiave pubblica del client. Qui faremo riferimento a p256dh come chiave pubblica dell'abbonamento. La chiave pubblica dell'abbonamento viene generata dal browser. Il browser manterrà segreta la chiave privata 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 salt e una chiave pubblica utilizzata solo per criptare i dati.
Salt
Il salt deve essere composto da 16 byte di dati casuali. In NodeJS, per creare un salt dobbiamo procedere come segue:
const salt = crypto.randomBytes(16);
Chiavi pubbliche/private
Le chiavi pubbliche e private devono essere generate utilizzando una curva ellittica P-256, come facciamo in Node:
const localKeysCurve = crypto.createECDH('prime256v1');
localKeysCurve.generateKeys();
const localPublicKey = localKeysCurve.getPublicKey();
const localPrivateKey = localKeysCurve.getPrivateKey();
Queste chiavi verranno chiamate "chiavi locali". Vengono utilizzate solo per la crittografia e non hanno nulla a che fare con le chiavi del server dell'applicazione.
Con il payload, l'auth secret e la chiave pubblica dell'abbonamento come input e con un salt e un insieme di chiavi locali appena generati, 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 di ECDH con Alice e Bob? In un attimo.
const sharedSecret = localKeysCurve.computeSecret(
subscription.keys.p256dh,
'base64',
);
Questo valore viene utilizzato nel passaggio successivo per calcolare la chiave pseudo-casuale (PRK).
Chiave pseudo-casuale
La chiave pseudo-random (PRK) è la combinazione del secret di autenticazione della sottoscrizione push e del secret condiviso appena creato.
const authEncBuff = new Buffer('Content-Encoding: auth\0', 'utf8');
const prk = hkdf(subscription.keys.auth, sharedSecret, authEncBuff, 32);
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 è il comportamento previsto dai browser che decrittografano il messaggio, che si aspettano un numero di byte per la codifica dei contenuti, seguito da un byte con valore 0, seguito dai dati criptati.
La nostra chiave pseudo-randomica esegue semplicemente l'autenticazione, il secret condiviso e un'informazione di codifica tramite HKDF (ovvero, la rende 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,
]);
Il buffer di contesto finale è un'etichetta, il numero di byte nella chiave pubblica della sottoscrizione, seguito dalla chiave stessa, poi il numero di byte della chiave pubblica locale, seguito dalla chiave stessa.
Con questo valore di contesto possiamo 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.
Innanzitutto, dobbiamo creare i byte di dati per il nonce e la 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 elaborate tramite HKDF combinando il salt 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 il nonce e la chiave di crittografia dei contenuti.
Esegui la crittografia
Ora che abbiamo la chiave di crittografia dei contenuti, possiamo crittografare il payload.
Creiamo un algoritmo di crittografia AES128 utilizzando la chiave di crittografia dei contenuti come chiave e il nonce come vettore di inizializzazione.
In Node, l'operazione viene eseguita in questo modo:
const cipher = crypto.createCipheriv(
'id-aes128-GCM',
contentEncryptionKey,
nonce,
);
Prima di criptare il payload, dobbiamo definire la quantità di spaziatura interna da aggiungere all'inizio del payload. Il motivo per cui vogliamo aggiungere il padding è che impedisce il rischio che gli intercettatori possano determinare i "tipi" di messaggi in base alle dimensioni del payload.
Devi aggiungere due byte di spaziatura interna per indicare la lunghezza di un'eventuale spaziatura interna aggiuntiva.
Ad esempio, se non hai aggiunto spaziatura interna, avrai due byte con valore 0, ovvero non esiste spaziatura interna, dopo questi due byte leggerai il payload. Se hai aggiunto 5 byte di spaziatura interna, i primi due byte avranno un valore pari a 5, quindi il consumer 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 attraverso questo algoritmo di crittografia.
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 nostro payload criptato. Evviva!
Non resta che determinare come questo payload viene inviato al servizio push.
Intestazioni e corpo del payload criptato
Per inviare questo payload criptato al servizio push, dobbiamo definire alcune intestazioni diverse nella nostra richiesta POST.
Intestazione di crittografia
L'intestazione "Encryption" deve contenere il salt utilizzato per criptare il payload.
Il salt di 16 byte deve essere codificato in base64 in modo sicuro per l'URL e aggiunto all'intestazione Encryption, in questo modo:
Encryption: salt=[URL Safe Base64 Encoded Salt]
Intestazione Crypto-Key
Abbiamo visto che l'intestazione Crypto-Key viene utilizzata nella sezione "Chiavi del server applicazioni" per contenere la chiave pubblica del server 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 di tipo, lunghezza e codifica dei contenuti
L'intestazione Content-Length indica 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'
Una volta impostate queste intestazioni, dobbiamo inviare il payload criptato come corpo della richiesta. Nota che Content-Type è impostato su application/octet-stream. Questo perché il payload criptato deve essere inviato come stream di byte.
In NodeJS, l'operazione viene eseguita in questo modo:
const pushRequest = https.request(httpsOptions, function(pushResponse) {
pushRequest.write(encryptedPayload);
pushRequest.end();
Altre intestazioni?
Abbiamo trattato le intestazioni utilizzate per le chiavi JWT/del server applicazioni (ovvero come identificare l'applicazione con il servizio push) e le intestazioni utilizzate per inviare un payload criptato.
Esistono intestazioni aggiuntive che i servizi push utilizzano per modificare il comportamento dei messaggi inviati. Alcune di queste intestazioni sono obbligatorie, mentre altre sono facoltative.
Intestazione TTL
Obbligatorio
TTL (o durata TTL) è un numero intero che specifica il numero di secondi per cui vuoi che il messaggio push rimanga nel servizio push prima di essere recapitato. Alla scadenza del 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 recapitare il messaggio immediatamente, ma se il dispositivo non è raggiungibile, il messaggio verrà immediatamente rimosso dalla coda del servizio push.
Tecnicamente, un servizio push può ridurre il valore TTL di un messaggio push, se lo desidera. Puoi capire se questo è successo 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 i nomi degli argomenti corrispondono.
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 viene acceso.
Urgenza
Facoltativo
L'urgenza indica al servizio push l'importanza di un messaggio per l'utente. Questo campo può essere utilizzato dal servizio push per contribuire a preservare la durata della batteria del dispositivo di un utente, attivandosi 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]
Tutto insieme
Se hai altre domande su come funziona tutto questo, puoi sempre vedere come le librerie attivano i messaggi push su the web-push-libs org.
Una volta ottenuto un payload criptato e le intestazioni riportate sopra, devi solo effettuare una richiesta POST
a endpoint in un PushSubscription.
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 indicherà se la richiesta è andata a buon fine o meno.
| Codice di stato | Descrizione |
|---|---|
| 201 | Creata. La richiesta di invio di un messaggio push è stata ricevuta e accettata. |
| 429 | Numero richieste eccessivo. Ciò significa che il tuo server applicazioni ha raggiunto un limite di frequenza con un servizio push. Il servizio push deve includere un'intestazione "Retry-After" per indicare quanto tempo deve trascorrere prima che sia possibile effettuare un'altra richiesta. |
| 400 | Richiesta non valida. In genere, questo significa che una delle intestazioni non è valida o non è formattata correttamente. |
| 404 | Non trovato. Questo indica che l'abbonamento è scaduto e non può essere utilizzato. In questo caso, devi eliminare l'oggetto `PushSubscription` e attendere che il client iscriva nuovamente l'utente. |
| 410 | Non più disponibile. L'abbonamento non è più valido e deve essere rimosso dal server dell'applicazione. Questo problema può essere riprodotto chiamando `unsubscribe()` su un oggetto `PushSubscription`. |
| 413 | Dimensioni del payload troppo grandi. La dimensione minima del payload che un servizio push deve supportare è di 4096 byte (o 4 kB). |
Per saperne di più sui codici di stato HTTP, puoi anche leggere lo standard Web Push (RFC8030).
Passaggi successivi
- Panoramica delle notifiche push web
- Come funziona Push
- Iscrizione di un utente
- UX delle autorizzazioni
- Invio di messaggi con le librerie di push sul web
- 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 segnalazione di bug