OpenE2EE

Multi-device

Linking a second device, the difference between provisioning and transfer, and why a new device cannot read messages that predate it.

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

Every Alice-and-Bob tutorial stops at two devices that both existed before the first message. Real products do not work that way. Alice buys a tablet in March, and her phone dies in July. Both events affect session state that started its ratchet in January. This page covers those events.

The device model

A user is not an account row. A user is a changing set of devices, each of which is its own cryptographic identity with its own sessions.

The primary device uses deviceId 1. The backend allocates linked device IDs from 2 through 5. A client must not choose its own linked-device ID. That gives a ceiling of five devices: one primary and four linked. The relay owns slot allocation because it is the only party that can see the whole set and enforce uniqueness against an authenticated account.

Sesame is the specification that governs this. The SDK selects SESAME's per-user identity-key model and provisions that tuple unchanged across linked devices. Registration IDs, prekeys, and sessions are device-specific. Sesame is Revision 2, dated 2017-04-14. It is the least-implemented member of the Signal Protocol family. As far as we are aware, no other JavaScript library implements it. This SDK therefore offers a distinct capability.

Provisioning is not transfer

These are two deliberately separate operations and confusing them is the most expensive mistake available on this page.

ProvisioningTransfer
PurposeAdd a linked deviceMove to replacement hardware
Moves identity materialyesyes
Moves prekeysnoyes
Moves sessionsnoyes
Moves message historynovia encrypted backup, if you built one
Subpath/device/provisioning/device

Provisioning adds a peer. Transfer replaces one. A user might tap "add my tablet" and expect their archive to appear. Provisioning does not move that archive and never intended to do so.

Linking a device

The primary device opens a short-lived session and renders the QR:

import {
  generateProvisioningQR,
  provisionDevice,
} from '@open-e2ee/signal-protocol-sdk/device/provisioning';

const { sessionId, qrCodeUrl, ephemeralKeyPair } =
  await generateProvisioningQR(relay, userId);

await appQr.show(qrCodeUrl);

const { newDeviceEphemeralPublicKey } =
  await appProvisioning.waitForLinkedDevice(sessionId);

await provisionDevice(
  relay,
  appProfile,
  sessionId,
  ephemeralKeyPair.privateKey,
  newDeviceEphemeralPublicKey,
  userId,
  { identityStore, groupStateStore },
);

The new device scans it, joins the session, and stores the encrypted result:

import {
  connectToProvisioningSession,
  getDeviceMetadata,
  parseProvisioningQR,
  receiveProvisioningMessage,
} from '@open-e2ee/signal-protocol-sdk/device/provisioning';

const { sessionId, primaryEphemeralPublicKey } = parseProvisioningQR(scannedQrCode);

const deviceMetadata = getDeviceMetadata("Alice's tablet");
const linkedKeys = await connectToProvisioningSession(relay, sessionId, deviceMetadata);

const provisioning = await receiveProvisioningMessage(
  relay,
  sessionId,
  linkedKeys.privateKey,
  primaryEphemeralPublicKey,
  {
    identityStore,
    localStateStore,
    groupStateStore,
    usernameStateStore,
    deviceMetadata,
  },
);

console.log(provisioning.deviceId); // allocated by the backend, 2–5

Only then can the linked device construct a client. Linked devices must already contain provisioned identity material before SignalProtocolClient.create(..., { deviceId: 2 }). Calling it first gives you a device holding a different identity than the account it claims to belong to.

Security boundaries

  • Provisioning sessions expire after five minutes.
  • Ephemeral ECDH keys derive the provisioning/transfer encryption keys.
  • Devices encrypt identity and backup material before it reaches a relay or transport.
  • Devices encrypt names for backend storage.
  • Backend authentication must bind registration, provisioning, unlink, and removal to the owning account.
  • Account reset must clear the device-ID cache, platform secret storage, and protocol store as one product-level lifecycle.

The QR channel authenticates the session only to the extent that your application protects what the user scans or shares. A screenshot in a group chat exposes a provisioning invitation. Only the five-minute expiry prevents that invitation from authorising an unwanted linked device.

Provisioning moves identity material to a linked device. Message history stays on the primary device.primary · deviceId 1linked · deviceId 2–5sealno historyno sessions
Encrypted identity material crosses the boundary and exposes metadata. Message history stays on the primary device.

Keeping linked devices in step

Sessions are per-device. A linked device can contradict the primary if it does not receive a read, block, or verification decision. The SDK ships explicit sync operations for state that has no other path between devices:

await signal.syncReadToLinkedDevices(/* … */);
await signal.syncConfigurationToLinkedDevices(/* … */);
await signal.syncVerificationStateToLinkedDevices(/* … */);
await signal.syncBlockedRecipientsToLinkedDevices(blocked);

Also available: syncViewOnceOpenToLinkedDevices, syncMediaAttachmentDeleteToLinkedDevices, syncUsernameStateToLinkedDevices, syncRecipientUsernameToLinkedDevices, and syncTaskNotificationAckToLinkedDevices. Each is a message you choose to send. None of them fire on their own.

Transfer to replacement hardware

If a new device replaces an old device instead of joining it, use the transfer path:

import {
  prepareNewDeviceTransfer,
  prepareOldDeviceTransferWithBackup,
} from '@open-e2ee/signal-protocol-sdk/device';

const receiving = await prepareNewDeviceTransfer();
await appQr.show(receiving.qrCode);

const sending = await prepareOldDeviceTransferWithBackup(backupStorage);
const backup = await sending.getBackup(sessionIds);

The application owns transport selection, peer confirmation, progress UI, interruption recovery, and wiping old-device state after a successful replacement. Do not erase the old device before the new device validates and durably restores the backup. That ordering is the whole safety property, and it is yours to enforce.

Opacity ledger

ArtifactStays on deviceSent to relayIn object storeVisible as metadata
ephemeralKeyPair private halfyesnonono
ephemeralKeyPair public halfyesyesnoyes
sessionId from generateProvisioningQR()yesyesnoyes, plus five-minute lifetime
qrCodeUrlyesnonono
Provisioned identity materialyesencryptednothat a provisioning envelope moved
deviceMetadata device nameyesencryptednothat a name exists
provisioning.deviceIdyesyesnoyes — the relay allocates it
Registration IDs, prekeys, sessionsyes, per devicepublic prekeys onlynoprekey counts
Message historyyes, on devices that had itnonono
Transfer backup from getBackup(sessionIds)yesencrypted, if you route it that wayoptionalsize

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

Next

On this page