Registration gates: proof-of-work CAPTCHA and training-notice consent receipt (#54)

This commit is contained in:
Heimdall 2026-11-11 10:15:46 +00:00
commit 33be966b7d
28 changed files with 1253 additions and 56 deletions

View file

@ -73,7 +73,7 @@ For the full staged roadmap, including contribution points, revenue share, patro
## Local Dev Notes
- set `GitHubOAuth:ClientId` and `GitHubOAuth:ClientSecret` before using sign-in
- POST `/auth/bifrost/register` with `identity`, optional `displayName`, and optional `returnUrl` to create a Bifrost-native account without OAuth; this signs in as `Authenticated` but does not grant active member access
- POST `/auth/bifrost/register` with `identity`, optional `displayName`, optional `returnUrl`, a solved proof-of-work `challenge` and `nonce` (from `GET /auth/bifrost/challenge`), and `acceptedNoticeVersion` (the `version` from `GET /auth/bifrost/notice`) to create a Bifrost-native account without OAuth; this signs in as `Authenticated` but does not grant active member access
- set `Bootstrap:AdminGitHubLogins` with at least one GitHub login for the first active admin path
- HTTP bridge ledger configuration (`BIFROST_BRIDGE_BASE_URL` / `BIFROST_BRIDGE_TOKEN`) is removed from live CLI transport. Request-lane, governance, dispatch, and bridge receipts converge on Bifrost-owned `bifrost.crossing_receipt.v1` documents in `.bifrost/bridge-receipts.cc`.
- external bridge actions must carry typed CultMesh command provenance such as `BIFROST_CULTMESH_COMMAND_ID`; unreceipted and HTTP-ledger hatches fail closed.

View file

@ -23,7 +23,7 @@ That means Bifrost owns the operational meaning of the crossing:
It does not mean Bifrost should own OAuth sludge. Bifrost is both an identity provider and identity consumer: a GameCult actor should be able to register with Bifrost without OAuth, and that Bifrost identity should be enough to authenticate with most GameCult services. Heimdall owns outside-provider OAuth, linked-account token custody, grants, and signed account claims. Bifrost associates those Heimdall-backed accounts with the Bifrost identity, consumes the permission facts, and decides whether a bridge action is allowed in the Bifrost domain.
The native registration surface is `POST /auth/bifrost/register`. It creates a Bifrost-owned `UserAccount` with `BifrostIdentity` and `NormalizedBifrostIdentity`, signs the caller in with the Bifrost cookie, and leaves membership at `Authenticated`. It does not grant active member access, mint outside-provider tokens, or replace Heimdall's account-link and capability custody.
The native registration surface is `POST /auth/bifrost/register`, which takes `identity`, optional `displayName` and `returnUrl`, a solved proof-of-work `challenge` and `nonce` (from `GET /auth/bifrost/challenge`), and `acceptedNoticeVersion` (from `GET /auth/bifrost/notice`). It creates a Bifrost-owned `UserAccount` with `BifrostIdentity` and `NormalizedBifrostIdentity`, signs the caller in with the Bifrost cookie, and leaves membership at `Authenticated`. It does not grant active member access, mint outside-provider tokens, or replace Heimdall's account-link and capability custody.
## Ownership

View file

@ -0,0 +1,86 @@
# Public registration gates
Meta ruled yes to public registration on 2026-11-10. The gates she required: self-hosted CAPTCHA, email verification,
a training-notice consent receipt, rate limits and a brake, reserved names, and new accounts in Users with no vote.
This page scopes the three that did not exist, says where each lives, and records what is built.
## What exists
| Gate | Where | State |
| --- | --- | --- |
| Rate limits and brake | `OidcGuardMiddleware` (`docs/oidc-issuer.md`) | Merged. Covers `/connect/*` only, not the register endpoint. |
| Reserved names | `BifrostIdentityService.IsReserved`, 28 names | Merged, at creation and at authorize. |
| Chat tier, no vote | `Authenticated` / `PendingApproval` accounts chat and open no member or governance surface | Merged. |
| Suspension | `MembershipSuspensionService` | Merged. |
| CAPTCHA | `Features/Registration/RegistrationChallengeService` | Built here. |
| Consent receipt | `ConsentReceipt`, `TrainingNotice` | Built here. |
| Email verification | nowhere | Scoped below, not built. |
The hole these gates close: `POST /auth/bifrost/register` is anonymous, and since the chat tier merged, an
`Authenticated` account it creates can sign in to chat. Before this cut it took an identity name and nothing else.
## CAPTCHA
Lives in Bifrost, because the register endpoint is Bifrost's. Heimdall authenticates people through providers and has no
registration form.
Design: a hashcash proof of work. `GET /auth/bifrost/challenge` returns a Data Protection-protected token (random id and
expiry, five minutes) and a difficulty in bits. The client finds a decimal `nonce` such that
`SHA-256(challenge + ":" + nonce)` has at least that many leading zero bits, and posts both with the registration. The
server verifies the protection, the expiry and the hash, and spends the id once.
- Self-hosted: no third-party script, request or cookie; nothing to track.
- Accessible: there is nothing to see or hear and nothing to solve by hand. A screen-reader user, a keyboard user and a
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. 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
cannot solve the challenge, so the accessible fallback for them is owed with the form: a documented solver is not one. Do
not call the CAPTCHA gate complete for people until that fallback exists.
## Consent receipt
Lives in Bifrost: the account, the receipt and the registration are one transaction, and chat admission is Bifrost's
decision (`admission-is-bifrost-membership`).
- Notice: `TrainingNotice.Version` (positive integer, no leading zeros, per the version rule in Heimdall's
`answer-collector-consent.md`) and `TrainingNotice.Text`. Served at `GET /auth/bifrost/notice`. The text in the repo is
draft wording. Eureka owns the final wording in gamecult-ops; changing the text is a version bump, never an edit in
place. The text states that no Discord material is used.
- Receipt: `ConsentReceipt` (account, notice version, `AcceptedAtUtc`, `WithdrawnAtUtc`), unique per account and version,
written in the same save as the account. Registration without `acceptedNoticeVersion` equal to the current version
creates nothing. A new notice version needs a new acceptance; there is no path yet that asks for one.
- Withdrawal: `POST /auth/bifrost/consent/withdraw`, signed in, sets `WithdrawnAtUtc` once on the account's receipts.
Withdrawal stops future use and cannot recall past use; the notice says so. The row is one-way and never deleted.
- Consumer: a training export must include an account's data only while a receipt for the notice version in force
exists with `WithdrawnAtUtc` null. No export exists in Bifrost; this page is the contract it will be built against.
Scope limits: accounts that arrive through Heimdall providers (Discord, Patreon, Twitch, GitHub) are created in
`HeimdallAuthService.UpsertUserAccountAsync` and get no receipt. They reach chat the same way. Closing that needs a
consent step on the completion page and is the next cut after the form. Existing accounts are not re-asked.
## Email verification
Not built. It cannot be built in Bifrost alone:
- There is no mail transport anywhere in Bifrost, Heimdall or the chat stack. A sender (SMTP relay or provider, SPF/DKIM
for the sending domain) is ops infrastructure, and Bifrost's side is an `IEmailSender` port with that relay's settings
as secret files.
- A native account has no credential and no sign-in route (`concept-mill-votes-target.md`, claim 12). Verifying an
address proves nothing unless the address is also how the person returns. That makes email the credential: a
single-use sign-in link, and the choice of whether Bifrost keeps the address (needed to send it) or only a hash (cannot
send). The issuer doc promises no email leaves Bifrost; keeping one inside it is a privacy decision for the operator.
- Shape once ruled: registration becomes two steps. Step one takes identity, address, consent and the CAPTCHA, and mails a
single-use token; no account exists yet. Step two redeems the token and creates the account and receipt together. An
unverified account then cannot exist, so it cannot chat.
Failure it prevents: accounts for addresses nobody holds, and unrecoverable accounts. Owed: a mail relay from ops, and the
ruling on keeping the address.

View file

@ -0,0 +1,31 @@
namespace Bifrost.Web.Configuration;
/// <summary>Gates on the anonymous native registration (section <c>Bifrost:Registration</c>).</summary>
public sealed class RegistrationOptions
{
public const string SectionName = "Bifrost:Registration";
public const int MinDifficulty = 1;
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
{
/// <summary>Leading zero bits the SHA-256 of <c>challenge:nonce</c> must have. 18 is about 262k hashes, a second or two in a browser.</summary>
public int Difficulty { get; init; } = 18;
}

View file

@ -43,6 +43,7 @@ public sealed class BifrostDbContext(DbContextOptions<BifrostDbContext> options)
public DbSet<SealedBallot> SealedBallots => Set<SealedBallot>();
public DbSet<ConsentReceipt> ConsentReceipts => Set<ConsentReceipt>();
public DbSet<MindKey> MindKeys => Set<MindKey>();
public DbSet<MindNonce> MindNonces => Set<MindNonce>();
@ -322,6 +323,15 @@ public sealed class BifrostDbContext(DbContextOptions<BifrostDbContext> options)
.OnDelete(DeleteBehavior.Restrict);
});
modelBuilder.Entity<ConsentReceipt>(entity =>
{
entity.HasIndex(x => new { x.UserAccountId, x.NoticeVersion }).IsUnique();
entity.HasOne(x => x.UserAccount)
.WithMany()
.HasForeignKey(x => x.UserAccountId)
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<MindKey>(entity =>
{
entity.HasIndex(x => new { x.PersonaId, x.PublicKey }).IsUnique();

View file

@ -0,0 +1,40 @@
using System;
using Bifrost.Web.Data;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Bifrost.Web.Data.Migrations
{
// Hand-written like AddMindKeys: no dotnet-ef on the authoring host; the snapshot was edited to match.
[DbContext(typeof(BifrostDbContext))]
[Migration("20261111090000_AddConsentReceipts")]
public partial class AddConsentReceipts : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "ConsentReceipts",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
UserAccountId = table.Column<Guid>(type: "uuid", nullable: false),
NoticeVersion = table.Column<int>(type: "integer", nullable: false),
AcceptedAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
WithdrawnAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_ConsentReceipts", x => x.Id);
table.ForeignKey("FK_ConsentReceipts_UserAccounts_UserAccountId", x => x.UserAccountId, "UserAccounts", "Id", onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(name: "IX_ConsentReceipts_UserAccountId_NoticeVersion", table: "ConsentReceipts", columns: new[] { "UserAccountId", "NoticeVersion" }, unique: true);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(name: "ConsentReceipts");
}
}
}

View file

@ -1440,6 +1440,32 @@ namespace Bifrost.Web.Data.Migrations
b.ToTable("VolunteerClaims");
});
modelBuilder.Entity("Bifrost.Web.Domain.ConsentReceipt", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("AcceptedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<int>("NoticeVersion")
.HasColumnType("integer");
b.Property<Guid>("UserAccountId")
.HasColumnType("uuid");
b.Property<DateTimeOffset?>("WithdrawnAtUtc")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("UserAccountId", "NoticeVersion")
.IsUnique();
b.ToTable("ConsentReceipts");
});
modelBuilder.Entity("Bifrost.Web.Domain.MindKey", b =>
{
b.Property<Guid>("Id")
@ -2142,6 +2168,17 @@ namespace Bifrost.Web.Data.Migrations
b.Navigation("WorkItem");
});
modelBuilder.Entity("Bifrost.Web.Domain.ConsentReceipt", b =>
{
b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount")
.WithMany()
.HasForeignKey("UserAccountId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("UserAccount");
});
modelBuilder.Entity("Bifrost.Web.Domain.MemberProfile", b =>
{
b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount")

View file

@ -1155,6 +1155,25 @@ public sealed class AuditEvent
public UserAccount? ActorUserAccount { get; set; }
}
/// <summary>
/// An account's consent to the training notice, recorded with the notice version it accepted. Withdrawal sets
/// <see cref="WithdrawnAtUtc"/>; the row is never deleted, so what was agreed and when stays answerable.
/// </summary>
public sealed class ConsentReceipt
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid UserAccountId { get; set; }
public int NoticeVersion { get; set; }
public DateTimeOffset AcceptedAtUtc { get; set; }
public DateTimeOffset? WithdrawnAtUtc { get; set; }
public UserAccount? UserAccount { get; set; }
}
/// <summary>A Mind's registered Ed25519 public key. Bifrost never holds a private key.</summary>
public sealed class MindKey
{

View file

@ -15,6 +15,7 @@ public sealed partial class BifrostIdentityService(
public async Task<UserAccount> RegisterNativeAsync(
string identity,
string? displayName,
int noticeVersion,
CancellationToken cancellationToken)
{
var normalizedIdentity = NormalizeIdentity(identity);
@ -57,7 +58,14 @@ public sealed partial class BifrostIdentityService(
}
};
// One SaveChanges: no account exists without the receipt of the notice it accepted.
dbContext.UserAccounts.Add(userAccount);
dbContext.ConsentReceipts.Add(new ConsentReceipt
{
UserAccount = userAccount,
NoticeVersion = noticeVersion,
AcceptedAtUtc = now
});
await dbContext.SaveChangesAsync(cancellationToken);
return userAccount;
}

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

@ -0,0 +1,137 @@
using System.Security.Cryptography;
using System.Text;
using Bifrost.Web.Configuration;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.Extensions.Options;
namespace Bifrost.Web.Features.Registration;
public sealed record RegistrationChallenge(string Challenge, int Difficulty, DateTimeOffset ExpiresAt);
/// <summary>
/// Self-hosted proof-of-work challenge for the anonymous registration. A challenge is a data-protected
/// (random id, expiry) payload, so the server keeps nothing per issue; only consumed ids are remembered, in a bounded
/// set whose entries are dropped at expiry. A full set refuses both issuing and consuming rather than grow.
/// </summary>
public sealed class RegistrationChallengeService(
IDataProtectionProvider dataProtection,
IOptions<RegistrationOptions> options,
TimeProvider clock)
{
public const string Purpose = "Bifrost.Registration.Challenge.v1";
public static readonly TimeSpan Lifetime = TimeSpan.FromMinutes(5);
public const int MaxConsumed = 10_000;
private const int MaxNonceLength = 20;
private readonly IDataProtector protector = dataProtection.CreateProtector(Purpose);
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;
/// <summary>Null when the consumed set is full: refuse rather than grow.</summary>
public RegistrationChallenge? Issue()
{
var now = clock.GetUtcNow();
lock (gate)
{
Purge(now);
if (consumed.Count >= MaxConsumed)
{
return null;
}
}
var expiresAt = now + Lifetime;
var difficulty = Difficulty;
var id = Convert.ToHexString(RandomNumberGenerator.GetBytes(16));
var challenge = protector.Protect($"{id}.{expiresAt.ToUnixTimeMilliseconds()}.{difficulty}");
return new RegistrationChallenge(challenge, difficulty, expiresAt);
}
public bool TryConsume(string? challenge, string? nonce)
{
if (string.IsNullOrEmpty(challenge) || !IsNonce(nonce))
{
return false;
}
string id;
int difficulty;
DateTimeOffset expiresAt;
try
{
var payload = protector.Unprotect(challenge).Split('.');
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)
{
return false;
}
var now = clock.GetUtcNow();
if (now >= expiresAt)
{
return false;
}
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(challenge + ":" + nonce));
if (LeadingZeroBits(hash) < difficulty)
{
return false;
}
lock (gate)
{
Purge(now);
if (consumed.Count >= MaxConsumed || !consumed.Add(id))
{
return false;
}
expiries.Enqueue(id, expiresAt);
return true;
}
}
private void Purge(DateTimeOffset now)
{
while (expiries.TryPeek(out var id, out var expiresAt) && expiresAt <= now)
{
expiries.Dequeue();
consumed.Remove(id);
}
}
private static bool IsNonce(string? nonce) =>
nonce is { Length: >= 1 and <= MaxNonceLength } && nonce.All(c => c is >= '0' and <= '9');
public static int LeadingZeroBits(ReadOnlySpan<byte> hash)
{
var bits = 0;
foreach (var b in hash)
{
if (b == 0)
{
bits += 8;
continue;
}
return bits + System.Numerics.BitOperations.LeadingZeroCount((uint)b) - 24;
}
return bits;
}
}

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,17 @@
namespace Bifrost.Web.Features.Registration;
/// <summary>
/// The training notice a registrant accepts. DRAFT wording: Eureka replaces <see cref="Text"/>. Changing the wording
/// in substance means a new <see cref="Version"/> (a positive integer, no leading zeros), because a receipt records the
/// version the person accepted.
/// </summary>
public static class TrainingNotice
{
public const int Version = 1;
public const string Text =
"Chat messages and activity on GameCult chat may be used to train and evaluate AI models. " +
"No Discord material is used. " +
"Your consent is voluntary. It is recorded with this notice version and the time you give it, " +
"and you can withdraw it at any time. Withdrawing stops future use; it cannot recall use that already happened.";
}

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

@ -13,7 +13,8 @@ public sealed class StartupConfigurationValidator(
IOptions<StripeOptions> stripeOptions,
IOptions<BifrostHostOptions> hostOptions,
IOptions<ConceptVoteOptions> conceptVoteOptions,
IOptions<DeskOptions> deskOptions)
IOptions<DeskOptions> deskOptions,
IOptions<RegistrationOptions> registrationOptions)
{
public void Validate()
{
@ -48,6 +49,18 @@ public sealed class StartupConfigurationValidator(
ValidateDesk(deskOptions.Value, failures);
var difficulty = registrationOptions.Value.Captcha.Difficulty;
if (difficulty < RegistrationOptions.MinDifficulty || difficulty > RegistrationOptions.MaxDifficulty)
{
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

@ -14,6 +14,7 @@ using Bifrost.Web.Features.Membership;
using Bifrost.Web.Features.Motions;
using Bifrost.Web.Features.Oidc;
using Bifrost.Web.Features.Patronage;
using Bifrost.Web.Features.Registration;
using Bifrost.Web.Features.Shared;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
@ -131,6 +132,9 @@ builder.Services.AddScoped<SealedBallotService>();
builder.Services.AddScoped<MindIntakeService>();
builder.Services.AddOptions<MindIntakeOptions>().BindConfiguration(MindIntakeOptions.SectionName);
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>();
@ -386,13 +390,34 @@ app.MapPost("/auth/bifrost/register", async (
NativeBifrostRegistrationRequest request,
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)
{
return Results.Text(
$"Registration requires accepting the training notice, current version {TrainingNotice.Version}.",
statusCode: StatusCodes.Status400BadRequest);
}
if (!challenges.TryConsume(request.Challenge, request.Nonce))
{
return Results.Text("Registration challenge failed.", statusCode: StatusCodes.Status400BadRequest);
}
try
{
var userAccount = await bifrostIdentityService.RegisterNativeAsync(
request.Identity,
request.DisplayName,
TrainingNotice.Version,
cancellationToken);
await httpContext.SignInAsync(
CookieAuthenticationDefaults.AuthenticationScheme,
@ -414,6 +439,56 @@ app.MapPost("/auth/bifrost/register", async (
}
}).AllowAnonymous();
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
? Results.Text("Registration is busy; try again shortly.", statusCode: StatusCodes.Status503ServiceUnavailable)
: JsonResult(challenge, appJsonSerializerOptions);
}).AllowAnonymous();
app.MapGet("/auth/bifrost/notice", () =>
JsonResult(new { version = TrainingNotice.Version, text = TrainingNotice.Text }, appJsonSerializerOptions)).AllowAnonymous();
// Withdrawal stops future use; the receipt row stays. Idempotent: an already-withdrawn receipt keeps its first timestamp.
app.MapPost("/auth/bifrost/consent/withdraw", async (
ICurrentBifrostActorAccessor actors,
BifrostDbContext dbContext,
TimeProvider clock,
CancellationToken cancellationToken) =>
{
var actor = await actors.GetAsync(cancellationToken);
if (!actor.IsAuthenticated || actor.UserAccount is null)
{
return Results.Unauthorized();
}
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))
{
receipt.WithdrawnAtUtc = now;
}
await dbContext.SaveChangesAsync(cancellationToken);
return JsonResult(
new { withdrawnAtUtc = receipts.Min(x => x.WithdrawnAtUtc) },
appJsonSerializerOptions);
});
app.MapGet("/auth/heimdall/{provider}", async (
string provider,
HttpContext httpContext,
@ -792,7 +867,10 @@ public partial class Program;
public sealed record NativeBifrostRegistrationRequest(
string Identity,
string? DisplayName,
string? ReturnUrl);
string? ReturnUrl,
string? Challenge,
string? Nonce,
int? AcceptedNoticeVersion);
public sealed record NativeBifrostRegistrationResponse(
Guid UserAccountId,

View file

@ -44,7 +44,8 @@ public sealed class DeskOptionsTests
Options.Create(new StripeOptions()),
Options.Create(new BifrostHostOptions()),
Options.Create(new ConceptVoteOptions()),
Options.Create(Bind(values))).Validate();
Options.Create(Bind(values)),
Options.Create(new RegistrationOptions())).Validate();
}
private static void Refused(Dictionary<string, string?> values, string expectedFragment)

View file

@ -363,7 +363,8 @@ public sealed class HostTrustTests : IClassFixture<TestWebApplicationFactory>
MintWindow = mintWindow ?? TimeSpan.FromHours(1),
MaxTrackedKeys = maxTrackedKeys
}),
Options.Create(new DeskOptions())).Validate();
Options.Create(new DeskOptions()),
Options.Create(new RegistrationOptions())).Validate();
}
private sealed class StubHostEnvironment(string environmentName) : IHostEnvironment

View file

@ -24,6 +24,7 @@ public sealed class MigrationDiscoveryTests
"20261022163159_AddOpenIddict",
"20261109200000_AddSealedBallotMotions",
"20261109210000_AddMindKeys",
"20261111090000_AddConsentReceipts",
];
// Database.GetMigrations() reads assembly metadata only; no connection is opened.

View file

@ -838,7 +838,7 @@ public sealed class OidcIssuerTests : IClassFixture<OidcWebApplicationFactory>
private static async Task<string> RegisterAsync(HttpClient client, string identity)
{
using var response = await client.PostAsJsonAsync("/auth/bifrost/register", new { identity, displayName = identity });
using var response = await RegistrationKit.RegisterAsync(client, identity);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
return identity.ToLowerInvariant();
}

View file

@ -45,16 +45,8 @@ public sealed class PlatformEndpointsTests : IClassFixture<TestWebApplicationFac
public async Task Bifrost_native_registration_creates_authenticated_identity_without_oauth()
{
using var client = _factory.CreateClient();
using var request = new HttpRequestMessage(HttpMethod.Post, "/auth/bifrost/register");
request.Headers.Add("X-Test-Anonymous", "true");
request.Content = JsonContent.Create(new
{
identity = $"native-{Guid.NewGuid():N}",
displayName = "Native Bifrost User",
returnUrl = "/App"
});
using var response = await client.SendAsync(request);
using var response = await RegistrationKit.RegisterAsync(
client, $"native-{Guid.NewGuid():N}", "Native Bifrost User", "/App", anonymous: true);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var payload = await response.Content.ReadFromJsonAsync<JsonElement>();
@ -79,15 +71,8 @@ public sealed class PlatformEndpointsTests : IClassFixture<TestWebApplicationFac
using var client = _factory.CreateClient();
var identity = $"native-{Guid.NewGuid():N}";
using var firstRequest = new HttpRequestMessage(HttpMethod.Post, "/auth/bifrost/register");
firstRequest.Headers.Add("X-Test-Anonymous", "true");
firstRequest.Content = JsonContent.Create(new { identity, displayName = "First" });
using var firstResponse = await client.SendAsync(firstRequest);
using var secondRequest = new HttpRequestMessage(HttpMethod.Post, "/auth/bifrost/register");
secondRequest.Headers.Add("X-Test-Anonymous", "true");
secondRequest.Content = JsonContent.Create(new { identity = identity.ToUpperInvariant(), displayName = "Second" });
using var secondResponse = await client.SendAsync(secondRequest);
using var firstResponse = await RegistrationKit.RegisterAsync(client, identity, "First", anonymous: true);
using var secondResponse = await RegistrationKit.RegisterAsync(client, identity.ToUpperInvariant(), "Second", anonymous: true);
Assert.Equal(HttpStatusCode.OK, firstResponse.StatusCode);
Assert.Equal(HttpStatusCode.BadRequest, secondResponse.StatusCode);

View file

@ -0,0 +1,367 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Bifrost.Web.Configuration;
using Bifrost.Web.Data;
using Bifrost.Web.Features.Registration;
using Bifrost.Web.Features.Shared;
using Bifrost.Web.Tests.Support;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
namespace Bifrost.Web.Tests;
public sealed class RegistrationGateFactory : TestWebApplicationFactory
{
public ManualClock Time { get; } = new();
protected override void ConfigureWebHost(Microsoft.AspNetCore.Hosting.IWebHostBuilder builder)
{
base.ConfigureWebHost(builder);
builder.ConfigureServices(services =>
{
services.RemoveAll<TimeProvider>();
services.AddSingleton<TimeProvider>(Time);
});
}
}
// The anonymous register is gated by consent and a proof of work; every refusal must create nothing.
public sealed class RegistrationGateTests(RegistrationGateFactory factory) : IClassFixture<RegistrationGateFactory>
{
private static string NewIdentity() => $"gate-{Guid.NewGuid():N}";
private HttpClient Client() => factory.CreateClient();
private (int Accounts, int Receipts) Counts()
{
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
return (db.UserAccounts.Count(), db.ConsentReceipts.Count());
}
private async Task<(string Challenge, string Nonce)> SolvedAsync(HttpClient client) =>
await RegistrationKit.SolveAsync(client);
private async Task AssertRefusedCreatesNothingAsync(HttpClient client, object body, string? expectedFragment = null)
{
var before = Counts();
using var response = await RegistrationKit.PostAsync(client, body, anonymous: true);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
if (expectedFragment is not null)
{
Assert.Contains(expectedFragment, await response.Content.ReadAsStringAsync());
}
Assert.Equal(before, Counts());
}
[Fact]
public async Task No_consent_is_refused_and_creates_nothing()
{
using var client = Client();
var (challenge, nonce) = await SolvedAsync(client);
await AssertRefusedCreatesNothingAsync(
client,
new { identity = NewIdentity(), challenge, nonce },
$"current version {TrainingNotice.Version}");
}
[Theory]
[InlineData(0)]
[InlineData(-1)]
[InlineData(2)]
public async Task A_wrong_notice_version_is_refused_and_creates_nothing(int version)
{
using var client = Client();
var (challenge, nonce) = await SolvedAsync(client);
await AssertRefusedCreatesNothingAsync(
client,
new { identity = NewIdentity(), challenge, nonce, acceptedNoticeVersion = version });
}
[Fact]
public async Task A_missing_challenge_is_refused_and_creates_nothing()
{
using var client = Client();
await AssertRefusedCreatesNothingAsync(
client,
new { identity = NewIdentity(), acceptedNoticeVersion = TrainingNotice.Version });
}
[Theory]
[InlineData("garbage")]
[InlineData("")]
public async Task A_garbage_challenge_is_refused_and_creates_nothing(string challenge)
{
using var client = Client();
await AssertRefusedCreatesNothingAsync(
client,
new { identity = NewIdentity(), challenge, nonce = "1", acceptedNoticeVersion = TrainingNotice.Version });
}
[Fact]
public async Task A_tampered_challenge_is_refused_and_creates_nothing()
{
using var client = Client();
var (challenge, _) = await SolvedAsync(client);
var tampered = challenge[..^2] + (challenge[^2] == 'A' ? 'B' : 'A') + challenge[^1];
var nonce = RegistrationKit.Solve(tampered, 4);
await AssertRefusedCreatesNothingAsync(
client,
new { identity = NewIdentity(), challenge = tampered, nonce, acceptedNoticeVersion = TrainingNotice.Version });
}
[Fact]
public async Task A_wrong_nonce_is_refused_and_creates_nothing()
{
using var client = Client();
var issued = await client.GetFromJsonAsync<JsonElement>("/auth/bifrost/challenge");
var challenge = issued.GetProperty("challenge").GetString()!;
var difficulty = issued.GetProperty("difficulty").GetInt32();
var correct = RegistrationKit.Solve(challenge, difficulty);
// The next nonce that does not meet the difficulty.
var wrong = Enumerable.Range(0, int.MaxValue)
.Select(i => i.ToString())
.First(n => n != correct && RegistrationChallengeService.LeadingZeroBits(
System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(challenge + ":" + n))) < difficulty);
await AssertRefusedCreatesNothingAsync(
client,
new { identity = NewIdentity(), challenge, nonce = wrong, acceptedNoticeVersion = TrainingNotice.Version });
}
// Each malformed shape is padded until it meets the difficulty, so only the nonce-format guard can refuse it.
[Theory]
[InlineData("x", "")]
[InlineData("-", "")]
[InlineData("", "e")]
[InlineData("000000000000000000000", "")]
public async Task A_malformed_nonce_is_refused_even_when_its_hash_would_pass(string prefix, string suffix)
{
using var client = Client();
var (challenge, _) = await SolvedAsync(client);
var nonce = Enumerable.Range(0, int.MaxValue)
.Select(i => prefix + i + suffix)
.First(n => RegistrationChallengeService.LeadingZeroBits(
System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(challenge + ":" + n))) >= 4);
await AssertRefusedCreatesNothingAsync(
client,
new { identity = NewIdentity(), challenge, nonce, acceptedNoticeVersion = TrainingNotice.Version });
}
[Fact]
public async Task An_expired_challenge_is_refused_and_creates_nothing()
{
using var client = Client();
var (challenge, nonce) = await SolvedAsync(client);
factory.Time.Advance(RegistrationChallengeService.Lifetime + TimeSpan.FromSeconds(1));
await AssertRefusedCreatesNothingAsync(
client,
new { identity = NewIdentity(), challenge, nonce, acceptedNoticeVersion = TrainingNotice.Version });
}
[Fact]
public async Task A_consumed_challenge_cannot_be_replayed()
{
using var client = Client();
var (challenge, nonce) = await SolvedAsync(client);
var first = NewIdentity();
var body = (string identity) => new { identity, challenge, nonce, acceptedNoticeVersion = TrainingNotice.Version };
using var accepted = await RegistrationKit.PostAsync(client, body(first), anonymous: true);
Assert.Equal(HttpStatusCode.OK, accepted.StatusCode);
var before = Counts();
await AssertRefusedCreatesNothingAsync(client, body(NewIdentity()));
Assert.Equal(before, Counts());
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
Assert.Single(db.UserAccounts.Where(x => x.NormalizedBifrostIdentity == first));
}
[Fact]
public async Task A_nonce_solved_for_one_challenge_does_not_pass_another()
{
using var client = Client();
var (_, nonceForFirst) = await SolvedAsync(client);
var issued = await client.GetFromJsonAsync<JsonElement>("/auth/bifrost/challenge");
var second = issued.GetProperty("challenge").GetString()!;
var difficulty = issued.GetProperty("difficulty").GetInt32();
// Skip the rare case where the nonce happens to also solve the second challenge.
if (RegistrationChallengeService.LeadingZeroBits(
System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(second + ":" + nonceForFirst))) >= difficulty)
{
return;
}
await AssertRefusedCreatesNothingAsync(
client,
new { identity = NewIdentity(), challenge = second, nonce = nonceForFirst, acceptedNoticeVersion = TrainingNotice.Version });
}
[Fact]
public async Task Success_stores_the_receipt_with_the_account()
{
using var client = Client();
var identity = NewIdentity();
using var response = await RegistrationKit.RegisterAsync(client, identity, anonymous: true);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
var account = db.UserAccounts.Single(x => x.NormalizedBifrostIdentity == identity);
var receipt = db.ConsentReceipts.Single(x => x.UserAccountId == account.Id);
Assert.Equal(1, receipt.NoticeVersion);
Assert.Null(receipt.WithdrawnAtUtc);
Assert.InRange((factory.Time.GetUtcNow() - receipt.AcceptedAtUtc).Duration(), TimeSpan.Zero, TimeSpan.FromSeconds(5));
}
[Fact]
public async Task Withdraw_stamps_once_and_touches_only_the_caller()
{
using var client = Client();
var mine = NewIdentity();
var theirs = NewIdentity();
(await RegistrationKit.RegisterAsync(client, mine, anonymous: true)).Dispose();
(await RegistrationKit.RegisterAsync(client, theirs, anonymous: true)).Dispose();
async Task<JsonElement> WithdrawAsync()
{
using var request = new HttpRequestMessage(HttpMethod.Post, "/auth/bifrost/consent/withdraw");
request.Headers.Add("X-Test-Bifrost-Identity", mine);
using var response = await client.SendAsync(request);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
return await response.Content.ReadFromJsonAsync<JsonElement>();
}
var first = await WithdrawAsync();
factory.Time.Advance(TimeSpan.FromHours(1));
var second = await WithdrawAsync();
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
var myReceipt = db.ConsentReceipts.Single(x => x.UserAccount!.NormalizedBifrostIdentity == mine);
var theirReceipt = db.ConsentReceipts.Single(x => x.UserAccount!.NormalizedBifrostIdentity == theirs);
Assert.NotNull(myReceipt.WithdrawnAtUtc);
Assert.Equal(myReceipt.WithdrawnAtUtc, first.GetProperty("withdrawnAtUtc").GetDateTimeOffset());
Assert.Equal(first.GetProperty("withdrawnAtUtc").GetDateTimeOffset(), second.GetProperty("withdrawnAtUtc").GetDateTimeOffset());
Assert.Null(theirReceipt.WithdrawnAtUtc);
}
[Fact]
public async Task Anonymous_withdraw_is_refused_and_changes_nothing()
{
using var client = Client();
var identity = NewIdentity();
(await RegistrationKit.RegisterAsync(client, identity, anonymous: true)).Dispose();
using var request = new HttpRequestMessage(HttpMethod.Post, "/auth/bifrost/consent/withdraw");
request.Headers.Add("X-Test-Anonymous", "true");
using var response = await client.SendAsync(request);
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
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()
{
using var client = Client();
var notice = await client.GetFromJsonAsync<JsonElement>("/auth/bifrost/notice");
Assert.Equal(1, notice.GetProperty("version").GetInt32());
Assert.Contains("Discord", notice.GetProperty("text").GetString());
}
[Fact]
public async Task The_challenge_response_is_not_cached()
{
using var client = Client();
using var response = await client.GetAsync("/auth/bifrost/challenge");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Contains("no-store", response.Headers.CacheControl?.ToString());
}
[Theory]
[InlineData(0, false)]
[InlineData(1, true)]
[InlineData(18, true)]
[InlineData(30, true)]
[InlineData(31, false)]
public void Difficulty_outside_one_to_thirty_is_refused_at_startup(int difficulty, bool valid)
{
var options = new RegistrationOptions { Captcha = new RegistrationCaptchaOptions { Difficulty = difficulty } };
Action validate = () => new StartupConfigurationValidator(
new ConfigurationBuilder().Build(),
new TestingEnvironment(),
Options.Create(new GitHubOAuthOptions()),
Options.Create(new GitHubAppOptions()),
Options.Create(new HeimdallOptions()),
Options.Create(new StripeOptions()),
Options.Create(new BifrostHostOptions()),
Options.Create(new ConceptVoteOptions()),
Options.Create(new DeskOptions()),
Options.Create(options)).Validate();
if (valid)
{
validate();
}
else
{
Assert.Contains("Difficulty", Assert.Throws<InvalidOperationException>(validate).Message);
}
Assert.Equal(18, new RegistrationOptions().Captcha.Difficulty);
}
private sealed class TestingEnvironment : IHostEnvironment
{
public string EnvironmentName { get; set; } = "Testing";
public string ApplicationName { get; set; } = "Bifrost.Web";
public string ContentRootPath { get; set; } = string.Empty;
public Microsoft.Extensions.FileProviders.IFileProvider ContentRootFileProvider { get; set; } = null!;
}
}

View file

@ -0,0 +1,239 @@
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 An_IPv6_caller_owns_its_slash_64_and_a_mapped_IPv4_shares_its_IPv4_key()
{
using var client = factory.CreateClient();
string[] sameSlash64 = ["2001:db8:1:2::1", "2001:db8:1:2::2", "2001:db8:1:2:aaaa::3", "2001:db8:1:2:ffff:ffff:ffff:ffff", "2001:db8:1:2::5"];
var statuses = new List<HttpStatusCode>();
foreach (var peer in sameSlash64)
{
using var response = await ChallengeAsync(client, peer);
statuses.Add(response.StatusCode);
}
Assert.Equal(
[HttpStatusCode.OK, HttpStatusCode.OK, HttpStatusCode.OK, HttpStatusCode.TooManyRequests, HttpStatusCode.TooManyRequests],
statuses);
using var otherSlash64 = await ChallengeAsync(client, "2001:db8:1:3::1");
Assert.Equal(HttpStatusCode.OK, otherSlash64.StatusCode);
for (var i = 0; i < 3; i++)
{
using var ok = await ChallengeAsync(client, "203.0.113.50");
Assert.Equal(HttpStatusCode.OK, ok.StatusCode);
}
using var mapped = await ChallengeAsync(client, "::ffff:203.0.113.50");
Assert.Equal(HttpStatusCode.TooManyRequests, mapped.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

@ -28,7 +28,7 @@ public sealed class ReservedIdentityTests
{
var service = NewService(out var db);
var ex = await Assert.ThrowsAsync<BifrostIdentityException>(() => service.RegisterNativeAsync(name, null, default));
var ex = await Assert.ThrowsAsync<BifrostIdentityException>(() => service.RegisterNativeAsync(name, null, 1, default));
Assert.Equal("That Bifrost identity is reserved.", ex.Message);
Assert.Empty(db.UserAccounts);
@ -42,7 +42,7 @@ public sealed class ReservedIdentityTests
{
var service = NewService(out var db);
await Assert.ThrowsAsync<BifrostIdentityException>(() => service.RegisterNativeAsync(name, null, default));
await Assert.ThrowsAsync<BifrostIdentityException>(() => service.RegisterNativeAsync(name, null, 1, default));
Assert.Empty(db.UserAccounts);
}
@ -56,7 +56,7 @@ public sealed class ReservedIdentityTests
{
var service = NewService(out var db);
var account = await service.RegisterNativeAsync(name, null, default);
var account = await service.RegisterNativeAsync(name, null, 1, default);
Assert.Equal(name, account.NormalizedBifrostIdentity);
Assert.Single(db.UserAccounts);
@ -112,7 +112,7 @@ public sealed class ReservedIdentityTests
{
var service = NewService(out var db);
var ex = await Assert.ThrowsAsync<BifrostIdentityException>(() => service.RegisterNativeAsync(name, null, default));
var ex = await Assert.ThrowsAsync<BifrostIdentityException>(() => service.RegisterNativeAsync(name, null, 1, default));
Assert.Equal("That Bifrost identity is reserved.", ex.Message);
Assert.Empty(db.UserAccounts);
@ -147,7 +147,7 @@ public sealed class ReservedIdentityTests
public async Task Mixed_case_input_is_refused_by_register_and_suffixed_when_derived(string input)
{
var service = NewService(out var db);
await Assert.ThrowsAsync<BifrostIdentityException>(() => service.RegisterNativeAsync(input, null, default));
await Assert.ThrowsAsync<BifrostIdentityException>(() => service.RegisterNativeAsync(input, null, 1, default));
Assert.Empty(db.UserAccounts);
var account = new UserAccount { DisplayName = input };

View file

@ -403,7 +403,7 @@ public sealed class OidcLogoutFactory : OidcWebApplicationFactory
{
using var client = CreateClient(HostClientOptions());
var identity = $"member-{Guid.NewGuid():N}";
using (var register = await client.PostAsJsonAsync("/auth/bifrost/register", new { identity, displayName = identity }))
using (var register = await RegistrationKit.RegisterAsync(client, identity))
{
Assert.Equal(HttpStatusCode.OK, register.StatusCode);
}

View file

@ -0,0 +1,63 @@
using System.Net.Http.Json;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Bifrost.Web.Features.Registration;
namespace Bifrost.Web.Tests.Support;
/// <summary>Registers through the real gate: fetch a challenge, solve it, post with the notice version.</summary>
public static class RegistrationKit
{
public static async Task<(string Challenge, string Nonce)> SolveAsync(HttpClient client)
{
var issued = await client.GetFromJsonAsync<JsonElement>("/auth/bifrost/challenge");
var challenge = issued.GetProperty("challenge").GetString()!;
return (challenge, Solve(challenge, issued.GetProperty("difficulty").GetInt32()));
}
public static string Solve(string challenge, int difficulty)
{
for (long nonce = 0; ; nonce++)
{
var text = nonce.ToString(System.Globalization.CultureInfo.InvariantCulture);
if (RegistrationChallengeService.LeadingZeroBits(SHA256.HashData(Encoding.UTF8.GetBytes(challenge + ":" + text))) >= difficulty)
{
return text;
}
}
}
public static async Task<HttpResponseMessage> RegisterAsync(
HttpClient client,
string identity,
string? displayName = null,
string? returnUrl = null,
bool anonymous = false)
{
var (challenge, nonce) = await SolveAsync(client);
return await PostAsync(client, new
{
identity,
displayName = displayName ?? identity,
returnUrl,
challenge,
nonce,
acceptedNoticeVersion = TrainingNotice.Version
}, anonymous);
}
public static async Task<HttpResponseMessage> PostAsync(HttpClient client, object body, bool anonymous = false)
{
using var request = new HttpRequestMessage(HttpMethod.Post, "/auth/bifrost/register")
{
Content = JsonContent.Create(body)
};
if (anonymous)
{
request.Headers.Add("X-Test-Anonymous", "true");
}
return await client.SendAsync(request);
}
}

View file

@ -21,6 +21,16 @@ public sealed class TestAuthHandler(
return Task.FromResult(AuthenticateResult.NoResult());
}
// A test may sign in as a native account by naming its Bifrost identity; only that claim is sent, so no GitHub claim also matches.
if (Request.Headers.TryGetValue("X-Test-Bifrost-Identity", out var nativeIdentity))
{
var native = new ClaimsIdentity(
[new Claim(BifrostClaimTypes.BifrostIdentity, nativeIdentity.ToString())],
SchemeName);
return Task.FromResult(AuthenticateResult.Success(
new AuthenticationTicket(new ClaimsPrincipal(native), SchemeName)));
}
// A test may sign in as another account by naming its GitHub id and login.
var gitHubId = Request.Headers["X-Test-GitHub-Id"].FirstOrDefault() ?? "12345";
var login = Request.Headers["X-Test-GitHub-Login"].FirstOrDefault() ?? "test-admin";

View file

@ -38,6 +38,8 @@ public class TestWebApplicationFactory : WebApplicationFactory<Program>
["ConceptVotes:CastPermits"] = "100000",
["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",