feat(net): PacketHeaderOptional + full packet decode + CRC verify (Phase 4.4)
Brings the codec to end-to-end: a raw UDP datagram goes in, a parsed
Packet comes out with verified CRC (both plain and ISAAC-encrypted
variants). Synthetic packets built inside tests round-trip through
TryDecode cleanly.
Added:
- Packets/PacketHeaderOptional.cs: parses every flag-gated section
that lives between the 20-byte header and the body fragments —
AckSequence, RequestRetransmit (with count + array), RejectRetransmit,
ServerSwitch, LoginRequest (tail slurp), WorldLoginRequest,
ConnectResponse, CICMDCommand, TimeSync (double), EchoRequest (float),
Flow (FlowBytes + FlowInterval). Records the raw consumed bytes into
RawBytes so CalculateHash32 can hash them verbatim — AC's CRC requires
hashing the optional section separately from the main header and the
fragments.
- Packets/Packet.cs: a record type bundling Header, Optional, Fragments,
and the raw body bytes. Produced by the decoder, consumed by downstream
handlers in Phase 4.5.
- Packets/PacketCodec.cs: TryDecode(datagram, isaac?) that
1. Unpacks the header,
2. Bounds-checks DataSize against the buffer,
3. Parses the optional section,
4. If BlobFragments is set, walks the body tail as back-to-back
MessageFragment.TryParse calls,
5. Computes headerHash + optionalHash + fragmentHash,
6. Verifies CRC:
- Unencrypted: sum equals header.Checksum
- Encrypted: (header.Checksum - headerHash) XOR payloadHash must
equal the next ISAAC keystream word (which is consumed on match)
Returns a PacketDecodeResult(Packet?, DecodeError) so callers can log
and drop malformed packets instead of throwing.
- Public helper PacketCodec.CalculateFragmentHash32 so tests (and later
the encode path) can reuse the fragment-hash math.
Tests (7 new, 44 total in net project, 121 across both test projects):
- Minimal valid packet with AckSequence optional, no fragments, plain
checksum — verifies optional parse + CRC accept
- Wrong checksum rejected
- Buffer shorter than header → TooShort
- Header DataSize > buffer → HeaderSizeExceedsBuffer
- Packet with BlobFragments flag + one fragment: parses fragment and
validates the full headerHash + fragmentHash equals wire checksum
- Encrypted checksum ROUND TRIP: two ISAAC instances with same seed,
one encodes the checksum key, one decodes — validates the
(Header.Checksum - headerHash) XOR payloadHash == isaacNext contract
byte-for-byte
- Encrypted checksum with wrong key on the wire → rejected
Known limitation: the parser advances past WorldLoginRequest and
ConnectResponse their full 8 bytes whereas ACE "peeks" them (seek/reset).
The on-wire byte count is the same, only the read-position behavior
differs; any consumer that wanted to re-read those sections can do so
from Packet.BodyBytes.
Phase 4.5 (NetClient UDP pump + handshake state machine) next.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
3226c4bcab
commit
c64bbf29e4
4 changed files with 499 additions and 0 deletions
28
src/AcDream.Core.Net/Packets/Packet.cs
Normal file
28
src/AcDream.Core.Net/Packets/Packet.cs
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
namespace AcDream.Core.Net.Packets;
|
||||
|
||||
/// <summary>
|
||||
/// A fully-parsed AC UDP packet: 20-byte <see cref="Header"/>,
|
||||
/// variable-length <see cref="Optional"/> section, zero or more
|
||||
/// <see cref="Fragments"/>, and the raw body bytes following the header
|
||||
/// (for callers that want to re-read parts of the login/connect payload
|
||||
/// without re-invoking the parser).
|
||||
///
|
||||
/// <para>
|
||||
/// Produced by <see cref="PacketCodec.TryDecode"/> on the receive path;
|
||||
/// passed to <see cref="PacketCodec.Encode"/> on the transmit path after
|
||||
/// callers fill in the fields they want.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class Packet
|
||||
{
|
||||
public PacketHeader Header;
|
||||
public PacketHeaderOptional Optional { get; } = new();
|
||||
public List<MessageFragment> Fragments { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Copy of the body bytes (everything after the 20-byte header). Useful
|
||||
/// for LoginRequest packets where the login payload is the body tail and
|
||||
/// the caller wants to extract username/password bytes directly.
|
||||
/// </summary>
|
||||
public byte[] BodyBytes { get; set; } = Array.Empty<byte>();
|
||||
}
|
||||
129
src/AcDream.Core.Net/Packets/PacketCodec.cs
Normal file
129
src/AcDream.Core.Net/Packets/PacketCodec.cs
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
using AcDream.Core.Net.Cryptography;
|
||||
|
||||
namespace AcDream.Core.Net.Packets;
|
||||
|
||||
/// <summary>
|
||||
/// Decodes (and eventually encodes) AC UDP packets. Stateless decoder:
|
||||
/// callers feed it raw UDP datagram bytes + an optional ISAAC keystream
|
||||
/// for the encrypted-checksum path. Produces a <see cref="Packet"/> on
|
||||
/// success or a <see cref="PacketDecodeResult"/> error for the caller
|
||||
/// to log and drop.
|
||||
///
|
||||
/// <para>
|
||||
/// CRC verification algorithm (ported from ACE <c>ClientPacket.VerifyCRC</c>):
|
||||
/// <code>
|
||||
/// headerHash = Hash32(header bytes with Checksum field overwritten by 0xBADD70DD)
|
||||
/// optionalHash = Hash32(raw bytes of the optional header section)
|
||||
/// fragmentHash = sum of each fragment's (Hash32(fragHeader) + Hash32(fragPayload))
|
||||
///
|
||||
/// If EncryptedChecksum flag is set:
|
||||
/// key = (header.Checksum - headerHash) XOR (optionalHash + fragmentHash)
|
||||
/// key must equal the next ISAAC keystream word (consumed on success)
|
||||
/// Else:
|
||||
/// header.Checksum must equal (headerHash + optionalHash + fragmentHash)
|
||||
/// </code>
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class PacketCodec
|
||||
{
|
||||
public enum DecodeError
|
||||
{
|
||||
None = 0,
|
||||
TooShort, // buffer shorter than 20-byte header
|
||||
HeaderSizeExceedsBuffer, // header.DataSize + 20 > buffer.Length
|
||||
InvalidOptionalHeader,
|
||||
InvalidFragment,
|
||||
ChecksumMismatch,
|
||||
}
|
||||
|
||||
public readonly record struct PacketDecodeResult(Packet? Packet, DecodeError Error)
|
||||
{
|
||||
public bool IsOk => Error == DecodeError.None;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parse a UDP datagram into a <see cref="Packet"/> and verify its CRC.
|
||||
/// </summary>
|
||||
/// <param name="datagram">The raw UDP payload.</param>
|
||||
/// <param name="inboundIsaac">
|
||||
/// ISAAC keystream for checking encrypted checksums on inbound packets,
|
||||
/// or <c>null</c> if the packet's checksum will not be encrypted (any
|
||||
/// packet without the EncryptedChecksum flag is verified additively).
|
||||
/// </param>
|
||||
public static PacketDecodeResult TryDecode(ReadOnlySpan<byte> datagram, IsaacRandom? inboundIsaac)
|
||||
{
|
||||
if (datagram.Length < PacketHeader.Size)
|
||||
return new PacketDecodeResult(null, DecodeError.TooShort);
|
||||
|
||||
var packet = new Packet { Header = PacketHeader.Unpack(datagram) };
|
||||
int bodyLen = packet.Header.DataSize;
|
||||
if (datagram.Length - PacketHeader.Size < bodyLen)
|
||||
return new PacketDecodeResult(null, DecodeError.HeaderSizeExceedsBuffer);
|
||||
|
||||
var body = datagram.Slice(PacketHeader.Size, bodyLen);
|
||||
packet.BodyBytes = body.ToArray();
|
||||
|
||||
// Parse the optional header section first.
|
||||
int optionalConsumed = packet.Optional.Parse(body, packet.Header.Flags);
|
||||
if (optionalConsumed < 0)
|
||||
return new PacketDecodeResult(null, DecodeError.InvalidOptionalHeader);
|
||||
|
||||
// If the BlobFragments flag is set, walk the body tail as a sequence
|
||||
// of fragments. LoginRequest packets don't have fragments (they have
|
||||
// the login payload in the optional section instead).
|
||||
if (packet.Header.HasFlag(PacketHeaderFlags.BlobFragments))
|
||||
{
|
||||
var fragSlice = body.Slice(optionalConsumed);
|
||||
while (fragSlice.Length > 0)
|
||||
{
|
||||
var (frag, consumed) = MessageFragment.TryParse(fragSlice);
|
||||
if (frag is null || consumed == 0)
|
||||
return new PacketDecodeResult(null, DecodeError.InvalidFragment);
|
||||
packet.Fragments.Add(frag.Value);
|
||||
fragSlice = fragSlice.Slice(consumed);
|
||||
}
|
||||
}
|
||||
|
||||
// Compute the three checksum summands.
|
||||
uint headerHash = packet.Header.CalculateHeaderHash32();
|
||||
uint optionalHash = packet.Optional.CalculateHash32();
|
||||
uint fragmentHash = 0;
|
||||
foreach (var frag in packet.Fragments)
|
||||
fragmentHash += CalculateFragmentHash32(frag);
|
||||
|
||||
uint payloadHash = optionalHash + fragmentHash;
|
||||
|
||||
if (packet.Header.HasFlag(PacketHeaderFlags.EncryptedChecksum))
|
||||
{
|
||||
if (inboundIsaac is null)
|
||||
return new PacketDecodeResult(null, DecodeError.ChecksumMismatch);
|
||||
|
||||
// Expected key = (wireChecksum - headerHash) XOR payloadHash.
|
||||
// That key must equal the next ISAAC keystream word.
|
||||
uint expectedKey = (packet.Header.Checksum - headerHash) ^ payloadHash;
|
||||
uint isaacKey = inboundIsaac.Next();
|
||||
if (expectedKey != isaacKey)
|
||||
return new PacketDecodeResult(null, DecodeError.ChecksumMismatch);
|
||||
}
|
||||
else
|
||||
{
|
||||
uint expected = headerHash + payloadHash;
|
||||
if (packet.Header.Checksum != expected)
|
||||
return new PacketDecodeResult(null, DecodeError.ChecksumMismatch);
|
||||
}
|
||||
|
||||
return new PacketDecodeResult(packet, DecodeError.None);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Hash32 of a single fragment = Hash32(header 16 bytes) + Hash32(payload).
|
||||
/// Matches ACE's ClientPacketFragment.CalculateHash32. Public so callers
|
||||
/// (including tests) can reuse it when crafting synthetic packets.
|
||||
/// </summary>
|
||||
public static uint CalculateFragmentHash32(in MessageFragment frag)
|
||||
{
|
||||
Span<byte> headerBuf = stackalloc byte[MessageFragmentHeader.Size];
|
||||
frag.Header.Pack(headerBuf);
|
||||
return Hash32.Calculate(headerBuf) + Hash32.Calculate(frag.Payload);
|
||||
}
|
||||
}
|
||||
171
src/AcDream.Core.Net/Packets/PacketHeaderOptional.cs
Normal file
171
src/AcDream.Core.Net/Packets/PacketHeaderOptional.cs
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
using System.Buffers.Binary;
|
||||
|
||||
namespace AcDream.Core.Net.Packets;
|
||||
|
||||
/// <summary>
|
||||
/// The variable-sized "optional header" section that lives between the
|
||||
/// 20-byte <see cref="PacketHeader"/> and the body fragments (or login
|
||||
/// payload) of an AC UDP packet. Each sub-section is gated by a flag in
|
||||
/// <c>header.Flags</c> — no flags, no optional section.
|
||||
///
|
||||
/// <para>
|
||||
/// Fields that actually exist once parsed (all default-zeroed when the
|
||||
/// corresponding flag isn't set):
|
||||
/// </para>
|
||||
/// <list type="bullet">
|
||||
/// <item><see cref="AckSequence"/> — present when <see cref="PacketHeaderFlags.AckSequence"/> is set.</item>
|
||||
/// <item><see cref="RetransmitRequests"/> — list of sequence numbers to
|
||||
/// retransmit, present when <see cref="PacketHeaderFlags.RequestRetransmit"/> is set.</item>
|
||||
/// <item><see cref="TimeSync"/> — server time sync value, present when
|
||||
/// <see cref="PacketHeaderFlags.TimeSync"/> is set.</item>
|
||||
/// <item><see cref="EchoRequestClientTime"/> — present on echo requests.</item>
|
||||
/// <item><see cref="FlowBytes"/>/<see cref="FlowInterval"/> — flow control, present on Flow packets.</item>
|
||||
/// </list>
|
||||
///
|
||||
/// <para>
|
||||
/// The parser <b>also records the raw bytes it consumed</b> in
|
||||
/// <see cref="RawBytes"/> — AC's CRC verification requires hashing this
|
||||
/// optional section separately from the main header and the fragments, so
|
||||
/// the byte slice is needed downstream.
|
||||
/// </para>
|
||||
///
|
||||
/// Reimplemented from ACE's AGPL reference; see <c>NOTICE.md</c>.
|
||||
/// </summary>
|
||||
public sealed class PacketHeaderOptional
|
||||
{
|
||||
/// <summary>Raw bytes consumed from the wire, used by <see cref="CalculateHash32"/>.</summary>
|
||||
public byte[] RawBytes { get; private set; } = Array.Empty<byte>();
|
||||
|
||||
public uint AckSequence { get; private set; }
|
||||
public IReadOnlyList<uint> RetransmitRequests { get; private set; } = Array.Empty<uint>();
|
||||
public double TimeSync { get; private set; }
|
||||
public float EchoRequestClientTime { get; private set; }
|
||||
public uint FlowBytes { get; private set; }
|
||||
public ushort FlowInterval { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Parse the optional section from <paramref name="body"/> (which starts
|
||||
/// right after the 20-byte header). Returns the number of bytes consumed
|
||||
/// from <paramref name="body"/>, or -1 if the section is malformed
|
||||
/// (short reads, impossible lengths). On success, <see cref="RawBytes"/>
|
||||
/// holds a copy of the consumed slice.
|
||||
/// </summary>
|
||||
public int Parse(ReadOnlySpan<byte> body, PacketHeaderFlags flags)
|
||||
{
|
||||
int pos = 0;
|
||||
|
||||
// ServerSwitch: 8 bytes, no semantic parse yet (we just consume it
|
||||
// for checksum coverage — ACE doesn't decode these either).
|
||||
if (HasFlag(flags, PacketHeaderFlags.ServerSwitch))
|
||||
{
|
||||
if (!Take(body, ref pos, 8)) return -1;
|
||||
}
|
||||
|
||||
if (HasFlag(flags, PacketHeaderFlags.RequestRetransmit))
|
||||
{
|
||||
if (!Take(body, ref pos, 4)) return -1;
|
||||
uint count = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos - 4));
|
||||
// Bounds: count * 4 bytes must fit.
|
||||
if (count > 1024 || body.Length - pos < (int)count * 4) return -1;
|
||||
var list = new uint[count];
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
list[i] = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));
|
||||
pos += 4;
|
||||
}
|
||||
RetransmitRequests = list;
|
||||
}
|
||||
|
||||
if (HasFlag(flags, PacketHeaderFlags.RejectRetransmit))
|
||||
{
|
||||
if (!Take(body, ref pos, 4)) return -1;
|
||||
uint count = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos - 4));
|
||||
if (count > 1024 || body.Length - pos < (int)count * 4) return -1;
|
||||
pos += (int)count * 4; // consume without storing
|
||||
}
|
||||
|
||||
if (HasFlag(flags, PacketHeaderFlags.AckSequence))
|
||||
{
|
||||
if (!Take(body, ref pos, 4)) return -1;
|
||||
AckSequence = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos - 4));
|
||||
}
|
||||
|
||||
// LoginRequest: the remaining body (after all prior optional sections)
|
||||
// is the login payload. ACE reads it without advancing position so
|
||||
// the fragment loop can re-read it from the start — our parser mirrors
|
||||
// that behavior by "peeking" the remaining bytes into RawBytes without
|
||||
// advancing pos beyond this point in the main pipeline. Callers that
|
||||
// want the login blob should read from body[pos..] directly.
|
||||
//
|
||||
// Note: LoginRequest packets have no fragments after this point.
|
||||
if (HasFlag(flags, PacketHeaderFlags.LoginRequest))
|
||||
{
|
||||
// Count the entire remaining body as part of the optional section
|
||||
// for checksum purposes, and stop parsing here.
|
||||
int loginBytes = body.Length - pos;
|
||||
if (loginBytes < 0) return -1;
|
||||
RawBytes = body.Slice(0, pos + loginBytes).ToArray();
|
||||
return pos + loginBytes;
|
||||
}
|
||||
|
||||
// WorldLoginRequest: 8 bytes peeked (not advanced in ACE either — our
|
||||
// consumers treat it as covered by checksum, not advanced).
|
||||
// Strict port: we DO advance pos so the fragment loop reads after it,
|
||||
// matching the on-wire byte layout. ACE's peek-and-reset is an
|
||||
// implementation detail of its MemoryStream seeking that doesn't
|
||||
// affect the bytes-consumed count.
|
||||
if (HasFlag(flags, PacketHeaderFlags.WorldLoginRequest))
|
||||
{
|
||||
if (!Take(body, ref pos, 8)) return -1;
|
||||
}
|
||||
|
||||
if (HasFlag(flags, PacketHeaderFlags.ConnectResponse))
|
||||
{
|
||||
if (!Take(body, ref pos, 8)) return -1;
|
||||
}
|
||||
|
||||
if (HasFlag(flags, PacketHeaderFlags.CICMDCommand))
|
||||
{
|
||||
if (!Take(body, ref pos, 8)) return -1;
|
||||
}
|
||||
|
||||
if (HasFlag(flags, PacketHeaderFlags.TimeSync))
|
||||
{
|
||||
if (!Take(body, ref pos, 8)) return -1;
|
||||
TimeSync = BitConverter.Int64BitsToDouble(
|
||||
BinaryPrimitives.ReadInt64LittleEndian(body.Slice(pos - 8)));
|
||||
}
|
||||
|
||||
if (HasFlag(flags, PacketHeaderFlags.EchoRequest))
|
||||
{
|
||||
if (!Take(body, ref pos, 4)) return -1;
|
||||
EchoRequestClientTime = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos - 4));
|
||||
}
|
||||
|
||||
if (HasFlag(flags, PacketHeaderFlags.Flow))
|
||||
{
|
||||
if (!Take(body, ref pos, 6)) return -1;
|
||||
FlowBytes = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos - 6));
|
||||
FlowInterval = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos - 2));
|
||||
}
|
||||
|
||||
RawBytes = body.Slice(0, pos).ToArray();
|
||||
return pos;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Hash32 over the raw bytes consumed during <see cref="Parse"/>. Forms
|
||||
/// one of the three summands in the packet's CRC: header + optional +
|
||||
/// fragments.
|
||||
/// </summary>
|
||||
public uint CalculateHash32() => Cryptography.Hash32.Calculate(RawBytes);
|
||||
|
||||
private static bool HasFlag(PacketHeaderFlags all, PacketHeaderFlags bit) => (all & bit) != 0;
|
||||
|
||||
private static bool Take(ReadOnlySpan<byte> body, ref int pos, int n)
|
||||
{
|
||||
if (body.Length - pos < n) return false;
|
||||
pos += n;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
171
tests/AcDream.Core.Net.Tests/Packets/PacketCodecTests.cs
Normal file
171
tests/AcDream.Core.Net.Tests/Packets/PacketCodecTests.cs
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
using AcDream.Core.Net.Cryptography;
|
||||
using AcDream.Core.Net.Packets;
|
||||
|
||||
namespace AcDream.Core.Net.Tests.Packets;
|
||||
|
||||
public class PacketCodecTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Build a complete packet byte array by hand, compute its correct
|
||||
/// unencrypted CRC, and verify TryDecode parses it and accepts the CRC.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TryDecode_MinimalValidPacket_UnencryptedChecksum_Accepted()
|
||||
{
|
||||
// Body = one optional section (AckSequence, 4 bytes) + zero fragments.
|
||||
// ACK sequence in optional body = 0x12345678.
|
||||
byte[] body = new byte[4];
|
||||
body[0] = 0x78; body[1] = 0x56; body[2] = 0x34; body[3] = 0x12;
|
||||
|
||||
var header = new PacketHeader
|
||||
{
|
||||
Sequence = 1,
|
||||
Flags = PacketHeaderFlags.AckSequence,
|
||||
Id = 42,
|
||||
Time = 100,
|
||||
DataSize = (ushort)body.Length,
|
||||
Iteration = 0,
|
||||
};
|
||||
|
||||
// Compute the unencrypted checksum: headerHash + optionalHash.
|
||||
// No fragments → fragmentHash = 0.
|
||||
uint headerHash = header.CalculateHeaderHash32();
|
||||
uint optionalHash = Hash32.Calculate(body);
|
||||
header.Checksum = headerHash + optionalHash;
|
||||
|
||||
// Assemble the datagram.
|
||||
byte[] datagram = new byte[PacketHeader.Size + body.Length];
|
||||
header.Pack(datagram);
|
||||
body.CopyTo(datagram.AsSpan(PacketHeader.Size));
|
||||
|
||||
var result = PacketCodec.TryDecode(datagram, inboundIsaac: null);
|
||||
|
||||
Assert.Equal(PacketCodec.DecodeError.None, result.Error);
|
||||
Assert.NotNull(result.Packet);
|
||||
Assert.Equal(0x12345678u, result.Packet!.Optional.AckSequence);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryDecode_WrongChecksum_Rejected()
|
||||
{
|
||||
byte[] body = new byte[4] { 1, 2, 3, 4 };
|
||||
var header = new PacketHeader
|
||||
{
|
||||
Flags = PacketHeaderFlags.AckSequence,
|
||||
DataSize = (ushort)body.Length,
|
||||
Checksum = 0xDEADBEEFu, // garbage checksum
|
||||
};
|
||||
byte[] datagram = new byte[PacketHeader.Size + body.Length];
|
||||
header.Pack(datagram);
|
||||
body.CopyTo(datagram.AsSpan(PacketHeader.Size));
|
||||
|
||||
var result = PacketCodec.TryDecode(datagram, inboundIsaac: null);
|
||||
Assert.Equal(PacketCodec.DecodeError.ChecksumMismatch, result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryDecode_BufferShorterThanHeader_ReturnsTooShort()
|
||||
{
|
||||
var result = PacketCodec.TryDecode(new byte[19], inboundIsaac: null);
|
||||
Assert.Equal(PacketCodec.DecodeError.TooShort, result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryDecode_DataSizeExceedsBuffer_Rejected()
|
||||
{
|
||||
var header = new PacketHeader { DataSize = 1000 };
|
||||
byte[] datagram = new byte[PacketHeader.Size + 10]; // only 10 body bytes available
|
||||
header.Pack(datagram);
|
||||
|
||||
var result = PacketCodec.TryDecode(datagram, inboundIsaac: null);
|
||||
Assert.Equal(PacketCodec.DecodeError.HeaderSizeExceedsBuffer, result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryDecode_PacketWithOneFragment_ParsesAndValidatesCRC()
|
||||
{
|
||||
// Body: no optional section + one 16+3 = 19-byte fragment (BlobFragments flag).
|
||||
var fragHeader = new MessageFragmentHeader
|
||||
{
|
||||
Sequence = 1, Id = 0x80000001u, Count = 1,
|
||||
TotalSize = 19, Index = 0, Queue = 5,
|
||||
};
|
||||
byte[] fragBytes = new byte[19];
|
||||
fragHeader.Pack(fragBytes);
|
||||
fragBytes[16] = 0xAA; fragBytes[17] = 0xBB; fragBytes[18] = 0xCC;
|
||||
|
||||
var header = new PacketHeader
|
||||
{
|
||||
Flags = PacketHeaderFlags.BlobFragments,
|
||||
DataSize = (ushort)fragBytes.Length,
|
||||
};
|
||||
|
||||
// Compute unencrypted checksum.
|
||||
uint headerHash = header.CalculateHeaderHash32();
|
||||
uint optionalHash = 0; // empty optional section
|
||||
var frag = new MessageFragment(fragHeader, new byte[] { 0xAA, 0xBB, 0xCC });
|
||||
uint fragmentHash = PacketCodec.CalculateFragmentHash32(frag);
|
||||
header.Checksum = headerHash + optionalHash + fragmentHash;
|
||||
|
||||
byte[] datagram = new byte[PacketHeader.Size + fragBytes.Length];
|
||||
header.Pack(datagram);
|
||||
fragBytes.CopyTo(datagram.AsSpan(PacketHeader.Size));
|
||||
|
||||
var result = PacketCodec.TryDecode(datagram, inboundIsaac: null);
|
||||
|
||||
Assert.Equal(PacketCodec.DecodeError.None, result.Error);
|
||||
Assert.Single(result.Packet!.Fragments);
|
||||
Assert.Equal(new byte[] { 0xAA, 0xBB, 0xCC }, result.Packet.Fragments[0].Payload);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryDecode_EncryptedChecksum_AcceptsMatchingIsaacKey()
|
||||
{
|
||||
// The ISAAC-encrypted form: the wire Checksum should equal
|
||||
// headerHash + (isaacNext XOR payloadHash)
|
||||
// so that VerifyCRC recovers isaacNext as the key and consumes it.
|
||||
var body = new byte[4] { 1, 2, 3, 4 };
|
||||
var header = new PacketHeader
|
||||
{
|
||||
Flags = PacketHeaderFlags.AckSequence | PacketHeaderFlags.EncryptedChecksum,
|
||||
DataSize = (ushort)body.Length,
|
||||
};
|
||||
|
||||
// Build the same isaac in two places: one to compute the expected
|
||||
// checksum, one to pass to TryDecode. Both use seed 0x11223344.
|
||||
var seed = new byte[] { 0x44, 0x33, 0x22, 0x11 };
|
||||
var isaacForEncode = new IsaacRandom(seed);
|
||||
var isaacForDecode = new IsaacRandom(seed);
|
||||
|
||||
uint headerHash = header.CalculateHeaderHash32();
|
||||
uint payloadHash = Hash32.Calculate(body); // optional hash; no fragments
|
||||
uint isaacKey = isaacForEncode.Next();
|
||||
header.Checksum = headerHash + (isaacKey ^ payloadHash);
|
||||
|
||||
byte[] datagram = new byte[PacketHeader.Size + body.Length];
|
||||
header.Pack(datagram);
|
||||
body.CopyTo(datagram.AsSpan(PacketHeader.Size));
|
||||
|
||||
var result = PacketCodec.TryDecode(datagram, isaacForDecode);
|
||||
Assert.Equal(PacketCodec.DecodeError.None, result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryDecode_EncryptedChecksum_WrongIsaacKey_Rejected()
|
||||
{
|
||||
var body = new byte[4] { 1, 2, 3, 4 };
|
||||
var header = new PacketHeader
|
||||
{
|
||||
Flags = PacketHeaderFlags.AckSequence | PacketHeaderFlags.EncryptedChecksum,
|
||||
DataSize = (ushort)body.Length,
|
||||
Checksum = 0xDEADBEEFu, // wrong
|
||||
};
|
||||
byte[] datagram = new byte[PacketHeader.Size + body.Length];
|
||||
header.Pack(datagram);
|
||||
body.CopyTo(datagram.AsSpan(PacketHeader.Size));
|
||||
|
||||
// Decoder gets a fresh ISAAC; its first Next() definitely won't match 0xDEADBEEF.
|
||||
var result = PacketCodec.TryDecode(datagram, new IsaacRandom(new byte[] { 1, 2, 3, 4 }));
|
||||
Assert.Equal(PacketCodec.DecodeError.ChecksumMismatch, result.Error);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue