Skip to content
Open
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
37 changes: 37 additions & 0 deletions .github/workflows/build-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,43 @@ jobs:
throw "Elevated policy helper is not validly signed (status: $($HelperSignature.Status))."
}

- name: Validate staged policy signer binding (${{ matrix.platform }})
if: ${{ fromJSON(needs.preflight.outputs.dry-run) == false }}
shell: pwsh
run: |
$Platform = '${{ matrix.platform }}'
$HostPath = Join-Path $PWD "unigetui_bin/UniGetUI.exe"
$HelperPath = Join-Path $PWD "unigetui_bin/Assets/Utilities/UniGetUI.PolicyElevator.exe"
if (-not (Test-Path -LiteralPath $HostPath -PathType Leaf)) { throw "Windows app host was not staged at $HostPath" }
if (-not (Test-Path -LiteralPath $HelperPath -PathType Leaf)) { throw "Elevated policy helper was not staged at $HelperPath" }
# WinVerifyTrust validates PE files without executing them, so the x64 test host can inspect arm64.
$env:UNIGETUI_RELEASE_POLICY_ELEVATION_HOST_PATH = $HostPath
$env:UNIGETUI_RELEASE_POLICY_ELEVATION_HELPER_PATH = $HelperPath
Write-Host "Authenticode/SPKI binding validation for win-$Platform staged artifacts."
$ResultsDirectory = Join-Path $env:RUNNER_TEMP "policy-signer-binding-$Platform"
if (Test-Path -LiteralPath $ResultsDirectory) {
Remove-Item -LiteralPath $ResultsDirectory -Recurse -Force
}

dotnet test src/UniGetUI.PackageEngine.Tests/UniGetUI.PackageEngine.Tests.csproj `
--no-build --no-restore --verbosity q --nologo /p:Platform=x64 `
--framework net10.0-windows10.0.26100.0 `
--filter "FullyQualifiedName=UniGetUI.PackageEngine.Tests.PolicyWriteElevation.PolicyElevationTrustPolicyTests.ReleaseArtifacts_AreAuthenticodeBound_WhenProvidedByReleaseWorkflow" `
--results-directory $ResultsDirectory `
--logger "trx;LogFileName=policy-signer-binding.trx"
if ($LASTEXITCODE -ne 0) { throw "Staged policy elevation signer binding validation failed for win-$Platform." }

$TrxPath = Join-Path $ResultsDirectory "policy-signer-binding.trx"
if (-not (Test-Path -LiteralPath $TrxPath -PathType Leaf)) {
throw "Policy signer-binding test did not produce $TrxPath."
}

[xml]$Trx = Get-Content -LiteralPath $TrxPath -Raw
$ExecutedTests = @($Trx.SelectNodes("//*[local-name()='UnitTestResult']"))
if ($ExecutedTests.Count -ne 1 -or $ExecutedTests[0].outcome -ne "Passed") {
throw "Expected exactly one passing policy signer-binding test; observed $($ExecutedTests.Count)."
}

- name: Build installer
shell: pwsh
run: |
Expand Down
188 changes: 37 additions & 151 deletions src/UniGetUI.AgentPolicy.ElevatedHelper/AuthenticatedBrokerTransport.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
using System.IO.Pipes;
using System.Security.Principal;
using System.Text;
using Devolutions.Now.Policy.Api;
using Devolutions.Now.Policy.Client;
using Microsoft.Win32.SafeHandles;
Expand All @@ -11,12 +10,10 @@ namespace UniGetUI.AgentPolicy.ElevatedHelper;

internal sealed class AuthenticatedBrokerTransport : IBrokerTransport
{
private const string DefaultPipeName = "Devolutions.Now.PackageBroker.v1";
private const int ConnectTimeoutMilliseconds = 5000;
private const int ReadTimeoutMilliseconds = 30000;
private const int MaxHeaderBytes = 65536;
internal const int MaxPolicyManagementResponseBytes =
BrokerApi.MaxPolicyManagementBodyBytes * 3 + MaxHeaderBytes;
BoundedNamedPipeBrokerTransport.MaxPolicyManagementResponseBodyBytes;
private readonly string _pipeName;
private readonly Func<NamedPipeClientStream, string, IDisposable> _authenticate;

Expand All @@ -29,7 +26,7 @@ internal AuthenticatedBrokerTransport(
string? pipeName,
Func<NamedPipeClientStream, string, IDisposable> authenticate)
{
_pipeName = string.IsNullOrWhiteSpace(pipeName) ? DefaultPipeName : pipeName;
_pipeName = string.IsNullOrWhiteSpace(pipeName) ? BrokerApi.DefaultPipeName : pipeName;
_authenticate = authenticate;
}

Expand All @@ -40,31 +37,52 @@ public async Task<BrokerTransportResponse> Send(
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);

var pipe = new NamedPipeClientStream(
".",
_pipeName,
PipeDirection.InOut,
PipeOptions.Asynchronous,
TokenImpersonationLevel.Identification);
bool cleanupTransferred = false;
try
{
using var pipe = new NamedPipeClientStream(
".",
_pipeName,
PipeDirection.InOut,
PipeOptions.Asynchronous,
TokenImpersonationLevel.Identification);
using (CancellationTokenSource connectCancellation =
CancellationTokenSource.CreateLinkedTokenSource(cancellationToken))
{
connectCancellation.CancelAfter(ConnectTimeoutMilliseconds);
await pipe.ConnectAsync(connectCancellation.Token).ConfigureAwait(false);
}

using IDisposable server = _authenticate(pipe, request.Path);
await WriteRequestAsync(pipe, request, cancellationToken).ConfigureAwait(false);
PolicyElevationHelperSynchronousStageResult<IDisposable> authentication =
await PolicyElevationHelperSynchronousStageRunner.RunAsync(
() => _authenticate(pipe, request.Path),
cancellationToken,
static abandoned => abandoned.Dispose(),
pipe.Dispose).ConfigureAwait(false);
if (!authentication.Completed)
{
// Authentication may still hold the pipe handle. Its continuation now owns both the
// eventual authentication result and the pipe, so the helper can stop waiting safely.
cleanupTransferred = true;
cancellationToken.ThrowIfCancellationRequested();
throw BrokerFailure(
BrokerClientErrorKind.Timeout,
$"Timed out authenticating the package broker at {request.Path}.",
request.Path);
}

using IDisposable server = authentication.Value;
await BoundedNamedPipeBrokerTransport
.WriteRequestAsync(pipe, request, cancellationToken)
.ConfigureAwait(false);
using CancellationTokenSource readCancellation =
CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
readCancellation.CancelAfter(ReadTimeoutMilliseconds);
return await ReadResponseAsync(
return await BoundedNamedPipeBrokerTransport.ReadResponseAsync(
pipe,
request.Path,
MaxPolicyManagementResponseBytes,
BrokerClientErrorKind.BrokerUnavailable,
readCancellation.Token).ConfigureAwait(false);
}
catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested)
Expand All @@ -91,147 +109,15 @@ public async Task<BrokerTransportResponse> Send(
request.Path,
ex);
}
}

public void Dispose()
{
}

private static async Task WriteRequestAsync(
Stream pipe,
BrokerTransportRequest request,
CancellationToken cancellationToken)
{
var headers = new StringBuilder()
.Append(request.Method)
.Append(' ')
.Append(request.Path)
.Append(" HTTP/1.1\r\n")
.Append("Host: now-package-broker\r\n")
.Append("Connection: close\r\n");
foreach ((string name, string value) in request.Headers)
finally
{
if (!name.Equals("Host", StringComparison.OrdinalIgnoreCase))
{
headers.Append(name).Append(": ").Append(value).Append("\r\n");
}
if (!cleanupTransferred)
pipe.Dispose();
}

byte[]? body = request.Body is null ? null : Encoding.UTF8.GetBytes(request.Body);
headers.Append("Content-Length: ").Append(body?.Length ?? 0).Append("\r\n\r\n");
await pipe.WriteAsync(
Encoding.ASCII.GetBytes(headers.ToString()),
cancellationToken).ConfigureAwait(false);
if (body is not null)
{
await pipe.WriteAsync(body, cancellationToken).ConfigureAwait(false);
}

await pipe.FlushAsync(cancellationToken).ConfigureAwait(false);
}

private static async Task<BrokerTransportResponse> ReadResponseAsync(
Stream pipe,
string path,
CancellationToken cancellationToken)
public void Dispose()
{
byte[] buffer = new byte[MaxHeaderBytes];
int totalRead = 0;
while (totalRead < MaxHeaderBytes)
{
int read = await pipe.ReadAsync(
buffer.AsMemory(totalRead, MaxHeaderBytes - totalRead),
cancellationToken).ConfigureAwait(false);
if (read == 0)
{
throw BrokerFailure(
BrokerClientErrorKind.BrokerUnavailable,
$"The package broker disconnected before sending a complete response for {path}.",
path);
}

totalRead += read;
string received = Encoding.ASCII.GetString(buffer, 0, totalRead);
int headerEnd = received.IndexOf("\r\n\r\n", StringComparison.Ordinal);
if (headerEnd < 0)
{
continue;
}

string[] lines = received[..headerEnd].Split("\r\n");
string[] status = lines[0].Split(' ', 3, StringSplitOptions.RemoveEmptyEntries);
if (status.Length < 2 || !int.TryParse(status[1], out int statusCode))
{
throw BrokerFailure(
BrokerClientErrorKind.InvalidResponse,
$"The package broker returned an invalid HTTP status line for {path}.",
path);
}

int? contentLength = null;
for (int index = 1; index < lines.Length; index++)
{
int separator = lines[index].IndexOf(':');
if (separator <= 0)
{
continue;
}

string name = lines[index][..separator].Trim();
if (!name.Equals("Content-Length", StringComparison.OrdinalIgnoreCase))
{
continue;
}

if (contentLength is not null
|| !int.TryParse(lines[index][(separator + 1)..].Trim(), out int parsed)
|| parsed < 0
|| parsed > MaxPolicyManagementResponseBytes)
{
throw BrokerFailure(
BrokerClientErrorKind.InvalidResponse,
$"The package broker returned an invalid Content-Length for {path}.",
path);
}

contentLength = parsed;
}

int bodyLength = contentLength ?? 0;
int bodyStart = headerEnd + 4;
if (bodyStart + bodyLength > buffer.Length)
{
Array.Resize(ref buffer, bodyStart + bodyLength);
}

int bodyRead = totalRead - bodyStart;
while (bodyRead < bodyLength)
{
read = await pipe.ReadAsync(
buffer.AsMemory(bodyStart + bodyRead, bodyLength - bodyRead),
cancellationToken).ConfigureAwait(false);
if (read == 0)
{
throw BrokerFailure(
BrokerClientErrorKind.BrokerUnavailable,
$"The package broker disconnected before sending the complete response body for {path}.",
path);
}

bodyRead += read;
}

return new BrokerTransportResponse
{
StatusCode = statusCode,
Body = Encoding.UTF8.GetString(buffer, bodyStart, bodyLength),
};
}

throw BrokerFailure(
BrokerClientErrorKind.InvalidResponse,
$"The package broker returned response headers that are too large for {path}.",
path);
}

private static BrokerClientException BrokerFailure(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@
Link="Shared\Protocol\%(Filename)%(Extension)" />
<Compile Include="..\UniGetUI.PackageEngine.AgentBroker\PolicyWriteElevation\Interop\*.cs"
Link="Shared\Interop\%(Filename)%(Extension)" />
<Compile Include="..\UniGetUI.PackageEngine.AgentBroker\PolicyWriteElevation\Shared\*.cs"
Link="Shared\Transport\%(Filename)%(Extension)" />
<Compile Include="..\SharedAssemblyInfo.cs" Link="SharedAssemblyInfo.cs" />
</ItemGroup>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ public sealed record PolicyEditorValidationState(
string SubmittedRawJson,
PolicyDraftDocument CanonicalDraft,
string Receipt,
PolicyEditorFindingIndex Findings);
PolicyEditorFindingIndex Findings,
long Epoch);

public sealed record PolicyEditorConflictSnapshot(
string SubmittedCanonicalRawJson,
Expand All @@ -35,6 +36,7 @@ public sealed class PolicyEditorSession
private long _mutationGeneration;
private long _cleanMutationGeneration;
private long _baselineVersion;
private long _validationEpoch;
private bool _isDirty;

public PolicyEditorOperationKind Operation { get; private set; }
Expand All @@ -56,6 +58,8 @@ public sealed class PolicyEditorSession

public long MutationGeneration => _mutationGeneration;

internal long ValidationEpoch => _validationEpoch;

public bool IsRawAnalysisPending { get; private set; }
internal bool LastRawAnalysisWasFormattingOnly { get; private set; }

Expand All @@ -65,6 +69,7 @@ public sealed class PolicyEditorSession

public bool IsValidationCurrent =>
Validation is not null
&& Validation.Epoch == _validationEpoch
&& !IsRawAnalysisPending
&& string.Equals(
Validation.SubmittedRawJson,
Expand Down Expand Up @@ -367,10 +372,21 @@ public void ApplyValidationResult(
string submittedRawJson,
PolicyValidationResult validation,
IReadOnlyList<PolicyValidationFinding>? boundedFindings = null,
int omittedFindingCount = 0) =>
TryApplyValidationResult(submittedRawJson, validation, _validationEpoch,
boundedFindings, omittedFindingCount);

internal bool TryApplyValidationResult(
string submittedRawJson,
PolicyValidationResult validation,
long validationEpoch,
IReadOnlyList<PolicyValidationFinding>? boundedFindings = null,
int omittedFindingCount = 0)
{
ArgumentNullException.ThrowIfNull(submittedRawJson);
ArgumentNullException.ThrowIfNull(validation);
if (validationEpoch != _validationEpoch)
return false;

IReadOnlyList<PolicyValidationFinding> findings;
if (boundedFindings is not null)
Expand Down Expand Up @@ -406,15 +422,17 @@ public void ApplyValidationResult(
|| string.IsNullOrWhiteSpace(validation.ValidationReceipt))
{
Validation = null;
return;
return true;
}

Validation = new PolicyEditorValidationState(
submittedRawJson,
PolicyEditorMapper.CloneDraftDocument(validation.CanonicalDraft),
validation.ValidationReceipt,
Findings);
Findings,
_validationEpoch);
Operation = ResolveOperationForDraftId(validation.CanonicalDraft.Metadata.Id);
return true;
}

public void CaptureConflict(
Expand Down Expand Up @@ -588,6 +606,7 @@ private void SetBaseline(string baselineRawJson, long mutationGeneration)
_baselineRawJson = baselineRawJson;
_cleanMutationGeneration = mutationGeneration;
_baselineVersion++;
_validationEpoch++;
}

private void ClearContentState()
Expand Down Expand Up @@ -623,6 +642,7 @@ private bool TryGetCanonicalEffectiveRaw(

string effectiveRawJson = GetEffectiveRawJson();
if (Validation is not null
&& Validation.Epoch == _validationEpoch
&& string.Equals(
Validation.SubmittedRawJson,
effectiveRawJson,
Expand Down
Loading
Loading