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