diff --git a/src/AcDream.Core.Net/Packets/Packet.cs b/src/AcDream.Core.Net/Packets/Packet.cs new file mode 100644 index 0000000..60da44c --- /dev/null +++ b/src/AcDream.Core.Net/Packets/Packet.cs @@ -0,0 +1,28 @@ +namespace AcDream.Core.Net.Packets; + +/// +/// A fully-parsed AC UDP packet: 20-byte , +/// variable-length section, zero or more +/// , 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). +/// +/// +/// Produced by on the receive path; +/// passed to on the transmit path after +/// callers fill in the fields they want. +/// +/// +public sealed class Packet +{ + public PacketHeader Header; + public PacketHeaderOptional Optional { get; } = new(); + public List Fragments { get; } = new(); + + /// + /// 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. + /// + public byte[] BodyBytes { get; set; } = Array.Empty(); +} diff --git a/src/AcDream.Core.Net/Packets/PacketCodec.cs b/src/AcDream.Core.Net/Packets/PacketCodec.cs new file mode 100644 index 0000000..615c729 --- /dev/null +++ b/src/AcDream.Core.Net/Packets/PacketCodec.cs @@ -0,0 +1,129 @@ +using AcDream.Core.Net.Cryptography; + +namespace AcDream.Core.Net.Packets; + +/// +/// 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 on +/// success or a error for the caller +/// to log and drop. +/// +/// +/// CRC verification algorithm (ported from ACE ClientPacket.VerifyCRC): +/// +/// 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) +/// +/// +/// +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; + } + + /// + /// Parse a UDP datagram into a and verify its CRC. + /// + /// The raw UDP payload. + /// + /// ISAAC keystream for checking encrypted checksums on inbound packets, + /// or null if the packet's checksum will not be encrypted (any + /// packet without the EncryptedChecksum flag is verified additively). + /// + public static PacketDecodeResult TryDecode(ReadOnlySpan 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); + } + + /// + /// 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. + /// + public static uint CalculateFragmentHash32(in MessageFragment frag) + { + Span headerBuf = stackalloc byte[MessageFragmentHeader.Size]; + frag.Header.Pack(headerBuf); + return Hash32.Calculate(headerBuf) + Hash32.Calculate(frag.Payload); + } +} diff --git a/src/AcDream.Core.Net/Packets/PacketHeaderOptional.cs b/src/AcDream.Core.Net/Packets/PacketHeaderOptional.cs new file mode 100644 index 0000000..8ce7953 --- /dev/null +++ b/src/AcDream.Core.Net/Packets/PacketHeaderOptional.cs @@ -0,0 +1,171 @@ +using System.Buffers.Binary; + +namespace AcDream.Core.Net.Packets; + +/// +/// The variable-sized "optional header" section that lives between the +/// 20-byte and the body fragments (or login +/// payload) of an AC UDP packet. Each sub-section is gated by a flag in +/// header.Flags — no flags, no optional section. +/// +/// +/// Fields that actually exist once parsed (all default-zeroed when the +/// corresponding flag isn't set): +/// +/// +/// — present when is set. +/// — list of sequence numbers to +/// retransmit, present when is set. +/// — server time sync value, present when +/// is set. +/// — present on echo requests. +/// / — flow control, present on Flow packets. +/// +/// +/// +/// The parser also records the raw bytes it consumed in +/// — AC's CRC verification requires hashing this +/// optional section separately from the main header and the fragments, so +/// the byte slice is needed downstream. +/// +/// +/// Reimplemented from ACE's AGPL reference; see NOTICE.md. +/// +public sealed class PacketHeaderOptional +{ + /// Raw bytes consumed from the wire, used by . + public byte[] RawBytes { get; private set; } = Array.Empty(); + + public uint AckSequence { get; private set; } + public IReadOnlyList RetransmitRequests { get; private set; } = Array.Empty(); + public double TimeSync { get; private set; } + public float EchoRequestClientTime { get; private set; } + public uint FlowBytes { get; private set; } + public ushort FlowInterval { get; private set; } + + /// + /// Parse the optional section from (which starts + /// right after the 20-byte header). Returns the number of bytes consumed + /// from , or -1 if the section is malformed + /// (short reads, impossible lengths). On success, + /// holds a copy of the consumed slice. + /// + public int Parse(ReadOnlySpan 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; + } + + /// + /// Hash32 over the raw bytes consumed during . Forms + /// one of the three summands in the packet's CRC: header + optional + + /// fragments. + /// + public uint CalculateHash32() => Cryptography.Hash32.Calculate(RawBytes); + + private static bool HasFlag(PacketHeaderFlags all, PacketHeaderFlags bit) => (all & bit) != 0; + + private static bool Take(ReadOnlySpan body, ref int pos, int n) + { + if (body.Length - pos < n) return false; + pos += n; + return true; + } +} diff --git a/tests/AcDream.Core.Net.Tests/Packets/PacketCodecTests.cs b/tests/AcDream.Core.Net.Tests/Packets/PacketCodecTests.cs new file mode 100644 index 0000000..c410787 --- /dev/null +++ b/tests/AcDream.Core.Net.Tests/Packets/PacketCodecTests.cs @@ -0,0 +1,171 @@ +using AcDream.Core.Net.Cryptography; +using AcDream.Core.Net.Packets; + +namespace AcDream.Core.Net.Tests.Packets; + +public class PacketCodecTests +{ + /// + /// Build a complete packet byte array by hand, compute its correct + /// unencrypted CRC, and verify TryDecode parses it and accepts the CRC. + /// + [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); + } +}