Add project files.
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user