Abbiamo visto come è possibile usare una libreria per attivare i messaggi push, ma cosa fanno esattamente queste librerie?
Stanno facendo richieste di rete garantendo al contempo il 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 delle applicazioni e come vengono inviati il payload criptato e i dati associati.
Questo non è un bel lato del web push e non sono esperto di crittografia, ma analizziamo ogni parte perché è utile sapere cosa fanno queste librerie in background.
Chiavi server dell'applicazione
Quando ci iscriviamo a un utente, trasmettiamo un applicationServerKey
. Questa chiave viene passata al servizio push e utilizzata per verificare che l'applicazione che ha sottoscritto l'abbonamento all'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 viene definito dalle specifiche VAPID.
Che cosa significa effettivamente tutto questo e che cosa succede esattamente? Ecco i passaggi per l'autenticazione dei server di applicazioni:
- Il server delle applicazioni firma alcune informazioni JSON con la propria chiave 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
trasmesso nella chiamata di abbonamento. - 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 per indicare le chiavi pubbliche e private.
Le "informazioni firmate" aggiunte a un'intestazione della richiesta sono un token web JSON.
Token web JSON
Un token web JSON (o in breve JWT) è un modo per inviare un messaggio a una terza parte in modo che il destinatario possa convalidare chi l'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 https://jwt.io/ sono disponibili numerose librerie che possono eseguire la firma per te. Ti consiglio di farlo dove puoi. Per completezza, vediamo come creare manualmente un JWT firmato.
Web push e JWT firmati
Un JWT firmato è semplicemente una stringa, anche se può essere pensato come tre stringhe unite da punti.
La prima e la seconda stringa (le informazioni JWT e i dati JWT) sono pezzi di JSON che sono stati codificati in base64, il che significa che sono leggibili pubblicamente.
La prima stringa contiene informazioni sul JWT stesso, che indicano quale algoritmo è stato utilizzato per creare la firma.
Le informazioni JWT per il web push 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 il web push, 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 il destinatario del JWT. Per il web push il pubblico è il servizio push, quindi lo impostiamo sull'origine del servizio push.
Il valore exp
è la scadenza del JWT, che impedisce agli snoopers 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 necessarie 12 ore anziché 24 ore 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 doveva contattare il mittente, possa trovare le informazioni di contatto del JWT. Questo è il motivo per cui la libreria push web
ha bisogno di un indirizzo email.
Proprio come JWT Info, 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 (i dati JWT e dei dati JWT) con un punto, che chiameremo "token non firmato", e la firma.
Il processo di firma richiede la crittografia del "token non firmato" tramite ES256. Secondo le specifiche JWT, ES256 è l'abbreviazione di "ECDSA using the P-256 curve and the SHA-256 hash algoritmi". Con le criptovalute web 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 delle applicazioni 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 web push sotto forma di intestazione Authorization
con l'intestazione WebPush
anteposta, in questo modo:
Authorization: 'WebPush [JWT Info].[JWT Data].[Signature]';
Il protocollo web push indica inoltre che la chiave pubblica del server delle applicazioni deve essere inviata nell'intestazione Crypto-Key
come stringa con codifica Base64 sicura dell'URL con anteposta p256ecdsa=
.
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 riceve un messaggio push, possa accedere ai dati che riceve.
Una domanda che nasce da tutti coloro che hanno utilizzato altri servizi push è perché il payload push web deve essere criptato? Con le app native, i messaggi push possono inviare dati come testo normale.
Uno dei vantaggi del web push è che, poiché tutti i servizi push utilizzano la stessa API (il protocollo web push), gli sviluppatori non devono preoccuparsi di quale sia il servizio push. Possiamo effettuare una richiesta nel formato corretto e aspettarci che venga inviato un messaggio push. Lo svantaggio di questo è che gli sviluppatori potrebbero potenzialmente inviare messaggi a un servizio push non affidabile. Criptando il payload, un servizio push non può leggere i dati inviati. Solo il browser può decriptare le informazioni. In questo modo i dati dell'utente sono protetti.
La crittografia del payload è definita nella specifica di Message Encryption.
Prima di esaminare i passaggi specifici per criptare un payload dei messaggi push, dovremmo esaminare alcune tecniche che verranno utilizzate durante il processo di crittografia. (enorme punta di cappello a Mat Scales per il suo eccellente articolo sulla crittografia push.)
ECDH e HKDF
Sia ECDH che HKDF vengono utilizzati durante tutto il processo di crittografia e offrono vantaggi legati alla crittografia delle informazioni.
ECDH: scambio chiave Diffie-Hellman della curva ellittica
Immagina di avere due persone che vogliono condividere informazioni, Alice e Bob. Sia Alice che Bob hanno le proprie chiavi pubbliche e private. Alice e Bob condividono tra loro le loro chiavi pubbliche.
L'utile proprietà delle chiavi generate con ECDH è che Alice può utilizzare la propria 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". Per questo motivo "X" è un segreto condiviso e Alice e Bob devono condividere solo la loro chiave pubblica. Ora Bob e Alice possono usare la "X" per criptare e decriptare i messaggi tra loro.
Per quanto ne so, ECDH definisce le proprietà delle curve che consentono questa "caratteristica" della creazione di un secret condiviso "X".
Questa è una spiegazione generale dell'ECDH. Per saperne di più, ti consiglio di guardare questo video.
In termini di codice, la maggior parte dei linguaggi / piattaforme è dotata di librerie che semplificano la generazione di queste chiavi.
Nel nodo eseguirei 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
Su Wikipedia, la descrizione sintetica di HKDF è:
HKDF è una funzione di derivazione delle chiavi basata su HMAC che trasforma qualsiasi materiale di chiave debole in materiale con chiave crittografica efficace. Può essere utilizzato, ad esempio, per convertire Diffie Hellman ha scambiato secret condivisi in materiale delle chiavi adatto per l'utilizzo per la crittografia, il controllo dell'integrità o l'autenticazione.
Essenzialmente, HKDF prenderà input non particolarmente sicuri e li renderà più sicuri.
Le specifiche che definiscono questa crittografia richiedono l'uso di SHA-256 come algoritmo hash e le chiavi risultanti per HKDF nel web push non devono superare i 256 bit (32 byte).
Nel nodo potrebbe essere implementato in questo 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);
}
Punta del cappello nell'articolo della bilancia Mat per questo codice di esempio.
Questa sezione copre apertamente ECDH e HKDF.
ECDH, un modo sicuro per condividere chiavi pubbliche e generare un secret condiviso. HKDF è un modo per prendere materiale non sicuro e renderlo sicuro.
che verrà utilizzato durante la crittografia del nostro payload. Adesso vediamo cosa prendiamo come input e come viene criptato.
Input
Quando vogliamo inviare un messaggio push a un utente con un payload, occorrono tre input:
- Il payload stesso.
- Il secret
auth
diPushSubscription
. - La chiave
p256dh
delPushSubscription
.
Abbiamo notato che i valori auth
e p256dh
vengono recuperati da un valore PushSubscription
. Tuttavia, come promemoria rapido, a causa di 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 considerato un secret e non deve essere condiviso all'esterno della tua applicazione.
La chiave p256dh
è una chiave pubblica, talvolta definita chiave pubblica del client. In questo caso faremo riferimento a p256dh
come chiave pubblica dell'abbonamento. La chiave pubblica dell'abbonamento viene generata
dal browser. Il browser manterrà il secret della chiave privata e lo 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 sale e una chiave pubblica utilizzata solo per la crittografia dei dati.
Sali
Il sale deve contenere 16 byte di dati casuali. In NodeJS, dobbiamo creare un sale nel seguente modo:
const salt = crypto.randomBytes(16);
Chiavi pubbliche / private
Le chiavi pubbliche e private dovrebbero essere generate utilizzando una curva ellittica P-256, operazione che eseguiresti in Node in questo modo:
const localKeysCurve = crypto.createECDH('prime256v1');
localKeysCurve.generateKeys();
const localPublicKey = localKeysCurve.getPublicKey();
const localPrivateKey = localKeysCurve.getPrivateKey();
Chiameremo queste chiavi come "chiavi locali". Vengono utilizzati solo per la crittografia e non hanno nulla a che fare con le chiavi server delle applicazioni.
Con il payload, il secret di autenticazione e la chiave pubblica di sottoscrizione come input, nonché con un sale e un set di chiavi locali appena generati, siamo pronti per eseguire effettivamente 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 ECDH con Alice e Bob? in tutta semplicità).
const sharedSecret = localKeysCurve.computeSecret(
subscription.keys.p256dh,
'base64',
);
Questa viene utilizzata nel passaggio successivo per calcolare la pseudo-chiave casuale (PRK).
Chiave pseudo casuale
La pseudo-chiave casuale (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);
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 valore 0 alla fine del buffer. Ciò è 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 (rendendola più efficace a livello crittografico).
Contesto
Il "contesto" è un insieme di byte utilizzato per calcolare in un secondo momento due valori nel browser di crittografia. È essenzialmente 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, ovvero il numero di byte nella chiave pubblica dell'abbonamento seguito dalla chiave stessa, quindi il numero di byte della chiave pubblica locale e infine la chiave stessa.
Con questo valore di contesto possiamo utilizzarlo nella creazione di un nonce e di una chiave di crittografia dei contenuti (CEK).
Chiave e nonce di crittografia dei contenuti
Un nonce è un valore che impedisce gli attacchi di ripetizione, in quanto dovrebbe essere utilizzato una sola volta.
La chiave di crittografia dei contenuti (CEK) è la chiave che verrà utilizzata alla fine per criptare il nostro 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 elaborate tramite HKDF combinando il sale e 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);
Questo ci fornisce la nostra chiave di crittografia nonce e dei contenuti.
Eseguire la crittografia
Ora che abbiamo la nostra chiave di crittografia dei contenuti, possiamo criptare il payload.
Creiamo una crittografia AES128 utilizzando la chiave di crittografia dei contenuti come chiave e il nonce è un vettore di inizializzazione.
In Node, puoi farlo in questo modo:
const cipher = crypto.createCipheriv(
'id-aes128-GCM',
contentEncryptionKey,
nonce,
);
Prima di criptare il payload, dobbiamo definire la spaziatura interna che vogliamo aggiungere all'inizio del payload. Il motivo per cui vorremmo aggiungere la spaziatura interna è che impedisce il rischio che i ficcanaso siano in grado di 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, avresti due byte con valore 0, ovvero non esiste una spaziatura interna, perché 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 5 byte e 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);
Eseguiamo quindi la spaziatura interna e il payload tramite questa 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. Benissimo!
Non rimane che determinare in che modo questo payload viene inviato al servizio push.
Intestazioni e corpo del payload criptati
Per inviare questo payload criptato al servizio push, dobbiamo definire alcune intestazioni diverse nella richiesta POST.
Intestazione crittografia
L'intestazione "Crittografia" deve contenere il sale utilizzato per criptare il payload.
Il sale da 16 byte deve essere codificato in modo sicuro per l'URL Base64 e aggiunto all'intestazione Crittografia, in questo modo:
Encryption: salt=[URL Safe Base64 Encoded Salt]
Intestazione Crypto-Key
Abbiamo notato che l'intestazione Crypto-Key
viene utilizzata nella sezione "Chiavi server di applicazioni" 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 ha il seguente aspetto:
Crypto-Key: dh=[URL Safe Base64 Encoded Local Public Key String]; p256ecdsa=[URL Safe Base64 Encoded Public Application Server Key]
Tipo di contenuti, lunghezza e codifica delle intestazioni
L'intestazione Content-Length
è il numero di byte nel payload
criptato. Le intestazioni "Content-Type" e "Content-Encoding" sono valori fissi.
Questo è quanto illustrato 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, eseguiremmo questa operazione:
const pushRequest = https.request(httpsOptions, function(pushResponse) {
pushRequest.write(encryptedPayload);
pushRequest.end();
Altre intestazioni?
Abbiamo esaminato le intestazioni utilizzate per le chiavi JWT / server dell'applicazione (ovvero come identificare l'applicazione con il servizio push) e abbiamo parlato delle intestazioni utilizzate per inviare un payload criptato.
Esistono intestazioni aggiuntive utilizzate dai servizi di push per modificare il comportamento dei messaggi inviati. Alcune di queste intestazioni sono obbligatorie, mentre altre facoltative.
Intestazione TTL
Obbligatorio
TTL
(o durata) è un numero intero che specifica per quanti secondi deve trascorrere il messaggio push sul servizio push prima che venga recapitato. Alla scadenza di 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 può essere raggiunto, il messaggio verrà eliminato immediatamente dalla coda del servizio push.
Tecnicamente, un servizio push può ridurre il TTL
di un messaggio push, se lo desidera. Per capire se ciò si è verificato, esamina l'intestazione TTL
nella risposta da 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 di argomento corrispondenti.
Questa funzionalità è utile negli scenari in cui vengono inviati più messaggi mentre un dispositivo è offline e vuoi che l'utente visualizzi il messaggio più recente solo quando il dispositivo è acceso.
Urgenza
Facoltativo
L'urgenza indica al servizio push quanto sia importante un messaggio per l'utente. Questo può essere utilizzato dal servizio push per preservare la durata della batteria del dispositivo di un utente riattivando la ricezione di messaggi importanti solo 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, puoi sempre vedere in che modo le librerie attivano i messaggi push sull'organizzazione web-push-libs.
Una volta che hai 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, in quanto indica se la richiesta è riuscita o meno.
Codice di stato | Descrizione |
---|---|
201 | Creata. La richiesta di invio di un messaggio push è stata ricevuta e accettata. |
429 | Troppe richieste. Il che significa che il server delle applicazioni ha raggiunto un limite di frequenza con un servizio push. Il servizio push deve includere un'intestazione "Riprova dopo" 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 è formattata in modo errato. |
404 | Non trovato. Ciò indica che l'abbonamento è scaduto e non può essere utilizzato. In questo caso devi eliminare "PushSubscription" e attendere che il client sottoscriva di nuovo l'utente. |
410 | Fine. L'abbonamento non è più valido e deve essere rimosso dal server delle applicazioni. Questo può essere riprodotto chiamando "unsubscribe()" su un "PushSubscription". |
413 | Dimensione payload troppo grande. La dimensione minima del payload che un servizio push deve supportare è di 4096 byte (o 4 kB). |
Passaggi successivi
- Panoramica delle notifiche push web
- Come funziona la modalità push
- Iscrizione di un utente
- Esperienza utente delle autorizzazioni
- Invio di messaggi con librerie web push
- protocollo web push
- Gestione degli eventi push
- Visualizzazione di una notifica
- Comportamento notifica
- Pattern di notifica comuni
- Domande frequenti sulle notifiche push
- Problemi comuni e segnalazione di bug