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