Skip to content

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.

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.

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.

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 certificate (cleartext)
{
"sender_addr": "0x…",
"device_id": "uuid-v4",
"cert_expiry": 1729200000,
"issuer": "zentalk-api",
"server_sig": "base64…"
}
  • server_sig is 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_expiry in the future, (2) Ed25519 signature verifies against the pinned issuer key, (3) only then trust sender_addr.

The short TTL limits the exposure from a compromised relay or stolen certificate to at most one hour (threat model adversary A4).

Sealed envelope v2
+---------------------+
| 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.

Key derivation
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-GCM
mac_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:

  1. A 2-byte bucket_id is 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.
  2. The 32-byte slot at offset 34 is no longer recipient_id. It carries bind_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 computation
bucket_id = HMAC-SHA256(bucket_secret, lowercase(recipient_addr))[0:4] as u32 BE mod bucket_count
bind_tag = randomBytes(32) // per envelope, never derived from the recipient

bucket_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.

  • 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_bucket in WebSocket events) and is deliberately independent of ZENTALK_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.

“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/sealed and POST /api/v1/relay/sealed-v3 both 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_enc is 12 + plaintext + 16 bytes 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.

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.)

  • The mesh row of a DM carries the existing v2 envelope body as its encrypted_payload and an empty sender_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} — no iat, no subject) minted by POST /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_sig and no cert-signing key to steal, and it works for phone accounts that have no wallet signer.
  • Sealing happens only in mesh-only storage mode and only towards a peer whose ssm1 capability 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.

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.