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

  1. S resolves R's userPublicKeyArmored (the inbox key on R's AddressPGP row).
  2. 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.
  3. S encrypts the complete envelope to R's user OpenPGP key.
  4. 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 with recipient_mailbox_unknown; it does not post the bare armor. Live clients still post the bare armor for ordinary messages.
  5. Optionally, S wraps the result in one or more outer OpenPGP layers addressed to A or to a hop chain. The first /post then shows the outer key ID to a path observer.
  6. 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 as no_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.
  7. 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.
  8. 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 @BeamioTag inside 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

  1. R resolves B's route public key.
  2. R signs a mailbox_listen command and encrypts it to B's route OpenPGP key. The command carries a fresh 32-byte streamKey (base64), generated for every listen and kept only in memory; B refuses a listen without it.
  3. R opens an HTTP/SSE request to healthy entry C.
  4. 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 6 Message.armor() stream / thenable into Buffer.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-only uncaughtException that leaves the SSE open is a protocol bug: the client waits until its ~12s connect_timeout while B is never dialed. Field lesson: Peel, hop-sig, and listen timeouts.
  5. B decrypts the control command, verifies that R belongs to its route, and attaches the SSE response to the appropriate listen pool.
  6. B pushes stored and live business ciphertext through C; only R decrypts the business envelope. Dedicated mailbox_listen sessions are keyed by a connection instance, not by wallet, so every healthy device session for R receives the same encrypted armor.
  7. 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 /post confidentiality 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

results matching ""

    No results matching ""