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>"
}
ciphertext is confidential. sender_did, senderPublicKey, chat_id, keyGeneration and encryption_mode travel in the clear and are visible to any relay. See 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 | No |
| Account address | On-chain | Yes |
| Username, if registered | On-chain | Yes |
| Display name and avatar | On-chain, optional | Yes, if set |
| Group membership | On-chain | Encrypted list |
| 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 Envelope metadata is cleartext
The sender DID, sender public key and chat id are unencrypted in the wire envelope. A relay can build a social graph of who communicates with whom and when, even though it cannot read a single message. This is pseudonymous rather than anonymous, and describing it as "relays cannot tell who you are" would be too strong: they cannot tell who you are in the real world, but they can link your messages to each other.
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.