diff --git a/.gitignore b/.gitignore index a5278cce3..39f4b07c4 100644 --- a/.gitignore +++ b/.gitignore @@ -131,6 +131,7 @@ data/pending-restart.json # Stray dev screenshot artifacts desktop-initial.png venv/ +data/hub/ # Credential material must never be committable. An agent stored a registry # token at /opt/taos/secrets/ on 2026-07-27 - correct permissions, but inside the diff --git a/changelog.d/2561-contacts-fingerprint-keying.md b/changelog.d/2561-contacts-fingerprint-keying.md new file mode 100644 index 000000000..0603fa416 --- /dev/null +++ b/changelog.d/2561-contacts-fingerprint-keying.md @@ -0,0 +1,10 @@ +### Fixed +- Cross-user contacts are now keyed on the peer's ed25519 signing-key + fingerprint rather than on their username, so a peer who changes or reuses a + username can no longer be confused with an existing pinned contact. Stores + created before this change are upgraded in place on first open: existing rows + have their `peer_fingerprint` backfilled from the stored public key, and rows + whose key is missing or malformed are left unkeyed instead of aborting the + upgrade. Revocation now reports how many contacts matched and cascades to + every contact sharing the revoked fingerprint, and blocking a peer cascades + consistently through both block paths (#2561). diff --git a/docs/design/cross-user-collaboration.md b/docs/design/cross-user-collaboration.md index ed39795c2..866dcc91a 100644 --- a/docs/design/cross-user-collaboration.md +++ b/docs/design/cross-user-collaboration.md @@ -427,6 +427,9 @@ lane (real build/test CI, fork-approval rules apply), FLEET = free-model builder - A1 LEAD: `contacts_store` plus `peer_links` plus envelope sign/verify plus peer route family plus rate limits. - A2 LEAD: friend-accept to contact-row plus handshake wiring (hub subscription). + **Deviation:** the peer-link token is minted locally without hub delivery — + the token exchange requires both instances to be online simultaneously, which + is acceptable for a pilot and avoids hub-trust assumptions for bearer tokens. - A3 HOGNEK: hub sealed-envelope relay endpoints on taos.my (T1, not pilot-blocking, can trail). - A4 FLEET (cards): ContactsApp taOS section UI; presence dots; request diff --git a/tests/test_collab_a2_handshake.py b/tests/test_collab_a2_handshake.py new file mode 100644 index 000000000..6e66250b1 --- /dev/null +++ b/tests/test_collab_a2_handshake.py @@ -0,0 +1,845 @@ +"""Tests for hub friend-accept -> contact row + peer-link handshake (collab A2). + +Covers: contact creation on accept, peer-link establishment, block cascade +to contacts_store. +""" + +from __future__ import annotations + +import json +import time +from pathlib import Path + +import httpx +import pytest +import pytest_asyncio +from httpx import ASGITransport, AsyncClient + +from taos_test_csrf import csrf_event_hooks + +from tinyagentos.contacts_store import generate_peer_token, _hash_token + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _fake_dir_resp(status=200, body=None): + """Build a fake upstream HTTP response matching _forward_to's interface.""" + if body is None: + body = {} + return httpx.Response( + status_code=status, + content=json.dumps(body).encode("utf-8"), + headers={"content-type": "application/json"}, + ) + + +def _patch_account_proxy(monkeypatch, handler): + """Intercept httpx.AsyncClient.request for calls to _UPSTREAM.""" + _UPSTREAM = "https://taos.my" + orig = httpx.AsyncClient.request + + async def routed(self, method, url, **kw): + url_s = str(url) + if url_s.startswith(_UPSTREAM): + return await handler(method, url_s, **kw) + return await orig(self, method, url, **kw) + + monkeypatch.setattr("httpx.AsyncClient.request", routed) + + +def _bootstrap_hub_identity(data_dir: Path, username: str = "localnode") -> str: + """Create a hub identity keystore + author row, return local id.""" + import sqlite3 + + from tinyagentos.hub import identity as _hub_identity + + hub_dir = data_dir / "hub" + hub_dir.mkdir(parents=True, exist_ok=True) + + _hub_identity.clear() + ident = _hub_identity.load_or_create() + fp = _hub_identity.signing_fingerprint() + + hub_db = hub_dir / "hub.db" + conn = sqlite3.connect(str(hub_db)) + conn.execute( + """CREATE TABLE IF NOT EXISTS hub_authors ( + fingerprint TEXT PRIMARY KEY, + username TEXT, + signing_pubkey TEXT, + encryption_pubkey TEXT, + updated_at REAL + )""" + ) + conn.execute( + "INSERT OR REPLACE INTO hub_authors (fingerprint, username, signing_pubkey, encryption_pubkey, updated_at) " + "VALUES (?, ?, ?, ?, ?)", + (fp, username, ident["signing_public"], ident["encryption_public"], time.time()), + ) + # Also create hub_relationships and hub_objects for completeness + conn.executescript( + """ + CREATE TABLE IF NOT EXISTS hub_objects ( + hash TEXT PRIMARY KEY, author TEXT NOT NULL, type TEXT NOT NULL, + seq INTEGER, version INTEGER, body TEXT NOT NULL, created_at REAL NOT NULL + ); + CREATE TABLE IF NOT EXISTS hub_relationships ( + peer TEXT NOT NULL, kind TEXT NOT NULL, statement TEXT, + quota_hint INTEGER, updated_at REAL NOT NULL, + PRIMARY KEY (peer, kind) + ); + CREATE TABLE IF NOT EXISTS hub_chain ( + author TEXT NOT NULL, seq INTEGER NOT NULL, hash TEXT NOT NULL, + prev_hash TEXT, type TEXT NOT NULL, target TEXT, created_at REAL NOT NULL, + PRIMARY KEY (author, seq) + ); + """ + ) + conn.commit() + conn.close() + return f"hub:{username}" + + +_PEER_FP = "9a2db2e23f1504cd056606553ac049c5e718e8f9ce9233876df1a7a1821af885" # SHA-256 of _PEER_SIGNING_PUB +_PEER_USERNAME = "remotepeer" +_PEER_SIGNING_PUB = "ab" * 32 # 64-char fake Ed25519 pubkey +_PEER_ENCRYPTION_PUB = "cd" * 32 # 64-char fake X25519 pubkey + +# A second, distinct peer that shares _PEER_USERNAME's username but has a +# different signing key (hence a different fingerprint). Used to prove the +# contact key is fingerprint-based: a username collision must not overwrite +# the first contact's pinned key material. +_PEER2_FP = "b9c61610704cb9b9ea441aa8afe5d7d8e852a30f918001cda5c19951ffb62aad" # SHA-256 of _PEER2_SIGNING_PUB +_PEER2_SIGNING_PUB = "ef" * 32 +_PEER2_ENCRYPTION_PUB = "fe" * 32 + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest_asyncio.fixture +async def app_with_contacts(tmp_data_dir, monkeypatch): + """Create an app with contacts_store and a bootstrapped hub identity.""" + from tinyagentos.app import create_app + + _app = create_app(data_dir=tmp_data_dir) + + # Initialise contacts_store + store = _app.state.contacts_store + if store._db is not None: + await store.close() + await store.init() + + # Bootstrap hub identity + monkeypatch.setenv("TAOS_DATA_DIR", str(tmp_data_dir)) + _bootstrap_hub_identity(tmp_data_dir) + + yield _app + + # Cleanup: close the contacts_store so the on-disk database file + # is released before tmp_data_dir (tmp_path) tears down. + store = _app.state.contacts_store + if store is not None and store._db is not None: + await store.close() + + +@pytest_asyncio.fixture +async def client_with_contacts(app_with_contacts): + """Async client with contacts_store, auth, and proxied directory.""" + _app = app_with_contacts + + _app.state.auth.setup_user("admin", "Test Admin", "", "testpass") + _rec = _app.state.auth.find_user("admin") + _uid = _rec["id"] if _rec else "" + _token = _app.state.auth.create_session(user_id=_uid, long_lived=True) + _app.state._startup_complete = True + + transport = ASGITransport(app=_app) + async with AsyncClient( + transport=transport, + base_url="http://test", + cookies={"taos_session": _token}, + event_hooks=csrf_event_hooks(), + ) as c: + yield c + + +# --------------------------------------------------------------------------- +# Tests: friend-accept -> contact + peer link +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +class TestFriendAcceptHandshake: + async def test_accept_creates_contact_and_peer_link( + self, client_with_contacts, app_with_contacts, monkeypatch + ): + """Accepting a friend request creates a contact row and peer link.""" + dir_resp_body = { + "peer": _PEER_FP, + "username": _PEER_USERNAME, + "display_name": "Remote Peer", + "signing_pubkey": _PEER_SIGNING_PUB, + "encryption_pubkey": _PEER_ENCRYPTION_PUB, + "endpoints": ["https://peer.example.com:6969"], + } + + async def handler(method, url, **kw): + return _fake_dir_resp(body=dir_resp_body) + + _patch_account_proxy(monkeypatch, handler) + + resp = await client_with_contacts.post( + "/api/hub/friends/requests/test-rid/accept", + json={"peer_fingerprint": _PEER_FP}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["state"] == "accepted" + assert data["peer"] == _PEER_FP + + # Verify contact row was created + store = app_with_contacts.state.contacts_store + contact = await store.get_contact(f"hub:{_PEER_FP}") + assert contact is not None, "contact should be created on accept" + assert contact["hub_username"] == _PEER_USERNAME + assert contact["display_name"] == "Remote Peer" + assert contact["ed25519_pub"] == _PEER_SIGNING_PUB + assert contact["x25519_pub"] == _PEER_ENCRYPTION_PUB + assert contact["status"] == "active" + + # Verify peer link was established + link = await store.get_peer_link(f"hub:{_PEER_FP}") + assert link is not None, "peer link should be established on accept" + assert link["endpoints"] == [{"kind": "hub", "url": "https://peer.example.com:6969", "priority": 0}] + # inbound_token should be a fresh token + assert link["inbound_token_hash"] is not None + # outbound_token is empty placeholder until A3 handshake reply + assert link["outbound_token"] == "" + + async def test_accept_falls_back_to_hub_authors( + self, client_with_contacts, app_with_contacts, monkeypatch + ): + """When the directory omits pubkeys, fall back to hub_authors.""" + # Directory response *without* pubkeys + dir_resp_body = { + "peer": _PEER_FP, + "username": _PEER_USERNAME, + "display_name": "Remote Peer", + "endpoints": ["https://peer.example.com:6969"], + } + + async def handler(method, url, **kw): + return _fake_dir_resp(body=dir_resp_body) + + _patch_account_proxy(monkeypatch, handler) + + # Pre-populate hub_authors so the fallback works + from tinyagentos.hub.store import HubStore + hub_store = HubStore( + Path(app_with_contacts.state.data_dir) / "hub" / "hub.db" + ) + try: + await hub_store.init() + await hub_store.upsert_author( + _PEER_FP, + username=_PEER_USERNAME, + signing_pubkey=_PEER_SIGNING_PUB, + encryption_pubkey=_PEER_ENCRYPTION_PUB, + ) + finally: + await hub_store.close() + + resp = await client_with_contacts.post( + "/api/hub/friends/requests/test-rid-2/accept", + json={"peer_fingerprint": _PEER_FP}, + ) + assert resp.status_code == 200 + + store = app_with_contacts.state.contacts_store + contact = await store.get_contact(f"hub:{_PEER_FP}") + assert contact is not None + assert contact["ed25519_pub"] == _PEER_SIGNING_PUB + assert contact["x25519_pub"] == _PEER_ENCRYPTION_PUB + + async def test_accept_skips_handshake_when_no_pubkeys( + self, client_with_contacts, app_with_contacts, monkeypatch + ): + """When neither directory nor hub_authors have pubkeys, accept still + succeeds but skips the handshake (no contact row).""" + dir_resp_body = { + "peer": _PEER_FP, + "username": _PEER_USERNAME, + } + + async def handler(method, url, **kw): + return _fake_dir_resp(body=dir_resp_body) + + _patch_account_proxy(monkeypatch, handler) + + resp = await client_with_contacts.post( + "/api/hub/friends/requests/test-rid-3/accept", + json={"peer_fingerprint": _PEER_FP}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["state"] == "accepted" + + store = app_with_contacts.state.contacts_store + contact = await store.get_contact(f"hub:{_PEER_FP}") + assert contact is None, "no contact should be created without pubkeys" + link = await store.get_peer_link(f"hub:{_PEER_FP}") + assert link is None, "no peer link should be established without pubkeys" + + async def test_accept_handles_non_list_endpoints( + self, client_with_contacts, app_with_contacts, monkeypatch + ): + """Gracefully handles endpoints that are a string or missing.""" + dir_resp_body = { + "peer": _PEER_FP, + "username": _PEER_USERNAME, + "signing_pubkey": _PEER_SIGNING_PUB, + "encryption_pubkey": _PEER_ENCRYPTION_PUB, + "endpoints": '["https://peer.example.com:6969"]', # JSON string + } + + async def handler(method, url, **kw): + return _fake_dir_resp(body=dir_resp_body) + + _patch_account_proxy(monkeypatch, handler) + + resp = await client_with_contacts.post( + "/api/hub/friends/requests/test-rid-ep/accept", + json={"peer_fingerprint": _PEER_FP}, + ) + assert resp.status_code == 200 + + store = app_with_contacts.state.contacts_store + link = await store.get_peer_link(f"hub:{_PEER_FP}") + assert link is not None + assert link["endpoints"] == [{"kind": "hub", "url": "https://peer.example.com:6969", "priority": 0}] + + async def test_accept_reupsert_contact( + self, client_with_contacts, app_with_contacts, monkeypatch + ): + """Re-accepting a friend (re-establish) refreshes the contact and link, + and clears a prior revocation.""" + dir_resp_body = { + "peer": _PEER_FP, + "username": _PEER_USERNAME, + "signing_pubkey": _PEER_SIGNING_PUB, + "encryption_pubkey": _PEER_ENCRYPTION_PUB, + "endpoints": ["https://first.example.com:6969"], + } + + async def handler(method, url, **kw): + return _fake_dir_resp(body=dir_resp_body) + + _patch_account_proxy(monkeypatch, handler) + + # First accept + resp = await client_with_contacts.post( + "/api/hub/friends/requests/test-rid-re/accept", + json={"peer_fingerprint": _PEER_FP}, + ) + assert resp.status_code == 200 + + store = app_with_contacts.state.contacts_store + first_link = await store.get_peer_link(f"hub:{_PEER_FP}") + first_established = first_link["established_at"] + + # Simulate a revocation so we can verify re-establish actually clears it. + await store.revoke_peer_link(f"hub:{_PEER_FP}") + revoked_link = await store.get_peer_link(f"hub:{_PEER_FP}") + assert revoked_link["revoked_at"] is not None, "revocation must stick" + + # Second accept with different endpoints — should re-establish and clear + dir_resp_body["endpoints"] = ["https://second.example.com:6969"] + resp2 = await client_with_contacts.post( + "/api/hub/friends/requests/test-rid-re/accept", + json={"peer_fingerprint": _PEER_FP}, + ) + assert resp2.status_code == 200 + + link = await store.get_peer_link(f"hub:{_PEER_FP}") + assert link is not None + assert link["endpoints"] == [{"kind": "hub", "url": "https://second.example.com:6969", "priority": 0}] + assert link["revoked_at"] is None, "re-establish must clear revocation" + + async def test_accept_without_contacts_store_does_not_crash( + self, client_with_contacts, app_with_contacts, monkeypatch + ): + """Handshake is best-effort — missing contacts_store must not break accept.""" + app_with_contacts.state.contacts_store = None + + dir_resp_body = { + "peer": _PEER_FP, + "username": _PEER_USERNAME, + "signing_pubkey": _PEER_SIGNING_PUB, + "encryption_pubkey": _PEER_ENCRYPTION_PUB, + } + + async def handler(method, url, **kw): + return _fake_dir_resp(body=dir_resp_body) + + _patch_account_proxy(monkeypatch, handler) + + resp = await client_with_contacts.post( + "/api/hub/friends/requests/test-rid-nocs/accept", + json={"peer_fingerprint": _PEER_FP}, + ) + assert resp.status_code == 200 + assert resp.json()["state"] == "accepted" + + async def test_accept_same_username_second_peer_does_not_overwrite( + self, client_with_contacts, app_with_contacts, monkeypatch + ): + """A second peer sharing an existing contact's username must pin its own + fingerprint-keyed contact without overwriting the first's key material.""" + store = app_with_contacts.state.contacts_store + + # First peer: username "remotepeer", fingerprint _PEER_FP. + dir_resp_1 = { + "peer": _PEER_FP, + "username": _PEER_USERNAME, + "display_name": "Remote One", + "signing_pubkey": _PEER_SIGNING_PUB, + "encryption_pubkey": _PEER_ENCRYPTION_PUB, + "endpoints": ["https://one.example.com:6969"], + } + + async def handler1(method, url, **kw): + return _fake_dir_resp(body=dir_resp_1) + + _patch_account_proxy(monkeypatch, handler1) + resp = await client_with_contacts.post( + "/api/hub/friends/requests/test-rid-dup1/accept", + json={"peer_fingerprint": _PEER_FP}, + ) + assert resp.status_code == 200 + + first = await store.get_contact(f"hub:{_PEER_FP}") + assert first is not None + assert first["ed25519_pub"] == _PEER_SIGNING_PUB + assert first["x25519_pub"] == _PEER_ENCRYPTION_PUB + + # Second peer: SAME username, different fingerprint + key material. + dir_resp_2 = { + "peer": _PEER2_FP, + "username": _PEER_USERNAME, + "display_name": "Remote Two", + "signing_pubkey": _PEER2_SIGNING_PUB, + "encryption_pubkey": _PEER2_ENCRYPTION_PUB, + "endpoints": ["https://two.example.com:6969"], + } + + async def handler2(method, url, **kw): + return _fake_dir_resp(body=dir_resp_2) + + _patch_account_proxy(monkeypatch, handler2) + resp2 = await client_with_contacts.post( + "/api/hub/friends/requests/test-rid-dup2/accept", + json={"peer_fingerprint": _PEER2_FP}, + ) + assert resp2.status_code == 200 + + # The first contact's pins are intact — NOT overwritten by the name twin. + first_again = await store.get_contact(f"hub:{_PEER_FP}") + assert first_again is not None + assert first_again["ed25519_pub"] == _PEER_SIGNING_PUB + assert first_again["x25519_pub"] == _PEER_ENCRYPTION_PUB + assert first_again["peer_fingerprint"] == _PEER_FP + + # The second peer got its own distinct, fingerprint-keyed contact. + second = await store.get_contact(f"hub:{_PEER2_FP}") + assert second is not None + assert second["ed25519_pub"] == _PEER2_SIGNING_PUB + assert second["x25519_pub"] == _PEER2_ENCRYPTION_PUB + assert second["peer_fingerprint"] == _PEER2_FP + + # Both share a username but are distinct contacts. + assert second["hub_username"] == first_again["hub_username"] == _PEER_USERNAME + + +# --------------------------------------------------------------------------- +# Tests: block -> cascade to contacts +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +class TestBlockCascade: + async def test_block_cascades_to_contacts_store( + self, client_with_contacts, app_with_contacts, monkeypatch + ): + """Blocking a friend revokes the peer link.""" + # First, create a contact and peer link so there's something to revoke. + store = app_with_contacts.state.contacts_store + await store.add_contact( + contact_id=f"hub:{_PEER_FP}", + hub_username=_PEER_USERNAME, + display_name="Remote", + ed25519_pub=_PEER_SIGNING_PUB, + x25519_pub=_PEER_ENCRYPTION_PUB, + peer_fingerprint=_PEER_FP, + ) + await store.establish_peer_link( + contact_id=f"hub:{_PEER_FP}", + inbound_token=generate_peer_token(), + outbound_token=generate_peer_token(), + ) + + # Pre-populate hub_authors so block can resolve fingerprint -> username. + from tinyagentos.hub.store import HubStore + hub_store = HubStore( + Path(app_with_contacts.state.data_dir) / "hub" / "hub.db" + ) + try: + await hub_store.init() + await hub_store.upsert_author( + _PEER_FP, + username=_PEER_USERNAME, + signing_pubkey=_PEER_SIGNING_PUB, + encryption_pubkey=_PEER_ENCRYPTION_PUB, + ) + finally: + await hub_store.close() + + # Mock the directory block edge revoke call (best-effort, must not fail block). + async def handler(method, url, **kw): + if "/api/hub/edges/revoke" in url: + return _fake_dir_resp(body={"status": "revoked"}) + return _fake_dir_resp(body={}) + + _patch_account_proxy(monkeypatch, handler) + + resp = await client_with_contacts.post( + "/api/hub/friends/block", + json={"peer_fingerprint": _PEER_FP}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["state"] == "blocked" + + # Verify peer link is revoked + link = await store.get_peer_link(f"hub:{_PEER_FP}") + assert link["revoked_at"] is not None + + # Verify contact is blocked + contact = await store.get_contact(f"hub:{_PEER_FP}") + assert contact["status"] == "blocked" + + async def test_block_cascade_handles_missing_contacts_store( + self, client_with_contacts, app_with_contacts, monkeypatch + ): + """Block must succeed even when contacts_store is unavailable.""" + app_with_contacts.state.contacts_store = None + + async def handler(method, url, **kw): + if "/api/hub/edges/revoke" in url: + return _fake_dir_resp(body={"status": "revoked"}) + return _fake_dir_resp(body={}) + + _patch_account_proxy(monkeypatch, handler) + + resp = await client_with_contacts.post( + "/api/hub/friends/block", + json={"peer_fingerprint": _PEER_FP}, + ) + assert resp.status_code == 200 + assert resp.json()["state"] == "blocked" + + async def test_block_cascade_fingerprint_fallback( + self, client_with_contacts, app_with_contacts, monkeypatch + ): + """Block revokes peer link via fingerprint fallback when hub_authors is empty.""" + store = app_with_contacts.state.contacts_store + + # Create contact + peer link with fingerprint, but do NOT seed hub_authors. + await store.add_contact( + contact_id=f"hub:{_PEER_FP}", + hub_username=_PEER_USERNAME, + display_name="Remote", + ed25519_pub=_PEER_SIGNING_PUB, + x25519_pub=_PEER_ENCRYPTION_PUB, + peer_fingerprint=_PEER_FP, + ) + await store.establish_peer_link( + contact_id=f"hub:{_PEER_FP}", + inbound_token=generate_peer_token(), + outbound_token=generate_peer_token(), + ) + + # Mock directory block-edge revoke. + async def handler(method, url, **kw): + if "/api/hub/edges/revoke" in url: + return _fake_dir_resp(body={"status": "revoked"}) + return _fake_dir_resp(body={}) + + _patch_account_proxy(monkeypatch, handler) + + resp = await client_with_contacts.post( + "/api/hub/friends/block", + json={"peer_fingerprint": _PEER_FP}, + ) + assert resp.status_code == 200 + assert resp.json()["state"] == "blocked" + + # Verify peer link is revoked (fingerprint fallback worked) + link = await store.get_peer_link(f"hub:{_PEER_FP}") + assert link["revoked_at"] is not None + + # Verify contact is blocked + contact = await store.get_contact(f"hub:{_PEER_FP}") + assert contact["status"] == "blocked" + + async def test_block_cascade_revokes_when_cached_username_stale( + self, client_with_contacts, app_with_contacts, monkeypatch + ): + """Block revokes the peer link even when the hub_authors username cache + is present-but-stale (renamed since the contact was pinned).""" + store = app_with_contacts.state.contacts_store + + # Contact pinned under the fingerprint, with the ORIGINAL username. + await store.add_contact( + contact_id=f"hub:{_PEER_FP}", + hub_username="original-name", + display_name="Remote", + ed25519_pub=_PEER_SIGNING_PUB, + x25519_pub=_PEER_ENCRYPTION_PUB, + peer_fingerprint=_PEER_FP, + ) + await store.establish_peer_link( + contact_id=f"hub:{_PEER_FP}", + inbound_token=generate_peer_token(), + outbound_token=generate_peer_token(), + ) + + # Seed hub_authors with a DIFFERENT (stale) username for the same + # fingerprint — the peer renamed after the contact was pinned. + from tinyagentos.hub.store import HubStore + + hub_store = HubStore( + Path(app_with_contacts.state.data_dir) / "hub" / "hub.db" + ) + try: + await hub_store.init() + await hub_store.upsert_author( + _PEER_FP, + username="renamed-later", + signing_pubkey=_PEER_SIGNING_PUB, + encryption_pubkey=_PEER_ENCRYPTION_PUB, + ) + finally: + await hub_store.close() + + async def handler(method, url, **kw): + if "/api/hub/edges/revoke" in url: + return _fake_dir_resp(body={"status": "revoked"}) + return _fake_dir_resp(body={}) + + _patch_account_proxy(monkeypatch, handler) + + resp = await client_with_contacts.post( + "/api/hub/friends/block", + json={"peer_fingerprint": _PEER_FP}, + ) + assert resp.status_code == 200 + assert resp.json()["state"] == "blocked" + + # Revocation resolved via the fingerprint, not the stale username. + link = await store.get_peer_link(f"hub:{_PEER_FP}") + assert link["revoked_at"] is not None + contact = await store.get_contact(f"hub:{_PEER_FP}") + assert contact["status"] == "blocked" + + async def test_block_cascade_revokes_all_contacts_sharing_fingerprint( + self, client_with_contacts, app_with_contacts, monkeypatch + ): + """Block revokes every contact pinned to the fingerprint, not just the + first row — legacy username-keyed contacts can share a fingerprint, and + revoking only rows[0] would leave a live peer link behind.""" + store = app_with_contacts.state.contacts_store + + # Two legacy contacts keyed on DIFFERENT usernames but the SAME + # fingerprint (the state a rename-before-accept used to produce). + legacy_ids = ("hub:legacy-name-one", "hub:legacy-name-two") + for legacy_id, name in ( + (legacy_ids[0], "legacy-name-one"), + (legacy_ids[1], "legacy-name-two"), + ): + await store.add_contact( + contact_id=legacy_id, + hub_username=name, + display_name="Remote", + ed25519_pub=_PEER_SIGNING_PUB, + x25519_pub=_PEER_ENCRYPTION_PUB, + peer_fingerprint=_PEER_FP, + ) + await store.establish_peer_link( + contact_id=legacy_id, + inbound_token=generate_peer_token(), + outbound_token=generate_peer_token(), + ) + + async def handler(method, url, **kw): + if "/api/hub/edges/revoke" in url: + return _fake_dir_resp(body={"status": "revoked"}) + return _fake_dir_resp(body={}) + + _patch_account_proxy(monkeypatch, handler) + + resp = await client_with_contacts.post( + "/api/hub/friends/block", + json={"peer_fingerprint": _PEER_FP}, + ) + assert resp.status_code == 200 + assert resp.json()["state"] == "blocked" + + # BOTH legacy contacts must be revoked and blocked, not just the first. + for legacy_id in legacy_ids: + link = await store.get_peer_link(legacy_id) + assert link["revoked_at"] is not None, ( + f"peer link {legacy_id} must be revoked" + ) + contact = await store.get_contact(legacy_id) + assert contact["status"] == "blocked", ( + f"contact {legacy_id} must be blocked" + ) + + +# --------------------------------------------------------------------------- +# Security regression tests +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +class TestSecurityRegression: + async def test_anti_imposter_mismatched_pubkey( + self, client_with_contacts, app_with_contacts, monkeypatch + ): + """A directory response with a signing_pubkey that does NOT match the + peer fingerprint must NOT create a contact — prevents an imposter from + hijacking the handshake.""" + dir_resp_body = { + "peer": _PEER_FP, + "username": _PEER_USERNAME, + "display_name": "Imposter", + "signing_pubkey": "ff" * 32, # WRONG — does not hash to _PEER_FP + "encryption_pubkey": _PEER_ENCRYPTION_PUB, + "endpoints": ["https://imposter.example.com:6969"], + } + + async def handler(method, url, **kw): + return _fake_dir_resp(body=dir_resp_body) + + _patch_account_proxy(monkeypatch, handler) + + resp = await client_with_contacts.post( + "/api/hub/friends/requests/test-rid-imp/accept", + json={"peer_fingerprint": _PEER_FP}, + ) + # Should still return 200 (accept doesn't fail) but NO contact created + assert resp.status_code == 200 + + store = app_with_contacts.state.contacts_store + contact = await store.get_contact(f"hub:{_PEER_FP}") + assert contact is None, "imposter pubkey must not create a contact" + link = await store.get_peer_link(f"hub:{_PEER_FP}") + assert link is None, "imposter pubkey must not create a peer link" + + async def test_authz_rejection_no_handshake( + self, client_with_contacts, app_with_contacts, monkeypatch + ): + """A failed directory lookup (403/404) must NOT establish a contact + or peer link — the handshake must fail closed. The route returns + the upstream status code on failure.""" + # Directory returns 403 + async def handler(method, url, **kw): + return _fake_dir_resp( + status=403, body={"error": "forbidden"} + ) + + _patch_account_proxy(monkeypatch, handler) + + resp = await client_with_contacts.post( + "/api/hub/friends/requests/test-rid-403/accept", + json={"peer_fingerprint": _PEER_FP}, + ) + # Route passes upstream status; no contact/handshake happens + assert resp.status_code == 403 + data = resp.json() + assert data["state"] == "rejected" + + store = app_with_contacts.state.contacts_store + contact = await store.get_contact(f"hub:{_PEER_FP}") + assert contact is None, "403 must not create a contact" + link = await store.get_peer_link(f"hub:{_PEER_FP}") + assert link is None, "403 must not create a peer link" + + async def test_block_guard_prevent_reaccept_resurrection( + self, client_with_contacts, app_with_contacts, monkeypatch + ): + """Blocking a peer then re-accepting the same fingerprint must NOT + resurrect the contact — the block guard prevents it.""" + store = app_with_contacts.state.contacts_store + + # Create a contact and peer link, then block. + await store.add_contact( + contact_id=f"hub:{_PEER_FP}", + hub_username=_PEER_USERNAME, + display_name="Remote", + ed25519_pub=_PEER_SIGNING_PUB, + x25519_pub=_PEER_ENCRYPTION_PUB, + peer_fingerprint=_PEER_FP, + ) + await store.establish_peer_link( + contact_id=f"hub:{_PEER_FP}", + inbound_token=generate_peer_token(), + outbound_token=generate_peer_token(), + ) + await store.set_contact_status(f"hub:{_PEER_FP}", "blocked") + + # Also add REL_BLOCK on hub relationships — the accept guard + # checks has_edge(peer, REL_BLOCK) at the hub layer, not the + # contacts layer. + from tinyagentos.hub.store import HubStore + hub_store = HubStore( + Path(app_with_contacts.state.data_dir) / "hub" / "hub.db" + ) + try: + await hub_store.init() + await hub_store.put_relationship(_PEER_FP, "block") + finally: + await hub_store.close() + + # Now try to re-accept + dir_resp_body = { + "peer": _PEER_FP, + "username": _PEER_USERNAME, + "display_name": "Remote Peer", + "signing_pubkey": _PEER_SIGNING_PUB, + "encryption_pubkey": _PEER_ENCRYPTION_PUB, + "endpoints": ["https://peer.example.com:6969"], + } + + async def handler(method, url, **kw): + return _fake_dir_resp(body=dir_resp_body) + + _patch_account_proxy(monkeypatch, handler) + + resp = await client_with_contacts.post( + "/api/hub/friends/requests/test-rid-res/accept", + json={"peer_fingerprint": _PEER_FP}, + ) + assert resp.status_code == 200 + + # Contact must still be blocked — NOT resurrected to active + contact = await store.get_contact(f"hub:{_PEER_FP}") + assert contact is not None + assert contact["status"] == "blocked", ( + "blocked contact must not be resurrected by re-accept" + ) diff --git a/tests/test_store_upgrades.py b/tests/test_store_upgrades.py index 1ee5bf330..a3360df2a 100644 --- a/tests/test_store_upgrades.py +++ b/tests/test_store_upgrades.py @@ -29,6 +29,8 @@ from tinyagentos.projects.task_store import ProjectTaskStore from tinyagentos.chat.channel_store import ChatChannelStore from tinyagentos.notes.shared_docs_store import SharedDocsStore +from tinyagentos.contacts_store import ContactsStore +from tinyagentos.hub.identity import fingerprint as _compute_fingerprint # --------------------------------------------------------------------------- @@ -483,6 +485,165 @@ async def test_upgrade_adds_migration_columns(self, tmp_path): await store.close() +# --------------------------------------------------------------------------- +# ContactsStore — column peer_fingerprint added in _post_init +# --------------------------------------------------------------------------- + +CONTACTS_V0_SCHEMA = """\ +CREATE TABLE IF NOT EXISTS contacts ( + contact_id TEXT PRIMARY KEY, + hub_username TEXT NOT NULL UNIQUE, + display_name TEXT NOT NULL, + ed25519_pub TEXT NOT NULL, + x25519_pub TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + local_crm_id TEXT, + created_at REAL NOT NULL, + revoked_at REAL +); +CREATE TABLE IF NOT EXISTS peer_links ( + contact_id TEXT PRIMARY KEY REFERENCES contacts(contact_id), + inbound_token_hash TEXT NOT NULL, + outbound_token TEXT NOT NULL, + endpoints TEXT NOT NULL DEFAULT '[]', + established_at REAL NOT NULL, + last_seen_at REAL, + revoked_at REAL +); +CREATE INDEX IF NOT EXISTS idx_peer_links_token_hash ON peer_links(inbound_token_hash); +CREATE TABLE IF NOT EXISTS peer_nonces ( + nonce TEXT NOT NULL, + contact_id TEXT NOT NULL, + kind TEXT NOT NULL DEFAULT '', + seen_at REAL NOT NULL, + PRIMARY KEY (contact_id, kind, nonce) +); +""" + + +@pytest.mark.asyncio +class TestContactsStoreUpgrade: + async def test_upgrade_adds_peer_fingerprint_column(self, tmp_path): + db_path = tmp_path / "contacts.db" + _seed_db(db_path, CONTACTS_V0_SCHEMA) + store = ContactsStore(db_path) + await store.init() + try: + cols = _column_names(db_path, "contacts") + assert "peer_fingerprint" in cols, "peer_fingerprint missing after upgrade" + finally: + await store.close() + + async def test_upgrade_add_contact_works_after_upgrade(self, tmp_path): + db_path = tmp_path / "contacts.db" + _seed_db(db_path, CONTACTS_V0_SCHEMA) + store = ContactsStore(db_path) + await store.init() + try: + await store.add_contact( + contact_id="hub:test", + hub_username="test", + display_name="Test", + ed25519_pub="ab" * 32, + x25519_pub="cd" * 32, + peer_fingerprint="deadbeef", + ) + contact = await store.get_contact("hub:test") + assert contact is not None + assert contact["peer_fingerprint"] == "deadbeef" + finally: + await store.close() + + async def test_upgrade_backfills_fingerprint_for_existing_rows( + self, tmp_path + ): + """Regression: rows predating peer_fingerprint must get backfilled. + + Without backfill, block_peer (routes/hub.py) resolves peers by + fingerprint only and silently fails to revoke the peer link for + every pre-existing contact — the block path fails open. + """ + db_path = tmp_path / "contacts.db" + _seed_db(db_path, CONTACTS_V0_SCHEMA) + # Seed a pre-existing contact with key material but no fingerprint + # column (the v0 schema has no peer_fingerprint). + now = time.time() + db = sqlite3.connect(str(db_path)) + db.execute( + "INSERT INTO contacts " + "(contact_id, hub_username, display_name, ed25519_pub, x25519_pub," + " status, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", + ("hub:testpeer", "testpeer", "Test Peer", + "ab" * 32, "cd" * 32, "active", now), + ) + db.commit() + db.close() + + store = ContactsStore(db_path) + await store.init() + try: + contact = await store.get_contact("hub:testpeer") + assert contact is not None + assert contact["peer_fingerprint"] != "", ( + "peer_fingerprint not backfilled for pre-existing row" + ) + expected = _compute_fingerprint("ab" * 32) + assert contact["peer_fingerprint"] == expected, ( + f"fingerprint mismatch: {contact['peer_fingerprint']} != {expected}" + ) + finally: + await store.close() + + async def test_upgrade_skips_malformed_ed25519_during_backfill( + self, tmp_path + ): + """Backfill must not brick boot when a v0 row has non-hex key material. + + _compute_fingerprint calls bytes.fromhex, which raises ValueError on + odd-length strings, non-hex chars, or embedded NULs. A single bad row + must never crash init() or abort the rest of the backfill. + """ + db_path = tmp_path / "contacts.db" + _seed_db(db_path, CONTACTS_V0_SCHEMA) + now = time.time() + db = sqlite3.connect(str(db_path)) + # Row with valid hex — must be backfilled. + db.execute( + "INSERT INTO contacts " + "(contact_id, hub_username, display_name, ed25519_pub, x25519_pub," + " status, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", + ("hub:valid", "valid", "Valid", "ab" * 32, "cd" * 32, + "active", now), + ) + # Row with malformed hex — must NOT crash init(). + db.execute( + "INSERT INTO contacts " + "(contact_id, hub_username, display_name, ed25519_pub, x25519_pub," + " status, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", + ("hub:badhex", "badhex", "Bad Hex", "not-hex-data!!!", "cd" * 32, + "active", now), + ) + db.commit() + db.close() + + store = ContactsStore(db_path) + await store.init() # must not raise + try: + # Valid row is backfilled. + valid = await store.get_contact("hub:valid") + assert valid is not None + assert valid["peer_fingerprint"] == _compute_fingerprint("ab" * 32) + + # Bad row is reachable (not bricked), fingerprint stays empty. + bad = await store.get_contact("hub:badhex") + assert bad is not None + assert bad["peer_fingerprint"] == "", ( + "malformed-hex row must keep empty fingerprint, not crash" + ) + finally: + await store.close() + + # --------------------------------------------------------------------------- # Regression: no SCHEMA CREATE INDEX references a _post_init-added column # --------------------------------------------------------------------------- diff --git a/tinyagentos/contacts_store.py b/tinyagentos/contacts_store.py index 39d63f23d..db6d5b4aa 100644 --- a/tinyagentos/contacts_store.py +++ b/tinyagentos/contacts_store.py @@ -1,6 +1,7 @@ from __future__ import annotations import hashlib +import logging import secrets import sqlite3 import time @@ -8,15 +9,19 @@ from typing import Optional from tinyagentos.base_store import BaseStore +from tinyagentos.hub.identity import fingerprint as _compute_fingerprint + +logger = logging.getLogger(__name__) CONTACTS_SCHEMA = """ CREATE TABLE IF NOT EXISTS contacts ( - contact_id TEXT PRIMARY KEY, -- "hub:{username}" e.g. "hub:hogne" - hub_username TEXT NOT NULL UNIQUE, + contact_id TEXT PRIMARY KEY, -- "hub:{fingerprint}" — canonical key, never the username + hub_username TEXT NOT NULL, -- display column; not unique (distinct peers may share a name) display_name TEXT NOT NULL, - ed25519_pub TEXT NOT NULL, -- pinned at friend-accept - x25519_pub TEXT NOT NULL, + ed25519_pub TEXT NOT NULL, -- pinned at friend-accept; verified via signature challenge + x25519_pub TEXT NOT NULL, -- accepted unverified — no verification protocol exists at this head; re-pinned every accept/re-accept + peer_fingerprint TEXT NOT NULL DEFAULT '', -- signing-key fingerprint; stable lookup key status TEXT NOT NULL DEFAULT 'pending', -- pending|active|blocked|revoked local_crm_id TEXT, -- optional link to existing CRM row created_at REAL NOT NULL, @@ -75,6 +80,112 @@ class ContactsStore(BaseStore): MIGRATIONS: list = [] + async def _post_init(self) -> None: + """Add ``peer_fingerprint`` column on pre-existing databases. + + The ``peer_fingerprint`` column was added after contacts_store shipped + to dev (PR #2025). BaseStore's migration runner uses baseline-at-latest + semantics, so a MIGRATIONS entry would stamp existing DBs at the latest + version without executing the ALTER, leaving the column absent. We + use the guarded PRAGMA pattern instead: check if the column exists, and + ALTER only when it is missing. Fresh databases get the column from + SCHEMA; upgraded databases get it here. + """ + existing_cols = { + row[1] + for row in await ( + await self._db.execute("PRAGMA table_info(contacts)") + ).fetchall() + } + if "peer_fingerprint" not in existing_cols: + await self._db.execute( + "ALTER TABLE contacts ADD COLUMN peer_fingerprint TEXT NOT NULL DEFAULT ''" + ) + await self._db.commit() + + # Drop the legacy UNIQUE constraint on hub_username. Contacts are now + # keyed on the peer's signing-key fingerprint (contact_id = "hub:{fp}"), + # so username is a non-unique display column: two distinct peers may + # share a name without one overwriting the other's pinned key material. + # SQLite refuses to drop an inline UNIQUE auto-index, so we rebuild the + # table without it (same pattern as db_migrations' namespace rebuild). + if await self._hub_username_unique_index_exists(): + await self._db.execute("BEGIN") + try: + await self._db.execute( + "CREATE TABLE contacts_new (" + " contact_id TEXT PRIMARY KEY," + " hub_username TEXT NOT NULL," + " display_name TEXT NOT NULL," + " ed25519_pub TEXT NOT NULL," + " x25519_pub TEXT NOT NULL," + " peer_fingerprint TEXT NOT NULL DEFAULT ''," + " status TEXT NOT NULL DEFAULT 'pending'," + " local_crm_id TEXT," + " created_at REAL NOT NULL," + " revoked_at REAL" + ")" + ) + await self._db.execute( + "INSERT INTO contacts_new " + "(contact_id, hub_username, display_name, ed25519_pub, x25519_pub, " + " peer_fingerprint, status, local_crm_id, created_at, revoked_at) " + "SELECT contact_id, hub_username, display_name, ed25519_pub, x25519_pub, " + " peer_fingerprint, status, local_crm_id, created_at, revoked_at " + "FROM contacts" + ) + await self._db.execute("DROP TABLE contacts") + await self._db.execute( + "ALTER TABLE contacts_new RENAME TO contacts" + ) + await self._db.commit() + except BaseException: + await self._db.rollback() + raise + + # Backfill peer_fingerprint for pre-existing contacts that predate the + # column. The ALTER TABLE above seeds them with DEFAULT '', and the + # rebuild copies those empty values verbatim. Without backfill, these + # contacts are invisible to block_peer (which resolves by fingerprint), + # so the block path fails open — the peer link is never revoked. + async with self._db.execute( + "SELECT contact_id, ed25519_pub FROM contacts " + "WHERE peer_fingerprint = '' AND ed25519_pub != ''" + ) as cursor: + stale = await cursor.fetchall() + for contact_id, ed25519_pub in stale: + try: + fp = _compute_fingerprint(ed25519_pub) + except (ValueError, TypeError) as exc: + logger.warning( + "contacts: cannot backfill fingerprint for %s: %s", + contact_id, exc, + ) + continue + await self._db.execute( + "UPDATE contacts SET peer_fingerprint = ? WHERE contact_id = ?", + (fp, contact_id), + ) + if stale: + await self._db.commit() + + async def _hub_username_unique_index_exists(self) -> bool: + """True when the legacy UNIQUE auto-index on hub_username still exists.""" + for idx in await ( + await self._db.execute("PRAGMA index_list('contacts')") + ).fetchall(): + # index_list row: (seq, name, unique, origin, partial) + if idx[2] and idx[3] == "u": + cols = [ + r[2] + for r in await ( + await self._db.execute(f"PRAGMA index_info('{idx[1]}')") + ).fetchall() + ] + if cols == ["hub_username"]: + return True + return False + # ------------------------------------------------------------------ # contacts # ------------------------------------------------------------------ @@ -87,6 +198,7 @@ async def add_contact( display_name: str, ed25519_pub: str, x25519_pub: str, + peer_fingerprint: str = "", status: str = "active", local_crm_id: str | None = None, ) -> None: @@ -104,17 +216,18 @@ async def add_contact( await self._db.execute( """INSERT INTO contacts (contact_id, hub_username, display_name, ed25519_pub, x25519_pub, - status, local_crm_id, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) + peer_fingerprint, status, local_crm_id, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(contact_id) DO UPDATE SET ed25519_pub = excluded.ed25519_pub, x25519_pub = excluded.x25519_pub, + peer_fingerprint = excluded.peer_fingerprint, display_name = excluded.display_name, status = excluded.status, local_crm_id = excluded.local_crm_id, revoked_at = NULL""", (contact_id, hub_username, display_name, ed25519_pub, x25519_pub, - status, local_crm_id, now), + peer_fingerprint, status, local_crm_id, now), ) await self._db.commit() @@ -134,6 +247,44 @@ async def get_contact_by_username(self, hub_username: str) -> Optional[dict]: columns = [desc[0] for desc in cursor.description] return _row_to_dict(columns, rows[0]) if rows else None + async def get_contacts_by_fingerprint( + self, peer_fingerprint: str + ) -> list[dict]: + """Return every contact row pinned to a peer signing-key fingerprint. + + Under fingerprint keying there is at most one row per fingerprint + (contact_id == "hub:{fingerprint}"), but legacy username-keyed rows or a + mid-flight rename can leave several contacts sharing a fingerprint. + Revocation flows must act on ALL matches — never silently pick the + first — so this returns the full list rather than ``rows[0]``. + """ + if not peer_fingerprint: + return [] + async with self._db.execute( + "SELECT * FROM contacts WHERE peer_fingerprint = ?", + (peer_fingerprint,), + ) as cursor: + rows = await cursor.fetchall() + columns = [desc[0] for desc in cursor.description] + return [_row_to_dict(columns, r) for r in rows] + + async def get_contact_by_fingerprint( + self, peer_fingerprint: str + ) -> Optional[dict]: + """Look up a contact by its peer signing-key fingerprint. + + Returns the contact row, or None if no contact is pinned to this + fingerprint. This is the canonical lookup: the contact is keyed on the + fingerprint (the stable, peer-independent identifier), so it is the + primary path for the block cascade and any revocation flow. + + Single-row convenience wrapper around :meth:`get_contacts_by_fingerprint` + for flows that expect at most one match; revocation flows should use the + plural form so a stale duplicate is never silently skipped. + """ + matches = await self.get_contacts_by_fingerprint(peer_fingerprint) + return matches[0] if matches else None + async def set_contact_status(self, contact_id: str, status: str) -> None: if status not in VALID_CONTACT_STATUSES: raise ValueError( @@ -287,18 +438,26 @@ async def mark_peer_seen(self, contact_id: str) -> None: ) await self._db.commit() - async def revoke_peer_link(self, contact_id: str) -> None: - """Revoke the peer link (cascades to block contact).""" + async def revoke_peer_link(self, contact_id: str) -> bool: + """Revoke the peer link (cascades to block contact). + + Returns True when a peer_link row actually matched the ``contact_id`` + (i.e. there was a link to revoke), False when the UPDATE matched zero + rows. A safety revoke that matched nothing must never be reported as + success, so callers are expected to act on the return value. + """ now = time.time() - await self._db.execute( + cursor = await self._db.execute( "UPDATE peer_links SET revoked_at = ? WHERE contact_id = ?", (now, contact_id), ) + matched = (cursor.rowcount or 0) > 0 await self._db.execute( "UPDATE contacts SET status = 'revoked', revoked_at = ? WHERE contact_id = ?", (now, contact_id), ) await self._db.commit() + return matched # --------------------------------------------------------------------------- diff --git a/tinyagentos/peer.py b/tinyagentos/peer.py index d48c67cd2..9f61d5d3b 100644 --- a/tinyagentos/peer.py +++ b/tinyagentos/peer.py @@ -22,6 +22,7 @@ from pathlib import Path from tinyagentos.hub.identity import ( + public_identity, sign as _sign, signing_fingerprint, verify_signature, @@ -165,6 +166,93 @@ def mint_peer_token(sub: str) -> tuple[str, str]: return raw, token_hash +# --------------------------------------------------------------------------- +# Handshake delivery +# --------------------------------------------------------------------------- +# WARNING: send_handshake / deliver_handshake currently have zero callers +# repo-wide. deliver_handshake POSTs to peer-supplied URLs with no SSRF +# guard — wire a validating transport (e.g. an ssrf-safe httpx wrapper that +# blocks internal/loopback/cloud-metadata targets) before either function +# gets a caller. Without it, a malicious peer endpoint could probe or +# traverse the local network. + + +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 `` 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. + """ + local_ident = public_identity() + from_username = resolve_local_identity_id() + 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" + 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() + + # --------------------------------------------------------------------------- # helpers # --------------------------------------------------------------------------- diff --git a/tinyagentos/routes/hub.py b/tinyagentos/routes/hub.py index 7b214ccee..e5ed876df 100644 --- a/tinyagentos/routes/hub.py +++ b/tinyagentos/routes/hub.py @@ -35,6 +35,7 @@ from pydantic import BaseModel from tinyagentos.auth_context import CurrentUser, current_user +from tinyagentos.contacts_store import generate_peer_token from tinyagentos.hub import identity, posts, relationships, store as hub_store from tinyagentos.routes.account_proxy import _forward_to @@ -95,6 +96,133 @@ async def _get_store(request: Request) -> hub_store.HubStore: return store +async def _try_handshake( + request: Request, + directory_resp: dict, + peer_fingerprint: str, +) -> None: + """Establish a peer channel on friend-accept (collab A2). + + Extracts the peer's Ed25519/X25519 pubkeys and advertised endpoints + from the directory response. Falls back to the local hub_authors + table for pubkeys when the directory omits them. + + On success a contact row is created (or refreshed) and a peer link is + established with a freshly minted inbound token. Failures are + logged but never block the accept — the accept always succeeds even + when the handshake side-effect temporarily can't complete. + """ + contacts_store = getattr(request.app.state, "contacts_store", None) + if contacts_store is None: + return + + username = directory_resp.get("username") or directory_resp.get("target") or "" + + # contact_id is keyed on the peer's signing-key fingerprint — the canonical + # author identifier (see hub/store.py) — never the peer-controlled username. + # A username collision or rename therefore can neither overwrite a pinned + # contact's key material nor fragment the same peer across two contact rows. + if not peer_fingerprint: + # Without a fingerprint there is nothing stable to pin TOFU against; + # skip the handshake rather than key on a mutable name. + return + contact_id = f"hub:{peer_fingerprint}" + + # Pubkeys: directory first, then local hub_authors cache. + ed25519_pub = directory_resp.get("signing_pubkey") or "" + x25519_pub = directory_resp.get("encryption_pubkey") or "" + + try: + # Guard: a blocked peer must not be resurrected on re-accept. + # Wrapped inside the try block so a store/has_edge failure never + # blocks the accept — the handshake is always best-effort. + store = await _get_store(request) + if peer_fingerprint and await store.has_edge(peer_fingerprint, relationships.REL_BLOCK): + return + if not ed25519_pub or not x25519_pub: + # Fall back to hub_authors (populated during friend-request flow). + store = await _get_store(request) + author = await store.get_author(peer_fingerprint) if peer_fingerprint else None + if author: + ed25519_pub = ed25519_pub or author.get("signing_pubkey", "") + x25519_pub = x25519_pub or author.get("encryption_pubkey", "") + + if not ed25519_pub or not x25519_pub: + logger.warning( + "friend-accept handshake skipped: no pubkeys for %s", contact_id + ) + return + + # Verify the directory-supplied pubkey matches the expected peer fingerprint. + # A mismatch (or malformed hex from a malicious directory) means the + # directory returned a key for the wrong identity; skip the handshake + # to avoid pinning TOFU keys from an imposter. + if peer_fingerprint and identity.fingerprint(ed25519_pub) != peer_fingerprint: + logger.warning( + "friend-accept handshake skipped: pubkey fingerprint mismatch for %s " + "(expected %s)", + contact_id, + peer_fingerprint, + ) + return + + display_name = directory_resp.get("display_name") or username + endpoints = directory_resp.get("endpoints") + if isinstance(endpoints, str): + try: + endpoints = json.loads(endpoints) + except (ValueError, TypeError): + endpoints = [] + if not isinstance(endpoints, list): + endpoints = [] + + # Normalize bare strings to the dict form consumed by peer link + # consumers (e.g., #2045's contact grid expects url/kind/priority). + endpoints = [ + {"kind": "hub", "url": e, "priority": i} + if isinstance(e, str) + else e + for i, e in enumerate(endpoints) + ] + + # Create/refresh the contact row (trust-on-first-use key pinning). + await contacts_store.add_contact( + contact_id=contact_id, + hub_username=username, + display_name=display_name, + ed25519_pub=ed25519_pub, + x25519_pub=x25519_pub, + peer_fingerprint=peer_fingerprint, + ) + + # Mint the inbound token WE give to the remote instance. + # NOTE: A2 intentionally stores the inbound token locally but does NOT + # deliver it to the remote peer — the token exchange channel doesn't + # exist yet. A3 (the first handshake reply containing the remote's + # outbound token) completes the two-way exchange. Until then, the + # inbound auth channel is inert (no remote request will carry this + # token) and find_contact_by_inbound_token() will never match. + inbound_token = generate_peer_token() + # The outbound_token is the token THEY mint for us — we don't have it + # until the first handshake reply arrives. Store an empty placeholder + # so the peer link row exists; the first handshake reply (future A3) + # updates this field. + outbound_token = "" + + await contacts_store.establish_peer_link( + contact_id=contact_id, + inbound_token=inbound_token, + outbound_token=outbound_token, + endpoints=endpoints, + ) + logger.info( + "friend-accept handshake: contact=%s endpoints=%s", + contact_id, endpoints, + ) + except Exception: + logger.exception("friend-accept handshake failed for %s", contact_id) + + # --- slice 2: own profile --------------------------------------------------- @@ -323,6 +451,15 @@ async def accept_friend_request( await store.put_relationship( peer, relationships.REL_FRIEND, statement=statement ) + + # --- A2: friend-accept -> contact row + peer-link handshake --- + # The directory response carries the peer's identity material (pubkeys, + # endpoints) so we can establish the peer channel immediately on accept. + # When the directory omits these fields, we fall back to the locally cached + # hub_authors record (populated during the friend-request flow). + if peer: + await _try_handshake(request, resp, peer) + return {"state": "accepted", "peer": peer, "directory": resp} @@ -387,6 +524,49 @@ async def block_peer( ) except Exception as exc: # noqa: BLE001 logger.warning("hub block: directory edge revoke failed: %s", exc) + + # Cascade to contacts: revoke the peer link so the blocked contact can no + # longer authenticate on the peer channel (A2 subscribe-to-block). + # Resolve the peer via its signing-key fingerprint — the canonical contact + # key — rather than the hub_authors username cache, which is peer-controlled + # and can be stale (renamed since the contact was pinned). A present-but- + # stale username row must never break revocation. + contacts_store = getattr(request.app.state, "contacts_store", None) + if contacts_store is not None: + try: + # Resolve ALL contacts pinned to this fingerprint. Legacy + # username-keyed rows (or a rename mid-flight) can leave several + # contacts sharing a fingerprint; revoke each one rather than + # silently picking the first. + contacts = await contacts_store.get_contacts_by_fingerprint(peer) + if not contacts: + logger.warning( + "hub block: could not resolve fingerprint %s to a " + "contact; peer link may still be active", peer, + ) + for contact in contacts: + cid = contact["contact_id"] + revoked = await contacts_store.revoke_peer_link(cid) + if not revoked: + # A revoke that matched no peer_link row must not be + # silently treated as success — log it loudly so a + # fail-open regression is visible. + logger.warning( + "hub block: revoke_peer_link matched no peer_link row " + "for contact %s (fingerprint %s); link may already be " + "absent or revoked", cid, peer, + ) + # Mark the contact as blocked so the UI reflects the distinct + # status rather than leaving it at the prior accepted state. + try: + await contacts_store.set_contact_status(cid, "blocked") + except Exception: + logger.warning( + "hub block: set_contact_status blocked failed for %s", cid + ) + except Exception: + logger.exception("hub block: contacts-store cascade failed for %s", peer) + return {"state": "blocked", "peer": peer, "severed": severed}