Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Metadata Privacy & Sealed Sender

End-to-end encryption hides what is said. This chapter specifies how Konstruct also reduces who says it to whom that a server can observe — and, just as importantly, states honestly what it does not hide. It builds on the Threat Model (server adversary) and the Message Encryption chapter.

8.1 Goal and non-goals

Goal. For all ordinary user traffic, remove the sender's identity from what any server stores or must read to route a message. A server should be able to deliver a message to its recipient without learning who sent it.

Non-goals. This is metadata minimisation, not network-layer unlinkability. Konstruct does not run a mixnet and does not claim to defeat an adversary who can watch both legs of a connection and correlate by timing and volume. See Architecture Overview for that boundary. The honest residual-metadata list is in §8.7 below.

8.2 The sealed envelope

The primary current stealth path for ordinary sealed sends is a SendSealedMessageRequest containing only a SealedSenderEnvelope plus an optional attempt id. There is no outer Envelope, no sender, no conversation_id, and no server-visible content type on that RPC (construct-server/shared/proto/services/messaging_service.proto:25-:30, :281-:289). A legacy authenticated Envelope.sealed_sender path exists for transitional session-control traffic; it still masks sender, conversation id, and real content type on the outer envelope, then enters the same dispatch_sealed_sender server path. Unauthenticated SendSealedMessage is the sender-hiding transport boundary for ordinary sealed sends.

The sealed structures are defined normatively in shared/proto/core/envelope.proto (message SealedSenderEnvelope, envelope.proto:365; SealedInner, envelope.proto:390).

SealedSenderEnvelope {          // visible to the home/entry server
  recipient_server : string     // federation destination domain (S2S routing)
  sealed_inner     : bytes      // opaque — the home server MUST NOT parse it
  forwarding_token : bytes      // HMAC-SHA256(server_secret, sealed_inner || timestamp)
  timestamp        : int64      // freshness; reject if > 5 min old
}

SealedInner {                   // read by the destination server
  recipient_user_id     : string   // needed to route to the recipient
  delivery_tag          : bytes    // random 32 B; server dedups for 24h
  sender_cert_ciphertext: bytes    // SenderCertificate sealed to recipient IK — server MUST NOT decrypt
  encrypted_payload     : bytes    // the Double Ratchet ciphertext (Chapter 5)
  content_type          : ContentType       // DEPRECATED; ignored by server
  priority              : MessagePriority   // DEPRECATED; ignored by server
  ttl                   : uint32            // DEPRECATED; ignored by server
  token_nonce           : bytes    // Privacy Pass (optional, §8.5)
  token_bytes           : bytes
  token_spend_id        : bytes    // optional logical-message spend id
  session_envelope      : bytes    // field 21, construct-core 0.26 — see "Session envelope" below
  first_flight          : bytes    // field 22, construct-core 0.27 — see "First flight" below
}

content_type, priority, and ttl remain in SealedInner only for compatibility. They are server-visible metadata leaks and the server MUST NOT use them for routing, priority, notification text, or UI (construct-server/shared/proto/core/envelope.proto:386-:418).

Session envelope (construct-core 0.26)

Once two devices hold a session, a sealed message carries neither a certificate nor a readable wire payload. It carries session_envelope (construct-core/src/crypto/sealed_sender/envelope.rs):

keys(s→r) = HKDF-SHA256(salt = ∅, ikm = SK,
                        info = "construct-envelope-v1" ‖ 0x00 ‖ s ‖ 0x00 ‖ r, 64)
          = k_env ‖ k_tag                     s, r: sender and recipient device ids
envelope  = nonce(12) ‖ tag(16) ‖ ChaCha20-Poly1305(k_env, nonce, kind ‖ body, ad = tag)
tag       = HMAC-SHA256(k_tag, nonce)[..16]
kind      = 0x01 wire payload | 0x02 DECRYPTION_ERROR

SK is the session's PQXDH v2 root, which includes an ML-KEM-1024 secret. The recipient computes the tag under each key pair it holds — kept in an envelope book beside the sessions, because sessions are restored lazily (sealed_sender/book.rs) — and the pair that matches names the writer. The tag is a PRF output on a random nonce, so it links nothing; there is no counter to keep in step. When session_envelope is set, sender_cert_ciphertext and encrypted_payload are empty, and the outer type is generic even for a DECRYPTION_ERROR.

A pair outlives its ratchet state: a local reset or a deleted chat sends nothing, and the writer goes on writing. The pair is retired for 7 days (the queue window), still names the writer, and the DECRYPTION_ERROR goes back along it. Decision: construct-docs decisions/sealed-envelope-keyed-by-the-session.md.

First flight (construct-core 0.27)

Until the peer answers, the recipient has no session to find a tag in, and every message the initiator writes carries the PQXDH v2 header: the prekey ids, the ML-KEM-1024 ciphertext and the initiator's ML-KEM identity key. That key is the same on every first flight a device writes, so in the clear it names the sender (FF-1, resolved). Such a message carries first_flight (construct-core/src/crypto/sealed_sender/first_flight.rs):

first_flight = BE32(kyber_prekey_id) ‖ kem_ciphertext ‖ eph(32) ‖ nonce(12)
               ‖ ChaCha20-Poly1305(k_first, nonce, body, ad = everything before nonce)
body         = BE16(len certificate) ‖ certificate ‖ wire payload without kem_ciphertext
ff_key       = HKDF-SHA256(salt = ∅, ikm = SS_mlkem, info = "construct-first-flight-v1")
k_first      = HKDF-SHA256(salt = "ConstructSEALED-first-v1",
                           ikm  = X25519(eph, IK_recipient) ‖ ff_key,
                           info = eph ‖ SHA-256(kem_ciphertext))

Outside stay only the recipient's Kyber prekey id and the ciphertext to it, which the recipient needs to derive the key and which name only the recipient. The handshake's encapsulation keys both the box and the session root, under different labels. The box costs six bytes over the certificate and payload it carries. ff_key is kept beside the session's envelope pair: the initiator seals with it until answered, and the responder opens the later first flights with it (their one-time Kyber prekey was burned by the first) until the initiator proves itself. When first_flight is set, sender_cert_ciphertext, encrypted_payload and session_envelope are empty. Decision: construct-docs decisions/first-flight-sealed-whole.md.

A first flight to a Kyber prekey the recipient no longer holds could not be opened, its writer would not be known, and no DECRYPTION_ERROR would answer it. The timing rules out that case: a signed prekey is kept 14 days after rotation, the queue holds a message 7, and an initiator does not write on a handshake left unanswered for 7 days — it opens a new one (Chapter 3 §3.5, construct-core 0.27.1).

What stays classical. A DECRYPTION_ERROR about a first message the recipient could not read goes in an X25519 box to the writer's identity key (sealed_sender/mod.rs:72, seal_to_x25519_public), and device metadata goes to siblings the same way. Tracked as PQC-1 (Chapter 7 §7.3). New normal sealed sends leave content_type at UNSPECIFIED = 0, which proto3 omits from the wire. The real application content type rides inside the encrypted payload, as byte 5 of the KNST frame (Chapter 5 §5.8.4), and since construct-core 0.29–0.30 the core classifies a decrypted message by that byte, not by the envelope. The client constrains this boundary with SealedEnvelopeType: .generic serialises to no content type, and the one structural exception is DECRYPTION_ERROR (28): it answers a message the ratchet could not read, so it cannot be encrypted with that ratchet. Until 2026-09-27/28 the exceptions were SESSION_RESET (21) and SESSION_RESET_INIT (24); nothing sends either now. (The iOS enum still carries a .sessionResetInit case with no sender; it is scheduled for removal.) (construct-ios Services/Messaging/ContentTypeRouting.swift:39-:82, :171-:197; Security/StealthSenderService.swift:393-:415).

What each party sees:

  • Home / entry server: recipient_server plus an opaque sealed_inner blob and its forwarding_token. It routes by destination domain and forwards the blob without parsing it.
  • Destination server: parses SealedInner. It learns the recipient, the delivery_tag (for replay suppression), optional Privacy Pass token fields, optional token_spend_id, and the opaque encrypted_payload size. For ordinary sealed traffic it does not learn the message kind; it can see only the deprecated/structural content_type exceptions if present. It does not learn the sender: the sender identity lives only inside sender_cert_ciphertext, which is encrypted to the recipient's identity key, and which the server "MUST NOT attempt to decrypt" (envelope.proto:398-:404).

On the single-server deployment shipping today the home and destination roles are the same process, so it sees the SealedInner fields above — but still never the sender.

On the client, the current no-outer-envelope path is construct-ios Networking/gRPC/Services/MessagingServiceClient.swift (SendSealedMessage) plus Security/StealthSenderService.swift (buildSealedInner). Server-side routing is construct-server/messaging-service/src/envelope.rs:139-:270: the federation hop forwards sealed_inner opaquely, while local delivery decodes only recipient_user_id, token fields, and delivery_tag.

8.3 Sender certificate and recipient verification

The recipient learns and verifies the sender from the SenderCertificate (envelope.proto:425) inside sender_cert_ciphertext:

SenderCertificate {
  sender_user_id      : string
  sender_domain       : string   // home server, for public-key lookup
  sender_identity_key : bytes    // X25519 (32 B) — must match the session
  sender_device_id    : string
  issued_at           : int64
  expires_at          : int64    // typically issued_at + 24h
  server_signature    : bytes    // Ed25519 by the sender's home server
  server_kid          : bytes    // field 8 (core 0.32): id of a delegated hybrid key; empty if none
  server_signature_hybrid : bytes // field 9 (core 0.32): Ed25519 ‖ ML-DSA-65 (3373 B) by that key
}

The certificate is issued and signed by the sender's home server (identity-service). The Ed25519 server_signature is made with the key published at the server's /.well-known document and covers the direct big-endian concatenation sender_user_id || sender_domain || sender_identity_key || sender_device_id || issued_at || expires_at, with no label and no length prefixes (build_cert_sign_payload, construct-core/src/crypto/sealed_sender/mod.rs:151). The hybrid signature, when present, is made by a key delegated from an offline root and covers "konstruct/v1/sender-cert" ‖ kid ‖ body with every field length-prefixed, and a certificate it signs may claim at most a day of lifetime (Chapter 3 §3.11).

The core judges the certificate (since construct-core 0.32.0; SenderCertificate::identity_for_opening and ::verdict, sealed_sender/mod.rs:299-:417; UDL certificate_verdict). With a root pinned and the hybrid signature present, the hybrid signature decides alone; otherwise the Ed25519 signature decides against the keys the platform handed to the core. No root is pinned yet (PINNED_ROOTS is empty, server_trust.rs:50), so today every certificate is judged by Ed25519 against the server's published key and the keys pinned in the app. The verdict is Vouched, NoKey (no key to check with yet — transient), BadSignature (also: the key does not derive to the named device) or Expired (signed and naming its device, but expired longer ago than a message may wait at the relay); see Appendix A §A.14 for each verdict's cause and the client's part. Until 2026-10-03 each client also checked the Ed25519 signature itself, with different age and device rules from the core's; that second check is gone.

The recipient attests the sender through one of three levers (SenderVouchBasis in construct-ios Security/StealthSenderService.swift):

  1. Session lever (.session): the message came in a session envelope, and the session pair whose tag matched names the writer. Post-quantum; no server key involved.
  2. KT lever (.kt): the certificate's sender_identity_key matches the recipient's locally stored, key-transparency-verified knownIdentityKey for that contact. No dependency on any fetched server key.
  3. Signature lever (.signature): the core's verdict on the certificate is Vouched. Used on first contact, before a local KT key exists.

Delivery on an existing session is not gated on attestation. A message that opens a session is: it opens only from a certificate that passes identity_for_opening (Chapter 4 §4.4.2), and with no key to check it yet it waits rather than opens. On a session already held, if no lever vouches (missing or stale server key, expired cert), the message is still delivered and logged as UNVOUCHED with a reason — the Double Ratchet is the real authenticator, and an unvouched attestation triggers a bundle-key refresh so the next message re-vouches (MessageRouter.routeIncomingMessage, construct-ios). This is a deliberate availability-over-strictness choice: a key rotation must not silently drop mail.

8.4 Always-on scope, and the fail-closed invariant

Sealed sending is on by default in release builds — there is no user toggle to turn it off (construct-ios Services/StealthPolicy.swift:43 isEnabled, :72 shouldUseSealedSender). DEBUG builds keep a developer override for exercising the legacy identified path.

Invariant (fail-closed): while stealth is on, every in-scope application send is sealed or queued — it is never emitted identified. Identified sends are legal only when shouldUseSealedSender() is false or when the traffic class is deliberately excluded below. When sealing is temporarily impossible (recipient identity key not yet known, sender certificate unfetchable), the send does not fall back to an identified envelope; it throws StealthDowngradeBlocked and the message is held for a later retry (StealthSendRecovery.swift, ChunkedMessageDelivery.swift). This closes a server-influence deanonymisation vector: a server that could force identified sends by failing sealed ones could deanonymise on demand.

Traffic in scope (sealed):

  • User messages (text, media, voice, files, replies) and edits.
  • End-to-end delivery receipts — otherwise sender↔recipient timing correlation leaks even when bodies are sealed.
  • Call signalling (SDP / ICE — see Transport).
  • Session control: since 2026-09-28 the only session-control message is the DECRYPTION_ERROR (Chapter 5 §5.10) — the handshake pings, session_ready, SESSION_RESET_INIT and END_SESSION are gone from the protocol. With all user traffic sealed, directed control messages were the primary remaining cleartext sender → recipient signal, so the error is sealed too, with a fixed 191-byte box (client MessagingServiceClient.swift sendDecryptionError). It still travels on the authenticated path, so the relay sees the sending account.

Traffic deliberately excluded (identified, by decision — the leak is low-value or the frequency/cost is high):

  • End-to-end heartbeats (content_type = 13). Their real type is now inside the encrypted KNST frame rather than in a server-visible heartbeat content type, but they still use the identified send path (construct-ios Services/Messaging/OutboundSessionService.swift:187-:233).
  • Multi-device internal sync (a user talking to their own devices).

8.5 Anti-abuse: Privacy Pass tokens

Removing the sender identity also removes the server's usual per-sender abuse lever. Konstruct restores one with Privacy Pass anonymous tokens: a sealed send MAY carry token_nonce + token_bytes and may carry token_spend_id for multi-envelope logical messages (envelope.proto:420-:444), a blind-signed single-use credential the sender spends from a local wallet. The token is itself sealed to the destination server's X25519 key so relay operators cannot read the spent token. The full construction — the VOPRF, issuance/redemption, and the verifiable-issuance DLEQ proof — is specified in Chapter 9.

Deployment status. Token enforcement is governed by MSG_STEALTH_TOKEN_POLICY, currently warn in production: tokens are issued, attached, and redemption-validated, but an absent or failed token does not block delivery — anti-abuse is degraded, anonymity is intact. enforce is deferred past 1.0 (see Implementation Status). Issuance is rate-limited with an age-tiered per-account cap.

Verifiable issuance (planned/partial). So that a malicious issuer cannot tag individual users via a per-user signing key, the issuer proves in zero knowledge that every token was signed with the same published key (batched Chaum–Pedersen DLEQ). The server half is implemented (construct-crypto/src/privacy_pass.rs) and the client pins the issuer key and verifies the proof; treat end-to-end verifiable-VOPRF as in progress until enforcement relies on it. Until then, sender-unlinkability against a malicious (as opposed to honest-but-curious) server is not claimed.

8.6 Connection-layer metadata: IP minimisation

Sealed sender hides application-layer identity; the network layer still exposes the connection's source IP address, which is unavoidable for packet routing. Konstruct minimises what is retained: the server never stores a raw client IP. Anti-abuse rate-limit keys and logs use a salted one-way hash of the address (construct-utils/src/lib.rs:92 hash_client_ip, applied in construct-user-service/src/account.rs:140 and construct-auth-service/src/devices.rs:292). Per-address anti-abuse granularity is preserved (same address → same tag); the raw address is not written to databases, rate-limit state, or logs, and no raw IP-to-account mapping is kept.

Honest limit: a salted hash of the small IPv4 address space is not perfectly anonymous against a party holding the salt — it removes the raw address from storage, it does not make the network origin undiscoverable. To keep the address off the path entirely, route through VEIL (Transport), a VPN, or Tor; the terminating endpoint still sees the apparent source address of the connection it accepts.

8.7 Residual metadata — what a server still sees

Even with sealed sender always on, a compromised destination server can still observe the following. This is the honest counterpart to §8.1.

MetadataVisible?Note
Message contentNoEnd-to-end encrypted; key material is not on the server.
Sender identity (per message)No (sealed)After first contact, only the recipient's session pair names it (session envelope).
Sender of a session's first messageNo (sealed whole, construct-core 0.27)The certificate and the handshake header are inside the first-flight box. The Android client still fetches bundles over an authenticated call, so the Kyber prekey id left outside can tie its first contacts to the account (BF-1). The bundle fetch and the send share a connection address — the network-adversary non-goal.
Recipient identity + timingYesRequired to deliver; SealedInner.recipient_user_id.
Message kind (content_type)No for ordinary sealed trafficNormal sealed traffic leaves the field absent; the one structural exception is a DECRYPTION_ERROR (28) outside a session envelope (§8.2). The kind is byte 5 of the encrypted KNST frame. Until 2026-10-16 this row named 21 and 24, which nothing sends since 2026-09-28.
Ciphertext size after padding, volumeYesPadding buckets blunt but do not erase this.
Whether a post-quantum exchange is in flightYes, from frame sizeThe PQ section of the WirePayload header sits outside the plaintext padding (PKCS#7 to a multiple of 255 B, Chapter 5 §5.7). While an exchange is pending, every outgoing frame, receipts included, carries the encapsulation key (1184 B) or the ciphertext (1088 B), so it is about 1.1 KB longer than its padding bucket would make it. The server cannot read the section; it sees the outer envelope size, so a run of longer frames marks an epoch change. This is rhythm metadata about the conversation. Tracked as PQR-3.
Attachment sizeA Padmé bucket, not the size (construct-core 0.33.0)The media server sees the blob's length, padded to at most 12 % over the file and never under 4 096 bytes (Chapter 2 §2.2.3); before 0.33.0 it saw the size to the byte. A legacy blob uploaded before then still has its exact size.
Contact graphYesContact relationships are stored to route streams.
Connection IP (live)YesAt connection time; only a salted hash is retained (§8.6).
sender_id → recipient_id edgeNot from one sealed messageMay still be inferable from timing/volume correlation — the network-adversary non-goal.

8.8 References

  • Wire structures: shared/proto/core/envelope.proto (SealedSenderEnvelope, SealedInner, SenderCertificate).
  • Server: messaging-service/src/envelope.rs; construct-core/src/crypto/privacy_pass/; construct-utils/src/lib.rs (hash_client_ip).
  • Client (construct-ios): Services/StealthPolicy.swift, Security/StealthSenderService.swift, Networking/gRPC/Services/MessagingServiceClient.swift, Services/Session/SessionCoordinator.swift, Security/StealthSendRecovery.swift.
  • Design rationale (internal): construct-docs decisions/ — stealth-sealed-sender-v2-always-on, sealed-sender-anti-abuse-economics, sealed-sender-session-control-channel, stealth-heartbeat-exclusion, server-influence-minimization, stealth-phase-c-verifiable-voprf.