DChat cryptographic
specification
Every primitive, parameter, derivation path and wire format used by DChat, written at the level someone would need to reimplement it or attack it. Including the parts we consider weak.
Scope and notation
Covered: identity derivation, direct-message key agreement and encryption, group messaging, key rotation, and at-rest protection on the device. Not covered here: the consensus layer (CometBFT), IBC, and the WebRTC media path, which have their own documentation.
|| denotes concatenation. All multi-byte integers are big-endian. All base64 is standard RFC 4648 with padding. Byte lengths are exact and enforced at runtime.
Identity and account keys
A DChat identity is a secp256k1 key pair. There is no username-password credential anywhere in the system; the key pair is the account.
2.1 Seed
Recovery phrases are BIP-39 mnemonics over the English wordlist, generated at 12 or 24 words. New accounts default to 24 words, giving 256 bits of entropy. Every generated mnemonic is round-tripped through derivation before being shown to the user; a mnemonic that fails to derive is discarded rather than displayed.
2.2 Derivation
master = BIP32-Master(seed)
identity = master / 44' / 118' / 0' / 0 / 0
Coin type 118 is the registered Cosmos SLIP-44 index. The curve is secp256k1. Private keys are 32 bytes; public keys are stored and transmitted in 33-byte compressed form.
2.3 Address
The account address is the standard Cosmos account identifier derived from the compressed public key, bech32-encoded with the human-readable prefix dc. Group identifiers share the dc prefix and are validated against it before any key operation.
2.4 Signatures
Signing is ECDSA over secp256k1, deterministic per RFC 6979. The message is hashed with SHA-256 before signing. Signatures are emitted as a raw 64-byte R || S pair rather than DER, matching Cosmos ADR-036 for offline signature verification. Every signature is verified against its own public key immediately after generation, and generation fails closed if that check does not pass. Verification accepts both the 64-byte raw form and 70 to 72-byte DER, converting internally.
Direct messages
Direct messages use static-static ECDH between the sender's chat private key and the recipient's chat public key. This is the ecdh encryption mode on the wire.
3.1 Key agreement
shared = X(P) // 32-byte x-coordinate of the shared point
The shared secret is the raw x-coordinate, 32 bytes, with the point-format prefix stripped. It is never used as a key directly; it is only ever input keying material for HKDF.
3.2 Key derivation
info = "dchat-message-encryption-v1"
K = HKDF-SHA256(ikm = shared, salt, info, L = 32)
Other context strings are used for other purposes, each isolating its own key: dchat-config-v1 dchat-sync-v1 dchat-pairing-ecies-v1 dchat-ipns-v1 dchat-notes-media-v1 and dchat-acct-cfg-secret-v1. A key derived for one context cannot decrypt another context's data.
3.3 Encryption
ct, tag = ChaCha20-Poly1305-Encrypt(K, nonce, plaintext)
Key 256 bits, nonce 96 bits, authentication tag 128 bits. A fresh nonce is drawn from the platform CSPRNG for every message; nonces are never derived from a counter, so there is no cross-device counter state to desynchronise. The implementation is the managed BouncyCastle ChaCha20Poly1305 chosen because it behaves identically on Android and in WebAssembly.
3.4 Wire envelope
"ciphertext": "<base64>",
"iv": "<base64, 12 bytes>",
"authTag": "<base64, 16 bytes>",
"senderPublicKey": "<hex, 33 bytes compressed>",
"keyGeneration": <int>,
"chat_id": "<string>",
"encryption_mode": "ecdh" | "sender_key" | "ecdh_bootstrap" | "none",
"sender_did": "<string>"
}
K_outer = HKDF(ECDH(e_sk, pk_recipient_chat)), and encrypts the envelope under it with ChaCha20-Poly1305. What travels is the ephemeral public key, the outer ciphertext and a routing tag. sender_did senderPublicKey chat_id keyGeneration and encryption_mode are all inside the sealed layer, so a relay cannot read them and cannot link two messages from the same sender. Only the recipient can open it. See 3.5.3.5 Sealed sender
The envelope in 3.4 is the inner structure. It never travels in that form. The sending client wraps it in a second, outer layer whose key is derived from a key pair that exists for exactly one message:
K_outer = HKDF-SHA256(ECDH(e_sk, pk_recipient_chat))
outer = ChaCha20-Poly1305(K_outer, envelope)
wire = { e_pk, outer, routing_tag }
Because e_sk is generated fresh for every message and destroyed after use, two messages from the same sender to the same recipient produce unrelated outer keys and unrelated ciphertext. There is no stable sender-derived value on the wire to correlate. A relay holds e_pk, which is random-looking and never repeats, and outer, for which it has no key. Only the holder of the recipient chat private key can complete the ECDH, recover K_outer and open the envelope.
This is what makes the transport different from a network that stores messages by recipient address: DChat's flood-based delivery never needed to know the sender in order to route, so removing the sender from the clear costs nothing operationally. A design that assigns storage by recipient identity has to read the destination to route at all.
What sealed sender does not hide is listed in 8.1.
Group messages
Groups use the N-sender-keys model: every member holds their own symmetric sending key for the group, rather than the group sharing one key. This is the sender_key encryption mode.
Generation
A sender key is 32 bytes drawn directly from the platform CSPRNG. It is not derived from the identity key, so a sender key discloses nothing about the account that owns it.
Distribution
Each member's sender key is delivered to every other member over the pairwise ECDH channel from section 3. The group therefore inherits the confidentiality of the direct-message path, and no server ever handles a sender key in the clear.
Use
A message to the group is encrypted once under the sender's own sender key with ChaCha20-Poly1305 and the same parameters as section 3.3. Recipients select the decryption key by the sender DID and key generation carried in the envelope.
Membership change
When a member is removed or leaves, every remaining member's sender key is rotated and redistributed to the reduced membership, so the departed member cannot decrypt what follows. Historical keys are kept locally, so messages already received stay readable.
This is enforced by the client, not by the SDK. The SDK exposes the rotation call; the DChat app invokes it on removal and on leave. A third-party client built on the SDK gets no post-removal rotation unless it makes that call itself. Rotation is also best-effort per member: a failure for one member is logged and the loop continues, so a partial rotation is possible.
Key rotation
Chat keys and sender keys carry a generation number and rotate when a policy threshold is crossed. Rotation triggers when any threshold is exceeded, not all.
| Policy | Messages | Age |
|---|---|---|
| Default | 10,000 | 30 days |
| Conservative | 5,000 | 14 days |
| Relaxed | 20,000 | 60 days |
Emergency rotation can be triggered manually at any time, independent of thresholds, for a key believed to be compromised. The keyGeneration field in the envelope lets a recipient select the right historical key, so rotation never renders already-received history unreadable.
The message counter is persisted every 50 messages rather than on every send. If the process is killed between boundaries the counter can lose at most 49 increments, delaying a rotation by at most 0.5% of the threshold. The age-based cap is unaffected by this.
Data at rest
Everything sensitive stored on the device is wrapped under a device master key held in platform secure storage, which on Android means a key backed by the Android Keystore.
K_at_rest = PBKDF2-HMAC-SHA256(device_key, salt, iterations, 32)
blob = nonce(12) || tag(16) || ciphertext
PBKDF2 runs at 250,000 iterations on native platforms. In WebAssembly it runs at 10,000 because WASM is single-threaded and 250,000 iterations block the event loop for two to three seconds. The reduction is defensible only because the input is not a human password but a 256-bit random device key, so the KDF is not doing brute-force resistance work. It is still a deliberate weakening on that platform and is listed in 8.4.
Current blobs use the nonce || tag || ciphertext layout. A legacy nonce || ciphertext || tag layout is still accepted on read, for data written by older builds. The device master key is stored twice, primary and backup, because losing it orphans every blob it wraps; the backup exists to heal the primary rather than mint a replacement.
What reaches the chain
The blockchain carries identity, not conversation. Message ciphertext is never written to it.
| Data | Location | Public |
|---|---|---|
| Message content | Participant devices only, in a SQLCipher-encrypted database | No |
| Account address | On-chain | Yes |
| Username, required at account creation | On-chain | Yes |
| Display name and avatar | On-chain. Setting one is optional | Yes, if set |
| Group membership and roles | Member devices only, asserted by signed control messages | No |
| Media | IPFS, encrypted before upload | Hash public, content not |
| Private key and recovery phrase | Device only | Never |
Known weaknesses
These are properties of the current implementation that a reviewer would find. Publishing them is cheaper than having them discovered and reported as concealment.
8.1 What sealed sender does not hide
Sealed sender (3.5) removes the sender identity and chat id from the wire, so a relay cannot read them and cannot link two messages to a common sender. Three things remain visible, and it is worth being precise about them rather than claiming more than the construction delivers.
Your IP address is visible to the node you connect to. That is true of any network you send packets over, and it is not addressed by encrypting the envelope. Unlinkable routing tags and optional private routing are the roadmap items that narrow this further.
Message size and timing are not obscured. DChat does not pad messages to a fixed length, generate cover traffic, or delay delivery. An observer positioned to watch traffic can see approximate sizes and when they were sent, without learning who sent them or what they contain.
The recipient learns the sender, by design. The person you are talking to can open the envelope and see who wrote it. Sealed sender protects the sender from the network, not from the correspondent.
8.2 The HKDF salt is a fixed constant
The salt is SHA-256("dchat-salt-v1") identical for every user and every message. RFC 5869 permits a constant or absent salt, and security still rests on the ECDH secret, so this is not an exploitable flaw. It does mean the derivation gains no domain separation from the salt, and separation is carried entirely by the info string.
8.3 No Double Ratchet
Key agreement is static-static ECDH. There is no per-message ratchet, no ephemeral prekeys and therefore no post-compromise self-healing. Forward secrecy is coarse-grained and comes only from the rotation policy in section 5: an attacker who obtains a chat key reads that generation's window of up to 10,000 messages or 30 days, not a single message. This is the largest gap between DChat and Signal, and closing it is a protocol change, not a parameter change.
8.4 Reduced PBKDF2 work factor on WebAssembly
10,000 iterations rather than 250,000 in the browser client, for the reasons in section 6. Acceptable given a high-entropy input, but it is a platform-dependent security parameter and reviewers should know it exists.
8.5 No independent audit
Nothing in this document has been reviewed by an external cryptographer or security firm. The primitives are standard and used in their intended modes, but "standard primitives" is not the same as "correctly composed", and only a review establishes the difference.
8.6 No traffic analysis resistance
No padding to fixed sizes, no cover traffic, no delay mixing. Message timing and approximate length are observable to anyone watching the network.
8.7 Post-removal group rotation is a client responsibility
As described in section 4, rotating sender keys after a member is removed is performed by the DChat client, not enforced inside the SDK. Two consequences follow. A third-party client that omits the call leaves departed members able to decrypt subsequent group traffic until the routine policy rotation fires. And because rotation iterates members individually and tolerates per-member failure, an interrupted rotation can leave some members on a new generation and others on the old one.
Versioning
This is specification version 1.0, published 21 July 2026, describing the protocol as implemented at that date. Context strings carry explicit versions (-v1 suffixes) so a future revision can change derivation without breaking deployed clients. Material changes to this document will be listed here with dates.
Found a flaw in this?
That is the point of publishing it. Reports are read by a person and credited in the fix.