From 2b895e1527896b205d00783851fa700d8688f157 Mon Sep 17 00:00:00 2001 From: waldekmastykarz Date: Sat, 31 Oct 2026 10:04:46 +0100 Subject: [PATCH 01/12] Trust the Dev Proxy root certificate on Linux via 'devproxy cert ensure' Adds Linux support to 'devproxy cert ensure' and 'devproxy cert remove': installs the root certificate into the system store on Debian/Ubuntu (update-ca-certificates), Fedora/RHEL and Arch (update-ca-trust) using sudo, and into the user's NSS database (~/.pki/nssdb) when certutil is available. Trust stays opt-in on Linux; the first-run warning now points to 'devproxy cert ensure'. Unknown distros get manual instructions. Closes #601 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Proxy/IRootCertificateTrust.cs | 2 +- .../Proxy/RootTrustPolicy.cs | 6 +- DevProxy.Tests/LinuxCertificateHelperTests.cs | 133 +++++++ DevProxy/Proxy/LinuxCertificateHelper.cs | 345 ++++++++++++++++++ DevProxy/Proxy/RootCertificateTrust.cs | 23 +- README.md | 2 +- skills/dev-proxy/references/installation.md | 15 +- 7 files changed, 515 insertions(+), 11 deletions(-) create mode 100644 DevProxy.Tests/LinuxCertificateHelperTests.cs create mode 100644 DevProxy/Proxy/LinuxCertificateHelper.cs diff --git a/DevProxy.Abstractions/Proxy/IRootCertificateTrust.cs b/DevProxy.Abstractions/Proxy/IRootCertificateTrust.cs index 5bae4edbf..81e94f91f 100644 --- a/DevProxy.Abstractions/Proxy/IRootCertificateTrust.cs +++ b/DevProxy.Abstractions/Proxy/IRootCertificateTrust.cs @@ -14,7 +14,7 @@ namespace DevProxy.Abstractions.Proxy; /// This is the engine-host boundary for certificate trust: a proxy engine (e.g. the /// Kestrel engine) owns minting/persisting its root but cannot reference the host's /// platform trust helpers, so it calls this abstraction. The host supplies the -/// implementation (mac keychain, Windows root store, first-run prompt). Implementations +/// implementation (mac keychain, Windows root store, Linux system store, first-run prompt). Implementations /// are expected to be idempotent and to honor the user's trust/first-run configuration. /// /// diff --git a/DevProxy.Abstractions/Proxy/RootTrustPolicy.cs b/DevProxy.Abstractions/Proxy/RootTrustPolicy.cs index 826bd524b..c606d8a93 100644 --- a/DevProxy.Abstractions/Proxy/RootTrustPolicy.cs +++ b/DevProxy.Abstractions/Proxy/RootTrustPolicy.cs @@ -16,7 +16,9 @@ public enum RootTrustAction /// Install the root into the Windows CurrentUser root store. TrustWindowsStore, - /// Trust can't be automated on Linux; tell the user to trust manually. + /// + /// Linux trust needs sudo, so it's opt-in via cert ensure; tell the user to run it. + /// ManualLinux, } @@ -24,7 +26,7 @@ public enum RootTrustAction /// Pure decision table for OS-trust installation, factored out of the platform I/O so it /// can be exhaustively unit-tested. Mirrors today's behavior: Windows installs into the /// user root store whenever cert install is enabled; macOS trusts via the keychain only on -/// first run (and only if the user doesn't decline); Linux is manual. +/// first run (and only if the user doesn't decline); Linux is opt-in via cert ensure. /// /// /// installCert == false ─────────────────────► Skip diff --git a/DevProxy.Tests/LinuxCertificateHelperTests.cs b/DevProxy.Tests/LinuxCertificateHelperTests.cs new file mode 100644 index 000000000..fe09eb6ab --- /dev/null +++ b/DevProxy.Tests/LinuxCertificateHelperTests.cs @@ -0,0 +1,133 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using DevProxy.Proxy; +using Xunit; + +namespace DevProxy.Tests; + +public class LinuxCertificateHelperTests +{ + private static Func Executables(params string[] names) => + name => names.Contains(name) ? $"/usr/sbin/{name}" : null; + + private static Func Directories(params string[] dirs) => dirs.Contains; + + [Fact] + public void DetectTrustStore_Debian() + { + var store = LinuxCertificateHelper.DetectTrustStore( + Directories("/usr/local/share/ca-certificates"), + Executables("update-ca-certificates")); + + Assert.NotNull(store); + Assert.Equal("/usr/local/share/ca-certificates/devproxy-ca.crt", store.CertificatePath); + Assert.Equal("/usr/sbin/update-ca-certificates", store.UpdateCommand); + Assert.Empty(store.UpdateArguments); + Assert.Equal(["--fresh"], store.RemoveUpdateArguments); + } + + [Fact] + public void DetectTrustStore_Fedora() + { + var store = LinuxCertificateHelper.DetectTrustStore( + Directories("/etc/pki/ca-trust/source/anchors", "/etc/ca-certificates/trust-source/anchors"), + Executables("update-ca-trust")); + + Assert.NotNull(store); + Assert.Equal("/etc/pki/ca-trust/source/anchors/devproxy-ca.crt", store.CertificatePath); + Assert.Equal("/usr/sbin/update-ca-trust", store.UpdateCommand); + Assert.Equal(["extract"], store.UpdateArguments); + } + + [Fact] + public void DetectTrustStore_Arch() + { + var store = LinuxCertificateHelper.DetectTrustStore( + Directories("/etc/ca-certificates/trust-source/anchors"), + Executables("update-ca-trust")); + + Assert.NotNull(store); + Assert.Equal("/etc/ca-certificates/trust-source/anchors/devproxy-ca.crt", store.CertificatePath); + } + + [Fact] + public void DetectTrustStore_PrefersDebianWhenBothToolsExist() + { + var store = LinuxCertificateHelper.DetectTrustStore( + Directories("/usr/local/share/ca-certificates", "/etc/pki/ca-trust/source/anchors"), + Executables("update-ca-certificates", "update-ca-trust")); + + Assert.NotNull(store); + Assert.Equal("/usr/local/share/ca-certificates", store.AnchorDirectory); + } + + [Fact] + public void DetectTrustStore_UnknownDistro_ReturnsNull() + { + Assert.Null(LinuxCertificateHelper.DetectTrustStore(Directories(), Executables())); + Assert.Null(LinuxCertificateHelper.DetectTrustStore( + Directories("/usr/local/share/ca-certificates"), Executables())); + Assert.Null(LinuxCertificateHelper.DetectTrustStore( + Directories(), Executables("update-ca-trust", "update-ca-certificates"))); + } + + [Fact] + public void Elevate_Root_RunsDirectly() + { + var command = new LinuxCommand("update-ca-certificates", []); + + var elevated = LinuxCertificateHelper.Elevate(command, isRoot: true, isInteractive: false); + + Assert.Same(command, elevated); + } + + [Fact] + public void Elevate_Interactive_UsesSudo() + { + var elevated = LinuxCertificateHelper.Elevate( + new("install", ["-m", "0644", "a", "b"]), isRoot: false, isInteractive: true); + + Assert.Equal("sudo", elevated.FileName); + Assert.Equal(["--", "install", "-m", "0644", "a", "b"], elevated.Arguments); + } + + [Fact] + public void Elevate_NonInteractive_UsesSudoWithoutPrompt() + { + var elevated = LinuxCertificateHelper.Elevate( + new("rm", ["-f", "x"]), isRoot: false, isInteractive: false); + + Assert.Equal("sudo", elevated.FileName); + Assert.Equal(["-n", "--", "rm", "-f", "x"], elevated.Arguments); + } + + [Fact] + public void GetTrustCommands_InstallsThenRefreshes() + { + var store = new LinuxTrustStore("Fedora/RHEL", "/etc/pki/ca-trust/source/anchors", "/usr/bin/update-ca-trust", ["extract"], ["extract"]); + + var commands = LinuxCertificateHelper.GetTrustCommands(store, "/tmp/cert.crt"); + + Assert.Equal(2, commands.Count); + Assert.Equal("install", commands[0].FileName); + Assert.Equal(["-D", "-m", "0644", "/tmp/cert.crt", "/etc/pki/ca-trust/source/anchors/devproxy-ca.crt"], commands[0].Arguments); + Assert.Equal("/usr/bin/update-ca-trust", commands[1].FileName); + Assert.Equal(["extract"], commands[1].Arguments); + } + + [Fact] + public void GetUntrustCommands_RemovesThenRefreshes() + { + var store = new LinuxTrustStore("Debian/Ubuntu", "/usr/local/share/ca-certificates", "/usr/sbin/update-ca-certificates", [], ["--fresh"]); + + var commands = LinuxCertificateHelper.GetUntrustCommands(store); + + Assert.Equal(2, commands.Count); + Assert.Equal("rm", commands[0].FileName); + Assert.Equal(["-f", "/usr/local/share/ca-certificates/devproxy-ca.crt"], commands[0].Arguments); + Assert.Equal("/usr/sbin/update-ca-certificates", commands[1].FileName); + Assert.Equal(["--fresh"], commands[1].Arguments); + } +} diff --git a/DevProxy/Proxy/LinuxCertificateHelper.cs b/DevProxy/Proxy/LinuxCertificateHelper.cs new file mode 100644 index 000000000..ca4bbb5f6 --- /dev/null +++ b/DevProxy/Proxy/LinuxCertificateHelper.cs @@ -0,0 +1,345 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Diagnostics; +using System.Security.Cryptography.X509Certificates; + +namespace DevProxy.Proxy; + +/// A distro-specific system CA trust store: where anchors go and how to refresh it. +internal sealed record LinuxTrustStore( + string Name, + string AnchorDirectory, + string UpdateCommand, + IReadOnlyList UpdateArguments, + IReadOnlyList RemoveUpdateArguments) +{ + public string CertificatePath => Path.Combine(AnchorDirectory, LinuxCertificateHelper.CertificateFileName); +} + +/// A process invocation (file name + argument list, no shell). +internal sealed record LinuxCommand(string FileName, IReadOnlyList Arguments) +{ + public override string ToString() => string.Join(' ', [FileName, .. Arguments]); +} + +internal static class LinuxCertificateHelper +{ + internal const string CertificateFileName = "devproxy-ca.crt"; + internal const string NssNickname = "Dev Proxy CA"; + + private static readonly string[] _sbinDirectories = ["/usr/local/sbin", "/usr/sbin", "/sbin"]; + + /// + /// Detects the system CA trust store. Supports Debian/Ubuntu (update-ca-certificates), + /// Fedora/RHEL (update-ca-trust) and Arch (update-ca-trust with trust-source anchors). + /// Returns null when the distro isn't recognized. + /// + internal static LinuxTrustStore? DetectTrustStore( + Func directoryExists, + Func findExecutable) + { + var updateCaCertificates = findExecutable("update-ca-certificates"); + if (updateCaCertificates is not null && directoryExists("/usr/local/share/ca-certificates")) + { + return new( + "Debian/Ubuntu", + "/usr/local/share/ca-certificates", + updateCaCertificates, + [], + ["--fresh"]); + } + + var updateCaTrust = findExecutable("update-ca-trust"); + if (updateCaTrust is not null) + { + if (directoryExists("/etc/pki/ca-trust/source/anchors")) + { + return new("Fedora/RHEL", "/etc/pki/ca-trust/source/anchors", updateCaTrust, ["extract"], ["extract"]); + } + + if (directoryExists("/etc/ca-certificates/trust-source/anchors")) + { + return new("Arch", "/etc/ca-certificates/trust-source/anchors", updateCaTrust, ["extract"], ["extract"]); + } + } + + return null; + } + + /// + /// Wraps a command that needs root. Runs it directly when already root; otherwise via + /// sudo, with -n when there's no terminal to prompt on so it fails instead of hanging. + /// + internal static LinuxCommand Elevate(LinuxCommand command, bool isRoot, bool isInteractive) + { + if (isRoot) + { + return command; + } + + List args = isInteractive ? [] : ["-n"]; + args.Add("--"); + args.Add(command.FileName); + args.AddRange(command.Arguments); + return new("sudo", args); + } + + internal static IReadOnlyList GetTrustCommands(LinuxTrustStore store, string pemFilePath) => + [ + new("install", ["-D", "-m", "0644", pemFilePath, store.CertificatePath]), + new(store.UpdateCommand, store.UpdateArguments), + ]; + + internal static IReadOnlyList GetUntrustCommands(LinuxTrustStore store) => + [ + new("rm", ["-f", store.CertificatePath]), + new(store.UpdateCommand, store.RemoveUpdateArguments), + ]; + + internal static string GetNssDatabaseDirectory() => + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".pki", "nssdb"); + + internal static LinuxTrustStore? DetectTrustStore() => DetectTrustStore(Directory.Exists, FindExecutable); + + internal static bool TrustCertificate(X509Certificate2 certificate, LinuxTrustStore store, ILogger logger) + { + var pemFilePath = ExportCertificateToPem(certificate, logger); + if (pemFilePath is null) + { + return false; + } + + try + { + var (isRoot, isInteractive) = GetContext(); + logger.LogInformation( + "Adding the Dev Proxy root certificate to the {Distro} system certificate store ({Path}).{SudoHint}", + store.Name, + store.CertificatePath, + isRoot ? string.Empty : " You might be asked for your password."); + + foreach (var command in GetTrustCommands(store, pemFilePath)) + { + if (!Run(Elevate(command, isRoot, isInteractive), logger, redirectOutput: false)) + { + if (!isRoot && !isInteractive) + { + logger.LogError("If sudo requires a password, run 'devproxy cert ensure' in an interactive terminal."); + } + return false; + } + } + logger.LogInformation("Certificate added to the system certificate store."); + + TrustInNssDatabase(pemFilePath, logger); + return true; + } + finally + { + CleanupFile(pemFilePath); + } + } + + internal static void RemoveTrustedCertificate(ILogger logger) + { + var store = DetectTrustStore(); + if (store is null) + { + logger.LogWarning("Remove the Dev Proxy root certificate from your trust store manually."); + } + else if (File.Exists(store.CertificatePath)) + { + var (isRoot, isInteractive) = GetContext(); + logger.LogInformation( + "Removing the Dev Proxy root certificate from the system certificate store ({Path}).{SudoHint}", + store.CertificatePath, + isRoot ? string.Empty : " You might be asked for your password."); + + var removed = true; + foreach (var command in GetUntrustCommands(store)) + { + if (!Run(Elevate(command, isRoot, isInteractive), logger, redirectOutput: false)) + { + removed = false; + break; + } + } + if (removed) + { + logger.LogInformation("Certificate removed from the system certificate store."); + } + } + + RemoveFromNssDatabase(logger); + } + + private static void TrustInNssDatabase(string pemFilePath, ILogger logger) + { + var certutil = FindExecutable("certutil"); + if (certutil is null) + { + logger.LogInformation( + "To have Chrome and other NSS-based apps trust the certificate, install certutil (libnss3-tools on Debian/Ubuntu, nss-tools on Fedora/RHEL, nss on Arch) and run 'devproxy cert ensure' again."); + return; + } + + var nssDir = GetNssDatabaseDirectory(); + var db = $"sql:{nssDir}"; + if (!Directory.Exists(nssDir)) + { + _ = Directory.CreateDirectory(nssDir); + if (!Run(new(certutil, ["-N", "-d", db, "--empty-password"]), logger, redirectOutput: true)) + { + return; + } + } + + // Replace any previous Dev Proxy root (it's regenerated per machine) so re-runs are idempotent. + _ = Run(new(certutil, ["-D", "-d", db, "-n", NssNickname]), logger, redirectOutput: true, logFailure: false); + if (Run(new(certutil, ["-A", "-d", db, "-n", NssNickname, "-t", "C,,", "-i", pemFilePath]), logger, redirectOutput: true)) + { + logger.LogInformation("Certificate added to the NSS database ({Path}).", nssDir); + } + } + + private static void RemoveFromNssDatabase(ILogger logger) + { + var certutil = FindExecutable("certutil"); + var nssDir = GetNssDatabaseDirectory(); + if (certutil is null || !Directory.Exists(nssDir)) + { + return; + } + + if (Run(new(certutil, ["-D", "-d", $"sql:{nssDir}", "-n", NssNickname]), logger, redirectOutput: true, logFailure: false)) + { + logger.LogInformation("Certificate removed from the NSS database ({Path}).", nssDir); + } + } + + internal static void LogManualInstructions(ILogger logger) + { + logger.LogWarning( + "Couldn't detect your distro's certificate store. Trust the Dev Proxy root certificate manually: export it with 'openssl pkcs12 -in ~/.config/dev-proxy/rootCert.pfx -clcerts -nokeys -out devproxy-ca.crt -passin pass:', add devproxy-ca.crt to your system's CA anchors and refresh the store. More information: https://aka.ms/devproxy/start"); + } + + private static (bool IsRoot, bool IsInteractive) GetContext() => + (Environment.UserName == "root", + !Console.IsInputRedirected && Environment.GetEnvironmentVariable("CI") is null); + + internal static string? FindExecutable(string name) + { + var path = Environment.GetEnvironmentVariable("PATH") ?? string.Empty; + var directories = path.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries) + .Concat(_sbinDirectories); + foreach (var directory in directories) + { + var candidate = Path.Combine(directory, name); + if (File.Exists(candidate)) + { + return candidate; + } + } + return null; + } + + private static bool Run(LinuxCommand command, ILogger logger, bool redirectOutput, bool logFailure = true) + { + var startInfo = new ProcessStartInfo + { + FileName = command.FileName, + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = redirectOutput, + RedirectStandardError = redirectOutput, + }; + foreach (var argument in command.Arguments) + { + startInfo.ArgumentList.Add(argument); + } + + try + { + using var process = new Process { StartInfo = startInfo }; + var stderr = new System.Text.StringBuilder(); + if (redirectOutput) + { + process.ErrorDataReceived += (_, e) => stderr.AppendLine(e.Data); + } + _ = process.Start(); + if (redirectOutput) + { + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + } + process.WaitForExit(); + + if (process.ExitCode != 0) + { + if (logFailure) + { + var error = stderr.ToString().Trim(); + if (error.Length > 0) + { + logger.LogError("Command '{Command}' failed with exit code {ExitCode}: {Error}", command, process.ExitCode, error); + } + else + { + logger.LogError("Command '{Command}' failed with exit code {ExitCode}.", command, process.ExitCode); + } + } + return false; + } + return true; + } + catch (Exception ex) when (ex is System.ComponentModel.Win32Exception or InvalidOperationException) + { + if (logFailure) + { + logger.LogError(ex, "Couldn't run '{Command}'", command); + } + return false; + } + } + + private static string? ExportCertificateToPem(X509Certificate2 certificate, ILogger logger) + { + try + { + // Public certificate only; the private key never leaves the PFX. + var pem = certificate.ExportCertificatePem(); + var pemFilePath = Path.Combine(Path.GetTempPath(), $"devproxy-ca-{Guid.NewGuid():N}.crt"); + var options = new FileStreamOptions { Mode = FileMode.CreateNew, Access = FileAccess.Write }; + if (!OperatingSystem.IsWindows()) + { + options.UnixCreateMode = UnixFileMode.UserRead | UnixFileMode.UserWrite; + } + using (var stream = new FileStream(pemFilePath, options)) + using (var writer = new StreamWriter(stream)) + { + writer.Write(pem); + writer.Write('\n'); + } + return pemFilePath; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or System.Security.Cryptography.CryptographicException) + { + logger.LogError(ex, "Failed to export the root certificate to PEM"); + return null; + } + } + + private static void CleanupFile(string filePath) + { + try + { + File.Delete(filePath); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // ignore cleanup failures + } + } +} diff --git a/DevProxy/Proxy/RootCertificateTrust.cs b/DevProxy/Proxy/RootCertificateTrust.cs index 5693bf8aa..72ddd8fbe 100644 --- a/DevProxy/Proxy/RootCertificateTrust.cs +++ b/DevProxy/Proxy/RootCertificateTrust.cs @@ -11,7 +11,8 @@ namespace DevProxy.Proxy; /// Host-side for the Kestrel engine. The engine mints /// and persists its root, then calls ; this performs the actual /// platform trust install (macOS keychain via , Windows -/// CurrentUser root store) gated by the user's install/first-run configuration. The trust +/// CurrentUser root store, Linux system store via on +/// explicit cert ensure only) gated by the user's install/first-run configuration. The trust /// decision itself lives in (pure + unit-tested); this class /// is only the I/O around it. /// @@ -62,7 +63,7 @@ public void EnsureTrusted(X509Certificate2 rootCertificate) case RootTrustAction.ManualLinux: logger.LogWarning( - "Trust the Dev Proxy root certificate manually so your tools accept intercepted HTTPS traffic."); + "To have your tools accept intercepted HTTPS traffic, trust the Dev Proxy root certificate by running 'devproxy cert ensure' (requires sudo)."); break; case RootTrustAction.Skip: @@ -90,6 +91,20 @@ public void Trust(X509Certificate2 rootCertificate) throw new InvalidOperationException("Failed to install the root certificate into the Windows root store."); } } + else if (OperatingSystem.IsLinux()) + { + var store = LinuxCertificateHelper.DetectTrustStore(); + if (store is null) + { + LinuxCertificateHelper.LogManualInstructions(logger); + return; + } + if (!LinuxCertificateHelper.TrustCertificate(rootCertificate, store, logger)) + { + throw new InvalidOperationException("Failed to trust the root certificate in the system certificate store."); + } + logger.LogInformation("Certificate trusted successfully."); + } else { logger.LogWarning( @@ -110,6 +125,10 @@ public void Untrust(X509Certificate2 rootCertificate) { RemoveFromWindowsRootStore(rootCertificate); } + else if (OperatingSystem.IsLinux()) + { + LinuxCertificateHelper.RemoveTrustedCertificate(logger); + } else { logger.LogWarning( diff --git a/README.md b/README.md index 40cce32ab..24edf66ee 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,7 @@ OpenAI | `openai-throttling` Anthropic | `anthropic-throttling` Microsoft Graph | `microsoft-graph-rate-limiting` -The first time you start Dev Proxy, trust its certificate so that it can intercept HTTPS requests. On Linux, you need to trust the certificate manually. Our [tutorial](https://aka.ms/devproxy/start) walks you through these steps. +The first time you start Dev Proxy, trust its certificate so that it can intercept HTTPS requests. On Linux, run `devproxy cert ensure` to trust it (requires sudo). Our [tutorial](https://aka.ms/devproxy/start) walks you through these steps. Then, run your app as usual. It keeps calling the real API URLs, and Dev Proxy simulates the API's rate limits and errors. Find presets for other APIs in the [samples gallery](https://aka.ms/devproxy/samples). diff --git a/skills/dev-proxy/references/installation.md b/skills/dev-proxy/references/installation.md index fdb020921..ef1ce5ec2 100644 --- a/skills/dev-proxy/references/installation.md +++ b/skills/dev-proxy/references/installation.md @@ -100,10 +100,17 @@ The first time Dev Proxy starts, it requires certificate trust and (on some plat 2. When the certificate warning appears for "Dev Proxy CA", select "Yes" to install. 3. When the Windows Firewall warning appears, select "Allow access". -### Linux (Ubuntu) +### Linux -1. Run `devproxy` and press Enter. -2. In a separate terminal, trust the certificate: +Trust the certificate (requires sudo, so Dev Proxy doesn't do it automatically on first run): + +```bash +devproxy cert ensure +``` + +This adds the certificate to the system certificate store on Debian/Ubuntu (`update-ca-certificates`), Fedora/RHEL and Arch (`update-ca-trust`). If `certutil` is installed (`libnss3-tools` on Debian/Ubuntu, `nss-tools` on Fedora/RHEL, `nss` on Arch), it also adds it to the NSS database (`~/.pki/nssdb`) used by Chrome. To undo, run `devproxy cert remove`. + +On other distributions, trust the certificate manually. For example, on Ubuntu: ```bash # Export Dev Proxy root certificate @@ -114,8 +121,6 @@ sudo cp dev-proxy-ca.crt /usr/local/share/ca-certificates/ sudo update-ca-certificates ``` -For non-Ubuntu distributions, the certificate trust steps may differ. - ## Verify Installation After first-run setup, verify Dev Proxy intercepts requests: From 12734cbc4ce24118f554fd064f827dd591987099 Mon Sep 17 00:00:00 2001 From: waldekmastykarz Date: Sat, 31 Oct 2026 10:16:53 +0100 Subject: [PATCH 02/12] Harden Linux certificate trust per review feedback - Resolve root-run refresh tools only from system directories, not PATH - Remove the legacy dev-proxy-ca.crt anchor when it matches the current root - On remove, refresh the store when the CA bundle still contains the root, so a removal whose refresh failed can be retried Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- DevProxy.Tests/LinuxCertificateHelperTests.cs | 93 ++++++++++++++- DevProxy/Proxy/LinuxCertificateHelper.cs | 111 ++++++++++++++---- DevProxy/Proxy/RootCertificateTrust.cs | 2 +- 3 files changed, 180 insertions(+), 26 deletions(-) diff --git a/DevProxy.Tests/LinuxCertificateHelperTests.cs b/DevProxy.Tests/LinuxCertificateHelperTests.cs index fe09eb6ab..144495713 100644 --- a/DevProxy.Tests/LinuxCertificateHelperTests.cs +++ b/DevProxy.Tests/LinuxCertificateHelperTests.cs @@ -2,6 +2,8 @@ // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; using DevProxy.Proxy; using Xunit; @@ -23,6 +25,8 @@ public void DetectTrustStore_Debian() Assert.NotNull(store); Assert.Equal("/usr/local/share/ca-certificates/devproxy-ca.crt", store.CertificatePath); + Assert.Equal("/usr/local/share/ca-certificates/dev-proxy-ca.crt", store.LegacyCertificatePath); + Assert.Equal("/etc/ssl/certs/ca-certificates.crt", store.BundlePath); Assert.Equal("/usr/sbin/update-ca-certificates", store.UpdateCommand); Assert.Empty(store.UpdateArguments); Assert.Equal(["--fresh"], store.RemoveUpdateArguments); @@ -37,6 +41,7 @@ public void DetectTrustStore_Fedora() Assert.NotNull(store); Assert.Equal("/etc/pki/ca-trust/source/anchors/devproxy-ca.crt", store.CertificatePath); + Assert.Equal("/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem", store.BundlePath); Assert.Equal("/usr/sbin/update-ca-trust", store.UpdateCommand); Assert.Equal(["extract"], store.UpdateArguments); } @@ -50,6 +55,7 @@ public void DetectTrustStore_Arch() Assert.NotNull(store); Assert.Equal("/etc/ca-certificates/trust-source/anchors/devproxy-ca.crt", store.CertificatePath); + Assert.Equal("/etc/ca-certificates/extracted/tls-ca-bundle.pem", store.BundlePath); } [Fact] @@ -103,12 +109,33 @@ public void Elevate_NonInteractive_UsesSudoWithoutPrompt() Assert.Equal(["-n", "--", "rm", "-f", "x"], elevated.Arguments); } + private static readonly LinuxTrustStore _fedora = new("Fedora/RHEL", "/etc/pki/ca-trust/source/anchors", "/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem", "/usr/bin/update-ca-trust", ["extract"], ["extract"]); + private static readonly LinuxTrustStore _debian = new("Debian/Ubuntu", "/usr/local/share/ca-certificates", "/etc/ssl/certs/ca-certificates.crt", "/usr/sbin/update-ca-certificates", [], ["--fresh"]); + + [Fact] + public void FindSystemExecutable_IgnoresPath() + { + var dir = Directory.CreateTempSubdirectory(); + try + { + var name = $"devproxy-test-{Guid.NewGuid():N}"; + File.WriteAllText(Path.Combine(dir.FullName, name), string.Empty); + + Assert.NotNull(LinuxCertificateHelper.FindExecutable(name, dir.FullName)); + Assert.Null(LinuxCertificateHelper.FindSystemExecutable(name)); + } + finally + { + dir.Delete(true); + } + } + [Fact] public void GetTrustCommands_InstallsThenRefreshes() { - var store = new LinuxTrustStore("Fedora/RHEL", "/etc/pki/ca-trust/source/anchors", "/usr/bin/update-ca-trust", ["extract"], ["extract"]); + var store = _fedora; - var commands = LinuxCertificateHelper.GetTrustCommands(store, "/tmp/cert.crt"); + var commands = LinuxCertificateHelper.GetTrustCommands(store, "/tmp/cert.crt", removeLegacy: false); Assert.Equal(2, commands.Count); Assert.Equal("install", commands[0].FileName); @@ -117,12 +144,70 @@ public void GetTrustCommands_InstallsThenRefreshes() Assert.Equal(["extract"], commands[1].Arguments); } + [Fact] + public void GetTrustCommands_RemovesLegacyAnchorBeforeRefresh() + { + var commands = LinuxCertificateHelper.GetTrustCommands(_debian, "/tmp/cert.crt", removeLegacy: true); + + Assert.Equal(["install", "rm", "/usr/sbin/update-ca-certificates"], commands.Select(c => c.FileName)); + Assert.Equal(["-f", "/usr/local/share/ca-certificates/dev-proxy-ca.crt"], commands[1].Arguments); + } + + [Fact] + public void GetUntrustCommands_RemovesLegacyAnchorBeforeRefresh() + { + var commands = LinuxCertificateHelper.GetUntrustCommands(_debian, removeLegacy: true); + + Assert.Equal(["rm", "rm", "/usr/sbin/update-ca-certificates"], commands.Select(c => c.FileName)); + Assert.Equal(["-f", "/usr/local/share/ca-certificates/dev-proxy-ca.crt"], commands[1].Arguments); + } + + [Theory] + [InlineData(false, false, false, false)] + [InlineData(true, false, false, true)] + [InlineData(false, true, false, true)] + [InlineData(false, false, true, true)] + public void NeedsSystemStoreRemoval(bool anchorExists, bool legacyMatches, bool bundleContains, bool expected) => + Assert.Equal(expected, LinuxCertificateHelper.NeedsSystemStoreRemoval(anchorExists, legacyMatches, bundleContains)); + + [Fact] + public void FileContainsCertificate_MatchesOnlyTheSameCertificate() + { + using var root = CreateCertificate("CN=Dev Proxy CA"); + using var other = CreateCertificate("CN=Other CA"); + var bundle = Path.GetTempFileName(); + try + { + File.WriteAllText(bundle, other.ExportCertificatePem() + "\n" + root.ExportCertificatePem() + "\n"); + Assert.True(LinuxCertificateHelper.FileContainsCertificate(bundle, root)); + + File.WriteAllText(bundle, other.ExportCertificatePem()); + Assert.False(LinuxCertificateHelper.FileContainsCertificate(bundle, root)); + + File.WriteAllText(bundle, "not a certificate"); + Assert.False(LinuxCertificateHelper.FileContainsCertificate(bundle, root)); + } + finally + { + File.Delete(bundle); + } + + Assert.False(LinuxCertificateHelper.FileContainsCertificate(bundle, root)); + } + + private static X509Certificate2 CreateCertificate(string subject) + { + using var key = RSA.Create(2048); + var request = new CertificateRequest(subject, key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + return request.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(1)); + } + [Fact] public void GetUntrustCommands_RemovesThenRefreshes() { - var store = new LinuxTrustStore("Debian/Ubuntu", "/usr/local/share/ca-certificates", "/usr/sbin/update-ca-certificates", [], ["--fresh"]); + var store = _debian; - var commands = LinuxCertificateHelper.GetUntrustCommands(store); + var commands = LinuxCertificateHelper.GetUntrustCommands(store, removeLegacy: false); Assert.Equal(2, commands.Count); Assert.Equal("rm", commands[0].FileName); diff --git a/DevProxy/Proxy/LinuxCertificateHelper.cs b/DevProxy/Proxy/LinuxCertificateHelper.cs index ca4bbb5f6..258f5015a 100644 --- a/DevProxy/Proxy/LinuxCertificateHelper.cs +++ b/DevProxy/Proxy/LinuxCertificateHelper.cs @@ -11,11 +11,15 @@ namespace DevProxy.Proxy; internal sealed record LinuxTrustStore( string Name, string AnchorDirectory, + string BundlePath, string UpdateCommand, IReadOnlyList UpdateArguments, IReadOnlyList RemoveUpdateArguments) { public string CertificatePath => Path.Combine(AnchorDirectory, LinuxCertificateHelper.CertificateFileName); + + /// Anchor that earlier manual setup instructions told users to create. + public string LegacyCertificatePath => Path.Combine(AnchorDirectory, LinuxCertificateHelper.LegacyCertificateFileName); } /// A process invocation (file name + argument list, no shell). @@ -27,14 +31,20 @@ internal sealed record LinuxCommand(string FileName, IReadOnlyList Argum internal static class LinuxCertificateHelper { internal const string CertificateFileName = "devproxy-ca.crt"; + internal const string LegacyCertificateFileName = "dev-proxy-ca.crt"; internal const string NssNickname = "Dev Proxy CA"; private static readonly string[] _sbinDirectories = ["/usr/local/sbin", "/usr/sbin", "/sbin"]; + // Root-owned system directories. Tools that run as root are resolved only from here, + // never from the caller's PATH, so a user-writable PATH entry can't be executed via sudo. + private static readonly string[] _systemDirectories = ["/usr/sbin", "/usr/bin", "/sbin", "/bin"]; + /// /// Detects the system CA trust store. Supports Debian/Ubuntu (update-ca-certificates), /// Fedora/RHEL (update-ca-trust) and Arch (update-ca-trust with trust-source anchors). - /// Returns null when the distro isn't recognized. + /// Returns null when the distro isn't recognized. must + /// only resolve from trusted system directories because the result runs as root. /// internal static LinuxTrustStore? DetectTrustStore( Func directoryExists, @@ -46,6 +56,7 @@ internal static class LinuxCertificateHelper return new( "Debian/Ubuntu", "/usr/local/share/ca-certificates", + "/etc/ssl/certs/ca-certificates.crt", updateCaCertificates, [], ["--fresh"]); @@ -56,12 +67,12 @@ internal static class LinuxCertificateHelper { if (directoryExists("/etc/pki/ca-trust/source/anchors")) { - return new("Fedora/RHEL", "/etc/pki/ca-trust/source/anchors", updateCaTrust, ["extract"], ["extract"]); + return new("Fedora/RHEL", "/etc/pki/ca-trust/source/anchors", "/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem", updateCaTrust, ["extract"], ["extract"]); } if (directoryExists("/etc/ca-certificates/trust-source/anchors")) { - return new("Arch", "/etc/ca-certificates/trust-source/anchors", updateCaTrust, ["extract"], ["extract"]); + return new("Arch", "/etc/ca-certificates/trust-source/anchors", "/etc/ca-certificates/extracted/tls-ca-bundle.pem", updateCaTrust, ["extract"], ["extract"]); } } @@ -86,22 +97,70 @@ internal static LinuxCommand Elevate(LinuxCommand command, bool isRoot, bool isI return new("sudo", args); } - internal static IReadOnlyList GetTrustCommands(LinuxTrustStore store, string pemFilePath) => - [ - new("install", ["-D", "-m", "0644", pemFilePath, store.CertificatePath]), - new(store.UpdateCommand, store.UpdateArguments), - ]; + // install/rm are passed by name so sudo resolves them through its secure_path. + internal static IReadOnlyList GetTrustCommands(LinuxTrustStore store, string pemFilePath, bool removeLegacy) + { + List commands = [new("install", ["-D", "-m", "0644", pemFilePath, store.CertificatePath])]; + if (removeLegacy) + { + commands.Add(new("rm", ["-f", store.LegacyCertificatePath])); + } + commands.Add(new(store.UpdateCommand, store.UpdateArguments)); + return commands; + } + + internal static IReadOnlyList GetUntrustCommands(LinuxTrustStore store, bool removeLegacy) + { + List commands = [new("rm", ["-f", store.CertificatePath])]; + if (removeLegacy) + { + commands.Add(new("rm", ["-f", store.LegacyCertificatePath])); + } + commands.Add(new(store.UpdateCommand, store.RemoveUpdateArguments)); + return commands; + } - internal static IReadOnlyList GetUntrustCommands(LinuxTrustStore store) => - [ - new("rm", ["-f", store.CertificatePath]), - new(store.UpdateCommand, store.RemoveUpdateArguments), - ]; + /// + /// Whether cert remove has anything to undo. The bundle check covers a previous + /// removal where the anchor was deleted but refreshing the store failed. + /// + internal static bool NeedsSystemStoreRemoval(bool anchorExists, bool legacyAnchorMatches, bool bundleContainsCertificate) => + anchorExists || legacyAnchorMatches || bundleContainsCertificate; + + /// Whether the PEM file at holds . + internal static bool FileContainsCertificate(string path, X509Certificate2 certificate) + { + try + { + if (!File.Exists(path)) + { + return false; + } + + var certificates = new X509Certificate2Collection(); + certificates.ImportFromPemFile(path); + try + { + return certificates.Any(c => c.RawData.AsSpan().SequenceEqual(certificate.RawData)); + } + finally + { + foreach (var c in certificates) + { + c.Dispose(); + } + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or System.Security.Cryptography.CryptographicException) + { + return false; + } + } internal static string GetNssDatabaseDirectory() => Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".pki", "nssdb"); - internal static LinuxTrustStore? DetectTrustStore() => DetectTrustStore(Directory.Exists, FindExecutable); + internal static LinuxTrustStore? DetectTrustStore() => DetectTrustStore(Directory.Exists, FindSystemExecutable); internal static bool TrustCertificate(X509Certificate2 certificate, LinuxTrustStore store, ILogger logger) { @@ -120,7 +179,8 @@ internal static bool TrustCertificate(X509Certificate2 certificate, LinuxTrustSt store.CertificatePath, isRoot ? string.Empty : " You might be asked for your password."); - foreach (var command in GetTrustCommands(store, pemFilePath)) + var removeLegacy = FileContainsCertificate(store.LegacyCertificatePath, certificate); + foreach (var command in GetTrustCommands(store, pemFilePath, removeLegacy)) { if (!Run(Elevate(command, isRoot, isInteractive), logger, redirectOutput: false)) { @@ -142,14 +202,18 @@ internal static bool TrustCertificate(X509Certificate2 certificate, LinuxTrustSt } } - internal static void RemoveTrustedCertificate(ILogger logger) + internal static void RemoveTrustedCertificate(X509Certificate2 certificate, ILogger logger) { var store = DetectTrustStore(); + var removeLegacy = store is not null && FileContainsCertificate(store.LegacyCertificatePath, certificate); if (store is null) { logger.LogWarning("Remove the Dev Proxy root certificate from your trust store manually."); } - else if (File.Exists(store.CertificatePath)) + else if (NeedsSystemStoreRemoval( + File.Exists(store.CertificatePath), + removeLegacy, + FileContainsCertificate(store.BundlePath, certificate))) { var (isRoot, isInteractive) = GetContext(); logger.LogInformation( @@ -158,7 +222,7 @@ internal static void RemoveTrustedCertificate(ILogger logger) isRoot ? string.Empty : " You might be asked for your password."); var removed = true; - foreach (var command in GetUntrustCommands(store)) + foreach (var command in GetUntrustCommands(store, removeLegacy)) { if (!Run(Elevate(command, isRoot, isInteractive), logger, redirectOutput: false)) { @@ -229,10 +293,15 @@ private static (bool IsRoot, bool IsInteractive) GetContext() => (Environment.UserName == "root", !Console.IsInputRedirected && Environment.GetEnvironmentVariable("CI") is null); - internal static string? FindExecutable(string name) + internal static string? FindSystemExecutable(string name) => + _systemDirectories.Select(d => Path.Combine(d, name)).FirstOrDefault(File.Exists); + + internal static string? FindExecutable(string name) => + FindExecutable(name, Environment.GetEnvironmentVariable("PATH")); + + internal static string? FindExecutable(string name, string? path) { - var path = Environment.GetEnvironmentVariable("PATH") ?? string.Empty; - var directories = path.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries) + var directories = (path ?? string.Empty).Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries) .Concat(_sbinDirectories); foreach (var directory in directories) { diff --git a/DevProxy/Proxy/RootCertificateTrust.cs b/DevProxy/Proxy/RootCertificateTrust.cs index 72ddd8fbe..96310a205 100644 --- a/DevProxy/Proxy/RootCertificateTrust.cs +++ b/DevProxy/Proxy/RootCertificateTrust.cs @@ -127,7 +127,7 @@ public void Untrust(X509Certificate2 rootCertificate) } else if (OperatingSystem.IsLinux()) { - LinuxCertificateHelper.RemoveTrustedCertificate(logger); + LinuxCertificateHelper.RemoveTrustedCertificate(rootCertificate, logger); } else { From d2e4e1fb4c8b896d165e8e538680f1321670abbc Mon Sep 17 00:00:00 2001 From: waldekmastykarz Date: Sat, 31 Oct 2026 10:24:32 +0100 Subject: [PATCH 03/12] Resolve root commands from system directories and use Linux path separators Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- DevProxy.Tests/LinuxCertificateHelperTests.cs | 30 ++++++++++--- DevProxy/Proxy/LinuxCertificateHelper.cs | 45 ++++++++++++++----- 2 files changed, 60 insertions(+), 15 deletions(-) diff --git a/DevProxy.Tests/LinuxCertificateHelperTests.cs b/DevProxy.Tests/LinuxCertificateHelperTests.cs index 144495713..d38482c4a 100644 --- a/DevProxy.Tests/LinuxCertificateHelperTests.cs +++ b/DevProxy.Tests/LinuxCertificateHelperTests.cs @@ -80,20 +80,40 @@ public void DetectTrustStore_UnknownDistro_ReturnsNull() } [Fact] - public void Elevate_Root_RunsDirectly() + public void Elevate_Root_RunsAbsoluteCommandDirectly() { - var command = new LinuxCommand("update-ca-certificates", []); + var command = new LinuxCommand("/usr/sbin/update-ca-certificates", []); - var elevated = LinuxCertificateHelper.Elevate(command, isRoot: true, isInteractive: false); + var elevated = LinuxCertificateHelper.Elevate(command, isRoot: true, isInteractive: false, _ => null); Assert.Same(command, elevated); } + [Fact] + public void Elevate_Root_ResolvesBareCommandFromSystemDirectories() + { + var elevated = LinuxCertificateHelper.Elevate( + new("rm", ["-f", "x"]), isRoot: true, isInteractive: false, name => $"/usr/bin/{name}"); + + Assert.NotNull(elevated); + Assert.Equal("/usr/bin/rm", elevated.FileName); + Assert.Equal(["-f", "x"], elevated.Arguments); + } + + [Fact] + public void Elevate_Root_UnresolvedBareCommand_ReturnsNull() + { + var elevated = LinuxCertificateHelper.Elevate( + new("install", []), isRoot: true, isInteractive: false, _ => null); + + Assert.Null(elevated); + } + [Fact] public void Elevate_Interactive_UsesSudo() { var elevated = LinuxCertificateHelper.Elevate( - new("install", ["-m", "0644", "a", "b"]), isRoot: false, isInteractive: true); + new("install", ["-m", "0644", "a", "b"]), isRoot: false, isInteractive: true)!; Assert.Equal("sudo", elevated.FileName); Assert.Equal(["--", "install", "-m", "0644", "a", "b"], elevated.Arguments); @@ -103,7 +123,7 @@ public void Elevate_Interactive_UsesSudo() public void Elevate_NonInteractive_UsesSudoWithoutPrompt() { var elevated = LinuxCertificateHelper.Elevate( - new("rm", ["-f", "x"]), isRoot: false, isInteractive: false); + new("rm", ["-f", "x"]), isRoot: false, isInteractive: false)!; Assert.Equal("sudo", elevated.FileName); Assert.Equal(["-n", "--", "rm", "-f", "x"], elevated.Arguments); diff --git a/DevProxy/Proxy/LinuxCertificateHelper.cs b/DevProxy/Proxy/LinuxCertificateHelper.cs index 258f5015a..cc356ca3e 100644 --- a/DevProxy/Proxy/LinuxCertificateHelper.cs +++ b/DevProxy/Proxy/LinuxCertificateHelper.cs @@ -16,10 +16,10 @@ internal sealed record LinuxTrustStore( IReadOnlyList UpdateArguments, IReadOnlyList RemoveUpdateArguments) { - public string CertificatePath => Path.Combine(AnchorDirectory, LinuxCertificateHelper.CertificateFileName); + public string CertificatePath => $"{AnchorDirectory}/{LinuxCertificateHelper.CertificateFileName}"; /// Anchor that earlier manual setup instructions told users to create. - public string LegacyCertificatePath => Path.Combine(AnchorDirectory, LinuxCertificateHelper.LegacyCertificateFileName); + public string LegacyCertificatePath => $"{AnchorDirectory}/{LinuxCertificateHelper.LegacyCertificateFileName}"; } /// A process invocation (file name + argument list, no shell). @@ -80,14 +80,24 @@ internal static class LinuxCertificateHelper } /// - /// Wraps a command that needs root. Runs it directly when already root; otherwise via - /// sudo, with -n when there's no terminal to prompt on so it fails instead of hanging. + /// Wraps a command that needs root. When already root, runs it directly, resolving bare + /// names from system directories only (PATH could point to user-writable directories); + /// returns null if it can't be resolved. Otherwise runs it via sudo, with -n when + /// there's no terminal to prompt on so it fails instead of hanging. /// - internal static LinuxCommand Elevate(LinuxCommand command, bool isRoot, bool isInteractive) + internal static LinuxCommand? Elevate(LinuxCommand command, bool isRoot, bool isInteractive) => + Elevate(command, isRoot, isInteractive, FindSystemExecutable); + + internal static LinuxCommand? Elevate(LinuxCommand command, bool isRoot, bool isInteractive, Func findSystemExecutable) { if (isRoot) { - return command; + if (command.FileName.StartsWith('/')) + { + return command; + } + var resolved = findSystemExecutable(command.FileName); + return resolved is null ? null : command with { FileName = resolved }; } List args = isInteractive ? [] : ["-n"]; @@ -182,7 +192,7 @@ internal static bool TrustCertificate(X509Certificate2 certificate, LinuxTrustSt var removeLegacy = FileContainsCertificate(store.LegacyCertificatePath, certificate); foreach (var command in GetTrustCommands(store, pemFilePath, removeLegacy)) { - if (!Run(Elevate(command, isRoot, isInteractive), logger, redirectOutput: false)) + if (!RunElevated(command, isRoot, isInteractive, logger)) { if (!isRoot && !isInteractive) { @@ -224,7 +234,7 @@ internal static void RemoveTrustedCertificate(X509Certificate2 certificate, ILog var removed = true; foreach (var command in GetUntrustCommands(store, removeLegacy)) { - if (!Run(Elevate(command, isRoot, isInteractive), logger, redirectOutput: false)) + if (!RunElevated(command, isRoot, isInteractive, logger)) { removed = false; break; @@ -241,7 +251,7 @@ internal static void RemoveTrustedCertificate(X509Certificate2 certificate, ILog private static void TrustInNssDatabase(string pemFilePath, ILogger logger) { - var certutil = FindExecutable("certutil"); + var certutil = FindCertutil(); if (certutil is null) { logger.LogInformation( @@ -270,7 +280,7 @@ private static void TrustInNssDatabase(string pemFilePath, ILogger logger) private static void RemoveFromNssDatabase(ILogger logger) { - var certutil = FindExecutable("certutil"); + var certutil = FindCertutil(); var nssDir = GetNssDatabaseDirectory(); if (certutil is null || !Directory.Exists(nssDir)) { @@ -293,6 +303,21 @@ private static (bool IsRoot, bool IsInteractive) GetContext() => (Environment.UserName == "root", !Console.IsInputRedirected && Environment.GetEnvironmentVariable("CI") is null); + private static bool RunElevated(LinuxCommand command, bool isRoot, bool isInteractive, ILogger logger) + { + var elevated = Elevate(command, isRoot, isInteractive); + if (elevated is null) + { + logger.LogError("Couldn't find {Command} in {Directories}.", command.FileName, string.Join(", ", _systemDirectories)); + return false; + } + return Run(elevated, logger, redirectOutput: false); + } + + // When running as root, don't pick up certutil from PATH, which might be user-writable. + private static string? FindCertutil() => + GetContext().IsRoot ? FindSystemExecutable("certutil") : FindExecutable("certutil"); + internal static string? FindSystemExecutable(string name) => _systemDirectories.Select(d => Path.Combine(d, name)).FirstOrDefault(File.Exists); From 7fb1fac2074167c580994e5416cfb95f6e33ade0 Mon Sep 17 00:00:00 2001 From: waldekmastykarz Date: Sat, 31 Oct 2026 14:16:18 +0100 Subject: [PATCH 04/12] Skip Linux trust without non-interactive sudo and use Chromium's active NSS database Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- DevProxy.Tests/LinuxCertificateHelperTests.cs | 28 +++++++++++ DevProxy/Proxy/LinuxCertificateHelper.cs | 47 ++++++++++++++++--- DevProxy/Proxy/RootCertificateTrust.cs | 12 +++-- skills/dev-proxy/references/installation.md | 2 +- 4 files changed, 79 insertions(+), 10 deletions(-) diff --git a/DevProxy.Tests/LinuxCertificateHelperTests.cs b/DevProxy.Tests/LinuxCertificateHelperTests.cs index d38482c4a..bfcda92e3 100644 --- a/DevProxy.Tests/LinuxCertificateHelperTests.cs +++ b/DevProxy.Tests/LinuxCertificateHelperTests.cs @@ -132,6 +132,34 @@ public void Elevate_NonInteractive_UsesSudoWithoutPrompt() private static readonly LinuxTrustStore _fedora = new("Fedora/RHEL", "/etc/pki/ca-trust/source/anchors", "/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem", "/usr/bin/update-ca-trust", ["extract"], ["extract"]); private static readonly LinuxTrustStore _debian = new("Debian/Ubuntu", "/usr/local/share/ca-certificates", "/etc/ssl/certs/ca-certificates.crt", "/usr/sbin/update-ca-certificates", [], ["--fresh"]); + [Fact] + public void GetNssDatabaseDirectory_PrefersExistingLegacyDatabase() + { + Assert.Equal("/home/u/.pki/nssdb", + LinuxCertificateHelper.GetNssDatabaseDirectory("/home/u", Directories("/home/u/.pki/nssdb", "/home/u/.local/share/pki/nssdb"))); + } + + [Fact] + public void GetNssDatabaseDirectory_DefaultsToXdgLocation() + { + Assert.Equal("/home/u/.local/share/pki/nssdb", + LinuxCertificateHelper.GetNssDatabaseDirectory("/home/u", Directories())); + } + + [Theory] + [InlineData(false, false, false, true)] + [InlineData(false, false, true, false)] + [InlineData(false, true, false, false)] + [InlineData(true, false, false, false)] + public void ShouldSkipTrust_OnlyWhenSudoCantRunWithoutPrompt(bool isRoot, bool isInteractive, bool canSudo, bool expected) + { + var probed = false; + var skip = LinuxCertificateHelper.ShouldSkipTrust(isRoot, isInteractive, () => { probed = true; return canSudo; }); + + Assert.Equal(expected, skip); + Assert.Equal(!isRoot && !isInteractive, probed); + } + [Fact] public void FindSystemExecutable_IgnoresPath() { diff --git a/DevProxy/Proxy/LinuxCertificateHelper.cs b/DevProxy/Proxy/LinuxCertificateHelper.cs index cc356ca3e..5978477b1 100644 --- a/DevProxy/Proxy/LinuxCertificateHelper.cs +++ b/DevProxy/Proxy/LinuxCertificateHelper.cs @@ -4,6 +4,7 @@ using System.Diagnostics; using System.Security.Cryptography.X509Certificates; +using Microsoft.Extensions.Logging.Abstractions; namespace DevProxy.Proxy; @@ -23,6 +24,13 @@ internal sealed record LinuxTrustStore( } /// A process invocation (file name + argument list, no shell). +internal enum LinuxTrustResult +{ + Trusted, + Skipped, + Failed, +} + internal sealed record LinuxCommand(string FileName, IReadOnlyList Arguments) { public override string ToString() => string.Join(' ', [FileName, .. Arguments]); @@ -168,21 +176,45 @@ internal static bool FileContainsCertificate(string path, X509Certificate2 certi } internal static string GetNssDatabaseDirectory() => - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".pki", "nssdb"); + GetNssDatabaseDirectory(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), Directory.Exists); + + /// + /// Chromium uses the legacy ~/.pki/nssdb when it exists and ~/.local/share/pki/nssdb + /// otherwise (M146+). Install and remove share this selection. + /// + internal static string GetNssDatabaseDirectory(string home, Func dirExists) + { + var legacy = $"{home}/.pki/nssdb"; + return dirExists(legacy) ? legacy : $"{home}/.local/share/pki/nssdb"; + } + + /// + /// Without a terminal, sudo can't prompt for a password. If it also isn't allowed to run + /// without one (e.g. a restricted sudoers grant), skip trusting instead of failing. + /// + internal static bool ShouldSkipTrust(bool isRoot, bool isInteractive, Func canSudoWithoutPassword) => + !isRoot && !isInteractive && !canSudoWithoutPassword(); internal static LinuxTrustStore? DetectTrustStore() => DetectTrustStore(Directory.Exists, FindSystemExecutable); - internal static bool TrustCertificate(X509Certificate2 certificate, LinuxTrustStore store, ILogger logger) + internal static LinuxTrustResult TrustCertificate(X509Certificate2 certificate, LinuxTrustStore store, ILogger logger) { + var (isRoot, isInteractive) = GetContext(); + if (ShouldSkipTrust(isRoot, isInteractive, CanSudoWithoutPassword)) + { + logger.LogWarning( + "Skipped trusting the Dev Proxy root certificate because sudo needs a password and there's no terminal to ask for it. To trust the certificate, run 'devproxy cert ensure' in an interactive terminal."); + return LinuxTrustResult.Skipped; + } + var pemFilePath = ExportCertificateToPem(certificate, logger); if (pemFilePath is null) { - return false; + return LinuxTrustResult.Failed; } try { - var (isRoot, isInteractive) = GetContext(); logger.LogInformation( "Adding the Dev Proxy root certificate to the {Distro} system certificate store ({Path}).{SudoHint}", store.Name, @@ -198,13 +230,13 @@ internal static bool TrustCertificate(X509Certificate2 certificate, LinuxTrustSt { logger.LogError("If sudo requires a password, run 'devproxy cert ensure' in an interactive terminal."); } - return false; + return LinuxTrustResult.Failed; } } logger.LogInformation("Certificate added to the system certificate store."); TrustInNssDatabase(pemFilePath, logger); - return true; + return LinuxTrustResult.Trusted; } finally { @@ -303,6 +335,9 @@ private static (bool IsRoot, bool IsInteractive) GetContext() => (Environment.UserName == "root", !Console.IsInputRedirected && Environment.GetEnvironmentVariable("CI") is null); + private static bool CanSudoWithoutPassword() => + Run(new("sudo", ["-n", "true"]), NullLogger.Instance, redirectOutput: true, logFailure: false); + private static bool RunElevated(LinuxCommand command, bool isRoot, bool isInteractive, ILogger logger) { var elevated = Elevate(command, isRoot, isInteractive); diff --git a/DevProxy/Proxy/RootCertificateTrust.cs b/DevProxy/Proxy/RootCertificateTrust.cs index 96310a205..32bd9026b 100644 --- a/DevProxy/Proxy/RootCertificateTrust.cs +++ b/DevProxy/Proxy/RootCertificateTrust.cs @@ -99,11 +99,17 @@ public void Trust(X509Certificate2 rootCertificate) LinuxCertificateHelper.LogManualInstructions(logger); return; } - if (!LinuxCertificateHelper.TrustCertificate(rootCertificate, store, logger)) + switch (LinuxCertificateHelper.TrustCertificate(rootCertificate, store, logger)) { - throw new InvalidOperationException("Failed to trust the root certificate in the system certificate store."); + case LinuxTrustResult.Failed: + throw new InvalidOperationException("Failed to trust the root certificate in the system certificate store."); + case LinuxTrustResult.Trusted: + logger.LogInformation("Certificate trusted successfully."); + break; + case LinuxTrustResult.Skipped: + default: + break; } - logger.LogInformation("Certificate trusted successfully."); } else { diff --git a/skills/dev-proxy/references/installation.md b/skills/dev-proxy/references/installation.md index ef1ce5ec2..88a438af4 100644 --- a/skills/dev-proxy/references/installation.md +++ b/skills/dev-proxy/references/installation.md @@ -108,7 +108,7 @@ Trust the certificate (requires sudo, so Dev Proxy doesn't do it automatically o devproxy cert ensure ``` -This adds the certificate to the system certificate store on Debian/Ubuntu (`update-ca-certificates`), Fedora/RHEL and Arch (`update-ca-trust`). If `certutil` is installed (`libnss3-tools` on Debian/Ubuntu, `nss-tools` on Fedora/RHEL, `nss` on Arch), it also adds it to the NSS database (`~/.pki/nssdb`) used by Chrome. To undo, run `devproxy cert remove`. +This adds the certificate to the system certificate store on Debian/Ubuntu (`update-ca-certificates`), Fedora/RHEL and Arch (`update-ca-trust`). If `certutil` is installed (`libnss3-tools` on Debian/Ubuntu, `nss-tools` on Fedora/RHEL, `nss` on Arch), it also adds it to the NSS database used by Chrome (`~/.pki/nssdb` if it exists, otherwise `~/.local/share/pki/nssdb`). Without a terminal, if sudo needs a password, `cert ensure` creates the certificate but skips trusting it and shows a warning. To undo, run `devproxy cert remove`. On other distributions, trust the certificate manually. For example, on Ubuntu: From 3a45eba28d92baf4508aed606d5e3b10bd36fd6e Mon Sep 17 00:00:00 2001 From: waldekmastykarz Date: Sat, 31 Oct 2026 14:25:19 +0100 Subject: [PATCH 05/12] Check sudo permission for the actual Linux trust commands Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- DevProxy.Tests/LinuxCertificateHelperTests.cs | 9 ++++++ DevProxy/Proxy/LinuxCertificateHelper.cs | 30 +++++++++++-------- 2 files changed, 27 insertions(+), 12 deletions(-) diff --git a/DevProxy.Tests/LinuxCertificateHelperTests.cs b/DevProxy.Tests/LinuxCertificateHelperTests.cs index bfcda92e3..81830ea96 100644 --- a/DevProxy.Tests/LinuxCertificateHelperTests.cs +++ b/DevProxy.Tests/LinuxCertificateHelperTests.cs @@ -160,6 +160,15 @@ public void ShouldSkipTrust_OnlyWhenSudoCantRunWithoutPrompt(bool isRoot, bool i Assert.Equal(!isRoot && !isInteractive, probed); } + [Fact] + public void GetSudoCheckCommand_ListsExactCommandWithoutPrompt() + { + var check = LinuxCertificateHelper.GetSudoCheckCommand(new("install", ["-m", "0644", "a", "b"])); + + Assert.Equal("sudo", check.FileName); + Assert.Equal(["-n", "-l", "--", "install", "-m", "0644", "a", "b"], check.Arguments); + } + [Fact] public void FindSystemExecutable_IgnoresPath() { diff --git a/DevProxy/Proxy/LinuxCertificateHelper.cs b/DevProxy/Proxy/LinuxCertificateHelper.cs index 5978477b1..387da97ae 100644 --- a/DevProxy/Proxy/LinuxCertificateHelper.cs +++ b/DevProxy/Proxy/LinuxCertificateHelper.cs @@ -199,14 +199,6 @@ internal static bool ShouldSkipTrust(bool isRoot, bool isInteractive, Func internal static LinuxTrustResult TrustCertificate(X509Certificate2 certificate, LinuxTrustStore store, ILogger logger) { - var (isRoot, isInteractive) = GetContext(); - if (ShouldSkipTrust(isRoot, isInteractive, CanSudoWithoutPassword)) - { - logger.LogWarning( - "Skipped trusting the Dev Proxy root certificate because sudo needs a password and there's no terminal to ask for it. To trust the certificate, run 'devproxy cert ensure' in an interactive terminal."); - return LinuxTrustResult.Skipped; - } - var pemFilePath = ExportCertificateToPem(certificate, logger); if (pemFilePath is null) { @@ -215,14 +207,23 @@ internal static LinuxTrustResult TrustCertificate(X509Certificate2 certificate, try { + var (isRoot, isInteractive) = GetContext(); + var removeLegacy = FileContainsCertificate(store.LegacyCertificatePath, certificate); + var commands = GetTrustCommands(store, pemFilePath, removeLegacy); + if (ShouldSkipTrust(isRoot, isInteractive, () => commands.All(CanSudoWithoutPassword))) + { + logger.LogWarning( + "Skipped trusting the Dev Proxy root certificate because sudo needs a password and there's no terminal to ask for it. To trust the certificate, run 'devproxy cert ensure' in an interactive terminal."); + return LinuxTrustResult.Skipped; + } + logger.LogInformation( "Adding the Dev Proxy root certificate to the {Distro} system certificate store ({Path}).{SudoHint}", store.Name, store.CertificatePath, isRoot ? string.Empty : " You might be asked for your password."); - var removeLegacy = FileContainsCertificate(store.LegacyCertificatePath, certificate); - foreach (var command in GetTrustCommands(store, pemFilePath, removeLegacy)) + foreach (var command in commands) { if (!RunElevated(command, isRoot, isInteractive, logger)) { @@ -335,8 +336,13 @@ private static (bool IsRoot, bool IsInteractive) GetContext() => (Environment.UserName == "root", !Console.IsInputRedirected && Environment.GetEnvironmentVariable("CI") is null); - private static bool CanSudoWithoutPassword() => - Run(new("sudo", ["-n", "true"]), NullLogger.Instance, redirectOutput: true, logFailure: false); + // 'sudo -n -l ' checks that sudo allows this exact command without a password, + // without running it, so command-scoped sudoers grants are honored. + internal static LinuxCommand GetSudoCheckCommand(LinuxCommand command) => + new("sudo", ["-n", "-l", "--", command.FileName, .. command.Arguments]); + + private static bool CanSudoWithoutPassword(LinuxCommand command) => + Run(GetSudoCheckCommand(command), NullLogger.Instance, redirectOutput: true, logFailure: false); private static bool RunElevated(LinuxCommand command, bool isRoot, bool isInteractive, ILogger logger) { From 3ecf9d45349230c5b2fe8e939bc4213b33cc766d Mon Sep 17 00:00:00 2001 From: waldekmastykarz Date: Sat, 31 Oct 2026 14:39:18 +0100 Subject: [PATCH 06/12] Remove Linux NSS trust from both databases and save the cert on unknown distros Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- DevProxy.Tests/LinuxCertificateHelperTests.cs | 10 +++++ DevProxy/Proxy/LinuxCertificateHelper.cs | 41 ++++++++++++++++--- DevProxy/Proxy/RootCertificateTrust.cs | 2 +- skills/dev-proxy/references/installation.md | 4 +- 4 files changed, 48 insertions(+), 9 deletions(-) diff --git a/DevProxy.Tests/LinuxCertificateHelperTests.cs b/DevProxy.Tests/LinuxCertificateHelperTests.cs index 81830ea96..44b05e628 100644 --- a/DevProxy.Tests/LinuxCertificateHelperTests.cs +++ b/DevProxy.Tests/LinuxCertificateHelperTests.cs @@ -169,6 +169,16 @@ public void GetSudoCheckCommand_ListsExactCommandWithoutPrompt() Assert.Equal(["-n", "-l", "--", "install", "-m", "0644", "a", "b"], check.Arguments); } + [Fact] + public void GetNssDatabaseDirectoriesForRemoval_ReturnsEveryExistingDatabase() + { + Assert.Equal(["/home/u/.pki/nssdb", "/home/u/.local/share/pki/nssdb"], + LinuxCertificateHelper.GetNssDatabaseDirectoriesForRemoval("/home/u", Directories("/home/u/.pki/nssdb", "/home/u/.local/share/pki/nssdb"))); + Assert.Equal(["/home/u/.local/share/pki/nssdb"], + LinuxCertificateHelper.GetNssDatabaseDirectoriesForRemoval("/home/u", Directories("/home/u/.local/share/pki/nssdb"))); + Assert.Empty(LinuxCertificateHelper.GetNssDatabaseDirectoriesForRemoval("/home/u", Directories())); + } + [Fact] public void FindSystemExecutable_IgnoresPath() { diff --git a/DevProxy/Proxy/LinuxCertificateHelper.cs b/DevProxy/Proxy/LinuxCertificateHelper.cs index 387da97ae..be3b07b1e 100644 --- a/DevProxy/Proxy/LinuxCertificateHelper.cs +++ b/DevProxy/Proxy/LinuxCertificateHelper.cs @@ -314,22 +314,51 @@ private static void TrustInNssDatabase(string pemFilePath, ILogger logger) private static void RemoveFromNssDatabase(ILogger logger) { var certutil = FindCertutil(); - var nssDir = GetNssDatabaseDirectory(); - if (certutil is null || !Directory.Exists(nssDir)) + if (certutil is null) { return; } - if (Run(new(certutil, ["-D", "-d", $"sql:{nssDir}", "-n", NssNickname]), logger, redirectOutput: true, logFailure: false)) + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + foreach (var nssDir in GetNssDatabaseDirectoriesForRemoval(home, Directory.Exists)) { - logger.LogInformation("Certificate removed from the NSS database ({Path}).", nssDir); + if (Run(new(certutil, ["-D", "-d", $"sql:{nssDir}", "-n", NssNickname]), logger, redirectOutput: true, logFailure: false)) + { + logger.LogInformation("Certificate removed from the NSS database ({Path}).", nssDir); + } } } - internal static void LogManualInstructions(ILogger logger) + /// + /// The active NSS database can change (e.g. ~/.pki/nssdb is created after the certificate was + /// added to ~/.local/share/pki/nssdb), so removal checks every database that exists. + /// + internal static IEnumerable GetNssDatabaseDirectoriesForRemoval(string home, Func dirExists) => + new[] { $"{home}/.pki/nssdb", $"{home}/.local/share/pki/nssdb" }.Where(dirExists); + + /// + /// Writes the public root certificate to devproxy-ca.crt in the current directory so the user + /// can add it to their CA anchors, regardless of where the PFX is stored. + /// + internal static void WriteManualTrustInstructions(X509Certificate2 certificate, ILogger logger) { + const string help = "More information: https://aka.ms/devproxy/start"; + var path = Path.GetFullPath(CertificateFileName); + try + { + File.WriteAllText(path, certificate.ExportCertificatePem() + "\n"); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + logger.LogWarning( + "Couldn't detect your distro's certificate store, so trust the Dev Proxy root certificate manually. Couldn't save it to {Path}: {Error}. {Help}", + path, ex.Message, help); + return; + } + logger.LogWarning( - "Couldn't detect your distro's certificate store. Trust the Dev Proxy root certificate manually: export it with 'openssl pkcs12 -in ~/.config/dev-proxy/rootCert.pfx -clcerts -nokeys -out devproxy-ca.crt -passin pass:', add devproxy-ca.crt to your system's CA anchors and refresh the store. More information: https://aka.ms/devproxy/start"); + "Couldn't detect your distro's certificate store. Saved the Dev Proxy root certificate to {Path}. Add it to your system's CA anchors and refresh the store. {Help}", + path, help); } private static (bool IsRoot, bool IsInteractive) GetContext() => diff --git a/DevProxy/Proxy/RootCertificateTrust.cs b/DevProxy/Proxy/RootCertificateTrust.cs index 32bd9026b..bc97fe162 100644 --- a/DevProxy/Proxy/RootCertificateTrust.cs +++ b/DevProxy/Proxy/RootCertificateTrust.cs @@ -96,7 +96,7 @@ public void Trust(X509Certificate2 rootCertificate) var store = LinuxCertificateHelper.DetectTrustStore(); if (store is null) { - LinuxCertificateHelper.LogManualInstructions(logger); + LinuxCertificateHelper.WriteManualTrustInstructions(rootCertificate, logger); return; } switch (LinuxCertificateHelper.TrustCertificate(rootCertificate, store, logger)) diff --git a/skills/dev-proxy/references/installation.md b/skills/dev-proxy/references/installation.md index 88a438af4..8d4a984cc 100644 --- a/skills/dev-proxy/references/installation.md +++ b/skills/dev-proxy/references/installation.md @@ -108,9 +108,9 @@ Trust the certificate (requires sudo, so Dev Proxy doesn't do it automatically o devproxy cert ensure ``` -This adds the certificate to the system certificate store on Debian/Ubuntu (`update-ca-certificates`), Fedora/RHEL and Arch (`update-ca-trust`). If `certutil` is installed (`libnss3-tools` on Debian/Ubuntu, `nss-tools` on Fedora/RHEL, `nss` on Arch), it also adds it to the NSS database used by Chrome (`~/.pki/nssdb` if it exists, otherwise `~/.local/share/pki/nssdb`). Without a terminal, if sudo needs a password, `cert ensure` creates the certificate but skips trusting it and shows a warning. To undo, run `devproxy cert remove`. +This adds the certificate to the system certificate store on Debian/Ubuntu (`update-ca-certificates`), Fedora/RHEL and Arch (`update-ca-trust`). If `certutil` is installed (`libnss3-tools` on Debian/Ubuntu, `nss-tools` on Fedora/RHEL, `nss` on Arch), it also adds it to the NSS database used by Chrome (`~/.pki/nssdb` if it exists, otherwise `~/.local/share/pki/nssdb`). Without a terminal, if sudo needs a password, `cert ensure` creates the certificate but skips trusting it and shows a warning. To undo, run `devproxy cert remove`, which also removes the certificate from both NSS databases. -On other distributions, trust the certificate manually. For example, on Ubuntu: +On other distributions, `cert ensure` saves the public certificate to `devproxy-ca.crt` in the current directory; add it to your system's CA anchors. To do it fully by hand, for example on Ubuntu: ```bash # Export Dev Proxy root certificate From b5bcf472c75ac7c9dc64cf5387687f48d2269d35 Mon Sep 17 00:00:00 2001 From: waldekmastykarz Date: Sat, 31 Oct 2026 14:46:01 +0100 Subject: [PATCH 07/12] Report Linux NSS removal failures instead of skipping silently Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- DevProxy/Proxy/LinuxCertificateHelper.cs | 25 +++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/DevProxy/Proxy/LinuxCertificateHelper.cs b/DevProxy/Proxy/LinuxCertificateHelper.cs index be3b07b1e..f1274c9fe 100644 --- a/DevProxy/Proxy/LinuxCertificateHelper.cs +++ b/DevProxy/Proxy/LinuxCertificateHelper.cs @@ -313,19 +313,38 @@ private static void TrustInNssDatabase(string pemFilePath, ILogger logger) private static void RemoveFromNssDatabase(ILogger logger) { + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + var nssDirs = GetNssDatabaseDirectoriesForRemoval(home, Directory.Exists).ToArray(); + if (nssDirs.Length == 0) + { + return; + } + var certutil = FindCertutil(); if (certutil is null) { + logger.LogWarning( + "Couldn't find certutil to check the NSS database ({Paths}). If you added the Dev Proxy root certificate there, remove '{Nickname}' manually or install certutil and run 'devproxy cert remove' again.", + string.Join(", ", nssDirs), NssNickname); return; } - var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); - foreach (var nssDir in GetNssDatabaseDirectoriesForRemoval(home, Directory.Exists)) + foreach (var nssDir in nssDirs) { - if (Run(new(certutil, ["-D", "-d", $"sql:{nssDir}", "-n", NssNickname]), logger, redirectOutput: true, logFailure: false)) + var db = $"sql:{nssDir}"; + // Only attempt (and report failures of) removal when the certificate is there. + if (!Run(new(certutil, ["-L", "-d", db, "-n", NssNickname]), logger, redirectOutput: true, logFailure: false)) + { + continue; + } + if (Run(new(certutil, ["-D", "-d", db, "-n", NssNickname]), logger, redirectOutput: true)) { logger.LogInformation("Certificate removed from the NSS database ({Path}).", nssDir); } + else + { + logger.LogError("Couldn't remove the Dev Proxy root certificate from the NSS database ({Path}). Remove '{Nickname}' manually.", nssDir, NssNickname); + } } } From d500e05595d68e06068eeceac290299f587a3bb7 Mon Sep 17 00:00:00 2001 From: waldekmastykarz Date: Sat, 31 Oct 2026 15:10:46 +0100 Subject: [PATCH 08/12] Handle Linux sudo password and NSS failures during cert trust - Run trust commands with sudo -n and skip (rolling back the anchor) when sudo needs a password, instead of probing with sudo -l - Fail cert remove when the system store can't be updated - Warn instead of reporting success when the NSS step fails, and remove any untrusted leftover entry Refs #601 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- DevProxy.Tests/LinuxCertificateHelperTests.cs | 125 ++++++++-- DevProxy/Proxy/LinuxCertificateHelper.cs | 236 +++++++++++++----- DevProxy/Proxy/RootCertificateTrust.cs | 6 +- skills/dev-proxy/references/installation.md | 2 +- 4 files changed, 286 insertions(+), 83 deletions(-) diff --git a/DevProxy.Tests/LinuxCertificateHelperTests.cs b/DevProxy.Tests/LinuxCertificateHelperTests.cs index 44b05e628..c123be8a2 100644 --- a/DevProxy.Tests/LinuxCertificateHelperTests.cs +++ b/DevProxy.Tests/LinuxCertificateHelperTests.cs @@ -146,27 +146,124 @@ public void GetNssDatabaseDirectory_DefaultsToXdgLocation() LinuxCertificateHelper.GetNssDatabaseDirectory("/home/u", Directories())); } - [Theory] - [InlineData(false, false, false, true)] - [InlineData(false, false, true, false)] - [InlineData(false, true, false, false)] - [InlineData(true, false, false, false)] - public void ShouldSkipTrust_OnlyWhenSudoCantRunWithoutPrompt(bool isRoot, bool isInteractive, bool canSudo, bool expected) + private static readonly LinuxCommand _install = new("install", ["a", "b"]); + private static readonly LinuxCommand _refresh = new("update-ca-certificates", []); + private static readonly LinuxCommand _rollback = new("rm", ["-f", "b"]); + + private static Func Outcomes(List ran, params (LinuxCommand Command, CommandOutcome Outcome)[] outcomes) => + c => + { + ran.Add(c); + foreach (var (command, outcome) in outcomes) + { + if (command == c) + { + return outcome; + } + } + return CommandOutcome.Succeeded; + }; + + [Fact] + public void TrustSystemStore_AllSucceed_Trusted() + { + List ran = []; + + var outcome = LinuxCertificateHelper.TrustSystemStore([_install, _refresh], _rollback, Outcomes(ran)); + + Assert.Equal(SystemTrustOutcome.Trusted, outcome); + Assert.Equal([_install, _refresh], ran); + } + + [Fact] + public void TrustSystemStore_FirstNeedsPassword_SkipsWithoutChanges() + { + List ran = []; + + var outcome = LinuxCertificateHelper.TrustSystemStore( + [_install, _refresh], _rollback, Outcomes(ran, (_install, CommandOutcome.PasswordRequired))); + + Assert.Equal(SystemTrustOutcome.Skipped, outcome); + Assert.Equal([_install], ran); + } + + [Fact] + public void TrustSystemStore_RefreshNeedsPassword_RemovesAnchorAndSkips() + { + List ran = []; + + var outcome = LinuxCertificateHelper.TrustSystemStore( + [_install, _refresh], _rollback, Outcomes(ran, (_refresh, CommandOutcome.PasswordRequired))); + + Assert.Equal(SystemTrustOutcome.Skipped, outcome); + Assert.Equal([_install, _refresh, _rollback], ran); + } + + [Fact] + public void TrustSystemStore_RollbackNeedsPassword_ReportsAnchorLeft() { - var probed = false; - var skip = LinuxCertificateHelper.ShouldSkipTrust(isRoot, isInteractive, () => { probed = true; return canSudo; }); + List ran = []; - Assert.Equal(expected, skip); - Assert.Equal(!isRoot && !isInteractive, probed); + var outcome = LinuxCertificateHelper.TrustSystemStore( + [_install, _refresh], _rollback, + Outcomes(ran, (_refresh, CommandOutcome.PasswordRequired), (_rollback, CommandOutcome.PasswordRequired))); + + Assert.Equal(SystemTrustOutcome.SkippedAnchorLeft, outcome); + } + + [Fact] + public void TrustSystemStore_NothingToUndo_SkipsWithoutRollback() + { + List ran = []; + + var outcome = LinuxCertificateHelper.TrustSystemStore( + [_install, _refresh], rollback: null, Outcomes(ran, (_refresh, CommandOutcome.PasswordRequired))); + + Assert.Equal(SystemTrustOutcome.Skipped, outcome); + Assert.Equal([_install, _refresh], ran); + } + + [Fact] + public void TrustSystemStore_OtherFailure_Fails() + { + List ran = []; + + var outcome = LinuxCertificateHelper.TrustSystemStore( + [_install, _refresh], _rollback, Outcomes(ran, (_refresh, CommandOutcome.Failed))); + + Assert.Equal(SystemTrustOutcome.Failed, outcome); + Assert.Equal([_install, _refresh], ran); + } + + [Fact] + public void RunAll_StopsAtFirstFailureAndReportsIt() + { + List ran = []; + + var outcome = LinuxCertificateHelper.RunAll( + [_rollback, _refresh, _install], Outcomes(ran, (_refresh, CommandOutcome.PasswordRequired))); + + Assert.Equal(CommandOutcome.PasswordRequired, outcome); + Assert.Equal([_rollback, _refresh], ran); + } + + [Theory] + [InlineData("sudo: a password is required", true)] + [InlineData("rm: cannot remove 'x': Read-only file system", false)] + [InlineData("", false)] + public void IsPasswordRequired_DetectsSudoMessage(string stderr, bool expected) + { + Assert.Equal(expected, LinuxCertificateHelper.IsPasswordRequired(stderr)); } [Fact] - public void GetSudoCheckCommand_ListsExactCommandWithoutPrompt() + public void FormatToolError_JoinsLinesAndDropsTrailingPeriods() { - var check = LinuxCertificateHelper.GetSudoCheckCommand(new("install", ["-m", "0644", "a", "b"])); + var error = "Error opening input terminal for read\ncertutil: could not change trust: SEC_ERROR_TOKEN_NOT_LOGGED_IN: The token is not logged in..\n"; - Assert.Equal("sudo", check.FileName); - Assert.Equal(["-n", "-l", "--", "install", "-m", "0644", "a", "b"], check.Arguments); + Assert.Equal( + "Error opening input terminal for read certutil: could not change trust: SEC_ERROR_TOKEN_NOT_LOGGED_IN: The token is not logged in", + LinuxCertificateHelper.FormatToolError(error)); } [Fact] diff --git a/DevProxy/Proxy/LinuxCertificateHelper.cs b/DevProxy/Proxy/LinuxCertificateHelper.cs index f1274c9fe..7d55e7a42 100644 --- a/DevProxy/Proxy/LinuxCertificateHelper.cs +++ b/DevProxy/Proxy/LinuxCertificateHelper.cs @@ -4,7 +4,6 @@ using System.Diagnostics; using System.Security.Cryptography.X509Certificates; -using Microsoft.Extensions.Logging.Abstractions; namespace DevProxy.Proxy; @@ -27,10 +26,29 @@ internal sealed record LinuxTrustStore( internal enum LinuxTrustResult { Trusted, + /// The system store trusts the certificate, but NSS-based apps don't. + TrustedSystemOnly, Skipped, Failed, } +internal enum CommandOutcome +{ + Succeeded, + /// sudo needed a password and couldn't prompt for it. + PasswordRequired, + Failed, +} + +internal enum SystemTrustOutcome +{ + Trusted, + Skipped, + /// Skipped, but the anchor file was added and couldn't be removed again. + SkippedAnchorLeft, + Failed, +} + internal sealed record LinuxCommand(string FileName, IReadOnlyList Arguments) { public override string ToString() => string.Join(' ', [FileName, .. Arguments]); @@ -189,11 +207,50 @@ internal static string GetNssDatabaseDirectory(string home, Func d } /// - /// Without a terminal, sudo can't prompt for a password. If it also isn't allowed to run - /// without one (e.g. a restricted sudoers grant), skip trusting instead of failing. + /// Runs the trust commands in order. If sudo needs a password it can't prompt for, nothing + /// is trusted: when the anchor was already installed, it's removed again with + /// (null when there's nothing to undo). /// - internal static bool ShouldSkipTrust(bool isRoot, bool isInteractive, Func canSudoWithoutPassword) => - !isRoot && !isInteractive && !canSudoWithoutPassword(); + internal static SystemTrustOutcome TrustSystemStore( + IReadOnlyList commands, LinuxCommand? rollback, Func run) + { + for (var i = 0; i < commands.Count; i++) + { + switch (run(commands[i])) + { + case CommandOutcome.Succeeded: + continue; + case CommandOutcome.PasswordRequired: + if (i == 0 || rollback is null) + { + return SystemTrustOutcome.Skipped; + } + return run(rollback) == CommandOutcome.Succeeded + ? SystemTrustOutcome.Skipped + : SystemTrustOutcome.SkippedAnchorLeft; + case CommandOutcome.Failed: + default: + return SystemTrustOutcome.Failed; + } + } + return SystemTrustOutcome.Trusted; + } + + internal static CommandOutcome RunAll(IEnumerable commands, Func run) + { + foreach (var command in commands) + { + var outcome = run(command); + if (outcome != CommandOutcome.Succeeded) + { + return outcome; + } + } + return CommandOutcome.Succeeded; + } + + internal static bool IsPasswordRequired(string stderr) => + stderr.Contains("a password is required", StringComparison.OrdinalIgnoreCase); internal static LinuxTrustStore? DetectTrustStore() => DetectTrustStore(Directory.Exists, FindSystemExecutable); @@ -210,12 +267,10 @@ internal static LinuxTrustResult TrustCertificate(X509Certificate2 certificate, var (isRoot, isInteractive) = GetContext(); var removeLegacy = FileContainsCertificate(store.LegacyCertificatePath, certificate); var commands = GetTrustCommands(store, pemFilePath, removeLegacy); - if (ShouldSkipTrust(isRoot, isInteractive, () => commands.All(CanSudoWithoutPassword))) - { - logger.LogWarning( - "Skipped trusting the Dev Proxy root certificate because sudo needs a password and there's no terminal to ask for it. To trust the certificate, run 'devproxy cert ensure' in an interactive terminal."); - return LinuxTrustResult.Skipped; - } + // Undo the anchor install only if it changed something. + LinuxCommand? rollback = FileContainsCertificate(store.CertificatePath, certificate) + ? null + : new("rm", ["-f", store.CertificatePath]); logger.LogInformation( "Adding the Dev Proxy root certificate to the {Distro} system certificate store ({Path}).{SudoHint}", @@ -223,21 +278,28 @@ internal static LinuxTrustResult TrustCertificate(X509Certificate2 certificate, store.CertificatePath, isRoot ? string.Empty : " You might be asked for your password."); - foreach (var command in commands) + switch (TrustSystemStore(commands, rollback, c => RunElevated(c, isRoot, isInteractive, logger))) { - if (!RunElevated(command, isRoot, isInteractive, logger)) - { - if (!isRoot && !isInteractive) - { - logger.LogError("If sudo requires a password, run 'devproxy cert ensure' in an interactive terminal."); - } + case SystemTrustOutcome.Skipped: + logger.LogWarning( + "Skipped trusting the Dev Proxy root certificate because sudo needs a password and there's no terminal to ask for it. To trust the certificate, run 'devproxy cert ensure' in an interactive terminal."); + return LinuxTrustResult.Skipped; + case SystemTrustOutcome.SkippedAnchorLeft: + logger.LogWarning( + "Skipped trusting the Dev Proxy root certificate because sudo needs a password and there's no terminal to ask for it. The certificate file {Path} was added but isn't active, and couldn't be removed. To finish trusting it, run 'devproxy cert ensure' in an interactive terminal. To remove it, run 'sudo rm {Path}'.", + store.CertificatePath, store.CertificatePath); + return LinuxTrustResult.Skipped; + case SystemTrustOutcome.Failed: return LinuxTrustResult.Failed; - } + case SystemTrustOutcome.Trusted: + default: + break; } logger.LogInformation("Certificate added to the system certificate store."); - TrustInNssDatabase(pemFilePath, logger); - return LinuxTrustResult.Trusted; + return TrustInNssDatabase(pemFilePath, logger) + ? LinuxTrustResult.Trusted + : LinuxTrustResult.TrustedSystemOnly; } finally { @@ -245,8 +307,10 @@ internal static LinuxTrustResult TrustCertificate(X509Certificate2 certificate, } } - internal static void RemoveTrustedCertificate(X509Certificate2 certificate, ILogger logger) + /// Returns false when the system store still trusts the certificate. + internal static bool RemoveTrustedCertificate(X509Certificate2 certificate, ILogger logger) { + var removed = true; var store = DetectTrustStore(); var removeLegacy = store is not null && FileContainsCertificate(store.LegacyCertificatePath, certificate); if (store is null) @@ -264,50 +328,79 @@ internal static void RemoveTrustedCertificate(X509Certificate2 certificate, ILog store.CertificatePath, isRoot ? string.Empty : " You might be asked for your password."); - var removed = true; - foreach (var command in GetUntrustCommands(store, removeLegacy)) + var outcome = RunAll(GetUntrustCommands(store, removeLegacy), c => RunElevated(c, isRoot, isInteractive, logger)); + if (outcome == CommandOutcome.Succeeded) { - if (!RunElevated(command, isRoot, isInteractive, logger)) - { - removed = false; - break; - } + logger.LogInformation("Certificate removed from the system certificate store."); } - if (removed) + else { - logger.LogInformation("Certificate removed from the system certificate store."); + removed = false; + logger.LogError( + "The system certificate store still trusts the Dev Proxy root certificate{Reason}. To finish removing it, run 'devproxy cert remove' in an interactive terminal.", + outcome == CommandOutcome.PasswordRequired ? " because sudo needs a password and there's no terminal to ask for it" : string.Empty); } } RemoveFromNssDatabase(logger); + return removed; } - private static void TrustInNssDatabase(string pemFilePath, ILogger logger) + /// Returns false when certutil is available but couldn't add the certificate. + private static bool TrustInNssDatabase(string pemFilePath, ILogger logger) { var certutil = FindCertutil(); if (certutil is null) { logger.LogInformation( "To have Chrome and other NSS-based apps trust the certificate, install certutil (libnss3-tools on Debian/Ubuntu, nss-tools on Fedora/RHEL, nss on Arch) and run 'devproxy cert ensure' again."); - return; + return true; } var nssDir = GetNssDatabaseDirectory(); var db = $"sql:{nssDir}"; + string error; if (!Directory.Exists(nssDir)) { _ = Directory.CreateDirectory(nssDir); - if (!Run(new(certutil, ["-N", "-d", db, "--empty-password"]), logger, redirectOutput: true)) + error = Execute(new(certutil, ["-N", "-d", db, "--empty-password"]), redirectOutput: true).Error; + if (error.Length > 0) { - return; + LogNssTrustFailure(logger, nssDir, error, leftoverEntry: false); + return false; } } // Replace any previous Dev Proxy root (it's regenerated per machine) so re-runs are idempotent. _ = Run(new(certutil, ["-D", "-d", db, "-n", NssNickname]), logger, redirectOutput: true, logFailure: false); - if (Run(new(certutil, ["-A", "-d", db, "-n", NssNickname, "-t", "C,,", "-i", pemFilePath]), logger, redirectOutput: true)) + error = Execute(new(certutil, ["-A", "-d", db, "-n", NssNickname, "-t", "C,,", "-i", pemFilePath]), redirectOutput: true).Error; + if (error.Length == 0) { logger.LogInformation("Certificate added to the NSS database ({Path}).", nssDir); + return true; + } + + // A failed add can leave an entry without trust flags behind; try to remove it. + var leftoverEntry = + Run(new(certutil, ["-L", "-d", db, "-n", NssNickname]), logger, redirectOutput: true, logFailure: false) && + !Run(new(certutil, ["-D", "-d", db, "-n", NssNickname]), logger, redirectOutput: true, logFailure: false); + LogNssTrustFailure(logger, nssDir, error, leftoverEntry); + return false; + } + + internal static string FormatToolError(string error) => + string.Join(' ', error.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)).TrimEnd('.'); + + private static void LogNssTrustFailure(ILogger logger, string nssDir, string error, bool leftoverEntry) + { + logger.LogWarning( + "The system certificate store trusts the Dev Proxy root certificate, but Chrome and other NSS-based apps don't: couldn't add it to the NSS database ({Path}): {Error}. If the database has a password, run 'devproxy cert ensure' in an interactive terminal. Otherwise, check that you can write to it.", + nssDir, FormatToolError(error)); + if (leftoverEntry) + { + logger.LogWarning( + "The NSS database still has a '{Nickname}' entry that couldn't be removed. Remove it with: certutil -D -d sql:{Path} -n \"{Nickname}\"", + NssNickname, nssDir, NssNickname); } } @@ -384,23 +477,28 @@ private static (bool IsRoot, bool IsInteractive) GetContext() => (Environment.UserName == "root", !Console.IsInputRedirected && Environment.GetEnvironmentVariable("CI") is null); - // 'sudo -n -l ' checks that sudo allows this exact command without a password, - // without running it, so command-scoped sudoers grants are honored. - internal static LinuxCommand GetSudoCheckCommand(LinuxCommand command) => - new("sudo", ["-n", "-l", "--", command.FileName, .. command.Arguments]); - - private static bool CanSudoWithoutPassword(LinuxCommand command) => - Run(GetSudoCheckCommand(command), NullLogger.Instance, redirectOutput: true, logFailure: false); - - private static bool RunElevated(LinuxCommand command, bool isRoot, bool isInteractive, ILogger logger) + private static CommandOutcome RunElevated(LinuxCommand command, bool isRoot, bool isInteractive, ILogger logger) { var elevated = Elevate(command, isRoot, isInteractive); if (elevated is null) { logger.LogError("Couldn't find {Command} in {Directories}.", command.FileName, string.Join(", ", _systemDirectories)); - return false; + return CommandOutcome.Failed; + } + + // Without a terminal (sudo -n), capture stderr to tell a missing password from other failures. + var captureError = !isRoot && !isInteractive; + var (succeeded, error) = Execute(elevated, redirectOutput: captureError); + if (succeeded) + { + return CommandOutcome.Succeeded; + } + if (captureError && IsPasswordRequired(error)) + { + return CommandOutcome.PasswordRequired; } - return Run(elevated, logger, redirectOutput: false); + logger.LogError("Command '{Command}' failed{Error}", elevated, error.Length > 0 ? $": {error}" : "."); + return CommandOutcome.Failed; } // When running as root, don't pick up certutil from PATH, which might be user-writable. @@ -429,6 +527,20 @@ private static bool RunElevated(LinuxCommand command, bool isRoot, bool isIntera } private static bool Run(LinuxCommand command, ILogger logger, bool redirectOutput, bool logFailure = true) + { + var (succeeded, error) = Execute(command, redirectOutput); + if (!succeeded && logFailure) + { + logger.LogError("Command '{Command}' failed{Error}", command, error.Length > 0 ? $": {error}" : "."); + } + return succeeded; + } + + /// + /// Runs the command. On failure, Error has the captured stderr (when redirected), the exit + /// code, or why it couldn't start; it's empty on success. + /// + private static (bool Succeeded, string Error) Execute(LinuxCommand command, bool redirectOutput) { var startInfo = new ProcessStartInfo { @@ -442,6 +554,11 @@ private static bool Run(LinuxCommand command, ILogger logger, bool redirectOutpu { startInfo.ArgumentList.Add(argument); } + if (redirectOutput) + { + // Untranslated messages, so failures like sudo's "a password is required" can be detected. + startInfo.Environment["LC_ALL"] = "C"; + } try { @@ -459,31 +576,16 @@ private static bool Run(LinuxCommand command, ILogger logger, bool redirectOutpu } process.WaitForExit(); - if (process.ExitCode != 0) + if (process.ExitCode == 0) { - if (logFailure) - { - var error = stderr.ToString().Trim(); - if (error.Length > 0) - { - logger.LogError("Command '{Command}' failed with exit code {ExitCode}: {Error}", command, process.ExitCode, error); - } - else - { - logger.LogError("Command '{Command}' failed with exit code {ExitCode}.", command, process.ExitCode); - } - } - return false; + return (true, string.Empty); } - return true; + var error = stderr.ToString().Trim(); + return (false, error.Length > 0 ? error : $"exit code {process.ExitCode}"); } catch (Exception ex) when (ex is System.ComponentModel.Win32Exception or InvalidOperationException) { - if (logFailure) - { - logger.LogError(ex, "Couldn't run '{Command}'", command); - } - return false; + return (false, ex.Message); } } diff --git a/DevProxy/Proxy/RootCertificateTrust.cs b/DevProxy/Proxy/RootCertificateTrust.cs index bc97fe162..3a8983ba3 100644 --- a/DevProxy/Proxy/RootCertificateTrust.cs +++ b/DevProxy/Proxy/RootCertificateTrust.cs @@ -106,6 +106,7 @@ public void Trust(X509Certificate2 rootCertificate) case LinuxTrustResult.Trusted: logger.LogInformation("Certificate trusted successfully."); break; + case LinuxTrustResult.TrustedSystemOnly: case LinuxTrustResult.Skipped: default: break; @@ -133,7 +134,10 @@ public void Untrust(X509Certificate2 rootCertificate) } else if (OperatingSystem.IsLinux()) { - LinuxCertificateHelper.RemoveTrustedCertificate(rootCertificate, logger); + if (!LinuxCertificateHelper.RemoveTrustedCertificate(rootCertificate, logger)) + { + throw new InvalidOperationException("Failed to remove the root certificate from the system certificate store."); + } } else { diff --git a/skills/dev-proxy/references/installation.md b/skills/dev-proxy/references/installation.md index 8d4a984cc..4e844e176 100644 --- a/skills/dev-proxy/references/installation.md +++ b/skills/dev-proxy/references/installation.md @@ -108,7 +108,7 @@ Trust the certificate (requires sudo, so Dev Proxy doesn't do it automatically o devproxy cert ensure ``` -This adds the certificate to the system certificate store on Debian/Ubuntu (`update-ca-certificates`), Fedora/RHEL and Arch (`update-ca-trust`). If `certutil` is installed (`libnss3-tools` on Debian/Ubuntu, `nss-tools` on Fedora/RHEL, `nss` on Arch), it also adds it to the NSS database used by Chrome (`~/.pki/nssdb` if it exists, otherwise `~/.local/share/pki/nssdb`). Without a terminal, if sudo needs a password, `cert ensure` creates the certificate but skips trusting it and shows a warning. To undo, run `devproxy cert remove`, which also removes the certificate from both NSS databases. +This adds the certificate to the system certificate store on Debian/Ubuntu (`update-ca-certificates`), Fedora/RHEL and Arch (`update-ca-trust`). If `certutil` is installed (`libnss3-tools` on Debian/Ubuntu, `nss-tools` on Fedora/RHEL, `nss` on Arch), it also adds it to the NSS database used by Chrome (`~/.pki/nssdb` if it exists, otherwise `~/.local/share/pki/nssdb`). Without a terminal, if sudo needs a password, `cert ensure` creates the certificate but skips trusting it and shows a warning. If the system store is trusted but the NSS step fails (for example, the database has a password), it shows a warning. To undo, run `devproxy cert remove`, which also removes the certificate from both NSS databases. If it can't update the system store (for example, sudo needs a password and there's no terminal), it exits with an error. On other distributions, `cert ensure` saves the public certificate to `devproxy-ca.crt` in the current directory; add it to your system's CA anchors. To do it fully by hand, for example on Ubuntu: From f9fd178561c9b1fbe3672ce9c5ef4ef969a291df Mon Sep 17 00:00:00 2001 From: waldekmastykarz Date: Sat, 31 Oct 2026 15:18:19 +0100 Subject: [PATCH 09/12] Harden Linux cert fallback export and NSS error handling - Replace an existing devproxy-ca.crt instead of following a symlink - Warn instead of failing when the NSS directory can't be created - Exit with an error when cert remove can't delete an NSS entry Refs #601 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- DevProxy.Tests/LinuxCertificateHelperTests.cs | 46 +++++++++++++++++++ DevProxy/Proxy/LinuxCertificateHelper.cs | 38 ++++++++++++--- DevProxy/Proxy/RootCertificateTrust.cs | 2 +- skills/dev-proxy/references/installation.md | 2 +- 4 files changed, 79 insertions(+), 9 deletions(-) diff --git a/DevProxy.Tests/LinuxCertificateHelperTests.cs b/DevProxy.Tests/LinuxCertificateHelperTests.cs index c123be8a2..7dfd8ef90 100644 --- a/DevProxy.Tests/LinuxCertificateHelperTests.cs +++ b/DevProxy.Tests/LinuxCertificateHelperTests.cs @@ -266,6 +266,52 @@ public void FormatToolError_JoinsLinesAndDropsTrailingPeriods() LinuxCertificateHelper.FormatToolError(error)); } + [Fact] + public void WriteNewFile_ReplacesSymlinkWithoutTouchingTarget() + { + if (OperatingSystem.IsWindows()) + { + return; + } + + var dir = Directory.CreateTempSubdirectory(); + try + { + var target = Path.Combine(dir.FullName, "target"); + var path = Path.Combine(dir.FullName, "devproxy-ca.crt"); + File.WriteAllText(target, "original"); + _ = File.CreateSymbolicLink(path, target); + + LinuxCertificateHelper.WriteNewFile(path, "cert"); + + Assert.Equal("original", File.ReadAllText(target)); + Assert.Null(new FileInfo(path).LinkTarget); + Assert.Equal("cert", File.ReadAllText(path)); + } + finally + { + dir.Delete(recursive: true); + } + } + + [Fact] + public void WriteNewFile_OverwritesExistingFile() + { + var path = Path.GetTempFileName(); + try + { + File.WriteAllText(path, "old"); + + LinuxCertificateHelper.WriteNewFile(path, "new"); + + Assert.Equal("new", File.ReadAllText(path)); + } + finally + { + File.Delete(path); + } + } + [Fact] public void GetNssDatabaseDirectoriesForRemoval_ReturnsEveryExistingDatabase() { diff --git a/DevProxy/Proxy/LinuxCertificateHelper.cs b/DevProxy/Proxy/LinuxCertificateHelper.cs index 7d55e7a42..a5202f8b9 100644 --- a/DevProxy/Proxy/LinuxCertificateHelper.cs +++ b/DevProxy/Proxy/LinuxCertificateHelper.cs @@ -342,8 +342,8 @@ internal static bool RemoveTrustedCertificate(X509Certificate2 certificate, ILog } } - RemoveFromNssDatabase(logger); - return removed; + // Run NSS removal even if the system store step failed, so it's as complete as possible. + return RemoveFromNssDatabase(logger) && removed; } /// Returns false when certutil is available but couldn't add the certificate. @@ -362,7 +362,15 @@ private static bool TrustInNssDatabase(string pemFilePath, ILogger logger) string error; if (!Directory.Exists(nssDir)) { - _ = Directory.CreateDirectory(nssDir); + try + { + _ = Directory.CreateDirectory(nssDir); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + LogNssTrustFailure(logger, nssDir, ex.Message, leftoverEntry: false); + return false; + } error = Execute(new(certutil, ["-N", "-d", db, "--empty-password"]), redirectOutput: true).Error; if (error.Length > 0) { @@ -404,13 +412,14 @@ private static void LogNssTrustFailure(ILogger logger, string nssDir, string err } } - private static void RemoveFromNssDatabase(ILogger logger) + /// Returns false when certutil found the certificate but couldn't delete it. + private static bool RemoveFromNssDatabase(ILogger logger) { var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); var nssDirs = GetNssDatabaseDirectoriesForRemoval(home, Directory.Exists).ToArray(); if (nssDirs.Length == 0) { - return; + return true; } var certutil = FindCertutil(); @@ -419,9 +428,10 @@ private static void RemoveFromNssDatabase(ILogger logger) logger.LogWarning( "Couldn't find certutil to check the NSS database ({Paths}). If you added the Dev Proxy root certificate there, remove '{Nickname}' manually or install certutil and run 'devproxy cert remove' again.", string.Join(", ", nssDirs), NssNickname); - return; + return true; } + var removed = true; foreach (var nssDir in nssDirs) { var db = $"sql:{nssDir}"; @@ -436,9 +446,11 @@ private static void RemoveFromNssDatabase(ILogger logger) } else { + removed = false; logger.LogError("Couldn't remove the Dev Proxy root certificate from the NSS database ({Path}). Remove '{Nickname}' manually.", nssDir, NssNickname); } } + return removed; } /// @@ -448,6 +460,18 @@ private static void RemoveFromNssDatabase(ILogger logger) internal static IEnumerable GetNssDatabaseDirectoriesForRemoval(string home, Func dirExists) => new[] { $"{home}/.pki/nssdb", $"{home}/.local/share/pki/nssdb" }.Where(dirExists); + /// + /// Replaces rather than follows an existing entry: deleting a symlink doesn't touch its target, + /// and CreateNew fails if something is recreated at the path in between. + /// + internal static void WriteNewFile(string path, string contents) + { + File.Delete(path); + using var stream = new FileStream(path, FileMode.CreateNew, FileAccess.Write); + using var writer = new StreamWriter(stream); + writer.Write(contents); + } + /// /// Writes the public root certificate to devproxy-ca.crt in the current directory so the user /// can add it to their CA anchors, regardless of where the PFX is stored. @@ -458,7 +482,7 @@ internal static void WriteManualTrustInstructions(X509Certificate2 certificate, var path = Path.GetFullPath(CertificateFileName); try { - File.WriteAllText(path, certificate.ExportCertificatePem() + "\n"); + WriteNewFile(path, certificate.ExportCertificatePem() + "\n"); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { diff --git a/DevProxy/Proxy/RootCertificateTrust.cs b/DevProxy/Proxy/RootCertificateTrust.cs index 3a8983ba3..138263988 100644 --- a/DevProxy/Proxy/RootCertificateTrust.cs +++ b/DevProxy/Proxy/RootCertificateTrust.cs @@ -136,7 +136,7 @@ public void Untrust(X509Certificate2 rootCertificate) { if (!LinuxCertificateHelper.RemoveTrustedCertificate(rootCertificate, logger)) { - throw new InvalidOperationException("Failed to remove the root certificate from the system certificate store."); + throw new InvalidOperationException("Failed to fully remove the root certificate from the system certificate store and NSS databases."); } } else diff --git a/skills/dev-proxy/references/installation.md b/skills/dev-proxy/references/installation.md index 4e844e176..c3e458aec 100644 --- a/skills/dev-proxy/references/installation.md +++ b/skills/dev-proxy/references/installation.md @@ -108,7 +108,7 @@ Trust the certificate (requires sudo, so Dev Proxy doesn't do it automatically o devproxy cert ensure ``` -This adds the certificate to the system certificate store on Debian/Ubuntu (`update-ca-certificates`), Fedora/RHEL and Arch (`update-ca-trust`). If `certutil` is installed (`libnss3-tools` on Debian/Ubuntu, `nss-tools` on Fedora/RHEL, `nss` on Arch), it also adds it to the NSS database used by Chrome (`~/.pki/nssdb` if it exists, otherwise `~/.local/share/pki/nssdb`). Without a terminal, if sudo needs a password, `cert ensure` creates the certificate but skips trusting it and shows a warning. If the system store is trusted but the NSS step fails (for example, the database has a password), it shows a warning. To undo, run `devproxy cert remove`, which also removes the certificate from both NSS databases. If it can't update the system store (for example, sudo needs a password and there's no terminal), it exits with an error. +This adds the certificate to the system certificate store on Debian/Ubuntu (`update-ca-certificates`), Fedora/RHEL and Arch (`update-ca-trust`). If `certutil` is installed (`libnss3-tools` on Debian/Ubuntu, `nss-tools` on Fedora/RHEL, `nss` on Arch), it also adds it to the NSS database used by Chrome (`~/.pki/nssdb` if it exists, otherwise `~/.local/share/pki/nssdb`). Without a terminal, if sudo needs a password, `cert ensure` creates the certificate but skips trusting it and shows a warning. If the system store is trusted but the NSS step fails (for example, the database has a password), it shows a warning. To undo, run `devproxy cert remove`, which also removes the certificate from both NSS databases. If it can't update the system store (for example, sudo needs a password and there's no terminal) or delete the certificate from an NSS database, it exits with an error. On other distributions, `cert ensure` saves the public certificate to `devproxy-ca.crt` in the current directory; add it to your system's CA anchors. To do it fully by hand, for example on Ubuntu: From 0a9fa03603cf3e791fc29a1c0c0975599d9b2f81 Mon Sep 17 00:00:00 2001 From: waldekmastykarz Date: Sat, 31 Oct 2026 15:28:34 +0100 Subject: [PATCH 10/12] Preserve existing Linux trust on rollback and verify cert removal - Remove the legacy anchor only after the new anchor is trusted - Restore a replaced anchor's previous contents when trust is skipped - Fail cert remove if the bundle still contains the certificate Refs #601 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- DevProxy.Tests/LinuxCertificateHelperTests.cs | 36 +++++- DevProxy/Proxy/LinuxCertificateHelper.cs | 118 +++++++++++++----- 2 files changed, 118 insertions(+), 36 deletions(-) diff --git a/DevProxy.Tests/LinuxCertificateHelperTests.cs b/DevProxy.Tests/LinuxCertificateHelperTests.cs index 7dfd8ef90..e1065a8a3 100644 --- a/DevProxy.Tests/LinuxCertificateHelperTests.cs +++ b/DevProxy.Tests/LinuxCertificateHelperTests.cs @@ -345,7 +345,7 @@ public void GetTrustCommands_InstallsThenRefreshes() { var store = _fedora; - var commands = LinuxCertificateHelper.GetTrustCommands(store, "/tmp/cert.crt", removeLegacy: false); + var commands = LinuxCertificateHelper.GetTrustCommands(store, "/tmp/cert.crt"); Assert.Equal(2, commands.Count); Assert.Equal("install", commands[0].FileName); @@ -355,12 +355,38 @@ public void GetTrustCommands_InstallsThenRefreshes() } [Fact] - public void GetTrustCommands_RemovesLegacyAnchorBeforeRefresh() + public void GetLegacyCleanupCommands_RemovesLegacyAnchorThenRefreshes() { - var commands = LinuxCertificateHelper.GetTrustCommands(_debian, "/tmp/cert.crt", removeLegacy: true); + var commands = LinuxCertificateHelper.GetLegacyCleanupCommands(_debian); - Assert.Equal(["install", "rm", "/usr/sbin/update-ca-certificates"], commands.Select(c => c.FileName)); - Assert.Equal(["-f", "/usr/local/share/ca-certificates/dev-proxy-ca.crt"], commands[1].Arguments); + Assert.Equal(["rm", "/usr/sbin/update-ca-certificates"], commands.Select(c => c.FileName)); + Assert.Equal(["-f", "/usr/local/share/ca-certificates/dev-proxy-ca.crt"], commands[0].Arguments); + } + + [Fact] + public void GetTrustRollback_NothingWhenAnchorAlreadyHoldsCertificate() + { + Assert.Null(LinuxCertificateHelper.GetTrustRollback(_debian, anchorHoldsCertificate: true, backupPath: null)); + } + + [Fact] + public void GetTrustRollback_RestoresPreviousAnchor() + { + var rollback = LinuxCertificateHelper.GetTrustRollback(_debian, anchorHoldsCertificate: false, backupPath: "/tmp/backup.crt"); + + Assert.NotNull(rollback); + Assert.Equal("install", rollback.FileName); + Assert.Equal(["-D", "-m", "0644", "/tmp/backup.crt", "/usr/local/share/ca-certificates/devproxy-ca.crt"], rollback.Arguments); + } + + [Fact] + public void GetTrustRollback_RemovesNewAnchor() + { + var rollback = LinuxCertificateHelper.GetTrustRollback(_debian, anchorHoldsCertificate: false, backupPath: null); + + Assert.NotNull(rollback); + Assert.Equal("rm", rollback.FileName); + Assert.Equal(["-f", "/usr/local/share/ca-certificates/devproxy-ca.crt"], rollback.Arguments); } [Fact] diff --git a/DevProxy/Proxy/LinuxCertificateHelper.cs b/DevProxy/Proxy/LinuxCertificateHelper.cs index a5202f8b9..c366aebd0 100644 --- a/DevProxy/Proxy/LinuxCertificateHelper.cs +++ b/DevProxy/Proxy/LinuxCertificateHelper.cs @@ -134,16 +134,31 @@ internal static class LinuxCertificateHelper } // install/rm are passed by name so sudo resolves them through its secure_path. - internal static IReadOnlyList GetTrustCommands(LinuxTrustStore store, string pemFilePath, bool removeLegacy) - { - List commands = [new("install", ["-D", "-m", "0644", pemFilePath, store.CertificatePath])]; - if (removeLegacy) - { - commands.Add(new("rm", ["-f", store.LegacyCertificatePath])); - } - commands.Add(new(store.UpdateCommand, store.UpdateArguments)); - return commands; - } + internal static IReadOnlyList GetTrustCommands(LinuxTrustStore store, string pemFilePath) => + [ + new("install", ["-D", "-m", "0644", pemFilePath, store.CertificatePath]), + new(store.UpdateCommand, store.UpdateArguments), + ]; + + /// + /// Runs only after the new anchor is trusted, so a failure here never leaves the certificate + /// untrusted. The refresh drops the legacy anchor's links from the store. + /// + internal static IReadOnlyList GetLegacyCleanupCommands(LinuxTrustStore store) => + [ + new("rm", ["-f", store.LegacyCertificatePath]), + new(store.UpdateCommand, store.UpdateArguments), + ]; + + /// + /// How to undo the anchor install if trust is skipped: nothing when the anchor already held + /// this certificate, restore the previous anchor from when there + /// was one, otherwise remove the new anchor. + /// + internal static LinuxCommand? GetTrustRollback(LinuxTrustStore store, bool anchorHoldsCertificate, string? backupPath) => + anchorHoldsCertificate ? null + : backupPath is not null ? new("install", ["-D", "-m", "0644", backupPath, store.CertificatePath]) + : new("rm", ["-f", store.CertificatePath]); internal static IReadOnlyList GetUntrustCommands(LinuxTrustStore store, bool removeLegacy) { @@ -262,15 +277,17 @@ internal static LinuxTrustResult TrustCertificate(X509Certificate2 certificate, return LinuxTrustResult.Failed; } + string? backupPath = null; try { var (isRoot, isInteractive) = GetContext(); var removeLegacy = FileContainsCertificate(store.LegacyCertificatePath, certificate); - var commands = GetTrustCommands(store, pemFilePath, removeLegacy); - // Undo the anchor install only if it changed something. - LinuxCommand? rollback = FileContainsCertificate(store.CertificatePath, certificate) - ? null - : new("rm", ["-f", store.CertificatePath]); + var anchorHoldsCertificate = FileContainsCertificate(store.CertificatePath, certificate); + if (!anchorHoldsCertificate) + { + backupPath = BackupFile(store.CertificatePath, logger); + } + var rollback = GetTrustRollback(store, anchorHoldsCertificate, backupPath); logger.LogInformation( "Adding the Dev Proxy root certificate to the {Distro} system certificate store ({Path}).{SudoHint}", @@ -278,12 +295,17 @@ internal static LinuxTrustResult TrustCertificate(X509Certificate2 certificate, store.CertificatePath, isRoot ? string.Empty : " You might be asked for your password."); - switch (TrustSystemStore(commands, rollback, c => RunElevated(c, isRoot, isInteractive, logger))) + switch (TrustSystemStore(GetTrustCommands(store, pemFilePath), rollback, c => RunElevated(c, isRoot, isInteractive, logger))) { case SystemTrustOutcome.Skipped: logger.LogWarning( "Skipped trusting the Dev Proxy root certificate because sudo needs a password and there's no terminal to ask for it. To trust the certificate, run 'devproxy cert ensure' in an interactive terminal."); return LinuxTrustResult.Skipped; + case SystemTrustOutcome.SkippedAnchorLeft when backupPath is not null: + logger.LogWarning( + "Skipped trusting the Dev Proxy root certificate because sudo needs a password and there's no terminal to ask for it. The certificate file {Path} was replaced but the change isn't active, and the previous file couldn't be restored. To finish trusting the certificate, run 'devproxy cert ensure' in an interactive terminal.", + store.CertificatePath); + return LinuxTrustResult.Skipped; case SystemTrustOutcome.SkippedAnchorLeft: logger.LogWarning( "Skipped trusting the Dev Proxy root certificate because sudo needs a password and there's no terminal to ask for it. The certificate file {Path} was added but isn't active, and couldn't be removed. To finish trusting it, run 'devproxy cert ensure' in an interactive terminal. To remove it, run 'sudo rm {Path}'.", @@ -297,6 +319,14 @@ internal static LinuxTrustResult TrustCertificate(X509Certificate2 certificate, } logger.LogInformation("Certificate added to the system certificate store."); + if (removeLegacy && + RunAll(GetLegacyCleanupCommands(store), c => RunElevated(c, isRoot, isInteractive, logger)) != CommandOutcome.Succeeded) + { + logger.LogWarning( + "Couldn't remove the old certificate file {Path}. It holds the same certificate, so trust isn't affected. To remove it, run 'sudo rm {Path}' and refresh the system certificate store.", + store.LegacyCertificatePath, store.LegacyCertificatePath); + } + return TrustInNssDatabase(pemFilePath, logger) ? LinuxTrustResult.Trusted : LinuxTrustResult.TrustedSystemOnly; @@ -304,6 +334,10 @@ internal static LinuxTrustResult TrustCertificate(X509Certificate2 certificate, finally { CleanupFile(pemFilePath); + if (backupPath is not null) + { + CleanupFile(backupPath); + } } } @@ -329,7 +363,14 @@ internal static bool RemoveTrustedCertificate(X509Certificate2 certificate, ILog isRoot ? string.Empty : " You might be asked for your password."); var outcome = RunAll(GetUntrustCommands(store, removeLegacy), c => RunElevated(c, isRoot, isInteractive, logger)); - if (outcome == CommandOutcome.Succeeded) + if (outcome == CommandOutcome.Succeeded && FileContainsCertificate(store.BundlePath, certificate)) + { + removed = false; + logger.LogError( + "The system certificate store still trusts the Dev Proxy root certificate: {Bundle} still contains it, so another file in the CA anchors probably holds it. Remove that file and refresh the system certificate store.", + store.BundlePath); + } + else if (outcome == CommandOutcome.Succeeded) { logger.LogInformation("Certificate removed from the system certificate store."); } @@ -618,20 +659,7 @@ private static (bool Succeeded, string Error) Execute(LinuxCommand command, bool try { // Public certificate only; the private key never leaves the PFX. - var pem = certificate.ExportCertificatePem(); - var pemFilePath = Path.Combine(Path.GetTempPath(), $"devproxy-ca-{Guid.NewGuid():N}.crt"); - var options = new FileStreamOptions { Mode = FileMode.CreateNew, Access = FileAccess.Write }; - if (!OperatingSystem.IsWindows()) - { - options.UnixCreateMode = UnixFileMode.UserRead | UnixFileMode.UserWrite; - } - using (var stream = new FileStream(pemFilePath, options)) - using (var writer = new StreamWriter(stream)) - { - writer.Write(pem); - writer.Write('\n'); - } - return pemFilePath; + return WriteTempFile(certificate.ExportCertificatePem() + "\n"); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or System.Security.Cryptography.CryptographicException) { @@ -640,6 +668,34 @@ private static (bool Succeeded, string Error) Execute(LinuxCommand command, bool } } + /// Copies an existing anchor so it can be restored; null when there's none or it can't be read. + private static string? BackupFile(string path, ILogger logger) + { + try + { + return File.Exists(path) ? WriteTempFile(File.ReadAllText(path)) : null; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + logger.LogDebug(ex, "Couldn't back up {Path}", path); + return null; + } + } + + private static string WriteTempFile(string contents) + { + var path = Path.Combine(Path.GetTempPath(), $"devproxy-ca-{Guid.NewGuid():N}.crt"); + var options = new FileStreamOptions { Mode = FileMode.CreateNew, Access = FileAccess.Write }; + if (!OperatingSystem.IsWindows()) + { + options.UnixCreateMode = UnixFileMode.UserRead | UnixFileMode.UserWrite; + } + using var stream = new FileStream(path, options); + using var writer = new StreamWriter(stream); + writer.Write(contents); + return path; + } + private static void CleanupFile(string filePath) { try From e0cacb150524b79768e431f8de77c4b37ad68eb1 Mon Sep 17 00:00:00 2001 From: waldekmastykarz Date: Sat, 31 Oct 2026 15:45:43 +0100 Subject: [PATCH 11/12] Avoid NSS prompts without a terminal and harden anchor rollback - Pass an empty password file to certutil when non-interactive so it fails instead of prompting on /dev/tty - Treat only certutil's not-found error as a missing NSS entry; fail cert remove when the database can't be checked - Back up a replaced anchor with an elevated copy so it can always be restored Refs #601 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- DevProxy.Tests/LinuxCertificateHelperTests.cs | 37 ++++ DevProxy/Proxy/LinuxCertificateHelper.cs | 174 ++++++++++++++---- 2 files changed, 176 insertions(+), 35 deletions(-) diff --git a/DevProxy.Tests/LinuxCertificateHelperTests.cs b/DevProxy.Tests/LinuxCertificateHelperTests.cs index e1065a8a3..ff2659434 100644 --- a/DevProxy.Tests/LinuxCertificateHelperTests.cs +++ b/DevProxy.Tests/LinuxCertificateHelperTests.cs @@ -363,6 +363,43 @@ public void GetLegacyCleanupCommands_RemovesLegacyAnchorThenRefreshes() Assert.Equal(["-f", "/usr/local/share/ca-certificates/dev-proxy-ca.crt"], commands[0].Arguments); } + [Fact] + public void GetBackupCommand_CopiesAnchorPrivately() + { + var command = LinuxCertificateHelper.GetBackupCommand(_debian, "/tmp/devproxy-x/previous.crt"); + + Assert.Equal("install", command.FileName); + Assert.Equal(["-m", "0600", "/usr/local/share/ca-certificates/devproxy-ca.crt", "/tmp/devproxy-x/previous.crt"], command.Arguments); + } + + // Without a terminal, certutil gets an empty password file so a password-protected database + // fails (SEC_ERROR_TOKEN_NOT_LOGGED_IN) instead of prompting on /dev/tty. + [Fact] + public void GetCertutilCommand_AddsPasswordFileWhenGiven() + { + var command = LinuxCertificateHelper.GetCertutilCommand("/usr/bin/certutil", "/tmp/pw", "-L", "-d", "sql:/db", "-n", "Dev Proxy CA"); + + Assert.Equal(["-L", "-d", "sql:/db", "-n", "Dev Proxy CA", "-f", "/tmp/pw"], command.Arguments); + } + + [Fact] + public void GetCertutilCommand_NoPasswordFileWhenInteractive() + { + var command = LinuxCertificateHelper.GetCertutilCommand("/usr/bin/certutil", null, "-D", "-d", "sql:/db", "-n", "Dev Proxy CA"); + + Assert.Equal(["-D", "-d", "sql:/db", "-n", "Dev Proxy CA"], command.Arguments); + } + + [Theory] + [InlineData(true, "", "Present")] + [InlineData(false, "certutil: Could not find cert: Dev Proxy CA\n: PR_FILE_NOT_FOUND_ERROR: File not found", "Absent")] + [InlineData(false, "certutil: function failed: SEC_ERROR_BAD_DATABASE: security library: bad database.", "Unknown")] + [InlineData(false, "certutil: SEC_ERROR_TOKEN_NOT_LOGGED_IN", "Unknown")] + public void ClassifyNssLookup_OnlyNotFoundMeansAbsent(bool succeeded, string error, string expected) + { + Assert.Equal(Enum.Parse(expected), LinuxCertificateHelper.ClassifyNssLookup(succeeded, error)); + } + [Fact] public void GetTrustRollback_NothingWhenAnchorAlreadyHoldsCertificate() { diff --git a/DevProxy/Proxy/LinuxCertificateHelper.cs b/DevProxy/Proxy/LinuxCertificateHelper.cs index c366aebd0..be972905c 100644 --- a/DevProxy/Proxy/LinuxCertificateHelper.cs +++ b/DevProxy/Proxy/LinuxCertificateHelper.cs @@ -32,6 +32,13 @@ internal enum LinuxTrustResult Failed, } +internal enum NssLookup +{ + Present, + Absent, + Unknown, +} + internal enum CommandOutcome { Succeeded, @@ -155,6 +162,9 @@ internal static IReadOnlyList GetLegacyCleanupCommands(LinuxTrustS /// this certificate, restore the previous anchor from when there /// was one, otherwise remove the new anchor. /// + internal static LinuxCommand GetBackupCommand(LinuxTrustStore store, string backupPath) => + new("install", ["-m", "0600", store.CertificatePath, backupPath]); + internal static LinuxCommand? GetTrustRollback(LinuxTrustStore store, bool anchorHoldsCertificate, string? backupPath) => anchorHoldsCertificate ? null : backupPath is not null ? new("install", ["-D", "-m", "0644", backupPath, store.CertificatePath]) @@ -277,17 +287,14 @@ internal static LinuxTrustResult TrustCertificate(X509Certificate2 certificate, return LinuxTrustResult.Failed; } + const string skippedMessage = "Skipped trusting the Dev Proxy root certificate because sudo needs a password and there's no terminal to ask for it. To trust the certificate, run 'devproxy cert ensure' in an interactive terminal."; + DirectoryInfo? backupDir = null; string? backupPath = null; try { var (isRoot, isInteractive) = GetContext(); var removeLegacy = FileContainsCertificate(store.LegacyCertificatePath, certificate); var anchorHoldsCertificate = FileContainsCertificate(store.CertificatePath, certificate); - if (!anchorHoldsCertificate) - { - backupPath = BackupFile(store.CertificatePath, logger); - } - var rollback = GetTrustRollback(store, anchorHoldsCertificate, backupPath); logger.LogInformation( "Adding the Dev Proxy root certificate to the {Distro} system certificate store ({Path}).{SudoHint}", @@ -295,11 +302,30 @@ internal static LinuxTrustResult TrustCertificate(X509Certificate2 certificate, store.CertificatePath, isRoot ? string.Empty : " You might be asked for your password."); + if (!anchorHoldsCertificate && File.Exists(store.CertificatePath)) + { + // Back up the anchor being replaced so a skipped trust can restore it. Elevated, + // so it works even if the file isn't readable by the current user. + backupDir = Directory.CreateTempSubdirectory("devproxy-"); + backupPath = $"{backupDir.FullName}/previous.crt"; + switch (RunElevated(GetBackupCommand(store, backupPath), isRoot, isInteractive, logger)) + { + case CommandOutcome.PasswordRequired: + logger.LogWarning(skippedMessage); + return LinuxTrustResult.Skipped; + case CommandOutcome.Failed: + return LinuxTrustResult.Failed; + case CommandOutcome.Succeeded: + default: + break; + } + } + var rollback = GetTrustRollback(store, anchorHoldsCertificate, backupPath); + switch (TrustSystemStore(GetTrustCommands(store, pemFilePath), rollback, c => RunElevated(c, isRoot, isInteractive, logger))) { case SystemTrustOutcome.Skipped: - logger.LogWarning( - "Skipped trusting the Dev Proxy root certificate because sudo needs a password and there's no terminal to ask for it. To trust the certificate, run 'devproxy cert ensure' in an interactive terminal."); + logger.LogWarning(skippedMessage); return LinuxTrustResult.Skipped; case SystemTrustOutcome.SkippedAnchorLeft when backupPath is not null: logger.LogWarning( @@ -334,9 +360,14 @@ internal static LinuxTrustResult TrustCertificate(X509Certificate2 certificate, finally { CleanupFile(pemFilePath); - if (backupPath is not null) + try + { + // The backup is root-owned, but deleting it only needs write access to our directory. + backupDir?.Delete(recursive: true); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { - CleanupFile(backupPath); + // ignore cleanup failures } } } @@ -399,6 +430,22 @@ private static bool TrustInNssDatabase(string pemFilePath, ILogger logger) } var nssDir = GetNssDatabaseDirectory(); + var passwordFile = CreateNssPasswordFile(); + try + { + return TrustInNssDatabase(certutil, nssDir, passwordFile, pemFilePath, logger); + } + finally + { + if (passwordFile is not null) + { + CleanupFile(passwordFile); + } + } + } + + private static bool TrustInNssDatabase(string certutil, string nssDir, string? passwordFile, string pemFilePath, ILogger logger) + { var db = $"sql:{nssDir}"; string error; if (!Directory.Exists(nssDir)) @@ -409,20 +456,20 @@ private static bool TrustInNssDatabase(string pemFilePath, ILogger logger) } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { - LogNssTrustFailure(logger, nssDir, ex.Message, leftoverEntry: false); + LogNssTrustFailure(logger, nssDir, ex.Message, NssLookup.Absent); return false; } error = Execute(new(certutil, ["-N", "-d", db, "--empty-password"]), redirectOutput: true).Error; if (error.Length > 0) { - LogNssTrustFailure(logger, nssDir, error, leftoverEntry: false); + LogNssTrustFailure(logger, nssDir, error, NssLookup.Absent); return false; } } // Replace any previous Dev Proxy root (it's regenerated per machine) so re-runs are idempotent. - _ = Run(new(certutil, ["-D", "-d", db, "-n", NssNickname]), logger, redirectOutput: true, logFailure: false); - error = Execute(new(certutil, ["-A", "-d", db, "-n", NssNickname, "-t", "C,,", "-i", pemFilePath]), redirectOutput: true).Error; + _ = Run(GetCertutilCommand(certutil, passwordFile, "-D", "-d", db, "-n", NssNickname), logger, redirectOutput: true, logFailure: false); + error = Execute(GetCertutilCommand(certutil, passwordFile, "-A", "-d", db, "-n", NssNickname, "-t", "C,,", "-i", pemFilePath), redirectOutput: true).Error; if (error.Length == 0) { logger.LogInformation("Certificate added to the NSS database ({Path}).", nssDir); @@ -430,27 +477,73 @@ private static bool TrustInNssDatabase(string pemFilePath, ILogger logger) } // A failed add can leave an entry without trust flags behind; try to remove it. - var leftoverEntry = - Run(new(certutil, ["-L", "-d", db, "-n", NssNickname]), logger, redirectOutput: true, logFailure: false) && - !Run(new(certutil, ["-D", "-d", db, "-n", NssNickname]), logger, redirectOutput: true, logFailure: false); - LogNssTrustFailure(logger, nssDir, error, leftoverEntry); + var leftover = LookUpNssEntry(certutil, passwordFile, db); + if (leftover == NssLookup.Present && + Run(GetCertutilCommand(certutil, passwordFile, "-D", "-d", db, "-n", NssNickname), logger, redirectOutput: true, logFailure: false)) + { + leftover = NssLookup.Absent; + } + LogNssTrustFailure(logger, nssDir, error, leftover); return false; } + /// + /// Without a terminal, certutil is given an empty password file (-f) so a + /// password-protected database fails with SEC_ERROR_TOKEN_NOT_LOGGED_IN instead of prompting + /// on /dev/tty, which it does even when stdin is redirected. Databases without a password work + /// as usual. Returns null when interactive, so certutil can prompt. + /// + private static string? CreateNssPasswordFile() + { + if (GetContext().IsInteractive) + { + return null; + } + try + { + return WriteTempFile(string.Empty); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return null; + } + } + + internal static LinuxCommand GetCertutilCommand(string certutil, string? passwordFile, params string[] arguments) => + new(certutil, passwordFile is null ? arguments : [.. arguments, "-f", passwordFile]); + + private static NssLookup LookUpNssEntry(string certutil, string? passwordFile, string db) + { + var (succeeded, error) = Execute(GetCertutilCommand(certutil, passwordFile, "-L", "-d", db, "-n", NssNickname), redirectOutput: true); + return ClassifyNssLookup(succeeded, error); + } + + /// Only certutil's "not found" error means there's no entry; other errors mean it couldn't check. + internal static NssLookup ClassifyNssLookup(bool succeeded, string error) => + succeeded ? NssLookup.Present + : error.Contains("PR_FILE_NOT_FOUND_ERROR", StringComparison.Ordinal) ? NssLookup.Absent + : NssLookup.Unknown; + internal static string FormatToolError(string error) => string.Join(' ', error.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)).TrimEnd('.'); - private static void LogNssTrustFailure(ILogger logger, string nssDir, string error, bool leftoverEntry) + private static void LogNssTrustFailure(ILogger logger, string nssDir, string error, NssLookup leftover) { logger.LogWarning( "The system certificate store trusts the Dev Proxy root certificate, but Chrome and other NSS-based apps don't: couldn't add it to the NSS database ({Path}): {Error}. If the database has a password, run 'devproxy cert ensure' in an interactive terminal. Otherwise, check that you can write to it.", nssDir, FormatToolError(error)); - if (leftoverEntry) + if (leftover == NssLookup.Present) { logger.LogWarning( "The NSS database still has a '{Nickname}' entry that couldn't be removed. Remove it with: certutil -D -d sql:{Path} -n \"{Nickname}\"", NssNickname, nssDir, NssNickname); } + else if (leftover == NssLookup.Unknown) + { + logger.LogWarning( + "Couldn't check whether the NSS database has a '{Nickname}' entry left without trust. If it does, remove it with: certutil -D -d sql:{Path} -n \"{Nickname}\"", + NssNickname, nssDir, NssNickname); + } } /// Returns false when certutil found the certificate but couldn't delete it. @@ -472,16 +565,41 @@ private static bool RemoveFromNssDatabase(ILogger logger) return true; } + var passwordFile = CreateNssPasswordFile(); + try + { + return RemoveFromNssDatabases(certutil, nssDirs, passwordFile, logger); + } + finally + { + if (passwordFile is not null) + { + CleanupFile(passwordFile); + } + } + } + + private static bool RemoveFromNssDatabases(string certutil, string[] nssDirs, string? passwordFile, ILogger logger) + { var removed = true; foreach (var nssDir in nssDirs) { var db = $"sql:{nssDir}"; // Only attempt (and report failures of) removal when the certificate is there. - if (!Run(new(certutil, ["-L", "-d", db, "-n", NssNickname]), logger, redirectOutput: true, logFailure: false)) + var lookup = LookUpNssEntry(certutil, passwordFile, db); + if (lookup == NssLookup.Absent) { continue; } - if (Run(new(certutil, ["-D", "-d", db, "-n", NssNickname]), logger, redirectOutput: true)) + if (lookup == NssLookup.Unknown) + { + removed = false; + logger.LogError( + "Couldn't check the NSS database ({Path}) for the Dev Proxy root certificate. If it's there, remove '{Nickname}' manually.", + nssDir, NssNickname); + continue; + } + if (Run(GetCertutilCommand(certutil, passwordFile, "-D", "-d", db, "-n", NssNickname), logger, redirectOutput: true)) { logger.LogInformation("Certificate removed from the NSS database ({Path}).", nssDir); } @@ -668,20 +786,6 @@ private static (bool Succeeded, string Error) Execute(LinuxCommand command, bool } } - /// Copies an existing anchor so it can be restored; null when there's none or it can't be read. - private static string? BackupFile(string path, ILogger logger) - { - try - { - return File.Exists(path) ? WriteTempFile(File.ReadAllText(path)) : null; - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) - { - logger.LogDebug(ex, "Couldn't back up {Path}", path); - return null; - } - } - private static string WriteTempFile(string contents) { var path = Path.Combine(Path.GetTempPath(), $"devproxy-ca-{Guid.NewGuid():N}.crt"); From ef9a0e6954e8c600154c977d59bc6c4de2cd49bc Mon Sep 17 00:00:00 2001 From: waldekmastykarz Date: Sat, 31 Oct 2026 15:50:35 +0100 Subject: [PATCH 12/12] Use /dev/null as certutil's password file without a terminal Nothing to create, so certutil can't fall back to prompting. Refs #601 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- DevProxy.Tests/LinuxCertificateHelperTests.cs | 8 ++++ DevProxy/Proxy/LinuxCertificateHelper.cs | 48 +++---------------- 2 files changed, 15 insertions(+), 41 deletions(-) diff --git a/DevProxy.Tests/LinuxCertificateHelperTests.cs b/DevProxy.Tests/LinuxCertificateHelperTests.cs index ff2659434..1d7437692 100644 --- a/DevProxy.Tests/LinuxCertificateHelperTests.cs +++ b/DevProxy.Tests/LinuxCertificateHelperTests.cs @@ -372,6 +372,14 @@ public void GetBackupCommand_CopiesAnchorPrivately() Assert.Equal(["-m", "0600", "/usr/local/share/ca-certificates/devproxy-ca.crt", "/tmp/devproxy-x/previous.crt"], command.Arguments); } + [Theory] + [InlineData(true, null)] + [InlineData(false, "/dev/null")] + public void GetNssPasswordFile_EmptyPasswordOnlyWithoutTerminal(bool isInteractive, string? expected) + { + Assert.Equal(expected, LinuxCertificateHelper.GetNssPasswordFile(isInteractive)); + } + // Without a terminal, certutil gets an empty password file so a password-protected database // fails (SEC_ERROR_TOKEN_NOT_LOGGED_IN) instead of prompting on /dev/tty. [Fact] diff --git a/DevProxy/Proxy/LinuxCertificateHelper.cs b/DevProxy/Proxy/LinuxCertificateHelper.cs index be972905c..496fc5a60 100644 --- a/DevProxy/Proxy/LinuxCertificateHelper.cs +++ b/DevProxy/Proxy/LinuxCertificateHelper.cs @@ -430,18 +430,7 @@ private static bool TrustInNssDatabase(string pemFilePath, ILogger logger) } var nssDir = GetNssDatabaseDirectory(); - var passwordFile = CreateNssPasswordFile(); - try - { - return TrustInNssDatabase(certutil, nssDir, passwordFile, pemFilePath, logger); - } - finally - { - if (passwordFile is not null) - { - CleanupFile(passwordFile); - } - } + return TrustInNssDatabase(certutil, nssDir, GetNssPasswordFile(), pemFilePath, logger); } private static bool TrustInNssDatabase(string certutil, string nssDir, string? passwordFile, string pemFilePath, ILogger logger) @@ -488,26 +477,14 @@ private static bool TrustInNssDatabase(string certutil, string nssDir, string? p } /// - /// Without a terminal, certutil is given an empty password file (-f) so a + /// Without a terminal, certutil reads an empty password from /dev/null (-f) so a /// password-protected database fails with SEC_ERROR_TOKEN_NOT_LOGGED_IN instead of prompting /// on /dev/tty, which it does even when stdin is redirected. Databases without a password work - /// as usual. Returns null when interactive, so certutil can prompt. + /// as usual. Null when interactive, so certutil can prompt. /// - private static string? CreateNssPasswordFile() - { - if (GetContext().IsInteractive) - { - return null; - } - try - { - return WriteTempFile(string.Empty); - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) - { - return null; - } - } + internal static string? GetNssPasswordFile(bool isInteractive) => isInteractive ? null : "/dev/null"; + + private static string? GetNssPasswordFile() => GetNssPasswordFile(GetContext().IsInteractive); internal static LinuxCommand GetCertutilCommand(string certutil, string? passwordFile, params string[] arguments) => new(certutil, passwordFile is null ? arguments : [.. arguments, "-f", passwordFile]); @@ -565,18 +542,7 @@ private static bool RemoveFromNssDatabase(ILogger logger) return true; } - var passwordFile = CreateNssPasswordFile(); - try - { - return RemoveFromNssDatabases(certutil, nssDirs, passwordFile, logger); - } - finally - { - if (passwordFile is not null) - { - CleanupFile(passwordFile); - } - } + return RemoveFromNssDatabases(certutil, nssDirs, GetNssPasswordFile(), logger); } private static bool RemoveFromNssDatabases(string certutil, string[] nssDirs, string? passwordFile, ILogger logger)