From 7396130bc1b6c39d198dd4e905ada7ebf83571a9 Mon Sep 17 00:00:00 2001 From: Pedro Larroy Date: Wed, 19 Aug 2026 09:33:04 -0700 Subject: [PATCH 1/2] fix(connection): trust MSIX-packaged wslrelay.exe when Authenticode check fails Modern WSL distributes as an MSIX package (Program Files\WSL / WindowsApps), which doesn't embed per-file Authenticode/catalog signatures on its EXEs -- trust is established at the package level instead. This caused genuine Microsoft wslrelay.exe binaries to be rejected during setup's loopback listener provenance check with "WSL relay Authenticode verification failed (Unsigned)". Add a fallback in WindowsAuthenticodeVerifier: when the classic Authenticode check fails and the file is wslrelay.exe, corroborate trust via the installed AppX package instead. Requires an exact match on the known WSL package family name (MicrosoftCorporationII.WindowsSubsystemForLinux_8wekyb3d8bbwe, whose suffix is derived from the publisher's signing cert), a real SignatureKind, and a Microsoft publisher (reusing the existing HasMicrosoftPublisherIdentity check). Looked up via a PowerShell Get-AppxPackage shell-out, matching this file's existing pattern for invoking schtasks.exe/wsl.exe. The primary Authenticode/catalog check is unchanged and always consulted first; the fallback narrows to wslrelay.exe specifically and never weakens the trust bar for any other binary. --- .../WindowsAuthenticodeVerifier.cs | 198 +++++++++++++++++- ...dLocalGatewayPortProvenanceServiceTests.cs | 88 ++++++++ 2 files changed, 285 insertions(+), 1 deletion(-) diff --git a/src/OpenClaw.Connection/WindowsAuthenticodeVerifier.cs b/src/OpenClaw.Connection/WindowsAuthenticodeVerifier.cs index f2e190d3d..d251a249e 100644 --- a/src/OpenClaw.Connection/WindowsAuthenticodeVerifier.cs +++ b/src/OpenClaw.Connection/WindowsAuthenticodeVerifier.cs @@ -1,5 +1,9 @@ using System; +using System.Diagnostics; using System.IO; +using System.Linq; +using System.Text.Json; +using System.Threading; using Microsoft.Security.Extensions; namespace OpenClaw.Connection; @@ -11,9 +15,51 @@ internal readonly record struct AuthenticodeTrustResult(bool IsTrusted, string? public static AuthenticodeTrustResult Rejected(string detail) => new(false, detail); } +/// +/// Minimal projection of a Windows AppX/MSIX package as reported by +/// Get-AppxPackage, used to corroborate genuine Microsoft WSL binaries that +/// ship without a per-file Authenticode/catalog signature (MSIX packages are +/// trusted at the package level, not per-file). +/// +internal readonly record struct AppxPackageInfo( + string? PackageFamilyName, + string? Publisher, + string? SignatureKind); + internal static class WindowsAuthenticodeVerifier { - public static AuthenticodeTrustResult VerifyMicrosoftSignedFile(string path) + // Modern WSL ships as an MSIX package. The trailing suffix is a hash derived + // from the publisher's signing certificate/public key, so an impostor package + // re-signed with a different certificate would get a different family name. + private const string WslPackageFamilyName = + "MicrosoftCorporationII.WindowsSubsystemForLinux_8wekyb3d8bbwe"; + + public static AuthenticodeTrustResult VerifyMicrosoftSignedFile(string path) => + VerifyMicrosoftSignedFile(path, LookupWslAppxPackageViaPowerShell); + + /// + /// Test seam: allows callers to substitute a fake WSL AppX package lookup + /// instead of shelling out to PowerShell. + /// + internal static AuthenticodeTrustResult VerifyMicrosoftSignedFile( + string path, + Func lookupWslPackage) + { + var primary = VerifyAuthenticodeSignature(path); + if (primary.IsTrusted) + return primary; + + // MSIX packages (modern WSL) don't carry a per-file Authenticode/catalog + // signature, so a failed classic check isn't conclusive for wslrelay.exe. + // Only consult the package-level fallback for the WSL binaries this + // codebase actually cares about; never widen trust for arbitrary files. + if (!string.Equals(Path.GetFileName(path), "wslrelay.exe", StringComparison.OrdinalIgnoreCase)) + return primary; + + return VerifyWslPackageFallback(primary, lookupWslPackage); + } + + private static AuthenticodeTrustResult VerifyAuthenticodeSignature(string path) { try { @@ -45,6 +91,50 @@ public static AuthenticodeTrustResult VerifyMicrosoftSignedFile(string path) } } + private static AuthenticodeTrustResult VerifyWslPackageFallback( + AuthenticodeTrustResult primaryFailure, + Func lookupWslPackage) + { + AppxPackageInfo? package; + try + { + package = lookupWslPackage(); + } + catch + { + package = null; + } + + if (package is not { } info) + { + // No genuine WSL AppX package installed to corroborate; surface the + // original Authenticode diagnostic rather than a new, less useful one. + return primaryFailure; + } + + if (!string.Equals(info.PackageFamilyName, WslPackageFamilyName, StringComparison.Ordinal)) + { + // Doesn't match the well-known family name (a different/impostor + // package can't corroborate this binary); surface the original detail. + return primaryFailure; + } + + if (string.IsNullOrEmpty(info.SignatureKind) || + string.Equals(info.SignatureKind, "None", StringComparison.OrdinalIgnoreCase)) + { + return AuthenticodeTrustResult.Rejected( + "WSL package signature verification failed: the installed WSL package is unsigned."); + } + + if (info.Publisher is null || !HasMicrosoftPublisherIdentity(info.Publisher)) + { + return AuthenticodeTrustResult.Rejected( + "WSL package signature verification failed: the installed WSL package's publisher is not Microsoft Corporation."); + } + + return AuthenticodeTrustResult.Trusted(); + } + internal static bool HasMicrosoftPublisherIdentity(string subject) => subject.Split(',') .Select(part => part.Trim()) @@ -53,4 +143,110 @@ internal static bool HasMicrosoftPublisherIdentity(string subject) => part, "O=Microsoft Corporation", StringComparison.OrdinalIgnoreCase)); + + private static AppxPackageInfo? LookupWslAppxPackageViaPowerShell() + { + Process? process = null; + try + { + var psi = new ProcessStartInfo + { + FileName = ResolvePowerShellPath(), + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + }; + psi.ArgumentList.Add("-NoProfile"); + psi.ArgumentList.Add("-NonInteractive"); + psi.ArgumentList.Add("-Command"); + psi.ArgumentList.Add( + "Get-AppxPackage -Name 'MicrosoftCorporationII.WindowsSubsystemForLinux' | " + + "Select-Object -First 1 PackageFamilyName, Publisher, SignatureKind | " + + "ConvertTo-Json -Compress"); + + process = Process.Start(psi); + if (process is null) + return null; + + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(8)); + var stdoutTask = process.StandardOutput.ReadToEndAsync(timeout.Token); + var stderrTask = process.StandardError.ReadToEndAsync(timeout.Token); + + if (!process.WaitForExit(8_000)) + { + try { process.Kill(entireProcessTree: true); } catch { } + return null; + } + + string output; + try + { + output = stdoutTask.GetAwaiter().GetResult(); + _ = stderrTask.GetAwaiter().GetResult(); + } + catch + { + return null; + } + + if (process.ExitCode != 0 || string.IsNullOrWhiteSpace(output)) + return null; + + return ParseAppxPackageJson(output); + } + catch + { + return null; + } + finally + { + process?.Dispose(); + } + } + + private static string ResolvePowerShellPath() + { + var systemRoot = Environment.GetFolderPath(Environment.SpecialFolder.Windows); + if (string.IsNullOrWhiteSpace(systemRoot)) + systemRoot = Environment.GetEnvironmentVariable("SystemRoot"); + if (string.IsNullOrWhiteSpace(systemRoot)) + systemRoot = @"C:\Windows"; + return Path.Combine(systemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe"); + } + + private static AppxPackageInfo? ParseAppxPackageJson(string json) + { + try + { + using var document = JsonDocument.Parse(json); + var root = document.RootElement; + if (root.ValueKind == JsonValueKind.Array) + { + if (root.GetArrayLength() == 0) + return null; + root = root[0]; + } + if (root.ValueKind != JsonValueKind.Object) + return null; + + var familyName = GetStringProperty(root, "PackageFamilyName"); + if (familyName is null) + return null; + + return new AppxPackageInfo( + familyName, + GetStringProperty(root, "Publisher"), + GetStringProperty(root, "SignatureKind")); + } + catch + { + return null; + } + } + + private static string? GetStringProperty(JsonElement element, string propertyName) => + element.TryGetProperty(propertyName, out var value) && value.ValueKind == JsonValueKind.String + ? value.GetString() + : null; } diff --git a/tests/OpenClaw.Connection.Tests/ManagedLocalGatewayPortProvenanceServiceTests.cs b/tests/OpenClaw.Connection.Tests/ManagedLocalGatewayPortProvenanceServiceTests.cs index b5856a970..c2ab454f4 100644 --- a/tests/OpenClaw.Connection.Tests/ManagedLocalGatewayPortProvenanceServiceTests.cs +++ b/tests/OpenClaw.Connection.Tests/ManagedLocalGatewayPortProvenanceServiceTests.cs @@ -53,6 +53,94 @@ public void VerifyMicrosoftSignedFile_RejectsUnsignedAssembly() Assert.Contains("Authenticode verification failed", result.Detail); } + private const string WslPackageFamilyName = + "MicrosoftCorporationII.WindowsSubsystemForLinux_8wekyb3d8bbwe"; + private const string NonCanonicalWslRelayPath = @"C:\Program Files\WSL\wslrelay.exe"; + + [Fact] + public void VerifyMicrosoftSignedFile_AuthenticodeFailsButWslPackageCorroborates_IsTrusted() + { + var result = WindowsAuthenticodeVerifier.VerifyMicrosoftSignedFile( + NonCanonicalWslRelayPath, + () => new AppxPackageInfo( + WslPackageFamilyName, + "CN=Microsoft Windows, O=Microsoft Corporation, C=US", + "Developer")); + + Assert.True(result.IsTrusted, result.Detail); + } + + [Fact] + public void VerifyMicrosoftSignedFile_AuthenticodeFailsAndNoWslPackageFound_IsRejectedWithOriginalDetail() + { + var result = WindowsAuthenticodeVerifier.VerifyMicrosoftSignedFile( + NonCanonicalWslRelayPath, + () => null); + + Assert.False(result.IsTrusted); + Assert.Contains("Authenticode", result.Detail, StringComparison.Ordinal); + } + + [Fact] + public void VerifyMicrosoftSignedFile_AuthenticodeFailsAndWslPackageFamilyNameMismatches_IsRejected() + { + var result = WindowsAuthenticodeVerifier.VerifyMicrosoftSignedFile( + NonCanonicalWslRelayPath, + () => new AppxPackageInfo( + "SomeImpostor.WindowsSubsystemForLinux_deadbeefcafe", + "CN=Microsoft Windows, O=Microsoft Corporation, C=US", + "Developer")); + + Assert.False(result.IsTrusted); + } + + [Fact] + public void VerifyMicrosoftSignedFile_AuthenticodeFailsAndWslPackageUnsigned_IsRejected() + { + var result = WindowsAuthenticodeVerifier.VerifyMicrosoftSignedFile( + NonCanonicalWslRelayPath, + () => new AppxPackageInfo( + WslPackageFamilyName, + "CN=Microsoft Windows, O=Microsoft Corporation, C=US", + "None")); + + Assert.False(result.IsTrusted); + Assert.Contains("WSL package", result.Detail, StringComparison.Ordinal); + } + + [Fact] + public void VerifyMicrosoftSignedFile_AuthenticodeFailsAndWslPackagePublisherNotMicrosoft_IsRejected() + { + var result = WindowsAuthenticodeVerifier.VerifyMicrosoftSignedFile( + NonCanonicalWslRelayPath, + () => new AppxPackageInfo( + WslPackageFamilyName, + "CN=Evil Corp, O=Evil Corp, C=US", + "Developer")); + + Assert.False(result.IsTrusted); + Assert.Contains("WSL package", result.Detail, StringComparison.Ordinal); + } + + [Fact] + public void VerifyMicrosoftSignedFile_AuthenticodeSucceeds_NeverConsultsWslPackageFallback() + { + var windowsDir = Environment.GetFolderPath(Environment.SpecialFolder.Windows); + var wslPath = Path.Combine(windowsDir, "System32", "wsl.exe"); + var fallbackInvoked = false; + + var result = WindowsAuthenticodeVerifier.VerifyMicrosoftSignedFile( + wslPath, + () => + { + fallbackInvoked = true; + throw new InvalidOperationException("Fallback should not be consulted."); + }); + + Assert.True(result.IsTrusted, result.Detail); + Assert.False(fallbackInvoked); + } + [Theory] [InlineData("CN=Microsoft Windows, O=Microsoft Corporation, C=US", true)] [InlineData("CN=Microsoft Corporation Test Certificate, O=Example Corp, C=US", false)] From 832709143cbfd349533a4e4cf4a762412be7de5d Mon Sep 17 00:00:00 2001 From: Scott Hanselman Date: Fri, 21 Aug 2026 15:14:27 -0700 Subject: [PATCH 2/2] fix(connection): bind WSL relay fallback to package Require package-owned WindowsApps paths or a protected external relay whose ACLs and version bind it to the installed Microsoft WSL package. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 76619eef-66f0-45be-ad09-b753347eea6b --- .../WindowsAuthenticodeVerifier.cs | 285 +++++++++++++++++- ...dLocalGatewayPortProvenanceServiceTests.cs | 234 ++++++++++++-- 2 files changed, 481 insertions(+), 38 deletions(-) diff --git a/src/OpenClaw.Connection/WindowsAuthenticodeVerifier.cs b/src/OpenClaw.Connection/WindowsAuthenticodeVerifier.cs index d251a249e..c6223964e 100644 --- a/src/OpenClaw.Connection/WindowsAuthenticodeVerifier.cs +++ b/src/OpenClaw.Connection/WindowsAuthenticodeVerifier.cs @@ -2,6 +2,9 @@ using System.Diagnostics; using System.IO; using System.Linq; +using System.Runtime.Versioning; +using System.Security.AccessControl; +using System.Security.Principal; using System.Text.Json; using System.Threading; using Microsoft.Security.Extensions; @@ -24,7 +27,21 @@ internal readonly record struct AuthenticodeTrustResult(bool IsTrusted, string? internal readonly record struct AppxPackageInfo( string? PackageFamilyName, string? Publisher, - string? SignatureKind); + string? SignatureKind, + string? InstallLocation, + string? Version); + +internal readonly record struct WslRelayPathInspection( + bool IsSecure, + string? FileVersion, + string? Detail) +{ + public static WslRelayPathInspection Secure(string? fileVersion) => + new(true, fileVersion, null); + + public static WslRelayPathInspection Rejected(string detail) => + new(false, null, detail); +} internal static class WindowsAuthenticodeVerifier { @@ -33,19 +50,37 @@ internal static class WindowsAuthenticodeVerifier // re-signed with a different certificate would get a different family name. private const string WslPackageFamilyName = "MicrosoftCorporationII.WindowsSubsystemForLinux_8wekyb3d8bbwe"; + private const string TrustedInstallerSid = + "S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464"; public static AuthenticodeTrustResult VerifyMicrosoftSignedFile(string path) => - VerifyMicrosoftSignedFile(path, LookupWslAppxPackageViaPowerShell); + VerifyMicrosoftSignedFile( + path, + VerifyAuthenticodeSignature, + LookupWslAppxPackageViaPowerShell, + InspectWslRelayPath); /// - /// Test seam: allows callers to substitute a fake WSL AppX package lookup - /// instead of shelling out to PowerShell. + /// Test seam for the primary signature, WSL package, and protected-path + /// evidence used by the package fallback. /// internal static AuthenticodeTrustResult VerifyMicrosoftSignedFile( string path, - Func lookupWslPackage) + Func verifyAuthenticode, + Func lookupWslPackage, + Func inspectRelayPath) { - var primary = VerifyAuthenticodeSignature(path); + AuthenticodeTrustResult primary; + try + { + primary = verifyAuthenticode(path); + } + catch + { + return AuthenticodeTrustResult.Rejected( + "WSL relay Authenticode verification could not complete."); + } + if (primary.IsTrusted) return primary; @@ -56,7 +91,7 @@ internal static AuthenticodeTrustResult VerifyMicrosoftSignedFile( if (!string.Equals(Path.GetFileName(path), "wslrelay.exe", StringComparison.OrdinalIgnoreCase)) return primary; - return VerifyWslPackageFallback(primary, lookupWslPackage); + return VerifyWslPackageFallback(path, primary, lookupWslPackage, inspectRelayPath); } private static AuthenticodeTrustResult VerifyAuthenticodeSignature(string path) @@ -92,8 +127,10 @@ private static AuthenticodeTrustResult VerifyAuthenticodeSignature(string path) } private static AuthenticodeTrustResult VerifyWslPackageFallback( + string path, AuthenticodeTrustResult primaryFailure, - Func lookupWslPackage) + Func lookupWslPackage, + Func inspectRelayPath) { AppxPackageInfo? package; try @@ -132,9 +169,98 @@ private static AuthenticodeTrustResult VerifyWslPackageFallback( "WSL package signature verification failed: the installed WSL package's publisher is not Microsoft Corporation."); } + if (string.IsNullOrWhiteSpace(info.InstallLocation) || + !Path.IsPathFullyQualified(info.InstallLocation)) + { + return AuthenticodeTrustResult.Rejected( + "WSL package provenance verification failed: package location data is unavailable."); + } + + string fullPath; + string installLocation; + try + { + fullPath = Path.GetFullPath(path); + installLocation = Path.GetFullPath(info.InstallLocation); + } + catch + { + return AuthenticodeTrustResult.Rejected( + "WSL package provenance verification failed: package location data is invalid."); + } + + var programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); + var windowsAppsRoot = Path.Combine(programFiles, "WindowsApps"); + if (IsPathWithin(installLocation, windowsAppsRoot) && + IsPathWithin(fullPath, installLocation)) + { + return ToTrustResult( + InspectProtectedRelayPath(fullPath, programFiles, inspectRelayPath)); + } + + var externalRelayPath = Path.Combine(programFiles, "WSL", "wslrelay.exe"); + if (!string.Equals(fullPath, externalRelayPath, StringComparison.OrdinalIgnoreCase)) + { + return AuthenticodeTrustResult.Rejected( + "WSL package provenance verification failed: the relay is not owned by the installed WSL package."); + } + + var inspection = InspectProtectedRelayPath( + fullPath, + programFiles, + inspectRelayPath); + if (!inspection.IsSecure) + return ToTrustResult(inspection); + + // The external WSL layout stamps wslrelay.exe with the package version. + // Exact equality binds the protected external bytes to the installed package. + if (!VersionsMatch(info.Version, inspection.FileVersion)) + { + return AuthenticodeTrustResult.Rejected( + "WSL package provenance verification failed: the external relay version does not match the installed WSL package."); + } + return AuthenticodeTrustResult.Trusted(); } + private static WslRelayPathInspection InspectProtectedRelayPath( + string fullPath, + string trustedRoot, + Func inspectRelayPath) + { + try + { + return inspectRelayPath(fullPath, trustedRoot); + } + catch + { + return WslRelayPathInspection.Rejected( + "WSL package provenance verification failed: the relay path could not be inspected."); + } + } + + private static AuthenticodeTrustResult ToTrustResult(WslRelayPathInspection inspection) => + inspection.IsSecure + ? AuthenticodeTrustResult.Trusted() + : AuthenticodeTrustResult.Rejected( + inspection.Detail ?? + "WSL package provenance verification failed: the relay path is not protected."); + + private static bool VersionsMatch(string? packageVersion, string? fileVersion) => + Version.TryParse(packageVersion, out var parsedPackageVersion) && + Version.TryParse(fileVersion, out var parsedFileVersion) && + parsedPackageVersion.Equals(parsedFileVersion); + + private static bool IsPathWithin(string path, string root) + { + var relative = Path.GetRelativePath(root, path); + return !Path.IsPathRooted(relative) && + !string.Equals(relative, "..", StringComparison.Ordinal) && + !relative.StartsWith( + $"..{Path.DirectorySeparatorChar}", + StringComparison.Ordinal); + } + internal static bool HasMicrosoftPublisherIdentity(string subject) => subject.Split(',') .Select(part => part.Trim()) @@ -162,7 +288,10 @@ internal static bool HasMicrosoftPublisherIdentity(string subject) => psi.ArgumentList.Add("-Command"); psi.ArgumentList.Add( "Get-AppxPackage -Name 'MicrosoftCorporationII.WindowsSubsystemForLinux' | " + - "Select-Object -First 1 PackageFamilyName, Publisher, SignatureKind | " + + "Sort-Object Version -Descending | " + + "Select-Object -First 1 PackageFamilyName, Publisher, InstallLocation, " + + "@{Name='SignatureKind'; Expression={$_.SignatureKind.ToString()}}, " + + "@{Name='Version'; Expression={$_.Version.ToString()}} | " + "ConvertTo-Json -Compress"); process = Process.Start(psi); @@ -237,7 +366,9 @@ private static string ResolvePowerShellPath() return new AppxPackageInfo( familyName, GetStringProperty(root, "Publisher"), - GetStringProperty(root, "SignatureKind")); + GetStringProperty(root, "SignatureKind"), + GetStringProperty(root, "InstallLocation"), + GetStringProperty(root, "Version")); } catch { @@ -249,4 +380,138 @@ private static string ResolvePowerShellPath() element.TryGetProperty(propertyName, out var value) && value.ValueKind == JsonValueKind.String ? value.GetString() : null; + + private static WslRelayPathInspection InspectWslRelayPath( + string path, + string trustedRoot) + { + if (!OperatingSystem.IsWindows()) + { + return WslRelayPathInspection.Rejected( + "WSL package provenance verification is only available on Windows."); + } + + try + { + var fullPath = Path.GetFullPath(path); + var fullRoot = Path.GetFullPath(trustedRoot); + if (!File.Exists(fullPath) || !IsPathWithin(fullPath, fullRoot)) + { + return WslRelayPathInspection.Rejected( + "WSL package provenance verification failed: the relay path is unavailable or outside its trusted root."); + } + + for (var current = fullPath; ; current = Path.GetDirectoryName(current)!) + { + if ((File.GetAttributes(current) & FileAttributes.ReparsePoint) != 0) + { + return WslRelayPathInspection.Rejected( + "WSL package provenance verification failed: the relay path contains a reparse point."); + } + + FileSystemSecurity security = + string.Equals(current, fullPath, StringComparison.OrdinalIgnoreCase) + ? new FileInfo(current).GetAccessControl( + AccessControlSections.Owner | AccessControlSections.Access) + : new DirectoryInfo(current).GetAccessControl( + AccessControlSections.Owner | AccessControlSections.Access); + if (!HasProtectedOwnershipAndWriteAcl(security)) + { + return WslRelayPathInspection.Rejected( + "WSL package provenance verification failed: the relay path permits untrusted modification."); + } + + if (string.Equals(current, fullRoot, StringComparison.OrdinalIgnoreCase)) + break; + + if (string.IsNullOrWhiteSpace(Path.GetDirectoryName(current))) + { + return WslRelayPathInspection.Rejected( + "WSL package provenance verification failed: the relay path escaped its trusted root."); + } + } + + return WslRelayPathInspection.Secure( + FileVersionInfo.GetVersionInfo(fullPath).FileVersion); + } + catch + { + return WslRelayPathInspection.Rejected( + "WSL package provenance verification failed: the relay path could not be inspected."); + } + } + + [SupportedOSPlatform("windows")] + private static bool HasProtectedOwnershipAndWriteAcl(FileSystemSecurity security) + { + var owner = security.GetOwner(typeof(SecurityIdentifier)) as SecurityIdentifier; + var rules = security.GetAccessRules( + includeExplicit: true, + includeInherited: true, + targetType: typeof(SecurityIdentifier)) + .OfType(); + var descriptor = new RawSecurityDescriptor( + security.GetSecurityDescriptorBinaryForm(), + offset: 0); + var hasDiscretionaryAcl = + (descriptor.ControlFlags & ControlFlags.DiscretionaryAclPresent) != 0 && + descriptor.DiscretionaryAcl is not null; + return HasProtectedOwnershipAndWriteAcl( + owner, + rules, + hasDiscretionaryAcl); + } + + [SupportedOSPlatform("windows")] + internal static bool HasProtectedOwnershipAndWriteAcl( + SecurityIdentifier? owner, + IEnumerable rules, + bool hasDiscretionaryAcl) + { + if (!hasDiscretionaryAcl) + return false; + + var system = new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null); + var administrators = new SecurityIdentifier( + WellKnownSidType.BuiltinAdministratorsSid, + null); + var trustedInstaller = new SecurityIdentifier(TrustedInstallerSid); + if (owner is null || + (!owner.Equals(system) && + !owner.Equals(administrators) && + !owner.Equals(trustedInstaller))) + { + return false; + } + + const FileSystemRights writeRights = + FileSystemRights.WriteData | + FileSystemRights.AppendData | + FileSystemRights.WriteExtendedAttributes | + FileSystemRights.WriteAttributes | + FileSystemRights.DeleteSubdirectoriesAndFiles | + FileSystemRights.Delete | + FileSystemRights.ChangePermissions | + FileSystemRights.TakeOwnership; + + foreach (var rule in rules) + { + if ((rule.PropagationFlags & PropagationFlags.InheritOnly) != 0 || + rule.AccessControlType != AccessControlType.Allow || + (rule.FileSystemRights & writeRights) == 0) + { + continue; + } + + if (rule.IdentityReference is not SecurityIdentifier sid || + (!sid.Equals(system) && + !sid.Equals(administrators) && + !sid.Equals(trustedInstaller))) + { + return false; + } + } + + return true; + } } diff --git a/tests/OpenClaw.Connection.Tests/ManagedLocalGatewayPortProvenanceServiceTests.cs b/tests/OpenClaw.Connection.Tests/ManagedLocalGatewayPortProvenanceServiceTests.cs index c2ab454f4..da0b5e7b2 100644 --- a/tests/OpenClaw.Connection.Tests/ManagedLocalGatewayPortProvenanceServiceTests.cs +++ b/tests/OpenClaw.Connection.Tests/ManagedLocalGatewayPortProvenanceServiceTests.cs @@ -1,4 +1,7 @@ using System.Net; +using System.Runtime.Versioning; +using System.Security.AccessControl; +using System.Security.Principal; using OpenClaw.Shared; using Xunit; @@ -55,41 +58,120 @@ public void VerifyMicrosoftSignedFile_RejectsUnsignedAssembly() private const string WslPackageFamilyName = "MicrosoftCorporationII.WindowsSubsystemForLinux_8wekyb3d8bbwe"; - private const string NonCanonicalWslRelayPath = @"C:\Program Files\WSL\wslrelay.exe"; + private const string WslPackageInstallLocation = + @"C:\Program Files\WindowsApps\MicrosoftCorporationII.WindowsSubsystemForLinux_2.9.4.0_x64__8wekyb3d8bbwe"; + private const string ExternalWslRelayPath = @"C:\Program Files\WSL\wslrelay.exe"; + private const string PackageVersion = "2.9.4.0"; [Fact] - public void VerifyMicrosoftSignedFile_AuthenticodeFailsButWslPackageCorroborates_IsTrusted() + public void VerifyMicrosoftSignedFile_AuthenticodeFailsButProtectedExternalRelayMatchesPackage_IsTrusted() { - var result = WindowsAuthenticodeVerifier.VerifyMicrosoftSignedFile( - NonCanonicalWslRelayPath, - () => new AppxPackageInfo( - WslPackageFamilyName, - "CN=Microsoft Windows, O=Microsoft Corporation, C=US", - "Developer")); + var result = VerifyPackageFallback(ExternalWslRelayPath, ValidWslPackage()); Assert.True(result.IsTrusted, result.Detail); } [Fact] - public void VerifyMicrosoftSignedFile_AuthenticodeFailsAndNoWslPackageFound_IsRejectedWithOriginalDetail() + public void VerifyMicrosoftSignedFile_AuthenticodeFailsButPackageOwnsWindowsAppsRelay_IsTrusted() + { + var packageRelayPath = Path.Combine(WslPackageInstallLocation, "wslrelay.exe"); + + var result = VerifyPackageFallback( + packageRelayPath, + ValidWslPackage(), + WslRelayPathInspection.Secure("different-file-version")); + + Assert.True(result.IsTrusted, result.Detail); + } + + [Fact] + public void VerifyMicrosoftSignedFile_SiblingWindowsAppsRelay_IsRejectedWithoutPathInspection() { + var inspected = false; + var siblingRelayPath = Path.Combine( + @"C:\Program Files\WindowsApps\SomeOther.Package_1.0.0.0_x64__deadbeefcafe", + "wslrelay.exe"); + var result = WindowsAuthenticodeVerifier.VerifyMicrosoftSignedFile( - NonCanonicalWslRelayPath, - () => null); + siblingRelayPath, + _ => AuthenticodeTrustResult.Rejected("Injected primary failure."), + () => ValidWslPackage(), + (_, _) => + { + inspected = true; + return WslRelayPathInspection.Secure(PackageVersion); + }); Assert.False(result.IsTrusted); - Assert.Contains("Authenticode", result.Detail, StringComparison.Ordinal); + Assert.False(inspected); + Assert.Contains("not owned", result.Detail, StringComparison.Ordinal); } [Fact] - public void VerifyMicrosoftSignedFile_AuthenticodeFailsAndWslPackageFamilyNameMismatches_IsRejected() + public void VerifyMicrosoftSignedFile_ExternalRelayVersionMismatch_IsRejected() + { + var result = VerifyPackageFallback( + ExternalWslRelayPath, + ValidWslPackage(), + WslRelayPathInspection.Secure("2.9.3.0")); + + Assert.False(result.IsTrusted); + Assert.Contains("version does not match", result.Detail, StringComparison.Ordinal); + } + + [Fact] + public void VerifyMicrosoftSignedFile_ExternalRelayWithUnprotectedPath_IsRejected() + { + var result = VerifyPackageFallback( + ExternalWslRelayPath, + ValidWslPackage(), + WslRelayPathInspection.Rejected("Injected unprotected path.")); + + Assert.False(result.IsTrusted); + Assert.Contains("unprotected", result.Detail, StringComparison.Ordinal); + } + + [Fact] + public void VerifyMicrosoftSignedFile_RelativePackageLocation_IsRejectedWithoutPathInspection() { + var inspected = false; + var package = ValidWslPackage() with { InstallLocation = @"relative\package" }; + var result = WindowsAuthenticodeVerifier.VerifyMicrosoftSignedFile( - NonCanonicalWslRelayPath, - () => new AppxPackageInfo( + ExternalWslRelayPath, + _ => AuthenticodeTrustResult.Rejected("Injected primary failure."), + () => package, + (_, _) => + { + inspected = true; + return WslRelayPathInspection.Secure(PackageVersion); + }); + + Assert.False(result.IsTrusted); + Assert.False(inspected); + Assert.Contains("location data is unavailable", result.Detail, StringComparison.Ordinal); + } + + [Fact] + public void VerifyMicrosoftSignedFile_AuthenticodeFailsAndNoWslPackageFound_IsRejectedWithOriginalDetail() + { + var result = VerifyPackageFallback(ExternalWslRelayPath, null); + + Assert.False(result.IsTrusted); + Assert.Contains("Injected primary failure", result.Detail, StringComparison.Ordinal); + } + + [Fact] + public void VerifyMicrosoftSignedFile_AuthenticodeFailsAndWslPackageFamilyNameMismatches_IsRejected() + { + var result = VerifyPackageFallback( + ExternalWslRelayPath, + new AppxPackageInfo( "SomeImpostor.WindowsSubsystemForLinux_deadbeefcafe", "CN=Microsoft Windows, O=Microsoft Corporation, C=US", - "Developer")); + "Developer", + WslPackageInstallLocation, + PackageVersion)); Assert.False(result.IsTrusted); } @@ -97,12 +179,14 @@ public void VerifyMicrosoftSignedFile_AuthenticodeFailsAndWslPackageFamilyNameMi [Fact] public void VerifyMicrosoftSignedFile_AuthenticodeFailsAndWslPackageUnsigned_IsRejected() { - var result = WindowsAuthenticodeVerifier.VerifyMicrosoftSignedFile( - NonCanonicalWslRelayPath, - () => new AppxPackageInfo( + var result = VerifyPackageFallback( + ExternalWslRelayPath, + new AppxPackageInfo( WslPackageFamilyName, "CN=Microsoft Windows, O=Microsoft Corporation, C=US", - "None")); + "None", + WslPackageInstallLocation, + PackageVersion)); Assert.False(result.IsTrusted); Assert.Contains("WSL package", result.Detail, StringComparison.Ordinal); @@ -111,12 +195,14 @@ public void VerifyMicrosoftSignedFile_AuthenticodeFailsAndWslPackageUnsigned_IsR [Fact] public void VerifyMicrosoftSignedFile_AuthenticodeFailsAndWslPackagePublisherNotMicrosoft_IsRejected() { - var result = WindowsAuthenticodeVerifier.VerifyMicrosoftSignedFile( - NonCanonicalWslRelayPath, - () => new AppxPackageInfo( + var result = VerifyPackageFallback( + ExternalWslRelayPath, + new AppxPackageInfo( WslPackageFamilyName, "CN=Evil Corp, O=Evil Corp, C=US", - "Developer")); + "Developer", + WslPackageInstallLocation, + PackageVersion)); Assert.False(result.IsTrusted); Assert.Contains("WSL package", result.Detail, StringComparison.Ordinal); @@ -125,22 +211,114 @@ public void VerifyMicrosoftSignedFile_AuthenticodeFailsAndWslPackagePublisherNot [Fact] public void VerifyMicrosoftSignedFile_AuthenticodeSucceeds_NeverConsultsWslPackageFallback() { - var windowsDir = Environment.GetFolderPath(Environment.SpecialFolder.Windows); - var wslPath = Path.Combine(windowsDir, "System32", "wsl.exe"); var fallbackInvoked = false; var result = WindowsAuthenticodeVerifier.VerifyMicrosoftSignedFile( - wslPath, + ExternalWslRelayPath, + _ => AuthenticodeTrustResult.Trusted(), () => { fallbackInvoked = true; throw new InvalidOperationException("Fallback should not be consulted."); - }); + }, + (_, _) => throw new InvalidOperationException("Path should not be inspected.")); Assert.True(result.IsTrusted, result.Detail); Assert.False(fallbackInvoked); } + [Fact] + public void VerifyMicrosoftSignedFile_NonRelayFailure_NeverConsultsWslPackageFallback() + { + var fallbackInvoked = false; + + var result = WindowsAuthenticodeVerifier.VerifyMicrosoftSignedFile( + @"C:\Program Files\WSL\wsl.exe", + _ => AuthenticodeTrustResult.Rejected("Injected primary failure."), + () => + { + fallbackInvoked = true; + return ValidWslPackage(); + }, + (_, _) => throw new InvalidOperationException("Path should not be inspected.")); + + Assert.False(result.IsTrusted); + Assert.Contains("Injected primary failure", result.Detail, StringComparison.Ordinal); + Assert.False(fallbackInvoked); + } + + private static AppxPackageInfo ValidWslPackage() => new( + WslPackageFamilyName, + "CN=Microsoft Windows, O=Microsoft Corporation, C=US", + "Developer", + WslPackageInstallLocation, + PackageVersion); + + private static AuthenticodeTrustResult VerifyPackageFallback( + string path, + AppxPackageInfo? package, + WslRelayPathInspection? pathInspection = null) => + WindowsAuthenticodeVerifier.VerifyMicrosoftSignedFile( + path, + _ => AuthenticodeTrustResult.Rejected("Injected primary failure."), + () => package, + (_, _) => pathInspection ?? WslRelayPathInspection.Secure(PackageVersion)); + + [Fact] + [SupportedOSPlatform("windows")] + public void HasProtectedOwnershipAndWriteAcl_AllowsReadOnlyUsersButRejectsUserWrite() + { + var system = new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null); + var administrators = new SecurityIdentifier( + WellKnownSidType.BuiltinAdministratorsSid, + null); + var users = new SecurityIdentifier(WellKnownSidType.BuiltinUsersSid, null); + FileSystemAccessRule[] protectedRules = + [ + new(system, FileSystemRights.FullControl, AccessControlType.Allow), + new(administrators, FileSystemRights.Modify, AccessControlType.Allow), + new(users, FileSystemRights.ReadAndExecute, AccessControlType.Allow), + new( + new SecurityIdentifier(WellKnownSidType.CreatorOwnerSid, null), + FileSystemRights.FullControl, + InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit, + PropagationFlags.InheritOnly, + AccessControlType.Allow), + ]; + + Assert.True( + WindowsAuthenticodeVerifier.HasProtectedOwnershipAndWriteAcl( + system, + protectedRules, + hasDiscretionaryAcl: true)); + + var writableByUsers = protectedRules.Append( + new FileSystemAccessRule( + users, + FileSystemRights.WriteData, + AccessControlType.Allow)); + Assert.False( + WindowsAuthenticodeVerifier.HasProtectedOwnershipAndWriteAcl( + system, + writableByUsers, + hasDiscretionaryAcl: true)); + Assert.False( + WindowsAuthenticodeVerifier.HasProtectedOwnershipAndWriteAcl( + users, + protectedRules, + hasDiscretionaryAcl: true)); + Assert.False( + WindowsAuthenticodeVerifier.HasProtectedOwnershipAndWriteAcl( + system, + [], + hasDiscretionaryAcl: false)); + Assert.True( + WindowsAuthenticodeVerifier.HasProtectedOwnershipAndWriteAcl( + system, + [], + hasDiscretionaryAcl: true)); + } + [Theory] [InlineData("CN=Microsoft Windows, O=Microsoft Corporation, C=US", true)] [InlineData("CN=Microsoft Corporation Test Certificate, O=Example Corp, C=US", false)]