gabriel / musehub public
test_identity_repo_phase2.py python
275 lines 11.1 KB
Raw
sha256:0997d6250ae6476362f6fe2025af7789f46d03df3e9f34356d5e8ee79b201923 fix(issues): use issue number as pagination cursor, not cre… Sonnet 4.6 patch 8 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 1 commit
sha256:0997d6250ae6476362f6fe2025af7789f46d03df3e9f34356d5e8ee79b201923 fix(issues): use issue number as pagination cursor, not cre… Sonnet 4.6 patch 8 days ago