Ein Zugang, den ausser DogFather niemand bemerkt

Aus dem Anforderungsdokument (Master-Blueprint V3.0) fehlte die Rolle
"Modi" ganz -- es gab nur spicy, admin, manager, scout, creator. Filipes
Bedingung dazu: "dass die keine neue eingangs kachel bekommen wie spicy
dogfather und so sondern einfach einen code. damit die von der workspace
auch nicht mal sehen dass die modis von mir einen eigenen zugang haben."

DER EINGANG. Es gibt keine sechste Kachel und es laesst sich auch keine
erzwingen: Wer von aussen `rolle: "modi"` schickt, bekommt wortgleich
dieselbe Antwort wie bei einer erfundenen Rolle. Ein Modi tippt auf
irgendeine vorhandene Kachel -- welche, ist gleichgueltig -- und gibt
seinen Code ein. Der Code allein entscheidet.

Moeglich macht das eine neue Spalte `code_kennung`: ein HMAC ueber den
Code, in Mikrosekunden nachgeschlagen. Zwei naheliegende Wege wurden
verworfen, weil man sie finden kann: ein Merkmal im Code ("M-...") waere
ein sichtbares Kennzeichen auf dem Zettel des Modis, eine eigene Adresse
(/modi.html) eine Seite, die man aufrufen kann. Der Suchschluessel steht
VOR dem gewohnten Weg, nicht dahinter -- ein Rueckfall nach einem
Fehlversuch haette genau die Fehlversuche verlaengert, und daran waere
es zu erkennen gewesen.

Unbedenklich, weil nachgemessen: Ein Code hat 16 Zeichen aus einem
32er-Alphabet, also 80 Bit Zufall. Der Suchschluessel sagt ausserdem nur,
WEN man pruefen soll -- ob der Code stimmt, sagt weiterhin scrypt.

DIE UNSICHTBARKEIT sitzt in verborgeneIds(), also an derselben einen
Stelle wie die Regel fuer den zweiten Admin-Zugang, und nicht in den
rund 170 Abfragen, die Personen lesen. Sie haengt dabei an der ROLLE und
nicht an einer Nummer -- die Schwachstelle, die im Kommentar der alten
Regel offen dasteht (wird Zugang 1 geloescht, rueckt der naechste nach),
kann einer Rolle nicht passieren.

Nach Filipes Entscheidungen: Die Modis sehen sich untereinander (Kapitel
7.2 des Dokuments), VanVan sieht sie mit (Kapitel 3, sie traegt dieselbe
Rolle), Codes gibt Filipe selbst weiter -- kein Einladelink, der in einem
Verlauf landen kann.

ZWEIMAL WAERE DAS WORT "MODI" BEINAHE IN EINER DATEI GELANDET, DIE JEDER
BEKOMMT: in den Rollenlisten von start.js und personen.js. Ein Blick in
den Quelltext haette genuegt. Der Anzeigename kommt jetzt aus
/workspace/api/ich (beschreibt immer nur den Angemeldeten selbst), die
Rollenauswahl aus der Antwort des Servers und nur an die DogFather-Rolle.

GEPRUEFT mit pruef-modi-verborgen.mjs (45 Pruefungen): fuenf Kacheln
fuehren mit dem Modi-Code hinein, ein Manager-Code auf fremder Kachel
weiterhin nicht (sonst waere nebenbei die Rollenpruefung abgeschafft),
und ueber fuenf Schnittstellen sieht ausser DogFather, VanVan und den
Modis niemand etwas -- auch nicht die ZAHL daneben.

Die Gegenprobe steht bewusst ganz unten, weil sie eine Person absichtlich
aus der Regel aushaengt: Weiter oben haette sie jeden Abschnitt danach
verfaelscht. Beim ersten Anlauf stand sie in der Mitte, und prompt tauchte
die Person in einer spaeteren Managerliste auf.

Abschnitt 5 prueft den Weg durch die Anwendung selbst (DogFather legt an,
der Modi meldet sich an). Die Abschnitte davor tragen die Personen von
Hand ein und rechnen den Suchschluessel selbst aus -- damit waere NICHT
bewiesen, dass personAnlegen() ihn im Betrieb schreibt. Ohne ihn kaeme
kein einziger echter Modi herein, und oben waere trotzdem alles gruen.

Die Umstellung der Rollenliste in der Datenbank steht ab jetzt einmal in
rollenRegelUmstellen() statt zum dritten Mal abgeschrieben. Jede Abschrift
waere eine Gelegenheit, eine der vier Absicherungen zu vergessen: Sicherung
vorher, Zaehlung innerhalb der Transaktion, Spaltenliste aus der Tabelle,
Pruefung auf verwaiste Verweise danach.

pruef-personen-formular erwartet jetzt sechs Rollen statt fuenf und prueft
die Liste statt nur die Anzahl -- sechs Karten koennten auch fuenf richtige
und eine doppelte sein. Dass die sechste dort auftaucht, ist gleichzeitig
der Nachweis, dass der Weg ueber den Server funktioniert.

Unveraendert bestanden: pruef-spicy (60), pruef-rollen (97),
pruef-verborgen, pruef-personen-liste, pruef-css-klassen.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-09-10 00:11:39 +02:00
co-authored by Claude Opus 5
parent 88e2b5cd5d
commit 7be488e0ca
27 changed files with 1103 additions and 383 deletions
+383 -103
View File
@@ -21,7 +21,7 @@
import express from "express";
import {
randomBytes, scryptSync, timingSafeEqual, createHash,
randomBytes, scryptSync, timingSafeEqual, createHash, createHmac,
} from "node:crypto";
import { dirname, join } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
@@ -57,19 +57,34 @@ const COOKIE = "dfw_sitzung";
const SITZUNG_STUNDEN = 12;
const VERSUCHE_MAX = 8; // pro IP
const VERSUCHE_FENSTER_MIN = 10;
const ROLLEN = new Set(["spicy", "admin", "manager", "scout", "creator"]);
const ROLLEN = new Set(["spicy", "admin", "manager", "scout", "creator", "modi"]);
/* Die Reihenfolge, in der Rollen ueberall erscheinen: Spicy Media
zuerst, dann DogFather, Manager, Scout, Creator. Steht hier einmal,
damit keine Liste eine eigene Reihenfolge erfindet. */
export const ROLLEN_REIHE = ["spicy", "admin", "manager", "scout", "creator"];
export const ROLLEN_REIHE = ["spicy", "admin", "manager", "scout", "creator", "modi"];
/* DIE ROLLEN MIT EINER KACHEL AUF DER ANMELDESEITE.
'modi' steht hier NICHT drin, und das ist der Kern des verborgenen
Zugangs: Es gibt keine sechste Kachel, und es laesst sich auch keine
erzwingen. Wer von aussen `rolle: "modi"` schickt, bekommt genau
dieselbe Antwort wie bei einer erfundenen Rolle -- er erfaehrt also
nicht einmal, dass es sie gibt.
Zwei Listen statt einer, weil es zwei verschiedene Fragen sind:
ROLLEN sagt, welche Rollen es GIBT (die Datenbank laesst nur diese
zu). Diese hier sagt, mit welchen man sich ANMELDEN kann, indem man
sie anklickt. Waere es eine Liste, haette 'modi' entweder eine Kachel
-- oder es gaebe die Rolle gar nicht. */
const ROLLEN_KACHEL = new Set(["spicy", "admin", "manager", "scout", "creator"]);
/* Als SQL-Ausdruck fuer ORDER BY. "ORDER BY rolle" waere alphabetisch
(admin, creator, manager, scout, spicy) -- also fast genau falsch
herum. */
export const ROLLEN_SORTIERUNG =
"CASE rolle WHEN 'spicy' THEN 0 WHEN 'admin' THEN 1 WHEN 'manager' THEN 2"
+ " WHEN 'scout' THEN 3 ELSE 4 END";
+ " WHEN 'scout' THEN 3 WHEN 'creator' THEN 4 ELSE 5 END";
/* LEITUNG = DogFather und Manager. Ein Manager darf alles, was
DogFather darf -- mit genau zwei Ausnahmen, die in
@@ -168,6 +183,7 @@ export const ROLLEN_NAME = {
manager: "Manager",
scout: "Scout",
creator: "Creator",
modi: "Modi",
};
/* scrypt-Parameter. N=2^15 braucht auf diesem Server rund 150 ms — spürbar
@@ -194,6 +210,122 @@ let _dbFehler = null;
SQLite selbst und in sich konsistent, anders als ein Dateikopie
waehrend laufender Schreibvorgaenge).
===================================================================== */
/* =====================================================================
DIE ROLLENLISTE DER DATENBANK ERWEITERN (09.09.2026)
Welche Rollen es geben darf, steht als CHECK-Regel in der Tabelle
`personen`. SQLite kann eine CHECK-Regel nicht aendern -- der einzige
saubere Weg ist: neue Tabelle, Daten hinueber, alte weg, neue
umbenennen. Das ist der Moment, in dem eine Datenbank kaputtgehen
kann. Deshalb steht der Ablauf ab jetzt EINMAL hier.
WARUM ALS FUNKTION: Er stand vorher zweimal da -- einmal fuer
'manager', einmal fuer 'spicy'. Fuer 'modi' waere es die dritte
Abschrift geworden, und jede Abschrift ist eine Gelegenheit, eine der
vier Absicherungen zu vergessen: die Sicherung VORHER, die Zaehlung
INNERHALB der Transaktion, die Spaltenliste aus der Tabelle statt aus
dem Gedaechtnis, die Pruefung auf verwaiste Verweise DANACH. Der
manager-Block darueber bleibt unangetastet: Er baut die Tabelle mit
einer fest hingeschriebenen Spaltenliste auf und ist damit ein
anderer Fall -- ihn mit einzufangen waere ein zweiter Umbau an einer
Stelle, an der ein Fehler Daten kostet.
GEPRUEFT WIRD DIE REGEL, NICHT DER TEXT (07.09.2026, gefunden von
pruef-spicy): SQLite hebt den CREATE-Text woertlich auf, mitsamt
Kommentaren. Ein erklaerender Satz mit dem Wort 'spicy' genuegte, und
die Umstellung hielt die Tabelle fuer schon umgestellt, obwohl die
CHECK-Regel noch die alte war. Deshalb wird die Regel herausgeschnitten
und NUR darin gesucht.
@param marker Die Rolle, an der erkannt wird, ob schon umgestellt ist.
@param rollen Die vollstaendige neue Liste erlaubter Rollen.
===================================================================== */
function rollenRegelUmstellen(d, marker, rollen, jetztStempel) {
const rollenPlan = d.prepare(
"SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'personen'").get()?.sql || "";
const regel = rollenPlan.match(/rolle\s+IN\s*\(([^)]*)\)/i)?.[1] || "";
/* Keine Regel gefunden heisst: Es gibt nichts umzustellen. Nicht
etwa "dann bauen wir sie neu" -- eine Tabelle ohne CHECK ist ein
Zustand, den jemand ansehen sollte, kein Fall fuer Automatik. */
if (!rollenPlan || !regel || regel.includes(`'${marker}'`)) return;
const sicherung = `${DB_PFAD}.vor-${marker}-${jetztStempel}`;
try {
d.exec(`VACUUM INTO '${sicherung.replace(/'/g, "''")}'`);
console.log("[workspace] Sicherung vor der Umstellung:", sicherung);
} catch (fehler) {
/* Ohne Sicherung wird NICHT umgestellt. Lieber laeuft die neue Rolle
noch nicht, als dass Daten ohne Netz angefasst werden. */
console.error("[workspace] Sicherung fehlgeschlagen, Umstellung abgebrochen:", fehler?.message);
return;
}
/* Nur die Rollenliste ersetzen -- und pruefen, dass es geklappt hat.
`IF NOT EXISTS` MUSS MIT INS MUSTER (07.09.2026, gefunden von
pruef-spicy). Die Tabelle wurde mit `CREATE TABLE IF NOT EXISTS
personen` angelegt, also steht das auch in sqlite_master. Ohne diese
drei Woerter im Muster griff die Ersetzung nicht, der Bauplan blieb
unveraendert, die Schutzabfrage darunter schlug an -- und die
Umstellung waere auf dem echten Server NIE gelaufen. Sie haette es
gesagt (das ist der Wert der Abfrage), aber sie waere nie gelaufen. */
const neuerPlan = rollenPlan
.replace(/CREATE TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["'`]?personen["'`]?/i,
"CREATE TABLE personen_neu")
.replace(/rolle\s+IN\s*\([^)]*\)/i,
`rolle IN (${rollen.map((r) => `'${r}'`).join(",")})`);
if (!neuerPlan.includes(`'${marker}'`) || !neuerPlan.includes("personen_neu")) {
console.error("[workspace] Umstellung abgebrochen: Der Bauplan liess sich nicht "
+ "umschreiben. Steht die CHECK-Regel noch so da wie erwartet?");
return;
}
/* Die Spaltenliste kommt aus der Tabelle, nicht aus dem Gedaechtnis. */
const spalten = d.prepare("PRAGMA table_info(personen)").all().map((z) => z.name);
if (!spalten.length) {
console.error("[workspace] Umstellung abgebrochen: keine Spalten gefunden.");
return;
}
const liste = spalten.map((n) => `"${n}"`).join(", ");
/* Fremdschluessel muessen aus sein, weil andere Tabellen auf
personen(id) zeigen -- und das laesst sich nicht innerhalb einer
Transaktion umschalten. */
d.exec("PRAGMA foreign_keys = OFF");
try {
const vorher = d.prepare("SELECT COUNT(*) AS n FROM personen").get().n;
d.exec("BEGIN");
d.exec(neuerPlan);
d.exec(`INSERT INTO personen_neu (${liste}) SELECT ${liste} FROM personen;`);
const nachher = d.prepare("SELECT COUNT(*) AS n FROM personen_neu").get().n;
/* Die Zaehlung steht INNERHALB der Transaktion -- stimmt sie nicht,
wird zurueckgerollt und die alte Tabelle bleibt unberuehrt. */
if (nachher !== vorher) {
d.exec("ROLLBACK");
console.error(`[workspace] Umstellung abgebrochen: ${vorher} Personen vorher, `
+ `${nachher} nachher. Sicherung: ${sicherung}`);
} else {
d.exec("DROP TABLE personen;");
d.exec("ALTER TABLE personen_neu RENAME TO personen;");
d.exec("COMMIT");
const kaputt = d.prepare("PRAGMA foreign_key_check").all();
if (kaputt.length) {
console.error("[workspace] ACHTUNG: nach der Umstellung", kaputt.length,
"verwaiste Verweise. Sicherung liegt unter", sicherung);
} else {
console.log(`[workspace] Rolle '${marker}' freigeschaltet, ${nachher} Personen, `
+ `${spalten.length} Spalten uebernommen, Verweise geprueft.`);
}
}
} catch (fehler) {
try { d.exec("ROLLBACK"); } catch { /* schon zurueckgerollt */ }
console.error("[workspace] Umstellung fehlgeschlagen:", fehler?.message,
"-- Sicherung:", sicherung);
} finally {
d.exec("PRAGMA foreign_keys = ON");
}
}
function umstellungen(d) {
/* MIT SEKUNDEN, nicht nur mit Minuten (07.09.2026).
@@ -458,6 +590,30 @@ function umstellungen(d) {
naheliegende Abkuerzung gewesen -- und faellt in dem Moment um,
in dem jemand den Titel einer uebernommenen Aufgabe aendert. */
["aufgaben", "vorlage", "TEXT"],
/* DER SUCHSCHLUESSEL ZUM CODE (09.09.2026).
Bisher gab es nur den scrypt-Hash, und der ist mit Absicht
langsam (rund 150 ms). Wer eine Person AM CODE finden will, muss
deshalb alle Kandidaten der Reihe nach durchrechnen -- die
Anmeldung tut genau das, und im Code steht seit langem der
Hinweis, dass das ab etwa 50 Codes je Rolle spuerbar wird.
Diese Spalte ist die schnelle Antwort auf "zu welcher Person
gehoert dieser Code?": ein HMAC ueber den Code, in Mikrosekunden
berechnet und mit einem Index hinterlegt.
WARUM DAS UNBEDENKLICH IST -- nachgemessen, nicht angenommen: Ein
Code besteht aus 16 Zeichen eines 32er-Alphabets, das sind 80 Bit
Zufall. Selbst wenn jemand die ganze Datenbank haette, waere
Durchprobieren aussichtslos, egal wie schnell die Rechnung ist.
Der Schluessel steckt zusaetzlich nicht im Code, sondern in den
Einstellungen (siehe kennungSchluessel).
DER SCRYPT-HASH BLEIBT DIE ENTSCHEIDUNG. Der Suchschluessel sagt
nur, WEN man pruefen soll; ob der Code stimmt, sagt weiterhin
allein scrypt. Ein Treffer hier allein laesst niemanden herein. */
["personen", "code_kennung", "TEXT"],
]) {
try {
const vorhanden = d.prepare(`PRAGMA table_info(${tabelle})`).all().map((s) => s.name);
@@ -475,6 +631,16 @@ function umstellungen(d) {
die ganze Termintabelle. Er steht hier unten und nicht oben im
Bauplan, weil die beiden Spalten erst durch die Schleife darüber
entstehen -- oben gäbe es sie beim ersten Start noch nicht. */
try {
/* Ohne Index waere der Suchschluessel sinnlos -- SQLite laese die
ganze Personentabelle, und genau das sollte er ersparen. Steht
aus demselben Grund hier unten wie der Termin-Index: Die Spalte
entsteht erst durch die Schleife darueber. */
d.exec("CREATE INDEX IF NOT EXISTS idx_personen_kennung ON personen (code_kennung)");
} catch (fehler) {
console.error("[workspace] Index idx_personen_kennung:", fehler?.message);
}
try {
d.exec("CREATE INDEX IF NOT EXISTS idx_termine_serie ON termine (serie_id, serie_tag)");
} catch (fehler) {
@@ -980,91 +1146,14 @@ function umstellungen(d) {
}
}
const rollenPlan = d.prepare(
"SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'personen'").get()?.sql || "";
/* GEPRUEFT WIRD DIE REGEL, NICHT DER TEXT (07.09.2026).
Hier stand `rollenPlan.includes("'spicy'")`. Das ist eine Suche im
ganzen gespeicherten Bauplan -- und SQLite hebt den woertlich auf,
mitsamt allen Kommentaren darin. Ein erklaerender Satz mit dem Wort
'spicy' genuegte, und die Umstellung hielt die Tabelle fuer schon
umgestellt, obwohl die CHECK-Regel noch die alte war. Beim Bauen
genau so passiert.
Jetzt wird die Regel selbst herausgeschnitten und NUR darin gesucht.
Findet sich keine, gibt es nichts umzustellen. */
const regel = rollenPlan.match(/rolle\s+IN\s*\(([^)]*)\)/i)?.[1] || "";
if (rollenPlan && regel && !regel.includes("'spicy'")) {
const sicherung = `${DB_PFAD}.vor-spicy-${jetztStempel}`;
try {
d.exec(`VACUUM INTO '${sicherung.replace(/'/g, "''")}'`);
console.log("[workspace] Sicherung vor der Umstellung:", sicherung);
} catch (fehler) {
console.error("[workspace] Sicherung fehlgeschlagen, Umstellung abgebrochen:", fehler?.message);
return;
}
/* Nur die Rollenliste ersetzen -- und pruefen, dass es geklappt hat. */
const neuerPlan = rollenPlan
/* `IF NOT EXISTS` MUSS MIT INS MUSTER (07.09.2026, gefunden von
pruef-spicy). SQLite hebt den CREATE-Text WOERTLICH auf -- die
Tabelle wurde mit `CREATE TABLE IF NOT EXISTS personen` angelegt,
also steht das auch in sqlite_master. Ohne diese drei Woerter im
Muster griff die Ersetzung nicht, der Bauplan blieb unveraendert,
die Schutzabfrage darunter schlug an -- und die Umstellung waere
auf dem echten Server NIE gelaufen. Sie haette es gesagt (das ist
der Wert der Abfrage), aber sie waere nie gelaufen. */
.replace(/CREATE TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["'`]?personen["'`]?/i,
"CREATE TABLE personen_neu")
.replace(/rolle\s+IN\s*\([^)]*\)/i, "rolle IN ('spicy','admin','manager','scout','creator')");
if (!neuerPlan.includes("'spicy'") || !neuerPlan.includes("personen_neu")) {
console.error("[workspace] Umstellung abgebrochen: Der Bauplan liess sich nicht "
+ "umschreiben. Steht die CHECK-Regel noch so da wie erwartet?");
return;
}
/* Die Spaltenliste kommt aus der Tabelle, nicht aus dem Gedaechtnis. */
const spalten = d.prepare("PRAGMA table_info(personen)").all().map((z) => z.name);
if (!spalten.length) {
console.error("[workspace] Umstellung abgebrochen: keine Spalten gefunden.");
return;
}
const liste = spalten.map((n) => `"${n}"`).join(", ");
d.exec("PRAGMA foreign_keys = OFF");
try {
const vorher = d.prepare("SELECT COUNT(*) AS n FROM personen").get().n;
d.exec("BEGIN");
d.exec(neuerPlan);
d.exec(`INSERT INTO personen_neu (${liste}) SELECT ${liste} FROM personen;`);
const nachher = d.prepare("SELECT COUNT(*) AS n FROM personen_neu").get().n;
/* Die Zaehlung steht INNERHALB der Transaktion -- stimmt sie nicht,
wird zurueckgerollt und die alte Tabelle bleibt unberuehrt. */
if (nachher !== vorher) {
d.exec("ROLLBACK");
console.error(`[workspace] Umstellung abgebrochen: ${vorher} Personen vorher, `
+ `${nachher} nachher. Sicherung: ${sicherung}`);
} else {
d.exec("DROP TABLE personen;");
d.exec("ALTER TABLE personen_neu RENAME TO personen;");
d.exec("COMMIT");
const kaputt = d.prepare("PRAGMA foreign_key_check").all();
if (kaputt.length) {
console.error("[workspace] ACHTUNG: nach der Umstellung", kaputt.length,
"verwaiste Verweise. Sicherung liegt unter", sicherung);
} else {
console.log(`[workspace] Rolle 'spicy' freigeschaltet, ${nachher} Personen, `
+ `${spalten.length} Spalten uebernommen, Verweise geprueft.`);
}
}
} catch (fehler) {
try { d.exec("ROLLBACK"); } catch { /* schon zurueckgerollt */ }
console.error("[workspace] Umstellung fehlgeschlagen:", fehler?.message,
"-- Sicherung:", sicherung);
} finally {
d.exec("PRAGMA foreign_keys = ON");
}
}
/* Welche Rollen die Datenbank zulaesst. Der Ablauf steht in
rollenRegelUmstellen() -- einmal, nicht je Rolle abgeschrieben.
Beide Aufrufe stehen hier: Auf einer Datenbank, die 'spicy' schon
kennt, tut der erste nichts und nur der zweite laeuft. */
rollenRegelUmstellen(d, "spicy",
["spicy", "admin", "manager", "scout", "creator"], jetztStempel);
rollenRegelUmstellen(d, "modi",
["spicy", "admin", "manager", "scout", "creator", "modi"], jetztStempel);
}
export function db() {
@@ -1094,7 +1183,7 @@ export function db() {
CREATE TABLE IF NOT EXISTS personen (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
rolle TEXT NOT NULL CHECK (rolle IN ('spicy','admin','manager','scout','creator')),
rolle TEXT NOT NULL CHECK (rolle IN ('spicy','admin','manager','scout','creator','modi')),
code_hash TEXT NOT NULL,
code_salt TEXT NOT NULL,
code_n INTEGER NOT NULL,
@@ -1838,6 +1927,62 @@ function hashe(code, salt, N) {
{ N, r: SCRYPT.r, p: SCRYPT.p, maxmem: 256 * 1024 * 1024 }).toString("hex");
}
/* =====================================================================
DER SUCHSCHLUESSEL ZU EINEM CODE (09.09.2026)
Beantwortet in Mikrosekunden, WEN man pruefen soll -- nicht, ob der
Code stimmt. Das bleibt Sache von scrypt darunter.
WARUM EIN HMAC UND KEIN EINFACHER HASH: Ein einfacher Hash ueber den
Code waere ueberall gleich. Mit einem Schluessel, der nur in dieser
einen Datenbank steht, ist der Wert ausserhalb wertlos -- er laesst
sich nirgends nachschlagen und nirgends wiedererkennen.
WARUM DER SCHLUESSEL IN DIE DATENBANK GEHOERT UND NICHT IN DIE
UMGEBUNG: Eine neue Pflichtangabe in server/.env ist eine Angabe, die
beim naechsten Aufsetzen fehlen kann. Sie wuerde nicht laut scheitern,
sondern leise: Der stille Zugang fuende niemanden mehr, und die
Anmeldung saehe aus wie ein falscher Code. Aus der Datenbank kann sie
nicht verschwinden -- sie wird mitgesichert wie alles andere.
OHNE SCHLUESSEL GIBT ES KEINE ANTWORT, nicht etwa eine schlechtere:
`null` heisst "konnte nicht nachsehen", und die Anmeldung behandelt
das wie "nicht gefunden". Ein Rueckfall auf einen ungeschluesselten
Wert waere die stille Verschlechterung, die niemandem auffaellt. */
let _kennungSchluessel = null;
function kennungSchluessel() {
if (_kennungSchluessel) return _kennungSchluessel;
try {
const d = db();
const lies = () => d.prepare(
"SELECT wert FROM einstellungen WHERE schluessel = 'code_kennung_schluessel'").get()?.wert;
let wert = lies();
if (!wert) {
/* Bewusst NICHT ueber einstellungSetzen(): Das schriebe einen
Eintrag ins Protokoll, und ein Protokolleintrag ist genau die
Spur, die es hier nicht geben soll. */
d.prepare(`INSERT INTO einstellungen (schluessel, wert, geaendert, von)
VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING`)
.run("code_kennung_schluessel", randomBytes(32).toString("hex"),
new Date().toISOString());
/* Danach nochmal LESEN statt den erzeugten Wert zu benutzen:
Legen zwei Vorgaenge ihn im selben Augenblick an, gewinnt einer
-- und beide muessen mit demselben weiterrechnen. */
wert = lies();
}
_kennungSchluessel = wert || null;
return _kennungSchluessel;
} catch {
return null;
}
}
export function codeKennung(code) {
const schluessel = kennungSchluessel();
if (!schluessel || !code) return null;
return createHmac("sha256", schluessel).update(String(code)).digest("hex");
}
/* Vergleich in konstanter Zeit. Ein normaler ===-Vergleich bricht beim
ersten abweichenden Zeichen ab; aus den Laufzeitunterschieden lässt sich
ein Geheimnis Zeichen für Zeichen erraten. */
@@ -2075,6 +2220,33 @@ workspaceRouter.use((req, res, next) => {
return next();
});
/**
* Gehoert dieser Code zu einem verborgenen Zugang?
*
* Gibt die Person zurueck oder null. `null` heisst hier ausdruecklich
* "nein oder nicht feststellbar" -- ohne Suchschluessel (etwa weil die
* Einstellung fehlt) gibt codeKennung() null zurueck, und dann wird
* niemand angemeldet. Das ist die richtige Richtung: Im Zweifel kommt
* niemand herein, statt dass im Zweifel jemand hereinkommt.
*/
function stillerZugang(code) {
try {
const kennung = codeKennung(code);
if (!kennung) return null;
const k = db().prepare(
"SELECT id, name, rolle, code_hash, code_salt, code_n FROM personen "
+ "WHERE code_kennung = ? AND rolle = 'modi' AND aktiv = 1").get(kennung);
if (!k) return null;
/* Trotz Treffer wird geprueft. Der Suchschluessel ist ein
Wegweiser, kein Ausweis -- und ein Wegweiser, dem man blind
folgt, ist eine Hintertuer. */
return gleich(hashe(code, k.code_salt, k.code_n), k.code_hash) ? k : null;
} catch (fehler) {
console.error("[workspace] Stiller Zugang nicht pruefbar:", fehler?.message);
return null;
}
}
workspaceRouter.post("/workspace/api/anmelden", (req, res) => {
const ip = echteIp(req);
try {
@@ -2085,7 +2257,56 @@ workspaceRouter.post("/workspace/api/anmelden", (req, res) => {
const rolle = String(req.body?.rolle || "");
const code = String(req.body?.code || "");
if (!ROLLEN.has(rolle) || code.length < 4 || code.length > 200) {
const codeBrauchbar = code.length >= 4 && code.length <= 200;
/* =================================================================
DER STILLE ZUGANG (09.09.2026)
Wunsch Filipe: *"dass die keine neue eingangs kachel bekommen wie
spicy dogfather und so sondern einfach einen code. damit die von
der workspace auch nicht mal sehen dass die modis von mir einen
eigenen zugang haben."*
Ein Modi oeffnet dieselbe Seite, tippt auf IRGENDEINE vorhandene
Kachel und gibt seinen Code ein. Welche Kachel, ist gleichgueltig
-- der Code allein entscheidet. Von aussen sieht das aus wie jede
andere Anmeldung: dieselbe Seite, dieselbe Anfrage, dieselben
Felder.
ZWEI NAHELIEGENDE WEGE, DIE ABSICHTLICH NICHT GEWAEHLT WURDEN:
* Ein Merkmal im Code ("M-..."): waere ein sichtbares Kennzeichen
auf dem Zettel des Modis. Wer den Code sieht, sieht die Sorte.
* Eine eigene Adresse (/modi.html): waere eine Seite, die man
finden kann -- und die Seitenpruefung zaehlt Seiten.
WARUM DAS VOR DEM GEWOHNTEN WEG STEHT UND NICHT DAHINTER: Ein
Rueckfall NACH einem Fehlversuch haette den gewohnten Weg
verlaengert -- und zwar nur dann, wenn er scheitert. Genau daran
waere es zu erkennen gewesen: Fehlversuche dauern ploetzlich
laenger als frueher. Der Suchschluessel kostet Mikrosekunden und
trifft in aller Regel nichts; fuer alle anderen bleibt der Ablauf
unveraendert, auch in der Zeit.
DER SUCHSCHLUESSEL ALLEIN LAESST NIEMANDEN HEREIN. Er sagt nur,
WEN man pruefen soll -- danach entscheidet scrypt wie ueberall. */
const still = codeBrauchbar ? stillerZugang(code) : null;
if (still) {
db().prepare("DELETE FROM versuche WHERE ip = ?").run(ip);
db().prepare("UPDATE personen SET letzter_login = ? WHERE id = ?").run(jetzt(), still.id);
sitzungSetzen(res, still, req);
protokolliere("anmeldung", {
personId: still.id, rolle: still.rolle, ip, detail: still.name,
});
return res.json({
weiter: "/workspace/start.html", name: still.name, rolle: still.rolle,
});
}
/* Ab hier der gewohnte Weg -- unveraendert. 'modi' ist keine
Kachel-Rolle und faellt deshalb in dieselbe Antwort wie eine
erfundene. */
if (!ROLLEN_KACHEL.has(rolle) || !codeBrauchbar) {
db().prepare("INSERT INTO versuche (ip, zeitpunkt) VALUES (?,?)").run(ip, jetzt());
return res.status(401).json({ fehler: "ungueltig" });
}
@@ -2452,14 +2673,51 @@ function sichtbareCreatorIdsRoh(person) {
===================================================================== */
export function verborgeneIds(person) {
try {
const admins = db().prepare(
const d = db();
/* ERSTER TEIL: die verborgenen Admin-Zugaenge (Regel vom 09.09.2026).
Der Admin mit der kleinsten Nummer ist DogFather, jeder weitere
Admin-Zugang ist verborgen. */
const admins = d.prepare(
"SELECT id FROM personen WHERE rolle = 'admin' ORDER BY id").all().map((z) => z.id);
if (admins.length < 2) return [];
const versteckt = admins.slice(1);
if (!person) return versteckt;
if (person.id === admins[0]) return []; /* DogFather sieht alle */
if (versteckt.includes(person.id)) return []; /* sich selbst sieht man */
return versteckt;
const versteckteAdmins = admins.length >= 2 ? admins.slice(1) : [];
/* ZWEITER TEIL: das Modi-Team.
Hier haengt die Regel an der ROLLE und nicht an einer Nummer --
und das ist der Unterschied, auf den es ankommt. Beim ersten Teil
steht die Schwachstelle im Kommentar oben ausdruecklich da: Wuerde
Zugang 1 geloescht, rueckte der naechste nach und waere ploetzlich
sichtbar. Eine Rolle kann das nicht passieren. Wer einen Modi
verbergen will, muss ihm nichts anhaengen und nichts nachhalten --
er IST verborgen, solange er ein Modi ist.
WER DARF SIE SEHEN, und nur diese zwei:
* die DogFather-Rolle (admin) -- also auch die rechte Hand,
denn sie traegt dieselbe Rolle. So gewollt (Kapitel 3 des
Anforderungsdokuments: gleicher Ueberblick).
* ein Modi selbst -- sie sind untereinander ein Team.
Manager, Scouts, Creator und Spicy Media sehen sie nicht. Nicht
"sehen sie ohne Einzelheiten", sondern gar nicht: kein Name, kein
Eintrag, keine Zahl, die sie mitzaehlt. */
const modis = d.prepare(
"SELECT id FROM personen WHERE rolle = 'modi'").all().map((z) => z.id);
/* Ohne bekannte Person wird BEIDES verborgen. Das ist die strengere
Antwort, und bei einer Sichtbarkeitsregel ist die strengere die
richtige: Wer nicht weiss, wer fragt, zeigt nichts. */
const darfAdminsSehen = !!person
&& (person.id === admins[0] || versteckteAdmins.includes(person.id));
const darfModisSehen = !!person
&& (person.rolle === "admin" || person.rolle === "modi");
const weg = [];
if (!darfAdminsSehen) weg.push(...versteckteAdmins);
if (!darfModisSehen) weg.push(...modis);
/* Doppelte heraus: Ein Modi-Zugang koennte theoretisch auch in der
ersten Liste stehen, wenn jemand die Rollen umhaengt. */
return [...new Set(weg)];
} catch {
/* Ohne Datenbank lieber nichts verbergen als abstuerzen -- diese
Funktion darf keine Seite lahmlegen. Sie laeuft in jedem
@@ -2510,7 +2768,27 @@ function sichtbarePersonenIdsRoh(person) {
for (const z of db().prepare(
"SELECT id FROM personen WHERE rolle = 'admin' AND aktiv = 1").all()) dogi.push(z.id);
} catch { /* ohne Datenbank bleibt die Liste, wie sie war */ }
return [...new Set([person.id, ...creator, ...scouts, ...dogi])];
/* DAS MODI-TEAM SIEHT SICH UNTEREINANDER (Entscheidung Filipe,
09.09.2026: "ja, sie sind untereinander ein Team").
Ohne diese Zeilen waere jeder Modi allein: kein Team-Gruppenchat,
keine Schicht, die man tauschen kann, keine Eskalation, die man
weiterreicht, ohne dass DogFather sie einzeln durchstellt.
Es ist KEIN Loch in der Verborgenheit, sondern ihre andere Seite.
Was diese Liste zusaetzlich hergibt, wird gleich danach durch
ohneVerborgene() wieder eingeschraenkt -- und dort steht, dass ein
Modi nur fuer die DogFather-Rolle und fuer Modis sichtbar ist. Wer
kein Modi ist, bekommt diese Nummern hier gar nicht erst. */
const modis = [];
if (person.rolle === "modi") {
try {
for (const z of db().prepare(
"SELECT id FROM personen WHERE rolle = 'modi' AND aktiv = 1").all()) modis.push(z.id);
} catch { /* ohne Datenbank bleibt die Liste, wie sie war */ }
}
return [...new Set([person.id, ...creator, ...scouts, ...dogi, ...modis])];
}
/** WER BETREUT MICH -- die Blickrichtung nach oben.
@@ -2975,8 +3253,9 @@ export function personAnlegen(name, rolle, akteur = null) {
const salt = randomBytes(16).toString("hex");
const hash = hashe(code, salt, SCRYPT.N);
const { lastInsertRowid } = db().prepare(
"INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt) VALUES (?,?,?,?,?,1,?)"
).run(name, rolle, hash, salt, SCRYPT.N, jetzt());
"INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, code_kennung, aktiv, erstellt)"
+ " VALUES (?,?,?,?,?,?,1,?)"
).run(name, rolle, hash, salt, SCRYPT.N, codeKennung(code), jetzt());
protokolliere("person_angelegt", {
personId: akteur?.id ?? null, rolle: akteur?.rolle ?? null,
ip: akteur?.ip ?? null, detail: `${name} (${rolle})`,
@@ -3000,8 +3279,9 @@ export function codeNeu(id, akteur = null, behalteToken = null) {
if (!person) throw new Error(`Keine Person mit Nummer ${id}`);
const code = codeErzeugen();
const salt = randomBytes(16).toString("hex");
db().prepare("UPDATE personen SET code_hash = ?, code_salt = ?, code_n = ? WHERE id = ?")
.run(hashe(code, salt, SCRYPT.N), salt, SCRYPT.N, id);
db().prepare(
"UPDATE personen SET code_hash = ?, code_salt = ?, code_n = ?, code_kennung = ? WHERE id = ?")
.run(hashe(code, salt, SCRYPT.N), salt, SCRYPT.N, codeKennung(code), id);
/* Offene Sitzungen beenden -- ein neuer Code soll den alten Zugang
wirklich beenden, nicht nur die nächste Anmeldung betreffen.