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
+25 -3
View File
@@ -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
+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
// 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}`,
);