feat(datenmodell): Schema, tagCodes und Geheimnisse (#20)
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s

Vollständiges Datenmodell in deutscher Benennung: 20 Tabellen, 9 Aufzählungen,
eine Migration, dazu die framework-freien Helfer für tagCodes und Geheimnisse.

Regeln stehen zusätzlich in der Datenbank, nicht nur im Service: 12 CHECKs,
2 partielle Eindeutigkeitsindizes, 4 zusammengesetzte Fremdschlüssel. Die
Mandantengrenze ist damit strukturell erzwungen. Schrittergebnisse, Kommentare
und Protokolleinträge kaskadieren bewusst nicht — das Archiv ist das Gedächtnis
der Anwendung.

106 Tests, davon 42 gegen die echten Datenbankregeln.

Closes #6

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit was merged in pull request #20.
This commit is contained in:
2026-09-24 23:11:58 +00:00
co-authored by Claude Opus 5
parent 49ce06f206
commit 7a0f875f55
19 changed files with 2820 additions and 40 deletions
+136
View File
@@ -0,0 +1,136 @@
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "node:crypto";
/**
* Verschluesselt Geheimnisse, die die Anwendung im Klartext wieder braucht (#006):
* heute das LDAP-Bind-Passwort, spaeter Zugangsdaten von Connectoren. Fuer
* Anmeldepasswoerter von Benutzern ist das der falsche Weg -- die werden gehasht
* (argon2) und nie entschluesselt.
*
* AES-256-GCM, weil es neben der Verschluesselung auch die Unverfaelschtheit
* bezeugt: ein veraenderter Geheimtext wirft beim Entschluesseln, statt still
* Unsinn zu liefern.
*/
const ALGORITHMUS = "aes-256-gcm";
/** GCM ist auf 96 Bit IV ausgelegt; andere Laengen kosten eine zusaetzliche Ableitung. */
const IV_LAENGE = 12;
const AUTH_TAG_LAENGE = 16;
const SCHLUESSEL_LAENGE = 32;
/**
* Festes Salz, absichtlich hier im Code.
*
* Ein Salz soll Angriffe mit vorberechneten Tabellen ueber viele schwache
* Passwoerter hinweg verteuern. Hier gibt es genau ein Geheimnis, `APP_SECRET`
* ist mit `openssl rand -base64 48` erzeugt und damit hochentropisch -- eine
* Tabelle dafuer vorzuberechnen ist aussichtslos. Umgekehrt muss der Schluessel
* ueber Neustarts und Container-Neubauten hinweg genau derselbe sein, sonst sind
* alle gespeicherten Geheimnisse nach einem Deploy wertlos. Ein zufaelliges Salz
* muesste also ohnehin neben dem Geheimtext liegen und schuetzte nichts
* zusaetzlich.
*/
const SALZ = "laufzettel.geheimnisse.v1";
/** Ein Versionspraefix, damit ein spaeterer Formatwechsel alte Werte noch lesen kann. */
const FORMAT_VERSION = "v1";
const MINDESTLAENGE = 32;
const FORMAT_HINWEIS = `Erwartet wird '${FORMAT_VERSION}.<iv>.<authTag>.<geheimtext>' mit base64-kodierten Teilen.`;
export class GeheimnisFehler extends Error {
constructor(nachricht: string, optionen?: ErrorOptions) {
super(nachricht, optionen);
this.name = "GeheimnisFehler";
}
}
/**
* Der Schluessel wird erst beim ersten Aufruf abgeleitet, nicht beim Import:
* `next build` laeuft ohne gesetztes `APP_SECRET`, und `scryptSync` ist
* absichtlich langsam. Gemerkt wird die Quelle mit, damit ein geaendertes
* `APP_SECRET` (in Tests, nach einer Rotation) nicht am Zwischenspeicher
* vorbeilaeuft.
*/
let abgeleitet: { quelle: string; schluessel: Buffer } | undefined;
function schluesselHolen(): Buffer {
const quelle = process.env["APP_SECRET"] ?? "";
if (quelle.length < MINDESTLAENGE) {
// Die tatsaechliche Laenge steht bewusst nicht in der Meldung: sie koennte in
// einem Log oder einer Fehlerseite landen und verriete etwas ueber das
// Geheimnis. Der Hinweis auf die Mindestlaenge genuegt zum Beheben.
throw new GeheimnisFehler(
`APP_SECRET fehlt oder ist zu kurz (mindestens ${MINDESTLAENGE} Zeichen). ` +
"Erzeugen mit: openssl rand -base64 48",
);
}
if (abgeleitet?.quelle !== quelle) {
abgeleitet = { quelle, schluessel: scryptSync(quelle, SALZ, SCHLUESSEL_LAENGE) };
}
return abgeleitet.schluessel;
}
export function verschluesseln(klartext: string): string {
const schluessel = schluesselHolen();
// Je Aufruf ein neuer IV. Ein zweites Mal denselben IV mit demselben
// Schluessel zu benutzen bricht GCM vollstaendig.
const iv = randomBytes(IV_LAENGE);
const chiffre = createCipheriv(ALGORITHMUS, schluessel, iv, { authTagLength: AUTH_TAG_LAENGE });
const geheim = Buffer.concat([chiffre.update(klartext, "utf8"), chiffre.final()]);
// base64 verwendet keinen Punkt, der Punkt taugt also als Trenner.
return [
FORMAT_VERSION,
iv.toString("base64"),
chiffre.getAuthTag().toString("base64"),
geheim.toString("base64"),
].join(".");
}
export function entschluesseln(geheimtext: string): string {
const teile = geheimtext.split(".");
const [version, ivTeil, tagTeil, datenTeil] = teile;
if (
teile.length !== 4 ||
version === undefined ||
ivTeil === undefined ||
tagTeil === undefined ||
datenTeil === undefined
) {
throw new GeheimnisFehler(`Geheimtext hat ein unbekanntes Format. ${FORMAT_HINWEIS}`);
}
if (version !== FORMAT_VERSION) {
throw new GeheimnisFehler(`Geheimtext traegt die unbekannte Version '${version}'. ${FORMAT_HINWEIS}`);
}
const iv = Buffer.from(ivTeil, "base64");
const authTag = Buffer.from(tagTeil, "base64");
if (iv.length !== IV_LAENGE || authTag.length !== AUTH_TAG_LAENGE) {
throw new GeheimnisFehler(
`Geheimtext ist unvollstaendig: IV oder Auth-Tag haben die falsche Laenge. ${FORMAT_HINWEIS}`,
);
}
const schluessel = schluesselHolen();
try {
const entschluessler = createDecipheriv(ALGORITHMUS, schluessel, iv, {
authTagLength: AUTH_TAG_LAENGE,
});
entschluessler.setAuthTag(authTag);
const klar = Buffer.concat([
entschluessler.update(Buffer.from(datenTeil, "base64")),
// `final` prueft das Auth-Tag und wirft, wenn Geheimtext oder Tag
// veraendert wurden oder der Schluessel nicht passt.
entschluessler.final(),
]);
return klar.toString("utf8");
} catch (ursache) {
throw new GeheimnisFehler(
"Geheimtext liess sich nicht entschluesseln: Auth-Tag passt nicht. Entweder wurde der " +
"Wert veraendert, oder APP_SECRET ist nicht mehr dasselbe wie beim Verschluesseln.",
{ cause: ursache },
);
}
}
+79
View File
@@ -0,0 +1,79 @@
import { randomInt } from "node:crypto";
/**
* Erzeugt die Codes fuer die QR-Aufkleber (#006).
*
* Ein tagCode wird laut Konvention nie geaendert und nie wiederverwendet. Er wird
* abgetippt und vorgelesen, deshalb sind die verwechselbaren Zeichen entschaerft.
* Entscheidend ist nicht, jedes einzelne zu streichen, sondern jede Gruppe
* aufzuloesen: aus {0, O} ist beides weg, aus {1, I, L} sind 1 und I weg. Das L
* bleibt und ist gerade deshalb eindeutig -- seine Doppelgaenger gibt es hier nicht.
* Es steht mit dem Praefix `LZ-` ohnehin in jedem Code und haelt die Breite bei
* genau 32 Zeichen. 32^6 sind rund 1,07 Mrd. Moeglichkeiten -- genug, dass Ziehen
* und Nachfragen billiger ist als ein Zaehler.
*/
export const TAGCODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
export const TAGCODE_PRAEFIX = "LZ-";
export const TAGCODE_LAENGE = 6;
/** Mehr Versuche lohnen nicht: bei dieser Breite ist schon der erste praktisch immer frei. */
export const TAGCODE_HOECHSTVERSUCHE = 10;
/**
* Das Muster wird aus den Bausteinen gebaut, damit es nicht auseinanderlaeuft,
* wenn Alphabet oder Laenge sich aendern. Das Alphabet enthaelt keine
* Sonderzeichen der regulaeren Ausdruecke, ist also ohne Maskierung einsetzbar.
*/
export const TAGCODE_MUSTER: RegExp = new RegExp(
`^${TAGCODE_PRAEFIX}[${TAGCODE_ALPHABET}]{${TAGCODE_LAENGE}}$`,
);
export class TagCodeFehler extends Error {
constructor(nachricht: string) {
super(nachricht);
this.name = "TagCodeFehler";
}
}
export function tagCodeErzeugen(): string {
let zeichen = "";
for (let stelle = 0; stelle < TAGCODE_LAENGE; stelle += 1) {
// `randomInt` zieht kryptografisch sicher und gleichverteilt: es verwirft
// Werte am Rand, statt zu rechnen (`byte % 32` waere hier ebenfalls
// verzerrungsfrei, weil 32 die 256 teilt -- aber nur solange das Alphabet
// genau 32 Zeichen hat, und darauf soll sich niemand verlassen muessen).
// `charAt` statt Index, weil `noUncheckedIndexedAccess` sonst ein
// `undefined` einschleust, das hier nicht auftreten kann.
zeichen += TAGCODE_ALPHABET.charAt(randomInt(0, TAGCODE_ALPHABET.length));
}
return TAGCODE_PRAEFIX + zeichen;
}
export function istTagCode(wert: string): boolean {
return TAGCODE_MUSTER.test(wert);
}
/**
* Zieht einen Code, der noch frei ist.
*
* Die Pruefung wird hereingereicht, damit dieses Modul framework- und
* datenbankfrei bleibt (siehe Schichtung in CLAUDE.md). Die letzte Instanz fuer
* Eindeutigkeit ist ohnehin der Unique-Index in der Datenbank; diese Schleife
* erspart nur den absehbaren Schreibfehler.
*/
export async function tagCodeEindeutigErzeugen(
istVergeben: (code: string) => Promise<boolean>,
): Promise<string> {
for (let versuch = 0; versuch < TAGCODE_HOECHSTVERSUCHE; versuch += 1) {
const code = tagCodeErzeugen();
if (!(await istVergeben(code))) {
return code;
}
}
throw new TagCodeFehler(
`Nach ${TAGCODE_HOECHSTVERSUCHE} Versuchen war kein freier tagCode zu finden. ` +
"Das deutet auf eine defekte Vergabepruefung hin, nicht auf einen erschoepften Vorrat.",
);
}