Zero-trust mailbox routing
Evidence level: Implemented capability. The delivery path below is specified by the current protocol and implemented in CoNET-SI and participating clients; deployment health remains separate evidence.
Mailbox routing is the specified Layer Minus forwarding protocol. It separates the node that accepts a client connection from the node that stores ciphertext for the destination wallet. It is one piece of the permissionless cloud: do not trust A, B, or C. Any of them may be malicious. Privacy comes from encryption and role split, not from picking an honest operator.
L0 stops at “deliver this OpenPGP armor to the mailbox of this key.” Chat schemas, POS permission types, acknowledgements, and UI are application combinations of the same path.
Voice-call wake-up is also an application combination. The caller posts one voice_offer_store command with push: 1,
encrypted to the callee's mailbox route PGP, through a random entry that is
neither mailbox. It carries opaque callId + sessionId and offerArmor: the
signed call offer already encrypted to the callee user PGP. The callee
mailbox stores that ciphertext under callId + sessionId without decrypting
it, finds its own user from the offer's recipient key ID, and calls the Beamio
push API. The callee mailbox already serves that wallet, so no other node
learns the callee, and the caller never calls the push API. A killed-app native
SDK later fetches the offer with voice_offer_fetch and decrypts it locally.
callId is a random wake-up reference; sessionId identifies one offer. The
command and push carry no wallet, session key, private key, audio, or call
plaintext.
The callee mailbox also writes the offer into the callee's live mailbox SSE.
There is no other wake path: every node ignores the old fallback fields
(targetWallet, offerArmor on a voice_listen to the caller's own mailbox)
since 2 November 2026, and new clients no longer send them.
Initiator-hidden voice stays in force: the plaintext initiating application
wallet exists only inside the user-PGP offer. The mailbox must not decrypt the
offer and must not put the caller wallet or @BeamioTag in a command, the push
request, or logs.
Roles
| Symbol | Role |
|---|---|
| S | Sender of the business envelope (application EOA; may differ from any routing wallet) |
| R | AddressPGP row that owns the inbox user PGP and mailbox B. This may be a dedicated routing wallet, not the product display / payment EOA |
| A | Healthy entry used for sending |
| B | Mailbox selected by R's route binding |
| C | Healthy entry used for listening |
For the intended route, A ≠ B and C ≠ B. A and C may be different entries. A client must not optimize the path by connecting directly to B.
How business delivery works
Send: S → A → B
- S resolves R's
userPublicKeyArmored(the inbox key on R's AddressPGP row). - S signs the application envelope with its sender EOA. The sender wallet is a field of that application envelope, not a plaintext HTTP or hop-header field. That EOA does not have to be R, and R does not have to be the recipient's display wallet.
- S encrypts the complete envelope to R's user OpenPGP key.
- Required (live in the web app since Consumer PWA 0.52.930): S wraps that armor as mailbox work
{ data: armor, NoPush? }encrypted to B's route PGP, so A and any path observer see B's route key ID (one node key shared by many users) instead of R's user key ID. Without B's route key S fails withrecipient_mailbox_unknown; it does not post the bare armor. Live clients still post the bare armor for ordinary messages. - Optionally, S wraps the result in one or more outer OpenPGP layers addressed to A or to a hop chain. The first
/postthen shows the outer key ID to a path observer. - S posts the armored ciphertext to one random healthy entry A over HTTP or HTTPS. A must be neither R's mailbox nor S's own mailbox. S tries another entry only after a clear failure (connection error, non-2xx, a
{ok:false}body such asno_route, or no response within about 8 seconds), never the same ciphertext to several entries at once (in rollout; live browser clients still post to up to four entries in parallel). The HTTP JSON is only{ "data": "<OpenPGP armor>" }. Do not add sibling fields (NoPush,beamioNoPush, flags). Extra plaintext fields raise inspection risk. Because the body is already ciphertext, HTTP is sufficient and is the intended client path where TLS SNI or JA3/JA4 would be classified or blocked. - A reads
getEncryptionKeyIDs(). If the key is not local, A forwards the same armor and signs the SI hop header. If the key is local, A decrypts once. When the plaintext is still OpenPGP and the inner side-channel key ID is not this node, A forwards the inner armor if hop signatures stay at or below 3. Same-node inner PGP is an attack (end). A does not read user-PGP business plaintext. - B stores the inbound armor before attempting live SSE delivery (once per armor hash, so a retried post is not stored or pushed twice). B does not
decrypt the user-PGP business envelope and therefore cannot read the
sender wallet or the sender PGP key carried inside it. Only R learns those
after local decrypt and EIP-191 verification. The recovered signer is R's view of the
sender. A wallet or
@BeamioTaginside the plaintext is a claim, not that proof. When B ends the socket, A frees that connection. SI hop signatures are the credential the last decrypting hop uses to meter prior-hop bytes against the user wallet for GB. GB issuance from these counts is paused.
Mailbox work envelope (B decrypts a delivery instruction)
HTTP to the entry remains { "data": "<mailBoxNodeOpenPGP armor>" }. Only B decrypts the work JSON.
inner user-PGP armor
→ JSON { data: innerArmor }
→ OpenPGP encrypt to mailbox B route PGP
→ optional wrap to this entry
→ POST { data } to A ≠ B
| Layer | Content | Encrypt to |
|---|---|---|
| HTTP (entry / SI→SI) | Only { data: armor } |
— |
| Optional entry wrap | Inner armor | That entry route PGP |
| Mailbox work | { data: innerArmor } only |
Mailbox B route PGP |
| Business | Chat / application offer | Recipient user PGP |
NoPush in mailbox work only means Chat/APNs “skip native badge.” Every node honours the flag when it unwraps mailbox work (since 2 November 2026). New clients send every user-PGP delivery as mailbox work (ordinary messages omit NoPush).
Required B behavior after unwrapping mailbox work: read the inner armor’s encryption key ID (user PGP, 16-hex, case-insensitive) and getRoute it: store and deliver to the Chat listen when the route is local, otherwise forward one SI hop. L0 exclusive occupancy (l0_listen, l0_connect, mining with listenKind: "l0", and idle-L0 delivery of mailbox work) was removed from CoNET-SI on 2026-10-31. SI no longer keeps an idle L0 pool or matches mailbox work against it.
Missing B’s route public key is a failure. Do not fall back to an HTTP sibling field. gossip_delivery_ack is a signed route command, not mailbox work. Chat sender receipts that still use historical NoPush are Chat-only.
Wire samples: SI developer guide — mailbox work.
The key ID is an intentional OpenPGP side channel. It exists so that a node can learn where to send the next hop without learning what the innermost ciphertext says. A peel node still learns the next key ID.
S ── user-PGP ciphertext, optional wrap to A ──▶ A
A ── not local: forward same armor
A ── local decrypt + inner key ≠ A: forward inner armor ──▶ B
A is metered for forwarding, not for reading content
Listen: R → C → B → R
- R resolves B's route public key.
- R signs a
mailbox_listencommand and encrypts it to B's route OpenPGP key. The command carries a fresh 32-bytestreamKey(base64), generated for every listen and kept only in memory; B refuses a listen without it. - R opens an HTTP/SSE request to healthy entry C.
- C forwards the opaque command to B over HTTP on port 80. If the client wrapped the listen command to C’s route key, C peels once and must hop-sign the inner UTF-8 armor string. Prefer the peel plaintext when it already contains
BEGIN PGP MESSAGE. Do not pass an OpenPGP.js 6Message.armor()stream / thenable intoBuffer.byteLength. Hop-sign failure, non-UTF-8 armor, or C→B TCP timeout (~8s) must return a fast 404 and close the client socket. A log-onlyuncaughtExceptionthat leaves the SSE open is a protocol bug: the client waits until its ~12sconnect_timeoutwhile B is never dialed. Field lesson: Peel, hop-sig, and listen timeouts. - B decrypts the control command, verifies that R belongs to its route, and attaches the SSE response to the appropriate listen pool.
- B pushes stored and live business ciphertext through C; only R decrypts the business envelope. Dedicated
mailbox_listensessions are keyed by a connection instance, not by wallet, so every healthy device session for R receives the same encrypted armor. - B seals every frame it writes on that SSE (handshake, offline flush, live message, keepalive, UDP notice) as
data: base64(iv[12] ‖ AES-256-GCM ciphertext ‖ tag[16]), with a random IV per frame and the plaintext JSON padded with spaces to a multiple of 256 bytes. C then sees only B's address, frame timing and bucketed length. The ACK still hashes the inner user-PGP armor.
R ── route-PGP listen ──▶ C ── HTTP :80 ──▶ B
R ◀──── business frames over SSE through C (sealed with streamKey) ──── B
Client-to-entry /post may use HTTP or HTTPS. Native and censorship-sensitive clients should prefer HTTP so that delivery does not depend on a TLS handshake. Browser pages served over HTTPS may still be forced to HTTPS by mixed-content policy. SI-to-SI forwarding uses HTTP on port 80. An entry's HTTPS certificate failure is therefore not evidence that the forwarding plane is broken, and it is not a reason to require TLS for /post.
A native app (iOS / Android) does not use https://{domain}.conet.network/post for ciphertext delivery. Its native Chat SDK reads GuardianNodesInfoV6.getAllNodes itself and stores ip_addr (field node[3]), plus the diagnostic PGPKey and region. The PWA does not supply or refresh that list. (In rollout the SDK polls the contract's storage root and pulls the full table only when it changes, the same way CoNET-SI does, and keeps a pool of probed healthy entries warm while the daemon is active.) The shell POSTs { "data": "<armor>" } to http://{ip}/post or http://[{ipv6}]/post on port 80, picks the entry IP, and excludes mailbox B's IP (A ≠ B, C ≠ B still apply). It derives mailbox B's IP by matching the stored route public key to a node's PGP. The PWA bridge (postPgpCiphertext) must not pass an entry, domain, or URL. Domain strings stay diagnostic. If the roster is empty, the bridge returns no_si_roster and the PWA may fall back to the browser HTTPS URL until the shell's own getAllNodes refresh succeeds. Once a roster exists, the shell does not fall back to a Guardian hostname. Since chat-sdk-ios 0.1.7 / chat-sdk-android 0.2.5 the native SDK also owns the mailbox SSE (plain HTTP to an entry IP, port 80), signed with the keys the page provisioned; the WebView no longer opens its own listen. Native posts and SSE are plain HTTP without an extra entry layer; this is a known limitation, not being changed now.
Listen namespaces
The SI runtime labels long-lived sessions so that unrelated lifecycle policies do not interfere with one another:
| Use | Command | listenKind |
Pool |
|---|---|---|---|
| Chat, Merchant OS, Alliance (legacy) | mining |
chat |
Shared liveness pool, labeled chat. Receives no Chat messages (its frames cannot be sealed); presence only |
| Mailbox B, multi-device Chat | mailbox_listen |
— | Dedicated mailbox pool; one wallet may have multiple SSE instances and each receives a fan-out copy. streamKey required, every frame sealed |
| LayerMinus mining gossip | mining |
Omitted; defaults to mining | Shared liveness pool, labeled mining |
| UDP client | udp_listen, or mining |
udp |
Separate UDP client pool |
| UDP server | udp_server_listen, or mining |
udp_server |
Separate UDP server pool |
| Real-time voice participant | voice_listen |
— | Separate temporary voice-session pool. Only v3 is accepted: a write key, a temporary wallet signature bound to this relay, and a payer authorization |
Chat, mailbox, mining, UDP, and voice are distinct SI listen namespaces.
Voice uses voice_listen plus unsigned, wallet-free chunk posts (CVC2
binary literals) only for opaque media relay; it never uses the normal
mailbox_listen SSE.
L0 exclusive occupancy (l0_listen, l0_connect, mining with listenKind: "l0", and idle-L0 delivery of mailbox work) was removed from CoNET-SI on 2026-10-31. l0_listen / l0_connect now close the
connection, and listenKind: "l0" is refused.
A completed HTTP request body does not make a receive-only SSE socket stale. SI checks whether the socket remains writable; chat-only timeout or zombie policy must not evict a mining session.
Mailbox B fan-out and keepalive
mailbox_listen is the preferred Chat receive command. The signed command may
include a client-generated opaque instanceId; if omitted, B assigns one.
The mailbox pool stores sessions by (wallet, instanceId) and maintains a
case-insensitive user-PGP-key index. A message is first persisted by saveLocal
and then delivered independently to every non-stale session. A failed session
is removed without preventing delivery to the remaining devices.
Mailbox SSE sessions use a reliability keepalive scheduled as a non-overlapping
setTimeout chain. The current implementation chooses a bounded delay between
60 and 180 seconds for each session. This jitter is for reconnect and proxy
load distribution; it is not a traffic-masquerading or detection-evasion
feature. Operators must configure upstream idle timeouts above the 180-second
bound plus network margin.
Epoch / listing SSE frames ({ status, epoch, ipaddress, … } or nodeWallets) prove the listen pipe is alive. They are not business delivery. B must not treat a healthy writable chat listen as expired solely because connectedAt is older than a few seconds. Live SSE is skipped only when the socket is stale or unwritable; the armor is still stored (saveLocal). PGP key IDs used to attach a listen and to look up that listen must be compared case-insensitively (uppercase hex).
Delivery and presence semantics
Layer Minus exposes several milestones. They are not interchangeable:
| Observation | What it proves | What it does not prove |
|---|---|---|
| Entry returns 2xx | The entry accepted the request | B stored it, R decrypted it, or UI displayed it |
| Listen handshake | C reached B and B attached a session | Any business message has been processed |
Mailbox gossip_delivery_ack |
The recipient client accepted the identified armor and acknowledged it to B | The sender has seen a receipt |
| Sender delivery receipt | The recipient application reported the message delivered | Human reading or response |
Chat clients send the mailbox acknowledgement and a sender-facing receipt after successful application ingestion. Until acknowledgement, B may retain the encrypted offline copy. On durable chat saveLocal (unless mailbox-work NoPush / skipPush), B enqueues native push when the recipient has a registered pushDevice—whether or not an SSE listen is currently online.
Presence is local to the destination mailbox. A signed wallet_online_query, encrypted to B's route key and sent through C, asks whether the target has a non-stale listen session in B's pool. The historical on-chain routeOnline field is not current presence truth.
Native shell status (wallet_native_wake_query)
This command is part of the CoNET Chat protocol. A client sends it only after wallet_online_query for the same contact.
| Rule | Requirement |
|---|---|
| Encrypt to | The contact mailbox B route PGP |
| HTTP | POST /post with body { "data": "<OpenPGP armor>" } only |
| Path | A healthy entry node C, and C must not be B. C forwards the armor to B |
| Querier IP | The querier must not open a socket to mailbox B. Entry C is the only client-facing hop, so B learns the query and not the querier's IP address |
| Answer | { ok: true, wallet, nativeWakeable } |
nativeWakeable: true |
The mailbox-registered wallet has a registered native shell on iOS, Android, Windows, Linux, or macOS that push can wake |
nativeWakeable: false |
No such registered shell |
| Secrets | The response contains no device token, push credential, or private key |
| Failure | { ok: false, … } is untrusted. Keep the last trusted boolean |
A direct POST to B is not a compliant query. It would show the querier's IP to the destination mailbox and is forbidden for the same reason listen and presence are forbidden to dial B.
Voice media carried by Chat
voice_message_v1 is not a mailbox media primitive. It is a typed business
object carried inside recipient user-PGP armor. The sender encrypts the audio
bytes locally with AES-256-GCM, stores the encrypted fragment through the
existing IPFS storage path, and sends a manifest containing the fragment hash,
AES key, nonce, MIME type, duration, and plaintext size only inside the
recipient-readable Chat envelope.
The encoded encrypted fragment is uploaded in ordered 512 KiB chunks. The gateway applies a 256 MiB maximum object boundary. A mailbox or entry must not receive the AES key, nonce, plaintext audio, or a voice-specific HTTP field. The fragment hash is an identifier for ciphertext and is not a substitute for recipient authorization.
On the client, the recipient verifies the Chat signature and manifest, retrieves the ciphertext, checks the hash, and relies on AES-GCM authentication before creating a playback Blob. Object URLs are local ephemeral resources: revoke them when playback or the owning view ends, and do not upload or put decoded audio into mailbox storage or Chat history. IPFS and mailbox nodes therefore retain only encrypted media, while entries and mailbox operators may still observe ciphertext size, timing, arrival, and listen metadata.
Voice replay protection, deduplication, and privacy settings belong to the
application. Clients should bind a voice object to sendId plus an optional
nonce/expiry, reject duplicate playback, and fail closed on hash, size, MIME,
duration, or GCM errors. Supported policy choices may include voice disabled,
contacts-only or recipient-only delivery, local retention limits, recording
limits, and optional padding/delayed upload. These choices do not change the
A/B/C route or create a new SI command.
For real-time voice/video, the same boundary applies to media chunks. Each chunk is an OpenPGP binary literal
"CVC2" ‖ locator(16) ‖ HMAC16 ‖ blob, encrypted to the route PGP of the node
that holds the listener's voice session and posted through one random entry
from a background-warmed pool, a different entry per chunk. The node checks the
HMAC with the session's write key and writes only the blob to the session SSE.
Sequence numbers, capture time, media kind and fragment numbers are inside the
AES-GCM blob (padded to a multiple of 256 bytes), so the node cannot read or
reorder them; the client decrypts first and then reorders and reassembles.
Voice chunks carry no wallet and no wallet signature. The older v1 format
(voice_uplink JSON with outer seq, callId and session IDs) and v2 listens
are no longer accepted by any node since 2 November 2026.
Voice channel v3 (every node since 2 November 2026; clients in release
candidates): the relay that holds a session is chosen by
the listening side and is never either party's mailbox. voice_listen must
carry v: 3, a temporary wallet, that wallet's signature bound to this relay's
route key ID, and the paying callee's EIP-191 authorization over the temporary
wallet. The relay checks both signatures, an expiry at most 48 hours ahead and
one temporary wallet per session, and refuses anything else with a specific
error (voice_listen_v3_required, voice_temp_auth, voice_pay_auth,
voice_pay_expired, voice_temp_reused). It does not forward v1 frames. Wire
details: CoNET Chat developer guide.
Offline storage charges (approved, not yet live)
Today a mailbox stores offline copies until the recipient's ACK without charging. The operator decided on 7 November 2026 that users register with their mailbox node and prepay at least 0.5 GB of credit, and that storage is charged by stored bytes and time until the ACK, at the DAO price of 1 GB forwarded = 1 GB stored for 30 days. None of it is implemented yet. Full rule and open items: Mailbox storage billing.
Guarantees and non-guarantees
When routing and encryption rules are followed:
- A and C cannot decrypt business content (user-PGP innermost armor);
- the application sender wallet is inside that business armor, so A, B, and C do not receive it as a plaintext routing field;
- A and C can read the OpenPGP key ID on the layer they handle. If the client used an outer envelope, the first-hop observer sees A's key, not R's. After a local decrypt, A still sees the next key ID — that is the routing primitive, not a leak of message text. When a client posts the business armor without the mailbox layer (released clients do), that next key ID is R's user key (public on chain), so A learns the recipient. New clients always add the mailbox layer, so A sees only B's route key ID; C sees only sealed frames;
- B can decrypt mailbox control and mailbox-work JSON (
NoPush) but not user-PGP business content; - B sees an entry connection instead of a direct client connection;
- client
/postconfidentiality does not require HTTPS; and - a forwarding node is metered, and is meant to be paid in GB, for relaying ciphertext, which aligns the incentive with delivery rather than inspection. GB issuance from metering is paused.
The design does not hide the client IP from A or C. It separates that IP
observation from the sender wallet and mailbox state: A/C see the connection,
while B sees the destination route through an entry connection. The design
also does not prevent a global observer from correlating timing and sizes,
protect a compromised endpoint, or guarantee availability of an entry or
mailbox. Ciphertext key IDs and routing metadata remain visible where
forwarding requires them. HTTP /post makes the application shape visible to
a path observer in exchange for avoiding TLS metadata.
For real-time voice, compare the actual command fields rather than relying on
the word “relay.” A v3 voice_listen carries a channel locator, a write key,
a timestamp, a temporary wallet and the paying callee's authorization; chunks
carry the locator, an HMAC and padded ciphertext. Each relay of the call
learns the paying callee's wallet and a temporary wallet; it does not learn
the other party
(CoNET Chat).
A/B/C are roles. Collusion of A+B, C+B, or one operator running all three reconstructs send relationships or binds a wallet to an IP. Distinct Guardian addresses are not an operator-domain proof.
checkSign authenticates the signed command string. Mailbox saveLocal appends armor and does not consume a nonce. A valid old request can be replayed unless the application binds messageId / nonce / expiry and persists consumed state. See security limits.
Direct-to-B requests violate the privacy model even if they function. Other protocol violations include encrypting business data to B's route key, targeting an AA without user PGP material, choosing a non-exact tag result, placing a UDP symmetric key in a route-key command, and putting mailbox instructions (NoPush / beamioNoPush) on the HTTP JSON instead of inside B-decryptable mailbox work.
Single recipient key ID (group fan-out)
OpenPGP packets expose every encryption key ID needed to unwrap the session key. A multi-recipient armor therefore lists every member’s key ID on the wire. Any entry or mailbox that handles that armor can reconstruct a group membership set without decrypting the body.
Normative for Layer Minus and Chat group V1:
| Rule | Requirement |
|---|---|
| One armor, one recipient | Business user-PGP armor and mailbox-work inner armor on /post must encrypt to exactly one encryption key ID |
| SI fail-closed | If SI observes more than one encryption key ID, it rejects the post (rejected_multi_recipient_armor) and does not store or forward it |
| Group fan-out | A group message is N independent single-recipient posts (same application object, different user PGP). Prefer distinct healthy entries A ≠ each recipient’s B so no single entry sees the full fan-out set |
| HTTP shape | Still only { "data": "<armor>" } — no roster, member list, or group id on the JSON |
Group roster, tombstone, and admin proposal objects live inside the recipient-user-PGP ciphertext (Chat application composition). SI does not parse them. See CoNET Chat developer guide — Group chat V1 and Security limits.
Implementation anchors
- SI key-ID routing, peel-and-forward, and HTTP :80 forward:
src/CoNET-SI/src/util/localNodeCommand.ts(getEncryptionKeyIDs,getSingleEncryptionKeyId/rejected_multi_recipient_armor, local decrypt then inner-key forward,forwardEncryptedSocket,BandwidthCount) - Mailbox persistence, acknowledgement, presence, and socket health:
src/CoNET-SI/src/util/util.ts - Chat listen command and worker-owned SSE loop:
src/SilentPassUI/vendor/conet-chat-sdk/src/worker/gossip-core.ts; native daemons in chat-sdk-ios / chat-sdk-android (BeamioConetChatListenSession) - Route diagnostic:
scripts/testConetDepinMessage.ts
Next
- How to use Layer Minus explains how applications combine this forwarding path.
- Peel, hop-sig, and listen timeouts is the field lesson for wrap-to-C listen (peel crash, hung SSE,
forward <clientIP>). - SI developer guide and CoNET Chat developer guide have TypeScript samples for
/post, listen, and receipts. - Security limits covers collusion, replay, and threat grades.
- Wallet-addressed peer identity explains the keys used above.
- HTTP transport and Fetch-and-Close explains the wire carrier and short-session option.
- UDP frame forwarding applies the same A/B/C model to encrypted application frames.
- Persistent application streams — historical reference; the exclusive L0 attachments it relies on were removed from SI on 2026-10-31.
- CoNET Chat describes the relationship-private Chat product on this path.