gabriel / musehub public
musehub_auth.py python
915 lines 32.6 KB Cold
Raw
sha256:92528ae07d0e1239d87fd5fd1f439e8fbb49c9778a9a400bc4a736073fb28316 feat: byte-range blob reads, file attribution DAG walk, bra… Sonnet 4.6 minor ⚠ breaking 143 days ago
1 """Public-key challenge-response authentication service.
2
3 Security properties
4 -------------------
5 - **No rolling your own crypto.** All signature operations are delegated to
6 PyCA ``cryptography`` (Ed25519). See ``musehub/crypto/keys.py``.
7
8 - **Constant-time fingerprint comparison.** ``hmac.compare_digest()`` prevents
9 timing-based side-channel attacks on the fingerprint check.
10
11 - **256-bit nonce (CSPRNG).** ``secrets.token_bytes(32)`` uses the OS CSPRNG.
12
13 - **DB-backed challenges.** Nonces are stored in ``musehub_auth_challenges``
14 (Postgres) with TTL expiry so any server instance in a blue-green deploy can
15 verify a challenge regardless of which instance issued it.
16
17 - **Fingerprint binding.** Each row binds nonce → fingerprint so a challenge
18 issued for key A cannot be presented with key B.
19
20 - **Replay prevention.** Each nonce row is deleted immediately after first use.
21
22 - **Key-size validation.** Checked before crypto invocation.
23
24 - **Short TTL.** Challenges expire in 5 minutes.
25
26 Protocol overview
27 -----------------
28 1. ``create_challenge(session, fingerprint, algorithm)``
29 → inserts a ``MusehubAuthChallenge`` row; returns nonce_hex.
30
31 2. Client signs ``bytes.fromhex(nonce_hex)`` with its private key.
32
33 3. ``verify_and_authenticate(nonce_hex, public_key_b64, signature_b64, ...)``
34 a. Look up and atomically delete the nonce row (rejects expired / unknown).
35 b. Recompute fingerprint and compare via ``hmac.compare_digest``.
36 c. Decode public key and signature from base64.
37 d. Delegate signature verification to ``musehub.crypto.keys.verify_signature``.
38 e. Create identity on first use; update ``last_used_at`` on login.
39 """
40
41 import logging
42 import secrets
43 from datetime import datetime, timedelta, timezone
44
45 from musehub.core.genesis import compute_identity_id, compute_key_id
46
47 from sqlalchemy import delete, select, update
48 from sqlalchemy.exc import IntegrityError
49 from sqlalchemy.ext.asyncio import AsyncSession
50
51 from musehub.crypto.keys import (
52 KeyAlgorithm,
53 b64url_decode,
54 fingerprints_equal,
55 key_fingerprint,
56 verify_signature,
57 AlgorithmNotImplementedError,
58 InvalidKeyError,
59 SignatureError,
60 )
61 from musehub.db.musehub_auth_models import MusehubAuthChallenge, MusehubAuthKey
62 from musehub.db.musehub_identity_models import MusehubIdentity
63 from musehub.db.musehub_repo_models import MusehubBranch, MusehubCommit, MusehubObject, MusehubRepo, MusehubSnapshot
64 from musehub.models.musehub_auth import (
65 AgentRegistrationResponse,
66 AuthKeyResponse,
67 VerifyResponse,
68 )
69 from musehub.types.json_types import JSONObject
70
71 logger = logging.getLogger(__name__)
72
73 CHALLENGE_TTL_SECONDS: int = 300 # 5 minutes
74
75 async def _create_identity_repo(
76 session: AsyncSession,
77 *,
78 identity_id: str,
79 handle: str,
80 public_key_b64: str,
81 identity_type: str = "human",
82 ) -> None:
83 """Create the canonical {handle}/identity repo and commit the initial IdentityRecord.
84
85 Called once per new identity, immediately after the identity row is
86 committed. Idempotent: if the repo already exists, does nothing.
87 """
88 import datetime as _dt
89
90 from muse.plugins.identity.records import IdentityRecord, identity_path, record_to_bytes
91 from musehub.services.musehub_repository import create_repo
92 from musehub.services.musehub_sync import commit_files_to_repo
93
94 # Idempotency guard
95 from sqlalchemy import select as _select
96 existing = await session.execute(
97 _select(MusehubRepo).where(
98 MusehubRepo.owner == handle,
99 MusehubRepo.slug == "identity",
100 )
101 )
102 if existing.scalar_one_or_none() is not None:
103 return
104
105 repo_response = await create_repo(
106 session,
107 name="identity",
108 owner=handle,
109 visibility="private",
110 owner_user_id=identity_id,
111 owner_identity_id=identity_id,
112 domain="identity",
113 description=f"Identity record for {handle}",
114 )
115
116 # Persist domain label so queries can filter by domain.
117 repo_row = await session.get(MusehubRepo, repo_response.repo_id)
118 if repo_row is not None:
119 repo_row.domain_id = "identity"
120 await session.flush()
121
122 record: IdentityRecord = {
123 "handle": handle,
124 "type": identity_type, # type: ignore[typeddict-item]
125 "pubkey": public_key_b64,
126 "quorum": None,
127 "registered_at": _dt.datetime.now(_dt.timezone.utc).isoformat(),
128 "metadata": {},
129 }
130
131 await commit_files_to_repo(
132 session,
133 repo_id=repo_response.repo_id,
134 branch="main",
135 files={identity_path(handle): record_to_bytes(record)},
136 message=f"identity: register {handle}",
137 author=identity_id,
138 )
139
140 async def _commit_spawns_relationship(
141 session: AsyncSession,
142 *,
143 parent_handle: str,
144 agent_handle: str,
145 parent_identity_id: str,
146 ) -> None:
147 """Commit a SPAWNS RelationshipRecord to the parent's identity repo.
148
149 No-op when the parent has no identity repo (migration-period safety).
150 """
151 from muse.plugins.identity.records import (
152 RelationshipRecord,
153 record_to_bytes,
154 relationship_path,
155 )
156 from musehub.services.musehub_sync import commit_files_to_repo
157 from sqlalchemy import select as _select
158
159 # Find parent's identity repo — no-op if missing.
160 repo_result = await session.execute(
161 _select(MusehubRepo).where(
162 MusehubRepo.owner == parent_handle,
163 MusehubRepo.slug == "identity",
164 )
165 )
166 repo = repo_result.scalar_one_or_none()
167 if repo is None:
168 return
169
170 rel: RelationshipRecord = {
171 "from_handle": parent_handle,
172 "to_handle": agent_handle,
173 "edge_type": "spawns",
174 "weight": None,
175 "authorized_by": [],
176 }
177
178 await commit_files_to_repo(
179 session,
180 repo_id=repo.repo_id,
181 branch="main",
182 files={relationship_path(parent_handle, "spawns", agent_handle): record_to_bytes(rel)},
183 message=f"identity: {parent_handle} spawns {agent_handle}",
184 author=parent_identity_id,
185 )
186
187 async def _commit_key_rotation_to_identity_repo(
188 session: AsyncSession,
189 *,
190 identity_id: str,
191 new_public_key_b64: str,
192 ) -> None:
193 """Commit an updated IdentityRecord to the identity repo after key rotation.
194
195 Reads the current HEAD record, replaces pubkey, and writes a new commit.
196 No-op if the identity repo doesn't exist (migration-period safety).
197 """
198 import json as _json
199 import msgpack as _msgpack
200 from muse.plugins.identity.records import identity_path, record_to_bytes
201 from musehub.storage.backends import read_object_bytes as _read_object_bytes
202 from musehub.services.musehub_sync import commit_files_to_repo
203 from sqlalchemy import select as _select
204
205 # Resolve handle from identity_id.
206 identity_row = await session.get(MusehubIdentity, identity_id)
207 if identity_row is None:
208 return
209 handle = identity_row.handle
210
211 # Find the identity repo.
212 repo_result = await session.execute(
213 _select(MusehubRepo).where(
214 MusehubRepo.owner == handle,
215 MusehubRepo.slug == "identity",
216 )
217 )
218 repo = repo_result.scalar_one_or_none()
219 if repo is None:
220 return
221
222 # Read the current HEAD record from the snapshot manifest.
223 branch_result = await session.execute(
224 _select(MusehubBranch).where(
225 MusehubBranch.repo_id == repo.repo_id,
226 MusehubBranch.name == "main",
227 )
228 )
229 branch = branch_result.scalar_one_or_none()
230 if branch is None or branch.head_commit_id is None:
231 return
232
233 commit_row = await session.get(MusehubCommit, branch.head_commit_id)
234 if commit_row is None or commit_row.snapshot_id is None:
235 return
236
237 snap = await session.get(MusehubSnapshot, commit_row.snapshot_id)
238 if snap is None:
239 return
240
241 manifest: JSONObject = _msgpack.unpackb(snap.manifest_blob, raw=False)
242 file_path = identity_path(handle)
243 if file_path not in manifest:
244 return
245
246 obj = await session.get(MusehubObject, manifest[file_path])
247 if obj is None:
248 return
249
250 raw = await _read_object_bytes(obj, session=session)
251 if raw is None:
252 return
253 current_record: JSONObject = _json.loads(raw)
254
255 # Write the updated record with the new pubkey.
256 updated_record = {**current_record, "pubkey": new_public_key_b64}
257 await commit_files_to_repo(
258 session,
259 repo_id=repo.repo_id,
260 branch="main",
261 files={file_path: record_to_bytes(updated_record)}, # type: ignore[arg-type]
262 message=f"identity: rotate key for {handle}",
263 author=identity_id,
264 )
265
266 # ---------------------------------------------------------------------------
267 # Step 1: Issue challenge
268 # ---------------------------------------------------------------------------
269
270 async def create_challenge(session: AsyncSession, fingerprint: str, algorithm: str) -> str:
271 """Create a nonce, persist it to Postgres, and return it as a hex string.
272
273 The client must sign ``bytes.fromhex(returned_nonce)`` with its private key
274 and submit the result to ``POST /api/auth/verify``.
275
276 Lazily purges expired rows on each call so the table stays small without
277 requiring a dedicated cleanup job.
278
279 Returns the nonce hex string (64 hex chars = 32 bytes).
280 """
281 # Lazy expiry sweep — delete all rows whose TTL has elapsed.
282 await session.execute(
283 delete(MusehubAuthChallenge).where(
284 MusehubAuthChallenge.expires_at < datetime.now(timezone.utc)
285 )
286 )
287
288 nonce_hex = secrets.token_bytes(32).hex() # 256-bit nonce from OS CSPRNG
289 expires_at = datetime.now(timezone.utc) + timedelta(seconds=CHALLENGE_TTL_SECONDS)
290 row = MusehubAuthChallenge(
291 nonce_hex=nonce_hex,
292 fingerprint=fingerprint,
293 algorithm=algorithm,
294 expires_at=expires_at,
295 )
296 session.add(row)
297 await session.commit()
298 return nonce_hex
299
300 # ---------------------------------------------------------------------------
301 # Step 2: Verify challenge and authenticate
302 # ---------------------------------------------------------------------------
303
304 class AuthError(Exception):
305 """Raised for any authentication failure. Maps to an HTTP error in the route."""
306
307 def __init__(self, detail: str, status_code: int = 401) -> None:
308 super().__init__(detail)
309 self.detail = detail
310 self.status_code = status_code
311
312 async def verify_and_authenticate(
313 *,
314 session: AsyncSession,
315 challenge_token: str,
316 public_key_b64: str,
317 signature_b64: str,
318 handle: str | None,
319 display_name: str | None,
320 label: str | None,
321 identity_type: str = "human",
322 ) -> VerifyResponse:
323 """Full challenge verification + identity resolution.
324
325 ``challenge_token`` is the nonce_hex returned by ``create_challenge()``.
326
327 Raises ``AuthError`` for every authentication or validation failure.
328 All failures are logged at WARNING level for intrusion detection.
329 """
330 nonce_hex = challenge_token # field name kept for API compatibility
331
332 # 1. Look up and consume the nonce row (single-use, atomic delete).
333 challenge_row: MusehubAuthChallenge | None = (
334 await session.execute(
335 select(MusehubAuthChallenge).where(MusehubAuthChallenge.nonce_hex == nonce_hex)
336 )
337 ).scalar_one_or_none()
338
339 if challenge_row is None:
340 logger.warning("Auth failure: unknown or expired nonce")
341 raise AuthError("Unknown or expired challenge — request a new one", status_code=400)
342
343 # Delete immediately — single-use semantics prevent replay.
344 await session.delete(challenge_row)
345 await session.flush()
346
347 challenge_fingerprint = challenge_row.fingerprint
348 algorithm_str = challenge_row.algorithm
349 if datetime.now(timezone.utc) > challenge_row.expires_at:
350 logger.warning("Auth failure: nonce expired")
351 raise AuthError("Challenge has expired — request a new one", status_code=400)
352
353 # 2. Decode the submitted public key (must be canonically prefixed: "ed25519:<b64url>")
354 try:
355 from muse.core.types import decode_pubkey, decode_sig
356 _, raw_key = decode_pubkey(public_key_b64)
357 except Exception as exc:
358 logger.warning("Auth failure: cannot decode public_key_b64 — %s", exc)
359 raise AuthError("public_key_b64 must be canonically prefixed (ed25519:<base64url>)", status_code=422)
360
361 # 3. Constant-time fingerprint check
362 computed_fp = key_fingerprint(raw_key)
363 if not fingerprints_equal(computed_fp, challenge_fingerprint):
364 logger.warning(
365 "Auth failure: key substitution attempt — computed=%s challenge=%s",
366 computed_fp[:16],
367 challenge_fingerprint[:16],
368 )
369 raise AuthError(
370 "Public key does not match the fingerprint in the challenge",
371 status_code=401,
372 )
373
374 # 4. Decode the signature (must be canonically prefixed: "ed25519:<b64url>")
375 try:
376 _, sig_bytes = decode_sig(signature_b64)
377 except Exception as exc:
378 logger.warning("Auth failure: cannot decode signature_b64 — %s", exc)
379 raise AuthError("signature_b64 must be canonically prefixed (ed25519:<base64url>)", status_code=422)
380
381 # 5. Verify signature over the raw nonce bytes
382 try:
383 algo = KeyAlgorithm(algorithm_str)
384 verify_signature(
385 algorithm=algo,
386 public_key_bytes=raw_key,
387 message=bytes.fromhex(nonce_hex),
388 signature_bytes=sig_bytes,
389 )
390 except AlgorithmNotImplementedError as exc:
391 logger.warning("Auth failure: unimplemented algorithm '%s'", algorithm_str)
392 raise AuthError(str(exc), status_code=422)
393 except (InvalidKeyError, SignatureError) as exc:
394 logger.warning("Auth failure: crypto error — %s", exc)
395 raise AuthError(str(exc), status_code=401)
396 except (ValueError, TypeError) as exc:
397 logger.warning("Auth failure: malformed key/nonce/signature — %s", exc)
398 raise AuthError(f"Encoding error: {exc}", status_code=422)
399
400 # 6. Look up the key in the database — revoked keys must never match here,
401 # or a revoked key could still re-authenticate (musehub#223).
402 key_row: MusehubAuthKey | None = (
403 await session.execute(
404 select(MusehubAuthKey).where(
405 MusehubAuthKey.fingerprint == computed_fp,
406 MusehubAuthKey.revoked_at.is_(None),
407 )
408 )
409 ).scalar_one_or_none()
410
411 is_new_identity = False
412
413 if key_row is None:
414 # --- Registration path ---
415 if not handle:
416 raise AuthError(
417 "handle is required when registering a new public key",
418 status_code=422,
419 )
420
421 # Normalize to lowercase to prevent handle squatting (Gabriel == gabriel).
422 handle = handle.strip().lower()
423
424 # Check if this handle belongs to a dead identity (identity exists but
425 # has no keys — caused by logout or failed key rotation). If so, add
426 # the new key to the existing identity instead of creating a new one.
427 existing_identity: MusehubIdentity | None = (
428 await session.execute(
429 select(MusehubIdentity).where(
430 MusehubIdentity.handle == handle,
431 MusehubIdentity.deleted_at.is_(None),
432 )
433 )
434 ).scalar_one_or_none()
435
436 if existing_identity is not None:
437 existing_keys = (
438 await session.execute(
439 select(MusehubAuthKey).where(
440 MusehubAuthKey.identity_id == existing_identity.identity_id,
441 MusehubAuthKey.revoked_at.is_(None),
442 )
443 )
444 ).scalars().all()
445
446 if existing_keys:
447 # Handle is live — a different key cannot claim it.
448 raise AuthError(
449 f"Handle '{handle}' is already taken or this key is already registered",
450 status_code=409,
451 )
452
453 # Dead identity recovery: add the new key under the existing identity.
454 logger.warning(
455 "Auth: dead identity recovery for handle=%s identity_id=%s",
456 handle, existing_identity.identity_id[:16],
457 )
458 try:
459 key_row = MusehubAuthKey(
460 key_id=compute_key_id(existing_identity.identity_id, public_key_b64),
461 identity_id=existing_identity.identity_id,
462 algorithm=algorithm_str,
463 public_key_b64=public_key_b64,
464 fingerprint=computed_fp,
465 label=label or "",
466 last_used_at=datetime.now(timezone.utc),
467 )
468 session.add(key_row)
469 await session.commit()
470 identity = existing_identity
471 logger.info(
472 "✅ Auth: dead identity recovered handle=%s algo=%s",
473 handle, algorithm_str,
474 )
475 except IntegrityError:
476 await session.rollback()
477 raise AuthError(
478 f"Handle '{handle}' is already taken or this key is already registered",
479 status_code=409,
480 )
481
482 else:
483 # Truly new identity — normal registration path.
484 try:
485 _now = datetime.now(timezone.utc)
486 identity = MusehubIdentity(
487 identity_id=compute_identity_id(raw_key),
488 handle=handle,
489 identity_type=identity_type,
490 display_name=display_name or handle,
491 tos_accepted_at=_now,
492 tos_version="1.0",
493 )
494 session.add(identity)
495 await session.flush()
496
497 key_row = MusehubAuthKey(
498 key_id=compute_key_id(identity.identity_id, public_key_b64),
499 identity_id=identity.identity_id,
500 algorithm=algorithm_str,
501 public_key_b64=public_key_b64,
502 fingerprint=computed_fp,
503 label=label or "",
504 last_used_at=datetime.now(timezone.utc),
505 )
506 session.add(key_row)
507 await session.commit()
508 is_new_identity = True
509 logger.info(
510 "✅ Auth: registered handle=%s algo=%s type=%s",
511 handle, algorithm_str, identity_type,
512 )
513 await _create_identity_repo(
514 session,
515 identity_id=identity.identity_id,
516 handle=handle,
517 public_key_b64=public_key_b64,
518 identity_type=identity_type,
519 )
520 await session.commit()
521
522 except IntegrityError:
523 await session.rollback()
524 raise AuthError(
525 f"Handle '{handle}' is already taken or this key is already registered",
526 status_code=409,
527 )
528
529 else:
530 # --- Login path (re-register existing key) ---
531 identity_row: MusehubIdentity | None = (
532 await session.execute(
533 select(MusehubIdentity).where(MusehubIdentity.identity_id == key_row.identity_id)
534 )
535 ).scalar_one_or_none()
536
537 if identity_row is None:
538 logger.warning("Auth failure: key exists but identity is gone — key_id=%s", key_row.key_id)
539 raise AuthError("Identity for this key no longer exists", status_code=401)
540
541 identity = identity_row
542
543 await session.execute(
544 update(MusehubAuthKey)
545 .where(MusehubAuthKey.key_id == key_row.key_id)
546 .values(last_used_at=datetime.now(timezone.utc))
547 )
548 await session.commit()
549 logger.info("✅ Auth: login handle=%s algo=%s", identity.handle, algorithm_str)
550
551 await session.refresh(key_row)
552
553 last_used = key_row.last_used_at
554 return VerifyResponse(
555 handle=identity.handle,
556 identity_id=identity.identity_id,
557 is_new_identity=is_new_identity,
558 auth_method=algorithm_str,
559 key=AuthKeyResponse(
560 key_id=key_row.key_id,
561 algorithm=key_row.algorithm,
562 fingerprint=key_row.fingerprint,
563 label=key_row.label,
564 created_at=key_row.created_at.isoformat(),
565 last_used_at=last_used.isoformat() if last_used else None,
566 ),
567 )
568
569 # ---------------------------------------------------------------------------
570 # Key management helpers
571 # ---------------------------------------------------------------------------
572
573 async def get_keys_for_identity(
574 session: AsyncSession, identity_id: str
575 ) -> list[AuthKeyResponse]:
576 """Return all active (non-revoked) public keys for an identity (no key material)."""
577 rows = (
578 await session.execute(
579 select(MusehubAuthKey)
580 .where(
581 MusehubAuthKey.identity_id == identity_id,
582 MusehubAuthKey.revoked_at.is_(None),
583 )
584 .order_by(MusehubAuthKey.created_at)
585 )
586 ).scalars().all()
587
588 return [
589 AuthKeyResponse(
590 key_id=r.key_id,
591 algorithm=r.algorithm,
592 fingerprint=r.fingerprint,
593 label=r.label,
594 created_at=r.created_at.isoformat(),
595 last_used_at=r.last_used_at.isoformat() if r.last_used_at else None,
596 )
597 for r in rows
598 ]
599
600 async def revoke_key(
601 session: AsyncSession, identity_id: str, key_id: str
602 ) -> bool:
603 """Soft-delete a key row by setting revoked_at (musehub#223).
604
605 Returns True if revoked, False if not found or already revoked --
606 revoking an already-revoked key is not idempotent-successful, matching
607 DELETE semantics elsewhere in this codebase (musehub#231/#224).
608 """
609 row: MusehubAuthKey | None = (
610 await session.execute(
611 select(MusehubAuthKey).where(
612 MusehubAuthKey.key_id == key_id,
613 MusehubAuthKey.identity_id == identity_id,
614 MusehubAuthKey.revoked_at.is_(None),
615 )
616 )
617 ).scalar_one_or_none()
618
619 if row is None:
620 return False
621
622 row.revoked_at = datetime.now(timezone.utc)
623 await session.commit()
624 return True
625
626 async def add_key_for_identity(
627 *,
628 session: AsyncSession,
629 identity_id: str,
630 challenge_token: str,
631 public_key_b64: str,
632 signature_b64: str,
633 label: str | None = None,
634 algorithm: str = "ed25519",
635 ) -> "AuthKeyResponse":
636 """Add a new key to an existing identity during key rotation.
637
638 Security guarantees
639 -------------------
640 - The challenge-response proof (``challenge_token`` + ``signature_b64``)
641 shows the caller possesses the new private key — prevents key injection.
642 - The MSign ``Authorization`` header (verified upstream by ``require_valid_token``)
643 shows the caller already owns an existing key for ``identity_id`` — prevents
644 an unauthenticated caller from adding keys to someone else's identity.
645
646 Raises ``AuthError`` on any crypto or DB failure.
647 """
648 nonce_hex = challenge_token
649
650 # 1. Consume the nonce (single-use, atomic delete).
651 challenge_row: MusehubAuthChallenge | None = (
652 await session.execute(
653 select(MusehubAuthChallenge).where(MusehubAuthChallenge.nonce_hex == nonce_hex)
654 )
655 ).scalar_one_or_none()
656
657 if challenge_row is None:
658 raise AuthError("Unknown or expired challenge — request a new one", status_code=400)
659
660 await session.delete(challenge_row)
661 await session.flush()
662
663 challenge_fingerprint = challenge_row.fingerprint
664 algorithm_str = challenge_row.algorithm
665 if datetime.now(timezone.utc) > challenge_row.expires_at:
666 raise AuthError("Challenge has expired — request a new one", status_code=400)
667
668 # 2. Decode and fingerprint-check the new public key.
669 try:
670 from muse.core.types import decode_pubkey, decode_sig
671 _, raw_key = decode_pubkey(public_key_b64)
672 except Exception as exc:
673 raise AuthError(
674 "public_key_b64 must be canonically prefixed (ed25519:<base64url>)",
675 status_code=422,
676 ) from exc
677
678 computed_fp = key_fingerprint(raw_key)
679 if not fingerprints_equal(computed_fp, challenge_fingerprint):
680 raise AuthError(
681 "Public key does not match the fingerprint in the challenge",
682 status_code=401,
683 )
684
685 # 3. Verify the new key's ownership proof.
686 try:
687 _, sig_bytes = decode_sig(signature_b64)
688 except Exception as exc:
689 raise AuthError(
690 "signature_b64 must be canonically prefixed (ed25519:<base64url>)",
691 status_code=422,
692 ) from exc
693
694 try:
695 algo = KeyAlgorithm(algorithm_str)
696 verify_signature(
697 algorithm=algo,
698 public_key_bytes=raw_key,
699 message=bytes.fromhex(nonce_hex),
700 signature_bytes=sig_bytes,
701 )
702 except (AlgorithmNotImplementedError, InvalidKeyError, SignatureError, ValueError, TypeError) as exc:
703 raise AuthError(f"Signature verification failed: {exc}", status_code=401) from exc
704
705 # 4. Persist the new key under the caller's existing identity.
706 _now = datetime.now(timezone.utc)
707 from musehub.core.genesis import compute_key_id as _compute_key_id
708 key_id = _compute_key_id(identity_id, public_key_b64)
709 key_row = MusehubAuthKey(
710 key_id=key_id,
711 identity_id=identity_id,
712 algorithm=algorithm_str,
713 public_key_b64=public_key_b64,
714 fingerprint=computed_fp,
715 label=label or "",
716 last_used_at=_now,
717 )
718 session.add(key_row)
719 try:
720 await session.commit()
721 except IntegrityError:
722 await session.rollback()
723 raise AuthError("Key is already registered", status_code=409)
724
725 logger.info(
726 "🔑 Key added via rotation: identity_id=%s fp=%s", identity_id, computed_fp[:16]
727 )
728
729 await _commit_key_rotation_to_identity_repo(
730 session,
731 identity_id=identity_id,
732 new_public_key_b64=public_key_b64,
733 )
734
735 from musehub.models.musehub_auth import AuthKeyResponse as _AuthKeyResponse
736 return _AuthKeyResponse(
737 key_id=key_row.key_id,
738 algorithm=key_row.algorithm,
739 fingerprint=key_row.fingerprint,
740 label=key_row.label,
741 created_at=key_row.created_at.isoformat(),
742 last_used_at=key_row.last_used_at.isoformat() if key_row.last_used_at else None,
743 )
744
745 async def register_agent_identity(
746 *,
747 session: AsyncSession,
748 handle: str,
749 public_key_b64: str,
750 fingerprint: str,
751 algorithm: str,
752 spawned_by: str,
753 agent_model: str = "",
754 scope: list[str] | None = None,
755 expires_at: str | None = None,
756 label: str = "",
757 ) -> AgentRegistrationResponse:
758 """Operator-provisioned agent identity registration.
759
760 Creates a new ``MusehubIdentity(identity_type="agent")`` with ``spawned_by``
761 set to the operator's handle, then registers the agent's public key.
762
763 This is the server-side implementation of ``POST /api/identities/agent``.
764 The caller must be authenticated via MSign (the operator proves their
765 identity before provisioning an agent key — trust at provisioning time).
766
767 Args:
768 session: Async DB session.
769 handle: Desired agent handle (validated lowercase).
770 public_key_b64: URL-safe base64 of the raw public key.
771 fingerprint: SHA-256 hex of the raw public key bytes.
772 algorithm: Key algorithm ("ed25519").
773 spawned_by: Handle of the authenticated operator.
774 agent_model: LLM model string, e.g. "claude-sonnet-4-6".
775 scope: Permitted operation scopes.
776 expires_at: ISO-8601 UTC expiry string; None = no expiry.
777 label: Friendly key label.
778
779 Returns:
780 :class:`AgentRegistrationResponse`
781
782 Raises:
783 ``AuthError`` (409) if the handle or key fingerprint is already taken.
784 """
785 from datetime import datetime as _dt
786
787 # Validate & decode public key (must be canonically prefixed: "ed25519:<b64url>")
788 try:
789 from muse.core.types import decode_pubkey
790 _, raw_key = decode_pubkey(public_key_b64)
791 except Exception as exc:
792 logger.warning("Agent reg failure: cannot decode public_key_b64 — %s", exc)
793 raise AuthError("public_key_b64 must be canonically prefixed (ed25519:<base64url>)", status_code=422)
794
795 computed_fp = key_fingerprint(raw_key)
796 if computed_fp != fingerprint:
797 logger.warning(
798 "Agent reg failure: fingerprint mismatch — provided=%s computed=%s",
799 fingerprint[:16],
800 computed_fp[:16],
801 )
802 raise AuthError(
803 "Provided fingerprint does not match the public key",
804 status_code=422,
805 )
806
807 # Parse expires_at
808 expires_dt: "datetime | None" = None
809 if expires_at:
810 try:
811 from datetime import timezone as _tz
812 expires_dt = _dt.fromisoformat(expires_at.replace("Z", "+00:00"))
813 if expires_dt.tzinfo is None:
814 expires_dt = expires_dt.replace(tzinfo=_tz.utc)
815 except ValueError as exc:
816 raise AuthError(f"expires_at is not valid ISO-8601: {exc}", status_code=422)
817
818 is_new_identity = False
819
820 # Check whether this fingerprint is already registered (and still active --
821 # a revoked key must be eligible for fresh registration, musehub#223).
822 existing_key: MusehubAuthKey | None = (
823 await session.execute(
824 select(MusehubAuthKey).where(
825 MusehubAuthKey.fingerprint == computed_fp,
826 MusehubAuthKey.revoked_at.is_(None),
827 )
828 )
829 ).scalar_one_or_none()
830
831 if existing_key is not None:
832 # Key already exists — look up the identity and return it.
833 identity: MusehubIdentity | None = (
834 await session.execute(
835 select(MusehubIdentity).where(MusehubIdentity.identity_id == existing_key.identity_id)
836 )
837 ).scalar_one_or_none()
838 if identity is None:
839 raise AuthError("Orphaned key — identity missing", status_code=500)
840 await session.refresh(existing_key)
841 else:
842 # New registration.
843 try:
844 from datetime import timezone as _tz
845 _now = _dt.now(_tz.utc)
846 identity = MusehubIdentity(
847 identity_id=compute_identity_id(raw_key),
848 handle=handle,
849 identity_type="agent",
850 display_name=handle,
851 spawned_by=spawned_by,
852 scope=scope or [],
853 expires_at=expires_dt,
854 agent_model=agent_model or None,
855 agent_capabilities=scope or [],
856 tos_accepted_at=_now,
857 tos_version="1.0",
858 )
859 session.add(identity)
860 await session.flush()
861
862 existing_key = MusehubAuthKey(
863 key_id=compute_key_id(identity.identity_id, public_key_b64),
864 identity_id=identity.identity_id,
865 algorithm=algorithm,
866 public_key_b64=public_key_b64,
867 fingerprint=computed_fp,
868 label=label or "",
869 last_used_at=_dt.now(_tz.utc),
870 )
871 session.add(existing_key)
872 await session.commit()
873 is_new_identity = True
874 logger.info(
875 "✅ Agent registered: handle=%s spawned_by=%s algo=%s",
876 handle, spawned_by, algorithm,
877 )
878 await _create_identity_repo(
879 session,
880 identity_id=identity.identity_id,
881 handle=handle,
882 public_key_b64=public_key_b64,
883 identity_type="agent",
884 )
885 await _commit_spawns_relationship(
886 session,
887 parent_handle=spawned_by,
888 agent_handle=handle,
889 parent_identity_id=identity.identity_id,
890 )
891 await session.commit()
892
893 except IntegrityError:
894 await session.rollback()
895 raise AuthError(
896 f"Handle '{handle}' is already taken or this key is already registered",
897 status_code=409,
898 )
899
900 await session.refresh(existing_key)
901 last_used = existing_key.last_used_at
902 return AgentRegistrationResponse(
903 handle=identity.handle,
904 identity_id=identity.identity_id,
905 is_new_identity=is_new_identity,
906 spawned_by=spawned_by,
907 key=AuthKeyResponse(
908 key_id=existing_key.key_id,
909 algorithm=existing_key.algorithm,
910 fingerprint=existing_key.fingerprint,
911 label=existing_key.label,
912 created_at=existing_key.created_at.isoformat(),
913 last_used_at=last_used.isoformat() if last_used else None,
914 ),
915 )
File History 2 commits
sha256:92528ae07d0e1239d87fd5fd1f439e8fbb49c9778a9a400bc4a736073fb28316 feat: byte-range blob reads, file attribution DAG walk, bra… Sonnet 4.6 minor ⚠ 143 days ago
sha256:c3c2cb91e8fb6b8235f67868620336ac3e425529430280edb6ec79a47258525f fix: dead identity recovery — allow re-keying a handle with… Sonnet 4.6 patch 148 days ago