feat(persistence): encrypt admin_config secure keys at rest + direct repo tests
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
53
tests/VolcanicCore.Tests/AdminConfigEncryptionTests.cs
Normal file
53
tests/VolcanicCore.Tests/AdminConfigEncryptionTests.cs
Normal 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()!);
|
||||
}
|
||||
}
|
||||
44
tests/VolcanicCore.Tests/AdminConfigRepositoryTests.cs
Normal file
44
tests/VolcanicCore.Tests/AdminConfigRepositoryTests.cs
Normal 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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user