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