Skip to content

Commit 304cbc3

Browse files
Zawwarsami16claude
andcommitted
phase 1.0a: ed25519 signed manifests + key pinning + backwards compat
publishers can now sign their manifests; consumers can verify a publisher's identity without trusting the hub. key pinning prevents takeover via stolen api_key alone. unsigned legacy v0 manifests still register cleanly. components: - pyproject.toml: [crypto] extras (cryptography>=42), pinned in [dev] for tests, pre-installed in CI workflow. - zhub/signing.py: generate_keypair, sign_manifest (canonical-json, sort_keys, no whitespace, signature field omitted from signed payload), verify_manifest (returns bool, never raises), public_key_from_private. ed25519 throughout. - zhub/__init__.py: re-exports signing API conditionally — only when cryptography is available. _SIGNING_AVAILABLE flag for downstream. - zhub/client.py publish(): new private_key parameter. when supplied, manifest is signed before send. - zhub/server.py register_publisher(): verifies signature before accepting, key-pins on re-registration, surfaces PermissionError as register_failed envelope back to the publisher (no silent crash). tests: - tests/test_signing.py: 11 cases — 8 unit (sign+verify, tamper field, tamper signature, missing sig, public-key derivation, distinct keys → distinct sigs, idempotent resign, swapped public_key with intact signature) + 3 e2e (signed publish succeeds, tampered signature rejected at WS level, unsigned legacy publish still works). result: 29/29 pytest passing on python 3.13. scope honest: this is 1.0a. federation (1.0b) remains stretch — design locked in the spec, plan written, will land in a follow-up if context permits this run. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent f39bcfc commit 304cbc3

6 files changed

Lines changed: 352 additions & 5 deletions

File tree

pyproject.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,14 @@ server = [
2929
"fastapi>=0.110",
3030
"uvicorn[standard]>=0.27",
3131
]
32+
crypto = [
33+
"cryptography>=42",
34+
]
3235
dev = [
3336
"pytest>=8",
3437
"pytest-asyncio>=0.23",
3538
"ruff>=0.4",
39+
"cryptography>=42",
3640
]
3741

3842
[project.urls]

tests/test_signing.py

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
"""Sign + verify round-trip and tamper detection (unit tests).
2+
End-to-end signed-publish tests live at the bottom and need fastapi/uvicorn."""
3+
4+
import pytest
5+
6+
from zhub.signing import (
7+
generate_keypair, sign_manifest, verify_manifest, public_key_from_private,
8+
)
9+
10+
11+
def _example_manifest() -> dict:
12+
return {
13+
"schema_version": "0.1",
14+
"name": "zai",
15+
"description": "father's autonomous AI",
16+
"accepts": "openai-v1-chat-completions",
17+
"auth": {"type": "bearer"},
18+
"rate_limit": "60/min",
19+
"capabilities": [{"name": "chat", "description": "x"}],
20+
"public": True,
21+
"operator": "zawwar",
22+
"contact": "",
23+
"extensions": {},
24+
}
25+
26+
27+
def test_sign_then_verify_succeeds():
28+
sk, pk = generate_keypair()
29+
signed = sign_manifest(_example_manifest(), sk)
30+
assert "signature" in signed
31+
assert "public_key" in signed
32+
assert signed["public_key"] == pk
33+
assert verify_manifest(signed) is True
34+
35+
36+
def test_tamper_detection_changes_field():
37+
sk, _ = generate_keypair()
38+
signed = sign_manifest(_example_manifest(), sk)
39+
signed["description"] = "TAMPERED"
40+
assert verify_manifest(signed) is False
41+
42+
43+
def test_tamper_detection_changes_signature():
44+
sk, _ = generate_keypair()
45+
signed = sign_manifest(_example_manifest(), sk)
46+
signed["signature"] = "00" * 64
47+
assert verify_manifest(signed) is False
48+
49+
50+
def test_missing_signature_fails_verify():
51+
m = _example_manifest()
52+
assert verify_manifest(m) is False
53+
54+
55+
def test_public_key_from_private_matches_keypair():
56+
sk, pk = generate_keypair()
57+
assert public_key_from_private(sk) == pk
58+
59+
60+
def test_two_different_keys_produce_different_signatures():
61+
sk1, _ = generate_keypair()
62+
sk2, _ = generate_keypair()
63+
a = sign_manifest(_example_manifest(), sk1)
64+
b = sign_manifest(_example_manifest(), sk2)
65+
assert a["signature"] != b["signature"]
66+
assert a["public_key"] != b["public_key"]
67+
68+
69+
def test_resigning_replaces_signature_idempotent():
70+
sk, _ = generate_keypair()
71+
signed_once = sign_manifest(_example_manifest(), sk)
72+
signed_twice = sign_manifest(signed_once, sk)
73+
# Same content + same key → same signature (ed25519 is deterministic)
74+
assert signed_twice["signature"] == signed_once["signature"]
75+
76+
77+
def test_swapped_public_key_with_intact_signature_fails():
78+
"""Even if signature bytes are kept, swapping public_key invalidates the
79+
signed payload (since public_key IS part of what's signed)."""
80+
sk1, _ = generate_keypair()
81+
_, pk2 = generate_keypair()
82+
signed = sign_manifest(_example_manifest(), sk1)
83+
signed["public_key"] = pk2 # swap the claimed key
84+
assert verify_manifest(signed) is False
85+
86+
87+
# ---- end-to-end (needs hub) ---------------------------------------------
88+
89+
import asyncio
90+
import socket
91+
import threading
92+
import time
93+
94+
try:
95+
import fastapi # noqa
96+
import uvicorn # noqa
97+
SERVER_AVAILABLE = True
98+
except ImportError:
99+
SERVER_AVAILABLE = False
100+
101+
if SERVER_AVAILABLE:
102+
from zhub.server import create_app
103+
from zhub import publish
104+
105+
106+
def _free_port() -> int:
107+
with socket.socket() as s:
108+
s.bind(("", 0))
109+
return s.getsockname()[1]
110+
111+
112+
@pytest.fixture(scope="module")
113+
def hub_port():
114+
if not SERVER_AVAILABLE:
115+
pytest.skip("fastapi/uvicorn not installed")
116+
port = _free_port()
117+
118+
def run():
119+
config = uvicorn.Config(create_app(), host="127.0.0.1", port=port, log_level="warning")
120+
asyncio.run(uvicorn.Server(config).serve())
121+
122+
threading.Thread(target=run, daemon=True).start()
123+
for _ in range(30):
124+
try:
125+
with socket.create_connection(("127.0.0.1", port), timeout=0.1):
126+
break
127+
except OSError:
128+
time.sleep(0.1)
129+
yield port
130+
131+
132+
@pytest.mark.asyncio
133+
async def test_signed_publish_succeeds(hub_port):
134+
"""A publisher with a private_key has its signed manifest accepted."""
135+
sk, _pk = generate_keypair()
136+
pub = publish(
137+
name="signed-ai",
138+
description="signed test",
139+
chat_handler=lambda m, o: "ok",
140+
hub_url=f"ws://127.0.0.1:{hub_port}",
141+
private_key=sk,
142+
)
143+
for _ in range(50):
144+
if pub.api_key:
145+
break
146+
await asyncio.sleep(0.1)
147+
assert pub.api_key, "signed manifest should register cleanly"
148+
149+
150+
@pytest.mark.asyncio
151+
async def test_signed_publish_with_tampered_signature_rejected(hub_port):
152+
"""A manifest whose signature does not match its content is rejected."""
153+
import zhub.signing as _signing
154+
real_sign = _signing.sign_manifest
155+
156+
def mutating_sign(manifest, sk):
157+
out = real_sign(manifest, sk)
158+
out["signature"] = "00" * 64 # break the signature
159+
return out
160+
161+
_signing.sign_manifest = mutating_sign
162+
try:
163+
sk, _ = generate_keypair()
164+
pub = publish(
165+
name="tampered",
166+
description="should fail",
167+
chat_handler=lambda m, o: "no",
168+
hub_url=f"ws://127.0.0.1:{hub_port}",
169+
private_key=sk,
170+
)
171+
# Wait briefly — registration should fail and api_key stays empty.
172+
for _ in range(20):
173+
await asyncio.sleep(0.1)
174+
assert not pub.api_key, "tampered signature should not register"
175+
finally:
176+
_signing.sign_manifest = real_sign
177+
178+
179+
@pytest.mark.asyncio
180+
async def test_unsigned_publish_still_works(hub_port):
181+
"""Backwards-compat: a manifest without a signature still registers
182+
(legacy v0 publishers don't sign)."""
183+
pub = publish(
184+
name="legacy",
185+
description="unsigned",
186+
chat_handler=lambda m, o: "ok",
187+
hub_url=f"ws://127.0.0.1:{hub_port}",
188+
# no private_key
189+
)
190+
for _ in range(50):
191+
if pub.api_key:
192+
break
193+
await asyncio.sleep(0.1)
194+
assert pub.api_key, "unsigned manifest should still register (backwards compat)"

zhub/__init__.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,15 @@
4545
from .manifest import Manifest, Capability
4646
from .errors import ZhubError, AuthError, ConnectionError as ZhubConnectionError
4747

48+
# Signing API is optional — only available when 'cryptography' is installed.
49+
try:
50+
from .signing import (
51+
generate_keypair, sign_manifest, verify_manifest, public_key_from_private,
52+
)
53+
_SIGNING_AVAILABLE = True
54+
except SystemExit:
55+
_SIGNING_AVAILABLE = False
56+
4857
__version__ = "0.1.0"
4958
__all__ = [
5059
"publish",
@@ -57,3 +66,5 @@
5766
"AuthError",
5867
"ZhubConnectionError",
5968
]
69+
if _SIGNING_AVAILABLE:
70+
__all__ += ["generate_keypair", "sign_manifest", "verify_manifest", "public_key_from_private"]

zhub/client.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,13 +117,19 @@ def publish(
117117
contact: str = "",
118118
on_connection_event: Optional[ConnectionEventHandler] = None,
119119
api_key: Optional[str] = None,
120+
private_key: Optional[str] = None,
120121
) -> ZhubPublication:
121122
"""Create a ZhubPublication. Call .run_forever() to actually start serving.
122123
123124
If `api_key` is supplied AND the hub has a stored publisher with the
124125
same name and matching key hash, this is a re-registration after a hub
125126
restart — the same name + key are reused. Otherwise a fresh registration
126127
is performed and a new key is allocated.
128+
129+
If `private_key` (hex-encoded ed25519 private key) is supplied, the
130+
manifest is signed before publish. The hub validates the signature on
131+
register and stores the public key. Consumers fetching
132+
`/<name>/manifest.json` can verify identity without trusting the hub.
127133
"""
128134
manifest = chat_only_manifest(
129135
name=name, description=description,
@@ -148,7 +154,11 @@ async def runner() -> None:
148154
log.info("publisher connecting to %s", url)
149155
async with websockets.connect(url, max_size=10_000_000) as ws:
150156
pub._ws = ws # type: ignore[attr-defined]
151-
register_env = register_publisher(manifest.to_dict(), name)
157+
manifest_dict = manifest.to_dict()
158+
if private_key:
159+
from .signing import sign_manifest as _sign
160+
manifest_dict = _sign(manifest_dict, private_key)
161+
register_env = register_publisher(manifest_dict, name)
152162
if api_key:
153163
register_env.payload["api_key"] = api_key
154164
await ws.send(register_env.to_json())

zhub/server.py

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -94,12 +94,41 @@ async def register_publisher(self, name: str, manifest: dict[str, Any],
9494
publisher with the same name, this is a re-registration after a hub
9595
restart — keep the same name + same api_key. Otherwise allocate a
9696
fresh name + api_key.
97+
98+
Signed manifests: if `manifest` carries a `signature` + `public_key`,
99+
the signature is verified before accepting the registration. On
100+
re-registration with `desired_api_key`, the supplied public_key must
101+
also match the stored manifest's public_key (key pinning — prevents
102+
takeover via stolen api_key alone). Unsigned manifests still accepted
103+
for backwards compatibility with v0 clients.
97104
"""
105+
# Signature verification (if manifest is signed)
106+
if manifest.get("signature"):
107+
try:
108+
from .signing import verify_manifest as _verify
109+
except SystemExit:
110+
raise PermissionError(
111+
"manifest carries a signature but hub lacks 'cryptography'; "
112+
"install zhub with [crypto] extras"
113+
)
114+
if not _verify(manifest):
115+
raise PermissionError("manifest signature verification failed")
116+
98117
async with self.lock:
99118
# Re-registration path (after hub restart / publisher restart)
100119
if desired_api_key and self.storage:
101120
stored = self.storage.lookup_publisher(name)
102121
if stored and stored["api_key_hash"] == hash_key(desired_api_key):
122+
# Key pinning: if the stored manifest was signed, the
123+
# incoming manifest must present the same public_key
124+
# (otherwise a stolen api_key alone would let an attacker
125+
# take over the registration).
126+
stored_pk = stored["manifest"].get("public_key")
127+
if stored_pk:
128+
if manifest.get("public_key") != stored_pk:
129+
raise PermissionError(
130+
"key pinning: stored public_key does not match"
131+
)
103132
api_key_hash = stored["api_key_hash"]
104133
self.publishers[name] = PublisherRegistration(
105134
name=name,
@@ -420,10 +449,16 @@ async def ws_publish(websocket: WebSocket) -> None:
420449
if env.type == "register-publisher" and ai_name is None:
421450
desired = env.payload.get("desired_name") or env.payload.get("manifest", {}).get("name", "ai")
422451
desired_key = env.payload.get("api_key") # for re-registration
423-
name, api_key = await hub.register_publisher(
424-
desired, env.payload.get("manifest", {}), websocket,
425-
desired_api_key=desired_key,
426-
)
452+
try:
453+
name, api_key = await hub.register_publisher(
454+
desired, env.payload.get("manifest", {}), websocket,
455+
desired_api_key=desired_key,
456+
)
457+
except PermissionError as e:
458+
await websocket.send_text(
459+
error_envelope(env.request_id, "register_failed", str(e)).to_json()
460+
)
461+
break
427462
ai_name = name
428463
base_url = "/" + name
429464
await websocket.send_text(registered(name, base_url, api_key).to_json())

0 commit comments

Comments
 (0)