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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions docs/decisions/2026-07-31-contacts-and-pair-addresses.md
Original file line number Diff line number Diff line change
@@ -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 <petname>` владелец сам называет контакта;
без имени enrollment не проходит. Всё дальнейшее (превью, кандидаты, треды,
`--to`) ссылается на petname, не на самоназвание пира. Petname уникален.
- **Scope на контакт**: `share allow <project> --to <petname>`. Бакет
`--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)
98 changes: 73 additions & 25 deletions src/session_recall/share/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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 <petname>")
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 <project> --to {petname}\n"
f"revoke anytime: session-recall share revoke {petname}")
return 0

if cmd == "devices":
Expand All @@ -376,30 +398,56 @@ 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":
peer = trust.revoke(args.peer)
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 <project>")
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 <project> --to <petname>")
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 <petname> (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
27 changes: 20 additions & 7 deletions src/session_recall/share/envelope.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))),
}
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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
Expand Down
Loading
Loading