LogoPear Docs
How ToManage identity

Add Keet identity to a chat app

Anchor a chat user to a portable identity key derived from a mnemonic, using keet-identity-key on top of the pear-chat scaffold.

This guide shows you how to add a Keet-style portable identity to pear-chat so a user's identity survives across devices and reinstalls. The reference implementation is pear-chat-identity.

This guide is about the Pear-end, not the shell. The code below lives in the Bare worker — 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 for the cross-platform model and current support.

This is a delta-only how-to. The shared scaffold is explained in the Reshape into a production app tutorial — read it first.

Before you begin

  • A working clone of pear-chat (or your own app built from the getting-started path).
  • Comfort with Corestore and the Autobase-backed room model.

What changes

LayerChange
DependenciesAdd keet-identity-key and hypercore-crypto.
WorkerGenerate or load a mnemonic, derive a Keet identity key, and attach it as the user's WorkerTask account.
SchemaExtend the messages collection so each message carries a stable identityKey field.

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

Steps

Add the dependencies

npm install keet-identity-key hypercore-crypto

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

workers/index.js
  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)

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 example keeps the original storage-namespaced Autobase (via ChatRoomIdentity) but extends the message schema to carry the proof:

workers/worker-task.js
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

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 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:
workers/chat-room-identity.js
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

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:
rm -rf spec && npm run build:db

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.

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.

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

renderer/app.js
    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.

Run it

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 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 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 but keep identity-mnemonic.txt (substitute your own productName for <productName>):

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

On this page