diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 40e2c2121..8f83cd4b1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -878,6 +878,9 @@ jobs: $DAgentSessionExecutable = Join-Path $TargetOutputPath "DevolutionsSession.exe" echo "dagent-session-executable=$DAgentSessionExecutable" >> $Env:GITHUB_OUTPUT + $DAgentPolicyConsentHelper = Join-Path $TargetOutputPath "DevolutionsAgentPolicyConsent.exe" + echo "dagent-policy-consent-helper=$DAgentPolicyConsentHelper" >> $Env:GITHUB_OUTPUT + $DAgentUpdaterExecutable = Join-Path $TargetOutputPath "DevolutionsAgentUpdater.exe" echo "dagent-updater-executable=$DAgentUpdaterExecutable" >> $Env:GITHUB_OUTPUT } @@ -1043,6 +1046,28 @@ jobs: DAGENT_EXECUTABLE: ${{ steps.load-variables.outputs.dagent-executable }} TARGET_OUTPUT_PATH: ${{ steps.load-variables.outputs.target-output-path }} + - name: Build NativeAOT policy consent helper + if: ${{ matrix.os == 'windows' }} + run: | + $Rid = "win-${{ matrix.arch }}" + $Output = Split-Path -Parent '${{ steps.load-variables.outputs.dagent-policy-consent-helper }}' + dotnet publish package/AgentPolicyConsent/DevolutionsAgentPolicyConsent.csproj ` + --configuration Release ` + --runtime $Rid ` + --output $Output ` + -p:Version=${{ needs.preflight.outputs.version }} + if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE + } + $Helper = '${{ steps.load-variables.outputs.dagent-policy-consent-helper }}' + if (-Not (Test-Path -LiteralPath $Helper -PathType Leaf)) { + throw "NativeAOT policy consent helper was not produced" + } + if ((Get-Item -LiteralPath $Helper).Length -gt 8MB) { + throw "NativeAOT policy consent helper exceeds 8 MiB" + } + shell: pwsh + - name: Package if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} run: | @@ -1056,6 +1081,7 @@ jobs: $Env:DAGENT_PEDM_SHELL_EXT_DLL = "${{ steps.load-variables.outputs.dagent-pedm-shell-ext-dll }}" $Env:DAGENT_PEDM_SHELL_EXT_MSIX = "${{ steps.load-variables.outputs.dagent-pedm-shell-ext-msix }}" $Env:DAGENT_SESSION_EXECUTABLE = "${{ steps.load-variables.outputs.dagent-session-executable }}" + $Env:DAGENT_POLICY_CONSENT_HELPER = "${{ steps.load-variables.outputs.dagent-policy-consent-helper }}" $Env:DAGENT_TUN2SOCKS_EXE = "${{ steps.tun2socks.outputs.tun2socks-executable-path }}" $Env:DAGENT_WINTUN_DLL = "${{ steps.tun2socks.outputs.wintun-library-path }}" $Env:DAGENT_MULTI_PWSH_EXECUTABLE = "${{ steps.multi-pwsh.outputs.executable-path }}" @@ -1177,6 +1203,10 @@ jobs: run: dotnet test package/AgentWindowsManaged.Tests/DevolutionsAgent.Installer.Tests.csproj shell: pwsh + - name: Policy consent helper tests + run: dotnet test package/AgentPolicyConsent.Tests/DevolutionsAgentPolicyConsent.Tests.csproj -c Release + shell: pwsh + winapi-sanitizer-tests: name: Windows API sanitizer tests diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index 27ee014f9..af56e0b9f 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -322,7 +322,7 @@ jobs: run: | $IncludePattern = @(switch ('${{ matrix.project }}') { 'devolutions-gateway' { @('DevolutionsGateway.exe') } - 'devolutions-agent' { @('DevolutionsAgent.exe', 'DevolutionsAgentUpdater.exe', 'DevolutionsPedmShellExt.dll', 'DevolutionsPedmShellExt.msix', 'DevolutionsDesktopAgent.exe') } + 'devolutions-agent' { @('DevolutionsAgent.exe', 'DevolutionsAgentUpdater.exe', 'DevolutionsAgentPolicyConsent.exe', 'DevolutionsPedmShellExt.dll', 'DevolutionsPedmShellExt.msix', 'DevolutionsDesktopAgent.exe') } 'jetsocat' { @('jetsocat.exe', 'jetsocat') } }) $ExcludePattern = "*.pdb" @@ -495,6 +495,7 @@ jobs: $Env:DAGENT_PEDM_SHELL_EXT_DLL = Get-ChildItem -Path $ArchRoot -Filter 'DevolutionsPedmShellExt.dll' -File | Select-Object -First 1 $Env:DAGENT_PEDM_SHELL_EXT_MSIX = Get-ChildItem -Path $ArchRoot -Filter 'DevolutionsPedmShellExt.msix' -File | Select-Object -First 1 $Env:DAGENT_SESSION_EXECUTABLE = Get-ChildItem -Path $ArchRoot -Filter 'DevolutionsSession.exe' -File | Select-Object -First 1 + $Env:DAGENT_POLICY_CONSENT_HELPER = Get-ChildItem -Path $ArchRoot -Filter 'DevolutionsAgentPolicyConsent.exe' -File | Select-Object -First 1 $Env:DAGENT_TUN2SOCKS_EXE = Join-Path $ArchRoot 'tun2socks.exe' $Env:DAGENT_WINTUN_DLL = Join-Path $ArchRoot 'wintun.dll' $MultiPwshDirectory = Join-Path $Env:RUNNER_TEMP 'multi-pwsh' 'windows' $Arch @@ -508,6 +509,7 @@ jobs: Write-Host "DAGENT_PEDM_SHELL_EXT_DLL = ${Env:DAGENT_PEDM_SHELL_EXT_DLL}" Write-Host "DAGENT_PEDM_SHELL_EXT_MSIX = ${Env:DAGENT_PEDM_SHELL_EXT_MSIX}" Write-Host "DAGENT_SESSION_EXECUTABLE = ${Env:DAGENT_SESSION_EXECUTABLE}" + Write-Host "DAGENT_POLICY_CONSENT_HELPER = ${Env:DAGENT_POLICY_CONSENT_HELPER}" Write-Host "DAGENT_TUN2SOCKS_EXE = ${Env:DAGENT_TUN2SOCKS_EXE}" Write-Host "DAGENT_WINTUN_DLL = ${Env:DAGENT_WINTUN_DLL}" Write-Host "DAGENT_MULTI_PWSH_EXECUTABLE = ${Env:DAGENT_MULTI_PWSH_EXECUTABLE}" @@ -534,7 +536,8 @@ jobs: @((Join-Path $ArchRoot DesktopAgent), (Get-ChildItem -Path $ArchRoot -Filter 'DevolutionsPedmShellExt.dll' | Select-Object -First 1), (Get-ChildItem -Path $ArchRoot -Filter 'DevolutionsPedmShellExt.msix' | Select-Object -First 1), - (Get-ChildItem -Path $ArchRoot -Filter 'DevolutionsSession.exe' | Select-Object -First 1)) | ForEach-Object { + (Get-ChildItem -Path $ArchRoot -Filter 'DevolutionsSession.exe' | Select-Object -First 1), + (Get-ChildItem -Path $ArchRoot -Filter 'DevolutionsAgentPolicyConsent.exe' | Select-Object -First 1)) | ForEach-Object { Remove-Item $_ -Recurse -ErrorAction SilentlyContinue | Out-Null } } diff --git a/ci/package-agent-windows.ps1 b/ci/package-agent-windows.ps1 index 4a37cc512..246cfe245 100644 --- a/ci/package-agent-windows.ps1 +++ b/ci/package-agent-windows.ps1 @@ -11,6 +11,8 @@ param( [parameter(Mandatory = $true)] [string] $SessionExe, [parameter(Mandatory = $true)] + [string] $PolicyConsentHelper, + [parameter(Mandatory = $true)] [ValidateSet('x64', 'arm64')] [string] $Architecture, [string] $Outfile @@ -98,6 +100,9 @@ function New-AgentMsi() { # The path to the devolutions-session.exe file. [string] $SessionExe, [parameter(Mandatory = $true)] + # The path to the signed DevolutionsAgentPolicyConsent.exe file. + [string] $PolicyConsentHelper, + [parameter(Mandatory = $true)] [ValidateSet('x64', 'arm64')] # Architecture: x64 or arm64 [string] $Architecture, @@ -120,6 +125,7 @@ function New-AgentMsi() { $PedmDll = Convert-Path -Path $PedmDll $PedmMsix = Convert-Path -Path $PedmMsix $SessionExe = Convert-Path -Path $SessionExe + $PolicyConsentHelper = Convert-Path -Path $PolicyConsentHelper if ($Outfile) { $Outfile = Convert-Path -Path $Outfile } @@ -137,6 +143,7 @@ function New-AgentMsi() { $myUpdaterExe = Set-FileNameAndCopy -Path $UpdaterExe -NewName 'DevolutionsAgentUpdater.exe' # The session is a service that gets launched on demand. $mySessionExe = Set-FileNameAndCopy -Path $SessionExe -NewName 'DevolutionsSession.exe' + $myPolicyConsentHelper = Set-FileNameAndCopy -Path $PolicyConsentHelper -NewName 'DevolutionsAgentPolicyConsent.exe' Write-Output "$repoDir\dotnet\DesktopAgent\bin\Release\net48\DevolutionsDesktopAgent.exe" @@ -145,6 +152,7 @@ function New-AgentMsi() { Set-EnvVarPath 'DAGENT_PEDM_SHELL_EXT_DLL' $myPedmDll Set-EnvVarPath 'DAGENT_PEDM_SHELL_EXT_MSIX' $myPedmMsix Set-EnvVarPath 'DAGENT_SESSION_EXECUTABLE' $mySessionExe + Set-EnvVarPath 'DAGENT_POLICY_CONSENT_HELPER' $myPolicyConsentHelper # The actual DevolutionsDesktopAgent.exe will be `\dotnet\DesktopAgent\bin\Release\net48\DevolutionsDesktopAgent.exe`. # After install, the contents of `net48` will be copied to `C:\Program Files\Devolutions\Agent\desktop\`. @@ -184,4 +192,4 @@ function New-AgentMsi() { Pop-Location } -New-AgentMsi -Generate:($Generate.IsPresent) -Exe $Exe -UpdaterExe $UpdaterExe -PedmDll $PedmDll -PedmMsix $PedmMsix -SessionExe $SessionExe -Architecture $Architecture -Outfile $Outfile +New-AgentMsi -Generate:($Generate.IsPresent) -Exe $Exe -UpdaterExe $UpdaterExe -PedmDll $PedmDll -PedmMsix $PedmMsix -SessionExe $SessionExe -PolicyConsentHelper $PolicyConsentHelper -Architecture $Architecture -Outfile $Outfile diff --git a/crates/now-package-broker/src/auth.rs b/crates/now-package-broker/src/auth.rs index d647e750c..f293a0306 100644 --- a/crates/now-package-broker/src/auth.rs +++ b/crates/now-package-broker/src/auth.rs @@ -32,6 +32,7 @@ use windows::Win32::System::Threading::{ use crate::policy_security::RetainedExecutableSecurity; const PROCESS_SYNCHRONIZE: PROCESS_ACCESS_RIGHTS = PROCESS_ACCESS_RIGHTS(0x0010_0000); +const POLICY_CONSENT_HELPER_NAME: &str = "DevolutionsAgentPolicyConsent.exe"; const PROCESS_IDENTITY_ACCESS: PROCESS_ACCESS_RIGHTS = PROCESS_ACCESS_RIGHTS( PROCESS_QUERY_INFORMATION.0 | PROCESS_QUERY_LIMITED_INFORMATION.0 | PROCESS_VM_READ.0 | PROCESS_SYNCHRONIZE.0, ); @@ -289,6 +290,20 @@ impl PipeClient { Ok(()) } + pub(crate) fn validate_policy_write(&self, skip_signature_validation: bool) -> anyhow::Result<()> { + self.validate_connection(skip_signature_validation)?; + // Dev builds cannot enforce helper identity when signature validation is explicitly disabled. + if signature_validation_skipped(skip_signature_validation) { + return Ok(()); + } + let agent = std::env::current_exe().context("failed to query Agent executable path")?; + let executable_file = self + .executable_file + .as_deref() + .context("policy consent helper executable handle is not retained")?; + Self::validate_policy_consent_helper_path(&self.executable_path, executable_file, &agent) + } + fn validate_process_instance(&self) -> anyhow::Result<()> { let Some(process) = &self.process else { return Ok(()); @@ -304,6 +319,29 @@ impl PipeClient { ) } + fn validate_policy_consent_helper_path(client: &Path, client_file: &File, agent: &Path) -> anyhow::Result<()> { + if !client + .file_name() + .is_some_and(|name| name.eq_ignore_ascii_case(POLICY_CONSENT_HELPER_NAME)) + { + bail!("policy replacement requires the Agent policy consent helper"); + } + let expected = agent + .parent() + .context("Agent executable has no installation directory")? + .join(POLICY_CONSENT_HELPER_NAME); + if !crate::policy_security::windows_paths_equal(client, &expected) { + bail!("policy consent helper is not the installed Agent helper path"); + } + let expected_id = file_id(&expected).context("failed to query installed policy consent helper identity")?; + let retained_id = + file_id_from_handle(client_file).context("failed to query retained policy consent helper identity")?; + if !same_file(&expected_id, &retained_id) { + bail!("policy consent helper does not match the installed helper"); + } + Ok(()) + } + /// Validate that the request's `effective_user` denotes the authenticated pipe client user. /// /// The name is resolved to a SID and compared against the SID captured at connect, @@ -539,6 +577,51 @@ mod tests { .expect_err("a recycled PID with a different creation time must be rejected"); } + #[test] + fn policy_consent_helper_requires_exact_agent_sibling_path() { + let current_executable = std::env::current_exe().expect("current executable"); + let current_file = open_executable_file(¤t_executable).expect("open current executable"); + let agent = Path::new(r"C:\Program Files\Devolutions\Agent\DevolutionsAgent.exe"); + assert!( + PipeClient::validate_policy_consent_helper_path( + Path::new(r"C:\Program Files\Devolutions\Agent\DevolutionsAgentPolicyConsent.exe"), + ¤t_file, + agent, + ) + .is_err(), + "path text alone must not authorize a different retained image" + ); + assert!( + PipeClient::validate_policy_consent_helper_path( + Path::new(r"C:\Users\Alice\DevolutionsAgentPolicyConsent.exe"), + ¤t_file, + agent, + ) + .is_err() + ); + assert!( + PipeClient::validate_policy_consent_helper_path( + Path::new(r"C:\Users\Alice\UniGetUI.exe"), + ¤t_file, + agent, + ) + .is_err() + ); + } + + #[test] + fn policy_consent_helper_accepts_exact_retained_sibling() { + let temp = tempfile::tempdir().expect("temp directory"); + let agent = temp.path().join("DevolutionsAgent.exe"); + let helper = temp.path().join(POLICY_CONSENT_HELPER_NAME); + std::fs::write(&agent, b"agent path anchor").expect("write Agent path anchor"); + std::fs::copy(std::env::current_exe().expect("current executable"), &helper).expect("copy helper fixture"); + let retained = open_executable_file(&helper).expect("retain helper fixture"); + + PipeClient::validate_policy_consent_helper_path(&helper, &retained, &agent) + .expect("exact retained sibling must be accepted"); + } + #[test] fn exited_process_cannot_supply_executable_identity() { let mut child = std::process::Command::new("powershell.exe") diff --git a/crates/now-package-broker/src/server/mod.rs b/crates/now-package-broker/src/server/mod.rs index 0f2a5fd6c..eea38ce7a 100644 --- a/crates/now-package-broker/src/server/mod.rs +++ b/crates/now-package-broker/src/server/mod.rs @@ -135,7 +135,12 @@ async fn authenticate_policy_management( | (&Method::PUT, "/v1/policy") ); if protected { - if let Err(error) = client.validate_connection(state.skip_signature_validation) { + let authentication = if matches!((request.method(), request.uri().path()), (&Method::PUT, "/v1/policy")) { + client.validate_policy_write(state.skip_signature_validation) + } else { + client.validate_connection(state.skip_signature_validation) + }; + if let Err(error) = authentication { if let Some(audit) = write_audit { audit.denied(crate::audit::DenialReason::AuthenticationFailed); } diff --git a/package/AgentPolicyConsent.Tests/DevolutionsAgentPolicyConsent.Tests.csproj b/package/AgentPolicyConsent.Tests/DevolutionsAgentPolicyConsent.Tests.csproj new file mode 100644 index 000000000..24bdd8852 --- /dev/null +++ b/package/AgentPolicyConsent.Tests/DevolutionsAgentPolicyConsent.Tests.csproj @@ -0,0 +1,22 @@ + + + net10.0-windows + win-x64 + true + false + enable + enable + + + + + + all + + + + + + diff --git a/package/AgentPolicyConsent.Tests/ProtocolTests.cs b/package/AgentPolicyConsent.Tests/ProtocolTests.cs new file mode 100644 index 000000000..9b4aafaee --- /dev/null +++ b/package/AgentPolicyConsent.Tests/ProtocolTests.cs @@ -0,0 +1,525 @@ +using System.Buffers.Binary; +using System.Security.AccessControl; +using System.Text.Json; +using DevolutionsAgentPolicyConsent; +using Microsoft.Win32.SafeHandles; +using Xunit; + +namespace DevolutionsAgentPolicyConsent.Tests; + +public sealed class ProtocolTests +{ + private const string RequestId = "0123456789abcdef0123456789abcdef"; + + [Fact] + public void ArgumentsRequireExactBoundIdentity() + { + Arguments parsed = Protocol.ParseArguments( + [ + "--protocol", "2.0", + "--pipe", $"UniGetUI.PolicyElevation.{RequestId}", + "--parent-pid", "42", + "--parent-created", "638900000000000000", + "--session", "1", + ]); + + Assert.Equal(42, parsed.ParentProcessId); + Assert.Equal((uint)1, parsed.SessionId); + } + + public sealed class TrustPolicyTests + { + [Fact] + public void CurrentAndTransitionSignersAreAccepted() + { + Assert.True(PeerLease.IsAllowedSigner(PeerLease.CurrentUiSignerSpkiSha256)); + Assert.True(PeerLease.IsAllowedSigner(PeerLease.TransitionUiSignerSpkiSha256)); + } + + [Fact] + public void LookalikeSignerIsRejected() + { + Assert.False(PeerLease.IsAllowedSigner(new string('0', 64))); + } + + [Fact] + public void AgentSignerRequiresKnownDevolutionsCertificate() + { + Assert.All( + PolicyConsentContract.DevolutionsSignerSha1Thumbprints, + thumbprint => Assert.True(PeerLease.IsAllowedDevolutionsSigner(thumbprint))); + Assert.False(PeerLease.IsAllowedDevolutionsSigner(new string('0', 40))); + } + + [Theory] + [InlineData("O:SYG:SYD:(A;;FA;;;SY)(A;;FA;;;BA)(A;;0x1200A9;;;BU)", PeerLease.FileTamperRights, true)] + [InlineData("O:BUG:SYD:(A;;FA;;;SY)(A;;FA;;;BA)", PeerLease.FileTamperRights, false)] + [InlineData("O:SYG:SYD:(A;;FA;;;SY)(A;;FA;;;BA)(A;;GW;;;BU)", PeerLease.FileTamperRights, false)] + [InlineData("O:SYG:SYD:(A;;FA;;;SY)(A;;FA;;;BA)(A;;0x6;;;AU)", PeerLease.ParentDirectoryTamperRights, false)] + [InlineData("O:SYG:SYD:(A;;FA;;;SY)(A;;FA;;;BA)(A;;0x6;;;AU)", PeerLease.AncestorDirectoryTamperRights, true)] + [InlineData("O:SYG:SYD:(A;;FA;;;SY)(A;;FA;;;BA)(A;;0x40;;;BU)", PeerLease.AncestorDirectoryTamperRights, false)] + public void ProtectedAgentPathRequiresTrustedOwnerAndWriters( + string sddl, + int tamperRights, + bool accepted) + { + RawSecurityDescriptor descriptor = new(sddl); + if (accepted) + { + PeerLease.VerifyTrustedSecurityDescriptor(descriptor, "test path", tamperRights); + } + else + { + Assert.Throws( + () => PeerLease.VerifyTrustedSecurityDescriptor(descriptor, "test path", tamperRights)); + } + } + + [Fact] + public void ProtectedAgentPathRejectsReparsePoints() + { + Assert.True(PeerLease.IsReparsePoint(PeerLease.FileAttributeReparsePoint)); + Assert.False(PeerLease.IsReparsePoint(0)); + } + + [Fact] + public void SignerMatchingIsCaseSensitive() + { + Assert.False(PeerLease.IsAllowedSigner(PeerLease.CurrentUiSignerSpkiSha256.ToUpperInvariant())); + } + + [Theory] + [InlineData("3.3.7")] + [InlineData("2026.2.7")] + [InlineData("2026.2.7-preview")] + public void ProductBindingSupportsProtocolEraAndCurrentInstallModes(string version) + { + Assert.True(PeerLease.IsSupportedUiIdentity("UniGetUI", "UniGetUI.dll", version)); + } + + [Theory] + [InlineData("Lookalike", "UniGetUI.dll", "2026.2.7")] + [InlineData("UniGetUI", "malware.exe", "2026.2.7")] + [InlineData("UniGetUI", "UniGetUI.dll", "3.3.6")] + public void ProductBindingRejectsLookalikes(string product, string originalFilename, string version) + { + Assert.False(PeerLease.IsSupportedUiIdentity(product, originalFilename, version)); + } + + [Theory] + [InlineData(41, 638900000000000000, 1)] + [InlineData(42, 638900000000000001, 1)] + [InlineData(42, 638900000000000000, 2)] + public void ProcessIdentityRejectsPidReuseAndSessionMismatch(int pid, long created, uint session) + { + Arguments expected = new("pipe", 42, 638900000000000000, 1); + Assert.False(PeerLease.MatchesProcessIdentity(expected, pid, created, session)); + } + + [Fact] + public void ProcessIdentityAcceptsExactRetainedInstance() + { + Arguments expected = new("pipe", 42, 638900000000000000, 1); + Assert.True(PeerLease.MatchesProcessIdentity(expected, 42, 638900000000000000, 1)); + } + + [Fact] + public void BrokerServerRequiresExactAgentSiblingPath() + { + Assert.True(BrokerServerLease.IsExpectedPath( + @"C:\Program Files\Devolutions\Agent\DevolutionsAgent.exe", + @"c:\PROGRAM FILES\Devolutions\Agent\DevolutionsAgent.exe")); + Assert.False(BrokerServerLease.IsExpectedPath( + @"C:\Users\Alice\DevolutionsAgent.exe", + @"C:\Program Files\Devolutions\Agent\DevolutionsAgent.exe")); + Assert.True(BrokerServerLease.IsExpectedPath( + @"D:\Managed Apps\Agent\DevolutionsAgent.exe", + @"d:\managed apps\Agent\DevolutionsAgent.exe")); + } + + [Fact] + public void AuthenticodeSignerComesFromRetainedImageHandle() + { + string path = Path.Combine(AppContext.BaseDirectory, "testhost.exe"); + using SafeFileHandle image = OpenImage(path); + using var certificate = PeerLease.VerifyAuthenticodeSigner(path, image, "test host"); + Assert.NotEmpty(certificate.RawData); + } + + [Fact] + public void AuthenticodeRequiresFreshWholeChainRevocation() + { + Native.WinTrustData data = new(IntPtr.Zero); + + Assert.Equal(Native.WtdRevokeWholeChain, data.RevocationChecks); + Assert.Equal( + Native.WtdRevocationCheckChain | Native.WtdDisableMd2Md4, + data.ProviderFlags); + Assert.Equal(0u, data.ProviderFlags & Native.WtdCacheOnlyUrlRetrieval); + } + + [Theory] + [InlineData(0, true)] + [InlineData(unchecked((int)0x800B010C), false)] + [InlineData(unchecked((int)0x80092012), false)] + [InlineData(unchecked((int)0x80092013), false)] + [InlineData(unchecked((int)0x800B010E), false)] + public void AuthenticodeFailsClosedForIndeterminateStatus(int status, bool accepted) + { + Assert.Equal(accepted, PeerLease.IsAuthenticodeStatusAccepted(status)); + } + + [Fact] + public void ProcessImageMappingAcceptsOnlyCurrentMappedImage() + { + using SafeProcessHandle process = Native.OpenProcess( + PeerLease.ProcessQueryInformation | + PeerLease.ProcessQueryLimitedInformation | + PeerLease.Synchronize, + false, + Environment.ProcessId); + Assert.False(process.IsInvalid); + + string processPath = PeerLease.ImagePath(process); + using SafeFileHandle processImage = OpenImage(processPath); + PeerLease.VerifyImageMapping(process, processImage); + + using SafeFileHandle differentImage = + OpenImage(Path.Combine(AppContext.BaseDirectory, "DevolutionsAgentPolicyConsent.exe")); + Assert.Throws(() => PeerLease.VerifyImageMapping(process, differentImage)); + } + + [Fact] + public void BrokerServerRejectsNonSystemProcessToken() + { + using SafeProcessHandle process = Native.OpenProcess( + PeerLease.ProcessQueryLimitedInformation, + false, + Environment.ProcessId); + Assert.False(process.IsInvalid); + Assert.False(PeerLease.IsLocalSystemProcess(process)); + } + + [Fact] + public void FileIdentityRejectsDifferentImage() + { + using SafeFileHandle first = OpenImage(Path.Combine(AppContext.BaseDirectory, "testhost.exe")); + using SafeFileHandle same = OpenImage(Path.Combine(AppContext.BaseDirectory, "testhost.exe")); + using SafeFileHandle different = + OpenImage(Path.Combine(AppContext.BaseDirectory, "DevolutionsAgentPolicyConsent.exe")); + + Assert.True(PeerLease.SameFile(first, same)); + Assert.False(PeerLease.SameFile(first, different)); + } + + private static SafeFileHandle OpenImage(string path) + { + SafeFileHandle image = Native.CreateFile( + path, + PeerLease.GenericRead | PeerLease.FileExecute | PeerLease.Synchronize, + PeerLease.FileShareRead, + IntPtr.Zero, + PeerLease.OpenExisting, + 0, + IntPtr.Zero); + Assert.False(image.IsInvalid); + return image; + } + } + + [Theory] + [InlineData("--extra")] + [InlineData("--pipe")] + public void ArgumentsRejectUnknownOrDuplicateNames(string name) + { + string[] args = + [ + "--protocol", "2.0", + "--pipe", $"UniGetUI.PolicyElevation.{RequestId}", + "--parent-pid", "42", + "--parent-created", "638900000000000000", + name, "1", + ]; + + Assert.Throws(() => Protocol.ParseArguments(args)); + } + + [Fact] + public async Task FrameUsesBigEndianLengthAndRoundTrips() + { + byte[] body = [1, 2, 3, 4]; + using MemoryStream stream = new(); + await Protocol.WriteFrameAsync(stream, body, body.Length, CancellationToken.None); + Assert.Equal((uint)body.Length, BinaryPrimitives.ReadUInt32BigEndian(stream.GetBuffer())); + stream.Position = 0; + Assert.Equal(body, await Protocol.ReadFrameAsync(stream, body.Length, CancellationToken.None)); + } + + [Fact] + public async Task OversizedFrameIsRejectedBeforeBodyRead() + { + byte[] header = new byte[4]; + BinaryPrimitives.WriteUInt32BigEndian(header, 128); + using MemoryStream stream = new(header); + await Assert.ThrowsAsync( + () => Protocol.ReadFrameAsync(stream, 127, CancellationToken.None)); + } + + [Fact] + public async Task ZeroLengthAndTruncatedFramesAreRejected() + { + using MemoryStream empty = new(new byte[4]); + await Assert.ThrowsAsync( + () => Protocol.ReadFrameAsync(empty, 128, CancellationToken.None)); + + using MemoryStream truncated = new([0, 0, 0, 2, 1]); + await Assert.ThrowsAsync( + () => Protocol.ReadFrameAsync(truncated, 128, CancellationToken.None)); + } + + [Fact] + public async Task FrameReadHonorsCancellation() + { + using CancellationTokenSource cancellation = new(TimeSpan.FromMilliseconds(25)); + await Assert.ThrowsAnyAsync( + () => Protocol.ReadFrameAsync(new BlockingStream(), 128, cancellation.Token)); + } + + [Fact] + public void RequestRejectsUnknownJsonMembers() + { + string json = + $$"""{"protocolVersion":"2.0","requestId":"{{RequestId}}","operation":"Update","conflictHandling":"Reject","expectedStoreToken":"a","validationReceipt":"b","warningsAcknowledged":false,"draft":{},"command":"cmd.exe"}"""; + + Assert.Throws( + () => JsonSerializer.Deserialize(json, ProtocolJsonContext.Default.ElevationRequest)); + } + + [Fact] + public void OfficialRequestContainsOnlyPolicyReplacementFields() + { + using JsonDocument draft = JsonDocument.Parse("""{"Metadata":{"Id":"tests.policy"}}"""); + ElevationRequest request = new( + "2.0", + RequestId, + "Update", + "ConfirmOverwrite", + "token", + "receipt", + true, + draft.RootElement.Clone()); + + using JsonDocument official = JsonDocument.Parse(BrokerClient.CreateOfficialRequest(request)); + string[] names = official.RootElement.EnumerateObject().Select(property => property.Name).ToArray(); + Assert.Equal( + [ + "RequestKind", + "RequestVersion", + "ExpectedStoreToken", + "Operation", + "ConflictHandling", + "WarningsAcknowledged", + "Draft", + "ValidationReceipt", + ], names); + } + + [Fact] + public void RequestRequiresEveryMemberAndObjectDraft() + { + string missingAcknowledgement = + $$$"""{"protocolVersion":"2.0","requestId":"{{{RequestId}}}","operation":"Update","conflictHandling":"Reject","expectedStoreToken":"a","validationReceipt":"b","draft":{}}"""; + Assert.Throws( + () => JsonSerializer.Deserialize(missingAcknowledgement, ProtocolJsonContext.Default.ElevationRequest)); + + using JsonDocument draft = JsonDocument.Parse("[]"); + ElevationRequest request = new("2.0", RequestId, "Update", "Reject", "a", "b", false, draft.RootElement); + Assert.Throws(() => Protocol.ValidateRequest(request)); + } + + [Theory] + [InlineData("Delete", "Reject")] + [InlineData("Update", "Overwrite")] + public void RequestRejectsUnknownOperations(string operation, string conflictHandling) + { + using JsonDocument draft = JsonDocument.Parse("{}"); + ElevationRequest request = new( + "2.0", + RequestId, + operation, + conflictHandling, + "a", + "b", + false, + draft.RootElement); + + Assert.Throws(() => Protocol.ValidateRequest(request)); + } + + [Theory] + [InlineData("a", true)] + [InlineData("A0._~:-", true)] + [InlineData("a/b", false)] + [InlineData("a b", false)] + [InlineData("a\"b", false)] + [InlineData("-token", false)] + public void CredentialsMatchOfficialPolicyApiCharacterRules(string value, bool accepted) + { + Assert.Equal(accepted, Protocol.IsCredential(value, 512)); + } + + [Fact] + public void CommittedResponseContainsOnlyStoreToken() + { + ElevationResponse response = new( + "2.0", + RequestId, + "Committed", + null, + null, + "new-token", + null, + null, + null); + + Protocol.ValidateResponse(response); + string json = JsonSerializer.Serialize(response, ProtocolJsonContext.Default.ElevationResponse); + Assert.DoesNotContain("payload", json, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("message", json, StringComparison.OrdinalIgnoreCase); + } + + [Theory] + [InlineData("Active", "policy.id")] + [InlineData("Missing", null)] + [InlineData("Invalid", null)] + public void StaleRejectionCarriesBoundedConflictContext(string state, string? policyId) + { + ElevationResponse response = new( + "2.0", + RequestId, + "Rejected", + 409, + "StalePolicyStoreToken", + null, + "current-token", + state, + policyId); + + Protocol.ValidateResponse(response); + } + + [Fact] + public void UnknownResponseCannotClaimCommitOrConflict() + { + ElevationResponse response = new( + "2.0", + RequestId, + "Unknown", + null, + "Timeout", + "claimed-token", + null, + null, + null); + + Assert.Throws(() => Protocol.ValidateResponse(response)); + } + + [Fact] + public void ResponseRequiresExplicitNullableMembers() + { + string missingConflictFields = + $$$"""{"protocolVersion":"2.0","requestId":"{{{RequestId}}}","disposition":"Unknown","brokerStatusCode":null,"brokerErrorCode":"Timeout","committedStoreToken":null}"""; + + Assert.Throws( + () => JsonSerializer.Deserialize(missingConflictFields, ProtocolJsonContext.Default.ElevationResponse)); + } + + [Fact] + public void MaximumValidStaleResponseFitsWireBudget() + { + ElevationResponse response = new( + "2.0", + RequestId, + "Rejected", + 409, + "StalePolicyStoreToken", + null, + "T" + new string('~', 511), + "Active", + "P" + new string('~', 2047)); + + Protocol.ValidateResponse(response); + byte[] body = JsonSerializer.SerializeToUtf8Bytes( + response, + ProtocolJsonContext.Default.ElevationResponse); + Assert.InRange(body.Length, 1, Protocol.MaxResponseBodyBytes); + } + + [Fact] + public void BrokerSuccessMapsToCompactCommittedAcknowledgement() + { + ElevationResponse response = BrokerClient.ParseResponse( + RequestId, + HttpResponse( + 200, + """{"ResponseKind":"PolicyReplacementResponse","ResponseVersion":"1.0","Management":{"StoreToken":"new-token"}}""")); + + Assert.Equal("Committed", response.Disposition); + Assert.Equal("new-token", response.CommittedStoreToken); + Assert.Null(response.BrokerStatusCode); + } + + [Fact] + public void BrokerStaleErrorMapsExactConflictContext() + { + ElevationResponse response = BrokerClient.ParseResponse( + RequestId, + HttpResponse( + 409, + """{"ResponseKind":"ErrorResponse","ResponseVersion":"1.0","Code":"StalePolicyStoreToken","Management":{"StoreToken":"current-token","State":"Active","Policy":{"Metadata":{"Id":"policy.id"}}}}""")); + + Assert.Equal("Rejected", response.Disposition); + Assert.Equal(409, response.BrokerStatusCode); + Assert.Equal("current-token", response.ConflictStoreToken); + Assert.Equal("Active", response.ConflictState); + Assert.Equal("policy.id", response.ConflictPolicyId); + } + + [Fact] + public void EmptyBrokerResponseMapsToUnknown() + { + ElevationResponse response = BrokerClient.ParseResponse( + RequestId, + HttpResponse(503, string.Empty)); + + Assert.Equal("Unknown", response.Disposition); + Assert.Equal(503, response.BrokerStatusCode); + Assert.Equal("EmptyResponse", response.BrokerErrorCode); + } + + private static byte[] HttpResponse(int status, string body) => + System.Text.Encoding.UTF8.GetBytes( + $"HTTP/1.1 {status} Test\r\nContent-Length: {System.Text.Encoding.UTF8.GetByteCount(body)}\r\n\r\n{body}"); + + private sealed class BlockingStream : Stream + { + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } + public override void Flush() => throw new NotSupportedException(); + public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + public override async ValueTask ReadAsync( + Memory buffer, + CancellationToken cancellationToken = default) + { + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + return 0; + } + } +} diff --git a/package/AgentPolicyConsent/BrokerClient.cs b/package/AgentPolicyConsent/BrokerClient.cs new file mode 100644 index 000000000..1dfe80305 --- /dev/null +++ b/package/AgentPolicyConsent/BrokerClient.cs @@ -0,0 +1,297 @@ +using System.Buffers; +using System.IO.Pipes; +using System.Text; +using System.Text.Json; + +namespace DevolutionsAgentPolicyConsent; + +internal static class BrokerClient +{ + private const string PipeName = "Devolutions.Now.PackageBroker.v1"; + private const int MaximumHeaderBytes = 64 * 1024; + private const int MaximumBrokerResponseBytes = 50_606_928; + private static readonly TimeSpan ConnectTimeout = TimeSpan.FromSeconds(5); + + internal static async Task ReplaceAsync( + ElevationRequest request, + CancellationToken cancellationToken) + { + byte[] body = CreateOfficialRequest(request); + try + { + using NamedPipeClientStream pipe = new( + ".", + PipeName, + PipeDirection.InOut, + PipeOptions.Asynchronous | PipeOptions.WriteThrough, + System.Security.Principal.TokenImpersonationLevel.Anonymous); + + using CancellationTokenSource connectTimeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + connectTimeout.CancelAfter(ConnectTimeout); + await pipe.ConnectAsync(connectTimeout.Token); + using BrokerServerLease broker = await OpenBrokerServerAsync(pipe, cancellationToken); + + byte[] headers = Encoding.ASCII.GetBytes( + $"PUT /v1/policy HTTP/1.1\r\nHost: now-package-broker\r\nConnection: close\r\n" + + $"Content-Type: application/json\r\nAccept: application/json\r\nContent-Length: {body.Length}\r\n\r\n"); + await pipe.WriteAsync(headers, cancellationToken); + await pipe.WriteAsync(body, cancellationToken); + await pipe.FlushAsync(cancellationToken); + + byte[] response = await ReadBoundedAsync( + pipe, + MaximumBrokerResponseBytes + MaximumHeaderBytes, + cancellationToken); + return ParseResponse(request.RequestId, response); + } + catch (OperationCanceledException) + { + return Unknown(request.RequestId, null, "Timeout"); + } + catch (IOException) + { + return Unknown(request.RequestId, null, "BrokerUnavailable"); + } + catch (JsonException) + { + return Unknown(request.RequestId, null, "InvalidResponse"); + } + catch (BrokerResponseException error) + { + return Unknown(request.RequestId, error.StatusCode, "InvalidResponse"); + } + catch (BrokerAuthenticationException) + { + return Rejected(request.RequestId, "Unauthorized"); + } + catch (InvalidOperationException) + { + return Unknown(request.RequestId, null, "InvalidResponse"); + } + catch (ProtocolException) + { + return Unknown(request.RequestId, null, "InvalidResponse"); + } + } + + internal static byte[] CreateOfficialRequest(ElevationRequest request) + { + ArrayBufferWriter buffer = new(); + using Utf8JsonWriter writer = new(buffer); + writer.WriteStartObject(); + writer.WriteString("RequestKind", "PolicyReplacementRequest"); + writer.WriteString("RequestVersion", "1.0"); + writer.WriteString("ExpectedStoreToken", request.ExpectedStoreToken); + writer.WriteString("Operation", request.Operation); + writer.WriteString("ConflictHandling", request.ConflictHandling); + writer.WriteBoolean("WarningsAcknowledged", request.WarningsAcknowledged); + writer.WritePropertyName("Draft"); + request.Draft.WriteTo(writer); + writer.WriteString("ValidationReceipt", request.ValidationReceipt); + writer.WriteEndObject(); + writer.Flush(); + if (buffer.WrittenCount > 16_777_216) + { + throw new ProtocolException("official policy request exceeds broker limit"); + } + return buffer.WrittenSpan.ToArray(); + } + + internal static ElevationResponse ParseResponse(string requestId, byte[] response) + { + ReadOnlySpan delimiter = "\r\n\r\n"u8; + int headerEnd = response.AsSpan().IndexOf(delimiter); + if (headerEnd < 0 || headerEnd > MaximumHeaderBytes) + { + throw new BrokerResponseException(null); + } + + string statusLine = Encoding.ASCII.GetString(response.AsSpan(0, headerEnd)).Split("\r\n", 2)[0]; + string[] statusParts = statusLine.Split(' ', 3, StringSplitOptions.RemoveEmptyEntries); + if (statusParts.Length < 2 || !int.TryParse(statusParts[1], out int status)) + { + throw new BrokerResponseException(null); + } + + ReadOnlyMemory body = response.AsMemory(headerEnd + delimiter.Length); + if (body.IsEmpty) + { + return Unknown(requestId, status, "EmptyResponse"); + } + + try + { + return ParseResponseBody(requestId, status, body); + } + catch (Exception error) when (error is JsonException or InvalidOperationException or ProtocolException) + { + throw new BrokerResponseException(status, error); + } + } + + private static ElevationResponse ParseResponseBody( + string requestId, + int status, + ReadOnlyMemory body) + { + using JsonDocument document = JsonDocument.Parse(body); + JsonElement payload = document.RootElement; + if (status is >= 200 and <= 299) + { + RequireString(payload, "ResponseKind", "PolicyReplacementResponse"); + RequireString(payload, "ResponseVersion", "1.0"); + string token = RequireNestedString(payload, "Management", "StoreToken"); + ElevationResponse committed = new( + Protocol.Version, + requestId, + "Committed", + null, + null, + token, + null, + null, + null); + Protocol.ValidateResponse(committed); + return committed; + } + + RequireString(payload, "ResponseKind", "ErrorResponse"); + RequireString(payload, "ResponseVersion", "1.0"); + string code = RequireString(payload, "Code"); + string? conflictToken = null; + string? conflictState = null; + string? conflictPolicyId = null; + if (code == "StalePolicyStoreToken") + { + JsonElement management = RequireObject(payload, "Management"); + conflictToken = RequireString(management, "StoreToken"); + conflictState = RequireString(management, "State"); + if (conflictState == "Active") + { + JsonElement policy = RequireObject(management, "Policy"); + JsonElement metadata = RequireObject(policy, "Metadata"); + conflictPolicyId = RequireString(metadata, "Id"); + } + } + ElevationResponse rejected = new( + Protocol.Version, + requestId, + "Rejected", + status, + Truncate(code, 64), + null, + conflictToken, + conflictState, + conflictPolicyId); + Protocol.ValidateResponse(rejected); + return rejected; + } + + private static void RequireString(JsonElement payload, string property, string expected) + { + if (!payload.TryGetProperty(property, out JsonElement value) || + value.ValueKind != JsonValueKind.String || + value.GetString() != expected) + { + throw new InvalidOperationException("broker response contract mismatch"); + } + } + + private static string RequireString(JsonElement payload, string property) + { + if (!payload.TryGetProperty(property, out JsonElement value) || + value.ValueKind != JsonValueKind.String || + value.GetString() is not { } result) + { + throw new InvalidOperationException("broker response contract mismatch"); + } + return result; + } + + private static string RequireNestedString(JsonElement payload, string parent, string property) => + RequireString(RequireObject(payload, parent), property); + + private static JsonElement RequireObject(JsonElement payload, string property) + { + if (!payload.TryGetProperty(property, out JsonElement value) || + value.ValueKind != JsonValueKind.Object) + { + throw new InvalidOperationException("broker response contract mismatch"); + } + return value; + } + + private static async Task ReadBoundedAsync(Stream stream, int maximum, CancellationToken cancellationToken) + { + using MemoryStream response = new(); + byte[] buffer = new byte[16 * 1024]; + while (true) + { + int read = await stream.ReadAsync(buffer, cancellationToken); + if (read == 0) + { + return response.ToArray(); + } + if (response.Length + read > maximum) + { + throw new InvalidOperationException("broker response exceeds limit"); + } + response.Write(buffer, 0, read); + } + } + + private static ElevationResponse Unknown(string requestId, int? statusCode, string errorCode) => + new(Protocol.Version, requestId, "Unknown", statusCode, errorCode, null, null, null, null); + + private static ElevationResponse Rejected(string requestId, string errorCode) => + new(Protocol.Version, requestId, "Rejected", null, errorCode, null, null, null, null); + + private static async Task OpenBrokerServerAsync( + NamedPipeClientStream pipe, + CancellationToken cancellationToken) + { + Task open = Task.Run(() => BrokerServerLease.Open(pipe.SafePipeHandle)); + try + { + return await open.WaitAsync(cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + DisposeLateResult(open); + throw; + } + catch (Exception error) + { + DisposeLateResult(open); + throw new BrokerAuthenticationException(error); + } + } + + private static void DisposeLateResult(Task open) + { + _ = open.ContinueWith( + static completed => + { + if (completed.Status == TaskStatus.RanToCompletion) + { + completed.Result.Dispose(); + } + _ = completed.Exception; + }, + CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + } + + private static string Truncate(string value, int maximum) => + value.Length <= maximum ? value : value[..maximum]; + + private sealed class BrokerResponseException(int? statusCode, Exception? innerException = null) + : Exception("broker response contract mismatch", innerException) + { + internal int? StatusCode { get; } = statusCode; + } + + private sealed class BrokerAuthenticationException(Exception innerException) + : Exception("broker server authentication failed", innerException); +} diff --git a/package/AgentPolicyConsent/DevolutionsAgentPolicyConsent.csproj b/package/AgentPolicyConsent/DevolutionsAgentPolicyConsent.csproj new file mode 100644 index 000000000..a8257d4dc --- /dev/null +++ b/package/AgentPolicyConsent/DevolutionsAgentPolicyConsent.csproj @@ -0,0 +1,23 @@ + + + WinExe + net10.0-windows + win-x64 + true + true + true + true + false + enable + enable + true + app.manifest + DevolutionsAgentPolicyConsent + DevolutionsAgentPolicyConsent + Devolutions Agent Policy Consent + Devolutions Inc. + + + + + diff --git a/package/AgentPolicyConsent/PeerTrust.cs b/package/AgentPolicyConsent/PeerTrust.cs new file mode 100644 index 000000000..2948e109e --- /dev/null +++ b/package/AgentPolicyConsent/PeerTrust.cs @@ -0,0 +1,901 @@ +using Microsoft.Win32.SafeHandles; +using System.ComponentModel; +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Security.AccessControl; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Security.Principal; +using System.Text; + +namespace DevolutionsAgentPolicyConsent; + +internal sealed class PeerLease : IDisposable +{ + // SHA-256 digests of accepted UniGetUI signer SPKIs. Keep both keys during certificate rollover. + internal const string TransitionUiSignerSpkiSha256 = + PolicyConsentContract.TransitionUiSignerSpkiSha256; + internal const string CurrentUiSignerSpkiSha256 = + PolicyConsentContract.CurrentUiSignerSpkiSha256; + + internal const uint ProcessQueryLimitedInformation = 0x1000; + internal const uint ProcessQueryInformation = 0x0400; + internal const uint Synchronize = 0x0010_0000; + internal const uint GenericRead = 0x8000_0000; + internal const uint FileExecute = 0x20; + internal const uint FileReadAttributes = 0x80; + internal const uint ReadControl = 0x0002_0000; + internal const uint FileShareRead = 0x1; + internal const uint FileShareWrite = 0x2; + internal const uint OpenExisting = 3; + internal const uint FileAttributeReparsePoint = 0x400; + internal const uint FileFlagBackupSemantics = 0x0200_0000; + internal const uint FileFlagOpenReparsePoint = 0x0020_0000; + internal const int ProcessImageFileMapping = 44; + internal const uint StillActive = 259; + + private readonly SafeProcessHandle process; + private readonly SafeFileHandle image; + private readonly int processId; + private readonly long createdUtcTicks; + private readonly uint sessionId; + + private PeerLease( + SafeProcessHandle process, + SafeFileHandle image, + int processId, + long createdUtcTicks, + uint sessionId) + { + this.process = process; + this.image = image; + this.processId = processId; + this.createdUtcTicks = createdUtcTicks; + this.sessionId = sessionId; + } + + internal static PeerLease Open(Arguments arguments) + { + SafeProcessHandle process = Native.OpenProcess( + ProcessQueryInformation | ProcessQueryLimitedInformation | Synchronize, + false, + arguments.ParentProcessId); + if (process.IsInvalid) + { + throw new Win32Exception(); + } + + try + { + long created = CreationTime(process); + uint session = SessionId(arguments.ParentProcessId); + if (!MatchesProcessIdentity( + arguments, + arguments.ParentProcessId, + created, + session)) + { + throw new InvalidOperationException("parent process identity mismatch"); + } + + string path = ImagePath(process); + SafeFileHandle image = Native.CreateFile( + path, + GenericRead | FileExecute | Synchronize, + FileShareRead, + IntPtr.Zero, + OpenExisting, + 0, + IntPtr.Zero); + if (image.IsInvalid) + { + throw new Win32Exception(); + } + + try + { + VerifyImageMapping(process, image); + VerifyImageMetadata(path, image); + using X509Certificate2 signer = VerifyAuthenticodeSigner(path, image, "parent image"); + VerifySigner(signer); + VerifyImageMapping(process, image); + EnsureActive(process); + return new PeerLease(process, image, arguments.ParentProcessId, created, session); + } + catch + { + image.Dispose(); + throw; + } + } + catch + { + process.Dispose(); + throw; + } + } + + internal void VerifyConnectedServer(int serverProcessId) + { + Arguments expected = new(string.Empty, processId, createdUtcTicks, sessionId); + if (!MatchesProcessIdentity(expected, serverProcessId, CreationTime(process), SessionId(serverProcessId))) + { + throw new InvalidOperationException("connected server process identity mismatch"); + } + VerifyImageMapping(process, image); + EnsureActive(process); + } + + internal static bool IsAllowedSigner(string digest) => + FixedTimeEqualsHex(digest, CurrentUiSignerSpkiSha256) || + FixedTimeEqualsHex(digest, TransitionUiSignerSpkiSha256); + + internal static bool IsAllowedDevolutionsSigner(string thumbprint) => + PolicyConsentContract.DevolutionsSignerSha1Thumbprints.Any( + expected => FixedTimeEqualsHex(thumbprint, expected, 40)); + + internal static bool IsSupportedUiIdentity(string? productName, string? originalFilename, string? productVersion) => + string.Equals(productName, "UniGetUI", StringComparison.Ordinal) && + string.Equals(originalFilename, "UniGetUI.dll", StringComparison.OrdinalIgnoreCase) && + productVersion is not null && + Version.TryParse(productVersion.Split(['+', '-'], StringSplitOptions.TrimEntries)[0], out Version? parsed) && + parsed >= new Version(3, 3, 7); + + internal static bool MatchesProcessIdentity( + Arguments expected, + int processId, + long createdUtcTicks, + uint sessionId) => + processId == expected.ParentProcessId && + createdUtcTicks == expected.ParentCreatedUtcTicks && + sessionId == expected.SessionId; + + public void Dispose() + { + image.Dispose(); + process.Dispose(); + } + + private static void VerifyImageMetadata(string path, SafeFileHandle retainedImage) + { + VerifyPathIdentity(path, retainedImage); + if ((File.GetAttributes(path) & FileAttributes.ReparsePoint) != 0) + { + throw new InvalidOperationException("parent image is a reparse point"); + } + + FileVersionInfo version = FileVersionInfo.GetVersionInfo(path); + if (!IsSupportedUiIdentity(version.ProductName, version.OriginalFilename, version.ProductVersion)) + { + throw new InvalidOperationException("parent image product identity mismatch"); + } + VerifyPathIdentity(path, retainedImage); + } + + private static void VerifyPathIdentity(string path, SafeFileHandle retainedImage) + { + using SafeFileHandle reopened = Native.CreateFile( + path, + GenericRead | FileExecute | Synchronize, + FileShareRead, + IntPtr.Zero, + OpenExisting, + 0, + IntPtr.Zero); + if (reopened.IsInvalid) + { + throw new Win32Exception(); + } + if (!SameFile(retainedImage, reopened)) + { + throw new InvalidOperationException("parent image path no longer identifies the retained image"); + } + } + + internal static bool SameFile(SafeFileHandle left, SafeFileHandle right) + { + if (!Native.GetFileInformationByHandle(left, out Native.ByHandleFileInformation leftInfo) || + !Native.GetFileInformationByHandle(right, out Native.ByHandleFileInformation rightInfo)) + { + throw new Win32Exception(); + } + return leftInfo.VolumeSerialNumber == rightInfo.VolumeSerialNumber && + leftInfo.FileIndexHigh == rightInfo.FileIndexHigh && + leftInfo.FileIndexLow == rightInfo.FileIndexLow; + } + + internal static bool IsLocalSystemProcess(SafeProcessHandle process) + { + if (!Native.OpenProcessToken(process, 0x0008, out SafeAccessTokenHandle token)) + { + throw new Win32Exception(); + } + using (token) + { + _ = Native.GetTokenInformation(token, 1, IntPtr.Zero, 0, out uint length); + if (length == 0) + { + throw new Win32Exception(); + } + + IntPtr information = Marshal.AllocHGlobal(checked((int)length)); + try + { + if (!Native.GetTokenInformation(token, 1, information, length, out _)) + { + throw new Win32Exception(); + } + IntPtr sid = Marshal.ReadIntPtr(information); + return Native.IsWellKnownSid(sid, 22); + } + finally + { + Marshal.FreeHGlobal(information); + } + } + } + + internal static X509Certificate2 VerifyAuthenticodeSigner( + string path, + SafeFileHandle image, + string subject) + { + Guid action = new("00AAC56B-CD44-11d0-8CC2-00C04FC295EE"); + Native.WinTrustFileInfo file = new(path, image.DangerousGetHandle()); + IntPtr filePointer = Marshal.AllocHGlobal(Marshal.SizeOf()); + IntPtr dataPointer = Marshal.AllocHGlobal(Marshal.SizeOf()); + bool fileInitialized = false; + bool dataInitialized = false; + try + { + Marshal.StructureToPtr(file, filePointer, false); + fileInitialized = true; + Native.WinTrustData data = new(filePointer); + Marshal.StructureToPtr(data, dataPointer, false); + dataInitialized = true; + int status = Native.WinVerifyTrust(new IntPtr(-1), ref action, dataPointer); + if (!IsAuthenticodeStatusAccepted(status)) + { + throw new InvalidOperationException($"{subject} Authenticode validation failed (0x{status:X8})"); + } + data = Marshal.PtrToStructure(dataPointer); + IntPtr providerData = Native.WTHelperProvDataFromStateData(data.StateData); + IntPtr providerSigner = providerData == IntPtr.Zero + ? IntPtr.Zero + : Native.WTHelperGetProvSignerFromChain(providerData, 0, false, 0); + if (providerSigner == IntPtr.Zero) + { + throw new InvalidOperationException($"{subject} Authenticode signer is unavailable"); + } + + Native.CryptProviderSigner signer = Marshal.PtrToStructure(providerSigner); + if (signer.CertificateChainCount == 0 || signer.CertificateChain == IntPtr.Zero) + { + throw new InvalidOperationException($"{subject} Authenticode certificate chain is empty"); + } + Native.CryptProviderCertificate certificate = + Marshal.PtrToStructure(signer.CertificateChain); +#pragma warning disable SYSLIB0057 // WinVerifyTrust returns the certificate context for the exact retained image. + return new X509Certificate2(certificate.CertificateContext); +#pragma warning restore SYSLIB0057 + } + finally + { + if (dataInitialized) + { + Native.WinTrustData data = Marshal.PtrToStructure(dataPointer); + if (data.StateData != IntPtr.Zero) + { + data.StateAction = 2; + Marshal.StructureToPtr(data, dataPointer, true); + _ = Native.WinVerifyTrust(new IntPtr(-1), ref action, dataPointer); + } + Marshal.DestroyStructure(dataPointer); + } + if (fileInitialized) + { + Marshal.DestroyStructure(filePointer); + } + Marshal.FreeHGlobal(dataPointer); + Marshal.FreeHGlobal(filePointer); + } + } + + internal static bool IsAuthenticodeStatusAccepted(int status) => status == 0; + + private static void VerifySigner(X509Certificate2 certificate) + { + byte[] subjectPublicKeyInfo; + using (RSA? rsa = certificate.GetRSAPublicKey()) + { + if (rsa is not null) + { + subjectPublicKeyInfo = rsa.ExportSubjectPublicKeyInfo(); + } + else + { + using ECDsa? ecdsa = certificate.GetECDsaPublicKey(); + subjectPublicKeyInfo = ecdsa?.ExportSubjectPublicKeyInfo() + ?? throw new InvalidOperationException("unsupported parent signer key"); + } + } + + string digest = Convert.ToHexString(SHA256.HashData(subjectPublicKeyInfo)).ToLowerInvariant(); + if (!IsAllowedSigner(digest)) + { + throw new InvalidOperationException("parent image signer is not authorized"); + } + } + + private static bool FixedTimeEqualsHex(string candidate, string expected, int length = 64) + { + if (candidate.Length != length || + expected.Length != length || + candidate.AsSpan().IndexOfAnyExcept("0123456789abcdef") >= 0) + { + return false; + } + try + { + return CryptographicOperations.FixedTimeEquals( + Convert.FromHexString(candidate), + Convert.FromHexString(expected)); + } + catch (FormatException) + { + return false; + } + } + + internal static void VerifyImageMapping(SafeProcessHandle process, SafeFileHandle image) + { + IntPtr fileHandle = image.DangerousGetHandle(); + int status = Native.NtQueryInformationProcess( + process.DangerousGetHandle(), + ProcessImageFileMapping, + ref fileHandle, + IntPtr.Size, + out _); + if (status != 0) + { + throw new InvalidOperationException($"parent image mapping mismatch (0x{status:X8})"); + } + } + + internal static void VerifyProtectedPath(SafeFileHandle handle, string subject, int tamperRights) + { + if (!Native.GetFileInformationByHandle(handle, out Native.ByHandleFileInformation information)) + { + throw new Win32Exception(); + } + if (IsReparsePoint(information.FileAttributes)) + { + throw new InvalidOperationException($"{subject} is a reparse point"); + } + + uint error = Native.GetSecurityInfo( + handle, + 1, + 0x1 | 0x4, + out _, + out _, + out _, + out _, + out IntPtr securityDescriptor); + if (error != 0) + { + throw new Win32Exception(checked((int)error)); + } + + try + { + int length = checked((int)Native.GetSecurityDescriptorLength(securityDescriptor)); + byte[] bytes = new byte[length]; + Marshal.Copy(securityDescriptor, bytes, 0, length); + VerifyTrustedSecurityDescriptor(new RawSecurityDescriptor(bytes, 0), subject, tamperRights); + } + finally + { + _ = Native.LocalFree(securityDescriptor); + } + } + + internal static bool IsReparsePoint(uint attributes) => + (attributes & FileAttributeReparsePoint) != 0; + + internal const int FileTamperRights = + 0x0000_0002 | 0x0000_0004 | 0x0000_0010 | 0x0000_0100 | + 0x0001_0000 | 0x0004_0000 | 0x0008_0000 | 0x1000_0000 | 0x4000_0000; + internal const int ParentDirectoryTamperRights = + 0x0000_0002 | 0x0000_0004 | 0x0000_0040 | + 0x0001_0000 | 0x0004_0000 | 0x0008_0000 | 0x1000_0000 | 0x4000_0000; + internal const int AncestorDirectoryTamperRights = + 0x0000_0040 | 0x0001_0000 | 0x0004_0000 | 0x0008_0000 | 0x1000_0000; + + internal static void VerifyTrustedSecurityDescriptor( + RawSecurityDescriptor descriptor, + string subject, + int tamperRights) + { + if (descriptor.Owner is not SecurityIdentifier owner || !IsTrustedWriter(owner)) + { + throw new InvalidOperationException($"{subject} has an untrusted owner"); + } + if (!descriptor.ControlFlags.HasFlag(ControlFlags.DiscretionaryAclPresent) || + descriptor.DiscretionaryAcl is not { Count: > 0 } dacl) + { + throw new InvalidOperationException($"{subject} has no protective DACL"); + } + + foreach (GenericAce generic in dacl) + { + if (generic.AceFlags.HasFlag(AceFlags.InheritOnly) || !IsAccessAllowedAce(generic.AceType)) + { + continue; + } + if (generic is not QualifiedAce ace || ace is not KnownAce known) + { + throw new InvalidOperationException($"{subject} has an unsupported access-allowed entry"); + } + if ((known.AccessMask & tamperRights) == 0) + { + continue; + } + if (ace.SecurityIdentifier is null || !IsTrustedWriter(ace.SecurityIdentifier)) + { + throw new InvalidOperationException($"{subject} grants write access to an untrusted principal"); + } + } + } + + private static bool IsAccessAllowedAce(AceType type) => + type is AceType.AccessAllowed or + AceType.AccessAllowedCompound or + AceType.AccessAllowedObject or + AceType.AccessAllowedCallback or + AceType.AccessAllowedCallbackObject; + + private static bool IsTrustedWriter(SecurityIdentifier sid) => + sid.IsWellKnown(WellKnownSidType.LocalSystemSid) || + sid.IsWellKnown(WellKnownSidType.BuiltinAdministratorsSid) || + string.Equals( + sid.Value, + "S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464", + StringComparison.Ordinal); + + internal static string ImagePath(SafeProcessHandle process) + { + int capacity = 260; + while (capacity <= 32_768) + { + StringBuilder path = new(capacity); + int length = capacity; + if (Native.QueryFullProcessImageName(process, 0, path, ref length)) + { + return path.ToString(); + } + if (Marshal.GetLastWin32Error() != 122) + { + throw new Win32Exception(); + } + capacity *= 2; + } + throw new InvalidOperationException("parent image path is too long"); + } + + private static long CreationTime(SafeProcessHandle process) + { + if (!Native.GetProcessTimes(process, out long created, out _, out _, out _)) + { + throw new Win32Exception(); + } + return DateTime.FromFileTimeUtc(created).Ticks; + } + + private static uint SessionId(int processId) + { + if (!Native.ProcessIdToSessionId(processId, out uint sessionId)) + { + throw new Win32Exception(); + } + return sessionId; + } + + internal static void EnsureActive(SafeProcessHandle process) + { + if (!Native.GetExitCodeProcess(process, out uint exitCode)) + { + throw new Win32Exception(); + } + if (exitCode != StillActive) + { + throw new InvalidOperationException("parent process exited"); + } + } +} + +internal sealed class BrokerServerLease : IDisposable +{ + private const string AgentExecutableName = "DevolutionsAgent.exe"; + + private readonly SafeProcessHandle process; + private readonly SafeFileHandle image; + private readonly List directories; + + private BrokerServerLease( + SafeProcessHandle process, + SafeFileHandle image, + List directories) + { + this.process = process; + this.image = image; + this.directories = directories; + } + + internal static BrokerServerLease Open(SafePipeHandle pipe) + { + if (!Native.GetNamedPipeServerProcessId(pipe, out int processId)) + { + throw new Win32Exception(); + } + + SafeProcessHandle process = Native.OpenProcess( + PeerLease.ProcessQueryInformation | + PeerLease.ProcessQueryLimitedInformation | + PeerLease.Synchronize, + false, + processId); + if (process.IsInvalid) + { + throw new Win32Exception(); + } + + try + { + if (!PeerLease.IsLocalSystemProcess(process)) + { + throw new InvalidOperationException("broker server is not running as LocalSystem"); + } + string helperPath = Environment.ProcessPath + ?? throw new InvalidOperationException("helper executable path is unavailable"); + string expectedPath = Path.Combine( + Path.GetDirectoryName(helperPath) + ?? throw new InvalidOperationException("helper installation directory is unavailable"), + AgentExecutableName); + string serverPath = PeerLease.ImagePath(process); + if (!IsExpectedPath(serverPath, expectedPath)) + { + throw new InvalidOperationException("broker server is not the installed Agent"); + } + + SafeFileHandle image = Native.CreateFile( + serverPath, + PeerLease.GenericRead | + PeerLease.FileExecute | + PeerLease.ReadControl | + PeerLease.Synchronize, + PeerLease.FileShareRead, + IntPtr.Zero, + PeerLease.OpenExisting, + PeerLease.FileFlagOpenReparsePoint, + IntPtr.Zero); + if (image.IsInvalid) + { + throw new Win32Exception(); + } + + try + { + PeerLease.VerifyImageMapping(process, image); + PeerLease.VerifyProtectedPath(image, "broker server image", PeerLease.FileTamperRights); + using X509Certificate2 signer = + PeerLease.VerifyAuthenticodeSigner(serverPath, image, "broker server"); + string thumbprint = signer.GetCertHashString(HashAlgorithmName.SHA1).ToLowerInvariant(); + if (!PeerLease.IsAllowedDevolutionsSigner(thumbprint)) + { + throw new InvalidOperationException("broker server signer is not authorized"); + } + List? directories = RetainProtectedDirectories( + Path.GetDirectoryName(expectedPath) + ?? throw new InvalidOperationException("Agent installation directory is unavailable")); + try + { + PeerLease.VerifyImageMapping(process, image); + PeerLease.EnsureActive(process); + if (!Native.GetNamedPipeServerProcessId(pipe, out int confirmedProcessId) || + confirmedProcessId != processId) + { + throw new InvalidOperationException("broker server process changed during authentication"); + } + return new BrokerServerLease(process, image, directories); + } + catch + { + foreach (SafeFileHandle directory in directories) + { + directory.Dispose(); + } + throw; + } + } + catch + { + image.Dispose(); + throw; + } + } + catch + { + process.Dispose(); + throw; + } + } + + internal static bool IsExpectedPath(string actual, string expected) => + string.Equals( + Path.GetFullPath(actual), + Path.GetFullPath(expected), + StringComparison.OrdinalIgnoreCase); + + public void Dispose() + { + foreach (SafeFileHandle directory in directories) + { + directory.Dispose(); + } + image.Dispose(); + process.Dispose(); + } + + private static List RetainProtectedDirectories(string installationDirectory) + { + List handles = []; + try + { + int tamperRights = PeerLease.ParentDirectoryTamperRights; + for (DirectoryInfo? directory = new(Path.GetFullPath(installationDirectory)); + directory is not null; + directory = directory.Parent) + { + SafeFileHandle handle = Native.CreateFile( + directory.FullName, + PeerLease.FileReadAttributes | PeerLease.ReadControl | PeerLease.Synchronize, + PeerLease.FileShareRead | PeerLease.FileShareWrite, + IntPtr.Zero, + PeerLease.OpenExisting, + PeerLease.FileFlagBackupSemantics | PeerLease.FileFlagOpenReparsePoint, + IntPtr.Zero); + if (handle.IsInvalid) + { + throw new Win32Exception(); + } + handles.Add(handle); + PeerLease.VerifyProtectedPath( + handle, + $"Agent installation directory '{directory.FullName}'", + tamperRights); + tamperRights = PeerLease.AncestorDirectoryTamperRights; + } + return handles; + } + catch + { + foreach (SafeFileHandle handle in handles) + { + handle.Dispose(); + } + throw; + } + } +} + +internal static partial class Native +{ + internal const uint WtdRevokeWholeChain = 1; + internal const uint WtdRevocationCheckChain = 0x0000_0040; + internal const uint WtdCacheOnlyUrlRetrieval = 0x0000_1000; + internal const uint WtdDisableMd2Md4 = 0x0000_2000; + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + internal readonly struct WinTrustFileInfo + { + internal readonly uint StructSize; + [MarshalAs(UnmanagedType.LPWStr)] + internal readonly string FilePath; + internal readonly IntPtr FileHandle; + internal readonly IntPtr KnownSubject; + + internal WinTrustFileInfo(string filePath, IntPtr fileHandle) + { + StructSize = checked((uint)Marshal.SizeOf()); + FilePath = filePath; + FileHandle = fileHandle; + KnownSubject = IntPtr.Zero; + } + } + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + internal struct WinTrustData + { + internal uint StructSize; + internal IntPtr PolicyCallbackData; + internal IntPtr SipClientData; + internal uint UiChoice; + internal uint RevocationChecks; + internal uint UnionChoice; + internal IntPtr FileInfo; + internal uint StateAction; + internal IntPtr StateData; + internal IntPtr UrlReference; + internal uint ProviderFlags; + internal uint UiContext; + internal IntPtr SignatureSettings; + + internal WinTrustData(IntPtr fileInfo) + { + StructSize = checked((uint)Marshal.SizeOf()); + PolicyCallbackData = IntPtr.Zero; + SipClientData = IntPtr.Zero; + UiChoice = 2; + RevocationChecks = WtdRevokeWholeChain; + UnionChoice = 1; + FileInfo = fileInfo; + StateAction = 1; + StateData = IntPtr.Zero; + UrlReference = IntPtr.Zero; + ProviderFlags = WtdRevocationCheckChain | WtdDisableMd2Md4; + UiContext = 0; + SignatureSettings = IntPtr.Zero; + } + } + + [StructLayout(LayoutKind.Sequential)] + internal readonly struct CryptProviderSigner + { + internal readonly uint StructSize; + internal readonly NativeFileTime VerifyAsOf; + internal readonly uint CertificateChainCount; + internal readonly IntPtr CertificateChain; + internal readonly uint SignerType; + internal readonly IntPtr SignerInfo; + internal readonly uint Error; + internal readonly uint CounterSignerCount; + internal readonly IntPtr CounterSigners; + internal readonly IntPtr ChainContext; + } + + [StructLayout(LayoutKind.Sequential)] + internal readonly struct NativeFileTime + { + internal readonly uint LowDateTime; + internal readonly uint HighDateTime; + } + + [StructLayout(LayoutKind.Sequential)] + internal readonly struct ByHandleFileInformation + { + internal readonly uint FileAttributes; + internal readonly NativeFileTime CreationTime; + internal readonly NativeFileTime LastAccessTime; + internal readonly NativeFileTime LastWriteTime; + internal readonly uint VolumeSerialNumber; + internal readonly uint FileSizeHigh; + internal readonly uint FileSizeLow; + internal readonly uint NumberOfLinks; + internal readonly uint FileIndexHigh; + internal readonly uint FileIndexLow; + } + + [StructLayout(LayoutKind.Sequential)] + internal readonly struct CryptProviderCertificate + { + internal readonly uint StructSize; + internal readonly IntPtr CertificateContext; + } + + [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static extern bool QueryFullProcessImageName( + SafeProcessHandle process, + uint flags, + [Out] StringBuilder path, + ref int size); + + [LibraryImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool GetProcessTimes( + SafeProcessHandle process, + out long creation, + out long exit, + out long kernel, + out long user); + + [LibraryImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool ProcessIdToSessionId(int processId, out uint sessionId); + + [LibraryImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool GetExitCodeProcess(SafeProcessHandle process, out uint exitCode); + + [LibraryImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool GetFileInformationByHandle( + SafeFileHandle file, + out ByHandleFileInformation information); + + [LibraryImport("advapi32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool OpenProcessToken( + SafeProcessHandle process, + uint desiredAccess, + out SafeAccessTokenHandle token); + + [LibraryImport("advapi32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool GetTokenInformation( + SafeAccessTokenHandle token, + int informationClass, + IntPtr information, + uint informationLength, + out uint returnLength); + + [LibraryImport("advapi32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool IsWellKnownSid(IntPtr sid, int wellKnownSidType); + + [LibraryImport("advapi32.dll", SetLastError = true)] + internal static partial uint GetSecurityInfo( + SafeFileHandle handle, + uint objectType, + uint securityInformation, + out IntPtr owner, + out IntPtr group, + out IntPtr dacl, + out IntPtr sacl, + out IntPtr securityDescriptor); + + [LibraryImport("advapi32.dll")] + internal static partial uint GetSecurityDescriptorLength(IntPtr securityDescriptor); + + [LibraryImport("kernel32.dll")] + internal static partial IntPtr LocalFree(IntPtr memory); + + [LibraryImport("kernel32.dll", EntryPoint = "CreateFileW", SetLastError = true, StringMarshalling = StringMarshalling.Utf16)] + internal static partial SafeFileHandle CreateFile( + string fileName, + uint desiredAccess, + uint shareMode, + IntPtr securityAttributes, + uint creationDisposition, + uint flagsAndAttributes, + IntPtr templateFile); + + [LibraryImport("kernel32.dll", SetLastError = true)] + internal static partial SafeProcessHandle OpenProcess(uint desiredAccess, [MarshalAs(UnmanagedType.Bool)] bool inherit, int processId); + + [LibraryImport("ntdll.dll")] + internal static partial int NtQueryInformationProcess( + IntPtr process, + int informationClass, + ref IntPtr information, + int informationLength, + out int returnLength); + + [LibraryImport("wintrust.dll", SetLastError = true)] + internal static partial int WinVerifyTrust(IntPtr window, ref Guid action, IntPtr data); + + [LibraryImport("wintrust.dll")] + internal static partial IntPtr WTHelperProvDataFromStateData(IntPtr stateData); + + [LibraryImport("wintrust.dll")] + internal static partial IntPtr WTHelperGetProvSignerFromChain( + IntPtr providerData, + uint signerIndex, + [MarshalAs(UnmanagedType.Bool)] bool counterSigner, + uint counterSignerIndex); + + [LibraryImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool GetNamedPipeServerProcessId(SafePipeHandle pipe, out int processId); +} diff --git a/package/AgentPolicyConsent/PolicyConsentContract.cs b/package/AgentPolicyConsent/PolicyConsentContract.cs new file mode 100644 index 000000000..697d606d3 --- /dev/null +++ b/package/AgentPolicyConsent/PolicyConsentContract.cs @@ -0,0 +1,33 @@ +namespace DevolutionsAgentPolicyConsent +{ + internal static class PolicyConsentContract + { + internal const string ProtocolVersion = "2.0"; + internal const string ExecutableName = "DevolutionsAgentPolicyConsent.exe"; + internal const string ProductName = "Devolutions Agent Policy Consent"; + + // UniGetUI 2026.2.7: subject CN=Devolutions Inc, O=Devolutions Inc, C=CA; + // issuer CN=GlobalSign GCC R45 EV CodeSigning CA 2020, O=GlobalSign nv-sa, C=BE; + // serial 73D3C33603FF8BB44224F25E, SHA-1 8DB5A43BB8AFE4D2FFB92DA9007D8997A4CC4E13, + // valid 2023-10-30T17:51:18Z through 2026-10-30T17:51:18Z. + internal const string CurrentUiSignerSpkiSha256 = + "e43ed3368eaabff61abc79eb338cba9da88a80d93b751735ff417f26afa579a8"; + + // UniGetUI 3.3.7: subject CN="Open Source Developer, Martí Climent López", + // O=Open Source Developer, C=ES; issuer CN=Certum Code Signing 2021 CA, + // O=Asseco Data Systems S.A., C=PL; + // serial 1AC2CAA58AF100E402D9812002C08B30, SHA-1 28949703053434989162B12C101497DE35FE4E8E, + // valid 2025-06-24T18:02:38Z through 2026-06-24T18:02:37Z. + // Remove this transition pin when the minimum supported UniGetUI version postdates its last signed release. + internal const string TransitionUiSignerSpkiSha256 = + "99e7adb5894e242d87d32b8ad6cb5a1e0d2dd791a447bd7192c30189ef083fab"; + + // Keep synchronized with devolutions-agent-shared/src/windows/code_signing.rs. + internal static readonly string[] DevolutionsSignerSha1Thumbprints = + [ + "3f5202a9432d54293bdfe6f7e46adb0a6f8b3ba6", + "8db5a43bb8afe4d2ffb92da9007d8997a4cc4e13", + "50f753333811ff11f1920274afde3ffd4468b210", + ]; + } +} diff --git a/package/AgentPolicyConsent/Program.cs b/package/AgentPolicyConsent/Program.cs new file mode 100644 index 000000000..2ec726a4f --- /dev/null +++ b/package/AgentPolicyConsent/Program.cs @@ -0,0 +1,173 @@ +using System.IO.Pipes; +using System.Security.Principal; +using System.Text.Json; + +namespace DevolutionsAgentPolicyConsent; + +internal static class Program +{ + private const int Success = 0; + private const int InvalidArguments = 10; + private const int ConnectionFailure = 11; + private const int PeerAuthenticationFailure = 12; + private const int ProtocolFailure = 13; + private const int UnexpectedFailure = 14; + + private static async Task Main(string[] args) + { + if (!OperatingSystem.IsWindows()) + { + return InvalidArguments; + } + + Arguments arguments; + try + { + arguments = Protocol.ParseArguments(args); + } + catch (ProtocolException) + { + return InvalidArguments; + } + + using CancellationTokenSource handshake = new(Protocol.ConnectTimeout); + PeerLease peer; + try + { + peer = await OpenPeerAsync(arguments, handshake.Token); + } + catch (OperationCanceledException) + { + return ConnectionFailure; + } + catch + { + return PeerAuthenticationFailure; + } + + using (peer) + using (NamedPipeClientStream pipe = new( + ".", + arguments.PipeName, + PipeDirection.InOut, + PipeOptions.Asynchronous | PipeOptions.WriteThrough, + TokenImpersonationLevel.Anonymous)) + { + try + { + await pipe.ConnectAsync(handshake.Token); + if (!Native.GetNamedPipeServerProcessId(pipe.SafePipeHandle, out int serverProcessId)) + { + return PeerAuthenticationFailure; + } + peer.VerifyConnectedServer(serverProcessId); + byte[] body = await Protocol.ReadFrameAsync(pipe, Protocol.MaxRequestBodyBytes, handshake.Token); + ElevationRequest request = JsonSerializer.Deserialize(body, ProtocolJsonContext.Default.ElevationRequest) + ?? throw new ProtocolException("request is null"); + Protocol.ValidateRequest(request); + peer.VerifyConnectedServer(serverProcessId); + + using CancellationTokenSource exchange = new(Protocol.ExchangeTimeout); + using CancellationTokenSource brokerCancellation = + CancellationTokenSource.CreateLinkedTokenSource(exchange.Token); + using CancellationTokenSource monitorCancellation = + CancellationTokenSource.CreateLinkedTokenSource(exchange.Token); + Task monitor = MonitorHostAsync(pipe, brokerCancellation, monitorCancellation.Token); + ElevationResponse response = await BrokerClient.ReplaceAsync(request, brokerCancellation.Token); + monitorCancellation.Cancel(); + await IgnoreCancellationAsync(monitor); + Protocol.ValidateResponse(response); + + byte[] responseBody = JsonSerializer.SerializeToUtf8Bytes( + response, + ProtocolJsonContext.Default.ElevationResponse); + using CancellationTokenSource responseWrite = new(Protocol.ResponseWriteTimeout); + await Protocol.WriteFrameAsync( + pipe, + responseBody, + Protocol.MaxResponseBodyBytes, + responseWrite.Token); + return Success; + } + catch (OperationCanceledException) + { + return ConnectionFailure; + } + catch (IOException) + { + return ConnectionFailure; + } + catch (ProtocolException) + { + return ProtocolFailure; + } + catch (JsonException) + { + return ProtocolFailure; + } + catch + { + return UnexpectedFailure; + } + } + } + + private static async Task OpenPeerAsync(Arguments arguments, CancellationToken cancellationToken) + { + Task open = Task.Run(() => PeerLease.Open(arguments)); + try + { + return await open.WaitAsync(cancellationToken); + } + catch + { + _ = open.ContinueWith( + static completed => + { + if (completed.Status == TaskStatus.RanToCompletion) + { + completed.Result.Dispose(); + } + _ = completed.Exception; + }, + CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + throw; + } + } + + private static async Task MonitorHostAsync( + Stream pipe, + CancellationTokenSource brokerCancellation, + CancellationToken cancellationToken) + { + byte[] unexpected = new byte[1]; + try + { + int read = await pipe.ReadAsync(unexpected, cancellationToken); + if (read is 0 or 1) + { + brokerCancellation.Cancel(); + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + } + catch (IOException) + { + brokerCancellation.Cancel(); + } + } + + private static async Task IgnoreCancellationAsync(Task task) + { + try + { + await task; + } + catch (OperationCanceledException) + { + } + } +} diff --git a/package/AgentPolicyConsent/Protocol.cs b/package/AgentPolicyConsent/Protocol.cs new file mode 100644 index 000000000..a9057db90 --- /dev/null +++ b/package/AgentPolicyConsent/Protocol.cs @@ -0,0 +1,240 @@ +using System.Buffers.Binary; +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace DevolutionsAgentPolicyConsent; + +internal static class Protocol +{ + internal const string Version = PolicyConsentContract.ProtocolVersion; + internal const string PipePrefix = "UniGetUI.PolicyElevation."; + internal const int MaxRequestBodyBytes = 16_793_054; + internal const int MaxResponseBodyBytes = 15_618; + internal static readonly TimeSpan ConnectTimeout = TimeSpan.FromSeconds(45); + internal static readonly TimeSpan ExchangeTimeout = TimeSpan.FromMinutes(2); + internal static readonly TimeSpan ResponseWriteTimeout = TimeSpan.FromSeconds(10); + + internal static Arguments ParseArguments(string[] args) + { + if (args.Length != 10) + { + throw new ProtocolException("invalid argument count"); + } + + Dictionary values = new(StringComparer.Ordinal); + for (int index = 0; index < args.Length; index += 2) + { + if (!values.TryAdd(args[index], args[index + 1])) + { + throw new ProtocolException("duplicate argument"); + } + } + + string protocol = Required(values, "--protocol"); + string pipe = Required(values, "--pipe"); + if (protocol != Version || + !pipe.StartsWith(PipePrefix, StringComparison.Ordinal) || + !IsLowerHex(pipe.AsSpan(PipePrefix.Length), 32) || + !int.TryParse( + Required(values, "--parent-pid"), + NumberStyles.None, + CultureInfo.InvariantCulture, + out int parentPid) || + parentPid <= 0 || + !long.TryParse( + Required(values, "--parent-created"), + NumberStyles.None, + CultureInfo.InvariantCulture, + out long parentCreated) || + parentCreated <= 0 || + !uint.TryParse( + Required(values, "--session"), + NumberStyles.None, + CultureInfo.InvariantCulture, + out uint session) || + values.Count != 5) + { + throw new ProtocolException("invalid argument value"); + } + + return new Arguments(pipe, parentPid, parentCreated, session); + } + + internal static void ValidateRequest(ElevationRequest request) + { + if (request.ProtocolVersion != Version || + !IsLowerHex(request.RequestId.AsSpan(), 32) || + request.Operation is not ("Update" or "ReplaceIdentity" or "Create" or "Repair") || + request.ConflictHandling is not ("Reject" or "ConfirmOverwrite") || + !IsCredential(request.ExpectedStoreToken, 512) || + !IsCredential(request.ValidationReceipt, 2048) || + request.Draft.ValueKind != JsonValueKind.Object) + { + throw new ProtocolException("invalid request"); + } + } + + internal static void ValidateResponse(ElevationResponse response) + { + if (response.ProtocolVersion != Version || + !IsLowerHex(response.RequestId.AsSpan(), 32) || + response.Disposition is not ("Committed" or "Rejected" or "Unknown") || + !IsOptionalCredential(response.BrokerErrorCode, 64)) + { + throw new ProtocolException("invalid response"); + } + + bool hasConflict = + response.ConflictStoreToken is not null || + response.ConflictState is not null || + response.ConflictPolicyId is not null; + switch (response.Disposition) + { + case "Committed" when + response.BrokerStatusCode is null && + response.BrokerErrorCode is null && + IsCredential(response.CommittedStoreToken, 512) && + !hasConflict: + return; + case "Rejected" when + response.CommittedStoreToken is null && + response.BrokerErrorCode is not null: + ValidateConflict(response, hasConflict); + return; + case "Unknown" when + response.CommittedStoreToken is null && + response.BrokerErrorCode is not null && + !hasConflict: + return; + default: + throw new ProtocolException("invalid response shape"); + } + } + + internal static async Task ReadFrameAsync(Stream stream, int maximum, CancellationToken cancellationToken) + { + byte[] header = new byte[4]; + await ReadExactlyAsync(stream, header, cancellationToken); + uint length = BinaryPrimitives.ReadUInt32BigEndian(header); + if (length == 0 || length > maximum) + { + throw new ProtocolException("invalid frame length"); + } + + byte[] body = GC.AllocateUninitializedArray(checked((int)length)); + await ReadExactlyAsync(stream, body, cancellationToken); + return body; + } + + internal static async Task WriteFrameAsync( + Stream stream, + ReadOnlyMemory body, + int maximum, + CancellationToken cancellationToken) + { + if (body.IsEmpty || body.Length > maximum) + { + throw new ProtocolException("invalid frame length"); + } + + byte[] header = new byte[4]; + BinaryPrimitives.WriteUInt32BigEndian(header, checked((uint)body.Length)); + await stream.WriteAsync(header, cancellationToken); + await stream.WriteAsync(body, cancellationToken); + await stream.FlushAsync(cancellationToken); + } + + private static async Task ReadExactlyAsync(Stream stream, Memory buffer, CancellationToken cancellationToken) + { + int offset = 0; + while (offset < buffer.Length) + { + int read = await stream.ReadAsync(buffer[offset..], cancellationToken); + if (read == 0) + { + throw new EndOfStreamException("unexpected end of elevation frame"); + } + offset += read; + } + } + + private static string Required(Dictionary values, string key) => + values.TryGetValue(key, out string? value) && value.Length != 0 + ? value + : throw new ProtocolException("missing argument"); + + private static bool IsLowerHex(ReadOnlySpan value, int length) => + value.Length == length && value.IndexOfAnyExcept("0123456789abcdef") < 0; + + internal static bool IsCredential(string? value, int maximum) => + value is not null && + value.Length is > 0 && + value.Length <= maximum && + IsAsciiAlphaNumeric(value[0]) && + value.AsSpan(1).IndexOfAnyExcept("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._~:-") < 0; + + private static bool IsOptionalCredential(string? value, int maximum) => + value is null || IsCredential(value, maximum); + + private static void ValidateConflict(ElevationResponse response, bool hasConflict) + { + if (response.BrokerErrorCode != "StalePolicyStoreToken") + { + if (hasConflict) + { + throw new ProtocolException("non-stale response carries conflict fields"); + } + return; + } + + if (!IsCredential(response.ConflictStoreToken, 512) || + response.ConflictState is not ("Active" or "Missing" or "Invalid") || + (response.ConflictState == "Active" + ? !IsCredential(response.ConflictPolicyId, 2048) + : response.ConflictPolicyId is not null)) + { + throw new ProtocolException("invalid stale conflict"); + } + } + + private static bool IsAsciiAlphaNumeric(char value) => + value is >= '0' and <= '9' or >= 'A' and <= 'Z' or >= 'a' and <= 'z'; +} + +internal sealed record Arguments(string PipeName, int ParentProcessId, long ParentCreatedUtcTicks, uint SessionId); + +[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)] +internal sealed record ElevationRequest( + [property: JsonRequired] string ProtocolVersion, + [property: JsonRequired] string RequestId, + [property: JsonRequired] string Operation, + [property: JsonRequired] string ConflictHandling, + [property: JsonRequired] string ExpectedStoreToken, + [property: JsonRequired] string ValidationReceipt, + [property: JsonRequired] bool WarningsAcknowledged, + [property: JsonRequired] JsonElement Draft); + +[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)] +internal sealed record ElevationResponse( + [property: JsonRequired] string ProtocolVersion, + [property: JsonRequired] string RequestId, + [property: JsonRequired] string Disposition, + [property: JsonRequired] int? BrokerStatusCode, + [property: JsonRequired] string? BrokerErrorCode, + [property: JsonRequired] string? CommittedStoreToken, + [property: JsonRequired] string? ConflictStoreToken, + [property: JsonRequired] string? ConflictState, + [property: JsonRequired] string? ConflictPolicyId); + +[JsonSourceGenerationOptions( + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = false, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow, + DefaultIgnoreCondition = JsonIgnoreCondition.Never, + GenerationMode = JsonSourceGenerationMode.Metadata)] +[JsonSerializable(typeof(ElevationRequest))] +[JsonSerializable(typeof(ElevationResponse))] +internal sealed partial class ProtocolJsonContext : JsonSerializerContext; + +internal sealed class ProtocolException(string message) : Exception(message); diff --git a/package/AgentPolicyConsent/app.manifest b/package/AgentPolicyConsent/app.manifest new file mode 100644 index 000000000..d303ea813 --- /dev/null +++ b/package/AgentPolicyConsent/app.manifest @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/package/AgentWindowsManaged.Tests/PackageBrokerInstallerTests.cs b/package/AgentWindowsManaged.Tests/PackageBrokerInstallerTests.cs index 3e7c19b01..6dc273ed7 100644 --- a/package/AgentWindowsManaged.Tests/PackageBrokerInstallerTests.cs +++ b/package/AgentWindowsManaged.Tests/PackageBrokerInstallerTests.cs @@ -1,5 +1,6 @@ using DevolutionsAgent; using DevolutionsAgent.Actions; +using DevolutionsAgent.Resources; using Microsoft.Deployment.WindowsInstaller; using System; using System.ComponentModel; @@ -620,6 +621,55 @@ public void MigrationActionsUseDeferredRollbackCommitSequence() Assert.Equal(Condition.NOT_BeingRemoved.ToString(), migrate.Condition.ToString()); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public void PolicyConsentDiscoveryIsTransactionalAndArchitectureCorrect(bool win64) + { + RegValue value = Program.CreatePolicyConsentRegistryValue( + "ProtocolVersion", + DevolutionsAgent.Resources.Includes.POLICY_CONSENT_PROTOCOL_VERSION, + win64); + + Assert.Equal(RegistryHive.LocalMachine, value.Root); + Assert.Equal(@"Software\Devolutions\Agent\PolicyConsentHelper", value.Key); + Assert.Equal(RegistryKeyAction.createAndRemoveOnUninstall, value.RegistryKeyAction); + Assert.Equal(win64, value.Win64); + Assert.Equal( + win64 ? "Type=string; Component:Win64=yes" : "Type=string", + value.AttributesDefinition); + Assert.Contains(Features.AGENT_FEATURE, value.ActualFeatures); + } + + [Theory] + [InlineData(Platform.x86, false)] + [InlineData(Platform.x64, true)] + [InlineData(Platform.arm64, true)] + public void PolicyConsentDiscoveryUsesNativeRegistryView(Platform platform, bool expected) + { + Assert.Equal(expected, Program.Use64BitRegistryView(platform)); + } + + [Fact] + public void PolicyConsentDiscoveryPublishesFixedProtectedHelperIdentity() + { + (string Name, string Value)[] values = + [ + ("ProtocolVersion", DevolutionsAgent.Resources.Includes.POLICY_CONSENT_PROTOCOL_VERSION), + ("ExecutableName", DevolutionsAgent.Resources.Includes.POLICY_CONSENT_EXECUTABLE_NAME), + ("ExecutablePath", "[INSTALLDIR]DevolutionsAgentPolicyConsent.exe"), + ("ProductName", DevolutionsAgent.Resources.Includes.POLICY_CONSENT_PRODUCT_NAME), + ]; + + foreach ((string name, string expectedValue) in values) + { + RegValue value = Program.CreatePolicyConsentRegistryValue(name, expectedValue, true); + Assert.Equal(expectedValue, value.Value); + Assert.Equal(RegistryKeyAction.createAndRemoveOnUninstall, value.RegistryKeyAction); + Assert.Contains(Features.AGENT_FEATURE, value.ActualFeatures); + } + } + [Theory] [InlineData("marker inspection")] [InlineData("marker deletion")] diff --git a/package/AgentWindowsManaged/DevolutionsAgent.csproj b/package/AgentWindowsManaged/DevolutionsAgent.csproj index 527dd4bd4..6df8d0103 100644 --- a/package/AgentWindowsManaged/DevolutionsAgent.csproj +++ b/package/AgentWindowsManaged/DevolutionsAgent.csproj @@ -6,6 +6,7 @@ latest + diff --git a/package/AgentWindowsManaged/Program.cs b/package/AgentWindowsManaged/Program.cs index 09a32b242..567150057 100644 --- a/package/AgentWindowsManaged/Program.cs +++ b/package/AgentWindowsManaged/Program.cs @@ -77,6 +77,10 @@ private static string ResolveArtifact(string varName, string defaultPath = null) private static string DevolutionsAgentExePath => ResolveArtifact("DAGENT_EXECUTABLE", "..\\..\\target\\debug\\devolutions-agent.exe"); + private static string DevolutionsAgentPolicyConsentPath => ResolveArtifact( + "DAGENT_POLICY_CONSENT_HELPER", + "..\\AgentPolicyConsent\\bin\\Release\\net10.0-windows\\win-x64\\publish\\DevolutionsAgentPolicyConsent.exe"); + private static string DevolutionsDesktopAgentPath { // ReSharper disable once ArrangeAccessorOwnerBody @@ -123,6 +127,12 @@ private static Version DevolutionsAgentVersion } } + // The MSI version drops the "20" prefix; discovery restores the calendar-year product version. + private static Version DevolutionsAgentProductVersion => new( + DevolutionsAgentVersion.Major + 2000, + DevolutionsAgentVersion.Minor, + DevolutionsAgentVersion.Build); + private static WixSharp.Platform TargetPlatform { get @@ -298,6 +308,10 @@ static void Main() new (Features.AGENT_FEATURE, DevolutionsMultiPwshExe) { TargetFileName = "multi-pwsh.exe" + }, + new (Features.AGENT_FEATURE, DevolutionsAgentPolicyConsentPath) + { + TargetFileName = Includes.POLICY_CONSENT_EXECUTABLE_NAME } }, Dirs = new[] @@ -325,6 +339,34 @@ static void Main() Win64 = project.Platform == Platform.x64, RegistryKeyAction = RegistryKeyAction.create, }, + CreatePolicyConsentRegistryValue( + "ProtocolVersion", + Includes.POLICY_CONSENT_PROTOCOL_VERSION, + Use64BitRegistryView(project.Platform)), + CreatePolicyConsentRegistryValue( + "ExecutableName", + Includes.POLICY_CONSENT_EXECUTABLE_NAME, + Use64BitRegistryView(project.Platform)), + CreatePolicyConsentRegistryValue( + "ExecutablePath", + $"[{AgentProperties.InstallDir}]{Includes.POLICY_CONSENT_EXECUTABLE_NAME}", + Use64BitRegistryView(project.Platform)), + CreatePolicyConsentRegistryValue( + "ProductName", + Includes.POLICY_CONSENT_PRODUCT_NAME, + Use64BitRegistryView(project.Platform)), + CreatePolicyConsentRegistryValue( + "ProductVersion", + DevolutionsAgentProductVersion.ToString(), + Use64BitRegistryView(project.Platform)), + CreatePolicyConsentRegistryValue( + "CurrentUiSignerSpkiSha256", + Includes.POLICY_CONSENT_CURRENT_UI_SIGNER_SPKI_SHA256, + Use64BitRegistryView(project.Platform)), + CreatePolicyConsentRegistryValue( + "TransitionUiSignerSpkiSha256", + Includes.POLICY_CONSENT_TRANSITION_UI_SIGNER_SPKI_SHA256, + Use64BitRegistryView(project.Platform)), new (RegistryHive.LocalMachine, "SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Run", Includes.SERVICE_NAME, $"[{AgentProperties.InstallDir}]{Includes.DESKTOP_DIRECTORY_NAME}\\{Includes.DESKTOP_EXECUTABLE_NAME}") { Win64 = project.Platform == Platform.x64, @@ -435,6 +477,23 @@ internal static RegValue CreateEventLogSourceRegistryValue(bool win64) => RegistryKeyAction = RegistryKeyAction.createAndRemoveOnUninstall, }; + internal static RegValue CreatePolicyConsentRegistryValue(string name, string value, bool win64) => + new( + RegistryHive.LocalMachine, + $"Software\\{Includes.VENDOR_NAME}\\{Includes.SHORT_NAME}\\PolicyConsentHelper", + name, + value) + { + AttributesDefinition = win64 ? "Type=string; Component:Win64=yes" : "Type=string", + Win64 = win64, + RegistryKeyAction = RegistryKeyAction.createAndRemoveOnUninstall, + Feature = Features.AGENT_FEATURE, + }; + + // Discovery follows the consumer's native view; legacy Agent values retain their existing layout. + internal static bool Use64BitRegistryView(Platform? platform) => + platform is Platform.x64 or Platform.arm64; + private static void Project_UnhandledException(ExceptionEventArgs e) { string errorMessage = diff --git a/package/AgentWindowsManaged/Resources/Includes.cs b/package/AgentWindowsManaged/Resources/Includes.cs index 667a1747c..bb5bab31f 100644 --- a/package/AgentWindowsManaged/Resources/Includes.cs +++ b/package/AgentWindowsManaged/Resources/Includes.cs @@ -18,6 +18,21 @@ internal static class Includes internal static readonly string EXECUTABLE_NAME = "DevolutionsAgent.exe"; + internal static readonly string POLICY_CONSENT_EXECUTABLE_NAME = + DevolutionsAgentPolicyConsent.PolicyConsentContract.ExecutableName; + + internal static readonly string POLICY_CONSENT_PRODUCT_NAME = + DevolutionsAgentPolicyConsent.PolicyConsentContract.ProductName; + + internal static readonly string POLICY_CONSENT_PROTOCOL_VERSION = + DevolutionsAgentPolicyConsent.PolicyConsentContract.ProtocolVersion; + + internal static readonly string POLICY_CONSENT_CURRENT_UI_SIGNER_SPKI_SHA256 = + DevolutionsAgentPolicyConsent.PolicyConsentContract.CurrentUiSignerSpkiSha256; + + internal static readonly string POLICY_CONSENT_TRANSITION_UI_SIGNER_SPKI_SHA256 = + DevolutionsAgentPolicyConsent.PolicyConsentContract.TransitionUiSignerSpkiSha256; + internal static readonly string DESKTOP_DIRECTORY_NAME = "desktop"; internal static readonly string DESKTOP_EXECUTABLE_NAME = "DevolutionsDesktopAgent.exe";