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