From 8bd8d688ef42b36a396539ebd0f973fa5c77cf8b Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Fri, 8 Aug 2025 14:10:16 +0200 Subject: [PATCH] Add generic secretreader to support files when running in docker (#1098) --- apps/server/AliasVault.Admin/Program.cs | 12 +- .../Controllers/AuthController.cs | 13 +- apps/server/AliasVault.Api/Program.cs | 7 +- .../Utilities/SecretReader.cs | 138 ++++++++++++++++++ .../AliasVault.Cryptography.Server.csproj | 1 + .../DataProtectionExtensions.cs | 24 +-- 6 files changed, 155 insertions(+), 40 deletions(-) create mode 100644 apps/server/Shared/AliasVault.Shared.Server/Utilities/SecretReader.cs diff --git a/apps/server/AliasVault.Admin/Program.cs b/apps/server/AliasVault.Admin/Program.cs index a6e15d7b0..6a8bba222 100644 --- a/apps/server/AliasVault.Admin/Program.cs +++ b/apps/server/AliasVault.Admin/Program.cs @@ -5,7 +5,6 @@ // //----------------------------------------------------------------------- -using System.Globalization; using System.Reflection; using AliasServerDb; using AliasServerDb.Configuration; @@ -19,6 +18,7 @@ using AliasVault.Logging; using AliasVault.RazorComponents.Services; using AliasVault.Shared.Models.Configuration; using AliasVault.Shared.Server.Services; +using AliasVault.Shared.Server.Utilities; using ApexCharts; using Microsoft.AspNetCore.Components.Authorization; using Microsoft.AspNetCore.HttpOverrides; @@ -30,13 +30,13 @@ builder.Configuration.AddJsonFile("appsettings.json", optional: false, reloadOnC builder.Configuration.AddJsonFile($"appsettings.{builder.Environment.EnvironmentName}.json", optional: true, reloadOnChange: true); builder.Services.ConfigureLogging(builder.Configuration, Assembly.GetExecutingAssembly().GetName().Name!, "../../logs"); -// Create global config object, get values from environment variables. +// Create global config object, get values from environment variables or container secrets. var config = new Config(); -var adminPasswordHash = Environment.GetEnvironmentVariable("ADMIN_PASSWORD_HASH") ?? throw new KeyNotFoundException("ADMIN_PASSWORD_HASH environment variable is not set."); -config.AdminPasswordHash = adminPasswordHash; -var lastPasswordChanged = Environment.GetEnvironmentVariable("ADMIN_PASSWORD_GENERATED") ?? throw new KeyNotFoundException("ADMIN_PASSWORD_GENERATED environment variable is not set."); -config.LastPasswordChanged = DateTime.Parse(lastPasswordChanged, CultureInfo.InvariantCulture); +// Get admin password hash and generation timestamp using SecretReader +var (adminPasswordHash, lastPasswordChanged) = SecretReader.GetAdminPasswordHash(); +config.AdminPasswordHash = adminPasswordHash; +config.LastPasswordChanged = lastPasswordChanged; var ipLoggingEnabled = Environment.GetEnvironmentVariable("IP_LOGGING_ENABLED") ?? "false"; config.IpLoggingEnabled = bool.Parse(ipLoggingEnabled); diff --git a/apps/server/AliasVault.Api/Controllers/AuthController.cs b/apps/server/AliasVault.Api/Controllers/AuthController.cs index bc0ad43dd..0132fa9d5 100644 --- a/apps/server/AliasVault.Api/Controllers/AuthController.cs +++ b/apps/server/AliasVault.Api/Controllers/AuthController.cs @@ -22,6 +22,7 @@ using AliasVault.Shared.Models.WebApi.Auth; using AliasVault.Shared.Models.WebApi.PasswordChange; using AliasVault.Shared.Providers.Time; using AliasVault.Shared.Server.Services; +using AliasVault.Shared.Server.Utilities; using Asp.Versioning; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; @@ -615,19 +616,13 @@ public class AuthController(IAliasServerDbContextFactory dbContextFactory, UserM } /// - /// Get the JWT key from the environment variables. + /// Get the JWT key from the environment variables or container secrets. /// /// JWT key as string. - /// Thrown if environment variable does not exist. + /// Thrown if JWT key cannot be found. private static string GetJwtKey() { - var jwtKey = Environment.GetEnvironmentVariable("JWT_KEY"); - if (jwtKey is null) - { - throw new KeyNotFoundException("JWT_KEY environment variable is not set."); - } - - return jwtKey; + return SecretReader.GetJwtKey(); } /// diff --git a/apps/server/AliasVault.Api/Program.cs b/apps/server/AliasVault.Api/Program.cs index 365d51cb6..c3fd6e053 100644 --- a/apps/server/AliasVault.Api/Program.cs +++ b/apps/server/AliasVault.Api/Program.cs @@ -20,6 +20,7 @@ using AliasVault.Logging; using AliasVault.Shared.Models.Configuration; using AliasVault.Shared.Providers.Time; using AliasVault.Shared.Server.Services; +using AliasVault.Shared.Server.Utilities; using Asp.Versioning; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Identity; @@ -94,11 +95,7 @@ builder.Services.AddAuthentication(options => options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; }).AddJwtBearer(options => { - var jwtKey = Environment.GetEnvironmentVariable("JWT_KEY"); - if (jwtKey is null) - { - throw new KeyNotFoundException("JWT_KEY environment variable is not set."); - } + var jwtKey = SecretReader.GetJwtKey(); options.IncludeErrorDetails = true; options.TokenValidationParameters = new TokenValidationParameters diff --git a/apps/server/Shared/AliasVault.Shared.Server/Utilities/SecretReader.cs b/apps/server/Shared/AliasVault.Shared.Server/Utilities/SecretReader.cs new file mode 100644 index 000000000..fb58c5cc0 --- /dev/null +++ b/apps/server/Shared/AliasVault.Shared.Server/Utilities/SecretReader.cs @@ -0,0 +1,138 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) lanedirt. All rights reserved. +// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information. +// +//----------------------------------------------------------------------- + +namespace AliasVault.Shared.Server.Utilities; + +using System; +using System.Globalization; +using System.IO; + +/// +/// Utility class for reading secrets from environment variables or container secret files. +/// +public static class SecretReader +{ + /// + /// Determines if the application is running in a container. + /// + /// True if running in a container, false otherwise. + public static bool IsRunningInContainer() + { + return Environment.GetEnvironmentVariable("DOTNET_RUNNING_IN_CONTAINER") == "true"; + } + + /// + /// Gets the JWT key from either the container secrets file or environment variable. + /// + /// The JWT key. + /// Thrown when the JWT key cannot be found or is invalid. + public static string GetJwtKey() + { + if (IsRunningInContainer()) + { + return ReadSecretFromFile("/secrets/jwt_key", "JWT key"); + } + + var jwtKey = Environment.GetEnvironmentVariable("JWT_KEY"); + if (string.IsNullOrEmpty(jwtKey)) + { + throw new KeyNotFoundException("JWT_KEY environment variable is not set."); + } + + return jwtKey; + } + + /// + /// Gets the admin password hash and generation timestamp. + /// + /// A tuple containing the password hash and the timestamp when it was generated. + /// Thrown when the admin password hash cannot be found. + /// Thrown when the admin password hash file has an invalid format. + public static (string PasswordHash, DateTime GeneratedAt) GetAdminPasswordHash() + { + if (IsRunningInContainer()) + { + var secretsFilePath = "/secrets/admin_password_hash"; + if (!File.Exists(secretsFilePath)) + { + throw new KeyNotFoundException($"Admin password hash file not found at {secretsFilePath}. Container initialization may have failed."); + } + + var secretContent = File.ReadAllText(secretsFilePath).Trim(); + if (string.IsNullOrEmpty(secretContent)) + { + throw new KeyNotFoundException($"Admin password hash file at {secretsFilePath} is empty."); + } + + // Parse hash and timestamp separated by | + var parts = secretContent.Split('|'); + if (parts.Length != 2) + { + throw new InvalidOperationException($"Invalid format in {secretsFilePath}. Expected format: hash|timestamp"); + } + + var passwordHash = parts[0]; + if (!DateTime.TryParse(parts[1], CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var generatedAt)) + { + throw new InvalidOperationException($"Invalid timestamp format in {secretsFilePath}: {parts[1]}"); + } + + return (passwordHash, generatedAt); + } + else + { + // Not in container - use environment variables + var passwordHash = Environment.GetEnvironmentVariable("ADMIN_PASSWORD_HASH") + ?? throw new KeyNotFoundException("ADMIN_PASSWORD_HASH environment variable is not set."); + + var generatedAtStr = Environment.GetEnvironmentVariable("ADMIN_PASSWORD_GENERATED") + ?? throw new KeyNotFoundException("ADMIN_PASSWORD_GENERATED environment variable is not set."); + + var generatedAt = DateTime.Parse(generatedAtStr, CultureInfo.InvariantCulture); + return (passwordHash, generatedAt); + } + } + + /// + /// Gets the data protection certificate password. + /// + /// The data protection certificate password. + /// Thrown when the certificate password cannot be found. + public static string GetDataProtectionCertPassword() + { + if (IsRunningInContainer()) + { + return ReadSecretFromFile("/secrets/data_protection_cert_pass", "Certificate password"); + } + + return Environment.GetEnvironmentVariable("DATA_PROTECTION_CERT_PASS") + ?? throw new KeyNotFoundException("DATA_PROTECTION_CERT_PASS is not set in configuration or environment variables."); + } + + /// + /// Reads a secret from a file in the container secrets directory. + /// + /// The path to the secret file. + /// The name of the secret for error messages. + /// The secret value. + /// Thrown when the secret file cannot be found or is invalid. + private static string ReadSecretFromFile(string filePath, string secretName) + { + if (!File.Exists(filePath)) + { + throw new KeyNotFoundException($"{secretName} file not found at {filePath}. Container initialization may have failed."); + } + + var secretValue = File.ReadAllText(filePath).Trim(); + if (string.IsNullOrEmpty(secretValue)) + { + throw new KeyNotFoundException($"{secretName} file at {filePath} is empty."); + } + + return secretValue; + } +} diff --git a/apps/server/Utilities/Cryptography/AliasVault.Cryptography.Server/AliasVault.Cryptography.Server.csproj b/apps/server/Utilities/Cryptography/AliasVault.Cryptography.Server/AliasVault.Cryptography.Server.csproj index 2f9ac9fa4..a9f739cf3 100644 --- a/apps/server/Utilities/Cryptography/AliasVault.Cryptography.Server/AliasVault.Cryptography.Server.csproj +++ b/apps/server/Utilities/Cryptography/AliasVault.Cryptography.Server/AliasVault.Cryptography.Server.csproj @@ -30,6 +30,7 @@ + diff --git a/apps/server/Utilities/Cryptography/AliasVault.Cryptography.Server/DataProtectionExtensions.cs b/apps/server/Utilities/Cryptography/AliasVault.Cryptography.Server/DataProtectionExtensions.cs index ed43a96d0..d5b04f680 100644 --- a/apps/server/Utilities/Cryptography/AliasVault.Cryptography.Server/DataProtectionExtensions.cs +++ b/apps/server/Utilities/Cryptography/AliasVault.Cryptography.Server/DataProtectionExtensions.cs @@ -9,6 +9,7 @@ namespace AliasVault.Cryptography.Server; using System.Security.Cryptography.X509Certificates; using AliasServerDb; +using AliasVault.Shared.Server.Utilities; using Microsoft.AspNetCore.DataProtection; using Microsoft.Extensions.DependencyInjection; @@ -28,14 +29,11 @@ public static class DataProtectionExtensions this IServiceCollection services, string applicationName) { - // Determine if running in a container - var isContainer = Environment.GetEnvironmentVariable("DOTNET_RUNNING_IN_CONTAINER") == "true"; - var dataProtectionBuilder = services.AddDataProtection() .PersistKeysToDbContext() .SetApplicationName(applicationName); - if (isContainer) + if (SecretReader.IsRunningInContainer()) { ConfigureContainerDataProtection(dataProtectionBuilder); } @@ -53,19 +51,6 @@ public static class DataProtectionExtensions /// The data protection builder. private static void ConfigureContainerDataProtection(IDataProtectionBuilder dataProtectionBuilder) { - // In container, load password from file - var certPassPath = "/secrets/data_protection_cert_pass"; - if (!File.Exists(certPassPath)) - { - throw new KeyNotFoundException($"Certificate password file not found at {certPassPath}."); - } - - var certPassword = File.ReadAllText(certPassPath).Trim(); - if (string.IsNullOrEmpty(certPassword)) - { - throw new KeyNotFoundException($"Certificate password file at {certPassPath} is empty."); - } - // When running in containers, don't use certificate-based key protection due to Linux keystore limitations // Keys are protected by database access controls, TLS, and container isolation dataProtectionBuilder @@ -84,9 +69,8 @@ public static class DataProtectionExtensions /// The application name. private static void ConfigureDevelopmentDataProtection(IDataProtectionBuilder dataProtectionBuilder, string applicationName) { - // Not in container, require environment variable - var certPassword = Environment.GetEnvironmentVariable("DATA_PROTECTION_CERT_PASS") - ?? throw new KeyNotFoundException("DATA_PROTECTION_CERT_PASS is not set in configuration or environment variables."); + // Not in container, get certificate password using SecretReader + var certPassword = SecretReader.GetDataProtectionCertPassword(); var certPath = $"../../certificates/app/{applicationName}.DataProtection.pfx"; if (certPassword == "Development")