> ## Documentation Index
> Fetch the complete documentation index at: https://www.offlineprotocol.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Identity & Addressing

> How devices identify themselves on the mesh with self-certifying off1 addresses, and how to migrate from userId.

# 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

```
address = bech32m( 0x01 ‖ SHA-256(publicKey)[0..20] )
```

The result is a 44-character string beginning `off1`. It is a 160-bit hash of an identity key,
so it cannot be guessed.

```typescript theme={null}
// Your own address
const mine = await protocol.localAddress();   // "off1q..." or null

// Derive a peer's address from their public key (no protocol instance needed)
const theirs = await protocol.deriveAddress(peerPublicKey);
```

`localAddress()` returns `null` until startup completes, because the key lives in storage that
is not open before then. The [`identity_ready`](/docs/mesh-sdk/events#identity_ready) event carries
the same value the moment it becomes known:

```typescript theme={null}
protocol.on('identity_ready', ({ address }) => setMyAddress(address));
```

The address is stable across restarts for the same `profile`.

<Note>
  `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.
</Note>

## `profile` is not your identity

`ProtocolConfig` takes a `profile` string, and it is easy to mistake it for an identity. It
is not.

|                   | `profile`                                           | address                          |
| ----------------- | --------------------------------------------------- | -------------------------------- |
| Chosen by         | your app                                            | derived from a key the SDK mints |
| Leaves the device | never                                               | yes; it is your wire identity    |
| Purpose           | selects which stored identity this instance runs as | routing and authentication       |
| Seen by peers     | no                                                  | yes                              |

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_discovered`](/docs/mesh-sdk/events#neighbor_discovered) reports `peer_id` as the
  peer's canonical address, on every transport
* [`connection_request_received`](/docs/mesh-sdk/events#connection_request_received) reports
  `sender` the same way
* [`message_received`](/docs/mesh-sdk/events#message_received) reports `sender` the same way

```typescript theme={null}
protocol.on('neighbor_discovered', async (event) => {
  await protocol.sendMessage({
    recipient: event.peer_id,   // already an off1… address
    content: 'Hello!',
  });
});
```

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

```typescript theme={null}
const derived = await protocol.deriveAddress(scanned.publicKey);
if (derived !== scanned.address) {
  throw new Error('Invite failed verification');
}
```

<Note>
  **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.
</Note>

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

```diff theme={null}
  const protocol = new OfflineProtocol({
    appId: 'my-app',
-   userId: currentUserId,
+   profile: currentUserId,
  });
```

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.

|                                                                                                                    | Keyed by                         | Changes?                                                     |
| ------------------------------------------------------------------------------------------------------------------ | -------------------------------- | ------------------------------------------------------------ |
| **Your own** storage: local DB filename, MMKV/UserDefaults namespace, cache directories, the SDK storage namespace | the string you pass as `profile` | **No.** Keep passing the same string you passed as `userId`. |
| **Peer** identity: `recipient`, conversation keys, contact rows, group rosters                                     | the peer's `off1…` address       | **Yes.**                                                     |

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

```typescript theme={null}
const db = open(`db-${userId}.sqlite`);   // or `user-${userId}` for MMKV
```

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

```typescript theme={null}
const shouldWipe = nextUserId !== tornDownUserId;   // now true for everyone
```

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:

```typescript theme={null}
await protocol.destroy();
await protocol.wipePersistedState(appId, oldUserId);
```

### 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:

```typescript theme={null}
const publicKey = await protocol.getIdentityPublicKey();   // 32 bytes
const signature = await protocol.signData(nonce);          // 64 bytes
const ok = await protocol.verifySignature(publicKey, nonce, signature);
```

<Card title="Next: API Methods" icon="arrow-right" href="/docs/mesh-sdk/methods">
  The full method reference, including identity and MLS operations.
</Card>
