diff --git a/src/Bifrost.Web/Configuration/ConceptVoteOptions.cs b/src/Bifrost.Web/Configuration/ConceptVoteOptions.cs index 2b73824..624832c 100644 --- a/src/Bifrost.Web/Configuration/ConceptVoteOptions.cs +++ b/src/Bifrost.Web/Configuration/ConceptVoteOptions.cs @@ -4,7 +4,12 @@ public sealed class ConceptVoteOptions { public const string SectionName = "ConceptVotes"; - // Keys the network tag. Required outside Development and Testing (StartupConfigurationValidator). + // HMAC-SHA256 output is 32 bytes (RFC 2104 advises a key at least as long as the hash output), so a shorter + // secret makes the tag brute-forceable over the IPv4 space if the table leaks. + public const int MinNetworkTagSecretLength = 32; + + // Keys the network tag. Required outside Development and Testing, at least MinNetworkTagSecretLength + // characters (StartupConfigurationValidator). public string NetworkTagSecret { get; init; } = string.Empty; // The research note (docs/research/anonymous-vote-stuffing-prior-art.md:57-61) finds no source for a diff --git a/src/Bifrost.Web/Features/ConceptVotes/NetworkPrefix.cs b/src/Bifrost.Web/Features/ConceptVotes/NetworkPrefix.cs index 3c88f47..6fd4213 100644 --- a/src/Bifrost.Web/Features/ConceptVotes/NetworkPrefix.cs +++ b/src/Bifrost.Web/Features/ConceptVotes/NetworkPrefix.cs @@ -6,6 +6,11 @@ namespace Bifrost.Web.Features.ConceptVotes; public static class NetworkPrefix { + public const int IPv4Bits = 32; + public const int IPv6Bits = 128; + + public static bool IsValidLength(int length, int addressBits) => length >= 0 && length <= addressBits; + // The only prefix computation: IPv4-mapped IPv6 folds to IPv4, then the address is masked. Returns CIDR text. public static string Of(IPAddress address, int ipv4Length, int ipv6Length) { @@ -16,7 +21,7 @@ public static class NetworkPrefix var bytes = address.GetAddressBytes(); var length = bytes.Length == 4 ? ipv4Length : ipv6Length; - if (length < 0 || length > bytes.Length * 8) + if (!IsValidLength(length, bytes.Length * 8)) { throw new ArgumentOutOfRangeException(nameof(ipv4Length), "Prefix length is outside the address family."); } diff --git a/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs b/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs index 84bcda7..eef6d84 100644 --- a/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs +++ b/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs @@ -1,4 +1,5 @@ using Bifrost.Web.Configuration; +using Bifrost.Web.Features.ConceptVotes; using Microsoft.Extensions.Options; namespace Bifrost.Web.Features.Shared; @@ -10,7 +11,8 @@ public sealed class StartupConfigurationValidator( IOptions gitHubAppOptions, IOptions heimdallOptions, IOptions stripeOptions, - IOptions hostOptions) + IOptions hostOptions, + IOptions conceptVoteOptions) { public void Validate() { @@ -71,9 +73,22 @@ public sealed class StartupConfigurationValidator( failures.Add("DataProtection:KeysPath must be configured for production."); } - if (string.IsNullOrWhiteSpace(configuration["ConceptVotes:NetworkTagSecret"])) + var conceptVotes = conceptVoteOptions.Value; + if (string.IsNullOrWhiteSpace(conceptVotes.NetworkTagSecret) || + conceptVotes.NetworkTagSecret.Length < ConceptVoteOptions.MinNetworkTagSecretLength) { - failures.Add("ConceptVotes:NetworkTagSecret must be configured for production."); + failures.Add( + $"ConceptVotes:NetworkTagSecret must be configured with at least {ConceptVoteOptions.MinNetworkTagSecretLength} characters for production."); + } + + if (!NetworkPrefix.IsValidLength(conceptVotes.IPv4PrefixLength, NetworkPrefix.IPv4Bits)) + { + failures.Add($"ConceptVotes:IPv4PrefixLength must be between 0 and {NetworkPrefix.IPv4Bits}."); + } + + if (!NetworkPrefix.IsValidLength(conceptVotes.IPv6PrefixLength, NetworkPrefix.IPv6Bits)) + { + failures.Add($"ConceptVotes:IPv6PrefixLength must be between 0 and {NetworkPrefix.IPv6Bits}."); } if (failures.Count > 0) diff --git a/tests/Bifrost.Web.Tests/HostTrustTests.cs b/tests/Bifrost.Web.Tests/HostTrustTests.cs index 7824f49..21ba350 100644 --- a/tests/Bifrost.Web.Tests/HostTrustTests.cs +++ b/tests/Bifrost.Web.Tests/HostTrustTests.cs @@ -157,6 +157,34 @@ public sealed class HostTrustTests : IClassFixture Assert.DoesNotContain("DataProtection:KeysPath", refusal.Message); } + [Theory] + [InlineData("short-secret")] + [InlineData("0123456789012345678901234567890")] + public void Production_refuses_a_short_network_tag_secret_without_echoing_it(string secret) + { + var refusal = Assert.Throws( + () => Validate(keysPath: "/keys", tagSecret: secret)); + + Assert.Contains("ConceptVotes:NetworkTagSecret", refusal.Message); + Assert.DoesNotContain(secret, refusal.Message); + Validate(keysPath: "/keys", tagSecret: "01234567890123456789012345678901"); + } + + [Theory] + [InlineData(33, 56, "ConceptVotes:IPv4PrefixLength")] + [InlineData(-1, 56, "ConceptVotes:IPv4PrefixLength")] + [InlineData(32, 129, "ConceptVotes:IPv6PrefixLength")] + [InlineData(32, -1, "ConceptVotes:IPv6PrefixLength")] + public void Production_refuses_prefix_lengths_outside_the_address_family(int v4, int v6, string key) + { + var refusal = Assert.Throws( + () => Validate(keysPath: "/keys", ipv4Length: v4, ipv6Length: v6)); + + Assert.Contains(key, refusal.Message); + Validate(keysPath: "/keys", ipv4Length: 32, ipv6Length: 128); + Validate(keysPath: "/keys", ipv4Length: 0, ipv6Length: 0); + } + [Fact] public void Development_does_not_require_keys_path_or_tag_secret() { @@ -208,18 +236,21 @@ public sealed class HostTrustTests : IClassFixture } } + private const string ValidTagSecret = "tag-secret-0123456789-0123456789-abcdef"; + private static void Validate( string? keysPath, string environment = "Production", string? connectionString = "Host=db", - string? tagSecret = "tag-secret") + string? tagSecret = ValidTagSecret, + int ipv4Length = 32, + int ipv6Length = 56) { var values = new Dictionary { ["AllowedHosts"] = "bifrost.example", ["ConnectionStrings:Bifrost"] = connectionString, ["DataProtection:KeysPath"] = keysPath, - ["ConceptVotes:NetworkTagSecret"] = tagSecret }; var configuration = new ConfigurationBuilder().AddInMemoryCollection(values).Build(); var hostEnvironment = new StubHostEnvironment(environment); @@ -235,6 +266,12 @@ public sealed class HostTrustTests : IClassFixture { PublicBaseUrl = "https://bifrost.example", ExpectedHost = "bifrost.example" + }), + Options.Create(new ConceptVoteOptions + { + NetworkTagSecret = tagSecret ?? string.Empty, + IPv4PrefixLength = ipv4Length, + IPv6PrefixLength = ipv6Length })).Validate(); }