audit-D16: operator key must never be an agent identity
ci / lint-test-leaks (pull_request) Canceled after 0s
ci / lint-test-leaks (pull_request) Canceled after 0s
Background: CAYCE_OPS_OPERATOR_PUBKEY was set to Cayce nostr pubkey — Cayce was approving itself. Signature was technically valid but semantically null: human approval signed by the agent is not human approval. - signing.py: validate_no_self_approval() rejects the operator pubkey when it matches (a) the pubkey derived from any private key in the config, or (b) any pubkey in CAYCE_OPS_FORBIDDEN_OPERATOR_PUBKEYS deny-list. - executor/maild/publisher _signing() now calls the validation, so every daemon and every CLI approve-* path refuses to operate (D16). - tests: 6 new (agent-key refusal, human key passes, empty passes, explicit denylist, end-to-end daemon refusal, derivation roundtrip). - docs/DECISIONS.md: D16. The operator signature is now produced OUTSIDE the VPS. CAYCE_OPS_OPERATOR_PUBKEY must stay empty until the external signer exists — daemons refuse, which is correct. The Cayce nsec itself is NOT touched (still the agent identity for publishing and anchoring).
This commit is contained in:
+3
-1
@@ -311,7 +311,9 @@ def test_send_failure_does_not_resurrect_sent_mails(cfg, sess):
|
||||
p.id, f"desk{i}@acme.example.org", f"[cayce] inquiry {i}", "Hello!", sess
|
||||
)
|
||||
assert (
|
||||
maild._approve_draft(mail_id, "operator", sess, signature=_approve_signed(sess, mail_id))
|
||||
maild._approve_draft(
|
||||
mail_id, "operator", sess, signature=_approve_signed(sess, mail_id)
|
||||
)
|
||||
== 0
|
||||
)
|
||||
assert maild._send_one(STUB_CFG, sess, mail_id, sender=lambda *a, **k: None) == "sent"
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Tests for D16: the operator key may never be an agent identity.
|
||||
|
||||
Background: the operator key was, at some point, Cayce's nostr key. The
|
||||
daemon must refuse to operate in that configuration, because "human
|
||||
approval" signed by the agent is not human approval.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from cayceops.signing import (
|
||||
SelfApprovalError,
|
||||
pubkey_from_private_hex,
|
||||
validate_no_self_approval,
|
||||
)
|
||||
|
||||
# keys invented for tests only (allowlisted in .gitleaks.toml)
|
||||
CAYCE_SECRET = "11" * 32 # simulates Cayce nostr identity
|
||||
CAYCE_PUBKEY = pubkey_from_private_hex(CAYCE_SECRET)
|
||||
HUMAN_SECRET = "33" * 32 # a proper human operator key
|
||||
HUMAN_PUBKEY = pubkey_from_private_hex(HUMAN_SECRET)
|
||||
|
||||
|
||||
def _cfg(monkeypatch, operator_pubkey: str, nostr_private_key: str = CAYCE_SECRET):
|
||||
from cayceops.config import Config
|
||||
|
||||
monkeypatch.setenv("CAYCE_OPS_DEPLOYMENT_ID", "itest")
|
||||
monkeypatch.setenv("CAYCE_OPS_OPERATOR_PUBKEY", operator_pubkey)
|
||||
monkeypatch.setenv("CAYCE_OPS_NOSTR_PRIVATE_KEY", nostr_private_key)
|
||||
return Config()
|
||||
|
||||
|
||||
def test_agent_pubkey_as_operator_is_refused(monkeypatch):
|
||||
"""The exact bug: operator pubkey = Cayce pubkey must fail."""
|
||||
cfg = _cfg(monkeypatch, operator_pubkey=CAYCE_PUBKEY)
|
||||
with pytest.raises(SelfApprovalError):
|
||||
validate_no_self_approval(cfg)
|
||||
|
||||
|
||||
def test_human_pubkey_passes(monkeypatch):
|
||||
cfg = _cfg(monkeypatch, operator_pubkey=HUMAN_PUBKEY)
|
||||
validate_no_self_approval(cfg) # does not raise
|
||||
|
||||
|
||||
def test_empty_operator_pubkey_passes_validation(monkeypatch):
|
||||
"""Empty is the 'not configured' state — rejected elsewhere, not here."""
|
||||
cfg = _cfg(monkeypatch, operator_pubkey="")
|
||||
validate_no_self_approval(cfg)
|
||||
|
||||
|
||||
def test_explicit_denylist_is_honoured(monkeypatch):
|
||||
"""A key held outside this process can be listed explicitly."""
|
||||
outside_pubkey = pubkey_from_private_hex("44" * 32)
|
||||
monkeypatch.setenv(
|
||||
"CAYCE_OPS_FORBIDDEN_OPERATOR_PUBKEYS", f"{outside_pubkey}, {HUMAN_PUBKEY}"
|
||||
)
|
||||
cfg = _cfg(monkeypatch, operator_pubkey=HUMAN_PUBKEY, nostr_private_key="")
|
||||
with pytest.raises(SelfApprovalError):
|
||||
validate_no_self_approval(cfg)
|
||||
|
||||
|
||||
def test_daemon_refuses_to_start_with_agent_key(monkeypatch, sess, db_engine):
|
||||
"""End-to-end: executor.run must refuse (exit 2) when the operator
|
||||
pubkey is Cayce's identity."""
|
||||
from cayceops.components.executor import executor
|
||||
from cayceops.models import DaemonRun
|
||||
|
||||
cfg = _cfg(monkeypatch, operator_pubkey=CAYCE_PUBKEY)
|
||||
code = executor.run(cfg, sess)
|
||||
assert code == 2
|
||||
run_row = sess.query(DaemonRun).filter_by(daemon="executor").one()
|
||||
assert run_row.status == "error"
|
||||
assert "agent identity" in run_row.detail["refused"]
|
||||
|
||||
|
||||
def test_pubkey_derivation_roundtrip():
|
||||
assert pubkey_from_private_hex(HUMAN_SECRET) == HUMAN_PUBKEY
|
||||
with pytest.raises(ValueError):
|
||||
pubkey_from_private_hex("")
|
||||
with pytest.raises(ValueError):
|
||||
pubkey_from_private_hex("nothex")
|
||||
Reference in New Issue
Block a user