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).
173 lines
5.4 KiB
Python
173 lines
5.4 KiB
Python
"""Operator approval signing — mandatory, content- and deployment-bound.
|
|
|
|
Every human approval (executor approve / maild approve-draft / publisher
|
|
approve) must carry a BIP-340 signature made with the operator key:
|
|
|
|
message = sha256("cayce-ops:<type>:<deployment_id>:<id>:<content_hash>")
|
|
|
|
where content_hash binds the approval to the exact approved content:
|
|
|
|
proposal: canonical json of {kind, title, url, detail}
|
|
mail: canonical json of {to_addr, subject, body}
|
|
content: canonical json of {channel, caption, link}
|
|
|
|
There is NO degraded mode: without CAYCE_OPS_OPERATOR_PUBKEY and
|
|
CAYCE_OPS_DEPLOYMENT_ID the daemons refuse to execute/send/publish
|
|
(see cayceops.config.Config.signing_credentials).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
|
|
|
|
class SignatureError(RuntimeError):
|
|
"""Raised when an approval signature is missing, invalid, or foreign."""
|
|
|
|
|
|
def _canonical(obj: dict) -> str:
|
|
return json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
|
|
|
|
|
|
def _sha256_hex(text: str) -> str:
|
|
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
|
|
|
|
|
def proposal_content_hash(kind: str, title: str, url: str | None, detail: dict | None) -> str:
|
|
return _sha256_hex(
|
|
_canonical(
|
|
{
|
|
"kind": kind,
|
|
"title": title,
|
|
"url": url,
|
|
"detail": detail or {},
|
|
}
|
|
)
|
|
)
|
|
|
|
|
|
def mail_content_hash(to_addr: str | None, subject: str | None, body: str | None) -> str:
|
|
return _sha256_hex(
|
|
_canonical(
|
|
{
|
|
"to_addr": to_addr,
|
|
"subject": subject,
|
|
"body": body,
|
|
}
|
|
)
|
|
)
|
|
|
|
|
|
def content_content_hash(channel: str, caption: str, link: str | None) -> str:
|
|
return _sha256_hex(
|
|
_canonical(
|
|
{
|
|
"channel": channel,
|
|
"caption": caption,
|
|
"link": link,
|
|
}
|
|
)
|
|
)
|
|
|
|
|
|
def approval_message(
|
|
item_type: str, deployment_id: str, item_id: int, content_hash: str
|
|
) -> bytes:
|
|
"""The exact 32-byte message the operator signs."""
|
|
raw = f"cayce-ops:{item_type}:{deployment_id}:{item_id}:{content_hash}"
|
|
return hashlib.sha256(raw.encode("utf-8")).digest()
|
|
|
|
|
|
def verify_signature(
|
|
item_type: str,
|
|
deployment_id: str,
|
|
item_id: int,
|
|
content_hash: str,
|
|
signature_hex: str | None,
|
|
operator_pubkey_hex: str,
|
|
) -> bool:
|
|
"""Verify an operator approval signature. Foreign deployments or edited
|
|
content verify False because both are inside the signed message."""
|
|
if not signature_hex or not operator_pubkey_hex or not deployment_id:
|
|
return False
|
|
from cayceops.components.publisher.nostr import _schnorr_verify
|
|
|
|
try:
|
|
return _schnorr_verify(
|
|
bytes.fromhex(signature_hex),
|
|
approval_message(item_type, deployment_id, item_id, content_hash),
|
|
bytes.fromhex(operator_pubkey_hex),
|
|
)
|
|
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."
|
|
)
|