diff --git a/docs/todo.md b/docs/todo.md index 0bbf270f..f3698cf5 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -97,6 +97,44 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung ## ✅ Erledigt +- [x] **🔄 Gegenbuch: Rewind auf signierten Praefix benennbar gemacht (Pentest R178-01)** (2026-08-18) + - Fund: Mein Signatur-Gate faengt Force-Push mit **fremder oder unsignierter** + Historie – aber ein **Rewind auf einen aelteren, echt signierten Stand** + ist signaturseitig einwandfrei. Angreifer spult `origin/main` auf einen + frueheren Checkpoint zurueck und kuerzt die Datenbank passend: alle + Signaturen `G`, Pin korrekt, Reihe lueckenlos. Ein Notar-Klon mit lokalem + Vorlauf merkt es; ein **frischer Auditoren-Klon** meldete `OK`, exit 0 – + also ausgerechnet im dokumentierten Pruef-Fall. + - **Ehrliche Einordnung:** Das laesst sich im Skript nicht kryptographisch + erkennen, die Historie ist ja echt. Also zwei Dinge statt eines + Scheinfixes: + 1. `NOTARY_MIN_SEQ` – die zuletzt bekannte Nummer als Bezugspunkt. Ist die + Reihe kuerzer, ist das das Bild eines Rewinds → Alarm. Jeder Lauf nennt + die Nummer am Ende, damit sie ueberhaupt bekannt sein kann. + 2. Ohne diesen Bezugspunkt sagt die Erfolgsmeldung jetzt ausdruecklich, + dass ein Zurueckspulen **nicht** erkennbar war – „OK“ soll nicht mehr + Gewissheit behaupten als vorhanden ist. + - README: serverseitiger **Rewind-Schutz (non-fast-forward verbieten)** ist + jetzt als Pflicht formuliert, mit der Begruendung warum Signaturpruefung + allein dagegen nichts ausrichtet – und mit dem ausdruecklichen Hinweis, + dass ein frischer Klon den Rewind nicht sieht. + - Kleinkram aus seinem Bericht: `NOTARY_SIGNER_FINGERPRINT` wird beim + **Einlesen** getrimmt (ein Zeilenumbruch loeste sonst 4/4 Fehlalarme aus, + die auf den *korrekten* Fingerabdruck zeigten); die „nie gepusht“-Meldung + priorisiert jetzt **Untersuchen** statt Pushen und nennt den passenden + `git log`-Befehl – „pushen“ haette einen falsch signierten Commit dauerhaft + in die Kette gebracht. + - Beim Testen selbst gefunden: der allererste git-Aufruf war ungeschuetzt und + warf bei kaputtem Klon einen Node-Stacktrace – jetzt erklaerende Meldung. + - Verifiziert: Rewind 3→1 bei passend gekuerzter DB → frischer Klon ohne + Bezugspunkt `OK` **mit Vorbehalt**, mit `NOTARY_MIN_SEQ=3` → **Alarm + exit 2**; Pin mit Zeilenumbruch → kein Fehlalarm mehr; regulaerer Lauf + unveraendert. Testlabor und Port geraeumt. + - Seine Bestaetigungen: Pin-Ableitung faellt bei Key-Literal/GPG-Key-ID + korrekt fail-closed aus; der Rollback bei Pin-Mismatch ist vollstaendig, + und selbst ein Absturz zwischen Commit und Reset wird vom + „nie gepusht“-Check aufgefangen. + - [x] **📌 Gegenbuch: Fingerabdruck-Pin verpflichtend und vollstaendig angewandt (Pentest R177)** (2026-08-18) - **R177-01 (MEDIUM)** – Der Pin war optional. Ohne ihn war der Vertrauensanker die gesamte `allowed_signers`-**Menge**, nicht der eine diff --git a/tools/audit-notary/README.md b/tools/audit-notary/README.md index 893d381d..ddaa33d8 100644 --- a/tools/audit-notary/README.md +++ b/tools/audit-notary/README.md @@ -39,9 +39,31 @@ Als Cronjob, stündlich: CRM_TOKEN=... node /pfad/notary.mjs >> notary.log 2>&1 ``` -**Force-Push serverseitig sperren.** Ohne das ist die Append-only-Eigenschaft -nur geliehen – wer Schreibrecht auf das Repository erlangt, schreibt die -Historie sonst einfach um. Bei GitHub/GitLab: Branch-Protection auf `main`. +**Force-Push serverseitig sperren – das ist Pflicht, nicht Empfehlung.** + +Konkret muss der Server **non-fast-forward-Pushes verbieten** (Rewind-Schutz), +nicht nur „irgendeine" Branch-Protection. Der Grund ist nicht offensichtlich: + +Das Skript prüft jede Signatur. Aber ein Angreifer mit Force-Push-Recht muss +gar nichts fälschen – er kann die Reihe schlicht auf einen **älteren, echt +signierten Stand zurückspulen** und die Datenbank passend kürzen. Alle +Signaturen bleiben gültig, der Fingerabdruck stimmt, die Nummerierung ist +lückenlos. Kryptographisch ist daran nichts auszusetzen; es fehlt nur das Ende. + +Ein Notar-Rechner, der die höhere Nummer noch lokal kennt, merkt es. Ein +**frischer Klon merkt es nicht** – und das ist ausgerechnet der Auditoren-Fall. + +Deshalb zusätzlich: + +```bash +# Die zuletzt bekannte Nummer mitgeben – dann fällt ein Rewind auch ohne +# lokalen Zustand auf. Das Skript nennt sie am Ende jedes Laufs. +NOTARY_MIN_SEQ=42 node notary.mjs --check +``` + +Ohne `NOTARY_MIN_SEQ` weist die Erfolgsmeldung ausdrücklich darauf hin, dass +ein Zurückspulen nicht erkennbar war. Ein „OK" ohne diesen Zusatz bedeutet +mehr als eines mit. ## Der entscheidende Punkt: es wird tatsächlich geprüft diff --git a/tools/audit-notary/notary.mjs b/tools/audit-notary/notary.mjs index 81b573ab..27597939 100755 --- a/tools/audit-notary/notary.mjs +++ b/tools/audit-notary/notary.mjs @@ -55,7 +55,25 @@ const SIGN = process.env.NOTARY_SIGN !== 'false'; // Ist nichts gesetzt, wird der Pin aus `user.signingkey` abgeleitet. Klappt // auch das nicht, wird abgebrochen statt stillschweigend die ganze Menge zu // akzeptieren. -let PIN = process.env.NOTARY_SIGNER_FINGERPRINT || null; +// `.trim()` beim EINLESEN (Pentest R178): ein Zeilenumbruch aus der +// Umgebungsvariable liess sonst jeden Commit als „anderer Schlüssel“ auflaufen – +// ein Fehlalarm, der auf den korrekten Fingerabdruck zeigt und damit maximal +// verwirrt. +let PIN = (process.env.NOTARY_SIGNER_FINGERPRINT || '').trim() || null; + +// Bekannte Mindesthoehe der Checkpoint-Reihe (Pentest R178-01). +// +// Ein Angreifer mit Force-Push-Recht kann die Reihe auf einen AELTEREN, +// echt signierten Praefix zurueckspulen und die Datenbank passend kuerzen. +// Alle Signaturen bleiben gueltig, der Pin passt, die Reihe ist lueckenlos – +// signaturseitig ist daran nichts auszusetzen. Ein Notar-Klon mit lokalem +// Vorlauf merkt es (er erinnert die hoehere seq); ein FRISCHER Auditoren-Klon +// merkt es nicht. +// +// Wer die zuletzt gesehene seq kennt (aus Monitoring, Protokoll, Mail), kann +// sie hier mitgeben – dann faellt das Zurueckspulen auch ohne lokalen Zustand +// auf. +const MIN_SEQ = Number.parseInt(process.env.NOTARY_MIN_SEQ || '', 10); if (!SIGN && process.env.NOTARY_INSECURE_ACK !== 'mir-ist-klar-dass-das-ungeschuetzt-ist') { console.error( 'NOTARY_SIGN=false deaktiviert den gesamten Schutz des Gegenbuchs.\n' + @@ -156,12 +174,33 @@ async function hole(pfad) { const OHNE_ANKER = () => NUR_LOKAL ? ' – ACHTUNG: nur lokal, kein externer Anker, also kein Manipulationsschutz.' : ''; +// Ohne bekannte Mindesthoehe bleibt ein Rewind auf einen aelteren, echt +// signierten Stand unerkennbar (R178-01). Das gehoert in die Erfolgsmeldung, +// sonst liest sich „OK“ als mehr Gewissheit, als vorhanden ist. +const REWIND_VORBEHALT = () => + Number.isFinite(MIN_SEQ) + ? '' + : '\n Hinweis: Ein Zurückspulen der Reihe auf einen älteren, ebenfalls signierten Stand ' + + 'ist hieraus NICHT erkennbar.\n' + + ' Dagegen hilft serverseitiger Rewind-Schutz (non-fast-forward verbieten) oder ' + + 'NOTARY_MIN_SEQ mit der zuletzt bekannten Nummer.'; + const alarm = (text) => { console.error(`ALARM: ${text}`); process.exit(2); }; // --------------------------------------------------------------------------- // 1) Vertrauenswuerdigen Stand bestimmen: bevorzugt der Remote-Kopf. // --------------------------------------------------------------------------- -const zweig = git('rev-parse', '--abbrev-ref', 'HEAD'); +// Auch der allererste git-Aufruf muss erklaerend scheitern koennen – sonst +// steht bei einem kaputten Klon ein Node-Stacktrace statt einer Diagnose. +const zweigErmittelt = gitStill('rev-parse', '--abbrev-ref', 'HEAD'); +if (!zweigErmittelt.ok || !zweigErmittelt.out || zweigErmittelt.out === 'HEAD') { + console.error( + 'Kein ausgecheckter Branch gefunden – ist das hier wirklich ein Klon des Gegenbuchs?\n' + + (zweigErmittelt.ok ? '' : ` git-Meldung: ${zweigErmittelt.out}`), + ); + process.exit(1); +} +const zweig = zweigErmittelt.out; let ref = 'HEAD'; const hatRemote = gitStill('remote', 'get-url', REMOTE).ok; @@ -204,8 +243,12 @@ if (ref !== 'HEAD') { const voraus = gitStill('rev-list', '--count', `${ref}..HEAD`); if (voraus.ok && Number(voraus.out) > 0) { alarm( - `${voraus.out} lokale(r) Commit(s) wurden nie gepusht. Das Gegenbuch ist damit ` + - `nur lokal – entweder pushen oder untersuchen, wie sie entstanden sind.`, + `${voraus.out} lokale(r) Commit(s) wurden nie gepusht – das Gegenbuch ist insoweit ` + + `nicht beglaubigt.\n` + + `ZUERST PRÜFEN, wie sie entstanden sind: ein abgebrochener Lauf hinterlässt so einen ` + + `Commit ebenso wie ein falsch signierter (dann darf er NICHT gepusht werden, sonst ist ` + + `die Kette dauerhaft rot).\n` + + ` Ansehen mit: git log ${ref}..HEAD --format='%h %G? %GF %s'`, ); } } @@ -293,6 +336,15 @@ for (let i = 0; i < bisher.length; i++) { } } +if (Number.isFinite(MIN_SEQ) && bisher.length < MIN_SEQ) { + alarm( + `Die Checkpoint-Reihe ist kürzer als bekannt: erwartet mindestens ${MIN_SEQ}, ` + + `vorhanden ${bisher.length}.\n` + + `Die vorhandenen Einträge sind zwar korrekt signiert – aber es fehlen welche am Ende. ` + + `Das ist das Bild eines Rewinds per Force-Push.`, + ); +} + // --------------------------------------------------------------------------- // 4) Abgleich mit dem CRM, VOR dem Anhaengen. // --------------------------------------------------------------------------- @@ -336,7 +388,7 @@ if (NUR_PRUEFEN) { `OK: ${bisher.length} Checkpoint(s) geprüft – Signaturen gültig, Reihe lückenlos, ` + `Arbeitsdatei deckungsgleich mit dem signierten Stand` + (letzter ? `, CRM stimmt mit Checkpoint ${letzter.seq} überein (maxId ${letzter.maxId}).` : '.') + - OHNE_ANKER(), + OHNE_ANKER() + REWIND_VORBEHALT(), ); process.exit(0); } @@ -407,4 +459,7 @@ if (PUSH && !NUR_LOKAL) { } } -console.log(`OK: Checkpoint ${eintrag.seq} beglaubigt (maxId ${eintrag.maxId}).` + OHNE_ANKER()); +console.log( + `OK: Checkpoint ${eintrag.seq} beglaubigt (maxId ${eintrag.maxId}).` + OHNE_ANKER() + + `\n Zuletzt bekannte Nummer für die nächste Prüfung: NOTARY_MIN_SEQ=${eintrag.seq}`, +);