fix(auth): heal legacy plaintext admin.password_hash_source on boot

This commit is contained in:
Claude
2026-08-28 18:54:35 +00:00
parent 3d9df50f20
commit 6ffc59870c
2 changed files with 101 additions and 1 deletions

View File

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

View File

@@ -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!));
}
}