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