feat(datenmodell): Schema, tagCodes und Geheimnisse (#20)
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:
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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.",
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user