diff --git a/cloudflare-worker/migrations/0019_webdesign_push.sql b/cloudflare-worker/migrations/0019_webdesign_push.sql new file mode 100644 index 00000000..6becbb9d --- /dev/null +++ b/cloudflare-worker/migrations/0019_webdesign_push.sql @@ -0,0 +1,54 @@ +-- ===================================================================== +-- Push-Benachrichtigungen: angemeldete Geraete +-- +-- WARUM EINE EIGENE TABELLE UND KEIN FELD AM BENUTZER +-- +-- Ein Mensch hat mehrere Geraete -- Handy, Rechner, vielleicht ein +-- Tablet -- und jedes meldet sich einzeln an. Jedes bekommt eine eigene +-- Adresse und eigene Schluessel. Ein einzelnes Feld am Benutzer koennte +-- immer nur das zuletzt angemeldete Geraet halten, und die Anmeldung am +-- Rechner wuerde die am Handy stillschweigend loeschen. +-- +-- WAS HIER STEHT UND WAS NICHT +-- +-- endpunkt, p256dh und auth kommen vom Browser. Sie sind keine +-- Geheimnisse im engeren Sinn, aber sie erlauben es, an genau dieses +-- Geraet zu senden -- deshalb liegen sie nicht offener als der Rest der +-- Datenbank. +-- +-- ZAEHLER STATT SOFORT LOESCHEN +-- +-- Meldet der Push-Dienst 404 oder 410, ist das Geraet endgueltig weg und +-- der Eintrag wird geloescht. Bei allen anderen Fehlern (Netz gestoert, +-- Dienst kurz nicht erreichbar) waere Loeschen falsch -- dann wuerde +-- eine einzelne Stoerung die Anmeldung kosten. Deshalb ein Zaehler: +-- voruebergehende Fehler heilen sich, dauerhafte fallen auf. +-- ===================================================================== + +CREATE TABLE IF NOT EXISTS wd_push_geraete ( + id TEXT PRIMARY KEY, + + -- Die Adresse beim Push-Dienst. Eindeutig: Meldet sich dasselbe + -- Geraet erneut an, soll der alte Eintrag ersetzt werden und kein + -- zweiter danebenstehen -- sonst kaeme jede Meldung doppelt. + endpunkt TEXT NOT NULL UNIQUE, + + p256dh TEXT NOT NULL, + auth TEXT NOT NULL, + + -- Wem gehoert das Geraet. Verweist auf die Team-Anmeldung, damit beim + -- Entzug eines Zugangs auch dessen Geraete verschwinden koennen. + benutzer_id TEXT, + + -- Etwas Wiedererkennbares fuer die Liste in der Verwaltung ("Chrome + -- auf Android"). Ohne das steht dort nur eine 200 Zeichen lange + -- Adresse, und niemand weiss, welches Geraet er gerade abmeldet. + bezeichnung TEXT, + + erstellt_am TEXT NOT NULL, + zuletzt_ok_am TEXT, + fehler_zaehler INTEGER NOT NULL DEFAULT 0, + letzter_fehler TEXT +); + +CREATE INDEX IF NOT EXISTS idx_wd_push_benutzer ON wd_push_geraete (benutzer_id); diff --git a/server-internal/db.js b/server-internal/db.js index 36e87e16..8a6e83ff 100644 --- a/server-internal/db.js +++ b/server-internal/db.js @@ -65,6 +65,9 @@ const MIGRATION_FILES = [ // Altfaelle: Projekte, die abgebrochen wurden, BEVOR es das // Archiv-Kennzeichen gab, stehen sonst weiter in der laufenden Liste. "0018_webdesign_altabbrueche_archivieren.sql", + // Push-Benachrichtigung aufs Handy: ein Eintrag je angemeldetem + // Geraet, damit Handy und Rechner nebeneinander bestehen koennen. + "0019_webdesign_push.sql", ]; function alreadyApplied(name) { diff --git a/server-internal/index.js b/server-internal/index.js index 0ed6969e..2f7d6b58 100644 --- a/server-internal/index.js +++ b/server-internal/index.js @@ -86,6 +86,12 @@ import { anfrageAbsenden, anfragenListe, anfrageDetail, anfrageStatus, anfrageArchivieren, anfrageNotiz, } from "./routes/webdesign-anfragen.js"; +/* Push-Benachrichtigung (26.08.2026). Ohne Fremdpaket gebaut, weil der + Dienst unter /home/dogiintern mit Rechten 700 liegt -- dort ist kein + npm erreichbar. Node bringt P-256, HKDF und AES-GCM selbst mit. */ +import { + pushStand, pushSchluessel, pushAnmelden, pushAbmelden, pushTest, +} from "./routes/webdesign-push.js"; /* Kundenportal (Masterplan S.12). Eigene Anmeldung mit eigener Sitzung -- Kunden sind KEINE Teammitglieder und duerfen mit deren Rechtesystem nichts zu tun haben. Die Mandantentrennung sitzt in den Handlern: @@ -138,6 +144,7 @@ import { angebotVorschau, angebotSenden, angebotZurueckziehen } Sie muessen beim Start EINMAL entschluesselt werden, weil die Pruefungen des PayPal-Moduls synchron sind. */ import { geheimnisseLaden } from "./lib/webdesign-geheimnisse.js"; +import { vapidSicherstellen } from "./lib/webdesign-push.js"; import { vorlagenSicherstellen } from "./lib/webdesign-vorlagen.js"; initDb(); @@ -157,6 +164,17 @@ geheimnisseLaden() .then((n) => { if (n) console.log(`[webdesign] ${n} Einstellungen aus der Datenbank geladen.`); }) .catch((e) => console.error("[webdesign] Einstellungen nicht ladbar:", e?.message)); +/* Push-Schluessel bereitstellen. Beim allerersten Start werden sie + erzeugt, danach nur noch gelesen -- ein Wechsel wuerde alle + angemeldeten Geraete stillschweigend abhaengen. + + Faellt das aus, laeuft der Dienst weiter: Ohne Benachrichtigung + arbeitet die Seite normal, sie meldet sich nur nicht von selbst. Die + Verwaltung zeigt den Zustand an, statt ihn zu verschweigen. */ +vapidSicherstellen() + .then((r) => { if (r.zustand === "neu") console.log("[push] eingerichtet."); }) + .catch((e) => console.error("[push] nicht einsatzbereit:", e?.message)); + const PORT = Number(process.env.PORT || 4200); const app = express(); app.set("trust proxy", 1); @@ -305,6 +323,16 @@ app.post("/webdesign/admin/anfragen/:id/status", wrap(anfrageStatus)); app.post("/webdesign/admin/anfragen/:id/archivieren", wrap(anfrageArchivieren)); app.post("/webdesign/admin/anfragen/:id/notiz", wrap(anfrageNotiz)); +/* Benachrichtigung aufs Handy (26.08.2026). + Der Stand-Endpunkt ist der wichtigste davon: Er beantwortet die Frage + "wuerde ich es mitbekommen, wenn jetzt eine Anfrage kaeme?". Genau + die war bisher nicht zu beantworten. */ +app.get("/webdesign/admin/push/stand", wrap(pushStand)); +app.get("/webdesign/admin/push/schluessel", wrap(pushSchluessel)); +app.post("/webdesign/admin/push/anmelden", wrap(pushAnmelden)); +app.post("/webdesign/admin/push/abmelden", wrap(pushAbmelden)); +app.post("/webdesign/admin/push/test", wrap(pushTest)); + /* Annehmen und Ablehnen. Die Vorschau-Endpunkte lesen nur und veraendern nichts -- deshalb GET. diff --git a/server-internal/lib/webdesign-push.js b/server-internal/lib/webdesign-push.js new file mode 100644 index 00000000..e22ebe17 --- /dev/null +++ b/server-internal/lib/webdesign-push.js @@ -0,0 +1,237 @@ +/* ===================================================================== + webdesign-push.js — Geraete verwalten und Meldungen verschicken + + DIE SCHLUESSEL ERZEUGT DER SERVER SELBST + + Ein Push-Absender braucht ein dauerhaftes Schluesselpaar. Ueblich + waere, es von Hand zu erzeugen und in die .env zu schreiben. + + Das geht hier nicht: Der Dienst liegt unter /home/dogiintern mit + Rechten 700. Also erzeugt der Server das Paar beim ersten Start + selbst und legt es in der Datenbank ab -- den privaten Teil + verschluesselt, ueber denselben Weg wie die PayPal-Zugangsdaten. + + Das ist nicht nur ein Notbehelf, sondern besser: Es gibt keinen + Einrichtungsschritt, der vergessen werden kann, und der private + Schluessel steht nie in einer Datei, die jemand versehentlich kopiert. + + WICHTIG: Das Paar darf sich nicht aendern. Wechselt der oeffentliche + Schluessel, sind alle bestehenden Anmeldungen wertlos -- jedes Geraet + muesste sich neu anmelden, ohne dass jemand merkt, warum ploetzlich + nichts mehr ankommt. Deshalb wird nur erzeugt, wenn nichts da ist. + + WAS IN EINER MELDUNG STEHT + + Nur die Anfragenummer und das Paket -- keine Namen, keine + E-Mail-Adressen. Eine Push-Meldung erscheint auf einem + Sperrbildschirm, den auch jemand sieht, der nur zufaellig daneben + steht. Wer die Anfrage lesen will, oeffnet die Verwaltung. + + Der Push-Dienst selbst kann ohnehin nichts mitlesen: Der Inhalt ist + fuer das Zielgeraet verschluesselt (siehe lib/webpush.js). + ===================================================================== */ + +import crypto from "crypto"; +import { db } from "../db.js"; +import { encryptCode, decryptCode } from "./crypto.js"; +import { schluesselpaarErzeugen, senden } from "./webpush.js"; + +const SCHLUESSEL_OEFFENTLICH = "wd_push_vapid_oeffentlich"; +const SCHLUESSEL_PRIVAT = "wd_push_vapid_privat"; + +let vapid = null; + +function einstellungLesen(schluessel) { + const zeile = db.prepare(`SELECT value FROM app_settings WHERE key = ?`).get(schluessel); + return zeile ? zeile.value : null; +} + +function einstellungSchreiben(schluessel, wert) { + db.prepare( + `INSERT INTO app_settings (key, value, updated_at) VALUES (?,?,?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at` + ).run(schluessel, wert, new Date().toISOString()); +} + +/* Beim Start aufzurufen. Erzeugt das Paar nur beim allerersten Mal. */ +export async function vapidSicherstellen() { + const oeffentlich = einstellungLesen(SCHLUESSEL_OEFFENTLICH); + const privatRoh = einstellungLesen(SCHLUESSEL_PRIVAT); + + if (oeffentlich && privatRoh) { + try { + vapid = { + oeffentlich, + privat: await decryptCode(privatRoh), + kontakt: kontakt(), + }; + return { zustand: "vorhanden" }; + } catch (e) { + /* Der ENCRYPTION_KEY wurde gewechselt. Neu erzeugen waere hier + GENAU FALSCH: Es wuerde alle Anmeldungen unbrauchbar machen, + ohne dass jemand erfaehrt, warum keine Meldungen mehr kommen. + Lieber sichtbar nichts tun. */ + console.error("[push] VAPID-Schluessel nicht entschluesselbar — Push ist aus. " + + "Ursache ist fast immer ein gewechselter ENCRYPTION_KEY."); + return { zustand: "unlesbar" }; + } + } + + const paar = schluesselpaarErzeugen(); + einstellungSchreiben(SCHLUESSEL_OEFFENTLICH, paar.oeffentlich); + einstellungSchreiben(SCHLUESSEL_PRIVAT, await encryptCode(paar.privat)); + vapid = { oeffentlich: paar.oeffentlich, privat: paar.privat, kontakt: kontakt() }; + console.log("[push] Neues VAPID-Schluesselpaar erzeugt und gespeichert."); + return { zustand: "neu" }; +} + +/* Die Norm verlangt eine Kontaktmoeglichkeit, damit ein Push-Dienst bei + Problemen jemanden erreichen kann. */ +function kontakt() { + return process.env.PUSH_KONTAKT || "mailto:kontakt@dogfather-universe.com"; +} + +export function istBereit() { return !!vapid; } +export function oeffentlicherSchluessel() { return vapid ? vapid.oeffentlich : null; } + +/* --------------------------------------------------------------------- + GERAETE + --------------------------------------------------------------------- */ + +export function geraetAnmelden(abo, benutzerId, bezeichnung) { + if (!abo || !abo.endpoint || !abo.keys || !abo.keys.p256dh || !abo.keys.auth) { + throw new Error("Anmeldung unvollstaendig."); + } + const jetzt = new Date().toISOString(); + + /* Bei erneuter Anmeldung desselben Geraets den vorhandenen Eintrag + auffrischen statt einen zweiten anzulegen -- sonst kaeme jede + Meldung mehrfach. Der Fehlerzaehler wird dabei zurueckgesetzt: Wer + sich gerade erfolgreich anmeldet, ist offensichtlich erreichbar. */ + db.prepare( + `INSERT INTO wd_push_geraete + (id, endpunkt, p256dh, auth, benutzer_id, bezeichnung, erstellt_am, fehler_zaehler) + VALUES (?,?,?,?,?,?,?,0) + ON CONFLICT(endpunkt) DO UPDATE SET + p256dh = excluded.p256dh, + auth = excluded.auth, + benutzer_id = excluded.benutzer_id, + bezeichnung = excluded.bezeichnung, + fehler_zaehler = 0, + letzter_fehler = NULL` + ).run( + crypto.randomUUID(), String(abo.endpoint), String(abo.keys.p256dh), + String(abo.keys.auth), benutzerId || null, + String(bezeichnung || "").slice(0, 80) || null, jetzt + ); + + return { angemeldet: true }; +} + +export function geraetAbmelden(endpunkt) { + const r = db.prepare(`DELETE FROM wd_push_geraete WHERE endpunkt = ?`).run(String(endpunkt)); + return { abgemeldet: r.changes > 0 }; +} + +/* Fuer die Anzeige. Die volle Adresse wird NICHT herausgegeben -- sie + nuetzt in der Oberflaeche nichts und gehoert nicht in ein Protokoll + oder einen Screenshot. Zum Abmelden genuegt die Kennung. */ +export function geraeteListe(benutzerId) { + const zeilen = benutzerId + ? db.prepare(`SELECT * FROM wd_push_geraete WHERE benutzer_id = ? ORDER BY erstellt_am DESC`).all(benutzerId) + : db.prepare(`SELECT * FROM wd_push_geraete ORDER BY erstellt_am DESC`).all(); + + return zeilen.map((z) => ({ + id: z.id, + bezeichnung: z.bezeichnung || "Unbenanntes Geraet", + dienst: dienstName(z.endpunkt), + erstellt: z.erstellt_am, + zuletztOk: z.zuletzt_ok_am, + fehler: z.fehler_zaehler, + letzterFehler: z.letzter_fehler, + })); +} + +/* "Google", "Mozilla", "Microsoft" -- hilft beim Wiedererkennen, ohne + die Adresse preiszugeben. */ +function dienstName(endpunkt) { + try { + const h = new URL(endpunkt).hostname; + if (h.includes("google")) return "Google (Chrome/Android)"; + if (h.includes("mozilla")) return "Mozilla (Firefox)"; + if (h.includes("microsoft") || h.includes("windows")) return "Microsoft (Edge)"; + if (h.includes("apple") || h.includes("icloud")) return "Apple (Safari/iPhone)"; + return h; + } catch { return "unbekannt"; } +} + +/* --------------------------------------------------------------------- + SENDEN + --------------------------------------------------------------------- */ + +export async function meldeAllen(inhalt) { + if (!vapid) return { gesendet: 0, grund: "Push ist nicht eingerichtet." }; + + const geraete = db.prepare(`SELECT * FROM wd_push_geraete`).all(); + if (!geraete.length) return { gesendet: 0, grund: "Kein Geraet angemeldet." }; + + const nachricht = JSON.stringify(inhalt); + const jetzt = new Date().toISOString(); + let gesendet = 0, entfernt = 0, fehler = 0; + + /* Alle gleichzeitig. Bei einem halben Dutzend Geraeten faellt das + kaum ins Gewicht -- nacheinander waere es aber die Summe aller + Zeitueberschreitungen, und ein einziges haengendes Geraet wuerde + die Meldung fuer alle anderen verzoegern. */ + const ergebnisse = await Promise.allSettled( + geraete.map((g) => senden( + { endpunkt: g.endpunkt, p256dh: g.p256dh, auth: g.auth }, + nachricht, vapid + )) + ); + + ergebnisse.forEach((e, i) => { + const g = geraete[i]; + if (e.status !== "fulfilled") { + fehler++; + merkeFehler(g.id, String(e.reason?.message || e.reason).slice(0, 200)); + return; + } + if (e.value.zustand === "ok") { + gesendet++; + db.prepare(`UPDATE wd_push_geraete SET zuletzt_ok_am = ?, fehler_zaehler = 0, letzter_fehler = NULL WHERE id = ?`) + .run(jetzt, g.id); + } else if (e.value.zustand === "abgemeldet") { + /* Endgueltig weg. Aufraeumen, sonst waechst die Liste mit + Karteileichen und jede Meldung dauert laenger. */ + entfernt++; + db.prepare(`DELETE FROM wd_push_geraete WHERE id = ?`).run(g.id); + } else { + fehler++; + merkeFehler(g.id, `HTTP ${e.value.status}: ${e.value.text || ""}`.slice(0, 200)); + } + }); + + return { gesendet, entfernt, fehler }; +} + +function merkeFehler(id, text) { + db.prepare(`UPDATE wd_push_geraete SET fehler_zaehler = fehler_zaehler + 1, letzter_fehler = ? WHERE id = ?`) + .run(text, id); +} + +/* Der Stand fuer die Anzeige in der Verwaltung. Beantwortet die Frage, + die man tatsaechlich hat: Wuerde ich es mitbekommen, wenn jetzt eine + Anfrage kaeme? */ +export function stand() { + const anzahl = db.prepare(`SELECT count(*) AS n FROM wd_push_geraete`).get().n; + const mitFehlern = db.prepare(`SELECT count(*) AS n FROM wd_push_geraete WHERE fehler_zaehler > 0`).get().n; + return { + eingerichtet: !!vapid, + oeffentlicherSchluessel: vapid ? vapid.oeffentlich : null, + geraete: anzahl, + geraeteMitFehlern: mitFehlern, + /* Die eine Zeile, auf die es ankommt. */ + wirktMeldung: !!vapid && anzahl > 0, + }; +} diff --git a/server-internal/lib/webpush.js b/server-internal/lib/webpush.js new file mode 100644 index 00000000..695463cb --- /dev/null +++ b/server-internal/lib/webpush.js @@ -0,0 +1,234 @@ +/* ===================================================================== + 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 }; +} diff --git a/server-internal/routes/webdesign-anfragen.js b/server-internal/routes/webdesign-anfragen.js index 5007f506..b560d1fb 100644 --- a/server-internal/routes/webdesign-anfragen.js +++ b/server-internal/routes/webdesign-anfragen.js @@ -22,6 +22,7 @@ import { ipHash, besucherIp, spracheOderDe, verlauf, PAKETE, ANFRAGE_STATUS, } from "../lib/webdesign-helfer.js"; +import { meldeAllen } from "../lib/webdesign-push.js"; /* --------------------------------------------------------------------- Rechteprüfung. Der Eigentümer darf immer alles — genau wie im @@ -201,20 +202,61 @@ export async function anfrageAbsenden(req, res) { }); } -/* Discord-Benachrichtigung über den bereits vorhandenen Webhook-Weg. +/* Benachrichtigung über alle eingerichteten Wege. Bewusst OHNE Kundendaten im Text: die Meldung sagt nur, dass etwas da ist. Wer die Anfrage lesen will, meldet sich in der Verwaltung an — - ein Discord-Kanal ist kein sicherer Ort für Kontaktdaten. */ + weder ein Discord-Kanal noch ein Sperrbildschirm ist ein sicherer Ort + für Kontaktdaten. + + ⚠️ HIER STAND EINMAL EIN STILLES `if (!url) return;` (behoben + 26.08.2026) + + Der Discord-Weg las eine Umgebungsvariable, die in .env.example gar + nicht aufgeführt war — also mit hoher Wahrscheinlichkeit nie gesetzt + wurde. Fehlte sie, kehrte die Funktion wortlos zurück: kein Fehler, + keine Protokollzeile, nichts. Es gab damit eine Benachrichtigung, die + nur im Quelltext existierte, und niemand konnte das bemerken. + + Das ist die unangenehmste Sorte Fehler: nicht einer, der etwas + kaputtmacht, sondern einer, der NICHTS tut und dabei aussieht wie + Erfolg. Deshalb wird jetzt jeder Weg einzeln protokolliert — auch und + gerade, wenn er übersprungen wird. */ async function benachrichtige(nummer, name, email, paket) { + const wege = []; + + /* Weg 1: Push aufs Handy. */ + try { + const r = await meldeAllen({ + titel: "Neue Anfrage", + text: `${nummer} (${paket})`, + url: "/webdesign/verwaltung.html?bereich=anfragen", + art: "anfrage", + }); + wege.push(r.gesendet ? `push: ${r.gesendet} Gerät(e)` : `push: nichts (${r.grund || "keins erreichbar"})`); + } catch (e) { + wege.push(`push: FEHLER ${e?.message || e}`); + } + + /* Weg 2: Discord, falls eingerichtet. */ const url = process.env.DISCORD_WEBHOOK_WEBDESIGN; - if (!url) return; - await fetch(url, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - content: `🖥️ Neue Webdesign-Anfrage **${nummer}** (${paket}). Details in der Verwaltung.`, - }), - }); + if (!url) { + wege.push("discord: nicht eingerichtet"); + } else { + try { + const a = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + content: `🖥️ Neue Webdesign-Anfrage **${nummer}** (${paket}). Details in der Verwaltung.`, + }), + }); + wege.push(a.ok ? "discord: ok" : `discord: HTTP ${a.status}`); + } catch (e) { + wege.push(`discord: FEHLER ${e?.message || e}`); + } + } + + console.log(`[webdesign] Anfrage ${nummer} — Benachrichtigung: ${wege.join(", ")}`); } /* --------------------------------------------------------------------- diff --git a/server-internal/routes/webdesign-push.js b/server-internal/routes/webdesign-push.js new file mode 100644 index 00000000..86b6151b --- /dev/null +++ b/server-internal/routes/webdesign-push.js @@ -0,0 +1,120 @@ +/* ===================================================================== + webdesign-push.js — Geraete an- und abmelden, Stand anzeigen, testen + + DER TESTKNOPF IST HIER DER WICHTIGSTE ENDPUNKT + + Eine Benachrichtigung hat eine unangenehme Eigenschaft: Solange nichts + passiert, sieht "funktioniert" und "kaputt" voellig gleich aus. Genau + daran ist der bisherige Discord-Weg gescheitert -- er war eingebaut, + aber nie eingerichtet, und niemand konnte das sehen. + + Deshalb gibt es hier zwei Dinge, die man sonst gern weglaesst: eine + Anzeige, die die eigentliche Frage beantwortet ("wuerde ich es + mitbekommen, wenn jetzt eine Anfrage kaeme?"), und einen Knopf, der es + ausprobiert, statt es zu behaupten. + ===================================================================== */ + +import { json } from "../lib/http.js"; +import { requireSession } from "../lib/auth.js"; +import { hasPermission } from "../lib/permissions.js"; +import { ausweisPruefen } from "../lib/webdesign-ausweis.js"; +import { text } from "../lib/webdesign-helfer.js"; +import { + geraetAnmelden, geraetAbmelden, geraeteListe, meldeAllen, stand, istBereit, + oeffentlicherSchluessel, +} from "../lib/webdesign-push.js"; + +/* Gleiche Struktur wie in den uebrigen Webdesign-Routen. */ +async function darf(req, recht) { + const ausweis = ausweisPruefen(req); + if (ausweis) return { erlaubt: true, sitzung: { actor: ausweis.rolle, isOwner: true } }; + const sitzung = await requireSession(req); + if (!sitzung) return { erlaubt: false, grund: 401, sitzung: null }; + if (sitzung.isOwner) return { erlaubt: true, sitzung }; + const ok = hasPermission(sitzung.permissions, sitzung.overrides, recht); + return { erlaubt: ok, grund: ok ? null : 403, sitzung }; +} + +/* Wer Anfragen sehen darf, darf auch ueber neue benachrichtigt werden. + Ein eigenes Recht dafuer waere eine Huerde ohne Gewinn: Die Meldung + enthaelt weniger als die Liste, die derselbe Mensch ohnehin oeffnen + darf. */ +const RECHT = "WD_ANFRAGEN_VIEW"; + +export async function pushStand(req, res) { + const p = await darf(req, RECHT); + if (!p.erlaubt) return json(res, { ok: false, error: "Kein Zugriff." }, p.grund); + + return json(res, { + ok: true, + ...stand(), + geraete: geraeteListe(null), + }); +} + +/* Den oeffentlichen Schluessel braucht der Browser, BEVOR er sich + anmelden kann. Er ist kein Geheimnis -- er steht per Definition in + jedem Browser, der sich angemeldet hat. */ +export async function pushSchluessel(req, res) { + const p = await darf(req, RECHT); + if (!p.erlaubt) return json(res, { ok: false, error: "Kein Zugriff." }, p.grund); + + if (!istBereit()) { + return json(res, { + ok: false, + error: "Push ist auf dem Server nicht eingerichtet.", + }, 503); + } + return json(res, { ok: true, schluessel: oeffentlicherSchluessel() }); +} + +export async function pushAnmelden(req, res) { + const p = await darf(req, RECHT); + if (!p.erlaubt) return json(res, { ok: false, error: "Kein Zugriff." }, p.grund); + + try { + const abo = req.body && req.body.abo; + geraetAnmelden( + abo, + p.sitzung?.userId || p.sitzung?.actor || null, + text(req.body?.bezeichnung, 80) + ); + /* Sofort eine Meldung schicken. Nicht als Spielerei: Wer den Knopf + drueckt, will WISSEN, ob es geht -- und der einzige ehrliche + Beweis ist eine Meldung, die tatsaechlich auf dem Bildschirm + erscheint. */ + const r = await meldeAllen({ + titel: "Benachrichtigungen sind an", + text: "Ab jetzt meldet sich diese Seite bei neuen Anfragen.", + url: "/webdesign/verwaltung.html", + art: "test", + }); + return json(res, { ok: true, ...stand(), probe: r }); + } catch (e) { + return json(res, { ok: false, error: e.message || "Anmeldung fehlgeschlagen." }, 400); + } +} + +export async function pushAbmelden(req, res) { + const p = await darf(req, RECHT); + if (!p.erlaubt) return json(res, { ok: false, error: "Kein Zugriff." }, p.grund); + + const endpunkt = text(req.body?.endpunkt, 500); + if (!endpunkt) return json(res, { ok: false, error: "Keine Adresse angegeben." }, 400); + + const r = geraetAbmelden(endpunkt); + return json(res, { ok: true, ...r, ...stand() }); +} + +export async function pushTest(req, res) { + const p = await darf(req, RECHT); + if (!p.erlaubt) return json(res, { ok: false, error: "Kein Zugriff." }, p.grund); + + const r = await meldeAllen({ + titel: "Testmeldung", + text: "Wenn du das liest, kommen Meldungen an.", + url: "/webdesign/verwaltung.html", + art: "test", + }); + return json(res, { ok: true, ergebnis: r, ...stand() }); +} diff --git a/server-internal/test-push-kette.mjs b/server-internal/test-push-kette.mjs new file mode 100644 index 00000000..6e9f0573 --- /dev/null +++ b/server-internal/test-push-kette.mjs @@ -0,0 +1,187 @@ +/* ===================================================================== + 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); diff --git a/server-internal/test-webpush.mjs b/server-internal/test-webpush.mjs new file mode 100644 index 00000000..1346c68c --- /dev/null +++ b/server-internal/test-webpush.mjs @@ -0,0 +1,176 @@ +/* ===================================================================== + 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); diff --git a/webdesign/sw.js b/webdesign/sw.js index 9344ff5c..bb8e5abb 100644 --- a/webdesign/sw.js +++ b/webdesign/sw.js @@ -34,7 +34,7 @@ Start alle alten Zwischenspeicher weg. Muss bei jeder Änderung an den Dateien unten hochgezählt werden, sonst hängen Nutzer auf einem alten Stand fest. */ -const CACHE_NAME = "dogfather-webdesign-v51"; +const CACHE_NAME = "dogfather-webdesign-v52"; /* Bausteine, die die Oberfläche zum Anzeigen braucht. Bewusst KEINE HTML-Datei in dieser Liste. */ @@ -189,3 +189,75 @@ self.addEventListener("fetch", (ereignis) => { /* Alles Übrige: kein respondWith -> der Browser macht es ganz normal selbst. Das ist die sicherste Voreinstellung. */ }); + +/* ===================================================================== + PUSH-MELDUNGEN (26.08.2026) + + Ab hier geht es nicht mehr um Zwischenspeicher, sondern um + Benachrichtigungen. Beides liegt im selben Service Worker, weil der + Browser nur einen je Geltungsbereich zulaesst. + + WARUM IMMER ETWAS ANGEZEIGT WIRD + + Browser verlangen: Wer die Erlaubnis fuer Benachrichtigungen hat und + eine Push-Nachricht empfaengt, MUSS etwas sichtbar machen. Wer still + empfaengt, verliert die Erlaubnis wieder -- zu Recht, denn sonst + waere Push ein bequemer Weg, ein Geraet unbemerkt anzufunken. + + Deshalb gibt es unten auch fuer den Fall einer unlesbaren Nachricht + eine Ersatzmeldung, statt einfach nichts zu tun. + ===================================================================== */ + +self.addEventListener("push", (ereignis) => { + let daten = {}; + try { + daten = ereignis.data ? ereignis.data.json() : {}; + } catch (e) { + /* Kam etwas an, das kein JSON ist, wird trotzdem gemeldet -- siehe + oben. Lieber eine unspezifische Meldung als eine verlorene + Erlaubnis. */ + daten = { titel: "Neue Meldung", text: "In der Verwaltung nachsehen." }; + } + + const titel = daten.titel || "Dogfather Webdesign"; + const optionen = { + body: daten.text || "", + icon: "/assets/img/wd-app-192.png", + badge: "/assets/img/wd-app-192.png", + /* Gleiche Kennung = neue Meldung ERSETZT die alte. Kommen fuenf + Anfragen hintereinander, soll nicht fuenfmal dasselbe untereinander + stehen. */ + tag: daten.art || "allgemein", + renotify: false, + /* Bewusst NICHT requireInteraction: Eine Meldung, die stehen bleibt, + bis man sie wegklickt, ist auf dem Sperrbildschirm eine Zumutung. */ + data: { url: daten.url || "/webdesign/verwaltung.html" }, + /* Sanft. Die Seite soll augenschonend sein, und das gilt auch fuer + das, was sie nachts auf einem Sperrbildschirm veranstaltet. */ + silent: false, + vibrate: [80, 40, 80], + }; + + ereignis.waitUntil(self.registration.showNotification(titel, optionen)); +}); + +self.addEventListener("notificationclick", (ereignis) => { + ereignis.notification.close(); + const ziel = (ereignis.notification.data && ereignis.notification.data.url) + || "/webdesign/verwaltung.html"; + + /* Ist die Verwaltung schon offen, wird dieses Fenster nach vorn geholt + statt ein zweites zu oeffnen. Sonst sammeln sich mit jeder Meldung + weitere Fenster derselben Seite an. */ + ereignis.waitUntil( + self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((fenster) => { + for (const f of fenster) { + if (f.url.includes("/webdesign/") && "focus" in f) { + if ("navigate" in f && !f.url.endsWith(ziel)) f.navigate(ziel).catch(() => {}); + return f.focus(); + } + } + return self.clients.openWindow(ziel); + }) + ); +}); diff --git a/webdesign/verwaltung.html b/webdesign/verwaltung.html index 357c04cd..52c5195d 100644 --- a/webdesign/verwaltung.html +++ b/webdesign/verwaltung.html @@ -2114,6 +2114,16 @@ erst nach dem Scrollen, und der Sinn ist dahin. --> + + +

Was auf dich wartet

@@ -2606,6 +2616,234 @@ var uebersichtGeladen = 0; + /* ===================================================================== + BENACHRICHTIGUNG AUFS HANDY (26.08.2026) + + Die Karte beantwortet EINE Frage: Würde ich es mitbekommen, wenn + jetzt eine Anfrage käme? Alles andere ist Beiwerk. + + Vier Zustände, und jeder bekommt einen anderen Text — denn was zu + tun ist, unterscheidet sich völlig: + + 1. Der Browser kann kein Push (z.B. iPhone-Safari im Reiter, + ohne dass die Seite auf dem Home-Bildschirm liegt). + 2. Push ist möglich, aber noch nicht eingeschaltet. + 3. Eingeschaltet und es kommt an. + 4. Der Nutzer hat es im Browser dauerhaft verboten. Dann hilft + kein Knopf mehr — das lässt sich nur in den + Browsereinstellungen zurücknehmen, und genau das muss dort + stehen, statt einen Knopf anzubieten, der nichts bewirkt. + ===================================================================== */ + var pushKarteBeschaeftigt = 0; + + function pushMoeglich() { + return ("serviceWorker" in navigator) && ("PushManager" in window) + && ("Notification" in window); + } + + async function ladePushKarte() { + var kasten = document.getElementById("vw-push-karte"); + if (!kasten) return; + + var d; + try { + d = await api("/webdesign/admin/push/stand"); + } catch (fehler) { + /* Der Server ist älter als diese Seite — kein Grund, die + Übersicht mit einer Fehlermeldung zu belasten. */ + kasten.hidden = true; + return; + } + if (!d || !d.ok) { kasten.hidden = true; return; } + + /* Ist DIESER Browser angemeldet? Das ist nicht dasselbe wie "es gibt + angemeldete Geräte": Am Rechner eingeschaltet zu haben, sagt nichts + darüber, ob das Handy meldet. */ + var diesesGeraet = 0; + if (pushMoeglich()) { + try { + var reg = await navigator.serviceWorker.getRegistration("/webdesign/"); + var abo = reg && await reg.pushManager.getSubscription(); + diesesGeraet = !!abo; + } catch (e) { diesesGeraet = 0; } + } + + zeichnePushKarte(d, diesesGeraet); + } + + function zeichnePushKarte(d, diesesGeraet) { + var kasten = document.getElementById("vw-push-karte"); + var erlaubnis = ("Notification" in window) ? Notification.permission : "unsupported"; + var h = ""; + + if (!pushMoeglich()) { + h = '

Dieser Browser kann keine Benachrichtigungen empfangen. ' + + 'Auf dem iPhone geht es erst, wenn die Seite über „Teilen → Zum Home-Bildschirm“ ' + + 'installiert und von dort geöffnet wird.

'; + kasten.innerHTML = kachel("Benachrichtigung", h, "leise"); + kasten.hidden = false; + return; + } + + if (erlaubnis === "denied") { + h = '

Benachrichtigungen sind für diese Seite im Browser blockiert. ' + + 'Ein Knopf hilft hier nicht weiter — das lässt sich nur in den Browsereinstellungen ' + + 'zurücknehmen (Schloss-Symbol neben der Adresse → Benachrichtigungen → Zulassen).

'; + kasten.innerHTML = kachel("Benachrichtigung blockiert", h, "warn"); + kasten.hidden = false; + return; + } + + if (diesesGeraet && d.wirktMeldung) { + h = '

Dieses Gerät bekommt eine Meldung, sobald eine neue Anfrage eingeht.' + + (d.geraete > 1 ? ' Insgesamt sind ' + s(String(d.geraete)) + ' Geräte angemeldet.' : '') + + '

' + + (d.geraeteMitFehlern + ? '

' + s(String(d.geraeteMitFehlern)) + + ' Gerät(e) haben zuletzt nicht geantwortet.

' + : '') + + '
' + + '' + + '' + + '
'; + kasten.innerHTML = kachel("Benachrichtigung ist an", h, "gut"); + } else { + h = '

Neue Anfragen fallen im Moment nur auf, wenn du hier nachsiehst. ' + + 'Mit einer Meldung aufs Handy bekommst du es sofort mit — im Text stehen nur ' + + 'Nummer und Paket, keine Kundendaten.

' + + '
' + + '' + + '
'; + kasten.innerHTML = kachel("Benachrichtigung einschalten", h, "warn"); + } + + kasten.hidden = false; + verdrahtePushKnoepfe(); + } + + function kachel(titel, inhalt, art) { + var farbe = art === "gut" ? "var(--wd-aqua)" + : art === "warn" ? "var(--wd-lila-hell)" : "var(--wd-rand)"; + return '
' + + '

' + s(titel) + '

' + inhalt + '
'; + } + + function verdrahtePushKnoepfe() { + var an = document.getElementById("vw-push-an"); + if (an) an.addEventListener("click", pushEinschalten); + var aus = document.getElementById("vw-push-aus"); + if (aus) aus.addEventListener("click", pushAusschalten); + var test = document.getElementById("vw-push-test"); + if (test) test.addEventListener("click", pushProbe); + } + + /* Der Base64-Schlüssel muss als Byte-Feld übergeben werden. Ein + Zeichenkettenwert wird ohne Fehlermeldung abgelehnt — der Aufruf + schlägt einfach fehl und nennt keinen Grund. */ + function schluesselZuBytes(text) { + var voll = (text + "=".repeat((4 - text.length % 4) % 4)) + .replace(/-/g, "+").replace(/_/g, "/"); + var roh = atob(voll); + var feld = new Uint8Array(roh.length); + for (var i = 0; i < roh.length; i++) feld[i] = roh.charCodeAt(i); + return feld; + } + + async function pushEinschalten() { + if (pushKarteBeschaeftigt) return; + pushKarteBeschaeftigt = 1; + var knopf = document.getElementById("vw-push-an"); + if (knopf) { knopf.disabled = true; knopf.textContent = "Einen Moment…"; } + + try { + var erlaubnis = await Notification.requestPermission(); + if (erlaubnis !== "granted") { await ladePushKarte(); return; } + + var k = await api("/webdesign/admin/push/schluessel"); + if (!k || !k.ok) throw new Error(k && k.error ? k.error : "Kein Schlüssel vom Server."); + + var reg = await navigator.serviceWorker.register("/webdesign/sw.js", { scope: "/webdesign/" }); + await navigator.serviceWorker.ready; + + var abo = await reg.pushManager.subscribe({ + /* Ohne diese Angabe verweigern alle Browser die Anmeldung: Sie + verlangen die Zusage, dass jede Nachricht auch sichtbar wird. */ + userVisibleOnly: true, + applicationServerKey: schluesselZuBytes(k.schluessel), + }); + + await api("/webdesign/admin/push/anmelden", { + method: "POST", + body: { abo: abo.toJSON(), bezeichnung: geraeteName() }, + }); + } catch (fehler) { + var kasten = document.getElementById("vw-push-karte"); + if (kasten) { + kasten.innerHTML = kachel("Einschalten hat nicht geklappt", + '

' + s((fehler && fehler.message) || "Unbekannter Fehler") + '

' + + '
' + + '' + + '
', "warn"); + verdrahtePushKnoepfe(); + } + return; + } finally { + pushKarteBeschaeftigt = 0; + } + await ladePushKarte(); + } + + async function pushAusschalten() { + if (pushKarteBeschaeftigt) return; + pushKarteBeschaeftigt = 1; + try { + var reg = await navigator.serviceWorker.getRegistration("/webdesign/"); + var abo = reg && await reg.pushManager.getSubscription(); + if (abo) { + /* Erst dem Server sagen, dann im Browser abmelden. Andersherum + wäre die Adresse schon weg, wenn der Server sie noch bräuchte + — und der Eintrag bliebe als Karteileiche zurück. */ + await api("/webdesign/admin/push/abmelden", { + method: "POST", body: { endpunkt: abo.endpoint }, + }).catch(function () {}); + await abo.unsubscribe(); + } + } catch (e) { /* nicht schlimm — die Anzeige unten korrigiert sich */ } + pushKarteBeschaeftigt = 0; + await ladePushKarte(); + } + + async function pushProbe() { + var knopf = document.getElementById("vw-push-test"); + if (knopf) { knopf.disabled = true; knopf.textContent = "Wird geschickt…"; } + try { + var r = await api("/webdesign/admin/push/test", { method: "POST", body: {} }); + if (knopf) { + var e = r && r.ergebnis; + knopf.textContent = (e && e.gesendet) + ? "An " + e.gesendet + " Gerät(e) geschickt" + : "Nichts erreicht"; + } + } catch (fehler) { + if (knopf) knopf.textContent = "Hat nicht geklappt"; + } + setTimeout(function () { + if (knopf) { knopf.disabled = false; knopf.textContent = "Probemeldung schicken"; } + }, 3500); + } + + /* Etwas Wiedererkennbares für die Geräteliste. Ohne das steht dort nur + eine 200 Zeichen lange Adresse. */ + function geraeteName() { + var u = navigator.userAgent; + var browser = /Edg\//.test(u) ? "Edge" : /Chrome\//.test(u) ? "Chrome" + : /Firefox\//.test(u) ? "Firefox" : /Safari\//.test(u) ? "Safari" : "Browser"; + var system = /Android/.test(u) ? "Android" : /iPhone|iPad/.test(u) ? "iPhone/iPad" + : /Windows/.test(u) ? "Windows" : /Mac OS/.test(u) ? "Mac" : ""; + return browser + (system ? " auf " + system : ""); + } + async function ladeUebersicht() { var laedt = document.getElementById("vw-ub-laedt"); var inhalt = document.getElementById("vw-ub-inhalt"); @@ -2649,6 +2887,12 @@ laedt.hidden = true; inhalt.hidden = false; + /* Bewusst OHNE await: Die Übersicht soll nicht darauf warten. Die + Abfrage fragt den Browser nach dem Anmeldestand, und das kann auf + einem gerade erst gestarteten Service Worker einen Moment dauern. + Die Karte erscheint dann eben eine Sekunde später. */ + ladePushKarte(); + var m = d.beiMir, k = d.beiKunde, g = d.geld; document.getElementById("vw-ub-beimir").innerHTML =