From cd65501f631aea40fe795d68c25e4db677e31bf2 Mon Sep 17 00:00:00 2001 From: Derek Nance Date: Thu, 16 Apr 2026 16:17:24 -0500 Subject: [PATCH 1/7] refactor setup shell commands --- util/Setup/CertBuilder.cs | 94 +++++++++++++++++++++++----- util/Setup/EnvironmentFileBuilder.cs | 10 +-- util/Setup/Helpers.cs | 43 ++++++------- util/Setup/Program.cs | 64 ++++++++++++++++--- 4 files changed, 159 insertions(+), 52 deletions(-) diff --git a/util/Setup/CertBuilder.cs b/util/Setup/CertBuilder.cs index ff8aac25b025..813fcaeefa6c 100644 --- a/util/Setup/CertBuilder.cs +++ b/util/Setup/CertBuilder.cs @@ -44,12 +44,28 @@ public void BuildForInstall() _context.Config.Ssl = true; _context.Install.Trusted = false; _context.Install.SelfSignedCert = true; - Helpers.Exec("openssl req -x509 -newkey rsa:4096 -sha256 -nodes -days 36500 " + - $"-keyout /bitwarden/ssl/self/{_context.Install.Domain}/private.key " + - $"-out /bitwarden/ssl/self/{_context.Install.Domain}/certificate.crt " + - $"-reqexts SAN -extensions SAN " + - $"-config <(cat /usr/lib/ssl/openssl.cnf <(printf '[SAN]\nsubjectAltName=DNS:{_context.Install.Domain}\nbasicConstraints=CA:true')) " + - $"-subj \"/C=US/ST=California/L=Santa Barbara/O=Bitwarden Inc./OU=Bitwarden/CN={_context.Install.Domain}\""); + var opensslConfig = File.ReadAllText("/usr/lib/ssl/openssl.cnf") + + $"\n[SAN]\nsubjectAltName=DNS:{_context.Install.Domain}\nbasicConstraints=CA:true"; + var opensslConfigPath = Path.GetTempFileName(); + + File.WriteAllText(opensslConfigPath, opensslConfig); + Helpers.Exec( + "openssl", + [ + "req", + "-x509", + "-newkey", "rsa:4096", + "-sha256", + "-nodes", + "-days", "36500", + "-keyout", $"/bitwarden/ssl/self/{_context.Install.Domain}/private.key", + "-out", $"/bitwarden/ssl/self/{_context.Install.Domain}/certificate.crt", + "-reqexts", "SAN", + "-extensions", "SAN", + "-config", opensslConfigPath, + "-subj", $"/C=US/ST=California/L=Santa Barbara/O=Bitwarden Inc./OU=Bitwarden/CN={_context.Install.Domain}", + ]); + File.Delete(opensslConfigPath); } } } @@ -58,9 +74,15 @@ public void BuildForInstall() { _context.Install.Trusted = true; _context.Install.DiffieHellman = true; - Directory.CreateDirectory($"{_context.App.RootDirectory}/letsencrypt/live/{_context.Install.Domain}/"); - Helpers.Exec($"openssl dhparam -out " + - $"{_context.App.RootDirectory}/letsencrypt/live/{_context.Install.Domain}/dhparam.pem 2048"); + var directory = $"{_context.App.RootDirectory}/letsencrypt/live/{_context.Install.Domain}/"; + Directory.CreateDirectory(directory); + Helpers.Exec( + "openssl", + [ + "dhparam", + "-out", $"{directory}/dhparam.pem", + "2048", + ]); } else if (_context.Config.Ssl && !_context.Install.SelfSignedCert) { @@ -71,10 +93,29 @@ public void BuildForInstall() Helpers.WriteLine(_context, "Generating key for IdentityServer."); _context.Install.IdentityCertPassword = Helpers.SecureRandomString(32, alpha: true, numeric: true); Directory.CreateDirectory($"{_context.App.RootDirectory}/identity/"); - Helpers.Exec("openssl req -x509 -newkey rsa:4096 -sha256 -nodes -keyout identity.key " + - "-out identity.crt -subj \"/CN=Bitwarden IdentityServer\" -days 36500"); - Helpers.Exec($"openssl pkcs12 -export -out {_context.App.RootDirectory}/identity/identity.pfx -inkey identity.key " + - $"-in identity.crt -passout pass:{_context.Install.IdentityCertPassword}"); + Helpers.Exec( + "openssl", + [ + "req", + "-x509", + "-newkey", "rsa:4096", + "-sha256", + "-nodes", + "-keyout", "identity.key", + "-out", "identity.crt", + "-subj", "/CN=Bitwarden IdentityServer", + "-days", "36500", + ]); + Helpers.Exec( + "openssl", + [ + "pkcs12", + "-export", + "-out", $"{_context.App.RootDirectory}/identity/identity.pfx", + "-inkey", "identity.key", + "-in", "identity.crt", + "-passout", $"pass:{_context.Install.IdentityCertPassword}", + ]); Helpers.WriteLine(_context); @@ -102,10 +143,29 @@ public void BuildForUpdater() Directory.CreateDirectory($"{_context.App.RootDirectory}/key-connector/"); var keyConnectorCertPassword = Helpers.GetValueFromEnvFile(_context.App, "key-connector", "keyConnectorSettings__certificate__filesystemPassword"); - Helpers.Exec("openssl req -x509 -newkey rsa:4096 -sha256 -nodes -keyout bwkc.key " + - "-out bwkc.crt -subj \"/CN=Bitwarden Key Connector\" -days 36500"); - Helpers.Exec($"openssl pkcs12 -export -out {_context.App.RootDirectory}/key-connector/bwkc.pfx -inkey bwkc.key " + - $"-in bwkc.crt -passout pass:{keyConnectorCertPassword}"); + Helpers.Exec( + "openssl", + [ + "req", + "-x509", + "-newkey", "rsa:4096", + "-sha256", + "-nodes", + "-keyout", "bwkc.key", + "-out", "bwkc.crt", + "-subj", "/CN=Bitwarden Key Connector", + "-days", "36500", + ]); + Helpers.Exec( + "openssl", + [ + "pkcs12", + "-export", + "-out", $"{_context.App.RootDirectory}/key-connector/bwkc.pfx", + "-inkey", "bwkc.key", + "-in", "bwkc.crt", + "-passout", $"pass:{keyConnectorCertPassword}" + ]); } } } diff --git a/util/Setup/EnvironmentFileBuilder.cs b/util/Setup/EnvironmentFileBuilder.cs index 50e6650546bf..79d98a90c251 100644 --- a/util/Setup/EnvironmentFileBuilder.cs +++ b/util/Setup/EnvironmentFileBuilder.cs @@ -178,13 +178,13 @@ private void Build() { sw.Write(template(new TemplateModel(_globalValues))); } - Helpers.Exec($"chmod 600 {_context.App.RootDirectory}/docker/global.env"); + Helpers.Exec("chmod", ["600", $"{_context.App.RootDirectory}/docker/global.env"]); using (var sw = File.CreateText($"{_context.App.RootDirectory}/docker/mssql.env")) { sw.Write(template(new TemplateModel(_mssqlValues))); } - Helpers.Exec($"chmod 600 {_context.App.RootDirectory}/docker/mssql.env"); + Helpers.Exec("chmod", ["600", $"{_context.App.RootDirectory}/docker/mssql.env"]); Helpers.WriteLine(_context, "Building docker environment override files."); Directory.CreateDirectory($"{_context.App.RootDirectory}/env/"); @@ -192,13 +192,13 @@ private void Build() { sw.Write(template(new TemplateModel(_globalOverrideValues))); } - Helpers.Exec($"chmod 600 {_context.App.RootDirectory}/env/global.override.env"); + Helpers.Exec("chmod", ["600", $"{_context.App.RootDirectory}/env/global.override.env"]); using (var sw = File.CreateText($"{_context.App.RootDirectory}/env/mssql.override.env")) { sw.Write(template(new TemplateModel(_mssqlOverrideValues))); } - Helpers.Exec($"chmod 600 {_context.App.RootDirectory}/env/mssql.override.env"); + Helpers.Exec("chmod", ["600", $"{_context.App.RootDirectory}/env/mssql.override.env"]); if (_context.Config.EnableKeyConnector) { @@ -207,7 +207,7 @@ private void Build() sw.Write(template(new TemplateModel(_keyConnectorOverrideValues))); } - Helpers.Exec($"chmod 600 {_context.App.RootDirectory}/env/key-connector.override.env"); + Helpers.Exec("chmod", ["600", $"{_context.App.RootDirectory}/env/key-connector.override.env"]); } // Empty uid env file. Only used on Linux hosts. diff --git a/util/Setup/Helpers.cs b/util/Setup/Helpers.cs index 40cf8fa8277a..b89aa1a6bc83 100644 --- a/util/Setup/Helpers.cs +++ b/util/Setup/Helpers.cs @@ -3,7 +3,6 @@ using System.Diagnostics; using System.Reflection; -using System.Runtime.InteropServices; using System.Security.Cryptography; using System.Text; @@ -112,35 +111,37 @@ public static string GetValueFromEnvFile(Application config, string envFile, str return null; } - public static string Exec(string cmd, bool returnStdout = false) + public static string Exec(string cmd, string[] arguments = null, bool returnStdout = false, bool returnStderr = false) { - var process = new Process + var startInfo = new ProcessStartInfo(cmd, arguments ?? []) { - StartInfo = new ProcessStartInfo - { - RedirectStandardOutput = true, - UseShellExecute = false, - CreateNoWindow = true, - WindowStyle = ProcessWindowStyle.Hidden - } + RedirectStandardOutput = returnStdout, + RedirectStandardError = returnStderr, + UseShellExecute = false, + CreateNoWindow = true, + WindowStyle = ProcessWindowStyle.Hidden }; - if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + var process = new Process { StartInfo = startInfo }; + + var result = new StringBuilder(); + + process.OutputDataReceived += (_, e) => { - var escapedArgs = cmd.Replace("\"", "\\\""); - process.StartInfo.FileName = "/bin/sh"; - process.StartInfo.Arguments = $"-c \"{escapedArgs}\""; - } - else + if (!returnStdout || e.Data == null) return; + result.Append(e.Data); + }; + + process.ErrorDataReceived += (_, e) => { - process.StartInfo.FileName = "powershell"; - process.StartInfo.Arguments = cmd; - } + if (!returnStderr || e.Data == null) return; + result.Append(e.Data); + }; process.Start(); - var result = returnStdout ? process.StandardOutput.ReadToEnd() : null; process.WaitForExit(); - return result; + + return result.ToString(); } public static string ReadInput(string prompt) diff --git a/util/Setup/Program.cs b/util/Setup/Program.cs index f7fa3ebe0a38..3c42e45c8be5 100644 --- a/util/Setup/Program.cs +++ b/util/Setup/Program.cs @@ -159,19 +159,65 @@ private static void Update(Application application) // a new cert and bag to replace the old Identity.pfx. This fixes an issue that came up as a result of // moving the project to .NET 5. _context.Install.IdentityCertPassword = Helpers.GetValueFromEnvFile(_context.App, "global", "globalSettings__identityServer__certificatePassword"); - var certCountString = Helpers.Exec($"openssl pkcs12 -nokeys -info -in {application.RootDirectory}/identity/identity.pfx " + - $"-passin pass:{_context.Install.IdentityCertPassword} 2> /dev/null | grep -c \"\\-----BEGIN CERTIFICATE----\"", true); - if (int.TryParse(certCountString, out var certCount) && certCount > 1) + var certOutput = Helpers.Exec( + "openssl", + [ + "pkcs12", + "-nokeys", + "-info", + "-in", $"{application.RootDirectory}/identity/identity.pfx", + "-passin", $"pass:{_context.Install.IdentityCertPassword}", + ], + returnStdout: true, + returnStderr: false); + + var certCount = certOutput + .Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries) + .Count(line => line.Contains("-----BEGIN CERTIFICATE-----")); + + if (certCount > 1) { // Extract key from identity.pfx - Helpers.Exec($"openssl pkcs12 -in {application.RootDirectory}/identity/identity.pfx -nocerts -nodes -out identity.key " + - $"-passin pass:{_context.Install.IdentityCertPassword} > /dev/null 2>&1"); + Helpers.Exec( + "openssl", + [ + "pkcs12", + "-in", $"{application.RootDirectory}/identity/identity.pfx", + "-nocerts", + "-nodes", + "-out identity.key", + "-passin", $"pass:{_context.Install.IdentityCertPassword}", + ], + returnStdout: false, + returnStderr: false); + // Extract certificate from identity.pfx - Helpers.Exec($"openssl pkcs12 -in {application.RootDirectory}/identity/identity.pfx -clcerts -nokeys -out identity.crt " + - $"-passin pass:{_context.Install.IdentityCertPassword} > /dev/null 2>&1"); + Helpers.Exec( + "openssl", + [ + "pkcs12", + "-in", $"{application.RootDirectory}/identity/identity.pfx", + "-clcerts", + "-nokeys", + "-out", "identity.crt", + "-passin", $"pass:{_context.Install.IdentityCertPassword}", + ], + returnStdout: false, + returnStderr: false); + // Create new PKCS12 bag with certificate and key - Helpers.Exec($"openssl pkcs12 -export -out {application.RootDirectory}/identity/identity.pfx -inkey identity.key " + - $"-in identity.crt -passout pass:{_context.Install.IdentityCertPassword} > /dev/null 2>&1"); + Helpers.Exec( + "openssl", + [ + "pkcs12", + "-export", + "-out", $"{application.RootDirectory}/identity/identity.pfx", + "-inkey", "identity.key", + "-in", $"identity.crt", + "-passout", $"pass:{_context.Install.IdentityCertPassword}" + ], + returnStdout: false, + returnStderr: false); } if (_context.Parameters.ContainsKey("db")) From a5b2ac033e9b8879c0a6ed817c55e293263cb924 Mon Sep 17 00:00:00 2001 From: Derek Nance Date: Fri, 17 Apr 2026 14:04:22 -0500 Subject: [PATCH 2/7] missed a directory update --- util/Setup/CertBuilder.cs | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/util/Setup/CertBuilder.cs b/util/Setup/CertBuilder.cs index 813fcaeefa6c..8725ff9f7290 100644 --- a/util/Setup/CertBuilder.cs +++ b/util/Setup/CertBuilder.cs @@ -39,7 +39,7 @@ public void BuildForInstall() } else if (_context.App.ReadQuestion("Do you want to generate a self-signed SSL certificate?")) { - Directory.CreateDirectory($"{_context.App.RootDirectory}/ssl/self/{_context.Install.Domain}/"); + var directory = Directory.CreateDirectory($"{_context.App.RootDirectory}/ssl/self/{_context.Install.Domain}/"); Helpers.WriteLine(_context, "Generating self signed SSL certificate."); _context.Config.Ssl = true; _context.Install.Trusted = false; @@ -58,8 +58,8 @@ public void BuildForInstall() "-sha256", "-nodes", "-days", "36500", - "-keyout", $"/bitwarden/ssl/self/{_context.Install.Domain}/private.key", - "-out", $"/bitwarden/ssl/self/{_context.Install.Domain}/certificate.crt", + "-keyout", $"{directory.FullName}/private.key", + "-out", $"{directory.FullName}/certificate.crt", "-reqexts", "SAN", "-extensions", "SAN", "-config", opensslConfigPath, @@ -74,13 +74,12 @@ public void BuildForInstall() { _context.Install.Trusted = true; _context.Install.DiffieHellman = true; - var directory = $"{_context.App.RootDirectory}/letsencrypt/live/{_context.Install.Domain}/"; - Directory.CreateDirectory(directory); + var directory = Directory.CreateDirectory($"{_context.App.RootDirectory}/letsencrypt/live/{_context.Install.Domain}/"); Helpers.Exec( "openssl", [ "dhparam", - "-out", $"{directory}/dhparam.pem", + "-out", $"{directory.FullName}/dhparam.pem", "2048", ]); } From 85a5b248f0fb2ca9667699ca8721654e21cab112 Mon Sep 17 00:00:00 2001 From: Derek Nance Date: Fri, 17 Apr 2026 14:49:11 -0500 Subject: [PATCH 3/7] use local time for date comparison assert a cert with a non-utc `NotBefore` is not considered in range by xUnit's assert helper. --- test/Setup.Test/ProgramTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/Setup.Test/ProgramTests.cs b/test/Setup.Test/ProgramTests.cs index 256bcc68be9b..401930a338ea 100644 --- a/test/Setup.Test/ProgramTests.cs +++ b/test/Setup.Test/ProgramTests.cs @@ -72,7 +72,7 @@ public async Task Install_Works() Assert.True(certFile.Exists); var cert = new X509Certificate2(certFile.FullName); - var hundredYearsFromNow = DateTime.UtcNow.AddDays(36500); + var hundredYearsFromNow = DateTime.Now.AddDays(36500); Assert.InRange(cert.NotAfter, hundredYearsFromNow.AddMinutes(-1), hundredYearsFromNow.AddMinutes(1)); From 59cd2ee0f04ea4762eb75f44743aac0777f559ae Mon Sep 17 00:00:00 2001 From: Derek Nance Date: Fri, 17 Apr 2026 15:42:36 -0500 Subject: [PATCH 4/7] fix a missed arg split --- util/Setup/Program.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/util/Setup/Program.cs b/util/Setup/Program.cs index 3c42e45c8be5..0a75dd3eaa98 100644 --- a/util/Setup/Program.cs +++ b/util/Setup/Program.cs @@ -185,7 +185,7 @@ private static void Update(Application application) "-in", $"{application.RootDirectory}/identity/identity.pfx", "-nocerts", "-nodes", - "-out identity.key", + "-out", "identity.key", "-passin", $"pass:{_context.Install.IdentityCertPassword}", ], returnStdout: false, From e0f7bd3213d44e0eab33da335785c5f5737c0989 Mon Sep 17 00:00:00 2001 From: Derek Nance Date: Fri, 17 Apr 2026 15:43:34 -0500 Subject: [PATCH 5/7] fix process output ingestion --- util/Setup/Helpers.cs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/util/Setup/Helpers.cs b/util/Setup/Helpers.cs index b89aa1a6bc83..34dd2f17dd3e 100644 --- a/util/Setup/Helpers.cs +++ b/util/Setup/Helpers.cs @@ -129,16 +129,20 @@ public static string Exec(string cmd, string[] arguments = null, bool returnStdo process.OutputDataReceived += (_, e) => { if (!returnStdout || e.Data == null) return; - result.Append(e.Data); + result.AppendLine(e.Data); }; process.ErrorDataReceived += (_, e) => { if (!returnStderr || e.Data == null) return; - result.Append(e.Data); + result.AppendLine(e.Data); }; process.Start(); + + if (returnStdout) process.BeginOutputReadLine(); + if (returnStderr) process.BeginErrorReadLine(); + process.WaitForExit(); return result.ToString(); From 84eedb8666e5bd262f3f7c37fd05a04003c6b41c Mon Sep 17 00:00:00 2001 From: Derek Nance Date: Mon, 20 Apr 2026 16:14:03 -0500 Subject: [PATCH 6/7] clean up temp dir --- util/Setup/CertBuilder.cs | 42 ++++++++++++++++++++++----------------- 1 file changed, 24 insertions(+), 18 deletions(-) diff --git a/util/Setup/CertBuilder.cs b/util/Setup/CertBuilder.cs index 8725ff9f7290..f5e9d8ad76d6 100644 --- a/util/Setup/CertBuilder.cs +++ b/util/Setup/CertBuilder.cs @@ -48,24 +48,30 @@ public void BuildForInstall() $"\n[SAN]\nsubjectAltName=DNS:{_context.Install.Domain}\nbasicConstraints=CA:true"; var opensslConfigPath = Path.GetTempFileName(); - File.WriteAllText(opensslConfigPath, opensslConfig); - Helpers.Exec( - "openssl", - [ - "req", - "-x509", - "-newkey", "rsa:4096", - "-sha256", - "-nodes", - "-days", "36500", - "-keyout", $"{directory.FullName}/private.key", - "-out", $"{directory.FullName}/certificate.crt", - "-reqexts", "SAN", - "-extensions", "SAN", - "-config", opensslConfigPath, - "-subj", $"/C=US/ST=California/L=Santa Barbara/O=Bitwarden Inc./OU=Bitwarden/CN={_context.Install.Domain}", - ]); - File.Delete(opensslConfigPath); + try + { + File.WriteAllText(opensslConfigPath, opensslConfig); + Helpers.Exec( + "openssl", + [ + "req", + "-x509", + "-newkey", "rsa:4096", + "-sha256", + "-nodes", + "-days", "36500", + "-keyout", $"{directory.FullName}/private.key", + "-out", $"{directory.FullName}/certificate.crt", + "-reqexts", "SAN", + "-extensions", "SAN", + "-config", opensslConfigPath, + "-subj", $"/C=US/ST=California/L=Santa Barbara/O=Bitwarden Inc./OU=Bitwarden/CN={_context.Install.Domain}", + ]); + } + finally + { + File.Delete(opensslConfigPath); + } } } } From 9ed9c3b548541ed2ab8b76e41693e91a27bbc609 Mon Sep 17 00:00:00 2001 From: Derek Nance Date: Mon, 20 Apr 2026 16:17:12 -0500 Subject: [PATCH 7/7] validate domain arg --- util/Setup/Program.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/util/Setup/Program.cs b/util/Setup/Program.cs index 0a75dd3eaa98..1ab83369ae4e 100644 --- a/util/Setup/Program.cs +++ b/util/Setup/Program.cs @@ -96,6 +96,11 @@ private static void Install(Application application) if (_context.Parameters.TryGetValue("domain", out var domain)) { _context.Install.Domain = domain.ToLowerInvariant(); + if (Uri.CheckHostName(_context.Install.Domain) != UriHostNameType.Dns) + { + Helpers.WriteError("Domain is invalid. A valid domain name is required (e.g. bitwarden.example.com)."); + return; + } } if (_context.Parameters.TryGetValue("dbname", out var database)) {