Gegenbuch: Fingerabdruck-Pin verpflichtend und vollstaendig angewandt (R177)

R177-01 (MEDIUM): Der Pin war optional. Ohne ihn war der Vertrauensanker die
gesamte allowed_signers-Menge, nicht der eine Notar-Schluessel: ein zweiter
dort gelisteter Schluessel konnte das Gegenbuch waschen und force-pushen, und
%G? war G. R176-01 hatte "jeder selbst erzeugte Schluessel" geschlossen, "jeder
erlaubte Schluessel" blieb offen. Fix: Pin wird aus user.signingkey abgeleitet;
laesst er sich nicht bestimmen, wird abgebrochen statt die ganze Liste zu
akzeptieren.

R177-02 (MEDIUM): Der Schreib-Lauf prueft den frischen Commit nur auf %G?,
nicht auf den Pin. Ein Notar-Host mit falsch konfiguriertem Schluessel meldete
"beglaubigt" und pushte - und ab da war die Kette dauerhaft rot, behebbar nur
per Force-Push, den die Branch-Protection gerade verhindern soll. Fix:
Pin-Abgleich am frischen Commit vor dem Push, bei Abweichung Ruecknahme.

Kleinkram: NOTARY_ALLOW_LOCAL faerbt Erfolgsmeldungen ein und pusht nicht mehr
ins Leere; CRM-Fehler liefern eine erklaerende Zeile statt Node-Stacktrace.

Verifiziert mit drei SSH-Schluesseln gegen echten Remote: keyC-Angriff -> Alarm
exit 2 auch ohne gesetzten Pin; Schreiblauf mit falschem Schluessel ->
zurueckgerollt, nichts gepusht; CRM nicht erreichbar/401 -> saubere Meldung;
Lokalmodus eingefaerbt; saubere Historie ohne Fehlalarm.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-21 21:09:58 +02:00
co-authored by Claude Opus 5
parent 5952fb1894
commit 8d2dfb8be1
3 changed files with 163 additions and 13 deletions
+16 -3
View File
@@ -71,9 +71,22 @@ Deshalb gilt jetzt:
- **Ein Remote muss erreichbar sein.** Ist er es nicht, bricht das Skript mit
Code 3 ab, statt dem lokalen Stand zu glauben. Ein Gegenbuch, dessen
beglaubigter Stand sich nicht feststellen lässt, gibt kein grünes Licht.
- Optional, aber empfohlen: `NOTARY_SIGNER_FINGERPRINT` auf den Fingerabdruck
des Notar-Schlüssels setzen (`git log -1 --format=%GF`). Dann muss jeder
Commit von genau diesem Schlüssel stammen.
- **Genau ein Schlüssel zählt, nicht die ganze Liste.** `allowed_signers` kann
mehrere Principals enthalten ein Kollege, ein Alt-Schlüssel, eine global
geteilte Datei. Jeder davon würde sonst als „gültig" durchgehen und könnte
das Gegenbuch umschreiben. Deshalb prüft das Skript zusätzlich den
**Fingerabdruck** des Signierschlüssels.
Er wird automatisch aus `user.signingkey` abgeleitet. Lässt er sich nicht
bestimmen, bricht das Skript ab statt stillschweigend die ganze Liste zu
akzeptieren. Explizit setzen geht auch:
```bash
export NOTARY_SIGNER_FINGERPRINT=$(git log -1 --format=%GF)
```
Wichtig: exakt der `%GF`-Wert (`SHA256:…`), nicht die vollständige Ausgabe
von `ssh-keygen -lf`.
## Rückgabecodes
+113 -10
View File
@@ -35,14 +35,27 @@ const REMOTE = process.env.NOTARY_REMOTE || 'origin';
// Reiner Pruefmodus fuer Auditoren: fuehrt alle Kontrollen aus, haengt aber
// nichts an und braucht kein Schreibrecht.
const NUR_PRUEFEN = process.argv.includes('--check') || process.env.NOTARY_CHECK_ONLY === 'true';
// Wird gesetzt, wenn ohne externen Anker gearbeitet wird faerbt jede
// Erfolgsmeldung ein (Pentest R177, Kleinkram).
let NUR_LOKAL = false;
// Ohne Signatur ist das Gegenbuch wertlos: dann kann jeder mit Schreibrecht
// die Reihe faelschen. Der Schalter existiert nur fuer Tests und verlangt eine
// zusaetzliche, bewusste Bestaetigung der Reflex "Cron hakt, also abschalten"
// soll nicht ausreichen.
const SIGN = process.env.NOTARY_SIGN !== 'false';
// Optionaler Fingerabdruck des erwarteten Signierschluessels (`%GF`).
const PIN = process.env.NOTARY_SIGNER_FINGERPRINT || null;
// Fingerabdruck des erwarteten Signierschluessels (`%GF`).
//
// NICHT optional (Pentest R177-01): Ohne Pin ist der Vertrauensanker die
// gesamte `allowed_signers`-MENGE, nicht der eine Notar-Schluessel. Jeder dort
// gelistete Zweitschluessel anderer Nutzer, Alt-Key, global geteilte Datei
// kann das Gegenbuch dann waschen und force-pushen, und die Pruefung meldet
// „gültig“. Live gezeigt.
//
// 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;
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' +
@@ -67,14 +80,82 @@ const gitStill = (...args) => {
catch (e) { return { ok: false, out: String(e.stderr || e.message).trim() }; }
};
// Pin aus der lokalen Signierkonfiguration ableiten, wenn nicht vorgegeben.
if (SIGN && !PIN) {
const konf = gitStill('config', 'user.signingkey');
const kandidat = konf.ok ? konf.out.trim().replace(/^key::/, '') : '';
if (kandidat && existsSync(kandidat)) {
try {
// `ssh-keygen -lf` liefert z. B. "256 SHA256:abc… kommentar (ED25519)".
// Gebraucht wird genau das mittlere Feld dasselbe Format, das git als
// %GF ausgibt.
const zeile = execFileSync('ssh-keygen', ['-lf', kandidat], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
}).trim();
const treffer = zeile.split(/\s+/).find((t) => t.startsWith('SHA256:'));
if (treffer) PIN = treffer;
} catch {
/* faellt unten in den Abbruch */
}
}
if (!PIN) {
console.error(
'Der erwartete Signierschlüssel steht nicht fest.\n' +
'Ohne ihn wäre der Vertrauensanker die gesamte allowed_signers-Liste und nicht\n' +
'der eine Notar-Schlüssel jeder dort gelistete Zweitschlüssel könnte das\n' +
'Gegenbuch umschreiben, ohne dass es auffällt.\n' +
'Abhilfe: NOTARY_SIGNER_FINGERPRINT setzen (git log -1 --format=%GF)\n' +
'oder user.signingkey auf die .pub-Datei des Notar-Schlüssels zeigen lassen.',
);
process.exit(1);
}
}
async function hole(pfad) {
const r = await fetch(`${CRM_URL}${pfad}`, { headers: { Authorization: `Bearer ${CRM_TOKEN}` } });
if (!r.ok) throw new Error(`${pfad} -> HTTP ${r.status}`);
const j = await r.json();
if (!j.success) throw new Error(`${pfad} -> ${j.error}`);
// Fehler werden hier zu einer erklaerenden Zeile frueher flog ein
// Node-Stacktrace hoch, also genau die kryptische erste Zeile, die fuer
// git-Meldungen schon abgestellt war.
let r;
try {
r = await fetch(`${CRM_URL}${pfad}`, { headers: { Authorization: `Bearer ${CRM_TOKEN}` } });
} catch (e) {
console.error(
`Das CRM ist nicht erreichbar (${CRM_URL}).\n` +
` Grund: ${e instanceof Error ? e.message : String(e)}\n` +
'Ohne aktuellen Kontrollwert kann nichts beglaubigt werden das ist ein ' +
'Verfügbarkeitsproblem, kein Befund.',
);
process.exit(1);
}
if (r.status === 401 || r.status === 403) {
console.error(
`Das CRM hat den Zugriff abgelehnt (HTTP ${r.status}).\n` +
'Meist ist der Token abgelaufen oder ihm fehlt das Recht audit:read.',
);
process.exit(1);
}
if (!r.ok) {
console.error(`Unerwartete Antwort des CRM: ${pfad} -> HTTP ${r.status}`);
process.exit(1);
}
let j;
try {
j = await r.json();
} catch {
console.error(`Antwort des CRM ist kein JSON (${pfad}) steht ein Proxy oder eine Fehlerseite davor?`);
process.exit(1);
}
if (!j.success) {
console.error(`Das CRM meldet einen Fehler: ${j.error}`);
process.exit(1);
}
return j.data;
}
const OHNE_ANKER = () =>
NUR_LOKAL ? ' ACHTUNG: nur lokal, kein externer Anker, also kein Manipulationsschutz.' : '';
const alarm = (text) => { console.error(`ALARM: ${text}`); process.exit(2); };
// ---------------------------------------------------------------------------
@@ -105,7 +186,11 @@ if (hatRemote) {
);
process.exit(3);
}
} else if (process.env.NOTARY_ALLOW_LOCAL !== 'true') {
} else if (process.env.NOTARY_ALLOW_LOCAL === 'true') {
// Der Schalter darf nicht zu einem glatten „OK“ fuehren: Leckt er je ins
// Produktivumfeld, gaebe es gruenes Licht auf nicht notarisiertem Stand.
NUR_LOKAL = true;
} else {
console.error(
`Kein Remote '${REMOTE}' konfiguriert. Ein Gegenbuch, das nur lokal liegt, schützt nichts \n` +
`es steht im selben Zugriffsbereich wie alles andere auf diesem Rechner.\n` +
@@ -250,7 +335,8 @@ if (NUR_PRUEFEN) {
console.log(
`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}).` : '.'),
(letzter ? `, CRM stimmt mit Checkpoint ${letzter.seq} überein (maxId ${letzter.maxId}).` : '.') +
OHNE_ANKER(),
);
process.exit(0);
}
@@ -291,10 +377,27 @@ if (SIGN) {
git('reset', '--hard', 'HEAD~1');
alarm(`Der neue Commit trägt keine gültige Signatur (%G?=${st}). Zurückgerollt.`);
}
// Auch der FRISCHE Commit muss vom festgelegten Schluessel stammen
// (Pentest R177-02). Vorher wurde hier nur `%G?` geprueft: Ein Notar-Host
// mit falsch konfiguriertem Schluessel meldete „beglaubigt“ und pushte
// und ab da war die Kette bei jeder Folgepruefung dauerhaft rot, behebbar
// nur per Force-Push, den die Branch-Protection gerade verhindern soll.
// Deshalb: pruefen und zuruecknehmen, BEVOR gepusht wird.
if (PIN) {
const fp = git('log', '-1', '--format=%GF', 'HEAD').trim();
if (fp !== PIN) {
git('reset', '--hard', 'HEAD~1');
alarm(
`Der neue Commit wurde mit einem anderen Schlüssel signiert als festgelegt.\n` +
` erwartet: ${PIN}\n verwendet: ${fp}\n` +
`Zurückgerollt, nichts gepusht bitte user.signingkey auf dem Notar-Rechner prüfen.`,
);
}
}
}
if (!SIGN) console.warn('WARNUNG: Commit wurde NICHT signiert das Gegenbuch ist ungeschützt.');
if (PUSH) {
if (PUSH && !NUR_LOKAL) {
const p = gitStill('push', REMOTE, zweig);
if (!p.ok) {
console.error(
@@ -304,4 +407,4 @@ if (PUSH) {
}
}
console.log(`OK: Checkpoint ${eintrag.seq} beglaubigt (maxId ${eintrag.maxId}).`);
console.log(`OK: Checkpoint ${eintrag.seq} beglaubigt (maxId ${eintrag.maxId}).` + OHNE_ANKER());