identity.py python
619 lines 22.9 KB
Raw
sha256:cf6265cea8c21d9228d90dec13ef6ec2dab5103d466db9cc4590681832de4bf8 docs(KD-STAGING): sync governance after KD-6b DONE Human 13 days ago
1 """Global identity store — ``~/.muse/identity.toml``.
2
3 Ed25519 key-pair credentials are kept here, separate from per-repository
4 configuration. This means keys are never accidentally committed to
5 version control, and a single identity can authenticate across all
6 repositories on the same hub.
7
8 Why global, not per-repo
9 -------------------------
10 Muse makes identity a first-class, machine-scoped concept.
11 The repository knows *where* the hub is (``[hub] url`` in config.toml).
12 The machine knows *who you are* (this file). The two concerns are
13 deliberately separated.
14
15 Identity types
16 --------------
17 ``type = "human"``
18 A person. Authenticated via Ed25519 key pair.
19
20 ``type = "agent"``
21 An autonomous process. The ``capabilities`` field reflects what the
22 agent is allowed to do, enabling self-inspection before attempting an
23 operation. The ``provisioned_by`` field records the human handle that
24 authorised this agent, establishing the trust chain at provisioning time.
25
26 File format
27 -----------
28 TOML with one section per hub hostname or compound ``hostname#agent_id`` key::
29
30 ["localhost:10003"]
31 type = "human"
32 handle = "gabriel"
33 key_path = "/Users/gabriel/.muse/keys/localhost:10003.pem"
34 algorithm = "ed25519"
35 fingerprint = "<sha256hex>"
36
37 ["localhost:10003#agentception-abc123"]
38 type = "agent"
39 handle = "agentception-abc123"
40 key_path = "/Users/gabriel/.muse/keys/localhost:10003__agentception-abc123.pem"
41 algorithm = "ed25519"
42 fingerprint = "<sha256hex>"
43 provisioned_by = "gabriel"
44 capabilities = ["push", "pull"]
45
46 Compound key format
47 -------------------
48 ``"hostname#agent_id"`` — the ``#`` separator is not a valid hostname
49 character so it unambiguously separates the hub from the agent handle.
50 Human entries use the bare hostname; agent entries append ``#<agent_id>``.
51
52 Security model
53 --------------
54 - ``~/.muse/`` is created with mode 0o700 (user-only directory).
55 - ``~/.muse/identity.toml`` is written with mode 0o600 **from the first
56 byte** — using ``os.open()`` + ``os.fchmod()`` before any data is written,
57 eliminating the TOCTOU window that ``write_text()`` + ``chmod()`` creates.
58 - Writes are atomic: data goes to a temp file in the same directory, then
59 ``os.replace()`` renames it over the target. A kill signal during write
60 leaves the old file intact, never a partial file.
61 - Symlink guard: if the target path is already a symlink, write is refused.
62 This blocks symlink-based credential-overwrite attacks.
63 - The file is never read or written as part of a repository snapshot.
64 """
65
66 from __future__ import annotations
67
68 import contextlib
69 import fcntl
70 import logging
71 import os
72 import pathlib
73 import stat
74 import tempfile
75 import tomllib
76 from collections.abc import Generator
77 from typing import TypedDict
78
79 logger = logging.getLogger(__name__)
80
81 _IDENTITY_DIR = pathlib.Path.home() / ".muse"
82 _IDENTITY_FILE = _IDENTITY_DIR / "identity.toml"
83
84 type _IdentityMap = dict[str, "IdentityEntry"]
85
86
87 # ---------------------------------------------------------------------------
88 # Types
89 # ---------------------------------------------------------------------------
90
91
92 class IdentityEntry(TypedDict, total=False):
93 """One authenticated identity, keyed by hub hostname in identity.toml.
94
95 Human entry (``~/.muse/identity.toml``)::
96
97 ["localhost:10003"]
98 type = "human"
99 handle = "gabriel"
100 key_path = "/Users/gabriel/.muse/keys/localhost:10003.pem"
101 algorithm = "ed25519"
102 fingerprint = "<sha256hex>"
103
104 Agent entry (compound key ``hostname#agent_id``)::
105
106 ["localhost:10003#agentception-abc123"]
107 type = "agent"
108 handle = "agentception-abc123"
109 key_path = "/Users/gabriel/.muse/keys/localhost:10003__agentception-abc123.pem"
110 algorithm = "ed25519"
111 fingerprint = "<sha256hex>"
112 provisioned_by = "gabriel"
113 capabilities = ["push", "pull"]
114 """
115
116 type: str # "human" | "agent"
117 handle: str # hub-assigned handle, e.g. "gabriel" or "agentception-abc123"
118 key_path: str # absolute path to Ed25519 private key PEM
119 algorithm: str # "ed25519" | "ml-dsa-65"
120 fingerprint: str # SHA-256 hex of public key (for display / verification)
121 capabilities: list[str] # agent capability strings (empty for humans)
122 provisioned_by: str # for agents: handle of the human who provisioned this key
123
124
125 # ---------------------------------------------------------------------------
126 # Path helper
127 # ---------------------------------------------------------------------------
128
129
130 def get_identity_path() -> pathlib.Path:
131 """Return the path to the global identity file (``~/.muse/identity.toml``)."""
132 return _IDENTITY_FILE
133
134
135 def _identity_key(hostname: str, agent_id: str | None = None) -> str:
136 """Build the TOML section key for a given hostname and optional agent_id.
137
138 Human identities: ``"localhost:10003"``
139 Agent identities: ``"localhost:10003#agentception-abc123"``
140
141 The ``#`` character is not valid in a hostname so it unambiguously marks
142 the boundary between hub and agent handle.
143 """
144 if agent_id:
145 return f"{hostname}#{agent_id}"
146 return hostname
147
148
149 # ---------------------------------------------------------------------------
150 # URL → hostname normalisation
151 # ---------------------------------------------------------------------------
152
153
154 def hostname_from_url(url: str) -> str:
155 """Normalise *url* to a lowercase hostname suitable for use as a dict key.
156
157 Security properties
158 -------------------
159 - Strips the scheme (``https://``), so different scheme representations of
160 the same host resolve to the same key.
161 - Strips userinfo (``user:password@``) — embedded credentials in a URL are
162 never stored as part of the hostname key.
163 - Normalises to lowercase — DNS is case-insensitive, so ``MUSEHUB.AI``
164 and ``musehub.ai`` are the same host and must resolve to the same entry.
165
166 Examples::
167
168 "https://musehub.ai/repos/x" → "musehub.ai"
169 "https://admin:[email protected]" → "musehub.ai"
170 "MUSEHUB.AI" → "musehub.ai"
171 "https://musehub.ai" → "musehub.ai"
172 "musehub.ai:8443" → "musehub.ai:8443"
173 """
174 stripped = url.strip().rstrip("/")
175 # Remove scheme.
176 if "://" in stripped:
177 stripped = stripped.split("://", 1)[1]
178 # Remove userinfo (user:password@) — never embed credentials in the key.
179 if "@" in stripped:
180 stripped = stripped.rsplit("@", 1)[1]
181 # Keep only host[:port], strip any path.
182 hostname = stripped.split("/")[0]
183 # Normalise to lowercase — DNS is case-insensitive.
184 return hostname.lower()
185
186
187 # ---------------------------------------------------------------------------
188 # TOML serialiser (write-side — stdlib tomllib is read-only)
189 # ---------------------------------------------------------------------------
190
191
192 def _toml_escape(value: str) -> str:
193 """Escape a string value for embedding in a TOML double-quoted string."""
194 return value.replace("\\", "\\\\").replace('"', '\\"')
195
196
197 def _dump_identity(identities: _IdentityMap) -> str:
198 """Serialise a hostname → entry mapping to TOML text.
199
200 All hostnames are quoted in the section header so that dotted names
201 (e.g. ``musehub.ai``) are treated as literal keys, not nested tables.
202 All string values are TOML-escaped to prevent injection.
203 """
204 lines: list[str] = []
205 for hostname in sorted(identities):
206 entry = identities[hostname]
207 # Always quote the section key — dotted names are literal, not nested.
208 lines.append(f'["{_toml_escape(hostname)}"]')
209 t = entry.get("type", "")
210 if t:
211 lines.append(f'type = "{_toml_escape(t)}"')
212 handle = entry.get("handle", "")
213 if handle:
214 lines.append(f'handle = "{_toml_escape(handle)}"')
215 key_path = entry.get("key_path", "")
216 if key_path:
217 lines.append(f'key_path = "{_toml_escape(key_path)}"')
218 algorithm = entry.get("algorithm", "")
219 if algorithm:
220 lines.append(f'algorithm = "{_toml_escape(algorithm)}"')
221 fingerprint = entry.get("fingerprint", "")
222 if fingerprint:
223 lines.append(f'fingerprint = "{_toml_escape(fingerprint)}"')
224 provisioned_by = entry.get("provisioned_by", "")
225 if provisioned_by:
226 lines.append(f'provisioned_by = "{_toml_escape(provisioned_by)}"')
227 caps = entry.get("capabilities") or []
228 if caps:
229 caps_str = ", ".join(f'"{_toml_escape(c)}"' for c in caps)
230 lines.append(f"capabilities = [{caps_str}]")
231 lines.append("")
232 return "\n".join(lines)
233
234
235 # ---------------------------------------------------------------------------
236 # Load / save
237 # ---------------------------------------------------------------------------
238
239
240 def _load_all(path: pathlib.Path) -> _IdentityMap:
241 """Load all identity entries from *path*. Returns empty dict if absent."""
242 if not path.is_file():
243 return {}
244 try:
245 with path.open("rb") as fh:
246 raw = tomllib.load(fh)
247 except Exception as exc: # noqa: BLE001
248 # Log only the exception *type*, never its message — a TOML parse
249 # error surfaced by tomllib includes the offending line, which can
250 # contain a fragment of the token being written when the file is corrupt.
251 logger.warning(
252 "⚠️ Failed to parse identity file %s (%s — run `muse auth register` to re-authenticate)",
253 path,
254 type(exc).__name__,
255 )
256 return {}
257
258 result: _IdentityMap = {}
259 for hostname, raw_entry in raw.items():
260 if not isinstance(raw_entry, dict):
261 continue
262 entry: IdentityEntry = {}
263 t = raw_entry.get("type")
264 if isinstance(t, str):
265 entry["type"] = t
266 handle = raw_entry.get("handle")
267 if isinstance(handle, str):
268 entry["handle"] = handle
269 key_path = raw_entry.get("key_path")
270 if isinstance(key_path, str):
271 entry["key_path"] = key_path
272 algorithm = raw_entry.get("algorithm")
273 if isinstance(algorithm, str):
274 entry["algorithm"] = algorithm
275 fingerprint = raw_entry.get("fingerprint")
276 if isinstance(fingerprint, str):
277 entry["fingerprint"] = fingerprint
278 caps = raw_entry.get("capabilities")
279 if isinstance(caps, list):
280 entry["capabilities"] = [str(c) for c in caps if isinstance(c, str)]
281 provisioned_by = raw_entry.get("provisioned_by")
282 if isinstance(provisioned_by, str):
283 entry["provisioned_by"] = provisioned_by
284 result[hostname] = entry
285
286 return result
287
288
289 @contextlib.contextmanager
290 def _identity_write_lock() -> Generator[None, None, None]:
291 """Acquire an exclusive advisory write-lock on the identity store.
292
293 Uses a dedicated lock file (``~/.muse/.identity.lock``) so that the lock
294 survives the atomic rename of ``identity.toml`` itself.
295
296 Advisory (cooperative) locking protects all Muse processes that use this
297 lock against concurrent read-modify-write races. Direct file edits by
298 external tools bypass the lock — that is acceptable; the user is then
299 responsible for data consistency.
300
301 POSIX-only (``fcntl.flock``). The lock is blocking with no timeout;
302 CLI commands are short-lived and lock contention is expected to be brief.
303 """
304 lock_path = _IDENTITY_DIR / ".identity.lock"
305 _IDENTITY_DIR.mkdir(parents=True, exist_ok=True)
306 # Create the lock file with owner-only permissions; O_CLOEXEC prevents
307 # child processes from inheriting the file descriptor.
308 lock_fd = os.open(
309 str(lock_path),
310 os.O_CREAT | os.O_WRONLY | os.O_CLOEXEC,
311 stat.S_IRUSR | stat.S_IWUSR,
312 )
313 try:
314 fcntl.flock(lock_fd, fcntl.LOCK_EX)
315 try:
316 yield
317 finally:
318 fcntl.flock(lock_fd, fcntl.LOCK_UN)
319 finally:
320 os.close(lock_fd)
321
322
323 def _save_all(identities: _IdentityMap, path: pathlib.Path) -> None:
324 """Write *identities* to *path* securely.
325
326 Security guarantees
327 -------------------
328 1. **Symlink guard** — refuses to write if *path* is already a symlink,
329 preventing an attacker from pre-placing a symlink to a file they want
330 overwritten.
331 2. **0o700 directory** — ``~/.muse/`` is restricted to the owner so other
332 local users cannot list or traverse it.
333 3. **0o600 from byte zero** — the temp file is ``fchmod``-ed to 0o600
334 *before* any data is written, eliminating the TOCTOU window that
335 ``write_text()`` + ``chmod()`` creates.
336 4. **Atomic rename** — ``os.replace()`` swaps the temp file over the
337 target atomically; a kill signal during write leaves the old file intact.
338 """
339 dir_path = path.parent
340
341 # 1. Create ~/.muse/ with owner-only permissions (0o700).
342 dir_path.mkdir(parents=True, exist_ok=True)
343 try:
344 os.chmod(dir_path, stat.S_IRWXU) # 0o700
345 except OSError as exc:
346 logger.warning("⚠️ Could not set permissions on %s: %s", dir_path, exc)
347
348 # 2. Symlink guard — never follow a symlink placed at the target path.
349 if path.is_symlink():
350 raise OSError(
351 f"Security: {path} is a symlink. "
352 "Refusing to write credentials to a symlink target."
353 )
354
355 text = _dump_identity(identities)
356
357 # 3. Write to a temp file in the same directory (same fs → atomic rename).
358 # Set 0o600 via fchmod *before* writing any data.
359 fd, tmp_path_str = tempfile.mkstemp(dir=dir_path, prefix=".identity-tmp-")
360 tmp_path = pathlib.Path(tmp_path_str)
361 try:
362 os.fchmod(fd, stat.S_IRUSR | stat.S_IWUSR) # 0o600 before any data
363 with os.fdopen(fd, "w", encoding="utf-8") as fh:
364 fh.write(text)
365 # 4. Atomic rename — old file stays intact if we crash before this.
366 os.replace(tmp_path, path)
367 except Exception:
368 try:
369 tmp_path.unlink(missing_ok=True)
370 except OSError:
371 pass
372 raise
373
374
375 # ---------------------------------------------------------------------------
376 # Public API
377 # ---------------------------------------------------------------------------
378
379
380 def load_identity(hub_url: str, agent_id: str | None = None) -> IdentityEntry | None:
381 """Return the stored identity for *hub_url* (and optional *agent_id*), or ``None``.
382
383 The URL is normalised to a hostname before lookup, so
384 ``https://musehub.ai/repos/x`` and ``musehub.ai`` resolve to the same
385 entry.
386
387 Args:
388 hub_url: Hub URL or bare hostname.
389 agent_id: Agent handle (e.g. ``"agentception-abc123"``). When
390 supplied the lookup key is ``"hostname#agent_id"``,
391 i.e. the agent's dedicated entry rather than the human's.
392
393 Returns:
394 :class:`IdentityEntry` if an identity is stored, else ``None``.
395 """
396 hostname = hostname_from_url(hub_url)
397 key = _identity_key(hostname, agent_id)
398 return _load_all(_IDENTITY_FILE).get(key)
399
400
401 def save_identity(hub_url: str, entry: IdentityEntry, agent_id: str | None = None) -> None:
402 """Store *entry* as the identity for *hub_url* (and optional *agent_id*).
403
404 The entire read-modify-write cycle is wrapped in an exclusive advisory
405 lock so that concurrent ``muse auth register`` calls (e.g. from parallel
406 agents) cannot race and overwrite each other's entries.
407
408 Creates ``~/.muse/identity.toml`` with mode 0o600 if it does not exist.
409
410 Args:
411 hub_url: Hub URL or bare hostname.
412 entry: Identity data to store.
413 agent_id: When supplied, the entry is stored under the compound key
414 ``"hostname#agent_id"`` rather than the bare hostname.
415 """
416 hostname = hostname_from_url(hub_url)
417 key = _identity_key(hostname, agent_id)
418 with _identity_write_lock():
419 identities = _load_all(_IDENTITY_FILE)
420 identities[key] = entry
421 _save_all(identities, _IDENTITY_FILE)
422 logger.info("✅ Identity for %s saved", key)
423
424
425 def clear_identity(hub_url: str, agent_id: str | None = None) -> bool:
426 """Remove the stored identity for *hub_url* (and optional *agent_id*).
427
428 The entire read-modify-write cycle is wrapped in an exclusive advisory
429 lock (see :func:`save_identity`).
430
431 Args:
432 hub_url: Hub URL or bare hostname.
433 agent_id: When supplied, removes the agent's compound-key entry.
434
435 Returns:
436 ``True`` if an entry was removed, ``False`` if no entry existed.
437 """
438 hostname = hostname_from_url(hub_url)
439 key = _identity_key(hostname, agent_id)
440 with _identity_write_lock():
441 identities = _load_all(_IDENTITY_FILE)
442 if key not in identities:
443 return False
444 del identities[key]
445 _save_all(identities, _IDENTITY_FILE)
446 logger.info("✅ Identity for %s cleared", key)
447 return True
448
449
450 def _check_key_file_permissions(key_path: pathlib.Path) -> bool:
451 """Return ``True`` when *key_path* has safe permissions (owner-only, 0o600 or stricter).
452
453 Checks:
454 1. File mode must not expose group-read (0o040) or world-read (0o004) bits.
455 2. File must be owned by the current user (skipped when uid == 0).
456
457 Logs a WARNING and returns ``False`` when either check fails, including
458 the fix command so the user can resolve the problem.
459
460 Args:
461 key_path: Path to the private key PEM file.
462
463 Returns:
464 ``True`` when permissions are safe, ``False`` otherwise.
465 """
466 try:
467 st = key_path.stat()
468 except OSError as exc:
469 logger.warning("⚠️ Cannot stat key file %s: %s", key_path, exc)
470 return False
471
472 # Permissions: any bit beyond owner read/write (0o600) is unsafe.
473 # 0o177 masks all bits that are NOT owner read/write:
474 # 0o177 = 0o177 (all group, world, and special bits)
475 mode_octal = st.st_mode & 0o777
476 if mode_octal & 0o177:
477 logger.warning(
478 "⚠️ Key file %s has permissions %04o — expected 0600 or stricter. "
479 "Group/world-readable private key files are refused for security. "
480 "Fix with: chmod 600 %s",
481 key_path,
482 mode_octal,
483 key_path,
484 )
485 return False
486
487 # Ownership: refuse keys owned by another user (root is exempt).
488 current_uid = os.getuid()
489 if current_uid != 0 and st.st_uid != current_uid:
490 logger.warning(
491 "⚠️ Key file %s is owned by UID %d but running as UID %d — "
492 "refusing to use a key owned by another user. "
493 "Ensure the key is owned by you, or use a different key path.",
494 key_path,
495 st.st_uid,
496 current_uid,
497 )
498 return False
499
500 return True
501
502
503 def _load_private_key_from_path(key_path_str: str) -> "object | None":
504 """Load an Ed25519 private key from *key_path_str*. Returns ``None`` on any failure.
505
506 Permission checks performed before loading:
507 - File mode must be 0o600 or stricter (no group/world read bits).
508 - File must be owned by the current user (root is exempt from ownership check).
509
510 Returns ``None`` (with a WARNING) when either check fails, preventing
511 accidental use of an insecure or foreign key.
512 """
513 key_path = pathlib.Path(key_path_str)
514 if not key_path.is_file():
515 logger.warning("⚠️ Key file not found: %s", key_path)
516 return None
517
518 # Security gate: refuse keys with unsafe permissions or foreign ownership.
519 if not _check_key_file_permissions(key_path):
520 return None
521
522 try:
523 from cryptography.hazmat.primitives.serialization import load_pem_private_key
524 pem_bytes = key_path.read_bytes()
525 return load_pem_private_key(pem_bytes, password=None)
526 except Exception as exc:
527 logger.warning("⚠️ Could not load private key from %s: %s", key_path, exc)
528 return None
529
530
531 def resolve_signing_identity(
532 hub_url: str,
533 agent_id: str | None = None,
534 ) -> "tuple[str, object] | None":
535 """Return ``(handle, private_key)`` for *hub_url*, or ``None``.
536
537 Resolution order when *agent_id* is provided:
538 1. Agent-specific entry (``"hostname#agent_id"``) — used if present.
539 2. Human entry (bare hostname) — fallback for operators who have not yet
540 registered a dedicated agent key.
541
542 When *agent_id* is ``None``, only the human (bare-hostname) entry is tried.
543
544 The private key is loaded from the ``key_path`` stored in ``identity.toml``.
545 Returns ``None`` when no identity is configured or the key file is missing.
546
547 Args:
548 hub_url: Hub URL or bare hostname.
549 agent_id: Optional agent handle to look up a dedicated agent key first.
550
551 Returns:
552 ``(handle, Ed25519PrivateKey)`` tuple, or ``None``.
553 """
554 # 1. Try agent-specific entry when an agent_id is given.
555 if agent_id:
556 agent_entry = load_identity(hub_url, agent_id=agent_id)
557 if agent_entry is not None:
558 handle = agent_entry.get("handle", "")
559 key_path_str = agent_entry.get("key_path", "")
560 if handle and key_path_str:
561 private_key = _load_private_key_from_path(key_path_str)
562 if private_key is not None:
563 logger.debug("✅ Agent signing key resolved: handle=%s", handle)
564 return handle, private_key
565
566 # 2. Fall back to the human entry.
567 human_entry = load_identity(hub_url)
568 if human_entry is None:
569 return None
570
571 handle = human_entry.get("handle", "")
572 key_path_str = human_entry.get("key_path", "")
573
574 if not handle or not key_path_str:
575 return None
576
577 private_key = _load_private_key_from_path(key_path_str)
578 if private_key is None:
579 return None
580
581 if agent_id:
582 logger.debug(
583 "⚠️ No dedicated key for agent '%s' — signing with human key (handle=%s). "
584 "Run `muse auth keygen --agent-id %s` to provision a dedicated agent key.",
585 agent_id, handle, agent_id,
586 )
587 return handle, private_key
588
589
590 def list_all_identities() -> _IdentityMap:
591 """Return all stored identities keyed by hub hostname.
592
593 Returns an empty dict if the identity file does not exist.
594 """
595 return _load_all(_IDENTITY_FILE)
596
597
598 def clear_all_identities() -> list[str]:
599 """Remove every stored identity in a single atomic write.
600
601 The entire read-modify-write cycle is wrapped in an exclusive advisory
602 lock (see :func:`save_identity`) so no concurrent process can race.
603
604 This is O(1) writes regardless of how many identities are stored —
605 far cheaper than calling :func:`clear_identity` in a loop, which does
606 N separate lock → read → write cycles.
607
608 Returns:
609 Sorted list of hostnames that were removed. Empty list if the
610 identity file did not exist or contained no entries.
611 """
612 with _identity_write_lock():
613 identities = _load_all(_IDENTITY_FILE)
614 if not identities:
615 return []
616 removed = sorted(identities.keys())
617 _save_all({}, _IDENTITY_FILE)
618 logger.info("✅ All identities cleared (%d hub(s))", len(removed))
619 return removed
File History 1 commit
sha256:cf6265cea8c21d9228d90dec13ef6ec2dab5103d466db9cc4590681832de4bf8 docs(KD-STAGING): sync governance after KD-6b DONE Human 13 days ago