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:
+30
-8
@@ -74,10 +74,10 @@ def executor_approve(
|
||||
proposal_id: int,
|
||||
actor: str = typer.Option(..., help="Human approver id"),
|
||||
signature: str = typer.Option(
|
||||
None, help="BIP-340 sig (required when operator pubkey is set)"
|
||||
..., help="BIP-340 sig over the approval message (mandatory)"
|
||||
),
|
||||
) -> None:
|
||||
"""Approve a proposal (human action, audited)."""
|
||||
"""Approve a proposal (human action, audited; signature mandatory)."""
|
||||
raise typer.Exit(
|
||||
_load_component("executor").approve(proposal_id, actor, signature=signature)
|
||||
)
|
||||
@@ -101,9 +101,17 @@ def maild_poll() -> None:
|
||||
|
||||
|
||||
@maild_app.command("approve-draft")
|
||||
def maild_approve(msg_id: int, actor: str = typer.Option(...)) -> None:
|
||||
"""Approve a drafted reply so SMTP may send it."""
|
||||
raise typer.Exit(_load_component("maild").approve_draft(msg_id, actor))
|
||||
def maild_approve(
|
||||
msg_id: int,
|
||||
actor: str = typer.Option(...),
|
||||
signature: str = typer.Option(
|
||||
..., help="BIP-340 sig over the approval message (mandatory)"
|
||||
),
|
||||
) -> None:
|
||||
"""Approve a drafted reply so SMTP may send it (signature mandatory)."""
|
||||
raise typer.Exit(
|
||||
_load_component("maild").approve_draft(msg_id, actor, signature)
|
||||
)
|
||||
|
||||
|
||||
@maild_app.command("send-approved")
|
||||
@@ -153,9 +161,17 @@ def publisher_image(item_id: int) -> None:
|
||||
|
||||
|
||||
@publisher_app.command("approve")
|
||||
def publisher_approve(item_id: int, actor: str = typer.Option(...)) -> None:
|
||||
"""Approve a draft content item."""
|
||||
raise typer.Exit(_load_component("publisher").approve(item_id, actor))
|
||||
def publisher_approve(
|
||||
item_id: int,
|
||||
actor: str = typer.Option(...),
|
||||
signature: str = typer.Option(
|
||||
..., help="BIP-340 sig over the approval message (mandatory)"
|
||||
),
|
||||
) -> None:
|
||||
"""Approve a draft content item (signature mandatory)."""
|
||||
raise typer.Exit(
|
||||
_load_component("publisher").approve(item_id, actor, signature)
|
||||
)
|
||||
|
||||
|
||||
@publisher_app.command("publish-due")
|
||||
@@ -187,6 +203,12 @@ def monitor_run() -> None:
|
||||
raise typer.Exit(_load_component("monitor").build())
|
||||
|
||||
|
||||
@monitor_app.command("anchor")
|
||||
def monitor_anchor() -> None:
|
||||
"""Publish today's audit-chain anchor as a signed Nostr note (1/day)."""
|
||||
raise typer.Exit(_load_component("monitor").anchor())
|
||||
|
||||
|
||||
def main() -> None: # pragma: no cover
|
||||
app()
|
||||
|
||||
|
||||
@@ -37,11 +37,19 @@ def _load_cfg():
|
||||
|
||||
# --- human transitions -------------------------------------------------------
|
||||
|
||||
# message signed by the operator key when CAYCE_OPS_OPERATOR_PUBKEY is set
|
||||
def approval_message(proposal_id: int) -> bytes:
|
||||
import hashlib
|
||||
GATE_ERROR = 2 # missing/invalid configuration or signature
|
||||
|
||||
return hashlib.sha256(f"cayce-ops:approve:{proposal_id}".encode()).digest()
|
||||
|
||||
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
|
||||
|
||||
|
||||
def approve(
|
||||
@@ -51,36 +59,54 @@ def approve(
|
||||
sess=None,
|
||||
signature: str | None = None,
|
||||
) -> int:
|
||||
"""Human approves a proposal. Audited with the operator's identity.
|
||||
|
||||
When CAYCE_OPS_OPERATOR_PUBKEY is configured, `signature` (hex, BIP-340
|
||||
over approval_message(id)) is REQUIRED and verified against that key —
|
||||
'actor' then is a label, the key is the proof (audit finding 1).
|
||||
"""
|
||||
from cayceops.components.publisher.nostr import verify_schnorr
|
||||
"""Human approves a proposal. Signature is MANDATORY and bound to the
|
||||
proposal's current content and this deployment (audit finding 1)."""
|
||||
from cayceops.config import ConfigError
|
||||
from cayceops.signing import (
|
||||
SignatureError,
|
||||
proposal_content_hash,
|
||||
verify_signature,
|
||||
)
|
||||
|
||||
cfg = cfg or _load_cfg()
|
||||
if cfg.operator_pubkey:
|
||||
ok = bool(signature) and verify_schnorr(
|
||||
signature, approval_message(proposal_id), cfg.operator_pubkey
|
||||
)
|
||||
if not ok and sess is not None:
|
||||
try:
|
||||
deployment_id, pubkey = _signing(cfg)
|
||||
except ConfigError as exc:
|
||||
if sess is not None:
|
||||
audit(
|
||||
sess,
|
||||
actor=actor,
|
||||
action="approve",
|
||||
proposal_id=proposal_id,
|
||||
result="error",
|
||||
detail={
|
||||
"reason": (
|
||||
"operator pubkey configured: signature required"
|
||||
if not signature
|
||||
else "invalid approval signature"
|
||||
)
|
||||
},
|
||||
detail={"reason": str(exc)},
|
||||
)
|
||||
if not ok:
|
||||
return 2
|
||||
return GATE_ERROR
|
||||
|
||||
proposal = sess.get(Proposal, proposal_id) if sess is not None else None
|
||||
if proposal is None:
|
||||
return 1
|
||||
chash = proposal_content_hash(
|
||||
proposal.kind, proposal.title, proposal.url, proposal.detail
|
||||
)
|
||||
try:
|
||||
if not signature:
|
||||
raise SignatureError("signature required")
|
||||
if not verify_signature(
|
||||
"proposal", deployment_id, proposal_id, chash, signature, pubkey
|
||||
):
|
||||
raise SignatureError("signature invalid for this content/deployment")
|
||||
except SignatureError as exc:
|
||||
audit(
|
||||
sess,
|
||||
actor=actor,
|
||||
action="approve",
|
||||
proposal_id=proposal_id,
|
||||
result="error",
|
||||
detail={"reason": str(exc), "content_hash": chash},
|
||||
)
|
||||
return GATE_ERROR
|
||||
|
||||
return _decide(proposal_id, actor, ProposalStatus.APPROVED, cfg, sess, signature)
|
||||
|
||||
|
||||
@@ -122,8 +148,8 @@ def _decide(
|
||||
if decision == ProposalStatus.APPROVED:
|
||||
proposal.approved_by = actor
|
||||
proposal.approved_at = datetime.now(UTC)
|
||||
if signature:
|
||||
detail["signature"] = signature[:32] + "..."
|
||||
proposal.operator_signature = signature # FULL signature, row + audit
|
||||
detail["signature"] = signature or ""
|
||||
verb = "approve" if decision == ProposalStatus.APPROVED else "reject"
|
||||
audit(sess, actor=actor, action=verb, proposal_id=proposal_id, detail=detail)
|
||||
return 0
|
||||
@@ -136,9 +162,20 @@ def _decide(
|
||||
|
||||
|
||||
def _auto_approve(cfg: Config, sess) -> int:
|
||||
"""Auto-approve proposed items per policy. Returns count approved."""
|
||||
if not cfg.autonomous_mode or not cfg.autonomous_kinds:
|
||||
return 0
|
||||
"""DISABLED (audit round 2, point 1a): approvals require operator
|
||||
signatures bound to content — there is no non-human approval path.
|
||||
AUTONOMOUS_MODE no longer grants approval powers (DECISIONS D13)."""
|
||||
if cfg.autonomous_mode and cfg.autonomous_kinds:
|
||||
audit(
|
||||
sess,
|
||||
actor="auto:policy",
|
||||
action="autonomy_disabled",
|
||||
result="error",
|
||||
detail={
|
||||
"reason": "mandatory operator signatures: no non-human approvals",
|
||||
},
|
||||
)
|
||||
return 0
|
||||
midnight = datetime.now(UTC).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
used = (
|
||||
sess.query(func.count(AuditEntry.id))
|
||||
@@ -188,8 +225,67 @@ def _auto_approve(cfg: Config, sess) -> int:
|
||||
# --- execution ----------------------------------------------------------------
|
||||
|
||||
|
||||
def _verify_approval_signature(proposal: Proposal, cfg: Config, sess) -> bool:
|
||||
"""Run-time gate (audit round 2, 1d): a row in `approved` MUST carry a
|
||||
valid operator signature for its CURRENT content and THIS deployment.
|
||||
Rows approved by raw SQL (no signature) or edited after approval are
|
||||
rejected, audited as error, and revert to `proposed`."""
|
||||
from cayceops.signing import (
|
||||
SignatureError,
|
||||
proposal_content_hash,
|
||||
verify_signature,
|
||||
)
|
||||
|
||||
try:
|
||||
deployment_id, pubkey = _signing(cfg)
|
||||
except Exception as exc: # noqa: BLE001 — ConfigError at daemon boundary
|
||||
audit(
|
||||
sess,
|
||||
actor="daemon:executor",
|
||||
action="execute_refused",
|
||||
proposal_id=proposal.id,
|
||||
result="error",
|
||||
detail={"reason": str(exc)},
|
||||
)
|
||||
return False
|
||||
chash = proposal_content_hash(
|
||||
proposal.kind, proposal.title, proposal.url, proposal.detail
|
||||
)
|
||||
try:
|
||||
if not verify_signature(
|
||||
"proposal",
|
||||
deployment_id,
|
||||
proposal.id,
|
||||
chash,
|
||||
proposal.operator_signature,
|
||||
pubkey,
|
||||
):
|
||||
raise SignatureError(
|
||||
"no valid operator signature for current content/deployment"
|
||||
)
|
||||
except SignatureError as exc:
|
||||
proposal.status = ProposalStatus.PROPOSED # back to pre-approval
|
||||
proposal.operator_signature = None
|
||||
audit(
|
||||
sess,
|
||||
actor="daemon:executor",
|
||||
action="execute_refused",
|
||||
proposal_id=proposal.id,
|
||||
result="error",
|
||||
detail={
|
||||
"reason": str(exc),
|
||||
"content_hash": chash,
|
||||
"had_signature": bool(proposal.operator_signature),
|
||||
},
|
||||
)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def execute_one(proposal: Proposal, cfg: Config, sess) -> bool:
|
||||
"""Run the plugin for one approved proposal. Returns True on success."""
|
||||
if not _verify_approval_signature(proposal, cfg, sess):
|
||||
return False
|
||||
proposal.status = ProposalStatus.RUNNING
|
||||
proposal.attempts += 1
|
||||
audit(
|
||||
@@ -256,6 +352,13 @@ def run(cfg: Config | None = None, sess=None) -> int:
|
||||
run_row = DaemonRun(daemon="executor")
|
||||
sess.add(run_row)
|
||||
sess.flush()
|
||||
try:
|
||||
_signing(cfg) # refuse to run without signing config (no degraded mode)
|
||||
except Exception as exc: # noqa: BLE001 — ConfigError at boundary
|
||||
run_row.status = "error"
|
||||
run_row.detail = {"refused": str(exc)[:200]}
|
||||
run_row.finished_at = datetime.now(UTC)
|
||||
return 2
|
||||
try:
|
||||
auto = _auto_approve(cfg, sess)
|
||||
pending = (
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -114,7 +114,33 @@ def _plan(cfg: Config, sess, days: int = 4) -> dict:
|
||||
# --- human gate -------------------------------------------------------------------
|
||||
|
||||
|
||||
def _approve(item_id: int, actor: str, sess) -> int:
|
||||
GATE_ERROR = 2 # missing/invalid configuration or signature
|
||||
|
||||
|
||||
def _signing(cfg: Config) -> tuple[str, str]:
|
||||
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
|
||||
|
||||
|
||||
def _content_binding(item: ContentItem) -> str:
|
||||
from cayceops.signing import content_content_hash
|
||||
|
||||
return content_content_hash(item.channel, item.caption, item.link)
|
||||
|
||||
|
||||
def _approve(
|
||||
item_id: int, actor: str, sess, signature: str | None = None
|
||||
) -> int:
|
||||
"""Signature MANDATORY, bound to (channel, caption, link) + deployment."""
|
||||
from cayceops.config import ConfigError
|
||||
from cayceops.signing import SignatureError, verify_signature
|
||||
|
||||
item = sess.get(ContentItem, item_id)
|
||||
if item is None or item.status != ContentStatus.DRAFT:
|
||||
audit(
|
||||
@@ -125,17 +151,58 @@ def _approve(item_id: int, actor: str, sess) -> int:
|
||||
detail={"reason": f"item {item_id} not in draft state"},
|
||||
)
|
||||
return 1
|
||||
try:
|
||||
deployment_id, pubkey = _signing(cfg_from(sess))
|
||||
except ConfigError as exc:
|
||||
audit(
|
||||
sess,
|
||||
actor=actor,
|
||||
action="approve_content",
|
||||
result="error",
|
||||
detail={"reason": str(exc)},
|
||||
)
|
||||
return GATE_ERROR
|
||||
chash = _content_binding(item)
|
||||
try:
|
||||
if not signature:
|
||||
raise SignatureError("signature required")
|
||||
if not verify_signature(
|
||||
"content", deployment_id, item_id, chash, signature, pubkey
|
||||
):
|
||||
raise SignatureError("signature invalid for this content/deployment")
|
||||
except SignatureError as exc:
|
||||
audit(
|
||||
sess,
|
||||
actor=actor,
|
||||
action="approve_content",
|
||||
result="error",
|
||||
detail={"reason": str(exc), "content_hash": chash},
|
||||
)
|
||||
return GATE_ERROR
|
||||
item.status = ContentStatus.APPROVED
|
||||
item.approved_hash = _content_hash(item.caption) # binding: caption as approved
|
||||
item.approved_hash = _content_hash(item.caption)
|
||||
item.operator_signature = signature # FULL signature
|
||||
audit(
|
||||
sess,
|
||||
actor=actor,
|
||||
action="approve_content",
|
||||
detail={"item": item.id, "channel": item.channel, "hash": item.approved_hash},
|
||||
detail={
|
||||
"item": item.id,
|
||||
"channel": item.channel,
|
||||
"hash": item.approved_hash,
|
||||
"signature": signature,
|
||||
},
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def cfg_from(sess):
|
||||
"""Config from env (publisher commands are single-invocation CLIs)."""
|
||||
from cayceops.config import load
|
||||
|
||||
return load()
|
||||
|
||||
|
||||
# --- image rendering ----------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -192,14 +259,41 @@ def _publish_due(cfg: Config, sess, nostr_sender=None) -> dict:
|
||||
if item.scheduled_for and item.scheduled_for > now:
|
||||
continue # not due yet
|
||||
|
||||
# binding: caption must be exactly what was approved (audit finding)
|
||||
if item.approved_hash and _content_hash(item.caption) != item.approved_hash:
|
||||
# run-time signature gate (audit round 2, 1d): valid operator
|
||||
# signature required for CURRENT channel/caption/link + deployment.
|
||||
# Unsigned rows (raw SQL approval) or edited ones revert to DRAFT.
|
||||
from cayceops.signing import verify_signature
|
||||
|
||||
try:
|
||||
deployment_id, pubkey = _signing(cfg)
|
||||
except Exception as exc: # noqa: BLE001 — ConfigError
|
||||
audit(
|
||||
sess,
|
||||
actor="daemon:publisher",
|
||||
action="publish_refused_hash_mismatch",
|
||||
action="publish_refused",
|
||||
result="error",
|
||||
detail={"item": item.id},
|
||||
detail={"item": item.id, "reason": str(exc)},
|
||||
)
|
||||
stats["failed"] += 1
|
||||
continue
|
||||
if not verify_signature(
|
||||
"content",
|
||||
deployment_id,
|
||||
item.id,
|
||||
_content_binding(item),
|
||||
item.operator_signature,
|
||||
pubkey,
|
||||
):
|
||||
item.status = ContentStatus.DRAFT # back to pre-approval
|
||||
audit(
|
||||
sess,
|
||||
actor="daemon:publisher",
|
||||
action="publish_refused",
|
||||
result="error",
|
||||
detail={
|
||||
"item": item.id,
|
||||
"reason": "no valid operator signature for current content/deployment",
|
||||
},
|
||||
)
|
||||
stats["tampered"] += 1
|
||||
continue
|
||||
@@ -287,6 +381,13 @@ def publish_due(cfg: Config | None = None) -> int:
|
||||
run_row = DaemonRun(daemon="publisher")
|
||||
sess.add(run_row)
|
||||
sess.flush()
|
||||
try:
|
||||
_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)
|
||||
return 2
|
||||
stats = _publish_due(cfg, sess)
|
||||
run_row.status = "ok"
|
||||
run_row.detail = stats
|
||||
@@ -312,11 +413,11 @@ def make_image(item_id: int, cfg: Config | None = None) -> int:
|
||||
engine().dispose()
|
||||
|
||||
|
||||
def approve(item_id: int, actor: str) -> int:
|
||||
def approve(item_id: int, actor: str, signature: str | None = None) -> int:
|
||||
from cayceops.db import engine
|
||||
|
||||
try:
|
||||
with session_scope() as sess:
|
||||
return _approve(item_id, actor, sess)
|
||||
return _approve(item_id, actor, sess, signature)
|
||||
finally:
|
||||
engine().dispose()
|
||||
|
||||
+10
-2
@@ -84,11 +84,15 @@ class Config:
|
||||
enable_http_actions: bool = field(
|
||||
default_factory=lambda: _bool("CAYCE_OPS_ENABLE_HTTP_ACTIONS", False)
|
||||
)
|
||||
# when set (64-char hex x-only pubkey), human approvals MUST carry a valid
|
||||
# BIP-340 signature over "cayce-ops:approve:<id>" made by the operator key.
|
||||
# MANDATORY for approval-gated work: the operator x-only pubkey and the
|
||||
# deployment identifier are part of every signed approval message.
|
||||
# Without BOTH, daemons refuse to execute/send/publish — no degraded mode.
|
||||
operator_pubkey: str = field(
|
||||
default_factory=lambda: os.environ.get("CAYCE_OPS_OPERATOR_PUBKEY", "")
|
||||
)
|
||||
deployment_id: str = field(
|
||||
default_factory=lambda: os.environ.get("CAYCE_OPS_DEPLOYMENT_ID", "")
|
||||
)
|
||||
|
||||
# --- mail ---
|
||||
imap_host: str = field(
|
||||
@@ -167,6 +171,10 @@ class Config:
|
||||
)
|
||||
|
||||
|
||||
class ConfigError(RuntimeError):
|
||||
"""Raised when mandatory configuration is missing."""
|
||||
|
||||
|
||||
def load() -> Config:
|
||||
"""Build the process Config snapshot."""
|
||||
return Config()
|
||||
|
||||
+86
-14
@@ -21,9 +21,10 @@ from __future__ import annotations
|
||||
import enum
|
||||
import hashlib
|
||||
import json
|
||||
import threading
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import JSON, DateTime, Enum, Float, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy import JSON, DateTime, Enum, Float, ForeignKey, Integer, String, Text, text
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
|
||||
|
||||
@@ -77,6 +78,8 @@ class Proposal(Base):
|
||||
dedupe_key: Mapped[str | None] = mapped_column(String(256), unique=True, nullable=True)
|
||||
approved_by: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
approved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
# full operator approval signature (128 hex chars) — never truncated
|
||||
operator_signature: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=utcnow, onupdate=utcnow
|
||||
@@ -103,6 +106,11 @@ class AuditEntry(Base):
|
||||
entry_hash: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
|
||||
|
||||
# reentrant: several audit() calls may share one transaction
|
||||
_AUDIT_LOCK = threading.RLock()
|
||||
_AUDIT_LOCK_PG_KEY = 7471186757 # pg_advisory_xact_lock key for cross-process
|
||||
|
||||
|
||||
def audit(
|
||||
sess,
|
||||
actor: str,
|
||||
@@ -113,13 +121,66 @@ def audit(
|
||||
) -> AuditEntry:
|
||||
"""Record one audited action, chained to the previous entry.
|
||||
|
||||
Chain: entry_hash = sha256(prev_hash | ts | actor | action | proposal_id | detail | result).
|
||||
Every component writes through this helper.
|
||||
Chain: entry_hash = sha256(prev_hash | ts_epoch | actor | action |
|
||||
proposal_id | detail | result). Writes are SERIALIZED (audit round 2,
|
||||
point 4): pg_advisory_xact_lock on Postgres, in-process lock on SQLite —
|
||||
concurrent writers can no longer fork the chain.
|
||||
|
||||
Legacy rows (entry_hash IS NULL, pre-0002) are ignored when picking
|
||||
prev_hash: the chain starts GENESIS at the first hashed row.
|
||||
"""
|
||||
prev = sess.query(AuditEntry).order_by(AuditEntry.id.desc()).first()
|
||||
prev_hash = prev.entry_hash if prev is not None else "GENESIS"
|
||||
bind = sess.get_bind()
|
||||
if bind.dialect.name == "postgresql":
|
||||
# xact-scoped: PG releases the lock at commit/rollback automatically
|
||||
sess.execute(
|
||||
text("SELECT pg_advisory_xact_lock(:k)"), {"k": _AUDIT_LOCK_PG_KEY}
|
||||
)
|
||||
prev = sess.query(AuditEntry).order_by(AuditEntry.id.desc()).first()
|
||||
prev_hash = prev.entry_hash if prev is not None else "GENESIS"
|
||||
entry = _build_entry(prev_hash, actor, action, proposal_id, detail, result)
|
||||
sess.add(entry)
|
||||
return entry
|
||||
|
||||
# SQLite path: hold the in-process lock until the session commits or
|
||||
# rolls back, so concurrent writers cannot read the same prev_hash
|
||||
# (round-2 point 4 — verified by test_concurrent_writers_keep_chain_verifiable)
|
||||
_AUDIT_LOCK.acquire()
|
||||
released = False
|
||||
|
||||
def _release(*_args):
|
||||
nonlocal released
|
||||
if not released:
|
||||
released = True
|
||||
try:
|
||||
_AUDIT_LOCK.release()
|
||||
except RuntimeError: # pragma: no cover
|
||||
pass
|
||||
|
||||
from sqlalchemy import event
|
||||
|
||||
event.listen(sess, "after_commit", _release)
|
||||
event.listen(sess, "after_rollback", _release)
|
||||
event.listen(sess, "after_soft_rollback", _release)
|
||||
try:
|
||||
prev = sess.query(AuditEntry).order_by(AuditEntry.id.desc()).first()
|
||||
prev_hash = prev.entry_hash if prev is not None else "GENESIS"
|
||||
entry = _build_entry(prev_hash, actor, action, proposal_id, detail, result)
|
||||
sess.add(entry)
|
||||
return entry
|
||||
except Exception:
|
||||
_release()
|
||||
raise
|
||||
|
||||
|
||||
def _build_entry(
|
||||
prev_hash: str,
|
||||
actor: str,
|
||||
action: str,
|
||||
proposal_id: int | None,
|
||||
detail: dict | None,
|
||||
result: str,
|
||||
) -> AuditEntry:
|
||||
ts = utcnow()
|
||||
# epoch int: identical after a sqlite/pg round-trip (tz-aware isoformat is not)
|
||||
ts_int = int(ts.timestamp())
|
||||
detail_json = json.dumps(detail or {}, sort_keys=True, ensure_ascii=False)
|
||||
payload = "|".join(
|
||||
@@ -133,31 +194,40 @@ def audit(
|
||||
result,
|
||||
]
|
||||
)
|
||||
entry = AuditEntry(
|
||||
return AuditEntry(
|
||||
ts=ts,
|
||||
actor=actor,
|
||||
action=action,
|
||||
proposal_id=proposal_id,
|
||||
detail=detail or {},
|
||||
result=result,
|
||||
prev_hash=prev_hash,
|
||||
prev_hash=prev_hash or "GENESIS",
|
||||
entry_hash=hashlib.sha256(payload.encode("utf-8")).hexdigest(),
|
||||
)
|
||||
sess.add(entry)
|
||||
return entry
|
||||
|
||||
|
||||
def verify_audit_chain(sess) -> tuple[bool, int | None]:
|
||||
"""Re-walk the chain. Returns (ok, first_broken_entry_id or None)."""
|
||||
prev_hash = "GENESIS"
|
||||
"""Re-walk the chain. Returns (ok, first_broken_entry_id or None).
|
||||
|
||||
Legacy rows (entry_hash IS NULL, written before migration 0002) are
|
||||
skipped and documented: the chain begins GENESIS at the first hashed row,
|
||||
so a migrated database verifies clean without any tampering.
|
||||
"""
|
||||
prev_hash: str | None = None # None = still in legacy prefix
|
||||
for entry in sess.query(AuditEntry).order_by(AuditEntry.id).all():
|
||||
if entry.entry_hash is None:
|
||||
continue # legacy row: outside the chain by documented genesis
|
||||
if prev_hash is None:
|
||||
expected_prev = "GENESIS" # first hashed row anchors the chain
|
||||
else:
|
||||
expected_prev = prev_hash
|
||||
detail_json = json.dumps(entry.detail or {}, sort_keys=True, ensure_ascii=False)
|
||||
ts = entry.ts
|
||||
if ts.tzinfo is None:
|
||||
ts = ts.replace(tzinfo=UTC)
|
||||
payload = "|".join(
|
||||
[
|
||||
prev_hash,
|
||||
expected_prev,
|
||||
str(int(ts.timestamp())),
|
||||
entry.actor,
|
||||
entry.action,
|
||||
@@ -167,7 +237,7 @@ def verify_audit_chain(sess) -> tuple[bool, int | None]:
|
||||
]
|
||||
)
|
||||
expected = hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
||||
if entry.prev_hash != prev_hash or entry.entry_hash != expected:
|
||||
if entry.prev_hash != expected_prev or entry.entry_hash != expected:
|
||||
return False, entry.id
|
||||
prev_hash = entry.entry_hash
|
||||
return True, None
|
||||
@@ -222,6 +292,7 @@ class MailMessage(Base):
|
||||
# binding: hash of the draft text the human approved + who approved it
|
||||
approved_hash: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
approved_by: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
operator_signature: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
processed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
|
||||
@@ -254,6 +325,7 @@ class ContentItem(Base):
|
||||
attempts: Mapped[int] = mapped_column(Integer, default=0)
|
||||
# binding: hash of the caption the human approved
|
||||
approved_hash: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
operator_signature: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Operator approval signing — mandatory, content- and deployment-bound.
|
||||
|
||||
Every human approval (executor approve / maild approve-draft / publisher
|
||||
approve) must carry a BIP-340 signature made with the operator key:
|
||||
|
||||
message = sha256("cayce-ops:<type>:<deployment_id>:<id>:<content_hash>")
|
||||
|
||||
where content_hash binds the approval to the exact approved content:
|
||||
|
||||
proposal: canonical json of {kind, title, url, detail}
|
||||
mail: canonical json of {to_addr, subject, body}
|
||||
content: canonical json of {channel, caption, link}
|
||||
|
||||
There is NO degraded mode: without CAYCE_OPS_OPERATOR_PUBKEY and
|
||||
CAYCE_OPS_DEPLOYMENT_ID the daemons refuse to execute/send/publish
|
||||
(see cayceops.config.Config.signing_credentials).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
|
||||
class SignatureError(RuntimeError):
|
||||
"""Raised when an approval signature is missing, invalid, or foreign."""
|
||||
|
||||
|
||||
def _canonical(obj: dict) -> str:
|
||||
return json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
|
||||
|
||||
|
||||
def _sha256_hex(text: str) -> str:
|
||||
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def proposal_content_hash(kind: str, title: str, url: str | None, detail: dict | None) -> str:
|
||||
return _sha256_hex(
|
||||
_canonical(
|
||||
{
|
||||
"kind": kind,
|
||||
"title": title,
|
||||
"url": url,
|
||||
"detail": detail or {},
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def mail_content_hash(to_addr: str | None, subject: str | None, body: str | None) -> str:
|
||||
return _sha256_hex(
|
||||
_canonical(
|
||||
{
|
||||
"to_addr": to_addr,
|
||||
"subject": subject,
|
||||
"body": body,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def content_content_hash(channel: str, caption: str, link: str | None) -> str:
|
||||
return _sha256_hex(
|
||||
_canonical(
|
||||
{
|
||||
"channel": channel,
|
||||
"caption": caption,
|
||||
"link": link,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def approval_message(
|
||||
item_type: str, deployment_id: str, item_id: int, content_hash: str
|
||||
) -> bytes:
|
||||
"""The exact 32-byte message the operator signs."""
|
||||
raw = f"cayce-ops:{item_type}:{deployment_id}:{item_id}:{content_hash}"
|
||||
return hashlib.sha256(raw.encode("utf-8")).digest()
|
||||
|
||||
|
||||
def verify_signature(
|
||||
item_type: str,
|
||||
deployment_id: str,
|
||||
item_id: int,
|
||||
content_hash: str,
|
||||
signature_hex: str | None,
|
||||
operator_pubkey_hex: str,
|
||||
) -> bool:
|
||||
"""Verify an operator approval signature. Foreign deployments or edited
|
||||
content verify False because both are inside the signed message."""
|
||||
if not signature_hex or not operator_pubkey_hex or not deployment_id:
|
||||
return False
|
||||
from cayceops.components.publisher.nostr import _schnorr_verify
|
||||
|
||||
try:
|
||||
return _schnorr_verify(
|
||||
bytes.fromhex(signature_hex),
|
||||
approval_message(item_type, deployment_id, item_id, content_hash),
|
||||
bytes.fromhex(operator_pubkey_hex),
|
||||
)
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
Reference in New Issue
Block a user