/* ===================================================================== webpush.js — Push-Nachrichten aufs Handy, ohne Fremdpaket WARUM SELBST GEBAUT UND NICHT "npm install web-push" Der interne Dienst liegt unter /home/dogiintern und steht auf 700. Dort komme ich weder an npm noch an die .env. Jede neue Abhaengigkeit haette also bedeutet: Filipe muss bei jedem Schritt mitmachen, und jede spaetere Aenderung wieder. Node bringt alles Noetige mit -- P-256, HKDF und AES-128-GCM sind eingebaut. Auf dem Server nachgeprueft (Node 24). Also kein fremder Code, keine Lieferkette, keine Version, die irgendwann nicht mehr gepflegt wird. WIE PUSH FUNKTIONIERT (kurz) Der Browser meldet sich beim Push-Dienst seines Herstellers an und bekommt eine Adresse. Diese Adresse plus zwei Schluessel schickt er uns. Wollen wir etwas senden, verschluesseln wir die Nachricht fuer genau dieses Geraet und legen sie bei der Adresse ab. Entscheidend: Der Push-Dienst leitet nur weiter. Er kann den Inhalt NICHT lesen -- die Nachricht ist fuer den Browser des Empfaengers verschluesselt, nicht fuer ihn. Deshalb ist es vertretbar, hier ueberhaupt etwas zu senden, was mit Kundendaten zu tun hat. ZWEI NORMEN RFC 8291 regelt die Verschluesselung, RFC 8292 den Nachweis, dass die Nachricht wirklich von uns kommt (VAPID). Beides ist hier umgesetzt. GEPRUEFT GEGEN DIE NORM Die Verschluesselung wird nicht nur "sieht richtig aus" getestet, sondern gegen das Rechenbeispiel in RFC 8291, Abschnitt 5: dieselben Eingaben muessen Zeichen fuer Zeichen dasselbe Ergebnis liefern. Dafuer lassen sich Salt und Sitzungsschluessel von aussen vorgeben -- im Betrieb werden sie immer zufaellig erzeugt. ===================================================================== */ import crypto from "crypto"; /* --------------------------------------------------------------------- Base64 in der URL-Variante. Push benutzt durchgehend diese Form: ohne "=", und mit - und _ statt + und /. --------------------------------------------------------------------- */ export function b64u(buf) { return Buffer.from(buf).toString("base64") .replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); } export function ausB64u(s) { return Buffer.from(String(s).replace(/-/g, "+").replace(/_/g, "/"), "base64"); } /* --------------------------------------------------------------------- VAPID-SCHLUESSELPAAR Der oeffentliche Teil darf jeder sehen -- er steht sogar im Browser. Der private Teil ist das Geheimnis: Wer ihn hat, kann in unserem Namen Push-Nachrichten schicken. Der oeffentliche Schluessel wird als "unkomprimierter Punkt" gebraucht: ein 0x04 gefolgt von X und Y, zusammen 65 Byte. Node liefert ihn als JWK mit getrennten X- und Y-Werten, deshalb hier zusammengesetzt. --------------------------------------------------------------------- */ export function schluesselpaarErzeugen() { const { publicKey, privateKey } = crypto.generateKeyPairSync("ec", { namedCurve: "prime256v1", }); const jwk = publicKey.export({ format: "jwk" }); const roh = Buffer.concat([ Buffer.from([4]), ausB64u(jwk.x), ausB64u(jwk.y), ]); return { oeffentlich: b64u(roh), privat: privateKey.export({ format: "pem", type: "pkcs8" }).toString(), }; } /* --------------------------------------------------------------------- VAPID-NACHWEIS Ein kurzlebiger, signierter Ausweis. Er sagt dem Push-Dienst: Diese Nachricht kommt von dem, dem dieser oeffentliche Schluessel gehoert. FALLSTRICK: Node liefert ECDSA-Signaturen standardmaessig im DER-Format (mit Laengenangaben und fuehrenden Nullen). JWT verlangt aber die nackten 64 Byte -- r direkt gefolgt von s. Ohne "dsaEncoding: ieee-p1363" wird die Signatur abgelehnt, und der Fehler lautet nur "invalid JWT", was in die voellig falsche Richtung zeigt. --------------------------------------------------------------------- */ export function vapidKopf(endpunkt, privatPem, oeffentlich, kontakt, jetztSek) { const url = new URL(endpunkt); const jetzt = jetztSek || Math.floor(Date.now() / 1000); const kopf = b64u(JSON.stringify({ typ: "JWT", alg: "ES256" })); const rumpf = b64u(JSON.stringify({ aud: url.origin, /* Zwoelf Stunden. Die Norm erlaubt bis 24, aber kuerzer ist besser: Faengt jemand den Ausweis ab, ist er schneller wertlos. */ exp: jetzt + 12 * 60 * 60, sub: kontakt, })); const daten = Buffer.from(`${kopf}.${rumpf}`); const sig = crypto.sign("sha256", daten, { key: crypto.createPrivateKey(privatPem), dsaEncoding: "ieee-p1363", }); return `vapid t=${kopf}.${rumpf}.${b64u(sig)}, k=${oeffentlich}`; } /* --------------------------------------------------------------------- VERSCHLUESSELUNG NACH RFC 8291 Ablauf: 1. Ein Sitzungsschluesselpaar erzeugen (gilt nur fuer diese eine Nachricht). 2. Daraus und aus dem oeffentlichen Schluessel des Geraets ein gemeinsames Geheimnis rechnen (ECDH). 3. Mit dem Authentifizierungsgeheimnis des Geraets zu einem Grundschluessel verruehren (HKDF). 4. Daraus Inhaltsschluessel und Zaehlwert ableiten. 5. Verschluesseln, und alles zusammensetzen, was der Empfaenger zum Entschluesseln braucht. Der zusammengesetzte Koerper sieht so aus: Salt (16) | Blockgroesse (4) | Laenge (1) | Sitzungsschluessel (65) | Inhalt --------------------------------------------------------------------- */ export function verschluesseln(nachricht, p256dh, auth, opt = {}) { const empfaenger = ausB64u(p256dh); const geheimnis = ausB64u(auth); if (empfaenger.length !== 65 || empfaenger[0] !== 4) { throw new Error("Geraeteschluessel hat nicht die erwartete Form."); } if (geheimnis.length !== 16) { throw new Error("Authentifizierungsgeheimnis muss 16 Byte lang sein."); } /* Im Betrieb immer zufaellig. Vorgebbar nur, damit sich das Rechenbeispiel der Norm nachrechnen laesst. */ const salt = opt.salt ? Buffer.from(opt.salt) : crypto.randomBytes(16); const ecdh = crypto.createECDH("prime256v1"); if (opt.sitzungPrivat) ecdh.setPrivateKey(Buffer.from(opt.sitzungPrivat)); else ecdh.generateKeys(); const sitzungOeffentlich = ecdh.getPublicKey(); const gemeinsam = ecdh.computeSecret(empfaenger); /* Schritt 3. Das "info" bindet beide oeffentlichen Schluessel mit ein -- dadurch passt der Schluessel nur zu genau diesem Geraet und genau dieser Nachricht. */ const grundInfo = Buffer.concat([ Buffer.from("WebPush: info\0"), empfaenger, sitzungOeffentlich, ]); const grund = Buffer.from( crypto.hkdfSync("sha256", gemeinsam, geheimnis, grundInfo, 32) ); const cek = Buffer.from( crypto.hkdfSync("sha256", grund, salt, Buffer.from("Content-Encoding: aes128gcm\0"), 16) ); const nonce = Buffer.from( crypto.hkdfSync("sha256", grund, salt, Buffer.from("Content-Encoding: nonce\0"), 12) ); /* Das 0x02 markiert das Ende. Es gehoert mit verschluesselt -- ein Empfaenger prueft daran, dass die Nachricht vollstaendig ist. */ const klar = Buffer.concat([Buffer.from(nachricht, "utf8"), Buffer.from([2])]); const chiffre = crypto.createCipheriv("aes-128-gcm", cek, nonce); const inhalt = Buffer.concat([chiffre.update(klar), chiffre.final(), chiffre.getAuthTag()]); const blockgroesse = Buffer.alloc(4); blockgroesse.writeUInt32BE(opt.blockgroesse || 4096, 0); return Buffer.concat([ salt, blockgroesse, Buffer.from([sitzungOeffentlich.length]), sitzungOeffentlich, inhalt, ]); } /* --------------------------------------------------------------------- SENDEN Der Rueckgabewert unterscheidet drei Faelle, und die Unterscheidung ist wichtiger als sie aussieht: ok -- angekommen abgemeldet -- das Geraet gibt es nicht mehr (404/410). Der Eintrag MUSS geloescht werden, sonst sammeln sich Karteileichen an und jede Meldung dauert laenger, weil jedes Mal erfolglos an tote Adressen gesendet wird. fehler -- alles andere (Netz, Dienst gestoert). Eintrag behalten. --------------------------------------------------------------------- */ export async function senden(abo, nachricht, vapid, opt = {}) { const koerper = verschluesseln(nachricht, abo.p256dh, abo.auth, opt); const antwort = await fetch(abo.endpunkt, { method: "POST", headers: { "Content-Encoding": "aes128gcm", "Content-Type": "application/octet-stream", /* Wie lange der Dienst die Nachricht aufhebt, falls das Geraet gerade aus ist. Vier Stunden: Eine Anfrage von heute Nacht ist morgen frueh noch von Belang. */ TTL: String(opt.ttl == null ? 4 * 60 * 60 : opt.ttl), Urgency: opt.dringlichkeit || "normal", Authorization: vapidKopf(abo.endpunkt, vapid.privat, vapid.oeffentlich, vapid.kontakt), }, body: koerper, }); if (antwort.status === 404 || antwort.status === 410) { return { zustand: "abgemeldet", status: antwort.status }; } if (!antwort.ok) { const text = await antwort.text().catch(() => ""); return { zustand: "fehler", status: antwort.status, text: text.slice(0, 200) }; } return { zustand: "ok", status: antwort.status }; }