/* DER GANZE WEG EINER BENACHRICHTIGUNG. pruef-push.mjs beweist, dass die RECHNUNG stimmt -- gegen die Testvektoren von RFC 8291 und 8292. Diese Pruefung geht einen Schritt weiter und fragt: Kommt am anderen Ende auch wirklich der Text an? Dafuer wird ein Browser nachgespielt. Er erzeugt sein Schluesselpaar wie ein echter, der Server verschickt eine Nachricht an einen nachgebauten Push-Dienst, und dieser "Browser" macht sie mit seinem privaten Schluessel wieder auf. WARUM DAS NOETIG IST, obwohl die Testvektoren schon stimmen: Der Vektor prueft EINEN festen Fall mit festem Salz und festem Schluesselpaar. Im Betrieb werden beide je Nachricht neu erzeugt -- und ein Fehler genau dort (etwa ein Schluessel in der falschen Reihenfolge) faellt gegen einen festen Vektor nicht auf. Dazu die zwei Dinge, die im Alltag zaehlen: * Der Push-Dienst darf den Inhalt NICHT lesen koennen. * Eine tote Anmeldung muss als tot erkannt werden, sonst scheitert jeder weitere Lauf an derselben Stelle. GEGENPROBE am Ende: Mit dem falschen Schluessel darf sich die Nachricht NICHT aufmachen lassen. */ import { createServer } from "node:http"; import { createECDH, hkdfSync, createDecipheriv } from "node:crypto"; import { verschluesseln, schluesselErzeugen, schicken, b64u, } from "./workspace-push-krypto.js"; let fehler = 0; const ok = (b, t) => { console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; /* ---- Ein Browser, wie er sich anmelden wuerde -------------------------- */ const browser = createECDH("prime256v1"); browser.generateKeys(); const auth = Buffer.from([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]); const anmeldung = { p256dh: b64u(browser.getPublicKey()), auth: b64u(auth) }; /** Macht die Nachricht auf -- genau wie es der echte Browser tut * (RFC 8291, in die andere Richtung gelesen). */ function entschluesseln(paket, wer = browser, geheim16 = auth) { const salz = paket.subarray(0, 16); const len = paket.readUInt8(20); const senderOeff = paket.subarray(21, 21 + len); const rest = paket.subarray(21 + len); const gemeinsam = wer.computeSecret(senderOeff); const prkInfo = Buffer.concat([ Buffer.from("WebPush: info\0"), wer.getPublicKey(), senderOeff, ]); const ikm = Buffer.from(hkdfSync("sha256", gemeinsam, geheim16, prkInfo, 32)); const cek = Buffer.from(hkdfSync("sha256", ikm, salz, Buffer.from("Content-Encoding: aes128gcm\0"), 16)); const nonce = Buffer.from(hkdfSync("sha256", ikm, salz, Buffer.from("Content-Encoding: nonce\0"), 12)); const tag = rest.subarray(rest.length - 16); const daten = rest.subarray(0, rest.length - 16); const dec = createDecipheriv("aes-128-gcm", cek, nonce); dec.setAuthTag(tag); const klar = Buffer.concat([dec.update(daten), dec.final()]); return klar.subarray(0, klar.length - 1).toString("utf8"); // Polsterbyte weg } const TEXT = JSON.stringify({ titel: "Es funktioniert", text: "Probe aus dem Workspace", ziel: "/workspace/start.html", }); /* ======================================================================= 1. Verschluesseln und wieder aufmachen ======================================================================= */ console.log("\n=== Verschluesseln -> aufmachen ==="); { const paket = verschluesseln(TEXT, anmeldung.p256dh, anmeldung.auth); ok(paket.length > 86, `verschluesselt (${paket.length} Bytes)`); const zurueck = entschluesseln(paket); ok(zurueck === TEXT, zurueck === TEXT ? "der Browser bekommt GENAU den Text zurueck" : `ABWEICHUNG: ${zurueck.slice(0, 60)}`); } /* ======================================================================= 2. Zehnmal hintereinander -- jedes Mal neue Schluessel ======================================================================= */ console.log("\n=== Zehn Nachrichten, jede mit eigenem Schluessel ==="); { const gesehen = new Set(); let alleLesbar = true; for (let i = 0; i < 10; i++) { const p = verschluesseln(TEXT, anmeldung.p256dh, anmeldung.auth); gesehen.add(b64u(p)); if (entschluesseln(p) !== TEXT) alleLesbar = false; } ok(gesehen.size === 10, `${gesehen.size} von 10 sind verschieden (Salz und Paar sind neu)`); ok(alleLesbar, "und trotzdem alle zehn lesbar"); } /* ======================================================================= 3. Ueber HTTP, wie beim echten Dienst ======================================================================= */ console.log("\n=== Zustellung ueber HTTP ==="); let empfangen = null, kopfzeilen = null; { const dienst = createServer((q, a) => { const stuecke = []; q.on("data", (c) => stuecke.push(c)); q.on("end", () => { empfangen = Buffer.concat(stuecke); kopfzeilen = q.headers; a.writeHead(201); a.end(); }); }); await new Promise((r) => dienst.listen(4341, "127.0.0.1", r)); const paar = schluesselErzeugen(); const e = await schicken({ endpunkt: "http://127.0.0.1:4341/push/abc", ...anmeldung }, TEXT, paar, "mailto:test@dogfather-universe.com"); ok(e.ok, `zugestellt (Status ${e.status})`); ok(!!empfangen && empfangen.length > 86, `der Dienst hat ${empfangen?.length || 0} Bytes bekommen`); ok(kopfzeilen?.["content-encoding"] === "aes128gcm", `Kodierung: ${kopfzeilen?.["content-encoding"]}`); ok(String(kopfzeilen?.authorization || "").startsWith("vapid t="), "die VAPID-Signatur ist dabei"); ok(kopfzeilen?.ttl === "3600", `TTL: ${kopfzeilen?.ttl}`); ok(entschluesseln(empfangen) === TEXT, "und der Inhalt kommt unveraendert an"); dienst.close(); } /* ======================================================================= 4. Der Push-Dienst sieht nichts vom Inhalt ======================================================================= */ console.log("\n=== Was der Push-Dienst zu sehen bekommt ==="); { const alsText = empfangen.toString("utf8"); const alsLatin = empfangen.toString("latin1"); ok(!alsText.includes("Es funktioniert") && !alsLatin.includes("Es funktioniert"), "der Titel steht nicht im Klartext im Paket"); ok(!alsText.includes("Workspace") && !alsLatin.includes("Workspace"), "auch sonst kein lesbares Wort"); /* Das ist der eigentliche Grund, warum dieser Weg vertretbar ist: Google, Mozilla und Apple leiten weiter, ohne mitlesen zu koennen. */ } /* ======================================================================= 5. Tote Anmeldungen ======================================================================= */ console.log("\n=== Ein Geraet, das es nicht mehr gibt ==="); { const paar = schluesselErzeugen(); for (const [code, erwartetWeg] of [[410, true], [404, true], [500, false], [429, false]]) { const d = createServer((q, a) => { a.writeHead(code); a.end(); }); await new Promise((r) => d.listen(4342, "127.0.0.1", r)); const e = await schicken({ endpunkt: "http://127.0.0.1:4342/x", ...anmeldung }, TEXT, paar, "mailto:t@dogfather-universe.com"); ok(e.weg === erwartetWeg, `${code} -> weg=${e.weg} (erwartet ${erwartetWeg})` + (erwartetWeg ? " — die Anmeldung wird geloescht" : " — sie bleibt, es kann voruebergehend sein")); d.close(); await new Promise((r) => setTimeout(r, 60)); } } /* ======================================================================= 6. Ein Dienst, der gar nicht antwortet ======================================================================= */ console.log("\n=== Wenn der Push-Dienst schweigt ==="); { const paar = schluesselErzeugen(); /* Adresse, die niemand bedient. Der Aufruf darf NICHT werfen und nicht ewig haengen -- sonst bliebe der ganze Lauf stehen und die uebrigen Empfaenger bekaemen nichts. */ const e = await schicken({ endpunkt: "http://127.0.0.1:4399/tot", ...anmeldung }, TEXT, paar, "mailto:t@dogfather-universe.com"); ok(e.ok === false && e.weg === false, `kein Absturz, die Anmeldung bleibt (${e.grund?.slice(0, 40) || e.status})`); } /* ======================================================================= GEGENPROBE — mit dem falschen Schluessel darf es NICHT gehen ======================================================================= */ console.log("\n=== Gegenprobe ==="); { const fremder = createECDH("prime256v1"); fremder.generateKeys(); const paket = verschluesseln(TEXT, anmeldung.p256dh, anmeldung.auth); let ging = false; try { ging = entschluesseln(paket, fremder, auth) === TEXT; } catch { ging = false; } ok(!ging, "ein fremder Schluessel kann die Nachricht NICHT aufmachen"); let mitFalschemGeheimnis = false; try { const anderes = Buffer.from(auth); anderes[0] ^= 0xff; mitFalschemGeheimnis = entschluesseln(paket, browser, anderes) === TEXT; } catch { mitFalschemGeheimnis = false; } ok(!mitFalschemGeheimnis, "auch mit falschem Geheimnis nicht"); /* Und der Beweis, dass diese Messung ueberhaupt "geht auf" erkennt. */ ok(entschluesseln(paket) === TEXT, "mit dem richtigen Schluessel dagegen schon — die Messung unterscheidet also"); } console.log(`\n${fehler === 0 ? "ALLES IN ORDNUNG" : `${fehler} FEHLER`}`); process.exit(fehler ? 1 : 0);