diff --git a/tests/test_hub_relay.py b/tests/test_hub_relay.py new file mode 100644 index 000000000..7bfe30265 --- /dev/null +++ b/tests/test_hub_relay.py @@ -0,0 +1,178 @@ +"""Tests for the hub sealed-envelope relay (cross-user collab A3).""" +from __future__ import annotations + +import json +import os +import pytest + +from cryptography.hazmat.primitives.asymmetric.x25519 import ( + X25519PrivateKey, +) + +from tinyagentos.hub.relay import ( + canonicalize, + seal, + unseal, + build_envelope, + MAX_ENVELOPE_SIZE, + _b64, + _b64_decode, +) + +# --------------------------------------------------------------------------- +# Round-trip: seal then unseal +# --------------------------------------------------------------------------- + + +def _make_x25519_keypair() -> tuple[str, str]: + """Return (priv_hex, pub_hex) for a fresh X25519 keypair.""" + priv = X25519PrivateKey.generate() + pub = priv.public_key() + return ( + priv.private_bytes_raw().hex(), + pub.public_bytes_raw().hex(), + ) + + +class TestRoundTrip: + """A sealed envelope must survive seal→unseal across a real keypair.""" + + def test_small_payload(self): + priv_hex, pub_hex = _make_x25519_keypair() + plaintext = b"hello sealed world" + envelope = seal(plaintext=plaintext, + recipient_x25519_pub_hex=pub_hex) + assert "sender_ephemeral_pub" in envelope + assert "nonce" in envelope + assert "ciphertext" in envelope + result = unseal(envelope=envelope, + recipient_x25519_priv_hex=priv_hex) + assert result == plaintext + + def test_larger_payload(self): + priv_hex, pub_hex = _make_x25519_keypair() + plaintext = os.urandom(4096) + envelope = seal(plaintext=plaintext, + recipient_x25519_pub_hex=pub_hex) + result = unseal(envelope=envelope, + recipient_x25519_priv_hex=priv_hex) + assert result == plaintext + + def test_wrong_recipient_fails(self): + alice_priv, alice_pub = _make_x25519_keypair() + _, bob_pub = _make_x25519_keypair() + envelope = seal(plaintext=b"secret", + recipient_x25519_pub_hex=alice_pub) + # Bob tries to decrypt: should return None. + bob_priv, _ = _make_x25519_keypair() + result = unseal(envelope=envelope, + recipient_x25519_priv_hex=bob_priv) + assert result is None + + def test_tampered_ciphertext_fails(self): + priv_hex, pub_hex = _make_x25519_keypair() + envelope = seal(plaintext=b"tamper me", + recipient_x25519_pub_hex=pub_hex) + # Flip a byte in the ciphertext. + ct = bytearray(_b64_decode(envelope["ciphertext"])) + ct[0] ^= 0xFF + envelope["ciphertext"] = _b64(bytes(ct)) + result = unseal(envelope=envelope, + recipient_x25519_priv_hex=priv_hex) + assert result is None + + def test_unicode_plaintext(self): + priv_hex, pub_hex = _make_x25519_keypair() + plaintext = "héllo wörld 🔐".encode("utf-8") + envelope = seal(plaintext=plaintext, + recipient_x25519_pub_hex=pub_hex) + result = unseal(envelope=envelope, + recipient_x25519_priv_hex=priv_hex) + assert result == plaintext + + +# --------------------------------------------------------------------------- +# build_envelope +# --------------------------------------------------------------------------- + +class TestBuildEnvelope: + def test_includes_recipient(self): + _, pub_hex = _make_x25519_keypair() + outer = build_envelope( + recipient="hub:alice", + recipient_x25519_pub_hex=pub_hex, + inner_payload=b'{"type":"test"}', + ) + assert outer["recipient"] == "hub:alice" + assert "sender_ephemeral_pub" in outer + assert "nonce" in outer + assert "ciphertext" in outer + assert "created_at" in outer + + def test_enforces_size_limit(self): + _, pub_hex = _make_x25519_keypair() + # Build a payload just over the limit (after base64 overhead). + large = b"x" * (MAX_ENVELOPE_SIZE + 1) + with pytest.raises(ValueError, match="exceeds"): + build_envelope( + recipient="hub:alice", + recipient_x25519_pub_hex=pub_hex, + inner_payload=large, + ) + + def test_inner_json_preserved_after_roundtrip(self): + priv_hex, pub_hex = _make_x25519_keypair() + inner = json.dumps( + {"from": "hub:alice", "to": "hub:bob", "kind": "chat", + "body": "hello"} + ).encode("utf-8") + outer = build_envelope( + recipient="hub:bob", + recipient_x25519_pub_hex=pub_hex, + inner_payload=inner, + ) + result = unseal(envelope=outer, + recipient_x25519_priv_hex=priv_hex) + assert json.loads(result) == json.loads(inner) + + +# --------------------------------------------------------------------------- +# canonicalize +# --------------------------------------------------------------------------- + +class TestCanonicalize: + def test_strips_sig(self): + payload = {"from": "a", "to": "b", "sig": "deadbeef"} + c = canonicalize(payload) + decoded = json.loads(c) + assert "sig" not in decoded + assert decoded["from"] == "a" + + def test_sorted_keys(self): + payload = {"z": 1, "a": 2, "m": 3} + c = canonicalize(payload) + # canonical JSON: sorted keys, compact + assert c == b'{"a":2,"m":3,"z":1}' + + def test_ensure_ascii_false_preserves_unicode(self): + payload = {"text": "café"} + c = canonicalize(payload) + assert "café".encode("utf-8") in c + + +# --------------------------------------------------------------------------- +# base64 round-trip +# --------------------------------------------------------------------------- + +class TestBase64: + def test_roundtrip(self): + data = os.urandom(32) + assert _b64_decode(_b64(data)) == data + + def test_url_safe(self): + # All-zero bytes produce '/' in standard base64 but '_' in urlsafe. + data = bytes([0xFF, 0xFF]) + enc = _b64(data) + assert "/" not in enc + assert "+" not in enc + assert "=" not in enc # padding stripped diff --git a/tests/test_routes_account_proxy.py b/tests/test_routes_account_proxy.py index 12a5dc0c2..af0f68831 100644 --- a/tests/test_routes_account_proxy.py +++ b/tests/test_routes_account_proxy.py @@ -621,3 +621,115 @@ async def handler(method, url, **kw): assert r.status_code == 200 assert "taos_session" not in captured.get("cookie", "") assert captured.get("cookie", "") == "" + + +# --- Hub sealed-envelope relay (cross-user collab A3) --- + + +@pytest.mark.asyncio +async def test_hub_relay_drop_forwards_envelope(client, monkeypatch): + """POST /api/account/hub/relay/drop forwards to + {base}/api/hub/relay/drop with the sealed envelope body.""" + monkeypatch.setenv("TAOS_ACCOUNT_BASE_URL", "https://taos.my") + captured: dict[str, str] = {} + + async def handler(method, url, **kw): + captured["method"] = method + captured["url"] = url + captured["body"] = kw.get("content", b"").decode("utf-8") + return _FakeResp( + content=b'{"status":"queued","count":1}', + headers={"content-type": "application/json"}, + ) + + _patch_upstream(monkeypatch, handler) + r = await client.post( + "/api/account/hub/relay/drop", + json={ + "recipient": "hub:bob", + "sender_ephemeral_pub": "aa", + "nonce": "bb", + "ciphertext": "cc", + }, + ) + assert r.status_code == 200 + assert r.json()["status"] == "queued" + assert captured["url"] == "https://taos.my/api/hub/relay/drop" + assert captured["method"] == "POST" + assert "hub:bob" in captured["body"] + + +@pytest.mark.asyncio +async def test_hub_relay_poll_forwards_recipient(client, monkeypatch): + """GET /api/account/hub/relay/poll?recipient=hub:alice forwards to + {base}/api/hub/relay/poll?recipient=hub:alice.""" + monkeypatch.setenv("TAOS_ACCOUNT_BASE_URL", "https://taos.my") + captured: dict[str, str] = {} + + async def handler(method, url, **kw): + captured["method"] = method + captured["url"] = url + return _FakeResp( + content=b'{"envelopes":[],"count":0}', + headers={"content-type": "application/json"}, + ) + + _patch_upstream(monkeypatch, handler) + r = await client.get("/api/account/hub/relay/poll?recipient=hub:alice") + assert r.status_code == 200 + assert r.json()["count"] == 0 + assert captured["url"] == "https://taos.my/api/hub/relay/poll?recipient=hub:alice" + assert captured["method"] == "GET" + + +@pytest.mark.asyncio +async def test_hub_relay_drop_rejects_invalid_recipient(client, monkeypatch): + """An invalid recipient in the body is rejected 400 before forwarding.""" + monkeypatch.setenv("TAOS_ACCOUNT_BASE_URL", "https://taos.my") + + async def handler(method, url, **kw): + pytest.fail("must not be called for invalid recipient") + + _patch_upstream(monkeypatch, handler) + r = await client.post( + "/api/account/hub/relay/drop", + json={ + "recipient": "../admin", + "sender_ephemeral_pub": "aa", + "nonce": "bb", + "ciphertext": "cc", + }, + ) + assert r.status_code == 400 + assert "invalid recipient" in r.json()["error"].lower() + + +@pytest.mark.asyncio +async def test_hub_relay_drop_rejects_non_dict_body(client, monkeypatch): + """A JSON array (or any non-dict) body is rejected 400 before forwarding.""" + monkeypatch.setenv("TAOS_ACCOUNT_BASE_URL", "https://taos.my") + + async def handler(method, url, **kw): + pytest.fail("must not be called for non-dict body") + + _patch_upstream(monkeypatch, handler) + r = await client.post( + "/api/account/hub/relay/drop", + json=[1, 2, 3], + ) + assert r.status_code == 400 + assert "invalid body" in r.json()["error"].lower() + + +@pytest.mark.asyncio +async def test_hub_relay_poll_rejects_invalid_recipient(client, monkeypatch): + """An invalid recipient (path injection) is rejected 400 before forwarding.""" + monkeypatch.setenv("TAOS_ACCOUNT_BASE_URL", "https://taos.my") + + async def handler(method, url, **kw): + pytest.fail("must not be called for invalid recipient") + + _patch_upstream(monkeypatch, handler) + r = await client.get("/api/account/hub/relay/poll?recipient=../admin") + assert r.status_code == 400 + assert "invalid recipient" in r.json()["error"].lower() diff --git a/tinyagentos/hub/relay.py b/tinyagentos/hub/relay.py new file mode 100644 index 000000000..0c18480d4 --- /dev/null +++ b/tinyagentos/hub/relay.py @@ -0,0 +1,192 @@ +"""Hub relay — X25519 envelope sealing for store-and-forward through taos.my. + +See ``docs/design/cross-user-collaboration.md`` (section 6 "Transport" and +section 7 "Human-to-human chat"). The hub relay carries *sealed* envelopes: +the outer envelope has only the recipient username (for routing) and the +sender's ephemeral X25519 public key; the inner payload is X25519-encrypted +with ChaCha20-Poly1305 so the hub never sees plaintext. + +Envelope lifecycle +------------------ +1. **Seal** — the sender encrypts the signed inner payload to the recipient's + X25519 public key (ECDH + HKDF → ChaCha20-Poly1305), wraps it in an outer + envelope, and drops it at the hub. +2. **Store** — the hub queues the outer envelope (SQLite, TTL 7 d, max 200 + per recipient). The hub never decrypts; it only inspects ``recipient`` for + routing and ``created_at`` for expiry. +3. **Poll** — the recipient's node polls the hub via the account proxy, gets + the queued envelopes, and **unseals** each one with its own X25519 private + key. Verified envelopes are deleted from the hub; malformed ones are + silently dropped. + +Constraints (enforced at the hub, surfaced in the API) +- Max envelope size: 32 KB (JSON-serialised outer envelope, UTF-8 encoded) +- TTL: 7 days (hub prunes expired envelopes on poll) +- Per-recipient cap: 200 queued envelopes (hub rejects when full) +""" +from __future__ import annotations + +import base64 +import json +import os +import time + +from cryptography.hazmat.primitives.asymmetric.x25519 import ( + X25519PrivateKey, + X25519PublicKey, +) +from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305 +from cryptography.hazmat.primitives.kdf.hkdf import HKDF +from cryptography.hazmat.primitives import hashes + +# --------------------------------------------------------------------------- +# Constants (shared with the hub) +# --------------------------------------------------------------------------- + +MAX_ENVELOPE_SIZE = 32 * 1024 # 32 KB +ENVELOPE_TTL_SECONDS = 7 * 24 * 3600 # 7 days +MAX_QUEUED_PER_RECIPIENT = 200 + +# HKDF info strings to domain-separate different key usages. +_HKDF_INFO_SEAL = b"taos-hub-relay-seal-v1" + + +# --------------------------------------------------------------------------- +# Low-level X25519 ECDH + HKDF → ChaCha20-Poly1305 +# --------------------------------------------------------------------------- + +def _derive_symmetric_key( + ephemeral_private: X25519PrivateKey, + recipient_public: X25519PublicKey, +) -> bytes: + """ECDH + HKDF-SHA256 → 32-byte ChaCha20-Poly1305 key.""" + shared = ephemeral_private.exchange(recipient_public) + return HKDF( + algorithm=hashes.SHA256(), + length=32, + salt=None, + info=_HKDF_INFO_SEAL, + ).derive(shared) + + +# --------------------------------------------------------------------------- +# Outer envelope helpers +# --------------------------------------------------------------------------- + +def _b64(data: bytes) -> str: + """Standard base64 (no padding), URL-safe for transport.""" + return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii") + + +def _b64_decode(s: str) -> bytes: + """Reverse of _b64 — tolerant of missing padding.""" + s = s.strip() + s += "=" * ((4 - len(s) % 4) % 4) + return base64.urlsafe_b64decode(s) + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + + +def seal( + *, + plaintext: bytes, + recipient_x25519_pub_hex: str, +) -> dict: + """Encrypt *plaintext* to *recipient_x25519_pub_hex* and return an outer + envelope suitable for ``POST /api/hub/relay/drop``. + + Returns a dict with keys ``sender_ephemeral_pub``, ``nonce``, + ``ciphertext`` (all base64-encoded) plus a ``created_at`` timestamp. + """ + recipient_pub = X25519PublicKey.from_public_bytes( + bytes.fromhex(recipient_x25519_pub_hex) + ) + ephemeral_priv = X25519PrivateKey.generate() + ephemeral_pub_raw = ephemeral_priv.public_key().public_bytes_raw() + key = _derive_symmetric_key(ephemeral_priv, recipient_pub) + + # ChaCha20-Poly1305 requires a 12-byte nonce. Generate one per-message + # (96 bits from os.urandom is safe for this AEAD). + nonce = os.urandom(12) + aead = ChaCha20Poly1305(key) + ciphertext = aead.encrypt(nonce, plaintext, None) + + return { + "sender_ephemeral_pub": _b64(ephemeral_pub_raw), + "nonce": _b64(nonce), + "ciphertext": _b64(ciphertext), + "created_at": time.time(), + } + + +def unseal( + *, + envelope: dict, + recipient_x25519_priv_hex: str, +) -> bytes | None: + """Decrypt a sealed envelope with the recipient's X25519 private key. + + Returns the plaintext bytes on success, ``None`` on any failure (wrong key, + tampered ciphertext, malformed envelope). This is deliberately fail-closed + so a corrupted envelope is silently dropped rather than raising. + """ + try: + ephemeral_pub_raw = _b64_decode(envelope["sender_ephemeral_pub"]) + nonce = _b64_decode(envelope["nonce"]) + ciphertext = _b64_decode(envelope["ciphertext"]) + recipient_priv = X25519PrivateKey.from_private_bytes( + bytes.fromhex(recipient_x25519_priv_hex) + ) + ephemeral_pub = X25519PublicKey.from_public_bytes(ephemeral_pub_raw) + key = _derive_symmetric_key(recipient_priv, ephemeral_pub) + aead = ChaCha20Poly1305(key) + return aead.decrypt(nonce, ciphertext, None) + except Exception: + return None + + +def build_envelope( + *, + recipient: str, + recipient_x25519_pub_hex: str, + inner_payload: bytes, +) -> dict: + """Seal *inner_payload* and return the complete outer envelope for hub drop. + + ``recipient`` is the hub username (e.g. ``"hub:hogne"``) — the only field + the hub inspects. The inner payload is the signed canonical-JSON object + that the recipient verifies after unsealing. + """ + outer = seal(plaintext=inner_payload, + recipient_x25519_pub_hex=recipient_x25519_pub_hex) + outer["recipient"] = recipient + # Enforce max size: the JSON-serialised envelope must be ≤ 32 KB. + raw = json.dumps(outer, sort_keys=True, separators=(",", ":")) + raw_bytes = raw.encode("utf-8") + if len(raw_bytes) > MAX_ENVELOPE_SIZE: + raise ValueError( + f"sealed envelope is {len(raw_bytes)} bytes, " + f"exceeds {MAX_ENVELOPE_SIZE} byte limit" + ) + return outer + + +# --------------------------------------------------------------------------- +# Inner signed-envelope helpers (Ed25519 signing done by callers) +# --------------------------------------------------------------------------- + + +def canonicalize(payload: dict) -> bytes: + """Canonical JSON bytes of *payload*, excluding any ``sig`` field. + + Used by callers to produce the bytes that are Ed25519-signed (design doc + section 2, "Auth story for the peer channel"). The same bytes are fed to + :func:`seal` so the recipient can verify the signature after unsealing. + """ + inner = {k: v for k, v in payload.items() if k != "sig"} + return json.dumps( + inner, sort_keys=True, separators=(",", ":"), ensure_ascii=False + ).encode("utf-8") diff --git a/tinyagentos/routes/account_proxy.py b/tinyagentos/routes/account_proxy.py index c47b37dff..3ec2eec3a 100644 --- a/tinyagentos/routes/account_proxy.py +++ b/tinyagentos/routes/account_proxy.py @@ -66,6 +66,11 @@ "hub_presence_get": ("GET", "/api/hub/presence"), # Block asks the hub to sever the accepted edge (no more presence/hints). "hub_edge_revoke": ("POST", "/api/hub/edges/revoke"), + # Hub sealed-envelope relay (cross-user collab A3): store-and-forward + # through taos.my. The node seals with the recipient's X25519 public key; + # the hub never sees plaintext. Recipients poll via the account proxy. + "hub_relay_drop": ("POST", "/api/hub/relay/drop"), + "hub_relay_poll": ("GET", "/api/hub/relay/poll"), } _TIMEOUT = httpx.Timeout(15.0) @@ -228,6 +233,16 @@ def _valid_rid(rid: str) -> bool: return bool(_RID_RE.match(rid)) +# Hub recipient usernames carry a "hub:" prefix (e.g. "hub:hogne") so they +# are distinguishable from other identity namespaces. The prefix is fixed; +# the suffix is the same alphanumeric + hyphen/underscore token shape as _RID_RE. +_HUB_RECIPIENT_RE = re.compile(r"^hub:[A-Za-z0-9_-]{1,64}$") + + +def _valid_hub_recipient(recipient: str) -> bool: + return bool(_HUB_RECIPIENT_RE.match(recipient)) + + # --- Account subdomain actions (account model slice 3) --- # Proxy to the taos.my subdomain claims service (slices 1 and 2 of the account # model). The client calls same-origin /api/account/subdomains/*; we forward to @@ -353,6 +368,42 @@ async def hub_edge_revoke(request: Request): return await _forward(request, "hub_edge_revoke") +# --- Hub sealed-envelope relay (cross-user collab A3) --- +# The node seals an inner payload to the recipient's X25519 public key and +# drops it at the hub; the recipient polls for queued envelopes. The hub +# never sees plaintext — it only inspects ``recipient`` for routing. + +@router.post("/api/account/hub/relay/drop") +async def hub_relay_drop(request: Request): + body = await request.body() + try: + payload = json.loads(body) + except (json.JSONDecodeError, UnicodeDecodeError): + return JSONResponse({"error": "invalid body"}, status_code=400) + if not isinstance(payload, dict): + return JSONResponse({"error": "invalid body"}, status_code=400) + recipient = str(payload.get("recipient", "")) + if not _valid_hub_recipient(recipient): + return JSONResponse({"error": "invalid recipient"}, status_code=400) + return await _forward_to(request, "POST", _ACTIONS["hub_relay_drop"][1], body=body) + + +@router.get("/api/account/hub/relay/poll") +async def hub_relay_poll(request: Request): + """Poll for queued envelopes addressed to ``recipient``. + + The ``recipient`` query param is a validated hub username (e.g. + ``hub:hogne``). The hub returns the sealed envelopes; the caller + unseals locally with its X25519 private key. + """ + recipient = request.query_params.get("recipient", "") + if not _valid_hub_recipient(str(recipient)): + return JSONResponse({"error": "invalid recipient"}, status_code=400) + _method, path = _ACTIONS["hub_relay_poll"] + return await _forward_to(request, "GET", + f"{path}?recipient={recipient}") + + @router.get("/api/account/me") async def account_me(request: Request): return await _forward(request, "me")