Gegenbuch: nur vertrauenswuerdige Signaturen, fail-closed ohne Remote (R176)

R176-01 (HIGH): Das Signatur-Gate akzeptierte %G? = G ODER U. Bei
SSH-Signaturen bedeutet U woertlich "gute Signatur, aber kein passender
Principal" - der Schluessel steht also NICHT in allowed_signers. Damit
passierte jeder selbst erzeugte Schluessel das Gate und der einzige
In-System-Vertrauensanker war wirkungslos. End-to-end reproduziert: Gegenbuch
mit fremdem Schluessel re-signiert und force-gepusht -> "OK", exit 0.
Vorbedingung war nur Remote-Schreibrecht, kein Host-Zugriff.
Fix: nur G an beiden Stellen, dazu 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 Ausfalls gruenes Licht auf nicht notarisiertem Zustand. Fix: fail-closed
mit Code 3, ebenso ohne konfigurierten Remote (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 gegen echten Remote: Angriff mit fremdem
Key + Force-Push -> Alarm exit 2 (vorher OK); Remote unerreichbar -> exit 3;
kein Remote -> exit 3; falscher Fingerabdruck-Pin -> Alarm; saubere Historie
ohne Fehlalarm. Rueckgabecodes in der README dokumentiert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-21 20:26:55 +02:00
co-authored by Claude Opus 5
parent 375d4ae1e2
commit 5952fb1894
3 changed files with 131 additions and 8 deletions
+31
View File
@@ -97,6 +97,37 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
## ✅ Erledigt ## ✅ 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) - [x] **🔏 Gegenbuch: verifizierender Leser statt Absichtserklaerung (Pentest R175)** (2026-08-18)
- **R175-01 (HIGH)** Der Kernsatz des Pentesters: „Append-only allein - **R175-01 (HIGH)** Der Kernsatz des Pentesters: „Append-only allein
reicht nicht, es braucht einen verifizierenden Leser.“ Meine erste Fassung reicht nicht, es braucht einen verifizierenden Leser.“ Meine erste Fassung
+27
View File
@@ -60,6 +60,33 @@ Deshalb gilt jetzt:
- Geschrieben wird erst nach erfolgreichem, signiertem Commit scheitert er, - Geschrieben wird erst nach erfolgreichem, signiertem Commit scheitert er,
wird die Datei zurückgerollt 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 ## Prüfmodus für Auditoren
```bash ```bash
+73 -8
View File
@@ -41,6 +41,8 @@ const NUR_PRUEFEN = process.argv.includes('--check') || process.env.NOTARY_CHECK
// zusaetzliche, bewusste Bestaetigung der Reflex "Cron hakt, also abschalten" // zusaetzliche, bewusste Bestaetigung der Reflex "Cron hakt, also abschalten"
// soll nicht ausreichen. // soll nicht ausreichen.
const SIGN = process.env.NOTARY_SIGN !== 'false'; 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') { if (!SIGN && process.env.NOTARY_INSECURE_ACK !== 'mir-ist-klar-dass-das-ungeschuetzt-ist') {
console.error( console.error(
'NOTARY_SIGN=false deaktiviert den gesamten Schutz des Gegenbuchs.\n' + 'NOTARY_SIGN=false deaktiviert den gesamten Schutz des Gegenbuchs.\n' +
@@ -54,10 +56,15 @@ if (!CRM_URL || !CRM_TOKEN) {
process.exit(1); 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) => { const gitStill = (...args) => {
try { return { ok: true, out: git(...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) { 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'); const zweig = git('rev-parse', '--abbrev-ref', 'HEAD');
let 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); const f = gitStill('fetch', '--quiet', REMOTE);
if (!f.ok) console.warn(`HINWEIS: fetch von ${REMOTE} fehlgeschlagen prüfe gegen lokalen Stand.\n${f.out}`); if (f.ok && gitStill('rev-parse', '--verify', `${REMOTE}/${zweig}`).ok) {
else if (gitStill('rev-parse', '--verify', `${REMOTE}/${zweig}`).ok) ref = `${REMOTE}/${zweig}`; 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. // 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}`); if (!commits.ok) alarm(`Historie nicht lesbar: ${commits.out}`);
const zeilen = commits.out.split('\n').filter((z) => z.trim()); const zeilen = commits.out.split('\n').filter((z) => z.trim());
if (SIGN) { 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(' ')) .map((z) => z.split(' '))
.filter(([, status]) => status !== 'G' && status !== 'U'); .filter(([, status]) => status !== 'G');
// Der allererste (leere) init-Commit darf unsigniert sein, sonst nichts. // Der allererste (leere) init-Commit darf unsigniert sein, sonst nichts.
const relevant = schlecht.filter(([sha]) => { const relevant = schlecht.filter(([sha]) => {
const dateien = gitStill('show', '--name-only', '--format=', sha); const dateien = gitStill('show', '--name-only', '--format=', sha);
return dateien.ok && dateien.out.includes(LEDGER_NAME); 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) { if (relevant.length) {
alarm( alarm(
`${relevant.length} Commit(s) mit Gegenbuch-Änderungen tragen KEINE gültige Signatur:\n ` + `${relevant.length} Commit(s) mit Gegenbuch-Änderungen tragen KEINE gültige Signatur:\n ` +
@@ -222,7 +287,7 @@ try {
// Absichtserklaerung. // Absichtserklaerung.
if (SIGN) { if (SIGN) {
const st = git('log', '-1', '--format=%G?', 'HEAD'); const st = git('log', '-1', '--format=%G?', 'HEAD');
if (st !== 'G' && st !== 'U') { if (st !== 'G') {
git('reset', '--hard', 'HEAD~1'); git('reset', '--hard', 'HEAD~1');
alarm(`Der neue Commit trägt keine gültige Signatur (%G?=${st}). Zurückgerollt.`); alarm(`Der neue Commit trägt keine gültige Signatur (%G?=${st}). Zurückgerollt.`);
} }