Introduction
Document status: v0.4.0 — draft, unreleased (see the Changelog). Implementation reference:
construct-core0.48.x (see Implementation Status). Document license: CC BY 4.0 · Reference implementation: Apache-2.0.
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 mandatory hybrid post-quantum handshake (PQXDH v2): an ML-KEM-1024 (NIST FIPS 203) secret enters the initial root key beside the X25519 outputs, so every message — the first included — is protected by both.
-
Sessions that renew by sending: a device keeps a few previous states, and an unreadable message is answered with a decryption error naming the state it was written on. There is no reset message to forge, replay or ration.
-
A sparse continuous post-quantum ratchet (Suite 4) that adds new ML-KEM-768 contributions after session establishment, one derived key per message since construct-core 0.24.0 (§2.4).
Post-quantum protection covers the content of one-to-one messages and attachments, who sent a sealed one-to-one message, and the key exchange of calls. Server signatures, device authentication, groups and Privacy Pass are still classical; Threat Model — Post-quantum coverage lists every layer.
-
Metadata-minimising sealed sender, backed by Privacy Pass tokens for abuse resistance.
-
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. This draft now includes the 1:1 wire formats and error registry, but federation and group messaging are still documented at status/design level rather than as complete independent interop specifications.
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, Suite 4 PQ ratchet) | 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 | Kotlin client on the same core release as iOS; exchanges messages with iOS on the test stand. Debug builds only — no signed release or store distribution yet; no VEIL transport yet. See Chapter 7 §7.2. |
| Federation (server-to-server) | Implemented — inbound + outbound sealed delivery, Ed25519-signed. Multi-node interoperability test outstanding. |
| Sealed sender | Implemented and on by default — all in-scope outgoing user traffic (messages, receipts, call signalling, session-control handshake) is sealed and leaves no server-readable sender id in sealed delivery; identified-downgrade paths are fail-closed. Privacy Pass token enforcement runs in warn mode (not enforce). |
| MLS group chat | Core implemented and documented in Chapter 12 as design / partial; no shipping product surface and not yet a full normative interop spec. |
| QUIC / HTTP-3 transport | In production — engine-QUIC direct path, plain QUIC in release, 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 sender identity from the delivery path for sealed traffic. | It does not erase pre-existing server-side contact records or hide the recipient from the delivery node. |
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 — engine-QUIC/H3 for speed everywhere, veil-front HTTPS for censored networks — selected by one client-side router. | construct-transport, construct-veil, iOS TransportRouter | 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 from sealed delivery requests; 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 the content of one-to-one messages and of the attachments they carry. Every session is opened with PQXDH v2 (ML-KEM-1024), mandatory since construct-core 0.18.0; there is no classical-only session to fall back to. Who sent a message (the sealed envelope, and the first message of a session sealed whole) and the media of calls (DTLS 1.3 with X25519MLKEM768) are covered too. This does not cover every layer: groups, several server-signed objects and some sealed boxes are still classical — see Post-quantum coverage below. Until 2026-10-01 this line promised the property "for sessions that used Suite 2 (PQXDH)", a suite no session negotiates, and named no exceptions.
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: ordinary sealed sends use the dedicated
SendSealedMessage RPC whose request carries only sealed_sender
bytes plus an optional attempt id; transitional session-control paths
may still carry Envelope.sealed_sender, but the outer sender,
conversation id, and real content type are omitted. In both paths the
client 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:43(isEnabled),:72(shouldUseSealedSender). - Client transport:
construct-iosNetworking/gRPC/Services/MessagingServiceClient.swift:203-:225(sendSealedMessageuses no outerEnvelope, sender, conversation id, or content type). - Legacy sealed control transport:
construct-iosNetworking/gRPC/Services/MessagingServiceClient.swift:35-:68,:269-:351;construct-servermessaging-service/src/grpc.rs:333-:360. - Sealed-inner construction:
construct-iosSecurity/StealthSenderService.swift:400-:415(ordinary traffic omitscontent_type; only structural sealed-sender exceptions are visible before decrypt). - Server handling:
construct-servermessaging-service/src/grpc.rs:701-:750(send_sealed_messagedeliberately does not extract an authenticated user id) andmessaging-service/src/envelope.rs:140-:270(dispatch_sealed_senderroutes fromSealedInnerwithout a sender).
What the server can still see today (single-trusted-server alpha), even with sealed sender:
- The recipient identifier, delivery tag, token fields, payload size,
and delivery timestamp of each sealed message. The sender is not in
the
SendSealedMessagerequest or the plaintextSealedInner; it is insidesender_cert_ciphertext. - 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. Against an adversary who also has a quantum computer the classical step does not heal; post-quantum healing waits for the next completed epoch, at most 7 days, and up to about twice that if the stolen state is the initiator's with a proposal pending (Appendix B §B.6).
Forward secrecy holds up to the retention window, not to the moment of
compromise. A stolen snapshot contains, besides the current state: up to
3 previous states per peer device, kept for 7 days
(MAX_PREVIOUS_STATES, PREVIOUS_STATE_TTL_SECONDS,
construct-core/src/orchestration/session_lifecycle.rs:29,37); the
previous epoch's post-quantum chain (PQ_CHAIN_RETENTION = 2,
double_ratchet/mod.rs:246); and up to 1000 skipped message keys for
7 days (max_skipped_messages, max_skipped_message_age_seconds,
config.rs). Messages those keys open are readable from the snapshot
(Chapter 5 §5.9).
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.
Post-quantum coverage
"Post-quantum" in this specification means: an adversary who records traffic today and obtains a cryptographically relevant quantum computer later learns nothing protected by that layer. The table lists every layer that uses public-key cryptography, and whether it meets that bar today. Symmetric primitives (ChaCha20-Poly1305, AES-256-GCM, HKDF-SHA256) are not listed: at 256-bit keys they are not the weak point.
| Layer | Post-quantum? | Mechanism | Source |
|---|---|---|---|
| 1:1 session handshake | Yes | PQXDH v2: an ML-KEM-1024 (Kyber-1024) secret in the root key beside X25519 (04) | construct-core/src/crypto/pq_x3dh.rs |
| 1:1 messages after the handshake | Yes | Suite 4: sparse continuous ML-KEM-768 (Kyber-768) ratchet, one post-quantum key per message, a new epoch at least every 7 days (02 §2.4) | construct-core/src/crypto/messaging/double_ratchet/internals.rs |
| Attachments | Yes, through the message | AES-256-GCM, key carried inside the 1:1 message; blob padded to a Padmé bucket since construct-core 0.33.0 (02 §2.2.3) | construct-core/src/media.rs |
| Prekey bundle authentication | Yes | Ed25519 + ML-DSA-65 (Dilithium-3) hybrid signatures, required before PQXDH | construct-core/src/orchestration/pq_prekey_plan.rs:217 |
| History transfer between own devices | Yes | Channel key from X25519 and ML-KEM | construct-core/src/history/channel.rs:49 |
| Sealed sender: who sent a message, after first contact | Yes (construct-core 0.26) | Session envelope: keyed from the session's PQXDH v2 root, writer found by a tag (08) | construct-core/src/crypto/sealed_sender/envelope.rs, book.rs |
| Sealed sender: who sent a session's first message | Yes (construct-core 0.27) | First-flight box: X25519 plus the handshake's ML-KEM-1024 secret; the certificate and the whole handshake header are inside (08). Android bundle fetches still name the fetcher (BF-1) | construct-core/src/crypto/sealed_sender/first_flight.rs |
| Other sealed boxes | Partly | A DECRYPTION_ERROR about an enveloped message goes back along the session envelope (post-quantum). One about a failed first message, and device metadata sealed to sibling devices, are still the X25519 box | src/crypto/sealed_sender/mod.rs (seal_to_x25519_public), src/orchestration/decryption_error.rs:120, src/uniffi_bindings.rs (seal_to_device_key) |
| Server-signed objects | No (authentication) | Sender certificates and Key Transparency tree heads are judged by Ed25519. Hybrid Ed25519 + ML-DSA-65 server keys delegated by an offline root are implemented in the core (0.32.0, 03 §3.11) but decide nothing until a root is pinned, and none is. A future quantum attacker could forge an Ed25519 signature; it cannot use that to read recorded traffic. After first contact a peer is held by its pinned KEM identity key, not the certificate (04) | src/crypto/sealed_sender/mod.rs (verify_sender_cert, check_signature), src/crypto/key_transparency.rs (verify_proof), src/crypto/server_trust.rs:50 (PINNED_ROOTS, empty) |
| Device and recovery authentication to the server | No (authentication) | Ed25519 device signatures and the Ed25519 recovery key derived from the seed phrase (11). A forgery would act on the account at the server; it does not decrypt messages | src/crypto/keys.rs:472, src/crypto/recovery.rs:92 |
| Calls | Yes (since 2026-10-01) | DTLS 1.3 with X25519MLKEM768 for the SRTP keys (10). Chosen by the two endpoints: a peer whose build does not offer the group gets X25519 alone, and the app cannot see which was agreed | libwebrtc field trial WebRTC-EnableDtlsPqc, construct-ios WebRTCSession.swift |
| Group messaging (MLS) | No | MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519 (12); no shipping product surface | src/group/mls_store.rs:39 |
| Privacy Pass tokens | No (anti-abuse only) | VOPRF over Ristretto255 (09). A quantum attacker could mint tokens; blinding still keeps a redeemed token unlinkable to its issuance | src/crypto/privacy_pass/mod.rs |
| Transport TLS | Partly | VEIL: since construct-core 0.31.2 the client's TLS profiles use rustls on aws-lc-rs and offer X25519MLKEM768 first, then X25519, P-256, P-384; whether it is agreed depends on the front, whose relay crate is built for it. QUIC: rustls with the ring provider, which offers no hybrid group. The iOS direct gRPC path uses the system TLS stack, whose hybrid support has not been verified against the server (06). TLS is not what keeps message content confidential either way | construct-veil c3330a4 src/tls_fingerprint.rs:189-:240, crates/construct-veil-relay/Cargo.toml:28; construct-transport/Cargo.toml:22 |
The rows marked No or Partly are tracked as PQC-1…PQC-6 in
Chapter 7 §7.3.
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 of 1:1 content | PQXDH v2 (ML-KEM-1024) and the Suite 4 ratchet (ML-KEM-768) (04, 02) — not every layer, see Post-quantum coverage | A future quantum-equipped attacker replaying recorded traffic |
| Identity unforgeability | Ed25519 + ML-DSA-65 hybrid 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. Three core suite identifiers are currently
accepted by construct-core: Suite 1 (classical ratchet), Suite 2
(hybrid Ed25519 + ML-DSA-65 signatures), and Suite 4 (sparse continuous
ML-KEM-768 ratchet, one post-quantum key per message). Suite 3, an
earlier PQ-ratchet design that mixed one key per epoch instead of per
message, is retired without a compatibility path (§2.4). Independently of the suite, every session opens with
PQXDH v2 — ML-KEM-1024 in the initial root key — and a session is not
created without it (§2.3, Chapter 4).
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 | Description |
|---|---|---|
SUITE_CLASSIC_V1 | 0x0001 | X25519 + Ed25519 + ChaCha20-Poly1305 + HKDF-SHA256 |
SUITE_PQ_HYBRID_V1 | 0x0002 | Suite 1 + hybrid Ed25519 + ML-DSA-65 signatures |
SUITE_PQ_RATCHET_V1 | 0x0003 | Retired (construct-core 0.24.0, PQR-2). Suite 1 + sparse continuous ML-KEM-768 ratchet, one epoch secret mixed into every message key of the epoch. Refused outright at unpack and at session restore; see §2.4. |
SUITE_PQ_RATCHET_V2 | 0x0004 | Suite 1 + sparse continuous ML-KEM-768 ratchet, one derived key per message (§2.4) |
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.
In the WirePayload header it is encoded as u16 little-endian
(construct-core/src/wire_payload.rs). Signature
prologues use their own explicitly-specified byte order (§2.2.2).
The accepted IDs — currently 1, 2 and 4 — are defined in
SuiteID::new (construct-core/src/crypto/suite_id.rs); 3 is named
only so a refusal can say what it refused
(SuiteID::RETIRED_PQ_RATCHET_V1).
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:
- X25519 signed prekey:
Ed25519_Sign(SK_priv, b"KonstruktX3DH-v1" || [0x00, 0x01] || SPK_pub). - ML-KEM-1024 prekey, signed and one-time alike:
Ed25519_Sign(SK_priv, b"KonstruktX3DH-v1" || [0x00, 0x11] || created_at (u64 BE) || KEM_pub), and the same message under the hybrid key. The v1 message (0x10, a 768-bit key, no time) is retired and MUST NOT verify as v2. - Hybrid identity binding:
Ed25519_Sign(SK_priv, b"KonstruktHybridId-v1" || hybrid_identity_key).
- X25519 signed prekey:
spk_rotation_epoch is a freshness/replay field carried beside the
prekey in the bundle; it is not part of the current prekey signature
message. Server verification builds the prekey message in
construct-server/key-service/src/core.rs:150-:179; the shared
hybrid helper builds the same bytes in
construct-server/crates/construct-crypto/src/pqc/hybrid.rs:55-:65.
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 on the device with AES-256-GCM
(crate aes-gcm 0.10) under a fresh per-file 256-bit key before upload. The
per-file key and the SHA-256 of the blob never reach the media server — they
travel end-to-end inside the (ChaCha20-Poly1305-sealed) message, so the
server stores only an opaque blob. Since construct-core 0.33.0 (2026-10-04)
the core seals and opens media for every client (seal_media, open_media,
construct-core/src/media.rs:99-:164), and the blob is padded to a
Padmé bucket:
v1 blob : nonce(12) ‖ AES-256-GCM(key, nonce, ad = "konstruct-media-v1", P) ‖ tag(16)
P : BE64(len file) ‖ file ‖ 0x00 … 0x00
|blob| : max(Padmé(36 + |file|), 4096), at most 100 000 000 bytes
Padmé(L): L rounded up so that only its top ⌊log₂ ⌊log₂ L⌋⌋ + 1 bits may be set
Padmé (Nikitin et al., Reducing Metadata Leakage from Encrypted Files and
Communication with PURBs, PETS 2019; padme, media.rs:79-:87) costs at
most 12 % and about 3 % from 64 KiB up, and reduces what the size reveals
from log L to log log L bits. The 4 096-byte floor keeps small files — an
avatar, a short voice note — from being told apart by size. On open, the
length prefix MUST fit the payload and every padding byte MUST be zero
(unpad, media.rs:168), so each file has one encoding. The errors these functions raise, and how
they reach the client, are in
Appendix A §A.12.
The version is told apart by the AEAD, not by a marker byte: a blob is first
opened with the v1 associated data and, if that fails, as a legacy blob
(nonce(12) ‖ AES-256-GCM(key, nonce, no ad, file) ‖ tag(16), the exact
file size — the format before 0.33.0). Neither kind can authenticate as the
other, so the server sees no version byte. A reader older than 0.33.0
cannot open a v1 blob and fails with an authentication error. Before 0.33.0
each client sealed media itself (CryptoKit on iOS, javax.crypto on
Android) and the server saw every file's size to the byte. The Double
Ratchet stays on ChaCha20-Poly1305.
2.2.4 HKDF-SHA-256
- Algorithm: HKDF per RFC 5869, instantiated with SHA-256.
- Crate:
hkdf 0.12. - Several 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 |
|---|---|---|---|---|
| PQXDH root key (Ch. 4 §4.3) | [0xFF; 32] | DH_combined || kem_ss | b"Construct-PQXDH-RootKey-v3" (26 B) || SHA-256(KEM_pub) || SHA-256(kem_ct) || SHA-256(KIK_A) | 32 |
| KEM identity seed (Ch. 4 §4.4.4) | empty | ML-DSA-65 seed (32 B) | b"Construct-KEM-identity-v1" (25 B) | 64 |
| Identity answer, root (Ch. 4 §4.4.4) | kik_ss | RK | b"Construct-KEM-identity-root-v1" (30 B) | 32 |
| Identity answer, chain (Ch. 4 §4.4.4) | kik_ss | CK | b"Construct-KEM-identity-chain-v1" (31 B) | 32 |
Classical X3DH root key (builds without post-quantum only) | [0xFF; 32] | DH_combined | b"Construct-X3DH-RootKey-v1" (25 B) | 32 |
| Initial Double Ratchet root normalisation | [0xFE; 32] | X3DH root key | b"InitialRootKey" (14 B) | 32 |
| Double Ratchet root step (Ch. 5 §5.2) | RK | dh_out (32 B) | b"Double-Ratchet-Root-Key-Expansion" (33 B) | 64 |
| Double Ratchet chain step (Ch. 5 §5.2) | CK | empty string | b"Double-Ratchet-Chain-Key-Expansion" (34 B) | 64 |
| PQ ratchet epoch chains (§2.4) | empty | ML-KEM-768 shared secret | b"construct-pqr-chains-v2" ‖ epoch (u32 BE) (23 + 4 = 27 B) | 64 |
| PQ ratchet chain step (§2.4) | empty | PQ chain key | b"construct-pqr-step-v2" (21 B) | 64 |
| PQ ratchet message-key mix (§2.4) | PQ chain key at (epoch, index) | Double Ratchet message key | b"construct-pqr-msg-v2" (20 B) | 32 |
| PQ ratchet EK hash | empty string | ML-KEM-768 encapsulation key | b"construct-pqr-ekhash-v1" (23 B) | 8 |
The Double Ratchet root and chain KDFs are implemented in
construct-core/src/crypto/suites/classic.rs and mirrored
by the hybrid provider. The initial root normalisation is in
construct-core/src/crypto/messaging/double_ratchet/messaging.rs.
The PQXDH v2 root key is construct-core/src/crypto/handshake/x3dh.rs.
The PQ ratchet HKDF calls (pq_epoch_chains, pq_chain_step,
mix_pq_message_key, pq_ek_hash) are in
construct-core/src/crypto/messaging/double_ratchet/internals.rs.
Changed in construct-core 0.24.0 (PQR-2,
decisions/pq-ratchet-per-message-chain.md). Until then the PQ message-key mix used a singlepq_epoch_secretstored for the lifetime of an epoch (info = b"construct-pqr-msg-v1", retired with Suite 3), so every message key of an epoch was derived from the same salt. The epoch secret is now spent once, into the two chain keys above, and never stored (§2.4).
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:131). - 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 Post-quantum key agreement (PQXDH v2, mandatory)
Every session's initial root key includes an ML-KEM-1024 shared secret (Chapter 4 §4.3). This is not a suite: it applies to Suites 1, 2 and 3 alike, and there is no classical-only way to open a session with a shipping client.
Changed 2026-09-25. Earlier revisions specified PQXDH v1: an ML-KEM-768 secret mixed into the root key after the first DH ratchet step (
info = "construct-pqxdh-v1"), which left the first chain of every session classical only, and made PQ optional. v1 is removed without a compatibility path.
2.3.1 ML-KEM-1024 (prekeys and the first message)
- Algorithm: ML-KEM-1024 per NIST FIPS 203.
- Crate:
ml-kem(featurepost-quantum, which every platform build enables). - Encapsulation key: 1568 bytes. Ciphertext: 1568 bytes. Shared secret: 32 bytes.
- Secret keys are generated and kept by the core as 64-byte seeds
(
MLKEM_SEED_SIZE,construct-core/src/crypto/pq_x3dh.rs:155). - Implementations MUST use the FIPS 203 final variant.
ML-KEM-768 (1184-byte key, 1088-byte ciphertext) is used only in the Suite 4 ratchet (§2.4). The split — 1024 for prekeys, 768 in the ratchet — is the one Signal (PQXDH and SPQR) and Apple PQ3 use.
2.3.2 Hybrid design
IKM = DH1 || DH2 || DH3 [|| DH4] || kem_ss
SK_root = HKDF(salt = F, IKM,
info = "Construct-PQXDH-RootKey-v3" || SHA-256(KEM_pub) || SHA-256(kem_ct)
|| SHA-256(KIK_A),
L = 32)
Because SK_root depends on both the DH outputs (classical) and
kem_ss (post-quantum), an adversary must break both to recover any
message, including the first. Breaking only X25519 leaves kem_ss as a
32-byte unknown input; breaking only ML-KEM leaves the DH outputs. The
Kyber key and ciphertext are bound through info, so the construction
does not rely on which binding properties a particular KEM has.
2.3.3 Hybrid signatures
Ed25519 stays the classical authentication path, and every Kyber prekey additionally carries a hybrid signature using ML-DSA-65 (Dilithium-3) per NIST FIPS 204. Hybrid signatures do not replace Ed25519 in the wire format; they are a second attestation over the same sign-message.
The hybrid signature public key format is:
hybrid_identity_key = ed25519_pk(32) || mldsa65_pk(1952) -- 1984 B
hybrid_signature = ed25519_sig(64) || mldsa65_sig(3309) -- 3373 B
The stored hybrid signing secret is
ed25519_seed(32) || mldsa65_seed(32) || mldsa65_pk(1952)
(2016 B). The device binds that independent hybrid identity key to
its existing Ed25519 identity with
Ed25519("KonstruktHybridId-v1" || hybrid_identity_key). Prekey-level
hybrid signatures cover
"KonstruktX3DH-v1" || [0x00, 0x01] || SPK_pub for the X25519 SPK and
"KonstruktX3DH-v1" || [0x00, 0x11] || created_at || KEM_pub for every
ML-KEM-1024 prekey. Reference sizes and verification behaviour:
construct-core/src/crypto/suites/hybrid.rs:1-:35,
construct-server/shared/proto/services/key_service.proto:248-:270,
and construct-ios Security/HybridBundleVerifier.swift:34-:120.
For opening a session, the hybrid fields are required
(construct-core/src/orchestration/pq_prekey_plan.rs): a bundle without
the hybrid identity key, with an invalid cross-signature, or with a Kyber
prekey whose Ed25519 or hybrid signature is missing or invalid is refused,
and no session is created. The fingerprint of the hybrid identity key is
pinned per device; a changed one is refused (HybridIdentityChanged).
Until 2026-09-24 no receiving side checked Kyber signatures at all, and a
substituted Kyber key would have received the ML-KEM secret of every
session opened to that device while every indicator said "PQ".
The hybrid key's own trust root is still its Ed25519 cross-signature; the identity and addressing layer is not post-quantum (Chapter 7).
2.4 Suite 4 — Sparse continuous PQ ratchet, per-message chains
Suite 4 (0x0004) is a ratchet-level extension, not a new protobuf
CryptoSuite bundle value, and it is not negotiated from the bundle at all: it is mandatory
wherever the post-quantum build feature is present (every platform build, §2.3.1). An
initiator with that feature always proposes it
(session_api::negotiated_initiator_suite/local_supports_pq_ratchet,
construct-core/src/crypto/session_api.rs), and a responder built the same way refuses a first
message on any other suite (PQXDH_REQUIRED, construct-core/src/orchestration/orchestrator.rs).
The iOS client's bundle-to-suite mapping produces only core suite 1 or 2
(construct-ios Networking/gRPC/Services/KeyServiceClient.swift) — suite 4 is never read out of
a bundle, it is simply what every current build runs.
Changed with PQXDH v2 (2026-09-25, construct-core 0.18,
decisions/pqxdh-v2-mandatory-pq-cutover.md). Suite 4 (then Suite 3) used to be negotiated per session from an unsignedsupports_pq_ratchetcapability flag the server served beside the bundle: a device that had advertised or used it was refused (PQ_DOWNGRADE_REFUSED) if a later bundle dropped the flag. The same commit that landed PQXDH v2 removed both the flag and its downgrade ledger (prd) — there was nothing left for either side to negotiate once the suite became mandatory. A bundle that still carries the old field is read as before and the field is ignored.
Changed in construct-core 0.24.0 (PQR-2,
decisions/pq-ratchet-per-message-chain.md). Suite 3 (0x0003) was this same sparse exchange, but a completed epoch's ML-KEM secret was stored and mixed unchanged into every message key of the epoch — roughly sixteen DH-ratchet turns' worth of messages sharing one PQ contribution, with four such secrets retained for late deliveries. A stolen session state plus a broken X25519 therefore read every message of those epochs, already-read ones included: the classical half of the ratchet had per-message forward secrecy, the post-quantum half had only per-epoch. Suite 3 is retired without a compatibility path, as PQXDH v1 was before it (§2.3): its messages are refused at unpack (RetiredSuite(3), Appendix A §A.4) and its stored sessions are refused at restore — the next send opens a fresh Suite 4 session. What is unchanged by this revision: the exchange protocol itself (EK proposal, CT completion, cadence,ek_hashdisambiguation — rules 1–4 below), where in the frame the PQ material rides, and the mixing shape (the PQ key as the HKDF salt, §2.2.4).
At a configured cadence (§2.4.3), the designated Suite 4 initiator attaches an ML-KEM-768 encapsulation key to outgoing messages. The peer encapsulates once and re-attaches the ciphertext until the initiator activates the epoch — this exchange is unchanged from Suite 3. What changed is what a completed epoch yields.
2.4.1 From an epoch secret to two chains
The moment an epoch completes — the initiator on decapsulating the responder's ciphertext, the responder (provisionally, ahead of promotion) at encapsulation — its ML-KEM-768 shared secret is spent at once into two directional chain keys and then discarded; it is never written to persistent state:
ck[epoch, i→r] || ck[epoch, r→i]
= HKDF-SHA-256(salt = empty, IKM = epoch_secret,
info = b"construct-pqr-chains-v2" || epoch (u32 BE), L = 64)
-> (output[0..32], output[32..64])
ck[epoch, i→r] is the chain the PQ-exchange initiator sends on; ck[epoch, r→i] is the
chain the responder sends on. (The PQ-exchange initiator is always the Double Ratchet
initiator — single-initiator discipline, unchanged from Suite 3.) Each side's own send chain is
its direction; the other is its receive chain. Both start at index 0.
A chain steps forward one key at a time:
ck' || k = HKDF-SHA-256(salt = empty, IKM = ck, info = b"construct-pqr-step-v2", L = 64)
-> (output[0..32], output[32..64])
ck' replaces the chain key; k is the key for the chain's current index, and the index then
increments. A message takes the next key of its sender's chain for the current epoch and names
(epoch, index) in its header (§5.3); the receiver advances the matching chain to that index,
keeping every passed-over key as a skipped PQ key (§2.4.3). The per-message key is mixed exactly
as Suite 3 mixed its epoch secret — only the salt's provenance changed:
MK_pq = HKDF-SHA-256(salt = k, IKM = MK_dr, info = b"construct-pqr-msg-v2", L = 32)
pq_message_epoch = 0 (with pq_key_index = 0) means no PQ epoch has completed yet — MK_pq = MK_dr, unmixed. A non-zero pq_key_index naming epoch 0 is a wire error
(PqKeyIndexWithoutEpoch, Appendix A §A.4); a pq_message_epoch naming a chain that is neither
retained nor provisional is a hard decrypt error, because silently skipping the mix would be a
downgrade. The implemented state machine is pq_epoch_chains, pq_chain_step, pq_send_key,
pq_receive_key, mix_pq_message_key
(construct-core/src/crypto/messaging/double_ratchet/internals.rs); full receiver procedure and
retention bound: Chapter 5 §5.8 and
§5.8.3.
2.4.2 Known-answer vectors
For secret = 0x42 repeated 32 times, epoch = 1, Double Ratchet message key = 0x07 repeated
32 times (construct-core/src/crypto/messaging/double_ratchet/tests.rs,
test_pqr2_derivations_known_answers, recomputed independently in Python hmac against RFC 5869
when pinned, 2026-09-30):
| Derivation | Value (hex) |
|---|---|
Initiator's send chain key (ck[1, i→r]) | 1580b9a74a9abc6d8cefbaf561c65c64adc8b33849656f1b0051db2d9af8842f |
Initiator's receive chain key (ck[1, r→i]) | 30730bf58df681adddf740b817d1c5ff17b8517b05e383fbe1dea05ead8ae57b |
| Send chain, key at index 0 | c03bff04f55cd6c9b7b1952306a32de0347e12307f0c9265cd88f4111f7e9325 |
| Send chain, key at index 1 | 7fc0bad75c864e2a790dbfbb04e1e102763d88098ba1d8b7e7210c8cafd002d3 |
| Message key mixed from the key at index 0 | bd28758bb7c74d3d64fee6e4002b4f6e716169c1148aea322b9800a505976966 |
The responder's chains are the initiator's, crossed: the responder's receive chain equals the
initiator's send chain and vice versa. These are the vectors another implementation (or
construct-tui, paused, §7.2) checks itself against once published to
construct-protos/conformance.
2.4.3 Cadence and retention
| Parameter | Reference value | Source |
|---|---|---|
| Rekey cadence | 16 DH-ratchet turns | construct-core/src/config.rs |
| Cadence bounds (env override) | clamped to [4, 64] | config.rs |
Rekey age floor (pq_ratchet_max_age_seconds) | 7 days | config.rs |
| Retained epochs' chains | 2 (current + previous) — PQ_CHAIN_RETENTION | double_ratchet/mod.rs |
| PQ skipped-key bounds | shared with the classical MKSKIPPED bounds: 1000 keys, 2000-message jump, 7-day age | Chapter 5 §5.8.1 |
| Unanswered proposal abandoned after | max_skipped_message_age_seconds | internals.rs (abandon_unanswered_pq_exchange) |
The rekey-cadence counter advances inside the DH ratchet step, so the unit is a DH ratchet turn — a change of conversational direction — not a message and not elapsed time. A one-sided burst of a hundred messages performs no DH step and so makes no PQ progress by the turn count alone; sixteen alternations do.
Changed in construct-core 0.24.1 (PQR-1, TODO 64.1). Until then the turn count was the only trigger, so a conversation that never changes direction — one side always writing, the other always reading — never took a DH step and so never rekeyed: the epoch the PQ ratchet started with was the epoch it kept forever, the exact state the ratchet exists to leave. The exchange initiator now also checks the current epoch's age on every
encryptcall (maybe_start_pq_exchange_by_age) and proposes a new one, turns or not, once it has stood forpq_ratchet_max_age_seconds— 7 days, the floor Apple PQ3 guarantees; the turn-counted path checks the same age and fires early too.pq_epoch_since(set when an epoch completes, and at session creation) is the clock; it is persisted (CFECfePqRatchetStateV2.since), and a restored blob with no value for it is read as maximally old, so the next send proposes rather than trusting an age it cannot know.Corrected 2026-10-01 (construct-core
68cd63c, test only). This note used to say the gap was still open: only the exchange initiator proposes, so a conversation in which only the responder writes could not rekey, and closing that needed roles that alternate per epoch. It missed delivery receipts. The initiator answers every delivered message with a receipt sent through the session (§2.4.4 rule 6), so itsencryptruns, the age check runs with it, and the proposal rides on the receipt; the responder's next message carries the ciphertext and the next receipt promotes the epoch (test_pqr1_a_responder_only_conversation_rekeys_through_the_initiators_receipts). Alternating roles would not have helped any case: every exchange needs both sides to send at least once, whoever proposes. A peer that sends nothing at all — not even receipts — is a device that is off, and no assignment of roles rekeys with it.
A pending EK/CT field is attached by encrypt to every outgoing frame, which includes control
frames such as delivery receipts. A peer that only sends receipts therefore both advances the
turn counter and carries the exchange forward, without the user replying — and, since 0.24.1, is
also what makes the age check itself run (it runs on every encrypt, receipts included).
Only the current epoch keeps a send chain: starting a newer epoch's send chain erases the
older one outright. The previous epoch keeps its receive chain one epoch longer, so a message
sent just before the peer switched epochs still decrypts; beyond PQ_CHAIN_RETENTION the whole
epoch is dropped. This is a hard bound with a hard consequence, unchanged in kind from Suite 3: a
message naming an epoch outside the retained set, and not covered by an already-kept skipped PQ
key, is a decrypt error by the same no-silent-downgrade rule as an unknown epoch. It replaces
Suite 3's PQ_EPOCH_RETENTION = 4 stored secrets, and answers PQR-4 (Chapter 7 §7.3): the bound
on out-of-order tolerance is now the same skipped-key window the classical half already uses, not
an independently-sized epoch count.
2.4.4 Normative rules for the sparse exchange
-
Commit on success only. PQ processing of a received frame — EK ingestion, ciphertext completion, epoch promotion, chain derivation — MUST run only after the carrying message has authenticated and decrypted. A malformed or hostile PQ field MUST NOT alter session state, and the classical delivery of its carrier MUST be unaffected. By construction an EK/CT field always rides on a frame tagged with a pre-completion epoch, so decrypting the carrier never depends on the material it carries (
internals.rs,commit_pq_post_decrypt). -
Re-attach until implicitly acknowledged. There is no explicit acknowledgement frame. The initiator re-attaches its EK to every outgoing message until a matching ciphertext arrives; the responder re-attaches its ciphertext until a peer message tagged at or above the provisional epoch arrives — which proves the initiator decapsulated the same secret, because that tag was just used successfully to derive the message key. A dropped message therefore costs bandwidth, never a stuck exchange (
internals.rs,commit_pq_post_decrypt). -
One exchange in flight. A new proposal MUST NOT be started while one is pending; the cadence counter simply retries on the next turn.
-
ek_hashdisambiguates re-proposals. After an unanswered proposal is abandoned, the same epoch number may be re-proposed with a fresh keypair. The 8-byteek_hashaccompanying a ciphertext identifies which encapsulation key it completes; a ciphertext whose hash does not match the pending keypair MUST be ignored, and the local proposal kept. -
Per-message chains, not a per-epoch secret. The reference derives each message's PQ contribution from its sender's epoch chain at the key's own index (§2.4.1); the epoch secret itself is spent once and never stored. Forward secrecy at message granularity is now supplied by both halves of the ratchet — the classical Double Ratchet as before, and the PQ chains since this revision. A read message's PQ key is derivable by neither side afterwards: the chain that produced it has already moved past that index. See Appendix B §B.6 for how this compares with other deployed designs — Suite 4 now matches Signal SPQR's granularity in kind. Both designs reseed a symmetric chain from a fresh ML-KEM secret every epoch; that reseeding is where each one's post-quantum healing after a compromise comes from. They differ in how often an epoch completes (Suite 4: at least every 16 DH turns or 7 days; SPQR: the next epoch starts as soon as the previous one completes), in who generates the encapsulation key (Suite 4: always the DR initiator; SPQR: the roles alternate), and in how the encapsulation key and ciphertext travel (Suite 4: whole, on every outgoing frame; SPQR: erasure-coded chunks). Those differences make Suite 4 slower to heal; see Appendix B §B.6.
-
Every delivered message is answered through the session. A client MUST answer each message it decrypts with a delivery receipt (content type 14) encrypted on the same session, and MUST NOT offer a setting that turns these off. With single-initiator discipline (rule 3) this is what lets a conversation that only the responder writes reach a new epoch: the initiator's receipts are its sends. A client that read without answering would leave such a conversation on one epoch for as long as it ran. Several receipts MAY be batched into one message (
DirectReceipt.message_idsis repeated); what matters is that the initiator sends.
2.5 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.6 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 |
| Decrypted plaintext the core does not hand on (a control frame's header around its body, the notification peek's text) | As soon as it is classified (construct-core 0.45.1; Chapter 5 §5.8.4) |
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.7 Constants summary
For ease of cross-reference, all normative byte constants used in this specification:
| Constant | Value | Length |
|---|---|---|
| X3DH/prekey prologue | b"KonstruktX3DH-v1" | 16 B |
| Hybrid identity bind prologue | b"KonstruktHybridId-v1" | 20 B |
| Salt F (X3DH HKDF) | [0xFF; 32] | 32 B |
| Salt for initial DR root | [0xFE; 32] | 32 B |
| Info (PQXDH root key, prefix) | b"Construct-PQXDH-RootKey-v3" | 26 B |
| Info (KEM identity seed) | b"Construct-KEM-identity-v1" | 25 B |
| Info (identity answer, root / chain) | b"Construct-KEM-identity-root-v1" / b"Construct-KEM-identity-chain-v1" | 30 / 31 B |
| Info (classical X3DH root key, non-PQ builds) | b"Construct-X3DH-RootKey-v1" | 25 B |
| Kyber prekey sign-message suite byte | 0x11 | 1 B |
Kyber SPK maximum age (signed created_at) | 30 days | — |
PQXDH v2 flag in wire suite_id | 0x0100 | — |
KEM identity key / identity answer flags in wire suite_id | 0x0200 / 0x0400 | — |
| Info (initial DR root) | b"InitialRootKey" | 14 B |
| Info (Double Ratchet root step) | b"Double-Ratchet-Root-Key-Expansion" | 33 B |
| Info (Double Ratchet chain step) | b"Double-Ratchet-Chain-Key-Expansion" | 34 B |
| Info (PQ ratchet epoch chains) | b"construct-pqr-chains-v2" | 23 B |
| Info (PQ ratchet chain step) | b"construct-pqr-step-v2" | 21 B |
| Info (PQ ratchet message-key mix) | b"construct-pqr-msg-v2" (v1, retired with Suite 3) | 20 B |
| Info (PQ ratchet EK hash) | b"construct-pqr-ekhash-v1" | 23 B |
| Suite 1 ID | 0x0001 | 2 B (u16; LE in WirePayload, BE inside X3DH prologue) |
| Suite 2 ID | 0x0002 | 2 B (u16; LE in WirePayload, BE inside X3DH prologue) |
| Suite 3 ID (retired, PQR-2) | 0x0003 | 2 B; refused at unpack (RetiredSuite) and at session restore |
| Suite 4 ID | 0x0004 | 2 B (u16; LE in WirePayload) |
| PQ ratchet rekey cadence | 16 ratchet turns (clamped [4, 64]) | — |
PQ ratchet retained epochs' chains (PQ_CHAIN_RETENTION) | 2 (current + previous) | — |
| PQ key index wire encoding | unsigned LEB128, minimal, 1–5 B | — |
| ML-KEM-1024 encapsulation key (prekeys) | 1568 | B |
| ML-KEM-1024 ciphertext (first flight) | 1568 | B |
| ML-KEM-768 encapsulation key (PQ ratchet only) | 1184 | B |
| ML-KEM-768 ciphertext (PQ ratchet only) | 1088 | B |
| ML-KEM shared secret | 32 | B |
| 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.8 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
- NIST FIPS 204 (ML-DSA): https://csrc.nist.gov/pubs/fips/204/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 may have one or more active devices. All E2EE key
material is device-scoped: the server stores public keys only, never
private keys, and the key-service protobuf explicitly treats all keys
as device-specific (construct-server/shared/proto/services/key_service.proto:17-:18).
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 | Rotate weekly; clean peer acceptance ≤ 30 days | 32 B / 32 B | Medium-term, rotated periodically |
| One-time prekeys (OPK) | X25519 | Single use | 32 B / 32 B | Consumed on first message |
ML-KEM-1024 signed prekey (KEM-SPK; proto name kyber_pre_key) | ML-KEM-1024 | Rotate periodically; refused by peers when its signed created_at is > 30 days old | 1568 B / 64 B seed | Post-quantum part of every handshake |
ML-KEM-1024 one-time prekeys (KEM-OPK; proto name kyber_one_time_pre_key) | ML-KEM-1024 | Single use | 1568 B / 64 B seed | Post-quantum, consumed on use |
| Hybrid signature identity key | Ed25519 + ML-DSA-65 | Permanent; required to be opened to | 1984 B / 2016 B | PQ attestation key bound to the device signing key |
The X25519 and Ed25519 sizes are fixed by the underlying curves
(Curve25519, Edwards25519). The ML-KEM-1024 sizes are fixed by NIST
FIPS 203 (public key 1568 bytes, ciphertext 1568 bytes); the core keeps
the secret as the 64-byte seed it was generated from. ML-KEM-768 appears
only in the Suite 4 ratchet, never as a published prekey. The
hybrid signature sizes come from the implemented Ed25519 + ML-DSA-65
format in construct-core/src/crypto/suites/hybrid.rs:1-:35.
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
device_id is the stable device key identifier used for device
inventory, safety-number UX, Key Transparency leaves, and the
contact_id every session is keyed by. It is derived from the device identity public key 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 (construct-core/src/device_id.rs:16-:45).
An interoperable implementation MUST produce the same value from the
same IK_pub.
device_id is the Double Ratchet AEAD sender/receiver identifier:
a session is a ratchet between two devices, and AD version 3 binds the
two device ids plus the session id (§5.4). The core checks that a peer's
identity key derives to the contact_id a session is addressed by before
it seals a first flight to it (construct-core/src/orchestration/orchestrator.rs:1631).
The server's account UUID never enters the session layer.
Corrected 2026-10-16. Until this revision the paragraph said the opposite — that the AD binds server account UUIDs. Clients moved the session layer to device ids on 2026-08-26; the text was not updated.
3.5 Medium-term keys: signed prekeys
X25519 signed prekey (SPK)
The SPK is an X25519 keypair generated at registration. The shipping
client rotates its own SPK every 7 days and force-rotates around day 8
(construct-ios Services/Crypto/PreKeyRotationService.swift:29-:35).
The Rust core's clean peer-freshness boundary is
SPK_MAX_AGE_SECS = 30 * 24 * 3600
(construct-core/src/crypto/client_api.rs:61-:71). A bundle older
than this boundary is stale for the clean session-init path; the
reference also exposes an explicit degraded
init_session_allowing_stale path that skips only the freshness check
while still verifying signatures (construct-core/src/crypto/client_api.rs:365-:379).
The SPK carries a spk_rotation_epoch: u32. Each rotation increments
this counter monotonically. The current prekey signature does not
include the epoch; clients use the epoch and timestamp as freshness
signals beside the signed key.
When the application rotates the SPK, it MUST upload the new (public-key, signature, epoch) tuple to the server and SHOULD retain the previous SPK private half briefly so in-flight first messages that referenced the old bundle can still decrypt.
The Ed25519 signature over the X25519 SPK is computed over:
b"KonstruktX3DH-v1" || [0x00, 0x01] || SPK_pub
ML-KEM-1024 signed prekey (KEM-SPK)
An ML-KEM-1024 encapsulation key, the post-quantum counterpart of the SPK (Chapter 4 §4.3). Every Kyber prekey — signed and one-time — is signed over its creation time:
b"KonstruktX3DH-v1" || [0x00, 0x11] || created_at (u64 BE, unix seconds) || KEM_pub
twice: with the Ed25519 signing key and with the hybrid identity key
(construct-core/src/crypto/kyber_prekey_auth.rs:44-:57). created_at
is under both signatures because the bundle's upload time
(kyber_spk_uploaded_at) is the server's own, unsigned claim. An initiator
refuses a KEM-SPK whose signed created_at is more than 30 days old, and
unlike the classical SPK there is no stale-tolerant override: for a Kyber
key, skipping the check is the replay attack itself.
The v1 sign-message (suite byte 0x10, a 768-bit key, no time) is retired;
a v1 signature MUST NOT verify as v2. The protobuf field names still say
kyber_*.
The old secrets of a rotated SPK and KEM-SPK are kept for 14 days
(SPK_RETENTION_AFTER_ROTATION_SECS, crypto/keys.rs:18): the relay
queue holds a message for 7 days (QUEUE_TTL_SECS, the server's
MESSAGE_TTL_DAYS), and a handshake to the old key must still open.
Since construct-core 0.27.1 an initiator does not write on a handshake its
peer has not answered within 7 days (MAX_UNANSWERED_HANDSHAKE_AGE_SECS):
it retires the state and opens a new one from a fresh bundle. The three
numbers are one inequality, checked when the core compiles —
handshake age + queue ≤ retention — because a first flight is sealed under
its handshake's secret (Chapter 8, "First flight"), and one that outlives
its prekey names no writer and gets no answer.
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).
ML-KEM-1024 one-time prekeys (KEM-OPK)
A pool of single-use ML-KEM-1024 encapsulation keys, each signed like the
KEM-SPK (with its own created_at). The initiator prefers a KEM-OPK;
without one, or if the offered one fails its checks, it encapsulates to
the KEM-SPK and sets kyber_otpk_id = 0. Without a KEM-SPK that passes
its checks, no session is created: there is no classical fallback.
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 |
current_pq_epoch, PQ epoch chains, PQ skipped keys, pending PQ exchange/ciphertext | Suite 4 sparse continuous PQ ratchet state (per-message chains, §2.4), absent or inert in Suite 1/2 |
The Double Ratchet operations on these are specified in
Chapter 5. Suite 4 state is persisted
inside the CFE session-state payload as CfePqRatchetStateV2 (CFE field
pqr2; construct-core/src/cfe/types.rs). The earlier pqr key
(CfePqRatchetStateV1, one stored epoch secret) is retired with
Suite 3 and ignored on an old blob rather than migrated — a suite-3
session is not restored at all (§2.4). 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 state, CFE records (incl. dh_ratchet_private, RK, chain keys) | After-first-unlock, device-only — a push decrypt runs while the device is locked (construct-ios Security/KeychainManager.swift:21) |
| 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 directory bundle is a protobuf PreKeyBundle, not a packed binary
struct. Protobuf owns scalar encoding on the service boundary; the
canonical byte strings that are signed are specified separately in
§3.5 and §2.3.3. The fields a responder publishes are:
PreKeyBundle ::=
registration_id : u32
identity_key : bytes -- X25519 IK public
signed_pre_key : bytes -- X25519 SPK public
signed_pre_key_id : u32
signed_pre_key_signature : bytes -- Ed25519 over SPK sign-message
one_time_pre_key : optional bytes
one_time_pre_key_id : optional u32
crypto_suite : CryptoSuite
generated_at : int64
spk_uploaded_at : int64
spk_rotation_epoch : u32
-- PQXDH v2 (mandatory for opening); proto names say `kyber_*`.
kyber_pre_key : optional bytes -- ML-KEM-1024 KEM-SPK, 1568 B
kyber_pre_key_id : optional u32
kyber_pre_key_signature : optional bytes -- Ed25519 over the 0x11 sign-message
kyber_one_time_pre_key : optional bytes -- ML-KEM-1024 KEM-OPK, 1568 B
kyber_one_time_pre_key_id : optional u32
kyber_spk_uploaded_at : optional int64
kyber_spk_rotation_epoch : optional u32
-- Server / transparency / PQ signature extensions.
bundle_signature : bytes
hybrid_identity_key : optional bytes -- 1984 B
hybrid_identity_signature : optional bytes -- 64 B Ed25519 cross-signature
signed_pre_key_hybrid_signature : optional bytes -- 3373 B
kyber_pre_key_hybrid_signature : optional bytes -- 3373 B
supports_pq_ratchet : bool -- deprecated since PQXDH v2 (2026-09-25); ignored, field 24 kept only so the number is never reused
kyber_pre_key_created_at : optional u64 -- signed, field 25
kyber_one_time_pre_key_signature : optional bytes -- field 26
kyber_one_time_pre_key_created_at : optional u64 -- signed, field 27
kyber_one_time_pre_key_hybrid_signature : optional bytes -- field 28
The hybrid fields are optional in the schema but required by a client
opening a session (Chapter 4 §4.3). bundle_signature (the server's
signature over the bundle) is not a basis of trust: the device signatures
are what make the bundle independent of the server, and the server's
signature does not protect against the server.
The current schema is message PreKeyBundle in
construct-server/shared/proto/services/key_service.proto (fields 25–28
added with PQXDH v2, 2026-09-25).
supports_pq_ratchet (field 24) was, before PQXDH v2, a capability flag that let a session
negotiate the PQ ratchet. Since 2026-09-25 the PQ ratchet is mandatory wherever the core's
post-quantum feature is built (§2.4) and nothing reads this field: a client MAY still send it
for older servers, but a conforming server and client alike MUST ignore it. It is not removed
from the schema, only deprecated — reusing field 24 for something else would let an old bundle's
stale byte be misread by a new client.
A bundle consumer MUST verify the SPK signature against the published Ed25519 verifying key 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, KEM-SPK | Periodic + on app launch if stale | Old secrets kept 14 days after rotation; peers refuse a KEM-SPK whose signed age exceeds 30 days |
| OPK, KEM-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 state as a whole | Any message carrying the handshake header opens a new state; the replaced one is kept as a previous state (Ch. 5 §5.9). A DECRYPTION_ERROR naming the current state retires it (§5.10). | On demand; also a manual, local reset |
These rotation invariants are what give the protocol its forward secrecy and post-compromise security properties (Chapters 1 and 5).
3.11 Server signing keys, rooted offline
The server signs three kinds of object a client checks: sender
certificates (Chapter 8 §8.3), Key Transparency tree heads (Chapter 13)
and sticker manifests. Until construct-core 0.32.0 (2026-10-03) all three
were signed with one Ed25519 key that had no id and no lifetime, and
clients learned that key from the server's own /.well-known document —
so whoever held the server chose the key clients trusted, and a leaked
key signed for ever. construct-core 0.32.0 implements the replacement;
it is not yet in force, because no root is pinned (below).
The chain. Both steps are hybrid Ed25519 + ML-DSA-65 signatures (§2.3; both halves must verify):
root (offline; its public key pinned in the client build)
└─ delegation: "this key, for this purpose, from not_before to not_after" — signed by a root
└─ server signature: label(purpose) ‖ kid ‖ body — signed by that key
delegation = 0x01 ‖ purpose(1) ‖ BE64(not_before) ‖ BE64(not_after)
‖ public_key(1984) ‖ root_signature(3373) — 5 375 bytes
root signs "konstruct/v1/delegation" ‖ 0x01 ‖ purpose ‖ BE64 ‖ BE64 ‖ public_key
kid = SHA-256("konstruct/v1/kid" ‖ public_key)[0..16]
label = "konstruct/v1/sender-cert" (purpose 1) | "konstruct/v1/kt-head" (2)
| "konstruct/v1/sticker-manifest" (3)
Every variable-length field of a signed body is length-prefixed
(BE16 for certificate fields). The Ed25519 certificate signature that
preceded it had neither a label nor length prefixes, so "ab" ‖ "c" and
"a" ‖ "bc" signed the same bytes. The kid is derived from the key, so
the id a signature names and the key that checks it cannot disagree.
The format is one crate, construct-core/server-trust/src/lib.rs, used
by the core, the server and the offline root tool alike; its bytes are
pinned by conformance vectors (construct-core/tests/conformance/knst_server_trust.json).
What a client accepts (ServerKeyRing,
construct-core/src/crypto/server_trust.rs:139-:212):
- A delegation is admitted only if a pinned root signed it
(
verify_rooted,:110). The platform hands delegations to the core (OrchestratorCore::admit_server_delegations); a server that serves a delegation cannot have made it. - A server signature is accepted only from an admitted key, only for the
purpose its delegation names, only if its signing time falls inside the
delegation's window, and only if the verifier's clock is no later than
the window's end plus the purpose's grace: a sender certificate's grace
is its one-day maximum lifetime plus the relay's 7-day queue; a tree head
has none (it is checked when fetched); a sticker manifest is not bounded
(
Purpose::grace_secs,server-trust/src/lib.rs:99-:105). The grace, measured on the verifier's clock, is what ends a leaked key, since its holder can write any signing time it likes. - With roots pinned, an object that carries the hybrid signature is judged
by it alone — a good Ed25519 signature beside a failing hybrid one
does not rescue it. An object without the hybrid signature is still
judged by Ed25519 until
HYBRID_REQUIREDis set (server_trust.rs:56, currentlyfalse, while servers that do not sign hybrid are still answering). A delegation not yet admitted is transient: the open waits rather than refuses.
Status today. PINNED_ROOTS is empty
(construct-core/src/crypto/server_trust.rs:50): no root ceremony has
taken place, so no delegation can be admitted and the hybrid signature
decides nothing. Until a root is pinned, a client judges certificates and
tree heads exactly as before 0.32.0 — by Ed25519 against the keys the
platform hands to the core (set_trusted_server_keys: the server's
/.well-known key and any keys pinned in the app). The server can
therefore still choose the key a client trusts, and server signatures are
not post-quantum (PQC-2, Chapter 7 §7.3).
What has changed already: the decision is made in the core
(SenderCertificate::verdict, key_transparency::verify_proof), not by
each client, so it changes for every client at once when a root is pinned.
The refusal reasons behind those verdicts (TrustError) are listed in
Appendix A §A.13.
The root's private half is two 32-byte seeds (Ed25519, ML-DSA-65), each
written as a 24-word BIP-39 phrase — 48 words
(server_trust::root_words); it never touches a server.
Session Handshake (X3DH + PQXDH v2)
This chapter specifies how a Konstruct session between two devices is established. The classical part (X3DH) follows the Signal Protocol X3DH specification with the parameters in §4.1. The post-quantum part (PQXDH v2) adds an ML-KEM-1024 shared secret to the initial root-key derivation, so the session is secure from its first message if either the classical or the post-quantum component holds.
PQXDH v2 is mandatory: a new session is not created without a validly signed ML-KEM-1024 prekey (§4.5). There is no classical-only fallback.
Changed 2026-09-25 / 2026-09-27. Earlier revisions of this chapter specified PQXDH v1 (ML-KEM-768 mixed in after the first DH ratchet step, so the first chain was classical only), a responder that fetched the initiator's bundle from the server, and a tie-break rule for simultaneous initiation. All three are gone. See the changelog.
Keywords MUST, MUST NOT, SHOULD, MAY are per RFC 2119.
4.1 Notation and constants
| Symbol | Definition |
|---|---|
| INITIATOR (Alice) | The device that opens a session by sending to a device it holds no session with. |
| RESPONDER (Bob) | The device whose published bundle Alice consumes; Bob learns of the session when a message carrying the handshake header reaches him. |
| IK_X | Identity key of device X (X25519). Subscript pub / priv for the halves. |
| SK_X | Signing key of device X (Ed25519). |
| HIK_X | Hybrid identity key of device X (Ed25519 + ML-DSA-65), §2.3.3. |
| SPK_X | Signed prekey of device X (X25519). |
| OPK_X | A one-time prekey of device X (X25519). |
| EK_A | Ephemeral key generated by Alice, used once per handshake (X25519). |
| KEM_X | ML-KEM-1024 prekey of device X — the signed Kyber prekey (KEM-SPK) or a one-time Kyber prekey (KEM-OPK). Protobuf field names use kyber_*. |
| DH(a, B) | X25519 of private scalar a and public point B. 32 bytes. |
| KDF(salt, IKM, info, L) | HKDF-SHA-256 extract-then-expand, L bytes (hkdf crate). |
| F | Salt [0xFF; 32], as in Signal's X3DH §2.2. |
|| | Byte concatenation. |
Fixed byte strings:
| Constant | Value | Source |
|---|---|---|
| Prologue | b"KonstruktX3DH-v1" (16 bytes) | construct-core/src/crypto/keys.rs |
| Salt F | [0xFF; 32] | construct-core/src/crypto/handshake/x3dh.rs:169 |
| HKDF info, PQXDH root key | b"Construct-PQXDH-RootKey-v3" ‖ SHA-256(KEM_pub) ‖ SHA-256(kem_ct) ‖ SHA-256(KIK_A) | x3dh.rs (X3DH_ROOT_INFO_PQXDH, derive_root_key) |
| HKDF info, classical root key | b"Construct-X3DH-RootKey-v1" — builds without the post-quantum feature only; such builds cannot open sessions with the shipping clients | x3dh.rs:152 |
| Kyber prekey signature suite byte | 0x11 | construct-core/src/crypto/kyber_prekey_auth.rs:37 |
| Kyber SPK maximum age | 30 days, by signed created_at | kyber_prekey_auth.rs:42 |
| PQXDH v2 wire flag | bit 0x0100 of the wire suite_id | construct-core/src/wire_payload.rs (PQXDH_V2_FLAG) |
| KEM identity key flag | bit 0x0200: the initiator's KEM identity key follows the KEM ciphertext | wire_payload.rs (KEM_IDENTITY_FLAG) |
| Identity answer flag | bit 0x0400: the responder's answer to it follows | wire_payload.rs (IDENTITY_PROOF_FLAG) |
| KEM identity seed | HKDF-SHA256(salt = ∅, IKM = ML-DSA-65 seed of the hybrid key, info = b"Construct-KEM-identity-v1", L = 64) | construct-core/src/crypto/keys.rs (kem_identity_seed) |
| Identity-answer mix labels | b"Construct-KEM-identity-root-v1", b"Construct-KEM-identity-chain-v1" | double_ratchet/internals.rs (mix_identity_secret) |
| Suite identifiers | 0x0001 classic, 0x0002 PQ hybrid signatures, 0x0004 PQ ratchet (0x0003 retired, PQR-2) | construct-core/src/crypto/suite_id.rs |
An interoperable implementation MUST use these exact byte values; any difference produces a different root key and the first message fails its AEAD check.
4.2 Bundle publication (Bob)
Before any handshake, Bob MUST have published a bundle (wire format in §3.9). Bob MUST:
-
Generate an SPK and an ML-KEM-1024 KEM-SPK, and a batch of OPKs and KEM-OPKs.
-
Sign the SPK:
sig_SPK = Ed25519_Sign(SK_priv, b"KonstruktX3DH-v1" || [0x00, 0x01] || SPK_pub). -
Sign every Kyber prekey, signed and one-time alike, over its creation time:
m = b"KonstruktX3DH-v1" || [0x00, 0x11] || created_at (u64 BE) || KEM_pub sig_K = Ed25519_Sign(SK_priv, m) hsig_K = Hybrid_Sign(HIK_priv, m) -- Ed25519 + ML-DSA-65created_atsits under the signature because the bundle's upload time is the server's own, unsigned claim; without it a server could serve a long-rotated key as current (kyber_prekey_auth.rs:44-:57). -
Publish the hybrid identity key
HIK_pubwith its cross-signature (§2.3.3). It is required: PQXDH v2 refuses a bundle without it. -
Upload the bundle to the directory and rotate SPK and KEM-SPK periodically. Old SPK secrets (classical and Kyber) are kept for 14 days after rotation, so that handshakes already in the relay's 7-day queue still open (
SPK_RETENTION_AFTER_ROTATION_SECS,crypto/keys.rs:18).
The Kyber secret keys, the choice of prekey and decapsulation live in the core only; platforms store and publish what the core produces and never handle ML-KEM secrets.
4.3 Initiator path
Alice opens a session with one of Bob's devices when she has a message for it and holds no session with it.
Step 1: Fetch and validate the bundle
Alice fetches the bundle of Bob's device. She MUST:
-
Verify
sig_SPKunderSK_pub. On failure, abort. -
Check SPK freshness (
SPK_MAX_AGE_SECS, 30 days). Implementations MAY expose an explicit degraded path that skips only this check for the classical SPK; it never applies to Kyber prekeys (step 4). -
If the bundle offers an OPK, select exactly one.
-
Plan the post-quantum part (
construct-core/src/orchestration/pq_prekey_plan.rs:108,plan_pqxdh). Alice MUST refuse the bundle — and create no session — if any of these holds:Refusal Condition NoKyberPrekeyno Kyber prekey offered KyberPrekeyMalformedthe key is not a 1568-byte ML-KEM-1024 key KyberSignatureMissing/KyberSignatureInvalidsig_Kabsent or not verifyingHybridIdentityMissing/HybridIdentityBindingInvalidno HIK_pub, or its cross-signature failsHybridSignatureMissing/HybridSignatureInvalidhsig_Kabsent or not verifyingKyberPrekeyStalethe KEM-SPK's signed created_atis older than 30 daysHybridIdentityChangedHIK_pubdiffers from the one pinned for this deviceA KEM-OPK is preferred; if it fails its checks, the KEM-SPK is used, and the KEM-SPK MUST pass them. The fingerprint of
HIK_pubis pinned once X3DH has verified the bundle.
There is no PQ-ratchet downgrade check here: the PQ ratchet (Suite 4, §2.4) is mandatory wherever
post-quantum is built, so there is nothing a bundle can withhold to downgrade it.
Removed with PQXDH v2 (2026-09-25, construct-core 0.18). Before then, step 5 of this list refused a bundle (
PQ_DOWNGRADE_REFUSED) if this device had previously advertised or used the PQ ratchet and the fetched bundle no longer advertised the unsignedsupports_pq_ratchetcapability. The same commit that made PQXDH v2 mandatory removed the capability flag and its downgrade ledger together: once every build negotiates the PQ ratchet unconditionally, a capability nobody can withhold cannot be downgraded.
Step 2: Classical DH
Alice generates EK_A and computes, in this order:
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
Without an OPK the handshake is 3-DH, with reduced forward secrecy for the first flight; this is
the open issue BS-3.
Step 3: ML-KEM-1024 encapsulation
(kem_ct, kem_ss) = ML-KEM-1024.Encaps(KEM_pub_B) -- kem_ct: 1568 bytes, kem_ss: 32 bytes
Step 4: Root key
IKM = DH1 || DH2 || DH3 [|| DH4] || kem_ss
info = b"Construct-PQXDH-RootKey-v3" || SHA-256(KEM_pub_B) || SHA-256(kem_ct) || SHA-256(KIK_A)
SK_root = KDF(salt = F, IKM, info, L = 32)
(x3dh.rs, derive_root_key.) KIK_A is Alice's ML-KEM-1024 identity key (§4.4.4). The Kyber
key, the ciphertext and KIK_A are bound through info; a KIK_A substituted in transit gives
Bob a different root key, so the first message fails rather than a foreign key being pinned. v2
of this label had no KIK_A; a v2 first message is refused. ML-KEM already
hashes the encapsulation key into its secret; binding both explicitly removes any reliance on a
particular KEM's properties. Implementations MUST zeroise IKM and kem_ss after this step.
Because kem_ss enters the initial root key, every message of the session — including the
first — is protected by both components.
Step 5: First message and the handshake header
Alice initialises the Double Ratchet from SK_root, taking SPK_B_pub as the initial remote
ratchet key, and encrypts. Every message she sends on this state carries the handshake
header until Bob's first reply arrives (construct-core/src/crypto/messaging/double_ratchet/mod.rs:306-:317):
EK_A_pubandopk_id(in the header fields of §5.3);kem_ct(1568 bytes) andkyber_otpk_id(0 when the KEM-SPK was used);KIK_A, Alice's ML-KEM-1024 identity key (1568 bytes), with bit0x0200;- the PQXDH v2 flag, bit
0x0100of the wiresuite_id. The receiver strips it before the suite is used; its purpose is that a core without PQXDH v2 rejects the frame as an unknown suite instead of deriving a classical key and reading the AEAD failure as corruption.
Repeating the header means Bob can build the session from whichever message of the first flight reaches him first. Once Bob replies, Alice stops sending it.
4.4 Responder path
4.4.1 What opens a session
A message opens a new state if and only if it carries a KEM ciphertext
(construct-core/src/orchestration/receiving_init_plan.rs:37). Its message number does not
matter: a header on message 5 opens exactly as one on message 0. Until 2026-09-27 only message 0
could open, and a lost first message cost the session until a reset.
- If Bob holds no state with Alice's device, the message is queued and opened (§4.4.2).
- If Bob holds a state and the message decrypts on it or on a previous state (§5.9), it is simply delivered.
- If Bob holds a state and nothing held decrypts it but it carries the header, a new state is opened from it. The state it replaces is not dropped: it becomes a previous state (§5.9).
4.4.2 Opening without the server
Bob does not fetch Alice's bundle to open. A sealed message carries a sender certificate
(§8), and the certificate names Alice's identity key and device id, signed by the server.
Bob's core opens with the key the certificate names once the certificate passes
identity_for_opening (construct-core/src/crypto/sealed_sender/mod.rs:299):
- The certificate signature verifies under a server key the client accepts
(
check_signature,:323; Chapter 3 §3.11): a delegated hybrid key once a root is pinned, otherwise one of the Ed25519 certificate-signing keys the platform handed to the core. With no such key held yet, nothing is dropped and the open is retried later. - The identity key is 32 bytes and derives to the device id the certificate names. A certificate naming someone else's device is refused.
- The certificate has not been expired for longer than a message may wait at the relay
(
MAX_DELIVERY_AGE_SECS, 7 days,sealed_sender/mod.rs:213).
The signature is mandatory here and only here. Anyone who knows Bob's public identity key can seal an envelope to him, so an unsigned certificate is a stranger's claim to be any account. Before 2026-09-27 that did not matter because the key came from a bundle the server served; now the key comes from the certificate, and the server's signature is what binds the key to an account. Delivery into an existing session does not consult the certificate: the ratchet authenticates it.
What this removes: the server no longer learns, from a bundle fetch, whom a sealed first message was from, and the receiver no longer guesses which of the sender's devices wrote it.
Every message names its writer. A first message names its device through the certificate; a
later sealed message through the session envelope's tag (Chapter 8). A receiver therefore decrypts
a message on the one session of the device it names, and never tries it against each of a peer's
devices in turn. construct-core 0.34.0 (2026-10-04) removed the export that planned such a walk
(plan_receiving_decrypt); it had been used only for unsealed messages from a peer.
4.4.3 Key agreement
Bob computes
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 present
kem_ss = ML-KEM-1024.Decaps(KEM_priv_B[kyber_otpk_id or SPK], kem_ct)
derives SK_root exactly as in §4.3 Step 4, initialises the receiving ratchet with EK_A_pub,
and decrypts. If the AEAD fails, the attempt leaves nothing behind: a state already held with
Alice's device is put back unchanged. Only after a successful decrypt does Bob delete the
consumed OPK_priv and KEM-OPK secret.
If the named OPK or Kyber prekey is no longer held, Bob cannot reproduce the key. He answers with
a DECRYPTION_ERROR carrying the hint PrekeyUnavailable (§5.10), and Alice's next open skips
one-time prekeys.
4.4.4 The initiator proves its KEM identity key
Everything above authenticates Alice to Bob classically: DH1 uses her X25519 identity key and
the certificate carries the server's Ed25519 signature. A quantum adversary able to break both at
the time of the attack could open a state "from Alice", and since any handshake header opens a
state (§5.9), at any time. Alice's side does not have this gap — she encapsulates to a Kyber key
signed by Bob's pinned hybrid key. The proof below closes Bob's side without a signature, so the
first contact stays deniable (decisions/responder-authenticates-initiator-by-kem.md).
-
The key. Every device has an ML-KEM-1024 identity key
KIK, whose 64-byte seed is derived from the ML-DSA-65 seed of its hybrid key (§4.1). It is not stored separately and is restored with the private keys. The X25519 identity is not a usable source: its private half follows from the public one for a quantum adversary. -
The pin. Bob pins
SHA-256(KIK_A)for Alice's device on the first open that decrypts. An open naming anotherKIKfor a pinned device is refused (KEM_IDENTITY_CHANGED); one naming none is refused (KEM_IDENTITY_REQUIRED). The first contact is trust-on-first-use, as the initiator's hybrid pin is. -
The answer. Having opened, Bob computes
(kik_ct, kik_ss) = ML-KEM-1024.Encaps(KIK_A)and, before sending anything, replaces the root key and his first sending chain key:RK' = HKDF(salt = kik_ss, IKM = RK, info = "Construct-KEM-identity-root-v1", L = 32) CKs' = HKDF(salt = kik_ss, IKM = CKs, info = "Construct-KEM-identity-chain-v1", L = 32)kik_ct(1568 bytes, bit0x0400) rides on every message Bob sends until Alice has proved herself. -
Alice decapsulates
kik_ctwithKIK_Aand applies the same two derivations on her first receiving DH ratchet step — to the root and the receiving chain that step produced. Without the answer that step fails (the core reports it); with a wrong key the AEAD fails. -
The proof. A message from Alice that Bob decrypts on a chain after that step could only be derived by the holder of
KIK_A. Bob stops attachingkik_ctand labels the stateReceivedProven; until then it isReceived.
What this does not give: messages of Alice's first flight are authenticated classically, and an unproven state is not treated differently from a proven one when it becomes current. Both are open questions in the decision.
4.5 Simultaneous opening
There is no tie-break. If Alice and Bob open at the same time, each holds the state it opened and receives the other's header; each opens the peer's state beside its own. Both states are kept (§5.9) and the first message either side reads settles which one is current. Nothing is announced, confirmed, ranked or waited for.
A device keeps at most one open in flight per peer device: two concurrent opens would spend
two of the peer's one-time prekeys for one state that will be used
(construct-core/src/orchestration/session_machine.rs, Opening, 30 s time-out).
4.6 Suite 4 is not negotiated
Suite 4 (the sparse continuous ML-KEM-768 ratchet, per-message chains — §2.4, §5) is not
negotiated from the bundle's crypto_suite, or from anything else: it is mandatory wherever the
core's post-quantum build feature is present, which is every shipping platform build. The
handshake itself is the same PQXDH v2 for every suite; Suite 4 is the ratchet layer that
continues after it. The split mirrors Signal (PQXDH and SPQR) and Apple PQ3: ML-KEM-1024 for
prekeys and the first message, ML-KEM-768 in the ratchet.
Changed with PQXDH v2 (2026-09-25, construct-core 0.18). Before then this suite (numbered 3) was negotiated per session from an unsigned
supports_pq_ratchetbundle capability, with a downgrade refused (PQ_DOWNGRADE_REFUSED, §4.3 Step 1) if a device that had used it saw the capability withdrawn. The same commit that made PQXDH v2 mandatory removed the capability and the downgrade check together, heading this section "Suite 3/4 negotiation boundary" until this revision — there has been no negotiation boundary to describe since 2026-09-25.
Suite 3 — the same mandatory ratchet, but an epoch secret mixed once per epoch instead of a chain per message — is retired as of construct-core 0.24.0 without a compatibility path: a suite-3 message is refused at unpack, and a suite-3 stored session is refused at restore, so the next send opens a suite-4 session instead (§2.4).
4.7 Failure modes
| Failure | Required behaviour |
|---|---|
sig_SPK fails | Abort. Do not retry against the same bundle. |
Any PqxdhRefusal (§4.3 Step 1.4) | Abort. No session is created; there is no classical fallback. |
Sender certificate fails identity_for_opening | Do not open. NoTrustedKey is transient and retried; the others are refusals. |
| Responder AEAD fails on the first message | Nothing changes; a held state is restored. OPK is not consumed. |
| Named OPK / Kyber prekey not held | DECRYPTION_ERROR with PrekeyUnavailable (§5.10). |
| First flight names no KEM identity key | Refuse (KEM_IDENTITY_REQUIRED). |
| KEM identity key differs from the one pinned for the device | Refuse (KEM_IDENTITY_CHANGED); a held state is kept. |
| A reply carries no answer where the first receiving step needs one | The message does not decrypt; nothing changes. |
| Both devices open at once | Both states kept; see §4.5. |
4.8 Reference
- Root key and X3DH:
construct-core/src/crypto/handshake/x3dh.rs - ML-KEM:
construct-core/src/crypto/pq_x3dh.rs(mlkem1024_*) - Kyber prekey signatures:
construct-core/src/crypto/kyber_prekey_auth.rs - PQ plan and refusals:
construct-core/src/orchestration/pq_prekey_plan.rs - Receiving open:
construct-core/src/orchestration/orchestrator.rs(open_receiving) - Sender certificate check:
construct-core/src/crypto/sealed_sender/mod.rs - Design record: construct-docs
decisions/pqxdh-v2-mandatory-pq-cutover.md,decisions/first-message-opens-without-the-server.md
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 |
current_pq_epoch | u32 | 0 | Suite 4 (PQ ratchet) only: highest completed PQ epoch — the one outgoing messages key on |
pq_chains | bounded list | {} | Suite 4 only: the current and previous epoch's send/receive chain keys (§2.4), capped at PQ_CHAIN_RETENTION = 2. Not the ML-KEM shared secret itself — that is spent into these chains once and discarded (PQR-2) |
pq_skipped_keys | Map<(epoch, index), [u8;32]> | {} | Suite 4 only: PQ chain keys passed over on the way to a later index — the PQ counterpart of MKSKIPPED, bounded the same way |
| pending PQ exchange / ciphertext | optional | none | Suite 4 only: in-flight sparse PQ ratchet material (the exchange protocol — EK proposal, CT completion, cadence — is unchanged by PQR-2; only what a completed epoch yields changed) |
| handshake header | optional | Ch. 4 §4.3 Step 5 | INITIATOR only: EK_A, opk_id, kem_ct, kyber_otpk_id, repeated on every outgoing message until the peer's first reply |
The reference implementation packs these into
SessionState in construct-core/src/crypto/messaging/double_ratchet/.
A device holds, per peer device, one current state and up to three previous states (§5.9). What this chapter calls "the session" is the current state unless stated otherwise.
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"Double-Ratchet-Root-Key-Expansion", L = 64)
-> (output[0..32], output[32..64])
KDF_CK(ck) -> (mk, ck')
= HKDF-SHA-256(salt = ck, IKM = empty,
info = b"Double-Ratchet-Chain-Key-Expansion", L = 64)
-> (output[0..32], output[32..64])
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. The initial X3DH root key is first normalised for the
Double Ratchet with HKDF(salt = [0xFE; 32], IKM = x3dh_root, info = b"InitialRootKey", L = 32).
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 extension fields) ::=
message_number : u32 little-endian (4 B)
dh_public_key : [u8; 32] (32 B)
otpk_id : u32 little-endian (4 B) -- 0 if N/A
kyber_otpk_id : u32 little-endian (4 B) -- 0 if N/A
kem_len : u16 little-endian (2 B)
prev_chain_length : u32 little-endian (4 B)
suite_id : u16 little-endian (2 B) -- bits 0x0100 / 0x0200 / 0x0400, below
-- followed by kem_ct (kem_len bytes; absent when kem_len = 0)
-- followed by u16 LE len + KIK_A (1568 B) when bit 0x0200 is set (first flight only)
-- followed by u16 LE len + kik_ct (1568 B) when bit 0x0400 is set (responder, until proved)
-- followed by PQ-ratchet section when suite_id = 0x0004 (below); suite_id = 0x0003 is the
retired epoch-granular ratchet and is refused outright, before this section is read
-- 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:25-:56). The
variable KEM ciphertext follows the header when kem_len > 0: it is the
PQXDH v2 handshake header (Ch. 4 §4.3 Step 5), 1568 bytes of ML-KEM-1024
ciphertext, present on every message of the initiator's first flight and
absent afterwards. A frame that carries it sets bit 0x0100 of suite_id
(PQXDH_V2_FLAG, wire_payload.rs:39); unpack strips the bit, so the
suite the AEAD authenticates is the low byte. Bit 0x0200 marks the
initiator's KEM identity key after the ciphertext, and is valid only with
0x0100; bit 0x0400 marks the responder's answer to it (Ch. 4 §4.4.4).
Both objects are exactly 1568 bytes; any other length is refused at
unpack. A platform MUST hand the whole payload to the core: a message
rebuilt from parsed fields loses the answer, and the initiator cannot
read the reply. A KEM ciphertext on a frame
is exactly what makes it able to open a session (§4.4.1). The
pack/unpack routines are wire_payload::pack /
wire_payload::unpack; deviating from the ordering or endianness
produces non-interoperable frames.
Suite 4 (the PQ ratchet, per-message chains — §2.4) adds a PQ section between kem_ct and the
AEAD frame:
PqRatchetSection ::=
pq_message_epoch : u32 little-endian
pq_key_index : unsigned LEB128, minimal encoding, 1-5 bytes
field_type : u8 -- 0 none, 1 EK proposal, 2 CT completion
field_type = 1:
field_epoch : u32 little-endian
ek_len : u16 little-endian
ek : bytes -- ML-KEM-768 public key, normally 1184 B
field_type = 2:
field_epoch : u32 little-endian
ek_hash : [u8; 8]
ct_len : u16 little-endian
ct : bytes -- ML-KEM-768 ciphertext, normally 1088 B
pq_message_epoch and pq_key_index are always present for Suite 4, even when field_type = 0.
They are 0 (no PQ epoch has completed yet) on the first messages of a session, and no PQ section
is encoded at all for Suite 1 and Suite 2 frames.
pq_key_index MUST be encoded as minimal unsigned LEB128: seven bits per byte low-group
first, the high bit set on every byte but the last, at most five bytes, no bits set past bit 31,
and no encoding with a redundant (non-minimal) leading byte — e.g. 0x80 0x00 for the value 0
MUST be rejected. A receiver that accepts a non-minimal encoding would let the wire carry two
distinct byte sequences for the same numeric value; the AD (§5.4) binds the value, not its
bytes, so a relay could then flip the encoding of an in-flight frame without the AEAD noticing.
NonCanonicalKeyIndex is the error for any of these violations, and PqKeyIndexWithoutEpoch is
the error for a non-zero pq_key_index on a frame whose pq_message_epoch is 0 (Appendix A
§A.4). A frame whose suite_id (after stripping the flag bits) is 0x0003 is rejected as
RetiredSuite(3) before any of this is parsed — the retired suite had no pq_key_index field at
all, so reading its bytes under the suite-4 layout would silently misparse them.
Reference: wire_payload::pack / wire_payload::unpack, and the LEB128 codec
(read_leb128 / write_leb128) — construct-core/src/wire_payload.rs.
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)
sender_device_id : utf-8 bytes (32 hex chars) (32 B; §3.4)
receiver_device_id : utf-8 bytes (32 hex chars) (32 B; §3.4)
session_id : [u8; 16] (16 B; decoded from 32 lowercase hex chars)
dh_public_key : [u8; 32] (32 B)
message_number : u32 big-endian (4 B)
pq_message_epoch : u32 big-endian (4 B; Suite 4 only)
pq_key_index : u32 big-endian (4 B; Suite 4 only)
Total length with 32-character device ids: 117 bytes without the PQ
fields, 125 bytes for Suite 4, which every current session uses (the
retired Suite 3 bound only pq_message_epoch — one u32 short of binding
the key index at all). The session id is derived as
HKDF(salt = x3dh_root, IKM = b"construct-session-id", info = b"Construct-SessionID-v2\x00" || min_id || 0x00 || max_id, L = 16) over the same two device ids, and stored as hex
(construct-core/src/crypto/messaging/double_ratchet/mod.rs:278-:299).
The reference constructs encryption AD in
construct-core/src/crypto/messaging/double_ratchet/messaging.rs:317-:343
and decryption AD in internals.rs:762-:793.
Corrected 2026-10-16. Earlier revisions named the two fields
sender_user_id/receiver_user_id, 36-character server UUIDs, and gave 125 / 133 bytes. The session layer has been keyed by device ids since 2026-08-26 (§3.4); the field layout is unchanged, only the identifiers and therefore the lengths.
Order is normative. Each direction of a session computes its own AD —
ENCRYPT uses (local_id_sender, contact_id_receiver); DECRYPT
uses (contact_id_sender, local_id_receiver) — the core's local_user_id
and contact_id, both device ids — with the field
positions swapped so the AD on each side matches:
ENCRYPT side (Alice → Bob):
AD = 0x03 || alice_device_id || bob_device_id || session_id || ...
DECRYPT side (Bob receiving from Alice):
AD = 0x03 || alice_device_id || bob_device_id || session_id || ...
i.e. the "sender_id" position is always populated with the sender's device id regardless of which side is computing AD. A mismatch (e.g. one side passing a 36-character account UUID where the other passes the 32-character device id) produces a different AD and an AEAD failure on every message, with no other symptom.
5.4.1 One AD version
A receiver MUST decrypt with AD_VERSION = 3 only. A message whose AD was
built with version 2 does not open and is not retried
(construct-core/src/crypto/messaging/double_ratchet/internals.rs:727-:731,
decrypt_with_key; test test_ad_v2_message_is_refused).
Changed in construct-core 0.24.2 (2026-09-30). Until then a failed AEAD was retried once with
AD_VERSION = 2, for messages in flight during the v2 → v3 migration. Every session is PQXDH v2 since 2026-09-25 and every PQXDH v2 writer uses AD v3, so the retry could no longer open anything (SEC-006, resolved). This section kept prescribing the retry until 2026-10-16.
5.5 Encryption (RatchetEncrypt)
RatchetEncrypt(state, plaintext, peer_id):
1. padded = pkcs7_pad(plaintext, 255) -- §5.7
2. (mk_dr, CKs') = KDF_CK(state.CKs)
3. state.CKs = CKs'
4. (pq_epoch, pq_index, pq_key) = pq_send_key(state) -- §2.4; (0, 0, None) unless
-- state.suite_id = 4 and an epoch has completed
5. mk = mix_pq_message_key(mk_dr, pq_key)
6. message_number = state.Ns
7. state.Ns += 1
8. header = {
message_number = message_number,
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,
}
9. ad = build_ad(AD_VERSION_3, state.local_user_id,
peer_id, state.session_id,
state.DHs.pub, message_number,
(pq_epoch, pq_index) if state.suite_id = 4)
10. (nonce, ct, tag) = AEAD-Encrypt(key = mk,
plaintext = padded,
associated_data = ad)
11. zeroise(mk_dr, mk, pq_key)
12. return wire_payload::pack(header, kem_ct = None,
sealed_box = nonce || ct || tag,
pq_message_epoch = pq_epoch,
pq_key_index = pq_index,
pq_ratchet_field = pending Suite 4 field, if any)
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.
Step 4, pq_send_key, advances the current epoch's send chain (§2.4) before the AEAD runs — a
message that is then never sent (dropped locally, app killed) leaves a gap in the chain that the
receiver treats exactly like a skipped classical message, not as a state divergence.
mix_pq_message_key (step 5) returns the Double Ratchet message key unchanged when pq_key is
absent — no epoch has completed yet (pq_epoch = pq_index = 0), or the suite is not the PQ
ratchet. Otherwise it derives HKDF(salt = pq_key, IKM = mk_dr, info = b"construct-pqr-msg-v2", L = 32): the mix shape is unchanged from the retired Suite 3 (the PQ key is the HKDF salt); what
changed is where pq_key comes from — the next key of the current epoch's send chain (§2.4), not
a secret stored once per epoch. Reference: mix_pq_message_key, pq_send_key
(construct-core/src/crypto/messaging/double_ratchet/internals.rs).
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
-- DH ratchet check (skipped-key expiry: §5.8.2)
4. If header.dh_public_key != state.DHr:
SkipChainKeysUntil(state, header.prev_chain_length)
DHRatchetStep(state, header.dh_public_key)
-- Message key lookup (PQ chain retention: §5.8.3)
5. SkipChainKeysUntil(state, header.message_number)
6. (mk_dr, CKr') = KDF_CK(state.CKr)
7. state.CKr = CKr'
8. state.Nr += 1
-- AEAD decrypt (one AD version, §5.4.1)
9. pq_key = pq_receive_key(state, header.pq_message_epoch, header.pq_key_index) -- §2.4, below
10. mk = mix_pq_message_key(mk_dr, pq_key)
11. ad = build_ad(AD_VERSION_3, peer_id, state.local_user_id,
state.session_id, header.dh_public_key,
header.message_number,
(header.pq_message_epoch, header.pq_key_index) if state.suite_id = 4)
12. padded = AEAD-Decrypt(key = mk, ciphertext = framing,
associated_data = ad) -- failure: restore snapshot, error
13. commit Suite 4 PQ field only after authenticated decrypt succeeds
14. zeroise(mk_dr, mk, pq_key)
15. plaintext = pkcs7_unpad(padded)
16. return plaintext -- a KNST frame, §5.8.4
pq_receive_key(state, epoch, index) (step 9; suite 4, epoch != 0) is the PQ counterpart of
the classical chain-skip in step 5, and is where the new forward secrecy actually lives:
- If
(epoch, index)is a kept PQ skipped key, remove and return it. - Otherwise locate
epoch's chain: the current epoch, the previous one (PQ_CHAIN_RETENTION = 2, §5.8.3), or — responder only, before promotion — the provisional chain of an epoch not yet activated. Anepochmatching none of these is a hard decrypt error: silently skipping the mix would be a downgrade, and it is exactly the case a compromised, evicted chain could otherwise be substituted for. - If
indexis behind the chain's current position, the key was already spent: reject as a duplicate (error prefixedMESSAGE_KEY_CONSUMED). - If
indexis more thanMAX_MESSAGE_JUMPahead of the chain's position, reject as a DoS attempt (§5.8.1). - Step the chain forward to
index(pq_chain_step, §2.4), keeping every passed-over key as a PQ skipped key (pq_skipped_keys, keyed by(epoch, index)), bounded by the sameMAX_SKIPPED_MESSAGEScount andMAX_SKIPPED_MESSAGE_AGE_SECONDSage asMKSKIPPED. Return the key atindex.
For epoch = 0 (no PQ epoch completed yet), pq_receive_key returns no key, and a non-zero
index is itself an error — a frame cannot name a key index without naming its epoch. Every
mutation this makes (the chain's position, any newly kept skipped keys) is covered by the same
decrypt-snapshot rollback as the classical chain: if the AEAD in step 12 ultimately fails, the
snapshot taken before decryption began is restored, so a forged or tampered pq_key_index
consumes nothing. Reference: pq_receive_key, pq_chain_step
(construct-core/src/crypto/messaging/double_ratchet/internals.rs).
For Suite 4, a carried EK/CT field is processed only after the carrier
message has authenticated and decrypted successfully (step 13). A well-formed but
cryptographically unusable EK/CT field is ignored for PQ state and MUST
NOT roll back already delivered classical plaintext; an invalid wire
encoding is rejected by wire_payload::unpack (Appendix A §A.4).
5.8.1 Mandatory DoS guards
| Constant | Default | Source |
|---|---|---|
MAX_SKIPPED_MESSAGES | 1000 | construct-core/src/config.rs:143 |
MAX_MESSAGE_JUMP | 2000 | construct-core/src/config.rs:144 |
MAX_SKIPPED_MESSAGE_AGE_SECONDS | 604800 (7 days) | :145 |
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.8.3 PQ ratchet retention (Suite 4)
| Constant | Value | Source |
|---|---|---|
PQ_CHAIN_RETENTION | 2 (the current epoch and the one before it) | construct-core/src/crypto/messaging/double_ratchet/mod.rs |
| PQ skipped-key bounds | shared with MKSKIPPED: MAX_SKIPPED_MESSAGES (1000), MAX_MESSAGE_JUMP (2000), MAX_SKIPPED_MESSAGE_AGE_SECONDS (7 days) | §5.8.1 |
Only the current epoch keeps a send chain: when a newer epoch starts sending, an older epoch's
send chain (if it still had one) is erased outright — nothing is ever written on it again. Its
receive chain is kept one epoch longer, so a message sent just before the peer switched epochs
still decrypts; beyond PQ_CHAIN_RETENTION the whole epoch — chain and any of its still-kept
skipped keys — is dropped.
This replaces the retired Suite 3's PQ_EPOCH_RETENTION = 4 stored epoch secrets, and is the
answer to PQR-4 (Chapter 7 §7.3): the bound on how far out of order a PQ-ratchet message can
arrive and still decrypt is now the same skipped-key window the classical half already uses, not
an independently-sized epoch count. A message naming an epoch outside this window is a hard
decrypt error, by the same no-silent-downgrade rule as an unknown epoch (§5.8, step 9).
5.8.4 The plaintext frame (KNST) and what a decrypted message is
What the ratchet encrypts (before padding, §5.7) is a KNST frame: a
30-byte header and a body (construct-core/src/knst.rs:1-:13).
[0..4] magic "KNST"
[4] version 0x01
[5] content_type — inside the ciphertext, so the server cannot read it
[6..22] message id, the UUID's 16 raw bytes
[22..24] chunk_index u16 BE
[24..26] total_chunks u16 BE
[26..30] plaintext_length u32 BE — the whole body's length, in every frame
[30..] body
A body is split into frames of at most 3 770 bytes (CHUNK_PAYLOAD_SIZE),
at most 256 frames (MAX_CHUNKS); a body that needs more is refused, not
truncated, and an empty body is one frame (encode_chunks, knst.rs:132).
A control payload is always one frame (frame_whole, :125). Since
construct-core 0.31.0 (2026-10-02) the core writes and parses the frame
for every client (UDL knst_encode_chunks, knst_frame_whole,
knst_parse); before that each client did so by hand. The byte layout is
pinned by construct-protos/conformance/knst_frame.json. Reassembling a
chunked body is still the client's.
Byte 5 is the authority for what a sealed message is. A sealed
envelope's outer type is generic (Chapter 8 §8.2), so a receiver MUST
classify a decrypted message by the frame's byte 5, not by the envelope.
The core does, in this order (decrypted_kind,
construct-core/src/orchestration/orchestrator.rs:3097):
- A single-frame control frame (
total_chunks = 1, length within the payload) whose byte 5 is 12 is a call signal: actionCallSignalDecryptedwith the frame's body (construct-core 0.29.0). - A single-frame control frame whose byte 5 is in the silent control
set is action
ControlFrameDecrypted { content_type, body }: never a chat message, no notification, nothing for the transcript (0.30.0). - An identified envelope of type 12 with an unframed body is a call signal.
- Anything else is a message, announced unless its type is 13 or 14.
A frame that claims a control type but is one chunk of several is not
taken for a control frame. The silent control set
(SILENT_CONTROL_TYPES, knst.rs:38) is pinned by a test to the
cross-client table construct-protos/conformance/knst_content_types.json:
| Byte 5 | Name | Note |
|---|---|---|
| 12 | CALL_SIGNAL | its own action, CallSignalDecrypted |
| 13 | HEARTBEAT | |
| 14 | DELIVERY_RECEIPT | |
| 25, 26 | SESSION_PING, SESSION_READY | retired since 2026-09-27 — nothing sends them; still classified silent, so a frame of either never becomes a chat message |
| 27 | CONTACT_CARD | |
| 29 | PROFILE | |
| 30 | READ_SYNC | construct-core 0.48.1; own devices only |
READ_SYNC (30) carries how far the reader has read each chat, from one
of a user's devices to its others. A client MUST NOT address it to a
contact, and a receiver MUST drop one that did not come from its own
account (own_devices_only in the table). The core classifies the frame
as silent; the own-account check is the client's (construct-ios
Services/Messaging/MessageRouter.swift:1665-:1671). Before
construct-core 0.48.1 a frame of type 30 read as a message.
Decrypted text the core no longer needs is wiped. When only a control
frame's body leaves the core, the whole decrypted frame is zeroised rather
than merely freed (take_frame_body, orchestrator.rs:3117), and the
notification peek below zeroises what it decrypted as soon as it has
classified it (peek_verdict, :163), since construct-core 0.45.1. Text
handed to the platform (MessageDecrypted, the notification preview)
leaves through the FFI; its buffers are the binding's and the client's.
A view that acts on nothing (NotificationPeek, construct-core
0.44.0; construct-core/src/construct_core.udl:909-:937). A notification
extension runs before an alert is shown and may ask the core what a sealed
delivery is, without changing anything: it is built from the key record and
the saved orchestrator state, fetches session states by device through a
callback, and answers one of AlreadyHandled (the message key is already
spent), Visible (a message the app would announce), Control (a silent
control frame, a call signal or a DECRYPTION_ERROR) or Unreadable (not
read here, for any reason — the alert shows). It classifies with the same
decrypted_kind as the receive path, so "is this announced" has one
answer. It saves, sends and marks nothing, and no session state leaves it.
Only the session-envelope route is read; a first flight or a certificate
route answers Unreadable.
5.9 Previous states
A session renews by sending: any message carrying the handshake header opens a new state (§4.4.1),
and a device may open one at any time. So that nothing in flight on the old state is lost, the
replaced state is kept, not dropped
(construct-core/src/orchestration/session_lifecycle.rs).
| Constant | Value | Source |
|---|---|---|
MAX_PREVIOUS_STATES | 3 per peer device, oldest dropped first | session_lifecycle.rs:29 |
PREVIOUS_STATE_TTL_SECONDS | 604800 (7 days) from the moment it was replaced | session_lifecycle.rs:37 |
Receiving:
Decrypt(record, msg):
1. try the current state
2. on failure, try each previous state, newest first
-- a "message key already used" error from any state is a duplicate: stop, report it
3. a previous state that decrypts is PROMOTED: it becomes current, and the current one
becomes the newest previous state — unless it is held back (below)
4. nothing decrypts:
carries the handshake header → open a new state from it (§4.4)
carries none → DECRYPTION_ERROR to the writer (§5.10)
Every replacement — a receiving open, our own reopen, a promotion — makes the replaced state the newest previous one. A state leaves the record only by the count or age bound. The bounds are a compromise: a kept state is chain keys kept, and until it is dropped a compromise of the device also exposes what is still in flight on it. Signal keeps forty states with no age bound.
A state is held back when it was retired because the peer said it cannot read it (§5.10). A held-back state still decrypts what arrives on it but is never promoted: our next message on it would fail the same way, so the next send opens a new state instead.
Two devices that open at the same time (§4.5) each end up with both states; the first message either side reads promotes one, and the other ages out.
5.10 DECRYPTION_ERROR
When nothing held decrypts a message and it carries no handshake header, the receiver tells the writer. There is no END_SESSION, heal or session-reset message: the error names the state the unread message was written on, so the writer can act on exactly that state and a stale error does nothing.
Changed 2026-09-28. Until then the receiver sent END_SESSION (content type 21), which named no state, and both sides rationed it with time windows, cooldowns and retry budgets because a redelivered or reordered one could not be told from a live one. Type 21 is retired; a receiver acknowledges and ignores it.
5.10.1 Payload
Content type 28 (CONTENT_TYPE_DECRYPTION_ERROR). Plaintext, fixed length 131 bytes
(construct-core/src/orchestration/decryption_error.rs):
version : u8 = 1
hint : u8 -- 0 none, 1 PrekeyUnavailable; unknown values read as none
ratchet_key : [u8; 32] -- the DH ratchet key in the unread message's header
id_len : u8 -- ≤ 96
message_id : [u8; id_len] -- the unread message, as the receiver saw it
zero padding to 131 bytes
The plaintext is sealed to the writer's X25519 identity key — the key its sender certificate
named — with the sealed-box construction of §8, under the domain salt
b"ConstructDECRYPTERR-v1", distinct from the sender-certificate box so that neither box can be
passed off as the other. The sealed box is 191 bytes whatever the id length. It cannot be
ratchet-encrypted: the ratchet is what failed. It travels as a sealed envelope whose inner
content type is 28, the one type an outer SealedInner may name (§8).
An unsealed message carries no certificate, so no error can be addressed for it; none is sent.
5.10.2 Writer
On receiving an error, the writer opens it with its identity secret and compares ratchet_key
with its own sending keys for that peer device:
ratchet_key is the sending key of… | Writer does |
|---|---|
| the current state | retire it (it becomes a held-back previous state, §5.9); the next message opens a new state. With hint PrekeyUnavailable, that open uses no one-time prekey. Then resend message_id once. |
| a previous state | resend message_id once; the state was already replaced |
| none held | nothing — the error is stale (redelivered, reordered, or about a state long gone) |
A message is resent at most once per error it is named in. The resend is an ordinary message on the new state, so it carries the handshake header and the receiver opens from it.
Why a forged error does nothing. An error about a message inside a session envelope arrives by
that envelope's key pair, and is filed under the pair it came by, not under any state it quotes.
An error about a first message is a sealed box anyone holding the writer's public identity key
can make, and the device it is filed under comes from a sender certificate the platforms do not
refuse — so neither its sealer nor its claimed device is authenticated. What protects the writer
is the lookup above: only a ratchet_key that is the sending key of the writer's own state
with that device acts. That key travels only in the header of the unread message, inside an
envelope the relay cannot open, so the relay, a stranger, or a peer naming another device cannot
produce a matching one; a made-up key, a right key filed under another device, a replay of an
error already answered, and a burst of copies each draw at most the one retire and one resend of
the table above (construct-core orchestrator.rs, pqxdh_v2_tests: a_made_up_ratchet_key_retires_nothing_and_opens_nothing,
a_boxed_error_with_the_right_key_filed_under_another_device_does_nothing,
a_replayed_error_retires_nothing_more_and_resends_nothing_more,
a_burst_of_one_error_draws_one_retire_and_one_resend,
an_enveloped_error_names_the_pair_it_came_by_not_the_state_it_quotes).
5.10.3 Receiver
The receiver sends one error per unread message and marks the message processed only once the error was built, so a redelivery is a duplicate and produces no second error.
An error is sealed only to a writer the server vouches for: the unread message's sender
certificate MUST pass identity_for_opening against the server keys held, and its key MUST
derive to the device the message was filed under (Orchestrator::vouched_writer,
construct-core 0.20.1). Otherwise no error is sent and the message is not recorded, so the same
message arriving with an honest certificate is still answered. Anyone who knows the recipient's
public identity key can seal an envelope to it; without this check an unsigned certificate
would make the recipient answer a stranger's claim, to a device the stranger named. Until
construct-core 0.20.1 the check was applied only on the failed-open path (DE-1).
When a receiving open fails because the named OPK or Kyber prekey is gone (§4.4.3), the
error carries PrekeyUnavailable.
What the relay learns: both shipping clients send the error on the authenticated SendMessage
path with the payload inside SealedInner — the same path END_SESSION used — so the relay learns
that this account sent that account a control envelope, as it did for END_SESSION. It no longer
sees a 1024-byte pad whose size identified the kind; the error box has a fixed 191-byte size.
Moving session control to the unauthenticated SendSealedMessage path is part of the open item
on sealed control traffic (Chapter 7).
5.11 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.12 References
- Specification: this chapter.
- Reference implementation:
construct-core/src/crypto/messaging/double_ratchet/construct-core/src/wire_payload.rsconstruct-core/src/knst.rs(plaintext frame)construct-core/src/orchestration/session_lifecycle.rs(previous states)construct-core/src/orchestration/decryption_error.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 direct path is also in production as an alternative to
the HTTP/2 path, selected by the client-side transport router with an
HTTP/2 fallback. The current client calls it engine-QUIC and gates
it through FeatureFlags.engineQuicExperimental, which defaults on;
release builds use plain QUIC and force Salamander-style datagram
obfuscation off (construct-ios Utilities/Constants.swift:414-:456,
Networking/gRPC/GRPCChannelManager.swift:474-:535). The H3 path is
implemented in construct-transport/src/client.rs; 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 LE | 4 B | Double Ratchet sending counter Ns |
| dh_public_key | bytes | 32 B | Current sending DH public (DHs.pub) |
| otpk_id | u32 LE | 4 B | OPK id consumed by the X3DH initiator; 0 if N/A |
| kyber_otpk_id | u32 LE | 4 B | ML-KEM-1024 one-time prekey id consumed; 0 = the KEM-SPK was used |
| kem_len | u16 LE | 2 B | Length of the KEM ciphertext that follows; 0 when absent |
| prev_chain_length | u32 LE | 4 B | Previous-chain length PN |
| suite_id | u16 LE | 2 B | 0x0001 Suite 1, 0x0002 Suite 2, or 0x0004 Suite 4 (PQ ratchet); bit 0x0100 set on a frame carrying the PQXDH v2 header, 0x0200 with the initiator's KEM identity key, 0x0400 with the responder's answer to it. 0x0003, the retired epoch-granular PQ ratchet (Suite 3), is refused outright — see §2.4 |
| kem_ct | bytes | kem_len B | ML-KEM-1024 ciphertext, 1568 B, on every message of the initiator's first flight (the handshake header) |
| kem_identity | u16 LE len + bytes | 2 + 1568 B | With bit 0x0200: the initiator's ML-KEM-1024 identity key (Ch. 4 §4.4.4) |
| identity_answer | u16 LE len + bytes | 2 + 1568 B | With bit 0x0400: the responder's ML-KEM-1024 answer to it |
| pq_ratchet_section | bytes | variable | Present only when suite_id = 0x0004; see §5.3 |
| aead_frame | bytes | variable | `nonce(12) |
Total fixed header size: 52 bytes
(construct-core/src/wire_payload.rs). All numeric fields
in the fixed WirePayload header are little-endian, including the PQ-ratchet section's
pq_message_epoch; its pq_key_index is minimal unsigned LEB128, not a fixed-width integer
(construct-core/src/wire_payload.rs, §5.3).
A WirePayload is the unit of work the Double Ratchet produces and
consumes. It MUST NOT carry plaintext routing fields outside of
suite_id and the lengths needed to parse the envelope; metadata such
as sender, recipient, timestamps, and conversation ids belongs in the
transport-level wrapper, not in the WirePayload itself.
6.3 CFE — Construct Frame Encoding
When a WirePayload (or any other typed protocol message) crosses the FFI boundary between the Rust core and a platform binding (Swift, Kotlin), it MUST be wrapped in a CFE envelope. CFE eliminates the JSON parsing attack surface previously present at the FFI line.
6.3.1 Envelope layout
CFE envelope (16-byte header + payload) ::=
magic : [u8; 2] = [0x43, 0x46] -- "CF"
version : u8 = 0x01
msg_type : u8 -- CfeMessageType enum tag
flags : u8 -- reserved, MUST be 0
reserved : [u8; 3] = [0x00; 3]
payload_len : u32 LE -- length of the MessagePack body
crc32 : u32 LE -- CRC-32 over payload only
payload : [u8; payload_len] -- MessagePack body
Header constants are defined in construct-core/src/cfe/envelope.rs:
CFE_MAGIC = [0x43, 0x46], CFE_VERSION = 0x01,
CFE_HEADER_LEN = 16, SUPPORTED_FLAGS_MASK = 0x00, and
MAX_PAYLOAD_LEN = 256 * 1024 (:8-:25). The encoder writes the
header in the order above and serialises the payload with
rmp_serde::to_vec_named (:47-:66).
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 the
msg_typebyte maps to a knownCfeMessageType. - Verify
flags == 0; flag constants exist in code, but v1 supports no flags (SUPPORTED_FLAGS_MASK = 0x00). - Verify the three reserved bytes are zero.
- Decode
payload_lenas little-endian and reject values above the implementation cap (reference: 256 KiB). - Verify the buffer contains exactly enough bytes for the declared payload.
- Verify
crc32matches recomputed CRC-32 over the payload bytes only (construct-core/src/cfe/envelope.rs:135-:148). - 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 |
| MessagingService | SendSealedMessage | unary sealed send, deliberately no 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.
SendSealedMessage carries a SealedSenderEnvelope and deliberately
does not extract an authenticated user id; anti-abuse is enforced by
per-IP rate limiting, Privacy Pass token redemption, and delivery-tag
replay checks (construct-server/messaging-service/src/grpc.rs:701-:750,
messaging-service/src/envelope.rs:139-:270). This is the transport
entry point that removes the sender identity from the server-visible
request for sealed sender (Chapter 8).
For key fetches, new clients MUST set
consume_one_time_prekey explicitly. Legacy absence is interpreted as
"consume" for wire compatibility, while non-session lookups should set
it to false to avoid draining OPK pools
(construct-server/shared/proto/services/key_service.proto:80-:103).
6.5 VEIL — anti-censorship transport tier
When the direct gRPC-over-TLS path is blocked, throttled, or fingerprinted by an adversarial network, the client MAY route through VEIL instead. VEIL is a pluggable transport tier with several backend strategies:
| 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. Key exchange: the client's TLS profiles (rustls on aws-lc-rs) offer the hybrid X25519MLKEM768 group first, then X25519, P-256, P-384 (construct-veil src/tls_fingerprint.rs, the revision construct-core pins since 0.31.2); the group agreed is the front's choice (PQC-6). |
| 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 supported flags | 0x00 mask; all flags rejected | cfe/envelope.rs:17, :111-:113 |
| CFE max payload | 256 KiB | cfe/envelope.rs:19-:25, :120-:125 |
| WirePayload header length | 52 bytes (fixed) | wire_payload.rs:25-:56 |
| 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 + PQXDH v2 handshake (ML-KEM-1024 in the initial root key) | Implemented and mandatory since 2026-09-25 (core 0.18); shipped in iOS TestFlight and the Android build. No classical-only path; v1 removed without compatibility | construct-core/src/crypto/handshake/x3dh.rs:164-:182; src/crypto/pq_x3dh.rs (mlkem1024_*); refusals src/orchestration/pq_prekey_plan.rs |
Kyber prekey signatures (Ed25519 + hybrid, over created_at) | Implemented, verified by the initiator's core; a missing or invalid one refuses the session | construct-core/src/crypto/kyber_prekey_auth.rs |
| PQ-ratchet downgrade refusal | Removed with PQXDH v2 (2026-09-25, core 0.18). Until then, a device that had advertised or used the PQ ratchet was refused (PQ_DOWNGRADE_REFUSED) if a later bundle stopped advertising the unsigned supports_pq_ratchet capability. The same commit made the PQ ratchet mandatory wherever post-quantum is built and deleted both the capability and its downgrade ledger — nothing is left to downgrade | construct-core/src/crypto/session_api.rs (local_supports_pq_ratchet); removal noted in src/orchestration/pq_prekey_plan.rs |
| Responder's proof of the initiator (ML-KEM-1024 identity key) | Implemented and mandatory since 2026-09-28 (core 0.22) — the responder pins the initiator's KEM identity key and answers to it; the initiator is ReceivedProven once it sends on a chain after that answer. No signature: the first contact stays deniable. First contact is trust-on-first-use | construct-core/src/orchestration/orchestrator.rs (admit_kem_identity); double_ratchet/internals.rs (answer_initiator_identity, mix_identity_secret) |
| Receiving open from the sender certificate | Implemented since 2026-09-27 — no bundle fetch to receive; the certificate's server signature is required | construct-core/src/orchestration/orchestrator.rs (open_receiving); src/crypto/sealed_sender/mod.rs:299 (identity_for_opening) |
| Previous states (renew by sending) | Implemented since 2026-09-27 — up to 3 previous states per device, 7-day bound; any message with the handshake header opens | construct-core/src/orchestration/session_lifecycle.rs:31-:39 |
| DECRYPTION_ERROR (content type 28) | Implemented since 2026-09-28 on core, iOS and Android; replaces END_SESSION | construct-core/src/orchestration/decryption_error.rs |
| Sparse continuous PQ ratchet (Suite 4; per-message chains, PQR-2, since core 0.24.0) | Implemented and mandatory wherever the post-quantum build feature is present (every platform build) since PQXDH v2, 2026-09-25 (core 0.18) — not bundle-negotiated; a first message on any other suite is refused (PQXDH_REQUIRED). Suite 3 (epoch-granular) is retired without a compatibility path | construct-core/src/crypto/suite_id.rs; src/crypto/session_api.rs; src/orchestration/orchestrator.rs; wire section src/wire_payload.rs |
| Double Ratchet | Implemented, including DH ratchet, skipped-key handling, AD v3 only (the v2 retry was removed in core 0.24.2), and Suite 4 PQ epoch/index tags | construct-core/src/crypto/messaging/double_ratchet/messaging.rs; internals.rs |
| PKCS#7 length padding (mod 255) | Implemented, constant-time unpad | construct-core/src/traffic_protection/padding.rs:56-:130 |
| ACK deduplication store | Implemented | construct-core/src/orchestration/ack_store.rs:83-:144 |
| CFE binary envelope at FFI | Implemented, used by iOS / macOS / Android bindings | construct-core/src/cfe/envelope.rs:8-:25, :47-:66 |
| WirePayload binary frame | Implemented, little-endian fixed header + Suite 4 PQ-ratchet section (LEB128 key index) | construct-core/src/wire_payload.rs |
| KNST plaintext frame and classification | Implemented in the core since 0.29–0.31 (2026-10-02): the core writes and parses the 30-byte frame for every client and names a decrypted message by its byte 5 — call signal, silent control frame (ControlFrameDecrypted; 12, 13, 14, 25, 26, 27, 29, 30) or message (Ch. 5 §5.8.4). Chunk reassembly stays on the platforms | construct-core/src/knst.rs; src/orchestration/orchestrator.rs:3039-:3124 (decrypted_actions, decrypted_kind) |
| Notification peek | Implemented (core 0.44.0): a read-only view a notification extension asks whether a sealed delivery is a message to announce; it saves, sends and marks nothing. Only the session-envelope route is read; others answer Unreadable | construct-core/src/orchestration/orchestrator.rs (peek_sealed); UDL NotificationPeek |
| Media blob sealing | Implemented in the core (0.33.0): AES-256-GCM, the blob padded to a Padmé bucket (≥ 4 096 B); legacy unpadded blobs still open. Both the iOS client and the Android development branch call it | construct-core/src/media.rs; iOS Networking/gRPC/Services/MediaServiceClient.swift, Android media/MediaCrypto.kt |
| Server keys rooted offline | Implemented, not in force. Hybrid delegations from an offline root, labelled length-prefixed server signatures, and the client's judgement of certificates and KT heads in the core (0.32.0). PINNED_ROOTS is empty — no root has been created — so every check falls back to Ed25519 against the keys the platform hands over (Ch. 3 §3.11) | construct-core/src/crypto/server_trust.rs:50; format server-trust/src/lib.rs; offline tool src/bin/konstruct-root.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/mls_store.rs:1-:39 |
| 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/mod.rs:59-:172; server issuance construct-server/identity-service/src/main.rs:942-:1057 |
| Key transparency | Per-bundle inclusion proofs live; split-view detection not. The server maintains an append-only Merkle log and returns a Signed Tree Head + inclusion proof inline with every bundle (key-service/src/kt.rs, migrations 044/054); the client's core verifies inclusion + STH signature on receipt (verify_proof, since core 0.32.0; the Swift KeyTransparencyVerifier is removed). Missing: a public monitor endpoint, in-practice consistency checking, and STH gossip/auditing — so the current STH catches a self-contradicting server but not one that equivocates consistently across victims. Design + gap in Chapter 13. | server key-service/src/kt.rs:172-:309; client construct-core/src/crypto/key_transparency.rs:305-:384, called from construct-ios Networking/gRPC/Services/KeyServiceClient.swift |
| ML-DSA-65 hybrid PQ signatures (Ed25519 + ML-DSA-65) | Implemented and required to open a session: a missing or invalid hybrid identity, cross-signature or Kyber-prekey hybrid signature refuses it; the hybrid identity is pinned per device. The hybrid key's trust root is still its Ed25519 cross-signature | construct-core/src/crypto/suites/hybrid.rs; checks src/orchestration/pq_prekey_plan.rs; server crates/construct-crypto/src/pqc/hybrid.rs |
| Sealed sender | Implemented and on by default. All outgoing user traffic — messages, delivery receipts, call signalling, and session control (since 2026-09-28 the only session-control message is DECRYPTION_ERROR, content type 28) — is sealed. Ordinary sends use the dedicated SendSealedMessage path; DECRYPTION_ERROR still uses the authenticated Envelope.sealed_sender path, but omit the outer sender, conversation id, and real content type. Identified-downgrade paths (retries, seal-failure, in-scope control channel) are closed and fail-closed: stealth-on ⇒ an in-scope application send is sealed or queued, never emitted identified. | server messaging-service/src/grpc.rs:333-:360, :701-:750, messaging-service/src/envelope.rs:140-:270; client policy construct-ios Services/StealthPolicy.swift:43; sealed RPC Networking/gRPC/Services/MessagingServiceClient.swift:203-:225; sealed control Services/Session/SessionCoordinator.swift:1208-:1312, MessagingServiceClient.swift:269-:351 |
| 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). | construct-server/messaging-service/src/envelope.rs:188-:248; construct-core/src/crypto/privacy_pass/mod.rs:59-:172 |
| Federation (S2S) | Implemented — inbound + outbound sealed delivery, Ed25519-signed, per-origin rate-limited. Multi-node interoperability test outstanding. | construct-server/messaging-service/src/federation.rs:135-:258, :291-:360 |
| QUIC / HTTP-3 transport | In production as the engine-QUIC direct path; HTTP/2 fallback remains mandatory. Release builds use plain QUIC; Salamander-style per-datagram obfuscation is forced off outside DEBUG. | construct-ios Utilities/Constants.swift, Networking/gRPC/GRPCChannelManager.swift; construct-transport/src/client.rs |
| 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 | The same Swift code base and the same core as iOS, linked directly through UniFFI (the construct-engine single-binary path was retired on 2026-07-28). The desktop GUI is frozen: it builds, but is not distributed. |
| Android | aarch64-linux-android, armv7-linux-androideabi, x86_64-linux-android | Kotlin client; its development branch pins construct-core 0.48.0 (construct-core.lock): PQXDH v2, receiving opens from the sender certificate, DECRYPTION_ERROR, sealed sender. Exchanges messages with iOS on the test stand. Debug builds only — no signed release, no store distribution. No Google Play Services dependency by design. No VEIL transport yet. |
Terminal client (construct-tui, Linux / macOS) | native | Paused since 2026-08-25. It predates PQXDH v2 and the event-based session answers of core 0.19–0.21, so it does not interoperate with the current clients. |
| Windows | — | No client. |
| Web (WASM) | — | Planned, not started. |
7.3 Open security issues
The table below lists residual risks that remain after the current code audit. It deliberately excludes items that have shipped since the older internal TODO list was written.
| 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. The ML-KEM-1024 secret still enters the root key, so the first flight is not classical-only. | Surface a warning to the application layer. |
KT-1 | High | Key Transparency returns per-bundle inclusion proofs, but there is no public monitor endpoint, no in-practice consistency checking, and no STH gossip/auditor. A consistently equivocating server can still maintain a split view. | Publish monitor/consistency API, persist and compare STHs, and add gossip/auditor path. |
PQR-3 | Medium | The ML-KEM-768 encapsulation key (1184 B) and ciphertext (1088 B) are carried whole and re-attached to every outgoing frame until implicitly acknowledged, so an unacknowledged exchange costs that much per message for its duration. Signal SPQR spreads both across Reed–Solomon-coded chunks instead, bounding per-message overhead. Because the section is outside the plaintext padding, it also makes a frame about 1.1 KB longer while an exchange is pending, which tells the server when one is in flight (Chapter 8 §8.7). | Chunk the objects with a systematic erasure code, or bound re-attachment by a retry schedule. |
PQR-4 | Low | PQ_CHAIN_RETENTION = 2 (the current epoch and the one before it) bounds how many epochs' chains are kept; an older epoch's message opens only by a skipped key, bounded like the classical ones (1000 keys, a 2000-message jump, 7-day age — §5.8.1/§2.4.3). What is lost by design (no silent downgrade) is the last message its writer sent on an epoch, arriving after the reader has completed two further exchanges. Whether real reordering ever reaches that depth is being measured: construct-core 0.25.0 counts, per process, decrypted messages by epoch lag, the deepest skip, and decrypt failures on an evicted epoch (orchestration/reorder_stats.rs, exported as OrchestratorCore::reorder_stats); clients write them to their local diagnostics log. | Read the counters from real use; keep 2 if no eviction loss appears, otherwise raise the retention. |
PQR-5 | Medium | Post-quantum healing after a state compromise is slower than Signal SPQR's. A Suite 4 epoch completes at least every 16 DH turns or 7 days (pq_ratchet_interval, pq_ratchet_max_age_seconds); SPQR's next epoch starts as soon as the previous completes. The encapsulation key is always generated by the DR initiator (double_ratchet/internals.rs:312,330), so a state stolen from the initiator while a proposal is pending yields the decapsulation key and the next epoch; the worst case is about 2 × the cadence. SPQR alternates the roles. See Appendix B §B.6. | Continuous epochs and alternating roles, with chunked transport ("PQR v3", together with PQR-3). |
PQC-1 | Low | Two sealed boxes are still X25519 only. Who wrote a message is post-quantum for an established session (session envelope, construct-core 0.26) and for a session's first flight (first-flight box, 0.27; Chapter 8). Still classical: a DECRYPTION_ERROR about a first message the recipient could not read (it goes to the writer's identity key, sealed_sender/mod.rs:72, seal_to_x25519_public), and device metadata sealed to siblings. | Hybrid box for sibling metadata (rare, +1568 bytes acceptable); the DECRYPTION_ERROR follows. |
BF-1 | Medium | The Android client fetches bundles over an authenticated call. The server then knows which account was handed which one-time prekey, and could tie a first contact to its sender by the prekey the handshake used. The iOS client fetches over the sealed channel with no token (KeyServiceClient.swift), so the server sees an address, not an account; since construct-core 0.27 the X25519 one-time prekey id is inside the first-flight box in any case. The Kyber prekey id stays outside: the recipient needs it to derive the box key. | The Android bundle fetch over the sealed channel (construct-docs TODO 87). |
PQC-2 | Low | Server-signed objects — sender certificates (verify_sender_cert, sealed_sender/mod.rs:176) and Key Transparency tree heads (tree_head_signable, key_transparency.rs:260) — are judged by Ed25519. The hybrid mechanism is in the core since 0.32.0 (delegated Ed25519 + ML-DSA-65 keys, Chapter 3 §3.11), but PINNED_ROOTS is empty, so it decides nothing yet; with roots pinned and HYBRID_REQUIRED still false, a certificate stripped of its hybrid signature would still be judged by Ed25519. A quantum attacker could forge an Ed25519 signature; forging does not decrypt recorded traffic, and after first contact a peer is held by its pinned KEM identity key. | Run the root ceremony and pin the roots; set HYBRID_REQUIRED once every server signs hybrid. |
PQC-3 | Low | Device authentication to the server (crypto/keys.rs:472) and the recovery key derived from the seed phrase (crypto/recovery.rs:92) are Ed25519 only. A forgery would act on the account at the server (sign in, start recovery); it does not decrypt messages. | Hybrid signatures for device and recovery challenges. |
PQC-4 | WebRTC-EnableDtlsPqc, webrtc-sdk 150.7871.01; observed on the wire, Chapter 10 §10.3). Remaining: the group is chosen by both endpoints and not visible to the app, so a peer without the trial silently gets X25519. | Android sets the same trial when it implements calls. | |
PQC-5 | Low | Group messaging uses MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519 (group/mls_store.rs:39). No product surface ships groups yet. | Choose a hybrid MLS ciphersuite before groups ship. |
PQC-6 | Low | Transport TLS key exchange is post-quantum on one path only. VEIL: since construct-core 0.31.2 (construct-veil c3330a4) the client offers X25519MLKEM768 first on aws-lc-rs (src/tls_fingerprint.rs:189-:242); the group agreed is the front's choice. QUIC (construct-transport) still uses rustls with the ring provider, which offers no hybrid group (construct-transport/Cargo.toml:22). The iOS direct gRPC path's system TLS has not been verified against the server. Message content does not depend on any of them. | Move QUIC to a provider with X25519MLKEM768; verify what the direct path negotiates. |
SEC-009 | Low | Session state (CFE records containing dh_ratchet_private, the root key and chain keys) is stored in the platform key store 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. |
Resolved since the previous revision:
-
FF-1— a first flight named its sender in the clear (found and fixed 2026-10-01, construct-core 0.27). Until the peer answers, every message an initiator writes carries the PQXDH v2 header, and with it the initiator's ML-KEM-1024 identity key — the same key on every first flight that device writes. It travelled in the clear wire payload beside the X25519 certificate box. A copy to one's own device travels identified, so for a multi-device account the server could learn which account the key belongs to and read the sender of every sealed first contact off it; for a single-device account the key linked all its first contacts. The first-flight box (Chapter 8) now carries the whole wire payload except the recipient's Kyber prekey id and the ML-KEM ciphertext. -
SEC-006— the AD v2 fallback decrypt is gone (construct-core 0.24.2). Every session is PQXDH v2, opened since 2026-09-25, and every writer of PQXDH v2 seals with AD v3: the retry could no longer open anything and only doubled the work of each failure. -
DE-2— the map from a sealed copy's server-assigned id to the sender's own id is persisted on both clients (iOS since 2026-09-30, 30-day retention; Android in its database), so a DECRYPTION_ERROR that arrives after the writer restarted still resends the named message. -
PQR-1— Suite 4 rekeyed only on a count of DH-ratchet turns, so a conversation that never changed direction (one side always writing, the other always reading) never rekeyed at all. construct-core 0.24.1 (2026-09-30) added a 7-day age floor: the exchange initiator proposes a new epoch on its next send once the current one is that old, turns or not — matching the floor Apple PQ3 guarantees. Resolved: the gap this entry used to name — only the exchange initiator proposes, so a conversation only the responder writes "cannot rekey" — does not exist, because the initiator answers every message with a receipt through the session and that receipt carries the proposal (core68cd63c, test only; Chapter 2 §2.4.4 rule 6 makes the receipt normative). No role alternation is needed. See Chapter 2 §2.4.3 and Appendix B §B.3. -
PQR-2— every message key in a Suite 3 PQ epoch was derived from the same storedpq_epoch_secret, so the post-quantum half of the ratchet had epoch, not message, forward-secrecy granularity. construct-core 0.24.0 (2026-09-30) replaced the stored secret with two per-direction chains, stepped per message and never persisted (Suite 4); see Chapter 2 §2.4 and Appendix B §B.6.
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, in-module) | KDF labels, AD construction, padding, wire-payload round-trips, ratchet invariants; the PQXDH v2 handshake end to end between two cores; Kyber prekey signature checks and every PqxdhRefusal; previous-state promotion, retirement and bounds; DECRYPTION_ERROR sealing, domain separation and the writer's current / previous / unknown cases. Session behaviour is tested in the modules that own it (session_lifecycle.rs, orchestrator.rs, decryption_error.rs), not in tests/. |
Integration tests (tests/) | Two files: orchestrator state save / restore (state_management_test.rs) and the build-version stamp. |
| 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). |
Conformance vectors (tests/conformance/) | Files shared with the other clients through construct-protos/conformance: the KNST frame byte for byte (knst_frame.json), the content-type table the silent control set is pinned to (knst_content_types.json), and delegations and server signatures of each purpose, verified and tampered (knst_server_trust.json). |
What is not covered today:
- Full product-level multi-device interoperability coverage across all sealed-sender, sender-sync, and Suite 4 cases.
- 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
The primary current stealth path for ordinary sealed sends is a
SendSealedMessageRequest containing only a SealedSenderEnvelope
plus an optional attempt id. There is no outer Envelope, no sender,
no conversation_id, and no server-visible content type on that RPC
(construct-server/shared/proto/services/messaging_service.proto:25-:30,
:281-:289). A legacy authenticated Envelope.sealed_sender path
exists for transitional session-control traffic; it still masks sender,
conversation id, and real content type on the outer envelope, then
enters the same dispatch_sealed_sender server path. Unauthenticated
SendSealedMessage is the sender-hiding transport boundary for
ordinary sealed sends.
The sealed structures are defined normatively in
shared/proto/core/envelope.proto (message SealedSenderEnvelope,
envelope.proto:365; SealedInner, envelope.proto:390).
SealedSenderEnvelope { // visible to the home/entry server
recipient_server : string // federation destination domain (S2S routing)
sealed_inner : bytes // opaque — the home server MUST NOT parse it
forwarding_token : bytes // HMAC-SHA256(server_secret, sealed_inner || timestamp)
timestamp : int64 // freshness; reject if > 5 min old
}
SealedInner { // read by the destination server
recipient_user_id : string // needed to route to the recipient
delivery_tag : bytes // random 32 B; server dedups for 24h
sender_cert_ciphertext: bytes // SenderCertificate sealed to recipient IK — server MUST NOT decrypt
encrypted_payload : bytes // the Double Ratchet ciphertext (Chapter 5)
content_type : ContentType // DEPRECATED; ignored by server
priority : MessagePriority // DEPRECATED; ignored by server
ttl : uint32 // DEPRECATED; ignored by server
token_nonce : bytes // Privacy Pass (optional, §8.5)
token_bytes : bytes
token_spend_id : bytes // optional logical-message spend id
session_envelope : bytes // field 21, construct-core 0.26 — see "Session envelope" below
first_flight : bytes // field 22, construct-core 0.27 — see "First flight" below
}
content_type, priority, and ttl remain in SealedInner only for
compatibility. They are server-visible metadata leaks and the server
MUST NOT use them for routing, priority, notification text, or UI
(construct-server/shared/proto/core/envelope.proto:386-:418).
Session envelope (construct-core 0.26)
Once two devices hold a session, a sealed message carries neither a
certificate nor a readable wire payload. It carries session_envelope
(construct-core/src/crypto/sealed_sender/envelope.rs):
keys(s→r) = HKDF-SHA256(salt = ∅, ikm = SK,
info = "construct-envelope-v1" ‖ 0x00 ‖ s ‖ 0x00 ‖ r, 64)
= k_env ‖ k_tag s, r: sender and recipient device ids
envelope = nonce(12) ‖ tag(16) ‖ ChaCha20-Poly1305(k_env, nonce, kind ‖ body, ad = tag)
tag = HMAC-SHA256(k_tag, nonce)[..16]
kind = 0x01 wire payload | 0x02 DECRYPTION_ERROR
SK is the session's PQXDH v2 root, which includes an ML-KEM-1024 secret.
The recipient computes the tag under each key pair it holds — kept in an
envelope book beside the sessions, because sessions are restored lazily
(sealed_sender/book.rs) — and the pair that matches names the writer.
The tag is a PRF output on a random nonce, so it links nothing; there is
no counter to keep in step. When session_envelope is set,
sender_cert_ciphertext and encrypted_payload are empty, and the outer
type is generic even for a DECRYPTION_ERROR.
A pair outlives its ratchet state: a local reset or a deleted chat sends
nothing, and the writer goes on writing. The pair is retired for 7 days
(the queue window), still names the writer, and the DECRYPTION_ERROR goes
back along it. Decision: construct-docs
decisions/sealed-envelope-keyed-by-the-session.md.
First flight (construct-core 0.27)
Until the peer answers, the recipient has no session to find a tag in, and
every message the initiator writes carries the PQXDH v2 header: the prekey
ids, the ML-KEM-1024 ciphertext and the initiator's ML-KEM identity key.
That key is the same on every first flight a device writes, so in the clear
it names the sender (FF-1, resolved). Such a message carries
first_flight (construct-core/src/crypto/sealed_sender/first_flight.rs):
first_flight = BE32(kyber_prekey_id) ‖ kem_ciphertext ‖ eph(32) ‖ nonce(12)
‖ ChaCha20-Poly1305(k_first, nonce, body, ad = everything before nonce)
body = BE16(len certificate) ‖ certificate ‖ wire payload without kem_ciphertext
ff_key = HKDF-SHA256(salt = ∅, ikm = SS_mlkem, info = "construct-first-flight-v1")
k_first = HKDF-SHA256(salt = "ConstructSEALED-first-v1",
ikm = X25519(eph, IK_recipient) ‖ ff_key,
info = eph ‖ SHA-256(kem_ciphertext))
Outside stay only the recipient's Kyber prekey id and the ciphertext to it,
which the recipient needs to derive the key and which name only the
recipient. The handshake's encapsulation keys both the box and the session
root, under different labels. The box costs six bytes over the certificate
and payload it carries. ff_key is kept beside the session's envelope pair:
the initiator seals with it until answered, and the responder opens the
later first flights with it (their one-time Kyber prekey was burned by the
first) until the initiator proves itself. When first_flight is set,
sender_cert_ciphertext, encrypted_payload and session_envelope are
empty. Decision: construct-docs decisions/first-flight-sealed-whole.md.
A first flight to a Kyber prekey the recipient no longer holds could not be opened, its writer would not be known, and no DECRYPTION_ERROR would answer it. The timing rules out that case: a signed prekey is kept 14 days after rotation, the queue holds a message 7, and an initiator does not write on a handshake left unanswered for 7 days — it opens a new one (Chapter 3 §3.5, construct-core 0.27.1).
What stays classical. A DECRYPTION_ERROR about a first message the
recipient could not read goes in an X25519 box to the writer's identity key
(sealed_sender/mod.rs:72, seal_to_x25519_public), and device metadata goes to siblings the same
way. Tracked as PQC-1
(Chapter 7 §7.3).
New normal sealed sends leave content_type at UNSPECIFIED = 0,
which proto3 omits from the wire. The real application content type
rides inside the encrypted payload, as byte 5 of the KNST frame
(Chapter 5 §5.8.4),
and since construct-core 0.29–0.30 the core classifies a decrypted
message by that byte, not by the envelope. The client constrains this boundary with SealedEnvelopeType:
.generic serialises to no content type, and the one structural
exception is DECRYPTION_ERROR (28): it answers a message the ratchet
could not read, so it cannot be encrypted with that ratchet. Until
2026-09-27/28 the exceptions were SESSION_RESET (21) and
SESSION_RESET_INIT (24); nothing sends either now. (The iOS enum still
carries a .sessionResetInit case with no sender; it is scheduled for
removal.)
(construct-ios Services/Messaging/ContentTypeRouting.swift:39-:82,
:171-:197; Security/StealthSenderService.swift:393-:415).
What each party sees:
- Home / entry server:
recipient_serverplus an opaquesealed_innerblob and itsforwarding_token. It routes by destination domain and forwards the blob without parsing it. - Destination server: parses
SealedInner. It learns the recipient, thedelivery_tag(for replay suppression), optional Privacy Pass token fields, optionaltoken_spend_id, and the opaqueencrypted_payloadsize. For ordinary sealed traffic it does not learn the message kind; it can see only the deprecated/structuralcontent_typeexceptions if present. It does not learn the sender: the sender identity lives only insidesender_cert_ciphertext, which is encrypted to the recipient's identity key, and which the server "MUST NOT attempt to decrypt" (envelope.proto:398-:404).
On the single-server deployment shipping today the home and destination
roles are the same process, so it sees the SealedInner fields above — but
still never the sender.
On the client, the current no-outer-envelope path is
construct-ios Networking/gRPC/Services/MessagingServiceClient.swift
(SendSealedMessage) plus Security/StealthSenderService.swift
(buildSealedInner). Server-side routing is
construct-server/messaging-service/src/envelope.rs:139-:270: the
federation hop forwards sealed_inner opaquely, while local delivery
decodes only recipient_user_id, token fields, and delivery_tag.
8.3 Sender certificate and recipient verification
The recipient learns and verifies the sender from the SenderCertificate
(envelope.proto:425) inside sender_cert_ciphertext:
SenderCertificate {
sender_user_id : string
sender_domain : string // home server, for public-key lookup
sender_identity_key : bytes // X25519 (32 B) — must match the session
sender_device_id : string
issued_at : int64
expires_at : int64 // typically issued_at + 24h
server_signature : bytes // Ed25519 by the sender's home server
server_kid : bytes // field 8 (core 0.32): id of a delegated hybrid key; empty if none
server_signature_hybrid : bytes // field 9 (core 0.32): Ed25519 ‖ ML-DSA-65 (3373 B) by that key
}
The certificate is issued and signed by the sender's home server
(identity-service). The Ed25519 server_signature is made with the key
published at the server's /.well-known document and covers the direct
big-endian concatenation sender_user_id || sender_domain || sender_identity_key || sender_device_id || issued_at || expires_at, with no
label and no length prefixes (build_cert_sign_payload,
construct-core/src/crypto/sealed_sender/mod.rs:151). The hybrid signature,
when present, is made by a key delegated from an offline root and covers
"konstruct/v1/sender-cert" ‖ kid ‖ body with every field length-prefixed,
and a certificate it signs may claim at most a day of lifetime (Chapter 3
§3.11).
The core judges the certificate (since construct-core 0.32.0;
SenderCertificate::identity_for_opening and ::verdict,
sealed_sender/mod.rs:299-:417; UDL certificate_verdict). With a root
pinned and the hybrid signature present, the hybrid signature decides
alone; otherwise the Ed25519 signature decides against the keys the
platform handed to the core. No root is pinned yet (PINNED_ROOTS is
empty, server_trust.rs:50), so today every certificate is judged by
Ed25519 against the server's published key and the keys pinned in the
app. The verdict is Vouched, NoKey (no key to check with yet —
transient), BadSignature (also: the key does not derive to the named
device) or Expired (signed and naming its device, but expired longer ago
than a message may wait at the relay); see
Appendix A §A.14
for each verdict's cause and the client's part. Until 2026-10-03 each client also
checked the Ed25519 signature itself, with different age and device rules
from the core's; that second check is gone.
The recipient attests the sender through one of three levers
(SenderVouchBasis in construct-ios Security/StealthSenderService.swift):
- Session lever (
.session): the message came in a session envelope, and the session pair whose tag matched names the writer. Post-quantum; no server key involved. - KT lever (
.kt): the certificate'ssender_identity_keymatches the recipient's locally stored, key-transparency-verifiedknownIdentityKeyfor that contact. No dependency on any fetched server key. - Signature lever (
.signature): the core's verdict on the certificate isVouched. Used on first contact, before a local KT key exists.
Delivery on an existing session is not gated on attestation. A message
that opens a session is: it opens only from a certificate that passes
identity_for_opening (Chapter 4 §4.4.2), and with no key to check it
yet it waits rather than opens. On a session already held, if no lever
vouches (missing or stale server key, expired cert), the message is still
delivered and logged as UNVOUCHED with a reason — the Double Ratchet is the real
authenticator, and an unvouched attestation triggers a bundle-key refresh
so the next message re-vouches (MessageRouter.routeIncomingMessage,
construct-ios). This is a deliberate availability-over-strictness choice:
a key rotation must not silently drop mail.
8.4 Always-on scope, and the fail-closed invariant
Sealed sending is on by default in release builds — there is no user
toggle to turn it off (construct-ios Services/StealthPolicy.swift:43
isEnabled, :72 shouldUseSealedSender). DEBUG builds keep a developer
override for exercising the legacy identified path.
Invariant (fail-closed): while stealth is on, every in-scope
application send is sealed or queued — it is never emitted identified.
Identified sends are legal only when shouldUseSealedSender() is false
or when the traffic class is deliberately excluded below. When sealing
is temporarily impossible (recipient identity key not yet known, sender
certificate unfetchable), the send does not fall back to an identified
envelope; it throws StealthDowngradeBlocked and the message is held
for a later retry (StealthSendRecovery.swift,
ChunkedMessageDelivery.swift). This closes a server-influence
deanonymisation vector: a server that could force identified sends by
failing sealed ones could deanonymise on demand.
Traffic in scope (sealed):
- User messages (text, media, voice, files, replies) and edits.
- End-to-end delivery receipts — otherwise sender↔recipient timing correlation leaks even when bodies are sealed.
- Call signalling (SDP / ICE — see Transport).
- Session control: since 2026-09-28 the only session-control message
is the DECRYPTION_ERROR (Chapter 5 §5.10) — the handshake pings,
session_ready,SESSION_RESET_INITandEND_SESSIONare gone from the protocol. With all user traffic sealed, directed control messages were the primary remaining cleartextsender → recipientsignal, so the error is sealed too, with a fixed 191-byte box (clientMessagingServiceClient.swiftsendDecryptionError). It still travels on the authenticated path, so the relay sees the sending account.
Traffic deliberately excluded (identified, by decision — the leak is low-value or the frequency/cost is high):
- End-to-end heartbeats (
content_type = 13). Their real type is now inside the encrypted KNST frame rather than in a server-visible heartbeat content type, but they still use the identified send path (construct-iosServices/Messaging/OutboundSessionService.swift:187-:233). - Multi-device internal sync (a user talking to their own devices).
8.5 Anti-abuse: Privacy Pass tokens
Removing the sender identity also removes the server's usual per-sender
abuse lever. Konstruct restores one with Privacy Pass anonymous tokens:
a sealed send MAY carry token_nonce + token_bytes and may carry
token_spend_id for multi-envelope logical messages
(envelope.proto:420-:444), a blind-signed single-use credential
the sender spends from a local wallet.
The token is itself sealed to the destination server's X25519 key so relay
operators cannot read the spent token. The full construction — the VOPRF,
issuance/redemption, and the verifiable-issuance DLEQ proof — is specified
in Chapter 9.
Deployment status. Token enforcement is governed by
MSG_STEALTH_TOKEN_POLICY, currently warn in production: tokens are
issued, attached, and redemption-validated, but an absent or failed token
does not block delivery — anti-abuse is degraded, anonymity is intact.
enforce is deferred past 1.0 (see Implementation
Status). Issuance is rate-limited with an
age-tiered per-account cap.
Verifiable issuance (planned/partial). So that a malicious issuer
cannot tag individual users via a per-user signing key, the issuer proves in
zero knowledge that every token was signed with the same published key
(batched Chaum–Pedersen DLEQ). The server half is implemented
(construct-crypto/src/privacy_pass.rs) and the client pins the issuer key
and verifies the proof; treat end-to-end verifiable-VOPRF as in progress
until enforcement relies on it. Until then, sender-unlinkability against a
malicious (as opposed to honest-but-curious) server is not claimed.
8.6 Connection-layer metadata: IP minimisation
Sealed sender hides application-layer identity; the network layer still
exposes the connection's source IP address, which is unavoidable for packet
routing. Konstruct minimises what is retained: the server never stores a
raw client IP. Anti-abuse rate-limit keys and logs use a salted one-way
hash of the address (construct-utils/src/lib.rs:92 hash_client_ip,
applied in construct-user-service/src/account.rs:140 and
construct-auth-service/src/devices.rs:292). Per-address anti-abuse
granularity is preserved (same address → same tag); the raw address is not
written to databases, rate-limit state, or logs, and no raw IP-to-account
mapping is kept.
Honest limit: a salted hash of the small IPv4 address space is not perfectly anonymous against a party holding the salt — it removes the raw address from storage, it does not make the network origin undiscoverable. To keep the address off the path entirely, route through VEIL (Transport), a VPN, or Tor; the terminating endpoint still sees the apparent source address of the connection it accepts.
8.7 Residual metadata — what a server still sees
Even with sealed sender always on, a compromised destination server can still observe the following. This is the honest counterpart to §8.1.
| Metadata | Visible? | Note |
|---|---|---|
| Message content | No | End-to-end encrypted; key material is not on the server. |
| Sender identity (per message) | No (sealed) | After first contact, only the recipient's session pair names it (session envelope). |
| Sender of a session's first message | No (sealed whole, construct-core 0.27) | The certificate and the handshake header are inside the first-flight box. The Android client still fetches bundles over an authenticated call, so the Kyber prekey id left outside can tie its first contacts to the account (BF-1). The bundle fetch and the send share a connection address — the network-adversary non-goal. |
| Recipient identity + timing | Yes | Required to deliver; SealedInner.recipient_user_id. |
Message kind (content_type) | No for ordinary sealed traffic | Normal sealed traffic leaves the field absent; the one structural exception is a DECRYPTION_ERROR (28) outside a session envelope (§8.2). The kind is byte 5 of the encrypted KNST frame. Until 2026-10-16 this row named 21 and 24, which nothing sends since 2026-09-28. |
| Ciphertext size after padding, volume | Yes | Padding buckets blunt but do not erase this. |
| Whether a post-quantum exchange is in flight | Yes, from frame size | The PQ section of the WirePayload header sits outside the plaintext padding (PKCS#7 to a multiple of 255 B, Chapter 5 §5.7). While an exchange is pending, every outgoing frame, receipts included, carries the encapsulation key (1184 B) or the ciphertext (1088 B), so it is about 1.1 KB longer than its padding bucket would make it. The server cannot read the section; it sees the outer envelope size, so a run of longer frames marks an epoch change. This is rhythm metadata about the conversation. Tracked as PQR-3. |
| Attachment size | A Padmé bucket, not the size (construct-core 0.33.0) | The media server sees the blob's length, padded to at most 12 % over the file and never under 4 096 bytes (Chapter 2 §2.2.3); before 0.33.0 it saw the size to the byte. A legacy blob uploaded before then still has its exact size. |
| Contact graph | Yes | Contact relationships are stored to route streams. |
| Connection IP (live) | Yes | At connection time; only a salted hash is retained (§8.6). |
sender_id → recipient_id edge | Not from one sealed message | May still be inferable from timing/volume correlation — the network-adversary non-goal. |
8.8 References
- Wire structures:
shared/proto/core/envelope.proto(SealedSenderEnvelope,SealedInner,SenderCertificate). - Server:
messaging-service/src/envelope.rs;construct-core/src/crypto/privacy_pass/;construct-utils/src/lib.rs(hash_client_ip). - Client (
construct-ios):Services/StealthPolicy.swift,Security/StealthSenderService.swift,Networking/gRPC/Services/MessagingServiceClient.swift,Services/Session/SessionCoordinator.swift,Security/StealthSendRecovery.swift. - Design rationale (internal): construct-docs
decisions/—stealth-sealed-sender-v2-always-on,sealed-sender-anti-abuse-economics,sealed-sender-session-control-channel,stealth-heartbeat-exclusion,server-influence-minimization,stealth-phase-c-verifiable-voprf.
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:216); token derivation
derive_token (:200); the k is from_bytes_mod_order-reduced
identically on both sides (:213 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:1150). 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:59, applied
:1195): 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:149;
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/token_redeem.rs:108), 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:173, token_gate_action), 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:59). |
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:216,derive_token:200,open_sealed_token_bytes:149) and DLEQ (DLEQ_DOMAIN:237,issuer_public_key:263,generate_dleq_proof:316,verify_dleq_proof:355).identity-service/src/main.rs— issuance (issue_tokens:1150) and caps (effective_issuance_cap:59).messaging-service/src/token_redeem.rs— redemption (redeem_token_checked:108);messaging-service/src/envelope.rs— policy (TokenRejected:18, policy switchtoken_gate_action:173).- 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:14,
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
and are sealed like any other in-scope 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. On the ordinary message path, the real call content type
(12) rides inside the encrypted KNST frame; SealedInner.content_type
and the outer envelope remain UNSPECIFIED
(construct-ios Services/Calls/CallManager.swift:1295-:1300). The
receiver's core names a decrypted call signal by that byte and hands its
body to the client as CallSignalDecrypted (construct-core 0.29.0,
Chapter 5 §5.8.4);
until then the core read only the envelope's type, so a sealed call
signal surfaced as an ordinary decrypted message and each client had to
look inside the frame itself.
The client-side framing that carries the suite id and PQ ratchet fields
for a call signal is CallSignalCrypto (Services/Calls/CallSignalCrypto.swift:87-:104);
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.
The DTLS handshake is post-quantum since 2026-10-01 (iOS, construct-ios 008c7f80): DTLS 1.3
with the hybrid key exchange X25519MLKEM768, so the SRTP keys come from a secret a recording
adversary needs both X25519 and ML-KEM-768 to recover. libwebrtc carries the group behind the
field trial WebRTC-EnableDtlsPqc, which the client turns on before it builds its
peer-connection factory (webrtc-sdk 150.7871.01). The fingerprints that authenticate the
handshake travel in the SDP, inside the post-quantum session, so an active attacker cannot steer
the group down without replacing a fingerprint.
Observed on the wire (two clients, 2026-10-01): ClientHello supported_versions DTLS 1.3, key
shares X25519MLKEM768 (1216 bytes) and X25519 (32 bytes); ServerHello X25519MLKEM768 (1120 bytes);
TLS_AES_128_GCM_SHA256.
Limits:
- The group is chosen by the two endpoints. A peer whose build does not set the trial agrees on X25519 alone, silently — libwebrtc does not expose the negotiated group to the application, so the client cannot refuse such a call. Both current builds set it; the Android client has no calls yet and will set it when it does.
- A test (
WebRTCFieldTrialsTests) fails if a WebRTC update removes the trial from the binary; that is the only warning such an update would give. - Video, when it ships, rides the same DTLS-SRTP transport and is covered without further work.
Changed 2026-10-01 (
PQC-4). Until then this section said the protection was classical: the fingerprints were authenticated by the post-quantum session, but the SRTP keys came from an ECDHE-only DTLS handshake a future quantum computer could break.
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. Post-quantum since 2026-10-01: the DTLS 1.3 key exchange is X25519MLKEM768 (§10.3), unless the peer's build does not offer it. |
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/mls_store.rs:39) — 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;
it has to be chosen before groups ship (PQC-5, Chapter 7 §7.3).
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: per-bundle inclusion proofs are live; split-view detection is not. The server does maintain an append-only Merkle log and returns a Signed Tree Head (STH) plus an inclusion proof inline with every pre-key bundle, and the client does verify both on receipt. What is missing is the transparency layer that makes KT meaningful against a malicious (not merely honest-but-curious) server: a publicly monitorable log endpoint, consistency checking over time, and STH gossip / third-party auditing. Until those exist, the STH is a self-signed commitment that catches a server which contradicts itself to you — not one that equivocates consistently across victims. This chapter records what is deployed, and the exact remaining 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
(device_id, identity_public_key)—kt_hash_leaf(device_id, identity_key_b64). A key rotation appends a new leaf rather than mutating the old one (db_ensure_leaf), so the log records the full history. The hybrid PQ identity key gets its own leaf kind (domain byte0x02vs0x00for the X25519 identity leaf,key_transparency.rs:246-:248) in the same tree, so both keys share one STH. - Tree head. The Merkle Tree Hash (
merkle_tree_hash/kt_compute_root) reduces all leaves to a single root. The server signs a Signed Tree Head (STH) = Ed25519("ConstructKT-v1" ‖ BE64(tree_size) ‖ root_hash) with its bundle-signing key (tree_head_signable,construct-core/src/crypto/key_transparency.rs:260). Since construct-core 0.32.0 a head may also be signed by a delegated hybrid key, over"konstruct/v1/kt-head" ‖ kid ‖ BE64(tree_size) ‖ root_hash(KtSignedTreeHead; Chapter 3 §3.11). - 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 is live today
The cryptographic machinery is deployed and exercised on every bundle fetch, not merely designed:
- Server appends + proves inline. On each pre-key-bundle request the
server ensures the device's leaf exists (idempotent; a changed key appends
a rotation leaf), builds the inclusion proof against the whole tree, and
signs the STH —
build_kt_proof/build_hybrid_kt_proof(key-service/src/kt.rs). The append-only table is DB-enforced: migration 044 documents thatUPDATE/DELETEare never granted onkt_leaves. The proof rides in the bundle response asKtInclusionProof. - Client verifies inclusion + STH signature, in the core. Since
construct-core 0.32.0 (2026-10-03) the core judges a bundle's proofs
(
key_transparency::verify_proof,construct-core/src/crypto/key_transparency.rs:305-:384; UDLverify_kt_proofs): it recomputes the leaf from the leaf domain (0x00X25519 identity,0x02hybrid identity), the device id and the key, checks the inclusion proof against the proof's root, and then judges the head the proof is relative to. With a root pinned, a delegated head decides alone and MUST name the proof's tree size and root; otherwise the Ed25519 head signature decides against the keys the platform handed to the core. The verdict isVerified,Unavailable(no key to check with — not evidence of anything),MalformedProof,InclusionProofInvalidorSignatureInvalid. No root is pinned yet, so today the head is checked by Ed25519 against the server's published key, as before. Until 2026-10-03 this check was written in each client (iOSKeyTransparencyVerifier, now removed); the iOS client calls the core fromKeyServiceClient. A failed verdict is recorded on the contact and logged; it does not reject the bundle. Every variant, its cause and the client's part: Appendix A §A.15.
A KT-verified key is the strongest certificate-based sender-attestation
lever used by sealed sender (the .kt lever, Chapter 8
§8.3):
a sealed sender whose certificate key matches a locally KT-verified key is
vouched without depending on any freshly fetched server key.
13.4 What is missing — the split-view gap
Everything below the crypto is the part that makes KT meaningful against a server that is actively malicious rather than honest-but-curious. None of it is deployed:
| Gap | Consequence today |
|---|---|
No public monitor endpoint. The STH is only ever handed to the requesting client inline with a bundle. There is no GetLatestTreeHead / GetConsistencyProof RPC (key_service.proto has only reserved comments for GetKeyHistory / ReportKeyMismatch). | Nobody — not the client, not a third party — can fetch the latest STH or a consistency proof independently of a bundle request. |
Consistency not checked in practice. The client has the kt_verify_consistency primitive but never calls it: there is no stored "last seen STH" and no endpoint to fetch (old_root, new_root, proof). | The append-only property is not actually verified over time; the client trusts each STH in isolation. |
| No STH gossip / split-view detection. The STH is signed by the same key that serves bundles (until a root is pinned and heads are signed by a delegated key — and even then the delegated key is the server's). Without clients (or a witness) cross-checking STHs, a server can sign tree A for the victim and tree B for everyone else — both verify locally. | This is precisely the equivocation KT is supposed to catch, and it is currently uncaught. |
| No independent auditor / monitor. No party mirrors the log to confirm it is genuinely append-only and that a given user's key line has not silently forked. | The log's honesty rests on the operator. |
Why it is not done yet. These are infrastructure and trust-distribution problems, not missing crypto. Split-view detection needs someone to gossip with — either multiple independent nodes/witnesses or a client-to-client gossip channel; federation is only newly implemented and single-node in practice, so there is no second party to cross-check against. A monitorable log endpoint plus an auditor is a separate service (storage, a gossip protocol, or a CT-style witness). For 1.0 the immediate MITM story is covered by trust-on-first-use pinning, safety-number comparison, and the certificate-signature lever (Chapter 8).
Honest limit. Today's STH is a signed, non-repudiable commitment from the server to you. It catches a server that contradicts itself to you, and gives you a durable signed record — but not a server that equivocates consistently across victims. Closing that requires the monitor endpoint, in-practice consistency checking, and gossip/auditing above. See Implementation Status.
13.5 References
key-service/src/kt.rs(server) —build_kt_proof,build_hybrid_kt_proof,db_ensure_leaf,generate_inclusion_proof,tree_head_signable;shared/migrations/044_key_transparency.sql(append-only log),054_kt_hybrid_leaf.sql(hybrid leaf kind).construct-core/src/crypto/key_transparency.rs—kt_hash_leaf,kt_compute_root,kt_verify_inclusion,kt_verify_consistency,merkle_tree_hash(shared client/server algorithm);verify_proof, the client's judgement of a bundle's proofs and head.construct-core/src/crypto/server_trust.rs— delegated head keys (Chapter 3 §3.11).construct-iosNetworking/gRPC/Services/KeyServiceClient.swift— callsverify_kt_proofson every bundle fetch.- 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.12–A.18 | Added in this revision (construct-core up to 0.48.1). Not covered by the §A.2 rule: variants MAY change between releases, and a client reads the UDL of the core it links. |
| §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:31.
| 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. | Handled by the core, not the caller: the other held states are tried, a frame carrying the handshake header opens a new state, and otherwise a DECRYPTION_ERROR goes to the writer (Chapter 5 §5.9–§5.10). There is no heal and no 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 the clean-path SPK_MAX_AGE_SECS boundary (30 days in construct-core/src/crypto/client_api.rs:61-:71). | Trigger a fresh bundle fetch / rotation wake. An implementation may offer an explicit degraded init_session_allowing_stale path, but it must still verify signatures and mark the resulting session at-risk. |
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.2.1 Session refusal prefixes
Session-level refusals travel inside the message of
SessionInitializationFailed or as the code of a NotifyError action.
Clients branch on the prefix, which is stable; the text after it is not.
| Prefix / code | Raised when | Required handling |
|---|---|---|
PQ_REQUIRED | The bundle yields no Kyber prekey the core can trust — any PqxdhRefusal (Chapter 4 §4.3). | No session. Do not retry classically; there is no classical path. A held state, if any, is left as it was. |
PQ_DOWNGRADE_REFUSED | Removed with PQXDH v2 (2026-09-25, construct-core 0.18). Was raised when a device that had advertised or used the PQ ratchet saw a bundle stop advertising the unsigned supports_pq_ratchet capability. That capability and its downgrade ledger were deleted the same commit that made the PQ ratchet mandatory wherever post-quantum is built — there is no longer a capability to withdraw, so this prefix is never produced by a current core. | — |
SENDER_CERTIFICATE_REFUSED / SENDER_DEVICE_MISMATCH | A sender certificate failed identity_for_opening (bad signature, key does not derive to the named device, expired past the delivery window), or names another device than the one the message was queued under. | Do not open from it. NoTrustedKey is transient: the open is retried once the server key is held. |
KEM_IDENTITY_REQUIRED | A first flight named no KEM identity key — a core older than the responder's proof of the initiator (Chapter 4 §4.4.4). | No session. Upgrade the sender. |
KEM_IDENTITY_CHANGED | A first flight named a KEM identity key other than the one pinned for the sending device. | No session; a held state is kept. Treat as an impersonation attempt, not a transition. |
KEM_IDENTITY_UNAVAILABLE | This device has no hybrid key to derive its KEM identity key from, so it cannot open as initiator. | No session. The hybrid key is created with the first bundle upload. |
OPEN_SESSION_REFUSED | The core's answer to SessionBundleFetched, the platform's reply to OpenSession, when the reopen was refused: the message carries the underlying prefix (PQ_REQUIRED: …, a stale or malformed bundle). | Nothing — the held session was kept and the open has ended. Retried on a later launch's upgrade sweep. |
DECRYPTION_ERROR_UNREADABLE | A received DECRYPTION_ERROR could not be opened with our identity key, or is malformed. | Nothing — no state is retired and nothing is resent. |
decrypt_failed | A message could not be read by any held state and carried no handshake header. | The core has already produced the DECRYPTION_ERROR (Chapter 5 §5.10) when the message allowed one; the platform only sends it. |
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 the MessagePack payload bytes does not match the stored value. |
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 (enum WirePayloadError).
| 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 (Suite 2 first-message reference value is 1088). |
WP-PQ-FIELD-TOO-LARGE | PqFieldTooLarge(usize) | Suite 4 PQ ratchet EK/CT field exceeds u16::MAX bytes. |
WP-PQ-FIELD-TYPE | InvalidPqFieldType(u8) | Suite 4 PQ section carries a field type other than 0, 1, or 2. |
WP-TOO-SHORT | TooShort(usize) | Buffer is shorter than the 52-byte fixed header, or too short for a length it has already read (a KEM object, the PQ section, pq_key_index's LEB128 bytes). |
WP-RETIRED-SUITE | RetiredSuite(u16) | suite_id (after stripping the flag bits) is 0x0003, the epoch-granular PQ ratchet retired in construct-core 0.24.0 (PQR-2, §2.4). Checked before the PQ section is parsed, since that suite's frames have no pq_key_index field to parse. |
WP-PQ-INDEX-NO-EPOCH | PqKeyIndexWithoutEpoch(u32) | pq_key_index is non-zero while pq_message_epoch = 0 — a key index without an epoch to belong to. |
WP-PQ-INDEX-NONCANONICAL | NonCanonicalKeyIndex | pq_key_index's LEB128 encoding is not the unique minimal one: more than 5 bytes, a nonzero bit past bit 31, or a final byte that could have been omitted (e.g. 0x80 0x00 for 0). The AD (§5.4) binds the decoded value, not its wire bytes, so a non-minimal encoding would let a relay re-encode the same value undetected. |
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.
Dropped also means finished with: the receiver records the message as processed and
acknowledges it, so the server stops redelivering it, and sends nothing back — there is no
session state in it a DECRYPTION_ERROR could name. The reference core says so with its own
routing verdict, MalformedDropped { message_id } (construct-core 0.28.0), placed before the
NotifyError that carries the reason. Until 0.28 the reason was the only answer, which a
platform could not tell from "no decision": a suite-3 message from a device on an old build was
logged as undecided and left no processed record (2026-10-01). The stream cursor still moved
past it, so it was not redelivered.
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:22.
| 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 Encapsulate failed (ML-KEM-1024 at the handshake, ML-KEM-768 in Suite 4). |
KemDecapsulationError(String) | ML-KEM 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 Media blobs — MediaError
Raised by seal_media, open_media and the blob-size helpers
(Chapter 2 §2.2.3).
Defined in construct-core/src/media.rs:53. It does not cross the FFI by
name: media_error (construct-core/src/uniffi_bindings.rs:3003) maps each
variant to a §A.2 variant, shown in the last column.
| Tag | Variant | Raised when | FFI variant | Client behaviour |
|---|---|---|---|---|
MEDIA-TOO-LARGE | TooLarge(len) | The file is larger than max_plaintext_len() — the 100 000 000-byte blob ceiling less the 36 bytes of nonce, tag and length prefix. | EncryptionFailed | Ask media_max_plaintext_len() before encoding or uploading, and refuse the attachment up front. |
MEDIA-INVALID-KEY | InvalidKey(len) | The key given to open_media is not MEDIA_KEY_LEN (32) bytes. | InvalidKeyData | The key comes from the message; treat the message's media reference as malformed. |
MEDIA-UNAUTHENTICATED | Unauthenticated | The blob is shorter than nonce plus tag, or opens neither as a v1 blob nor as a pre-0.33 blob under this key. | DecryptionFailed | Do not render the file. The blob, or the key the message carries, is wrong; retrying with the same pair cannot succeed. |
MEDIA-MALFORMED-PADDING | MalformedPadding | The blob authenticated as v1, but its length prefix does not fit the payload or a padding byte is not zero. | InvalidCiphertext | Do not render the file. A conforming sealer never writes this. |
A legacy blob has no padding, so MEDIA-MALFORMED-PADDING is reachable only
for v1.
A.13 Server-signature trust — TrustError
Raised by ServerKeyRing and the delegation functions
(Chapter 3 §3.11).
Defined in construct-core/src/crypto/server_trust.rs:60. It is not
exported through construct_core.udl: admit_server_delegations returns only
how many delegations were kept, and the other callers fold each variant into
the verdicts of §A.14 and §A.15. A client therefore never matches on it; the
table is the reason behind those verdicts.
| Tag | Variant | Produced when | Folded into |
|---|---|---|---|
TRUST-MALFORMED | Malformed | Delegation bytes do not decode; or a delegation is issued for a public key that is not hybrid-sized, or with not_before >= not_after. | A delegation is not admitted. |
TRUST-NO-ROOT | NoRoot | verify_rooted was given an empty root list: this build pins no root (PINNED_ROOTS is empty today). | A delegation is not admitted. |
TRUST-NOT-ROOTED | NotRooted | No pinned root verifies the delegation's root signature. | A delegation is not admitted. |
TRUST-UNKNOWN-KEY | UnknownKey | No admitted key has the named key id. Transient: the delegation may not have reached the client yet. | NoKey (certificate), Unavailable (tree head) |
TRUST-WRONG-PURPOSE | WrongPurpose | The key exists but was delegated for another purpose (sender certificate, tree head, sticker manifest). | BadSignature / SignatureInvalid |
TRUST-OUTSIDE-WINDOW | OutsideWindow | The signing time lies outside the delegation's not_before..not_after. | BadSignature / SignatureInvalid |
TRUST-EXPIRED | Expired | The verifier's clock is past the window's end plus the purpose's grace (Purpose::grace_secs, server-trust/src/lib.rs:99). | Expired (certificate), SignatureInvalid (tree head) |
TRUST-BAD-SIGNATURE | BadSignature | The hybrid Ed25519 + ML-DSA-65 signature does not verify under the delegated key. | BadSignature / SignatureInvalid |
An implementation MUST keep UnknownKey distinct from the rest: it is the
only variant that says "not yet", and every other one is a refusal.
A.14 Sender-certificate verdict — CertificateVerdict
The answer of certificate_verdict for a SenderCertificate
(Chapter 8 §8.3).
It is a UDL enum (construct_core.udl, enum CertificateVerdict), defined in
construct-core/src/crypto/sealed_sender/mod.rs:421 and derived by
SenderCertificate::verdict from the internal SenderRefusal
(sealed_sender/mod.rs:279) — the same checks a session opens on, answered
as a label.
| Tag | Variant | Produced when | Client behaviour |
|---|---|---|---|
CERT-VOUCHED | Vouched | The signature verifies under an accepted server key and the key derives to the device the certificate names. | The certificate may open a session and attest the sender. |
CERT-NO-KEY | NoKey | No server key to check with yet (SenderRefusal::NoTrustedKey; TrustError::UnknownKey). Transient. | Nothing is dropped. Retry after set_trusted_server_keys / admit_server_delegations have been called. |
CERT-BAD-SIGNATURE | BadSignature | The signature fails; the key does not derive to the named device or is not an X25519 key; or a hybrid-signed certificate claims more than one day of lifetime or names a key id of the wrong length. | Do not open a session from it and do not attest the sender. |
CERT-EXPIRED | Expired | Signed, naming its device, and expired longer ago than a message can wait at the relay (MAX_DELIVERY_AGE_SECS, 7 days). Reached only after the signature and device checks pass. | Do not open from it. A platform MAY pin the key of an expired-but-signed certificate. |
A.15 Key Transparency verdict — KtVerdict
The answer of verify_kt_proofs for one inclusion proof
(Chapter 13 §13.3).
A UDL enum, defined in construct-core/src/crypto/key_transparency.rs:290
and returned by verify_proof (key_transparency.rs:305). verify_kt_proofs
returns one verdict for the X25519 identity key and, when a hybrid identity
key was given, a second for it (KtVerdicts { identity, hybrid }).
| Tag | Variant | Produced when | Client behaviour |
|---|---|---|---|
KT-VERIFIED | Verified | The leaf is included under the proof's root and the tree head's signature verifies. | Record the key as KT-verified. |
KT-UNAVAILABLE | Unavailable | No key to check the head with yet: no Ed25519 keys handed over, or the head names a key id not admitted. | Not evidence of anything. |
KT-MALFORMED-PROOF | MalformedProof | A root or proof hash is not 32 bytes, tree_size is 0, leaf_index >= tree_size, or the head's key id is the wrong length. | Record the failure. |
KT-INCLUSION-INVALID | InclusionProofInvalid | The recomputed leaf is not under the root; or, with a root pinned, the delegated head names a different tree size or root than the proof. | Record the failure. |
KT-SIGNATURE-INVALID | SignatureInvalid | The head signature does not verify; or a root is pinned, no delegated head came with the proof and HYBRID_REQUIRED is set. | Record the failure. |
A failed verdict is recorded on the contact and logged; it does not reject the bundle (Chapter 13 §13.3 step 2).
A.16 History transfer — HistoryError
Why a history transfer between a user's own devices was refused
(construct-core/src/history/). The wire name is a UDL [Error] enum
(enum HistoryError), defined in construct-core/src/uniffi_bindings.rs:1169
and converted from the core's HistoryFailure (history/mod.rs:48), one case
for one case. Each case's message is the specification's name for it
(as_str); a client MUST log that name and map the case to text for a person.
| Tag | Variant | Raised when |
|---|---|---|
HIST-MALFORMED | Malformed | Bytes that are not the format, or break a rule of it that has no name of its own. |
HIST-TRUNCATED | Truncated | The stream ended before its End record. |
HIST-UNKNOWN-VERSION | UnknownVersion | A manifest format_version this reader does not speak. |
HIST-USER-MISMATCH | UserMismatch | A file for another account than this device's. |
HIST-RECORD-ORDER | RecordOrder | A record the phase does not allow, or out of the required order. |
HIST-ENVELOPE-MANIFEST | EnvelopeManifestMismatch | The manifest names a different snapshot or account than the envelope it arrived in. |
HIST-V1-REFUSED | V1RefusedForHistory | A history frame on CTT1 v1, the PIN-HMAC backup handshake. |
HIST-IDENTITY-MISMATCH | IdentityMismatch | Keys or ids in a frame are not the ones the directory holds for that device. |
HIST-KEM-KEY-ID | KemKeyIdMismatch | The frame was made for a Kyber prekey this device no longer holds. |
HIST-QR-PIN-MISMATCH | QrPinMismatch | The link QR pinned other keys than the frame carries. |
HIST-QR-PIN-ABSENT | QrPinAbsent | No QR pin and no bundle-only allowance: nothing authenticates the other device. |
HIST-NO-HYBRID-KEY | NoHybridKey | The other device has no hybrid identity key to verify with. |
HIST-SIGNATURE-INVALID | SignatureInvalid | The hybrid signature does not verify under the known key. |
HIST-CHUNK-OPEN-FAILED | ChunkOpenFailed | A sealed chunk did not open: wrong key, reordered, spliced or corrupted. |
HIST-LOCAL-KEYS | LocalKeysUnavailable | This device lacks a key the transfer needs (hybrid identity, Kyber signed prekey). |
Every case is a refusal of the transfer at hand; none is a "not yet" like
NoKey in §A.14.
A.17 Local store — LocalStoreError
Errors of the core's SQLCipher store that every client keeps. A UDL [Error]
enum, defined in construct-core/src/local_store_bindings.rs:12 and converted
from store::StoreError.
| Tag | Variant | Raised when | Client behaviour |
|---|---|---|---|
STORE-WRONG-KEY | WrongKey | The key does not open this store. | Do not retry with the same key. |
STORE-KEY-LENGTH | KeyLength | The store key is not 32 bytes. | The store key is 32 random bytes kept by the platform's key store. |
STORE-SCHEMA-TOO-NEW | SchemaTooNew | The store was written by a newer build than this one. | Do not open it with this build. |
STORE-CLOSED | Closed | The handle was used after the store was wiped. | The handle is dead; do not use it again. |
STORE-STORAGE | Storage(message) | SQLite or file errors, with the detail in the message. | Surface as an internal error; do not parse the message. |
A.18 Notification peek — PeekVerdict
Not an error: the answer of NotificationPeek::peek for one sealed delivery,
decided without acting on it (Chapter 5 §5.8.4).
A UDL enum, defined in construct-core/src/orchestration/orchestrator.rs:175.
| Tag | Variant | Meaning | Client behaviour |
|---|---|---|---|
PEEK-ALREADY-HANDLED | AlreadyHandled | The app already processed it: its message key is spent in the stored session. | Nothing to announce. |
PEEK-VISIBLE | Visible | A message the app would announce. | Show the alert. |
PEEK-CONTROL | Control | A silent control frame, a call signal or a DECRYPTION_ERROR. | Nothing to announce. |
PEEK-UNREADABLE | Unreadable | Not read here, for any reason; a first flight or a certificate route is always this. | The alert shows. |
A.19 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 - Media:
construct-core/src/media.rs - Server-signature trust:
construct-core/src/crypto/server_trust.rs - Certificate verdict:
construct-core/src/crypto/sealed_sender/mod.rs - KT verdict:
construct-core/src/crypto/key_transparency.rs - History transfer:
construct-core/src/history/mod.rs,uniffi_bindings.rs - Local store:
construct-core/src/local_store_bindings.rs - Notification peek:
construct-core/src/orchestration/orchestrator.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/
Appendix B — Post-Quantum Designs in Other Deployed Messengers
This appendix situates Konstruct's post-quantum construction against the other post-quantum messaging designs that are publicly documented and deployed at scale. It exists because the design decisions in Chapter 2 §2.4 and Chapter 4 §4.3 are not novel in kind, and a reader should be able to see which parts are conventional and which are ours.
Claims about other systems are sourced from their published specifications and are dated; claims about Konstruct cite the reference implementation, as required by the editorial rule.
B.1 The shared shape
Every deployed design in this space, Konstruct included, follows the same two-part pattern:
- A post-quantum KEM is run alongside a classical Diffie–Hellman, never instead of it, and the two secrets are combined through a KDF so that an adversary must break both.
- The classical Double Ratchet is left intact, and post-quantum material is layered on top of it rather than replacing its DH ratchet.
The differences are in when fresh KEM material is introduced after the handshake, and how the large KEM objects are carried.
The parameter split is shared too: Signal PQXDH, Apple PQ3 and Konstruct (since PQXDH v2, 2026-09-25) use the 1024 parameter set for the prekeys and the initial key, and the continuing ratchets — Apple's rekey, Signal SPQR, Konstruct Suite 4 — use ML-KEM-768. Konstruct's earlier PQXDH v1 used ML-KEM-768 and mixed its secret in only after the first DH ratchet step, so the first chain of every session was classical; v2 puts the secret in the initial root key.
B.2 Handshake-only versus continuing
| System | PQ at handshake | PQ after handshake |
|---|---|---|
| Signal PQXDH (2023) | yes | no |
| Apple iMessage PQ3 (2024) | yes | yes — periodic rekey |
| Signal Triple Ratchet / SPQR (Oct 2025) | yes | yes — continuous chunked ratchet |
| Konstruct Suites 1 and 2 | yes — PQXDH v2, mandatory (§4.3) | no |
| Konstruct Suite 4 | yes | yes — sparse periodic rekey (§2.4) |
Signal's PQXDH was the first at-scale deployment and deliberately covered only the initial handshake, which means it provided no post-quantum post-compromise security: a session that ran for months rested on a single KEM contribution made at its start. Apple's PQ3 and Signal's later SPQR both exist to close that gap, and Konstruct's Suite 4 addresses the same gap by the same reasoning.
Konstruct without Suite 4 therefore sits at the PQXDH level. The continuing property requires Suite 4, which is negotiated separately (§2.4).
B.3 Rekey cadence
| System | Cadence |
|---|---|
| Apple PQ3 | approximately every 50 messages, and at least once every 7 days |
| Signal SPQR | continuous — a new exchange proceeds as fast as message flow allows |
| Konstruct Suite 4 | every 16 DH-ratchet turns, and at least once every 7 days (construct-core 0.24.1) |
The three units are not comparable directly. Konstruct counts DH ratchet turns — changes of conversational direction — while additionally bounding the epoch's wall-clock age; Apple counts messages and also guarantees a floor in wall-clock time; Signal is bounded only by how fast chunks can be carried.
Changed in construct-core 0.24.1 (PQR-1, TODO 64.1). Until then Konstruct was the only one of the three with no time-based floor: a one-sided conversation took no DH turn and so never rekeyed, however long it ran. The exchange initiator now also proposes a new epoch on its next send once the current one has stood for
pq_ratchet_max_age_seconds(7 days) — the same floor Apple PQ3 guarantees — independent of the turn count. A conversation in which only the responder writes is covered too: the initiator's delivery receipts are its sends and carry the proposal (Chapter 2 §2.4.3, §2.4.4 rule 6). Until 2026-10-01 this note said otherwise.
B.4 Carrying the KEM objects
In the continuing ratchets, ML-KEM-768 encapsulation keys are 1184 bytes and ciphertexts 1088 bytes, against 32 bytes for an X25519 public key. (Konstruct's handshake ciphertext is ML-KEM-1024, 1568 bytes, and rides only on the initiator's first flight.) Each system resolves this differently:
- Apple PQ3 sends the key whole and accepts the cost, reporting that the PQ ratchet adds more than 2 KB to a message. The published rationale for rekeying only periodically is precisely that sending it with every message caused visible delivery delays on poor connectivity.
- Signal SPQR splits both objects into chunks protected by Reed–Solomon systematic erasure codes, spread across message headers: roughly 36 and 30 chunks for the two bulk phases, so that any sufficient subset reconstructs the object regardless of loss or reordering. The published design also splits the encapsulation key into a 64-byte seed-plus-hash phase and a bulk phase so the two directions can transmit in parallel.
- Konstruct Suite 4 sends each object whole in the frame's PQ section (Chapter 5 §5.3), and re-attaches it to every outgoing message until implicitly acknowledged (§2.4.4 rule 2). Loss is therefore recovered by repetition rather than by redundancy, at a cost of 1184 or 1088 bytes per outgoing message for the duration of an unacknowledged exchange.
B.5 Advancing without a reply
A rekey that needs a message in the opposite direction stalls in a one-sided conversation. Apple PQ3 addresses this explicitly by letting encrypted delivery receipts carry the ratchet forward, so a device that is merely online completes the exchange without the user replying.
Konstruct obtains the same property without a dedicated mechanism. Delivery receipts are ordinary session frames — they are encrypted through the same ratchet as any message (content type 14 inside the frame) — so they advance the DH ratchet turn counter on receipt and carry any pending PQ field on send (§2.4.3). A device that is online and acknowledging therefore keeps the exchange moving whether or not its user replies.
B.6 Granularity of the post-quantum guarantee
Changed 2026-09-30 (construct-core 0.24.0, PQR-2). Until then this section described a real asymmetry: Konstruct's Suite 3 derived every message key of an epoch from one stored
pq_epoch_secret, so the post-quantum contribution was constant within an epoch while the classical half already ratcheted per message. That gap is closed. What follows is the current state, kept honest about what changed and when rather than rewritten as if it had always been true.
Signal's SPQR carries its own symmetric chain inside the post-quantum component, so the post-quantum contribution ratchets between rekeys and the post-quantum half of forward secrecy has the same per-message granularity as the classical half. Each epoch's ML-KEM Braid output reseeds that chain.
Konstruct's Suite 4 does the same in kind, by a different mechanism. A completed epoch's ML-KEM-768 secret is spent once, into two directional chain keys (§2.4.1), and is never itself stored; each message takes the next key of its sender's chain, and a receiver's PQ key for a read message is derivable by neither side afterwards — the chain that produced it has already stepped past that index (§2.4.4 rule 5). Forward secrecy at message granularity is now supplied by both halves of the ratchet, where before it was the classical Double Ratchet alone.
A completed epoch, in turn, now arrives at least every 16 DH-ratchet turns or every 7 days —
whichever comes first, since PQR-1 was resolved in construct-core 0.24.1 (§2.4.3) — rather than
only on a turn count a one-sided conversation might never reach. The practical reading: an
adversary who steals a session state now recovers no post-quantum contribution to a message
already read, regardless of epoch, whether or not they can also break X25519. What they can still
recover is the future of the current epoch's chains and any not-yet-consumed skipped key within
PQ_CHAIN_RETENTION (§2.4.3) — the same residual an equivalent compromise leaves against the
classical chain.
Healing after a state compromise
Per-message granularity protects what was already read. How quickly the protocol heals after a state is stolen is a separate property, and there Suite 4 is slower than SPQR:
| Konstruct Suite 4 | Signal SPQR | |
|---|---|---|
| Epoch frequency | At least every 16 DH turns or 7 days, whichever comes first (pq_ratchet_interval, pq_ratchet_max_age_seconds; construct-core src/config.rs:68,73, defaults :146-147) | The next epoch starts as soon as the previous one completes: a few exchanges |
| Who generates the encapsulation key | Always the DR initiator (maybe_advance_pq_ratchet, maybe_start_pq_exchange_by_age: is_pq_initiator only; double_ratchet/internals.rs:312,330) | The roles alternate |
| Wire form | The whole encapsulation key (1184 B) or ciphertext (1088 B) in the WirePayload PQ section of every outgoing frame, receipts included, until implicitly acknowledged; outside the plaintext padding (§2.4.4 rule 2; Chapter 5 §5.3) | Erasure-coded chunks of uniform size |
Against an adversary who steals a session state and later has a quantum computer, Suite 4 heals within up to a week and SPQR within a few messages. On post-quantum healing after compromise Suite 4 is weaker. The classical Double Ratchet heals in one round trip, but that does not close the gap: an adversary with a quantum computer recovers the classical half of every new key, so only the post-quantum half still protects, and it renews only when an epoch completes.
The initiator role adds an asymmetry. The decapsulation key of a pending proposal lives in the initiator's state, so a state stolen from the initiator while a proposal is pending yields that key, and with it the next epoch's secret; the exchange after that heals. The worst case is about twice the cadence, 2 × 7 days. A responder's stolen state does not reveal an epoch that has not been proposed yet, so the initiator is the more valuable target. SPQR alternates the roles and has no such distinction.
Closing the gap is future work, tracked as PQR-3 and PQR-5
(Chapter 7 §7.3):
chunked encapsulation keys and ciphertexts carried inside the padded
ciphertext, continuous epochs, and alternating roles ("PQR v3"). No date
is set.
B.7 Authenticating the peer
Both deployed designs authenticate the peer classically: PQXDH's identity keys are X25519 / XEdDSA and its specification states that authentication rests on the discrete-log problem; PQ3 signs with ECDSA P-256. The stated reasoning is the same in both — breaking authentication needs a quantum computer at the time of the attack, while confidentiality has to survive one built later.
Konstruct authenticates post-quantum in both directions after the first contact, by two different means. The initiator encapsulates to a Kyber prekey signed by the responder's pinned hybrid (Ed25519 + ML-DSA-65) key, so only the responder can read. The responder encapsulates to the initiator's pinned ML-KEM-1024 identity key and mixes the secret into its first reply, so only the initiator can continue (Chapter 4 §4.4.4). The second direction uses a KEM rather than a signature on purpose: a signature over the handshake would be a transferable proof that one device opened a conversation with another. Both pins are trust-on-first-use, and the initiator's first-flight messages are authenticated classically until the responder's answer is read.
B.8 Group messaging
Konstruct's group path is MLS (Chapter 12).
Post-quantum MLS cipher suites combining ML-KEM with traditional
elliptic-curve KEMs are specified in an IETF draft
(draft-ietf-mls-pq-ciphersuites) and are not adopted here; the
group path is classical today.
Matrix, for comparison, uses Olm/Megolm and has no deployed post-quantum layer; its published direction is migration to MLS, which would inherit MLS's post-quantum cipher suites when those are adopted.
B.9 Formal analysis
Signal's SPQR implementation is machine-checked with Hax and F* for panic-freedom and field-arithmetic correctness, with ProVerif models of the protocol properties; the underlying construction was published at Eurocrypt 2025 and USENIX Security 2025. Apple's PQ3 received an independent mechanised analysis published at USENIX Security 2025.
Konstruct's construction has received no formal analysis. This is stated as a fact about the current state of the work, not as a deficiency claim about the construction.
B.10 Sources
- Signal — Signal Protocol and Post-Quantum Ratchets (2 October 2025)
- signalapp/SparsePostQuantumRatchet
- Signal — The PQXDH Key Agreement Protocol
- Apple Security Research — iMessage with PQ3 (2024)
- A Formal Analysis of Apple's iMessage PQ3 Protocol, USENIX Security 2025
- ML-KEM and Hybrid Cipher Suites for MLS, IETF draft
- Olm & Megolm — Matrix Specification
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-transport — QUIC / HTTP-3 transport (
construct-engineis archived) - 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.4.0 — unreleased (2026-10-16)
Reconciles the specification with construct-core 0.48.1 (origin/main, 2026-10-15); the last
reconciliation was with 0.28.0 on 2026-10-01. Mostly normative additions. One change is
wire-incompatible in one direction: a media blob written by core ≥ 0.33.0 does not open in a
reader older than 0.33.0 (the reverse still works). While the document is 0.x, a wire break moves
the minor version, as v0.3.0 did. Also corrects four statements that were already wrong at v0.3.0.
- SPQR comparison corrected (editorial; Appendix B §B.6,
§2.4.4 rule 5). The text said SPQR seeds a symmetric chain once
per handshake; it reseeds every epoch. The comparison now states the real differences (epoch
frequency, key-generator role, wire form) and that Suite 4 is weaker on post-quantum healing
after a state compromise. New
PQR-5;PQR-3raised to Medium because the PQ section outside the padding shows when an exchange is in flight (§8.7). The threat model now states that forward secrecy holds up to the retention window. - Server keys rooted offline — implemented, not in force (2026-10-03, construct-core 0.32.0;
Ch. 3 §3.11, new).
Hybrid Ed25519 + ML-DSA-65 delegations from an offline root; server signatures labelled by
purpose (
konstruct/v1/sender-cert,kt-head,sticker-manifest), naming a key id derived from the key, with every field length-prefixed; per-purpose validity windows and grace. The client's judgement of a sender certificate and of a KT head moved into the core (certificate_verdict,verify_kt_proofs);SenderCertificategainsserver_kid(8) andserver_signature_hybrid(9).PINNED_ROOTSis empty, so until a root is pinned every check is Ed25519 against the keys the platform hands over, as before. Chapters 1 (coverage table), 4 §4.4.2, 7 (PQC-2, component row), 8 §8.3 and 13 §13.2–13.3 say so. Before: the book described one unversioned Ed25519 key learned from the server, and KT verification in Swift. - The KNST frame and what a decrypted message is (2026-10-02, construct-core 0.29.0–0.31.0;
Ch. 5 §5.8.4,
new). The book mentioned "KNST byte 5" without defining the frame. Now: the 30-byte header,
chunking (3 770 bytes, at most 256 frames), and the normative rule that a sealed message is
classified by byte 5, not by its generic envelope — call signal (
CallSignalDecrypted, 0.29.0), silent control frame (ControlFrameDecrypted, 0.30.0), else a message. The core writes and parses the frame for every client since 0.31.0. Ch. 10 and §8.2 point to it. - READ_SYNC (content type 30) (2026-10-15, construct-core 0.48.1; Ch. 5 §5.8.4). A silent control frame carrying read positions between a user's own devices; a client MUST NOT send it to a contact and MUST drop one not from its own account. Before 0.48.1 the core read a type-30 frame as a message.
- Media blobs sealed by the core and padded to Padmé buckets (2026-10-04, construct-core
0.33.0; Ch. 2 §2.2.3,
Ch. 8 §8.7, Ch. 1).
v1 blob = AES-256-GCM with associated data
konstruct-media-v1over a length prefix, the file and zero padding, sizedmax(Padmé(36 + |file|), 4096). Before: each client sealed media itself and the server saw every file's size to the byte; the book cited the iOS code. - Every message names its writer (2026-10-04, construct-core 0.34.0;
Ch. 4 §4.4.2). A receiver decrypts
on the one session of the device a message names;
plan_receiving_decrypt, which tried a message against each of a peer's devices, is removed. - Notification peek (2026-10-11, construct-core 0.44.0; Ch. 5 §5.8.4, Ch. 7). A read-only view a notification extension asks whether a sealed delivery is a message to announce.
- Decrypted text the core does not hand on is wiped (2026-10-11, construct-core 0.45.1; Ch. 2 §2.6, Ch. 5 §5.8.4): a control frame around its body, and the notification peek's text, are zeroised, not merely freed.
- VEIL's TLS offers X25519MLKEM768 (2026-10-03, construct-core 0.31.2 pins construct-veil
c3330a4; Ch. 1, Ch. 6 §6.5, Ch. 7PQC-6). Transport TLS moves from "not established" to "partly": the VEIL client offers the hybrid group first onaws-lc-rs; QUIC still usesringwith no hybrid group, and the direct path's system TLS is unverified. - Corrected: the AD binds device ids, not account UUIDs (Ch. 5 §5.4, Ch. 3 §3.4). The session layer has been keyed by 32-character device ids since 2026-08-26; the book still named 36-character server UUIDs, so the AD lengths it gave (125 / 133 bytes) were wrong — they are 117 / 125.
- Corrected: one AD version (Ch. 5 §5.4.1).
§5.4.1 and the §5.8 pseudocode still prescribed the AD v2 retry that construct-core 0.24.2
removed and that v0.3.0 already listed as resolved (
SEC-006). - Appendix A: error and verdict enums added since core 0.28 (§A.12–§A.18):
MediaError,TrustError,CertificateVerdict,KtVerdict,HistoryError,LocalStoreError,PeekVerdict, each with its variants, cause, client behaviour and source. Source line references across the chapters were also refreshed (editorial). - Corrected elsewhere: the introduction named construct-core v0.9.x, a document version of
v0.1.2, an MIT licence for the reference implementation (it is Apache-2.0), and "who sent a
sealed message, calls" as classical; the Chapter 1 coverage table still listed the PQR-1 gap
as open; Chapter 3 §3.8 gave session state the when-unlocked access class (it is
after-first-unlock); Chapter 13 called the
0x00KT leaf the Ed25519 identity (it is the X25519 one); the Chapter 8 residual table named content types 21 and 24 as the outer exceptions (it is 28); Chapter 7 cited Swift verifiers that no longer exist and an Android core of 0.21.
v0.3.0 — unreleased (2026-09-30)
Wire-incompatible. Reconciles the specification with construct-core 0.24.0 (PQR-2,
decisions/pq-ratchet-per-message-chain.md) and 0.24.1 (PQR-1). Suite 3 (the sparse continuous PQ
ratchet as it existed through core 0.23) is retired without a compatibility path, as PQXDH v1 was
before it: builds speaking Suite 3 cannot exchange PQ-ratchet messages with builds speaking Suite
4, and a stored Suite-3 session is not carried forward — the next send simply opens a Suite-4 one.
This revision also corrects a description that had been stale since PQXDH v2 (2026-09-25, core
0.18): the book still described the PQ ratchet as bundle-negotiated with a downgrade refusal,
which that same cutover removed.
-
A payload that does not parse is finished with (2026-10-01, construct-core 0.28.0; Appendix A §A.4). Dropped now also means recorded and acknowledged, with nothing sent back; the core names it with the routing verdict
MalformedDropped. Before, a malformed or retired-suite message got only an error, which a client could not tell from "no decision": it was logged as undecided and left no processed record (the stream cursor still moved past it). -
Calls are post-quantum (2026-10-01,
PQC-4resolved; Ch. 10 §10.3, Ch. 1 — Post-quantum coverage). The DTLS handshake of a call is DTLS 1.3 with X25519MLKEM768: libwebrtc's field trialWebRTC-EnableDtlsPqc, turned on by the iOS client on webrtc-sdk 150.7871.01. Observed on the wire: key shares of 1216 and 1120 bytes. Not frame encryption: for a two-party call the DTLS exchange is what keys the media, and it now needs ML-KEM-768 broken as well as X25519. Limit: the endpoints choose the group, and a peer without the trial gets X25519 silently. -
PQR-1 has no remaining gap (2026-10-01, construct-core
68cd63c, test only; Ch. 2 §2.4.3, §2.4.4 rule 6, Ch. 7 §7.3). The book said a conversation only the responder writes could not rekey and that closing it needed roles that alternate per epoch. The initiator's delivery receipts are sends: they run the age check and carry the proposal, so such a conversation reaches a new epoch in three round trips. Role alternation would not have helped any case — every exchange needs both sides to send once. New normative rule 6: a client answers every decrypted message with a receipt on the session and offers no switch to turn receipts off; both clients already do, so no implementation changes. -
What is and is not post-quantum, layer by layer (2026-10-01, Ch. 1 — Post-quantum coverage). The threat model promised quantum-recording resistance "for sessions that used Suite 2 (PQXDH)" — a suite no session negotiates — and named no exception. It now says what is true: the content of one-to-one messages and attachments is protected, every session being PQXDH v2. It also lists the layers that are still classical: the sealed-sender box (who sent a message), other X25519 sealed boxes, server-signed certificates and tree heads, device and recovery authentication, calls (DTLS-SRTP), MLS groups, Privacy Pass, and transport TLS (not established). Each one has a source line and a §7.3 row,
PQC-1…PQC-6. Chapters 8, 10 and 12 and the introduction say the same where they describe those layers. The "Identity unforgeability" goal names the hybrid Ed25519 + ML-DSA-65 bundle signatures that PQXDH requires, not Ed25519 alone. -
The first flight sealed whole (construct-core 0.27.0, 2026-10-01; Ch. 8, "First flight";
SealedInner.first_flight = 22). Found while preparing the anonymous bundle fetch: until the peer answers, every message an initiator writes carried its ML-KEM identity key in the clear handshake header, and an own-device copy ties that key to the account — the server could read the sender of a sealed first contact off it (FF-1, resolved in the same revision). The certificate and the whole header are now sealed under X25519 plus the handshake's ML-KEM secret; only the recipient's Kyber prekey id and the ciphertext stay outside.BF-1corrected: the iOS client already fetches bundles over the sealed channel, the Android client does not.PQC-1narrows to two rare boxes. Wire-incompatible with core 0.26; sessions of it still in their first flights are refused and renew. With it (0.27.1): an initiator does not write on a handshake its peer has not answered in 7 days, so no first flight outlives the prekey it was sealed to — handshake age 7 + queue 7 ≤ prekey retention 14, checked at compile time. Retired envelope pairs are kept for the queue window, 7 days; the 30 written before was the receipt-routing TTL, mistaken for the queue. -
The session envelope (construct-core 0.26.0, 2026-10-01; Ch. 8, "Session envelope";
SealedInner.session_envelope = 21). After first contact a sealed message carries no certificate and no readable wire payload. It carries an envelope keyed from the session's PQXDH v2 root, and the recipient finds who wrote it by a tag. Who sent an established-session message is post-quantum, and the server no longer sees the ratchet header that linked a chain's messages. A pair outlives its ratchet (retired for the queue window), so a reader that reset still answers the writer along it. The coverage table andPQC-1are updated. NewBF-1: the authenticated bundle fetch names a first message's sender to the server, which is why the first flight's box is not the next thing to make post-quantum. Wire-incompatible with core ≤ 0.25; stored sessions from it are refused and renew on the next send. -
§7.3 brought up to date (2026-10-01).
SEC-006resolved (construct-core 0.24.2: the AD v2 fallback decrypt removed);DE-2resolved (the sealed-copy id map is persisted on both clients);PQR-4now describes the loss it is about and the measurement that will settle it (construct-core 0.25.0,ReorderStats);SEC-009says CFE records, not session JSON. -
The post-quantum half of the ratchet gets per-message forward secrecy (Ch. 2 §2.4, Ch. 5 §5.1, Ch. 5 §5.8.3). Until now, a completed PQ epoch's ML-KEM-768 secret was stored and mixed unchanged into every message key of the epoch (
pq_epoch_secret, up to 4 epochs retained); the classical half of the ratchet already had per-message forward secrecy, the post-quantum half only per-epoch. A completed epoch's secret is now spent at once into two directional chain keys (HKDF-SHA-256(salt = ∅, IKM = epoch_secret, info = "construct-pqr-chains-v2" ‖ epoch, L = 64)) and never stored; each message takes the next key of its sender's chain (info = "construct-pqr-step-v2"), mixed into the Double Ratchet key exactly as before (info = "construct-pqr-msg-v2", the PQ key as the HKDF salt). The receiver advances its chain to the message's index, keeping passed-over keys as skipped PQ keys under the same count/jump/age bounds as classical skipped keys; a failed decrypt rolls back all PQ state via the existing snapshot mechanism, so a forged index consumes nothing. Only the current epoch's send chain is live;PQ_CHAIN_RETENTION = 2(current + previous epoch's receive chains) replaces the old 4-epoch-secret retention and is the new answer toPQR-4(Ch. 7 §7.3) — the bound is the skipped-key window, not an independently-sized epoch count.PQR-2is resolved by this change. -
Suite 4 =
PQ_RATCHET(0x0004); Suite 3 (0x0003) is retired. The WirePayload PQ section (Ch. 5 §5.3) gainspq_key_index, encoded as minimal unsigned LEB128 (1 byte below 128) directly afterpq_message_epoch; a non-minimal encoding, an index without an epoch, or a frame still claiming suite 3 is refused (NonCanonicalKeyIndex,PqKeyIndexWithoutEpoch,RetiredSuite— Appendix A §A.4). The AD (Ch. 5 §5.4) now bindspq_message_epoch ‖ pq_key_index(bothu32BE): 133 bytes for a Suite 4 UUID session, up from Suite 3's 129.WirePayload.pq_key_indexadded to the UniFFI record. -
Known-answer vectors for the three new derivations (secret =
0x42×32, epoch = 1, DR key =0x07×32), recomputed independently against RFC 5869 and pinned in Ch. 2 §2.4.2: the initiator's send and receive chain keys, the send chain's keys at index 0 and 1, and the message key mixed from index 0. To be published toconstruct-protos/conformancealongside the existing content-type vectors. -
CfePqRatchetStateV2(CFE keypqr2: epoch chains, skipped PQ keys, provisional chains in place of a provisional secret) replacesCfePqRatchetStateV1(CFE keypqr) (Ch. 3 §3.7). The old key is not migrated — a Suite-3 session is refused at restore, not upgraded in place. -
Appendix B §B.6 rewritten: Konstruct's post-quantum forward secrecy now matches Signal SPQR's in kind (per-message). The comparison describes Konstruct's own mechanism (two chains reseeded from a fresh KEM secret every epoch) without characterising SPQR's internal chain structure, which this book has not independently verified against a primary source. The earlier per-epoch asymmetry is kept in the text as a dated "changed" note, not silently rewritten away.
-
PQR-1 resolved (construct-core 0.24.1). An epoch used to rekey only on a count of DH-ratchet turns, so a one-sided conversation — one side always writing, the other always reading — never took a turn and so never rekeyed.
pq_ratchet_max_age_seconds(7 days, the floor Apple PQ3 guarantees) is now checked on everyencrypt: past it, the exchange initiator proposes a new epoch on its next send regardless of the turn count (maybe_start_pq_exchange_by_age); the turn-counted path checks the same age.pq_epoch_sinceis the clock, persisted as CFECfePqRatchetStateV2.since; a blob without it reads as maximally old. Remaining gap, stated in the code and here: only the exchange initiator ever proposes, so a conversation in which only the responder writes still cannot rekey by either path — closing that needs roles that alternate per epoch, not a parameter change. See Ch. 2 §2.4.3, Appendix B §B.3, and Ch. 7 §7.3. -
Correction: the PQ ratchet has not been bundle-negotiated since PQXDH v2 (2026-09-25, core 0.18), and this book kept describing it as if it still were. The unsigned
supports_pq_ratchetbundle capability and its downgrade refusal (PQ_DOWNGRADE_REFUSED) were removed the same commit that made PQXDH v2 — and with it the PQ ratchet — mandatory wherever the core'spost-quantumfeature is built; a first message on any other suite is refused (PQXDH_REQUIRED). Corrected in Ch. 2 §2.4, Ch. 3 §3.7, Ch. 4 §4.3/§4.6/§4.7, Ch. 7 §7.1, and Appendix A §A.2.1. The field (proto field 24) is not removed from the wire schema, only deprecated, so a stale value from an old client is read and ignored rather than misparsed. -
Every "Suite 3" naming elsewhere in this book (§2.1 suite table, §2.7 constants, Ch. 4 §4.6, Ch. 6 §6.2, Ch. 7 §7.1/§7.3, Appendix A's
KemEncapsulationErrorrow, the introduction) now reads Suite 4, with a historical note where the old numbering still matters.
v0.2.0 — unreleased (2026-09-28)
Wire-incompatible. Reconciles the specification with construct-core 0.18–0.20. Builds speaking v0.1 cannot open sessions with builds speaking v0.2; there is no compatibility path before 1.0.
- PQXDH v2, mandatory (Ch. 2 §2.3,
Ch. 4 §4.3). The ML-KEM
secret enters the initial root key
(
info = "Construct-PQXDH-RootKey-v2" || SHA-256(KEM_pub) || SHA-256(kem_ct)) instead of being mixed in after the first DH ratchet step, so the first chain is no longer classical only. Prekeys move from ML-KEM-768 to ML-KEM-1024 (1568-byte key and ciphertext); ML-KEM-768 remains only in the Suite 3 ratchet. A session is not created without a validly signed Kyber prekey and a hybrid identity. Removed:construct-pqxdh-v1, the deferred contribution store (KyberSessionState), the classical fallback. - Kyber prekey signatures v2 (Ch. 3):
suite byte
0x11, signedcreated_at, Ed25519 and hybrid, on one-time Kyber prekeys too; a KEM-SPK older than 30 days is refused with no stale override. Bundle fields 25–28. - PQ-ratchet downgrade refusal: a device that advertised or used Suite 3 and stops advertising it is refused.
- Handshake header on the whole first flight, and it opens at any message
number (Ch. 4 §4.4.1).
Wire
suite_idbit0x0100marks it. - The responder opens from the sender certificate (Ch. 4 §4.4.2), with its server signature required; no bundle fetch to receive.
- No tie-break (Ch. 4 §4.5). Simultaneous openings keep both states.
- Previous states (Ch. 5 §5.9): up to three per peer device for seven days; one that decrypts is promoted.
- DECRYPTION_ERROR replaces END_SESSION and healing
(Ch. 5 §5.10): content
type 28, a fixed 191-byte sealed box naming the ratchet key of the unread
message; the writer retires that state only if it is current and resends
once. Content types 21 (END_SESSION) and 24 (SESSION_RESET_INIT) are
retired, as are
session_ready, the handshake ping and the heal queue. - A DECRYPTION_ERROR is sealed only to a writer whose certificate passes the server-signature
and device checks, on every path (Ch. 5 §5.10.3);
DE-1, found while writing this revision, was fixed in construct-core 0.20.1. - The responder proves the initiator post-quantum
(Ch. 4 §4.4.4):
every device has an ML-KEM-1024 identity key derived from its hybrid key's ML-DSA seed; the
first flight names it (wire bit
0x0200), bound into the root key (RootKey-v3); the responder pins it, encapsulates to it and mixes the secret into its first sending chain, and carries the ciphertext (bit0x0400) until the initiator sends on a later chain. Before this, only the initiator had post-quantum authentication of its peer. No signature is added. - Open issue added:
DE-2(Ch. 7). Corrected: the test-coverage table overstated the integration tests. - Platform matrix (Ch. 7 §7.2) and
the introduction brought up to date: Android is a working client on the current core, not
Phase 0; macOS links the core directly (the
construct-enginepath was retired 2026-07-28);construct-tuiis paused and does not interoperate. Transport references point atconstruct-transport.
v0.1.0 — unreleased
Initial public draft. The core protocol 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, and open issue tracker.
- 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 — recorded as the production engine-QUIC direct path 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 and is sealed; the real call type lives inside the encrypted KNST frame for ordinary sends. 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 — RFC 6962-style append-only key log, Signed Tree Head, inclusion proofs, and the honest deployment boundary: per-bundle inclusion proofs are live, while public monitoring, consistency checking, and STH gossip remain open.
-
Key Transparency status corrected (2026-07-27). A code audit found the earlier "server not yet publishing the log" claim was wrong: the server maintains an append-only Merkle log and returns a Signed Tree Head + inclusion proof inline with every pre-key bundle (
key-service/src/kt.rs, migrations 044/054), and the client verifies inclusion + STH signature on receipt (KeyTransparencyVerifier). Chapter 13 and the Implementation Status KT row were rewritten to state what is live and to name the real remaining gap — no public monitor endpoint, no in-practice consistency checking, no STH gossip/auditor — i.e. the current STH catches a self-contradicting server but not one that equivocates consistently across victims (split view). -
Protocol-code audit (2026-08-26). Reconciled the public spec with
construct-core,construct-server, and the iOS client on the protocol surfaces implementers need:- WirePayload and CFE byte layouts corrected to little-endian where the reference encodes little-endian; CFE CRC corrected to payload-only.
- Suite 3 (
PQ_RATCHET) documented as a separate, capability-negotiated sparse continuous ML-KEM-768 ratchet, with its WirePayload PQ section and message-key HKDF. - Double Ratchet KDF labels corrected (
Double-Ratchet-*), AD v3 length corrected to 125 B for UUID sessions (129 B with Suite 3 epoch), and AD v2 fallback corrected to the same field layout with a different version byte. - Prekey signatures corrected from
public_key || epochtob"KonstruktX3DH-v1" || [0x00, suite_id] || public_key; SPK clean freshness corrected from 10 days to 30 days with explicit stale-tolerant degraded init. - Hybrid Ed25519 + ML-DSA-65 signatures updated from planned to implemented/capability-gated, including the hybrid identity cross-signature and 3373-byte signature format.
- Sealed sender metadata updated: normal sealed traffic no longer exposes
real
content_type;SealedInner.content_type,priority, andttlare deprecated server-visible compatibility fields, with only structural exceptions 21 and 24 allowed before decryption.
-
Suite 3 operational parameters and prior-art comparison (2026-08-28). Read out of
construct-corewhile answering how the classical and post-quantum halves combine end to end.- §2.4.1 adds the cadence and retention constants that the chapter
previously described only as "a configured cadence": 16 DH-ratchet
turns (clamped
[4, 64]), 4 retained epoch secrets, unanswered proposals abandoned by age. The unit matters and was not stated: the counter advances inside the DH ratchet step, so a one-sided burst of any length makes no PQ progress. A pending field rides on every outgoing frame including delivery receipts, so an acknowledging peer carries the exchange forward without replying. - §2.4.2 adds five normative rules that were implemented but unwritten:
commit-on-success (a malformed PQ field must not alter session state
and must not affect its carrier's classical delivery), re-attach until
implicitly acknowledged, one exchange in flight,
ek_hashdisambiguation of re-proposals, and the fact that the epoch secret is constant within an epoch. - New Appendix B compares the construction with Signal PQXDH, Signal's Triple Ratchet / SPQR (October 2025), Apple iMessage PQ3, and the MLS post-quantum cipher-suite drafts, on five axes: handshake-only versus continuing, rekey cadence, how the 1184/1088-byte KEM objects are carried, advancing without a reply, and the granularity of the post-quantum guarantee.
- §7.3 records four open issues that comparison surfaced —
PQR-1(no wall-clock rekey floor),PQR-2(epoch-granular post-quantum forward secrecy against message-granular classical),PQR-3(KEM objects carried whole and re-attached per message),PQR-4(epoch retention of 4 against a skipped-key tolerance of 1000).
- §2.4.1 adds the cadence and retention constants that the chapter
previously described only as "a configured cadence": 16 DH-ratchet
turns (clamped