using System; using System.Collections.Generic; using System.IO; using System.Security.Cryptography; using Newtonsoft.Json; using Xunit; using Xunit.Abstractions; namespace SabreTools.Wrappers.Test { [Collection("NintendoDisc")] public class NintendoDiscEncryptionTests { private readonly ITestOutputHelper _output; public NintendoDiscEncryptionTests(ITestOutputHelper output) { _output = output; } // ----------------------------------------------------------------------- // DecryptTitleKey — no provider set // ----------------------------------------------------------------------- [Fact] public void DecryptTitleKey_NoProvider_ReturnsNull() { NintendoDisc.CommonKeyProvider = null; Assert.Null(NintendoDisc.DecryptTitleKey(new byte[16], new byte[8], 0)); } // ----------------------------------------------------------------------- // DecryptTitleKey — argument guards // ----------------------------------------------------------------------- [Fact] public void DecryptTitleKey_NullEncKey_ReturnsNull() { NintendoDisc.CommonKeyProvider = _ => new byte[16]; try { Assert.Null(NintendoDisc.DecryptTitleKey(null!, new byte[8], 0)); } finally { NintendoDisc.CommonKeyProvider = null; } } [Fact] public void DecryptTitleKey_WrongLengthEncKey_ReturnsNull() { NintendoDisc.CommonKeyProvider = _ => new byte[16]; try { Assert.Null(NintendoDisc.DecryptTitleKey(new byte[8], new byte[8], 0)); } finally { NintendoDisc.CommonKeyProvider = null; } } [Fact] public void DecryptTitleKey_NullTitleId_ReturnsNull() { NintendoDisc.CommonKeyProvider = _ => new byte[16]; try { Assert.Null(NintendoDisc.DecryptTitleKey(new byte[16], null!, 0)); } finally { NintendoDisc.CommonKeyProvider = null; } } [Fact] public void DecryptTitleKey_WrongLengthTitleId_ReturnsNull() { NintendoDisc.CommonKeyProvider = _ => new byte[16]; try { Assert.Null(NintendoDisc.DecryptTitleKey(new byte[16], new byte[4], 0)); } finally { NintendoDisc.CommonKeyProvider = null; } } // ----------------------------------------------------------------------- // DecryptTitleKey — provider returns null for unknown index // ----------------------------------------------------------------------- [Fact] public void DecryptTitleKey_UnknownIndex_ReturnsNull() { NintendoDisc.CommonKeyProvider = _ => null; try { Assert.Null(NintendoDisc.DecryptTitleKey(new byte[16], new byte[8], 0)); } finally { NintendoDisc.CommonKeyProvider = null; } } // ----------------------------------------------------------------------- // DecryptTitleKey — round-trip with injected key // ----------------------------------------------------------------------- [Fact] public void DecryptTitleKey_WithInjectedKey_RoundTrips() { byte[] commonKey = { 0xDE, 0xAD, 0xBE, 0xEF, 0xCA, 0xFE, 0xF0, 0x0D, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, }; byte[] plainTitleKey = { 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[] iv = new byte[16]; Array.Copy(titleId, 0, iv, 0, 8); byte[] encTitleKey = AesCbc.Encrypt(plainTitleKey, commonKey, iv) ?? throw new InvalidOperationException("AesCbc.Encrypt returned null"); NintendoDisc.CommonKeyProvider = _ => commonKey; try { byte[]? decrypted = NintendoDisc.DecryptTitleKey(encTitleKey, titleId, 0); Assert.NotNull(decrypted); Assert.Equal(plainTitleKey, decrypted); } finally { NintendoDisc.CommonKeyProvider = null; } } // ----------------------------------------------------------------------- // Integration test — reads a real key file supplied by the user. // // Copy keys.json.example to keys.json and fill in the real key bytes. // The test is silently skipped when the file is absent OR when the loaded // keys do not hash to the expected SHA256 values hardcoded below, so CI // stays green without real keys in the repository. // ----------------------------------------------------------------------- // SHA256(retail common key bytes) private const string RetailKeySha256 = "de38aeab4fe0c36d828a47e6fd315100e7ce234d3b00aa25e6ad6f5ff2824af8"; // SHA256(Korean common key bytes) private const string KoreanKeySha256 = "b9f42ca27a1e178f0f14ebf1a05d486fa8db8d08875336c4e6e8dfae29f2901c"; [Fact] public void LoadFromKeyFile_RealKeys_DecryptTitleKey_Succeeds() { string keyFile = Path.Combine( AppContext.BaseDirectory, "TestData", "NintendoDisc", "keys.json"); _output.WriteLine($"Looking for key file: {keyFile}"); if (!File.Exists(keyFile)) { _output.WriteLine("Key file not found — test skipped."); return; } _output.WriteLine("Key file found. Parsing..."); var provider = LoadKeyProvider(keyFile); NintendoDisc.CommonKeyProvider = provider; try { byte[]? retail = provider.Invoke(0); byte[]? korean = provider.Invoke(1); string retailHash = retail is null ? "(missing)" : Sha256Hex(retail); string koreanHash = korean is null ? "(missing)" : Sha256Hex(korean); _output.WriteLine($"retail (index 0) SHA256 : {retailHash}"); _output.WriteLine($" expected : {RetailKeySha256}"); _output.WriteLine($" match : {retailHash == RetailKeySha256}"); _output.WriteLine($"korean (index 1) SHA256 : {koreanHash}"); _output.WriteLine($" expected : {KoreanKeySha256}"); _output.WriteLine($" match : {koreanHash == KoreanKeySha256}"); if (retail is null || retailHash != RetailKeySha256) { _output.WriteLine("retail key did not match — integration assertions skipped."); return; } if (korean is null || koreanHash != KoreanKeySha256) { _output.WriteLine("korean key did not match — integration assertions skipped."); return; } _output.WriteLine("Both keys verified — running assertions."); Assert.Equal(16, retail.Length); Assert.Equal(16, korean.Length); _output.WriteLine("Assertions passed."); } finally { NintendoDisc.CommonKeyProvider = null; } } private static string Sha256Hex(byte[] data) { using var sha = SHA256.Create(); return BitConverter.ToString(sha.ComputeHash(data)).Replace("-", string.Empty).ToLowerInvariant(); } // ----------------------------------------------------------------------- // Helper — parses the named JSON key file and returns a provider delegate. // Lives here in the test project; the library itself never does file I/O. // ----------------------------------------------------------------------- /// /// Parses a named Wii common-key JSON file and returns a /// -compatible delegate. /// /// /// Expected file format: /// /// [ /// { "name": "retail", "index": 0, "key": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" }, /// { "name": "korean", "index": 1, "key": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" } /// ] /// /// Whitespace inside hex strings is ignored. Returns from the /// delegate for any index not present in the file. /// internal static Func LoadKeyProvider(string path) { string json = File.ReadAllText(path); var entries = JsonConvert.DeserializeObject>(json) ?? throw new FormatException("Key file could not be deserialized."); var map = new Dictionary(); foreach (var entry in entries) { if (entry.Key is null) throw new FormatException($"Entry '{entry.Name}' is missing a key value."); string hex = entry.Key.Replace(" ", string.Empty).Replace("-", string.Empty); if (hex.Length != 32) throw new FormatException($"Entry '{entry.Name}' key must be 16 bytes (32 hex chars), got {hex.Length / 2}."); byte[] bytes = new byte[16]; for (int i = 0; i < 16; i++) bytes[i] = Convert.ToByte(hex.Substring(i * 2, 2), 16); map[entry.Index] = bytes; } return index => map.TryGetValue(index, out byte[]? k) ? k : null; } private sealed class WiiKeyEntry { [JsonProperty("name")] public string? Name { get; set; } [JsonProperty("index")] public byte Index { get; set; } [JsonProperty("key")] public string? Key { get; set; } } } }