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; the metadata that remains observable is enumerated in What the relay sees.
Sources: the normative wire spec in the zentalk-web repository (docs/SEALED_SENDER_WIRE_FORMAT.md), zentalk/src/lib/relay/sealed-envelope.ts (client), zentalk/src/services/e2ee/sealed-sender.ts (DM 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 (both opt-in, default false; the server side is gated independently). 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)+---------------------+| 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: the wire format is identical to v2 except a 2-byte bucket_id is inserted between the outer header and recipient_id, and the type byte changes to 0x0191. The bucket_id is not part of the MAC input (it is a routing hint, not authenticated content).
bucket_id = HMAC-SHA256(bucket_secret, lowercase(recipient_addr))[0:4] as u32 BE mod 1024Bucket 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.
With 1,024 buckets, each bucket aggregates many recipients, so the relay learns only a k-anonymity set rather than a per-recipient routing tag.
What the relay sees
Section titled “What the relay sees”| Observation | v2 | v3 |
|---|---|---|
| Sender address | No | No |
| Recipient address | 32-byte hash | 2-byte bucket (k ≥ 128 target) |
| Payload size | Padding bucket | Padding bucket |
| Identical-message byte patterns | No (random nonce wrap) | No |
| Timestamp | Exact | Coarser timing planned |
Relay logging policy is correspondingly restricted: INFO logs carry only the event type; DEBUG logs at most a recipient-id prefix and a padded size bucket; the cert-issuance handler never logs the certificate or wallet address.
DM sender-address sealing
Section titled “DM sender-address sealing”Independent of relay envelopes, mesh direct messages seal the plaintext sender_address field so mesh storage nodes cannot read it (zentalk/src/services/e2ee/sealed-sender.ts):
base64( ephemeral_pub(32) || iv(12) || AES-256-GCM ciphertext+tag )Derivation: ephemeral X25519 ECDH against the recipient’s identity DH key, then HKDF-SHA256(shared, salt = 32 zero bytes, info = "zentalk.sealed-sender.v1") to an AES-256-GCM key. The message ID is bound as AAD, so a sealed sender blob cannot be transplanted onto a different message.
Related pages
Section titled “Related pages”- Security overview — the full metadata table
- Double Ratchet — the inner E2EE layer that sealed sender wraps
