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
+121
View File
@@ -0,0 +1,121 @@
using Wingnal.Protocol.Curve;
namespace Wingnal.Protocol.State;
/// <summary>Identifies a remote party + device, e.g. ("+15551234567" or an ACI uuid, deviceId).</summary>
public readonly record struct SignalProtocolAddress(string Name, uint DeviceId);
/// <summary>A public identity key (long-term Curve25519 key used for X3DH and signatures).</summary>
public sealed class IdentityKey
{
/// <summary>Raw 32-byte Montgomery public key.</summary>
public byte[] PublicKey { get; }
public IdentityKey(byte[] publicKey) => PublicKey = publicKey;
/// <summary>33-byte DjbECPublicKey serialization (0x05 || u).</summary>
public byte[] Serialize() => Curve25519.EncodePoint(PublicKey);
public static IdentityKey Decode(ReadOnlySpan<byte> serialized) => new(Curve25519.DecodePoint(serialized));
}
public sealed class IdentityKeyPair
{
public IdentityKey PublicKey { get; }
public byte[] PrivateKey { get; } // raw 32
public IdentityKeyPair(IdentityKey publicKey, byte[] privateKey)
{
PublicKey = publicKey;
PrivateKey = privateKey;
}
public static IdentityKeyPair Generate()
{
ECKeyPair kp = Curve25519.GenerateKeyPair();
return new IdentityKeyPair(new IdentityKey(kp.PublicKey), kp.PrivateKey);
}
}
public sealed class PreKeyRecord
{
public uint Id { get; }
public ECKeyPair KeyPair { get; }
public PreKeyRecord(uint id, ECKeyPair keyPair)
{
Id = id;
KeyPair = keyPair;
}
public static PreKeyRecord Generate(uint id) => new(id, Curve25519.GenerateKeyPair());
}
public sealed class SignedPreKeyRecord
{
public uint Id { get; }
public ECKeyPair KeyPair { get; }
public byte[] Signature { get; }
public long Timestamp { get; }
public SignedPreKeyRecord(uint id, ECKeyPair keyPair, byte[] signature, long timestamp)
{
Id = id;
KeyPair = keyPair;
Signature = signature;
Timestamp = timestamp;
}
}
public sealed class KyberPreKeyRecord
{
public uint Id { get; }
public KyberKeyPair KeyPair { get; }
public byte[] Signature { get; }
public long Timestamp { get; }
public KyberPreKeyRecord(uint id, KyberKeyPair keyPair, byte[] signature, long timestamp)
{
Id = id;
KeyPair = keyPair;
Signature = signature;
Timestamp = timestamp;
}
}
/// <summary>
/// The bundle of public keys an initiator fetches for a recipient device (GET /v2/keys), used to
/// build an outgoing X3DH/PQXDH session. The one-time prekey is optional; the Kyber prekey is
/// present for PQXDH.
/// </summary>
public sealed class PreKeyBundle
{
public uint RegistrationId { get; }
public uint DeviceId { get; }
public uint? PreKeyId { get; }
public byte[]? PreKeyPublic { get; } // raw 32
public uint SignedPreKeyId { get; }
public byte[] SignedPreKeyPublic { get; } // raw 32
public byte[] SignedPreKeySignature { get; }
public IdentityKey IdentityKey { get; }
public uint? KyberPreKeyId { get; }
public byte[]? KyberPreKeyPublic { get; } // ML-KEM-1024 encoded
public byte[]? KyberPreKeySignature { get; }
public PreKeyBundle(uint registrationId, uint deviceId, uint? preKeyId, byte[]? preKeyPublic,
uint signedPreKeyId, byte[] signedPreKeyPublic, byte[] signedPreKeySignature, IdentityKey identityKey,
uint? kyberPreKeyId = null, byte[]? kyberPreKeyPublic = null, byte[]? kyberPreKeySignature = null)
{
RegistrationId = registrationId;
DeviceId = deviceId;
PreKeyId = preKeyId;
PreKeyPublic = preKeyPublic;
SignedPreKeyId = signedPreKeyId;
SignedPreKeyPublic = signedPreKeyPublic;
SignedPreKeySignature = signedPreKeySignature;
IdentityKey = identityKey;
KyberPreKeyId = kyberPreKeyId;
KyberPreKeyPublic = kyberPreKeyPublic;
KyberPreKeySignature = kyberPreKeySignature;
}
}
+54
View File
@@ -0,0 +1,54 @@
using System.IO;
namespace Wingnal.Protocol.State;
/// <summary>
/// Wraps the current <see cref="SessionState"/> plus a bounded list of archived previous states.
/// Archiving (on re-keying / new session setup) lets the decryptor still process in-flight messages
/// encrypted under the prior session.
/// </summary>
public sealed class SessionRecord
{
private const int MaxArchivedStates = 40;
private readonly LinkedList<SessionState> _previousStates = new();
public SessionState State { get; private set; }
public SessionRecord() => State = new SessionState();
public SessionRecord(SessionState state) => State = state;
public IEnumerable<SessionState> PreviousStates => _previousStates;
/// <summary>Moves the current state into the archive and starts a fresh one.</summary>
public void ArchiveCurrentState()
{
if (!State.IsInitialized) return;
_previousStates.AddFirst(State);
while (_previousStates.Count > MaxArchivedStates)
_previousStates.RemoveLast();
State = new SessionState();
}
/// <summary>Serializes the current + archived states (durable session persistence).</summary>
public byte[] Serialize()
{
using var ms = new MemoryStream();
using var w = new BinaryWriter(ms);
State.Write(w);
w.Write(_previousStates.Count);
foreach (SessionState prev in _previousStates) prev.Write(w);
w.Flush();
return ms.ToArray();
}
public static SessionRecord Deserialize(byte[] bytes)
{
using var ms = new MemoryStream(bytes);
using var r = new BinaryReader(ms);
var record = new SessionRecord(SessionState.Read(r));
int n = r.ReadInt32();
for (int i = 0; i < n; i++) record._previousStates.AddLast(SessionState.Read(r));
return record;
}
}
+200
View File
@@ -0,0 +1,200 @@
using System.IO;
using Wingnal.Protocol.Curve;
using Wingnal.Protocol.Ratchet;
using Wingnal.Protocol.Spqr;
namespace Wingnal.Protocol.State;
/// <summary>The sender's unacknowledged X3DH/PQXDH setup data, replayed in each outgoing message
/// until the peer's first reply confirms the session (then cleared).</summary>
public sealed class PendingPreKey
{
public uint? PreKeyId { get; }
public uint SignedPreKeyId { get; }
public uint? KyberPreKeyId { get; }
public byte[]? KyberCiphertext { get; }
public byte[] BaseKey { get; } // raw 32
public PendingPreKey(uint? preKeyId, uint signedPreKeyId, uint? kyberPreKeyId, byte[]? kyberCiphertext, byte[] baseKey)
{
PreKeyId = preKeyId;
SignedPreKeyId = signedPreKeyId;
KyberPreKeyId = kyberPreKeyId;
KyberCiphertext = kyberCiphertext;
BaseKey = baseKey;
}
}
/// <summary>One receiving chain, keyed by the peer's ratchet public key, plus a bounded cache of
/// skipped message keys (out-of-order / dropped messages).</summary>
public sealed class ReceiverChain
{
private const int MaxMessageKeys = 2000;
public byte[] RatchetKey { get; } // raw 32, the peer's ratchet public key
public ChainKey ChainKey { get; set; }
// Skipped/out-of-order messages cache the message-key SEED (not the final keys), so the SPQR
// per-message salt can be applied when the out-of-order message arrives. Counter -> seed.
private readonly Dictionary<uint, byte[]> _messageSeeds = new();
private readonly Queue<uint> _order = new();
public ReceiverChain(byte[] ratchetKey, ChainKey chainKey)
{
RatchetKey = ratchetKey;
ChainKey = chainKey;
}
public bool TryTakeMessageSeed(uint counter, out byte[] seed)
{
if (_messageSeeds.Remove(counter, out byte[]? found))
{
seed = found;
return true;
}
seed = default!;
return false;
}
public void StoreMessageSeed(uint counter, byte[] seed)
{
_messageSeeds[counter] = seed;
_order.Enqueue(counter);
while (_order.Count > MaxMessageKeys)
_messageSeeds.Remove(_order.Dequeue());
}
internal void Write(BinaryWriter w)
{
w.WriteBlob(RatchetKey);
w.WriteBlob(ChainKey.Key);
w.Write(ChainKey.Index);
w.Write(_messageSeeds.Count);
foreach (KeyValuePair<uint, byte[]> kv in _messageSeeds) { w.Write(kv.Key); w.WriteBlob(kv.Value); }
}
internal static ReceiverChain Read(BinaryReader r)
{
byte[] ratchetKey = r.ReadBlob();
var chainKey = new ChainKey(r.ReadBlob(), r.ReadUInt32());
var chain = new ReceiverChain(ratchetKey, chainKey);
int n = r.ReadInt32();
for (int i = 0; i < n; i++) chain.StoreMessageSeed(r.ReadUInt32(), r.ReadBlob());
return chain;
}
}
/// <summary>
/// Mutable Double Ratchet session state: root key, current sending chain, recent receiving chains,
/// identities, and (for an initiator) the pending prekey. Mirrors libsignal's SessionState.
/// </summary>
public sealed class SessionState
{
private const int MaxReceiverChains = 5;
public int SessionVersion { get; set; }
public IdentityKey? LocalIdentity { get; set; }
public IdentityKey? RemoteIdentity { get; set; }
public uint LocalRegistrationId { get; set; }
public uint RemoteRegistrationId { get; set; }
public RootKey? RootKey { get; set; }
public ECKeyPair? SenderRatchetKeyPair { get; set; }
public ChainKey? SenderChainKey { get; set; }
public uint PreviousCounter { get; set; }
public PendingPreKey? PendingPreKey { get; set; }
public byte[]? AliceBaseKey { get; set; } // responder-side dedupe of repeated prekey messages
/// <summary>The 32-byte SPQR auth_key (3rd HKDF slice from PQXDH); null for classic X3DH sessions.</summary>
public byte[]? SpqrAuthKey { get; set; }
/// <summary>The live Sparse Post-Quantum Ratchet (in-memory); null = SPQR disabled (classic session).</summary>
public SpqrRatchet? Spqr { get; set; }
private readonly LinkedList<ReceiverChain> _receiverChains = new();
public bool HasSenderChain => SenderRatchetKeyPair is not null && SenderChainKey is not null;
public bool IsInitialized => RootKey is not null;
public ReceiverChain? FindReceiverChain(byte[] ratchetKey)
{
foreach (ReceiverChain chain in _receiverChains)
if (chain.RatchetKey.AsSpan().SequenceEqual(ratchetKey))
return chain;
return null;
}
public void AddReceiverChain(byte[] ratchetKey, ChainKey chainKey)
{
_receiverChains.AddFirst(new ReceiverChain(ratchetKey, chainKey));
while (_receiverChains.Count > MaxReceiverChains)
_receiverChains.RemoveLast();
}
// ── serialization (durable session persistence) ──
internal void Write(BinaryWriter w)
{
w.Write(SessionVersion);
WriteId(w, LocalIdentity);
WriteId(w, RemoteIdentity);
w.Write(LocalRegistrationId);
w.Write(RemoteRegistrationId);
w.Write(RootKey is not null); if (RootKey is not null) w.WriteBlob(RootKey.Key);
w.Write(SenderRatchetKeyPair is not null);
if (SenderRatchetKeyPair is not null) { w.WriteBlob(SenderRatchetKeyPair.PrivateKey); w.WriteBlob(SenderRatchetKeyPair.PublicKey); }
w.Write(SenderChainKey is not null);
if (SenderChainKey is not null) { w.WriteBlob(SenderChainKey.Key); w.Write(SenderChainKey.Index); }
w.Write(PreviousCounter);
w.Write(PendingPreKey is not null); if (PendingPreKey is { } pp) WritePending(w, pp);
w.Write(AliceBaseKey is not null); if (AliceBaseKey is not null) w.WriteBlob(AliceBaseKey);
w.Write(SpqrAuthKey is not null); if (SpqrAuthKey is not null) w.WriteBlob(SpqrAuthKey);
w.Write(Spqr is not null); if (Spqr is not null) w.WriteBlob(Spqr.Serialize());
w.Write(_receiverChains.Count);
foreach (ReceiverChain c in _receiverChains) c.Write(w);
}
internal static SessionState Read(BinaryReader r)
{
var s = new SessionState
{
SessionVersion = r.ReadInt32(),
LocalIdentity = ReadId(r),
RemoteIdentity = ReadId(r),
LocalRegistrationId = r.ReadUInt32(),
RemoteRegistrationId = r.ReadUInt32(),
};
if (r.ReadBoolean()) s.RootKey = new RootKey(r.ReadBlob());
if (r.ReadBoolean()) s.SenderRatchetKeyPair = new ECKeyPair(r.ReadBlob(), r.ReadBlob());
if (r.ReadBoolean()) s.SenderChainKey = new ChainKey(r.ReadBlob(), r.ReadUInt32());
s.PreviousCounter = r.ReadUInt32();
if (r.ReadBoolean()) s.PendingPreKey = ReadPending(r);
if (r.ReadBoolean()) s.AliceBaseKey = r.ReadBlob();
if (r.ReadBoolean()) s.SpqrAuthKey = r.ReadBlob();
if (r.ReadBoolean()) s.Spqr = SpqrRatchet.Deserialize(r.ReadBlob());
int n = r.ReadInt32();
for (int i = 0; i < n; i++) s._receiverChains.AddLast(ReceiverChain.Read(r));
return s;
}
private static void WriteId(BinaryWriter w, IdentityKey? id) { w.Write(id is not null); if (id is not null) w.WriteBlob(id.PublicKey); }
private static IdentityKey? ReadId(BinaryReader r) => r.ReadBoolean() ? new IdentityKey(r.ReadBlob()) : null;
private static void WritePending(BinaryWriter w, PendingPreKey p)
{
w.Write(p.PreKeyId.HasValue); if (p.PreKeyId.HasValue) w.Write(p.PreKeyId.Value);
w.Write(p.SignedPreKeyId);
w.Write(p.KyberPreKeyId.HasValue); if (p.KyberPreKeyId.HasValue) w.Write(p.KyberPreKeyId.Value);
w.Write(p.KyberCiphertext is not null); if (p.KyberCiphertext is not null) w.WriteBlob(p.KyberCiphertext);
w.WriteBlob(p.BaseKey);
}
private static PendingPreKey ReadPending(BinaryReader r)
{
uint? preKeyId = r.ReadBoolean() ? r.ReadUInt32() : null;
uint signedId = r.ReadUInt32();
uint? kyberId = r.ReadBoolean() ? r.ReadUInt32() : null;
byte[]? kyberCt = r.ReadBoolean() ? r.ReadBlob() : null;
byte[] baseKey = r.ReadBlob();
return new PendingPreKey(preKeyId, signedId, kyberId, kyberCt, baseKey);
}
}
+62
View File
@@ -0,0 +1,62 @@
namespace Wingnal.Protocol.State;
/// <summary>
/// Store contracts mirroring libsignal's. Implementations are in-memory (tests) or SQLite-backed
/// (app, added later). Kept synchronous to match libsignal's reference semantics.
/// </summary>
public interface IIdentityKeyStore
{
IdentityKeyPair GetIdentityKeyPair();
uint GetLocalRegistrationId();
/// <summary>Stores a remote identity. Returns true if it replaced a different existing key.</summary>
bool SaveIdentity(SignalProtocolAddress address, IdentityKey identity);
/// <summary>Trust-on-first-use: an identity is trusted if we have none stored for the address yet,
/// or the presented one matches what we stored. A DIFFERENT key for a known address is untrusted
/// (until the user verifies + approves it, which overwrites the stored key via SaveIdentity).</summary>
bool IsTrustedIdentity(SignalProtocolAddress address, IdentityKey identity);
IdentityKey? GetIdentity(SignalProtocolAddress address);
}
public interface IPreKeyStore
{
PreKeyRecord LoadPreKey(uint preKeyId);
void StorePreKey(uint preKeyId, PreKeyRecord record);
bool ContainsPreKey(uint preKeyId);
void RemovePreKey(uint preKeyId);
}
public interface ISignedPreKeyStore
{
SignedPreKeyRecord LoadSignedPreKey(uint signedPreKeyId);
void StoreSignedPreKey(uint signedPreKeyId, SignedPreKeyRecord record);
bool ContainsSignedPreKey(uint signedPreKeyId);
}
public interface IKyberPreKeyStore
{
KyberPreKeyRecord LoadKyberPreKey(uint kyberPreKeyId);
void StoreKyberPreKey(uint kyberPreKeyId, KyberPreKeyRecord record);
bool ContainsKyberPreKey(uint kyberPreKeyId);
void MarkKyberPreKeyUsed(uint kyberPreKeyId);
}
public interface ISessionStore
{
SessionRecord LoadSession(SignalProtocolAddress address);
bool ContainsSession(SignalProtocolAddress address);
void StoreSession(SignalProtocolAddress address, SessionRecord record);
void DeleteSession(SignalProtocolAddress address);
/// <summary>Device ids of <paramref name="name"/> that already have a session (for active-session
/// reuse on send, so we avoid re-fetching a prekey bundle every time).</summary>
IReadOnlyList<uint> GetSubDeviceSessions(string name);
}
/// <summary>The full protocol store an application provides to the session layer.</summary>
public interface ISignalProtocolStore
: IIdentityKeyStore, IPreKeyStore, ISignedPreKeyStore, IKyberPreKeyStore, ISessionStore
{
}