Skip to content

d3chat Encryption

Source of truth:

  • d3chat/frontend/src/crypto/bootstrap.ts
  • d3chat/frontend/src/crypto/encrypt.ts
  • d3chat/frontend/src/crypto/x3dh.ts
  • d3chat/frontend/src/crypto/senderKey.ts
  • d3chat/backend/app/routers/keys.py
  • d3chat/backend/app/models/key.py
  • d3chat/backend/app/models/sender_key.py

Security Goals

d3chat’s cryptographic design targets:

  1. prevent server-side plaintext access for protected message flows
  2. maintain multi-device compatibility
  3. support asynchronous DM setup
  4. 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:

  1. generate ECDH identity pair
  2. generate ECDSA signing pair
  3. generate ECDH signed pre-key
  4. sign signed-prekey public bytes with ECDSA private key
  5. generate batch OTP prekeys (default 20)
  6. upload bundle to POST /keys/upload
  7. 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:

  1. resolve peer member
  2. fetch peer bundles via /keys/{user_id}/bundles
  3. derive shared secret using X3DH-inspired DH tuple
  4. derive AES session key via HKDF
  5. persist session key locally
  6. publish setup metadata to /keys/channels/{channel_id}/x3dh-setup

Responder path

When decrypting without local session key:

  1. fetch setup metadata from /keys/channels/{channel_id}/x3dh-setup
  2. perform responder-side mirror DH operations
  3. derive same session key
  4. 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:

  1. client generates sender key material
  2. uploads with POST /keys/channels/{channel_id}/sender-keys
  3. 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:

  • content
  • content_type
  • protocol_version
  • sender_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

  1. OTP usage signaling in DM setup could be strengthened with explicit indexed OTP linkage.
  2. Device key attestation and transparency logs are not currently present.
  3. Secure hardware-backed key storage varies by client platform; mobile should use OS keychain/secure enclave equivalents where available.
  4. Forward secrecy guarantees depend on lifecycle policies (rotation cadence and session invalidation discipline).

Mobile Implementation Checklist (Encryption)

  1. Persist all private key material in secure local store.
  2. Rehydrate session and sender-chain state before rendering encrypted channels.
  3. Decrypt sender-key messages in deterministic order.
  4. Handle keys.low_otp by scheduling replenishment.
  5. Keep plaintext cache keyed by message id for own-message replay resilience.