gabriel / musehub public
musehub_auth.py python
897 lines 31.8 KB
Raw
sha256:92528ae07d0e1239d87fd5fd1f439e8fbb49c9778a9a400bc4a736073fb28316 feat: byte-range blob reads, file attribution DAG walk, bra… Sonnet 4.6 minor ⚠ breaking 17 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
401 key_row: MusehubAuthKey | None = (
402 await session.execute(
403 select(MusehubAuthKey).where(MusehubAuthKey.fingerprint == computed_fp)
404 )
405 ).scalar_one_or_none()
406
407 is_new_identity = False
408
409 if key_row is None:
410 # --- Registration path ---
411 if not handle:
412 raise AuthError(
413 "handle is required when registering a new public key",
414 status_code=422,
415 )
416
417 # Normalize to lowercase to prevent handle squatting (Gabriel == gabriel).
418 handle = handle.strip().lower()
419
420 # Check if this handle belongs to a dead identity (identity exists but
421 # has no keys — caused by logout or failed key rotation). If so, add
422 # the new key to the existing identity instead of creating a new one.
423 existing_identity: MusehubIdentity | None = (
424 await session.execute(
425 select(MusehubIdentity).where(
426 MusehubIdentity.handle == handle,
427 MusehubIdentity.deleted_at.is_(None),
428 )
429 )
430 ).scalar_one_or_none()
431
432 if existing_identity is not None:
433 existing_keys = (
434 await session.execute(
435 select(MusehubAuthKey).where(
436 MusehubAuthKey.identity_id == existing_identity.identity_id
437 )
438 )
439 ).scalars().all()
440
441 if existing_keys:
442 # Handle is live — a different key cannot claim it.
443 raise AuthError(
444 f"Handle '{handle}' is already taken or this key is already registered",
445 status_code=409,
446 )
447
448 # Dead identity recovery: add the new key under the existing identity.
449 logger.warning(
450 "Auth: dead identity recovery for handle=%s identity_id=%s",
451 handle, existing_identity.identity_id[:16],
452 )
453 try:
454 key_row = MusehubAuthKey(
455 key_id=compute_key_id(existing_identity.identity_id, public_key_b64),
456 identity_id=existing_identity.identity_id,
457 algorithm=algorithm_str,
458 public_key_b64=public_key_b64,
459 fingerprint=computed_fp,
460 label=label or "",
461 last_used_at=datetime.now(timezone.utc),
462 )
463 session.add(key_row)
464 await session.commit()
465 identity = existing_identity
466 logger.info(
467 "✅ Auth: dead identity recovered handle=%s algo=%s",
468 handle, algorithm_str,
469 )
470 except IntegrityError:
471 await session.rollback()
472 raise AuthError(
473 f"Handle '{handle}' is already taken or this key is already registered",
474 status_code=409,
475 )
476
477 else:
478 # Truly new identity — normal registration path.
479 try:
480 _now = datetime.now(timezone.utc)
481 identity = MusehubIdentity(
482 identity_id=compute_identity_id(raw_key),
483 handle=handle,
484 identity_type=identity_type,
485 display_name=display_name or handle,
486 tos_accepted_at=_now,
487 tos_version="1.0",
488 )
489 session.add(identity)
490 await session.flush()
491
492 key_row = MusehubAuthKey(
493 key_id=compute_key_id(identity.identity_id, public_key_b64),
494 identity_id=identity.identity_id,
495 algorithm=algorithm_str,
496 public_key_b64=public_key_b64,
497 fingerprint=computed_fp,
498 label=label or "",
499 last_used_at=datetime.now(timezone.utc),
500 )
501 session.add(key_row)
502 await session.commit()
503 is_new_identity = True
504 logger.info(
505 "✅ Auth: registered handle=%s algo=%s type=%s",
506 handle, algorithm_str, identity_type,
507 )
508 await _create_identity_repo(
509 session,
510 identity_id=identity.identity_id,
511 handle=handle,
512 public_key_b64=public_key_b64,
513 identity_type=identity_type,
514 )
515 await session.commit()
516
517 except IntegrityError:
518 await session.rollback()
519 raise AuthError(
520 f"Handle '{handle}' is already taken or this key is already registered",
521 status_code=409,
522 )
523
524 else:
525 # --- Login path (re-register existing key) ---
526 identity_row: MusehubIdentity | None = (
527 await session.execute(
528 select(MusehubIdentity).where(MusehubIdentity.identity_id == key_row.identity_id)
529 )
530 ).scalar_one_or_none()
531
532 if identity_row is None:
533 logger.warning("Auth failure: key exists but identity is gone — key_id=%s", key_row.key_id)
534 raise AuthError("Identity for this key no longer exists", status_code=401)
535
536 identity = identity_row
537
538 await session.execute(
539 update(MusehubAuthKey)
540 .where(MusehubAuthKey.key_id == key_row.key_id)
541 .values(last_used_at=datetime.now(timezone.utc))
542 )
543 await session.commit()
544 logger.info("✅ Auth: login handle=%s algo=%s", identity.handle, algorithm_str)
545
546 await session.refresh(key_row)
547
548 last_used = key_row.last_used_at
549 return VerifyResponse(
550 handle=identity.handle,
551 identity_id=identity.identity_id,
552 is_new_identity=is_new_identity,
553 auth_method=algorithm_str,
554 key=AuthKeyResponse(
555 key_id=key_row.key_id,
556 algorithm=key_row.algorithm,
557 fingerprint=key_row.fingerprint,
558 label=key_row.label,
559 created_at=key_row.created_at.isoformat(),
560 last_used_at=last_used.isoformat() if last_used else None,
561 ),
562 )
563
564 # ---------------------------------------------------------------------------
565 # Key management helpers
566 # ---------------------------------------------------------------------------
567
568 async def get_keys_for_identity(
569 session: AsyncSession, identity_id: str
570 ) -> list[AuthKeyResponse]:
571 """Return all registered public keys for an identity (no key material)."""
572 rows = (
573 await session.execute(
574 select(MusehubAuthKey)
575 .where(MusehubAuthKey.identity_id == identity_id)
576 .order_by(MusehubAuthKey.created_at)
577 )
578 ).scalars().all()
579
580 return [
581 AuthKeyResponse(
582 key_id=r.key_id,
583 algorithm=r.algorithm,
584 fingerprint=r.fingerprint,
585 label=r.label,
586 created_at=r.created_at.isoformat(),
587 last_used_at=r.last_used_at.isoformat() if r.last_used_at else None,
588 )
589 for r in rows
590 ]
591
592 async def revoke_key(
593 session: AsyncSession, identity_id: str, key_id: str
594 ) -> bool:
595 """Delete a key row. Returns True if deleted, False if not found."""
596 row: MusehubAuthKey | None = (
597 await session.execute(
598 select(MusehubAuthKey).where(
599 MusehubAuthKey.key_id == key_id,
600 MusehubAuthKey.identity_id == identity_id,
601 )
602 )
603 ).scalar_one_or_none()
604
605 if row is None:
606 return False
607
608 await session.delete(row)
609 await session.commit()
610 return True
611
612 async def add_key_for_identity(
613 *,
614 session: AsyncSession,
615 identity_id: str,
616 challenge_token: str,
617 public_key_b64: str,
618 signature_b64: str,
619 label: str | None = None,
620 algorithm: str = "ed25519",
621 ) -> "AuthKeyResponse":
622 """Add a new key to an existing identity during key rotation.
623
624 Security guarantees
625 -------------------
626 - The challenge-response proof (``challenge_token`` + ``signature_b64``)
627 shows the caller possesses the new private key — prevents key injection.
628 - The MSign ``Authorization`` header (verified upstream by ``require_valid_token``)
629 shows the caller already owns an existing key for ``identity_id`` — prevents
630 an unauthenticated caller from adding keys to someone else's identity.
631
632 Raises ``AuthError`` on any crypto or DB failure.
633 """
634 nonce_hex = challenge_token
635
636 # 1. Consume the nonce (single-use, atomic delete).
637 challenge_row: MusehubAuthChallenge | None = (
638 await session.execute(
639 select(MusehubAuthChallenge).where(MusehubAuthChallenge.nonce_hex == nonce_hex)
640 )
641 ).scalar_one_or_none()
642
643 if challenge_row is None:
644 raise AuthError("Unknown or expired challenge — request a new one", status_code=400)
645
646 await session.delete(challenge_row)
647 await session.flush()
648
649 challenge_fingerprint = challenge_row.fingerprint
650 algorithm_str = challenge_row.algorithm
651 if datetime.now(timezone.utc) > challenge_row.expires_at:
652 raise AuthError("Challenge has expired — request a new one", status_code=400)
653
654 # 2. Decode and fingerprint-check the new public key.
655 try:
656 from muse.core.types import decode_pubkey, decode_sig
657 _, raw_key = decode_pubkey(public_key_b64)
658 except Exception as exc:
659 raise AuthError(
660 "public_key_b64 must be canonically prefixed (ed25519:<base64url>)",
661 status_code=422,
662 ) from exc
663
664 computed_fp = key_fingerprint(raw_key)
665 if not fingerprints_equal(computed_fp, challenge_fingerprint):
666 raise AuthError(
667 "Public key does not match the fingerprint in the challenge",
668 status_code=401,
669 )
670
671 # 3. Verify the new key's ownership proof.
672 try:
673 _, sig_bytes = decode_sig(signature_b64)
674 except Exception as exc:
675 raise AuthError(
676 "signature_b64 must be canonically prefixed (ed25519:<base64url>)",
677 status_code=422,
678 ) from exc
679
680 try:
681 algo = KeyAlgorithm(algorithm_str)
682 verify_signature(
683 algorithm=algo,
684 public_key_bytes=raw_key,
685 message=bytes.fromhex(nonce_hex),
686 signature_bytes=sig_bytes,
687 )
688 except (AlgorithmNotImplementedError, InvalidKeyError, SignatureError, ValueError, TypeError) as exc:
689 raise AuthError(f"Signature verification failed: {exc}", status_code=401) from exc
690
691 # 4. Persist the new key under the caller's existing identity.
692 _now = datetime.now(timezone.utc)
693 from musehub.core.genesis import compute_key_id as _compute_key_id
694 key_id = _compute_key_id(identity_id, public_key_b64)
695 key_row = MusehubAuthKey(
696 key_id=key_id,
697 identity_id=identity_id,
698 algorithm=algorithm_str,
699 public_key_b64=public_key_b64,
700 fingerprint=computed_fp,
701 label=label or "",
702 last_used_at=_now,
703 )
704 session.add(key_row)
705 try:
706 await session.commit()
707 except IntegrityError:
708 await session.rollback()
709 raise AuthError("Key is already registered", status_code=409)
710
711 logger.info(
712 "🔑 Key added via rotation: identity_id=%s fp=%s", identity_id, computed_fp[:16]
713 )
714
715 await _commit_key_rotation_to_identity_repo(
716 session,
717 identity_id=identity_id,
718 new_public_key_b64=public_key_b64,
719 )
720
721 from musehub.models.musehub_auth import AuthKeyResponse as _AuthKeyResponse
722 return _AuthKeyResponse(
723 key_id=key_row.key_id,
724 algorithm=key_row.algorithm,
725 fingerprint=key_row.fingerprint,
726 label=key_row.label,
727 created_at=key_row.created_at.isoformat(),
728 last_used_at=key_row.last_used_at.isoformat() if key_row.last_used_at else None,
729 )
730
731 async def register_agent_identity(
732 *,
733 session: AsyncSession,
734 handle: str,
735 public_key_b64: str,
736 fingerprint: str,
737 algorithm: str,
738 spawned_by: str,
739 agent_model: str = "",
740 scope: list[str] | None = None,
741 expires_at: str | None = None,
742 label: str = "",
743 ) -> AgentRegistrationResponse:
744 """Operator-provisioned agent identity registration.
745
746 Creates a new ``MusehubIdentity(identity_type="agent")`` with ``spawned_by``
747 set to the operator's handle, then registers the agent's public key.
748
749 This is the server-side implementation of ``POST /api/identities/agent``.
750 The caller must be authenticated via MSign (the operator proves their
751 identity before provisioning an agent key — trust at provisioning time).
752
753 Args:
754 session: Async DB session.
755 handle: Desired agent handle (validated lowercase).
756 public_key_b64: URL-safe base64 of the raw public key.
757 fingerprint: SHA-256 hex of the raw public key bytes.
758 algorithm: Key algorithm ("ed25519").
759 spawned_by: Handle of the authenticated operator.
760 agent_model: LLM model string, e.g. "claude-sonnet-4-6".
761 scope: Permitted operation scopes.
762 expires_at: ISO-8601 UTC expiry string; None = no expiry.
763 label: Friendly key label.
764
765 Returns:
766 :class:`AgentRegistrationResponse`
767
768 Raises:
769 ``AuthError`` (409) if the handle or key fingerprint is already taken.
770 """
771 from datetime import datetime as _dt
772
773 # Validate & decode public key (must be canonically prefixed: "ed25519:<b64url>")
774 try:
775 from muse.core.types import decode_pubkey
776 _, raw_key = decode_pubkey(public_key_b64)
777 except Exception as exc:
778 logger.warning("Agent reg failure: cannot decode public_key_b64 — %s", exc)
779 raise AuthError("public_key_b64 must be canonically prefixed (ed25519:<base64url>)", status_code=422)
780
781 computed_fp = key_fingerprint(raw_key)
782 if computed_fp != fingerprint:
783 logger.warning(
784 "Agent reg failure: fingerprint mismatch — provided=%s computed=%s",
785 fingerprint[:16],
786 computed_fp[:16],
787 )
788 raise AuthError(
789 "Provided fingerprint does not match the public key",
790 status_code=422,
791 )
792
793 # Parse expires_at
794 expires_dt: "datetime | None" = None
795 if expires_at:
796 try:
797 from datetime import timezone as _tz
798 expires_dt = _dt.fromisoformat(expires_at.replace("Z", "+00:00"))
799 if expires_dt.tzinfo is None:
800 expires_dt = expires_dt.replace(tzinfo=_tz.utc)
801 except ValueError as exc:
802 raise AuthError(f"expires_at is not valid ISO-8601: {exc}", status_code=422)
803
804 is_new_identity = False
805
806 # Check whether this fingerprint is already registered.
807 existing_key: MusehubAuthKey | None = (
808 await session.execute(
809 select(MusehubAuthKey).where(MusehubAuthKey.fingerprint == computed_fp)
810 )
811 ).scalar_one_or_none()
812
813 if existing_key is not None:
814 # Key already exists — look up the identity and return it.
815 identity: MusehubIdentity | None = (
816 await session.execute(
817 select(MusehubIdentity).where(MusehubIdentity.identity_id == existing_key.identity_id)
818 )
819 ).scalar_one_or_none()
820 if identity is None:
821 raise AuthError("Orphaned key — identity missing", status_code=500)
822 await session.refresh(existing_key)
823 else:
824 # New registration.
825 try:
826 from datetime import timezone as _tz
827 _now = _dt.now(_tz.utc)
828 identity = MusehubIdentity(
829 identity_id=compute_identity_id(raw_key),
830 handle=handle,
831 identity_type="agent",
832 display_name=handle,
833 spawned_by=spawned_by,
834 scope=scope or [],
835 expires_at=expires_dt,
836 agent_model=agent_model or None,
837 agent_capabilities=scope or [],
838 tos_accepted_at=_now,
839 tos_version="1.0",
840 )
841 session.add(identity)
842 await session.flush()
843
844 existing_key = MusehubAuthKey(
845 key_id=compute_key_id(identity.identity_id, public_key_b64),
846 identity_id=identity.identity_id,
847 algorithm=algorithm,
848 public_key_b64=public_key_b64,
849 fingerprint=computed_fp,
850 label=label or "",
851 last_used_at=_dt.now(_tz.utc),
852 )
853 session.add(existing_key)
854 await session.commit()
855 is_new_identity = True
856 logger.info(
857 "✅ Agent registered: handle=%s spawned_by=%s algo=%s",
858 handle, spawned_by, algorithm,
859 )
860 await _create_identity_repo(
861 session,
862 identity_id=identity.identity_id,
863 handle=handle,
864 public_key_b64=public_key_b64,
865 identity_type="agent",
866 )
867 await _commit_spawns_relationship(
868 session,
869 parent_handle=spawned_by,
870 agent_handle=handle,
871 parent_identity_id=identity.identity_id,
872 )
873 await session.commit()
874
875 except IntegrityError:
876 await session.rollback()
877 raise AuthError(
878 f"Handle '{handle}' is already taken or this key is already registered",
879 status_code=409,
880 )
881
882 await session.refresh(existing_key)
883 last_used = existing_key.last_used_at
884 return AgentRegistrationResponse(
885 handle=identity.handle,
886 identity_id=identity.identity_id,
887 is_new_identity=is_new_identity,
888 spawned_by=spawned_by,
889 key=AuthKeyResponse(
890 key_id=existing_key.key_id,
891 algorithm=existing_key.algorithm,
892 fingerprint=existing_key.fingerprint,
893 label=existing_key.label,
894 created_at=existing_key.created_at.isoformat(),
895 last_used_at=last_used.isoformat() if last_used else None,
896 ),
897 )
File History 2 commits
sha256:92528ae07d0e1239d87fd5fd1f439e8fbb49c9778a9a400bc4a736073fb28316 feat: byte-range blob reads, file attribution DAG walk, bra… Sonnet 4.6 minor 17 days ago
sha256:c3c2cb91e8fb6b8235f67868620336ac3e425529430280edb6ec79a47258525f fix: dead identity recovery — allow re-keying a handle with… Sonnet 4.6 patch 22 days ago