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_serverplus an opaquesealed_innerblob and itsforwarding_token. It routes by destination domain and forwards the blob without parsing it. - Destination server: parses
SealedInner. It learns the recipient, thedelivery_tag(for replay suppression), optional Privacy Pass token fields, optionaltoken_spend_id, and the opaqueencrypted_payloadsize. For ordinary sealed traffic it does not learn the message kind; it can see only the deprecated/structuralcontent_typeexceptions if present. It does not learn the sender: the sender identity lives only insidesender_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):
- 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. - KT lever (
.kt): the certificate'ssender_identity_keymatches the recipient's locally stored, key-transparency-verifiedknownIdentityKeyfor that contact. No dependency on any fetched server key. - Signature lever (
.signature): the core's verdict on the certificate isVouched. 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_INITandEND_SESSIONare gone from the protocol. With all user traffic sealed, directed control messages were the primary remaining cleartextsender → recipientsignal, so the error is sealed too, with a fixed 191-byte box (clientMessagingServiceClient.swiftsendDecryptionError). 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-iosServices/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.
| Metadata | Visible? | Note |
|---|---|---|
| Message content | No | End-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 message | No (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 + timing | Yes | Required to deliver; SealedInner.recipient_user_id. |
Message kind (content_type) | No for ordinary sealed traffic | Normal 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, volume | Yes | Padding buckets blunt but do not erase this. |
| Whether a post-quantum exchange is in flight | Yes, from frame size | The 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 size | A 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 graph | Yes | Contact relationships are stored to route streams. |
| Connection IP (live) | Yes | At connection time; only a salted hash is retained (§8.6). |
sender_id → recipient_id edge | Not from one sealed message | May 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.