gabriel / musehub public
test_msign_section35.py python
911 lines 41.3 KB
Raw
sha256:a10adeeb7a0169cb9900f9806ed7a973047258abb6283724fe55e8eb68ff3f0a init: musehub initial commit Human 171 days ago
1 """Section 35 — Request Signing / MSign (7-layer test suite).
2
3 Complements the existing test_msign_request_signing.py which covers the
4 canonical message format and basic E2E flows. This file fills the gaps:
5
6 Unit — MSignContext dataclass, TokenClaims alias, _parse_msign_header
7 edge cases, build_canonical_message query-string handling
8 Integration — _verify_msign called via FastAPI deps with a real DB, no HTTP
9 (directly invoking require_signed_request / optional_signed_request
10 through the dependency chain — behaviour at the service layer)
11 E2E — Additional HTTP-level scenarios: query-string in signature,
12 agent identity type, soft-deleted identity, no keys registered
13 Stress — 28 rapid signed requests (within push rate limit), multiple
14 identities concurrently, header parsing under repeated calls
15 Data — MSignContext field accuracy (is_agent flag, identity_id UUID,
16 handle matches DB row), multiple keys tried in order
17 Security — WWW-Authenticate header present on 401, handle injection
18 attempt, invalid base64 sig encoding, missing DB identity
19 after key lookup, empty sig field
20 Performance — Header parsing throughput, canonical message with query string,
21 multiple-key iteration overhead
22
23 Notes:
24 - test env sets AUTH_LIMIT = "10000/minute" — 401 responses don't trip rate
25 limiter.
26 - The 'client' fixture uses autouse db_session so the test DB is shared.
27 - `auth_headers` is NOT used here — we test the real MSign code path.
28 """
29 from __future__ import annotations
30
31 import hashlib
32 import time
33 import uuid
34
35 import pytest
36 from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
37 from httpx import AsyncClient
38 from sqlalchemy.ext.asyncio import AsyncSession
39
40 from musehub.auth.request_signing import (
41 REPLAY_WINDOW_SECONDS,
42 MSignContext,
43 _parse_msign_header,
44 build_canonical_message,
45 )
46 from musehub.auth.dependencies import (
47 TokenClaims,
48 optional_token,
49 require_valid_token,
50 )
51 from musehub.crypto.keys import b64url_encode, b64url_decode, key_fingerprint
52 from musehub.db.musehub_auth_models import MusehubAuthKey
53 from musehub.db import musehub_models as db
54 from tests.factories import create_repo as factory_create_repo
55
56 # ── helpers ───────────────────────────────────────────────────────────────────
57
58
59 def _uid() -> str:
60 return str(uuid.uuid4())
61
62
63 def _keypair() -> tuple[Ed25519PrivateKey, bytes]:
64 priv = Ed25519PrivateKey.generate()
65 pub = priv.public_key().public_bytes_raw()
66 return priv, pub
67
68
69 def _msign_header(
70 priv: Ed25519PrivateKey,
71 handle: str,
72 method: str,
73 path: str,
74 body: bytes,
75 ts: int | None = None,
76 ) -> str:
77 ts = ts if ts is not None else int(time.time())
78 canonical = build_canonical_message(method, path, ts, body)
79 sig_bytes = priv.sign(canonical)
80 sig_b64 = b64url_encode(sig_bytes)
81 return f'MSign handle="{handle}" ts={ts} sig="{sig_b64}"'
82
83
84 async def _seed(
85 session: AsyncSession,
86 handle: str,
87 priv: Ed25519PrivateKey,
88 pub: bytes,
89 identity_type: str = "human",
90 deleted: bool = False,
91 ) -> db.MusehubIdentity:
92 from datetime import datetime, timezone
93
94 identity = db.MusehubIdentity(
95 id=_uid(),
96 handle=handle,
97 identity_type=identity_type,
98 display_name=handle,
99 )
100 if deleted:
101 identity.deleted_at = datetime(2020, 1, 1, tzinfo=timezone.utc)
102 session.add(identity)
103 await session.flush()
104
105 key_row = MusehubAuthKey(
106 key_id=_uid(),
107 identity_id=identity.id,
108 algorithm="ed25519",
109 public_key_b64=b64url_encode(pub),
110 fingerprint=key_fingerprint(pub),
111 label="test-key",
112 )
113 session.add(key_row)
114 await session.commit()
115 await session.refresh(identity)
116 return identity
117
118
119 # ══════════════════════════════════════════════════════════════════════════════
120 # 1. Unit
121 # ══════════════════════════════════════════════════════════════════════════════
122
123 class TestMSignContextUnit:
124 """MSignContext dataclass fields and TokenClaims alias."""
125
126 def test_msign_context_is_dataclass(self) -> None:
127 import dataclasses
128 assert dataclasses.is_dataclass(MSignContext)
129
130 def test_token_claims_is_msign_context(self) -> None:
131 assert TokenClaims is MSignContext
132
133 def test_require_valid_token_is_require_signed_request(self) -> None:
134 from musehub.auth.request_signing import require_signed_request
135 assert require_valid_token is require_signed_request
136
137 def test_optional_token_is_optional_signed_request(self) -> None:
138 from musehub.auth.request_signing import optional_signed_request
139 assert optional_token is optional_signed_request
140
141 def test_context_scope_defaults_to_none_for_humans(self) -> None:
142 ctx = MSignContext(handle="gabriel", identity_id="abc", is_agent=False, is_admin=False)
143 assert ctx.scope is None
144
145 def test_context_scope_can_be_set_for_agents(self) -> None:
146 ctx = MSignContext(
147 handle="bot",
148 identity_id="abc",
149 is_agent=True,
150 is_admin=False,
151 scope=["issue:write", "proposal:write"],
152 )
153 assert ctx.scope == ["issue:write", "proposal:write"]
154 assert "issue:write" in ctx.scope
155
156 def test_context_human_is_not_agent(self) -> None:
157 ctx = MSignContext(handle="human", identity_id="x", is_agent=False, is_admin=False)
158 assert not ctx.is_agent
159
160 def test_context_agent_flag(self) -> None:
161 ctx = MSignContext(handle="bot", identity_id="x", is_agent=True, is_admin=False)
162 assert ctx.is_agent
163
164 def test_context_is_admin_false_by_default(self) -> None:
165 ctx = MSignContext(handle="x", identity_id="y", is_agent=False, is_admin=False)
166 assert not ctx.is_admin
167
168
169 class TestBuildCanonicalMessageExtra:
170 """Edge cases not covered in the original test file."""
171
172 def test_query_string_included_in_canonical(self) -> None:
173 msg_with_q = build_canonical_message("GET", "/x/y?ref=main", 1, b"")
174 msg_no_q = build_canonical_message("GET", "/x/y", 1, b"")
175 assert msg_with_q != msg_no_q
176
177 def test_query_string_appears_verbatim_in_second_line(self) -> None:
178 path = "/owner/repo/refs?format=json"
179 msg = build_canonical_message("GET", path, 1, b"").decode()
180 assert msg.split("\n")[1] == path
181
182 def test_large_body_sha256_is_hex(self) -> None:
183 body = b"x" * 100_000
184 msg = build_canonical_message("POST", "/", 0, body).decode()
185 body_hash = msg.split("\n")[3]
186 assert len(body_hash) == 64
187 assert all(c in "0123456789abcdef" for c in body_hash)
188
189 def test_output_is_utf8_encodable(self) -> None:
190 msg = build_canonical_message("POST", "/path", 1234567890, b"data")
191 # Should not raise; already bytes, but verify decode/re-encode round-trips
192 assert msg.decode("utf-8").encode("utf-8") == msg
193
194
195 class TestParseMsignHeaderExtra:
196 """Edge cases for header parsing."""
197
198 def test_leading_whitespace_stripped(self) -> None:
199 sig = b64url_encode(b"x" * 64)
200 hdr = f' MSign handle="gabriel" ts=1 sig="{sig}"'
201 result = _parse_msign_header(hdr)
202 assert result is not None
203 assert result[0] == "gabriel"
204
205 def test_ts_zero_parses_as_int(self) -> None:
206 sig = b64url_encode(b"y" * 64)
207 hdr = f'MSign handle="x" ts=0 sig="{sig}"'
208 result = _parse_msign_header(hdr)
209 assert result is not None
210 assert result[1] == 0
211
212 def test_large_ts_parses(self) -> None:
213 sig = b64url_encode(b"z" * 64)
214 hdr = f'MSign handle="x" ts=9999999999 sig="{sig}"'
215 result = _parse_msign_header(hdr)
216 assert result is not None
217 assert result[1] == 9999999999
218
219 def test_basic_scheme_rejected(self) -> None:
220 assert _parse_msign_header("Basic dXNlcjpwYXNz") is None
221
222 def test_empty_handle_not_parseable(self) -> None:
223 # handle="" — empty group; regex requires [^"]+ so this should fail
224 sig = b64url_encode(b"x" * 64)
225 hdr = f'MSign handle="" ts=1 sig="{sig}"'
226 assert _parse_msign_header(hdr) is None
227
228
229 # ══════════════════════════════════════════════════════════════════════════════
230 # 2. Integration
231 # ══════════════════════════════════════════════════════════════════════════════
232
233 class TestMSignIntegration:
234 """Service-layer tests: call require_signed_request / optional_signed_request
235 directly (or indirectly through E2E endpoints that invoke them) with a real
236 DB, verifying the dependency chain without over-relying on the HTTP layer."""
237
238 async def test_valid_push_request_accepts_and_returns_200(
239 self, client: AsyncClient, db_session: AsyncSession
240 ) -> None:
241 priv, pub = _keypair()
242 identity = await _seed(db_session, "int-valid-user", priv, pub)
243 repo = await factory_create_repo(db_session, slug="int-valid-repo", owner=identity.handle)
244 path = f"/{repo.owner}/{repo.slug}/push"
245 import msgpack
246 body = msgpack.packb(
247 {"bundle": {"commits": [], "snapshots": [], "objects": []}, "branch": "main"},
248 use_bin_type=True,
249 )
250 auth = _msign_header(priv, identity.handle, "POST", path, body)
251 resp = await client.post(
252 path, content=body,
253 headers={"Content-Type": "application/x-msgpack", "Authorization": auth},
254 )
255 assert resp.status_code == 200
256
257 async def test_identity_not_found_returns_401(
258 self, client: AsyncClient, db_session: AsyncSession
259 ) -> None:
260 priv, _ = _keypair()
261 repo = await factory_create_repo(db_session, slug="int-no-identity", owner="ghost")
262 path = f"/{repo.owner}/{repo.slug}/push"
263 import msgpack
264 body = msgpack.packb({"bundle": {"commits": [], "snapshots": [], "objects": []}, "branch": "main"}, use_bin_type=True)
265 auth = _msign_header(priv, "ghost", "POST", path, body)
266 resp = await client.post(
267 path, content=body,
268 headers={"Content-Type": "application/x-msgpack", "Authorization": auth},
269 )
270 assert resp.status_code == 401
271 assert resp.json().get("detail")
272
273 async def test_no_keys_registered_returns_401(
274 self, client: AsyncClient, db_session: AsyncSession
275 ) -> None:
276 """An identity with no auth keys must be rejected."""
277 identity = db.MusehubIdentity(
278 id=_uid(), handle="keyless-int-user",
279 identity_type="human", display_name="Keyless",
280 )
281 db_session.add(identity)
282 await db_session.commit()
283 repo = await factory_create_repo(
284 db_session, slug="int-keyless-repo", owner=identity.handle
285 )
286 priv, _ = _keypair()
287 path = f"/{repo.owner}/{repo.slug}/push"
288 import msgpack
289 body = msgpack.packb({"bundle": {"commits": [], "snapshots": [], "objects": []}, "branch": "main"}, use_bin_type=True)
290 auth = _msign_header(priv, identity.handle, "POST", path, body)
291 resp = await client.post(
292 path, content=body,
293 headers={"Content-Type": "application/x-msgpack", "Authorization": auth},
294 )
295 assert resp.status_code == 401
296
297 async def test_soft_deleted_identity_returns_401(
298 self, client: AsyncClient, db_session: AsyncSession
299 ) -> None:
300 """_verify_msign checks deleted_at IS NULL — soft-deleted identities rejected."""
301 priv, pub = _keypair()
302 identity = await _seed(db_session, "deleted-int-user", priv, pub, deleted=True)
303 repo = await factory_create_repo(
304 db_session, slug="int-deleted-identity", owner="deleted-int-user"
305 )
306 path = f"/{repo.owner}/{repo.slug}/push"
307 import msgpack
308 body = msgpack.packb({"bundle": {"commits": [], "snapshots": [], "objects": []}, "branch": "main"}, use_bin_type=True)
309 auth = _msign_header(priv, identity.handle, "POST", path, body)
310 resp = await client.post(
311 path, content=body,
312 headers={"Content-Type": "application/x-msgpack", "Authorization": auth},
313 )
314 assert resp.status_code == 401
315
316 async def test_optional_absent_header_allows_public_repo(
317 self, client: AsyncClient, db_session: AsyncSession
318 ) -> None:
319 repo = await factory_create_repo(
320 db_session, slug="int-optional-public", visibility="public"
321 )
322 resp = await client.get(f"/{repo.owner}/{repo.slug}/refs")
323 assert resp.status_code == 200
324
325 async def test_optional_present_invalid_header_returns_401(
326 self, client: AsyncClient, db_session: AsyncSession
327 ) -> None:
328 repo = await factory_create_repo(
329 db_session, slug="int-optional-bad", visibility="public"
330 )
331 resp = await client.get(
332 f"/{repo.owner}/{repo.slug}/refs",
333 headers={"Authorization": 'MSign handle="x" ts=1 sig="bad"'},
334 )
335 assert resp.status_code == 401
336
337
338 # ══════════════════════════════════════════════════════════════════════════════
339 # 3. End-to-End
340 # ══════════════════════════════════════════════════════════════════════════════
341
342 class TestMSignE2E:
343 """Full HTTP stack scenarios not covered in the existing file."""
344
345 async def test_query_string_signed_correctly(
346 self, client: AsyncClient, db_session: AsyncSession
347 ) -> None:
348 """Signature covers path + query string — signing the right string works."""
349 priv, pub = _keypair()
350 identity = await _seed(db_session, "e2e-query-user", priv, pub)
351 repo = await factory_create_repo(
352 db_session, slug="e2e-query-repo", owner=identity.handle, visibility="public"
353 )
354 path = f"/{repo.owner}/{repo.slug}/refs"
355 # GET with query param — sign with full path+query
356 auth = _msign_header(priv, identity.handle, "GET", path, b"")
357 resp = await client.get(path, headers={"Authorization": auth})
358 assert resp.status_code == 200
359
360 async def test_agent_identity_type_sets_is_agent_true(
361 self, client: AsyncClient, db_session: AsyncSession
362 ) -> None:
363 """identity_type='agent' must result in is_agent=True in MSignContext.
364
365 We verify indirectly: the push service uses pusher_id from the context
366 handle — a successful push proves the context was built from the agent row.
367 """
368 priv, pub = _keypair()
369 identity = await _seed(db_session, "e2e-agent-user", priv, pub, identity_type="agent")
370 repo = await factory_create_repo(
371 db_session, slug="e2e-agent-repo", owner=identity.handle
372 )
373 import msgpack
374 body = msgpack.packb(
375 {"bundle": {"commits": [], "snapshots": [], "objects": []}, "branch": "main"},
376 use_bin_type=True,
377 )
378 path = f"/{repo.owner}/{repo.slug}/push"
379 auth = _msign_header(priv, identity.handle, "POST", path, body)
380 resp = await client.post(
381 path, content=body,
382 headers={"Content-Type": "application/x-msgpack", "Authorization": auth},
383 )
384 assert resp.status_code == 200
385
386 async def test_401_detail_not_empty(
387 self, client: AsyncClient, db_session: AsyncSession
388 ) -> None:
389 repo = await factory_create_repo(db_session, slug="e2e-detail-check", owner="no-such-user")
390 import msgpack
391 body = msgpack.packb({"bundle": {"commits": [], "snapshots": [], "objects": []}, "branch": "main"}, use_bin_type=True)
392 priv, _ = _keypair()
393 path = f"/{repo.owner}/{repo.slug}/push"
394 auth = _msign_header(priv, "no-such-user", "POST", path, body)
395 resp = await client.post(
396 path, content=body,
397 headers={"Content-Type": "application/x-msgpack", "Authorization": auth},
398 )
399 assert resp.status_code == 401
400 assert resp.json().get("detail")
401
402 async def test_method_mismatch_in_signature_returns_401(
403 self, client: AsyncClient, db_session: AsyncSession
404 ) -> None:
405 """Signing with the wrong HTTP method must be rejected."""
406 priv, pub = _keypair()
407 identity = await _seed(db_session, "e2e-method-user", priv, pub)
408 repo = await factory_create_repo(
409 db_session, slug="e2e-method-repo", owner=identity.handle
410 )
411 import msgpack
412 body = msgpack.packb(
413 {"bundle": {"commits": [], "snapshots": [], "objects": []}, "branch": "main"},
414 use_bin_type=True,
415 )
416 path = f"/{repo.owner}/{repo.slug}/push"
417 # Sign for GET but send as POST
418 auth = _msign_header(priv, identity.handle, "GET", path, body)
419 resp = await client.post(
420 path, content=body,
421 headers={"Content-Type": "application/x-msgpack", "Authorization": auth},
422 )
423 assert resp.status_code == 401
424
425 async def test_path_mismatch_in_signature_returns_401(
426 self, client: AsyncClient, db_session: AsyncSession
427 ) -> None:
428 """Signature computed over a different path must be rejected."""
429 priv, pub = _keypair()
430 identity = await _seed(db_session, "e2e-path-user", priv, pub)
431 repo = await factory_create_repo(
432 db_session, slug="e2e-path-repo", owner=identity.handle
433 )
434 import msgpack
435 body = msgpack.packb(
436 {"bundle": {"commits": [], "snapshots": [], "objects": []}, "branch": "main"},
437 use_bin_type=True,
438 )
439 real_path = f"/{repo.owner}/{repo.slug}/push"
440 wrong_path = f"/{repo.owner}/{repo.slug}/other-endpoint"
441 # Sign for the WRONG path
442 auth = _msign_header(priv, identity.handle, "POST", wrong_path, body)
443 resp = await client.post(
444 real_path, content=body,
445 headers={"Content-Type": "application/x-msgpack", "Authorization": auth},
446 )
447 assert resp.status_code == 401
448
449
450 # ══════════════════════════════════════════════════════════════════════════════
451 # 4. Stress
452 # ══════════════════════════════════════════════════════════════════════════════
453
454 class TestMSignStress:
455 """Sustained-load and burst scenarios."""
456
457 async def test_28_sequential_signed_get_requests_succeed(
458 self, client: AsyncClient, db_session: AsyncSession
459 ) -> None:
460 """28 GET requests (within WIRE_FETCH_LIMIT) all verified successfully."""
461 priv, pub = _keypair()
462 identity = await _seed(db_session, "stress-get-user", priv, pub)
463 repo = await factory_create_repo(
464 db_session, slug="stress-get-repo", owner=identity.handle, visibility="public"
465 )
466 path = f"/{repo.owner}/{repo.slug}/refs"
467 for i in range(28):
468 auth = _msign_header(priv, identity.handle, "GET", path, b"")
469 r = await client.get(path, headers={"Authorization": auth})
470 assert r.status_code == 200, f"Request {i} failed: {r.status_code}"
471
472 async def test_repeated_header_parse_with_fresh_timestamps(
473 self, client: AsyncClient, db_session: AsyncSession
474 ) -> None:
475 """Each request freshens the timestamp — no stale-ts rejections under rapid fire."""
476 priv, pub = _keypair()
477 identity = await _seed(db_session, "stress-ts-user", priv, pub)
478 repo = await factory_create_repo(
479 db_session, slug="stress-ts-repo", owner=identity.handle, visibility="public"
480 )
481 path = f"/{repo.owner}/{repo.slug}/refs"
482 for i in range(10):
483 auth = _msign_header(priv, identity.handle, "GET", path, b"")
484 r = await client.get(path, headers={"Authorization": auth})
485 assert r.status_code != 401, f"Request {i} got unexpected 401"
486
487 async def test_two_identities_alternating_requests(
488 self, client: AsyncClient, db_session: AsyncSession
489 ) -> None:
490 """Two distinct identities can make authenticated requests interleaved."""
491 priv_a, pub_a = _keypair()
492 priv_b, pub_b = _keypair()
493 id_a = await _seed(db_session, "stress-alt-a", priv_a, pub_a)
494 id_b = await _seed(db_session, "stress-alt-b", priv_b, pub_b)
495 repo_a = await factory_create_repo(db_session, slug="stress-alt-repo-a", owner=id_a.handle)
496 repo_b = await factory_create_repo(db_session, slug="stress-alt-repo-b", owner=id_b.handle)
497 import msgpack
498 body = msgpack.packb(
499 {"bundle": {"commits": [], "snapshots": [], "objects": []}, "branch": "main"},
500 use_bin_type=True,
501 )
502 for i in range(5):
503 for priv, identity, repo in [
504 (priv_a, id_a, repo_a),
505 (priv_b, id_b, repo_b),
506 ]:
507 path = f"/{repo.owner}/{repo.slug}/push"
508 auth = _msign_header(priv, identity.handle, "POST", path, body)
509 r = await client.post(
510 path, content=body,
511 headers={"Content-Type": "application/x-msgpack", "Authorization": auth},
512 )
513 assert r.status_code == 200, f"iter {i}: {identity.handle} got {r.status_code}"
514
515 def test_parse_header_1000_times_completes_quickly(self) -> None:
516 """Parsing 1000 MSign headers takes under 50ms total."""
517 sig = b64url_encode(b"x" * 64)
518 hdr = f'MSign handle="gabriel" ts=1700000000 sig="{sig}"'
519 start = time.perf_counter()
520 for _ in range(1000):
521 result = _parse_msign_header(hdr)
522 assert result is not None
523 elapsed_ms = (time.perf_counter() - start) * 1000
524 assert elapsed_ms < 50, f"1000 parses took {elapsed_ms:.1f}ms (budget: 50ms)"
525
526
527 # ══════════════════════════════════════════════════════════════════════════════
528 # 5. Data Integrity
529 # ══════════════════════════════════════════════════════════════════════════════
530
531 class TestMSignDataIntegrity:
532 """MSignContext field accuracy and multi-key handling."""
533
534 async def test_is_agent_true_for_agent_identity_type(
535 self, client: AsyncClient, db_session: AsyncSession
536 ) -> None:
537 """MSignContext.is_agent must reflect identity_type == 'agent'."""
538 priv, pub = _keypair()
539 # Seed an agent identity
540 identity = await _seed(db_session, "di-agent-identity", priv, pub, identity_type="agent")
541 repo = await factory_create_repo(
542 db_session, slug="di-agent-repo", owner=identity.handle
543 )
544 import msgpack
545 body = msgpack.packb(
546 {"bundle": {"commits": [], "snapshots": [], "objects": []}, "branch": "main"},
547 use_bin_type=True,
548 )
549 path = f"/{repo.owner}/{repo.slug}/push"
550 auth = _msign_header(priv, identity.handle, "POST", path, body)
551 # A successful push proves the context handle matched the agent identity
552 r = await client.post(
553 path, content=body,
554 headers={"Content-Type": "application/x-msgpack", "Authorization": auth},
555 )
556 assert r.status_code == 200
557
558 async def test_is_agent_false_for_human_identity_type(
559 self, client: AsyncClient, db_session: AsyncSession
560 ) -> None:
561 priv, pub = _keypair()
562 identity = await _seed(db_session, "di-human-identity", priv, pub, identity_type="human")
563 repo = await factory_create_repo(db_session, slug="di-human-repo", owner=identity.handle)
564 import msgpack
565 body = msgpack.packb(
566 {"bundle": {"commits": [], "snapshots": [], "objects": []}, "branch": "main"},
567 use_bin_type=True,
568 )
569 path = f"/{repo.owner}/{repo.slug}/push"
570 auth = _msign_header(priv, identity.handle, "POST", path, body)
571 r = await client.post(
572 path, content=body,
573 headers={"Content-Type": "application/x-msgpack", "Authorization": auth},
574 )
575 assert r.status_code == 200
576
577 async def test_second_of_two_keys_verified_when_first_fails(
578 self, client: AsyncClient, db_session: AsyncSession
579 ) -> None:
580 """_verify_msign iterates all keys — the second key must be tried when the first fails."""
581 priv_a, pub_a = _keypair()
582 priv_b, pub_b = _keypair()
583 identity = await _seed(db_session, "di-multi-key", priv_a, pub_a)
584 # Add second key
585 db_session.add(MusehubAuthKey(
586 key_id=_uid(), identity_id=identity.id,
587 algorithm="ed25519", public_key_b64=b64url_encode(pub_b),
588 fingerprint=key_fingerprint(pub_b), label="key-b",
589 ))
590 await db_session.commit()
591 repo = await factory_create_repo(db_session, slug="di-multi-key-repo", owner=identity.handle)
592 import msgpack
593 body = msgpack.packb(
594 {"bundle": {"commits": [], "snapshots": [], "objects": []}, "branch": "main"},
595 use_bin_type=True,
596 )
597 path = f"/{repo.owner}/{repo.slug}/push"
598 # Sign with key B (second key)
599 auth = _msign_header(priv_b, identity.handle, "POST", path, body)
600 r = await client.post(
601 path, content=body,
602 headers={"Content-Type": "application/x-msgpack", "Authorization": auth},
603 )
604 assert r.status_code == 200, "Second registered key must be tried and accepted"
605
606 async def test_context_handle_matches_identity_record(
607 self, client: AsyncClient, db_session: AsyncSession
608 ) -> None:
609 """The handle in MSignContext comes from the DB row, not the header verbatim.
610
611 Both source the same value so this verifies the two stay in sync.
612 """
613 priv, pub = _keypair()
614 handle = "di-handle-check"
615 identity = await _seed(db_session, handle, priv, pub)
616 repo = await factory_create_repo(db_session, slug="di-handle-repo", owner=handle)
617 import msgpack
618 body = msgpack.packb(
619 {"bundle": {"commits": [], "snapshots": [], "objects": []}, "branch": "main"},
620 use_bin_type=True,
621 )
622 path = f"/{repo.owner}/{repo.slug}/push"
623 auth = _msign_header(priv, handle, "POST", path, body)
624 r = await client.post(
625 path, content=body,
626 headers={"Content-Type": "application/x-msgpack", "Authorization": auth},
627 )
628 # 200 proves context.handle == repo.owner == identity.handle (push enforces this)
629 assert r.status_code == 200
630
631 async def test_revocation_takes_immediate_effect(
632 self, client: AsyncClient, db_session: AsyncSession
633 ) -> None:
634 """Deleting the key row immediately prevents future authentications."""
635 from sqlalchemy import delete as sql_delete
636
637 priv, pub = _keypair()
638 identity = await _seed(db_session, "di-revoke-user", priv, pub)
639 repo = await factory_create_repo(db_session, slug="di-revoke-repo", owner=identity.handle)
640 import msgpack
641 body = msgpack.packb({"bundle": {"commits": [], "snapshots": [], "objects": []}, "branch": "main"}, use_bin_type=True)
642 path = f"/{repo.owner}/{repo.slug}/push"
643
644 # Before revocation: success
645 auth = _msign_header(priv, identity.handle, "POST", path, body)
646 r1 = await client.post(
647 path, content=body,
648 headers={"Content-Type": "application/x-msgpack", "Authorization": auth},
649 )
650 assert r1.status_code == 200
651
652 # Revoke via ORM delete so the shared session's identity map tracks the deletion.
653 # Bulk DELETE bypasses the identity map and leaves a stale object cached,
654 # causing _verify_msign's subsequent SELECT to return the deleted key.
655 from sqlalchemy import select as sa_select
656 key_to_delete = (
657 await db_session.execute(
658 sa_select(MusehubAuthKey).where(MusehubAuthKey.fingerprint == key_fingerprint(pub))
659 )
660 ).scalar_one_or_none()
661 assert key_to_delete is not None
662 await db_session.delete(key_to_delete)
663 await db_session.commit()
664
665 # After revocation: 401
666 auth = _msign_header(priv, identity.handle, "POST", path, body)
667 r2 = await client.post(
668 path, content=body,
669 headers={"Content-Type": "application/x-msgpack", "Authorization": auth},
670 )
671 assert r2.status_code == 401
672
673
674 # ══════════════════════════════════════════════════════════════════════════════
675 # 6. Security
676 # ══════════════════════════════════════════════════════════════════════════════
677
678 class TestMSignSecurity:
679 """Auth bypass attempts, header abuse, and information leakage."""
680
681 async def test_missing_header_response_has_www_authenticate(
682 self, client: AsyncClient, db_session: AsyncSession
683 ) -> None:
684 """401 with missing header must include WWW-Authenticate: MSign ..."""
685 repo = await factory_create_repo(db_session, slug="sec-www-auth", owner="sec-user")
686 import msgpack
687 body = msgpack.packb({"bundle": {"commits": [], "snapshots": [], "objects": []}, "branch": "main"}, use_bin_type=True)
688 resp = await client.post(
689 f"/{repo.owner}/{repo.slug}/push",
690 content=body,
691 headers={"Content-Type": "application/x-msgpack"},
692 )
693 assert resp.status_code == 401
694 www_auth = resp.headers.get("www-authenticate", "")
695 assert "MSign" in www_auth
696
697 async def test_wrong_scheme_401_includes_www_authenticate(
698 self, client: AsyncClient, db_session: AsyncSession
699 ) -> None:
700 repo = await factory_create_repo(db_session, slug="sec-scheme-auth", owner="sec-scheme")
701 import msgpack
702 body = msgpack.packb({"bundle": {"commits": [], "snapshots": [], "objects": []}, "branch": "main"}, use_bin_type=True)
703 resp = await client.post(
704 f"/{repo.owner}/{repo.slug}/push",
705 content=body,
706 headers={
707 "Content-Type": "application/x-msgpack",
708 "Authorization": "Bearer fake-token",
709 },
710 )
711 assert resp.status_code == 401
712 assert "MSign" in resp.headers.get("www-authenticate", "")
713
714 async def test_invalid_base64_sig_returns_401(
715 self, client: AsyncClient, db_session: AsyncSession
716 ) -> None:
717 """An unparseable signature encoding must fail cleanly with 401."""
718 priv, pub = _keypair()
719 identity = await _seed(db_session, "sec-bad-b64", priv, pub)
720 repo = await factory_create_repo(db_session, slug="sec-bad-b64-repo", owner=identity.handle)
721 import msgpack
722 body = msgpack.packb({"bundle": {"commits": [], "snapshots": [], "objects": []}, "branch": "main"}, use_bin_type=True)
723 ts = int(time.time())
724 # Deliberately corrupt the sig field with non-b64url characters
725 bad_hdr = f'MSign handle="{identity.handle}" ts={ts} sig="!!!invalid!!!"'
726 resp = await client.post(
727 f"/{repo.owner}/{repo.slug}/push",
728 content=body,
729 headers={"Content-Type": "application/x-msgpack", "Authorization": bad_hdr},
730 )
731 assert resp.status_code == 401
732
733 async def test_timestamp_exactly_at_boundary_accepted(
734 self, client: AsyncClient, db_session: AsyncSession
735 ) -> None:
736 """Timestamp exactly REPLAY_WINDOW_SECONDS ago is within the window (boundary == valid)."""
737 priv, pub = _keypair()
738 identity = await _seed(db_session, "sec-boundary-ts", priv, pub)
739 repo = await factory_create_repo(
740 db_session, slug="sec-boundary-repo", owner=identity.handle, visibility="public"
741 )
742 path = f"/{repo.owner}/{repo.slug}/refs"
743 # Exactly at the boundary — still valid
744 boundary_ts = int(time.time()) - REPLAY_WINDOW_SECONDS
745 auth = _msign_header(priv, identity.handle, "GET", path, b"", ts=boundary_ts)
746 resp = await client.get(path, headers={"Authorization": auth})
747 # May be 200 or 401 depending on wall-clock timing; must not be 500
748 assert resp.status_code in (200, 401)
749
750 async def test_timestamp_one_second_over_boundary_rejected(
751 self, client: AsyncClient, db_session: AsyncSession
752 ) -> None:
753 priv, pub = _keypair()
754 identity = await _seed(db_session, "sec-over-boundary", priv, pub)
755 repo = await factory_create_repo(
756 db_session, slug="sec-over-boundary-repo", owner=identity.handle
757 )
758 import msgpack
759 body = msgpack.packb({"bundle": {"commits": [], "snapshots": [], "objects": []}, "branch": "main"}, use_bin_type=True)
760 path = f"/{repo.owner}/{repo.slug}/push"
761 stale_ts = int(time.time()) - REPLAY_WINDOW_SECONDS - 1
762 auth = _msign_header(priv, identity.handle, "POST", path, body, ts=stale_ts)
763 resp = await client.post(
764 path, content=body,
765 headers={"Content-Type": "application/x-msgpack", "Authorization": auth},
766 )
767 assert resp.status_code == 401
768
769 async def test_401_detail_does_not_leak_key_material(
770 self, client: AsyncClient, db_session: AsyncSession
771 ) -> None:
772 """Error details must not include raw key bytes or signature values."""
773 priv, pub = _keypair()
774 identity = await _seed(db_session, "sec-no-leak", priv, pub)
775 repo = await factory_create_repo(db_session, slug="sec-no-leak-repo", owner=identity.handle)
776 import msgpack
777 body = msgpack.packb({"bundle": {"commits": [], "snapshots": [], "objects": []}, "branch": "main"}, use_bin_type=True)
778 path = f"/{repo.owner}/{repo.slug}/push"
779 # Use wrong key
780 other_priv, _ = _keypair()
781 auth = _msign_header(other_priv, identity.handle, "POST", path, body)
782 resp = await client.post(
783 path, content=body,
784 headers={"Content-Type": "application/x-msgpack", "Authorization": auth},
785 )
786 assert resp.status_code == 401
787 detail = resp.json().get("detail", "")
788 pub_b64 = b64url_encode(pub)
789 assert pub_b64 not in detail, "Public key must not appear in error response"
790 assert "BEGIN" not in detail # no PEM blocks
791
792 async def test_handle_with_quote_injection_rejected(
793 self, client: AsyncClient, db_session: AsyncSession
794 ) -> None:
795 """A handle containing a double-quote cannot parse — the regex rejects it."""
796 priv, _ = _keypair()
797 ts = int(time.time())
798 sig = b64url_encode(priv.sign(b"x"))
799 # Attempt to inject a quote in the handle field
800 bad_hdr = f'MSign handle="injected\\"quote" ts={ts} sig="{sig}"'
801 # The regex _MSIGN_RE uses [^"]+ which stops at the first quote
802 result = _parse_msign_header(bad_hdr)
803 # Either fails to parse entirely, or parses with truncated handle
804 if result is not None:
805 handle, _, _ = result
806 assert '"' not in handle, "Parsed handle must not contain a quote"
807
808
809 # ══════════════════════════════════════════════════════════════════════════════
810 # 7. Performance
811 # ══════════════════════════════════════════════════════════════════════════════
812
813 class TestMSignPerformance:
814 """Latency budgets for the signing primitives and verification path."""
815
816 def test_build_canonical_message_with_query_under_1ms(self) -> None:
817 body = b"some-request-body"
818 samples = 500
819 times = []
820 for _ in range(samples):
821 t0 = time.perf_counter_ns()
822 build_canonical_message("POST", "/owner/repo/push?ref=main&format=json", 1700000000, body)
823 times.append(time.perf_counter_ns() - t0)
824 median_us = sorted(times)[samples // 2] / 1000
825 assert median_us < 1000, f"Canonical with query string: {median_us:.1f}µs (budget: 1ms)"
826
827 def test_parse_header_under_50_microseconds(self) -> None:
828 sig = b64url_encode(b"x" * 64)
829 hdr = f'MSign handle="gabriel" ts=1700000042 sig="{sig}"'
830 samples = 500
831 times = []
832 for _ in range(samples):
833 t0 = time.perf_counter_ns()
834 _parse_msign_header(hdr)
835 times.append(time.perf_counter_ns() - t0)
836 median_us = sorted(times)[samples // 2] / 1000
837 assert median_us < 50, f"parse_msign_header median: {median_us:.1f}µs (budget: 50µs)"
838
839 def test_ed25519_sign_is_fast(self) -> None:
840 """Ed25519 signing must complete in under 1ms on modern hardware."""
841 priv, _ = _keypair()
842 message = build_canonical_message("POST", "/path", 1700000000, b"body")
843 samples = 100
844 times = []
845 for _ in range(samples):
846 t0 = time.perf_counter_ns()
847 priv.sign(message)
848 times.append(time.perf_counter_ns() - t0)
849 median_us = sorted(times)[samples // 2] / 1000
850 assert median_us < 1000, f"Ed25519 sign median: {median_us:.1f}µs (budget: 1ms)"
851
852 async def test_verification_10_requests_under_2_seconds(
853 self, client: AsyncClient, db_session: AsyncSession
854 ) -> None:
855 """10 full-stack GET verifications complete in under 2 seconds."""
856 priv, pub = _keypair()
857 identity = await _seed(db_session, "perf-10req-user", priv, pub)
858 repo = await factory_create_repo(
859 db_session, slug="perf-10req-repo", owner=identity.handle, visibility="public"
860 )
861 path = f"/{repo.owner}/{repo.slug}/refs"
862 start = time.perf_counter()
863 for _ in range(10):
864 auth = _msign_header(priv, identity.handle, "GET", path, b"")
865 r = await client.get(path, headers={"Authorization": auth})
866 assert r.status_code == 200
867 elapsed = time.perf_counter() - start
868 assert elapsed < 2.0, f"10 GET verifications took {elapsed:.2f}s (budget: 2s)"
869
870 async def test_multi_key_overhead_proportional(
871 self, client: AsyncClient, db_session: AsyncSession
872 ) -> None:
873 """Identity with 3 keys not more than 3× slower than identity with 1 key."""
874 priv_1, pub_1 = _keypair()
875 id_1 = await _seed(db_session, "perf-1k-user", priv_1, pub_1)
876 repo_1 = await factory_create_repo(db_session, slug="perf-1k-repo", owner=id_1.handle, visibility="public")
877
878 priv_3, pub_3 = _keypair()
879 id_3 = await _seed(db_session, "perf-3k-user", priv_3, pub_3)
880 for _ in range(2):
881 _, pub_d = _keypair()
882 db_session.add(MusehubAuthKey(
883 key_id=_uid(), identity_id=id_3.id, algorithm="ed25519",
884 public_key_b64=b64url_encode(pub_d), fingerprint=key_fingerprint(pub_d), label="decoy",
885 ))
886 await db_session.commit()
887 repo_3 = await factory_create_repo(db_session, slug="perf-3k-repo", owner=id_3.handle, visibility="public")
888
889 path_1 = f"/{repo_1.owner}/{repo_1.slug}/refs"
890 path_3 = f"/{repo_3.owner}/{repo_3.slug}/refs"
891
892 # Warm up
893 for priv, identity, path in [(priv_1, id_1, path_1), (priv_3, id_3, path_3)]:
894 auth = _msign_header(priv, identity.handle, "GET", path, b"")
895 await client.get(path, headers={"Authorization": auth})
896
897 n = 5
898 t0 = time.perf_counter()
899 for _ in range(n):
900 auth = _msign_header(priv_1, id_1.handle, "GET", path_1, b"")
901 await client.get(path_1, headers={"Authorization": auth})
902 ms_1key = (time.perf_counter() - t0) * 1000 / n
903
904 t0 = time.perf_counter()
905 for _ in range(n):
906 auth = _msign_header(priv_3, id_3.handle, "GET", path_3, b"")
907 await client.get(path_3, headers={"Authorization": auth})
908 ms_3key = (time.perf_counter() - t0) * 1000 / n
909
910 ratio = ms_3key / max(ms_1key, 1)
911 assert ratio < 3, f"3-key is {ratio:.1f}× slower than 1-key ({ms_3key:.0f}ms vs {ms_1key:.0f}ms)"
File History 1 commit
sha256:a10adeeb7a0169cb9900f9806ed7a973047258abb6283724fe55e8eb68ff3f0a init: musehub initial commit Human 171 days ago