""" AUTH.3D — approval_verifier.py Verification-Logik für Human DELETE Approvals (alle §8-Checks). Der DELETE Executor MUSS VOR DELETE alle Checks bestehen, sonst DELETE_DENIED. FAIL CLOSED: jede Abweichung -> DELETE_DENIED. KEIN Tolaria HTTP DELETE. """ from __future__ import annotations import datetime from typing import Any, Dict, List, Optional from approval_payload import ( APPROVAL_VERSION, OPERATION_DELETE, ApprovalPayloadError, canonicalize, parse_payload, ) from approval_signature import key_id, verify # Reason Codes (FAIL CLOSED) RC_SCHEMA_INVALID = "DELETE_DENIED_SCHEMA_INVALID" RC_VERSION_UNSUPPORTED = "DELETE_DENIED_VERSION_UNSUPPORTED" RC_SIGNATURE_INVALID = "DELETE_DENIED_SIGNATURE_INVALID" RC_KEY_REVOKED = "DELETE_DENIED_KEY_REVOKED" RC_OPERATION_MISMATCH = "DELETE_DENIED_OPERATION_MISMATCH" RC_MISSION_MISMATCH = "DELETE_DENIED_MISSION_MISMATCH" RC_REQUEST_MISMATCH = "DELETE_DENIED_REQUEST_MISMATCH" RC_OBJECT_MISMATCH = "DELETE_DENIED_OBJECT_MISMATCH" RC_PATH_MISMATCH = "DELETE_DENIED_PATH_MISMATCH" RC_COMMIT_MISMATCH = "DELETE_DENIED_COMMIT_MISMATCH" RC_PROVENANCE_MISMATCH = "DELETE_DENIED_PROVENANCE_MISMATCH" RC_NONCE_MISMATCH = "DELETE_DENIED_NONCE_MISMATCH" RC_ISSUED_FUTURE = "DELETE_DENIED_ISSUED_FUTURE" RC_EXPIRED = "DELETE_DENIED_EXPIRED" RC_CONSUMED = "DELETE_DENIED_CONSUMED" RC_STATE_DISALLOWED = "DELETE_DENIED_STATE_DISALLOWED" RC_REPLAY = "DELETE_DENIED_REPLAY" RC_CROSS_OBJECT = "DELETE_DENIED_CROSS_OBJECT" RC_CROSS_MISSION = "DELETE_DENIED_CROSS_MISSION" RC_KEY_ID_UNKNOWN = "DELETE_DENIED_KEY_ID_UNKNOWN" class ApprovalVerificationError(Exception): """Verification fehlgeschlagen (FAIL CLOSED).""" def __init__(self, reason_code: str, message: str): super().__init__(message) self.reason_code = reason_code self.message = message class ApprovalVerifier: """ Verifiziert eine Human DELETE Approval gegen den erwarteten Kontext. Der Verifier besitzt NUR Public Keys (verify-only). Er kann KEINE Approval erzeugen (kein Private Key). """ def __init__( self, public_keys: Dict[str, Any], revoked_key_ids: Optional[set] = None, now_fn=None, ): """ public_keys: {key_id: Ed25519PublicKey} — aktuelle + ggf. transitioning Keys. revoked_key_ids: set von key_ids, die revoked sind. now_fn: optionaler Zeitgeber (Tests); Default datetime.datetime.now(timezone.utc). """ self.public_keys = dict(public_keys) self.revoked_key_ids = set(revoked_key_ids or set()) self._now_fn = now_fn or (lambda: datetime.datetime.now(datetime.timezone.utc)) def _now(self) -> datetime.datetime: return self._now_fn() def verify_approval( self, raw_payload: str, signature: str, *, expected_mission_id: str, expected_delete_request_id: str, expected_object_id: str, expected_vault_path: str, expected_commit: str, expected_provenance_hash: str, expected_nonce: str, consumed: bool = False, state_allows_delete: bool = True, used_nonces: Optional[set] = None, ) -> Dict[str, Any]: """ Führt ALLE §8-Checks aus. Wirft ApprovalVerificationError (DELETE_DENIED) bei jedem Fehler. Gibt bei Erfolg ein dict mit Verifikations-Ergebnis zurück. """ used_nonces = used_nonces or set() # 1. Schema gültig (kanonischer Payload) try: payload = parse_payload(raw_payload) except ApprovalPayloadError as e: raise ApprovalVerificationError(RC_SCHEMA_INVALID, str(e)) from e # 2. Unterstützte Approval-Version if payload.get("approval_version") != APPROVAL_VERSION: raise ApprovalVerificationError( RC_VERSION_UNSUPPORTED, f"Unsupported approval_version: {payload.get('approval_version')}", ) # 3. Signatur gültig (Ed25519, Public Key) # Zuerst KEY_ID aus dem Payload ableiten? Nein — KEY_ID wird separat übergeben. # Wir prüfen gegen ALLE bekannten (nicht-revoked) Public Keys. sig_valid = False matched_key_id = None for kid, pub in self.public_keys.items(): if kid in self.revoked_key_ids: continue if verify(pub, payload, signature): sig_valid = True matched_key_id = kid break if not sig_valid: raise ApprovalVerificationError(RC_SIGNATURE_INVALID, "Signatur ungültig oder Key unbekannt/revoked") # 4. Key nicht revoked (bereits in Schleife geprüft) # 5. Operation == DELETE if payload.get("operation") != OPERATION_DELETE: raise ApprovalVerificationError( RC_OPERATION_MISMATCH, f"operation muss {OPERATION_DELETE} sein, nicht {payload.get('operation')}", ) # 6. Mission stimmt if payload.get("mission_id") != expected_mission_id: raise ApprovalVerificationError( RC_MISSION_MISMATCH, f"mission_id {payload.get('mission_id')} != erwartet {expected_mission_id}", ) # 7. Delete Request stimmt if payload.get("delete_request_id") != expected_delete_request_id: raise ApprovalVerificationError( RC_REQUEST_MISMATCH, f"delete_request_id {payload.get('delete_request_id')} != erwartet {expected_delete_request_id}", ) # 8. Object stimmt if payload.get("object_id") != expected_object_id: raise ApprovalVerificationError( RC_OBJECT_MISMATCH, f"object_id {payload.get('object_id')} != erwartet {expected_object_id}", ) # 9. Path stimmt if payload.get("vault_path") != expected_vault_path: raise ApprovalVerificationError( RC_PATH_MISMATCH, f"vault_path {payload.get('vault_path')} != erwartet {expected_vault_path}", ) # 10. Commit stimmt if payload.get("expected_commit") != expected_commit: raise ApprovalVerificationError( RC_COMMIT_MISMATCH, f"expected_commit {payload.get('expected_commit')} != erwartet {expected_commit}", ) # 11. Provenance stimmt if payload.get("expected_provenance_hash") != expected_provenance_hash: raise ApprovalVerificationError( RC_PROVENANCE_MISMATCH, f"expected_provenance_hash {payload.get('expected_provenance_hash')} != erwartet {expected_provenance_hash}", ) # 12. Nonce stimmt if payload.get("nonce") != expected_nonce: raise ApprovalVerificationError( RC_NONCE_MISMATCH, f"nonce {payload.get('nonce')} != erwartet {expected_nonce}", ) # 13. issued_at plausibel (nicht in der Zukunft) issued = _parse_iso(payload["issued_at"]) if issued > self._now() + datetime.timedelta(minutes=5): raise ApprovalVerificationError(RC_ISSUED_FUTURE, "issued_at liegt in der Zukunft") # 14. expires_at nicht überschritten expires = _parse_iso(payload["expires_at"]) if expires < self._now(): raise ApprovalVerificationError(RC_EXPIRED, "Approval ist abgelaufen") # 15. Approval noch nicht consumed if consumed: raise ApprovalVerificationError(RC_CONSUMED, "Approval ist bereits consumed") # 16. State erlaubt Delete if not state_allows_delete: raise ApprovalVerificationError(RC_STATE_DISALLOWED, "State erlaubt kein Delete") # 17. kein Replay (Nonce bereits verwendet) if payload["nonce"] in used_nonces: raise ApprovalVerificationError(RC_REPLAY, "Nonce wurde bereits verwendet (Replay)") # 18. kein Cross-Object-Reuse (Object-Binding bereits geprüft) # 19. kein Cross-Mission-Reuse (Mission-Binding bereits geprüft) return { "verified": True, "key_id": matched_key_id, "approval_id": payload["approval_id"], "nonce": payload["nonce"], "payload_hash": _payload_hash(payload), } def _parse_iso(value: str) -> datetime.datetime: """Parst ISO-8601-UTC (…Z) zu datetime (UTC).""" # Format: YYYY-MM-DDTHH:MM:SSZ dt = datetime.datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ") return dt.replace(tzinfo=datetime.timezone.utc) def _payload_hash(payload: Dict[str, Any]) -> str: from approval_payload import payload_hash return payload_hash(payload)