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.
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. 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”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.
Related pages
Section titled “Related pages”- Security overview — the full metadata table
- Double Ratchet — the inner E2EE layer that sealed sender wraps
