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
+85
View File
@@ -0,0 +1,85 @@
using System.Security.Cryptography;
using System.Text;
using Wingnal.Protocol.Crypto;
namespace Wingnal.Protocol.Spqr;
/// <summary>
/// The SPQR authenticator (ported from SparsePostQuantumRatchet v1.5.1 src/authenticator.rs). Keeps a
/// rolling root key + MAC key, advanced each epoch via HKDF, and MACs the chunked header/ciphertext
/// material so a man-in-the-middle can't tamper with the spread-out ML-KEM bytes. HKDF-SHA256 +
/// HMAC-SHA256; the domain-separation strings must match Signal byte-for-byte.
/// </summary>
public sealed class Authenticator
{
public const int MacSize = 32;
private static readonly byte[] ZeroSalt = new byte[32];
private static readonly byte[] UpdateInfo = "Signal_PQCKA_V1_MLKEM768:Authenticator Update"u8.ToArray();
private static readonly byte[] CiphertextLabel = "Signal_PQCKA_V1_MLKEM768:ciphertext"u8.ToArray();
private static readonly byte[] HeaderLabel = "Signal_PQCKA_V1_MLKEM768:ekheader"u8.ToArray();
private byte[] _rootKey = new byte[32];
private byte[] _macKey = new byte[32];
public Authenticator(byte[] rootKey, ulong epoch) => Update(epoch, rootKey);
private Authenticator(byte[] rootKey, byte[] macKey, bool _)
{
_rootKey = (byte[])rootKey.Clone();
_macKey = (byte[])macKey.Clone();
}
public byte[] RootKey => _rootKey;
public byte[] MacKey => _macKey;
/// <summary>Deep copy (used so a failed recv doesn't corrupt committed state).</summary>
public Authenticator Clone() => new(_rootKey, _macKey, true);
internal void Write(System.IO.BinaryWriter w) { w.WriteBlob(_rootKey); w.WriteBlob(_macKey); }
internal static Authenticator Read(System.IO.BinaryReader r) => new(r.ReadBlob(), r.ReadBlob(), true);
public void Update(ulong epoch, byte[] k)
{
byte[] ikm = Concat(_rootKey, k);
byte[] info = Concat(UpdateInfo, Be64(epoch));
byte[] okm = CryptoPrimitives.Hkdf(ikm, ZeroSalt, info, 64);
_rootKey = okm[..32];
_macKey = okm[32..];
}
public byte[] MacCiphertext(ulong epoch, byte[] ciphertext) =>
Mac(CiphertextLabel, epoch, ciphertext);
public byte[] MacHeader(ulong epoch, byte[] header) =>
Mac(HeaderLabel, epoch, header);
public bool VerifyCiphertext(ulong epoch, byte[] ciphertext, byte[] expectedMac) =>
CryptographicOperations.FixedTimeEquals(expectedMac, MacCiphertext(epoch, ciphertext));
public bool VerifyHeader(ulong epoch, byte[] header, byte[] expectedMac) =>
CryptographicOperations.FixedTimeEquals(expectedMac, MacHeader(epoch, header));
private byte[] Mac(byte[] label, ulong epoch, byte[] data)
{
byte[] macData = Concat(label, Be64(epoch), data);
return CryptoPrimitives.HmacSha256(_macKey, macData); // already 32 bytes
}
private static byte[] Be64(ulong v)
{
var b = new byte[8];
for (int i = 7; i >= 0; i--) { b[i] = (byte)(v & 0xFF); v >>= 8; }
return b;
}
private static byte[] Concat(params byte[][] parts)
{
int len = 0;
foreach (byte[] p in parts) len += p.Length;
var result = new byte[len];
int off = 0;
foreach (byte[] p in parts) { Buffer.BlockCopy(p, 0, result, off, p.Length); off += p.Length; }
return result;
}
}
+296
View File
@@ -0,0 +1,296 @@
using System.IO;
using Wingnal.Protocol.Crypto;
namespace Wingnal.Protocol.Spqr;
public enum Direction { A2B, B2A }
public static class DirectionExtensions
{
public static Direction Switch(this Direction d) => d == Direction.A2B ? Direction.B2A : Direction.A2B;
}
/// <summary>A shared secret for a given ratchet epoch.</summary>
public sealed record EpochSecret(ulong Epoch, byte[] Secret);
/// <summary>Bounds for the symmetric chain (out-of-order tolerance / max forward jump).</summary>
public sealed class ChainParams
{
public const uint DefaultMaxJump = 25_000;
public const uint DefaultMaxOooKeys = 2_000;
public uint MaxJump { get; init; } = DefaultMaxJump;
public uint MaxOooKeys { get; init; } = DefaultMaxOooKeys;
internal int TrimSize => (int)((long)MaxOooKeys * 11 / 10 + 1);
}
/// <summary>
/// SPQR symmetric key chain (ported from SparsePostQuantumRatchet v1.5.1 src/chain.rs). Per epoch and
/// direction it maintains a hash chain (HKDF) producing 32-byte keys; <see cref="SendKey"/> advances
/// it and <see cref="RecvKey"/> retrieves keys by index, tolerating out-of-order delivery via a
/// bounded key history. The A2B send chain matches the B2A receive chain (and vice versa).
/// </summary>
public sealed class Chain
{
private static readonly byte[] ZeroSalt = new byte[32];
private static readonly byte[] StartInfo = "Signal PQ Ratchet V1 Chain Start"u8.ToArray(); // two spaces
private static readonly byte[] NextInfo = "Signal PQ Ratchet V1 Chain Next"u8.ToArray();
private static readonly byte[] AddEpochInfo = "Signal PQ Ratchet V1 Chain Add Epoch"u8.ToArray();
private const int EpochsToKeepPriorToSendEpoch = 1;
private sealed class KeyHistory
{
private const int KeySize = 4 + 32;
private readonly List<byte> _data = new();
/// <summary>Raw history bytes, for state serialization.</summary>
public byte[] Data { get => _data.ToArray(); set { _data.Clear(); _data.AddRange(value); } }
public void Add(uint idx, byte[] key)
{
_data.AddRange(Be32(idx));
_data.AddRange(key);
}
public void Clear() => _data.Clear();
public void Gc(uint currentKey, ChainParams p)
{
if (_data.Count < p.TrimSize * KeySize) return;
uint horizon = currentKey - p.MaxOooKeys;
int i = 0;
while (i < _data.Count)
{
uint entryIdx = ReadBe32(i);
if (horizon > entryIdx) RemoveAt(i);
else i += KeySize;
}
}
private void RemoveAt(int index)
{
int newEnd = _data.Count - KeySize;
if (index + KeySize < _data.Count)
for (int k = 0; k < KeySize; k++) _data[index + k] = _data[newEnd + k];
_data.RemoveRange(newEnd, KeySize);
}
public byte[] Get(uint at, uint currentCtr, ChainParams p)
{
if (at + p.MaxOooKeys < currentCtr)
throw new SpqrException($"key trimmed: {at}");
for (int i = 0; i < _data.Count; i += KeySize)
if (ReadBe32(i) == at)
{
var outp = new byte[32];
for (int k = 0; k < 32; k++) outp[k] = _data[i + 4 + k];
RemoveAt(i);
return outp;
}
throw new SpqrException($"key already requested: {at}");
}
private uint ReadBe32(int i) =>
(uint)((_data[i] << 24) | (_data[i + 1] << 16) | (_data[i + 2] << 8) | _data[i + 3]);
}
private sealed class ChainEpochDirection
{
public uint Ctr;
public byte[] Next;
public readonly KeyHistory Prev = new();
public ChainEpochDirection(byte[] k) => Next = (byte[])k.Clone();
public (uint Idx, byte[] Key) NextKey()
{
Ctr += 1;
byte[] info = Concat(Be32(Ctr), NextInfo);
byte[] gen = CryptoPrimitives.Hkdf(Next, ZeroSalt, info, 64);
Next = gen[..32];
return (Ctr, gen[32..64]);
}
public byte[] Key(uint at, ChainParams p)
{
if (at > Ctr)
{
if (at - Ctr > p.MaxJump) throw new SpqrException($"key jump {Ctr} -> {at}");
}
else if (at < Ctr)
{
return Prev.Get(at, Ctr, p);
}
else
{
throw new SpqrException($"key already requested: {at}");
}
if (at > Ctr + p.MaxOooKeys) Prev.Clear();
while (at > Ctr + 1)
{
(uint idx, byte[] k) = NextKey();
if (Ctr + p.MaxOooKeys >= at) Prev.Add(idx, k);
}
Prev.Gc(Ctr, p);
return NextKey().Key;
}
public void ClearNext() => Next = Array.Empty<byte>();
}
private sealed class ChainEpoch
{
public required ChainEpochDirection Send;
public required ChainEpochDirection Recv;
}
private readonly Direction _dir;
private ulong _currentEpoch;
private ulong _sendEpoch;
private readonly LinkedList<ChainEpoch> _links = new();
private byte[] _nextRoot;
private readonly ChainParams _params;
public Chain(byte[] initialKey, Direction dir, ChainParams parameters)
{
_dir = dir;
_params = parameters;
byte[] gen = CryptoPrimitives.Hkdf(initialKey, ZeroSalt, StartInfo, 96);
_nextRoot = gen[0..32];
_links.AddLast(new ChainEpoch
{
Send = CedForDirection(gen, dir),
Recv = CedForDirection(gen, dir.Switch()),
});
}
private static ChainEpochDirection CedForDirection(byte[] gen, Direction dir) =>
new(dir == Direction.A2B ? gen[32..64] : gen[64..96]);
public void AddEpoch(EpochSecret epochSecret)
{
if (epochSecret.Epoch != _currentEpoch + 1)
throw new SpqrException($"epoch must be {_currentEpoch + 1}, got {epochSecret.Epoch}");
byte[] gen = CryptoPrimitives.Hkdf(epochSecret.Secret, _nextRoot, AddEpochInfo, 96);
_currentEpoch = epochSecret.Epoch;
_nextRoot = gen[0..32];
_links.AddLast(new ChainEpoch
{
Send = CedForDirection(gen, _dir),
Recv = CedForDirection(gen, _dir.Switch()),
});
}
private int EpochIdx(ulong epoch)
{
if (epoch > _currentEpoch) throw new SpqrException($"epoch out of range: {epoch}");
int back = (int)(_currentEpoch - epoch);
if (back >= _links.Count) throw new SpqrException($"epoch out of range: {epoch}");
return _links.Count - 1 - back;
}
public (uint Index, byte[] Key) SendKey(ulong epoch)
{
if (epoch < _sendEpoch) throw new SpqrException($"send key epoch decreased {_sendEpoch} -> {epoch}");
int epochIndex = EpochIdx(epoch);
if (_sendEpoch != epoch)
{
_sendEpoch = epoch;
while (epochIndex > EpochsToKeepPriorToSendEpoch)
{
_links.RemoveFirst();
epochIndex--;
}
int i = 0;
foreach (ChainEpoch link in _links)
{
if (i >= epochIndex) break;
link.Send.ClearNext();
i++;
}
}
return LinkAt(epochIndex).Send.NextKey();
}
public byte[] RecvKey(ulong epoch, uint index) => LinkAt(EpochIdx(epoch)).Recv.Key(index, _params);
private ChainEpoch LinkAt(int index)
{
LinkedListNode<ChainEpoch> node = _links.First!;
for (int i = 0; i < index; i++) node = node.Next!;
return node.Value;
}
private static byte[] Be32(uint v) => new[] { (byte)(v >> 24), (byte)(v >> 16), (byte)(v >> 8), (byte)v };
private static byte[] Concat(byte[] a, byte[] b)
{
var r = new byte[a.Length + b.Length];
Buffer.BlockCopy(a, 0, r, 0, a.Length);
Buffer.BlockCopy(b, 0, r, a.Length, b.Length);
return r;
}
// ── serialization (local-only state persistence) ──
private Chain(Direction dir, ChainParams parameters)
{
_dir = dir;
_params = parameters;
_nextRoot = Array.Empty<byte>();
}
internal void Write(BinaryWriter w)
{
w.Write((int)_dir);
w.Write(_currentEpoch);
w.Write(_sendEpoch);
w.WriteBlob(_nextRoot);
w.Write(_params.MaxJump);
w.Write(_params.MaxOooKeys);
w.Write(_links.Count);
foreach (ChainEpoch link in _links) { WriteDir(w, link.Send); WriteDir(w, link.Recv); }
}
private static void WriteDir(BinaryWriter w, ChainEpochDirection d)
{
w.Write(d.Ctr);
w.WriteBlob(d.Next);
w.WriteBlob(d.Prev.Data);
}
internal static Chain Read(BinaryReader r)
{
var dir = (Direction)r.ReadInt32();
ulong cur = r.ReadUInt64();
ulong send = r.ReadUInt64();
byte[] nextRoot = r.ReadBlob();
var p = new ChainParams { MaxJump = r.ReadUInt32(), MaxOooKeys = r.ReadUInt32() };
var chain = new Chain(dir, p);
chain._currentEpoch = cur;
chain._sendEpoch = send;
chain._nextRoot = nextRoot;
int n = r.ReadInt32();
for (int i = 0; i < n; i++)
chain._links.AddLast(new ChainEpoch { Send = ReadDir(r), Recv = ReadDir(r) });
return chain;
}
private static ChainEpochDirection ReadDir(BinaryReader r)
{
uint ctr = r.ReadUInt32();
byte[] next = r.ReadBlob();
byte[] prev = r.ReadBlob();
var d = new ChainEpochDirection(next) { Ctr = ctr };
d.Prev.Data = prev;
return d;
}
}
/// <summary>Errors from the SPQR layer.</summary>
public sealed class SpqrException : Exception
{
public SpqrException(string message) : base(message) { }
}
+66
View File
@@ -0,0 +1,66 @@
namespace Wingnal.Protocol.Spqr;
/// <summary>
/// Arithmetic in GF(2^16) with reduction polynomial 0x1100b, ported from Signal's
/// SparsePostQuantumRatchet (v1.5.1) <c>src/encoding/gf.rs</c>. This is the base field for the
/// Reed-Solomon-style erasure coding that "sparsely" spreads ML-KEM-768 keys/ciphertexts across
/// messages. Addition/subtraction are XOR; multiplication is carryless-multiply then reduce;
/// inversion is a^(2^16-2) via square-and-multiply.
/// </summary>
public readonly struct Gf16 : IEquatable<Gf16>
{
public const uint Poly = 0x1100b;
public static readonly Gf16 Zero = new(0);
public static readonly Gf16 One = new(1);
public ushort Value { get; }
public Gf16(ushort value) => Value = value;
public static Gf16 Add(Gf16 a, Gf16 b) => new((ushort)(a.Value ^ b.Value));
public static Gf16 Sub(Gf16 a, Gf16 b) => new((ushort)(a.Value ^ b.Value));
public static Gf16 Mul(Gf16 a, Gf16 b) => new(PolyReduce(PolyMul(a.Value, b.Value)));
/// <summary>a / b = a * b^(2^16-2). Dividing by zero yields zero (matches the reference loop).</summary>
public static Gf16 Div(Gf16 a, Gf16 b)
{
// out = self * other^(2+4+...+2^15) = self * other^(2^16-2) = self * inv(other).
Gf16 square = b;
Gf16 outp = a;
for (int i = 1; i < 16; i++)
{
square = Mul(square, square);
outp = Mul(outp, square);
}
return outp;
}
public static Gf16 Inv(Gf16 a) => Div(One, a);
/// <summary>Carryless (polynomial) multiply of two 16-bit values into a 32-bit result.</summary>
private static uint PolyMul(ushort a, ushort b)
{
uint acc = 0;
uint me = a;
for (int shift = 0; shift < 16; shift++)
if ((b & (1 << shift)) != 0)
acc ^= me << shift;
return acc;
}
/// <summary>Reduce a 32-bit carryless product modulo POLY (a 17-bit polynomial) to 16 bits.</summary>
private static ushort PolyReduce(uint v)
{
for (int bit = 31; bit >= 16; bit--)
if ((v & (1u << bit)) != 0)
v ^= Poly << (bit - 16);
return (ushort)v;
}
public bool Equals(Gf16 other) => Value == other.Value;
public override bool Equals(object? obj) => obj is Gf16 g && Equals(g);
public override int GetHashCode() => Value;
public override string ToString() => $"GF16({Value})";
}
+735
View File
@@ -0,0 +1,735 @@
using Org.BouncyCastle.Crypto.Digests;
namespace Wingnal.Protocol.Spqr;
/// <summary>
/// Pure-C# FIPS-203 ML-KEM-768 with the "incremental" ek/ciphertext split used by Signal's Sparse
/// Post-Quantum Ratchet (libcrux <c>mlkem768::incremental</c>). The ring arithmetic (q=3329, NTT,
/// zetas, Montgomery/Barrett, basemul, CBD-eta2, matrix gen) is identical to round-3 Kyber-1024
/// (<see cref="Curve.Kyber1024"/>); the differences are k=3, d_u=10 / d_v=4 compression, the FIPS-203
/// hashing (keygen G(d‖k), encaps (K,r)=G(m‖H(ek)), implicit reject J(z‖c)), and NO final KDF.
///
/// Incremental layout (byte-exact with libcrux, confirmed against cryspen/libcrux):
/// keygen → hdr(64) = rho(32)‖H(ek)(32); ek/pk2(1152) = ByteEncode12(t̂); dk(2400) =
/// dkPke(1152)‖ek(1184)‖H(ek)(32)‖z(32), where ek(1184) = ByteEncode12(t̂)‖rho.
/// encaps1(hdr,m) → ct1(960) = Compress_{10}(u); es (local); ss(32) = K.
/// encaps2(ek,es) → ct2(128) = Compress_{4}(v).
/// decaps(dk,ct1,ct2) → ss(32): standard FIPS-203 decaps on ct = ct1‖ct2 (1088).
/// The encapsulation state <c>es</c> is never transmitted, so its format is our own.
/// </summary>
internal static class MlKem768
{
public const int N = 256;
public const int Q = 3329;
public const int K = 3;
public const int Eta = 2; // eta1 == eta2 == 2
public const int SymBytes = 32;
public const byte RankByte = 3; // FIPS-203 domain-separation byte k for ML-KEM-768
public const int PolyBytes = 384;
public const int PolyVecBytes = K * PolyBytes; // 1152 = ByteEncode12(vec)
private const int Du = 10;
private const int Dv = 4;
public const int PolyVecCompressedBytes = K * (N * Du / 8); // 960
public const int PolyCompressedBytes = N * Dv / 8; // 128
public const int EkBytes = PolyVecBytes + SymBytes; // 1184 (full FIPS-203 encapsulation key)
public const int Pk2Bytes = PolyVecBytes; // 1152 (incremental "encapsulation key")
public const int HeaderBytes = 2 * SymBytes; // 64
public const int CiphertextBytes = PolyVecCompressedBytes + PolyCompressedBytes; // 1088
public const int Ct1Bytes = PolyVecCompressedBytes; // 960
public const int Ct2Bytes = PolyCompressedBytes; // 128
public const int DkBytes = PolyVecBytes + EkBytes + SymBytes + SymBytes; // 2400
public const int SsBytes = 32;
private const short QINV = -3327; // q^-1 mod 2^16
private static readonly short[] Zetas =
{
-1044, -758, -359, -1517, 1493, 1422, 287, 202,
-171, 622, 1577, 182, 962, -1202, -1474, 1468,
573, -1325, 264, 383, -829, 1458, -1602, -130,
-681, 1017, 732, 608, -1542, 411, -205, -1571,
1223, 652, -552, 1015, -1293, 1491, -282, -1544,
516, -8, -320, -666, -1618, -1162, 126, 1469,
-853, -90, -271, 830, 107, -1421, -247, -951,
-398, 961, -1508, -725, 448, -1065, 677, -1275,
-1103, 430, 555, 843, -1251, 871, 1550, 105,
422, 587, 177, -235, -291, -460, 1574, 1653,
-246, 778, 1159, -147, -777, 1483, -602, 1119,
-1590, 644, -872, 349, 418, 329, -156, -75,
817, 1097, 603, 610, 1322, -1285, -1465, 384,
-1215, -136, 1218, -1335, -874, 220, -1187, -1659,
-1185, -1530, -1278, 794, -1510, -854, -870, 478,
-108, -308, 996, 991, 958, -1460, 1522, 1628,
};
// ---- reductions ----
private static short MontgomeryReduce(int a)
{
unchecked
{
short t = (short)((short)a * QINV);
return (short)((a - (int)t * Q) >> 16);
}
}
private static short BarrettReduce(short a)
{
unchecked
{
const int v = ((1 << 26) + Q / 2) / Q;
short t = (short)((v * a + (1 << 25)) >> 26);
return (short)(a - (short)(t * Q));
}
}
private static short FqMul(short a, short b) => MontgomeryReduce(a * b);
// ---- NTT ----
private static void Ntt(short[] r)
{
unchecked
{
int k = 1;
for (int len = 128; len >= 2; len >>= 1)
for (int start = 0; start < 256; start += 2 * len)
{
short zeta = Zetas[k++];
for (int j = start; j < start + len; j++)
{
short t = FqMul(zeta, r[j + len]);
r[j + len] = (short)(r[j] - t);
r[j] = (short)(r[j] + t);
}
}
}
}
private static void InvNtt(short[] r)
{
unchecked
{
const short f = 1441; // mont^2/128
int k = 127;
for (int len = 2; len <= 128; len <<= 1)
for (int start = 0; start < 256; start += 2 * len)
{
short zeta = Zetas[k--];
for (int j = start; j < start + len; j++)
{
short t = r[j];
r[j] = BarrettReduce((short)(t + r[j + len]));
r[j + len] = (short)(r[j + len] - t);
r[j + len] = FqMul(zeta, r[j + len]);
}
}
for (int j = 0; j < 256; j++) r[j] = FqMul(r[j], f);
}
}
private static void BaseMul(short[] r, int rOff, short[] a, int aOff, short[] b, int bOff, short zeta)
{
unchecked
{
r[rOff] = FqMul(a[aOff + 1], b[bOff + 1]);
r[rOff] = FqMul(r[rOff], zeta);
r[rOff] = (short)(r[rOff] + FqMul(a[aOff], b[bOff]));
r[rOff + 1] = FqMul(a[aOff], b[bOff + 1]);
r[rOff + 1] = (short)(r[rOff + 1] + FqMul(a[aOff + 1], b[bOff]));
}
}
// ---- hashing ----
private static byte[] Sha3_256(byte[] data, int off, int len)
{
var d = new Sha3Digest(256);
d.BlockUpdate(data, off, len);
var o = new byte[32];
d.DoFinal(o, 0);
return o;
}
private static byte[] Sha3_512(byte[] data)
{
var d = new Sha3Digest(512);
d.BlockUpdate(data, 0, data.Length);
var o = new byte[64];
d.DoFinal(o, 0);
return o;
}
private static byte[] Shake256(byte[] data, int len)
{
var d = new ShakeDigest(256);
d.BlockUpdate(data, 0, data.Length);
var o = new byte[len];
d.Output(o, 0, len);
return o;
}
// ---- CBD (eta = 2) ----
private static uint Load32Le(byte[] x, int off) =>
(uint)(x[off] | (x[off + 1] << 8) | (x[off + 2] << 16) | (x[off + 3] << 24));
private static void Cbd2(short[] r, byte[] buf)
{
unchecked
{
for (int i = 0; i < N / 8; i++)
{
uint t = Load32Le(buf, 4 * i);
uint d = t & 0x55555555u;
d += (t >> 1) & 0x55555555u;
for (int j = 0; j < 8; j++)
{
short a = (short)((d >> (4 * j + 0)) & 0x3);
short b = (short)((d >> (4 * j + 2)) & 0x3);
r[8 * i + j] = (short)(a - b);
}
}
}
}
private static short[] GetNoise(byte[] seed, byte nonce)
{
var extkey = new byte[SymBytes + 1];
Array.Copy(seed, extkey, SymBytes);
extkey[SymBytes] = nonce;
byte[] buf = Shake256(extkey, Eta * N / 4);
var r = new short[N];
Cbd2(r, buf);
return r;
}
// ---- poly (de)serialization: ByteEncode12 / ByteDecode12 ----
private static void PolyToBytes(byte[] r, int rOff, short[] a)
{
unchecked
{
for (int i = 0; i < N / 2; i++)
{
ushort t0 = (ushort)(a[2 * i] + ((a[2 * i] >> 15) & Q));
ushort t1 = (ushort)(a[2 * i + 1] + ((a[2 * i + 1] >> 15) & Q));
r[rOff + 3 * i + 0] = (byte)t0;
r[rOff + 3 * i + 1] = (byte)((t0 >> 8) | (t1 << 4));
r[rOff + 3 * i + 2] = (byte)(t1 >> 4);
}
}
}
private static void PolyFromBytes(short[] r, byte[] a, int aOff)
{
unchecked
{
for (int i = 0; i < N / 2; i++)
{
r[2 * i] = (short)(((a[aOff + 3 * i + 0] >> 0) | (a[aOff + 3 * i + 1] << 8)) & 0xFFF);
r[2 * i + 1] = (short)(((a[aOff + 3 * i + 1] >> 4) | (a[aOff + 3 * i + 2] << 4)) & 0xFFF);
}
}
}
// ---- generic d-bit compression (LSB-first bit packing, FIPS-203 ByteEncode/Compress) ----
private static void CompressPoly(byte[] outBuf, int outOff, short[] a, int d)
{
unchecked
{
uint mask = (1u << d) - 1;
ulong acc = 0;
int bits = 0, pos = outOff;
for (int i = 0; i < N; i++)
{
int u = a[i];
u += (u >> 15) & Q;
uint t = (uint)(((((ulong)u << d) + Q / 2) / Q) & mask);
acc |= (ulong)t << bits;
bits += d;
while (bits >= 8) { outBuf[pos++] = (byte)acc; acc >>= 8; bits -= 8; }
}
}
}
private static short[] DecompressPoly(byte[] a, int aOff, int d)
{
unchecked
{
var r = new short[N];
uint mask = (1u << d) - 1;
ulong acc = 0;
int bits = 0, pos = aOff;
for (int i = 0; i < N; i++)
{
while (bits < d) { acc |= (ulong)a[pos++] << bits; bits += 8; }
uint t = (uint)(acc & mask);
acc >>= d;
bits -= d;
r[i] = (short)(((uint)t * Q + (1u << (d - 1))) >> d);
}
return r;
}
}
private static void PolyFromMsg(short[] r, byte[] msg)
{
unchecked
{
for (int i = 0; i < N / 8; i++)
for (int j = 0; j < 8; j++)
{
short mask = (short)(-(short)((msg[i] >> j) & 1));
r[8 * i + j] = (short)(mask & ((Q + 1) / 2));
}
}
}
private static byte[] PolyToMsg(short[] a)
{
unchecked
{
var msg = new byte[SymBytes];
for (int i = 0; i < N / 8; i++)
{
msg[i] = 0;
for (int j = 0; j < 8; j++)
{
int t = a[8 * i + j];
t += (t >> 15) & Q;
t = (((t << 1) + Q / 2) / Q) & 1;
msg[i] |= (byte)(t << j);
}
}
return msg;
}
}
private static void PolyNtt(short[] r) { Ntt(r); PolyReduce(r); }
private static void PolyBaseMulMont(short[] r, short[] a, short[] b)
{
unchecked
{
for (int i = 0; i < N / 4; i++)
{
BaseMul(r, 4 * i, a, 4 * i, b, 4 * i, Zetas[64 + i]);
BaseMul(r, 4 * i + 2, a, 4 * i + 2, b, 4 * i + 2, (short)(-Zetas[64 + i]));
}
}
}
private static void PolyToMont(short[] r)
{
unchecked
{
const short f = (short)((1L << 32) % Q);
for (int i = 0; i < N; i++) r[i] = MontgomeryReduce(r[i] * f);
}
}
private static void PolyReduce(short[] r) { for (int i = 0; i < N; i++) r[i] = BarrettReduce(r[i]); }
private static void PolyAdd(short[] r, short[] a, short[] b) { unchecked { for (int i = 0; i < N; i++) r[i] = (short)(a[i] + b[i]); } }
private static void PolySub(short[] r, short[] a, short[] b) { unchecked { for (int i = 0; i < N; i++) r[i] = (short)(a[i] - b[i]); } }
// ---- polyvec ----
private static short[][] NewPolyVec()
{
var v = new short[K][];
for (int i = 0; i < K; i++) v[i] = new short[N];
return v;
}
private static void PolyVecToBytes(byte[] r, int rOff, short[][] a)
{
for (int i = 0; i < K; i++) PolyToBytes(r, rOff + i * PolyBytes, a[i]);
}
private static short[][] PolyVecFromBytes(byte[] a, int aOff)
{
var r = NewPolyVec();
for (int i = 0; i < K; i++) PolyFromBytes(r[i], a, aOff + i * PolyBytes);
return r;
}
private static void PolyVecCompress(byte[] r, int rOff, short[][] a)
{
for (int i = 0; i < K; i++) CompressPoly(r, rOff + i * (N * Du / 8), a[i], Du);
}
private static short[][] PolyVecDecompress(byte[] a, int aOff)
{
var r = NewPolyVec();
for (int i = 0; i < K; i++) r[i] = DecompressPoly(a, aOff + i * (N * Du / 8), Du);
return r;
}
private static void PolyVecNtt(short[][] r) { for (int i = 0; i < K; i++) PolyNtt(r[i]); }
private static void PolyVecBaseMulAccMont(short[] r, short[][] a, short[][] b)
{
var t = new short[N];
PolyBaseMulMont(r, a[0], b[0]);
for (int i = 1; i < K; i++) { PolyBaseMulMont(t, a[i], b[i]); PolyAdd(r, r, t); }
PolyReduce(r);
}
private static void PolyVecReduce(short[][] r) { for (int i = 0; i < K; i++) PolyReduce(r[i]); }
private static void PolyVecAdd(short[][] r, short[][] a, short[][] b) { for (int i = 0; i < K; i++) PolyAdd(r[i], a[i], b[i]); }
// ---- matrix generation (identical to round-3 Kyber: SHAKE128 rejection sampling) ----
private const int XofBlockBytes = 168;
private const int GenMatrixNBlocks = (12 * N / 8 * (1 << 12) / Q + XofBlockBytes) / XofBlockBytes;
private static int RejUniform(short[] r, int rOff, int len, byte[] buf, int buflen)
{
unchecked
{
int ctr = 0, pos = 0;
while (ctr < len && pos + 3 <= buflen)
{
ushort val0 = (ushort)(((buf[pos + 0] >> 0) | (buf[pos + 1] << 8)) & 0xFFF);
ushort val1 = (ushort)(((buf[pos + 1] >> 4) | (buf[pos + 2] << 4)) & 0xFFF);
pos += 3;
if (val0 < Q) r[rOff + ctr++] = (short)val0;
if (ctr < len && val1 < Q) r[rOff + ctr++] = (short)val1;
}
return ctr;
}
}
private static short[][][] GenMatrix(byte[] seed, bool transposed)
{
var a = new short[K][][];
for (int i = 0; i < K; i++)
{
a[i] = NewPolyVec();
for (int j = 0; j < K; j++)
{
var extseed = new byte[SymBytes + 2];
Array.Copy(seed, extseed, SymBytes);
extseed[SymBytes] = (byte)(transposed ? i : j);
extseed[SymBytes + 1] = (byte)(transposed ? j : i);
var xof = new ShakeDigest(128);
xof.BlockUpdate(extseed, 0, extseed.Length);
var buf = new byte[GenMatrixNBlocks * XofBlockBytes + 2];
xof.Output(buf, 0, GenMatrixNBlocks * XofBlockBytes);
int buflen = GenMatrixNBlocks * XofBlockBytes;
int ctr = RejUniform(a[i][j], 0, N, buf, buflen);
while (ctr < N)
{
int off = buflen % 3;
for (int k = 0; k < off; k++) buf[k] = buf[buflen - off + k];
xof.Output(buf, off, XofBlockBytes);
buflen = off + XofBlockBytes;
ctr += RejUniform(a[i][j], ctr, N - ctr, buf, buflen);
}
}
}
return a;
}
// ---- K-PKE keygen (FIPS-203) ----
private static void KPkeKeygen(byte[] d, out short[][] skpv, out short[][] pkpv, out byte[] rho)
{
var dk = new byte[SymBytes + 1];
Array.Copy(d, dk, SymBytes);
dk[SymBytes] = RankByte; // FIPS-203: (rho,sigma) = G(d || k)
byte[] buf = Sha3_512(dk);
rho = buf[..SymBytes];
var sigma = buf[SymBytes..];
// Keygen samples A[i][j] from XOF(rho, j, i) (pq-crystals gen_a, transposed:false); encrypt
// uses the transpose Aᵀ (transposed:true). Identical convention to round-3 Kyber.
short[][][] a = GenMatrix(rho, transposed: false);
skpv = NewPolyVec();
var e = NewPolyVec();
byte nonce = 0;
for (int i = 0; i < K; i++) skpv[i] = GetNoise(sigma, nonce++);
for (int i = 0; i < K; i++) e[i] = GetNoise(sigma, nonce++);
PolyVecNtt(skpv);
PolyVecNtt(e);
pkpv = NewPolyVec();
for (int i = 0; i < K; i++)
{
PolyVecBaseMulAccMont(pkpv[i], a[i], skpv);
PolyToMont(pkpv[i]);
}
PolyVecAdd(pkpv, pkpv, e);
PolyVecReduce(pkpv);
}
// ---- standard FIPS-203 API (for KAT) ----
/// <summary>FIPS-203 ML-KEM.KeyGen_internal(d, z) → (ek 1184, dk 2400).</summary>
public static void KeyGen(byte[] d, byte[] z, out byte[] ek, out byte[] dk)
{
KPkeKeygen(d, out short[][] skpv, out short[][] pkpv, out byte[] rho);
ek = new byte[EkBytes];
PolyVecToBytes(ek, 0, pkpv);
Array.Copy(rho, 0, ek, PolyVecBytes, SymBytes);
dk = new byte[DkBytes];
PolyVecToBytes(dk, 0, skpv); // dkPke = ByteEncode12(s)
Array.Copy(ek, 0, dk, PolyVecBytes, EkBytes); // ek
byte[] hek = Sha3_256(ek, 0, EkBytes);
Array.Copy(hek, 0, dk, PolyVecBytes + EkBytes, SymBytes); // H(ek)
Array.Copy(z, 0, dk, PolyVecBytes + EkBytes + SymBytes, SymBytes); // z
}
/// <summary>K-PKE encryption split into the c1 (u, compressed du) and c2 (v, compressed dv) halves.
/// Pass <paramref name="tHat"/> = null to skip c2 (incremental encaps1).</summary>
private static void KPkeEncrypt(byte[] rho, short[][]? tHat, byte[] msg, byte[] coins,
short[][] rOut, out short[] e2Out, byte[]? ct1, byte[]? ct2)
{
short[][][] at = GenMatrix(rho, transposed: true); // Aᵀ for encrypt (see KPkeKeygen note)
var sp = NewPolyVec();
var ep = NewPolyVec();
byte nonce = 0;
for (int i = 0; i < K; i++) sp[i] = GetNoise(coins, nonce++);
for (int i = 0; i < K; i++) ep[i] = GetNoise(coins, nonce++);
short[] epp = GetNoise(coins, nonce);
PolyVecNtt(sp);
for (int i = 0; i < K; i++) rOut[i] = (short[])sp[i].Clone(); // r̂ saved for encaps2
e2Out = epp;
if (ct1 is not null)
{
var b = NewPolyVec();
for (int i = 0; i < K; i++) PolyVecBaseMulAccMont(b[i], at[i], sp);
for (int i = 0; i < K; i++) InvNtt(b[i]);
PolyVecAdd(b, b, ep);
PolyVecReduce(b);
PolyVecCompress(ct1, 0, b);
}
if (ct2 is not null && tHat is not null)
{
var v = new short[N];
PolyVecBaseMulAccMont(v, tHat, sp);
InvNtt(v);
var k = new short[N];
PolyFromMsg(k, msg);
PolyAdd(v, v, epp);
PolyAdd(v, v, k);
PolyReduce(v);
CompressPoly(ct2, 0, v, Dv);
}
}
/// <summary>FIPS-203 ML-KEM.Encaps_internal(ek, m) → (c 1088, ss 32). For KAT / standard use.</summary>
public static void Encaps(byte[] ek, byte[] m, out byte[] ct, out byte[] ss)
{
byte[] hek = Sha3_256(ek, 0, EkBytes);
byte[] g = Sha3_512(Concat(m, hek));
ss = g[..SymBytes];
byte[] coins = g[SymBytes..];
byte[] rho = ek[PolyVecBytes..EkBytes];
short[][] tHat = PolyVecFromBytes(ek, 0);
var rHat = new short[K][];
ct = new byte[CiphertextBytes];
var ct1 = new byte[Ct1Bytes];
var ct2 = new byte[Ct2Bytes];
KPkeEncrypt(rho, tHat, m, coins, rHat, out _, ct1, ct2);
Array.Copy(ct1, 0, ct, 0, Ct1Bytes);
Array.Copy(ct2, 0, ct, Ct1Bytes, Ct2Bytes);
}
/// <summary>FIPS-203 ML-KEM.Decaps(dk, c) → ss 32 (c = ct1‖ct2).</summary>
public static byte[] Decaps(byte[] dk, byte[] ct1, byte[] ct2)
{
var dkPke = dk[..PolyVecBytes];
var ek = new byte[EkBytes];
Array.Copy(dk, PolyVecBytes, ek, 0, EkBytes);
var hek = new byte[SymBytes];
Array.Copy(dk, PolyVecBytes + EkBytes, hek, 0, SymBytes);
var z = new byte[SymBytes];
Array.Copy(dk, PolyVecBytes + EkBytes + SymBytes, z, 0, SymBytes);
// K-PKE.Decrypt
short[][] u = PolyVecDecompress(ct1, 0);
short[] v = DecompressPoly(ct2, 0, Dv);
short[][] skpv = PolyVecFromBytes(dkPke, 0);
PolyVecNtt(u);
var mp = new short[N];
PolyVecBaseMulAccMont(mp, skpv, u);
InvNtt(mp);
PolySub(mp, v, mp);
PolyReduce(mp);
byte[] mPrime = PolyToMsg(mp);
// (K', r') = G(m' || H(ek))
byte[] g = Sha3_512(Concat(mPrime, hek));
byte[] kPrime = g[..SymBytes];
byte[] coins = g[SymBytes..];
// re-encrypt and compare
byte[] rho = ek[PolyVecBytes..EkBytes];
short[][] tHat = PolyVecFromBytes(ek, 0);
var rHat = new short[K][];
var ct1Cmp = new byte[Ct1Bytes];
var ct2Cmp = new byte[Ct2Bytes];
KPkeEncrypt(rho, tHat, mPrime, coins, rHat, out _, ct1Cmp, ct2Cmp);
int fail = Verify(ct1, ct1Cmp) | Verify(ct2, ct2Cmp);
// implicit reject: K_bar = J(z || c)
byte[] kBar = Shake256(Concat(z, Concat(ct1, ct2)), SsBytes);
var outSs = new byte[SsBytes];
CMov(outSs, kPrime, kBar, (byte)fail);
return outSs;
}
// ---- incremental API ----
public sealed class Keys
{
public required byte[] Header { get; init; } // 64
public required byte[] Ek { get; init; } // 1152 (pk2)
public required byte[] Dk { get; init; } // 2400
}
/// <summary>Incremental keygen → header (rho‖H(ek)), ek/pk2 (ByteEncode12(t̂)), dk.</summary>
public static Keys Generate(byte[] d, byte[] z)
{
KeyGen(d, z, out byte[] ekFull, out byte[] dk);
var header = new byte[HeaderBytes];
Array.Copy(ekFull, PolyVecBytes, header, 0, SymBytes); // rho
byte[] hek = Sha3_256(ekFull, 0, EkBytes);
Array.Copy(hek, 0, header, SymBytes, SymBytes); // H(ek)
var pk2 = new byte[Pk2Bytes];
Array.Copy(ekFull, 0, pk2, 0, Pk2Bytes); // ByteEncode12(t̂)
return new Keys { Header = header, Ek = pk2, Dk = dk };
}
/// <summary>Validates that an encapsulation key (pk2) matches a header: H(pk2‖rho) == hdr hash.</summary>
public static bool EkMatchesHeader(byte[] pk2, byte[] header)
{
if (pk2.Length != Pk2Bytes || header.Length != HeaderBytes) return false;
var ekFull = new byte[EkBytes];
Array.Copy(pk2, 0, ekFull, 0, Pk2Bytes);
Array.Copy(header, 0, ekFull, PolyVecBytes, SymBytes); // rho from header
byte[] hek = Sha3_256(ekFull, 0, EkBytes);
return CryptographicEquals(hek, header.AsSpan(SymBytes, SymBytes));
}
/// <summary>encaps1(hdr, m) → (ct1 960, es, ss 32). The shared secret is available immediately.</summary>
public static void Encaps1(byte[] header, byte[] m, out byte[] ct1, out byte[] es, out byte[] ss)
{
byte[] rho = header[..SymBytes];
byte[] hek = header[SymBytes..HeaderBytes];
byte[] g = Sha3_512(Concat(m, hek));
ss = g[..SymBytes];
byte[] coins = g[SymBytes..];
var rHat = new short[K][];
ct1 = new byte[Ct1Bytes];
KPkeEncrypt(rho, tHat: null, m, coins, rHat, out short[] e2, ct1, ct2: null);
es = SerializeState(rHat, e2, m);
}
/// <summary>encaps2(ek/pk2, es) → ct2 128.</summary>
public static byte[] Encaps2(byte[] pk2, byte[] es)
{
DeserializeState(es, out short[][] rHat, out short[] e2, out byte[] m);
short[][] tHat = PolyVecFromBytes(pk2, 0);
var v = new short[N];
PolyVecBaseMulAccMont(v, tHat, rHat);
InvNtt(v);
var k = new short[N];
PolyFromMsg(k, m);
PolyAdd(v, v, e2);
PolyAdd(v, v, k);
PolyReduce(v);
var ct2 = new byte[Ct2Bytes];
CompressPoly(ct2, 0, v, Dv);
return ct2;
}
/// <summary>decaps(dk, ct1, ct2) → ss 32.</summary>
public static byte[] DecapsIncremental(byte[] dk, byte[] ct1, byte[] ct2) => Decaps(dk, ct1, ct2);
// ---- local encapsulation-state serialization (never transmitted; our own format) ----
// es = r̂ (K*N int16 LE) || e2 (N int16 LE) || m (32)
private static byte[] SerializeState(short[][] rHat, short[] e2, byte[] m)
{
var es = new byte[(K * N + N) * 2 + SymBytes];
int p = 0;
for (int i = 0; i < K; i++)
for (int j = 0; j < N; j++) { es[p++] = (byte)rHat[i][j]; es[p++] = (byte)(rHat[i][j] >> 8); }
for (int j = 0; j < N; j++) { es[p++] = (byte)e2[j]; es[p++] = (byte)(e2[j] >> 8); }
Array.Copy(m, 0, es, p, SymBytes);
return es;
}
private static void DeserializeState(byte[] es, out short[][] rHat, out short[] e2, out byte[] m)
{
rHat = NewPolyVec();
e2 = new short[N];
int p = 0;
for (int i = 0; i < K; i++)
for (int j = 0; j < N; j++) { rHat[i][j] = (short)(es[p] | (es[p + 1] << 8)); p += 2; }
for (int j = 0; j < N; j++) { e2[j] = (short)(es[p] | (es[p + 1] << 8)); p += 2; }
m = new byte[SymBytes];
Array.Copy(es, p, m, 0, SymBytes);
}
// ---- helpers ----
private static byte[] Concat(byte[] a, byte[] b)
{
var r = new byte[a.Length + b.Length];
Buffer.BlockCopy(a, 0, r, 0, a.Length);
Buffer.BlockCopy(b, 0, r, a.Length, b.Length);
return r;
}
private static int Verify(byte[] a, byte[] b)
{
unchecked
{
byte r = 0;
for (int i = 0; i < a.Length; i++) r |= (byte)(a[i] ^ b[i]);
return (int)((ulong)(0 - (ulong)r) >> 63);
}
}
private static void CMov(byte[] dst, byte[] good, byte[] bad, byte fail)
{
unchecked
{
byte mask = (byte)(-(sbyte)fail); // fail==1 -> 0xFF (use bad), fail==0 -> 0x00 (use good)
for (int i = 0; i < dst.Length; i++)
dst[i] = (byte)(good[i] ^ (mask & (good[i] ^ bad[i])));
}
}
private static bool CryptographicEquals(byte[] a, ReadOnlySpan<byte> b)
{
if (a.Length != b.Length) return false;
int r = 0;
for (int i = 0; i < a.Length; i++) r |= a[i] ^ b[i];
return r == 0;
}
}
+254
View File
@@ -0,0 +1,254 @@
using System.IO;
namespace Wingnal.Protocol.Spqr;
/// <summary>
/// Polynomial fountain/erasure code over GF(2^16), ported from Signal's SparsePostQuantumRatchet
/// v1.5.1 <c>src/encoding/polynomial.rs</c>. A message is split into <see cref="NumPolys"/>=16
/// polynomials (one per 2-byte symbol position, round-robin); chunk <c>idx</c> carries each
/// polynomial evaluated at x=idx (16 symbols = 32 bytes). The first ⌈M/16⌉ chunks are the message
/// itself (systematic); later chunks are redundancy. The decoder Lagrange-interpolates each
/// polynomial once it has enough points. Used to spread large ML-KEM-768 keys/ciphertexts across
/// many ratchet messages.
/// </summary>
public static class Polynomial
{
public const int ChunkSize = 32;
public const int NumPolys = ChunkSize / 2; // 16
/// <summary>A polynomial over GF(2^16), coefficients low-order first (coeff[0] = constant term).</summary>
public sealed class Poly
{
public Gf16[] Coefficients { get; }
public Poly(Gf16[] coefficients) => Coefficients = coefficients;
/// <summary>Evaluate f(x) via Horner's method.</summary>
public Gf16 Evaluate(Gf16 x)
{
Gf16 acc = Gf16.Zero;
for (int i = Coefficients.Length - 1; i >= 0; i--)
acc = Gf16.Add(Gf16.Mul(acc, x), Coefficients[i]);
return acc;
}
/// <summary>The unique polynomial through the given (x,y) points (distinct x). Standard Lagrange.</summary>
public static Poly Interpolate((Gf16 X, Gf16 Y)[] points)
{
int n = points.Length;
var coeffs = new Gf16[n];
for (int i = 0; i < n; i++) coeffs[i] = Gf16.Zero;
if (n == 0) return new Poly(coeffs);
for (int i = 0; i < n; i++)
{
// basis_i(x) = PRODUCT_{m != i} (x - x_m) / (x_i - x_m), scaled by y_i.
var basis = new Gf16[n];
basis[0] = Gf16.One;
int deg = 0;
Gf16 denom = Gf16.One;
for (int m = 0; m < n; m++)
{
if (m == i) continue;
// multiply basis by (x - x_m): basis = basis*x + basis*(-x_m). (-x_m == x_m in GF(2^k))
var next = new Gf16[n];
for (int k = deg; k >= 0; k--)
{
// contribute basis[k] * x -> next[k+1]
next[k + 1] = Gf16.Add(next[k + 1], basis[k]);
// contribute basis[k] * x_m -> next[k]
next[k] = Gf16.Add(next[k], Gf16.Mul(basis[k], points[m].X));
}
basis = next;
deg++;
denom = Gf16.Mul(denom, Gf16.Sub(points[i].X, points[m].X));
}
Gf16 scale = Gf16.Mul(points[i].Y, Gf16.Inv(denom));
for (int k = 0; k < n; k++)
coeffs[k] = Gf16.Add(coeffs[k], Gf16.Mul(basis[k], scale));
}
return new Poly(coeffs);
}
}
/// <summary>Encodes a message (even length) into an unbounded stream of 32-byte indexed chunks.</summary>
public sealed class Encoder
{
private readonly List<Gf16>[] _data = new List<Gf16>[NumPolys];
private readonly Poly?[] _polys = new Poly?[NumPolys];
private uint _nextIdx;
public Encoder(byte[] message)
{
if (message.Length % 2 != 0)
throw new ArgumentException("message length must be even", nameof(message));
for (int j = 0; j < NumPolys; j++) _data[j] = new List<Gf16>();
for (int i = 0; i < message.Length / 2; i++)
{
ushort v = (ushort)((message[2 * i] << 8) | message[2 * i + 1]);
_data[i % NumPolys].Add(new Gf16(v));
}
}
public (ushort Index, byte[] Data) NextChunk()
{
ushort idx = (ushort)_nextIdx;
_nextIdx++;
return ChunkAt(idx);
}
public (ushort Index, byte[] Data) ChunkAt(ushort idx)
{
var data = new byte[ChunkSize];
for (int j = 0; j < NumPolys; j++)
{
Gf16 v = PointAt(j, idx);
data[2 * j] = (byte)(v.Value >> 8);
data[2 * j + 1] = (byte)v.Value;
}
return (idx, data);
}
private Gf16 PointAt(int poly, int idx)
{
List<Gf16> pts = _data[poly];
if (idx < pts.Count)
return pts[idx]; // systematic: original data value
// Redundancy point: interpolate (cached) and evaluate.
Poly p = _polys[poly] ??= BuildPoly(pts);
return p.Evaluate(new Gf16((ushort)idx));
}
private static Poly BuildPoly(List<Gf16> values)
{
var points = new (Gf16, Gf16)[values.Count];
for (int x = 0; x < values.Count; x++)
points[x] = (new Gf16((ushort)x), values[x]);
return Poly.Interpolate(points);
}
private Encoder() { for (int j = 0; j < NumPolys; j++) _data[j] = new List<Gf16>(); }
internal void Write(BinaryWriter w)
{
w.Write(_nextIdx);
for (int j = 0; j < NumPolys; j++)
{
w.Write(_data[j].Count);
foreach (Gf16 g in _data[j]) w.Write(g.Value);
}
}
internal static Encoder Read(BinaryReader r)
{
var e = new Encoder();
e._nextIdx = r.ReadUInt32();
for (int j = 0; j < NumPolys; j++)
{
int c = r.ReadInt32();
for (int k = 0; k < c; k++) e._data[j].Add(new Gf16(r.ReadUInt16()));
}
return e;
}
}
/// <summary>Collects chunks until it can reconstruct the original <paramref name="lenBytes"/>-byte message.</summary>
public sealed class Decoder
{
private readonly int _lenBytes;
private readonly int _symbolCount; // M = lenBytes/2 GF16 symbols
private readonly int[] _pointsPerPoly = new int[NumPolys];
private readonly Dictionary<ushort, Gf16[]> _chunks = new(); // idx -> 16 symbols
public Decoder(int lenBytes)
{
if (lenBytes % 2 != 0) throw new ArgumentException("length must be even", nameof(lenBytes));
_lenBytes = lenBytes;
_symbolCount = lenBytes / 2;
for (int i = 0; i < _symbolCount; i++)
_pointsPerPoly[i % NumPolys]++;
}
public void AddChunk(ushort index, byte[] data)
{
if (data.Length != ChunkSize) throw new ArgumentException("chunk must be 32 bytes", nameof(data));
var symbols = new Gf16[NumPolys];
for (int j = 0; j < NumPolys; j++)
symbols[j] = new Gf16((ushort)((data[2 * j] << 8) | data[2 * j + 1]));
_chunks[index] = symbols;
}
public bool CanReconstruct()
{
for (int j = 0; j < NumPolys; j++)
if (_chunks.Count < _pointsPerPoly[j])
return false;
// Every poly needs pointsPerPoly[j] distinct x; we have _chunks.Count distinct indices,
// and max points-per-poly <= _chunks.Count is the binding constraint.
return _chunks.Count >= MaxPoints();
}
public byte[]? DecodedMessage()
{
if (!CanReconstruct()) return null;
// Sort received indices for determinism; use the first N for each poly.
ushort[] indices = _chunks.Keys.OrderBy(k => k).ToArray();
var msg = new byte[_lenBytes];
for (int j = 0; j < NumPolys; j++)
{
int need = _pointsPerPoly[j];
if (need == 0) continue;
var points = new (Gf16, Gf16)[need];
for (int k = 0; k < need; k++)
{
ushort idx = indices[k];
points[k] = (new Gf16(idx), _chunks[idx][j]);
}
Poly p = Poly.Interpolate(points);
for (int x = 0; x < need; x++)
{
int pos = x * NumPolys + j; // message symbol position
if (pos >= _symbolCount) break;
Gf16 v = x < need ? p.Evaluate(new Gf16((ushort)x)) : Gf16.Zero;
msg[2 * pos] = (byte)(v.Value >> 8);
msg[2 * pos + 1] = (byte)v.Value;
}
}
return msg;
}
private int MaxPoints()
{
int max = 0;
for (int j = 0; j < NumPolys; j++) max = Math.Max(max, _pointsPerPoly[j]);
return max;
}
internal void Write(BinaryWriter w)
{
w.Write(_lenBytes);
w.Write(_chunks.Count);
foreach (KeyValuePair<ushort, Gf16[]> kv in _chunks)
{
w.Write(kv.Key);
foreach (Gf16 g in kv.Value) w.Write(g.Value);
}
}
internal static Decoder Read(BinaryReader r)
{
var d = new Decoder(r.ReadInt32());
int n = r.ReadInt32();
for (int i = 0; i < n; i++)
{
ushort idx = r.ReadUInt16();
var syms = new Gf16[NumPolys];
for (int j = 0; j < NumPolys; j++) syms[j] = new Gf16(r.ReadUInt16());
d._chunks[idx] = syms;
}
return d;
}
}
}
+499
View File
@@ -0,0 +1,499 @@
using System.IO;
using Enc = Wingnal.Protocol.Spqr.Polynomial.Encoder;
using Dec = Wingnal.Protocol.Spqr.Polynomial.Decoder;
namespace Wingnal.Protocol.Spqr;
/// <summary>A 32-byte fountain-code chunk with its index.</summary>
public sealed class SpqrChunk
{
public ushort Index { get; }
public byte[] Data { get; } // 32
public SpqrChunk(ushort index, byte[] data) { Index = index; Data = data; }
}
public enum SpqrMsgKind { None, Hdr, Ek, EkCt1Ack, Ct1Ack, Ct1, Ct2 }
/// <summary>The SPQR per-message payload (one of the V1Msg inner_msg variants).</summary>
public sealed class SpqrPayload
{
public SpqrMsgKind Kind { get; }
public SpqrChunk? Chunk { get; }
public bool Ack { get; }
private SpqrPayload(SpqrMsgKind kind, SpqrChunk? chunk, bool ack) { Kind = kind; Chunk = chunk; Ack = ack; }
public static readonly SpqrPayload None = new(SpqrMsgKind.None, null, false);
public static SpqrPayload Hdr(SpqrChunk c) => new(SpqrMsgKind.Hdr, c, false);
public static SpqrPayload Ek(SpqrChunk c) => new(SpqrMsgKind.Ek, c, false);
public static SpqrPayload EkCt1Ack(SpqrChunk c) => new(SpqrMsgKind.EkCt1Ack, c, false);
public static SpqrPayload Ct1Ack(bool ack) => new(SpqrMsgKind.Ct1Ack, null, ack);
public static SpqrPayload Ct1(SpqrChunk c) => new(SpqrMsgKind.Ct1, c, false);
public static SpqrPayload Ct2(SpqrChunk c) => new(SpqrMsgKind.Ct2, c, false);
}
/// <summary>An SCKA message: an epoch plus a payload.</summary>
public sealed class SpqrMessage
{
public ulong Epoch { get; }
public SpqrPayload Payload { get; }
public SpqrMessage(ulong epoch, SpqrPayload payload) { Epoch = epoch; Payload = payload; }
}
/// <summary>
/// The SPQR v1 chunked SCKA state machine (ported from SparsePostQuantumRatchet v1.5.1
/// src/v1/chunked/{states,send_ek,send_ct}.rs). It wraps the unchunked crypto states with polynomial
/// encoders/decoders so the large ML-KEM-768 header/ek/ct blobs are spread across many messages.
/// <see cref="Send"/> emits the next chunk (and possibly a new EpochSecret); <see cref="Recv"/> ingests
/// a peer chunk and advances state. Header/ct chunks carry an authenticator MAC appended to the blob.
/// </summary>
public sealed class SckaStates
{
private const int HeaderSize = MlKem768.HeaderBytes; // 64
private const int MacSize = Authenticator.MacSize; // 32
private const int Ct1Size = MlKem768.Ct1Bytes; // 960
private const int Ct2Size = MlKem768.Ct2Bytes; // 128
private const int EkSize = MlKem768.Pk2Bytes; // 1152
public sealed class SendResult
{
public required SpqrMessage Msg { get; init; }
public EpochSecret? Key { get; init; }
public required SckaStates State { get; init; }
}
public sealed class RecvResult
{
public EpochSecret? Key { get; init; }
public required SckaStates State { get; init; }
}
// Exactly one of these is non-null (mirrors the Rust States enum).
private readonly object _inner;
private SckaStates(object inner) => _inner = inner;
public static SckaStates InitA(byte[] authKey) =>
new(new CKeysUnsampled(UcKeysUnsampled.New(authKey)));
public static SckaStates InitB(byte[] authKey) =>
new(new CNoHeaderReceived(UcNoHeaderReceived.New(authKey),
new Dec(HeaderSize + MacSize)));
private static ulong EpochOf(object s) => s switch
{
CKeysUnsampled x => x.Uc.Epoch,
CKeysSampled x => x.Uc.Epoch,
CHeaderSent x => x.Uc.Epoch,
CCt1Received x => x.Uc.Epoch,
CEkSentCt1Received x => x.Uc.Epoch,
CNoHeaderReceived x => x.Uc.Epoch,
CHeaderReceived x => x.Uc.Epoch,
CCt1Sampled x => x.Uc.Epoch,
CEkReceivedCt1Sampled x => x.Uc.Epoch,
CCt1Acknowledged x => x.Uc.Epoch,
CCt2Sampled x => x.Uc.Epoch,
_ => throw new SpqrException("unknown state"),
};
// ───────────────────────── send ─────────────────────────
public SendResult Send()
{
switch (_inner)
{
// send_ek
case CKeysUnsampled s:
{
ulong epoch = s.Uc.Epoch;
(UcHeaderSent uc, byte[] hdr, byte[] mac) = s.Uc.SendHeader();
var enc = new Enc(Cat(hdr, mac));
SpqrChunk chunk = Next(enc);
return new SendResult
{
Msg = new SpqrMessage(epoch, SpqrPayload.Hdr(chunk)),
State = new SckaStates(new CKeysSampled(uc, enc)),
};
}
case CKeysSampled s:
{
SpqrChunk chunk = Next(s.SendingHdr);
return new SendResult
{
Msg = new SpqrMessage(s.Uc.Epoch, SpqrPayload.Hdr(chunk)),
State = this,
};
}
case CHeaderSent s:
{
SpqrChunk chunk = Next(s.SendingEk);
return new SendResult
{
Msg = new SpqrMessage(s.Uc.Epoch, SpqrPayload.Ek(chunk)),
State = this,
};
}
case CCt1Received s:
{
SpqrChunk chunk = Next(s.SendingEk);
return new SendResult
{
Msg = new SpqrMessage(s.Uc.Epoch, SpqrPayload.EkCt1Ack(chunk)),
State = this,
};
}
case CEkSentCt1Received s:
{
return new SendResult
{
Msg = new SpqrMessage(s.Uc.Epoch, SpqrPayload.Ct1Ack(true)),
State = this,
};
}
// send_ct
case CNoHeaderReceived s:
{
return new SendResult
{
Msg = new SpqrMessage(s.Uc.Epoch, SpqrPayload.None),
State = this,
};
}
case CHeaderReceived s:
{
ulong epoch = s.Uc.Epoch;
(UcCt1Sent uc, byte[] ct1, EpochSecret secret) = s.Uc.SendCt1();
var enc = new Enc(ct1);
SpqrChunk chunk = Next(enc);
return new SendResult
{
Msg = new SpqrMessage(epoch, SpqrPayload.Ct1(chunk)),
Key = secret,
State = new SckaStates(new CCt1Sampled(uc, enc, s.ReceivingEk)),
};
}
case CCt1Sampled s:
{
SpqrChunk chunk = Next(s.SendingCt1);
return new SendResult
{
Msg = new SpqrMessage(s.Uc.Epoch, SpqrPayload.Ct1(chunk)),
State = this,
};
}
case CEkReceivedCt1Sampled s:
{
SpqrChunk chunk = Next(s.SendingCt1);
return new SendResult
{
Msg = new SpqrMessage(s.Uc.Epoch, SpqrPayload.Ct1(chunk)),
State = this,
};
}
case CCt1Acknowledged s:
{
return new SendResult
{
Msg = new SpqrMessage(s.Uc.Epoch, SpqrPayload.None),
State = this,
};
}
case CCt2Sampled s:
{
SpqrChunk chunk = Next(s.SendingCt2);
return new SendResult
{
Msg = new SpqrMessage(s.Uc.Epoch, SpqrPayload.Ct2(chunk)),
State = this,
};
}
default: throw new SpqrException("unknown state");
}
}
// ───────────────────────── recv ─────────────────────────
public RecvResult Recv(SpqrMessage msg)
{
EpochSecret? key = null;
object newState;
ulong epoch = EpochOf(_inner);
switch (_inner)
{
// send_ek
case CKeysUnsampled s:
RequireNotGreater(msg.Epoch, epoch);
newState = s; // Less or Equal: stay
break;
case CKeysSampled s:
RequireNotGreater(msg.Epoch, epoch);
if (msg.Epoch == epoch && msg.Payload.Kind == SpqrMsgKind.Ct1)
{
UcEkSent uc; byte[] ek;
(uc, ek) = s.Uc.SendEk();
var ct1Dec = new Dec(Ct1Size);
ct1Dec.AddChunk(msg.Payload.Chunk!.Index, msg.Payload.Chunk.Data);
newState = new CHeaderSent(uc, new Enc(ek), ct1Dec);
}
else newState = s;
break;
case CHeaderSent s:
RequireNotGreater(msg.Epoch, epoch);
if (msg.Epoch == epoch && msg.Payload.Kind == SpqrMsgKind.Ct1)
{
s.ReceivingCt1.AddChunk(msg.Payload.Chunk!.Index, msg.Payload.Chunk.Data);
byte[]? decoded = s.ReceivingCt1.DecodedMessage();
if (decoded is not null)
{
UcEkSentCt1Received uc = s.Uc.RecvCt1(msg.Epoch, decoded);
newState = new CCt1Received(uc, s.SendingEk);
}
else newState = s;
}
else newState = s;
break;
case CCt1Received s:
RequireNotGreater(msg.Epoch, epoch);
if (msg.Epoch == epoch && msg.Payload.Kind == SpqrMsgKind.Ct2)
{
var ct2Dec = new Dec(Ct2Size + MacSize);
ct2Dec.AddChunk(msg.Payload.Chunk!.Index, msg.Payload.Chunk.Data);
newState = new CEkSentCt1Received(s.Uc, ct2Dec);
}
else newState = s;
break;
case CEkSentCt1Received s:
RequireNotGreater(msg.Epoch, epoch);
if (msg.Epoch == epoch && msg.Payload.Kind == SpqrMsgKind.Ct2)
{
s.ReceivingCt2.AddChunk(msg.Payload.Chunk!.Index, msg.Payload.Chunk.Data);
byte[]? decoded = s.ReceivingCt2.DecodedMessage();
if (decoded is not null)
{
byte[] ct2 = decoded[..Ct2Size];
byte[] mac = decoded[Ct2Size..];
(UcNoHeaderReceived uc, EpochSecret sec) = s.Uc.RecvCt2(ct2, mac);
key = sec;
newState = new CNoHeaderReceived(uc, new Dec(HeaderSize + MacSize));
}
else newState = s;
}
else newState = s;
break;
// send_ct
case CNoHeaderReceived s:
RequireNotGreater(msg.Epoch, epoch);
if (msg.Epoch == epoch && msg.Payload.Kind == SpqrMsgKind.Hdr)
{
s.ReceivingHdr.AddChunk(msg.Payload.Chunk!.Index, msg.Payload.Chunk.Data);
byte[]? decoded = s.ReceivingHdr.DecodedMessage();
if (decoded is not null)
{
byte[] hdr = decoded[..HeaderSize];
byte[] mac = decoded[HeaderSize..];
UcHeaderReceived uc = s.Uc.RecvHeader(msg.Epoch, hdr, mac);
newState = new CHeaderReceived(uc, new Dec(EkSize));
}
else newState = s;
}
else newState = s;
break;
case CHeaderReceived s:
RequireNotGreater(msg.Epoch, epoch);
newState = s; // no recv transition; we only send_ct1 from here
break;
case CCt1Sampled s:
RequireNotGreater(msg.Epoch, epoch);
if (msg.Epoch == epoch)
{
SpqrChunk? chunk = null; bool ack = false;
if (msg.Payload.Kind == SpqrMsgKind.Ek) { chunk = msg.Payload.Chunk; ack = false; }
else if (msg.Payload.Kind == SpqrMsgKind.EkCt1Ack) { chunk = msg.Payload.Chunk; ack = true; }
if (chunk is not null)
{
s.ReceivingEk.AddChunk(chunk.Index, chunk.Data);
byte[]? decoded = s.ReceivingEk.DecodedMessage();
if (decoded is not null)
{
UcCt1SentEkReceived uc = s.Uc.RecvEk(msg.Epoch, decoded);
if (ack)
{
(UcCt2Sent uc2, byte[] ct2, byte[] mac) = uc.SendCt2();
newState = new CCt2Sampled(uc2, new Enc(Cat(ct2, mac)));
}
else newState = new CEkReceivedCt1Sampled(uc, s.SendingCt1);
}
else if (ack)
newState = new CCt1Acknowledged(s.Uc, s.ReceivingEk);
else newState = s;
}
else newState = s;
}
else newState = s;
break;
case CEkReceivedCt1Sampled s:
RequireNotGreater(msg.Epoch, epoch);
if (msg.Epoch == epoch &&
((msg.Payload.Kind == SpqrMsgKind.Ct1Ack && msg.Payload.Ack) ||
msg.Payload.Kind == SpqrMsgKind.EkCt1Ack))
{
(UcCt2Sent uc2, byte[] ct2, byte[] mac) = s.Uc.SendCt2();
newState = new CCt2Sampled(uc2, new Enc(Cat(ct2, mac)));
}
else newState = s;
break;
case CCt1Acknowledged s:
RequireNotGreater(msg.Epoch, epoch);
if (msg.Epoch == epoch)
{
SpqrChunk? chunk = msg.Payload.Kind is SpqrMsgKind.Ek or SpqrMsgKind.EkCt1Ack
? msg.Payload.Chunk : null;
if (chunk is not null)
{
s.ReceivingEk.AddChunk(chunk.Index, chunk.Data);
byte[]? decoded = s.ReceivingEk.DecodedMessage();
if (decoded is not null)
{
UcCt1SentEkReceived uc = s.Uc.RecvEk(msg.Epoch, decoded);
(UcCt2Sent uc2, byte[] ct2, byte[] mac) = uc.SendCt2();
newState = new CCt2Sampled(uc2, new Enc(Cat(ct2, mac)));
}
else newState = s;
}
else newState = s;
}
else newState = s;
break;
case CCt2Sampled s:
if (msg.Epoch > epoch)
{
if (msg.Epoch == epoch + 1)
{
UcKeysUnsampled uc = s.Uc.RecvNextEpoch(msg.Epoch);
newState = new CKeysUnsampled(uc);
}
else throw new SpqrException($"epoch out of range: {msg.Epoch}");
}
else newState = s; // Less or Equal: stay
break;
default: throw new SpqrException("unknown state");
}
return new RecvResult { Key = key, State = newState == _inner ? this : new SckaStates(newState) };
}
private static void RequireNotGreater(ulong msgEpoch, ulong stateEpoch)
{
if (msgEpoch > stateEpoch) throw new SpqrException($"epoch out of range: {msgEpoch}");
}
private static SpqrChunk Next(Enc enc)
{
(ushort idx, byte[] data) = enc.NextChunk();
return new SpqrChunk(idx, data);
}
private static byte[] Cat(byte[] a, byte[] b)
{
var r = new byte[a.Length + b.Length];
Buffer.BlockCopy(a, 0, r, 0, a.Length);
Buffer.BlockCopy(b, 0, r, a.Length, b.Length);
return r;
}
// ── serialization (local-only state persistence) ──
internal void Write(BinaryWriter w)
{
switch (_inner)
{
case CKeysUnsampled s: w.Write((byte)1); WKeysUnsampled(w, s.Uc); break;
case CKeysSampled s: w.Write((byte)2); WHeaderSent(w, s.Uc); s.SendingHdr.Write(w); break;
case CHeaderSent s: w.Write((byte)3); WEkSent(w, s.Uc); s.SendingEk.Write(w); s.ReceivingCt1.Write(w); break;
case CCt1Received s: w.Write((byte)4); WEkSentCt1Received(w, s.Uc); s.SendingEk.Write(w); break;
case CEkSentCt1Received s: w.Write((byte)5); WEkSentCt1Received(w, s.Uc); s.ReceivingCt2.Write(w); break;
case CNoHeaderReceived s: w.Write((byte)6); WNoHeaderReceived(w, s.Uc); s.ReceivingHdr.Write(w); break;
case CHeaderReceived s: w.Write((byte)7); WHeaderReceived(w, s.Uc); s.ReceivingEk.Write(w); break;
case CCt1Sampled s: w.Write((byte)8); WCt1Sent(w, s.Uc); s.SendingCt1.Write(w); s.ReceivingEk.Write(w); break;
case CEkReceivedCt1Sampled s: w.Write((byte)9); WCt1SentEkReceived(w, s.Uc); s.SendingCt1.Write(w); break;
case CCt1Acknowledged s: w.Write((byte)10); WCt1Sent(w, s.Uc); s.ReceivingEk.Write(w); break;
case CCt2Sampled s: w.Write((byte)11); WCt2Sent(w, s.Uc); s.SendingCt2.Write(w); break;
default: throw new SpqrException("unknown state");
}
}
internal static SckaStates Read(BinaryReader r)
{
byte tag = r.ReadByte();
object inner = tag switch
{
1 => new CKeysUnsampled(RKeysUnsampled(r)),
2 => new CKeysSampled(RHeaderSent(r), Enc.Read(r)),
3 => new CHeaderSent(REkSent(r), Enc.Read(r), Dec.Read(r)),
4 => new CCt1Received(REkSentCt1Received(r), Enc.Read(r)),
5 => new CEkSentCt1Received(REkSentCt1Received(r), Dec.Read(r)),
6 => new CNoHeaderReceived(RNoHeaderReceived(r), Dec.Read(r)),
7 => new CHeaderReceived(RHeaderReceived(r), Dec.Read(r)),
8 => new CCt1Sampled(RCt1Sent(r), Enc.Read(r), Dec.Read(r)),
9 => new CEkReceivedCt1Sampled(RCt1SentEkReceived(r), Enc.Read(r)),
10 => new CCt1Acknowledged(RCt1Sent(r), Dec.Read(r)),
11 => new CCt2Sampled(RCt2Sent(r), Enc.Read(r)),
_ => throw new SpqrException("unknown state tag"),
};
return new SckaStates(inner);
}
// Unchunked-state field writers/readers (fields are public; Authenticator round-trips itself).
private static void WKeysUnsampled(BinaryWriter w, UcKeysUnsampled u) { w.Write(u.Epoch); u.Auth.Write(w); }
private static UcKeysUnsampled RKeysUnsampled(BinaryReader r) => new(r.ReadUInt64(), Authenticator.Read(r));
private static void WHeaderSent(BinaryWriter w, UcHeaderSent u) { w.Write(u.Epoch); u.Auth.Write(w); w.WriteBlob(u.Ek); w.WriteBlob(u.Dk); }
private static UcHeaderSent RHeaderSent(BinaryReader r) => new(r.ReadUInt64(), Authenticator.Read(r), r.ReadBlob(), r.ReadBlob());
private static void WEkSent(BinaryWriter w, UcEkSent u) { w.Write(u.Epoch); u.Auth.Write(w); w.WriteBlob(u.Dk); }
private static UcEkSent REkSent(BinaryReader r) => new(r.ReadUInt64(), Authenticator.Read(r), r.ReadBlob());
private static void WEkSentCt1Received(BinaryWriter w, UcEkSentCt1Received u) { w.Write(u.Epoch); u.Auth.Write(w); w.WriteBlob(u.Dk); w.WriteBlob(u.Ct1); }
private static UcEkSentCt1Received REkSentCt1Received(BinaryReader r) => new(r.ReadUInt64(), Authenticator.Read(r), r.ReadBlob(), r.ReadBlob());
private static void WNoHeaderReceived(BinaryWriter w, UcNoHeaderReceived u) { w.Write(u.Epoch); u.Auth.Write(w); }
private static UcNoHeaderReceived RNoHeaderReceived(BinaryReader r) => new(r.ReadUInt64(), Authenticator.Read(r));
private static void WHeaderReceived(BinaryWriter w, UcHeaderReceived u) { w.Write(u.Epoch); u.Auth.Write(w); w.WriteBlob(u.Hdr); }
private static UcHeaderReceived RHeaderReceived(BinaryReader r) => new(r.ReadUInt64(), Authenticator.Read(r), r.ReadBlob());
private static void WCt1Sent(BinaryWriter w, UcCt1Sent u) { w.Write(u.Epoch); u.Auth.Write(w); w.WriteBlob(u.Hdr); w.WriteBlob(u.Es); w.WriteBlob(u.Ct1); }
private static UcCt1Sent RCt1Sent(BinaryReader r) => new(r.ReadUInt64(), Authenticator.Read(r), r.ReadBlob(), r.ReadBlob(), r.ReadBlob());
private static void WCt1SentEkReceived(BinaryWriter w, UcCt1SentEkReceived u) { w.Write(u.Epoch); u.Auth.Write(w); w.WriteBlob(u.Es); w.WriteBlob(u.Ek); w.WriteBlob(u.Ct1); }
private static UcCt1SentEkReceived RCt1SentEkReceived(BinaryReader r) => new(r.ReadUInt64(), Authenticator.Read(r), r.ReadBlob(), r.ReadBlob(), r.ReadBlob());
private static void WCt2Sent(BinaryWriter w, UcCt2Sent u) { w.Write(u.Epoch); u.Auth.Write(w); }
private static UcCt2Sent RCt2Sent(BinaryReader r) => new(r.ReadUInt64(), Authenticator.Read(r));
// ── chunked state holders (mirror proto V1State.Chunked.*) ──
private sealed class CKeysUnsampled { public UcKeysUnsampled Uc; public CKeysUnsampled(UcKeysUnsampled uc) => Uc = uc; }
private sealed class CKeysSampled { public UcHeaderSent Uc; public Enc SendingHdr; public CKeysSampled(UcHeaderSent uc, Enc h) { Uc = uc; SendingHdr = h; } }
private sealed class CHeaderSent { public UcEkSent Uc; public Enc SendingEk; public Dec ReceivingCt1; public CHeaderSent(UcEkSent uc, Enc e, Dec d) { Uc = uc; SendingEk = e; ReceivingCt1 = d; } }
private sealed class CCt1Received { public UcEkSentCt1Received Uc; public Enc SendingEk; public CCt1Received(UcEkSentCt1Received uc, Enc e) { Uc = uc; SendingEk = e; } }
private sealed class CEkSentCt1Received { public UcEkSentCt1Received Uc; public Dec ReceivingCt2; public CEkSentCt1Received(UcEkSentCt1Received uc, Dec d) { Uc = uc; ReceivingCt2 = d; } }
private sealed class CNoHeaderReceived { public UcNoHeaderReceived Uc; public Dec ReceivingHdr; public CNoHeaderReceived(UcNoHeaderReceived uc, Dec d) { Uc = uc; ReceivingHdr = d; } }
private sealed class CHeaderReceived { public UcHeaderReceived Uc; public Dec ReceivingEk; public CHeaderReceived(UcHeaderReceived uc, Dec d) { Uc = uc; ReceivingEk = d; } }
private sealed class CCt1Sampled { public UcCt1Sent Uc; public Enc SendingCt1; public Dec ReceivingEk; public CCt1Sampled(UcCt1Sent uc, Enc e, Dec d) { Uc = uc; SendingCt1 = e; ReceivingEk = d; } }
private sealed class CEkReceivedCt1Sampled { public UcCt1SentEkReceived Uc; public Enc SendingCt1; public CEkReceivedCt1Sampled(UcCt1SentEkReceived uc, Enc e) { Uc = uc; SendingCt1 = e; } }
private sealed class CCt1Acknowledged { public UcCt1Sent Uc; public Dec ReceivingEk; public CCt1Acknowledged(UcCt1Sent uc, Dec d) { Uc = uc; ReceivingEk = d; } }
private sealed class CCt2Sampled { public UcCt2Sent Uc; public Enc SendingCt2; public CCt2Sampled(UcCt2Sent uc, Enc e) { Uc = uc; SendingCt2 = e; } }
}
+202
View File
@@ -0,0 +1,202 @@
using System.Security.Cryptography;
using Wingnal.Protocol.Crypto;
namespace Wingnal.Protocol.Spqr;
/// <summary>
/// The "unchunked" SPQR v1 SCKA crypto states (ported from SparsePostQuantumRatchet v1.5.1
/// src/v1/unchunked/{send_ek,send_ct}.rs). These hold the raw ML-KEM-768 material + Authenticator and
/// perform the actual KEM operations; the chunked layer (<see cref="SckaChunked"/>) spreads the large
/// header/ek/ct byte blobs across many ratchet messages via the polynomial fountain code.
///
/// Two role tracks alternate per epoch:
/// send_ek: KeysUnsampled → HeaderSent → EkSent → EkSentCt1Received → (switch to send_ct, epoch+1)
/// send_ct: NoHeaderReceived → HeaderReceived → Ct1Sent → Ct1SentEkReceived → Ct2Sent → (switch, epoch+1)
/// </summary>
internal static class SckaKdf
{
private static readonly byte[] ZeroSalt = new byte[32];
private static readonly byte[] SckaKeyLabel = "Signal_PQCKA_V1_MLKEM768:SCKA Key"u8.ToArray();
/// <summary>HKDF(salt=0^32, ikm=ss, info="…SCKA Key"‖BE64(epoch), 32) — turns a raw ML-KEM shared
/// secret into the per-epoch secret mixed into the symmetric Chain.</summary>
public static byte[] DeriveEpochSecret(byte[] ss, ulong epoch)
{
var info = new byte[SckaKeyLabel.Length + 8];
Buffer.BlockCopy(SckaKeyLabel, 0, info, 0, SckaKeyLabel.Length);
for (int i = 7; i >= 0; i--) { info[SckaKeyLabel.Length + i] = (byte)(epoch & 0xFF); epoch >>= 8; }
return CryptoPrimitives.Hkdf(ss, ZeroSalt, info, 32);
}
public static byte[] Random32()
{
var b = new byte[32];
RandomNumberGenerator.Fill(b);
return b;
}
}
// ───────────────────────── send_ek track ─────────────────────────
internal sealed class UcKeysUnsampled
{
public ulong Epoch;
public Authenticator Auth;
public UcKeysUnsampled(ulong epoch, Authenticator auth) { Epoch = epoch; Auth = auth; }
public static UcKeysUnsampled New(byte[] authKey) => new(1, new Authenticator(authKey, 1));
/// <summary>Generates a fresh ML-KEM keypair, MACs its header, advances to HeaderSent.</summary>
public (UcHeaderSent State, byte[] Hdr, byte[] Mac) SendHeader()
{
MlKem768.Keys keys = MlKem768.Generate(SckaKdf.Random32(), SckaKdf.Random32());
byte[] mac = Auth.MacHeader(Epoch, keys.Header);
return (new UcHeaderSent(Epoch, Auth, keys.Ek, keys.Dk), keys.Header, mac);
}
}
internal sealed class UcHeaderSent
{
public ulong Epoch;
public Authenticator Auth;
public byte[] Ek; // 1152
public byte[] Dk; // 2400
public UcHeaderSent(ulong epoch, Authenticator auth, byte[] ek, byte[] dk)
{ Epoch = epoch; Auth = auth; Ek = ek; Dk = dk; }
public (UcEkSent State, byte[] Ek) SendEk() => (new UcEkSent(Epoch, Auth, Dk), Ek);
}
internal sealed class UcEkSent
{
public ulong Epoch;
public Authenticator Auth;
public byte[] Dk;
public UcEkSent(ulong epoch, Authenticator auth, byte[] dk) { Epoch = epoch; Auth = auth; Dk = dk; }
public UcEkSentCt1Received RecvCt1(ulong epoch, byte[] ct1)
{
if (epoch != Epoch) throw new SpqrException("epoch mismatch");
return new UcEkSentCt1Received(Epoch, Auth, Dk, ct1);
}
}
internal sealed class UcEkSentCt1Received
{
public ulong Epoch;
public Authenticator Auth;
public byte[] Dk;
public byte[] Ct1; // 960
public UcEkSentCt1Received(ulong epoch, Authenticator auth, byte[] dk, byte[] ct1)
{ Epoch = epoch; Auth = auth; Dk = dk; Ct1 = ct1; }
/// <summary>Decapsulates ct1‖ct2, derives + mixes the epoch secret, verifies the ciphertext MAC,
/// and switches into the send_ct track for the next epoch.</summary>
public (UcNoHeaderReceived State, EpochSecret Secret) RecvCt2(byte[] ct2, byte[] mac)
{
byte[] ss = MlKem768.DecapsIncremental(Dk, Ct1, ct2);
byte[] secret = SckaKdf.DeriveEpochSecret(ss, Epoch);
Auth.Update(Epoch, secret);
var full = new byte[Ct1.Length + ct2.Length];
Buffer.BlockCopy(Ct1, 0, full, 0, Ct1.Length);
Buffer.BlockCopy(ct2, 0, full, Ct1.Length, ct2.Length);
if (!Auth.VerifyCiphertext(Epoch, full, mac))
throw new SpqrException("ciphertext MAC verification failed");
return (new UcNoHeaderReceived(Epoch + 1, Auth), new EpochSecret(Epoch, secret));
}
}
// ───────────────────────── send_ct track ─────────────────────────
internal sealed class UcNoHeaderReceived
{
public ulong Epoch;
public Authenticator Auth;
public UcNoHeaderReceived(ulong epoch, Authenticator auth) { Epoch = epoch; Auth = auth; }
public static UcNoHeaderReceived New(byte[] authKey) => new(1, new Authenticator(authKey, 1));
public UcHeaderReceived RecvHeader(ulong epoch, byte[] hdr, byte[] mac)
{
if (epoch != Epoch) throw new SpqrException("epoch mismatch");
if (!Auth.VerifyHeader(Epoch, hdr, mac)) throw new SpqrException("header MAC verification failed");
return new UcHeaderReceived(Epoch, Auth, hdr);
}
}
internal sealed class UcHeaderReceived
{
public ulong Epoch;
public Authenticator Auth;
public byte[] Hdr; // 64
public UcHeaderReceived(ulong epoch, Authenticator auth, byte[] hdr) { Epoch = epoch; Auth = auth; Hdr = hdr; }
/// <summary>encaps1 against the received header, derives + mixes the epoch secret.</summary>
public (UcCt1Sent State, byte[] Ct1, EpochSecret Secret) SendCt1()
{
MlKem768.Encaps1(Hdr, SckaKdf.Random32(), out byte[] ct1, out byte[] es, out byte[] ss);
byte[] secret = SckaKdf.DeriveEpochSecret(ss, Epoch);
Auth.Update(Epoch, secret);
return (new UcCt1Sent(Epoch, Auth, Hdr, es, ct1), ct1, new EpochSecret(Epoch, secret));
}
}
internal sealed class UcCt1Sent
{
public ulong Epoch;
public Authenticator Auth;
public byte[] Hdr; // 64
public byte[] Es; // encaps state (local)
public byte[] Ct1; // 960
public UcCt1Sent(ulong epoch, Authenticator auth, byte[] hdr, byte[] es, byte[] ct1)
{ Epoch = epoch; Auth = auth; Hdr = hdr; Es = es; Ct1 = ct1; }
public UcCt1SentEkReceived RecvEk(ulong epoch, byte[] ek)
{
if (epoch != Epoch) throw new SpqrException("epoch mismatch");
if (!MlKem768.EkMatchesHeader(ek, Hdr)) throw new SpqrException("erroneous data received");
return new UcCt1SentEkReceived(Epoch, Auth, Es, ek, Ct1);
}
}
internal sealed class UcCt1SentEkReceived
{
public ulong Epoch;
public Authenticator Auth;
public byte[] Es;
public byte[] Ek; // 1152
public byte[] Ct1; // 960
public UcCt1SentEkReceived(ulong epoch, Authenticator auth, byte[] es, byte[] ek, byte[] ct1)
{ Epoch = epoch; Auth = auth; Es = es; Ek = ek; Ct1 = ct1; }
/// <summary>encaps2 to produce ct2, then MAC ct1‖ct2.</summary>
public (UcCt2Sent State, byte[] Ct2, byte[] Mac) SendCt2()
{
byte[] ct2 = MlKem768.Encaps2(Ek, Es);
var full = new byte[Ct1.Length + ct2.Length];
Buffer.BlockCopy(Ct1, 0, full, 0, Ct1.Length);
Buffer.BlockCopy(ct2, 0, full, Ct1.Length, ct2.Length);
byte[] mac = Auth.MacCiphertext(Epoch, full);
return (new UcCt2Sent(Epoch, Auth), ct2, mac);
}
}
internal sealed class UcCt2Sent
{
public ulong Epoch;
public Authenticator Auth;
public UcCt2Sent(ulong epoch, Authenticator auth) { Epoch = epoch; Auth = auth; }
public UcKeysUnsampled RecvNextEpoch(ulong nextEpoch)
{
if (nextEpoch != Epoch + 1) throw new SpqrException("epoch must advance by one");
return new UcKeysUnsampled(Epoch + 1, Auth);
}
}
+21
View File
@@ -0,0 +1,21 @@
using System.IO;
namespace Wingnal.Protocol.Spqr;
/// <summary>Length-prefixed binary helpers for the SPQR/session state serialization. The format is
/// local-only (never sent to a peer) — the ML-KEM Braid spec leaves state serialization
/// implementation-defined — so this compact custom encoding is sufficient.</summary>
internal static class Bin
{
public static void WriteBlob(this BinaryWriter w, byte[] b)
{
w.Write(b.Length);
w.Write(b);
}
public static byte[] ReadBlob(this BinaryReader r)
{
int n = r.ReadInt32();
return r.ReadBytes(n);
}
}
+265
View File
@@ -0,0 +1,265 @@
using System.IO;
namespace Wingnal.Protocol.Spqr;
public enum SpqrVersion { V0 = 0, V1 = 1 }
/// <summary>Parameters for <see cref="SpqrRatchet.InitialState"/> (mirrors lib.rs Params).</summary>
public sealed class SpqrParams
{
public required Direction Direction { get; init; }
public required SpqrVersion Version { get; init; }
public required SpqrVersion MinVersion { get; init; }
public required byte[] AuthKey { get; init; }
public ChainParams ChainParams { get; init; } = new();
}
/// <summary>
/// Top-level SPQR ratchet API (ported from SparsePostQuantumRatchet v1.5.1 src/lib.rs). Glues the
/// chunked SCKA state machine to the symmetric <see cref="Chain"/>: <see cref="Send"/> emits the wire
/// <c>pq_ratchet</c> bytes plus an optional 32-byte message key (the HKDF salt libsignal mixes into the
/// per-message WhisperMessageKeys), and <see cref="Recv"/> consumes peer bytes and returns the matching
/// salt. State is kept in-memory (object form); proto serialization for durable storage is layered on
/// separately. Only V1 is implemented (libsignal mandates V1/min-V1 on both ends).
/// </summary>
public sealed class SpqrRatchet
{
private SckaStates? _inner; // null ⇒ V0 (disabled)
private Chain? _chain;
// Version-negotiation block (present until the first recv); needed to lazily build the Chain.
private bool _hasVn;
private byte[]? _vnAuthKey;
private Direction _vnDirection;
private SpqrVersion _vnMinVersion;
private ChainParams _vnChainParams = new();
private SpqrRatchet() { }
public SpqrVersion CurrentVersion => _inner is null ? SpqrVersion.V0 : SpqrVersion.V1;
public static SpqrRatchet InitialState(SpqrParams p)
{
var r = new SpqrRatchet();
if (p.Version == SpqrVersion.V0) return r; // empty/disabled
r._inner = p.Direction == Direction.A2B
? SckaStates.InitA(p.AuthKey)
: SckaStates.InitB(p.AuthKey);
r._hasVn = true;
r._vnAuthKey = p.AuthKey;
r._vnDirection = p.Direction;
r._vnMinVersion = p.MinVersion;
r._vnChainParams = p.ChainParams;
return r;
}
public sealed class SendOutput
{
public required byte[] Message { get; init; } // wire pq_ratchet bytes (empty for V0)
public byte[]? Key { get; init; } // 32-byte HKDF salt, or null
}
public SendOutput Send()
{
if (_inner is null) return new SendOutput { Message = Array.Empty<byte>(), Key = null };
SckaStates.SendResult sr = _inner.Send();
Chain? chain;
if (_chain is not null) chain = _chain;
else if (_hasVn) chain = _vnMinVersion > SpqrVersion.V0 ? new Chain(_vnAuthKey!, _vnDirection, _vnChainParams) : null;
else throw new SpqrException("chain not available");
uint index; byte[] msgKey;
if (chain is null) { index = 0; msgKey = Array.Empty<byte>(); }
else
{
if (sr.Key is not null) chain.AddEpoch(sr.Key);
(index, msgKey) = chain.SendKey(sr.Msg.Epoch - 1);
}
byte[] wire = SerializeMessage(sr.Msg, index);
_inner = sr.State;
if (chain is not null) _chain = chain; // version_negotiation unchanged on send
return new SendOutput { Message = wire, Key = msgKey.Length == 0 ? null : msgKey };
}
/// <summary>Process a peer's pq_ratchet bytes; returns the 32-byte message-key salt (or null).</summary>
public byte[]? Recv(byte[] message)
{
if (_inner is null) return null; // V0
// Version negotiation: libsignal uses V1/min-V1 both ways, so msg version (1) == our version (1).
SpqrVersion? msgVer = MsgVersion(message);
if (msgVer is null) return null; // unsupported higher version: ignore
if (msgVer.Value < SpqrVersion.V1)
throw new SpqrException("SPQR version downgrade not supported");
(SpqrMessage scka, uint index) = DeserializeMessage(message);
SckaStates.RecvResult rr = _inner.Recv(scka);
ulong msgKeyEpoch = scka.Epoch - 1;
Chain chain = _chain ?? (_hasVn
? new Chain(_vnAuthKey!, _vnDirection, _vnChainParams)
: throw new SpqrException("chain not available"));
if (rr.Key is not null) chain.AddEpoch(rr.Key);
byte[] msgKey = msgKeyEpoch == 0 && index == 0
? Array.Empty<byte>()
: chain.RecvKey(msgKeyEpoch, index);
_inner = rr.State;
_chain = chain;
_hasVn = false; // receiving clears version negotiation
return msgKey.Length == 0 ? null : msgKey;
}
// ── state serialization (local-only persistence) ──
public byte[] Serialize()
{
using var ms = new MemoryStream();
using var w = new BinaryWriter(ms);
if (_inner is null)
{
w.Write((byte)0); // V0 / disabled
}
else
{
w.Write((byte)1);
_inner.Write(w);
w.Write(_chain is not null);
_chain?.Write(w);
w.Write(_hasVn);
if (_hasVn)
{
w.WriteBlob(_vnAuthKey!);
w.Write((int)_vnDirection);
w.Write((int)_vnMinVersion);
w.Write(_vnChainParams.MaxJump);
w.Write(_vnChainParams.MaxOooKeys);
}
}
w.Flush();
return ms.ToArray();
}
public static SpqrRatchet Deserialize(byte[] bytes)
{
var r = new SpqrRatchet();
using var ms = new MemoryStream(bytes);
using var rd = new BinaryReader(ms);
if (rd.ReadByte() == 0) return r; // V0
r._inner = SckaStates.Read(rd);
if (rd.ReadBoolean()) r._chain = Chain.Read(rd);
r._hasVn = rd.ReadBoolean();
if (r._hasVn)
{
r._vnAuthKey = rd.ReadBlob();
r._vnDirection = (Direction)rd.ReadInt32();
r._vnMinVersion = (SpqrVersion)rd.ReadInt32();
r._vnChainParams = new ChainParams { MaxJump = rd.ReadUInt32(), MaxOooKeys = rd.ReadUInt32() };
}
return r;
}
private static SpqrVersion? MsgVersion(byte[] msg)
{
if (msg.Length == 0) return SpqrVersion.V0;
return msg[0] switch { 0 => SpqrVersion.V0, 1 => SpqrVersion.V1, _ => null };
}
// ── wire message format (see v1/chunked/states/serialize.rs) ──
// [version=1] [varint epoch] [varint index] [type:1] [optional: varint chunkIndex || 32B data]
private enum MsgType : byte { None = 0, Hdr = 1, Ek = 2, EkCt1Ack = 3, Ct1Ack = 4, Ct1 = 5, Ct2 = 6 }
private static byte[] SerializeMessage(SpqrMessage msg, uint index)
{
var o = new List<byte>(40) { (byte)SpqrVersion.V1 };
EncodeVarint(msg.Epoch, o);
EncodeVarint(index, o);
MsgType type = msg.Payload.Kind switch
{
SpqrMsgKind.None => MsgType.None,
SpqrMsgKind.Hdr => MsgType.Hdr,
SpqrMsgKind.Ek => MsgType.Ek,
SpqrMsgKind.EkCt1Ack => MsgType.EkCt1Ack,
SpqrMsgKind.Ct1Ack => MsgType.Ct1Ack,
SpqrMsgKind.Ct1 => MsgType.Ct1,
SpqrMsgKind.Ct2 => MsgType.Ct2,
_ => throw new SpqrException("bad payload"),
};
o.Add((byte)type);
if (msg.Payload.Chunk is { } chunk)
{
EncodeVarint(chunk.Index, o);
o.AddRange(chunk.Data);
}
return o.ToArray();
}
private static (SpqrMessage Msg, uint Index) DeserializeMessage(byte[] from)
{
if (from.Length == 0 || from[0] != (byte)SpqrVersion.V1) throw new SpqrException("message decode failed");
int at = 1;
ulong epoch = DecodeVarint(from, ref at);
if (epoch == 0) throw new SpqrException("message decode failed");
ulong indexU = DecodeVarint(from, ref at);
if (indexU > uint.MaxValue) throw new SpqrException("message decode failed");
if (at >= from.Length) throw new SpqrException("message decode failed");
var type = (MsgType)from[at];
at++;
SpqrPayload payload = type switch
{
MsgType.None => SpqrPayload.None,
MsgType.Ct1Ack => SpqrPayload.Ct1Ack(true),
MsgType.Hdr => SpqrPayload.Hdr(DecodeChunk(from, ref at)),
MsgType.Ek => SpqrPayload.Ek(DecodeChunk(from, ref at)),
MsgType.EkCt1Ack => SpqrPayload.EkCt1Ack(DecodeChunk(from, ref at)),
MsgType.Ct1 => SpqrPayload.Ct1(DecodeChunk(from, ref at)),
MsgType.Ct2 => SpqrPayload.Ct2(DecodeChunk(from, ref at)),
_ => throw new SpqrException("message decode failed"),
};
return (new SpqrMessage(epoch, payload), (uint)indexU);
}
private static SpqrChunk DecodeChunk(byte[] from, ref int at)
{
ulong index = DecodeVarint(from, ref at);
int start = at;
at += 32;
if (at > from.Length || index > 65535) throw new SpqrException("message decode failed");
return new SpqrChunk((ushort)index, from[start..at]);
}
private static void EncodeVarint(ulong a, List<byte> into)
{
for (int i = 0; i < 10; i++)
{
byte b = (byte)(a & 0x7F);
if (a < 0x80) { into.Add(b); break; }
into.Add((byte)(0x80 | b));
a >>= 7;
}
}
private static ulong DecodeVarint(byte[] from, ref int at)
{
ulong outv = 0;
int start = at;
if (start >= from.Length) throw new SpqrException("message decode failed");
int max = Math.Min(10, from.Length - start);
int i = 0;
bool done = false;
while (i < max && !done)
{
byte b = from[start + i];
outv |= ((ulong)b & 0x7F) << (7 * i);
i++;
done = (b & 0x80) == 0;
}
if (!done) throw new SpqrException("message decode failed");
at += i;
return outv;
}
}