feat(net): AcDream.Core.Net scaffold + ISAAC keystream (Phase 4.1)

First step of Phase 4 (networking). Adds a new AcDream.Core.Net project
for the AC UDP protocol implementation and a matching AcDream.Core.Net.Tests
project. Keeps networking isolated from rendering and the dat layer,
which also keeps the AGPL-reference-material hygiene cleaner.

AcDream.Core.Net/NOTICE.md documents the attribution policy: we read
ACE's AGPL network code (and holtburger's Rust ac-protocol crate) to
understand AC's wire format, but we reimplement everything in acdream's
own style. Wire-format facts aren't copyrightable; specific code is.

This commit adds one component: IsaacRandom — AC's variant of Bob
Jenkins' ISAAC PRNG, used to XOR a keystream into the CRC field of
every outbound packet for authentication. Clean-room reimplementation
based on reading:
  - references/ACE/Source/ACE.Common/Cryptography/ISAAC.cs (AGPL oracle)
  - Bob Jenkins' public ISAAC algorithm description

Implementation notes:
  - 256 uint32 mm[] state, 256 uint32 rsl[] output buffer, a/b/c regs
  - Initialize() runs 4 golden-ratio Mix() warmup rounds then two fold-in
    passes over rsl[] and mm[] (fresh instance → both start as zeroes)
  - AC variant: seed is exactly 4 bytes, interpreted as little-endian
    uint32 assigned to a = b = c before the first Scramble()
  - Scramble() produces 256 output words in one pass; Next() consumes
    them backwards from offset 255 → 0, re-scrambling at offset -1
  - Test seed 0x12345678 matches ACE's reference output byte-for-byte
    across the first 16 values (golden vectors transcribed from a
    throwaway oracle harness that compiled ACE's ISAAC.cs and printed
    its output; the harness was deleted after extracting the values)

Tests (5, all passing):
  - Next_Seed12345678_MatchesAceGoldenVectors: 16 golden uint32 values
  - Next_TwoInstancesSameSeed_ProduceIdenticalSequence: 1000 outputs
  - Next_DifferentSeeds_ProduceDifferentFirstOutput
  - Next_512Calls_SpansTwoScrambleBatches: >400 distinct values in 512
    outputs (catches all-zero / stuck-at-one bugs at scramble boundary)
  - Ctor_ShortSeed_Throws

Both test projects still green: 77 core + 5 net = 82/82.

Phase 4.2 (packet framing + checksum) next.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-11 14:14:28 +02:00
parent e0dfecdf23
commit 293584d6e8
6 changed files with 313 additions and 0 deletions

View file

@ -0,0 +1,172 @@
namespace AcDream.Core.Net.Cryptography;
/// <summary>
/// Asheron's Call variant of Bob Jenkins' ISAAC keystream generator. AC's
/// client and server each seed an ISAAC instance with a 4-byte value shared
/// during the connect handshake, then XOR the keystream into the CRC field of
/// each outbound packet so the peer can both verify the packet is authentic
/// and recover the CRC for the real checksum comparison.
///
/// <para>
/// Algorithm overview (public ISAAC from Bob Jenkins, 1996):
/// <list type="number">
/// <item>Internal state is 256 uint32 <c>mm[]</c> words plus <c>a</c>,
/// <c>b</c>, <c>c</c> registers and a 256-word output buffer
/// <c>randRsl[]</c>.</item>
/// <item>Initialization mixes <c>mm[]</c> via 4 rounds of Jenkins' golden-
/// ratio shuffle on 8-word state, then folds in the seed-derived
/// <c>randRsl[]</c> and <c>mm[]</c> words via two more passes.</item>
/// <item><c>Scramble()</c> produces 256 output words per call, consumed in
/// reverse order (offset 255 → 0) by <c>Next()</c>. When the buffer
/// is drained the scramble re-runs.</item>
/// <item>AC variant: seed is exactly 4 bytes (the UDP-handshake ISAAC seed
/// from the server). Those 4 bytes are interpreted as a
/// little-endian uint32 and assigned to <c>a = b = c</c> before the
/// first scramble. The rest of <c>mm[]</c> and <c>randRsl[]</c>
/// start at zero.</item>
/// </list>
/// </para>
///
/// <para>
/// This is a clean-room reimplementation based on reading the public
/// algorithm description and ACE's AGPL reference implementation at
/// <c>references/ACE/Source/ACE.Common/Cryptography/ISAAC.cs</c>. See
/// <c>NOTICE.md</c> for attribution. No code is copied.
/// </para>
/// </summary>
public sealed class IsaacRandom
{
private const int StateSize = 256;
private const uint GoldenRatio = 0x9E3779B9u;
private readonly uint[] _mm = new uint[StateSize];
private readonly uint[] _rsl = new uint[StateSize];
private uint _a;
private uint _b;
private uint _c;
/// <summary>Current position in <see cref="_rsl"/>. <see cref="Next"/>
/// consumes values from index <c>_offset</c> down to 0 then re-scrambles.</summary>
private int _offset;
/// <summary>
/// Construct a new keystream seeded with the 4 bytes starting at
/// <paramref name="seedBytes"/>[0]. The bytes are interpreted as a
/// little-endian uint32 and fed into the ISAAC initialization such that
/// two instances with the same seed produce identical output sequences.
/// </summary>
public IsaacRandom(ReadOnlySpan<byte> seedBytes)
{
if (seedBytes.Length < 4)
throw new ArgumentException("seed must be at least 4 bytes", nameof(seedBytes));
Initialize();
uint seed = (uint)seedBytes[0]
| ((uint)seedBytes[1] << 8)
| ((uint)seedBytes[2] << 16)
| ((uint)seedBytes[3] << 24);
_a = _b = _c = seed;
Scramble();
_offset = StateSize - 1;
}
/// <summary>
/// Return the next uint32 of the keystream. Cheap — amortized one array
/// read per call, with a scramble round every 256 calls.
/// </summary>
public uint Next()
{
uint value = _rsl[_offset];
if (_offset > 0)
{
_offset--;
}
else
{
Scramble();
_offset = StateSize - 1;
}
return value;
}
/// <summary>
/// Run the Jenkins golden-ratio shuffle on the supplied 8-word state.
/// Used during initialization to stir <c>mm[]</c> and <c>randRsl[]</c>.
/// Each line is a mix step from the reference; the constants are the
/// Jenkins-published avalanche offsets.
/// </summary>
private static void Mix(Span<uint> s)
{
s[0] ^= s[1] << 11; s[3] += s[0]; s[1] += s[2];
s[1] ^= s[2] >> 2; s[4] += s[1]; s[2] += s[3];
s[2] ^= s[3] << 8; s[5] += s[2]; s[3] += s[4];
s[3] ^= s[4] >> 16; s[6] += s[3]; s[4] += s[5];
s[4] ^= s[5] << 10; s[7] += s[4]; s[5] += s[6];
s[5] ^= s[6] >> 4; s[0] += s[5]; s[6] += s[7];
s[6] ^= s[7] << 8; s[1] += s[6]; s[7] += s[0];
s[7] ^= s[0] >> 9; s[2] += s[7]; s[0] += s[1];
}
private void Initialize()
{
// mm[] and rsl[] start as all zeroes.
Span<uint> s = stackalloc uint[8];
for (int i = 0; i < 8; i++) s[i] = GoldenRatio;
// 4 warmup rounds so the initial state diverges from the golden-ratio
// pattern before we start folding in real values.
for (int i = 0; i < 4; i++) Mix(s);
// First pass folds _rsl (zeroes on a fresh instance) into mm[].
for (int j = 0; j < StateSize; j += 8)
{
for (int k = 0; k < 8; k++) s[k] += _rsl[j + k];
Mix(s);
for (int k = 0; k < 8; k++) _mm[j + k] = s[k];
}
// Second pass folds mm[] (now populated) back into itself.
for (int j = 0; j < StateSize; j += 8)
{
for (int k = 0; k < 8; k++) s[k] += _mm[j + k];
Mix(s);
for (int k = 0; k < 8; k++) _mm[j + k] = s[k];
}
}
/// <summary>
/// Produce the next 256 output words into <see cref="_rsl"/>. Consumed
/// in reverse by <see cref="Next"/>. Each iteration rotates <c>a</c>
/// through one of four avalanche shifts depending on <c>i &amp; 3</c>,
/// pulls an indirection from the far half of the state, and produces the
/// next output + next state word.
/// </summary>
private void Scramble()
{
_c++;
_b += _c;
for (int i = 0; i < StateSize; i++)
{
uint x = _mm[i];
switch (i & 3)
{
case 0: _a ^= _a << 13; break;
case 1: _a ^= _a >> 6; break;
case 2: _a ^= _a << 2; break;
case 3: _a ^= _a >> 16; break;
}
_a += _mm[(i + 128) & 0xFF];
uint y = _mm[(int)((x >> 2) & 0xFF)] + _a + _b;
_mm[i] = y;
uint nextB = _mm[(int)((y >> 10) & 0xFF)] + x;
_rsl[i] = nextB;
_b = nextB;
}
}
}