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

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

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)
+---------------------+
| 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: 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 computation
bucket_id = HMAC-SHA256(bucket_secret, lowercase(recipient_addr))[0:4] as u32 BE mod 1024
  • 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.

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.

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

Sealed DM sender address
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.