Introduction
Document status: v0.1.2 — early draft. Implementation reference:
construct-corev0.9.x (see Implementation Status). Document license: CC BY 4.0 · Reference implementation: MIT.
This document is the public technical specification of the Konstruct messenger protocol — what it does, how it does it, and where it provably falls short today.
It is written against the open-source reference implementation. Every
algorithmic claim in this document is verifiable by reading the cited
source file in the konstruct-msg/construct-core repository. If
something in the text does not match the code, the code wins and the
text is a bug — please open a security advisory.
Looking for a single-page read? → View the whole specification as one page (concatenated, searchable with Ctrl+F).
What Konstruct is
A messenger protocol built on the Signal Protocol design — X3DH handshake + Double Ratchet for ongoing messaging — extended with:
- A hybrid post-quantum KEM (ML-KEM-768, NIST FIPS 203) layered alongside the classical X25519 key exchange.
- A pluggable transport layer (VEIL) designed to keep the messenger reachable when the network operator is hostile.
- A binary FFI envelope (CFE) so the Rust crypto core can be shared unchanged across iOS, macOS, Android, and desktop clients without exposing a JSON parsing attack surface.
What this document is not
- Not a security audit. No third-party cryptographic audit has been performed at the time of writing. The text identifies known open issues but does not certify the absence of others.
- Not a marketing site. Public-facing positioning lives at konstruct.cc. This document is for implementers, auditors, and readers who want the protocol at the level of cryptographic detail needed to reason about it or build a compatible client.
- Not a full protocol RFC. Wire format reference, error code registry, and the federation / group-chat protocols are out of scope for v0.1 and will land in later versions.
Conventions
This specification uses RFC 2119 keywords (MUST, MUST NOT, SHOULD, MAY) where it makes a normative requirement on an implementation. Descriptive prose uses ordinary English.
Code references take the form path/to/file.rs:LINE and point at the
public reference implementation
(construct-core).
Honest current status
| Area | Status |
|---|---|
| Cryptographic core (X3DH, Double Ratchet, hybrid PQ KEM) | Implemented and used in production by the iOS TestFlight build. |
| iOS / macOS client | Production-quality code, distributed via TestFlight beta. No public App Store release yet. |
| Android client | Phase 0 — Rust core builds, Kotlin wrapper not yet written. |
| Federation (server-to-server) | Implemented — inbound + outbound sealed delivery, Ed25519-signed. Multi-node interoperability test outstanding. |
| Sealed sender | Implemented and on by default — all outgoing user traffic (messages, receipts, call signalling, session-control handshake) is sealed and leaves no sender_id at rest; identified-downgrade paths are fail-closed. Privacy Pass token enforcement runs in warn mode (not enforce). |
| MLS group chat | Implemented in construct-core but not yet documented at protocol-spec level. To be added in a future revision. |
| QUIC / HTTP-3 transport | In production — plain QUIC (construct-transport), always-on with an HTTP/2 fallback. |
| VEIL veil-front (honest-front transport) | In production — the primary obfuscation transport for censored networks. |
| VEIL obfs4 / WebTunnel (legacy) | Retired — cut by active DPI in the target region; superseded by veil-front, standalone relay archived. |
| External security audit | Planned. Not yet performed. |
Document layout
| Chapter | Audience |
|---|---|
| Threat Model | Who Konstruct protects against, who it doesn't. Read first. |
| Cryptographic Primitives | Exact algorithm choices, key sizes, library versions. |
| Identity & Key Hierarchy | Long-term, medium-term, and per-session keys. |
| Session Handshake | X3DH and the post-quantum extension PQXDH. |
| Message Encryption | Double Ratchet, AEAD framing, associated data. |
| Transport Layer | gRPC over TLS, VEIL anti-censorship, CFE binary envelope. |
| Implementation Status | What works, what's open, what's planned. |
Architecture Overview
Orientation chapter. This is the map of the whole system: how the cryptographic core (Message Encryption) and the Transport Layer fit together, and how Konstruct stays reachable as a network operator turns hostile. It is descriptive architecture — normative (RFC 2119) requirements live in the per-component chapters it points to. For the authoritative per-component build state, see Implementation Status.
Konstruct is a layered system with one invariant at the bottom and a ladder of reachability strategies above it. The invariant: end-to-end encryption is always on and never depends on any server (see Threat Model and Message Encryption). Everything above the crypto core exists to answer a single question — can this message reach its recipient? — as the network becomes more adversarial, degrading gracefully from a fast direct connection down to a device-to-device mesh that needs no network at all.
Design position
Four goals pull against one another. Konstruct takes a deliberate position rather than pretending to maximise all four at once:
| Goal | Position |
|---|---|
| Security (E2EE) | A floor, not a tradeoff. Content confidentiality/integrity is always on and independent of any server. |
| Censorship-resistance | The primary driver. The hardest tier is a national allowlist (only an explicit set of destinations is reachable), which pure obfuscation cannot cross. |
| Usability | Kept high. Entry selection, relay choice, and re-homing are designed to require no user configuration. |
| Anonymity | Metadata minimisation (sealed sender), not network-layer unlinkability. Konstruct deliberately does not run a mixnet. |
The anonymity position is load-bearing enough to state precisely. Konstruct provides pseudonymity with no real-world anchor, not unlinkability against a global observer:
| Provided | Not provided (deliberately out of scope) |
|---|---|
| Identity is a public key — registration is passwordless, with no phone number, email, or mandatory username. There is no personal datum to link an account to a real person, even under full server seizure. | Network-layer unlinkability — who-talks-to-whom against an adversary who can watch both legs of a relay or correlate timing. That is a mixnet's job; a single relay hop hides the user's IP, not the linkage. |
| Sealed sender removes the sender identity, and the server-side social graph, from the delivery path. |
A mixnet (Sphinx packets, per-hop cover traffic) would add network-layer unlinkability at a large cost to usability and — being enumerable — to censorship-resistance itself. For Konstruct's users that tradeoff is not worth it; the sealed-sender ceiling is the accepted metadata boundary.
The layered model
Above the always-on cryptographic floor, the system is five layers plus an offline foundation. Each is a distinct concern with its own implementation; they are composed, not merged.
always-on floor ─── E2EE (Double Ratchet · sealed sender) · identity = public key (no PII)
══════════════════════════════════════════════════════════════════════════════════════
▲ outward path
│ Transport move bytes + obfuscation (QUIC/H3 · veil-front HTTPS · H2 fallback)
│ EntryDirectory find a reachable, un-burned entry point
│ RouteLayer a single proxy hop that hides the user's IP from the server
│ Overlay location-independent addressing & discovery (route_id, DHT)
│ Delivery federation, store-and-forward, trust posture
▼
Mesh floor device-to-device with no network at all (foundation)
| Layer | Role | Where it lives | Status |
|---|---|---|---|
| Transport | Carry bytes; obfuscate them where a censor inspects. Two stacks — QUIC/H3 for speed everywhere, veil-front HTTPS for censored networks — selected by one client-side router. | construct-transport, construct-veil | Both stacks implemented; plain QUIC and veil-front in production use. |
| EntryDirectory | Discover a live entry point the censor has not blocked, and rotate off blocked ones without user action. | client + backend (design) | Designed; not yet implemented. |
| RouteLayer | One proxy hop hiding the user IP from the home server (IP-hiding, not unlinkability). | veil-front relay | The single-hop model is the accepted design; deeper anonymity (mixnet) is explicitly out of scope. |
| Overlay | Address an account by a location-independent identifier so it stays reachable after it moves. | construct-core, backend | Identity key + route_id present; dual-addressing and DHT discovery planned. |
| Delivery | Server-to-server sealed delivery between independent deployments; a seizure-safe relay posture; two domestic nodes forming a self-contained island. | construct-core (federation), relay profiles | Federation implemented; multi-node interoperability test outstanding. |
| Mesh floor | Keep 1:1 messaging alive with no internet and no server, over local radio. | design (see below) | Analysis only; a proof-of-concept spike is the next step. |
route_id is the SHA-256 of the account's identity public key. Because it is
derived from the key and carries no location, an account can move between
servers (or onto a domestic island, or onto the mesh) and remain addressable —
this is what makes zero-configuration re-homing and the offline mesh share one
identity model.
Graceful degradation by network tier
The layers active at any moment depend on how hostile the network is. The same system reconfigures itself down the ladder:
| Layer | Free (no censor) | Moderate–hard DPI (blacklist) | Allowlist island (whitelist) | Blackout (no network) |
|---|---|---|---|---|
| Transport | plain QUIC, direct | veil-front, obfuscated HTTPS | obfuscation optional, in-zone | mesh links (WiFi-Direct / BLE) |
| EntryDirectory | — | discover a foreign entry | discover a domestic entry | — |
| RouteLayer | — | one hop, hide IP | — | — |
| Overlay | route_id | route_id | in-zone (island) DHT | mesh announce, no server |
| Delivery | home server | foreign, federated | domestic island, no foreign egress | store-carry-forward, delay-tolerant |
Two properties of this table matter. First, obfuscation answers "can they tell what this is?", not "can this arrive at all?" — under an allowlist the censor drops a packet by destination before inspecting it, so the answer there is not a better disguise but an in-zone deployment that never needs to cross the border (in-country federation, a domestic-only overlay). Second, veil-front has no transport fallback beneath it; if its relay is blocked, reachability depends on the EntryDirectory layer having diverse entries, and ultimately on the mesh floor. The layers are coupled: finishing the transport is not the same as finishing censorship-resistance.
The offline mesh floor (design)
The lowest rung keeps two people messaging with no infrastructure at all — a total blackout or a fully sealed island. It is a parallel offline mini-stack that supplies its own addressing, routing, and store-carry-forward, and it carries opaque Konstruct end-to-end payloads — it does not replace the message crypto.
The design direction adopts Reticulum (a cryptographic networking stack
that routes over any medium) as the overlay, for two reasons: its
destination addressing is a hash of a public key, which maps directly onto
Konstruct's route_id; and it is medium-agnostic, so "which radio" becomes a
pluggable interface rather than a new routing engine.
| Interface | Range | Bandwidth | Platform reach | Role |
|---|---|---|---|---|
| WiFi-Direct / Wi-Fi Aware (Android); Multipeer/AWDL (iOS) | ~50–100 m | high | same-platform only (the two do not interoperate) | preferred high-bandwidth link |
| BLE | ~10 m | low | iOS + Android | the cross-platform floor |
| LoRa | kilometres | very low (duty-cycle limited) | companion radio hardware | long-range, later |
Message security remains Konstruct's end-to-end layer; a mesh relay learns no more than an online sealed-sender relay does. This rung is analysis-stage: the gating questions are the Rust implementation path and cross-platform local interoperability, to be resolved by a laptop-first spike before any mobile work.
What the architecture does and does not provide
Consistent with Transport §6.6, stated for the whole system:
Provided: content confidentiality and integrity (E2EE, always on); no link between an account and a real-world person; sealed sender removing sender identity and the at-rest social graph from delivery; the user's IP hidden from the home server; the home server's IP hidden from the censor; and continued operation as the network degrades — including, by design, with no network.
Not provided: unlinkability of who-talks-to-whom against an adversary who observes both ends or correlates timing; recipient identity at the delivering node; connection metadata (times, sizes, IPs a relay itself sees). A single relay hop is IP-hiding, not anonymity, and Konstruct does not claim otherwise.
Where the detail lives
| For | Read |
|---|---|
| Who the system protects against | Threat Model |
| The cryptographic floor these layers carry | Message Encryption |
| Normative transport, CFE, and VEIL requirements | Transport Layer |
| The authoritative, per-component build state | Implementation Status |
Threat Model
This chapter defines who Konstruct claims to protect against, who it explicitly does not, and the assumptions on which every later chapter rests. Read it before reading the rest — a security property only means something against a specific adversary class, and most disagreements about "is X secure" reduce to disagreements about which adversary the discussion has in mind.
Adversary classes (in scope)
Network adversary
Capabilities: full passive recording of traffic between any two endpoints; full active control of the network path (drop, inject, modify, reorder, replay); deep-packet inspection and traffic classification; observation across multiple vantage points (e.g. recording at both ISP and at a transit AS).
What Konstruct guarantees against this adversary:
- Plaintext confidentiality — recovered traffic decrypts to nothing more than ciphertext blobs and routing-shaped metadata.
- Tamper detection — any in-flight modification of a Double Ratchet ciphertext is rejected by AEAD.
- Forward secrecy — recording today does not enable decryption later if a long-term key is later compromised.
- Quantum-recording resistance — recording today does not enable decryption later by a quantum-equipped attacker, for sessions that used Suite 2 (PQXDH). Suite 1-only sessions are vulnerable in this scenario.
What Konstruct does not guarantee:
- That a sufficiently sophisticated network adversary cannot infer that communication is happening. The transport layer reduces this surface (VEIL, padding, cover traffic) but does not eliminate it.
Server adversary
Three sub-classes, treated together because the design is the same: honest-but-curious, malicious, fully compromised. Konstruct's server is blind to message content by construction — the same key material the client uses to decrypt simply is not on the server.
Sealed sender is deployed and on by default. Outgoing user traffic —
messages, delivery receipts, call signalling, and the session-control
handshake — is sealed: the client omits the sender_id from the outer
envelope and seals a server-issued sender certificate to the recipient's
identity key, so only the recipient can recover who sent a message. A
compromised server therefore cannot read sender_id from sealed
traffic and cannot directly reconstruct the sender_id → recipient_id
edge for a sealed message.
- Client policy (always-on in release builds):
construct-iosServices/StealthPolicy.swift:42(isEnabled),:71(shouldUseSealedSender). - Envelope masking:
construct-iosNetworking/gRPC/Services/MessagingServiceClient.swift(buildEnvelopeomitssender,conversation_id, and the realcontent_typewhen aSealedInneris present). - Server handling:
messaging-service/src/envelope.rs:38(if envelope.is_sealed_sender { … }— the sender is hidden from the proto).
What the server can still see today (single-trusted-server alpha), even with sealed sender:
- The recipient identifier and the delivery timestamp of each message. (The sender is not in the sealed envelope.)
- The pre-existing contact graph — contact relationships are stored to route message streams.
- Ciphertext size after padding and traffic timing/volume.
- Connection metadata at the network layer: the source IP address
(unavoidable for packet routing), transport handshake characteristics,
and session durations. The server does not store the raw IP — its
anti-abuse rate-limit keys and logs use a salted one-way hash of the
address (
construct-utils/src/lib.rs:92hash_client_ip, applied inconstruct-user-service/src/account.rs:140andconstruct-auth-service/src/devices.rs:292). The 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 origin undiscoverable. - The encrypted payload bytes (it must, in order to route them).
Anti-abuse tokens (Privacy Pass) accompany sealed sends. Server-side token
enforcement (MSG_STEALTH_TOKEN_POLICY) currently runs in warn mode,
not enforce — see Implementation Status.
Historical device adversary
Capability: after a session has been used for some period of time, obtain a snapshot of a participating device's state (key material at rest, session state, message history).
Konstruct guarantees:
- Past message confidentiality up to the moment of compromise (forward secrecy via the Double Ratchet's per-message key derivation and key eviction).
- Self-healing of future messages after at most one round-trip via the DH ratchet step — provided the attacker is no longer active in the network path.
Spam / Sybil adversary
Capability: automated mass registration; commodity GPU farms; disposable IP pools.
Konstruct deters this with a memory-hard proof of work at registration (Argon2id, see Cryptographic Primitives) and server-side rate limits. Full prevention of nation-state-resourced Sybil attacks is not a Konstruct goal — that would require identity verification, which is incompatible with privacy goals elsewhere in the design.
Out of scope (explicit non-goals)
Konstruct does not defend against:
- A live compromise of an unlocked device at the moment of decrypt. If the attacker has the running process, no end-to-end protocol can help.
- OS-, kernel-, or secure-enclave-level compromise of the host
platform. Keys are stored using platform key stores (on iOS, the
Keychain; crypto and Double-Ratchet session state that must survive a
background/locked push-decrypt is gated
kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly—construct-iosSecurity/KeychainManager.swift:21cryptoKeyAccessible); if those primitives are broken, so is everything that relies on them. - Hardware-level side channels (Spectre, power analysis, EM
emanations). Software-level constant-time primitives (
subtlecrate, audited AEAD implementations) are used where they apply; this does not extend to physics. - Coercion of the user. A protocol cannot stop a person from being forced to unlock their phone.
Security goals — formal statement
| Goal | Mechanism (chapter) | Guaranteed against |
|---|---|---|
| Confidentiality | X3DH/PQXDH + Double Ratchet AEAD (04, 05) | Network, server (honest-but-curious or malicious) |
| Integrity & authentication | ChaCha20-Poly1305 AEAD with bound AD (05) | Network, server |
| Forward secrecy | Per-message key derivation + chain key eviction (05) | Historical device compromise |
| Post-compromise security | DH ratchet step after one round-trip (05) | Network compromise of a single session |
| Replay resistance | Two-layer dedup: protocol (Double Ratchet message number) + application (ACK store) (05) | Network |
| Post-quantum confidentiality | Hybrid PQXDH KEM (04) | A future quantum-equipped attacker replaying recorded traffic |
| Identity unforgeability | Ed25519 signatures over prekey bundles (03) | Network, server |
Trust assumptions
- Trusted today (would compromise security if breached): the local
device's OS and key store; the bundled
construct-corebinary; the audited Rust cryptography crates listed in Cryptographic Primitives; the user's choice not to expose their device to an active attacker. - Untrusted today: the network path; the server (for message content); other users (until they are explicitly added as contacts).
- Trust shrinks over time, by design: the federation roadmap is intended to remove the single-trusted-server assumption; the veil-front transport is intended to reduce reliance on the network not being adversarial.
The remainder of this specification proceeds from this threat model. A property described later as "secure" means "secure against the adversary classes above and no others".
Cryptographic Primitives
This chapter enumerates every cryptographic primitive that an interoperable Konstruct implementation MUST use, with concrete parameters, byte sizes, and crate-level references to the verified reference implementation. Two cryptographic suites are defined: Suite 1 (classical, always active) and Suite 2 (Suite 1 plus a post-quantum KEM, opt-in).
Keywords MUST, MUST NOT, SHOULD, MAY are per RFC 2119.
2.1 Suite identifiers
Each session is parameterised by a suite identifier. The suite is fixed at handshake time and MUST NOT change for the lifetime of the session.
| Identifier | Value (u16, big-endian on wire) | Description |
|---|---|---|
SUITE_CLASSIC_V1 | 0x0001 | X25519 + Ed25519 + ChaCha20-Poly1305 + HKDF-SHA256 |
SUITE_PQ_HYBRID_V1 | 0x0002 | Suite 1 + ML-KEM-768 hybrid KEM |
The suite identifier appears in the WirePayload header (Chapter 5 §5.3) and MUST be checked by the receiver before any cryptographic operation; a mismatched suite MUST cause the message to be rejected.
2.2 Suite 1 — Classical (always active)
2.2.1 X25519 key agreement
- Curve: Curve25519 per RFC 7748.
- Crate:
x25519-dalek 2.0with thereusable_secretsandstatic_secretsfeatures. - Public key size: 32 bytes (Montgomery form, encoded little-endian).
- Private key size: 32 bytes (clamped scalar).
- DH operation:
DH(a, B) = scalar_mult(a, B). Output: 32 bytes. - Implementations MUST clamp scalars per RFC 7748 §5; the
dalekcrate does this internally. - Implementations MUST validate that received public keys are not
all-zero (which would force the DH output to zero). The reference
inherits this check from
dalek.
2.2.2 Ed25519 signatures
- Algorithm: Ed25519 per RFC 8032.
- Crate:
ed25519-dalek 2.0with thestdandrand_corefeatures. - Public key size: 32 bytes.
- Private key (signing key) size: 32 bytes (seed) or 64 bytes (expanded). The reference uses the 32-byte seed form.
- Signature size: 64 bytes (
R || S, each 32 bytes). - Verification:
Verify(VK, M, sig) → ok | error. The reference usesVerifyingKey::verify_strictwhere strict validation is required. - An interoperable signer MUST sign the same canonical encoding of the
signed artefact. Signed artefacts in this specification are:
- Signed prekey:
Ed25519_Sign(SK_priv, SPK_pub || rotation_epoch_be) - Kyber signed prekey:
Ed25519_Sign(SK_priv, KEM_pub || rotation_epoch_be) - Other artefacts (registration receipts, invites) are out of scope for v0.1.
- Signed prekey:
2.2.3 ChaCha20-Poly1305 AEAD
- Algorithm: ChaCha20-Poly1305 per RFC 8439.
- Crate:
chacha20poly1305 0.10with thestdandgetrandomfeatures. - Key size: 32 bytes.
- Nonce size: 12 bytes. Each AEAD invocation in Konstruct uses a freshly random nonce (never a counter-derived nonce); see Chapter 5 §5.5.
- Tag size: 16 bytes (Poly1305).
- Associated Data: variable; the AD construction for Double Ratchet messages is specified in Chapter 5 §5.4.
- The plaintext input to the AEAD MUST be the PKCS#7-padded plaintext (§5.7), not the raw application bytes.
ChaCha20-Poly1305 is the AEAD for Double Ratchet message payloads. A
second AEAD is used for media attachments (photos, video, files, voice
messages): each attachment is encrypted client-side with AES-256-GCM
under a fresh per-file 256-bit key, wire format nonce(12) || ciphertext || tag(16), before upload. The per-file key never reaches the media server —
it travels end-to-end inside the (ChaCha20-Poly1305-sealed) message, so the
server stores only an opaque encrypted blob. Reference: construct-ios
Services/MediaUploadService.swift:83 (decryptMediaData, 32-byte
AES-256-GCM key). AES-256-GCM here rides Apple/hardware-accelerated
CryptoKit; the Double Ratchet deliberately stays on ChaCha20-Poly1305.
2.2.4 HKDF-SHA-256
- Algorithm: HKDF per RFC 5869, instantiated with SHA-256.
- Crate:
hkdf 0.12. - Three distinct HKDF invocations appear in the specification, each
with a normative
infobyte string. Implementations MUST use the exact byte strings below.
| Use | salt | IKM | info | L |
|---|---|---|---|---|
| X3DH root key (Ch. 4 §4.3) | [0xFF; 32] | DH_combined | b"Construct-X3DH-RootKey-v1" (25 B) | 32 |
| Double Ratchet root step (Ch. 5 §5.2) | RK | dh_out (32 B) | b"Construct-DoubleRatchet-RootKey-v1" (33 B) | 64 |
| PQ contribution at RK₁ (Ch. 4 §4.5) | RK₁ | kem_ss (32 B) | b"Construct-X3DH-RootKey-v1" (25 B) | 32 |
The Double Ratchet chain-step uses HMAC-SHA-256 directly rather than HKDF; see Chapter 5 §5.2.
2.2.5 PBKDF2 (password-based KDF)
- Algorithm: PBKDF2 per RFC 8018, HMAC-SHA-256 PRF.
- Crate:
pbkdf2 0.12with thesimplefeature. - Iterations (reference default): 100 000
(
construct-core/src/config.rs:117). - Use site: master-key derivation for at-rest encryption of recovery artefacts. PBKDF2 is not used in the wire protocol, only in application-level key wrapping.
2.2.6 Argon2id (anti-spam PoW)
- Algorithm: Argon2id per RFC 9106.
- Crate:
argon2 0.5. - Version:
V0x13(Argon2 v1.3). - Parameters (reference):
- Memory cost
m= 32 768 KiB - Iterations
t= 2 - Parallelism
p= 1
- Memory cost
- Use site: registration proof-of-work, in
construct-core/src/pow.rs. The server issues a fresh PoW challenge per registration attempt; the client returns a nonce whose Argon2id hash satisfies a difficulty target.
2.3 Suite 2 — Post-quantum extension (opt-in)
Suite 2 inherits all of Suite 1, and adds a hybrid post-quantum KEM whose shared secret is mixed into the root key after the first DH ratchet step (the "deferred" application; see Chapter 4 §4.5).
2.3.1 ML-KEM-768 (Kyber-768)
- Algorithm: ML-KEM-768 per NIST FIPS 203.
- Crate:
ml-kem 0.3.0(feature-gated aspost-quantum). - Encapsulation key size: 1184 bytes.
- Ciphertext size: 1088 bytes.
- Decapsulation (secret) key size: 2400 bytes (expanded form, as
exposed by
ExpandedKeyEncodingin theml-kemcrate). The reference exports the expanded form because re-derivation from a 32-byte seed adds runtime cost; both are equivalent at the protocol level. - Shared secret size: 32 bytes.
- Operations:
(ek, dk) = MlKem768::Generate()(ct, ss) = MlKem768::Encapsulate(ek)ss = MlKem768::Decapsulate(dk, ct)
- Implementations MUST use the FIPS-203 final variant, not the
earlier
Kyber-768-R3draft variant. The reference crateml-kem 0.3.0implements FIPS 203 final.
2.3.2 Hybrid design
The combined session security is determined by:
SK_root = HKDF(F, DH_combined, "Construct-X3DH-RootKey-v1", 32)
RK₁ = (root after first DH ratchet step)
RK₁' = HKDF(RK₁, kem_ss, "Construct-X3DH-RootKey-v1", 32)
Because RK₁' depends on both DH_combined (classical) and kem_ss
(post-quantum), an adversary MUST break both components to recover
plaintext. Breaking only X25519 leaves kem_ss as a 32-byte
unknown input to the KDF; breaking only ML-KEM-768 leaves
DH_combined similarly. This is the standard hybrid-security argument
and the reason Suite 2 is constructed as KEM alongside rather than
instead of classical X3DH.
2.3.3 Signatures in Suite 2
Suite 2 does not define hybrid signatures. All signature operations (signed prekey, signed Kyber prekey, registration receipts) use Ed25519 even when the session is operating under Suite 2.
Hybrid PQ signatures using ML-DSA-65 (Dilithium-3, NIST FIPS 204) are planned but not yet implemented; see Implementation Status §7.1.
2.4 Randomness
All key generation, ephemeral keypair generation, and AEAD nonces MUST
be sourced from a cryptographically secure RNG. The reference uses
rand::rngs::OsRng for classical primitives and a separately-seeded
getrandom-backed RNG for the ml-kem crate's PQ operations
(getrandom_pq feature).
Implementations MUST NOT use deterministic or counter-derived randomness for any of the values above. A failure of the OS RNG MUST cause the operation to abort, not to proceed with a weak source.
2.5 Key zeroization
All ephemeral and per-message keys MUST be zeroised before their memory is released:
| Material | Zeroise after |
|---|---|
DH_combined, individual DH_n outputs | X3DH root key derivation completes |
kem_ss | PQ contribution applied at RK₁ |
MK (per-message key) | AEAD operation completes (encrypt or decrypt) |
dh_out (DH ratchet output) | Both KDF_RK calls in the ratchet step complete |
Chain key superseded by KDF_CK | Immediately on derivation of the successor |
The reference uses zeroize / zeroize::Zeroizing from the
zeroize 1.7 crate for these. Long-term keys (IK_priv, SK_priv)
are kept in platform-protected storage; they are not zeroised at
runtime because they are needed across process lifetimes.
2.6 Constants summary
For ease of cross-reference, all normative byte constants used in this specification:
| Constant | Value | Length |
|---|---|---|
| Prologue | b"KonstruktX3DH-v1" | 17 B |
| Salt F (X3DH HKDF) | [0xFF; 32] | 32 B |
| Info (X3DH root key) | b"Construct-X3DH-RootKey-v1" | 25 B |
| Info (Double Ratchet root step) | b"Construct-DoubleRatchet-RootKey-v1" | 33 B |
| Info (PQ contribution) | b"Construct-X3DH-RootKey-v1" | 25 B |
| Suite 1 ID | 0x0001 | 2 B (u16 BE) |
| Suite 2 ID | 0x0002 | 2 B (u16 BE) |
| Argon2id version | V0x13 | — |
| CFE magic | [0x43, 0x46] ("CF") | 2 B |
| CFE version | 0x01 | 1 B |
These constants MUST be byte-identical between conforming implementations. Changing any of them produces an instantly non-interoperable handshake or AEAD failure.
2.7 References
- Reference implementation (single source of truth for parameters
above):
construct-core/, particularlysrc/crypto/,src/pow.rs, andCargo.toml. - NIST FIPS 203 (ML-KEM): https://csrc.nist.gov/pubs/fips/203/final
- RFC 7748 (X25519): https://www.rfc-editor.org/rfc/rfc7748
- RFC 8032 (Ed25519): https://www.rfc-editor.org/rfc/rfc8032
- RFC 8439 (ChaCha20-Poly1305): https://www.rfc-editor.org/rfc/rfc8439
- RFC 5869 (HKDF): https://www.rfc-editor.org/rfc/rfc5869
- RFC 9106 (Argon2): https://www.rfc-editor.org/rfc/rfc9106
Identity & Key Hierarchy
This chapter specifies, for an interoperable Konstruct implementation, what keys exist, how long they live, where they are stored, and what each one is used for. The structure mirrors the Signal Protocol's X3DH / Double Ratchet decomposition into long-term, medium-term, and ephemeral key material, extended with a parallel set of post-quantum keys.
The keywords MUST, MUST NOT, SHOULD, SHOULD NOT, and MAY in this chapter are to be interpreted as described in RFC 2119.
3.1 Identity model
A Konstruct identity is a 36-character UUID issued by the registration service. It MUST be treated as opaque by the protocol — all cryptographic operations are bound to keys, not to the identifier.
An identity is bound to exactly one device at a time. (Multi-device support is a planned feature; until it ships, "identity" and "device" are 1-to-1.) A device is uniquely identified by a 32-character hex string derived deterministically from its identity public key (see §3.4).
Implementations MUST NOT mix the two identifier spaces — using a
device-id where a user-id is expected (or vice versa) breaks the
Associated Data (AD) check in the Double Ratchet AEAD and causes
permanent decryption failure. The reference implementation enforces
this through distinct Rust types (ServerUserId, CryptoDeviceId).
3.2 Key inventory
A registered identity holds, at minimum, the following key material.
All keys MUST be generated with cryptographically secure randomness;
the reference uses rand::rngs::OsRng.
| Key | Algorithm | Lifetime | Size (pub / priv) | Role |
|---|---|---|---|---|
| Identity key (IK) | X25519 | Permanent | 32 B / 32 B | Long-term key agreement seed |
| Signing key (SK) | Ed25519 | Permanent | 32 B / 32 B | Signs prekey bundles |
| Signed prekey (SPK) | X25519 | ≤ 10 days | 32 B / 32 B | Medium-term, rotated periodically |
| One-time prekeys (OPK) | X25519 | Single use | 32 B / 32 B | Consumed on first message |
| Kyber signed prekey (Kyber-SPK) | ML-KEM-768 | ≤ 10 days | 1184 B / 2400 B | Post-quantum medium-term (Suite 2) |
| Kyber one-time prekeys (Kyber-OPK) | ML-KEM-768 | Single use | 1184 B / 2400 B | Post-quantum, consumed on use |
The X25519 and Ed25519 sizes are fixed by the underlying curves
(Curve25519, Edwards25519). The ML-KEM-768 sizes are fixed by NIST
FIPS 203 (public key 1184 bytes, ciphertext 1088 bytes, secret key
2400 bytes in expanded form as exposed by the ml-kem crate).
3.3 Long-term keys
Identity key (IK)
The X25519 long-term key. It MUST be generated once at registration and never rotated for the lifetime of the identity. Rotating it constitutes destroying the identity.
The private half MUST be stored in the platform's secure key store
under an "after-first-unlock" access class equivalent (iOS:
kSecAttrAccessibleAfterFirstUnlock; Android: hardware-backed
Keystore with equivalent flag). The public half is published in the
registration bundle (§3.6) and is what other parties run X25519
against.
Signing key (SK)
A separate Ed25519 keypair used only to sign prekey bundles. It MUST NOT be reused for any other purpose. Like the IK, it is permanent and stored in the platform key store. The split between IK (key agreement) and SK (signatures) follows the Signal convention and lets each algorithm be replaced independently.
A Konstruct signature, where it appears in this specification, is the
64-byte EdDSA signature over the canonical encoding of the signed
artefact, verified with ed25519_dalek::VerifyingKey::verify.
3.4 Device identifier
Both peers in a Double Ratchet AEAD must agree on the AD construction (§5.4). The AD includes a stable per-identity tag that survives session resets. The reference implementation derives this tag as:
device_id = LOWER_HEX(SHA-256(IK_pub))[0..32]
i.e. the lower-case hexadecimal SHA-256 of the 32-byte X25519 identity
public key, truncated to 32 characters. An interoperable implementation
MUST produce the same value from the same IK_pub.
3.5 Medium-term keys: signed prekeys
X25519 signed prekey (SPK)
The SPK is an X25519 keypair generated at registration and rotated at
most every 10 days. The reference enforces this with
SPK_MAX_AGE_SECS = 10 * 24 * 3600
(construct-core/src/crypto/client_api.rs:65). A bundle whose SPK is
older than this MUST be rejected by a responder.
The SPK carries a spk_rotation_epoch: u32. Each rotation increments
this counter monotonically. The epoch is bound into the bundle
signature (§3.6) so that an attacker cannot replay an older valid SPK
into a new bundle.
When the application rotates the SPK, it MUST upload the new (public-key, signature, epoch) tuple to the server and SHOULD continue to accept incoming X3DH initiations using the previous SPK for a grace window (default: until the bundle TTL expires) to avoid dropping in-flight handshakes.
Kyber signed prekey (Kyber-SPK)
An ML-KEM-768 encapsulation key serving the analogous role for the
post-quantum extension. Lifetime, rotation cadence, and epoch are
identical to the X25519 SPK. A bundle whose Kyber-SPK is older than
SPK_MAX_AGE_SECS MUST be rejected when Suite 2 is in use.
The Kyber-SPK signature is computed over the entire
(Kyber-SPK || rotation_epoch_be_bytes) payload with the identity's
Ed25519 signing key; PQ signatures (ML-DSA-65) are planned but not
in the reference today, so the signature on the Kyber bundle is
classical Ed25519 even in Suite 2.
3.6 Ephemeral / one-time prekeys
X25519 one-time prekeys (OPK)
A pool of single-use X25519 keypairs uploaded with the registration
bundle. Each OPK has a u32 identifier; the X3DH initiator (Alice)
selects one and the server marks it consumed.
If the OPK pool drops below an implementation-defined threshold (the
reference uses 20), the client MUST upload fresh OPKs. Running out of
OPKs falls back to a 3-DH handshake without the OPK component, which
has reduced forward secrecy (the protocol's BS-3 open issue — see
Implementation Status).
Kyber one-time prekeys (Kyber-OPK)
A pool of single-use ML-KEM-768 encapsulation keys serving the same purpose for the post-quantum extension. The server SHOULD maintain the Kyber-OPK pool with the same threshold logic as classical OPKs.
A Suite 2 initiation in which no Kyber-OPK is available MUST NOT
silently downgrade to Suite 1. The reference enforces this via
SEC-002 (mandatory Kyber epoch validation in Suite 2 handshakes).
3.7 Per-session keys (Double Ratchet)
Once X3DH completes (Chapter 4), the session state machine maintains:
| Symbol | Role |
|---|---|
| RK | 32-byte root key, mixed into each DH ratchet step |
| CK_s, CK_r | Sending and receiving chain keys (32 B each) |
| MK_n | Per-message keys (32 B), derived from a chain key, used once, then deleted |
| DHs, DHr | Local sending DH keypair and remote DH public |
| Ns, Nr, PN | Sending counter, receiving counter, previous-chain length |
The Double Ratchet operations on these are specified in
Chapter 5. Per-message keys MUST be
zeroed immediately after AEAD decrypt; the reference uses
zeroize::Zeroizing.
3.8 Key storage at rest
The reference implementation stores keys with the following access classes; an interoperable implementation SHOULD provide an equivalent guarantee on its platform:
| Key | Access class |
|---|---|
| IK_priv, SK_priv | After-first-unlock, device-only, non-syncable |
Session JSON (incl. dh_ratchet_private, RK, chain keys) | When-unlocked, device-only |
| Refresh / auth tokens | After-first-unlock |
device_id (derived value) | After-first-unlock |
The session blob currently stores dh_ratchet_private in cleartext
within the platform-protected blob. Adding an additional
encryption-at-rest layer is open work tracked as SEC-009 in the
implementation status.
3.9 Public key bundle (wire format)
The bundle a responder publishes to the directory MUST contain:
RegistrationBundle ::=
suite_id : u16
identity_pub : [u8; 32] -- X25519
signing_pub : [u8; 32] -- Ed25519
signed_prekey_pub : [u8; 32] -- X25519
signed_prekey_sig : [u8; 64] -- Ed25519(SK_priv,
SPK_pub || epoch_be)
spk_rotation_epoch : u32
one_time_prekeys : Vec<(u32, [u8; 32])> -- (id, pub)
-- Suite 2 (PQXDH) extension, omitted in Suite 1 bundles:
kyber_pre_key_pub : Option<[u8; 1184]>
kyber_pre_key_sig : Option<[u8; 64]> -- Ed25519
kyber_spk_rotation_epoch : Option<u32>
kyber_one_time_prekeys : Option<Vec<(u32, [u8; 1184])>>
All multi-byte integers are big-endian. The exact serialised form crossing the FFI boundary is a packed binary struct (no JSON), see Chapter 6 §6.2.
A responder MUST verify the SPK signature against signing_pub before
performing any DH against the SPK. A bundle that fails signature
verification MUST be rejected; the handshake MUST NOT proceed.
3.10 Key rotation summary
| Key | Rotation trigger | Cadence |
|---|---|---|
| IK, SK | None (rotating destroys the identity) | Never |
| SPK, Kyber-SPK | Periodic + on app launch if stale | ≤ 10 days |
| OPK, Kyber-OPK | Consumed on each handshake | Re-uploaded when pool < threshold |
| RK, CK_s, CK_r | Every Double Ratchet step | Per message / per round-trip |
| MK | Every message | Used once, then deleted |
| Session as a whole | END_SESSION, healing fallback | On demand |
These rotation invariants are what give the protocol its forward secrecy and post-compromise security properties (Chapters 1 and 5).
Session Handshake (X3DH + PQXDH)
This chapter specifies the cryptographic handshake that establishes a new Konstruct session between two identities. The classical part (X3DH) follows the Signal Protocol X3DH whitepaper with concrete parameters listed in §4.1. The post-quantum extension (PQXDH) layers an ML-KEM-768 KEM on top of X3DH such that the resulting session is secure if either the classical or the post-quantum component holds.
Keywords MUST, MUST NOT, SHOULD, MAY are per RFC 2119.
4.1 Notation and constants
| Symbol | Definition |
|---|---|
| INITIATOR (Alice) | The party that begins the handshake by fetching the responder's bundle. |
| RESPONDER (Bob) | The party whose published bundle Alice consumes; Bob first learns the session exists when he decrypts the first message. |
| IK_X | Identity key of party X (X25519). Subscript pub / priv for the halves. |
| SK_X | Signing key of party X (Ed25519). |
| SPK_X | Signed prekey of party X (X25519). |
| OPK_X | A one-time prekey of party X (X25519). |
| EK_A | Ephemeral key generated by Alice, used exactly once per handshake (X25519). |
| KEM_X | ML-KEM-768 encapsulation key of party X (Kyber-SPK or Kyber-OPK). |
| DH(a, B) | X25519 scalar multiplication of private scalar a against public point B. Output: 32 bytes. |
| KDF(salt, IKM, info, L) | HKDF-SHA-256 extract-then-expand. Output: L bytes. The reference uses the hkdf crate. |
| F | Constant salt: [0xFF, 0xFF, ..., 0xFF] (32 bytes). Required by the Signal X3DH spec §2.2. |
|| | Byte concatenation. |
The following protocol-level byte strings are fixed:
| Constant | Value | Source |
|---|---|---|
| Prologue | b"KonstruktX3DH-v1" (17 bytes) | construct-core/src/crypto/keys.rs:17 |
| Salt F | [0xFF; 32] | construct-core/src/crypto/handshake/x3dh.rs:410 |
| HKDF info (root key derivation) | b"Construct-X3DH-RootKey-v1" (25 bytes) | construct-core/src/crypto/handshake/x3dh.rs:414 |
| Suite 1 identifier | 0x01 | construct-core/src/config.rs:125 |
| Suite 2 identifier | 0x02 | reserved for PQXDH |
An interoperable implementation MUST use these exact byte values. Changing any of them produces incompatible sessions (the AEAD on the first message will fail).
4.2 Bundle publication (Bob)
Before any handshake can occur, Bob MUST have published a registration bundle to the directory. The bundle wire format is defined in §3.9.
Bob MUST:
- Generate a fresh SPK and Kyber-SPK at registration; record the
issue timestamp and
spk_rotation_epoch = 0. - Sign the SPK as
sig_SPK = Ed25519_Sign(SK_priv, SPK_pub || epoch_be). - Sign the Kyber-SPK as
sig_KSPK = Ed25519_Sign(SK_priv, KEM_pub || epoch_be). - Upload (IK_pub, SK_pub, SPK_pub, sig_SPK, epoch, OPKs, Kyber-SPK, sig_KSPK, Kyber-OPKs) to the directory.
- Re-upload (rotate) the SPK and Kyber-SPK at most every 10 days
(
SPK_MAX_AGE_SECS, §3.5), monotonically incrementing the epoch.
4.3 X3DH (Suite 1) — initiator path
Alice MUST perform the following steps to initiate a session with Bob.
Step 1: Fetch and validate the bundle
Alice fetches Bob's bundle from the directory. She MUST then:
- Verify
sig_SPKoverSPK_pub || epoch_beusingSK_pub. If verification fails, abort. - Verify that
SPK_age = now − SPK_issued_at ≤ SPK_MAX_AGE_SECS(10 days). A stale SPK MUST cause abort (replay protection perSEC-001). - If the bundle contains one or more
OPKs, select exactly one and record itsopk_id(for Bob to consume). - (Suite 2 only) Verify
sig_KSPKand Kyber-SPK freshness identically. If Suite 2 is requested and the Kyber-SPK is missing or stale, abort (SEC-002). The protocol MUST NOT silently downgrade from Suite 2 to Suite 1.
Step 2: Generate ephemerals
Alice generates a fresh X25519 ephemeral keypair EK_A.
Step 3: Compute DH outputs
The four (or three, if no OPK) DH outputs are:
DH1 = DH(IK_A_priv, SPK_B_pub)
DH2 = DH(EK_A_priv, IK_B_pub)
DH3 = DH(EK_A_priv, SPK_B_pub)
DH4 = DH(EK_A_priv, OPK_B_pub) — omitted if no OPK
The order matters; an implementation that concatenates them in a different order produces a different root key and is non-interoperable.
DH_combined = DH1 || DH2 || DH3 || DH4
In Suite 1 the length of DH_combined is either 128 bytes (4-DH) or
96 bytes (3-DH fallback). The 3-DH fallback path is functional but
has reduced forward secrecy and is tracked as the open issue BS-3;
implementations SHOULD warn when it is taken.
Step 4: Derive the root key
SK_root = KDF(salt = F, IKM = DH_combined,
info = b"Construct-X3DH-RootKey-v1", L = 32)
SK_root is the initial Double Ratchet root key (RK₀). Implementations
MUST zeroise DH_combined and the individual DH_n slices after this
step.
Step 5: Initialise the Double Ratchet sending state
The initiator immediately performs the first DH ratchet step
(see §5.6) using SK_root
and Bob's SPK_pub as the initial remote DH key. The result is RK₁
plus a new sending chain key CK_s₀.
Step 6: Encrypt and send the first message
Alice encrypts her first plaintext under MK_0 = KDF_CK(CK_s₀, "msg")
(§5.4). The wire-format envelope MUST contain:
FirstMessageEnvelope (Suite 1) ::=
suite_id : u16 = 0x0001
ek_pub : [u8; 32] -- Alice's X25519 ephemeral pub
opk_id : Option<u32> -- which of Bob's OPKs was used
message_number : u32 = 0
dh_pub : [u8; 32] -- Alice's first ratchet DH pub
prev_chain_length : u32 = 0
nonce : [u8; 12]
ciphertext : Bytes
aead_tag : [u8; 16]
Field ordering and sizes are normative for interoperability. The
nonce is generated by the AEAD implementation (chacha20poly1305 0.10).
4.4 X3DH (Suite 1) — responder path
Bob first learns about the session by receiving Alice's first message. He MUST:
-
Look up his own
IK_priv,SPK_priv(at the epoch indicated by the bundle Alice consumed), andOPK_priv[opk_id]from his key store. -
Compute the same four DH outputs:
DH1 = DH(SPK_B_priv, IK_A_pub) DH2 = DH(IK_B_priv, EK_A_pub) DH3 = DH(SPK_B_priv, EK_A_pub) DH4 = DH(OPK_B_priv, EK_A_pub) — when opk_id is presentNote: subscripts swap to maintain
DH(IK_A_priv, SPK_B_pub) = DH(SPK_B_priv, IK_A_pub)(X25519 is symmetric). -
Derive
SK_rootwith the same KDF call as the initiator. -
Initialise the Double Ratchet receiving state, then run the first ratchet step using
EK_A_pubfrom the envelope as the initial remote DH key. -
AEAD-decrypt the ciphertext (with AD as defined in §5.4). If decrypt fails, abort and surface a clear error (the reference returns
Crypto::AeadVerifyFailedrather than silently dropping). -
MUST delete the consumed
OPK_priv[opk_id]so it cannot be reused (this is the source of forward secrecy contributed by the OPK).
If both Alice and Bob initiate concurrently (each fetched the other's bundle simultaneously), the tie-break rule is:
The party whose
device_idis lexicographically greater wins the INITIATOR role and proceeds; the other party MUST discard its half-built state and accept the winner's first message as RESPONDER.
The reference implements this in orchestration/session_lifecycle.rs.
4.5 PQXDH (Suite 2) — post-quantum extension
Suite 2 augments Suite 1 with an ML-KEM-768 shared secret. The
addition is deferred: the ML-KEM shared secret is mixed in after
the first DH ratchet step, not into the initial root-key derivation.
This design lets the same KEM_priv be safely paired with multiple
incoming sessions and matches the construct-core implementation in
src/crypto/pq_x3dh.rs.
Step P1: Initiator KEM encapsulation
After §4.3 Step 1 (bundle validation), Alice additionally selects Bob's Kyber-SPK (or, if available, a Kyber-OPK) and calls:
(KEM_pub_ct, kem_ss) = MlKem768::Encapsulate(KEM_pub_B)
KEM_pub_ct is the 1088-byte ciphertext. kem_ss is the 32-byte
post-quantum shared secret. The classical X3DH proceeds in parallel
and produces SK_root exactly as in §4.3.
Step P2: First message includes the KEM ciphertext
The Suite 2 envelope additionally carries:
+ kem_ct : [u8; 1088] -- ML-KEM-768 ciphertext
+ kyber_opk_id : Option<u32> -- which Kyber-OPK was consumed
+ kem_len : u16 = 1088 -- defensive length tag
The classical part of the envelope is unchanged.
Step P3: Initiator caches kem_ss until the first ratchet completes
Alice MUST persist kem_ss to durable storage before sending the
first message. The reference uses RustPQContributions, an
in-memory + Keychain-backed store, to survive an app crash between
"first message sent" and "PQ contribution applied" (open issue BS-6:
the persistence layer is currently in-memory only, which means a crash
in this window degrades the session to classical-only silently).
Step P4: Responder KEM decapsulation
Bob, after performing classical X3DH (§4.4) and the first DH ratchet step, calls:
kem_ss = MlKem768::Decapsulate(KEM_priv_B, kem_ct)
He MUST then immediately persist kem_ss (same BS-6 caveat).
Step P5: Apply the PQ contribution at RK₁
After the first Double Ratchet DH step has produced RK₁ (the post- first-ratchet root key), both sides MUST update:
RK₁' = KDF(salt = RK₁, IKM = kem_ss,
info = b"Construct-X3DH-RootKey-v1", L = 32)
and re-derive any sending chain key that was already computed from RK₁. From this point onward the session is secured under both the classical X25519 contribution and the PQ ML-KEM-768 contribution.
The deferred-mix design ensures the responder applies the PQ strengthening at the same logical state as the initiator (after the first DH step), which is required for the chains to stay in sync.
Step P6: Zeroise kem_ss
Once RK₁' is computed and persisted, kem_ss MUST be zeroised. The
PQ contribution store removes the entry from the Keychain at this
point.
4.6 Failure modes and recovery
| Failure | Required behaviour |
|---|---|
| Bundle signature verification fails | Abort handshake. Do not retry against the same bundle. |
| SPK or Kyber-SPK stale (> 10 days) | Abort. Trigger directory refetch. |
| Suite 2 requested but Kyber-SPK missing | Abort. MUST NOT downgrade to Suite 1 silently. |
| AEAD decrypt of first message fails | Abort. RESPONDER MUST NOT mark OPK consumed if it has not yet decrypted successfully. |
| Both parties initiate concurrently | Apply tie-break rule (§4.4); loser discards state. |
| App crash between sending first message and PQ contribution apply (initiator) | Recover from RustPQContributions Keychain entry on next launch. If no entry survives, the session is classically secure only. |
4.7 Reference
- Classical X3DH:
construct-core/src/crypto/handshake/x3dh.rs - PQXDH KEM:
construct-core/src/crypto/pq_x3dh.rs - Session initiation orchestration:
construct-core/src/orchestration/session_lifecycle.rs - PQ contribution store:
construct-core/src/orchestration/pq_contribution.rs
The cryptographic test vectors that an interoperable implementation
should reproduce are in construct-core/tests/handshake_vectors.rs
(to be expanded in v0.2 of this specification with a published vector
set).
Message Encryption (Double Ratchet)
Once a session is established by the handshake of Chapter 4, every subsequent message is encrypted under the Double Ratchet algorithm of Perrin and Marlinspike. This chapter specifies Konstruct's variant: state variables, the symmetric and DH ratchet steps, the AEAD framing and Associated Data, and the DoS guards that an interoperable implementation MUST enforce.
Keywords MUST, MUST NOT, SHOULD, MAY are per RFC 2119.
5.1 Session state
Each party maintains the following state per session. All values are
zeroised on session destruction; MK and intermediate chain-key
material MUST be zeroised immediately after use.
| Symbol | Type | Initialised by | Meaning |
|---|---|---|---|
| RK | [u8; 32] | X3DH (Ch. 4 §4.3 Step 4) | Root key, advanced by DH ratchet |
| DHs | X25519 keypair | Ratchet step | Sending DH keypair (rotated each step) |
| DHr | [u8; 32] | Remote dh_pub from peer header | Last seen remote DH public |
| CKs | [u8; 32] | DH ratchet | Sending chain key |
| CKr | [u8; 32] | DH ratchet | Receiving chain key |
| Ns | u32 | 0 | Messages sent in the current sending chain |
| Nr | u32 | 0 | Messages received in the current receiving chain |
| PN | u32 | 0 | Number of messages in the previous sending chain |
| MKSKIPPED | Map<(DHr, n), [u8;32]> | {} | Per-message keys for out-of-order delivery |
The reference implementation packs these into
SessionState in construct-core/src/crypto/messaging/double_ratchet/.
5.2 KDF helpers
Two distinct HKDF-SHA-256 instances are used:
KDF_RK(rk, dh_out) -> (rk', ck')
= HKDF-SHA-256(salt = rk, IKM = dh_out,
info = b"Construct-DoubleRatchet-RootKey-v1", L = 64)
-> (rk'[0..32], ck'[32..64])
KDF_CK(ck) -> (ck', mk)
where
mk = HMAC-SHA-256(key = ck, data = 0x01)[0..32]
ck' = HMAC-SHA-256(key = ck, data = 0x02)[0..32]
KDF_RK mixes a new DH output into the root key and emits a fresh
chain key. KDF_CK advances a chain key one step and emits a single
message key.
5.3 Wire format (WirePayload header)
Every encrypted message on the wire is preceded by a fixed-layout binary header followed by AEAD-protected ciphertext.
WirePayload (header, 52 bytes fixed + variable Kyber fields) ::=
message_number : u32 big-endian (4 B)
dh_public_key : [u8; 32] (32 B)
otpk_id : u32 big-endian (4 B) -- 0 if N/A
kyber_otpk_id : u32 big-endian (4 B) -- 0 if N/A
kem_len : u16 big-endian (2 B)
prev_chain_length : u32 big-endian (4 B)
suite_id : u16 big-endian (2 B)
-- followed by kem_ct (kem_len bytes; absent in Suite 1)
-- followed by AEAD framing (nonce || ciphertext || tag)
HEADER_SIZE = 52 bytes is fixed by the reference (sum of the field
sizes above, construct-core/src/wire_payload.rs:30). The variable
KEM ciphertext follows the header when suite_id = 0x0002 and
kem_len > 0. The pack/unpack routines are
wire_payload::pack / wire_payload::unpack; deviating from the
ordering or endianness produces non-interoperable frames.
The AEAD output (nonce || ciphertext || tag) uses:
| Component | Size |
|---|---|
| Nonce | 12 bytes (ChaCha20-Poly1305) |
| Ciphertext | padded_plaintext.len() bytes (1:1 with plaintext after padding) |
| Tag | 16 bytes (Poly1305) |
5.4 Associated Data construction (AD)
The AEAD MUST be called with an Associated Data buffer that binds the ciphertext to its session, parties, ratchet position, and protocol version. The current format is AD version 3, defined as:
AD_v3 ::=
ad_version : u8 = 3 (1 B)
contact_id : utf-8 bytes (36 chars) (36 B for UUID)
local_user_id : utf-8 bytes (36 chars) (36 B for UUID)
session_id : [u8; 32] (32 B)
dh_public_key : [u8; 32] (32 B)
message_number : u32 big-endian (4 B)
Total length for canonical 36-character UUIDs: 141 bytes. The
reference constructs this in
construct-core/src/crypto/messaging/double_ratchet/internals.rs:223.
Order is normative. Each direction of a session computes its own AD —
ENCRYPT uses (local_user_id_sender, contact_id_receiver); DECRYPT
uses (contact_id_sender, local_user_id_receiver), with the field
positions swapped so the AD on each side matches:
ENCRYPT side (Alice → Bob):
AD = 0x03 || alice_user_id || bob_user_id || session_id || ...
DECRYPT side (Bob receiving from Alice):
AD = 0x03 || alice_user_id || bob_user_id || session_id || ...
i.e. the "sender_id" position is always populated with the sender's
user-id regardless of which side is computing AD. A mismatch (e.g.
using device_id (32-char hex) instead of user_id (36-char UUID))
produces an AD length difference and instant AEAD failure.
5.4.1 AD migration (v2 → v3)
The previous version AD_VERSION_PREV = 2 differs from v3 only in
that it omits the session_id field. A receiver MUST attempt
decryption first with AD_VERSION = 3; if AEAD verification fails,
the receiver MUST retry once with AD_VERSION = 2 before treating
the message as undecryptable. This fallback path is purely for
in-flight v2 messages during the migration window and SHOULD be
removed in a future protocol revision (SEC-006).
5.5 Encryption (RatchetEncrypt)
RatchetEncrypt(state, plaintext, peer_id):
1. (CKs', mk) = KDF_CK(state.CKs)
2. state.CKs = CKs'
3. header = {
message_number = state.Ns,
dh_public_key = state.DHs.pub,
prev_chain_length = state.PN,
suite_id = state.suite_id,
kem_len = 0, -- non-handshake messages
otpk_id = 0,
kyber_otpk_id = 0,
}
4. ad = build_ad(AD_VERSION_3, state.local_user_id,
peer_id, state.session_id,
state.DHs.pub, state.Ns)
5. padded = pkcs7_pad(plaintext, 255) -- §5.7
6. (nonce, ct, tag) = AEAD-Encrypt(key = mk,
plaintext = padded,
associated_data = ad)
7. zeroise(mk)
8. state.Ns += 1
9. return wire_payload::pack(header, kem_ct = None,
nonce || ct || tag)
The reference uses chacha20poly1305 0.10 for AEAD. The nonce is a
fresh 12-byte random per message; it is part of the AEAD output and
MUST be transmitted alongside the ciphertext.
5.6 DH ratchet step
A DH ratchet step occurs when an incoming message carries a dh_public_key
the receiver has not seen before (i.e. the peer rotated their sending
keypair). The step is:
DHRatchetStep(state, peer_dh_pub):
1. state.PN = state.Ns
2. state.Ns = 0
3. state.Nr = 0
4. state.DHr = peer_dh_pub
5. dh_out = DH(state.DHs.priv, state.DHr)
6. (state.RK, state.CKr) = KDF_RK(state.RK, dh_out)
7. state.DHs = X25519::generate()
8. dh_out = DH(state.DHs.priv, state.DHr)
9. (state.RK, state.CKs) = KDF_RK(state.RK, dh_out)
10. zeroise(dh_out)
This performs two KDF_RK invocations: one to derive the receiving
chain key (matching the peer's just-completed sending chain) and one
to derive the new sending chain key (after rotating the local DH
keypair). The order is normative; reversing it produces incompatible
chain alignment.
5.7 PKCS#7 padding (length-hiding)
Plaintext MUST be padded to a multiple of 255 bytes using PKCS#7 before AEAD-encryption. The padding length byte is itself part of the plaintext (verified during unpad). This hides the exact application plaintext length from a network observer, leaving only the bucket size (multiple of 255).
The reference implements unpad in
construct-core/src/traffic_protection/padding.rs using XOR-based
constant-time validation: diff |= byte ^ expected aggregated across
the padding region, then checked against zero. A non-constant-time
unpad would leak padding length through timing.
5.8 Decryption (RatchetDecrypt)
RatchetDecrypt(state, wire_bytes, peer_id):
1. (header, kem_ct, framing) = wire_payload::unpack(wire_bytes)
-- §5.8.1 DoS guards (MUST be enforced)
2. If header.message_number > state.Nr + MAX_MESSAGE_JUMP:
reject as DoS attempt
3. skipped = header.message_number - state.Nr
If skipped > MAX_SKIPPED_MESSAGES:
reject as DoS attempt
-- §5.8.2 DH ratchet check
4. If header.dh_public_key != state.DHr:
SkipChainKeysUntil(state, header.prev_chain_length)
DHRatchetStep(state, header.dh_public_key)
-- §5.8.3 Message key lookup
5. SkipChainKeysUntil(state, header.message_number)
6. (CKr', mk) = KDF_CK(state.CKr)
7. state.CKr = CKr'
8. state.Nr += 1
-- §5.8.4 AEAD decrypt with fallback
9. ad = build_ad(AD_VERSION_3, peer_id, state.local_user_id,
state.session_id, header.dh_public_key,
header.message_number)
10. try:
padded = AEAD-Decrypt(key = mk, ciphertext = framing,
associated_data = ad)
except AeadVerifyFailed:
ad_v2 = build_ad(AD_VERSION_PREV, ...) -- §5.4.1
padded = AEAD-Decrypt(..., associated_data = ad_v2)
11. zeroise(mk)
12. plaintext = pkcs7_unpad(padded)
13. return plaintext
5.8.1 Mandatory DoS guards
| Constant | Default | Source |
|---|---|---|
MAX_SKIPPED_MESSAGES | 1000 | construct-core/src/config.rs:128 |
MAX_MESSAGE_JUMP | 2000 | construct-core/src/config.rs:129 |
MAX_SKIPPED_MESSAGE_AGE_SECONDS | 604800 (7 days) | :130 |
A message that violates any of these MUST be rejected without
performing the AEAD operation. Otherwise an attacker can force the
receiver to derive an arbitrary number of skipped message keys (CPU /
memory DoS) by spoofing a header with a giant message_number.
5.8.2 Skipped message key cleanup
Skipped message keys (MKSKIPPED) MUST be expired:
- By count: oldest first when
len(MKSKIPPED) > MAX_SKIPPED_MESSAGES. - By age: any key older than
MAX_SKIPPED_MESSAGE_AGE_SECONDS. - By DH ratchet: keys belonging to a chain older than
state.DHr − 2ratchet steps SHOULD be evicted.
5.9 Self-healing (END_SESSION fallback)
If decryption of the first message of a session (message_number =
0, dh_ratchet step 0) fails, the receiver MAY trigger session healing
before falling back to a full handshake. The healing protocol is
out of scope of this chapter; see
construct-core/src/orchestration/healing_queue.rs for the reference
implementation. Constraints:
- Healing MUST be attempted at most 3 times per contact per 24-hour window.
- A successful healing MUST result in a session that satisfies all the security properties of a freshly negotiated session (forward secrecy, post-compromise security).
- If healing exhausts its retry budget, the receiver MUST send an
END_SESSIONcontrol message and fall through to a full X3DH/PQXDH handshake from §4.
5.10 Security properties (informal)
The Double Ratchet, applied as above, provides:
- Forward secrecy: compromise of any state component at time
tdoes not enable decryption of messages from timet − 1or earlier, because the chain keys and message keys used then have been zeroised and the root key has been re-derived through irreversible KDF and DH operations. - Post-compromise security: compromise of all secret state at time
t, followed by no further active attack, leaves the attacker unable to decrypt messages from timet + Δonce a single DH ratchet step has completed (typically one round-trip). - Replay resistance: a replayed ciphertext fails the AEAD check
on the second receive (because
mkhas been zeroised) and also fails application-layer ACK dedup.
A formal proof against a specified adversary is not part of this specification. The reference implementation is intended to be amenable to formal verification (Kani / Prusti); that work is planned but not done.
5.11 References
- Specification: this chapter.
- Reference implementation:
construct-core/src/crypto/messaging/double_ratchet/construct-core/src/wire_payload.rsconstruct-core/src/traffic_protection/padding.rs
- Original design: Perrin & Marlinspike, The Double Ratchet Algorithm, https://signal.org/docs/specifications/doubleratchet/.
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 transport (construct-transport) is also in production as
an alternative to the HTTP/2 path, selected by the client-side transport
router with an HTTP/2 fallback; 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:
| Field | Type | Size | Description |
|---|---|---|---|
| message_number | u32 BE | 4 B | Double Ratchet sending counter Ns |
| dh_public_key | bytes | 32 B | Current sending DH public (DHs.pub) |
| otpk_id | u32 BE | 4 B | OPK id consumed by the X3DH initiator; 0 if N/A |
| kyber_otpk_id | u32 BE | 4 B | Kyber-OPK id consumed; 0 if N/A |
| kem_len | u16 BE | 2 B | Length of the KEM ciphertext that follows; 0 in Suite 1 |
| prev_chain_length | u32 BE | 4 B | Previous-chain length PN |
| suite_id | u16 BE | 2 B | 0x0001 Suite 1 or 0x0002 Suite 2 |
| kem_ct | bytes | kem_len B | ML-KEM-768 ciphertext (Suite 2 only, 1088 B) |
| aead_frame | bytes | variable | `nonce(12) |
Total fixed header size: 52 bytes
(construct-core/src/wire_payload.rs:30).
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
payload_len : u32 BE -- length of the MessagePack body
flags : u8 -- reserved, MUST be 0
reserved : [u8; 3] = [0x00; 3]
crc32 : u32 BE -- CRC-32 over (magic..reserved || payload)
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.
6.3.2 Required validations
A receiver of a CFE envelope MUST:
- Verify the magic bytes match exactly. Mismatch → reject.
- Verify the version is supported (currently only
0x01). - Verify
payload_lendoes not exceed the implementation-defined maximum (reference: 256 KiB). - Verify
crc32matches recomputed CRC over the header (with the crc32 field zeroed) plus the payload. - 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:
| Service | RPC | Direction |
|---|---|---|
| AuthService | GetPowChallenge, RegisterDevice, AuthenticateDevice, RefreshToken | unary, no JWT required |
| UserService | CheckUsernameAvailability | unary, no JWT required |
| UserService | (other) | unary, JWT required |
| DeviceService | * | unary, no JWT required |
| MessagingService | MessageStream | bidirectional stream, JWT required |
| SignalingService | Signal | bidirectional stream, JWT required |
| KeyService | prekey upload / fetch | unary, 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.
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:
| Backend | Status | Wire shape on the network |
|---|---|---|
| veil-front | Production | TLS 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 / WebTunnel | Retired | Superseded 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 ofconstruct-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_b64MUST 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 tier | Path used |
|---|---|
| Free / uncensored | Direct 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
| Property | Mechanism |
|---|---|
| Tamper detection on the FFI line | CFE CRC-32 + magic bytes |
| Bounded FFI input size | CFE payload_len cap |
| Server cannot read message content | Cryptographic core (Ch. 5), not the transport |
| Length privacy from a network observer | PKCS#7 padding (§5.7) + VEIL length bucketing |
| Censorship resistance | VEIL backends (§6.5) |
| Memory safety at the FFI boundary | CFE owned Vec<u8> (no raw pointers) + bounded length |
What the transport layer does not guarantee
| Exposure | Mitigation status |
|---|---|
| IP visibility to the relay operator | Inherent — 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 region | veil-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 server | Removed 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
| Constant | Value | Source |
|---|---|---|
| CFE magic | [0x43, 0x46] | cfe/envelope.rs:8 |
| CFE version | 0x01 | cfe/envelope.rs:9 |
| CFE header length | 16 bytes | cfe/envelope.rs:10 |
| CFE max payload | 256 KiB | reference implementation cap |
| WirePayload header length | 52 bytes (fixed) | wire_payload.rs:30 |
| Padding modulus | 255 | traffic_protection/padding.rs |
| VEIL probe timeout | implementation-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
Implementation Status
This chapter is the honest matrix of what the reference implementation
in konstruct-msg/construct-core (and the surrounding repositories)
actually does today, distinct from what this specification
requires a fully-conforming implementation to do.
It is the chapter readers should consult before relying on Konstruct for any specific threat model. Every line is grounded in a source-tree reference so it can be re-verified independently.
7.1 Component matrix
| Component | Implementation status | Where it lives |
|---|---|---|
| X3DH classical handshake | Implemented, shipped in iOS TestFlight build | construct-core/src/crypto/handshake/x3dh.rs |
| PQXDH (ML-KEM-768) extension | Implemented, opt-in via Suite 2 flag, shipped | construct-core/src/crypto/pq_x3dh.rs |
| Double Ratchet | Implemented, including DH ratchet, skipped-key handling, AD v3 (with v2 fallback) | construct-core/src/crypto/messaging/double_ratchet/ |
| PKCS#7 length padding (mod 255) | Implemented, constant-time unpad | construct-core/src/traffic_protection/padding.rs |
| Session healing queue | Implemented, Keychain-backed | construct-core/src/orchestration/healing_queue.rs |
| ACK deduplication store | Implemented | construct-core/src/orchestration/ack_store.rs |
| PQ contribution store (deferred KEM ss) | Implemented (RAM), persistent layer pending | construct-core/src/orchestration/pq_contribution.rs |
| CFE binary envelope at FFI | Implemented, used by iOS / macOS / Android bindings | construct-core/src/cfe/ |
| WirePayload binary frame | Implemented | construct-core/src/wire_payload.rs |
| MLS group chat (RFC 9420) | Core present (OpenMLS, ciphersuite MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519), no shipping product surface; design documented in Chapter 12, not yet a normative interop spec | construct-core/src/group/ |
| Argon2id proof-of-work | Implemented | construct-core/src/pow.rs |
| Account recovery — BIP39 (12-word) + SLIP-39 social recovery | Implemented. BIP39 restores account access on a new device (recovery Ed25519 keypair, server holds only the public key); SLIP-39 threshold-restores the identity vault. See Chapter 11. | construct-core/src/crypto/social_recovery.rs; BIP39/BIP32 derivation in construct-core |
| Privacy Pass token issuance | Implemented (feature-gated) | construct-core/src/crypto/privacy_pass/ |
| Key transparency | Client verification implemented (RFC 6962 Merkle tree hash, inclusion + consistency proofs, STH signature) — server not yet publishing the log, so not live end-to-end. Design in Chapter 13. | construct-core/src/crypto/key_transparency.rs |
| ML-DSA-65 hybrid PQ signatures (Ed25519 + ML-DSA-65) | Implemented, opt-in via post-quantum feature flag | construct-crypto/src/pqc/hybrid.rs; server wire-up in construct-server/e2e.rs:318 |
| Sealed sender | Implemented and on by default. All outgoing user traffic — messages, delivery receipts, call signalling, and the session-control handshake (session_ready / tie-break ping / SESSION_RESET_INIT / END_SESSION) — is sealed, leaving no sender_id on the outer envelope. Identified-downgrade paths (retries, seal-failure, control channel) are closed and fail-closed: stealth-on ⇒ a send is sealed or queued, never emitted identified. | server messaging-service/src/envelope.rs; client policy construct-ios Services/StealthPolicy.swift:42; control-channel sealing construct-ios Services/Session/SessionCoordinator.swift (sendSessionControlCore) + Networking/gRPC/Services/MessagingServiceClient.swift (sendEndSession, buildEnvelope) |
| Client IP minimisation | Implemented — the server never stores a raw client IP; anti-abuse rate-limit keys and logs use a salted one-way hash (hash_client_ip) of the address. Honest limit: a salted hash of the small IPv4 space is not perfectly anonymous against a salt-holder. | construct-utils/src/lib.rs:92; applied in construct-user-service/src/account.rs:140, construct-auth-service/src/devices.rs:292 |
| Privacy Pass token enforcement | Warn mode in production (MSG_STEALTH_TOKEN_POLICY=warn); tokens are issued, attached to sealed sends, and redemption-validated, but a failed/absent token does not block delivery. enforce is deferred past 1.0 (needs verifiable-VOPRF client + soak). | messaging-service/src/envelope.rs; construct-core/src/crypto/privacy_pass/ |
| Federation (S2S) | Implemented — inbound + outbound sealed delivery, Ed25519-signed, per-origin rate-limited. Multi-node interoperability test outstanding. | messaging-service/src/federation.rs |
| QUIC / HTTP-3 transport | In production (plain QUIC); HTTP/2 fallback; per-packet obfuscation demoted to debug-only. | construct-transport |
| Direct P2P delivery | Not implemented. All traffic via server. | — |
| Formal verification (Kani / Prusti) | Not started. | — |
| External cryptographic audit | Not performed. | — |
7.2 Platform matrix
| Platform | Build target | Status |
|---|---|---|
| iOS device | aarch64-apple-ios | Production-quality code, shipped via TestFlight beta. No public App Store release. |
| iOS simulator | aarch64-apple-ios-sim | Builds and tests pass. |
| macOS | aarch64-apple-darwin, x86_64-apple-darwin | Builds and runs; mid-migration from direct construct-core to a construct-engine-mediated path. |
| Android | aarch64-linux-android, armv7-linux-androideabi, x86_64-linux-android | construct-core cross-compiles cleanly; UniFFI bindings regenerated. No Kotlin VEIL surface yet (Phase 0). |
| Desktop (Linux / Windows) | native | CLI tools only; no application client. |
| Web (WASM) | — | Planned, not started. |
7.3 Open security issues
The reference tracks these in TODO.md. They are listed here so a
reader can decide whether the current build is adequate for a given
threat model.
| Tag | Severity | Description | Target fix |
|---|---|---|---|
BS-3 | High | If no X25519 OPK is available at handshake time, the protocol falls back to 3-DH silently. Forward secrecy from the OPK contribution is lost; the user is not notified. | Surface a warning to the application layer; consider hard-failing in Suite 2 when both OPK and Kyber-OPK are unavailable. |
BS-6 | High | The deferred PQ shared secret kem_ss is held in RAM only between "first message sent" and "PQ contribution applied at RK₁". An app crash in that window silently downgrades the session to classical-only. | Persist kem_ss to Keychain (a la RustPQContributions's in-memory store, but with disk backing). |
SEC-005 | Medium | SPK_MAX_AGE_SECS is 10 days; the SPK rotation policy itself rotates at 7 days. The 3-day overlap window weakens forward secrecy by extending the SPK validity. | Align rotation interval (rotate at 7 days, accept up to 10) and add a stale-SPK alert at the 8-day mark. |
SEC-006 | Medium | The AD v2 → v3 graceful migration uses a fallback decrypt path. Once no v2 messages can be in flight, the fallback SHOULD be removed. | Remove AD_VERSION_PREV fallback after the in-flight window passes. |
SEC-009 | Low | Session JSON (containing dh_ratchet_private, RK, chain keys) is stored in the platform key store but without an additional encryption-at-rest layer. If the OS key store is compromised, an attacker reads the session state. | Add wrap-with-master-key at the session-export boundary. |
Items listed as "Out of scope" in Chapter 1 §1.2 are NOT on this list — they are not bugs, they are explicit non-goals.
7.4 Test coverage
The reference includes:
| Test suite | Scope |
|---|---|
Unit tests (cargo test) | Per-module: KDF labels, AD construction, padding edge cases, wire-payload round-trips, ratchet step invariants. |
Integration tests (tests/) | Cross-module: full X3DH handshake + first message exchange; PQ contribution apply; session healing round-trip; CFE envelope corner cases. |
| Cross-platform fixture tests | iOS bridging-header parity, UniFFI binding generation. |
| Property / fuzz tests | WirePayload decoder fuzz target (no panic on arbitrary bytes). |
| Cross-reference vectors | Cryptographic library outputs cross-checked against published test vectors (NIST FIPS 203 for ML-KEM, RFC 8032 for Ed25519). |
What is not covered today:
- End-to-end multi-device tests (no multi-device support yet).
- Multi-node (two-VPS) federation interoperability test — federation is implemented with contract-level tests, but the two-server integration test is outstanding.
- Formal verification (planned, not done).
- Adversarial / red-team testing by an external party.
7.5 Reproducibility
The reference is built from the commit indicated in the version metadata of the released artefact. To reproduce a build:
git clone https://github.com/konstruct-msg/construct-core
cd construct-core
# iOS staticlib:
./build_crypto_lib.sh --all # see construct-ios repo for the wrapper
# Android shared library:
cd ../construct-android
./build_crypto_lib.sh --all
The Cargo.lock checked into the repository pins every transitive
dependency, so two builds from the same commit will produce binaries
that differ only in compiler-version-dependent ways (debug info,
timestamp markers).
7.6 Versioning policy
This specification follows Semantic Versioning at the document level:
- MAJOR — a wire-incompatible protocol change.
- MINOR — a backwards-compatible normative addition (new field, new optional behaviour).
- PATCH — editorial corrections that do not change implementer obligations.
The implementation's own versioning is separate; see the
construct-core Cargo.toml for the crate version.
7.7 References
- Open-issue tracker (internal):
construct-core/TODO.md. - Disclosure & contact: Disclosure.
- Document changelog: Changelog.
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
A sealed message replaces the normal Envelope sender field with a
SealedSenderEnvelope. The wire structures are defined normatively in
shared/proto/core/envelope.proto (message SealedSenderEnvelope,
envelope.proto:359; SealedInner, envelope.proto:380).
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
priority : MessagePriority
ttl : uint32
token_nonce : bytes // Privacy Pass (optional, §8.5)
token_bytes : bytes
}
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, thecontent_type(message kind — needed to compose the push notification and set priority), thedelivery_tag(for replay suppression), and the opaqueencrypted_payload. 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:390).
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 outer-envelope masking is applied in construct-ios
Networking/gRPC/Services/MessagingServiceClient.swift (buildEnvelope):
when a SealedInner is present it sets sealed_sender and omits sender,
conversation_id, and the real content_type from the outer Envelope.
Server-side reconstruction and the sender-hiding path are in
messaging-service/src/envelope.rs:38 (if envelope.is_sealed_sender { … }).
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
}
The certificate is issued and signed by the sender's home server
(identity-service) with the same Ed25519 key published at
.well-known/konstruct. The signature covers the direct big-endian
concatenation sender_user_id || sender_domain || sender_identity_key || sender_device_id || issued_at || expires_at — one canonical format, pinned
in stealth-sealed-sender v2 Phase 3 (server
identity-service::build_sender_cert_sign_payload; client construct-ios
Security/StealthSenderService.swift buildCertPayload).
The recipient attests the sender through one of two levers, strongest
first (SenderVouchBasis in StealthSenderService.swift):
- 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 certificate's Ed25519 signature validates against the sender's home-server public key (fetched or pinned). Used on first contact, before a local KT key exists.
Delivery is not gated on attestation. If neither lever vouches (missing
or stale bundle 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:42
isEnabled, :71 shouldUseSealedSender). DEBUG builds keep a developer
override for exercising the legacy identified path.
Invariant (fail-closed): while stealth is on, every message send is
sealed or queued — it is never emitted identified. Identified sends are
legal only when shouldUseSealedSender() is false. 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).
- The session-control handshake:
session_ready, the tie-break ping,SESSION_RESET_INIT, andEND_SESSION. With all user traffic sealed, these directed control messages were the primary remaining cleartextsender → recipientsignal, so they are sealed too (clientServices/Session/SessionCoordinator.swiftsendSessionControlCore,MessagingServiceClient.swiftsendEndSession).
Traffic deliberately excluded (identified, by decision — the leak is low-value or the frequency/cost is high):
- End-to-end heartbeats (
content_type = 13). - 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 (envelope.proto:409),
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) | Only inside the recipient-encrypted certificate. |
| Recipient identity + timing | Yes | Required to deliver; SealedInner.recipient_user_id. |
Message kind (content_type) | Yes | Needed for notification text / priority. |
| Ciphertext size after padding, volume | Yes | Padding buckets blunt but do not erase this. |
| 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.
Anti-Abuse: Privacy Pass Tokens
Sealed sender (Chapter 8) removes the sender's identity from what a server can read. That also removes the server's usual per-sender abuse lever: it can no longer rate-limit "this account is flooding" because it does not know which account sent a sealed message. Konstruct restores an abuse lever without re-introducing an identity — anonymous, single-use Privacy Pass tokens. This chapter specifies the verifiable Oblivious Pseudo-Random Function (VOPRF) they are built on, the issuance and redemption flows, and — the security-critical part — the zero-knowledge proof that stops a malicious issuer from turning the tokens themselves into a de-anonymising tag.
9.1 Design requirement
A token must satisfy two properties at once:
- Unlinkable: the server that redeems a token cannot connect it to the issuance event that minted it, nor to the account that requested it.
- Unforgeable & one-time: only the server's secret issuer key can mint a valid token, and each token spends exactly once.
A blind VOPRF gives both: the client blinds its token material before issuance, so the issuer signs something it cannot read; the client then unblinds to a value only it holds; redemption re-derives and checks that value. Double-spend is caught by a server-side seen-set.
9.2 The VOPRF construction
- Group: Ristretto255;
G = RISTRETTO_BASEPOINT_POINT. Points are 32-byte canonical compressed encodings; scalars are 32-byte canonical little-endian. - Hash-to-group:
H(x) = RistrettoPoint::from_hash(SHA-512(x)). - Issuer secret: a scalar
k; its public commitment isK = k·G(§9.5).
Let nonce be 32 random bytes chosen by the client, and r a random
blinding scalar.
Client blind: T = H(nonce); B = r·T → send B
Server evaluate: Z = k·B → return Z (+ DLEQ proof, §9.5)
Client unblind: N = r⁻¹·Z = k·T
Token: token = HKDF-SHA512( compress(N) ‖ nonce, info="ConstructPP-v1" )[0..32]
The client stores (token, nonce) in a local wallet and later presents the
pair when spending. At redemption the server recomputes the same value from
nonce and its own k:
verify: N' = k·H(nonce); token' = HKDF-SHA512( compress(N') ‖ nonce, "ConstructPP-v1" );
accept iff constant_time_eq(token, token')
Reference: redemption verify_token
(construct-crypto/src/privacy_pass.rs:174); token derivation
derive_token (:158); the k is from_bytes_mod_order-reduced
identically on both sides (:174 comment) — a mismatch there issues fine
but fails every redemption.
9.3 Issuance and issuance caps
Tokens are minted by identity-service issue_tokens
(identity-service/src/main.rs:907). The request carries the blinded
points Bᵢ; the response returns the evaluations Zᵢ = k·Bᵢ, the issuer
commitment K (server_pubkey, proto field 2), the batched DLEQ proof
(field 3), and issuer_key_version (field 4). Because the points are
blinded, the issuer cannot read the token material it signs.
Anti-abuse at issuance is an age-tiered per-account hourly cap
(effective_issuance_cap, identity-service/src/main.rs:65, applied
:952): a young account (below TOKEN_ISSUANCE_MATURITY_HOURS, default
24 h) is capped at TOKEN_ISSUANCE_YOUNG_MAX_PER_HOUR (default 30); a
matured account at TOKEN_ISSUANCE_MAX_PER_HOUR (default 120). An
account-age lookup failure fails safe to the young cap. The client wallet
tops up reactively toward the cap as it spends and bootstraps an initial
batch at registration.
9.4 Redemption, double-spend, and policy
A sealed send MAY carry the spent token in SealedInner.token_nonce /
SealedInner.token_bytes (Chapter 8 §8.2). The
token bytes are themselves sealed to the destination server's X25519 key
(open_sealed_token_bytes, construct-crypto/src/privacy_pass.rs:107;
client seals with the key delivered over the authenticated
GetSenderCertificateResponse), so a relay operator cannot read the spent
token in transit.
The server redeems via redeem_token_checked
(messaging-service/src/envelope.rs:208), which runs verify_token and a
single-use check (a Redis seen-set keyed by the token, SET NX). Outcomes
map to a typed TokenRejected error (envelope.rs:18) with a
FAILED_PRECONDITION "privacy_pass:{label}" status (labels:
missing_token / invalid_token / double_spent / decrypt_failed / …).
Enforcement is governed by MSG_STEALTH_TOKEN_POLICY
(envelope.rs:192), a three-way switch:
| Policy | Behaviour |
|---|---|
off | tokens ignored. |
warn | current production — tokens verified and the result logged, but a bad/absent token does not block delivery. Anti-abuse degraded, anonymity intact. |
enforce | a bad/absent token fails the send with the typed error. Deferred past 1.0 (§9.6). |
The client's response to an enforce rejection is never to fall back to
an identified send — it force-replenishes the wallet, rebuilds the sealed
envelope with a fresh token, and retries the sealed path once
(StealthSendRecovery.swift; the Chapter 8 §8.4
fail-closed invariant). A server that could force identified sends by
rejecting tokens could otherwise deanonymise on demand.
9.5 Verifiable issuance — batched DLEQ
Blinding hides the token from an honest-but-curious issuer. It does not
by itself stop a malicious issuer from a key-tagging attack: a
compromised server could evaluate a targeted user under a unique per-user
secret kᵤ while still publishing K = k·G, silently marking that user's
tokens so they de-anonymise the sealed sender at redemption.
The defence is a batched Chaum–Pedersen DLEQ proof returned with each
issuance: a non-interactive zero-knowledge proof that every Zᵢ was
evaluated with the same scalar k whose commitment K = k·G is published.
A per-user kᵤ produces a proof that fails, so the client rejects the
tokens instead of unknowingly carrying a tag.
The commitment is published at /.well-known/construct-server as
token_issuer_public (= K) and token_issuer_key_version. The client
pins K per version and verifies each response's proof against the
pinned K — it must not trust the server_pubkey echoed in the
response as the commitment, which would defeat the purpose.
The transcript is a client-parity contract — iOS and Android reimplement
verification byte-for-byte; any change is a flag-day break (bump the domain
string). Full spec: construct-docs cryptocore/privacy-pass-dleq-v1.md;
reference construct-crypto/src/privacy_pass.rs (DLEQ_DOMAIN:195,
issuer_public_key:221, generate_dleq_proof:274, verify_dleq_proof:313).
DOMAIN = "ConstructPP-DLEQ-v1"(ASCII, 19 bytes). Context tags:0x00seed,0x01coefficient,0x02nonce,0x03challenge.- Hash-to-scalar: SHA-512 wide reduction over the concatenated byte-strings.
For a batch (Bᵢ, Zᵢ), i = 0..n, in request order:
Composites (prover and verifier, identical):
seed = SHA512( DOMAIN ‖ 0x00 ‖ K ‖ (B_0‖Z_0) ‖ … ‖ (B_{n-1}‖Z_{n-1}) )
d_i = SHA512→scalar( DOMAIN ‖ 0x01 ‖ seed ‖ u32_be(i) ‖ B_i ‖ Z_i )
M = Σ_i d_i·B_i , Zc = Σ_i d_i·Z_i
Prove (server; needs k):
t = SHA512→scalar( DOMAIN ‖ 0x02 ‖ k ‖ M ‖ Zc ) // deterministic nonce
c = SHA512→scalar( DOMAIN ‖ 0x03 ‖ K ‖ M ‖ Zc ‖ t·G ‖ t·M )
s = t + c·k ; proof = c ‖ s // 64 bytes
Verify (client / auditor):
A1' = s·G − c·K , A2' = s·M − c·Zc
c' = SHA512→scalar( DOMAIN ‖ 0x03 ‖ K ‖ M ‖ Zc ‖ A1' ‖ A2' )
accept iff c' == c (constant-time)
The deterministic nonce t = H(DOMAIN ‖ 0x02 ‖ k ‖ M ‖ Zc) binds the secret
k and the statement (M, Zc) (which never repeats across distinct
batches), so no RNG is used and there is no nonce-reuse-leaks-k failure
mode. A known-answer test vector pins the whole transcript
(privacy_pass::tests::dleq_kat_vector; proof prefix
a5fc4353…).
9.6 Status and honest limits
| Piece | Status |
|---|---|
VOPRF issuance + redemption (verify_token) | Implemented, in production. |
| Age-tiered issuance cap | Implemented (identity-service/src/main.rs:65). |
Token enforcement (MSG_STEALTH_TOKEN_POLICY) | warn in production; enforce deferred past 1.0. |
| DLEQ proof — server issuance | Implemented (construct-crypto/src/privacy_pass.rs). |
DLEQ verification — client (iOS pinned K v1) | Implemented and device-confirmed. |
Well-known publication of token_issuer_public | Operational step to fully activate client pinning across versions. |
Claim boundary. Until token enforcement is on enforce and every
client hard-verifies the DLEQ against a pinned commitment, Konstruct claims
sender-unlinkability against an honest-but-curious server, not against a
malicious/compromised one. The DLEQ machinery is what upgrades that
claim; it is shipped on the server and verified on iOS, but the end-to-end
guarantee is only load-bearing once enforce relies on it. Marketing and
threat-model text must not over-claim past this line — see
Threat Model.
9.7 References
construct-crypto/src/privacy_pass.rs— VOPRF (verify_token:174,derive_token:158,open_sealed_token_bytes:107) and DLEQ (DLEQ_DOMAIN:195,issuer_public_key:221,generate_dleq_proof:274,verify_dleq_proof:313).identity-service/src/main.rs— issuance (issue_tokens:907) and caps (effective_issuance_cap:65).messaging-service/src/envelope.rs— redemption + policy (redeem_token_checked:208,TokenRejected:18, policy switch:192).- Client (
construct-ios):BlindTokenService,StealthSendRecovery.swift. - Contract / rationale (construct-docs):
cryptocore/privacy-pass-dleq-v1.md;decisions/stealth-phase-c-verifiable-voprf,sealed-sender-anti-abuse-economics.
Voice and Video Calls
Konstruct carries 1:1 real-time calls over WebRTC. This chapter specifies how call setup is protected by the same end-to-end cryptography as chat, and how the call media is encrypted between the two devices — and states honestly what the connectivity layer still exposes.
Status: audio 1:1 is implemented; video is wired through the
call-entry UI but the media layer is not yet enabled
(construct-ios Services/Calls/CallsFeature.swift, isVideoEnabled = false). Group calls / SFU are out of scope (Chapter 7).
10.1 Two planes: signalling and media
A WebRTC call has two data planes:
- the signalling plane — the SDP offer/answer that negotiates codecs and keys, and the ICE candidates that discover a network path;
- the media plane — the actual encrypted audio/video (SRTP).
Konstruct's property is that the signalling plane rides the existing end-to-end-encrypted message path, and because the media keys are agreed inside that signalling, the media plane is end-to-end encrypted between the two devices too.
10.2 Signalling over the E2EE path
SDP offers/answers and ICE candidates are not sent to the server in the
clear. They are encrypted with the peer's Double Ratchet session
(Chapter 5) as call-signal messages
(content_type = 12) and are sealed like any other user traffic
(Chapter 8) — so the server can read neither
the SDP (which carries the DTLS fingerprints and ICE ufrag/pwd) nor the
sender identity. The client-side framing that carries the suite id and PQ
ratchet fields for a call signal is CallSignalCrypto (ENC:v3 frame);
orchestration is in Services/Calls/CallManager.swift.
Two delivery routes exist, both E2EE:
- the message path — the first offer is typically delivered E2EE ahead of the answer (a backgrounded callee is woken by a PushKit VoIP token that carries no call content);
- a low-latency signal stream (
SignalingService.Signal, opened viaSignalingServiceClient) for real-time exchange once both peers are live. The server relays these frames without being able to decrypt them.
ICE candidate batching. ICE candidates are flushed in size-bounded
batches to stay under the signalling rate limit and, critically, under the
padded-frame size cap — Suite-3 PQ blobs per candidate can otherwise
overflow a single frame and drop the whole batch, so the client splits a
large flush into chunks (CallManager.swift,
Shared_Proto_Signaling_V1_IceCandidate).
TURN credentials for relayed connectivity are fetched per call
(SignalingServiceClient.getTurnCredentials).
10.3 Media encryption
Call media uses WebRTC's standard DTLS-SRTP. The DTLS handshake that establishes the SRTP keys authenticates itself with certificate fingerprints — and those fingerprints are exchanged inside the SDP, which travelled over the E2EE signalling channel above. An active network attacker therefore cannot substitute its own DTLS fingerprints (that would require forging an E2EE-authenticated SDP), so for a 1:1 call the media is end-to-end encrypted between the two devices. No application-layer media re-encryption (e.g. SFrame) is required for the two-party case; SFrame would only be needed for a future group/SFU topology where a server forwards media.
Neither the Konstruct server nor a TURN relay used for NAT traversal can decrypt the media: a TURN relay forwards only opaque SRTP.
10.4 What the connectivity layer still exposes
Honest counterpart to §10.1 — even with signalling sealed and media DTLS-SRTP-encrypted:
| Exposure | Note |
|---|---|
| That a call is being set up, and its timing | The signal exchange and TURN-credential fetch are observable as events (sealed, but present). |
| Participants' network addresses | ICE exchanges candidate IP:port pairs so the devices can find a path; a TURN relay sees both peers' addresses. This is connection metadata, not call content. |
| Media timing / volume | Inherent to real-time media; not hidden. |
| Call content (audio/video) | Not exposed — DTLS-SRTP, keyed via E2EE signalling. |
Because ICE reveals network addresses to establish direct connectivity, a privacy-maximising user who wants to hide their address from the peer or a relay should force TURN-relayed connectivity (so only the relay's address is shared with the peer) or route through a network-layer anonymiser; this is a connectivity/anonymity trade-off, not a content-confidentiality one.
10.5 References
- Client (
construct-ios):Services/Calls/CallManager.swift,Services/Calls/CallSignalCrypto.swift,Services/Calls/WebRTCSession.swift,Services/Calls/CallsFeature.swift;Networking/gRPC/Services/SignalingServiceClient.swift. - Protos:
konstruct-msg/construct-protossignalingpackage (IceCandidate,Signal). - Sealing of call signals: Chapter 8; message encryption: Chapter 5.
- Design detail (construct-docs):
client/specs/CALLS_CLIENT_SPEC.md.
Account Recovery
Konstruct has no password and no email, so the usual "reset via email" path does not exist. Recovery is key-based and comes in two independent mechanisms with different goals: a 12-word BIP39 phrase that restores account access on a new device, and SLIP-39 social recovery that threshold-restores the identity vault itself. Both are implemented in the shared Rust core so every client behaves identically.
Throughout: the recovery secrets never leave the device to the server in the clear. The server can only ever hold a recovery public key — there is no plaintext key escrow.
11.1 BIP39 seed-phrase account recovery
11.1.1 Derivation
A recovery phrase is 12 BIP39 words = 128 bits of entropy. 12 (not 24)
is deliberate: 128 bits already matches the Ed25519 security level that the
derived key targets, so 24 words would add UX cost for no cryptographic
gain (client/specs/ACCOUNT_RECOVERY_CLIENT_SPEC.md).
seed_phrase (12 BIP39 words)
→ mnemonic_to_seed() -- PBKDF2, 2048 rounds, no passphrase → 64-byte seed
→ BIP32 derive m/44'/0'/0'/0/0
→ Ed25519 (recovery_private_key, recovery_public_key) -- 32 B each
The core exposes generate_mnemonic, validate_mnemonic,
mnemonic_to_seed, derive_recovery_keypair, and
sign_recovery_challenge / verify_recovery_signature
(construct-core BIP39/BIP32 + ed25519-dalek).
11.1.2 Setup and recovery flows
Recovery is optional and set up separately from registration (recommended UX: prompt after first registration):
- Setup — the client derives the recovery keypair, signs
"CONSTRUCT_RECOVERY_SETUP:{userId}:{timestamp}"with the recovery private key, and callsSetRecoveryKeywith the recovery public key plus the signature. The server stores only the public key. - Recover (new device) — the user enters the 12 words (checksum
validated locally first); the client re-derives the recovery keypair,
signs a fresh challenge, generates new device identity keys, and
calls
RecoverAccount. The server verifies the signature against the stored recovery public key and re-associates the account (userId) with the new device.
11.1.3 What it restores — and what it does not
- Restores: access to the same account identifier (
userId) on a new device, provided the user holds the 12 words. - Does not restore: the previous device's Double Ratchet sessions or local message history. The new device registers fresh identity keys, so contacts observe an identity-key change and sessions re-establish (their clients surface this as expected). Recovery is account re-access, not a cryptographic clone of the lost device.
- Trust note: possession of the 12 words is sufficient to re-take the account. The phrase MUST be stored as carefully as any root credential; §11.2 exists precisely to remove the single-phrase single point of failure for users who want that.
11.2 SLIP-39 social recovery (vault)
Social recovery targets a different goal: threshold recovery of an identity
vault key across several trustees or locations, so that no single share
— and no single lost phrase — can either recover or deny recovery of the
identity. Implemented in construct-core/src/crypto/social_recovery.rs.
11.2.1 Construction
- A 32-byte
vault_keyprotects the identity/backup bundle. vault_keyis split with Shamir Secret Sharing over GF(2⁸) intoshare_countshares requiring athresholdto reconstruct; both are in2..=10withthreshold ≤ share_count(split_secret).- Each share (a 1-based index + 32 bytes + checksum) is encoded as a 28-word SLIP-39 mnemonic over a 1024-word wordlist (10 bits/word).
- Recovery: the user supplies ≥ threshold mnemonics; the core
validates each share checksum and reconstructs
vault_keyby Lagrange interpolation (combine_shares), then decrypts the bundle.
Because it is t-of-n, losing up to n − t shares still recovers, and
gaining fewer than t shares reveals nothing about vault_key.
11.3 Privacy and trust properties
| Property | BIP39 (11.1) | SLIP-39 (11.2) |
|---|---|---|
| Secret leaves device to server? | No — server stores only the recovery public key. | No — shares are held by the user/trustees; the vault ciphertext, if backed up, is opaque. |
| Single point of failure | Yes — one 12-word phrase. | No — t-of-n threshold. |
| Restores | Account access (new device keys) | Identity vault (the key material itself) |
| Server can recover the account alone? | No — needs the recovery private key it never holds. | No — needs ≥ t shares it never holds. |
Honest limits: neither mechanism can help a user who loses all recovery material — there is no server-side backdoor by design. The BIP39 phrase is a bearer credential (whoever holds it can re-take the account), which is why social recovery is offered for users who prefer distributed trust over a single phrase.
11.4 References
construct-core/src/crypto/social_recovery.rs— SLIP-39 Shamir split / combine, 28-word encoding.- construct-core BIP39/BIP32 recovery-keypair derivation
(
generate_mnemonic,mnemonic_to_seed,derive_recovery_keypair,sign_recovery_challenge). client/specs/ACCOUNT_RECOVERY_CLIENT_SPEC.md— flows, RPC surface (SetRecoveryKey,RecoverAccount), derivation-path rationale.
Group Messaging (MLS)
Status: designed, core implemented, not a shipped feature. The cryptographic group engine exists in
construct-core/src/group/(built on OpenMLS), but there is no group-chat product surface in the shipping clients, and this chapter is not yet a normative interoperability specification. It documents the design and the current implementation boundary so the direction is on record; treat every statement as "designed / partial", not "in production". The 1:1 protocol (Chapters 4–5) is unaffected.
12.1 Why MLS rather than pairwise fan-out
The 1:1 protocol (Chapter 5) gives two-party
forward secrecy and post-compromise security via the Double Ratchet.
Extending that to groups by encrypting separately to each member (pairwise
fan-out) costs O(n) per message and has no group-level post-compromise
security. Konstruct's group design instead uses MLS — the Messaging Layer
Security protocol, RFC 9420 —
which maintains a shared group secret through a logarithmic-cost ratchet
tree, so adding/removing a member and healing after a compromise are
O(log n) operations.
12.2 Ciphersuite and library
- MLS ciphersuite:
MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519(construct-core/src/group/mod.rs) — DHKEM-X25519 for the ratchet-tree HPKE, AES-128-GCM as the group AEAD, SHA-256 as the hash, Ed25519 for signatures. This is a standard RFC 9420 ciphersuite; interoperability is a design goal. - Implementation: the OpenMLS Rust library with the
OpenMlsRustCryptoprovider, wrapped byconstruct-core'sgroupmodule. Konstruct does not re-implement the MLS state machine.
Post-quantum note: this ciphersuite is classical (X25519/Ed25519). A hybrid PQ MLS ciphersuite is future work and is not part of the current design.
12.3 Group state model
MLS advances a group through epochs. Membership changes and key updates
are carried as Proposals that are applied by a Commit; a new member
is bootstrapped with a Welcome message. The core surfaces exactly these
concepts (construct-core/src/group/mls_error.rs: EpochMismatch,
WelcomeError, CommitError):
- KeyPackage — a member publishes a signed key package (an X25519 HPKE init key + Ed25519 credential) that others use to add it.
- Welcome — joining material for a newly added member; processed via
join_from_welcome. - Commit — applies a batch of proposals and advances the epoch; a client
whose local epoch is behind MUST pull pending commits before sending
(
EpochMismatch→ "pull pending commits first").
The server orders and fans out these handshake messages but, as in the 1:1 case, cannot derive the group secret — it never holds the members' HPKE private keys.
12.4 Device store and persistence
OpenMLS persists all group state (ratchet-tree material, pending key
packages, epoch secrets) through its StorageProvider. Konstruct uses
one long-lived provider per device — MlsStore
(construct-core/src/group/mls_store.rs) — rather than a throwaway provider
per group, because a Welcome references key-package material that must
already be in the same store that later loads the group by id. The whole
store is exported for host-app persistence as a versioned CFE blob
(CfeMlsStoreV1, msg_type = 0x44; CFE envelope per
§6.3).
12.5 Implementation boundary
| Piece | Status |
|---|---|
| MLS engine (OpenMLS wrapper, ciphersuite, device store) | Implemented in construct-core/src/group/. |
| Group create / add / remove / commit / welcome plumbing | Present at the core API level. |
| Server group service (ordering, fan-out, key-package directory) | Partial / not covered here. |
| Client group-chat product surface (UI, notifications, membership UX) | Not shipped. |
| Normative interop spec (wire messages, directory RPCs) | Not written — this chapter is descriptive only. |
| Hybrid PQ group ciphersuite | Future work. |
Until the interop spec and the shipping surface exist, a second implementation cannot target Konstruct groups from this document alone. That work is tracked as a future revision; see Implementation Status.
Key Transparency
Status: client verification implemented, not yet live end-to-end. The RFC 6962 proof-verification primitives exist and are tested in
construct-core/src/crypto/key_transparency.rs, but the server does not yet publish the transparency log (Signed Tree Heads + proofs), so the end-to-end guarantee below is designed and client-ready, not in production. This chapter records the design and the exact implementation boundary.
13.1 The problem it solves
End-to-end encryption authenticates messages to a key, but a client still has to trust that the identity key it fetched for a contact is really that contact's key. A malicious or compromised directory server could hand out a key it controls (a key-substitution / MITM attack). Fingerprint comparison ("safety numbers") defends against this but depends on users manually comparing out of band.
Key Transparency (KT) makes the directory accountable instead: every identity key the server ever vouches for is committed to a public, append-only log, and clients cryptographically verify that the key they were given is in that log and that the log has never been rewritten. A server that equivocates — showing one key to the victim and another to everyone else — is caught, because it cannot produce consistent proofs against a single published log.
13.2 Construction (RFC 6962)
Konstruct's log follows RFC 6962 §2 (the Certificate Transparency Merkle tree), reused for identity keys:
- Leaf. Each device registration appends a leaf hashing the pair
(device_id, identity_public_key)—kt_hash_leaf(device_id, identity_key_b64). - Tree head. The Merkle Tree Hash (
merkle_tree_hash/kt_compute_root) reduces all leaves to a single root. The server publishes a Signed Tree Head (STH) = Ed25519(tree_size ‖ root_hash). - Inclusion proof (RFC 6962 §2.1.3,
kt_verify_inclusion): proves a specific(device_id, identity_key)leaf is present at a given index in a tree of a given size and root. - Consistency proof (
kt_verify_consistency): proves that a newer tree is an append-only extension of an older one — the log grew, and no earlier entry was altered or removed.
13.3 What a client verifies
When live, a conforming client MUST, for a contact's identity key:
- Inclusion — verify the received identity key is a leaf in the log under the current STH. A key that is not in the log is not trusted as KT-verified.
- Consistency — verify each new STH is consistent with the last STH the client saw, so the server cannot silently rewrite history or fork the log between checks.
Only a key that passes both is marked KT-verified. This is the strongest
of the sender-attestation levers used by sealed sender (the .kt lever,
Chapter 8 §8.3):
a sealed sender whose certificate key matches a locally stored KT-verified
key is vouched with no dependency on any freshly fetched server key.
13.4 Implementation boundary
| Piece | Status |
|---|---|
| RFC 6962 Merkle tree hash, inclusion + consistency verification | Implemented (key_transparency.rs, unit-tested). |
| Client STH signature check (Ed25519) | Implemented primitive. |
| Server-published append-only log + STH endpoint | Not yet deployed. |
| Gossip / auditor cross-checking of STHs | Not designed here — a full KT deployment also needs out-of-band STH gossip to detect a server that forks the log per-client; that is future work. |
Honest limit. Until the server publishes the log and clients gossip STHs, KT provides no live guarantee — key trust today rests on trust-on-first-use pinning plus the certificate-signature lever (Chapter 8). KT is the mechanism that upgrades that from "trust the server the first time" to "the server cannot lie about a key without being caught"; the cryptographic verifier is ready, the deployment is the remaining work. See Implementation Status.
13.5 References
construct-core/src/crypto/key_transparency.rs—kt_hash_leaf,kt_compute_root,kt_verify_inclusion,kt_verify_consistency,merkle_tree_hash.- RFC 6962 — Certificate Transparency Merkle tree (the reused construction).
- Sender-attestation use of KT-verified keys: Chapter 8 §8.3.
Appendix A — Error Registry
This appendix is the canonical registry of every error that a
conforming Konstruct client can observe, organised by the layer that
produces it. Each entry cites the exact variant in construct-core
(or, where noted, the sibling construct-veil crate) and records the
condition that raises it.
Keywords MUST, MUST NOT, SHOULD, MAY are per RFC 2119.
A.1 Layered error model
Errors in Konstruct travel up a stack of progressively narrower surfaces. An error condition that originates deep inside the cryptographic core is converted at each layer boundary into a variant appropriate for the consumer at that layer:
application (iOS / macOS / Android UI)
▲
│ uniffi_bindings::CryptoError (FFI surface, §A.2)
────────────── ┼ ───────────────────────────────────────────────
│ cfe::CfeError (envelope, §A.3)
│ wire_payload::WirePayloadError (framing, §A.4)
│ traffic_protection::PaddingError (padding, §A.5)
│
│ error::CryptoError (internal, §A.6)
│ utils::ConstructError (top-level, §A.7)
│ group::mls_error::MlsError (groups, §A.8)
▲
Rust core
The dividing line at "FFI surface" is normatively significant: only
the variants in §A.2 cross the UniFFI / JNI boundary by name. All
deeper error types are mapped into SessionInitializationFailed,
EncryptionFailed, or DecryptionFailed with a human-readable
message field. Application code MUST NOT attempt to parse those
message strings — they are diagnostic only.
The transport tier (Chapter 6 §6.5)
has its own error surface in the separate construct-veil crate; it
is included as §A.9 for completeness but is informational rather than
normative for the core protocol.
Wire-level errors (server → client) flow as standard gRPC status codes and are covered in §A.10.
A.1.1 Stability policy
| Surface | Stability |
|---|---|
| §A.2 FFI surface | Stable. Variants MUST NOT be reordered or repurposed. New variants are MINOR additions. |
| §A.3 CFE envelope | Stable. The wire format is fixed; new validation errors are MINOR additions. |
| §A.4 WirePayload | Stable. Header layout is normative. |
| §A.5 Padding | Stable. |
| §A.6–A.8 Internal | Unstable. Variant names and counts MAY change between releases. Catch the FFI surface (§A.2) instead. |
| §A.9 VEIL | Unstable. Tracked in the construct-veil crate. |
| §A.10 gRPC | Inherited from gRPC; semantics MUST follow the gRPC spec. |
A.2 FFI surface — CryptoError
The application-facing error type exposed across UniFFI / JNI.
Defined in construct-core/src/uniffi_bindings.rs:30.
| Tag | Variant | Raised when | Recovery |
|---|---|---|---|
FFI-INIT | InitializationFailed | The core failed to initialise (e.g. RNG unavailable, keychain locked). | Surface to user; retry after unlock or restart. |
FFI-SESSION-NOT-FOUND | SessionNotFound | An operation referenced a session id that no session exists for in local state. | Trigger a fresh init_session for the peer. |
FFI-SESSION-INIT | SessionInitializationFailed { message } | X3DH / PQXDH handshake construction failed. Wraps a deeper error::CryptoError. | Surface and retry after fetching a fresh prekey bundle. |
FFI-ENCRYPT | EncryptionFailed { message } | RatchetEncrypt failed. Most commonly: session state corruption or AEAD allocation failure. | Surface; do not silently fall back. |
FFI-DECRYPT | DecryptionFailed { message } | RatchetDecrypt failed. Most commonly: bad AD, replayed message, skipped-key cache exhausted, or tampered ciphertext. | Apply healing flow (init_receiving_session retry) if msg_num == 0; otherwise END_SESSION. |
FFI-INVALID-KEY | InvalidKeyData | A key field had the wrong length or failed point validation. | Reject the message / bundle. |
FFI-INVALID-CT | InvalidCiphertext | A KEM ciphertext or AEAD frame failed structural validation. | Reject the message. |
FFI-SERIALIZE | SerializationFailed | A MessagePack encode failed for an outbound CFE payload. | Surface as internal error. |
FFI-MSGPACK-DESERIALIZE | MessagePackDeserializationFailed | A MessagePack decode failed on an incoming CFE payload. | Reject the envelope. |
FFI-SPK-STALE | PeerSpkStale { age_secs } | The peer's Signed Pre-Key exceeds SPK_MAX_AGE_SECS (10 days). | Wait for the peer to open their app and rotate, or surface to user. Do NOT proceed with X3DH against a stale SPK. |
FFI-SPK-STALE is the only variant with structured data that
applications MUST react to: it conveys the age_secs so that UI can
report "peer hasn't been online for N days" without re-deriving the
threshold.
A.3 CFE envelope errors — CfeError
Raised when parsing a CFE envelope at the FFI boundary. Defined in
construct-core/src/cfe/error.rs. Every entry below is a parse-time
or integrity failure and MUST cause the envelope to be rejected
before its payload is deserialised.
| Tag | Variant | Condition |
|---|---|---|
CFE-TOO-SHORT | TooShort { min, got } | Buffer is shorter than the 16-byte header. |
CFE-INVALID-MAGIC | InvalidMagic | First two bytes are not [0x43, 0x46]. |
CFE-LEGACY-JSON | LegacyJson | Buffer begins with { or [ — caller is on a pre-CFE path. |
CFE-UNSUP-VERSION | UnsupportedVersion(u8) | Version byte is not 0x01. |
CFE-UNKNOWN-TYPE | UnknownType(u8) | msg_type byte does not correspond to any known CfeMessageType variant. |
CFE-CRC-MISMATCH | ChecksumMismatch { stored, computed } | CRC-32 over `(header[crc=0] |
CFE-PAYLOAD-TOO-LARGE | PayloadTooLarge { max, got } | payload_len exceeds the implementation cap (reference: 256 KiB). |
CFE-TRUNCATED | TruncatedPayload { expected, got } | Buffer ends before payload_len bytes can be read. |
CFE-RESERVED-NONZERO | InvalidReservedBytes | The three reserved bytes are not [0x00, 0x00, 0x00]. |
CFE-UNSUP-FLAGS | UnsupportedFlags(u8) | flags byte is non-zero (no flags defined in v1). |
CFE-TYPE-MISMATCH | TypeMismatch { expected, got } | Decoder was invoked for a specific type but the envelope carries a different one. |
CFE-INVALID-FORMAT | InvalidFormat | Catch-all structural error (used by helpers that detect format violations beyond the schema). |
CFE-SERIALIZE | SerializeFailed(String) | MessagePack encoder rejected the payload (e.g. unsupported type). |
CFE-DESERIALIZE | DeserializeFailed(String) | MessagePack decoder rejected the payload. |
CFE-LEGACY-JSON-PARSE | LegacyJsonParseFailed(String) | A pre-CFE JSON payload could not be parsed even on the migration path. |
CFE-B64-DECODE | Base64DecodeFailed(String) | A base64-encoded field inside a payload failed to decode. Should NOT occur in current binary payloads. |
CFE-HEX-DECODE | HexDecodeFailed(String) | A hex-encoded field failed to decode. |
CFE-INVALID-FIELD | InvalidField(String) | A semantic field-level validation failed inside an otherwise well-formed payload. |
CFE-KDF-FAILED | KeyDerivationFailed(String) | A KDF step inside a CFE-wrapped operation failed. |
CFE-CRC-MISMATCH, CFE-PAYLOAD-TOO-LARGE, and CFE-TRUNCATED are
the load-bearing integrity errors: they MUST cause an immediate
rejection without any attempt to deserialise the payload.
A.4 WirePayload framing — WirePayloadError
Raised by the WirePayload pack/unpack routines
(Chapter 6 §6.2).
Defined in construct-core/src/wire_payload.rs:172.
| Tag | Variant | Condition |
|---|---|---|
WP-INVALID-DH | InvalidDhPublicKey(usize) | The DH public key field is not exactly 32 bytes (X25519 Montgomery point). |
WP-KEM-TOO-LARGE | KemTooLarge(usize) | KEM ciphertext exceeds u16::MAX bytes (only Suite 2; reference value is 1088). |
WP-TOO-SHORT | TooShort(usize) | Buffer is shorter than the 52-byte fixed header. |
A WirePayload that fails to parse MUST be dropped without affecting session state. The receiver MUST NOT advance its Double Ratchet on a malformed frame.
A.5 Padding errors — PaddingError
Raised by the PKCS#7-style padding helpers
(Chapter 5 §5.7).
Defined in construct-core/src/traffic_protection/padding.rs:21.
| Tag | Variant | Condition |
|---|---|---|
PAD-TOO-LARGE | MessageTooLarge(actual, max) | Plaintext exceeds MAX_MESSAGE_SIZE (reference: 65 536 bytes). |
PAD-INVALID | InvalidPadding | Last byte of the unpadded buffer indicates a length that exceeds the buffer or fails the constant-time unpad check. |
PAD-EMPTY | EmptyMessage | Caller attempted to unpad a zero-byte buffer. |
PAD-INVALID MUST be returned in constant time relative to the
plaintext length, to avoid leaking padding information via a timing
side channel.
A.6 Internal CryptoError
Defined in construct-core/src/error.rs. These variants are not
exposed across the FFI surface; they are converted to one of the §A.2
variants at the boundary (From<error::CryptoError> for uniffi_bindings::CryptoError).
| Variant | Approximate condition |
|---|---|
KeyGenerationError(String) | RNG failure, dalek keypair generation rejected. |
SigningError(String) | Ed25519 sign failed (typically wraps ed25519_dalek::SignatureError). |
SignatureVerificationError(String) | Ed25519 verification failed. Includes the SPK signature check. |
KemEncapsulationError(String) | ML-KEM-768 Encapsulate failed. |
KemDecapsulationError(String) | ML-KEM-768 Decapsulate failed (malformed ciphertext or wrong key). |
AeadEncryptionError(String) | ChaCha20-Poly1305 seal failed (typically allocation). |
AeadDecryptionError(String) | ChaCha20-Poly1305 open failed — tag mismatch, wrong AD, wrong key. |
KeyDerivationError(String) | HKDF / HMAC-SHA-256 step failed (rare; usually IKM-length validation). |
NonceGenerationError(String) | RNG returned an error during AEAD nonce sampling. |
InvalidInputError(String) | Generic input-shape rejection. |
SerializationError(String) | Internal serialization failure (typically MessagePack). |
DeserializationError(String) | Internal deserialization failure. |
InvalidKeyData | Point-on-curve or length check failed (mapped to FFI-INVALID-KEY). |
InvalidCiphertext | Structural ciphertext check failed (mapped to FFI-INVALID-CT). |
Other(String) | Catch-all. |
A.7 Top-level ConstructError
Defined in construct-core/src/utils/error.rs. This is the Result<T>
type returned by most public functions in the core; it composes
CryptoError via #[from].
| Variant | Approximate condition |
|---|---|
Crypto(CryptoError) | Any §A.6 variant, propagated. |
StorageError(String) | Local persistence (Keychain / SecureStorage / SQLite) failed. |
NetworkError(String) | Transport-layer call failed before reaching the protocol layer. |
SerializationError(String) | Top-level serialisation failure (distinct from CryptoError::SerializationError which is per-crypto-step). |
ValidationError(String) | Domain-level input validation failed (e.g. malformed user id). |
SessionError(String) | Session state inconsistency (e.g. trying to encrypt before init). |
NotFound(String) | A keyed lookup failed (user, session, pre-key id). |
InvalidInput(String) | Caller passed an argument that fails an invariant. |
InternalError(String) | Should-be-unreachable branch hit; treat as a bug. |
NotImplemented | A feature stub was called. MUST be surfaced as unimplemented at the FFI surface, not silently swallowed. |
Unauthenticated(String) | The local state indicates the device is not authenticated (no device_id, no auth token). |
A.8 MLS group errors — MlsError
Defined in construct-core/src/group/mls_error.rs. MLS itself is
documented at protocol-spec level in a future revision (see
Implementation Status §7.1);
the errors are listed here so that current implementers know what to
expect from the construct-core/src/group/ API surface.
| Tag | Variant | Condition |
|---|---|---|
MLS-CRYPTO | CryptoError(String) | Underlying crypto operation failed inside a group-protocol step. |
MLS-EPOCH-MISMATCH | EpochMismatch | Local epoch is behind the server; caller MUST FetchCommits and reapply before retrying. |
MLS-NOT-MEMBER | NotAMember | Caller is not a member of the addressed group. |
MLS-SERIALIZE | SerializationError(String) | Group state could not be (de)serialised. |
MLS-WELCOME | WelcomeError(String) | A Welcome message was invalid, expired, or addressed wrong keys. |
MLS-COMMIT | CommitError(String) | A commit could not be applied (stale epoch, invalid signature, etc.). |
MLS-CRYPT | EncryptionError(String) | Application-message encrypt or decrypt failed inside the group. |
A.9 VEIL transport errors (informational)
These errors are produced by the construct-veil crate, NOT the
protocol core. They are not part of the normative protocol surface
and MAY change between releases of construct-veil. They are
documented here as a navigation aid for implementers wiring the
anti-censorship tier.
A.9.1 Coordinator — CoordinatorError
(construct-veil/src/veil/coordinator.rs)
| Variant | Condition |
|---|---|
Io(std::io::Error) | Underlying socket / I/O failure. |
Scoring(String) | Persistent score store reported an error. |
Stopped | Session was cancelled by the caller before a backend won. |
AllProbesFailed | Every configured backend failed its probe. |
A.9.2 Obfuscator — ObfuscatorError
(construct-veil/src/veil/obfuscator.rs)
| Variant | Condition |
|---|---|
Io | I/O error during obfuscated handshake or stream. |
ConnectionRefused | TCP connect was refused by the relay. |
Tls(String) | TLS handshake or peer verification failed. |
Handshake(String) | obfs4 or WebSocket upgrade failed at the application layer. |
Timeout | Probe exceeded its time budget. |
Cancelled | Probe was cancelled by the coordinator. |
FingerprintBlocked | TLS alert 40 / handshake_failure — DPI has classified the method. |
WebTunnelDecoyResponse | Non-101 on WebSocket upgrade — transparent proxy interception. |
A.9.3 Probe failure classification — ProbeFailureReason
(construct-veil/src/veil/fsm/types.rs) — used by the scoring layer
to bucket probe outcomes:
FingerprintBlocked · WebTunnelDecoyResponse · TlsCertProblem ·
ConnectionFailed · Timeout · Unknown.
The sibling enum TransportFailureKind covers steady-state transport
degradation (after a probe has already won) and shares the same
spelling for the network-observable categories.
A.9.4 Scoring — ScoringError
(construct-veil/src/veil/scoring.rs)
| Variant | Condition |
|---|---|
DbError(String) | SQLite persistence error in the per-backend score store. |
A.10 gRPC status codes (informational)
Server-to-client errors in the MessagingService / AuthService etc. use standard gRPC status codes. The conventions below are normative for a conforming Konstruct deployment:
| gRPC status | Numeric | Konstruct semantics |
|---|---|---|
OK | 0 | Request succeeded. |
UNAUTHENTICATED | 16 | JWT missing, invalid, or expired. Client MUST clear the in-memory auth state, fall back to RefreshToken, and if that fails treat the device as logged out. |
PERMISSION_DENIED | 7 | The authenticated identity is not allowed to perform this operation (e.g. wrong device id). Treated equivalently to UNAUTHENTICATED for device-key disposal. |
INVALID_ARGUMENT | 3 | Request was structurally invalid (e.g. malformed user id). MUST NOT delete local device keys. |
NOT_FOUND | 5 | Addressed resource (user, pre-key, message) does not exist. |
ALREADY_EXISTS | 6 | Idempotent create attempted on a resource that already exists (e.g. duplicate registration). |
RESOURCE_EXHAUSTED | 8 | PoW failed, rate limit hit, or pre-key pool empty. Client MUST back off; SHOULD surface a user-visible cooldown. |
FAILED_PRECONDITION | 9 | Operation requires earlier state (e.g. encrypt before session init). |
ABORTED | 10 | Concurrent modification (rare; used for prekey-bundle race resolution). |
UNAVAILABLE | 14 | Server is starting up, restarting, or routed through a failing relay. Client SHOULD retry with backoff. |
INTERNAL | 13 | Server-side bug. MUST NOT be auto-retried more than once. |
DEADLINE_EXCEEDED | 4 | Request took longer than the per-RPC deadline. SHOULD be retried with a fresh deadline. |
A.10.1 Auth disposal rules (CRITICAL)
The client MUST distinguish "transport failure" from "server says you are not authenticated":
- On
UNAUTHENTICATED(16) orPERMISSION_DENIED(7) — and only on those codes — the client MUST delete its device keys and trigger re-registration. - On any other gRPC error (
UNAVAILABLE,DEADLINE_EXCEEDED,INTERNAL, etc.) the client MUST NOT touch device keys. These are transient failures.
The reference iOS interceptor implements this distinction in
AuthInterceptor.swift; client implementations on other platforms
MUST replicate it. Deleting device keys on a transient failure
silently logs the user out and forces them through re-registration —
this has been a recurring bug in early development; the rule above
exists to prevent regressions.
A.11 Diagnostics & logging guidance
When surfacing any error from this registry to the user or to logs:
- At the FFI surface (§A.2), the variant tag is the stable
contract. Show or log it verbatim. Do not parse the
messagefield for control flow. - Below the FFI surface (§A.6–A.8), the variant names are advisory only. Logs MAY include them for debugging but the application code SHOULD treat the §A.2 variant it eventually receives as the source of truth.
- Wire integrity errors (§A.3
CFE-CRC-MISMATCH, §A.4WP-*) SHOULD be counted as security-relevant log events. A sustained stream of these from a single peer indicates either a misbehaving client or an active attacker injecting bytes. FFI-SPK-STALEis a user-facing condition, not an internal error. UIs SHOULD render it as "this person hasn't opened Konstruct in N days" rather than as a generic failure dialog.
A.12 References
- FFI surface:
construct-core/src/uniffi_bindings.rs - CFE envelope:
construct-core/src/cfe/error.rs - WirePayload framing:
construct-core/src/wire_payload.rs - Padding:
construct-core/src/traffic_protection/padding.rs - Internal crypto:
construct-core/src/error.rs - Top-level
Result:construct-core/src/utils/error.rs - MLS:
construct-core/src/group/mls_error.rs - VEIL coordinator:
construct-veil/src/veil/coordinator.rs - VEIL obfuscator:
construct-veil/src/veil/obfuscator.rs - VEIL probe FSM:
construct-veil/src/veil/fsm/types.rs - VEIL scoring:
construct-veil/src/veil/scoring.rs - gRPC status codes: https://grpc.io/docs/guides/status-codes/
Disclosure & Contact
Reporting a security issue
Konstruct does not operate a security inbox. Use GitHub's private vulnerability reporting on any of the relevant repositories — your report will be visible only to maintainers until a fix is published.
- construct-core — cryptographic core
- construct-ios — iOS / macOS client
- construct-server — server-side
- construct-veil — anti-censorship transport
- construct-engine — QUIC engine
- construct-protocol — issues with this specification
The canonical disclosure metadata file (RFC 9116) is at konstruct.cc/.well-known/security.txt.
Scope
Cryptographic, transport, or implementation issues in any of the public repositories listed above are in scope.
Server infrastructure issues (denial-of-service, mis-configuration of the single deployment server) are out of scope while the project is in single-server alpha — the whole network depends on one trusted operator today, and infrastructure hardening is part of the federation roadmap rather than something a disclosure can usefully fix.
Project updates
Project updates and ad-hoc technical posts are at @maxeliseyev on X.
Changelog
The Konstruct Protocol Specification follows Semantic Versioning at the document level:
- MAJOR — a wire-incompatible protocol change.
- MINOR — a backwards-compatible normative addition (new field, new optional behaviour, new normative requirement that an existing implementation already satisfies).
- PATCH — editorial corrections that do not change implementer obligations.
v0.1.0 — unreleased
Initial public draft. All seven content chapters and the introduction are present at full depth: RFC 2119 normative language, byte-level wire layouts, mathematical handshake notation (DH₁..DH₄, INITIATOR / RESPONDER role separation), and verified-against-code parameter values.
- Introduction — project scope, conventions, honest current status table.
- Threat Model — adversary classes (network, server, historical-device, spam/Sybil), explicit non-goals, trust assumptions.
- Cryptographic Primitives — Suite 1 (X25519, Ed25519, ChaCha20-Poly1305, HKDF-SHA-256, PBKDF2, Argon2id) and Suite 2 (adds ML-KEM-768, FIPS 203). Constants summary table, randomness rules, zeroization requirements.
- Identity & Key Hierarchy — six key types, sizes, rotation cadence, storage classes, registration bundle wire format.
- Session Handshake — X3DH initiator and responder paths with full math; PQXDH deferred contribution at RK₁; tie-break rule for concurrent initiation.
- Message Encryption — Double Ratchet state machine, KDF helpers, AD v3 layout (with v2 fallback), DH ratchet step, mandatory DoS guards, PKCS#7 padding (mod 255).
- Transport Layer — WirePayload binary header (52 B fixed + variable KEM), CFE envelope (16 B header + MessagePack), gRPC service surface, VEIL anti-censorship tier.
- Implementation Status — honest
component matrix (what's implemented, what's stubbed, what's open
security issue), platform support matrix, open issue tracker
(
BS-3,BS-6,SEC-005,SEC-006,SEC-009). - Appendix A — Error Registry — every error variant a conforming client can observe, organised by surface (FFI / CFE / WirePayload / padding / internal / MLS / VEIL / gRPC). Includes the auth-disposal rules that prevent accidental device-key deletion on transient transport failures.
- Disclosure & Contact — GitHub Security Advisories per repository; no email inbox.
Editorial decisions in this revision
- Konstruct (Latin) / Конструкт (Cyrillic) as the canonical brand spellings.
- NIST FIPS names for PQ algorithms (
ML-KEM-768,ML-DSA-65), with the informal name (Kyber-768,Dilithium-3) in parentheses for first mention. - Code-grounded claims — every algorithm and constant cites its
source file in
construct-core. The editorial rule for this repo is "code wins" (seeAGENTS.md). - No self-graded "security score" — replaced with a concrete open-issues table in Implementation Status.
- Federation, P2P, sealed sender, MLS — explicitly marked as not-yet-implemented rather than present-tense claims.
v0.1.1 — unreleased
- ML-DSA-65 hybrid signatures — status updated from "Not implemented"
to "Implemented (optional, feature-gated)" in the component matrix.
Server-side wire verification activated (
construct-servere2e.rs:318), closing the last gap that kept PQ signatures decorative.
Known gaps for v0.2
- Published test vectors for the X3DH and Double Ratchet operations.
- MLS group-chat protocol (the code is in
construct-core/src/group/but not yet documented at protocol-spec level). - Federation (server-to-server) protocol — currently single-server.
- Wire-format reference appendix with hex-dumped example handshakes.
Removed from this revision compared with the internal draft
Content from the internal whitepaper draft that did not meet the "verified against code" editorial bar was deferred rather than copied:
- Self-rated "security score 8.5 / 10 → 10/10" — no industry-standard rubric exists for such a score.
- "First messenger with formally verified Rust Signal Protocol implementation" — aspirational, not done.
- Some roadmap dates from the internal draft, which belong on the marketing site rather than in a normative specification.
v0.1.2 — unreleased
-
New Architecture Overview chapter — an orientation map of the whole system: the always-on E2EE floor, the layered model (transport, entry discovery, route, overlay, delivery) and its offline mesh foundation, and how the system degrades gracefully across censorship tiers (free → DPI blacklist → national allowlist → blackout). Descriptive architecture; normative detail stays in the per-component chapters.
-
Status reconciliation against current code. Several component statuses that were accurate at v0.1.0 have since shipped:
- Federation (server-to-server) — "not implemented" → implemented (inbound + outbound sealed delivery, Ed25519-signed; multi-node interoperability test outstanding).
- Sealed sender — "not implemented" → implemented (sealed path
carries no
sender_idat rest; enforced-default rollout in progress). - VEIL transport — veil-front is now the production obfuscation transport; obfs4 / WebTunnel are retired (were "deployed" / "proof-of-concept").
- QUIC / HTTP-3 (
construct-transport) — recorded as a production transport with HTTP/2 fallback; not yet normatively specified.
Federation and sealed sender, listed as v0.2 gaps under v0.1.1, are now implemented.
-
Accuracy pass (2026-07-27) — code-verified corrections. Fixed claims that had drifted from the reference implementation:
- Sealed sender / threat model — resolved a contradiction where
Chapter 1 still said sealed sender was "not yet
deployed" while Chapter 7 said
"implemented". Sealed sender is on by default; the server-adversary
section now states the server cannot read
sender_idfor sealed traffic, lists the true residual metadata, and cites the masking code (messaging-service/src/envelope.rs:38,StealthPolicy.swift:42,SessionCoordinator.swift/MessagingServiceClient.swift). - Session-control channel — documented that
session_ready/ ping /SESSION_RESET_INIT/END_SESSIONare now sealed (fail-closed). - Client IP minimisation — new status entry: the server stores no raw
IP, only a salted hash (
construct-utils/src/lib.rs:92). - Privacy Pass enforcement — clarified it runs in
warn, notenforce(deferred past 1.0). - Keychain accessibility — corrected
WhenUnlockedThisDeviceOnly→AfterFirstUnlockThisDeviceOnlyfor crypto/session state (KeychainManager.swift:21). - Media AEAD — documented AES-256-GCM (per-file key, delivered E2E)
for attachments, distinct from the Double Ratchet's ChaCha20-Poly1305
(
MediaUploadService.swift:83).
- Sealed sender / threat model — resolved a contradiction where
Chapter 1 still said sealed sender was "not yet
deployed" while Chapter 7 said
"implemented". Sealed sender is on by default; the server-adversary
section now states the server cannot read
-
New Metadata Privacy & Sealed Sender chapter — the sealed-envelope wire structures (
SealedSenderEnvelope,SealedInner,SenderCertificatefromcore/envelope.proto), what each server role sees vs. cannot, recipient-side sender verification (KT / signature vouching, unvouched-but-delivered), the always-on fail-closed invariant and sealed/excluded scope, Privacy Pass anti-abuse (warnstatus, verifiable-VOPRF in progress), IP minimisation, and an honest residual-metadata table. All claims code-cited. -
New Anti-Abuse: Privacy Pass Tokens chapter — the anonymous-token VOPRF over Ristretto255 (blind → evaluate → unblind → redeem,
verify_token), age-tiered issuance caps, redemption + double-spend- the
off/warn/enforcepolicy switch (production iswarn), and the verifiable-issuance batched Chaum–Pedersen DLEQ (full transcript, malicious-issuer key-tagging defence, client key-pinning, KAT). Honest claim boundary: honest-but-curious today, malicious onceenforcerelies on the DLEQ. All claims code-cited.
- the
-
Chapter 6 (Transport) deepened + corrected. Fixed stale non-guarantee rows (sealed sender is on-by-default; obfs4 is retired, not an "in-progress fix"; raw IPs not persisted). New §6.5.3 — the direct-first
.autoconnection ladder and graceful degradation across censorship tiers, with the honest limit that obfuscation does not cross a national allowlist and VEIL is not itself a metadata-hiding layer. -
New Voice and Video Calls chapter — call signalling (SDP/ICE) rides the E2EE message path (
content_type = 12, sealed); media is WebRTC DTLS-SRTP keyed via that E2EE signalling, so a 1:1 call is end-to-end encrypted (no SFrame needed); audio shipped, video disabled; honest connectivity-metadata table (ICE address exchange, TURN). -
New Account Recovery chapter — BIP39 12-word account re-access (Ed25519 recovery keypair, server stores only the public key; restores account, not history) and SLIP-39
t-of-nsocial recovery of the identity vault (Shamir over GF(2⁸), 28-word mnemonics). No server-side key escrow. All claims code-cited. -
New Group Messaging (MLS) chapter (designed / partial) — the MLS group engine (OpenMLS, ciphersuite
MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519, epoch/Commit/Welcome model, per-device CFE-persisted store). Clearly marked: core implemented, no shipping surface, not yet a normative interop spec. -
New Key Transparency chapter (designed / client-ready) — RFC 6962 append-only key log, Signed Tree Head, inclusion + consistency proofs; the client verifier is implemented and tested, the server log is not yet deployed. Honest boundary stated.