d3chat Encryption
Source of truth:
d3chat/frontend/src/crypto/bootstrap.tsd3chat/frontend/src/crypto/encrypt.tsd3chat/frontend/src/crypto/x3dh.tsd3chat/frontend/src/crypto/senderKey.tsd3chat/backend/app/routers/keys.pyd3chat/backend/app/models/key.pyd3chat/backend/app/models/sender_key.py
Security Goals
d3chat’s cryptographic design targets:
- prevent server-side plaintext access for protected message flows
- maintain multi-device compatibility
- support asynchronous DM setup
- provide efficient group messaging with manageable overhead
The design is implementation-pragmatic: it favors browser-native cryptographic primitives and explicit server-coordinated key distribution endpoints.
Cryptographic Primitive Set
Client-side primitives
- ECDH P-256: shared-secret derivation
- ECDSA P-256: signed pre-key authenticity
- AES-GCM: payload encryption/decryption
- HKDF/HMAC-SHA256: key derivation and ratcheting
Server-side primitives
Server does not decrypt payload content. It stores/relays key material and ciphertext blobs, with signature and policy validation in control paths.
Key Hierarchy and Identity Model
User and device scope
Keys are device-scoped, not user-global. Each device independently provisions:
- identity key pair
- signed pre-key
- one-time prekeys (batch)
- sender-chain state for group channels
Server persistence
device_keys stores published public material and OTP pool metadata for each device.
sender_keys stores per (channel_id, device_id) chain material references used for sender-key distribution.
Device Bootstrap Flow
During first authenticated session on a device:
- generate ECDH identity pair
- generate ECDSA signing pair
- generate ECDH signed pre-key
- sign signed-prekey public bytes with ECDSA private key
- generate batch OTP prekeys (default 20)
- upload bundle to
POST /keys/upload - persist local key state in client storage
Bootstrap is idempotent by local key existence checks.
Direct Message Encryption (X3DH-Inspired)
Initiator path
For first secure DM in a channel:
- resolve peer member
- fetch peer bundles via
/keys/{user_id}/bundles - derive shared secret using X3DH-inspired DH tuple
- derive AES session key via HKDF
- persist session key locally
- publish setup metadata to
/keys/channels/{channel_id}/x3dh-setup
Responder path
When decrypting without local session key:
- fetch setup metadata from
/keys/channels/{channel_id}/x3dh-setup - perform responder-side mirror DH operations
- derive same session key
- persist session key and decrypt
Current implementation nuance
Code currently strips OTP participation in initiator session derivation for compatibility with setup relay semantics (used_one_time_key: false), trading some theoretical properties for deterministic derivation in current flow.
Group Encryption (Sender Keys)
Model
Each sender device maintains a chain key per channel. Messages are encrypted with per-message keys derived by ratcheting:
- message key = HMAC(chain_key, 0x01)
- next chain key = HMAC(chain_key, 0x02)
Ciphertext embeds message number prefix: "<n>:<ciphertext>".
Distribution
When a local sender key is absent:
- client generates sender key material
- uploads with
POST /keys/channels/{channel_id}/sender-keys - backend stores/upserts and relays to federation peers when channel is federated
Decrypt ordering requirement
Receiver ratchets forward to target message number. This is monotonic and one-way. Messages must be processed in order per (channel, sender_device) stream.
Key Consumption and Replenishment
OTP consumption behavior
GET /keys/{user_id}/bundles and GET /keys/{device_id}/bundle may consume one OTP when present.
When remaining OTP count falls below threshold (<10), backend emits keys.low_otp realtime event.
Replenishment
Client replenishes with POST /keys/one-time and appends new OTP set.
Payload and Protocol Versioning
Messages include:
contentcontent_typeprotocol_versionsender_device_id
protocol_version=2 is used by current frontend encrypted send path.
Clients should branch decode/encryption behavior by protocol version to support migration paths.
Failure Modes and Recovery
Missing key bundle
If recipient has no published bundle, DM secure setup cannot proceed.
Lost local ratchet state
Sender-key decryption can fail for historical messages if chain has advanced and cached plaintext/state was lost.
Own-message decrypt pitfall
For sender-key channels, sender’s local chain may advance past its own emitted message before reload. Plaintext caching mitigates this UX issue.
Network partition during key publish
Clients should treat key publish as prerequisite for encrypted send and retry with backoff.
Security Caveats and Hardening Opportunities
- OTP usage signaling in DM setup could be strengthened with explicit indexed OTP linkage.
- Device key attestation and transparency logs are not currently present.
- Secure hardware-backed key storage varies by client platform; mobile should use OS keychain/secure enclave equivalents where available.
- Forward secrecy guarantees depend on lifecycle policies (rotation cadence and session invalidation discipline).
Mobile Implementation Checklist (Encryption)
- Persist all private key material in secure local store.
- Rehydrate session and sender-chain state before rendering encrypted channels.
- Decrypt sender-key messages in deterministic order.
- Handle
keys.low_otpby scheduling replenishment. - Keep plaintext cache keyed by message id for own-message replay resilience.