OpenE2EE

Key rotation

The five rotation cadences, their exact default intervals, and which ones the SDK performs for you.

Status
stable
Applies to
6.0.0
Platforms
Expo · Browser · Node
Prereqs
A working client from Start → Quickstart
Reading time
11 min

There is no single thing called "key rotation" in this system. There are five kinds of key with five different lifetimes, five different triggers, and five different failure modes when they go stale. Conflating them produces either a client that never rotates anything or one that rotates identity keys on a timer, which is considerably worse.

The five cadences

KeyTriggerWho performs it
One-time prekeysConsumption by peerssyncToServer() or rotatePreKeys() replenishes
EC signed prekeyTime intervalrotatePreKeys(), gated by interval
Kyber prekeyTime intervalrotatePreKeys(), gated by interval
Identity keysExplicit trust eventrotateAccountIdentity(...) only
Group sender keysMembership changerotateGroupSenderKey(groupId)

The exact numbers

Use these. Do not derive intervals from anything else.

ConstantDefaultMeaning
keyRefreshIntervalMs172800000 (2 days)Signed and Kyber prekey refresh interval
maxPreKeyAgeMs1209600000 (14 days)Hard age ceiling; a safety buffer above the refresh interval
preKeyCheckThrottleMs43200000 (12 hours)Minimum spacing between prekey status checks
ONE_TIME_PREKEY_BATCH_SIZE100Batch uploaded per replenishment, EC and Kyber alike
MIN_PREKEY_REPLENISHMENT_THRESHOLD10Internal floor that triggers replenishment
preKeyLowThreshold50Product-facing low-watermark for onPreKeyLow
MAX_EC_PREKEYS200Ceiling on stored EC prekeys
MAX_UNACKNOWLEDGED_SESSION_AGE_MS30 daysAge at which an unacknowledged session is stale
keyExpirationMs604800000 (7 days)Double Ratchet message-key retention

The 10 and the 50 have different purposes. MIN_PREKEY_REPLENISHMENT_THRESHOLD is the internal floor at which the SDK replenishes keys. preKeyLowThreshold controls when the onPreKeyLow callback fires. Your product can then update a dashboard, alert, or force a sync before the internal floor. Setting preKeyLowThreshold near 10 removes this warning margin.

One-time prekeys: consumed, then replenished

One-time prekeys exist for peers to use up. Each peer that establishes a session with you consumes one from your published bundle. A timer does not rotate them. Other people's behavior drains them at a rate you do not control.

syncToServer() and rotatePreKeys() both replenish. Each reads the server-held counts once and uploads a batch of 100 for a set below the internal floor.

await client.syncToServer();

const status = await client.checkPreKeyStatus();
// status.oneTimePreKeysRemaining: number
// status.needsReplenishment     : boolean

Client creation calls syncToServer() when you configure a relay. The SDK does not call it again for you. Call it when the app enters the foreground. Also call it after a relay outage or an operation that consumed keys. preKeyCheckThrottleMs (12 hours) throttles checkPreKeyStatus() internally. More frequent calls do not return newer information.

Wire the low callback at composition time:

const client = await createSignalProtocolClient({
  identity: { userId },
  adapters: { storage, relay },
  preKeyLowThreshold: 50,
  onPreKeyLow: (remaining) => {
    metrics.gauge('signal.prekeys.remaining', remaining);
    void client.syncToServer();
  },
});

Signed prekeys and Kyber prekeys: interval-driven

rotatePreKeys() rotates both in one publication. One inventory read decides whether the EC signed prekey or the Kyber prekey is older than keyRefreshIntervalMs, and whether either one-time set is below the internal floor. It publishes every due key in one upload and returns a PreKeyRotationResult: signedRotated, kyberRotated, oneTimeReplenished, and errors, one message per identity type that failed. Nothing due costs one read and no publication, so calling it more often than every two days is inexpensive and does nothing.

That gating is the reason the recommended pattern is to call it unconditionally on a schedule you own, rather than computing due-ness yourself:

import { withRetry } from '@open-e2ee/signal-protocol-sdk/utils/retry';

async function maintainKeys(client) {
  const result = await withRetry(() => client.rotatePreKeys(), {
    operationName: 'rotatePreKeys',
    maxRetries: 2,
    baseDelay: 2000,
    maxDelay: 30000,
  });
  if (result.errors.length > 0) {
    metrics.increment('signal.prekeys.rotation_errors', result.errors.length);
  }
}

In React, useKeyRotation({ signal, onRotationComplete }) from /hooks calls the same method, at most once per rateLimitMs (one hour by default).

The application owns the schedule. The SDK does not run a timer, does not register a background task, and does not wake your app. On a mobile client the practical trigger is app foreground. On a long-running Node process it is an interval. A client that is offline for three weeks will hold prekeys past maxPreKeyAgeMs (14 days) until it next runs this path.

getSessionHealth(userId) reports keyStatus.signedPreKeyAgeDays, keyStatus.kyberPreKeyAgeDays, and keyStatus.needsRotation. Those are the fields to graph across your population. A rising p95 on signedPreKeyAgeDays means your scheduling trigger is not firing for a meaningful cohort.

cleanupExpiredKeys(remoteAddress) removes expired message keys for a session. The Signal Protocol specification recommends deleting message keys older than one week to bound storage, which matches the keyExpirationMs default of 604800000. This is a per-session operation and returns a boolean.

Identity keys: the expensive one

Identity rotation is not maintenance. It is a trust event.

Every peer pins your exact composite-identity tuple. "Replacing either component of a pinned tuple fails closed until rotation is explicitly accepted. A retired tuple cannot silently regain trust." Identity rotation makes each peer raise UntrustedIdentityError. It also invalidates each safety number that both parties compared. A user who reached VERIFIED must repeat the ceremony.

rotateAccountIdentity(expectedCurrentCommitment, identityType?) rotates the identity. It uses a caller-authenticated compare-and-swap commitment, then publishes fresh prekeys for that namespace. Normal sync and linked-device provisioning never call it.

On the receiving side, a peer accepts a rotation with acceptIdentityRotation(userId, identity, identityType?). That call must be downstream of a human decision, never of an error handler. See identity change and safety numbers.

Group sender keys: membership-driven

Sender keys rotate when membership changes. The rotation removes a departed member's ability to read. removeGroupMemberV2(groupId, editorAci, targetAci) triggers sender-key rotation. rotateGroupSenderKey(groupId) explicitly rotates the key and returns { senderKeyId, distributionMessage }. Distribute the result with distributeGroupSenderKey(groupId, memberUserIds) or distributeSenderKeyToUser(groupId, recipientUserId). handleGroupMembershipChange(groupId, change) returns { rotated, distributionMessage? } so you can assert that rotation occurred.

Verify it rather than assuming it: getGroupSenderKeyStats(groupId, senderId, senderDeviceId) returns generation, and generation advancing is your evidence. See deletion and device revocation and groups.

What forceCompleteKeyReset() is for

It deletes sessions and prekeys and regenerates key material, returning a ForceKeyResetResult with deletedSessions and a deletedPreKeys breakdown (ecSignedPreKeys, ecOneTimePreKeys, kyberPreKeys, kemOneTimePreKeys). The SDK documents it as a development and debugging operation. It is a blunt instrument that resets every conversation on the device. If a production recovery path uses it, that path lacks a more specific operation.

Opacity ledger

ArtifactStays on deviceSent to relayIn object storeVisible as metadata
identityKeyPair private halfyesnonono
identityKeyPair public halfyesyesnoyes
EcSignedPreKey private halfyesnonono
EcSignedPreKey public half + signatureyesyesnoyes, with key ID
KyberPreKey private halfyesnonono
KyberPreKey public halfyesyesnoyes, 0x0A-tagged
EcOneTimePreKey public halves (batch of 100)yesyesnocount is inferable
Sender key (group)yesnonogeneration only, locally
oneTimePreKeysRemainingyesnonoinferable from fetch volume
Session record (version: 4)yesnonono

Next

On this page