CoNET Chat developer guide
Evidence level: Implemented capability, with a privacy upgrade in rollout. This page is a developer how-to for CoNET Chat on Layer Minus. Unmarked envelopes, commands, and APIs match current Beamio / chat-sdk clients. Text marked In rollout describes code that is written and tested but not yet confirmed in released clients; Planned describes decided designs that are not implemented. Write new clients to the rollout form. It is not a claim that Chat is a finished replacement for mainstream messengers.
CoNET Chat is relationship-private communication infrastructure: wallet identity, zero-trust mailbox routing, encrypted application envelopes, and wallet-controlled encrypted history. Use it to add wallet-addressed communication without first building a centralized user-and-relationship database. Product thesis, Beamio integration, and maturity limits live on CoNET Chat.
Public site: https://gitbook.conet.network/l0/chat-developer-guide.html
L0 only forwards OpenPGP by wallet / key ID. Chat is an application composition: user-PGP business envelopes, mailbox listen, dual receipts, presence, and an optional encrypted history track. SI primitives and reusable helpers live on the SI developer guide. Developer-track index: L0 development.
Public packages: CoNET-project/chat-sdk · @conet.project/chat-sdk. Reference clients: SilentPassUI cashtree (consumer) and cashtrees (merchant).
What a Chat client must implement
| Step | Encrypt to | Transport |
|---|---|---|
| Register user PGP + mailbox | — | POST https://beamio.app/api/regiestChatRoute; signing by the wallet (EIP-191, timestamp, single-use nonce) takes effect when the updated API is deployed (pending release), then confirm with AddressPGP searchKey. Register only when a successful read shows no record, a different key or no mailbox; keep the existing mailbox (rules) |
| Look up a contact | — | AddressPGP.searchKey(contact) over CoNET RPC, without an eth_call from (in rollout; the live Consumer PWA still sends its own wallet as from) |
| Send a message | Recipient EOA user PGP, always wrapped as mailbox work to the recipient mailbox B route PGP (the web app does so since PWA 0.52.930) | POST /post to one random entry A (not the recipient's or the sender's mailbox); the next entry only after a clear failure |
| Listen | Own mailbox B route PGP | command: "mailbox_listen" as SSE via entry C ≠ B. It must carry a fresh streamKey and every frame is sealed. The legacy mining + listenKind: "chat" pool receives no Chat |
| After inbound ingest | (1) B route PGP ACK · (2) sender user PGP receipt, then mailbox-work wrap NoPush to sender mailbox B |
ACK only after the message is stored. ACK and receipt both via entries ≠ B; HTTP still { data } only. NoPush is Chat/APNs only |
| Presence (green dot) | Contact mailbox B route PGP | wallet_online_query via entry C ≠ B (never dial B; that hides the querier's IP). Carries the querier's wallet: known limitation |
| Native shell status | Contact mailbox B route PGP | wallet_native_wake_query via the same entry C ≠ B, after presence. Same limitation |
| Call offer | Signed offer to callee user PGP, inside a route command to the callee mailbox B route PGP | voice_offer_store with push: 1 via a random entry ≠ either mailbox |
| Voice channel (v3) | Relay route PGP; the relay is chosen by the listening side and is neither mailbox | voice_listen v: 3 with temporary-wallet and payer signatures via an entry ≠ the relay; chunks via random entries |
| Optional recover history | — | Encrypted IPFS fragments + ChatIndexRegistry head pointer |
Protocol status (live, in rollout, planned) and the known limitations are
listed on CoNET Chat.
Every Guardian node runs the new CoNET-SI since 2 November 2026, with no
backward compatibility: every mailbox refuses a mailbox_listen without
streamKey, and every relay refuses a voice_listen that is not v3. Released
clients follow these rules (chat-sdk-ios v0.1.8, chat-sdk-android v0.2.6,
Consumer PWA 0.52.930); older clients no longer receive in real time or call.
Relationship-privacy field contract
The application sender wallet is confidential routing metadata until the recipient decrypts:
{ from: senderEOA, text, signature }
→ encrypt complete object to recipient user PGP
→ wrap { data: armor } to recipient mailbox B route PGP (the web app does so since PWA 0.52.930)
→ POST { data: armor } through one random entry A
| Role | Receives | Must not receive |
|---|---|---|
| Entry A | Client IP, ciphertext, timing, size, outer key ID. With the mailbox layer (web app since PWA 0.52.930): the recipient mailbox's route key. Without it: the recipient's user key | Plaintext sender wallet or Chat body |
| Mailbox B | Destination route, stored armor, listen state | Sender wallet inside the business envelope or Chat body |
| Listen entry C | Recipient-side connection IP, encrypted listen traffic: sealed, padded SSE frames (every node refuses an unsealed listen) | Decrypted Chat body, recipient key ID of each message |
| Recipient | Decrypted envelope and verified sender wallet | Other users' private keys |
This attempts to break three direct associations: sender wallet ↔ recipient, message content ↔ relay, and user IP ↔ communication identity. It does not make IP disappear: A/C see the connecting IP, while B sees route state through an entry connection. Collusion and global timing analysis remain outside the guarantee.
Protected file attachments
Chat file attachments use the encrypted file_message_v1 application type. The
composer accepts non-photo, non-audio files and browser folder drops; photos,
audio, and voice recordings continue to use their existing paths.
- The client recursively collects dropped files, creates a ZIP in the browser
with
fflate, and encrypts the ZIP with AES-GCM. - The encrypted Data URL is uploaded to
ipfs.conet.networkusing authenticated 512 KiB multipart chunks. Upload status is queried first so interrupted uploads resume; each job can be cancelled independently. The client enforces a roughly 240 MiB Data URL ceiling before upload. - The encrypted PGP application envelope carries a
file_message_v1manifest containing the fragment hash, AES key and IV, display name, file count, total size, and file names/sizes.
The manifest is protected by the normal recipient user-PGP envelope. Ordinary UI, logs, localStorage, and encrypted history must never expose plaintext file contents, the AES key, or fragment hash. Recipients fetch, decrypt, and unzip locally, then receive per-file download/preview controls. Object URLs must be revoked after each download or component unmount.
Do not encrypt business Chat to an AA Smart Wallet unless that AA has its own AddressPGP row. Do not take search-users results[0] as the gossip target (CoNET ≠ CONET).
Voice-call wake-up and offer delivery
A device may need a native wake-up before its mailbox SSE is up. The caller delivers the signed call offer, already encrypted to the callee's user PGP, to the callee's own mailbox with one route command. It is encrypted to the callee mailbox route PGP and posted through a random entry that is neither the callee's nor the caller's mailbox:
{
"command": "voice_offer_store",
"callId": "opaque-call-id",
"sessionId": "voice-session-opaque-id",
"expiresAt": 1710000123456,
"push": 1,
"pushTimestamp": 1710000000,
"offerArmor": "-----BEGIN PGP MESSAGE-----\n...\n-----END PGP MESSAGE-----",
"timestamp": 1710000000
}
callId is a random wake-up reference and must not contain the caller's
@BeamioTag or wallet address. sessionId (16–128 characters) is the
capability for this offer index and is used for deduplication. The command has
no walletAddress and no wallet signature.
The callee mailbox stores the ciphertext under callId + sessionId, finds its
own user from the offer's recipient key ID, and asks /api/voiceCallPush to
ring that user's registered devices (ios_voip uses APNs PushKit/CallKit,
android uses high-priority FCM). It answers { ok, delivered, pushed }. pushed: false
means the callee has no push device; the call still proceeds over a live SSE.
The callee mailbox already knows its own user, so no other node learns whom the
caller is calling, and the caller never calls the push API. The mailbox never
decrypts or logs the offer and never forwards keys or audio. A push response is
not proof that the offer was read or the call answered.
The callee mailbox also writes the offer into the callee's live mailbox SSE
(stored without a chat push), so a browser callee receives it with no other
leg. A failed store is a failed dial; pushed: false is not.
There is no other wake path. Released clients still send a fallback
voice_listen to their own mailbox carrying targetWallet, callId,
expiresAt, pushTimestamp and offerArmor; every node ignores those fields
since 2 November 2026. New clients must not send them.
Killed-app identity (CallKit / Incoming)
offerArmor is too large for APNs/FCM. When the app process was killed, the
woken native Chat SDK:
- reports the system incoming UI with a temporary opaque label;
- posts
voice_offer_fetch(encrypted to its own mailbox route PGP, through an entry C ≠ B, using only the pushcallId/sessionId); - decrypts the returned armor locally with the user PGP private key it already holds in the Keychain / Keystore (provisioned once by the app page, see Using the SDKs);
- recovers the caller EOA from
callerSignature, looks up@BeamioTagfor that address, and updates CallKit / Incoming to a larger@tagplus a smaller shortened address.
{
"command": "voice_offer_fetch",
"callId": "opaque-call-id",
"sessionId": "voice-session-opaque-id",
"timestamp": 1710000000
}
The returned offerArmor is a normal Chat envelope. After the PGP decrypt, the
literal is base64(UTF-8 JSON) ({ timestamp, text, from, signMessage }, with
the signed offer JSON in text), exactly like every other Chat message. Decode
it before looking for voice_call_offer_v1; a client that parses the decrypted
literal as JSON directly never finds the offer and the call stays anonymous.
Do not put the caller wallet or tag into the push payload.
In rollout: caller display. Show only the wallet recovered from the
signatures. The offer's callerTag is outside callerSignature; treat it as a
claim and never use it for the name or the avatar. Look up the tag for the
recovered wallet in the local record first, and only then with one exact
address query. Draw the avatar from local data (a BeamioTag avatar is a
deterministic DiceBear fun-emoji image seeded by the tag); never fetch an image
when a call rings. Show addresses as a short capsule (0x1234…5678) that
copies the full address.
In rollout: late pushes (Android). Before ringing, classify the push: on
time → ring; more than 5 seconds late but inside the ring window → wait up to
about 5 seconds for the mailbox listen and do not ring if the call has ended;
past expiresAt → record a missed call without ringing; an end-only push →
do not ring. A call that ended before it rang must never ring later.
Call control
Every online client keeps its mailbox SSE open during dialing, ringing and the
call. Call control therefore travels as ordinary Chat messages through the
mailbox: signed by the sender wallet, encrypted to the peer's user PGP, posted
through a random entry, wrapped to the peer's mailbox route key (the web app does so since PWA 0.52.930). This covers caller cancel, decline, ring timeout and hang-up
(voice_end_v1):
{
"type": "voice_end_v1",
"callId": "opaque-call-id",
"sessionId": "caller-ring-session-id",
"from": "0xEnder",
"to": "0xPeer",
"reason": "hangup",
"createdAt": 1710000000000,
"timestamp": 1710000000000
}
from and to are inside the recipient-PGP envelope only. A connected call
may also send the same object as an AES control chunk on the peer's voice
channel. Receivers match the call by callId, end it on whichever copy arrives
first, and ignore the second.
In the native apps the caller's cancel is prepared when the dial starts (signed with the SDK's stored wallet key, encrypted to the callee, kept in memory only) and posted through the pre-warmed entry pool the moment the caller hangs up before an answer (in rollout). While ringing, the SDK re-signs a fresh copy every 2 seconds so the callee records an accurate end time. The page receives the result and sends no copy.
In rollout: wake receipt. As soon as the callee's device rings, it sends
the caller an ordinary signed message with NoPush:
{ "type": "voice_call_ringing_v1", "callId": "…", "sessionId": "<ring ID>", "from": "0xCallee", "to": "0xCaller", "createdAt": 0, "timestamp": 0 }
The caller accepts it only if callId matches its current dial and the
recovered signer is the callee, then shows "Ringing…". With no receipt within
8 seconds (VOICE_PEER_WAKE_TIMEOUT_MS), show that the callee's phone may be
restricting the app, and keep ringing.
In rollout: ring window. Every platform rings for 60 seconds from the
offer's createdAt. The caller decides: at createdAt + 60 s it sends
voice_call_timeout_v1 and records timed_out. A native caller signs that
timeout at dial time and sends it from its own timer; the callee closes 60
seconds after its own ring started as a backup and also sends a timeout, not
a decline.
In rollout: busy. A device already in another call answers a new offer
with voice_call_reject_v1 and reason: "busy", without ringing; the caller
plays a busy tone and both sides record busy. If the same verified caller
rings again while its earlier unanswered call is still shown, end the earlier
call as cancelled instead. Busy handling is still in development.
Voice channel v2 (removed from nodes)
Each listening side opens a temporary voice session that never occupies
mailbox_listen. The command is unsigned and carries no wallet:
{
"command": "voice_listen",
"sessionId": "<16-byte channel locator, hex>",
"timestamp": 1710000000,
"v": 2,
"wk": "<32-byte write key, base64>"
}
It was encrypted to the route PGP of the node that held the session and
sent through an entry that is not that node; the node answered voice_ready
with v: 2. The listener gave the locator and write key to the peer only
inside the signed offer or an AES control chunk, and announced v2 in
voice_media_caps_v1 (chan: 2). Since 2 November 2026 every node refuses
this listen with voice_listen_v3_required; clients open channels only with
v3 (below).
The chunk format that follows is shared by v2 and v3. Each chunk sent to the peer is an OpenPGP binary literal encrypted to the session node's route PGP and posted through a random entry from the rotation pool:
"CVC2" ‖ locator (16 bytes) ‖ HMAC-SHA256(wk, locator ‖ blob)[:16] ‖ blob
blob = nonce(12) ‖ AES-GCM ciphertext ‖ tag(16), padded to a multiple of 256 bytes (≤ 9000)
AAD = "CVC2" ‖ locator
plaintext header (28 bytes):
ver u8 = 2 | kind u8 (0 audio, 1 video, 2 control) | flags u8 | reserved u8
seq u32 (per kind) | captureMs u64 | frameId u32 | fragIdx u16 | fragCnt u16 | dataLen u32
The node checks the HMAC and the size, rate-limits per channel, and writes
data: {"t":"c","d":"<base64 blob>"} to the session SSE. It answers
{ ok: true }, voice_chunk_auth, invalid_voice_chunk, or
target_voice_session_not_found. It never decrypts, reorders or stores media,
and media never enters offline Chat, push, ChatIndexRegistry, or history.
Receivers decrypt first, then reorder by (kind, seq) from the inner header,
drop frames whose capture time is too old, and reassemble video by
frameId / fragIdx / fragCnt. Exact duplicates are ignored; buffer and
reassembly sizes are bounded. The protocol is real-time best effort.
Voice channel v1 (voice_uplink / voice_downlink JSON with an outer seq,
targetSessionId and base64 payload, reordered by the outer seq before
decryption) is gone: no node forwards v1 frames since 2 November 2026, and new
clients have no v1 path.
Voice channel v3: payer-signed temporary channels (live, no payment yet)
Not backward compatible. Every node accepts only v3 listens since 2 November 2026, and released clients use v3. The format and the payer-signature check are live; payment, metering and charging are not implemented. The callee pays for all four channels of a call (caller receives voice / video, callee receives voice / video), each with its own temporary wallet, AES key and write key, all opened when the call starts. Each side opens its own two channels on relays it chooses at random, excluding both mailboxes.
Offer.
voice_call_offer_v1withproto: 3carriescallId,sessionId(ring ID only),from,to,createdAt,expiresAt,timestamp,codec,callerPgpandcallerRoute(base64 armors), andcallerSignatureover the text below. No session key, temporary wallet or relay; the caller opens no voice stream while dialing.CoNET voice_call_offer_v3 callId:<callId> sessionId:<sessionId> to:<callee, lower case> createdAt:<n> expiresAt:<n> timestamp:<n> codec:<codec> callerPgp:<base64> callerRoute:<base64>Accept. The callee sends
voice_call_accept_v1withproto: 3as an ordinary signed message to the caller's mailbox (NoPush). The chat envelope's signature authenticates the callee. It carriesch: { cv, cV, ev, eV }:cv, cV (caller's channels): { sid, key, wk, tk, pay } ev, eV (callee's channels): { sid, key, wk, relay, relayIp, relayKey } pay = { payer, notAfter, maxBytes: 0, maxSeconds: 0, sig } sig = EIP-191 by the payer over: conet.voice.sse.pay.v3 temp=<temporary wallet address, lower case> notAfter=<unix seconds> maxBytes=<n> maxSeconds=<n>sidis the 16-byte locator (hex),keythe AES-256 key andwkthe 32-byte write key (base64),tkthe temporary wallet's private key, andrelay/relayIp/relayKeythe callee relay's route key ID, address and base64 route public key. Clients setnotAfterto the answer time plus 12 hours.Listen. Each side sends, encrypted to the relay's route PGP, through an entry that is neither the relay nor its own mailbox:
{ "command": "voice_listen", "v": 3, "sessionId": "<sid>", "wk": "<base64>", "timestamp": 1710000000, "temp": "0xTemp", "tempSig": "0x…", "pay": { } }tempSigis the temporary wallet's EIP-191 signature overconet.voice.sse.listen.v3|<sessionId>|<wk base64>|<relay route key ID, upper case>|<timestamp>. The relay answersvoice_readywithlisten: 3, or refuses withvoice_listen_v3_required,invalid_voice_session,invalid_voice_timestamp,voice_temp_auth(badtempSig, or signed for another relay),voice_pay_auth,voice_pay_expired(notAfterpassed or more than 48 hours ahead) orvoice_temp_reused(the temporary wallet is bound to another live session). A reconnect with the same temporary wallet and locator is accepted. Nothing is counted or charged.Channel ready. When both its channels are up, the caller sends
{ type: "voice_channel_ready_v3", callId, voice, voiceIp, voiceKey, video, videoIp, videoKey }as an AES control chunk on the callee's voice channel, retrying every 300 ms for up to 10 s ontarget_voice_session_not_found. The callee starts media after it. Either side resends it after moving a channel to another relay.Media. Audio and control go to the peer's voice channel, video to the peer's video channel, in the chunk format above with that channel's keys.
voice_media_caps_v1still negotiates the video format; itschan/wkfields are gone.
Inside the native apps, the page hands the shell the relays' public route keys
(setNativeVoiceRelayNodes), so the shell can pick its relays and encrypt
listens; a cold-start answer uses the last cached copy. Background, results
and privacy trade-off:
CoNET Chat whitepaper, Section 10.
Native push and call-UI capability registration
When a Consumer PWA runs inside the iOS or Android shell, the shell sends its
push token and native call-UI capabilities to the PWA. The PWA signs the
registration payload with the wallet EOA and sends it as an encrypted
push_device_register command to the wallet's own mailbox route:
{
"command": "push_device_register",
"walletAddress": "0xUser",
"deviceToken": "opaque-platform-token",
"platform": "android",
"bundleId": "com.beamio.app",
"timestamp": 1710000000,
"registrationSignature": "0x...",
"capabilities": {
"nativeCallUi": true,
"fullScreenIntent": true,
"callKit": false
}
}
The mailbox validates that walletAddress is its route, then forwards the
signed registration to /api/registerPushDevice. The PWA does not call the
push API directly, and the mailbox must not log device tokens or forward
private keys. fullScreenIntent is a capability report only; Android still
controls the permission and may revoke it.
Identity
| Item | Rule |
|---|---|
| Protocol identity | EOA + user OpenPGP + mailbox route |
User PGP keyID |
Encryption subkey: getKeyIDs()[1], uppercase hex |
| Mailbox | A Guardian node. Current register API field routeKeyID is that node’s domain |
@BeamioTag |
Display label only. Look it up after signature recovery, by the recovered EOA. Exact username match when searching by tag. Never read a tag from the message body, and never use search-users results[0] |
| Presence | Mailbox listen-pool via wallet_online_query through entry C ≠ B. Ignore searchKey.routeOnline (SI no longer writes it). The query is signed by the querier's wallet, so the contact's mailbox sees who asks (known limitation, to be solved with web3://; do not patch it ad hoc) |
| Key lookup | searchKey(contact) does not depend on the caller: call it without from. Only reading your own encrypted private key needs your own address |
| Native shell status | wallet_native_wake_query through the same entry, after presence. Boolean nativeWakeable. Direct-to-B is forbidden because it would show the querier's IP to mailbox B |
| Optional split | Routing EOA for AddressPGP + listen / ACK / presence; sender / recipient EOA only inside the encrypted envelope |
| Sender confidentiality | Sender wallet address and sender PGP key stay inside the recipient user-PGP ciphertext until the recipient decrypts. Sender PGP private key never leaves the sender. Hops may see only the recipient key id |
Current Beamio clients often use one EOA for all of the above. A new app can split them: register and listen with a routing wallet, encrypt to that row’s inbox user PGP, and put the product from / display wallet only in layer ②/③. Mailbox B then sees the routing EOA. Do not also write the product wallet into listen JSON or hop-sigs. See Routing wallet versus sender / recipient wallets.
Register and searchKey samples: SI developer guide.
Envelope layers
Live sendMessage signs the inner application string (text), then OpenPGP-encrypts a JSON envelope to the recipient user PGP.
① application body
plain text — or — typed JSON (receipt, POS permission, …)
② pending row (what most clients put in envelope.text)
{ sendId, from: "me", text: <① as string>, createdAt }
③ outer envelope (EIP-191)
{ timestamp, text: <JSON.stringify(②)>, from: <sender EOA>, signMessage }
signMessage = wallet.signMessage(text) // signs ②, not ③
④ OpenPGP
encrypt base64(JSON.stringify(③)) to R userPublicKeyArmored
⑤ mailbox layer (always; web app since PWA 0.52.930)
{ data: <④ armor>, NoPush?: true } → OpenPGP encrypt to R's mailbox B route PGP
No route key for B → fail with recipient_mailbox_unknown; do not send ④ bare
⑥ POST
{ data: <armor> } → one random entry (not R's mailbox, not the sender's mailbox)
next entry only after a clear failure; at most 4 entries per message
HTTP JSON is **only** `{ data }`. **Ordinary chat** omits `NoPush` so an
offline recipient can get a native badge; a **sender receipt** sets
`NoPush: true`. Live clients still post ordinary chat as bare ④ and wrap only
receipts. See [SI mailbox work](si-developer-guide.md#3-mailbox-work-envelope-mailbox-b-decrypts)
and [Native push badge and `NoPush`](../applications/depin-chat.md#native-push-badge-and-nopush).
A clear failure is a connection error, a reset before any response, a non-2xx
status, a 2xx body {"ok": false, …}, or no response within about 8 seconds.
An entry with no route for the armor's key ID answers
{"ok": false, "error": "no_route"}. Pick entries from a background-warmed
pool so a send does not wait for probes.
Any other 2xx is success. A timed-out post may already have reached B, so the
retry can produce a second copy of the same ciphertext; the mailbox stores one
copy per armor hash, and recipients deduplicate by sendId.
Inbound: decrypt with the recipient user PGP private key → parse ③ → ethers.verifyMessage(text, signMessage) must recover from. The recovered address is the sender. The JSON from field is only a claim; a nested from: "me" is a direction flag, not an address. Drop the message only when recovery fails. When the claim differs from the recovered address, keep the message under the recovered address and show a warning on that message that names the claimed address (native SDKs pass both to the app; they do not drop it). Then look up @BeamioTag for the recovered EOA. Do not display a tag or wallet copied out of ① or ②.
Inbound identity verification
| Check | What the recipient trusts | What it must not trust |
|---|---|---|
Inbound chat message (text, voice_message_v1, file, any Messages bubble) |
The address recovered by verifyMessage(text, signMessage). That address is the session peer and the bubble sender; a differing claimed from is shown only as a warning |
from, a wallet, a display name, or @BeamioTag inside the JSON. Pending-row from: "me" is only a direction flag |
| Display name | BeamioTag record for the recovered EOA (local mirror, then exact-address search) | Any tag string in the message body |
voice_call_offer_v1 |
callerSignature over the canonical offer text below, and that signer equals the outer-envelope signer |
from or a tag inside the offer |
voice_call_offer_v1 canonical text (newline-joined, identity fields excluded):
CoNET voice_call_offer_v1
callId:<opaque>
sessionId:<opaque>
to:<callee EOA lowercase>
createdAt:<ms>
expiresAt:<ms>
timestamp:<ms>
sessionKey:<or empty>
tempWalletAddress:<lowercase or empty>
entryDomains:<comma-joined or empty>
codec:<or empty>
sessionKey and tempWalletAddress belong to the v2 flow, which no node supports since 2 November 2026. Voice channel v3 removes both from the offer and signs the CoNET voice_call_offer_v3 text shown under Voice channel v3.
The caller signs that string with the same EOA private key (signMessage). The callee recovers the address and only then looks up the tag. If recovery fails, or it disagrees with the outer-envelope signer, do not present the offer’s claimed identity. A body claim that disagrees with the recovered key may be shown only as an extra unverified line with a warning. Native wake-up stays generic until this on-device check finishes.
POS terminal authorization nests another object inside ① (type: "beamio_pos_terminal_permission_v1"). Merchant OS must unwrap along text and put it on Staff pending, not Messages. See CoNET Chat.
Sample: send
Reuse postArmor / entry picking from the SI guide. This is the Chat envelope
in its rollout form: the user-PGP armor is always wrapped to the recipient's
mailbox, and the post goes to one entry at a time.
import { createMessage, encrypt, enums, readKey } from 'openpgp'
import { ethers } from 'ethers'
export type ChatPendingRow = {
sendId: string
from: 'me'
text: string
createdAt: number
}
async function encryptText(text: string, armoredKey: string, zlib = false): Promise<string> {
return encrypt({
message: await createMessage({ text }),
encryptionKeys: await readKey({ armoredKey }),
config: zlib ? { preferredCompressionAlgorithm: enums.compression.zlib } : undefined,
}) as Promise<string>
}
export async function sendChatMessage(opts: {
wallet: ethers.Wallet
recipientUserPublicKeyArmored: string
recipientMailboxRoutePublicKeyArmored: string // from searchKey; required
entryDomains: string[] // healthy entries, random order
excludeDomains: string[] // recipient mailbox and own mailbox
body: string
noPush?: boolean // true only for sender receipts
sendId?: string
}): Promise<{ ok: boolean; sendId: string }> {
if (!opts.recipientMailboxRoutePublicKeyArmored) {
throw new Error('recipient_mailbox_unknown')
}
const sendId = opts.sendId ?? crypto.randomUUID()
const pending: ChatPendingRow = { sendId, from: 'me', text: opts.body, createdAt: Date.now() }
const text = JSON.stringify(pending)
const envelope = {
timestamp: Date.now(),
text,
from: opts.wallet.address,
signMessage: await opts.wallet.signMessage(text),
}
// ④ recipient user PGP: only the recipient can read the sender wallet.
const userArmor = await encryptText(
Buffer.from(JSON.stringify(envelope)).toString('base64'),
opts.recipientUserPublicKeyArmored,
true,
)
// ⑤ mailbox layer: the entry sees B's route key ID, not the recipient's key ID.
const work = opts.noPush ? { data: userArmor, NoPush: true } : { data: userArmor }
const armored = await encryptText(JSON.stringify(work), opts.recipientMailboxRoutePublicKeyArmored)
// ⑥ one entry at a time; switch only after a clear failure.
const skip = new Set(opts.excludeDomains)
const targets = opts.entryDomains.filter((d) => d && !skip.has(d)).slice(0, 4)
for (const domain of targets) {
try {
const res = await fetch(`https://${domain}.conet.network/post`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ data: armored }),
signal: AbortSignal.timeout(8_000),
})
if (!res.ok) continue
const reply = await res.text()
if (/^\s*\{/.test(reply) && JSON.parse(reply)?.ok === false) continue
return { ok: true, sendId }
} catch {
// connection error or timeout: try the next entry
}
}
return { ok: false, sendId }
}
HTTP 200 on an entry means the armor was accepted for forward. It does not
mean the recipient UI ingested it. Deduplicate inbound copies by sendId.
Live clients still post ordinary messages as the bare user-PGP armor (step ④)
and wrap only receipts; the live mailbox also ignores NoPush. Both change in
the rollout. Inside the iOS and Android apps, pages do not post at all: they
call the native SDK's send, which signs, encrypts, wraps, picks the entry and
posts, and a page treats the send as done only when the SDK returns
sent: true (in rollout).
Sample: listen
Chat listen is an SI route command, not user-PGP business. Use
mailbox_listen, which accepts an opaque per-device instanceId. Full helper:
SI developer guide.
// Fresh 32-byte key for every listen. Keep it in memory only; never log it.
const streamKeyRaw = crypto.getRandomValues(new Uint8Array(32))
const streamKey = await crypto.subtle.importKey('raw', streamKeyRaw, 'AES-GCM', false, ['decrypt'])
const command = {
command: 'mailbox_listen',
walletAddress: wallet.address,
instanceId: crypto.randomUUID(),
streamKey: btoa(String.fromCharCode(...streamKeyRaw)), // required
}
// sign JSON.stringify(command); encrypt { message, signMessage } to own route PGP
// POST { data } to entry C ≠ mailbox B; read the SSE.
/** Every SSE `data:` line is base64(iv[12] ‖ AES-256-GCM ciphertext ‖ tag[16]). */
async function openFrame(line: string): Promise<unknown> {
const raw = Uint8Array.from(atob(line), (c) => c.charCodeAt(0))
const plain = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: raw.subarray(0, 12) },
streamKey,
raw.subarray(12),
)
return JSON.parse(new TextDecoder().decode(plain)) // trailing space padding is ignored
}
The plaintext of each sealed frame is the same JSON the live node writes in
the clear: the handshake, { data: armor } for a stored or live message, and
{ type: "mailbox_keepalive", ts }. It is padded with spaces to a multiple of
256 bytes, and every frame has a random IV. Drop every frame that does not
open, including any plaintext frame, and log one warning without its content.
The armor hash for the mailbox ACK is still computed over the inner user-PGP
armor, and the ACK is sent only after the message is stored.
Listen health (in rollout). A listen that looks open but delivers nothing loses messages silently. Follow these rules:
- Report "listening" only after the mailbox's sealed handshake frame
(
kind: "mailbox") opens, not on the entry's HTTP 200. - Use an idle timeout above the mailbox's longest keepalive gap of 180 seconds (the SDKs use 200 s native, 240 s browser).
- After a stream that received any sealed frame drops, sign a new
mailbox_listenwith a newinstanceIdandstreamKeyand reconnect within about half a second. Reuse the same signed command on the next entry only when the mailbox never answered; a reusedinstanceIdlets the old connection's late close remove the new session at the mailbox. - If your signing and decrypting run in a host that the OS can reclaim (the native SDKs use a hidden WebView), detect that, rebuild it and retry once.
- When an inbound message fails to decrypt (other than one for a different key), reopen the listen, at most every 30 seconds, so the mailbox sends the unacknowledged copy again.
- On return to the foreground, reopen the listen if the last sealed frame is more than 45 seconds old.
Every node refuses a listen without streamKey and never writes plaintext
Chat frames; the old algorithm / Securitykey fields are not used for Chat.
The legacy mining + listenKind: "chat" pool receives no Chat messages.
| Frame | Treat as |
|---|---|
Handshake / { status, epoch, … } |
Liveness. Ignore for Messages |
{ data: <user-PGP armor> } |
Decrypt, verify, unwrap, store, then dual receipts |
mailbox_keepalive |
Liveness only |
udp_listen_attached / UDP frames |
Only if you implement UDP; not Chat bubbles |
Keep listen on a Worker (or in the native daemon). Reconnect with another random C ≠ B after idle or drop. Use a setTimeout chain after the previous attempt finishes. Native daemons hold the listen only while the app is in the foreground, ringing, or in a call, and keep it open throughout a call.
| Client rule | Why |
|---|---|
Start the ~12s connect_timeout after fetch |
Wrap / encrypt() can burn the budget before C is even dialed |
Emit listening only after res.ok and res.body |
404 / empty body is a failed peel hop, not a live mailbox |
Exclude the failed node.domain on reconnect |
One hung C should not be retried first |
Do not let history.load starve listen on a single Worker thread |
Recover can run before activeClient exists |
A peel-success SI log forward <ip> is the client source IP, not mailbox B. If C peels then throws on hop-sign (Buffer.byteLength / instance of h), B is never dialed. Switching C does not help until peelers return a UTF-8 armor string. See Peel, hop-sig, and listen timeouts.
Sample: verify inbound
export function verifyChatEnvelope(plainUtf8: string): {
from: string // recovered signer: the sender
claimedFrom?: string // set only when the envelope claims someone else
text: string
timestamp: number
} | null {
let env: { timestamp?: number; text?: string; from?: string; signMessage?: string }
try {
env = JSON.parse(plainUtf8)
} catch {
return null
}
if (!env.text || !env.from || !env.signMessage) return null
let recovered: string
try {
recovered = ethers.verifyMessage(env.text, env.signMessage)
} catch {
return null
}
const claimedFrom = recovered.toLowerCase() !== env.from.toLowerCase() ? env.from : undefined
return { from: recovered, claimedFrom, text: env.text, timestamp: Number(env.timestamp) || 0 }
}
/** Walk nested `text` for typed application JSON. */
export function unwrapTextJson(displayText: unknown, max = 6): unknown {
let cur = displayText
for (let i = 0; i < max; i++) {
if (typeof cur === 'string') {
const t = cur.trim()
if (!t) return null
try {
cur = JSON.parse(t)
continue
} catch {
return cur
}
}
if (cur && typeof cur === 'object' && typeof (cur as { text?: unknown }).text === 'string') {
cur = (cur as { text: string }).text
continue
}
return cur
}
return cur
}
After decrypt, if the Worker received armor, compute armorHash = keccak256(utf8(fullArmor)) before discarding the armor string. You need it for the mailbox ACK.
Dual receipts (required)
After a business message is ingested (verified, not a receipt, not POS-permission-only):
- Mailbox ACK — SI command
gossip_delivery_ackencrypted to B route PGP, sent only after the message is stored locally. Removes the matching offline store. Sample: SI developer guide. - Sender receipt — ordinary Chat business envelope whose inner type is
beamio_chat_delivery_receipt_v1, encrypted to the original sender’s user PGP, then wrapped as mailbox work{ data: <user-PGP armor>, NoPush: true }encrypted to the sender’s mailbox B route PGP. HTTP to the entry is still only{ data }. SDK:sendMessage(..., { beamioNoPush: true })(requiresto.routerArmoredPublicKey).
export const CHAT_DELIVERY_RECEIPT_V1 = 'beamio_chat_delivery_receipt_v1' as const
export type ChatDeliveryReceiptV1 = {
type: typeof CHAT_DELIVERY_RECEIPT_V1
sendId: string
armorHash?: string
deliveredAt: number
from: string
}
export function makeDeliveryReceipt(opts: {
sendId: string
fromEoa: string
armorHash?: string
}): ChatDeliveryReceiptV1 {
return {
type: CHAT_DELIVERY_RECEIPT_V1,
sendId: opts.sendId,
armorHash: opts.armorHash,
deliveredAt: Math.floor(Date.now() / 1000),
from: opts.fromEoa,
}
}
Send that object as pending.text (JSON string) using the same sendChatMessage path, encrypting to the sender user PGP.
On the sender: unwrap inbound; if type === beamio_chat_delivery_receipt_v1, mark the existing outbound bubble delivered by sendId. Do not append a Messages row. Do not increment unread.
Mailbox stores ciphertext first (saveLocal), then best-effort SSE. Do not treat SSE forWard SUCCESS as “the user has the message.” On every durable chat save (SSE online or offline), CoNET-SI enqueues native push when the recipient EOA has a registered pushDevice; Beamio API no-ops if none. NoPush / skipPush frames are meant never to enter that queue; the live SI drops the NoPush flag when it unwraps mailbox work, which the rollout fixes. The rollout also stores one copy per armor hash, so the same ciphertext posted twice is stored and pushed once. ACK removes offline armor; it does not gate whether push was sent.
Presence
On entering the chat list:
- Refresh route fields (
routersArmoreds/ route PGP) fromsearchKey. Do not copyrouteOnlineinto the green dot. - For each contact that has a mailbox route, send
wallet_online_query(SI guide sample). - Merge
onlineonly whenok === true. Failures keep the last trusted value. - Then send
wallet_native_wake_querythrough an entry node that is not mailbox B. MergenativeWakeableonly whenok === true. A failed lookup keeps the last trusted flag. - The green dot stays online-only. A voice-call control may be offered when
online || nativeWakeable.
Store the trusted tag, image, names, online, and nativeWakeable in the local BeamioTag record and show that record first. Refresh a field from the network when it is older than 180 seconds. A failed lookup keeps the last trusted value. While a conversation is open, that screen owns the online query and repeats wallet_online_query every 6 seconds through an entry that is not the mailbox. If no such entry is available, skip the query. Do not POST it to mailbox B.
Online means: that wallet is in this mailbox’s livenessListeningPool and the listen socket is not stale. It is not a chain write.
wallet_native_wake_query is a CoNET Chat protocol command. Encrypt it to the contact mailbox B route PGP and POST { "data": "<armor>" } to entry C. C must not be B, and the client must not open a connection to B. That entry hop is what keeps the querier's IP address off the destination mailbox. B answers { ok: true, wallet, nativeWakeable }. nativeWakeable is true when that mailbox-registered wallet has a registered iOS, Android, Windows, Linux, or macOS shell that push can wake, including while the listen session is offline. The answer carries no device token.
Resolve @BeamioTag
type TagHit = { address: string; username?: string; accountName?: string }
export function pickExactBeamioTag(
query: string,
results: TagHit[],
addressHint?: string,
): TagHit | null {
const q = query.replace(/^@/, '')
const exact = results.filter(
(r) => r.username === q || r.accountName === q,
)
if (exact.length === 1) return exact[0]
if (exact.length > 1 && addressHint) {
const hint = addressHint.toLowerCase()
const hit = exact.find((r) => r.address.toLowerCase() === hint)
return hit ?? null
}
const ci = results.filter(
(r) =>
r.username?.toLowerCase() === q.toLowerCase() ||
r.accountName?.toLowerCase() === q.toLowerCase(),
)
if (ci.length === 1) return ci[0]
return null
}
Current discovery HTTP: GET/POST https://beamio.app/api/search-users (application API, not SI). If the exact match is ambiguous, fail. Never encrypt to results[0].
Typed payloads
type |
UI |
|---|---|
| (plain / no type) | Messages bubble |
beamio_chat_delivery_receipt_v1 |
Update existing outbound status → delivered only |
beamio_pos_terminal_permission_v1 |
Merchant Staff → Pending terminal authorization only. Requires childBeamioTag. Do not show as a chat JSON dump |
POS permission is still delivered as user-PGP Chat armor. Classification happens after verify + unwrap.
Voice messages: voice_message_v1
Voice is an application payload carried by the same recipient-only Chat envelope. It does not add an SI command, a plaintext HTTP field, or a mailbox media API.
{
"type": "voice_message_v1",
"fragmentHash": "0x<64 lowercase hex characters>",
"key": "<base64 AES-256 key>",
"iv": "<base64 12-byte nonce>",
"mime": "audio/webm",
"durationMs": 4200,
"sizeBytes": 123456
}
The manifest above is an illustrative decrypted shape. In transit it is inside the signed Chat application envelope and OpenPGP-encrypted to the recipient's user PGP. The audio bytes are encrypted locally with AES-256-GCM before upload. The random 256-bit AES key and 96-bit GCM nonce are present only in the recipient-readable manifest; they must never be placed in HTTP JSON, a route-key command, an IPFS filename, logs, or a mailbox plaintext field.
The encrypted audio is stored as one IPFS fragment. Clients upload the encoded fragment in sequential 512 KiB chunks and finalize it with the fragment hash. The gateway rejects objects beyond the 256 MiB gateway boundary; clients should reject an oversized recording before starting capture or upload. The hash identifies the encrypted fragment, not the plaintext recording.
The receiver verifies the Chat signature and manifest fields, downloads the
fragment through the existing IPFS gateway, and lets AES-GCM authentication
fail closed before creating an object URL. Playback is local: keep the
decrypted Blob and its URL.createObjectURL() only for the active message
view, revoke the object URL when playback/unmount ends, and never persist
decrypted audio or the object URL in Chat history. A failed download,
authentication, MIME parse, size, or duration check is an unavailable voice
message—not an empty or trusted replacement.
Voice-specific replay and integrity rules are application rules: bind the
payload to the Chat sendId (and, where the client supports it, a message
nonce/expiry), deduplicate before playback, verify the EIP-191 sender
signature, verify the fragment hash over the uploaded encoded ciphertext,
and rely on AES-GCM authentication before release to an audio decoder.
Neither an IPFS hash nor an HTTP 2xx is proof that a human heard the clip.
Privacy modes are product configuration, not SI commands. A client may offer voice disabled, contacts-only, recipient-only delivery, recording-length and size limits, upload through a selected entry set, and local-only retention policy. These options must not weaken recipient user-PGP encryption or put the AES key in a route-visible command. Applications should disclose that mailbox/entry nodes can still observe ciphertext size, timing, fragment arrival, and listen state; optional padding or delayed upload is a future application mode, not a property of the current protocol.
Real-time voice calls
Real-time calls are a separate application stream from voice_message_v1.
They never consume the normal mailbox_listen Chat SSE, which stays open for
messages, attachments, delivery receipts, call control and offline flush.
Media uses temporary voice sessions with voice channel v2; offers go to the
callee's mailbox with voice_offer_store. The wire format, the v1 fallback and
the planned payer-signed channels are described under
Voice-call wake-up and offer delivery.
Caller ── voice_offer_store { push: 1, offerArmor } ──> callee mailbox ──> push / callee SSE
Caller ── voice_listen { v: 2, wk } ──> caller's session node
Callee ── voice_listen { v: 2, wk } ──> callee's session node
Caller ── CVC2 chunk (callee locator) ──> callee's session node ──> callee voice SSE
Callee ── CVC2 chunk (caller locator) ──> caller's session node ──> caller voice SSE
Either side ── call control as a Chat message ──> peer mailbox ──> peer mailbox SSE
The session key reaches the callee only inside the signed recipient-PGP offer.
It must never appear in voice_listen, a chunk header, HTTP JSON, SSE
plaintext, logs, or mailbox storage outside the encrypted offer. Voice
commands carry no wallet and no wallet signature; the locator and write key
are the only credentials. Frames are not saved to offline Chat, APNs,
ChatIndexRegistry, or encrypted history. A successful relay means only that
a chunk entered the peer's SSE queue.
Encrypted history (optional second track)
L0 does not store Chat history. Apps that promise cross-device recover use:
| Item | Value |
|---|---|
| Registry (UUPS proxy) | ChatIndexRegistry 0x1511Caa71081C84d8a591490D1b83879088EED72 |
| Read pointer | RPC getPointer(eoa) on CoNET L1 (not Cluster-only) |
| Write pointer | EIP-712 SetPointer → Cluster POST /api/setChatIndexPointer (gas relay) |
| Fragments | https://ipfs.conet.network/api/storageFragment / getFragment?hash= |
| Hash | keccak256(utf8(cipherB64)) |
Local profile.chats is the UI track. After recover it may be empty. Decrypt the index, create missing sessions by peer EOA, then merge. Do not if (!chats.length) return. POS permission and delivery receipts stay off the encrypted history track.
Keys are derived in a Worker from an EIP-191 domain over the EOA. Without the EOA signing key, history is unreadable. See the product page and chat-sdk history worker for the HKDF labels.
History synchronization is a union merge, not a length-based table replacement. Records are deduplicated by sendId, or by cid when no sendId exists, preserving unique appends from both devices. A conflicting seq/cid or prevCid fork is repaired by decrypting the records, ordering them by timestamp and sendId, re-encrypting them with a new linear chain, uploading the fragments, and publishing the new pointer. Network failure or an incomplete pull must preserve the trusted local mirror. The Worker serializes mutations and runs background syncFromHead() on a non-overlapping setTimeout chain, which is cleared when the Worker is destroyed; setInterval is not used.
The same Worker keeps a local plaintext corpus of fragments it has already decrypted. Each record is keyed by fragment cid and stored in Worker memory and Worker IndexedDB (beamio.chat.history.plain:{eoa}:{cid}), together with a search string taken from the rendered message (text, call status, file name, a voice-message marker, payment title). Read order for a cid is memory, local plaintext, local ciphertext, then IPFS. A cid already in the corpus is not downloaded again. A newly sent message is stored as plaintext when it is encrypted. When the chain head hash is unchanged, the Worker skips the index download.
UI code does not fetch history itself. It calls the module:
| Host function | SDK call | Behavior |
|---|---|---|
readDecryptedChatHistory(options?) |
history.read |
Full local corpus, optional peer and limit. No chain or IPFS round trip |
searchDecryptedChatHistory(query, options?) |
history.read({ query }) |
Whitespace-separated tokens, all required, case-insensitive, over the rendered text of the whole corpus |
onDecryptedChatHistory(cb) |
corpus published after history.load |
UI merges newly materialized rows into sessions |
history.read does not sync the chain. When the Worker is not ready the host bridge returns null. That is an untrusted miss, not an empty history. Do not log the query string or message bodies. Cross-device recover still uses RPC getPointer and IPFS when local plaintext for a cid is missing. HTTP /post stays { "data" } only; this corpus adds no wire field.
Using the SDKs
Implemented, pending acceptance: entry-node service and domain fallback. A
candidate of the native SDKs reaches nodes through one entry-node service per
app process: direct IP first, and HTTPS to the node's verified
<key ID>.conet.network domain only after transport failures on 3 different
nodes within 30 seconds. It is verified on real devices for ordinary network
switches, cellular messaging and airplane-mode recovery, but not yet under a
real carrier block. The iOS and Android apps have not integrated it, and no
release contains it. Details: CoNET Chat whitepaper, Section 7.6.
Three implementations share one command protocol: the TypeScript Worker
(@conet.project/chat-sdk, used by browsers), chat-sdk-ios
and chat-sdk-android. Inside
the iOS and Android apps the page never starts a Worker; it talks to the native
module.
Live (chat-sdk-ios 0.1.7, chat-sdk-android 0.2.5).
- The page calls
provisionIdentityonce with the wallet private key, the user PGP keys and the own route key. The native SDK stores them only in the Keychain (AfterFirstUnlockThisDeviceOnly) or the Keystore and refuses if it cannot.clearIdentitywipes them on sign-out or wallet change. Browsers keep the keys only in Worker memory, never on disk. - The native daemon signs
mailbox_listen, ACKs and sends itself and owns the mailbox SSE. It connects only while the app is in the foreground, while a call push is ringing, and during a call; nothing runs after the app exits. A call push opens the listen without waiting for the page. - Messages that arrive while no page is loaded are kept as PGP ciphertext and ACKed once the page has taken them.
- The native SDK posts to entries by IP over plain HTTP on port 80 (see the known limitations on CoNET Chat).
In rollout.
hellois the first command (ChatDaemon protocol v1). The answer listsprotocolVersion,sdkVersionand capabilities; hosts decide by capability, not by checking whether a method exists. Daemons that do not answer keep the pre-v1 behaviour. The spec and shared test vectors live in@conet.project/chat-sdk(src/protocol.ts,spec/).- The native SDK keeps a pool of about 8 probed, healthy random entries, so a send picks one at once. Probes carry no wallet or signature. Each send still uses a different entry and excludes both mailboxes. The pool runs only while the daemon is active.
- The native SDK maintains the node table itself, like CoNET-SI does: it reads
the storage root of
GuardianNodesInfoV6and pulls the full table only when the root changes. The page's node list is secondary. - Pages send through the native
sendinstead of posting ciphertext themselves.
Planned (ChatDaemon P2–P4). Inbound verification, storage and ACK move into the daemon, which becomes the only message store (the page's chat list is a display cache); then sending, presence, offers, nodes and route registration; then the old sign broker and legacy history paths are removed.
Host responsibilities that remain:
- implement UI ingest: classify typed payloads, create sessions on recover;
- register coupon / tag / merchant data on your own stores; the SDK is the gossip plane;
- keep secrets out of logs.
If you hand-roll, keep the same A/B/C and envelope rules so mailboxes stay interoperable with Beamio clients.
Diagnosis: “I sent but they never see it”
Walk this order. Do not skip to “the parser is broken.”
- Recipient EOA equals the AddressPGP row you encrypted to (not AA, not a prefix-search collision). Never take
search-usersresults[0]. - Recipient listen is up as
mailbox_listen(withstreamKey) and SSE Connected. A legacymining+listenKind: "chat"listen receives no Chat. Epoch / listing heartbeats prove the pipe is alive; they are not inbound Chat. If the console shows only[Gossip] Unknown formatand no message, the business armor never entered this mailbox — do not conclude “the parser is broken.” - If the Worker reports
connect_timeout(~12s) and changing C still fails: check whether C peeled then threw on hop-sign (string/ instance ofh) and neversocketForwardto B. A log-onlyuncaughtExceptionleaves the SSE open until the client timer. See Peel, hop-sig, and listen timeouts. - SI
forward <ip>after peel is the client source IP, not mailbox B. - Mailbox logged
forWard SUCCESSorsave to Local. Offline users get a flush on next listen; the client must process that flush (do not skip the first SSE event) and dedupesendId. - Fake-armor 404 is SI rejecting bad PGP, not a down node.
- Sender used an entry that is neither mailbox. Direct-to-B is not the product path. After the rollout, a send without the recipient's mailbox route key fails with
recipient_mailbox_unknownbefore any POST. - Recipient
isMyRoute/ wallet↔route hash consistency can block listen and presence without blocking user-PGP store.
Group chat V1 (small groups)
Group chat V1 is an application composition over the same single-recipient Layer Minus path. It does not introduce a multi-recipient OpenPGP armor, an SI group command, or a shared group mailbox.
| Constraint | Value |
|---|---|
| Max members | 10 (GROUP_V1_MAX_MEMBERS) |
| Delivery | One OpenPGP encrypt per recipient user PGP; N independent POST { data } |
| SI | Rejects multi-recipient armor (rejected_multi_recipient_armor) |
| Roster authority | Owner-only signed conet_group_roster_v1 |
| Larger groups / FS | Deferred to Chat crypto V3 (MLS / epoch) — security limits |
Typed objects (inside user-PGP ciphertext)
| Type | Who signs | Role |
|---|---|---|
conet_group_roster_v1 |
Owner EOA | Canonical membership + admins; version, prevRosterHash, members[], policy, validity window |
conet_group_admin_proposal_v1 |
Admin EOA | Proposal only — does not change local roster until the owner issues the next roster |
conet_group_tombstone_v1 |
Owner EOA | Ends the group; clients stop accepting new messages for that groupId |
conet_group_message_v1 |
Sender EOA | Body bound to groupId, rosterHash, sendId, seq, expiry |
conet_group_delivery_v1 |
(carrier) | Optional padding wrapper around a message before encrypt |
Signatures are EIP-191 over CoNET <kind> v1\n + canonical JSON (signature field omitted from the hash). Recipients verify and reject on mismatch.
Roster chain
- First roster:
version === 1,prevRosterHash === null. - Next roster:
version === current + 1andprevRosterHash === keccak256(utf8(canonicalJSON(current))). - Owner must remain a member; admins ⊆ members; no duplicate EOAs; member count ∈
[1, 10]. - Clients apply only after
assertNextGroupRosterV1(or equivalent). Forks and skipped versions fail closed.
Delivery privacy
for each member except sender:
pad body to fixed bucket (default 4096 bytes)
EIP-191 envelope (same Chat outer shape)
OpenPGP encrypt to that member’s user PGP (exactly one key)
wrap to that member’s mailbox B route PGP (the web app does so since PWA 0.52.930)
POST { data } through one random entry ≠ that member’s mailbox B and ≠ own mailbox
- Prefer distinct entries across the fan-out so no single entry observes the full recipient set.
- Bound concurrency (SDK default ≤ 4, typically 2) and add small jitter between posts.
- Do not compress before encrypt when using fixed-bucket padding.
- Verify the recipient OpenPGP fingerprint against the active roster before encrypt.
- Logs must not print roster, member EOAs, fingerprints, body, or armor.
Partial fan-out success is application-visible (acceptedRecipients /
failedRecipients). Transport 2xx on one recipient is not group delivery.
Client apply order
- Decrypt + verify outer Chat envelope (
verifyMessage→ sender). - Parse typed group object; verify object signature.
- For messages: require
rosterHashto match the locally accepted roster; reject if tombstoned or expired. - For proposals: store pending; wait for owner roster.
- For roster / tombstone: apply only if owner-signed and chain rules pass.
Reference implementation: vendor/conet-chat-sdk (group.ts,
GossipCore.sendGroup). SDK is the protocol surface; UI only displays results.
Checklist
- [ ] Sender registered with
regiestChatRoute(wallet-signed, with a fresh timestamp and nonce, once the updated API is deployed) +searchKeyshows the local subkey - [ ] A failed or timed-out read of your own record is retried, never answered by registering, picking a new mailbox or generating a new key
- [ ] Business encrypt-to recipient EOA user PGP; listen/ACK/presence/offer store encrypt-to route PGP
- [ ] (rollout) Every delivery wrapped to the recipient mailbox route PGP; no route key →
recipient_mailbox_unknown - [ ] One entry at a time, excluding both mailboxes; next entry only after a clear failure
- [ ]
searchKeyfor a contact sent withouteth_callfrom - [ ] Group V1 uses one armor per member (no multi-recipient OpenPGP); SI rejects multi-key armor
- [ ] Group roster / tombstone are owner-signed; proposals do not mutate roster alone
- [ ] Listen is
mailbox_listenvia C ≠ B; (rollout) freshstreamKey, sealed frames opened before parsing - [ ]
connect_timeoutstarts afterfetch;listeningrequiresres.ok+ body - [ ] Peel / hop-sign failure is diagnosed before “switch C” or “parser broken”
- [ ]
@tagresolution is exact; noresults[0] - [ ] Inbound sender = recovered signer; a different claimed
fromis a warning, not a drop; typed unwrap walkstext - [ ] Dual receipts after ingest; receipts and POS permission are not Messages rows
- [ ] Presence is
wallet_online_queryvia entry C ≠ B; failures do not clear a trusted green dot - [ ] Native shell status is
wallet_native_wake_queryvia that same entry, after presence, so mailbox B never sees the querier's IP - [ ] Call offers go only to the callee mailbox (
voice_offer_store,push: 1); notargetWallet/offerArmoronvoice_listen - [ ] Call control (cancel, decline, timeout, hang-up) is sent as Chat messages through the mailbox
- [ ] Voice commands carry no wallet and no wallet signature; chunks are v2
CVC2 - [ ] Recover creates missing sessions before merge
- [ ] No private keys, full private PGP,
streamKey, write keys or session keys in logs