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
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:
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:
neighbor_discoveredreportspeer_idas the peer’s canonical address, on every transportconnection_request_receivedreportssenderthe same waymessage_receivedreportssenderthe same way
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:
- The client calls
getIdentityPublicKey()andlocalAddress(). - The client signs a server-issued nonce with
signData(nonce). - Your server checks the signature and that
deriveAddress(publicKey) === address. - Your server stores the address on the account row and serves it from its existing user lookup.
Displaying an address
Anoff1… 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.
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:
recipientmust 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.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.
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 wherewipePersistedState
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.