Sealed sender
Sealed sender ensures that a relay carrying a message cannot learn who sent it: the sender’s identity travels inside an encrypted, server-signed certificate that only the recipient can open. The relay routes on an opaque recipient identifier (v2) or on a bucket id (v3). The metadata that remains observable — and who exactly it is hidden from — is enumerated in What the relay sees; what bucket routing is and is not worth at this deployment’s size is in What bucket routing does and does not give you.
Sources: the normative wire spec in the zentalk-web repository (docs/SEALED_SENDER_WIRE_FORMAT.md) and zentalk/src/lib/relay/sealed-envelope.ts (client).
This page describes the relay path only. Sender sealing on the mesh-storage path is a separate trust boundary with its own design and its own wire version — implemented, and shipped switched off. See DM sender-address sealing.
Scope and non-goals
Section titled “Scope and non-goals”The goal is per-envelope: a relay given a single intercepted envelope must not be able to determine which user sent it. Out of scope: cover traffic and anonymous credentials (later phases), and network-level observers correlating IP addresses.
API endpoints
Section titled “API endpoints”| Endpoint | Method | Purpose |
|---|---|---|
/api/v1/relay/sender-cert |
POST | Issue a sender certificate (authenticated) |
/api/v1/relay/sealed |
POST | Relay a sealed-sender v1/v2 envelope (legacy) |
/api/v1/relay/sealed-v3 |
POST | Relay a bucket-routed v3 envelope (202 Accepted); gated by ZENTALK_SEALED_SENDER_V3_ENABLED |
/api/v1/sealed-sender/bucket-secret |
GET | Fetch the daily bucket secret (authenticated, v3 only) |
Client feature flags: NEXT_PUBLIC_SEALED_SENDER_ENABLED and NEXT_PUBLIC_SEALED_SENDER_V3_ENABLED. The mesh-storage path has its own, separate flag — NEXT_PUBLIC_SEALED_MAILBOX_ENABLED, also false by default; see DM sender-address sealing. See Authentication for how these requests are authorized and the REST API for full request schemas.
Sender certificates
Section titled “Sender certificates”Certificate issuance proves to the recipient that the sender is a valid Zentalk user, without revealing the sender’s identity to the relay. The cleartext certificate (visible only to the recipient after unsealing) is a JSON record:
{ "sender_addr": "0x…", "device_id": "uuid-v4", "cert_expiry": 1729200000, "issuer": "zentalk-api", "server_sig": "base64…"}server_sigis an Ed25519 signature over the canonical JSON of the other four fields (keys sorted lexicographically, no whitespace), made with the server’s sender-cert signing key.- Certificate TTL is 1 hour; issuance is rate-limited to 10 per user per rolling hour. Clients cache and refresh certificates before expiry.
- Recipient validation order: (1)
cert_expiryin the future, (2) Ed25519 signature verifies against the pinned issuer key, (3) only then trustsender_addr.
The short TTL limits the exposure from a compromised relay or stolen certificate to at most one hour (threat model adversary A4).
Envelope v2 (type 0x0190)
Section titled “Envelope v2 (type 0x0190)”+---------------------+| Magic ZTAL (4) || Version 0x0200 (2) || Type 0x0190 (2) || Length uint32 BE || Flags 0x0001 (2) || Message ID (16) || Reserved 0x00 (2) |+---------------------+ 32-byte outer header| recipient_id (32) | address hash — v2 only (see v3 below)+---------------------+| cert_len u16 BE || sender_cert_enc | X25519-sealed certificate (≤ 1024 bytes)+---------------------+| pl_len u32 BE || payload_enc | wrapped E2EE ciphertext (≤ 1 MiB)+---------------------+| mac (32) | HMAC-SHA256 over everything above+---------------------+All integers are big-endian. Length declares the byte count of everything after the 32-byte outer header (32 + 2 + cert_len + 4 + pl_len + 32); the relay validates it against the actual body length without decrypting anything.
Sealing crypto
Section titled “Sealing crypto”ephemeral_priv, ephemeral_pub = X25519.generate()shared = X25519(ephemeral_priv, recipient_pub)okm = HKDF-SHA256(shared, salt = "zentalk-sealed-sender-v1" (24-byte ASCII), info = "zentalk/sealed-sender/v1", L = 64)enc_key = okm[0:32] // AES-256-GCMmac_key = okm[32:64] // HMAC-SHA256- Certificate:
sender_cert_enc = ephemeral_pub(32) || nonce(12) || AES-256-GCM(enc_key, nonce, cert_json, aad="cert"). - Payload: the chat content is already Double Ratchet ciphertext; sealed sender wraps it once more —
payload_enc = nonce(12) || AES-256-GCM(enc_key, nonce, e2ee_ciphertext, aad="payload")— with an independent random nonce, so a passive relay cannot match identical ciphertext bytes across messages. - MAC:
HMAC-SHA256(mac_key, header || recipient_id || cert_len || sender_cert_enc || pl_len || payload_enc), verified by the recipient in constant time after ECDH (the relay cannot verify it — it has no key).
Envelope v3 (type 0x0191) — bucket routing
Section titled “Envelope v3 (type 0x0191) — bucket routing”v3 replaces the routable per-recipient hash with a coarse bucket. Two things change against v2, and the type byte becomes 0x0191:
- A 2-byte
bucket_idis inserted at offset 32, between the outer header and the 32-byte slot. It is not part of the MAC input — it is a routing hint, not authenticated content, so a relay can re-bucket an envelope without invalidating the inner MAC. - The 32-byte slot at offset 34 is no longer
recipient_id. It carriesbind_tag = randomBytes(32), fresh per envelope.
Everything from cert_len onwards is byte-for-byte the v2 body, and the MAC input keeps its v2 shape with bind_tag sitting where recipient_id sat.
bucket_id = HMAC-SHA256(bucket_secret, lowercase(recipient_addr))[0:4] as u32 BE mod bucket_countbind_tag = randomBytes(32) // per envelope, never derived from the recipientbucket_count is a compile-time constant shared by client and server; clients read the value in force from the buckets field of the bucket-secret response. It cannot be changed without a coordinated wire change on both sides — see What bucket routing does and does not give you.
Bucket secret
Section titled “Bucket secret”- Fetched from
GET /api/v1/sealed-sender/bucket-secret, returning{ secret, day, buckets, version }. - Rotates at UTC midnight; clients refresh on day rollover or on server 404/410.
- Cached in memory only — never persisted. A leaked disk cache would allow an attacker to compute the bucket for any known address for the rest of the day.
What bucket routing does and does not give you
Section titled “What bucket routing does and does not give you”Bucket routing replaces a per-recipient tag with a shared one. Whether that produces an anonymity set depends entirely on how many recipients actually share a bucket, and that number is not a tunable: expected occupancy is the user population divided by the compile-time bucket count, so it grows only linearly with the population and cannot be adjusted without a wire change on both sides.
The constant was sized for a network far larger than this one. At closed-Beta population, expected occupancy is a fraction of one recipient per bucket — most buckets hold one member or none, and a bucket id is then close to a per-recipient tag under another name. Occupancy reaches order one only in the low thousands of users; an anonymity set in the low tens needs a population in the tens of thousands.
Two facts bound what this means in practice, and they point in opposite directions:
- v3 is off by default — in the server, in the client, and in the production compose files (the development compose turns it on). No sealed-sender traffic is bucket-routed in a default production deployment today.
- The same bucket partition is reused elsewhere, and that path is not gated on the v3 flag. Mesh-only delivery uses a content-free “new message” wakeup on the WebSocket that is addressed to the recipient’s bucket (
subscribe_bucketin WebSocket events) and is deliberately independent ofZENTALK_SEALED_SENDER_V3_ENABLED. It carries no sender, no recipient address, and no ciphertext — but the thin-occupancy property above already applies to it. Read “v3 is off” as bounding the sealed-sender path only, not as an all-clear for bucket occupancy.
What the relay sees
Section titled “What the relay sees”“Relay” here means an observer of the envelope bytes. The table describes what the envelope leaks, not what the service terminating the HTTP request knows.
| Observation | v2 | v3 |
|---|---|---|
| Sender address in the envelope | No | No |
| Recipient address | 32-byte unsalted hash | Not carried directly: the 32-byte slot is per-envelope randomness and routing uses a 2-byte bucket id. At current population a bucket usually identifies at most one recipient, and the operator can resolve membership regardless — see above |
| Payload size | Exact | Exact |
| Identical-message byte patterns | No (random nonce wrap) | No |
| Timestamp | Exact | Exact |
Two limits worth stating plainly, because they are easy to over-read:
- The terminating server still knows who sent the envelope.
POST /api/v1/relay/sealedandPOST /api/v1/relay/sealed-v3both require and validate a Bearer access token before accepting the body. Sealed sender holds against a relay that only forwards bytes; it does not hide the sender from the service that authenticates the request. Anonymous credentials, which would close this, are a later phase and are not implemented. - Payload size is not padded on the sealed-sender path. Padding helpers exist on both sides but are not wired to it, so
payload_encis12 + plaintext + 16bytes and its length still separates real traffic from cover traffic.
Relay logging policy is correspondingly restricted: INFO logs carry only the event type; DEBUG logs at most a recipient-id prefix and a size bucket; the cert-issuance handler never logs the certificate or wallet address.
DM sender-address sealing
Section titled “DM sender-address sealing”Today: mesh storage nodes see the sender address of a direct message in plaintext. The countermeasure — DM Sealed Mailbox v1, wire discriminator 0x0201, internally “ssm1” — is implemented across node, API and client and ships behind NEXT_PUBLIC_SEALED_MAILBOX_ENABLED, whose default is false. Read the rest of this section as “what changes when it is switched on”, not as a description of the running deployment.
This is a different trust boundary from the relay envelope described above, and the relay envelope does not cover it: sealed-envelope.ts protects the sender against a relay forwarding bytes, not against a mesh storage node holding a stored message. Turning on sealed sender v3 does not change the mesh path.
(A much earlier version of this page described a different, since-deleted scheme in zentalk/src/services/e2ee/sealed-sender.ts. That module was never imported by anything in any commit, so it never ran for any user; it was removed on 2026-08-12. It is unrelated to the design below.)
What ssm1 changes
Section titled “What ssm1 changes”- The mesh row of a DM carries the existing v2 envelope body as its
encrypted_payloadand an emptysender_addr. The node authorizes the write with a short-lived anonymous write token (typ: zt-anon-write, claims exactly{jti, exp, scope:"mesh_write", ver:1}— noiat, no subject) minted byPOST /api/v1/auth/anon-write-tokens. The node accepts that token for message store and for nothing else, and rejects it everywhere an identity is required. - The sender’s identity is in a certificate inside the sealed envelope, signed by the sender’s own Ed25519 E2EE identity key and checked against the recipient’s TOFU pin. Unlike the relay path above, no server issues this certificate — there is no
server_sigand no cert-signing key to steal, and it works for phone accounts that have no wallet signer. - Sealing happens only in
mesh-onlystorage mode and only towards a peer whosessm1capability arrived inside a key bundle whose own Ed25519 envelope signature verified during that fetch. - Once a peer has advertised
ssm1, a DM to them is sealed or not delivered — there is deliberately no fallback to the sender-disclosing path, because otherwise an attacker who makes every node refuse writes would be choosing the user’s privacy level.
What it does not change
Section titled “What it does not change”A node still sees, for every stored DM, the recipient mailbox (zh1_…), the timestamp, the exact size and the source IP of the write. Every replica of the row sees the same. The API sees the account at token-mint time, and continues to learn a coarse recipient bucket (1 in 1024) from the WebSocket nudge that follows a store. Hiding the recipient as well would require pair mailboxes, which are a v2 item with no date. This is Signal’s level of sender protection — not anonymity.
Group and channel traffic is unaffected: those requests still carry an owner-signed membership token naming the member’s address and role in cleartext. Message content remains end-to-end encrypted throughout and is not readable by any node in any configuration.
The residual is tracked in the project threat model as adversary A3 (malicious validator / mesh peer). Normative wire format: §11 of docs/SEALED_SENDER_WIRE_FORMAT.md in the zentalk-web repository; design and migration order: docs/architecture/ADR-DM-SEALED-MAILBOX-V1.md.
Related pages
Section titled “Related pages”- Security overview — the full metadata table
- Double Ratchet — the inner E2EE layer that sealed sender wraps
