Connectors: allow secrets to be entered in the UI, encrypted at rest
ci / build-test (push) Successful in 1m12s
ci / build-test (push) Successful in 1m12s
Reference-only secrets (SDD §6.4) meant adding a connector required editing a file on the server and restarting the service. In practice that leads to the token being pasted into the env-var *name* field, which fails with "environment variable '<token>' is not set" and gives no hint what went wrong. Add a second storage form, chosen per connector: type the secret in and it is encrypted via ASP.NET Core data protection before it is stored. The env-var reference stays as an equal alternative — this widens the choice rather than replacing it. Exactly one form survives a save, so a stale secret cannot linger and silently win; EndpointSecret.Resolve is the single resolution path. The guarantee that matters is preserved: no plaintext in the database, so pg_dump and JSON exports carry nothing usable. The trust boundary is stated plainly in §6.4 — the key ring is on disk, so this protects against leaked database content, not an attacker who already has the host, which is the same boundary an env var has. Details worth noting: - Key ring defaults to /var/lib/metervault/keys, outside the app directory, because the LXC updater republishes /opt/metervault on every update. Docker gets a named volume. Overridable via MeterVault__DataProtectionKeyPath. - Undecryptable ciphertext (key ring lost) falls back rather than throwing: an ingestion worker on a timer should degrade, not crash. - The stored secret is never sent to the browser; a blank field means "unchanged", not "cleared". - MQTT usernames are stored as-is — §6.4 covers tokens and passwords, and encrypting a username would only blank the field on every edit. - ExportService drops *_enc values: bound to the originating key ring, so useless where an export would be restored. Expect to re-enter after a restore. - HaConnectionTester now takes a resolved token, so the admin UI can test a token that has been typed but not yet saved. SDD §6.4 and §9 updated to describe both forms rather than contradict the code. Claude-Session: https://claude.ai/code/session_01V6joyergfvVLFEizH1hJLd
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
using MeterVault.Infrastructure.Ingestion;
|
||||
using MeterVault.Infrastructure.Security;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
|
||||
namespace MeterVault.Integration.Tests.Ingestion;
|
||||
|
||||
/// <summary>
|
||||
/// Connector secrets may be stored two ways (SDD §6.4): encrypted at rest after being typed into
|
||||
/// the admin UI, or as the name of an environment variable resolved at runtime. These pin which
|
||||
/// form wins and, more importantly, that neither form ever leaves plaintext in the config JSON.
|
||||
/// </summary>
|
||||
public sealed class EndpointSecretTests
|
||||
{
|
||||
private const string UnsetVar = "METERVAULT_DEFINITELY_UNSET_TOKEN_VAR";
|
||||
|
||||
private static SecretProtector NewProtector() => new(new EphemeralDataProtectionProvider());
|
||||
|
||||
[Fact]
|
||||
public void Encrypted_token_round_trips_and_is_not_plaintext_in_the_config()
|
||||
{
|
||||
var protector = NewProtector();
|
||||
var config = new HaEndpointConfig
|
||||
{
|
||||
BaseUrl = "http://ha.local:8123",
|
||||
TokenEnc = protector.Protect("super-secret-token"),
|
||||
};
|
||||
|
||||
Assert.Equal("super-secret-token", config.ResolveToken(protector));
|
||||
|
||||
// This JSON is what lands in ingestion_endpoint.config, in pg_dump, and in a JSON export.
|
||||
Assert.DoesNotContain("super-secret-token", config.ToJson(), StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Env_var_is_used_when_no_encrypted_token_is_present()
|
||||
{
|
||||
var variable = $"MV_TEST_TOKEN_{Guid.NewGuid():N}";
|
||||
Environment.SetEnvironmentVariable(variable, "from-the-environment");
|
||||
try
|
||||
{
|
||||
var config = new HaEndpointConfig { TokenEnv = variable };
|
||||
Assert.Equal("from-the-environment", config.ResolveToken(NewProtector()));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable(variable, null);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Encrypted_token_wins_when_both_forms_are_set()
|
||||
{
|
||||
var protector = NewProtector();
|
||||
var variable = $"MV_TEST_TOKEN_{Guid.NewGuid():N}";
|
||||
Environment.SetEnvironmentVariable(variable, "from-the-environment");
|
||||
try
|
||||
{
|
||||
var config = new HaEndpointConfig
|
||||
{
|
||||
TokenEnv = variable,
|
||||
TokenEnc = protector.Protect("typed-in-the-ui"),
|
||||
};
|
||||
|
||||
Assert.Equal("typed-in-the-ui", config.ResolveToken(protector));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable(variable, null);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Undecryptable_ciphertext_falls_back_instead_of_throwing()
|
||||
{
|
||||
// A key ring restored without its keys: the worker must degrade, not crash on a timer.
|
||||
var config = new HaEndpointConfig { TokenEnc = "not-valid-ciphertext", TokenEnv = UnsetVar };
|
||||
|
||||
var exception = Record.Exception(() => config.ResolveToken(NewProtector()));
|
||||
|
||||
Assert.Null(exception);
|
||||
Assert.Null(config.ResolveToken(NewProtector()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolving_without_a_protector_still_reads_the_env_var()
|
||||
{
|
||||
// ResolveToken() is called with no protector in unit contexts; the env-var path must work.
|
||||
var variable = $"MV_TEST_TOKEN_{Guid.NewGuid():N}";
|
||||
Environment.SetEnvironmentVariable(variable, "plain-env");
|
||||
try
|
||||
{
|
||||
Assert.Equal("plain-env", new HaEndpointConfig { TokenEnv = variable }.ResolveToken());
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable(variable, null);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mqtt_password_is_encrypted_while_username_stays_readable()
|
||||
{
|
||||
var protector = NewProtector();
|
||||
var config = new EndpointConfig
|
||||
{
|
||||
Host = "broker.local",
|
||||
Username = "metervault",
|
||||
PasswordEnc = protector.Protect("broker-password"),
|
||||
};
|
||||
|
||||
Assert.Equal("metervault", config.ResolveUsername(protector));
|
||||
Assert.Equal("broker-password", config.ResolvePassword(protector));
|
||||
|
||||
var json = config.ToJson();
|
||||
Assert.DoesNotContain("broker-password", json, StringComparison.Ordinal);
|
||||
Assert.Contains("metervault", json, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
@@ -13,29 +13,19 @@ public sealed class HaConnectionTesterTests
|
||||
[Fact]
|
||||
public async Task Missing_base_url_fails_without_network()
|
||||
{
|
||||
var result = await NewTester().TestAsync(baseUrl: "", tokenEnv: "SOME_TOKEN", entityId: null);
|
||||
var result = await NewTester().TestAsync(baseUrl: "", token: "a-token", entityId: null);
|
||||
|
||||
Assert.False(result.Ok);
|
||||
Assert.Contains("Base URL", result.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Missing_token_env_fails_without_network()
|
||||
public async Task Missing_token_fails_without_network()
|
||||
{
|
||||
var result = await NewTester().TestAsync(baseUrl: "http://ha.local:8123", tokenEnv: "", entityId: null);
|
||||
var result = await NewTester().TestAsync(baseUrl: "http://ha.local:8123", token: "", entityId: null);
|
||||
|
||||
Assert.False(result.Ok);
|
||||
Assert.Contains("env-var", result.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unset_token_env_var_fails_without_network()
|
||||
{
|
||||
var result = await NewTester().TestAsync(
|
||||
baseUrl: "http://ha.local:8123", tokenEnv: "METERVAULT_DEFINITELY_UNSET_TOKEN_VAR", entityId: null);
|
||||
|
||||
Assert.False(result.Ok);
|
||||
Assert.Contains("is not set", result.Message, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("token", result.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static HaConnectionTester NewTester() =>
|
||||
|
||||
@@ -66,7 +66,9 @@ public sealed class HomeAssistantWebSocketWorkerTests(TimescaleFixture fx)
|
||||
|
||||
await using var provider = BuildProvider(fx.ConnectionString);
|
||||
var worker = new HomeAssistantWebSocketWorker(
|
||||
provider.GetRequiredService<IServiceScopeFactory>(), NullLogger<HomeAssistantWebSocketWorker>.Instance);
|
||||
provider.GetRequiredService<IServiceScopeFactory>(),
|
||||
provider.GetRequiredService<MeterVault.Infrastructure.Security.SecretProtector>(),
|
||||
NullLogger<HomeAssistantWebSocketWorker>.Instance);
|
||||
|
||||
await worker.StartAsync(CancellationToken.None);
|
||||
try
|
||||
@@ -107,6 +109,10 @@ public sealed class HomeAssistantWebSocketWorkerTests(TimescaleFixture fx)
|
||||
.UseSnakeCaseNamingConvention());
|
||||
services.AddScoped<MeterVaultDbContext>(sp => sp.GetRequiredService<IDbContextFactory<MeterVaultDbContext>>().CreateDbContext());
|
||||
services.AddScoped<IngestionService>();
|
||||
// Ephemeral keys: this test's token comes from an env var, so nothing needs to outlive the run.
|
||||
services.AddSingleton<Microsoft.AspNetCore.DataProtection.IDataProtectionProvider>(
|
||||
new Microsoft.AspNetCore.DataProtection.EphemeralDataProtectionProvider());
|
||||
services.AddSingleton<MeterVault.Infrastructure.Security.SecretProtector>();
|
||||
return services.BuildServiceProvider();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user