test_identity_repo_phase2.py
python
sha256:bb2baaabdd19320bde50cb69d447fd1c1e571467df729be1a23b5e93064b046a
feat(intel): standardize headers, gauge icon, velocity card…
Sonnet 4.6
minor
⚠ breaking
142 days ago
| 1 | """Phase 2 — Key rotation commits to the identity repo. |
| 2 | |
| 3 | TDD regression suite: every test starts RED and turns GREEN as the feature |
| 4 | is implemented. Their permanent role is to prevent regressions. |
| 5 | |
| 6 | What this phase covers: |
| 7 | - add_key_for_identity() creates a new commit on the identity repo |
| 8 | - The new commit updates identities/{handle}.json with the rotated pubkey |
| 9 | - The commit history grows (2 commits: initial registration + rotation) |
| 10 | - A second rotation produces a third commit (each rotation is its own commit) |
| 11 | - The commit message follows the "identity: rotate key for {handle}" pattern |
| 12 | """ |
| 13 | from __future__ import annotations |
| 14 | |
| 15 | import json |
| 16 | |
| 17 | import pytest |
| 18 | from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey |
| 19 | from muse.core.types import encode_pubkey, encode_sig |
| 20 | from sqlalchemy import select |
| 21 | from sqlalchemy.ext.asyncio import AsyncSession |
| 22 | |
| 23 | from musehub.crypto.keys import b64url_encode, key_fingerprint |
| 24 | from musehub.db import musehub_models as db |
| 25 | from musehub.services.musehub_auth import ( |
| 26 | add_key_for_identity, |
| 27 | create_challenge, |
| 28 | verify_and_authenticate, |
| 29 | ) |
| 30 | |
| 31 | |
| 32 | # ── helpers ─────────────────────────────────────────────────────────────────── |
| 33 | |
| 34 | |
| 35 | def _keypair() -> tuple[Ed25519PrivateKey, bytes]: |
| 36 | priv = Ed25519PrivateKey.generate() |
| 37 | pub = priv.public_key().public_bytes_raw() |
| 38 | return priv, pub |
| 39 | |
| 40 | |
| 41 | def _sign_nonce(priv: Ed25519PrivateKey, nonce_hex: str) -> str: |
| 42 | sig_bytes = priv.sign(bytes.fromhex(nonce_hex)) |
| 43 | return encode_sig("ed25519", sig_bytes) |
| 44 | |
| 45 | |
| 46 | async def _register( |
| 47 | session: AsyncSession, |
| 48 | handle: str, |
| 49 | priv: Ed25519PrivateKey, |
| 50 | pub: bytes, |
| 51 | ) -> str: |
| 52 | """Full challenge → verify flow; returns identity_id.""" |
| 53 | public_key_b64 = encode_pubkey("ed25519", pub) |
| 54 | fp = key_fingerprint(pub) |
| 55 | nonce = await create_challenge(session, fingerprint=fp, algorithm="ed25519") |
| 56 | sig_b64 = _sign_nonce(priv, nonce) |
| 57 | result = await verify_and_authenticate( |
| 58 | session=session, |
| 59 | challenge_token=nonce, |
| 60 | public_key_b64=public_key_b64, |
| 61 | signature_b64=sig_b64, |
| 62 | handle=handle, |
| 63 | display_name=handle, |
| 64 | label="initial-key", |
| 65 | ) |
| 66 | return result.identity_id |
| 67 | |
| 68 | |
| 69 | async def _rotate( |
| 70 | session: AsyncSession, |
| 71 | identity_id: str, |
| 72 | new_priv: Ed25519PrivateKey, |
| 73 | new_pub: bytes, |
| 74 | ) -> None: |
| 75 | """Full challenge → add_key_for_identity flow for key rotation.""" |
| 76 | public_key_b64 = encode_pubkey("ed25519", new_pub) |
| 77 | fp = key_fingerprint(new_pub) |
| 78 | nonce = await create_challenge(session, fingerprint=fp, algorithm="ed25519") |
| 79 | sig_b64 = _sign_nonce(new_priv, nonce) |
| 80 | await add_key_for_identity( |
| 81 | session=session, |
| 82 | identity_id=identity_id, |
| 83 | challenge_token=nonce, |
| 84 | public_key_b64=public_key_b64, |
| 85 | signature_b64=sig_b64, |
| 86 | label="rotated-key", |
| 87 | ) |
| 88 | |
| 89 | |
| 90 | async def _get_identity_commits(session: AsyncSession, handle: str) -> list[db.MusehubCommit]: |
| 91 | """Return all commits on the identity repo's main branch, ordered by timestamp.""" |
| 92 | repo_result = await session.execute( |
| 93 | select(db.MusehubRepo).where( |
| 94 | db.MusehubRepo.owner == handle, |
| 95 | db.MusehubRepo.slug == "identity", |
| 96 | ) |
| 97 | ) |
| 98 | repo = repo_result.scalar_one() |
| 99 | |
| 100 | commits_result = await session.execute( |
| 101 | select(db.MusehubCommit).where( |
| 102 | db.MusehubCommit.repo_id == repo.repo_id, |
| 103 | db.MusehubCommit.branch == "main", |
| 104 | ).order_by(db.MusehubCommit.timestamp) |
| 105 | ) |
| 106 | return list(commits_result.scalars().all()) |
| 107 | |
| 108 | |
| 109 | async def _read_identity_record_from_commit( |
| 110 | session: AsyncSession, commit: db.MusehubCommit |
| 111 | ) -> dict: |
| 112 | """Read the identity record from a specific commit's snapshot.""" |
| 113 | import msgpack |
| 114 | from muse.plugins.identity.records import identity_path as _ip |
| 115 | from pathlib import Path |
| 116 | |
| 117 | snap_result = await session.execute( |
| 118 | select(db.MusehubSnapshot).where( |
| 119 | db.MusehubSnapshot.snapshot_id == commit.snapshot_id |
| 120 | ) |
| 121 | ) |
| 122 | snap = snap_result.scalar_one() |
| 123 | manifest: dict = msgpack.unpackb(snap.manifest_blob, raw=False) |
| 124 | |
| 125 | # identity_path uses the handle embedded in the commit's repo — derive from manifest |
| 126 | file_path = next(k for k in manifest if k.startswith("identities/") and k.endswith(".json")) |
| 127 | object_id = manifest[file_path] |
| 128 | |
| 129 | obj_result = await session.execute( |
| 130 | select(db.MusehubObject).where(db.MusehubObject.object_id == object_id) |
| 131 | ) |
| 132 | obj = obj_result.scalar_one() |
| 133 | |
| 134 | disk_uri = obj.disk_path or obj.storage_uri or "" |
| 135 | raw = Path(disk_uri.removeprefix("local://")).read_bytes() |
| 136 | return json.loads(raw) |
| 137 | |
| 138 | |
| 139 | # ═══════════════════════════════════════════════════════════════════════════════ |
| 140 | # 1. Rotation creates a new commit |
| 141 | # ═══════════════════════════════════════════════════════════════════════════════ |
| 142 | |
| 143 | |
| 144 | class TestRotationCreatesCommit: |
| 145 | async def test_rotation_adds_second_commit_to_identity_repo( |
| 146 | self, db_session: AsyncSession |
| 147 | ) -> None: |
| 148 | """add_key_for_identity must commit a new revision to the identity repo.""" |
| 149 | priv, pub = _keypair() |
| 150 | identity_id = await _register(db_session, "alice2", priv, pub) |
| 151 | |
| 152 | new_priv, new_pub = _keypair() |
| 153 | await _rotate(db_session, identity_id, new_priv, new_pub) |
| 154 | |
| 155 | commits = await _get_identity_commits(db_session, "alice2") |
| 156 | assert len(commits) == 2, ( |
| 157 | f"Expected 2 commits on identity repo after one rotation, got {len(commits)}." |
| 158 | ) |
| 159 | |
| 160 | async def test_second_rotation_adds_third_commit( |
| 161 | self, db_session: AsyncSession |
| 162 | ) -> None: |
| 163 | """Each rotation produces its own commit — history grows linearly.""" |
| 164 | priv, pub = _keypair() |
| 165 | identity_id = await _register(db_session, "bob2", priv, pub) |
| 166 | |
| 167 | new_priv1, new_pub1 = _keypair() |
| 168 | await _rotate(db_session, identity_id, new_priv1, new_pub1) |
| 169 | |
| 170 | new_priv2, new_pub2 = _keypair() |
| 171 | await _rotate(db_session, identity_id, new_priv2, new_pub2) |
| 172 | |
| 173 | commits = await _get_identity_commits(db_session, "bob2") |
| 174 | assert len(commits) == 3, ( |
| 175 | f"Expected 3 commits after two rotations, got {len(commits)}." |
| 176 | ) |
| 177 | |
| 178 | async def test_rotation_commit_message_identifies_handle( |
| 179 | self, db_session: AsyncSession |
| 180 | ) -> None: |
| 181 | """Rotation commit message must contain the handle for audit readability.""" |
| 182 | priv, pub = _keypair() |
| 183 | identity_id = await _register(db_session, "carol2", priv, pub) |
| 184 | |
| 185 | new_priv, new_pub = _keypair() |
| 186 | await _rotate(db_session, identity_id, new_priv, new_pub) |
| 187 | |
| 188 | commits = await _get_identity_commits(db_session, "carol2") |
| 189 | rotation_commit = commits[1] |
| 190 | assert "carol2" in rotation_commit.message, ( |
| 191 | f"Rotation commit message {rotation_commit.message!r} must contain the handle 'carol2'." |
| 192 | ) |
| 193 | |
| 194 | |
| 195 | # ═══════════════════════════════════════════════════════════════════════════════ |
| 196 | # 2. Rotation updates pubkey in the identity record |
| 197 | # ═══════════════════════════════════════════════════════════════════════════════ |
| 198 | |
| 199 | |
| 200 | class TestRotationUpdatesPubkey: |
| 201 | async def test_rotation_updates_pubkey_in_identity_record( |
| 202 | self, db_session: AsyncSession |
| 203 | ) -> None: |
| 204 | """The identity record after rotation must reflect the new public key.""" |
| 205 | priv, pub = _keypair() |
| 206 | identity_id = await _register(db_session, "dave2", priv, pub) |
| 207 | |
| 208 | new_priv, new_pub = _keypair() |
| 209 | new_pubkey_b64 = encode_pubkey("ed25519", new_pub) |
| 210 | await _rotate(db_session, identity_id, new_priv, new_pub) |
| 211 | |
| 212 | commits = await _get_identity_commits(db_session, "dave2") |
| 213 | latest_record = await _read_identity_record_from_commit(db_session, commits[-1]) |
| 214 | assert latest_record["pubkey"] == new_pubkey_b64, ( |
| 215 | f"After rotation, identity record pubkey should be {new_pubkey_b64!r}, " |
| 216 | f"got {latest_record['pubkey']!r}." |
| 217 | ) |
| 218 | |
| 219 | async def test_initial_record_pubkey_unchanged_in_history( |
| 220 | self, db_session: AsyncSession |
| 221 | ) -> None: |
| 222 | """The initial commit must still hold the original pubkey (immutable history).""" |
| 223 | priv, pub = _keypair() |
| 224 | original_pubkey_b64 = encode_pubkey("ed25519", pub) |
| 225 | identity_id = await _register(db_session, "eve2", priv, pub) |
| 226 | |
| 227 | new_priv, new_pub = _keypair() |
| 228 | await _rotate(db_session, identity_id, new_priv, new_pub) |
| 229 | |
| 230 | commits = await _get_identity_commits(db_session, "eve2") |
| 231 | initial_record = await _read_identity_record_from_commit(db_session, commits[0]) |
| 232 | assert initial_record["pubkey"] == original_pubkey_b64, ( |
| 233 | "The initial commit must be immutable — original pubkey must still be present " |
| 234 | f"in commit[0], got {initial_record['pubkey']!r}." |
| 235 | ) |
| 236 | |
| 237 | async def test_rotation_preserves_handle_and_type( |
| 238 | self, db_session: AsyncSession |
| 239 | ) -> None: |
| 240 | """Rotation must not clobber handle or type in the identity record.""" |
| 241 | priv, pub = _keypair() |
| 242 | identity_id = await _register(db_session, "frank2", priv, pub) |
| 243 | |
| 244 | new_priv, new_pub = _keypair() |
| 245 | await _rotate(db_session, identity_id, new_priv, new_pub) |
| 246 | |
| 247 | commits = await _get_identity_commits(db_session, "frank2") |
| 248 | latest_record = await _read_identity_record_from_commit(db_session, commits[-1]) |
| 249 | assert latest_record["handle"] == "frank2" |
| 250 | assert latest_record["type"] == "human" |
| 251 | |
| 252 | async def test_second_rotation_reflects_latest_pubkey( |
| 253 | self, db_session: AsyncSession |
| 254 | ) -> None: |
| 255 | """After two rotations, HEAD must have the second rotation's pubkey.""" |
| 256 | priv, pub = _keypair() |
| 257 | identity_id = await _register(db_session, "grace2", priv, pub) |
| 258 | |
| 259 | new_priv1, new_pub1 = _keypair() |
| 260 | await _rotate(db_session, identity_id, new_priv1, new_pub1) |
| 261 | |
| 262 | new_priv2, new_pub2 = _keypair() |
| 263 | final_pubkey_b64 = encode_pubkey("ed25519", new_pub2) |
| 264 | await _rotate(db_session, identity_id, new_priv2, new_pub2) |
| 265 | |
| 266 | commits = await _get_identity_commits(db_session, "grace2") |
| 267 | latest_record = await _read_identity_record_from_commit(db_session, commits[-1]) |
| 268 | assert latest_record["pubkey"] == final_pubkey_b64, ( |
| 269 | f"After two rotations, identity record pubkey should be the second " |
| 270 | f"rotation key {final_pubkey_b64!r}, got {latest_record['pubkey']!r}." |
| 271 | ) |
File History
1 commit
sha256:bb2baaabdd19320bde50cb69d447fd1c1e571467df729be1a23b5e93064b046a
feat(intel): standardize headers, gauge icon, velocity card…
Sonnet 4.6
minor
⚠
142 days ago