OpenE2EE

Convex relay integration

Wire a Convex deployment you own onto the relay contract, and know exactly which SDK artifact lands in which Convex table.

Status
pre-1.0
Applies to
0.1.0
Platforms
Expo · Browser · Node
Prereqs
A Convex deployment and a working client from Start → Quickstart
Reading time
11 min

The Convex relay adapter maps an application-owned generated Convex API module onto ISignalProtocolRelayServer. It is a client-side type mapping and nothing more.

The SDK does not ship a hosted relay. There is no OpenE2EE service, account, or endpoint in this package. The application owns its Convex deployment, schema, authentication, authorization, functions, retention policy, and operational controls. This boundary lets you replace the relay without changing protocol code.

Composition

Organise your Convex functions under one generated namespace, then pass that namespace directly.

import { createSignalProtocolClient } from '@open-e2ee/signal-protocol-sdk';
import {
  convexRelay,
  type ConvexSignalProtocolRelayApi,
} from '@open-e2ee/signal-protocol-sdk/remote/relay/convex';
import { api } from '../convex/_generated/api';

const signalApi = api.signal satisfies ConvexSignalProtocolRelayApi;

const relay = convexRelay({
  convex,
  api: signalApi,
  currentUserId: userId,
});

const client = await createSignalProtocolClient({
  identity: { userId },
  adapters: { storage, relay },
});

The class form is equivalent and takes positional arguments:

import { ConvexSignalProtocolRelayServer } from '@open-e2ee/signal-protocol-sdk/remote/relay/convex';

const relay = new ConvexSignalProtocolRelayServer(convex, signalApi, { currentUserId: userId });

satisfies ConvexSignalProtocolRelayApi checks the namespace at compile time. It reports a missing function or an incorrect function kind. The check does not widen the generated references or discard their precise argument types.

Convex generated references retain those types but do not implement the relay contract. The satisfies operator connects the two contracts.

ConvexSignalProtocolRelayApi describes the messages, devices, keys, certificates, provisioning, groups, and zkAuth groups. You define a set of FunctionReference values for each group. Some references are queries, and others are mutations. This distinction affects behavior. The API types keys.fetchPreKeyBundle as a mutation because each fetch consumes a one-time prekey.

Two Convex-shaped modules, two contracts

ConvexSignalProtocolRelayApi is the relay's function surface. The DeviceLifecycleManager from /device/lifecycle uses a separate, smaller DeviceLifecycleApi surface. It contains devices.getDevices, devices.registerDevice, devices.removeDevice, devices.getDeviceByIdfv, devices.unlinkSecondaryDevices, devices.unlinkAllDevices, and keys.getIdentityKey. The two surfaces share some names but are not interchangeable. See Device registration and lifecycle.

Authorization is the part the SDK cannot do for you

Public-key reads may be available to authenticated peers, but every write must derive ownership from server-side authentication rather than trusting a client-supplied user ID.

The adapter enforces that boundary. It sends targetUserId, targetDeviceId, senderDeviceId, ciphertext, messageType, timestamp, and routing flags. It does not send a sender user ID. The server derives the sender's identity from the authenticated call.

Do not let a caller supply the sender identity. That design lets any authenticated user forge an envelope's from field. The protocol does not detect this product-level forgery. The ciphertext still identifies its encrypting device, but an attacker controls your product's sender identity.

The same requirement applies to registration, provisioning, unlink, and removal.

Backend authentication must bind these operations to the owning account. The server also owns linked-device slot allocation. Allocate slots 2 through 5 inside an authenticated mutation. Do not allocate them on the client.

What lands in Convex

Nine component tables carry the relay's state:

devices, identityKeys, ecPreKeys, ecSignedPreKeys, kemOneTimePreKeys, kemLastResortPreKeys, messages, provisioningSessions, and prekeyBundleFetches.

What that means concretely:

  • Envelopes land in messages as base64 ciphertext plus routing metadata. Convex, your dashboard, and your operators cannot read their contents. They can query the recipient, device, timestamp, and size. Encryption does not hide this routing metadata.
  • Public prekey bundles are split across identityKeys, ecSignedPreKeys, ecPreKeys, kemOneTimePreKeys, and kemLastResortPreKeys. All entries contain public key material and signatures. The material is not secret, but its integrity is critical.
  • Device records live in devices. They contain the device ID, type, registration state, linked/enabled/active flags, and last-seen timestamp.
  • Encrypted device names exist as bytes on the device record. A device encrypts its name for backend storage. It uses the account identity key to decrypt the plaintext label that the device list shows.
  • Provisioning sessions live in provisioningSessions and expire after five minutes.
  • Prekey bundle fetches create records in prekeyBundleFetches. The next section depends on these consumption records.

What never lands in Convex: identity private keys, prekey private halves, session records, ratchet state, message plaintext, media keys, and the store's wrapping key. The relay never needs message plaintext or device private keys.

One-time-prekey consumption under concurrency

This is a correctness constraint your Convex functions must satisfy, not a performance note.

Two peers can call keys.fetchPreKeyBundle for the same target device at the same moment. The relay must not give both peers the same unused one-time prekey.

Use one Convex mutation for this operation. Select an unconsumed prekey row, mark it consumed, and return the bundle in that mutation. Convex mutations run as transactions. Optimistic concurrency control retries a mutation when another transaction invalidates its read set. Two competing callers therefore receive two different prekeys.

The failure shapes to avoid are the ones that look reasonable:

  • A query that reads the bundle plus a mutation that marks it consumed. This design uses two transactions. A second caller can use the gap between them.
  • An action that orchestrates both. Actions are not transactional. Data can change between an action's read and write operations.
  • Deleting the row after the caller confirms use. The caller might never confirm. The relay can serve the prekey during that wait.

When no unconsumed one-time prekey exists, return a bundle without one. The last-resort Kyber prekey and signed prekey support that case. A hard failure would stop the conversation instead of reducing its protection. Instrument the frequency of this fallback because it is otherwise invisible. See the prekey exhaustion failure mode in Relay and prekeys.

Opacity ledger

ArtifactConvex tableStays on deviceSent to relayIn object storeVisible as metadata
identityKeyPair private halfyesnonono
identityKeyPair public halfidentityKeysyesyesnoyes
Signed prekey public half + signatureecSignedPreKeysyesyesnoyes
EC one-time prekey public halvesecPreKeysyesyesnocount
Kyber one-time prekey public halveskemOneTimePreKeysyesyesnocount
Kyber last-resort prekey public halfkemLastResortPreKeysyesyesnoyes
All prekey private halvesyesnonono
Envelope ciphertextmessagesyesyesnosize
targetUserId, targetDeviceId, senderDeviceIdmessagesyesyesnoyes
messageType, timestamp, clientMessageIdmessagesyesyesnoyes
Message plaintextyesnonono
deviceId, registrationIddevicesyesyesnoyes
encryptedDeviceNamedevicesyesyesnociphertext length
Plaintext device nameyesnonono
Provisioning sessionIdprovisioningSessionsyesyesnoyes
Provisioning ephemeralKeyPair private halfyesnonono
One-time-prekey consumption recordprekeyBundleFetchesnoyesnoyes
SessionRecord (version: 4)yesnonono

Next

On this page