Gegenbuch: Rewind auf signierten Praefix benennbar gemacht (Pentest R178-01)

Das 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 - ausgerechnet im dokumentierten Pruef-Fall.

Das laesst sich im Skript nicht kryptographisch erkennen, die Historie ist echt.
Deshalb zwei Dinge statt eines Scheinfixes: NOTARY_MIN_SEQ als Bezugspunkt (ist
die Reihe kuerzer, Alarm; jeder Lauf nennt die Nummer), und ohne diesen
Bezugspunkt sagt die Erfolgsmeldung ausdruecklich, dass ein Zurueckspulen nicht
erkennbar war.

README: serverseitiger Rewind-Schutz (non-fast-forward verbieten) jetzt als
Pflicht formuliert, samt Begruendung und dem Hinweis, dass ein frischer Klon
den Rewind nicht sieht.

Kleinkram: NOTARY_SIGNER_FINGERPRINT wird beim Einlesen getrimmt (ein
Zeilenumbruch loeste 4/4 Fehlalarme aus, die auf den korrekten Fingerabdruck
zeigten); die "nie gepusht"-Meldung priorisiert Untersuchen statt Pushen.
Beim Testen selbst gefunden: der erste git-Aufruf war ungeschuetzt und warf bei
kaputtem Klon einen Stacktrace.

Verifiziert: Rewind 3->1 mit gekuerzter DB -> frischer Klon ohne Bezugspunkt OK
mit Vorbehalt, mit NOTARY_MIN_SEQ=3 -> Alarm exit 2; Pin mit Zeilenumbruch kein
Fehlalarm; regulaerer Lauf unveraendert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-21 21:46:02 +02:00
co-authored by Claude Opus 5
parent 8d2dfb8be1
commit d50d8f6036
3 changed files with 124 additions and 9 deletions
+38
View File
@@ -97,6 +97,44 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
## ✅ Erledigt ## ✅ 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) - [x] **📌 Gegenbuch: Fingerabdruck-Pin verpflichtend und vollstaendig angewandt (Pentest R177)** (2026-08-18)
- **R177-01 (MEDIUM)** Der Pin war optional. Ohne ihn war der - **R177-01 (MEDIUM)** Der Pin war optional. Ohne ihn war der
Vertrauensanker die gesamte `allowed_signers`-**Menge**, nicht der eine Vertrauensanker die gesamte `allowed_signers`-**Menge**, nicht der eine
+25 -3
View File
@@ -39,9 +39,31 @@ Als Cronjob, stündlich:
CRM_TOKEN=... node /pfad/notary.mjs >> notary.log 2>&1 CRM_TOKEN=... node /pfad/notary.mjs >> notary.log 2>&1
``` ```
**Force-Push serverseitig sperren.** Ohne das ist die Append-only-Eigenschaft **Force-Push serverseitig sperren das ist Pflicht, nicht Empfehlung.**
nur geliehen wer Schreibrecht auf das Repository erlangt, schreibt die
Historie sonst einfach um. Bei GitHub/GitLab: Branch-Protection auf `main`. 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 ## Der entscheidende Punkt: es wird tatsächlich geprüft
+61 -6
View File
@@ -55,7 +55,25 @@ const SIGN = process.env.NOTARY_SIGN !== 'false';
// Ist nichts gesetzt, wird der Pin aus `user.signingkey` abgeleitet. Klappt // Ist nichts gesetzt, wird der Pin aus `user.signingkey` abgeleitet. Klappt
// auch das nicht, wird abgebrochen statt stillschweigend die ganze Menge zu // auch das nicht, wird abgebrochen statt stillschweigend die ganze Menge zu
// akzeptieren. // 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') { if (!SIGN && process.env.NOTARY_INSECURE_ACK !== 'mir-ist-klar-dass-das-ungeschuetzt-ist') {
console.error( console.error(
'NOTARY_SIGN=false deaktiviert den gesamten Schutz des Gegenbuchs.\n' + 'NOTARY_SIGN=false deaktiviert den gesamten Schutz des Gegenbuchs.\n' +
@@ -156,12 +174,33 @@ async function hole(pfad) {
const OHNE_ANKER = () => const OHNE_ANKER = () =>
NUR_LOKAL ? ' ACHTUNG: nur lokal, kein externer Anker, also kein Manipulationsschutz.' : ''; 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); }; const alarm = (text) => { console.error(`ALARM: ${text}`); process.exit(2); };
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// 1) Vertrauenswuerdigen Stand bestimmen: bevorzugt der Remote-Kopf. // 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'; let ref = 'HEAD';
const hatRemote = gitStill('remote', 'get-url', REMOTE).ok; const hatRemote = gitStill('remote', 'get-url', REMOTE).ok;
@@ -204,8 +243,12 @@ if (ref !== 'HEAD') {
const voraus = gitStill('rev-list', '--count', `${ref}..HEAD`); const voraus = gitStill('rev-list', '--count', `${ref}..HEAD`);
if (voraus.ok && Number(voraus.out) > 0) { if (voraus.ok && Number(voraus.out) > 0) {
alarm( alarm(
`${voraus.out} lokale(r) Commit(s) wurden nie gepusht. Das Gegenbuch ist damit ` + `${voraus.out} lokale(r) Commit(s) wurden nie gepusht das Gegenbuch ist insoweit ` +
`nur lokal entweder pushen oder untersuchen, wie sie entstanden sind.`, `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. // 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, ` + `OK: ${bisher.length} Checkpoint(s) geprüft Signaturen gültig, Reihe lückenlos, ` +
`Arbeitsdatei deckungsgleich mit dem signierten Stand` + `Arbeitsdatei deckungsgleich mit dem signierten Stand` +
(letzter ? `, CRM stimmt mit Checkpoint ${letzter.seq} überein (maxId ${letzter.maxId}).` : '.') + (letzter ? `, CRM stimmt mit Checkpoint ${letzter.seq} überein (maxId ${letzter.maxId}).` : '.') +
OHNE_ANKER(), OHNE_ANKER() + REWIND_VORBEHALT(),
); );
process.exit(0); 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}`,
);