Relay and prekey infrastructure
What a relay must do, what it never needs, and the exact prekey batch sizes, thresholds, and rotation intervals the SDK uses.
- Status
- pre-1.0
- Applies to
- 0.1.0
- Platforms
- Expo · Browser · Node
- Prereqs
- A working client from Start → Quickstart
- Reading time
- 12 min
Signal Protocol encrypts content between devices. It does not provide account authentication, device discovery, mailbox delivery, or remote object storage. An authenticated relay provides those functions. The relay boundary keeps private keys and plaintext outside your backend.
A relay is a device, prekey, and envelope service. It is not a key escrow, not a message archive you can read, and not a trust anchor. The formula the SDK holds to, exactly as written:
the relay never needs message plaintext or device private keys
This statement defines what the protocol requires from a relay. It does not define what operators can observe or logs can retain. It also does not limit what an attacker can see after a deployment compromise. Your operations determine those properties. See Limits and metadata.
What a relay implementation must preserve
ISignalProtocolRelayServer is the contract. An implementation must preserve the package's protocol semantics for:
- identity and prekey upload
- prekey bundle fetch
- device registration and listing
- envelope delivery
- linked-device provisioning state
- stale-device and unlink cleanup
Two invariants sit on top of that list, and both are correctness requirements rather than recommendations.
One-time-prekey consumption
A bundle fetch must consume one-time prekeys atomically.
The relay must not give one unused prekey to multiple callers. Reuse removes the forward-secrecy property that the prekey provides. The responder's store rejects the second use as a replay. The second use causes session establishment to fail. It does not cause a silent reduction in protection. Use one atomic read-and-consume operation under concurrency.
Device ownership
The server allocates linked-device slots. Clients must not
select linked deviceId values. Unlink operations and stale cleanup must remain
consistent across active identity types. Readers need public-key access to create
sessions. Writers can change only their own account and device state. If a write
trusts a client-supplied user ID, any authenticated caller can publish another
user's keys.
For a custom backend:
import type { ISignalProtocolRelayServer } from '@open-e2ee/signal-protocol-sdk/remote/relay/types';For local development, inMemoryRelay() from /remote/relay/memory gives multiple clients a shared in-memory relay. It is development only. For a concrete production path, Convex relay integration maps a Convex deployment onto this contract.
Prekeys
This is the part that decides whether your product degrades gracefully or silently.
A peer uses a prekey bundle to create a session with an offline device. The bundle contains the device's composite identity, signed prekey, and last-resort Kyber prekey. When available, it also contains a one-time prekey that the relay consumes on fetch. This one-time prekey gives the first message forward secrecy before the ratchet turns. The following controls keep one-time prekeys available.
The device generates all private halves and keeps them in the local store. Only public halves reach the relay.
The numbers
These are the SDK's actual values. Use them. Do not extrapolate.
| Value | Setting | Meaning |
|---|---|---|
100 | ONE_TIME_PREKEY_BATCH_SIZE | One-time prekeys generated per replenishment, EC and Kyber alike |
10 | MIN_PREKEY_REPLENISHMENT_THRESHOLD | Internal trigger — below this, replenishment runs |
50 | preKeyLowThreshold (client config default) | Product-facing low-watermark for your own alerting |
200 | MAX_EC_PREKEYS | Ceiling on server-held EC prekeys; replenishment fills available slots |
172800000 | keyRefreshIntervalMs (2 days) | Signed-prekey and Kyber-prekey rotation interval |
1209600000 | maxPreKeyAgeMs (14 days) | Maximum age before prekey material is treated as stale |
43200000 | preKeyCheckThrottleMs (12 hours) | Minimum spacing between status checks |
The 10 and 50 values have different purposes. The SDK acts at 10. Your
application should detect the condition at 50. The checkPreKeyStatus() method
compares against this threshold, and the onPreKeyLow callback fires there. This
telemetry shows the decline before the SDK reaches its internal minimum.
The rotation interval is two days. A stale SDK source comment claims a longer
period. The keyRefreshIntervalMs default of 172800000 is authoritative.
The API
// Replenish and publish. Idempotent enough to run at app start.
await client.syncToServer();
// Report, without mutating.
const status = await client.checkPreKeyStatus();
// { oneTimePreKeysRemaining: number, needsReplenishment: boolean }syncToServer() fetches the server-held counts. If EC or Kyber one-time prekeys
are below the internal threshold, it generates a batch. It uploads the public
halves without exceeding the 200-key limit. checkPreKeyStatus() reports the
status. The SDK limits real checks to one per preKeyCheckThrottleMs.
Rotate the longer-lived keys explicitly:
await client.rotateEcSignedPreKey();
await client.rotateKyberPreKey();Both methods check age against keyRefreshIntervalMs. Use
forceCompleteKeyReset() when you cannot repair published device keys. Causes
include a restored backup, a reclaimed slot, or an identity mismatch. The method
regenerates and republishes all key material. It destroys existing sessions with
that device.
Peers will see a safety-number change. Key rotation covers the operational schedule.
Opacity ledger
| Artifact | Stays on device | Sent to relay | In object store | Visible as metadata |
|---|---|---|---|---|
identityKeyPair private half | yes | no | no | no |
compositeIdentity (canonical CompositeIdentityV1) | yes | yes | no | yes |
| Signed prekey public half + signature | yes | yes | no | yes |
| Signed prekey private half | yes | no | no | no |
EC one-time prekey public halves (batch of 100) | yes | yes | no | count |
| EC one-time prekey private halves | yes | no | no | no |
| Kyber one-time prekey public halves | yes | yes | no | count |
| Kyber last-resort prekey public half | yes | yes | no | yes |
| Kyber prekey private halves | yes | no | no | no |
registrationId | yes | yes | no | yes |
deviceId | yes | yes | no | yes |
encryptedDeviceName | yes | yes | no | ciphertext length |
| Envelope ciphertext | yes | yes | no | size, sender, recipient, timestamp |
| Message plaintext | yes | no | no | no |
SessionRecord (version: 4) | yes | no | no | no |
Next
- Convex relay integration: deploy a concrete relay
- Key rotation: run the two-day and fourteen-day schedules
- Limits and metadata: understand what the envelope exterior reveals
- Offline and reconciliation: deliver when the device is offline
Local encrypted storage
Why the storage adapter is required, what the four adapters actually support, and why the vault and the store must never be blurred.
Convex relay integration
Wire a Convex deployment you own onto the relay contract, and know exactly which SDK artifact lands in which Convex table.