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:
@@ -97,6 +97,40 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
|
||||
|
||||
## ✅ Erledigt
|
||||
|
||||
- [x] **📌 Gegenbuch: Fingerabdruck-Pin verpflichtend und vollstaendig angewandt (Pentest R177)** (2026-08-18)
|
||||
- **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 (Kollege,
|
||||
Alt-Key, geteilte Datei) konnte das Gegenbuch waschen, force-pushen – und
|
||||
`%G?` war `G`. Live gezeigt, exit 0. R176-01 hatte „jeder selbst erzeugte
|
||||
Schluessel“ geschlossen, „jeder **erlaubte** Schluessel“ blieb offen.
|
||||
Fix: Pin wird aus `user.signingkey` automatisch abgeleitet
|
||||
(`ssh-keygen -lf` → `SHA256:…`); 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
|
||||
bei jeder Folgepruefung dauerhaft rot, behebbar nur per Force-Push, den
|
||||
die Branch-Protection gerade verhindern soll. Selbst-DoS ohne Signal.
|
||||
Fix: Pin-Abgleich am frischen Commit **vor** dem Push, bei Abweichung
|
||||
Ruecknahme und Abbruch.
|
||||
- Kleinkram aus seinem Bericht: `NOTARY_ALLOW_LOCAL=true` faerbt jede
|
||||
Erfolgsmeldung jetzt ein („nur lokal, kein externer Anker“) und pusht
|
||||
nicht mehr ins Leere; CRM-Fehler (nicht erreichbar, 401/403, kein JSON)
|
||||
liefern eine erklaerende Zeile statt eines Node-Stacktrace.
|
||||
- Verifiziert mit drei SSH-Schluesseln gegen echten Remote: keyC-Angriff
|
||||
(in `allowed_signers`, aber nicht der Notar-Key) → **Alarm exit 2 auch
|
||||
ohne gesetzten Pin**; Schreiblauf mit falschem Schluessel → zurueckgerollt,
|
||||
**nichts gepusht**, keine Geisterzeile; CRM nicht erreichbar / 401 →
|
||||
saubere Meldung, exit 1; Lokalmodus → Erfolgsmeldung eingefaerbt;
|
||||
**saubere Historie: vier Laeufe plus Pruefmodus ohne Fehlalarm**.
|
||||
Alle Testartefakte geloescht, Ports frei.
|
||||
- Seine Antworten uebernommen: (a) `%GF` ist kryptographisch an die Signatur
|
||||
gebunden und nicht faelschbar – Non-Finding; (b) Code 3 bleibt getrennt von
|
||||
Code 2, „ich weiss es nicht“ darf nicht als „Manipulation“ gemeldet werden;
|
||||
(c) „Notar-Host **und** Key kompromittiert“ bleibt bewusst offen – dagegen
|
||||
hilft nur Branch-Protection am Remote, im Skript nicht erzwingbar.
|
||||
|
||||
- [x] **🔑 Gegenbuch: nur wirklich vertrauenswuerdige Signaturen, fail-closed ohne Remote (Pentest R176)** (2026-08-18)
|
||||
- **R176-01 (HIGH)** – Mein Signatur-Gate akzeptierte `%G?` = `G` **oder**
|
||||
`U`. Bei SSH-Signaturen bedeutet `U` woertlich „gute Signatur, aber kein
|
||||
|
||||
@@ -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
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user