auth.py python
956 lines 38.4 KB
Raw
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 33 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 # Auth endpoints on the hub (relative to the hub base URL).
105 _CHALLENGE_PATH = "/api/auth/challenge"
106 _VERIFY_PATH = "/api/auth/verify"
107
108 # Hard cap on response size to prevent OOM from a compromised hub.
109 _MAX_RESPONSE_BYTES = 1 * 1024 * 1024 # 1 MiB
110
111 # Only allow http and https — no file://, ftp://, data://, etc.
112 _ALLOWED_SCHEMES = frozenset({"http", "https"})
113
114
115 # ── TypedDicts ────────────────────────────────────────────────────────────────
116
117 class _KeygenJson(TypedDict):
118 """JSON schema for ``muse auth keygen --json``."""
119
120 status: str # "ok"
121 hub: str # hub URL
122 hostname: str # extracted hostname
123 key_path: str # absolute path to private key PEM
124 public_key_b64: str # base64url-encoded public key
125 fingerprint: str # SHA-256 hex fingerprint
126
127
128 class _RegisterJson(TypedDict):
129 """JSON schema for ``muse auth register --json``."""
130
131 status: str # "registered" | "authenticated"
132 hub: str # hub URL
133 handle: str # registered username
134 identity_id: str # hub-assigned ID
135 identity_type: str # "human" | "agent"
136 fingerprint: str # SHA-256 hex fingerprint of the public key
137 token_stored: bool # always true on success
138 identity_path: str # path to ~/.muse/identity.toml
139
140
141 class _WhoamiJson(TypedDict):
142 """JSON schema for ``muse auth whoami --json`` (per-identity entry)."""
143
144 hub: str # hostname key
145 type: str # "human" | "agent" | ""
146 handle: str # registered handle
147 fingerprint: str # SHA-256 hex of the public key
148 key_set: bool # true if an Ed25519 key is stored
149 capabilities: list[str]
150
151
152 class _LogoutJson(TypedDict):
153 """JSON schema for ``muse auth logout --json``."""
154
155 status: str # "ok" | "nothing_to_do"
156 hubs: list[str] # hostnames logged out from
157 count: int # number of identities removed
158
159
160 class _JsonPayload(TypedDict, total=False):
161 """Generic JSON request payload for hub auth endpoints (all fields optional str)."""
162
163 fingerprint: str
164 algorithm: str
165 challenge_token: str
166 public_key_b64: str
167 signature_b64: str
168 handle: str
169 label: str
170
171
172 class _ChallengeResp(TypedDict, total=False):
173 """Parsed response from the hub challenge endpoint."""
174
175 challengeToken: str
176 challenge_token: str
177 isNewKey: bool
178 is_new_key: bool
179
180
181 class _VerifyResp(TypedDict, total=False):
182 """Parsed response from the hub verify endpoint."""
183
184 handle: str
185 identityId: str
186 identity_id: str
187 isNewIdentity: bool
188 is_new_identity: bool
189
190
191 # ── HTTP helpers ──────────────────────────────────────────────────────────────
192
193 def _hub_base_url(hub_url: str) -> str:
194 """Extract the base URL (scheme + host + port) from a full hub URL.
195
196 Examples::
197
198 "https://musehub.ai/gabriel/muse" → "https://musehub.ai"
199 "http://localhost:10003" → "http://localhost:10003"
200
201 Raises:
202 SystemExit: If the URL scheme is not ``http`` or ``https``.
203 """
204 parsed = urllib.parse.urlparse(hub_url)
205 if parsed.scheme.lower() not in _ALLOWED_SCHEMES:
206 print(
207 f"❌ Hub URL scheme '{sanitize_display(parsed.scheme)}' is not allowed. "
208 f"Use http or https.",
209 file=sys.stderr,
210 )
211 raise SystemExit(ExitCode.USER_ERROR)
212 port_str = f":{parsed.port}" if parsed.port else ""
213 return f"{parsed.scheme}://{parsed.hostname}{port_str}"
214
215
216 def _json_post_raw(base_url: str, path: str, payload: _JsonPayload) -> _JsonPayload:
217 """POST *payload* as JSON and return the raw parsed response dict.
218
219 Private implementation — call :func:`_post_challenge` or
220 :func:`_post_verify` instead. Validates the URL scheme before any
221 network I/O to prevent SSRF.
222
223 Args:
224 base_url: Hub base URL (e.g. ``"https://musehub.ai"``).
225 path: Endpoint path (e.g. ``"/api/auth/challenge"``).
226 payload: Dict to serialise as the JSON body (``None`` values omitted).
227
228 Returns:
229 Parsed JSON response body as a dict.
230
231 Raises:
232 SystemExit: On invalid URL scheme, HTTP error, or network failure.
233 """
234 scheme = urllib.parse.urlparse(base_url).scheme.lower()
235 if scheme not in _ALLOWED_SCHEMES:
236 print(
237 f"❌ Hub URL scheme '{sanitize_display(scheme)}' is not allowed. "
238 f"Use http or https.",
239 file=sys.stderr,
240 )
241 raise SystemExit(ExitCode.USER_ERROR)
242
243 url = f"{base_url.rstrip('/')}{path}"
244 clean_payload = {k: v for k, v in payload.items() if v is not None}
245 body_bytes = json.dumps(clean_payload).encode("utf-8")
246 req = urllib.request.Request(
247 url=url,
248 data=body_bytes,
249 headers={
250 "Content-Type": "application/json",
251 "Accept": "application/json",
252 },
253 method="POST",
254 )
255 try:
256 with urllib.request.urlopen(req, timeout=30) as resp: # noqa: S310
257 raw: bytes = resp.read(_MAX_RESPONSE_BYTES + 1)
258 except urllib.error.HTTPError as exc:
259 try:
260 err_body = exc.read().decode("utf-8", errors="replace")[:400]
261 except Exception: # noqa: BLE001
262 err_body = ""
263 print(
264 f"❌ HTTP {exc.code} from {sanitize_display(url)}: {sanitize_display(err_body)}",
265 file=sys.stderr,
266 )
267 raise SystemExit(ExitCode.USER_ERROR) from exc
268 except urllib.error.URLError as exc:
269 print(
270 f"❌ Network error contacting {sanitize_display(url)}: "
271 f"{sanitize_display(str(exc.reason))}",
272 file=sys.stderr,
273 )
274 raise SystemExit(ExitCode.USER_ERROR) from exc
275
276 if len(raw) > _MAX_RESPONSE_BYTES:
277 print(f"❌ Response from {sanitize_display(url)} exceeds size limit.", file=sys.stderr)
278 raise SystemExit(ExitCode.USER_ERROR)
279
280 parsed = json.loads(raw)
281 if not isinstance(parsed, dict):
282 print(f"❌ Unexpected response shape from {sanitize_display(url)}.", file=sys.stderr)
283 raise SystemExit(ExitCode.USER_ERROR)
284 return parsed
285
286
287 def _post_challenge(base_url: str, payload: _JsonPayload) -> _ChallengeResp:
288 """POST to the challenge endpoint and return a typed response."""
289 raw = _json_post_raw(base_url, _CHALLENGE_PATH, payload)
290 return _ChallengeResp(
291 challengeToken=str(raw.get("challengeToken") or ""),
292 challenge_token=str(raw.get("challenge_token") or ""),
293 isNewKey=bool(raw.get("isNewKey") or raw.get("is_new_key")),
294 is_new_key=bool(raw.get("is_new_key")),
295 )
296
297
298 def _post_verify(base_url: str, payload: _JsonPayload) -> _VerifyResp:
299 """POST to the verify endpoint and return a typed response."""
300 raw = _json_post_raw(base_url, _VERIFY_PATH, payload)
301 return _VerifyResp(
302 handle=str(raw.get("handle") or ""),
303 identityId=str(raw.get("identityId") or ""),
304 identity_id=str(raw.get("identity_id") or ""),
305 isNewIdentity=bool(raw.get("isNewIdentity") or raw.get("is_new_identity")),
306 is_new_identity=bool(raw.get("is_new_identity")),
307 )
308
309
310 # ── Helpers ───────────────────────────────────────────────────────────────────
311
312 def _resolve_hub(hub_opt: str | None, repo_root: pathlib.Path | None = None) -> str | None:
313 """Return the hub URL: explicit option → repo config → None."""
314 if hub_opt:
315 return hub_opt
316 return get_hub_url(repo_root)
317
318
319 def _display_entry(hostname: str, entry: IdentityEntry, *, json_output: bool) -> None:
320 """Print an identity entry. JSON → stdout; human-readable → stderr."""
321 itype = entry.get("type") or ""
322 handle = entry.get("handle") or ""
323 fingerprint = entry.get("fingerprint") or ""
324 key_path = entry.get("key_path") or ""
325 caps = list(entry.get("capabilities") or [])
326
327 if json_output:
328 out: _WhoamiJson = {
329 "hub": hostname,
330 "type": itype,
331 "handle": handle,
332 "fingerprint": fingerprint,
333 "key_set": bool(key_path),
334 "capabilities": caps,
335 }
336 print(json.dumps(out))
337 else:
338 print("", file=sys.stderr)
339 print(f" Hub: {sanitize_display(hostname)}", file=sys.stderr)
340 print(f" Type: {itype or 'unknown'}", file=sys.stderr)
341 print(f" Handle: {sanitize_display(handle) or '—'}", file=sys.stderr)
342 print(f" Fingerprint: {fingerprint[:16] + '…' if fingerprint else '—'}", file=sys.stderr)
343 print(f" Key: {sanitize_display(key_path) or 'not set — run muse auth keygen'}", file=sys.stderr)
344 if caps:
345 print(f" Caps: {' '.join(caps)}", file=sys.stderr)
346 print("", file=sys.stderr)
347
348
349 # ── register ──────────────────────────────────────────────────────────────────
350
351 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
352 """Register the ``muse auth`` subcommand tree and all its flags.
353
354 Every subcommand accepts ``--json`` for machine-readable output on stdout.
355 All progress and diagnostic messages go to stderr.
356 """
357 parser = subparsers.add_parser(
358 "auth",
359 help="Identity management.",
360 description=__doc__,
361 formatter_class=argparse.RawDescriptionHelpFormatter,
362 )
363 subs = parser.add_subparsers(dest="subcommand", metavar="SUBCOMMAND")
364 subs.required = True
365
366 # ── keygen ───────────────────────────────────────────────────────────────
367 keygen_p = subs.add_parser(
368 "keygen",
369 help="Generate a new Ed25519 keypair for public-key authentication.",
370 description=(
371 "Generates a fresh Ed25519 keypair and stores the private key at\n"
372 "~/.muse/keys/{hostname}.pem (mode 0o600).\n"
373 "The public key fingerprint is printed to stderr for verification.\n"
374 "Run 'muse auth register' afterward to register the key with the hub."
375 ),
376 formatter_class=argparse.RawDescriptionHelpFormatter,
377 )
378 keygen_p.add_argument("--hub", default=None, metavar="URL",
379 help="Hub URL (e.g. https://musehub.ai). Falls back to [hub] url in config.toml.")
380 keygen_p.add_argument("--agent-id", default=None, metavar="AGENT_ID", dest="agent_id",
381 help=(
382 "Generate a dedicated keypair for this agent handle "
383 "(stored at ~/.muse/keys/{hostname}__{agent_id}.pem). "
384 "Omit for the human (operator) key."
385 ))
386 keygen_p.add_argument("--label", default=None, metavar="LABEL",
387 help='Friendly key label, e.g. "MacBook Pro" or "CI agent". Stored locally.')
388 keygen_p.add_argument("--force", action="store_true",
389 help="Overwrite an existing key for this hub without prompting.")
390 keygen_p.add_argument("--json", action="store_true", dest="json_output", default=False,
391 help="Emit a JSON object to stdout on success.")
392 keygen_p.set_defaults(func=run_keygen)
393
394 # ── register ─────────────────────────────────────────────────────────────
395 register_p = subs.add_parser(
396 "register",
397 help="Register the local Ed25519 key with a MuseHub instance (challenge-response).",
398 description=(
399 "Performs the Ed25519 challenge-response flow with the hub:\n"
400 " 1. POST /api/auth/challenge (fingerprint → nonce)\n"
401 " 2. Sign the nonce with the local private key\n"
402 " 3. POST /api/auth/verify (signed nonce → session token)\n\n"
403 "The resulting session token is saved to ~/.muse/identity.toml (0o600).\n"
404 "Use this command for initial registration AND to refresh an expired token."
405 ),
406 formatter_class=argparse.RawDescriptionHelpFormatter,
407 )
408 register_p.add_argument("--hub", default=None, metavar="URL",
409 help="Hub URL (e.g. https://musehub.ai). Falls back to [hub] url in config.toml.")
410 register_p.add_argument("--handle", default=None, metavar="HANDLE",
411 help=(
412 "Desired username (required for first-time registration; "
413 "ignored if the key is already registered)."
414 ))
415 register_p.add_argument("--label", default=None, metavar="LABEL",
416 help='Friendly key label, e.g. "MacBook Pro" or "CI agent".')
417 register_p.add_argument("--agent", action="store_true",
418 help="Mark this identity as an agent (default: human).")
419 register_p.add_argument("--agent-id", default=None, metavar="AGENT_ID", dest="agent_id",
420 help=(
421 "Agent handle whose dedicated key should be registered. "
422 "Loads ~/.muse/keys/{hostname}__{agent_id}.pem and stores "
423 "the identity under the compound key 'hostname#agent_id'. "
424 "Implies --agent."
425 ))
426 register_p.add_argument("--provisioned-by", default=None, metavar="HANDLE", dest="provisioned_by",
427 help=(
428 "Handle of the human who is provisioning this agent key. "
429 "Recorded in identity.toml as the trust-chain root. "
430 "Required when --agent-id is used."
431 ))
432 register_p.add_argument("--json", action="store_true", dest="json_output", default=False,
433 help="Emit a JSON object to stdout on success.")
434 register_p.set_defaults(func=run_register)
435
436 # ── whoami ────────────────────────────────────────────────────────────────
437 whoami_p = subs.add_parser(
438 "whoami",
439 help="Show the current identity stored in ~/.muse/identity.toml.",
440 description=(
441 "Print the identity stored in ~/.muse/identity.toml for a hub.\n"
442 "Exits non-zero when no identity is stored — useful for agent branching:\n\n"
443 " muse auth whoami --json || muse auth register --hub <url> --handle <name> --agent\n\n"
444 "With --all --json, emits a single JSON array (one object per hub):\n\n"
445 " muse auth whoami --all --json | jq '.[] | select(.type == \"agent\")'"
446 ),
447 formatter_class=argparse.RawDescriptionHelpFormatter,
448 )
449 whoami_p.add_argument("--hub", default=None, metavar="URL",
450 help="Hub URL to inspect. Defaults to the repo's configured hub.")
451 whoami_p.add_argument("--all", "-a", action="store_true", dest="all_hubs",
452 help="Show identities for all configured hubs.")
453 whoami_p.add_argument("--json", "-j", action="store_true", dest="json_output",
454 help="Emit JSON to stdout. With --all, emits a JSON array.")
455 whoami_p.set_defaults(func=run_whoami)
456
457 # ── logout ────────────────────────────────────────────────────────────────
458 logout_p = subs.add_parser(
459 "logout",
460 help="Remove stored credentials for a hub.",
461 description=(
462 "Remove the signing identity stored in ~/.muse/identity.toml for a hub.\n"
463 "Operation is idempotent — logging out when no identity is stored exits 0.\n\n"
464 "Agent quickstart:\n"
465 " muse auth logout --json # single hub, JSON result\n"
466 " muse auth logout --all --json # remove all hubs, sorted list\n\n"
467 "JSON output shape (stdout):\n"
468 " {\"status\": \"ok\" | \"nothing_to_do\",\n"
469 " \"hub\": \"<hostname>\", # single-hub only\n"
470 " \"hubs\": [\"<hostname>\", ...], # --all only (sorted)\n"
471 " \"count\": <int>} # --all only\n\n"
472 "Exit codes: 0 success (incl. nothing to do), 1 bad arguments, 3 internal error."
473 ),
474 formatter_class=argparse.RawDescriptionHelpFormatter,
475 )
476 logout_p.add_argument("--hub", default=None, metavar="URL",
477 help="Hub URL to log out from. Defaults to the repo's configured hub.")
478 logout_p.add_argument("--all", "-a", action="store_true", dest="all_hubs",
479 help="Remove credentials for ALL configured hubs.")
480 logout_p.add_argument("--json", "-j", action="store_true", dest="json_output", default=False,
481 help="Emit a JSON object to stdout on completion.")
482 logout_p.set_defaults(func=run_logout)
483
484
485 # ── keygen ────────────────────────────────────────────────────────────────────
486
487 def run_keygen(args: argparse.Namespace) -> None:
488 """Generate a new Ed25519 keypair for this hub (or for a specific agent).
489
490 Human keys are written to ``~/.muse/keys/{hostname}.pem`` (0o600).
491 Agent keys are written to ``~/.muse/keys/{hostname}__{agent_id}.pem``.
492
493 All progress text goes to stderr. With ``--json``, a :class:`_KeygenJson`
494 object is emitted to stdout so agents can capture the fingerprint and
495 public key without parsing human-readable text.
496
497 Run ``muse auth register`` immediately after to register the public key
498 with the hub and obtain a session token.
499 """
500 from muse.core.keypair import generate_keypair, key_path_for
501
502 hub: str | None = args.hub
503 agent_id: str | None = getattr(args, "agent_id", None)
504 label: str | None = args.label
505 force: bool = args.force
506 json_output: bool = args.json_output
507
508 hub_url = _resolve_hub(hub)
509 if hub_url is None:
510 print(
511 "❌ No hub URL provided.\n"
512 " Pass --hub <url>, or first run: muse hub connect <url>",
513 file=sys.stderr,
514 )
515 raise SystemExit(ExitCode.USER_ERROR)
516
517 hostname = hostname_from_url(hub_url)
518 key_path = key_path_for(hostname, agent_id)
519
520 if key_path.exists() and not force:
521 subject = f"{sanitize_display(hostname)}#{sanitize_display(agent_id)}" if agent_id else sanitize_display(hostname)
522 print(
523 f"⚠️ A key already exists for {subject}: {key_path}\n"
524 " Pass --force to overwrite it (you will need to re-register).",
525 file=sys.stderr,
526 )
527 raise SystemExit(ExitCode.USER_ERROR)
528
529 if key_path.exists() and force:
530 subject = f"{sanitize_display(hostname)}#{sanitize_display(agent_id)}" if agent_id else sanitize_display(hostname)
531 print(f"⚠️ Overwriting existing key for {subject}.", file=sys.stderr)
532
533 pub_b64, fingerprint = generate_keypair(hostname, agent_id)
534
535 if json_output:
536 out: _KeygenJson = {
537 "status": "ok",
538 "hub": hub_url,
539 "hostname": hostname,
540 "key_path": str(key_path),
541 "public_key_b64": pub_b64,
542 "fingerprint": fingerprint,
543 }
544 print(json.dumps(out))
545 else:
546 label_note = f" (label: {label!r})" if label else ""
547 agent_note = f" (agent: {agent_id})" if agent_id else ""
548 register_flags = f"--hub {hub_url} --handle <your-handle>"
549 if agent_id:
550 register_flags = f"--hub {hub_url} --agent-id {agent_id} --handle {agent_id} --provisioned-by <your-handle>"
551 print(
552 f"✅ Ed25519 keypair generated{label_note}{agent_note}\n"
553 f" Private key: {key_path}\n"
554 f" Public key (b64url): {pub_b64}\n"
555 f" Fingerprint (SHA-256): {fingerprint}\n\n"
556 f" Next step: muse auth register {register_flags}",
557 file=sys.stderr,
558 )
559
560
561 # ── register ──────────────────────────────────────────────────────────────────
562
563 def run_register(args: argparse.Namespace) -> None:
564 """Register the local Ed25519 key with a MuseHub instance.
565
566 Performs the full challenge-response flow:
567
568 1. Derives the public key from the local private key.
569 2. Requests a challenge nonce from ``/api/auth/challenge``.
570 3. Signs the raw nonce bytes with the Ed25519 private key.
571 4. Submits the signature to ``/api/auth/verify``.
572 5. Stores the returned identity in ``~/.muse/identity.toml``.
573
574 When ``--agent-id`` is supplied the agent's dedicated key
575 (``~/.muse/keys/{hostname}__{agent_id}.pem``) is used and the entry is
576 stored under the compound key ``"hostname#agent_id"``.
577
578 All progress messages go to stderr. With ``--json``, a
579 :class:`_RegisterJson` object is emitted to stdout on success — agents
580 can capture the identity ID and token path without parsing text.
581
582 Security notes:
583 - The private key never leaves the local machine; only the signature is sent.
584 - The nonce is signed as raw bytes — Ed25519 includes collision-resistant
585 prehashing internally.
586 - ``_json_post`` validates the hub URL scheme before any network I/O.
587 """
588 from muse.core.keypair import (
589 key_path_for,
590 load_private_key,
591 public_key_fingerprint,
592 public_key_to_b64url,
593 sign_bytes,
594 )
595
596 hub: str | None = args.hub
597 handle: str | None = args.handle
598 label: str | None = args.label
599 agent: bool = args.agent
600 agent_id: str | None = getattr(args, "agent_id", None)
601 provisioned_by: str | None = getattr(args, "provisioned_by", None)
602 json_output: bool = args.json_output
603
604 # --agent-id implies --agent
605 if agent_id:
606 agent = True
607
608 hub_url = _resolve_hub(hub)
609 if hub_url is None:
610 print(
611 "❌ No hub URL provided.\n"
612 " Pass --hub <url>, or first run: muse hub connect <url>",
613 file=sys.stderr,
614 )
615 raise SystemExit(ExitCode.USER_ERROR)
616
617 if agent_id and not provisioned_by:
618 print(
619 "❌ --agent-id requires --provisioned-by <your-handle>.\n"
620 " Example: muse auth register --agent-id agentception-abc123 "
621 "--handle agentception-abc123 --provisioned-by gabriel",
622 file=sys.stderr,
623 )
624 raise SystemExit(ExitCode.USER_ERROR)
625
626 hostname = hostname_from_url(hub_url)
627 base_url = _hub_base_url(hub_url)
628
629 private_key = load_private_key(hostname, agent_id)
630 if private_key is None:
631 keygen_flags = f"--hub {hub_url}"
632 if agent_id:
633 keygen_flags += f" --agent-id {agent_id}"
634 print(
635 f"❌ No Ed25519 key found for {sanitize_display(hostname)}"
636 + (f"#{sanitize_display(agent_id)}" if agent_id else "") + ".\n"
637 f" Generate one first: muse auth keygen {keygen_flags}",
638 file=sys.stderr,
639 )
640 raise SystemExit(ExitCode.USER_ERROR)
641
642 public_key = private_key.public_key()
643 fingerprint = public_key_fingerprint(public_key)
644 pub_b64 = public_key_to_b64url(public_key)
645
646 # Step 1: request a challenge nonce.
647 print(f" → Requesting challenge from {sanitize_display(base_url)} …", file=sys.stderr)
648 challenge_resp = _post_challenge(base_url, {
649 "fingerprint": fingerprint,
650 "algorithm": "ed25519",
651 })
652
653 challenge_token = challenge_resp.get("challengeToken") or challenge_resp.get("challenge_token") or ""
654 if not challenge_token:
655 print(
656 "❌ Hub returned an invalid challenge response (missing challengeToken).",
657 file=sys.stderr,
658 )
659 raise SystemExit(ExitCode.USER_ERROR)
660
661 is_new_key = challenge_resp.get("isNewKey") or challenge_resp.get("is_new_key") or False
662
663 if is_new_key and not handle:
664 print(
665 "❌ This key is not yet registered. "
666 "Pass --handle <username> to register it.",
667 file=sys.stderr,
668 )
669 raise SystemExit(ExitCode.USER_ERROR)
670
671 nonce_hex = challenge_token
672 if not nonce_hex or not all(c in "0123456789abcdef" for c in nonce_hex):
673 print(f"❌ Hub returned an invalid challenge (expected hex nonce).", file=sys.stderr)
674 raise SystemExit(ExitCode.USER_ERROR)
675
676 try:
677 nonce_bytes = bytes.fromhex(nonce_hex)
678 except ValueError as exc:
679 print(f"❌ Could not decode nonce: {sanitize_display(str(exc))}", file=sys.stderr)
680 raise SystemExit(ExitCode.USER_ERROR) from exc
681
682 # Step 2: sign the nonce.
683 signature_b64 = sign_bytes(private_key, nonce_bytes)
684
685 # Step 3: submit the signed nonce.
686 print(f" → Submitting signature to {sanitize_display(base_url)} …", file=sys.stderr)
687 verify_resp = _post_verify(base_url, {
688 "challenge_token": challenge_token,
689 "public_key_b64": pub_b64,
690 "signature_b64": signature_b64,
691 "handle": handle,
692 "label": label,
693 })
694
695 returned_handle = verify_resp.get("handle") or handle or ""
696 identity_id = verify_resp.get("identityId") or verify_resp.get("identity_id") or ""
697 is_new_identity = verify_resp.get("isNewIdentity") or verify_resp.get("is_new_identity") or False
698
699 if not returned_handle:
700 print("❌ Hub returned an invalid verify response (missing handle).", file=sys.stderr)
701 raise SystemExit(ExitCode.USER_ERROR)
702
703 identity_type = "agent" if agent else "human"
704
705 # Store handle + key_path + fingerprint.
706 # For agent keys, store under the compound key "hostname#agent_id" so that
707 # human and agent entries coexist in identity.toml without collision.
708 key_path = str(key_path_for(hostname, agent_id))
709 entry: IdentityEntry = {
710 "type": identity_type,
711 "handle": returned_handle,
712 "key_path": key_path,
713 "algorithm": "ed25519",
714 "fingerprint": fingerprint,
715 }
716 if provisioned_by:
717 entry["provisioned_by"] = provisioned_by
718
719 try:
720 save_identity(hub_url, entry, agent_id=agent_id)
721 except OSError as exc:
722 print(f"❌ Could not write credentials: {exc}", file=sys.stderr)
723 raise SystemExit(ExitCode.INTERNAL_ERROR) from exc
724
725 action = "registered" if is_new_identity else "authenticated"
726 identity_path = str(get_identity_path())
727
728 if json_output:
729 result: _RegisterJson = {
730 "status": action,
731 "hub": hub_url,
732 "handle": returned_handle,
733 "identity_id": identity_id,
734 "identity_type": identity_type,
735 "fingerprint": fingerprint,
736 "token_stored": False,
737 "identity_path": identity_path,
738 }
739 print(json.dumps(result))
740 else:
741 action_cap = action.capitalize()
742 prov_line = f"\n Provisioned by: {provisioned_by}" if provisioned_by else ""
743 print(
744 f"\n✅ {action_cap} as '{returned_handle}' on {hub_url}{prov_line}\n"
745 f" Identity ID: {identity_id}\n"
746 f" Auth method: ed25519 (key fingerprint: {fingerprint[:16]}…)\n"
747 f" Key path: {key_path}\n"
748 f" Identity stored in: {identity_path}",
749 file=sys.stderr,
750 )
751
752
753 # ── whoami ────────────────────────────────────────────────────────────────────
754
755 def run_whoami(args: argparse.Namespace) -> None:
756 """Show the identity stored in ``~/.muse/identity.toml`` for a hub.
757
758 Exits non-zero when no identity is stored so agents can branch on
759 authentication status::
760
761 muse auth whoami --hub http://localhost:10003 --json \\
762 || muse auth register --hub http://localhost:10003 --handle my-agent --agent
763
764 JSON output (``--json``)
765 ------------------------
766 Emits a :class:`_WhoamiJson` object to stdout::
767
768 {
769 "hub": "<hostname>",
770 "type": "human" | "agent",
771 "handle": "<registered handle>",
772 "key_set": true | false,
773 "capabilities": ["read", ...] ← only present when non-empty
774 }
775
776 With ``--all --json``, emits a **single JSON array** (one object per hub)
777 so agents can pipe to ``jq``::
778
779 muse auth whoami --all --json | jq '.[] | select(.type == "agent")'
780
781 Exit codes
782 ----------
783 0 Identity found and printed.
784 1 No hub configured, or no identity stored for that hub.
785 """
786 hub: str | None = args.hub
787 all_hubs: bool = args.all_hubs
788 json_output: bool = args.json_output
789
790 if all_hubs:
791 identities = list_all_identities()
792 if not identities:
793 print("No identities stored. Run `muse auth keygen` + `muse auth register`.", file=sys.stderr)
794 raise SystemExit(ExitCode.USER_ERROR)
795 if json_output:
796 entries = [
797 {
798 "hub": hostname,
799 "type": e.get("type") or "",
800 "handle": e.get("handle") or "",
801 "fingerprint": e.get("fingerprint") or "",
802 "key_set": bool(e.get("key_path")),
803 "capabilities": list(e.get("capabilities") or []),
804 }
805 for hostname, e in sorted(identities.items())
806 ]
807 print(json.dumps(entries))
808 else:
809 for hostname, stored_entry in sorted(identities.items()):
810 _display_entry(hostname, stored_entry, json_output=False)
811 return
812
813 hub_url = _resolve_hub(hub)
814 if hub_url is None:
815 # No hub in args or repo config — fall back to showing all identities.
816 identities = list_all_identities()
817 if not identities:
818 print("No identities stored. Run `muse auth keygen` + `muse auth register`.", file=sys.stderr)
819 raise SystemExit(ExitCode.USER_ERROR)
820 if json_output:
821 entries = [
822 {
823 "hub": hostname,
824 "type": e.get("type") or "",
825 "handle": e.get("handle") or "",
826 "fingerprint": e.get("fingerprint") or "",
827 "key_set": bool(e.get("key_path")),
828 "capabilities": list(e.get("capabilities") or []),
829 }
830 for hostname, e in sorted(identities.items())
831 ]
832 print(json.dumps(entries))
833 else:
834 for hostname, stored_entry in sorted(identities.items()):
835 _display_entry(hostname, stored_entry, json_output=False)
836 return
837
838 single_entry = load_identity(hub_url)
839 if single_entry is None:
840 print(
841 f"No identity stored for {sanitize_display(hub_url)}.\n"
842 f"Run: muse auth keygen --hub {hub_url} && muse auth register --hub {hub_url} --handle <your-handle>",
843 file=sys.stderr,
844 )
845 raise SystemExit(ExitCode.USER_ERROR)
846
847 _display_entry(hostname_from_url(hub_url), single_entry, json_output=json_output)
848
849
850 # ── logout ────────────────────────────────────────────────────────────────────
851
852 def run_logout(args: argparse.Namespace) -> None:
853 """Remove stored credentials for one hub or all hubs.
854
855 Deletes the matching entry (or entries) from ``~/.muse/identity.toml``.
856 The hub URL in ``.muse/config.toml`` is **not** touched — use
857 ``muse hub disconnect`` to remove the hub association from the repo too.
858
859 Idempotent
860 ----------
861 Calling ``logout`` when no identity is stored does **not** fail. The
862 JSON response uses ``status: "nothing_to_do"`` so agents can distinguish
863 "was logged in, now removed" from "was not logged in" without special-casing
864 exit codes::
865
866 muse auth logout --hub http://localhost:10003 --json
867 # → {"status": "nothing_to_do", "hubs": [], "count": 0} (exit 0)
868
869 Agent quickstart
870 ----------------
871 ::
872
873 muse auth logout --all --json # clear every hub in one shot
874
875 JSON output (``--json``)
876 ------------------------
877 ::
878
879 {
880 "status": "ok" | "nothing_to_do",
881 "hubs": ["hostname1", ...], ← sorted; empty on nothing_to_do
882 "count": <int> ← 0 on nothing_to_do
883 }
884
885 All diagnostic messages go to stderr regardless of ``--json``.
886
887 Performance note
888 ----------------
889 ``--all`` performs a single atomic read-modify-write via
890 :func:`~muse.core.identity.clear_all_identities`, regardless of how many
891 hubs are stored. It does **not** call :func:`~muse.core.identity.clear_identity`
892 in a loop.
893
894 Exit codes
895 ----------
896 0 Success (credentials removed or nothing to do).
897 1 No hub URL could be resolved (no ``--hub`` flag and no hub in config).
898 """
899 hub: str | None = args.hub
900 all_hubs: bool = args.all_hubs
901 json_output: bool = args.json_output
902
903 if all_hubs:
904 removed_hubs = clear_all_identities()
905 if not removed_hubs:
906 if json_output:
907 out: _LogoutJson = {"status": "nothing_to_do", "hubs": [], "count": 0}
908 print(json.dumps(out))
909 else:
910 print("No identities stored.", file=sys.stderr)
911 return
912 if json_output:
913 result: _LogoutJson = {
914 "status": "ok",
915 "hubs": removed_hubs, # already sorted by clear_all_identities
916 "count": len(removed_hubs),
917 }
918 print(json.dumps(result))
919 else:
920 hub_list = ", ".join(sanitize_display(h) for h in removed_hubs)
921 print(
922 f"✅ Logged out from {len(removed_hubs)} hub(s): {hub_list}",
923 file=sys.stderr,
924 )
925 return
926
927 hub_url = _resolve_hub(hub)
928 if hub_url is None:
929 print(
930 "❌ No hub URL provided.\n"
931 " Pass --hub <url>, or first run: muse hub connect <url>",
932 file=sys.stderr,
933 )
934 raise SystemExit(ExitCode.USER_ERROR)
935
936 removed = clear_identity(hub_url)
937 hub_display = hostname_from_url(hub_url)
938
939 if json_output:
940 if removed:
941 single_result: _LogoutJson = {
942 "status": "ok",
943 "hubs": [sanitize_display(hub_display)],
944 "count": 1,
945 }
946 else:
947 single_result = {"status": "nothing_to_do", "hubs": [], "count": 0}
948 print(json.dumps(single_result))
949 else:
950 if removed:
951 print(f"✅ Logged out from {sanitize_display(hub_display)}.", file=sys.stderr)
952 else:
953 print(
954 f"No identity stored for {sanitize_display(hub_display)} — nothing to do.",
955 file=sys.stderr,
956 )
File History 2 commits
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 33 days ago
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62 baseline: rc14 re-baseline after rc3 store corruption recovery Human patch 33 days ago