Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Conclave Protocol Specification

Version 0.1

Purpose

This document is the formal protocol specification for Conclave, a self-hosted, end-to-end encrypted group messaging system built on the Messaging Layer Security (MLS) protocol (RFC 9420). It defines the wire format, client-server API, protocol flows, and security properties necessary for any developer to implement a compatible Conclave server or client.

What Is Conclave

Conclave is a private, encrypted chat system designed for small-to-medium communities. It provides:

  • End-to-end encryption via MLS — the server never sees plaintext message content.
  • Forward secrecy and post-compromise security through MLS key ratcheting and epoch advancement.
  • Single-server architecture — each server is an isolated community with no federation.
  • Simple deployment — a single server binary, a single SQLite database file, and a single configuration file.

Conclave is a building block for third-party clients. Any application that implements this specification can interoperate with existing Conclave servers and clients.

What Conclave Is Not

  • Not federated. Each Conclave server is an isolated community. There is no server-to-server protocol.
  • Not a user discovery service. Users find each other out-of-band and connect to a known server.

Design Principles

  1. Security: MLS-based end-to-end encryption with no server-side access to plaintext. No compromises.
  2. Simplicity: One code path for all messaging — both two-person conversations and group rooms use MLS groups. Minimal feature surface.
  3. Efficiency: Binary wire format (Protocol Buffers). Compact storage (SQLite). Small binary footprint.
  4. Deployability: Single static binary. Single SQLite file. Single config file. No external services.

Scope

This specification covers:

  • The client-server HTTP API — all endpoints, request/response formats, and error handling.
  • The wire format — Protocol Buffers message definitions for all protocol messages.
  • The real-time event system — Server-Sent Events (SSE) for push notifications.
  • The MLS integration — how RFC 9420 primitives are used for encryption, key management, and group operations.
  • Protocol flows — step-by-step sequences for registration, group creation, messaging, invitations, and more.
  • Security properties — threat model, mitigations, and trust model.

This specification does not prescribe:

  • Client-side storage formats or UI implementation details.
  • Server-side database schema (the logical data model is described, but implementations may use any storage backend).
  • Programming language or runtime requirements.

Conventions

This specification uses the following conventions:

  • MUST, MUST NOT, SHOULD, SHOULD NOT, and MAY are used as defined in RFC 2119.
  • Protocol message field names are rendered in monospace.
  • HTTP endpoints are written as METHOD /path (e.g., POST /api/v1/register).
  • All protobuf types are in the conclave.v1 package.
  • MLS-specific terminology follows RFC 9420 definitions.

Terminology

TermDefinition
GroupAn MLS group containing one or more members. All messaging occurs within groups. Also referred to as a “room” in client user interfaces.
MemberA user who belongs to a group and can send and receive encrypted messages within it.
AdminA member with elevated privileges (invite, remove, promote, demote, update group settings). The group creator is the initial admin.
Key packageA pre-published MLS credential that allows other users to add someone to a group asynchronously (without the target being online).
Last-resort key packageA reusable key package that is never consumed, serving as a fallback when all regular key packages have been used.
EpochAn MLS concept — the version counter for a group’s key state. Epochs advance on each commit (member add/remove, key rotation).
CommitAn MLS operation that applies one or more proposals and advances the group epoch.
WelcomeAn MLS message that allows a new member to join a group, containing the necessary key material and group state.
External commitAn MLS mechanism that allows a user to rejoin a group using only the group’s public state (GroupInfo), without a Welcome message. Used for account reset.
Escrow inviteConclave’s two-phase invitation system where the inviter pre-builds the MLS commit and Welcome, and the invitee explicitly accepts or declines before joining.
TOFUTrust On First Use — a trust model where the first observed signing key fingerprint for a user is assumed authentic and stored locally. Subsequent changes are flagged as warnings.
FingerprintThe SHA-256 hash of a user’s MLS signing public key, represented as a 64-character lowercase hexadecimal string. Used for identity verification.
Sequence numberA per-group, server-assigned monotonically increasing integer that orders messages within a group.

Normative References

Architecture Overview

System Model

Conclave uses a client-server architecture with MLS running on top of HTTP/2.

graph LR
    subgraph Client
        MLS[MLS]
        UI[UI]
        Store[Local Store]
    end

    subgraph Server
        API[HTTP API]
        DB[SQLite DB]
        SSE[SSE Broadcast]
    end

    Client <-->|"HTTPS / HTTP/2<br>Protobuf request/response<br>SSE event stream"| Server

Each Conclave deployment consists of a single server and one or more clients. There is no server-to-server federation protocol — each server is an isolated community.

Component Roles

Server

The server is a stateless relay for encrypted MLS messages. It provides:

  • HTTP API: Accepts protobuf-encoded requests over HTTP/2 and returns protobuf-encoded responses.
  • Message storage: Stores opaque MLS ciphertext blobs. The server never interprets message contents.
  • Key package storage: Holds pre-published MLS key packages for asynchronous group additions.
  • Group metadata: Tracks group membership, roles, and MLS group state (GroupInfo) for external joins.
  • User management: Handles registration, authentication, and profile storage.
  • Real-time push: Broadcasts events to connected clients via Server-Sent Events (SSE).
  • Retention enforcement: Periodically deletes expired messages based on server-wide and per-group policies.

The server MUST NOT attempt to decrypt, interpret, or validate the contents of MLS messages. All MLS data (key packages, commits, welcomes, application messages) are treated as opaque byte blobs.

Client

A Conclave client is responsible for:

  • MLS operations: All cryptographic operations (key generation, encryption, decryption, group management) run on the client.
  • API communication: Sending protobuf-encoded HTTP requests to the server and processing responses.
  • SSE consumption: Maintaining a persistent connection to the server’s event stream for real-time notifications.
  • Local state: Persisting MLS group state, signing keys, message history, and session information.
  • Name resolution: Resolving integer IDs to human-readable display names using the local member cache or server lookup endpoints.
  • Identity verification: Managing the local TOFU fingerprint store for signing key verification.

Clients MAY implement any user interface (terminal, graphical, headless bot, etc.) as long as they conform to this specification’s API and MLS requirements.

Data Model

The server maintains the following logical entities:

Users

Each user has:

  • A unique integer ID (user_id), auto-assigned at registration.
  • A unique username (ASCII alphanumeric + underscores, 1–64 characters).
  • An optional alias (display name, up to 64 characters).
  • A password hash (Argon2id).
  • A signing_key_fingerprint (SHA-256 hex of the MLS signing public key, uploaded by the client).

Sessions

Each session has:

  • An opaque bearer token (256-bit random, hex-encoded).
  • An associated user_id.
  • A creation timestamp and expiry time.

Groups

Each group has:

  • A unique integer ID (group_id), auto-assigned at creation.
  • A unique group_name (same format as username).
  • An optional alias (display name).
  • An mls_group_id (hex-encoded MLS opaque group identifier, set on first commit).
  • A message_expiry_seconds setting (-1 = disabled, 0 = delete-after-fetch, >0 = seconds).

Group Members

Each membership record has:

  • A group_id and user_id pair (composite primary key).
  • A role: either "admin" or "member".

Messages

Each stored message has:

  • A group_id identifying the containing group.
  • A sender_id identifying the sending user.
  • An mls_message blob (opaque MLS ciphertext).
  • A sequence_num (unique within the group, monotonically increasing).
  • A created_at timestamp (Unix epoch seconds).

Key Packages

Each key package has:

  • An associated user_id.
  • A key_package_data blob (opaque MLS key package bytes).
  • An is_last_resort flag.

Pending Invites

Each pending invite holds the escrowed MLS materials for a two-phase invitation:

  • A group_id and invitee_id pair (unique constraint).
  • The inviter_id.
  • The commit_message, welcome_data, and group_info blobs.

Pending Welcomes

Each pending welcome holds an MLS Welcome message ready for the target user to process:

  • A group_id and user_id.
  • The welcome_data blob.

Group Info

Each group may have a stored MLS GroupInfo blob, used for external commits (account reset / rejoin). Updated by commit upload, member removal, and group departure operations.

Transport and Wire Format

HTTP/2 Transport

All client-server communication uses HTTP/2. The server listens for connections on a configurable address and port.

Transport Security

The server supports two transport modes:

  1. Plain HTTP: When TLS is not configured, the server listens on plain HTTP. The default port is 8080. This mode is intended for deployments behind a TLS-terminating reverse proxy (e.g., nginx, Cloudflare, Caddy).

  2. Native TLS: When TLS certificate and key paths are configured, the server serves HTTPS directly. The default port is 8443. The certificate and key MUST be in PEM format.

Clients MUST validate the server’s TLS certificate when connecting over HTTPS. Implementations MAY provide an option to accept invalid certificates for development and testing purposes.

URL Scheme

If a user specifies a server address without a URL scheme (e.g., example.com:8443), the client SHOULD automatically prepend https://.

Wire Format

Protocol Buffers

All request and response bodies use Protocol Buffers (proto3) serialization. The protobuf package is conclave.v1.

  • Content-Type: All requests and responses MUST use Content-Type: application/x-protobuf.
  • Encoding: Bodies contain raw serialized protobuf bytes (not base64-encoded, not JSON).
  • Empty messages: Protobuf message types with no fields (e.g., UploadCommitResponse {}) serialize to zero bytes. These are returned with the documented HTTP status code.

Request Body Limits

The server MUST reject request bodies larger than 1 MiB (1,048,576 bytes).

Error Responses

All error responses use the ErrorResponse protobuf message:

message ErrorResponse {
  string message = 1;
}

The message field contains a human-readable description of the error. Error responses use the same Content-Type: application/x-protobuf encoding.

The server MUST NOT expose internal implementation details (stack traces, database errors, file paths) in error messages returned to clients.

Design Rationale

Why Protobuf over HTTP Instead of gRPC

Conclave uses Protocol Buffers for message serialization without the gRPC transport layer. This provides schema-defined binary encoding with cross-language support while keeping the transport simple and proxy-friendly. Some CDN and reverse proxy services (e.g., Cloudflare) convert gRPC HTTP/2 to HTTP/1.1 gRPC-Web internally, which breaks bidirectional streaming and adds latency. Raw protobuf over standard HTTP/2 avoids these issues.

Why SSE Instead of WebSockets or gRPC Streaming

Server-Sent Events (SSE) is a standard long-lived HTTP response that proxies through virtually all HTTP infrastructure without issues. It provides the server-to-client push channel needed for real-time notifications without requiring bidirectional streaming (client-to-server communication uses standard HTTP requests).

Authentication

Overview

Conclave uses a simple bearer token authentication model. Users register with a username and password, log in to receive an opaque session token, and include that token in subsequent requests.

Registration

A client registers a new account by sending a POST /api/v1/register request with a username, password, and optional alias. See Authentication Endpoints for the full endpoint specification.

Registration Control

The server provides two configuration options to control registration access:

  • registration_enabled (boolean, default true): When true, anyone can register and the token check is bypassed. When false, registration requires a valid registration token.
  • registration_token (optional string): When registration_enabled is false, only requests providing this token can register. If no token is configured and registration is disabled, registration is entirely closed.

The registration token MUST contain only ASCII letters, digits, underscores, and hyphens ([a-zA-Z0-9_-]). Token comparison MUST use constant-time equality to prevent timing attacks.

The server MUST return HTTP 403 when registration is disabled or when the provided token is invalid.

Password Hashing

Passwords MUST be hashed using Argon2id with a random salt before storage. The server MUST NOT store plaintext passwords.

Login

A client logs in by sending a POST /api/v1/login request with a username and password. The server verifies the password against the stored Argon2id hash and, on success, generates a session token.

Timing Attack Mitigation

To prevent username enumeration via timing analysis, the server MUST perform password verification even when the requested username does not exist. This is typically accomplished by verifying against a precomputed dummy Argon2id hash, ensuring both the valid-user and invalid-user code paths have equivalent computational profiles.

Session Tokens

  • Tokens MUST be 256-bit cryptographically random values, hex-encoded to 64 characters.
  • Tokens MUST be generated using a cryptographically secure random number generator.
  • Tokens MUST have a configurable time-to-live (TTL). The default TTL is 7 days (604,800 seconds).
  • The server SHOULD store a hash (e.g., SHA-256) of the token rather than the raw token value.

Authenticated Requests

All endpoints except POST /api/v1/register and POST /api/v1/login require authentication.

Clients MUST include the session token in the Authorization header using the Bearer scheme:

Authorization: Bearer <token>

The server MUST validate the token against its session store and check the token’s expiry. If the token is missing, invalid, or expired, the server MUST return HTTP 401.

Logout

A client logs out by sending a POST /api/v1/logout request. The server MUST revoke the session token used in the request.

Password Change

Authenticated users can change their password via POST /api/v1/change-password. The server validates the new password (minimum 8 characters), hashes it with Argon2id, and updates the stored hash. Existing sessions MUST remain valid after a password change.

Server-Sent Events

Overview

Conclave uses Server-Sent Events (SSE) for real-time server-to-client push notifications. When a relevant action occurs (new message, group update, invitation, etc.), the server pushes an event to all affected clients through their open SSE connections.

SSE Endpoint

GET /api/v1/events
Authorization: Bearer <token>

This endpoint returns a persistent text/event-stream response. The server MUST send periodic keep-alive comments (: lines) to prevent connection timeouts.

Event Wire Format

Each SSE event is a standard SSE data: line containing a hex-encoded serialized ServerEvent protobuf message:

data: 0a0c0801100a18012205616c696365

Clients MUST:

  1. Read the data field from the SSE event.
  2. Hex-decode the string to obtain raw bytes.
  3. Deserialize the bytes as a conclave.v1.ServerEvent protobuf message.
  4. Dispatch based on the oneof event variant.

Event Targeting

Each event emitted by the server has a set of target user IDs. The SSE endpoint filters events so that each client only receives events addressed to their user_id. A client MUST NOT receive events targeted at other users.

Sender Inclusion Rules

The server follows specific rules about whether the action’s sender receives their own event:

  • Metadata operations (profile update, group settings update, promote, demote): The sender IS included in the broadcast. All clients — including the sender’s — receive the event and refresh their state.
  • MLS operations (send message, upload commit, accept invite): The sender is EXCLUDED from the broadcast. The sender has already applied the MLS state change locally, so re-processing it would cause errors or duplicate state transitions.

Event Types

The ServerEvent protobuf message uses a oneof to carry exactly one of the following event types:

NewMessageEvent

Emitted when a new encrypted message is stored in a group.

FieldTypeDescription
group_idint64The group the message was sent to
sequence_numuint64The server-assigned sequence number
sender_idint64The user who sent the message

Recipients: All group members except the sender.

Recommended client behavior: Fetch new messages from the group starting after the client’s last known sequence number.

GroupUpdateEvent

Emitted when group state changes.

FieldTypeDescription
group_idint64The affected group
update_typestringThe type of update (see below)

Update types:

ValueTriggerRecipients
"commit"MLS commit uploaded (member add/remove via invite acceptance)All members except the sender
"member_profile"A member changed their profile (alias)All co-members including the sender
"group_settings"Group alias, name, or expiry changedAll members including the sender
"role_change"A member was promoted or demotedAll members including the sender

Recommended client behavior: Refresh the group’s member list and metadata via GET /api/v1/groups.

WelcomeEvent

Emitted when a user has a pending Welcome message to process (after accepting an invite).

FieldTypeDescription
group_idint64The group the user was invited to
group_aliasstringThe group’s display alias

Recipients: The invitee only.

Recommended client behavior: Fetch pending welcomes via GET /api/v1/welcomes, process MLS Welcome messages, and join the groups.

MemberRemovedEvent

Emitted when a member is removed from or leaves a group.

FieldTypeDescription
group_idint64The affected group
removed_user_idint64The user who was removed or left

Recipients: All remaining group members AND the removed user.

Recommended client behavior: If removed_user_id matches the client’s own user ID, remove the group from local state. Otherwise, refresh the group’s member list.

IdentityResetEvent

Emitted when a member rejoins a group via external commit after an account reset.

FieldTypeDescription
group_idint64The affected group
user_idint64The user who reset their identity

Recipients: All group members except the user who reset.

Recommended client behavior: Refresh the group state. Display a warning that the user’s encryption keys have changed. Update the local TOFU fingerprint store if the fingerprint has changed.

InviteReceivedEvent

Emitted when a pending invite is created for a user.

FieldTypeDescription
invite_idint64The invite’s unique identifier
group_idint64The group being invited to
group_namestringThe group’s name
group_aliasstringThe group’s display alias
inviter_idint64The user who sent the invite

Recipients: The invitee only.

Recommended client behavior: Display the invitation with accept/decline options. The group name and alias are included because the invitee is not yet a member and cannot resolve them from their local cache.

InviteDeclinedEvent

Emitted when an invitee declines a pending invite, or when an admin cancels a pending invite.

FieldTypeDescription
group_idint64The group the invite was for
declined_user_idint64The user who declined (or whose invite was cancelled)

Recipients: The original inviter only.

Recommended client behavior: Perform a key rotation (empty MLS commit) to evict the phantom MLS leaf that was added when the invite was created. See Key Rotation.

InviteCancelledEvent

Emitted when an admin cancels a pending invite for a user.

FieldTypeDescription
group_idint64The group the invite was for

Recipients: The invitee only.

Recommended client behavior: Remove the cancelled invite from the pending invites display.

Lag Handling

If a client’s SSE connection falls behind the server’s event broadcast buffer, the server sends a transport-level lag notification:

event: lagged
data: 5

This is not a protobuf ServerEvent — it is a raw SSE event with the event type "lagged" and the data field containing the number of dropped events as a decimal string.

Clients SHOULD treat a lagged event as a signal to re-fetch all group state (via GET /api/v1/groups and relevant message endpoints) to ensure consistency.

Identifier Conventions

ID-First Referencing

Conclave uses an ID-first referencing convention: all API operations reference users and groups by their integer IDs (user_id, group_id). Human-readable names are used only at the user interface boundary.

User Identifiers

Each user is assigned a unique integer user_id at registration. This ID is:

  • Used in all API request bodies and path parameters for operations (invite, kick, promote, demote, send message, etc.).
  • Embedded in MLS credentials as big-endian i64 bytes (8 bytes).
  • Used in all SSE events to reference users.
  • Stable for the lifetime of the account — it never changes.

Usernames

Usernames are human-readable identifiers subject to the following constraints:

  • 1–64 characters long.
  • Must start with an ASCII alphanumeric character.
  • May contain only ASCII letters, digits, and underscores.
  • Must be unique across the server.

Usernames are used only in:

  • POST /api/v1/register and POST /api/v1/login (authentication).
  • GET /api/v1/users/{username} (name-to-ID resolution).

All other endpoints accept only integer IDs.

Display Names (Aliases)

Users may set an optional alias (display name) which is shown in client UIs. Aliases are not unique and not used for identification in the protocol.

Group Identifiers

Each group has two identifiers:

  1. Server group ID (group_id): A unique integer assigned by the server at group creation. Used in all API paths and request bodies.
  2. MLS group ID (mls_group_id): An opaque byte identifier assigned by the MLS layer, hex-encoded as a string. Set on the server during the first commit upload and used for MLS operations on the client.

Group Names

Group names follow the same format rules as usernames (1–64 characters, ASCII alphanumeric start, letters/digits/underscores only). Group names are unique across the server.

Group names are used only in:

  • POST /api/v1/groups (group creation).

Group Aliases

Groups may have an optional alias (display name) set by admins via PATCH /api/v1/groups/{id}.

Name Resolution

Client-to-Server (Input)

When a user types a command referencing another user by name (e.g., /invite alice), the client MUST:

  1. Resolve the username to a user_id via GET /api/v1/users/{username}.
  2. Use the user_id in all subsequent API calls.

Server-to-Client (Display)

For display purposes, clients SHOULD resolve user IDs to human-readable names using the following strategy:

  1. Local cache: Check the in-memory member data populated by ListGroupsResponse, which includes username, alias, and user_id for all group members.
  2. Server lookup: For cache misses (e.g., users who left the group), use GET /api/v1/users/by-id/{user_id}.
  3. Fallback: If the lookup fails, display user#<id> (e.g., user#42).

Batch Convenience

API responses that list members or groups (e.g., ListGroupsResponse, PendingInvite, InviteReceivedEvent) include human-readable names alongside IDs. This avoids N+1 lookup queries for display rendering. However, operational API endpoints MUST NOT accept names — only IDs.

Exceptions

The following SSE events include human-readable names because the recipient cannot resolve them from their local cache:

  • InviteReceivedEvent: Includes group_name and group_alias because the invitee is not yet a group member.
  • PendingInvite: Includes group_name, group_alias, and inviter_username for the same reason.

MLS Protocol Usage

Overview

Conclave uses the Messaging Layer Security (MLS) protocol (RFC 9420) for all end-to-end encryption. Every conversation — whether between two people or a large group — is an MLS group. There is no separate direct messaging protocol.

The server acts as a delivery service (as defined in RFC 9420 Section 3): it stores and forwards opaque MLS messages without interpreting their contents. All cryptographic operations (key generation, encryption, decryption, group state management) are performed exclusively on the client.

Cipher Suite

Conclave uses MLS cipher suite CURVE448_CHACHA (cipher suite ID 6 as defined in RFC 9420 Section 17.1):

ComponentAlgorithm
Key Exchange (KEM)X448
Authenticated Encryption (AEAD)ChaCha20-Poly1305
HashSHA-512
SignatureEd448
Security Level256-bit

All clients and servers in a Conclave deployment MUST support this cipher suite. All groups MUST use this cipher suite.

Sync Mode

MLS operations are CPU-bound cryptographic computations. The MLS library runs in synchronous mode on the client. Client implementations that use asynchronous runtimes (e.g., tokio, async-std) SHOULD offload MLS operations to a blocking task pool to avoid stalling the event loop.

Epoch Retention

MLS groups advance through epochs on each commit (member add, member remove, key rotation, external rejoin). Clients need to retain key material from prior epochs to decrypt messages that were encrypted under those epochs.

Clients SHOULD retain key material for at least 16 prior epochs. This allows a client to be offline through up to 16 group state transitions (commits) and still decrypt messages sent during those epochs.

Regular application messages (chat) do not advance the epoch. A client can be offline through an unlimited number of chat messages within the same epoch.

Decryption Error Handling

When a client fails to decrypt a message (e.g., because the epoch’s key material has been evicted), the client SHOULD:

  1. Display a warning to the user indicating the message could not be decrypted, including the message’s sequence number and the reason for failure.
  2. Advance the sequence tracking past the undecryptable message. Failed messages cannot be retried — blocking on them would cause infinite retry loops.
  3. Continue processing subsequent messages.

If a user experiences persistent decryption failures, they can perform an account reset to rejoin the group with fresh cryptographic state.

MLS Operations Summary

The following MLS operations are used in Conclave:

OperationMLS PrimitiveConclave Usage
Generate signing identitysignature_key_generate()Registration, account reset
Generate key packagegenerate_key_package_message()Pre-publishing credentials
Create groupcreate_group() + commit_builder().build()New group creation
Invite membercommit_builder().add_member(key_package).build()Adding members to groups
Join groupjoin_group(welcome_message)Processing a Welcome after invite acceptance
Encrypt messageencrypt_application_message(plaintext)Sending chat messages
Decrypt messageprocess_incoming_message(ciphertext)Receiving chat messages and commits
Remove membercommit_builder().remove_member(index).build()Kicking a member
Leave groupcommit_builder().remove_member(own_index).build()Voluntary departure
Rotate keyscommit_builder().build() (empty commit)Forward secrecy, phantom leaf cleanup
External rejoinexternal_commit_builder().build(group_info)Account reset rejoin
Export GroupInfogroup_info_message_allowing_ext_commit(true)Enabling external commits

Key Packages

Purpose

Key packages are pre-published MLS credentials that enable asynchronous group additions. When user A wants to add user B to a group, A fetches one of B’s key packages from the server and uses it to build an MLS Add proposal and Welcome message — even if B is offline. This is a core MLS concept defined in RFC 9420 Section 10.

Lifecycle

Initial Upload

After registration or login, a client SHOULD upload an initial set of key packages to the server:

  • 5 regular key packages: Single-use credentials consumed when the user is added to a group.
  • 1 last-resort key package: A reusable fallback that is never consumed.

Key packages are uploaded via POST /api/v1/key-packages with the entries field containing a list of KeyPackageEntry messages, each specifying the key package data and an is_last_resort flag.

Consumption

When a user invites another user to a group, the server returns one of the target’s key packages via the invite endpoint. Key packages are consumed in FIFO order (oldest regular package first).

  • Regular key packages are deleted from the server after consumption.
  • Last-resort key packages are returned but never deleted. Per RFC 9420 Section 16.6, the last-resort package ensures a user is always reachable even when all regular packages have been exhausted.

Replenishment

After a client is added to a group (i.e., processes an MLS Welcome message), it SHOULD upload replacement key packages to maintain availability. The recommended practice is to upload one new regular key package for each Welcome processed.

Server Limits

The server MUST enforce a maximum of 10 regular key packages per user. Uploads beyond this cap SHOULD replace the oldest existing packages.

Uploading a new last-resort key package replaces the previous last-resort package for that user.

Wire Format Validation

The server MUST validate all uploaded key packages for MLS wire format correctness per RFC 9420 Section 6:

  • The first 2 bytes MUST be the MLS version (0x0001 for MLS 1.0).
  • The next 2 bytes MUST be the wire format type (0x0005 for mls_key_package).
  • The minimum size is 4 bytes.
  • The maximum size is 16 KiB (16,384 bytes).

The server does NOT perform full cryptographic validation of key package contents (signature verification, credential validation, etc.). This validation is the responsibility of the client that consumes the key package during group operations.

Signing Key Fingerprint

When uploading key packages, the client SHOULD include a signing_key_fingerprint in the request. This is the SHA-256 hash of the client’s MLS signing public key, represented as a 64-character lowercase hexadecimal string. The server stores this fingerprint and distributes it to other users for TOFU identity verification.

Rate Limiting

The key package consumption endpoint (GET /api/v1/key-packages/{user_id}) MUST be rate-limited to 10 requests per minute per target user. This prevents an attacker from draining a user’s regular key packages, which would force fallback to the reusable last-resort package (with associated reuse risks per RFC 9420 Section 16.8).

Group Lifecycle

Group Creation

Creating a group is a two-step process:

  1. Server-side: The client calls POST /api/v1/groups with a group_name (and optional alias). The server creates the group record and adds the creator as the sole member with the admin role. The server returns a group_id.

  2. MLS-side: The client creates an MLS group locally (with no other members), producing a commit and GroupInfo. The client uploads these via POST /api/v1/groups/{id}/commit, which also sets the mls_group_id on the server.

After creation, the group has exactly one member (the creator). Additional members are added via the escrow invite system.

Group ID Mapping

The server uses auto-increment integer IDs (group_id), while MLS uses opaque byte identifiers (mls_group_id, hex-encoded as strings). The server stores the mls_group_id in the groups table after the first commit upload and returns it in ListGroupsResponse.

Clients MUST maintain a mapping between server group IDs and MLS group IDs. This mapping is populated from the ListGroupsResponse on login or reconnection.

Member Addition (Escrow Invite)

Adding members to an existing group uses a two-phase escrow system. See Escrow Invite System for the complete flow.

In summary:

  1. The admin fetches the target’s key package via the invite endpoint.
  2. The admin builds an MLS commit (adding the target) and Welcome message locally.
  3. The admin uploads the commit, Welcome, and GroupInfo to the server’s invite escrow.
  4. The target receives a notification and can accept or decline.
  5. On acceptance, the escrowed materials are finalized — the target processes the Welcome to join the group.

Member Removal (Kick)

An admin can remove a member from a group:

  1. The admin finds the target’s MLS leaf index (by matching the user_id in the target’s MLS BasicCredential).
  2. The admin builds an MLS removal commit targeting that leaf index.
  3. The admin uploads the commit and GroupInfo via POST /api/v1/groups/{id}/remove.
  4. The server removes the member from the group membership table and stores the commit as a group message.
  5. All remaining members (and the removed member) receive a MemberRemovedEvent.

See Member Removal and Departure for the complete flow.

Voluntary Departure (Leave)

A member can leave a group voluntarily:

  1. The member builds an MLS self-removal commit.
  2. The member uploads the commit and optional GroupInfo via POST /api/v1/groups/{id}/leave.
  3. The server removes the member from the group membership table and stores the commit as a group message.
  4. Remaining members receive a MemberRemovedEvent.
  5. The departing member deletes their local MLS group state.

Key Rotation

A group member (typically an admin) can rotate the group’s key material by building an empty MLS commit (no proposals). This:

  • Advances the group epoch.
  • Rotates key material, providing forward secrecy.
  • Is used to clean up phantom MLS leaves after a declined invite.

The commit is uploaded via POST /api/v1/groups/{id}/commit. See Key Rotation.

External Rejoin

After an account reset (where the user’s MLS state is wiped and regenerated), the user must rejoin their groups using MLS external commits:

  1. The user fetches the stored GroupInfo for each group via GET /api/v1/groups/{id}/group-info.
  2. The user builds an MLS external commit, which joins the group with a new leaf. If the user knows their old leaf index, the external commit includes a self-removal proposal to remove the old leaf.
  3. The user uploads the external commit via POST /api/v1/groups/{id}/external-join.
  4. Other group members receive an IdentityResetEvent.

External join REQUIRES:

  • The user MUST still be a member of the group on the server (group membership is not deleted during account reset).
  • A stored GroupInfo MUST exist for the group (GroupInfo is stored during commit uploads, member removals, and leave operations by authorized members).

See Account Reset and External Rejoin for the complete flow.

GroupInfo Storage

The server stores the latest MLS GroupInfo blob for each group. GroupInfo is updated by:

  • POST /api/v1/groups/{id}/commit — when group_info is provided.
  • POST /api/v1/groups/{id}/remove — when group_info is provided.
  • POST /api/v1/groups/{id}/leave — when group_info is provided.

GroupInfo is required for external joins. Clients SHOULD include GroupInfo in commit uploads to ensure it stays current.

Admin Role Management

  • Promote: An admin can promote a member to admin via POST /api/v1/groups/{id}/promote. This is a server-side metadata operation — it does not involve MLS.
  • Demote: An admin can demote another admin to member via POST /api/v1/groups/{id}/demote. The server MUST reject demotion of the last remaining admin.
  • Initial admin: The group creator is automatically assigned the admin role.
  • New member default: Members added via the invite system receive the member role.

Identity and Credentials

MLS Credentials

Conclave uses MLS BasicCredential for all group members. The credential contains the user’s identity data, which is embedded in their key packages and used by other group members to identify the sender of messages and commits.

Credential Format

The BasicCredential identity field contains the user’s user_id encoded as a big-endian i64 (8 bytes).

packet-beta
    0-63: "user_id (i64, big-endian, 8 bytes)"

For example, a user with user_id = 123 would have the identity bytes: 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x7B.

Using integer IDs instead of usernames ensures credential stability — the credential remains valid even if the user changes their display name.

Identity Provider

Conclave uses BasicIdentityProvider, which does not perform identity validation beyond matching the credential format. This means:

  • The server does not verify that the credential in a key package corresponds to a legitimate user.
  • Trust in user identities relies on the server’s control over key package storage (only authenticated users can upload key packages) and the TOFU fingerprint verification system.
  • This is suitable for closed communities where users trust the server operator.

For deployments requiring stronger identity assurance, future versions may support X.509 credentials with certificate authority validation.

Signing Key Pair

Each user has a long-lived MLS signing key pair used for signing key packages, commits, and messages.

Key Generation

The signing key pair is generated using the CURVE448_CHACHA cipher suite’s signature algorithm (Ed448). The key pair is generated:

  • On first registration, when the user’s MLS identity does not yet exist.
  • On account reset, when the user’s MLS state is wiped and regenerated.

Key Persistence

Clients MUST persist the following identity data locally:

  • Signing identity: The serialized SigningIdentity containing the public key and BasicCredential.
  • Signing secret key: The SignatureSecretKey (private key material).

These files contain sensitive cryptographic material and MUST be protected by appropriate filesystem permissions. Compromise of the signing secret key allows an attacker to impersonate the user.

Key Fingerprint

The fingerprint is the SHA-256 hash of the signing public key, represented as a 64-character lowercase hexadecimal string:

fingerprint = hex(SHA-256(signing_public_key))

For display purposes, fingerprints are formatted as 8 groups of 8 hex characters separated by spaces:

a1b2c3d4 e5f6a7b8 c9d0e1f2 a3b4c5d6 e7f8a9b0 c1d2e3f4 a5b6c7d8 e9f0a1b2

Fingerprints are used for TOFU identity verification.

Member Identification in Groups

When a client decrypts an MLS message (application message or commit), it extracts the sender’s SigningIdentity from the MLS protocol. The user_id is then extracted from the BasicCredential by reading the first 8 bytes as a big-endian i64.

This allows the client to map decrypted messages to user IDs for display name resolution via the ID-first referencing system.

MLS State Persistence

Clients MUST persist MLS group state to survive application restarts. This includes:

  • Group epoch key material (for decrypting messages from prior epochs).
  • Group membership (leaf nodes with credentials).
  • The group’s ratchet tree state.

The persistence mechanism is implementation-defined (e.g., SQLite, flat files, etc.). The key requirement is that a client restarting after a crash can continue to decrypt messages and participate in group operations without rejoining.

TOFU Fingerprint Verification

Overview

Conclave uses a Trust On First Use (TOFU) model — similar to SSH known_hosts — to let users detect signing key changes after initial contact. Each user’s MLS signing public key is hashed to produce a fingerprint. Clients store the first-seen fingerprint for each user and flag any subsequent changes.

Fingerprint Computation

The fingerprint is the SHA-256 hash of the user’s MLS signing public key, represented as a 64-character lowercase hexadecimal string:

fingerprint = lowercase_hex(SHA-256(signing_public_key_bytes))

The resulting string is exactly 64 characters (256 bits / 4 bits per hex digit).

Fingerprint Display Format

For user-facing display, fingerprints SHOULD be formatted as 8 groups of 8 hex characters separated by spaces:

a1b2c3d4 e5f6a7b8 c9d0e1f2 a3b4c5d6 e7f8a9b0 c1d2e3f4 a5b6c7d8 e9f0a1b2

For comparison and storage, fingerprints SHOULD be normalized by removing all whitespace and converting to lowercase.

Fingerprint Distribution

Upload

Clients compute their fingerprint locally and upload it to the server during key package upload (via the signing_key_fingerprint field in UploadKeyPackageRequest). This occurs:

  • On registration (initial key package upload).
  • On login (key package re-upload).
  • On account reset (new identity, new key packages).

Distribution

The server stores the fingerprint in the user’s record and distributes it alongside member data in:

  • GroupMember.signing_key_fingerprint: Included in ListGroupsResponse, so clients receive fingerprints for all co-members.
  • UserInfoResponse.signing_key_fingerprint: Returned by the user lookup endpoints (GET /api/v1/users/{username}, GET /api/v1/users/by-id/{user_id}, GET /api/v1/me).

Local TOFU Store

Clients MUST maintain a local store of known fingerprints, tracking the first-seen fingerprint for each user. The store contains:

FieldTypeDescription
user_idintegerPrimary key — the user being tracked
fingerprintstringThe stored fingerprint (64-char hex)
verifiedbooleanWhether the fingerprint has been manually verified

Verification States

A user’s fingerprint can be in one of four states:

StateConditionDisplay Indicator
UnknownThe server has no fingerprint for this user (e.g., legacy account, key packages not yet uploaded)[?]
UnverifiedThe client has stored a fingerprint on first contact, but the user has not manually confirmed it[?]
VerifiedThe user has confirmed the fingerprint out-of-band via the verify command(none)
ChangedThe server’s fingerprint differs from the locally stored value[!]

State Transitions

stateDiagram-v2
    [*] --> Unverified : First seen
    Unverified --> Verified : Manual verify
    Unverified --> Changed : Key change detected
    Verified --> Changed : Key change detected

    Unverified : Display: [?]
    Verified : Display: (none)
    Changed : Display: [!]

Key Change Detection

When a user performs an account reset, their signing keys are regenerated and a new fingerprint is uploaded to the server. Other clients detect the fingerprint change when they next receive the user’s updated member data (via ListGroupsResponse or UserInfoResponse).

A changed fingerprint triggers the [!] warning indicator. This alerts members to verify the new fingerprint out-of-band. Key changes are expected after /reset (account reset with new signing keys) but could also indicate a key substitution attack.

An IdentityResetEvent SSE event is sent to co-members when a user performs an external rejoin after reset, providing an additional signal.

User Commands

Clients SHOULD provide the following commands for fingerprint verification:

CommandDescription
/whois [username]Display a user’s fingerprint and verification status. With no argument, displays the current user’s own fingerprint.
/verify <username> <fingerprint>Manually verify a user’s fingerprint. The client checks that the provided fingerprint matches the server’s current fingerprint for that user, then stores it as verified. If the fingerprint does not match, the command is rejected.
/unverify <username>Remove verification status for a user, resetting them to Unverified (TOFU) state.
/trustedList all users in the local TOFU store with their fingerprints and verification status.

Security Model

Protection Provided

  • Post-first-contact key substitution: After the initial fingerprint is stored, any change — whether from a compromised server, man-in-the-middle, or legitimate key reset — is detected and flagged with [!].

Limitations

  • No protection against first-contact attacks: If the server is compromised during the initial key exchange, it could substitute a different key. This is the inherent limitation of TOFU.
  • Server-mediated distribution: The server distributes fingerprints. A compromised server could substitute fingerprints for new contacts before the client has stored them.

Stronger Assurance

Users can use /verify to confirm fingerprints through a trusted out-of-band channel (in person, phone call, verified messaging, etc.). This eliminates the first-contact vulnerability for verified users, upgrading from TOFU trust to verified trust.

The TOFU model is a practical trade-off between usability (no PKI or certificate authority required) and security (detects key changes after initial contact). For communities requiring stronger guarantees, out-of-band /verify provides a path to full fingerprint verification.

API Conventions

Base URL

All API endpoints are prefixed with /api/v1/. For example, given a server at https://chat.example.com:8443, the registration endpoint is:

https://chat.example.com:8443/api/v1/register

Content Type

All request and response bodies MUST use Content-Type: application/x-protobuf. Bodies contain raw serialized Protocol Buffers bytes using the conclave.v1 package.

Servers MUST reject requests with incorrect or missing content types for endpoints that expect a request body.

Authentication

All endpoints except POST /api/v1/register and POST /api/v1/login require authentication. Clients MUST include the session token in the Authorization header:

Authorization: Bearer <token>

If the header is missing, the token is invalid, or the token has expired, the server MUST return 401 Unauthorized.

Request Body Limits

The server MUST reject request bodies larger than 1 MiB (1,048,576 bytes).

Path Parameters

Path parameters are denoted with {name} placeholders in endpoint paths. For example, in /api/v1/groups/{group_id}/messages, the {group_id} segment is replaced with the actual group ID.

Query Parameters

Some endpoints accept query parameters for pagination or filtering. These are documented per-endpoint.

HTTP Status Codes

The API uses the following HTTP status codes:

CodeMeaningUsage
200 OKRequest succeededSuccessful GET, POST, PATCH operations
201 CreatedResource createdRegistration, group creation
204 No ContentSuccess with no response bodyLogout, welcome acceptance
400 Bad RequestValidation errorInvalid input format, missing required fields, constraint violations
401 UnauthorizedAuthentication or authorization failureMissing/invalid/expired token, not a group member when membership is required
403 ForbiddenAccess deniedRegistration disabled, invalid registration token
404 Not FoundResource does not existUser, group, key package, invite, or welcome not found
409 ConflictDuplicate resourceUsername/group name taken, user already a member, duplicate invite
500 Internal Server ErrorServer-side failureDatabase errors, encoding failures (no internal details exposed)

Error Responses

All error responses use the ErrorResponse protobuf message:

message ErrorResponse {
  string message = 1;  // Human-readable error description
}

The message field contains a description suitable for displaying to the user or logging. The server MUST NOT include internal implementation details (stack traces, database errors, file paths) in error messages.

Empty Response Bodies

Several response message types have no fields (e.g., UploadCommitResponse {}, RemoveMemberResponse {}). These serialize to zero bytes in protobuf. Endpoints returning these types use HTTP 200 OK with an empty body, unless a different status code is documented (e.g., 204 No Content for logout and welcome acceptance).

Endpoint Documentation Format

Each endpoint in this specification is documented with:

  • HTTP method and path
  • Authentication requirement (public or authenticated)
  • Authorization requirement (any authenticated user, group member, or group admin)
  • Request body (protobuf message type and field descriptions)
  • Response body (protobuf message type and field descriptions)
  • Query parameters (if any)
  • Status codes (success and error conditions)
  • SSE events emitted (if any)

Authentication Endpoints

Register

Creates a new user account.

POST /api/v1/register

Authentication: None (public endpoint).

Request Body — RegisterRequest

FieldTypeRequiredDescription
usernamestringYesUnique username. 1–64 characters, must start with ASCII alphanumeric, only letters/digits/underscores.
passwordstringYesPassword. Minimum 8 characters.
aliasstringNoDisplay name. Max 64 characters, no ASCII control characters.
registration_tokenstringNoRegistration token for invite-only servers. Required when registration_enabled is false.

Response Body — RegisterResponse

FieldTypeDescription
user_idint64The server-assigned unique user ID.

Status Codes

CodeCondition
201 CreatedRegistration successful.
400 Bad RequestInvalid username format, password too short, alias too long or contains control characters.
403 ForbiddenRegistration is disabled, or the provided registration token is invalid.
409 ConflictUsername already taken.

SSE Events

None.


Login

Authenticates a user and returns a session token.

POST /api/v1/login

Authentication: None (public endpoint).

Request Body — LoginRequest

FieldTypeRequiredDescription
usernamestringYesThe user’s username.
passwordstringYesThe user’s password.

Response Body — LoginResponse

FieldTypeDescription
tokenstringSession token (256-bit random, hex-encoded, 64 characters).
user_idint64The user’s unique ID.
usernamestringThe user’s username.

Status Codes

CodeCondition
200 OKLogin successful.
401 UnauthorizedInvalid username or password.

Notes

The server MUST perform timing-equalized password verification to prevent username enumeration. When the requested username does not exist, the server runs password verification against a dummy hash to ensure consistent response times.

SSE Events

None.


Logout

Revokes the current session token.

POST /api/v1/logout

Authentication: Required.

Request Body

None.

Response

HTTP 204 No Content with no body.

Status Codes

CodeCondition
204 No ContentLogout successful. Token revoked.
401 UnauthorizedInvalid or expired token.

SSE Events

None.


Get Current User

Returns the authenticated user’s profile information.

GET /api/v1/me

Authentication: Required.

Request Body

None.

Response Body — UserInfoResponse

FieldTypeDescription
user_idint64The user’s unique ID.
usernamestringThe user’s username.
aliasstringThe user’s display name (may be empty).
signing_key_fingerprintstringSHA-256 hex of the user’s MLS signing public key (may be empty).

Status Codes

CodeCondition
200 OKSuccess.
401 UnauthorizedInvalid or expired token.

SSE Events

None.


Update Profile

Updates the authenticated user’s display name.

PATCH /api/v1/me

Authentication: Required.

Request Body — UpdateProfileRequest

FieldTypeRequiredDescription
aliasstringYesNew display name. Max 64 characters, no ASCII control characters. Set to empty string to clear.

Response Body — UpdateProfileResponse

Empty message.

Status Codes

CodeCondition
200 OKProfile updated.
400 Bad RequestAlias too long or contains control characters.
401 UnauthorizedInvalid or expired token.

SSE Events

  • GroupUpdateEvent with update_type: "member_profile" — sent to all members of all groups the user belongs to, including the sender.

Change Password

Changes the authenticated user’s password.

POST /api/v1/change-password

Authentication: Required.

Request Body — ChangePasswordRequest

FieldTypeRequiredDescription
new_passwordstringYesThe new password. Minimum 8 characters.

Response Body — ChangePasswordResponse

Empty message.

Status Codes

CodeCondition
200 OKPassword changed.
400 Bad RequestNew password too short.
401 UnauthorizedInvalid or expired token.

Notes

Existing sessions remain valid after a password change. The server does NOT invalidate other sessions.

SSE Events

None.


Reset Account

Clears the user’s server-side key packages, preparing for an MLS identity reset. The client is responsible for regenerating MLS state and rejoining groups via external commits.

POST /api/v1/reset-account

Authentication: Required.

Request Body

None.

Response Body — ResetAccountResponse

Empty message.

Status Codes

CodeCondition
200 OKKey packages cleared.
401 UnauthorizedInvalid or expired token.

Notes

This endpoint only deletes the user’s key packages on the server. The client MUST then:

  1. Wipe local MLS state (identity, signing key, group state database).
  2. Generate a new MLS signing identity and key packages.
  3. Upload the new key packages with the new fingerprint.
  4. Rejoin each group via external commit.

Group membership on the server is NOT affected — the user remains a member of all their groups.

SSE Events

None (the IdentityResetEvent is emitted later, when the user performs external joins to rejoin groups).

User Endpoints

Look Up User by Username

Resolves a username to a user’s profile information.

GET /api/v1/users/{username}

Authentication: Required.

Path Parameters

ParameterTypeDescription
usernamestringThe username to look up.

Request Body

None.

Response Body — UserInfoResponse

FieldTypeDescription
user_idint64The user’s unique ID.
usernamestringThe user’s username.
aliasstringThe user’s display name (may be empty).
signing_key_fingerprintstringSHA-256 hex of the user’s MLS signing public key (may be empty).

Status Codes

CodeCondition
200 OKUser found.
401 UnauthorizedInvalid or expired token.
404 Not FoundNo user with that username exists.

SSE Events

None.


Look Up User by ID

Resolves a user ID to a user’s profile information.

GET /api/v1/users/by-id/{user_id}

Authentication: Required.

Path Parameters

ParameterTypeDescription
user_idint64The user ID to look up.

Request Body

None.

Response Body — UserInfoResponse

FieldTypeDescription
user_idint64The user’s unique ID.
usernamestringThe user’s username.
aliasstringThe user’s display name (may be empty).
signing_key_fingerprintstringSHA-256 hex of the user’s MLS signing public key (may be empty).

Status Codes

CodeCondition
200 OKUser found.
401 UnauthorizedInvalid or expired token.
404 Not FoundNo user with that ID exists.

SSE Events

None.

Key Package Endpoints

Upload Key Packages

Uploads one or more MLS key packages for the authenticated user.

POST /api/v1/key-packages

Authentication: Required.

Request Body — UploadKeyPackageRequest

The request supports two modes:

Batch mode (preferred):

FieldTypeRequiredDescription
entriesrepeated KeyPackageEntryYesList of key packages to upload.
signing_key_fingerprintstringNoSHA-256 hex of the user’s MLS signing public key (64 characters). Stored for TOFU verification.

Each KeyPackageEntry:

FieldTypeDescription
databytesRaw MLS key package bytes.
is_last_resortbooltrue for last-resort key packages, false for regular.

Legacy single-upload mode:

FieldTypeRequiredDescription
key_package_databytesYesA single key package (treated as a regular key package).
signing_key_fingerprintstringNoSHA-256 hex of the signing public key.

Response Body — UploadKeyPackageResponse

Empty message.

Validation

Each key package is validated:

  • Wire format: The first 2 bytes MUST be 0x0001 (MLS version 1.0). The next 2 bytes MUST be 0x0005 (wire format mls_key_package). Minimum 4 bytes.
  • Size: Each key package MUST be at most 16 KiB (16,384 bytes).
  • Server cap: The server stores at most 10 regular key packages per user. Excess uploads replace the oldest packages.
  • Last-resort: Uploading a last-resort package replaces any existing last-resort package for the user.

Status Codes

CodeCondition
200 OKKey packages uploaded successfully.
400 Bad RequestKey package fails wire format validation or exceeds size limit.
401 UnauthorizedInvalid or expired token.

SSE Events

None.


Fetch Key Package

Fetches (and consumes) a key package for the specified user. Used when inviting the user to a group.

GET /api/v1/key-packages/{user_id}

Authentication: Required.

Path Parameters

ParameterTypeDescription
user_idint64The user whose key package to fetch.

Request Body

None.

Response Body — GetKeyPackageResponse

FieldTypeDescription
key_package_databytesRaw MLS key package bytes.

Consumption Rules

  1. The oldest regular key package is returned and deleted from the server (FIFO).
  2. If no regular packages remain, the last-resort key package is returned but NOT deleted.
  3. If no key packages of any kind exist, the server returns 404.

Rate Limiting

This endpoint is rate-limited to 10 requests per minute per target user_id to prevent key package exhaustion attacks.

Status Codes

CodeCondition
200 OKKey package returned (and consumed if regular).
401 UnauthorizedInvalid or expired token.
404 Not FoundNo key packages available for this user.
429 Too Many RequestsRate limit exceeded.

SSE Events

None.

Group Endpoints

Create Group

Creates a new group with the authenticated user as the sole member and admin.

POST /api/v1/groups

Authentication: Required.

Request Body — CreateGroupRequest

FieldTypeRequiredDescription
group_namestringYesUnique group name. 1–64 characters, must start with ASCII alphanumeric, only letters/digits/underscores.
aliasstringNoDisplay name. Max 64 characters, no ASCII control characters.

Response Body — CreateGroupResponse

FieldTypeDescription
group_idint64The server-assigned unique group ID.

Notes

The creator is automatically added as a member with the admin role. No other members are added at creation time. Additional members are added via the escrow invite system.

After creating the group on the server, the client MUST:

  1. Create an MLS group locally.
  2. Upload the initial commit and GroupInfo via POST /api/v1/groups/{id}/commit, including the mls_group_id.

Status Codes

CodeCondition
201 CreatedGroup created successfully.
400 Bad RequestInvalid group name format, alias too long or contains control characters.
401 UnauthorizedInvalid or expired token.
409 ConflictGroup name already taken.

SSE Events

None.


List Groups

Lists all groups the authenticated user is a member of, including member lists and metadata.

GET /api/v1/groups

Authentication: Required.

Request Body

None.

Response Body — ListGroupsResponse

FieldTypeDescription
groupsrepeated GroupInfoList of groups the user belongs to.

Each GroupInfo:

FieldTypeDescription
group_idint64Server-assigned group ID.
aliasstringDisplay name (may be empty).
group_namestringUnique group name.
membersrepeated GroupMemberAll members of the group.
created_atuint64Unix timestamp of group creation (seconds).
mls_group_idstringHex-encoded MLS group identifier.
message_expiry_secondsint64Per-group message expiry (-1=disabled, 0=delete-after-fetch, >0=seconds).

Each GroupMember:

FieldTypeDescription
user_idint64Member’s user ID.
usernamestringMember’s username.
aliasstringMember’s display name (may be empty).
rolestringEither "admin" or "member".
signing_key_fingerprintstringSHA-256 hex of the member’s MLS signing public key (may be empty).

Status Codes

CodeCondition
200 OKSuccess.
401 UnauthorizedInvalid or expired token.

SSE Events

None.


Update Group

Updates a group’s alias, name, and/or message expiry settings.

PATCH /api/v1/groups/{group_id}

Authentication: Required. Authorization: Admin only.

Path Parameters

ParameterTypeDescription
group_idint64The group to update.

Request Body — UpdateGroupRequest

FieldTypeRequiredDescription
aliasstringNoNew display name. Max 64 characters, no ASCII control characters.
group_namestringNoNew group name. Same validation rules as creation.
message_expiry_secondsint64NoNew message expiry value. Only applied when update_message_expiry is true.
update_message_expiryboolNoMUST be true for the message_expiry_seconds field to take effect.

Message Expiry Validation

When update_message_expiry is true:

  • The value MUST be -1 (disabled), 0 (delete-after-fetch), or a positive integer (seconds).
  • If the server has a non-disabled retention policy (i.e., message_retention is not "-1"), the group expiry MUST NOT exceed the server retention value.

Response Body — UpdateGroupResponse

Empty message.

Status Codes

CodeCondition
200 OKGroup updated.
400 Bad RequestInvalid name/alias format, invalid expiry value, or expiry exceeds server retention.
401 UnauthorizedInvalid token, not a member, or not an admin.

SSE Events

  • GroupUpdateEvent with update_type: "group_settings" — sent to all group members, including the sender.

Get Group Info

Returns the stored MLS GroupInfo blob for a group. Required for external commits (account reset / rejoin).

GET /api/v1/groups/{group_id}/group-info

Authentication: Required. Authorization: Group member.

Path Parameters

ParameterTypeDescription
group_idint64The group whose GroupInfo to fetch.

Request Body

None.

Response Body — GetGroupInfoResponse

FieldTypeDescription
group_infobytesRaw MLS GroupInfo bytes.

Status Codes

CodeCondition
200 OKGroupInfo returned.
401 UnauthorizedInvalid token or not a group member.
404 Not FoundNo GroupInfo has been stored for this group.

SSE Events

None.


Get Retention Policy

Returns the server-wide retention policy and the group’s per-group expiry setting.

GET /api/v1/groups/{group_id}/retention

Authentication: Required. Authorization: Group member.

Path Parameters

ParameterTypeDescription
group_idint64The group to query.

Request Body

None.

Response Body — GetRetentionPolicyResponse

FieldTypeDescription
server_retention_secondsint64Server-wide retention (-1=disabled, 0=delete-after-fetch, >0=seconds).
group_expiry_secondsint64Per-group expiry (-1=disabled, 0=delete-after-fetch, >0=seconds).

Status Codes

CodeCondition
200 OKSuccess.
401 UnauthorizedInvalid token or not a group member.

SSE Events

None.

Member Management Endpoints

Invite to Group

Consumes key packages for the specified users, preparing for a group invitation. This is phase 1 of the escrow invite system.

POST /api/v1/groups/{group_id}/invite

Authentication: Required. Authorization: Admin only.

Path Parameters

ParameterTypeDescription
group_idint64The group to invite users to.

Request Body — InviteToGroupRequest

FieldTypeRequiredDescription
user_idsrepeated int64YesUser IDs to invite. Must contain at least one ID.

Response Body — InviteToGroupResponse

FieldTypeDescription
member_key_packagesmap<int64, bytes>Map of user_id to their consumed MLS key package bytes.

Notes

For each user ID in the request:

  • The user MUST exist (404 if any user is not found).
  • The user MUST NOT already be a group member (409 if already a member).
  • A key package MUST be available for the user (404 if no key packages).
  • If the user is the requester (self-invite), it is silently skipped.

After receiving the key packages, the client MUST build MLS commit and Welcome messages locally, then upload them via POST /api/v1/groups/{id}/escrow-invite for each invitee.

Status Codes

CodeCondition
200 OKKey packages returned for all valid invitees.
400 Bad RequestEmpty user_ids list.
401 UnauthorizedInvalid token, not a member, or not an admin.
404 Not FoundA specified user does not exist, or no key package is available.
409 ConflictA specified user is already a group member.

SSE Events

None (events are emitted during the escrow phase).


Remove Member

Removes a member from the group. The request includes the MLS removal commit.

POST /api/v1/groups/{group_id}/remove

Authentication: Required. Authorization: Admin only.

Path Parameters

ParameterTypeDescription
group_idint64The group to remove the member from.

Request Body — RemoveMemberRequest

FieldTypeRequiredDescription
user_idint64YesThe user to remove.
commit_messagebytesNoMLS commit for the removal. Stored as a group message if provided.
group_infobytesNoUpdated MLS GroupInfo. Stored for external commits if provided.

Response Body — RemoveMemberResponse

Empty message.

Status Codes

CodeCondition
200 OKMember removed.
400 Bad RequestTarget user is not a member of the group.
401 UnauthorizedInvalid token, not a member, or not an admin.
404 Not FoundTarget user does not exist.

SSE Events

  • MemberRemovedEvent — sent to all remaining group members AND the removed user.

Leave Group

The authenticated user voluntarily leaves a group.

POST /api/v1/groups/{group_id}/leave

Authentication: Required. Authorization: Group member.

Path Parameters

ParameterTypeDescription
group_idint64The group to leave.

Request Body — LeaveGroupRequest

FieldTypeRequiredDescription
commit_messagebytesNoMLS self-removal commit. Stored as a group message if provided.
group_infobytesNoUpdated MLS GroupInfo. Stored for external commits if provided.

Response Body — LeaveGroupResponse

Empty message.

Notes

After the server processes the request, the client SHOULD delete the local MLS group state for this group.

Status Codes

CodeCondition
200 OKSuccessfully left the group.
401 UnauthorizedInvalid token or not a member.

SSE Events

  • MemberRemovedEvent — sent to remaining group members only (NOT the departing user).

Promote Member

Promotes a group member to the admin role.

POST /api/v1/groups/{group_id}/promote

Authentication: Required. Authorization: Admin only.

Path Parameters

ParameterTypeDescription
group_idint64The group.

Request Body — PromoteMemberRequest

FieldTypeRequiredDescription
user_idint64YesThe member to promote.

Response Body — PromoteMemberResponse

Empty message.

Status Codes

CodeCondition
200 OKMember promoted to admin.
400 Bad RequestTarget user is not a member of the group.
401 UnauthorizedInvalid token, not a member, or not an admin.
404 Not FoundTarget user does not exist.
409 ConflictTarget user is already an admin.

SSE Events

  • GroupUpdateEvent with update_type: "role_change" — sent to all group members, including the sender.

Demote Member

Demotes an admin to the member role.

POST /api/v1/groups/{group_id}/demote

Authentication: Required. Authorization: Admin only.

Path Parameters

ParameterTypeDescription
group_idint64The group.

Request Body — DemoteMemberRequest

FieldTypeRequiredDescription
user_idint64YesThe admin to demote.

Response Body — DemoteMemberResponse

Empty message.

Notes

The server MUST reject the demotion of the last remaining admin of a group. Every group must have at least one admin at all times.

Status Codes

CodeCondition
200 OKAdmin demoted to member.
400 Bad RequestTarget user is not an admin, or is the last admin of the group.
401 UnauthorizedInvalid token, not a member, or not an admin.
404 Not FoundTarget user does not exist.

SSE Events

  • GroupUpdateEvent with update_type: "role_change" — sent to all group members, including the sender.

List Admins

Lists all members with the admin role in a group.

GET /api/v1/groups/{group_id}/admins

Authentication: Required. Authorization: Group member.

Path Parameters

ParameterTypeDescription
group_idint64The group to query.

Request Body

None.

Response Body — ListAdminsResponse

FieldTypeDescription
adminsrepeated GroupMemberList of group members with the admin role.

Status Codes

CodeCondition
200 OKSuccess.
401 UnauthorizedInvalid token or not a group member.

SSE Events

None.


External Join

Rejoins a group via MLS external commit after an account reset. The user must still be a server-side group member.

POST /api/v1/groups/{group_id}/external-join

Authentication: Required. Authorization: Group member (must be an existing server-side member).

Path Parameters

ParameterTypeDescription
group_idint64The group to rejoin.

Request Body — ExternalJoinRequest

FieldTypeRequiredDescription
commit_messagebytesNoMLS external commit. Stored as a group message if provided.
mls_group_idstringNoHex-encoded MLS group ID. Set on the server if not already set.

Response Body — ExternalJoinResponse

Empty message.

Prerequisites

  • The user MUST be an existing server-side member of the group.
  • A stored MLS GroupInfo MUST exist for the group (set by prior commit uploads, member removals, or leave operations).

Status Codes

CodeCondition
200 OKExternal join successful.
400 Bad RequestNo GroupInfo available for the group.
401 UnauthorizedInvalid token or not a group member.
404 Not FoundGroup does not exist.

SSE Events

  • IdentityResetEvent — sent to all other group members (excludes the rejoining user) if a commit message was provided.

Invite Endpoints

Escrow Invite

Uploads pre-built MLS commit, Welcome, and GroupInfo for a pending invitation. This is phase 2 of the escrow invite system, following the key package consumption in POST /api/v1/groups/{id}/invite.

POST /api/v1/groups/{group_id}/escrow-invite

Authentication: Required. Authorization: Admin only.

Path Parameters

ParameterTypeDescription
group_idint64The group the invite is for.

Request Body — EscrowInviteRequest

FieldTypeRequiredDescription
invitee_idint64YesThe user being invited (must not be 0).
commit_messagebytesYesMLS commit that adds the invitee. Must not be empty.
welcome_messagebytesYesMLS Welcome message for the invitee. Must not be empty.
group_infobytesYesUpdated MLS GroupInfo. Must not be empty.

Response Body — EscrowInviteResponse

Empty message.

Validation

  • The invitee MUST exist (404 if not found).
  • The invitee MUST NOT already be a group member (409 if already a member).
  • There MUST NOT already be a pending invite for the same (group_id, invitee_id) pair (409 if duplicate).

Status Codes

CodeCondition
200 OKInvite escrowed successfully.
400 Bad Requestinvitee_id is 0, or any required field is empty.
401 UnauthorizedInvalid token, not a member, or not an admin.
404 Not FoundInvitee does not exist.
409 ConflictInvitee is already a group member, or a pending invite already exists for this group+invitee.

SSE Events

  • InviteReceivedEvent — sent to the invitee only.

List Pending Invites (User)

Lists all pending invites addressed to the authenticated user.

GET /api/v1/invites

Authentication: Required.

Request Body

None.

Response Body — ListPendingInvitesResponse

FieldTypeDescription
invitesrepeated PendingInviteList of pending invites for the user.

Each PendingInvite:

FieldTypeDescription
invite_idint64Unique invite identifier.
group_idint64The group being invited to.
group_namestringThe group’s name.
group_aliasstringThe group’s display alias (may be empty).
inviter_usernamestringThe inviting user’s username.
inviter_idint64The inviting user’s ID.
invitee_idint64The invited user’s ID (the authenticated user).
created_atuint64Unix timestamp of invite creation (seconds).

Status Codes

CodeCondition
200 OKSuccess (may be empty).
401 UnauthorizedInvalid or expired token.

SSE Events

None.


Accept Invite

Accepts a pending invite. Atomically adds the user to the group and makes the escrowed Welcome available for processing.

POST /api/v1/invites/{invite_id}/accept

Authentication: Required. Authorization: The authenticated user MUST be the invite’s invitee_id.

Path Parameters

ParameterTypeDescription
invite_idint64The invite to accept.

Request Body

None.

Response Body — AcceptInviteResponse

Empty message.

Server Processing

Atomically within a single transaction:

  1. Delete the pending invite.
  2. Add the invitee to group_members with the member role.
  3. Store the escrowed Welcome message as a pending welcome.
  4. Store the escrowed commit as a group message (assigned the next sequence number).

Client Follow-up

After accepting, the client MUST:

  1. Fetch pending welcomes via GET /api/v1/welcomes.
  2. Process the MLS Welcome message to join the group.
  3. Acknowledge the welcome via POST /api/v1/welcomes/{id}/accept.
  4. Upload replacement key packages to replenish consumed ones.

Status Codes

CodeCondition
200 OKInvite accepted.
401 UnauthorizedInvalid token, or the authenticated user is not the invitee.
404 Not FoundInvite does not exist.

SSE Events

  • WelcomeEvent — sent to the invitee.
  • GroupUpdateEvent with update_type: "commit" — sent to existing group members (excludes the invitee).

Decline Invite

Declines a pending invite. The escrowed materials are discarded.

POST /api/v1/invites/{invite_id}/decline

Authentication: Required. Authorization: The authenticated user MUST be the invite’s invitee_id.

Path Parameters

ParameterTypeDescription
invite_idint64The invite to decline.

Request Body

None.

Response Body — DeclineInviteResponse

Empty message.

Notes

When an invite is declined, the inviter’s MLS group state already contains a phantom leaf for the invitee (added during the escrow commit). The InviteDeclinedEvent signals the inviter to perform a key rotation (empty commit) to evict this phantom leaf.

Status Codes

CodeCondition
200 OKInvite declined.
401 UnauthorizedInvalid token, or the authenticated user is not the invitee.
404 Not FoundInvite does not exist.

SSE Events

  • InviteDeclinedEvent — sent to the inviter only.

List Group Pending Invites

Lists all pending invites for a specific group.

GET /api/v1/groups/{group_id}/invites

Authentication: Required. Authorization: Admin only.

Path Parameters

ParameterTypeDescription
group_idint64The group to query.

Request Body

None.

Response Body — ListGroupPendingInvitesResponse

FieldTypeDescription
invitesrepeated PendingInviteList of pending invites for the group.

Status Codes

CodeCondition
200 OKSuccess (may be empty).
401 UnauthorizedInvalid token, not a member, or not an admin.

SSE Events

None.


Cancel Invite

Cancels a pending invite for a user. Available to group admins.

POST /api/v1/groups/{group_id}/cancel-invite

Authentication: Required. Authorization: Admin only.

Path Parameters

ParameterTypeDescription
group_idint64The group the invite belongs to.

Request Body — CancelInviteRequest

FieldTypeRequiredDescription
invitee_idint64YesThe user whose invite to cancel.

Response Body — CancelInviteResponse

Empty message.

Notes

Cancelling an invite triggers the same phantom leaf cleanup as declining: the original inviter receives an InviteDeclinedEvent and SHOULD perform a key rotation.

Status Codes

CodeCondition
200 OKInvite cancelled.
401 UnauthorizedInvalid token, not a member, or not an admin.
404 Not FoundNo pending invite exists for this group + invitee.

SSE Events

  • InviteCancelledEvent — sent to the invitee.
  • InviteDeclinedEvent — sent to the original inviter (triggers phantom leaf cleanup).

Welcome Endpoints

List Pending Welcomes

Lists all pending MLS Welcome messages for the authenticated user. Welcomes become available after the user accepts a group invite.

GET /api/v1/welcomes

Authentication: Required.

Request Body

None.

Response Body — ListPendingWelcomesResponse

FieldTypeDescription
welcomesrepeated PendingWelcomeList of pending Welcome messages.

Each PendingWelcome:

FieldTypeDescription
welcome_idint64Unique welcome identifier.
group_idint64The group this Welcome is for.
group_aliasstringThe group’s display alias (may be empty).
welcome_messagebytesRaw MLS Welcome message bytes.

Notes

The client MUST process each Welcome message through the MLS layer to join the group before acknowledging it via POST /api/v1/welcomes/{id}/accept.

Status Codes

CodeCondition
200 OKSuccess (may be empty).
401 UnauthorizedInvalid or expired token.

SSE Events

None.


Accept Welcome

Acknowledges that the client has processed a pending Welcome message. The server deletes the Welcome after acknowledgment.

POST /api/v1/welcomes/{welcome_id}/accept

Authentication: Required.

Path Parameters

ParameterTypeDescription
welcome_idint64The welcome to acknowledge.

Request Body

None.

Response

HTTP 204 No Content with no body.

Notes

The client SHOULD call this endpoint only after successfully processing the MLS Welcome message locally (i.e., after join_group() succeeds). If the client crashes between processing the Welcome and calling this endpoint, the Welcome remains available for re-fetch and re-processing.

After processing all Welcomes, the client SHOULD upload replacement key packages to replenish any that were consumed during the invitation process.

Status Codes

CodeCondition
204 No ContentWelcome acknowledged and deleted.
401 UnauthorizedInvalid or expired token.
404 Not FoundWelcome does not exist or does not belong to the authenticated user.

SSE Events

None.

Message Endpoints

Upload Commit

Uploads an MLS commit message and optional GroupInfo for a group. Used for group creation, key rotation, and other MLS state changes that are not member additions or removals (those use the invite and remove endpoints).

POST /api/v1/groups/{group_id}/commit

Authentication: Required. Authorization: Group member.

Path Parameters

ParameterTypeDescription
group_idint64The group the commit belongs to.

Request Body — UploadCommitRequest

FieldTypeRequiredDescription
commit_messagebytesNoMLS commit message. Stored as a group message (assigned next sequence number) if provided.
group_infobytesNoUpdated MLS GroupInfo. Stored for external commits if provided.
mls_group_idstringNoHex-encoded MLS group ID. Set on the server if not already set (typically on group creation).

Response Body — UploadCommitResponse

Empty message.

Notes

All database operations (message storage, GroupInfo update, MLS group ID setting) are performed atomically within a single transaction. SSE notifications are sent only after the transaction commits.

If the mls_group_id is already set on the group, a new value is ignored (the MLS group ID is set once, on group creation).

Status Codes

CodeCondition
200 OKCommit uploaded.
401 UnauthorizedInvalid token or not a group member.

SSE Events

  • GroupUpdateEvent with update_type: "commit" — sent to all group members except the sender (if a commit message was provided).

Send Message

Sends an encrypted MLS application message to a group.

POST /api/v1/groups/{group_id}/messages

Authentication: Required. Authorization: Group member.

Path Parameters

ParameterTypeDescription
group_idint64The group to send the message to.

Request Body — SendMessageRequest

FieldTypeRequiredDescription
mls_messagebytesYesEncrypted MLS application message (opaque ciphertext).

Response Body — SendMessageResponse

FieldTypeDescription
sequence_numuint64The server-assigned sequence number for this message within the group.

Notes

The server stores the message as an opaque blob. It does not decrypt, validate, or interpret the MLS ciphertext. Messages are assigned a monotonically increasing sequence number within each group.

For groups with a delete-after-fetch policy (message expiry = 0), the server updates the sender’s fetch watermark upon sending.

Status Codes

CodeCondition
200 OKMessage stored and sequence number assigned.
401 UnauthorizedInvalid token or not a group member.

SSE Events

  • NewMessageEvent — sent to all group members except the sender.

Fetch Messages

Fetches encrypted messages from a group, paginated by sequence number.

GET /api/v1/groups/{group_id}/messages

Authentication: Required. Authorization: Group member.

Path Parameters

ParameterTypeDescription
group_idint64The group to fetch messages from.

Query Parameters

ParameterTypeDefaultDescription
afterint640Fetch messages with sequence numbers strictly greater than this value.
limitint64100Maximum number of messages to return. Capped at 500.

Request Body

None.

Response Body — GetMessagesResponse

FieldTypeDescription
messagesrepeated StoredMessageList of messages, ordered by sequence number (ascending).

Each StoredMessage:

FieldTypeDescription
sequence_numuint64The message’s sequence number within the group.
sender_idint64The user ID of the sender.
mls_messagebytesThe encrypted MLS message (opaque ciphertext).
created_atuint64Unix timestamp of when the server received the message (seconds).

Notes

Messages include both application messages (chat) and commit messages (MLS state changes like member additions and removals). The client distinguishes between them during MLS decryption — application messages produce plaintext, while commits produce roster change information.

For groups with a delete-after-fetch policy (message expiry = 0), the server updates the fetching user’s watermark. Messages are deleted only after ALL group members have fetched past them.

Pagination

To fetch all messages since a known point:

  1. Set after to the highest sequence number the client has already processed.
  2. Repeat with after set to the highest sequence number in the response until the response contains fewer messages than the limit.

Status Codes

CodeCondition
200 OKSuccess (may return empty list if no new messages).
401 UnauthorizedInvalid token or not a group member.

SSE Events

None.

Event Stream

SSE Endpoint

Opens a persistent Server-Sent Events stream for real-time notifications.

GET /api/v1/events

Authentication: Required.

Response

  • Content-Type: text/event-stream
  • The connection remains open indefinitely.
  • The server sends periodic keep-alive comments (: lines) to prevent timeouts.

Wire Format

Each event is sent as an SSE data: line containing a hex-encoded serialized ServerEvent protobuf message:

data: <hex-encoded protobuf bytes>\n\n

Protobuf Definition

message ServerEvent {
  oneof event {
    NewMessageEvent new_message = 1;
    GroupUpdateEvent group_update = 2;
    WelcomeEvent welcome = 3;
    MemberRemovedEvent member_removed = 4;
    IdentityResetEvent identity_reset = 5;
    InviteReceivedEvent invite_received = 6;
    InviteDeclinedEvent invite_declined = 7;
    InviteCancelledEvent invite_cancelled = 8;
  }
}

Event Type Reference

NewMessageEvent

A new encrypted message was stored in a group.

message NewMessageEvent {
  int64 group_id = 1;
  uint64 sequence_num = 2;
  int64 sender_id = 3;
}

Recipients: All group members except the sender.

GroupUpdateEvent

Group state changed (member roster, metadata, or roles).

message GroupUpdateEvent {
  int64 group_id = 1;
  string update_type = 2;
}
update_typeTriggerRecipients
"commit"MLS commit stored (via invite acceptance)All members except sender
"member_profile"Member changed their profile aliasAll co-members including sender
"group_settings"Group alias/name/expiry changedAll members including sender
"role_change"Member promoted or demotedAll members including sender

WelcomeEvent

A pending Welcome message is ready for the user to process.

message WelcomeEvent {
  int64 group_id = 1;
  string group_alias = 2;
}

Recipients: The invitee only.

MemberRemovedEvent

A member was removed or left a group.

message MemberRemovedEvent {
  int64 group_id = 1;
  int64 removed_user_id = 2;
}

Recipients: All remaining group members AND the removed user.

IdentityResetEvent

A member rejoined a group with a new MLS identity via external commit.

message IdentityResetEvent {
  int64 group_id = 1;
  int64 user_id = 2;
}

Recipients: All group members except the user who reset.

InviteReceivedEvent

A pending invite was created for this user.

message InviteReceivedEvent {
  int64 invite_id = 1;
  int64 group_id = 2;
  string group_name = 3;
  string group_alias = 4;
  int64 inviter_id = 5;
}

Recipients: The invitee only.

InviteDeclinedEvent

An invitee declined a pending invite, or an admin cancelled an invite.

message InviteDeclinedEvent {
  int64 group_id = 1;
  int64 declined_user_id = 2;
}

Recipients: The original inviter only.

InviteCancelledEvent

An admin cancelled a pending invite for this user.

message InviteCancelledEvent {
  int64 group_id = 1;
}

Recipients: The invitee only.

Lag Handling

If a client’s SSE stream falls behind the server’s broadcast buffer, the server sends a transport-level notification:

event: lagged
data: <number of dropped events>

This is NOT a protobuf ServerEvent. It is a raw SSE event with the event type "lagged" and the data field containing the number of dropped events as a decimal string.

Clients SHOULD treat a lagged event as a signal to perform a full state refresh (re-fetch group lists, messages, and pending invites/welcomes).

Status Codes

CodeCondition
200 OKSSE stream established.
401 UnauthorizedInvalid or expired token.

Registration and Login

Registration Flow

Registration creates a new user account, establishes an MLS identity, and uploads initial key packages.

sequenceDiagram
    participant C as Client
    participant S as Server

    C->>S: POST /api/v1/register<br>RegisterRequest { username, password, alias?, token? }
    Note right of S: Validate username/password<br>Hash password (Argon2id)<br>Store user record
    S-->>C: 201 RegisterResponse { user_id }

    C->>S: POST /api/v1/login<br>LoginRequest { username, password }
    Note right of S: Verify password hash<br>Generate 256-bit token<br>Store session
    S-->>C: 200 LoginResponse { token, user_id, username }

    Note over C: Generate MLS signing key pair<br>Compute fingerprint = SHA-256(signing_public_key)<br>Generate 5 regular + 1 last-resort key packages

    C->>S: POST /api/v1/key-packages<br>UploadKeyPackageRequest { entries[6], signing_key_fingerprint }
    Note right of S: Validate wire format<br>Store key packages<br>Store fingerprint
    S-->>C: 200 OK

    Note over C: Save session locally:<br>server_url, token, user_id, username

    C->>S: GET /api/v1/events
    S-->>C: SSE stream established

Steps

  1. Register: The client sends the username, password, optional alias, and optional registration token. The server validates input, hashes the password, and creates the user record.

  2. Login: The client immediately logs in after registration. The server verifies the password, generates a session token, and returns it with the user ID.

  3. Generate MLS identity: The client generates an Ed448 signing key pair (part of the CURVE448_CHACHA cipher suite). The signing identity and secret key are persisted locally.

  4. Compute fingerprint: The client computes SHA-256(signing_public_key) and formats it as a 64-character lowercase hex string.

  5. Upload key packages: The client generates 5 regular and 1 last-resort key packages, then uploads them to the server along with the signing key fingerprint.

  6. Save session: The client persists the server URL, token, user ID, and username locally for future requests.

  7. Connect SSE: The client opens a persistent SSE connection for real-time event notifications.

Login Flow

Login follows the same sequence as registration, except step 1 (register) is skipped:

sequenceDiagram
    participant C as Client
    participant S as Server

    C->>S: POST /api/v1/login<br>LoginRequest { username, password }
    Note right of S: Verify password hash<br>Generate token
    S-->>C: 200 LoginResponse { token, user_id, username }

    Note over C: Load or generate MLS identity<br>Compute fingerprint<br>Generate key packages

    C->>S: POST /api/v1/key-packages
    Note right of S: Store key packages<br>Store fingerprint
    S-->>C: 200 OK

    Note over C: Save session locally

    C->>S: GET /api/v1/events
    S-->>C: SSE stream established

    C->>S: GET /api/v1/groups
    S-->>C: 200 ListGroupsResponse

    Note over C: Build group ID mapping<br>Fetch missed messages per group

On login, clients SHOULD:

  1. Load the existing MLS identity if available, or generate a new one.
  2. Upload fresh key packages (replenishing any that were consumed while offline).
  3. Fetch the group list to rebuild the server-group-ID to MLS-group-ID mapping.
  4. Fetch any missed messages for each group (using the locally stored last-seen sequence number).

Group Creation and Messaging

Group Creation Flow

Creating a group involves a server-side operation followed by MLS group initialization.

sequenceDiagram
    participant C as Creator
    participant S as Server

    C->>S: POST /api/v1/groups<br>CreateGroupRequest { group_name, alias? }
    Note right of S: Create group record<br>Add creator as admin
    S-->>C: 201 CreateGroupResponse { group_id }

    Note over C: MLS: create_group()<br>MLS: commit_builder().build()<br>MLS: apply_pending_commit()<br>→ commit, group_info, mls_group_id

    C->>S: POST /groups/{id}/commit<br>UploadCommitRequest { commit_message, group_info, mls_group_id }
    Note right of S: Store commit as msg seq 1<br>Store GroupInfo<br>Set mls_group_id
    S-->>C: 200 OK

    Note over C: Store mapping: group_id → mls_group_id

Steps

  1. Create on server: The client sends the group name and optional alias. The server creates the group record and adds the creator as the sole member with the admin role.

  2. Initialize MLS group: The client creates an MLS group locally. The initial MLS group has only the creator. The client builds an initial commit (which establishes the group’s cryptographic state) and extracts the GroupInfo.

  3. Upload commit: The client uploads the commit, GroupInfo, and the hex-encoded MLS group ID. The commit is stored as the first message (sequence number 1). The mls_group_id is recorded in the group’s server record.

  4. Store mapping: The client stores the mapping from the server’s group_id to the MLS mls_group_id for future operations.

Sending a Message

sequenceDiagram
    participant Sn as Sender
    participant S as Server
    participant R as Recipient

    Note over Sn: MLS: encrypt_application_message(plaintext)<br>→ ciphertext

    Sn->>S: POST /groups/{id}/messages<br>SendMessageRequest { mls_message: ciphertext }
    Note right of S: Store ciphertext blob<br>Assign sequence_num
    S-->>Sn: 200 SendMessageResponse { sequence_num }
    S--)R: SSE: NewMessageEvent<br>{ group_id, seq, sender_id }

Steps

  1. Encrypt: The client encrypts the plaintext message using MLS, producing an opaque ciphertext blob.

  2. Send: The client sends the ciphertext to the server. The server stores it as an opaque blob and assigns a monotonically increasing sequence number.

  3. Notify: The server broadcasts a NewMessageEvent to all group members except the sender, indicating that a new message is available.

Receiving Messages

sequenceDiagram
    participant S as Server
    participant R as Recipient

    S--)R: SSE: NewMessageEvent<br>{ group_id, seq, sender_id }

    R->>S: GET /groups/{id}/messages?after={last_seen_seq}
    S-->>R: 200 GetMessagesResponse<br>{ messages: [{ seq, sender_id, mls_message, created_at }] }

    Note over R: MLS: process_incoming_message<br>→ plaintext<br>Resolve sender_id → display name<br>Display message

Steps

  1. Receive notification: The client receives a NewMessageEvent via SSE, indicating a new message is available in a group.

  2. Fetch messages: The client fetches messages with sequence numbers after its last known sequence number using the after query parameter.

  3. Decrypt: For each message, the client processes it through the MLS layer:

    • Application messages produce decrypted plaintext (chat messages).
    • Commit messages produce roster change information (members added/removed, key rotations).
    • Failed decryption produces an error reason (epoch evicted, key missing, etc.).
  4. Resolve sender: The client maps the sender_id to a display name using its local member cache or the user lookup endpoint.

  5. Update tracking: The client updates its last-seen sequence number to avoid re-fetching processed messages.

Commit Messages vs. Application Messages

Both commits and application messages are stored in the same message table with sequential sequence numbers. The client distinguishes between them during MLS decryption:

  • Application messages: process_incoming_message() returns decrypted plaintext bytes. These are user-visible chat messages.
  • Commit messages: process_incoming_message() returns a commit result with information about roster changes (members added, members removed). Clients typically display these as system messages (e.g., “Alice joined the group”, “Group keys updated”).

An empty commit (no proposals, just epoch advancement) indicates a key rotation for forward secrecy.

Escrow Invite System

Overview

Conclave uses a two-phase escrow invite system for all post-creation member additions. This system requires the target user to explicitly accept or decline an invitation before being added to a group. This prevents invite spam and gives users control over which groups they join.

The system works by having the inviter pre-build the MLS commit and Welcome message, then uploading them to the server for escrow. The target can inspect the invitation and choose to accept (triggering group join) or decline (discarding the escrowed materials and triggering phantom leaf cleanup).

Full Invite Flow

Phase 1: Key Package Consumption

sequenceDiagram
    participant A as Admin
    participant S as Server

    A->>S: POST /groups/{id}/invite<br>InviteToGroupRequest { user_ids: [target_id] }
    Note right of S: Validate user exists<br>Validate not already member<br>Consume key package (FIFO)
    S-->>A: 200 InviteToGroupResponse<br>{ member_key_packages: { target_id: kp_bytes } }

The admin requests key packages for the target users. The server validates each user, checks they are not already group members, and returns one consumed key package per user.

Phase 2: Escrow

sequenceDiagram
    participant A as Admin
    participant S as Server
    participant T as Target

    Note over A: MLS: commit_builder().add_member(target_kp).build()<br>MLS: apply_pending_commit()<br>→ commit, welcome, group_info

    A->>S: POST /{id}/escrow-invite<br>EscrowInviteRequest { invitee_id, commit_message, welcome_message, group_info }
    Note right of S: Store in pending_invites
    S-->>A: 200 OK
    S--)T: SSE: InviteReceivedEvent<br>{ invite_id, group_id, group_name, inviter_id }

The admin builds the MLS commit (which adds the target as a new leaf) and the corresponding Welcome message. These are uploaded to the server’s invite escrow along with the updated GroupInfo.

At this point, the admin’s local MLS group state has already advanced — the target appears as a member in the admin’s MLS tree. However, the target has not yet joined and the server has not yet added them to the group membership.

The target receives an InviteReceivedEvent via SSE.

Phase 3a: Accept Path

sequenceDiagram
    participant T as Target
    participant S as Server
    participant A as Admin

    T->>S: POST /invites/{id}/accept
    Note right of S: Atomic transaction:<br>Delete pending_invite<br>Add to group_members<br>Store welcome → pending<br>Store commit as message
    S-->>T: 200 AcceptInviteResponse
    S--)T: SSE: WelcomeEvent (to target)
    S--)A: SSE: GroupUpdateEvent (to existing members)

    T->>S: GET /api/v1/welcomes
    S-->>T: ListPendingWelcomesResponse<br>{ welcomes: [{ welcome_id, group_id, welcome_message }] }

    Note over T: MLS: join_group(welcome_message)<br>→ mls_group_id

    T->>S: POST /welcomes/{id}/accept
    Note right of S: Delete pending welcome
    S-->>T: 204 No Content

    Note over T: Upload replacement key package
    T->>S: POST /api/v1/key-packages
    S-->>T: 200 OK

When the target accepts:

  1. The server atomically: deletes the pending invite, adds the target to group members, stores the escrowed Welcome as a pending welcome, and stores the escrowed commit as a group message.
  2. The target fetches and processes the Welcome message through the MLS layer to join the group.
  3. The target acknowledges the Welcome and uploads a replacement key package.

Phase 3b: Decline Path

sequenceDiagram
    participant T as Target
    participant S as Server
    participant A as Admin

    T->>S: POST /invites/{id}/decline
    Note right of S: Delete pending_invite
    S-->>T: 200 DeclineInviteResponse
    S--)A: SSE: InviteDeclinedEvent<br>{ group_id, declined_user_id }

    Note over A: Auto-rotate keys to evict phantom leaf

    A->>S: POST /{id}/commit<br>(empty commit = key rotation)
    S-->>A: 200 OK

When the target declines:

  1. The server deletes the pending invite and the escrowed materials.
  2. The inviter receives an InviteDeclinedEvent via SSE.
  3. The inviter’s client automatically performs a key rotation (empty MLS commit) to evict the phantom leaf that was added to the MLS tree during phase 2.

Invite Cancellation

An admin can cancel a pending invite:

sequenceDiagram
    participant A as Admin
    participant S as Server
    participant T as Target

    A->>S: POST /{id}/cancel-invite<br>CancelInviteRequest { invitee_id }
    Note right of S: Delete pending_invite
    S-->>A: 200 OK
    S--)T: SSE: InviteCancelledEvent (to target)
    S--)A: SSE: InviteDeclinedEvent (to original inviter, triggers key rotation)

Cancellation triggers the same phantom leaf cleanup as declining: the original inviter receives an InviteDeclinedEvent and performs a key rotation.

Constraints

  • A user MUST NOT have more than one pending invite per group. The server enforces a unique constraint on (group_id, invitee_id).
  • Pending invites have a configurable TTL (default 7 days). Expired invites are cleaned up by the server’s background task.
  • The inviter MUST be an admin of the group.
  • The invitee MUST exist and MUST NOT already be a group member.

Phantom Leaf Problem

When the admin builds the MLS commit during phase 2, the MLS group state advances locally — the target appears as a new leaf in the MLS tree. If the target declines (or the invite is cancelled), this leaf is “phantom”: it exists in the MLS tree but the user never actually joined.

The phantom leaf MUST be cleaned up via key rotation (an empty commit that advances the epoch). This is triggered automatically when the inviter’s client receives an InviteDeclinedEvent.

If the phantom leaf is not cleaned up, subsequent MLS operations may fail or produce unexpected behavior, as the MLS tree contains a member who cannot participate in the group.

Member Removal and Departure

Admin Removal (Kick)

An admin can remove a member from a group. This involves both an MLS commit (to remove the member from the MLS tree) and a server-side membership update.

sequenceDiagram
    participant A as Admin
    participant S as Server
    participant R as Removed

    Note over A: MLS: find_member_index(user_id) → leaf_index<br>MLS: commit_builder().remove_member(leaf_index).build()<br>→ commit, group_info

    A->>S: POST /groups/{id}/remove<br>RemoveMemberRequest { user_id, commit_message, group_info }
    Note right of S: Verify admin role<br>Store commit as message<br>Store GroupInfo<br>Remove from group_members
    S-->>A: 200 OK
    S--)R: SSE: MemberRemovedEvent { group_id, removed_id }<br>(to remaining AND removed)

Steps

  1. Find member index: The admin’s client scans the local MLS group tree to find the target user’s leaf index by matching the user_id embedded in their BasicCredential.

  2. Build removal commit: The admin builds an MLS commit with a Remove proposal targeting the member’s leaf index. This produces a commit message and updated GroupInfo.

  3. Upload removal: The admin sends the removal request to the server with the user ID, commit message, and GroupInfo. The server:

    • Verifies the requester is an admin.
    • Stores the commit as a group message (assigned the next sequence number).
    • Stores the updated GroupInfo (for potential external rejoin).
    • Removes the target from the group membership table.
  4. Notify: The server broadcasts a MemberRemovedEvent to all remaining members AND the removed user.

Client Behavior — Remaining Members

When a client receives a MemberRemovedEvent where removed_user_id does not match their own user ID:

  1. Fetch new messages (including the removal commit) via GET /groups/{id}/messages.
  2. Process the commit through MLS to advance the group epoch.
  3. Refresh the member list.

Client Behavior — Removed User

When a client receives a MemberRemovedEvent where removed_user_id matches their own user ID:

  1. Remove the group from the local display.
  2. Delete the local MLS group state for that group.

Voluntary Departure (Leave)

A member can voluntarily leave a group. The flow is similar to admin removal, but the member removes themselves.

sequenceDiagram
    participant M as Member
    participant S as Server
    participant O as Others

    Note over M: MLS: leave_group(mls_group_id)<br>→ commit, group_info<br>(may be None if MLS self-remove not supported)

    M->>S: POST /groups/{id}/leave<br>LeaveGroupRequest { commit_message?, group_info? }
    Note right of S: Store commit (if provided)<br>Store GroupInfo (if provided)<br>Remove from group_members
    S-->>M: 200 OK
    S--)O: SSE: MemberRemovedEvent<br>(to remaining members only)

    Note over M: Delete local MLS group state<br>Remove from local group mapping

Steps

  1. Build self-removal commit: The client attempts to build an MLS self-removal commit. If the MLS library does not support self-removal, the commit fields may be empty.

  2. Send leave request: The client sends the leave request with the optional commit and GroupInfo. The server:

    • Stores the commit as a group message if provided.
    • Stores the GroupInfo if provided (for external rejoin by other members).
    • Removes the user from the group membership table.
  3. Clean up locally: The client deletes its local MLS group state for the group and removes it from the group mapping.

  4. Notify: The server broadcasts a MemberRemovedEvent to remaining members only (the departing user does NOT receive it).

Remaining Members

Remaining members process the leave commit through MLS (if one was provided) to advance the group epoch and update the MLS tree.

Account Reset and External Rejoin

Overview

An account reset allows a user to regenerate their MLS cryptographic identity and rejoin all their groups with fresh key material. This is used when:

  • The user’s local MLS state is corrupted or lost.
  • The user suspects their signing key has been compromised.
  • The user wants to rotate their long-lived signing key.

After a reset, the user has a new signing key pair, a new fingerprint, and fresh MLS group state for all groups. Other group members are notified and will see a key change warning.

Account Reset Flow

sequenceDiagram
    participant U as User
    participant S as Server

    U->>S: GET /api/v1/groups
    S-->>U: 200 ListGroupsResponse (authoritative list of groups)

    Note over U: Collect old leaf indices from local MLS state (best-effort)

    U->>S: POST /api/v1/reset-account
    Note right of S: Delete all key packages<br>(membership unchanged)
    S-->>U: 200 OK

    Note over U: Wipe local MLS state:<br>signing identity, signing secret key, MLS state database<br>Generate new signing key pair<br>Compute new fingerprint<br>Generate 5 regular + 1 last-resort key packages

    U->>S: POST /api/v1/key-packages<br>(entries, new fingerprint)
    Note right of S: Store new key packages<br>Store new fingerprint
    S-->>U: 200 OK

Steps 1–4: Reset Identity

  1. Fetch group list: The client fetches the authoritative group list from the server. This is the source of truth for which groups the user belongs to (local state may be incomplete).

  2. Collect old leaf indices: For each group, the client attempts to find its own leaf index in the local MLS state. This is best-effort — if local state is corrupted or missing, the old index may not be available.

  3. Server reset: The client calls POST /api/v1/reset-account. The server deletes all of the user’s key packages. Group membership is NOT affected.

  4. Regenerate identity: The client wipes all local MLS state (signing identity, signing key, MLS database) and generates a completely new signing key pair. New key packages are generated and uploaded with the new fingerprint.

Group Rejoin Flow

After resetting the identity, the client must rejoin each group via MLS external commits:

sequenceDiagram
    participant U as User
    participant S as Server
    participant M as Members

    Note over U: For each group:

    U->>S: GET /groups/{id}/group-info
    S-->>U: GetGroupInfoResponse { group_info }

    Note over U: MLS: external_commit_builder()<br>.with_removal(old_index?)<br>.build(group_info)<br>→ new_mls_group_id, commit

    U->>S: POST /groups/{id}/external-join<br>ExternalJoinRequest { commit_message, mls_group_id }
    Note right of S: Store commit as message<br>Update mls_group_id
    S-->>U: 200 OK
    S--)M: SSE: IdentityResetEvent<br>{ group_id, user_id }

    Note over U: Store new mapping: group_id → new_mls_group_id

Steps 5–7: Rejoin Groups

  1. Fetch GroupInfo: For each group, the client fetches the stored MLS GroupInfo. This provides the public group state needed for the external commit.

  2. Build external commit: The client builds an MLS external commit (RFC 9420 Section 12.3.2) to join the group with its new identity:

    • If the old leaf index is known, the external commit includes a self-removal proposal to remove the stale leaf.
    • If the old leaf index is unknown, the external commit adds the new identity without removing the old one. The old leaf becomes an unreachable phantom and will be cleaned up when another member processes the commit.
  3. Upload external join: The client uploads the external commit. The server stores it as a group message and broadcasts an IdentityResetEvent to other members.

Error Handling

Each group rejoin is attempted independently. If a rejoin fails (e.g., no GroupInfo available, MLS error), the client SHOULD continue with other groups and report the failures. Common error scenarios:

  • No GroupInfo available (404): The group has no stored GroupInfo. This happens if no commits, removals, or leaves have occurred since the group was created. The user cannot rejoin this group without another member uploading fresh GroupInfo.
  • DuplicateLeafData: The MLS library may reject the external commit if the new identity collides with existing leaf data. The client SHOULD retry with the conflicting leaf index specified in the removal.

Other Members’ Perspective

When a user resets their account and rejoins via external commit:

  1. Other members receive IdentityResetEvent via SSE.
  2. Members fetch the external commit from the message stream and process it through MLS. The MLS tree is updated with the user’s new leaf.
  3. The user’s signing key fingerprint has changed. Other clients detect this via the TOFU verification system and display a [!] warning next to the user’s name.
  4. Members SHOULD verify the new fingerprint out-of-band (e.g., via /verify) to confirm the reset was legitimate.

Key Rotation

Overview

Key rotation is the process of advancing a group’s MLS epoch by building an empty commit (a commit with no proposals). This provides forward secrecy: after the epoch advances, the old key material is discarded and cannot be used to decrypt future messages.

Key Rotation Flow

sequenceDiagram
    participant M as Member
    participant S as Server
    participant O as Others

    Note over M: MLS: commit_builder().build()<br>(empty commit — no proposals)<br>→ commit, group_info

    M->>S: POST /groups/{id}/commit<br>UploadCommitRequest { commit_message, group_info }
    Note right of S: Store commit as message<br>Store GroupInfo
    S-->>M: 200 OK
    S--)O: SSE: GroupUpdateEvent<br>{ group_id, update_type: "commit" }

Steps

  1. Build empty commit: The client builds an MLS commit with no proposals. This advances the epoch and rotates the group’s key material.

  2. Upload commit: The client uploads the commit and updated GroupInfo to the server.

  3. Notify: The server broadcasts a GroupUpdateEvent with update_type: "commit" to all group members except the sender.

  4. Process commit: Other members fetch the commit via the messages endpoint and process it through MLS. The epoch advances and key material is rotated.

When Key Rotation Occurs

After Declined Invite (Phantom Leaf Cleanup)

When an admin invites a user to a group (via the escrow invite system), the admin’s MLS group state immediately advances — the invitee appears as a new leaf in the MLS tree. If the invitee declines the invite, this leaf becomes a phantom: it exists in the MLS tree but the user never actually joined.

When the admin receives an InviteDeclinedEvent, the client SHOULD automatically perform a key rotation to evict the phantom leaf. The empty commit advances the epoch and cleans up the stale tree state.

This also applies when an admin cancels a pending invite — the original inviter receives an InviteDeclinedEvent and SHOULD perform the same key rotation.

Explicit User Command

Users may explicitly request key rotation (e.g., via a /rotate command) to achieve forward secrecy at will. This is useful after sensitive discussions or when a user suspects their session may have been compromised.

Periodic Rotation (Optional)

Client implementations MAY implement periodic automatic key rotation as a policy. However, frequent rotation increases the rate of epoch advancement, which can cause decryption failures for offline members who exceed the epoch retention limit.

Forward Secrecy Properties

After a key rotation:

  • Messages encrypted under previous epochs cannot be decrypted by anyone who obtains the current epoch’s key material.
  • The old key material is discarded from the MLS state (subject to the epoch retention limit).
  • Any compromise of current keys does not reveal past messages.

Impact on Offline Members

Key rotation advances the epoch. Offline members must retain key material for prior epochs to decrypt messages sent during those epochs. If a member’s epoch retention limit (recommended: 16 epochs) is exceeded, they will be unable to decrypt messages from evicted epochs.

Frequent key rotation should be balanced against the risk of epoch exhaustion for offline members.

Retention and Expiration

Overview

Conclave provides two layers of message lifecycle control:

  1. Server-wide retention: A global maximum message age configured by the server operator.
  2. Per-group expiration: A stricter per-group policy set by group admins.

These two layers interact to determine the effective expiry for each group’s messages.

Server-Wide Retention

The server operator configures a global retention policy via the message_retention configuration field. This sets the maximum age for messages across all groups.

ValueBehavior
"-1" (default)Disabled — messages are kept indefinitely.
"0"Delete-after-fetch — messages are deleted after all group members have fetched them.
Duration string (e.g., "30d")Messages older than this duration are periodically deleted.

See Duration Format for the duration string syntax.

Per-Group Expiration

Group admins can set a per-group message expiry via PATCH /api/v1/groups/{id} with the message_expiry_seconds field and update_message_expiry: true.

ValueBehavior
-1 (default)Disabled — inherits the server-wide retention policy.
0Delete-after-fetch — messages are deleted after all group members have fetched them.
Positive integerMessages older than this many seconds are periodically deleted.

Constraint

When the server has a non-disabled retention policy (not "-1"), the per-group expiry MUST NOT exceed the server retention value. The server rejects such requests with HTTP 400.

Effective Expiry Calculation

The effective expiry for a group is determined by combining the server-wide retention and per-group expiration:

Server RetentionGroup ExpiryEffective Expiry
Disabled (-1)Disabled (-1)Disabled — messages kept indefinitely
Disabled (-1)Positive NN seconds
Disabled (-1)0Delete-after-fetch
Positive SDisabled (-1)S seconds
Positive SPositive Nmin(S, N) seconds (stricter wins)
0AnyDelete-after-fetch (0 always wins)
Any0Delete-after-fetch (0 always wins)

In summary:

  • If either layer is 0 (delete-after-fetch), the effective expiry is 0.
  • If both layers have positive values, the smaller (stricter) value applies.
  • If one layer is disabled (-1), the other layer’s value is used.
  • If both are disabled, messages are kept indefinitely.

Delete-After-Fetch Mode

When a group’s effective expiry is 0, the server uses a watermark-based deletion strategy:

  1. The server maintains a fetch watermark per member per group, tracking the highest sequence number each member has fetched.
  2. When a member fetches messages via GET /api/v1/groups/{id}/messages, their watermark is updated.
  3. When a member sends a message via POST /api/v1/groups/{id}/messages, their watermark is also updated (the sender has already “seen” their own message).
  4. A message is deleted only after ALL current group members have fetched past its sequence number (i.e., the minimum watermark across all members exceeds the message’s sequence number).

This ensures no group member misses a message, while still deleting messages as soon as possible after universal fetch.

Background Cleanup

The server runs a periodic background task to enforce retention and expiration policies. The task runs at a configurable interval (default: 1 hour, configured via cleanup_interval).

The cleanup task performs:

  1. Time-based deletion: For groups with a positive effective expiry, messages with created_at older than the expiry duration are deleted.
  2. Watermark-based deletion: For groups with effective expiry 0, messages whose sequence number falls below the minimum fetch watermark across all group members are deleted.
  3. Session cleanup: Expired session tokens are deleted.
  4. Invite cleanup: Pending invites older than the configured invite TTL (default: 7 days) are deleted.

Client-Side Behavior

Local Expiry Enforcement

After fetching messages, clients SHOULD also delete locally stored messages that exceed the group’s effective expiry from their local message history. This ensures consistent behavior between server and client storage.

Display Timer

For groups with active expiry policies, clients SHOULD run a periodic timer (e.g., every 1 second) to remove expired messages from the in-memory display. This provides real-time visual feedback as messages expire.

Querying the Policy

Clients can query the retention policy for a specific group via GET /api/v1/groups/{id}/retention, which returns both the server-wide retention and the per-group expiry settings.

Security Properties

End-to-End Encryption

All application messages in Conclave are MLS PrivateMessage ciphertexts. The server stores and forwards opaque encrypted blobs without any ability to access the plaintext content.

The server MUST NOT attempt to decrypt, interpret, or validate message contents. Implementations MUST ensure that no plaintext message data is logged, cached, or otherwise persisted on the server.

Forward Secrecy

MLS provides forward secrecy through key ratcheting. Each epoch has distinct key material, and advancing the epoch discards old keys. This means:

  • Compromising a user’s current keys does NOT reveal past messages.
  • Key rotation (empty commits) explicitly advances the epoch to discard old key material.
  • Regular application messages within an epoch use per-message keys derived from the epoch’s key schedule.

Post-Compromise Security

MLS commit operations rotate group key material, providing post-compromise security:

  • After a member’s keys are compromised, a subsequent commit operation (member add/remove, key rotation) generates new key material that the attacker cannot derive.
  • The group recovers security as soon as any non-compromised member performs a commit.

Authentication Security

Password Storage

Passwords MUST be hashed using Argon2id with random salts before storage. Argon2id provides resistance against both GPU-based and side-channel attacks.

Session Tokens

  • Tokens MUST be 256-bit cryptographically random values (hex-encoded, 64 characters).
  • Tokens MUST be generated using a cryptographically secure random number generator (e.g., OS entropy source).
  • The server SHOULD store a hash (SHA-256) of the token rather than the raw token value.
  • Tokens MUST have a configurable expiry (default: 7 days).
  • Tokens MUST be revocable via the logout endpoint.

Timing Attack Mitigation

The login endpoint MUST perform timing-equalized password verification:

  • When the requested username exists: verify the provided password against the stored hash.
  • When the requested username does NOT exist: verify the provided password against a precomputed dummy Argon2id hash.

This ensures both code paths have equivalent computational profiles, preventing username enumeration via timing analysis.

Registration Token Security

When registration is token-gated, the registration token comparison MUST use constant-time equality (e.g., subtle::ConstantTimeEq or equivalent) to prevent timing-based token guessing.

Transport Security

TLS

The server supports two transport modes:

  1. Native TLS: Direct HTTPS with server-terminated TLS.
  2. Plain HTTP: For deployments behind a TLS-terminating reverse proxy.

Clients MUST validate the server’s TLS certificate when connecting over HTTPS. Implementations MAY provide an option to accept invalid certificates for development/testing purposes, but this option MUST NOT be enabled in production deployments.

HTTP/2

All communication uses HTTP/2, which provides:

  • Multiplexed streams over a single TCP connection.
  • Header compression.
  • Binary framing.

MLS Identity Key Protection

The MLS signing key pair is the most sensitive credential in the system. Compromise of the signing secret key allows an attacker to:

  • Impersonate the user in MLS operations.
  • Sign commits and messages as the user.
  • Generate key packages on behalf of the user.

Clients MUST protect the signing secret key with appropriate filesystem permissions. The key SHOULD be stored with permissions restricting access to the owning user only (e.g., 0600 on Unix systems).

Transactional Integrity

Critical server operations that modify multiple database tables MUST be performed atomically within a single database transaction:

  • Invite acceptance: Deleting the pending invite, adding the member, storing the welcome, and storing the commit MUST all succeed or all fail.
  • Commit upload: Storing the commit message and updating the GroupInfo MUST be atomic.

SSE notifications MUST be sent only after the transaction commits, ensuring clients never receive notifications for partially applied state changes.

All post-creation member additions require the target’s explicit acceptance. There is no way to add a user to a group without their consent:

  1. The admin pre-builds the MLS materials and uploads them to escrow.
  2. The target receives a notification and can inspect the invitation.
  3. The target explicitly accepts or declines.
  4. Only on acceptance is the target added to the group.

This prevents invite spam and respects user autonomy.

Admin Role Authorization

Group management operations (invite, remove, update group settings, promote, demote, cancel invite) require the admin role.

  • The group creator is automatically assigned the admin role.
  • New members receive the member role by default.
  • Admins can promote members and demote other admins.
  • The server MUST reject demotion of the last remaining admin, ensuring every group always has at least one admin.

External Join Authorization

The POST /api/v1/groups/{id}/external-join endpoint requires:

  • The user MUST already be a server-side member of the group.
  • A stored MLS GroupInfo MUST exist for the group.

Since GroupInfo is only stored by authorized group members via commit upload, member removal, or leave operations, this prevents unauthorized users from joining arbitrary groups via external commit.

Threat Model and Mitigations

Threat: Username Enumeration

Attack: An attacker probes the login endpoint with different usernames to determine which accounts exist, by measuring response time differences.

Mitigation: The login endpoint performs timing-equalized password verification. When the requested username does not exist, the server runs verify_password() against a precomputed dummy Argon2id hash, ensuring both code paths (valid user, invalid user) have equivalent computational profiles.

Threat: Key Package Exhaustion

Attack: An attacker rapidly consumes a target user’s regular key packages, forcing fallback to the reusable last-resort package. The last-resort package carries security risks per RFC 9420 Section 16.8 (e.g., replay of group additions).

Mitigation:

  • The GET /api/v1/key-packages/{user_id} endpoint is rate-limited to 10 requests per minute per target user.
  • Last-resort key packages are never deleted — they ensure the user is always reachable even if all regular packages are exhausted.
  • Clients automatically replenish key packages after consumption.

Threat: MLS Wire Format Injection

Attack: An attacker uploads malformed or non-MLS data as key packages, which could cause failures when other users attempt to use those packages for group operations.

Mitigation: The server validates all uploaded key packages for MLS wire format correctness:

  • MLS version MUST be 0x0001 (MLS 1.0).
  • Wire format type MUST be 0x0005 (mls_key_package).
  • Size MUST be between 4 bytes and 16 KiB.

This prevents obviously malformed data from being stored. Note that the server does NOT perform full cryptographic validation — that responsibility falls on the consuming client.

Threat: Registration Abuse

Attack: An attacker creates many accounts to spam users or exhaust server resources.

Mitigation:

  • Server operators can disable public registration (registration_enabled: false).
  • When registration is disabled, a valid registration token is required. Token comparison uses constant-time equality to prevent timing-based guessing.
  • The registration token format is restricted to [a-zA-Z0-9_-], validated at config load time.

Threat: Invite Spam

Attack: A malicious admin adds users to groups without their consent, flooding their client with unwanted group memberships.

Mitigation: The two-phase escrow invite system requires explicit acceptance:

  1. The admin can only upload an invite to escrow — the target is not added to the group.
  2. The target receives a notification and can inspect the invitation.
  3. The target must explicitly accept to join, or decline to discard the invite.
  4. The server enforces a unique constraint on (group_id, invitee_id) — only one pending invite per user per group.
  5. Pending invites have a configurable TTL (default: 7 days) and are cleaned up by the server’s background task.

Threat: Unauthorized Group Access

Attack: A user who was never a group member attempts to join via external commit.

Mitigation: The external join endpoint requires:

  • The user MUST be an existing server-side member of the group.
  • A stored MLS GroupInfo MUST exist, and GroupInfo is only set by authorized members through commit uploads, member removals, or leave operations.

Threat: Key Substitution Attack

Attack: A compromised server substitutes a user’s signing public key with one controlled by the attacker, allowing the attacker to impersonate the user.

Mitigation (partial):

  • The TOFU fingerprint verification system stores the first-seen fingerprint for each user. Any subsequent change triggers a [!] warning.
  • Users can perform out-of-band fingerprint verification via /verify to confirm fingerprints through a trusted channel, eliminating the first-contact vulnerability.

Limitation: TOFU does not protect against first-contact attacks. If the server is compromised during the initial key exchange, it could substitute a different key before the client stores the fingerprint.

Threat: Message Replay

Attack: An attacker replays previously observed MLS ciphertexts to inject duplicate messages.

Mitigation: MLS provides built-in replay protection:

  • Each epoch has unique key material.
  • Per-message keys are derived from the epoch’s key schedule and consumed on use.
  • The MLS process_incoming_message() function rejects replayed messages.

Additionally, the server assigns monotonically increasing sequence numbers to messages. Clients track their last-seen sequence number and only fetch messages with higher sequence numbers.

Threat: Server Compromise

If an attacker compromises the server, they can:

  • NOT read message contents — all messages are E2E encrypted MLS ciphertexts.
  • NOT forge messages — they lack users’ MLS signing keys.
  • Observe metadata: Who communicates with whom, group membership, message timing and frequency, IP addresses.
  • Substitute keys for new contacts: Perform first-contact key substitution before TOFU fingerprints are stored (mitigated by out-of-band /verify).
  • Block messages: Prevent message delivery or selectively drop events.
  • Disrupt service: Delete groups, remove members, or take the server offline.

What Server Compromise Does NOT Allow

The fundamental security guarantee is that message content remains confidential even if the server is fully compromised, because the server never has access to MLS key material. The server is an untrusted relay by design.

Trust Model Limitations

Conclave currently uses BasicCredential with BasicIdentityProvider, which does NOT validate that MLS credentials correspond to legitimate users. Trust in user identities relies on:

  1. Server authentication gate: Only authenticated users can upload key packages.
  2. TOFU fingerprint tracking: Detects key changes after first contact.
  3. Out-of-band verification: /verify command for strong fingerprint confirmation.

For communities requiring stronger identity assurance (e.g., binding MLS identities to organizational PKI), future versions may support X.509 credentials with certificate authority validation.

Protobuf Schema Reference

All wire format messages are defined in the conclave.v1 protobuf package using proto3 syntax.

Authentication

message RegisterRequest {
  string username = 1;
  string password = 2;
  string alias = 3;
  string registration_token = 4;
}

message RegisterResponse {
  int64 user_id = 1;
}

message LoginRequest {
  string username = 1;
  string password = 2;
}

message LoginResponse {
  string token = 1;
  int64 user_id = 2;
  string username = 3;
}

Key Packages

message UploadKeyPackageRequest {
  bytes key_package_data = 1;                // Legacy single-upload
  repeated KeyPackageEntry entries = 2;      // Batch upload (preferred)
  string signing_key_fingerprint = 3;        // SHA-256 hex of signing public key
}

message KeyPackageEntry {
  bytes data = 1;                            // Raw MLS key package bytes
  bool is_last_resort = 2;                   // true for last-resort packages
}

message UploadKeyPackageResponse {}

message GetKeyPackageResponse {
  bytes key_package_data = 1;                // Raw MLS key package bytes
}

Groups

message CreateGroupRequest {
  string alias = 1;
  // Field 2 reserved (was member_usernames).
  string group_name = 3;
}

message CreateGroupResponse {
  int64 group_id = 1;
  // Field 2 reserved (was member_key_packages).
}

message GroupInfo {
  int64 group_id = 1;
  string alias = 2;
  // Field 3 reserved (was creator_id).
  repeated GroupMember members = 4;
  uint64 created_at = 5;                    // Unix timestamp (seconds)
  string group_name = 6;
  string mls_group_id = 7;                  // Hex-encoded MLS group ID
  int64 message_expiry_seconds = 8;         // -1=disabled, 0=fetch-then-delete, >0=seconds
}

message GroupMember {
  int64 user_id = 1;
  string username = 2;
  string alias = 3;
  string role = 4;                          // "admin" or "member"
  string signing_key_fingerprint = 5;       // SHA-256 hex of signing public key
}

message ListGroupsResponse {
  repeated GroupInfo groups = 1;
}

message UpdateGroupRequest {
  string alias = 1;
  string group_name = 2;
  int64 message_expiry_seconds = 3;
  bool update_message_expiry = 4;           // Must be true to apply expiry field
}

message UpdateGroupResponse {}

message GetRetentionPolicyResponse {
  int64 server_retention_seconds = 1;       // -1=disabled, 0=fetch-then-delete, >0=seconds
  int64 group_expiry_seconds = 2;           // -1=disabled, 0=fetch-then-delete, >0=seconds
}

Commits

message UploadCommitRequest {
  bytes commit_message = 1;
  // Field 2 reserved (was welcome_messages).
  bytes group_info = 3;                     // MLS GroupInfo for external commits
  string mls_group_id = 4;                  // Hex-encoded MLS group ID (set on creation)
}

message UploadCommitResponse {}

Welcomes

message PendingWelcome {
  int64 group_id = 1;
  string group_alias = 2;
  bytes welcome_message = 3;               // Raw MLS Welcome bytes
  int64 welcome_id = 4;
}

message ListPendingWelcomesResponse {
  repeated PendingWelcome welcomes = 1;
}

Invitations

message InviteToGroupRequest {
  repeated int64 user_ids = 1;
}

message InviteToGroupResponse {
  map<int64, bytes> member_key_packages = 1; // user_id → MLS key package
}

message EscrowInviteRequest {
  int64 invitee_id = 1;
  bytes commit_message = 2;
  bytes welcome_message = 3;
  bytes group_info = 4;
}

message EscrowInviteResponse {}

message PendingInvite {
  int64 invite_id = 1;
  int64 group_id = 2;
  string group_name = 3;
  string group_alias = 4;
  string inviter_username = 5;
  uint64 created_at = 6;                   // Unix timestamp (seconds)
  int64 invitee_id = 7;
  int64 inviter_id = 8;
}

message ListPendingInvitesResponse {
  repeated PendingInvite invites = 1;
}

message AcceptInviteResponse {}

message DeclineInviteResponse {}

message ListGroupPendingInvitesResponse {
  repeated PendingInvite invites = 1;
}

message CancelInviteRequest {
  int64 invitee_id = 1;
}

message CancelInviteResponse {}

Admin Management

message PromoteMemberRequest {
  int64 user_id = 1;
}

message PromoteMemberResponse {}

message DemoteMemberRequest {
  int64 user_id = 1;
}

message DemoteMemberResponse {}

message ListAdminsResponse {
  repeated GroupMember admins = 1;
}

Messages

message SendMessageRequest {
  bytes mls_message = 1;                   // Encrypted MLS application message
}

message SendMessageResponse {
  uint64 sequence_num = 1;                 // Server-assigned sequence number
}

message StoredMessage {
  uint64 sequence_num = 1;
  int64 sender_id = 2;
  // Field 3 reserved (was sender_username).
  bytes mls_message = 4;                   // Encrypted MLS message (opaque blob)
  uint64 created_at = 5;                   // Unix timestamp (seconds)
  // Field 6 reserved (was sender_alias).
}

message GetMessagesResponse {
  repeated StoredMessage messages = 1;
}

Member Management

message RemoveMemberRequest {
  int64 user_id = 1;
  bytes commit_message = 2;               // MLS removal commit
  bytes group_info = 3;                    // Updated MLS GroupInfo
}

message RemoveMemberResponse {}

message LeaveGroupRequest {
  bytes commit_message = 1;               // MLS self-removal commit
  bytes group_info = 2;                    // Updated MLS GroupInfo
}

message LeaveGroupResponse {}

External Commit

message GetGroupInfoResponse {
  bytes group_info = 1;                    // Raw MLS GroupInfo bytes
}

message ResetAccountResponse {}

message ExternalJoinRequest {
  bytes commit_message = 1;               // MLS external commit
  string mls_group_id = 2;                // Hex-encoded MLS group ID
}

message ExternalJoinResponse {}

Profile Updates

message UpdateProfileRequest {
  string alias = 1;
}

message UpdateProfileResponse {}

message ChangePasswordRequest {
  // Field 1 reserved.
  string new_password = 2;
}

message ChangePasswordResponse {}

SSE Events

message ServerEvent {
  oneof event {
    NewMessageEvent new_message = 1;
    GroupUpdateEvent group_update = 2;
    WelcomeEvent welcome = 3;
    MemberRemovedEvent member_removed = 4;
    IdentityResetEvent identity_reset = 5;
    InviteReceivedEvent invite_received = 6;
    InviteDeclinedEvent invite_declined = 7;
    InviteCancelledEvent invite_cancelled = 8;
  }
}

message NewMessageEvent {
  int64 group_id = 1;
  uint64 sequence_num = 2;
  int64 sender_id = 3;
}

message GroupUpdateEvent {
  int64 group_id = 1;
  string update_type = 2;                 // "commit", "member_profile", "group_settings", "role_change"
}

message WelcomeEvent {
  int64 group_id = 1;
  string group_alias = 2;
}

message MemberRemovedEvent {
  int64 group_id = 1;
  int64 removed_user_id = 2;
}

message IdentityResetEvent {
  int64 group_id = 1;
  int64 user_id = 2;
}

message InviteReceivedEvent {
  int64 invite_id = 1;
  int64 group_id = 2;
  string group_name = 3;
  string group_alias = 4;
  int64 inviter_id = 5;
}

message InviteDeclinedEvent {
  int64 group_id = 1;
  int64 declined_user_id = 2;
}

message InviteCancelledEvent {
  int64 group_id = 1;
}

Common

message ErrorResponse {
  string message = 1;                     // Human-readable error description
}

message UserInfoResponse {
  int64 user_id = 1;
  string username = 2;
  string alias = 3;
  string signing_key_fingerprint = 4;     // SHA-256 hex of signing public key
}

Reserved Fields

Several messages have gaps in field numbers due to removed fields. These field numbers are reserved and MUST NOT be reused with different semantics in future versions:

MessageFieldWas
CreateGroupRequest2member_usernames
CreateGroupResponse2member_key_packages
GroupInfo3creator_id
UploadCommitRequest2welcome_messages
StoredMessage3sender_username
StoredMessage6sender_alias
ChangePasswordRequest1(removed)

Validation Rules

This appendix documents all input validation rules enforced by the server.

Username

PropertyRule
Length1–64 characters
First characterMUST be ASCII alphanumeric ([a-zA-Z0-9])
Allowed charactersASCII letters, digits, and underscores ([a-zA-Z0-9_])
UniquenessMUST be unique across the server

Regex: ^[a-zA-Z0-9][a-zA-Z0-9_]{0,63}$

Error message: "username must start with a letter or digit and contain only ASCII letters, digits, and underscores"

Group Name

Group names follow the same rules as usernames.

PropertyRule
Length1–64 characters
First characterMUST be ASCII alphanumeric ([a-zA-Z0-9])
Allowed charactersASCII letters, digits, and underscores ([a-zA-Z0-9_])
UniquenessMUST be unique across the server

Regex: ^[a-zA-Z0-9][a-zA-Z0-9_]{0,63}$

Password

PropertyRule
Minimum length8 characters

No maximum length or character restrictions.

Error message: "password must be at least 8 characters"

Alias (Display Name)

Used for both user aliases and group aliases.

PropertyRule
Maximum length64 characters
Forbidden charactersASCII control characters: 0x000x1F and 0x7F
UnicodeAllowed
UniquenessNOT required

Error messages:

  • "alias exceeds maximum length" (if > 64 characters)
  • "must not contain ASCII control characters" (if contains control characters)

Registration Token

PropertyRule
Allowed charactersASCII letters, digits, underscores, and hyphens ([a-zA-Z0-9_-])
Validation timingValidated at server config load time
ComparisonMUST use constant-time equality

Key Package Data

PropertyRule
Minimum size4 bytes
Maximum size16,384 bytes (16 KiB)
Bytes 0–1MUST be 0x00 0x01 (MLS version 1.0)
Bytes 2–3MUST be 0x00 0x05 (wire format mls_key_package, per RFC 9420 Section 6)

Message Expiry Seconds

PropertyRule
Allowed values-1 (disabled), 0 (delete-after-fetch), or any positive integer
Server constraintWhen the server has a non-disabled retention policy (not "-1"), the group expiry MUST NOT exceed the server retention value

Message Fetch Limit

PropertyRule
Default100 messages per request
Maximum500 messages per request

Values above 500 are capped to 500.

Signing Key Fingerprint

PropertyRule
FormatLowercase hexadecimal string
Length64 characters (SHA-256 output = 256 bits = 64 hex digits)
ValidationNot strictly validated on upload; stored as-is

Key Package Count Limits

PropertyRule
Maximum regular packages per user10
Maximum last-resort packages per user1 (new upload replaces previous)
Rate limit on consumption10 requests per minute per target user

Duration Format

Several configuration fields and commands use a human-readable duration string format.

Syntax

<number><unit>

Where <number> is a positive integer and <unit> is one of the supported time units.

Special Values

ValueMeaning
"-1"Disabled / indefinite (no limit)
"0"Immediate / delete-after-fetch

Time Units

UnitMultiplier (seconds)ExampleEquivalent
s1"15s"15 seconds
h3,600"2h"7,200 seconds
d86,400"7d"604,800 seconds
w604,800"4w"2,419,200 seconds
m2,592,000 (30 days)"1m"2,592,000 seconds
y31,536,000 (365 days)"1y"31,536,000 seconds

Usage

This format is used in:

  • Server configuration: message_retention and cleanup_interval fields.
  • Client commands: /expire command for setting per-group message expiry.
  • API responses: Durations are represented as integer seconds (i64) in protobuf messages, not as duration strings. The string format is used only in configuration files and user-facing commands.

Examples

Duration StringSeconds
"15s"15
"2h"7,200
"7d"604,800
"4w"2,419,200
"1m"2,592,000
"1y"31,536,000
"-1"-1 (disabled)
"0"0 (immediate)

Error Conditions

Parsing fails for:

  • Empty strings.
  • Missing unit suffix (e.g., "30").
  • Non-positive numbers (e.g., "-5d", except the special value "-1").
  • Unknown unit characters (e.g., "5x").
  • Non-numeric prefixes (e.g., "abcd").

Error Codes

All error responses use the ErrorResponse protobuf message with a message field containing a human-readable error description. The HTTP status code indicates the error category.

Status Code Reference

400 Bad Request

Returned when the request contains invalid input.

ConditionExample message
Invalid username format"username must start with a letter or digit and contain only ASCII letters, digits, and underscores"
Password too short"password must be at least 8 characters"
Alias too long"alias exceeds maximum length"
Alias contains control characters"must not contain ASCII control characters"
Invalid group name formatSame as username validation
Missing required field"invitee_id is required"
Empty required field"commit_message is required"
Invalid message expiry value"message_expiry_seconds must be -1, 0, or positive"
Group expiry exceeds server retention"group expiry cannot exceed server retention"
Key package too large"key package exceeds maximum size"
Key package wire format invalid"invalid key package wire format"
Target not a group member"user is not a member of this group"
Cannot demote last admin"cannot demote the last admin"
No GroupInfo available for external join"no group info available"

401 Unauthorized

Returned when authentication or authorization fails.

ConditionContext
Missing Authorization headerAny authenticated endpoint
Invalid or expired tokenAny authenticated endpoint
Invalid username or passwordPOST /api/v1/login
Not a member of the groupGroup-scoped endpoints requiring membership
Not an admin of the groupAdmin-only endpoints
Not the invitee for this invitePOST /api/v1/invites/{id}/accept or decline

403 Forbidden

Returned when access is explicitly denied by server policy.

ConditionContext
Registration disabledPOST /api/v1/register when registration_enabled is false and no valid token provided
Invalid registration tokenPOST /api/v1/register with incorrect registration_token

404 Not Found

Returned when the referenced resource does not exist.

ConditionContext
User not foundGET /api/v1/users/{username}, GET /api/v1/users/by-id/{user_id}
No key packages availableGET /api/v1/key-packages/{user_id}
Group not foundGroup-scoped endpoints with invalid group_id
No GroupInfo storedGET /api/v1/groups/{id}/group-info
Invite not foundPOST /api/v1/invites/{id}/accept, decline
Welcome not foundPOST /api/v1/welcomes/{id}/accept
No pending invite for group+inviteePOST /api/v1/groups/{id}/cancel-invite
Target user does not existPOST /api/v1/groups/{id}/invite, remove, promote, demote

409 Conflict

Returned when the request conflicts with existing state.

ConditionContext
Username already takenPOST /api/v1/register
Group name already takenPOST /api/v1/groups
User already a group memberPOST /api/v1/groups/{id}/invite, POST /api/v1/groups/{id}/escrow-invite
User already an adminPOST /api/v1/groups/{id}/promote
Duplicate pending invitePOST /api/v1/groups/{id}/escrow-invite (same group+invitee)

429 Too Many Requests

Returned when a rate limit is exceeded.

ConditionContext
Key package fetch rate exceededGET /api/v1/key-packages/{user_id} (10 req/min per target user)

500 Internal Server Error

Returned for unexpected server-side failures.

ConditionNotes
Database errorsConnection failures, query errors, constraint violations
Password hashing failuresArgon2id computation errors
Protobuf encoding failuresSerialization errors

The server MUST NOT expose internal details in the error message. The message field SHOULD be a generic string such as "internal server error".

Error Response Format

All errors use the same protobuf message:

message ErrorResponse {
  string message = 1;
}

Clients SHOULD display the message field to the user or include it in logs. Clients SHOULD also use the HTTP status code to determine the error category and take appropriate action (e.g., re-authenticate on 401, display validation feedback on 400).

Notes

  • The server uses HTTP 401 for both authentication failures (invalid token) and authorization failures (not a group member, not an admin). This is a simplification — implementations should be aware that 401 can indicate either condition.
  • Error messages are informational and intended for human consumption. Clients SHOULD NOT parse error message strings programmatically — use the HTTP status code for control flow decisions.

Server Configuration

The server is configured via a TOML file. The server searches for configuration in the following order:

  1. Path specified via --config (or -c) command-line flag.
  2. ./conclave.toml in the current working directory.
  3. /etc/conclave/config.toml.
  4. Built-in defaults (if no config file is found).

Configuration Fields

Network

FieldTypeDefaultDescription
listen_addressstring"0.0.0.0"IP address to bind to.
listen_portinteger8443 (TLS) or 8080 (plain HTTP)Port to listen on. Default depends on whether TLS is configured.

Database

FieldTypeDefaultDescription
database_pathstring"conclave.db"Path to the SQLite database file. Created automatically if it does not exist.

Sessions

FieldTypeDefaultDescription
token_ttl_secondsinteger604800 (7 days)Session token lifetime in seconds. Tokens older than this are expired.

Invitations

FieldTypeDefaultDescription
invite_ttl_secondsinteger604800 (7 days)Pending invite lifetime in seconds. Expired invites are cleaned up by the background task.

Message Retention

FieldTypeDefaultDescription
message_retentionstring"-1"Global message retention policy. "-1" disables retention (keep forever). "0" enables delete-after-fetch. Duration format (e.g., "30d") sets maximum message age. See Duration Format.
cleanup_intervalstring"1h"Interval between background cleanup runs. Same duration format.

Registration Control

FieldTypeDefaultDescription
registration_enabledbooleantrueWhether public registration is open. When false, registration requires a valid token.
registration_tokenstring(none)Registration token for invite-only registration. Only checked when registration_enabled is false. Must contain only [a-zA-Z0-9_-].

TLS

FieldTypeDefaultDescription
tls_cert_pathstring(none)Path to the TLS certificate file (PEM format).
tls_key_pathstring(none)Path to the TLS private key file (PEM format).

When both tls_cert_path and tls_key_path are set, the server serves HTTPS directly. When neither is set, the server serves plain HTTP (suitable for running behind a reverse proxy). Setting only one of the two is invalid.

Example Configuration

Minimal (Plain HTTP Behind Reverse Proxy)

listen_address = "127.0.0.1"
listen_port = 8080
database_path = "/var/lib/conclave/conclave.db"

Native TLS

listen_address = "0.0.0.0"
listen_port = 8443
database_path = "/var/lib/conclave/conclave.db"
tls_cert_path = "/etc/conclave/cert.pem"
tls_key_path = "/etc/conclave/key.pem"

Invite-Only with Message Retention

listen_address = "0.0.0.0"
database_path = "/var/lib/conclave/conclave.db"
registration_enabled = false
registration_token = "my-secret-invite-code"
message_retention = "30d"
cleanup_interval = "1h"
tls_cert_path = "/etc/conclave/cert.pem"
tls_key_path = "/etc/conclave/key.pem"

Full Reference

# Network
listen_address = "0.0.0.0"
listen_port = 8443

# Database
database_path = "conclave.db"

# Sessions
token_ttl_seconds = 604800

# Invitations
invite_ttl_seconds = 604800

# Message retention
message_retention = "-1"
cleanup_interval = "1h"

# Registration
registration_enabled = true
# registration_token = "your-secret-token"

# TLS
# tls_cert_path = "/path/to/cert.pem"
# tls_key_path = "/path/to/key.pem"