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
|
## ✅ 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)
|
- [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**
|
- **R176-01 (HIGH)** – Mein Signatur-Gate akzeptierte `%G?` = `G` **oder**
|
||||||
`U`. Bei SSH-Signaturen bedeutet `U` woertlich „gute Signatur, aber kein
|
`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
|
- **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
|
Code 3 ab, statt dem lokalen Stand zu glauben. Ein Gegenbuch, dessen
|
||||||
beglaubigter Stand sich nicht feststellen lässt, gibt kein grünes Licht.
|
beglaubigter Stand sich nicht feststellen lässt, gibt kein grünes Licht.
|
||||||
- Optional, aber empfohlen: `NOTARY_SIGNER_FINGERPRINT` auf den Fingerabdruck
|
- **Genau ein Schlüssel zählt, nicht die ganze Liste.** `allowed_signers` kann
|
||||||
des Notar-Schlüssels setzen (`git log -1 --format=%GF`). Dann muss jeder
|
mehrere Principals enthalten – ein Kollege, ein Alt-Schlüssel, eine global
|
||||||
Commit von genau diesem Schlüssel stammen.
|
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
|
## 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
|
// Reiner Pruefmodus fuer Auditoren: fuehrt alle Kontrollen aus, haengt aber
|
||||||
// nichts an und braucht kein Schreibrecht.
|
// nichts an und braucht kein Schreibrecht.
|
||||||
const NUR_PRUEFEN = process.argv.includes('--check') || process.env.NOTARY_CHECK_ONLY === 'true';
|
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
|
// Ohne Signatur ist das Gegenbuch wertlos: dann kann jeder mit Schreibrecht
|
||||||
// die Reihe faelschen. Der Schalter existiert nur fuer Tests und verlangt eine
|
// die Reihe faelschen. Der Schalter existiert nur fuer Tests und verlangt eine
|
||||||
// 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`).
|
// Fingerabdruck des erwarteten Signierschluessels (`%GF`).
|
||||||
const PIN = process.env.NOTARY_SIGNER_FINGERPRINT || null;
|
//
|
||||||
|
// 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') {
|
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' +
|
||||||
@@ -67,14 +80,82 @@ const gitStill = (...args) => {
|
|||||||
catch (e) { return { ok: false, out: String(e.stderr || e.message).trim() }; }
|
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) {
|
async function hole(pfad) {
|
||||||
const r = await fetch(`${CRM_URL}${pfad}`, { headers: { Authorization: `Bearer ${CRM_TOKEN}` } });
|
// Fehler werden hier zu einer erklaerenden Zeile – frueher flog ein
|
||||||
if (!r.ok) throw new Error(`${pfad} -> HTTP ${r.status}`);
|
// Node-Stacktrace hoch, also genau die kryptische erste Zeile, die fuer
|
||||||
const j = await r.json();
|
// git-Meldungen schon abgestellt war.
|
||||||
if (!j.success) throw new Error(`${pfad} -> ${j.error}`);
|
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;
|
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); };
|
const alarm = (text) => { console.error(`ALARM: ${text}`); process.exit(2); };
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -105,7 +186,11 @@ if (hatRemote) {
|
|||||||
);
|
);
|
||||||
process.exit(3);
|
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(
|
console.error(
|
||||||
`Kein Remote '${REMOTE}' konfiguriert. Ein Gegenbuch, das nur lokal liegt, schützt nichts –\n` +
|
`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` +
|
`es steht im selben Zugriffsbereich wie alles andere auf diesem Rechner.\n` +
|
||||||
@@ -250,7 +335,8 @@ if (NUR_PRUEFEN) {
|
|||||||
console.log(
|
console.log(
|
||||||
`OK: ${bisher.length} Checkpoint(s) geprüft – Signaturen gültig, Reihe lückenlos, ` +
|
`OK: ${bisher.length} Checkpoint(s) geprüft – Signaturen gültig, Reihe lückenlos, ` +
|
||||||
`Arbeitsdatei deckungsgleich mit dem signierten Stand` +
|
`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);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
@@ -291,10 +377,27 @@ if (SIGN) {
|
|||||||
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.`);
|
||||||
}
|
}
|
||||||
|
// 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 (!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);
|
const p = gitStill('push', REMOTE, zweig);
|
||||||
if (!p.ok) {
|
if (!p.ok) {
|
||||||
console.error(
|
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