Add generic secretreader to support files when running in docker (#1098)

This commit is contained in:
Leendert de Borst
2025-08-08 14:10:16 +02:00
committed by Leendert de Borst
parent c174a6bfb4
commit 8bd8d688ef
6 changed files with 155 additions and 40 deletions

View File

@@ -5,7 +5,6 @@
// </copyright>
//-----------------------------------------------------------------------
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);

View File

@@ -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
}
/// <summary>
/// Get the JWT key from the environment variables.
/// Get the JWT key from the environment variables or container secrets.
/// </summary>
/// <returns>JWT key as string.</returns>
/// <exception cref="KeyNotFoundException">Thrown if environment variable does not exist.</exception>
/// <exception cref="KeyNotFoundException">Thrown if JWT key cannot be found.</exception>
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();
}
/// <summary>

View File

@@ -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

View File

@@ -0,0 +1,138 @@
//-----------------------------------------------------------------------
// <copyright file="SecretReader.cs" company="lanedirt">
// Copyright (c) lanedirt. All rights reserved.
// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Shared.Server.Utilities;
using System;
using System.Globalization;
using System.IO;
/// <summary>
/// Utility class for reading secrets from environment variables or container secret files.
/// </summary>
public static class SecretReader
{
/// <summary>
/// Determines if the application is running in a container.
/// </summary>
/// <returns>True if running in a container, false otherwise.</returns>
public static bool IsRunningInContainer()
{
return Environment.GetEnvironmentVariable("DOTNET_RUNNING_IN_CONTAINER") == "true";
}
/// <summary>
/// Gets the JWT key from either the container secrets file or environment variable.
/// </summary>
/// <returns>The JWT key.</returns>
/// <exception cref="KeyNotFoundException">Thrown when the JWT key cannot be found or is invalid.</exception>
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;
}
/// <summary>
/// Gets the admin password hash and generation timestamp.
/// </summary>
/// <returns>A tuple containing the password hash and the timestamp when it was generated.</returns>
/// <exception cref="KeyNotFoundException">Thrown when the admin password hash cannot be found.</exception>
/// <exception cref="InvalidOperationException">Thrown when the admin password hash file has an invalid format.</exception>
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);
}
}
/// <summary>
/// Gets the data protection certificate password.
/// </summary>
/// <returns>The data protection certificate password.</returns>
/// <exception cref="KeyNotFoundException">Thrown when the certificate password cannot be found.</exception>
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.");
}
/// <summary>
/// Reads a secret from a file in the container secrets directory.
/// </summary>
/// <param name="filePath">The path to the secret file.</param>
/// <param name="secretName">The name of the secret for error messages.</param>
/// <returns>The secret value.</returns>
/// <exception cref="KeyNotFoundException">Thrown when the secret file cannot be found or is invalid.</exception>
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;
}
}

View File

@@ -30,6 +30,7 @@
<ItemGroup>
<ProjectReference Include="..\..\..\Databases\AliasServerDb\AliasServerDb.csproj" />
<ProjectReference Include="..\..\..\Shared\AliasVault.Shared.Server\AliasVault.Shared.Server.csproj" />
</ItemGroup>
</Project>

View File

@@ -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<AliasServerDbContext>()
.SetApplicationName(applicationName);
if (isContainer)
if (SecretReader.IsRunningInContainer())
{
ConfigureContainerDataProtection(dataProtectionBuilder);
}
@@ -53,19 +51,6 @@ public static class DataProtectionExtensions
/// <param name="dataProtectionBuilder">The data protection builder.</param>
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
/// <param name="applicationName">The application name.</param>
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")