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:
+383
-103
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user