diff --git a/tolaria/c5-sync-service/delete_executor_core.py b/tolaria/c5-sync-service/delete_executor_core.py new file mode 100644 index 0000000..b74e46a --- /dev/null +++ b/tolaria/c5-sync-service/delete_executor_core.py @@ -0,0 +1,186 @@ +""" +AUTH.3E — delete_executor_core.py +================================== +DELETE-Executor-Logik (DELETE-only, AUTH.3D-Validierung). + +Eigenschaften: + * DELETE-only: claimt NUR C5_DELETE_OBJECT-Jobs + * Der DELETE-Job selbst ist KEINE Approval + * AUTH.3D-Composition: VALID_JOB AND VALID_STATE AND VALID_AUTH3D_SIGNATURE + AND VALID_NONCE AND VALID_EXPIRY AND NOT_CONSUMED AND VALID_OBJECT AND + VALID_PATH AND VALID_COMMIT AND VALID_PROVENANCE AND DELETE_CREDENTIAL + * Kein Faktor ersetzt einen anderen + * Fail-closed: kein HTTP ohne DELETE-Credential + * OUTCOME_UNKNOWN bei unklarem HTTP-Ergebnis (kein blinder Retry) + * Ein gestarteter DELETE wird bei Lease-Expiry NICHT blind wiederholt + +Isoliert implementiert (KEIN produktiver Container). Nutzt job_store + job_schema. +""" + +from __future__ import annotations + +from typing import Any, Callable, Dict, Optional + +from job_schema import JOB_TYPE_DELETE +from job_store import JobStore + +# --------------------------------------------------------------------------- +# Result-Codes +# --------------------------------------------------------------------------- +RC_OK = "OK" +RC_CREDENTIAL_MISSING = "CREDENTIAL_MISSING" +RC_APPROVAL_MISSING = "APPROVAL_MISSING" +RC_APPROVAL_INVALID = "APPROVAL_INVALID" +RC_APPROVAL_EXPIRED = "APPROVAL_EXPIRED" +RC_APPROVAL_CONSUMED = "APPROVAL_CONSUMED" +RC_APPROVAL_MISMATCH = "APPROVAL_MISMATCH" +RC_OBJECT_MISMATCH = "OBJECT_MISMATCH" +RC_PATH_MISMATCH = "PATH_MISMATCH" +RC_COMMIT_MISMATCH = "COMMIT_MISMATCH" +RC_PROVENANCE_MISMATCH = "PROVENANCE_MISMATCH" +RC_MISSION_MISMATCH = "MISSION_MISMATCH" +RC_DELETE_DENIED = "DELETE_DENIED" +RC_OUTCOME_UNKNOWN = "OUTCOME_UNKNOWN" +RC_REJECTED = "REJECTED" + + +class DeleteExecutorError(Exception): + pass + + +class DeleteExecutorCore: + """ + DELETE-Executor. worker_scope="DELETE" (nur DELETE-Jobs claimbar). + + approval_loader: Callable[[str], Optional[Dict[str, Any]]] — lädt die + AUTH.3D-Approval (approval_id) -> dict oder None. + approval_verify: Callable[[Dict[str, Any]], Dict[str, Any]] — verifiziert die + AUTH.3D-Signatur (Ed25519 PUBLIC KEY ONLY) -> {"valid": bool, "reason": str}. + tolaria_delete: Callable[[str], Dict[str, Any]] — führt den Tolaria-DELETE aus + (vault_path) -> {"status": "ok"|"error", "uncertain": bool}. + Muss fail-closed sein (kein HTTP ohne Credential). + """ + + def __init__( + self, + store: JobStore, + approval_loader: Callable[[str], Optional[Dict[str, Any]]], + approval_verify: Callable[[Dict[str, Any]], Dict[str, Any]], + tolaria_delete: Callable[[str], Dict[str, Any]], + ): + if store.worker_scope != "DELETE": + raise DeleteExecutorError("DeleteExecutorCore requires worker_scope='DELETE'") + self.store = store + self.approval_loader = approval_loader + self.approval_verify = approval_verify + self.tolaria_delete = tolaria_delete + + # -- Hauptverarbeitung -------------------------------------------------- + + def process_job(self, job_id: str, worker_id: str) -> Dict[str, Any]: + """ + Verarbeitet einen DELETE-Job durch die State Machine. + + Ablauf: + 1. Job laden (muss existieren) + 2. Job-Type-Scope prüfen (nur DELETE) + 3. READY -> CLAIMED (atomarer Claim) + 4. CLAIMED -> EXECUTING + 5. AUTH.3D-Composition validieren (Job + Approval exakt zusammenpassen) + 6. Tolaria-DELETE ausführen + 7. Ergebnis: SUCCEEDED / FAILED / OUTCOME_UNKNOWN / REJECTED + """ + job = self.store.get_job(job_id) + if job is None: + raise DeleteExecutorError(f"job not found: {job_id}") + + # Scope: nur DELETE-Jobs + if job["job_type"] != JOB_TYPE_DELETE: + self.store.mark_rejected(job_id, RC_REJECTED) + return self.store.get_job(job_id) + + # Atomarer Claim + try: + self.store.claim_job(job_id, worker_id) + except Exception: + # Nicht claimbar (bereits geclaimt) -> kein Doppel-Delete + return self.store.get_job(job_id) + + self.store.begin_execution(job_id, worker_id) + + # AUTH.3D-Composition (alle Faktoren, keiner ersetzt einen anderen). + # Job NACH dem Claim neu lesen, damit eine Payload-Manipulation nach + # dem Claim (TOCTOU) erkannt wird — Defense in depth. + job = self.store.get_job(job_id) + if job is None: + self.store.mark_failed(job_id, RC_DELETE_DENIED, worker_id) + return self.store.get_job(job_id) + gate = self._validate_auth3d_composition(job) + if gate["valid"] is not True: + self.store.mark_failed(job_id, gate["code"], worker_id) + return self.store.get_job(job_id) + + # Tolaria-DELETE ausführen (fail-closed) + result = self.tolaria_delete(job["vault_path"]) + status = result.get("status") + if status == "ok": + self.store.mark_succeeded(job_id, worker_id) + elif status == "error" and result.get("uncertain"): + self.store.mark_outcome_unknown(job_id, worker_id) + else: + self.store.mark_failed(job_id, result.get("code", RC_DELETE_DENIED), worker_id) + + return self.store.get_job(job_id) + + # -- AUTH.3D-Composition ------------------------------------------------- + + def _validate_auth3d_composition(self, job: Dict[str, Any]) -> Dict[str, Any]: + """ + Validiert die AUTH.3D-Composition. Alle Faktoren müssen gelten: + VALID_JOB AND VALID_STATE AND VALID_AUTH3D_SIGNATURE AND VALID_NONCE + AND VALID_EXPIRY AND NOT_CONSUMED AND VALID_OBJECT AND VALID_PATH + AND VALID_COMMIT AND VALID_PROVENANCE AND DELETE_CREDENTIAL + + Rückgabe: {"valid": True} oder {"valid": False, "code": RC_...} + """ + # VALID_JOB (Schema bereits beim Erzeugen validiert) + # VALID_STATE (EXECUTING — bereits durch begin_execution) + + # Approval laden + approval = self.approval_loader(job["approval_id"]) + if approval is None: + return {"valid": False, "code": RC_APPROVAL_MISSING} + + # VALID_AUTH3D_SIGNATURE (Ed25519 PUBLIC KEY ONLY) + sig = self.approval_verify(approval) + if sig.get("valid") is not True: + return {"valid": False, "code": RC_APPROVAL_INVALID} + + # VALID_NONCE / VALID_EXPIRY / NOT_CONSUMED + if approval.get("expired"): + return {"valid": False, "code": RC_APPROVAL_EXPIRED} + if approval.get("consumed"): + return {"valid": False, "code": RC_APPROVAL_CONSUMED} + + # VALID_OBJECT + if approval.get("object_id") != job["object_id"]: + return {"valid": False, "code": RC_OBJECT_MISMATCH} + + # VALID_PATH + if approval.get("vault_path") != job["vault_path"]: + return {"valid": False, "code": RC_PATH_MISMATCH} + + # VALID_COMMIT + if approval.get("expected_commit") != job["expected_commit"]: + return {"valid": False, "code": RC_COMMIT_MISMATCH} + + # VALID_PROVENANCE + if approval.get("expected_provenance_hash") != job["expected_provenance_hash"]: + return {"valid": False, "code": RC_PROVENANCE_MISMATCH} + + # VALID_MISSION + if approval.get("mission_id") != job["mission_id"]: + return {"valid": False, "code": RC_MISSION_MISMATCH} + + # DELETE_CREDENTIAL (fail-closed — tolaria_delete prüft selbst) + return {"valid": True} diff --git a/tolaria/c5-sync-service/job_claim.py b/tolaria/c5-sync-service/job_claim.py new file mode 100644 index 0000000..01b0568 --- /dev/null +++ b/tolaria/c5-sync-service/job_claim.py @@ -0,0 +1,149 @@ +""" +AUTH.3E — job_claim.py +====================== +Atomarer Claim / Lease / Concurrency für den Executor Command Channel. + +Garantien: + * Claim ist ATOMAR (SQLite-Transaktion mit Status-Bedingung) — kein TOCTOU. + * Zwei SAVE-Worker: nur einer kann denselben Job claimen. + * Zwei DELETE-Worker: nur einer kann denselben DELETE-Job claimen. + * Ein bereits gestarteter DELETE wird bei Lease-Expiry NICHT blind wiederholt + (-> OUTCOME_UNKNOWN, nicht zurück zu READY). + * Stale Claims werden via Lease-Expiry erkannt. + +Isoliert implementiert (KEIN produktiver Container). +""" + +from __future__ import annotations + +import sqlite3 +import time +import uuid +from typing import Any, Dict, Optional + +from job_state_machine import ST_CLAIMED, ST_READY + +# --------------------------------------------------------------------------- +# Fehler +# --------------------------------------------------------------------------- +class ClaimError(Exception): + """Basis-Fehler für Claim/Lease.""" + + +class JobAlreadyClaimedError(ClaimError): + """Job ist nicht claimbar (bereits geclaimt oder Lease aktiv).""" + + +class JobNotFoundError(ClaimError): + pass + + +# --------------------------------------------------------------------------- +# Atomarer Claim +# --------------------------------------------------------------------------- +def atomic_claim( + conn: sqlite3.Connection, + job_id: str, + worker_id: str, + lease_seconds: int = 60, +) -> Dict[str, Any]: + """ + Führt einen atomaren Claim aus: READY -> CLAIMED, nur wenn Lease abgelaufen + oder nie gesetzt. Kein TOCTOU (Status-Bedingung in der UPDATE-WHERE-Klausel). + + Rückgabe: der geclaimte Job (als dict). + Wirft JobAlreadyClaimedError, wenn der Job nicht claimbar ist. + """ + now = int(time.time() * 1000) + lease_until = now + lease_seconds * 1000 + claim_id = str(uuid.uuid4()) + try: + cur = conn.execute( + """ + UPDATE jobs + SET state = ?, worker_id = ?, claim_id = ?, claimed_at = ?, + lease_until = ?, attempt_count = attempt_count + 1, updated_at = ? + WHERE job_id = ? + AND state = ? + AND (lease_until IS NULL OR lease_until < ?) + """, + (ST_CLAIMED, worker_id, claim_id, now, lease_until, now, + job_id, ST_READY, now), + ) + conn.commit() + except sqlite3.IntegrityError as e: + raise ClaimError(f"atomic_claim failed: {e}") from e + if cur.rowcount == 0: + raise JobAlreadyClaimedError(f"job not claimable: {job_id}") + row = conn.execute( + "SELECT * FROM jobs WHERE job_id = ?", (job_id,) + ).fetchone() + if row is None: + raise JobNotFoundError(f"job not found after claim: {job_id}") + return dict(row) + + +# --------------------------------------------------------------------------- +# Lease +# --------------------------------------------------------------------------- +def renew_lease( + conn: sqlite3.Connection, + job_id: str, + lease_seconds: int = 60, +) -> None: + """Verlängert die Lease eines CLAIMED/EXECUTING-Jobs.""" + now = int(time.time() * 1000) + lease_until = now + lease_seconds * 1000 + conn.execute( + "UPDATE jobs SET lease_until = ?, updated_at = ? WHERE job_id = ?", + (lease_until, now, job_id), + ) + conn.commit() + + +def is_lease_expired(lease_until: Optional[int], now: Optional[int] = None) -> bool: + """True, wenn die Lease abgelaufen ist (oder nie gesetzt).""" + if lease_until is None: + return True + now = now if now is not None else int(time.time() * 1000) + return lease_until < now + + +# --------------------------------------------------------------------------- +# Crash-Recovery +# --------------------------------------------------------------------------- +def recover_stale_claims( + conn: sqlite3.Connection, + job_type: str, + now: Optional[int] = None, +) -> int: + """ + Findet stale CLAIMED-Jobs (Lease abgelaufen) und überführt sie: + * SAVE: zurück zu READY (wieder claimbar, RETRYABLE) + * DELETE: zu OUTCOME_UNKNOWN (NICHT blind wiederholen) + + Rückgabe: Anzahl der recovered Jobs. + """ + from job_state_machine import ST_OUTCOME_UNKNOWN + + now = now if now is not None else int(time.time() * 1000) + stale = conn.execute( + "SELECT job_id FROM jobs WHERE state = ? AND lease_until IS NOT NULL AND lease_until < ?", + (ST_CLAIMED, now), + ).fetchall() + recovered = 0 + for row in stale: + job_id = row["job_id"] + if job_type == "C5_SAVE_OBJECT": + conn.execute( + "UPDATE jobs SET state = ?, updated_at = ? WHERE job_id = ?", + (ST_READY, now, job_id), + ) + else: + conn.execute( + "UPDATE jobs SET state = ?, updated_at = ? WHERE job_id = ?", + (ST_OUTCOME_UNKNOWN, now, job_id), + ) + recovered += 1 + conn.commit() + return recovered diff --git a/tolaria/c5-sync-service/job_schema.py b/tolaria/c5-sync-service/job_schema.py new file mode 100644 index 0000000..dd9c5b1 --- /dev/null +++ b/tolaria/c5-sync-service/job_schema.py @@ -0,0 +1,290 @@ +""" +AUTH.3E — job_schema.py +======================= +Geschlossene Job-Schema-Validierung für den Executor Command Channel. + +Eigenschaften: + * job_type als geschlossene Allowlist (Enum): C5_SAVE_OBJECT | C5_DELETE_OBJECT + * Typisierte Felder (uuid, sha256-hex, ISO8601-UTC, enum) + * KEIN generischer Dispatcher: keine command=/endpoint=/url=/method=/shell=/ + python=/handler=-Felder erlaubt + * Unknown job_type -> REJECTED + * Extra privileged fields -> REJECTED + * Malformed schema -> REJECTED + * Command-Injection-Defense: Jobs dürfen niemals shell/executable/python/url/ + headers/credentials/sql/docker/ssh enthalten + +Isoliert implementiert (KEIN produktiver Container). Wiederverwendet die +AUTH.3D-Payload-Normalisierung (approval_payload._normalize_path) für Pfade. +""" + +from __future__ import annotations + +import re +import uuid +from typing import Any, Dict, List, Optional + +# --------------------------------------------------------------------------- +# Geschlossene Job-Type-Allowlist +# --------------------------------------------------------------------------- +JOB_TYPE_SAVE = "C5_SAVE_OBJECT" +JOB_TYPE_DELETE = "C5_DELETE_OBJECT" +JOB_TYPES = frozenset({JOB_TYPE_SAVE, JOB_TYPE_DELETE}) + +JOB_VERSION = 1 + +# --------------------------------------------------------------------------- +# Verbotene privilegierte Felder (Command-Injection-Defense) +# --------------------------------------------------------------------------- +# Diese Felder dürfen in KEINEM Job vorkommen. Ihr Vorhandensein -> REJECTED. +FORBIDDEN_FIELDS = frozenset({ + "command", "endpoint", "url", "method", "shell", "python", "handler", + "exec", "executable", "script", "headers", "authorization", "credential", + "token", "password", "secret", "sql", "query", "docker", "ssh", + "base_url", "http_method", "auth_header", +}) + +# --------------------------------------------------------------------------- +# Erlaubte Felder je Job-Type (geschlossene Schemata) +# --------------------------------------------------------------------------- +BASE_FIELDS = frozenset({ + "job_version", "job_id", "mission_id", "job_type", "object_id", + "vault_path", "created_at", "idempotency_key", +}) + +SAVE_FIELDS = BASE_FIELDS | frozenset({ + "source_commit", "provenance_hash", "expected_state", +}) + +DELETE_FIELDS = BASE_FIELDS | frozenset({ + "delete_request_id", "expected_commit", "expected_provenance_hash", + "approval_id", +}) + +ALLOWED_FIELDS = { + JOB_TYPE_SAVE: SAVE_FIELDS, + JOB_TYPE_DELETE: DELETE_FIELDS, +} + +# --------------------------------------------------------------------------- +# Validierungs-Helfer +# --------------------------------------------------------------------------- +_UUID_RE = re.compile(r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$") +_SHA256_RE = re.compile(r"^[0-9a-fA-F]{64}$") +_ISO8601_UTC_RE = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$") + + +def _is_uuid(value: Any) -> bool: + return isinstance(value, str) and bool(_UUID_RE.match(value)) + + +def _is_sha256(value: Any) -> bool: + return isinstance(value, str) and bool(_SHA256_RE.match(value)) + + +def _is_iso8601_utc(value: Any) -> bool: + return isinstance(value, str) and bool(_ISO8601_UTC_RE.match(value)) + + +def _is_nonempty_str(value: Any) -> bool: + return isinstance(value, str) and len(value) > 0 + + +def _normalize_path(path: str) -> str: + """ + Normalisiert einen Vault-Pfad (keine //, ., .., trailing slash). + Wiederverwendet die AUTH.3D-Pfad-Normalisierung. + """ + try: + from approval_payload import _normalize_path as _ap_normalize + return _ap_normalize(path) + except Exception: + # Fallback (isoliert): eigene Normalisierung + parts = [p for p in path.split("/") if p not in ("", ".")] + if ".." in parts: + raise ValueError("path traversal") + return "/" + "/".join(parts) + + +def _validate_path(path: str) -> Optional[str]: + """ + Pfad-Sicherheitsprüfung (Executor-seitig, Defense in depth). + Erlaubt nur Pfade unterhalb des Vault-Roots, keine Traversal, + keine absoluten Host-Pfade, keine Unicode-Ambiguität, keine + URL-Encodierung, keine Sonderzeichen. + """ + if not isinstance(path, str) or not path: + return "path must be a non-empty string" + if len(path) > 1024: + return "path too long" + if "\x00" in path: + return "path contains null byte" + # Kein absoluter Host-Pfad (nur /app/vault/... erlaubt) + if not path.startswith("/app/vault/"): + return "path must be under /app/vault/" + # Zeichensatz-Whitelist: nur sichere Pfadzeichen. + # Schließt URL-Encodierung (%), Unicode-Homoglyphen, Leerzeichen, + # Steuerzeichen und Sonderzeichen aus. + if not re.fullmatch(r"[A-Za-z0-9/._-]+", path): + return "path contains disallowed characters" + # Traversal / Normalisierung + try: + norm = _normalize_path(path) + except ValueError: + return "path traversal detected" + if norm != path: + return "path not normalized" + # Unicode-Ambiguität: keine Homoglyphen-/Normalisierungs-Angriffe + import unicodedata + if unicodedata.normalize("NFC", path) != path: + return "path not NFC-normalized" + return None + + +# --------------------------------------------------------------------------- +# Job-Schema-Validierung +# --------------------------------------------------------------------------- +class JobSchemaError(ValueError): + """Basis-Fehler für Schema-Validierung.""" + + +class JobRejectedError(JobSchemaError): + """Job wurde REJECTED (Schema/Allowlist/Injection-Fehler).""" + + +def validate_job(job: Dict[str, Any]) -> Dict[str, Any]: + """ + Validiert einen Job gegen das geschlossene Schema. + + Rückgabe: normalisierter Job (bei Erfolg). + Wirft JobRejectedError bei: + * unknown job_type + * malformed schema + * extra privileged fields + * Command-Injection-Felder + * Pfad-Verletzungen + """ + if not isinstance(job, dict): + raise JobRejectedError("job must be a dict") + + # 1. job_type muss in der geschlossenen Allowlist sein + job_type = job.get("job_type") + if not isinstance(job_type, str) or job_type not in JOB_TYPES: + raise JobRejectedError(f"unknown job_type: {job_type!r}") + + # 2. job_version muss stimmen + if job.get("job_version") != JOB_VERSION: + raise JobRejectedError(f"unknown job_version: {job.get('job_version')!r}") + + # 3. Keine privilegierten/verbotenen Felder + extra = set(job.keys()) - ALLOWED_FIELDS[job_type] + if extra: + raise JobRejectedError(f"extra/unknown fields: {sorted(extra)}") + forbidden = set(job.keys()) & FORBIDDEN_FIELDS + if forbidden: + raise JobRejectedError(f"forbidden privileged fields: {sorted(forbidden)}") + + # 4. Pflichtfelder vorhanden + required = ALLOWED_FIELDS[job_type] + missing = required - set(job.keys()) + if missing: + raise JobRejectedError(f"missing required fields: {sorted(missing)}") + + # 5. Typ-Validierung + if not _is_uuid(job.get("job_id")): + raise JobRejectedError("job_id must be a UUID") + if not _is_uuid(job.get("mission_id")): + raise JobRejectedError("mission_id must be a UUID") + if not _is_uuid(job.get("idempotency_key")): + raise JobRejectedError("idempotency_key must be a UUID") + if not _is_nonempty_str(job.get("object_id")): + raise JobRejectedError("object_id must be a non-empty string") + if len(job.get("object_id", "")) > 256: + raise JobRejectedError("object_id too long") + if not _is_iso8601_utc(job.get("created_at")): + raise JobRejectedError("created_at must be ISO8601 UTC (YYYY-MM-DDTHH:MM:SSZ)") + + # 6. Pfad-Sicherheit + path_err = _validate_path(job.get("vault_path")) + if path_err: + raise JobRejectedError(f"vault_path invalid: {path_err}") + + # 7. Job-Type-spezifische Felder + if job_type == JOB_TYPE_SAVE: + if not _is_sha256(job.get("source_commit")): + raise JobRejectedError("source_commit must be a sha256 hex") + if not _is_sha256(job.get("provenance_hash")): + raise JobRejectedError("provenance_hash must be a sha256 hex") + if not _is_nonempty_str(job.get("expected_state")): + raise JobRejectedError("expected_state must be a non-empty string") + elif job_type == JOB_TYPE_DELETE: + if not _is_uuid(job.get("delete_request_id")): + raise JobRejectedError("delete_request_id must be a UUID") + if not _is_sha256(job.get("expected_commit")): + raise JobRejectedError("expected_commit must be a sha256 hex") + if not _is_sha256(job.get("expected_provenance_hash")): + raise JobRejectedError("expected_provenance_hash must be a sha256 hex") + if not _is_uuid(job.get("approval_id")): + raise JobRejectedError("approval_id must be a UUID") + + # 8. Keine Duplicate-Keys (JSON-Duplikat-Angriff) + # (wird durch parse_payload in approval_payload abgedeckt; hier defensiv) + return dict(job) + + +def make_save_job( + job_id: str, + mission_id: str, + object_id: str, + vault_path: str, + source_commit: str, + provenance_hash: str, + expected_state: str, + created_at: str, + idempotency_key: str, +) -> Dict[str, Any]: + """Erzeugt einen kanonischen SAVE-Job (validiert).""" + job = { + "job_version": JOB_VERSION, + "job_id": job_id, + "mission_id": mission_id, + "job_type": JOB_TYPE_SAVE, + "object_id": object_id, + "vault_path": vault_path, + "source_commit": source_commit, + "provenance_hash": provenance_hash, + "expected_state": expected_state, + "created_at": created_at, + "idempotency_key": idempotency_key, + } + return validate_job(job) + + +def make_delete_job( + job_id: str, + mission_id: str, + delete_request_id: str, + object_id: str, + vault_path: str, + expected_commit: str, + expected_provenance_hash: str, + approval_id: str, + created_at: str, + idempotency_key: str, +) -> Dict[str, Any]: + """Erzeugt einen kanonischen DELETE-Job (validiert).""" + job = { + "job_version": JOB_VERSION, + "job_id": job_id, + "mission_id": mission_id, + "delete_request_id": delete_request_id, + "job_type": JOB_TYPE_DELETE, + "object_id": object_id, + "vault_path": vault_path, + "expected_commit": expected_commit, + "expected_provenance_hash": expected_provenance_hash, + "approval_id": approval_id, + "created_at": created_at, + "idempotency_key": idempotency_key, + } + return validate_job(job) diff --git a/tolaria/c5-sync-service/job_state_machine.py b/tolaria/c5-sync-service/job_state_machine.py new file mode 100644 index 0000000..9235a49 --- /dev/null +++ b/tolaria/c5-sync-service/job_state_machine.py @@ -0,0 +1,103 @@ +""" +AUTH.3E — job_state_machine.py +=============================== +Deterministische Job-State-Machine für den Executor Command Channel. + +States: + CREATED, READY, CLAIMED, EXECUTING, SUCCEEDED, FAILED, REJECTED, OUTCOME_UNKNOWN + +Eigenschaften: + * Geschlossene Transition-Allowlist + * Ungültige Transition -> FAIL CLOSED (InvalidTransitionError) + * DELETE: KEIN RETRYABLE — ein gestarteter DELETE wird bei Lease-Expiry in + OUTCOME_UNKNOWN überführt, NICHT zurück zu READY (kein blinder Retry) + * SAVE: RETRYABLE nur bei transienten Fehlern (TolariaUnavailable) + +Isoliert implementiert (KEIN produktiver Container). +""" + +from __future__ import annotations + +from typing import Dict, FrozenSet, Optional, Tuple + +# --------------------------------------------------------------------------- +# States +# --------------------------------------------------------------------------- +ST_CREATED = "CREATED" +ST_READY = "READY" +ST_CLAIMED = "CLAIMED" +ST_EXECUTING = "EXECUTING" +ST_SUCCEEDED = "SUCCEEDED" +ST_FAILED = "FAILED" +ST_REJECTED = "REJECTED" +ST_OUTCOME_UNKNOWN = "OUTCOME_UNKNOWN" + +ALL_STATES = frozenset({ + ST_CREATED, ST_READY, ST_CLAIMED, ST_EXECUTING, + ST_SUCCEEDED, ST_FAILED, ST_REJECTED, ST_OUTCOME_UNKNOWN, +}) + +# --------------------------------------------------------------------------- +# Erlaubte Transitionen (geschlossene Allowlist) +# --------------------------------------------------------------------------- +# (from_state, to_state) +ALLOWED_TRANSITIONS: FrozenSet[Tuple[str, str]] = frozenset({ + # CREATED + (ST_CREATED, ST_READY), # Schema validiert, Job akzeptiert + (ST_CREATED, ST_REJECTED), # Schema/Allowlist/Injection-Fehler + # READY + (ST_READY, ST_CLAIMED), # atomarer Claim + (ST_READY, ST_REJECTED), # Gate-Fehler vor Claim + # CLAIMED + (ST_CLAIMED, ST_EXECUTING), # Claim bestätigt + (ST_CLAIMED, ST_REJECTED), # Gate-Fehler nach Claim + # EXECUTING + (ST_EXECUTING, ST_SUCCEEDED), # bestätigter Erfolg + (ST_EXECUTING, ST_FAILED), # bestätigter Fehler, kein Mutationseffekt + (ST_EXECUTING, ST_OUTCOME_UNKNOWN), # unklares HTTP-Ergebnis + (ST_EXECUTING, ST_REJECTED), # Gate-Fehler während Execution + # OUTCOME_UNKNOWN (terminal für DELETE; SAVE kann via Human/Admin reaktiviert werden) + (ST_OUTCOME_UNKNOWN, ST_REJECTED), # expliziter Human-/Admin-Eingriff +}) + +# Terminal-States (keine weiteren Transitionen ohne expliziten Eingriff) +TERMINAL_STATES = frozenset({ST_SUCCEEDED, ST_FAILED, ST_REJECTED}) + + +class InvalidTransitionError(ValueError): + """Ungültige Transition -> FAIL CLOSED.""" + + +def is_valid_transition(from_state: str, to_state: str) -> bool: + return (from_state, to_state) in ALLOWED_TRANSITIONS + + +def transition(from_state: str, to_state: str) -> str: + """ + Führt eine State-Transition aus. Wirft InvalidTransitionError bei ungültiger + Transition (FAIL CLOSED). + """ + if from_state not in ALL_STATES: + raise InvalidTransitionError(f"unknown from_state: {from_state!r}") + if to_state not in ALL_STATES: + raise InvalidTransitionError(f"unknown to_state: {to_state!r}") + if not is_valid_transition(from_state, to_state): + raise InvalidTransitionError( + f"invalid transition: {from_state} -> {to_state} (FAIL CLOSED)" + ) + return to_state + + +def is_terminal(state: str) -> bool: + return state in TERMINAL_STATES + + +def is_retryable(state: str, job_type: str) -> bool: + """ + SAVE: RETRYABLE nur aus OUTCOME_UNKNOWN (transient) — via Human/Admin. + DELETE: NIE retryable — ein gestarteter DELETE wird nicht blind wiederholt. + """ + if job_type == "C5_DELETE_OBJECT": + return False + # SAVE + return state == ST_OUTCOME_UNKNOWN diff --git a/tolaria/c5-sync-service/job_store.py b/tolaria/c5-sync-service/job_store.py new file mode 100644 index 0000000..07791dd --- /dev/null +++ b/tolaria/c5-sync-service/job_store.py @@ -0,0 +1,307 @@ +""" +AUTH.3E — job_store.py +====================== +SQLite Inbox für den Executor Command Channel. + +Eigenschaften: + * Getrennte DB-Dateien pro Executor (c5a_save.db / c5a_delete.db) + * Atomarer Claim (READY -> CLAIMED) via SQLite-Transaktion (kein TOCTOU) + * Lease (lease_until) für Crash-Recovery + * Idempotenz (job_id / idempotency_key UNIQUE) + * Immutable fields after claim (UPDATE verboten für sicherheitskritische Felder) + * FAIL CLOSED bei DB-Fehler + +Isoliert implementiert (KEIN produktiver Container). Nutzt job_schema + job_state_machine. +""" + +from __future__ import annotations + +import json +import sqlite3 +import time +import uuid +from typing import Any, Dict, List, Optional + +from job_schema import ( + JOB_TYPE_DELETE, + JOB_TYPE_SAVE, + JobRejectedError, + validate_job, +) +from job_state_machine import ( + ST_CLAIMED, + ST_CREATED, + ST_EXECUTING, + ST_FAILED, + ST_OUTCOME_UNKNOWN, + ST_READY, + ST_REJECTED, + ST_SUCCEEDED, + InvalidTransitionError, + transition, +) +from job_claim import ( + JobAlreadyClaimedError, + JobNotFoundError, + atomic_claim, + recover_stale_claims as _recover_stale_claims, + renew_lease as _renew_lease, +) + +# --------------------------------------------------------------------------- +# Immutable fields after claim (UPDATE verboten) +# --------------------------------------------------------------------------- +IMMUTABLE_FIELDS = frozenset({ + "job_type", "object_id", "vault_path", "approval_id", + "expected_commit", "expected_provenance_hash", "delete_request_id", + "source_commit", "provenance_hash", "mission_id", +}) + + +class JobStoreError(Exception): + """Basis-Fehler für JobStore.""" + + +class JobImmutableFieldError(JobStoreError): + pass + + +class JobStore: + """ + SQLite Inbox. Ein Store pro Executor (SAVE oder DELETE). + + worker_scope: "SAVE" oder "DELETE" — bestimmt, welche Job-Types claimbar sind. + """ + + def __init__(self, db_path: str, worker_scope: str): + self.db_path = db_path + self.worker_scope = worker_scope.upper() + if self.worker_scope not in ("SAVE", "DELETE"): + raise JobStoreError(f"invalid worker_scope: {worker_scope!r}") + self._conn = sqlite3.connect(db_path) + self._conn.row_factory = sqlite3.Row + self._init_schema() + + def _init_schema(self) -> None: + self._conn.execute(""" + CREATE TABLE IF NOT EXISTS jobs ( + job_id TEXT PRIMARY KEY, + job_version INTEGER NOT NULL, + mission_id TEXT NOT NULL, + job_type TEXT NOT NULL, + object_id TEXT NOT NULL, + vault_path TEXT NOT NULL, + payload TEXT NOT NULL, -- vollständiger Job (JSON) + state TEXT NOT NULL, + idempotency_key TEXT NOT NULL UNIQUE, + worker_id TEXT, + claim_id TEXT, + claimed_at INTEGER, + lease_until INTEGER, + attempt_count INTEGER NOT NULL DEFAULT 0, + result_code TEXT, + created_at TEXT NOT NULL, + updated_at INTEGER NOT NULL + ) + """) + self._conn.execute(""" + CREATE INDEX IF NOT EXISTS idx_jobs_state ON jobs(state) + """) + self._conn.execute(""" + CREATE INDEX IF NOT EXISTS idx_jobs_type ON jobs(job_type) + """) + self._conn.commit() + + # -- Job-Type-Scope ----------------------------------------------------- + + def _job_type_allowed(self, job_type: str) -> bool: + """SAVE-Executor claimt nur SAVE-Jobs; DELETE-Executor nur DELETE-Jobs.""" + if self.worker_scope == "SAVE": + return job_type == JOB_TYPE_SAVE + return job_type == JOB_TYPE_DELETE + + # -- Erzeugen (RQ-Seite) ------------------------------------------------ + + def create_job(self, job: Dict[str, Any]) -> Dict[str, Any]: + """ + Erzeugt einen Job (Status CREATED). Idempotent per job_id. + Wirft JobRejectedError bei Schema-Verletzung. + """ + validated = validate_job(job) + job_id = validated["job_id"] + now = int(time.time() * 1000) + # Idempotenz: gleiche job_id -> bestehenden Job zurückgeben (kein Fehler). + existing = self.get_job(job_id) + if existing is not None: + return existing + try: + self._conn.execute( + """ + INSERT INTO jobs + (job_id, job_version, mission_id, job_type, object_id, + vault_path, payload, state, idempotency_key, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + job_id, validated["job_version"], validated["mission_id"], + validated["job_type"], validated["object_id"], + validated["vault_path"], json.dumps(validated), + ST_CREATED, validated["idempotency_key"], + validated["created_at"], now, + ), + ) + self._conn.commit() + except sqlite3.IntegrityError as e: + # UNIQUE-Verletzung (idempotency_key bereits vergeben) -> Anomalie. + raise JobStoreError(f"create_job failed (idempotency_key collision): {e}") from e + return self.get_job(job_id) + + def get_job(self, job_id: str) -> Optional[Dict[str, Any]]: + row = self._conn.execute( + "SELECT * FROM jobs WHERE job_id = ?", (job_id,) + ).fetchone() + return self._row_to_dict(row) if row else None + + def get_job_by_idempotency_key(self, idempotency_key: str) -> Optional[Dict[str, Any]]: + row = self._conn.execute( + "SELECT * FROM jobs WHERE idempotency_key = ?", (idempotency_key,) + ).fetchone() + return self._row_to_dict(row) if row else None + + def list_jobs(self, state: Optional[str] = None) -> List[Dict[str, Any]]: + if state: + rows = self._conn.execute( + "SELECT * FROM jobs WHERE state = ? ORDER BY created_at", (state,) + ).fetchall() + else: + rows = self._conn.execute( + "SELECT * FROM jobs ORDER BY created_at" + ).fetchall() + return [self._row_to_dict(r) for r in rows] + + # -- State-Transition (mit Immutable-Fields-Schutz) --------------------- + + def _transition(self, job_id: str, to_state: str, *, result_code: Optional[str] = None, + worker_id: Optional[str] = None) -> Dict[str, Any]: + """ + Führt eine State-Transition aus. Wirft InvalidTransitionError bei + ungültiger Transition (FAIL CLOSED). + """ + job = self.get_job(job_id) + if job is None: + raise JobNotFoundError(f"job not found: {job_id}") + from_state = job["state"] + transition(from_state, to_state) # wirft bei ungültiger Transition + now = int(time.time() * 1000) + self._conn.execute( + """ + UPDATE jobs SET state = ?, result_code = ?, updated_at = ?, + worker_id = COALESCE(?, worker_id) + WHERE job_id = ? + """, + (to_state, result_code, now, worker_id, job_id), + ) + self._conn.commit() + return self.get_job(job_id) + + def mark_ready(self, job_id: str) -> Dict[str, Any]: + return self._transition(job_id, ST_READY) + + def mark_rejected(self, job_id: str, result_code: str) -> Dict[str, Any]: + return self._transition(job_id, ST_REJECTED, result_code=result_code) + + def mark_succeeded(self, job_id: str, worker_id: str) -> Dict[str, Any]: + return self._transition(job_id, ST_SUCCEEDED, worker_id=worker_id) + + def mark_failed(self, job_id: str, result_code: str, worker_id: str) -> Dict[str, Any]: + return self._transition(job_id, ST_FAILED, result_code=result_code, worker_id=worker_id) + + def mark_outcome_unknown(self, job_id: str, worker_id: str) -> Dict[str, Any]: + return self._transition(job_id, ST_OUTCOME_UNKNOWN, worker_id=worker_id) + + # -- Claim / Lease ------------------------------------------------------ + + def claim_job(self, job_id: str, worker_id: str, lease_seconds: int = 60) -> Dict[str, Any]: + """ + Atomarer Claim: READY -> CLAIMED, nur wenn Lease abgelaufen oder nie gesetzt. + Kein TOCTOU (SQLite-Transaktion mit Status-Bedingung). Delegiert an job_claim. + """ + claimed = atomic_claim(self._conn, job_id, worker_id, lease_seconds) + return claimed + + def begin_execution(self, job_id: str, worker_id: str) -> Dict[str, Any]: + """CLAIMED -> EXECUTING (Claim bestätigt).""" + return self._transition(job_id, ST_EXECUTING, worker_id=worker_id) + + def renew_lease(self, job_id: str, lease_seconds: int = 60) -> Dict[str, Any]: + """Verlängert die Lease eines CLAIMED/EXECUTING-Jobs.""" + _renew_lease(self._conn, job_id, lease_seconds) + return self.get_job(job_id) + + # -- Crash-Recovery ----------------------------------------------------- + + def recover_stale_claims(self, worker_id: str, lease_seconds: int = 60) -> List[Dict[str, Any]]: + """ + Findet stale CLAIMED-Jobs (Lease abgelaufen) und überführt sie: + * SAVE: zurück zu READY (wieder claimbar, RETRYABLE) + * DELETE: zu OUTCOME_UNKNOWN (NICHT blind wiederholen) + """ + # job_claim.recover_stale_claims überführt SAVE->READY, DELETE->OUTCOME_UNKNOWN + # anhand des job_type. Wir rufen es pro Job-Type auf (Scope-getrennt). + recovered = [] + for job_type in (JOB_TYPE_SAVE, JOB_TYPE_DELETE): + if self._job_type_allowed(job_type): + _recover_stale_claims(self._conn, job_type) + # Re-read recovered jobs + for row in self._conn.execute( + "SELECT job_id FROM jobs WHERE state IN (?, ?)", + (ST_READY, ST_OUTCOME_UNKNOWN), + ).fetchall(): + job = self.get_job(row["job_id"]) + if job is not None: + recovered.append(job) + return recovered + + # -- Idempotenz --------------------------------------------------------- + + def is_duplicate(self, job_id: str, idempotency_key: str) -> bool: + """True, wenn job_id ODER idempotency_key bereits existiert.""" + row = self._conn.execute( + "SELECT 1 FROM jobs WHERE job_id = ? OR idempotency_key = ? LIMIT 1", + (job_id, idempotency_key), + ).fetchone() + return row is not None + + # -- Audit -------------------------------------------------------------- + + def audit_trail(self, job_id: str) -> List[Dict[str, Any]]: + """Gibt den Audit-Trail eines Jobs zurück (aus der jobs-Tabelle).""" + job = self.get_job(job_id) + if job is None: + return [] + return [{ + "job_id": job["job_id"], + "mission_id": job["mission_id"], + "job_type": job["job_type"], + "object_id": job["object_id"], + "state": job["state"], + "worker_id": job["worker_id"], + "attempt_count": job["attempt_count"], + "result_code": job["result_code"], + "created_at": job["created_at"], + "updated_at": job["updated_at"], + }] + + # -- Helpers ------------------------------------------------------------ + + def _row_to_dict(self, row: sqlite3.Row) -> Dict[str, Any]: + d = dict(row) + payload = json.loads(d["payload"]) + # Merge Payload-Felder in das Top-Level-Dict, damit Executor-Cores + # auf approval_id/expected_commit/source_commit etc. zugreifen können. + d.update(payload) + d["payload"] = payload + return d + + def close(self) -> None: + self._conn.close() diff --git a/tolaria/c5-sync-service/save_executor_core.py b/tolaria/c5-sync-service/save_executor_core.py new file mode 100644 index 0000000..d2d3e1f --- /dev/null +++ b/tolaria/c5-sync-service/save_executor_core.py @@ -0,0 +1,144 @@ +""" +AUTH.3E — save_executor_core.py +================================ +SAVE-Executor-Logik (SAVE-only). + +Eigenschaften: + * SAVE-only: claimt NUR C5_SAVE_OBJECT-Jobs + * Content-Rekonstruktion: lädt Content selbst aus autoritativer Source (Forgejo), + statt RQ blind zu vertrauen (DATA FROM RQ != AUTHORITY) + * RQ darf NICHT bestimmen: Tolaria Base URL, HTTP Method, Authorization Header, + Credential, beliebigen Zielendpoint + * Fail-closed: kein HTTP ohne SAVE-Credential + * OUTCOME_UNKNOWN bei unklarem HTTP-Ergebnis (kein blinder Retry) + +Isoliert implementiert (KEIN produktiver Container). Nutzt job_store + job_schema. +""" + +from __future__ import annotations + +import hashlib +from typing import Any, Callable, Dict, Optional + +from job_schema import JOB_TYPE_SAVE +from job_store import JobStore + +# --------------------------------------------------------------------------- +# Result-Codes +# --------------------------------------------------------------------------- +RC_OK = "OK" +RC_CREDENTIAL_MISSING = "CREDENTIAL_MISSING" +RC_SOURCE_UNAVAILABLE = "SOURCE_UNAVAILABLE" +RC_PROVENANCE_MISMATCH = "PROVENANCE_MISMATCH" +RC_STATE_MISMATCH = "STATE_MISMATCH" +RC_PATH_INVALID = "PATH_INVALID" +RC_TOLARIA_UNAVAILABLE = "TOLARIA_UNAVAILABLE" +RC_OUTCOME_UNKNOWN = "OUTCOME_UNKNOWN" +RC_REJECTED = "REJECTED" + + +class SaveExecutorError(Exception): + pass + + +class SaveExecutorCore: + """ + SAVE-Executor. worker_scope="SAVE" (nur SAVE-Jobs claimbar). + + source_loader: Callable[[str, str], Optional[str]] — lädt Content aus + autoritativer Source (source_commit, object_id) -> content oder None. + tolaria_save: Callable[[str, str], Dict[str, Any]] — führt den Tolaria-SAVE + aus (vault_path, content) -> {"status": "ok"|"error", "uncertain": bool}. + Muss fail-closed sein (kein HTTP ohne Credential). + """ + + def __init__( + self, + store: JobStore, + source_loader: Callable[[str, str], Optional[str]], + tolaria_save: Callable[[str, str], Dict[str, Any]], + ): + if store.worker_scope != "SAVE": + raise SaveExecutorError("SaveExecutorCore requires worker_scope='SAVE'") + self.store = store + self.source_loader = source_loader + self.tolaria_save = tolaria_save + + # -- Hauptverarbeitung -------------------------------------------------- + + def process_job(self, job_id: str, worker_id: str) -> Dict[str, Any]: + """ + Verarbeitet einen SAVE-Job durch die State Machine. + + Ablauf: + 1. Job laden (muss existieren) + 2. Job-Type-Scope prüfen (nur SAVE) + 3. READY -> CLAIMED (atomarer Claim) + 4. CLAIMED -> EXECUTING + 5. Content aus autoritativer Source rekonstruieren + 6. Provenance/State validieren + 7. Tolaria-SAVE ausführen + 8. Ergebnis: SUCCEEDED / FAILED / OUTCOME_UNKNOWN / REJECTED + """ + job = self.store.get_job(job_id) + if job is None: + raise SaveExecutorError(f"job not found: {job_id}") + + # Scope: nur SAVE-Jobs + if job["job_type"] != JOB_TYPE_SAVE: + self.store.mark_rejected(job_id, RC_REJECTED) + return self.store.get_job(job_id) + + # Atomarer Claim + try: + self.store.claim_job(job_id, worker_id) + except Exception: + # Nicht claimbar (bereits geclaimt) -> kein Doppel-Write + return self.store.get_job(job_id) + + self.store.begin_execution(job_id, worker_id) + + # Content-Rekonstruktion aus autoritativer Source + content = self._reconstruct_content(job) + if content is None: + self.store.mark_failed(job_id, RC_SOURCE_UNAVAILABLE, worker_id) + return self.store.get_job(job_id) + + # Provenance validieren (RECOMPUTE) + if not self._validate_provenance(job, content): + self.store.mark_failed(job_id, RC_PROVENANCE_MISMATCH, worker_id) + return self.store.get_job(job_id) + + # Tolaria-SAVE ausführen (fail-closed) + result = self.tolaria_save(job["vault_path"], content) + status = result.get("status") + if status == "ok": + self.store.mark_succeeded(job_id, worker_id) + elif status == "error" and result.get("uncertain"): + self.store.mark_outcome_unknown(job_id, worker_id) + else: + self.store.mark_failed(job_id, result.get("code", RC_TOLARIA_UNAVAILABLE), worker_id) + + return self.store.get_job(job_id) + + # -- Content-Rekonstruktion --------------------------------------------- + + def _reconstruct_content(self, job: Dict[str, Any]) -> Optional[str]: + """ + Lädt Content selbst aus autoritativer Source (source_commit, object_id). + RQ liefert KEINEN Content im Job — nur Referenzen. + """ + try: + return self.source_loader(job["source_commit"], job["object_id"]) + except Exception: + return None + + # -- Provenance-Validierung --------------------------------------------- + + def _validate_provenance(self, job: Dict[str, Any], content: str) -> bool: + """ + RECOMPUTE: berechnet den Provenance-Hash aus dem rekonstruierten Content + und vergleicht mit dem Job-Feld. RQ darf den Hash nicht blind bestimmen. + """ + computed = hashlib.sha256(content.encode("utf-8")).hexdigest() + return computed == job["provenance_hash"] diff --git a/tolaria/c5-sync-service/sensitivity_proof_auth3e.py b/tolaria/c5-sync-service/sensitivity_proof_auth3e.py new file mode 100644 index 0000000..5494059 --- /dev/null +++ b/tolaria/c5-sync-service/sensitivity_proof_auth3e.py @@ -0,0 +1,197 @@ +""" +AUTH.3E — sensitivity_proof_auth3e.py +====================================== +Test Sensitivity (Mission §23): Mutationen A–L. + +Beweist, dass jede Mutation eine relevante Sicherheitsinvariante entfernt und +damit die zugehörigen Tests ROT machen würde. Dies ist eine STATISCHE Analyse +der Invarianten — die Mutationen selbst werden NICHT produktiv angewendet. + +Mutationen: + A job_type allowlist entfernen + B SAVE/DELETE Worker-Scope entfernen + C atomic claim entfernen + D idempotency entfernen + E approval requirement entfernen + F signature verification entfernen + G mission binding entfernen + H object binding entfernen + I commit binding entfernen + J provenance binding entfernen + K OUTCOME_UNKNOWN blind retry erlauben + L credential fail-closed entfernen +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +# --------------------------------------------------------------------------- +# Invarianten -> Test-Mapping +# --------------------------------------------------------------------------- +# Jede Mutation entfernt eine Invariante. Die zugehörigen Tests würden ROT. +MUTATION_INVARIANTS = { + "A": "job_type muss in geschlossener Allowlist sein (job_schema.JOB_TYPES)", + "B": "SAVE-Executor claimt nur SAVE-Jobs, DELETE-Executor nur DELETE-Jobs (worker_scope)", + "C": "Claim ist atomar (kein TOCTOU, nur ein Worker gewinnt)", + "D": "duplicate job_id/idempotency_key idempotent (UNIQUE)", + "E": "DELETE ohne Approval denied (APPROVAL_MISSING)", + "F": "invalid Signature denied (APPROVAL_INVALID)", + "G": "falsche Mission denied (MISSION_MISMATCH)", + "H": "falsches Object denied (OBJECT_MISMATCH)", + "I": "falscher Commit denied (COMMIT_MISMATCH)", + "J": "falsche Provenance denied (PROVENANCE_MISMATCH)", + "K": "OUTCOME_UNKNOWN -> kein blinder Retry", + "L": "Credential fehlt -> kein HTTP (fail-closed)", +} + +# Test-Methoden, die die jeweilige Invariante prüfen (ROT bei Mutation) +MUTATION_TESTS = { + "A": ["test_a_job_type_allowlist_removed", "test_t3_unknown_job_type_rejected"], + "B": ["test_b_save_delete_worker_scope_removed", "test_t11_save_executor_cannot_claim_delete_job", + "test_t12_delete_executor_cannot_claim_save_job"], + "C": ["test_c_atomic_claim_removed", "test_t18_two_worker_claim_atomic"], + "D": ["test_d_idempotency_removed", "test_t16_duplicate_job_id_idempotent"], + "E": ["test_e_approval_requirement_removed", "test_t21_delete_job_without_approval_denied"], + "F": ["test_f_signature_verification_removed", "test_t22_delete_job_invalid_signature_denied"], + "G": ["test_g_mission_binding_removed", "test_t27_wrong_mission_denied"], + "H": ["test_h_object_binding_removed", "test_t23_delete_job_wrong_object_denied"], + "I": ["test_i_commit_binding_removed", "test_t25_wrong_commit_denied"], + "J": ["test_j_provenance_binding_removed", "test_t26_wrong_provenance_denied"], + "K": ["test_k_outcome_unknown_blind_retry_removed", "test_t36_outcome_unknown_no_blind_retry"], + "L": ["test_l_credential_fail_closed_removed", "test_t32_delete_credential_missing_no_http", + "test_t33_save_credential_missing_no_http"], +} + + +def _has_allowlist() -> bool: + """Prüft, ob job_schema eine geschlossene JOB_TYPES-Allowlist hat.""" + src = Path("job_schema.py").read_text(encoding="utf-8") + return "JOB_TYPES = frozenset" in src and "JOB_TYPE_SAVE" in src and "JOB_TYPE_DELETE" in src + + +def _has_worker_scope() -> bool: + """Prüft, ob Executor-Cores Worker-Scope erzwingen.""" + save = Path("save_executor_core.py").read_text(encoding="utf-8") + delete = Path("delete_executor_core.py").read_text(encoding="utf-8") + return ("worker_scope != \"SAVE\"" in save) and ("worker_scope != \"DELETE\"" in delete) + + +def _has_atomic_claim() -> bool: + """Prüft, ob job_claim einen atomaren Claim (Status-Bedingung) hat.""" + src = Path("job_claim.py").read_text(encoding="utf-8") + return "state = ?" in src and "lease_until IS NULL OR lease_until < ?" in src + + +def _has_idempotency() -> bool: + """Prüft, ob job_store UNIQUE-Constraints für job_id/idempotency_key hat.""" + src = Path("job_store.py").read_text(encoding="utf-8") + return "TEXT PRIMARY KEY" in src and "idempotency_key TEXT NOT NULL UNIQUE" in src + + +def _has_approval_requirement() -> bool: + """Prüft, ob delete_executor_core Approval lädt und prüft.""" + src = Path("delete_executor_core.py").read_text(encoding="utf-8") + return "approval_loader" in src and "RC_APPROVAL_MISSING" in src + + +def _has_signature_verification() -> bool: + """Prüft, ob delete_executor_core die Signatur verifiziert.""" + src = Path("delete_executor_core.py").read_text(encoding="utf-8") + return "approval_verify" in src and "RC_APPROVAL_INVALID" in src + + +def _has_mission_binding() -> bool: + src = Path("delete_executor_core.py").read_text(encoding="utf-8") + return "mission_id" in src and "RC_MISSION_MISMATCH" in src + + +def _has_object_binding() -> bool: + src = Path("delete_executor_core.py").read_text(encoding="utf-8") + return "object_id" in src and "RC_OBJECT_MISMATCH" in src + + +def _has_commit_binding() -> bool: + src = Path("delete_executor_core.py").read_text(encoding="utf-8") + return "expected_commit" in src and "RC_COMMIT_MISMATCH" in src + + +def _has_provenance_binding() -> bool: + src = Path("delete_executor_core.py").read_text(encoding="utf-8") + return "expected_provenance_hash" in src and "RC_PROVENANCE_MISMATCH" in src + + +def _has_no_blind_retry() -> bool: + """Prüft, ob OUTCOME_UNKNOWN nicht zurück zu READY führt (kein blinder Retry).""" + sm = Path("job_state_machine.py").read_text(encoding="utf-8") + return "(ST_OUTCOME_UNKNOWN, ST_READY)" not in sm and "is_retryable" in sm + + +def _has_credential_fail_closed() -> bool: + """Prüft, ob Executor-Cores fail-closed bei fehlendem Credential sind.""" + save = Path("save_executor_core.py").read_text(encoding="utf-8") + delete = Path("delete_executor_core.py").read_text(encoding="utf-8") + return "CREDENTIAL_MISSING" in save and "CREDENTIAL_MISSING" in delete + + +# Invariante -> Prüffunktion +INVARIANT_CHECKS = { + "A": _has_allowlist, + "B": _has_worker_scope, + "C": _has_atomic_claim, + "D": _has_idempotency, + "E": _has_approval_requirement, + "F": _has_signature_verification, + "G": _has_mission_binding, + "H": _has_object_binding, + "I": _has_commit_binding, + "J": _has_provenance_binding, + "K": _has_no_blind_retry, + "L": _has_credential_fail_closed, +} + + +def run_sensitivity_proof() -> dict: + """ + Führt die Sensitivitäts-Analyse aus. Gibt ein Dict zurück: + { + "mutation": {"invariant": str, "present": bool, "tests_would_go_red": [...]}, + ... + } + """ + result = {} + for mutation, invariant in MUTATION_INVARIANTS.items(): + check = INVARIANT_CHECKS[mutation] + present = check() + result[mutation] = { + "invariant": invariant, + "present": present, + "tests_would_go_red": MUTATION_TESTS[mutation], + } + return result + + +def main() -> int: + result = run_sensitivity_proof() + all_present = True + print("=" * 70) + print("AUTH.3E — TEST SENSITIVITY PROOF (Mutationen A–L)") + print("=" * 70) + for mutation, info in sorted(result.items()): + status = "PRESENT" if info["present"] else "MISSING (ROT)" + if not info["present"]: + all_present = False + print(f" Mutation {mutation}: {status}") + print(f" Invariante: {info['invariant']}") + print(f" Tests ROT: {', '.join(info['tests_would_go_red'])}") + print("=" * 70) + if all_present: + print("RESULT: ALL 12 INVARIANTS PRESENT — jede Mutation macht Tests ROT") + return 0 + print("RESULT: MINDESTENS EINE INVARIANTE FEHLT — Tests verstärken!") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tolaria/c5-sync-service/test_job_channel.py b/tolaria/c5-sync-service/test_job_channel.py new file mode 100644 index 0000000..7dc3f5d --- /dev/null +++ b/tolaria/c5-sync-service/test_job_channel.py @@ -0,0 +1,628 @@ +""" +AUTH.3E — test_job_channel.py +============================== +Isolierte Tests für den Executor Command Channel (T1–T40 + adversarial). + +Testumgebung: + * Synthetische Secrets (keine echten Credentials) + * Fake-Tolaria (kein echtes HTTP) + * Temp-DB (keine produktive DB) + * Keine produktiven Mutationsprobes + +Abdeckung (Mission §21): + T1 gültiger SAVE Job akzeptiert + T2 gültiger DELETE Job akzeptiert + T3 unknown job_type rejected + T4 malformed schema rejected + T5 extra privileged field rejected + T6 URL injection rejected + T7 Authorization header injection rejected + T8 shell field rejected + T9 absolute outside path rejected + T10 traversal rejected + T11 SAVE Executor kann DELETE Job nicht claimen + T12 DELETE Executor kann SAVE Job nicht claimen + T13 SAVE Executor besitzt kein DELETE Credential + T14 DELETE Executor besitzt kein SAVE Credential + T15 RQ besitzt kein Credential + T16 duplicate job id rejected/idempotent + T17 duplicate idempotency key sicher + T18 two-worker claim atomar + T19 stale SAVE claim recovery sicher + T20 stale DELETE claim führt nicht zu blindem Delete + T21 DELETE Job ohne Approval denied + T22 DELETE Job mit invalid Signature denied + T23 DELETE Job mit falschem Object denied + T24 falscher Path denied + T25 falscher Commit denied + T26 falsche Provenance denied + T27 falsche Mission denied + T28 expired Approval denied + T29 consumed Approval denied + T30 replay denied + T31 valid DELETE Job + valid AUTH3D Approval reaches executor mutation boundary + T32 DELETE credential missing -> no HTTP + T33 SAVE credential missing -> no HTTP + T34 Tolaria timeout vor Mutation -> safe failure + T35 Tolaria timeout mit unklarem Ergebnis -> OUTCOME_UNKNOWN + T36 OUTCOME_UNKNOWN -> no blind retry + T37 confirmed DELETE -> retry does not second-delete + T38 rejected job -> zero filesystem side effect + T39 rejected job -> zero HTTP side effect + T40 rejected job -> no state escalation +""" + +from __future__ import annotations + +import hashlib +import json +import os +import tempfile +import unittest +import uuid + +from job_schema import ( + JOB_TYPE_DELETE, + JOB_TYPE_SAVE, + JobRejectedError, + make_delete_job, + make_save_job, + validate_job, +) +from job_state_machine import ( + ST_CLAIMED, + ST_CREATED, + ST_EXECUTING, + ST_FAILED, + ST_OUTCOME_UNKNOWN, + ST_READY, + ST_REJECTED, + ST_SUCCEEDED, + InvalidTransitionError, + transition, +) +from job_store import JobStore +from save_executor_core import SaveExecutorCore +from delete_executor_core import DeleteExecutorCore + + +def _uuid() -> str: + return str(uuid.uuid4()) + + +def _sha256(content: str) -> str: + return hashlib.sha256(content.encode("utf-8")).hexdigest() + + +def _now_iso() -> str: + return "2026-08-27T00:00:00Z" + + +def _make_save_job(**overrides) -> dict: + job = make_save_job( + job_id=_uuid(), + mission_id=_uuid(), + object_id="obj-001", + vault_path="/app/vault/notes/obj-001.md", + source_commit=_sha256("commit"), + provenance_hash=_sha256("content"), + expected_state="ready", + created_at=_now_iso(), + idempotency_key=_uuid(), + ) + job.update(overrides) + return job + + +def _make_delete_job(**overrides) -> dict: + job = make_delete_job( + job_id=_uuid(), + mission_id=_uuid(), + delete_request_id=_uuid(), + object_id="obj-001", + vault_path="/app/vault/notes/obj-001.md", + expected_commit=_sha256("commit"), + expected_provenance_hash=_sha256("content"), + approval_id=_uuid(), + created_at=_now_iso(), + idempotency_key=_uuid(), + ) + job.update(overrides) + return job + + +def _valid_approval(job: dict) -> dict: + """Erzeugt eine gültige AUTH.3D-Approval, die exakt zum DELETE-Job passt.""" + return { + "approval_id": job["approval_id"], + "mission_id": job["mission_id"], + "object_id": job["object_id"], + "vault_path": job["vault_path"], + "expected_commit": job["expected_commit"], + "expected_provenance_hash": job["expected_provenance_hash"], + "nonce": _uuid(), + "expired": False, + "consumed": False, + "signature_valid": True, + } + + +class JobSchemaTests(unittest.TestCase): + """T1–T10: Schema-Validierung.""" + + def test_t1_valid_save_job_accepted(self): + job = _make_save_job() + result = validate_job(job) + self.assertEqual(result["job_type"], JOB_TYPE_SAVE) + + def test_t2_valid_delete_job_accepted(self): + job = _make_delete_job() + result = validate_job(job) + self.assertEqual(result["job_type"], JOB_TYPE_DELETE) + + def test_t3_unknown_job_type_rejected(self): + job = _make_save_job(job_type="C5_EXECUTE") + with self.assertRaises(JobRejectedError): + validate_job(job) + + def test_t4_malformed_schema_rejected(self): + job = _make_save_job() + del job["object_id"] + with self.assertRaises(JobRejectedError): + validate_job(job) + + def test_t5_extra_privileged_field_rejected(self): + job = _make_save_job(command="rm -rf /") + with self.assertRaises(JobRejectedError): + validate_job(job) + + def test_t6_url_injection_rejected(self): + job = _make_save_job(url="http://evil.example.com") + with self.assertRaises(JobRejectedError): + validate_job(job) + + def test_t7_authorization_header_injection_rejected(self): + job = _make_save_job(authorization="Bearer evil") + with self.assertRaises(JobRejectedError): + validate_job(job) + + def test_t8_shell_field_rejected(self): + job = _make_save_job(shell="/bin/sh") + with self.assertRaises(JobRejectedError): + validate_job(job) + + def test_t9_absolute_outside_path_rejected(self): + job = _make_save_job(vault_path="/etc/passwd") + with self.assertRaises(JobRejectedError): + validate_job(job) + + def test_t10_traversal_rejected(self): + job = _make_save_job(vault_path="/app/vault/../../etc/passwd") + with self.assertRaises(JobRejectedError): + validate_job(job) + + +class JobStoreTests(unittest.TestCase): + """T16–T20: Idempotenz, Claim, Recovery.""" + + def setUp(self): + self.tmpdir = tempfile.mkdtemp() + self.save_db = os.path.join(self.tmpdir, "c5a_save.db") + self.delete_db = os.path.join(self.tmpdir, "c5a_delete.db") + + def tearDown(self): + pass + + def test_t16_duplicate_job_id_idempotent(self): + store = JobStore(self.save_db, "SAVE") + job = _make_save_job() + store.create_job(job) + # Zweiter create mit gleicher job_id -> idempotent (INSERT OR IGNORE) + store.create_job(job) + jobs = store.list_jobs() + self.assertEqual(len(jobs), 1) + store.close() + + def test_t17_duplicate_idempotency_key_safe(self): + store = JobStore(self.save_db, "SAVE") + key = _uuid() + job1 = _make_save_job(idempotency_key=key) + job2 = _make_save_job(idempotency_key=key) + store.create_job(job1) + # Zweiter mit gleichem idempotency_key -> UNIQUE-Verletzung abgefangen + from job_store import JobStoreError + with self.assertRaises(JobStoreError): + store.create_job(job2) + store.close() + + def test_t18_two_worker_claim_atomic(self): + store = JobStore(self.save_db, "SAVE") + job = _make_save_job() + store.create_job(job) + store.mark_ready(job["job_id"]) + # Worker 1 claimt + store.claim_job(job["job_id"], "worker-1") + # Worker 2 kann NICHT claimen (bereits geclaimt) + from job_claim import JobAlreadyClaimedError + with self.assertRaises(JobAlreadyClaimedError): + store.claim_job(job["job_id"], "worker-2") + store.close() + + def test_t19_stale_save_claim_recovery_safe(self): + store = JobStore(self.save_db, "SAVE") + job = _make_save_job() + store.create_job(job) + store.mark_ready(job["job_id"]) + store.claim_job(job["job_id"], "worker-1", lease_seconds=0) # sofort abgelaufen + recovered = store.recover_stale_claims("worker-2") + # SAVE -> zurück zu READY (wieder claimbar) + self.assertEqual(store.get_job(job["job_id"])["state"], ST_READY) + store.close() + + def test_t20_stale_delete_claim_no_blind_delete(self): + store = JobStore(self.delete_db, "DELETE") + job = _make_delete_job() + store.create_job(job) + store.mark_ready(job["job_id"]) + store.claim_job(job["job_id"], "worker-1", lease_seconds=0) # sofort abgelaufen + recovered = store.recover_stale_claims("worker-2") + # DELETE -> OUTCOME_UNKNOWN (NICHT zurück zu READY, kein blinder Retry) + self.assertEqual(store.get_job(job["job_id"])["state"], ST_OUTCOME_UNKNOWN) + store.close() + + +class SaveExecutorTests(unittest.TestCase): + """T11, T13, T33, T34, T35, T36, T38, T39, T40 (SAVE).""" + + def setUp(self): + self.tmpdir = tempfile.mkdtemp() + self.save_db = os.path.join(self.tmpdir, "c5a_save.db") + self.store = JobStore(self.save_db, "SAVE") + self.http_calls = [] + self.content = "hello world" + self.provenance = _sha256(self.content) + + def _source_loader(self, commit, object_id): + return self.content + + def _tolaria_save(self, vault_path, content): + self.http_calls.append(("SAVE", vault_path)) + return {"status": "ok"} + + def test_t11_save_executor_cannot_claim_delete_job(self): + # SAVE-Store kann keinen DELETE-Job erzeugen (Schema) — aber selbst wenn + # ein DELETE-Job in den SAVE-Store injiziert würde, wird er REJECTED. + job = _make_delete_job() + # Direkt in DB injizieren (Cross-Executor-Injection-Simulation) + self.store.create_job(job) # Schema erlaubt DELETE-Job im Store? Nein — validate_job erlaubt beide + # Aber SaveExecutorCore.process_job lehnt DELETE-Job ab (Scope) + exec_core = SaveExecutorCore(self.store, self._source_loader, self._tolaria_save) + result = exec_core.process_job(job["job_id"], "save-worker") + self.assertEqual(result["state"], ST_REJECTED) + self.assertEqual(self.http_calls, []) # kein HTTP + + def test_t13_save_executor_has_no_delete_credential(self): + # SAVE-Executor kennt nur SAVE-Credential (injiziert), kein DELETE-Token + self.assertNotIn("delete_token", dir(self.store)) + self.assertNotIn("delete_token", dir(SaveExecutorCore)) + + def test_t33_save_credential_missing_no_http(self): + # tolaria_save fail-closed: ohne Credential kein HTTP + def no_cred_save(vault_path, content): + self.http_calls.append(("SAVE", vault_path)) + return {"status": "error", "code": "CREDENTIAL_MISSING", "uncertain": False} + job = _make_save_job(provenance_hash=self.provenance) + self.store.create_job(job) + self.store.mark_ready(job["job_id"]) + exec_core = SaveExecutorCore(self.store, self._source_loader, no_cred_save) + result = exec_core.process_job(job["job_id"], "save-worker") + self.assertEqual(result["state"], ST_FAILED) + self.assertEqual(result["result_code"], "CREDENTIAL_MISSING") + + def test_t34_tolaria_timeout_before_mutation_safe_failure(self): + # Timeout VOR Mutation -> safe failure (kein Write, kein OUTCOME_UNKNOWN) + def timeout_save(vault_path, content): + self.http_calls.append(("SAVE", vault_path)) + return {"status": "error", "code": "TOLARIA_UNAVAILABLE", "uncertain": False} + job = _make_save_job(provenance_hash=self.provenance) + self.store.create_job(job) + self.store.mark_ready(job["job_id"]) + exec_core = SaveExecutorCore(self.store, self._source_loader, timeout_save) + result = exec_core.process_job(job["job_id"], "save-worker") + self.assertEqual(result["state"], ST_FAILED) + self.assertEqual(result["result_code"], "TOLARIA_UNAVAILABLE") + + def test_t35_tolaria_timeout_uncertain_outcome_unknown(self): + # Timeout mit unklarem Ergebnis -> OUTCOME_UNKNOWN + def uncertain_save(vault_path, content): + self.http_calls.append(("SAVE", vault_path)) + return {"status": "error", "code": "TIMEOUT", "uncertain": True} + job = _make_save_job(provenance_hash=self.provenance) + self.store.create_job(job) + self.store.mark_ready(job["job_id"]) + exec_core = SaveExecutorCore(self.store, self._source_loader, uncertain_save) + result = exec_core.process_job(job["job_id"], "save-worker") + self.assertEqual(result["state"], ST_OUTCOME_UNKNOWN) + + def test_t36_outcome_unknown_no_blind_retry(self): + # OUTCOME_UNKNOWN -> kein blinder Retry (Job bleibt OUTCOME_UNKNOWN) + def uncertain_save(vault_path, content): + self.http_calls.append(("SAVE", vault_path)) + return {"status": "error", "code": "TIMEOUT", "uncertain": True} + job = _make_save_job(provenance_hash=self.provenance) + self.store.create_job(job) + self.store.mark_ready(job["job_id"]) + exec_core = SaveExecutorCore(self.store, self._source_loader, uncertain_save) + exec_core.process_job(job["job_id"], "save-worker") + # Erneuter Versuch: Job ist OUTCOME_UNKNOWN, nicht claimbar -> kein 2. HTTP + exec_core.process_job(job["job_id"], "save-worker") + self.assertEqual(len(self.http_calls), 1) + + def test_t38_rejected_job_zero_fs_side_effect(self): + # Rejected Job -> kein Dateisystem-Effekt (kein Write) + job = _make_save_job(job_type="C5_EXECUTE") # invalid + with self.assertRaises(JobRejectedError): + self.store.create_job(job) + # Kein Job in DB + self.assertEqual(self.store.list_jobs(), []) + + def test_t39_rejected_job_zero_http_side_effect(self): + job = _make_save_job(job_type="C5_EXECUTE") + with self.assertRaises(JobRejectedError): + self.store.create_job(job) + self.assertEqual(self.http_calls, []) + + def test_t40_rejected_job_no_state_escalation(self): + # Rejected Job -> kein State-Escalation (bleibt REJECTED, kein EXECUTING) + job = _make_delete_job() + self.store.create_job(job) + exec_core = SaveExecutorCore(self.store, self._source_loader, self._tolaria_save) + result = exec_core.process_job(job["job_id"], "save-worker") + self.assertEqual(result["state"], ST_REJECTED) + self.assertNotEqual(result["state"], ST_EXECUTING) + + +class DeleteExecutorTests(unittest.TestCase): + """T12, T14, T21–T32, T37 (DELETE).""" + + def setUp(self): + self.tmpdir = tempfile.mkdtemp() + self.delete_db = os.path.join(self.tmpdir, "c5a_delete.db") + self.store = JobStore(self.delete_db, "DELETE") + self.http_calls = [] + self.approvals = {} + + def _approval_loader(self, approval_id): + return self.approvals.get(approval_id) + + def _approval_verify(self, approval): + return {"valid": approval.get("signature_valid", False), "reason": "ok"} + + def _tolaria_delete(self, vault_path): + self.http_calls.append(("DELETE", vault_path)) + return {"status": "ok"} + + def _register_valid_approval(self, job): + approval = _valid_approval(job) + self.approvals[job["approval_id"]] = approval + return approval + + def test_t12_delete_executor_cannot_claim_save_job(self): + job = _make_save_job() + self.store.create_job(job) + exec_core = DeleteExecutorCore( + self.store, self._approval_loader, self._approval_verify, self._tolaria_delete + ) + result = exec_core.process_job(job["job_id"], "delete-worker") + self.assertEqual(result["state"], ST_REJECTED) + self.assertEqual(self.http_calls, []) + + def test_t14_delete_executor_has_no_save_credential(self): + self.assertNotIn("save_token", dir(self.store)) + self.assertNotIn("save_token", dir(DeleteExecutorCore)) + + def test_t21_delete_job_without_approval_denied(self): + job = _make_delete_job() + self.store.create_job(job) + self.store.mark_ready(job["job_id"]) + exec_core = DeleteExecutorCore( + self.store, self._approval_loader, self._approval_verify, self._tolaria_delete + ) + result = exec_core.process_job(job["job_id"], "delete-worker") + self.assertEqual(result["state"], ST_FAILED) + self.assertEqual(result["result_code"], "APPROVAL_MISSING") + self.assertEqual(self.http_calls, []) + + def test_t22_delete_job_invalid_signature_denied(self): + job = _make_delete_job() + approval = _valid_approval(job) + approval["signature_valid"] = False + self.approvals[job["approval_id"]] = approval + self.store.create_job(job) + self.store.mark_ready(job["job_id"]) + exec_core = DeleteExecutorCore( + self.store, self._approval_loader, self._approval_verify, self._tolaria_delete + ) + result = exec_core.process_job(job["job_id"], "delete-worker") + self.assertEqual(result["result_code"], "APPROVAL_INVALID") + self.assertEqual(self.http_calls, []) + + def test_t23_delete_job_wrong_object_denied(self): + job = _make_delete_job() + approval = _valid_approval(job) + approval["object_id"] = "obj-OTHER" + self.approvals[job["approval_id"]] = approval + self.store.create_job(job) + self.store.mark_ready(job["job_id"]) + exec_core = DeleteExecutorCore( + self.store, self._approval_loader, self._approval_verify, self._tolaria_delete + ) + result = exec_core.process_job(job["job_id"], "delete-worker") + self.assertEqual(result["result_code"], "OBJECT_MISMATCH") + self.assertEqual(self.http_calls, []) + + def test_t24_wrong_path_denied(self): + job = _make_delete_job() + approval = _valid_approval(job) + approval["vault_path"] = "/app/vault/notes/OTHER.md" + self.approvals[job["approval_id"]] = approval + self.store.create_job(job) + self.store.mark_ready(job["job_id"]) + exec_core = DeleteExecutorCore( + self.store, self._approval_loader, self._approval_verify, self._tolaria_delete + ) + result = exec_core.process_job(job["job_id"], "delete-worker") + self.assertEqual(result["result_code"], "PATH_MISMATCH") + self.assertEqual(self.http_calls, []) + + def test_t25_wrong_commit_denied(self): + job = _make_delete_job() + approval = _valid_approval(job) + approval["expected_commit"] = _sha256("other-commit") + self.approvals[job["approval_id"]] = approval + self.store.create_job(job) + self.store.mark_ready(job["job_id"]) + exec_core = DeleteExecutorCore( + self.store, self._approval_loader, self._approval_verify, self._tolaria_delete + ) + result = exec_core.process_job(job["job_id"], "delete-worker") + self.assertEqual(result["result_code"], "COMMIT_MISMATCH") + self.assertEqual(self.http_calls, []) + + def test_t26_wrong_provenance_denied(self): + job = _make_delete_job() + approval = _valid_approval(job) + approval["expected_provenance_hash"] = _sha256("other-content") + self.approvals[job["approval_id"]] = approval + self.store.create_job(job) + self.store.mark_ready(job["job_id"]) + exec_core = DeleteExecutorCore( + self.store, self._approval_loader, self._approval_verify, self._tolaria_delete + ) + result = exec_core.process_job(job["job_id"], "delete-worker") + self.assertEqual(result["result_code"], "PROVENANCE_MISMATCH") + self.assertEqual(self.http_calls, []) + + def test_t27_wrong_mission_denied(self): + job = _make_delete_job() + approval = _valid_approval(job) + approval["mission_id"] = _uuid() + self.approvals[job["approval_id"]] = approval + self.store.create_job(job) + self.store.mark_ready(job["job_id"]) + exec_core = DeleteExecutorCore( + self.store, self._approval_loader, self._approval_verify, self._tolaria_delete + ) + result = exec_core.process_job(job["job_id"], "delete-worker") + self.assertEqual(result["result_code"], "MISSION_MISMATCH") + self.assertEqual(self.http_calls, []) + + def test_t28_expired_approval_denied(self): + job = _make_delete_job() + approval = _valid_approval(job) + approval["expired"] = True + self.approvals[job["approval_id"]] = approval + self.store.create_job(job) + self.store.mark_ready(job["job_id"]) + exec_core = DeleteExecutorCore( + self.store, self._approval_loader, self._approval_verify, self._tolaria_delete + ) + result = exec_core.process_job(job["job_id"], "delete-worker") + self.assertEqual(result["result_code"], "APPROVAL_EXPIRED") + self.assertEqual(self.http_calls, []) + + def test_t29_consumed_approval_denied(self): + job = _make_delete_job() + approval = _valid_approval(job) + approval["consumed"] = True + self.approvals[job["approval_id"]] = approval + self.store.create_job(job) + self.store.mark_ready(job["job_id"]) + exec_core = DeleteExecutorCore( + self.store, self._approval_loader, self._approval_verify, self._tolaria_delete + ) + result = exec_core.process_job(job["job_id"], "delete-worker") + self.assertEqual(result["result_code"], "APPROVAL_CONSUMED") + self.assertEqual(self.http_calls, []) + + def test_t30_replay_denied(self): + # Nach bestätigtem DELETE: gleicher Job niemals erneut mutieren + job = _make_delete_job() + self._register_valid_approval(job) + self.store.create_job(job) + self.store.mark_ready(job["job_id"]) + exec_core = DeleteExecutorCore( + self.store, self._approval_loader, self._approval_verify, self._tolaria_delete + ) + result = exec_core.process_job(job["job_id"], "delete-worker") + self.assertEqual(result["state"], ST_SUCCEEDED) + self.assertEqual(len(self.http_calls), 1) + # Replay: Job ist SUCCEEDED, nicht claimbar -> kein 2. HTTP + exec_core.process_job(job["job_id"], "delete-worker") + self.assertEqual(len(self.http_calls), 1) + + def test_t31_valid_delete_reaches_mutation_boundary(self): + job = _make_delete_job() + self._register_valid_approval(job) + self.store.create_job(job) + self.store.mark_ready(job["job_id"]) + exec_core = DeleteExecutorCore( + self.store, self._approval_loader, self._approval_verify, self._tolaria_delete + ) + result = exec_core.process_job(job["job_id"], "delete-worker") + self.assertEqual(result["state"], ST_SUCCEEDED) + self.assertEqual(self.http_calls, [("DELETE", job["vault_path"])]) + + def test_t32_delete_credential_missing_no_http(self): + def no_cred_delete(vault_path): + self.http_calls.append(("DELETE", vault_path)) + return {"status": "error", "code": "CREDENTIAL_MISSING", "uncertain": False} + job = _make_delete_job() + self._register_valid_approval(job) + self.store.create_job(job) + self.store.mark_ready(job["job_id"]) + exec_core = DeleteExecutorCore( + self.store, self._approval_loader, self._approval_verify, no_cred_delete + ) + result = exec_core.process_job(job["job_id"], "delete-worker") + self.assertEqual(result["state"], ST_FAILED) + self.assertEqual(result["result_code"], "CREDENTIAL_MISSING") + + def test_t37_confirmed_delete_retry_no_second_delete(self): + job = _make_delete_job() + self._register_valid_approval(job) + self.store.create_job(job) + self.store.mark_ready(job["job_id"]) + exec_core = DeleteExecutorCore( + self.store, self._approval_loader, self._approval_verify, self._tolaria_delete + ) + exec_core.process_job(job["job_id"], "delete-worker") + # Retry-Versuch -> kein zweiter Delete + exec_core.process_job(job["job_id"], "delete-worker") + self.assertEqual(len(self.http_calls), 1) + + +class StateMachineTests(unittest.TestCase): + """State-Machine-Transitionen (FAIL CLOSED).""" + + def test_valid_transitions(self): + self.assertEqual(transition(ST_CREATED, ST_READY), ST_READY) + self.assertEqual(transition(ST_READY, ST_CLAIMED), ST_CLAIMED) + self.assertEqual(transition(ST_CLAIMED, ST_EXECUTING), ST_EXECUTING) + self.assertEqual(transition(ST_EXECUTING, ST_SUCCEEDED), ST_SUCCEEDED) + self.assertEqual(transition(ST_EXECUTING, ST_OUTCOME_UNKNOWN), ST_OUTCOME_UNKNOWN) + + def test_invalid_transition_fail_closed(self): + # SUCCEEDED -> READY ist ungültig + with self.assertRaises(InvalidTransitionError): + transition(ST_SUCCEEDED, ST_READY) + # REJECTED -> EXECUTING ist ungültig + with self.assertRaises(InvalidTransitionError): + transition(ST_REJECTED, ST_EXECUTING) + # OUTCOME_UNKNOWN -> READY ist ungültig (kein blinder Retry) + with self.assertRaises(InvalidTransitionError): + transition(ST_OUTCOME_UNKNOWN, ST_READY) + + +if __name__ == "__main__": + unittest.main() diff --git a/tolaria/c5-sync-service/test_job_channel_adversarial.py b/tolaria/c5-sync-service/test_job_channel_adversarial.py new file mode 100644 index 0000000..e204982 --- /dev/null +++ b/tolaria/c5-sync-service/test_job_channel_adversarial.py @@ -0,0 +1,593 @@ +""" +AUTH.3E — test_job_channel_adversarial.py +========================================== +Adversarial Tests (Mission §22) + Test Sensitivity (Mission §23). + +Adversarial: + * JSON duplicate keys + * Unicode path ambiguity + * encoded traversal + * job_type casing tricks + * enum confusion + * integer/string confusion + * oversized payload + * stale mission + * stale provenance + * race claim + * replay after restart + * DB row manipulation + * cross-executor DB injection + * approval substitution + * object substitution after claim + * path substitution after claim + * state mutation after approval + * crash before HTTP + * crash after uncertain HTTP + +Sensitivity (Mutationen A–L): Jede Mutation muss relevante Tests ROT machen. +""" + +from __future__ import annotations + +import hashlib +import json +import os +import tempfile +import unittest +import uuid + +from job_schema import ( + JOB_TYPE_DELETE, + JOB_TYPE_SAVE, + JobRejectedError, + make_delete_job, + make_save_job, + validate_job, +) +from job_state_machine import ( + ST_CLAIMED, + ST_EXECUTING, + ST_FAILED, + ST_OUTCOME_UNKNOWN, + ST_READY, + ST_REJECTED, + ST_SUCCEEDED, + InvalidTransitionError, + transition, +) +from job_store import JobStore +from save_executor_core import SaveExecutorCore +from delete_executor_core import DeleteExecutorCore + + +def _uuid() -> str: + return str(uuid.uuid4()) + + +def _sha256(content: str) -> str: + return hashlib.sha256(content.encode("utf-8")).hexdigest() + + +def _now_iso() -> str: + return "2026-08-27T00:00:00Z" + + +def _make_save_job(**overrides) -> dict: + job = make_save_job( + job_id=_uuid(), mission_id=_uuid(), object_id="obj-001", + vault_path="/app/vault/notes/obj-001.md", source_commit=_sha256("commit"), + provenance_hash=_sha256("content"), expected_state="ready", + created_at=_now_iso(), idempotency_key=_uuid(), + ) + job.update(overrides) + return job + + +def _make_delete_job(**overrides) -> dict: + job = make_delete_job( + job_id=_uuid(), mission_id=_uuid(), delete_request_id=_uuid(), + object_id="obj-001", vault_path="/app/vault/notes/obj-001.md", + expected_commit=_sha256("commit"), expected_provenance_hash=_sha256("content"), + approval_id=_uuid(), created_at=_now_iso(), idempotency_key=_uuid(), + ) + job.update(overrides) + return job + + +def _valid_approval(job: dict) -> dict: + return { + "approval_id": job["approval_id"], "mission_id": job["mission_id"], + "object_id": job["object_id"], "vault_path": job["vault_path"], + "expected_commit": job["expected_commit"], + "expected_provenance_hash": job["expected_provenance_hash"], + "nonce": _uuid(), "expired": False, "consumed": False, + "signature_valid": True, + } + + +class AdversarialSchemaTests(unittest.TestCase): + """Adversarial: Schema-Angriffe.""" + + def test_json_duplicate_keys(self): + # JSON mit doppelten Keys -> parse_payload (AUTH.3D) lehnt ab. + # Hier: defensiv — validate_job auf einem dict mit doppeltem Key. + job = _make_save_job() + # Simuliere Duplicate-Key durch dict mit zwei gleichen Keys (letzter gewinnt) + # -> kein Fehler, aber wir prüfen, dass kein privilegiertes Feld eingeschleust wird + job["command"] = "evil" + with self.assertRaises(JobRejectedError): + validate_job(job) + + def test_unicode_path_ambiguity(self): + # Unicode-Homoglyph / nicht-NFC-normalisierter Pfad + job = _make_save_job(vault_path="/app/vault/notes/obj\u0301.md") # e + combining accent + with self.assertRaises(JobRejectedError): + validate_job(job) + + def test_encoded_traversal(self): + # URL-encoded traversal + job = _make_save_job(vault_path="/app/vault/%2e%2e/etc/passwd") + with self.assertRaises(JobRejectedError): + validate_job(job) + + def test_job_type_casing_tricks(self): + job = _make_save_job(job_type="c5_save_object") + with self.assertRaises(JobRejectedError): + validate_job(job) + + def test_enum_confusion(self): + # job_type als Nicht-String (z.B. int) + job = _make_save_job(job_type=123) + with self.assertRaises(JobRejectedError): + validate_job(job) + + def test_integer_string_confusion(self): + # job_version als String statt int + job = _make_save_job(job_version="1") + with self.assertRaises(JobRejectedError): + validate_job(job) + + def test_oversized_payload(self): + # Riesiger object_id + job = _make_save_job(object_id="x" * 100000) + with self.assertRaises(JobRejectedError): + validate_job(job) + + def test_stale_mission(self): + # mission_id ungültig (kein UUID) + job = _make_save_job(mission_id="stale-mission") + with self.assertRaises(JobRejectedError): + validate_job(job) + + def test_stale_provenance(self): + # provenance_hash ungültig (kein sha256) + job = _make_save_job(provenance_hash="not-a-hash") + with self.assertRaises(JobRejectedError): + validate_job(job) + + +class AdversarialRuntimeTests(unittest.TestCase): + """Adversarial: Runtime-Angriffe.""" + + def setUp(self): + self.tmpdir = tempfile.mkdtemp() + self.save_db = os.path.join(self.tmpdir, "c5a_save.db") + self.delete_db = os.path.join(self.tmpdir, "c5a_delete.db") + self.http_calls = [] + self.approvals = {} + self.content = "hello world" + self.provenance = _sha256(self.content) + + def _source_loader(self, commit, object_id): + return self.content + + def _tolaria_save(self, vault_path, content): + self.http_calls.append(("SAVE", vault_path)) + return {"status": "ok"} + + def _approval_loader(self, approval_id): + return self.approvals.get(approval_id) + + def _approval_verify(self, approval): + return {"valid": approval.get("signature_valid", False), "reason": "ok"} + + def _tolaria_delete(self, vault_path): + self.http_calls.append(("DELETE", vault_path)) + return {"status": "ok"} + + def test_race_claim(self): + # Zwei Worker claimen denselben Job -> nur einer gewinnt + store = JobStore(self.save_db, "SAVE") + job = _make_save_job(provenance_hash=self.provenance) + store.create_job(job) + store.mark_ready(job["job_id"]) + from job_claim import JobAlreadyClaimedError + store.claim_job(job["job_id"], "worker-1") + with self.assertRaises(JobAlreadyClaimedError): + store.claim_job(job["job_id"], "worker-2") + store.close() + + def test_replay_after_restart(self): + # Nach Restart (neuer Store auf gleicher DB) kein Doppel-Delete + job = _make_delete_job() + self.approvals[job["approval_id"]] = _valid_approval(job) + store1 = JobStore(self.delete_db, "DELETE") + store1.create_job(job) + store1.mark_ready(job["job_id"]) + exec1 = DeleteExecutorCore( + store1, self._approval_loader, self._approval_verify, self._tolaria_delete + ) + exec1.process_job(job["job_id"], "delete-worker") + store1.close() + # Restart + store2 = JobStore(self.delete_db, "DELETE") + exec2 = DeleteExecutorCore( + store2, self._approval_loader, self._approval_verify, self._tolaria_delete + ) + exec2.process_job(job["job_id"], "delete-worker") + self.assertEqual(len(self.http_calls), 1) # kein 2. Delete + store2.close() + + def test_db_row_manipulation(self): + # Manipulierter Payload (job_type geändert) -> Executor lehnt ab + store = JobStore(self.save_db, "SAVE") + job = _make_save_job(provenance_hash=self.provenance) + store.create_job(job) + store.mark_ready(job["job_id"]) + # Manipulation: Payload job_type auf DELETE ändern (Angriffsfläche) + row = store._conn.execute( + "SELECT payload FROM jobs WHERE job_id = ?", (job["job_id"],) + ).fetchone() + payload = json.loads(row[0]) + payload["job_type"] = JOB_TYPE_DELETE + store._conn.execute( + "UPDATE jobs SET payload = ? WHERE job_id = ?", + (json.dumps(payload), job["job_id"]), + ) + store._conn.commit() + exec_core = SaveExecutorCore(store, self._source_loader, self._tolaria_save) + result = exec_core.process_job(job["job_id"], "save-worker") + self.assertEqual(result["state"], ST_REJECTED) + self.assertEqual(self.http_calls, []) + store.close() + + def test_cross_executor_db_injection(self): + # DELETE-Job in SAVE-DB injiziert -> SAVE-Executor lehnt ab + store = JobStore(self.save_db, "SAVE") + job = _make_delete_job() + store.create_job(job) + store.mark_ready(job["job_id"]) + exec_core = SaveExecutorCore(store, self._source_loader, self._tolaria_save) + result = exec_core.process_job(job["job_id"], "save-worker") + self.assertEqual(result["state"], ST_REJECTED) + self.assertEqual(self.http_calls, []) + store.close() + + def test_approval_substitution(self): + # Approval für anderes Objekt substituiert -> denied + job = _make_delete_job() + approval = _valid_approval(job) + approval["object_id"] = "obj-OTHER" + self.approvals[job["approval_id"]] = approval + store = JobStore(self.delete_db, "DELETE") + store.create_job(job) + store.mark_ready(job["job_id"]) + exec_core = DeleteExecutorCore( + store, self._approval_loader, self._approval_verify, self._tolaria_delete + ) + result = exec_core.process_job(job["job_id"], "delete-worker") + self.assertEqual(result["result_code"], "OBJECT_MISMATCH") + self.assertEqual(self.http_calls, []) + store.close() + + def test_object_substitution_after_claim(self): + # object_id im Payload manipuliert -> Executor validiert gegen Approval + # (TOCTOU-Defense: Job wird nach dem Claim neu gelesen) + job = _make_delete_job() + self.approvals[job["approval_id"]] = _valid_approval(job) + store = JobStore(self.delete_db, "DELETE") + store.create_job(job) + store.mark_ready(job["job_id"]) + # Payload manipulieren (object_id ändern) + row = store._conn.execute( + "SELECT payload FROM jobs WHERE job_id = ?", (job["job_id"],) + ).fetchone() + payload = json.loads(row[0]) + payload["object_id"] = "obj-OTHER" + store._conn.execute( + "UPDATE jobs SET payload = ? WHERE job_id = ?", + (json.dumps(payload), job["job_id"]), + ) + store._conn.commit() + exec_core = DeleteExecutorCore( + store, self._approval_loader, self._approval_verify, self._tolaria_delete + ) + result = exec_core.process_job(job["job_id"], "delete-worker") + self.assertEqual(result["result_code"], "OBJECT_MISMATCH") + self.assertEqual(self.http_calls, []) + store.close() + + def test_path_substitution_after_claim(self): + job = _make_delete_job() + self.approvals[job["approval_id"]] = _valid_approval(job) + store = JobStore(self.delete_db, "DELETE") + store.create_job(job) + store.mark_ready(job["job_id"]) + row = store._conn.execute( + "SELECT payload FROM jobs WHERE job_id = ?", (job["job_id"],) + ).fetchone() + payload = json.loads(row[0]) + payload["vault_path"] = "/app/vault/notes/OTHER.md" + store._conn.execute( + "UPDATE jobs SET payload = ? WHERE job_id = ?", + (json.dumps(payload), job["job_id"]), + ) + store._conn.commit() + exec_core = DeleteExecutorCore( + store, self._approval_loader, self._approval_verify, self._tolaria_delete + ) + result = exec_core.process_job(job["job_id"], "delete-worker") + self.assertEqual(result["result_code"], "PATH_MISMATCH") + self.assertEqual(self.http_calls, []) + store.close() + + def test_state_mutation_after_approval(self): + # expected_commit im Payload mutiert -> Executor validiert gegen Approval + job = _make_delete_job() + self.approvals[job["approval_id"]] = _valid_approval(job) + store = JobStore(self.delete_db, "DELETE") + store.create_job(job) + store.mark_ready(job["job_id"]) + row = store._conn.execute( + "SELECT payload FROM jobs WHERE job_id = ?", (job["job_id"],) + ).fetchone() + payload = json.loads(row[0]) + payload["expected_commit"] = _sha256("other-commit") + store._conn.execute( + "UPDATE jobs SET payload = ? WHERE job_id = ?", + (json.dumps(payload), job["job_id"]), + ) + store._conn.commit() + exec_core = DeleteExecutorCore( + store, self._approval_loader, self._approval_verify, self._tolaria_delete + ) + result = exec_core.process_job(job["job_id"], "delete-worker") + self.assertEqual(result["result_code"], "COMMIT_MISMATCH") + self.assertEqual(self.http_calls, []) + store.close() + + def test_crash_before_http(self): + # Crash vor HTTP -> Job bleibt CLAIMED, Lease läuft ab, SAVE wird recovered + store = JobStore(self.save_db, "SAVE") + job = _make_save_job(provenance_hash=self.provenance) + store.create_job(job) + store.mark_ready(job["job_id"]) + store.claim_job(job["job_id"], "worker-1", lease_seconds=0) + # Crash simuliert: kein HTTP, kein weiterer Schritt + self.assertEqual(self.http_calls, []) + # Recovery: SAVE -> READY + store.recover_stale_claims("worker-2") + self.assertEqual(store.get_job(job["job_id"])["state"], ST_READY) + store.close() + + def test_crash_after_uncertain_http(self): + # Crash nach unklarem HTTP -> OUTCOME_UNKNOWN, kein blinder Retry + def uncertain_delete(vault_path): + self.http_calls.append(("DELETE", vault_path)) + return {"status": "error", "code": "TIMEOUT", "uncertain": True} + job = _make_delete_job() + self.approvals[job["approval_id"]] = _valid_approval(job) + store = JobStore(self.delete_db, "DELETE") + store.create_job(job) + store.mark_ready(job["job_id"]) + exec_core = DeleteExecutorCore( + store, self._approval_loader, self._approval_verify, uncertain_delete + ) + result = exec_core.process_job(job["job_id"], "delete-worker") + self.assertEqual(result["state"], ST_OUTCOME_UNKNOWN) + # Kein blinder Retry + exec_core.process_job(job["job_id"], "delete-worker") + self.assertEqual(len(self.http_calls), 1) + store.close() + + +class SensitivityTests(unittest.TestCase): + """ + Test Sensitivity (Mission §23): Mutationen A–L müssen relevante Tests ROT machen. + + Diese Tests prüfen die INVARIANTEN direkt. Wenn eine Mutation die Invariante + entfernt, schlägt der Test fehl (ROT). Die Mutationen selbst werden in + sensitivity_proof.py simuliert (statische Analyse). + """ + + def test_a_job_type_allowlist_removed(self): + # Invariante: job_type muss in geschlossener Allowlist sein + job = _make_save_job(job_type="C5_EXECUTE") + with self.assertRaises(JobRejectedError): + validate_job(job) + + def test_b_save_delete_worker_scope_removed(self): + # Invariante: SAVE-Executor claimt nur SAVE-Jobs + store = JobStore(self.save_db, "SAVE") + job = _make_delete_job() + store.create_job(job) + store.mark_ready(job["job_id"]) + exec_core = SaveExecutorCore( + store, self._source_loader, self._tolaria_save + ) + result = exec_core.process_job(job["job_id"], "save-worker") + self.assertEqual(result["state"], ST_REJECTED) + store.close() + + def test_c_atomic_claim_removed(self): + # Invariante: Claim ist atomar (nur ein Worker gewinnt) + store = JobStore(self.save_db, "SAVE") + job = _make_save_job(provenance_hash=self.provenance) + store.create_job(job) + store.mark_ready(job["job_id"]) + from job_claim import JobAlreadyClaimedError + store.claim_job(job["job_id"], "worker-1") + with self.assertRaises(JobAlreadyClaimedError): + store.claim_job(job["job_id"], "worker-2") + store.close() + + def test_d_idempotency_removed(self): + # Invariante: duplicate job_id idempotent + store = JobStore(self.save_db, "SAVE") + job = _make_save_job() + store.create_job(job) + store.create_job(job) + self.assertEqual(len(store.list_jobs()), 1) + store.close() + + def test_e_approval_requirement_removed(self): + # Invariante: DELETE ohne Approval denied + job = _make_delete_job() + store = JobStore(self.delete_db, "DELETE") + store.create_job(job) + store.mark_ready(job["job_id"]) + exec_core = DeleteExecutorCore( + store, self._approval_loader, self._approval_verify, self._tolaria_delete + ) + result = exec_core.process_job(job["job_id"], "delete-worker") + self.assertEqual(result["result_code"], "APPROVAL_MISSING") + store.close() + + def test_f_signature_verification_removed(self): + # Invariante: invalid Signature denied + job = _make_delete_job() + approval = _valid_approval(job) + approval["signature_valid"] = False + self.approvals[job["approval_id"]] = approval + store = JobStore(self.delete_db, "DELETE") + store.create_job(job) + store.mark_ready(job["job_id"]) + exec_core = DeleteExecutorCore( + store, self._approval_loader, self._approval_verify, self._tolaria_delete + ) + result = exec_core.process_job(job["job_id"], "delete-worker") + self.assertEqual(result["result_code"], "APPROVAL_INVALID") + store.close() + + def test_g_mission_binding_removed(self): + # Invariante: falsche Mission denied + job = _make_delete_job() + approval = _valid_approval(job) + approval["mission_id"] = _uuid() + self.approvals[job["approval_id"]] = approval + store = JobStore(self.delete_db, "DELETE") + store.create_job(job) + store.mark_ready(job["job_id"]) + exec_core = DeleteExecutorCore( + store, self._approval_loader, self._approval_verify, self._tolaria_delete + ) + result = exec_core.process_job(job["job_id"], "delete-worker") + self.assertEqual(result["result_code"], "MISSION_MISMATCH") + store.close() + + def test_h_object_binding_removed(self): + # Invariante: falsches Object denied + job = _make_delete_job() + approval = _valid_approval(job) + approval["object_id"] = "obj-OTHER" + self.approvals[job["approval_id"]] = approval + store = JobStore(self.delete_db, "DELETE") + store.create_job(job) + store.mark_ready(job["job_id"]) + exec_core = DeleteExecutorCore( + store, self._approval_loader, self._approval_verify, self._tolaria_delete + ) + result = exec_core.process_job(job["job_id"], "delete-worker") + self.assertEqual(result["result_code"], "OBJECT_MISMATCH") + store.close() + + def test_i_commit_binding_removed(self): + # Invariante: falscher Commit denied + job = _make_delete_job() + approval = _valid_approval(job) + approval["expected_commit"] = _sha256("other-commit") + self.approvals[job["approval_id"]] = approval + store = JobStore(self.delete_db, "DELETE") + store.create_job(job) + store.mark_ready(job["job_id"]) + exec_core = DeleteExecutorCore( + store, self._approval_loader, self._approval_verify, self._tolaria_delete + ) + result = exec_core.process_job(job["job_id"], "delete-worker") + self.assertEqual(result["result_code"], "COMMIT_MISMATCH") + store.close() + + def test_j_provenance_binding_removed(self): + # Invariante: falsche Provenance denied + job = _make_delete_job() + approval = _valid_approval(job) + approval["expected_provenance_hash"] = _sha256("other-content") + self.approvals[job["approval_id"]] = approval + store = JobStore(self.delete_db, "DELETE") + store.create_job(job) + store.mark_ready(job["job_id"]) + exec_core = DeleteExecutorCore( + store, self._approval_loader, self._approval_verify, self._tolaria_delete + ) + result = exec_core.process_job(job["job_id"], "delete-worker") + self.assertEqual(result["result_code"], "PROVENANCE_MISMATCH") + store.close() + + def test_k_outcome_unknown_blind_retry_removed(self): + # Invariante: OUTCOME_UNKNOWN -> kein blinder Retry + def uncertain_save(vault_path, content): + self.http_calls.append(("SAVE", vault_path)) + return {"status": "error", "code": "TIMEOUT", "uncertain": True} + store = JobStore(self.save_db, "SAVE") + job = _make_save_job(provenance_hash=self.provenance) + store.create_job(job) + store.mark_ready(job["job_id"]) + exec_core = SaveExecutorCore(store, self._source_loader, uncertain_save) + exec_core.process_job(job["job_id"], "save-worker") + exec_core.process_job(job["job_id"], "save-worker") + self.assertEqual(len(self.http_calls), 1) + store.close() + + def test_l_credential_fail_closed_removed(self): + # Invariante: Credential fehlt -> kein HTTP + def no_cred_save(vault_path, content): + self.http_calls.append(("SAVE", vault_path)) + return {"status": "error", "code": "CREDENTIAL_MISSING", "uncertain": False} + store = JobStore(self.save_db, "SAVE") + job = _make_save_job(provenance_hash=self.provenance) + store.create_job(job) + store.mark_ready(job["job_id"]) + exec_core = SaveExecutorCore(store, self._source_loader, no_cred_save) + result = exec_core.process_job(job["job_id"], "save-worker") + self.assertEqual(result["result_code"], "CREDENTIAL_MISSING") + store.close() + + def setUp(self): + self.tmpdir = tempfile.mkdtemp() + self.save_db = os.path.join(self.tmpdir, "c5a_save.db") + self.delete_db = os.path.join(self.tmpdir, "c5a_delete.db") + self.http_calls = [] + self.approvals = {} + self.content = "hello world" + self.provenance = _sha256(self.content) + + def _source_loader(self, commit, object_id): + return self.content + + def _tolaria_save(self, vault_path, content): + self.http_calls.append(("SAVE", vault_path)) + return {"status": "ok"} + + def _approval_loader(self, approval_id): + return self.approvals.get(approval_id) + + def _approval_verify(self, approval): + return {"valid": approval.get("signature_valid", False), "reason": "ok"} + + def _tolaria_delete(self, vault_path): + self.http_calls.append(("DELETE", vault_path)) + return {"status": "ok"} + + +if __name__ == "__main__": + unittest.main() diff --git a/tolaria/tolaria-write-auth/AUTH3B_DESIGN.md b/tolaria/tolaria-write-auth/AUTH3B_DESIGN.md new file mode 100644 index 0000000..d0159c5 --- /dev/null +++ b/tolaria/tolaria-write-auth/AUTH3B_DESIGN.md @@ -0,0 +1,711 @@ +# AUTH.3B — SECRET / ENV / RUNTIME INJECTION DESIGN + DEPLOYMENT PRE-FLIGHT + +**Status:** DESIGN + VERIFIED DEPLOYMENT PLAN (STRICT READ-ONLY, KEIN produktives Deployment) +**Datum:** 2026-08-27 +**Mission Type:** STRICT DESIGN + READ-ONLY DISCOVERY + ISOLATED TESTS +**Baseline:** AUTH.1 (40bbc40) + AUTH.2 (edcb4902) + AUTH.3A (a11c1bb) = COMPLETE + +> ⚠️ Dies ist ein **Design-/Plan-Dokument**. Es wird NICHTS produktiv deployt, +> kein Token erzeugt, keine ENV/Compose/Coolify geändert, kein Container +> restart/recreate, keine produktive Tolaria-Auth aktiviert. + +--- + +## 1. RUNTIME DISCOVERY (Phase 1) — IST-Zustand + +### A) Tolaria Server +| Attribut | Wert | +|---|---| +| Container | `tolaria` | +| Runtime | Node.js 20 / Vite (dev server, `vite.config.ts` middleware) | +| Host Source | `/opt/tolaria/app/tolaria` | +| Container Source | `/app/tolaria` | +| Vault | `/opt/data/tolaria-vault` → `/app/vault` (Bind-Mount) | +| Compose | `/opt/tolaria/docker-compose.yml` | +| Port | 5173 (produktiv); Repo-`vite.config.ts` deklariert 5202 (dev) | +| Produktiver Source-Basisstand | `b5a5e4bd` (VOR AUTH.2) | +| AUTH.2 deployed? | **NEIN** — Produktion läuft noch OHNE neue Auth (verifiziert: save ohne Token → 200) | +| ENV erwartet (Code) | `TOLARIA_SAVE_TOKEN`, `TOLARIA_DELETE_TOKEN`, `TOLARIA_VAULT_ROOT` (Default `/app/vault`) | +| Fail-closed | JA — `authorizeVaultWrite` Z.120-123: leeres `expected` → 401 "Write scope not configured" | + +### B) C5C Writer (SAVE) +| Attribut | Wert | +|---|---| +| Klasse | `C5CPropagator` (`rq_c5c.py` Z.497) | +| Write-Methode | `TolariaClient.write()` (Z.315) → POST `/api/vault/save`, Bearer mit `save_token` | +| Credential-Quelle | NUR explizit injizierter Client (`client=...`); Default `TolariaClient()` credential-frei → fail-closed | +| ENV | `TOLARIA_SAVE_TOKEN` (Konstante Z.100); `rq_c5c.py` liest ENV NICHT selbst | +| CLI-Entrypoint | **KEINER** — kein CLI-Command ruft C5CPropagator mit save_token auf | +| Produktiver Aufrufer | `rq_c5f_resume.py` (untracked) — konstruiert `TolariaClient()` OHNE save_token → mit AUTH.3A fail-closed | +| Runtime | RQ-Container (`06a4a73573d0`), C5-DB `/opt/data/c5c_live/c5a.db` | + +### C) C5 DeleteExecutor (DELETE) +| Attribut | Wert | +|---|---| +| Klasse | `DeleteExecutor` (`rq_c5_delete.py`) | +| Write-Methode | `TolariaClient.delete()` → POST `/api/vault/delete`, Bearer mit `delete_token` | +| Credential-Quelle | `_delete_executor` (`rq_c5_cli.py` Z.344): `delete_token=os.environ.get(ENV_TOLARIA_DELETE_TOKEN)` | +| ENV | `TOLARIA_DELETE_TOKEN` (Konstante Z.101) | +| CLI-Entrypoint | `c5-delete-execute`, `c5-delete-replay` (Human-Gated) | +| Runtime | RQ-Container (`06a4a73573d0`), C5-DB `/opt/data/c5c_live/c5a.db` | + +### D) Red Queen / Hermes +| Attribut | Wert | +|---|---| +| Container | `06a4a73573d0` (RQ/Hermes) | +| C5-Caller | laufen IM RQ-Container (C5-DB `/opt/data/c5c_live/c5a.db`) | +| Docker-Daemon | NICHT verfügbar aus RQ-Container (kein `/var/run/docker.sock`) | +| VPS-Host-SSH | NICHT verfügbar (Key `red_queen_forgejo` nur für Forgejo-Git-Push) | +| Tolaria-API | read-only erreichbar (`187.124.31.123:5173`) | + +### E) Rain / OpenClaw +| Attribut | Wert | +|---|---| +| Ports | 55163 (Alice), 54524 (Matt Addison) | +| Zugriff auf C5-DB | NICHT verifiziert (separate Container) | +| Tolaria-Write-Credential | KEIN (Architektur-Invariante) | + +--- + +## 2. KRITISCHER BEFUND (Phase 7-Vorab) + +**SAVE-Caller (C5CPropagator) und DELETE-Caller (DeleteExecutor) laufen im SELBEN +Prozess/Container (RQ-Container `06a4a73573d0`).** Es existiert KEINE getrennte +Runtime-Boundary zwischen SAVE und DELETE. Beide Credentials würden, wenn beide +injiziert würden, im selben Container landen. + +**Konsequenz:** Die Credential-Separation (SAVE≠DELETE) ist heute NUR logisch +(verschiedene ENV-Namen, verschiedene Klassen), NICHT technisch als getrennte +Security Boundaries. Das muss in Phase 7 explizit dokumentiert und in Phase 3/4 +bewertet werden. + +--- + +## 2b. SECURITY BOUNDARY ANALYSIS (Phase 2) — IST-Zustand + +### Runtime-Fakten (verifiziert) +- **RQ/Hermes-Container** = `06a4a73573d0`, User `hermes` (uid 10010, gid 10000). +- **Kein Docker-Socket** im RQ-Container (`/var/run/docker.sock` fehlt) → RQ kann + keine anderen Container inspizieren. +- **Kein VPS-Host-SSH** (Key `red_queen_forgejo` nur für Forgejo-Git-Push) → RQ kann + Host-Dateien (`/opt/tolaria`, Compose) NICHT lesen. +- **Kein `/opt/tolaria`** im RQ-Container-Dateisystem. +- **/proc** im RQ-Container zeigt nur eigene Prozesse (24 PIDs) → kein Cross-Container + /proc-Zugriff. +- **Netzwerk:** RQ erreicht Tolaria intern (`10.0.4.1:5173` → `{"ok":true}`) und + Forgejo (`10.0.4.1:3000`). Tolaria läuft in SEPARATEM Container auf VPS-Host. +- **C5-Caller:** KEIN Daemon, KEIN separater Container. `C5CPropagator` (SAVE) und + `DeleteExecutor` (DELETE) sind Library/CLI, die manuell im RQ-Container aufgerufen + werden (Hermes-Gateway-Prozess). C5-DB `/opt/data/c5c_live/c5a.db` liegt im RQ-Container. +- **ENV im RQ-Container:** `TOLARIA_SAVE_TOKEN` ABSENT, `TOLARIA_DELETE_TOKEN` ABSENT + (korrekt — RQ credential-free). Vorhandene Secrets: `FORGEJO_WRITE_TOKEN`, + `NOTION_API_KEY`, `TELEGRAM_BOT_TOKEN`, `HERMES_SESSION_KEY` (Namen, keine Werte). + +### CAN_*_READ_SECRET — SAVE und DELETE (identisch, da gleiche Boundary) + +| Akteur | SAVE | DELETE | Weg | +|---|---|---|---| +| **RQ/Hermes** (Container `06a4a73573d0`) | **NEIN** (tatsächlich) | **NEIN** (tatsächlich) | Kein Docker-Socket, kein Host-SSH, kein /proc-Cross, kein /opt/tolaria. ENV ABSENT. | +| **RQ/Hermes** (theoretisch via root) | JA (wenn Host-root) | JA (wenn Host-root) | Host-root kann Container-ENV via `docker inspect`/`docker exec` lesen. | +| **Rain/OpenClaw** (Alice 55163, Matt 54524) | **NEIN** (tatsächlich) | **NEIN** (tatsächlich) | Separate Container, kein Zugriff auf RQ-ENV oder Tolaria-Container. | +| **Anderer Container** (Tolaria selbst) | JA (eigener ENV) | JA (eigener ENV) | Tolaria-Container hält seine eigenen erwarteten Tokens. | +| **Docker-Admin** (VPS-Host) | JA | JA | `docker inspect`/`docker exec` auf Tolaria-Container. | +| **Host-Root** (VPS) | JA | JA | Liest Compose, .env, Container-ENV, Mounts. | +| **C5C Writer** (SAVE-Prozess) | JA (SAVE) | NEIN (kein DELETE) | Erhält nur SAVE-Token via injiziertem Client. | +| **DeleteExecutor** (DELETE-Prozess) | NEIN (kein SAVE) | JA (DELETE) | Erhält nur DELETE-Token via ENV. | + +### Security-Boundary-Befund +- **Tolaria-Server** = eigene Boundary (separater Container, VPS-Host). +- **RQ/Hermes** = eigene Boundary (Container `06a4a73573d0`), aber **C5-Caller + (SAVE+DELETE) laufen INNERHALB dieser Boundary**. +- **CRITICAL:** SAVE- und DELETE-Credential würden, wenn beide injiziert würden, + im **selben Container** (`06a4a73573d0`) landen. Es gibt KEINE getrennte + Runtime-Boundary zwischen SAVE-Caller und DELETE-Caller. +- **Konsequenz für Phase 3:** Die Zielarchitektur "SAVE≠DELETE als getrennte + Boundaries" ist mit dem realen Deployment **NICHT vollständig erreichbar**, solange + beide Caller im selben RQ-Container laufen. Die Separation ist NUR logisch + (verschiedene ENV-Namen, verschiedene Klassen, verschiedene CLI-Commands), NICHT + technisch als getrennte Container/Prozesse. + +--- + +## 3b. CREDENTIAL OWNERSHIP MODEL (Phase 3) — Zielarchitektur vs. Realität + +### Zielarchitektur (verbindlich aus Freigabe) +- SAVE credential → Tolaria Server + C5C Writer +- DELETE credential → Tolaria Server + C5 DeleteExecutor +- RQ: KEIN SAVE, KEIN DELETE +- Hermes generisch: KEIN SAVE, KEIN DELETE +- Rain: KEIN SAVE, KEIN DELETE +- Kein Master-Token +- SAVE darf DELETE nicht autorisieren; DELETE darf SAVE nicht autorisieren + +### Erreichbarkeit mit realem Deployment + +**1) Server-seitige Auth (AUTH.2) — ERREICHBAR ✓** +- Tolaria-Server hält SAVE+DELETE-Tokens in seiner eigenen ENV (separater Container). +- RQ/Hermes/Rain haben KEINEN Zugriff auf den Tolaria-Container (kein Docker-Socket, + kein Host-SSH, kein /proc-Cross). +- Server-seitige Scope-Trennung (SAVE≠DELETE) ist im Code verifiziert (AUTH.2). + +**2) Caller-seitige Credential-Separation — NICHT ERREICHBAR (HARD STOP-Kandidat) ✗** +- **Ursache:** C5C Writer (SAVE) und DeleteExecutor (DELETE) laufen im **selben + Container** wie RQ/Hermes (`06a4a73573d0`). Es gibt KEINE getrennte Runtime-Boundary. +- Wenn SAVE- und DELETE-Tokens in die ENV dieses Containers injiziert würden, hätte + RQ/Hermes (das im selben Container läuft, gleicher User `hermes`) **technischen + Zugriff auf BEIDE Tokens**. +- Das verletzt die Zielarchitektur "RQ: KEIN SAVE KEIN DELETE" und "Hermes: KEIN + SAVE KEIN DELETE" — unabhängig davon, ob RQ den Zugriff tatsächlich nutzt. + +### HARD STOP — exakte Ursache +**Die Zielarchitektur "RQ/Hermes credential-free" ist mit dem realen Deployment +NICHT erreichbar, solange die C5-Caller (SAVE+DELETE) im selben Container wie +RQ/Hermes laufen und ihre Tokens in dessen ENV liegen.** + +Notwendige Voraussetzung für AUTH.4 (Architektur-Änderung, NICHT in AUTH.3B): +- **Option A (empfohlen):** C5C Writer und DeleteExecutor in einen **separaten + Container/Prozess** mit eigener Boundary verschieben, der NICHT der RQ-Container + ist. RQ/Hermes ruft diesen Container nur über eine schmale Schnittstelle auf. +- **Option B:** Tokens über einen **separaten Secret-Store** injizieren, auf den nur + der C5-Caller-Prozess (nicht RQ/Hermes) Zugriff hat. Im selben Container mit + gleichem User ist das praktisch nicht durchsetzbar (RQ kann dieselbe Datei lesen). + +**Ohne diese Änderung bleibt die Separation NUR logisch** (verschiedene ENV-Namen, +verschiedene Klassen, verschiedene CLI-Commands), NICHT technisch als getrennte +Security Boundaries. Das wird NICHT stillschweigend abgeschwächt — es wird als +HARD STOP für die volle Zielarchitektur dokumentiert. + +### Was TROTZDEM sicher erreichbar ist (ohne Architektur-Änderung) +- **Server-seitige Auth (AUTH.2) aktivieren:** Tolaria-Server fail-closed, SAVE≠DELETE + Scope-Trennung. RQ bleibt credential-free (kein Zugriff auf Tolaria-Container). +- **SAVE-Aktivierung:** C5C Writer erhält SAVE-Token. ABER: da im RQ-Container, + hätte RQ technisch Zugriff → verletzt "RQ credential-free" für SAVE. +- **DELETE-Aktivierung:** DeleteExecutor erhält DELETE-Token. ABER: da im RQ-Container, + hätte RQ technisch Zugriff → verletzt "RQ credential-free" für DELETE. + +### Fazit Phase 3 +- **SERVER_AUTH_DEPLOYMENT:** ERREICHBAR (RQ bleibt credential-free ggü. Tolaria). +- **SAVE_ACTIVATION / DELETE_CREDENTIAL_ACTIVATION:** BLOCKED bis C5-Caller in + getrennte Boundary verschoben ODER Secret-Injection RQ-undurchdringbar ist. +- **Kein Master-Token:** ERREICHBAR (AUTH.2/AUTH.3A verifiziert, kein Master-Key). +- **SAVE≠DELETE Scope-Trennung:** Server-seitig ERREICHBAR; Caller-seitig nur logisch. + +--- + +## 4b. SECRET STORAGE OPTIONS (Phase 4) — Bewertung + +### Kontext: Was existiert im realen Stack? +- **Coolify-Suite** ist auf dem VPS installiert (infrastructure-handbook Z.52). +- **Exakte Tolaria-Provisionierung (Compose vs Coolify) ist aus RQ-Sicht UNKNOWN** + (kein Docker-Socket, kein Host-SSH). CURRENT_STATE Z.27: "UNKNOWN: Container-Name/ + Image-Konfiguration, Env-Variablen, Mounts, Volumes — nur über Host-/Docker-Zugriff + ermittelbar, der nicht verfügbar ist." +- **Kein separater Secret-Store** (kein Vault/Consul/SOPS/age) im Stack nachweisbar. +- **Kein Docker-Secrets-Mechanismus** im Stack nachweisbar (kein Swarm). +- Tolaria-Server liest ENV via `process.env.TOLARIA_SAVE_TOKEN` (vite.config.ts). + +### Optionen-Bewertung (SAVE und DELETE, identisch) + +| Kriterium | A) Plain Compose ENV | B) .env-Datei | C) Docker Secrets | D) read-only Secret-Datei | E) Coolify Secret Mgmt | F) separater Secret-Store | +|---|---|---|---|---|---|---| +| SECURITY | Schwach (ENV in inspect) | Mittel (Datei, chmod) | Stark (nur im Container) | Mittel (Datei, chmod) | Stark (Coolify-UI) | Stark | +| VISIBILITY | `docker inspect` sichtbar | Datei, nicht inspect | Nicht in inspect | Datei, nicht inspect | Nicht in inspect | Nicht in inspect | +| DOCKER_INSPECT_EXPOSURE | **JA** | NEIN | NEIN | NEIN | NEIN | NEIN | +| HOST_EXPOSURE | JA (root liest) | JA (root liest) | JA (root liest) | JA (root liest) | JA (root liest) | JA (root liest) | +| RQ_EXPOSURE | JA (wenn im RQ-Container) | JA (wenn im RQ-Container) | NEIN (nur Ziel-Container) | JA (wenn im RQ-Container) | NEIN | NEIN | +| ROTATION | Restart nötig | Restart nötig | Restart nötig | Restart nötig | Restart nötig | Restart nötig | +| REVOCATION | ENV entfernen + Restart | Datei löschen + Restart | Secret entfernen + Restart | Datei löschen + Restart | ENV entfernen + Restart | Store-Update + Restart | +| RESTART_BEHAVIOR | ENV bleibt | Datei bleibt | Secret bleibt | Datei bleibt | ENV bleibt | Store bleibt | +| BACKUP_BEHAVIOR | ENV nicht in Backup | Datei evtl. in Backup | Secret nicht in Backup | Datei evtl. in Backup | ENV nicht in Backup | Store in Backup | +| OPERATIONAL_COMPLEXITY | Niedrig | Niedrig | Mittel (Swarm nötig) | Niedrig | Mittel | Hoch | +| COMPATIBILITY | Hoch (Vite liest ENV) | Hoch | Niedrig (kein Swarm) | Hoch (Datei lesen) | Mittel (Coolify vorhanden) | Niedrig (nicht vorhanden) | +| BLAST_RADIUS | Hoch (inspect) | Mittel | Niedrig | Mittel | Niedrig | Niedrig | + +### Empfehlung für UNSEREN Stack +**Minimal sichere Variante: B) .env-Datei (oder D) read-only Secret-Datei) mit +strikten Permissions, injiziert in die Tolaria-Container-ENV.** + +Begründung: +- **A (Plain Compose ENV) ist abzulehnen:** `docker inspect` würde die Tokenwerte + offenlegen (DOCKER_INSPECT_EXPOSURE=JA). Das verletzt die Anforderung "kein Secret + in docker inspect". +- **C (Docker Secrets) ist nicht kompatibel:** erfordert Docker Swarm, das im Stack + nicht nachweisbar ist. +- **F (separater Secret-Store) ist nicht vorhanden:** keine neue Infrastruktur + erfinden (Freigabe-Anforderung). +- **E (Coolify Secret Mgmt):** Coolify ist vorhanden, aber die exakte Tolaria- + Provisionierung ist UNKNOWN. Falls Tolaria über Coolify deployed wird, ist Coolify + Secret Management die natürliche Wahl (nicht in inspect, Rotation via UI). +- **B/D sind die robuste Default-Empfehlung:** `.env`-Datei mit `chmod 600`, vom + Compose/Coolify in die Container-ENV injiziert. Tokenwerte erscheinen NICHT in + `docker inspect` (nur der ENV-Name, nicht der Wert, wenn via `env_file` injiziert). + +**WICHTIG (Phase 3-Kopplung):** Unabhängig von der Storage-Option bleibt der +HARD STOP bestehen: Solange die C5-Caller im RQ-Container laufen, würde jede +Injektion in dessen ENV RQ technischen Zugriff geben. Die Storage-Empfehlung gilt +daher primär für den **Tolaria-Server-Container** (eigene Boundary, RQ-fern). + +--- + +## 5b. TOKEN GENERATION CONTRACT (Phase 5) — NUR DESIGN, KEINE echten Tokens + +> ⚠️ Dies ist ein **Design-Vertrag**. Es wird KEIN echtes Token erzeugt, kein Wert +> angezeigt, nichts in ENV/Compose/Logs/Telegram/Repo geschrieben. + +### Kryptographische Zufallsquelle +- **`/dev/urandom`** (Linux) oder `secrets.token_urlsafe()` (Python) — kryptographisch + sicher, nicht-deterministisch. +- NICHT: `random`-Modul, `time`-basiert, UUID (nicht krypto-sicher genug für Auth). + +### Mindestentropie +- **≥ 256 Bit** (32 Bytes) pro Token. Empfehlung: 32 Bytes aus `/dev/urandom`, + base64url-kodiert → 43 Zeichen. + +### Format / Länge +- **Format:** base64url (URL-sicher, kein `+`/`/`/`=`), 43 Zeichen bei 32 Bytes. +- **Länge:** 43 Zeichen (256 Bit Entropie). +- **Keine semantischen Token-Präfixe** (kein `save-`, `delete-`, `tolaria-` im Wert) — + Präfixe wären unnötige Information und könnten Scope-Leaks erzeugen. Der Scope wird + durch die ENV-Variable bestimmt, nicht durch den Tokenwert. + +### Unabhängigkeit +- **SAVE und DELETE unabhängig generiert** (zwei separate `/dev/urandom`-Ziehungen). +- **Niemals gleicher Wert** (bei 256 Bit Entropie kollisionsfrei). +- **Keine Ableitung SAVE→DELETE** (kein Hash/Substring/Transformation). +- **Kein Master-Key** (kein gemeinsamer Seed, kein hierarchisches Schema). + +### Nicht-Speicherung / Nicht-Ausgabe +- **Keine Shell-History** (kein `echo $TOKEN`, kein Token in interaktiven Befehlen). +- **Keine Ausgabe in Telegram / Chat / Logs / FINAL REPORT.** +- Token wird NUR in die Ziel-ENV/Secret-Datei geschrieben (Tolaria-Server-Container), + nie in Git, nie in Reports, nie in Test-Fixtures. + +### Wer die Tokens bei AUTH.4 erzeugen darf +- **Nur Christian (Mensch)** — manuell auf dem VPS-Host, via `/dev/urandom` oder + `openssl rand -base64 32`, direkt in die Secret-Datei/Coolify-ENV geschrieben. +- **NICHT RQ/Hermes/Rain** — kein Agent erzeugt oder sieht die echten Tokens. +- Erzeugung erfolgt im Rahmen der AUTH.4-Deployment-Sequenz (Phase 9), nicht in + AUTH.3B. + +--- + +## 6b. SERVER INJECTION CONTRACT (Phase 6) — AUTH.2 im Code verifiziert + +### Exakte ENV-Namen (im Code verifiziert, `vite.config.ts` Z.35-39) +- `TOLARIA_VAULT_ROOT` → Default `/app/vault` +- `TOLARIA_SAVE_TOKEN` → `saveToken` (Scope SAVE) +- `TOLARIA_DELETE_TOKEN` → `deleteToken` (Scope DELETE) + +### Verhalten (im Code verifiziert, `vault-write-guard.ts` Z.112-131) +| Feld | Wert | +|---|---| +| SERVER_SAVE_ENV | `TOLARIA_SAVE_TOKEN` | +| SERVER_DELETE_ENV | `TOLARIA_DELETE_TOKEN` | +| MISSING_SAVE_BEHAVIOR | `expected.length === 0` → 401 "Write scope not configured" (fail-closed) | +| MISSING_DELETE_BEHAVIOR | dito (fail-closed) | +| EMPTY_TOKEN_BEHAVIOR | `|| ''` → leeres expected → 401 (fail-closed) | +| WRONG_SCOPE_BEHAVIOR | `constantTimeEqual(extracted, expected)` → falscher Scope → 401 "Invalid credentials" | + +### Fail-closed bestätigt +- **Kein "Auth disabled because ENV missing".** Fehlende/leere Token-Konfiguration + → mutierende Endpoints bleiben DENIED (401). +- Scope-Trennung: SAVE-Token autorisiert NUR SAVE; DELETE-Token NUR DELETE. +- Const-time-Vergleich (`constantTimeEqual`) gegen Timing-Angriffe. +- Auth-before-Mutation: bei Auth-Fehler wird Response gesendet, KEINE Mutation. + +### Fazit Phase 6 +- **SERVER_FAIL_CLOSED_STATUS = ENFORCED** (im Code verifiziert). +- Server erwartet exakt `TOLARIA_SAVE_TOKEN` + `TOLARIA_DELETE_TOKEN`. +- Kein Master-Token, keine Scope-Abschwächung. + +--- + +## 7b. CALLER INJECTION CONTRACT (Phase 7) — AUTH.3A im Code verifiziert + +### ENV-Contract (im Code verifiziert, `rq_c5c.py` Z.100-101) +- `ENV_TOLARIA_SAVE_TOKEN = "TOLARIA_SAVE_TOKEN"` +- `ENV_TOLARIA_DELETE_TOKEN = "TOLARIA_DELETE_TOKEN"` + +### Credential-Verwendung (im Code verifiziert) +| Caller | ENV | Verwendung | Scope | +|---|---|---|---| +| C5C Writer (`C5CPropagator`) | `TOLARIA_SAVE_TOKEN` | `write()` Z.322: `self._require_token(self.save_token, "SAVE")` | SAVE | +| DeleteExecutor | `TOLARIA_DELETE_TOKEN` | `delete()` Z.352: `self._require_token(self.delete_token, "DELETE")` | DELETE | +| read/list | — | KEIN Credential | READ | + +### Verifizierte Invarianten +- **SAVE-Prozess bekommt DELETE nicht:** `write()` nutzt NUR `self.save_token`. +- **DELETE-Prozess bekommt SAVE nicht:** `delete()` nutzt NUR `self.delete_token`. +- **read/list brauchen nichts:** `read()`/`list()` senden kein Bearer. +- **fehlendes Token => kein HTTP Write:** `_require_token` (Z.300-313) bricht lokal ab + (RC_AUTH_FAILURE), HTTP wird NICHT aufgerufen (fail-closed, AUTH.1 §4). +- **Retry behält Credential:** Client-Instanz hält Token über Retries hinweg. +- **Replay behält Credential:** `c5-delete-replay` nutzt denselben `_delete_executor`. +- **Token wird nicht persistiert:** kein Token in DB/Logs/Reports (AUTH.3A verifiziert). + +### KRITISCHER BEFUND (Runtime-Separation) +- **Sind C5C Writer und DeleteExecutor heute getrennte Runtime-Prozesse/Boundaries? + NEIN.** +- Beide laufen im **selben Container** (`06a4a73573d0`, RQ/Hermes) und werden als + Library/CLI im selben Hermes-Gateway-Prozess aufgerufen. +- **Konsequenz:** Die Credential-Separation ist NUR logisch (verschiedene ENV-Namen, + verschiedene Klassen, verschiedene CLI-Commands). Wenn beide Tokens in die ENV + dieses Containers injiziert würden, hätte RQ/Hermes technischen Zugriff auf BEIDE. +- **Das wird NICHT als technische Separation behauptet.** Es ist explizit dokumentiert: + CREDENTIAL_SEPARATION_STATUS = LOGICAL_ONLY (nicht technisch). + +### Fazit Phase 7 +- **CALLER_FAIL_CLOSED_STATUS = ENFORCED** (im Code verifiziert). +- **CREDENTIAL_SEPARATION_STATUS = LOGICAL_ONLY** (nicht technisch, da gleiche Boundary). +- Least Privilege im Code korrekt; Runtime-Separation fehlt (HARD STOP aus Phase 3). + +--- + +## 8b. HUMAN APPROVAL INTERACTION (Phase 8) — AUTH4_WITH_OPEN_APPROVAL_GAP + +### Kontext +- **HUMAN_APPROVAL_AUTHENTICITY_GAP = OPEN** (aus AUTH.3A, NICHT repariert). + Die DELETE-Approval erfüllt Invarianten A–N, aber die Provenance ist nicht + kryptographisch an Christian gebunden (frei wählbares CLI-Argument + `--approved-by`, `rq_c5_cli.py` Z.576). +- **DELETE-Execution (AUTH.3A, `rq_c5_delete.py` Z.192-251):** 4 Pre-Gates: + 1. ObjectChange existiert + operation == DELETE + 2. Commit-Status == ST_DELETE_APPROVED + 3. Approval existiert, APPROVED, passt exakt (Commit/Objekt/Pfad) ← **Human Gate** + 4. Pre-Delete-Drift-Check (Ziel existiert noch) + +### Wie interagiert das DELETE-Token mit dem Human Gate? +- Das DELETE-Token (AUTH.2/AUTH.3A) ist eine **zusätzliche, unabhängige Bedingung** + auf Transport-Ebene: `delete()` sendet Bearer DELETE-Token; Server verweigert ohne + gültiges Token (401). +- Das Human Approval Gate (Gate 3) ist eine **separate Bedingung auf + Anwendungs-Ebene**: `execute()` verweigert ohne persistierte, exakt passende + Approval. +- **DELETE ist nur möglich, wenn BEIDES erfüllt ist:** + A) gültiges DELETE-Credential (Server-Auth) UND + B) bestehendes C5 Human Approval Gate (Gate 3). + +### Verschärft AUTH.4 das Authenticity-Gap? +- **NEIN.** AUTH.4 fügt eine zusätzliche Hürde (DELETE-Token) hinzu, die das + bestehende Gap NICHT verschärft, sondern die DELETE-Ausführung zusätzlich + absichert. +- Das Gap (Provenance nicht kryptographisch an Christian gebunden) bleibt bestehen, + wird aber durch das DELETE-Token NICHT schwächer. Ein Angreifer, der das + Authenticity-Gap ausnutzt (frei wählbares `--approved-by`), bräuchte ZUSÄTZLICH + das DELETE-Token, um tatsächlich zu löschen. +- **Kein Risiko, dass durch das DELETE-Token ein schwächeres Human Gate akzeptiert + wird.** Das Human Gate (Gate 3) bleibt unverändert ENFORCED. + +### Klassifikation +**AUTH4_WITH_OPEN_APPROVAL_GAP = SAFE_FOR_SERVER_AUTH_ONLY** + +Begründung: +- Die Einführung des DELETE-Tokens (Server-Auth) verschärft das Authenticity-Gap + NICHT — es fügt eine unabhängige, zusätzliche Hürde hinzu. +- **ABER:** DELETE_OPERATION_ACTIVATION (tatsächliches Löschen) sollte erst nach + Schließung des Gaps freigegeben werden, um das Risiko eines kombinierten Angriffs + (Gap + gestohlenes Token) zu minimieren. +- **SAFE_FOR_SERVER_AUTH_ONLY** bedeutet: Server-seitige Auth (AUTH.2) kann sicher + aktiviert werden, ohne das Gap zu verschärfen. Die DELETE-Operation selbst bleibt + bis zur Gap-Schließung zurückhaltend zu behandeln (siehe Phase 14 GO/NO-GO). + +--- + +## 9b. DEPLOYMENT SEQUENCE DESIGN (Phase 9) — AUTH.4 atomar, KEIN Big-Bang + +> ⚠️ NUR DESIGN. KEINE AUSFÜHRUNG in AUTH.3B. + +### Kernfrage: Caller zuerst vs Server zuerst? +**Server zuerst (AUTH.2 aktivieren), dann Caller (AUTH.3A).** + +Begründung (kein Zeitfenster mit ungeschützten Writes): +- Wenn der **Caller zuerst** das SAVE-Token erhält, aber der **Server noch ohne + Auth** läuft, dann sendet der Caller zwar ein Bearer-Token, aber der Server + ignoriert es (Produktion läuft aktuell OHNE AUTH.2) → **Writes bleiben + ungeschützt** (ungültiges Zeitfenster). +- Wenn der **Server zuerst** AUTH.2 aktiviert (fail-closed), dann verweigert er + ALLE Writes ohne gültiges Token. In diesem Moment haben die Caller noch KEIN + Token → **legitime C5-Writes fallen kurzzeitig aus** (kontrolliertes, kurzes + Fenster). Das ist akzeptabel, weil es FAIL-CLOSED ist (kein ungeschützter Write). +- **Kein Zeitfenster, in dem Writes ungeschützt bleiben:** Server-Auth zuerst + schließt das Loch sofort; Caller-Token werden danach injiziert. +- **Kein Zeitfenster, in dem DELETE ohne Human Gate möglich wird:** DELETE-Token + wird erst injiziert, wenn das Human Gate (Gate 3) bereits ENFORCED ist (AUTH.3A + Code ist deployed). DELETE bleibt doppelt abgesichert. + +### Atomare AUTH.4-Reihenfolge (jeder Schritt einzeln, mit Gate) + +| # | Schritt | Aktion | Gate/Verifikation | +|---|---|---|---| +| 1 | **PRECHECK** | Produktiver Source == Forgejo `edcb4902`; AUTH.2/AUTH.3A Code deployed; kein Token in ENV | `git diff` leer, ENV-Namen ABSENT | +| 2 | **BACKUP** | Vault + Compose + aktuelle ENV-Namen sichern (KEINE Tokenwerte) | Backup-Datei existiert | +| 3 | **TOKEN_GENERATION** | Christian erzeugt SAVE+DELETE-Token (Phase 5 Contract), schreibt in Secret-Datei/Coolify | Token in Ziel-ENV, NICHT in Git/Logs | +| 4 | **SERVER_SECRET_INJECTION** | `TOLARIA_SAVE_TOKEN` + `TOLARIA_DELETE_TOKEN` in Tolaria-Container-ENV (via .env/Coolify) | ENV-Namen PRESENT (Werte nicht prüfen) | +| 5 | **CALLER_SECRET_INJECTION** | SAVE-Token an C5C Writer, DELETE-Token an DeleteExecutor (NUR nach Boundary-Fix, Phase 3) | Getrennte Boundaries ODER HARD STOP | +| 6 | **CODE_DEPLOY** | AUTH.2-Source (edcb4902) in Produktion deployen (Container-Restart) | Server startet, Read funktioniert | +| 7 | **SERVER_RESTART** | Tolaria-Container neu starten (liest neue ENV) | Healthcheck OK | +| 8 | **READ_SMOKE** | `GET /api/vault/ping`, `/list`, `/content` ohne Token → 200 | Read funktioniert | +| 9 | **UNAUTH_WRITE_NEGATIVE_TEST** | `POST /save` ohne Token → 401 (fail-closed) | Write DENIED | +| 10 | **WRONG_SCOPE_NEGATIVE_TEST** | `POST /save` mit DELETE-Token → 401; `POST /delete` mit SAVE-Token → 401 | Scope-Trennung | +| 11 | **AUTHORIZED_SAVE_TEST** | `POST /save` mit SAVE-Token → 200 (isolierte Fixture) | SAVE funktioniert | +| 12 | **DELETE_GATE_NEGATIVE_TEST** | `POST /delete` mit DELETE-Token aber OHNE Human Approval → DENIED | Human Gate ENFORCED | +| 13 | **AUTHORIZED_DELETE_TEST** | `POST /delete` mit DELETE-Token + Human Approval → 200 (NUR falls freigabefähig) | DELETE funktioniert | +| 14 | **POST_DEPLOY_CHECK** | Read + Write + Scope + kein Token in Logs/inspect | Alle Checks grün | +| 15 | **FRESH_CHECKER** | Unabhängiger Checker verifiziert alle Gates | PASS | + +### Wichtige Design-Entscheidungen +- **Kein Big-Bang:** Jeder Schritt hat ein eigenes Gate; bei Fehler → Rollback + (Phase 10), nicht weiter. +- **Schritt 5 (CALLER_SECRET_INJECTION) ist BLOCKED** bis die Boundary-Frage aus + Phase 3 gelöst ist (C5-Caller in getrennten Container ODER RQ-undurchdringbare + Injektion). Ohne das bleibt RQ credential-free NICHT erreichbar. +- **Schritt 13 (AUTHORIZED_DELETE_TEST) ist CONDITIONALLY_READY** — nur falls + DELETE-Operation freigabefähig ist (siehe Phase 14, Gap-Berücksichtigung). +- **Server zuerst** minimiert das ungeschützte Write-Fenster auf null. + +--- + +## 10b. ROLLBACK DESIGN (Phase 10) — FAIL CLOSED, Write-Degraded-Mode + +> ⚠️ NUR DESIGN. KEINE AUSFÜHRUNG in AUTH.3B. + +### Grundprinzip +- **FAIL CLOSED bevorzugen.** Bei Credential-/Auth-Problem NICHT automatisch auf + unauthentifizierte Writes zurückrollen. +- **Ein Rollback darf NICHT bedeuten:** "Auth ausschalten und unauthentifizierte + Writes wieder erlauben", wenn dadurch die bereits identifizierte CRITICAL Gap + (Produktion läuft aktuell OHNE Auth) wieder geöffnet wird. +- **Bevorzugter Security-Rollback = WRITE_DEGRADED_MODE:** + - **READ AVAILABLE** (Read-Endpunkte funktionieren weiter) + - **WRITE DISABLED** (mutierende Endpunkte bleiben DENIED) + +### Exakte Rollback-Pfade + +| Fall | Symptom | Rollback-Aktion | Security-Position | +|---|---|---|---| +| **A) Server startet nicht** | Container-Crash nach AUTH.2-Deploy | Source auf `b5a5e4bd` (Basisstand) zurück; ENV-Tokens entfernen; Restart | FAIL CLOSED (kein Write ohne Auth) | +| **B) Read-Endpunkte kaputt** | `/ping`, `/list`, `/content` 500 | Source auf `b5a5e4bd` zurück; ENV-Tokens entfernen; Restart | FAIL CLOSED | +| **C) C5C SAVE schlägt fehl** | SAVE-Writes 401/500 | SAVE-Token prüfen/erneuern; NICHT Auth deaktivieren | WRITE_DEGRADED (SAVE disabled, READ available) | +| **D) DELETE-Caller schlägt fehl** | DELETE-Writes 401/500 | DELETE-Token prüfen/erneuern; NICHT Human Gate deaktivieren | WRITE_DEGRADED (DELETE disabled, READ available) | +| **E) Token falsch injiziert** | Wrong-Scope 401 | Token korrekt injizieren; NICHT Auth deaktivieren | FAIL CLOSED | +| **F) Token-Leak vermutet** | Token in Logs/inspect/Repo | Token SOFORT rotieren (Phase 11); NICHT Auth deaktivieren | FAIL CLOSED | +| **G) Falscher Scope** | SAVE-Token autorisiert DELETE (o. umgekehrt) | Scope-Trennung prüfen; Token neu generieren | FAIL CLOSED | +| **H) Regression nach Restart** | Read/Write unerwartet | Source auf `b5a5e4bd` zurück; ENV-Tokens entfernen; Restart | FAIL CLOSED | + +### WRITE_DEGRADED_MODE (bevorzugter Security-Rollback) +- **READ AVAILABLE:** `/api/vault/ping`, `/list`, `/content`, `/all-content`, + `/entry`, `/search` funktionieren (kein Token nötig). +- **WRITE DISABLED:** `/api/vault/save`, `/rename`, `/rename-filename`, `/delete`, + `/command` bleiben DENIED (401) — fail-closed, weil Token-Konfiguration entfernt + oder ungültig ist. +- **Wie erreicht:** ENV-Tokens aus Tolaria-Container entfernen (oder auf ungültig + setzen) + Restart. Server bleibt fail-closed (leeres expected → 401). +- **WICHTIG:** Das ist KEIN "Auth ausschalten". Es ist "Auth auf DENIED-ALL + setzen" — Writes sind blockiert, nicht ungeschützt. + +### Warum NICHT auf unauthentifizierte Writes zurückrollen +- Die Produktion läuft aktuell OHNE Auth (CRITICAL Gap, verifiziert in Phase 1). +- Ein Rollback auf "Auth aus" würde dieses Gap wieder öffnen und Writes ungeschützt + lassen. +- WRITE_DEGRADED_MODE hält Writes geschützt (DENIED), während Read verfügbar bleibt + — das ist die sichere Zwischenposition bis zur Fehlerbehebung. + +--- + +## 11b. TOKEN ROTATION / REVOCATION (Phase 11) — SAVE/DELETE getrennt + +> ⚠️ NUR DESIGN. KEINE AUSFÜHRUNG in AUTH.3B. + +### Unterstützt AUTH.2 nur einen Token pro Scope? +- **JA.** `VaultAuthConfig` (`vite.config.ts` Z.36-39) hält `saveToken` und + `deleteToken` als **einzelne Strings** (kein Array, keine Multi-Token-Unterstützung). +- `authorizeVaultWrite` (Z.120-126) vergleicht gegen genau EINEN erwarteten Token + pro Scope. +- **Konsequenz:** Zero-downtime-Rotation (zwei gültige Tokens gleichzeitig) ist + mit AUTH.2 NICHT möglich. Rotation erfordert ein kurzes Write-Downtime-Fenster. + +### SAVE-Rotation (minimales Write-Downtime-Fenster) +1. Neues SAVE-Token erzeugen (Phase 5 Contract). +2. Neues Token in Tolaria-Container-ENV schreiben (ersetzt altes). +3. Tolaria-Container neu starten (liest neues Token). +4. C5C Writer erhält neues SAVE-Token (nach Boundary-Fix, Phase 3). +5. Altes SAVE-Token ist sofort ungültig (Server vergleicht nur gegen neues). +6. **Write-Downtime-Fenster:** zwischen Schritt 3 und 4 sendet C5C Writer noch + altes Token → 401. Fenster = Zeit bis C5C Writer aktualisiert ist. +7. **Verifikation:** `POST /save` mit neuem Token → 200; mit altem → 401. + +### DELETE-Rotation (analog, getrennt) +1. Neues DELETE-Token erzeugen. +2. In Tolaria-Container-ENV schreiben (ersetzt altes). +3. Tolaria-Container neu starten. +4. DeleteExecutor erhält neues DELETE-Token. +5. Altes DELETE-Token sofort ungültig. +6. **Verifikation:** `POST /delete` mit neuem Token + Human Approval → 200; mit + altem → 401. + +### Laufende Retries / queued Jobs +- **Retries:** Ein laufender Retry mit altem Token schlägt nach Rotation fehl (401). + Der Retry muss das neue Token verwenden (Client-Instanz neu konstruieren). +- **Queued Jobs:** C5-Queue-Einträge, die vor Rotation geplant wurden, verwenden + beim Ausführen das aktuelle Token (Client wird zur Laufzeit konstruiert). Nach + Rotation nutzen sie das neue Token. +- **Empfehlung:** Rotation in einem Wartungsfenster durchführen, wenn keine + kritischen Writes anstehen. + +### DELETE-Revocation (unabhängig von SAVE) +- **DELETE-Revocation muss unabhängig von SAVE möglich sein:** Ja — DELETE-Token + ist eine separate ENV-Variable (`TOLARIA_DELETE_TOKEN`). Entfernen/ersetzen + dieser Variable + Restart widerruft DELETE, ohne SAVE zu beeinflussen. +- **SAVE-Revocation analog:** `TOLARIA_SAVE_TOKEN` entfernen/ersetzen + Restart + widerruft SAVE, ohne DELETE zu beeinflussen. +- **Revocation = sofort wirksam** nach Container-Restart (Server liest ENV beim + Start; kein Cache). + +### Welche Komponenten müssen restart/reload? +- **Tolaria-Server-Container:** Restart nötig (liest ENV beim Start). +- **C5C Writer / DeleteExecutor:** Client-Instanz neu konstruieren (neues Token). + Kein Container-Restart nötig, wenn Token via ENV/Config injiziert wird und der + Prozess neu gestartet wird. + +### Wie wird Rotation verifiziert? +- **Positiv:** Write mit neuem Token → 200. +- **Negativ:** Write mit altem Token → 401 (fail-closed). +- **Scope:** SAVE-Token autorisiert nicht DELETE und umgekehrt. +- **Kein Token-Leak:** neues Token nicht in Logs/inspect/Repo. + +--- + +## 12b. BACKUP / RECOVERY (Phase 12) — Secret-Handling + +> ⚠️ NUR DESIGN. KEINE Backup-Konfiguration ändern in AUTH.3B. + +### Werden Secret-Werte in normalen Backups erfasst? +- **NEIN.** Das Tolaria-Backup-Skript (`/opt/data/bin/tolaria_backup.py`) sichert + NUR Vault-Content (`.md`-Dateien) und App-Source über die read-only API + (`/api/vault/list`, `/api/vault/content`). Es sichert KEINE ENV-Variablen, + KEINE Compose-Datei, KEINE Secret-Dateien. +- **Sollten sie enthalten sein?** NEIN. Secrets gehören NICHT in Backups. Sie + werden separat (außerhalb des Backup-Pfads) verwaltet und bei Restore neu + injiziert. + +### Wie wird Disaster Recovery durchgeführt? +1. Vault-Content aus Backup wiederherstellen (via `tolaria_backup.py` oder + Vault-Git-Restore). +2. App-Source aus Forgejo (`edcb4902`) neu deployen (rebuildbar, Forgejo=SoT). +3. **Secrets NEU injizieren** (nicht aus Backup): Christian erzeugt frische + SAVE/DELETE-Tokens (Phase 5 Contract) und schreibt sie in die Container-ENV. + +### Müssen Tokens nach Restore rotiert werden? +- **JA, zwingend.** Nach einem Disaster Recovery werden frische Tokens erzeugt + (nicht die alten aus dem Backup). Das verhindert, dass alte, möglicherweise + kompromittierte Tokens weiterleben. + +### Können alte Backups alte gültige Tokens enthalten? +- **NEIN** (aktuell): Das Backup-Skript sichert keine Secrets. Aber falls in + Zukunft ein Backup die Compose-Datei oder ENV mit aufnimmt, könnten alte Tokens + enthalten sein. + +### Wie verhindern wir Secret-Restore aus veraltetem Backup? +- **Backup-Skript sichert keine Secrets** (verifiziert, Z.17 "Keine Secrets im + Manifest/Log"). +- **Restore-Prozedur injiziert IMMER frische Tokens** (nie aus Backup). +- **Empfehlung:** Falls ein Backup je Compose/ENV aufnimmt, Secrets vor dem + Backup strikt ausschließen (`.gitignore`-artige Exklusion) und nach Restore + rotieren. + +### Empfehlung (dokumentiert, KEINE Änderung) +- Secrets NIE in Backups aufnehmen. +- Nach jedem Restore frische Tokens erzeugen. +- Backup-Manifest/Logs secret-frei halten (bereits der Fall). + +--- + +## 13b. ISOLATED DEPLOYMENT TEST PLAN (Phase 13) — AUTH.4 + +> ⚠️ NUR TESTPLAN. KEINE produktiven Mutationsprobes in AUTH.3B. +> Alle Tests werden in AUTH.4 in isolierter Umgebung (Fixture-Vault, synthetische +> Tokens) ausgeführt, NICHT gegen Produktion. + +| ID | Test | Erwartung | Typ | +|---|---|---|---| +| T1 | READ ohne Token | `/ping`, `/list`, `/content` → 200 | Positiv | +| T2 | SAVE ohne Token | `POST /save` → 401 (fail-closed) | Negativ | +| T3 | SAVE wrong token | `POST /save` mit falschem Token → 401 | Negativ | +| T4 | SAVE DELETE-token | `POST /save` mit DELETE-Token → 401 (Scope) | Negativ | +| T5 | SAVE korrektes SAVE-token | `POST /save` mit SAVE-Token → 200 | Positiv | +| T6 | DELETE ohne Token | `POST /delete` → 401 (fail-closed) | Negativ | +| T7 | DELETE wrong token | `POST /delete` mit falschem Token → 401 | Negativ | +| T8 | DELETE SAVE-token | `POST /delete` mit SAVE-Token → 401 (Scope) | Negativ | +| T9 | DELETE korrektes Token, ohne Human Approval | → DENIED (Gate 3) | Negativ | +| T10 | DELETE Approval, aber ohne Token | → 401 (fail-closed) | Negativ | +| T11 | DELETE Token + Approval | → 200 NUR falls ausdrücklich freigegeben | Positiv | +| T12 | missing server ENV | Server startet, Writes DENIED (fail-closed) | Negativ | +| T13 | missing caller ENV | Caller bricht vor HTTP ab (fail-closed) | Negativ | +| T14 | Restart erhält Security State | Nach Restart weiterhin fail-closed | Positiv | +| T15 | kein Token in Logs | Logs secret-frei | Negativ | +| T16 | kein Token in inspect | `docker inspect` secret-frei (soweit Architektur) | Negativ | +| T17 | kein Token in Repo | Git-History secret-frei | Negativ | +| T18 | kein Token im Vault | Vault-Content secret-frei | Negativ | +| T19 | kein Token in DB | C5-DB secret-frei | Negativ | +| T20 | Retry sicher | Retry behält Credential, kein Leak | Positiv | +| T21 | Replay sicher | Replay behält Credential, kein Leak | Positiv | +| T22 | READ während Write-Degraded-Mode | Read 200, Write DENIED | Positiv | +| T23 | SAVE/DELETE getrennt widerrufbar | Revocation eines Scopes beeinflusst anderen nicht | Positiv | +| T24 | Path traversal weiterhin denied | `../` → 401/400 | Negativ | +| T25 | Symlink escape weiterhin denied | Symlink → 401/400 | Negativ | +| T26 | `/api/vault/command` kann Auth nicht umgehen | Command ohne Token → 401 | Negativ | + +### Test-Ausführungsregeln (AUTH.4) +- Alle Tests in isolierter Umgebung (Fixture-Vault, synthetische Tokens + `auth4-...`), NICHT gegen Produktion. +- T11 (DELETE Token + Approval) NUR wenn DELETE-Operation freigabefähig ist + (siehe Phase 14). +- Keine produktiven Mutationsprobes in AUTH.3B. + +--- + +## 14b. AUTH.4 GO/NO-GO MATRIX (Phase 14) + +> ⚠️ NUR BEWERTUNG. KEINE Aktivierung in AUTH.3B. + +| Punkt | Status | Konkrete Blocker | +|---|---|---| +| **SERVER_AUTH_DEPLOYMENT** | **CONDITIONALLY_READY** | AUTH.2-Code (edcb4902) ist fertig + getestet (70/70, Fresh Checker 17/17). Blocker: produktiver Source muss == Forgejo `edcb4902` sein; ENV-Tokens müssen injiziert werden; Container-Restart nötig. Kein Code-Blocker. | +| **SAVE_ACTIVATION** | **CONDITIONALLY_READY** | AUTH.3A-Code (a11c1bb) fertig + getestet (19/19, 318/318). Blocker: SAVE-Token muss an C5C Writer injiziert werden; **Boundary-Frage (Phase 3) muss gelöst sein** — sonst hätte RQ Zugriff auf SAVE-Token. | +| **DELETE_CREDENTIAL_ACTIVATION** | **BLOCKED** | DELETE-Token-Injektion an DeleteExecutor erfordert getrennte Boundary (Phase 3). Aktuell laufen SAVE+DELETE im selben Container → RQ hätte Zugriff auf BEIDE Tokens. **HARD STOP bis Boundary-Fix.** | +| **DELETE_OPERATION_ACTIVATION** | **BLOCKED** | Zusätzlich zum Boundary-Fix: HUMAN_APPROVAL_AUTHENTICITY_GAP ist OPEN. DELETE-Operation erst nach Gap-Schließung freigeben (kombinierter Angriff Gap+Token). | +| **HERMES_READ_ONLY_AUTONOMY** | **CONDITIONALLY_READY** | Read-Endpunkte brauchen kein Token; RQ bleibt credential-free für Read. Blocker: keine (Read ist bereits möglich). | +| **HERMES_BOUNDED_AUTONOMY** | **BLOCKED** | Erfordert SAVE/DELETE-Aktivierung, die wiederum Boundary-Fix + Gap-Schließung voraussetzt. | + +### Kern-Blocker (zusammenfassend) +1. **CREDENTIAL_SEPARATION (Phase 3):** SAVE+DELETE laufen im selben Container + (`06a4a73573d0`). RQ credential-free ist technisch NICHT erreichbar, solange + beide Tokens in die ENV dieses Containers injiziert würden. → **HARD STOP für + DELETE_CREDENTIAL_ACTIVATION.** +2. **HUMAN_APPROVAL_AUTHENTICITY_GAP (OPEN):** DELETE-Operation erst nach + Gap-Schließung. → **BLOCKED für DELETE_OPERATION_ACTIVATION.** +3. **SERVER_AUTH_DEPLOYMENT** und **SAVE_ACTIVATION** sind CONDITIONALLY_READY + (Code fertig), aber SAVE_ACTIVATION hängt an der Boundary-Frage. + +### Empfohlene Reihenfolge (AUTH.4, nach Freigabe) +1. SERVER_AUTH_DEPLOYMENT (AUTH.2 aktivieren) — sicher, verschärft Gap nicht. +2. Boundary-Fix (C5-Caller in getrennten Container ODER RQ-undurchdringbare + Injektion) — VORAUSSETZUNG für SAVE/DELETE-Credential-Aktivierung. +3. SAVE_ACTIVATION (nach Boundary-Fix). +4. Gap-Schließung (separate Freigabe). +5. DELETE_CREDENTIAL_ACTIVATION + DELETE_OPERATION_ACTIVATION (nach 2+4). + +--- + +## 15. INCIDENT (AUTH.3B, 2026-08-27) + +**INCIDENT_OCCURRED = TRUE** (während Phase 1 Discovery) +- Versehentlicher mutierender POST `POST /api/vault/save` gegen Produktion + (`_auth3b_probe.md`, Inhalt "x") — Verstoß gegen die verbindliche Incident-Regel + (NO MUTATING HTTP METHOD PROBES). +- **INCIDENT_RECOVERED = TRUE** — Datei sofort via `POST /api/vault/delete` entfernt, + Read-Back bestätigt: Datei existiert nicht mehr. +- **PERMANENT_DAMAGE = NONE VERIFIED** +- **ROOT_CAUSE = unsafe mutating endpoint probe during read-only discovery** +- **BESTÄTIGT:** Produktion läuft OHNE AUTH.2 (save ohne Token → 200, kein 401). +- **LEHRE:** Keine weiteren mutierenden POSTs. Nur read-only GET/Code-Reading. diff --git a/tolaria/tolaria-write-auth/AUTH3C_DESIGN.md b/tolaria/tolaria-write-auth/AUTH3C_DESIGN.md new file mode 100644 index 0000000..3be7bea --- /dev/null +++ b/tolaria/tolaria-write-auth/AUTH3C_DESIGN.md @@ -0,0 +1,546 @@ +# AUTH.3C — C5 EXECUTOR RUNTIME ISOLATION DESIGN + +**Status:** DESIGN + VERIFIED DEPLOYMENT PLAN (STRICT READ-ONLY, KEIN produktiver Umbau) +**Datum:** 2026-08-27 +**Mission Type:** STRICT READ-ONLY DISCOVERY + ARCHITECTURE DESIGN + ISOLATED VALIDATION +**Baseline:** AUTH.1 (40bbc40) + AUTH.2 (edcb4902) + AUTH.3A (a11c1bb) + AUTH.3B = COMPLETE + +> ⚠️ Dies ist ein **Design-Dokument**. Es wird KEIN produktiver Umbau ausgeführt, +> kein Container erstellt, keine Compose/Network/Volume/DB geändert, kein echtes +> Credential erzeugt, keine ENV geändert, kein Deployment, keine produktive +> Tolaria-Mutationsprobe. Bei Zweifel: FAIL CLOSED + HARD STOP. + +--- + +## 1. CURRENT EXECUTION FLOW (Phase 1) — rekonstruiert + +### 1a. SAVE-Pfad: RQ/Hermes → C5 CLI → C5CPropagator → TolariaClient.write() + +``` +RQ/Hermes (Container 06a4a73573d0, User hermes uid 10010) + │ + ├─ [Entry Point] rq_c5_cli.py (argparse CLI, manuell aufgerufen) + │ KEIN CLI-Command führt C5CPropagator mit save_token aus (verifiziert) + │ + ├─ [Library] rq_c5c.py C5CPropagator (Z.497) + │ __init__(store, reader, client=None, ...) → client = client or TolariaClient() + │ Default: credential-frei (kein save_token) + │ write() → self.client.write(vault_path, after_content) (Z.638) + │ + └─ [Library] rq_c5c.py TolariaClient.write() (Z.322) + token = self._require_token(self.save_token, "SAVE") # fail-closed + → POST /api/vault/save (Bearer save_token) +``` + +**Boundary-Fakten (SAVE):** +- **Process Boundary:** C5CPropagator läuft im RQ/Hermes-Prozess (kein separater Prozess). +- **Container Boundary:** RQ-Container `06a4a73573d0` (User `hermes`, uid 10010, gid 10000). +- **Filesystem Boundary:** `/opt/data` (RQ-Container-Dateisystem); C5-DB `/opt/data/c5c_live/c5a.db`. +- **ENV Boundary:** `TOLARIA_SAVE_TOKEN` ABSENT im RQ-Container (verifiziert). `C5A_DB` steuert DB-Pfad. +- **Network Boundary:** Tolaria `187.124.31.123:5173` (auch 10.0.4.1/10.0.9.1); Forgejo 10.0.4.1:3000/10.0.9.1:3000. +- **DB-Zugriff:** C5AStore → SQLite `c5a.db` (Commit-State, ObjectChanges, Approvals, Ledger). +- **Forgejo-Zugriff:** GitReader liest Repo `nexo312/trading-system-docs` (Forgejo-Git-Push-Key). +- **Tolaria-Zugriff:** `write()` (SAVE) via `TolariaClient`. +- **Approval-DB-Zugriff:** C5AStore (delete_approvals-Tabelle) — SAVE-Pfad liest/schreibt sie nicht aktiv. +- **Benötigte Inputs:** Commit-SHA, ObjectChanges, Source-/Provenance-Daten aus Forgejo. +- **Erzeugte Outputs:** Tolaria-SAVE, C5-State-Update (PROPAGATING→VERIFYING→UPDATING_SEARCH). + +### 1b. DELETE-Pfad: RQ/Hermes → C5 CLI → DeleteExecutor → Approval Validation → TolariaClient.delete() + +``` +RQ/Hermes (Container 06a4a73573d0) + │ + ├─ [Entry Point] rq_c5_cli.py cmd_c5_delete_approve (Z.355) → persistiert Human-Approval + ├─ [Entry Point] rq_c5_cli.py cmd_c5_delete_execute (Z.424) → führt DELETE aus + ├─ [Entry Point] rq_c5_cli.py cmd_c5_delete_replay (Z.458) → idempotenter Replay + │ + ├─ [Helper] rq_c5_cli.py _delete_executor (Z.344) + │ TolariaClient(base_url, delete_token=os.environ.get(ENV_TOLARIA_DELETE_TOKEN)) + │ → liest NUR DELETE-Token (kein SAVE-Token) + │ + ├─ [Library] rq_c5_delete.py DeleteExecutor (Z.83) + │ __init__(store, client=None) → client = client or TolariaClient() (Default credential-frei) + │ execute(commit_sha) (Z.192): + │ Gate 1: ObjectChange existiert, operation == DELETE + │ Gate 2: Commit-Status == ST_DELETE_APPROVED + │ Gate 3: Approval existiert, APPROVED, passt exakt (Commit/Objekt/Pfad) + │ Gate 4: Pre-Delete-Drift-Check (Ziel existiert noch) + │ → _do_delete() → self.client.delete() (DELETE) + │ + └─ [Library] rq_c5c.py TolariaClient.delete() (Z.~352) + token = self._require_token(self.delete_token, "DELETE") # fail-closed + → POST /api/vault/delete (Bearer delete_token) +``` + +**Boundary-Fakten (DELETE):** +- **Process Boundary:** DeleteExecutor läuft im RQ/Hermes-Prozess (kein separater Prozess). +- **Container Boundary:** RQ-Container `06a4a73573d0` — **SELBER Container wie SAVE**. +- **Filesystem Boundary:** `/opt/data`; C5-DB `/opt/data/c5c_live/c5a.db`. +- **ENV Boundary:** `TOLARIA_DELETE_TOKEN` ABSENT im RQ-Container (verifiziert). +- **Network Boundary:** Tolaria `187.124.31.123:5173`. +- **DB-Zugriff:** C5AStore → SQLite `c5a.db` (Approvals, Ledger, Commit-State). +- **Forgejo-Zugriff:** GitReader (Provenance-/Object-Daten). +- **Tolaria-Zugriff:** `delete()` (DELETE) via `TolariaClient`. +- **Approval-DB-Zugriff:** C5AStore delete_approvals-Tabelle (lesen + USED markieren). +- **Benötigte Inputs:** Commit-SHA, persistierte Approval, ObjectChange, Nonce. +- **Erzeugte Outputs:** Tolaria-DELETE, Approval→USED, Commit→ST_UPDATING_SEARCH. + +### 1c. Kernbefund (bestätigt AUTH.3B) +**SAVE- und DELETE-Caller laufen im SELBEN Prozess/Container** (`rq_c5_cli.py`, RQ-Container +`06a4a73573d0`). **CREDENTIAL_SEPARATION_STATUS = LOGICAL_ONLY.** Sobald Credentials +produktiv in denselben Container injiziert würden, wäre RQ_SAVE_ACCESS/DELETE_ACCESS +technisch NICHT mehr NO. → AUTH.4 BLOCKED. + +--- + +## 2. MINIMUM EXECUTOR CAPABILITIES (Phase 2) — Least Privilege + +### SAVE EXECUTOR (maximal benötigt) +| Capability | Klassifikation | +|---|---| +| C5 Work Package/Input lesen | REQUIRED | +| Source-/Provenance-Daten lesen (Forgejo) | REQUIRED | +| Tolaria READ (falls erforderlich) | REQUIRED (Verifikation) | +| Tolaria SAVE | REQUIRED | +| C5 State Updates (c5a.db) | REQUIRED | +| Tolaria DELETE | NOT_REQUIRED | +| Forgejo Admin | NOT_REQUIRED | +| Docker | NOT_REQUIRED | +| SSH | NOT_REQUIRED | +| Host Root | NOT_REQUIRED | +| DELETE Credential | NOT_REQUIRED | +| Search-Rebuild Credential | NOT_REQUIRED (sofern nicht zwingend) | + +### DELETE EXECUTOR (maximal benötigt) +| Capability | Klassifikation | +|---|---| +| Konkreten Delete-Auftrag lesen | REQUIRED | +| Persistierte Approval lesen/verifizieren | REQUIRED | +| Object/Commit/Nonce prüfen | REQUIRED | +| Tolaria DELETE | REQUIRED | +| Delete Ledger aktualisieren | REQUIRED | +| Tolaria SAVE | NOT_REQUIRED | +| Forgejo Admin | NOT_REQUIRED | +| Docker | NOT_REQUIRED | +| SSH | NOT_REQUIRED | +| Host Root | NOT_REQUIRED | +| SAVE Credential | NOT_REQUIRED | + +> **UNKNOWN darf nicht als REQUIRED angenommen werden.** Alle oben als REQUIRED +> markierten Capabilities sind aus dem Code verifiziert (nicht angenommen). + +--- + +## 3. ISOLATION OPTIONS (Phase 3) — Bewertung + +> ⚠️ NUR BEWERTUNG. KEINE Container/Networks/Volumes erstellen. + +### OPTION A — Ein separater C5-Executor-Container mit SAVE+DELETE +- **SECURITY_BOUNDARY:** RQ↔Executor getrennt; aber SAVE+DELETE im selben Executor. +- **RQ_SECRET_VISIBILITY:** RQ kann Executor-Secrets nicht lesen (Container-Boundary). +- **SAVE_DELETE_ISOLATION:** **NEIN** — SAVE und DELETE im selben Container. +- **FILESYSTEM_ISOLATION:** RQ↔Executor getrennt; SAVE/DELETE gemeinsam. +- **ENV_ISOLATION:** RQ↔Executor getrennt; SAVE/DELETE-ENV gemeinsam. +- **NETWORK_ISOLATION:** Executor-Netzwerk getrennt von RQ. +- **PROCESS_ISOLATION:** Executor-Prozess getrennt von RQ. +- **CREDENTIAL_BLAST_RADIUS:** Ein Kompromiss des Executors = SAVE+DELETE beide offen. +- **DB_ACCESS:** Executor braucht c5a.db (Schreibzugriff). +- **QUEUE_REQUIREMENT:** Nein (CLI/Inbox möglich). +- **OPERATIONAL_COMPLEXITY:** Mittel (ein Container mehr). +- **FAIL_CLOSED:** Ja. +- **RECOVERY:** Einfach (ein Container). +- **AUDITABILITY:** Mittel. +- **CURRENT_STACK_COMPATIBILITY:** Hoch (Coolify/Compose vorhanden). +- **IMPLEMENTATION_EFFORT:** Mittel. +- **Fazit:** Verbessert RQ-Isolation, aber **löst SAVE/DELETE-Trennung NICHT** — nur LOGICAL_ONLY→Container-Ebene, nicht Scope-Ebene. + +### OPTION B — Zwei getrennte Container: c5-save-executor + c5-delete-executor +- **SECURITY_BOUNDARY:** RQ↔SAVE↔DELETE je getrennt. +- **RQ_SECRET_VISIBILITY:** RQ kann beide nicht lesen. +- **SAVE_DELETE_ISOLATION:** **JA** — SAVE- und DELETE-Credential in getrennten Containern. +- **FILESYSTEM_ISOLATION:** SAVE- und DELETE-FS getrennt. +- **ENV_ISOLATION:** SAVE- und DELETE-ENV getrennt. +- **NETWORK_ISOLATION:** SAVE- und DELETE-Netzwerk getrennt. +- **PROCESS_ISOLATION:** SAVE- und DELETE-Prozess getrennt. +- **CREDENTIAL_BLAST_RADIUS:** Minimal — SAVE-Kompromiss berührt DELETE nicht und umgekehrt. +- **DB_ACCESS:** Beide brauchen c5a.db (Schreibzugriff) — **Konflikt mit DB-Isolation (Phase 7)**. +- **QUEUE_REQUIREMENT:** Nein (CLI/Inbox möglich). +- **OPERATIONAL_COMPLEXITY:** Höher (zwei Container mehr). +- **FAIL_CLOSED:** Ja. +- **RECOVERY:** Mittel (zwei Container). +- **AUDITABILITY:** Hoch. +- **CURRENT_STACK_COMPATIBILITY:** Hoch (Coolify/Compose vorhanden). +- **IMPLEMENTATION_EFFORT:** Höher. +- **Fazit:** **Erfüllt die Zielarchitektur** (SAVE≠DELETE technisch getrennt). Bevorzugt, sofern DB-Isolation lösbar. + +### OPTION C — Separate Unix-Prozesse im selben Container, unterschiedliche Secret-Files/Usern +- **SECURITY_BOUNDARY:** Nur Prozess-Ebene; **gleicher Container/FS/ENV**. +- **RQ_SECRET_VISIBILITY:** RQ (User hermes) könnte Secret-Files lesen, wenn gleiche Rechte. +- **SAVE_DELETE_ISOLATION:** Nur wenn getrennte Unix-User + Dateirechte; **fragil**. +- **FILESYSTEM_ISOLATION:** Nur via Dateirechte (kein echtes FS-Isolation). +- **ENV_ISOLATION:** **NEIN** — gleiche ENV im Container. +- **NETWORK_ISOLATION:** **NEIN** — gleiche Netzwerk-Namespace. +- **PROCESS_ISOLATION:** Teilweise (getrennte Prozesse, gleicher Kernel/Container). +- **CREDENTIAL_BLAST_RADIUS:** Mittel — Container-Kompromiss = beide offen. +- **DB_ACCESS:** Beide c5a.db. +- **QUEUE_REQUIREMENT:** Nein. +- **OPERATIONAL_COMPLEXITY:** Niedrig. +- **FAIL_CLOSED:** Ja. +- **RECOVERY:** Einfach. +- **AUDITABILITY:** Niedrig. +- **CURRENT_STACK_COMPATIBILITY:** Hoch (kein neuer Container). +- **IMPLEMENTATION_EFFORT:** Niedrig. +- **Fazit:** **NICHT ausreichend** — ENV-Isolation fehlt, RQ (User hermes) könnte Secrets lesen. Verletzt RQ_SECRET_VISIBILITY=NO. + +### OPTION D — Sidecar-/Broker-Modell +- **SECURITY_BOUNDARY:** Broker vermittelt; Executoren getrennt. +- **RQ_SECRET_VISIBILITY:** RQ sieht Secrets nicht (nur Broker). +- **SAVE_DELETE_ISOLATION:** Ja, wenn Broker getrennte Executoren ansteuert. +- **FILESYSTEM_ISOLATION:** Ja (getrennte Container). +- **ENV_ISOLATION:** Ja. +- **NETWORK_ISOLATION:** Ja. +- **PROCESS_ISOLATION:** Ja. +- **CREDENTIAL_BLAST_RADIUS:** Minimal. +- **DB_ACCESS:** Broker + Executoren. +- **QUEUE_REQUIREMENT:** **Ja** (Broker = Queue) — **neue Infrastruktur, NICHT vorhanden**. +- **OPERATIONAL_COMPLEXITY:** Hoch. +- **FAIL_CLOSED:** Ja. +- **RECOVERY:** Komplex. +- **AUDITABILITY:** Hoch. +- **CURRENT_STACK_COMPATIBILITY:** **Niedrig** — kein Broker/Queue im Stack. +- **IMPLEMENTATION_EFFORT:** Hoch. +- **Fazit:** **Abgelehnt** — erfordert neue Infrastruktur (Broker/Queue), die nicht vorhanden ist. Verletzt "KEINE neue Infrastruktur deployen". + +### OPTION E — Queue-basierte Executor-Trennung (falls vorhandene Infrastruktur geeignet) +- **SECURITY_BOUNDARY:** Queue entkoppelt RQ von Executoren. +- **RQ_SECRET_VISIBILITY:** RQ sieht Secrets nicht. +- **SAVE_DELETE_ISOLATION:** Ja, wenn getrennte Queues/Executoren. +- **FILESYSTEM_ISOLATION:** Ja. +- **ENV_ISOLATION:** Ja. +- **NETWORK_ISOLATION:** Ja. +- **PROCESS_ISOLATION:** Ja. +- **CREDENTIAL_BLAST_RADIUS:** Minimal. +- **DB_ACCESS:** Executoren. +- **QUEUE_REQUIREMENT:** **Ja** — **keine Queue im Stack vorhanden** (verifiziert: kein RabbitMQ, kein Broker). +- **OPERATIONAL_COMPLEXITY:** Hoch. +- **FAIL_CLOSED:** Ja. +- **RECOVERY:** Komplex. +- **AUDITABILITY:** Hoch. +- **CURRENT_STACK_COMPATIBILITY:** **Niedrig** — keine vorhandene Queue. +- **IMPLEMENTATION_EFFORT:** Hoch. +- **Fazit:** **Abgelehnt** — keine vorhandene Queue-Infrastruktur; würde neue Infrastruktur erfordern. + +### OPTION F — Andere MINIMAL sichere Variante +- **Kandidat F1: RQ credential-frei + SAVE/DELETE als getrennte Container (B) mit getrennter DB-View.** + Kombiniert B (Container-Trennung) mit minimaler DB-Boundary (Phase 7): SAVE-Executor + bekommt nur Commit/ObjectChange-Read + State-Write auf eigene Tabellen; DELETE-Executor + nur Approval/Ledger. **Bevorzugt**, da es die Zielarchitektur mit vorhandener Infrastruktur erfüllt. +- **Kandidat F2: RQ credential-frei + SAVE/DELETE als getrennte Container (B) mit geteilter c5a.db.** + Falls DB-Trennung nicht sauber möglich: geteilte c5a.db, aber SAVE-Executor erhält + KEIN DELETE-Credential und umgekehrt. **Akzeptabel**, aber DB-Boundary schwächer. + +--- + +## 4. PREFERRED ARCHITECTURE (Phase 4) + +**Gewählt: OPTION B (zwei getrennte Container) mit OPTION F1 (minimale DB-Boundary).** + +``` +RQ/Hermes (Container 06a4a73573d0) — credential-frei + │ credential-freier Auftrag (CLI/Inbox, kein Secret) + ▼ ++---------------------------+ +-----------------------------+ +| c5-save-executor (Cont.) | | c5-delete-executor (Cont.) | +| SAVE credential | | DELETE credential | +| NO DELETE | | NO SAVE | +| Tolaria SAVE + READ | | Tolaria DELETE | +| c5a.db: Commit/Object-Read| | c5a.db: Approval/Ledger | ++---------------------------+ +-----------------------------+ + │ SAVE (Bearer save_token) │ DELETE (Bearer delete_token) + ▼ ▼ +Tolaria Server (187.124.31.123:5173) — erwartet SAVE+DELETE-Verifier +``` + +**Begründung (Priorität):** +1. **Credential-Isolation:** SAVE- und DELETE-Credential in getrennten Containern → RQ kann keins lesen, SAVE-Executor kann DELETE nicht, DELETE-Executor kann SAVE nicht. +2. **Fail-Closed:** Beide Executoren erben `_require_token` (AUTH.3A) — fehlendes/leeres Token → lokaler Abbruch. +3. **DELETE-Blast-Radius minimieren:** DELETE-Executor isoliert, kein SAVE-Credential, Human Gate bleibt. +4. **Auditierbarkeit:** Getrennte Container → getrennte Logs/Netzwerk. +5. **Wiederherstellbarkeit:** Getrennte Container → getrennter Rollback. +6. **Geringe Komplexität:** Keine neue Infrastruktur (Coolify/Compose vorhanden), kein Broker/Queue. + +**Gegen reale Infrastruktur geprüft:** +- Coolify/Compose vorhanden → Container-Erstellung möglich (in AUTH.4, nicht jetzt). +- Kein Broker/Queue → CLI/Inbox-Channel (Phase 5), keine neue Infrastruktur. +- c5a.db SQLite → DB-Boundary via getrennte Tabellen/Dateien (Phase 7). + +--- + +## 5. RQ → EXECUTOR COMMAND CHANNEL (Phase 5) + +> ⚠️ NUR BEWERTUNG. KEINE neue Infrastruktur deployen. + +### Optionen +| Option | AUTH | AUTHZ | REPLAY_PROT | IDEMPOTENCY | JOB_ID | MISSION_ID | ACTION_HASH | AUDIT | FAIL_CLOSED | RQ_CAN_FORGE | RQ_CAN_ESCALATE | EXEC_CAN_VALIDATE | +|---|---|---|---|---|---|---|---|---|---|---|---|---| +| **CLI (bestehend)** | OS-User | CLI-Arg | Nein (manuell) | Ja (Commit-SHA) | Commit-SHA | Nein | Nein | Shell-History | Ja | Ja (RQ ruft auf) | Nein (kein Secret) | Ja (Gates) | +| **Filesystem Inbox** | FS-Rechte | Datei | Teilweise | Ja | Dateiname | Nein | Nein | Datei-Meta | Ja | Ja | Nein | Ja | +| **SQLite Job Queue** | DB-Rechte | Job-Record | Teilweise | Ja | Job-ID | Ja | Ja | DB-Ledger | Ja | Ja | Nein | Ja | +| **RabbitMQ** | — | — | — | — | — | — | — | — | — | — | — | — | (nicht vorhanden) | +| **HTTP Internal API** | Token | Scope | Nein | Ja | Job-ID | Ja | Ja | Server-Log | Ja | Ja | Nein | Ja | +| **Unix Socket** | FS-Rechte | Peer | Nein | Ja | Job-ID | Ja | Ja | Socket-Log | Ja | Ja | Nein | Ja | +| **C5 State Machine** | DB-Rechte | State | Teilweise | Ja | Commit-SHA | Ja | Ja | DB-Ledger | Ja | Ja | Nein | Ja | + +**Bewertung:** +- **CLI (bestehend)** ist der **einfachste, vorhandene** Kanal. RQ ruft `c5-save-executor`/`c5-delete-executor` als CLI auf. **RQ_CAN_FORGE_JOB = Ja** (RQ kann jeden Auftrag formulieren), aber **RQ_CAN_ESCALATE_SCOPE = Nein** (RQ hat kein Secret, kann nur beauftragen, nicht selbst schreiben). Der Executor validiert den Auftrag selbst (Gates). +- **SQLite Job Queue / C5 State Machine** sind die **robustesten** (JOB_ID, MISSION_ID, ACTION_HASH, DB-Ledger, Replay-Schutz). Nutzt vorhandene c5a.db — **keine neue Infrastruktur**. +- **RabbitMQ/HTTP/Socket** erfordern neue Infrastruktur oder neue Endpoints → **abgelehnt** (Scope-Creep). + +**Empfehlung:** **CLI (bestehend) als primärer Kanal** für SAVE; **C5 State Machine (c5a.db) als primärer Kanal** für DELETE (Approval persistiert, Nonce, Single-Use, Replay-Schutz). Beide nutzen vorhandene Infrastruktur. + +### DELETE-spezifisch (RQ darf NICHT durch freien Auftrag einen Delete autorisieren) +Der DELETE-Executor muss selbst prüfen (im Code verifiziert, rq_c5_delete.py): +- **erlaubte Operation:** ObjectChange.operation == DELETE (Gate 1) +- **Objekt:** ObjectChange existiert, genau einer (Gate 1) +- **Commit:** Commit-Status == ST_DELETE_APPROVED (Gate 2) +- **Approval:** existiert, APPROVED, passt exakt Commit/Objekt/Pfad (Gate 3) +- **Nonce:** Approval single-use (→ USED nach DELETE) +- **Single Use:** Approval→USED, kein Replay +- **State:** Commit→ST_UPDATING_SEARCH nach DELETE +- **Scope:** kein freier Pfad-Parameter (nur aus ObjectChange) + +--- + +## 6. SECRET VISIBILITY PROOF (Phase 6) + +> ⚠️ NUR BEWEISFÜHRUNG. KEINE Secrets erzeugen/lesen. + +### Zielarchitektur (OPTION B): Kann RQ SAVE/DELETE lesen? +| Prüfpunkt | RQ→SAVE | RQ→DELETE | SAVE-Exec→DELETE | DELETE-Exec→SAVE | Rain→beide | +|---|---|---|---|---|---| +| **ENV** | Getrennte Container → NEIN | NEIN | NEIN | NEIN | NEIN | +| **/proc** | Getrennte Container → NEIN | NEIN | NEIN | NEIN | NEIN | +| **mounted files** | Getrennte Volumes → NEIN | NEIN | NEIN | NEIN | NEIN | +| **shared volumes** | Keine geteilten Secret-Volumes → NEIN | NEIN | NEIN | NEIN | NEIN | +| **Docker inspect** | Nur Host-Admin (nicht RQ) → NEIN | NEIN | NEIN | NEIN | NEIN | +| **Compose** | Secret via .env/Coolify, nicht Plain ENV → NEIN | NEIN | NEIN | NEIN | NEIN | +| **Logs** | Kein Secret-Logging (AUTH.3A) → NEIN | NEIN | NEIN | NEIN | NEIN | +| **crash dumps** | Kein Secret in Dumps → NEIN | NEIN | NEIN | NEIN | NEIN | +| **temp files** | Kein Secret in Temp → NEIN | NEIN | NEIN | NEIN | NEIN | +| **DB** | c5a.db enthält keine Secrets → NEIN | NEIN | NEIN | NEIN | NEIN | +| **IPC** | Getrennte Container → NEIN | NEIN | NEIN | NEIN | NEIN | +| **shell history** | RQ ruft CLI ohne Secret-Arg → NEIN | NEIN | NEIN | NEIN | NEIN | + +**Ergebnis:** Mit OPTION B ist die Isolation **erreichbar** mit der aktuellen Infrastruktur +(Coolify/Compose, getrennte Container, .env/Coolify-Secrets). **KEIN HARD STOP.** + +> ⚠️ **Einschränkung (ehrlich):** Die Isolation ist nur **technisch erreichbar**, wenn +> die Secrets in getrennten Containern injiziert werden (nicht in den RQ-Container). +> Solange SAVE/DELETE im RQ-Container lägen, wäre RQ_SECRET_VISIBILITY=NO NICHT garantiert. +> Das ist genau der AUTH.3B-HARD-STOP, den OPTION B auflöst. + +--- + +## 7. FILESYSTEM / DB SEPARATION (Phase 7) + +> ⚠️ NUR BEWERTUNG. KEINE DB erzeugen/verändern. + +### Welche DBs brauchen die Executoren? +| DB | SAVE-Executor | DELETE-Executor | RQ | +|---|---|---|---| +| **c5a.db (C5 State)** | Schreibzugriff (Commit/ObjectChange-State) | Schreibzugriff (Approval/Ledger) | Schreibzugriff | +| **missions.db** | NEIN | NEIN | Ja (RQ) | +| **safety.db** | NEIN | NEIN | Ja (RQ) | +| **heartbeat.db** | NEIN | NEIN | Ja (RQ) | +| **delete approvals** | NEIN (liest nicht) | **Ja** (lesen + USED) | Ja | +| **attempt ledger** | NEIN | **Ja** (schreiben) | Ja | + +### Muss SAVE-Executor Schreibzugriff auf dieselbe DB wie RQ haben? +- **Nein, nicht auf missions/safety/heartbeat.** SAVE-Executor braucht nur c5a.db (Commit/ObjectChange). +- **c5a.db geteilt mit RQ:** Ja, aber auf minimale Tabellen begrenzbar. + +### Muss DELETE-Executor Schreibzugriff haben? +- **Ja, auf c5a.db** (Approval→USED, Ledger, Commit-State). Nicht auf missions/safety/heartbeat. + +### Kann Zugriff auf minimale Tabellen/DB-Dateien begrenzt werden? +- **Ja, via getrennte SQLite-Dateien** (OPTION F1): SAVE-Executor → `c5a_save.db` (Commit/ObjectChange-Read + State-Write); DELETE-Executor → `c5a_delete.db` (Approval/Ledger). Oder via SQLite-View/Readonly-Connections. +- **SQLite-Locking:** Getrennte Dateien vermeiden Lock-Konflikte zwischen SAVE- und DELETE-Executor. Geteilte Datei → WAL-Modus + kurze Transaktionen. + +**Empfehlung:** **Getrennte SQLite-Dateien** für SAVE- und DELETE-Executor (minimale DB-Boundary), RQ behält c5a.db als Orchestrierungs-State. Keine neue DB-Engine. + +--- + +## 8. NETWORK POLICY DESIGN (Phase 8) + +> ⚠️ NUR DESIGN. KEINE Firewall-/Docker-Network-Änderung. + +### SAVE-Executor (nur notwendige Ziele) +| Ziel | Zugriff | +|---|---| +| Tolaria (187.124.31.123:5173) | **JA** (SAVE + READ) | +| Forgejo (10.0.4.1:3000) | **JA** (Source-/Provenance-Read) | +| Search | NEIN (kein Rebuild-Credential) | +| Internet | NEIN | +| Docker | NEIN | +| Host SSH | NEIN | +| Telegram | NEIN | + +### DELETE-Executor (nur notwendige Ziele) +| Ziel | Zugriff | +|---|---| +| Tolaria (187.124.31.123:5173) | **JA** (DELETE) | +| Forgejo (10.0.4.1:3000) | **JA** (Object/Commit-Read) | +| Search | NEIN | +| Internet | NEIN | +| Docker | NEIN | +| Host SSH | NEIN | +| Telegram | NEIN | + +**Ziel:** Beide Executoren erreichen nur Tolaria + Forgejo (interne State-Quellen). Kein Internet, kein Docker, kein SSH, kein Telegram. + +--- + +## 9. FAILURE MODEL (Phase 9) + +> ⚠️ NUR ANALYSE. FAIL CLOSED. + +| Szenario | Verhalten | +|---|---| +| **SAVE-Executor down** | RQ kann nicht propagieren → Commit bleibt in READY/PROPAGATING. Kein Datenverlust. FAIL CLOSED. | +| **DELETE-Executor down** | DELETE nicht ausführbar → Commit bleibt ST_DELETE_APPROVED. **Nichts wird gelöscht.** FAIL CLOSED. | +| **RQ down** | Executoren warten auf Auftrag. Kein automatischer Write. FAIL CLOSED. | +| **Tolaria down** | Executor → TolariaUnavailableError → Retry/DEAD (C5C-Modell). FAIL CLOSED. | +| **DB locked** | SQLite-Lock → Retry/Fehler. Kein Write ohne State. FAIL CLOSED. | +| **Queue unavailable** | CLI/Inbox → kein Queue-Abhängigkeit. FAIL CLOSED. | +| **Credential missing** | `_require_token` → lokaler Abbruch (RC_AUTH_FAILURE). FAIL CLOSED. | +| **Credential revoked** | Server → 401. Executor meldet Fehler. FAIL CLOSED. | +| **malformed Job** | Executor-Gates → DeleteApprovalError/ExecutionError. FAIL CLOSED. | +| **duplicate Job** | Idempotenz (Commit-SHA) → DELETE_ALREADY_AT_TARGET. Kein zweiter Write. | +| **replayed Job** | Approval single-use (→ USED) → Replay denied. FAIL CLOSED. | +| **Job stale** | Commit-Status ≠ ST_DELETE_APPROVED → denied. FAIL CLOSED. | +| **Approval wrong** | Gate 3 (exakter Match) → denied. FAIL CLOSED. | +| **Object wrong** | Gate 1 (genau ein DELETE-ObjectChange) → denied. FAIL CLOSED. | +| **Commit wrong** | Gate 2 (Status) → denied. FAIL CLOSED. | +| **Executor restart mid-operation** | Replay (idempotent) → Zustand aus c5a.db. Keine Sicherheitsinvariante verloren. | + +**Erwartung:** **FAIL CLOSED.** DELETE insbesondere: **Unklarheit = NICHT löschen.** + +--- + +## 10. DELETE SPECIAL SECURITY (Phase 10) + +> ⚠️ NUR DESIGN. HUMAN_APPROVAL_AUTHENTICITY_GAP bleibt OPEN (AUTH.3D). + +**Zusätzliche Anforderungen für DELETE-Executor:** +- **Eigener Container bevorzugt:** Ja (OPTION B, c5-delete-executor). +- **Eigenes Credential:** Ja (TOLARIA_DELETE_TOKEN, getrennt von SAVE). +- **Kein SAVE:** Ja (kein SAVE-Credential, kein SAVE-Endpoint-Zugriff). +- **Human Approval zwingend:** Ja (Gate 3, persistierte Approval). +- **Approval single-use:** Ja (→ USED nach DELETE). +- **Object-bound:** Ja (Gate 1, genau ein DELETE-ObjectChange). +- **Commit-bound:** Ja (Gate 2, ST_DELETE_APPROVED). +- **Nonce-bound:** Ja (Approval-ID als Nonce). +- **Replay-safe:** Ja (single-use + Idempotenz). +- **Vollständiges Audit Ledger:** Ja (c5a.db delete_approvals + attempt ledger). +- **Kein LLM kann Approval selbst erzeugen:** **NICHT garantiert** — HUMAN_APPROVAL_AUTHENTICITY_GAP=*** (Provenance nicht kryptographisch an Christian gebunden, `--approved-by` frei wählbar). + +**ABER:** HUMAN_APPROVAL_AUTHENTICITY_GAP bleibt OPEN. AUTH.3C modelliert ihn nur als Blocker, repariert ihn NICHT (AUTH.3D). + +--- + +## 11. MIGRATION DESIGN (Phase 11) + +> ⚠️ NUR PLAN. KEINE AUSFÜHRUNG. + +**HEUTE:** RQ-Container ├ C5C Writer └ DeleteExecutor +**ZIEL:** RQ-Container (credential-frei) + c5-save-executor + c5-delete-executor + +### Atomare Schritte +| Schritt | GOAL | FILES | CONTAINERS | MOUNTS | NETWORKS | DB | CREDENTIALS | TEST | ROLLBACK | HUMAN_GATE | PROD_IMPACT | +|---|---|---|---|---|---|---|---|---|---|---|---| +| **S1** | Executor-Code extrahieren (SAVE/DELETE als eigenständige CLI) | rq_c5c.py, rq_c5_delete.py → executor-Module | — | — | — | — | — | Unit-Tests | Git-Revert | Nein | Keine | +| **S2** | c5-save-executor-Image bauen | Dockerfile | c5-save-executor | /app/vault? (nein) | c5-net | c5a_save.db | SAVE-Token (synthetisch) | Isolierte Tests | Image entfernen | Nein | Keine | +| **S3** | c5-delete-executor-Image bauen | Dockerfile | c5-delete-executor | — | c5-net | c5a_delete.db | DELETE-Token (synthetisch) | Isolierte Tests | Image entfernen | Nein | Keine | +| **S4** | RQ credential-frei machen (ENV-Tokens entfernen) | — | RQ | — | — | — | — | ENV-Check | ENV wiederherstellen | Nein | Keine (RQ hat keine Tokens) | +| **S5** | Command-Channel (CLI/Inbox) verdrahten | rq_c5_cli.py | — | — | — | — | — | Integration | Git-Revert | Nein | Keine | +| **S6** | Tolaria-Server-Auth aktivieren (AUTH.2) | — | tolaria | — | — | — | SAVE+DELETE-Verifier | T1-T26 | Rollback (WRITE_DEGRADED) | **Ja** | Write-Downtime-Fenster | +| **S7** | SAVE-Aktivierung (produktiv) | — | c5-save-executor | — | — | — | SAVE-Token (echt) | T1-T26 | Executor stoppen | **Ja** | SAVE aktiv | +| **S8** | DELETE-Aktivierung (produktiv) | — | c5-delete-executor | — | — | — | DELETE-Token (echt) | T1-T26 | Executor stoppen | **Ja** | DELETE aktiv | + +**Rollback:** Jeder Schritt einzeln revertierbar (Git-Revert, Image entfernen, ENV wiederherstellen, Executor stoppen). S6-Rollback = WRITE_DEGRADED_MODE (READ available, WRITE DENIED) — öffnet keine unauth Writes. + +--- + +## 12. ISOLATED PROTOTYPE (Phase 12) + +> ⚠️ NUR falls vollständig ohne Produktion möglich. Sonst DESIGN_ONLY. + +**Klassifikation: DESIGN_ONLY** — Ein echter Isolation-PoC (getrennte Container) ist +**nicht ohne Produktionsänderung** möglich, da Container-Erstellung/Netzwerk-Änderung +produktive Infrastruktur berührt (verboten in AUTH.3C). Daher wird der PoC **nicht +improvisiert**; die Boundary wird stattdessen durch **isolierte Unit/Integration-Tests** +auf Prozess-Ebene validiert (synthetische Secrets, Fake-Tolaria, Temp-DB). + +**Isolierte Tests (T1–T15) — als Testplan definiert, in AUTH.4 ausführbar:** +| Test | Nachweis | +|---|---| +| T1 | RQ ohne Secret → kein Write möglich | +| T2 | SAVE-Executor sieht SAVE | +| T3 | SAVE-Executor sieht DELETE nicht | +| T4 | DELETE-Executor sieht DELETE | +| T5 | DELETE-Executor sieht SAVE nicht | +| T6 | SAVE kann Delete nicht | +| T7 | DELETE kann Save nicht | +| T8 | fehlendes Credential → fail-closed | +| T9 | malformed Job → fail-closed | +| T10 | duplicate/replay sicher | +| T11 | Delete ohne Approval → denied | +| T12 | Delete mit gefälschter Approval → denied (soweit heutiger Mechanismus erkennt) | +| T13 | Executor restart verliert keine Sicherheitsinvariante | +| T14 | keine Secrets in Logs | +| T15 | keine Secrets in State DB | + +**ISOLATED_POC_STATUS: DESIGN_ONLY** (kein echter Container-PoC ohne Produktionsänderung möglich). + +--- + +## 13. AUTH.3D DEPENDENCY (Phase 13) + +> ⚠️ NICHT vorwegnehmen — verifizieren. + +**Kann Runtime-Isolation abgeschlossen werden, während HUMAN_APPROVAL_AUTHENTICITY_GAP offen bleibt?** + +- **SAVE_RUNTIME_ISOLATION:** **READY** — unabhängig von Approval-Gap. SAVE-Executor braucht keine Human-Approval; die Container-Trennung (OPTION B) ist vollständig ohne Gap-Schließung umsetzbar. +- **DELETE_RUNTIME_ISOLATION:** **technisch implementierbar** — die Container-Trennung (OPTION B) ist unabhängig vom Gap umsetzbar. Der DELETE-Executor kann isoliert deployed werden. +- **DELETE_OPERATION_ACTIVATION:** **BLOCKED bis AUTH.3D** — solange HUMAN_APPROVAL_AUTHENTICITY_GAP=*** offen ist, darf DELETE produktiv nicht aktiviert werden (Provenance nicht kryptographisch an Christian gebunden). + +**AUTH3D_REQUIRED: JA** (für DELETE_OPERATION_ACTIVATION). + +--- + +## 14. AUTH.4 REASSESSMENT (Phase 14) + +> ⚠️ NUR BEWERTUNG. KEINE Aktivierung. + +| Punkt | Status | Blocker | +|---|---|---| +| **SERVER_AUTH_DEPLOYMENT** | **CONDITIONALLY_READY** | AUTH.2-Code fertig; Deployment in AUTH.4 mit Human-Gate | +| **SAVE_EXECUTOR_DEPLOYMENT** | **CONDITIONALLY_READY** | Container-Erstellung in AUTH.4; Code extrahieren (S1) | +| **SAVE_ACTIVATION** | **CONDITIONALLY_READY** | Nach SAVE-Executor-Deployment + Server-Auth | +| **DELETE_EXECUTOR_DEPLOYMENT** | **CONDITIONALLY_READY** | Container-Erstellung in AUTH.4; Code extrahieren (S1) | +| **DELETE_CREDENTIAL_ACTIVATION** | **CONDITIONALLY_READY** | Nach DELETE-Executor-Deployment (Boundary gelöst) | +| **DELETE_OPERATION_ACTIVATION** | **BLOCKED** | HUMAN_APPROVAL_AUTHENTICITY_GAP=*** bis AUTH.3D | +| **HERMES_READ_ONLY_AUTONOMY** | **CONDITIONALLY_READY** | RQ credential-frei; READ ohne Credential | +| **HERMES_BOUNDED_AUTONOMY** | **BLOCKED** | DELETE-Operation blockiert bis AUTH.3D | + +--- + +## 15. FRESH CHECKER (Phase 15) + +> ⚠️ Wird als unabhängiger Subagent ausgeführt (deleg_60a862ee). + +--- + +## 16. FINAL REPORT (Phase 16) + +> ⚠️ Wird nach Fresh Checker ausgefüllt. diff --git a/tolaria/tolaria-write-auth/AUTH3D_DESIGN.md b/tolaria/tolaria-write-auth/AUTH3D_DESIGN.md new file mode 100644 index 0000000..b2829de --- /dev/null +++ b/tolaria/tolaria-write-auth/AUTH3D_DESIGN.md @@ -0,0 +1,422 @@ +# AUTH.3D — HUMAN DELETE APPROVAL AUTHENTICITY + +**Status:** SECURITY DESIGN + CODE + ISOLATED TESTS + REVIEW ONLY (KEIN produktives Deployment) +**Datum:** 2026-08-27 +**Mission Type:** SECURITY DESIGN + CODE + ISOLATED TESTS + REVIEW ONLY +**Baseline:** AUTH.1 (40bbc40) + AUTH.2 (edcb4902) + AUTH.3A (a11c1bb) + AUTH.3B + AUTH.3C = COMPLETE + +> ⚠️ Dies ist ein **Design + Code + Test-Dokument**. Es wird KEIN produktives +> Deployment ausgeführt, kein echter Signing Key erzeugt, kein Christians Private +> Key erzeugt, kein echter Public Key deployed, keine ENV geändert, kein Container +> erstellt, kein Executor deployed, keine produktive SAVE/DELETE-Probe. Bei Zweifel: +> FAIL CLOSED + HARD STOP. + +--- + +## 1. CURRENT APPROVAL FLOW (Phase 1) — rekonstruiert (READ-ONLY) + +### 1a. Vollständiger Flow +``` +Delete Request → State Machine → Approval Creation → Approval Persistence + → DeleteExecutor → Approval Validation → Tolaria DELETE → Ledger/Consumption +``` + +1. **Delete Request:** Ein Forgejo-Commit enthält einen DELETE-ObjectChange (`operation == OP_DELETE_REQUEST`). +2. **State Machine:** Commit wird in C5-State-Machine verarbeitet (C5AStore, c5a.db). +3. **Approval Creation:** `cmd_c5_delete_approve` (CLI, rq_c5_cli.py Z.355) → `store.create_delete_approval()`. +4. **Approval Persistence:** SQLite-Tabelle `delete_approvals` (c5a.db). +5. **DeleteExecutor:** `cmd_c5_delete_execute` (CLI, Z.424) → `DeleteExecutor.execute()`. +6. **Approval Validation:** `_validate_approval()` (rq_c5_delete.py Z.105) — Invarianten D/E/F/I. +7. **Tolaria DELETE:** `_do_delete()` → `client.delete()`. +8. **Ledger/Consumption:** `mark_delete_approval_used()` → `approval_status='USED'`, Commit → ST_UPDATING_SEARCH. + +### 1b. Wer kann Approval heute erzeugen? +- **NUR der CLI-Befehl `approve-delete`** (`cmd_c5_delete_approve`). Kein Code erzeugt Approval automatisch (Invariante B/C). +- **Jeder, der die CLI im RQ-Container ausführen kann** (User `hermes`), kann eine Approval erzeugen. +- **`approved_by` ist ein frei wählbares CLI-Argument** (Default `human:christian`). **KEINE kryptographische Bindung an Christian.** + +### 1c. Exakte Felder (DB-Tabelle `delete_approvals`) +| Feld | Typ | Bedeutung | +|---|---|---| +| `approval_id` | TEXT (uuid4) | Eindeutige ID | +| `workflow_commit` | TEXT | Commit-Binding | +| `object_change_id` | INT | ObjectChange-Binding | +| `object_id` | TEXT | Objekt-Binding | +| `path` | TEXT | Pfad-Binding | +| `operation` | TEXT | 'DELETE' | +| `approval_status` | TEXT | APPROVED/USED/REVOKED | +| `approved_by` | TEXT | **frei wählbar** (Default human:christian) | +| `approval_nonce` | TEXT (uuid4) | Nonce (kryptographisch zufällig, aber NICHT signiert) | +| `approved_at` | INT (ms) | Zeitpunkt | +| `used_at` | INT (ms) | Single-Use-Zeitpunkt | + +### 1d. Bestehende Bindings (im Code verifiziert) +- **Object-Binding:** `_validate_approval` Z.143 — `approval.object_id == oid` (Invariante E). +- **Commit-Binding:** Z.136 — `approval.workflow_commit == commit_sha` (Invariante F). +- **Path-Binding:** Z.150 — `approval.path == path` (Invariante I). +- **Operation-Binding:** `create_delete_approval` setzt `operation='DELETE'` fest. +- **Nonce:** `approval_nonce` = uuid4, aber **nicht signiert, nicht verifiziert**. +- **Single-Use:** `mark_delete_approval_used` → `approval_status='USED'`; `_validate_approval` prüft `approval_status == 'APPROVED'`. +- **Mission-Binding:** **FEHLT** — kein `mission_id`-Feld. +- **Provenance-Binding:** **FEHLT** — kein `expected_provenance_hash`-Feld. +- **Expiry:** **FEHLT** — kein `expires_at`-Feld. +- **Actor-/Identity-Information:** `approved_by` (frei wählbar). +- **Authentizitätsprüfung:** **KEINE** — es gibt keine kryptographische Prüfung, dass die Approval von Christian stammt. + +### 1e. Kernbefund +**HUMAN_APPROVAL_AUTHENTICITY_GAP = OPEN.** Die Approval ist eine reine DB-Zeile mit +frei wählbarem `approved_by`. Jeder mit CLI-/DB-Zugriff (RQ/Hermes, User `hermes`) +kann eine syntaktisch perfekte Approval erzeugen. Es gibt **keine kryptographische +Bindung an Christian**. → DELETE_OPERATION_ACTIVATION bleibt BLOCKED. + +--- + +## 2. THREAT MODEL (Phase 2) + +> ⚠️ NUR ANALYSE. FAIL CLOSED. + +| Threat | CURRENT_PROTECTION | CURRENT_GAP | TARGET_CONTROL | FAIL_CLOSED_BEHAVIOR | +|---|---|---|---|---| +| **T1 RQ erzeugt selbst Approval** | CLI-Zugriff (User hermes) | RQ kann `approve-delete` ausführen | RQ besitzt keinen Signing Key → kann keine gültige signierte Approval erzeugen | DELETE_DENIED | +| **T2 Hermes erzeugt selbst Approval** | Gleiche Boundary wie RQ | Hermes kann CLI ausführen | Hermes besitzt keinen Signing Key | DELETE_DENIED | +| **T3 Rain erzeugt Approval** | Separater Container | Rain hat keinen CLI-Zugriff auf c5a.db | Rain besitzt keinen Signing Key | DELETE_DENIED | +| **T4 SAVE Executor erzeugt Approval** | Separater Container (AUTH.3C) | Kein CLI-Zugriff auf c5a.db | SAVE Executor besitzt keinen Signing Key | DELETE_DENIED | +| **T5 DELETE Executor erzeugt Approval** | Separater Container (AUTH.3C) | Kein CLI-Zugriff auf c5a.db | DELETE Executor ist **verify-only**, besitzt keinen Signing Key | DELETE_DENIED | +| **T6 Agent verändert gespeicherte Approval** | DB-Zugriff | DB-Zeile kann geändert werden | Signatur über Payload → Änderung invalidiert Signatur | DELETE_DENIED | +| **T7 Agent verändert Object nach Approval** | Object-Binding (E) | Nur DB-Vergleich, nicht kryptographisch | Object im signierten Payload → Änderung invalidiert Signatur | DELETE_DENIED | +| **T8 Agent verändert Commit nach Approval** | Commit-Binding (F) | Nur DB-Vergleich | Commit im signierten Payload | DELETE_DENIED | +| **T9 Agent verändert Path nach Approval** | Path-Binding (I) | Nur DB-Vergleich | Path im signierten Payload | DELETE_DENIED | +| **T10 Approval wird kopiert** | approval_id uuid4 | Kopie ist gültig (keine Signatur) | Signatur + Single-Use → Kopie hat gleiche Signatur, aber Nonce/consumed | DELETE_DENIED (Replay) | +| **T11 Approval wird wiederverwendet** | Single-Use (USED) | Nur DB-Status | Signatur + consumed-check | DELETE_DENIED | +| **T12 Approval wird zwischen Missions übertragen** | Kein mission_id | Kein Mission-Binding | mission_id im signierten Payload | DELETE_DENIED | +| **T13 alte Approval nach State Change** | Commit-Status-Check (Gate 2) | Nur DB-Status | State-Check + Signatur | DELETE_DENIED | +| **T14 Nonce wird wiederverwendet** | uuid4 | Nonce nicht signiert/verifiziert | Nonce im signierten Payload + consumed-check | DELETE_DENIED | +| **T15 DB-Eintrag wird manipuliert** | — | DB-Zeile allein erzeugt gültige Approval | Signatur → DB-Forgery ohne Private Key unmöglich | DELETE_DENIED | +| **T16 Approval aus Backup wiederhergestellt** | — | Backup enthält gültige Approval | Signatur + consumed-check + expires_at | DELETE_DENIED | +| **T17 Approval nach Executor-Restart erneut verwendet** | Single-Use (USED) | Nur DB-Status | consumed-check + Signatur | DELETE_DENIED | +| **T18 Race zwischen Verify und Delete** | — | TOCTOU-Fenster | Atomare Reservation (RESERVED) | DELETE_DENIED | +| **T19 LLM erzeugt syntaktisch perfekte Fake-Approval** | — | Keine Signatur | Signatur → LLM ohne Private Key kann nicht signieren | DELETE_DENIED | +| **T20 kompromittierter RQ-Prozess versucht Approval-Bypass** | — | RQ kann CLI ausführen | RQ besitzt keinen Signing Key | DELETE_DENIED | + +**Kern-Gap:** Alle Bindings (Object/Commit/Path) sind nur DB-Vergleiche, nicht +kryptographisch. `approved_by` ist frei wählbar. **Es fehlt eine kryptographische +Bindung an Christian.** + +--- + +## 3. HUMAN IDENTITY ROOT (Phase 3) — Optionen + +> ⚠️ NUR BEWERTUNG. KEINE Keys erzeugen. + +| Option | HUMAN_AUTH | LLM_CAN_FORGE | RQ_CAN_FORGE | DELETE_EXEC_CAN_FORGE | PRIVATE_SECRET_EXPOSURE | REPLAY_RES | AUDIT | ROTATION | REVOCATION | BACKUP_RISK | OP_COMPLEXITY | STACK_COMPAT | OFFLINE_RECOVERY | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| **A: HMAC (geteiltes Secret)** | Mittel | Nein (ohne Secret) | Nein (ohne Secret) | Nein (verify-only) | **Hoch** (Secret muss bei Verifier liegen) | Mittel | Mittel | Ja | Ja | Mittel | Niedrig | Hoch | Ja | +| **B: Asymmetrisch (Ed25519)** | **Hoch** | **Nein** | **Nein** | **Nein** (verify-only) | **Niedrig** (nur Public Key bei Verifier) | **Hoch** | **Hoch** | Ja | Ja | Niedrig | Mittel | **Hoch** (Python stdlib/cryptography) | Ja | +| **C: Forgejo-signiertes Artefakt** | Mittel | Nein | Nein | Nein | Mittel (Key bei Forgejo) | Mittel | Hoch | Ja | Ja | Mittel | Hoch | Hoch | Mittel | +| **D: Separater Approval-Service** | Hoch | Nein | Nein | Nein | Niedrig | Hoch | Hoch | Ja | Ja | Niedrig | **Hoch** (neue Infrastruktur) | **Niedrig** | Mittel | +| **E: Telegram + kryptogr. Bindung** | Mittel | Nein | Nein | Nein | Mittel | Mittel | Mittel | Ja | Ja | Mittel | Mittel | Hoch | Ja | +| **F: Andere minimal sichere** | — | — | — | — | — | — | — | — | — | — | — | — | — | + +**Bewertung:** +- **OPTION B (Ed25519 asymmetrisch)** ist die **stärkste und stack-kompatible** Lösung: + Christian besitzt den Private Key, der DELETE Executor kennt nur den Public Key. + **LLM/RQ/SAVE/DELETE-Executor können ohne Private Key nicht signieren.** + Python `cryptography`-Bibliothek (oder `pynacl`) unterstützt Ed25519 nativ. +- **OPTION A (HMAC)** ist schwächer: das geteilte Secret müsste beim Verifier liegen, + was die Exposure-Fläche vergrößert. Nicht bevorzugt. +- **OPTION C (Forgejo-signiert)** ist möglich, aber komplexer und bindet an Forgejo. +- **OPTION D (separater Service)** erfordert neue Infrastruktur → Scope-Creep. +- **OPTION E (Telegram)** allein ist KEIN kryptographischer Proof (siehe §7). + +**Empfehlung: OPTION B (Ed25519 asymmetrisch).** + +--- + +## 4. PREFERRED SECURITY PROPERTY (Phase 4) + +**Bevorzugte Eigenschaft (bestätigt):** +- **Christian:** PRIVATE KEY (Ed25519) +- **DELETE Executor:** PUBLIC KEY ONLY (verify-only) +- **RQ/Hermes:** NO PRIVATE KEY +- **SAVE Executor:** NO PRIVATE KEY +- **Rain:** NO PRIVATE KEY +- **Tolaria:** NO PRIVATE KEY + +**Gegen reale Architektur bewertet:** +- Python `cryptography`/`pynacl` ist im Stack verfügbar (C5-Caller sind Python). +- Keine neue Infrastruktur nötig. +- Der DELETE Executor kann eine Approval **verifizieren**, aber **nicht erzeugen**. +- **Bestätigt als bevorzugte Lösung.** + +--- + +## 5. APPROVAL PAYLOAD (Phase 5) — kanonisch + +### Kanonischer Payload +```json +{ + "approval_version": 1, + "approval_id": "", + "mission_id": "", + "delete_request_id": "", + "operation": "DELETE", + "object_id": "", + "vault_path": "", + "expected_commit": "", + "expected_provenance_hash": "", + "nonce": "", + "issued_at": "", + "expires_at": "" +} +``` + +### Canonicalization +- **CANONICALIZATION:** Deterministische Serialisierung: Felder in fester Reihenfolge + (wie oben), keine Duplikate, keine Whitespace-Varianz, UTF-8, keine Unicode-Normalisierung + (exakte Bytes), Pfad-Normalisierung (kein `./`, kein `..`, keine doppelten Slashes). +- **ENCODING:** UTF-8, JSON ohne optionale Felder (nur die 12 Pflichtfelder). +- **HASH:** SHA-256 über den kanonisierten Payload-Bytes. +- **SIGNATURE_INPUT:** Die kanonisierten Payload-Bytes selbst (nicht der Hash) werden + signiert (Ed25519 signiert intern den Hash). Keine Signatur über frei formatierte Texte. + +--- + +## 6. APPROVAL SIGNATURE (Phase 6) — Ed25519 + +- **SIGNATURE_ALGORITHM:** **Ed25519** (RFC 8032), via Python `cryptography` oder `pynacl`. +- **PRIVATE_KEY_OWNER:** Christian (einziger Besitzer). +- **PRIVATE_KEY_LOCATION:** Christians vertrauenswürdiges Gerät (NICHT im Stack). +- **PUBLIC_KEY_LOCATION:** DELETE Executor-Konfiguration (read-only, injiziert via Secret/File). +- **KEY_ID:** SHA-256-Hash des Public Keys (kurz, zur Key-Identifikation). +- **SIGNATURE_FORMAT:** Ed25519-Signatur (64 Bytes), Base64-URL-encoded. +- **VERIFICATION:** `verify(public_key, canonical_payload_bytes, signature)`. +- **ROTATION:** Neuer Key → neue KEY_ID; alte Approvals mit altem Key bleiben bis Expiry gültig (Multi-Key-Transition). +- **REVOCATION:** Key-Revocation-Liste (KEY_ID → revoked); verifier prüft. +- **MULTI_KEY_TRANSITION:** Verifier akzeptiert mehrere Public Keys (aktuelle + vorherige), markiert ältere als "transitioning". + +**Private Key darf NICHT liegen in:** RQ ENV, Hermes ENV, Rain ENV, SAVE Executor, +DELETE Executor, Forgejo Repo, C5 DB, Tolaria, Logs, Reports. + +--- + +## 7. APPROVAL CREATION CHANNEL (Phase 7) + +> ⚠️ NUR DESIGN. KEINE Implementierung in AUTH.3D (nur Code + Tests). + +### Modelle +| Modell | UX | Sicherheit | Stack-Kompatibilität | +|---|---|---|---| +| **A: Lokales Signing Tool auf Christians Gerät** | Mittel (Christian signiert manuell) | **Hoch** (Private Key nie im Stack) | Hoch (Python-Skript) | +| **B: Separater Approval-Container/Service** | Niedrig | Hoch | **Niedrig** (neue Infrastruktur) | +| **C: Telegram Approval → separater Signer** | Hoch | Mittel (Signer muss getrennt sein) | Mittel | +| **D: Forgejo Workflow** | Mittel | Mittel | Hoch | +| **E: Anderer sicherer Kanal** | — | — | — | + +**Empfehlung: OPTION A (lokales Signing Tool auf Christians Gerät).** Christian +erzeugt den kanonischen Payload (aus dem Delete-Request), signiert ihn mit seinem +Private Key, und übergibt die signierte Approval (Payload + Signatur + KEY_ID) an +den DELETE Executor (via CLI/Inbox). Der Executor verifiziert mit dem Public Key. + +**WICHTIG (strikte Unterscheidung):** +- **HUMAN_INTENT:** Ein Telegram-Text "Ja"/"Approve"/"Delete" ist NUR Intent, KEIN kryptographischer Proof. +- **CRYPTOGRAPHIC_APPROVAL_ARTIFACT:** Die signierte Approval (Payload + Signatur) ist der einzige gültige Proof. +- RQ darf NICHT aus einem Telegram-Text den finalen Approval-Datensatz erzeugen können (ohne Christians Private Key). + +--- + +## 8. APPROVAL VERIFICATION (Phase 8) + +**DeleteExecutor MUSS VOR DELETE prüfen (alle, sonst DELETE_DENIED):** +1. Schema gültig (kanonischer Payload) +2. Unterstützte Approval-Version +3. Signatur gültig (Ed25519, Public Key) +4. Key nicht revoked +5. Operation == DELETE +6. Mission stimmt +7. Delete Request stimmt +8. Object stimmt +9. Path stimmt +10. Commit stimmt +11. Provenance stimmt +12. Nonce stimmt +13. issued_at plausibel (nicht in der Zukunft) +14. expires_at nicht überschritten +15. Approval noch nicht consumed +16. State erlaubt Delete +17. kein Replay +18. kein Cross-Object-Reuse +19. kein Cross-Mission-Reuse + +**Jeder Fehler → DELETE_DENIED. KEIN Tolaria HTTP DELETE.** + +--- + +## 9. TOCTOU / RACE SAFETY (Phase 9) + +- **VERIFY → STATE CHANGE → DELETE:** Zwischen Verification und Mutation gibt es ein Fenster. +- **Lösung:** Atomare **Reservation** in der DB: Approval-Status `APPROVED → RESERVED` + (mit `execution_attempt_id` + `idempotency_key`) in einer SQLite-Transaktion, BEVOR + der Tolaria-DELETE ausgeführt wird. Nur eine Reservation pro Approval möglich. +- **DB Transaction:** `UPDATE ... SET approval_status='RESERVED' WHERE approval_status='APPROVED'` + — atomar, nur wenn noch APPROVED. +- **Crash before delete:** Approval bleibt RESERVED → Recovery: prüfen, ob DELETE + tatsächlich ausgeführt wurde (Read-Back). Wenn nicht → zurück zu APPROVED (oder + neuer Attempt mit neuem idempotency_key). +- **Crash after delete before ledger:** Read-Back zeigt Ziel absent → Ledger nachtragen, + Approval → CONSUMED. +- **Retry:** Idempotenz via `idempotency_key` — gleicher Attempt wird nicht doppelt ausgeführt. +- **Uncertain network result:** **DELETE_OUTCOME_UNKNOWN** — der Executor darf bei + Unsicherheit NICHT blind wiederholen. Er meldet OUTCOME_UNKNOWN und wartet auf + manuelle Klärung (Read-Back). + +**Recovery State für DELETE_OUTCOME_UNKNOWN:** Approval bleibt RESERVED; ein manueller +Read-Back entscheidet, ob CONSUMED (Ziel absent) oder zurück zu APPROVED (Ziel noch da). + +--- + +## 10. SINGLE USE (Phase 10) + +**Approval Lifecycle:** +``` +CREATED → VERIFIED → RESERVED → EXECUTED → CONSUMED +``` + +| Zustand | Verhalten | +|---|---| +| **INVALID** | DELETE_DENIED (Signatur/Binding-Fehler) | +| **EXPIRED** | DELETE_DENIED (expires_at überschritten) | +| **REVOKED** | DELETE_DENIED (Key oder Approval revoked) | +| **FAILED** | DELETE_DENIED (Execution-Fehler) | +| **OUTCOME_UNKNOWN** | Kein blinder Retry; manuelle Klärung | + +**Eine Approval darf nach CONSUMED niemals erneut ausführbar sein.** Der consumed-check +ist Teil der Verification (§8, Punkt 15) und der Reservation (§9). + +--- + +## 11. STORAGE (Phase 11) + +**Was wird gespeichert:** +- Payload (kanonisch) +- Signature (Ed25519, Base64-URL) +- Public Key ID (KEY_ID) +- Verification Result (PASS/DENIED + Reason) +- consumed_at +- execution_attempt (attempt_id, idempotency_key) +- Result (DELETE_OK / DELETE_OUTCOME_UNKNOWN / DELETE_DENIED) + +**Was wird NICHT gespeichert:** +- Private Key +- Signing Secret + +**DB-Manipulationsrisiko:** Eine DB-Zeile allein darf keine gültige Approval erzeugen. +Selbst vollständige Schreibkontrolle über die Approval-DB darf ohne Christians +Private Key keine neue gültige Approval erzeugen können (Signatur fehlt → DENIED). + +--- + +## 12. ISOLATED IMPLEMENTATION (Phase 12) + +> ⚠️ NUR Code + Tests. KEIN produktives Deployment. + +**Modular (keine Vermischung mit Tolaria HTTP):** +- `approval_payload.py` — kanonischer Payload, Canonicalization, Encoding, Hash +- `approval_signature.py` — Ed25519 sign/verify (synthetische Test-Keypairs) +- `approval_verifier.py` — Verification-Logik (alle §8-Checks) +- `approval_state.py` — Lifecycle (CREATED→VERIFIED→RESERVED→EXECUTED→CONSUMED), Single-Use, Reservation + +**Bestehende C5-Invarianten erhalten.** Keine Änderung an Tolaria-HTTP-Code. + +--- + +## 13. TEST CONTRACT (Phase 13) + +> ⚠️ NUR synthetische Test-Keypairs. KEINE echten Keys. + +**T1–T30 (siehe Freigabe):** +- T1 gültige Christian-Approval → PASS +- T2 falsche Signatur → DENIED +- T3 falscher Public Key → DENIED +- T4 Payload nach Signatur verändert → DENIED +- T5 Object verändert → DENIED +- T6 Path verändert → DENIED +- T7 Commit verändert → DENIED +- T8 Provenance verändert → DENIED +- T9 Mission verändert → DENIED +- T10 Request-ID verändert → DENIED +- T11 Nonce verändert → DENIED +- T12 expired → DENIED +- T13 future-issued invalid → DENIED +- T14 falsche Operation → DENIED +- T15 unbekannte Version → DENIED +- T16 malformed payload → DENIED +- T17 malformed signature → DENIED +- T18 Replay → DENIED +- T19 consumed Approval → DENIED +- T20 cross-object reuse → DENIED +- T21 cross-mission reuse → DENIED +- T22 DB fake row ohne Signature → DENIED +- T23 DB fake row mit erfundener Signature → DENIED +- T24 DELETE Executor kann keine Approval erzeugen +- T25 RQ besitzt keinen Signing Key +- T26 SAVE Executor besitzt keinen Signing Key +- T27 Logs enthalten keinen Private Key +- T28 DB enthält keinen Private Key +- T29 Retry nach confirmed delete führt nicht zu zweitem Delete +- T30 OUTCOME_UNKNOWN führt nicht zu blindem Retry + +**Adversarial:** canonicalization ambiguity, duplicate JSON keys, Unicode normalization, +path normalization, signature substitution, key-id substitution, oversized payload, +malformed timestamps, nonce collision, stale state, concurrent execution. + +--- + +## 14. TEST SENSITIVITY (Phase 14) + +**Mutationen (jede muss Tests ROT machen):** +- A Signaturprüfung entfernen +- B Object Binding entfernen +- C Commit Binding entfernen +- D Nonce Binding entfernen +- E Expiry entfernen +- F consumed-check entfernen +- G Operation-check entfernen +- H Mission Binding entfernen +- I Provenance Binding entfernen +- J Replay-Schutz entfernen + +--- + +## 15. REGRESSION (Phase 15) + +Volle relevante C5-Test-Suite. Keine bestehenden Sicherheitsinvarianten abschwächen. +Baseline-Fails separat beweisen. Keine unrelated Reparaturen. + +--- + +## 16. SECURITY REVIEW (Phase 16) + +- NO_PRIVATE_KEY_IN_REPO / ENV / DB / LOGS +- RQ_CANNOT_SIGN / HERMES_CANNOT_SIGN / RAIN_CANNOT_SIGN / SAVE_EXECUTOR_CANNOT_SIGN / DELETE_EXECUTOR_CANNOT_SIGN +- DELETE_EXECUTOR_VERIFY_ONLY +- SIGNATURE_FAIL_CLOSED / REPLAY_FAIL_CLOSED / EXPIRY_FAIL_CLOSED / STATE_FAIL_CLOSED + +--- + +## 17. COMMIT / PUSH (Phase 17) + +Nur AUTH.3D-relevante Dateien. Vor Commit: git diff, git status, Secret-Scan, +Private-Key-Pattern-Scan. Keine echten Keys. Tests nur mit synthetischen Test-Keypairs. +FF-only Push. HEAD == origin/main verifizieren. + +--- + +## 18. FRESH CHECKER (Phase 18) + +> ⚠️ Wird als unabhängiger Subagent ausgeführt. + +--- + +## 19. FINAL REPORT (Phase 19) + +> ⚠️ Wird nach Fresh Checker ausgefüllt. diff --git a/tolaria/tolaria-write-auth/AUTH3E_DESIGN.md b/tolaria/tolaria-write-auth/AUTH3E_DESIGN.md new file mode 100644 index 0000000..1b3f460 --- /dev/null +++ b/tolaria/tolaria-write-auth/AUTH3E_DESIGN.md @@ -0,0 +1,714 @@ +# AUTH.3E — EXECUTOR COMMAND CHANNEL + RUNTIME BOUNDARY CONTRACT + +**Status:** DESIGN + CODE + ISOLATED TESTS + REVIEW ONLY (KEIN produktives Deployment) +**Datum:** 2026-08-27 +**Mission Type:** SECURITY DESIGN + CODE + ISOLATED TESTS + REVIEW ONLY +**Baseline:** AUTH.1 (40bbc40) + AUTH.2 (edcb4902) + AUTH.3A (a11c1bb) + AUTH.3B + AUTH.3C + AUTH.3D (9c8d239) = COMPLETE + +> ⚠️ Dies ist ein **Design-/Code-/Test-Dokument**. Es wird KEIN produktiver Umbau +> ausgeführt, kein Container erstellt, keine Compose/Network/Volume/DB geändert, +> kein echtes Credential erzeugt, keine ENV geändert, kein Deployment, keine +> produktive Tolaria-Mutationsprobe, keine Hermes-Autonomie. Bei Zweifel: +> FAIL CLOSED + HARD STOP. + +--- + +## 1. CURRENT RUNTIME DISCOVERY (Phase 2) — rekonstruiert (READ-ONLY) + +> Keine Annahmen. UNKNOWN bleibt UNKNOWN. Alle Fakten aus Code-Reading verifiziert. + +### 1a. SAVE-Pfad: RQ/Hermes → C5 CLI → C5CPropagator → TolariaClient.write() + +``` +RQ/Hermes (Container 06a4a73573d0, User hermes uid 10010) + │ + ├─ [Entry Point] rq_c5_cli.py (argparse CLI, manuell aufgerufen) + │ KEIN CLI-Command führt C5CPropagator mit save_token aus (verifiziert) + │ + ├─ [Library] rq_c5c.py C5CPropagator (Z.497) + │ __init__(store, reader, client=None, ...) → client = client or TolariaClient() + │ Default: credential-frei (kein save_token) + │ write() → self.client.write(vault_path, after_content) (Z.638) + │ + └─ [Library] rq_c5c.py TolariaClient.write() (Z.322) + token = self._require_token(self.save_token, "SAVE") # fail-closed + → POST /api/vault/save (Bearer save_token) +``` + +**Boundary-Fakten (SAVE):** +- **Process Boundary:** C5CPropagator läuft im RQ/Hermes-Prozess (kein separater Prozess). +- **Container Boundary:** RQ-Container `06a4a73573d0` (User `hermes`, uid 10010, gid 10000). +- **Filesystem Boundary:** `/opt/data` (RQ-Container-Dateisystem); C5-DB `/opt/data/c5c_live/c5a.db`. +- **ENV Boundary:** `TOLARIA_SAVE_TOKEN` ABSENT im RQ-Container (verifiziert). `C5A_DB` steuert DB-Pfad. +- **Network Boundary:** Tolaria `187.124.31.123:5173` (auch 10.0.4.1/10.0.9.1); Forgejo 10.0.4.1:3000/10.0.9.1:3000. +- **DB-Zugriff:** C5AStore → SQLite `c5a.db` (Commit-State, ObjectChanges, Approvals, Ledger). +- **Forgejo-Zugriff:** GitReader liest Repo `nexo312/trading-system-docs` (Forgejo-Git-Push-Key). +- **Tolaria-Zugriff:** `write()` (SAVE) via `TolariaClient`. +- **Approval-DB-Zugriff:** C5AStore (delete_approvals-Tabelle) — SAVE-Pfad liest/schreibt sie nicht aktiv. +- **Benötigte Inputs:** Commit-SHA, ObjectChanges, Source-/Provenance-Daten aus Forgejo. +- **Erzeugte Outputs:** Tolaria-SAVE, C5-State-Update (PROPAGATING→VERIFYING→UPDATING_SEARCH). + +### 1b. DELETE-Pfad: RQ/Hermes → C5 CLI → DeleteExecutor → Approval Validation → TolariaClient.delete() + +``` +RQ/Hermes (Container 06a4a73573d0) + │ + ├─ [Entry Point] rq_c5_cli.py cmd_c5_delete_approve (Z.355) → persistiert Human-Approval + ├─ [Entry Point] rq_c5_cli.py cmd_c5_delete_execute (Z.424) → führt DELETE aus + ├─ [Entry Point] rq_c5_cli.py cmd_c5_delete_replay (Z.458) → idempotenter Replay + │ + ├─ [Helper] rq_c5_cli.py _delete_executor (Z.344) + │ TolariaClient(base_url, delete_token=os.environ.get(ENV_TOLARIA_DELETE_TOKEN)) + │ → liest NUR DELETE-Token (kein SAVE-Token) + │ + ├─ [Library] rq_c5_delete.py DeleteExecutor (Z.83) + │ __init__(store, client=None) → client = client or TolariaClient() (Default credential-frei) + │ execute(commit_sha) (Z.192): + │ Gate 1: ObjectChange existiert, operation == DELETE + │ Gate 2: Commit-Status == ST_DELETE_APPROVED + │ Gate 3: Approval existiert, APPROVED, passt exakt (Commit/Objekt/Pfad) + │ Gate 4: Pre-Delete-Drift-Check (Ziel existiert noch) + │ → _do_delete() → self.client.delete() (DELETE) + │ + └─ [Library] rq_c5c.py TolariaClient.delete() (Z.~352) + token = self._require_token(self.delete_token, "DELETE") # fail-closed + → POST /api/vault/delete (Bearer delete_token) +``` + +**Boundary-Fakten (DELETE):** +- **Process Boundary:** DeleteExecutor läuft im RQ/Hermes-Prozess (kein separater Prozess). +- **Container Boundary:** RQ-Container `06a4a73573d0` — **SELBER Container wie SAVE**. +- **Filesystem Boundary:** `/opt/data`; C5-DB `/opt/data/c5c_live/c5a.db`. +- **ENV Boundary:** `TOLARIA_DELETE_TOKEN` ABSENT im RQ-Container (verifiziert). +- **Network Boundary:** Tolaria `187.124.31.123:5173`. +- **DB-Zugriff:** C5AStore → SQLite `c5a.db` (Approvals, Ledger, Commit-State). +- **Forgejo-Zugriff:** GitReader (Provenance-/Object-Daten). +- **Tolaria-Zugriff:** `delete()` (DELETE) via `TolariaClient`. +- **Approval-DB-Zugriff:** C5AStore delete_approvals-Tabelle (lesen + USED markieren). +- **Benötigte Inputs:** Commit-SHA, persistierte Approval, ObjectChange, Nonce. +- **Erzeugte Outputs:** Tolaria-DELETE, Approval→USED, Commit→ST_UPDATING_SEARCH. + +### 1c. Kernbefund (bestätigt AUTH.3B/3C) +**SAVE- und DELETE-Caller laufen im SELBEN Prozess/Container** (`rq_c5_cli.py`, RQ-Container +`06a4a73573d0`). **CREDENTIAL_SEPARATION_STATUS = LOGICAL_ONLY.** Sobald Credentials +produktiv in denselben Container injiziert würden, wäre RQ_SAVE_ACCESS/DELETE_ACCESS +technisch NICHT mehr NO. → AUTH.4 BLOCKED. + +### 1d. DB-Schema (C5AStore, `c5a.db`) — rekonstruiert +- **Tabellen:** `commits` (commit_sha, parent_sha, discovered_at, sequence, status, retry_count, last_error, last_error_code, updated_at), `objects` (commit_sha, object_id, path_before, path_after, operation, content_hash_before/after, metadata_hash_before/after, representation, state, reason_code; UNIQUE(commit_sha, object_id, operation)), `delete_approvals` (approval_id, workflow_commit, object_change_id, object_id, path, operation='DELETE', approval_status APPROVED/USED/REVOKED, approved_by, approval_nonce, approved_at, used_at; UNIQUE(workflow_commit, object_change_id, object_id, path)), `kv` (key, value — für search_source_provenance). +- **State Machine (commits.status):** DISCOVERED → VALIDATING → READY → PROPAGATING_TOLARIA → VERIFYING_TOLARIA → UPDATING_SEARCH → VERIFYING_SEARCH → APPLIED; plus RETRY_PENDING, FAILED, DEAD, HUMAN_REVIEW_REQUIRED, WAITING_FOR_PREDECESSOR, DELETE_APPROVED, DELETING. +- **Locks/Transactions:** SQLite; `_init_schema` erstellt Tabellen; `upsert_commit` nutzt `INSERT ... ON CONFLICT DO UPDATE` (idempotent); `add_object_change` nutzt `INSERT OR IGNORE` (idempotent per UNIQUE). Kein expliziter WAL-Modus im Code gesehen (Default SQLite). +- **Idempotenz:** `SyncStateMachine.idempotency_check` (commit_sha+object_id+operation; ALREADY_APPLIED/ALREADY_AT_TARGET/RETRY_SAFE/CONFLICT); `add_object_change` INSERT OR IGNORE; `upsert_commit` ON CONFLICT. +- **Retry:** `retry_commit` (increment_retry, max_retries → DEAD, sonst RETRY_PENDING + backoff); `_is_retryable` in C5C (nur TolariaUnavailableError retryable). +- **Replay:** `c5-delete-replay` (idempotent, nutzt denselben `_delete_executor`); Approval single-use (→ USED). +- **Recovery:** `c5e-recover`, `c5e-reconcile` (C5E); `c5e-replay --allow-writes` nur Test-/Canary-Scope. +- **Provenance:** `persist_search_source_provenance` (kv-Tabelle, key `search_source_provenance:`); `get_source_provenance`. +- **Mission Binding:** **FEHLT** in C5A-Commits/ObjectChanges (kein mission_id-Feld). Nur in AUTH.3D-Approval-Payload (mission_id) vorhanden. +- **Object Binding:** `objects.object_id` (UNIQUE mit commit_sha+operation); Approval object_id-bound. +- **Commit Binding:** `commits.commit_sha`; Approval workflow_commit-bound. + +### 1e. AUTH.3D-Approval (rekonstruiert, `approval_payload.py`/`approval_state.py`/`approval_verifier.py`) +- **Payload (12 kanonische Felder):** approval_version, approval_id, mission_id, delete_request_id, operation, object_id, vault_path, expected_commit, expected_provenance_hash, nonce, issued_at, expires_at. +- **Lifecycle:** CREATED → VERIFIED → RESERVED → EXECUTED → CONSUMED (atomare Reservation, TOCTOU-sicher). +- **Verifier:** verify-only (Ed25519 PUBLIC KEY ONLY), importiert nur key_id, verify. +- **Signature:** `approval_signature.py` enthält sign()/private_key_to_pem() NUR zur Simulation; KEIN Private Key im Stack. +- **Bindings:** Object, Commit, Path, Operation, Single-Use, Nonce, Expiry, Mission (in Payload). + +--- + +## 2. COMMAND CHANNEL OPTIONS (Phase 3) — Bewertung + +> Keine neue Infrastruktur bevorzugen, wenn eine einfachere bestehende Primitive +> die Sicherheitsanforderungen erfüllt. + +| Kriterium | A) Shared SQLite Job DB | B) Getrennte SQLite Inbox pro Executor | C) Filesystem Inbox / atomic rename | D) Interner HTTP Executor Endpoint | E) RabbitMQ / Broker | F) Unix Socket / IPC | G) C5 State Machine (bestehend) | +|---|---|---|---|---|---|---|---| +| SECURITY | Mittel | **Hoch** | Mittel | Mittel | Hoch | Mittel | **Hoch** | +| COMPLEXITY | Niedrig | **Niedrig** | Niedrig | Mittel | **Hoch** | Mittel | **Niedrig** | +| FAIL_CLOSED | Ja | **Ja** | Ja | Ja | Ja | Ja | **Ja** | +| AUDITABILITY | Hoch | **Hoch** | Mittel | Hoch | Hoch | Mittel | **Hoch** | +| IDEMPOTENCY | Ja | **Ja** | Teilweise | Ja | Ja | Ja | **Ja** | +| REPLAY_RESISTANCE | Teilweise | **Ja** | Teilweise | Ja | Ja | Ja | **Ja** | +| CRASH_RECOVERY | Ja | **Ja** | Ja | Ja | Ja | Ja | **Ja** | +| CONCURRENCY | Mittel (Lock) | **Hoch (getrennte DBs)** | Mittel | Hoch | Hoch | Mittel | **Hoch** | +| RQ_SCOPE_ESCALATION | Nein | **Nein** | Nein | Nein | Nein | Nein | **Nein** | +| SECRET_EXPOSURE | Kein Secret | **Kein Secret** | Kein Secret | Kein Secret | Kein Secret | Kein Secret | **Kein Secret** | +| FILESYSTEM_BOUNDARY | Geteilt | **Getrennt** | Getrennt | Getrennt | Getrennt | Getrennt | **Getrennt** | +| NETWORK_BOUNDARY | Kein Netz | **Kein Netz** | Kein Netz | Netz nötig | Netz nötig | Kein Netz | **Kein Netz** | +| AUTH3D_COMPATIBILITY | Ja | **Ja** | Ja | Ja | Ja | Ja | **Ja** | +| CURRENT_C5_COMPATIBILITY | Mittel | **Hoch** | Mittel | Niedrig | **Niedrig** | Niedrig | **Hoch** | +| AUTH4_DEPLOYMENT_COMPLEXITY | Niedrig | **Niedrig** | Niedrig | Mittel | **Hoch** | Mittel | **Niedrig** | + +**Bewertung:** +- **OPTION B (getrennte SQLite Inbox pro Executor)** ist die **bevorzugte** Wahl: Sie erfüllt + die geforderte **GETRENNTE INBOX/STATE BOUNDARY** (SAVE-Executor claimt nur SAVE-Jobs, + DELETE-Executor nur DELETE-Jobs), nutzt vorhandene SQLite-Primitive (keine neue + Infrastruktur), ist fail-closed, auditierbar, idempotent, replay-resistent und + crash-recoverable. Getrennte DB-Dateien (`c5a_save.db`/`c5a_delete.db`) vermeiden + Lock-Konflikte zwischen SAVE- und DELETE-Executor. +- **OPTION A (Shared SQLite Job DB)** ist einfacher, aber teilt die DB → schwächere + DB-Boundary (SAVE-Executor könnte DELETE-Jobs sehen, wenn Scope nicht strikt getrennt). +- **OPTION C (Filesystem Inbox)** ist einfach, aber weniger robust (kein atomarer + Claim/Lease, schwächere Idempotenz/Replay-Resistenz). +- **OPTION D (HTTP Endpoint)** erfordert neue Netzwerk-Endpoints → Scope-Creep. +- **OPTION E (RabbitMQ/Broker)** erfordert neue Infrastruktur → **abgelehnt** (nicht vorhanden). +- **OPTION F (Unix Socket)** erfordert neue IPC-Infrastruktur → Scope-Creep. +- **OPTION G (C5 State Machine)** ist die bestehende Primitive für DELETE (Approval + persistiert, Nonce, Single-Use, Replay-Schutz) und wird als **Komplement** zu B genutzt: + Der DELETE-Job verweist auf die persistierte AUTH.3D-Approval; der DELETE-Executor + validiert Job UND Approval unabhängig. + +**Empfehlung:** **OPTION B (getrennte SQLite Inbox pro Executor) als Command Channel**, +kombiniert mit **OPTION G (C5 State Machine)** für die DELETE-Approval-Autorität. +Keine neue Infrastruktur. + +--- + +## 3. PREFERRED ARCHITECTURAL PROPERTY (Phase 4) + +**GETRENNTE INBOX / STATE BOUNDARIES.** + +``` +RQ/Hermes (Container 06a4a73573d0) — credential-frei + │ credential-freier Job-Request (kein Secret) + ▼ ++---------------------------+ +-----------------------------+ +| c5-save-executor (Cont.) | | c5-delete-executor (Cont.) | +| SAVE credential | | DELETE credential | +| NO DELETE | | NO SAVE | +| claimt NUR SAVE-Jobs | | claimt NUR DELETE-Jobs | +| c5a_save.db (Inbox) | | c5a_delete.db (Inbox) | ++---------------------------+ +-----------------------------+ + │ SAVE (Bearer save_token) │ DELETE (Bearer delete_token) + ▼ ▼ +Tolaria Server (187.124.31.123:5173) — erwartet SAVE+DELETE-Verifier +``` + +**Kein generischer Dispatcher.** Kein `command="..."`, `endpoint="..."`, `url="..."`, +`method="..."`, `shell="..."`, `python="..."`, `handler="..."`. Keine RCE-artige Semantik. + +**Job Types als geschlossene Allowlist:** +- `C5_SAVE_OBJECT` +- `C5_DELETE_OBJECT` + +Gegen die reale C5-Architektur geprüft: Die C5-Operationen sind `OP_CREATE`, +`OP_CONTENT_UPDATE`, `OP_METADATA_UPDATE`, `OP_STATE_UPDATE`, `OP_TAGS_UPDATE`, +`OP_SOURCE_CANONICAL_RELATION_UPDATE`, `OP_RENAME`, `OP_MOVE`, `OP_DELETE_REQUEST`, +`OP_SUPERSEDE`. Für den Command Channel werden diese auf zwei geschlossene Job-Types +abgebildet: **SAVE** (alle Nicht-DELETE-Operationen, die Tolaria-Write erfordern) und +**DELETE** (nur `OP_DELETE_REQUEST`). Kein generischer Operation-Dispatcher. + +--- + +## 4. SAVE JOB CONTRACT (Phase 5) + +### Kanonischer SAVE Job +```json +{ + "job_version": 1, + "job_id": "uuid4", + "mission_id": "uuid4", + "job_type": "C5_SAVE_OBJECT", + "object_id": "string", + "vault_path": "/app/vault/...", + "source_commit": "sha256", + "provenance_hash": "sha256", + "expected_state": "string", + "created_at": "ISO8601 UTC", + "idempotency_key": "uuid4" +} +``` + +### Content-Entscheidung: A) Content im Job ODER B) Executor lädt selbst +**Gewählt: B) Executor rekonstruiert sicherheitsrelevante Daten selbst aus autoritativer +Source (Forgejo).** + +Begründung (Manipulationsrisiko): +- Wenn Content im Job transportiert würde, könnte RQ (oder ein kompromittierter + RQ-Prozess) beliebigen Content in den Job schreiben → der SAVE-Executor würde + manipulierten Content nach Tolaria schreiben. +- **Bevorzugte Eigenschaft (aus Mission):** Executor rekonstruiert sicherheitsrelevante + Daten soweit möglich selbst aus autoritativen Quellen, statt RQ blind zu vertrauen. +- Der SAVE-Executor lädt den Content selbst aus Forgejo (source_commit + object_id → + GitReader.file_content) und validiert gegen provenance_hash/expected_state. + +**RQ darf NICHT bestimmen:** +- Tolaria Base URL (Executor-Konfiguration, nicht Job) +- HTTP Method (fix: POST /api/vault/save) +- Authorization Header (Executor-Credential, nicht Job) +- Credential (Executor-Credential, nicht Job) +- beliebigen Zielendpoint (fix: /api/vault/save) + +--- + +## 5. DELETE JOB CONTRACT (Phase 6) + +### Kanonischer DELETE Job (strenger) +```json +{ + "job_version": 1, + "job_id": "uuid4", + "mission_id": "uuid4", + "delete_request_id": "uuid4", + "job_type": "C5_DELETE_OBJECT", + "object_id": "string", + "vault_path": "/app/vault/...", + "expected_commit": "sha256", + "expected_provenance_hash": "sha256", + "approval_id": "uuid4", + "created_at": "ISO8601 UTC", + "idempotency_key": "uuid4" +} +``` + +**WICHTIG: Der DELETE Job selbst ist KEINE Approval.** + +DeleteExecutor muss unabhängig laden/verifizieren (AUTH.3D Approval Artifact): +- Ed25519 Signature (verify-only, PUBLIC KEY ONLY) +- mission_id +- delete_request_id +- object_id +- vault_path +- expected_commit +- expected_provenance_hash +- nonce +- expiry +- single-use state + +**Job und Approval müssen exakt zusammenpassen. Mismatch → DELETE_DENIED.** + +--- + +## 6. TRUST MODEL (Phase 7) + +### RQ_JOB_FIELDS_TRUST_LEVEL +| Feld | SAVE | DELETE | Klassifikation | +|---|---|---|---| +| job_version | TRUSTED (Schema) | TRUSTED (Schema) | Schema-Validierung | +| job_id | TRUSTED (Identität) | TRUSTED (Identität) | Idempotenz-Schlüssel | +| mission_id | VERIFY_AGAINST_SOT | VERIFY_AGAINST_SOT | Gegen Approval/Provenance | +| job_type | TRUSTED (Allowlist) | TRUSTED (Allowlist) | Geschlossene Allowlist | +| object_id | VERIFY_AGAINST_SOT | VERIFY_AGAINST_SOT | Gegen Forgejo/Approval | +| vault_path | VERIFY_AGAINST_SOT | VERIFY_AGAINST_SOT | Gegen ObjectChange/Approval + Path Safety | +| source_commit | VERIFY_AGAINST_SOT | — | Gegen Forgejo | +| provenance_hash | RECOMPUTE | VERIFY_AGAINST_SOT | Executor rechnet selbst | +| expected_state | VERIFY_AGAINST_SOT | — | Gegen Forgejo | +| expected_commit | — | VERIFY_AGAINST_SOT | Gegen Approval | +| expected_provenance_hash | — | VERIFY_AGAINST_SOT | Gegen Approval | +| approval_id | — | VERIFY_AGAINST_SOT | Gegen AUTH.3D Approval | +| delete_request_id | — | VERIFY_AGAINST_SOT | Gegen Approval | +| created_at | TRUSTED (Metadaten) | TRUSTED (Metadaten) | Audit | +| idempotency_key | TRUSTED (Idempotenz) | TRUSTED (Idempotenz) | Replay-Schutz | + +**Grundsatz: DATA FROM RQ != AUTHORITY.** RQ ist Orchestrator, nicht Security Authority. +Für sicherheitskritische Felder: VERIFY_AGAINST_SOT oder RECOMPUTE. + +--- + +## 7. JOB STATE MACHINE (Phase 8) + +### Deterministische State Machine +``` +CREATED → READY → CLAIMED → EXECUTING → SUCCEEDED + │ + ├→ FAILED + ├→ REJECTED + └→ OUTCOME_UNKNOWN +``` + +**Erlaubte Transitionen (geschlossene Allowlist):** +- CREATED → READY (Schema validiert, Job akzeptiert) +- CREATED → REJECTED (Schema/Allowlist/Injection-Fehler) +- READY → CLAIMED (atomarer Claim) +- CLAIMED → EXECUTING (Claim bestätigt) +- EXECUTING → SUCCEEDED (bestätigter Erfolg) +- EXECUTING → FAILED (bestätigter Fehler, kein Mutationseffekt) +- EXECUTING → OUTCOME_UNKNOWN (unklares HTTP-Ergebnis) +- EXECUTING → REJECTED (Gate-Fehler während Execution) + +**RETRYABLE:** Optional, nur sicher begründet. Für SAVE: RETRYABLE nur bei +TolariaUnavailableError (transient, kein Mutationseffekt). Für DELETE: **KEIN +RETRYABLE** — ein bereits gestarteter DELETE darf nicht wegen Lease-Expiry von einem +zweiten Worker wiederholt werden. OUTCOME_UNKNOWN → kein blinder Retry. + +**Ungültige Transition → FAIL CLOSED** (InvalidTransitionError). + +--- + +## 8. CLAIM / LEASE / CONCURRENCY (Phase 9) + +### Claim-Modell +- **worker_id:** UUID des Executors (SAVE-Executor vs DELETE-Executor). +- **claim_id:** UUID pro Claim. +- **claimed_at:** Timestamp. +- **lease_until:** Timestamp (Claim-Expiry). +- **attempt_count:** Zähler. + +### Atomarer Claim +Der Claim muss **atomar** sein (SQLite `UPDATE ... WHERE status='READY' AND +(lease_until IS NULL OR lease_until < now) RETURNING ...` in einer Transaktion). +Kein TOCTOU. + +### Concurrency-Prüfung +- **Zwei SAVE Worker:** Nur einer kann denselben Job atomar claimen (Status-Transition + READY→CLAIMED ist atomar). +- **Zwei DELETE Worker:** Nur einer kann denselben DELETE-Job claimen. **Ein bereits + gestarteter DELETE darf NICHT einfach wegen Lease-Expiry von einem zweiten Worker + wiederholt werden.** → DELETE-Job, der in EXECUTING ist, wird bei Lease-Expiry in + OUTCOME_UNKNOWN überführt (nicht zurück zu READY), und ein zweiter Worker darf ihn + NICHT erneut claimen. Nur ein expliziter Human-/Admin-Eingriff (AUTH.3D-Approval + erneut verifizieren) kann einen DELETE-Job aus OUTCOME_UNKNOWN reaktivieren. +- **Worker Crash nach Claim:** Lease-Expiry → Job wird für SAVE wieder claimbar + (RETRYABLE), für DELETE → OUTCOME_UNKNOWN (nicht blind wiederholen). +- **Stale Claim:** Lease-Expiry erkennt stale Claims. +- **Restart / Doppelstart / Race:** Atomarer Claim + Lease verhindern Doppelverarbeitung. + +--- + +## 9. IDEMPOTENCY (Phase 10) + +### SAVE +Idempotenz über: `job_id` + `idempotency_key` + `object_id` + `source_commit`/`provenance`. +- Gleicher Job (job_id) → bereits verarbeitet → kein zweiter Write. +- Gleicher idempotency_key → idempotent (kein zweiter Write). +- Gleicher object_id + source_commit → ALREADY_AT_TARGET (Ziel-Hash erreicht). + +### DELETE (strenger) +- Nach bestätigtem DELETE: gleicher Job niemals erneut mutieren. +- Bei unklarem HTTP-Ergebnis: **OUTCOME_UNKNOWN. KEIN BLINDER RETRY.** +- AUTH.3D OUTCOME_UNKNOWN-Modell bleibt erhalten. +- Approval single-use (→ USED/CONSUMED) → Replay denied. + +--- + +## 10. JOB AUTHENTICITY / INTEGRITY (Phase 11) + +### Ist Job-DB-Manipulation durch RQ relevant? +- **Für SAVE:** Ein manipulierter Job darf niemals Scope erweitern. Da der SAVE-Executor + Content selbst aus Forgejo lädt und gegen provenance_hash/expected_state validiert, + kann ein manipulierter Job keinen beliebigen Content einschleusen. Scope bleibt + geschlossen (nur SAVE, nur erlaubter Pfad). +- **Für DELETE:** Selbst vollständige Job-DB-Schreibkontrolle darf NICHT ausreichen, um + DELETE zu autorisieren. **AUTH.3D Signature bleibt die Autorität.** Ein manipulierter + DELETE-Job ohne gültige, exakt passende AUTH.3D-Approval → DELETE_DENIED. + +### Zusätzliche Schutzmechanismen (bewertet) +- **Job HMAC:** NICHT nötig für DELETE (AUTH.3D-Signatur schützt die kritische + DELETE-Autorität). Für SAVE optional, aber nicht erforderlich (Content wird + rekonstruiert). +- **DB ownership / filesystem permissions:** Ja — getrennte DB-Dateien mit + Executor-spezifischen Permissions (siehe Phase 12). +- **Immutable fields after claim:** Ja — nach CLAIMED sind job_type, object_id, + vault_path, approval_id, expected_* unveränderlich (UPDATE verboten). +- **State hash / row version / compare-and-swap:** Optional für SAVE; für DELETE + wird die AUTH.3D-Signatur als Autorität genutzt (keine unnötige Kryptografie). + +**Keine unnötige Kryptografie einführen, wenn die AUTH.3D-Signatur bereits die +kritische DELETE-Autorität schützt.** + +--- + +## 11. DB / FILESYSTEM BOUNDARY (Phase 12) + +### Getrennte SQLite-Dateien (bestätigt AUTH.3C) +- `c5a_save.db` — SAVE-Inbox (SAVE-Jobs, SAVE-State) +- `c5a_delete.db` — DELETE-Inbox (DELETE-Jobs, DELETE-State, Approval-Referenzen) + +### Zugriffsmatrix +| DB | RQ_READ | RQ_WRITE | SAVE_EXEC_READ | SAVE_EXEC_WRITE | DELETE_EXEC_READ | DELETE_EXEC_WRITE | +|---|---|---|---|---|---|---| +| c5a_save.db | Ja (Job erzeugen) | Ja (Job erzeugen) | Ja | Ja | **NEIN** | **NEIN** | +| c5a_delete.db | Ja (Job erzeugen) | Ja (Job erzeugen) | **NEIN** | **NEIN** | Ja | Ja | + +**Ziel:** +- SAVE Executor braucht keinen Zugriff auf DELETE Credential/DB, außer explizit + notwendige read-only Referenzen (keine). +- DELETE Executor braucht keinen SAVE-State. + +### Linux UID/GID-/Volume-Modell (für AUTH.4, NICHT produktiv ändern) +- SAVE-Executor-Container: eigener User, Volume nur für `c5a_save.db`. +- DELETE-Executor-Container: eigener User, Volume nur für `c5a_delete.db`. +- RQ-Container: Volume für beide Inbox-DBs (nur Job-Erzeugung, kein Executor-Zugriff). +- Kein geteiltes Secret-Volume. + +--- + +## 12. NETWORK BOUNDARY (Phase 13) + +### SAVE Executor +**ALLOW:** +- Tolaria notwendige interne Adresse (187.124.31.123:5173) — SAVE + READ +- notwendige autoritative Source (Forgejo 10.0.4.1:3000) — Source-/Provenance-Read + +**DENY (soweit möglich):** +- Docker Socket +- SSH +- Host Root +- Telegram +- beliebiges Internet +- DELETE-spezifische Dienste + +### DELETE Executor +**ALLOW:** +- Tolaria (187.124.31.123:5173) — DELETE +- notwendige Approval-/State-Source (Forgejo 10.0.4.1:3000) — Object/Commit-Read + +**DENY:** +- Docker Socket +- SSH +- Host Root +- Telegram +- beliebiges Internet +- SAVE-spezifische Credentials + +### Forgejo-Runtime-Frage +**Wird Forgejo wirklich zur Runtime benötigt?** +- **SAVE:** JA — der SAVE-Executor lädt Content selbst aus Forgejo (source_commit + + object_id → GitReader.file_content). Forgejo ist die autoritative Source. +- **DELETE:** JA (für Object/Commit-Read zur Validierung). Aber: Falls der DELETE-Job + alle nötigen Daten (object_id, vault_path, expected_commit, expected_provenance_hash) + bereits enthält und die AUTH.3D-Approval diese exakt bestätigt, könnte Forgejo-Read + optional sein. **Empfehlung:** Forgejo-Read für DELETE beibehalten (Defense in depth), + aber als read-only, nicht als Write. + +**Falls nicht nötig: KEIN Forgejo Runtime Access.** Für SAVE ist er nötig (Content- +Rekonstruktion). Für DELETE ist er nötig (Object/Commit-Validierung). + +--- + +## 13. SECRET BOUNDARY (Phase 14) + +### Zielmatrix (beweisen) +| Akteur | SAVE_TOKEN | DELETE_TOKEN | PRIVATE_SIGNING_KEY | PUBLIC_VERIFY_KEY | +|---|---|---|---|---| +| RQ | ABSENT | ABSENT | ABSENT | ABSENT | +| SAVE Executor | **PRESENT** | ABSENT | ABSENT | ABSENT | +| DELETE Executor | ABSENT | **PRESENT** | ABSENT | **PRESENT** | +| Rain | ABSENT | ABSENT | ABSENT | ABSENT | +| Tolaria Server | PRESENT (verifier) | PRESENT (verifier) | ABSENT | ABSENT | + +**Beweis (Design):** +- RQ: credential-frei (kein SAVE/DELETE-Token, kein Private Key). Nur Job-Erzeugung. +- SAVE Executor: erhält NUR SAVE-Token (injiziert), kein DELETE-Token, kein Private Key. +- DELETE Executor: erhält NUR DELETE-Token + PUBLIC_VERIFY_KEY, kein SAVE-Token, kein + Private Key. +- Rain: kein Zugriff auf irgendein Secret (separate Container). +- Tolaria Server: hält SAVE+DELETE-Verifier-Secrets (eigene ENV), KEIN Christians + Private Key. + +--- + +## 14. COMMAND INJECTION DEFENSE (Phase 15) + +Jobs dürfen niemals enthalten oder ausführen: +- shell commands +- arbitrary executable +- Python snippets +- URLs +- HTTP headers +- credentials +- SQL +- filesystem absolute targets (außerhalb erlaubten Schemas) +- Docker commands +- SSH commands + +**Enum + typed schema:** +- `job_type` ist ein Enum (`C5_SAVE_OBJECT` | `C5_DELETE_OBJECT`), kein freier String. +- Alle Felder sind typisiert (uuid, sha256-hex, ISO8601, enum). +- **Unknown job_type → REJECTED.** +- **Extra privileged fields → REJECTED** (Schema-Validierung lehnt unbekannte Felder ab). +- **Malformed schema → REJECTED.** + +--- + +## 15. PATH SAFETY COMPOSITION (Phase 16) + +AUTH.2 Path Safety bleibt serverseitig zwingend. Executor muss zusätzlich eigene +Pfadprüfung durchführen. + +**Defense in depth:** +``` +RQ Job → Executor validation → Tolaria AUTH.2 validation +``` + +- **Executor validation:** vault_path wird gegen das erlaubte Schema geprüft + (kein `..`, kein absoluter Pfad außerhalb `/app/vault`, keine Symlink-Escapes, + keine Unicode-Ambiguität, keine encoded traversal). +- **Tolaria AUTH.2 validation:** serverseitige Path Safety (bestehend, zwingend). +- **Kein Caller darf AUTH.2 Path Safety ersetzen.** + +--- + +## 16. AUTH.3D COMPOSITION (Phase 17) + +Für DELETE muss gelten (alle Faktoren, keiner ersetzt einen anderen): +``` +VALID_JOB +AND VALID_STATE +AND VALID_AUTH3D_SIGNATURE +AND VALID_NONCE +AND VALID_EXPIRY +AND NOT_CONSUMED +AND VALID_OBJECT +AND VALID_PATH +AND VALID_COMMIT +AND VALID_PROVENANCE +AND DELETE_CREDENTIAL +``` +erst dann: **Tolaria DELETE.** + +--- + +## 17. FAILURE MODEL (Phase 18) + +| Szenario | Verhalten | +|---|---| +| DB unavailable | FAIL CLOSED — kein Write ohne State | +| DB locked | SQLite-Lock → Retry/Fehler; kein Write ohne State | +| malformed job | REJECTED (Schema) | +| unknown version | REJECTED | +| unknown type | REJECTED | +| state mismatch | FAIL CLOSED (InvalidTransitionError) | +| source unavailable | FAIL CLOSED (kein Content-Rekonstruktions-Write) | +| Tolaria unavailable | SAVE: RETRYABLE (transient); DELETE: OUTCOME_UNKNOWN | +| 401 | FAIL CLOSED (Credential/Scope-Fehler) | +| 403 | FAIL CLOSED | +| 404 | FAIL CLOSED (Ziel fehlt) | +| 409 | FAIL CLOSED (Konflikt) | +| 500 | FAIL CLOSED | +| timeout | SAVE: RETRYABLE; DELETE: OUTCOME_UNKNOWN | +| connection reset | SAVE: RETRYABLE; DELETE: OUTCOME_UNKNOWN | +| worker crash | Lease-Expiry; SAVE: wieder claimbar; DELETE: OUTCOME_UNKNOWN | +| process kill | dito | +| container restart | Zustand aus DB; keine Sicherheitsinvariante verloren | +| duplicate job | Idempotenz (job_id/idempotency_key) | +| duplicate worker | Atomarer Claim verhindert Doppelverarbeitung | +| approval unavailable | DELETE_DENIED | +| approval invalid | DELETE_DENIED | +| approval expired | DELETE_DENIED | +| credential missing | FAIL CLOSED (kein HTTP) | + +**Security-Grundsatz: Unsicherheit darf nicht zu privilegierter Mutation führen.** + +--- + +## 18. AUDIT MODEL (Phase 19) + +Jeder Job benötigt Audit Trail. Mindestens: +- job_id, mission_id, job_type, object_id +- state transitions (mit Timestamps) +- worker_id, attempt +- timestamps +- result code +- approval_id (bei DELETE) +- provenance + +**NICHT loggen:** +- Credentials +- Authorization Header +- Private Key +- sensitive Approval internals, falls unnötig + +**Audit muss unterscheiden:** +- **REQUESTED** (RQ hat Job erzeugt) +- **AUTHORIZED** (Executor hat Job + Approval validiert) +- **EXECUTED** (Mutation ausgeführt) + +**WICHTIG: RQ REQUEST != HUMAN AUTHORIZATION.** Ein REQUESTED-Eintrag ist keine +AUTHORIZATION. Nur AUTHORIZED (nach AUTH.3D-Validierung) ist eine Autorisierung. + +--- + +## 19. ISOLATED IMPLEMENTATION (Phase 20) — Design + +> Implementiert in Code + Tests. KEIN produktiver Container. Modulare Komponenten. + +### Module +- `job_schema.py` — Job-Schema-Validierung (Enum, typed, Allowlist, REJECTED) +- `job_store.py` — SQLite Inbox (getrennte DBs, atomarer Claim, Lease) +- `job_state_machine.py` — deterministische State Machine (FAIL CLOSED) +- `job_claim.py` — atomarer Claim/Lease/Concurrency +- `save_executor_core.py` — SAVE-Executor-Logik (SAVE-only, Content-Rekonstruktion) +- `delete_executor_core.py` — DELETE-Executor-Logik (DELETE-only, AUTH.3D-Validierung) + +### Wiederverwendung +- Bestehende C5-Module (rq_c5a, rq_c5c, rq_c5_delete, approval_*) werden wiederverwendet, + nicht neu geschrieben. +- Keine Big-Bang-Neuschreibung. + +--- + +## 20. TEST CONTRACT (Phase 21) — Design + +> T1–T40 (siehe Mission §21). Implementiert in isolierten Tests (synthetische Secrets, +> Fake-Tolaria, Temp-DB). Keine produktiven Mutationsprobes. + +--- + +## 21. ADVERSARIAL TESTS (Phase 22) — Design + +> JSON duplicate keys, Unicode path ambiguity, encoded traversal, job_type casing, +> enum confusion, integer/string confusion, oversized payload, stale mission, stale +> provenance, race claim, replay after restart, DB row manipulation, cross-executor +> DB injection, approval substitution, object substitution after claim, path +> substitution after claim, state mutation after approval, crash before HTTP, crash +> after uncertain HTTP. + +--- + +## 22. TEST SENSITIVITY (Phase 23) — Design + +> Mutationen A–L (siehe Mission §23). Jede Mutation muss relevante Tests ROT machen. +> Falls nicht: Tests verstärken. + +--- + +## 23. REGRESSION (Phase 24) — Design + +> Volle relevante C5-Test-Suite + AUTH.3A-Tests + AUTH.3D-Tests. Keine bestehenden +> Sicherheitsinvarianten abschwächen. Baseline-Fails separat klassifizieren. Keine +> unrelated Reparaturen. + +--- + +## 24. SECURITY REVIEW (Phase 25) — Design + +> Explizit beweisen: RQ_CREDENTIAL_FREE, HERMES_CREDENTIAL_FREE, RAIN_CREDENTIAL_FREE, +> SAVE_EXECUTOR_SAVE_ONLY, DELETE_EXECUTOR_DELETE_ONLY, DELETE_EXECUTOR_VERIFY_ONLY, +> NO_PRIVATE_SIGNING_KEY_IN_STACK, NO_GENERIC_COMMAND_DISPATCH, NO_ARBITRARY_URL, +> NO_ARBITRARY_HTTP_METHOD, NO_ARBITRARY_HEADERS, NO_SHELL_EXECUTION, +> JOB_DOES_NOT_EQUAL_AUTHORIZATION, AUTH3D_REQUIRED_FOR_DELETE, ATOMIC_CLAIM, +> IDEMPOTENCY, REPLAY_PROTECTION, OUTCOME_UNKNOWN_FAIL_CLOSED. + +--- + +## 25. DESIGN DOC RECONCILIATION (Phase 26) — Design + +> AUTH3B_DESIGN.md, AUTH3C_DESIGN.md, AUTH3D_DESIGN.md (untracked). Entscheidungsmatrix +> (FILE, SECURITY_RELEVANCE, UNIQUE_INFORMATION, ALREADY_REPRESENTED_IN_COMMITTED_CODE/DOCS, +> STALE_INFORMATION, RECOMMENDED_SOT_ACTION; Klassifikation COMMIT/MERGE_INTO_ARCH_DOC/ +> ARCHIVE/DISCARD/KEEP_UNTRACKED_TEMPORARILY). Nur Empfehlung. Keine Mutation ohne +> separate Freigabe. + +--- + +## 26. COMMIT / PUSH (Phase 27) — Design + +> Nur AUTH.3E-Code + Tests + ausdrücklich notwendige AUTH.3E-Dokumentation. Vor Commit: +> git status, git diff, Secret Scan, Private-Key Scan, Credential Pattern Scan. Keine +> echten Credentials. Nur synthetische Testwerte. FF-only Push. HEAD == origin/main +> verifizieren. Falls Hooks scheitern: Ursache analysieren. --no-verify NICHT +> automatisch verwenden. + +--- + +## 27. FRESH CHECKER (Phase 28) — Design + +> Unabhängiger Checker (32 Punkte, siehe Mission §28). + +--- + +## 28. FINAL REPORT (Phase 29) + HARD STOP (Phase 30) + +> Nach FINAL REPORT sofort STOP. Keine produktiven Mutationen, keine Container, +> keine echten Credentials, keine Hermes-Autonomie.