Skip to content

Commit 9e3b47a

Browse files
code refs, additional specs
1 parent 326d6cc commit 9e3b47a

5 files changed

Lines changed: 75 additions & 5 deletions

File tree

spec/modules/README.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,21 @@ Things that would surprise a competent Haskell developer reading the code for th
5252
- Alternatives considered and rejected
5353
- Known limitations and their justification
5454

55+
## Non-obvious threshold
56+
57+
The guiding principle: **non-obvious state machines and flows require documentation; standard things don't.**
58+
59+
Document:
60+
- Multi-step protocols and negotiation flows (e.g., KEM propose/accept round-trips)
61+
- Monotonic or irreversible state transitions (e.g., PQ support can only be enabled, never disabled)
62+
- Silent error behaviors (e.g., `verify` returns `False` on algorithm mismatch instead of an error)
63+
- Design rationale for non-standard choices (e.g., why byte-reverse a nonce, why hash-then-encrypt for authenticators)
64+
65+
Do NOT document:
66+
- Standard algorithm properties (e.g., Ed25519 public key derivable from private key)
67+
- Well-known protocol mechanics (e.g., HKDF usage per RFC 5869, deterministic nonce derivation in double ratchet)
68+
- Implementation details that follow directly from the type signatures
69+
5570
## What NOT to include
5671

5772
- **Type signatures** — the code has them
@@ -107,6 +122,14 @@ This is valuable — it confirms someone looked and found nothing to document.
107122

108123
## Linking conventions
109124

125+
### Module doc → protocol docs
126+
When a module implements or is governed by a protocol specification in `protocol/`, link to it near the top of the module doc (after the overview). Do not duplicate protocol content — just reference it:
127+
```markdown
128+
**Protocol spec**: [`protocol/pqdr.md`](../../../../protocol/pqdr.md) — Post-quantum resistant augmented double ratchet algorithm.
129+
```
130+
131+
This is especially important for modules in transport, protocol, client, server, and agent layers where behavior is defined by the protocol spec rather than being self-evident from the code.
132+
110133
### Module doc → other module docs
111134
Use fully qualified names as link text:
112135
```markdown

spec/modules/Simplex/Messaging/Crypto.md

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -55,10 +55,6 @@ Both apply `pad`/`unPad` by default. The `NoPad` variants skip padding.
5555

5656
The XSalsa20 implementation splits the 24-byte nonce into two 8-byte halves. The first half initializes the cipher state (prepended with 16 zero bytes), the second derives a subkey. The first 32 bytes of output become the Poly1305 one-time key (`rs`), then the rest encrypts the message. This is the standard NaCl construction.
5757

58-
## CbAuthenticator
59-
60-
An authentication scheme that encrypts the SHA-512 hash of the message using crypto_box, rather than the message itself. The result is 80 bytes (64 hash + 16 auth tag). Used for authenticating messages where the content is transmitted separately from the authentication proof.
61-
6258
## Secret box chains (sbcInit / sbcHkdf)
6359

6460
HKDF-based key chains for deriving sequential key+nonce pairs:
@@ -77,6 +73,22 @@ All keys are encoded as ASN.1 DER (X.509 SubjectPublicKeyInfo for public, PKCS#8
7773

7874
`GCMIV` constructor is not exported — only `gcmIV :: ByteString -> Either CryptoError GCMIV` is available, which validates that the input is exactly 12 bytes. This prevents construction of invalid IVs.
7975

76+
## verify silently returns False on algorithm mismatch
77+
78+
`verify :: APublicVerifyKey -> ASignature -> ByteString -> Bool` uses `testEquality` on the algorithm singletons. If the key is Ed25519 but the signature is Ed448 (or vice versa), `testEquality` fails and `verify` returns `False` — no error, no indication of a type mismatch. A correctly-formed signature can "fail" simply because the wrong algorithm key was passed.
79+
80+
## dh' returns raw DH output — no key derivation
81+
82+
`dh'` returns the raw X25519/X448 shared point with no hashing or HKDF. Callers must apply their own KDF: [SNTRUP761](./Crypto/SNTRUP761.md) hashes with SHA3-256, the [ratchet](./Crypto/Ratchet.md#kdf-functions) uses HKDF-SHA512. Not all DH libraries behave this way — some hash the output automatically.
83+
84+
## reverseNonce
85+
86+
`reverseNonce` creates a "reply" nonce by byte-reversing the original 24-byte nonce. Used for bidirectional communication where both sides need distinct nonces derived from the same starting value. The two nonces are guaranteed distinct unless the original is a byte palindrome, which is astronomically unlikely for random 24-byte values.
87+
88+
## CbAuthenticator
89+
90+
An authentication scheme that encrypts the SHA-512 hash of the message using crypto_box, rather than the message itself. The result is 80 bytes (64 hash + 16 auth tag). This is the djb-recommended authenticator scheme: it proves knowledge of the shared secret and the message content, without requiring the message to fit in a single crypto_box, and without revealing message content even to someone who compromises the shared key after verification.
91+
8092
## generateKeyPair is STM
8193

8294
Key generation uses `TVar ChaChaDRG` and runs in `STM`, not `IO`. This allows key generation inside `atomically` blocks, which is used extensively in handshake and ratchet initialization code.

spec/modules/Simplex/Messaging/Crypto/Ratchet.md

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ Implements the Signal double ratchet protocol extended with:
1212

1313
The ratchet uses X448 (not X25519) for DH operations — `type RatchetX448 = Ratchet 'X448`.
1414

15+
**Protocol spec**: [`protocol/pqdr.md`](../../../../protocol/pqdr.md) — Post-quantum resistant augmented double ratchet algorithm.
16+
1517
## PQ X3DH key agreement
1618

1719
`pqX3dhSnd` / `pqX3dhRcv` perform the extended X3DH:
@@ -46,9 +48,13 @@ Each message header carries `msgMaxVersion` (the sender's max supported ratchet
4648

4749
`largeP` detects the length-prefix format by peeking at the first byte: if < 32, it's a 2-byte `Large` prefix (new format); otherwise it's a 1-byte prefix (old format). This allows upgrading the header encoding format in a single message without a version bump.
4850

51+
## maxSkip = 512 — DoS protection
52+
53+
`maxSkip` is a hardcoded constant (not configurable). Messages claiming to be more than 512 positions ahead of the current counter are rejected with `CERatchetTooManySkipped`. This prevents an attacker from forcing the receiver to compute and store an unbounded number of skipped message keys.
54+
4955
## Skipped message keys
5056

51-
When messages arrive out of order, the ratchet computes and stores the message keys for skipped messages (up to `maxSkip = 512`). Skipped keys are stored in a `Map HeaderKey (Map Word32 MessageKey)` — keyed first by header key, then by message number.
57+
When messages arrive out of order, the ratchet computes and stores the message keys for skipped messages (up to `maxSkip`). Skipped keys are stored in a `Map HeaderKey (Map Word32 MessageKey)` — keyed first by header key, then by message number.
5258

5359
The `SkippedMsgDiff` type represents changes to the skipped key store as a diff rather than a full replacement — this is persisted to the database, and the full state is loaded for the next message. `applySMDiff` is only used in tests.
5460

@@ -61,6 +67,14 @@ Decryption tries three strategies in order:
6167

6268
If strategy 1 decrypts the header but the message number isn't in skipped keys, it checks whether this header key corresponds to the current or next ratchet to decide whether to advance.
6369

70+
### decryptSkipped — linear scan through all stored header keys
71+
72+
`decryptSkipped` iterates through ALL `(HeaderKey, SkippedHdrMsgKeys)` pairs, attempting header decryption with each key. When header decryption succeeds but the message number is NOT in the skipped keys for that header, the result is `SMHeader` — which includes whether the key matches the current ratchet (`rcHKr``SameRatchet`) or the next ratchet (`rcNHKr``AdvanceRatchet`). This falls through to normal decryption processing rather than producing an error.
73+
74+
### decryptMessage — ratchet advances even on failure
75+
76+
`decryptMessage` returns `Either CryptoError ByteString` inside the `ExceptT` monad — a message decryption failure does NOT abort the ratchet state update. The ratchet counter advances (`rcNr + 1`) and chain key updates (`rcCKr'`) regardless of whether the message body decrypts successfully. This preserves ratchet state consistency for retransmission and error recovery.
77+
6478
## rcEncryptHeader — separated from rcEncryptMsg
6579

6680
Encryption is split into two steps: `rcEncryptHeader` produces a `MsgEncryptKey` (containing the encrypted header and message key), then `rcEncryptMsg` uses that key to encrypt the message body. This separation allows the ratchet state to be updated (persisted) before the message is encrypted, which is important for crash recovery — if the process crashes after encrypting but before sending, the ratchet state must already reflect the advanced counter.
@@ -80,6 +94,19 @@ Two distinct newtypes with identical structure (`Bool` wrapper):
8094
- `PQSupport`: whether PQ **can** be used (determines header padding size, cannot be disabled once enabled)
8195
- `PQEncryption`: whether PQ **is** being used for the current send/receive ratchet
8296

97+
### pqEnableSupport is monotonic
98+
99+
`pqEnableSupport v sup enc = PQSupport $ sup || (v >= pqRatchetE2EEncryptVersion && enc)`. The `||` means once PQ support is `True`, it stays `True` regardless of subsequent messages. PQ encryption (usage) can be toggled per-message; PQ support (capability / header size) only ratchets up. This prevents the larger header format from being downgraded once negotiated.
100+
101+
## replyKEM_ — two-step KEM negotiation
102+
103+
KEM establishment requires two message round-trips, as described in the [PQDR KEM state machine](../../../../protocol/pqdr.md#kem-state-machine):
104+
105+
1. **Propose**: if the sender has no KEM in their header but the replier supports PQ at sufficient version, the replier includes a KEM proposal (`RKParamsProposed` — their encapsulation public key)
106+
2. **Accept**: if the sender proposed KEM, the replier accepts by encapsulating against the proposed key and including the ciphertext + their own new encapsulation key (`RKParamsAccepted`)
107+
108+
After acceptance, both sides have a shared KEM secret that is folded into the root KDF. Subsequent ratchet steps continue the KEM exchange with fresh keypairs on each side.
109+
83110
## Error semantics
84111

85112
- `CERatchetEarlierMessage n`: message number is `n` positions before the next expected (already processed or skipped-and-consumed)

src/Simplex/Messaging/Crypto.hs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1285,11 +1285,13 @@ verify' (PublicKeyEd25519 k) (SignatureEd25519 sig) msg = Ed25519.verify k msg s
12851285
verify' (PublicKeyEd448 k) (SignatureEd448 sig) msg = Ed448.verify k msg sig
12861286
{-# INLINE verify' #-}
12871287

1288+
-- spec: spec/modules/Simplex/Messaging/Crypto.md#verify-silently-returns-false-on-algorithm-mismatch
12881289
verify :: APublicVerifyKey -> ASignature -> ByteString -> Bool
12891290
verify (APublicVerifyKey a k) (ASignature a' sig) msg = case testEquality a a' of
12901291
Just Refl -> verify' k sig msg
12911292
_ -> False
12921293

1294+
-- spec: spec/modules/Simplex/Messaging/Crypto.md#dh-returns-raw-dh-output--no-key-derivation
12931295
dh' :: DhAlgorithm a => PublicKey a -> PrivateKey a -> DhSecret a
12941296
dh' (PublicKeyX25519 k) (PrivateKeyX25519 pk) = DhSecretX25519 $ X25519.dh k pk
12951297
dh' (PublicKeyX448 k) (PrivateKeyX448 pk) = DhSecretX448 $ X448.dh k pk
@@ -1418,6 +1420,7 @@ randomCbNonce = fmap CryptoBoxNonce . randomBytes 24
14181420
randomBytes :: Int -> TVar ChaChaDRG -> STM ByteString
14191421
randomBytes n gVar = stateTVar gVar $ randomBytesGenerate n
14201422

1423+
-- spec: spec/modules/Simplex/Messaging/Crypto.md#reversenonce
14211424
reverseNonce :: CbNonce -> CbNonce
14221425
reverseNonce (CryptoBoxNonce s) = CryptoBoxNonce (B.reverse s)
14231426

src/Simplex/Messaging/Crypto/Ratchet.hs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -840,9 +840,11 @@ pqEncToSupport (PQEncryption pq) = PQSupport pq
840840
pqSupportAnd :: PQSupport -> PQSupport -> PQSupport
841841
pqSupportAnd (PQSupport s1) (PQSupport s2) = PQSupport $ s1 && s2
842842

843+
-- spec: spec/modules/Simplex/Messaging/Crypto/Ratchet.md#pqenablesupport-is-monotonic
843844
pqEnableSupport :: VersionE2E -> PQSupport -> PQEncryption -> PQSupport
844845
pqEnableSupport v (PQSupport sup) (PQEncryption enc) = PQSupport $ sup || (v >= pqRatchetE2EEncryptVersion && enc)
845846

847+
-- spec: spec/modules/Simplex/Messaging/Crypto/Ratchet.md#replykem_--two-step-kem-negotiation
846848
replyKEM_ :: VersionE2E -> Maybe (RKEMParams 'RKSProposed) -> PQSupport -> Maybe AUseKEM
847849
replyKEM_ v kem_ = \case
848850
PQSupportOn | v >= pqRatchetE2EEncryptVersion -> Just $ case kem_ of
@@ -994,6 +996,7 @@ data RatchetStep = AdvanceRatchet | SameRatchet
994996

995997
type DecryptResult a = (Either CryptoError ByteString, Ratchet a, SkippedMsgDiff)
996998

999+
-- spec: spec/modules/Simplex/Messaging/Crypto/Ratchet.md#maxskip--512--dos-protection
9971000
maxSkip :: Word32
9981001
maxSkip = 512
9991002

@@ -1131,6 +1134,7 @@ rcDecrypt g rc@Ratchet {rcRcv, rcAD = Str rcAD, rcVersion} rcMKSkipped msg' = do
11311134
let (ck', mk, iv, _) = chainKdf ck
11321135
mks' = M.insert msgNs (MessageKey mk iv) mks
11331136
in advanceRcvRatchet (n - 1) ck' (msgNs + 1) mks'
1137+
-- spec: spec/modules/Simplex/Messaging/Crypto/Ratchet.md#decryptskipped--linear-scan-through-all-stored-header-keys
11341138
decryptSkipped :: EncMessageHeader -> EncRatchetMessage -> ExceptT CryptoError IO (SkippedMessage a)
11351139
decryptSkipped encHdr encMsg = tryDecryptSkipped SMNone $ M.assocs rcMKSkipped
11361140
where
@@ -1163,6 +1167,7 @@ rcDecrypt g rc@Ratchet {rcRcv, rcAD = Str rcAD, rcVersion} rcMKSkipped msg' = do
11631167
decryptHeader k EncMessageHeader {ehVersion, ehBody, ehAuthTag, ehIV} = do
11641168
header <- decryptAEAD k ehIV rcAD ehBody ehAuthTag `catchE` \_ -> throwE CERatchetHeader
11651169
parseE' CryptoHeaderError (msgHeaderP ehVersion) header
1170+
-- spec: spec/modules/Simplex/Messaging/Crypto/Ratchet.md#decryptmessage--ratchet-advances-even-on-failure
11661171
decryptMessage :: MessageKey -> EncRatchetMessage -> ExceptT CryptoError IO (Either CryptoError ByteString)
11671172
decryptMessage (MessageKey mk iv) EncRatchetMessage {emHeader, emBody, emAuthTag} =
11681173
-- DECRYPT(mk, cipher-text, CONCAT(AD, enc_header))

0 commit comments

Comments
 (0)