# ADR-007: Asynchronous friend-request / accept (single-use invite, X3DH-style prekey)

## Status

Accepted

## Date

2026-06-01

## Context

The pairwise root `R` (ADR-006) and a pinned identity were so far established only by the **synchronous**
authenticated handshake (`handshake.rs`) — both parties online at once. Real onboarding must work when
the person you're adding is **offline**: you scan their invite / paste their link now, and the exchange
completes whenever each side next connects. This is the classic async key-agreement problem (Signal's
X3DH, Tox/Briar friend requests).

Constraints: reuse only audited primitives (no new crypto — `coding-standards.md`); keep the hybrid PQ
guarantee (X25519 + ML-KEM-768); authenticate identities and enable out-of-band MITM detection (SAS);
ride the existing **oblivious relay** for transport; keep the creator's retained secret small.

## Decision

A single-use **invite** carrying a signed one-time **prekey**, completed asynchronously:

1. **Create** (`create_invite_pair`): the creator generates an ephemeral X25519 keypair + an ML-KEM-768
   keypair, **signs** `(id ‖ xpk ‖ ek)` with its Ed25519 identity, and emits a public `Invite`
   (`id, xpk, ek, prekey_sig, rendezvous`) plus an `InviteSecret` it retains. The retained secret is
   small: the 32-byte X25519 secret + the **64-byte ML-KEM seed** (not the 2400-byte expanded key —
   `pqkem::decapsulation_key_to_bytes` exports the seed) + the rendezvous secret.
2. **Request** (`request_friend`): the requester verifies the prekey signature, does
   `X25519(eph, xpk)` + `ML-KEM.encapsulate(ek)`, derives `K = hybrid_session_key(…, transcript)` and
   `R = pairwise_root(K)` (same combiner + root derivation as the sync handshake), and **signs the
   transcript**. Emits a `FriendRequest` (`id, xpk, ct, sig`).
3. **Accept** (`accept_friend`): the creator, later, reconstructs its prekey from `InviteSecret`,
   completes `X25519` + `ML-KEM.decapsulate`, **verifies the requester's signature**, derives the
   **same** `K`/`R`, pins the requester, and signs an `Accept`.
4. **Confirm** (`verify_accept`): the requester verifies the creator's signature → `R` confirmed,
   pin the creator.

The **transcript** binds both identities and every public value (`id_a ‖ id_b ‖ xpk_a ‖ xpk_b ‖ ek ‖ ct`),
role-separated for the two signatures. Both sides derive the identical `K`, so `safety_emoji(K)` is the
**SAS** to compare out-of-band. Transport (sealing the request/accept under a rendezvous-derived key and
depositing under a rendezvous token on the relay) is a separate layer, mirroring the directory records.

## Consequences

**Positive**
- Add an **offline** contact; `R` ends up identical to a sync handshake's, so directory records
  (ADR-006) and everything downstream "just work" for invited contacts.
- Hybrid PQ preserved; authentication by identity signature over a full transcript; SAS for MITM
  detection. Single-use invite limits exposure of the prekey/rendezvous.
- Tiny retained secret (X25519 secret + 64-byte ML-KEM seed) — cheap to persist sealed at rest.
- No new primitive: X25519, ML-KEM-768, Ed25519, HKDF, ChaCha20-Poly1305 only.

**Negative / trade-offs**
- The prekey is **one-time**: an invite is consumed by one successful request (re-use would reuse the
  ML-KEM prekey — discouraged). Multiple contacts ⇒ multiple invites (or a future signed-prekey + many
  one-time-prekeys bundle, as in X3DH).
- No forward secrecy *for the invite itself* before the ratchet starts (standard for X3DH prekeys);
  the Double Ratchet provides FS once messaging begins.
- Authentication is signature-based (not a long-term static DH); consistent with `handshake.rs`. A
  stolen identity key forges requests/accepts — same trust boundary as the rest of the system.

## Alternatives considered

- **Sync handshake only** — rejected: can't add offline peers; forces both online simultaneously.
- **Full X3DH with long-term + signed + one-time prekeys** — more robust (reusable signed prekey, FS
  options) but heavier; the single-use invite is the minimal correct first step and can grow into a
  full prekey bundle without changing the root derivation.
- **Persist the expanded 2400-byte ML-KEM key** — rejected: the 64-byte seed reconstructs it exactly,
  so we persist the seed.

## Future work

- Transport layer: seal + deposit the request/accept on the relay under rendezvous-derived token/key;
  CLI (`invite` / `accept`) + GUI; persist `InviteSecret` sealed at rest; pin contact + store `R`.
- Key rotation / "reset" migration record (signed "my new key is X" continuity), per ADR-006.
- Reusable signed-prekey + one-time-prekey bundle if many async adds per invite are needed.
