- signing module: canonical content hashes + approval_message sha256(cayce-ops:<type>:<deployment_id>:<id>:<content_hash>) - approve/approve-draft/publisher-approve REQUIRE signature; without CAYCE_OPS_OPERATOR_PUBKEY+DEPLOYMENT_ID the daemons refuse to execute/send/publish (no degraded mode) - run-time re-verification at execute/send/publish: unsigned rows (raw SQL approval) or edited content revert to pre-approval state, audited as error - FULL signatures stored on rows (operator_signature, migration 0003) and in audit detail — never truncated - signature failures audited from CLI wrappers too (1f) - AUTONOMOUS_MODE approval path removed (D13): no non-human approvals tests: SQL-approved row refused at execute/send/publish; to_addr change blocks send; foreign deployment/content signatures refused; full-signature persistence. 65 tests green.
104 lines
3.0 KiB
Python
104 lines
3.0 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
|