Cryptography
- Primitives
- Identity derivation
- Message encryption
- Receive-side verification
- Forward secrecy (X3DH-style prekeys)
- Reed-Solomon layers
Primitives
| Purpose | Primitive | Library |
|---|---|---|
| Key agreement | X25519 ECDH | cryptography.hazmat.primitives.asymmetric.x25519 |
| Signatures | Ed25519 | cryptography.hazmat.primitives.asymmetric.ed25519 |
| AEAD | ChaCha20-Poly1305 | cryptography.hazmat.primitives.ciphers.aead |
| KDF (session) | HKDF-SHA256 | cryptography.hazmat.primitives.kdf.hkdf |
| KDF (passphrase) | Argon2id | argon2-cffi |
| Erasure coding | Reed-Solomon k-of-n |
zfec |
| Per-chunk ECC | Reed-Solomon 32-symbol | reedsolo |
Identity derivation
A passphrase + 32-byte per-identity salt goes through Argon2id
with t=2, m=32 MiB, p=2, length=32 to produce the X25519 private
key bytes. Argon2’s memory-hardness means offline brute force is
dramatically more expensive than PBKDF2 at any realistic iteration
count.
The Ed25519 signing key is derived from the X25519 private key bytes via:
ed25519_seed = SHA-256(x25519_priv || b"DMP-v1-Ed25519-signing-key")
Both keys are therefore reconstructible from passphrase + salt, and
the CLI stores the salt in config.yaml so a lost config is
unrecoverable even with the passphrase.
Message encryption
One encrypt per message:
- Sender generates an ephemeral X25519 keypair.
- Sender does
ECDH(ephemeral_sk, recipient_pubkey), whererecipient_pubkeyis either a one-time prekey (FS path) or recipient’s long-term key (fallback). HKDF-SHA256(shared_secret, salt=b"DMP-v1", info=b"DMP-Message-Encryption", length=32)→ AEAD key.ChaCha20-Poly1305.encrypt(nonce=random 12 bytes, plaintext, associated_data=AAD)→ ciphertext.
The EncryptedMessage wire struct carries ephemeral_pub || nonce ||
ciphertext.
AAD binding
AAD is the canonical DMPHeader subset (version, message_type,
message_id, sender_id, recipient_id, timestamp, ttl) with
total_chunks = chunk_number = 0 as sentinels, followed by the
4-byte prekey_id used.
Any mutation of:
- a bound header field,
- or the claimed
prekey_id,
makes AEAD verification fail on receive.
Receive-side verification
- Fetch + parse the slot manifest. Verify Ed25519 signature against
embedded
sender_spk. - Enforce
total_chunks ≤ MAX_TOTAL_CHUNKS = 1024. - Verify
recipient_id == self.user_id. - Check
manifest.expfreshness. - Pinned-contact check:
manifest.sender_spkmust be a signing key in our contact list (TOFU fallback if no contacts are pinned at all). - Replay cache lookup:
(sender_spk, msg_id)must not be previously recorded. - Fetch chunks, unwrap per-chunk RS, collect
kvalid shares. zfec.decode(shares, k, n)→ plaintext DMPMessage bytes.- Parse the
DMPMessage. Enforce:outer.header.message_id == manifest.msg_idouter.header.recipient_id == manifest.recipient_idouter.header.is_expired() == False
- Look up
prekey_skbymanifest.prekey_idif ≠ 0; else use long-term X25519 key. ECDH+ HKDF + ChaCha20-Poly1305.decrypt with the same AAD the sender bound.- On success: record
(sender_spk, msg_id)in the replay cache, consume the prekey_sk (delete locally + from DNS).
Forward secrecy (X3DH-style prekeys)
See the user guide for the flow and tradeoffs. The one-sentence version: recipient publishes a pool of signed one-time X25519 prekeys, sender uses one per message, recipient deletes the matching sk on decrypt — so a later leak of either party’s long-term key can’t decrypt that message.
Reed-Solomon layers
Two independent layers, easy to confuse:
- Per-chunk RS (32 parity bytes over each data block). Repairs bit-errors inside a received chunk. Applied to the erasure share, not the plaintext.
- Cross-chunk erasure (k-of-n via zfec). Split length-prefixed
plaintext into k data blocks, compute
n-kparity blocks. Any k received reconstructs. Loss tolerance per message:n-kchunks.
The two compose: bit-errors inside a survived chunk are repaired at layer 1 before the share reaches layer 2.