From aff3e6df0f66ef98e54962210369954ebae9e02a Mon Sep 17 00:00:00 2001 From: max Date: Fri, 31 Jul 2026 11:40:44 +0500 Subject: [PATCH] =?UTF-8?q?feat(share):=20contacts=20=E2=80=94=20petnames,?= =?UTF-8?q?=20per-contact=20scope,=20per-pair=20inboxes,=20pause?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three holes the v1 gate left open, all rooted in "who exactly is allowed to see what": - The name a peer introduces themselves with is their own text, so a second contact could enroll under the first one's name. Enrollment now requires a petname the owner chooses (share trust ); every preview, candidate and lookup refers to it, never to the claimed name. - Scope was one list for all trusted peers, but what one colleague should know is not what another should. share allow --to grants per contact; the --to all bucket must be typed out, so opening a project to everyone is never the accident of a forgotten flag. - One inbox address for every peer handed the relay a social graph and made revoke a flag rather than a closed door. Each pairing now mints a fresh 128-bit inbox per side inside the invite's ten-minute window; envelopes must land on the inbox minted for that exact peer, revoke stops us listening on it, and the identity address stays only as a legacy fallback. Plus share pause/resume: a brake, not a mode — paused means mail is not even fetched (fetching is destructive), so questions wait at the relay instead of being consumed and dropped. Decision doc: docs/decisions/2026-07-31-contacts-and-pair-addresses.md Co-Authored-By: Claude Opus 5 --- .../2026-07-31-contacts-and-pair-addresses.md | 90 +++++++ src/session_recall/share/cli.py | 98 ++++++-- src/session_recall/share/envelope.py | 27 ++- src/session_recall/share/identity.py | 11 +- src/session_recall/share/pairing.py | 30 ++- src/session_recall/share/trust.py | 138 +++++++++-- src/session_recall/share/worker.py | 57 +++-- tests/test_share_cli.py | 33 ++- tests/test_share_contacts.py | 219 ++++++++++++++++++ tests/test_share_relay.py | 32 ++- 10 files changed, 629 insertions(+), 106 deletions(-) create mode 100644 docs/decisions/2026-07-31-contacts-and-pair-addresses.md create mode 100644 tests/test_share_contacts.py diff --git a/docs/decisions/2026-07-31-contacts-and-pair-addresses.md b/docs/decisions/2026-07-31-contacts-and-pair-addresses.md new file mode 100644 index 0000000..97588df --- /dev/null +++ b/docs/decisions/2026-07-31-contacts-and-pair-addresses.md @@ -0,0 +1,90 @@ +# Контакты: petname, scope на контакт, адрес на пару, пауза + +## Контекст + +После выката v1-гейта (Q&A между доверенными пирами через слепой relay) встал +вопрос «как обеспечить безопасность системы» в новой рамке, которую предложил +Максим: две оси — **local/cloud** (где живёт отвечающий демон) и **solo/team** +(может ли кто-то вообще спросить). Плюс три конкретных претензии к v1: + +1. «то, что знает один — не факт, что должен знать другой»: scope был общий на + всех доверенных, а не на контакт; +2. имя пира в bundle — его собственный текст: второй контакт может назваться + именем первого; +3. один адрес на всех пирах отдаёт relay социальный граф и делает `revoke` + флажком, а не закрытием канала. + +Отдельно обсуждали: как защитить данные при краже ПК, нужен ли «защищённый +сервис ключей» на сервере, нужен ли пользователям свой VPS, нужен ли туннель +между двумя локальными пользователями. + +## Решение + +- **Petname**: при `share trust ` владелец сам называет контакта; + без имени enrollment не проходит. Всё дальнейшее (превью, кандидаты, треды, + `--to`) ссылается на petname, не на самоназвание пира. Petname уникален. +- **Scope на контакт**: `share allow --to `. Бакет + `--to all` существует, но его надо напечатать явно — забытый флаг не + открывает проект всем. Пустой scope = пустой ответ (default deny как был). +- **Адрес на пару**: инвайт (10-минутное окно) мятит свежий 128-битный inbox + для того, кто его примет; обе стороны пары получают по своему. Identity-адрес + остаётся только как fallback для старых пар. Конверт обязан прийти ровно на + inbox, замятый для этого пира, — иначе silent drop. +- **`share pause` / `resume`**: рубильник. Пауза = почта даже не забирается + (fetch деструктивен), вопросы ждут в relay его TTL (7 дней). +- **Solo/team — не флаг, а состояние**: свежая установка ни с кем не спарена и + никому не отвечает; «team» наступает от `trust` + `allow`. Отдельный + переключатель добавил бы только способ ошибиться. +- **Cloud v1 = свой всегда-включённый узел** (тот же демон на не-спящей + коробке), не общий сервер. Хостимый вариант — потом, с минимизацией + (реплицируются только открытые кому-то проекты) и сканером на репликации. +- **Свой VPS пользователям не нужен**: relay слепой, один публичный relay + обслуживает всех; zero-infra вариант — FileTransport через общую папку. + +## Почему + +- Petname — потому что авторство имени = авторизация имени: единственное имя, + которому можно верить, выбрано владельцем в момент, когда он верифицировал + SAS голосом. Любое другое — текст атакующего. +- Scope на контакт — прямое следствие «одобрение выдаётся получателю, а не + тексту»: то, что можно Егору, не автоматически можно Лене. Тот же аргумент + похоронил кеш одобренных ответов. +- Адрес на пару — три выигрыша по цене одной правки, пока формат молодой: + relay видит острова, а не граф; `revoke` физически закрывает канал (мы + перестаём слушать адрес); утечка адреса у одного контакта не даёт ничего + против других. Честно: от кражи ноутбука владельца это НЕ защищает — с + ноутом уезжает подписной ключ, и вор «становится» владельцем. Это принято. +- Пауза не рвёт пары — тормоз, который можно дёрнуть без последствий, лучше + режима, который надо правильно выставить. +- Туннель не нужен: оба клиента ходят к relay исходящими запросами + (store-and-forward, как почта) — NAT и «второй спит» решены одной схемой. + +## Что протестировали + +- 270 тестов зелёные, включая новые: подмена имени (claimed "maxim" → + превью показывает petname), уникальность petname, изоляция scope между + двумя контактами, бакет all, пауза оставляет почту в транспорте и отвечает + после resume, полная церемония мятит разные адреса с обеих сторон, + кросс-inbox конверт (валидная криптография, чужой ящик) дропается, + revoke убирает inbox из прослушивания, legacy-пары без local_address + работают как раньше. +- Интеграционный relay-тест переписан на реальный флоу enrollment (через + pending_peer, как CLI) — identity-адрес больше не появляется на проводе. + +## Отвергли + +- «Защищённый сервис ключей» рядом с поисковым сервером — театр: если сервер + ищет, он видит открытый текст, а рут достаёт ключ из KMS тем же путём, что + приложение. Реально только как другой домен доверия (HSM/enclave) — не наш + масштаб. +- Прямой p2p/туннели (hole punching) — сложно, оба должны быть онлайн; + relay строго лучше по UX. +- Кеш одобренных ответов — `/ok` авторизует пару (текст, получатель), + переиспользование на другого получателя — новое решение, не кеш-хит. +- Solo/team как конфиг-флаг — состояние пар уже выражает то же самое. +- Ротация *ключей* на пару (не только адресов) — ломает SAS-церемонию и + ничего не добавляет: ключ и так один на устройство, компрометация устройства + фатальна независимо от числа ключей. + +--- +2026-07-31 · PR: feat/contacts (v1 гейт: docs/decisions/2026-07-30-p2p-sharing-v1-security-gate.md) diff --git a/src/session_recall/share/cli.py b/src/session_recall/share/cli.py index a2801bd..00c8cf1 100644 --- a/src/session_recall/share/cli.py +++ b/src/session_recall/share/cli.py @@ -30,13 +30,22 @@ def add_parser(sub) -> None: jp = ssub.add_parser("join", help="accept an invite code from a peer") jp.add_argument("code") ssub.add_parser("complete", help="finish pairing after the peer joined") - ssub.add_parser("trust", help="confirm the SAS matched; enroll the peer") + tp = ssub.add_parser("trust", + help="confirm the SAS matched; enroll the peer under " + "a name YOU choose") + tp.add_argument("petname", nargs="?", + help="what you will call them — unique, yours, required") ssub.add_parser("devices", help="list trusted peers") - rp = ssub.add_parser("revoke", help="revoke a peer by name or address") + rp = ssub.add_parser("revoke", help="revoke a peer by petname or address") rp.add_argument("peer") - ap = ssub.add_parser("allow", help="mark a project shareable (no arg: list)") + ap = ssub.add_parser("allow", + help="grant a contact access to a project (no arg: list)") ap.add_argument("project", nargs="?") + ap.add_argument("--to", dest="to", default=None, metavar="PETNAME", + help="which contact; `--to all` opens it to every contact") ap.add_argument("--remove", action="store_true") + ssub.add_parser("pause", help="stop answering (questions wait at the relay)") + ssub.add_parser("resume", help="resume answering after a pause") rlp = ssub.add_parser("relay", help="run the relay server (blind blob store)") rlp.add_argument("--port", type=int, default=8787) rlp.add_argument("--host", default="127.0.0.1", @@ -233,8 +242,7 @@ def run(args: argparse.Namespace) -> int: return 0 if cmd == "ask": - peer = (trust.get_by_address(args.peer) - or next((p for p in trust.peers() if p.name == args.peer), None)) + peer = trust.get(args.peer) if peer is None: print(f"no trusted peer {args.peer!r} — see: session-recall share devices") return 1 @@ -252,12 +260,14 @@ def run(args: argparse.Namespace) -> int: state = ShareState(sdir / "state.json") answers = 0 - for raw in transport.fetch_mail(ident.address): - got = open_incoming(ident, trust, state, raw) - if got is None or got.kind != "resp": - continue # requests are the answering service's business - answers += 1 - print(f"--- answer from {got.peer.name} ---\n{got.body.get('text', '')}\n") + for inbox in trust.inbox_addresses(ident): + for raw in transport.fetch_mail(inbox): + got = open_incoming(ident, trust, state, raw) + if got is None or got.kind != "resp": + continue # requests are the answering service's business + answers += 1 + print(f"--- answer from {got.peer.label} ---\n" + f"{got.body.get('text', '')}\n") if not answers: print("no answers waiting") return 0 @@ -357,16 +367,28 @@ def run(args: argparse.Namespace) -> int: print("nothing to trust — finish a pairing (join/complete) first") return 1 b = cand["bundle"] + # The petname is the owner's word against impersonation: the bundle's + # name is whatever the peer typed, so the name everything else refers + # to must be chosen HERE, by the human, at enrollment. + petname = (args.petname or "").strip() + if not petname: + print(f'they introduce themselves as {b["name"]!r} — that is their ' + "text, not a fact.\nenroll them under a name you choose: " + "session-recall share trust ") + return 1 try: trust.add(Peer(name=b["name"], address=b["address"], - sign_pk=b["sign_pk"], box_pk=b["box_pk"])) + sign_pk=b["sign_pk"], box_pk=b["box_pk"], + petname=petname, + local_address=cand.get("local_address", ""))) except ValueError as exc: print(exc) return 1 pairing.clear_pending_peer(sdir) - print(f"trusted: {b['name']} ({b['address']}), mode=accept\n" - "they can ask you questions once the answering service ships; " - "revoke anytime: session-recall share revoke " + b["name"]) + print(f"trusted: {petname} (introduces as {b['name']!r}), mode=accept\n" + f"they see nothing until you grant scope: " + f"session-recall share allow --to {petname}\n" + f"revoke anytime: session-recall share revoke {petname}") return 0 if cmd == "devices": @@ -376,7 +398,9 @@ def run(args: argparse.Namespace) -> int: return 0 for p in peers: mark = "REVOKED " if p.revoked else "" - print(f"{mark}{p.name} {p.address} mode={p.mode}") + scope = ", ".join(trust.projects_for(p)) or "(no scope — sees nothing)" + claimed = f' "{p.name}"' if p.name != p.label else "" + print(f"{mark}{p.label}{claimed} {p.address} mode={p.mode} {scope}") return 0 if cmd == "revoke": @@ -384,22 +408,46 @@ def run(args: argparse.Namespace) -> int: if peer is None: print(f"no active peer matching {args.peer!r}") return 1 - print(f"revoked: {peer.name} ({peer.address}) — their envelopes now drop silently") + print(f"revoked: {peer.label} ({peer.address}) — their channel is dead: " + "we stop reading their inbox and their envelopes drop silently") + return 0 + + if cmd in ("pause", "resume"): + trust.set_paused(cmd == "pause") + print("paused — nothing is read or answered; questions wait at the relay" + if cmd == "pause" else "resumed — answering again") return 0 if cmd == "allow": if args.project is None: - allowed = trust.allowed_projects() - print("\n".join(allowed) if allowed else - "no shareable projects (default deny) — add one: " - "session-recall share allow ") + lines = [f"all contacts: {p}" for p in trust.allowed_projects()] + for peer in trust.peers(): + lines += [f"{peer.label}: {p}" for p in peer.projects] + print("\n".join(lines) if lines else + "no grants (default deny) — every answer comes back empty.\n" + "grant one: session-recall share allow --to ") return 0 + # Granting requires saying WHO. `--to all` is the everyone-bucket and + # has to be typed out — opening a project to all contacts must never be + # the accident of forgetting a flag. + if args.to is None: + print("say who: session-recall share allow " + f"{args.project} --to (or --to all)") + return 1 + peer = None + if args.to != "all": + peer = trust.get(args.to) + if peer is None: + print(f"no trusted peer {args.to!r} — see: session-recall share devices") + return 1 if args.remove: - trust.disallow_project(args.project) - print(f"no longer shareable: {args.project}") + trust.disallow_project(args.project, peer) + print(f"removed: {args.project} from " + f"{'all contacts' if peer is None else peer.label}") else: - trust.allow_project(args.project) - print(f"shareable: {args.project}") + trust.allow_project(args.project, peer) + print(f"{peer.label if peer else 'every contact'} may now see: " + f"{args.project}") return 0 return 1 diff --git a/src/session_recall/share/envelope.py b/src/session_recall/share/envelope.py index c085fb4..28e2f96 100644 --- a/src/session_recall/share/envelope.py +++ b/src/session_recall/share/envelope.py @@ -82,11 +82,12 @@ class Incoming: def _sealed(identity: Identity, peer_box_pk: str, kind: str, body: dict, - to_address: str, in_reply_to: str | None) -> bytes: + to_address: str, in_reply_to: str | None, + from_address: str = "") -> bytes: box = Box(identity.box_key, PublicKey(unb64(peer_box_pk))) envelope = { "v": 1, "kind": kind, - "from": identity.address, "to": to_address, + "from": from_address or identity.address, "to": to_address, "ts": time.time(), "nonce": b64(os.urandom(16)), "ct": b64(box.encrypt(canonical(body))), } @@ -97,16 +98,24 @@ def _sealed(identity: Identity, peer_box_pk: str, kind: str, body: dict, return json.dumps(envelope).encode() +def _pair_addresses(peer: Peer | dict) -> tuple[str, str]: + """(their inbox, our inbox for them). Both are minted per pairing, so the + two addresses on the wire mean nothing outside this one relationship.""" + if isinstance(peer, Peer): + return peer.address, peer.local_address + return peer.get("address", ""), peer.get("local_address", "") + + def make_request(identity: Identity, peer: Peer | dict, question: str, task: str = "", problem: str = "", thread: str = "") -> bytes: """`thread` rides inside the encrypted body on purpose: the relay must not learn which envelopes belong to the same conversation.""" box_pk = peer.box_pk if isinstance(peer, Peer) else peer["box_pk"] - address = peer.address if isinstance(peer, Peer) else peer["address"] + to_address, from_address = _pair_addresses(peer) return _sealed(identity, box_pk, "req", {"question": question, "task": task, "problem": problem, "thread": thread}, - address, None) + to_address, None, from_address) def make_response(identity: Identity, peer: Peer, text: str, @@ -116,9 +125,10 @@ def make_response(identity: Identity, peer: Peer, text: str, many fragments it rests on, so the asker can tell a grounded answer from a guess. Session ids and project names stay home: the asker could not read them anyway, and they are exactly the metadata worth not leaking.""" + to_address, from_address = _pair_addresses(peer) return _sealed(identity, peer.box_pk, "resp", {"text": text, "thread": thread, "sources": sources}, - peer.address, in_reply_to) + to_address, in_reply_to, from_address) def open_incoming(identity: Identity, trust: TrustStore, state: ShareState, @@ -135,12 +145,15 @@ def open_incoming(identity: Identity, trust: TrustStore, state: ShareState, return None if env.get("kind") not in ("req", "resp"): return None - if env.get("to") != identity.address: - return None peer = trust.get_by_address(env.get("from", "")) if peer is None: # unknown or revoked → same silence return None + # Each pairing minted this peer their own inbox on our side; an envelope + # from them must land exactly there. Cross-inbox delivery would mean a + # replayed or misrouted blob even if the crypto checks out. + if env.get("to") != (peer.local_address or identity.address): + return None sig = env.pop("sig", None) if not sig: return None diff --git a/src/session_recall/share/identity.py b/src/session_recall/share/identity.py index 7f05502..55c65f7 100644 --- a/src/session_recall/share/identity.py +++ b/src/session_recall/share/identity.py @@ -45,10 +45,15 @@ def sign_pk_b64(self) -> str: def box_pk_b64(self) -> str: return b64(bytes(self.box_key.public_key)) - def public_bundle(self) -> dict: + def public_bundle(self, address: str | None = None) -> dict: """What the other side learns about us during pairing. Public keys and a - self-chosen name only — nothing here is sensitive.""" - return {"name": self.name, "address": self.address, + self-chosen name only — nothing here is sensitive. + + `address` is the inbox minted for this one peer (see trust.Peer). Each + pairing hands out a different one, so no contact learns an address any + other contact knows, and the relay sees unrelated islands instead of a + social graph. Omitted only where there is no pair yet.""" + return {"name": self.name, "address": address or self.address, "sign_pk": self.sign_pk_b64, "box_pk": self.box_pk_b64} diff --git a/src/session_recall/share/pairing.py b/src/session_recall/share/pairing.py index 1bb79cf..0adb38c 100644 --- a/src/session_recall/share/pairing.py +++ b/src/session_recall/share/pairing.py @@ -20,7 +20,7 @@ from nacl.secret import SecretBox from nacl.utils import random as nacl_random -from .crypto import b32, unb32, b64, unb64, canonical, sas_code, group +from .crypto import b32, unb32, b64, unb64, canonical, new_address, sas_code, group from .identity import Identity INVITE_TTL_S = 600 @@ -57,12 +57,20 @@ def _open(key: bytes, blob: bytes, ttl_s: float = INVITE_TTL_S) -> dict: def start_invite(identity: Identity, transport, share_dir: Path) -> str: - """Returns the code to hand over out-of-band (chat, voice, paper).""" + """Returns the code to hand over out-of-band (chat, voice, paper). + + The invite mints a fresh inbox address for whoever answers it, so this + ten-minute window is also where the pair's private channel is born: the code + is what the first joiner spends, and the address they get back is known to + them alone.""" invite_id = nacl_random(_ID_LEN) key = nacl_random(_KEY_LEN) - transport.put_slot(f"pair-a-{b32(invite_id)}", _seal(key, identity.public_bundle())) + local = new_address() + transport.put_slot(f"pair-a-{b32(invite_id)}", + _seal(key, identity.public_bundle(local))) (share_dir / PENDING_INVITE_FILE).write_text(json.dumps( - {"invite_id": b32(invite_id), "key": b64(key), "created_at": time.time()})) + {"invite_id": b32(invite_id), "key": b64(key), "local_address": local, + "created_at": time.time()})) return group(b32(invite_id + key)) @@ -75,8 +83,10 @@ def join(identity: Identity, transport, share_dir: Path, code: str) -> PairingRe if blob is None: raise PairingError("invite not found — expired, already used, or wrong code") their = _open(key, blob) - transport.put_slot(f"pair-b-{invite_id}", _seal(key, identity.public_bundle())) - return _finish(identity, share_dir, their) + local = new_address() + transport.put_slot(f"pair-b-{invite_id}", + _seal(key, identity.public_bundle(local))) + return _finish(identity, share_dir, their, local) def complete_invite(identity: Identity, transport, share_dir: Path) -> PairingResult: @@ -92,15 +102,17 @@ def complete_invite(identity: Identity, transport, share_dir: Path) -> PairingRe raise PairingError("the other side has not joined yet") their = _open(unb64(pending["key"]), blob) pending_path.unlink() - return _finish(identity, share_dir, their) + return _finish(identity, share_dir, their, pending.get("local_address", "")) -def _finish(identity: Identity, share_dir: Path, their: dict) -> PairingResult: +def _finish(identity: Identity, share_dir: Path, their: dict, + local_address: str = "") -> PairingResult: sas = sas_code(unb64(identity.sign_pk_b64), unb64(their["sign_pk"])) # Parked until the human confirms the SAS and runs `share trust` — pairing # never writes the trust store itself. (share_dir / PENDING_PEER_FILE).write_text(json.dumps( - {"bundle": their, "sas": sas, "ts": time.time()})) + {"bundle": their, "sas": sas, "local_address": local_address, + "ts": time.time()})) return PairingResult(bundle=their, sas=sas) diff --git a/src/session_recall/share/trust.py b/src/session_recall/share/trust.py index 24783e7..4b26144 100644 --- a/src/session_recall/share/trust.py +++ b/src/session_recall/share/trust.py @@ -1,8 +1,27 @@ -"""Trust store: who may ask, and which projects are shareable at all. - -Written only by the CLI in a human's hands. The MCP server and the (future) -answer worker get read access at most — an LLM must be physically unable to -enroll a peer or widen scope (gate §2: tokens are made by people). +"""Trust store: who may ask, what each of them may see, and where they reach us. + +Written only by the CLI in a human's hands. The MCP server and the answer +worker get read access at most — an LLM must be physically unable to enroll a +peer or widen scope (gate §2: tokens are made by people). + +Three things live per peer, and each answers a different question: + +- **petname** — what *we* call them. The name a peer picks for itself is their + text and can say anything, including "egor"; the petname is ours, unique, and + it is what every prompt and preview refers to. Impersonation by naming stops + being possible. +- **local_address** — the inbox *we* minted for this one peer during pairing. + Nobody else knows it, so the relay cannot tell that two senders are talking to + the same person, and revoking a peer stops us listening on that address at all + rather than merely flagging their envelopes. +- **projects** — what this peer may see. Scope is per contact because what one + colleague should know is not what another should: `share allow X --to egor`. + The `--to all` bucket exists for projects that are genuinely open to every + contact, and it is spelled out loud rather than being the silent default. + +Nothing here defends against the peer's own machine being stolen: their signing +key goes with it and the thief simply is them. What per-peer addressing buys is +that the blast radius stops at that one channel, and `share revoke` closes it. mode is stored per peer but v1 enforces `accept` everywhere: the bypass ramp (scanner hard-block, per-peer scope, volume caps) is deliberately not built yet @@ -21,19 +40,27 @@ @dataclass class Peer: - name: str - address: str + name: str # the peer's own claim — untrusted text, never a key + address: str # where we send to them (they minted it for us) sign_pk: str box_pk: str mode: str = "accept" added_at: float = 0.0 revoked: bool = False + petname: str = "" # our name for them; unique, used everywhere + local_address: str = "" # the inbox we minted for this peer alone + projects: list = field(default_factory=list) # what they specifically may see + + @property + def label(self) -> str: + return self.petname or self.name @dataclass class _State: peers: list = field(default_factory=list) allowed_projects: list = field(default_factory=list) + paused: bool = False class TrustStore: @@ -43,7 +70,8 @@ def __init__(self, path: Path): raw = json.loads(path.read_text()) self._state = _State( peers=[Peer(**p) for p in raw.get("peers", [])], - allowed_projects=list(raw.get("allowed_projects", []))) + allowed_projects=list(raw.get("allowed_projects", [])), + paused=bool(raw.get("paused", False))) else: self._state = _State() @@ -56,13 +84,18 @@ def _save(self) -> None: stat.S_IRUSR | stat.S_IWUSR) with os.fdopen(fd, "w") as f: json.dump({"peers": [asdict(p) for p in self._state.peers], - "allowed_projects": self._state.allowed_projects}, f, indent=2) + "allowed_projects": self._state.allowed_projects, + "paused": self._state.paused}, f, indent=2) os.replace(tmp, self.path) # -- peers --------------------------------------------------------------- def add(self, peer: Peer) -> None: if self.get_by_address(peer.address, include_revoked=True): raise ValueError(f"peer with address {peer.address} already present") + if peer.petname and self.get(peer.petname, include_revoked=True): + raise ValueError( + f"{peer.petname!r} is already taken — petnames are how you tell " + "contacts apart, so each one must be unique") peer.added_at = peer.added_at or time.time() self._state.peers.append(peer) self._save() @@ -71,31 +104,92 @@ def peers(self, include_revoked: bool = False) -> list[Peer]: return [p for p in self._state.peers if include_revoked or not p.revoked] def get_by_address(self, address: str, include_revoked: bool = False) -> Peer | None: + if not address: + return None for p in self._state.peers: if p.address == address and (include_revoked or not p.revoked): return p return None - def revoke(self, name_or_address: str) -> Peer | None: - """Flag, don't delete: a revoked key must stay known so its envelopes - keep failing closed instead of looking like a stranger's.""" - for p in self._state.peers: - if not p.revoked and name_or_address in (p.name, p.address): - p.revoked = True - self._save() + def get(self, ref: str, include_revoked: bool = False) -> Peer | None: + """By petname first — it is the name the owner chose and the only one + that cannot collide. The peer's self-chosen name is a last resort so + pre-petname stores keep working.""" + for p in self.peers(include_revoked): + if p.petname and p.petname == ref: + return p + for p in self.peers(include_revoked): + if ref in (p.address, p.name): return p return None + def revoke(self, ref: str) -> Peer | None: + """Flag, don't delete: a revoked key must stay known so its envelopes + keep failing closed instead of looking like a stranger's. The inbox we + minted for them drops out of `inbox_addresses` in the same move, so we + stop even collecting their mail.""" + peer = self.get(ref) + if peer is None: + return None + peer.revoked = True + self._save() + return peer + + def inbox_addresses(self, identity) -> list[str]: + """Every address we listen on: the per-peer inboxes plus the identity's + own, which is what pre-pairing installs and legacy peers still use.""" + seen, out = set(), [] + for address in [identity.address] + [p.local_address for p in self.peers()]: + if address and address not in seen: + seen.add(address) + out.append(address) + return out + # -- shareable scope ----------------------------------------------------- def allowed_projects(self) -> list[str]: + """The `--to all` bucket: open to every contact, present and future.""" return list(self._state.allowed_projects) - def allow_project(self, project: str) -> None: - if project not in self._state.allowed_projects: - self._state.allowed_projects.append(project) + def allow_project(self, project: str, peer: Peer | None = None) -> None: + if peer is None: + if project not in self._state.allowed_projects: + self._state.allowed_projects.append(project) + self._save() + return + if project not in peer.projects: + peer.projects.append(project) self._save() - def disallow_project(self, project: str) -> None: - if project in self._state.allowed_projects: - self._state.allowed_projects.remove(project) + def disallow_project(self, project: str, peer: Peer | None = None) -> None: + if peer is None: + if project in self._state.allowed_projects: + self._state.allowed_projects.remove(project) + self._save() + return + if project in peer.projects: + peer.projects.remove(project) self._save() + + def projects_for(self, peer: Peer | None) -> list[str]: + """What this peer may actually see: their own grants plus the everyone + bucket. Default deny — a freshly trusted peer with no grants and an + empty bucket sees nothing at all.""" + if peer is None: + return [] + out = list(self._state.allowed_projects) + for project in peer.projects: + if project not in out: + out.append(project) + return out + + # -- kill switch --------------------------------------------------------- + @property + def paused(self) -> bool: + return self._state.paused + + def set_paused(self, paused: bool) -> None: + """Stop answering without unpairing anyone. Paused means we do not even + collect mail, so questions wait in the relay's mailbox (7 days) instead + of being consumed and dropped.""" + self._state.paused = bool(paused) + self._save() diff --git a/src/session_recall/share/worker.py b/src/session_recall/share/worker.py index 7454fbb..e6f3850 100644 --- a/src/session_recall/share/worker.py +++ b/src/session_recall/share/worker.py @@ -8,8 +8,9 @@ the send process's job after `/ok` (next PR); default-deny is structural. Two more gate invariants live here: -- scope: only anchors from `share allow`-ed projects may enter a candidate - (default deny — empty allow-list means every answer comes back empty); +- scope: only anchors from projects THIS peer may see enter a candidate + (their own grants plus the `--to all` bucket; default deny — no grants + means every answer comes back empty); - the worker leaves no transcripts, so nothing it processes can re-enter the index and become a stored injection later. @@ -111,7 +112,7 @@ def build_candidate(incoming: Incoming, searcher: Searcher, composed = False if not chunks: - text = ("(nothing found within shareable scope — " + text = ("(nothing found within what this contact may see — " "see `session-recall share allow`)") else: written = composer(body, chunks, turns) if composer else None @@ -129,7 +130,7 @@ def build_candidate(incoming: Incoming, searcher: Searcher, findings.append({**asdict(f), "in": "source"}) cand = Candidate( - id=secrets.token_hex(4), peer_name=incoming.peer.name, + id=secrets.token_hex(4), peer_name=incoming.peer.label, peer_address=incoming.peer.address, question=body["question"], task=body["task"], problem=body["problem"], reply_nonce=incoming.nonce, created_at=time.time(), text=text, @@ -142,27 +143,37 @@ def build_candidate(incoming: Incoming, searcher: Searcher, def poll_once(identity: Identity, trust: TrustStore, state: ShareState, transport, searcher: Searcher, share_dir: Path, composer=None) -> list[Candidate]: - """One inbox sweep. Invalid envelopes vanish inside open_incoming (silent - drop); valid requests become pending candidates on disk.""" + """One sweep over every inbox we listen on (one per pairing plus the + legacy identity address). Invalid envelopes vanish inside open_incoming + (silent drop); valid requests become pending candidates on disk. + + Paused means paused *before* the fetch: consuming mail is destructive, so a + paused owner leaves questions parked in the relay (its TTL applies) instead + of eating and dropping them.""" + if trust.paused: + return [] out = [] - for raw in transport.fetch_mail(identity.address): - incoming = open_incoming(identity, trust, state, raw) - if incoming is None or incoming.kind != "req": - continue - thread_id = str(incoming.body.get("thread", ""))[:32] or thread_mod.new_id() - convo = thread_mod.open_or_create(share_dir, thread_id, - incoming.peer.address, incoming.peer.name) - if convo.closed or convo.should_close(): - convo.closed = True # a thread this old is a standing channel + for inbox in trust.inbox_addresses(identity): + for raw in transport.fetch_mail(inbox): + incoming = open_incoming(identity, trust, state, raw) + if incoming is None or incoming.kind != "req": + continue + thread_id = str(incoming.body.get("thread", ""))[:32] or thread_mod.new_id() + convo = thread_mod.open_or_create(share_dir, thread_id, + incoming.peer.address, + incoming.peer.label) + if convo.closed or convo.should_close(): + convo.closed = True # a thread this old is a standing channel + thread_mod.save(share_dir, convo) + continue + cand = build_candidate(incoming, searcher, + trust.projects_for(incoming.peer), + composer=composer, turns=convo.context()) + cand.thread = thread_id + convo.append("peer", cand.question, kind="question") thread_mod.save(share_dir, convo) - continue - cand = build_candidate(incoming, searcher, trust.allowed_projects(), - composer=composer, turns=convo.context()) - cand.thread = thread_id - convo.append("peer", cand.question, kind="question") - thread_mod.save(share_dir, convo) - _write_candidate(share_dir, cand) - out.append(cand) + _write_candidate(share_dir, cand) + out.append(cand) return out diff --git a/tests/test_share_cli.py b/tests/test_share_cli.py index e261748..0dcb2ab 100644 --- a/tests/test_share_cli.py +++ b/tests/test_share_cli.py @@ -32,22 +32,27 @@ def test_full_ceremony(homes, capsys): assert _run(["share", "init", "egor"]) == 0 assert _run(["share", "join", code]) == 0 sas_egor = capsys.readouterr().out - assert _run(["share", "trust"]) == 0 + # enrolling without choosing a name must not work — the bundle's name is + # the peer's own claim + assert _run(["share", "trust"]) == 1 + assert "name you choose" in capsys.readouterr().out + assert _run(["share", "trust", "maxim-lead"]) == 0 homes("maxim") assert _run(["share", "complete"]) == 0 sas_maxim = capsys.readouterr().out - assert _run(["share", "trust"]) == 0 + assert _run(["share", "trust", "egor-work"]) == 0 sas = [l for l in sas_egor.splitlines() if "SAS code:" in l] assert sas and sas == [l for l in sas_maxim.splitlines() if "SAS code:" in l] assert _run(["share", "devices"]) == 0 - assert "egor" in capsys.readouterr().out + out = capsys.readouterr().out + assert "egor-work" in out and '"egor"' in out # petname + their claim homes("egor") assert _run(["share", "devices"]) == 0 - assert "maxim" in capsys.readouterr().out + assert "maxim-lead" in capsys.readouterr().out def test_join_with_bad_code_fails_cleanly(homes, capsys): @@ -60,7 +65,7 @@ def test_join_with_bad_code_fails_cleanly(homes, capsys): def test_trust_requires_pairing(homes, capsys): homes("maxim") _run(["share", "init", "maxim"]) - assert _run(["share", "trust"]) == 1 + assert _run(["share", "trust", "egor"]) == 1 assert "nothing to trust" in capsys.readouterr().out @@ -70,14 +75,28 @@ def test_revoke_and_allow(homes, capsys): assert _run(["share", "allow"]) == 0 assert "default deny" in capsys.readouterr().out - assert _run(["share", "allow", "session-recall"]) == 0 + # granting without saying who must not work — opening a project to every + # contact has to be typed out, never the accident of a forgotten flag + assert _run(["share", "allow", "session-recall"]) == 1 + assert "say who" in capsys.readouterr().out + assert _run(["share", "allow", "session-recall", "--to", "all"]) == 0 assert _run(["share", "allow"]) == 0 assert "session-recall" in capsys.readouterr().out - assert _run(["share", "allow", "session-recall", "--remove"]) == 0 + assert _run(["share", "allow", "session-recall", "--to", "all", "--remove"]) == 0 + assert _run(["share", "allow", "x", "--to", "ghost"]) == 1 # unknown contact assert _run(["share", "revoke", "nobody"]) == 1 +def test_pause_and_resume(homes, capsys): + homes("maxim") + _run(["share", "init", "maxim"]) + assert _run(["share", "pause"]) == 0 + assert "paused" in capsys.readouterr().out + assert _run(["share", "resume"]) == 0 + assert "resumed" in capsys.readouterr().out + + def test_commands_without_identity_point_to_init(homes, capsys): homes("maxim") assert _run(["share", "devices"]) == 1 diff --git a/tests/test_share_contacts.py b/tests/test_share_contacts.py new file mode 100644 index 0000000..189bda2 --- /dev/null +++ b/tests/test_share_contacts.py @@ -0,0 +1,219 @@ +"""The contacts layer: petnames the owner chose, scope per contact, one inbox +per pairing, and the pause switch. + +Why each exists — petname: the name in a bundle is the peer's own claim, so a +second contact could introduce themselves as the first; the owner's petname is +what every preview and prompt refers to. Per-contact scope: what one colleague +should know is not what another should. Per-pair inboxes: the relay sees +unrelated islands instead of a social graph, and revoking closes the channel +itself. Pause: a brake the owner can pull without unpairing anyone.""" + +from dataclasses import dataclass + +import pytest + +from session_recall.share import identity as identity_mod +from session_recall.share import pairing +from session_recall.share.envelope import ShareState, make_request, open_incoming +from session_recall.share.transport import InMemoryTransport +from session_recall.share.trust import Peer, TrustStore +from session_recall.share.worker import poll_once + + +@dataclass +class FakeAnchor: + session_id: str = "sess-1234567890" + uuid: str = "u1" + role: str = "assistant" + snippet: str = "how we fixed it" + score: float = 0.9 + project: str = "alpha" + when: int = 1785000000 + source: str = "claude" + + +def _enroll(trust: TrustStore, ident, petname: str, local_address: str = "") -> Peer: + b = ident.public_bundle() + peer = Peer(name=b["name"], address=b["address"], sign_pk=b["sign_pk"], + box_pk=b["box_pk"], petname=petname, local_address=local_address) + trust.add(peer) + return peer + + +@pytest.fixture +def world(tmp_path): + maxim = identity_mod.create(tmp_path / "maxim", "maxim") + egor = identity_mod.create(tmp_path / "egor", "egor") + lena = identity_mod.create(tmp_path / "lena", "lena") + trust = TrustStore(tmp_path / "maxim" / "trust.json") + return {"maxim": maxim, "egor": egor, "lena": lena, "trust": trust, + "state": ShareState(tmp_path / "maxim" / "state.json"), + "transport": InMemoryTransport(), "mdir": tmp_path / "maxim"} + + +# -- petnames ----------------------------------------------------------------- +def test_petname_is_what_everything_refers_to(world): + """The peer introduces themselves as 'maxim' — the OWNER's name for them is + what the candidate (and so every preview) carries.""" + impostor = identity_mod.create(world["mdir"].parent / "i", "maxim") + _enroll(world["trust"], impostor, "egor-work") + world["trust"].allow_project("alpha") + world["transport"].post_mail(world["maxim"].address, make_request( + impostor, world["maxim"].public_bundle(), "q?")) + cand = poll_once(world["maxim"], world["trust"], world["state"], + world["transport"], lambda q, k: [FakeAnchor()], + world["mdir"])[0] + assert cand.peer_name == "egor-work" # not the claimed "maxim" + assert world["trust"].get("egor-work").name == "maxim" + + +def test_duplicate_petname_rejected(world): + _enroll(world["trust"], world["egor"], "egor") + with pytest.raises(ValueError, match="unique"): + _enroll(world["trust"], world["lena"], "egor") + + +def test_get_prefers_petname_over_claimed_name(world): + """lena's petname is egor's claimed name — lookups must never cross.""" + egor = _enroll(world["trust"], world["egor"], "lena") + lena = _enroll(world["trust"], world["lena"], "kate") + assert world["trust"].get("lena").address == egor.address + assert world["trust"].get("kate").address == lena.address + + +# -- per-contact scope -------------------------------------------------------- +def test_scope_is_per_contact(world): + """egor may see alpha, lena may see beta; neither sees the other's.""" + egor = _enroll(world["trust"], world["egor"], "egor") + lena = _enroll(world["trust"], world["lena"], "lena") + world["trust"].allow_project("alpha", egor) + world["trust"].allow_project("beta", lena) + anchors = [FakeAnchor(project="alpha"), FakeAnchor(project="beta")] + + for ident, expect in ((world["egor"], "alpha"), (world["lena"], "beta")): + world["transport"].post_mail(world["maxim"].address, make_request( + ident, world["maxim"].public_bundle(), "q?")) + cand = poll_once(world["maxim"], world["trust"], world["state"], + world["transport"], lambda q, k: anchors, + world["mdir"])[0] + assert [c["project"] for c in cand.chunks] == [expect] + + +def test_all_bucket_reaches_every_contact(world): + egor = _enroll(world["trust"], world["egor"], "egor") + world["trust"].allow_project("common") # peer=None → the all bucket + assert world["trust"].projects_for(egor) == ["common"] + + +def test_no_grants_means_nothing(world): + egor = _enroll(world["trust"], world["egor"], "egor") + assert world["trust"].projects_for(egor) == [] + assert world["trust"].projects_for(None) == [] + + +# -- pause -------------------------------------------------------------------- +def test_pause_leaves_mail_at_the_relay(world): + """Paused must mean not-even-fetched: consuming mail is destructive, so the + question has to survive the pause and be answered after resume.""" + _enroll(world["trust"], world["egor"], "egor") + world["trust"].allow_project("alpha") + world["transport"].post_mail(world["maxim"].address, make_request( + world["egor"], world["maxim"].public_bundle(), "q?")) + + world["trust"].set_paused(True) + assert poll_once(world["maxim"], world["trust"], world["state"], + world["transport"], lambda q, k: [FakeAnchor()], + world["mdir"]) == [] + assert world["transport"].mail # still parked, not consumed + + world["trust"].set_paused(False) + cands = poll_once(world["maxim"], world["trust"], world["state"], + world["transport"], lambda q, k: [FakeAnchor()], + world["mdir"]) + assert len(cands) == 1 + + +def test_pause_survives_reload(world): + world["trust"].set_paused(True) + assert TrustStore(world["trust"].path).paused is True + + +# -- per-pair inboxes --------------------------------------------------------- +def _pair(tmp_path): + """Full ceremony; both sides enroll with petnames. Returns everything the + round-trip tests need.""" + a = identity_mod.create(tmp_path / "a", "maxim") + b = identity_mod.create(tmp_path / "b", "egor") + t = InMemoryTransport() + code = pairing.start_invite(a, t, tmp_path / "a") + pairing.join(b, t, tmp_path / "b", code) + pairing.complete_invite(a, t, tmp_path / "a") + + def enroll(trust_path, own_dir, petname): + cand = pairing.pending_peer(own_dir) + trust = TrustStore(trust_path) + bb = cand["bundle"] + trust.add(Peer(name=bb["name"], address=bb["address"], + sign_pk=bb["sign_pk"], box_pk=bb["box_pk"], + petname=petname, + local_address=cand.get("local_address", ""))) + return trust + + at = enroll(tmp_path / "a" / "trust.json", tmp_path / "a", "egor") + bt = enroll(tmp_path / "b" / "trust.json", tmp_path / "b", "maxim") + return a, b, at, bt, t + + +def test_pairing_mints_fresh_addresses_per_pair(tmp_path): + a, b, at, bt, _ = _pair(tmp_path) + a_for_b = at.peers()[0].local_address # where a listens for b + b_for_a = bt.peers()[0].local_address # where b listens for a + minted = {a_for_b, b_for_a} + assert all(minted) + # neither side's identity address is on the wire for this pair + assert not minted & {a.address, b.address} + # and the addresses cross-reference: what a listens on is where b sends + assert bt.peers()[0].address == a_for_b + assert at.peers()[0].address == b_for_a + + +def test_roundtrip_over_pair_addresses(tmp_path): + a, b, at, bt, t = _pair(tmp_path) + peer_a = bt.peers()[0] # b's record of a + t.post_mail(peer_a.address, make_request(b, peer_a, "q?")) + # mail sits in the pair inbox, not the identity inbox + assert peer_a.address in t.mail and a.address not in t.mail + state = ShareState(tmp_path / "a" / "state.json") + cands = poll_once(a, at, state, t, lambda q, k: [], tmp_path / "a") + assert len(cands) == 1 and cands[0].peer_name == "egor" + + +def test_envelope_to_identity_inbox_rejected_when_pair_inbox_exists(tmp_path): + """A valid-crypto envelope aimed at the identity address must die once the + pair has its own inbox — cross-inbox delivery is a misroute by definition.""" + a, b, at, bt, t = _pair(tmp_path) + peer_a = bt.peers()[0] + forged = Peer(**{**peer_a.__dict__, "address": a.address}) + raw = make_request(b, forged, "q?") + state = ShareState(tmp_path / "a" / "state.json") + assert open_incoming(a, at, state, raw) is None + + +def test_revoke_stops_listening_on_their_inbox(tmp_path): + a, _, at, _, _ = _pair(tmp_path) + inbox = at.peers()[0].local_address + assert inbox in at.inbox_addresses(a) + at.revoke("egor") + assert inbox not in at.inbox_addresses(a) + + +def test_legacy_peer_without_pair_inbox_still_works(world): + """Pre-contacts stores have no local_address — the identity inbox stays a + working fallback so an upgrade breaks no existing pairing.""" + _enroll(world["trust"], world["egor"], "egor") # local_address="" + assert world["trust"].inbox_addresses(world["maxim"]) == [world["maxim"].address] + world["transport"].post_mail(world["maxim"].address, make_request( + world["egor"], world["maxim"].public_bundle(), "q?")) + cands = poll_once(world["maxim"], world["trust"], world["state"], + world["transport"], lambda q, k: [], world["mdir"]) + assert len(cands) == 1 diff --git a/tests/test_share_relay.py b/tests/test_share_relay.py index d476d77..eb1d30d 100644 --- a/tests/test_share_relay.py +++ b/tests/test_share_relay.py @@ -112,7 +112,10 @@ def test_slot_ttl(server): def test_full_ceremony_and_request_over_http(server, users, tmp_path): - """The whole v1 loop over a live relay: pair, trust, ask, gate.""" + """The whole v1 loop over a live relay: pair, trust, ask, gate. Both sides + enroll from their pending-peer files exactly as the CLI does, and the mail + travels over the pair's minted inbox — the identity address never appears + on the wire.""" url, _ = server maxim, egor = users tm = HttpRelayTransport(url, identity=maxim) @@ -124,15 +127,24 @@ def test_full_ceremony_and_request_over_http(server, users, tmp_path): completed = pairing.complete_invite(maxim, tm, mdir) assert joined.sas == completed.sas - maxim_trust = TrustStore(mdir / "trust.json") - b = joined.bundle # egor trusts maxim's bundle; maxim trusts egor's - maxim_trust.add(Peer(name=completed.bundle["name"], - address=completed.bundle["address"], - sign_pk=completed.bundle["sign_pk"], - box_pk=completed.bundle["box_pk"])) - - te.post_mail(b["address"], make_request(egor, b, "how did you fix CI?")) - inbox = tm.fetch_mail(maxim.address) + def enroll(own_dir, petname): + pend = pairing.pending_peer(own_dir) + trust = TrustStore(own_dir / "trust.json") + bb = pend["bundle"] + trust.add(Peer(name=bb["name"], address=bb["address"], + sign_pk=bb["sign_pk"], box_pk=bb["box_pk"], + petname=petname, + local_address=pend.get("local_address", ""))) + return trust + + maxim_trust, egor_trust = enroll(mdir, "egor"), enroll(edir, "maxim") + + peer_maxim = egor_trust.peers()[0] # egor's record of maxim + te.post_mail(peer_maxim.address, + make_request(egor, peer_maxim, "how did you fix CI?")) + pair_inbox = maxim_trust.peers()[0].local_address + assert pair_inbox == peer_maxim.address != maxim.address # the pair's island + inbox = tm.fetch_mail(pair_inbox) assert len(inbox) == 1 got = open_incoming(maxim, maxim_trust, ShareState(mdir / "state.json"), inbox[0]) assert got is not None and got.body["question"] == "how did you fix CI?"