OpenE2EE

Key rotation

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

Status
pre-1.0
Applies to
0.1.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() replenishes
EC signed prekeyTime intervalrotateEcSignedPreKey(), gated by interval
Kyber prekeyTime intervalrotateKyberPreKey(), 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.

You may encounter an older cadence in a source docstring on the rotation methods. The keyRefreshIntervalMs default of 172800000 ms is authoritative.

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 behaviour drains them at a rate you do not control.

syncToServer() is the operation that replenishes. Nothing else does.

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

rotateEcSignedPreKey() and rotateKyberPreKey() each return a boolean. They return true after they rotate a key. They return false before rotation is due. keyRefreshIntervalMs gates both functions internally, so calling them more often than every two days is inexpensive and does nothing.

That gating is the reason the recommended pattern is to call them 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) {
  await withRetry(() => client.rotateEcSignedPreKey(), {
    operationName: 'rotateEcSignedPreKey',
    maxRetries: 2,
    baseDelay: 2000,
    maxDelay: 30000,
  });
  await withRetry(() => client.rotateKyberPreKey(), {
    operationName: 'rotateKyberPreKey',
    maxRetries: 2,
    baseDelay: 2000,
    maxDelay: 30000,
  });
  await client.syncToServer();
}

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 IdentityKeyChangedError. 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