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])
|
@@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
|
||||||
|
|||||||
@@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user