gabriel / muse public
msign.py python
468 lines 16.9 KB
Raw
sha256:c5131d76c6eada02939111fda4aa8e51b0c1456b9983727cfd6be101916de14e merge: pull local/dev — resolve trivial _EXT_MAP symbol con… Sonnet 4.6 patch 13 days ago
1 """muse.core.msign — canonical MSign signing primitives.
2
3 Single source of truth for MSign authentication across the muse ecosystem:
4 MuseHub (repo VCS), Stori (DAW session auth), Maestro (NL→MIDI pipeline),
5 and MPay (real-time crypto micropayments).
6
7 Wire format
8 -----------
9
10 Every authenticated HTTP request carries::
11
12 Authorization: MSign handle="<handle>" alg="ed25519" ts=<unix_ts> sig="<b64url_sig>"
13
14 Canonical message (the bytes Ed25519 signs)::
15
16 {algorithm}\\n{METHOD}\\n{host}\\n{path_with_query}\\n{ts}\\n{body_hash}
17
18 where:
19 - ``algorithm`` is the signing algorithm identifier (``"ed25519"``).
20 - ``METHOD`` is the HTTP verb in uppercase (``"POST"``, ``"GET"``, …).
21 - ``host`` is the lowercased hostname with non-standard port kept
22 (``"staging.musehub.ai"``, ``"localhost:1337"``); standard ports
23 (80 for http, 443 for https) are stripped.
24 - ``path_with_query`` is the URL path including ``?query`` if present.
25 - ``ts`` is a decimal integer Unix timestamp (seconds since epoch).
26 - ``body_hash`` is ``"sha256:" + sha256(body).hexdigest()``; empty body → ``"sha256:e3b0c442…"``.
27
28 Algorithm: Ed25519 (RFC 8032). Signatures are base64url-encoded with no
29 padding (``=`` stripped). Replay window: ±30 seconds from server time.
30
31 MPay extension
32 --------------
33
34 ``build_payment_claim()`` uses the same Ed25519 key with a domain-separated
35 canonical message::
36
37 MPAY\\nFROM\\nTO\\nAMOUNT_NANO\\nCURRENCY\\nNONCE_HEX\\nMEMO
38
39 The ``MPAY`` domain separator prevents cross-protocol signature confusion.
40 Payment claims can be chained (each ``nonce_hex`` is the SHA-256 of the
41 previous claim's signature), enabling real-time streaming micropayments that
42 batch-settle on Avalanche L1.
43
44 Public API
45 ----------
46
47 :func:`canonical_message` — build the bytes to sign for an HTTP request
48 :func:`build_msign_header` — produce ``Authorization: MSign …`` value
49 :func:`parse_msign_header` — parse a header value into its components
50 :func:`verify_msign_header` — verify a header against a known public key
51 :func:`build_payment_claim` — sign an MPay micropayment attestation
52 """
53
54 import re
55 import time
56 import urllib.parse
57 from typing import TYPE_CHECKING, Any, TypedDict
58
59 import hashlib as _hashlib
60
61 from muse.core.types import DEFAULT_SIGN_ALGO, b64url_decode, b64url_encode
62
63 if TYPE_CHECKING:
64 from muse.core.transport import SigningIdentity
65
66 # ---------------------------------------------------------------------------
67 # Constants
68 # ---------------------------------------------------------------------------
69
70 REPLAY_WINDOW_SECONDS: int = 30
71 """Maximum age (in seconds) of an MSign timestamp accepted by the server."""
72
73 # Standard ports whose inclusion in the host header is redundant.
74 _STANDARD_PORTS: dict[str, int] = {"https": 443, "http": 80}
75
76 # Regex for parsing Authorization header values.
77 _MSIGN_RE = re.compile(
78 r'MSign\s+'
79 r'handle="(?P<handle>[^"]+)"\s+'
80 r'alg="(?P<alg>[^"]+)"\s+'
81 r'ts=(?P<ts>\d+)\s+'
82 r'sig="(?P<sig>[A-Za-z0-9_=-]+)"'
83 )
84
85 # sha256:-prefixed raw hash of empty bytes — the "no body" sentinel.
86 EMPTY_BODY_HASH: str = "sha256:" + _hashlib.sha256(b"").hexdigest()
87
88 # ---------------------------------------------------------------------------
89 # TypedDicts for structured return values
90 # ---------------------------------------------------------------------------
91
92 class ParsedMSign(TypedDict):
93 """Parsed components of an MSign Authorization header value."""
94 handle: str
95 alg: str
96 ts: int
97 sig: str
98
99 class _PaymentClaimRequired(TypedDict):
100 """Required fields present on every MPay claim."""
101
102 from_handle: str
103 to_handle: str
104 amount_nano: int
105 currency: str
106 nonce_hex: str
107 memo: str
108 ts: int
109 signature_b64: str
110 canonical_message: str
111
112 class PaymentClaim(_PaymentClaimRequired, total=False):
113 """Signed MPay micropayment attestation.
114
115 The required fields (inherited from :class:`_PaymentClaimRequired`) are
116 always present. The optional AVAX dual-signature fields are populated
117 when a secp256k1 key is supplied to :func:`build_payment_claim`.
118
119 A claim that carries both an Ed25519 MSign signature **and** an EIP-191
120 secp256k1 signature can be verified entirely on Avalanche C-Chain via
121 ``ecrecover`` — enabling trust-minimised on-chain settlement without
122 any off-chain oracle.
123
124 Optional fields
125 ---------------
126 payer_avax_address:
127 EIP-55 checksummed AVAX C-Chain address of the payer (``0x…``),
128 derived from the secp256k1 key used to produce ``eth_sig``.
129 recipient_avax_address:
130 EIP-55 checksummed AVAX C-Chain address of the recipient (``0x…``),
131 supplied by the caller as the on-chain settlement target.
132 eth_sig:
133 Hex-encoded 65-byte EIP-191 ``personal_sign`` signature over the
134 same canonical MPAY message, produced with the payer's secp256k1
135 key. ``v`` is 27 or 28 (legacy Ethereum recovery ID convention).
136 Verifiable on EVM chains with ``ecrecover(keccak256(prefixed_msg), sig)``.
137 """
138
139 payer_avax_address: str
140 recipient_avax_address: str
141 eth_sig: str
142
143 # ---------------------------------------------------------------------------
144 # Internal helpers
145 # ---------------------------------------------------------------------------
146
147 def _normalise_host(parsed_url: urllib.parse.ParseResult) -> str:
148 """Return the canonical host string for inclusion in the signed payload.
149
150 Lowercased; standard ports stripped (:443 for https, :80 for http);
151 non-standard ports kept (localhost:1337).
152 """
153 host = (parsed_url.hostname or "").lower()
154 port = parsed_url.port
155 scheme = parsed_url.scheme.lower()
156 if port is not None and port != _STANDARD_PORTS.get(scheme):
157 return f"{host}:{port}"
158 return host
159
160 # ---------------------------------------------------------------------------
161 # Core primitives
162 # ---------------------------------------------------------------------------
163
164 def canonical_message(
165 method: str,
166 path_with_query: str,
167 ts: int,
168 body_bytes: bytes,
169 host: str,
170 algorithm: str = DEFAULT_SIGN_ALGO,
171 ) -> bytes:
172 """Return the bytes that Ed25519 signs for an MSign HTTP request.
173
174 Args:
175 method: HTTP verb in uppercase (``"POST"``, ``"GET"``, …).
176 path_with_query: URL path including ``?query`` string if present.
177 ts: Unix timestamp (integer seconds).
178 body_bytes: Raw request body (``b""`` for requests with no body).
179 host: Canonical host string (lowercased; standard ports
180 stripped). E.g. ``"staging.musehub.ai"`` or
181 ``"localhost:1337"``.
182 algorithm: Signing algorithm identifier (default ``"ed25519"``).
183
184 Returns:
185 UTF-8 encoded canonical message ready for signing.
186
187 Example::
188
189 msg = canonical_message(
190 "POST", "/gabriel/muse/push", 1744000000, b"",
191 host="staging.musehub.ai",
192 )
193 # b"ed25519\\nPOST\\nstaging.musehub.ai\\n/gabriel/muse/push\\n1744000000\\ne3b0c44..."
194 """
195 body_hash = "sha256:" + _hashlib.sha256(body_bytes).hexdigest()
196 return f"{algorithm}\n{method.upper()}\n{host}\n{path_with_query}\n{ts}\n{body_hash}".encode()
197
198 def build_msign_header(
199 signing: "SigningIdentity",
200 method: str,
201 url: str,
202 body_bytes: bytes | None = None,
203 *,
204 ts: int | None = None,
205 ) -> str:
206 """Return an ``Authorization: MSign …`` header value for a request.
207
208 Args:
209 signing: Ed25519 signing identity (handle + private key).
210 method: HTTP verb (``"POST"``, ``"GET"``, …).
211 url: Full request URL including scheme and host.
212 body_bytes: Raw request body bytes, or ``None`` for an empty body.
213 ts: Unix timestamp override. **Use only in tests** to produce
214 deterministic output. Defaults to ``int(time.time())``.
215
216 Returns:
217 Complete ``Authorization`` header value, e.g.::
218
219 MSign handle="gabriel" alg="ed25519" ts=1744000000 sig="aBcDeFg..."
220
221 Raises:
222 AssertionError: If ``signing.private_key`` is not an
223 :class:`~cryptography.hazmat.primitives.asymmetric.ed25519.Ed25519PrivateKey`.
224 """
225 from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
226
227 parsed = urllib.parse.urlparse(url)
228 path_with_query = parsed.path
229 if parsed.query:
230 path_with_query = f"{path_with_query}?{parsed.query}"
231 host = _normalise_host(parsed)
232
233 if ts is None:
234 ts = int(time.time())
235
236 body = body_bytes or b""
237 msg = canonical_message(method, path_with_query, ts, body, host=host)
238
239 private_key = signing.private_key
240 assert isinstance(private_key, Ed25519PrivateKey)
241 sig_bytes = private_key.sign(msg)
242 sig_b64 = b64url_encode(sig_bytes)
243
244 return f'MSign handle="{signing.handle}" alg="{DEFAULT_SIGN_ALGO}" ts={ts} sig="{sig_b64}"'
245
246 def parse_msign_header(header_value: str) -> ParsedMSign:
247 """Parse an MSign Authorization header value into its components.
248
249 Args:
250 header_value: Full ``Authorization`` header value, e.g.
251 ``MSign handle="gabriel" alg="ed25519" ts=1744000000 sig="aBcD..."``
252
253 Returns:
254 :class:`ParsedMSign` with ``handle`` (str), ``alg`` (str), ``ts`` (int),
255 ``sig`` (str, base64url).
256
257 Raises:
258 ValueError: If *header_value* does not match the MSign format.
259 """
260 m = _MSIGN_RE.search(header_value)
261 if not m:
262 raise ValueError(
263 f"Not a valid MSign header: {header_value!r}. "
264 'Expected: MSign handle="<handle>" alg="<alg>" ts=<unix> sig="<b64url>"'
265 )
266 return ParsedMSign(
267 handle=m.group("handle"),
268 alg=m.group("alg"),
269 ts=int(m.group("ts")),
270 sig=m.group("sig"),
271 )
272
273 def verify_msign_header(
274 header_value: str,
275 method: str,
276 url: str,
277 body_bytes: bytes | None,
278 public_key_b64: str,
279 *,
280 max_age: int = REPLAY_WINDOW_SECONDS,
281 now: int | None = None,
282 ) -> tuple[bool, str]:
283 """Verify an MSign Authorization header against a known Ed25519 public key.
284
285 Args:
286 header_value: The ``Authorization`` header value to verify.
287 method: HTTP verb used for the request.
288 url: Full request URL.
289 body_bytes: Raw request body bytes (``None`` for empty body).
290 public_key_b64: URL-safe base64 Ed25519 public key (no padding).
291 max_age: Maximum timestamp age in seconds (default
292 :data:`REPLAY_WINDOW_SECONDS` = 30).
293 now: Override current time (Unix seconds) for testing.
294
295 Returns:
296 ``(True, "ok")`` on success.
297 ``(False, reason)`` on failure, where *reason* is a human-readable
298 explanation of why verification failed.
299
300 Example::
301
302 ok, reason = verify_msign_header(
303 header, "POST", url, body, public_key_b64
304 )
305 if not ok:
306 raise HTTPException(401, reason)
307 """
308 from cryptography.exceptions import InvalidSignature
309 from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
310
311 # 1. Parse header.
312 try:
313 parsed = parse_msign_header(header_value)
314 except ValueError as exc:
315 return False, str(exc)
316
317 ts = parsed["ts"]
318 alg = parsed["alg"]
319
320 # 2. Replay-window check.
321 if now is None:
322 now = int(time.time())
323 age = abs(now - ts)
324 if age > max_age:
325 return False, (
326 f"timestamp {ts} is {age}s outside the ±{max_age}s replay window "
327 f"(server time={now})"
328 )
329
330 # 3. Reconstruct canonical message.
331 parsed_url = urllib.parse.urlparse(url)
332 path_with_query = parsed_url.path
333 if parsed_url.query:
334 path_with_query = f"{path_with_query}?{parsed_url.query}"
335 host = _normalise_host(parsed_url)
336 body = body_bytes or b""
337 msg = canonical_message(method, path_with_query, ts, body, host=host, algorithm=alg)
338
339 # 4. Decode public key.
340 try:
341 pub_bytes = b64url_decode(public_key_b64)
342 pub_key = Ed25519PublicKey.from_public_bytes(pub_bytes)
343 except Exception as exc:
344 return False, f"invalid public key: {exc}"
345
346 # 5. Decode and verify signature.
347 try:
348 sig_b64 = parsed["sig"]
349 sig_bytes = b64url_decode(sig_b64)
350 pub_key.verify(sig_bytes, msg)
351 except InvalidSignature:
352 return False, "Ed25519 signature verification failed"
353 except Exception as exc:
354 return False, f"signature decode error: {exc}"
355
356 return True, "ok"
357
358 # ---------------------------------------------------------------------------
359 # MPay micropayment primitives
360 # ---------------------------------------------------------------------------
361
362 def build_payment_claim(
363 signing: "SigningIdentity",
364 from_handle: str,
365 to_handle: str,
366 amount_nano: int,
367 currency: str,
368 nonce_hex: str,
369 memo: str,
370 *,
371 ts: int | None = None,
372 avax_private_key: "Any | None" = None,
373 recipient_avax_address: str | None = None,
374 ) -> PaymentClaim:
375 """Sign an MPay micropayment claim, optionally with AVAX dual-signature.
376
377 Uses the same Ed25519 key as MSign HTTP auth with a ``MPAY`` domain
378 separator to prevent cross-protocol signature confusion.
379
380 Canonical message::
381
382 MPAY\\nFROM\\nTO\\nAMOUNT_NANO\\nCURRENCY\\nNONCE_HEX\\nMEMO\\nTIMESTAMP
383
384 Chain linkage: set ``nonce_hex`` to ``sha256(prev_claim["signature_b64"])``
385 to create a tamper-evident payment chain settleable on Avalanche L1.
386
387 AVAX dual-signature (``avax_private_key``)
388 -------------------------------------------
389 When *avax_private_key* is supplied, the **same canonical MPAY message** is
390 also signed with EIP-191 ``personal_sign`` using the caller's secp256k1 key.
391 The resulting 65-byte signature (``r || s || v``, ``v`` = 27 or 28) is stored
392 in ``eth_sig`` as a lowercase hex string. The payer's AVAX C-Chain address
393 is derived from the public key and stored in ``payer_avax_address``.
394
395 On-chain verification (Solidity sketch)::
396
397 bytes32 digest = keccak256(abi.encodePacked(
398 "\\x19Ethereum Signed Message:\\n",
399 Strings.toString(canonicalBytes.length),
400 canonicalBytes
401 ));
402 address recovered = ecrecover(digest, v, r, s);
403 require(recovered == claim.payerAvaxAddress, "bad sig");
404
405 Args:
406 signing: Ed25519 signing identity.
407 from_handle: Sender's hub handle.
408 to_handle: Recipient's hub handle.
409 amount_nano: Payment amount in nanoMUSE
410 (1 MUSE = 1_000_000_000 nanoMUSE).
411 currency: Currency identifier (``"nanoMUSE"``, ``"nanoETH"``, …).
412 nonce_hex: Hex nonce — use ``sha256(prev_sig_b64)`` for chain
413 linkage, or a fresh random 32-byte hex for the
414 first payment.
415 memo: Free-form memo string (e.g. ``"stem:sha256:abc123"``).
416 ts: Unix timestamp override (testing only).
417 avax_private_key: Optional ``eth_keys.PrivateKey`` for AVAX C-Chain
418 dual-signing. Obtain via
419 :func:`~muse.core.secp256k1_sign.derive_avax_key`.
420 When ``None``, the AVAX fields are omitted.
421 recipient_avax_address: EIP-55 checksummed AVAX C-Chain address of the
422 recipient. Stored verbatim — not derived from any
423 key. Pass when the recipient's on-chain address is
424 known at call time.
425
426 Returns:
427 :class:`PaymentClaim` with all required fields. Optional AVAX fields
428 (``payer_avax_address``, ``eth_sig``, ``recipient_avax_address``) are
429 present only when the corresponding arguments are supplied.
430 """
431 from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
432
433 if ts is None:
434 ts = int(time.time())
435
436 canonical = (
437 f"MPAY\n{from_handle}\n{to_handle}\n{amount_nano}\n"
438 f"{currency}\n{nonce_hex}\n{memo}\n{ts}"
439 ).encode()
440
441 private_key = signing.private_key
442 assert isinstance(private_key, Ed25519PrivateKey)
443 sig_bytes = private_key.sign(canonical)
444 sig_b64 = b64url_encode(sig_bytes)
445
446 claim = PaymentClaim(
447 from_handle=from_handle,
448 to_handle=to_handle,
449 amount_nano=amount_nano,
450 currency=currency,
451 nonce_hex=nonce_hex,
452 memo=memo,
453 ts=ts,
454 signature_b64=sig_b64,
455 canonical_message=canonical.decode(),
456 )
457
458 if avax_private_key is not None:
459 from muse.core.secp256k1_sign import avax_c_chain_address, eip191_sign
460
461 eth_sig_bytes = eip191_sign(avax_private_key, canonical)
462 claim["payer_avax_address"] = avax_c_chain_address(avax_private_key.public_key)
463 claim["eth_sig"] = eth_sig_bytes.hex()
464
465 if recipient_avax_address is not None:
466 claim["recipient_avax_address"] = recipient_avax_address
467
468 return claim
File History 5 commits
sha256:c5131d76c6eada02939111fda4aa8e51b0c1456b9983727cfd6be101916de14e merge: pull local/dev — resolve trivial _EXT_MAP symbol con… Sonnet 4.6 patch 13 days ago
sha256:9c33d61749fff814c5226d5386aa2af7064c2c02788594a25fdd709358132eea fix: _PROPOSAL_PREFIX_RESOLVE_LIMIT 200 → 100 to match hub … Sonnet 4.6 20 days ago
sha256:36c3cb3e76619d4c30a6d9bf81b5ec4ff148e30dcfed913e3114ca7b43b81c7e fix: rename objects→blobs in push client and all stale test… Sonnet 4.6 patch 22 days ago
sha256:c06a9b9b9fee26c68ea725b44d54b2c0a171301ce9de746d5b656617b4463a9a fix: repair four test failures from post-migration audit Sonnet 4.6 patch 29 days ago
sha256:1900655993c83c4107067375548a7be823e471d2515830842f1a12cba4bd3cdf fix: unified object store migration — idempotent writes, JS… Sonnet 4.6 minor 29 days ago