gabriel / muse public
sign.py python
865 lines 31.8 KB
Raw
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