Externer Anker: Audit-Kette HMAC-signiert (Hash-Version 3)

Schliesst den nach R166/R167 verbliebenen Grenzfall: Bis Version 2 war die
Kette selbsttragend - wer die DB schreiben kann, konnte jede Zeile aendern und
alle Folgehashes konsistent nachziehen, die Pruefung meldete "gueltig".

Version 3 signiert denselben Inhalt per HMAC-SHA256 mit AUDIT_HMAC_KEY, einem
Schluessel ausserhalb der Datenbank. Ohne ihn laesst sich keine gueltige
Signatur erzeugen; reiner DB-Schreibzugriff genuegt nicht mehr.

Fail-safe: Ohne Schluessel wird weiter Version 2 geschrieben, es faellt nichts
aus. Signierte Zeilen gelten dann als nicht pruefbar (unverifiableEntries) und
ausdruecklich nicht als manipuliert. AUDIT_HMAC_KEY_OLD erlaubt einen
Schluesselwechsel ohne Rehash.

Restluecke der Versionsgrenze geschlossen: Wird die FRUEHESTE Zeile einer Stufe
herabgestuft, wandert MIN(id) mit - die Grenze allein haette den Downgrade
durchgewunken (der erste Testlauf fiel genau darauf durch). Der Nachfolger ist
jedoch HMAC-signiert und sein previousHash ohne Schluessel nicht faelschbar;
eine unerklaerte Luecke vor einer signierten Zeile gilt deshalb als Befund.

Verifiziert: Inhalt geaendert -> erkannt; Downgrade 3->2 auf der fruehesten
V3-Zeile -> erkannt; dasselbe auf der letzten V3-Zeile (kein Nachfolger) ->
erkannt; ohne Schluessel 0 manipuliert / 2 nicht pruefbar; 40 parallele
Schreiber -> 40/40, 0 Forks, alle V3. tsc + vite build gruen.

AUDIT_HMAC_KEY in .env.example dokumentiert. Der Schluessel selbst liegt nur
lokal in .env (gitignored).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 18:50:57 +02:00
co-authored by Claude Opus 5
parent 1a349d142e
commit 044a12f73e
5 changed files with 173 additions and 27 deletions
+18
View File
@@ -19,3 +19,21 @@ ENCRYPTION_KEY="32-byte-hex-key-for-aes-256-gcm"
# Server
PORT=3001
NODE_ENV=development
# ==================== AUDIT-ANKER ====================
# Signiert Audit-Log-Eintraege per HMAC-SHA256 mit einem Schluessel, der NICHT
# in der Datenbank liegt. Damit reicht ein reiner DB-Schreibzugriff nicht mehr
# aus, um die Hash-Kette glaubwuerdig umzuschreiben (Pentest R166/R167).
#
# Erzeugen: openssl rand -hex 32
# Fail-safe: Ohne Schluessel schreibt das Audit-Log weiter die unsignierte
# Version 2 - es faellt nichts aus, der Anker fehlt nur.
# ACHTUNG: Schluessel sichern. Geht er verloren, sind alle damit signierten
# Eintraege nicht mehr pruefbar (sie gelten dann als "nicht pruefbar",
# NICHT als manipuliert).
AUDIT_HMAC_KEY=
# Nur waehrend eines Schluesselwechsels setzen: der vorherige Schluessel wird
# bei der Pruefung zusaetzlich akzeptiert, damit Altbestand ohne Rehash gueltig
# bleibt. Nach dem Wechsel wieder leeren.
AUDIT_HMAC_KEY_OLD=
@@ -159,6 +159,11 @@ export async function verifyIntegrity(req: AuthRequest, res: Response) {
: ' ohne dokumentierte Löschung')
: '';
const unverifiable = result.unverifiableEntries.length;
const keinSchluessel = unverifiable > 0
? ` ${unverifiable} Einträge sind HMAC-signiert und ohne konfigurierten AUDIT_HMAC_KEY nicht prüfbar.`
: '';
const message = tampered > 0
? `${tampered} MANIPULIERTE Einträge gefunden` + (gaps > 0 ? ` (zusätzlich ${luecken})` : '')
: gaps > 0
@@ -178,7 +183,8 @@ export async function verifyIntegrity(req: AuthRequest, res: Response) {
// Nur Lücken ohne protokollierte Löschung sind erklärungsbedürftig.
unexplainedGaps: result.unexplainedGaps,
tampered: tampered > 0,
message,
message: message + keinSchluessel,
unverifiableEntries: result.unverifiableEntries,
},
});
} catch (error) {
+114 -25
View File
@@ -207,11 +207,49 @@ export interface AuditHashV2Input {
previousHash?: string | null;
}
/**
* Externer Anker (Pentest R166/R167, Design-Empfehlung).
*
* Bis Version 2 war die Kette selbsttragend: Wer die DB schreiben kann, kann
* jede Zeile aendern und saemtliche Folgehashes konsistent nachziehen die
* Pruefung meldet dann sauber "gueltig". Version 3 signiert denselben Inhalt
* stattdessen per HMAC-SHA256 mit einem Schluessel, der NICHT in der Datenbank
* liegt (`AUDIT_HMAC_KEY`). Ohne diesen Schluessel laesst sich keine gueltige
* Signatur erzeugen reiner DB-Schreibzugriff genuegt damit nicht mehr.
*
* Fail-safe: Ohne konfigurierten Schluessel wird weiterhin Version 2
* geschrieben. Audit-Logging darf nie an fehlender Konfiguration scheitern.
* `AUDIT_HMAC_KEY_OLD` erlaubt einen Schluesselwechsel ohne Rehash bei der
* Pruefung wird zusaetzlich gegen den alten Schluessel getestet.
*/
function auditHmacKey(): string | null {
const k = process.env.AUDIT_HMAC_KEY;
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;
}
function generateHashV3(data: AuditHashV2Input, key: string): string {
return crypto.createHmac('sha256', key)
.update(JSON.stringify(buildContentPayload(data, 3)))
.digest('hex');
}
function generateHashV2(data: AuditHashV2Input): string {
return crypto.createHash('sha256')
.update(JSON.stringify(buildContentPayload(data, 2)))
.digest('hex');
}
/** Gemeinsamer Inhalts-Payload fuer Version 2 (SHA-256) und 3 (HMAC). */
function buildContentPayload(data: AuditHashV2Input, version: 2 | 3): Record<string, unknown> {
const n = <T>(v: T | null | undefined): T | null => (v === undefined ? null : v);
// Feldreihenfolge ist Teil des Hashes und darf nicht veraendert werden.
const payload = {
v: 2,
return {
v: version,
userId: n(data.userId),
userEmail: data.userEmail,
userRole: n(data.userRole),
@@ -237,8 +275,6 @@ function generateHashV2(data: AuditHashV2Input): string {
createdAt: data.createdAt.toISOString(),
previousHash: data.previousHash || '',
};
return crypto.createHash('sha256').update(JSON.stringify(payload)).digest('hex');
}
/**
@@ -352,8 +388,11 @@ export async function createAuditLog(data: CreateAuditLogData): Promise<void> {
const previousHash = lastRows[0]?.hash || null;
const createdAt = new Date();
// Neue Eintraege immer mit Version 2 (volle Feldabdeckung, R166-02).
const hash = generateHashV2({
// Version 3 (HMAC-signiert) sobald ein Schluessel konfiguriert ist,
// sonst Version 2 Audit-Logging darf an fehlender Konfiguration nicht
// scheitern (fail-safe, siehe generateHashV3).
const hmacKey = auditHmacKey();
const inhalt = {
userId: data.userId,
userEmail: data.userEmail,
userRole: data.userRole,
@@ -378,7 +417,8 @@ export async function createAuditLog(data: CreateAuditLogData): Promise<void> {
durationMs: data.durationMs,
createdAt,
previousHash,
});
};
const hash = hmacKey ? generateHashV3(inhalt, hmacKey) : generateHashV2(inhalt);
await tx.auditLog.create({
data: {
@@ -407,7 +447,7 @@ export async function createAuditLog(data: CreateAuditLogData): Promise<void> {
createdAt,
hash,
previousHash,
hashVersion: 2,
hashVersion: hmacKey ? 3 : 2,
},
});
}, {
@@ -567,6 +607,12 @@ export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{
* Loeschungs-Manifest erklaert ist. Nur diese sind erklaerungsbeduerftig.
*/
unexplainedGaps: number[];
/**
* HMAC-signierte Zeilen, die mangels konfiguriertem `AUDIT_HMAC_KEY` nicht
* geprueft werden konnten. Bewusst KEIN Manipulationsverdacht sonst waere
* eine fehlende Konfiguration ein Fehlalarm ueber das gesamte Log.
*/
unverifiableEntries: number[];
}> {
const where: Prisma.AuditLogWhereInput = {};
@@ -629,11 +675,20 @@ export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{
// neue Instanz parallel schreiben, koennen echte V1-Zeilen nach der Grenze
// entstehen und werden dann angezeigt. Beim hier ueblichen Deploy
// (pull + rebuild + restart einer Instanz) tritt das nicht auf.
const v2Cutover = await prisma.auditLog.aggregate({
where: { hashVersion: { gte: 2 } },
_min: { id: true },
});
const v2FromId = v2Cutover._min.id;
// Grenze pro Stufe: ab der ersten je mit Version >= v geschriebenen Zeile
// muss jede weitere Zeile mindestens Version v haben. Verallgemeinert auf
// Version 3 (HMAC-Anker), damit auch ein Ruecksprung 3→2 auffliegt.
const [c2, c3] = await Promise.all([
prisma.auditLog.aggregate({ where: { hashVersion: { gte: 2 } }, _min: { id: true } }),
prisma.auditLog.aggregate({ where: { hashVersion: { gte: 3 } }, _min: { id: true } }),
]);
const v2FromId = c2._min.id;
const v3FromId = c3._min.id;
const erwarteteVersion = (id: number): 1 | 2 | 3 => {
if (v3FromId !== null && id >= v3FromId) return 3;
if (v2FromId !== null && id >= v2FromId) return 2;
return 1;
};
// Dokumentierte Loeschungen einlesen: Luecken, die auf einen protokollierten
// Retention-Cleanup zurueckgehen, sind erklaert alle anderen nicht. Vorher
@@ -662,25 +717,27 @@ export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{
const tamperedEntries: number[] = [];
const chainGaps: number[] = [];
const unexplainedGaps: number[] = [];
const unverifiableEntries: number[] = [];
for (let i = 0; i < logs.length; i++) {
const log = logs[i];
// Erwartete Pruefstaerke aus der Kette, NICHT aus der Selbstauskunft.
const erwarteV2 = v2FromId !== null && log.id >= v2FromId;
if (erwarteV2 !== (log.hashVersion >= 2)) {
const erwartet = erwarteteVersion(log.id);
if (log.hashVersion !== erwartet) {
// Deklarierte Version passt nicht zur Position in der Kette →
// Downgrade-Versuch (oder manipulierte Version).
tamperedEntries.push(log.id);
continue;
}
// Pruefverfahren richtet sich nach der Version, mit der geschrieben wurde.
// Version 2 deckt alle Inhaltsspalten ab; Version 1 nur 7 Felder und darf
// zusaetzlich die historische Serialisierung nutzen (generateHashLegacy).
// Pruefverfahren richtet sich nach der erwarteten Version:
// 3 = HMAC-signiert (Schluessel ausserhalb der DB)
// 2 = SHA-256 ueber alle Inhaltsspalten
// 1 = SHA-256 ueber 7 Felder, zusaetzlich historische Serialisierung
let hashOk: boolean;
if (erwarteV2) {
hashOk = log.hash === generateHashV2({
if (erwartet >= 2) {
const inhalt = {
userId: log.userId,
userEmail: log.userEmail,
userRole: log.userRole,
@@ -705,7 +762,23 @@ export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{
durationMs: log.durationMs,
createdAt: log.createdAt,
previousHash: log.previousHash,
});
};
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) {
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));
} else {
hashOk = log.hash === generateHashV2(inhalt);
}
} else {
const v1 = {
userEmail: log.userEmail,
@@ -731,6 +804,19 @@ export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{
chainGaps.push(log.id);
if (!gapErklaert(previousLog.id, log.id)) {
unexplainedGaps.push(log.id);
// Ist die AKTUELLE Zeile HMAC-signiert, laesst sich ihr
// `previousHash` ohne Schluessel nicht nachziehen. Eine unerklaerte
// Luecke davor bedeutet dann zwingend: der Vorgaenger wurde
// veraendert oder entfernt. Das ist kein struktureller Zufall,
// sondern ein Befund.
//
// Dieser Pfad schliesst zugleich die Restluecke der Versionsgrenze:
// Wird die FRUEHESTE V3-Zeile herabgestuft, wandert `MIN(id)` mit und
// die Grenze allein wuerde den Downgrade durchwinken der Nachfolger
// erzeugt aber eine nicht faelschbare Luecke.
if (erwartet === 3) {
tamperedEntries.push(log.id);
}
}
}
}
@@ -745,6 +831,7 @@ export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{
tamperedEntries,
chainGaps,
unexplainedGaps,
unverifiableEntries,
};
}
@@ -782,17 +869,19 @@ export async function rehashAll(): Promise<{ rehashedCount: number }> {
},
});
const rehashKey = auditHmacKey();
let previousHash: string | null = null;
let count = 0;
for (const log of logs) {
// Rehash schreibt immer Version 2 (volle Feldabdeckung) ein Rueckfall
// auf Version 1 wuerde die Abdeckung nachtraeglich wieder verkleinern.
const hash = generateHashV2({ ...log, previousHash });
// Rehash schreibt die hoechste verfuegbare Stufe ein Rueckfall auf eine
// schwaechere Version wuerde die Abdeckung nachtraeglich verkleinern.
const inhalt: AuditHashV2Input = { ...log, previousHash };
const hash: string = rehashKey ? generateHashV3(inhalt, rehashKey) : generateHashV2(inhalt);
await prisma.auditLog.update({
where: { id: log.id },
data: { hash, previousHash, hashVersion: 2 },
data: { hash, previousHash, hashVersion: rehashKey ? 3 : 2 },
});
previousHash = hash;
+33
View File
@@ -97,6 +97,39 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
## ✅ Erledigt
- [x] **⚓ Externer Anker: Audit-Kette HMAC-signiert (Hash-Version 3)** (2026-08-18)
- Schliesst den nach R166/R167 verbliebenen Grenzfall: Bis Version 2 war die
Kette selbsttragend wer die DB schreiben kann, konnte jede Zeile aendern
und alle Folgehashes konsistent nachziehen, die Pruefung meldete „gueltig“.
- Version 3 signiert denselben Inhalt per **HMAC-SHA256** mit `AUDIT_HMAC_KEY`
einem Schluessel, der NICHT in der Datenbank liegt. Ohne ihn laesst sich
keine gueltige Signatur erzeugen; reiner DB-Schreibzugriff genuegt nicht mehr.
- **Fail-safe:** Ohne Schluessel schreibt das Audit-Log weiter Version 2, es
faellt nichts aus. Signierte Zeilen gelten dann als **nicht pruefbar**
(eigener Topf `unverifiableEntries`) und ausdruecklich NICHT als
manipuliert eine fehlende Konfiguration darf kein Fehlalarm ueber das
gesamte Log sein.
- **Schluesselwechsel ohne Rehash:** `AUDIT_HMAC_KEY_OLD` wird bei der
Pruefung zusaetzlich akzeptiert. Verifiziert: mit KEY_OLD 0 Befunde, ohne
KEY_OLD werden die alt signierten Zeilen erwartungsgemaess auffaellig.
- **Restluecke der Versionsgrenze geschlossen (selbst gefunden):** Wird die
FRUEHESTE Zeile einer Stufe herabgestuft, wandert `MIN(id)` mit die
Grenze allein haette den Downgrade durchgewunken (mein erster Testlauf fiel
genau darauf durch). Der Nachfolger ist jedoch HMAC-signiert, sein
`previousHash` ist ohne Schluessel nicht faelschbar. Eine unerklaerte Luecke
vor einer signierten Zeile gilt deshalb jetzt als Befund, nicht als
struktureller Zufall.
- Verifiziert: Inhalt geaendert + Hash beliebig → erkannt; Downgrade 3→2 mit
gueltigem V2-Hash auf der **fruehesten** V3-Zeile → erkannt; dasselbe auf
der **letzten** V3-Zeile (kein Nachfolger) → erkannt; ohne Schluessel
0 manipuliert / 2 nicht pruefbar; 40 parallele Schreiber → 40/40, 0 Forks,
alle V3. `tsc` + `vite build` gruen.
- **Betrieb:** `AUDIT_HMAC_KEY` in `.env.example` dokumentiert
(`openssl rand -hex 32`). Schluessel sichern geht er verloren, sind die
damit signierten Eintraege dauerhaft nicht mehr pruefbar (aber nicht als
manipuliert gemeldet). Der Dev-Schluessel liegt nur lokal in `.env`
(gitignored) und ist NICHT der Prod-Schluessel.
- [x] **🔒 hashVersion-Downgrade geschlossen (Pentest R167-01, HIGH)** (2026-08-18)
- Der Pentester hat den Angriffsweg, den ich beim Uebergeben von R166-02
selbst als naechsten benannt hatte, als **exploitbar bewiesen** (PoC gegen
+1 -1
View File
@@ -1752,7 +1752,7 @@ export const auditLogApi = {
return res.data;
},
verifyIntegrity: async () => {
const res = await api.post<ApiResponse<{ valid: boolean; checkedCount: number; invalidEntries: number[]; tamperedEntries: number[]; chainGaps: number[]; unexplainedGaps: number[]; tampered: boolean; message: string }>>('/audit-logs/verify');
const res = await api.post<ApiResponse<{ valid: boolean; checkedCount: number; invalidEntries: number[]; tamperedEntries: number[]; chainGaps: number[]; unexplainedGaps: number[]; unverifiableEntries: number[]; tampered: boolean; message: string }>>('/audit-logs/verify');
return res.data;
},
rehash: async () => {