Skip to main content

Identity & Addressing

Every device on the mesh holds an Ed25519 identity key and is addressed by a self-certifying address derived from it. Peers verify an address by re-deriving it from the key its owner presents, so claiming an address you do not hold the key for is not possible.

How an address is derived

The result is a 44-character string beginning off1. It is a 160-bit hash of an identity key, so it cannot be guessed.
localAddress() returns null until startup completes, because the key lives in storage that is not open before then. The identity_ready event carries the same value the moment it becomes known:
The address is stable across restarts for the same profile.
deriveAddress() needs no protocol instance and is safe to call before the protocol is created, which is what makes it usable for verifying an invite or QR code before connecting. It throws if the public key is not exactly 32 bytes.

profile is not your identity

ProtocolConfig takes a profile string, and it is easy to mistake it for an identity. It is not. The storage namespace is SHA-256(domain ‖ 0x00 ‖ appId ‖ 0x00 ‖ profile). An app hosting several accounts gives each its own profile; an app hosting one can pass a constant such as 'default'.

Reaching a peer

recipient must be a peer’s address. The value arrives already in the right form:

First contact

Reaching someone by typing a username was only ever possible when usernames were addresses. Until a signed username-discovery layer ships, first contact is invite or QR only: exchange { address, publicKey } and verify it on the spot.
If you already run an account system, do not wait for that layer. Binding an address to an account needs no new SDK surface:
  1. The client calls getIdentityPublicKey() and localAddress().
  2. The client signs a server-issued nonce with signData(nonce).
  3. Your server checks the signature and that deriveAddress(publicKey) === address.
  4. Your server stores the address on the account row and serves it from its existing user lookup.
That gives reach-by-username back immediately, with your own uniqueness guarantees, and stays correct after the discovery layer ships.

Displaying an address

An off1… string is not a name. Keep your own display names (the sender_name and accepted_by_name fields on connection events already carry them) and treat the address the way you would a phone number: the thing you route on, not the thing you show.

Migrating from userId

ProtocolConfig.userId was removed in v0.21.0. Before then, the app picked a string and that string was the device: its sender, its recipient, its peer_id, and the thing peers trusted. Nothing authenticated it, so impersonating someone cost typing their name.
Keeping the same string in profile keeps you in the same storage namespace; the namespace formula is unchanged. Passing your old userId through is not a shim; it is the intended use.

Only half of your data moves

The single most common way to get this migration wrong is to conclude “our user id changed, so everything keyed by it must change”. That is half right, and the wrong half destroys data.
  • Sending: recipient must be a peer’s address. A username reaches nobody.
  • Comparing: “is this message mine?” compares against localAddress(), not the profile.
  • Peer-keyed rows: conversation rows, contact records, and group membership keyed by a peer’s old username must be re-keyed by that peer’s address. There is no mapping from old peer ids to new ones; those identities genuinely changed.
  • Self-keyed state: leave it alone.

Two ways this fails silently

Neither throws, neither logs, and both look like a successful launch. 1. A per-user database opened under the new id.
An off1… address passes ordinary identifier validation, since it is plain lowercase alphanumerics, so this opens a different, empty database. No error, no fallback. The entire message history simply stops existing, and a fresh empty store looks exactly like a first launch. Fix: feed these the value you pass as profile, not the address. 2. A teardown check that reads the id change as an account switch.
If an id-changed comparison gates wiping SDK state, upgrading looks like every user switched accounts, and the app wipes its own MLS sessions on first launch. Fix: compare profiles, which do not change. The general rule: if a string was doing double duty as “who I am” and “which storage is mine”, the second job stays with profile. Only the first job moves to the address.

There is no in-place migration

Changing the identity changes the MLS credential, which invalidates every existing session regardless of anything else, and 1:1 session slots are named after the two ids, which OpenMLS cannot rename. This release therefore starts a fresh identity world: old sessions do not carry over, and peers on an older build fail cleanly at credential verification rather than half-working. Old containers are left untouched rather than deleted, so nothing is destroyed on upgrade. Clean them up when you are confident, by passing the old user id where wipePersistedState now expects a profile:

Relay-backed deployments

Relay accounts, JWTs, and group rosters move into address space with the server-side change. A build that sends address-shaped ids to a relay still expecting usernames will fail its identity check. Sequence the relay first.

Signing and verification

The identity key is available for your own protocols:

Next: API Methods

The full method reference, including identity and MLS operations.