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