diff --git a/README.md b/README.md index 1c7e79f..fd10e2f 100644 --- a/README.md +++ b/README.md @@ -18,11 +18,11 @@ dotnet linux-dev-certs install # Supported distros -- Fedora and derived (RHEL, Rocky, ...) +- Fedora and derived (RHEL, AlmaLinux, ...) - Debian and derived (Ubuntu, ...) Limitations: -- Ubuntu provides browser applications as snaps. These snaps do not use the system certificate store (https://bugs.launchpad.net/ubuntu/+source/chromium-browser/+bug/1901586). For .NET itself and CLI applications the development certificate will be trusted. For browsers, the user still needs manually accept the development certificate. +- Ubuntu browsers are packaged as snaps. Snaps do not use system certificates. If the user uses a snap-based Firefox, the CA certificate is added to its certificate store. Other browsers are (currently) not configured. # How it works diff --git a/src/linux-dev-certs/CertificateManager.aspnetcore.cs b/src/linux-dev-certs/CertificateManager.aspnetcore.cs index 3dae2d1..b7318b8 100644 --- a/src/linux-dev-certs/CertificateManager.aspnetcore.cs +++ b/src/linux-dev-certs/CertificateManager.aspnetcore.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Diagnostics; +using System.Runtime.InteropServices; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using System.Text; @@ -90,4 +92,156 @@ protected X509Certificate2 SaveCertificateCore(X509Certificate2 certificate, Sto return certificate; } + + // Copied from aspnetcore CertificateManager.cs + internal static void ExportCertificate(X509Certificate2 certificate, string path, bool includePrivateKey, string? password, CertificateKeyExportFormat format) + { + // if (Log.IsEnabled()) + // { + // Log.ExportCertificateStart(GetDescription(certificate), path, includePrivateKey); + // } + + // if (includePrivateKey && password == null) + // { + // Log.NoPasswordForCertificate(); + // } + + var targetDirectoryPath = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(targetDirectoryPath)) + { + // Log.CreateExportCertificateDirectory(targetDirectoryPath); + Directory.CreateDirectory(targetDirectoryPath); + } + + byte[] bytes; + byte[] keyBytes; + byte[]? pemEnvelope = null; + RSA? key = null; + + try + { + if (includePrivateKey) + { + switch (format) + { + case CertificateKeyExportFormat.Pfx: + bytes = certificate.Export(X509ContentType.Pkcs12, password); + break; + case CertificateKeyExportFormat.Pem: + key = certificate.GetRSAPrivateKey()!; + + char[] pem; + if (password != null) + { + keyBytes = key.ExportEncryptedPkcs8PrivateKey(password, new PbeParameters(PbeEncryptionAlgorithm.Aes256Cbc, HashAlgorithmName.SHA256, 100000)); + pem = PemEncoding.Write("ENCRYPTED PRIVATE KEY", keyBytes); + pemEnvelope = Encoding.ASCII.GetBytes(pem); + } + else + { + // Export the key first to an encrypted PEM to avoid issues with System.Security.Cryptography.Cng indicating that the operation is not supported. + // This is likely by design to avoid exporting the key by mistake. + // To bypass it, we export the certificate to pem temporarily and then we import it and export it as unprotected PEM. + keyBytes = key.ExportEncryptedPkcs8PrivateKey(string.Empty, new PbeParameters(PbeEncryptionAlgorithm.Aes256Cbc, HashAlgorithmName.SHA256, 1)); + pem = PemEncoding.Write("ENCRYPTED PRIVATE KEY", keyBytes); + key.Dispose(); + key = RSA.Create(); + key.ImportFromEncryptedPem(pem, string.Empty); + Array.Clear(keyBytes, 0, keyBytes.Length); + Array.Clear(pem, 0, pem.Length); + keyBytes = key.ExportPkcs8PrivateKey(); + pem = PemEncoding.Write("PRIVATE KEY", keyBytes); + pemEnvelope = Encoding.ASCII.GetBytes(pem); + } + + Array.Clear(keyBytes, 0, keyBytes.Length); + Array.Clear(pem, 0, pem.Length); + + bytes = Encoding.ASCII.GetBytes(PemEncoding.Write("CERTIFICATE", certificate.Export(X509ContentType.Cert))); + break; + default: + throw new InvalidOperationException("Unknown format."); + } + } + else + { + if (format == CertificateKeyExportFormat.Pem) + { + bytes = Encoding.ASCII.GetBytes(PemEncoding.Write("CERTIFICATE", certificate.Export(X509ContentType.Cert))); + } + else + { + bytes = certificate.Export(X509ContentType.Cert); + } + } + } + // catch (Exception e) when (Log.IsEnabled()) + // { + // Log.ExportCertificateError(e.ToString()); + // throw; + // } + finally + { + key?.Dispose(); + } + + try + { + // Log.WriteCertificateToDisk(path); + + // Create a temp file with the correct Unix file mode before moving it to the expected path. + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + var tempFilename = Path.GetTempFileName(); + File.Move(tempFilename, path, overwrite: true); + } + + File.WriteAllBytes(path, bytes); + } + // catch (Exception ex) when (Log.IsEnabled()) + // { + // Log.WriteCertificateToDiskError(ex.ToString()); + // throw; + // } + finally + { + Array.Clear(bytes, 0, bytes.Length); + } + + if (includePrivateKey && format == CertificateKeyExportFormat.Pem) + { + Debug.Assert(pemEnvelope != null); + + try + { + var keyPath = Path.ChangeExtension(path, ".key"); + // Log.WritePemKeyToDisk(keyPath); + + // Create a temp file with the correct Unix file mode before moving it to the expected path. + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + var tempFilename = Path.GetTempFileName(); + File.Move(tempFilename, keyPath, overwrite: true); + } + + File.WriteAllBytes(keyPath, pemEnvelope); + } + // catch (Exception ex) when (Log.IsEnabled()) + // { + // Log.WritePemKeyToDiskError(ex.ToString()); + // throw; + // } + finally + { + Array.Clear(pemEnvelope, 0, pemEnvelope.Length); + } + } + } +} + +// Copied from aspnetcore CertificateExportFormat.cs +internal enum CertificateKeyExportFormat +{ + Pfx, + Pem, } \ No newline at end of file diff --git a/src/linux-dev-certs/CertificateManager.cs b/src/linux-dev-certs/CertificateManager.cs index 87ee81a..fc8e155 100644 --- a/src/linux-dev-certs/CertificateManager.cs +++ b/src/linux-dev-certs/CertificateManager.cs @@ -1,3 +1,4 @@ +using System.Runtime.ConstrainedExecution; using System.Runtime.InteropServices; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; @@ -17,6 +18,9 @@ partial class CertificateManager public void InstallAndTrust() { + string username = Environment.UserName; + string certificateName = $"aspnet-dev-{username}"; + Console.WriteLine("Removing existing development certificates."); Execute("dotnet", "dev-certs", "https", "--clean"); @@ -24,33 +28,58 @@ public void InstallAndTrust() _caCertificate = CreateAspNetDevelopmentCACertificate(DateTime.UtcNow, DateTime.UtcNow.AddYears(10)); Console.WriteLine("Installing CA certificate."); - InstallCaCertificate(_caCertificate); + InstallCaCertificate(certificateName, _caCertificate); Console.WriteLine("Creating development certificate."); var devCert = CreateAspNetCoreHttpsDevelopmentCertificate(DateTime.UtcNow, DateTime.UtcNow.AddYears(1)); Console.WriteLine("Installing development certificate."); - SaveCertificateCore(devCert, StoreName.My, StoreLocation.CurrentUser); + devCert = SaveCertificateCore(devCert, StoreName.My, StoreLocation.CurrentUser); + + var additionalStores = FindAdditionaCertificateStores(); + foreach (ICertificateStore store in additionalStores) + { + Console.WriteLine($"Installing CA certificate to {store.Name}."); + if (!store.TryInstallCertificate(certificateName, devCert)) + { + Console.Error.WriteLine("Failed to install certificate."); + } + } + } + + private List FindAdditionaCertificateStores() + { + List stores = new(); + + string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile, Environment.SpecialFolderOption.DoNotVerify); + + // Snap applications don't use '/etc/ssl' certificates. Add the certificate explicitly. + // https://bugs.launchpad.net/ubuntu/+source/chromium-browser/+bug/1901586 + string firefoxSnapUserDirectory = Path.Combine(home, "snap/firefox/common/.mozilla/firefox"); + if (Directory.Exists(firefoxSnapUserDirectory)) + { + FindFirefoxCertificateStores(firefoxSnapUserDirectory, stores); + } + + return stores; } - private static void InstallCaCertificate(X509Certificate2 caCertificate) + private static void InstallCaCertificate(string name, X509Certificate2 caCertificate) { // Only the public key is stored. // The private key will only exist in the memory of this program // and no other certificates can be signed with it after the program terminates. char[] caCertPem = PemEncoding.Write("CERTIFICATE", caCertificate.Export(X509ContentType.Cert)); - string username = Environment.UserName; - string fileNameWithoutExtension = $"aspnet-{username}"; string certFilePath; string[] trustCommand; if (Directory.Exists(FedoraFamilyCaSourceDirectory)) { - certFilePath = $"{FedoraFamilyCaSourceDirectory}/{fileNameWithoutExtension}.pem"; - trustCommand = [ "update-ca-trust", "extract" ]; + certFilePath = $"{FedoraFamilyCaSourceDirectory}/{name}.pem"; + trustCommand = ["update-ca-trust", "extract"]; } else if (Directory.Exists(DebianFamilyCaSourceDirectory)) { - certFilePath = $"{DebianFamilyCaSourceDirectory}/{fileNameWithoutExtension}.crt"; - trustCommand = [ "update-ca-certificates" ]; + certFilePath = $"{DebianFamilyCaSourceDirectory}/{name}.crt"; + trustCommand = ["update-ca-certificates"]; } else { diff --git a/src/linux-dev-certs/CertificateManager.firefox.cs b/src/linux-dev-certs/CertificateManager.firefox.cs new file mode 100644 index 0000000..b57b8a0 --- /dev/null +++ b/src/linux-dev-certs/CertificateManager.firefox.cs @@ -0,0 +1,129 @@ +using System.Runtime.ConstrainedExecution; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using static LinuxDevCerts.ProcessHelper; + +namespace LinuxDevCerts; + +partial class CertificateManager +{ + private static void FindFirefoxCertificateStores(string firefoxUserDirectory, List stores) + { + string profilesIniFileName = Path.Combine(firefoxUserDirectory, "profiles.ini"); + if (File.Exists(profilesIniFileName)) + { + using FileStream profilesIniFile = File.OpenRead(profilesIniFileName); + List sections = ReadIniFile(profilesIniFile); + List profileFolders = new(); + foreach (var section in sections) + { + string? path; + if (section.Name.StartsWith("Install", StringComparison.InvariantCultureIgnoreCase)) + { + if (!section.Properties.TryGetValue("Default", out path)) + { + continue; + } + } + else if (!section.Properties.TryGetValue("Path", out path)) + { + continue; + } + + string profileFolder = Path.Combine(firefoxUserDirectory, path); + if (!profileFolders.Contains(profileFolder)) + { + profileFolders.Add(profileFolder); + if (Directory.Exists(profileFolder)) + { + stores.Add(new NssCertificateDatabase($"Firefox profile '{profileFolder}'", profileFolder)); + } + } + } + } + } + + private class IniSection + { + public string Name { get; } + + public IniSection(string name) + { + Name = name; + } + + public Dictionary Properties { get; } = new(); + } + + private static List ReadIniFile(Stream stream) + { + // Implementation from https://raw.githubusercontent.com/dotnet/runtime/5a1b8223dab2f7954e7f206095ba937e5d237299/src/libraries/Microsoft.Extensions.Configuration.Ini/src/IniStreamConfigurationProvider.cs. + // Licensed to the .NET Foundation under one or more agreements. + // The .NET Foundation licenses this file to you under the MIT license. + + var sections = new List(); + IniSection? currentSection = null; + + using (var reader = new StreamReader(stream)) + { + string sectionPrefix = string.Empty; + + while (reader.Peek() != -1) + { + string rawLine = reader.ReadLine()!; + string line = rawLine.Trim(); + + // Ignore blank lines + if (string.IsNullOrWhiteSpace(line)) + { + continue; + } + // Ignore comments + if (line[0] == ';' || line[0] == '#' || line[0] == '/') + { + continue; + } + // [Section:header] + if (line[0] == '[' && line[line.Length - 1] == ']') + { + // remove the brackets + string sectionName = line.Substring(1, line.Length - 2); + currentSection = new IniSection(sectionName); + sections.Add(currentSection); + continue; + } + + if (currentSection == null) + { + continue; + } + + // key = value OR "value" + int separator = line.IndexOf('='); + if (separator < 0) + { + throw new FormatException($"Unrecognized line format: '{rawLine}'."); + } + + string key = line.Substring(0, separator).Trim(); + string value = line.Substring(separator + 1).Trim(); + + // Remove quotes + if (value.Length > 1 && value[0] == '"' && value[value.Length - 1] == '"') + { + value = value.Substring(1, value.Length - 2); + } + + if (currentSection.Properties.ContainsKey(key)) + { + throw new FormatException($"A duplicate key '{key}' was found."); + } + + currentSection.Properties.Add(key, value); + } + } + + return sections; + } +} \ No newline at end of file diff --git a/src/linux-dev-certs/ICertificateStore.cs b/src/linux-dev-certs/ICertificateStore.cs new file mode 100644 index 0000000..0bd62ea --- /dev/null +++ b/src/linux-dev-certs/ICertificateStore.cs @@ -0,0 +1,7 @@ +using System.Security.Cryptography.X509Certificates; + +interface ICertificateStore +{ + public string Name { get; } + public bool TryInstallCertificate(string name, X509Certificate2 certificate); +} \ No newline at end of file diff --git a/src/linux-dev-certs/NssCertificateDatabase.cs b/src/linux-dev-certs/NssCertificateDatabase.cs new file mode 100644 index 0000000..b8153c8 --- /dev/null +++ b/src/linux-dev-certs/NssCertificateDatabase.cs @@ -0,0 +1,44 @@ +using System.Diagnostics; +using System.Security.Cryptography.X509Certificates; + +namespace LinuxDevCerts; + +internal class NssCertificateDatabase : ICertificateStore +{ + private string DatabasePath { get; } + + public string Name { get; } + + public NssCertificateDatabase(string name, string path) + { + Name = name; + DatabasePath = path; + } + + public bool TryInstallCertificate(string name, X509Certificate2 certificate) + { + string pemFile = Path.GetTempFileName(); + try + { + CertificateManager.ExportCertificate(certificate, pemFile, includePrivateKey: false, password: null, CertificateKeyExportFormat.Pem); + Process process = Process.Start(new ProcessStartInfo() { + FileName = "certutil", + ArgumentList = { "-d", DatabasePath, "-A", "-t", "C,,", "-n", name, "-i", pemFile }, + RedirectStandardOutput = true, + RedirectStandardError = true })!; + var stderr = process.StandardError.ReadToEnd(); + process.WaitForExit(); + bool success = process.ExitCode == 0; + return success; + } + finally + { + try + { + File.Delete(pemFile); + } + catch + { } + } + } +} \ No newline at end of file