Deletion and device revocation
What deletion can and cannot mean in an encrypted system, and the lifecycle operations that actually revoke access.
- Status
- pre-1.0
- Applies to
- 0.1.0
- Platforms
- Expo · Browser · Node
- Prereqs
- A working client from Start → Quickstart
- Reading time
- 12 min
Start with the boundary that controls every deletion claim. Your product copy and legal review must use the same boundary.
Cryptographic confidentiality creates no remote deletion guarantee. After another device decrypts a message, its user controls that plaintext. Copies can exist in device storage, backups, and screenshots. Deletion is a request to software that you do not operate. It is not an enforcement mechanism.
E2EE strengthens the case that you, the operator, cannot produce the content. It says nothing about what recipients retain. A "delete for everyone" feature is a cooperative convention among well-behaved clients. A modified client ignores it and nothing in the protocol detects that. Write your product copy to match. "Deleted for everyone" is defensible if it describes a request to participating clients. "Permanently erased everywhere" is not.
What each operation actually removes
| Operation | Removes | Does not remove |
|---|---|---|
deleteSession(remoteAddress) | Local session state for one peer | Anything at the relay; peer's copy |
archiveSession(remoteAddress) | Nothing — moves to inactive list | Ability to decrypt delayed messages |
deleteGroupSenderKey(groupId) | Local sender key for a group | Other members' keys or history |
deleteRemoteAttachment(attachment, options?) | The encrypted remote object | Local caches; recipients' copies |
client.media.cleanup(input, options?) | Local media staging and caches | Remote objects; message rows |
clearAllData() | All local keys and sessions | Anything remote; anything on a peer |
archiveSession(remoteAddress) is not deletion. It moves the current session to the inactive list. This preserves decryption for messages that are already in flight. Per SESAME §3.2, the "previously active session is moved to the head of the inactive sessions list." Using deleteSession where archiveSession is correct silently destroys in-flight messages.
deleteRemoteAttachment(attachment, options?) "only touches remote object storage. App-owned local message rows and local media caches must be deleted by the application." Pair it with client.media.cleanup(...) and with syncMediaAttachmentDeleteToLinkedDevices(...) so the user's own other devices converge, rather than one device showing a file the user believes they deleted.
clearAllData() permanently deletes all local keys and sessions. It is the local half of an account reset and it is not, on its own, an account reset.
Blocking
/blocking exports SignalProtocolBlockingManager, constructed as new SignalProtocolBlockingManager({ store, mirror }) over a SignalProtocolBlockingStore you supply.
import { SignalProtocolBlockingManager } from '@open-e2ee/signal-protocol-sdk/blocking';
const blocking = new SignalProtocolBlockingManager({ store, mirror });
await blocking.blockRecipient(recipientId);
const blocked = await blocking.listBlockedRecipients();
await client.syncBlockedRecipientsToLinkedDevices(blocked);
if (await blocking.isBlocked(senderId)) return; // drop before surfacingunblockRecipient(recipientId) reverses it. The local store is authoritative for the current device. The optional mirror projects the snapshot to your backend.
Blocking is a delivery and presentation policy, not a cryptographic control. A blocked sender's envelope can still reach your relay. Your application decides not to surface it. Do not describe blocking as preventing someone from sending to a user, because it does not.
Device revocation
Revoking a device is a backend operation with a local counterpart, and both halves must happen.
The backend half. "Backend authentication must bind registration, provisioning, unlink, and removal to the owning account." A relay must not accept an unlink request based only on a client claim. Such a request could disconnect another account's device. The relay owns linked-device slot allocation. Primary is 1, and the backend allocates linked devices from 2 through 5. The contract states that "clients must not choose linked deviceIds." Revocation frees a slot, and the backend controls that decision.
The local half. "Account reset must clear the device-ID cache, platform secret storage, and protocol store as one product-level lifecycle." Three stores, one operation:
- Device-ID cache:
/device/device-id(getDeviceId(),preloadDeviceId(),getDeviceIdSync()). - Platform secret storage: the vault, e.g.
ExpoSecureStoreSignalProtocolSecretVaultfrom/local/vault/expo-secure-store, holding the small bootstrap secret such as"signal-store-wrapping-key". UsedeleteSecret(key). - Protocol store:
clearAllData()on the client.
DeviceLifecycleManager from /device/lifecycle is the coordination point for this sequence.
Revocation and rotation have different boundaries. Unlinking a device does not rotate the account identity. It also cannot remove messages that the device already decrypted and stored. If an attacker controls the device, unlinking stops future delivery. It does not remove existing copies. See key rotation for the effects of identity rotation.
Group member removal: the security-critical one
Removing a member from a member list is a UI change. Rotating the sender key is the security control. Without rotation, the removed member still holds the sender key. They can decrypt each later group message that uses it.
removeGroupMemberV2(groupId, editorAci, targetAci) triggers sender-key rotation. leaveGroupV2(groupId, userAci) handles the departing member's own side. Pair it with deleteGroupSenderKey(groupId) locally. handleGroupMembershipChange(groupId, change) returns { rotated, distributionMessage? }. rotateGroupSenderKey(groupId) explicitly rotates the key and returns { senderKeyId, distributionMessage }. Pass that result through distributeGroupSenderKey(groupId, memberUserIds).
Assert the rotation rather than trusting it. getGroupSenderKeyStats(groupId, senderId, senderDeviceId) returns generation. A removal that did not advance generation did not revoke anything. Make that a test, not a code review comment. See groups and testing.
Rotation is forward-looking. It does not remove the departed member's ability to read messages sent before the rotation, which they already received. There is no protocol operation that does.
The deletion sequence for one account
Ordered so that a failure part-way through leaves the least usable residue:
- Revoke at the backend first, authenticated against the owning account. Delivery stops.
- Rotate sender keys for every group the account was in to protect the remaining members regardless of what happens next.
- Delete remote objects with
deleteRemoteAttachment(...)for attachments the account owned. - Clear local media with
client.media.cleanup(...)and delete app-owned message rows: your responsibility, not the SDK's. clearAllData().- Clear the vault secret and the device-ID cache.
Complete steps 1 and 2 first. Steps 3 through 6 destroy the credentials that those earlier steps need.
Opacity ledger
| Artifact | Stays on device | Sent to relay | In object store | Removed by |
|---|---|---|---|---|
Session record (version: 4) | yes | no | no | deleteSession / clearAllData |
identityKeyPair private half | yes | no | no | clearAllData |
identityKeyPair public half | yes | yes | no | backend removal, not the SDK |
| Sender key (group) | yes | no | no | deleteGroupSenderKey; revoked by rotation |
| Encrypted attachment object | staged locally | no | yes | deleteRemoteAttachment |
| Local media cache | yes | no | no | client.media.cleanup |
| Decrypted message rows | yes | no | no | your application |
BlockedRecipientEntry list | yes | mirror is optional | no | unblockRecipient |
| Vault bootstrap secret | yes | no | no | deleteSecret(key) |
| A recipient's decrypted copy | their device | no | no | nothing |