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