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
| Layer | Change |
|---|---|
| Dependencies | Add keet-identity-key and hypercore-crypto. |
| Worker | Generate or load a mnemonic, derive a Keet identity key, and attach it as the user's WorkerTask account. |
| Schema | Extend 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-cryptoPersist 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.
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:
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 = WorkerTaskAdd 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:
- Rename the class to
ChatRoomIdentity(L11), - Rename its
@pear-chat/*HyperDB/HyperDispatch collections to@pear-chat-identity/*(L111, L114, L117), and - Widen
addMessageto take and persist aproofalongside each message (L149–L153). Without this file the worker fails to boot withMODULE_NOT_FOUND: ./chat-room-identity:
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 = ChatRoomIdentityExtend the schema
chat-room-identity.js references a pear-chat-identity namespace and a new proof field, so update schema.js to match:
- Rename the namespace from
pear-chattopear-chat-identity, - Add a
prooffield (type: 'buffer') to themessagestruct that feeds themessagescollection, and - Then regenerate
spec/from a clean directory:
rm -rf spec && npm run build:dbDelete 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):
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 user1In 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 ofcorestore/
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 user1user1'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
- Create a portable identity with Keet identity keys — the Pear/Bare identity primitive behind this app, with no UI.
- 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 — keep the room reachable while the identity-holding device is offline.
- Workers — why the identity lives in the worker, not the renderer.