audit-1 (critical 1): mandatory operator signatures bound to content+deployment
- signing module: canonical content hashes + approval_message sha256(cayce-ops:<type>:<deployment_id>:<id>:<content_hash>) - approve/approve-draft/publisher-approve REQUIRE signature; without CAYCE_OPS_OPERATOR_PUBKEY+DEPLOYMENT_ID the daemons refuse to execute/send/publish (no degraded mode) - run-time re-verification at execute/send/publish: unsigned rows (raw SQL approval) or edited content revert to pre-approval state, audited as error - FULL signatures stored on rows (operator_signature, migration 0003) and in audit detail — never truncated - signature failures audited from CLI wrappers too (1f) - AUTONOMOUS_MODE approval path removed (D13): no non-human approvals tests: SQL-approved row refused at execute/send/publish; to_addr change blocks send; foreign deployment/content signatures refused; full-signature persistence. 65 tests green.
This commit is contained in:
@@ -39,6 +39,21 @@ from cayceops.models import (
|
||||
audit,
|
||||
)
|
||||
|
||||
GATE_ERROR = 2 # missing/invalid configuration or signature
|
||||
|
||||
|
||||
def _signing(cfg: Config) -> tuple[str, str]:
|
||||
"""(deployment_id, operator_pubkey) or ConfigError. No degraded mode."""
|
||||
from cayceops.config import ConfigError
|
||||
|
||||
if not cfg.operator_pubkey or not cfg.deployment_id:
|
||||
raise ConfigError(
|
||||
"approvals disabled: CAYCE_OPS_OPERATOR_PUBKEY and "
|
||||
"CAYCE_OPS_DEPLOYMENT_ID are both required"
|
||||
)
|
||||
return cfg.deployment_id, cfg.operator_pubkey
|
||||
|
||||
|
||||
# --- IMAP ---------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -252,13 +267,32 @@ def _poll(cfg: Config, sess, fetcher=None, marker=None) -> tuple[dict, list[byte
|
||||
# --- human gate: approve draft -------------------------------------------------
|
||||
|
||||
|
||||
def _approve_draft(mail_id: int, actor: str, sess) -> int:
|
||||
def _mail_to_addr(record) -> str | None:
|
||||
"""The address a send would use for this draft."""
|
||||
if record.direction == MailDirection.INBOUND:
|
||||
return record.from_addr
|
||||
return record.to_addr
|
||||
|
||||
|
||||
def _mail_content_hash(record) -> str:
|
||||
from cayceops.signing import mail_content_hash
|
||||
|
||||
return mail_content_hash(
|
||||
_mail_to_addr(record), record.subject, record.draft_reply
|
||||
)
|
||||
|
||||
|
||||
def _approve_draft(
|
||||
mail_id: int, actor: str, sess, signature: str | None = None
|
||||
) -> int:
|
||||
"""Human approves a drafted reply (inbound-threaded or first-contact).
|
||||
|
||||
The approval is BOUND to the draft text via sha256 — a later edit breaks
|
||||
the binding and the send refuses (audit finding: approved text was not
|
||||
tied to its content).
|
||||
Signature is MANDATORY and bound to (to_addr, subject, body) + deployment
|
||||
(audit round 2, 1a/1b). The full signature is stored on the row.
|
||||
"""
|
||||
from cayceops.config import ConfigError
|
||||
from cayceops.signing import SignatureError, verify_signature
|
||||
|
||||
record = sess.get(MailMessage, mail_id)
|
||||
if record is None:
|
||||
return 1
|
||||
@@ -272,15 +306,52 @@ def _approve_draft(mail_id: int, actor: str, sess) -> int:
|
||||
detail={"reason": f"invalid draft state: {record.draft_status.value}"},
|
||||
)
|
||||
return 1
|
||||
|
||||
from cayceops.config import load
|
||||
|
||||
cfg = load()
|
||||
try:
|
||||
deployment_id, pubkey = _signing(cfg)
|
||||
except ConfigError as exc:
|
||||
audit(
|
||||
sess,
|
||||
actor=actor,
|
||||
action="approve_draft",
|
||||
proposal_id=record.proposal_id,
|
||||
result="error",
|
||||
detail={"reason": str(exc)},
|
||||
)
|
||||
return GATE_ERROR
|
||||
|
||||
chash = _mail_content_hash(record)
|
||||
try:
|
||||
if not signature:
|
||||
raise SignatureError("signature required")
|
||||
if not verify_signature(
|
||||
"mail", deployment_id, mail_id, chash, signature, pubkey
|
||||
):
|
||||
raise SignatureError("signature invalid for this content/deployment")
|
||||
except SignatureError as exc:
|
||||
audit(
|
||||
sess,
|
||||
actor=actor,
|
||||
action="approve_draft",
|
||||
proposal_id=record.proposal_id,
|
||||
result="error",
|
||||
detail={"reason": str(exc), "content_hash": chash},
|
||||
)
|
||||
return GATE_ERROR
|
||||
|
||||
record.draft_status = DraftStatus.APPROVED
|
||||
record.approved_hash = _content_hash(record.draft_reply)
|
||||
record.approved_by = actor[:128]
|
||||
record.operator_signature = signature # FULL signature
|
||||
audit(
|
||||
sess,
|
||||
actor=actor,
|
||||
action="approve_draft",
|
||||
proposal_id=record.proposal_id,
|
||||
detail={"mail_id": record.id, "hash": record.approved_hash},
|
||||
detail={"mail_id": record.id, "hash": record.approved_hash, "signature": signature},
|
||||
)
|
||||
return 0
|
||||
|
||||
@@ -328,7 +399,9 @@ def _send_one(cfg: Config, sess, mail_id: int, sender=None) -> str:
|
||||
The caller owns the transaction: one message per commit, so an SMTP failure
|
||||
on message N never un-sends messages 1..N-1 (audit finding: duplicates).
|
||||
"""
|
||||
from cayceops.models import _content_hash
|
||||
|
||||
from cayceops.config import ConfigError
|
||||
from cayceops.signing import verify_signature
|
||||
|
||||
record = sess.get(MailMessage, mail_id)
|
||||
if record is None:
|
||||
@@ -336,6 +409,41 @@ def _send_one(cfg: Config, sess, mail_id: int, sender=None) -> str:
|
||||
if record.draft_status != DraftStatus.APPROVED:
|
||||
return "blocked"
|
||||
|
||||
# run-time signature gate (audit round 2, 1d): the row must carry a valid
|
||||
# operator signature for its CURRENT to_addr/subject/body + deployment.
|
||||
# Unsigned rows (raw SQL approval) or edited ones revert to DRAFTED.
|
||||
try:
|
||||
deployment_id, pubkey = _signing(cfg)
|
||||
except ConfigError as exc:
|
||||
audit(
|
||||
sess,
|
||||
actor="daemon:maild",
|
||||
action="send_refused",
|
||||
proposal_id=record.proposal_id,
|
||||
result="error",
|
||||
detail={"mail_id": record.id, "reason": str(exc)},
|
||||
)
|
||||
return "unsigned"
|
||||
current_hash = _mail_content_hash(record)
|
||||
if not verify_signature(
|
||||
"mail", deployment_id, record.id, current_hash, record.operator_signature, pubkey
|
||||
):
|
||||
record.draft_status = DraftStatus.DRAFTED # back to pre-approval
|
||||
audit(
|
||||
sess,
|
||||
actor="daemon:maild",
|
||||
action="send_refused",
|
||||
proposal_id=record.proposal_id,
|
||||
result="error",
|
||||
detail={
|
||||
"mail_id": record.id,
|
||||
"reason": "no valid operator signature for current content/deployment",
|
||||
"content_hash": current_hash,
|
||||
"had_signature": bool(record.operator_signature),
|
||||
},
|
||||
)
|
||||
return "hash_mismatch"
|
||||
|
||||
if record.direction == MailDirection.INBOUND:
|
||||
# reply: goes back to the inbound sender, threads to the inbound mail,
|
||||
# and gets its own OUTBOUND row
|
||||
@@ -357,19 +465,6 @@ def _send_one(cfg: Config, sess, mail_id: int, sender=None) -> str:
|
||||
record.draft_status = DraftStatus.SKIPPED
|
||||
return "blocked"
|
||||
|
||||
# binding: the text must be exactly what the human approved
|
||||
if record.approved_hash and _content_hash(record.draft_reply or "") != record.approved_hash:
|
||||
record.draft_status = DraftStatus.DRAFTED # needs fresh approval
|
||||
audit(
|
||||
sess,
|
||||
actor="daemon:maild",
|
||||
action="send_refused_hash_mismatch",
|
||||
proposal_id=record.proposal_id,
|
||||
result="error",
|
||||
detail={"mail_id": record.id},
|
||||
)
|
||||
return "hash_mismatch"
|
||||
|
||||
(sender or _smtp_send)(
|
||||
cfg,
|
||||
to_addr,
|
||||
@@ -436,10 +531,22 @@ def _send_approved(cfg: Config, sess, sender=None, factory=None) -> dict:
|
||||
outcome = _send_one(cfg, sess, mail_id, sender)
|
||||
except Exception as exc: # noqa: BLE001 — isolate the failed message
|
||||
outcome = "error"
|
||||
if sess is not None:
|
||||
detail = {"mail_id": mail_id, "error": str(exc)[:200]}
|
||||
audit(sess, actor="daemon:maild", action="send_failed",
|
||||
result="error", detail=detail)
|
||||
# persist the failure in its OWN transaction (the failed message's
|
||||
# transaction is dead)
|
||||
try:
|
||||
error_sess = factory() if factory is not None else None
|
||||
if error_sess is not None:
|
||||
audit(
|
||||
error_sess,
|
||||
actor="daemon:maild",
|
||||
action="send_failed",
|
||||
result="error",
|
||||
detail={"mail_id": mail_id, "error": str(exc)[:200]},
|
||||
)
|
||||
error_sess.commit()
|
||||
error_sess.close()
|
||||
except Exception: # noqa: S110 — best-effort failure audit
|
||||
pass
|
||||
stats[outcome] = stats.get(outcome, 0) + 1
|
||||
return stats
|
||||
|
||||
@@ -517,40 +624,48 @@ def poll(cfg: Config | None = None) -> int:
|
||||
engine().dispose()
|
||||
|
||||
|
||||
def approve_draft(mail_id: int, actor: str) -> int:
|
||||
def approve_draft(mail_id: int, actor: str, signature: str | None = None) -> int:
|
||||
from cayceops.db import engine
|
||||
|
||||
try:
|
||||
with session_scope() as sess:
|
||||
return _approve_draft(mail_id, actor, sess)
|
||||
return _approve_draft(mail_id, actor, sess, signature)
|
||||
finally:
|
||||
engine().dispose()
|
||||
|
||||
|
||||
def send_approved(cfg: Config | None = None) -> int:
|
||||
"""CLI: same code path as the tests (_send_approved with factory).
|
||||
|
||||
Failures are audited (send_failed), recorded in daemon_runs, and any
|
||||
error yields a non-zero exit code (audit round 2, point 5).
|
||||
"""
|
||||
from cayceops.config import load
|
||||
from cayceops.db import engine, session_factory
|
||||
|
||||
cfg = cfg or load()
|
||||
try:
|
||||
# ids first, then one transaction per message: a failure on message N
|
||||
# cannot roll back already-sent messages (audit finding: duplicates)
|
||||
with session_scope() as sess:
|
||||
ids = [
|
||||
row.id
|
||||
for row in sess.query(MailMessage.id)
|
||||
.filter(MailMessage.draft_status == DraftStatus.APPROVED)
|
||||
.all()
|
||||
]
|
||||
factory = session_factory()
|
||||
for mail_id in ids:
|
||||
with session_scope() as sess:
|
||||
run_row = DaemonRun(daemon="maild-send")
|
||||
sess.add(run_row)
|
||||
sess.flush()
|
||||
try:
|
||||
with factory() as one_sess:
|
||||
_send_one(cfg, one_sess, mail_id)
|
||||
one_sess.commit()
|
||||
except Exception as exc: # noqa: BLE001 — isolate each send
|
||||
print(f"send {mail_id} failed: {str(exc)[:160]}")
|
||||
return 0
|
||||
_signing(cfg) # no degraded mode
|
||||
except Exception as exc: # noqa: BLE001 — ConfigError
|
||||
run_row.status = "error"
|
||||
run_row.detail = {"refused": str(exc)[:200]}
|
||||
run_row.finished_at = datetime.now(UTC)
|
||||
print(f"send refused: {exc}")
|
||||
return 2
|
||||
stats = _send_approved(cfg, sess, factory=factory)
|
||||
run_row.status = "error" if stats.get("error") else "ok"
|
||||
run_row.detail = stats
|
||||
run_row.finished_at = datetime.now(UTC)
|
||||
print(stats)
|
||||
# any failure class is a non-zero exit (audit round 2, point 5)
|
||||
failures = stats.get("error") or stats.get("hash_mismatch") or stats.get("unsigned")
|
||||
return 1 if failures else 0
|
||||
finally:
|
||||
engine().dispose()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user