From 1f55e7c180d3b16a7b02f1c93dc06c61166b2331 Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Thu, 4 Jun 2026 13:34:24 -0700 Subject: [PATCH] Service: add UAC-free upgrade via RunInstallerRequest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an upgrade flow that lets the user run `gvfs upgrade [--allow-unsigned]` from a non-elevated shell and have the already-elevated GVFS.Service launch the installer on the caller's behalf. Removes the UAC prompt that the old upgrade path required on every install. Builds on PR #1994 (merged) which added the versioned install layout and mount-process detection for Versions\ subdirs. How it works: - CLI: New UpgradeVerb sends a RunInstallerRequest over the existing GVFS.Service named pipe. Returns immediately after the service confirms the installer launched ("Upgrade started"). - Service: New RunInstallerHandler verifies the installer, then launches it detached with /VERYSILENT /SUPPRESSMSGBOXES /NORESTART /STAGEIFMOUNTED=true /LOG=. Does NOT WaitForExit() — the installer stops GVFS.Service as part of its upgrade flow, so waiting would deadlock. - Staging: /STAGEIFMOUNTED=true is non-disruptive. With mounts active, the installer stages binaries to PendingUpgrade\ instead of unmounting. The existing PendingUpgradeHandler applies the staged upgrade on the next unmount. - Capability detection: Pre-2.1 services don't know the new RunInstallerRequest header and respond with "UnknownRequest". The verb detects this and emits a clear "your GVFS service is too old; install a newer GVFS first" error instead of a JSON deserialization stack trace. Clients can also probe the capability via `gvfs version` (>=2.1.x supports this flow). Security model — the service runs as LocalSystem and its named pipe is ACL'd for BUILTIN\Users, so the handler must assume the caller is untrusted: - Authenticode: Verified via WinVerifyTrust (WINTRUST_ACTION_GENERIC_VERIFY_V2) — the only Win32 API that actually checks the file's signed digest against its contents. Extracting just the signer cert (X509Certificate.CreateFromSignedFile + X509Chain.Build) is NOT sufficient: it leaves a tampered binary with an intact signature blob fully accepted. Maps the common HRESULTs (TRUST_E_NOSIGNATURE, TRUST_E_BAD_DIGEST, ...) to specific user-facing errors. - Publisher: Exact-string match against certificate.GetNameInfo(X509NameType.SimpleName) == "Microsoft Corporation". Avoids substring-collision attacks such as CN="Microsoft Corporation Ltd" or DNs that place the attacker's CN alongside "Microsoft Corporation" in other fields (a Subject.Contains check would accept either). - Product identity: PE ProductName must equal "VFS for Git", checked even when --allow-unsigned is in effect. Rejects other Microsoft-signed binaries (notepad.exe etc.) at the Authenticode-trusted but wrong-product stage. - --allow-unsigned: Available in DEBUG builds only. In release builds the CLI does not register the option and the service rejects AllowUnsigned=true requests outright (defense-in-depth against a hand-crafted pipe request bypassing the CLI). In debug builds the service additionally impersonates the pipe client (NamedPipeServerStream.RunAsClient, exposed via Connection.TryRunAsClient) and rejects unless the caller is in BUILTIN\Administrators. Without this gate, any local non-admin user could stamp ProductName="VFS for Git" onto an arbitrary binary and get LocalSystem code execution. - TOCTOU: The handler opens the installer with FileShare.Read (no SHARE_WRITE, no SHARE_DELETE) at the start of Run() and holds the handle across verify and Process.Start. On Windows this blocks any rename, delete, or write of the path, closing the window where an attacker could swap the file between verification and launch. Other notes: - GVFSJsonContext registers RunInstallerRequest and its Response for source-generated System.Text.Json serialization (NativeAOT-compatible). - RunInstallerHandler creates a fresh EventMetadata for each tracer call via CreateBaseMetadata(). JsonTracer mutates the passed-in metadata by adding the "Message" key, so a single shared instance would throw a duplicate-key exception on the second tracer call — and the throw was being caught and reported back as a false-positive "Upgrade failed" even though the installer had launched successfully. This matches the fresh-per-call pattern used by GetActiveRepoListHandler and RequestHandler. - Pipeline version bumped 2.0 -> 2.1 so the "service supports UAC-free upgrade" capability is detectable from the version number alone. Testing (manual end-to-end on Windows): - Unsigned dev installer without --allow-unsigned -> rejected ("Installer is not signed") - notepad.exe -> rejected by ProductName check - --allow-unsigned from non-admin shell (DEBUG build) -> rejected ("requires Administrator privileges") - Tampered Microsoft-signed installer (one byte flipped) -> rejected ("Authenticode hash does not match — file has been tampered with") - Real signed SetupGVFS.2.0.26147.6.exe with mounts active -> accepted, stages 23 files to PendingUpgrade\, mount status stays Ready - Unmount after staging -> PendingUpgradeHandler applies the staged upgrade within ~5s, version updates accordingly - 818 unit tests pass Assisted-by: Claude Opus 4.7 Signed-off-by: Tyrie Vella --- .azure-pipelines/release.yml | 2 +- GVFS/GVFS.Common/GVFSJsonContext.cs | 2 + GVFS/GVFS.Common/InstallerVerifier.cs | 403 ++++++++++++++++++ .../NamedPipes/NamedPipeMessages.cs | 35 ++ .../GVFS.Common/NamedPipes/NamedPipeServer.cs | 21 + GVFS/GVFS.Service/Handlers/RequestHandler.cs | 7 + .../Handlers/RunInstallerHandler.cs | 280 ++++++++++++ GVFS/GVFS/CommandLine/UpgradeVerb.cs | 111 ++++- 8 files changed, 844 insertions(+), 17 deletions(-) create mode 100644 GVFS/GVFS.Common/InstallerVerifier.cs create mode 100644 GVFS/GVFS.Service/Handlers/RunInstallerHandler.cs diff --git a/.azure-pipelines/release.yml b/.azure-pipelines/release.yml index 9433cb2f5..6c46d89ef 100644 --- a/.azure-pipelines/release.yml +++ b/.azure-pipelines/release.yml @@ -33,7 +33,7 @@ parameters: variables: - name: 'GVFSMajorAndMinorVersion' - value: '2.0' + value: '2.1' - name: 'GVFSRevision' value: $(Build.BuildNumber) - name: 'GVFSVersion' diff --git a/GVFS/GVFS.Common/GVFSJsonContext.cs b/GVFS/GVFS.Common/GVFSJsonContext.cs index 1a203ee8a..7fca1d2be 100644 --- a/GVFS/GVFS.Common/GVFSJsonContext.cs +++ b/GVFS/GVFS.Common/GVFSJsonContext.cs @@ -38,6 +38,8 @@ namespace GVFS.Common [JsonSerializable(typeof(NamedPipeMessages.EnableAndAttachProjFSRequest.Response), TypeInfoPropertyName = "EnableAndAttachProjFSResponse")] [JsonSerializable(typeof(NamedPipeMessages.GetActiveRepoListRequest))] [JsonSerializable(typeof(NamedPipeMessages.GetActiveRepoListRequest.Response), TypeInfoPropertyName = "GetActiveRepoListResponse")] + [JsonSerializable(typeof(NamedPipeMessages.RunInstallerRequest))] + [JsonSerializable(typeof(NamedPipeMessages.RunInstallerRequest.Response), TypeInfoPropertyName = "RunInstallerResponse")] [JsonSerializable(typeof(NamedPipeMessages.BaseResponse))] [JsonSerializable(typeof(TelemetryDaemonEventListener.PipeMessage))] [JsonSerializable(typeof(PrettyConsoleEventListener.ConsoleOutputPayload))] diff --git a/GVFS/GVFS.Common/InstallerVerifier.cs b/GVFS/GVFS.Common/InstallerVerifier.cs new file mode 100644 index 000000000..0bf59d1a9 --- /dev/null +++ b/GVFS/GVFS.Common/InstallerVerifier.cs @@ -0,0 +1,403 @@ +using GVFS.Common.Tracing; +using System; +using System.Diagnostics; +using System.IO; +using System.Runtime.InteropServices; +using System.Runtime.Versioning; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; + +namespace GVFS.Common +{ + /// + /// Verifies that an installer executable is a genuine VFS for Git installer + /// by checking its Authenticode signature and PE version info. + /// + public static class InstallerVerifier + { + public const string ExpectedProductName = "VFS for Git"; + public const string ExpectedSignerCommonName = "Microsoft Corporation"; + + /// + /// Verifies the installer at the given path. Returns true if the + /// installer passes all checks, false otherwise. + /// + /// + /// When true, skip Authenticode verification (for dev/test builds). + /// Product identity is still checked. + /// + public static bool TryVerifyInstaller( + ITracer tracer, + string installerPath, + bool allowUnsigned, + out string error) + { + ArgumentNullException.ThrowIfNull(tracer); + ArgumentNullException.ThrowIfNull(installerPath); + + if (!File.Exists(installerPath)) + { + error = $"Installer not found: {installerPath}"; + return false; + } + + // Always verify product identity, even when unsigned is allowed. + if (!TryVerifyProductIdentity(tracer, installerPath, out error)) + { + return false; + } + + if (allowUnsigned) + { + tracer.RelatedWarning( + $"{nameof(InstallerVerifier)}: Skipping Authenticode verification (--allow-unsigned)"); + error = null; + return true; + } + + if (!TryVerifyAuthenticodeSignature(tracer, installerPath, out error)) + { + return false; + } + + error = null; + return true; + } + + private static bool TryVerifyProductIdentity( + ITracer tracer, + string installerPath, + out string error) + { + FileVersionInfo versionInfo; + try + { + versionInfo = FileVersionInfo.GetVersionInfo(installerPath); + } + catch (Exception ex) + { + error = $"Failed to read version info from {installerPath}: {ex.Message}"; + tracer.RelatedError(error); + return false; + } + + string productName = versionInfo.ProductName?.Trim(); + if (!string.Equals(productName, ExpectedProductName, StringComparison.OrdinalIgnoreCase)) + { + error = $"Installer ProductName '{productName}' does not match expected '{ExpectedProductName}'"; + tracer.RelatedError($"{nameof(InstallerVerifier)}: {error}"); + return false; + } + + tracer.RelatedInfo( + $"{nameof(InstallerVerifier)}: Product identity verified — " + + $"ProductName='{productName}', FileVersion='{versionInfo.FileVersion?.Trim()}'"); + + error = null; + return true; + } + + private static bool TryVerifyAuthenticodeSignature( + ITracer tracer, + string installerPath, + out string error) + { + // Authenticode verification is Windows-only. Fail closed on + // other platforms — callers can opt in with --allow-unsigned + // when running on non-Windows for dev/test scenarios. + if (!OperatingSystem.IsWindows()) + { + error = "Authenticode verification is only supported on Windows"; + tracer.RelatedError($"{nameof(InstallerVerifier)}: {error}"); + return false; + } + + // Step 1: Verify the file's Authenticode signature with + // WinVerifyTrust and extract the leaf signer certificate from + // its provider state. This is the ONLY API that actually checks + // the signed digest against the file's contents — extracting + // the signer certificate by parsing the PE alone does NOT + // detect a tampered file with an intact signature blob. + if (!TryWinVerifyTrustAndGetSignerCert( + installerPath, + out byte[] signerCertBytes, + out string trustError)) + { + error = trustError; + tracer.RelatedError($"{nameof(InstallerVerifier)}: {error}"); + return false; + } + + // Step 2: After WinVerifyTrust has confirmed the file's + // signature is intact, inspect the signer certificate to + // verify it really is Microsoft (and not just any valid + // code-signing cert). Use the modern X509CertificateLoader + // to materialize the cert from the DER bytes we copied out of + // the WinTrust provider state — X509Certificate.CreateFromSignedFile + // is obsolete (SYSLIB0057) and its replacement does not parse + // PE signature blocks. + X509Certificate2 certificate; + try + { + certificate = X509CertificateLoader.LoadCertificate(signerCertBytes); + } + catch (CryptographicException ex) + { + error = $"Failed to parse signer certificate: {ex.Message}"; + tracer.RelatedError($"{nameof(InstallerVerifier)}: {error}"); + return false; + } + + using (certificate) + { + // Exact CN match — GetNameInfo(SimpleName) parses the + // certificate's Subject DN and returns the raw CN value, + // avoiding substring-collision attacks such as + // CN="Microsoft Corporation Ltd" or DNs that put the + // attacker's name in the CN field with "Microsoft + // Corporation" appearing elsewhere. + string signerCommonName = certificate.GetNameInfo(X509NameType.SimpleName, forIssuer: false); + if (!string.Equals(signerCommonName, ExpectedSignerCommonName, StringComparison.Ordinal)) + { + error = $"Installer signed by unexpected publisher: '{signerCommonName}' (Subject: {certificate.Subject})"; + tracer.RelatedError($"{nameof(InstallerVerifier)}: {error}"); + return false; + } + + tracer.RelatedInfo( + $"{nameof(InstallerVerifier)}: Authenticode signature verified — " + + $"Signer CN='{signerCommonName}', Thumbprint={certificate.Thumbprint}"); + } + + error = null; + return true; + } + + // WinVerifyTrust interop — verifies Authenticode hash + signature + // chain in one call. This is the same code path Windows uses for + // SmartScreen / SRP / WDAC, and is the ONLY supported way to + // verify Authenticode on Windows. See + // https://learn.microsoft.com/en-us/windows/win32/api/wintrust/nf-wintrust-winverifytrust. + + private static readonly Guid WINTRUST_ACTION_GENERIC_VERIFY_V2 = + new Guid("00AAC56B-CD44-11d0-8CC2-00C04FC295EE"); + + private const uint WTD_UI_NONE = 2; + private const uint WTD_REVOKE_WHOLECHAIN = 1; + private const uint WTD_CHOICE_FILE = 1; + private const uint WTD_STATEACTION_VERIFY = 1; + private const uint WTD_STATEACTION_CLOSE = 2; + + // S_OK and the few error codes we want to surface by name. + private const int S_OK = 0; + private const int TRUST_E_NOSIGNATURE = unchecked((int)0x800B0100); + private const int TRUST_E_BAD_DIGEST = unchecked((int)0x80096010); + private const int TRUST_E_PROVIDER_UNKNOWN = unchecked((int)0x800B0001); + private const int CRYPT_E_SECURITY_SETTINGS = unchecked((int)0x80092026); + + [StructLayout(LayoutKind.Sequential)] + private struct WINTRUST_FILE_INFO + { + public uint cbStruct; + [MarshalAs(UnmanagedType.LPWStr)] public string pcwszFilePath; + public IntPtr hFile; + public IntPtr pgKnownSubject; + } + + [StructLayout(LayoutKind.Sequential)] + private struct WINTRUST_DATA + { + public uint cbStruct; + public IntPtr pPolicyCallbackData; + public IntPtr pSIPClientData; + public uint dwUIChoice; + public uint fdwRevocationChecks; + public uint dwUnionChoice; + public IntPtr pFile; + public uint dwStateAction; + public IntPtr hWVTStateData; + public IntPtr pwszURLReference; + public uint dwProvFlags; + public uint dwUIContext; + public IntPtr pSignatureSettings; + } + + [DllImport("wintrust.dll", CharSet = CharSet.Unicode, ExactSpelling = true, SetLastError = false)] + [SupportedOSPlatform("windows")] + private static extern int WinVerifyTrust(IntPtr hWnd, ref Guid pgActionID, ref WINTRUST_DATA pWVTData); + + // WTHelper interop — used to extract the signer certificate from a + // WinVerifyTrust state after a successful verification. Avoids the + // obsolete X509Certificate.CreateFromSignedFile (SYSLIB0057), and + // reuses the verification we already did rather than reparsing the + // PE signature block from scratch. + + [DllImport("wintrust.dll", ExactSpelling = true, SetLastError = false)] + [SupportedOSPlatform("windows")] + private static extern IntPtr WTHelperProvDataFromStateData(IntPtr hStateData); + + [DllImport("wintrust.dll", ExactSpelling = true, SetLastError = false)] + [SupportedOSPlatform("windows")] + private static extern IntPtr WTHelperGetProvSignerFromChain( + IntPtr pProvData, + uint idxSigner, + [MarshalAs(UnmanagedType.Bool)] bool fCounterSigner, + uint idxCounterSigner); + + // Partial layouts — we only marshal the fields we need. Sequential + // layout means the runtime computes offsets/padding correctly for + // the leading fields; we never read past the declared end, so the + // trailing fields can be omitted without risk. + + [StructLayout(LayoutKind.Sequential)] + private struct CRYPT_PROVIDER_SGNR + { + public uint cbStruct; + public System.Runtime.InteropServices.ComTypes.FILETIME sftVerifyAsOf; + public uint csCertChain; + public IntPtr pasCertChain; + } + + [StructLayout(LayoutKind.Sequential)] + private struct CRYPT_PROVIDER_CERT + { + public uint cbStruct; + public IntPtr pCert; + } + + [StructLayout(LayoutKind.Sequential)] + private struct CERT_CONTEXT + { + public uint dwCertEncodingType; + public IntPtr pbCertEncoded; + public uint cbCertEncoded; + public IntPtr pCertInfo; + public IntPtr hCertStore; + } + + [SupportedOSPlatform("windows")] + private static bool TryWinVerifyTrustAndGetSignerCert( + string filePath, + out byte[] signerCertBytes, + out string error) + { + signerCertBytes = null; + + WINTRUST_FILE_INFO fileInfo = new WINTRUST_FILE_INFO + { + cbStruct = (uint)Marshal.SizeOf(), + pcwszFilePath = filePath, + hFile = IntPtr.Zero, + pgKnownSubject = IntPtr.Zero, + }; + + IntPtr fileInfoPtr = Marshal.AllocHGlobal(Marshal.SizeOf()); + try + { + Marshal.StructureToPtr(fileInfo, fileInfoPtr, fDeleteOld: false); + + WINTRUST_DATA data = new WINTRUST_DATA + { + cbStruct = (uint)Marshal.SizeOf(), + pPolicyCallbackData = IntPtr.Zero, + pSIPClientData = IntPtr.Zero, + dwUIChoice = WTD_UI_NONE, + fdwRevocationChecks = WTD_REVOKE_WHOLECHAIN, + dwUnionChoice = WTD_CHOICE_FILE, + pFile = fileInfoPtr, + dwStateAction = WTD_STATEACTION_VERIFY, + hWVTStateData = IntPtr.Zero, + pwszURLReference = IntPtr.Zero, + dwProvFlags = 0, + dwUIContext = 0, + pSignatureSettings = IntPtr.Zero, + }; + + Guid action = WINTRUST_ACTION_GENERIC_VERIFY_V2; + int verifyResult = WinVerifyTrust(IntPtr.Zero, ref action, ref data); + + string extractError = null; + if (verifyResult == S_OK) + { + // Extract the leaf signer cert from WinTrust's provider + // state BEFORE closing the state (which would free the + // underlying CERT_CONTEXT). + extractError = TryExtractSignerCert(data.hWVTStateData, out signerCertBytes); + } + + // Always close the state to release wintrust resources. + data.dwStateAction = WTD_STATEACTION_CLOSE; + WinVerifyTrust(IntPtr.Zero, ref action, ref data); + + if (verifyResult == S_OK) + { + if (signerCertBytes == null) + { + error = extractError ?? "Failed to extract signer certificate from verified file"; + return false; + } + error = null; + return true; + } + + error = verifyResult switch + { + TRUST_E_NOSIGNATURE => "Installer is not signed", + TRUST_E_BAD_DIGEST => "Installer Authenticode hash does not match — file has been tampered with", + TRUST_E_PROVIDER_UNKNOWN => "Authenticode trust provider is not available", + CRYPT_E_SECURITY_SETTINGS => "Authenticode verification blocked by local security policy", + _ => $"Authenticode verification failed (HRESULT 0x{verifyResult:X8})", + }; + return false; + } + finally + { + Marshal.FreeHGlobal(fileInfoPtr); + } + } + + [SupportedOSPlatform("windows")] + private static string TryExtractSignerCert(IntPtr hWVTStateData, out byte[] certBytes) + { + certBytes = null; + + IntPtr provData = WTHelperProvDataFromStateData(hWVTStateData); + if (provData == IntPtr.Zero) + { + return "WTHelperProvDataFromStateData returned null"; + } + + IntPtr signerPtr = WTHelperGetProvSignerFromChain(provData, idxSigner: 0, fCounterSigner: false, idxCounterSigner: 0); + if (signerPtr == IntPtr.Zero) + { + return "WTHelperGetProvSignerFromChain returned null"; + } + + CRYPT_PROVIDER_SGNR signer = Marshal.PtrToStructure(signerPtr); + if (signer.csCertChain == 0 || signer.pasCertChain == IntPtr.Zero) + { + return "Signer has no certificate chain"; + } + + // pasCertChain[0] is the leaf signer cert by convention. + CRYPT_PROVIDER_CERT providerCert = Marshal.PtrToStructure(signer.pasCertChain); + if (providerCert.pCert == IntPtr.Zero) + { + return "Signer certificate context is null"; + } + + CERT_CONTEXT certContext = Marshal.PtrToStructure(providerCert.pCert); + if (certContext.pbCertEncoded == IntPtr.Zero || certContext.cbCertEncoded == 0) + { + return "Signer certificate has no encoded data"; + } + + // Copy the DER bytes out — they live in the WinTrust state which + // is about to be freed. + byte[] buffer = new byte[certContext.cbCertEncoded]; + Marshal.Copy(certContext.pbCertEncoded, buffer, 0, buffer.Length); + certBytes = buffer; + return null; + } + } +} diff --git a/GVFS/GVFS.Common/NamedPipes/NamedPipeMessages.cs b/GVFS/GVFS.Common/NamedPipes/NamedPipeMessages.cs index d42c84873..6ca984d5b 100644 --- a/GVFS/GVFS.Common/NamedPipes/NamedPipeMessages.cs +++ b/GVFS/GVFS.Common/NamedPipes/NamedPipeMessages.cs @@ -452,6 +452,41 @@ public static Response FromMessage(Message message) } } + public class RunInstallerRequest + { + public const string Header = nameof(RunInstallerRequest); + + /// + /// Full path to the installer executable. + /// + public string InstallerPath { get; set; } + + /// + /// When true, skip Authenticode signature verification. + /// Product identity (ProductName) is still checked. + /// Intended for development/test builds only. + /// + public bool AllowUnsigned { get; set; } + + public static RunInstallerRequest FromMessage(Message message) + { + return GVFSJsonOptions.Deserialize(message.Body); + } + + public Message ToMessage() + { + return new Message(Header, GVFSJsonOptions.Serialize(this)); + } + + public class Response : BaseResponse + { + public static Response FromMessage(Message message) + { + return GVFSJsonOptions.Deserialize(message.Body); + } + } + } + public class BaseResponse { public const string Header = nameof(TRequest) + ResponseSuffix; diff --git a/GVFS/GVFS.Common/NamedPipes/NamedPipeServer.cs b/GVFS/GVFS.Common/NamedPipes/NamedPipeServer.cs index 40d2507be..54e1fed82 100644 --- a/GVFS/GVFS.Common/NamedPipes/NamedPipeServer.cs +++ b/GVFS/GVFS.Common/NamedPipes/NamedPipeServer.cs @@ -203,6 +203,27 @@ public bool IsConnected get { return !this.isStopping() && this.serverStream.IsConnected; } } + /// + /// Impersonates the connected client for the duration of + /// . Inside the action, + /// + /// returns the client's identity rather than the service's. + /// Returns false if impersonation is not supported on this + /// platform (only Windows named pipes carry caller identity). + /// + public bool TryRunAsClient(Action action) + { + ArgumentNullException.ThrowIfNull(action); + + if (!OperatingSystem.IsWindows()) + { + return false; + } + + this.serverStream.RunAsClient(() => action()); + return true; + } + public NamedPipeMessages.Message ReadMessage() { return NamedPipeMessages.Message.FromString(this.ReadRequest()); diff --git a/GVFS/GVFS.Service/Handlers/RequestHandler.cs b/GVFS/GVFS.Service/Handlers/RequestHandler.cs index 72e6e9e08..8a444242f 100644 --- a/GVFS/GVFS.Service/Handlers/RequestHandler.cs +++ b/GVFS/GVFS.Service/Handlers/RequestHandler.cs @@ -112,6 +112,13 @@ protected virtual void HandleMessage( this.TrySendResponse(tracer, response.ToMessage().ToString(), connection); break; + case NamedPipeMessages.RunInstallerRequest.Header: + this.requestDescription = "run installer"; + NamedPipeMessages.RunInstallerRequest installerRequest = NamedPipeMessages.RunInstallerRequest.FromMessage(message); + RunInstallerHandler installerHandler = new RunInstallerHandler(tracer, connection, installerRequest); + installerHandler.Run(); + break; + default: this.requestDescription = UnknownRequestDescription; EventMetadata metadata = new EventMetadata(); diff --git a/GVFS/GVFS.Service/Handlers/RunInstallerHandler.cs b/GVFS/GVFS.Service/Handlers/RunInstallerHandler.cs new file mode 100644 index 000000000..cb647572e --- /dev/null +++ b/GVFS/GVFS.Service/Handlers/RunInstallerHandler.cs @@ -0,0 +1,280 @@ +using GVFS.Common; +using GVFS.Common.NamedPipes; +using GVFS.Common.Tracing; +using System; +using System.Diagnostics; +using System.IO; +using System.Runtime.Versioning; +using System.Security.Principal; + +namespace GVFS.Service.Handlers +{ + public class RunInstallerHandler + { + private const string InstallerArgs = "/VERYSILENT /SUPPRESSMSGBOXES /NORESTART /STAGEIFMOUNTED=true /LOG=\"{0}\""; + + private readonly ITracer tracer; + private readonly NamedPipeServer.Connection connection; + private readonly NamedPipeMessages.RunInstallerRequest request; + + public RunInstallerHandler( + ITracer tracer, + NamedPipeServer.Connection connection, + NamedPipeMessages.RunInstallerRequest request) + { + this.tracer = tracer; + this.connection = connection; + this.request = request; + } + + public void Run() + { + NamedPipeMessages.RunInstallerRequest.Response response = + new NamedPipeMessages.RunInstallerRequest.Response(); + + try + { + string installerPath = this.request.InstallerPath; + if (string.IsNullOrWhiteSpace(installerPath)) + { + response.State = NamedPipeMessages.CompletionState.Failure; + response.ErrorMessage = "Installer path is required"; + this.tracer.RelatedError(this.CreateBaseMetadata(), response.ErrorMessage); + return; + } + + // Resolve to full path to prevent path traversal. + installerPath = Path.GetFullPath(installerPath); + + // --allow-unsigned is a debug-build-only escape hatch and + // is rejected outright in release builds. Defense-in-depth + // against a malicious client crafting a raw pipe request + // bypassing the CLI's debug-only flag registration. +#if !DEBUG + if (this.request.AllowUnsigned) + { + response.State = NamedPipeMessages.CompletionState.Failure; + response.ErrorMessage = + "--allow-unsigned is not supported in release builds. " + + "Use a signed installer."; + this.tracer.RelatedError(this.CreateBaseMetadata(), response.ErrorMessage); + return; + } +#else + // Debug builds still require the caller to be an + // Administrator when using --allow-unsigned: an unsigned + // installer is trivially attacker-controlled (any user can + // stamp ProductName="VFS for Git" onto a binary), and the + // pipe is open to BUILTIN\Users. Without this check, any + // non-admin user on a debug-build dev machine could get + // LocalSystem code execution through the service. + if (this.request.AllowUnsigned && !this.IsCallerAdministrator(out string identityError)) + { + response.State = NamedPipeMessages.CompletionState.Failure; + response.ErrorMessage = + "--allow-unsigned upgrades require Administrator privileges. " + + "Either use a signed installer or run 'gvfs upgrade --allow-unsigned' " + + "from an elevated shell."; + if (!string.IsNullOrEmpty(identityError)) + { + response.ErrorMessage += $" ({identityError})"; + } + this.tracer.RelatedError(this.CreateBaseMetadata(), response.ErrorMessage); + return; + } +#endif + + // Hold a deny-write/delete handle on the installer for the + // lifetime of verify+launch to prevent a TOCTOU swap by a + // non-admin caller (FileShare.Read allows readers but + // blocks any write, delete, or rename of the path). + FileStream installerLock; + try + { + installerLock = new FileStream( + installerPath, + FileMode.Open, + FileAccess.Read, + FileShare.Read); + } + catch (Exception ex) when (ex is FileNotFoundException + || ex is DirectoryNotFoundException + || ex is UnauthorizedAccessException + || ex is IOException) + { + response.State = NamedPipeMessages.CompletionState.Failure; + response.ErrorMessage = $"Failed to open installer for verification: {ex.Message}"; + this.tracer.RelatedError(this.CreateBaseMetadata(), response.ErrorMessage); + return; + } + + using (installerLock) + { + if (!InstallerVerifier.TryVerifyInstaller( + this.tracer, + installerPath, + this.request.AllowUnsigned, + out string verifyError)) + { + response.State = NamedPipeMessages.CompletionState.Failure; + response.ErrorMessage = verifyError; + return; + } + + string logPath = Path.Combine( + Configuration.AssemblyPath, + "ProgramData", + "upgrade-install.log"); + + this.tracer.RelatedInfo( + this.CreateBaseMetadata(), + $"{nameof(RunInstallerHandler)}: Verification passed, launching installer (log: {logPath})"); + + int exitCode = LaunchInstallerAndWait(installerPath, logPath); + + EventMetadata launchMetadata = this.CreateBaseMetadata(); + launchMetadata.Add("InstallerExitCode", exitCode); + + if (exitCode == 0) + { + response.State = NamedPipeMessages.CompletionState.Success; + this.tracer.RelatedInfo( + launchMetadata, + $"{nameof(RunInstallerHandler)}: Installer launched successfully"); + } + else + { + response.State = NamedPipeMessages.CompletionState.Failure; + response.ErrorMessage = $"Installer failed to launch (error {exitCode})"; + this.tracer.RelatedError(launchMetadata, response.ErrorMessage); + } + } + } + catch (Exception ex) + { + response.State = NamedPipeMessages.CompletionState.Failure; + response.ErrorMessage = $"Failed to run installer: {ex.Message}"; + EventMetadata exceptionMetadata = this.CreateBaseMetadata(); + exceptionMetadata.Add("Exception", ex.ToString()); + this.tracer.RelatedError( + exceptionMetadata, + $"{nameof(RunInstallerHandler)}: {response.ErrorMessage}"); + } + finally + { + this.connection.TrySendResponse(response.ToMessage().ToString()); + } + } + + /// + /// Returns a fresh populated with the + /// request's identifying fields. A new instance must be used for each + /// tracer call because mutates the metadata + /// (adds the "Message" key); reusing the same instance across calls + /// triggers a duplicate-key exception. + /// + private EventMetadata CreateBaseMetadata() + { + EventMetadata metadata = new EventMetadata(); + metadata.Add("InstallerPath", this.request?.InstallerPath); + metadata.Add("AllowUnsigned", this.request?.AllowUnsigned ?? false); + return metadata; + } + + /// + /// Impersonates the pipe client and tests whether the caller is in + /// the local Administrators group. Returns false (and sets + /// ) if impersonation fails or the caller is + /// a regular user. Required because the service runs as LocalSystem + /// and the pipe is accessible to all interactive users — without + /// this check, any non-admin user could request privileged + /// operations. + /// + private bool IsCallerAdministrator(out string error) + { + // Named-pipe client impersonation is a Windows-only capability. + // RunInstallerHandler runs in GVFS.Service which is Windows-only + // in practice, but guard anyway so we fail closed. + if (!OperatingSystem.IsWindows()) + { + error = "Client identity check is not supported on this platform"; + return false; + } + + bool isAdmin = false; + string innerError = null; + + bool impersonated = this.connection.TryRunAsClient(() => + { + try + { + isAdmin = IsCurrentUserAdministrator(); + } + catch (Exception ex) + { + innerError = ex.Message; + } + }); + + if (!impersonated) + { + error = "Failed to impersonate pipe client"; + return false; + } + + if (innerError != null) + { + error = innerError; + return false; + } + + error = null; + return isAdmin; + } + + [SupportedOSPlatform("windows")] + private static bool IsCurrentUserAdministrator() + { + using (WindowsIdentity identity = WindowsIdentity.GetCurrent()) + { + WindowsPrincipal principal = new WindowsPrincipal(identity); + return principal.IsInRole(WindowsBuiltInRole.Administrator); + } + } + + /// + /// Launches the installer as a detached process. The installer will + /// stop GVFS.Service as part of its upgrade flow, so we must not wait + /// for it to exit (that would deadlock — parent waiting on child that + /// kills parent). Returns 0 if the process started, or -1 on failure. + /// + private static int LaunchInstallerAndWait(string installerPath, string logPath) + { + string args = string.Format(InstallerArgs, logPath); + try + { + Process installerProcess = new Process(); + installerProcess.StartInfo = new ProcessStartInfo + { + FileName = installerPath, + Arguments = args, + UseShellExecute = false, + CreateNoWindow = true, + }; + + if (!installerProcess.Start()) + { + return -1; + } + + // Do NOT call WaitForExit(). The installer will stop + // GVFS.Service (our parent), so we'd deadlock. + return 0; + } + catch (Exception) + { + return -1; + } + } + } +} diff --git a/GVFS/GVFS/CommandLine/UpgradeVerb.cs b/GVFS/GVFS/CommandLine/UpgradeVerb.cs index 70f135d96..78ad5815f 100644 --- a/GVFS/GVFS/CommandLine/UpgradeVerb.cs +++ b/GVFS/GVFS/CommandLine/UpgradeVerb.cs @@ -1,5 +1,7 @@ using GVFS.Common; +using GVFS.Common.NamedPipes; using System; +using System.IO; namespace GVFS.CommandLine { @@ -12,24 +14,25 @@ public UpgradeVerb() this.Output = Console.Out; } - public bool Confirmed { get; set; } + public string InstallerPath { get; set; } - public bool DryRun { get; set; } - - public bool NoVerify { get; set; } + public bool AllowUnsigned { get; set; } public static System.CommandLine.Command CreateCommand() { - System.CommandLine.Command cmd = new System.CommandLine.Command("upgrade", "Checks for new GVFS release, downloads and installs it when available."); - - System.CommandLine.Option confirmOption = new System.CommandLine.Option("--confirm") { Description = "Pass in this flag to actually install the newest release" }; - cmd.Add(confirmOption); + System.CommandLine.Command cmd = new System.CommandLine.Command("upgrade", "Upgrade VFS for Git by running an installer through the GVFS service (no UAC required)."); - System.CommandLine.Option dryRunOption = new System.CommandLine.Option("--dry-run") { Description = "Display progress and errors, but don't install GVFS" }; - cmd.Add(dryRunOption); + System.CommandLine.Argument installerPathArg = new System.CommandLine.Argument("installer-path") + { + Description = "Path to the SetupGVFS.*.exe installer", + }; + cmd.Add(installerPathArg); - System.CommandLine.Option noVerifyOption = new System.CommandLine.Option("--no-verify") { Description = "Do not verify NuGet packages after downloading them. Some platforms do not support NuGet verification." }; - cmd.Add(noVerifyOption); +#if DEBUG + System.CommandLine.Option allowUnsignedOption = new System.CommandLine.Option( + "--allow-unsigned") { Description = "Skip Authenticode signature verification (debug builds only, requires admin)" }; + cmd.Add(allowUnsignedOption); +#endif System.CommandLine.Option internalOption = GVFSVerb.CreateInternalParametersOption(); cmd.Add(internalOption); @@ -37,9 +40,10 @@ public static System.CommandLine.Command CreateCommand() GVFSVerb.SetActionForNoEnlistment(cmd, internalOption, (verb, result) => { - verb.Confirmed = result.GetValue(confirmOption); - verb.DryRun = result.GetValue(dryRunOption); - verb.NoVerify = result.GetValue(noVerifyOption); + verb.InstallerPath = result.GetValue(installerPathArg); +#if DEBUG + verb.AllowUnsigned = result.GetValue(allowUnsignedOption); +#endif }); return cmd; @@ -52,7 +56,82 @@ protected override string VerbName public override void Execute() { - Console.Error.WriteLine("'gvfs upgrade' is no longer supported. Visit https://github.com/microsoft/vfsforgit for the latest install/upgrade instructions."); + if (string.IsNullOrWhiteSpace(this.InstallerPath)) + { + this.ReportErrorAndExit("Installer path is required. Usage: gvfs upgrade "); + return; + } + + string fullPath = Path.GetFullPath(this.InstallerPath); + if (!File.Exists(fullPath)) + { + this.ReportErrorAndExit($"Installer not found: {fullPath}"); + return; + } + + this.Output.WriteLine($"Requesting upgrade via GVFS service..."); + this.Output.WriteLine($"Installer: {fullPath}"); + + if (this.AllowUnsigned) + { + this.Output.WriteLine("WARNING: Authenticode signature verification is disabled (--allow-unsigned)"); + } + + NamedPipeMessages.RunInstallerRequest request = new NamedPipeMessages.RunInstallerRequest + { + InstallerPath = fullPath, + AllowUnsigned = this.AllowUnsigned, + }; + + using (NamedPipeClient client = new NamedPipeClient(this.ServicePipeName)) + { + if (!client.Connect()) + { + this.ReportErrorAndExit( + "Unable to connect to GVFS service. Is GVFS.Service running?"); + return; + } + + try + { + client.SendRequest(request.ToMessage()); + NamedPipeMessages.Message rawResponse = client.ReadResponse(); + + // Old GVFS.Service (pre-2.1) doesn't know about + // RunInstallerRequest and returns the literal + // "UnknownRequest" sentinel with no body. Detect that + // case explicitly so the user gets a clear "service is + // too old" message instead of a JSON deserialization + // stack trace. + if (string.Equals(rawResponse.Header, NamedPipeMessages.UnknownRequest, StringComparison.Ordinal)) + { + this.ReportErrorAndExit( + "The installed GVFS service does not support 'gvfs upgrade'. " + + "This feature requires GVFS.Service 2.1 or later — please install a " + + "newer GVFS using the standard installer first, then retry."); + return; + } + + NamedPipeMessages.RunInstallerRequest.Response response = + NamedPipeMessages.RunInstallerRequest.Response.FromMessage(rawResponse); + + if (response.State == NamedPipeMessages.CompletionState.Success) + { + this.Output.WriteLine("Upgrade started. The installer is running in the background."); + this.Output.WriteLine("GVFS service will restart automatically. Check 'gvfs version' after a few seconds."); + } + else + { + this.ReportErrorAndExit( + $"Upgrade failed: {response.ErrorMessage}"); + } + } + catch (BrokenPipeException ex) + { + this.ReportErrorAndExit( + $"Lost connection to GVFS service during upgrade: {ex.Message}"); + } + } } } }