Add project files.
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
using System.Security.Cryptography;
|
||||
using Wingnal.Protocol.Curve;
|
||||
using Wingnal.Protocol.State;
|
||||
|
||||
namespace Wingnal.Protocol.Groups;
|
||||
|
||||
/// <summary>
|
||||
/// Sets up sender-key sessions. The sender calls <see cref="Create"/> once per group/distribution to
|
||||
/// produce a SenderKeyDistributionMessage (fanned out 1:1 to members); each member calls
|
||||
/// <see cref="Process"/> on that SKDM to install a receiving state. Mirrors libsignal's
|
||||
/// GroupSessionBuilder.
|
||||
/// </summary>
|
||||
public sealed class GroupSessionBuilder
|
||||
{
|
||||
private const int MessageVersion = SenderKeyWire.CurrentVersion;
|
||||
|
||||
private readonly ISenderKeyStore _store;
|
||||
|
||||
public GroupSessionBuilder(ISenderKeyStore store) => _store = store;
|
||||
|
||||
/// <summary>
|
||||
/// Creates (or returns) our outgoing sender-key state for <paramref name="sender"/> +
|
||||
/// <paramref name="distributionId"/> and returns the SKDM describing it. If no state exists yet,
|
||||
/// a fresh chain (random 32-byte chain key, iteration 0), a random 31-bit chain id, and a new
|
||||
/// signing key pair are generated.
|
||||
/// </summary>
|
||||
public SenderKeyDistributionMessage Create(SignalProtocolAddress sender, Guid distributionId)
|
||||
{
|
||||
SenderKeyRecord record = _store.LoadSenderKey(sender, distributionId) ?? new SenderKeyRecord();
|
||||
|
||||
if (record.IsEmpty)
|
||||
{
|
||||
// 31-bit chain id (Java-compatible: top bit cleared) per libsignal.
|
||||
uint chainId = RandomUInt32() >> 1;
|
||||
byte[] chainKey = RandomNumberGenerator.GetBytes(32);
|
||||
ECKeyPair signingKey = Curve25519.GenerateKeyPair();
|
||||
|
||||
record.AddState(chainId, MessageVersion, iteration: 0, chainKey,
|
||||
signingKey.PublicKey, signingKey.PrivateKey);
|
||||
_store.StoreSenderKey(sender, distributionId, record);
|
||||
}
|
||||
|
||||
SenderKeyState state = record.State;
|
||||
return new SenderKeyDistributionMessage(state.MessageVersion, distributionId, state.ChainId,
|
||||
state.ChainKey.Iteration, state.ChainKey.Seed, state.SigningKeyPublic);
|
||||
}
|
||||
|
||||
/// <summary>Installs the receiving state described by <paramref name="skdm"/> for the given
|
||||
/// sender + the SKDM's distribution id.</summary>
|
||||
public void Process(SignalProtocolAddress sender, SenderKeyDistributionMessage skdm)
|
||||
{
|
||||
SenderKeyRecord record = _store.LoadSenderKey(sender, skdm.DistributionId) ?? new SenderKeyRecord();
|
||||
record.AddState(skdm.ChainId, skdm.MessageVersion, skdm.Iteration, skdm.ChainKey,
|
||||
skdm.SigningKeyPublic, signingKeyPrivate: null);
|
||||
_store.StoreSenderKey(sender, skdm.DistributionId, record);
|
||||
}
|
||||
|
||||
private static uint RandomUInt32() => BitConverter.ToUInt32(RandomNumberGenerator.GetBytes(4));
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
using Wingnal.Protocol.Crypto;
|
||||
using Wingnal.Protocol.Messages;
|
||||
using Wingnal.Protocol.State;
|
||||
|
||||
namespace Wingnal.Protocol.Groups;
|
||||
|
||||
/// <summary>
|
||||
/// Encrypts/decrypts group messages with a sender key. Encrypt advances our own sending chain and
|
||||
/// signs a SenderKeyMessage; decrypt selects the named chain, derives (and caches skipped) message
|
||||
/// keys, verifies the signature, and AES-256-CBC decrypts. Mirrors libsignal's group_cipher.
|
||||
/// </summary>
|
||||
public sealed class GroupSessionCipher
|
||||
{
|
||||
/// <summary>libsignal <c>consts::MAX_FORWARD_JUMPS</c> — reject messages too far in the future.</summary>
|
||||
private const int MaxForwardJumps = 25_000;
|
||||
|
||||
private readonly ISenderKeyStore _store;
|
||||
|
||||
public GroupSessionCipher(ISenderKeyStore store) => _store = store;
|
||||
|
||||
/// <summary>Encrypts <paramref name="plaintext"/> under our sending chain for (sender,
|
||||
/// distributionId). Requires that <see cref="GroupSessionBuilder.Create"/> ran first.</summary>
|
||||
public SenderKeyMessage Encrypt(SignalProtocolAddress sender, Guid distributionId, byte[] plaintext)
|
||||
{
|
||||
SenderKeyRecord record = _store.LoadSenderKey(sender, distributionId)
|
||||
?? throw new InvalidMessageException("no sender key to encrypt with");
|
||||
SenderKeyState state = record.State;
|
||||
if (state.SigningKeyPrivate is null)
|
||||
throw new InvalidMessageException("no private signing key (receive-only state)");
|
||||
|
||||
SenderChainKey chainKey = state.ChainKey;
|
||||
SenderMessageKey messageKey = chainKey.MessageKey();
|
||||
|
||||
byte[] ciphertext = CryptoPrimitives.AesCbcEncrypt(messageKey.CipherKey, messageKey.Iv, plaintext);
|
||||
|
||||
var skm = new SenderKeyMessage(state.MessageVersion, distributionId, state.ChainId,
|
||||
messageKey.Iteration, ciphertext, state.SigningKeyPrivate);
|
||||
|
||||
state.ChainKey = chainKey.Next();
|
||||
_store.StoreSenderKey(sender, distributionId, record);
|
||||
return skm;
|
||||
}
|
||||
|
||||
/// <summary>Decrypts a received <paramref name="message"/> from <paramref name="sender"/>.</summary>
|
||||
public byte[] Decrypt(SignalProtocolAddress sender, SenderKeyMessage message)
|
||||
{
|
||||
SenderKeyRecord record = _store.LoadSenderKey(sender, message.DistributionId)
|
||||
?? throw new InvalidMessageException("no sender key for this distribution");
|
||||
SenderKeyState state = record.StateForChainId(message.ChainId)
|
||||
?? throw new InvalidMessageException($"no sender key state for chain id {message.ChainId}");
|
||||
|
||||
if (!message.VerifySignature(state.SigningKeyPublic))
|
||||
throw new InvalidMessageException("invalid SenderKeyMessage signature");
|
||||
|
||||
SenderMessageKey messageKey = GetMessageKey(state, message.Iteration);
|
||||
|
||||
byte[] plaintext = CryptoPrimitives.AesCbcDecrypt(messageKey.CipherKey, messageKey.Iv, message.Ciphertext);
|
||||
_store.StoreSenderKey(sender, message.DistributionId, record);
|
||||
return plaintext;
|
||||
}
|
||||
|
||||
// Mirrors libsignal get_sender_key: serve a cached past key, else advance (caching skipped keys)
|
||||
// up to the requested iteration. Bounded by MAX_FORWARD_JUMPS.
|
||||
private static SenderMessageKey GetMessageKey(SenderKeyState state, uint iteration)
|
||||
{
|
||||
SenderChainKey chainKey = state.ChainKey;
|
||||
uint current = chainKey.Iteration;
|
||||
|
||||
if (current > iteration)
|
||||
{
|
||||
SenderMessageKey? cached = state.RemoveMessageKey(iteration);
|
||||
return cached ?? throw new DuplicateMessageException(
|
||||
$"message key for iteration {iteration} already used or skipped");
|
||||
}
|
||||
|
||||
if (iteration - current > MaxForwardJumps)
|
||||
throw new InvalidMessageException("message from too far into the future");
|
||||
|
||||
while (chainKey.Iteration < iteration)
|
||||
{
|
||||
state.AddMessageKey(chainKey.MessageKey());
|
||||
chainKey = chainKey.Next();
|
||||
}
|
||||
|
||||
state.ChainKey = chainKey.Next();
|
||||
return chainKey.MessageKey();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
using System.Security.Cryptography;
|
||||
using Wingnal.Protocol.Curve;
|
||||
using Wingnal.Protocol.Messages;
|
||||
|
||||
namespace Wingnal.Protocol.Groups;
|
||||
|
||||
/// <summary>
|
||||
/// Shared constants + helpers for the Sender Key (group) wire format. Byte-exact with libsignal
|
||||
/// (rust/protocol: <c>protocol.rs</c>, <c>proto/wire.proto</c>, tag v0.96.1).
|
||||
/// </summary>
|
||||
internal static class SenderKeyWire
|
||||
{
|
||||
/// <summary>libsignal <c>SENDERKEY_MESSAGE_CURRENT_VERSION</c> — the low nibble of the version byte.</summary>
|
||||
public const int CurrentVersion = 3;
|
||||
|
||||
/// <summary>Version byte: high nibble = message version, low nibble = current ciphertext version.</summary>
|
||||
public static byte VersionByte(int messageVersion) =>
|
||||
(byte)(((messageVersion & 0xF) << 4) | CurrentVersion);
|
||||
|
||||
/// <summary>A UUID's 16 bytes in RFC 4122 / network (big-endian) order — what libsignal's uuid
|
||||
/// crate emits via <c>as_bytes()</c>. (.NET's default <see cref="Guid.ToByteArray()"/> is
|
||||
/// mixed-endian; the <c>bigEndian</c> overload gives the RFC-4122 order directly.)</summary>
|
||||
public static byte[] DistributionBytes(Guid id) => id.ToByteArray(bigEndian: true);
|
||||
|
||||
public static Guid DistributionId(byte[] be)
|
||||
{
|
||||
if (be.Length != 16) throw new InvalidMessageException("bad distribution id length");
|
||||
return new Guid(be, bigEndian: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A group message ("SenderKeyMessage"): <c>version || protobuf(distribution_uuid, chain_id,
|
||||
/// iteration, ciphertext) || signature[64]</c>. The signature is XEdDSA over <c>version||protobuf</c>
|
||||
/// using the sender's per-distribution signing key.
|
||||
/// </summary>
|
||||
public sealed class SenderKeyMessage
|
||||
{
|
||||
private const int SignatureLen = 64;
|
||||
|
||||
public int MessageVersion { get; }
|
||||
public Guid DistributionId { get; }
|
||||
public uint ChainId { get; }
|
||||
public uint Iteration { get; }
|
||||
public byte[] Ciphertext { get; }
|
||||
private readonly byte[] _serialized;
|
||||
|
||||
/// <summary>Builds + signs a SenderKeyMessage. <paramref name="signingPrivateKey"/> is the raw
|
||||
/// 32-byte Curve25519 private signing scalar.</summary>
|
||||
public SenderKeyMessage(int messageVersion, Guid distributionId, uint chainId, uint iteration,
|
||||
byte[] ciphertext, byte[] signingPrivateKey)
|
||||
{
|
||||
MessageVersion = messageVersion;
|
||||
DistributionId = distributionId;
|
||||
ChainId = chainId;
|
||||
Iteration = iteration;
|
||||
Ciphertext = ciphertext;
|
||||
|
||||
var proto = new ProtoWriter();
|
||||
proto.WriteBytes(1, SenderKeyWire.DistributionBytes(distributionId));
|
||||
proto.WriteUInt32(2, chainId);
|
||||
proto.WriteUInt32(3, iteration);
|
||||
proto.WriteBytes(4, ciphertext);
|
||||
byte[] protoBytes = proto.ToArray();
|
||||
|
||||
var signed = new byte[1 + protoBytes.Length];
|
||||
signed[0] = SenderKeyWire.VersionByte(messageVersion);
|
||||
Array.Copy(protoBytes, 0, signed, 1, protoBytes.Length);
|
||||
|
||||
byte[] signature = XEd25519.CalculateSignature(signingPrivateKey, signed, RandomNumberGenerator.GetBytes(64));
|
||||
|
||||
_serialized = new byte[signed.Length + SignatureLen];
|
||||
Array.Copy(signed, 0, _serialized, 0, signed.Length);
|
||||
Array.Copy(signature, 0, _serialized, signed.Length, SignatureLen);
|
||||
}
|
||||
|
||||
// Distinct parameter order (serialized first) so this doesn't collide with the signing ctor above.
|
||||
private SenderKeyMessage(byte[] serialized, int version, Guid id, uint chainId, uint iteration, byte[] ciphertext)
|
||||
{
|
||||
_serialized = serialized;
|
||||
MessageVersion = version;
|
||||
DistributionId = id;
|
||||
ChainId = chainId;
|
||||
Iteration = iteration;
|
||||
Ciphertext = ciphertext;
|
||||
}
|
||||
|
||||
public byte[] Serialize() => _serialized;
|
||||
|
||||
public static SenderKeyMessage Parse(byte[] serialized)
|
||||
{
|
||||
if (serialized.Length < 1 + SignatureLen)
|
||||
throw new InvalidMessageException("SenderKeyMessage too short");
|
||||
|
||||
int version = (serialized[0] >> 4) & 0xF;
|
||||
var reader = new ProtoReader(serialized.AsSpan(1, serialized.Length - 1 - SignatureLen));
|
||||
|
||||
byte[]? distribution = null, ciphertext = null;
|
||||
uint chainId = 0, iteration = 0;
|
||||
while (reader.TryReadTag(out int field, out int wireType))
|
||||
{
|
||||
switch (field)
|
||||
{
|
||||
case 1: distribution = reader.ReadBytes(); break;
|
||||
case 2: chainId = reader.ReadUInt32(); break;
|
||||
case 3: iteration = reader.ReadUInt32(); break;
|
||||
case 4: ciphertext = reader.ReadBytes(); break;
|
||||
default: reader.SkipField(wireType); break;
|
||||
}
|
||||
}
|
||||
if (distribution is null || ciphertext is null)
|
||||
throw new InvalidMessageException("incomplete SenderKeyMessage");
|
||||
|
||||
return new SenderKeyMessage(serialized, version, SenderKeyWire.DistributionId(distribution),
|
||||
chainId, iteration, ciphertext);
|
||||
}
|
||||
|
||||
/// <summary>Verifies the XEdDSA signature against the signer's public key (raw 32-byte Montgomery).</summary>
|
||||
public bool VerifySignature(byte[] signingPublicKey)
|
||||
{
|
||||
int splitAt = _serialized.Length - SignatureLen;
|
||||
return XEd25519.VerifySignature(
|
||||
signingPublicKey,
|
||||
_serialized.AsSpan(0, splitAt),
|
||||
_serialized.AsSpan(splitAt, SignatureLen));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A SenderKeyDistributionMessage (SKDM): <c>version || protobuf(distribution_uuid, chain_id,
|
||||
/// iteration, chain_key[32], signing_key[33])</c>. Sent (1:1, sealed) to each group member so they can
|
||||
/// build a receiving sender-key state. No signature of its own — the included signing public key
|
||||
/// authenticates subsequent SenderKeyMessages.
|
||||
/// </summary>
|
||||
public sealed class SenderKeyDistributionMessage
|
||||
{
|
||||
public int MessageVersion { get; }
|
||||
public Guid DistributionId { get; }
|
||||
public uint ChainId { get; }
|
||||
public uint Iteration { get; }
|
||||
public byte[] ChainKey { get; } // 32-byte chain key seed
|
||||
public byte[] SigningKeyPublic { get; } // raw 32-byte Montgomery public
|
||||
private readonly byte[] _serialized;
|
||||
|
||||
public SenderKeyDistributionMessage(int messageVersion, Guid distributionId, uint chainId,
|
||||
uint iteration, byte[] chainKey, byte[] signingKeyPublic)
|
||||
{
|
||||
MessageVersion = messageVersion;
|
||||
DistributionId = distributionId;
|
||||
ChainId = chainId;
|
||||
Iteration = iteration;
|
||||
ChainKey = chainKey;
|
||||
SigningKeyPublic = signingKeyPublic;
|
||||
|
||||
var proto = new ProtoWriter();
|
||||
proto.WriteBytes(1, SenderKeyWire.DistributionBytes(distributionId));
|
||||
proto.WriteUInt32(2, chainId);
|
||||
proto.WriteUInt32(3, iteration);
|
||||
proto.WriteBytes(4, chainKey);
|
||||
proto.WriteBytes(5, Curve25519.EncodePoint(signingKeyPublic));
|
||||
byte[] protoBytes = proto.ToArray();
|
||||
|
||||
_serialized = new byte[1 + protoBytes.Length];
|
||||
_serialized[0] = SenderKeyWire.VersionByte(messageVersion);
|
||||
Array.Copy(protoBytes, 0, _serialized, 1, protoBytes.Length);
|
||||
}
|
||||
|
||||
private SenderKeyDistributionMessage(int version, Guid id, uint chainId, uint iteration,
|
||||
byte[] chainKey, byte[] signingPublic, byte[] serialized)
|
||||
{
|
||||
MessageVersion = version;
|
||||
DistributionId = id;
|
||||
ChainId = chainId;
|
||||
Iteration = iteration;
|
||||
ChainKey = chainKey;
|
||||
SigningKeyPublic = signingPublic;
|
||||
_serialized = serialized;
|
||||
}
|
||||
|
||||
public byte[] Serialize() => _serialized;
|
||||
|
||||
public static SenderKeyDistributionMessage Parse(byte[] serialized)
|
||||
{
|
||||
if (serialized.Length < 1) throw new InvalidMessageException("SKDM too short");
|
||||
|
||||
int version = (serialized[0] >> 4) & 0xF;
|
||||
var reader = new ProtoReader(serialized.AsSpan(1));
|
||||
|
||||
byte[]? distribution = null, chainKey = null, signingKey = null;
|
||||
uint chainId = 0, iteration = 0;
|
||||
while (reader.TryReadTag(out int field, out int wireType))
|
||||
{
|
||||
switch (field)
|
||||
{
|
||||
case 1: distribution = reader.ReadBytes(); break;
|
||||
case 2: chainId = reader.ReadUInt32(); break;
|
||||
case 3: iteration = reader.ReadUInt32(); break;
|
||||
case 4: chainKey = reader.ReadBytes(); break;
|
||||
case 5: signingKey = Curve25519.DecodePoint(reader.ReadBytes()); break;
|
||||
default: reader.SkipField(wireType); break;
|
||||
}
|
||||
}
|
||||
if (distribution is null || chainKey is null || signingKey is null)
|
||||
throw new InvalidMessageException("incomplete SenderKeyDistributionMessage");
|
||||
|
||||
return new SenderKeyDistributionMessage(version, SenderKeyWire.DistributionId(distribution),
|
||||
chainId, iteration, chainKey, signingKey, serialized);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using System.IO;
|
||||
using Wingnal.Protocol.Messages;
|
||||
|
||||
namespace Wingnal.Protocol.Groups;
|
||||
|
||||
/// <summary>
|
||||
/// All sender-key states for one (sender, distribution-id). The most-recently-added state is current
|
||||
/// (used for encrypting / the latest received chain); older states are kept (bounded) so in-flight
|
||||
/// messages under a superseded chain still decrypt. Mirrors libsignal's SenderKeyRecord.
|
||||
/// </summary>
|
||||
public sealed class SenderKeyRecord
|
||||
{
|
||||
/// <summary>libsignal <c>consts::MAX_SENDER_KEY_STATES</c>.</summary>
|
||||
public const int MaxStates = 5;
|
||||
|
||||
private readonly List<SenderKeyState> _states = new(); // index 0 = current
|
||||
|
||||
public bool IsEmpty => _states.Count == 0;
|
||||
|
||||
/// <summary>The current (most recent) state.</summary>
|
||||
public SenderKeyState State =>
|
||||
_states.Count > 0 ? _states[0] : throw new InvalidMessageException("no sender key state");
|
||||
|
||||
/// <summary>The state for a specific chain id (a received message names its chain), or null.</summary>
|
||||
public SenderKeyState? StateForChainId(uint chainId) =>
|
||||
_states.Find(s => s.ChainId == chainId);
|
||||
|
||||
/// <summary>
|
||||
/// Installs a sender-key state (from our own keygen, or from a processed SKDM). Idempotent for a
|
||||
/// repeated SKDM: if a state with the same chain id and signing key already exists it's left
|
||||
/// untouched (so re-processing the same distribution message doesn't rewind the chain).
|
||||
/// </summary>
|
||||
public void AddState(uint chainId, int messageVersion, uint iteration, byte[] chainKeySeed,
|
||||
byte[] signingKeyPublic, byte[]? signingKeyPrivate)
|
||||
{
|
||||
SenderKeyState? existing = _states.Find(s => s.ChainId == chainId);
|
||||
if (existing is not null && existing.SigningKeyPublic.AsSpan().SequenceEqual(signingKeyPublic))
|
||||
return;
|
||||
|
||||
_states.RemoveAll(s => s.ChainId == chainId);
|
||||
_states.Insert(0, new SenderKeyState(chainId, messageVersion, iteration, chainKeySeed,
|
||||
signingKeyPublic, signingKeyPrivate));
|
||||
while (_states.Count > MaxStates)
|
||||
_states.RemoveAt(_states.Count - 1);
|
||||
}
|
||||
|
||||
// ── durable persistence (local-only binary; never sent to a peer) ──
|
||||
|
||||
/// <summary>Serializes every state (index 0 = current) for durable storage.</summary>
|
||||
public byte[] Serialize()
|
||||
{
|
||||
using var ms = new MemoryStream();
|
||||
using var w = new BinaryWriter(ms);
|
||||
w.Write(_states.Count);
|
||||
foreach (SenderKeyState s in _states) s.Write(w);
|
||||
w.Flush();
|
||||
return ms.ToArray();
|
||||
}
|
||||
|
||||
public static SenderKeyRecord Deserialize(byte[] bytes)
|
||||
{
|
||||
using var ms = new MemoryStream(bytes);
|
||||
using var r = new BinaryReader(ms);
|
||||
var record = new SenderKeyRecord();
|
||||
int n = r.ReadInt32();
|
||||
for (int i = 0; i < n; i++) record._states.Add(SenderKeyState.Read(r));
|
||||
return record;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using Wingnal.Protocol.Crypto;
|
||||
using Wingnal.Protocol.Spqr; // Bin.WriteBlob/ReadBlob length-prefixed helpers
|
||||
|
||||
namespace Wingnal.Protocol.Groups;
|
||||
|
||||
/// <summary>
|
||||
/// A per-message symmetric key derived from a sender chain. Mirrors libsignal's
|
||||
/// <c>SenderMessageKey</c>: HKDF-SHA256 expand of the chain's <c>0x01</c> derivative with info
|
||||
/// "WhisperGroup" to 48 bytes = iv[16] || cipherKey[32].
|
||||
/// </summary>
|
||||
public sealed class SenderMessageKey
|
||||
{
|
||||
private static readonly byte[] Info = Encoding.UTF8.GetBytes("WhisperGroup");
|
||||
|
||||
public uint Iteration { get; }
|
||||
public byte[] Seed { get; } // the 32-byte 0x01-derivative (persisted form)
|
||||
public byte[] Iv { get; } // 16
|
||||
public byte[] CipherKey { get; } // 32
|
||||
|
||||
public SenderMessageKey(uint iteration, byte[] seed)
|
||||
{
|
||||
Iteration = iteration;
|
||||
Seed = seed;
|
||||
byte[] derived = CryptoPrimitives.Hkdf(seed, salt: null, Info, 48);
|
||||
Iv = derived.AsSpan(0, 16).ToArray();
|
||||
CipherKey = derived.AsSpan(16, 32).ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A sender chain key. <c>messageKey = HMAC-SHA256(chainKey, 0x01)</c>; the next chain key is
|
||||
/// <c>HMAC-SHA256(chainKey, 0x02)</c>. Identical construction to the 1:1 <c>ChainKey</c>, but the
|
||||
/// message-key seed is expanded with the group info string.
|
||||
/// </summary>
|
||||
public sealed class SenderChainKey
|
||||
{
|
||||
private static readonly byte[] MessageKeySeed = { 0x01 };
|
||||
private static readonly byte[] ChainKeySeed = { 0x02 };
|
||||
|
||||
public uint Iteration { get; }
|
||||
public byte[] Seed { get; }
|
||||
|
||||
public SenderChainKey(uint iteration, byte[] seed)
|
||||
{
|
||||
Iteration = iteration;
|
||||
Seed = seed;
|
||||
}
|
||||
|
||||
public SenderMessageKey MessageKey() =>
|
||||
new(Iteration, CryptoPrimitives.HmacSha256(Seed, MessageKeySeed));
|
||||
|
||||
public SenderChainKey Next() =>
|
||||
new(Iteration + 1, CryptoPrimitives.HmacSha256(Seed, ChainKeySeed));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One sender-key state for a (sender, distribution-id) chain: the symmetric chain, the signing key
|
||||
/// pair (private present only for our own outgoing chain), the chain id, message version, and a
|
||||
/// bounded FIFO cache of skipped/out-of-order message keys. Mirrors libsignal's SenderKeyState.
|
||||
/// </summary>
|
||||
public sealed class SenderKeyState
|
||||
{
|
||||
/// <summary>libsignal <c>consts::MAX_MESSAGE_KEYS</c> — bound on the skipped-key cache.</summary>
|
||||
public const int MaxMessageKeys = 2000;
|
||||
|
||||
public uint ChainId { get; }
|
||||
public int MessageVersion { get; }
|
||||
public byte[] SigningKeyPublic { get; } // raw 32-byte Montgomery
|
||||
public byte[]? SigningKeyPrivate { get; } // raw 32 (null for receive-only state)
|
||||
public SenderChainKey ChainKey { get; set; }
|
||||
|
||||
private readonly List<SenderMessageKey> _messageKeys = new();
|
||||
|
||||
public SenderKeyState(uint chainId, int messageVersion, uint iteration, byte[] chainKeySeed,
|
||||
byte[] signingKeyPublic, byte[]? signingKeyPrivate)
|
||||
{
|
||||
ChainId = chainId;
|
||||
MessageVersion = messageVersion;
|
||||
SigningKeyPublic = signingKeyPublic;
|
||||
SigningKeyPrivate = signingKeyPrivate;
|
||||
ChainKey = new SenderChainKey(iteration, chainKeySeed);
|
||||
}
|
||||
|
||||
public void AddMessageKey(SenderMessageKey key)
|
||||
{
|
||||
_messageKeys.Add(key);
|
||||
while (_messageKeys.Count > MaxMessageKeys)
|
||||
_messageKeys.RemoveAt(0); // FIFO eviction (oldest first), matching libsignal
|
||||
}
|
||||
|
||||
/// <summary>Removes and returns the cached key for <paramref name="iteration"/>, or null if absent
|
||||
/// (already used / never skipped).</summary>
|
||||
public SenderMessageKey? RemoveMessageKey(uint iteration)
|
||||
{
|
||||
int idx = _messageKeys.FindIndex(k => k.Iteration == iteration);
|
||||
if (idx < 0) return null;
|
||||
SenderMessageKey key = _messageKeys[idx];
|
||||
_messageKeys.RemoveAt(idx);
|
||||
return key;
|
||||
}
|
||||
|
||||
// ── durable persistence (local-only binary; never sent to a peer) ──
|
||||
|
||||
internal void Write(BinaryWriter w)
|
||||
{
|
||||
w.Write(ChainId);
|
||||
w.Write(MessageVersion);
|
||||
w.WriteBlob(SigningKeyPublic);
|
||||
w.Write(SigningKeyPrivate is not null);
|
||||
if (SigningKeyPrivate is not null) w.WriteBlob(SigningKeyPrivate);
|
||||
w.Write(ChainKey.Iteration);
|
||||
w.WriteBlob(ChainKey.Seed);
|
||||
w.Write(_messageKeys.Count);
|
||||
foreach (SenderMessageKey k in _messageKeys) { w.Write(k.Iteration); w.WriteBlob(k.Seed); }
|
||||
}
|
||||
|
||||
internal static SenderKeyState Read(BinaryReader r)
|
||||
{
|
||||
uint chainId = r.ReadUInt32();
|
||||
int messageVersion = r.ReadInt32();
|
||||
byte[] signingPublic = r.ReadBlob();
|
||||
byte[]? signingPrivate = r.ReadBoolean() ? r.ReadBlob() : null;
|
||||
uint iteration = r.ReadUInt32();
|
||||
byte[] chainSeed = r.ReadBlob();
|
||||
var state = new SenderKeyState(chainId, messageVersion, iteration, chainSeed, signingPublic, signingPrivate);
|
||||
int n = r.ReadInt32();
|
||||
for (int i = 0; i < n; i++)
|
||||
state.AddMessageKey(new SenderMessageKey(r.ReadUInt32(), r.ReadBlob()));
|
||||
return state;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using Wingnal.Protocol.State;
|
||||
|
||||
namespace Wingnal.Protocol.Groups;
|
||||
|
||||
/// <summary>Persists sender-key records, keyed by (sender address, distribution id). Mirrors
|
||||
/// libsignal's SenderKeyStore.</summary>
|
||||
public interface ISenderKeyStore
|
||||
{
|
||||
void StoreSenderKey(SignalProtocolAddress sender, Guid distributionId, SenderKeyRecord record);
|
||||
SenderKeyRecord? LoadSenderKey(SignalProtocolAddress sender, Guid distributionId);
|
||||
}
|
||||
|
||||
/// <summary>In-memory <see cref="ISenderKeyStore"/> for tests and the group crypto core.</summary>
|
||||
public sealed class InMemorySenderKeyStore : ISenderKeyStore
|
||||
{
|
||||
private readonly Dictionary<(SignalProtocolAddress, Guid), SenderKeyRecord> _store = new();
|
||||
|
||||
public void StoreSenderKey(SignalProtocolAddress sender, Guid distributionId, SenderKeyRecord record) =>
|
||||
_store[(sender, distributionId)] = record;
|
||||
|
||||
public SenderKeyRecord? LoadSenderKey(SignalProtocolAddress sender, Guid distributionId) =>
|
||||
_store.TryGetValue((sender, distributionId), out SenderKeyRecord? r) ? r : null;
|
||||
}
|
||||
Reference in New Issue
Block a user