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:
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user