Skip to content
Draft
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
15 changes: 15 additions & 0 deletions .github/workflows/build-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,13 @@ jobs:
throw "Windows app host was not produced at unigetui_bin/UniGetUI.exe"
}

# The elevated policy-write helper is authenticated by exact path at runtime, and it must
# be present here so the code-signing step below signs it and the integrity tree that is
# generated afterwards covers it.
if (-not (Test-Path "unigetui_bin/Assets/Utilities/UniGetUI.PolicyElevator.exe")) {
throw "Elevated policy helper was not staged at unigetui_bin/Assets/Utilities/UniGetUI.PolicyElevator.exe"
}

$MaxShippedPdbSizeBytes = 1MB
$PdbsToRemove = Get-ChildItem "unigetui_bin" -Filter "*.pdb" -File -Recurse | Where-Object {
$_.Length -gt $MaxShippedPdbSizeBytes
Expand Down Expand Up @@ -247,6 +254,14 @@ jobs:
-CertificateName '${{ secrets.CODE_SIGNING_CERTIFICATE_NAME }}' `
-TimestampServer '${{ vars.CODE_SIGNING_TIMESTAMP_SERVER }}'

# The helper is the one binary whose signature is checked at runtime by the host before
# it is elevated, so an unsigned helper must fail the release rather than ship.
$HelperPath = Join-Path $PWD "unigetui_bin/Assets/Utilities/UniGetUI.PolicyElevator.exe"
$HelperSignature = Get-AuthenticodeSignature $HelperPath
if ($HelperSignature.Status -ne "Valid") {
throw "Elevated policy helper is not validly signed (status: $($HelperSignature.Status))."
}

- name: Build installer
shell: pwsh
run: |
Expand Down
8 changes: 8 additions & 0 deletions UniGetUI.iss
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,8 @@ begin
// Elevator (gsudo cache) and pinget live in {app} and lock their own files.
TaskKillWait('UniGetUI Elevator.exe');
TaskKillWait('pinget.exe');
// The elevated policy helper is short-lived, but it lives in {app} and can hold a file lock.
TaskKillWait('UniGetUI.PolicyElevator.exe');
Sleep(1000); // let the OS release file handles before copying

end;
Expand Down Expand Up @@ -346,3 +348,9 @@ Filename: "{app}\{#MyAppExeName}"; Parameters: "--migrate-wingetui-to-unigetui";
Filename: {sys}\taskkill.exe; Parameters: "/f /im WingetUI.exe"; Flags: skipifdoesntexist runhidden; RunOnceId: "KillWingetUI"
Filename: {sys}\taskkill.exe; Parameters: "/f /im UniGetUI.exe"; Flags: skipifdoesntexist runhidden; RunOnceId: "KillUniGetUI"
Filename: {sys}\taskkill.exe; Parameters: "/f /im UniGetUI.Avalonia.exe"; Flags: skipifdoesntexist runhidden; RunOnceId: "KillUniGetUIAvalonia"
Filename: {sys}\taskkill.exe; Parameters: "/f /im UniGetUI.PolicyElevator.exe"; Flags: skipifdoesntexist runhidden; RunOnceId: "KillUniGetUIPolicyElevator"

[UninstallDelete]
; The elevated policy helper is authenticated by exact path, so a leftover copy must never
; survive an uninstall.
Type: files; Name: "{app}\Assets\Utilities\UniGetUI.PolicyElevator.exe"
7 changes: 7 additions & 0 deletions scripts/build.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,13 @@ if (-not (Test-Path $WindowsAppHostPath)) {
throw "Windows app host was not produced at $WindowsAppHostPath"
}

# The elevated policy-write helper is authenticated by exact path at runtime, so a missing or
# misplaced helper must fail the build rather than silently ship an install that cannot elevate.
$PolicyElevatorPath = Join-Path $BinDir "Assets\Utilities\UniGetUI.PolicyElevator.exe"
if (-not (Test-Path $PolicyElevatorPath)) {
throw "Elevated policy helper was not staged at $PolicyElevatorPath"
}

# Keep smaller symbols for useful local crash source information, and prune oversized ones.
$MaxShippedPdbSizeBytes = 1MB

Expand Down
196 changes: 195 additions & 1 deletion src/Languages/lang_en.json

Large diffs are not rendered by default.

116 changes: 116 additions & 0 deletions src/UniGetUI.AgentPolicy.ElevatedHelper/PolicyReplacementExecutor.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
using System.Text.Json;
using Devolutions.Now.Policy.Api;
using Devolutions.Now.Policy.Client;
using UniGetUI.PackageEngine.AgentBroker.PolicyWriteElevation;

namespace UniGetUI.AgentPolicy.ElevatedHelper;

/// <summary>
/// Turns the single broker replacement call into the bounded response frame contract.
/// </summary>
internal static class PolicyReplacementExecutor
{
public static async Task<PolicyElevationResponseMessage> ExecuteAsync(
PolicyElevationRequestMessage request,
CancellationToken cancellationToken)
{
var response = new PolicyElevationResponseMessage
{
ProtocolVersion = PolicyElevationProtocol.Version,
RequestId = request.RequestId,
};

try
{
using var client = CreateClient();

PolicyReplacementResponse replacement = await client.ReplacePolicy(
new PolicyReplacementRequest
{
Draft = request.Draft,
Operation = (PolicyReplacementOperation)request.Operation,
ConflictHandling = (PolicyConflictHandling)request.ConflictHandling,
ExpectedStoreToken = request.ExpectedStoreToken,
ValidationReceipt = request.ValidationReceipt,
WarningsAcknowledged = request.WarningsAcknowledged,
},
cancellationToken).ConfigureAwait(false);

response.Outcome = PolicyElevationResponseStatus.Replaced;
response.Payload = SerializePayload(replacement);
return response;
}
catch (BrokerClientException ex)
{
response.Outcome = ex.Kind switch
{
BrokerClientErrorKind.BrokerUnavailable => PolicyElevationResponseStatus.BrokerUnavailable,
BrokerClientErrorKind.Timeout => PolicyElevationResponseStatus.BrokerUnavailable,
BrokerClientErrorKind.EmptyResponse => PolicyElevationResponseStatus.BrokerInvalidResponse,
BrokerClientErrorKind.InvalidResponse => PolicyElevationResponseStatus.BrokerInvalidResponse,
_ => PolicyElevationResponseStatus.BrokerRejected,
};

response.BrokerStatusCode = ex.StatusCode;
response.BrokerErrorCode = Truncate(
ex.BrokerError?.Code.ToString() ?? ex.Kind.ToString(),
PolicyElevationProtocol.MaxBrokerErrorCodeCharacters);
response.Message = "The Agent rejected the policy write.";
response.Payload = ex.BrokerError is null
? null
: SerializePayload(ex.BrokerError);
return response;
}
catch (OperationCanceledException)
{
response.Outcome = PolicyElevationResponseStatus.BrokerUnavailable;
response.Message = "The broker did not answer before the elevated helper timed out.";
return response;
}
catch (Exception ex) when (ex is IOException or InvalidOperationException or JsonException)
{
response.Outcome = PolicyElevationResponseStatus.BrokerInvalidResponse;
response.Message = "The Agent returned an invalid policy response.";
return response;
}
}

public static PolicyElevationResponseMessage Rejected(string requestId, string reason)
=> new()
{
ProtocolVersion = PolicyElevationProtocol.Version,
RequestId = requestId,
Outcome = PolicyElevationResponseStatus.HelperRejected,
Message = Truncate(reason, PolicyElevationProtocol.MaxMessageCharacters),
};

private static BrokerClient CreateClient()
=> new(new BrokerClientOptions
{
RequestedElevation = Elevation.Elevated,
EffectiveUser = GetEffectiveUser(),
ClientExecutablePath = Environment.ProcessPath,
ClientVersion = typeof(PolicyReplacementExecutor).Assembly.GetName().Version?.ToString(),
});

private static string GetEffectiveUser()
=> string.IsNullOrWhiteSpace(Environment.UserDomainName)
? Environment.UserName
: $"{Environment.UserDomainName}\\{Environment.UserName}";

private static JsonElement SerializePayload<T>(T payload)
{
using JsonDocument document = JsonDocument.Parse(BrokerJson.Serialize(payload));
return document.RootElement.Clone();
}

private static string? Truncate(string? value, int maxCharacters)
{
if (value is null)
{
return null;
}

return value.Length <= maxCharacters ? value : value[..maxCharacters];
}
}
Loading