gabriel / musehub public
test_identity_repo_phase2.py python
271 lines 10.9 KB
Raw
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