OpenE2EE

Identity changes and safety numbers

Generating safety numbers, handling identity-key change errors, and designing a verification experience that users can actually complete.

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

Trust-on-first-use gets you a working session against a key you have never checked. It detects a later key change. It proves nothing about who was present at first contact. This page closes that gap, where the hard part is outside the cryptography.

Generating comparison data

The client method takes a user ID and returns the comparison object. It also returns an immutable token that confirms the exact displayed value:

const safetyNumber = await signal.verify(remoteUserId);
showVerificationDialog(safetyNumber);
await signal.confirmSafetyNumber(safetyNumber.confirmation);

The two steps are separate on purpose. A generated safety number never promotes trust. Only confirmSafetyNumber(confirmation) records a decision. It takes evidence for the value that the user saw. This token prevents accidental confirmation if the key rotates between render and tap.

For the full set of representations, including the scannable form, use the standalone module:

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

const safetyNumber = generateCompositeSafetyNumber(
  localCompositeIdentity,
  remoteCompositeIdentity,
  localUserId,
  remoteUserId,
);

console.log(safetyNumber.numeric); // 60 digits, for reading aloud
console.log(safetyNumber.emojis);  // for comparing on a screen

const comparison = safetyNumber.scannable.compare(scannedQrBytes);
if (comparison === 'match') {
  // record the user's decision
}

Trust starts at UNVERIFIED_TOFU. Authenticated safety-number comparison covers both peers' complete composite tuples. It promotes only the exact current tuple to VERIFIED. "Exact current tuple" defines the scope. Trust applies to one specific set of keys at one moment, not permanently to a contact.

The SDK creates comparison data; the application owns QR rendering, scanning, the verification user experience, and storage of the user's trust decision.

When an identity changes

The contact reinstalls, their new device generates a new identity, and the relay starts serving a tuple you never pinned. Two errors surface from that, on two different calls, and neither of them is an event the SDK hands you. There is no identity-change hook: registering every entry in SignalProtocolClientHooks and then changing an identity underneath a live session fires none of them.

The session path fails closed, but not at your call site

UntrustedIdentityError (.code UNTRUSTED_IDENTITY) is the identity error the SDK raises. It is constructed in five places: two on the sending side — building a session against a fetched prekey bundle, and encrypting on an established one — and three inside decrypt. It carries the address it refused and the tuple it refused for it:

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

try {
  await signal.send(remoteUserId, body);
} catch (error) {
  if (isUntrustedIdentityError(error)) {
    // .untrustedAddress — who. .identity — the tuple the SDK would not use.
    await queueForUserReview(error.untrustedAddress, error.identity);
    return;
  }
  throw error;
}

Keep that handler, and know what it is for. Both sending-side throws sit behind isTrustedIdentity(…, SENDING, …), and a contact reinstalling does not on its own make that answer false. On 0.1.0-alpha.12 the send path was probed in four configurations after the far device had rebuilt — an established ratchet, a deleted session forcing the prekey-bundle path, a VERIFIED trust state promoted through confirmSafetyNumber, and VERIFIED with the session deleted — and every one resolved with nothing thrown. So the handler is defence in depth for the paths that do reach a caller, not the branch a reinstall takes. Do not build the reinstall experience on it; build it on the section below.

verify() refuses before you accept

Once the relay is serving an identity that is not the one you pinned, verify() does not return a new safety number to show. It throws EncryptionError with .code IDENTITY_MISMATCH:

import {
  EncryptionErrorCode,
  isEncryptionError,
} from '@open-e2ee/signal-protocol-sdk/types';

try {
  showVerificationDialog(await signal.verify(remoteUserId));
} catch (error) {
  if (isEncryptionError(error) && error.code === EncryptionErrorCode.IDENTITY_MISMATCH) {
    // the relay serves a tuple you have not pinned; accept it or refuse it
    return promptForIdentityChange(remoteUserId);
  }
  throw error;
}

The pinned tuple is the trust object, and the relay is never allowed to select which tuple the user sees. That rule is what produces this refusal, and it constrains the screen you can build: there is no "here is the new safety number, compare it" dialog available before the change has been accepted. Accepting comes first, comparing comes second, and a design that assumes the other order cannot be implemented on this SDK.

confirmSafetyNumber raises the same code in three more places — when the confirmation does not match the pinned identity, when it does not match the value that was displayed, and when the relay's identity moved between display and tap. Each is the same guard: a decision is recorded only against the exact tuple the user actually saw.

Accepting the change

Accepting is explicit, and it takes the tuple:

const record = await signal.acceptIdentityRotation(remoteUserId, error.identity);

The tuple is the one on the error, if you caught it. If you did not, it is the one the relay is serving — read it from the relay you configured, with relay.getIdentityKey(remoteUserId). Supplying the tuple that is already trusted throws.

Acceptance resets the sessions bound to the old tuple and returns the contact's identity record. It does not mark the contact VERIFIED. Accepting a rotation and verifying it are different acts. If one tap combines them, a "verified" badge loses its meaning.

The part nobody wants to hear

Users cannot do this. The evidence is not ambiguous.

In one study, 21 of 28 computer science students could not verify a public key. In another, which explained the risks first, only 13% completed the ceremony. These people are not the general population. They started with a reason to care, and most of them still did not finish.

Keybase, whose users were unusually motivated, put it plainly:

"Checking is infeasible, since it happens way too often. Checking sucks."

They reframed trust-on-first-use as TADA, Trust After Device Additions. In a multi-device product, the decision does not occur only at first contact. Users repeat it every time anyone upgrades a phone, reinstalls an app, or links a tablet. On a fleet of any size, that is a steady drip of identity changes with a base rate that is almost entirely benign.

Where the industry actually went

The mature answer is not a better dialog. It is to make key changes auditable rather than to ask each user to adjudicate them.

Apple's Contact Key Verification and WhatsApp's Automatic Device Verification both move in this direction. The system checks continuity in the background. A person participates only after a failed check or when the stakes justify a real ceremony. The human decision becomes an exception path rather than a routine one.

This SDK gives you the comparison data. It does not give you a transparency log or an automated continuity check, and we do not claim to. What you can build with what is here:

  • Show the safety number only where it earns attention: a deliberate "verify this contact" flow, not an unsolicited banner.
  • Use scannable.compare(scannedQrBytes) for in-person verification. A QR scan succeeds where a 60-digit read-aloud does not.
  • Persist every UntrustedIdentityError with .untrustedAddress and .identity to an auditable record that your application owns — from the handler above and from wherever your logger sink writes, since on a reinstall the refusal reports there rather than to a caller. The tuple you held before it is the contact identity record you already have, so the record has both sides. A security team can use it to answer questions later. Do not make the user act as a verification oracle in the moment.
  • Reserve the interrupt for cases your product can justify. Examples include a first message, a high-value action, or an account that your own signals flagged.

Opacity ledger

ArtifactStays on deviceSent to relayIn object storeVisible as metadata
localCompositeIdentity private halfyesnonono
localCompositeIdentity public halfyesyesnoyes
remoteCompositeIdentityyes, cachedfetched from relaynoyes
safetyNumber.numericyesnonono
safetyNumber.emojisyesnonono
safetyNumber.scannable bytesyesnonono — it crosses out of band, by QR
safetyNumber.confirmationyesnonono
Trust state (UNVERIFIED_TOFU / VERIFIED)yesnonono
UntrustedIdentityError.identityyesalready publicnoyes
Your record of the user's verification decisionyes, app-ownednonono

Trust state is device-local. If a user verifies a contact on their phone, their tablet does not know. Call syncVerificationStateToLinkedDevices(...) to send that state. The SDK does not run this call in the background. See Multi-device.

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

Next

On this page