gabriel / musehub public
test_msign_request_signing.py python
829 lines 31.1 KB
Raw
sha256:a10adeeb7a0169cb9900f9806ed7a973047258abb6283724fe55e8eb68ff3f0a init: musehub initial commit Human 171 days ago
1 """Tests for the MSign per-request authentication layer.
2
3 MSign is the sole auth mechanism on every protected MuseHub endpoint.
4 Every authenticated request carries:
5
6 Authorization: MSign handle="{handle}" ts={unix_ts} sig="{b64url_sig}"
7
8 where sig is an Ed25519 signature over the canonical message:
9
10 "{METHOD}\n{path_with_query}\n{ts}\n{sha256_hex_of_body}"
11
12 Coverage:
13 Unit — build_canonical_message determinism and format; _parse_msign_header
14 Integration — require_signed_request / optional_signed_request FastAPI deps
15 E2E — real HTTP stack with real DB identity + key; happy and error paths
16 Security — missing header, wrong scheme, stale timestamp, future timestamp,
17 tampered body, wrong key, unknown handle, revoked key
18 Data — MSignContext fields match the registered identity
19 Stress — 50 sequential signed requests all succeed
20 """
21 from __future__ import annotations
22
23 import hashlib
24 import os
25 import time
26 import uuid
27
28 import msgpack
29 import pytest
30 from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
31 from httpx import AsyncClient
32 from sqlalchemy import select
33 from sqlalchemy.ext.asyncio import AsyncSession
34
35 from musehub.auth.request_signing import (
36 REPLAY_WINDOW_SECONDS,
37 build_canonical_message,
38 _parse_msign_header,
39 )
40 from musehub.muse_contracts.json_types import JSONObject
41 from musehub.crypto.keys import b64url_encode, b64url_decode, key_fingerprint
42 from musehub.db import musehub_models as db
43 from musehub.db.musehub_auth_models import MusehubAuthKey
44 from tests.factories import create_repo as factory_create_repo
45
46
47 # ── helpers ────────────────────────────────────────────────────────────────────
48
49
50 def _ed25519_keypair() -> tuple[Ed25519PrivateKey, bytes]:
51 priv = Ed25519PrivateKey.generate()
52 pub = priv.public_key().public_bytes_raw()
53 return priv, pub
54
55
56 def _msign_header(
57 priv: Ed25519PrivateKey,
58 handle: str,
59 method: str,
60 path: str,
61 body: bytes,
62 ts: int | None = None,
63 ) -> str:
64 """Build a valid ``Authorization: MSign …`` header for a test request."""
65 ts = ts if ts is not None else int(time.time())
66 canonical = build_canonical_message(method, path, ts, body)
67 sig_bytes = priv.sign(canonical)
68 sig_b64 = b64url_encode(sig_bytes)
69 return f'MSign handle="{handle}" ts={ts} sig="{sig_b64}"'
70
71
72 async def _seed_identity(
73 session: AsyncSession,
74 handle: str,
75 priv: Ed25519PrivateKey,
76 pub: bytes,
77 ) -> db.MusehubIdentity:
78 """Insert a MusehubIdentity + MusehubAuthKey row for use in MSign tests."""
79 identity = db.MusehubIdentity(
80 id=str(uuid.uuid4()),
81 handle=handle,
82 identity_type="human",
83 display_name=handle,
84 )
85 session.add(identity)
86 await session.flush()
87
88 key_row = MusehubAuthKey(
89 key_id=str(uuid.uuid4()),
90 identity_id=identity.id,
91 algorithm="ed25519",
92 public_key_b64=b64url_encode(pub),
93 fingerprint=key_fingerprint(pub),
94 label="test-key",
95 )
96 session.add(key_row)
97 await session.commit()
98 await session.refresh(identity)
99 return identity
100
101
102 def _mp(data: JSONObject) -> bytes:
103 return msgpack.packb(data, use_bin_type=True)
104
105
106 _EMPTY_PUSH = _mp({"bundle": {"commits": [], "snapshots": [], "objects": []}, "branch": "main"})
107
108
109 # ── unit: build_canonical_message ─────────────────────────────────────────────
110
111
112 class TestBuildCanonicalMessage:
113 def test_deterministic(self) -> None:
114 msg = build_canonical_message("POST", "/foo/bar", 1700000000, b"body")
115 assert msg == build_canonical_message("POST", "/foo/bar", 1700000000, b"body")
116
117 def test_format_is_four_lines(self) -> None:
118 msg = build_canonical_message("GET", "/foo", 1234, b"").decode()
119 parts = msg.split("\n")
120 assert len(parts) == 4
121
122 def test_first_line_is_method(self) -> None:
123 msg = build_canonical_message("DELETE", "/x", 1, b"").decode()
124 assert msg.startswith("DELETE\n")
125
126 def test_second_line_is_path(self) -> None:
127 msg = build_canonical_message("POST", "/owner/repo/push?ref=main", 1, b"").decode()
128 lines = msg.split("\n")
129 assert lines[1] == "/owner/repo/push?ref=main"
130
131 def test_third_line_is_timestamp(self) -> None:
132 ts = 1700000042
133 msg = build_canonical_message("GET", "/", ts, b"").decode()
134 assert msg.split("\n")[2] == str(ts)
135
136 def test_fourth_line_is_sha256_hex_of_body(self) -> None:
137 body = b"hello world"
138 expected = hashlib.sha256(body).hexdigest()
139 msg = build_canonical_message("POST", "/", 0, body).decode()
140 assert msg.split("\n")[3] == expected
141
142 def test_empty_body_produces_sha256_of_empty(self) -> None:
143 expected = hashlib.sha256(b"").hexdigest()
144 msg = build_canonical_message("GET", "/", 0, b"").decode()
145 assert msg.split("\n")[3] == expected
146
147 def test_different_method_produces_different_bytes(self) -> None:
148 assert build_canonical_message("GET", "/", 1, b"x") != build_canonical_message("POST", "/", 1, b"x")
149
150 def test_different_path_produces_different_bytes(self) -> None:
151 assert build_canonical_message("GET", "/a", 1, b"x") != build_canonical_message("GET", "/b", 1, b"x")
152
153 def test_different_ts_produces_different_bytes(self) -> None:
154 assert build_canonical_message("GET", "/", 1, b"x") != build_canonical_message("GET", "/", 2, b"x")
155
156 def test_different_body_produces_different_bytes(self) -> None:
157 assert build_canonical_message("GET", "/", 1, b"a") != build_canonical_message("GET", "/", 1, b"b")
158
159 def test_returns_bytes(self) -> None:
160 result = build_canonical_message("GET", "/", 0, b"")
161 assert isinstance(result, bytes)
162
163
164 # ── unit: _parse_msign_header ──────────────────────────────────────────────────
165
166
167 class TestParseMsignHeader:
168 def test_valid_header_parses(self) -> None:
169 sig = b64url_encode(os.urandom(64))
170 hdr = f'MSign handle="gabriel" ts=1700000000 sig="{sig}"'
171 result = _parse_msign_header(hdr)
172 assert result is not None
173 handle, ts, sig_out = result
174 assert handle == "gabriel"
175 assert ts == 1700000000
176 assert sig_out == sig
177
178 def test_returns_none_for_bearer(self) -> None:
179 assert _parse_msign_header("Bearer eyJhbGciOiJIUzI1NiJ9.x.y") is None
180
181 def test_returns_none_for_empty_string(self) -> None:
182 assert _parse_msign_header("") is None
183
184 def test_returns_none_for_missing_ts(self) -> None:
185 assert _parse_msign_header('MSign handle="gabriel" sig="abc"') is None
186
187 def test_returns_none_for_missing_sig(self) -> None:
188 assert _parse_msign_header('MSign handle="gabriel" ts=1234') is None
189
190 def test_returns_none_for_missing_handle(self) -> None:
191 assert _parse_msign_header('MSign ts=1234 sig="abc"') is None
192
193 def test_handle_with_hyphens_and_underscores(self) -> None:
194 sig = b64url_encode(os.urandom(64))
195 hdr = f'MSign handle="my-user_123" ts=1 sig="{sig}"'
196 result = _parse_msign_header(hdr)
197 assert result is not None
198 assert result[0] == "my-user_123"
199
200 def test_ts_is_int(self) -> None:
201 sig = b64url_encode(os.urandom(64))
202 hdr = f'MSign handle="x" ts=9999999999 sig="{sig}"'
203 result = _parse_msign_header(hdr)
204 assert result is not None
205 assert isinstance(result[1], int)
206
207
208 # ── E2E: require_signed_request (via push endpoint) ───────────────────────────
209
210
211 @pytest.mark.asyncio
212 async def test_missing_auth_header_returns_401(
213 client: AsyncClient,
214 db_session: AsyncSession,
215 ) -> None:
216 repo = await factory_create_repo(db_session, slug="msign-no-auth", owner="no-auth-user")
217 resp = await client.post(
218 f"/{repo.owner}/{repo.slug}/push",
219 content=_EMPTY_PUSH,
220 headers={"Content-Type": "application/x-msgpack"},
221 )
222 assert resp.status_code == 401
223
224
225 @pytest.mark.asyncio
226 async def test_bearer_scheme_returns_401(
227 client: AsyncClient,
228 db_session: AsyncSession,
229 ) -> None:
230 """Bearer tokens are rejected — MSign is the only accepted scheme."""
231 repo = await factory_create_repo(db_session, slug="msign-bearer-rejected", owner="bearer-user")
232 resp = await client.post(
233 f"/{repo.owner}/{repo.slug}/push",
234 content=_EMPTY_PUSH,
235 headers={
236 "Content-Type": "application/x-msgpack",
237 "Authorization": "Bearer eyJhbGciOiJIUzI1NiJ9.e30.abc123",
238 },
239 )
240 assert resp.status_code == 401
241
242
243 @pytest.mark.asyncio
244 async def test_malformed_msign_header_returns_401(
245 client: AsyncClient,
246 db_session: AsyncSession,
247 ) -> None:
248 repo = await factory_create_repo(db_session, slug="msign-malformed", owner="malformed-user")
249 for bad in [
250 "MSign",
251 "MSign junk",
252 'MSign handle="x"',
253 'MSign handle="x" ts=abc sig="def"',
254 ]:
255 resp = await client.post(
256 f"/{repo.owner}/{repo.slug}/push",
257 content=_EMPTY_PUSH,
258 headers={"Content-Type": "application/x-msgpack", "Authorization": bad},
259 )
260 assert resp.status_code == 401, f"Expected 401 for {bad!r}, got {resp.status_code}"
261
262
263 @pytest.mark.asyncio
264 async def test_stale_timestamp_returns_401(
265 client: AsyncClient,
266 db_session: AsyncSession,
267 ) -> None:
268 """Timestamp older than REPLAY_WINDOW_SECONDS must be rejected."""
269 priv, pub = _ed25519_keypair()
270 identity = await _seed_identity(db_session, "stale-ts-user", priv, pub)
271 repo = await factory_create_repo(db_session, slug="msign-stale-ts", owner=identity.handle)
272
273 stale_ts = int(time.time()) - REPLAY_WINDOW_SECONDS - 5
274 path = f"/{repo.owner}/{repo.slug}/push"
275 auth = _msign_header(priv, identity.handle, "POST", path, _EMPTY_PUSH, ts=stale_ts)
276 resp = await client.post(
277 path,
278 content=_EMPTY_PUSH,
279 headers={"Content-Type": "application/x-msgpack", "Authorization": auth},
280 )
281 assert resp.status_code == 401
282 assert "timestamp" in resp.json().get("detail", "").lower() or "skew" in resp.json().get("detail", "").lower()
283
284
285 @pytest.mark.asyncio
286 async def test_future_timestamp_returns_401(
287 client: AsyncClient,
288 db_session: AsyncSession,
289 ) -> None:
290 """Timestamp far in the future must also be rejected (replay prevention)."""
291 priv, pub = _ed25519_keypair()
292 identity = await _seed_identity(db_session, "future-ts-user", priv, pub)
293 repo = await factory_create_repo(db_session, slug="msign-future-ts", owner=identity.handle)
294
295 future_ts = int(time.time()) + REPLAY_WINDOW_SECONDS + 5
296 path = f"/{repo.owner}/{repo.slug}/push"
297 auth = _msign_header(priv, identity.handle, "POST", path, _EMPTY_PUSH, ts=future_ts)
298 resp = await client.post(
299 path,
300 content=_EMPTY_PUSH,
301 headers={"Content-Type": "application/x-msgpack", "Authorization": auth},
302 )
303 assert resp.status_code == 401
304
305
306 @pytest.mark.asyncio
307 async def test_tampered_body_returns_401(
308 client: AsyncClient,
309 db_session: AsyncSession,
310 ) -> None:
311 """Signature is over the original body hash — a different body must be rejected."""
312 priv, pub = _ed25519_keypair()
313 identity = await _seed_identity(db_session, "tampered-body-user", priv, pub)
314 repo = await factory_create_repo(db_session, slug="msign-tampered-body", owner=identity.handle)
315
316 path = f"/{repo.owner}/{repo.slug}/push"
317 original_body = _EMPTY_PUSH
318 # Sign for original_body but send different_body
319 auth = _msign_header(priv, identity.handle, "POST", path, original_body)
320 different_body = _mp({"bundle": {"commits": [], "snapshots": [], "objects": []}, "branch": "tampered"})
321
322 resp = await client.post(
323 path,
324 content=different_body,
325 headers={"Content-Type": "application/x-msgpack", "Authorization": auth},
326 )
327 assert resp.status_code == 401
328
329
330 @pytest.mark.asyncio
331 async def test_wrong_key_signature_returns_401(
332 client: AsyncClient,
333 db_session: AsyncSession,
334 ) -> None:
335 """Signature by a different (unregistered) key must be rejected."""
336 priv, pub = _ed25519_keypair()
337 identity = await _seed_identity(db_session, "wrong-key-user", priv, pub)
338 repo = await factory_create_repo(db_session, slug="msign-wrong-key", owner=identity.handle)
339
340 # Sign with a completely different private key
341 other_priv, _ = _ed25519_keypair()
342 path = f"/{repo.owner}/{repo.slug}/push"
343 auth = _msign_header(other_priv, identity.handle, "POST", path, _EMPTY_PUSH)
344
345 resp = await client.post(
346 path,
347 content=_EMPTY_PUSH,
348 headers={"Content-Type": "application/x-msgpack", "Authorization": auth},
349 )
350 assert resp.status_code == 401
351
352
353 @pytest.mark.asyncio
354 async def test_unknown_handle_returns_401(
355 client: AsyncClient,
356 db_session: AsyncSession,
357 ) -> None:
358 """A handle that has no identity record must be rejected."""
359 priv, _ = _ed25519_keypair()
360 repo = await factory_create_repo(db_session, slug="msign-unknown-handle", owner="ghost-user")
361
362 path = f"/{repo.owner}/{repo.slug}/push"
363 auth = _msign_header(priv, "ghost-user", "POST", path, _EMPTY_PUSH)
364 resp = await client.post(
365 path,
366 content=_EMPTY_PUSH,
367 headers={"Content-Type": "application/x-msgpack", "Authorization": auth},
368 )
369 assert resp.status_code == 401
370
371
372 @pytest.mark.asyncio
373 async def test_valid_msign_request_is_accepted(
374 client: AsyncClient,
375 db_session: AsyncSession,
376 ) -> None:
377 """A correctly signed request from a registered identity must succeed."""
378 priv, pub = _ed25519_keypair()
379 identity = await _seed_identity(db_session, "valid-msign-user", priv, pub)
380 repo = await factory_create_repo(
381 db_session, slug="msign-valid-push", owner=identity.handle
382 )
383
384 path = f"/{repo.owner}/{repo.slug}/push"
385 commit_id = uuid.uuid4().hex
386 body = _mp({
387 "bundle": {
388 "commits": [{"commit_id": commit_id, "branch": "main", "message": "test",
389 "committed_at": "2026-01-01T00:00:00+00:00",
390 "author": "Test <[email protected]>", "sem_ver_bump": "patch"}],
391 "snapshots": [],
392 "objects": [],
393 },
394 "branch": "main",
395 })
396 auth = _msign_header(priv, identity.handle, "POST", path, body)
397 resp = await client.post(
398 path,
399 content=body,
400 headers={
401 "Content-Type": "application/x-msgpack",
402 "Accept": "application/x-msgpack",
403 "Authorization": auth,
404 },
405 )
406 assert resp.status_code == 200, resp.text
407 data = msgpack.unpackb(resp.content, raw=False)
408 assert data["ok"] is True
409
410
411 @pytest.mark.asyncio
412 async def test_msign_context_contains_correct_identity(
413 client: AsyncClient,
414 db_session: AsyncSession,
415 ) -> None:
416 """The MSignContext injected by require_signed_request must match the seeded identity.
417
418 We verify indirectly: a successful push proves the context was built from the
419 registered identity (the push service compares pusher_id to repo.owner).
420 If the context had the wrong handle, the push would be rejected as unauthorized.
421 """
422 priv, pub = _ed25519_keypair()
423 identity = await _seed_identity(db_session, "ctx-identity-user", priv, pub)
424 repo = await factory_create_repo(
425 db_session, slug="msign-ctx-identity", owner=identity.handle
426 )
427
428 path = f"/{repo.owner}/{repo.slug}/push"
429 body = _EMPTY_PUSH
430 auth = _msign_header(priv, identity.handle, "POST", path, body)
431 resp = await client.post(
432 path, content=body,
433 headers={"Content-Type": "application/x-msgpack", "Authorization": auth},
434 )
435 # 200 proves pusher_id == repo.owner (correct handle in context)
436 assert resp.status_code == 200
437
438
439 # ── E2E: optional_signed_request (via refs endpoint) ──────────────────────────
440
441
442 @pytest.mark.asyncio
443 async def test_optional_msign_missing_header_still_serves_public_repo(
444 client: AsyncClient,
445 db_session: AsyncSession,
446 ) -> None:
447 """Public repo refs must be served without any Authorization header."""
448 repo = await factory_create_repo(db_session, slug="msign-optional-public", visibility="public")
449 resp = await client.get(f"/{repo.owner}/{repo.slug}/refs")
450 assert resp.status_code == 200
451
452
453 @pytest.mark.asyncio
454 async def test_optional_msign_valid_header_serves_private_repo(
455 client: AsyncClient,
456 db_session: AsyncSession,
457 ) -> None:
458 """A signed request to a private repo refs endpoint must succeed for the owner."""
459 priv, pub = _ed25519_keypair()
460 identity = await _seed_identity(db_session, "optional-msign-owner", priv, pub)
461 repo = await factory_create_repo(
462 db_session,
463 slug="msign-optional-private",
464 owner=identity.handle,
465 visibility="private",
466 )
467
468 path = f"/{repo.owner}/{repo.slug}/refs"
469 auth = _msign_header(priv, identity.handle, "GET", path, b"")
470 resp = await client.get(path, headers={"Authorization": auth})
471 assert resp.status_code == 200
472
473
474 @pytest.mark.asyncio
475 async def test_optional_msign_invalid_header_still_returns_401(
476 client: AsyncClient,
477 db_session: AsyncSession,
478 ) -> None:
479 """Even on optional-auth endpoints, a *present but invalid* MSign header must be rejected.
480
481 optional_signed_request returns None for *absent* headers, but raises 401 for
482 *present but invalid* headers — you cannot downgrade auth by sending garbage.
483 """
484 repo = await factory_create_repo(
485 db_session, slug="msign-optional-bad-hdr", visibility="public"
486 )
487 resp = await client.get(
488 f"/{repo.owner}/{repo.slug}/refs",
489 headers={"Authorization": "MSign garbage-not-valid"},
490 )
491 assert resp.status_code == 401
492
493
494 # ── Security: key revocation ──────────────────────────────────────────────────
495
496
497 @pytest.mark.asyncio
498 async def test_revoked_key_cannot_authenticate(
499 client: AsyncClient,
500 db_session: AsyncSession,
501 ) -> None:
502 """After a key is revoked, MSign requests signed with that key must be rejected."""
503 priv, pub = _ed25519_keypair()
504 identity = await _seed_identity(db_session, "revoke-test-user", priv, pub)
505 repo = await factory_create_repo(
506 db_session, slug="msign-revoke-test", owner=identity.handle
507 )
508
509 path = f"/{repo.owner}/{repo.slug}/push"
510 body = _EMPTY_PUSH
511 auth = _msign_header(priv, identity.handle, "POST", path, body)
512
513 # Before revocation: should succeed
514 resp_before = await client.post(
515 path, content=body,
516 headers={"Content-Type": "application/x-msgpack", "Authorization": auth},
517 )
518 assert resp_before.status_code == 200, resp_before.text
519
520 # Revoke: delete the MusehubAuthKey row via ORM so the shared session tracks
521 # the deletion properly (bulk DELETE bypasses the identity map).
522 fp = key_fingerprint(pub)
523 key_to_delete = (
524 await db_session.execute(
525 select(MusehubAuthKey).where(MusehubAuthKey.fingerprint == fp)
526 )
527 ).scalar_one_or_none()
528 assert key_to_delete is not None, "key not found — setup failed"
529 await db_session.delete(key_to_delete)
530 await db_session.commit()
531
532 # After revocation: same signature should be rejected (no keys for identity)
533 auth2 = _msign_header(priv, identity.handle, "POST", path, body)
534 resp_after = await client.post(
535 path, content=body,
536 headers={"Content-Type": "application/x-msgpack", "Authorization": auth2},
537 )
538 assert resp_after.status_code == 401
539
540
541 @pytest.mark.asyncio
542 async def test_second_key_still_works_after_first_revoked(
543 client: AsyncClient,
544 db_session: AsyncSession,
545 ) -> None:
546 """Multi-key: revoking one key must not affect other registered keys."""
547 from sqlalchemy import delete as sql_delete
548
549 priv_a, pub_a = _ed25519_keypair()
550 priv_b, pub_b = _ed25519_keypair()
551
552 identity = await _seed_identity(db_session, "multi-key-revoke-user", priv_a, pub_a)
553
554 # Register second key for the same identity
555 key_b = MusehubAuthKey(
556 key_id=str(uuid.uuid4()),
557 identity_id=identity.id,
558 algorithm="ed25519",
559 public_key_b64=b64url_encode(pub_b),
560 fingerprint=key_fingerprint(pub_b),
561 label="key-b",
562 )
563 db_session.add(key_b)
564 await db_session.commit()
565
566 repo = await factory_create_repo(
567 db_session, slug="msign-multi-key-revoke", owner=identity.handle
568 )
569
570 path = f"/{repo.owner}/{repo.slug}/push"
571 body = _EMPTY_PUSH
572
573 # Revoke key A
574 fp_a = key_fingerprint(pub_a)
575 await db_session.execute(
576 sql_delete(MusehubAuthKey).where(MusehubAuthKey.fingerprint == fp_a)
577 )
578 await db_session.commit()
579
580 # Key A rejected
581 auth_a = _msign_header(priv_a, identity.handle, "POST", path, body)
582 resp_a = await client.post(
583 path, content=body,
584 headers={"Content-Type": "application/x-msgpack", "Authorization": auth_a},
585 )
586 assert resp_a.status_code == 401
587
588 # Key B still works
589 auth_b = _msign_header(priv_b, identity.handle, "POST", path, body)
590 resp_b = await client.post(
591 path, content=body,
592 headers={"Content-Type": "application/x-msgpack", "Authorization": auth_b},
593 )
594 assert resp_b.status_code == 200, resp_b.text
595
596
597 # ── Stress: sequential signed requests ────────────────────────────────────────
598
599
600 @pytest.mark.asyncio
601 async def test_25_sequential_signed_requests_all_succeed(
602 client: AsyncClient,
603 db_session: AsyncSession,
604 ) -> None:
605 """25 sequential MSign-authenticated push requests must all be accepted.
606
607 Capped at 25 to stay within the WIRE_PUSH_LIMIT (30/min) so the test
608 exercises auth correctness without triggering rate limiting.
609 """
610 priv, pub = _ed25519_keypair()
611 identity = await _seed_identity(db_session, "stress-msign-user", priv, pub)
612 repo = await factory_create_repo(
613 db_session, slug="msign-stress-test", owner=identity.handle
614 )
615
616 path = f"/{repo.owner}/{repo.slug}/push"
617 body = _EMPTY_PUSH
618
619 start = time.perf_counter()
620 for i in range(25):
621 auth = _msign_header(priv, identity.handle, "POST", path, body)
622 resp = await client.post(
623 path, content=body,
624 headers={"Content-Type": "application/x-msgpack", "Authorization": auth},
625 )
626 assert resp.status_code == 200, f"Request {i} failed: {resp.status_code} {resp.text}"
627
628 elapsed = time.perf_counter() - start
629 assert elapsed < 10.0, f"25 signed requests took {elapsed:.2f}s — too slow"
630
631
632 # ── unit: REPLAY_WINDOW_SECONDS is a positive int ────────────────────────────
633
634
635 def test_replay_window_is_positive_int() -> None:
636 assert isinstance(REPLAY_WINDOW_SECONDS, int)
637 assert REPLAY_WINDOW_SECONDS > 0
638
639
640 def test_replay_window_is_at_least_15_seconds() -> None:
641 """Too small a window would break clients with minor clock drift."""
642 assert REPLAY_WINDOW_SECONDS >= 15
643
644
645 # ── Performance: canonical message computation latency ────────────────────────
646
647
648 def test_canonical_message_1kb_body_under_1ms() -> None:
649 """build_canonical_message over a 1 KB body must complete in under 1ms.
650
651 Called on every authenticated request — must be negligible overhead.
652 """
653 body = os.urandom(1024)
654 samples = 1000
655 times = []
656 for _ in range(samples):
657 t0 = time.perf_counter_ns()
658 build_canonical_message("POST", "/owner/repo/push", 1700000000, body)
659 times.append(time.perf_counter_ns() - t0)
660 median_us = sorted(times)[samples // 2] / 1000
661 assert median_us < 1000, f"Median canonical_message time: {median_us:.1f}µs — exceeds 1ms"
662
663
664 def test_canonical_message_1mb_body_under_10ms() -> None:
665 """build_canonical_message over a 1 MB body (SHA-256 of large pack) must be under 10ms."""
666 body = os.urandom(1024 * 1024)
667 samples = 20
668 times = []
669 for _ in range(samples):
670 t0 = time.perf_counter_ns()
671 build_canonical_message("POST", "/owner/repo/push", 1700000000, body)
672 times.append(time.perf_counter_ns() - t0)
673 median_ms = sorted(times)[samples // 2] / 1_000_000
674 assert median_ms < 10, f"Median canonical_message(1MB) time: {median_ms:.2f}ms — exceeds 10ms"
675
676
677 def test_canonical_message_empty_body_is_fast() -> None:
678 """build_canonical_message with an empty body (common for GET requests) is under 100µs.
679
680 GET requests carry no body — the canonical message is just the SHA-256 of b''.
681 This is the cheapest possible call and must have negligible overhead.
682 """
683 samples = 1000
684 times = []
685 for _ in range(samples):
686 t0 = time.perf_counter_ns()
687 build_canonical_message("GET", "/owner/repo/refs", 1700000000, b"")
688 times.append(time.perf_counter_ns() - t0)
689 median_us = sorted(times)[samples // 2] / 1000
690 assert median_us < 100, f"Empty-body canonical_message median: {median_us:.1f}µs — exceeds 100µs"
691
692
693 # ── Performance: key lookup query efficiency ──────────────────────────────────
694
695
696 @pytest.mark.asyncio
697 async def test_verification_with_1_key_under_latency_budget(
698 client: AsyncClient,
699 db_session: AsyncSession,
700 ) -> None:
701 """MSign verification for an identity with 1 key must complete under 200ms."""
702 priv, pub = _ed25519_keypair()
703 identity = await _seed_identity(db_session, "perf-1key-user", priv, pub)
704 repo = await factory_create_repo(db_session, slug="perf-1key-repo", owner=identity.handle)
705
706 path = f"/{repo.owner}/{repo.slug}/push"
707 # Warm up (first request includes session setup overhead)
708 auth = _msign_header(priv, identity.handle, "POST", path, _EMPTY_PUSH)
709 await client.post(path, content=_EMPTY_PUSH,
710 headers={"Content-Type": "application/x-msgpack", "Authorization": auth})
711
712 t0 = time.perf_counter()
713 auth = _msign_header(priv, identity.handle, "POST", path, _EMPTY_PUSH)
714 resp = await client.post(path, content=_EMPTY_PUSH,
715 headers={"Content-Type": "application/x-msgpack", "Authorization": auth})
716 elapsed_ms = (time.perf_counter() - t0) * 1000
717
718 assert resp.status_code == 200
719 assert elapsed_ms < 200, f"1-key verification took {elapsed_ms:.0f}ms — exceeds 200ms"
720
721
722 @pytest.mark.asyncio
723 async def test_verification_with_5_keys_under_latency_budget(
724 client: AsyncClient,
725 db_session: AsyncSession,
726 ) -> None:
727 """MSign verification for an identity with 5 keys must complete under 200ms.
728
729 _verify_msign iterates all keys until one verifies. With 5 keys and the
730 correct key last in the list (worst case), latency must still be acceptable.
731 """
732 priv_correct, pub_correct = _ed25519_keypair()
733 identity = await _seed_identity(db_session, "perf-5key-user", priv_correct, pub_correct)
734
735 # Add 4 more decoy keys — correct key was inserted first so DB returns it last
736 for i in range(4):
737 _, pub_decoy = _ed25519_keypair()
738 db_session.add(MusehubAuthKey(
739 key_id=str(uuid.uuid4()),
740 identity_id=identity.id,
741 algorithm="ed25519",
742 public_key_b64=b64url_encode(pub_decoy),
743 fingerprint=key_fingerprint(pub_decoy),
744 label=f"decoy-{i}",
745 ))
746 await db_session.commit()
747
748 repo = await factory_create_repo(db_session, slug="perf-5key-repo", owner=identity.handle)
749 path = f"/{repo.owner}/{repo.slug}/push"
750
751 # Warm up
752 auth = _msign_header(priv_correct, identity.handle, "POST", path, _EMPTY_PUSH)
753 await client.post(path, content=_EMPTY_PUSH,
754 headers={"Content-Type": "application/x-msgpack", "Authorization": auth})
755
756 t0 = time.perf_counter()
757 auth = _msign_header(priv_correct, identity.handle, "POST", path, _EMPTY_PUSH)
758 resp = await client.post(path, content=_EMPTY_PUSH,
759 headers={"Content-Type": "application/x-msgpack", "Authorization": auth})
760 elapsed_ms = (time.perf_counter() - t0) * 1000
761
762 assert resp.status_code == 200
763 assert elapsed_ms < 200, f"5-key verification took {elapsed_ms:.0f}ms — exceeds 200ms"
764
765
766 @pytest.mark.asyncio
767 async def test_key_lookup_does_not_degrade_with_more_keys(
768 client: AsyncClient,
769 db_session: AsyncSession,
770 ) -> None:
771 """Verification with 5 keys must not be more than 5× slower than with 1 key.
772
773 _verify_msign does O(N) crypto iterations across keys, but each Ed25519
774 verify is fast (~0.1ms). The DB query is a single SELECT — not N queries.
775 The total overhead must remain proportional, not super-linear.
776 """
777 # Identity A: 1 key
778 priv_a, pub_a = _ed25519_keypair()
779 identity_a = await _seed_identity(db_session, "perf-1key-cmp", priv_a, pub_a)
780 repo_a = await factory_create_repo(db_session, slug="perf-cmp-1key", owner=identity_a.handle)
781
782 # Identity B: 5 keys (correct key is key B[0], 4 decoys added after)
783 priv_b, pub_b = _ed25519_keypair()
784 identity_b = await _seed_identity(db_session, "perf-5key-cmp", priv_b, pub_b)
785 repo_b = await factory_create_repo(db_session, slug="perf-cmp-5key", owner=identity_b.handle)
786 for i in range(4):
787 _, pub_decoy = _ed25519_keypair()
788 db_session.add(MusehubAuthKey(
789 key_id=str(uuid.uuid4()),
790 identity_id=identity_b.id,
791 algorithm="ed25519",
792 public_key_b64=b64url_encode(pub_decoy),
793 fingerprint=key_fingerprint(pub_decoy),
794 label=f"decoy-{i}",
795 ))
796 await db_session.commit()
797
798 path_a = f"/{repo_a.owner}/{repo_a.slug}/push"
799 path_b = f"/{repo_b.owner}/{repo_b.slug}/push"
800
801 # Warm up both
802 for priv, identity, path in [(priv_a, identity_a, path_a), (priv_b, identity_b, path_b)]:
803 h = _msign_header(priv, identity.handle, "POST", path, _EMPTY_PUSH)
804 await client.post(path, content=_EMPTY_PUSH,
805 headers={"Content-Type": "application/x-msgpack", "Authorization": h})
806
807 # Measure 1-key identity
808 t0 = time.perf_counter()
809 for _ in range(5):
810 h = _msign_header(priv_a, identity_a.handle, "POST", path_a, _EMPTY_PUSH)
811 r = await client.post(path_a, content=_EMPTY_PUSH,
812 headers={"Content-Type": "application/x-msgpack", "Authorization": h})
813 assert r.status_code == 200
814 time_1key_ms = (time.perf_counter() - t0) * 1000 / 5
815
816 # Measure 5-key identity
817 t0 = time.perf_counter()
818 for _ in range(5):
819 h = _msign_header(priv_b, identity_b.handle, "POST", path_b, _EMPTY_PUSH)
820 r = await client.post(path_b, content=_EMPTY_PUSH,
821 headers={"Content-Type": "application/x-msgpack", "Authorization": h})
822 assert r.status_code == 200
823 time_5key_ms = (time.perf_counter() - t0) * 1000 / 5
824
825 ratio = time_5key_ms / max(time_1key_ms, 1)
826 assert ratio < 5, (
827 f"5-key verification is {ratio:.1f}× slower than 1-key ({time_5key_ms:.0f}ms vs "
828 f"{time_1key_ms:.0f}ms) — key iteration is super-linear"
829 )
File History 1 commit
sha256:a10adeeb7a0169cb9900f9806ed7a973047258abb6283724fe55e8eb68ff3f0a init: musehub initial commit Human 171 days ago