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
@@ -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;
}
}
+133
View File
@@ -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;
}
}
+23
View File
@@ -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;
}