Files
dogfather-universe/server/workspace-push-krypto.js
T
DogFatherGitandClaude Opus 5 627f710d71 Aufgaben abbrechen, Teilnehmerwahl fuer alle, Benachrichtigungen
Zwei Wuensche vom 05.09.2026, dazu drei Fehler, die dabei ans Licht kamen.

ABBRECHEN (Wunsch: "in jedem status die aufgaben auch abbrechen koennen,
nur ich die manager und scouts")

Neuer Status mit Pflicht-Grund, aus jedem der vier Status heraus.
Festgehalten wird auch, WO die Aufgabe stand -- "im Review abgebrochen"
ist eine andere Aussage als "nie angefangen", und das Wiederaufnehmen
geht dorthin zurueck statt nach "offen".

Eigener Weg statt "abgebrochen" in der Statusliste: Dort entscheidet
darfAendern(), und das laesst auch den zustaendigen Creator aendern.
Der gewoehnliche PATCH kann diesen Zustand deshalb gar nicht erreichen
-- auch nicht fuer DogFather, sonst waere die Grund-Pflicht umgehbar.

Abgebrochenes steht in einem zugeklappten Bereich unter dem Brett, nicht
als fuenfte Spalte: Am Handy waeren dann alle fuenf unlesbar schmal.
Verschwinden darf es nicht, sonst waere der Abbruch ein Loeschen mit
Zwischenschritt.

Die Tabelle musste dafuer getauscht werden (SQLite kann CHECK nicht
aendern). Vorher auf einer Kopie durchgespielt: 40 von 40 Aufgaben,
Inhalte, Verweise und Indizes geprueft, Gegenprobe zeigt, dass der CHECK
noch lebt.

TEILNEHMERWAHL (Wunsch: "das soll viel besser aussehen und fuer jeden
verfuegbar sein")

Auf dem Bildschirm klebten die Namen aneinander: "DogfatherDogFather".
Ursache war, dass kalender.html das Stylesheet mit diesen Klassen nie
eingebunden hat -- sie standen in dateien.css. Vierzig gruene Pruefungen
zur Teilnehmerwahl hatten das nicht gemerkt, weil keine je gefragt hat,
ob es AUSSIEHT wie gedacht.

Jetzt eigene Klassen im eigenen Stylesheet, nach Rollen gruppiert: Die
Rolle steht einmal als Ueberschrift statt neunmal am Namen. Damit ist
das Kleben an der Wurzel weg, nicht zugepflastert.

"Fuer jeden" war mehr als ein hidden zu entfernen: darfEintragen() haette
einem Creator nur sich selbst erlaubt. Er haette seinen Scout gesehen,
angeklickt, und der Server haette ihn still weggelassen -- ein Knopf, der
nichts tut. einladbareIds() schaut jetzt in beide Richtungen, bewusst
getrennt von /api/personen: Wer die erweitert, gibt einem Creator
nebenbei die Moeglichkeit, seinem Scout Aufgaben zuzuweisen.

BENACHRICHTIGUNGEN (Wunsch: "sowas, und dass es perfekt funktioniert
fuer jeden")

Web Push nach RFC 8291/8292, ohne fremde Abhaengigkeit. Der Knopf sagt
in jeder Lage die Wahrheit, auch die unbequemen: abgelehnt (mit dem
Hinweis, wo man es zuruecknimmt), iPhone im Reiter (mit Anleitung),
Browser ohne Push. Ein Knopf, der bei abgelehnter Berechtigung nur
nichts tut, ist der sichere Weg zu "das funktioniert nicht".

DREI FEHLER, DIE DABEI AUFFIELEN

1. Ein defekter Zugangsdatensatz sperrte ALLE einer Rolle aus. Wirft
   hashe() bei einer Person, flog die ganze Anmeldung in den catch: 503
   "nicht verfuegbar" fuer jeden mit dieser Rolle. Aufgefallen durch
   einen eigenen Testfehler. Jetzt wird die defekte Person uebersprungen
   und laut protokolliert; die Gegenprobe zeigt, dass ein falscher Code
   weiterhin abgelehnt wird.

2. Die Glocke sprengte die Kopfleiste -- zweimal. Bei 320 px lag die
   Lupe des Suchknopfes auf dem Sicht-Umschalter (ein Knopf, der auf 12
   Seiten ins Leere tippt), bei 768 px wurde der Abmelden-Knopf bis zu
   15 px aus dem Bild geschoben, weil die Textgrenze auf 760 stand und
   ein Tablet 768 hat. Nachgewiesen durch Messen mit und ohne Glocke,
   nicht durch Vermuten.

3. .block__frage war viermal gestaltet und stand auf einer Seite, die
   keine dieser Dateien laedt -- derselbe Fehler wie bei der
   Teilnehmerwahl. Gefunden von der neuen Klassenpruefung beim ersten
   Lauf.

NEUE PRUEFUNGEN

pruef-css-klassen   jede gestaltete Klasse muss auf ihrer Seite ankommen
                    (unterscheidet Struktur-Anker von echtem Verlust)
pruef-dabei-optik   die Wahl im Browser, an den echten Pixeln
pruef-abbrechen     Umstellung auf einer Kopie, Rechte, Rueckweg
pruef-abbrechen-optik   Knopf, Dialog, Bereich, Handy
pruef-glocke        Zustaende, An/Abmelden, jede Rolle
pruef-push(-weg)    Rechnung gegen die RFC-Vektoren, Zustellung

pruef-struktur prueft jetzt zusaetzlich, ob sich jedes Server-Modul als
ESM laden laesst. node --check auf einer .js-Datei prueft als CommonJS
und meldete "ok", waehrend der Import scheiterte.

Gesamtlauf: 55 von 55 Dateien, 1973 von 1973 Punkten.

Was NICHT geprueft werden konnte und deshalb dasteht: Der Schritt
"Browser holt eine Adresse beim Push-Dienst" braucht eine Verbindung zu
Googles FCM, die ein Pruef-Browser nicht hat. Die Pruefung misst das
zuerst und meldet es als dritten Ausgang, statt gruen zu sein.
Verschluesselung und Zustellung sind getrennt geprueft; diese eine
Strecke beweist sich erst auf dem Server.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-05 18:05:28 +02:00

234 lines
9.8 KiB
JavaScript

/* =====================================================================
workspace-push-krypto.js — Web Push, verschlüsselt und signiert.
Der reine Rechenteil: Er nimmt eine Anmeldung (Endpunkt + zwei
Schlüssel des Browsers) und einen Text, und macht daraus das, was
der Push-Dienst von Google/Mozilla/Apple entgegennimmt.
---------------------------------------------------------------------
WARUM SELBST UND NICHT `web-push`
Der Perfektionsplan nannte die Bibliothek `web-push`. Beim Nachsehen
sprachen zwei Dinge dagegen:
LIZENZ web-push steht unter MPL-2.0. Die Hausregel lautet
"bevorzugt MIT oder Apache 2.0, die dem Nutzer selbst
gehört". MPL ist schwaches Copyleft -- benutzbar, aber
nicht das, was hier vorgegeben ist.
UMFANG Sie bringt fünf weitere Pakete mit (asn1.js, http_ece,
https-proxy-agent, jws, minimist), die ihrerseits welche
haben. Der Workspace läuft bisher mit DREI Abhängigkeiten
insgesamt; Anmeldung, Sitzungen und Datenbank kommen
vollständig aus Node selbst.
Node 24 bringt jede benötigte Rechenart mit -- nachgemessen:
createECDH, hkdfSync, createCipheriv (aes-128-gcm), createSign mit
ES256 und ieee-p1363-Kodierung. Damit ist der Eigenbau kein
Selbst-Erfinden von Kryptographie, sondern das Zusammensetzen
vorhandener, geprüfter Bausteine nach zwei RFCs.
---------------------------------------------------------------------
WAS HIER PASSIERT, IN ZWEI SCHRITTEN
1. VERSCHLÜSSELN (RFC 8291, "aes128gcm")
Der Server erzeugt für JEDE Nachricht ein neues Schlüsselpaar,
rechnet daraus mit dem öffentlichen Schlüssel des Browsers ein
gemeinsames Geheimnis (ECDH), leitet daraus über HKDF den
eigentlichen Schlüssel ab und verschlüsselt damit.
Der Push-Dienst selbst sieht dabei NUR Zeichensalat. Weder Google
noch Apple erfahren, was in der Benachrichtigung steht -- das ist
der Grund, warum dieser Weg überhaupt vertretbar ist.
2. SIGNIEREN (RFC 8292, "VAPID")
Ein kurzlebiges JWT, signiert mit dem privaten Serverschlüssel.
Es beweist dem Push-Dienst, dass die Nachricht wirklich von
dogfather-universe.com kommt.
---------------------------------------------------------------------
GEPRÜFT WIRD GEGEN DIE TESTVEKTOREN DER RFCs, nicht gegen das eigene
Gefühl. RFC 8291, Anhang A enthält vollständige Beispieldaten samt
erwartetem Ergebnis. Wenn diese Rechnung sie trifft, ist sie richtig
-- und wenn sich morgen jemand vertippt, fällt es sofort auf.
Siehe pruef-push.mjs.
===================================================================== */
import {
createECDH, hkdfSync, createCipheriv, createSign, randomBytes,
createPublicKey, createPrivateKey,
} from "node:crypto";
/* ---------- Kleinkram --------------------------------------------------- */
/** Base64 ohne Polster und mit URL-tauglichen Zeichen -- die einzige
* Schreibweise, die im Web-Push-Umfeld vorkommt. */
export const b64u = (buf) => Buffer.from(buf).toString("base64")
.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
export const ausB64u = (s) => Buffer.from(
String(s).replace(/-/g, "+").replace(/_/g, "/"), "base64");
/* ---------- 1. Verschlüsseln (RFC 8291) --------------------------------- */
/**
* Verschlüsselt einen Text für genau eine Anmeldung.
*
* @param {string} text was ankommen soll (JSON)
* @param {string} p256dhB64u öffentlicher Schlüssel des Browsers
* @param {string} authB64u das Zufallsgeheimnis des Browsers
* @param {Buffer} [salt] nur für die Prüfung gegen den Testvektor
* @param {object} [eigenes] dito -- sonst wird je Nachricht neu erzeugt
*/
export function verschluesseln(text, p256dhB64u, authB64u, salt = null, eigenes = null) {
const empfaenger = ausB64u(p256dhB64u); // 65 Byte, unkomprimiert
const auth = ausB64u(authB64u); // 16 Byte
if (empfaenger.length !== 65 || empfaenger[0] !== 4) {
throw new Error("Der Schluessel des Browsers hat nicht die erwartete Form.");
}
if (auth.length !== 16) {
throw new Error("Das Geheimnis des Browsers hat nicht die erwartete Laenge.");
}
/* Ein eigenes Paar JE NACHRICHT. Ein wiederverwendetes waere der
klassische Fehler an dieser Stelle: Aus zwei Nachrichten mit
demselben Schluessel und demselben Salz laesst sich der Klartext
angreifen. */
const ecdh = createECDH("prime256v1");
if (eigenes) ecdh.setPrivateKey(eigenes);
else ecdh.generateKeys();
const eigenerOeff = ecdh.getPublicKey(); // 65 Byte
const gemeinsam = ecdh.computeSecret(empfaenger);
const salz = salt || randomBytes(16);
/* Schritt 1: aus dem gemeinsamen Geheimnis und `auth` wird das
"pseudo random key". Der Info-Text ist im RFC woertlich
vorgeschrieben -- ein Tippfehler hier ergibt eine Nachricht, die
der Browser stumm verwirft. */
const prkInfo = Buffer.concat([
Buffer.from("WebPush: info\0"), empfaenger, eigenerOeff,
]);
const ikm = Buffer.from(hkdfSync("sha256", gemeinsam, auth, prkInfo, 32));
/* Schritt 2: daraus Schluessel (16 Byte) und Startwert (12 Byte). */
const cek = Buffer.from(hkdfSync("sha256", ikm, salz,
Buffer.from("Content-Encoding: aes128gcm\0"), 16));
const nonce = Buffer.from(hkdfSync("sha256", ikm, salz,
Buffer.from("Content-Encoding: nonce\0"), 12));
/* Das Polster: Der Klartext bekommt eine 0x02 angehaengt (letzter
Datensatz). Ohne dieses Byte verwirft der Browser die Nachricht --
ohne jede Meldung, und man sucht den Fehler im eigenen Code. */
const klartext = Buffer.concat([Buffer.from(text, "utf8"), Buffer.from([2])]);
const c = createCipheriv("aes-128-gcm", cek, nonce);
const geheim = Buffer.concat([c.update(klartext), c.final(), c.getAuthTag()]);
/* Der Kopf des Datensatzes: Salz, Datensatzgroesse, Laenge und
Inhalt des eigenen oeffentlichen Schluessels. */
const kopf = Buffer.alloc(21);
salz.copy(kopf, 0);
kopf.writeUInt32BE(4096, 16);
kopf.writeUInt8(eigenerOeff.length, 20);
return Buffer.concat([kopf, eigenerOeff, geheim]);
}
/* ---------- 2. Signieren (RFC 8292, VAPID) ------------------------------ */
/** Erzeugt ein neues VAPID-Schluesselpaar. Einmal je Server. */
export function schluesselErzeugen() {
const ecdh = createECDH("prime256v1");
ecdh.generateKeys();
return {
oeffentlich: b64u(ecdh.getPublicKey()),
privat: b64u(ecdh.getPrivateKey()),
};
}
/** Der private Schluessel als PKCS8, damit createSign ihn annimmt.
* Node kann aus rohen 32 Byte nichts machen -- der Umweg ueber DER
* ist der uebliche und braucht keine ASN.1-Bibliothek, weil das
* Geruest fuer P-256 immer gleich ist. */
function privatAlsSchluessel(privatRoh, oeffentlichRoh) {
const kopf = Buffer.from("308187020100301306072a8648ce3d020106082a8648ce3d030107046d306b0201010420", "hex");
const mitte = Buffer.from("a144034200", "hex");
const der = Buffer.concat([kopf, privatRoh, mitte, oeffentlichRoh]);
return createPrivateKey({ key: der, format: "der", type: "pkcs8" });
}
/**
* Baut die Kopfzeilen, die der Push-Dienst sehen will.
*
* @param {string} endpunkt die Adresse aus der Anmeldung
* @param {object} paar { oeffentlich, privat } als base64url
* @param {string} absender "mailto:..." -- wen der Dienst anschreibt,
* wenn etwas nicht stimmt
* @param {number} stunden wie lange das JWT gilt
*/
export function vapidKopf(endpunkt, paar, absender, stunden = 12) {
const ziel = new URL(endpunkt);
const jetzt = Math.floor(Date.now() / 1000);
const teil1 = b64u(JSON.stringify({ typ: "JWT", alg: "ES256" }));
const teil2 = b64u(JSON.stringify({
aud: `${ziel.protocol}//${ziel.host}`,
/* Nicht laenger als 24 Stunden -- das schreibt der RFC vor, und
Firefox lehnt laengere ab. */
exp: jetzt + Math.min(stunden, 23) * 3600,
sub: absender,
}));
const schluessel = privatAlsSchluessel(ausB64u(paar.privat), ausB64u(paar.oeffentlich));
/* `ieee-p1363` ist die rohe Form (r||s, 64 Byte). Die Voreinstellung
waere DER -- die nimmt kein Push-Dienst an. */
const sig = createSign("SHA256")
.update(`${teil1}.${teil2}`)
.sign({ key: schluessel, dsaEncoding: "ieee-p1363" });
return {
Authorization: `vapid t=${teil1}.${teil2}.${b64u(sig)}, k=${paar.oeffentlich}`,
"Content-Encoding": "aes128gcm",
"Content-Type": "application/octet-stream",
};
}
/* ---------- 3. Verschicken ---------------------------------------------- */
/**
* Schickt eine Nachricht an eine Anmeldung.
*
* Gibt IMMER ein Ergebnis zurueck statt zu werfen -- der Aufrufer
* verschickt an viele Menschen, und eine abgelaufene Anmeldung darf den
* ganzen Lauf nicht abbrechen.
*
* `weg: true` heisst: Diese Anmeldung ist tot (Browser deinstalliert,
* Berechtigung entzogen) und gehoert geloescht. Der Push-Dienst sagt
* das mit 404 oder 410.
*/
export async function schicken(anmeldung, text, paar, absender, { ttl = 3600 } = {}) {
try {
const koerper = verschluesseln(text, anmeldung.p256dh, anmeldung.auth);
const kopf = vapidKopf(anmeldung.endpunkt, paar, absender);
const a = await fetch(anmeldung.endpunkt, {
method: "POST",
headers: { ...kopf, TTL: String(ttl), Urgency: "normal" },
body: koerper,
/* Ein haengender Push-Dienst darf den Lauf nicht aufhalten. */
signal: AbortSignal.timeout(15_000),
});
if (a.status === 404 || a.status === 410) return { ok: false, weg: true, status: a.status };
if (!a.ok) {
const grund = await a.text().catch(() => "");
return { ok: false, weg: false, status: a.status, grund: grund.slice(0, 200) };
}
return { ok: true, status: a.status };
} catch (fehler) {
return { ok: false, weg: false, status: 0, grund: String(fehler?.message || fehler).slice(0, 200) };
}
}