gabriel / musehub public
test_identity_repo_phase2.py python
275 lines 11.1 KB
Raw
sha256:73f24973678ceefd56628ee17c6d0535d86e33533828af6c63348f50941515ad Merge branch 'fix/smoke-test-tag-label' into dev Human 15 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 12 commits
sha256:cfefc25a166c3c3eed8ea3529aee19ea350bc05f2954d007420e924133b7d8ce chore: pivot to nightly channel — bump version to 0.2.0.dev… Sonnet 5 patch 13 days ago
sha256:d035733f21ccff27735fddebfbbe0ed24565a32a22db8de5885402262671ecd2 chore: bump version to 0.2.0rc15 for musehub#113 fix release Sonnet 4.6 patch 15 days ago
sha256:0032d6cfa33bc3c8367436ad768e7dd0e339b4332153160247da8266cb5fa352 Merge branch 'task/version-tags-phase3-server' into dev Human 17 days ago
sha256:4669620efda9ff41c55bdefd1f7bfe1c239d468428744c84ead9957e5a003a53 merge: rescue snapshot-recovery hardening (c00aa21d) into d… Opus 4.8 minor 30 days ago
sha256:a59da49c4611b970fc4b6ae48678ce4943261c213a07ddbd73ce9201df869b4a fix: remove false-positive proposal_comments index drop fro… Sonnet 4.6 patch 34 days ago
sha256:0a240d6dbff234f07d98a28a4a9a68db702f3f9ff9260196f24219bdb1c0b6f3 feat: render markdown mists as HTML with heading anchor links Sonnet 4.6 patch 35 days ago
sha256:24a7d47486ebc4ebd1832830580e177ec6f877b48dced8c000e198cdec4ce9d6 Merge 'task/bump-version-rc12' into 'dev' — proposal: Bump … Human 36 days ago
sha256:b9ff931d147e0114a1f17060f415b89ed551c170a91ff226c70437aa5c85f9ee Merge 'task/bump-version-rc12' into 'dev' — proposal: Bump … Human 36 days ago
sha256:d1122d21e73471879b460037b22c0b50fded7c423444a176f248428f75dac39c Merge 'task/fix-issue-pagination-cursor' into 'dev' — propo… Human 36 days ago
sha256:01e18975e73d2b3cd5b6db7929c895bef9aa6e0d4391dc5b2adfc548b41318dd Merge 'feat/adding-debug-logs-to-staging' into 'dev' — prop… Human 36 days ago
sha256:6b1949fc2797ca4c1936a637a4cbfec828ef56cf52398a2e74ca3c4f494e728f fix: use wire_bytes not mpack_bytes_raw in compute_object_b… Sonnet 4.6 patch 48 days ago
sha256:b99f2455dc346966d040133f5203297e6e3ef5803a93728a2c30568d0a0f7583 rename: delta_add → delta_upsert across wire format, models… Sonnet 4.6 patch 50 days ago