diff --git a/docs/todo.md b/docs/todo.md index 89fa7d83..b33dacea 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -97,6 +97,37 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung ## ✅ Erledigt +- [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 + passender Principal“ – also: der Schluessel steht **nicht** in + `allowed_signers`. Damit passierte **jeder selbst erzeugte Schluessel** das + Gate, und der einzige In-System-Vertrauensanker war wirkungslos. Der + Pentester hat es end-to-end gefahren: Gegenbuch mit fremdem Schluessel + re-signiert und force-gepusht → „OK, Signaturen gültig“, exit 0. + Vorbedingung war nur Remote-Schreibrecht, kein Host-Zugriff. + Fix: **nur `G`** an beiden Stellen. Zusaetzlich optionales Pinnen des + erwarteten Signierschluessels ueber `NOTARY_SIGNER_FINGERPRINT` (`%GF`). + - **R176-02 (MEDIUM)** – War der Remote unerreichbar, fiel der Ablauf still + auf `HEAD` zurueck und die „nie gepusht“-Pruefung wurde uebersprungen – + ausgerechnet unter der Bedingung, die einen Push-Fehlschlag verursacht. + `--check` meldete waehrend eines Remote-Ausfalls gruenes Licht auf nicht + notarisiertem Zustand. Fix: **fail-closed**, Abbruch mit Code 3 statt dem + lokalen Stand zu glauben; ebenso, wenn gar kein Remote konfiguriert ist + (bewusster Testlauf nur mit `NOTARY_ALLOW_LOCAL=true`). + - Nebenbei: git-eigene Fehlermeldungen standen VOR der eigenen Erklaerung – + stderr wird jetzt abgefangen und gezielt weitergereicht. + - Verifiziert mit zwei SSH-Schluesseln (keyA vertrauenswuerdig, keyB nicht) + gegen echten Remote: Angriff mit keyB + Force-Push → **Alarm, exit 2** + (vorher „OK“); Remote unerreichbar → **exit 3**; kein Remote → exit 3; + falscher Fingerabdruck-Pin → Alarm; **saubere Historie: drei Laeufe und + Pruefmodus ohne Fehlalarm**. Alle Testartefakte geloescht, Port frei. + - Seine Antwort auf Frage (a) uebernommen: der Vergleich Arbeitsdatei ↔ + signierter Baum ist nicht umgehbar; `.trim()` auf beiden Seiten schluckt + nur Kosmetik. Notiert als Restrisiko: internes CRLF koennte bei + `core.autocrlf` auf dem Notar-Host einen Fehlalarm ausloesen – + Verfuegbarkeit, kein Integritaetsloch. + - [x] **🔏 Gegenbuch: verifizierender Leser statt Absichtserklaerung (Pentest R175)** (2026-08-18) - **R175-01 (HIGH)** – Der Kernsatz des Pentesters: „Append-only allein reicht nicht, es braucht einen verifizierenden Leser.“ Meine erste Fassung diff --git a/tools/audit-notary/README.md b/tools/audit-notary/README.md index 31b9e956..c65c1492 100644 --- a/tools/audit-notary/README.md +++ b/tools/audit-notary/README.md @@ -60,6 +60,33 @@ Deshalb gilt jetzt: - Geschrieben wird erst nach erfolgreichem, signiertem Commit – scheitert er, wird die Datei zurückgerollt +## Voraussetzungen, die wirklich Voraussetzungen sind + +- **`allowed_signers` muss den Notar-Schlüssel enthalten.** Das Skript + akzeptiert ausschließlich Signaturen, die git als `G` bewertet – bei + SSH-Signaturen heißt das wörtlich „Schlüssel steht in `allowed_signers`". + Ein fremder, selbst erzeugter Schlüssel liefert `U` und wird abgelehnt. + *(Bei GPG statt SSH liefert auch ein legitimer Schlüssel ohne Ownertrust ein + `U` – dann Ownertrust setzen, nicht `U` wieder zulassen.)* +- **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. + +## Rückgabecodes + +| Code | Bedeutung | +|---|---| +| 0 | alles in Ordnung, Checkpoint angehängt (bzw. Prüfung bestanden) | +| 1 | Betriebsfehler (Konfiguration, Commit oder Push fehlgeschlagen) | +| 2 | **Befund** – Widerspruch zwischen CRM und Gegenbuch, oder ungültige Signatur | +| 3 | beglaubigter Stand nicht feststellbar (Remote fehlt oder unerreichbar) | + +Für Cron gilt: **jeder** Code außer 0 gehört gemeldet. Code 2 ist der Alarm, +Code 3 heißt „ich weiß es nicht" – und das ist ausdrücklich kein Freibrief. + ## Prüfmodus für Auditoren ```bash diff --git a/tools/audit-notary/notary.mjs b/tools/audit-notary/notary.mjs index b7058c30..9557dcfd 100755 --- a/tools/audit-notary/notary.mjs +++ b/tools/audit-notary/notary.mjs @@ -41,6 +41,8 @@ const NUR_PRUEFEN = process.argv.includes('--check') || process.env.NOTARY_CHECK // 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; 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' + @@ -54,10 +56,15 @@ if (!CRM_URL || !CRM_TOKEN) { process.exit(1); } -const git = (...args) => execFileSync('git', args, { encoding: 'utf8' }).trim(); +// stderr wird bewusst abgefangen statt durchgereicht: sonst stehen git-eigene +// Fehlermeldungen VOR der Erklaerung dieses Skripts und der Betreiber liest +// zuerst etwas Kryptisches. Relevante git-Ausgaben geben wir gezielt weiter. +const GIT_STDIO = ['ignore', 'pipe', 'pipe']; +const git = (...args) => + execFileSync('git', args, { encoding: 'utf8', stdio: GIT_STDIO }).trim(); const gitStill = (...args) => { try { return { ok: true, out: git(...args) }; } - catch (e) { return { ok: false, out: String(e.stderr || e.message) }; } + catch (e) { return { ok: false, out: String(e.stderr || e.message).trim() }; } }; async function hole(pfad) { @@ -75,10 +82,36 @@ const alarm = (text) => { console.error(`ALARM: ${text}`); process.exit(2); }; // --------------------------------------------------------------------------- const zweig = git('rev-parse', '--abbrev-ref', 'HEAD'); let ref = 'HEAD'; -if (gitStill('remote', 'get-url', REMOTE).ok) { +const hatRemote = gitStill('remote', 'get-url', REMOTE).ok; + +if (hatRemote) { const f = gitStill('fetch', '--quiet', REMOTE); - if (!f.ok) console.warn(`HINWEIS: fetch von ${REMOTE} fehlgeschlagen – prüfe gegen lokalen Stand.\n${f.out}`); - else if (gitStill('rev-parse', '--verify', `${REMOTE}/${zweig}`).ok) ref = `${REMOTE}/${zweig}`; + if (f.ok && gitStill('rev-parse', '--verify', `${REMOTE}/${zweig}`).ok) { + ref = `${REMOTE}/${zweig}`; + } else { + // FAIL-CLOSED (Pentest R176-02): Frueher fiel der Ablauf hier still auf + // `HEAD` zurueck – ausgerechnet unter der Bedingung, die einen vorherigen + // Push-Fehlschlag verursacht hat. Ein nie gepushter Checkpoint galt dann + // als beglaubigt, und `--check` meldete waehrend eines Remote-Ausfalls + // gruenes Licht auf nicht notarisiertem Zustand. + // + // Ohne erreichbaren Remote laesst sich der beglaubigte Stand schlicht + // nicht feststellen. Das ist kein Grund, dem lokalen Stand zu glauben. + console.error( + `Der beglaubigte Stand ist nicht feststellbar: ${REMOTE}/${zweig} nicht erreichbar.\n` + + `Ohne Remote kann nicht entschieden werden, ob der lokale Stand jemals notarisiert wurde.\n` + + `Das ist ein Verfügbarkeitsproblem – kein Freibrief. Erst Verbindung klären, dann erneut prüfen.\n` + + (f.ok ? '' : ` git-Meldung: ${f.out}\n`), + ); + process.exit(3); + } +} else if (process.env.NOTARY_ALLOW_LOCAL !== 'true') { + 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` + + `Für einen bewussten lokalen Testlauf: NOTARY_ALLOW_LOCAL=true`, + ); + process.exit(3); } // Lokale Commits, die dem Remote voraus sind, sind nicht beglaubigt. @@ -99,14 +132,46 @@ const commits = gitStill('log', '--format=%H %G?', ref); if (!commits.ok) alarm(`Historie nicht lesbar: ${commits.out}`); const zeilen = commits.out.split('\n').filter((z) => z.trim()); if (SIGN) { - const schlecht = zeilen + // NUR `G` (Pentest R176-01). Bei SSH-Signaturen bedeutet `U` woertlich + // „gute Signatur, aber kein passender Principal“ – also: der Schluessel + // steht NICHT in `allowed_signers`. Wer `U` akzeptiert, akzeptiert jeden + // selbst erzeugten Schluessel und macht damit genau den Vertrauensanker + // wirkungslos, den er zu pruefen glaubt. Live gezeigt: mit einem fremden + // Schluessel re-signiertes, force-gepushtes Gegenbuch lief als „gültig“ + // durch. + // + // Hinweis fuer GPG statt SSH: dort liefert ein gueltiger, aber nicht + // ownertrusted Schluessel ebenfalls `U`. Dann Ownertrust auf den + // Notar-Schluessel setzen – NICHT `U` wieder zulassen. + const schlecht = zeilen .map((z) => z.split(' ')) - .filter(([, status]) => status !== 'G' && status !== 'U'); + .filter(([, status]) => status !== 'G'); // Der allererste (leere) init-Commit darf unsigniert sein, sonst nichts. const relevant = schlecht.filter(([sha]) => { const dateien = gitStill('show', '--name-only', '--format=', sha); return dateien.ok && dateien.out.includes(LEDGER_NAME); }); + // Optional schaerfer: den erwarteten Signierschluessel festnageln, statt sich + // allein auf die Bewertung von git zu verlassen. + if (PIN) { + const fremde = zeilen + .map(([, ] , i) => i) + .map((i) => zeilen[i].split(' ')[0]) + .filter((sha) => { + const dateien = gitStill('show', '--name-only', '--format=', sha); + if (!dateien.ok || !dateien.out.includes(LEDGER_NAME)) return false; + const fp = gitStill('log', '-1', '--format=%GF', sha); + return !fp.ok || fp.out.trim() !== PIN; + }); + if (fremde.length) { + alarm( + `${fremde.length} Commit(s) wurden mit einem anderen als dem festgelegten ` + + `Schlüssel signiert (erwartet: ${PIN}):\n ` + + fremde.map((sha) => sha.slice(0, 12)).join('\n '), + ); + } + } + if (relevant.length) { alarm( `${relevant.length} Commit(s) mit Gegenbuch-Änderungen tragen KEINE gültige Signatur:\n ` + @@ -222,7 +287,7 @@ try { // Absichtserklaerung. if (SIGN) { const st = git('log', '-1', '--format=%G?', 'HEAD'); - if (st !== 'G' && st !== 'U') { + if (st !== 'G') { git('reset', '--hard', 'HEAD~1'); alarm(`Der neue Commit trägt keine gültige Signatur (%G?=${st}). Zurückgerollt.`); }