BEFUND ZUERST, DENN ER WAR ANDERS ALS ERWARTET
Es gab sehr wohl eine Benachrichtigung -- ueber einen Discord-Webhook,
sauber gebaut, bewusst ohne Kundendaten im Text. Sie begann aber mit:
const url = process.env.DISCORD_WEBHOOK_WEBDESIGN;
if (!url) return;
Und diese Variable ist in .env.example nirgends aufgefuehrt. Eine
Variable, die niemand kennt, wird nicht gesetzt; dann kehrte die
Funktion wortlos zurueck. Kein Fehler, keine Protokollzeile. Es gab
eine Benachrichtigung, die nur im Quelltext existierte -- und niemand
konnte das bemerken, weil "funktioniert" und "kaputt" identisch
aussehen, solange nichts passiert.
WAS JETZT DA IST
Push aufs Handy, ohne Fremdpaket. Der interne Dienst liegt unter
/home/dogiintern mit Rechten 700 -- dort ist kein npm erreichbar, jede
Abhaengigkeit haette dauerhafte Handarbeit bedeutet. Node bringt P-256,
HKDF und AES-128-GCM selbst mit.
Die Schluessel erzeugt der Server beim ersten Start selbst und legt den
privaten Teil verschluesselt in der Datenbank ab (derselbe Weg wie die
PayPal-Zugangsdaten). Damit gibt es keinen Einrichtungsschritt, der
vergessen werden kann.
GEPRUEFT
Gegen RFC 8291 statt gegen ein Bauchgefuehl: alle fuenf Zwischenwerte
aus Anhang A stimmen (ECDH-Geheimnis, PRK_key, IKM, CEK, NONCE), und
die fertige Nachricht ist Byte fuer Byte die aus Abschnitt 5 der Norm.
Bei Kryptographie erzeugt ein Ableitungsfehler keinen Absturz, sondern
Bytes, die genauso zufaellig aussehen wie richtige.
Dazu ein Kettentest mit einem echten Empfaenger, der entschluesselt:
Kopfzeilen, Inhalt, keine Kundendaten in der Meldung, 410 loescht das
Geraet, 500 loescht es NICHT (sonst kostet eine einzelne Stoerung die
Anmeldung). 50 Pruefungen, alle gruen.
DREI ENTSCHEIDUNGEN
1. In der Meldung stehen nur Nummer und Paket. Sie erscheint auf einem
Sperrbildschirm, den auch jemand sieht, der zufaellig danebensteht.
2. Ist der Schluessel unlesbar, wird KEIN neuer erzeugt. Das waere der
bequeme Weg und der schlimmste: Ein neuer oeffentlicher Schluessel
macht schlagartig jede Anmeldung wertlos, ohne dass jemand erfaehrt,
warum nichts mehr ankommt.
3. Die Verwaltung zeigt den Zustand an und hat einen Testknopf. Genau
das fehlte dem Discord-Weg. Bei verweigerter Erlaubnis erscheint
kein Knopf, der nichts bewirkt, sondern der Weg ueber die
Browsereinstellungen.
Das stille "if (!url) return;" ist ersetzt: Jeder Weg wird einzeln
protokolliert -- auch und gerade, wenn er uebersprungen wird.
Co-Authored-By: Claude Opus 5 <[email protected]>
188 lines
8.1 KiB
JavaScript
188 lines
8.1 KiB
JavaScript
/* =====================================================================
|
|
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);
|