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:
@@ -41,14 +41,20 @@ GATE_ERROR = 2 # missing/invalid configuration or signature
|
|||||||
|
|
||||||
|
|
||||||
def _signing(cfg: Config) -> tuple[str, str]:
|
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.config import ConfigError
|
||||||
|
from cayceops.signing import validate_no_self_approval
|
||||||
|
|
||||||
if not cfg.operator_pubkey or not cfg.deployment_id:
|
if not cfg.operator_pubkey or not cfg.deployment_id:
|
||||||
raise ConfigError(
|
raise ConfigError(
|
||||||
"approvals disabled: CAYCE_OPS_OPERATOR_PUBKEY and "
|
"approvals disabled: CAYCE_OPS_OPERATOR_PUBKEY and "
|
||||||
"CAYCE_OPS_DEPLOYMENT_ID are both required"
|
"CAYCE_OPS_DEPLOYMENT_ID are both required"
|
||||||
)
|
)
|
||||||
|
validate_no_self_approval(cfg)
|
||||||
return cfg.deployment_id, cfg.operator_pubkey
|
return cfg.deployment_id, cfg.operator_pubkey
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -43,14 +43,20 @@ GATE_ERROR = 2 # missing/invalid configuration or signature
|
|||||||
|
|
||||||
|
|
||||||
def _signing(cfg: Config) -> tuple[str, str]:
|
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.config import ConfigError
|
||||||
|
from cayceops.signing import validate_no_self_approval
|
||||||
|
|
||||||
if not cfg.operator_pubkey or not cfg.deployment_id:
|
if not cfg.operator_pubkey or not cfg.deployment_id:
|
||||||
raise ConfigError(
|
raise ConfigError(
|
||||||
"approvals disabled: CAYCE_OPS_OPERATOR_PUBKEY and "
|
"approvals disabled: CAYCE_OPS_OPERATOR_PUBKEY and "
|
||||||
"CAYCE_OPS_DEPLOYMENT_ID are both required"
|
"CAYCE_OPS_DEPLOYMENT_ID are both required"
|
||||||
)
|
)
|
||||||
|
validate_no_self_approval(cfg)
|
||||||
return cfg.deployment_id, cfg.operator_pubkey
|
return cfg.deployment_id, cfg.operator_pubkey
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -118,13 +118,20 @@ GATE_ERROR = 2 # missing/invalid configuration or signature
|
|||||||
|
|
||||||
|
|
||||||
def _signing(cfg: Config) -> tuple[str, str]:
|
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.config import ConfigError
|
||||||
|
from cayceops.signing import validate_no_self_approval
|
||||||
|
|
||||||
if not cfg.operator_pubkey or not cfg.deployment_id:
|
if not cfg.operator_pubkey or not cfg.deployment_id:
|
||||||
raise ConfigError(
|
raise ConfigError(
|
||||||
"approvals disabled: CAYCE_OPS_OPERATOR_PUBKEY and "
|
"approvals disabled: CAYCE_OPS_OPERATOR_PUBKEY and "
|
||||||
"CAYCE_OPS_DEPLOYMENT_ID are both required"
|
"CAYCE_OPS_DEPLOYMENT_ID are both required"
|
||||||
)
|
)
|
||||||
|
validate_no_self_approval(cfg)
|
||||||
return cfg.deployment_id, cfg.operator_pubkey
|
return cfg.deployment_id, cfg.operator_pubkey
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -101,3 +101,72 @@ def verify_signature(
|
|||||||
)
|
)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
return False
|
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."
|
||||||
|
)
|
||||||
|
|||||||
@@ -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
|
needs_review. Simpler and strictly conservative vs. per-pattern negation
|
||||||
lists. *Cost:* genuinely-positive approvals containing stray negations
|
lists. *Cost:* genuinely-positive approvals containing stray negations
|
||||||
(e.g. "we have no reason not to welcome you") need human triage.
|
(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.
|
||||||
|
|||||||
+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
|
p.id, f"desk{i}@acme.example.org", f"[cayce] inquiry {i}", "Hello!", sess
|
||||||
)
|
)
|
||||||
assert (
|
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
|
== 0
|
||||||
)
|
)
|
||||||
assert maild._send_one(STUB_CFG, sess, mail_id, sender=lambda *a, **k: None) == "sent"
|
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