From 6ffc59870c97bfb1168a837131d360cbf63b2dc4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 18:54:35 +0000 Subject: [PATCH] fix(auth): heal legacy plaintext admin.password_hash_source on boot --- src/VolcanicCore.Api/Auth/AdminBootstrap.cs | 21 ++++- .../AdminBootstrapPlaintextReseedTests.cs | 81 +++++++++++++++++++ 2 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 tests/VolcanicCore.Tests/AdminBootstrapPlaintextReseedTests.cs diff --git a/src/VolcanicCore.Api/Auth/AdminBootstrap.cs b/src/VolcanicCore.Api/Auth/AdminBootstrap.cs index 5f7a9d8..637316d 100644 --- a/src/VolcanicCore.Api/Auth/AdminBootstrap.cs +++ b/src/VolcanicCore.Api/Auth/AdminBootstrap.cs @@ -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; diff --git a/tests/VolcanicCore.Tests/AdminBootstrapPlaintextReseedTests.cs b/tests/VolcanicCore.Tests/AdminBootstrapPlaintextReseedTests.cs new file mode 100644 index 0000000..b38cd8a --- /dev/null +++ b/tests/VolcanicCore.Tests/AdminBootstrapPlaintextReseedTests.cs @@ -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!)); + } +}