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
- Security: MLS-based end-to-end encryption with no server-side access to plaintext. No compromises.
- Simplicity: One code path for all messaging — both two-person conversations and group rooms use MLS groups. Minimal feature surface.
- Efficiency: Binary wire format (Protocol Buffers). Compact storage (SQLite). Small binary footprint.
- 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.v1package. - MLS-specific terminology follows RFC 9420 definitions.
Terminology
| Term | Definition |
|---|---|
| Group | An MLS group containing one or more members. All messaging occurs within groups. Also referred to as a “room” in client user interfaces. |
| Member | A user who belongs to a group and can send and receive encrypted messages within it. |
| Admin | A member with elevated privileges (invite, remove, promote, demote, update group settings). The group creator is the initial admin. |
| Key package | A pre-published MLS credential that allows other users to add someone to a group asynchronously (without the target being online). |
| Last-resort key package | A reusable key package that is never consumed, serving as a fallback when all regular key packages have been used. |
| Epoch | An MLS concept — the version counter for a group’s key state. Epochs advance on each commit (member add/remove, key rotation). |
| Commit | An MLS operation that applies one or more proposals and advances the group epoch. |
| Welcome | An MLS message that allows a new member to join a group, containing the necessary key material and group state. |
| External commit | An 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 invite | Conclave’s two-phase invitation system where the inviter pre-builds the MLS commit and Welcome, and the invitee explicitly accepts or declines before joining. |
| TOFU | Trust 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. |
| Fingerprint | The SHA-256 hash of a user’s MLS signing public key, represented as a 64-character lowercase hexadecimal string. Used for identity verification. |
| Sequence number | A per-group, server-assigned monotonically increasing integer that orders messages within a group. |
Normative References
- RFC 9420 — The Messaging Layer Security (MLS) Protocol
- RFC 2119 — Key words for use in RFCs
- Protocol Buffers Language Guide (proto3)
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_secondssetting (-1 = disabled, 0 = delete-after-fetch, >0 = seconds).
Group Members
Each membership record has:
- A
group_idanduser_idpair (composite primary key). - A
role: either"admin"or"member".
Messages
Each stored message has:
- A
group_ididentifying the containing group. - A
sender_ididentifying the sending user. - An
mls_messageblob (opaque MLS ciphertext). - A
sequence_num(unique within the group, monotonically increasing). - A
created_attimestamp (Unix epoch seconds).
Key Packages
Each key package has:
- An associated
user_id. - A
key_package_datablob (opaque MLS key package bytes). - An
is_last_resortflag.
Pending Invites
Each pending invite holds the escrowed MLS materials for a two-phase invitation:
- A
group_idandinvitee_idpair (unique constraint). - The
inviter_id. - The
commit_message,welcome_data, andgroup_infoblobs.
Pending Welcomes
Each pending welcome holds an MLS Welcome message ready for the target user to process:
- A
group_idanduser_id. - The
welcome_datablob.
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:
-
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).
-
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, defaulttrue): Whentrue, anyone can register and the token check is bypassed. Whenfalse, registration requires a valid registration token.registration_token(optional string): Whenregistration_enabledisfalse, 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:
- Read the
datafield from the SSE event. - Hex-decode the string to obtain raw bytes.
- Deserialize the bytes as a
conclave.v1.ServerEventprotobuf message. - Dispatch based on the
oneof eventvariant.
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.
| Field | Type | Description |
|---|---|---|
group_id | int64 | The group the message was sent to |
sequence_num | uint64 | The server-assigned sequence number |
sender_id | int64 | The 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.
| Field | Type | Description |
|---|---|---|
group_id | int64 | The affected group |
update_type | string | The type of update (see below) |
Update types:
| Value | Trigger | Recipients |
|---|---|---|
"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 changed | All members including the sender |
"role_change" | A member was promoted or demoted | All 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).
| Field | Type | Description |
|---|---|---|
group_id | int64 | The group the user was invited to |
group_alias | string | The 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.
| Field | Type | Description |
|---|---|---|
group_id | int64 | The affected group |
removed_user_id | int64 | The 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.
| Field | Type | Description |
|---|---|---|
group_id | int64 | The affected group |
user_id | int64 | The 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.
| Field | Type | Description |
|---|---|---|
invite_id | int64 | The invite’s unique identifier |
group_id | int64 | The group being invited to |
group_name | string | The group’s name |
group_alias | string | The group’s display alias |
inviter_id | int64 | The 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.
| Field | Type | Description |
|---|---|---|
group_id | int64 | The group the invite was for |
declined_user_id | int64 | The 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.
| Field | Type | Description |
|---|---|---|
group_id | int64 | The 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/registerandPOST /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:
- Server group ID (
group_id): A unique integer assigned by the server at group creation. Used in all API paths and request bodies. - 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:
- Resolve the username to a
user_idviaGET /api/v1/users/{username}. - Use the
user_idin all subsequent API calls.
Server-to-Client (Display)
For display purposes, clients SHOULD resolve user IDs to human-readable names using the following strategy:
- Local cache: Check the in-memory member data populated by
ListGroupsResponse, which includesusername,alias, anduser_idfor all group members. - Server lookup: For cache misses (e.g., users who left the group), use
GET /api/v1/users/by-id/{user_id}. - 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: Includesgroup_nameandgroup_aliasbecause the invitee is not yet a group member.PendingInvite: Includesgroup_name,group_alias, andinviter_usernamefor 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):
| Component | Algorithm |
|---|---|
| Key Exchange (KEM) | X448 |
| Authenticated Encryption (AEAD) | ChaCha20-Poly1305 |
| Hash | SHA-512 |
| Signature | Ed448 |
| Security Level | 256-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:
- Display a warning to the user indicating the message could not be decrypted, including the message’s sequence number and the reason for failure.
- Advance the sequence tracking past the undecryptable message. Failed messages cannot be retried — blocking on them would cause infinite retry loops.
- 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:
| Operation | MLS Primitive | Conclave Usage |
|---|---|---|
| Generate signing identity | signature_key_generate() | Registration, account reset |
| Generate key package | generate_key_package_message() | Pre-publishing credentials |
| Create group | create_group() + commit_builder().build() | New group creation |
| Invite member | commit_builder().add_member(key_package).build() | Adding members to groups |
| Join group | join_group(welcome_message) | Processing a Welcome after invite acceptance |
| Encrypt message | encrypt_application_message(plaintext) | Sending chat messages |
| Decrypt message | process_incoming_message(ciphertext) | Receiving chat messages and commits |
| Remove member | commit_builder().remove_member(index).build() | Kicking a member |
| Leave group | commit_builder().remove_member(own_index).build() | Voluntary departure |
| Rotate keys | commit_builder().build() (empty commit) | Forward secrecy, phantom leaf cleanup |
| External rejoin | external_commit_builder().build(group_info) | Account reset rejoin |
| Export GroupInfo | group_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 (
0x0001for MLS 1.0). - The next 2 bytes MUST be the wire format type (
0x0005formls_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:
-
Server-side: The client calls
POST /api/v1/groupswith agroup_name(and optionalalias). The server creates the group record and adds the creator as the sole member with theadminrole. The server returns agroup_id. -
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 themls_group_idon 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:
- The admin fetches the target’s key package via the invite endpoint.
- The admin builds an MLS commit (adding the target) and Welcome message locally.
- The admin uploads the commit, Welcome, and GroupInfo to the server’s invite escrow.
- The target receives a notification and can accept or decline.
- 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:
- The admin finds the target’s MLS leaf index (by matching the
user_idin the target’s MLSBasicCredential). - The admin builds an MLS removal commit targeting that leaf index.
- The admin uploads the commit and GroupInfo via
POST /api/v1/groups/{id}/remove. - The server removes the member from the group membership table and stores the commit as a group message.
- 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:
- The member builds an MLS self-removal commit.
- The member uploads the commit and optional GroupInfo via
POST /api/v1/groups/{id}/leave. - The server removes the member from the group membership table and stores the commit as a group message.
- Remaining members receive a
MemberRemovedEvent. - 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:
- The user fetches the stored GroupInfo for each group via
GET /api/v1/groups/{id}/group-info. - 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.
- The user uploads the external commit via
POST /api/v1/groups/{id}/external-join. - 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— whengroup_infois provided.POST /api/v1/groups/{id}/remove— whengroup_infois provided.POST /api/v1/groups/{id}/leave— whengroup_infois 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
adminrole. - New member default: Members added via the invite system receive the
memberrole.
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
SigningIdentitycontaining the public key andBasicCredential. - 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 inListGroupsResponse, 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:
| Field | Type | Description |
|---|---|---|
user_id | integer | Primary key — the user being tracked |
fingerprint | string | The stored fingerprint (64-char hex) |
verified | boolean | Whether the fingerprint has been manually verified |
Verification States
A user’s fingerprint can be in one of four states:
| State | Condition | Display Indicator |
|---|---|---|
| Unknown | The server has no fingerprint for this user (e.g., legacy account, key packages not yet uploaded) | [?] |
| Unverified | The client has stored a fingerprint on first contact, but the user has not manually confirmed it | [?] |
| Verified | The user has confirmed the fingerprint out-of-band via the verify command | (none) |
| Changed | The 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:
| Command | Description |
|---|---|
/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. |
/trusted | List 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:
| Code | Meaning | Usage |
|---|---|---|
| 200 OK | Request succeeded | Successful GET, POST, PATCH operations |
| 201 Created | Resource created | Registration, group creation |
| 204 No Content | Success with no response body | Logout, welcome acceptance |
| 400 Bad Request | Validation error | Invalid input format, missing required fields, constraint violations |
| 401 Unauthorized | Authentication or authorization failure | Missing/invalid/expired token, not a group member when membership is required |
| 403 Forbidden | Access denied | Registration disabled, invalid registration token |
| 404 Not Found | Resource does not exist | User, group, key package, invite, or welcome not found |
| 409 Conflict | Duplicate resource | Username/group name taken, user already a member, duplicate invite |
| 500 Internal Server Error | Server-side failure | Database 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
| Field | Type | Required | Description |
|---|---|---|---|
username | string | Yes | Unique username. 1–64 characters, must start with ASCII alphanumeric, only letters/digits/underscores. |
password | string | Yes | Password. Minimum 8 characters. |
alias | string | No | Display name. Max 64 characters, no ASCII control characters. |
registration_token | string | No | Registration token for invite-only servers. Required when registration_enabled is false. |
Response Body — RegisterResponse
| Field | Type | Description |
|---|---|---|
user_id | int64 | The server-assigned unique user ID. |
Status Codes
| Code | Condition |
|---|---|
| 201 Created | Registration successful. |
| 400 Bad Request | Invalid username format, password too short, alias too long or contains control characters. |
| 403 Forbidden | Registration is disabled, or the provided registration token is invalid. |
| 409 Conflict | Username 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
| Field | Type | Required | Description |
|---|---|---|---|
username | string | Yes | The user’s username. |
password | string | Yes | The user’s password. |
Response Body — LoginResponse
| Field | Type | Description |
|---|---|---|
token | string | Session token (256-bit random, hex-encoded, 64 characters). |
user_id | int64 | The user’s unique ID. |
username | string | The user’s username. |
Status Codes
| Code | Condition |
|---|---|
| 200 OK | Login successful. |
| 401 Unauthorized | Invalid 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
| Code | Condition |
|---|---|
| 204 No Content | Logout successful. Token revoked. |
| 401 Unauthorized | Invalid 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
| Field | Type | Description |
|---|---|---|
user_id | int64 | The user’s unique ID. |
username | string | The user’s username. |
alias | string | The user’s display name (may be empty). |
signing_key_fingerprint | string | SHA-256 hex of the user’s MLS signing public key (may be empty). |
Status Codes
| Code | Condition |
|---|---|
| 200 OK | Success. |
| 401 Unauthorized | Invalid or expired token. |
SSE Events
None.
Update Profile
Updates the authenticated user’s display name.
PATCH /api/v1/me
Authentication: Required.
Request Body — UpdateProfileRequest
| Field | Type | Required | Description |
|---|---|---|---|
alias | string | Yes | New display name. Max 64 characters, no ASCII control characters. Set to empty string to clear. |
Response Body — UpdateProfileResponse
Empty message.
Status Codes
| Code | Condition |
|---|---|
| 200 OK | Profile updated. |
| 400 Bad Request | Alias too long or contains control characters. |
| 401 Unauthorized | Invalid or expired token. |
SSE Events
GroupUpdateEventwithupdate_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
| Field | Type | Required | Description |
|---|---|---|---|
new_password | string | Yes | The new password. Minimum 8 characters. |
Response Body — ChangePasswordResponse
Empty message.
Status Codes
| Code | Condition |
|---|---|
| 200 OK | Password changed. |
| 400 Bad Request | New password too short. |
| 401 Unauthorized | Invalid 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
| Code | Condition |
|---|---|
| 200 OK | Key packages cleared. |
| 401 Unauthorized | Invalid or expired token. |
Notes
This endpoint only deletes the user’s key packages on the server. The client MUST then:
- Wipe local MLS state (identity, signing key, group state database).
- Generate a new MLS signing identity and key packages.
- Upload the new key packages with the new fingerprint.
- 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
| Parameter | Type | Description |
|---|---|---|
username | string | The username to look up. |
Request Body
None.
Response Body — UserInfoResponse
| Field | Type | Description |
|---|---|---|
user_id | int64 | The user’s unique ID. |
username | string | The user’s username. |
alias | string | The user’s display name (may be empty). |
signing_key_fingerprint | string | SHA-256 hex of the user’s MLS signing public key (may be empty). |
Status Codes
| Code | Condition |
|---|---|
| 200 OK | User found. |
| 401 Unauthorized | Invalid or expired token. |
| 404 Not Found | No 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
| Parameter | Type | Description |
|---|---|---|
user_id | int64 | The user ID to look up. |
Request Body
None.
Response Body — UserInfoResponse
| Field | Type | Description |
|---|---|---|
user_id | int64 | The user’s unique ID. |
username | string | The user’s username. |
alias | string | The user’s display name (may be empty). |
signing_key_fingerprint | string | SHA-256 hex of the user’s MLS signing public key (may be empty). |
Status Codes
| Code | Condition |
|---|---|
| 200 OK | User found. |
| 401 Unauthorized | Invalid or expired token. |
| 404 Not Found | No 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):
| Field | Type | Required | Description |
|---|---|---|---|
entries | repeated KeyPackageEntry | Yes | List of key packages to upload. |
signing_key_fingerprint | string | No | SHA-256 hex of the user’s MLS signing public key (64 characters). Stored for TOFU verification. |
Each KeyPackageEntry:
| Field | Type | Description |
|---|---|---|
data | bytes | Raw MLS key package bytes. |
is_last_resort | bool | true for last-resort key packages, false for regular. |
Legacy single-upload mode:
| Field | Type | Required | Description |
|---|---|---|---|
key_package_data | bytes | Yes | A single key package (treated as a regular key package). |
signing_key_fingerprint | string | No | SHA-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 be0x0005(wire formatmls_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
| Code | Condition |
|---|---|
| 200 OK | Key packages uploaded successfully. |
| 400 Bad Request | Key package fails wire format validation or exceeds size limit. |
| 401 Unauthorized | Invalid 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
| Parameter | Type | Description |
|---|---|---|
user_id | int64 | The user whose key package to fetch. |
Request Body
None.
Response Body — GetKeyPackageResponse
| Field | Type | Description |
|---|---|---|
key_package_data | bytes | Raw MLS key package bytes. |
Consumption Rules
- The oldest regular key package is returned and deleted from the server (FIFO).
- If no regular packages remain, the last-resort key package is returned but NOT deleted.
- 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
| Code | Condition |
|---|---|
| 200 OK | Key package returned (and consumed if regular). |
| 401 Unauthorized | Invalid or expired token. |
| 404 Not Found | No key packages available for this user. |
| 429 Too Many Requests | Rate 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
| Field | Type | Required | Description |
|---|---|---|---|
group_name | string | Yes | Unique group name. 1–64 characters, must start with ASCII alphanumeric, only letters/digits/underscores. |
alias | string | No | Display name. Max 64 characters, no ASCII control characters. |
Response Body — CreateGroupResponse
| Field | Type | Description |
|---|---|---|
group_id | int64 | The 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:
- Create an MLS group locally.
- Upload the initial commit and GroupInfo via
POST /api/v1/groups/{id}/commit, including themls_group_id.
Status Codes
| Code | Condition |
|---|---|
| 201 Created | Group created successfully. |
| 400 Bad Request | Invalid group name format, alias too long or contains control characters. |
| 401 Unauthorized | Invalid or expired token. |
| 409 Conflict | Group 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
| Field | Type | Description |
|---|---|---|
groups | repeated GroupInfo | List of groups the user belongs to. |
Each GroupInfo:
| Field | Type | Description |
|---|---|---|
group_id | int64 | Server-assigned group ID. |
alias | string | Display name (may be empty). |
group_name | string | Unique group name. |
members | repeated GroupMember | All members of the group. |
created_at | uint64 | Unix timestamp of group creation (seconds). |
mls_group_id | string | Hex-encoded MLS group identifier. |
message_expiry_seconds | int64 | Per-group message expiry (-1=disabled, 0=delete-after-fetch, >0=seconds). |
Each GroupMember:
| Field | Type | Description |
|---|---|---|
user_id | int64 | Member’s user ID. |
username | string | Member’s username. |
alias | string | Member’s display name (may be empty). |
role | string | Either "admin" or "member". |
signing_key_fingerprint | string | SHA-256 hex of the member’s MLS signing public key (may be empty). |
Status Codes
| Code | Condition |
|---|---|
| 200 OK | Success. |
| 401 Unauthorized | Invalid 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
| Parameter | Type | Description |
|---|---|---|
group_id | int64 | The group to update. |
Request Body — UpdateGroupRequest
| Field | Type | Required | Description |
|---|---|---|---|
alias | string | No | New display name. Max 64 characters, no ASCII control characters. |
group_name | string | No | New group name. Same validation rules as creation. |
message_expiry_seconds | int64 | No | New message expiry value. Only applied when update_message_expiry is true. |
update_message_expiry | bool | No | MUST 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_retentionis not"-1"), the group expiry MUST NOT exceed the server retention value.
Response Body — UpdateGroupResponse
Empty message.
Status Codes
| Code | Condition |
|---|---|
| 200 OK | Group updated. |
| 400 Bad Request | Invalid name/alias format, invalid expiry value, or expiry exceeds server retention. |
| 401 Unauthorized | Invalid token, not a member, or not an admin. |
SSE Events
GroupUpdateEventwithupdate_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
| Parameter | Type | Description |
|---|---|---|
group_id | int64 | The group whose GroupInfo to fetch. |
Request Body
None.
Response Body — GetGroupInfoResponse
| Field | Type | Description |
|---|---|---|
group_info | bytes | Raw MLS GroupInfo bytes. |
Status Codes
| Code | Condition |
|---|---|
| 200 OK | GroupInfo returned. |
| 401 Unauthorized | Invalid token or not a group member. |
| 404 Not Found | No 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
| Parameter | Type | Description |
|---|---|---|
group_id | int64 | The group to query. |
Request Body
None.
Response Body — GetRetentionPolicyResponse
| Field | Type | Description |
|---|---|---|
server_retention_seconds | int64 | Server-wide retention (-1=disabled, 0=delete-after-fetch, >0=seconds). |
group_expiry_seconds | int64 | Per-group expiry (-1=disabled, 0=delete-after-fetch, >0=seconds). |
Status Codes
| Code | Condition |
|---|---|
| 200 OK | Success. |
| 401 Unauthorized | Invalid 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
| Parameter | Type | Description |
|---|---|---|
group_id | int64 | The group to invite users to. |
Request Body — InviteToGroupRequest
| Field | Type | Required | Description |
|---|---|---|---|
user_ids | repeated int64 | Yes | User IDs to invite. Must contain at least one ID. |
Response Body — InviteToGroupResponse
| Field | Type | Description |
|---|---|---|
member_key_packages | map<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
| Code | Condition |
|---|---|
| 200 OK | Key packages returned for all valid invitees. |
| 400 Bad Request | Empty user_ids list. |
| 401 Unauthorized | Invalid token, not a member, or not an admin. |
| 404 Not Found | A specified user does not exist, or no key package is available. |
| 409 Conflict | A 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
| Parameter | Type | Description |
|---|---|---|
group_id | int64 | The group to remove the member from. |
Request Body — RemoveMemberRequest
| Field | Type | Required | Description |
|---|---|---|---|
user_id | int64 | Yes | The user to remove. |
commit_message | bytes | No | MLS commit for the removal. Stored as a group message if provided. |
group_info | bytes | No | Updated MLS GroupInfo. Stored for external commits if provided. |
Response Body — RemoveMemberResponse
Empty message.
Status Codes
| Code | Condition |
|---|---|
| 200 OK | Member removed. |
| 400 Bad Request | Target user is not a member of the group. |
| 401 Unauthorized | Invalid token, not a member, or not an admin. |
| 404 Not Found | Target 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
| Parameter | Type | Description |
|---|---|---|
group_id | int64 | The group to leave. |
Request Body — LeaveGroupRequest
| Field | Type | Required | Description |
|---|---|---|---|
commit_message | bytes | No | MLS self-removal commit. Stored as a group message if provided. |
group_info | bytes | No | Updated 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
| Code | Condition |
|---|---|
| 200 OK | Successfully left the group. |
| 401 Unauthorized | Invalid 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
| Parameter | Type | Description |
|---|---|---|
group_id | int64 | The group. |
Request Body — PromoteMemberRequest
| Field | Type | Required | Description |
|---|---|---|---|
user_id | int64 | Yes | The member to promote. |
Response Body — PromoteMemberResponse
Empty message.
Status Codes
| Code | Condition |
|---|---|
| 200 OK | Member promoted to admin. |
| 400 Bad Request | Target user is not a member of the group. |
| 401 Unauthorized | Invalid token, not a member, or not an admin. |
| 404 Not Found | Target user does not exist. |
| 409 Conflict | Target user is already an admin. |
SSE Events
GroupUpdateEventwithupdate_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
| Parameter | Type | Description |
|---|---|---|
group_id | int64 | The group. |
Request Body — DemoteMemberRequest
| Field | Type | Required | Description |
|---|---|---|---|
user_id | int64 | Yes | The 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
| Code | Condition |
|---|---|
| 200 OK | Admin demoted to member. |
| 400 Bad Request | Target user is not an admin, or is the last admin of the group. |
| 401 Unauthorized | Invalid token, not a member, or not an admin. |
| 404 Not Found | Target user does not exist. |
SSE Events
GroupUpdateEventwithupdate_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
| Parameter | Type | Description |
|---|---|---|
group_id | int64 | The group to query. |
Request Body
None.
Response Body — ListAdminsResponse
| Field | Type | Description |
|---|---|---|
admins | repeated GroupMember | List of group members with the admin role. |
Status Codes
| Code | Condition |
|---|---|
| 200 OK | Success. |
| 401 Unauthorized | Invalid 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
| Parameter | Type | Description |
|---|---|---|
group_id | int64 | The group to rejoin. |
Request Body — ExternalJoinRequest
| Field | Type | Required | Description |
|---|---|---|---|
commit_message | bytes | No | MLS external commit. Stored as a group message if provided. |
mls_group_id | string | No | Hex-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
| Code | Condition |
|---|---|
| 200 OK | External join successful. |
| 400 Bad Request | No GroupInfo available for the group. |
| 401 Unauthorized | Invalid token or not a group member. |
| 404 Not Found | Group 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
| Parameter | Type | Description |
|---|---|---|
group_id | int64 | The group the invite is for. |
Request Body — EscrowInviteRequest
| Field | Type | Required | Description |
|---|---|---|---|
invitee_id | int64 | Yes | The user being invited (must not be 0). |
commit_message | bytes | Yes | MLS commit that adds the invitee. Must not be empty. |
welcome_message | bytes | Yes | MLS Welcome message for the invitee. Must not be empty. |
group_info | bytes | Yes | Updated 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
| Code | Condition |
|---|---|
| 200 OK | Invite escrowed successfully. |
| 400 Bad Request | invitee_id is 0, or any required field is empty. |
| 401 Unauthorized | Invalid token, not a member, or not an admin. |
| 404 Not Found | Invitee does not exist. |
| 409 Conflict | Invitee 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
| Field | Type | Description |
|---|---|---|
invites | repeated PendingInvite | List of pending invites for the user. |
Each PendingInvite:
| Field | Type | Description |
|---|---|---|
invite_id | int64 | Unique invite identifier. |
group_id | int64 | The group being invited to. |
group_name | string | The group’s name. |
group_alias | string | The group’s display alias (may be empty). |
inviter_username | string | The inviting user’s username. |
inviter_id | int64 | The inviting user’s ID. |
invitee_id | int64 | The invited user’s ID (the authenticated user). |
created_at | uint64 | Unix timestamp of invite creation (seconds). |
Status Codes
| Code | Condition |
|---|---|
| 200 OK | Success (may be empty). |
| 401 Unauthorized | Invalid 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
| Parameter | Type | Description |
|---|---|---|
invite_id | int64 | The invite to accept. |
Request Body
None.
Response Body — AcceptInviteResponse
Empty message.
Server Processing
Atomically within a single transaction:
- Delete the pending invite.
- Add the invitee to
group_memberswith thememberrole. - Store the escrowed Welcome message as a pending welcome.
- Store the escrowed commit as a group message (assigned the next sequence number).
Client Follow-up
After accepting, the client MUST:
- Fetch pending welcomes via
GET /api/v1/welcomes. - Process the MLS Welcome message to join the group.
- Acknowledge the welcome via
POST /api/v1/welcomes/{id}/accept. - Upload replacement key packages to replenish consumed ones.
Status Codes
| Code | Condition |
|---|---|
| 200 OK | Invite accepted. |
| 401 Unauthorized | Invalid token, or the authenticated user is not the invitee. |
| 404 Not Found | Invite does not exist. |
SSE Events
WelcomeEvent— sent to the invitee.GroupUpdateEventwithupdate_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
| Parameter | Type | Description |
|---|---|---|
invite_id | int64 | The 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
| Code | Condition |
|---|---|
| 200 OK | Invite declined. |
| 401 Unauthorized | Invalid token, or the authenticated user is not the invitee. |
| 404 Not Found | Invite 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
| Parameter | Type | Description |
|---|---|---|
group_id | int64 | The group to query. |
Request Body
None.
Response Body — ListGroupPendingInvitesResponse
| Field | Type | Description |
|---|---|---|
invites | repeated PendingInvite | List of pending invites for the group. |
Status Codes
| Code | Condition |
|---|---|
| 200 OK | Success (may be empty). |
| 401 Unauthorized | Invalid 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
| Parameter | Type | Description |
|---|---|---|
group_id | int64 | The group the invite belongs to. |
Request Body — CancelInviteRequest
| Field | Type | Required | Description |
|---|---|---|---|
invitee_id | int64 | Yes | The 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
| Code | Condition |
|---|---|
| 200 OK | Invite cancelled. |
| 401 Unauthorized | Invalid token, not a member, or not an admin. |
| 404 Not Found | No 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
| Field | Type | Description |
|---|---|---|
welcomes | repeated PendingWelcome | List of pending Welcome messages. |
Each PendingWelcome:
| Field | Type | Description |
|---|---|---|
welcome_id | int64 | Unique welcome identifier. |
group_id | int64 | The group this Welcome is for. |
group_alias | string | The group’s display alias (may be empty). |
welcome_message | bytes | Raw 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
| Code | Condition |
|---|---|
| 200 OK | Success (may be empty). |
| 401 Unauthorized | Invalid 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
| Parameter | Type | Description |
|---|---|---|
welcome_id | int64 | The 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
| Code | Condition |
|---|---|
| 204 No Content | Welcome acknowledged and deleted. |
| 401 Unauthorized | Invalid or expired token. |
| 404 Not Found | Welcome 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
| Parameter | Type | Description |
|---|---|---|
group_id | int64 | The group the commit belongs to. |
Request Body — UploadCommitRequest
| Field | Type | Required | Description |
|---|---|---|---|
commit_message | bytes | No | MLS commit message. Stored as a group message (assigned next sequence number) if provided. |
group_info | bytes | No | Updated MLS GroupInfo. Stored for external commits if provided. |
mls_group_id | string | No | Hex-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
| Code | Condition |
|---|---|
| 200 OK | Commit uploaded. |
| 401 Unauthorized | Invalid token or not a group member. |
SSE Events
GroupUpdateEventwithupdate_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
| Parameter | Type | Description |
|---|---|---|
group_id | int64 | The group to send the message to. |
Request Body — SendMessageRequest
| Field | Type | Required | Description |
|---|---|---|---|
mls_message | bytes | Yes | Encrypted MLS application message (opaque ciphertext). |
Response Body — SendMessageResponse
| Field | Type | Description |
|---|---|---|
sequence_num | uint64 | The 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
| Code | Condition |
|---|---|
| 200 OK | Message stored and sequence number assigned. |
| 401 Unauthorized | Invalid 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
| Parameter | Type | Description |
|---|---|---|
group_id | int64 | The group to fetch messages from. |
Query Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
after | int64 | 0 | Fetch messages with sequence numbers strictly greater than this value. |
limit | int64 | 100 | Maximum number of messages to return. Capped at 500. |
Request Body
None.
Response Body — GetMessagesResponse
| Field | Type | Description |
|---|---|---|
messages | repeated StoredMessage | List of messages, ordered by sequence number (ascending). |
Each StoredMessage:
| Field | Type | Description |
|---|---|---|
sequence_num | uint64 | The message’s sequence number within the group. |
sender_id | int64 | The user ID of the sender. |
mls_message | bytes | The encrypted MLS message (opaque ciphertext). |
created_at | uint64 | Unix 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:
- Set
afterto the highest sequence number the client has already processed. - Repeat with
afterset to the highest sequence number in the response until the response contains fewer messages than the limit.
Status Codes
| Code | Condition |
|---|---|
| 200 OK | Success (may return empty list if no new messages). |
| 401 Unauthorized | Invalid 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_type | Trigger | Recipients |
|---|---|---|
"commit" | MLS commit stored (via invite acceptance) | All members except sender |
"member_profile" | Member changed their profile alias | All co-members including sender |
"group_settings" | Group alias/name/expiry changed | All members including sender |
"role_change" | Member promoted or demoted | All 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
| Code | Condition |
|---|---|
| 200 OK | SSE stream established. |
| 401 Unauthorized | Invalid 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
-
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.
-
Login: The client immediately logs in after registration. The server verifies the password, generates a session token, and returns it with the user ID.
-
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.
-
Compute fingerprint: The client computes
SHA-256(signing_public_key)and formats it as a 64-character lowercase hex string. -
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.
-
Save session: The client persists the server URL, token, user ID, and username locally for future requests.
-
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:
- Load the existing MLS identity if available, or generate a new one.
- Upload fresh key packages (replenishing any that were consumed while offline).
- Fetch the group list to rebuild the server-group-ID to MLS-group-ID mapping.
- 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
-
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
adminrole. -
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.
-
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_idis recorded in the group’s server record. -
Store mapping: The client stores the mapping from the server’s
group_idto the MLSmls_group_idfor 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
-
Encrypt: The client encrypts the plaintext message using MLS, producing an opaque ciphertext blob.
-
Send: The client sends the ciphertext to the server. The server stores it as an opaque blob and assigns a monotonically increasing sequence number.
-
Notify: The server broadcasts a
NewMessageEventto 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
-
Receive notification: The client receives a
NewMessageEventvia SSE, indicating a new message is available in a group. -
Fetch messages: The client fetches messages with sequence numbers after its last known sequence number using the
afterquery parameter. -
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.).
-
Resolve sender: The client maps the
sender_idto a display name using its local member cache or the user lookup endpoint. -
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:
- 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.
- The target fetches and processes the Welcome message through the MLS layer to join the group.
- 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:
- The server deletes the pending invite and the escrowed materials.
- The inviter receives an
InviteDeclinedEventvia SSE. - 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
-
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_idembedded in theirBasicCredential. -
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.
-
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.
-
Notify: The server broadcasts a
MemberRemovedEventto 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:
- Fetch new messages (including the removal commit) via
GET /groups/{id}/messages. - Process the commit through MLS to advance the group epoch.
- Refresh the member list.
Client Behavior — Removed User
When a client receives a MemberRemovedEvent where removed_user_id matches their own user ID:
- Remove the group from the local display.
- 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
-
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.
-
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.
-
Clean up locally: The client deletes its local MLS group state for the group and removes it from the group mapping.
-
Notify: The server broadcasts a
MemberRemovedEventto 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
-
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).
-
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.
-
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. -
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
-
Fetch GroupInfo: For each group, the client fetches the stored MLS GroupInfo. This provides the public group state needed for the external commit.
-
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.
-
Upload external join: The client uploads the external commit. The server stores it as a group message and broadcasts an
IdentityResetEventto 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:
- Other members receive
IdentityResetEventvia SSE. - 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.
- 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. - 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
-
Build empty commit: The client builds an MLS commit with no proposals. This advances the epoch and rotates the group’s key material.
-
Upload commit: The client uploads the commit and updated GroupInfo to the server.
-
Notify: The server broadcasts a
GroupUpdateEventwithupdate_type: "commit"to all group members except the sender. -
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:
- Server-wide retention: A global maximum message age configured by the server operator.
- 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.
| Value | Behavior |
|---|---|
"-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.
| Value | Behavior |
|---|---|
-1 (default) | Disabled — inherits the server-wide retention policy. |
0 | Delete-after-fetch — messages are deleted after all group members have fetched them. |
| Positive integer | Messages 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 Retention | Group Expiry | Effective Expiry |
|---|---|---|
Disabled (-1) | Disabled (-1) | Disabled — messages kept indefinitely |
Disabled (-1) | Positive N | N seconds |
Disabled (-1) | 0 | Delete-after-fetch |
Positive S | Disabled (-1) | S seconds |
Positive S | Positive N | min(S, N) seconds (stricter wins) |
0 | Any | Delete-after-fetch (0 always wins) |
| Any | 0 | Delete-after-fetch (0 always wins) |
In summary:
- If either layer is
0(delete-after-fetch), the effective expiry is0. - 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:
- The server maintains a fetch watermark per member per group, tracking the highest sequence number each member has fetched.
- When a member fetches messages via
GET /api/v1/groups/{id}/messages, their watermark is updated. - 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). - 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:
- Time-based deletion: For groups with a positive effective expiry, messages with
created_atolder than the expiry duration are deleted. - 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. - Session cleanup: Expired session tokens are deleted.
- 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:
- Native TLS: Direct HTTPS with server-terminated TLS.
- 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.
Invite Consent
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:
- The admin pre-builds the MLS materials and uploads them to escrow.
- The target receives a notification and can inspect the invitation.
- The target explicitly accepts or declines.
- 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
adminrole. - New members receive the
memberrole 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:
- The admin can only upload an invite to escrow — the target is not added to the group.
- The target receives a notification and can inspect the invitation.
- The target must explicitly accept to join, or decline to discard the invite.
- The server enforces a unique constraint on
(group_id, invitee_id)— only one pending invite per user per group. - 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
/verifyto 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:
- Server authentication gate: Only authenticated users can upload key packages.
- TOFU fingerprint tracking: Detects key changes after first contact.
- Out-of-band verification:
/verifycommand 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:
| Message | Field | Was |
|---|---|---|
CreateGroupRequest | 2 | member_usernames |
CreateGroupResponse | 2 | member_key_packages |
GroupInfo | 3 | creator_id |
UploadCommitRequest | 2 | welcome_messages |
StoredMessage | 3 | sender_username |
StoredMessage | 6 | sender_alias |
ChangePasswordRequest | 1 | (removed) |
Validation Rules
This appendix documents all input validation rules enforced by the server.
Username
| Property | Rule |
|---|---|
| Length | 1–64 characters |
| First character | MUST be ASCII alphanumeric ([a-zA-Z0-9]) |
| Allowed characters | ASCII letters, digits, and underscores ([a-zA-Z0-9_]) |
| Uniqueness | MUST 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.
| Property | Rule |
|---|---|
| Length | 1–64 characters |
| First character | MUST be ASCII alphanumeric ([a-zA-Z0-9]) |
| Allowed characters | ASCII letters, digits, and underscores ([a-zA-Z0-9_]) |
| Uniqueness | MUST be unique across the server |
Regex: ^[a-zA-Z0-9][a-zA-Z0-9_]{0,63}$
Password
| Property | Rule |
|---|---|
| Minimum length | 8 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.
| Property | Rule |
|---|---|
| Maximum length | 64 characters |
| Forbidden characters | ASCII control characters: 0x00–0x1F and 0x7F |
| Unicode | Allowed |
| Uniqueness | NOT required |
Error messages:
"alias exceeds maximum length"(if > 64 characters)"must not contain ASCII control characters"(if contains control characters)
Registration Token
| Property | Rule |
|---|---|
| Allowed characters | ASCII letters, digits, underscores, and hyphens ([a-zA-Z0-9_-]) |
| Validation timing | Validated at server config load time |
| Comparison | MUST use constant-time equality |
Key Package Data
| Property | Rule |
|---|---|
| Minimum size | 4 bytes |
| Maximum size | 16,384 bytes (16 KiB) |
| Bytes 0–1 | MUST be 0x00 0x01 (MLS version 1.0) |
| Bytes 2–3 | MUST be 0x00 0x05 (wire format mls_key_package, per RFC 9420 Section 6) |
Message Expiry Seconds
| Property | Rule |
|---|---|
| Allowed values | -1 (disabled), 0 (delete-after-fetch), or any positive integer |
| Server constraint | When the server has a non-disabled retention policy (not "-1"), the group expiry MUST NOT exceed the server retention value |
Message Fetch Limit
| Property | Rule |
|---|---|
| Default | 100 messages per request |
| Maximum | 500 messages per request |
Values above 500 are capped to 500.
Signing Key Fingerprint
| Property | Rule |
|---|---|
| Format | Lowercase hexadecimal string |
| Length | 64 characters (SHA-256 output = 256 bits = 64 hex digits) |
| Validation | Not strictly validated on upload; stored as-is |
Key Package Count Limits
| Property | Rule |
|---|---|
| Maximum regular packages per user | 10 |
| Maximum last-resort packages per user | 1 (new upload replaces previous) |
| Rate limit on consumption | 10 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
| Value | Meaning |
|---|---|
"-1" | Disabled / indefinite (no limit) |
"0" | Immediate / delete-after-fetch |
Time Units
| Unit | Multiplier (seconds) | Example | Equivalent |
|---|---|---|---|
s | 1 | "15s" | 15 seconds |
h | 3,600 | "2h" | 7,200 seconds |
d | 86,400 | "7d" | 604,800 seconds |
w | 604,800 | "4w" | 2,419,200 seconds |
m | 2,592,000 (30 days) | "1m" | 2,592,000 seconds |
y | 31,536,000 (365 days) | "1y" | 31,536,000 seconds |
Usage
This format is used in:
- Server configuration:
message_retentionandcleanup_intervalfields. - Client commands:
/expirecommand 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 String | Seconds |
|---|---|
"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.
| Condition | Example 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 format | Same 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.
| Condition | Context |
|---|---|
Missing Authorization header | Any authenticated endpoint |
| Invalid or expired token | Any authenticated endpoint |
| Invalid username or password | POST /api/v1/login |
| Not a member of the group | Group-scoped endpoints requiring membership |
| Not an admin of the group | Admin-only endpoints |
| Not the invitee for this invite | POST /api/v1/invites/{id}/accept or decline |
403 Forbidden
Returned when access is explicitly denied by server policy.
| Condition | Context |
|---|---|
| Registration disabled | POST /api/v1/register when registration_enabled is false and no valid token provided |
| Invalid registration token | POST /api/v1/register with incorrect registration_token |
404 Not Found
Returned when the referenced resource does not exist.
| Condition | Context |
|---|---|
| User not found | GET /api/v1/users/{username}, GET /api/v1/users/by-id/{user_id} |
| No key packages available | GET /api/v1/key-packages/{user_id} |
| Group not found | Group-scoped endpoints with invalid group_id |
| No GroupInfo stored | GET /api/v1/groups/{id}/group-info |
| Invite not found | POST /api/v1/invites/{id}/accept, decline |
| Welcome not found | POST /api/v1/welcomes/{id}/accept |
| No pending invite for group+invitee | POST /api/v1/groups/{id}/cancel-invite |
| Target user does not exist | POST /api/v1/groups/{id}/invite, remove, promote, demote |
409 Conflict
Returned when the request conflicts with existing state.
| Condition | Context |
|---|---|
| Username already taken | POST /api/v1/register |
| Group name already taken | POST /api/v1/groups |
| User already a group member | POST /api/v1/groups/{id}/invite, POST /api/v1/groups/{id}/escrow-invite |
| User already an admin | POST /api/v1/groups/{id}/promote |
| Duplicate pending invite | POST /api/v1/groups/{id}/escrow-invite (same group+invitee) |
429 Too Many Requests
Returned when a rate limit is exceeded.
| Condition | Context |
|---|---|
| Key package fetch rate exceeded | GET /api/v1/key-packages/{user_id} (10 req/min per target user) |
500 Internal Server Error
Returned for unexpected server-side failures.
| Condition | Notes |
|---|---|
| Database errors | Connection failures, query errors, constraint violations |
| Password hashing failures | Argon2id computation errors |
| Protobuf encoding failures | Serialization 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:
- Path specified via
--config(or-c) command-line flag. ./conclave.tomlin the current working directory./etc/conclave/config.toml.- Built-in defaults (if no config file is found).
Configuration Fields
Network
| Field | Type | Default | Description |
|---|---|---|---|
listen_address | string | "0.0.0.0" | IP address to bind to. |
listen_port | integer | 8443 (TLS) or 8080 (plain HTTP) | Port to listen on. Default depends on whether TLS is configured. |
Database
| Field | Type | Default | Description |
|---|---|---|---|
database_path | string | "conclave.db" | Path to the SQLite database file. Created automatically if it does not exist. |
Sessions
| Field | Type | Default | Description |
|---|---|---|---|
token_ttl_seconds | integer | 604800 (7 days) | Session token lifetime in seconds. Tokens older than this are expired. |
Invitations
| Field | Type | Default | Description |
|---|---|---|---|
invite_ttl_seconds | integer | 604800 (7 days) | Pending invite lifetime in seconds. Expired invites are cleaned up by the background task. |
Message Retention
| Field | Type | Default | Description |
|---|---|---|---|
message_retention | string | "-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_interval | string | "1h" | Interval between background cleanup runs. Same duration format. |
Registration Control
| Field | Type | Default | Description |
|---|---|---|---|
registration_enabled | boolean | true | Whether public registration is open. When false, registration requires a valid token. |
registration_token | string | (none) | Registration token for invite-only registration. Only checked when registration_enabled is false. Must contain only [a-zA-Z0-9_-]. |
TLS
| Field | Type | Default | Description |
|---|---|---|---|
tls_cert_path | string | (none) | Path to the TLS certificate file (PEM format). |
tls_key_path | string | (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"