diff --git a/DevProxy.Abstractions/Proxy/IRootCertificateTrust.cs b/DevProxy.Abstractions/Proxy/IRootCertificateTrust.cs
index 5bae4edb..81e94f91 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 826bd524..c606d8a9 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 00000000..1d743769
--- /dev/null
+++ b/DevProxy.Tests/LinuxCertificateHelperTests.cs
@@ -0,0 +1,499 @@
+// 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.Security.Cryptography;
+using System.Security.Cryptography.X509Certificates;
+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/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);
+ }
+
+ [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("/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);
+ }
+
+ [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);
+ Assert.Equal("/etc/ca-certificates/extracted/tls-ca-bundle.pem", store.BundlePath);
+ }
+
+ [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_RunsAbsoluteCommandDirectly()
+ {
+ var command = new LinuxCommand("/usr/sbin/update-ca-certificates", []);
+
+ 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)!;
+
+ 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);
+ }
+
+ 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()));
+ }
+
+ 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()
+ {
+ List ran = [];
+
+ 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 FormatToolError_JoinsLinesAndDropsTrailingPeriods()
+ {
+ 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(
+ "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]
+ 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()
+ {
+ 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()
+ {
+ 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 = _fedora;
+
+ 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 GetLegacyCleanupCommands_RemovesLegacyAnchorThenRefreshes()
+ {
+ var commands = LinuxCertificateHelper.GetLegacyCleanupCommands(_debian);
+
+ 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 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);
+ }
+
+ [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]
+ 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()
+ {
+ 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]
+ 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 = _debian;
+
+ var commands = LinuxCertificateHelper.GetUntrustCommands(store, removeLegacy: false);
+
+ 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 00000000..496fc5a6
--- /dev/null
+++ b/DevProxy/Proxy/LinuxCertificateHelper.cs
@@ -0,0 +1,780 @@
+// 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 BundlePath,
+ string UpdateCommand,
+ IReadOnlyList UpdateArguments,
+ IReadOnlyList RemoveUpdateArguments)
+{
+ public string CertificatePath => $"{AnchorDirectory}/{LinuxCertificateHelper.CertificateFileName}";
+
+ /// Anchor that earlier manual setup instructions told users to create.
+ public string LegacyCertificatePath => $"{AnchorDirectory}/{LinuxCertificateHelper.LegacyCertificateFileName}";
+}
+
+/// A process invocation (file name + argument list, no shell).
+internal enum LinuxTrustResult
+{
+ Trusted,
+ /// The system store trusts the certificate, but NSS-based apps don't.
+ TrustedSystemOnly,
+ Skipped,
+ Failed,
+}
+
+internal enum NssLookup
+{
+ Present,
+ Absent,
+ Unknown,
+}
+
+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]);
+}
+
+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. must
+ /// only resolve from trusted system directories because the result runs as root.
+ ///
+ 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",
+ "/etc/ssl/certs/ca-certificates.crt",
+ 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", "/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", "/etc/ca-certificates/extracted/tls-ca-bundle.pem", updateCaTrust, ["extract"], ["extract"]);
+ }
+ }
+
+ return null;
+ }
+
+ ///
+ /// 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) =>
+ Elevate(command, isRoot, isInteractive, FindSystemExecutable);
+
+ internal static LinuxCommand? Elevate(LinuxCommand command, bool isRoot, bool isInteractive, Func findSystemExecutable)
+ {
+ if (isRoot)
+ {
+ if (command.FileName.StartsWith('/'))
+ {
+ return command;
+ }
+ var resolved = findSystemExecutable(command.FileName);
+ return resolved is null ? null : command with { FileName = resolved };
+ }
+
+ List args = isInteractive ? [] : ["-n"];
+ args.Add("--");
+ args.Add(command.FileName);
+ args.AddRange(command.Arguments);
+ return new("sudo", args);
+ }
+
+ // install/rm are passed by name so sudo resolves them through its secure_path.
+ 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 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])
+ : new("rm", ["-f", store.CertificatePath]);
+
+ 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;
+ }
+
+ ///
+ /// 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() =>
+ 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";
+ }
+
+ ///
+ /// 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 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);
+
+ internal static LinuxTrustResult TrustCertificate(X509Certificate2 certificate, LinuxTrustStore store, ILogger logger)
+ {
+ var pemFilePath = ExportCertificateToPem(certificate, logger);
+ if (pemFilePath is null)
+ {
+ 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);
+
+ 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.");
+
+ 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(skippedMessage);
+ 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}'.",
+ 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.");
+
+ 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;
+ }
+ finally
+ {
+ CleanupFile(pemFilePath);
+ 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)
+ {
+ // ignore cleanup failures
+ }
+ }
+ }
+
+ /// 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)
+ {
+ logger.LogWarning("Remove the Dev Proxy root certificate from your trust store manually.");
+ }
+ else if (NeedsSystemStoreRemoval(
+ File.Exists(store.CertificatePath),
+ removeLegacy,
+ FileContainsCertificate(store.BundlePath, certificate)))
+ {
+ 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 outcome = RunAll(GetUntrustCommands(store, removeLegacy), c => RunElevated(c, isRoot, isInteractive, logger));
+ 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.");
+ }
+ else
+ {
+ 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);
+ }
+ }
+
+ // 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.
+ 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 true;
+ }
+
+ var nssDir = GetNssDatabaseDirectory();
+ return TrustInNssDatabase(certutil, nssDir, GetNssPasswordFile(), pemFilePath, logger);
+ }
+
+ private static bool TrustInNssDatabase(string certutil, string nssDir, string? passwordFile, string pemFilePath, ILogger logger)
+ {
+ var db = $"sql:{nssDir}";
+ string error;
+ if (!Directory.Exists(nssDir))
+ {
+ try
+ {
+ _ = Directory.CreateDirectory(nssDir);
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ 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, NssLookup.Absent);
+ return false;
+ }
+ }
+
+ // Replace any previous Dev Proxy root (it's regenerated per machine) so re-runs are idempotent.
+ _ = 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);
+ return true;
+ }
+
+ // A failed add can leave an entry without trust flags behind; try to remove it.
+ 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 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. Null when interactive, so certutil can prompt.
+ ///
+ 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]);
+
+ 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, 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 (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.
+ 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 true;
+ }
+
+ 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 true;
+ }
+
+ return RemoveFromNssDatabases(certutil, nssDirs, GetNssPasswordFile(), logger);
+ }
+
+ 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.
+ var lookup = LookUpNssEntry(certutil, passwordFile, db);
+ if (lookup == NssLookup.Absent)
+ {
+ continue;
+ }
+ 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);
+ }
+ 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;
+ }
+
+ ///
+ /// 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);
+
+ ///
+ /// 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.
+ ///
+ internal static void WriteManualTrustInstructions(X509Certificate2 certificate, ILogger logger)
+ {
+ const string help = "More information: https://aka.ms/devproxy/start";
+ var path = Path.GetFullPath(CertificateFileName);
+ try
+ {
+ WriteNewFile(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. 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() =>
+ (Environment.UserName == "root",
+ !Console.IsInputRedirected && Environment.GetEnvironmentVariable("CI") is null);
+
+ 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 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;
+ }
+ 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.
+ 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);
+
+ internal static string? FindExecutable(string name) =>
+ FindExecutable(name, Environment.GetEnvironmentVariable("PATH"));
+
+ internal static string? FindExecutable(string name, string? path)
+ {
+ var directories = (path ?? string.Empty).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 (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
+ {
+ FileName = command.FileName,
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ RedirectStandardOutput = redirectOutput,
+ RedirectStandardError = redirectOutput,
+ };
+ foreach (var argument in command.Arguments)
+ {
+ 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
+ {
+ 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)
+ {
+ return (true, string.Empty);
+ }
+ 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)
+ {
+ return (false, ex.Message);
+ }
+ }
+
+ private static string? ExportCertificateToPem(X509Certificate2 certificate, ILogger logger)
+ {
+ try
+ {
+ // Public certificate only; the private key never leaves the PFX.
+ return WriteTempFile(certificate.ExportCertificatePem() + "\n");
+ }
+ 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 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
+ {
+ 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 5693bf8a..13826398 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,27 @@ 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.WriteManualTrustInstructions(rootCertificate, logger);
+ return;
+ }
+ switch (LinuxCertificateHelper.TrustCertificate(rootCertificate, store, logger))
+ {
+ 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.TrustedSystemOnly:
+ case LinuxTrustResult.Skipped:
+ default:
+ break;
+ }
+ }
else
{
logger.LogWarning(
@@ -110,6 +132,13 @@ public void Untrust(X509Certificate2 rootCertificate)
{
RemoveFromWindowsRootStore(rootCertificate);
}
+ else if (OperatingSystem.IsLinux())
+ {
+ if (!LinuxCertificateHelper.RemoveTrustedCertificate(rootCertificate, logger))
+ {
+ throw new InvalidOperationException("Failed to fully remove the root certificate from the system certificate store and NSS databases.");
+ }
+ }
else
{
logger.LogWarning(
diff --git a/README.md b/README.md
index 40cce32a..24edf66e 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 fdb02092..c3e458ae 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 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:
```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: