diff --git a/docs/concept-mill-votes-target.md b/docs/concept-mill-votes-target.md index 30a523e..40935af 100644 --- a/docs/concept-mill-votes-target.md +++ b/docs/concept-mill-votes-target.md @@ -41,7 +41,7 @@ Bifrost head `bf83a93` (2026-10-10; `20d5efa` plus the prior-art note, docs only ### Data Protection on the live host -18. The live env file sets no `DataProtection__KeysPath` (key names only: `AllowedHosts`, `Bootstrap__*`, `ConnectionStrings__Bifrost`, `GitHubApp__EnableWebhookSync`, `GitHubOAuth__*`, `Heimdall__*`, `Host__*`, `Stripe__*`). So `Program.cs:113-119` never calls `PersistKeysToFileSystem`, and ASP.NET Core's default applies: the service runs as `bifrost` with `HOME=/srv/bifrost`, and the key ring is `/srv/bifrost/.aspnet/DataProtection-Keys` with two keys (created 2026-04-22 and 2026-07-22, the 90-day roll; prior-art note `docs/research/anonymous-vote-stuffing-prior-art.md:65-69`). A third, unused directory `/srv/bifrost/data-protection-keys` holds one key from 2026-07-15. Keys therefore persist across restarts today by accident of `HOME`, not by configuration. +18. The live env file sets no `DataProtection__KeysPath` (key names only: `AllowedHosts`, `Bootstrap__*`, `ConnectionStrings__Bifrost`, `GitHubApp__EnableWebhookSync`, `GitHubOAuth__*`, `Heimdall__*` (the app secret as `Heimdall__AppSecret`; `Heimdall__AppSecretFile` is the replacement), `Host__*`, `Stripe__*`). So `Program.cs:113-119` never calls `PersistKeysToFileSystem`, and ASP.NET Core's default applies: the service runs as `bifrost` with `HOME=/srv/bifrost`, and the key ring is `/srv/bifrost/.aspnet/DataProtection-Keys` with two keys (created 2026-04-22 and 2026-07-22, the 90-day roll; prior-art note `docs/research/anonymous-vote-stuffing-prior-art.md:65-69`). A third, unused directory `/srv/bifrost/data-protection-keys` holds one key from 2026-07-15. Keys therefore persist across restarts today by accident of `HOME`, not by configuration. 19. The service's working directory resolves to the release directory: `readlink /proc//cwd` = `/srv/bifrost/app/releases/20260723-daedcc4`. Data Protection's default application discriminator is the content-root path (prior-art note `:67`), so every deploy into a new release directory isolates payloads protected by the previous release: auth cookies, antiforgery tokens and any future device token stop decrypting after each deploy. Mechanism from source docs plus this probe; not observed as logged-out users. `SetApplicationName` is not called anywhere (`git grep SetApplicationName bf83a93` is empty). ### Proxy and client address diff --git a/docs/deploy-main-to-live.md b/docs/deploy-main-to-live.md index 88997ec..9e50e98 100644 --- a/docs/deploy-main-to-live.md +++ b/docs/deploy-main-to-live.md @@ -54,7 +54,13 @@ permits, windows and `MaxTrackedKeys`. New since the live release, and not read by the validator: -- **`Heimdall__AppSecret`**: required for Discord and Patreon sign-in. Without it `HeimdallOptions.IsDiscordConfigured` and +- **`Heimdall__AppSecretFile`**: the preferred way to give Bifrost the app secret below. A path to a file holding it (one + trailing newlines are trimmed), for example `%d/heimdall-app-secret` under systemd `LoadCredential=`, so the secret never + rides in the process environment. When set, the file wins over `Heimdall__AppSecret`; a missing, unreadable or empty file + stops startup in every environment, with a message that names the option and path and never the value. Ops steps: + `docs/heimdall-app-secret-file.md`. +- **`Heimdall__AppSecret`** (deprecated; use `Heimdall__AppSecretFile`; startup logs a warning when it is set): required for + Discord and Patreon sign-in. Without it `HeimdallOptions.IsDiscordConfigured` and `IsPatreonConfigured` are false and those sign-ins stop working (GitHub sign-in is unaffected); startup does not fail, so a missing key shows only at the live check. The value is Heimdall's app secret for slug `bifrost` (`GC_ACCESS_APP_BIFROST_SHARED_SECRET` on the Heimdall side). Bifrost sends it as `x-heimdall-app-secret`, redacted diff --git a/docs/heimdall-app-secret-file.md b/docs/heimdall-app-secret-file.md new file mode 100644 index 0000000..2f8c283 --- /dev/null +++ b/docs/heimdall-app-secret-file.md @@ -0,0 +1,17 @@ +# Heimdall app secret from a file + +`Heimdall:AppSecret` (`Heimdall__AppSecret`) is Bifrost's app secret for Heimdall slug `bifrost`; it is sent as the +`x-heimdall-app-secret` header. Set through the environment it rides in the process environment. `Heimdall:AppSecretFile` +(`Heimdall__AppSecretFile`) names a file holding it instead. + +- File set: Bifrost reads it once at startup and trailing newlines are trimmed. The file wins over `Heimdall__AppSecret`. +- File set but missing, unreadable, a directory, or empty: startup fails in every environment. The message names + `Heimdall:AppSecretFile` and the path, never the secret. +- Only `Heimdall__AppSecret` set: still works; startup logs a deprecation warning (without the value). +- Neither set: Discord and Patreon sign-in stay off, as before. + +Under systemd, pass the file with `LoadCredential=heimdall-app-secret:` and +`Environment=Heimdall__AppSecretFile=%d/heimdall-app-secret`. + +Code: `HeimdallOptions.ResolveAppSecret` (`src/Bifrost.Web/Configuration/HeimdallOptions.cs`), wired in `Program.cs`, forced at +startup by `StartupConfigurationValidator.Validate`. Tests: `tests/Bifrost.Web.Tests/HeimdallAppSecretFileTests.cs`. diff --git a/src/Bifrost.Web/Configuration/HeimdallOptions.cs b/src/Bifrost.Web/Configuration/HeimdallOptions.cs index 01eaec9..2dda782 100644 --- a/src/Bifrost.Web/Configuration/HeimdallOptions.cs +++ b/src/Bifrost.Web/Configuration/HeimdallOptions.cs @@ -14,7 +14,11 @@ public sealed class HeimdallOptions public string PatreonTierTitle { get; init; } = "Inner Sanctum"; - public string AppSecret { get; init; } = string.Empty; + /// Deprecated: rides in the process environment. Use ; the file wins when both are set. + public string AppSecret { get; set; } = string.Empty; + + /// Path of a file holding the app secret (systemd LoadCredential style: %d/heimdall-app-secret). + public string AppSecretFile { get; init; } = string.Empty; public string PatronSupportIntakeSecret { get; init; } = string.Empty; @@ -39,4 +43,37 @@ public sealed class HeimdallOptions IsConfigured && EnablePatronSupportIntake && !string.IsNullOrWhiteSpace(PatronSupportIntakeSecret); + + /// + /// Resolves once, when the options are built. A set must be a + /// readable, non-empty file, or startup fails; the message names the option and path, never a secret. + /// + public static void ResolveAppSecret(HeimdallOptions options, ILogger logger) + { + if (!string.IsNullOrWhiteSpace(options.AppSecret)) + { + logger.LogWarning("Heimdall:AppSecret is deprecated: it rides in the process environment. Use Heimdall:AppSecretFile, which wins when both are set."); + } + + if (!string.IsNullOrWhiteSpace(options.AppSecretFile)) + { + string secret; + try + { + secret = File.ReadAllText(options.AppSecretFile).TrimEnd('\r', '\n'); + } + catch (Exception error) when (error is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException) + { + throw new InvalidOperationException( + $"Heimdall:AppSecretFile must name a readable file; '{options.AppSecretFile}' could not be read ({error.GetType().Name})."); + } + + if (string.IsNullOrWhiteSpace(secret)) + { + throw new InvalidOperationException($"Heimdall:AppSecretFile '{options.AppSecretFile}' is empty."); + } + + options.AppSecret = secret; + } + } } diff --git a/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs b/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs index a5d0752..2ab16cf 100644 --- a/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs +++ b/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs @@ -22,6 +22,9 @@ public sealed class StartupConfigurationValidator( { var failures = new List(); + // Building the options resolves Heimdall:AppSecretFile, and a bad file stops startup in every environment. + _ = heimdallOptions.Value; + // Checked in every environment: a bad value here would otherwise pass startup and throw on every cast. var conceptVotes = conceptVoteOptions.Value; if (!NetworkPrefix.IsValidLength(conceptVotes.IPv4PrefixLength, NetworkPrefix.IPv4Bits)) diff --git a/src/Bifrost.Web/Program.cs b/src/Bifrost.Web/Program.cs index c7e4587..be3df54 100644 --- a/src/Bifrost.Web/Program.cs +++ b/src/Bifrost.Web/Program.cs @@ -64,7 +64,8 @@ builder.Services builder.Services .AddOptions() - .BindConfiguration(HeimdallOptions.SectionName); + .BindConfiguration(HeimdallOptions.SectionName) + .PostConfigure>(HeimdallOptions.ResolveAppSecret); builder.Services .AddOptions() diff --git a/src/Bifrost.Web/appsettings.json b/src/Bifrost.Web/appsettings.json index 47bbb7b..09b3310 100644 --- a/src/Bifrost.Web/appsettings.json +++ b/src/Bifrost.Web/appsettings.json @@ -28,6 +28,7 @@ "BaseUrl": "https://heimdall.gamecult.org", "AppSlug": "bifrost", "AppSecret": "", + "AppSecretFile": "", "DiscordGuildId": "", "DiscordAllowedRoleIds": [], "PatreonTierTitle": "Inner Sanctum", diff --git a/tests/Bifrost.Web.Tests/HeimdallAppSecretFileTests.cs b/tests/Bifrost.Web.Tests/HeimdallAppSecretFileTests.cs new file mode 100644 index 0000000..1094bb5 --- /dev/null +++ b/tests/Bifrost.Web.Tests/HeimdallAppSecretFileTests.cs @@ -0,0 +1,157 @@ +using Bifrost.Web.Configuration; +using Bifrost.Web.Tests.Support; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Bifrost.Web.Tests; + +public sealed class HeimdallAppSecretFileTests : IDisposable +{ + private const string FileSecret = "file-secret-7f3a91c2"; + private const string EnvSecret = "env-secret-0b5d44e8"; + + private readonly string _directory = Directory.CreateTempSubdirectory("bifrost-appsecret-").FullName; + private readonly CapturingLoggerProvider _logs = new(); + + public void Dispose() => Directory.Delete(_directory, recursive: true); + + private string Write(string content) + { + var path = Path.Combine(_directory, Guid.NewGuid().ToString("N")); + File.WriteAllText(path, content); + return path; + } + + private HeimdallOptions Resolve(string appSecret = "", string appSecretFile = "") + { + var options = new HeimdallOptions { AppSecret = appSecret, AppSecretFile = appSecretFile }; + HeimdallOptions.ResolveAppSecret(options, _logs.CreateLogger("test")); + return options; + } + + private void AssertNoSecretLogged() + { + foreach (var line in _logs.Lines) + { + Assert.DoesNotContain(FileSecret, line); + Assert.DoesNotContain(EnvSecret, line); + } + } + + [Theory] + [InlineData("")] + [InlineData("\n")] + [InlineData("\r\n")] + public void The_file_is_the_secret_with_only_trailing_newlines_trimmed(string ending) + { + var options = Resolve(appSecretFile: Write(FileSecret + ending)); + + Assert.Equal(FileSecret, options.AppSecret); + Assert.False(options.IsDiscordConfigured); // no guild configured + Assert.True(options.IsPatreonConfigured); + Assert.Empty(_logs.Lines); + } + + [Fact] + public void Inner_whitespace_survives_the_trim() + { + Assert.Equal(" a b ", Resolve(appSecretFile: Write(" a b \n")).AppSecret); + } + + [Fact] + public void The_file_wins_over_the_environment_value_and_the_deprecation_is_logged() + { + var options = Resolve(EnvSecret, Write(FileSecret + "\n")); + + Assert.Equal(FileSecret, options.AppSecret); + Assert.Contains(_logs.WarningLines, line => line.Contains("deprecated")); + AssertNoSecretLogged(); + } + + [Fact] + public void A_missing_file_fails_closed_naming_option_and_path_but_no_secret() + { + var path = Path.Combine(_directory, "absent"); + + var failure = Assert.Throws(() => Resolve(EnvSecret, path)); + + Assert.Contains("Heimdall:AppSecretFile", failure.Message); + Assert.Contains(path, failure.Message); + Assert.DoesNotContain(EnvSecret, failure.Message); + } + + [Fact] + public void A_directory_in_place_of_the_file_fails_closed() + { + Assert.Throws(() => Resolve(appSecretFile: _directory)); + } + + [Theory] + [InlineData("")] + [InlineData("\n")] + [InlineData(" \r\n")] + public void An_empty_file_fails_closed_even_when_the_environment_value_is_set(string content) + { + var path = Write(content); + + var failure = Assert.Throws(() => Resolve(EnvSecret, path)); + + Assert.Contains("Heimdall:AppSecretFile", failure.Message); + Assert.Contains(path, failure.Message); + Assert.DoesNotContain(EnvSecret, failure.Message); + } + + [Fact] + public void The_environment_value_alone_still_works_and_warns_without_logging_it() + { + var options = Resolve(EnvSecret); + + Assert.Equal(EnvSecret, options.AppSecret); + Assert.Single(_logs.WarningLines, line => line.Contains("deprecated") && line.Contains("Heimdall:AppSecretFile")); + AssertNoSecretLogged(); + } + + [Fact] + public void Neither_set_is_unconfigured_as_before_and_silent() + { + var options = Resolve(); + + Assert.Equal(string.Empty, options.AppSecret); + Assert.False(options.IsPatreonConfigured); + Assert.Empty(_logs.Lines); + } + + private TestWebApplicationFactory Host(string file) => new TestHost(file, _logs); + + private sealed class TestHost(string file, CapturingLoggerProvider logs) : TestWebApplicationFactory + { + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + base.ConfigureWebHost(builder); + builder.ConfigureAppConfiguration((_, configuration) => + configuration.AddInMemoryCollection(new Dictionary { ["Heimdall:AppSecretFile"] = file })); + builder.ConfigureLogging(logging => logging.AddProvider(logs)); + } + } + + [Fact] + public void The_running_host_resolves_the_file_into_its_options() + { + using var host = Host(Write(FileSecret + "\n")); + + Assert.Equal(FileSecret, host.Services.GetRequiredService>().Value.AppSecret); + } + + [Fact] + public void The_running_host_refuses_to_start_on_a_missing_file_in_every_environment() + { + using var host = Host(Path.Combine(_directory, "absent")); + + var failure = Assert.ThrowsAny(() => host.CreateClient()); + + Assert.Contains("Heimdall:AppSecretFile", failure.ToString()); + } +} diff --git a/tests/Bifrost.Web.Tests/Support/CapturingLoggerProvider.cs b/tests/Bifrost.Web.Tests/Support/CapturingLoggerProvider.cs index ea7558b..a641e7c 100644 --- a/tests/Bifrost.Web.Tests/Support/CapturingLoggerProvider.cs +++ b/tests/Bifrost.Web.Tests/Support/CapturingLoggerProvider.cs @@ -5,6 +5,19 @@ namespace Bifrost.Web.Tests.Support; public sealed class CapturingLoggerProvider : ILoggerProvider { private readonly List _lines = []; + private readonly List _warnings = []; + + /// Lines logged at Warning or above, to pin that an operator-facing notice reaches the journal. + public IReadOnlyList WarningLines + { + get + { + lock (_lines) + { + return _warnings.ToArray(); + } + } + } public IReadOnlyList Lines { @@ -38,7 +51,12 @@ public sealed class CapturingLoggerProvider : ILoggerProvider { lock (owner._lines) { - owner._lines.Add(formatter(state, exception) + (exception is null ? string.Empty : "\n" + exception)); + var line = formatter(state, exception) + (exception is null ? string.Empty : "\n" + exception); + owner._lines.Add(line); + if (logLevel >= LogLevel.Warning) + { + owner._warnings.Add(line); + } } } }