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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user