Startup validation: tag secret minimum length and prefix lengths inside the address family, naming keys not values

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-10 09:56:34 +02:00
commit eca71f589c
4 changed files with 69 additions and 7 deletions

View file

@ -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

View file

@ -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.");
}

View file

@ -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> gitHubAppOptions,
IOptions<HeimdallOptions> heimdallOptions,
IOptions<StripeOptions> stripeOptions,
IOptions<BifrostHostOptions> hostOptions)
IOptions<BifrostHostOptions> hostOptions,
IOptions<ConceptVoteOptions> 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)

View file

@ -157,6 +157,34 @@ public sealed class HostTrustTests : IClassFixture<TestWebApplicationFactory>
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<InvalidOperationException>(
() => 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<InvalidOperationException>(
() => 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<TestWebApplicationFactory>
}
}
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<string, string?>
{
["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<TestWebApplicationFactory>
{
PublicBaseUrl = "https://bifrost.example",
ExpectedHost = "bifrost.example"
}),
Options.Create(new ConceptVoteOptions
{
NetworkTagSecret = tagSecret ?? string.Empty,
IPv4PrefixLength = ipv4Length,
IPv6PrefixLength = ipv6Length
})).Validate();
}