feat(persistence): encrypt admin_config secure keys at rest + direct repo tests

This commit is contained in:
Claude
2026-08-28 14:35:05 +00:00
parent 11aae45000
commit af905361eb
6 changed files with 128 additions and 12 deletions

View File

@@ -20,11 +20,8 @@ public static class AdminBootstrap
if (existingHash is not null && existingSource == adminPassword) return;
repo.Set(PasswordHashKey, pw.Hash(adminPassword));
// Storing the plaintext env value here (not the hash) is the change-detector for
// future boots: same env → skip re-hash, changed env → re-seed. Trade-off is
// deliberate for the single-admin design — the env value is already trusted at
// boot time and no more sensitive than the credential itself. Encryption at rest
// for admin_config is deferred to Plan 2.
// Change-detector for boot: same env value → skip re-hash; changed → re-seed.
// Stored encrypted at rest via AdminConfigRepository.DefaultSecureKeys.
repo.Set(PasswordSourceKey, adminPassword);
}
}

View File

@@ -66,6 +66,8 @@ builder.Services.AddSingleton(settings);
Directory.CreateDirectory(settings.DataDirectory);
builder.Services.AddSingleton(new SqliteConnectionFactory(settings.DatabasePath));
var encKey = EncryptionKeyLoader.Load(settings.EncryptionKeyBase64, settings.EncryptionKeyFilePath);
builder.Services.AddSingleton(new EncryptionService(encKey));
builder.Services.AddSingleton<AdminConfigRepository>();
builder.Services.AddSingleton<Migrator>(_ => new Migrator(new IMigration[]
{
@@ -106,9 +108,6 @@ builder.Services.AddSingleton<NodeRegistry>(sp =>
builder.Services.AddSingleton<ConnectionProviderRegistry>(sp =>
new ConnectionProviderRegistry(sp.GetServices<IConnectionProvider>()));
var encKey = EncryptionKeyLoader.Load(settings.EncryptionKeyBase64, settings.EncryptionKeyFilePath);
builder.Services.AddSingleton(new EncryptionService(encKey));
var app = builder.Build();
// Apply migrations at boot.

View File

@@ -1,11 +1,29 @@
using Microsoft.Data.Sqlite;
using VolcanicCore.Persistence.Encryption;
namespace VolcanicCore.Persistence.Repositories;
public sealed class AdminConfigRepository
{
public static readonly IReadOnlySet<string> DefaultSecureKeys =
new HashSet<string>(StringComparer.Ordinal)
{
"admin.password_hash_source",
};
private readonly SqliteConnectionFactory _factory;
public AdminConfigRepository(SqliteConnectionFactory factory) => _factory = factory;
private readonly EncryptionService _enc;
private readonly IReadOnlySet<string> _secureKeys;
public AdminConfigRepository(
SqliteConnectionFactory factory,
EncryptionService enc,
IReadOnlySet<string>? secureKeys = null)
{
_factory = factory;
_enc = enc;
_secureKeys = secureKeys ?? DefaultSecureKeys;
}
public string? Get(string key)
{
@@ -13,11 +31,14 @@ public sealed class AdminConfigRepository
using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT value FROM admin_config WHERE key = $k";
cmd.Parameters.AddWithValue("$k", key);
return cmd.ExecuteScalar() as string;
var raw = cmd.ExecuteScalar() as string;
if (raw is null) return null;
return _secureKeys.Contains(key) ? _enc.Decrypt(raw) : raw;
}
public void Set(string key, string value)
{
var stored = _secureKeys.Contains(key) ? _enc.Encrypt(value) : value;
using var conn = _factory.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = @"
@@ -25,7 +46,7 @@ public sealed class AdminConfigRepository
ON CONFLICT(key) DO UPDATE SET value = excluded.value;
";
cmd.Parameters.AddWithValue("$k", key);
cmd.Parameters.AddWithValue("$v", value);
cmd.Parameters.AddWithValue("$v", stored);
cmd.ExecuteNonQuery();
}
}

View File

@@ -1,6 +1,7 @@
using Microsoft.Data.Sqlite;
using VolcanicCore.Api.Auth;
using VolcanicCore.Persistence;
using VolcanicCore.Persistence.Encryption;
using VolcanicCore.Persistence.Migrations;
using VolcanicCore.Persistence.Repositories;
@@ -14,7 +15,8 @@ public class AdminBootstrapTests
var f = new SqliteConnectionFactory(path);
using (var conn = f.Open())
new Migrator(new IMigration[] { new Migration001Initial() }).Apply(conn);
return (f, new AdminConfigRepository(f));
var key = new byte[32]; System.Security.Cryptography.RandomNumberGenerator.Fill(key);
return (f, new AdminConfigRepository(f, new EncryptionService(key)));
}
[Fact]

View File

@@ -0,0 +1,53 @@
using Microsoft.Data.Sqlite;
using System.Security.Cryptography;
using VolcanicCore.Persistence;
using VolcanicCore.Persistence.Encryption;
using VolcanicCore.Persistence.Migrations;
using VolcanicCore.Persistence.Repositories;
namespace VolcanicCore.Tests;
public class AdminConfigEncryptionTests
{
private static (AdminConfigRepository repo, SqliteConnectionFactory f, EncryptionService enc)
Setup(IReadOnlySet<string> secureKeys)
{
var path = Path.Combine(Path.GetTempPath(), $"vc-{Guid.NewGuid():N}.db");
var f = new SqliteConnectionFactory(path);
using (var conn = f.Open())
new Migrator(new IMigration[] { new Migration001Initial() }).Apply(conn);
var key = new byte[32]; RandomNumberGenerator.Fill(key);
var enc = new EncryptionService(key);
return (new AdminConfigRepository(f, enc, secureKeys), f, enc);
}
[Fact]
public void Secure_key_is_stored_encrypted_on_disk_but_reads_back_plaintext()
{
var secureKeys = new HashSet<string>(StringComparer.Ordinal) { "admin.password_hash_source" };
var (repo, f, enc) = Setup(secureKeys);
repo.Set("admin.password_hash_source", "hunter2");
// Reads back cleartext
Assert.Equal("hunter2", repo.Get("admin.password_hash_source"));
// Raw disk value is NOT plaintext, and decrypts back to plaintext.
using var conn = f.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT value FROM admin_config WHERE key = 'admin.password_hash_source';";
var raw = (string)cmd.ExecuteScalar()!;
Assert.NotEqual("hunter2", raw);
Assert.Equal("hunter2", enc.Decrypt(raw));
}
[Fact]
public void Non_secure_key_is_stored_as_plaintext()
{
var (repo, f, _) = Setup(new HashSet<string>());
repo.Set("some.key", "value");
using var conn = f.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT value FROM admin_config WHERE key = 'some.key';";
Assert.Equal("value", (string)cmd.ExecuteScalar()!);
}
}

View File

@@ -0,0 +1,44 @@
using System.Security.Cryptography;
using VolcanicCore.Persistence;
using VolcanicCore.Persistence.Encryption;
using VolcanicCore.Persistence.Migrations;
using VolcanicCore.Persistence.Repositories;
namespace VolcanicCore.Tests;
public class AdminConfigRepositoryTests
{
private static (AdminConfigRepository repo, string dbPath) Setup()
{
var path = Path.Combine(Path.GetTempPath(), $"vc-{Guid.NewGuid():N}.db");
var f = new SqliteConnectionFactory(path);
using (var conn = f.Open())
new Migrator(new IMigration[] { new Migration001Initial() }).Apply(conn);
var key = new byte[32]; RandomNumberGenerator.Fill(key);
return (new AdminConfigRepository(f, new EncryptionService(key), new HashSet<string>()), path);
}
[Fact]
public void Get_returns_null_for_unknown_key()
{
var (repo, _) = Setup();
Assert.Null(repo.Get("nope"));
}
[Fact]
public void Set_inserts_new_row_and_get_reads_it_back()
{
var (repo, _) = Setup();
repo.Set("k", "v");
Assert.Equal("v", repo.Get("k"));
}
[Fact]
public void Set_updates_existing_row_via_upsert()
{
var (repo, _) = Setup();
repo.Set("k", "v1");
repo.Set("k", "v2");
Assert.Equal("v2", repo.Get("k"));
}
}