/* WEB PUSH — gerechnet gegen die Testvektoren der RFCs. WARUM DAS DIE WICHTIGSTE PRUEFUNG DIESES BAUSTEINS IST. Die Verschluesselung fuer Web Push ist an einer Stelle besonders unangenehm: Stimmt irgendetwas nicht -- ein Byte im Info-Text, die Reihenfolge zweier Schluessel, das Polster am Ende --, dann verwirft der Browser die Nachricht STILL. Kein Fehler, keine Meldung, keine Konsolenzeile. Der Push-Dienst antwortet mit 201 "angenommen", und trotzdem kommt nie etwas an. Man kann so etwas nicht durch Ausprobieren richtig bekommen. Deshalb wird hier nicht "sieht gut aus" geprueft, sondern gegen die vollstaendigen Beispieldaten aus RFC 8291, Anhang A (Message Encryption for Web Push) RFC 8292 (VAPID) Trifft die eigene Rechnung diese Zahlen, ist sie richtig -- nicht wahrscheinlich richtig, sondern richtig. Und wenn sich morgen jemand vertippt, faellt es in derselben Sekunde auf. GEGENPROBE am Ende: ein absichtlich veraenderter Schluessel MUSS ein anderes Ergebnis liefern. Eine Pruefung, die immer bestaetigt, bestaetigt nichts. */ import { verschluesseln, vapidKopf, schluesselErzeugen, b64u, ausB64u, } from "./workspace-push-krypto.js"; import { createECDH, createVerify, createPublicKey } from "node:crypto"; let fehler = 0; const ok = (b, t) => { console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; /* ======================================================================= 1. RFC 8291, Anhang A — der vollstaendige Testvektor ======================================================================= */ console.log("\n=== RFC 8291 Anhang A: Verschluesselung ==="); { /* Die Werte stehen woertlich im RFC. */ const klartext = "When I grow up, I want to be a watermelon"; const empfaengerOeff = "BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcx" + "aOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4"; const auth = "BTBZMqHH6r4Tts7J_aSIgg"; const salz = ausB64u("DGv6ra1nlYgDCS1FRnbzlw"); const eigenerPrivat = ausB64u("yfWPiYE-n46HLnH0KqZOF1fJJU3MYrct3AELtAQ-oRw"); /* Das erwartete Ergebnis, woertlich aus dem RFC (dort ueber mehrere Zeilen umbrochen, hier zusammengesetzt). ⚠️ AUS DER QUELLE ABSCHREIBEN, NICHT AUS DEM GEDAECHTNIS. Der erste Entwurf dieser Pruefung trug hier einen Wert, der ab Zeichen 61 abwich -- die Pruefung meldete einen Fehler, und der lag in ihr selbst, nicht in der Rechnung. Der Kopf (Salz, Groesse, Schluessel) stimmte, weil er unmittelbar aus den Eingaben folgt; nur der verschluesselte Teil war erfunden. Eine Pruefung mit einer falschen Erwartung ist schlimmer als keine: Sie schickt die Fehlersuche an die falsche Stelle. */ const erwartet = "DGv6ra1nlYgDCS1FRnbzlwAAEABBBP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27" + "mlmlMoZIIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A_yl95bQpu6" + "cVPTpK4Mqgkf1CXztLVBSt2Ks3oZwbuwXPXLWyouBWLVWGNWQexSgSxsj_Qulc" + "y4a-fN"; const ergebnis = verschluesseln(klartext, empfaengerOeff, auth, salz, eigenerPrivat); const alsText = b64u(ergebnis); ok(alsText === erwartet, alsText === erwartet ? `die Rechnung trifft den Testvektor auf das Byte genau (${ergebnis.length} Bytes)` : `ABWEICHUNG\n erwartet: ${erwartet.slice(0, 60)}…\n` + ` bekommen: ${alsText.slice(0, 60)}…`); /* Die Form des Ergebnisses, unabhaengig vom Inhalt: 16 Byte Salz, 4 Byte Groesse, 1 Byte Laenge, 65 Byte Schluessel, dann der Rest. */ ok(ergebnis.length === 16 + 4 + 1 + 65 + klartext.length + 1 + 16, `die Laenge stimmt (${ergebnis.length} Bytes: Kopf 86 + Text ${klartext.length} + Polster 1 + Pruefsumme 16)`); ok(ergebnis.readUInt8(20) === 65, "die Schluessellaenge im Kopf steht auf 65"); ok(ergebnis.readUInt32BE(16) === 4096, "die Datensatzgroesse steht auf 4096"); } /* ======================================================================= 2. Jede Nachricht bekommt ein eigenes Salz und ein eigenes Paar ======================================================================= */ console.log("\n=== Kein Schluessel wird zweimal benutzt ==="); { const empf = "BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcx" + "aOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4"; const auth = "BTBZMqHH6r4Tts7J_aSIgg"; const a = verschluesseln("dieselbe Nachricht", empf, auth); const b = verschluesseln("dieselbe Nachricht", empf, auth); /* Zwei Verschluesselungen desselben Textes muessen VERSCHIEDEN aussehen. Waeren sie gleich, wuerde entweder das Salz oder das Schluesselpaar wiederverwendet -- der klassische Fehler an dieser Stelle, und einer, der die Verschluesselung angreifbar macht. */ ok(!a.equals(b), "zweimal derselbe Text ergibt zwei verschiedene Nachrichten"); ok(!a.subarray(0, 16).equals(b.subarray(0, 16)), "das Salz ist jedes Mal neu"); ok(!a.subarray(21, 86).equals(b.subarray(21, 86)), "das Schluesselpaar ist jedes Mal neu"); } /* ======================================================================= 3. VAPID — ist das JWT echt und gueltig? ======================================================================= */ console.log("\n=== RFC 8292: VAPID-Signatur ==="); { const paar = schluesselErzeugen(); ok(ausB64u(paar.oeffentlich).length === 65, `oeffentlicher Schluessel: ${ausB64u(paar.oeffentlich).length} Bytes (65 erwartet)`); ok(ausB64u(paar.privat).length === 32, `privater Schluessel: ${ausB64u(paar.privat).length} Bytes (32 erwartet)`); const kopf = vapidKopf("https://fcm.googleapis.com/fcm/send/abc123", paar, "mailto:kontakt@dogfather-universe.com"); ok(kopf.Authorization.startsWith("vapid t="), "die Kopfzeile hat die vorgeschriebene Form"); ok(kopf["Content-Encoding"] === "aes128gcm", "die Kodierung ist aes128gcm"); const jwt = kopf.Authorization.match(/t=([^,]+)/)[1]; const [t1, t2, sig] = jwt.split("."); const kopfDaten = JSON.parse(ausB64u(t1).toString()); const inhalt = JSON.parse(ausB64u(t2).toString()); ok(kopfDaten.alg === "ES256", `Verfahren: ${kopfDaten.alg}`); ok(inhalt.aud === "https://fcm.googleapis.com", `Empfaenger: ${inhalt.aud} (nur Schema und Host, kein Pfad)`); ok(inhalt.sub === "mailto:kontakt@dogfather-universe.com", `Absender: ${inhalt.sub}`); const stunden = (inhalt.exp - Math.floor(Date.now() / 1000)) / 3600; ok(stunden > 0 && stunden <= 24, `laeuft in ${stunden.toFixed(1)} Stunden ab (hoechstens 24)`); /* DER EIGENTLICHE BEWEIS: Laesst sich die Signatur mit dem oeffentlichen Schluessel pruefen? Genau das tut der Push-Dienst. Alles davor waere hinfaellig, wenn das hier nicht stimmt. */ const roh = ausB64u(paar.oeffentlich); const der = Buffer.concat([ Buffer.from("3059301306072a8648ce3d020106082a8648ce3d030107034200", "hex"), roh, ]); const pub = createPublicKey({ key: der, format: "der", type: "spki" }); const echt = createVerify("SHA256").update(`${t1}.${t2}`) .verify({ key: pub, dsaEncoding: "ieee-p1363" }, ausB64u(sig)); ok(echt, "die Signatur laesst sich mit dem oeffentlichen Schluessel pruefen"); ok(ausB64u(sig).length === 64, `Signatur: ${ausB64u(sig).length} Bytes (64, also r||s roh)`); } /* ======================================================================= 4. Unbrauchbare Angaben werden abgewiesen ======================================================================= */ console.log("\n=== Kaputte Anmeldedaten ==="); { const guteAuth = "BTBZMqHH6r4Tts7J_aSIgg"; for (const [wie, p256dh, auth] of [ ["zu kurzer Schluessel", b64u(Buffer.alloc(30)), guteAuth], ["Schluessel ohne 0x04 vorn", b64u(Buffer.alloc(65)), guteAuth], ["zu kurzes Geheimnis", "BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcx" + "aOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4", b64u(Buffer.alloc(8))], ["leer", "", ""], ]) { let geworfen = false; try { verschluesseln("test", p256dh, auth); } catch { geworfen = true; } ok(geworfen, `${wie} wird abgewiesen`); } } /* ======================================================================= GEGENPROBE — merkt die Pruefung ueberhaupt einen Fehler? ======================================================================= */ console.log("\n=== Gegenprobe (muss abweichen) ==="); { const klartext = "When I grow up, I want to be a watermelon"; const empf = "BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcx" + "aOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4"; const auth = "BTBZMqHH6r4Tts7J_aSIgg"; const salz = ausB64u("DGv6ra1nlYgDCS1FRnbzlw"); const privat = ausB64u("yfWPiYE-n46HLnH0KqZOF1fJJU3MYrct3AELtAQ-oRw"); const richtig = b64u(verschluesseln(klartext, empf, auth, salz, privat)); /* Ein einziges verändertes Byte im Geheimnis muss ein voellig anderes Ergebnis liefern. Waere es dasselbe, ginge das Geheimnis gar nicht in die Rechnung ein -- und die Uebereinstimmung oben waere Zufall. */ const andereAuth = b64u(Buffer.concat([ausB64u(auth).subarray(0, 15), Buffer.from([0xff])])); const anders = b64u(verschluesseln(klartext, empf, andereAuth, salz, privat)); ok(anders !== richtig, "ein veraendertes Geheimnis ergibt ein anderes Ergebnis"); const andererText = b64u(verschluesseln(klartext + "!", empf, auth, salz, privat)); ok(andererText !== richtig, "ein veraenderter Text ergibt ein anderes Ergebnis"); const anderesSalz = b64u(verschluesseln(klartext, empf, auth, Buffer.concat([salz.subarray(0, 15), Buffer.from([0x00])]), privat)); ok(anderesSalz !== richtig, "ein veraendertes Salz ergibt ein anderes Ergebnis"); } console.log(`\n${fehler === 0 ? "ALLES IN ORDNUNG" : `${fehler} FEHLER`}`); process.exit(fehler ? 1 : 0);