"""Proposer signature over the canonical PROPOSE message. Every merge proposal is optionally signed by the proposer using their registered Ed25519 key. The signature binds their cryptographic identity to the specific act of opening this proposal. Canonical PROPOSE message (UTF-8, LF line endings): PROPOSE\\n repo_id: sha256:\\n from_branch: \\n to_branch: \\n author: \\n created_at: The client supplies ``proposerTimestamp`` (the ``created_at`` field it will sign over). The server verifies the signature immediately on receipt using the proposer's pre-image, then stores the signature alongside the assigned ``proposal_id``. The timestamp must be within ±5 minutes of server time to prevent replay attacks. """ from __future__ import annotations from datetime import datetime, timezone, timedelta from muse.core.types import decode_pubkey, decode_sig from musehub.crypto.keys import KeyAlgorithm, SignatureError, verify_signature _DOMAIN_PREFIX = "PROPOSE" _MAX_SKEW = timedelta(minutes=5) def canonical_propose_message( *, repo_id: str, from_branch: str, to_branch: str, author: str, created_at: datetime, proposal_id: str | None = None, ) -> bytes: """Return the canonical UTF-8 bytes that the proposer signs. ``proposal_id`` is optional — omit it for pre-creation signing where the server hasn't assigned an ID yet. When present it is included as the second line for post-creation verification. """ lines = [_DOMAIN_PREFIX] if proposal_id is not None: lines.append(f"proposal_id: {proposal_id}") lines += [ f"repo_id: {repo_id}", f"from_branch: {from_branch}", f"to_branch: {to_branch}", f"author: {author}", f"created_at: {created_at.isoformat()}", ] return "\n".join(lines).encode("utf-8") def verify_proposer_signature( *, message: bytes, signature: str, public_key: str, ) -> None: """Verify an Ed25519 proposer signature. Args: message: Canonical PROPOSE message bytes. signature: ``ed25519:`` signature string. public_key: ``ed25519:`` public key string. Raises: SignatureError: Signature is invalid, malformed, or wrong algorithm. """ try: sig_algo, sig_bytes = decode_sig(signature) key_algo, key_bytes = decode_pubkey(public_key) except ValueError as exc: raise SignatureError(f"Malformed signature or public key: {exc}") from exc if sig_algo != "ed25519" or key_algo != "ed25519": raise SignatureError(f"Unsupported algorithm: sig={sig_algo} key={key_algo}") verify_signature( algorithm=KeyAlgorithm.ED25519, public_key_bytes=key_bytes, message=message, signature_bytes=sig_bytes, ) def check_timestamp_skew(client_ts: datetime) -> None: """Raise ValueError if client_ts is more than ±5 minutes from now. Prevents replay attacks using stale PROPOSE messages. """ now = datetime.now(tz=timezone.utc) if client_ts.tzinfo is None: client_ts = client_ts.replace(tzinfo=timezone.utc) skew = abs(now - client_ts) if skew > _MAX_SKEW: raise ValueError( f"Proposer timestamp is {skew.seconds}s from server time — max allowed is {int(_MAX_SKEW.total_seconds())}s" )