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 and is not implemented — 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. 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.

Not implemented. Mesh storage nodes see the sender address of a direct message in plaintext.

An earlier version of this page described a scheme that sealed the sender_address field of mesh direct messages against storage nodes. That description was wrong. The module it referred to (zentalk/src/services/e2ee/sealed-sender.ts) was never imported by anything — not by the service worker, not by the e2ee barrel, and not in any commit of the repository’s history — so the sealing never ran for any user. The file was deleted on 2026-08-12. It was dead code, never a control.

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.

The exposure is tracked as an accepted residual risk in the project threat model (adversary A3, malicious validator / mesh peer). In short: a mesh storage node can see which address sent a stored direct message, and for group and channel traffic it additionally receives 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.