Gegenbuch: verifizierender Leser statt Absichtserklaerung (Pentest R175)

R175-01 (HIGH): Die erste Fassung signierte zwar, prueft aber nie. Sie las ihre
Wahrheit per readFileSync aus der lokalen Arbeitsdatei, nirgends gab es ein
git verify-commit - das -S war write-only ohne Konsument. Live reproduziert:
DB-Tail abgeschnitten und die lokale Ledger-Zeile angepasst -> "OK, Checkpoint
beglaubigt", exit 0, kein Alarm.

Fix: Wahrheitsquelle ist der signierte Commit-Baum (bevorzugt der Remote-Kopf);
jeder Commit mit Gegenbuch-Aenderung muss eine gueltige Signatur tragen; weicht
die Arbeitsdatei vom signierten Stand ab, wird abgebrochen; lokale, nie
gepushte Commits gelten nicht als beglaubigt; die Signatur des frisch
erzeugten Commits wird gegengeprueft. Dazu ein Pruefmodus --check fuer
Auditoren.

R175-02 (MEDIUM): /checkpoint fuhr je Aufruf ein volles verifyIntegrity (O(n),
0,85 s bei 16k Zeilen) - authentifizierte DoS-Verstaerkung. Gebraucht wurde nur
die Siegel-Wurzel. Jetzt Kopf-Hash aus der Kopfzeile, Wurzel aus dem juengsten
gueltigen Marker, sealLeafCount statt des teuren Status.

R175-03: writeFileSync lief vor dem Commit, eine verwaiste Zeile wurde vom
Folgelauf zementiert. Jetzt Ruecknahme bei Fehlschlag, und NOTARY_SIGN=false
verlangt zusaetzlich NOTARY_INSECURE_ACK.

Verifiziert mit echtem SSH-Signaturschluessel: stilles Waschen -> Alarm;
erfundene Zeile -> Alarm; unsignierter Commit -> Alarm; Commit-Fehlschlag ->
zurueckgerollt; Reflex-Schalter verweigert; Pruefmodus haengt nichts an.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-21 16:13:54 +02:00
co-authored by Claude Opus 5
parent f3ded9afbc
commit 375d4ae1e2
4 changed files with 293 additions and 76 deletions
+43 -5
View File
@@ -700,7 +700,7 @@ export async function getCheckpoint(atId?: number): Promise<{
maxId: number | null;
chainHead: string | null;
sealRoot: string | null;
sealStatus: string;
sealLeafCount: number;
atId?: number;
atHash?: string | null;
}> {
@@ -708,22 +708,60 @@ export async function getCheckpoint(atId?: number): Promise<{
orderBy: { id: 'desc' },
select: { id: true, hash: true },
});
const pruefung = await verifyIntegrity();
// BEWUSST KEIN verifyIntegrity() hier (Pentest R175-02): das laeuft ueber
// alle Zeilen und kostet bei grossen Logs Sekunden je Aufruf ein
// authentifizierter Leser koennte damit die Datenbank in die Knie zwingen.
// Gebraucht wird von der Vollpruefung ohnehin nur die Siegel-Wurzel, und die
// steht im juengsten gueltigen Marker. Der Kopf-Hash kommt direkt aus der
// Kopfzeile.
const c3 = await prisma.auditLog.aggregate({
where: { hashVersion: { gte: 3 } },
_min: { id: true },
});
const v3FromId = c3._min.id;
const schluessel = [auditHmacKey(), ...auditHmacKeysOld()].filter(
(k): k is string => !!k,
);
const markerKandidaten = await prisma.auditLog.findMany({
where: { resourceType: BACKLOG_SEAL_RESOURCE, endpoint: BACKLOG_SEAL_ENDPOINT },
orderBy: { id: 'desc' },
take: 20,
});
let sealRoot: string | null = null;
if (schluessel.length && v3FromId !== null) {
for (const r of markerKandidaten) {
if (r.id < v3FromId || r.hashVersion < 3) continue;
if (!schluessel.some((k) => r.hash === generateHashV3(r as unknown as AuditHashV2Input, k))) continue;
try {
const m = JSON.parse(r.changesAfter || '{}');
if (typeof m.root === 'string' && m.root.length > 0) {
sealRoot = m.root;
break;
}
} catch {
/* naechster Kandidat */
}
}
}
const blattAnzahl = await prisma.auditBacklogSeal.count();
const ergebnis: {
ts: string;
maxId: number | null;
chainHead: string | null;
sealRoot: string | null;
sealStatus: string;
sealLeafCount: number;
atId?: number;
atHash?: string | null;
} = {
ts: new Date().toISOString(),
maxId: kopf?.id ?? null,
chainHead: kopf?.hash ?? null,
sealRoot: pruefung.backlogSealRoot,
sealStatus: pruefung.backlogSealStatus,
sealRoot,
// Reicht der Gegenstelle, um das Verschwinden des Siegels zu bemerken:
// Blaetter ohne Wurzel = Marker entfernt.
sealLeafCount: blattAnzahl,
};
if (atId !== undefined && Number.isFinite(atId)) {