- approval_payload: kanonischer, deterministischer Approval-Payload - approval_signature: Ed25519 sign/verify (Christian=Private Key, Executor=Public Key only) - approval_verifier: verify-only, alle Bindings fail-closed - approval_state: Lifecycle CREATED->CONSUMED, Single-Use, Reservation, OUTCOME_UNKNOWN - test_approval_auth3d: T1-T30 + adversarial (50 Tests) - test_approval_helpers: synthetische Test-Keypairs - sensitivity_proof: Mutationen A-J machen Tests ROT Nur synthetische Test-Keypairs. Kein produktives Deployment. DELETE_OPERATION_ACTIVATION bleibt BLOCKED bis AUTH.3D geprueft.
232 lines
8.6 KiB
Python
232 lines
8.6 KiB
Python
"""
|
|
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)
|