gabriel / muse public
auth.py python
2,309 lines 94.3 KB
Raw
sha256:ae1eca169c5efe00a6415971ed0e7259f68df3e3ce23935c4b1b714c2df6a240 Merge branch 'dev' into main Human 22 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 "public_key_b64": "<b64url>", "fingerprint": "sha256:<64-hex>",
57 "hd_path": "<slip0010-path>"}
58
59 ``muse auth register --json``::
60
61 {"status": "registered"|"authenticated", "hub": "<url>",
62 "handle": "<name>", "identity_type": "human"|"agent",
63 "fingerprint": "sha256:<64-hex>", "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 import argparse
76 import json
77 import logging
78 import os
79 import pathlib
80 import sys
81 import urllib.error
82 import urllib.parse
83 import urllib.request
84 from typing import TypedDict
85
86 from muse.cli.config import get_hub_url
87 from muse.core.types import DEFAULT_SIGN_ALGO, blob_id
88 from muse.core.envelope import EnvelopeJson, make_envelope
89 from muse.core.errors import ExitCode
90 from muse.core.timing import start_timer
91 from muse.core.identity import (
92 IdentityEntry,
93 clear_all_identities,
94 clear_identity,
95 get_identity_path,
96 hostname_from_url,
97 list_all_identities,
98 load_identity,
99 save_identity,
100 )
101 from muse.core.validation import sanitize_display
102
103 logger = logging.getLogger(__name__)
104
105 type _DerivedPaths = dict[str, str]
106
107 # Module-level hooks so tests can monkeypatch without importing getpass at module load.
108 def _isatty() -> bool:
109 return sys.stdin.isatty()
110
111 def _stderr_isatty() -> bool:
112 return sys.stderr.isatty()
113
114 def _getpass(prompt: str = "") -> str:
115 import getpass as _gp
116 return _gp.getpass(prompt)
117
118 def _read_mnemonic_securely(fd: int | None = None) -> str:
119 """Read a BIP39 mnemonic without exposing it in process args or shell history.
120
121 Input is accepted through three channels in priority order:
122
123 1. *fd* (``--mnemonic-fd N``) — read one line from file descriptor *N*,
124 close it immediately. Used by orchestrators that pass secrets via pipe.
125 2. Non-TTY stdin — read one line from ``sys.stdin``. Triggered by piped
126 input (``echo "..." | muse auth recover``) or heredoc redirects.
127 3. TTY stdin — prompt via :func:`getpass.getpass` with echo disabled.
128 The prompt and the phrase itself are never written to the terminal
129 buffer that shell history reads.
130
131 Args:
132 fd: File descriptor number to read from, or ``None`` to use stdin / TTY.
133
134 Returns:
135 The stripped mnemonic phrase.
136
137 Raises:
138 SystemExit(1): If the fd is invalid, unreadable, or the input is empty.
139 """
140 phrase: str
141
142 if fd is not None:
143 if fd < 3:
144 print(
145 f"❌ --mnemonic-fd {fd} is invalid: "
146 "fd 0 (stdin), 1 (stdout), and 2 (stderr) are reserved and must not be used.",
147 file=sys.stderr,
148 )
149 raise SystemExit(ExitCode.USER_ERROR)
150 try:
151 with os.fdopen(fd, "r", encoding="utf-8") as fh:
152 phrase = fh.readline().strip()
153 except OSError as exc:
154 print(f"muse auth: cannot read from fd {fd}: {exc}", file=sys.stderr)
155 raise SystemExit(ExitCode.USER_ERROR)
156 elif not sys.stdin.isatty():
157 phrase = sys.stdin.readline().strip()
158 else:
159 import getpass
160 try:
161 phrase = getpass.getpass("Enter BIP39 mnemonic: ").strip()
162 except (KeyboardInterrupt, EOFError):
163 print("", file=sys.stderr)
164 raise SystemExit(ExitCode.USER_ERROR)
165
166 if not phrase:
167 print("muse auth: mnemonic input was empty.", file=sys.stderr)
168 raise SystemExit(ExitCode.USER_ERROR)
169
170 return phrase
171
172 def _resolve_passphrase(args: "argparse.Namespace") -> str:
173 """Return the BIP-39 passphrase using only safe delivery channels.
174
175 Priority (highest to lowest):
176 1. ``--passphrase-fd N`` — read from pipe fd; never visible in ``ps``/procfs.
177 2. ``MUSE_BIP39_PASSPHRASE`` env var — visible to process owner in
178 ``/proc/pid/environ`` but not world-readable.
179 3. Interactive ``getpass`` prompt — TTY only, no echo.
180 4. Empty string — standard BIP-39 behaviour (no passphrase).
181
182 The passphrase is never stored — callers must supply it at every derivation.
183 """
184 # 1. --passphrase-fd
185 passphrase_fd: int | None = getattr(args, "passphrase_fd", None)
186 if passphrase_fd is not None:
187 if passphrase_fd < 3:
188 print(
189 f"❌ --passphrase-fd {passphrase_fd} is invalid: "
190 "fd 0 (stdin), 1 (stdout), and 2 (stderr) are reserved and must not be used.",
191 file=sys.stderr,
192 )
193 raise SystemExit(ExitCode.USER_ERROR)
194 try:
195 raw = os.read(passphrase_fd, 4096)
196 os.close(passphrase_fd)
197 except OSError as exc:
198 print(f"muse auth: cannot read passphrase from fd {passphrase_fd}: {exc}", file=sys.stderr)
199 raise SystemExit(ExitCode.USER_ERROR)
200 return raw.decode("utf-8").rstrip("\n")
201
202 # 2. env var
203 env_val = os.environ.get("MUSE_BIP39_PASSPHRASE")
204 if env_val is not None:
205 logger.warning(
206 "MUSE_BIP39_PASSPHRASE is set: passphrase is visible to the process owner "
207 "in /proc/pid/environ. Prefer --passphrase-fd N (pipe fd) for production use — "
208 "it never appears in the process environment."
209 )
210 return env_val
211
212 # 3. interactive TTY prompt
213 if _isatty():
214 try:
215 return _getpass("BIP-39 passphrase (leave blank for none): ")
216 except (KeyboardInterrupt, EOFError):
217 print("", file=sys.stderr)
218 raise SystemExit(ExitCode.USER_ERROR)
219
220 # 4. empty — non-interactive, no env var, no fd
221 return ""
222
223 # Auth endpoints on the hub (relative to the hub base URL).
224 _CHALLENGE_PATH = "/api/auth/challenge"
225 _VERIFY_PATH = "/api/auth/verify"
226
227 # Hard cap on response size to prevent OOM from a compromised hub.
228 _MAX_RESPONSE_BYTES = 1 * 1024 * 1024 # 1 MiB
229
230 # Only allow http and https — no file://, ftp://, data://, etc.
231 _ALLOWED_SCHEMES = frozenset({"http", "https"})
232
233 # ── TypedDicts ────────────────────────────────────────────────────────────────
234
235 class _KeygenJson(EnvelopeJson, total=False):
236 """JSON schema for ``muse auth keygen --json`` (human key)."""
237
238 status: str # "ok"
239 hub: str # hub URL
240 hostname: str # extracted hostname
241 public_key_b64: str # base64url-encoded public key
242 fingerprint: str # sha256:<64-hex> fingerprint
243 hd_path: str # SLIP-0010 derivation path
244 mnemonic_word_count: int # number of BIP39 mnemonic words
245
246 class _AgentKeygenJson(EnvelopeJson):
247 """JSON schema for ``muse auth keygen --agent-id --json``."""
248
249 status: str
250 hub: str
251 hostname: str
252 agent_id: str
253 public_key_b64: str
254 fingerprint: str
255 hd_path: str
256 slot: int
257 provisioned_by_fingerprint: str
258
259 class _RegisterJson(EnvelopeJson):
260 """JSON schema for ``muse auth register --json``."""
261
262 status: str # "registered" | "authenticated"
263 hub: str # hub URL
264 handle: str # registered username
265 identity_id: str # hub-assigned ID
266 identity_type: str # "human" | "agent"
267 fingerprint: str # SHA-256 hex fingerprint of the public key
268 token_stored: bool # always true on success
269 identity_path: str # path to ~/.muse/identity.toml
270
271 class _WhoamiJson(TypedDict, total=False):
272 """JSON schema for a single identity entry (used in list output)."""
273
274 hub: str # hostname key
275 type: str # "human" | "agent" | ""
276 handle: str # registered handle
277 fingerprint: str # SHA-256 hex of the public key
278 key_set: bool # true if an Ed25519 key is stored
279 capabilities: list[str]
280 provisioned_by: str # agent only: handle of the human who provisioned this key
281 hd_path: str # HD keys only: SLIP-0010 derivation path
282
283 class _WhoamiSingleJson(EnvelopeJson, total=False):
284 """JSON schema for ``muse auth whoami --json`` (single-hub output)."""
285
286 hub: str
287 type: str
288 handle: str
289 fingerprint: str
290 key_set: bool
291 capabilities: list[str]
292 provisioned_by: str
293 hd_path: str
294
295 class _WhoamiAllJson(EnvelopeJson):
296 """JSON schema for ``muse auth whoami --json`` (multi-hub listing)."""
297
298 identities: list[_WhoamiJson]
299
300 class _ShowJson(TypedDict, total=False):
301 """JSON schema for a single identity detail entry (used in show output)."""
302
303 hub: str
304 handle: str
305 type: str
306 fingerprint: str
307 algorithm: str # "ed25519" | "ml-dsa-65"
308 provisioned_by: str # agent only: handle of provisioning human
309 provisioned_by_fingerprint: str # agent only: fingerprint of provisioning key
310 hd_path: str
311 mnemonic_word_count: int
312 derived_paths: _DerivedPaths
313 avax_c_chain_address: str
314
315 class _ShowOutputJson(EnvelopeJson, total=False):
316 """JSON schema for ``muse auth show --json``."""
317
318 hub: str
319 handle: str
320 type: str
321 fingerprint: str
322 algorithm: str
323 provisioned_by: str
324 provisioned_by_fingerprint: str
325 hd_path: str
326 mnemonic_word_count: int
327 derived_paths: _DerivedPaths
328 avax_c_chain_address: str
329
330 class _LogoutJson(EnvelopeJson):
331 """JSON schema for ``muse auth logout --json``."""
332
333 status: str # "ok" | "nothing_to_do"
334 hubs: list[str] # hostnames logged out from
335 count: int # number of identities removed
336
337 class _RecoverJson(EnvelopeJson, total=False):
338 """JSON schema for ``muse auth recover --json``."""
339
340 status: str
341 hub: str
342 hostname: str
343 public_key_b64: str
344 fingerprint: str
345 hd_path: str
346 agent_id: str
347
348 class _RotateJson(EnvelopeJson):
349 """JSON schema for ``muse auth rotate --json``."""
350
351 status: str
352 hub: str
353 hostname: str
354 fingerprint: str
355 hd_path: str
356 rotation_index: int
357
358 class _CleanupKeysJson(EnvelopeJson):
359 """JSON schema for ``muse auth cleanup-keys --json``."""
360
361 destroyed: list[str] # absolute paths of PEM files overwritten and deleted
362 count: int
363
364 class _SecurityCheckJson(EnvelopeJson):
365 """JSON schema for ``muse auth security-check --json``."""
366
367 mnemonic_in_keychain: bool
368 no_pem_files: bool
369 no_key_path_in_identity: bool
370 fingerprint_matches_mnemonic: bool
371 pem_files_found: list[str]
372 key_path_entries: list[str]
373 ok: bool
374
375 class _JsonPayload(TypedDict, total=False):
376 """Generic JSON request payload for hub auth endpoints (all fields optional str)."""
377
378 fingerprint: str
379 algorithm: str
380 challenge_token: str
381 public_key_b64: str
382 signature_b64: str
383 handle: str
384 label: str
385
386 class _ChallengeResp(TypedDict, total=False):
387 """Parsed response from the hub challenge endpoint (snake_case canonical)."""
388
389 challenge_token: str
390 is_new_key: bool
391 expires_in: int
392 algorithm: str
393
394 class _VerifyResp(TypedDict, total=False):
395 """Parsed response from the hub verify/add-key endpoint (snake_case canonical)."""
396
397 handle: str
398 identity_id: str
399 is_new_identity: bool
400 auth_method: str
401
402 # ── HTTP helpers ──────────────────────────────────────────────────────────────
403
404 def _hub_base_url(hub_url: str) -> str:
405 """Extract the base URL (scheme + host + port) from a full hub URL.
406
407 Examples::
408
409 "https://musehub.ai/gabriel/muse" → "https://musehub.ai"
410 "https://localhost:1337" → "https://localhost:1337"
411
412 Raises:
413 SystemExit: If the URL scheme is not ``http`` or ``https``.
414 """
415 parsed = urllib.parse.urlparse(hub_url)
416 if parsed.scheme.lower() not in _ALLOWED_SCHEMES:
417 print(
418 f"❌ Hub URL scheme '{sanitize_display(parsed.scheme)}' is not allowed. "
419 f"Use http or https.",
420 file=sys.stderr,
421 )
422 raise SystemExit(ExitCode.USER_ERROR)
423 port_str = f":{parsed.port}" if parsed.port else ""
424 return f"{parsed.scheme}://{parsed.hostname}{port_str}"
425
426 def _json_post_raw(
427 base_url: str,
428 path: str,
429 payload: _JsonPayload,
430 extra_headers: "dict[str, str] | None" = None,
431 ) -> _JsonPayload:
432 """POST *payload* as JSON and return the raw parsed response dict.
433
434 Private implementation — call :func:`_post_challenge` or
435 :func:`_post_verify` instead. Validates the URL scheme before any
436 network I/O to prevent SSRF.
437
438 Args:
439 base_url: Hub base URL (e.g. ``"https://musehub.ai"``).
440 path: Endpoint path (e.g. ``"/api/auth/challenge"``).
441 payload: Dict to serialise as the JSON body (``None`` values omitted).
442 extra_headers: Optional additional HTTP headers (e.g. ``Authorization``
443 for MSign-authenticated endpoints).
444
445 Returns:
446 Parsed JSON response body as a dict.
447
448 Raises:
449 SystemExit: On invalid URL scheme, HTTP error, or network failure.
450 """
451 scheme = urllib.parse.urlparse(base_url).scheme.lower()
452 if scheme not in _ALLOWED_SCHEMES:
453 print(
454 f"❌ Hub URL scheme '{sanitize_display(scheme)}' is not allowed. "
455 f"Use http or https.",
456 file=sys.stderr,
457 )
458 raise SystemExit(ExitCode.USER_ERROR)
459
460 url = f"{base_url.rstrip('/')}{path}"
461 clean_payload = {k: v for k, v in payload.items() if v is not None}
462 body_bytes = json.dumps(clean_payload).encode("utf-8")
463 headers: dict[str, str] = {
464 "Content-Type": "application/json",
465 "Accept": "application/json",
466 }
467 if extra_headers:
468 headers.update(extra_headers)
469 req = urllib.request.Request(
470 url=url,
471 data=body_bytes,
472 headers=headers,
473 method="POST",
474 )
475 # For localhost HTTPS use the bundled self-signed CA cert so that
476 # muse auth register works against the local dev hub without requiring
477 # the cert to be installed system-wide.
478 ssl_ctx: "ssl.SSLContext | None" = None
479 parsed_url = urllib.parse.urlparse(base_url)
480 if parsed_url.scheme == "https" and parsed_url.hostname in ("localhost", "127.0.0.1"):
481 import ssl as _ssl
482 from muse.core.transport import _mkcert_ca
483 ca = _mkcert_ca()
484 if ca is not None:
485 ssl_ctx = _ssl.create_default_context(cafile=str(ca))
486 try:
487 with urllib.request.urlopen(req, timeout=30, context=ssl_ctx) as resp: # noqa: S310
488 raw: bytes = resp.read(_MAX_RESPONSE_BYTES + 1)
489 except urllib.error.HTTPError as exc:
490 try:
491 err_body = exc.read().decode("utf-8", errors="replace")[:400]
492 except Exception: # noqa: BLE001
493 err_body = ""
494 print(
495 f"❌ HTTP {exc.code} from {sanitize_display(url)}: {sanitize_display(err_body)}",
496 file=sys.stderr,
497 )
498 raise SystemExit(ExitCode.USER_ERROR) from exc
499 except urllib.error.URLError as exc:
500 print(
501 f"❌ Network error contacting {sanitize_display(url)}: "
502 f"{sanitize_display(str(exc.reason))}",
503 file=sys.stderr,
504 )
505 raise SystemExit(ExitCode.USER_ERROR) from exc
506
507 if len(raw) > _MAX_RESPONSE_BYTES:
508 print(f"❌ Response from {sanitize_display(url)} exceeds size limit.", file=sys.stderr)
509 raise SystemExit(ExitCode.USER_ERROR)
510
511 parsed = json.loads(raw)
512 if not isinstance(parsed, dict):
513 print(f"❌ Unexpected response shape from {sanitize_display(url)}.", file=sys.stderr)
514 raise SystemExit(ExitCode.USER_ERROR)
515 return parsed
516
517 def _hub_delete(url: str, auth_header: str, ssl_ctx: "object | None" = None) -> None:
518 """Send a signed DELETE request to a hub endpoint.
519
520 Raises ``urllib.error.HTTPError`` / ``urllib.error.URLError`` on failure.
521 Extracted so tests can spy on ``muse.cli.commands.auth._hub_delete``
522 instead of reaching into ``urllib.request``.
523 """
524 req = urllib.request.Request(
525 url,
526 headers={"Authorization": auth_header, "Accept": "application/json"},
527 method="DELETE",
528 )
529 with urllib.request.urlopen(req, timeout=10, context=ssl_ctx) as _resp: # noqa: S310
530 pass
531
532 def _post_challenge(base_url: str, payload: _JsonPayload) -> _ChallengeResp:
533 """POST to the challenge endpoint and return a typed response."""
534 raw = _json_post_raw(base_url, _CHALLENGE_PATH, payload)
535 return _ChallengeResp(
536 challenge_token=str(raw.get("challenge_token") or ""),
537 is_new_key=bool(raw.get("is_new_key")),
538 expires_in=int(raw.get("expires_in") or 300),
539 algorithm=str(raw.get("algorithm") or ""),
540 )
541
542 def _post_verify(base_url: str, payload: _JsonPayload) -> _VerifyResp:
543 """POST to the verify endpoint and return a typed response."""
544 raw = _json_post_raw(base_url, _VERIFY_PATH, payload)
545 return _VerifyResp(
546 handle=str(raw.get("handle") or ""),
547 identity_id=str(raw.get("identity_id") or ""),
548 is_new_identity=bool(raw.get("is_new_identity")),
549 auth_method=str(raw.get("auth_method") or ""),
550 )
551
552 # ── Helpers ───────────────────────────────────────────────────────────────────
553
554 def _resolve_hub(hub_opt: str | None, repo_root: pathlib.Path | None = None) -> str | None:
555 """Return the hub URL: explicit option → repo config → None."""
556 if hub_opt:
557 return hub_opt
558 return get_hub_url(repo_root)
559
560 def _display_entry(hostname: str, entry: IdentityEntry, *, json_output: bool, elapsed: "Callable[[], float] | None" = None) -> None:
561 """Print an identity entry. JSON → stdout; human-readable → stderr."""
562 from muse.core.timing import start_timer
563 if elapsed is None:
564 elapsed = start_timer()
565 itype = entry.get("type") or ""
566 handle = entry.get("handle") or ""
567 fingerprint = entry.get("fingerprint") or ""
568 caps = list(entry.get("capabilities") or [])
569 provisioned_by = entry.get("provisioned_by") or ""
570 hd_path = entry.get("hd_path") or ""
571
572 if json_output:
573 out: _WhoamiSingleJson = _WhoamiSingleJson(
574 **make_envelope(elapsed),
575 hub=hostname,
576 type=itype,
577 handle=handle,
578 fingerprint=fingerprint,
579 key_set=bool(hd_path),
580 capabilities=caps,
581 )
582 if provisioned_by:
583 out["provisioned_by"] = provisioned_by
584 if hd_path:
585 out["hd_path"] = hd_path
586 print(json.dumps(out))
587 else:
588 print("", file=sys.stderr)
589 print(f" Hub: {sanitize_display(hostname)}", file=sys.stderr)
590 print(f" Type: {itype or 'unknown'}", file=sys.stderr)
591 print(f" Handle: {sanitize_display(handle) or '—'}", file=sys.stderr)
592 print(f" Fingerprint: {fingerprint or '—'}", file=sys.stderr)
593 print(f" Key: {'keychain' if hd_path else 'not set — run muse auth keygen'}", file=sys.stderr)
594 if hd_path:
595 print(f" HD path: {hd_path}", file=sys.stderr)
596 if caps:
597 print(f" Caps: {' '.join(caps)}", file=sys.stderr)
598 print("", file=sys.stderr)
599
600 # ── register ──────────────────────────────────────────────────────────────────
601
602 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
603 """Register the ``muse auth`` subcommand tree and all its flags.
604
605 Every subcommand accepts ``--json`` for machine-readable output on stdout.
606 All progress and diagnostic messages go to stderr.
607 """
608 parser = subparsers.add_parser(
609 "auth",
610 help="Identity management.",
611 description=__doc__,
612 formatter_class=argparse.RawDescriptionHelpFormatter,
613 )
614 subs = parser.add_subparsers(dest="subcommand", metavar="SUBCOMMAND")
615 subs.required = True
616
617 # ── keygen ───────────────────────────────────────────────────────────────
618 keygen_p = subs.add_parser(
619 "keygen",
620 help="Generate a new Ed25519 keypair for public-key authentication.",
621 description=(
622 "Generates a fresh Ed25519 keypair and stores the private key at\n"
623 "~/.muse/keys/{hostname}.pem (mode 0o600).\n"
624 "The public key fingerprint is printed to stderr for verification.\n"
625 "Run 'muse auth register' afterward to register the key with the hub."
626 ),
627 formatter_class=argparse.RawDescriptionHelpFormatter,
628 )
629 keygen_p.add_argument("--hub", default=None, metavar="URL",
630 help="Hub URL (e.g. https://musehub.ai). Falls back to [hub] url in config.toml.")
631 keygen_p.add_argument("--agent-id", default=None, metavar="AGENT_ID", dest="agent_id",
632 help=(
633 "Generate a dedicated keypair for this agent handle "
634 "(stored at ~/.muse/keys/{hostname}__{agent_id}.pem). "
635 "Omit for the human (operator) key."
636 ))
637 keygen_p.add_argument("--label", default=None, metavar="LABEL",
638 help='Friendly key label, e.g. "MacBook Pro" or "CI agent". Stored locally.')
639 keygen_p.add_argument("--force", action="store_true",
640 help="Overwrite an existing identity entry for this hub without prompting.")
641 keygen_p.add_argument("--destroy-mnemonic", action="store_true", dest="destroy_mnemonic",
642 help=(
643 "Generate fresh entropy and overwrite the existing keychain mnemonic. "
644 "IRREVERSIBLE: all previously derived keys become unrecoverable. "
645 "Requires --force."
646 ))
647 keygen_p.add_argument("--json", "-j", action="store_true", dest="json_out", default=False,
648 help="Emit a JSON object to stdout on success.")
649 keygen_p.add_argument(
650 "--strength", type=int, default=256, dest="hd_strength",
651 metavar="BITS",
652 help=(
653 "BIP39 entropy strength in bits: 128 (12 words), 160 (15), "
654 "192 (18), 224 (21), or 256 (24 words, default)."
655 ),
656 )
657 keygen_p.add_argument(
658 "--language", default="english", dest="hd_language",
659 metavar="LANG",
660 help=(
661 "BIP39 wordlist language for the generated mnemonic "
662 "(e.g. english, spanish, japanese). Default: english."
663 ),
664 )
665 keygen_p.add_argument(
666 "--passphrase-fd", type=int, default=None, dest="passphrase_fd", metavar="N",
667 help=(
668 "Read BIP-39 extension passphrase ('25th word') from file descriptor N. "
669 "Never stored; supply at every derivation. "
670 "Falls back to MUSE_BIP39_PASSPHRASE env var, then interactive prompt on a TTY."
671 ),
672 )
673 keygen_p.set_defaults(func=run_keygen)
674
675 # ── recover ──────────────────────────────────────────────────────────────
676 recover_p = subs.add_parser(
677 "recover",
678 help="Re-derive and overwrite key(s) from a BIP39 mnemonic.",
679 description=(
680 "Re-derives the Ed25519 identity key from a BIP39 mnemonic and\n"
681 "overwrites the local PEM file. Use this to restore keys after\n"
682 "losing the PEM file (e.g. new machine, disk failure).\n\n"
683 "The resulting fingerprint will match the original registration\n"
684 "exactly — no re-registration is needed."
685 ),
686 formatter_class=argparse.RawDescriptionHelpFormatter,
687 )
688 recover_p.add_argument("--hub", default=None, metavar="URL",
689 help="Hub URL. Falls back to [hub] url in config.toml.")
690 recover_p.add_argument("--mnemonic-fd", type=int, default=None, metavar="N",
691 dest="mnemonic_fd",
692 help="Read BIP39 mnemonic from file descriptor N (for scripted use). "
693 "If omitted: reads from stdin if piped, or prompts interactively.")
694 recover_p.add_argument("--agent-id", default=None, metavar="AGENT_ID", dest="agent_id",
695 help="Recover an agent key (derives from the operator mnemonic at the agent's slot).")
696 recover_p.add_argument("--force", action="store_true",
697 help="Overwrite an existing PEM without prompting.")
698 recover_p.add_argument("--json", "-j", action="store_true", dest="json_out", default=False,
699 help="Emit a JSON object to stdout on success.")
700 recover_p.add_argument(
701 "--passphrase-fd", type=int, default=None, dest="passphrase_fd", metavar="N",
702 help=(
703 "Read BIP-39 extension passphrase from file descriptor N. "
704 "Must match the one used at keygen. "
705 "Falls back to MUSE_BIP39_PASSPHRASE env var, then interactive prompt on a TTY."
706 ),
707 )
708 recover_p.set_defaults(func=run_recover)
709
710 # ── rotate ───────────────────────────────────────────────────────────────
711 rotate_p = subs.add_parser(
712 "rotate",
713 help="Rotate the identity key to the next HD derivation index.",
714 description=(
715 "Derives a new Ed25519 identity key at rotation_index + 1,\n"
716 "overwrites the local PEM, and updates identity.toml.\n\n"
717 "After rotation, re-register the new fingerprint with the hub:\n"
718 " muse auth register --hub <url> --handle <handle>"
719 ),
720 formatter_class=argparse.RawDescriptionHelpFormatter,
721 )
722 rotate_p.add_argument("--hub", default=None, metavar="URL",
723 help="Hub URL. Falls back to [hub] url in config.toml.")
724 rotate_p.add_argument("--mnemonic-fd", type=int, default=None, metavar="N",
725 dest="mnemonic_fd",
726 help="Read BIP39 mnemonic from file descriptor N. "
727 "If omitted: reads from stdin if piped, else prompts.")
728 rotate_p.add_argument(
729 "--passphrase-fd", type=int, default=None, dest="passphrase_fd", metavar="N",
730 help="Read BIP-39 extension passphrase from file descriptor N. "
731 "Must match the one used at keygen. "
732 "Falls back to MUSE_BIP39_PASSPHRASE env var, then interactive prompt on a TTY.",
733 )
734 rotate_p.add_argument("--json", "-j", action="store_true", dest="json_out", default=False,
735 help="Emit a JSON object to stdout on success.")
736 rotate_p.set_defaults(func=run_rotate)
737
738 # ── register ─────────────────────────────────────────────────────────────
739 register_p = subs.add_parser(
740 "register",
741 help="Register the local Ed25519 key with a MuseHub instance (challenge-response).",
742 description=(
743 "Performs the Ed25519 challenge-response flow with the hub:\n"
744 " 1. POST /api/auth/challenge (fingerprint → nonce)\n"
745 " 2. Sign the nonce with the local private key\n"
746 " 3. POST /api/auth/verify (signed nonce → session token)\n\n"
747 "The resulting session token is saved to ~/.muse/identity.toml (0o600).\n"
748 "Use this command for initial registration AND to refresh an expired token."
749 ),
750 formatter_class=argparse.RawDescriptionHelpFormatter,
751 )
752 register_p.add_argument("--hub", default=None, metavar="URL",
753 help="Hub URL (e.g. https://musehub.ai). Falls back to [hub] url in config.toml.")
754 register_p.add_argument("--handle", default=None, metavar="HANDLE",
755 help=(
756 "Desired username (required for first-time registration; "
757 "ignored if the key is already registered)."
758 ))
759 register_p.add_argument("--label", default=None, metavar="LABEL",
760 help='Friendly key label, e.g. "MacBook Pro" or "CI agent".')
761 register_p.add_argument("--agent", action="store_true",
762 help="Mark this identity as an agent (default: human).")
763 register_p.add_argument("--agent-id", default=None, metavar="AGENT_ID", dest="agent_id",
764 help=(
765 "Agent handle whose dedicated key should be registered. "
766 "Loads ~/.muse/keys/{hostname}__{agent_id}.pem and stores "
767 "the identity under the compound key 'hostname#agent_id'. "
768 "Implies --agent."
769 ))
770 register_p.add_argument("--provisioned-by", default=None, metavar="HANDLE", dest="provisioned_by",
771 help=(
772 "Handle of the human who is provisioning this agent key. "
773 "Recorded in identity.toml as the trust-chain root. "
774 "Required when --agent-id is used."
775 ))
776 register_p.add_argument("--json", "-j", action="store_true", dest="json_out", default=False,
777 help="Emit a JSON object to stdout on success.")
778 register_p.set_defaults(func=run_register)
779
780 # ── whoami ────────────────────────────────────────────────────────────────
781 whoami_p = subs.add_parser(
782 "whoami",
783 help="Show the current identity stored in ~/.muse/identity.toml.",
784 description=(
785 "Print the identity stored in ~/.muse/identity.toml for a hub.\n"
786 "Exits non-zero when no identity is stored — useful for agent branching:\n\n"
787 " muse auth whoami --json || muse auth register --hub <url> --handle <name> --agent\n\n"
788 "With --all --json, emits a single JSON array (one object per hub):\n\n"
789 " muse auth whoami --all --json | jq '.[] | select(.type == \"agent\")'"
790 ),
791 formatter_class=argparse.RawDescriptionHelpFormatter,
792 )
793 whoami_p.add_argument("--hub", default=None, metavar="URL",
794 help="Hub URL to inspect. Defaults to the repo's configured hub.")
795 whoami_p.add_argument("--all", "-a", action="store_true", dest="all_hubs",
796 help="Show identities for all configured hubs.")
797 whoami_p.add_argument(
798 "--type", default=None, metavar="TYPE", dest="identity_type",
799 help=(
800 'Filter by identity type: "human" or "agent". '
801 'Only valid with --all. '
802 'Exits non-zero when no matching identity is found.'
803 ),
804 )
805 whoami_p.add_argument("--json", "-j", action="store_true", dest="json_out",
806 help="Emit JSON to stdout. With --all, emits a JSON array.")
807 whoami_p.set_defaults(func=run_whoami)
808
809 # ── logout ────────────────────────────────────────────────────────────────
810 logout_p = subs.add_parser(
811 "logout",
812 help="Remove stored credentials for a hub.",
813 description=(
814 "Remove the signing identity stored in ~/.muse/identity.toml for a hub.\n"
815 "Operation is idempotent — logging out when no identity is stored exits 0.\n\n"
816 "Agent quickstart:\n"
817 " muse auth logout --json # single hub, JSON result\n"
818 " muse auth logout --all --json # remove all hubs, sorted list\n\n"
819 "JSON output shape (stdout):\n"
820 " {\"status\": \"ok\" | \"nothing_to_do\",\n"
821 " \"hub\": \"<hostname>\", # single-hub only\n"
822 " \"hubs\": [\"<hostname>\", ...], # --all only (sorted)\n"
823 " \"count\": <int>} # --all only\n\n"
824 "Exit codes: 0 success (incl. nothing to do), 1 bad arguments, 3 internal error."
825 ),
826 formatter_class=argparse.RawDescriptionHelpFormatter,
827 )
828 logout_p.add_argument("--hub", default=None, metavar="URL",
829 help="Hub URL to log out from. Defaults to the repo's configured hub.")
830 logout_p.add_argument("--all", "-a", action="store_true", dest="all_hubs",
831 help="Remove credentials for ALL configured hubs.")
832 logout_p.add_argument("--json", "-j", action="store_true", dest="json_out", default=False,
833 help="Emit a JSON object to stdout on completion.")
834 logout_p.set_defaults(func=run_logout)
835
836 # ── show ──────────────────────────────────────────────────────────────────
837 show_p = subs.add_parser(
838 "show",
839 help="Display full identity details including HD derivation paths and AVAX address.",
840 description=(
841 "Print detailed identity information stored in ~/.muse/identity.toml.\n"
842 "For HD identities this includes:\n"
843 " - Mnemonic word count\n"
844 " - All six-level Muse derivation paths (MSign, MPay, AVAX …)\n"
845 " - AVAX C-Chain address derived from the BIP39 mnemonic\n\n"
846 "JSON output (--json):\n"
847 " {\"hub\": \"<hostname>\", \"handle\": \"<handle>\",\n"
848 " \"mnemonic_word_count\": 12,\n"
849 " \"derived_paths\": {\"identity_msign\": \"m/…\", …},\n"
850 " \"avax_c_chain_address\": \"0x…\"}"
851 ),
852 formatter_class=argparse.RawDescriptionHelpFormatter,
853 )
854 show_p.add_argument("--hub", default=None, metavar="URL",
855 help="Hub URL to inspect. Defaults to the repo's configured hub.")
856 show_p.add_argument("--json", "-j", action="store_true", dest="json_out", default=False,
857 help="Emit a JSON object to stdout.")
858 show_p.set_defaults(func=run_show)
859
860 # ── cleanup-keys ──────────────────────────────────────────────────────────
861 cleanup_p = subs.add_parser(
862 "cleanup-keys",
863 help="Securely overwrite and delete stale PEM files from ~/.muse/keys/.",
864 description=(
865 "Overwrite every *.pem file in ~/.muse/keys/ with random bytes, then\n"
866 "delete it. Keys are now derived from the mnemonic in the OS keychain\n"
867 "at sign time — PEM files are vestigial and represent unprotected key\n"
868 "material. This command removes them permanently.\n\n"
869 "WARNING: ensure your mnemonic is safely stored in the OS keychain\n"
870 "(run `muse auth security-check`) before running this command.\n\n"
871 "JSON output (--json):\n"
872 " {\"destroyed\": [\"<path>\", ...], \"count\": <int>}\n\n"
873 "Exit codes: 0 success, 3 I/O error."
874 ),
875 formatter_class=argparse.RawDescriptionHelpFormatter,
876 )
877 cleanup_p.add_argument("--json", "-j", action="store_true", dest="json_out", default=False,
878 help="Emit a JSON object to stdout.")
879 cleanup_p.set_defaults(func=run_cleanup_keys)
880
881 # ── security-check ────────────────────────────────────────────────────────
882 seccheck_p = subs.add_parser(
883 "security-check",
884 help="Verify the local identity is in a clean, PEM-free state.",
885 description=(
886 "Run a set of security invariant checks against the local identity:\n"
887 " 1. Mnemonic is present in the OS keychain\n"
888 " 2. No PEM files exist in ~/.muse/keys/\n"
889 " 3. identity.toml has no key_path fields\n"
890 " 4. Fingerprint in identity.toml matches mnemonic derivation\n\n"
891 "Exits 0 when all checks pass. Exits 1 if any check fails.\n\n"
892 "JSON output (--json):\n"
893 " {\"ok\": true|false, \"mnemonic_in_keychain\": true|false, ...}"
894 ),
895 formatter_class=argparse.RawDescriptionHelpFormatter,
896 )
897 seccheck_p.add_argument("--hub", default=None, metavar="URL",
898 help="Hub to check fingerprint against. Defaults to the repo's configured hub.")
899 seccheck_p.add_argument("--json", "-j", action="store_true", dest="json_out", default=False,
900 help="Emit a JSON object to stdout.")
901 seccheck_p.set_defaults(func=run_security_check)
902
903 # ── keygen ────────────────────────────────────────────────────────────────────
904
905 def run_keygen(args: argparse.Namespace) -> None:
906 """Generate a new Ed25519 keypair for this hub (or for a specific agent).
907
908 All keys are derived from a BIP39 mnemonic via SLIP-0010 Ed25519 HD
909 derivation (no JBOK mode). Human keygen produces a fresh 24-word mnemonic
910 and prints it **once** to stderr. Agent keygen (``--agent-id``) derives a
911 sub-seed from the operator's existing mnemonic — no new mnemonic is generated.
912
913 Agent quickstart
914 ----------------
915 ::
916
917 muse auth keygen --hub https://localhost:1337 --json
918 muse auth keygen --hub https://localhost:1337 --agent-id mybot --json
919
920 JSON fields
921 -----------
922 status ``"ok"`` on success.
923 hub Hub URL used.
924 hostname Hostname extracted from the hub URL.
925 public_key_b64 Base64url-encoded 32-byte Ed25519 public key.
926 fingerprint ``sha256:<64-hex>`` fingerprint of the public key.
927 hd_path SLIP-0010 derivation path string.
928 mnemonic_word_count Number of BIP39 mnemonic words (human keys only).
929
930 Exit codes
931 ----------
932 0 Keypair generated successfully.
933 1 No hub URL, existing key without ``--force``, derivation error.
934 """
935 elapsed = start_timer()
936 from muse.core.keypair import derive_hd_public_info
937 from muse.core.bip39 import (
938 generate_mnemonic, mnemonic_to_seed, word_count,
939 Bip39Error, _WORDS_FOR_STRENGTH, SUPPORTED_LANGUAGES,
940 )
941 from muse.core.hdkeys import (
942 muse_path, agent_id_to_slot, derive_agent_sub_seed,
943 DOMAIN_IDENTITY, ENTITY_HUMAN, ENTITY_AGENT, ROLE_SIGN,
944 )
945
946 hub: str | None = args.hub
947 agent_id: str | None = getattr(args, "agent_id", None)
948 label: str | None = args.label
949 force: bool = args.force
950 destroy_mnemonic: bool = getattr(args, "destroy_mnemonic", False)
951 json_out: bool = args.json_out
952 hd_strength: int = getattr(args, "hd_strength", 256)
953 hd_language: str = getattr(args, "hd_language", "english")
954 passphrase: str = _resolve_passphrase(args)
955
956 hub_url = _resolve_hub(hub)
957 if hub_url is None:
958 print(
959 "❌ No hub URL provided.\n"
960 " Pass --hub <url>, or first run: muse hub connect <url>",
961 file=sys.stderr,
962 )
963 raise SystemExit(ExitCode.USER_ERROR)
964
965 hub_url = _hub_base_url(hub_url)
966 hostname = hostname_from_url(hub_url)
967
968 # Guard: reject if an identity already exists for this hub (human or agent) unless --force.
969 _existing_entry = load_identity(hub_url, agent_id=agent_id)
970 if _existing_entry is not None and not force:
971 subject = f"{hostname}/{agent_id}" if agent_id else hostname
972 print(
973 f"❌ An identity already exists for {subject}.\n"
974 " Pass --force to overwrite it.",
975 file=sys.stderr,
976 )
977 raise SystemExit(ExitCode.USER_ERROR)
978
979 if agent_id:
980 # ── Agent key: derived from operator's mnemonic ───────────────────────
981 operator_entry = load_identity(hub_url)
982 if operator_entry is None:
983 print(
984 "❌ No operator identity found for this hub.\n"
985 f" Run 'muse auth keygen --hub {hub_url}' first to establish the operator key.",
986 file=sys.stderr,
987 )
988 raise SystemExit(ExitCode.USER_ERROR)
989
990 operator_mnemonic = operator_entry.get("mnemonic")
991 if not operator_mnemonic:
992 print(
993 "❌ Operator mnemonic is not available.\n"
994 " Agent keys are derived from the operator's BIP39 mnemonic.\n"
995 " Ensure the mnemonic is stored in the OS keychain, or re-key with:\n"
996 f" muse auth keygen --hub {hub_url} --force",
997 file=sys.stderr,
998 )
999 raise SystemExit(ExitCode.USER_ERROR)
1000 operator_seed = mnemonic_to_seed(operator_mnemonic, passphrase)
1001 slot = agent_id_to_slot(agent_id)
1002 agent_sub_seed = derive_agent_sub_seed(operator_seed, DOMAIN_IDENTITY, slot)
1003 pub_b64, fingerprint = derive_hd_public_info(agent_sub_seed)
1004
1005 hd_path_str = muse_path(DOMAIN_IDENTITY, ENTITY_AGENT, slot)
1006 provisional_entry: IdentityEntry = {
1007 "type": "agent",
1008 "handle": "",
1009 "algorithm": DEFAULT_SIGN_ALGO,
1010 "fingerprint": fingerprint,
1011 "hd_path": hd_path_str,
1012 "provisioned_by_fingerprint": operator_entry["fingerprint"],
1013 }
1014 try:
1015 save_identity(hub_url, provisional_entry, agent_id=agent_id)
1016 except OSError as exc:
1017 print(f"❌ Could not persist agent identity: {exc}", file=sys.stderr)
1018 raise SystemExit(ExitCode.INTERNAL_ERROR) from exc
1019
1020 if json_out:
1021 print(json.dumps(_AgentKeygenJson(
1022 **make_envelope(elapsed),
1023 status="ok",
1024 hub=hub_url,
1025 hostname=hostname,
1026 agent_id=agent_id,
1027 public_key_b64=pub_b64,
1028 fingerprint=fingerprint,
1029 hd_path=hd_path_str,
1030 slot=slot,
1031 provisioned_by_fingerprint=operator_entry["fingerprint"],
1032 )))
1033 else:
1034 register_flags = (
1035 f"--hub {hub_url} --agent-id {agent_id} "
1036 f"--handle {agent_id} --provisioned-by <your-handle>"
1037 )
1038 print(
1039 f"✅ Agent Ed25519 key derived (agent: {agent_id})\n"
1040 f" Public key (b64url): {pub_b64}\n"
1041 f" Fingerprint (SHA-256): {fingerprint}\n"
1042 f" HD path: {hd_path_str} (slot {slot})\n"
1043 f" Derived from operator: {operator_entry['fingerprint'][:16]}…\n\n"
1044 f" Next step: muse auth register {register_flags}",
1045 file=sys.stderr,
1046 )
1047 return
1048
1049 # ── Human key: generate fresh mnemonic ────────────────────────────────────
1050 if hd_strength not in _WORDS_FOR_STRENGTH:
1051 print(
1052 f"❌ Unsupported --strength {hd_strength}. "
1053 f"Must be one of {sorted(_WORDS_FOR_STRENGTH)}.",
1054 file=sys.stderr,
1055 )
1056 raise SystemExit(ExitCode.USER_ERROR)
1057
1058 if hd_language not in SUPPORTED_LANGUAGES:
1059 print(
1060 f"❌ Unsupported --language {sanitize_display(hd_language)!r}. "
1061 f"Supported: {sorted(SUPPORTED_LANGUAGES)}.",
1062 file=sys.stderr,
1063 )
1064 raise SystemExit(ExitCode.USER_ERROR)
1065
1066 # Reuse the existing keychain mnemonic when available.
1067 # Generating fresh entropy irreversibly destroys the old mnemonic and all
1068 # keys derived from it — requires --destroy-mnemonic to be explicit.
1069 from muse.core.keychain import load as kc_load, is_available as kc_avail
1070 existing_mnemonic: str | None = kc_load() if kc_avail() else None
1071 if existing_mnemonic and destroy_mnemonic and not force:
1072 print(
1073 "❌ --destroy-mnemonic requires --force.\n"
1074 " This is a double-confirmation requirement — both flags must be\n"
1075 " present to overwrite the existing mnemonic and identity.",
1076 file=sys.stderr,
1077 )
1078 raise SystemExit(ExitCode.USER_ERROR)
1079 if existing_mnemonic and destroy_mnemonic:
1080 mnemonic_to_generate = True
1081 elif existing_mnemonic:
1082 mnemonic_to_generate = False
1083 else:
1084 mnemonic_to_generate = True # no existing mnemonic — always generate
1085
1086 if mnemonic_to_generate:
1087 try:
1088 mnemonic = generate_mnemonic(strength=hd_strength, language=hd_language)
1089 except Bip39Error as exc:
1090 print(f"❌ Mnemonic generation failed: {exc}", file=sys.stderr)
1091 raise SystemExit(ExitCode.INTERNAL_ERROR)
1092 else:
1093 mnemonic = existing_mnemonic # type: ignore[assignment]
1094
1095 seed = mnemonic_to_seed(mnemonic, passphrase)
1096 pub_b64, fingerprint = derive_hd_public_info(seed)
1097
1098 hd_path_str = muse_path(DOMAIN_IDENTITY, ENTITY_HUMAN)
1099 n_words = len(mnemonic.strip().split())
1100
1101 # Preserve the registered handle from an existing entry so that --force
1102 # re-keying (e.g. key rotation) does not silently clear the username and
1103 # break subsequent MSign authentication.
1104 _existing_handle = (_existing_entry or {}).get("handle") or ""
1105 provisional_entry: IdentityEntry = {
1106 "type": "human",
1107 "handle": _existing_handle,
1108 "algorithm": DEFAULT_SIGN_ALGO,
1109 "fingerprint": fingerprint,
1110 "hd_path": hd_path_str,
1111 }
1112 try:
1113 save_identity(hub_url, provisional_entry, mnemonic=mnemonic)
1114 except OSError as exc:
1115 print(f"❌ Could not persist HD provenance: {exc}", file=sys.stderr)
1116 raise SystemExit(ExitCode.INTERNAL_ERROR) from exc
1117
1118 # Never print the mnemonic to the terminal — it would persist in scrollback
1119 # and screen recordings. The mnemonic is stored in the OS keychain; users
1120 # retrieve it on demand via the platform keychain CLI and pipe directly to
1121 # their password manager, so it never touches the terminal display at all.
1122 action = "generated and stored" if mnemonic_to_generate else "already stored"
1123 print(
1124 f"\n✅ {n_words}-word BIP-39 mnemonic {action} in your OS keychain.\n"
1125 " Back it up now — pipe directly to your password manager so it\n"
1126 " never appears in your terminal scrollback:\n\n"
1127 " macOS: security find-generic-password -s muse -a mnemonic -w | pbcopy\n"
1128 " Linux: secret-tool lookup service muse account mnemonic | xclip -selection clipboard\n",
1129 file=sys.stderr,
1130 )
1131
1132 if json_out:
1133 print(json.dumps(_KeygenJson(
1134 **make_envelope(elapsed),
1135 status="ok",
1136 hub=hub_url,
1137 hostname=hostname,
1138 public_key_b64=pub_b64,
1139 fingerprint=fingerprint,
1140 hd_path=hd_path_str,
1141 mnemonic_word_count=n_words,
1142 )))
1143 else:
1144 label_note = f" (label: {label!r})" if label else ""
1145 print(
1146 f"✅ Ed25519 keypair generated{label_note}\n"
1147 f" Public key (b64url): {pub_b64}\n"
1148 f" Fingerprint (SHA-256): {fingerprint}\n"
1149 f" HD path: {hd_path_str}\n"
1150 f" Mnemonic: {n_words} words ({hd_language})\n\n"
1151 f" Next step: muse auth register --hub {hub_url} --handle <your-handle>",
1152 file=sys.stderr,
1153 )
1154
1155 # ── recover ───────────────────────────────────────────────────────────────────
1156
1157 def run_recover(args: argparse.Namespace) -> None:
1158 """Re-derive the Ed25519 identity from a BIP39 mnemonic and store it in the keychain.
1159
1160 The resulting fingerprint is identical to the original — no re-registration
1161 with the hub is needed. No PEM file is written; the key is derived on demand
1162 from the mnemonic stored in the OS keychain.
1163
1164 Agent quickstart
1165 ----------------
1166 ::
1167
1168 muse auth recover --hub https://localhost:1337 --json
1169 muse auth recover --hub https://localhost:1337 --agent-id mybot --json
1170
1171 JSON fields
1172 -----------
1173 status ``"ok"`` on success.
1174 hub Hub URL used.
1175 hostname Hostname extracted from the hub URL.
1176 fingerprint SHA-256 hex fingerprint (matches original registration).
1177 hd_path SLIP-0010 derivation path string.
1178
1179 Exit codes
1180 ----------
1181 0 Key recovered successfully.
1182 1 Invalid mnemonic, no hub URL, or existing entry without ``--force``.
1183 3 I/O error writing identity.toml.
1184 """
1185 elapsed = start_timer()
1186 from muse.core.keypair import derive_hd_public_info
1187 from muse.core.bip39 import validate_mnemonic, mnemonic_to_seed
1188 from muse.core.hdkeys import (
1189 muse_path, agent_id_to_slot, derive_agent_sub_seed,
1190 DOMAIN_IDENTITY, ENTITY_HUMAN, ENTITY_AGENT,
1191 )
1192 from muse.core.keychain import store as kc_store
1193
1194 hub: str | None = args.hub
1195 mnemonic_fd: int | None = getattr(args, "mnemonic_fd", None)
1196 mnemonic: str = _read_mnemonic_securely(fd=mnemonic_fd)
1197 agent_id: str | None = getattr(args, "agent_id", None)
1198 force: bool = args.force
1199 json_out: bool = args.json_out
1200 passphrase: str = _resolve_passphrase(args)
1201
1202 hub_url = _resolve_hub(hub)
1203 if hub_url is None:
1204 print(
1205 "❌ No hub URL provided.\n"
1206 " Pass --hub <url>, or first run: muse hub connect <url>",
1207 file=sys.stderr,
1208 )
1209 raise SystemExit(ExitCode.USER_ERROR)
1210
1211 if not validate_mnemonic(mnemonic):
1212 print("❌ Invalid mnemonic: phrase did not pass BIP39 validation.", file=sys.stderr)
1213 raise SystemExit(ExitCode.USER_ERROR)
1214
1215 hostname = hostname_from_url(hub_url)
1216
1217 existing = load_identity(hub_url, agent_id)
1218 if existing is not None and not force:
1219 subject = f"{sanitize_display(hostname)}#{sanitize_display(agent_id)}" if agent_id else sanitize_display(hostname)
1220 print(
1221 f"⚠️ An identity already exists for {subject}.\n"
1222 " Pass --force to overwrite it.",
1223 file=sys.stderr,
1224 )
1225 raise SystemExit(ExitCode.USER_ERROR)
1226
1227 operator_seed = mnemonic_to_seed(mnemonic, passphrase)
1228
1229 if agent_id:
1230 slot = agent_id_to_slot(agent_id)
1231 agent_sub_seed = derive_agent_sub_seed(operator_seed, DOMAIN_IDENTITY, slot)
1232 pub_b64, fingerprint = derive_hd_public_info(agent_sub_seed)
1233 agent_sub_seed.zero()
1234 hd_path_str = muse_path(DOMAIN_IDENTITY, ENTITY_AGENT, slot)
1235 else:
1236 pub_b64, fingerprint = derive_hd_public_info(operator_seed)
1237 hd_path_str = muse_path(DOMAIN_IDENTITY, ENTITY_HUMAN)
1238
1239 operator_seed[:] = b"\x00" * len(operator_seed)
1240
1241 handle = existing.get("handle", "") if existing else ""
1242 entry: IdentityEntry = {
1243 "type": "agent" if agent_id else "human",
1244 "handle": handle,
1245 "algorithm": DEFAULT_SIGN_ALGO,
1246 "fingerprint": fingerprint,
1247 "hd_path": hd_path_str,
1248 }
1249 try:
1250 save_identity(
1251 hub_url,
1252 entry,
1253 agent_id=agent_id,
1254 mnemonic=mnemonic if not agent_id else None,
1255 )
1256 except OSError as exc:
1257 print(f"❌ Could not persist identity: {exc}", file=sys.stderr)
1258 raise SystemExit(ExitCode.INTERNAL_ERROR) from exc
1259
1260 # Store the mnemonic in the OS keychain for future key derivations.
1261 if not agent_id:
1262 kc_store(mnemonic)
1263
1264 if json_out:
1265 out: _RecoverJson = _RecoverJson(
1266 **make_envelope(elapsed),
1267 status="ok",
1268 hub=hub_url,
1269 hostname=hostname,
1270 public_key_b64=pub_b64,
1271 fingerprint=fingerprint,
1272 hd_path=hd_path_str,
1273 )
1274 if agent_id:
1275 out["agent_id"] = agent_id
1276 print(json.dumps(out))
1277 else:
1278 subject = f"agent '{agent_id}'" if agent_id else "human"
1279 print(
1280 f"✅ Key recovered for {subject}\n"
1281 f" Fingerprint (SHA-256): {fingerprint}\n"
1282 f" HD path: {hd_path_str}",
1283 file=sys.stderr,
1284 )
1285
1286 # ── rotate ────────────────────────────────────────────────────────────────────
1287
1288 def _parse_rotation_index(hd_path: str) -> int:
1289 """Return the rotation index (6th path component) from an hd_path string.
1290
1291 E.g. ``"m/1075233755'/0'/0'/0'/0'/2'"`` → ``2``.
1292 Returns ``0`` if the path cannot be parsed (safe default — index 0 is current).
1293 """
1294 try:
1295 parts = hd_path.rstrip("/").split("/")
1296 # parts[0] = "m", parts[1..6] = the six levels
1297 return int(parts[-1].rstrip("'"))
1298 except (IndexError, ValueError):
1299 return 0
1300
1301 def _compute_key_id(identity_id: str, public_key_b64: str) -> str:
1302 """Return the hub key_id for a registered key.
1303
1304 Mirrors musehub.core.genesis.compute_key_id:
1305 key_id = sha256(identity_id NUL public_key_b64)
1306
1307 The NUL separator prevents collision between an identity_id that ends
1308 with a public_key_b64 prefix.
1309 """
1310 payload = f"{identity_id}\x00{public_key_b64}".encode("utf-8")
1311 return blob_id(payload)
1312
1313 def run_rotate(args: argparse.Namespace) -> None:
1314 """Rotate the Ed25519 identity key to the next HD derivation index.
1315
1316 The full atomic sequence:
1317 1. Derive the new key at index+1 from the OS keychain mnemonic.
1318 2. Register the new key with the hub via challenge-response (using
1319 the old key's signing identity so the request is authenticated).
1320 3. Deregister the old key from the hub via
1321 ``DELETE /api/auth/keys/{handle}/{key_id}`` (signed with the old key
1322 before identity.toml is updated — the old key is still valid at this
1323 point because the hub now has both).
1324 4. Update identity.toml with the new key.
1325
1326 If hub registration fails (step 2), identity.toml is NOT updated —
1327 local and remote stay in sync. No PEM file is written.
1328
1329 Exit codes
1330 ----------
1331 0 Success.
1332 1 No existing identity, no mnemonic in keychain, or hub registration fails.
1333 """
1334 elapsed = start_timer()
1335 from muse.core.keypair import derive_hd_public_info
1336 from muse.core.bip39 import mnemonic_to_seed
1337 from muse.core.hdkeys import (
1338 muse_path, DOMAIN_IDENTITY, ENTITY_HUMAN, ROLE_SIGN,
1339 )
1340 from muse.core.keychain import load as kc_load, is_available as kc_avail
1341
1342 hub: str | None = args.hub
1343 json_out: bool = args.json_out
1344 passphrase: str = _resolve_passphrase(args)
1345
1346 hub_url = _resolve_hub(hub)
1347 if hub_url is None:
1348 print(
1349 "❌ No hub URL provided.\n"
1350 " Pass --hub <url>, or first run: muse hub connect <url>",
1351 file=sys.stderr,
1352 )
1353 raise SystemExit(ExitCode.USER_ERROR)
1354
1355 hostname = hostname_from_url(hub_url)
1356
1357 # Must have an existing identity to rotate from.
1358 existing = load_identity(hub_url)
1359 if existing is None:
1360 print(
1361 f"❌ No identity found for {sanitize_display(hub_url)}.\n"
1362 " Run 'muse auth keygen' first to establish an identity before rotating.",
1363 file=sys.stderr,
1364 )
1365 raise SystemExit(ExitCode.USER_ERROR)
1366
1367 # Read mnemonic from the OS keychain — no stdin prompt needed.
1368 mnemonic: str | None = kc_load() if kc_avail() else None
1369 if not mnemonic:
1370 print(
1371 "❌ No mnemonic found in OS keychain.\n"
1372 " Run 'muse auth recover' to restore the mnemonic first.",
1373 file=sys.stderr,
1374 )
1375 raise SystemExit(ExitCode.USER_ERROR)
1376
1377 current_hd_path = existing.get("hd_path") or muse_path(DOMAIN_IDENTITY)
1378 current_index = _parse_rotation_index(current_hd_path)
1379 new_index = current_index + 1
1380
1381 seed = mnemonic_to_seed(mnemonic, passphrase)
1382
1383 # Derive the OLD key (needed to compute old_key_id and to sign the DELETE).
1384 old_pub_b64, old_fingerprint = derive_hd_public_info(seed, rotation_index=current_index)
1385 # old fingerprint == sha256(old_public_key_bytes) == the hub's identity_id for this user.
1386 old_identity_id = old_fingerprint # sha256(pub_key_bytes) ≡ fingerprint
1387 old_key_id = _compute_key_id(old_identity_id, old_pub_b64)
1388
1389 new_hd_path = muse_path(DOMAIN_IDENTITY, ENTITY_HUMAN, role=ROLE_SIGN, index=new_index)
1390 new_pub_b64, new_fingerprint = derive_hd_public_info(seed, rotation_index=new_index)
1391 seed[:] = b"\x00" * len(seed)
1392
1393 handle = existing.get("handle", "")
1394
1395 # ── Step 2: register the new key with the hub ─────────────────────────────
1396 # Uses POST /api/auth/keys (MSign-authenticated with the OLD key to prove
1397 # account ownership + challenge-response with the NEW key to prove new key
1398 # ownership). This is the correct rotation endpoint — POST /api/auth/verify
1399 # only works for fresh registrations.
1400 print(f" → Registering new key with {sanitize_display(hub_url)} …", file=sys.stderr)
1401
1402 from muse.core.msign import build_msign_header as _build_msign
1403 from muse.core.transport import SigningIdentity as _SigningIdentity
1404 from muse.core.hdkeys import derive_identity_key as _derive_key
1405 from muse.core.keypair import sign_bytes as _sign_bytes
1406 from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey as _Ed25519PrivKey
1407
1408 # Derive both keys from the mnemonic once each; zero seeds immediately.
1409 old_seed = mnemonic_to_seed(mnemonic, passphrase)
1410 old_dk = _derive_key(old_seed, index=current_index)
1411 old_seed[:] = b"\x00" * len(old_seed)
1412 old_priv_key = _Ed25519PrivKey.from_private_bytes(old_dk.private_bytes)
1413 old_dk.zero()
1414
1415 new_seed = mnemonic_to_seed(mnemonic, passphrase)
1416 new_dk = _derive_key(new_seed, index=new_index)
1417 new_seed[:] = b"\x00" * len(new_seed)
1418 new_priv_key = _Ed25519PrivKey.from_private_bytes(new_dk.private_bytes)
1419 new_dk.zero()
1420
1421 # Get a challenge nonce for the new key's fingerprint.
1422 try:
1423 challenge_resp = _post_challenge(hub_url, {
1424 "fingerprint": new_fingerprint,
1425 "algorithm": DEFAULT_SIGN_ALGO,
1426 })
1427 except SystemExit:
1428 print("❌ Hub registration failed — identity.toml unchanged.", file=sys.stderr)
1429 raise
1430
1431 token = challenge_resp.get("challenge_token", "")
1432 # Hub issues hex nonces; sign the raw bytes.
1433 try:
1434 nonce_bytes = bytes.fromhex(token)
1435 except ValueError:
1436 nonce_bytes = token.encode("utf-8")
1437 new_key_sig = _sign_bytes(new_priv_key, nonce_bytes)
1438
1439 # POST /api/auth/keys — MSign with old key proves account ownership;
1440 # new key signature in body proves new key ownership.
1441 parsed_url = urllib.parse.urlparse(hub_url)
1442 server_root = f"{parsed_url.scheme}://{parsed_url.netloc}"
1443 add_key_path = "/api/auth/keys"
1444 add_key_url = f"{server_root}{add_key_path}"
1445
1446 ssl_ctx: "object | None" = None
1447 if parsed_url.hostname in ("localhost", "127.0.0.1"):
1448 import ssl as _ssl
1449 from muse.core.transport import _mkcert_ca
1450 ca = _mkcert_ca()
1451 if ca is not None:
1452 ssl_ctx = _ssl.create_default_context(cafile=str(ca))
1453
1454 # MSign must cover the actual request body — precompute the bytes before signing.
1455 add_key_payload = {
1456 "challenge_token": token,
1457 "public_key_b64": new_pub_b64,
1458 "signature_b64": new_key_sig,
1459 "label": f"rotation-index-{new_index}",
1460 }
1461 add_key_body = json.dumps(add_key_payload).encode("utf-8")
1462
1463 old_signing = _SigningIdentity(handle=handle, private_key=old_priv_key)
1464 add_key_auth = _build_msign(old_signing, "POST", add_key_url, add_key_body)
1465
1466 try:
1467 _json_post_raw(
1468 hub_url,
1469 add_key_path,
1470 add_key_payload,
1471 extra_headers={"Authorization": add_key_auth},
1472 )
1473 except SystemExit:
1474 print("❌ Hub registration failed — identity.toml unchanged.", file=sys.stderr)
1475 raise
1476
1477 # ── Step 3: deregister the old key from the hub ───────────────────────────
1478 # Sign the DELETE with the OLD key (still valid; hub now has both keys).
1479 # A failure here is non-fatal — the new key is already registered.
1480 delete_path = f"/api/auth/keys/{urllib.parse.quote(handle)}/{urllib.parse.quote(old_key_id)}"
1481 delete_url = f"{server_root}{delete_path}"
1482 auth_header = _build_msign(old_signing, "DELETE", delete_url, None)
1483
1484 try:
1485 _hub_delete(delete_url, auth_header, ssl_ctx)
1486 print(" → Old key deregistered from hub.", file=sys.stderr)
1487 except (urllib.error.HTTPError, urllib.error.URLError) as exc:
1488 # Non-fatal: warn but proceed — new key is registered, old key
1489 # may have already been deregistered or not been registered.
1490 _reason = getattr(exc, "code", None) or getattr(exc, "reason", exc)
1491 print(
1492 f"⚠️ Could not deregister old key from hub ({_reason}) — "
1493 "it may already be revoked or was never registered.",
1494 file=sys.stderr,
1495 )
1496
1497 # ── Step 4: persist the new identity locally ──────────────────────────────
1498 entry: IdentityEntry = {
1499 "type": "human",
1500 "handle": handle,
1501 "algorithm": DEFAULT_SIGN_ALGO,
1502 "fingerprint": new_fingerprint,
1503 "hd_path": new_hd_path,
1504 }
1505 try:
1506 save_identity(hub_url, entry, mnemonic=mnemonic)
1507 except OSError as exc:
1508 print(f"❌ Could not persist rotated identity: {exc}", file=sys.stderr)
1509 raise SystemExit(ExitCode.INTERNAL_ERROR) from exc
1510
1511 if json_out:
1512 print(json.dumps(_RotateJson(
1513 **make_envelope(elapsed),
1514 status="ok",
1515 hub=hub_url,
1516 hostname=hostname,
1517 fingerprint=new_fingerprint,
1518 hd_path=new_hd_path,
1519 rotation_index=new_index,
1520 )))
1521 else:
1522 print(
1523 f"✅ Key rotated (index {current_index} → {new_index})\n"
1524 f" New fingerprint: {new_fingerprint}\n"
1525 f" HD path: {new_hd_path}",
1526 file=sys.stderr,
1527 )
1528
1529 # ── register ──────────────────────────────────────────────────────────────────
1530
1531 def run_register(args: argparse.Namespace) -> None:
1532 """Register the local Ed25519 key with a MuseHub instance.
1533
1534 Performs the full Ed25519 challenge-response flow: request nonce →
1535 sign with local private key → submit signature → store returned identity in
1536 ``~/.muse/identity.toml``. The private key never leaves the local machine.
1537
1538 Agent quickstart
1539 ----------------
1540 ::
1541
1542 muse auth register --hub https://localhost:1337 --handle gabriel --json
1543 muse auth register --hub https://localhost:1337 --handle mybot --agent-id mybot --provisioned-by gabriel --json
1544
1545 JSON fields
1546 -----------
1547 status ``"registered"`` (new key) or ``"authenticated"`` (re-auth).
1548 hub Hub URL used.
1549 handle Registered username confirmed by the hub.
1550 identity_id Hub-assigned ID for this identity.
1551 identity_type ``"human"`` or ``"agent"``.
1552 fingerprint SHA-256 hex fingerprint of the public key.
1553 token_stored Always ``true`` on success.
1554 identity_path Path to ``~/.muse/identity.toml``.
1555
1556 Exit codes
1557 ----------
1558 0 Registered or re-authenticated successfully.
1559 1 No hub URL, no local key, hub rejected the signature, or missing ``--handle``.
1560 3 I/O error writing identity file.
1561 """
1562 elapsed = start_timer()
1563 from muse.core.keypair import (
1564 public_key_fingerprint,
1565 public_key_to_b64url,
1566 sign_bytes,
1567 )
1568
1569 hub: str | None = args.hub
1570 handle: str | None = args.handle
1571 label: str | None = args.label
1572 agent: bool = args.agent
1573 agent_id: str | None = getattr(args, "agent_id", None)
1574 provisioned_by: str | None = getattr(args, "provisioned_by", None)
1575 json_out: bool = args.json_out
1576
1577 # --agent-id implies --agent
1578 if agent_id:
1579 agent = True
1580
1581 hub_url = _resolve_hub(hub)
1582 if hub_url is None:
1583 print(
1584 "❌ No hub URL provided.\n"
1585 " Pass --hub <url>, or first run: muse hub connect <url>",
1586 file=sys.stderr,
1587 )
1588 raise SystemExit(ExitCode.USER_ERROR)
1589
1590 if agent_id and not provisioned_by:
1591 print(
1592 "❌ --agent-id requires --provisioned-by <your-handle>.\n"
1593 " Example: muse auth register --agent-id agentception-abc123 "
1594 "--handle agentception-abc123 --provisioned-by gabriel",
1595 file=sys.stderr,
1596 )
1597 raise SystemExit(ExitCode.USER_ERROR)
1598
1599 hostname = hostname_from_url(hub_url)
1600 base_url = _hub_base_url(hub_url)
1601
1602 # Derive the private key from the mnemonic stored in the OS keychain.
1603 # The hd_path was written by `muse auth keygen` into the identity entry.
1604 keygen_entry = load_identity(hub_url, agent_id=agent_id)
1605 hd_path_for_register = keygen_entry.get("hd_path") if keygen_entry else None
1606
1607 private_key = None
1608 if hd_path_for_register:
1609 from muse.core.keychain import load as kc_load
1610 from muse.core.bip39 import mnemonic_to_seed
1611 from muse.core.slip010 import derive_path, to_ed25519_private_key
1612 mnemonic = kc_load()
1613 if mnemonic:
1614 seed = mnemonic_to_seed(mnemonic)
1615 dk = derive_path(seed, hd_path_for_register)
1616 try:
1617 private_key = to_ed25519_private_key(dk)
1618 finally:
1619 dk.zero()
1620
1621 if private_key is None:
1622 keygen_flags = f"--hub {hub_url}"
1623 if agent_id:
1624 keygen_flags += f" --agent-id {agent_id}"
1625 print(
1626 f"❌ No Ed25519 key found for {sanitize_display(hostname)}"
1627 f"{'#' + sanitize_display(agent_id) if agent_id else ''}.\n"
1628 f" Generate one first: muse auth keygen {keygen_flags}",
1629 file=sys.stderr,
1630 )
1631 raise SystemExit(ExitCode.USER_ERROR)
1632
1633 public_key = private_key.public_key()
1634 fingerprint = public_key_fingerprint(public_key)
1635 pub_b64 = public_key_to_b64url(public_key)
1636
1637 # Step 1: request a challenge nonce.
1638 print(f" → Requesting challenge from {sanitize_display(base_url)} …", file=sys.stderr)
1639 challenge_resp = _post_challenge(base_url, {
1640 "fingerprint": fingerprint,
1641 "algorithm": DEFAULT_SIGN_ALGO,
1642 })
1643
1644 challenge_token = challenge_resp.get("challenge_token") or ""
1645 if not challenge_token:
1646 print(
1647 "❌ Hub returned an invalid challenge response (missing challenge_token).",
1648 file=sys.stderr,
1649 )
1650 raise SystemExit(ExitCode.USER_ERROR)
1651
1652 is_new_key = challenge_resp.get("is_new_key") or False
1653
1654 if is_new_key and not handle:
1655 print(
1656 "❌ This key is not yet registered. "
1657 "Pass --handle <username> to register it.",
1658 file=sys.stderr,
1659 )
1660 raise SystemExit(ExitCode.USER_ERROR)
1661
1662 nonce_hex = challenge_token
1663 if not nonce_hex or not all(c in "0123456789abcdef" for c in nonce_hex):
1664 print(f"❌ Hub returned an invalid challenge (expected hex nonce).", file=sys.stderr)
1665 raise SystemExit(ExitCode.USER_ERROR)
1666
1667 try:
1668 nonce_bytes = bytes.fromhex(nonce_hex)
1669 except ValueError as exc:
1670 print(f"❌ Could not decode nonce: {sanitize_display(str(exc))}", file=sys.stderr)
1671 raise SystemExit(ExitCode.USER_ERROR) from exc
1672
1673 # Step 2: sign the nonce.
1674 signature_b64 = sign_bytes(private_key, nonce_bytes)
1675
1676 # Step 3: submit the signed nonce.
1677 print(f" → Submitting signature to {sanitize_display(base_url)} …", file=sys.stderr)
1678 verify_resp = _post_verify(base_url, {
1679 "challenge_token": challenge_token,
1680 "public_key_b64": pub_b64,
1681 "signature_b64": signature_b64,
1682 "handle": handle,
1683 "label": label,
1684 })
1685
1686 returned_handle = verify_resp.get("handle") or handle or ""
1687 identity_id = verify_resp.get("identity_id") or ""
1688 is_new_identity = verify_resp.get("is_new_identity") or False
1689
1690 if not returned_handle:
1691 print("❌ Hub returned an invalid verify response (missing handle).", file=sys.stderr)
1692 raise SystemExit(ExitCode.USER_ERROR)
1693
1694 identity_type = "agent" if agent else "human"
1695
1696 # Build the identity entry — no key_path (mnemonic lives in the OS keychain).
1697 # For agent keys, store under the compound key "hostname#agent_id" so that
1698 # human and agent entries coexist in identity.toml without collision.
1699 entry: IdentityEntry = {
1700 "type": identity_type,
1701 "handle": returned_handle,
1702 "algorithm": DEFAULT_SIGN_ALGO,
1703 "fingerprint": fingerprint,
1704 }
1705 if provisioned_by:
1706 entry["provisioned_by"] = provisioned_by
1707
1708 # Preserve HD derivation path written by `muse auth keygen`.
1709 # keygen_entry was already loaded above to derive the private key.
1710 if hd_path_for_register:
1711 entry["hd_path"] = hd_path_for_register
1712
1713 try:
1714 save_identity(hub_url, entry, agent_id=agent_id)
1715 except OSError as exc:
1716 print(f"❌ Could not write credentials: {exc}", file=sys.stderr)
1717 raise SystemExit(ExitCode.INTERNAL_ERROR) from exc
1718
1719 action = "registered" if is_new_identity else "authenticated"
1720 identity_path = str(get_identity_path())
1721
1722 if json_out:
1723 print(json.dumps(_RegisterJson(
1724 **make_envelope(elapsed),
1725 status=action,
1726 hub=hub_url,
1727 handle=returned_handle,
1728 identity_id=identity_id,
1729 identity_type=identity_type,
1730 fingerprint=fingerprint,
1731 token_stored=False,
1732 identity_path=identity_path,
1733 )))
1734 else:
1735 action_cap = action.capitalize()
1736 prov_line = f"\n Provisioned by: {provisioned_by}" if provisioned_by else ""
1737 print(
1738 f"\n✅ {action_cap} as '{returned_handle}' on {hub_url}{prov_line}\n"
1739 f" Identity ID: {identity_id}\n"
1740 f" Auth method: ed25519 (key fingerprint: {fingerprint[:16]}…)\n"
1741 f" HD path: {hd_path_for_register or 'not set'}\n"
1742 f" Identity stored in: {identity_path}",
1743 file=sys.stderr,
1744 )
1745
1746 # ── whoami ────────────────────────────────────────────────────────────────────
1747
1748 def run_whoami(args: argparse.Namespace) -> None:
1749 """Show the identity stored in ``~/.muse/identity.toml`` for a hub.
1750
1751 Exits non-zero when no identity is stored — useful for agent branching on
1752 authentication status. With ``--all``, shows every configured hub.
1753
1754 Agent quickstart
1755 ----------------
1756 ::
1757
1758 muse auth whoami --json
1759 muse auth whoami --hub https://localhost:1337 --json
1760 muse auth whoami --all --json
1761 muse auth whoami --all --type agent --json
1762
1763 JSON fields
1764 -----------
1765 hub Hub hostname.
1766 type ``"human"`` or ``"agent"``.
1767 handle Registered handle.
1768 fingerprint SHA-256 hex fingerprint of the public key.
1769 key_set ``true`` if an Ed25519 key PEM is stored.
1770 capabilities List of capability strings (may be empty).
1771
1772 With ``--all``, the response is a list of the above objects.
1773
1774 Exit codes
1775 ----------
1776 0 Identity found and printed.
1777 1 No hub configured, or no identity stored for that hub.
1778 """
1779 elapsed = start_timer()
1780 hub: str | None = args.hub
1781 all_hubs: bool = args.all_hubs
1782 json_out: bool = args.json_out
1783 identity_type: str | None = getattr(args, "identity_type", None)
1784
1785 # Validate --type value early
1786 _VALID_TYPES = ("human", "agent")
1787 if identity_type is not None:
1788 if identity_type not in _VALID_TYPES:
1789 print(
1790 f"❌ Invalid --type value: {sanitize_display(identity_type)!r}\n"
1791 f" Valid values: {', '.join(_VALID_TYPES)}",
1792 file=sys.stderr,
1793 )
1794 raise SystemExit(ExitCode.USER_ERROR)
1795
1796 if all_hubs:
1797 identities = list_all_identities()
1798 if not identities:
1799 print("No identities stored. Run `muse auth keygen` + `muse auth register`.", file=sys.stderr)
1800 raise SystemExit(ExitCode.USER_ERROR)
1801
1802 if identity_type is not None:
1803 identities = {
1804 hostname: e
1805 for hostname, e in identities.items()
1806 if e.get("type") == identity_type
1807 }
1808 if not identities:
1809 print(
1810 f"No identities of type {identity_type!r} found.",
1811 file=sys.stderr,
1812 )
1813 raise SystemExit(ExitCode.USER_ERROR)
1814
1815 if json_out:
1816 entries_out: list[_WhoamiJson] = []
1817 for hostname, e in sorted(identities.items()):
1818 entry_dict: _WhoamiJson = {
1819 "hub": hostname,
1820 "type": e.get("type") or "",
1821 "handle": e.get("handle") or "",
1822 "fingerprint": e.get("fingerprint") or "",
1823 "key_set": bool(e.get("hd_path")),
1824 "capabilities": list(e.get("capabilities") or []),
1825 }
1826 pb = e.get("provisioned_by") or ""
1827 if pb:
1828 entry_dict["provisioned_by"] = pb
1829 hdp = e.get("hd_path") or ""
1830 if hdp:
1831 entry_dict["hd_path"] = hdp
1832 entries_out.append(entry_dict)
1833 print(json.dumps(_WhoamiAllJson(**make_envelope(elapsed), identities=entries_out)))
1834 else:
1835 for hostname, stored_entry in sorted(identities.items()):
1836 _display_entry(hostname, stored_entry, json_output=False)
1837 return
1838
1839 hub_url = _resolve_hub(hub)
1840 if hub_url is None:
1841 # No hub in args or repo config — fall back to showing all identities.
1842 identities = list_all_identities()
1843 if not identities:
1844 print("No identities stored. Run `muse auth keygen` + `muse auth register`.", file=sys.stderr)
1845 raise SystemExit(ExitCode.USER_ERROR)
1846 if json_out:
1847 entries_out = []
1848 for hostname, e in sorted(identities.items()):
1849 entry_dict = {
1850 "hub": hostname,
1851 "type": e.get("type") or "",
1852 "handle": e.get("handle") or "",
1853 "fingerprint": e.get("fingerprint") or "",
1854 "key_set": bool(e.get("hd_path")),
1855 "capabilities": list(e.get("capabilities") or []),
1856 }
1857 pb = e.get("provisioned_by") or ""
1858 if pb:
1859 entry_dict["provisioned_by"] = pb
1860 hdp = e.get("hd_path") or ""
1861 if hdp:
1862 entry_dict["hd_path"] = hdp
1863 entries_out.append(entry_dict)
1864 print(json.dumps(_WhoamiAllJson(**make_envelope(elapsed), identities=entries_out)))
1865 else:
1866 for hostname, stored_entry in sorted(identities.items()):
1867 _display_entry(hostname, stored_entry, json_output=False)
1868 return
1869
1870 single_entry = load_identity(hub_url)
1871 if single_entry is None:
1872 print(
1873 f"No identity stored for {sanitize_display(hub_url)}.\n"
1874 f"Run: muse auth keygen --hub {hub_url} && muse auth register --hub {hub_url} --handle <your-handle>",
1875 file=sys.stderr,
1876 )
1877 raise SystemExit(ExitCode.USER_ERROR)
1878
1879 _display_entry(hostname_from_url(hub_url), single_entry, json_output=json_out, elapsed=elapsed)
1880
1881 # ── logout ────────────────────────────────────────────────────────────────────
1882
1883 def run_logout(args: argparse.Namespace) -> None:
1884 """Remove stored credentials for one hub or all hubs.
1885
1886 Deletes entries from ``~/.muse/identity.toml``. Idempotent — logging out
1887 when no identity is stored exits 0 with ``status: "nothing_to_do"``. The
1888 hub URL in ``.muse/config.toml`` is not touched; use ``muse hub disconnect``
1889 to remove the hub association from the repo.
1890
1891 Agent quickstart
1892 ----------------
1893 ::
1894
1895 muse auth logout --json
1896 muse auth logout --hub https://localhost:1337 --json
1897 muse auth logout --all --json
1898
1899 JSON fields
1900 -----------
1901 status ``"ok"`` (credentials removed) or ``"nothing_to_do"`` (none found).
1902 hubs Sorted list of hostnames logged out from; empty on nothing_to_do.
1903 count Number of identities removed; ``0`` on nothing_to_do.
1904
1905 Exit codes
1906 ----------
1907 0 Success (credentials removed or nothing to do).
1908 1 No hub URL could be resolved.
1909 """
1910 elapsed = start_timer()
1911 hub: str | None = args.hub
1912 all_hubs: bool = args.all_hubs
1913 json_out: bool = args.json_out
1914
1915 if all_hubs:
1916 removed_hubs = clear_all_identities()
1917 if not removed_hubs:
1918 if json_out:
1919 print(json.dumps(_LogoutJson(**make_envelope(elapsed), status="nothing_to_do", hubs=[], count=0)))
1920 else:
1921 print("No identities stored.", file=sys.stderr)
1922 return
1923 if json_out:
1924 print(json.dumps(_LogoutJson(
1925 **make_envelope(elapsed),
1926 status="ok",
1927 hubs=removed_hubs, # already sorted by clear_all_identities
1928 count=len(removed_hubs),
1929 )))
1930 else:
1931 hub_list = ", ".join(sanitize_display(h) for h in removed_hubs)
1932 print(
1933 f"✅ Logged out from {len(removed_hubs)} hub(s): {hub_list}",
1934 file=sys.stderr,
1935 )
1936 return
1937
1938 hub_url = _resolve_hub(hub)
1939 if hub_url is None:
1940 print(
1941 "❌ No hub URL provided.\n"
1942 " Pass --hub <url>, or first run: muse hub connect <url>",
1943 file=sys.stderr,
1944 )
1945 raise SystemExit(ExitCode.USER_ERROR)
1946
1947 removed = clear_identity(hub_url)
1948 hub_display = hostname_from_url(hub_url)
1949
1950 if json_out:
1951 if removed:
1952 print(json.dumps(_LogoutJson(
1953 **make_envelope(elapsed),
1954 status="ok",
1955 hubs=[sanitize_display(hub_display)],
1956 count=1,
1957 )))
1958 else:
1959 print(json.dumps(_LogoutJson(**make_envelope(elapsed), status="nothing_to_do", hubs=[], count=0)))
1960 else:
1961 if removed:
1962 print(f"✅ Logged out from {sanitize_display(hub_display)}.", file=sys.stderr)
1963 else:
1964 print(
1965 f"No identity stored for {sanitize_display(hub_display)} — nothing to do.",
1966 file=sys.stderr,
1967 )
1968
1969 # ── show ──────────────────────────────────────────────────────────────────────
1970
1971 def _show_identity_detail(
1972 hostname: str,
1973 entry: IdentityEntry,
1974 *,
1975 json_output: bool,
1976 ) -> None:
1977 """Print detailed identity info including HD derivation paths.
1978
1979 For HD identities the mnemonic is read from the entry only to derive the
1980 AVAX C-Chain address — it is **never** printed. All diagnostic text goes
1981 to stderr; ``--json`` output goes to stdout.
1982
1983 Args:
1984 hostname: Hub hostname (used as the display / JSON ``hub`` key).
1985 entry: Identity entry loaded from ``~/.muse/identity.toml``.
1986 json_output: When ``True``, emit a JSON object to stdout.
1987 """
1988 elapsed = start_timer()
1989
1990 from muse.core.hdkeys import (
1991 DOMAIN_IDENTITY,
1992 DOMAIN_PAYMENTS,
1993 ENTITY_AGENT,
1994 ENTITY_HUMAN,
1995 muse_path,
1996 )
1997
1998 mnemonic = entry.get("mnemonic") or ""
1999 hd_path = entry.get("hd_path") or ""
2000 handle = entry.get("handle") or ""
2001 itype = entry.get("type") or ""
2002 fingerprint = entry.get("fingerprint") or ""
2003 algorithm = entry.get("algorithm") or ""
2004 provisioned_by = entry.get("provisioned_by") or ""
2005 provisioned_by_fingerprint = entry.get("provisioned_by_fingerprint") or ""
2006
2007 derived_paths: _DerivedPaths[str, str] = {
2008 "identity_msign": muse_path(DOMAIN_IDENTITY, ENTITY_HUMAN),
2009 "payments_mpay": muse_path(DOMAIN_PAYMENTS, ENTITY_HUMAN),
2010 "avax_c_chain": "m/44'/60'/0'/0/0",
2011 "agent_slot_0": muse_path(DOMAIN_IDENTITY, ENTITY_AGENT, entity_id=0),
2012 }
2013
2014 avax_address: str | None = None
2015 mnemonic_word_count: int = 0
2016 if hd_path and mnemonic:
2017 from muse.core.bip39 import mnemonic_to_seed
2018 from muse.core.secp256k1_sign import avax_c_chain_address, derive_avax_key
2019
2020 words = mnemonic.strip().split()
2021 mnemonic_word_count = len(words)
2022 try:
2023 seed = mnemonic_to_seed(mnemonic)
2024 avax_key = derive_avax_key(seed)
2025 avax_address = avax_c_chain_address(avax_key.public_key)
2026 except Exception:
2027 pass # non-fatal — omit address rather than crashing
2028
2029 if json_output:
2030 out: _ShowOutputJson = _ShowOutputJson(
2031 **make_envelope(elapsed),
2032 hub=hostname,
2033 handle=handle,
2034 type=itype,
2035 fingerprint=fingerprint,
2036 )
2037 if algorithm:
2038 out["algorithm"] = algorithm
2039 if provisioned_by:
2040 out["provisioned_by"] = provisioned_by
2041 if provisioned_by_fingerprint:
2042 out["provisioned_by_fingerprint"] = provisioned_by_fingerprint
2043 if hd_path:
2044 out["hd_path"] = hd_path
2045 out["mnemonic_word_count"] = mnemonic_word_count
2046 out["derived_paths"] = derived_paths
2047 if avax_address:
2048 out["avax_c_chain_address"] = avax_address
2049 print(json.dumps(out))
2050 else:
2051 print("", file=sys.stderr)
2052 print(f" Hub: {sanitize_display(hostname)}", file=sys.stderr)
2053 print(f" Handle: {sanitize_display(handle) or '—'}", file=sys.stderr)
2054 print(f" Type: {itype or 'unknown'}", file=sys.stderr)
2055 print(f" Fingerprint: {fingerprint[:16] + '…' if fingerprint else '—'}", file=sys.stderr)
2056 if hd_path:
2057 print(f" HD path: {hd_path}", file=sys.stderr)
2058 print(f" Mnemonic: {mnemonic_word_count} words (phrase not shown)", file=sys.stderr)
2059 print("", file=sys.stderr)
2060 print(" Derived paths:", file=sys.stderr)
2061 for name, path in derived_paths.items():
2062 print(f" {name:<20} {path}", file=sys.stderr)
2063 if avax_address:
2064 print(f" AVAX C-Chain: {avax_address}", file=sys.stderr)
2065 print("", file=sys.stderr)
2066
2067 def run_show(args: argparse.Namespace) -> None:
2068 """Display full identity details including HD derivation paths and AVAX address.
2069
2070 For HD identities, prints all six-level Muse derivation paths and the AVAX
2071 C-Chain address. The mnemonic phrase itself is **never** printed — only the
2072 word count is shown.
2073
2074 Agent quickstart
2075 ----------------
2076 ::
2077
2078 muse auth show --hub https://localhost:1337 --json
2079 muse auth show --json # all stored hubs
2080
2081 JSON fields
2082 -----------
2083 hub Hub hostname.
2084 handle Registered handle.
2085 type ``"human"`` or ``"agent"``.
2086 fingerprint ``sha256:<64-hex>`` fingerprint of the public key.
2087 hd_path SLIP-0010 derivation path (HD identities only).
2088 mnemonic_word_count Number of BIP39 mnemonic words (HD identities only).
2089 derived_paths Dict of named paths: identity_msign, payments_mpay, etc. (HD only).
2090 avax_c_chain_address AVAX C-Chain address (HD identities only).
2091
2092 Exit codes
2093 ----------
2094 0 Identity found and displayed.
2095 1 No hub configured and no identities stored.
2096 """
2097 elapsed = start_timer()
2098 hub: str | None = args.hub
2099 json_out: bool = args.json_out
2100
2101 hub_url = _resolve_hub(hub)
2102 if hub_url is None:
2103 identities = list_all_identities()
2104 if not identities:
2105 print("No identities stored. Run `muse auth keygen` + `muse auth register`.", file=sys.stderr)
2106 raise SystemExit(ExitCode.USER_ERROR)
2107 for hostname, stored_entry in sorted(identities.items()):
2108 _show_identity_detail(hostname, stored_entry, json_output=json_out)
2109 return
2110
2111 entry = load_identity(hub_url)
2112 if entry is None:
2113 print(
2114 f"No identity stored for {sanitize_display(hub_url)}.\n"
2115 f"Run: muse auth keygen --hub {hub_url} && muse auth register --hub {hub_url} --handle <your-handle>",
2116 file=sys.stderr,
2117 )
2118 raise SystemExit(ExitCode.USER_ERROR)
2119
2120 _show_identity_detail(hostname_from_url(hub_url), entry, json_output=json_out)
2121
2122 # ── cleanup-keys ──────────────────────────────────────────────────────────────
2123
2124 def run_cleanup_keys(args: argparse.Namespace) -> None:
2125 """Securely overwrite and delete stale PEM files from ~/.muse/keys/.
2126
2127 Every ``*.pem`` file is overwritten with cryptographically random bytes of
2128 the same length, then unlinked. After Phases 1–4 of the key-material
2129 security migration, private keys are derived from the OS-keychain mnemonic
2130 at sign time — PEM files are vestigial unprotected key material.
2131
2132 Agent quickstart
2133 ----------------
2134 ::
2135
2136 muse auth cleanup-keys --json
2137
2138 JSON fields
2139 -----------
2140 destroyed List of absolute paths overwritten and deleted.
2141 count Number of files destroyed.
2142
2143 Exit codes
2144 ----------
2145 0 All PEM files destroyed (or none existed).
2146 3 I/O error during overwrite or unlink.
2147 """
2148 elapsed = start_timer()
2149 json_out: bool = args.json_out
2150
2151 from muse.core.keypair import _KEYS_DIR
2152
2153 destroyed: list[str] = []
2154 pem_files = sorted(_KEYS_DIR.glob("*.pem")) if _KEYS_DIR.exists() else []
2155
2156 for pem_path in pem_files:
2157 try:
2158 size = pem_path.stat().st_size
2159 with pem_path.open("r+b") as fh:
2160 fh.write(os.urandom(size))
2161 fh.flush()
2162 os.fsync(fh.fileno())
2163 pem_path.unlink()
2164 destroyed.append(str(pem_path))
2165 except OSError as exc:
2166 print(f"❌ Could not destroy {pem_path}: {exc}", file=sys.stderr)
2167 raise SystemExit(ExitCode.INTERNAL_ERROR) from exc
2168
2169 if json_out:
2170 print(json.dumps(_CleanupKeysJson(
2171 **make_envelope(elapsed),
2172 destroyed=destroyed,
2173 count=len(destroyed),
2174 )))
2175 else:
2176 if destroyed:
2177 for path in destroyed:
2178 print(f" 🔥 Destroyed: {path}", file=sys.stderr)
2179 print(
2180 f"\n✅ {len(destroyed)} PEM file(s) securely overwritten and deleted.\n"
2181 f" Key material now derived exclusively from the OS-keychain mnemonic.",
2182 file=sys.stderr,
2183 )
2184 else:
2185 print("✅ No PEM files found — nothing to clean up.", file=sys.stderr)
2186
2187 # ── security-check ────────────────────────────────────────────────────────────
2188
2189 def run_security_check(args: argparse.Namespace) -> None:
2190 """Verify the local identity is in a clean, PEM-free state.
2191
2192 Runs four invariant checks:
2193
2194 1. Mnemonic is present in the OS keychain.
2195 2. No PEM files exist in ``~/.muse/keys/``.
2196 3. ``identity.toml`` has no ``key_path`` fields.
2197 4. Fingerprint in ``identity.toml`` matches derivation from the keychain mnemonic.
2198
2199 Exits 0 when all checks pass. Exits 1 if any check fails.
2200
2201 Agent quickstart
2202 ----------------
2203 ::
2204
2205 muse auth security-check --json
2206 muse auth security-check --hub https://localhost:1337 --json
2207
2208 JSON fields
2209 -----------
2210 ok ``true`` when all four checks pass.
2211 mnemonic_in_keychain Mnemonic is present in OS keychain.
2212 no_pem_files No ``*.pem`` files found in ``~/.muse/keys/``.
2213 no_key_path_in_identity No ``key_path`` field in any identity entry.
2214 fingerprint_matches_mnemonic Stored fingerprint matches mnemonic derivation.
2215 pem_files_found List of PEM paths found (empty on success).
2216 key_path_entries List of hostnames whose entries still have ``key_path``.
2217
2218 Exit codes
2219 ----------
2220 0 All checks pass.
2221 1 One or more checks fail.
2222 """
2223 elapsed = start_timer()
2224 hub: str | None = args.hub
2225 json_out: bool = args.json_out
2226
2227 from muse.core.keypair import _KEYS_DIR
2228 from muse.core.keychain import load as kc_load, is_available as kc_avail
2229 from muse.core.identity import list_all_identities
2230
2231 # Check 1: mnemonic in keychain
2232 mnemonic_in_keychain = False
2233 if kc_avail():
2234 mnemonic_in_keychain = bool(kc_load())
2235
2236 # Check 2: no PEM files
2237 pem_files_found: list[str] = []
2238 if _KEYS_DIR.exists():
2239 pem_files_found = [str(p) for p in sorted(_KEYS_DIR.glob("*.pem"))]
2240 no_pem_files = len(pem_files_found) == 0
2241
2242 # Check 3: no key_path in any identity entry
2243 key_path_entries: list[str] = []
2244 all_ids = list_all_identities()
2245 for hostname, entry in all_ids.items():
2246 if entry.get("key_path"):
2247 key_path_entries.append(hostname)
2248 no_key_path_in_identity = len(key_path_entries) == 0
2249
2250 # Check 4: fingerprint matches mnemonic derivation
2251 fingerprint_matches_mnemonic = False
2252 if mnemonic_in_keychain:
2253 hub_url = _resolve_hub(hub)
2254 if hub_url is None and all_ids:
2255 # Pick the first stored identity if no hub specified
2256 first_hostname = next(iter(all_ids))
2257 hub_url = f"https://{first_hostname}"
2258 if hub_url:
2259 from muse.core.identity import load_identity
2260 from muse.core.bip39 import mnemonic_to_seed
2261 from muse.core.keypair import derive_hd_public_info
2262 entry = load_identity(hub_url)
2263 if entry and entry.get("hd_path"):
2264 mnemonic = kc_load()
2265 if mnemonic:
2266 seed = mnemonic_to_seed(mnemonic)
2267 rotation = _parse_rotation_index(entry["hd_path"])
2268 _, expected_fp = derive_hd_public_info(seed, rotation_index=rotation)
2269 stored_fp = entry.get("fingerprint", "")
2270 fingerprint_matches_mnemonic = (stored_fp == expected_fp)
2271
2272 ok = (
2273 mnemonic_in_keychain
2274 and no_pem_files
2275 and no_key_path_in_identity
2276 and fingerprint_matches_mnemonic
2277 )
2278
2279 if json_out:
2280 print(json.dumps(_SecurityCheckJson(
2281 **make_envelope(elapsed),
2282 mnemonic_in_keychain=mnemonic_in_keychain,
2283 no_pem_files=no_pem_files,
2284 no_key_path_in_identity=no_key_path_in_identity,
2285 fingerprint_matches_mnemonic=fingerprint_matches_mnemonic,
2286 pem_files_found=pem_files_found,
2287 key_path_entries=key_path_entries,
2288 ok=ok,
2289 )))
2290 else:
2291 def _line(label: str, passed: bool, detail: str = "") -> None:
2292 icon = "✅" if passed else "❌"
2293 suffix = f" — {detail}" if detail else ""
2294 print(f" {icon} {label}{suffix}", file=sys.stderr)
2295
2296 print("\nmuse auth security-check\n", file=sys.stderr)
2297 _line("Mnemonic in OS keychain", mnemonic_in_keychain,
2298 "" if mnemonic_in_keychain else "run muse auth recover to restore")
2299 _line("No PEM files on disk", no_pem_files,
2300 ", ".join(pem_files_found) if pem_files_found else "")
2301 _line("No key_path in identity.toml", no_key_path_in_identity,
2302 ", ".join(key_path_entries) if key_path_entries else "")
2303 _line("Fingerprint matches mnemonic", fingerprint_matches_mnemonic,
2304 "" if fingerprint_matches_mnemonic else "re-register to fix")
2305 print(f"\n{'✅ All checks passed.' if ok else '❌ One or more checks failed.'}\n",
2306 file=sys.stderr)
2307
2308 if not ok:
2309 raise SystemExit(ExitCode.USER_ERROR)
File History 6 commits
sha256:ae1eca169c5efe00a6415971ed0e7259f68df3e3ce23935c4b1b714c2df6a240 Merge branch 'dev' into main Human 22 days ago
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 30 days ago
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62 baseline: rc14 re-baseline after rc3 store corruption recovery Human patch 30 days ago
sha256:50d413a635f57761c3fce1f70251b957b6423821525bf330747ef4007339222e Merge branch 'dev' into main Human 33 days ago
sha256:fb67fed5a4d3e40de84bdd163de94ef1386570bef1dd1a020a732c8a038962ce Merge branch 'dev' into main Human 52 days ago
sha256:1c4b3e3a9a1f300774c3ee662b572a698d5fd405bf765a71e6011a2e9c3eaaaa feat: Muse — version control for the agent era Human 104 days ago