gabriel / musehub public
request_signing.py python
308 lines 10.9 KB
Raw
sha256:c748a46461e554a9560edea4edf4c3dffd2a7f81a1948ebdba60f5ec940c6e25 fix: use raw ASGI path for MSign canonical message Sonnet 4.6 patch 43 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 def build_canonical_message(
114 method: str,
115 path_with_query: str,
116 ts: int,
117 body_bytes: bytes,
118 *,
119 host: str = "",
120 algorithm: str = DEFAULT_SIGN_ALGO,
121 ) -> bytes:
122 """Return the bytes that were signed by the client.
123
124 Layout: ``{algorithm}\\n{METHOD}\\n{host}\\n{path_with_query}\\n{ts}\\n{body_hash}``
125 """
126 body_hash = "sha256:" + _hashlib.sha256(body_bytes).hexdigest()
127 return f"{algorithm}\n{method.upper()}\n{host}\n{path_with_query}\n{ts}\n{body_hash}".encode()
128
129 async def _verify_msign(
130 request: Request,
131 db: AsyncSession,
132 required: bool,
133 ) -> MSignContext | None:
134 """Core verification logic shared by required and optional dependencies."""
135 from muse.core.types import decode_pubkey
136 from musehub.crypto.keys import KeyAlgorithm, verify_signature, SignatureError, InvalidKeyError, b64url_decode
137
138 auth_header: str | None = request.headers.get("Authorization")
139
140 if not auth_header:
141 if required:
142 raise HTTPException(
143 status_code=status.HTTP_401_UNAUTHORIZED,
144 detail="MSign Authorization header required.",
145 headers={"WWW-Authenticate": 'MSign realm="musehub"'},
146 )
147 return None
148
149 if not auth_header.startswith("MSign "):
150 if required:
151 raise HTTPException(
152 status_code=status.HTTP_401_UNAUTHORIZED,
153 detail="Invalid authorization scheme — expected MSign.",
154 headers={"WWW-Authenticate": 'MSign realm="musehub"'},
155 )
156 return None
157
158 parsed = _parse_msign_header(auth_header)
159 if parsed is None:
160 raise HTTPException(
161 status_code=status.HTTP_401_UNAUTHORIZED,
162 detail="Malformed MSign header.",
163 headers={"WWW-Authenticate": 'MSign realm="musehub"'},
164 )
165
166 handle, alg, ts, sig_b64 = parsed
167
168 # Replay protection
169 server_ts = int(time.time())
170 if abs(server_ts - ts) > REPLAY_WINDOW_SECONDS:
171 raise HTTPException(
172 status_code=status.HTTP_401_UNAUTHORIZED,
173 detail=f"Request timestamp too far from server time (skew={abs(server_ts - ts)}s, max={REPLAY_WINDOW_SECONDS}s).",
174 )
175
176 # Look up identity
177 identity: MusehubIdentity | None = (
178 await db.execute(
179 select(MusehubIdentity).where(
180 MusehubIdentity.handle == handle,
181 MusehubIdentity.deleted_at.is_(None),
182 )
183 )
184 ).scalar_one_or_none()
185
186 if identity is None:
187 logger.warning("MSign: unknown handle '%s'", handle)
188 raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Unknown identity.")
189
190 # Reject expired identities — checked before key verification so
191 # a rotated key on an expired identity cannot be used to re-authenticate.
192 if identity.expires_at is not None:
193 now = datetime.datetime.now(datetime.timezone.utc)
194 if identity.expires_at <= now:
195 logger.warning(
196 "MSign: expired identity '%s' (expires_at=%s)",
197 handle,
198 identity.expires_at.isoformat(),
199 )
200 raise HTTPException(
201 status_code=status.HTTP_401_UNAUTHORIZED,
202 detail="Identity has expired.",
203 )
204
205 # Load all auth keys for this identity
206 key_rows = (
207 await db.execute(
208 select(MusehubAuthKey).where(MusehubAuthKey.identity_id == identity.identity_id)
209 )
210 ).scalars().all()
211
212 if not key_rows:
213 raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="No registered keys for identity.")
214
215 _ct = request.headers.get("content-type", "")
216 if _ct.startswith("application/x-muse-mpack"):
217 body_bytes = b""
218 else:
219 body_bytes = await request.body()
220 path_with_query = request.url.path
221 if request.url.query:
222 path_with_query = f"{path_with_query}?{request.url.query}"
223 host = _normalise_host(request)
224 canonical = build_canonical_message(
225 request.method, path_with_query, ts, body_bytes,
226 host=host, algorithm=alg,
227 )
228
229 # Try to verify against any key
230 try:
231 sig_bytes = b64url_decode(sig_b64)
232 except Exception:
233 raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid signature encoding.")
234
235 verified = False
236 for key_row in key_rows:
237 try:
238 algo = KeyAlgorithm(key_row.algorithm)
239 _, raw_key = decode_pubkey(key_row.public_key_b64)
240 verify_signature(
241 algorithm=algo,
242 public_key_bytes=raw_key,
243 message=canonical,
244 signature_bytes=sig_bytes,
245 )
246 verified = True
247 break
248 except (SignatureError, InvalidKeyError, ValueError):
249 continue
250
251 if not verified:
252 logger.warning("MSign: signature verification failed for handle='%s'", handle)
253 raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Signature verification failed.")
254
255 return MSignContext(
256 handle=identity.handle,
257 identity_id=identity.identity_id,
258 is_agent=(identity.identity_type == "agent"),
259 is_admin=identity.is_admin,
260 scope=identity.scope,
261 )
262
263 # ---------------------------------------------------------------------------
264 # FastAPI dependencies
265 # ---------------------------------------------------------------------------
266
267 async def require_signed_request(
268 request: Request,
269 db: AsyncSession = Depends(get_db),
270 ) -> MSignContext:
271 """Require a valid MSign Authorization header. Raises 401 if missing or invalid."""
272 result = await _verify_msign(request, db, required=True)
273 assert result is not None
274 return result
275
276 async def optional_signed_request(
277 request: Request,
278 db: AsyncSession = Depends(get_db),
279 ) -> MSignContext | None:
280 """Accept an optional MSign header. Returns None for anonymous requests."""
281 return await _verify_msign(request, db, required=False)
282
283 def require_scope(scope_value: str) -> Callable[..., Awaitable[MSignContext]]:
284 """Dependency factory: require a valid MSign header AND the specified scope.
285
286 Identities with ``scope = None`` (humans) pass unconditionally.
287 Identities with a scope list (agents) are rejected with 403 unless
288 ``scope_value`` appears in their scope list.
289
290 Usage::
291
292 @router.post("/issues")
293 async def create_issue(
294 token: TokenClaims = Depends(require_scope("issue:write")),
295 ) -> IssueResponse:
296 ...
297 """
298 async def _dependency(
299 claims: MSignContext = Depends(require_signed_request),
300 ) -> MSignContext:
301 if claims.scope is not None and scope_value not in claims.scope:
302 raise HTTPException(
303 status_code=status.HTTP_403_FORBIDDEN,
304 detail=f"Scope '{scope_value}' required.",
305 )
306 return claims
307
308 return _dependency
File History 1 commit
sha256:c748a46461e554a9560edea4edf4c3dffd2a7f81a1948ebdba60f5ec940c6e25 fix: use raw ASGI path for MSign canonical message Sonnet 4.6 patch 43 days ago