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:
@@ -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;
|
||||
@@ -1274,6 +1274,23 @@ model RefreshTokenRecord {
|
||||
@@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 {
|
||||
CREATE
|
||||
READ
|
||||
|
||||
@@ -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.`
|
||||
: '';
|
||||
|
||||
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
|
||||
? `${tampered} MANIPULIERTE Einträge gefunden` + (gaps > 0 ? ` (zusätzlich ${luecken})` : '')
|
||||
: 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.
|
||||
unexplainedGaps: result.unexplainedGaps,
|
||||
tampered: tampered > 0,
|
||||
message: message + keinSchluessel,
|
||||
message: message + keinSchluessel + siegel,
|
||||
unverifiableEntries: result.unverifiableEntries,
|
||||
// Zustand des Bestandssiegels ueber den nicht signierbaren Altbestand.
|
||||
backlogSealStatus: result.backlogSealStatus,
|
||||
backlogTampered: result.backlogTampered,
|
||||
},
|
||||
});
|
||||
} 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
|
||||
*/
|
||||
|
||||
@@ -31,6 +31,9 @@ router.post('/verify', requirePermission('audit:read'), auditLogController.verif
|
||||
// Hash-Kette reparieren
|
||||
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
|
||||
router.post('/cleanup', requirePermission('audit:admin'), auditLogController.runRetentionCleanup);
|
||||
|
||||
|
||||
@@ -615,6 +615,112 @@ export async function getAuditLogsByDataSubject(customerId: number) {
|
||||
/**
|
||||
* 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<{
|
||||
valid: boolean;
|
||||
checkedCount: number;
|
||||
@@ -643,6 +749,13 @@ export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{
|
||||
* eine fehlende Konfiguration ein Fehlalarm ueber das gesamte Log.
|
||||
*/
|
||||
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 = {};
|
||||
|
||||
@@ -769,6 +882,71 @@ export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{
|
||||
const unexplainedGaps: 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++) {
|
||||
const log = logs[i];
|
||||
|
||||
@@ -885,6 +1063,8 @@ export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{
|
||||
chainGaps,
|
||||
unexplainedGaps,
|
||||
unverifiableEntries,
|
||||
backlogSealStatus,
|
||||
backlogTampered,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -97,6 +97,42 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
|
||||
|
||||
## ✅ 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)
|
||||
- **R171-01:** `verifyIntegrity` vertraute Loeschungs-Manifesten
|
||||
bedingungslos – ohne zu pruefen, ob die Traegerzeile ueberhaupt signiert
|
||||
|
||||
Reference in New Issue
Block a user