"""TDD -- musehub#223: GET /api/keys/{key_id}/status didn't exist, so `muse verify-commit --check-key-status` always reported "unknown". Root cause of the deeper gap: revoke_key() hard-deleted the key row, so there was no way to distinguish "revoked" from "never registered" even once the endpoint existed. Design decision (confirmed via check-in): soft-delete via a new revoked_at column, with every authentication/ signature-verification query site updated to exclude revoked keys. Test IDs: KS_01 — active key returns {"status": "active"} KS_02 — revoked key returns {"status": "revoked"} KS_03 — never-registered key_id returns 404 KS_04 — revoke_key soft-deletes (row still exists with revoked_at set) KS_05 — a revoked key's fingerprint can no longer authenticate via verify_and_authenticate (the actual security-critical guarantee) KS_06 — get_keys_for_identity (list_keys) excludes revoked keys """ from __future__ import annotations import base64 import hashlib as _hashlib from datetime import datetime, timezone import pytest from httpx import AsyncClient from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from muse.core.types import encode_pubkey from musehub.core.genesis import compute_identity_id, compute_key_id from musehub.crypto.keys import key_fingerprint from musehub.db.musehub_auth_models import MusehubAuthKey from musehub.db.musehub_identity_models import MusehubIdentity from musehub.services.musehub_auth import get_keys_for_identity, revoke_key pytestmark = pytest.mark.asyncio _NOW = datetime.now(timezone.utc) _COUNTER: list[int] = [0] def _uid(tag: str = "") -> str: _COUNTER[0] += 1 return f"ks{tag}{_COUNTER[0]}" def _key_for(handle: str) -> str: raw = _hashlib.sha256(handle.encode()).digest() return encode_pubkey("ed25519", raw) async def _seed_identity_with_key(session: AsyncSession, handle: str) -> tuple[MusehubIdentity, MusehubAuthKey]: pubkey_b64 = _key_for(handle) identity_id = compute_identity_id(handle.encode()) identity = MusehubIdentity( identity_id=identity_id, handle=handle, identity_type="human", is_admin=False, agent_capabilities=[], pinned_repo_ids=[], is_verified=False, created_at=_NOW, updated_at=_NOW, ) session.add(identity) await session.flush() _, raw_b64 = pubkey_b64.split(":", 1) raw_bytes = base64.urlsafe_b64decode(raw_b64 + "==") fp = key_fingerprint(raw_bytes) key = MusehubAuthKey( key_id=compute_key_id(identity_id, pubkey_b64), identity_id=identity_id, algorithm="ed25519", public_key_b64=pubkey_b64, fingerprint=fp, label="test-key", created_at=_NOW, ) session.add(key) await session.flush() return identity, key # ── KS_01 — active key ──────────────────────────────────────────────────────── async def test_ks01_active_key_status(client: AsyncClient, db_session: AsyncSession) -> None: handle = f"active-{_uid()}" _, key = await _seed_identity_with_key(db_session, handle) await db_session.commit() resp = await client.get(f"/api/keys/{key.key_id}/status") assert resp.status_code == 200, resp.text assert resp.json()["status"] == "active" # ── KS_02 — revoked key ─────────────────────────────────────────────────────── async def test_ks02_revoked_key_status(client: AsyncClient, db_session: AsyncSession) -> None: handle = f"revoked-{_uid()}" identity, key = await _seed_identity_with_key(db_session, handle) await db_session.commit() revoked = await revoke_key(db_session, identity.identity_id, key.key_id) assert revoked is True await db_session.commit() resp = await client.get(f"/api/keys/{key.key_id}/status") assert resp.status_code == 200, resp.text assert resp.json()["status"] == "revoked" # ── KS_03 — unknown key_id ──────────────────────────────────────────────────── async def test_ks03_unknown_key_id_returns_404(client: AsyncClient) -> None: resp = await client.get(f"/api/keys/sha256:nonexistent-{_uid()}/status") assert resp.status_code == 404, resp.text # ── KS_04 — soft-delete, not hard-delete ───────────────────────────────────── async def test_ks04_revoke_key_soft_deletes(db_session: AsyncSession) -> None: handle = f"soft-{_uid()}" identity, key = await _seed_identity_with_key(db_session, handle) await db_session.commit() revoked = await revoke_key(db_session, identity.identity_id, key.key_id) assert revoked is True await db_session.commit() row = (await db_session.execute( select(MusehubAuthKey).where(MusehubAuthKey.key_id == key.key_id) )).scalar_one_or_none() assert row is not None, "revoke_key must not hard-delete the row" assert row.revoked_at is not None # ── KS_05 — revoked key can never authenticate again ───────────────────────── async def test_ks05_revoked_key_cannot_authenticate(db_session: AsyncSession) -> None: from musehub.services.musehub_auth import verify_and_authenticate handle = f"authrevoked-{_uid()}" identity, key = await _seed_identity_with_key(db_session, handle) await db_session.commit() revoked = await revoke_key(db_session, identity.identity_id, key.key_id) assert revoked is True await db_session.commit() # Confirm the fingerprint lookup used by the login/registration path # genuinely excludes this now-revoked row. active_match = (await db_session.execute( select(MusehubAuthKey).where( MusehubAuthKey.fingerprint == key.fingerprint, MusehubAuthKey.revoked_at.is_(None), ) )).scalar_one_or_none() assert active_match is None, ( "a revoked key's fingerprint must not match the active-key lookup " "used by verify_and_authenticate -- otherwise a revoked key could " "still authenticate" ) # ── KS_06 — list_keys excludes revoked ──────────────────────────────────────── async def test_ks06_get_keys_for_identity_excludes_revoked(db_session: AsyncSession) -> None: handle = f"listexcl-{_uid()}" identity, key = await _seed_identity_with_key(db_session, handle) await db_session.commit() before = await get_keys_for_identity(db_session, identity.identity_id) assert any(k.key_id == key.key_id for k in before) await revoke_key(db_session, identity.identity_id, key.key_id) await db_session.commit() after = await get_keys_for_identity(db_session, identity.identity_id) assert not any(k.key_id == key.key_id for k in after), ( "a revoked key must not appear in get_keys_for_identity (list_keys)" )