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:
@@ -1,6 +1,7 @@
|
||||
@page "/admin/connectors"
|
||||
@inject Microsoft.EntityFrameworkCore.IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory
|
||||
@inject MeterVault.Infrastructure.Ingestion.HaConnectionTester HaTester
|
||||
@inject MeterVault.Infrastructure.Security.SecretProtector Secrets
|
||||
@inject ISnackbar Snackbar
|
||||
@inject IDialogService DialogService
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@@ -70,7 +71,22 @@ else
|
||||
@if (_working.Type == EndpointType.HomeAssistant)
|
||||
{
|
||||
<MudTextField @bind-Value="_working.BaseUrl" Label="Base URL (e.g. http://homeassistant.local:8123)" Class="mb-2" />
|
||||
<MudTextField @bind-Value="_working.TokenEnv" Label="Token env-var name (e.g. HA_TOKEN)" Class="mb-2" />
|
||||
<MudSwitch T="bool" @bind-Value="_working.UseDirectToken" Label="Enter the token here" Color="Color.Primary" Class="mb-1" />
|
||||
@if (_working.UseDirectToken)
|
||||
{
|
||||
<MudTextField @bind-Value="_working.Token" InputType="InputType.Password" Class="mb-1"
|
||||
Label="@(_working.HasStoredToken ? "Long-lived access token (stored — type to replace)" : "Long-lived access token")" />
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="mb-2 d-block">
|
||||
Encrypted before it is stored; database dumps and JSON exports carry nothing usable.
|
||||
</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudTextField @bind-Value="_working.TokenEnv" Label="Token env-var name (e.g. HA_TOKEN)" Class="mb-1" />
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="mb-2 d-block">
|
||||
The variable's <em>name</em>, not the token. Set it on the server and restart the app.
|
||||
</MudText>
|
||||
}
|
||||
<MudSwitch T="bool" @bind-Value="_working.UseWebSocket" Label="Real-time WebSocket push" Color="Color.Primary" Class="mb-1" />
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="mb-2 d-block">
|
||||
On: subscribe to state changes and ingest in real time. Off: REST poll on each source's interval.
|
||||
@@ -93,8 +109,21 @@ else
|
||||
<MudTextField @bind-Value="_working.Host" Label="Host" Class="mb-2" />
|
||||
<MudNumericField T="int" @bind-Value="_working.Port" Label="Port" Class="mb-2" />
|
||||
<MudSwitch T="bool" @bind-Value="_working.Tls" Label="TLS" Color="Color.Primary" Class="mb-2" />
|
||||
<MudTextField @bind-Value="_working.UsernameEnv" Label="Username env-var name (optional)" Class="mb-2" />
|
||||
<MudTextField @bind-Value="_working.PasswordEnv" Label="Password env-var name (optional)" Class="mb-2" />
|
||||
<MudSwitch T="bool" @bind-Value="_working.UseDirectCredentials" Label="Enter credentials here" Color="Color.Primary" Class="mb-1" />
|
||||
@if (_working.UseDirectCredentials)
|
||||
{
|
||||
<MudTextField @bind-Value="_working.Username" Label="Username (optional)" Class="mb-1" />
|
||||
<MudTextField @bind-Value="_working.Password" InputType="InputType.Password" Class="mb-1"
|
||||
Label="@(_working.HasStoredPassword ? "Password (stored — type to replace)" : "Password (optional)")" />
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="mb-2 d-block">
|
||||
Encrypted before it is stored; database dumps and JSON exports carry nothing usable.
|
||||
</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudTextField @bind-Value="_working.UsernameEnv" Label="Username env-var name (optional)" Class="mb-2" />
|
||||
<MudTextField @bind-Value="_working.PasswordEnv" Label="Password env-var name (optional)" Class="mb-2" />
|
||||
}
|
||||
<MudTextField @bind-Value="_working.ExtraTopics" Label="Extra topics (comma-separated, optional)" Class="mb-2" />
|
||||
}
|
||||
<MudSwitch T="bool" @bind-Value="_working.IsEnabled" Label="Enabled" Color="Color.Primary" />
|
||||
@@ -135,6 +164,10 @@ else
|
||||
{
|
||||
Id = endpoint.Id, Type = endpoint.Type, Name = endpoint.Name, IsEnabled = endpoint.IsEnabled,
|
||||
BaseUrl = ha.BaseUrl, TokenEnv = ha.TokenEnv, UseWebSocket = ha.UseWebSocket,
|
||||
// Carry the ciphertext through untouched and never send the secret to the browser:
|
||||
// the field stays blank and only a typed value replaces what is stored.
|
||||
TokenEnc = ha.TokenEnc,
|
||||
UseDirectToken = !string.IsNullOrWhiteSpace(ha.TokenEnc),
|
||||
};
|
||||
}
|
||||
else
|
||||
@@ -145,6 +178,9 @@ else
|
||||
Id = endpoint.Id, Type = endpoint.Type, Name = endpoint.Name, IsEnabled = endpoint.IsEnabled,
|
||||
Host = mqtt.Host, Port = mqtt.Port, Tls = mqtt.Tls,
|
||||
UsernameEnv = mqtt.UsernameEnv, PasswordEnv = mqtt.PasswordEnv,
|
||||
Username = mqtt.Username, PasswordEnc = mqtt.PasswordEnc,
|
||||
UseDirectCredentials =
|
||||
!string.IsNullOrWhiteSpace(mqtt.Username) || !string.IsNullOrWhiteSpace(mqtt.PasswordEnc),
|
||||
ExtraTopics = string.Join(", ", mqtt.ExtraTopics),
|
||||
};
|
||||
}
|
||||
@@ -157,7 +193,33 @@ else
|
||||
_testResult = null;
|
||||
try
|
||||
{
|
||||
_testResult = await HaTester.TestAsync(_working.BaseUrl, _working.TokenEnv, _working.TestEntityId);
|
||||
// Test what the connector would actually use — including a token typed but not yet
|
||||
// saved, so a bad token is caught before it is stored.
|
||||
if (_working.UseDirectToken)
|
||||
{
|
||||
var token = !string.IsNullOrWhiteSpace(_working.Token)
|
||||
? _working.Token
|
||||
: (Secrets.TryUnprotect(_working.TokenEnc, out var stored) ? stored : null);
|
||||
|
||||
_testResult = string.IsNullOrWhiteSpace(token)
|
||||
? new HaTestResult(false, "Enter a token first.")
|
||||
: await HaTester.TestAsync(_working.BaseUrl, token, _working.TestEntityId);
|
||||
}
|
||||
else if (string.IsNullOrWhiteSpace(_working.TokenEnv))
|
||||
{
|
||||
_testResult = new HaTestResult(false,
|
||||
"Name the environment variable holding the token, or switch on \"Enter the token here\".");
|
||||
}
|
||||
else if (Environment.GetEnvironmentVariable(_working.TokenEnv) is not { Length: > 0 } envToken)
|
||||
{
|
||||
_testResult = new HaTestResult(false,
|
||||
$"Environment variable '{_working.TokenEnv}' is not set on the server. Set it and restart the app, "
|
||||
+ "or switch on \"Enter the token here\" to store the token directly.");
|
||||
}
|
||||
else
|
||||
{
|
||||
_testResult = await HaTester.TestAsync(_working.BaseUrl, envToken, _working.TestEntityId);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -173,15 +235,36 @@ else
|
||||
return;
|
||||
}
|
||||
|
||||
if (_working.Type == EndpointType.HomeAssistant
|
||||
&& _working.UseDirectToken
|
||||
&& string.IsNullOrWhiteSpace(_working.Token)
|
||||
&& !_working.HasStoredToken)
|
||||
{
|
||||
Snackbar.Add("Enter the token, or switch off \"Enter the token here\" and name an env var.", Severity.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
var config = _working.Type == EndpointType.HomeAssistant
|
||||
? new HaEndpointConfig { BaseUrl = Trim(_working.BaseUrl), TokenEnv = Trim(_working.TokenEnv), UseWebSocket = _working.UseWebSocket }.ToJson()
|
||||
? new HaEndpointConfig
|
||||
{
|
||||
BaseUrl = Trim(_working.BaseUrl),
|
||||
UseWebSocket = _working.UseWebSocket,
|
||||
// Exactly one storage form survives a save: switching modes clears the other, so a
|
||||
// stale token cannot linger and silently win at resolution time.
|
||||
TokenEnv = _working.UseDirectToken ? null : Trim(_working.TokenEnv),
|
||||
TokenEnc = _working.UseDirectToken ? ProtectOrKeep(_working.Token, _working.TokenEnc) : null,
|
||||
}.ToJson()
|
||||
: new EndpointConfig
|
||||
{
|
||||
Host = string.IsNullOrWhiteSpace(_working.Host) ? "localhost" : _working.Host.Trim(),
|
||||
Port = _working.Port,
|
||||
Tls = _working.Tls,
|
||||
UsernameEnv = Trim(_working.UsernameEnv),
|
||||
PasswordEnv = Trim(_working.PasswordEnv),
|
||||
UsernameEnv = _working.UseDirectCredentials ? null : Trim(_working.UsernameEnv),
|
||||
PasswordEnv = _working.UseDirectCredentials ? null : Trim(_working.PasswordEnv),
|
||||
Username = _working.UseDirectCredentials ? Trim(_working.Username) : null,
|
||||
PasswordEnc = _working.UseDirectCredentials
|
||||
? ProtectOrKeep(_working.Password, _working.PasswordEnc)
|
||||
: null,
|
||||
ExtraTopics = SplitTopics(_working.ExtraTopics),
|
||||
}.ToJson();
|
||||
|
||||
@@ -225,6 +308,13 @@ else
|
||||
|
||||
private static string? Trim(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
/// <summary>
|
||||
/// Encrypts a newly typed secret, or keeps the stored ciphertext when the field was left blank.
|
||||
/// The plaintext is never sent to the browser, so blank means "unchanged", not "cleared".
|
||||
/// </summary>
|
||||
private string? ProtectOrKeep(string? typed, string? existingCiphertext) =>
|
||||
string.IsNullOrWhiteSpace(typed) ? existingCiphertext : Secrets.Protect(typed);
|
||||
|
||||
private static IReadOnlyList<string> SplitTopics(string? csv) =>
|
||||
string.IsNullOrWhiteSpace(csv) ? [] : [.. csv.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)];
|
||||
|
||||
@@ -241,6 +331,17 @@ else
|
||||
public bool UseWebSocket { get; set; }
|
||||
public string? TestEntityId { get; set; }
|
||||
|
||||
/// <summary>True to store the token here (encrypted); false to name an env var.</summary>
|
||||
public bool UseDirectToken { get; set; }
|
||||
|
||||
/// <summary>Typed token. Always blank on load — a stored secret is never sent to the browser.</summary>
|
||||
public string? Token { get; set; }
|
||||
|
||||
/// <summary>Stored ciphertext, round-tripped so leaving <see cref="Token"/> blank keeps it.</summary>
|
||||
public string? TokenEnc { get; set; }
|
||||
|
||||
public bool HasStoredToken => !string.IsNullOrWhiteSpace(TokenEnc);
|
||||
|
||||
// MQTT broker
|
||||
public string? Host { get; set; } = "localhost";
|
||||
public int Port { get; set; } = 1883;
|
||||
@@ -248,5 +349,16 @@ else
|
||||
public string? UsernameEnv { get; set; }
|
||||
public string? PasswordEnv { get; set; }
|
||||
public string? ExtraTopics { get; set; }
|
||||
|
||||
public bool UseDirectCredentials { get; set; }
|
||||
|
||||
/// <summary>Username entered directly — not a secret, so shown when editing.</summary>
|
||||
public string? Username { get; set; }
|
||||
|
||||
public string? Password { get; set; }
|
||||
|
||||
public string? PasswordEnc { get; set; }
|
||||
|
||||
public bool HasStoredPassword => !string.IsNullOrWhiteSpace(PasswordEnc);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user