sign.py
python
sha256:08c083095bcaffb4c43ce947668fac93bf5d261e13d321776170c4019dd1d77d
fix: migration must never update identity.toml on a failed …
Sonnet 5
minor
⚠ breaking
2 days ago
| 1 | """muse sign — MSign request signing for humans, agents, and scripts. |
| 2 | |
| 3 | Makes MSign a first-class tool rather than a library implementation detail. |
| 4 | Every authenticated call to MuseHub, Stori, Maestro, or MPay nodes can be |
| 5 | signed, verified, and debugged without implementing the protocol by hand. |
| 6 | |
| 7 | Subcommands |
| 8 | ----------- |
| 9 | |
| 10 | :: |
| 11 | |
| 12 | muse sign header --method POST --path /owner/repo/push --hub URL |
| 13 | muse sign verify --header 'MSign ...' --method POST --url URL [--body-file F] |
| 14 | muse sign request --method POST --url URL [--body-file F | --body STR] |
| 15 | muse sign curl --method POST --url URL [--body-file F | --body STR] |
| 16 | muse sign payment --from HANDLE --to HANDLE --amount N --nonce HEX |
| 17 | |
| 18 | Identity resolution (for ``header``, ``request``, ``curl``, ``payment``) |
| 19 | -------------------------------------------------------------------------- |
| 20 | |
| 21 | 1. ``MUSE_AGENT_KEY_FD`` env var — file descriptor carrying 64-byte sub-seed |
| 22 | (the only supported env-based injection; secret travels through kernel |
| 23 | pipe buffer, never visible in ``/proc/<pid>/environ``). |
| 24 | 2. ``--agent-id ID`` → ``~/.muse/identity.toml`` agent entry. |
| 25 | 3. ``~/.muse/identity.toml`` human entry keyed by hub hostname. |
| 26 | 4. Hub URL from ``.muse/config.toml`` in the current repository. |
| 27 | |
| 28 | JSON schemas |
| 29 | ----------- |
| 30 | |
| 31 | ``muse sign header --json``:: |
| 32 | |
| 33 | { |
| 34 | "handle": "<hub handle>", |
| 35 | "hub": "<hub url>", |
| 36 | "method": "<HTTP verb>", |
| 37 | "path": "<URL path+query>", |
| 38 | "timestamp": <unix int>, |
| 39 | "body_sha256": "sha256:<hex>", |
| 40 | "signature_b64": "<base64url>", |
| 41 | "header_value": "MSign handle=... ts=... sig=...", |
| 42 | "algorithm": "ed25519", |
| 43 | "fingerprint": "<sha256hex of public key>", |
| 44 | "duration_ms": 0.3, |
| 45 | "exit_code": 0 |
| 46 | } |
| 47 | |
| 48 | ``muse sign verify --json``:: |
| 49 | |
| 50 | {"valid": true|false, "reason": "<ok | why it failed>", "duration_ms": 0.3, "exit_code": 0} |
| 51 | |
| 52 | ``muse sign request --json``:: |
| 53 | |
| 54 | { |
| 55 | "ok": true|false, |
| 56 | "header_value": "MSign ...", "response": <decoded body>, |
| 57 | "duration_ms": 12.1, "exit_code": 0 |
| 58 | } |
| 59 | |
| 60 | ``muse sign payment --json``:: |
| 61 | |
| 62 | { |
| 63 | "from_handle": "...", "to_handle": "...", "amount_nano": N, |
| 64 | "currency": "...", "nonce_hex": "...", "memo": "...", |
| 65 | "ts": <unix>, "signature_b64": "...", "canonical_message": "...", |
| 66 | "duration_ms": 0.5, "exit_code": 0 |
| 67 | } |
| 68 | |
| 69 | Security |
| 70 | -------- |
| 71 | |
| 72 | - ``sign request`` refuses non-HTTPS URLs when credentials are present |
| 73 | (same gate as :class:`~muse.core.transport.HttpTransport`). |
| 74 | - ``--timestamp`` override is for **testing only** — any production caller |
| 75 | omitting it gets ``int(time.time())`` at signing time. |
| 76 | - The private key is never logged or printed. |
| 77 | """ |
| 78 | |
| 79 | import argparse |
| 80 | import json |
| 81 | import pathlib |
| 82 | import sys |
| 83 | import time |
| 84 | from datetime import datetime, timezone |
| 85 | from muse.core.types import DEFAULT_SIGN_ALGO, b64url_encode, blob_id, encode_pubkey, encode_sig, public_key_fingerprint |
| 86 | from muse.core.envelope import EnvelopeJson, JsonValue, make_envelope |
| 87 | from muse.core.timing import start_timer |
| 88 | from typing import TYPE_CHECKING, TypedDict |
| 89 | |
| 90 | if TYPE_CHECKING: |
| 91 | from muse.core.transport import SigningIdentity |
| 92 | |
| 93 | # --------------------------------------------------------------------------- |
| 94 | # Internal helpers |
| 95 | # --------------------------------------------------------------------------- |
| 96 | |
| 97 | def _load_signing( |
| 98 | hub: str | None, |
| 99 | agent_id: str | None = None, |
| 100 | ) -> "SigningIdentity": |
| 101 | """Resolve a signing identity or exit with a clear error.""" |
| 102 | from muse.cli.config import get_signing_identity |
| 103 | signing = get_signing_identity(remote_url=hub, agent_id=agent_id) |
| 104 | if signing is None: |
| 105 | hub_hint = hub or "the configured hub" |
| 106 | print( |
| 107 | f"❌ No signing identity found for {hub_hint}.\n" |
| 108 | " Run: muse auth keygen && muse auth register", |
| 109 | file=sys.stderr, |
| 110 | ) |
| 111 | raise SystemExit(1) |
| 112 | return signing |
| 113 | |
| 114 | def _read_body(args: argparse.Namespace) -> bytes: |
| 115 | """Read body bytes from --body-file or --body flag.""" |
| 116 | body_file: str | None = getattr(args, "body_file", None) |
| 117 | body_str: str | None = getattr(args, "body", None) |
| 118 | |
| 119 | if body_file: |
| 120 | if body_file == "-": |
| 121 | return sys.stdin.buffer.read() |
| 122 | return pathlib.Path(body_file).read_bytes() |
| 123 | if body_str: |
| 124 | return body_str.encode() |
| 125 | return b"" |
| 126 | |
| 127 | def _public_key_info(signing: "SigningIdentity") -> tuple[str, str]: |
| 128 | """Return (public_key_b64, fingerprint) for a signing identity.""" |
| 129 | pub_raw = signing.private_key.public_key().public_bytes_raw() |
| 130 | pub_b64 = b64url_encode(pub_raw) |
| 131 | fingerprint = public_key_fingerprint(pub_raw) |
| 132 | return pub_b64, fingerprint |
| 133 | |
| 134 | # --------------------------------------------------------------------------- |
| 135 | # JSON wire format |
| 136 | # --------------------------------------------------------------------------- |
| 137 | |
| 138 | class _SignHeaderJson(EnvelopeJson): |
| 139 | """JSON envelope for ``muse sign header --json``.""" |
| 140 | |
| 141 | handle: str |
| 142 | hub: str |
| 143 | method: str |
| 144 | path: str |
| 145 | signing_ts: int |
| 146 | body_sha256: str |
| 147 | signature_b64: str |
| 148 | header_value: str |
| 149 | algorithm: str |
| 150 | fingerprint: str |
| 151 | |
| 152 | class _SignVerifyJson(EnvelopeJson): |
| 153 | """JSON envelope for ``muse sign verify --json``.""" |
| 154 | |
| 155 | valid: bool |
| 156 | reason: str |
| 157 | |
| 158 | class _SignRequestJson(EnvelopeJson, total=False): |
| 159 | """JSON envelope for ``muse sign request --json``.""" |
| 160 | |
| 161 | ok: bool |
| 162 | header_value: str |
| 163 | response: JsonValue |
| 164 | |
| 165 | class _SignPaymentJson(EnvelopeJson, total=False): |
| 166 | """JSON envelope for ``muse sign payment --json``.""" |
| 167 | |
| 168 | from_handle: str |
| 169 | to_handle: str |
| 170 | amount_nano: int |
| 171 | currency: str |
| 172 | nonce_hex: str |
| 173 | memo: str |
| 174 | ts: int |
| 175 | signature_b64: str |
| 176 | canonical_message: str |
| 177 | |
| 178 | # --------------------------------------------------------------------------- |
| 179 | # Subcommand: header |
| 180 | # --------------------------------------------------------------------------- |
| 181 | |
| 182 | def run_header(args: argparse.Namespace) -> None: |
| 183 | """Emit an ``Authorization: MSign …`` header value. |
| 184 | |
| 185 | Computes the MSign Authorization header for a request and prints it to |
| 186 | stdout. With ``--json`` emits structured metadata useful for debugging |
| 187 | or pipeline consumption. |
| 188 | |
| 189 | Agent quickstart:: |
| 190 | |
| 191 | muse sign header --method POST --path /gabriel/muse/push --hub https://staging.musehub.ai --json |
| 192 | muse sign header --method GET --url https://staging.musehub.ai/gabriel/muse/repo --json |
| 193 | |
| 194 | JSON fields:: |
| 195 | |
| 196 | handle str Hub handle of the signing identity |
| 197 | hub str Hub URL the key is registered with |
| 198 | method str HTTP verb (uppercased) |
| 199 | path str URL path + query string |
| 200 | timestamp int Unix timestamp embedded in the signature |
| 201 | body_sha256 str ``sha256:``-prefixed SHA-256 of the request body |
| 202 | signature_b64 str URL-safe base64 Ed25519 signature |
| 203 | header_value str Full "MSign handle=... ts=... sig=..." value |
| 204 | algorithm str Always "ed25519" |
| 205 | fingerprint str Hex SHA-256 of the signing public key |
| 206 | |
| 207 | Exit codes:: |
| 208 | |
| 209 | 0 Success. |
| 210 | 1 No signing identity found. |
| 211 | """ |
| 212 | from muse.core.msign import build_msign_header |
| 213 | import urllib.parse |
| 214 | |
| 215 | elapsed = start_timer() |
| 216 | |
| 217 | signing = _load_signing( |
| 218 | getattr(args, "hub", None), |
| 219 | getattr(args, "agent_id", None), |
| 220 | ) |
| 221 | body = _read_body(args) |
| 222 | method = args.method.upper() |
| 223 | ts: int | None = getattr(args, "timestamp", None) |
| 224 | |
| 225 | # Resolve path vs full URL. |
| 226 | path: str = getattr(args, "path", None) or "" |
| 227 | hub: str = getattr(args, "hub", None) or "" |
| 228 | if path and hub: |
| 229 | url = f"{hub.rstrip('/')}/{path.lstrip('/')}" |
| 230 | elif path: |
| 231 | url = f"http://localhost{'' if path.startswith('/') else '/'}{path}" |
| 232 | else: |
| 233 | url = args.url if hasattr(args, "url") else "" |
| 234 | |
| 235 | header_value = build_msign_header(signing, method, url, body, ts=ts) |
| 236 | |
| 237 | if getattr(args, "json_out", False): |
| 238 | import urllib.parse as _up |
| 239 | parsed = _up.urlparse(url) |
| 240 | path_with_query = parsed.path + (f"?{parsed.query}" if parsed.query else "") |
| 241 | _ts = int(header_value.split("ts=")[1].split(" ")[0]) |
| 242 | body_sha256 = blob_id(body) |
| 243 | _, fingerprint = _public_key_info(signing) |
| 244 | sig_b64 = header_value.split('sig="')[1].rstrip('"') |
| 245 | print(json.dumps(_SignHeaderJson( |
| 246 | **make_envelope(elapsed), |
| 247 | handle=signing.handle, |
| 248 | hub=hub or f"{parsed.scheme}://{parsed.netloc}", |
| 249 | method=method, |
| 250 | path=path_with_query, |
| 251 | signing_ts=_ts, |
| 252 | body_sha256=body_sha256, |
| 253 | signature_b64=sig_b64, |
| 254 | header_value=header_value, |
| 255 | algorithm=DEFAULT_SIGN_ALGO, |
| 256 | fingerprint=fingerprint, |
| 257 | ))) |
| 258 | else: |
| 259 | print(header_value) |
| 260 | |
| 261 | # --------------------------------------------------------------------------- |
| 262 | # Subcommand: verify |
| 263 | # --------------------------------------------------------------------------- |
| 264 | |
| 265 | def run_verify(args: argparse.Namespace) -> None: |
| 266 | """Verify an MSign Authorization header value. |
| 267 | |
| 268 | Checks the signature, timestamp freshness, and canonical message against |
| 269 | the supplied public key. Exits 0 if valid, 1 if not. |
| 270 | |
| 271 | Agent quickstart:: |
| 272 | |
| 273 | muse sign verify --header 'MSign handle=...' --method POST --url URL --public-key-b64 KEY --json |
| 274 | |
| 275 | JSON fields:: |
| 276 | |
| 277 | valid bool True if the signature is valid and within max_age |
| 278 | reason str "ok" on success, or a description of the failure |
| 279 | |
| 280 | Exit codes:: |
| 281 | |
| 282 | 0 Valid signature. |
| 283 | 1 Invalid signature, expired timestamp, or key mismatch. |
| 284 | """ |
| 285 | from muse.core.msign import verify_msign_header |
| 286 | |
| 287 | elapsed = start_timer() |
| 288 | |
| 289 | body = _read_body(args) |
| 290 | method = args.method.upper() |
| 291 | url: str = args.url |
| 292 | pub_b64: str = args.public_key_b64 |
| 293 | header_value: str = args.header |
| 294 | max_age: int = getattr(args, "max_age", 30) |
| 295 | |
| 296 | ok, reason = verify_msign_header( |
| 297 | header_value, method, url, body, pub_b64, max_age=max_age |
| 298 | ) |
| 299 | |
| 300 | exit_code = 0 if ok else 1 |
| 301 | as_json = getattr(args, "json_out", False) |
| 302 | if as_json: |
| 303 | print(json.dumps(_SignVerifyJson( |
| 304 | **make_envelope(elapsed, exit_code=exit_code), |
| 305 | valid=ok, |
| 306 | reason=reason, |
| 307 | ))) |
| 308 | else: |
| 309 | if ok: |
| 310 | print("✅ Valid", file=sys.stderr) |
| 311 | else: |
| 312 | print(f"❌ Invalid: {reason}", file=sys.stderr) |
| 313 | |
| 314 | if not ok: |
| 315 | raise SystemExit(exit_code) |
| 316 | |
| 317 | # --------------------------------------------------------------------------- |
| 318 | # Subcommand: request |
| 319 | # --------------------------------------------------------------------------- |
| 320 | |
| 321 | def run_request(args: argparse.Namespace) -> None: |
| 322 | """Sign and execute an HTTP request, printing the response. |
| 323 | |
| 324 | Signs the request with MSign and executes it via |
| 325 | :class:`~muse.core.transport.HttpTransport`. The response body is |
| 326 | decoded as JSON or raw text in that order. |
| 327 | |
| 328 | Agent quickstart:: |
| 329 | |
| 330 | muse sign request --method POST --url https://staging.musehub.ai/gabriel/muse/repair-object --body-file payload.json --json |
| 331 | muse sign request --method GET --url https://staging.musehub.ai/gabriel/muse/repo --json |
| 332 | |
| 333 | JSON fields:: |
| 334 | |
| 335 | ok bool True when the request completed |
| 336 | header_value str The MSign Authorization header that was sent |
| 337 | response any Decoded response body (JSON object, list, or string) |
| 338 | |
| 339 | Exit codes:: |
| 340 | |
| 341 | 0 Request completed (check response for API-level errors). |
| 342 | 1 No signing identity found. |
| 343 | 2 Network or transport error. |
| 344 | """ |
| 345 | from muse.core.msign import build_msign_header |
| 346 | from muse.core.transport import HttpTransport, TransportError |
| 347 | |
| 348 | elapsed = start_timer() |
| 349 | |
| 350 | hub: str | None = getattr(args, "hub", None) |
| 351 | url: str = args.url |
| 352 | method: str = args.method.upper() |
| 353 | content_type: str = getattr(args, "content_type", "application/json") |
| 354 | as_json = getattr(args, "json_out", False) |
| 355 | ts: int | None = getattr(args, "timestamp", None) |
| 356 | |
| 357 | signing = _load_signing( |
| 358 | hub or url, |
| 359 | getattr(args, "agent_id", None), |
| 360 | ) |
| 361 | body = _read_body(args) |
| 362 | header_value = build_msign_header(signing, method, url, body, ts=ts) |
| 363 | |
| 364 | transport = HttpTransport() |
| 365 | req = transport._build_request(method, url, signing, body or None, content_type) |
| 366 | |
| 367 | try: |
| 368 | raw = transport._execute(req) |
| 369 | except TransportError as exc: |
| 370 | print(f"❌ Request failed: {exc}", file=sys.stderr) |
| 371 | raise SystemExit(2) |
| 372 | |
| 373 | # Decode response: JSON first, then raw text. |
| 374 | response_data = None |
| 375 | try: |
| 376 | response_data = json.loads(raw) |
| 377 | except Exception: |
| 378 | response_data = raw.decode(errors="replace") |
| 379 | |
| 380 | if as_json: |
| 381 | print(json.dumps(_SignRequestJson( |
| 382 | **make_envelope(elapsed), |
| 383 | ok=True, |
| 384 | header_value=header_value, |
| 385 | response=response_data, |
| 386 | ), default=str)) |
| 387 | else: |
| 388 | if isinstance(response_data, (dict, list)): |
| 389 | print(json.dumps(response_data, default=str)) |
| 390 | else: |
| 391 | print(response_data) |
| 392 | |
| 393 | # --------------------------------------------------------------------------- |
| 394 | # Subcommand: curl |
| 395 | # --------------------------------------------------------------------------- |
| 396 | |
| 397 | def run_curl(args: argparse.Namespace) -> None: |
| 398 | """Print a signed curl command ready to copy-paste or pipe to bash. |
| 399 | |
| 400 | Emits a multi-line curl invocation with the ``Authorization: MSign ...`` |
| 401 | header pre-computed. Suitable for direct execution via ``| bash`` or |
| 402 | inclusion in deployment scripts. |
| 403 | |
| 404 | Agent quickstart:: |
| 405 | |
| 406 | muse sign curl --method POST --url https://staging.musehub.ai/gabriel/muse/repair-object --body-file payload.json |
| 407 | muse sign curl --method GET --url https://staging.musehub.ai/gabriel/muse/repo | bash |
| 408 | |
| 409 | JSON fields:: |
| 410 | |
| 411 | (no --json output; curl command is always emitted as plain text) |
| 412 | |
| 413 | Exit codes:: |
| 414 | |
| 415 | 0 Success. |
| 416 | 1 No signing identity found. |
| 417 | """ |
| 418 | from muse.core.msign import build_msign_header |
| 419 | |
| 420 | hub: str | None = getattr(args, "hub", None) |
| 421 | url: str = args.url |
| 422 | method: str = args.method.upper() |
| 423 | content_type: str = getattr(args, "content_type", "application/json") |
| 424 | body_file: str | None = getattr(args, "body_file", None) |
| 425 | body_str: str | None = getattr(args, "body", None) |
| 426 | ts: int | None = getattr(args, "timestamp", None) |
| 427 | |
| 428 | signing = _load_signing( |
| 429 | hub or url, |
| 430 | getattr(args, "agent_id", None), |
| 431 | ) |
| 432 | body = _read_body(args) |
| 433 | header_value = build_msign_header(signing, method, url, body, ts=ts) |
| 434 | |
| 435 | lines = [f"curl -X {method}"] |
| 436 | lines.append(f" -H 'Authorization: {header_value}'") |
| 437 | if body: |
| 438 | lines.append(f" -H 'Content-Type: {content_type}'") |
| 439 | if body_file and body_file != "-": |
| 440 | lines.append(f" --data-binary @{body_file}") |
| 441 | elif body_str: |
| 442 | safe = body_str.replace("'", "'\\''") |
| 443 | lines.append(f" --data '{safe}'") |
| 444 | elif body and not body_file: |
| 445 | lines.append(f" --data-binary -") |
| 446 | lines.append(f" {url}") |
| 447 | |
| 448 | print(" \\\n".join(lines)) |
| 449 | |
| 450 | # --------------------------------------------------------------------------- |
| 451 | # Subcommand: payment |
| 452 | # --------------------------------------------------------------------------- |
| 453 | |
| 454 | def run_payment(args: argparse.Namespace) -> None: |
| 455 | """Sign an MPay micropayment claim. |
| 456 | |
| 457 | Produces a signed micropayment claim for the MPay streaming protocol. |
| 458 | The canonical message is Ed25519-signed, and the nonce can be chained |
| 459 | to the previous signature to form a tamper-evident payment chain. |
| 460 | |
| 461 | Agent quickstart:: |
| 462 | |
| 463 | muse sign payment --from alice --to bob --amount 1000000 --nonce HEXNONCE --json |
| 464 | muse sign payment --from alice --to bob --amount 500 --nonce HEXNONCE --memo "stem:sha256:abc" --json |
| 465 | |
| 466 | JSON fields:: |
| 467 | |
| 468 | from_handle str Sender hub handle |
| 469 | to_handle str Recipient hub handle |
| 470 | amount_nano int Amount in nanoMUSE |
| 471 | currency str Currency identifier (default: nanoMUSE) |
| 472 | nonce_hex str 64-char hex nonce |
| 473 | memo str Free-form memo |
| 474 | ts int Unix timestamp |
| 475 | signature_b64 str URL-safe base64 Ed25519 signature |
| 476 | canonical_message str The signed canonical message string |
| 477 | |
| 478 | Exit codes:: |
| 479 | |
| 480 | 0 Success. |
| 481 | 1 No signing identity found. |
| 482 | """ |
| 483 | from muse.core.msign import build_payment_claim |
| 484 | |
| 485 | elapsed = start_timer() |
| 486 | |
| 487 | hub: str | None = getattr(args, "hub", None) |
| 488 | as_json = getattr(args, "json_out", False) |
| 489 | ts: int | None = getattr(args, "timestamp", None) |
| 490 | |
| 491 | signing = _load_signing( |
| 492 | hub, |
| 493 | getattr(args, "agent_id", None), |
| 494 | ) |
| 495 | |
| 496 | claim = build_payment_claim( |
| 497 | signing, |
| 498 | from_handle=args.from_handle, |
| 499 | to_handle=args.to_handle, |
| 500 | amount_nano=args.amount, |
| 501 | currency=getattr(args, "currency", "nanoMUSE"), |
| 502 | nonce_hex=args.nonce, |
| 503 | memo=getattr(args, "memo", ""), |
| 504 | ts=ts, |
| 505 | ) |
| 506 | |
| 507 | if as_json: |
| 508 | print(json.dumps(_SignPaymentJson( |
| 509 | **make_envelope(elapsed), |
| 510 | **claim, |
| 511 | ))) |
| 512 | else: |
| 513 | print(f"From: {claim['from_handle']}", file=sys.stderr) |
| 514 | print(f"To: {claim['to_handle']}", file=sys.stderr) |
| 515 | print(f"Amount: {claim['amount_nano']} {claim['currency']}", file=sys.stderr) |
| 516 | print(f"Nonce: {claim['nonce_hex']}", file=sys.stderr) |
| 517 | print(f"Timestamp: {claim['ts']}", file=sys.stderr) |
| 518 | print(f"Signature: {claim['signature_b64']}", file=sys.stderr) |
| 519 | |
| 520 | # --------------------------------------------------------------------------- |
| 521 | # Subcommand: propose |
| 522 | # --------------------------------------------------------------------------- |
| 523 | |
| 524 | def canonical_propose_message( |
| 525 | *, |
| 526 | repo_id: str, |
| 527 | from_branch: str, |
| 528 | to_branch: str, |
| 529 | author: str, |
| 530 | created_at: datetime, |
| 531 | proposal_id: str | None = None, |
| 532 | ) -> bytes: |
| 533 | """Return the canonical UTF-8 bytes that a proposer signs over. |
| 534 | |
| 535 | Used by ``muse sign propose`` and by the MuseHub server to verify. |
| 536 | The format is intentionally minimal and line-oriented so it is trivial |
| 537 | to reconstruct in any language. |
| 538 | |
| 539 | Canonical PROPOSE message (UTF-8, LF line endings):: |
| 540 | |
| 541 | PROPOSE |
| 542 | [proposal_id: sha256:<hex>] ← only when proposal_id is given |
| 543 | repo_id: sha256:<hex> |
| 544 | from_branch: <name> |
| 545 | to_branch: <name> |
| 546 | author: <handle> |
| 547 | created_at: <ISO-8601 UTC with offset> |
| 548 | """ |
| 549 | lines = ["PROPOSE"] |
| 550 | if proposal_id is not None: |
| 551 | lines.append(f"proposal_id: {proposal_id}") |
| 552 | lines += [ |
| 553 | f"repo_id: {repo_id}", |
| 554 | f"from_branch: {from_branch}", |
| 555 | f"to_branch: {to_branch}", |
| 556 | f"author: {author}", |
| 557 | f"created_at: {created_at.isoformat()}", |
| 558 | ] |
| 559 | return "\n".join(lines).encode("utf-8") |
| 560 | |
| 561 | |
| 562 | class _SignProposeJson(EnvelopeJson): |
| 563 | """JSON envelope for ``muse sign propose --json``.""" |
| 564 | |
| 565 | handle: str |
| 566 | hub: str |
| 567 | repo_id: str |
| 568 | from_branch: str |
| 569 | to_branch: str |
| 570 | author: str |
| 571 | proposer_timestamp: str |
| 572 | proposer_public_key: str |
| 573 | proposer_signature: str |
| 574 | canonical_message: str |
| 575 | |
| 576 | |
| 577 | def run_propose(args: argparse.Namespace) -> None: |
| 578 | """Sign a canonical PROPOSE message ready for ``muse hub proposal create``. |
| 579 | |
| 580 | Loads the signing identity for the target hub, builds the canonical |
| 581 | PROPOSE pre-image, signs it with Ed25519, and emits the three fields |
| 582 | needed by the MuseHub API: ``proposerPublicKey``, |
| 583 | ``proposerSignature``, and ``proposerTimestamp``. |
| 584 | |
| 585 | Agent quickstart:: |
| 586 | |
| 587 | muse sign propose \\ |
| 588 | --repo-id sha256:<hex> \\ |
| 589 | --from-branch feat/my-thing \\ |
| 590 | --to-branch dev \\ |
| 591 | --hub https://localhost:1337 --json |
| 592 | |
| 593 | JSON fields:: |
| 594 | |
| 595 | handle str Hub handle of the signing identity |
| 596 | hub str Hub URL |
| 597 | repo_id str sha256-prefixed repo ID |
| 598 | from_branch str Source branch name |
| 599 | to_branch str Target branch name |
| 600 | author str Proposer's hub handle (same as handle) |
| 601 | proposer_timestamp str ISO-8601 UTC timestamp embedded in the message |
| 602 | proposer_public_key str ``ed25519:<base64url>`` public key |
| 603 | proposer_signature str ``ed25519:<base64url>`` signature |
| 604 | canonical_message str The exact message bytes that were signed |
| 605 | |
| 606 | Exit codes:: |
| 607 | |
| 608 | 0 Success. |
| 609 | 1 No signing identity found. |
| 610 | """ |
| 611 | elapsed = start_timer() |
| 612 | |
| 613 | hub: str | None = getattr(args, "hub", None) |
| 614 | signing = _load_signing(hub, getattr(args, "agent_id", None)) |
| 615 | |
| 616 | repo_id: str = args.repo_id |
| 617 | from_branch: str = args.from_branch |
| 618 | to_branch: str = args.to_branch |
| 619 | author: str = signing.handle |
| 620 | |
| 621 | # Use a caller-supplied timestamp if provided (for testing), otherwise now. |
| 622 | ts_override: int | None = getattr(args, "timestamp", None) |
| 623 | if ts_override is not None: |
| 624 | created_at = datetime.fromtimestamp(ts_override, tz=timezone.utc) |
| 625 | else: |
| 626 | created_at = datetime.now(tz=timezone.utc) |
| 627 | proposer_timestamp = created_at.isoformat() |
| 628 | |
| 629 | message = canonical_propose_message( |
| 630 | repo_id=repo_id, |
| 631 | from_branch=from_branch, |
| 632 | to_branch=to_branch, |
| 633 | author=author, |
| 634 | created_at=created_at, |
| 635 | ) |
| 636 | |
| 637 | # Sign |
| 638 | sig_bytes = signing.private_key.sign(message) |
| 639 | pub_bytes = signing.private_key.public_key().public_bytes_raw() |
| 640 | proposer_signature = encode_sig("ed25519", sig_bytes) |
| 641 | proposer_public_key = encode_pubkey("ed25519", pub_bytes) |
| 642 | |
| 643 | as_json = getattr(args, "json_out", False) |
| 644 | if as_json: |
| 645 | print(json.dumps(_SignProposeJson( |
| 646 | **make_envelope(elapsed), |
| 647 | handle=signing.handle, |
| 648 | hub=hub or "", |
| 649 | repo_id=repo_id, |
| 650 | from_branch=from_branch, |
| 651 | to_branch=to_branch, |
| 652 | author=author, |
| 653 | proposer_timestamp=proposer_timestamp, |
| 654 | proposer_public_key=proposer_public_key, |
| 655 | proposer_signature=proposer_signature, |
| 656 | canonical_message=message.decode("utf-8"), |
| 657 | ))) |
| 658 | else: |
| 659 | print(f"PROPOSER: @{author}", file=sys.stderr) |
| 660 | print(f"repo_id: {repo_id}", file=sys.stderr) |
| 661 | print(f"from_branch: {from_branch}", file=sys.stderr) |
| 662 | print(f"to_branch: {to_branch}", file=sys.stderr) |
| 663 | print(f"proposer_timestamp: {proposer_timestamp}", file=sys.stderr) |
| 664 | print(f"proposer_public_key: {proposer_public_key}", file=sys.stderr) |
| 665 | print(f"proposer_signature: {proposer_signature}", file=sys.stderr) |
| 666 | |
| 667 | |
| 668 | # --------------------------------------------------------------------------- |
| 669 | # argparse registration |
| 670 | # --------------------------------------------------------------------------- |
| 671 | |
| 672 | def _add_common_flags(p: argparse.ArgumentParser) -> None: |
| 673 | """Add identity + output flags shared by most subcommands.""" |
| 674 | p.add_argument( |
| 675 | "--hub", |
| 676 | metavar="URL", |
| 677 | help="Hub URL (determines which key to use, e.g. https://staging.musehub.ai).", |
| 678 | ) |
| 679 | p.add_argument( |
| 680 | "--agent-id", |
| 681 | metavar="ID", |
| 682 | dest="agent_id", |
| 683 | help="Sign as a specific agent identity from identity.toml.", |
| 684 | ) |
| 685 | p.add_argument( |
| 686 | "--timestamp", |
| 687 | metavar="N", |
| 688 | type=int, |
| 689 | default=None, |
| 690 | help="Override Unix timestamp (testing only — omit in production).", |
| 691 | ) |
| 692 | p.add_argument("--json", "-j", action="store_true", dest="json_out", help="Machine-readable JSON output.") |
| 693 | |
| 694 | def _add_body_flags(p: argparse.ArgumentParser) -> None: |
| 695 | """Add --body and --body-file flags.""" |
| 696 | grp = p.add_mutually_exclusive_group() |
| 697 | grp.add_argument( |
| 698 | "--body-file", |
| 699 | metavar="FILE", |
| 700 | dest="body_file", |
| 701 | help="Read request body from FILE (use '-' for stdin).", |
| 702 | ) |
| 703 | grp.add_argument( |
| 704 | "--body", |
| 705 | metavar="STRING", |
| 706 | help="Inline request body string (UTF-8 encoded).", |
| 707 | ) |
| 708 | |
| 709 | def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None: |
| 710 | """Register ``muse sign`` and its subcommands.""" |
| 711 | p = subparsers.add_parser( |
| 712 | "sign", |
| 713 | help="MSign request signing — produce, verify, and execute signed requests.", |
| 714 | description=__doc__, |
| 715 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 716 | ) |
| 717 | sub = p.add_subparsers(dest="sign_cmd", metavar="SUBCOMMAND") |
| 718 | sub.required = True |
| 719 | |
| 720 | # ── header ──────────────────────────────────────────────────────────────── |
| 721 | ph = sub.add_parser( |
| 722 | "header", |
| 723 | help="Emit an Authorization: MSign … header value.", |
| 724 | description=( |
| 725 | "Compute and print the MSign Authorization header for a request.\n\n" |
| 726 | "Shell use:\n" |
| 727 | " AUTH=$(muse sign header --method POST --path /owner/repo/push --hub URL)\n" |
| 728 | " curl -X POST -H \"Authorization: $AUTH\" URL --data-binary @mpack.bin" |
| 729 | ), |
| 730 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 731 | ) |
| 732 | ph.set_defaults(func=run_header) |
| 733 | ph.add_argument("--method", default="POST", metavar="VERB", help="HTTP method (default: POST).") |
| 734 | grp = ph.add_mutually_exclusive_group(required=True) |
| 735 | grp.add_argument("--path", metavar="PATH", help="URL path (e.g. /gabriel/muse/push). Combined with --hub.") |
| 736 | grp.add_argument("--url", metavar="URL", help="Full URL (alternative to --path + --hub).") |
| 737 | _add_body_flags(ph) |
| 738 | _add_common_flags(ph) |
| 739 | |
| 740 | # ── verify ──────────────────────────────────────────────────────────────── |
| 741 | pv = sub.add_parser( |
| 742 | "verify", |
| 743 | help="Verify an MSign Authorization header.", |
| 744 | description=( |
| 745 | "Exit 0 if the header is valid, exit 1 if not.\n" |
| 746 | "Useful in test harnesses and pre-receive hooks." |
| 747 | ), |
| 748 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 749 | ) |
| 750 | pv.set_defaults(func=run_verify) |
| 751 | pv.add_argument("--header", required=True, metavar="VALUE", |
| 752 | help="Full Authorization header value to verify.") |
| 753 | pv.add_argument("--method", required=True, metavar="VERB", help="HTTP method.") |
| 754 | pv.add_argument("--url", required=True, metavar="URL", help="Full request URL.") |
| 755 | pv.add_argument("--public-key-b64", required=True, dest="public_key_b64", |
| 756 | metavar="B64", help="URL-safe base64 Ed25519 public key (no padding).") |
| 757 | pv.add_argument("--max-age", type=int, default=30, dest="max_age", |
| 758 | metavar="SECS", help="Replay window in seconds (default: 30).") |
| 759 | _add_body_flags(pv) |
| 760 | pv.add_argument("--json", "-j", action="store_true", dest="json_out", help="JSON output.") |
| 761 | |
| 762 | # ── request ─────────────────────────────────────────────────────────────── |
| 763 | pr = sub.add_parser( |
| 764 | "request", |
| 765 | help="Sign and execute an authenticated HTTP request.", |
| 766 | description=( |
| 767 | "Sign a request with MSign and execute it, printing the response.\n\n" |
| 768 | "Example:\n" |
| 769 | " muse sign request --method POST \\\\\n" |
| 770 | " --url https://staging.musehub.ai/gabriel/muse/repair-object \\\\\n" |
| 771 | " --body-file payload.json --json" |
| 772 | ), |
| 773 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 774 | ) |
| 775 | pr.set_defaults(func=run_request) |
| 776 | pr.add_argument("--method", default="POST", metavar="VERB", help="HTTP method (default: POST).") |
| 777 | pr.add_argument("--url", required=True, metavar="URL", help="Full request URL.") |
| 778 | pr.add_argument( |
| 779 | "--content-type", |
| 780 | default="application/json", |
| 781 | dest="content_type", |
| 782 | metavar="TYPE", |
| 783 | help="Content-Type header (default: application/json).", |
| 784 | ) |
| 785 | _add_body_flags(pr) |
| 786 | _add_common_flags(pr) |
| 787 | |
| 788 | # ── curl ────────────────────────────────────────────────────────────────── |
| 789 | pc = sub.add_parser( |
| 790 | "curl", |
| 791 | help="Print a signed curl command (copy-paste or pipe to bash).", |
| 792 | description=( |
| 793 | "Emit a ready-to-run curl command with the MSign Authorization header.\n\n" |
| 794 | "Example:\n" |
| 795 | " muse sign curl --method POST \\\\\n" |
| 796 | " --url https://staging.musehub.ai/gabriel/muse/repair-object \\\\\n" |
| 797 | " --body-file payload.json | bash" |
| 798 | ), |
| 799 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 800 | ) |
| 801 | pc.set_defaults(func=run_curl) |
| 802 | pc.add_argument("--method", default="POST", metavar="VERB", help="HTTP method.") |
| 803 | pc.add_argument("--url", required=True, metavar="URL", help="Full request URL.") |
| 804 | pc.add_argument( |
| 805 | "--content-type", |
| 806 | default="application/json", |
| 807 | dest="content_type", |
| 808 | metavar="TYPE", |
| 809 | help="Content-Type header (default: application/json).", |
| 810 | ) |
| 811 | _add_body_flags(pc) |
| 812 | _add_common_flags(pc) |
| 813 | |
| 814 | # ── payment ─────────────────────────────────────────────────────────────── |
| 815 | pp = sub.add_parser( |
| 816 | "payment", |
| 817 | help="Sign an MPay micropayment claim.", |
| 818 | description=( |
| 819 | "Sign a micropayment claim for the MPay streaming protocol.\n\n" |
| 820 | "Canonical message: MPAY\\nFROM\\nTO\\nAMOUNT_NANO\\nCURRENCY\\nNONCE_HEX\\nMEMO\\nTS\n\n" |
| 821 | "Chain linkage: set --nonce to sha256(prev_signature_b64) to create\n" |
| 822 | "a tamper-evident chain settleable on Avalanche L1." |
| 823 | ), |
| 824 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 825 | ) |
| 826 | pp.set_defaults(func=run_payment) |
| 827 | pp.add_argument("--from", dest="from_handle", required=True, metavar="HANDLE", |
| 828 | help="Sender's hub handle.") |
| 829 | pp.add_argument("--to", dest="to_handle", required=True, metavar="HANDLE", |
| 830 | help="Recipient's hub handle.") |
| 831 | pp.add_argument("--amount", required=True, type=int, metavar="N", |
| 832 | help="Amount in nanoMUSE (1 MUSE = 1_000_000_000 nanoMUSE).") |
| 833 | pp.add_argument("--nonce", required=True, metavar="HEX", |
| 834 | help="64-char hex nonce. Use sha256(prev_sig_b64) for chain linkage.") |
| 835 | pp.add_argument("--currency", default="nanoMUSE", metavar="CURRENCY", |
| 836 | help="Currency identifier (default: nanoMUSE).") |
| 837 | pp.add_argument("--memo", default="", metavar="TEXT", |
| 838 | help="Free-form memo (e.g. 'stem:sha256:abc123').") |
| 839 | _add_common_flags(pp) |
| 840 | |
| 841 | # ── propose ─────────────────────────────────────────────────────────────── |
| 842 | ppr = sub.add_parser( |
| 843 | "propose", |
| 844 | help="Sign a canonical PROPOSE message for a merge proposal.", |
| 845 | description=( |
| 846 | "Sign a PROPOSE message with Ed25519 and emit the three fields\n" |
| 847 | "needed by the MuseHub API: proposerPublicKey, proposerSignature,\n" |
| 848 | "and proposerTimestamp.\n\n" |
| 849 | "Example:\n" |
| 850 | " muse sign propose \\\\\n" |
| 851 | " --repo-id sha256:<hex> \\\\\n" |
| 852 | " --from-branch feat/my-thing \\\\\n" |
| 853 | " --to-branch dev \\\\\n" |
| 854 | " --hub https://localhost:1337 --json" |
| 855 | ), |
| 856 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 857 | ) |
| 858 | ppr.set_defaults(func=run_propose) |
| 859 | ppr.add_argument("--repo-id", required=True, dest="repo_id", metavar="REPO_ID", |
| 860 | help="sha256-prefixed repository ID.") |
| 861 | ppr.add_argument("--from-branch", required=True, dest="from_branch", metavar="BRANCH", |
| 862 | help="Source branch name.") |
| 863 | ppr.add_argument("--to-branch", required=True, dest="to_branch", metavar="BRANCH", |
| 864 | help="Target branch name.") |
| 865 | _add_common_flags(ppr) |
File History
1 commit
sha256:08c083095bcaffb4c43ce947668fac93bf5d261e13d321776170c4019dd1d77d
fix: migration must never update identity.toml on a failed …
Sonnet 5
minor
⚠
2 days ago