Skip to content

Commit

Permalink
More tests
Browse files Browse the repository at this point in the history
  • Loading branch information
svrooij committed Nov 21, 2024
1 parent e933c79 commit 931b17b
Show file tree
Hide file tree
Showing 5 changed files with 211 additions and 29 deletions.
50 changes: 41 additions & 9 deletions src/SvR.ContentPrep/Encryptor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,19 @@ private static byte[] GenerateAesIV()
return aes.IV;
}

/// <summary>
/// Input stream will be encrypted and written to the target stream. The target stream will contain the hash (32 bytes) (of the IV and the encrypted data), the IV (16 bytes) and the encrypted data
/// </summary>
/// <param name="sourceStream">Input stream, needs Seek and Read</param>
/// <param name="targetStream">Output stream, needs seek and Read and Write</param>
/// <param name="encryptionKey">AES Encryption key</param>
/// <param name="hmacKey">Some random data to compute an unique hash and validate the input afterwards</param>
/// <param name="initializationVector">AES initialization vector</param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
/// <remarks>If the output stream is not 48 bytes bigger then the input stream, something is wrong</remarks>
/// <exception cref="ArgumentNullException"></exception>
/// <exception cref="ArgumentException"></exception>
private static async Task<byte[]> EncryptStreamWithIVAsync(
Stream sourceStream,
Stream targetStream,
Expand Down Expand Up @@ -120,11 +133,15 @@ private static async Task<byte[]> EncryptStreamWithIVAsync(
byte[]? encryptedFileHash;
using Aes aes = Aes.Create();
using HMACSHA256 hmac = new HMACSHA256(hmacKey);
int offset = hmac.HashSize / 8;
int hashSize = hmac.HashSize / 8; //32 bytes
int ivLength = initializationVector.Length; // 16 bytes
// Create an empty buffer for a specific length
byte[] buffer = new byte[offset + initializationVector.Length];
// Write the empty IV to the targetFileStream (empty bytes)
await targetStream.WriteAsync(buffer, 0, offset + initializationVector.Length, cancellationToken);
byte[] buffer = new byte[hashSize];
// Write the empty hash (empty bytes) and IV to the targetFileStream
await targetStream.WriteAsync(buffer, 0, buffer.Length, cancellationToken);
await targetStream.WriteAsync(initializationVector, 0, ivLength, cancellationToken);
await targetStream.FlushAsync(cancellationToken);
// At this point the targetStream will contain the empty hash (32 bytes) and the IV (16 bytes)
using (ICryptoTransform cryptoTransform = aes.CreateEncryptor(encryptionKey, initializationVector))
// Create a CryptoStream to write the encrypted data to the targetStream
#if NET8_0_OR_GREATER
Expand All @@ -146,13 +163,15 @@ private static async Task<byte[]> EncryptStreamWithIVAsync(
cryptoStream.FlushFinalBlock();
}
cancellationToken.ThrowIfCancellationRequested();

// Rewind the targetStream to the exact position where the IV should be written
targetStream.Seek(offset, SeekOrigin.Begin);
// Write the IV to the targetStream
await targetStream.WriteAsync(initializationVector, 0, initializationVector.Length, cancellationToken);
await targetStream.FlushAsync(cancellationToken);
//targetStream.Seek(hashSize, SeekOrigin.Begin);
//// Write the IV to the targetStream
//await targetStream.WriteAsync(initializationVector, 0, initializationVector.Length, cancellationToken);
//await targetStream.FlushAsync(cancellationToken);

// Rewind the targetStream to the exact position of the start of the IV (which should be included in the hash)
targetStream.Seek(offset, SeekOrigin.Begin);
targetStream.Seek(hashSize, SeekOrigin.Begin);
// Compute the hash of the targetStream
byte[] hash = await hmac.ComputeHashAsync(targetStream, cancellationToken);
encryptedFileHash = hash;
Expand All @@ -167,6 +186,15 @@ private static async Task<byte[]> EncryptStreamWithIVAsync(
return encryptedFileHash;
}

/// <summary>
/// Validate and decrypt a stream using the encryptionKey and hmacKey
/// </summary>
/// <param name="inputStream"></param>
/// <param name="encryptionKey">Base64 representation of the encryption key</param>
/// <param name="hmacKey">Base64 encoded representation of the hmacKey</param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
/// <exception cref="InvalidDataException">If the hash does not match it will throw an error</exception>
internal static async Task<Stream> DecryptStreamAsync(Stream inputStream, string encryptionKey, string hmacKey, CancellationToken cancellationToken)
{
var resultStream = new MemoryStream();
Expand All @@ -183,10 +211,14 @@ internal static async Task<Stream> DecryptStreamAsync(Stream inputStream, string
{
throw new InvalidDataException("Hashes do not match");
}
// Go to end of the hash
inputStream.Seek(offset, SeekOrigin.Begin);

// Read the IV from the stream (16 pytes)
byte[] iv = new byte[aes.IV.Length];
await inputStream.ReadAsync(iv, 0, iv.Length, cancellationToken);

// At this point the inputStream is at the start of the encrypted data
using ICryptoTransform cryptoTransform = aes.CreateDecryptor(encryptionKeyBytes, iv);
using CryptoStream cryptoStream = new CryptoStream(inputStream, cryptoTransform, CryptoStreamMode.Read);
await cryptoStream.CopyToAsync(resultStream, DefaultBufferSize, cancellationToken);
Expand Down
46 changes: 45 additions & 1 deletion src/SvR.ContentPrep/Packager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ public Packager(ILogger<Packager>? logger = null)
}

/// <summary>
/// Encrypt a zip file to be uploaded to Intune programmatically, without zipping the result with the `MetaData\detections.xml` file.
/// Encrypt a zip file to be uploaded to Intune programmatically, without zipping the result with the `MetaData\detections.xml` file. The output stream should be exactly 48 bytes larger than the input stream. Because it will have the hash (32 bytes) and the iv (16 bytes) prefixed.
/// </summary>
/// <param name="streamWithZippedSetupFiles"><see cref="Stream"/> with the zipped setup files</param>
/// <param name="outputStream"><see cref="Stream"/> to write the encrypted package to</param>
Expand Down Expand Up @@ -209,6 +209,50 @@ public Packager(ILogger<Packager>? logger = null)
}
}

/// <summary>
/// Decrypt and unpack a stream to a folder
/// </summary>
/// <param name="encryptedStream">Encrypted file stream, needs CanRead and CanSeek. Hash (32 bytes) + IV (16 bytes) + encrypted data</param>
/// <param name="outputFolder">Folder to extract the contents to</param>
/// <param name="encryptionInfo">Encryption info as generated upon encrypting. Only the `EncryptionKey` and the `MacKey` are required.</param>
/// <param name="cancellationToken">Cancellation token if your want to be able to cancel this request.</param>
/// <returns></returns>
/// <exception cref="ArgumentNullException"></exception>
/// <exception cref="ArgumentException"></exception>
/// <exception cref="InvalidDataException"></exception>
public async Task<bool> DecryptAndUnpackStreamToFolder(Stream encryptedStream, string outputFolder, FileEncryptionInfo encryptionInfo, CancellationToken cancellationToken = default)
{
if (encryptedStream == null)
throw new ArgumentNullException(nameof(encryptedStream));
if (string.IsNullOrEmpty(outputFolder))
throw new ArgumentNullException(nameof(outputFolder));
if (encryptionInfo == null)
throw new ArgumentNullException(nameof(encryptionInfo));
if (!encryptedStream.CanRead || !encryptedStream.CanSeek)
throw new ArgumentException("Stream can not be read or seeked in", nameof(encryptedStream));
if (encryptedStream.Length < 49)
throw new InvalidDataException("Stream is too short to contain a valid encryption header");
_logger.LogDebug("Decrypting stream to {OutputFolder}", outputFolder);

long streamLength = encryptedStream.Length;

try
{
using (Stream decryptedStream = await Encryptor.DecryptStreamAsync(encryptedStream, encryptionInfo.EncryptionKey!, encryptionInfo.MacKey!, cancellationToken))
{
await Zipper.UnzipStreamAsync(decryptedStream, outputFolder, cancellationToken);
//decryptedStream.Close();
_logger.LogInformation("Unpacked stream of size {StreamSize} to {OutputFolder}", streamLength, outputFolder);
}
return true;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error decrypting stream to folder {OutputFolder}", outputFolder);
throw;
}
}

internal static void TryWritingToFolder(string folder)
{
string path = Directory.Exists(folder) ? Path.Combine(folder, Guid.NewGuid().ToString()) : throw new DirectoryNotFoundException(string.Format(CultureInfo.InvariantCulture, "Folder '{0}' can not be found", folder));
Expand Down
137 changes: 121 additions & 16 deletions tests/SvR.ContentPrep.Tests/Packager.Tests.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
using System.Diagnostics;
using System.IO.Compression;
using System.Security.Cryptography;
using FluentAssertions;

namespace SvRooij.ContentPrep.Tests;

Expand Down Expand Up @@ -36,21 +38,66 @@ public async Task Packager_CreatePackage_Succeeds(int sizeInMb, int milliseconds
var fastEnough = (expectedPackageMs > stopwatch.ElapsedMilliseconds);
Assert.IsTrue(fastEnough, $"It took {stopwatch.ElapsedMilliseconds}ms to package instead of {expectedPackageMs}");
}
catch (AssertFailedException)
{
throw;
}
catch (TaskCanceledException)
{
Assert.Fail("Expected no timeout but got one");
}
catch (Exception ex)
finally
{
TestHelper.RemoveFolderIfExists(setupFolder);
TestHelper.RemoveFolderIfExists(outputDirectory);
}

}

[TestMethod]
[DataRow(2, 10000, 2000L)]
[DataRow(10, 30000, 3000L)]
[DataRow(100, 60000, 8000L)]
public async Task Packager_CreateUploadablePackage_Succeeds(int sizeInMb, int millisecondsDelay, long expectedPackageMs)
{
// Create Timeout in case something goes wrong
var cts = new CancellationTokenSource(millisecondsDelay);
var setupFolder = TestHelper.GetTestFolder();
var setupFile = await TestHelper.GenerateTempFileInFolder(setupFolder, setupFileName, sizeInMb, cts.Token);

var zipFolder = TestHelper.GetTestFolder();
var zipFilename = Path.Combine(zipFolder, "intunewin.tmp");
var zipStream = new FileStream(zipFilename, FileMode.Create, FileAccess.ReadWrite, FileShare.Delete, 8192, true);
ZipFile.CreateFromDirectory(setupFolder, zipStream, CompressionLevel.NoCompression, false);
long zipSize = zipStream.Length;

var outputDirectory = TestHelper.GetTestFolder();
var outputFile = Path.Combine(outputDirectory, "setup.intunewin");
var intuneWinStream = new FileStream(outputFile, FileMode.Create, FileAccess.ReadWrite, FileShare.Delete, 8192, true);
zipStream.Seek(0, SeekOrigin.Begin);
var packager = new Packager();

try
{

var stopwatch = new Stopwatch();
stopwatch.Start();
var info = await packager.CreateUploadablePackage(zipStream, intuneWinStream, new Models.ApplicationDetails { Name = "Test", SetupFile = setupFileName }, cts.Token);
stopwatch.Stop();
var fileExists = File.Exists(outputFile);
fileExists.Should().BeTrue("output package should exist");
var outputFilesize = new FileInfo(outputFile).Length;
zipSize.Should().BeLessThan(outputFilesize, "output package should have encryption data attached");
info!.UnencryptedContentSize.Should().Be(zipSize, "library should report correct input size");
var expectedSize = zipSize + 60;
outputFilesize.Should().Be(expectedSize, "output file should be 60 bytes bigger then input file");
expectedPackageMs.Should().BeGreaterThan(stopwatch.ElapsedMilliseconds, $"It took {stopwatch.ElapsedMilliseconds}ms to package instead of {expectedPackageMs}");

}
catch (TaskCanceledException)
{
Assert.Fail("Expected no exceptions but got {0}", ex.GetType().Name);
Assert.Fail("Expected no timeout but got one");
}
finally
{
TestHelper.RemoveFolderIfExists(setupFolder);
TestHelper.RemoveFolderIfExists(zipFolder);
TestHelper.RemoveFolderIfExists(outputDirectory);
}

Expand All @@ -63,7 +110,7 @@ public async Task Packager_Unpack_Succeeds()
var setupFolder = TestHelper.GetTestFolder();
var setupFile = await TestHelper.GenerateTempFileInFolder(setupFolder, setupFileName, 10, cts.Token);
var hasher = SHA256.Create();
await using var fs = new FileStream(setupFile, FileMode.Open, FileAccess.Read, FileShare.Read, bufferSize: 4096,
await using var fs = new FileStream(setupFile, FileMode.Open, FileAccess.Read, FileShare.Read, bufferSize: 8192,
useAsync: true);
var hash = await hasher.ComputeHashAsync(fs, cts.Token);
var originalFilesize = new FileInfo(setupFile).Length;
Expand All @@ -85,25 +132,83 @@ public async Task Packager_Unpack_Succeeds()
var unpackedFilesize = new FileInfo(unpackedSetup).Length;

Assert.AreEqual(originalFilesize, unpackedFilesize, "Original and unpacked setup are not the same size");
await using var outputFs = new FileStream(unpackedSetup, FileMode.Open, FileAccess.Read, FileShare.Read, bufferSize: 4096,
await using var outputFs = new FileStream(unpackedSetup, FileMode.Open, FileAccess.Read, FileShare.Read, bufferSize: 8192,
useAsync: true);
var outputHash = await hasher.ComputeHashAsync(outputFs, cts.Token);

var hashesAreEqual = TestHelper.CompareHashes(hash, outputHash);
Assert.IsTrue(hashesAreEqual, "Hashes don't match");
outputHash.Should().BeEquivalentTo(hash, "hashes should match");
}
catch (TaskCanceledException)
{
Assert.Fail("Expected no timeout but got one");
}
finally
{
TestHelper.RemoveFolderIfExists(packageDirectory);
TestHelper.RemoveFolderIfExists(outputDirectory);
TestHelper.RemoveFolderIfExists(setupFolder);
hasher.Dispose();
}
catch (AssertFailedException)


}

[TestMethod]
public async Task Packager_DecryptAndUnpack_Succeeds()
{
var cts = new CancellationTokenSource(60000);
var setupFolder = TestHelper.GetTestFolder();
var setupFile = await TestHelper.GenerateTempFileInFolder(setupFolder, setupFileName, 112, cts.Token);
var hasher = SHA256.Create();
await using var fs = new FileStream(setupFile, FileMode.Open, FileAccess.Read, FileShare.Read, bufferSize: 8192,
useAsync: true);
var hash = await hasher.ComputeHashAsync(fs, cts.Token);

var zipFolder = TestHelper.GetTestFolder();
var zipFilename = Path.Combine(zipFolder, "intunewin.tmp");
var zipStream = new FileStream(zipFilename, FileMode.Create, FileAccess.ReadWrite, FileShare.Delete, 8192, true);
ZipFile.CreateFromDirectory(setupFolder, zipStream, CompressionLevel.NoCompression, false);
long zipSize = zipStream.Length;

var originalFilesize = new FileInfo(setupFile).Length;
var outputDirectory = TestHelper.GetTestFolder();
var outputFile = Path.Combine(outputDirectory, "setup.intunewin");
var intuneWinStream = new FileStream(outputFile, FileMode.Create, FileAccess.ReadWrite, FileShare.ReadWrite | FileShare.Delete, 8192, true);
zipStream.Seek(0, SeekOrigin.Begin);
var packager = new Packager();

// Create a fake package (this is tested in another test, so we can skip that)
var info = await packager.CreateUploadablePackage(zipStream, intuneWinStream, new Models.ApplicationDetails { Name = "Test", SetupFile = setupFileName }, cts.Token);
await intuneWinStream.FlushAsync(cts.Token);
await intuneWinStream.DisposeAsync();
intuneWinStream = null;
await Task.Delay(1000, cts.Token);

var packageStream = new FileStream(outputFile, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete, 8192, true);
info.Should().NotBeNull("info should not be null");
info!.EncryptionInfo.Should().NotBeNull("encryption info should not be null");

try
{
throw;
// Start actual test
await packager.DecryptAndUnpackStreamToFolder(packageStream, outputDirectory, info!.EncryptionInfo!, cts.Token);

var unpackedSetup = Path.Combine(outputDirectory, setupFileName);
var unpackedFilesize = new FileInfo(unpackedSetup).Length;

unpackedFilesize.Should().Be(originalFilesize, "the file should exactly match");
await using var outputFs = new FileStream(unpackedSetup, FileMode.Open, FileAccess.Read, FileShare.Read, bufferSize: 4096,
useAsync: true);
var outputHash = await hasher.ComputeHashAsync(outputFs, cts.Token);
outputHash.Should().BeEquivalentTo(hash, "hashes should match");
}
catch (Exception ex)
catch (TaskCanceledException)
{
Assert.Fail("Expected no exceptions but got {0}", ex.GetType().Name);
Assert.Fail("Expected no timeout but got one");
}
finally
{
TestHelper.RemoveFolderIfExists(packageDirectory);
TestHelper.RemoveFolderIfExists(outputDirectory);
TestHelper.RemoveFolderIfExists(zipFolder);
TestHelper.RemoveFolderIfExists(setupFolder);
hasher.Dispose();
}
Expand Down
5 changes: 3 additions & 2 deletions tests/SvR.ContentPrep.Tests/SvR.ContentPrep.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,10 @@
<Version>0.1.2-alpha0013</Version>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.12.2" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="MSTest.TestAdapter" Version="3.6.0" />
<PackageReference Include="MSTest.TestFramework" Version="3.6.0" />
<PackageReference Include="MSTest.TestAdapter" Version="3.6.2" />
<PackageReference Include="MSTest.TestFramework" Version="3.6.2" />
<PackageReference Include="coverlet.collector" Version="6.0.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
Expand Down
2 changes: 1 addition & 1 deletion tests/SvR.ContentPrep.Tests/TestHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ public static async Task<string> GenerateTempFileInFolder(string folder, string
Random rnd = new Random();
byte[] data = new byte[1024];
await using var fs = new FileStream(tempFilename, FileMode.OpenOrCreate, FileAccess.Write, FileShare.Read,
bufferSize: 4096, useAsync: true);
bufferSize: 8192, useAsync: true);
int iterations = sizeInMb * 1024;
for (int i = 0; i < iterations; i++)
{
Expand Down

0 comments on commit 931b17b

Please sign in to comment.