gabriel / muse public
auth.py python
1,489 lines 60.4 KB
Raw
sha256:2fa778aba8ab0ec15295b8624c6480a573482ffc9c206a6d9546f1c41d2c2b7b feat: supercharge muse blame + remove --porcelain everywhere Human patch 164 days ago
1 """muse auth — identity management.
2
3 Muse has two primary user types: **humans** and **agents**. Both are
4 first-class identities authenticated via Ed25519 key-pair challenge-response.
5 This command manages the identity lifecycle: keygen, register, whoami, logout.
6
7 Why not ``muse config set`` for credentials?
8 ---------------------------------------------
9 Credentials belong to the machine, not the repository. Storing credentials
10 inside ``.muse/config.toml`` means they could be committed to version control,
11 shared across repos accidentally, or tied to a single repo when the identity is
12 global. Instead:
13
14 - Credentials live in ``~/.muse/identity.toml`` (mode 0o600, never
15 read by the snapshot engine).
16 - ``config.toml`` records *where* the hub is (``[hub] url``), not *who
17 you are*.
18 - This command owns the identity lifecycle: keygen, register, whoami, logout.
19
20 Authentication flow
21 --------------------
22 ::
23
24 # Step 1: generate an Ed25519 key pair (private key stored in ~/.muse/keys/)
25 muse auth keygen --hub https://musehub.ai
26
27 # Step 2: register the public key with the hub via challenge-response
28 muse auth register --hub https://musehub.ai --handle alice
29
30 # Inspect stored identity:
31 muse auth whoami
32
33 Security model
34 --------------
35 - ``_json_post`` validates the URL scheme (``http``/``https`` only) before
36 making any network request — prevents SSRF from a tampered hub URL.
37 - All diagnostic messages (progress, warnings, errors) go to **stderr**.
38 **stdout** is reserved for machine-readable output and the interactive
39 prompt (get-url returns a bare URL; --json returns a JSON object).
40
41 Subcommands
42 -----------
43 ::
44
45 muse auth keygen [--hub HUB] [--label LABEL] [--force] [--json]
46 muse auth register [--hub HUB] [--handle HANDLE] [--label LABEL]
47 [--agent] [--json]
48 muse auth whoami [--hub HUB] [--all] [--json]
49 muse auth logout [--hub HUB] [--all] [--json]
50
51 JSON schemas
52 ------------
53 ``muse auth keygen --json``::
54
55 {"status": "ok", "hub": "<url>", "hostname": "<host>",
56 "key_path": "<path>", "public_key_b64": "<b64url>",
57 "fingerprint": "<sha256hex>"}
58
59 ``muse auth register --json``::
60
61 {"status": "registered"|"authenticated", "hub": "<url>",
62 "handle": "<name>", "identity_type": "human"|"agent",
63 "fingerprint": "<sha256hex>", "identity_path": "<path>"}
64
65 ``muse auth whoami --json``::
66
67 {"hub": "<hostname>", "type": "<type>", "handle": "<handle>",
68 "key_set": true, "capabilities": []}
69
70 ``muse auth logout --json``::
71
72 {"status": "ok"|"nothing_to_do", "hubs": ["<hostname>", ...], "count": <N>}
73 """
74
75 from __future__ import annotations
76
77 import argparse
78 import json
79 import logging
80 import os
81 import pathlib
82 import sys
83 import urllib.error
84 import urllib.parse
85 import urllib.request
86 from typing import TypedDict
87
88 from muse.cli.config import get_hub_url
89 from muse.core.errors import ExitCode
90 from muse.core.identity import (
91 IdentityEntry,
92 clear_all_identities,
93 clear_identity,
94 get_identity_path,
95 hostname_from_url,
96 list_all_identities,
97 load_identity,
98 save_identity,
99 )
100 from muse.core.validation import sanitize_display
101
102 logger = logging.getLogger(__name__)
103
104
105 def _read_mnemonic_securely(fd: int | None = None) -> str:
106 """Read a BIP39 mnemonic without exposing it in process args or shell history.
107
108 Input is accepted through three channels in priority order:
109
110 1. *fd* (``--mnemonic-fd N``) — read one line from file descriptor *N*,
111 close it immediately. Used by orchestrators that pass secrets via pipe.
112 2. Non-TTY stdin — read one line from ``sys.stdin``. Triggered by piped
113 input (``echo "..." | muse auth recover``) or heredoc redirects.
114 3. TTY stdin — prompt via :func:`getpass.getpass` with echo disabled.
115 The prompt and the phrase itself are never written to the terminal
116 buffer that shell history reads.
117
118 Args:
119 fd: File descriptor number to read from, or ``None`` to use stdin / TTY.
120
121 Returns:
122 The stripped mnemonic phrase.
123
124 Raises:
125 SystemExit(1): If the fd is invalid, unreadable, or the input is empty.
126 """
127 phrase: str
128
129 if fd is not None:
130 try:
131 with os.fdopen(fd, "r", encoding="utf-8") as fh:
132 phrase = fh.readline().strip()
133 except OSError as exc:
134 print(f"muse auth: cannot read from fd {fd}: {exc}", file=sys.stderr)
135 raise SystemExit(ExitCode.USER_ERROR)
136 elif not sys.stdin.isatty():
137 phrase = sys.stdin.readline().strip()
138 else:
139 import getpass
140 try:
141 phrase = getpass.getpass("Enter BIP39 mnemonic: ").strip()
142 except (KeyboardInterrupt, EOFError):
143 print("", file=sys.stderr)
144 raise SystemExit(ExitCode.USER_ERROR)
145
146 if not phrase:
147 print("muse auth: mnemonic input was empty.", file=sys.stderr)
148 raise SystemExit(ExitCode.USER_ERROR)
149
150 return phrase
151
152
153 # Auth endpoints on the hub (relative to the hub base URL).
154 _CHALLENGE_PATH = "/api/auth/challenge"
155 _VERIFY_PATH = "/api/auth/verify"
156
157 # Hard cap on response size to prevent OOM from a compromised hub.
158 _MAX_RESPONSE_BYTES = 1 * 1024 * 1024 # 1 MiB
159
160 # Only allow http and https — no file://, ftp://, data://, etc.
161 _ALLOWED_SCHEMES = frozenset({"http", "https"})
162
163
164 # ── TypedDicts ────────────────────────────────────────────────────────────────
165
166 class _KeygenJson(TypedDict):
167 """JSON schema for ``muse auth keygen --json``."""
168
169 status: str # "ok"
170 hub: str # hub URL
171 hostname: str # extracted hostname
172 key_path: str # absolute path to private key PEM
173 public_key_b64: str # base64url-encoded public key
174 fingerprint: str # SHA-256 hex fingerprint
175
176
177 class _RegisterJson(TypedDict):
178 """JSON schema for ``muse auth register --json``."""
179
180 status: str # "registered" | "authenticated"
181 hub: str # hub URL
182 handle: str # registered username
183 identity_id: str # hub-assigned ID
184 identity_type: str # "human" | "agent"
185 fingerprint: str # SHA-256 hex fingerprint of the public key
186 token_stored: bool # always true on success
187 identity_path: str # path to ~/.muse/identity.toml
188
189
190 class _WhoamiJson(TypedDict):
191 """JSON schema for ``muse auth whoami --json`` (per-identity entry)."""
192
193 hub: str # hostname key
194 type: str # "human" | "agent" | ""
195 handle: str # registered handle
196 fingerprint: str # SHA-256 hex of the public key
197 key_set: bool # true if an Ed25519 key is stored
198 capabilities: list[str]
199
200
201 class _ShowJson(TypedDict, total=False):
202 """JSON schema for ``muse auth show --json`` (per-identity detail)."""
203
204 hub: str
205 handle: str
206 type: str
207 fingerprint: str
208 hd_path: str
209 mnemonic_word_count: int
210 derived_paths: dict
211 avax_c_chain_address: str
212
213
214 class _LogoutJson(TypedDict):
215 """JSON schema for ``muse auth logout --json``."""
216
217 status: str # "ok" | "nothing_to_do"
218 hubs: list[str] # hostnames logged out from
219 count: int # number of identities removed
220
221
222 class _JsonPayload(TypedDict, total=False):
223 """Generic JSON request payload for hub auth endpoints (all fields optional str)."""
224
225 fingerprint: str
226 algorithm: str
227 challenge_token: str
228 public_key_b64: str
229 signature_b64: str
230 handle: str
231 label: str
232
233
234 class _ChallengeResp(TypedDict, total=False):
235 """Parsed response from the hub challenge endpoint."""
236
237 challengeToken: str
238 challenge_token: str
239 isNewKey: bool
240 is_new_key: bool
241
242
243 class _VerifyResp(TypedDict, total=False):
244 """Parsed response from the hub verify endpoint."""
245
246 handle: str
247 identityId: str
248 identity_id: str
249 isNewIdentity: bool
250 is_new_identity: bool
251
252
253 # ── HTTP helpers ──────────────────────────────────────────────────────────────
254
255 def _hub_base_url(hub_url: str) -> str:
256 """Extract the base URL (scheme + host + port) from a full hub URL.
257
258 Examples::
259
260 "https://musehub.ai/gabriel/muse" → "https://musehub.ai"
261 "http://localhost:10003" → "http://localhost:10003"
262
263 Raises:
264 SystemExit: If the URL scheme is not ``http`` or ``https``.
265 """
266 parsed = urllib.parse.urlparse(hub_url)
267 if parsed.scheme.lower() not in _ALLOWED_SCHEMES:
268 print(
269 f"❌ Hub URL scheme '{sanitize_display(parsed.scheme)}' is not allowed. "
270 f"Use http or https.",
271 file=sys.stderr,
272 )
273 raise SystemExit(ExitCode.USER_ERROR)
274 port_str = f":{parsed.port}" if parsed.port else ""
275 return f"{parsed.scheme}://{parsed.hostname}{port_str}"
276
277
278 def _json_post_raw(base_url: str, path: str, payload: _JsonPayload) -> _JsonPayload:
279 """POST *payload* as JSON and return the raw parsed response dict.
280
281 Private implementation — call :func:`_post_challenge` or
282 :func:`_post_verify` instead. Validates the URL scheme before any
283 network I/O to prevent SSRF.
284
285 Args:
286 base_url: Hub base URL (e.g. ``"https://musehub.ai"``).
287 path: Endpoint path (e.g. ``"/api/auth/challenge"``).
288 payload: Dict to serialise as the JSON body (``None`` values omitted).
289
290 Returns:
291 Parsed JSON response body as a dict.
292
293 Raises:
294 SystemExit: On invalid URL scheme, HTTP error, or network failure.
295 """
296 scheme = urllib.parse.urlparse(base_url).scheme.lower()
297 if scheme not in _ALLOWED_SCHEMES:
298 print(
299 f"❌ Hub URL scheme '{sanitize_display(scheme)}' is not allowed. "
300 f"Use http or https.",
301 file=sys.stderr,
302 )
303 raise SystemExit(ExitCode.USER_ERROR)
304
305 url = f"{base_url.rstrip('/')}{path}"
306 clean_payload = {k: v for k, v in payload.items() if v is not None}
307 body_bytes = json.dumps(clean_payload).encode("utf-8")
308 req = urllib.request.Request(
309 url=url,
310 data=body_bytes,
311 headers={
312 "Content-Type": "application/json",
313 "Accept": "application/json",
314 },
315 method="POST",
316 )
317 try:
318 with urllib.request.urlopen(req, timeout=30) as resp: # noqa: S310
319 raw: bytes = resp.read(_MAX_RESPONSE_BYTES + 1)
320 except urllib.error.HTTPError as exc:
321 try:
322 err_body = exc.read().decode("utf-8", errors="replace")[:400]
323 except Exception: # noqa: BLE001
324 err_body = ""
325 print(
326 f"❌ HTTP {exc.code} from {sanitize_display(url)}: {sanitize_display(err_body)}",
327 file=sys.stderr,
328 )
329 raise SystemExit(ExitCode.USER_ERROR) from exc
330 except urllib.error.URLError as exc:
331 print(
332 f"❌ Network error contacting {sanitize_display(url)}: "
333 f"{sanitize_display(str(exc.reason))}",
334 file=sys.stderr,
335 )
336 raise SystemExit(ExitCode.USER_ERROR) from exc
337
338 if len(raw) > _MAX_RESPONSE_BYTES:
339 print(f"❌ Response from {sanitize_display(url)} exceeds size limit.", file=sys.stderr)
340 raise SystemExit(ExitCode.USER_ERROR)
341
342 parsed = json.loads(raw)
343 if not isinstance(parsed, dict):
344 print(f"❌ Unexpected response shape from {sanitize_display(url)}.", file=sys.stderr)
345 raise SystemExit(ExitCode.USER_ERROR)
346 return parsed
347
348
349 def _post_challenge(base_url: str, payload: _JsonPayload) -> _ChallengeResp:
350 """POST to the challenge endpoint and return a typed response."""
351 raw = _json_post_raw(base_url, _CHALLENGE_PATH, payload)
352 return _ChallengeResp(
353 challengeToken=str(raw.get("challengeToken") or ""),
354 challenge_token=str(raw.get("challenge_token") or ""),
355 isNewKey=bool(raw.get("isNewKey") or raw.get("is_new_key")),
356 is_new_key=bool(raw.get("is_new_key")),
357 )
358
359
360 def _post_verify(base_url: str, payload: _JsonPayload) -> _VerifyResp:
361 """POST to the verify endpoint and return a typed response."""
362 raw = _json_post_raw(base_url, _VERIFY_PATH, payload)
363 return _VerifyResp(
364 handle=str(raw.get("handle") or ""),
365 identityId=str(raw.get("identityId") or ""),
366 identity_id=str(raw.get("identity_id") or ""),
367 isNewIdentity=bool(raw.get("isNewIdentity") or raw.get("is_new_identity")),
368 is_new_identity=bool(raw.get("is_new_identity")),
369 )
370
371
372 # ── Helpers ───────────────────────────────────────────────────────────────────
373
374 def _resolve_hub(hub_opt: str | None, repo_root: pathlib.Path | None = None) -> str | None:
375 """Return the hub URL: explicit option → repo config → None."""
376 if hub_opt:
377 return hub_opt
378 return get_hub_url(repo_root)
379
380
381 def _display_entry(hostname: str, entry: IdentityEntry, *, json_output: bool) -> None:
382 """Print an identity entry. JSON → stdout; human-readable → stderr."""
383 itype = entry.get("type") or ""
384 handle = entry.get("handle") or ""
385 fingerprint = entry.get("fingerprint") or ""
386 key_path = entry.get("key_path") or ""
387 caps = list(entry.get("capabilities") or [])
388
389 if json_output:
390 out: _WhoamiJson = {
391 "hub": hostname,
392 "type": itype,
393 "handle": handle,
394 "fingerprint": fingerprint,
395 "key_set": bool(key_path),
396 "capabilities": caps,
397 }
398 print(json.dumps(out))
399 else:
400 print("", file=sys.stderr)
401 print(f" Hub: {sanitize_display(hostname)}", file=sys.stderr)
402 print(f" Type: {itype or 'unknown'}", file=sys.stderr)
403 print(f" Handle: {sanitize_display(handle) or '—'}", file=sys.stderr)
404 print(f" Fingerprint: {fingerprint[:16] + '…' if fingerprint else '—'}", file=sys.stderr)
405 print(f" Key: {sanitize_display(key_path) or 'not set — run muse auth keygen'}", file=sys.stderr)
406 if caps:
407 print(f" Caps: {' '.join(caps)}", file=sys.stderr)
408 print("", file=sys.stderr)
409
410
411 # ── register ──────────────────────────────────────────────────────────────────
412
413 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
414 """Register the ``muse auth`` subcommand tree and all its flags.
415
416 Every subcommand accepts ``--json`` for machine-readable output on stdout.
417 All progress and diagnostic messages go to stderr.
418 """
419 parser = subparsers.add_parser(
420 "auth",
421 help="Identity management.",
422 description=__doc__,
423 formatter_class=argparse.RawDescriptionHelpFormatter,
424 )
425 subs = parser.add_subparsers(dest="subcommand", metavar="SUBCOMMAND")
426 subs.required = True
427
428 # ── keygen ───────────────────────────────────────────────────────────────
429 keygen_p = subs.add_parser(
430 "keygen",
431 help="Generate a new Ed25519 keypair for public-key authentication.",
432 description=(
433 "Generates a fresh Ed25519 keypair and stores the private key at\n"
434 "~/.muse/keys/{hostname}.pem (mode 0o600).\n"
435 "The public key fingerprint is printed to stderr for verification.\n"
436 "Run 'muse auth register' afterward to register the key with the hub."
437 ),
438 formatter_class=argparse.RawDescriptionHelpFormatter,
439 )
440 keygen_p.add_argument("--hub", default=None, metavar="URL",
441 help="Hub URL (e.g. https://musehub.ai). Falls back to [hub] url in config.toml.")
442 keygen_p.add_argument("--agent-id", default=None, metavar="AGENT_ID", dest="agent_id",
443 help=(
444 "Generate a dedicated keypair for this agent handle "
445 "(stored at ~/.muse/keys/{hostname}__{agent_id}.pem). "
446 "Omit for the human (operator) key."
447 ))
448 keygen_p.add_argument("--label", default=None, metavar="LABEL",
449 help='Friendly key label, e.g. "MacBook Pro" or "CI agent". Stored locally.')
450 keygen_p.add_argument("--force", action="store_true",
451 help="Overwrite an existing key for this hub without prompting.")
452 keygen_p.add_argument("--json", action="store_true", dest="json_output", default=False,
453 help="Emit a JSON object to stdout on success.")
454 keygen_p.add_argument(
455 "--strength", type=int, default=256, dest="hd_strength",
456 metavar="BITS",
457 help=(
458 "BIP39 entropy strength in bits: 128 (12 words), 160 (15), "
459 "192 (18), 224 (21), or 256 (24 words, default)."
460 ),
461 )
462 keygen_p.add_argument(
463 "--language", default="english", dest="hd_language",
464 metavar="LANG",
465 help=(
466 "BIP39 wordlist language for the generated mnemonic "
467 "(e.g. english, spanish, japanese). Default: english."
468 ),
469 )
470 keygen_p.set_defaults(func=run_keygen)
471
472 # ── recover ──────────────────────────────────────────────────────────────
473 recover_p = subs.add_parser(
474 "recover",
475 help="Re-derive and overwrite key(s) from a BIP39 mnemonic.",
476 description=(
477 "Re-derives the Ed25519 identity key from a BIP39 mnemonic and\n"
478 "overwrites the local PEM file. Use this to restore keys after\n"
479 "losing the PEM file (e.g. new machine, disk failure).\n\n"
480 "The resulting fingerprint will match the original registration\n"
481 "exactly — no re-registration is needed."
482 ),
483 formatter_class=argparse.RawDescriptionHelpFormatter,
484 )
485 recover_p.add_argument("--hub", default=None, metavar="URL",
486 help="Hub URL. Falls back to [hub] url in config.toml.")
487 recover_p.add_argument("--mnemonic-fd", type=int, default=None, metavar="N",
488 dest="mnemonic_fd",
489 help="Read BIP39 mnemonic from file descriptor N (for scripted use). "
490 "If omitted: reads from stdin if piped, or prompts interactively.")
491 recover_p.add_argument("--agent-id", default=None, metavar="AGENT_ID", dest="agent_id",
492 help="Recover an agent key (derives from the operator mnemonic at the agent's slot).")
493 recover_p.add_argument("--force", action="store_true",
494 help="Overwrite an existing PEM without prompting.")
495 recover_p.add_argument("--json", action="store_true", dest="json_output", default=False,
496 help="Emit a JSON object to stdout on success.")
497 recover_p.set_defaults(func=run_recover)
498
499 # ── register ─────────────────────────────────────────────────────────────
500 register_p = subs.add_parser(
501 "register",
502 help="Register the local Ed25519 key with a MuseHub instance (challenge-response).",
503 description=(
504 "Performs the Ed25519 challenge-response flow with the hub:\n"
505 " 1. POST /api/auth/challenge (fingerprint → nonce)\n"
506 " 2. Sign the nonce with the local private key\n"
507 " 3. POST /api/auth/verify (signed nonce → session token)\n\n"
508 "The resulting session token is saved to ~/.muse/identity.toml (0o600).\n"
509 "Use this command for initial registration AND to refresh an expired token."
510 ),
511 formatter_class=argparse.RawDescriptionHelpFormatter,
512 )
513 register_p.add_argument("--hub", default=None, metavar="URL",
514 help="Hub URL (e.g. https://musehub.ai). Falls back to [hub] url in config.toml.")
515 register_p.add_argument("--handle", default=None, metavar="HANDLE",
516 help=(
517 "Desired username (required for first-time registration; "
518 "ignored if the key is already registered)."
519 ))
520 register_p.add_argument("--label", default=None, metavar="LABEL",
521 help='Friendly key label, e.g. "MacBook Pro" or "CI agent".')
522 register_p.add_argument("--agent", action="store_true",
523 help="Mark this identity as an agent (default: human).")
524 register_p.add_argument("--agent-id", default=None, metavar="AGENT_ID", dest="agent_id",
525 help=(
526 "Agent handle whose dedicated key should be registered. "
527 "Loads ~/.muse/keys/{hostname}__{agent_id}.pem and stores "
528 "the identity under the compound key 'hostname#agent_id'. "
529 "Implies --agent."
530 ))
531 register_p.add_argument("--provisioned-by", default=None, metavar="HANDLE", dest="provisioned_by",
532 help=(
533 "Handle of the human who is provisioning this agent key. "
534 "Recorded in identity.toml as the trust-chain root. "
535 "Required when --agent-id is used."
536 ))
537 register_p.add_argument("--json", action="store_true", dest="json_output", default=False,
538 help="Emit a JSON object to stdout on success.")
539 register_p.set_defaults(func=run_register)
540
541 # ── whoami ────────────────────────────────────────────────────────────────
542 whoami_p = subs.add_parser(
543 "whoami",
544 help="Show the current identity stored in ~/.muse/identity.toml.",
545 description=(
546 "Print the identity stored in ~/.muse/identity.toml for a hub.\n"
547 "Exits non-zero when no identity is stored — useful for agent branching:\n\n"
548 " muse auth whoami --json || muse auth register --hub <url> --handle <name> --agent\n\n"
549 "With --all --json, emits a single JSON array (one object per hub):\n\n"
550 " muse auth whoami --all --json | jq '.[] | select(.type == \"agent\")'"
551 ),
552 formatter_class=argparse.RawDescriptionHelpFormatter,
553 )
554 whoami_p.add_argument("--hub", default=None, metavar="URL",
555 help="Hub URL to inspect. Defaults to the repo's configured hub.")
556 whoami_p.add_argument("--all", "-a", action="store_true", dest="all_hubs",
557 help="Show identities for all configured hubs.")
558 whoami_p.add_argument("--json", "-j", action="store_true", dest="json_output",
559 help="Emit JSON to stdout. With --all, emits a JSON array.")
560 whoami_p.set_defaults(func=run_whoami)
561
562 # ── logout ────────────────────────────────────────────────────────────────
563 logout_p = subs.add_parser(
564 "logout",
565 help="Remove stored credentials for a hub.",
566 description=(
567 "Remove the signing identity stored in ~/.muse/identity.toml for a hub.\n"
568 "Operation is idempotent — logging out when no identity is stored exits 0.\n\n"
569 "Agent quickstart:\n"
570 " muse auth logout --json # single hub, JSON result\n"
571 " muse auth logout --all --json # remove all hubs, sorted list\n\n"
572 "JSON output shape (stdout):\n"
573 " {\"status\": \"ok\" | \"nothing_to_do\",\n"
574 " \"hub\": \"<hostname>\", # single-hub only\n"
575 " \"hubs\": [\"<hostname>\", ...], # --all only (sorted)\n"
576 " \"count\": <int>} # --all only\n\n"
577 "Exit codes: 0 success (incl. nothing to do), 1 bad arguments, 3 internal error."
578 ),
579 formatter_class=argparse.RawDescriptionHelpFormatter,
580 )
581 logout_p.add_argument("--hub", default=None, metavar="URL",
582 help="Hub URL to log out from. Defaults to the repo's configured hub.")
583 logout_p.add_argument("--all", "-a", action="store_true", dest="all_hubs",
584 help="Remove credentials for ALL configured hubs.")
585 logout_p.add_argument("--json", "-j", action="store_true", dest="json_output", default=False,
586 help="Emit a JSON object to stdout on completion.")
587 logout_p.set_defaults(func=run_logout)
588
589 # ── show ──────────────────────────────────────────────────────────────────
590 show_p = subs.add_parser(
591 "show",
592 help="Display full identity details including HD derivation paths and AVAX address.",
593 description=(
594 "Print detailed identity information stored in ~/.muse/identity.toml.\n"
595 "For HD identities this includes:\n"
596 " - Mnemonic word count\n"
597 " - All six-level Muse derivation paths (MSign, MPay, AVAX …)\n"
598 " - AVAX C-Chain address derived from the BIP39 mnemonic\n\n"
599 "JSON output (--json):\n"
600 " {\"hub\": \"<hostname>\", \"handle\": \"<handle>\",\n"
601 " \"mnemonic_word_count\": 12,\n"
602 " \"derived_paths\": {\"identity_msign\": \"m/…\", …},\n"
603 " \"avax_c_chain_address\": \"0x…\"}"
604 ),
605 formatter_class=argparse.RawDescriptionHelpFormatter,
606 )
607 show_p.add_argument("--hub", default=None, metavar="URL",
608 help="Hub URL to inspect. Defaults to the repo's configured hub.")
609 show_p.add_argument("--json", "-j", action="store_true", dest="json_output", default=False,
610 help="Emit a JSON object to stdout.")
611 show_p.set_defaults(func=run_show)
612
613 # ── keygen ────────────────────────────────────────────────────────────────────
614
615 def run_keygen(args: argparse.Namespace) -> None:
616 """Generate a new Ed25519 keypair for this hub (or for a specific agent).
617
618 All keys are derived from a BIP39 mnemonic via SLIP-0010 Ed25519 HD
619 derivation. There is no random (JBOK) mode.
620
621 Human keys
622 ----------
623 A fresh 24-word BIP39 mnemonic is generated and the identity key is derived
624 at ``m/1075233755'/0'/0'/0'/0'/0'``. The mnemonic is printed **once** to
625 stderr — write it on paper and store it securely. It is the root secret
626 from which all keys (identity, payments, code, music, agent sub-seeds) can
627 be re-derived.
628
629 Agent keys
630 ----------
631 Pass ``--agent-id <handle>`` to derive a key for an agent. No new mnemonic
632 is generated — the agent's key is derived deterministically from the
633 operator's existing mnemonic via :func:`~muse.core.hdkeys.derive_agent_sub_seed`.
634 The operator must have run ``muse auth keygen`` first.
635
636 The agent's slot is determined by hashing the handle string:
637 ``slot = sha256(handle)[:4] & 0x7FFFFFFF``. This mapping is permanent.
638 """
639 from muse.core.keypair import generate_hd_keypair, key_path_for
640 from muse.core.bip39 import (
641 generate_mnemonic, mnemonic_to_seed, word_count,
642 Bip39Error, _WORDS_FOR_STRENGTH, SUPPORTED_LANGUAGES,
643 )
644 from muse.core.hdkeys import (
645 muse_path, agent_id_to_slot, derive_agent_sub_seed,
646 DOMAIN_IDENTITY, ENTITY_HUMAN, ENTITY_AGENT, ROLE_SIGN,
647 )
648
649 hub: str | None = args.hub
650 agent_id: str | None = getattr(args, "agent_id", None)
651 label: str | None = args.label
652 force: bool = args.force
653 json_output: bool = args.json_output
654 hd_strength: int = getattr(args, "hd_strength", 256)
655 hd_language: str = getattr(args, "hd_language", "english")
656
657 hub_url = _resolve_hub(hub)
658 if hub_url is None:
659 print(
660 "❌ No hub URL provided.\n"
661 " Pass --hub <url>, or first run: muse hub connect <url>",
662 file=sys.stderr,
663 )
664 raise SystemExit(ExitCode.USER_ERROR)
665
666 hostname = hostname_from_url(hub_url)
667 key_path = key_path_for(hostname, agent_id)
668
669 if key_path.exists() and not force:
670 subject = f"{sanitize_display(hostname)}#{sanitize_display(agent_id)}" if agent_id else sanitize_display(hostname)
671 print(
672 f"⚠️ A key already exists for {subject}: {key_path}\n"
673 " Pass --force to overwrite it (you will need to re-register).",
674 file=sys.stderr,
675 )
676 raise SystemExit(ExitCode.USER_ERROR)
677
678 if key_path.exists() and force:
679 subject = f"{sanitize_display(hostname)}#{sanitize_display(agent_id)}" if agent_id else sanitize_display(hostname)
680 print(f"⚠️ Overwriting existing key for {subject}.", file=sys.stderr)
681
682 if agent_id:
683 # ── Agent key: derived from operator's mnemonic ───────────────────────
684 operator_entry = load_identity(hub_url)
685 if operator_entry is None:
686 print(
687 "❌ No operator identity found for this hub.\n"
688 f" Run 'muse auth keygen --hub {hub_url}' first to establish the operator key.",
689 file=sys.stderr,
690 )
691 raise SystemExit(ExitCode.USER_ERROR)
692
693 operator_mnemonic = operator_entry.get("mnemonic")
694 if not operator_mnemonic:
695 print(
696 "❌ Operator mnemonic is not available.\n"
697 " Agent keys are derived from the operator's BIP39 mnemonic.\n"
698 " Ensure the mnemonic is stored in the OS keychain, or re-key with:\n"
699 f" muse auth keygen --hub {hub_url} --force",
700 file=sys.stderr,
701 )
702 raise SystemExit(ExitCode.USER_ERROR)
703 operator_seed = mnemonic_to_seed(operator_mnemonic)
704 slot = agent_id_to_slot(agent_id)
705 agent_sub_seed = derive_agent_sub_seed(operator_seed, DOMAIN_IDENTITY, slot)
706 pub_b64, fingerprint = generate_hd_keypair(hostname, agent_sub_seed, agent_id)
707
708 hd_path_str = muse_path(DOMAIN_IDENTITY, ENTITY_AGENT, slot)
709 provisional_entry: IdentityEntry = {
710 "type": "agent",
711 "handle": "",
712 "key_path": str(key_path),
713 "algorithm": "ed25519",
714 "fingerprint": fingerprint,
715 "hd_path": hd_path_str,
716 "provisioned_by_fingerprint": operator_entry["fingerprint"],
717 }
718 try:
719 save_identity(hub_url, provisional_entry, agent_id=agent_id)
720 except OSError as exc:
721 print(f"❌ Could not persist agent identity: {exc}", file=sys.stderr)
722 raise SystemExit(ExitCode.INTERNAL_ERROR) from exc
723
724 if json_output:
725 out_agent = {
726 "status": "ok",
727 "hub": hub_url,
728 "hostname": hostname,
729 "agent_id": agent_id,
730 "key_path": str(key_path),
731 "public_key_b64": pub_b64,
732 "fingerprint": fingerprint,
733 "hd_path": hd_path_str,
734 "slot": slot,
735 "provisioned_by_fingerprint": operator_entry["fingerprint"],
736 }
737 print(json.dumps(out_agent))
738 else:
739 register_flags = (
740 f"--hub {hub_url} --agent-id {agent_id} "
741 f"--handle {agent_id} --provisioned-by <your-handle>"
742 )
743 print(
744 f"✅ Agent Ed25519 key derived (agent: {agent_id})\n"
745 f" Private key: {key_path}\n"
746 f" Public key (b64url): {pub_b64}\n"
747 f" Fingerprint (SHA-256): {fingerprint}\n"
748 f" HD path: {hd_path_str} (slot {slot})\n"
749 f" Derived from operator: {operator_entry['fingerprint'][:16]}…\n\n"
750 f" Next step: muse auth register {register_flags}",
751 file=sys.stderr,
752 )
753 return
754
755 # ── Human key: generate fresh mnemonic ────────────────────────────────────
756 if hd_strength not in _WORDS_FOR_STRENGTH:
757 print(
758 f"❌ Unsupported --strength {hd_strength}. "
759 f"Must be one of {sorted(_WORDS_FOR_STRENGTH)}.",
760 file=sys.stderr,
761 )
762 raise SystemExit(ExitCode.USER_ERROR)
763
764 if hd_language not in SUPPORTED_LANGUAGES:
765 print(
766 f"❌ Unsupported --language {sanitize_display(hd_language)!r}. "
767 f"Supported: {sorted(SUPPORTED_LANGUAGES)}.",
768 file=sys.stderr,
769 )
770 raise SystemExit(ExitCode.USER_ERROR)
771
772 try:
773 mnemonic = generate_mnemonic(strength=hd_strength, language=hd_language)
774 except Bip39Error as exc:
775 print(f"❌ Mnemonic generation failed: {exc}", file=sys.stderr)
776 raise SystemExit(ExitCode.INTERNAL_ERROR)
777
778 seed = mnemonic_to_seed(mnemonic)
779 pub_b64, fingerprint = generate_hd_keypair(hostname, seed)
780
781 hd_path_str = muse_path(DOMAIN_IDENTITY, ENTITY_HUMAN)
782 n_words = word_count(hd_strength)
783
784 provisional_entry: IdentityEntry = {
785 "type": "human",
786 "handle": "",
787 "key_path": str(key_path),
788 "algorithm": "ed25519",
789 "fingerprint": fingerprint,
790 "hd_path": hd_path_str,
791 }
792 try:
793 save_identity(hub_url, provisional_entry, mnemonic=mnemonic)
794 except OSError as exc:
795 print(f"❌ Could not persist HD provenance: {exc}", file=sys.stderr)
796 raise SystemExit(ExitCode.INTERNAL_ERROR) from exc
797
798 # Print mnemonic to stderr — never stdout (stdout is machine-readable JSON).
799 print(
800 "\n"
801 "╔══════════════════════════════════════════════════════════════════╗\n"
802 "║ 🔑 YOUR BIP39 MNEMONIC — WRITE THIS DOWN NOW ║\n"
803 "║ Never store digitally without encryption. ║\n"
804 "║ Losing it means permanent loss of access to all derived keys. ║\n"
805 "╚══════════════════════════════════════════════════════════════════╝\n"
806 f"\n {mnemonic}\n",
807 file=sys.stderr,
808 )
809
810 if json_output:
811 out_human: _KeygenJson = {
812 "status": "ok",
813 "hub": hub_url,
814 "hostname": hostname,
815 "key_path": str(key_path),
816 "public_key_b64": pub_b64,
817 "fingerprint": fingerprint,
818 "hd_path": hd_path_str,
819 "mnemonic_word_count": n_words,
820 }
821 print(json.dumps(out_human))
822 else:
823 label_note = f" (label: {label!r})" if label else ""
824 print(
825 f"✅ Ed25519 keypair generated{label_note}\n"
826 f" Private key: {key_path}\n"
827 f" Public key (b64url): {pub_b64}\n"
828 f" Fingerprint (SHA-256): {fingerprint}\n"
829 f" HD path: {hd_path_str}\n"
830 f" Mnemonic: {n_words} words ({hd_language})\n\n"
831 f" Next step: muse auth register --hub {hub_url} --handle <your-handle>",
832 file=sys.stderr,
833 )
834
835
836 # ── recover ───────────────────────────────────────────────────────────────────
837
838
839 def run_recover(args: argparse.Namespace) -> None:
840 """Re-derive and overwrite key(s) from a BIP39 mnemonic.
841
842 Uses the mnemonic to re-derive the Ed25519 identity key and overwrites
843 the local PEM file. The resulting fingerprint is identical to the
844 original — no re-registration with the hub is needed.
845 """
846 from muse.core.keypair import generate_hd_keypair, key_path_for
847 from muse.core.bip39 import validate_mnemonic, mnemonic_to_seed, Bip39Error
848 from muse.core.hdkeys import (
849 muse_path, agent_id_to_slot, derive_agent_sub_seed,
850 DOMAIN_IDENTITY, ENTITY_HUMAN, ENTITY_AGENT,
851 )
852
853 hub: str | None = args.hub
854 mnemonic_fd: int | None = getattr(args, "mnemonic_fd", None)
855 mnemonic: str = _read_mnemonic_securely(fd=mnemonic_fd)
856 agent_id: str | None = getattr(args, "agent_id", None)
857 force: bool = args.force
858 json_output: bool = args.json_output
859
860 hub_url = _resolve_hub(hub)
861 if hub_url is None:
862 print(
863 "❌ No hub URL provided.\n"
864 " Pass --hub <url>, or first run: muse hub connect <url>",
865 file=sys.stderr,
866 )
867 raise SystemExit(ExitCode.USER_ERROR)
868
869 if not validate_mnemonic(mnemonic):
870 print(f"❌ Invalid mnemonic: phrase did not pass BIP39 validation.", file=sys.stderr)
871 raise SystemExit(ExitCode.USER_ERROR)
872
873 hostname = hostname_from_url(hub_url)
874 key_path = key_path_for(hostname, agent_id)
875
876 if key_path.exists() and not force:
877 subject = f"{sanitize_display(hostname)}#{sanitize_display(agent_id)}" if agent_id else sanitize_display(hostname)
878 print(
879 f"⚠️ A key already exists for {subject}: {key_path}\n"
880 " Pass --force to overwrite it.",
881 file=sys.stderr,
882 )
883 raise SystemExit(ExitCode.USER_ERROR)
884
885 operator_seed = mnemonic_to_seed(mnemonic)
886
887 if agent_id:
888 slot = agent_id_to_slot(agent_id)
889 agent_sub_seed = derive_agent_sub_seed(operator_seed, DOMAIN_IDENTITY, slot)
890 pub_b64, fingerprint = generate_hd_keypair(hostname, agent_sub_seed, agent_id)
891 hd_path_str = muse_path(DOMAIN_IDENTITY, ENTITY_AGENT, slot)
892 else:
893 pub_b64, fingerprint = generate_hd_keypair(hostname, operator_seed)
894 hd_path_str = muse_path(DOMAIN_IDENTITY, ENTITY_HUMAN)
895
896 # Update identity.toml — preserve handle if the entry already exists.
897 existing = load_identity(hub_url, agent_id)
898 handle = existing.get("handle", "") if existing else ""
899 entry: IdentityEntry = {
900 "type": "agent" if agent_id else "human",
901 "handle": handle,
902 "key_path": str(key_path),
903 "algorithm": "ed25519",
904 "fingerprint": fingerprint,
905 "hd_path": hd_path_str,
906 }
907 try:
908 save_identity(
909 hub_url,
910 entry,
911 agent_id=agent_id,
912 mnemonic=mnemonic if not agent_id else None,
913 )
914 except OSError as exc:
915 print(f"❌ Could not persist identity: {exc}", file=sys.stderr)
916 raise SystemExit(ExitCode.INTERNAL_ERROR) from exc
917
918 if json_output:
919 out: dict = {
920 "status": "ok",
921 "hub": hub_url,
922 "hostname": hostname,
923 "key_path": str(key_path),
924 "public_key_b64": pub_b64,
925 "fingerprint": fingerprint,
926 "hd_path": hd_path_str,
927 }
928 if agent_id:
929 out["agent_id"] = agent_id
930 print(json.dumps(out))
931 else:
932 subject = f"agent '{agent_id}'" if agent_id else "human"
933 print(
934 f"✅ Key recovered for {subject}\n"
935 f" Private key: {key_path}\n"
936 f" Fingerprint (SHA-256): {fingerprint}\n"
937 f" HD path: {hd_path_str}",
938 file=sys.stderr,
939 )
940
941
942 # ── register ──────────────────────────────────────────────────────────────────
943
944 def run_register(args: argparse.Namespace) -> None:
945 """Register the local Ed25519 key with a MuseHub instance.
946
947 Performs the full challenge-response flow:
948
949 1. Derives the public key from the local private key.
950 2. Requests a challenge nonce from ``/api/auth/challenge``.
951 3. Signs the raw nonce bytes with the Ed25519 private key.
952 4. Submits the signature to ``/api/auth/verify``.
953 5. Stores the returned identity in ``~/.muse/identity.toml``.
954
955 When ``--agent-id`` is supplied the agent's dedicated key
956 (``~/.muse/keys/{hostname}__{agent_id}.pem``) is used and the entry is
957 stored under the compound key ``"hostname#agent_id"``.
958
959 All progress messages go to stderr. With ``--json``, a
960 :class:`_RegisterJson` object is emitted to stdout on success — agents
961 can capture the identity ID and token path without parsing text.
962
963 Security notes:
964 - The private key never leaves the local machine; only the signature is sent.
965 - The nonce is signed as raw bytes — Ed25519 includes collision-resistant
966 prehashing internally.
967 - ``_json_post`` validates the hub URL scheme before any network I/O.
968 """
969 from muse.core.keypair import (
970 key_path_for,
971 load_private_key,
972 public_key_fingerprint,
973 public_key_to_b64url,
974 sign_bytes,
975 )
976
977 hub: str | None = args.hub
978 handle: str | None = args.handle
979 label: str | None = args.label
980 agent: bool = args.agent
981 agent_id: str | None = getattr(args, "agent_id", None)
982 provisioned_by: str | None = getattr(args, "provisioned_by", None)
983 json_output: bool = args.json_output
984
985 # --agent-id implies --agent
986 if agent_id:
987 agent = True
988
989 hub_url = _resolve_hub(hub)
990 if hub_url is None:
991 print(
992 "❌ No hub URL provided.\n"
993 " Pass --hub <url>, or first run: muse hub connect <url>",
994 file=sys.stderr,
995 )
996 raise SystemExit(ExitCode.USER_ERROR)
997
998 if agent_id and not provisioned_by:
999 print(
1000 "❌ --agent-id requires --provisioned-by <your-handle>.\n"
1001 " Example: muse auth register --agent-id agentception-abc123 "
1002 "--handle agentception-abc123 --provisioned-by gabriel",
1003 file=sys.stderr,
1004 )
1005 raise SystemExit(ExitCode.USER_ERROR)
1006
1007 hostname = hostname_from_url(hub_url)
1008 base_url = _hub_base_url(hub_url)
1009
1010 private_key = load_private_key(hostname, agent_id)
1011 if private_key is None:
1012 keygen_flags = f"--hub {hub_url}"
1013 if agent_id:
1014 keygen_flags += f" --agent-id {agent_id}"
1015 print(
1016 f"❌ No Ed25519 key found for {sanitize_display(hostname)}"
1017 + (f"#{sanitize_display(agent_id)}" if agent_id else "") + ".\n"
1018 f" Generate one first: muse auth keygen {keygen_flags}",
1019 file=sys.stderr,
1020 )
1021 raise SystemExit(ExitCode.USER_ERROR)
1022
1023 public_key = private_key.public_key()
1024 fingerprint = public_key_fingerprint(public_key)
1025 pub_b64 = public_key_to_b64url(public_key)
1026
1027 # Step 1: request a challenge nonce.
1028 print(f" → Requesting challenge from {sanitize_display(base_url)} …", file=sys.stderr)
1029 challenge_resp = _post_challenge(base_url, {
1030 "fingerprint": fingerprint,
1031 "algorithm": "ed25519",
1032 })
1033
1034 challenge_token = challenge_resp.get("challengeToken") or challenge_resp.get("challenge_token") or ""
1035 if not challenge_token:
1036 print(
1037 "❌ Hub returned an invalid challenge response (missing challengeToken).",
1038 file=sys.stderr,
1039 )
1040 raise SystemExit(ExitCode.USER_ERROR)
1041
1042 is_new_key = challenge_resp.get("isNewKey") or challenge_resp.get("is_new_key") or False
1043
1044 if is_new_key and not handle:
1045 print(
1046 "❌ This key is not yet registered. "
1047 "Pass --handle <username> to register it.",
1048 file=sys.stderr,
1049 )
1050 raise SystemExit(ExitCode.USER_ERROR)
1051
1052 nonce_hex = challenge_token
1053 if not nonce_hex or not all(c in "0123456789abcdef" for c in nonce_hex):
1054 print(f"❌ Hub returned an invalid challenge (expected hex nonce).", file=sys.stderr)
1055 raise SystemExit(ExitCode.USER_ERROR)
1056
1057 try:
1058 nonce_bytes = bytes.fromhex(nonce_hex)
1059 except ValueError as exc:
1060 print(f"❌ Could not decode nonce: {sanitize_display(str(exc))}", file=sys.stderr)
1061 raise SystemExit(ExitCode.USER_ERROR) from exc
1062
1063 # Step 2: sign the nonce.
1064 signature_b64 = sign_bytes(private_key, nonce_bytes)
1065
1066 # Step 3: submit the signed nonce.
1067 print(f" → Submitting signature to {sanitize_display(base_url)} …", file=sys.stderr)
1068 verify_resp = _post_verify(base_url, {
1069 "challenge_token": challenge_token,
1070 "public_key_b64": pub_b64,
1071 "signature_b64": signature_b64,
1072 "handle": handle,
1073 "label": label,
1074 })
1075
1076 returned_handle = verify_resp.get("handle") or handle or ""
1077 identity_id = verify_resp.get("identityId") or verify_resp.get("identity_id") or ""
1078 is_new_identity = verify_resp.get("isNewIdentity") or verify_resp.get("is_new_identity") or False
1079
1080 if not returned_handle:
1081 print("❌ Hub returned an invalid verify response (missing handle).", file=sys.stderr)
1082 raise SystemExit(ExitCode.USER_ERROR)
1083
1084 identity_type = "agent" if agent else "human"
1085
1086 # Store handle + key_path + fingerprint.
1087 # For agent keys, store under the compound key "hostname#agent_id" so that
1088 # human and agent entries coexist in identity.toml without collision.
1089 key_path = str(key_path_for(hostname, agent_id))
1090 entry: IdentityEntry = {
1091 "type": identity_type,
1092 "handle": returned_handle,
1093 "key_path": key_path,
1094 "algorithm": "ed25519",
1095 "fingerprint": fingerprint,
1096 }
1097 if provisioned_by:
1098 entry["provisioned_by"] = provisioned_by
1099
1100 # Preserve HD derivation path written by `muse auth keygen`.
1101 # The mnemonic lives in the OS keychain and does not need transferring.
1102 existing = load_identity(hub_url, agent_id=agent_id)
1103 if existing and existing.get("hd_path"):
1104 entry["hd_path"] = existing["hd_path"]
1105
1106 try:
1107 save_identity(hub_url, entry, agent_id=agent_id)
1108 except OSError as exc:
1109 print(f"❌ Could not write credentials: {exc}", file=sys.stderr)
1110 raise SystemExit(ExitCode.INTERNAL_ERROR) from exc
1111
1112 action = "registered" if is_new_identity else "authenticated"
1113 identity_path = str(get_identity_path())
1114
1115 if json_output:
1116 result: _RegisterJson = {
1117 "status": action,
1118 "hub": hub_url,
1119 "handle": returned_handle,
1120 "identity_id": identity_id,
1121 "identity_type": identity_type,
1122 "fingerprint": fingerprint,
1123 "token_stored": False,
1124 "identity_path": identity_path,
1125 }
1126 print(json.dumps(result))
1127 else:
1128 action_cap = action.capitalize()
1129 prov_line = f"\n Provisioned by: {provisioned_by}" if provisioned_by else ""
1130 print(
1131 f"\n✅ {action_cap} as '{returned_handle}' on {hub_url}{prov_line}\n"
1132 f" Identity ID: {identity_id}\n"
1133 f" Auth method: ed25519 (key fingerprint: {fingerprint[:16]}…)\n"
1134 f" Key path: {key_path}\n"
1135 f" Identity stored in: {identity_path}",
1136 file=sys.stderr,
1137 )
1138
1139
1140 # ── whoami ────────────────────────────────────────────────────────────────────
1141
1142 def run_whoami(args: argparse.Namespace) -> None:
1143 """Show the identity stored in ``~/.muse/identity.toml`` for a hub.
1144
1145 Exits non-zero when no identity is stored so agents can branch on
1146 authentication status::
1147
1148 muse auth whoami --hub http://localhost:10003 --json \\
1149 || muse auth register --hub http://localhost:10003 --handle my-agent --agent
1150
1151 JSON output (``--json``)
1152 ------------------------
1153 Emits a :class:`_WhoamiJson` object to stdout::
1154
1155 {
1156 "hub": "<hostname>",
1157 "type": "human" | "agent",
1158 "handle": "<registered handle>",
1159 "key_set": true | false,
1160 "capabilities": ["read", ...] ← only present when non-empty
1161 }
1162
1163 With ``--all --json``, emits a **single JSON array** (one object per hub)
1164 so agents can pipe to ``jq``::
1165
1166 muse auth whoami --all --json | jq '.[] | select(.type == "agent")'
1167
1168 Exit codes
1169 ----------
1170 0 Identity found and printed.
1171 1 No hub configured, or no identity stored for that hub.
1172 """
1173 hub: str | None = args.hub
1174 all_hubs: bool = args.all_hubs
1175 json_output: bool = args.json_output
1176
1177 if all_hubs:
1178 identities = list_all_identities()
1179 if not identities:
1180 print("No identities stored. Run `muse auth keygen` + `muse auth register`.", file=sys.stderr)
1181 raise SystemExit(ExitCode.USER_ERROR)
1182 if json_output:
1183 entries = [
1184 {
1185 "hub": hostname,
1186 "type": e.get("type") or "",
1187 "handle": e.get("handle") or "",
1188 "fingerprint": e.get("fingerprint") or "",
1189 "key_set": bool(e.get("key_path")),
1190 "capabilities": list(e.get("capabilities") or []),
1191 }
1192 for hostname, e in sorted(identities.items())
1193 ]
1194 print(json.dumps(entries))
1195 else:
1196 for hostname, stored_entry in sorted(identities.items()):
1197 _display_entry(hostname, stored_entry, json_output=False)
1198 return
1199
1200 hub_url = _resolve_hub(hub)
1201 if hub_url is None:
1202 # No hub in args or repo config — fall back to showing all identities.
1203 identities = list_all_identities()
1204 if not identities:
1205 print("No identities stored. Run `muse auth keygen` + `muse auth register`.", file=sys.stderr)
1206 raise SystemExit(ExitCode.USER_ERROR)
1207 if json_output:
1208 entries = [
1209 {
1210 "hub": hostname,
1211 "type": e.get("type") or "",
1212 "handle": e.get("handle") or "",
1213 "fingerprint": e.get("fingerprint") or "",
1214 "key_set": bool(e.get("key_path")),
1215 "capabilities": list(e.get("capabilities") or []),
1216 }
1217 for hostname, e in sorted(identities.items())
1218 ]
1219 print(json.dumps(entries))
1220 else:
1221 for hostname, stored_entry in sorted(identities.items()):
1222 _display_entry(hostname, stored_entry, json_output=False)
1223 return
1224
1225 single_entry = load_identity(hub_url)
1226 if single_entry is None:
1227 print(
1228 f"No identity stored for {sanitize_display(hub_url)}.\n"
1229 f"Run: muse auth keygen --hub {hub_url} && muse auth register --hub {hub_url} --handle <your-handle>",
1230 file=sys.stderr,
1231 )
1232 raise SystemExit(ExitCode.USER_ERROR)
1233
1234 _display_entry(hostname_from_url(hub_url), single_entry, json_output=json_output)
1235
1236
1237 # ── logout ────────────────────────────────────────────────────────────────────
1238
1239 def run_logout(args: argparse.Namespace) -> None:
1240 """Remove stored credentials for one hub or all hubs.
1241
1242 Deletes the matching entry (or entries) from ``~/.muse/identity.toml``.
1243 The hub URL in ``.muse/config.toml`` is **not** touched — use
1244 ``muse hub disconnect`` to remove the hub association from the repo too.
1245
1246 Idempotent
1247 ----------
1248 Calling ``logout`` when no identity is stored does **not** fail. The
1249 JSON response uses ``status: "nothing_to_do"`` so agents can distinguish
1250 "was logged in, now removed" from "was not logged in" without special-casing
1251 exit codes::
1252
1253 muse auth logout --hub http://localhost:10003 --json
1254 # → {"status": "nothing_to_do", "hubs": [], "count": 0} (exit 0)
1255
1256 Agent quickstart
1257 ----------------
1258 ::
1259
1260 muse auth logout --all --json # clear every hub in one shot
1261
1262 JSON output (``--json``)
1263 ------------------------
1264 ::
1265
1266 {
1267 "status": "ok" | "nothing_to_do",
1268 "hubs": ["hostname1", ...], ← sorted; empty on nothing_to_do
1269 "count": <int> ← 0 on nothing_to_do
1270 }
1271
1272 All diagnostic messages go to stderr regardless of ``--json``.
1273
1274 Performance note
1275 ----------------
1276 ``--all`` performs a single atomic read-modify-write via
1277 :func:`~muse.core.identity.clear_all_identities`, regardless of how many
1278 hubs are stored. It does **not** call :func:`~muse.core.identity.clear_identity`
1279 in a loop.
1280
1281 Exit codes
1282 ----------
1283 0 Success (credentials removed or nothing to do).
1284 1 No hub URL could be resolved (no ``--hub`` flag and no hub in config).
1285 """
1286 hub: str | None = args.hub
1287 all_hubs: bool = args.all_hubs
1288 json_output: bool = args.json_output
1289
1290 if all_hubs:
1291 removed_hubs = clear_all_identities()
1292 if not removed_hubs:
1293 if json_output:
1294 out: _LogoutJson = {"status": "nothing_to_do", "hubs": [], "count": 0}
1295 print(json.dumps(out))
1296 else:
1297 print("No identities stored.", file=sys.stderr)
1298 return
1299 if json_output:
1300 result: _LogoutJson = {
1301 "status": "ok",
1302 "hubs": removed_hubs, # already sorted by clear_all_identities
1303 "count": len(removed_hubs),
1304 }
1305 print(json.dumps(result))
1306 else:
1307 hub_list = ", ".join(sanitize_display(h) for h in removed_hubs)
1308 print(
1309 f"✅ Logged out from {len(removed_hubs)} hub(s): {hub_list}",
1310 file=sys.stderr,
1311 )
1312 return
1313
1314 hub_url = _resolve_hub(hub)
1315 if hub_url is None:
1316 print(
1317 "❌ No hub URL provided.\n"
1318 " Pass --hub <url>, or first run: muse hub connect <url>",
1319 file=sys.stderr,
1320 )
1321 raise SystemExit(ExitCode.USER_ERROR)
1322
1323 removed = clear_identity(hub_url)
1324 hub_display = hostname_from_url(hub_url)
1325
1326 if json_output:
1327 if removed:
1328 single_result: _LogoutJson = {
1329 "status": "ok",
1330 "hubs": [sanitize_display(hub_display)],
1331 "count": 1,
1332 }
1333 else:
1334 single_result = {"status": "nothing_to_do", "hubs": [], "count": 0}
1335 print(json.dumps(single_result))
1336 else:
1337 if removed:
1338 print(f"✅ Logged out from {sanitize_display(hub_display)}.", file=sys.stderr)
1339 else:
1340 print(
1341 f"No identity stored for {sanitize_display(hub_display)} — nothing to do.",
1342 file=sys.stderr,
1343 )
1344
1345
1346 # ── show ──────────────────────────────────────────────────────────────────────
1347
1348 def _show_identity_detail(
1349 hostname: str,
1350 entry: IdentityEntry,
1351 *,
1352 json_output: bool,
1353 ) -> None:
1354 """Print detailed identity info including HD derivation paths.
1355
1356 For HD identities the mnemonic is read from the entry only to derive the
1357 AVAX C-Chain address — it is **never** printed. All diagnostic text goes
1358 to stderr; ``--json`` output goes to stdout.
1359
1360 Args:
1361 hostname: Hub hostname (used as the display / JSON ``hub`` key).
1362 entry: Identity entry loaded from ``~/.muse/identity.toml``.
1363 json_output: When ``True``, emit a JSON object to stdout.
1364 """
1365 from muse.core.hdkeys import (
1366 DOMAIN_IDENTITY,
1367 DOMAIN_PAYMENTS,
1368 ENTITY_AGENT,
1369 ENTITY_HUMAN,
1370 muse_path,
1371 )
1372
1373 mnemonic = entry.get("mnemonic") or ""
1374 hd_path = entry.get("hd_path") or ""
1375 handle = entry.get("handle") or ""
1376 itype = entry.get("type") or ""
1377 fingerprint = entry.get("fingerprint") or ""
1378
1379 derived_paths: dict[str, str] = {
1380 "identity_msign": muse_path(DOMAIN_IDENTITY, ENTITY_HUMAN),
1381 "payments_mpay": muse_path(DOMAIN_PAYMENTS, ENTITY_HUMAN),
1382 "avax_c_chain": "m/44'/60'/0'/0/0",
1383 "agent_slot_0": muse_path(DOMAIN_IDENTITY, ENTITY_AGENT, entity_id=0),
1384 }
1385
1386 avax_address: str | None = None
1387 mnemonic_word_count: int = 0
1388 if hd_path and mnemonic:
1389 from muse.core.bip39 import mnemonic_to_seed
1390 from muse.core.secp256k1_sign import avax_c_chain_address, derive_avax_key
1391
1392 words = mnemonic.strip().split()
1393 mnemonic_word_count = len(words)
1394 try:
1395 seed = mnemonic_to_seed(mnemonic)
1396 avax_key = derive_avax_key(seed)
1397 avax_address = avax_c_chain_address(avax_key.public_key)
1398 except Exception:
1399 pass # non-fatal — omit address rather than crashing
1400
1401 if json_output:
1402 out: _ShowJson = {
1403 "hub": hostname,
1404 "handle": handle,
1405 "type": itype,
1406 "fingerprint": fingerprint,
1407 }
1408 if hd_path:
1409 out["hd_path"] = hd_path
1410 out["mnemonic_word_count"] = mnemonic_word_count
1411 out["derived_paths"] = derived_paths
1412 if avax_address:
1413 out["avax_c_chain_address"] = avax_address
1414 print(json.dumps(out))
1415 else:
1416 print("", file=sys.stderr)
1417 print(f" Hub: {sanitize_display(hostname)}", file=sys.stderr)
1418 print(f" Handle: {sanitize_display(handle) or '—'}", file=sys.stderr)
1419 print(f" Type: {itype or 'unknown'}", file=sys.stderr)
1420 print(f" Fingerprint: {fingerprint[:16] + '…' if fingerprint else '—'}", file=sys.stderr)
1421 if hd_path:
1422 print(f" HD path: {hd_path}", file=sys.stderr)
1423 print(f" Mnemonic: {mnemonic_word_count} words (phrase not shown)", file=sys.stderr)
1424 print("", file=sys.stderr)
1425 print(" Derived paths:", file=sys.stderr)
1426 for name, path in derived_paths.items():
1427 print(f" {name:<20} {path}", file=sys.stderr)
1428 if avax_address:
1429 print(f" AVAX C-Chain: {avax_address}", file=sys.stderr)
1430 print("", file=sys.stderr)
1431
1432
1433 def run_show(args: argparse.Namespace) -> None:
1434 """Display full identity details for one or all hubs.
1435
1436 For HD identities, the six-level Muse derivation paths are printed together
1437 with the AVAX C-Chain address derived from the BIP39 mnemonic. The mnemonic
1438 phrase itself is **never** printed by this command — only the word count is shown.
1439
1440 JSON output (``--json``)
1441 ------------------------
1442 ::
1443
1444 {
1445 "hub": "<hostname>",
1446 "handle": "<handle>",
1447 "type": "human" | "agent",
1448 "fingerprint": "<sha256hex>",
1449 "hd_path": "m/1075233755'/…", ← HD only
1450 "mnemonic_word_count": 12, ← HD only
1451 "derived_paths": { ← HD only
1452 "identity_msign": "m/…",
1453 "payments_mpay": "m/…",
1454 "avax_c_chain": "m/44'/60'/0'/0/0",
1455 "agent_slot_0": "m/…"
1456 },
1457 "avax_c_chain_address": "0x…" ← HD only
1458 }
1459
1460 Without ``--hub``, the command falls back to showing all stored identities.
1461
1462 Exit codes
1463 ----------
1464 0 Identity found and displayed.
1465 1 No hub configured and no identities stored.
1466 """
1467 hub: str | None = args.hub
1468 json_output: bool = args.json_output
1469
1470 hub_url = _resolve_hub(hub)
1471 if hub_url is None:
1472 identities = list_all_identities()
1473 if not identities:
1474 print("No identities stored. Run `muse auth keygen` + `muse auth register`.", file=sys.stderr)
1475 raise SystemExit(ExitCode.USER_ERROR)
1476 for hostname, stored_entry in sorted(identities.items()):
1477 _show_identity_detail(hostname, stored_entry, json_output=json_output)
1478 return
1479
1480 entry = load_identity(hub_url)
1481 if entry is None:
1482 print(
1483 f"No identity stored for {sanitize_display(hub_url)}.\n"
1484 f"Run: muse auth keygen --hub {hub_url} && muse auth register --hub {hub_url} --handle <your-handle>",
1485 file=sys.stderr,
1486 )
1487 raise SystemExit(ExitCode.USER_ERROR)
1488
1489 _show_identity_detail(hostname_from_url(hub_url), entry, json_output=json_output)
File History 1 commit
sha256:2fa778aba8ab0ec15295b8624c6480a573482ffc9c206a6d9546f1c41d2c2b7b feat: supercharge muse blame + remove --porcelain everywhere Human patch 164 days ago