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