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