Benachrichtigung bei neuer Anfrage: Push aufs Handy
BEFUND ZUERST, DENN ER WAR ANDERS ALS ERWARTET
Es gab sehr wohl eine Benachrichtigung -- ueber einen Discord-Webhook,
sauber gebaut, bewusst ohne Kundendaten im Text. Sie begann aber mit:
const url = process.env.DISCORD_WEBHOOK_WEBDESIGN;
if (!url) return;
Und diese Variable ist in .env.example nirgends aufgefuehrt. Eine
Variable, die niemand kennt, wird nicht gesetzt; dann kehrte die
Funktion wortlos zurueck. Kein Fehler, keine Protokollzeile. Es gab
eine Benachrichtigung, die nur im Quelltext existierte -- und niemand
konnte das bemerken, weil "funktioniert" und "kaputt" identisch
aussehen, solange nichts passiert.
WAS JETZT DA IST
Push aufs Handy, ohne Fremdpaket. Der interne Dienst liegt unter
/home/dogiintern mit Rechten 700 -- dort ist kein npm erreichbar, jede
Abhaengigkeit haette dauerhafte Handarbeit bedeutet. Node bringt P-256,
HKDF und AES-128-GCM selbst mit.
Die Schluessel erzeugt der Server beim ersten Start selbst und legt den
privaten Teil verschluesselt in der Datenbank ab (derselbe Weg wie die
PayPal-Zugangsdaten). Damit gibt es keinen Einrichtungsschritt, der
vergessen werden kann.
GEPRUEFT
Gegen RFC 8291 statt gegen ein Bauchgefuehl: alle fuenf Zwischenwerte
aus Anhang A stimmen (ECDH-Geheimnis, PRK_key, IKM, CEK, NONCE), und
die fertige Nachricht ist Byte fuer Byte die aus Abschnitt 5 der Norm.
Bei Kryptographie erzeugt ein Ableitungsfehler keinen Absturz, sondern
Bytes, die genauso zufaellig aussehen wie richtige.
Dazu ein Kettentest mit einem echten Empfaenger, der entschluesselt:
Kopfzeilen, Inhalt, keine Kundendaten in der Meldung, 410 loescht das
Geraet, 500 loescht es NICHT (sonst kostet eine einzelne Stoerung die
Anmeldung). 50 Pruefungen, alle gruen.
DREI ENTSCHEIDUNGEN
1. In der Meldung stehen nur Nummer und Paket. Sie erscheint auf einem
Sperrbildschirm, den auch jemand sieht, der zufaellig danebensteht.
2. Ist der Schluessel unlesbar, wird KEIN neuer erzeugt. Das waere der
bequeme Weg und der schlimmste: Ein neuer oeffentlicher Schluessel
macht schlagartig jede Anmeldung wertlos, ohne dass jemand erfaehrt,
warum nichts mehr ankommt.
3. Die Verwaltung zeigt den Zustand an und hat einen Testknopf. Genau
das fehlte dem Discord-Weg. Bei verweigerter Erlaubnis erscheint
kein Knopf, der nichts bewirkt, sondern der Weg ueber die
Browsereinstellungen.
Das stille "if (!url) return;" ist ersetzt: Jeder Weg wird einzeln
protokolliert -- auch und gerade, wenn er uebersprungen wird.
Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:[email protected]";
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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(", ")}`);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------
|
||||
|
||||
@@ -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() });
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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:[email protected]", 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);
|
||||
Reference in New Issue
Block a user