Bestandssiegel: Altbestand gegen stille Aenderung gesichert (Pentest R171-02)

Bei Hash-Version 1 sind nur 7 von 24 Spalten gehasht. Ein DB-Schreibzugriff
konnte eine LOGIN_FAILED-Zeile auf success=1 setzen, das Label umschreiben und
errorMessage leeren - alles Nicht-Hash-Felder, Hash unveraendert - und /verify
meldete weiterhin valid=true. Ein Einbruchsversuch war unsichtbar in einen
Erfolg umschreibbar.

Rueckwirkend signieren geht nicht, ein Rehash waere die falsche Medizin.
Stattdessen ein einmaliges, nicht destruktives Bestandssiegel: je Altzeile ein
Blattwert, die Wurzel darueber in einem HMAC-signierten Marker.

Umgesetzt nach den vier Bedingungen aus dem Pentest:
1. Blaetter ueber den vollen Zeileninhalt inkl. id und hashVersion, nicht ueber
   den 7-Feld-V1-Hash - sonst lebte die Luecke im Siegel weiter.
2. Wurzel signiert (steht im Marker, der selbst V3/HMAC ist). Ohne
   AUDIT_HMAC_KEY wird das Siegeln abgelehnt.
3. Bereich fix auf [1 ... v3FromId-1] statt Live-Abfrage hashVersion < 3. Sonst
   haette ein Up-Flip der Grenzzeile sie aus der geprueften Menge gedraengt.
4. Pruefung je id: vorhanden, weiterhin Altbestand, Inhalt == Blatt, dazu
   Wurzelabgleich.

Neuer Endpunkt POST /audit-logs/seal-backlog (audit:admin, confirm SEAL).
/verify meldet den Siegelzustand im Klartext, auch wenn kein Siegel existiert.

Verifiziert in separater Wegwerf-DB mit Mischbestand (6xV1, 5xV2, 5xV3): ohne
Siegel ist der Angriff unsichtbar, mit Siegel wird er erkannt und die Zeile
benannt; der Up-Flip der Grenzzeile wird ebenfalls erkannt. Wegwerf-DB danach
geloescht, Dev-Daten unberuehrt. tsc + vite build gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 22:12:51 +02:00
co-authored by Claude Opus 5
parent 2b0af772a2
commit 2932598c98
6 changed files with 300 additions and 1 deletions
@@ -0,0 +1,16 @@
-- Bestandssiegel fuer den nicht signierbaren Altbestand (Pentest R171-02).
--
-- Bei Hash-Version 1 sind nur 7 von 24 Spalten gehasht. Ein Schreibzugriff auf
-- die Datenbank kann dort Felder wie `success` oder `resourceLabel` aendern,
-- ohne den gespeicherten Hash zu beruehren - die Pruefung meldet weiterhin
-- "gueltig". Live gezeigt: ein fehlgeschlagener Login liess sich unsichtbar in
-- einen erfolgreichen umschreiben.
--
-- Rueckwirkend signieren geht nicht. Stattdessen wird der Altbestand einmalig
-- versiegelt: je Zeile ein Blattwert ueber den vollen Inhalt, die Wurzel
-- darueber in einem HMAC-signierten Marker.
CREATE TABLE IF NOT EXISTS `AuditBacklogSeal` (
`auditLogId` INT NOT NULL,
`leafHash` VARCHAR(191) NOT NULL,
PRIMARY KEY (`auditLogId`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+17
View File
@@ -1274,6 +1274,23 @@ model RefreshTokenRecord {
@@index([customerId]) @@index([customerId])
} }
/// Blattwerte des Bestandssiegels (Pentest R171-02 / R172).
///
/// Der Altbestand (Hash-Version 1/2) laesst sich nicht rueckwirkend signieren
/// ein Siegel vergibt man beim Anlegen. Bei V1 sind nur 7 von 24 Spalten
/// gehasht, ein DB-Schreiber kann dort z. B. `success` von false auf true
/// setzen, ohne den Hash zu beruehren: die Pruefung meldet weiterhin "gueltig".
///
/// Das Bestandssiegel schliesst das, ohne irgendetwas umzuschreiben: Zum
/// Zeitpunkt X wird je Altzeile ein Blattwert ueber den VOLLEN Inhalt
/// (alle Spalten inkl. id und hashVersion) gebildet; die Wurzel darueber
/// steht in einem HMAC-signierten Marker. Jede spaetere Aenderung an einer
/// Altzeile bricht Blatt und Wurzel.
model AuditBacklogSeal {
auditLogId Int @id
leafHash String
}
enum AuditAction { enum AuditAction {
CREATE CREATE
READ READ
+48 -1
View File
@@ -164,6 +164,13 @@ export async function verifyIntegrity(req: AuthRequest, res: Response) {
? ` ${unverifiable} Einträge sind HMAC-signiert und ohne konfigurierten AUDIT_HMAC_KEY nicht prüfbar.` ? ` ${unverifiable} Einträge sind HMAC-signiert und ohne konfigurierten AUDIT_HMAC_KEY nicht prüfbar.`
: ''; : '';
const siegel =
result.backlogSealStatus === 'gebrochen'
? ` ⚠ Bestandssiegel GEBROCHEN: ${result.backlogTampered.length} Alteinträge wurden nachträglich verändert.`
: result.backlogSealStatus === 'kein_siegel'
? ' Hinweis: Der Altbestand ist nicht versiegelt Änderungen daran wären nicht erkennbar.'
: '';
const message = tampered > 0 const message = tampered > 0
? `${tampered} MANIPULIERTE Einträge gefunden` + (gaps > 0 ? ` (zusätzlich ${luecken})` : '') ? `${tampered} MANIPULIERTE Einträge gefunden` + (gaps > 0 ? ` (zusätzlich ${luecken})` : '')
: gaps > 0 : gaps > 0
@@ -183,8 +190,11 @@ export async function verifyIntegrity(req: AuthRequest, res: Response) {
// Nur Lücken ohne protokollierte Löschung sind erklärungsbedürftig. // Nur Lücken ohne protokollierte Löschung sind erklärungsbedürftig.
unexplainedGaps: result.unexplainedGaps, unexplainedGaps: result.unexplainedGaps,
tampered: tampered > 0, tampered: tampered > 0,
message: message + keinSchluessel, message: message + keinSchluessel + siegel,
unverifiableEntries: result.unverifiableEntries, unverifiableEntries: result.unverifiableEntries,
// Zustand des Bestandssiegels ueber den nicht signierbaren Altbestand.
backlogSealStatus: result.backlogSealStatus,
backlogTampered: result.backlogTampered,
}, },
}); });
} catch (error) { } catch (error) {
@@ -229,6 +239,43 @@ export async function rehashAll(req: AuthRequest, res: Response) {
} }
} }
/**
* Altbestand versiegeln (Pentest R171-02).
*
* Einmalige, NICHT destruktive Aktion: schreibt nichts um, sondern haelt den
* Ist-Inhalt der nicht signierbaren Alteintraege fest.
*/
export async function sealBacklog(req: AuthRequest, res: Response) {
try {
if (req.body?.confirm !== 'SEAL') {
res.status(400).json({
success: false,
error:
'Versiegelt den aktuellen Stand des Altbestands. Zum Bestätigen ' +
'{"confirm":"SEAL"} mitsenden.',
});
return;
}
const result = await auditService.sealBacklog({
userEmail: req.user?.email,
ipAddress: req.ip || (req.socket as any)?.remoteAddress,
});
res.json({
success: true,
data: result,
message:
`${result.sealedCount} Alteinträge (id ${result.fromId}${result.toId}) versiegelt. ` +
'Spätere Änderungen an diesen Einträgen fallen ab sofort auf.',
});
} catch (error) {
console.error('Fehler beim Versiegeln des Altbestands:', error);
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Versiegeln',
});
}
}
/** /**
* Retention-Policies abrufen * Retention-Policies abrufen
*/ */
+3
View File
@@ -31,6 +31,9 @@ router.post('/verify', requirePermission('audit:read'), auditLogController.verif
// Hash-Kette reparieren // Hash-Kette reparieren
router.post('/rehash', requirePermission('audit:admin'), auditLogController.rehashAll); router.post('/rehash', requirePermission('audit:admin'), auditLogController.rehashAll);
// Altbestand versiegeln (nicht destruktiv)
router.post('/seal-backlog', requirePermission('audit:admin'), auditLogController.sealBacklog);
// Retention-Cleanup manuell ausführen // Retention-Cleanup manuell ausführen
router.post('/cleanup', requirePermission('audit:admin'), auditLogController.runRetentionCleanup); router.post('/cleanup', requirePermission('audit:admin'), auditLogController.runRetentionCleanup);
+180
View File
@@ -615,6 +615,112 @@ export async function getAuditLogsByDataSubject(customerId: number) {
/** /**
* Verifiziert die Integrität der Hash-Kette * Verifiziert die Integrität der Hash-Kette
*/ */
/**
* Blattwert einer Altbestands-Zeile fuer das Bestandssiegel (Pentest R171-02).
*
* Bewusst ueber den VOLLEN Zeileninhalt inkl. `id` und `hashVersion` nicht
* ueber den gespeicherten 7-Feld-Hash der Version 1. Sonst lebte die Luecke im
* Siegel weiter: genau die 17 ungehashten Spalten waeren wieder frei
* editierbar. `hashVersion` gehoert mit hinein, damit ein Hochstufen der Zeile
* (V1→V2, ohne Schluessel nachrechenbar) das Blatt bricht, statt sie klammheimlich
* aus dem Siegelbereich zu schieben.
*/
function backlogLeaf(row: Record<string, unknown>): string {
const feld = (v: unknown) =>
v instanceof Date ? v.toISOString() : v === undefined ? null : v;
// Feste Reihenfolge Teil des Blattwerts.
const payload = [
'id', 'userId', 'userEmail', 'userRole', 'customerId', 'isCustomerPortal',
'action', 'sensitivity', 'resourceType', 'resourceId', 'resourceLabel',
'endpoint', 'httpMethod', 'ipAddress', 'userAgent', 'changesBefore',
'changesAfter', 'changesEncrypted', 'dataSubjectId', 'legalBasis', 'success',
'errorMessage', 'durationMs', 'createdAt', 'hash', 'previousHash',
'hashVersion',
].map((k) => [k, feld(row[k])]);
return crypto.createHash('sha256').update(JSON.stringify(payload)).digest('hex');
}
/** Wurzel ueber die Blaetter in id-Reihenfolge. */
function backlogRoot(leaves: Array<{ auditLogId: number; leafHash: string }>): string {
const sortiert = [...leaves].sort((a, b) => a.auditLogId - b.auditLogId);
return crypto
.createHash('sha256')
.update(JSON.stringify(sortiert.map((l) => [l.auditLogId, l.leafHash])))
.digest('hex');
}
/** Endpoint-Kennung des Siegel-Markers (dient zugleich als Suchschluessel). */
const BACKLOG_SEAL_ENDPOINT = '/api/audit-logs/seal-backlog';
/**
* Versiegelt den Altbestand einmalig.
*
* Der Bereich ist FIX auf `[1 … v3FromId-1]` gebunden, nicht auf eine
* Live-Abfrage `hashVersion < 3`. Sonst koennte ein Angreifer die Grenzzeile
* hochstufen und sie damit aus der geprueften Menge draengen sie erschiene
* dann nur als harmlose Luecke. Zeilen ab `v3FromId` deckt die Versionsgrenze
* ab (sie MUESSEN V3 sein), es bleibt also keine Naht dazwischen.
*/
export async function sealBacklog(
ausgeloestVon?: { userEmail?: string; ipAddress?: string },
): Promise<{ sealedCount: number; fromId: number; toId: number; root: string }> {
if (!auditHmacKey()) {
throw new Error(
'Bestandssiegel erfordert einen konfigurierten AUDIT_HMAC_KEY ohne ihn liesse sich die Wurzel nachrechnen.',
);
}
const c3 = await prisma.auditLog.aggregate({
where: { hashVersion: { gte: 3 } },
_min: { id: true },
});
const v3FromId = c3._min.id;
if (v3FromId === null) {
throw new Error(
'Noch keine signierten Einträge vorhanden bitte zuerst AUDIT_HMAC_KEY setzen und das Backend neu starten.',
);
}
const toId = v3FromId - 1;
if (toId < 1) {
throw new Error('Kein Altbestand vorhanden es gibt nichts zu versiegeln.');
}
const rows = await prisma.auditLog.findMany({
where: { id: { lte: toId } },
orderBy: { id: 'asc' },
});
const leaves = rows.map((r) => ({
auditLogId: r.id,
leafHash: backlogLeaf(r as unknown as Record<string, unknown>),
}));
const root = backlogRoot(leaves);
await prisma.$transaction(async (tx) => {
await tx.auditBacklogSeal.deleteMany({});
for (let i = 0; i < leaves.length; i += 500) {
await tx.auditBacklogSeal.createMany({ data: leaves.slice(i, i + 500) });
}
}, { timeout: 60000, maxWait: 20000 });
// Marker ist selbst HMAC-signiert (Version 3) damit ist die Wurzel
// signiert, ohne dass es einen zweiten Signaturpfad braucht.
await createAuditLog({
userEmail: ausgeloestVon?.userEmail || 'system',
userRole: 'System',
action: 'CREATE',
resourceType: 'AuditLog',
resourceLabel: `Bestandssiegel erstellt: ${leaves.length} Alteinträge (id 1${toId})`,
endpoint: BACKLOG_SEAL_ENDPOINT,
httpMethod: 'POST',
ipAddress: ausgeloestVon?.ipAddress || 'system',
sensitivity: 'CRITICAL',
changesAfter: { fromId: 1, toId, count: leaves.length, root },
success: true,
});
return { sealedCount: leaves.length, fromId: 1, toId, root };
}
export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{ export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{
valid: boolean; valid: boolean;
checkedCount: number; checkedCount: number;
@@ -643,6 +749,13 @@ export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{
* eine fehlende Konfiguration ein Fehlalarm ueber das gesamte Log. * eine fehlende Konfiguration ein Fehlalarm ueber das gesamte Log.
*/ */
unverifiableEntries: number[]; unverifiableEntries: number[];
/**
* Zustand des Bestandssiegels ueber den nicht signierbaren Altbestand.
* `kein_siegel` = nie erstellt (oder Marker nicht pruefbar).
*/
backlogSealStatus: 'kein_siegel' | 'intakt' | 'gebrochen';
/** Altbestands-Zeilen, deren Inhalt vom Siegel abweicht. */
backlogTampered: number[];
}> { }> {
const where: Prisma.AuditLogWhereInput = {}; const where: Prisma.AuditLogWhereInput = {};
@@ -769,6 +882,71 @@ export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{
const unexplainedGaps: number[] = []; const unexplainedGaps: number[] = [];
const unverifiableEntries: number[] = []; const unverifiableEntries: number[] = [];
// ---------------------------------------------------------------------
// Bestandssiegel pruefen (Pentest R171-02)
//
// Der Altbestand ist nicht signierbar: bei Version 1 sind nur 7 von 24
// Spalten gehasht. Wurde er versiegelt, wird hier je Zeile geprueft:
// vorhanden, immer noch Altbestand (hashVersion < 3) und Inhalt unveraendert.
// Der Marker muss selbst signiert und gueltig sein - sonst waere das Siegel
// ueber denselben Weg faelschbar wie zuvor die Manifeste (R171-01).
const backlogTampered: number[] = [];
let backlogSealStatus: 'kein_siegel' | 'intakt' | 'gebrochen' = 'kein_siegel';
const siegelKandidaten = await prisma.auditLog.findMany({
where: { resourceType: 'AuditLog', action: 'CREATE', endpoint: BACKLOG_SEAL_ENDPOINT },
orderBy: { id: 'desc' },
});
const siegelSchluessel = [auditHmacKey(), ...auditHmacKeysOld()].filter(
(k): k is string => !!k,
);
const siegel = siegelSchluessel.length
? siegelKandidaten.find(
(r) =>
erwarteteVersion(r.id) === 3 &&
r.hashVersion >= 3 &&
siegelSchluessel.some((k) => r.hash === generateHashV3(r as unknown as AuditHashV2Input, k)),
)
: undefined;
if (siegel) {
try {
const meta = JSON.parse(siegel.changesAfter || '{}');
const bis: number = meta.toId;
const blaetter = await prisma.auditBacklogSeal.findMany();
const erwartet = new Map(blaetter.map((b) => [b.auditLogId, b.leafHash]));
const altzeilen = await prisma.auditLog.findMany({
where: { id: { lte: bis } },
orderBy: { id: 'asc' },
});
const vorhanden = new Map(altzeilen.map((r) => [r.id, r]));
for (const [id, blatt] of erwartet) {
const zeile = vorhanden.get(id);
// Fehlende Zeile schlaegt bereits als Ketten-Luecke auf; hier zaehlt
// die inhaltliche Veraenderung.
if (!zeile) continue;
if (zeile.hashVersion >= 3 || backlogLeaf(zeile as unknown as Record<string, unknown>) !== blatt) {
backlogTampered.push(id);
}
}
// Wurzel gegenrechnen: faengt auch Zeilen ab, die dem Siegel
// nachtraeglich hinzugefuegt oder daraus entfernt wurden.
const wurzelJetzt = backlogRoot(blaetter);
backlogSealStatus =
backlogTampered.length === 0 && wurzelJetzt === meta.root ? 'intakt' : 'gebrochen';
if (wurzelJetzt !== meta.root && backlogTampered.length === 0) {
// Blattbestand selbst veraendert, ohne dass eine Zeile abweicht.
backlogSealStatus = 'gebrochen';
}
} catch {
backlogSealStatus = 'gebrochen';
}
}
tamperedEntries.push(...backlogTampered);
for (let i = 0; i < logs.length; i++) { for (let i = 0; i < logs.length; i++) {
const log = logs[i]; const log = logs[i];
@@ -885,6 +1063,8 @@ export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{
chainGaps, chainGaps,
unexplainedGaps, unexplainedGaps,
unverifiableEntries, unverifiableEntries,
backlogSealStatus,
backlogTampered,
}; };
} }
+36
View File
@@ -97,6 +97,42 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
## ✅ Erledigt ## ✅ Erledigt
- [x] **🧿 Bestandssiegel: Altbestand gegen stille Aenderung gesichert (Pentest R171-02, HIGH)** (2026-08-18)
- Der Pentester hat den Fund live reproduziert: Bei Hash-Version 1 sind nur
7 von 24 Spalten gehasht. Ein DB-Schreibzugriff konnte eine
`LOGIN_FAILED`-Zeile auf `success=1` setzen, das Label auf
„…hat sich angemeldet“ aendern und `errorMessage` leeren **alles
Nicht-Hash-Felder, Hash unveraendert** → `/verify` meldete `valid=true`.
Ein Einbruchsversuch war unsichtbar in einen Erfolg umschreibbar.
- Rueckwirkend signieren geht nicht; ein Rehash waere die falsche Medizin.
Stattdessen **Bestandssiegel**: einmalig, nicht destruktiv, schreibt nichts
um. Je Altzeile ein Blattwert, die Wurzel darueber in einem HMAC-signierten
Marker.
- Umgesetzt exakt nach seinen vier Bedingungen:
1. **Blaetter ueber den vollen Zeileninhalt** (alle Spalten inkl. `id` und
`hashVersion`), NICHT ueber den 7-Feld-V1-Hash sonst lebte die Luecke
im Siegel weiter.
2. **Wurzel signiert** sie steht im Marker, der selbst Version 3 und
HMAC-signiert ist. Ohne `AUDIT_HMAC_KEY` wird das Siegeln abgelehnt.
3. **Bereich fix auf `[1 … v3FromId-1]`** gebunden statt per Live-Abfrage
`hashVersion < 3`. Sein Umgehungsweg: ein Up-Flip der Grenzzeile
(V1→V2, schluessellos nachrechenbar) haette sie sonst aus der geprueften
Menge gedraengt sie waere nur als harmlose Luecke erschienen.
4. **Pruefung je id**: vorhanden, weiterhin Altbestand, Inhalt == Blatt.
Zusaetzlich Wurzelabgleich, damit auch ein veraenderter Blattbestand
auffliegt.
- Naht geschlossen: Zeilen ab `v3FromId` MUESSEN laut Versionsgrenze V3
sein, es bleibt also keine ungesiegelte Zone dazwischen.
- Neuer Endpunkt `POST /audit-logs/seal-backlog` (`audit:admin`,
`{"confirm":"SEAL"}`). `/verify` meldet den Siegelzustand im Klartext
auch wenn **kein** Siegel existiert („Aenderungen daran waeren nicht
erkennbar“).
- Verifiziert in einer separaten Wegwerf-Datenbank mit realistischem
Mischbestand (6×V1, 5×V2, 5×V3): ohne Siegel ist der Angriff unsichtbar
(`valid=true`) mit Siegel wird er erkannt und die Zeile benannt (`[3]`);
sein Up-Flip der Grenzzeile wird ebenfalls erkannt (`[6]`). Wegwerf-DB
danach geloescht, Dev-Daten unberuehrt. `tsc` + `vite build` gruen.
- [x] **🔒 Manifest-Kanal abgesichert (Pentest R171-01, HIGH; R171-03, LOW)** (2026-08-18) - [x] **🔒 Manifest-Kanal abgesichert (Pentest R171-01, HIGH; R171-03, LOW)** (2026-08-18)
- **R171-01:** `verifyIntegrity` vertraute Loeschungs-Manifesten - **R171-01:** `verifyIntegrity` vertraute Loeschungs-Manifesten
bedingungslos ohne zu pruefen, ob die Traegerzeile ueberhaupt signiert bedingungslos ohne zu pruefen, ob die Traegerzeile ueberhaupt signiert