Decisions
ADR-0018: Rotating pairwise rendezvous for post-pairing reachability
- Status: Accepted; implemented for Beta
- Date: 2026-07-15
Context
Komms currently publishes signed prekey bundles under H(IK) in the Kademlia
DHT. That path is necessary for first contact by kult address and remains
self-authenticating. A public bundle may contain recipient-selected
mailbox/relay introduction paths, but ADR-0017 no longer permits Standard or
Private mode to publish a current direct IP route under the stable account
lookup by default. Sovereign users may make that explicit tradeoff. Once two
peers have an authenticated session, they can discover each other's changing
internet routes through a pairwise capability that a public-key scraper cannot
calculate.
A naive fixed slot H(shared_secret || "locator") is insufficient. It remains
linkable for the life of the relationship, lets the service correlate repeated
online periods, underspecifies key extraction, and gives no replay or downgrade
rules. A GET query also places the capability in URLs and common log paths.
The design must not reuse the existing daily delivery token: doing so would
link rendezvous activity to mailbox deposits and on-wire envelope tokens.
The service is not a mailbox. It stores no messages, prekey bundles, identity records, notification tokens, or contact data. It only returns a short-lived, fixed-size encrypted route record for an already paired direction.
Decision
1. Rendezvous is post-pairing and direction-scoped
The DHT, direct QR/file exchange, and mesh announcements remain the ways to obtain and authenticate an initial prekey bundle. Rendezvous capability material is derived only after a verified PQXDH session exists and is never placed in a public prekey bundle or kult address.
During session establishment kult-crypto derives a 32-byte
hybrid_service_exporter alongside, but not from, the mailbox key:
hybrid_service_exporter = HKDF-SHA-256(
salt = handshake_transcript_hash,
ikm = initial_root_key,
info = "Komms-Hybrid-Service-Exporter-v1"
)All expansions use the existing HKDF-SHA-256 primitive and the exact labels above; bare hash concatenation is not an interoperable substitute.
The exporter is stored as separately sealed service state, excluded from KKR backups, and deleted with the contact/session. Restore or identity migration requires a fresh authenticated handshake and exporter. A legacy session has no implicit exporter; enabling rendezvous for it uses the existing authenticated session to negotiate a re-handshake rather than deriving from an unauthenticated or one-sided value.
The device first-flight wrapper is versioned as KDI2 and carries one of three
session intents:
Establishhas no predecessor. If both devices establish concurrently, the lower immutable physical-device id's locally initiated flight wins. The retaining side still authenticates and persists the other first payload and returns its end-to-end acknowledgement over the winning ratchet; the other side installs that same winning ratchet. Queued ciphertext bound to the losing ratchet is deleted in that installation transaction, and each exact nonterminal device-delivery promise returns toQueuedwith no wire id so a later bounded pass encrypts it on the winner. This is session convergence, never an account-authority or manifest fork tiebreaker.Replace(prior_session_id)is accepted only when the exact transcript-bound predecessor is still current. A stale, reordered, or replayed replacement fails closed.Resetidentifies a sender-side durable session-reset/recovery path and installs only after the ordinary device certificate and accepted authority chain verify.
Released KDI1 flights remain decodable as an explicit legacy intent. They do
not gain a synthetic exporter or predecessor binding.
For each recipient direction and rendezvous provider:
provider_id = SHA-256(canonical_provider_origin || provider_static_key)
K_locator = HKDF-SHA-256(
salt = provider_id,
ikm = hybrid_service_exporter,
info = "Komms-Rendezvous-Locator-v1" || IK_recipient
)
K_payload = HKDF-SHA-256(
salt = provider_id,
ikm = hybrid_service_exporter,
info = "Komms-Rendezvous-Payload-v1" || IK_recipient
)
slot(epoch) = HMAC-SHA-256(
K_locator,
"Komms-Rendezvous-Slot-v1" || u64_be(epoch)
)
E_epoch = HKDF-SHA-256(
salt = u64_be(epoch),
ikm = K_payload,
info = "Komms-Rendezvous-Epoch-Key-v1"
)An epoch is 3,600 Unix seconds. Registrations expire no later than two hours after receipt. Clients may register and query the current and next epoch and may query the immediately previous epoch for clock skew; no other window is valid. Provider-specific derivation prevents two operators from comparing slot values. Direction scoping prevents one peer's two receive directions from sharing a slot sequence.
Both paired endpoints can calculate a direction's slot and AEAD key. A malicious contact can therefore publish a valid value only into the unique slot used for its own view of that recipient; it cannot poison another contact's slot. This per-contact denial is accepted and ends when the contact is removed and the session exporter is discarded. Adding server-visible publisher identities or stable write keys would make epoch rotation linkable and is rejected for v1.
2. Route records are canonical, bounded, padded, and replay-resistant
The plaintext route record uses a fixed binary encoding with big-endian integers:
version(1) = 01
flags(1) = 00
epoch(8)
generation(8)
issued_at(8)
expires_at(8)
route_count(1) = 0..8
repeated route_count times:
kind(1) = 01 multiaddr | 02 mailbox relay
value_len(2) = 1..512
value(value_len)
zero padding to exactly 4,096 bytesUnused bytes must be zero and the complete plaintext is always 4,096 bytes.
Routes use the existing canonical DeliveryHint interpretation. Duplicate
routes, invalid UTF-8, embedded NUL, unsupported kinds, invalid multiaddresses,
more than eight routes, or trailing non-zero data fail closed. A route record
contains no public Komms identity, petname, group information, push capability,
or message state.
generation is a strictly increasing per (contact, direction, provider)
counter held in sealed core service state. epoch must match the queried slot,
issued_at <= expires_at, lifetime is at most 7,200 seconds, and a client
rejects an authenticated generation lower than the greatest it has accepted.
Generation state expires only when the corresponding epoch can no longer be
served. Wall-clock rollback never revives an expired accepted record.
The record is sealed with XChaCha20-Poly1305 under E_epoch and a fresh random
24-byte nonce. Associated data is:
"Komms-Rendezvous-Record-v1" || provider_id || slot || u64_be(epoch)The wire payload is exactly nonce(24) || ciphertext(4,096 + 16). Nonce reuse
under one epoch key is forbidden. The service cannot forge or modify a record;
it can replay, replace, suppress, or return random bytes, all of which the
client handles through AEAD, generation, epoch, and expiry validation.
3. The HTTP surface does not reveal hit/miss through shape
The normative media type is application/komms-rendezvous-v1; JSON is not a
production wire format. TLS terminates in the rendezvous process, not a general
reverse proxy that logs bodies. Capabilities never appear in a URL.
POST /v1/rendezvous/register
request = slot(32) || epoch(8) || ttl_seconds(4) || sealed_record(4,136)
response = fixed 64-byte acknowledgement body
POST /v1/rendezvous/lookup
request = slot(32) || epoch(8) || zero_pad(24)
response = sealed_record(4,136)Every syntactically valid lookup returns HTTP 200 and exactly 4,136 bytes. A miss returns fresh random bytes. The client alone distinguishes a valid record by AEAD and semantic validation. Register responses have one fixed shape whether the value was inserted, replaced, capped, or rejected by local capacity policy; clients confirm success only by a subsequent valid lookup and never treat a registration acknowledgement as reachability.
Malformed length/version requests fail before allocation with a uniform 400
body. Responses use Cache-Control: no-store. Compression, redirects, cookies,
authentication headers, request IDs reflected to the client, and third-party
scripts are forbidden.
4. Server storage and abuse controls are bounded on every axis
The primary key is the 32-byte slot; the value is the fixed sealed record plus an absolute server receipt expiry. Storage is a fixed-capacity in-process map or equivalently constrained RAM store with persistence, replication, snapshots, append-only logs, swap, hibernation, and core dumps disabled. TTL is capped at 7,200 seconds and cannot be extended by a lookup. Replacement never increases the number of records.
The service enforces global concurrent-request, record-count, memory, bandwidth,
per-slot operation, and body-size ceilings before work. Network rate limits are
adaptive signals, not the sole authorization boundary: a fixed 60-per-minute
/24 or /48 policy is forbidden because carrier NATs, campuses, and Tor exits
would become shared denial domains. Direct and anonymized ingress may have
different admission policies. A bounded client puzzle or anonymous admission
token may be activated under load, but it cannot encode identity and requires a
versioned extension before becoming mandatory.
Clean shutdown attempts to zero map storage. This is defense in depth, not a claim that abrupt host seizure or termination leaves no recoverable bytes.
5. Clients query on demand, not once per F4 heartbeat per contact
kult-node retains delivery hints by source:
- manually supplied/out-of-band hints, until the user removes them;
- signed DHT bundle hints, until their signed expiry;
- rendezvous hints, until the authenticated route-record expiry; and
- LAN observations, until the existing LAN expiry.
One source never overwrites another. The node queries rendezvous only when a
peer has queued work without a fresh usable route, the user opens an active
conversation, call setup needs a fresh route, a native wake tells the recipient
to collect, or the current rendezvous record nears expiry while the app is
active. Queries use jitter, coalescing, exponential backoff, and a per-peer
single-flight guard. F4 probes the merged fresh hint set and remains advisory;
the rendezvous service never declares realtime, bulk, or delivery success.
Rendezvous configuration, exporters, generation counters, and leases are sealed core service state. The user's mode/provider preference may use F5, but network leases and pending operations must not be implemented as F5 UI metadata or B8 scheduled messages.
Alternatives considered
Static H(shared_secret || label) slots
Rejected. The service can link the same relationship and online pattern for its entire lifetime, and the construction lacks provider and direction separation.
Reuse daily mailbox delivery tokens
Rejected. Tokens are already visible in envelopes and mailbox registration. Reuse would join otherwise separate metadata surfaces and let a relay recognize rendezvous lookups for tokens it serves.
Publish encrypted routes under H(IK)
Rejected. Encryption hides route contents but preserves a globally enumerable, stable public-identity locator and lets observers track updates.
Return 404 for a missing slot with a fixed delay
Rejected. Status, body length, cache behavior, server scheduling, and network jitter still distinguish paths. An indistinguishable fixed-size dummy is simpler and does not deliberately hold resources for a timer.
Replace the DHT and initial invite path
Rejected. A pairwise slot does not exist before an authenticated relationship, and making a convenience provider the first-contact authority would violate ADR-0017.
Consequences
- Established contacts gain private, rapidly expiring route discovery without exposing a public identity lookup to the provider.
- Standard first contact normally reaches a recipient-selected mailbox/introduction path from the signed DHT bundle; rendezvous becomes available only after that first authenticated handshake.
- Each relationship costs multiple registrations across adjacent epochs; clients must stagger and coalesce work rather than burst every contact at launch.
- Fixed 4 KiB records trade bandwidth for bounded parsing and response-shape privacy. This path is internet-only and never rides an airtime transport.
Implemented Beta profile
The verified PQXDH transcript now derives the exporter exactly once beside the
mailbox key. Ratchet serialization deliberately omits it. kult-store keeps
the exporter, session id, provider roles, generations, clock floor, route
source, retry state, and conflict floors in a separate store-v2 row; routine
backup never encodes that row. Removing a session or device removes the
corresponding service authority. A restored or legacy ratchet without this row
cannot synthesize an exporter: an authenticated provider control may request a
fresh PQXDH exchange, and rendezvous remains disabled until both endpoints
complete that exchange.
kult-crypto implements the provider/recipient/epoch-separated schedule and
fixed XChaCha20-Poly1305 protection. kult-protocol owns the strict 4,096-byte
route record, 4,136-byte seal, 4,180-byte register request, 64-byte lookup
request, 64-byte register response, and fixed malformed response codecs.
Decoders reject invalid versions, lengths, bounds, route ordering, duplicate
routes, non-canonical routes, non-zero padding, and invalid time/generation
state without variable-size allocation.
The dedicated kult-rendezvous component is an in-memory, persistence-free
service boundary, not an identity-bearing endpoint or mailbox. It enforces
explicit record, accounted-memory, concurrency, global operation/byte,
per-slot, client-bucket, bucket-count, epoch, TTL, and bounded-expiry-sweep
limits. Valid hits, misses, capacity refusal, overload, and rate refusal retain
the same success status and body shape. A register acknowledgement is random
and becomes locally confirmed only after a self-lookup returns the exact
authenticated record. Mutable ciphertext and opaque admission keys are
zeroized on orderly teardown. The HTTPS/TLS process wrapper, image, and
deployment hardening remain the separate ADR-0034 reference-service work.
Recipient-selected provider sets are complete authenticated pairwise controls bound to the sender account, certified device, device-authority generation, and their own monotonic generation. The complete local set is sealed separately and survives restart; rollback and same-generation replacement fail closed. Two different authenticated complete remote sets at one generation disable all lookup roles, clear their routes, emit a visible conflict, and remain disabled across restart until a strictly newer set arrives. Likewise, two different valid route records at one generation clear that provider source and establish a durable conflict floor; ordering never chooses authority.
kult-node retains manual, authenticated discovery, LAN, and rendezvous routes
as independent sources. It registers current and next epochs and queries only
previous/current/next. Work begins only for queued content lacking a fresh
route, an explicit active-conversation request, direct-call setup, or
near-expiry active state. Per-device/provider single-flight keys, coalescing,
initial jitter, exponential backoff, an operation/time budget, and a
five-failure circuit breaker bound hostile or unavailable providers. RPC,
UniFFI, desktop, Android, and iOS expose only bounded refresh and foreground
conversation-state controls plus a visible conflict; no surface treats service
processing as registration, reachability, sent, or delivered.
Compact normative vector
The in-tree known-answer test fixes these inputs:
| Field | Value |
|---|---|
| Recipient identity seed | 64 bytes of 01 |
| Canonical provider origin | https://vector.example |
| Provider static key | 32 bytes of 02 |
| Hybrid service exporter | 32 bytes of 03 |
| Epoch | 42 |
| Plaintext | Komms vector v1! followed by zeroes to 4,096 bytes |
| Provider id | 9935516320b17996593eb230fc34e0937209e308feaaa7ebb91fe370c15118fd |
| Recipient Ed25519 key | 8a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5c |
| Recipient X25519 key | a4e09292b651c278b9772c569f5fa9bb13d906b46ab68c9df9dc2b4409f8a209 |
| Slot | b80ca6f4d326fefb8477f342f06c7bb16adbf8056d25ef88c2552bd39ffc87d6 |
| Nonce | 0b4b6e38ee282f373c44950b4f4942f2c41253afab011f1b |
| SHA-256 of the 4,136-byte seal | 43424dfcf7f0cf4c190cc88f52e1feff4139c1f252838198415f137082b2723e |
Beta implementation and local tests are not deployed-service, independent interoperability, independent security-review, hostile real-network, or physical-device evidence. Pinned direct TLS and loopback-Tor clients now follow the complete Standard/Private/Sovereign mode contract, but no qualified Tor or non-colluding OHTTP deployment has earned the Private claim. See Operating modes and provider configuration.
- A service still observes slot activity and connection metadata; Private mode reduces but does not eliminate correlation.
- Existing contacts require an authenticated exporter upgrade/re-handshake, and restore rotates all rendezvous state just as it rotates live session state.