Audit-Siegel: Umstiegsweg dokumentiert + Rotations-Fussangel entschaerft

Frage aus dem Betrieb: bestehende Installation hat noch keinen
AUDIT_HMAC_KEY - was passiert beim nachtraeglichen Setzen? Antwort jetzt in
README und .env.example: setzen, neu starten, fertig. Bestehende Eintraege
bleiben unveraendert gueltig, neue werden gesiegelt, alt und neu koexistieren
ohne Fehlalarm. Nachgemessen auf gemischtem Bestand (4903 x V1, 57 x V2,
67 x V3): 0 Beanstandungen. Rueckwirkend siegeln ist nicht moeglich.

Dabei zwei Fehler in der eigenen Doku gefunden und korrigiert:

1. Behauptet war, nach dem Leeren von AUDIT_HMAC_KEY_OLD seien alte Eintraege
   "nicht mehr pruefbar". Tatsaechlich werden sie als MANIPULIERT gemeldet -
   ein falscher Schluessel ist von einer Faelschung nicht zu unterscheiden.
   Gemessen: 67 Eintraege als manipuliert. Nur wenn GAR KEIN Schluessel
   gesetzt ist, gilt "nicht pruefbar". Doku entsprechend korrigiert, inkl.
   Warnung, das Feld nicht voreilig zu leeren.

2. AUDIT_HMAC_KEY_OLD bot nur einen Platz. Beim ZWEITEN Wechsel waeren alle
   mit dem ersten Schluessel gesiegelten Eintraege faelschlich als
   manipuliert erschienen (reproduziert: 67). Das Feld nimmt jetzt eine
   kommagetrennte Liste entgegen; verifiziert: mit beiden Alt-Schluesseln
   0 Beanstandungen, mit nur dem juengsten 67.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 19:14:24 +02:00
co-authored by Claude Opus 5
parent 93cb5ffd27
commit fb0915df12
3 changed files with 94 additions and 25 deletions
+19 -11
View File
@@ -227,9 +227,16 @@ function auditHmacKey(): string | null {
return k && k.trim().length > 0 ? k : null;
}
function auditHmacKeyOld(): string | null {
const k = process.env.AUDIT_HMAC_KEY_OLD;
return k && k.trim().length > 0 ? k : null;
/**
* Frueher verwendete Schluessel. Kommagetrennt, damit auch ein ZWEITER Wechsel
* moeglich ist, ohne die zuerst signierten Eintraege zu verlieren - mit nur
* einem Platz wuerden die aeltesten sonst als "manipuliert" gemeldet.
*/
function auditHmacKeysOld(): string[] {
return (process.env.AUDIT_HMAC_KEY_OLD || '')
.split(',')
.map((k) => k.trim())
.filter((k) => k.length > 0);
}
function generateHashV3(data: AuditHashV2Input, key: string): string {
@@ -765,17 +772,18 @@ export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{
};
if (erwartet === 3) {
// HMAC-signiert: ohne Schluessel ist die Zeile nicht pruefbar. Das als
// "manipuliert" zu melden waere ein Fehlalarm daher eigener Topf.
const key = auditHmacKey();
const keyOld = auditHmacKeyOld();
if (!key && !keyOld) {
// HMAC-signiert: ohne JEDEN Schluessel ist die Zeile nicht pruefbar.
// Das als "manipuliert" zu melden waere ein Fehlalarm daher eigener
// Topf. Ein FALSCHER Schluessel ist dagegen nicht von einer Faelschung
// zu unterscheiden und wird bewusst als Befund gemeldet.
const kandidaten = [auditHmacKey(), ...auditHmacKeysOld()].filter(
(k): k is string => !!k,
);
if (kandidaten.length === 0) {
unverifiableEntries.push(log.id);
continue;
}
// keyOld deckt den Zeitraum vor einem Schluesselwechsel ab.
hashOk = (!!key && log.hash === generateHashV3(inhalt, key))
|| (!!keyOld && log.hash === generateHashV3(inhalt, keyOld));
hashOk = kandidaten.some((k) => log.hash === generateHashV3(inhalt, k));
} else {
hashOk = log.hash === generateHashV2(inhalt);
}