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