# ADR-006: Directory records (connect by identity — signed, per-contact-encrypted, blinded)

## Status

Accepted

## Date

2026-05-31

## Context

Users must not have to paste an address every time. A contact should reach you by your **identity**,
with the app resolving your *current* address (stable `.onion` for messaging; optional direct IP for
calls, ADR-004) automatically — including after your address rotates or your IP changes.

This requires publishing an **address record** somewhere a contact can find it. The hard constraint:
do it **without** giving the relay (or any other contact) a way to read your address, link your
records over time, or tie them to your identity — i.e. without weakening the obliviousness the relay
guarantees (ADR-005). A naive "publish my onion to a directory keyed by my identity" leaks the
identity↔address mapping to the relay and the world.

## Decision

A **directory record** (`pvtcoms_core::directory`) is a signed, per-contact-encrypted address record
deposited on the oblivious relay under a blinded, rotating token. It is built on the **pairwise root**
`R` two contacts establish at first contact (handshake / invite).

**Three independent values, derived from `R` by distinct labels (key separation):**
- **lookup token** `dir_token(R, publisher_id, epoch) = HMAC(R, "dir-loc" ‖ publisher_id ‖ epoch)`
- **record key** `dir_key(R, publisher_id) = HMAC(R, "dir-key" ‖ publisher_id)`
- **message keys** — the ratchet/queue secrets, a *separate* derivation entirely.

Knowing one reveals nothing about the others. Derivations are **directional** (bound to
`publisher_id`), so A→B and B→A never collide, and each is **per-contact** (bound to the pairwise `R`),
so only the intended contact can compute the token, decrypt the record, or even locate it.

**Record** = `{ identity, seq, expires_at, addresses[] }`, canonically serialized (bounded parser),
**signed** by the publisher's Ed25519 identity (`SIGN_CTX ‖ bytes`), then **sealed** with
ChaCha20-Poly1305 under `dir_key`. The wire blob is `nonce ‖ ciphertext`, deposited under `dir_token`.

**Anti-linkability:** the token rotates per epoch, and the AEAD nonce is an **epoch+seq-bound
synthetic nonce** (`HMAC(dir_key, "dir-nonce" ‖ epoch ‖ seq)`), so re-publishing unchanged content in
a new epoch yields *different* ciphertext — the relay cannot link a record across epochs. Never a
caller-random nonce (per `coding-standards.md`), and unique per (epoch, seq) so no reuse.

**Anti-rollback / freshness:** the reader keeps the highest `seq` seen per contact and rejects older
records; `expires_at` bounds staleness. The reader verifies the signature against the **pinned**
contact identity and checks the record claims that same identity.

## Consequences

**Positive**
- Connect by identity; address rotation / dynamic IP propagate automatically to contacts.
- Relay stays oblivious: opaque token + ciphertext, unlinkable across epochs, no identity exposure.
- Reuses primitives only (HMAC, Ed25519, ChaCha20-Poly1305) — no new crypto.
- Each address is encrypted **per contact**, so the "address key differs from message keys, and other
  contacts can't read it" requirement the user asked for is structural.

**Negative / trade-offs**
- One record must be published **per contact** (N copies, each under that contact's `R`). Fine for
  realistic contact counts; cost is O(contacts) re-publishes on address change.
- Requires a **pairwise root `R`** to exist — establishing it from the authenticated handshake and
  storing it per contact is a follow-up slice (this ADR covers the record engine, which takes `R` as
  input).
- A contact who is *removed* still holds `R` and can keep resolving your address until you **rotate**
  (publish under a fresh root / identity). Consistent with ADR-005's revocation note.

## Alternatives considered

- **Global directory keyed by identity** (publish onion under `H(identity)`) — rejected: hands the
  relay the identity↔address map; trivially linkable.
- **Single record encrypted to all contacts** (one blob, multi-recipient) — leaks the contact-set size
  and couples rotation; per-contact records keep each pair independent and unlinkable.
- **Unsigned records** — rejected: a malicious relay could substitute an address (MITM the *next*
  connection). Signing by the pinned identity makes records self-certifying.

## Future work

- Establish `R` from the hybrid handshake (HKDF of the authenticated shared secret) and persist it per
  contact (`contacts.rs`).
- Wire publish/lookup into the relay client + the app (GUI "connect by identity").
- The friend-request / accept (consent) flow that bootstraps `R`, and the key-rotation / "reset"
  migration record.
