diff --git a/docs/todo.md b/docs/todo.md index b33dacea..0bbf270f 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -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 diff --git a/tools/audit-notary/README.md b/tools/audit-notary/README.md index c65c1492..893d381d 100644 --- a/tools/audit-notary/README.md +++ b/tools/audit-notary/README.md @@ -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 diff --git a/tools/audit-notary/notary.mjs b/tools/audit-notary/notary.mjs index 9557dcfd..81b573ab 100755 --- a/tools/audit-notary/notary.mjs +++ b/tools/audit-notary/notary.mjs @@ -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());