"""MSign authentication — 7-layer test suite. MSign is MuseHub's per-request Ed25519 authentication protocol. Every authenticated HTTP request carries: Authorization: MSign handle="" alg="ed25519" ts= sig="" where ``sig`` is the Ed25519 signature over the canonical message: {algorithm}\\n{METHOD}\\n{host}\\n{path_with_query}\\n{ts}\\n{sha256_hex(body)} For ``application/x-muse-wire`` (streaming push) the body hash is always SHA-256("") — the body is content-addressed per-frame; MSign only covers identity + replay protection. Canonical prefix invariant -------------------------- Every cryptographic value stored in the database is canonically prefixed: - ``public_key_b64`` → ``"ed25519:"`` - ``fingerprint`` → ``"sha256:<64-hex>"`` ``b64url_decode`` in ``musehub.crypto.keys`` handles both prefixed and bare values for backward compatibility, but all new data must be prefixed. Wire protocol format (MWP) -------------------------- Push requests use ``application/x-muse-wire`` with framed binary data: b"muse" 4 bytes magic 0x01 1 byte version uint32 BE 4 bytes envelope_len msgpack dict {ft, sz, id} envelope (ft=frame type, sz=payload bytes, id=blob_id) uint64 BE 8 bytes payload_len raw bytes msgpack payload Frame types: H (HEADER), O (OBJECT), OC (OBJECT_CHUNK), C (COMMIT_PACK), E (END). A minimal valid push is H + C + E with no objects and no commits. Test layers ----------- 1. Unit — MSignContext dataclass, TokenClaims alias, _parse_msign_header edge cases, build_canonical_message query-string handling 2. Integration — _verify_msign via FastAPI deps with a real DB, no HTTP (invoking require_signed_request / optional_signed_request through the dependency chain — service-layer behaviour) 3. E2E — Full HTTP stack: query-string in signature, agent identity type, method/path mismatch, soft-deleted identity 4. Stress — 28 rapid signed GET requests, concurrent identities, header parsing under repeated calls 5. Data — MSignContext field accuracy, multi-key iteration order, revocation takes immediate effect 6. Security — WWW-Authenticate header on 401, handle injection, invalid base64 sig, stale timestamp, no key-material leakage 7. Performance — Header parsing throughput, canonical message latency, Ed25519 sign latency, end-to-end verification budget Notes ----- - ``AUTH_LIMIT = "10000/minute"`` in the test environment — 401s do not trip the rate limiter. - The ``client`` fixture uses autouse ``db_session`` so the test DB is shared across requests within a test. - ``auth_headers`` bypass fixture is NOT used here — all tests exercise the real MSign code path. """ from __future__ import annotations import hashlib import secrets import struct import time import msgpack import pytest from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from httpx import AsyncClient from sqlalchemy.ext.asyncio import AsyncSession from muse.core.types import blob_id, encode_pubkey from musehub.types.json_types import JSONObject from musehub.auth.request_signing import ( REPLAY_WINDOW_SECONDS, MSignContext, _parse_msign_header, build_canonical_message, ) from musehub.auth.dependencies import ( TokenClaims, optional_token, require_valid_token, ) from musehub.crypto.keys import b64url_encode, b64url_decode, key_fingerprint from musehub.db.musehub_auth_models import MusehubAuthKey from musehub.db import musehub_models as db from tests.factories import create_repo as factory_create_repo # ── Wire protocol helpers ────────────────────────────────────────────────────── def _encode_mwp_frame(ft: str, payload_dict: JSONObject) -> bytes: """Encode a single MWP wire frame with the correct 5-part binary layout. Layout: b"muse" | 0x01 | uint32(envelope_len) | envelope_msgpack | uint64(payload_len) | payload_msgpack The envelope carries {ft, sz, id} where: - ``ft`` — frame type string ("H", "C", "E", …) - ``sz`` — byte length of the payload - ``id`` — blob_id (``sha256:<64-hex>``) of the payload bytes The ``id`` field is verified by the server before processing, so it must be correct. ``blob_id`` from ``muse.core.types`` produces the expected ``sha256:`` format. """ payload = msgpack.packb(payload_dict, use_bin_type=True) payload_id = blob_id(payload) envelope = msgpack.packb({"ft": ft, "sz": len(payload), "id": payload_id}, use_bin_type=True) return ( b"muse" + b"\x01" + struct.pack(">I", len(envelope)) + envelope + struct.pack(">Q", len(payload)) + payload ) def _minimal_push_body(branch: str = "main") -> bytes: """Return the minimal valid MWP wire body for a no-op push. Sends H (HEADER) → C (COMMIT_PACK with empty lists) → E (END). Zero objects, zero commits. The server accepts this as a valid push that advances no branch and stores nothing. """ h = _encode_mwp_frame("H", { "t": "H", "branch": branch, "force": False, "head": None, "have": [], "n_objects": 0, "n_commits": 0, }) c = _encode_mwp_frame("C", {"t": "C", "commits": [], "snapshots": []}) e = _encode_mwp_frame("E", {"t": "E"}) return h + c + e # ── General helpers ──────────────────────────────────────────────────────────── def _uid() -> str: return secrets.token_hex(16) def _keypair() -> tuple[Ed25519PrivateKey, bytes]: priv = Ed25519PrivateKey.generate() pub = priv.public_key().public_bytes_raw() return priv, pub def _msign_header( priv: Ed25519PrivateKey, handle: str, method: str, path: str, body: bytes, ts: int | None = None, host: str = "test", ) -> str: """Build a valid ``Authorization: MSign …`` header for test requests. ``body`` is the bytes that the client would hash into the canonical message. For ``application/x-muse-wire`` pushes, always pass ``b""`` because the server uses an empty body hash for streaming wire requests (body integrity is provided by per-frame content addressing). """ ts = ts if ts is not None else int(time.time()) canonical = build_canonical_message(method, path, ts, body, host=host) sig_bytes = priv.sign(canonical) sig_b64 = b64url_encode(sig_bytes) return f'MSign handle="{handle}" alg="ed25519" ts={ts} sig="{sig_b64}"' async def _seed( session: AsyncSession, handle: str, priv: Ed25519PrivateKey, pub: bytes, identity_type: str = "human", deleted: bool = False, ) -> db.MusehubIdentity: """Insert a MusehubIdentity and one MusehubAuthKey row for test use. ``public_key_b64`` is stored with the canonical ``"ed25519:"`` prefix — this matches the invariant enforced throughout the Muse ecosystem. ``b64url_decode`` on the read path handles both prefixed and bare values. """ from datetime import datetime, timezone identity = db.MusehubIdentity( identity_id=_uid(), handle=handle, identity_type=identity_type, display_name=handle, ) if deleted: identity.deleted_at = datetime(2020, 1, 1, tzinfo=timezone.utc) session.add(identity) await session.flush() key_row = MusehubAuthKey( key_id=_uid(), identity_id=identity.identity_id, algorithm="ed25519", public_key_b64=encode_pubkey("ed25519", pub), # canonical prefix always fingerprint=key_fingerprint(pub), label="test-key", ) session.add(key_row) await session.commit() await session.refresh(identity) return identity async def _push( client: AsyncClient, priv: Ed25519PrivateKey, handle: str, owner: str, slug: str, branch: str = "main", ) -> int: """Execute a minimal MWP push and return the HTTP status code. Signs the request correctly for ``application/x-muse-wire``: the body hash in the canonical message is SHA-256("") regardless of the actual wire bytes sent — per MSign spec for streaming wire content types. """ path = f"/{owner}/{slug}/push/stream" body = _minimal_push_body(branch) auth = _msign_header(priv, handle, "POST", path, b"") # empty body for wire signing resp = await client.post( path, content=body, headers={"Content-Type": "application/x-muse-wire", "Authorization": auth}, ) return resp.status_code # ══════════════════════════════════════════════════════════════════════════════ # 1. Unit # ══════════════════════════════════════════════════════════════════════════════ class TestMSignContextUnit: """MSignContext dataclass fields and alias exports from auth.dependencies.""" def test_msign_context_is_dataclass(self) -> None: import dataclasses assert dataclasses.is_dataclass(MSignContext) def test_token_claims_is_msign_context(self) -> None: """TokenClaims is the canonical re-export of MSignContext.""" assert TokenClaims is MSignContext def test_require_valid_token_is_require_signed_request(self) -> None: from musehub.auth.request_signing import require_signed_request assert require_valid_token is require_signed_request def test_optional_token_is_optional_signed_request(self) -> None: from musehub.auth.request_signing import optional_signed_request assert optional_token is optional_signed_request def test_context_scope_defaults_to_none_for_humans(self) -> None: ctx = MSignContext(handle="gabriel", identity_id="abc", is_agent=False, is_admin=False) assert ctx.scope is None def test_context_scope_can_be_set_for_agents(self) -> None: ctx = MSignContext( handle="bot", identity_id="abc", is_agent=True, is_admin=False, scope=["issue:write", "proposal:write"], ) assert ctx.scope == ["issue:write", "proposal:write"] assert "issue:write" in ctx.scope def test_context_human_is_not_agent(self) -> None: ctx = MSignContext(handle="human", identity_id="x", is_agent=False, is_admin=False) assert not ctx.is_agent def test_context_agent_flag(self) -> None: ctx = MSignContext(handle="bot", identity_id="x", is_agent=True, is_admin=False) assert ctx.is_agent def test_context_is_admin_false_by_default(self) -> None: ctx = MSignContext(handle="x", identity_id="y", is_agent=False, is_admin=False) assert not ctx.is_admin class TestBuildCanonicalMessageUnit: """Edge cases for build_canonical_message.""" def test_query_string_included_in_canonical(self) -> None: """Signing with vs. without query string produces different bytes.""" msg_with_q = build_canonical_message("GET", "/x/y?ref=main", 1, b"") msg_no_q = build_canonical_message("GET", "/x/y", 1, b"") assert msg_with_q != msg_no_q def test_query_string_appears_verbatim_in_fourth_line(self) -> None: """The path (line 4 of 6) is stored verbatim including the query string.""" path = "/owner/repo/refs?format=json" msg = build_canonical_message("GET", path, 1, b"").decode() assert msg.split("\n")[3] == path def test_large_body_sha256_is_hex(self) -> None: """Body hash line must be the canonical ``sha256:<64-hex>`` form (71 chars).""" body = b"x" * 100_000 msg = build_canonical_message("POST", "/", 0, body).decode() body_hash = msg.split("\n")[5] assert body_hash.startswith("sha256:") assert len(body_hash) == 71 assert all(c in "0123456789abcdef" for c in body_hash[7:]) def test_output_is_utf8_bytes(self) -> None: msg = build_canonical_message("POST", "/path", 1234567890, b"data") assert msg.decode("utf-8").encode("utf-8") == msg def test_empty_body_hash_is_blob_id_of_empty(self) -> None: """Empty body produces ``blob_id(b"")`` — the canonical ``sha256:`` prefixed form.""" msg = build_canonical_message("POST", "/push/stream", 1, b"").decode() body_hash = msg.split("\n")[5] assert body_hash == blob_id(b"") def test_algorithm_is_first_line(self) -> None: """Algorithm identifier must be the first line for downgrade-attack protection.""" msg = build_canonical_message("POST", "/path", 1, b"").decode() assert msg.split("\n")[0] == "ed25519" class TestParseMsignHeaderUnit: """Edge cases for _parse_msign_header.""" def test_leading_whitespace_stripped(self) -> None: sig = b64url_encode(b"x" * 64) hdr = f' MSign handle="gabriel" alg="ed25519" ts=1 sig="{sig}"' result = _parse_msign_header(hdr) assert result is not None assert result[0] == "gabriel" def test_ts_zero_parses_as_int(self) -> None: sig = b64url_encode(b"y" * 64) hdr = f'MSign handle="x" alg="ed25519" ts=0 sig="{sig}"' result = _parse_msign_header(hdr) assert result is not None assert result[2] == 0 def test_large_ts_parses(self) -> None: sig = b64url_encode(b"z" * 64) hdr = f'MSign handle="x" alg="ed25519" ts=9999999999 sig="{sig}"' result = _parse_msign_header(hdr) assert result is not None assert result[2] == 9999999999 def test_basic_scheme_rejected(self) -> None: assert _parse_msign_header("Basic dXNlcjpwYXNz") is None def test_empty_handle_rejected(self) -> None: """handle="" cannot parse — regex requires [^"]+ (at least one char).""" sig = b64url_encode(b"x" * 64) hdr = f'MSign handle="" alg="ed25519" ts=1 sig="{sig}"' assert _parse_msign_header(hdr) is None def test_returns_four_tuple(self) -> None: """Successful parse returns (handle, alg, ts, sig_b64).""" sig = b64url_encode(b"x" * 64) hdr = f'MSign handle="gabriel" alg="ed25519" ts=1700000000 sig="{sig}"' result = _parse_msign_header(hdr) assert result is not None handle, alg, ts, sig_out = result assert handle == "gabriel" assert alg == "ed25519" assert ts == 1700000000 assert sig_out == sig class TestMWPFrameEncoderUnit: """Unit tests for the _encode_mwp_frame / _minimal_push_body helpers. Verifies the helpers produce spec-compliant binary frames before any integration or E2E tests depend on them. """ def test_magic_bytes_correct(self) -> None: frame = _encode_mwp_frame("H", {"t": "H", "branch": "main", "force": False, "head": None, "have": [], "n_objects": 0, "n_commits": 0}) assert frame[:4] == b"muse" def test_version_byte_is_one(self) -> None: frame = _encode_mwp_frame("E", {"t": "E"}) assert frame[4:5] == b"\x01" def test_envelope_is_valid_msgpack(self) -> None: frame = _encode_mwp_frame("E", {"t": "E"}) header_len = struct.unpack(">I", frame[5:9])[0] envelope = msgpack.unpackb(frame[9:9 + header_len], raw=False) assert envelope["ft"] == "E" assert "sz" in envelope assert "id" in envelope def test_payload_hash_matches_envelope_id(self) -> None: """Server verifies blob_id(payload) == envelope['id'] — must be correct.""" frame = _encode_mwp_frame("C", {"t": "C", "commits": [], "snapshots": []}) header_len = struct.unpack(">I", frame[5:9])[0] envelope = msgpack.unpackb(frame[9:9 + header_len], raw=False) payload_start = 9 + header_len + 8 # +8 for uint64 payload_len prefix payload = frame[payload_start:] assert blob_id(payload) == envelope["id"] def test_minimal_push_body_starts_with_header_frame(self) -> None: body = _minimal_push_body() header_len = struct.unpack(">I", body[5:9])[0] first_envelope = msgpack.unpackb(body[9:9 + header_len], raw=False) assert first_envelope["ft"] == "H" def test_minimal_push_body_ends_with_end_frame(self) -> None: body = _minimal_push_body() # Walk frames to find the last one pos = 0 last_ft = None while pos < len(body): assert body[pos:pos + 4] == b"muse" header_len = struct.unpack(">I", body[pos + 5:pos + 9])[0] envelope = msgpack.unpackb(body[pos + 9:pos + 9 + header_len], raw=False) payload_len = struct.unpack(">Q", body[pos + 9 + header_len:pos + 9 + header_len + 8])[0] last_ft = envelope["ft"] pos = pos + 9 + header_len + 8 + payload_len assert last_ft == "E" # ══════════════════════════════════════════════════════════════════════════════ # 2. Integration # ══════════════════════════════════════════════════════════════════════════════ class TestMSignIntegration: """Service-layer tests: real DB, full dependency chain, no mocking. All push requests use ``application/x-muse-wire`` + MWP binary body and sign against an empty body hash (matching the MSign spec for wire content types). GET requests to ``/refs`` sign against the actual (empty) request body. """ async def test_valid_push_returns_200( self, client: AsyncClient, db_session: AsyncSession ) -> None: """A correctly signed push from a registered identity succeeds.""" priv, pub = _keypair() identity = await _seed(db_session, "int-valid-user", priv, pub) repo = await factory_create_repo(db_session, slug="int-valid-repo", owner=identity.handle) status_code = await _push(client, priv, identity.handle, repo.owner, repo.slug) assert status_code == 200 async def test_unknown_identity_returns_401( self, client: AsyncClient, db_session: AsyncSession ) -> None: """Signing with a handle not in the DB returns 401.""" priv, _ = _keypair() repo = await factory_create_repo(db_session, slug="int-no-identity", owner="ghost") status_code = await _push(client, priv, "ghost", repo.owner, repo.slug) assert status_code == 401 async def test_no_keys_registered_returns_401( self, client: AsyncClient, db_session: AsyncSession ) -> None: """An identity with no auth keys registered is rejected.""" identity = db.MusehubIdentity( identity_id=_uid(), handle="keyless-int-user", identity_type="human", display_name="Keyless", ) db_session.add(identity) await db_session.commit() repo = await factory_create_repo( db_session, slug="int-keyless-repo", owner=identity.handle ) priv, _ = _keypair() status_code = await _push(client, priv, identity.handle, repo.owner, repo.slug) assert status_code == 401 async def test_soft_deleted_identity_returns_401( self, client: AsyncClient, db_session: AsyncSession ) -> None: """``deleted_at IS NOT NULL`` identities are rejected regardless of key validity.""" priv, pub = _keypair() identity = await _seed(db_session, "deleted-int-user", priv, pub, deleted=True) repo = await factory_create_repo( db_session, slug="int-deleted-identity", owner="deleted-int-user" ) status_code = await _push(client, priv, identity.handle, repo.owner, repo.slug) assert status_code == 401 async def test_optional_absent_header_allows_public_repo( self, client: AsyncClient, db_session: AsyncSession ) -> None: """``optional_token`` routes accept anonymous requests for public repos.""" repo = await factory_create_repo( db_session, slug="int-optional-public", visibility="public" ) resp = await client.get(f"/{repo.owner}/{repo.slug}/refs") assert resp.status_code == 200 async def test_optional_invalid_header_returns_401( self, client: AsyncClient, db_session: AsyncSession ) -> None: """A malformed MSign header on an optional route still returns 401.""" repo = await factory_create_repo( db_session, slug="int-optional-bad", visibility="public" ) resp = await client.get( f"/{repo.owner}/{repo.slug}/refs", headers={"Authorization": 'MSign handle="x" alg="ed25519" ts=1 sig="bad"'}, ) assert resp.status_code == 401 # ══════════════════════════════════════════════════════════════════════════════ # 3. End-to-End # ══════════════════════════════════════════════════════════════════════════════ class TestMSignE2E: """Full HTTP stack scenarios exercising the complete request path.""" async def test_query_string_signed_correctly( self, client: AsyncClient, db_session: AsyncSession ) -> None: """Signing path + query string succeeds — server derives the same canonical message.""" priv, pub = _keypair() identity = await _seed(db_session, "e2e-query-user", priv, pub) repo = await factory_create_repo( db_session, slug="e2e-query-repo", owner=identity.handle, visibility="public" ) path = f"/{repo.owner}/{repo.slug}/refs" auth = _msign_header(priv, identity.handle, "GET", path, b"") resp = await client.get(path, headers={"Authorization": auth}) assert resp.status_code == 200 async def test_agent_identity_push_succeeds( self, client: AsyncClient, db_session: AsyncSession ) -> None: """``identity_type='agent'`` is not blocked — agents can push.""" priv, pub = _keypair() identity = await _seed(db_session, "e2e-agent-user", priv, pub, identity_type="agent") repo = await factory_create_repo( db_session, slug="e2e-agent-repo", owner=identity.handle ) status_code = await _push(client, priv, identity.handle, repo.owner, repo.slug) assert status_code == 200 async def test_401_response_has_detail( self, client: AsyncClient, db_session: AsyncSession ) -> None: """401 responses must include a non-empty ``detail`` field.""" repo = await factory_create_repo(db_session, slug="e2e-detail-check", owner="no-such-user") priv, _ = _keypair() status_code = await _push(client, priv, "no-such-user", repo.owner, repo.slug) assert status_code == 401 async def test_method_mismatch_in_signature_returns_401( self, client: AsyncClient, db_session: AsyncSession ) -> None: """Signing with the wrong HTTP method produces a different canonical message → 401.""" priv, pub = _keypair() identity = await _seed(db_session, "e2e-method-user", priv, pub) repo = await factory_create_repo( db_session, slug="e2e-method-repo", owner=identity.handle ) path = f"/{repo.owner}/{repo.slug}/push/stream" body = _minimal_push_body() # Sign for GET but send as POST auth = _msign_header(priv, identity.handle, "GET", path, b"") resp = await client.post( path, content=body, headers={"Content-Type": "application/x-muse-wire", "Authorization": auth}, ) assert resp.status_code == 401 async def test_path_mismatch_in_signature_returns_401( self, client: AsyncClient, db_session: AsyncSession ) -> None: """Signature computed over a different path is rejected — canonical message mismatch.""" priv, pub = _keypair() identity = await _seed(db_session, "e2e-path-user", priv, pub) repo = await factory_create_repo( db_session, slug="e2e-path-repo", owner=identity.handle ) body = _minimal_push_body() real_path = f"/{repo.owner}/{repo.slug}/push/stream" wrong_path = f"/{repo.owner}/{repo.slug}/other-endpoint" auth = _msign_header(priv, identity.handle, "POST", wrong_path, b"") resp = await client.post( real_path, content=body, headers={"Content-Type": "application/x-muse-wire", "Authorization": auth}, ) assert resp.status_code == 401 async def test_refs_endpoint_requires_no_auth_for_public_repo( self, client: AsyncClient, db_session: AsyncSession ) -> None: """Public repos return 200 on ``/refs`` without any Authorization header.""" repo = await factory_create_repo( db_session, slug="e2e-public-refs", owner="public-owner", visibility="public" ) resp = await client.get(f"/{repo.owner}/{repo.slug}/refs") assert resp.status_code == 200 async def test_push_without_auth_returns_401( self, client: AsyncClient, db_session: AsyncSession ) -> None: """Push endpoint always requires auth — no header → 401.""" repo = await factory_create_repo(db_session, slug="e2e-no-auth-push", owner="any-owner") body = _minimal_push_body() resp = await client.post( f"/{repo.owner}/{repo.slug}/push/stream", content=body, headers={"Content-Type": "application/x-muse-wire"}, ) assert resp.status_code == 401 # ══════════════════════════════════════════════════════════════════════════════ # 4. Stress # ══════════════════════════════════════════════════════════════════════════════ class TestMSignStress: """Sustained-load and burst scenarios to catch race conditions and resource leaks.""" async def test_28_sequential_signed_get_requests_succeed( self, client: AsyncClient, db_session: AsyncSession ) -> None: """28 consecutive GET /refs requests (within WIRE_FETCH_LIMIT) all verify correctly.""" priv, pub = _keypair() identity = await _seed(db_session, "stress-get-user", priv, pub) repo = await factory_create_repo( db_session, slug="stress-get-repo", owner=identity.handle, visibility="public" ) path = f"/{repo.owner}/{repo.slug}/refs" for i in range(28): auth = _msign_header(priv, identity.handle, "GET", path, b"") r = await client.get(path, headers={"Authorization": auth}) assert r.status_code == 200, f"Request {i} failed: {r.status_code}" async def test_repeated_requests_with_fresh_timestamps_succeed( self, client: AsyncClient, db_session: AsyncSession ) -> None: """Each request refreshes the timestamp — no stale-ts rejections under rapid fire.""" priv, pub = _keypair() identity = await _seed(db_session, "stress-ts-user", priv, pub) repo = await factory_create_repo( db_session, slug="stress-ts-repo", owner=identity.handle, visibility="public" ) path = f"/{repo.owner}/{repo.slug}/refs" for i in range(10): auth = _msign_header(priv, identity.handle, "GET", path, b"") r = await client.get(path, headers={"Authorization": auth}) assert r.status_code != 401, f"Request {i} got unexpected 401" async def test_two_identities_alternating_push_requests( self, client: AsyncClient, db_session: AsyncSession ) -> None: """Two distinct identities can make authenticated push requests interleaved.""" priv_a, pub_a = _keypair() priv_b, pub_b = _keypair() id_a = await _seed(db_session, "stress-alt-a", priv_a, pub_a) id_b = await _seed(db_session, "stress-alt-b", priv_b, pub_b) repo_a = await factory_create_repo(db_session, slug="stress-alt-repo-a", owner=id_a.handle) repo_b = await factory_create_repo(db_session, slug="stress-alt-repo-b", owner=id_b.handle) for i in range(5): for priv, identity, repo in [ (priv_a, id_a, repo_a), (priv_b, id_b, repo_b), ]: sc = await _push(client, priv, identity.handle, repo.owner, repo.slug) assert sc == 200, f"iter {i}: {identity.handle} got {sc}" def test_parse_header_1000_times_completes_under_50ms(self) -> None: """Parsing 1000 MSign headers completes in under 50ms total (no DB, pure CPU).""" sig = b64url_encode(b"x" * 64) hdr = f'MSign handle="gabriel" alg="ed25519" ts=1700000000 sig="{sig}"' start = time.perf_counter() for _ in range(1000): result = _parse_msign_header(hdr) assert result is not None elapsed_ms = (time.perf_counter() - start) * 1000 assert elapsed_ms < 50, f"1000 parses took {elapsed_ms:.1f}ms (budget: 50ms)" async def test_five_pushes_same_identity_all_succeed( self, client: AsyncClient, db_session: AsyncSession ) -> None: """Five sequential pushes from the same identity all return 200.""" priv, pub = _keypair() identity = await _seed(db_session, "stress-5push-user", priv, pub) repo = await factory_create_repo( db_session, slug="stress-5push-repo", owner=identity.handle ) for i in range(5): sc = await _push(client, priv, identity.handle, repo.owner, repo.slug) assert sc == 200, f"Push {i} returned {sc}" # ══════════════════════════════════════════════════════════════════════════════ # 5. Data Integrity # ══════════════════════════════════════════════════════════════════════════════ class TestMSignDataIntegrity: """MSignContext field accuracy, canonical key storage, and multi-key handling.""" async def test_is_agent_true_for_agent_identity_type( self, client: AsyncClient, db_session: AsyncSession ) -> None: """``identity_type='agent'`` must yield a valid MSignContext (proved by 200).""" priv, pub = _keypair() identity = await _seed(db_session, "di-agent-identity", priv, pub, identity_type="agent") repo = await factory_create_repo(db_session, slug="di-agent-repo", owner=identity.handle) sc = await _push(client, priv, identity.handle, repo.owner, repo.slug) assert sc == 200 async def test_is_agent_false_for_human_identity_type( self, client: AsyncClient, db_session: AsyncSession ) -> None: """``identity_type='human'`` must also produce a valid context and succeed.""" priv, pub = _keypair() identity = await _seed(db_session, "di-human-identity", priv, pub, identity_type="human") repo = await factory_create_repo(db_session, slug="di-human-repo", owner=identity.handle) sc = await _push(client, priv, identity.handle, repo.owner, repo.slug) assert sc == 200 async def test_second_of_two_keys_verified_when_first_fails( self, client: AsyncClient, db_session: AsyncSession ) -> None: """_verify_msign iterates all keys for an identity — second key must be tried.""" priv_a, pub_a = _keypair() priv_b, pub_b = _keypair() identity = await _seed(db_session, "di-multi-key", priv_a, pub_a) # Add second key with canonical prefix db_session.add(MusehubAuthKey( key_id=_uid(), identity_id=identity.identity_id, algorithm="ed25519", public_key_b64=encode_pubkey("ed25519", pub_b), fingerprint=key_fingerprint(pub_b), label="key-b", )) await db_session.commit() repo = await factory_create_repo(db_session, slug="di-multi-key-repo", owner=identity.handle) # Sign with key B (second key — first key will fail, second must succeed) sc = await _push(client, priv_b, identity.handle, repo.owner, repo.slug) assert sc == 200, "Second registered key must be tried and accepted" async def test_three_decoy_keys_then_correct_key( self, client: AsyncClient, db_session: AsyncSession ) -> None: """Correct key at index 3 is still found after three non-matching decoys.""" priv_correct, pub_correct = _keypair() identity = await _seed(db_session, "di-decoy-keys", priv_correct, pub_correct) # Add 3 decoy keys (not the one we'll sign with) for i in range(3): _, pub_decoy = _keypair() db_session.add(MusehubAuthKey( key_id=_uid(), identity_id=identity.identity_id, algorithm="ed25519", public_key_b64=encode_pubkey("ed25519", pub_decoy), fingerprint=key_fingerprint(pub_decoy), label=f"decoy-{i}", )) await db_session.commit() repo = await factory_create_repo(db_session, slug="di-decoy-repo", owner=identity.handle) sc = await _push(client, priv_correct, identity.handle, repo.owner, repo.slug) assert sc == 200 async def test_context_handle_matches_identity_handle( self, client: AsyncClient, db_session: AsyncSession ) -> None: """MSignContext.handle comes from the DB identity row, not just the header. A successful push proves ``context.handle == repo.owner == identity.handle``. """ priv, pub = _keypair() handle = "di-handle-check" identity = await _seed(db_session, handle, priv, pub) repo = await factory_create_repo(db_session, slug="di-handle-repo", owner=handle) sc = await _push(client, priv, handle, repo.owner, repo.slug) assert sc == 200 async def test_canonical_prefix_stored_and_verified( self, client: AsyncClient, db_session: AsyncSession ) -> None: """Key stored with ``ed25519:`` prefix verifies correctly via b64url_decode.""" priv, pub = _keypair() identity = db.MusehubIdentity( identity_id=_uid(), handle="di-prefix-user", identity_type="human", display_name="Prefix Test", ) db_session.add(identity) await db_session.flush() # Store with canonical prefix explicitly key_row = MusehubAuthKey( key_id=_uid(), identity_id=identity.identity_id, algorithm="ed25519", public_key_b64=encode_pubkey("ed25519", pub), fingerprint=key_fingerprint(pub), label="prefixed-key", ) db_session.add(key_row) await db_session.commit() repo = await factory_create_repo(db_session, slug="di-prefix-repo", owner=identity.handle) sc = await _push(client, priv, identity.handle, repo.owner, repo.slug) assert sc == 200, "Prefixed public_key_b64 must be decoded and verified correctly" async def test_revocation_takes_immediate_effect( self, client: AsyncClient, db_session: AsyncSession ) -> None: """Deleting the key row immediately prevents future authentications.""" from sqlalchemy import select as sa_select priv, pub = _keypair() identity = await _seed(db_session, "di-revoke-user", priv, pub) repo = await factory_create_repo(db_session, slug="di-revoke-repo", owner=identity.handle) # Before revocation: success assert await _push(client, priv, identity.handle, repo.owner, repo.slug) == 200 # Revoke via ORM delete — bulk DELETE bypasses the identity map and # can leave the key cached; ORM delete ensures the session invalidates it. key_to_delete = ( await db_session.execute( sa_select(MusehubAuthKey).where( MusehubAuthKey.fingerprint == key_fingerprint(pub) ) ) ).scalar_one_or_none() assert key_to_delete is not None await db_session.delete(key_to_delete) await db_session.commit() # After revocation: 401 assert await _push(client, priv, identity.handle, repo.owner, repo.slug) == 401 # ══════════════════════════════════════════════════════════════════════════════ # 6. Security # ══════════════════════════════════════════════════════════════════════════════ class TestMSignSecurity: """Auth bypass attempts, header abuse, and information leakage checks.""" async def test_missing_header_response_has_www_authenticate( self, client: AsyncClient, db_session: AsyncSession ) -> None: """401 with missing Authorization header must include ``WWW-Authenticate: MSign``.""" repo = await factory_create_repo(db_session, slug="sec-www-auth", owner="sec-user") body = _minimal_push_body() resp = await client.post( f"/{repo.owner}/{repo.slug}/push/stream", content=body, headers={"Content-Type": "application/x-muse-wire"}, ) assert resp.status_code == 401 www_auth = resp.headers.get("www-authenticate", "") assert "MSign" in www_auth async def test_wrong_scheme_returns_401_with_www_authenticate( self, client: AsyncClient, db_session: AsyncSession ) -> None: """Bearer tokens and other schemes must be rejected with WWW-Authenticate: MSign.""" repo = await factory_create_repo(db_session, slug="sec-scheme-auth", owner="sec-scheme") body = _minimal_push_body() resp = await client.post( f"/{repo.owner}/{repo.slug}/push/stream", content=body, headers={ "Content-Type": "application/x-muse-wire", "Authorization": "Bearer fake-token", }, ) assert resp.status_code == 401 assert "MSign" in resp.headers.get("www-authenticate", "") async def test_invalid_base64_sig_returns_401( self, client: AsyncClient, db_session: AsyncSession ) -> None: """A signature field with non-base64url characters must fail cleanly with 401.""" priv, pub = _keypair() identity = await _seed(db_session, "sec-bad-b64", priv, pub) repo = await factory_create_repo(db_session, slug="sec-bad-b64-repo", owner=identity.handle) ts = int(time.time()) bad_hdr = f'MSign handle="{identity.handle}" alg="ed25519" ts={ts} sig="!!!invalid!!!"' body = _minimal_push_body() resp = await client.post( f"/{repo.owner}/{repo.slug}/push/stream", content=body, headers={"Content-Type": "application/x-muse-wire", "Authorization": bad_hdr}, ) assert resp.status_code == 401 async def test_timestamp_exactly_at_boundary_does_not_crash( self, client: AsyncClient, db_session: AsyncSession ) -> None: """Timestamp exactly REPLAY_WINDOW_SECONDS ago is at the boundary — must not 500.""" priv, pub = _keypair() identity = await _seed(db_session, "sec-boundary-ts", priv, pub) repo = await factory_create_repo( db_session, slug="sec-boundary-repo", owner=identity.handle, visibility="public" ) path = f"/{repo.owner}/{repo.slug}/refs" boundary_ts = int(time.time()) - REPLAY_WINDOW_SECONDS auth = _msign_header(priv, identity.handle, "GET", path, b"", ts=boundary_ts) resp = await client.get(path, headers={"Authorization": auth}) assert resp.status_code in (200, 401), f"Expected 200 or 401, got {resp.status_code}" async def test_stale_timestamp_returns_401( self, client: AsyncClient, db_session: AsyncSession ) -> None: """Timestamp more than REPLAY_WINDOW_SECONDS old must be rejected.""" priv, pub = _keypair() identity = await _seed(db_session, "sec-over-boundary", priv, pub) repo = await factory_create_repo( db_session, slug="sec-over-boundary-repo", owner=identity.handle ) stale_ts = int(time.time()) - REPLAY_WINDOW_SECONDS - 1 body = _minimal_push_body() auth = _msign_header(priv, identity.handle, "POST", f"/{repo.owner}/{repo.slug}/push/stream", b"", ts=stale_ts) resp = await client.post( f"/{repo.owner}/{repo.slug}/push/stream", content=body, headers={"Content-Type": "application/x-muse-wire", "Authorization": auth}, ) assert resp.status_code == 401 async def test_wrong_key_returns_401_without_leaking_key_material( self, client: AsyncClient, db_session: AsyncSession ) -> None: """Signing with the wrong key returns 401 — error detail must not contain key bytes.""" priv, pub = _keypair() identity = await _seed(db_session, "sec-no-leak", priv, pub) repo = await factory_create_repo(db_session, slug="sec-no-leak-repo", owner=identity.handle) other_priv, _ = _keypair() # Wrong key sc = await _push(client, other_priv, identity.handle, repo.owner, repo.slug) assert sc == 401 # Re-send to capture JSON detail path = f"/{repo.owner}/{repo.slug}/push/stream" body = _minimal_push_body() auth = _msign_header(other_priv, identity.handle, "POST", path, b"") resp = await client.post( path, content=body, headers={"Content-Type": "application/x-muse-wire", "Authorization": auth}, ) detail = resp.json().get("detail", "") pub_b64_bare = b64url_encode(pub) assert pub_b64_bare not in detail, "Bare public key must not appear in error response" assert encode_pubkey("ed25519", pub) not in detail, "Prefixed public key must not appear in error response" assert "BEGIN" not in detail # no PEM blocks async def test_handle_quote_injection_rejected_or_truncated( self, client: AsyncClient, db_session: AsyncSession ) -> None: """A handle containing a double-quote cannot parse — regex stops at first ``"``.""" priv, _ = _keypair() ts = int(time.time()) sig = b64url_encode(priv.sign(b"x")) bad_hdr = f'MSign handle="injected\\"quote" alg="ed25519" ts={ts} sig="{sig}"' result = _parse_msign_header(bad_hdr) if result is not None: handle, _, _, _ = result assert '"' not in handle, "Parsed handle must not contain a quote character" async def test_future_timestamp_returns_401( self, client: AsyncClient, db_session: AsyncSession ) -> None: """Timestamps far in the future exceed the replay window and must be rejected.""" priv, pub = _keypair() identity = await _seed(db_session, "sec-future-ts", priv, pub) repo = await factory_create_repo( db_session, slug="sec-future-ts-repo", owner=identity.handle ) future_ts = int(time.time()) + REPLAY_WINDOW_SECONDS + 60 body = _minimal_push_body() path = f"/{repo.owner}/{repo.slug}/push/stream" auth = _msign_header(priv, identity.handle, "POST", path, b"", ts=future_ts) resp = await client.post( path, content=body, headers={"Content-Type": "application/x-muse-wire", "Authorization": auth}, ) assert resp.status_code == 401 # ══════════════════════════════════════════════════════════════════════════════ # 7. Performance # ══════════════════════════════════════════════════════════════════════════════ class TestMSignPerformance: """Latency budgets for signing primitives and the full verification path.""" def test_build_canonical_message_with_query_under_1ms(self) -> None: """``build_canonical_message`` with a query string must complete in under 1ms (median).""" body = b"some-request-body" samples = 500 times = [] for _ in range(samples): t0 = time.perf_counter_ns() build_canonical_message("POST", "/owner/repo/push?ref=main&format=json", 1700000000, body) times.append(time.perf_counter_ns() - t0) median_us = sorted(times)[samples // 2] / 1000 assert median_us < 1000, f"Canonical with query string: {median_us:.1f}µs (budget: 1ms)" def test_parse_header_under_50_microseconds_median(self) -> None: """``_parse_msign_header`` must complete in under 50µs (median over 500 calls).""" sig = b64url_encode(b"x" * 64) hdr = f'MSign handle="gabriel" alg="ed25519" ts=1700000042 sig="{sig}"' samples = 500 times = [] for _ in range(samples): t0 = time.perf_counter_ns() _parse_msign_header(hdr) times.append(time.perf_counter_ns() - t0) median_us = sorted(times)[samples // 2] / 1000 assert median_us < 50, f"parse_msign_header median: {median_us:.1f}µs (budget: 50µs)" def test_ed25519_sign_under_1ms_median(self) -> None: """Ed25519 signing must complete in under 1ms median on modern hardware.""" priv, _ = _keypair() message = build_canonical_message("POST", "/path", 1700000000, b"body") samples = 100 times = [] for _ in range(samples): t0 = time.perf_counter_ns() priv.sign(message) times.append(time.perf_counter_ns() - t0) median_us = sorted(times)[samples // 2] / 1000 assert median_us < 1000, f"Ed25519 sign median: {median_us:.1f}µs (budget: 1ms)" async def test_10_full_stack_verifications_under_2_seconds( self, client: AsyncClient, db_session: AsyncSession ) -> None: """10 full-stack GET /refs verifications (including DB query) complete in under 2s.""" priv, pub = _keypair() identity = await _seed(db_session, "perf-10req-user", priv, pub) repo = await factory_create_repo( db_session, slug="perf-10req-repo", owner=identity.handle, visibility="public" ) path = f"/{repo.owner}/{repo.slug}/refs" start = time.perf_counter() for _ in range(10): auth = _msign_header(priv, identity.handle, "GET", path, b"") r = await client.get(path, headers={"Authorization": auth}) assert r.status_code == 200 elapsed = time.perf_counter() - start assert elapsed < 2.0, f"10 GET verifications took {elapsed:.2f}s (budget: 2s)" async def test_multi_key_overhead_proportional( self, client: AsyncClient, db_session: AsyncSession ) -> None: """Identity with 3 keys must not be more than 3× slower than identity with 1 key.""" priv_1, pub_1 = _keypair() id_1 = await _seed(db_session, "perf-1k-user", priv_1, pub_1) repo_1 = await factory_create_repo( db_session, slug="perf-1k-repo", owner=id_1.handle, visibility="public" ) priv_3, pub_3 = _keypair() id_3 = await _seed(db_session, "perf-3k-user", priv_3, pub_3) for _ in range(2): _, pub_d = _keypair() db_session.add(MusehubAuthKey( key_id=_uid(), identity_id=id_3.identity_id, algorithm="ed25519", public_key_b64=encode_pubkey("ed25519", pub_d), fingerprint=key_fingerprint(pub_d), label="decoy", )) await db_session.commit() repo_3 = await factory_create_repo( db_session, slug="perf-3k-repo", owner=id_3.handle, visibility="public" ) path_1 = f"/{repo_1.owner}/{repo_1.slug}/refs" path_3 = f"/{repo_3.owner}/{repo_3.slug}/refs" # Warm up for priv, identity, path in [(priv_1, id_1, path_1), (priv_3, id_3, path_3)]: auth = _msign_header(priv, identity.handle, "GET", path, b"") await client.get(path, headers={"Authorization": auth}) n = 5 t0 = time.perf_counter() for _ in range(n): auth = _msign_header(priv_1, id_1.handle, "GET", path_1, b"") await client.get(path_1, headers={"Authorization": auth}) ms_1key = (time.perf_counter() - t0) * 1000 / n t0 = time.perf_counter() for _ in range(n): auth = _msign_header(priv_3, id_3.handle, "GET", path_3, b"") await client.get(path_3, headers={"Authorization": auth}) ms_3key = (time.perf_counter() - t0) * 1000 / n ratio = ms_3key / max(ms_1key, 1) assert ratio < 3, ( f"3-key is {ratio:.1f}× slower than 1-key ({ms_3key:.0f}ms vs {ms_1key:.0f}ms)" )