@@ -178,14 +178,24 @@ module Crypto =
178178 let info = Encoding.UTF8.GetBytes(Constants.hkdfInfo)
179179 HKDF.DeriveKey(HashAlgorithmName.SHA256, rawSecret, 32, salt, info)
180180
181− // Blob layout: [ephPub(32) | nonce(24) | ciphertext | senderPub(32) | signature(64)]
181+ // Blob layout v2: [ephPub(32) | nonce(24) | ciphertext]
182+ // encrypted payload: [senderPub(32) | signature(64) | compressed]
183+ // signature signs (recipientPub ++ compressed) — prevents forwarding
184+ // Legacy layout: [ephPub(32) | nonce(24) | ciphertext | senderPub(32) | signature(64)]
182185 let private ephPubLen = 32
183186 let private nonceLen = 24
184187 let private pubLen = 32
185188 let private sigLen = 64
186189 let private headerLen = ephPubLen + nonceLen
187− let private trailerLen = pubLen + sigLen
188− let private minBlobLen = headerLen + 16 + trailerLen // 16 = AEAD tag
190+ let private legacyTrailerLen = pubLen + sigLen
191+ let private minBlobLen = headerLen + pubLen + sigLen + 16 // inner fields + AEAD tag
192+
193+ let private deriveSymKey recipientPrivKey ephPub =
194+ let recipientX25519Priv = ed25519SkToCurve25519 recipientPrivKey
195+ let rawSecret = scalarMult recipientX25519Priv ephPub
196+ let recipientEd25519Pub = recipientPrivKey.[32..63]
197+ let recipientX25519Pub = ed25519PkToCurve25519 recipientEd25519Pub
198+ deriveKey ephPub recipientX25519Pub rawSecret
189199
190200 let encrypt (senderPrivKey: byte[]) (recipientEd25519Pub: byte[]) (plaintext: string) : byte[] option =
191201 if senderPrivKey.Length <> 64 || recipientEd25519Pub.Length <> 32 then
@@ -200,17 +210,12 @@ module Crypto =
200210 let nonce = getRandomBytes 24
201211 let ptBytes = Encoding.UTF8.GetBytes(plaintext)
202212 let compressed = LZ4Pickler.Pickle(ptBytes)
203− let ciphertext = aeadEncrypt compressed nonce key
204− let signature = signDetached ciphertext senderPrivKey
205−
206− Some(
207− Array.concat
208− [| ephPub
209− nonce
210− ciphertext
211− senderPubKey
212− signature |]
213− )
213+ let signedData = Array.append recipientEd25519Pub compressed
214+ let signature = signDetached signedData senderPrivKey
215+ let inner = Array.concat [| senderPubKey; signature; compressed |]
216+ let ciphertext = aeadEncrypt inner nonce key
217+
218+ Some(Array.concat [| ephPub; nonce; ciphertext |])
214219 with ex ->
215220 log $"encrypt error: {ex.Message}"
216221 None
@@ -219,24 +224,52 @@ module Crypto =
219224 if recipientPrivKey.Length <> 64 || blob.Length < minBlobLen then
220225 None
221226 else
222− try
223− let ephPub = blob.[.. ephPubLen - 1]
224− let nonce = blob.[ephPubLen .. headerLen - 1]
225− let ciphertext = blob.[headerLen .. blob.Length - trailerLen - 1]
226− let senderPub = blob.[blob.Length - trailerLen .. blob.Length - sigLen - 1]
227− let signature = blob.[blob.Length - sigLen ..]
227+ let ephPub = blob.[.. ephPubLen - 1]
228+ let nonce = blob.[ephPubLen .. headerLen - 1]
229+
230+ // Try v2 format: entire payload after header is ciphertext
231+ let v2Result =
232+ try
233+ let ciphertext = blob.[headerLen ..]
234+ let key = deriveSymKey recipientPrivKey ephPub
235+ let decrypted = aeadDecrypt ciphertext nonce key
228236
229− if not (verifyDetached signature ciphertext senderPub) then
237+ if decrypted.Length < pubLen + sigLen then
238+ None
239+ else
240+ let senderPub = decrypted.[.. pubLen - 1]
241+ let signature = decrypted.[pubLen .. pubLen + sigLen - 1]
242+ let compressed = decrypted.[pubLen + sigLen ..]
243+
244+ let recipientPub = recipientPrivKey.[32..63]
245+ let signedData = Array.append recipientPub compressed
246+
247+ if verifyDetached signature signedData senderPub then
248+ let plaintext = LZ4Pickler.Unpickle(compressed)
249+ Some(Encoding.UTF8.GetString(plaintext), toHex senderPub)
250+ else
251+ None
252+ with _ -> None
253+
254+ match v2Result with
255+ | Some _ -> v2Result
256+ | None ->
257+ // Fall back to legacy format: trailer has senderPub + signature in plaintext
258+ try
259+ if blob.Length < headerLen + 16 + legacyTrailerLen then
260+ None
261+ else
262+ let ciphertext = blob.[headerLen .. blob.Length - legacyTrailerLen - 1]
263+ let senderPub = blob.[blob.Length - legacyTrailerLen .. blob.Length - sigLen - 1]
264+ let signature = blob.[blob.Length - sigLen ..]
265+
266+ if not (verifyDetached signature ciphertext senderPub) then
267+ None
268+ else
269+ let key = deriveSymKey recipientPrivKey ephPub
270+ let decrypted = aeadDecrypt ciphertext nonce key
271+ let plaintext = LZ4Pickler.Unpickle(decrypted)
272+ Some(Encoding.UTF8.GetString(plaintext), toHex senderPub)
273+ with ex ->
274+ log $"decrypt error: {ex.Message}"
230275 None
231− else
232− let recipientX25519Priv = ed25519SkToCurve25519 recipientPrivKey
233− let rawSecret = scalarMult recipientX25519Priv ephPub
234− let recipientEd25519Pub = recipientPrivKey.[32..63]
235− let recipientX25519Pub = ed25519PkToCurve25519 recipientEd25519Pub
236− let key = deriveKey ephPub recipientX25519Pub rawSecret
237− let decrypted = aeadDecrypt ciphertext nonce key
238− let plaintext = LZ4Pickler.Unpickle(decrypted)
239− Some(Encoding.UTF8.GetString(plaintext), toHex senderPub)
240− with ex ->
241− log $"decrypt error: {ex.Message}"
242− None