From faa9693b3df45c11ad14bda08349fbc1f52a1607 Mon Sep 17 00:00:00 2001 From: hognek <227774406+hognek@users.noreply.github.com> Date: Sun, 19 Jul 2026 13:35:40 +0200 Subject: [PATCH 01/20] feat(hub): friend-accept creates contact row and peer-link handshake MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On friend-accept: - Extract peer Ed25519/X25519 pubkeys from directory response - Fall back to hub_authors cache when directory omits pubkeys - Create contact row (trust-on-first-use key pinning) - Mint inbound peer token (hashed at rest) - Establish peer link with advertised endpoints - Handshake is best-effort — failures never block the accept On block: - Cascade to contacts_store.revoke_peer_link() - Resolve fingerprint->username via hub_authors cache Tests: 8/8 pass (contact creation, pubkey fallback, no-pubkey skip, endpoint parsing, re-upsert, missing-store guard, block cascade, block cascade missing-store). Existing 37 contacts_peer tests unaffected. Part of #2012 (cross-user collaboration), milestone A2. Closes #2014. --- tests/test_collab_a2_handshake.py | 462 ++++++++++++++++++++++++++++++ tinyagentos/routes/hub.py | 111 +++++++ 2 files changed, 573 insertions(+) create mode 100644 tests/test_collab_a2_handshake.py diff --git a/tests/test_collab_a2_handshake.py b/tests/test_collab_a2_handshake.py new file mode 100644 index 000000000..51372e97b --- /dev/null +++ b/tests/test_collab_a2_handshake.py @@ -0,0 +1,462 @@ +"""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 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 = "deadbeef" * 8 # 64-char fake fingerprint +_PEER_USERNAME = "remotepeer" +_PEER_SIGNING_PUB = "ab" * 32 # 64-char fake Ed25519 pubkey +_PEER_ENCRYPTION_PUB = "cd" * 32 # 64-char fake X25519 pubkey + + +# --------------------------------------------------------------------------- +# 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) + + return _app + + +@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}, + ) 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_USERNAME}") + 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_USERNAME}") + assert link is not None, "peer link should be established on accept" + assert link["endpoints"] == ["https://peer.example.com:6969"] + # 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"] == "" + + # The contact should be findable by the inbound token. + # We can't read the plaintext token, but the hash lookup works. + inbound_contact = await store.find_contact_by_inbound_token( + # Generate a new token and use its hash — we can't read the stored plaintext + # but we can verify the hash is deterministic. + "placeholder-not-testable-directly" + ) + # Actually, we should test the token flow differently. + # Let's just verify the link exists and the hash is consistent. + + 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" + ) + await hub_store.init() + await hub_store.upsert_author( + _PEER_FP, + username=_PEER_USERNAME, + signing_pubkey=_PEER_SIGNING_PUB, + encryption_pubkey=_PEER_ENCRYPTION_PUB, + ) + + 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_USERNAME}") + 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_USERNAME}") + assert contact is None, "no contact should be created 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_USERNAME}") + assert link is not None + assert link["endpoints"] == ["https://peer.example.com:6969"] + + 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.""" + 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_USERNAME}") + first_established = first_link["established_at"] + + # Second accept with different endpoints — should update + 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_USERNAME}") + assert link is not None + assert link["endpoints"] == ["https://second.example.com:6969"] + assert link["revoked_at"] is None # re-establish clears 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" + + +# --------------------------------------------------------------------------- +# 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_USERNAME}", + hub_username=_PEER_USERNAME, + display_name="Remote", + ed25519_pub=_PEER_SIGNING_PUB, + x25519_pub=_PEER_ENCRYPTION_PUB, + ) + await store.establish_peer_link( + contact_id=f"hub:{_PEER_USERNAME}", + 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" + ) + await hub_store.init() + await hub_store.upsert_author( + _PEER_FP, + username=_PEER_USERNAME, + signing_pubkey=_PEER_SIGNING_PUB, + encryption_pubkey=_PEER_ENCRYPTION_PUB, + ) + + # 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_USERNAME}") + assert link["revoked_at"] is not None + + # Verify contact is revoked + contact = await store.get_contact(f"hub:{_PEER_USERNAME}") + assert contact["status"] == "revoked" + + 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" diff --git a/tinyagentos/routes/hub.py b/tinyagentos/routes/hub.py index 7b214ccee..752997375 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,93 @@ 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 "" + if not username: + # Can't form a contact_id without a username. + return + + contact_id = f"hub:{username}" + + # Pubkeys: directory first, then local hub_authors cache. + ed25519_pub = directory_resp.get("signing_pubkey") or "" + x25519_pub = directory_resp.get("encryption_pubkey") or "" + + if not ed25519_pub or not x25519_pub: + # Fall back to hub_authors (populated during friend-request flow). + hub_store = await _get_store(request) + author = await hub_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 + + 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 = [] + + try: + # 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, + ) + + # Mint the inbound token WE give to the remote instance. + 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 +411,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 +484,20 @@ 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). + # The peer fingerprint is a hub signing key fingerprint; resolve it to a + # contact_id (hub:username) via the hub_authors cache. + contacts_store = getattr(request.app.state, "contacts_store", None) + if contacts_store is not None: + try: + author = await store.get_author(peer) + if author and author.get("username"): + await contacts_store.revoke_peer_link(f"hub:{author['username']}") + except Exception: + logger.exception("hub block: contacts-store cascade failed for %s", peer) + return {"state": "blocked", "peer": peer, "severed": severed} From 848a01bbb1244a066658d64a06576bd091d3ae2e Mon Sep 17 00:00:00 2001 From: hognek <227774406+hognek@users.noreply.github.com> Date: Mon, 20 Jul 2026 01:55:21 +0200 Subject: [PATCH 02/20] =?UTF-8?q?fix(hub):=20address=20Kilo=20findings=20?= =?UTF-8?q?=E2=80=94=20block-cascade=20fallback,=20token-flow=20doc,=20dea?= =?UTF-8?q?d=20test=20code?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - WARNING: block cascade now falls back to contact-table scan when hub_authors cache is missing, with explicit log warning on failure - WARNING: document that A2 intentionally stores inbound token locally without delivering it (A3 completes the exchange) - SUGGESTION: remove dead test code (placeholder token lookup) --- data/hub/identity.json | 1 + tests/test_collab_a2_handshake.py | 10 ---------- tinyagentos/routes/hub.py | 25 ++++++++++++++++++++++++- 3 files changed, 25 insertions(+), 11 deletions(-) create mode 100644 data/hub/identity.json diff --git a/data/hub/identity.json b/data/hub/identity.json new file mode 100644 index 000000000..bae8d3272 --- /dev/null +++ b/data/hub/identity.json @@ -0,0 +1 @@ +{"signing_private": "9af62cb1e50222bd57e894e3dab5f9444851f0309224c0b37bf45ed21143d178", "signing_public": "9ae572677819955cf2aace721129e8a1d393d21ecc47ef964db8c3f0ff3b98da", "encryption_private": "c02cc57f5a4940700d9a80dd4578467e63308a251bf643f26c13c3f049670662", "encryption_public": "a1ac687647b24884912035b4c67ae749f136477a1bb031d24817a66236717005", "created_at": 1784503810.4560094} \ No newline at end of file diff --git a/tests/test_collab_a2_handshake.py b/tests/test_collab_a2_handshake.py index 51372e97b..c8bc6639a 100644 --- a/tests/test_collab_a2_handshake.py +++ b/tests/test_collab_a2_handshake.py @@ -204,16 +204,6 @@ async def handler(method, url, **kw): # outbound_token is empty placeholder until A3 handshake reply assert link["outbound_token"] == "" - # The contact should be findable by the inbound token. - # We can't read the plaintext token, but the hash lookup works. - inbound_contact = await store.find_contact_by_inbound_token( - # Generate a new token and use its hash — we can't read the stored plaintext - # but we can verify the hash is deterministic. - "placeholder-not-testable-directly" - ) - # Actually, we should test the token flow differently. - # Let's just verify the link exists and the hash is consistent. - async def test_accept_falls_back_to_hub_authors( self, client_with_contacts, app_with_contacts, monkeypatch ): diff --git a/tinyagentos/routes/hub.py b/tinyagentos/routes/hub.py index 752997375..332d791ae 100644 --- a/tinyagentos/routes/hub.py +++ b/tinyagentos/routes/hub.py @@ -162,6 +162,12 @@ async def _try_handshake( ) # 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 @@ -488,13 +494,30 @@ async def block_peer( # Cascade to contacts: revoke the peer link so the blocked contact can no # longer authenticate on the peer channel (A2 subscribe-to-block). # The peer fingerprint is a hub signing key fingerprint; resolve it to a - # contact_id (hub:username) via the hub_authors cache. + # contact_id (hub:username) via the hub_authors cache, falling back to a + # contact-table scan when the author row is missing or stale. contacts_store = getattr(request.app.state, "contacts_store", None) if contacts_store is not None: try: author = await store.get_author(peer) if author and author.get("username"): await contacts_store.revoke_peer_link(f"hub:{author['username']}") + else: + # Fallback: scan contacts for a matching ed25519 fingerprint. + all_contacts = await contacts_store.list_contacts() + for contact in all_contacts: + if contact.get("ed25519_pub") == peer: + await contacts_store.revoke_peer_link(contact["contact_id"]) + logger.warning( + "hub block: revoke via contact scan (author missing) " + "for %s", peer, + ) + break + else: + logger.warning( + "hub block: could not resolve peer %s to a contact; " + "peer link may still be active", peer, + ) except Exception: logger.exception("hub block: contacts-store cascade failed for %s", peer) From 2c951c48d8b41c438a7d98af6bcce3d45d18fc4d Mon Sep 17 00:00:00 2001 From: hognek <227774406+hognek@users.noreply.github.com> Date: Mon, 20 Jul 2026 02:24:20 +0200 Subject: [PATCH 03/20] =?UTF-8?q?fix(hub):=20address=20Kilo=20round=202=20?= =?UTF-8?q?=E2=80=94=20remove=20committed=20keys,=20fix=20cascade=20fallba?= =?UTF-8?q?ck,=20doc=20contact=5Fid?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CRITICAL: remove committed data/hub/identity.json (test-generated keys) and add data/hub/ to .gitignore - WARNING: document that contact_id is derived from untrusted directory username (TOFU key-pinning bound to peer-controllable name) with future direction - SUGGESTION: remove broken fingerprint-vs-pubkey fallback in block cascade (peer fingerprint != ed25519_pub key — comparison would never match) --- .gitignore | 1 + data/hub/identity.json | 1 - tinyagentos/routes/hub.py | 28 +++++++++++++--------------- 3 files changed, 14 insertions(+), 16 deletions(-) delete mode 100644 data/hub/identity.json 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/data/hub/identity.json b/data/hub/identity.json deleted file mode 100644 index bae8d3272..000000000 --- a/data/hub/identity.json +++ /dev/null @@ -1 +0,0 @@ -{"signing_private": "9af62cb1e50222bd57e894e3dab5f9444851f0309224c0b37bf45ed21143d178", "signing_public": "9ae572677819955cf2aace721129e8a1d393d21ecc47ef964db8c3f0ff3b98da", "encryption_private": "c02cc57f5a4940700d9a80dd4578467e63308a251bf643f26c13c3f049670662", "encryption_public": "a1ac687647b24884912035b4c67ae749f136477a1bb031d24817a66236717005", "created_at": 1784503810.4560094} \ No newline at end of file diff --git a/tinyagentos/routes/hub.py b/tinyagentos/routes/hub.py index 332d791ae..0f5561776 100644 --- a/tinyagentos/routes/hub.py +++ b/tinyagentos/routes/hub.py @@ -121,6 +121,12 @@ async def _try_handshake( # Can't form a contact_id without a username. return + # NOTE: contact_id is derived from the directory-supplied username, not the + # verified fingerprint. This means TOFU key-pinning is bound to a + # peer-controllable name — a peer that changes its username between the + # request and accept flows could create a shadow contact. Future designs + # should consider binding to a canonical fingerprint-based identifier + # (e.g. hub:{fingerprint}) with username as a display column. contact_id = f"hub:{username}" # Pubkeys: directory first, then local hub_authors cache. @@ -503,21 +509,13 @@ async def block_peer( if author and author.get("username"): await contacts_store.revoke_peer_link(f"hub:{author['username']}") else: - # Fallback: scan contacts for a matching ed25519 fingerprint. - all_contacts = await contacts_store.list_contacts() - for contact in all_contacts: - if contact.get("ed25519_pub") == peer: - await contacts_store.revoke_peer_link(contact["contact_id"]) - logger.warning( - "hub block: revoke via contact scan (author missing) " - "for %s", peer, - ) - break - else: - logger.warning( - "hub block: could not resolve peer %s to a contact; " - "peer link may still be active", peer, - ) + # Author row is missing from hub_authors cache (may have been + # pruned or never populated). We cannot resolve fingerprint → + # contact_id without the author record, so log and skip. + logger.warning( + "hub block: author missing from hub_authors for %s; " + "peer link may still be active", peer, + ) except Exception: logger.exception("hub block: contacts-store cascade failed for %s", peer) From e086cebdac48c41f412aeb4fcbbdf77f18c9acc2 Mon Sep 17 00:00:00 2001 From: hognek <227774406+hognek@users.noreply.github.com> Date: Mon, 20 Jul 2026 02:56:07 +0200 Subject: [PATCH 04/20] =?UTF-8?q?fix(hub):=20address=20CodeRabbit=20findin?= =?UTF-8?q?gs=20=E2=80=94=20HubStore=20close,=20fingerprint=20verification?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SUGGESTION: wrap HubStore init/upsert in try/finally with close() in both test_collab_a2_handshake.py locations to prevent leaked database connections - SUGGESTION: verify directory-supplied ed25519_pub fingerprint matches expected peer_fingerprint in _try_handshake; skip handshake on mismatch to avoid pinning TOFU keys from an imposter - Update _PEER_FP test constant to actual fingerprint of _PEER_SIGNING_PUB so the new fingerprint check passes consistently Tests: 103/103 pass (collab A2 handshake + hub + contacts peer) --- tests/test_collab_a2_handshake.py | 36 ++++++++++++++++++------------- tinyagentos/routes/hub.py | 12 +++++++++++ 2 files changed, 33 insertions(+), 15 deletions(-) diff --git a/tests/test_collab_a2_handshake.py b/tests/test_collab_a2_handshake.py index c8bc6639a..66350b357 100644 --- a/tests/test_collab_a2_handshake.py +++ b/tests/test_collab_a2_handshake.py @@ -100,7 +100,7 @@ def _bootstrap_hub_identity(data_dir: Path, username: str = "localnode") -> str: return f"hub:{username}" -_PEER_FP = "deadbeef" * 8 # 64-char fake fingerprint +_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 @@ -226,13 +226,16 @@ async def handler(method, url, **kw): hub_store = HubStore( Path(app_with_contacts.state.data_dir) / "hub" / "hub.db" ) - await hub_store.init() - await hub_store.upsert_author( - _PEER_FP, - username=_PEER_USERNAME, - signing_pubkey=_PEER_SIGNING_PUB, - encryption_pubkey=_PEER_ENCRYPTION_PUB, - ) + 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", @@ -399,13 +402,16 @@ async def test_block_cascades_to_contacts_store( hub_store = HubStore( Path(app_with_contacts.state.data_dir) / "hub" / "hub.db" ) - await hub_store.init() - await hub_store.upsert_author( - _PEER_FP, - username=_PEER_USERNAME, - signing_pubkey=_PEER_SIGNING_PUB, - encryption_pubkey=_PEER_ENCRYPTION_PUB, - ) + 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): diff --git a/tinyagentos/routes/hub.py b/tinyagentos/routes/hub.py index 0f5561776..0d41a885c 100644 --- a/tinyagentos/routes/hub.py +++ b/tinyagentos/routes/hub.py @@ -147,6 +147,18 @@ async def _try_handshake( ) return + # Verify the directory-supplied pubkey matches the expected peer fingerprint. + # A mismatch 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): From c78d5be1095715f5e80dd8f167bdbafad47e9bd2 Mon Sep 17 00:00:00 2001 From: hognek <227774406+hognek@users.noreply.github.com> Date: Mon, 20 Jul 2026 15:53:41 +0200 Subject: [PATCH 05/20] fix(hub): widen handshake exception boundary and implement block-cascade fingerprint fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Widen try/except in _try_handshake to cover hub_authors lookup, fingerprint validation, and endpoint processing — prevents ValueError from bytes.fromhex() on malformed directory pubkeys from crashing the accept endpoint (CodeRabbit CRITICAL). - Add peer_fingerprint column to contacts table with migration, store it at friend-accept for stable fingerprint→contact lookup. - Implement fingerprint-based fallback in block_peer's contact cascade: when hub_authors is missing or stale, resolve via get_contact_by_fingerprint() instead of silently skipping. - Rename hub_store→store in _try_handshake to avoid shadowing the module-level import (CodeRabbit nit). - Add test_block_cascade_fingerprint_fallback: verifies block revokes peer link via fingerprint when hub_authors is empty. --- tests/test_collab_a2_handshake.py | 45 +++++++++++++++ tinyagentos/contacts_store.py | 34 +++++++++-- tinyagentos/routes/hub.py | 93 ++++++++++++++++--------------- 3 files changed, 124 insertions(+), 48 deletions(-) diff --git a/tests/test_collab_a2_handshake.py b/tests/test_collab_a2_handshake.py index 66350b357..aaa938832 100644 --- a/tests/test_collab_a2_handshake.py +++ b/tests/test_collab_a2_handshake.py @@ -390,6 +390,7 @@ async def test_block_cascades_to_contacts_store( 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_USERNAME}", @@ -456,3 +457,47 @@ async def handler(method, url, **kw): ) 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_USERNAME}", + 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_USERNAME}", + 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_USERNAME}") + assert link["revoked_at"] is not None + + # Verify contact is revoked + contact = await store.get_contact(f"hub:{_PEER_USERNAME}") + assert contact["status"] == "revoked" diff --git a/tinyagentos/contacts_store.py b/tinyagentos/contacts_store.py index 39d63f23d..5d2300269 100644 --- a/tinyagentos/contacts_store.py +++ b/tinyagentos/contacts_store.py @@ -17,6 +17,7 @@ display_name TEXT NOT NULL, ed25519_pub TEXT NOT NULL, -- pinned at friend-accept x25519_pub TEXT NOT NULL, + 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, @@ -73,7 +74,11 @@ class ContactsStore(BaseStore): SCHEMA = CONTACTS_SCHEMA - MIGRATIONS: list = [] + MIGRATIONS: list = [ + # Add peer_fingerprint column for stable fingerprint→contact resolution + # in the block cascade (independent of the volatile hub_authors cache). + (1, """ALTER TABLE contacts ADD COLUMN peer_fingerprint TEXT NOT NULL DEFAULT ''"""), + ] # ------------------------------------------------------------------ # contacts @@ -87,6 +92,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 +110,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 +141,25 @@ 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_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. Used by the block cascade as a fallback when the + hub_authors cache is missing or stale. + """ + if not peer_fingerprint: + return None + 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, rows[0]) if rows else None + async def set_contact_status(self, contact_id: str, status: str) -> None: if status not in VALID_CONTACT_STATUSES: raise ValueError( diff --git a/tinyagentos/routes/hub.py b/tinyagentos/routes/hub.py index 0d41a885c..72f760914 100644 --- a/tinyagentos/routes/hub.py +++ b/tinyagentos/routes/hub.py @@ -133,43 +133,44 @@ async def _try_handshake( ed25519_pub = directory_resp.get("signing_pubkey") or "" x25519_pub = directory_resp.get("encryption_pubkey") or "" - if not ed25519_pub or not x25519_pub: - # Fall back to hub_authors (populated during friend-request flow). - hub_store = await _get_store(request) - author = await hub_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 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 + try: + 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): + 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 = [] - if not isinstance(endpoints, list): - endpoints = [] - try: # Create/refresh the contact row (trust-on-first-use key pinning). await contacts_store.add_contact( contact_id=contact_id, @@ -177,6 +178,7 @@ async def _try_handshake( 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. @@ -511,9 +513,9 @@ async def block_peer( # Cascade to contacts: revoke the peer link so the blocked contact can no # longer authenticate on the peer channel (A2 subscribe-to-block). - # The peer fingerprint is a hub signing key fingerprint; resolve it to a - # contact_id (hub:username) via the hub_authors cache, falling back to a - # contact-table scan when the author row is missing or stale. + # Resolve the peer fingerprint to a contact_id first via the hub_authors + # cache, then fall back to a direct fingerprint lookup on the contacts + # table when the author row is missing or stale. contacts_store = getattr(request.app.state, "contacts_store", None) if contacts_store is not None: try: @@ -521,13 +523,16 @@ async def block_peer( if author and author.get("username"): await contacts_store.revoke_peer_link(f"hub:{author['username']}") else: - # Author row is missing from hub_authors cache (may have been - # pruned or never populated). We cannot resolve fingerprint → - # contact_id without the author record, so log and skip. - logger.warning( - "hub block: author missing from hub_authors for %s; " - "peer link may still be active", peer, - ) + # Fall back to the fingerprint pinned on the contacts row + # (independent of the volatile hub_authors cache). + contact = await contacts_store.get_contact_by_fingerprint(peer) + if contact: + await contacts_store.revoke_peer_link(contact["contact_id"]) + else: + logger.warning( + "hub block: could not resolve fingerprint %s to a " + "contact; peer link may still be active", peer, + ) except Exception: logger.exception("hub block: contacts-store cascade failed for %s", peer) From b3188ae712ede628e863c6476f763bc4e802b039 Mon Sep 17 00:00:00 2001 From: hognek <227774406+hognek@users.noreply.github.com> Date: Mon, 20 Jul 2026 18:50:09 +0200 Subject: [PATCH 06/20] fix(contacts): replace migration with guarded _post_init for peer_fingerprint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit jaylfc deep review at 4b5903b3 — fold all six findings: 1. BLOCKER: peer_fingerprint retrofit migration was a no-op on every pre-existing DB. BaseStore's migration runner uses baseline-at-latest semantics — existing DBs get stamped at version 1 without executing the ALTER, so the column was absent after init(). The broad except in _try_handshake swallowed the resulting OperationalError, and the block-cascade security fix was similarly swallowed. Replaced the MIGRATIONS list with a guarded _post_init that checks PRAGMA table_info('contacts') and ALTER TABLE ADD COLUMN only when peer_fingerprint is absent. Same pattern as agent_registry_store's _migration_v1_add_status. Fresh databases still get the column from SCHEMA; upgraded databases get it from _post_init. Added two ContactsStore upgrade tests in test_store_upgrades.py following the existing pattern — column-presence check and add_contact-after-upgrade. 2. Fold 1 (send_handshake): A2 intentionally stores the inbound token locally without delivering it — the token exchange channel doesn't exist yet. A3 completes the two-way exchange. The send_handshake envelope builder from #2046 is deferred to a follow-up PR linked from the tracking issue. This is a spec deviation from cross-user-collaboration.md Day 0 (mint token on BOTH sides), filed as a tracking issue. 3. Fold 3 (.gitignore): the data/hub/ ignore line is justified — this branch's own history committed identity.json with throwaway test keys at 2b28043a (removed at 500da607). The .gitignore prevents future accidental commits. Squash merge will keep dev history clean. 4. Key hygiene: the keys in 2b28043a were throwaway test keys never used against real endpoints. Squash merge removes them from dev history. #2042 re-commits the same file; coordination note added in-thread. 5. Re-trigger: @coderabbitai review after push. 6. Track-don't-block (Kilo W1): accepted the documented NOTE about contact_id bound to peer-controllable username. Follow-up issue filed for fingerprint-keyed contact IDs in a future slice. BONUS: Fixed CodeRabbit nit from head review — test_accept_reupsert_contact now actually revokes between accepts to verify re-establishment clears revoked_at (was a no-op assertion before). --- tests/test_collab_a2_handshake.py | 12 ++++-- tests/test_store_upgrades.py | 71 +++++++++++++++++++++++++++++++ tinyagentos/contacts_store.py | 29 ++++++++++--- 3 files changed, 104 insertions(+), 8 deletions(-) diff --git a/tests/test_collab_a2_handshake.py b/tests/test_collab_a2_handshake.py index aaa938832..0bad2fd0a 100644 --- a/tests/test_collab_a2_handshake.py +++ b/tests/test_collab_a2_handshake.py @@ -307,7 +307,8 @@ async def handler(method, url, **kw): 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.""" + """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, @@ -332,7 +333,12 @@ async def handler(method, url, **kw): first_link = await store.get_peer_link(f"hub:{_PEER_USERNAME}") first_established = first_link["established_at"] - # Second accept with different endpoints — should update + # Simulate a revocation so we can verify re-establish actually clears it. + await store.revoke_peer_link(f"hub:{_PEER_USERNAME}") + revoked_link = await store.get_peer_link(f"hub:{_PEER_USERNAME}") + 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", @@ -343,7 +349,7 @@ async def handler(method, url, **kw): link = await store.get_peer_link(f"hub:{_PEER_USERNAME}") assert link is not None assert link["endpoints"] == ["https://second.example.com:6969"] - assert link["revoked_at"] is None # re-establish clears revocation + 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 diff --git a/tests/test_store_upgrades.py b/tests/test_store_upgrades.py index 1ee5bf330..776e203d9 100644 --- a/tests/test_store_upgrades.py +++ b/tests/test_store_upgrades.py @@ -29,6 +29,7 @@ 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 # --------------------------------------------------------------------------- @@ -483,6 +484,76 @@ 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() + + # --------------------------------------------------------------------------- # Regression: no SCHEMA CREATE INDEX references a _post_init-added column # --------------------------------------------------------------------------- diff --git a/tinyagentos/contacts_store.py b/tinyagentos/contacts_store.py index 5d2300269..1b71dc474 100644 --- a/tinyagentos/contacts_store.py +++ b/tinyagentos/contacts_store.py @@ -74,11 +74,30 @@ class ContactsStore(BaseStore): SCHEMA = CONTACTS_SCHEMA - MIGRATIONS: list = [ - # Add peer_fingerprint column for stable fingerprint→contact resolution - # in the block cascade (independent of the volatile hub_authors cache). - (1, """ALTER TABLE contacts ADD COLUMN peer_fingerprint TEXT NOT NULL DEFAULT ''"""), - ] + 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() # ------------------------------------------------------------------ # contacts From 9acbc8fc2695d310ef3378d36df0e19b0e0d67ef Mon Sep 17 00:00:00 2001 From: hognek <227774406+hognek@users.noreply.github.com> Date: Tue, 28 Jul 2026 18:08:11 +0200 Subject: [PATCH 07/20] fix(hub): normalize endpoints to dict form and guard re-accept on REL_BLOCK 1) _try_handshake stores directory_resp['endpoints'] as a list of strings but the only consumer (#2045's contact grid) expects dicts with url/kind/priority fields. Normalize bare strings to {'kind': 'hub', 'url': e, 'priority': i} in the handshake path. 2) A blocked peer (REL_BLOCK edge in hub_relationships) is resurrected on re-accept because _try_handshake runs unconditionally. Guard the handshake with a has_edge check before any contact-store operations. --- tests/test_collab_a2_handshake.py | 6 +++--- tinyagentos/routes/hub.py | 14 ++++++++++++++ 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/tests/test_collab_a2_handshake.py b/tests/test_collab_a2_handshake.py index 0bad2fd0a..ef070f6de 100644 --- a/tests/test_collab_a2_handshake.py +++ b/tests/test_collab_a2_handshake.py @@ -198,7 +198,7 @@ async def handler(method, url, **kw): # Verify peer link was established link = await store.get_peer_link(f"hub:{_PEER_USERNAME}") assert link is not None, "peer link should be established on accept" - assert link["endpoints"] == ["https://peer.example.com:6969"] + 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 @@ -302,7 +302,7 @@ async def handler(method, url, **kw): store = app_with_contacts.state.contacts_store link = await store.get_peer_link(f"hub:{_PEER_USERNAME}") assert link is not None - assert link["endpoints"] == ["https://peer.example.com:6969"] + 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 @@ -348,7 +348,7 @@ async def handler(method, url, **kw): link = await store.get_peer_link(f"hub:{_PEER_USERNAME}") assert link is not None - assert link["endpoints"] == ["https://second.example.com:6969"] + 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( diff --git a/tinyagentos/routes/hub.py b/tinyagentos/routes/hub.py index 72f760914..375e500ba 100644 --- a/tinyagentos/routes/hub.py +++ b/tinyagentos/routes/hub.py @@ -112,6 +112,11 @@ async def _try_handshake( logged but never block the accept — the accept always succeeds even when the handshake side-effect temporarily can't complete. """ + # Guard: a blocked peer must not be resurrected on re-accept. + store = await _get_store(request) + if peer_fingerprint and await store.has_edge(peer_fingerprint, relationships.REL_BLOCK): + return + contacts_store = getattr(request.app.state, "contacts_store", None) if contacts_store is None: return @@ -171,6 +176,15 @@ async def _try_handshake( 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, From 15392b8ea64307451980d4956af9694060dde8dd Mon Sep 17 00:00:00 2001 From: hognek <227774406+hognek@users.noreply.github.com> Date: Wed, 29 Jul 2026 17:53:07 +0200 Subject: [PATCH 08/20] fix(hub): address 3 small items from jaylfc review on #2043 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Security regression tests: anti-imposter (mismatched pubkey → no contact), authz-rejection (403 → no handshake), REL_BLOCK guard (blocked contact not resurrected by re-accept) 2. Docs deviation: note mint-without-delivery for A2 friend-accept in cross-user-collaboration.md 3. Block cascade: call set_contact_status(cid, 'blocked') so the distinct status is used rather than leaving it at the prior accepted state --- docs/design/cross-user-collaboration.md | 3 + tests/test_collab_a2_handshake.py | 125 +++++++++++++++++++++++- tinyagentos/routes/hub.py | 9 ++ 3 files changed, 133 insertions(+), 4 deletions(-) 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 index ef070f6de..007ebb697 100644 --- a/tests/test_collab_a2_handshake.py +++ b/tests/test_collab_a2_handshake.py @@ -440,9 +440,9 @@ async def handler(method, url, **kw): link = await store.get_peer_link(f"hub:{_PEER_USERNAME}") assert link["revoked_at"] is not None - # Verify contact is revoked + # Verify contact is blocked contact = await store.get_contact(f"hub:{_PEER_USERNAME}") - assert contact["status"] == "revoked" + assert contact["status"] == "blocked" async def test_block_cascade_handles_missing_contacts_store( self, client_with_contacts, app_with_contacts, monkeypatch @@ -504,6 +504,123 @@ async def handler(method, url, **kw): link = await store.get_peer_link(f"hub:{_PEER_USERNAME}") assert link["revoked_at"] is not None - # Verify contact is revoked + # Verify contact is blocked contact = await store.get_contact(f"hub:{_PEER_USERNAME}") - assert contact["status"] == "revoked" + assert contact["status"] == "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_USERNAME}") + assert contact is None, "imposter pubkey must not create a contact" + + 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.""" + # 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}, + ) + # Accept should handle the upstream error gracefully + assert resp.status_code == 200 + data = resp.json() + # State must indicate failure + assert data["state"] != "accepted" + + store = app_with_contacts.state.contacts_store + contact = await store.get_contact(f"hub:{_PEER_USERNAME}") + assert contact is None, "403 must not create a contact" + + 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_USERNAME}", + 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_USERNAME}", + inbound_token=generate_peer_token(), + outbound_token=generate_peer_token(), + ) + await store.set_contact_status(f"hub:{_PEER_USERNAME}", "blocked") + + # 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_USERNAME}") + assert contact is not None + assert contact["status"] == "blocked", ( + "blocked contact must not be resurrected by re-accept" + ) diff --git a/tinyagentos/routes/hub.py b/tinyagentos/routes/hub.py index 375e500ba..0041d387b 100644 --- a/tinyagentos/routes/hub.py +++ b/tinyagentos/routes/hub.py @@ -542,6 +542,15 @@ async def block_peer( contact = await contacts_store.get_contact_by_fingerprint(peer) if contact: await contacts_store.revoke_peer_link(contact["contact_id"]) + # Mark the contact as blocked so the UI reflects the distinct + # status rather than leaving it at the prior accepted state. + cid = contact["contact_id"] + try: + await contacts_store.set_contact_status(cid, "blocked") + except Exception: + logger.warning( + "hub block: set_contact_status blocked failed for %s", cid + ) else: logger.warning( "hub block: could not resolve fingerprint %s to a " From 9870bc9bcf6bc312525c087bfcee91c87357b2a2 Mon Sep 17 00:00:00 2001 From: hognek <227774406+hognek@users.noreply.github.com> Date: Wed, 29 Jul 2026 18:37:17 +0200 Subject: [PATCH 09/20] fix(hub): move set_contact_status call after both block-cascade branches --- tinyagentos/routes/hub.py | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/tinyagentos/routes/hub.py b/tinyagentos/routes/hub.py index 0041d387b..b9f23fd34 100644 --- a/tinyagentos/routes/hub.py +++ b/tinyagentos/routes/hub.py @@ -534,28 +534,31 @@ async def block_peer( if contacts_store is not None: try: author = await store.get_author(peer) + cid = None if author and author.get("username"): - await contacts_store.revoke_peer_link(f"hub:{author['username']}") + cid = f"hub:{author['username']}" + await contacts_store.revoke_peer_link(cid) else: # Fall back to the fingerprint pinned on the contacts row # (independent of the volatile hub_authors cache). contact = await contacts_store.get_contact_by_fingerprint(peer) if contact: await contacts_store.revoke_peer_link(contact["contact_id"]) - # Mark the contact as blocked so the UI reflects the distinct - # status rather than leaving it at the prior accepted state. cid = contact["contact_id"] - try: - await contacts_store.set_contact_status(cid, "blocked") - except Exception: - logger.warning( - "hub block: set_contact_status blocked failed for %s", cid - ) else: logger.warning( "hub block: could not resolve fingerprint %s to a " "contact; peer link may still be active", peer, ) + # Mark the contact as blocked so the UI reflects the distinct + # status rather than leaving it at the prior accepted state. + if cid is not None: + 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) From 2be8025681e8bd975476ab9b0e3f81bbbe58f211 Mon Sep 17 00:00:00 2001 From: hognek <227774406+hognek@users.noreply.github.com> Date: Wed, 29 Jul 2026 18:37:45 +0200 Subject: [PATCH 10/20] fix(tests): repair two security regression tests for #2043 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - test_authz_rejection: accept route returns upstream status code (403), not 200 wrapped — update assertion and state check - test_block_guard: _try_handshake guard checks hub REL_BLOCK not contact status — add REL_BLOCK relationship in test setup --- tests/test_collab_a2_handshake.py | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/tests/test_collab_a2_handshake.py b/tests/test_collab_a2_handshake.py index 007ebb697..238cd818e 100644 --- a/tests/test_collab_a2_handshake.py +++ b/tests/test_collab_a2_handshake.py @@ -551,7 +551,8 @@ 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.""" + 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( @@ -564,11 +565,10 @@ async def handler(method, url, **kw): "/api/hub/friends/requests/test-rid-403/accept", json={"peer_fingerprint": _PEER_FP}, ) - # Accept should handle the upstream error gracefully - assert resp.status_code == 200 + # Route passes upstream status; no contact/handshake happens + assert resp.status_code == 403 data = resp.json() - # State must indicate failure - assert data["state"] != "accepted" + assert data["state"] == "rejected" store = app_with_contacts.state.contacts_store contact = await store.get_contact(f"hub:{_PEER_USERNAME}") @@ -597,6 +597,19 @@ async def test_block_guard_prevent_reaccept_resurrection( ) await store.set_contact_status(f"hub:{_PEER_USERNAME}", "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, From 7c2df2d5019488473975b6fd03a07158447b805e Mon Sep 17 00:00:00 2001 From: hognek <227774406+hognek@users.noreply.github.com> Date: Wed, 29 Jul 2026 18:57:33 +0200 Subject: [PATCH 11/20] ci: retrigger CI after sniffio infra failure in shard (3.13, 4) From 4a4777e443f2ba98547a3d02bba67604826bb198 Mon Sep 17 00:00:00 2001 From: hognek <227774406+hognek@users.noreply.github.com> Date: Sun, 2 Aug 2026 10:24:34 +0200 Subject: [PATCH 12/20] fix(collab): fold send_handshake + deliver_handshake from #2046 into peer.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fold the sender-side handshake code from PR #2046 into this branch's peer.py. The send_handshake() function builds an Ed25519-signed handshake envelope addressed to a remote contact, carrying the inbound peer token, advertised endpoints, and public keys. deliver_handshake() delivers the envelope to the peer's endpoints (best-effort, first-2xx). This resolves jaylfc's HOLD (1): the PR previously only had hub.py receive side — the sender side from #2046 is now included. HOLD (2) — the peer_fingerprint migration — was already resolved in a prior commit (5281509e) which replaced the MIGRATIONS entry with a guarded _post_init (PRAGMA table_info + ALTER TABLE). Existing DB upgrade tests (test_store_upgrades.py::TestContactsStoreUpgrade) pass. --- tinyagentos/peer.py | 82 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/tinyagentos/peer.py b/tinyagentos/peer.py index d48c67cd2..5da6ddd14 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,87 @@ def mint_peer_token(sub: str) -> tuple[str, str]: return raw, token_hash +# --------------------------------------------------------------------------- +# Handshake delivery +# --------------------------------------------------------------------------- + + +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 # --------------------------------------------------------------------------- From 108046efe5c1d0804949d2b41a683d81e189d707 Mon Sep 17 00:00:00 2001 From: hognek <227774406+hognek@users.noreply.github.com> Date: Sun, 2 Aug 2026 19:03:25 +0200 Subject: [PATCH 13/20] =?UTF-8?q?fix:=20address=20CodeRabbit=20findings=20?= =?UTF-8?q?on=20PR=20#2043=20=E2=80=94=20block-guard,=20peer=5Flinks=20ass?= =?UTF-8?q?ertions,=20fixture=20leak?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Wrap block-guard has_edge() call inside try block so a store failure never blocks the accept (best-effort handshake contract). - Add peer_links assertions to three negative-path tests (no-pubkeys, imposter pubkey, 403 rejection) verifying that no token-bearing artifact is created when the handshake is skipped. - Convert app_with_contacts fixture to yield/close to prevent contacts_store database file leak during tmp_data_dir teardown. --- tests/test_collab_a2_handshake.py | 14 +++++++++++++- tinyagentos/routes/hub.py | 11 ++++++----- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/tests/test_collab_a2_handshake.py b/tests/test_collab_a2_handshake.py index 238cd818e..242666d65 100644 --- a/tests/test_collab_a2_handshake.py +++ b/tests/test_collab_a2_handshake.py @@ -128,7 +128,13 @@ async def app_with_contacts(tmp_data_dir, monkeypatch): monkeypatch.setenv("TAOS_DATA_DIR", str(tmp_data_dir)) _bootstrap_hub_identity(tmp_data_dir) - return _app + 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 @@ -275,6 +281,8 @@ async def handler(method, url, **kw): store = app_with_contacts.state.contacts_store contact = await store.get_contact(f"hub:{_PEER_USERNAME}") assert contact is None, "no contact should be created without pubkeys" + link = await store.get_peer_link(f"hub:{_PEER_USERNAME}") + 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 @@ -546,6 +554,8 @@ async def handler(method, url, **kw): store = app_with_contacts.state.contacts_store contact = await store.get_contact(f"hub:{_PEER_USERNAME}") assert contact is None, "imposter pubkey must not create a contact" + link = await store.get_peer_link(f"hub:{_PEER_USERNAME}") + 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 @@ -573,6 +583,8 @@ async def handler(method, url, **kw): store = app_with_contacts.state.contacts_store contact = await store.get_contact(f"hub:{_PEER_USERNAME}") assert contact is None, "403 must not create a contact" + link = await store.get_peer_link(f"hub:{_PEER_USERNAME}") + 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 diff --git a/tinyagentos/routes/hub.py b/tinyagentos/routes/hub.py index b9f23fd34..6f4b7a0bd 100644 --- a/tinyagentos/routes/hub.py +++ b/tinyagentos/routes/hub.py @@ -112,11 +112,6 @@ async def _try_handshake( logged but never block the accept — the accept always succeeds even when the handshake side-effect temporarily can't complete. """ - # Guard: a blocked peer must not be resurrected on re-accept. - store = await _get_store(request) - if peer_fingerprint and await store.has_edge(peer_fingerprint, relationships.REL_BLOCK): - return - contacts_store = getattr(request.app.state, "contacts_store", None) if contacts_store is None: return @@ -139,6 +134,12 @@ async def _try_handshake( 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) From c454eec47e7f3b8fba7aff8ac98b856fbeb8328a Mon Sep 17 00:00:00 2001 From: hognek <227774406+hognek@users.noreply.github.com> Date: Sun, 2 Aug 2026 20:14:14 +0200 Subject: [PATCH 14/20] chore: retrigger CI (CLA author fix + doc-gate) Docs-Reviewed: retrigger CI after author identity fix; no API surface changes From 97096ceb401e23f1702057b7954a9b9769159be9 Mon Sep 17 00:00:00 2001 From: hognek <227774406+hognek@users.noreply.github.com> Date: Thu, 13 Aug 2026 23:25:24 +0200 Subject: [PATCH 15/20] fix(hub): key TOFU contact pin on signing-key fingerprint, not username (#2043) contact_id was derived from the peer-controlled directory username, so a username collision or rename could overwrite a pinned contact's key material or fragment the same peer across two contact rows. Key on the fingerprint (contact_id = 'hub:{fingerprint}'), drop the UNIQUE constraint on hub_username, and make block_peer resolve via get_contact_by_fingerprint as the primary path. --- tests/test_collab_a2_handshake.py | 189 ++++++++++++++++++++++++++---- tinyagentos/contacts_store.py | 66 ++++++++++- tinyagentos/routes/hub.py | 47 ++++---- 3 files changed, 245 insertions(+), 57 deletions(-) diff --git a/tests/test_collab_a2_handshake.py b/tests/test_collab_a2_handshake.py index 242666d65..500a06af2 100644 --- a/tests/test_collab_a2_handshake.py +++ b/tests/test_collab_a2_handshake.py @@ -105,6 +105,14 @@ def _bootstrap_hub_identity(data_dir: Path, username: str = "localnode") -> str: _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 @@ -193,7 +201,7 @@ async def handler(method, url, **kw): # Verify contact row was created store = app_with_contacts.state.contacts_store - contact = await store.get_contact(f"hub:{_PEER_USERNAME}") + 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" @@ -202,7 +210,7 @@ async def handler(method, url, **kw): assert contact["status"] == "active" # Verify peer link was established - link = await store.get_peer_link(f"hub:{_PEER_USERNAME}") + 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 @@ -250,7 +258,7 @@ async def handler(method, url, **kw): assert resp.status_code == 200 store = app_with_contacts.state.contacts_store - contact = await store.get_contact(f"hub:{_PEER_USERNAME}") + 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 @@ -279,9 +287,9 @@ async def handler(method, url, **kw): assert data["state"] == "accepted" store = app_with_contacts.state.contacts_store - contact = await store.get_contact(f"hub:{_PEER_USERNAME}") + 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_USERNAME}") + 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( @@ -308,7 +316,7 @@ async def handler(method, url, **kw): assert resp.status_code == 200 store = app_with_contacts.state.contacts_store - link = await store.get_peer_link(f"hub:{_PEER_USERNAME}") + 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}] @@ -338,12 +346,12 @@ async def handler(method, url, **kw): assert resp.status_code == 200 store = app_with_contacts.state.contacts_store - first_link = await store.get_peer_link(f"hub:{_PEER_USERNAME}") + 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_USERNAME}") - revoked_link = await store.get_peer_link(f"hub:{_PEER_USERNAME}") + 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 @@ -354,7 +362,7 @@ async def handler(method, url, **kw): ) assert resp2.status_code == 200 - link = await store.get_peer_link(f"hub:{_PEER_USERNAME}") + 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" @@ -384,6 +392,75 @@ async def handler(method, url, **kw): 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 @@ -399,7 +476,7 @@ async def test_block_cascades_to_contacts_store( # 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_USERNAME}", + contact_id=f"hub:{_PEER_FP}", hub_username=_PEER_USERNAME, display_name="Remote", ed25519_pub=_PEER_SIGNING_PUB, @@ -407,7 +484,7 @@ async def test_block_cascades_to_contacts_store( peer_fingerprint=_PEER_FP, ) await store.establish_peer_link( - contact_id=f"hub:{_PEER_USERNAME}", + contact_id=f"hub:{_PEER_FP}", inbound_token=generate_peer_token(), outbound_token=generate_peer_token(), ) @@ -445,11 +522,11 @@ async def handler(method, url, **kw): assert data["state"] == "blocked" # Verify peer link is revoked - link = await store.get_peer_link(f"hub:{_PEER_USERNAME}") + 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_USERNAME}") + contact = await store.get_contact(f"hub:{_PEER_FP}") assert contact["status"] == "blocked" async def test_block_cascade_handles_missing_contacts_store( @@ -480,7 +557,7 @@ async def test_block_cascade_fingerprint_fallback( # Create contact + peer link with fingerprint, but do NOT seed hub_authors. await store.add_contact( - contact_id=f"hub:{_PEER_USERNAME}", + contact_id=f"hub:{_PEER_FP}", hub_username=_PEER_USERNAME, display_name="Remote", ed25519_pub=_PEER_SIGNING_PUB, @@ -488,7 +565,7 @@ async def test_block_cascade_fingerprint_fallback( peer_fingerprint=_PEER_FP, ) await store.establish_peer_link( - contact_id=f"hub:{_PEER_USERNAME}", + contact_id=f"hub:{_PEER_FP}", inbound_token=generate_peer_token(), outbound_token=generate_peer_token(), ) @@ -509,11 +586,71 @@ async def handler(method, url, **kw): assert resp.json()["state"] == "blocked" # Verify peer link is revoked (fingerprint fallback worked) - link = await store.get_peer_link(f"hub:{_PEER_USERNAME}") + 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_USERNAME}") + 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" @@ -552,9 +689,9 @@ async def handler(method, url, **kw): assert resp.status_code == 200 store = app_with_contacts.state.contacts_store - contact = await store.get_contact(f"hub:{_PEER_USERNAME}") + 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_USERNAME}") + 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( @@ -581,9 +718,9 @@ async def handler(method, url, **kw): assert data["state"] == "rejected" store = app_with_contacts.state.contacts_store - contact = await store.get_contact(f"hub:{_PEER_USERNAME}") + 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_USERNAME}") + 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( @@ -595,7 +732,7 @@ async def test_block_guard_prevent_reaccept_resurrection( # Create a contact and peer link, then block. await store.add_contact( - contact_id=f"hub:{_PEER_USERNAME}", + contact_id=f"hub:{_PEER_FP}", hub_username=_PEER_USERNAME, display_name="Remote", ed25519_pub=_PEER_SIGNING_PUB, @@ -603,11 +740,11 @@ async def test_block_guard_prevent_reaccept_resurrection( peer_fingerprint=_PEER_FP, ) await store.establish_peer_link( - contact_id=f"hub:{_PEER_USERNAME}", + contact_id=f"hub:{_PEER_FP}", inbound_token=generate_peer_token(), outbound_token=generate_peer_token(), ) - await store.set_contact_status(f"hub:{_PEER_USERNAME}", "blocked") + 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 @@ -644,7 +781,7 @@ async def handler(method, url, **kw): assert resp.status_code == 200 # Contact must still be blocked — NOT resurrected to active - contact = await store.get_contact(f"hub:{_PEER_USERNAME}") + 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/tinyagentos/contacts_store.py b/tinyagentos/contacts_store.py index 1b71dc474..8b7fc62f6 100644 --- a/tinyagentos/contacts_store.py +++ b/tinyagentos/contacts_store.py @@ -12,8 +12,8 @@ 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, @@ -99,6 +99,63 @@ async def _post_init(self) -> None: ) 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 + + 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 # ------------------------------------------------------------------ @@ -166,8 +223,9 @@ async def get_contact_by_fingerprint( """Look up a contact by its peer signing-key fingerprint. Returns the contact row, or None if no contact is pinned to this - fingerprint. Used by the block cascade as a fallback when the - hub_authors cache is missing or stale. + 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. """ if not peer_fingerprint: return None diff --git a/tinyagentos/routes/hub.py b/tinyagentos/routes/hub.py index 6f4b7a0bd..4663fd740 100644 --- a/tinyagentos/routes/hub.py +++ b/tinyagentos/routes/hub.py @@ -117,17 +117,16 @@ async def _try_handshake( return username = directory_resp.get("username") or directory_resp.get("target") or "" - if not username: - # Can't form a contact_id without a username. - return - # NOTE: contact_id is derived from the directory-supplied username, not the - # verified fingerprint. This means TOFU key-pinning is bound to a - # peer-controllable name — a peer that changes its username between the - # request and accept flows could create a shadow contact. Future designs - # should consider binding to a canonical fingerprint-based identifier - # (e.g. hub:{fingerprint}) with username as a display column. - contact_id = f"hub:{username}" + # 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 "" @@ -528,29 +527,23 @@ async def block_peer( # 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 fingerprint to a contact_id first via the hub_authors - # cache, then fall back to a direct fingerprint lookup on the contacts - # table when the author row is missing or stale. + # 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: - author = await store.get_author(peer) + contact = await contacts_store.get_contact_by_fingerprint(peer) cid = None - if author and author.get("username"): - cid = f"hub:{author['username']}" + if contact: + cid = contact["contact_id"] await contacts_store.revoke_peer_link(cid) else: - # Fall back to the fingerprint pinned on the contacts row - # (independent of the volatile hub_authors cache). - contact = await contacts_store.get_contact_by_fingerprint(peer) - if contact: - await contacts_store.revoke_peer_link(contact["contact_id"]) - cid = contact["contact_id"] - else: - logger.warning( - "hub block: could not resolve fingerprint %s to a " - "contact; peer link may still be active", peer, - ) + logger.warning( + "hub block: could not resolve fingerprint %s to a " + "contact; peer link may still be active", peer, + ) # Mark the contact as blocked so the UI reflects the distinct # status rather than leaving it at the prior accepted state. if cid is not None: From 5dd9097b82d6a86646106b6407d497d1a5001f6d Mon Sep 17 00:00:00 2001 From: hognek <227774406+hognek@users.noreply.github.com> Date: Tue, 18 Aug 2026 09:53:55 +0200 Subject: [PATCH 16/20] fix(hub): report revoke matches + revoke all fingerprint contacts (#2043) Complete the two supporting changes jaylfc required alongside the fingerprint-keyed TOFU pin: 1. revoke_peer_link now returns a bool (True when a peer_link row matched) and block_peer logs loudly when a revoke matched zero rows, so a fail-open revoke can never be silently reported as success. 2. The block cascade now revokes every contact pinned to a fingerprint via get_contacts_by_fingerprint instead of get_contact_by_fingerprint's rows[0]. Legacy username-keyed rows (or a rename mid-flight) can leave several contacts sharing a fingerprint; revoking only the first would leave a live peer link behind. Adds test_block_cascade_revokes_all_contacts_sharing_fingerprint (two legacy contacts, one fingerprint, both must end revoked+blocked). --- tests/test_collab_a2_handshake.py | 54 +++++++++++++++++++++++++++++++ tinyagentos/contacts_store.py | 50 +++++++++++++++++++++------- tinyagentos/routes/hub.py | 29 +++++++++++------ 3 files changed, 112 insertions(+), 21 deletions(-) diff --git a/tests/test_collab_a2_handshake.py b/tests/test_collab_a2_handshake.py index 500a06af2..cde17f26f 100644 --- a/tests/test_collab_a2_handshake.py +++ b/tests/test_collab_a2_handshake.py @@ -653,6 +653,60 @@ async def handler(method, url, **kw): 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 diff --git a/tinyagentos/contacts_store.py b/tinyagentos/contacts_store.py index 8b7fc62f6..532ae6462 100644 --- a/tinyagentos/contacts_store.py +++ b/tinyagentos/contacts_store.py @@ -217,25 +217,43 @@ 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_contact_by_fingerprint( + async def get_contacts_by_fingerprint( self, peer_fingerprint: str - ) -> Optional[dict]: - """Look up a contact by its peer signing-key fingerprint. + ) -> list[dict]: + """Return every contact row pinned to a 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. + 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 None + 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, rows[0]) if rows else None + 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: @@ -390,18 +408,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/routes/hub.py b/tinyagentos/routes/hub.py index 4663fd740..e5ed876df 100644 --- a/tinyagentos/routes/hub.py +++ b/tinyagentos/routes/hub.py @@ -534,19 +534,30 @@ async def block_peer( contacts_store = getattr(request.app.state, "contacts_store", None) if contacts_store is not None: try: - contact = await contacts_store.get_contact_by_fingerprint(peer) - cid = None - if contact: - cid = contact["contact_id"] - await contacts_store.revoke_peer_link(cid) - else: + # 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, ) - # Mark the contact as blocked so the UI reflects the distinct - # status rather than leaving it at the prior accepted state. - if cid is not None: + 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: From b79acab5ea181cc28b64d4ef75a643a856dc2301 Mon Sep 17 00:00:00 2001 From: hognek <227774406+hognek@users.noreply.github.com> Date: Thu, 27 Aug 2026 21:51:05 +0200 Subject: [PATCH 17/20] fix(contacts): backfill peer_fingerprint for pre-existing rows in _post_init MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without backfill, contacts that predate the peer_fingerprint column keep DEFAULT '' forever. block_peer (routes/hub.py) resolves peers by fingerprint only, so every pre-existing contact is unreachable by the block path — the peer link is never revoked and the blocked peer keeps authenticating on /api/peer/*. - Backfill peer_fingerprint from identity.fingerprint(ed25519_pub) for all rows where peer_fingerprint is empty but ed25519_pub is set. - Add regression test that seeds a v0 (pre-column) contacts DB and verifies fingerprints are backfilled on upgrade. - Document x25519_pub as accepted unverified (no verification protocol at this head; re-pinned every accept). - Flag deliver_handshake SSRF risk: POSTs to peer-supplied URLs with no guard — wire an ssrf-safe transport before adding a caller. --- tests/test_store_upgrades.py | 41 +++++++++++++++++++++++++++++++++++ tinyagentos/contacts_store.py | 24 ++++++++++++++++++-- tinyagentos/peer.py | 6 +++++ 3 files changed, 69 insertions(+), 2 deletions(-) diff --git a/tests/test_store_upgrades.py b/tests/test_store_upgrades.py index 776e203d9..5767af997 100644 --- a/tests/test_store_upgrades.py +++ b/tests/test_store_upgrades.py @@ -30,6 +30,7 @@ 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 # --------------------------------------------------------------------------- @@ -553,6 +554,46 @@ async def test_upgrade_add_contact_works_after_upgrade(self, tmp_path): 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() + # --------------------------------------------------------------------------- # Regression: no SCHEMA CREATE INDEX references a _post_init-added column diff --git a/tinyagentos/contacts_store.py b/tinyagentos/contacts_store.py index 532ae6462..893624fc7 100644 --- a/tinyagentos/contacts_store.py +++ b/tinyagentos/contacts_store.py @@ -8,6 +8,7 @@ from typing import Optional from tinyagentos.base_store import BaseStore +from tinyagentos.hub.identity import fingerprint as _compute_fingerprint CONTACTS_SCHEMA = """ @@ -15,8 +16,8 @@ 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 @@ -139,6 +140,25 @@ async def _post_init(self) -> None: 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: + fp = _compute_fingerprint(ed25519_pub) + 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 ( diff --git a/tinyagentos/peer.py b/tinyagentos/peer.py index 5da6ddd14..9f61d5d3b 100644 --- a/tinyagentos/peer.py +++ b/tinyagentos/peer.py @@ -169,6 +169,12 @@ def mint_peer_token(sub: str) -> tuple[str, str]: # --------------------------------------------------------------------------- # 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( From 121d78c5bd73c402c51b5e9190597088c693f410 Mon Sep 17 00:00:00 2001 From: hognek <227774406+hognek@users.noreply.github.com> Date: Thu, 27 Aug 2026 22:04:24 +0200 Subject: [PATCH 18/20] fix(contacts): guard fingerprint backfill against malformed ed25519_pub _bytes.fromhex in _compute_fingerprint raises ValueError on non-hex key material (odd-length strings, non-hex chars, embedded NULs). A single v0 row with malformed ed25519_pub would crash init() and brick the entire contacts store on upgrade. - Wrap per-row _compute_fingerprint in try/except (ValueError, TypeError); log a warning and skip the row so the rest of the backfill completes. - Add regression test: seed a v0 DB with one valid and one malformed ed25519_pub row; assert init() completes, the valid row is backfilled, and the malformed row is left alone (not bricked). --- tests/test_store_upgrades.py | 49 +++++++++++++++++++++++++++++++++++ tinyagentos/contacts_store.py | 12 ++++++++- 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/tests/test_store_upgrades.py b/tests/test_store_upgrades.py index 5767af997..a3360df2a 100644 --- a/tests/test_store_upgrades.py +++ b/tests/test_store_upgrades.py @@ -594,6 +594,55 @@ async def test_upgrade_backfills_fingerprint_for_existing_rows( 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 893624fc7..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 @@ -10,6 +11,8 @@ 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 ( @@ -151,7 +154,14 @@ async def _post_init(self) -> None: ) as cursor: stale = await cursor.fetchall() for contact_id, ed25519_pub in stale: - fp = _compute_fingerprint(ed25519_pub) + 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), From 6e6c56f04255fbde747bce0e4d24a2e05ae3b818 Mon Sep 17 00:00:00 2001 From: hognek <227774406+hognek@users.noreply.github.com> Date: Fri, 28 Aug 2026 00:07:17 +0200 Subject: [PATCH 19/20] fix(tests): arm collab_a2_handshake client with CSRF event hooks after #2547 inversion The conftest CSRF inversion (#2547, f1b01d90d) made verify_csrf enforce for every test that builds its own AsyncClient. The client_with_contacts fixture injected taos_session but was missing event_hooks, so every POST to /api/hub/friends/requests/{rid}/accept and /api/hub/friends/block returned 403 instead of 200. Add the csrf_event_hooks import and pass it at client construction time, matching the pattern applied to 42 other test modules in the CSRF sweep. --- tests/test_collab_a2_handshake.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/test_collab_a2_handshake.py b/tests/test_collab_a2_handshake.py index cde17f26f..6e66250b1 100644 --- a/tests/test_collab_a2_handshake.py +++ b/tests/test_collab_a2_handshake.py @@ -15,6 +15,8 @@ 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 # --------------------------------------------------------------------------- @@ -161,6 +163,7 @@ async def client_with_contacts(app_with_contacts): transport=transport, base_url="http://test", cookies={"taos_session": _token}, + event_hooks=csrf_event_hooks(), ) as c: yield c From ebae070366f6c654e29cba363499ba45000ee218 Mon Sep 17 00:00:00 2001 From: jaylfc Date: Fri, 28 Aug 2026 00:20:34 +0000 Subject: [PATCH 20/20] docs(changelog): add fragment for #2561 contacts fingerprint keying --- changelog.d/2561-contacts-fingerprint-keying.md | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 changelog.d/2561-contacts-fingerprint-keying.md 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).