/* ===================================================================== Prueft die Push-Verschluesselung gegen die Norm selbst. Bei Kryptographie hilft "sieht plausibel aus" nichts. Ein Fehler in der Schluesselableitung erzeugt keinen Absturz -- er erzeugt Bytes, die genauso zufaellig aussehen wie richtige. Auffallen wuerde es erst daran, dass auf keinem Handy je eine Meldung ankommt, und die Suche danach faengt dann garantiert an der falschen Stelle an. Deshalb wird hier gegen das Rechenbeispiel aus RFC 8291 geprueft -- Abschnitt 5 fuer die fertige Nachricht, Anhang A fuer die Zwischenwerte. Diese Zwischenwerte sind der eigentliche Gewinn: Weicht einer ab, weiss man sofort, WELCHER Schritt falsch ist, statt nur zu wissen, dass irgendetwas nicht stimmt. Quelle: https://www.rfc-editor.org/rfc/rfc8291.txt ===================================================================== */ import crypto from "crypto"; import { b64u, ausB64u, verschluesseln, schluesselpaarErzeugen, vapidKopf, } from "./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 : "")); } }; /* --- Die Werte aus der Norm, unveraendert ------------------------- */ const V = { klartext: "When I grow up, I want to be a watermelon", authSecret: "BTBZMqHH6r4Tts7J_aSIgg", uaPublic: "BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcxaOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4", uaPrivate: "q1dXpw3UpT5VOmu_cf_v6ih07Aems3njxI-JWgLcM94", asPublic: "BP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27mlmlMoZIIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A8", asPrivate: "yfWPiYE-n46HLnH0KqZOF1fJJU3MYrct3AELtAQ-oRw", salt: "DGv6ra1nlYgDCS1FRnbzlw", // Anhang A shared: "kyrL1jIIOHEzg3sM2ZWRHDRB62YACZhhSlknJ672kSs", prkKey: "Snr3JMxaHVDXHWJn5wdC52WjpCtd2EIEGBykDcZW32k", ikm: "S4lYMb_L0FxCeq0WhDx813KgSYqU26kOyzWUdsXYyrg", cek: "oIhVW04MRdy2XN9CiKLxTg", nonce: "4h_95klXJ5E_qnoN", }; console.log("1. DIE EINZELNEN ABLEITUNGSSCHRITTE (RFC 8291, Anhang A)"); const ecdh = crypto.createECDH("prime256v1"); ecdh.setPrivateKey(ausB64u(V.asPrivate)); pruefe("Sitzungsschluessel passt zum privaten Teil", b64u(ecdh.getPublicKey()) === V.asPublic, b64u(ecdh.getPublicKey()).slice(0, 24) + "…"); const gemeinsam = ecdh.computeSecret(ausB64u(V.uaPublic)); pruefe("gemeinsames Geheimnis (ECDH)", b64u(gemeinsam) === V.shared, b64u(gemeinsam)); const prkKey = crypto.createHmac("sha256", ausB64u(V.authSecret)).update(gemeinsam).digest(); pruefe("PRK_key", b64u(prkKey) === V.prkKey, b64u(prkKey)); const keyInfo = Buffer.concat([ Buffer.from("WebPush: info\0"), ausB64u(V.uaPublic), ausB64u(V.asPublic), ]); const ikm = Buffer.from( crypto.hkdfSync("sha256", gemeinsam, ausB64u(V.authSecret), keyInfo, 32) ); pruefe("IKM", b64u(ikm) === V.ikm, b64u(ikm)); const cek = Buffer.from( crypto.hkdfSync("sha256", ikm, ausB64u(V.salt), Buffer.from("Content-Encoding: aes128gcm\0"), 16) ); pruefe("CEK (Inhaltsschluessel)", b64u(cek) === V.cek, b64u(cek)); const nonce = Buffer.from( crypto.hkdfSync("sha256", ikm, ausB64u(V.salt), Buffer.from("Content-Encoding: nonce\0"), 12) ); pruefe("NONCE (Zaehlwert)", b64u(nonce) === V.nonce, b64u(nonce)); console.log("\n2. DIE FERTIGE NACHRICHT"); const koerper = verschluesseln(V.klartext, V.uaPublic, V.authSecret, { salt: ausB64u(V.salt), sitzungPrivat: ausB64u(V.asPrivate), blockgroesse: 4096, }); /* Der Kopf ist unverschluesselt und laesst sich Feld fuer Feld nachrechnen. Stimmt er, sitzt der Empfaenger nicht vor einer Nachricht, deren Aufbau er gar nicht erst lesen kann. */ pruefe("Salt steht vorn", b64u(koerper.subarray(0, 16)) === V.salt); pruefe("Blockgroesse 4096", koerper.readUInt32BE(16) === 4096, String(koerper.readUInt32BE(16))); pruefe("Laengenangabe 65", koerper[20] === 65, String(koerper[20])); pruefe("Sitzungsschluessel eingebettet", b64u(koerper.subarray(21, 86)) === V.asPublic); /* Und der Inhalt selbst. Der Vergleich laeuft gegen die Zeile aus Abschnitt 5 der Norm -- dieselbe Eingabe muss Byte fuer Byte dasselbe ergeben. */ const AUS_DER_NORM = "DGv6ra1nlYgDCS1FRnbzlwAAEABBBP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27ml" + "mlMoZIIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A_yl95bQpu6cVPT" + "pK4Mqgkf1CXztLVBSt2Ks3oZwbuwXPXLWyouBWLVWGNWQexSgSxsj_Qulcy4a-fN"; const meins = b64u(koerper); pruefe("Nachricht stimmt mit der Norm ueberein", meins === AUS_DER_NORM, meins === AUS_DER_NORM ? `${koerper.length} Byte` : `meins ${meins.length} / Norm ${AUS_DER_NORM.length} Zeichen`); if (meins !== AUS_DER_NORM) { /* Zeigt, AB WO es auseinanderlaeuft -- das grenzt den Fehler zwischen Kopf, Schluessel und Inhalt ein. */ let i = 0; while (i < Math.min(meins.length, AUS_DER_NORM.length) && meins[i] === AUS_DER_NORM[i]) i++; console.log(` erste Abweichung an Zeichen ${i} (Kopf endet bei 115)`); console.log(` meins: …${meins.slice(Math.max(0, i - 10), i + 20)}`); console.log(` Norm : …${AUS_DER_NORM.slice(Math.max(0, i - 10), i + 20)}`); } console.log("\n3. RUNDLAUF: laesst sich das Verschluesselte wieder oeffnen?"); /* Gegenprobe mit frischen Zufallswerten. Der Normvergleich oben prueft einen einzigen festen Fall; hier wird geprueft, dass es auch mit selbst erzeugten Schluesseln aufgeht -- also im Betrieb. */ const empfaenger = crypto.createECDH("prime256v1"); empfaenger.generateKeys(); const authNeu = crypto.randomBytes(16); const text = "Neue Anfrage A-2608-0007 (onepager)"; const paket = verschluesseln(text, b64u(empfaenger.getPublicKey()), b64u(authNeu)); /* Entschluesseln wie ein Browser es taete. */ const rSalt = paket.subarray(0, 16); const rAsPub = paket.subarray(21, 86); const rInhalt = paket.subarray(86); const rGemeinsam = empfaenger.computeSecret(rAsPub); const rIkm = Buffer.from(crypto.hkdfSync("sha256", rGemeinsam, authNeu, Buffer.concat([Buffer.from("WebPush: info\0"), empfaenger.getPublicKey(), rAsPub]), 32)); const rCek = Buffer.from(crypto.hkdfSync("sha256", rIkm, rSalt, Buffer.from("Content-Encoding: aes128gcm\0"), 16)); const rNonce = Buffer.from(crypto.hkdfSync("sha256", rIkm, rSalt, Buffer.from("Content-Encoding: nonce\0"), 12)); const entschluesseler = crypto.createDecipheriv("aes-128-gcm", rCek, rNonce); entschluesseler.setAuthTag(rInhalt.subarray(rInhalt.length - 16)); const klar = Buffer.concat([ entschluesseler.update(rInhalt.subarray(0, rInhalt.length - 16)), entschluesseler.final(), ]); const zurueck = klar.subarray(0, klar.length - 1).toString("utf8"); pruefe("entschluesselter Text ist der gesendete", zurueck === text, zurueck); pruefe("Abschlusszeichen 0x02 sitzt am Ende", klar[klar.length - 1] === 2); console.log("\n4. VAPID-AUSWEIS"); const paar = schluesselpaarErzeugen(); pruefe("oeffentlicher Schluessel ist 65 Byte", ausB64u(paar.oeffentlich).length === 65, ausB64u(paar.oeffentlich).length + " Byte"); pruefe("beginnt mit 0x04 (unkomprimierter Punkt)", ausB64u(paar.oeffentlich)[0] === 4); const kopf = vapidKopf("https://fcm.googleapis.com/fcm/send/abc", paar.privat, paar.oeffentlich, "mailto:kontakt@dogfather-universe.com", 1000000); pruefe("Kopf hat die Form 'vapid t=…, k=…'", /^vapid t=[\w-]+\.[\w-]+\.[\w-]+, k=[\w-]+$/.test(kopf)); const [teilA, teilB, teilC] = kopf.slice(8).split(", ")[0].split("."); const rumpf = JSON.parse(ausB64u(teilB).toString()); pruefe("Empfaenger ist der Ursprung des Endpunkts", rumpf.aud === "https://fcm.googleapis.com", rumpf.aud); pruefe("laeuft nach 12 Stunden ab", rumpf.exp === 1000000 + 43200, String(rumpf.exp - 1000000) + " Sekunden"); /* Die 64 Byte sind der eigentliche Fallstrick: Node liefert ohne ausdrueckliche Angabe eine DER-Signatur (70-72 Byte), die jeder Push-Dienst wortlos ablehnt. */ pruefe("Signatur ist 64 Byte roh, nicht DER", ausB64u(teilC).length === 64, ausB64u(teilC).length + " Byte"); const echt = crypto.verify("sha256", Buffer.from(`${teilA}.${teilB}`), { key: crypto.createPublicKey(crypto.createPrivateKey(paar.privat)), dsaEncoding: "ieee-p1363" }, ausB64u(teilC)); pruefe("Signatur ist gueltig", echt); console.log(`\n===== ${ok} bestanden, ${fehl} fehlgeschlagen =====`); process.exit(fehl ? 1 : 0);