Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
185 changes: 185 additions & 0 deletions tests/test_routes_hub_slice3.py
Original file line number Diff line number Diff line change
Expand Up @@ -275,3 +275,188 @@ async def pres_handler(method, url, **kw):
assert resp.json()["endpoints"] == ["wss://x", "wss://y"]
fwd = [c for c in captured if c["url"].endswith("/api/hub/presence?username=alice")]
assert fwd, "presence was not forwarded to the directory"


# ---------------------------------------------------------------------------
# Milestone A2: friend-accept -> contact row + peer link + block cascade
# ---------------------------------------------------------------------------


class TestFriendAcceptContactCreation:
"""Friend-accept creates a contact row and peer link."""

@pytest.mark.asyncio
async def test_accept_creates_contact_row(self, client, upstream):
"""Friend-accept with identity info creates a contact row in contacts_store."""
from tinyagentos.hub import identity as idmod

# Ensure we have a local hub identity so the handler can run.
idmod.load_or_create()

# Initialise contacts_store — the lifespan may not have done this in tests.
contacts = client._transport.app.state.contacts_store
if contacts._db is not None:
await contacts.close()
await contacts.init()

async def handler(method, url, **kw):
return _FakeResp(content=json.dumps({
"peer": "peerFP",
"username": "hogne",
"display_name": "Hogne",
"signing_pubkey": "ab" * 32,
"encryption_pubkey": "cd" * 32,
"endpoints": [],
}).encode())
_set(upstream, handler)
resp = await client.post(
"/api/hub/friends/requests/rid-9/accept",
json={"peer_fingerprint": "peerFP"},
)
assert resp.status_code == 200
assert resp.json()["state"] == "accepted"

# Verify the contact row was created.
contact = await contacts.get_contact("hub:hogne")
assert contact is not None
assert contact["hub_username"] == "hogne"
assert contact["display_name"] == "Hogne"
assert contact["ed25519_pub"] == "ab" * 32
assert contact["x25519_pub"] == "cd" * 32
assert contact["status"] == "active"

@pytest.mark.asyncio
async def test_accept_creates_peer_link(self, client, upstream):
"""Friend-accept creates a peer_link with inbound token and endpoints."""
from tinyagentos.hub import identity as idmod

idmod.load_or_create()

# Initialise contacts_store.
contacts = client._transport.app.state.contacts_store
if contacts._db is not None:
await contacts.close()
await contacts.init()

async def handler(method, url, **kw):
return _FakeResp(content=json.dumps({
"peer": "peerFP",
"username": "hogne",
"display_name": "Hogne",
"signing_pubkey": "ab" * 32,
"encryption_pubkey": "cd" * 32,
"endpoints": [],
}).encode())
_set(upstream, handler)
resp = await client.post(
"/api/hub/friends/requests/rid-9/accept",
json={"peer_fingerprint": "peerFP"},
)
assert resp.status_code == 200

contacts = client._transport.app.state.contacts_store
plink = await contacts.get_peer_link("hub:hogne")
assert plink is not None
# inbound_token_hash should be set (non-empty)
assert plink.get("inbound_token_hash")
assert len(plink["inbound_token_hash"]) == 64 # SHA-256 hex
# outbound_token starts empty (filled on handshake reply)
assert plink["outbound_token"] == ""
# endpoints stored as empty list when directory returns none
assert plink["endpoints"] == []

@pytest.mark.asyncio
async def test_accept_no_contacts_store_does_not_crash(self, client, upstream):
"""Friend-accept without a contacts_store (e.g. uninitialised) still succeeds."""
from tinyagentos.hub import identity as idmod

idmod.load_or_create()

async def handler(method, url, **kw):
return _FakeResp(content=json.dumps({
"peer": "peerFP",
"username": "alice",
}).encode())
_set(upstream, handler)

# Temporarily remove contacts_store to simulate missing store.
saved = client._transport.app.state.contacts_store
client._transport.app.state.contacts_store = None
try:
resp = await client.post(
"/api/hub/friends/requests/rid-9/accept",
json={"peer_fingerprint": "peerFP"},
)
assert resp.status_code == 200
assert resp.json()["state"] == "accepted"
finally:
client._transport.app.state.contacts_store = saved


class TestBlockCascade:
"""Block cascades to revoke the peer link."""

@pytest.mark.asyncio
async def test_block_revokes_peer_link(self, client, upstream):
"""Blocking a peer whose fingerprint matches a hub author revokes their peer link."""
from tinyagentos.hub import identity as idmod

idmod.load_or_create()

# Initialise contacts_store.
contacts = client._transport.app.state.contacts_store
if contacts._db is not None:
await contacts.close()
await contacts.init()

# First accept to create the contact + peer link.
async def accept_handler(method, url, **kw):
return _FakeResp(content=json.dumps({
"peer": "peerFP",
"username": "hogne",
"display_name": "Hogne",
"signing_pubkey": "ab" * 32,
"encryption_pubkey": "cd" * 32,
"endpoints": [],
}).encode())
_set(upstream, accept_handler)
resp = await client.post(
"/api/hub/friends/requests/rid-1/accept",
json={"peer_fingerprint": "peerFP"},
)
assert resp.json()["state"] == "accepted"

# Verify peer link exists before block.
contacts = client._transport.app.state.contacts_store
plink = await contacts.get_peer_link("hub:hogne")
assert plink is not None
assert plink.get("revoked_at") is None

# Register the hub author so get_author() resolves fingerprint → username.
# The friend-accept handler only records the local friend edge, not an
# author row for the peer. Register one manually: fingerprint "peerFP"
# (the blocked peer) → username "hogne" so the cascade finds the contact.
from tinyagentos.hub.store import HubStore
hub = HubStore(hub_store.default_db_path())
await hub.init()
try:
await hub.upsert_author("peerFP", username="hogne")

# Now block — should cascade to revoke_peer_link.
captured, calls = upstream
resp = await client.post(
"/api/hub/friends/block",
json={"peer_fingerprint": "peerFP"},
)
assert resp.json()["state"] == "blocked"

# Verify the peer link was revoked.
plink = await contacts.get_peer_link("hub:hogne")
assert plink is not None
assert plink.get("revoked_at") is not None
# Contact status should also be revoked.
contact = await contacts.get_contact("hub:hogne")
assert contact is not None
assert contact["status"] == "revoked"
finally:
await hub.close()
78 changes: 78 additions & 0 deletions tinyagentos/peer.py
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,84 @@ def _canonical_json(obj: dict) -> bytes:
return json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")


def send_handshake(
*,
to_username: str,
inbound_token: str,
endpoints: list[str],
signing_pubkey: str,
encryption_pubkey: str,
) -> dict:
"""Build a handshake envelope addressed to a remote contact.

The handshake envelope carries the inbound peer token (which the remote
instance should present as ``Authorization: Bearer <token>`` when calling
our ``POST /api/peer/*`` routes), our advertised endpoints, and our public
keys so the remote side can pin them in its own contact row.

Returns the envelope dict (not yet delivered). The caller is responsible
for delivering it to the peer's endpoints.
"""
from tinyagentos.hub import identity as _hub_identity

local_ident = _hub_identity.public_identity()
from_username = resolve_local_identity_id()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: send_handshake resolves the local identity without a data_dir.

Here resolve_local_identity_id() is called with no data_dir, so it resolves hub.db from TAOS_DATA_DIR env or the default project data/hub path. But the caller in routes/hub.py resolves the request's configured identity via _resolve_local_identity_id(request.app.state.data_dir) (line 394) and intends this same identity to sign the envelope. In any deployment where the hub data dir differs from the default, send_handshake can resolve a different local identity (or raise RuntimeError("cannot send handshake: no local hub identity")), causing the handshake to be built/signed under the wrong identity or fail entirely.

send_handshake should accept a data_dir parameter (or take the already-resolved identity id) and pass it through to resolve_local_identity_id(data_dir).


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

if from_username is None:
raise RuntimeError("cannot send handshake: no local hub identity")
# Strip "hub:" prefix to get bare username
bare_from = from_username.split(":", 1)[1] if from_username.startswith("hub:") else from_username

body = {
"inbound_token": inbound_token,
"endpoints": endpoints,
"signing_pubkey": signing_pubkey or local_ident.get("signing_pubkey", ""),
"encryption_pubkey": encryption_pubkey or local_ident.get("encryption_pubkey", ""),
}
return build_envelope(
from_username=bare_from,
to_username=to_username,
kind="handshake",
body=body,
)


async def deliver_handshake(
envelope: dict,
peer_endpoints: list[str],
*,
http_client=None,
) -> bool:
"""Deliver a handshake envelope to the peer's endpoints (best-effort).

Tries each endpoint in order; stops on the first 2xx response. Returns
True if at least one endpoint accepted the envelope, False otherwise.

``http_client`` should be an ``httpx.AsyncClient``. If None, a temporary
client is created and torn down.
"""
import httpx

own_client = http_client is None
if own_client:
http_client = httpx.AsyncClient(timeout=15.0)

try:
for ep in peer_endpoints:
url = ep.rstrip("/") + "/api/peer/inbox"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: Handshake transmits the plaintext inbound_token secret; enforce TLS and surface delivery failures.

The envelope body carries inbound_token — the secret the peer uses as Bearer to authenticate against our /api/peer/* routes. It is POSTed to the peer's peer_endpoints, which are attacker/peer-supplied and may be plain http://. If a peer endpoint is HTTP, the token travels in cleartext. Consider rejecting/warning on non-HTTPS endpoints, and ensure TLS verification stays on.

Also, the inner except Exception: continue (line 248) silently swallows all errors for every endpoint, so a misconfigured/broken handshake delivery produces no log and always returns False without diagnostics. At minimum, log the failure reason per endpoint so the fire-and-forget task isn't entirely invisible.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

try:
resp = await http_client.post(
url, json={"envelope": envelope},
)
if 200 <= resp.status_code < 300:
return True
except Exception:
continue
return False
finally:
if own_client:
await http_client.aclose()


def resolve_local_identity_id(data_dir: str | Path | None = None) -> str | None:
"""Return this node's local hub identity ID (``"hub:<username>"``), or None.

Expand Down
Loading
Loading