test_msign_request_signing.py
python
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