Repository navigation
v2.7.0
Security and protocol-correctness release. Fixes two key-handling defects that could zero or
corrupt live private keys, three Double Ratchet deviations from the Signal specification, and a
password-KDF weakness. All 546 unit tests pass on net8.0 and net10.0.
Added
- Double Ratchet
PNheader field:EncryptedMessage.PreviousChainLengthand
DoubleRatchetSession.PreviousSendChainLengthimplement the Signal header's previous-chain-length
field, which is required to recover message keys left unclaimed in a receiving chain when a DH
ratchet step replaces it. Absent on messages from older versions, where it defaults to0. - Explicit key-format conversion:
KeyConversion.ConvertPublicKeyToX25519(byte[], bool inputIsEd25519)
converts a public key using a caller-declared format instead of guessing. Prefer it over the
format-sniffingConvertEd25519PublicKeyToX25519(byte[]).
Fixed
- Ed25519/X25519 key type confusion (critical):
KeyConversion.ConvertEd25519PublicKeyToX25519()
identified a key's format by trying Ed25519 validation first. Ed25519 and X25519 public keys are
both 32 opaque bytes, and roughly 12.4% of X25519 public keys (measured 2 486 / 20 000) also
decode as valid Ed25519 points, so that fraction of already-X25519 keys was silently converted into
a different key.DeviceManager.ProcessSyncMessage()now matches a sender hint against the stored
key verbatim before attempting any conversion, making device lookup deterministic. This was the
cause of intermittentMultiDeviceSynchronization_ShouldRecoverFromMessageLossfailures. - Private key destroyed by its own callers (critical):
CryptoProvider.ConvertEd25519PrivateKeyToX25519()
returned the caller's array unchanged when given a 32-byte key. All four call sites clear the
returned buffer in afinallyblock, so the call zeroed the live device/identity private key. It
now returns a copy.MailboxManagerlikewise no longer aliases_identityKeyPair.PrivateKey. - Unilateral ratchet rotation broke sessions:
KeyRotationStrategy.Standardrotated the sender's
DH ratchet key every 20 messages regardless of whether the peer had received it. A DH ratchet step
is only valid as a response to a new peer ratchet key; when both parties rotated before receiving
the other's key, their DH inputs diverged and the session became permanently unrecoverable.
Rotation is now driven solely byDecrypt()on receipt of a newSenderDHKey. - Out-of-order messages lost across ratchet steps:
SkipReceiverMessageKeysAsync()was a no-op.
It is replaced bySkipReceiverMessageKeys(), which uses the newPNfield to derive and retain
the unclaimed keys of the outgoing chain before it is discarded. - Sent message keys retained (forward secrecy):
Encrypt()stored up to 100 used message keys in
DoubleRatchetSession.SentMessages, which is serialized to disk. Anyone obtaining a persisted
session could decrypt previously sent traffic. Message keys are no longer retained after use. - Fixed application-wide Argon2id salt:
StoreKeyAsync()/RetrieveKeyAsync()derived their
encryption key with a hardcoded salt, so every user sharing a password shared an encryption key and
a single precomputation table applied to all installations. Each stored key now carries a fresh
random 16-byte salt. Breaking: the stored blob layout changed fromnonce‖ciphertextto
salt‖nonce‖ciphertext; password-protected keys written by earlier versions cannot be read. - X25519 small-order point blocklist was inert: one of the three entries in
Sodium.SMALL_ORDER_POINTSwas a hex string passed toConvert.FromBase64String(), decoding to 48
bytes and therefore never matching a 32-byte key. Replaced with the full 12-entry libsodium
blacklist, hex-decoded, including the non-canonical high-bit-set encodings. - Session clone shared key material:
DeepCloneSession()copied every byte array except
RootKeyand the ratchet key pair, which were shared by reference — so clearing the clone destroyed
the caller's session state. All arrays are now copied. - Superseded keys left in memory: retired root keys, chain keys, ratchet private keys, evicted
skipped-message keys, and decrypted plaintext buffers are now zeroed viaSecureMemory.SecureClear()
on both success and failure paths. - Skipped-key eviction order: the skipped-message-key cache evicted via
Dictionary.Keys.Take(),
whose enumeration order is unspecified and unrelated to age. Eviction is now ordered by message
number, and every evicted key is zeroed. - Skipped-key consumption on failure: a skipped message key was removed from the cache before
decryption was attempted, discarding it if decryption failed. It is now consumed only on success. Nonce.GenerateNonce(ReadOnlySpan<byte>, uint)always threw: the method allocated a
Constants.NONCE_SIZE(12-byte) buffer and then sliced[..16], guaranteeing
ArgumentOutOfRangeException. The salt buffer is now sized independently of the nonce.- Redundant key copies in
AES:Decrypt()calledkey.ToArray()twice — once purely as a null
check on a freshly allocated array, which can never be null — leaving both copies of the key on the
heap unzeroed. BothEncrypt()andDecrypt()now use a single copy, cleared in afinally.