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:
+113
-10
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user