using System; using System.IO; using System.Linq; using SabreTools.Numerics.Extensions; using SabreTools.Security.Cryptography; using Xunit; using static SabreTools.Data.Models.NintendoDisc.Constants; namespace SabreTools.Wrappers.Test { public class WIATests { /// /// Arbitrary test-only common key — no relation to any real Wii key. /// Used by both and . /// private static readonly byte[] TestCommonKey = [ 0xDE, 0xAD, 0xBE, 0xEF, 0xCA, 0xFE, 0xF0, 0x0D, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, ]; #region Constants private const int HeaderAreaSize = 0x8000; private const long IsoSize = Partition1Data + WiiGroupSize; private const long Partition0Offset = 0x60000; private const long Partition0Data = Partition0Offset + HeaderAreaSize; private const long Partition1Offset = Partition0Data + WiiGroupSize; private const long Partition1Data = Partition1Offset + HeaderAreaSize; private const long PartitionListOffset = 0x50000; private const long PartitionTableOffset = 0x40000; #endregion [Fact] public void NullArray_Null() { byte[]? data = null; int offset = 0; var actual = WIA.Create(data, offset); Assert.Null(actual); } [Fact] public void EmptyArray_Null() { byte[]? data = []; int offset = 0; var actual = WIA.Create(data, offset); Assert.Null(actual); } [Fact] public void InvalidArray_Null() { byte[]? data = [.. Enumerable.Repeat(0xFF, 1024)]; int offset = 0; var actual = WIA.Create(data, offset); Assert.Null(actual); } [Fact] public void NullStream_Null() { Stream? data = null; var actual = WIA.Create(data); Assert.Null(actual); } [Fact] public void EmptyStream_Null() { Stream? data = new MemoryStream([]); var actual = WIA.Create(data); Assert.Null(actual); } [Fact] public void InvalidStream_Null() { Stream? data = new MemoryStream([.. Enumerable.Repeat(0xFF, 1024)]); var actual = WIA.Create(data); Assert.Null(actual); } /// /// Build the smallest valid WIA we can to get a non-null wrapper, /// but for the guard test we only need to exercise the null-path branch. /// We can create a real wrapper via the round-trip helper and then call /// DumpIso with a null path — that must return false. /// [Fact(Skip = "Common keys are validated so this cannot pass")] public void DumpIso_NullPath_ReturnsFalse() { var wia = BuildMinimalWiiWia(); Assert.NotNull(wia); Assert.False(wia!.DumpIso(null)); } /// /// Builds a synthetic Wii disc with 2 fake partitions (each 1 WiiGroup = 64 × 0x8000 bytes of /// known plaintext encrypted with an arbitrary key), converts it to WIA (NONE compression), /// reads it back through , then decrypts every Wii data block in the /// dumped ISO using and asserts the decrypted bytes /// match the original plaintext. /// /// This exercises both directions: /// • WIA write path re-encrypts partition data correctly () /// • WIA read path () re-encrypts WIA decrypted groups back to /// ISO-layout AES-CBC blocks via GetCachedEncGroup / EncryptWiiGroup /// /// Anti-bias: the final decryption uses — a single-block /// AES-CBC call that is completely independent of EncryptWiiGroup — so a symmetric bug /// (broken encrypt paired with broken decrypt) would still fail the plaintext comparison. /// The title key is encrypted via (BouncyCastle), while the /// verification uses — a different code path. /// [Fact(Skip = "Common keys are validated so this cannot pass")] public void Wii_WiaNoneRoundTrip_Succeeds() { // ---- Build synthetic Wii ISO ---- byte[] iso = BuildMinimalWiiIso(TestCommonKey); // ---- NintendoDisc.Create must succeed ---- var nd = NintendoDisc.Create(new MemoryStream(iso)); Assert.NotNull(nd); Assert.NotNull(nd!.PartitionTableEntries); Assert.Equal(2, nd.PartitionTableEntries!.Length); // ---- Compress to WIA (NONE, no lossy transforms) ---- using var wiaMs = new MemoryStream(); bool written = WIA.ConvertFromDiscToStream(nd, wiaMs, isRvz: false, compressionType: Data.Models.WIA.WiaRvzCompressionType.None, compressionLevel: 5, chunkSize: Data.Models.WIA.Constants.DefaultChunkSize, out Exception? writeEx); Assert.True(written, $"ConvertFromDiscToStream failed: {writeEx?.GetType().Name}: {writeEx?.Message}\n{writeEx?.StackTrace}"); // ---- Decompress back to ISO ---- wiaMs.Position = 0; var wia = WIA.Create(wiaMs); Assert.NotNull(wia); string tempIso = Path.GetTempFileName() + ".iso"; try { bool dumped = wia!.DumpIso(tempIso); Assert.True(dumped, "DumpIso should succeed"); byte[] dumpedIso = File.ReadAllBytes(tempIso); byte[] titleKey = [ 0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF, 0xFE, 0xDC, 0xBA, 0x98, 0x76, 0x54, 0x32, 0x10, ]; byte[] plain0 = new byte[WiiBlocksPerGroup * WiiBlockDataSize]; for (int i = 0; i < plain0.Length; i++) { plain0[i] = 0xAA; } byte[] plain1 = new byte[WiiBlocksPerGroup * WiiBlockDataSize]; for (int i = 0; i < plain1.Length; i++) { plain1[i] = 0xBB; } // ---- Anti-bias verification: decrypt each block using DecryptBlock only ---- VerifyPartitionPlaintext(dumpedIso, Partition0Data, plain0, titleKey, WiiBlocksPerGroup, WiiBlockSize, WiiBlockDataSize, partitionLabel: "Partition 0"); VerifyPartitionPlaintext(dumpedIso, Partition1Data, plain1, titleKey, WiiBlocksPerGroup, WiiBlockSize, WiiBlockDataSize, partitionLabel: "Partition 1"); } finally { if (File.Exists(tempIso)) File.Delete(tempIso); } } #region Wii test helpers /// /// Builds a minimal synthetic Wii disc (one WiiGroup per partition) and returns a live /// wrapper backed by a . /// Returns null if any step fails. /// private static WIA? BuildMinimalWiiWia() { try { byte[] iso = BuildMinimalWiiIso(TestCommonKey); var nd = NintendoDisc.Create(new MemoryStream(iso)); if (nd is null) return null; // TODO: Force this? nd.WiiDecrypter.RetailCommonKey = TestCommonKey; nd.WiiDecrypter.KoreanCommonKey = TestCommonKey; var ms = new MemoryStream(); bool ok = WIA.ConvertFromDiscToStream(nd, ms, isRvz: false, compressionType: Data.Models.WIA.WiaRvzCompressionType.None, compressionLevel: 5, chunkSize: Data.Models.WIA.Constants.DefaultChunkSize, out var exception); if (!ok) return null; ms.Position = 0; return WIA.Create(ms); } catch { return null; } } /// /// Builds a minimal synthetic Wii ISO with 2 partitions (1 WiiGroup each), encrypted /// with . /// private static byte[] BuildMinimalWiiIso(byte[] commonKey) { byte[] titleKey = [ 0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF, 0xFE, 0xDC, 0xBA, 0x98, 0x76, 0x54, 0x32, 0x10, ]; byte[] titleId = [0x00, 0x01, 0x00, 0x45, 0x52, 0x53, 0x42, 0x00]; byte[] encTitleKey = EncryptTitleKeyIndependent(titleKey, titleId, commonKey); byte[] plain0 = new byte[WiiGroupDataSize]; for (int i = 0; i < plain0.Length; i++) { plain0[i] = 0xAA; } byte[] plain1 = new byte[WiiGroupDataSize]; for (int i = 0; i < plain1.Length; i++) { plain1[i] = 0xBB; } byte[] enc0 = WIA.EncryptWiiGroup(plain0, titleKey, WiiBlocksPerGroup); byte[] enc1 = WIA.EncryptWiiGroup(plain1, titleKey, WiiBlocksPerGroup); byte[] iso = new byte[IsoSize]; iso[0] = (byte)'R'; iso[1] = (byte)'S'; iso[2] = (byte)'B'; iso[3] = (byte)'E'; iso[4] = (byte)'0'; iso[5] = (byte)'1'; iso[0x18] = 0x5D; iso[0x19] = 0x1C; iso[0x1A] = 0x9E; iso[0x1B] = 0xA3; int off = (int)PartitionTableOffset; iso.WriteBigEndian(ref off, 2u); iso.WriteBigEndian(ref off, (uint)(PartitionListOffset >> 2)); off = (int)PartitionListOffset; iso.WriteBigEndian(ref off, (uint)(Partition0Offset >> 2)); iso.WriteBigEndian(ref off, 0u); iso.WriteBigEndian(ref off, (uint)(Partition1Offset >> 2)); iso.WriteBigEndian(ref off, 1u); WritePartitionHeader(iso, Partition0Offset, encTitleKey, titleId, ckIdx: 0); WritePartitionHeader(iso, Partition1Offset, encTitleKey, titleId, ckIdx: 0); Array.Copy(enc0, 0, iso, Partition0Data, enc0.Length); Array.Copy(enc1, 0, iso, Partition1Data, enc1.Length); return iso; } /// /// /// /// /// /// /// /// private static void WritePartitionHeader(byte[] iso, long partOffset, byte[] encTitleKey, byte[] titleId, byte ckIdx) { // Signature type 0x10001 at partOffset+0 int off = (int)partOffset; iso.WriteBigEndian(ref off, 0x10001u); // Encrypted title key at partOffset+0x1BF (16 bytes) Array.Copy(encTitleKey, 0, iso, partOffset + 0x1BF, 16); // Title ID at partOffset+0x1DC (8 bytes) Array.Copy(titleId, 0, iso, partOffset + 0x1DC, 8); // Common key index at partOffset+0x1F1 iso[partOffset + 0x1F1] = ckIdx; // Data offset at partOffset+0x2B8 (shifted >>2): data starts at +0x8000 // 0x8000 >> 2 = 0x2000 off = (int)(partOffset + 0x2B8); iso.WriteBigEndian(ref off, 0x2000u); // Data size at partOffset+0x2BC (shifted >>2): exactly 1 WiiGroup = 0x200000 // 0x200000 >> 2 = 0x80000 iso.WriteBigEndian(ref off, 0x80000u); } /// /// Decrypts each block of one WII partition in the dumped ISO using only /// (a single-block AES-CBC call that is /// completely independent of EncryptWiiGroup) and asserts the decrypted /// block data matches the corresponding slice of . /// private static void VerifyPartitionPlaintext(byte[] iso, long dataStart, byte[] expectedPlaintext, byte[] titleKey, int blocksPerGroup, int blockSize, int blockDataSize, string partitionLabel) { for (int b = 0; b < blocksPerGroup; b++) { long blockOff = dataStart + ((long)b * blockSize); // IV = bytes at offset 0x3D0 within the encrypted hash block byte[] iv = new byte[16]; Array.Copy(iso, blockOff + 0x3D0, iv, 0, 16); // Encrypted data block follows the 0x400-byte hash block byte[] encData = new byte[blockDataSize]; Array.Copy(iso, blockOff + 0x400, encData, 0, blockDataSize); byte[]? dec = WiiDecrypter.DecryptBlock(encData, titleKey, iv); Assert.NotNull(dec); // Compare against known plaintext slice int plainOff = b * blockDataSize; for (int i = 0; i < blockDataSize; i++) { if (dec![i] != expectedPlaintext[plainOff + i]) Assert.Fail($"{partitionLabel} block {b} byte {i}: expected 0x{expectedPlaintext[plainOff + i]:X2}, got 0x{dec[i]:X2}"); } } } /// /// Encrypts a Wii title key with the given using /// . /// private static byte[] EncryptTitleKeyIndependent(byte[] titleKey, byte[] titleId, byte[] commonKey) { byte[] iv = new byte[16]; Array.Copy(titleId, 0, iv, 0, 8); return AESCBC.Encrypt(titleKey, commonKey, iv) ?? throw new InvalidOperationException("AESCBC.Encrypt returned null"); } #endregion } }