Budget registration per address; embed difficulty; expiry queue; 404 on empty withdraw

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Heimdall 2026-11-11 09:34:44 +00:00
commit e67257c750
11 changed files with 368 additions and 36 deletions

View file

@ -34,9 +34,12 @@ server verifies the protection, the expiry and the hash, and spends the id once.
script all pass the same way, at the cost of CPU time on the client.
- Cost: difficulty 18 is about 260,000 hashes on average, well under a second natively and a few seconds in browser
JavaScript. It prices a bulk registration run in CPU; it does not stop a determined attacker with CPUs, and it is not a
proof of humanity.
- Replay: the spent-id set is in memory and bounded; a restart forgets it, so a challenge can be replayed for at most its
five-minute life. When the set is full the endpoint refuses rather than grows.
proof of humanity. The difficulty is embedded in the signed challenge, so a challenge verifies at the difficulty it was issued with.
- Replay: the spent-id set is in memory and bounded (10,000, entries dropped at expiry); a restart forgets it, so a challenge
can be replayed for at most its five-minute life. When the set is full the endpoint refuses rather than grows. One address
cannot fill it alone: `GET /auth/bifrost/challenge` and `POST /auth/bifrost/register` share one per-address budget
(`Bifrost:Registration:Limits`, 10 per minute by default, IPv6 by /64, over budget is 429 with Retry-After). An attacker
with many addresses can still fill the set; that is the accepted bound, the same as oidc-issuer.md.
Not built: the sign-up form. No registration page exists today; the endpoint is the contract and the only client is a
script. The form (solving in a worker, a `<noscript>` message) is the cut after this one. Without JavaScript a person

View file

@ -10,6 +10,18 @@ public sealed class RegistrationOptions
public const int MaxDifficulty = 30;
public RegistrationCaptchaOptions Captcha { get; init; } = new();
public RegistrationLimitsOptions Limits { get; init; } = new();
}
/// <summary>One budget on GET /auth/bifrost/challenge and POST /auth/bifrost/register together, per source address in one fixed window.</summary>
public sealed class RegistrationLimitsOptions
{
public int PerAddressPermits { get; init; } = 10;
public TimeSpan Window { get; init; } = TimeSpan.FromMinutes(1);
public int MaxTrackedKeys { get; init; } = 10_000;
}
public sealed class RegistrationCaptchaOptions

View file

@ -35,7 +35,7 @@ public sealed class OidcGuardMiddleware(RequestDelegate next, OidcIssuerOptions
return;
}
if (limit.Spend(AddressKey(http.Connection.RemoteIpAddress)) is { } byAddress)
if (limit.Spend(ClientAddress.BudgetKey(http.Connection.RemoteIpAddress)) is { } byAddress)
{
await RefuseAsync(http, StatusCodes.Status429TooManyRequests, "too_many_requests", byAddress.RetryAfter);
return;
@ -86,29 +86,6 @@ public sealed class OidcGuardMiddleware(RequestDelegate next, OidcIssuerOptions
}
}
// An IPv6 caller owns its /64, so rotating the low bits buys nothing.
private static string AddressKey(IPAddress? address)
{
if (address is null)
{
return "unknown";
}
if (address.IsIPv4MappedToIPv6)
{
address = address.MapToIPv4();
}
if (address.AddressFamily != System.Net.Sockets.AddressFamily.InterNetworkV6)
{
return address.ToString();
}
var bytes = address.GetAddressBytes();
Array.Clear(bytes, 8, 8);
return new IPAddress(bytes).ToString() + "/64";
}
private static Task RefuseAsync(HttpContext http, int status, string error, TimeSpan retryAfter)
{
http.Response.StatusCode = status;

View file

@ -27,7 +27,9 @@ public sealed class RegistrationChallengeService(
private const int MaxNonceLength = 20;
private readonly IDataProtector protector = dataProtection.CreateProtector(Purpose);
private readonly Dictionary<string, DateTimeOffset> consumed = new(StringComparer.Ordinal);
private readonly HashSet<string> consumed = new(StringComparer.Ordinal);
// Ordered by expiry: a challenge consumed late has an earlier expiry than one consumed before it, so a plain FIFO would strand it.
private readonly PriorityQueue<string, DateTimeOffset> expiries = new();
private readonly object gate = new();
private int Difficulty => options.Value.Captcha.Difficulty;
@ -46,9 +48,10 @@ public sealed class RegistrationChallengeService(
}
var expiresAt = now + Lifetime;
var difficulty = Difficulty;
var id = Convert.ToHexString(RandomNumberGenerator.GetBytes(16));
var challenge = protector.Protect($"{id}.{expiresAt.ToUnixTimeMilliseconds()}");
return new RegistrationChallenge(challenge, Difficulty, expiresAt);
var challenge = protector.Protect($"{id}.{expiresAt.ToUnixTimeMilliseconds()}.{difficulty}");
return new RegistrationChallenge(challenge, difficulty, expiresAt);
}
public bool TryConsume(string? challenge, string? nonce)
@ -59,16 +62,18 @@ public sealed class RegistrationChallengeService(
}
string id;
int difficulty;
DateTimeOffset expiresAt;
try
{
var payload = protector.Unprotect(challenge).Split('.');
if (payload.Length != 2)
if (payload.Length != 3)
{
return false;
}
id = payload[0];
difficulty = int.Parse(payload[2], System.Globalization.CultureInfo.InvariantCulture);
expiresAt = DateTimeOffset.FromUnixTimeMilliseconds(long.Parse(payload[1], System.Globalization.CultureInfo.InvariantCulture));
}
catch (Exception)
@ -83,7 +88,7 @@ public sealed class RegistrationChallengeService(
}
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(challenge + ":" + nonce));
if (LeadingZeroBits(hash) < Difficulty)
if (LeadingZeroBits(hash) < difficulty)
{
return false;
}
@ -91,20 +96,21 @@ public sealed class RegistrationChallengeService(
lock (gate)
{
Purge(now);
if (consumed.Count >= MaxConsumed || consumed.ContainsKey(id))
if (consumed.Count >= MaxConsumed || !consumed.Add(id))
{
return false;
}
consumed[id] = expiresAt;
expiries.Enqueue(id, expiresAt);
return true;
}
}
private void Purge(DateTimeOffset now)
{
foreach (var id in consumed.Where(x => x.Value <= now).Select(x => x.Key).ToList())
while (expiries.TryPeek(out var id, out var expiresAt) && expiresAt <= now)
{
expiries.Dequeue();
consumed.Remove(id);
}
}

View file

@ -0,0 +1,43 @@
using Bifrost.Web.Configuration;
using Bifrost.Web.Features.Shared;
using Microsoft.Extensions.Options;
namespace Bifrost.Web.Features.Registration;
/// <summary>
/// The per-address permits on the registration endpoints (challenge and register share one budget), on the shared
/// fixed-window budget. It exists so one address cannot fill the consumed-challenge set alone. It fails closed:
/// anything it cannot decide is a 503, never a pass.
/// </summary>
public sealed class RegistrationRateLimit(IOptions<RegistrationOptions> options, TimeProvider clock, ILogger<RegistrationRateLimit> logger)
{
private readonly FixedWindowBudget addresses = new(
options.Value.Limits.PerAddressPermits,
options.Value.Limits.Window,
options.Value.Limits.MaxTrackedKeys,
clock);
/// <summary>Null when the request may proceed; otherwise the refusal to return.</summary>
public IResult? Refuse(HttpContext http)
{
try
{
if (addresses.Spend(ClientAddress.BudgetKey(http.Connection.RemoteIpAddress)) is not { } denial)
{
return null;
}
http.Response.Headers.RetryAfter = RetryAfter(denial.RetryAfter);
return Results.Text("Too many registration requests from this address.", statusCode: StatusCodes.Status429TooManyRequests);
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
logger.LogError("Registration rate limit could not decide ({ExceptionType}); the request is refused.", exception.GetType().Name);
http.Response.Headers.RetryAfter = "60";
return Results.Text("Registration is unavailable; try again shortly.", statusCode: StatusCodes.Status503ServiceUnavailable);
}
}
private static string RetryAfter(TimeSpan wait) =>
Math.Max(1, (int)Math.Ceiling(wait.TotalSeconds)).ToString(System.Globalization.CultureInfo.InvariantCulture);
}

View file

@ -0,0 +1,32 @@
using System.Net;
namespace Bifrost.Web.Features.Shared;
public static class ClientAddress
{
/// <summary>
/// The partition key for a per-address budget: the connection's address, already corrected by the forwarded-headers
/// handling that trusts only the local proxy. An IPv6 caller owns its /64, so rotating the low bits buys nothing.
/// </summary>
public static string BudgetKey(IPAddress? address)
{
if (address is null)
{
return "unknown";
}
if (address.IsIPv4MappedToIPv6)
{
address = address.MapToIPv4();
}
if (address.AddressFamily != System.Net.Sockets.AddressFamily.InterNetworkV6)
{
return address.ToString();
}
var bytes = address.GetAddressBytes();
Array.Clear(bytes, 8, 8);
return new IPAddress(bytes).ToString() + "/64";
}
}

View file

@ -55,6 +55,12 @@ public sealed class StartupConfigurationValidator(
failures.Add($"Bifrost:Registration:Captcha:Difficulty must be between {RegistrationOptions.MinDifficulty} and {RegistrationOptions.MaxDifficulty}.");
}
var limits = registrationOptions.Value.Limits;
if (limits.PerAddressPermits < 1 || limits.Window <= TimeSpan.Zero || limits.MaxTrackedKeys < 1)
{
failures.Add("Bifrost:Registration:Limits:PerAddressPermits and MaxTrackedKeys must be at least 1 and Window must be positive.");
}
if (hostEnvironment.IsDevelopment() || hostEnvironment.IsEnvironment("Testing"))
{
Refuse(failures);

View file

@ -134,6 +134,7 @@ builder.Services.AddOptions<MindIntakeOptions>().BindConfiguration(MindIntakeOpt
builder.Services.AddSingleton<MindIntakeRateLimit>(); // TimeProvider and options resolve from the container
builder.Services.AddOptions<RegistrationOptions>().BindConfiguration(RegistrationOptions.SectionName);
builder.Services.AddSingleton<RegistrationChallengeService>();
builder.Services.AddSingleton<RegistrationRateLimit>();
builder.Services.AddHostedService<SealedBallotCloser>();
builder.Services.AddScoped<MotionEveSurfaceService>();
builder.Services.AddScoped<PatronageService>();
@ -390,8 +391,14 @@ app.MapPost("/auth/bifrost/register", async (
HttpContext httpContext,
BifrostIdentityService bifrostIdentityService,
RegistrationChallengeService challenges,
RegistrationRateLimit rateLimit,
CancellationToken cancellationToken) =>
{
if (rateLimit.Refuse(httpContext) is { } refused)
{
return refused;
}
// Consent first, then the proof of work; a refusal here creates nothing.
if (request.AcceptedNoticeVersion != TrainingNotice.Version)
{
@ -432,8 +439,13 @@ app.MapPost("/auth/bifrost/register", async (
}
}).AllowAnonymous();
app.MapGet("/auth/bifrost/challenge", (HttpContext httpContext, RegistrationChallengeService challenges) =>
app.MapGet("/auth/bifrost/challenge", (HttpContext httpContext, RegistrationChallengeService challenges, RegistrationRateLimit rateLimit) =>
{
if (rateLimit.Refuse(httpContext) is { } refused)
{
return refused;
}
httpContext.Response.Headers.CacheControl = "no-store";
var challenge = challenges.Issue();
return challenge is null
@ -460,6 +472,11 @@ app.MapPost("/auth/bifrost/consent/withdraw", async (
var receipts = await dbContext.ConsentReceipts
.Where(x => x.UserAccountId == actor.UserAccount.Id)
.ToListAsync(cancellationToken);
if (receipts.Count == 0)
{
return Results.Text("No consent receipt is recorded for this account.", statusCode: StatusCodes.Status404NotFound);
}
var now = clock.GetUtcNow();
foreach (var receipt in receipts.Where(x => x.WithdrawnAtUtc is null))
{

View file

@ -278,6 +278,30 @@ public sealed class RegistrationGateTests(RegistrationGateFactory factory) : ICl
Assert.DoesNotContain(db.ConsentReceipts, x => x.WithdrawnAtUtc != null && x.UserAccount!.NormalizedBifrostIdentity == identity);
}
[Fact]
public async Task Withdraw_for_an_account_with_no_receipt_is_404()
{
using var client = Client();
var identity = NewIdentity();
using (var scope = factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
db.UserAccounts.Add(new Bifrost.Web.Domain.UserAccount
{
BifrostIdentity = identity,
NormalizedBifrostIdentity = identity,
DisplayName = identity
});
await db.SaveChangesAsync();
}
using var request = new HttpRequestMessage(HttpMethod.Post, "/auth/bifrost/consent/withdraw");
request.Headers.Add("X-Test-Bifrost-Identity", identity);
using var response = await client.SendAsync(request);
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
public async Task The_notice_endpoint_names_the_version_and_the_discord_exclusion()
{

View file

@ -0,0 +1,211 @@
using System.Net;
using System.Net.Http.Json;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Bifrost.Web.Configuration;
using Bifrost.Web.Data;
using Bifrost.Web.Features.Registration;
using Bifrost.Web.Tests.Support;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
namespace Bifrost.Web.Tests;
// The challenge service on its own: single use under contention, the bounded set, the purpose, and the difficulty it issued.
public sealed class RegistrationChallengeServiceTests
{
private sealed class MutableOptions(int difficulty) : IOptions<RegistrationOptions>
{
public RegistrationOptions Value { get; set; } = With(difficulty);
public static RegistrationOptions With(int d) => new() { Captcha = new RegistrationCaptchaOptions { Difficulty = d } };
}
private static RegistrationChallengeService NewService(IDataProtectionProvider provider, IOptions<RegistrationOptions> options, TimeProvider clock) =>
new(provider, options, clock);
[Fact]
public async Task Concurrent_consumes_of_one_challenge_yield_exactly_one_success()
{
var service = NewService(new EphemeralDataProtectionProvider(), new MutableOptions(1), new ManualClock());
for (var round = 0; round < 300; round++)
{
var challenge = service.Issue()!;
var nonce = RegistrationKit.Solve(challenge.Challenge, challenge.Difficulty);
using var start = new ManualResetEventSlim();
var tasks = Enumerable.Range(0, 16).Select(_ => Task.Factory.StartNew(
() => { start.Wait(); return service.TryConsume(challenge.Challenge, nonce); },
TaskCreationOptions.LongRunning)).ToArray();
start.Set();
var results = await Task.WhenAll(tasks);
Assert.Equal(1, results.Count(x => x));
}
}
[Fact]
public void A_full_consumed_set_refuses_to_issue_and_to_consume_until_entries_expire()
{
var clock = new ManualClock();
var service = NewService(new EphemeralDataProtectionProvider(), new MutableOptions(1), clock);
var pending = new List<(string, string)>();
for (var i = 0; i < RegistrationChallengeService.MaxConsumed + 1; i++)
{
var c = service.Issue()!;
pending.Add((c.Challenge, RegistrationKit.Solve(c.Challenge, c.Difficulty)));
}
foreach (var (c, n) in pending.Take(RegistrationChallengeService.MaxConsumed))
{
Assert.True(service.TryConsume(c, n));
}
Assert.Null(service.Issue());
Assert.False(service.TryConsume(pending[^1].Item1, pending[^1].Item2));
clock.Advance(RegistrationChallengeService.Lifetime + TimeSpan.FromSeconds(1));
Assert.NotNull(service.Issue());
}
[Fact]
public void A_token_protected_for_another_purpose_is_refused()
{
var provider = new EphemeralDataProtectionProvider();
var clock = new ManualClock();
var service = NewService(provider, new MutableOptions(1), clock);
var expiry = (clock.GetUtcNow() + TimeSpan.FromMinutes(1)).ToUnixTimeMilliseconds();
var forged = provider.CreateProtector("Bifrost.Something.Else.v1").Protect($"{Guid.NewGuid():N}.{expiry}.1");
var nonce = RegistrationKit.Solve(forged, 1);
Assert.False(service.TryConsume(forged, nonce));
}
[Fact]
public void A_challenge_verifies_at_the_difficulty_it_was_issued_with()
{
var options = new MutableOptions(1);
var service = NewService(new EphemeralDataProtectionProvider(), options, new ManualClock());
var easy = service.Issue()!;
var easyNonce = RegistrationKit.Solve(easy.Challenge, 1);
// A nonce that meets 1 bit but not 12: only the embedded difficulty lets it through after the option rises.
var weak = Enumerable.Range(0, int.MaxValue).Select(i => i.ToString()).First(n =>
{
var bits = RegistrationChallengeService.LeadingZeroBits(SHA256.HashData(Encoding.UTF8.GetBytes(easy.Challenge + ":" + n)));
return bits >= 1 && bits < 12;
});
options.Value = MutableOptions.With(12);
Assert.Equal(1, easy.Difficulty);
Assert.True(service.TryConsume(easy.Challenge, weak));
Assert.Equal(12, service.Issue()!.Difficulty);
_ = easyNonce;
}
}
public sealed class RegistrationBudgetFactory : TestWebApplicationFactory
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
base.ConfigureWebHost(builder);
builder.ConfigureAppConfiguration((_, configuration) =>
configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
["Bifrost:Registration:Limits:PerAddressPermits"] = "3"
}));
builder.ConfigureServices(services => services.AddTransient<Microsoft.AspNetCore.Hosting.IStartupFilter, PeerFilter>());
}
private sealed class PeerFilter : Microsoft.AspNetCore.Hosting.IStartupFilter
{
public Action<Microsoft.AspNetCore.Builder.IApplicationBuilder> Configure(Action<Microsoft.AspNetCore.Builder.IApplicationBuilder> next) => app =>
{
app.Use((context, nextMiddleware) =>
{
context.Connection.RemoteIpAddress = IPAddress.Parse(context.Request.Headers["X-Test-Peer"].FirstOrDefault() ?? "198.51.100.1");
return nextMiddleware();
});
next(app);
};
}
}
public sealed class RegistrationBudgetTests(RegistrationBudgetFactory factory) : IClassFixture<RegistrationBudgetFactory>
{
private static async Task<HttpResponseMessage> ChallengeAsync(HttpClient client, string peer)
{
using var request = new HttpRequestMessage(HttpMethod.Get, "/auth/bifrost/challenge");
request.Headers.Add("X-Test-Peer", peer);
return await client.SendAsync(request);
}
[Fact]
public async Task The_request_past_the_address_budget_gets_429_and_another_address_is_unaffected()
{
using var client = factory.CreateClient();
for (var i = 0; i < 3; i++)
{
using var ok = await ChallengeAsync(client, "203.0.113.5");
Assert.Equal(HttpStatusCode.OK, ok.StatusCode);
}
using var refused = await ChallengeAsync(client, "203.0.113.5");
using var other = await ChallengeAsync(client, "203.0.113.6");
Assert.Equal(HttpStatusCode.TooManyRequests, refused.StatusCode);
Assert.True(refused.Headers.RetryAfter is not null);
Assert.Equal(HttpStatusCode.OK, other.StatusCode);
}
[Fact]
public async Task Challenge_and_register_spend_one_budget_and_a_refused_register_creates_nothing()
{
using var client = factory.CreateClient();
const string peer = "203.0.113.7";
for (var i = 0; i < 3; i++)
{
using var ok = await ChallengeAsync(client, peer);
Assert.Equal(HttpStatusCode.OK, ok.StatusCode);
}
using var request = new HttpRequestMessage(HttpMethod.Post, "/auth/bifrost/register")
{
Content = JsonContent.Create(new { identity = $"budget-{Guid.NewGuid():N}", acceptedNoticeVersion = TrainingNotice.Version })
};
request.Headers.Add("X-Test-Anonymous", "true");
request.Headers.Add("X-Test-Peer", peer);
using var response = await client.SendAsync(request);
Assert.Equal(HttpStatusCode.TooManyRequests, response.StatusCode);
using var scope = factory.Services.CreateScope();
Assert.DoesNotContain(scope.ServiceProvider.GetRequiredService<BifrostDbContext>().UserAccounts, x => x.NormalizedBifrostIdentity!.StartsWith("budget-"));
}
}
public sealed class RegistrationFullSetTests(RegistrationGateFactory factory) : IClassFixture<RegistrationGateFactory>
{
[Fact]
public async Task A_full_consumed_set_makes_the_challenge_endpoint_answer_503()
{
var service = factory.Services.GetRequiredService<RegistrationChallengeService>();
for (var i = 0; i < RegistrationChallengeService.MaxConsumed; i++)
{
var c = service.Issue()!;
Assert.True(service.TryConsume(c.Challenge, RegistrationKit.Solve(c.Challenge, c.Difficulty)));
}
using var client = factory.CreateClient();
using var response = await client.GetAsync("/auth/bifrost/challenge");
Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode);
factory.Time.Advance(RegistrationChallengeService.Lifetime + TimeSpan.FromSeconds(1));
using var after = await client.GetAsync("/auth/bifrost/challenge");
Assert.Equal(HttpStatusCode.OK, after.StatusCode);
}
}

View file

@ -39,6 +39,7 @@ public class TestWebApplicationFactory : WebApplicationFactory<Program>
["ConceptVotes:MintPermits"] = "100000",
["MindIntake:Permits"] = "100000",
["Bifrost:Registration:Captcha:Difficulty"] = "4",
["Bifrost:Registration:Limits:PerAddressPermits"] = "100000",
["Host:PublicBaseUrl"] = "https://localhost",
["Host:ExpectedHost"] = "localhost",
["Host:RequireStrictHostValidation"] = "false",