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,12 +1,14 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using MeterVault.Infrastructure.Security;
|
||||
|
||||
namespace MeterVault.Infrastructure.Ingestion;
|
||||
|
||||
/// <summary>
|
||||
/// The parsed <see cref="Core.Domain.IngestionEndpoint.Config"/> JSON for a Home Assistant
|
||||
/// connection (SDD §6.2). The long-lived token is stored by reference only: <see cref="TokenEnv"/>
|
||||
/// names an environment variable resolved at runtime — never the token itself (SDD §6.4).
|
||||
/// connection (SDD §6.2). The long-lived token is never held here as plaintext (SDD §6.4): either
|
||||
/// <see cref="TokenEnv"/> names an environment variable resolved at runtime, or
|
||||
/// <see cref="TokenEnc"/> holds it encrypted under the app's data-protection key ring.
|
||||
/// </summary>
|
||||
public sealed record HaEndpointConfig
|
||||
{
|
||||
@@ -22,6 +24,12 @@ public sealed record HaEndpointConfig
|
||||
/// <summary>Name of the environment variable holding the long-lived access token.</summary>
|
||||
public string? TokenEnv { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The long-lived access token, encrypted by <see cref="Security.SecretProtector"/>. Set when
|
||||
/// the operator typed the token into the admin UI instead of naming an environment variable.
|
||||
/// </summary>
|
||||
public string? TokenEnc { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When true, a persistent WebSocket subscription pushes state changes in real time
|
||||
/// (<see cref="HomeAssistantWebSocketWorker"/>); when false (default) the REST poll worker
|
||||
@@ -48,7 +56,10 @@ public sealed record HaEndpointConfig
|
||||
|
||||
public string ToJson() => JsonSerializer.Serialize(this, Options);
|
||||
|
||||
/// <summary>Resolves the token from the referenced environment variable (null if unset).</summary>
|
||||
public string? ResolveToken() =>
|
||||
string.IsNullOrWhiteSpace(TokenEnv) ? null : Environment.GetEnvironmentVariable(TokenEnv);
|
||||
/// <summary>
|
||||
/// Resolves the token: the encrypted value when one was entered directly, otherwise the
|
||||
/// referenced environment variable. Null when neither yields anything.
|
||||
/// </summary>
|
||||
public string? ResolveToken(SecretProtector? protector = null) =>
|
||||
EndpointSecret.Resolve(TokenEnc, TokenEnv, protector);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user