"""Per-request Ed25519 signature verification for MSign authentication. Every authenticated request carries: Authorization: MSign handle="gabriel" alg="ed25519" ts=1712345678 sig="" where ``sig`` is an Ed25519 signature over the canonical message: ALGORITHM\\n METHOD\\n HOST\\n PATH_WITH_QUERY\\n UNIX_TS\\n SHA256_HEX(body_bytes or "") The algorithm identifier is the first field inside the signed bytes — this makes downgrade attacks cryptographically impossible during algorithm migration (e.g. ed25519 → ml-dsa-65). The host field prevents cross- environment replay: a request signed for localhost cannot be replayed against staging or production. Replay protection: the server rejects requests where ``abs(server_time - ts) > REPLAY_WINDOW_SECONDS`` (default 30). Scope enforcement: identities with a non-None ``scope`` list are restricted to the named capability tokens. Human identities carry ``scope = None`` and pass all scope checks unconditionally. Agent identities carry an explicit scope list populated at registration time. No server secret, no token expiry, no refresh — the key IS the credential. """ import datetime import logging import re import time from collections.abc import Awaitable, Callable from dataclasses import dataclass, field from fastapi import Depends, HTTPException, Request, status from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession import hashlib as _hashlib from muse.core.types import DEFAULT_SIGN_ALGO from musehub.crypto.keys import KeyAlgorithm from musehub.db.database import get_db from musehub.db.musehub_auth_models import MusehubAuthKey from musehub.db.musehub_identity_models import MusehubIdentity logger = logging.getLogger(__name__) REPLAY_WINDOW_SECONDS: int = 30 # Matches: MSign handle="gabriel" alg="ed25519" ts=1712345678 sig="" _MSIGN_RE = re.compile( r'^MSign\s+' r'handle="(?P[^"]+)"\s+' r'alg="(?P[^"]+)"\s+' r'ts=(?P\d+)\s+' r'sig="(?P[A-Za-z0-9_=-]+)"' ) # Standard ports stripped from the host in canonical messages (RFC 7230). _STANDARD_PORTS: dict[str, int] = {"https": 443, "http": 80} # --------------------------------------------------------------------------- # Context object returned by the FastAPI dependency # --------------------------------------------------------------------------- @dataclass class MSignContext: """Verified request context — passed as the dependency result to route handlers. ``scope`` mirrors the value stored on ``MusehubIdentity.scope``: - ``None`` — human identity; no capability restrictions apply. - ``[...]`` — agent identity; only the listed capability tokens are permitted. Routes enforce this via ``require_scope()``. """ handle: str # identity handle, e.g. "gabriel" identity_id: str # internal sha256 genesis hash from musehub_identities.id is_agent: bool # True when identity_type == "agent" is_admin: bool # True when identity.is_admin column is True; never derived from identity_type scope: list[str] | None = field(default=None) # --------------------------------------------------------------------------- # Internal helpers # --------------------------------------------------------------------------- def _parse_msign_header(authorization: str) -> tuple[str, str, int, str] | None: """Parse ``Authorization: MSign …`` and return ``(handle, alg, ts, sig_b64)`` or None.""" m = _MSIGN_RE.match(authorization.strip()) if m is None: return None return m.group("handle"), m.group("alg"), int(m.group("ts")), m.group("sig") def _normalise_host(request: Request) -> str: """Return the canonical host string for inclusion in the signed payload. Lowercased; standard ports stripped (:443 for https, :80 for http); non-standard ports kept (localhost:1337). """ url = request.url host = (url.hostname or "").lower() port = url.port scheme = url.scheme.lower() if port is not None and port != _STANDARD_PORTS.get(scheme): return f"{host}:{port}" return host def build_canonical_message( method: str, path_with_query: str, ts: int, body_bytes: bytes, *, host: str = "", algorithm: str = DEFAULT_SIGN_ALGO, ) -> bytes: """Return the bytes that were signed by the client. Layout: ``{algorithm}\\n{METHOD}\\n{host}\\n{path_with_query}\\n{ts}\\n{body_hash}`` """ body_hash = "sha256:" + _hashlib.sha256(body_bytes).hexdigest() return f"{algorithm}\n{method.upper()}\n{host}\n{path_with_query}\n{ts}\n{body_hash}".encode() async def _verify_msign( request: Request, db: AsyncSession, required: bool, ) -> MSignContext | None: """Core verification logic shared by required and optional dependencies.""" from muse.core.types import decode_pubkey from musehub.crypto.keys import KeyAlgorithm, verify_signature, SignatureError, InvalidKeyError, b64url_decode auth_header: str | None = request.headers.get("Authorization") if not auth_header: if required: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="MSign Authorization header required.", headers={"WWW-Authenticate": 'MSign realm="musehub"'}, ) return None if not auth_header.startswith("MSign "): if required: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid authorization scheme — expected MSign.", headers={"WWW-Authenticate": 'MSign realm="musehub"'}, ) return None parsed = _parse_msign_header(auth_header) if parsed is None: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Malformed MSign header.", headers={"WWW-Authenticate": 'MSign realm="musehub"'}, ) handle, alg, ts, sig_b64 = parsed # Replay protection server_ts = int(time.time()) if abs(server_ts - ts) > REPLAY_WINDOW_SECONDS: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail=f"Request timestamp too far from server time (skew={abs(server_ts - ts)}s, max={REPLAY_WINDOW_SECONDS}s).", ) # Look up identity identity: MusehubIdentity | None = ( await db.execute( select(MusehubIdentity).where( MusehubIdentity.handle == handle, MusehubIdentity.deleted_at.is_(None), ) ) ).scalar_one_or_none() if identity is None: logger.warning("MSign: unknown handle '%s'", handle) raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Unknown identity.") # Reject expired identities — checked before key verification so # a rotated key on an expired identity cannot be used to re-authenticate. if identity.expires_at is not None: now = datetime.datetime.now(datetime.timezone.utc) if identity.expires_at <= now: logger.warning( "MSign: expired identity '%s' (expires_at=%s)", handle, identity.expires_at.isoformat(), ) raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Identity has expired.", ) # Load all auth keys for this identity key_rows = ( await db.execute( select(MusehubAuthKey).where(MusehubAuthKey.identity_id == identity.identity_id) ) ).scalars().all() if not key_rows: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="No registered keys for identity.") _ct = request.headers.get("content-type", "") if _ct.startswith("application/x-muse-mpack"): body_bytes = b"" else: body_bytes = await request.body() path_with_query = request.url.path if request.url.query: path_with_query = f"{path_with_query}?{request.url.query}" host = _normalise_host(request) canonical = build_canonical_message( request.method, path_with_query, ts, body_bytes, host=host, algorithm=alg, ) # Try to verify against any key try: sig_bytes = b64url_decode(sig_b64) except Exception: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid signature encoding.") verified = False for key_row in key_rows: try: algo = KeyAlgorithm(key_row.algorithm) _, raw_key = decode_pubkey(key_row.public_key_b64) verify_signature( algorithm=algo, public_key_bytes=raw_key, message=canonical, signature_bytes=sig_bytes, ) verified = True break except (SignatureError, InvalidKeyError, ValueError): continue if not verified: logger.warning("MSign: signature verification failed for handle='%s'", handle) raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Signature verification failed.") return MSignContext( handle=identity.handle, identity_id=identity.identity_id, is_agent=(identity.identity_type == "agent"), is_admin=identity.is_admin, scope=identity.scope, ) # --------------------------------------------------------------------------- # FastAPI dependencies # --------------------------------------------------------------------------- async def require_signed_request( request: Request, db: AsyncSession = Depends(get_db), ) -> MSignContext: """Require a valid MSign Authorization header. Raises 401 if missing or invalid.""" result = await _verify_msign(request, db, required=True) assert result is not None return result async def optional_signed_request( request: Request, db: AsyncSession = Depends(get_db), ) -> MSignContext | None: """Accept an optional MSign header. Returns None for anonymous requests.""" return await _verify_msign(request, db, required=False) def require_scope(scope_value: str) -> Callable[..., Awaitable[MSignContext]]: """Dependency factory: require a valid MSign header AND the specified scope. Identities with ``scope = None`` (humans) pass unconditionally. Identities with a scope list (agents) are rejected with 403 unless ``scope_value`` appears in their scope list. Usage:: @router.post("/issues") async def create_issue( token: TokenClaims = Depends(require_scope("issue:write")), ) -> IssueResponse: ... """ async def _dependency( claims: MSignContext = Depends(require_signed_request), ) -> MSignContext: if claims.scope is not None and scope_value not in claims.scope: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=f"Scope '{scope_value}' required.", ) return claims return _dependency