diff --git a/Essentials.ObfuscationProviders.Composite/CompositeObfuscationProvider.cs b/Essentials.ObfuscationProviders.Composite/CompositeObfuscationProvider.cs index cc80a1f..6c900b6 100644 --- a/Essentials.ObfuscationProviders.Composite/CompositeObfuscationProvider.cs +++ b/Essentials.ObfuscationProviders.Composite/CompositeObfuscationProvider.cs @@ -58,10 +58,9 @@ public bool TryObfuscate(ReadOnlySpan data, Span destination, out in { bytesWritten = 0; - byte[] current = data.ToArray(); - foreach (IObfuscationProvider stage in _stages) + if (!TryRunForward(data.ToArray(), out byte[] current)) { - current = stage.Obfuscate(current); + return false; } if (destination.Length < current.Length) @@ -84,10 +83,9 @@ public bool TryObfuscate(Stream data, Stream destination) using MemoryStream buffer = new(); data.CopyTo(buffer); - byte[] current = buffer.ToArray(); - foreach (IObfuscationProvider stage in _stages) + if (!TryRunForward(buffer.ToArray(), out byte[] current)) { - current = stage.Obfuscate(current); + return false; } destination.Write(current, 0, current.Length); @@ -99,10 +97,9 @@ public bool TryDeobfuscate(ReadOnlySpan obfuscatedData, Span destina { bytesWritten = 0; - byte[] current = obfuscatedData.ToArray(); - for (int i = _stages.Count - 1; i >= 0; i--) + if (!TryRunReverse(obfuscatedData.ToArray(), out byte[] current)) { - current = _stages[i].Deobfuscate(current); + return false; } if (destination.Length < current.Length) @@ -125,13 +122,58 @@ public bool TryDeobfuscate(Stream obfuscatedData, Stream destination) using MemoryStream buffer = new(); obfuscatedData.CopyTo(buffer); - byte[] current = buffer.ToArray(); - for (int i = _stages.Count - 1; i >= 0; i--) + if (!TryRunReverse(buffer.ToArray(), out byte[] current)) { - current = _stages[i].Deobfuscate(current); + return false; } destination.Write(current, 0, current.Length); return true; } + + /// + /// Obfuscates through every stage in order, stopping at the first stage + /// that fails. Each stage runs through its own Try method, so a failure is reported rather + /// than thrown and the composite keeps the Try contract of its stages. + /// + private bool TryRunForward(byte[] data, out byte[] result) + { + result = data; + foreach (IObfuscationProvider stage in _stages) + { + byte[] buffer = new byte[stage.GetMaxObfuscatedLength(result.Length)]; + if (!stage.TryObfuscate(result, buffer, out int written)) + { + result = []; + return false; + } + + result = buffer.AsSpan(0, written).ToArray(); + } + + return true; + } + + /// + /// Deobfuscates through every stage in reverse order, stopping at the + /// first stage that fails, for the same reason as . + /// + private bool TryRunReverse(byte[] data, out byte[] result) + { + result = data; + for (int i = _stages.Count - 1; i >= 0; i--) + { + IObfuscationProvider stage = _stages[i]; + byte[] buffer = new byte[stage.GetMaxDeobfuscatedLength(result.Length)]; + if (!stage.TryDeobfuscate(result, buffer, out int written)) + { + result = []; + return false; + } + + result = buffer.AsSpan(0, written).ToArray(); + } + + return true; + } } \ No newline at end of file diff --git a/Essentials.Tests/ObfuscationProviderTests.cs b/Essentials.Tests/ObfuscationProviderTests.cs index fc27435..5107881 100644 --- a/Essentials.Tests/ObfuscationProviderTests.cs +++ b/Essentials.Tests/ObfuscationProviderTests.cs @@ -6,7 +6,9 @@ namespace ktsu.Essentials.Tests; using System.Text; using ktsu.Essentials; using ktsu.Essentials.ObfuscationProviders.Base64; +using ktsu.Essentials.ObfuscationProviders.Composite; using ktsu.Essentials.ObfuscationProviders.Hex; +using ktsu.Essentials.ObfuscationProviders.Xor; using Microsoft.Extensions.DependencyInjection; using Microsoft.VisualStudio.TestTools.UnitTesting; @@ -95,4 +97,38 @@ public void Obfuscation_Hex_Roundtrip_String() Assert.AreNotEqual(original, obfuscated, "Obfuscated text should not match the input"); Assert.AreEqual(original, restored); } + + private static IObfuscationProvider CompositeWithFallibleStage() + => new CompositeObfuscationProvider([new XorObfuscationProvider(), new Base64ObfuscationProvider()]); + + [TestMethod] + public void Obfuscation_Composite_TryDeobfuscate_Span_ReturnsFalseWhenAStageFails() + { + IObfuscationProvider provider = CompositeWithFallibleStage(); + byte[] invalid = Encoding.UTF8.GetBytes("!!!"); + byte[] destination = new byte[provider.GetMaxDeobfuscatedLength(invalid.Length)]; + + Assert.IsFalse(provider.TryDeobfuscate(invalid, destination, out int bytesWritten)); + Assert.AreEqual(0, bytesWritten); + } + + [TestMethod] + public void Obfuscation_Composite_TryDeobfuscate_Stream_ReturnsFalseWhenAStageFails() + { + IObfuscationProvider provider = CompositeWithFallibleStage(); + using MemoryStream invalid = new(Encoding.UTF8.GetBytes("!!!")); + using MemoryStream destination = new(); + + Assert.IsFalse(provider.TryDeobfuscate(invalid, destination)); + Assert.AreEqual(0, destination.Length, "Nothing should be written when a stage fails"); + } + + [TestMethod] + public void Obfuscation_Composite_WithFallibleStage_Roundtrips() + { + IObfuscationProvider provider = CompositeWithFallibleStage(); + byte[] original = Encoding.UTF8.GetBytes("composite with a base64 stage"); + + CollectionAssert.AreEqual(original, provider.Deobfuscate(provider.Obfuscate(original))); + } } \ No newline at end of file