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