Skip to content

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.

When two devices want to establish their first contact (e.g., via a QR code scan), they need to:

  1. Create a new MLS group for 1:1 chat
  2. Exchange identity information
  3. Store each other as contacts

ContactRequest handles this entire flow in a single message type.

ContactRequest Structure
/// Contact request identifier (UUIDv7)
pub struct RequestId(Uuidv7);
/// Initial ContactRequest
struct 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,
}
  • 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

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.

ProtectionHow other messages get itContactRequest
Sender authenticationMLS credential in the ratchet treesignature field
Replay rejectionMLS consumes and deletes per-generation keysrequest_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.

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.

  1. Check the timestamp window

    Reject if timestamp is outside CONTACT_REQUEST_WINDOW. Pure arithmetic, no state.

  2. Check request_id against the dedup set

    Reject if already seen. A hash lookup, and the cheapest way to kill a replay.

  3. Verify the signature

    Locate the entry in devices whose device_id equals sender, confirm device_id == Blake3(public_key), then verify signature against that key.

  4. Record request_id

    Insert into the dedup set only after the signature verifies, so an attacker cannot poison the cache with unsigned requests bearing predicted identifiers.

  5. 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.

// Generous, because a ContactRequest may sit queued while the recipient is offline.
// The window's job is bounding dedup storage, not freshness
const CONTACT_REQUEST_WINDOW: Duration = Duration::days(7);
  • Recipients MUST reject requests whose timestamp is outside the window
  • Recipients MUST retain seen request_id values 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

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
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
  1. 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
  2. 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
  3. Create DirectMessageGroup

    The requester creates a new MLS group:

    • Group ID is deterministic: Blake3(sorted(user1_id, user2_id))
    • Both users are founders
  4. Send ContactRequest

    Requester sends ContactRequest with:

    • Their UserIdentity (for contact store)
    • Their DevicePublicInfo array
  5. Verify, then Fetch KeyPackages

    Recipient receives ContactRequest and:

    1. Verifies timestamp, request_id freshness, and signature before anything else
    2. Extracts device IDs from the devices field
    3. For each device, derives the handle and fetches a KeyPackage, spending one token per fetch
    4. Validates KeyPackage signatures and credentials
  6. 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
  7. Group Ready!

    Both devices now have:

    • A shared DirectMessageGroup
    • Each other in their contact store
    • Full encryption for 1:1 chat
  • Request Authentication:

    • ContactRequest MUST carry a signature over 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
  • Replay Rejection:

    • Recipients MUST reject requests outside CONTACT_REQUEST_WINDOW
    • Recipients MUST reject a request_id already seen within that window
    • Recipients MUST complete verification before fetching any KeyPackage
  • 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
  • Contact Store:
    • Contact information is stored locally only
    • Not shared with servers
    • Users can delete contacts at any time
  • 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
{
"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"
}

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())
}