Beamio CoNET Chat SDK β runs the gossip transport entirely inside a Web Worker (zero main-thread openpgp decrypt/encrypt, zero ethers.verifyMessage), plus fragmented, symmetrically-encrypted IPFS history. UI-agnostic, reusable across SilentPassUI / bizSite / Alliance / POS.
Motivation: running
openpgp.decrypt+ethers.verifyMessageon the main thread caused a "UI freeze for tens of seconds after startup". This SDK moves all inbound decryption, outbound encryption, signing, and SSE connect/reconnect into a Worker. The main thread only orchestratespostMessageand dispatches events.
- Worker-first: every heavy crypto operation runs inside the Worker; the UI never blocks.
- UI-agnostic: pure TS/ESM +
.d.ts, no React and no app-specific imports. The host injects private keys, RPC, node discovery, IPFS, and persistence. - Zero-trust routing built in: send via entry A, listen via entry C (β mailbox B),
listenKind:'chat', business messages encrypted to the recipient's EOA user PGP, delivery ACK encrypted to the route B key. - Encrypted fragmented history: HKDF domain separation + ratcheting fragment keys + AES-256-GCM fragments + encrypted index manifest + server-side
point-pointers + IndexedDB local-first. - Private keys never persist inside the SDK: keys are used only in Worker memory to derive an
ethers.Walletand the history master. The host decides whether to persist locally (consumers/POS may store; bizSite keeps session memory only).
βββββββββββββββββββββββββββ Main thread (host UI) βββββββββββββββββββββββββββ
β BeamioChatClient β
β β’ init() β getNodes() β postMessage(init) β
β β’ sendMessage / queryPresence / setRoutes / setNodes β
β β’ on('message'|'delivery'|'presence'|'status'|'log'|historyBuffer) β
β β’ history.load / history.append / history.onBuffer β
β β² plaintext env.line β host's existing addNewMessage serial queue β
ββββββββββββββββ¬ββββββββββββββββββββββββββββββββ²ββββββββββββββββββββββββββββ
postMessage(Command) postMessage(Event/Receipt)
ββββββββββββββββΌββββββββββββββββββββββββββββββββ΄ββββββββββββββββββββββββββββ
β Web Worker (worker/entry.ts) β
β β’ GossipCore: SSE connect/reconnect, openpgp decrypt/encrypt, β
β EIP-191 sign, presence wallet_online_query, delivery ACK β
β β’ HistoryStore: master + HKDF + ratcheting fragments + AES-GCM + IPFS β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Install as a dependency (per src-subprojects-are-independent: each sub-project owns formal dependencies; never cross-../.. import).
npm install @conet-project/chat-sdkPeer dependencies (provided by the host):
| Package | Version |
|---|---|
ethers |
^6.0.0 |
openpgp |
^5.0.0 || ^6.0.0 |
Runtime: a browser / WebView with Worker + crypto.subtle (PWA inside iOS/Android native shells is supported). Node >=18 for build/test only.
The host creates the Worker (the SDK does not hardcode a worker URL, to stay bundler-agnostic):
import { createBeamioChatClient, type BeamioChatConfig } from '@conet-project/chat-sdk'
const config: BeamioChatConfig = {
identity: {
eoaAddress, // 0x⦠(SDK normalizes internally)
privateKeyHex, // raw EOA private key hex, used only inside the Worker
pgpPrivateKeyArmored, // armored PGP private key (decrypt inbound)
pgpPassphrase: '',
pgpPublicKeyArmored, // armored PGP public key (keyID / diagnostics)
ownRouteArmoredPublicKey, // your mailbox B route public key
},
conetRpcUrl: 'https://rpc1.conet.network',
addressPgpContractAddress: CONET_ADDRESS_PGP,
getNodes: async () => fetchHealthyNodes(), // host owns node discovery/caching
ipfsBaseUrl: 'https://ipfs.conet.network/api',
chainId: 224422, // chainId used in the history-master derivation domain (default 224422 CoNET L1)
}
const client = createBeamioChatClient(config, {
workerFactory: () =>
new Worker(new URL('@conet-project/chat-sdk/worker', import.meta.url), { type: 'module' }),
})
await client.init() // start Worker + inject keys/nodes; the Worker begins listening
// inbound plaintext lines β hand to the host's existing serial checkSign/parse queue
const off = client.on('message', (env) => {
addNewMessage(env.line) // env.line already carries _beamioPgpArmorHash for delivery ACK
})
// later, update the contacts to listen for / probe
client.setRoutes(myContactRoutes)| Environment | workerFactory |
|---|---|
| Vite | () => new Worker(new URL('@conet-project/chat-sdk/worker', import.meta.url), { type: 'module' }) |
| Webpack 5 / CRA (Craco) | same as above; Webpack 5 statically recognizes new Worker(new URL(...)) and emits a separate worker chunk |
| Vendored (see below) | () => new Worker(new URL('../vendor/beamio-chat-sdk/worker/entry.ts', import.meta.url), { type: 'module', name: 'beamio-chat-gossip' }) |
| Field | Type | Description |
|---|---|---|
identity |
ChatIdentity |
see below |
conetRpcUrl |
string |
CoNET DePIN RPC (reads AddressPGP, etc.) |
addressPgpContractAddress |
string |
AddressPGP contract address |
getNodes |
() => Promise<NodeInfo[]> |
returns a snapshot of currently healthy nodes (host owns discovery/caching) |
ipfsBaseUrl |
string |
IPFS fragment gateway base, e.g. https://ipfs.conet.network/api |
ipfsWriteBaseUrl? |
string |
IPFS write base (defaults to ipfsBaseUrl) |
persistence? |
PersistenceAdapter |
IndexedDB adapter (optional; history is memory-only without it) |
runtime? |
ChatRuntimeOptions |
sendFanout (default 3), reconnectBaseMs (4000), reconnectMaxMs (30000) |
chainId? |
number |
chainId in the history-master derivation domain (default 224422 CoNET L1) |
| Field | Description |
|---|---|
eoaAddress |
EOA address |
privateKeyHex |
raw private key hex (with or without 0x). Used only inside the Worker for EIP-191 signing and history-master derivation |
pgpPrivateKeyArmored |
armored PGP private key, decrypts inbound |
pgpPassphrase? |
PGP private key passphrase (if any) |
pgpPublicKeyArmored? |
armored PGP public key |
ownRouteArmoredPublicKey? |
your mailbox B route public key (listen encryption target) |
One-time key generation +
registerChatRouteroute registration stay on the host (curve25519 generation is fast and not a freeze source). Inject the generated keys viaidentity.
| Field | Description |
|---|---|
address |
contact EOA (lowercase) |
userPublicKeyArmored |
recipient's user PGP public key β business-message encryption target |
routerArmoredPublicKey? |
recipient's mailbox B route public key β listen / ACK encryption target |
routePgpKeyID? |
route keyID (optional) |
| Method | Description |
|---|---|
init(): Promise<void> |
start Worker, inject config/nodes; the Worker begins listening. Idempotent (no-op if already running) |
sendMessage(to, payload, opts?) |
encrypt and send a business payload to a contact via entry A β B. Returns { sendId } |
queryPresence(contacts) |
probe mailbox listen-pool presence; returns Record<addrLower, boolean> (only ok:true counts) |
setRoutes(routes) |
update the set of contacts to listen for / probe |
setNodes(nodes) |
push a refreshed node snapshot (host owns discovery) |
postMailboxCommand(routerArmoredPublicKey, command) |
encrypt an arbitrary mailbox command (e.g. gossip_delivery_ack) to route B, sent via entry C β B |
on(event, cb): Unsubscribe |
subscribe to events (below); returns an unsubscribe function |
history |
BeamioChatHistory (below) |
pause() / resume() |
pause/resume Worker listening |
destroy() |
tear down the Worker and all listeners (idempotent) |
| Event | Payload | Description |
|---|---|---|
message |
InboundEnvelope |
env.line = host-ready JSON plaintext line (carries _beamioPgpArmorHash), handed to the host's serial checkSign/parse queue |
delivery |
DeliveryReceiptEvent |
delivery receipt (sendId / deliveredAt / from); host marks its own bubble as delivered |
presence |
PresenceEvent |
online: Record<addr, boolean>, reliable results only |
status |
StatusEvent |
idle/connecting/listening/reconnecting/paused/error; listening fires on each heartbeat to refresh main-thread staleness |
log |
ChatLogEvent |
structured log (never contains private keys / plaintext / full ciphertext) |
historyBuffer |
HistoryBufferEvent |
incremental batches during history restore/append, fed to the UI incrementally |
| Method | Description |
|---|---|
load(options?) |
restore encrypted history: locate index (point-${L} / IndexedDB) β decrypt β decrypt the tail first β backfill in the background; emits historyBuffer incrementally |
append(entry) |
write a newly received/sent entry to encrypted history + local mirror |
onBuffer(cb): Unsubscribe |
subscribe to incremental batches during restore/append |
HistoryLoadOptions: peer? (single contact or all), tailCount? (default 60, i.e. "last 2 screens"), localOnly? (IndexedDB only, for instant display).
Follows conet-p2p-mailbox-routing-protocol and beamio-conet-chat-protocol:
- Send business messages: encrypt to the recipient's EOA user PGP, POST to a healthy entry A β mailbox B.
- Recipient listen: encrypt to the mailbox B route key, SSE-connect via entry C β B, and always include
listenKind:'chat'. - Delivery ACK: encrypt to the mailbox B route key.
- Never connect directly to mailbox B; never target the recipient's AA (must be EOA user PGP).
master = keccak256( EOA_sign("beamio.chat.history.v1|chainId|eoa") ) // private key never leaves the Worker
locator L = HKDF(master, "index-locator") // hidden in a hash ocean; server-side point-${L} points to the current index
indexKey = HKDF(master, "index-enc") // AES-256-GCM encrypts the ordered index manifest
fragment ratchet k_i = HKDF(master, `frag|${seq}|${cid_{i-1}}`) // cid_{-1}=HKDF(master,"frag-genesis")
cipher_i = AES-GCM(k_i, plaintext_i); cid_i = keccak256(cipher_i) // uploaded as a fragment
- Every
cid_{i-1}is recorded in the index, so anyk_iis O(1) to derive β enabling newest-first restore. - IndexedDB local-first:
localOnlyfor instant display; network failures do not overwrite trusted local history (beamio-trusted-vs-untrusted-fetch). - Server-side
point-pointers: content is still stored askeccak(content)=hash1, with an extra aliaspoint-${L}βhash1. Requires EOA signature + owner binding + monotonic timestamp (blocks hijacking/replay). Seex402sdk/src/endpoint/fragmentClusterServer.ts.
Because sub-projects live in separate Git repos and file: deps are awkward on remote builds, SilentPassUI vendors the SDK source into src/vendor/beamio-chat-sdk/, bridged via services/chatWorkerBridge.ts:
// services/chatWorkerBridge.ts (key points)
import { createBeamioChatClient } from '../vendor/beamio-chat-sdk'
function makeGossipWorker(): Worker {
return new Worker(new URL('../vendor/beamio-chat-sdk/worker/entry.ts', import.meta.url), {
type: 'module',
name: 'beamio-chat-gossip',
})
}
const client = createBeamioChatClient(config, { workerFactory: makeGossipWorker })
client.on('message', (env) => { if (env.line) onLine(env.line) }) // β App.tsx addNewMessage
client.on('status', (st) => { if (st.status === 'listening') noteGossipActivity() })
await client.init()In services/chat.ts, connectToGossipNode only parses routes + healthy entries (for delivery ACK) on the main thread; the inbound LISTEN loop is fully delegated to the Worker. Decrypted plaintext lines flow through onLine β newMessage β App.tsx addNewMessage (unchanged serial queue).
Keep the vendored copy in sync with the source; changes to this SDK must be mirrored into
src/SilentPassUI/src/vendor/beamio-chat-sdk/. Vendored relative imports strip.jssuffixes to match CRAmoduleResolution:"node".
- Private keys / plaintext / full ciphertext must never be logged;
logevents emit structured summaries only. - The history
masterlives only in Worker memory; consumers/POS may store keys locally, bizSite keeps session memory only (policy owned by the host; the SDK never self-persists). - Always send
listenKind:'chat'; never connect directly to mailbox B. point-has CoNET-private semantics; public-gateway retrieval relies on CIDv1 mapping and must be documented.
npm run build # tsc -p tsconfig.json β dist/ (ESM + .d.ts)
npm run typecheck # tsc --noEmit
npm run clean # rm -rf distsrc/
index.ts # public entry: createBeamioChatClient + types
client.ts # main-thread BeamioChatClient (starts worker + postMessage protocol)
protocol.ts # main β worker postMessage protocol
types.ts # public types (UI-agnostic)
crypto.ts # WebCrypto + keccak/HKDF/AES-GCM (worker & main thread)
nodes.ts # node probing (worker & main thread)
worker/
entry.ts # worker entry (imported only in a Worker context)
gossip-core.ts # SSE + openpgp decrypt/encrypt + sign + presence + ACK
history.ts # encrypted fragmented IPFS history + IndexedDB
Package exports:
| Entry | Usage |
|---|---|
@conet-project/chat-sdk |
main-thread API (createBeamioChatClient + types) |
@conet-project/chat-sdk/worker |
worker entry; only used as the target of new Worker(...) |
conet-p2p-mailbox-routing-protocol/beamio-conet-chat-protocolβ routing and envelopesconet-depin-chat-app-devβ app practices (receipts / presence / listenKind)beamio-trusted-vs-untrusted-fetchβ failures never overwrite trusted local statex402sdk/src/endpoint/fragmentClusterServer.tsβpoint-pointer serversrc-subprojects-are-independentβ sub-projects are independent; no cross-repo imports
MIT (private release, publishConfig.access = restricted).