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

Transport Layer

This chapter specifies how Konstruct messages are carried from a client to the server and (currently) onward to the recipient. It defines the FFI binary envelope (CFE), the on-wire framing, the gRPC service surface, and the VEIL anti-censorship transport tier.

Keywords MUST, MUST NOT, SHOULD, MAY are per RFC 2119.

6.1 Layering overview

                Plaintext (application)
                       │
                       ▼
          ┌─ Konstruct cryptographic core (Rust)
          │     X3DH/PQXDH + Double Ratchet (Ch. 4-5)
          │     padding (PKCS#7 mod 255)
          │     WirePayload pack (§6.2)
          ▼
   AEAD ciphertext frames
          │
          ▼
   CFE binary envelope (§6.3) over UniFFI / JNI / direct C FFI
          │
          ▼
   gRPC bidirectional stream (§6.4) — HTTP/2 over TLS 1.3
          │
          ▼  (optional, when direct TLS is blocked)
   VEIL tier (§6.5) — veil-front  (obfs4 / WebTunnel retired)
          │
          ▼
   TCP / QUIC over the public internet

Layers below Konstruct (TLS 1.3, HTTP/2, TCP, QUIC) are standard and out of scope here.

A QUIC/HTTP-3 direct path is also in production as an alternative to the HTTP/2 path, selected by the client-side transport router with an HTTP/2 fallback. The current client calls it engine-QUIC and gates it through FeatureFlags.engineQuicExperimental, which defaults on; release builds use plain QUIC and force Salamander-style datagram obfuscation off (construct-ios Utilities/Constants.swift:414-:456, Networking/gRPC/GRPCChannelManager.swift:474-:535). The H3 path is implemented in construct-engine/src/transport/mod.rs:50-:113 and src/transport/connection.rs:60-:107; it is not yet specified normatively in this chapter (planned for a future revision).

6.2 Wire format (WirePayload)

Every encrypted Konstruct message crosses the wire as a WirePayload — a packed binary frame with the layout defined in §5.3. Restated for completeness:

FieldTypeSizeDescription
message_numberu32 LE4 BDouble Ratchet sending counter Ns
dh_public_keybytes32 BCurrent sending DH public (DHs.pub)
otpk_idu32 LE4 BOPK id consumed by the X3DH initiator; 0 if N/A
kyber_otpk_idu32 LE4 BML-KEM-768 OPK id consumed; 0 if N/A
kem_lenu16 LE2 BLength of the KEM ciphertext that follows; 0 when absent
prev_chain_lengthu32 LE4 BPrevious-chain length PN
suite_idu16 LE2 B0x0001 Suite 1, 0x0002 Suite 2, or 0x0003 Suite 3
kem_ctbyteskem_len BML-KEM-768 ciphertext for PQXDH first messages (1088 B when present)
suite3_pq_sectionbytesvariablePresent only when suite_id = 0x0003; see §5.3
aead_framebytesvariable`nonce(12)

Total fixed header size: 52 bytes (construct-core/src/wire_payload.rs:22-:36). All numeric fields in the fixed WirePayload header and Suite 3 PQ section are little-endian (construct-core/src/wire_payload.rs:106-:129, :220-:264).

A WirePayload is the unit of work the Double Ratchet produces and consumes. It MUST NOT carry plaintext routing fields outside of suite_id and the lengths needed to parse the envelope; metadata such as sender, recipient, timestamps, and conversation ids belongs in the transport-level wrapper, not in the WirePayload itself.

6.3 CFE — Construct Frame Encoding

When a WirePayload (or any other typed protocol message) crosses the FFI boundary between the Rust core and a platform binding (Swift, Kotlin), it MUST be wrapped in a CFE envelope. CFE eliminates the JSON parsing attack surface previously present at the FFI line.

6.3.1 Envelope layout

CFE envelope (16-byte header + payload) ::=
    magic        : [u8; 2]  = [0x43, 0x46]    -- "CF"
    version      : u8       = 0x01
    msg_type     : u8                          -- CfeMessageType enum tag
    flags        : u8                          -- reserved, MUST be 0
    reserved     : [u8; 3]  = [0x00; 3]
    payload_len  : u32 LE                      -- length of the MessagePack body
    crc32        : u32 LE                      -- CRC-32 over payload only
    payload      : [u8; payload_len]           -- MessagePack body

Header constants are defined in construct-core/src/cfe/envelope.rs: CFE_MAGIC = [0x43, 0x46], CFE_VERSION = 0x01, CFE_HEADER_LEN = 16, SUPPORTED_FLAGS_MASK = 0x00, and MAX_PAYLOAD_LEN = 256 * 1024 (:8-:25). The encoder writes the header in the order above and serialises the payload with rmp_serde::to_vec_named (:48-:65).

6.3.2 Required validations

A receiver of a CFE envelope MUST:

  1. Verify the magic bytes match exactly. Mismatch → reject.
  2. Verify the version is supported (currently only 0x01).
  3. Verify the msg_type byte maps to a known CfeMessageType.
  4. Verify flags == 0; flag constants exist in code, but v1 supports no flags (SUPPORTED_FLAGS_MASK = 0x00).
  5. Verify the three reserved bytes are zero.
  6. Decode payload_len as little-endian and reject values above the implementation cap (reference: 256 KiB).
  7. Verify the buffer contains exactly enough bytes for the declared payload.
  8. Verify crc32 matches recomputed CRC-32 over the payload bytes only (construct-core/src/cfe/envelope.rs:135-:147).
  9. Decode the payload as MessagePack only if all checks above pass.

These checks are what make CFE strictly safer than the JSON predecessor: a malformed envelope is rejected before any deserialisation is attempted, and the bounded payload_len prevents unbounded allocation.

6.3.3 Message types

The msg_type byte selects the MessagePack schema for the payload. The reference defines 14 incoming and 28 outgoing message types covering events such as IncomingMessage, SessionStateChanged, Action::SendEncryptedMessage, etc. The enum is normative; new variants MUST be added with a new value, never by repurposing an existing one.

6.4 gRPC service surface

Konstruct uses gRPC over HTTP/2 over TLS 1.3 as the primary transport. The protobuf service definitions live in the konstruct-msg/construct-protos package (separate repository); the ones relevant to a client implementer are:

ServiceRPCDirection
AuthServiceGetPowChallenge, RegisterDevice, AuthenticateDevice, RefreshTokenunary, no JWT required
UserServiceCheckUsernameAvailabilityunary, no JWT required
UserService(other)unary, JWT required
DeviceService*unary, no JWT required
MessagingServiceMessageStreambidirectional stream, JWT required
MessagingServiceSendSealedMessageunary sealed send, deliberately no JWT required
SignalingServiceSignalbidirectional stream, JWT required
KeyServiceprekey upload / fetchunary, JWT required

JWT-required RPCs MUST carry an authorization: Bearer <token> header and an x-user-id header. The reference adds them in an AuthInterceptor; an interoperable client implementation MUST do the same.

MessageStream carries WirePayload frames (§6.2) as bytes in the request/response stream. The server treats the byte field as opaque and routes by metadata fields outside the WirePayload.

SendSealedMessage carries a SealedSenderEnvelope and deliberately does not extract an authenticated user id; anti-abuse is enforced by per-IP rate limiting, Privacy Pass token redemption, and delivery-tag replay checks (construct-server/messaging-service/src/grpc.rs:701-:750, messaging-service/src/envelope.rs:139-:270). This is the transport entry point that removes the sender identity from the server-visible request for sealed sender (Chapter 8).

For key fetches, new clients MUST set consume_one_time_prekey explicitly. Legacy absence is interpreted as "consume" for wire compatibility, while non-session lookups should set it to false to avoid draining OPK pools (construct-server/shared/proto/services/key_service.proto:80-:103).

6.5 VEIL — anti-censorship transport tier

When the direct gRPC-over-TLS path is blocked, throttled, or fingerprinted by an adversarial network, the client MAY route through VEIL instead. VEIL is a pluggable transport tier with several backend strategies:

BackendStatusWire shape on the network
veil-frontProductionTLS 1.3 to an honest cover application; the relay routes valid AUTH frames to the tunnel and everything else to the cover app via a constant-shape gate. The primary (and only production) obfuscation backend on mobile.
obfs4 / WebTunnelRetiredSuperseded by veil-front and cut by active DPI in the target region. Adapters remain in-tree but are not registered on mobile builds; the standalone relay repository is archived.

The VEIL coordinator (in construct-veil/src/veil/coordinator.rs) runs a happy-eyeballs probe race over the configured backends and keeps per-backend persistent quality scores in a small SQLite store. The winner is dispatched as the data plane; losers are cancelled.

6.5.1 Pluggable transport selection

A client MUST honour:

  • An explicit user preference (VeilMode = .off | .auto | .on).
  • A network-fingerprint hash that namespaces per-backend score caches (so that scores from network A do not pollute scores on network B).
  • A per-backend MethodId (obfs4 = 0, webTunnel = 1, masque = 2, veilFront = 3). Method ids are normative on the Rust C FFI of construct-veil.

6.5.2 Constant-shape gate (veil-front)

For the veil-front backend, the relay MUST satisfy:

  • Failed authentication MUST be routed to the cover application using the cover's own response timing and shape. There MUST NOT be a separate "tunnel rejected" code path with distinguishable timing.
  • Frame-level length bucketing MUST be applied to the tunnel direction so that record-length distributions are bounded.
  • veil_front_ticket_b64 MUST be supplied by the client; an empty ticket field MUST cause the veil-front method to be excluded from the probe race rather than silently downgraded.

The constant-shape requirement is the load-bearing property of veil-front; if a future deployment violates it, the wire becomes distinguishable from the cover application and the construction's purpose is defeated.

6.5.3 Connection ladder and graceful degradation

In VeilMode = .auto (the default) the client is direct-first: it attempts the plain gRPC/QUIC path first and escalates to VEIL only on a real connection failure. It MUST NOT pre-activate a relay purely because of coarse geography — a censored network that is momentarily reachable directly should use the direct path, and a relay is engaged only when the direct attempt actually fails.

The result is a ladder that degrades with the hostility of the network:

Network tierPath used
Free / uncensoredDirect gRPC/QUIC; HTTP/2 fallback.
DPI blacklist (throttle / fingerprint direct TLS)Escalate to veil-front (honest-front TLS to a cover application).
National allowlist (only permitted destinations reachable)Not crossable by obfuscation alone — this is an explicit non-goal of the transport tier.
Blackout (no connectivity)Out of scope for the server-routed transport; an offline-mesh foundation is a separate design track.

This is deliberately honest: obfuscation buys reachability against classification, not against an adversary who drops everything except an allowlist. See Architecture Overview for the tiered model. VEIL also is not a metadata-hiding layer on its own — it helps a connection blend in, but a network observer who already sees the connection can still infer timing and volume; sealed sender (Chapter 8) and padding (§5.7) are the metadata mechanisms, not VEIL.

6.6 Transport guarantees and non-guarantees

What the transport layer guarantees

PropertyMechanism
Tamper detection on the FFI lineCFE CRC-32 + magic bytes
Bounded FFI input sizeCFE payload_len cap
Server cannot read message contentCryptographic core (Ch. 5), not the transport
Length privacy from a network observerPKCS#7 padding (§5.7) + VEIL length bucketing
Censorship resistanceVEIL backends (§6.5)
Memory safety at the FFI boundaryCFE owned Vec<u8> (no raw pointers) + bounded length

What the transport layer does not guarantee

ExposureMitigation status
IP visibility to the relay operatorInherent — a relay terminates your connection and sees its source address. Server-side, only a salted hash is retained (Ch. 8 §8.6); to keep the address off the path, route through VEIL, a VPN, or Tor.
Server sees per-connection metadata (timestamps, session durations)Inherent to a client–server design; raw client IPs are not persisted (salted hash only).
Active DPI in a hostile regionveil-front (honest-front TLS) is the production answer; the retired obfs4/WebTunnel backends were cut by active DPI. Even veil-front does not cross a national allowlist (where only explicitly permitted destinations are reachable) — see Architecture Overview.
Sender identity to the serverRemoved on all user traffic — sealed sender is on by default (Ch. 8); the identified path is fail-closed, not a silent downgrade.

6.7 Configuration constants summary

ConstantValueSource
CFE magic[0x43, 0x46]cfe/envelope.rs:8
CFE version0x01cfe/envelope.rs:9
CFE header length16 bytescfe/envelope.rs:10
CFE supported flags0x00 mask; all flags rejectedcfe/envelope.rs:17, :110-:112
CFE max payload256 KiBcfe/envelope.rs:19-:25, :119-:124
WirePayload header length52 bytes (fixed)wire_payload.rs:22-:36
Padding modulus255traffic_protection/padding.rs
VEIL probe timeoutimplementation-defined (reference: a few seconds per backend)construct-veil/src/veil/coordinator.rs

6.8 References

  • WirePayload: construct-core/src/wire_payload.rs
  • CFE envelope: construct-core/src/cfe/envelope.rs, cfe/types.rs
  • gRPC service definitions: konstruct-msg/construct-protos
  • VEIL coordinator and FFI: konstruct-msg/construct-veil