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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user