gabriel / musehub public
signing.py python
106 lines 3.4 KB
Raw
sha256:f99af7b1a7f36c4d537d1c630d4b71fc39222b1255f82e930929e2fc89015e11 fix: relax browse_repo perf budget to 500ms — 200ms was too… Sonnet 4.6 102 days ago
1 """Proposer signature over the canonical PROPOSE message.
2
3 Every merge proposal is optionally signed by the proposer using their
4 registered Ed25519 key. The signature binds their cryptographic identity
5 to the specific act of opening this proposal.
6
7 Canonical PROPOSE message (UTF-8, LF line endings):
8 PROPOSE\\n
9 repo_id: sha256:<hex>\\n
10 from_branch: <name>\\n
11 to_branch: <name>\\n
12 author: <handle>\\n
13 created_at: <ISO-8601 UTC with offset>
14
15 The client supplies ``proposerTimestamp`` (the ``created_at`` field it will
16 sign over). The server verifies the signature immediately on receipt using
17 the proposer's pre-image, then stores the signature alongside the assigned
18 ``proposal_id``. The timestamp must be within ±5 minutes of server time to
19 prevent replay attacks.
20 """
21 from __future__ import annotations
22
23 from datetime import datetime, timezone, timedelta
24
25 from muse.core.types import decode_pubkey, decode_sig
26 from musehub.crypto.keys import KeyAlgorithm, SignatureError, verify_signature
27
28
29 _DOMAIN_PREFIX = "PROPOSE"
30 _MAX_SKEW = timedelta(minutes=5)
31
32
33 def canonical_propose_message(
34 *,
35 repo_id: str,
36 from_branch: str,
37 to_branch: str,
38 author: str,
39 created_at: datetime,
40 proposal_id: str | None = None,
41 ) -> bytes:
42 """Return the canonical UTF-8 bytes that the proposer signs.
43
44 ``proposal_id`` is optional — omit it for pre-creation signing where the
45 server hasn't assigned an ID yet. When present it is included as the
46 second line for post-creation verification.
47 """
48 lines = [_DOMAIN_PREFIX]
49 if proposal_id is not None:
50 lines.append(f"proposal_id: {proposal_id}")
51 lines += [
52 f"repo_id: {repo_id}",
53 f"from_branch: {from_branch}",
54 f"to_branch: {to_branch}",
55 f"author: {author}",
56 f"created_at: {created_at.isoformat()}",
57 ]
58 return "\n".join(lines).encode("utf-8")
59
60
61 def verify_proposer_signature(
62 *,
63 message: bytes,
64 signature: str,
65 public_key: str,
66 ) -> None:
67 """Verify an Ed25519 proposer signature.
68
69 Args:
70 message: Canonical PROPOSE message bytes.
71 signature: ``ed25519:<base64url>`` signature string.
72 public_key: ``ed25519:<base64url>`` public key string.
73
74 Raises:
75 SignatureError: Signature is invalid, malformed, or wrong algorithm.
76 """
77 try:
78 sig_algo, sig_bytes = decode_sig(signature)
79 key_algo, key_bytes = decode_pubkey(public_key)
80 except ValueError as exc:
81 raise SignatureError(f"Malformed signature or public key: {exc}") from exc
82
83 if sig_algo != "ed25519" or key_algo != "ed25519":
84 raise SignatureError(f"Unsupported algorithm: sig={sig_algo} key={key_algo}")
85
86 verify_signature(
87 algorithm=KeyAlgorithm.ED25519,
88 public_key_bytes=key_bytes,
89 message=message,
90 signature_bytes=sig_bytes,
91 )
92
93
94 def check_timestamp_skew(client_ts: datetime) -> None:
95 """Raise ValueError if client_ts is more than ±5 minutes from now.
96
97 Prevents replay attacks using stale PROPOSE messages.
98 """
99 now = datetime.now(tz=timezone.utc)
100 if client_ts.tzinfo is None:
101 client_ts = client_ts.replace(tzinfo=timezone.utc)
102 skew = abs(now - client_ts)
103 if skew > _MAX_SKEW:
104 raise ValueError(
105 f"Proposer timestamp is {skew.seconds}s from server time — max allowed is {int(_MAX_SKEW.total_seconds())}s"
106 )
File History 1 commit
sha256:f99af7b1a7f36c4d537d1c630d4b71fc39222b1255f82e930929e2fc89015e11 fix: relax browse_repo perf budget to 500ms — 200ms was too… Sonnet 4.6 102 days ago