Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion TownSuite.CodeSigning.Client/SigningClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
private readonly string _url;
private readonly SemaphoreSlim semaphore = new SemaphoreSlim(4, 4);
private string batchId;
public SigningClient(HttpClient client, string baseUrl)

Check warning on line 18 in TownSuite.CodeSigning.Client/SigningClient.cs

View workflow job for this annotation

GitHub Actions / linux-build

Non-nullable field 'batchId' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the field as nullable.

Check warning on line 18 in TownSuite.CodeSigning.Client/SigningClient.cs

View workflow job for this annotation

GitHub Actions / windows-build

Non-nullable field 'batchId' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the field as nullable.
{
_client = client;
_url = baseUrl;
Expand Down Expand Up @@ -47,7 +47,7 @@
batchDetached = detached;
foreach (string filepath in filepaths)
{
bool failures = false;

Check warning on line 50 in TownSuite.CodeSigning.Client/SigningClient.cs

View workflow job for this annotation

GitHub Actions / linux-build

The variable 'failures' is assigned but its value is never used

Check warning on line 50 in TownSuite.CodeSigning.Client/SigningClient.cs

View workflow job for this annotation

GitHub Actions / windows-build

The variable 'failures' is assigned but its value is never used
try
{
var isHealthy = await HealthCheck();
Expand Down Expand Up @@ -96,11 +96,21 @@
return failedUploads.ToArray();
}

private static void AddFileExtensionHeader(HttpRequestMessage request, string filepath)
{
string extension = Path.GetExtension(filepath);
if (!string.IsNullOrEmpty(extension))
{
request.Headers.Add("X-FileExtension", extension);
}
}

private async Task SendRequest(bool quickFail, bool ignoreFailures, List<(string FailedFile, string Message)> failedUploads, string filepath, HttpRequestMessage request)
{
try
{
await semaphore.WaitAsync();
AddFileExtensionHeader(request, filepath);
using var fs = File.OpenRead(filepath);
request.Content = new StreamContent(fs);
Console.WriteLine($"Uploading file: {filepath}");
Expand Down Expand Up @@ -151,12 +161,21 @@
}
foreach (var file in results.Failures)
{
TrackedFiles.Remove(TrackedFiles.First(x => x.FilePath == file.FailedFile));
var tracked = TrackedFiles.FirstOrDefault(x => x.FilePath == file.FailedFile);
if (tracked.FilePath is not null) TrackedFiles.Remove(tracked);
}
await Task.Delay(1000);
count++;
}

foreach (var file in TrackedFiles.ToArray())
{
string message = $"Timed out after {batchTimeoutInSeconds} seconds waiting for the signing service to return a signed file.";
failedUploads.Add((file.FilePath, message));
Console.WriteLine($"Timed out waiting for: {file.FilePath}");
}
TrackedFiles.Clear();

return failedUploads.ToArray();
}

Expand All @@ -178,6 +197,7 @@

var request = new HttpRequestMessage(HttpMethod.Get, pollUrl);
request.Headers.Add("X-BatchId", batchId);
AddFileExtensionHeader(request, file.FilePath);
if (batchDetached) request.Headers.Add("X-Detached", "true");

var response = await _client.SendAsync(request);
Expand Down
114 changes: 114 additions & 0 deletions TownSuite.CodeSigning.ClientTests/SigningClientTimeoutTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
using NUnit.Framework;
using System.Net;
using TownSuite.CodeSigning.Client;

namespace TownSuite.CodeSigning.ClientTests
{
[TestFixture]
public class SigningClientTimeoutTest
{
private string _tempDir = string.Empty;

[SetUp]
public void SetUp()
{
_tempDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
Directory.CreateDirectory(_tempDir);
}

[TearDown]
public void TearDown()
{
try
{
if (Directory.Exists(_tempDir)) Directory.Delete(_tempDir, true);
}
catch { }
}

private sealed class NeverReadyHandler : HttpMessageHandler
{
public int PollCount;
public readonly List<string?> UploadExtensionHeaders = new();
public readonly List<string?> PollExtensionHeaders = new();

protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
string path = request.RequestUri!.AbsolutePath;

if (request.Method == HttpMethod.Get && path == "/healthz")
{
return Respond(HttpStatusCode.OK, "Healthy");
}
if (request.Method == HttpMethod.Post && path == "/sign/batch")
{
UploadExtensionHeaders.Add(ReadExtension(request));
return Respond(HttpStatusCode.OK, $"\"{Guid.NewGuid()}\"");
}
if (request.Method == HttpMethod.Get && path == "/sign/batch")
{
PollExtensionHeaders.Add(ReadExtension(request));
Interlocked.Increment(ref PollCount);
return Respond((HttpStatusCode)425, "The file has not been signed yet");
}

return Respond(HttpStatusCode.NotFound, string.Empty);
}

private static string? ReadExtension(HttpRequestMessage request) =>
request.Headers.TryGetValues("X-FileExtension", out var values) ? values.FirstOrDefault() : null;

private static Task<HttpResponseMessage> Respond(HttpStatusCode code, string body) =>
Task.FromResult(new HttpResponseMessage(code) { Content = new StringContent(body) });
}

private static (SigningClient Client, NeverReadyHandler Handler) Build()
{
var handler = new NeverReadyHandler();
var httpClient = new HttpClient(handler);
return (new SigningClient(httpClient, "http://localhost:5000/sign"), handler);
}

[Test]
public async Task ServiceNeverReturnsSignedFile_TimeoutIsReportedAsFailure()
{
string filePath = Path.Combine(_tempDir, "installer.msix");
File.WriteAllBytes(filePath, new byte[] { 1, 2, 3 });

var (client, handler) = Build();

var uploadFailures = await client.UploadFiles(quickFail: false, ignoreFailures: false, new[] { filePath }, detached: false);
Assert.That(uploadFailures, Is.Empty, "upload should succeed against the fake service");

var failures = await client.DownloadSignedFiles(quickFail: false, ignoreFailures: false, batchTimeoutInSeconds: 2);

Assert.Multiple(() =>
{
Assert.That(handler.PollCount, Is.GreaterThan(0), "the client should have polled at least once");
Assert.That(failures, Has.Length.EqualTo(1));
Assert.That(failures[0].FailedFile, Is.EqualTo(filePath));
Assert.That(failures[0].Message, Does.Contain("Timed out"));
});
}

[Test]
public async Task OriginalExtensionIsSentOnUploadAndPoll()
{
string filePath = Path.Combine(_tempDir, "installer.msix");
File.WriteAllBytes(filePath, new byte[] { 1, 2, 3 });

var (client, handler) = Build();

await client.UploadFiles(quickFail: false, ignoreFailures: false, new[] { filePath }, detached: false);
await client.DownloadSignedFiles(quickFail: false, ignoreFailures: false, batchTimeoutInSeconds: 2);

Assert.Multiple(() =>
{
Assert.That(handler.UploadExtensionHeaders, Is.Not.Empty);
Assert.That(handler.UploadExtensionHeaders, Has.All.EqualTo(".msix"));
Assert.That(handler.PollExtensionHeaders, Is.Not.Empty);
Assert.That(handler.PollExtensionHeaders, Has.All.EqualTo(".msix"));
});
}
}
}
51 changes: 46 additions & 5 deletions TownSuite.CodeSigning.Service/BatchedSigning.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,31 @@
using Microsoft.Extensions.Primitives;
using System.Collections;
using System.Runtime.InteropServices;
using System.Text.RegularExpressions;

namespace TownSuite.CodeSigning.Service
{
public static class BatchedSigning
{
private static readonly Regex SafeExtension = new(@"^\.[A-Za-z0-9]{1,15}$", RegexOptions.Compiled);

public static string GetSanitizedExtension(StringValues fileExtension)
{
string value = fileExtension.ToString();
if (string.IsNullOrWhiteSpace(value))
{
return string.Empty;
}

value = value.Trim();
if (!value.StartsWith('.'))
{
value = "." + value;
}

return SafeExtension.IsMatch(value) ? value.ToLowerInvariant() : string.Empty;
}

public static string GetTempFolder()
{
string? envTemp = Environment.GetEnvironmentVariable("TOWNSUITE_CODESIGNING_TEMP");
Expand All @@ -22,12 +42,14 @@
{
headers.TryGetValue("X-BatchId", out var batchId);
headers.TryGetValue("X-BatchReady", out var batchReady);
headers.TryGetValue("X-FileExtension", out var fileExtension);
bool isBatchJob = VerifyBatchId(batchId);

string id = Guid.NewGuid().ToString();
string extension = GetSanitizedExtension(fileExtension);

var workingFolder = new DirectoryInfo(Path.Combine(GetTempFolder(), isBatchJob ? batchId : id));

Check warning on line 51 in TownSuite.CodeSigning.Service/BatchedSigning.cs

View workflow job for this annotation

GitHub Actions / linux-build

Possible null reference argument for parameter 'path2' in 'string Path.Combine(string path1, string path2)'.

Check warning on line 51 in TownSuite.CodeSigning.Service/BatchedSigning.cs

View workflow job for this annotation

GitHub Actions / linux-build

Possible null reference assignment.

Check warning on line 51 in TownSuite.CodeSigning.Service/BatchedSigning.cs

View workflow job for this annotation

GitHub Actions / windows-build

Possible null reference argument for parameter 'path2' in 'string Path.Combine(string path1, string path2)'.

Check warning on line 51 in TownSuite.CodeSigning.Service/BatchedSigning.cs

View workflow job for this annotation

GitHub Actions / windows-build

Possible null reference assignment.
string workingFilePath = System.IO.Path.Combine(workingFolder.FullName, $"{id}.workingfile");
string workingFilePath = System.IO.Path.Combine(workingFolder.FullName, $"{id}.workingfile{extension}");
try
{
workingFolder.CreateIfNotExists();
Expand All @@ -46,7 +68,7 @@
else if (isBatchJob && !string.IsNullOrWhiteSpace(batchReady))
{
var files = GetBatchFiles(workingFolder);
ProcessFile(signer, logger, batchId, workingFolder, files);

Check warning on line 71 in TownSuite.CodeSigning.Service/BatchedSigning.cs

View workflow job for this annotation

GitHub Actions / linux-build

Possible null reference argument for parameter 'id' in 'void BatchedSigning.ProcessFile(ISigner signer, ILogger logger, string id, DirectoryInfo workingFolder, string[] files)'.

Check warning on line 71 in TownSuite.CodeSigning.Service/BatchedSigning.cs

View workflow job for this annotation

GitHub Actions / windows-build

Possible null reference argument for parameter 'id' in 'void BatchedSigning.ProcessFile(ISigner signer, ILogger logger, string id, DirectoryInfo workingFolder, string[] files)'.
}

return Results.Ok(id);
Expand Down Expand Up @@ -109,14 +131,20 @@

private static string[] GetBatchFiles(DirectoryInfo workingFolder)
{
return workingFolder.GetFiles("*.workingfile").Where(p => p.Length>0).Select(p => p.Name).ToArray();
return workingFolder.GetFiles("*.workingfile*")
.Where(p => p.Length > 0)
.Where(p => !p.Name.EndsWith(".sig", StringComparison.OrdinalIgnoreCase))
.Select(p => p.Name)
.ToArray();
}

public static async Task<IResult> Get(Dictionary<string, StringValues> headers, string id, ISigner signer)
{
headers.TryGetValue("X-BatchId", out var batchId);
headers.TryGetValue("X-FileExtension", out var fileExtension);
bool isBatchJob = VerifyBatchId(batchId);

string extension = GetSanitizedExtension(fileExtension);
var workingFolder = new DirectoryInfo(Path.Combine(GetTempFolder(), isBatchJob ? batchId : id));

if (!workingFolder.Exists)
Expand All @@ -127,19 +155,32 @@
string signedFilesIndicator = isBatchJob ? $"{batchId}.signed" : $"{id}.signed";
if (System.IO.File.Exists(System.IO.Path.Combine(workingFolder.FullName, signedFilesIndicator)))
{
var workingFile = System.IO.Path.Combine(workingFolder.FullName, signer.GetFileName(id));
var workingFile = System.IO.Path.Combine(workingFolder.FullName, signer.GetFileName(id, extension));
var workingStream = new TempFileStream(workingFile, !isBatchJob ? workingFolder : null);
return Results.Stream(workingStream);
}

if (System.IO.File.Exists(System.IO.Path.Combine(workingFolder.FullName, $"{id}.error")))
string? errorFile = FindErrorFile(workingFolder, isBatchJob ? batchId.ToString() : null, id);
if (errorFile is not null)
{
return Results.Problem(title: "Failure to sign", detail: await File.ReadAllTextAsync(System.IO.Path.Combine(workingFolder.FullName, $"{id}.error")), statusCode: 500);
return Results.Problem(title: "Failure to sign", detail: await File.ReadAllTextAsync(errorFile), statusCode: 500);
}

return Results.Problem(title: "Not Signed", detail: "The file has not been signed yet", statusCode: 425);
}

private static string? FindErrorFile(DirectoryInfo workingFolder, string? batchId, string id)
{
var candidates = new[] { $"{id}.error", batchId is null ? null : $"{batchId}.error" };
foreach (var candidate in candidates)
{
if (candidate is null) continue;
string path = System.IO.Path.Combine(workingFolder.FullName, candidate);
if (System.IO.File.Exists(path)) return path;
}
return null;
}

static void CleanupDir(DirectoryInfo dir, ILogger logger)
{
try
Expand Down
2 changes: 1 addition & 1 deletion TownSuite.CodeSigning.Service/ISigner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,6 @@
public interface ISigner
{
Task<(bool IsSigned, string Message)> SignAsync(string workingDir, string[] files);
string GetFileName(string id);
string GetFileName(string id, string extension);
}
}
4 changes: 2 additions & 2 deletions TownSuite.CodeSigning.Service/Signer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -104,9 +104,9 @@ private string MessageSnapshot()
return (p.ExitCode == 0, output);
}

public string GetFileName(string id)
public string GetFileName(string id, string extension)
{
return $"{id}.workingfile";
return $"{id}.workingfile{extension}";
}

private void process_ErrorDataReceived(object sender, DataReceivedEventArgs e)
Expand Down
6 changes: 3 additions & 3 deletions TownSuite.CodeSigning.Service/SignerDetached.cs
Original file line number Diff line number Diff line change
Expand Up @@ -201,14 +201,14 @@ private void CleanupOriginalFiles(string workingDir, string[] files)
}
}

public string GetFileName(string id)
public string GetFileName(string id, string extension)
{
if (string.IsNullOrWhiteSpace(_settings.OpenSSL.OsslSignCodePath) || string.IsNullOrWhiteSpace(_settings.OpenSSL.TimestampOptions))
{
return $"{id}.workingfile.sig";
return $"{id}.workingfile{extension}.sig";
}

return $"{id}.workingfile.timestamped.sig";
return $"{id}.workingfile{extension}.timestamped.sig";
}

private void process_ErrorDataReceived(object sender, DataReceivedEventArgs e)
Expand Down
4 changes: 2 additions & 2 deletions TownSuite.CodeSigning.Tests/BatachedDetachedSignatureTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ public async Task Test1(string[] srcFiles)
{
var signer = new SignerDetached(settings, NSubstitute.Substitute.For<ILogger<SignerDetached>>());
string id = ids[i];
string signaturePath = Path.Combine(AppContext.BaseDirectory, signer.GetFileName(id));
string signaturePath = Path.Combine(AppContext.BaseDirectory, signer.GetFileName(id, string.Empty));
var dr = await BatchedSigning.Get(new Dictionary<string, Microsoft.Extensions.Primitives.StringValues>(), id, signer);

if (dr is Microsoft.AspNetCore.Http.HttpResults.FileStreamHttpResult streamResult)
Expand Down Expand Up @@ -96,7 +96,7 @@ public async Task Test1(string[] srcFiles)
var id = ids[i];
var originalFile = Path.Combine(AppContext.BaseDirectory, srcFiles[i]);
var signer = new SignerDetached(settings, NSubstitute.Substitute.For<ILogger<SignerDetached>>());
var signatureFile = Path.Combine(AppContext.BaseDirectory, signer.GetFileName(id));
var signatureFile = Path.Combine(AppContext.BaseDirectory, signer.GetFileName(id, string.Empty));

var valid = Certs.ValidateDetachedSignature(originalFile, signatureFile, OneTimeUnitTestSetup.certPath, OneTimeUnitTestSetup.password);
if (!valid)
Expand Down
Loading
Loading