From 0d24309433b0ad6f11c171ba07ba2bd0f0f37c44 Mon Sep 17 00:00:00 2001 From: Cayce Date: Wed, 28 Oct 2026 19:51:04 +0000 Subject: [PATCH] audit-D16: operator key must never be an agent identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- cayceops/components/executor/executor.py | 8 ++- cayceops/components/maild/maild.py | 8 ++- cayceops/components/publisher/publisher.py | 7 ++ cayceops/signing.py | 69 ++++++++++++++++++ docs/DECISIONS.md | 27 +++++++ tests/test_maild.py | 4 +- tests/test_no_self_approval.py | 82 ++++++++++++++++++++++ 7 files changed, 202 insertions(+), 3 deletions(-) create mode 100644 tests/test_no_self_approval.py diff --git a/cayceops/components/executor/executor.py b/cayceops/components/executor/executor.py index b1d8edf..7590315 100644 --- a/cayceops/components/executor/executor.py +++ b/cayceops/components/executor/executor.py @@ -41,14 +41,20 @@ GATE_ERROR = 2 # missing/invalid configuration or signature def _signing(cfg: Config) -> tuple[str, str]: - """(deployment_id, operator_pubkey) or ConfigError. No degraded mode.""" + """(deployment_id, operator_pubkey) or ConfigError. No degraded mode. + + Also rejects the agent's own identity as operator (D16): the operator + key must belong to a human and must never reside on this server. + """ from cayceops.config import ConfigError + from cayceops.signing import validate_no_self_approval 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" ) + validate_no_self_approval(cfg) return cfg.deployment_id, cfg.operator_pubkey diff --git a/cayceops/components/maild/maild.py b/cayceops/components/maild/maild.py index 0dd2da7..d57ba87 100644 --- a/cayceops/components/maild/maild.py +++ b/cayceops/components/maild/maild.py @@ -43,14 +43,20 @@ GATE_ERROR = 2 # missing/invalid configuration or signature def _signing(cfg: Config) -> tuple[str, str]: - """(deployment_id, operator_pubkey) or ConfigError. No degraded mode.""" + """(deployment_id, operator_pubkey) or ConfigError. No degraded mode. + + Also rejects the agent's own identity as operator (D16): the operator + key must belong to a human and must never reside on this server. + """ from cayceops.config import ConfigError + from cayceops.signing import validate_no_self_approval 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" ) + validate_no_self_approval(cfg) return cfg.deployment_id, cfg.operator_pubkey diff --git a/cayceops/components/publisher/publisher.py b/cayceops/components/publisher/publisher.py index 6bde7d1..a3bc6b5 100644 --- a/cayceops/components/publisher/publisher.py +++ b/cayceops/components/publisher/publisher.py @@ -118,13 +118,20 @@ GATE_ERROR = 2 # missing/invalid configuration or signature def _signing(cfg: Config) -> tuple[str, str]: + """(deployment_id, operator_pubkey) or ConfigError. No degraded mode. + + Also rejects the agent's own identity as operator (D16): the operator + key must belong to a human and must never reside on this server. + """ from cayceops.config import ConfigError + from cayceops.signing import validate_no_self_approval 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" ) + validate_no_self_approval(cfg) return cfg.deployment_id, cfg.operator_pubkey diff --git a/cayceops/signing.py b/cayceops/signing.py index df25b06..21a94fe 100644 --- a/cayceops/signing.py +++ b/cayceops/signing.py @@ -101,3 +101,72 @@ def verify_signature( ) except (ValueError, TypeError): return False + + +def pubkey_from_private_hex(private_key_hex: str) -> str: + """Derive the 64-char x-only public key from a 32-byte hex private key. + + Raises ValueError on malformed input. + """ + if not private_key_hex: + raise ValueError("empty private key") + try: + from coincurve import PublicKey + + return PublicKey.from_secret(bytes.fromhex(private_key_hex)).format().hex()[2:] + except (ImportError, ValueError) as exc: + raise ValueError(f"cannot derive pubkey: {exc}") from exc + + +class SelfApprovalError(RuntimeError): + """Raised when the configured operator pubkey is the identity of any + key held by the system itself (e.g. Cayce nostr identity). The design + requires a *separate human* operator key — never an agent key.""" + + +FORBIDDEN_IDENTITIES_ENV = "CAYCE_OPS_FORBIDDEN_OPERATOR_PUBKEYS" + + +def forbidden_identities(cfg) -> list[str]: + """Pubkeys that may never act as the operator key. + + Includes: + 1. Any private key loaded from the config (nostr_private_key, or any + future agent identity). + 2. An explicit deny-list in CAYCE_OPS_FORBIDDEN_OPERATOR_PUBKEYS + (comma-separated hex pubkeys) for keys held outside this process. + """ + listed: list[str] = [] + if cfg.nostr_private_key: + try: + listed.append(pubkey_from_private_hex(cfg.nostr_private_key)) + except ValueError: + pass # malformed nostr key: _signing() already catches it elsewhere + import os + + raw = os.environ.get(FORBIDDEN_IDENTITIES_ENV, "") + for tok in raw.split(","): + tok = tok.strip().lower() + if len(tok) == 64: + listed.append(tok) + return listed + + +def validate_no_self_approval(cfg) -> None: + """Refuse to operate if the operator pubkey equals any identity held by + the system. Raises SelfApprovalError. + + Empty operator_pubkey is fine here (that's the 'not configured' state + which _signing() rejects elsewhere); this check is about *wrong* keys. + """ + if not cfg.operator_pubkey: + return + me = cfg.operator_pubkey.strip().lower() + for forbidden in forbidden_identities(cfg): + if me == forbidden: + raise SelfApprovalError( + "CAYCE_OPS_OPERATOR_PUBKEY is an agent identity (Cayce or " + "another in-process key). The operator key must belong to a " + "human and must never reside on this server. Refusing to " + "operate. See docs/DECISIONS.md D16." + ) diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 8380ee1..58b4300 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -117,3 +117,30 @@ Any negation token anywhere in a mail vetoes an `approval` classification → needs_review. Simpler and strictly conservative vs. per-pattern negation lists. *Cost:* genuinely-positive approvals containing stray negations (e.g. "we have no reason not to welcome you") need human triage. + +## D16 — La clave del operador nunca es una identidad de agente + +**Fecha:** 2026-10-28 + +**Contexto:** se descubrió que `CAYCE_OPS_OPERATOR_PUBKEY` estaba configurada +con la pubkey de Cayce — es decir, Cayce se aprobaba a sí misma. La firma +BIP-340 era técnicamente válida pero semánticamente nula: "aprobación humana" +firmada por el agente no es aprobación humana. + +**Regla:** los daemons y el CLI se niegan a operar si la pubkey del operador +coincide con: +1. La pubkey derivada de cualquier clave privada cargada en la config + (`CAYCE_OPS_NOSTR_PRIVATE_KEY` u otra identidad de agente futura), o +2. Cualquier pubkey en `CAYCE_OPS_FORBIDDEN_OPERATOR_PUBKEYS` (deny-list + explícita, para claves fuera de este proceso). + +**Implementación:** `cayceops.signing.validate_no_self_approval()`, llamada +desde `_signing()` en executor/maild/publisher. Lanza `SelfApprovalError`. + +**La firma del operador se produce FUERA de la VPS.** El CLI `approve-*` +acepta la firma como parámetro (ya lo hace); nunca la genera él. Mientras +no exista el signer externo, `CAYCE_OPS_OPERATOR_PUBKEY` debe permanecer +vacía — los daemons se niegan a operar y eso es lo correcto. + +**La nsec de Cayce NO se toca:** sigue siendo la identidad del agente para +publicar y anclar. Lo que se prohíbe es usarla como clave de operador. diff --git a/tests/test_maild.py b/tests/test_maild.py index 05d9cb0..f574a42 100644 --- a/tests/test_maild.py +++ b/tests/test_maild.py @@ -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" diff --git a/tests/test_no_self_approval.py b/tests/test_no_self_approval.py new file mode 100644 index 0000000..f6e7394 --- /dev/null +++ b/tests/test_no_self_approval.py @@ -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")