Fa arrivare le push ad app chiusa e rende diagnosticabile quando non arrivano.
Il testo della notifica viaggia ora cifrato dentro la push (aes128gcm, RFC
8188/8291) invece di essere recuperato dal service worker con una fetch al
risveglio. Era quella fetch a non chiudersi in tempo: ad app chiusa il browser
tiene vivo il worker pochi secondi, showNotification non veniva mai chiamata e
non compariva niente, mentre ad app aperta con la rete calda sembrava tutto a
posto. La POST porta anche Urgency: high, che chiede la consegna immediata
invece di far accumulare i messaggi fino al risveglio del dispositivo.
Cadono i pezzi che esistevano solo per rimediare al payload vuoto: la route
push-messaggio, la coda promemoria_push con la sua scadenza a 12 ore,
messaggioPalloniOggi() e il timeout nel worker. Tutti e tre i mittenti avevano
gia il testo pronto prima di inviare.
Il worker si aggiorna da solo all'avvio e a ogni ritorno in primo piano
(mantieniWorkerPushAggiornato): nella webapp installata quello vecchio puo
sopravvivere a lungo, e senza questo un dispositivo resterebbe fermo alla
versione che va a cercare il testo in rete.
Profilo -> Opzioni ha "Mandami una notifica di prova", visibile solo a notifiche
attive: manda una push a questo dispositivo e riporta stato HTTP, corpo della
risposta e se l'endpoint risulta davvero in push_subscriptions. Senza, "non
arriva" era cieco: ogni prova richiedeva un admin, un evento nello stato giusto
e una seconda persona, e la risposta del servizio push veniva buttata via.
inviaPush torna { stato, corpo } e logga il corpo sui rifiuti.
Dalle prove sul campo: a parita di server, iPhone installato da Home riceve ad
app chiusa. Su Android installato come webapp resta da verificare: il WebAPK e
un'app Android a se, con permesso notifiche (Android 13+) e voce batteria
distinti da quelli di Chrome. Annotato nei limiti noti.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,10 +2,12 @@
|
||||
* Check dell'invio push server-side: `bun test/unit/webpush-server.test.ts`.
|
||||
* `inviaPush` firma un JWT VAPID con Web Crypto e fa una POST all'endpoint del
|
||||
* browser: qui generiamo una vera coppia di chiavi P-256 e sostituiamo `fetch`
|
||||
* per intercettare la richiesta, così il test non tocca mai la rete.
|
||||
* per intercettare la richiesta, così il test non tocca mai la rete. Il corpo
|
||||
* cifrato viene decifrato con la chiave del "dispositivo": se la cifratura fosse
|
||||
* sbagliata il browser scarterebbe la push in silenzio e non arriverebbe niente.
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import { ORE_VALIDITA_PROMEMORIA, inviaPush, promemoriaAncoraValido } from "@/lib/webpush.server";
|
||||
import { inviaPush } from "@/lib/webpush.server";
|
||||
|
||||
function base64UrlEncode(bytes: Uint8Array): string {
|
||||
let binary = "";
|
||||
@@ -13,6 +15,33 @@ function base64UrlEncode(bytes: Uint8Array): string {
|
||||
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||
}
|
||||
|
||||
function concat(...parti: Uint8Array[]): Uint8Array {
|
||||
const out = new Uint8Array(parti.reduce((n, p) => n + p.length, 0));
|
||||
let offset = 0;
|
||||
for (const parte of parti) {
|
||||
out.set(parte, offset);
|
||||
offset += parte.length;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function etichetta(testo: string): Uint8Array {
|
||||
return concat(new TextEncoder().encode(testo), new Uint8Array([0]));
|
||||
}
|
||||
|
||||
async function hkdf(salt: Uint8Array, ikm: Uint8Array, info: Uint8Array, byte: number) {
|
||||
const chiave = await crypto.subtle.importKey("raw", ikm as BufferSource, "HKDF", false, [
|
||||
"deriveBits",
|
||||
]);
|
||||
return new Uint8Array(
|
||||
await crypto.subtle.deriveBits(
|
||||
{ name: "HKDF", hash: "SHA-256", salt: salt as BufferSource, info: info as BufferSource },
|
||||
chiave,
|
||||
byte * 8,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async function chiaviVapidDiProva() {
|
||||
const coppia = await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, [
|
||||
"sign",
|
||||
@@ -23,6 +52,64 @@ async function chiaviVapidDiProva() {
|
||||
return { publicKey: base64UrlEncode(raw), privateKey: jwk.d! };
|
||||
}
|
||||
|
||||
/** Il "dispositivo": chiavi che finirebbero in `push_subscriptions`, più la privata. */
|
||||
async function dispositivoDiProva() {
|
||||
const coppia = await crypto.subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, true, [
|
||||
"deriveBits",
|
||||
]);
|
||||
const pubblica = new Uint8Array(await crypto.subtle.exportKey("raw", coppia.publicKey));
|
||||
const auth = crypto.getRandomValues(new Uint8Array(16));
|
||||
return {
|
||||
iscrizione: {
|
||||
endpoint: "https://push.example/abc123",
|
||||
p256dh: base64UrlEncode(pubblica),
|
||||
auth: base64UrlEncode(auth),
|
||||
},
|
||||
pubblica,
|
||||
auth,
|
||||
privata: coppia.privateKey,
|
||||
};
|
||||
}
|
||||
|
||||
/** Fa quello che fa il browser alla ricezione: apre il record aes128gcm (RFC 8188/8291). */
|
||||
async function decifra(corpo: Uint8Array, device: Awaited<ReturnType<typeof dispositivoDiProva>>) {
|
||||
const salt = corpo.slice(0, 16);
|
||||
const lunghezzaChiave = corpo[20]!;
|
||||
const asPublic = corpo.slice(21, 21 + lunghezzaChiave);
|
||||
const cifrato = corpo.slice(21 + lunghezzaChiave);
|
||||
|
||||
const asKey = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
asPublic as BufferSource,
|
||||
{ name: "ECDH", namedCurve: "P-256" },
|
||||
false,
|
||||
[],
|
||||
);
|
||||
const condiviso = new Uint8Array(
|
||||
await crypto.subtle.deriveBits({ name: "ECDH", public: asKey }, device.privata, 256),
|
||||
);
|
||||
const ikm = await hkdf(
|
||||
device.auth,
|
||||
condiviso,
|
||||
concat(etichetta("WebPush: info"), device.pubblica, asPublic),
|
||||
32,
|
||||
);
|
||||
const cek = await hkdf(salt, ikm, etichetta("Content-Encoding: aes128gcm"), 16);
|
||||
const nonce = await hkdf(salt, ikm, etichetta("Content-Encoding: nonce"), 12);
|
||||
const aes = await crypto.subtle.importKey("raw", cek as BufferSource, "AES-GCM", false, [
|
||||
"decrypt",
|
||||
]);
|
||||
const chiaro = new Uint8Array(
|
||||
await crypto.subtle.decrypt(
|
||||
{ name: "AES-GCM", iv: nonce as BufferSource },
|
||||
aes,
|
||||
cifrato as BufferSource,
|
||||
),
|
||||
);
|
||||
assert.equal(chiaro.at(-1), 2, "l'ultimo record finisce col delimitatore 0x02");
|
||||
return new TextDecoder().decode(chiaro.slice(0, -1));
|
||||
}
|
||||
|
||||
const originali = {
|
||||
pub: process.env["VAPID_PUBLIC_KEY"],
|
||||
priv: process.env["VAPID_PRIVATE_KEY"],
|
||||
@@ -40,11 +127,16 @@ function ripristinaEnv() {
|
||||
}
|
||||
}
|
||||
|
||||
const device = await dispositivoDiProva();
|
||||
|
||||
try {
|
||||
// --- senza chiavi configurate: rifiuta subito, senza tentare la firma -------
|
||||
delete process.env["VAPID_PUBLIC_KEY"];
|
||||
delete process.env["VAPID_PRIVATE_KEY"];
|
||||
await assert.rejects(() => inviaPush("https://push.example/ep"), /Chiavi VAPID non configurate/);
|
||||
await assert.rejects(
|
||||
() => inviaPush(device.iscrizione, "t", "b"),
|
||||
/Chiavi VAPID non configurate/,
|
||||
);
|
||||
|
||||
// --- con le chiavi: firma il JWT e chiama fetch con l'header vapid ----------
|
||||
const { publicKey, privateKey } = await chiaviVapidDiProva();
|
||||
@@ -60,15 +152,25 @@ try {
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
const stato = await inviaPush("https://push.example/abc123");
|
||||
const { stato, corpo: risposta } = await inviaPush(
|
||||
device.iscrizione,
|
||||
"Porta i palloni",
|
||||
"Stasera tocca a te.",
|
||||
);
|
||||
assert.equal(stato, 201, "restituisce lo status della risposta");
|
||||
assert.equal(typeof risposta, "string", "riporta anche il corpo, per capire i rifiuti");
|
||||
assert.ok(richiesta, "ha chiamato fetch");
|
||||
assert.equal(richiesta!.url, "https://push.example/abc123");
|
||||
assert.equal(richiesta!.init.method, "POST");
|
||||
|
||||
const headers = new Headers(richiesta!.init.headers);
|
||||
assert.equal(headers.get("TTL"), "86400");
|
||||
assert.equal(headers.get("Content-Length"), "0");
|
||||
assert.equal(headers.get("Content-Encoding"), "aes128gcm");
|
||||
assert.equal(
|
||||
headers.get("Urgency"),
|
||||
"high",
|
||||
"con l'urgenza normale un telefono in doze accumula i messaggi fino al risveglio: la notifica arriverebbe solo a dispositivo già attivo",
|
||||
);
|
||||
|
||||
const auth = headers.get("Authorization")!;
|
||||
assert.ok(auth.startsWith("vapid t="), "usa lo schema vapid con il token JWT");
|
||||
@@ -83,6 +185,15 @@ try {
|
||||
assert.equal(claim.aud, "https://push.example", "l'audience è l'origine dell'endpoint");
|
||||
assert.equal(claim.sub, "mailto:test@example.com");
|
||||
assert.ok(claim.exp > Date.now() / 1000, "il token scade nel futuro");
|
||||
|
||||
// --- il payload: il testo viaggia dentro la push, non lo si va a cercare --
|
||||
const corpo = new Uint8Array(richiesta!.init.body as ArrayBuffer);
|
||||
assert.equal(corpo[20], 65, "l'intestazione dichiara la chiave effimera da 65 byte");
|
||||
assert.deepEqual(
|
||||
JSON.parse(await decifra(corpo, device)),
|
||||
{ title: "Porta i palloni", body: "Stasera tocca a te." },
|
||||
"il dispositivo legge titolo e testo senza nessuna chiamata di rete",
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = fetchOriginale;
|
||||
}
|
||||
@@ -90,24 +201,4 @@ try {
|
||||
ripristinaEnv();
|
||||
}
|
||||
|
||||
// --- scadenza dei promemoria in coda ------------------------------------------
|
||||
// La coda si svuota solo quando il dispositivo legge: se la push non arriva mai, la
|
||||
// riga resta. Senza scadenza dirotterebbe la notifica successiva, giorni dopo.
|
||||
const ADESSO = new Date("2026-09-06T12:00:00Z");
|
||||
const oreFa = (n: number) => new Date(ADESSO.getTime() - n * 60 * 60 * 1000).toISOString();
|
||||
|
||||
assert.equal(promemoriaAncoraValido(oreFa(1), ADESSO), true, "un'ora fa è attuale");
|
||||
assert.equal(
|
||||
promemoriaAncoraValido(oreFa(ORE_VALIDITA_PROMEMORIA - 0.1), ADESSO),
|
||||
true,
|
||||
"poco prima della scadenza vale ancora",
|
||||
);
|
||||
assert.equal(
|
||||
promemoriaAncoraValido(oreFa(ORE_VALIDITA_PROMEMORIA + 0.1), ADESSO),
|
||||
false,
|
||||
"oltre la scadenza non è più un promemoria",
|
||||
);
|
||||
assert.equal(promemoriaAncoraValido(oreFa(72), ADESSO), false, "un sollecito di tre giorni fa no");
|
||||
assert.equal(promemoriaAncoraValido("non-una-data", ADESSO), false, "una data illeggibile scade");
|
||||
|
||||
console.log("webpush-server: ok");
|
||||
|
||||
Reference in New Issue
Block a user