ContactRequest
ContactRequest is the message type used for initial contact establishment between devices that are not yet in any shared MLS group. It handles the bootstrapping of new contacts and direct message groups.
Purpose
Section titled “Purpose”When two devices want to establish their first contact (e.g., via a QR code scan), they need to:
- Create a new MLS group for 1:1 chat
- Exchange identity information
- Store each other as contacts
ContactRequest handles this entire flow in a single message type.
ContactRequest Structure
Section titled “ContactRequest Structure”/// Contact request identifier (UUIDv7)pub struct RequestId(Uuidv7);
/// Initial ContactRequeststruct ContactRequest { // UUIDv7 for ordering and replay rejection request_id: RequestId,
// Device sending the request sender: DeviceId,
// Bounds how long this request is acceptable, and therefore how long // recipients must retain request_id for deduplication timestamp: u64,
// Their UserIdentity (for contact store) user_identity: UserIdentity,
// Their devices (each contains delivery address and keypackage server) // Recipient fetches KeyPackages from each device's keypackage_server devices: Vec<DevicePublicInfo>,
// Ed25519 over the canonical serialization of every preceding field. // ContactRequest has no MLS layer, and the outer HPKE seal authenticates // nothing, so this signature is the only proof of origin signature: Ed25519Signature,}Field Specification
Section titled “Field Specification”request_id- Wrapper around Uuidv7
- Unique identifier for this contact request
- Used for deduplication and ordering
sender- DeviceId of the device sending the request
- Authenticated by the sender’s device key
timestamp- Bounds how long this request is acceptable
- Tells recipients how long they need to retain this request_id for deduplication
user_identity- UserIdentity of the requester
- Stored in the recipient’s contact store
- See UserIdentity
devices- All devices controlled by this user
- Used to create the DirectMessageGroup with all their devices
- See DevicePublicInfo
signature- Ed25519 over the canonical serialization of every preceding field.
- ContactRequest has no MLS layer, and the outer HPKE seal authenticates nothing so this signature is the only proof of origin
Authentication and Replay Rejection
Section titled “Authentication and Replay Rejection”ContactRequest is the only message type that arrives without an MLS group behind it. Both protections that other messages inherit are therefore absent, and must be supplied by the message itself.
| Protection | How other messages get it | ContactRequest |
|---|---|---|
| Sender authentication | MLS credential in the ratchet tree | signature field |
| Replay rejection | MLS consumes and deletes per-generation keys | request_id deduplication |
The outer HPKE seal provides neither. Base mode involves no sender key, and a captured envelope decrypts correctly every time it is redelivered.
Verification Order
Section titled “Verification Order”Order matters here more than usual, because processing a ContactRequest is expensive: the recipient fetches a KeyPackage for every device in devices, and each fetch spends a FetchKeyPackage token. A replayed request is therefore an amplification attack where one captured envelope can be used to drain a victim’s token budget and the sender’s KeyPackage supply at N tokens per replay.
-
Check the timestamp window
Reject if
timestampis outsideCONTACT_REQUEST_WINDOW. Pure arithmetic, no state. -
Check
request_idagainst the dedup setReject if already seen. A hash lookup, and the cheapest way to kill a replay.
-
Verify the signature
Locate the entry in
deviceswhosedevice_idequalssender, confirmdevice_id == Blake3(public_key), then verifysignatureagainst that key. -
Record
request_idInsert into the dedup set only after the signature verifies, so an attacker cannot poison the cache with unsigned requests bearing predicted identifiers.
-
Fetch KeyPackages now
Every step above is local and free. Nothing that costs a token or a network round trip runs until the request is proven authentic and fresh.
Window and Retention
Section titled “Window and Retention”// Generous, because a ContactRequest may sit queued while the recipient is offline.// The window's job is bounding dedup storage, not freshnessconst CONTACT_REQUEST_WINDOW: Duration = Duration::days(7);- Recipients MUST reject requests whose
timestampis outside the window - Recipients MUST retain seen
request_idvalues for at least the window length - Retaining for exactly the window is sufficient: a replay older than that is rejected on timestamp alone, so dedup state can be discarded rather than kept indefinitely
Requests for an Existing Group
Section titled “Requests for an Existing Group”Because the DirectMessageGroup ID is deterministic, a replayed or duplicated request maps to a group that already exists.
- Recipients MUST NOT re-create or reset an existing DirectMessageGroup
- Recipients MUST NOT re-issue MLSWelcome to devices already in the group
- A request naming a device not yet in an existing group MAY be treated as a device addition
Contact Establishment Flow
Section titled “Contact Establishment Flow”Overview
Section titled “Overview”sequenceDiagram Alice ->> Relay Server: Upload KeyPackages to Server Alice ->> Bob: Scan QR / Exchange InfoPackage Alice ->> Bob: Create DirectMessageGroup and Send ContactRequest Bob ->> Relay Server: Fetch KeyPackage from server for each device in devices Bob ->> Alice: Receive MLSWelcome Alice --> Bob: Both in DM group, ready to chat
Detailed Steps
Section titled “Detailed Steps”-
KeyPackage Upload
Alice’s devices upload KeyPackages to their respective
keypackage_server:- Each device uploads 50-100 KeyPackages
- KeyPackages are stored under a derived handle, encrypted at rest so the server never learns which device they belong to
- The server URL for each device is stored in DevicePublicInfo
- See Blind KeyPackage Distribution
-
InfoPackage Exchange
Both devices exchange InfoPackages (via QR code, NFC, or link):
- Contains: UserIdentity, DevicePublicInfo array
- Does NOT contain KeyPackages (they’re on the respective servers)
- See InfoPackages
-
Create DirectMessageGroup
The requester creates a new MLS group:
- Group ID is deterministic: Blake3(sorted(user1_id, user2_id))
- Both users are founders
-
Send ContactRequest
Requester sends ContactRequest with:
- Their UserIdentity (for contact store)
- Their DevicePublicInfo array
-
Verify, then Fetch KeyPackages
Recipient receives ContactRequest and:
- Verifies timestamp,
request_idfreshness, and signature before anything else - Extracts device IDs from the
devicesfield - For each device, derives the handle and fetches a KeyPackage, spending one token per fetch
- Validates KeyPackage signatures and credentials
- Verifies timestamp,
-
Add Members and Send Welcome
Recipient:
- Creates the same DirectMessageGroup (same group ID)
- Stores the sender’s UserIdentity in contact store
- Adds sender’s devices to the group using the fetched KeyPackages
- Sends MLSWelcome to the new devices
-
Group Ready!
Both devices now have:
- A shared DirectMessageGroup
- Each other in their contact store
- Full encryption for 1:1 chat
Requirements
Section titled “Requirements”Security Requirements
Section titled “Security Requirements”-
Request Authentication:
- ContactRequest MUST carry a
signatureover all preceding fields - Recipients MUST verify
device_id == Blake3(public_key)for the sending device - Recipients MUST reject requests whose signature does not verify
- Recipients MUST NOT act on any field of an unverified request
- ContactRequest MUST carry a
-
Replay Rejection:
- Recipients MUST reject requests outside
CONTACT_REQUEST_WINDOW - Recipients MUST reject a
request_idalready seen within that window - Recipients MUST complete verification before fetching any KeyPackage
- Recipients MUST reject requests outside
-
Identity Verification:
- Clients SHOULD verify the UserIdentity matches what was received via InfoPackage
- Device IDs in ContactRequest MUST match UserIdentity’s device list
-
KeyPackage Validation:
- Fetched KeyPackages MUST be valid (not expired, not used)
- KeyPackage credential MUST match device_id
- Clients MUST remove used KeyPackages from storage
Privacy Requirements
Section titled “Privacy Requirements”- Contact Store:
- Contact information is stored locally only
- Not shared with servers
- Users can delete contacts at any time
NOT Protocol Responsibilities
Section titled “NOT Protocol Responsibilities”- How to exchange InfoPackages (QR, NFC, link, etc.)
- When to show contact requests to users
- How to display contacts in UI
- Contact list management
- Blocking/muting new contacts
Example ContactRequest
Section titled “Example ContactRequest”{ "request_id": "019e607a-9846-7483-8698-83fbc5b8130a", "sender": "474351448d59e658e6588814e230194bb6ce894183e77c57e0fa48f44f558da4", "timestamp": 1779735133, "user_identity": { "user_id": "a1b2c3d4e5f6...", "created_at": 1779735133, "default_persona": { "display_name": "Alice", "profile_picture": null, "bio": "Hello!", "pronouns": null }, "personas": {} }, "devices": [ { "device_id": "474351448d59e658e6588814e230194bb6ce894183e77c57e0fa48f44f558da4", "public_key": "base64encoded...", "contact_addresses": [ { "prefix": "LUJ5R34FQRVGFZSUVLLTJOJX", "server": "chat.example.com", "mailbox_public": "base64_x25519_public_key", "created_at": 1779734100, "active": true } ], "keypackage_server": "chat.example.com", "linked_at": 1779734100 }, { "device_id": "e0d9fedfc79241f387f6f3cbf91f76eb289a09073947b6706fc61b400ecb0ac9", "public_key": "base64encoded...", "contact_addresses": [ { "prefix": "LINN6GOLKQ37E63UKU2ACOZO", "server": "chat.example.com", "mailbox_public": "base64_x25519_public_key", "created_at": 1779734200, "active": true } ], "keypackage_server": "chat.example.com", "linked_at": 1779734200 } ], "signature": "ed25519_signature_hex_128_chars"}DirectMessageGroup ID Derivation
Section titled “DirectMessageGroup ID Derivation”Group ID is deterministic to ensure both parties create the same group:
fn create_dm_group_id(user1: &UserId, user2: &UserId) -> GroupId { let mut sorted = [*user1, *user2]; sorted.sort(); // Ensures same ID regardless of who creates group blake3(&sorted.concat())}