gabriel / musehub public
request_signing.py python
330 lines 12.1 KB
Raw
sha256:c748a46461e554a9560edea4edf4c3dffd2a7f81a1948ebdba60f5ec940c6e25 fix: use raw ASGI path for MSign canonical message Sonnet 4.6 patch 47 days ago
1 """Per-request Ed25519 signature verification for MSign authentication.
2
3 Every authenticated request carries:
4
5 Authorization: MSign handle="gabriel" alg="ed25519" ts=1712345678 sig="<b64url>"
6
7 where ``sig`` is an Ed25519 signature over the canonical message:
8
9 ALGORITHM\\n
10 METHOD\\n
11 HOST\\n
12 PATH_WITH_QUERY\\n
13 UNIX_TS\\n
14 SHA256_HEX(body_bytes or "")
15
16 The algorithm identifier is the first field inside the signed bytes — this
17 makes downgrade attacks cryptographically impossible during algorithm
18 migration (e.g. ed25519 → ml-dsa-65). The host field prevents cross-
19 environment replay: a request signed for localhost cannot be replayed
20 against staging or production.
21
22 Replay protection: the server rejects requests where
23 ``abs(server_time - ts) > REPLAY_WINDOW_SECONDS`` (default 30).
24
25 Scope enforcement: identities with a non-None ``scope`` list are restricted
26 to the named capability tokens. Human identities carry ``scope = None``
27 and pass all scope checks unconditionally. Agent identities carry an
28 explicit scope list populated at registration time.
29
30 No server secret, no token expiry, no refresh — the key IS the credential.
31 """
32
33 import datetime
34 import logging
35 import re
36 import time
37 from collections.abc import Awaitable, Callable
38 from dataclasses import dataclass, field
39
40 from fastapi import Depends, HTTPException, Request, status
41 from sqlalchemy import select
42 from sqlalchemy.ext.asyncio import AsyncSession
43
44 import hashlib as _hashlib
45
46 from muse.core.types import DEFAULT_SIGN_ALGO
47 from musehub.crypto.keys import KeyAlgorithm
48 from musehub.db.database import get_db
49 from musehub.db.musehub_auth_models import MusehubAuthKey
50 from musehub.db.musehub_identity_models import MusehubIdentity
51
52 logger = logging.getLogger(__name__)
53
54 REPLAY_WINDOW_SECONDS: int = 30
55
56 # Matches: MSign handle="gabriel" alg="ed25519" ts=1712345678 sig="<b64url>"
57 _MSIGN_RE = re.compile(
58 r'^MSign\s+'
59 r'handle="(?P<handle>[^"]+)"\s+'
60 r'alg="(?P<alg>[^"]+)"\s+'
61 r'ts=(?P<ts>\d+)\s+'
62 r'sig="(?P<sig>[A-Za-z0-9_=-]+)"'
63 )
64
65 # Standard ports stripped from the host in canonical messages (RFC 7230).
66 _STANDARD_PORTS: dict[str, int] = {"https": 443, "http": 80}
67
68 # ---------------------------------------------------------------------------
69 # Context object returned by the FastAPI dependency
70 # ---------------------------------------------------------------------------
71
72 @dataclass
73 class MSignContext:
74 """Verified request context — passed as the dependency result to route handlers.
75
76 ``scope`` mirrors the value stored on ``MusehubIdentity.scope``:
77 - ``None`` — human identity; no capability restrictions apply.
78 - ``[...]`` — agent identity; only the listed capability tokens are
79 permitted. Routes enforce this via ``require_scope()``.
80 """
81
82 handle: str # identity handle, e.g. "gabriel"
83 identity_id: str # internal sha256 genesis hash from musehub_identities.id
84 is_agent: bool # True when identity_type == "agent"
85 is_admin: bool # True when identity.is_admin column is True; never derived from identity_type
86 scope: list[str] | None = field(default=None)
87
88 # ---------------------------------------------------------------------------
89 # Internal helpers
90 # ---------------------------------------------------------------------------
91
92 def _parse_msign_header(authorization: str) -> tuple[str, str, int, str] | None:
93 """Parse ``Authorization: MSign …`` and return ``(handle, alg, ts, sig_b64)`` or None."""
94 m = _MSIGN_RE.match(authorization.strip())
95 if m is None:
96 return None
97 return m.group("handle"), m.group("alg"), int(m.group("ts")), m.group("sig")
98
99 def _normalise_host(request: Request) -> str:
100 """Return the canonical host string for inclusion in the signed payload.
101
102 Lowercased; standard ports stripped (:443 for https, :80 for http);
103 non-standard ports kept (localhost:1337).
104 """
105 url = request.url
106 host = (url.hostname or "").lower()
107 port = url.port
108 scheme = url.scheme.lower()
109 if port is not None and port != _STANDARD_PORTS.get(scheme):
110 return f"{host}:{port}"
111 return host
112
113
114 def _extract_signed_path(request: Request) -> str:
115 """Return the path+query string to use in the canonical signed message.
116
117 Uvicorn/ASGI sets ``scope["path"]`` (and therefore ``request.url.path``)
118 to the *decoded* URL path — e.g. a ``DELETE`` to
119 ``/gabriel/muse/branches/feat%2Ffix-foo`` arrives with
120 ``request.url.path == "/gabriel/muse/branches/feat/fix-foo"``. The client
121 signs the *encoded* form (see ``muse.core.msign.build_msign_header``), so
122 using ``request.url.path`` here caused a signature mismatch — and an
123 HTTP 401 — for any branch name containing a slash (``feat/*``, ``task/*``,
124 i.e. nearly every real branch name in this workspace's convention).
125
126 ``scope["raw_path"]`` carries the original encoded bytes off the wire and
127 is used instead whenever present, matching what the client actually
128 signed. Falls back to ``request.url.path`` for ASGI servers that omit
129 ``raw_path``.
130 """
131 raw_path: bytes = request.scope.get("raw_path", b"")
132 path_with_query = raw_path.decode("ascii", errors="replace") if raw_path else request.url.path
133 if request.url.query:
134 path_with_query = f"{path_with_query}?{request.url.query}"
135 return path_with_query
136
137 def build_canonical_message(
138 method: str,
139 path_with_query: str,
140 ts: int,
141 body_bytes: bytes,
142 *,
143 host: str = "",
144 algorithm: str = DEFAULT_SIGN_ALGO,
145 ) -> bytes:
146 """Return the bytes that were signed by the client.
147
148 Layout: ``{algorithm}\\n{METHOD}\\n{host}\\n{path_with_query}\\n{ts}\\n{body_hash}``
149 """
150 body_hash = "sha256:" + _hashlib.sha256(body_bytes).hexdigest()
151 return f"{algorithm}\n{method.upper()}\n{host}\n{path_with_query}\n{ts}\n{body_hash}".encode()
152
153 async def _verify_msign(
154 request: Request,
155 db: AsyncSession,
156 required: bool,
157 ) -> MSignContext | None:
158 """Core verification logic shared by required and optional dependencies."""
159 from muse.core.types import decode_pubkey
160 from musehub.crypto.keys import KeyAlgorithm, verify_signature, SignatureError, InvalidKeyError, b64url_decode
161
162 auth_header: str | None = request.headers.get("Authorization")
163
164 if not auth_header:
165 if required:
166 raise HTTPException(
167 status_code=status.HTTP_401_UNAUTHORIZED,
168 detail="MSign Authorization header required.",
169 headers={"WWW-Authenticate": 'MSign realm="musehub"'},
170 )
171 return None
172
173 if not auth_header.startswith("MSign "):
174 if required:
175 raise HTTPException(
176 status_code=status.HTTP_401_UNAUTHORIZED,
177 detail="Invalid authorization scheme — expected MSign.",
178 headers={"WWW-Authenticate": 'MSign realm="musehub"'},
179 )
180 return None
181
182 parsed = _parse_msign_header(auth_header)
183 if parsed is None:
184 raise HTTPException(
185 status_code=status.HTTP_401_UNAUTHORIZED,
186 detail="Malformed MSign header.",
187 headers={"WWW-Authenticate": 'MSign realm="musehub"'},
188 )
189
190 handle, alg, ts, sig_b64 = parsed
191
192 # Replay protection
193 server_ts = int(time.time())
194 if abs(server_ts - ts) > REPLAY_WINDOW_SECONDS:
195 raise HTTPException(
196 status_code=status.HTTP_401_UNAUTHORIZED,
197 detail=f"Request timestamp too far from server time (skew={abs(server_ts - ts)}s, max={REPLAY_WINDOW_SECONDS}s).",
198 )
199
200 # Look up identity
201 identity: MusehubIdentity | None = (
202 await db.execute(
203 select(MusehubIdentity).where(
204 MusehubIdentity.handle == handle,
205 MusehubIdentity.deleted_at.is_(None),
206 )
207 )
208 ).scalar_one_or_none()
209
210 if identity is None:
211 logger.warning("MSign: unknown handle '%s'", handle)
212 raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Unknown identity.")
213
214 # Reject expired identities — checked before key verification so
215 # a rotated key on an expired identity cannot be used to re-authenticate.
216 if identity.expires_at is not None:
217 now = datetime.datetime.now(datetime.timezone.utc)
218 if identity.expires_at <= now:
219 logger.warning(
220 "MSign: expired identity '%s' (expires_at=%s)",
221 handle,
222 identity.expires_at.isoformat(),
223 )
224 raise HTTPException(
225 status_code=status.HTTP_401_UNAUTHORIZED,
226 detail="Identity has expired.",
227 )
228
229 # Load all auth keys for this identity
230 key_rows = (
231 await db.execute(
232 select(MusehubAuthKey).where(MusehubAuthKey.identity_id == identity.identity_id)
233 )
234 ).scalars().all()
235
236 if not key_rows:
237 raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="No registered keys for identity.")
238
239 _ct = request.headers.get("content-type", "")
240 if _ct.startswith("application/x-muse-mpack"):
241 body_bytes = b""
242 else:
243 body_bytes = await request.body()
244 path_with_query = _extract_signed_path(request)
245 host = _normalise_host(request)
246 canonical = build_canonical_message(
247 request.method, path_with_query, ts, body_bytes,
248 host=host, algorithm=alg,
249 )
250
251 # Try to verify against any key
252 try:
253 sig_bytes = b64url_decode(sig_b64)
254 except Exception:
255 raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid signature encoding.")
256
257 verified = False
258 for key_row in key_rows:
259 try:
260 algo = KeyAlgorithm(key_row.algorithm)
261 _, raw_key = decode_pubkey(key_row.public_key_b64)
262 verify_signature(
263 algorithm=algo,
264 public_key_bytes=raw_key,
265 message=canonical,
266 signature_bytes=sig_bytes,
267 )
268 verified = True
269 break
270 except (SignatureError, InvalidKeyError, ValueError):
271 continue
272
273 if not verified:
274 logger.warning("MSign: signature verification failed for handle='%s'", handle)
275 raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Signature verification failed.")
276
277 return MSignContext(
278 handle=identity.handle,
279 identity_id=identity.identity_id,
280 is_agent=(identity.identity_type == "agent"),
281 is_admin=identity.is_admin,
282 scope=identity.scope,
283 )
284
285 # ---------------------------------------------------------------------------
286 # FastAPI dependencies
287 # ---------------------------------------------------------------------------
288
289 async def require_signed_request(
290 request: Request,
291 db: AsyncSession = Depends(get_db),
292 ) -> MSignContext:
293 """Require a valid MSign Authorization header. Raises 401 if missing or invalid."""
294 result = await _verify_msign(request, db, required=True)
295 assert result is not None
296 return result
297
298 async def optional_signed_request(
299 request: Request,
300 db: AsyncSession = Depends(get_db),
301 ) -> MSignContext | None:
302 """Accept an optional MSign header. Returns None for anonymous requests."""
303 return await _verify_msign(request, db, required=False)
304
305 def require_scope(scope_value: str) -> Callable[..., Awaitable[MSignContext]]:
306 """Dependency factory: require a valid MSign header AND the specified scope.
307
308 Identities with ``scope = None`` (humans) pass unconditionally.
309 Identities with a scope list (agents) are rejected with 403 unless
310 ``scope_value`` appears in their scope list.
311
312 Usage::
313
314 @router.post("/issues")
315 async def create_issue(
316 token: TokenClaims = Depends(require_scope("issue:write")),
317 ) -> IssueResponse:
318 ...
319 """
320 async def _dependency(
321 claims: MSignContext = Depends(require_signed_request),
322 ) -> MSignContext:
323 if claims.scope is not None and scope_value not in claims.scope:
324 raise HTTPException(
325 status_code=status.HTTP_403_FORBIDDEN,
326 detail=f"Scope '{scope_value}' required.",
327 )
328 return claims
329
330 return _dependency
File History 1 commit
sha256:c748a46461e554a9560edea4edf4c3dffd2a7f81a1948ebdba60f5ec940c6e25 fix: use raw ASGI path for MSign canonical message Sonnet 4.6 patch 47 days ago