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