gabriel / musehub public
test_commit_signature_verification.py python
553 lines 20.6 KB
Raw
sha256:bb2baaabdd19320bde50cb69d447fd1c1e571467df729be1a23b5e93064b046a feat(intel): standardize headers, gauge icon, velocity card… Sonnet 4.6 minor ⚠ breaking 142 days ago
1 """TDD — Server-side Ed25519 commit signature verification.
2
3 Security requirement: when a commit arrives with a non-empty ``signature``
4 AND a non-empty ``signer_public_key``, the server MUST cryptographically
5 verify the Ed25519 signature before accepting the push. A presence-only
6 check is not sufficient — a pusher who passes MSign auth could forge any
7 signature string and the server would store it as verified provenance.
8
9 Test IDs
10 --------
11 SV1 Valid signature → push accepted
12 SV2 Forged (garbage) signature bytes → push rejected 422
13 SV3 Valid signature but wrong public key declared → push rejected 422
14 SV4 signature present, signer_public_key empty → push rejected 422
15 SV5 Unsigned commit (require_signed_commits=False) → accepted (regression guard)
16 SV6 Unsigned commit (require_signed_commits=True) → rejected (regression guard)
17 SV7 Mixed batch: one valid + one forged → entire push rejected
18 """
19 from __future__ import annotations
20
21 import hashlib
22 from datetime import datetime, timezone
23 from unittest.mock import AsyncMock
24
25 import msgpack
26 import pytest
27 from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
28 from httpx import AsyncClient
29 from sqlalchemy.ext.asyncio import AsyncSession
30
31 from muse.core.mpack import WIRE_CONTENT_TYPE
32 from muse.core.provenance import (
33 encode_public_key,
34 provenance_payload,
35 sign_commit_ed25519,
36 sign_commit_record,
37 )
38 from muse.core.types import encode_pubkey, public_key_fingerprint
39 from musehub.db.musehub_models import MusehubBranch, MusehubRepo
40 from musehub.core.genesis import compute_branch_id, compute_identity_id, compute_repo_id
41 from musehub.models.wire import (
42 SFRAME_COMMIT_PACK,
43 SFRAME_END,
44 SFRAME_ERROR,
45 SFRAME_HEADER,
46 SFRAME_RESULT,
47 )
48 from muse.core.mpack import MuseWireFrameWriter
49 from musehub.types.json_types import JSONObject, StrDict
50
51 _fw = MuseWireFrameWriter()
52
53
54 # ---------------------------------------------------------------------------
55 # Frame helpers (mirrors test_wire_push_stream.py conventions)
56 # ---------------------------------------------------------------------------
57
58 def _sha256_oid(raw: bytes) -> str:
59 return "sha256:" + hashlib.sha256(raw).hexdigest()
60
61
62 def _utc() -> str:
63 return datetime.now(tz=timezone.utc).isoformat()
64
65
66 def _pack(data: JSONObject) -> bytes:
67 return msgpack.packb(data, use_bin_type=True)
68
69
70 def _wrap(ft: str, data: JSONObject) -> bytes:
71 return _fw.wrap(frame_type=ft, payload=_pack(data))
72
73
74 def _header_frame(*, branch: str = "main", n_objects: int = 0, n_commits: int = 0) -> bytes:
75 return _wrap(SFRAME_HEADER, {
76 "t": SFRAME_HEADER, "branch": branch, "force": False,
77 "have": [], "n_objects": n_objects, "n_commits": n_commits,
78 })
79
80
81 def _commit_pack_frame(commits: list[dict], snapshots: list[dict] | None = None) -> bytes:
82 return _wrap(SFRAME_COMMIT_PACK, {
83 "t": SFRAME_COMMIT_PACK,
84 "commits": commits,
85 "snapshots": snapshots or [],
86 "snapshot_deltas": [],
87 })
88
89
90 def _end_frame(n_objects: int = 0, n_commits: int = 0) -> bytes:
91 return _wrap(SFRAME_END, {"t": SFRAME_END, "n_objects": n_objects, "n_commits": n_commits})
92
93
94 # ---------------------------------------------------------------------------
95 # Ed25519 test key generation
96 # ---------------------------------------------------------------------------
97
98 def _new_keypair() -> tuple[Ed25519PrivateKey, bytes, str]:
99 """Return (private_key, raw_pub_bytes, encoded_pub_str)."""
100 private_key = Ed25519PrivateKey.generate()
101 raw_bytes, pub_str = encode_public_key(private_key)
102 return private_key, raw_bytes, pub_str
103
104
105 # ---------------------------------------------------------------------------
106 # Commit builder with optional real Ed25519 signature
107 # ---------------------------------------------------------------------------
108
109 def _make_commit(
110 *,
111 commit_id: str | None = None,
112 snapshot_id: str | None = None,
113 branch: str = "main",
114 author: str = "gabriel",
115 committed_at: str | None = None,
116 private_key: Ed25519PrivateKey | None = None,
117 # Override specific wire fields after signing:
118 override_signature: str | None = None,
119 override_signer_public_key: str | None = None,
120 override_signer_key_id: str | None = None,
121 ) -> JSONObject:
122 ts = committed_at or _utc()
123 cid = commit_id or _sha256_oid(f"sv-commit-{ts}".encode())
124 snap_id = snapshot_id or _sha256_oid(b"sv-default-snap")
125
126 commit: JSONObject = {
127 "commit_id": cid,
128 "parent_commit_id": None,
129 "parent2_commit_id": None,
130 "snapshot_id": snap_id,
131 "branch": branch,
132 "message": "test commit",
133 "author": author,
134 "committed_at": ts,
135 "signature": "",
136 "signer_public_key": "",
137 "signer_key_id": "",
138 "agent_id": "claude-code",
139 "model_id": "claude-sonnet-4-6",
140 "metadata": {},
141 }
142
143 if private_key is not None:
144 result = sign_commit_record(
145 cid, "claude-code", private_key,
146 author=author, model_id="claude-sonnet-4-6", committed_at=ts,
147 )
148 assert result is not None
149 sig, pub_b64, key_id = result
150 commit["signature"] = sig
151 commit["signer_public_key"] = pub_b64
152 commit["signer_key_id"] = key_id
153
154 # Overrides for adversarial tests
155 if override_signature is not None:
156 commit["signature"] = override_signature
157 if override_signer_public_key is not None:
158 commit["signer_public_key"] = override_signer_public_key
159 if override_signer_key_id is not None:
160 commit["signer_key_id"] = override_signer_key_id
161
162 return commit
163
164
165 def _make_snapshot(snapshot_id: str) -> JSONObject:
166 return {"snapshot_id": snapshot_id, "manifest": {}, "committed_at": _utc()}
167
168
169 # ---------------------------------------------------------------------------
170 # DB + backend helpers
171 # ---------------------------------------------------------------------------
172
173 async def _make_repo(
174 db_session: AsyncSession, name: str, owner: str = "testuser",
175 ) -> MusehubRepo:
176 owner_user_id = compute_identity_id(owner.encode())
177 slug = name.lower().replace(" ", "-")
178 created_at = datetime.now(tz=timezone.utc)
179 repo_id = compute_repo_id(owner_user_id, slug, "code", created_at.isoformat())
180 repo = MusehubRepo(
181 repo_id=repo_id, name=name, owner=owner, slug=slug,
182 visibility="public", owner_user_id=owner_user_id,
183 description="", tags=[], created_at=created_at,
184 )
185 db_session.add(repo)
186 await db_session.commit()
187 branch = MusehubBranch(
188 branch_id=compute_branch_id(repo_id, "main"),
189 repo_id=repo_id, name="main",
190 )
191 db_session.add(branch)
192 await db_session.commit()
193 await db_session.refresh(repo)
194 return repo
195
196
197 def _stub_r2(monkeypatch: pytest.MonkeyPatch) -> None:
198 _store: dict[str, bytes] = {}
199 backend = AsyncMock()
200 backend.exists = AsyncMock(side_effect=lambda oid: oid in _store)
201 backend.put = AsyncMock(side_effect=lambda oid, data: _store.update({oid: data}) or f"r2://{oid}")
202 backend.get = AsyncMock(side_effect=lambda oid: _store.get(oid))
203 monkeypatch.setattr("musehub.services.musehub_wire.get_backend", lambda: backend)
204
205
206 def _decode_result(resp_content: bytes) -> JSONObject:
207 unpacker = msgpack.Unpacker(raw=False)
208 unpacker.feed(resp_content)
209 last: JSONObject = {}
210 for frame in unpacker:
211 last = frame
212 return last
213
214
215 # ---------------------------------------------------------------------------
216 # SV1 — Valid signature: push accepted
217 # ---------------------------------------------------------------------------
218
219 @pytest.mark.asyncio
220 async def test_sv1_valid_signature_accepted(
221 client: AsyncClient, db_session: AsyncSession,
222 auth_headers: StrDict, monkeypatch: pytest.MonkeyPatch,
223 ) -> None:
224 """SV1: A commit with a real Ed25519 signature is accepted by the server."""
225 _stub_r2(monkeypatch)
226 repo = await _make_repo(db_session, "SV1 Valid Sig", owner="testuser")
227
228 private_key, _, _ = _new_keypair()
229 snap_id = _sha256_oid(b"sv1-snap")
230 commit = _make_commit(snapshot_id=snap_id, private_key=private_key)
231 snap = _make_snapshot(snap_id)
232 body = _header_frame(n_commits=1) + _commit_pack_frame([commit], [snap]) + _end_frame(n_commits=1)
233
234 resp = await client.post(
235 f"/{repo.owner}/{repo.slug}/push/stream",
236 content=body,
237 headers={**auth_headers, "Content-Type": WIRE_CONTENT_TYPE},
238 )
239 result = _decode_result(resp.content)
240 assert resp.status_code == 200, f"expected 200, got {resp.status_code}: {result}"
241 assert result.get("ok") is True, f"expected ok=True, got: {result}"
242
243
244 # ---------------------------------------------------------------------------
245 # SV2 — Forged/garbage signature: push rejected
246 # ---------------------------------------------------------------------------
247
248 @pytest.mark.asyncio
249 async def test_sv2_forged_signature_rejected(
250 client: AsyncClient, db_session: AsyncSession,
251 auth_headers: StrDict, monkeypatch: pytest.MonkeyPatch,
252 ) -> None:
253 """SV2: A commit with a present-but-invalid signature is rejected with 422.
254
255 The server must not accept a push where signature bytes do not verify
256 against the declared public key, even when require_signed_commits is off.
257 """
258 _stub_r2(monkeypatch)
259 repo = await _make_repo(db_session, "SV2 Forged Sig", owner="testuser")
260
261 # Generate a real key so the public key is valid — but forge the signature
262 private_key, raw_pub, pub_str = _new_keypair()
263 key_id = public_key_fingerprint(raw_pub)
264 forged_sig = "ed25519:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
265
266 snap_id = _sha256_oid(b"sv2-snap")
267 commit = _make_commit(
268 snapshot_id=snap_id,
269 override_signature=forged_sig,
270 override_signer_public_key=pub_str,
271 override_signer_key_id=key_id,
272 )
273 snap = _make_snapshot(snap_id)
274 body = _header_frame(n_commits=1) + _commit_pack_frame([commit], [snap]) + _end_frame(n_commits=1)
275
276 resp = await client.post(
277 f"/{repo.owner}/{repo.slug}/push/stream",
278 content=body,
279 headers={**auth_headers, "Content-Type": WIRE_CONTENT_TYPE},
280 )
281 result = _decode_result(resp.content)
282 # Must be rejected — not silently accepted
283 is_error = result.get("t") == SFRAME_ERROR
284 is_http_error = resp.status_code == 422
285 assert is_error or is_http_error, (
286 f"SV2: forged signature must be rejected. "
287 f"Got status={resp.status_code}, result={result}"
288 )
289 if is_error:
290 assert result.get("code") == 422, f"expected error code 422, got: {result}"
291
292
293 # ---------------------------------------------------------------------------
294 # SV3 — Valid signature but wrong declared public key: rejected
295 # ---------------------------------------------------------------------------
296
297 @pytest.mark.asyncio
298 async def test_sv3_wrong_public_key_rejected(
299 client: AsyncClient, db_session: AsyncSession,
300 auth_headers: StrDict, monkeypatch: pytest.MonkeyPatch,
301 ) -> None:
302 """SV3: A commit signed with key A but declaring key B is rejected.
303
304 This catches the impersonation attack where a pusher signs with their own
305 key but claims a trusted agent's public key.
306 """
307 _stub_r2(monkeypatch)
308 repo = await _make_repo(db_session, "SV3 Wrong Pubkey", owner="testuser")
309
310 # Sign with key_a, but declare key_b
311 from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
312 key_a = Ed25519PrivateKey.generate()
313 key_b = Ed25519PrivateKey.generate()
314 pub_b_bytes = key_b.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
315 pub_b_encoded = encode_pubkey("ed25519", pub_b_bytes)
316 key_b_id = public_key_fingerprint(pub_b_bytes)
317
318 snap_id = _sha256_oid(b"sv3-snap")
319 # Build a real signature with key_a
320 commit_id = _sha256_oid(b"sv3-commit")
321 ts = _utc()
322 payload = provenance_payload(
323 commit_id, author="gabriel", agent_id="claude-code",
324 model_id="claude-sonnet-4-6", committed_at=ts,
325 )
326 sig_a = sign_commit_ed25519(payload, key_a)
327
328 commit = _make_commit(
329 commit_id=commit_id,
330 snapshot_id=snap_id,
331 committed_at=ts,
332 # Signature from key_a, but public key is key_b → mismatch
333 override_signature=sig_a,
334 override_signer_public_key=pub_b_encoded,
335 override_signer_key_id=key_b_id,
336 )
337 snap = _make_snapshot(snap_id)
338 body = _header_frame(n_commits=1) + _commit_pack_frame([commit], [snap]) + _end_frame(n_commits=1)
339
340 resp = await client.post(
341 f"/{repo.owner}/{repo.slug}/push/stream",
342 content=body,
343 headers={**auth_headers, "Content-Type": WIRE_CONTENT_TYPE},
344 )
345 result = _decode_result(resp.content)
346 is_error = result.get("t") == SFRAME_ERROR
347 is_http_error = resp.status_code == 422
348 assert is_error or is_http_error, (
349 f"SV3: sig/pubkey mismatch must be rejected. "
350 f"Got status={resp.status_code}, result={result}"
351 )
352 if is_error:
353 assert result.get("code") == 422, f"expected error code 422, got: {result}"
354
355
356 # ---------------------------------------------------------------------------
357 # SV4 — signature present but signer_public_key empty: rejected
358 # ---------------------------------------------------------------------------
359
360 @pytest.mark.asyncio
361 async def test_sv4_signature_without_public_key_rejected(
362 client: AsyncClient, db_session: AsyncSession,
363 auth_headers: StrDict, monkeypatch: pytest.MonkeyPatch,
364 ) -> None:
365 """SV4: A commit with a non-empty signature but empty signer_public_key is rejected.
366
367 Without the public key we cannot verify the signature — accepting would
368 mean storing unverifiable provenance claims.
369 """
370 _stub_r2(monkeypatch)
371 repo = await _make_repo(db_session, "SV4 Sig No Pubkey", owner="testuser")
372
373 private_key = Ed25519PrivateKey.generate()
374 snap_id = _sha256_oid(b"sv4-snap")
375 commit_id = _sha256_oid(b"sv4-commit")
376 ts = _utc()
377 payload = provenance_payload(commit_id, author="gabriel", committed_at=ts)
378 sig = sign_commit_ed25519(payload, private_key)
379
380 commit = _make_commit(
381 commit_id=commit_id,
382 snapshot_id=snap_id,
383 committed_at=ts,
384 override_signature=sig,
385 override_signer_public_key="", # no public key → can't verify
386 override_signer_key_id="",
387 )
388 snap = _make_snapshot(snap_id)
389 body = _header_frame(n_commits=1) + _commit_pack_frame([commit], [snap]) + _end_frame(n_commits=1)
390
391 resp = await client.post(
392 f"/{repo.owner}/{repo.slug}/push/stream",
393 content=body,
394 headers={**auth_headers, "Content-Type": WIRE_CONTENT_TYPE},
395 )
396 result = _decode_result(resp.content)
397 is_error = result.get("t") == SFRAME_ERROR
398 is_http_error = resp.status_code == 422
399 assert is_error or is_http_error, (
400 f"SV4: signature without public key must be rejected. "
401 f"Got status={resp.status_code}, result={result}"
402 )
403 if is_error:
404 assert result.get("code") == 422, f"expected error code 422, got: {result}"
405
406
407 # ---------------------------------------------------------------------------
408 # SV5 — Unsigned commit, require_signed_commits=False: accepted (regression guard)
409 # ---------------------------------------------------------------------------
410
411 @pytest.mark.asyncio
412 async def test_sv5_unsigned_commit_accepted_when_signing_not_required(
413 client: AsyncClient, db_session: AsyncSession,
414 auth_headers: StrDict, monkeypatch: pytest.MonkeyPatch,
415 ) -> None:
416 """SV5: Unsigned commits are still accepted when require_signed_commits is off.
417
418 The signature verification fix must not change existing behavior for
419 repos that do not mandate signed commits.
420 """
421 _stub_r2(monkeypatch)
422 monkeypatch.setattr(
423 "musehub.services.musehub_wire.settings",
424 _make_settings(require_signed_commits=False),
425 )
426 repo = await _make_repo(db_session, "SV5 Unsigned OK", owner="testuser")
427
428 snap_id = _sha256_oid(b"sv5-snap")
429 commit = _make_commit(snapshot_id=snap_id) # no private_key → unsigned
430 snap = _make_snapshot(snap_id)
431 body = _header_frame(n_commits=1) + _commit_pack_frame([commit], [snap]) + _end_frame(n_commits=1)
432
433 resp = await client.post(
434 f"/{repo.owner}/{repo.slug}/push/stream",
435 content=body,
436 headers={**auth_headers, "Content-Type": WIRE_CONTENT_TYPE},
437 )
438 result = _decode_result(resp.content)
439 assert resp.status_code == 200, f"SV5: unsigned commit should be accepted when not required"
440 assert result.get("ok") is True, f"expected ok=True, got: {result}"
441
442
443 # ---------------------------------------------------------------------------
444 # SV6 — Unsigned commit, require_signed_commits=True: rejected (regression guard)
445 # ---------------------------------------------------------------------------
446
447 @pytest.mark.asyncio
448 async def test_sv6_unsigned_commit_rejected_when_signing_required(
449 client: AsyncClient, db_session: AsyncSession,
450 auth_headers: StrDict, monkeypatch: pytest.MonkeyPatch,
451 ) -> None:
452 """SV6: Unsigned commits are rejected when require_signed_commits is on."""
453 _stub_r2(monkeypatch)
454 monkeypatch.setattr(
455 "musehub.services.musehub_wire.settings",
456 _make_settings(require_signed_commits=True),
457 )
458 repo = await _make_repo(db_session, "SV6 Unsigned Bad", owner="testuser")
459
460 snap_id = _sha256_oid(b"sv6-snap")
461 commit = _make_commit(snapshot_id=snap_id) # unsigned
462 snap = _make_snapshot(snap_id)
463 body = _header_frame(n_commits=1) + _commit_pack_frame([commit], [snap]) + _end_frame(n_commits=1)
464
465 resp = await client.post(
466 f"/{repo.owner}/{repo.slug}/push/stream",
467 content=body,
468 headers={**auth_headers, "Content-Type": WIRE_CONTENT_TYPE},
469 )
470 result = _decode_result(resp.content)
471 is_error = result.get("t") == SFRAME_ERROR
472 is_http_error = resp.status_code == 422
473 assert is_error or is_http_error, (
474 f"SV6: unsigned commit must be rejected when required. "
475 f"Got status={resp.status_code}, result={result}"
476 )
477
478
479 # ---------------------------------------------------------------------------
480 # SV7 — Mixed batch: one valid + one forged → entire push rejected
481 # ---------------------------------------------------------------------------
482
483 @pytest.mark.asyncio
484 async def test_sv7_mixed_batch_one_forged_rejects_all(
485 client: AsyncClient, db_session: AsyncSession,
486 auth_headers: StrDict, monkeypatch: pytest.MonkeyPatch,
487 ) -> None:
488 """SV7: A batch with one valid and one forged commit is fully rejected.
489
490 The push must be atomic — a single bad commit invalidates the entire push,
491 not just the offending commit.
492 """
493 _stub_r2(monkeypatch)
494 repo = await _make_repo(db_session, "SV7 Mixed Batch", owner="testuser")
495
496 private_key, raw_pub, pub_str = _new_keypair()
497 key_id = public_key_fingerprint(raw_pub)
498
499 # Commit 1: legitimately signed
500 snap_id_1 = _sha256_oid(b"sv7-snap-1")
501 commit1 = _make_commit(snapshot_id=snap_id_1, private_key=private_key)
502
503 # Commit 2: forged signature (garbage bytes but valid format prefix)
504 snap_id_2 = _sha256_oid(b"sv7-snap-2")
505 forged_sig = "ed25519:BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"
506 commit2 = _make_commit(
507 snapshot_id=snap_id_2,
508 override_signature=forged_sig,
509 override_signer_public_key=pub_str,
510 override_signer_key_id=key_id,
511 )
512
513 snap1 = _make_snapshot(snap_id_1)
514 snap2 = _make_snapshot(snap_id_2)
515 body = (
516 _header_frame(n_commits=2)
517 + _commit_pack_frame([commit1, commit2], [snap1, snap2])
518 + _end_frame(n_commits=2)
519 )
520
521 resp = await client.post(
522 f"/{repo.owner}/{repo.slug}/push/stream",
523 content=body,
524 headers={**auth_headers, "Content-Type": WIRE_CONTENT_TYPE},
525 )
526 result = _decode_result(resp.content)
527 is_error = result.get("t") == SFRAME_ERROR
528 is_http_error = resp.status_code == 422
529 assert is_error or is_http_error, (
530 f"SV7: mixed batch with one forged commit must be fully rejected. "
531 f"Got status={resp.status_code}, result={result}"
532 )
533 if is_error:
534 assert result.get("code") == 422, f"expected error code 422, got: {result}"
535
536
537 # ---------------------------------------------------------------------------
538 # Settings factory helper
539 # ---------------------------------------------------------------------------
540
541 class _SettingsStub:
542 require_signed_commits: bool
543 per_repo_quota_bytes: int
544 trusted_agent_ids: list[str]
545
546
547 def _make_settings(*, require_signed_commits: bool = False) -> _SettingsStub:
548 """Return a minimal settings stub that wire_push_stream reads."""
549 s = _SettingsStub()
550 s.require_signed_commits = require_signed_commits
551 s.per_repo_quota_bytes = 0 # 0 means no quota check
552 s.trusted_agent_ids = []
553 return s
File History 1 commit
sha256:bb2baaabdd19320bde50cb69d447fd1c1e571467df729be1a23b5e93064b046a feat(intel): standardize headers, gauge icon, velocity card… Sonnet 4.6 minor 142 days ago