# Add Keet identity to a chat app





This guide shows you how to **add a Keet-style portable identity** to [`pear-chat`](https://github.com/holepunchto/pear-docs/tree/preview/examples/getting-started/pear-chat) so a user's identity survives across devices and reinstalls. The reference implementation is [`pear-chat-identity`](https://github.com/holepunchto/pear-docs/tree/preview/examples/how-to/manage-identity/keet-identity).

<Callout type="info">
  **This guide is about the Pear-end, not the shell.** The code below lives in the Bare [worker](/pear/explanation/workers)—the peer-to-peer logic, not the user interface. Because the Pear-end never imports DOM APIs and never assumes a UI framework, the same worker is portable across **desktop (Electron)**, **mobile (React Native via Bare iOS / Bare Android)**, and **terminal**. The example apps ship an Electron shell, but only the UI half changes per platform—the logic here stays the same. See [Runtime and languages](/pear/explanation/runtime-and-languages) for the cross-platform model and current support.
</Callout>

This is a **delta-only** how-to. The shared scaffold is explained in the [Reshape into a production app](/p2p/getting-started/build-a-peer-to-peer-chat/reshape-into-a-production-app) tutorial—read it first.

## Before you begin [#before-you-begin]

* A working clone of `pear-chat` (or your own app built from the getting-started path).
* Comfort with [Corestore](/p2p/reference/helpers/corestore) and the [Autobase](/p2p/reference/building-blocks/autobase)-backed room model.

## What changes [#what-changes]

| Layer        | Change                                                                                                                                               |
| ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| Dependencies | Add [`keet-identity-key`](https://www.npmjs.com/package/keet-identity-key) and [`hypercore-crypto`](https://www.npmjs.com/package/hypercore-crypto). |
| Worker       | Generate or load a mnemonic, derive a Keet identity key inside `WorkerTask`, and use it to sign every message.                                       |
| Schema       | Extend the `message` struct so each message carries a `proof` field that ties it to a stable identity.                                               |

The Electron shell, worker transport (plain JSON over a `FramedStream`), and vanilla renderer stay as in the getting-started chat app.

## Steps [#steps]

<Steps>
  <Step>
    ### Add the dependencies [#add-the-dependencies]

    ```bash skip="desktop-gui"
    npm install keet-identity-key hypercore-crypto
    ```
  </Step>

  <Step>
    ### Persist a mnemonic [#persist-a-mnemonic]

    In `workers/index.js`, resolve the mnemonic before constructing `WorkerTask`. A `--mnemonic` flag wins if supplied (L27); otherwise read `identity-mnemonic.txt` from the app storage directory (L28, L30), and if that file does not exist yet, generate a fresh 24-word phrase with [`keet-identity-key`](https://www.npmjs.com/package/keet-identity-key) (L33). Persist it back (L35) so every subsequent start loads the same identity. Treat the mnemonic file as **sensitive**—do not check it into version control, and back it up the way you would a wallet seed.

    ```js file=<rootDir>/examples/how-to/manage-identity/keet-identity/workers/index.js#L27-L35 title="workers/index.js" lineNumbers {1,2,4,7,9} skip="example-import"
      let mnemonic = cmd.flags.mnemonic
      const mnemonicPath = path.join(appStorage, 'identity-mnemonic.txt')
      if (!mnemonic) {
        mnemonic = await fs.promises.readFile(mnemonicPath, 'utf-8').catch((err) => {
          if (err.code !== 'ENOENT') throw err
        })
        mnemonic = mnemonic || Identity.generateMnemonic()
      }
      await fs.promises.writeFile(mnemonicPath, mnemonic)
    ```
  </Step>

  <Step>
    ### Derive an identity and attest a device inside `WorkerTask` [#derive-an-identity-and-attest-a-device-inside-workertask]

    Pass the resolved mnemonic into `WorkerTask` as a constructor argument (`new WorkerTask(pipe, storage, mnemonic, cmd.flags)`). In `workers/worker-task.js`, the constructor stores the mnemonic (L16) and builds a `ChatRoomIdentity` room (L24). In `_open`, load the identity from that mnemonic and bootstrap a per-device key pair the identity attests (L34–L36). Each appended message is signed with the device key via `Identity.attestData` (L49–L50), so the worker stamps every line with a verifiable proof—and `_messages` verifies each proof with `Identity.verify` (L68–L71) before forwarding messages to the renderer. The [`pear-chat-identity`](https://github.com/holepunchto/pear-docs/tree/preview/examples/how-to/manage-identity/keet-identity) example keeps the original storage-namespaced [Autobase](/p2p/reference/building-blocks/autobase) (via `ChatRoomIdentity`) but extends the message schema to carry the proof:

    ```js file=<rootDir>/examples/how-to/manage-identity/keet-identity/workers/worker-task.js title="workers/worker-task.js" lineNumbers {16,24,34-36,49-50,68-71} skip="example-import"
    const Corestore = require('corestore')
    const debounce = require('debounceify')
    const crypto = require('hypercore-crypto')
    const Hyperswarm = require('hyperswarm')
    const Identity = require('keet-identity-key')
    const ReadyResource = require('ready-resource')

    const ChatRoomIdentity = require('./chat-room-identity')

    class WorkerTask extends ReadyResource {
      constructor (pipe, storage, mnemonic, opts = {}) {
        super()

        this.pipe = pipe
        this.storage = storage
        this.mnemonic = mnemonic
        this.invite = opts.invite
        this.name = opts.name || `User ${Date.now()}`

        this.store = new Corestore(storage)
        this.swarm = new Hyperswarm()
        this.swarm.on('connection', (conn) => this.store.replicate(conn))

        this.room = new ChatRoomIdentity(this.store, this.swarm, this.invite)
        this.debounceMessages = debounce(() => this._messages())
        this.room.on('update', () => this.debounceMessages())

        this.identity = null
        this.deviceKeyPair = null
        this.deviceProof = null
      }

      async _open () {
        this.identity = await Identity.from({ mnemonic: this.mnemonic })
        this.deviceKeyPair = crypto.keyPair()
        this.deviceProof = await this.identity.bootstrap(this.deviceKeyPair.publicKey)

        await this.store.ready()
        await this.room.ready()

        this.pipe.on('data', async (data) => {
          let message
          try {
            message = JSON.parse(data)
          } catch {
            return
          }
          if (message.type === 'add-message') {
            const proof = Identity.attestData(Buffer.from(message.text), this.deviceKeyPair, this.deviceProof)
            await this.room.addMessage(message.text, proof, { name: this.name, at: Date.now() })
          }
        })
        await this.debounceMessages()

        this.pipe.write(JSON.stringify({ type: 'invite', invite: await this.room.getInvite() }))
      }

      async _close () {
        await this.room.close()
        await this.swarm.destroy()
        await this.store.close()
      }

      async _messages () {
        const messages = await this.room.getMessages()
        messages.sort((a, b) => a.info.at - b.info.at)
        for (const msg of messages) {
          const res = Identity.verify(msg.proof, Buffer.from(msg.text), {
            expectedIdentity: this.identity.identityPublicKey
          })
          msg.info.verified = !!res
        }
        this.pipe.write(JSON.stringify({ type: 'messages', messages }))
      }
    }

    module.exports = WorkerTask
    ```
  </Step>

  <Step>
    ### Add the identity-aware room [#add-the-identity-aware-room]

    `worker-task.js` now imports `ChatRoomIdentity` instead of the tutorial's `ChatRoom`. Create `workers/chat-room-identity.js` as a copy of the tutorial's [`chat-room.js`](https://github.com/holepunchto/pear-docs/blob/preview/examples/getting-started/pear-chat/workers/chat-room.js) with three changes:

    1. Rename the class to `ChatRoomIdentity` (L11),
    2. Rename its `@pear-chat/*` HyperDB/HyperDispatch collections to `@pear-chat-identity/*` (L111, L114, L117), and
    3. Widen `addMessage` to take and persist a `proof` alongside each message (L149–L153).
       Without this file the worker fails to boot with `MODULE_NOT_FOUND: ./chat-room-identity`:

    ```js file=<rootDir>/examples/how-to/manage-identity/keet-identity/workers/chat-room-identity.js title="workers/chat-room-identity.js" lineNumbers {11,111,114,117,149-153} skip="example-import"
    const Autobase = require('autobase')
    const b4a = require('b4a')
    const BlindPairing = require('blind-pairing')
    const HyperDB = require('hyperdb')
    const ReadyResource = require('ready-resource')
    const z32 = require('z32')

    const ChatDispatch = require('../spec/dispatch')
    const ChatDb = require('../spec/db')

    class ChatRoomIdentity extends ReadyResource {
      constructor (store, swarm, invite) {
        super()

        this.store = store
        this.swarm = swarm
        this.invite = invite

        this.pairing = new BlindPairing(swarm)

        /** @type {{ add: function(string, function(any, { view: HyperDB, base: Autobase })) }} */
        this.router = new ChatDispatch.Router()
        this._setupRouter()

        this.localBase = Autobase.getLocalCore(this.store)
        this.base = null
        this.pairMember = null
      }

      async _open () {
        await this.localBase.ready()
        const localKey = this.localBase.key
        const isEmpty = this.localBase.length === 0

        let key
        let encryptionKey
        if (isEmpty && this.invite) {
          const res = await new Promise((resolve) => {
            this.pairing.addCandidate({
              invite: z32.decode(this.invite),
              userData: localKey,
              onadd: resolve
            })
          })
          key = res.key
          encryptionKey = res.encryptionKey
        }

        // if base is not initialized, key and encryptionKey must be provided
        // if base is already initialized in this store namespace, key and encryptionKey can be omitted
        await this.localBase.close()
        this.base = new Autobase(this.store, key, {
          encrypt: true,
          encryptionKey,
          open: this._openBase.bind(this),
          close: this._closeBase.bind(this),
          apply: this._applyBase.bind(this)
        })

        const writablePromise = new Promise((resolve) => {
          this.base.on('update', () => {
            if (this.base.writable) resolve()
            if (!this.base._interrupting) this.emit('update')
          })
        })
        await this.base.ready()
        this.swarm.join(this.base.discoveryKey)
        if (!this.base.writable) await writablePromise

        this.view.core.download({ start: 0, end: -1 })

        this.pairMember = this.pairing.addMember({
          discoveryKey: this.base.discoveryKey,
          /** @type {function(import('blind-pairing-core').MemberRequest)} */
          onadd: async (request) => {
            const inv = await this.view.findOne('@pear-chat-identity/invites', { id: request.inviteId })
            if (!inv) return
            request.open(inv.publicKey)
            await this.addWriter(request.userData)
            request.confirm({
              key: this.base.key,
              encryptionKey: this.base.encryptionKey
            })
          }
        })
      }

      async _close () {
        await this.pairMember?.close()
        await this.base?.close()
        await this.localBase.close()
        await this.pairing.close()
      }

      _openBase (store) {
        return HyperDB.bee(store.get('view'), ChatDb, { extension: false, autoUpdate: true })
      }

      async _closeBase (view) {
        await view.close()
      }

      async _applyBase (nodes, view, base) {
        for (const node of nodes) {
          await this.router.dispatch(node.value, { view, base })
        }
        await view.flush()
      }

      _setupRouter () {
        this.router.add('@pear-chat-identity/add-writer', async (data, context) => {
          await context.base.addWriter(data.key)
        })
        this.router.add('@pear-chat-identity/add-invite', async (data, context) => {
          await context.view.insert('@pear-chat-identity/invites', data)
        })
        this.router.add('@pear-chat-identity/add-message', async (data, context) => {
          await context.view.insert('@pear-chat-identity/messages', data)
        })
      }

      /** @type {HyperDB} */
      get view () {
        return this.base.view
      }

      async getInvite () {
        const existing = await this.view.findOne('@pear-chat-identity/invites', {})
        if (existing) {
          return z32.encode(existing.invite)
        }
        const { id, invite, publicKey, expires } = BlindPairing.createInvite(this.base.key)
        await this.base.append(
          ChatDispatch.encode('@pear-chat-identity/add-invite', { id, invite, publicKey, expires })
        )
        return z32.encode(invite)
      }

      async addWriter (key) {
        await this.base.append(
          ChatDispatch.encode('@pear-chat-identity/add-writer', { key: b4a.isBuffer(key) ? key : b4a.from(key) })
        )
      }

      async getMessages ({ reverse = true, limit = 100 } = {}) {
        return await this.view.find('@pear-chat-identity/messages', { reverse, limit }).toArray()
      }

      async addMessage (text, proof, info) {
        const id = Math.random().toString(16).slice(2)
        await this.base.append(
          ChatDispatch.encode('@pear-chat-identity/add-message', { id, text, proof, info })
        )
      }
    }

    module.exports = ChatRoomIdentity
    ```
  </Step>

  <Step>
    ### Extend the schema [#extend-the-schema]

    `chat-room-identity.js` references a `pear-chat-identity` namespace and a new `proof` field, so update `schema.js` to match:

    1. Rename the namespace from `pear-chat` to `pear-chat-identity`,
    2. Add a `proof` field (`type: 'buffer'`) to the `message` struct that feeds the `messages` collection, and
    3. Then regenerate `spec/` from a **clean** directory:

    ```bash skip="desktop-gui"
    rm -rf spec && npm run build:db
    ```

    <Callout type="warning">
      Delete `spec/` before regenerating. The schema generators (`hyperschema`, `hyperdispatch`, `hyperdb`) **merge** into the existing manifests rather than overwriting them, so if you regenerate on top of the old `pear-chat` spec the stale registrations linger alongside the new `pear-chat-identity` ones. Starting from a clean `spec/` keeps only the `pear-chat-identity` namespace.
    </Callout>

    `ChatRoomIdentity.addMessage` then stores the `proof` alongside each message. `_messages` verifies each one with `Identity.verify(msg.proof, Buffer.from(msg.text), { expectedIdentity: this.identity.identityPublicKey })`, so anyone replicating the room can confirm which identity authored each line—across reinstalls and across machines, since the same mnemonic always yields the same identity.
  </Step>

  <Step>
    ### Surface the identity in the UI [#surface-the-identity-in-the-ui]

    The worker already verifies every message in `_messages` and stamps `msg.info.verified` (step 3), and that boolean rides along with each message in the same `{ type: 'messages', messages }` JSON payload the renderer already receives. So the renderer needs **no new message type**—it just reads the extra field.

    In `renderer/app.js`, each message row reads `message.info?.verified` (L43) and renders a verified/unverified badge next to the sender's name (L44–L47), then appends it to the row's metadata line (L53):

    ```js file=<rootDir>/examples/how-to/manage-identity/keet-identity/renderer/app.js#L43-L54 title="renderer/app.js" lineNumbers {1-5,11} skip="example-import"
        const verified = message.info?.verified
        const badge = document.createElement('span')
        badge.className = `text-[10px] ${verified ? 'text-emerald-400' : 'text-rose-400'}`
        badge.title = verified ? 'Signature verified' : 'Signature invalid'
        badge.textContent = verified ? '● verified' : '○ unverified'

        const time = document.createElement('span')
        time.className = 'ml-auto text-xs text-neutral-500'
        time.textContent = new Date(message.info?.at).toLocaleTimeString()

        meta.append(name, badge, time)

    ```

    The badge turns green only when the proof on that message validates against the identity that authored it—so a peer replicating the room can see, per line, which messages are provably from a given identity across reinstalls and devices.
  </Step>
</Steps>

## Run it [#run-it]

```bash skip="desktop-gui"
npm run build
npm start -- --storage /tmp/identity-user1 --name user1
```

In development, `electron/main.js` namespaces `--storage <dir>` by your app's `productName` (from `package.json`—it's `PearChat` if you're building on top of the getting-started app) and the worker writes into an `app-storage` subdirectory. So with `--storage /tmp/identity-user1` the files land at `/tmp/identity-user1/<productName>/app-storage/`:

* `…/app-storage/corestore/`—the [Hypercore](/p2p/reference/building-blocks/hypercore) data
* `…/app-storage/identity-mnemonic.txt`—the mnemonic, a sibling of `corestore/`

The `app-storage` folder only appears once the app worker has run (the [`pear-runtime`](/pear/reference/pear/runtime) folder next to it is the separate updater store). The exact path is easiest to copy from the terminal: the worker logs a `Storage: …/app-storage/corestore` line on startup, and the mnemonic sits next to that `corestore/` directory.

Quit the app, blow away the [Corestore](/p2p/reference/helpers/corestore) but **keep `identity-mnemonic.txt`** (substitute your own `productName` for `<productName>`):

```bash skip="desktop-gui"
rm -rf /tmp/identity-user1/<productName>/app-storage/corestore
npm start -- --storage /tmp/identity-user1 --name user1
```

user1's identity key is unchanged. Copy `identity-mnemonic.txt` into another machine's matching `app-storage` directory and the same identity follows there.

## Where to go next [#where-to-go-next]

* [Create a portable identity with Keet identity keys](/p2p/how-to/manage-identity/create-a-portable-identity-with-keet-identity-key)—the Pear/Bare identity primitive behind this app, with no UI.
* [Connect two peers by key with HyperDHT](/p2p/how-to/connect-to-peers/connect-two-peers-by-key-with-hyperdht)—once you have an identity key, you can dial it directly.
* [Add blind peering to a chat app](/p2p/how-to/blind-peering/add-blind-peering-to-a-chat-app)—keep the room reachable while the identity-holding device is offline.
* [Workers](/pear/explanation/workers)—why the identity lives in the worker, not the renderer.
