mirror of
https://github.com/SabreTools/SabreTools.Serialization.git
synced 2026-09-21 22:35:06 +00:00
First round of cleanup for DolphinLib additions
This commit is contained in:
60
SabreTools.Wrappers.Test/GCZTests.cs
Normal file
60
SabreTools.Wrappers.Test/GCZTests.cs
Normal file
@@ -0,0 +1,60 @@
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Xunit;
|
||||
|
||||
namespace SabreTools.Wrappers.Test
|
||||
{
|
||||
public class GCZTests
|
||||
{
|
||||
[Fact]
|
||||
public void NullArray_Null()
|
||||
{
|
||||
byte[]? data = null;
|
||||
int offset = 0;
|
||||
var actual = GCZ.Create(data, offset);
|
||||
Assert.Null(actual);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmptyArray_Null()
|
||||
{
|
||||
byte[]? data = [];
|
||||
int offset = 0;
|
||||
var actual = GCZ.Create(data, offset);
|
||||
Assert.Null(actual);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvalidArray_Null()
|
||||
{
|
||||
byte[]? data = [.. Enumerable.Repeat<byte>(0xFF, 1024)];
|
||||
int offset = 0;
|
||||
var actual = GCZ.Create(data, offset);
|
||||
Assert.Null(actual);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NullStream_Null()
|
||||
{
|
||||
Stream? data = null;
|
||||
var actual = GCZ.Create(data);
|
||||
Assert.Null(actual);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmptyStream_Null()
|
||||
{
|
||||
Stream? data = new MemoryStream([]);
|
||||
var actual = GCZ.Create(data);
|
||||
Assert.Null(actual);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvalidStream_Null()
|
||||
{
|
||||
Stream? data = new MemoryStream([.. Enumerable.Repeat<byte>(0xFF, 1024)]);
|
||||
var actual = GCZ.Create(data);
|
||||
Assert.Null(actual);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,238 +0,0 @@
|
||||
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.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Parses a named Wii common-key JSON file and returns a
|
||||
/// <see cref="NintendoDisc.CommonKeyProvider"/>-compatible delegate.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Expected file format:
|
||||
/// <code>
|
||||
/// [
|
||||
/// { "name": "retail", "index": 0, "key": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" },
|
||||
/// { "name": "korean", "index": 1, "key": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" }
|
||||
/// ]
|
||||
/// </code>
|
||||
/// Whitespace inside hex strings is ignored. Returns <see langword="null"/> from the
|
||||
/// delegate for any index not present in the file.
|
||||
/// </remarks>
|
||||
internal static Func<byte, byte[]?> LoadKeyProvider(string path)
|
||||
{
|
||||
string json = File.ReadAllText(path);
|
||||
var entries = JsonConvert.DeserializeObject<List<WiiKeyEntry>>(json)
|
||||
?? throw new FormatException("Key file could not be deserialized.");
|
||||
|
||||
var map = new Dictionary<byte, byte[]>();
|
||||
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; }
|
||||
}
|
||||
}
|
||||
}
|
||||
94
SabreTools.Wrappers.Test/NintendoDiscTests.cs
Normal file
94
SabreTools.Wrappers.Test/NintendoDiscTests.cs
Normal file
@@ -0,0 +1,94 @@
|
||||
using System;
|
||||
using Xunit;
|
||||
|
||||
namespace SabreTools.Wrappers.Test
|
||||
{
|
||||
[Collection("NintendoDisc")]
|
||||
public class NintendoDiscTests
|
||||
{
|
||||
#region DecryptTitleKey
|
||||
|
||||
[Fact]
|
||||
public void DecryptTitleKey_EmptyKeys_Null()
|
||||
{
|
||||
NintendoDisc.RetailCommonKey = [];
|
||||
NintendoDisc.KoreanCommonKey = [];
|
||||
|
||||
Assert.Null(NintendoDisc.DecryptTitleKey(new byte[16], new byte[8], 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecryptTitleKey_NullEncKey_Null()
|
||||
{
|
||||
NintendoDisc.RetailCommonKey = new byte[16];
|
||||
NintendoDisc.KoreanCommonKey = new byte[16];
|
||||
|
||||
Assert.Null(NintendoDisc.DecryptTitleKey(null, new byte[8], 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecryptTitleKey_WrongLengthEncKey_Null()
|
||||
{
|
||||
NintendoDisc.RetailCommonKey = new byte[16];
|
||||
NintendoDisc.KoreanCommonKey = new byte[16];
|
||||
|
||||
Assert.Null(NintendoDisc.DecryptTitleKey(new byte[8], new byte[8], 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecryptTitleKey_NullTitleId_Null()
|
||||
{
|
||||
NintendoDisc.RetailCommonKey = new byte[16];
|
||||
NintendoDisc.KoreanCommonKey = new byte[16];
|
||||
|
||||
Assert.Null(NintendoDisc.DecryptTitleKey(new byte[16], null, 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecryptTitleKey_WrongLengthTitleId_Null()
|
||||
{
|
||||
NintendoDisc.RetailCommonKey = new byte[16];
|
||||
NintendoDisc.KoreanCommonKey = new byte[16];
|
||||
|
||||
Assert.Null(NintendoDisc.DecryptTitleKey(new byte[16], new byte[4], 0));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(-1)]
|
||||
[InlineData(2)]
|
||||
public void DecryptTitleKey_UnknownIndex_Null(int index)
|
||||
{
|
||||
Assert.Null(NintendoDisc.DecryptTitleKey(new byte[16], new byte[8], index));
|
||||
}
|
||||
|
||||
[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.RetailCommonKey = commonKey;
|
||||
NintendoDisc.KoreanCommonKey = commonKey;
|
||||
|
||||
byte[]? decrypted = NintendoDisc.DecryptTitleKey(encTitleKey, titleId, 0);
|
||||
Assert.NotNull(decrypted);
|
||||
Assert.Equal(plainTitleKey, decrypted);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
@@ -3,15 +3,48 @@ using System.IO;
|
||||
using System.Linq;
|
||||
using SabreTools.Numerics.Extensions;
|
||||
using Xunit;
|
||||
using static SabreTools.Data.Models.NintendoDisc.Constants;
|
||||
|
||||
namespace SabreTools.Wrappers.Test
|
||||
{
|
||||
[Collection("NintendoDisc")]
|
||||
public class WIATests
|
||||
{
|
||||
// -----------------------------------------------------------------------
|
||||
// WIA.Create null / invalid guards
|
||||
// -----------------------------------------------------------------------
|
||||
/// <summary>
|
||||
/// Arbitrary test-only common key — no relation to any real Wii key.
|
||||
/// Used by both <see cref="BuildMinimalWiiIso"/> and <see cref="EncryptTitleKeyIndependent"/>.
|
||||
/// </summary>
|
||||
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
|
||||
|
||||
public WIATests()
|
||||
{
|
||||
NintendoDisc.RetailCommonKey = TestCommonKey;
|
||||
NintendoDisc.KoreanCommonKey = TestCommonKey;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NullArray_Null()
|
||||
@@ -64,66 +97,21 @@ namespace SabreTools.Wrappers.Test
|
||||
Assert.Null(actual);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// DumpIso guard
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void DumpIso_NullPath_ReturnsFalse()
|
||||
{
|
||||
// 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.
|
||||
var wia = BuildMinimalWiiWia();
|
||||
|
||||
Assert.NotNull(wia);
|
||||
Assert.False(wia!.DumpIso(null!));
|
||||
Assert.False(wia!.DumpIso(null));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// -----------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Builds a minimal synthetic Wii disc (one WiiGroup per partition) and returns a live
|
||||
/// <see cref="WIA"/> wrapper backed by a <see cref="MemoryStream"/>.
|
||||
/// Returns null if any step fails.
|
||||
/// </summary>
|
||||
private static WIA? BuildMinimalWiiWia()
|
||||
{
|
||||
NintendoDisc.CommonKeyProvider = _ => TestCommonKey;
|
||||
try
|
||||
{
|
||||
byte[] iso = BuildMinimalWiiIso(TestCommonKey);
|
||||
var nd = NintendoDisc.Create(new MemoryStream(iso));
|
||||
if (nd is null) return null;
|
||||
|
||||
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 _);
|
||||
if (!ok) return null;
|
||||
ms.Position = 0;
|
||||
return WIA.Create(ms);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
NintendoDisc.CommonKeyProvider = null;
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Round-trip: Wii (partition crypto — encrypt → WIA → dump → decrypt)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// 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),
|
||||
@@ -134,10 +122,10 @@ namespace SabreTools.Wrappers.Test
|
||||
/// This exercises both directions:
|
||||
/// • WIA write path re-encrypts partition data correctly (<see cref="WIA.ConvertFromDiscToStream"/>)
|
||||
/// • WIA read path (<see cref="WiaVirtualStream"/>) re-encrypts WIA decrypted groups back to
|
||||
/// ISO-layout AES-CBC blocks via <c>GetCachedEncGroup</c> / <c>EncryptWiiGroup</c>
|
||||
/// ISO-layout AES-CBC blocks via GetCachedEncGroup / EncryptWiiGroup
|
||||
///
|
||||
/// Anti-bias: the final decryption uses <see cref="NintendoDisc.DecryptBlock"/> — a single-block
|
||||
/// AES-CBC call that is completely independent of <c>EncryptWiiGroup</c> — so a symmetric bug
|
||||
/// 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 <see cref="AesCbc.Encrypt"/> (BouncyCastle), while the
|
||||
/// verification uses <see cref="NintendoDisc.DecryptBlock"/> — a different code path.
|
||||
@@ -145,9 +133,6 @@ namespace SabreTools.Wrappers.Test
|
||||
[Fact]
|
||||
public void Wii_WiaNoneRoundTrip_Succeeds()
|
||||
{
|
||||
NintendoDisc.CommonKeyProvider = _ => TestCommonKey;
|
||||
try
|
||||
{
|
||||
// ---- Build synthetic Wii ISO ----
|
||||
byte[] iso = BuildMinimalWiiIso(TestCommonKey);
|
||||
|
||||
@@ -165,8 +150,7 @@ namespace SabreTools.Wrappers.Test
|
||||
compressionLevel: 5,
|
||||
chunkSize: Data.Models.WIA.Constants.DefaultChunkSize,
|
||||
out Exception? writeEx);
|
||||
Assert.True(written,
|
||||
$"ConvertFromDiscToStream failed: {writeEx?.GetType().Name}: {writeEx?.Message}\n{writeEx?.StackTrace}");
|
||||
Assert.True(written, $"ConvertFromDiscToStream failed: {writeEx?.GetType().Name}: {writeEx?.Message}\n{writeEx?.StackTrace}");
|
||||
|
||||
// ---- Decompress back to ISO ----
|
||||
wiaMs.Position = 0;
|
||||
@@ -181,58 +165,85 @@ namespace SabreTools.Wrappers.Test
|
||||
|
||||
byte[] dumpedIso = File.ReadAllBytes(tempIso);
|
||||
|
||||
const int WiiBlockSize = 0x8000;
|
||||
const int WiiBlockDataSize = 0x7C00;
|
||||
const int WiiBlocksPerGroup = 64;
|
||||
const int WiiGroupSize = WiiBlocksPerGroup * WiiBlockSize;
|
||||
const int HeaderAreaSize = 0x8000;
|
||||
const long Partition0Offset = 0x60000;
|
||||
const long Partition0Data = Partition0Offset + HeaderAreaSize;
|
||||
const long Partition1Offset = Partition0Data + WiiGroupSize;
|
||||
const long Partition1Data = Partition1Offset + HeaderAreaSize;
|
||||
|
||||
byte[] titleKey = new byte[16]
|
||||
{
|
||||
byte[] titleKey =
|
||||
[
|
||||
0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF,
|
||||
0xFE, 0xDC, 0xBA, 0x98, 0x76, 0x54, 0x32, 0x10,
|
||||
};
|
||||
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;
|
||||
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;
|
||||
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,
|
||||
Partition0Data,
|
||||
plain0,
|
||||
titleKey,
|
||||
WiiBlocksPerGroup,
|
||||
WiiBlockSize,
|
||||
WiiBlockDataSize,
|
||||
partitionLabel: "Partition 0");
|
||||
|
||||
VerifyPartitionPlaintext(dumpedIso, Partition1Data, plain1, titleKey,
|
||||
WiiBlocksPerGroup, WiiBlockSize, WiiBlockDataSize, partitionLabel: "Partition 1");
|
||||
VerifyPartitionPlaintext(dumpedIso,
|
||||
Partition1Data,
|
||||
plain1,
|
||||
titleKey,
|
||||
WiiBlocksPerGroup,
|
||||
WiiBlockSize,
|
||||
WiiBlockDataSize,
|
||||
partitionLabel: "Partition 1");
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(tempIso)) File.Delete(tempIso);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
NintendoDisc.CommonKeyProvider = null;
|
||||
if (File.Exists(tempIso))
|
||||
File.Delete(tempIso);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Wii test helpers
|
||||
// -----------------------------------------------------------------------
|
||||
#region Wii test helpers
|
||||
|
||||
/// <summary>
|
||||
/// Arbitrary test-only common key — no relation to any real Wii key.
|
||||
/// Used by both <see cref="BuildMinimalWiiIso"/> and <see cref="EncryptTitleKeyIndependent"/>.
|
||||
/// Builds a minimal synthetic Wii disc (one WiiGroup per partition) and returns a live
|
||||
/// <see cref="WIA"/> wrapper backed by a <see cref="MemoryStream"/>.
|
||||
/// Returns null if any step fails.
|
||||
/// </summary>
|
||||
private static readonly byte[] TestCommonKey =
|
||||
private static WIA? BuildMinimalWiiWia()
|
||||
{
|
||||
0xDE, 0xAD, 0xBE, 0xEF, 0xCA, 0xFE, 0xF0, 0x0D,
|
||||
0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88,
|
||||
};
|
||||
try
|
||||
{
|
||||
byte[] iso = BuildMinimalWiiIso(TestCommonKey);
|
||||
var nd = NintendoDisc.Create(new MemoryStream(iso));
|
||||
if (nd is null)
|
||||
return null;
|
||||
|
||||
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 _);
|
||||
|
||||
if (!ok)
|
||||
return null;
|
||||
|
||||
ms.Position = 0;
|
||||
return WIA.Create(ms);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a minimal synthetic Wii ISO with 2 partitions (1 WiiGroup each), encrypted
|
||||
@@ -240,37 +251,29 @@ namespace SabreTools.Wrappers.Test
|
||||
/// </summary>
|
||||
private static byte[] BuildMinimalWiiIso(byte[] commonKey)
|
||||
{
|
||||
const int WiiBlockSize = 0x8000;
|
||||
const int WiiBlockDataSize = 0x7C00;
|
||||
const int WiiBlocksPerGroup = 64;
|
||||
const int WiiGroupDataSize = WiiBlocksPerGroup * WiiBlockDataSize;
|
||||
const int WiiGroupSize = WiiBlocksPerGroup * WiiBlockSize;
|
||||
|
||||
byte[] titleKey = new byte[16]
|
||||
{
|
||||
byte[] titleKey =
|
||||
[
|
||||
0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF,
|
||||
0xFE, 0xDC, 0xBA, 0x98, 0x76, 0x54, 0x32, 0x10,
|
||||
};
|
||||
byte[] titleId = new byte[8] { 0x00, 0x01, 0x00, 0x45, 0x52, 0x53, 0x42, 0x00 };
|
||||
];
|
||||
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;
|
||||
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;
|
||||
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);
|
||||
|
||||
const long PartitionTableOffset = 0x40000;
|
||||
const long PartitionListOffset = 0x50000;
|
||||
const long Partition0Offset = 0x60000;
|
||||
const int HeaderAreaSize = 0x8000; // data starts one full block after partition base
|
||||
const long Partition0Data = Partition0Offset + HeaderAreaSize;
|
||||
const long Partition1Offset = Partition0Data + WiiGroupSize;
|
||||
const long Partition1Data = Partition1Offset + HeaderAreaSize;
|
||||
const long IsoSize = Partition1Data + WiiGroupSize;
|
||||
|
||||
byte[] iso = new byte[IsoSize];
|
||||
|
||||
iso[0] = (byte)'R'; iso[1] = (byte)'S'; iso[2] = (byte)'B'; iso[3] = (byte)'E';
|
||||
@@ -296,8 +299,19 @@ namespace SabreTools.Wrappers.Test
|
||||
return iso;
|
||||
}
|
||||
|
||||
private static void WritePartitionHeader(byte[] iso, long partOffset,
|
||||
byte[] encTitleKey, byte[] titleId, byte ckIdx)
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
/// <param name="iso"></param>
|
||||
/// <param name="partOffset"></param>
|
||||
/// <param name="encTitleKey"></param>
|
||||
/// <param name="titleId"></param>
|
||||
/// <param name="ckIdx"></param>
|
||||
private static void WritePartitionHeader(byte[] iso,
|
||||
long partOffset,
|
||||
byte[] encTitleKey,
|
||||
byte[] titleId,
|
||||
byte ckIdx)
|
||||
{
|
||||
// Signature type 0x10001 at partOffset+0
|
||||
int off = (int)partOffset;
|
||||
@@ -325,12 +339,16 @@ namespace SabreTools.Wrappers.Test
|
||||
/// <summary>
|
||||
/// Decrypts each block of one WII partition in the dumped ISO using only
|
||||
/// <see cref="NintendoDisc.DecryptBlock"/> (a single-block AES-CBC call that is
|
||||
/// completely independent of <c>EncryptWiiGroup</c>) and asserts the decrypted
|
||||
/// completely independent of EncryptWiiGroup) and asserts the decrypted
|
||||
/// block data matches the corresponding slice of <paramref name="expectedPlaintext"/>.
|
||||
/// </summary>
|
||||
private static void VerifyPartitionPlaintext(byte[] iso, long dataStart,
|
||||
byte[] expectedPlaintext, byte[] titleKey,
|
||||
int blocksPerGroup, int blockSize, int blockDataSize,
|
||||
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++)
|
||||
@@ -370,6 +388,6 @@ namespace SabreTools.Wrappers.Test
|
||||
?? throw new InvalidOperationException("AesCbc.Encrypt returned null");
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user