OpenE2EE

Connect an Expo app to a Convex encrypted-envelope relay

Replace the in-memory relay with a Convex deployment you own, and get one-time-prekey consumption right under concurrency.

Status
pre-1.0
Applies to
0.1.0
Platforms
Expo · Browser · Node
Prereqs
A working Expo client from Guides → Build an encrypted Expo conversation, and a Convex deployment you own
Reading time
15 min

The previous guide stopped at inMemoryRelay(): an in-memory relay with no authentication, no persistence, and no concurrency. This one replaces it with a relay you own.

Define the ownership boundary before you write code: the SDK ships an adapter, not a hosted relay.

This package has no OpenE2EE endpoint, account, or Convex component. ConvexSignalProtocolRelayServer maps your generated Convex API module to ISignalProtocolRelayServer. Your application owns its Convex deployment, schema, authentication, authorization, functions, retention policy, and operational controls.

This design requires more work than a hosted service. It also makes the relay replaceable without changes to protocol code. OpenE2EE does not receive your users' metadata.

Intended audience

You have an Expo client from build an encrypted Expo conversation that sends messages through inMemoryRelay(). Now you need delivery between devices. You can write Convex queries and mutations and reason about transactions.

Prerequisites

  • A working client from the Expo guide: development build, ExpoSignalProtocolStore, ExpoSecureStoreSignalProtocolSecretVault.
  • A Convex deployment you control, with authentication configured. Every write below derives its identity from ctx.auth. A relay that trusts a client-supplied user ID is not a relay, it is a forgery service.
  • Relay and prekeys for the contract, and Convex relay integration for the adapter's boundary in isolation.

0.1.x; public APIs and persisted formats may change before 1.0.

What this guide builds

An Expo device with an attached local store sending across a trust boundary into a Convex relay that holds a sealed envelope and a readable public prekey bundleExpo deviceExpoSignalProtocolStoremessages · sealed, ticks are what stays readableecPreKeys · kemOneTimePreKeys · outlined, and consumed onceseal
The relay holds two different types of data. The envelope stays sealed. The public prekey bundle is readable and designed to travel. The distinction defines the correctness problem on this page.

Install

npm install @open-e2ee/signal-protocol-sdk@0.1.0 convex

Stage 1: the function namespace

The adapter calls a nested namespace of Convex function references described by ConvexSignalProtocolRelayApi. You define every one of them. The exact shape, verbatim from the adapter's types:

  • messages: send, getPendingMessages, markDelivered, getGroupMembers, getActiveDevices, sendUnidentified, sendMultiRecipientUnidentified, sendRetryRequest, getPendingRetryRequests, markRetryRequestHandled
  • devices: getDevices, registerDevice, removeDevice, markDeviceConnected, markDeviceDisconnected, presenceHeartbeat
  • keys: uploadIdentityKey, getIdentityKey, uploadPreKeys, fetchPreKeyBundle, getPreKeyCount, clearStaleKemPreKeys, uploadEcSignedPreKey, uploadKemLastResortPreKey, getEcSignedPreKeyMetadata, getKemLastResortPreKeyMetadata
  • certificates: issueSenderCertificate
  • provisioning: createProvisioningSession, connectNewDevice, sendProvisioningMessage, getProvisioningMessage, completeProvisioning, acknowledgeProvisioning, rollbackProvisioning, deleteProvisioningSession
  • groups: createGroup, getGroup, getGroupChanges, submitGroupChange, refreshGroupSendEndorsements
  • zkAuth: issueAuthCredentialMutation

Whether each is a query or a mutation is not cosmetic. The generated API types keys.fetchPreKeyBundle as a mutation because a bundle fetch consumes a one-time prekey. Hold that thought.

Organise them under one generated namespace, convex/signal/* exported as api.signal, so the adapter can take the namespace whole.

Stage 2: wire the adapter

import { createSignalProtocolClient } from '@open-e2ee/signal-protocol-sdk';
import { expoStore } from '@open-e2ee/signal-protocol-sdk/local/store/expo';
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,
  getAuthToken: async () => authToken,
});

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

await signal.syncToServer();
signal.registerHook('onMessageDecrypted', persistMessage);
signal.startRelaySubscription();

The class form is equivalent and positional:

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

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

That is the entire diff from the previous guide: inMemoryRelay() becomes convexRelay({...}). Nothing else in the client changes, which is the point of an adapter boundary.

satisfies ConvexSignalProtocolRelayApi provides a compile-time error for a missing function or wrong type. It preserves the generated references and their argument types.

getAuthToken is optional and changes the transport: supplied, the adapter creates an internal Convex client with WebSocket push, reading EXPO_PUBLIC_CONVEX_URL. Omitted, it falls back to polling messages.getPendingMessages. Both deliver. Only one is instant.

Stage 3: authorization is the part the SDK cannot do

Look at what the adapter sends when it delivers an envelope: targetUserId, targetDeviceId, senderDeviceId, ciphertext, messageType, urgent, ephemeral, groupId, timestamp, clientMessageId, recipientRegistrationId.

senderUserId is absent, deliberately. The server derives it from JWT auth. The same applies to devices.registerDevice, which sends deviceId, encryptedDeviceName, and deviceType but no user ID.

If your mutation accepts a caller-supplied sender identity, any authenticated user can forge an envelope's from field. The protocol cannot detect this product-layer error. The ciphertext remains authentic, but the attacker controls the displayed sender identity.

// convex/signal/messages.ts — application-owned
export const send = mutation({
  args: {
    targetUserId: v.string(),
    targetDeviceId: v.number(),
    senderDeviceId: v.number(),
    ciphertext: v.string(),
    messageType: v.string(),
    timestamp: v.number(),
    clientMessageId: v.optional(v.string()),
    urgent: v.optional(v.boolean()),
    ephemeral: v.optional(v.boolean()),
    groupId: v.optional(v.string()),
    recipientRegistrationId: v.optional(v.number()),
  },
  handler: async (ctx, args) => {
    const identity = await ctx.auth.getUserIdentity();
    if (!identity) throw new Error('Unauthenticated');
    const senderUserId = identity.subject; // never args
    // insert into your messages table, return { messageId, serverTimestamp }
  },
});

Two more rules from the relay contract, both non-negotiable:

  • Backend authentication must bind registration, provisioning, unlink, and removal to the owning account.
  • The server owns linked-device slot allocation. Clients must not choose linked deviceIds. Allocate from 2 through 5 inside an authenticated mutation. Device 1 is the primary. Maximum 5 devices per user.

Public-key reads may be available to authenticated peers. Every write must derive ownership from server-side authentication.

Stage 4: one-time-prekey consumption under concurrency

This deserves its own section because it is a correctness bug, not a performance note, and because it is invisible in testing.

Two peers can call keys.fetchPreKeyBundle for the same target device at once. The relay must not return the same unused one-time prekey twice.

A one-time prekey gives PQXDH a per-session secret that no other session shares. If two initiators receive one prekey, both sessions use the same one-time input. The recipient deletes the private half after first use. The second session then fails or uses less independent key material than the protocol expects. This defect weakens forward secrecy without an explicit log signal. A 2025 WhatsApp study found that 13% of companion devices lacked a one-time prekey during the scan.

Convex runs mutations as transactions, so the implementation is short. In one mutation, select an unused prekey row, consume it, and return the bundle. Convex retries a mutation when another transaction invalidates its read set. Two concurrent callers therefore receive different prekeys.

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

  • A query followed by a consume mutation. Two transactions leave a window for a second caller. The SDK therefore types fetchPreKeyBundle as a mutation.
  • An action that orchestrates both. Actions are not transactional. Another operation can change a value between an action's read and write.
  • Deleting the row only after the caller confirms use. The caller may never confirm. Meanwhile the prekey is still servable.

When no unconsumed one-time prekey exists, return a bundle without one rather than failing. The Kyber last-resort prekey and the signed prekey exist for exactly that case, and a hard failure takes the conversation down instead of degrading it. Then instrument the rate, because degradation is otherwise silent.

The adapter expects the server to derive the fetcher's identity from authentication. It documents a limit of 10 fetches per minute for each fetcher-target pair. Bundle fetch also enables enumeration. Without a limit, an authenticated user can drain another user's prekeys and force later sessions onto the last-resort key.

Stage 5: the rest of the relay's responsibilities

The contract lists six, and each one has a shape your functions have to honour.

Identity and prekey upload. keys.uploadIdentityKey carries mode: 'provision' | 'rotate', compositeIdentity, registrationId, and identityType. The base64 compositeIdentity contains CompositeIdentityV1. Rotation also carries expectedCurrentCommitment, a compare-and-swap value that you must check. keys.uploadPreKeys carries keys: [{ type, keyId, publicKey, signature }]. The type selects ecPreKey, ecSignedPreKey, kemOneTimePreKey, or kemLastResortPreKey as the destination.

Prekey bundle fetch. Covered above. Return deviceId, registrationId, compositeIdentity, signedPreKey, an optional oneTimePreKey, and an optional kyberPreKey.

Device registration and listing. devices.registerDevice allocates the slot. devices.getDevices lists them. Device names arrive as encryptedDeviceName bytes. The device encrypts names for backend storage and decrypts each label before rendering the device list.

Envelope delivery. messages.send, messages.getPendingMessages({ deviceId }), messages.markDelivered({ messageId }). Make send idempotent on clientMessageId so a client retrying after an unknown result gets the original accept metadata instead of a duplicate pending envelope.

Linked-device provisioning state. Eight functions, and one hard limit: provisioning sessions expire after five minutes. Ephemeral ECDH keys derive the provisioning encryption keys. Devices encrypt identity material before it reaches the relay. Provisioning transfers identity: not sessions and not history. Migrating to replacement hardware is a different operation entirely. See Multi-device and Recovery, backup, migration.

Stale-device and unlink cleanup. Must stay consistent across active identity types. keys.clearStaleKemPreKeys exists for the prekey half of it.

What the backend can see

Opacity ledger

ArtifactConvex tableStays on deviceSent to relayIn object storeVisible as metadata
identityKeyPair private halfyesnon/ano
compositeIdentity (public CompositeIdentityV1)identityKeysyesyesn/ayes
registrationIdidentityKeysyesyesn/ayes
ecSignedPreKey public half + signatureecSignedPreKeysyesyesn/ayes
ecPreKey public halvesecPreKeysyesyesn/acount
kemOneTimePreKey public halves + signatureskemOneTimePreKeysyesyesn/acount
kemLastResortPreKey public half + signaturekemLastResortPreKeysyesyesn/ayes
All prekey private halvesyesnon/ano
Envelope ciphertextmessagesyesyesn/asize
targetUserId, targetDeviceId, senderDeviceIdmessagesyesyesn/ayes
messageType, timestamp, clientMessageIdmessagesyesyesn/ayes
senderUserId (derived from JWT auth)messagesn/anon/ayes
Message plaintextyesnon/ano
deviceId, deviceTypedevicesyesyesn/ayes
encryptedDeviceNamedevicesyesyesn/aciphertext length
Plaintext device nameyesnon/ano
Provisioning sessionId and encrypted payloadprovisioningSessionsyesyesn/ayes
Provisioning ephemeral private keyyesnon/ano
One-time-prekey consumption recordprekeyBundleFetchesnoyesn/ayes
SessionRecord (version: 4), ratchet stateyesnon/ano

Those nine table names, devices, identityKeys, ecPreKeys, ecSignedPreKeys, kemOneTimePreKeys, kemLastResortPreKeys, messages, provisioningSessions, prekeyBundleFetches, come from the adapter's own type documentation. The SDK does not ship them as a schema. Its types use these nine buckets, and your convex/schema.ts creates them.

The relay never needs message plaintext or device private keys. It does need everything in the "Sent to relay" column, and that column is your metadata exposure: see Limits and metadata.

Failure and recovery behaviour

Production caveats

  • We publish the package on npm as 0.1.x: AGPL-3.0-or-later, reviewed continuously by adversarial AI agents, and not audited by any independent firm. Public APIs and persisted formats may change before 1.0.
  • The Expo store requires a development build and is not available in Expo Go. The bare React Native store needs a key-value backend you supply and verify with the exported backend-conformance kit. inMemoryRelay() and inMemoryStore() are development only and must not reach production.
  • Push reads EXPO_PUBLIC_CONVEX_URL and needs getAuthToken. Without it, polling adds one function call for each device and interval.
  • Multi-device cost is multiplicative: (nA−1)·nB encryptions per message. Five devices per user is not five times the work.
  • Sealed sender does not compose. Published research: "this one-sided anonymity is broken when two parties send multiple messages back and forth; that is, the promise of sealed sender does not compose over a conversation of messages": linkable in as few as 5 messages. E2EE is not anonymity.
  • Rebuild server-side features. Meta rebuilt "well over 100" Messenger features because "A lot of the logic was on the server and that doesn't work anymore." Plan for search, moderation, and notification-content changes.
  • Assurance today: 384 modules, 6,893 assertions, 2 skipped, 0 failed, 330 s. Public CI runs npm ci, build, typecheck, and npm audit --omit=dev.

Next

On this page