Add project files.

This commit is contained in:
micro
2026-06-20 09:31:59 -05:00
parent 9bb3f00d86
commit 4baa4ce8c0
346 changed files with 55751 additions and 0 deletions
+46
View File
@@ -0,0 +1,46 @@
using System.Text;
using Wingnal.Protocol.Crypto;
namespace Wingnal.Protocol.Ratchet;
/// <summary>
/// A symmetric-ratchet chain key. Each step is HMAC-SHA256(chainKey, 0x02); message keys are
/// derived via HMAC-SHA256(chainKey, 0x01) then HKDF "WhisperMessageKeys".
/// </summary>
public sealed class ChainKey
{
private static readonly byte[] MessageKeySeed = { 0x01 };
private static readonly byte[] ChainKeySeed = { 0x02 };
private static readonly byte[] MessageKeysInfo = Encoding.UTF8.GetBytes("WhisperMessageKeys");
public byte[] Key { get; }
public uint Index { get; }
public ChainKey(byte[] key, uint index)
{
Key = key;
Index = index;
}
public ChainKey Next() => new(CryptoPrimitives.HmacSha256(Key, ChainKeySeed), Index + 1);
/// <summary>The per-message key seed (HMAC(chainKey, 0x01)); the input keying material for
/// <see cref="DeriveMessageKeys"/>. Cached for skipped messages so the SPQR salt can be applied
/// lazily when the out-of-order message actually arrives.</summary>
public byte[] MessageKeySeedBytes => CryptoPrimitives.HmacSha256(Key, MessageKeySeed);
/// <summary>Derives the AES/HMAC/IV message keys from a seed. <paramref name="pqrSalt"/> is the
/// SPQR per-message key used as the HKDF salt (null for classic sessions). Matches libsignal
/// <c>MessageKeys::derive_keys(seed, salt=pqr_key, "WhisperMessageKeys")</c>.</summary>
public static MessageKeys DeriveMessageKeys(byte[] seed, byte[]? pqrSalt, uint counter)
{
byte[] material = CryptoPrimitives.Hkdf(seed, salt: pqrSalt, MessageKeysInfo, 80);
var cipherKey = material.AsSpan(0, 32).ToArray();
var macKey = material.AsSpan(32, 32).ToArray();
var iv = material.AsSpan(64, 16).ToArray();
return new MessageKeys(cipherKey, macKey, iv, counter);
}
public MessageKeys GetMessageKeys(byte[]? pqrSalt = null) =>
DeriveMessageKeys(MessageKeySeedBytes, pqrSalt, Index);
}
+18
View File
@@ -0,0 +1,18 @@
namespace Wingnal.Protocol.Ratchet;
/// <summary>The per-message keys derived from a chain key: AES-256 key, HMAC-SHA256 key, and IV.</summary>
public sealed class MessageKeys
{
public byte[] CipherKey { get; } // 32
public byte[] MacKey { get; } // 32
public byte[] Iv { get; } // 16
public uint Counter { get; }
public MessageKeys(byte[] cipherKey, byte[] macKey, byte[] iv, uint counter)
{
CipherKey = cipherKey;
MacKey = macKey;
Iv = iv;
Counter = counter;
}
}
@@ -0,0 +1,138 @@
using System.Text;
using Wingnal.Protocol.Crypto;
using Wingnal.Protocol.Curve;
using Wingnal.Protocol.Spqr;
using Wingnal.Protocol.State;
namespace Wingnal.Protocol.Ratchet;
/// <summary>Initiator (Alice) X3DH/PQXDH inputs.</summary>
public sealed class AliceParameters
{
public required IdentityKeyPair OurIdentityKey { get; init; }
public required ECKeyPair OurBaseKey { get; init; }
public required IdentityKey TheirIdentityKey { get; init; }
public required byte[] TheirSignedPreKey { get; init; } // raw 32
public required byte[] TheirRatchetKey { get; init; } // raw 32 (== signed prekey)
public byte[]? TheirOneTimePreKey { get; init; } // raw 32
public byte[]? KyberSharedSecret { get; init; } // from encapsulation (PQXDH)
}
/// <summary>Responder (Bob) X3DH/PQXDH inputs.</summary>
public sealed class BobParameters
{
public required IdentityKeyPair OurIdentityKey { get; init; }
public required ECKeyPair OurSignedPreKey { get; init; }
public required ECKeyPair OurRatchetKey { get; init; } // == signed prekey
public ECKeyPair? OurOneTimePreKey { get; init; }
public required IdentityKey TheirIdentityKey { get; init; }
public required byte[] TheirBaseKey { get; init; } // raw 32
public byte[]? KyberSharedSecret { get; init; } // from decapsulation (PQXDH)
}
/// <summary>
/// Builds the initial Double Ratchet state from X3DH/PQXDH agreements. The DH and (for PQXDH) Kyber
/// secrets are concatenated after 32 discontinuity bytes (0xFF), then HKDF "WhisperText" yields the
/// root and initial chain key. Initiator then performs one DH ratchet step to open its sending chain.
/// </summary>
public static class RatchetingSession
{
private static readonly byte[] DiscontinuityBytes = BuildDiscontinuity();
private static readonly byte[] DeriveInfo = Encoding.UTF8.GetBytes("WhisperText");
// PQXDH uses a distinct HKDF label and derives an extra 32-byte slice (the SPQR auth_key) beyond
// the root and chain keys. Matches libsignal pqxdh.rs HandshakeKeys::derive.
private static readonly byte[] PqxdhDeriveInfo =
Encoding.UTF8.GetBytes("WhisperText_X25519_SHA-256_CRYSTALS-KYBER-1024");
public static void InitializeAlice(SessionState state, AliceParameters p)
{
state.SessionVersion = p.KyberSharedSecret is not null ? 4 : 3;
state.LocalIdentity = p.OurIdentityKey.PublicKey;
state.RemoteIdentity = p.TheirIdentityKey;
ECKeyPair sendingRatchetKey = Curve25519.GenerateKeyPair();
using var secrets = new MemoryStream();
secrets.Write(DiscontinuityBytes);
secrets.Write(Curve25519.CalculateAgreement(p.TheirSignedPreKey, p.OurIdentityKey.PrivateKey)); // DH1
secrets.Write(Curve25519.CalculateAgreement(p.TheirIdentityKey.PublicKey, p.OurBaseKey.PrivateKey)); // DH2
secrets.Write(Curve25519.CalculateAgreement(p.TheirSignedPreKey, p.OurBaseKey.PrivateKey)); // DH3
if (p.TheirOneTimePreKey is not null)
secrets.Write(Curve25519.CalculateAgreement(p.TheirOneTimePreKey, p.OurBaseKey.PrivateKey)); // DH4
if (p.KyberSharedSecret is not null)
secrets.Write(p.KyberSharedSecret);
(RootKey rootKey, ChainKey chainKey, byte[]? authKey) = DeriveKeys(secrets.ToArray(), p.KyberSharedSecret is not null);
state.SpqrAuthKey = authKey;
state.Spqr = CreateSpqr(authKey, Direction.A2B);
(RootKey sendingRoot, ChainKey sendingChain) = rootKey.CreateChain(p.TheirRatchetKey, sendingRatchetKey);
state.AddReceiverChain(p.TheirRatchetKey, chainKey);
state.SenderRatchetKeyPair = sendingRatchetKey;
state.SenderChainKey = sendingChain;
state.RootKey = sendingRoot;
state.PreviousCounter = 0;
}
public static void InitializeBob(SessionState state, BobParameters p)
{
state.SessionVersion = p.KyberSharedSecret is not null ? 4 : 3;
state.LocalIdentity = p.OurIdentityKey.PublicKey;
state.RemoteIdentity = p.TheirIdentityKey;
using var secrets = new MemoryStream();
secrets.Write(DiscontinuityBytes);
secrets.Write(Curve25519.CalculateAgreement(p.TheirIdentityKey.PublicKey, p.OurSignedPreKey.PrivateKey)); // DH1
secrets.Write(Curve25519.CalculateAgreement(p.TheirBaseKey, p.OurIdentityKey.PrivateKey)); // DH2
secrets.Write(Curve25519.CalculateAgreement(p.TheirBaseKey, p.OurSignedPreKey.PrivateKey)); // DH3
if (p.OurOneTimePreKey is not null)
secrets.Write(Curve25519.CalculateAgreement(p.TheirBaseKey, p.OurOneTimePreKey.PrivateKey)); // DH4
if (p.KyberSharedSecret is not null)
secrets.Write(p.KyberSharedSecret);
(RootKey rootKey, ChainKey chainKey, byte[]? authKey) = DeriveKeys(secrets.ToArray(), p.KyberSharedSecret is not null);
state.SpqrAuthKey = authKey;
state.Spqr = CreateSpqr(authKey, Direction.B2A);
state.SenderRatchetKeyPair = p.OurRatchetKey;
state.SenderChainKey = chainKey;
state.RootKey = rootKey;
state.PreviousCounter = 0;
}
private static (RootKey, ChainKey, byte[]? AuthKey) DeriveKeys(byte[] masterSecret, bool pqxdh)
{
if (!pqxdh)
{
byte[] derived = CryptoPrimitives.Hkdf(masterSecret, salt: null, DeriveInfo, 64);
return (new RootKey(derived.AsSpan(0, 32).ToArray()), new ChainKey(derived.AsSpan(32, 32).ToArray(), 0), null);
}
// PQXDH: HKDF expands to root[32] || chain[32] || pqr_key[32]; the last slice seeds SPQR.
byte[] pq = CryptoPrimitives.Hkdf(masterSecret, salt: null, PqxdhDeriveInfo, 96);
return (new RootKey(pq.AsSpan(0, 32).ToArray()), new ChainKey(pq.AsSpan(32, 32).ToArray(), 0),
pq.AsSpan(64, 32).ToArray());
}
// Initialize the Sparse Post-Quantum Ratchet for a PQXDH (v4) session. Both ends mandate V1.
// chain_params default to libsignal's non-self-session values (max_jump=25000, max_ooo=2000).
private static SpqrRatchet? CreateSpqr(byte[]? authKey, Direction direction)
{
if (authKey is null) return null;
return SpqrRatchet.InitialState(new SpqrParams
{
Direction = direction,
Version = SpqrVersion.V1,
MinVersion = SpqrVersion.V1,
AuthKey = authKey,
ChainParams = new ChainParams(),
});
}
private static byte[] BuildDiscontinuity()
{
var bytes = new byte[32];
Array.Fill(bytes, (byte)0xFF);
return bytes;
}
}
+27
View File
@@ -0,0 +1,27 @@
using System.Text;
using Wingnal.Protocol.Crypto;
using Wingnal.Protocol.Curve;
namespace Wingnal.Protocol.Ratchet;
/// <summary>
/// The Double Ratchet root key. A DH ratchet step derives a new root key and a fresh chain key from
/// the current root key (as HKDF salt) and a new DH output, with info "WhisperRatchet".
/// </summary>
public sealed class RootKey
{
private static readonly byte[] Info = Encoding.UTF8.GetBytes("WhisperRatchet");
public byte[] Key { get; }
public RootKey(byte[] key) => Key = key;
public (RootKey RootKey, ChainKey ChainKey) CreateChain(byte[] theirRatchetKey, ECKeyPair ourRatchetKey)
{
byte[] dh = Curve25519.CalculateAgreement(theirRatchetKey, ourRatchetKey.PrivateKey);
byte[] derived = CryptoPrimitives.Hkdf(dh, salt: Key, Info, 64);
var newRootKey = new RootKey(derived.AsSpan(0, 32).ToArray());
var newChainKey = new ChainKey(derived.AsSpan(32, 32).ToArray(), 0);
return (newRootKey, newChainKey);
}
}
+139
View File
@@ -0,0 +1,139 @@
using Wingnal.Protocol.Curve;
using Wingnal.Protocol.Messages;
using Wingnal.Protocol.State;
namespace Wingnal.Protocol.Ratchet;
/// <summary>
/// Establishes sessions: from a fetched <see cref="PreKeyBundle"/> (initiator) or from an inbound
/// <see cref="PreKeySignalMessage"/> (responder). Verifies prekey signatures with XEdDSA.
/// </summary>
public sealed class SessionBuilder
{
private readonly ISessionStore _sessionStore;
private readonly IPreKeyStore _preKeyStore;
private readonly ISignedPreKeyStore _signedPreKeyStore;
private readonly IKyberPreKeyStore _kyberPreKeyStore;
private readonly IIdentityKeyStore _identityStore;
private readonly SignalProtocolAddress _remoteAddress;
public SessionBuilder(ISessionStore sessionStore, IPreKeyStore preKeyStore,
ISignedPreKeyStore signedPreKeyStore, IKyberPreKeyStore kyberPreKeyStore,
IIdentityKeyStore identityStore, SignalProtocolAddress remoteAddress)
{
_sessionStore = sessionStore;
_preKeyStore = preKeyStore;
_signedPreKeyStore = signedPreKeyStore;
_kyberPreKeyStore = kyberPreKeyStore;
_identityStore = identityStore;
_remoteAddress = remoteAddress;
}
/// <summary>Initiator: build an outgoing session from a fetched bundle.</summary>
public void Process(PreKeyBundle bundle)
{
// Refuse to build a session to an identity that doesn't match the one we already trust (MITM /
// reinstall). The caller surfaces this so the user can verify the safety number + approve.
if (!_identityStore.IsTrustedIdentity(_remoteAddress, bundle.IdentityKey))
throw new UntrustedIdentityException(_remoteAddress, bundle.IdentityKey);
if (!XEd25519.VerifySignature(bundle.IdentityKey.PublicKey,
Curve25519.EncodePoint(bundle.SignedPreKeyPublic), bundle.SignedPreKeySignature))
throw new InvalidMessageException("invalid signed prekey signature");
byte[]? kyberCiphertext = null, kyberSharedSecret = null;
if (bundle.KyberPreKeyPublic is not null)
{
if (bundle.KyberPreKeySignature is null ||
!XEd25519.VerifySignature(bundle.IdentityKey.PublicKey,
KemKeySerialization.Serialize(bundle.KyberPreKeyPublic), bundle.KyberPreKeySignature))
throw new InvalidMessageException("invalid kyber prekey signature");
KyberEncapsulation encapsulation = Kyber.Encapsulate(bundle.KyberPreKeyPublic);
// The wire carries the libsignal-serialized (type-prefixed) ciphertext.
kyberCiphertext = KemKeySerialization.Serialize(encapsulation.CipherText);
kyberSharedSecret = encapsulation.SharedSecret;
}
ECKeyPair ourBaseKey = Curve25519.GenerateKeyPair();
var parameters = new AliceParameters
{
OurIdentityKey = _identityStore.GetIdentityKeyPair(),
OurBaseKey = ourBaseKey,
TheirIdentityKey = bundle.IdentityKey,
TheirSignedPreKey = bundle.SignedPreKeyPublic,
TheirRatchetKey = bundle.SignedPreKeyPublic,
TheirOneTimePreKey = bundle.PreKeyPublic,
KyberSharedSecret = kyberSharedSecret,
};
SessionRecord record = _sessionStore.ContainsSession(_remoteAddress)
? _sessionStore.LoadSession(_remoteAddress)
: new SessionRecord();
record.ArchiveCurrentState();
RatchetingSession.InitializeAlice(record.State, parameters);
record.State.PendingPreKey = new PendingPreKey(bundle.PreKeyId, bundle.SignedPreKeyId,
bundle.KyberPreKeyId, kyberCiphertext, ourBaseKey.PublicKey);
record.State.LocalRegistrationId = _identityStore.GetLocalRegistrationId();
record.State.RemoteRegistrationId = bundle.RegistrationId;
_identityStore.SaveIdentity(_remoteAddress, bundle.IdentityKey);
_sessionStore.StoreSession(_remoteAddress, record);
}
/// <summary>
/// Responder: initialize the session from an inbound PreKeySignalMessage (mutates the given
/// record). Returns the one-time prekey id consumed, if any, so the caller can delete it.
/// </summary>
public uint? Process(SessionRecord record, PreKeySignalMessage message)
{
if (record.State.AliceBaseKey is not null && record.State.AliceBaseKey.AsSpan().SequenceEqual(message.BaseKey))
return null; // already processed this prekey message
// Don't accept a session from an identity we don't trust (changed key) until the user approves.
if (!_identityStore.IsTrustedIdentity(_remoteAddress, message.IdentityKey))
throw new UntrustedIdentityException(_remoteAddress, message.IdentityKey);
SignedPreKeyRecord signedPreKey = _signedPreKeyStore.LoadSignedPreKey(message.SignedPreKeyId);
ECKeyPair? oneTimePreKey = null;
if (message.PreKeyId.HasValue)
oneTimePreKey = _preKeyStore.LoadPreKey(message.PreKeyId.Value).KeyPair;
byte[]? kyberSharedSecret = null;
if (message.KyberPreKeyId.HasValue)
{
KyberPreKeyRecord kyberRecord = _kyberPreKeyStore.LoadKyberPreKey(message.KyberPreKeyId.Value);
kyberSharedSecret = Kyber.Decapsulate(kyberRecord.KeyPair.PrivateKey,
KemKeySerialization.Deserialize(message.KyberCiphertext!));
}
var parameters = new BobParameters
{
OurIdentityKey = _identityStore.GetIdentityKeyPair(),
OurSignedPreKey = signedPreKey.KeyPair,
OurRatchetKey = signedPreKey.KeyPair,
OurOneTimePreKey = oneTimePreKey,
TheirIdentityKey = message.IdentityKey,
TheirBaseKey = message.BaseKey,
KyberSharedSecret = kyberSharedSecret,
};
record.ArchiveCurrentState();
RatchetingSession.InitializeBob(record.State, parameters);
record.State.LocalRegistrationId = _identityStore.GetLocalRegistrationId();
record.State.RemoteRegistrationId = message.RegistrationId;
record.State.AliceBaseKey = message.BaseKey;
_identityStore.SaveIdentity(_remoteAddress, message.IdentityKey);
if (message.KyberPreKeyId.HasValue)
_kyberPreKeyStore.MarkKyberPreKeyUsed(message.KyberPreKeyId.Value);
return message.PreKeyId;
}
}
+186
View File
@@ -0,0 +1,186 @@
using Wingnal.Protocol.Crypto;
using Wingnal.Protocol.Curve;
using Wingnal.Protocol.Messages;
using Wingnal.Protocol.Spqr;
using Wingnal.Protocol.State;
namespace Wingnal.Protocol.Ratchet;
/// <summary>
/// Encrypts/decrypts messages for one peer using the Double Ratchet. Encrypt advances the sending
/// chain; decrypt performs DH ratchet steps on new ratchet keys and handles skipped (out-of-order)
/// message keys. Mirrors libsignal's SessionCipher.
/// </summary>
public sealed class SessionCipher
{
private const int MaxSkip = 2000;
private readonly ISessionStore _sessionStore;
private readonly IPreKeyStore _preKeyStore;
private readonly IIdentityKeyStore _identityStore;
private readonly SignalProtocolAddress _remoteAddress;
private readonly SessionBuilder _sessionBuilder;
public SessionCipher(ISessionStore sessionStore, IPreKeyStore preKeyStore,
ISignedPreKeyStore signedPreKeyStore, IKyberPreKeyStore kyberPreKeyStore,
IIdentityKeyStore identityStore, SignalProtocolAddress remoteAddress)
{
_sessionStore = sessionStore;
_preKeyStore = preKeyStore;
_identityStore = identityStore;
_remoteAddress = remoteAddress;
_sessionBuilder = new SessionBuilder(sessionStore, preKeyStore, signedPreKeyStore,
kyberPreKeyStore, identityStore, remoteAddress);
}
public ICiphertextMessage Encrypt(byte[] plaintext)
{
SessionRecord record = _sessionStore.LoadSession(_remoteAddress);
SessionState state = record.State;
ChainKey chainKey = state.SenderChainKey ?? throw new InvalidOperationException("no sender chain");
// Advance the Sparse Post-Quantum Ratchet (if enabled): the produced bytes ride in
// SignalMessage.pq_ratchet, and the produced key salts the message-key derivation.
byte[]? pqRatchet = null, pqrSalt = null;
if (state.Spqr is not null)
{
SpqrRatchet.SendOutput sent = state.Spqr.Send();
pqRatchet = sent.Message;
pqrSalt = sent.Key;
}
MessageKeys messageKeys = chainKey.GetMessageKeys(pqrSalt);
byte[] ciphertextBody = CryptoPrimitives.AesCbcEncrypt(messageKeys.CipherKey, messageKeys.Iv, plaintext);
var signalMessage = new SignalMessage(state.SessionVersion, messageKeys.MacKey,
state.SenderRatchetKeyPair!.PublicKey, chainKey.Index, state.PreviousCounter,
ciphertextBody, state.LocalIdentity!, state.RemoteIdentity!, pqRatchet);
ICiphertextMessage result = signalMessage;
if (state.PendingPreKey is { } pending)
{
result = new PreKeySignalMessage(state.SessionVersion, state.LocalRegistrationId,
pending.PreKeyId, pending.SignedPreKeyId, pending.KyberPreKeyId, pending.KyberCiphertext,
pending.BaseKey, state.LocalIdentity!, signalMessage);
}
state.SenderChainKey = chainKey.Next();
_sessionStore.StoreSession(_remoteAddress, record);
return result;
}
public byte[] DecryptPreKeyMessage(PreKeySignalMessage message)
{
SessionRecord record = _sessionStore.ContainsSession(_remoteAddress)
? _sessionStore.LoadSession(_remoteAddress)
: new SessionRecord();
uint? unsignedPreKeyId = _sessionBuilder.Process(record, message);
byte[] plaintext = Decrypt(record, message.Message);
_sessionStore.StoreSession(_remoteAddress, record);
if (unsignedPreKeyId.HasValue)
_preKeyStore.RemovePreKey(unsignedPreKeyId.Value);
return plaintext;
}
public byte[] DecryptSignalMessage(SignalMessage message)
{
SessionRecord record = _sessionStore.LoadSession(_remoteAddress);
byte[] plaintext = Decrypt(record, message);
_sessionStore.StoreSession(_remoteAddress, record);
return plaintext;
}
private byte[] Decrypt(SessionRecord record, SignalMessage message)
{
try
{
return DecryptWithState(record.State, message);
}
catch (Exception ex) when (ex is InvalidMessageException or DuplicateMessageException)
{
foreach (SessionState previous in record.PreviousStates)
{
try { return DecryptWithState(previous, message); }
catch (Exception inner) when (inner is InvalidMessageException or DuplicateMessageException) { }
}
throw;
}
}
private byte[] DecryptWithState(SessionState state, SignalMessage message)
{
if (!state.HasSenderChain)
throw new InvalidMessageException("uninitialized session");
byte[] theirEphemeral = message.SenderRatchetKey;
ChainKey chainKey = GetOrCreateChainKey(state, theirEphemeral);
byte[] seed = GetOrCreateMessageSeed(state, theirEphemeral, chainKey, message.Counter);
// Advance the SPQR receiving ratchet with this message's pq_ratchet bytes; the returned key
// salts the message-key derivation (matching libsignal's per-message WhisperMessageKeys salt).
byte[]? pqrSalt = state.Spqr?.Recv(message.PqRatchet ?? Array.Empty<byte>());
MessageKeys messageKeys = ChainKey.DeriveMessageKeys(seed, pqrSalt, message.Counter);
if (!message.VerifyMac(state.RemoteIdentity!, state.LocalIdentity!, messageKeys.MacKey))
throw new InvalidMessageException("bad MAC");
byte[] plaintext = CryptoPrimitives.AesCbcDecrypt(messageKeys.CipherKey, messageKeys.Iv, message.Body);
state.PendingPreKey = null;
return plaintext;
}
private static ChainKey GetOrCreateChainKey(SessionState state, byte[] theirEphemeral)
{
ReceiverChain? existing = state.FindReceiverChain(theirEphemeral);
if (existing is not null)
return existing.ChainKey;
// New ratchet key from the peer: perform a DH ratchet step.
RootKey rootKey = state.RootKey!;
ECKeyPair ourEphemeral = state.SenderRatchetKeyPair!;
(RootKey receiverRoot, ChainKey receiverChainKey) = rootKey.CreateChain(theirEphemeral, ourEphemeral);
ECKeyPair ourNewEphemeral = Curve25519.GenerateKeyPair();
(RootKey senderRoot, ChainKey senderChainKey) = receiverRoot.CreateChain(theirEphemeral, ourNewEphemeral);
state.RootKey = senderRoot;
state.AddReceiverChain(theirEphemeral, receiverChainKey);
state.PreviousCounter = state.SenderChainKey!.Index == 0 ? 0 : state.SenderChainKey.Index - 1;
state.SenderRatchetKeyPair = ourNewEphemeral;
state.SenderChainKey = senderChainKey;
return receiverChainKey;
}
// Returns the message-key SEED for the given counter (advancing/caching the DR chain as needed).
// The SPQR salt is applied to the seed separately, so out-of-order messages each get their own salt.
private static byte[] GetOrCreateMessageSeed(SessionState state, byte[] theirEphemeral, ChainKey chainKey, uint counter)
{
ReceiverChain chain = state.FindReceiverChain(theirEphemeral)!;
if (chainKey.Index > counter)
{
if (chain.TryTakeMessageSeed(counter, out byte[] cached))
return cached;
throw new DuplicateMessageException($"message key for counter {counter} already used or skipped");
}
if (counter - chainKey.Index > MaxSkip)
throw new InvalidMessageException("too many skipped messages");
ChainKey current = chainKey;
while (current.Index < counter)
{
chain.StoreMessageSeed(current.Index, current.MessageKeySeedBytes);
current = current.Next();
}
chain.ChainKey = current.Next();
return current.MessageKeySeedBytes;
}
}