/* ===================================================================== Prueft die GANZE Kette, nicht nur die Rechenschritte. test-webpush.mjs beweist, dass die Verschluesselung der Norm entspricht. Das ist die halbe Miete -- sagt aber nichts darueber, ob im Betrieb tatsaechlich etwas ankommt. Dazwischen liegen die Stellen, an denen es erfahrungsgemaess scheitert: falsche Kopfzeilen, ein Geraet, das nach einem Fehler ewig weiter angefunkt wird, oder eine Meldung, die Kundendaten enthaelt, obwohl sie das nicht soll. Deshalb laeuft hier ein echter Empfaenger mit: ein kleiner Server, der sich verhaelt wie ein Push-Dienst, die Nachricht ENTSCHLUESSELT und nachsieht, was drinsteht. ===================================================================== */ import http from "http"; import crypto from "crypto"; import fs from "fs"; import { fileURLToPath } from "url"; import { dirname, join } from "path"; const __dirname = dirname(fileURLToPath(import.meta.url)); const TEST_DB = join(__dirname, "test-push.db"); /* Vor dem Import von db.js setzen -- danach ist der Pfad festgelegt. */ fs.rmSync(TEST_DB, { force: true }); fs.rmSync(TEST_DB + "-wal", { force: true }); fs.rmSync(TEST_DB + "-shm", { force: true }); process.env.DB_PATH = TEST_DB; process.env.ENCRYPTION_KEY = Buffer.from(crypto.randomBytes(32)).toString("base64"); const { db, initDb } = await import("./db.js"); initDb(); const P = await import("./lib/webdesign-push.js"); const { ausB64u, b64u } = await import("./lib/webpush.js"); let ok = 0, fehl = 0; const pruefe = (name, gut, info) => { if (gut) { ok++; console.log(" ok " + name + (info ? " -> " + info : "")); } else { fehl++; console.log(" FEHL " + name + (info ? " -> " + info : "")); } }; /* --- Ein Empfaenger, der wirklich entschluesselt -------------------- */ const empfaenger = crypto.createECDH("prime256v1"); empfaenger.generateKeys(); const authGeheimnis = crypto.randomBytes(16); let empfangen = []; let antwortStatus = 201; const dienst = http.createServer((q, r) => { const teile = []; q.on("data", (d) => teile.push(d)); q.on("end", () => { const koerper = Buffer.concat(teile); let klartext = null; try { const salt = koerper.subarray(0, 16); const asPub = koerper.subarray(21, 86); const inhalt = koerper.subarray(86); const gemeinsam = empfaenger.computeSecret(asPub); const ikm = Buffer.from(crypto.hkdfSync("sha256", gemeinsam, authGeheimnis, Buffer.concat([Buffer.from("WebPush: info\0"), empfaenger.getPublicKey(), asPub]), 32)); const cek = Buffer.from(crypto.hkdfSync("sha256", ikm, salt, Buffer.from("Content-Encoding: aes128gcm\0"), 16)); const nonce = Buffer.from(crypto.hkdfSync("sha256", ikm, salt, Buffer.from("Content-Encoding: nonce\0"), 12)); const d = crypto.createDecipheriv("aes-128-gcm", cek, nonce); d.setAuthTag(inhalt.subarray(inhalt.length - 16)); const roh = Buffer.concat([d.update(inhalt.subarray(0, inhalt.length - 16)), d.final()]); klartext = roh.subarray(0, roh.length - 1).toString("utf8"); } catch (e) { klartext = "ENTSCHLUESSELN FEHLGESCHLAGEN: " + e.message; } empfangen.push({ kopf: q.headers, klartext, laenge: koerper.length }); r.writeHead(antwortStatus); r.end(""); }); }); await new Promise((f) => dienst.listen(4407, f)); const ENDPUNKT = "http://127.0.0.1:4407/push/geraet-1"; console.log("1. SCHLUESSEL ENTSTEHEN BEIM ERSTEN START"); const e1 = await P.vapidSicherstellen(); pruefe("beim ersten Mal neu erzeugt", e1.zustand === "neu", e1.zustand); const schluessel1 = P.oeffentlicherSchluessel(); pruefe("oeffentlicher Schluessel ist da", !!schluessel1 && ausB64u(schluessel1).length === 65); /* Der wichtigste Test dieser Datei. Wechselt der oeffentliche Schluessel, sind schlagartig ALLE Anmeldungen wertlos -- und niemand merkt, warum nichts mehr ankommt. */ const e2 = await P.vapidSicherstellen(); pruefe("beim zweiten Start NICHT neu erzeugt", e2.zustand === "vorhanden", e2.zustand); pruefe("Schluessel ist unveraendert", P.oeffentlicherSchluessel() === schluessel1); console.log("\n2. GERAET ANMELDEN"); P.geraetAnmelden({ endpoint: ENDPUNKT, keys: { p256dh: b64u(empfaenger.getPublicKey()), auth: b64u(authGeheimnis) }, }, "dogfather", "Chrome auf Android"); pruefe("ein Geraet in der Liste", P.geraeteListe(null).length === 1); /* Zweimal dasselbe Geraet darf nicht zwei Eintraege ergeben -- sonst kaeme jede Meldung doppelt. */ P.geraetAnmelden({ endpoint: ENDPUNKT, keys: { p256dh: b64u(empfaenger.getPublicKey()), auth: b64u(authGeheimnis) }, }, "dogfather", "Chrome auf Android"); pruefe("erneute Anmeldung ergibt keinen zweiten Eintrag", P.geraeteListe(null).length === 1, P.geraeteListe(null).length + " Eintrag/Eintraege"); pruefe("Adresse wird NICHT herausgegeben", !JSON.stringify(P.geraeteListe(null)).includes("4407")); pruefe("Dienst wird lesbar benannt", P.geraeteListe(null)[0].dienst === "127.0.0.1", P.geraeteListe(null)[0].dienst); console.log("\n3. EINE MELDUNG GEHT RAUS UND KOMMT LESBAR AN"); empfangen = []; const r1 = await P.meldeAllen({ titel: "Neue Anfrage", text: "A-2608-0009 (shop)", url: "/webdesign/verwaltung.html", art: "anfrage", }); pruefe("als gesendet gemeldet", r1.gesendet === 1, JSON.stringify(r1)); pruefe("genau eine Nachricht angekommen", empfangen.length === 1); const n = empfangen[0]; pruefe("Kopfzeile Content-Encoding", n.kopf["content-encoding"] === "aes128gcm", n.kopf["content-encoding"]); pruefe("Kopfzeile Content-Type", n.kopf["content-type"] === "application/octet-stream"); pruefe("TTL gesetzt", !!n.kopf.ttl, n.kopf.ttl + " Sekunden"); pruefe("VAPID-Ausweis mitgeschickt", /^vapid t=.+, k=.+$/.test(n.kopf.authorization || "")); let inhalt = {}; try { inhalt = JSON.parse(n.klartext); } catch (e) { /* faellt unten auf */ } pruefe("Nachricht liess sich entschluesseln", !!inhalt.titel, n.klartext?.slice(0, 60)); pruefe("Titel stimmt", inhalt.titel === "Neue Anfrage", inhalt.titel); pruefe("Text stimmt", inhalt.text === "A-2608-0009 (shop)", inhalt.text); /* Datenschutz: Auf einem Sperrbildschirm haben Kundendaten nichts verloren. Der Test prueft es an der Stelle, an der es zaehlt -- im entschluesselten Text, nicht im Quelltext. */ console.log("\n4. KEINE KUNDENDATEN IN DER MELDUNG"); const alles = (n.klartext || "").toLowerCase(); for (const verboten of ["@", "telefon", "gmail", "budget"]) { pruefe(`enthaelt kein "${verboten}"`, !alles.includes(verboten)); } console.log("\n5. EIN ABGEMELDETES GERAET WIRD ENTFERNT"); antwortStatus = 410; const r2 = await P.meldeAllen({ titel: "x", text: "y" }); pruefe("als abgemeldet erkannt", r2.entfernt === 1, JSON.stringify(r2)); pruefe("Eintrag ist weg", P.geraeteListe(null).length === 0); console.log("\n6. EIN VORUEBERGEHENDER FEHLER LOESCHT NICHTS"); P.geraetAnmelden({ endpoint: ENDPUNKT, keys: { p256dh: b64u(empfaenger.getPublicKey()), auth: b64u(authGeheimnis) }, }, "dogfather", "Chrome auf Android"); antwortStatus = 500; const r3 = await P.meldeAllen({ titel: "x", text: "y" }); pruefe("als Fehler gezaehlt", r3.fehler === 1, JSON.stringify(r3)); /* Der entscheidende Unterschied: Bei 500 ist der Dienst gestoert, nicht das Geraet weg. Loeschen waere hier falsch -- eine einzelne Stoerung wuerde die Anmeldung kosten. */ pruefe("Geraet bleibt erhalten", P.geraeteListe(null).length === 1); pruefe("Fehler wird vermerkt", P.geraeteListe(null)[0].fehler === 1, "Zaehler " + P.geraeteListe(null)[0].fehler); antwortStatus = 201; await P.meldeAllen({ titel: "x", text: "y" }); pruefe("nach Erfolg ist der Zaehler wieder 0", P.geraeteListe(null)[0].fehler === 0); console.log("\n7. DIE ANZEIGE SAGT DIE WAHRHEIT"); const st = P.stand(); pruefe("meldet eingerichtet", st.eingerichtet === true); pruefe("meldet, dass eine Meldung wirkt", st.wirktMeldung === true); P.geraetAbmelden(ENDPUNKT); pruefe("ohne Geraet wirkt keine Meldung mehr", P.stand().wirktMeldung === false); dienst.close(); db.close(); fs.rmSync(TEST_DB, { force: true }); fs.rmSync(TEST_DB + "-wal", { force: true }); fs.rmSync(TEST_DB + "-shm", { force: true }); console.log(`\n===== ${ok} bestanden, ${fehl} fehlgeschlagen =====`); process.exit(fehl ? 1 : 0);