OpenE2EE

Device registration and lifecycle

How a user becomes a set of devices, who allocates device IDs, and what registration, provisioning, unlink, and account reset each own.

Status
pre-1.0
Applies to
0.1.0
Platforms
Expo · Browser · Node
Prereqs
A working client from Start → Quickstart
Reading time
10 min

A user represents a changing set of devices, not an account row. Signal Protocol sessions address individual devices. The SDK therefore scopes each key, session, and envelope to a (userId, deviceId) pair. Your product defines the account above that pair.

This fact controls the complete lifecycle. A reinstalled phone becomes a different device for the same user. A linked tablet receives a new cryptographic identity with no previous sessions. An account reset destroys device-local key material. No server can restore that material.

One user has three devices. Each device holds its own private key.deviceId: 1, primarydeviceId: 2, linkeddeviceId: 3, linked
Each device holds its own private key material. No key crosses a device boundary.

Construct a client with a device identity

createSignalProtocolClient takes the device identity up front. It is not mutable afterwards.

import { createSignalProtocolClient } from '@open-e2ee/signal-protocol-sdk';

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

deviceId is optional and defaults to 1, the primary device. A single-device product can omit it. Add an explicit value before the product supports another device.

Let the backend allocate device IDs

The backend allocates linked device IDs from 2 through 5. A client must not select its own linked-device ID. Each user can have at most five devices: one primary and four linked devices.

If two devices both select deviceId: 2, they overwrite each other's prekeys. They also receive envelopes for the other device. The resulting session state cannot converge. The relay contract assigns linked-device slot allocation to the server.

Provision a linked device before you construct its client. Write the provisioned identity material before you call SignalProtocolClient.create(..., { deviceId: 2 }). An empty store does not bootstrap a linked device. Instead, it creates an identity that the rest of the account does not recognize.

The device-ID cache

Store the allocated ID in platform secure storage so it survives process restarts. Read it through the device-ID module, not the client.

import {
  clearDeviceIdCache,
  getDeviceId,
  getDeviceIdSync,
  preloadDeviceId,
} from '@open-e2ee/signal-protocol-sdk/device/device-id';

await preloadDeviceId();
const deviceId = await getDeviceId();

getDeviceIdSync() returns the cached value or the primary-device default. Call it after preloadDeviceId() resolves. Otherwise, use it only where a fallback to device 1 is safe. A synchronous read before preload returns 1 on a linked device. All dependent calls then address the wrong device.

What syncToServer() publishes

await client.syncToServer();

This call publishes the device's public identity material and prekeys. It also replenishes low one-time-prekey inventory. After the call, peers can fetch a bundle and send envelopes to the device. Before the call, the relay cannot fan messages out to it.

Afterward, client.syncStatus reports 'synced' | 'failed' | 'none'. The device sends public key material and routing metadata. The relay never needs message plaintext or device private keys. The device generates private halves and keeps them in the local store. See Relay and prekeys for replenishment values and the rotation schedule.

DeviceLifecycleManager

DeviceLifecycleManager provides a framework-neutral registration workflow. It persists the device ID, compares it with the backend registry, detects stale or orphaned devices, and clears local state.

import {
  DeviceLifecycleManager,
  type DeviceLifecycleDeps,
} from '@open-e2ee/signal-protocol-sdk/device/lifecycle';

const lifecycle = new DeviceLifecycleManager(userId, deps);
await lifecycle.initialize();

DeviceLifecycleDeps injects a secure store and a backend client with query and mutation. It also injects your generated function references, key-storage operations, and logger. The manager forwards those references. It does not select a backend. It exposes initialize(), registerDevice(), fetchDevices(), loadStoredDeviceId(), checkDeviceStale(), makePrimary(), and clearLocalDevice().

The application remains responsible for authenticated backend functions and for placing initialization in its own startup lifecycle.

One lifecycle, four operations

Registration, provisioning, unlink, and account reset are four points on one product-level lifecycle, not four independent features.

Registration claims a device slot and publishes key material.

Provisioning adds a linked device. It transfers account identity material and optional account metadata, but not existing sessions or message history. A provisioning session expires after five minutes. Ephemeral ECDH keys derive its encryption keys. The source device encrypts identity material before transport.

Unlink removes a slot and must remain consistent across active identity types.

Account reset must clear the device-ID cache, platform secret storage, and protocol store as one product-level lifecycle.

Do not clear only the protocol store with client.clearAllData(). If the cached device ID and vault wrapping key remain, the device reports a linked ID. It also holds a key for a database that no longer exists. The device then generates a new identity under an occupied slot. Clear all three resources, or clear none.

The device encrypts its name for backend storage. The relay holds encryptedDeviceName bytes. The device decrypts the plaintext label for its local device list.

Authentication is yours

The SDK does not provide login, session tokens, or an account model.

Backend authentication must bind registration, provisioning, unlink, and removal to the owning account. Do not accept a client-supplied userId for a relay write. That design lets any authenticated caller register another user's device.

The SDK interface guide defines this responsibility boundary:

"the client owns protocol coordination; the host application owns persistence, authentication, authorization, and product policy."

Opacity ledger

ArtifactStays on deviceSent to relayIn object storeVisible as metadata
identityKeyPair private halfyesnonono
identityKeyPair public halfyesyesnoyes
compositeIdentity (canonical CompositeIdentityV1)yesyesnoyes
deviceId (cached under DEVICE_ID_KEY)yesyesnoyes
registrationIdyesyesnoyes
encryptedDeviceNameyesyesnociphertext length
Plaintext device nameyesnonono
SessionRecord (version: 4)yesnonono
Provisioning ephemeralKeyPair private halfyesnonono
Provisioning sessionIdyesyesnoyes
"signal-store-wrapping-key" vault entryyesnonono

Next

On this page