gabriel / musehub public
test_identity_repo_phase2.py python
272 lines 11.0 KB
Raw
sha256:a34090cc4a394a78bd72cbbe34b08cc59525141e19135b6c0ab154f10611b9ef debug(push/stream): instrument O-frame decode path with INF… Sonnet 4.6 patch 121 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 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[db.MusehubCommit]:
92 """Return all commits on the identity repo's main branch, ordered by timestamp."""
93 repo_result = await session.execute(
94 select(db.MusehubRepo).where(
95 db.MusehubRepo.owner == handle,
96 db.MusehubRepo.slug == "identity",
97 )
98 )
99 repo = repo_result.scalar_one()
100
101 commits_result = await session.execute(
102 select(db.MusehubCommit).where(
103 db.MusehubCommit.repo_id == repo.repo_id,
104 db.MusehubCommit.branch == "main",
105 ).order_by(db.MusehubCommit.timestamp)
106 )
107 return list(commits_result.scalars().all())
108
109
110 async def _read_identity_record_from_commit(
111 session: AsyncSession, commit: db.MusehubCommit
112 ) -> JSONObject:
113 """Read the identity record from a specific commit's snapshot."""
114 import msgpack
115 from muse.plugins.identity.records import identity_path as _ip
116 from pathlib import Path
117
118 snap_result = await session.execute(
119 select(db.MusehubSnapshot).where(
120 db.MusehubSnapshot.snapshot_id == commit.snapshot_id
121 )
122 )
123 snap = snap_result.scalar_one()
124 manifest: JSONObject = msgpack.unpackb(snap.manifest_blob, raw=False)
125
126 # identity_path uses the handle embedded in the commit's repo — derive from manifest
127 file_path = next(k for k in manifest if k.startswith("identities/") and k.endswith(".json"))
128 object_id = manifest[file_path]
129
130 obj_result = await session.execute(
131 select(db.MusehubObject).where(db.MusehubObject.object_id == object_id)
132 )
133 obj = obj_result.scalar_one()
134
135 disk_uri = obj.disk_path or obj.storage_uri or ""
136 raw = Path(disk_uri.removeprefix("local://")).read_bytes()
137 return json.loads(raw)
138
139
140 # ═══════════════════════════════════════════════════════════════════════════════
141 # 1. Rotation creates a new commit
142 # ═══════════════════════════════════════════════════════════════════════════════
143
144
145 class TestRotationCreatesCommit:
146 async def test_rotation_adds_second_commit_to_identity_repo(
147 self, db_session: AsyncSession
148 ) -> None:
149 """add_key_for_identity must commit a new revision to the identity repo."""
150 priv, pub = _keypair()
151 identity_id = await _register(db_session, "alice2", priv, pub)
152
153 new_priv, new_pub = _keypair()
154 await _rotate(db_session, identity_id, new_priv, new_pub)
155
156 commits = await _get_identity_commits(db_session, "alice2")
157 assert len(commits) == 2, (
158 f"Expected 2 commits on identity repo after one rotation, got {len(commits)}."
159 )
160
161 async def test_second_rotation_adds_third_commit(
162 self, db_session: AsyncSession
163 ) -> None:
164 """Each rotation produces its own commit — history grows linearly."""
165 priv, pub = _keypair()
166 identity_id = await _register(db_session, "bob2", priv, pub)
167
168 new_priv1, new_pub1 = _keypair()
169 await _rotate(db_session, identity_id, new_priv1, new_pub1)
170
171 new_priv2, new_pub2 = _keypair()
172 await _rotate(db_session, identity_id, new_priv2, new_pub2)
173
174 commits = await _get_identity_commits(db_session, "bob2")
175 assert len(commits) == 3, (
176 f"Expected 3 commits after two rotations, got {len(commits)}."
177 )
178
179 async def test_rotation_commit_message_identifies_handle(
180 self, db_session: AsyncSession
181 ) -> None:
182 """Rotation commit message must contain the handle for audit readability."""
183 priv, pub = _keypair()
184 identity_id = await _register(db_session, "carol2", priv, pub)
185
186 new_priv, new_pub = _keypair()
187 await _rotate(db_session, identity_id, new_priv, new_pub)
188
189 commits = await _get_identity_commits(db_session, "carol2")
190 rotation_commit = commits[1]
191 assert "carol2" in rotation_commit.message, (
192 f"Rotation commit message {rotation_commit.message!r} must contain the handle 'carol2'."
193 )
194
195
196 # ═══════════════════════════════════════════════════════════════════════════════
197 # 2. Rotation updates pubkey in the identity record
198 # ═══════════════════════════════════════════════════════════════════════════════
199
200
201 class TestRotationUpdatesPubkey:
202 async def test_rotation_updates_pubkey_in_identity_record(
203 self, db_session: AsyncSession
204 ) -> None:
205 """The identity record after rotation must reflect the new public key."""
206 priv, pub = _keypair()
207 identity_id = await _register(db_session, "dave2", priv, pub)
208
209 new_priv, new_pub = _keypair()
210 new_pubkey_b64 = encode_pubkey("ed25519", new_pub)
211 await _rotate(db_session, identity_id, new_priv, new_pub)
212
213 commits = await _get_identity_commits(db_session, "dave2")
214 latest_record = await _read_identity_record_from_commit(db_session, commits[-1])
215 assert latest_record["pubkey"] == new_pubkey_b64, (
216 f"After rotation, identity record pubkey should be {new_pubkey_b64!r}, "
217 f"got {latest_record['pubkey']!r}."
218 )
219
220 async def test_initial_record_pubkey_unchanged_in_history(
221 self, db_session: AsyncSession
222 ) -> None:
223 """The initial commit must still hold the original pubkey (immutable history)."""
224 priv, pub = _keypair()
225 original_pubkey_b64 = encode_pubkey("ed25519", pub)
226 identity_id = await _register(db_session, "eve2", priv, pub)
227
228 new_priv, new_pub = _keypair()
229 await _rotate(db_session, identity_id, new_priv, new_pub)
230
231 commits = await _get_identity_commits(db_session, "eve2")
232 initial_record = await _read_identity_record_from_commit(db_session, commits[0])
233 assert initial_record["pubkey"] == original_pubkey_b64, (
234 "The initial commit must be immutable — original pubkey must still be present "
235 f"in commit[0], got {initial_record['pubkey']!r}."
236 )
237
238 async def test_rotation_preserves_handle_and_type(
239 self, db_session: AsyncSession
240 ) -> None:
241 """Rotation must not clobber handle or type in the identity record."""
242 priv, pub = _keypair()
243 identity_id = await _register(db_session, "frank2", priv, pub)
244
245 new_priv, new_pub = _keypair()
246 await _rotate(db_session, identity_id, new_priv, new_pub)
247
248 commits = await _get_identity_commits(db_session, "frank2")
249 latest_record = await _read_identity_record_from_commit(db_session, commits[-1])
250 assert latest_record["handle"] == "frank2"
251 assert latest_record["type"] == "human"
252
253 async def test_second_rotation_reflects_latest_pubkey(
254 self, db_session: AsyncSession
255 ) -> None:
256 """After two rotations, HEAD must have the second rotation's pubkey."""
257 priv, pub = _keypair()
258 identity_id = await _register(db_session, "grace2", priv, pub)
259
260 new_priv1, new_pub1 = _keypair()
261 await _rotate(db_session, identity_id, new_priv1, new_pub1)
262
263 new_priv2, new_pub2 = _keypair()
264 final_pubkey_b64 = encode_pubkey("ed25519", new_pub2)
265 await _rotate(db_session, identity_id, new_priv2, new_pub2)
266
267 commits = await _get_identity_commits(db_session, "grace2")
268 latest_record = await _read_identity_record_from_commit(db_session, commits[-1])
269 assert latest_record["pubkey"] == final_pubkey_b64, (
270 f"After two rotations, identity record pubkey should be the second "
271 f"rotation key {final_pubkey_b64!r}, got {latest_record['pubkey']!r}."
272 )
File History 1 commit
sha256:a34090cc4a394a78bd72cbbe34b08cc59525141e19135b6c0ab154f10611b9ef debug(push/stream): instrument O-frame decode path with INF… Sonnet 4.6 patch 121 days ago