fix(auth): heal legacy plaintext admin.password_hash_source on boot
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
using System.Security.Cryptography;
|
||||
using VolcanicCore.Persistence.Repositories;
|
||||
|
||||
namespace VolcanicCore.Api.Auth;
|
||||
@@ -14,7 +15,25 @@ public static class AdminBootstrap
|
||||
"ADMIN_PASSWORD is required. Set the env var to a non-empty value.");
|
||||
|
||||
var existingHash = repo.Get(PasswordHashKey);
|
||||
var existingSource = repo.Get(PasswordSourceKey);
|
||||
|
||||
// Pre-Plan-2 DBs stored PasswordSourceKey as plaintext; the current
|
||||
// encrypted-at-rest repo will throw trying to decrypt it — either
|
||||
// FormatException (not valid base64) or CryptographicException
|
||||
// (wrong length / auth tag mismatch). Treat either as "unset" so
|
||||
// the boot re-seeds the row in the new format on the Set() below.
|
||||
string? existingSource;
|
||||
try
|
||||
{
|
||||
existingSource = repo.Get(PasswordSourceKey);
|
||||
}
|
||||
catch (CryptographicException)
|
||||
{
|
||||
existingSource = null;
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
existingSource = null;
|
||||
}
|
||||
|
||||
// Re-hash only if the env value has changed since last boot.
|
||||
if (existingHash is not null && existingSource == adminPassword) return;
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
using VolcanicCore.Api.Auth;
|
||||
using VolcanicCore.Persistence;
|
||||
using VolcanicCore.Persistence.Encryption;
|
||||
using VolcanicCore.Persistence.Migrations;
|
||||
using VolcanicCore.Persistence.Repositories;
|
||||
using Xunit;
|
||||
|
||||
namespace VolcanicCore.Tests;
|
||||
|
||||
public class AdminBootstrapPlaintextReseedTests : IDisposable
|
||||
{
|
||||
private readonly string _dataDir = Path.Combine(Path.GetTempPath(), $"vc-plaintext-{Guid.NewGuid():N}");
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { Directory.Delete(_dataDir, recursive: true); } catch { }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Seed_heals_legacy_plaintext_password_source_row()
|
||||
{
|
||||
Directory.CreateDirectory(_dataDir);
|
||||
var dbPath = Path.Combine(_dataDir, "volcaniccore.db");
|
||||
var factory = new SqliteConnectionFactory(dbPath);
|
||||
|
||||
// Migrate the schema so admin_config exists.
|
||||
using (var conn = factory.Open())
|
||||
{
|
||||
var mig = new Migrator(new IMigration[]
|
||||
{
|
||||
new Migration001Initial(),
|
||||
new Migration002Connections(),
|
||||
new Migration003Workflows(),
|
||||
new Migration004Executions(),
|
||||
new Migration005NodeExecutions(),
|
||||
});
|
||||
mig.Apply(conn);
|
||||
}
|
||||
|
||||
// Simulate a pre-Plan-2 DB: write the password_hash_source as PLAINTEXT,
|
||||
// bypassing AdminConfigRepository so the row is not AES-GCM encrypted.
|
||||
using (var conn = factory.Open())
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = @"
|
||||
INSERT INTO admin_config (key, value) VALUES ($k, $v)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value;";
|
||||
cmd.Parameters.AddWithValue("$k", AdminBootstrap.PasswordSourceKey);
|
||||
cmd.Parameters.AddWithValue("$v", "legacy-plaintext-password");
|
||||
cmd.ExecuteNonQuery();
|
||||
|
||||
// Also seed the (bcrypt) hash so the "existingHash is not null" branch runs.
|
||||
using var cmd2 = conn.CreateCommand();
|
||||
cmd2.CommandText = @"
|
||||
INSERT INTO admin_config (key, value) VALUES ($k, $v)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value;";
|
||||
cmd2.Parameters.AddWithValue("$k", AdminBootstrap.PasswordHashKey);
|
||||
cmd2.Parameters.AddWithValue("$v", "$2a$11$fakebcryptvaluetopasslength.check.aaaaaaaaaaaaaaaaaaaaaa");
|
||||
cmd2.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
var encKey = new byte[32];
|
||||
var enc = new EncryptionService(encKey);
|
||||
var repo = new AdminConfigRepository(factory, enc);
|
||||
var pw = new PasswordService();
|
||||
|
||||
// ACT — with the fix, Seed treats the un-decryptable row as "not set"
|
||||
// and re-seeds it (properly encrypted this time).
|
||||
AdminBootstrap.Seed(repo, pw, "the-new-env-password");
|
||||
|
||||
// Reading it back through the repo should now decrypt cleanly.
|
||||
var restored = repo.Get(AdminBootstrap.PasswordSourceKey);
|
||||
Assert.Equal("the-new-env-password", restored);
|
||||
|
||||
// And the hash was rewritten so the new password verifies.
|
||||
var hash = repo.Get(AdminBootstrap.PasswordHashKey);
|
||||
Assert.NotNull(hash);
|
||||
Assert.True(pw.Verify("the-new-env-password", hash!));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user