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