From 513d614c25728a97a0107b2d59a69022500d3e86 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Wed, 11 Nov 2026 08:49:19 +0000 Subject: [PATCH 1/5] Gate native registration with proof-of-work and a training-notice consent receipt Co-Authored-By: Claude Sonnet 5.5 --- README.md | 2 +- docs/bridge.md | 2 +- .../Configuration/RegistrationOptions.cs | 19 + src/Bifrost.Web/Data/BifrostDbContext.cs | 10 + .../20261111090000_AddConsentReceipts.cs | 40 +++ .../BifrostDbContextModelSnapshot.cs | 37 ++ src/Bifrost.Web/Domain/BifrostModels.cs | 19 + .../Membership/BifrostIdentityService.cs | 8 + .../RegistrationChallengeService.cs | 131 +++++++ .../Features/Registration/TrainingNotice.cs | 17 + .../Shared/StartupConfigurationValidator.cs | 9 +- src/Bifrost.Web/Program.cs | 63 +++- tests/Bifrost.Web.Tests/DeskOptionsTests.cs | 3 +- tests/Bifrost.Web.Tests/HostTrustTests.cs | 3 +- .../MigrationDiscoveryTests.cs | 1 + tests/Bifrost.Web.Tests/OidcIssuerTests.cs | 2 +- .../PlatformEndpointsTests.cs | 23 +- .../RegistrationGateTests.cs | 338 ++++++++++++++++++ .../ReservedIdentityTests.cs | 10 +- .../Support/OidcLogoutKit.cs | 2 +- .../Support/RegistrationKit.cs | 63 ++++ .../Support/TestAuthHandler.cs | 10 + .../Support/TestWebApplicationFactory.cs | 1 + 23 files changed, 781 insertions(+), 32 deletions(-) create mode 100644 src/Bifrost.Web/Configuration/RegistrationOptions.cs create mode 100644 src/Bifrost.Web/Data/Migrations/20261111090000_AddConsentReceipts.cs create mode 100644 src/Bifrost.Web/Features/Registration/RegistrationChallengeService.cs create mode 100644 src/Bifrost.Web/Features/Registration/TrainingNotice.cs create mode 100644 tests/Bifrost.Web.Tests/RegistrationGateTests.cs create mode 100644 tests/Bifrost.Web.Tests/Support/RegistrationKit.cs diff --git a/README.md b/README.md index 0e4ea9d..33302e1 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/bridge.md b/docs/bridge.md index b9b35a6..da23705 100644 --- a/docs/bridge.md +++ b/docs/bridge.md @@ -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 diff --git a/src/Bifrost.Web/Configuration/RegistrationOptions.cs b/src/Bifrost.Web/Configuration/RegistrationOptions.cs new file mode 100644 index 0000000..3ec593f --- /dev/null +++ b/src/Bifrost.Web/Configuration/RegistrationOptions.cs @@ -0,0 +1,19 @@ +namespace Bifrost.Web.Configuration; + +/// Gates on the anonymous native registration (section Bifrost:Registration). +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 sealed class RegistrationCaptchaOptions +{ + /// Leading zero bits the SHA-256 of challenge:nonce must have. 18 is about 262k hashes, a second or two in a browser. + public int Difficulty { get; init; } = 18; +} diff --git a/src/Bifrost.Web/Data/BifrostDbContext.cs b/src/Bifrost.Web/Data/BifrostDbContext.cs index 87f4dd1..57f5788 100644 --- a/src/Bifrost.Web/Data/BifrostDbContext.cs +++ b/src/Bifrost.Web/Data/BifrostDbContext.cs @@ -43,6 +43,7 @@ public sealed class BifrostDbContext(DbContextOptions options) public DbSet SealedBallots => Set(); + public DbSet ConsentReceipts => Set(); public DbSet MindKeys => Set(); public DbSet MindNonces => Set(); @@ -322,6 +323,15 @@ public sealed class BifrostDbContext(DbContextOptions options) .OnDelete(DeleteBehavior.Restrict); }); + modelBuilder.Entity(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(entity => { entity.HasIndex(x => new { x.PersonaId, x.PublicKey }).IsUnique(); diff --git a/src/Bifrost.Web/Data/Migrations/20261111090000_AddConsentReceipts.cs b/src/Bifrost.Web/Data/Migrations/20261111090000_AddConsentReceipts.cs new file mode 100644 index 0000000..c7e4acc --- /dev/null +++ b/src/Bifrost.Web/Data/Migrations/20261111090000_AddConsentReceipts.cs @@ -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(type: "uuid", nullable: false), + UserAccountId = table.Column(type: "uuid", nullable: false), + NoticeVersion = table.Column(type: "integer", nullable: false), + AcceptedAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + WithdrawnAtUtc = table.Column(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"); + } + } +} diff --git a/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs b/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs index b2f8dd0..63f6a40 100644 --- a/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs +++ b/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs @@ -1440,6 +1440,32 @@ namespace Bifrost.Web.Data.Migrations b.ToTable("VolunteerClaims"); }); + modelBuilder.Entity("Bifrost.Web.Domain.ConsentReceipt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AcceptedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("NoticeVersion") + .HasColumnType("integer"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.Property("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("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") diff --git a/src/Bifrost.Web/Domain/BifrostModels.cs b/src/Bifrost.Web/Domain/BifrostModels.cs index 3cc0506..2e5d680 100644 --- a/src/Bifrost.Web/Domain/BifrostModels.cs +++ b/src/Bifrost.Web/Domain/BifrostModels.cs @@ -1155,6 +1155,25 @@ public sealed class AuditEvent public UserAccount? ActorUserAccount { get; set; } } +/// +/// An account's consent to the training notice, recorded with the notice version it accepted. Withdrawal sets +/// ; the row is never deleted, so what was agreed and when stays answerable. +/// +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; } +} + /// A Mind's registered Ed25519 public key. Bifrost never holds a private key. public sealed class MindKey { diff --git a/src/Bifrost.Web/Features/Membership/BifrostIdentityService.cs b/src/Bifrost.Web/Features/Membership/BifrostIdentityService.cs index af6596e..ced6ed9 100644 --- a/src/Bifrost.Web/Features/Membership/BifrostIdentityService.cs +++ b/src/Bifrost.Web/Features/Membership/BifrostIdentityService.cs @@ -15,6 +15,7 @@ public sealed partial class BifrostIdentityService( public async Task 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; } diff --git a/src/Bifrost.Web/Features/Registration/RegistrationChallengeService.cs b/src/Bifrost.Web/Features/Registration/RegistrationChallengeService.cs new file mode 100644 index 0000000..b750f4f --- /dev/null +++ b/src/Bifrost.Web/Features/Registration/RegistrationChallengeService.cs @@ -0,0 +1,131 @@ +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); + +/// +/// 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. +/// +public sealed class RegistrationChallengeService( + IDataProtectionProvider dataProtection, + IOptions 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 Dictionary consumed = new(StringComparer.Ordinal); + private readonly object gate = new(); + + private int Difficulty => options.Value.Captcha.Difficulty; + + /// Null when the consumed set is full: refuse rather than grow. + public RegistrationChallenge? Issue() + { + var now = clock.GetUtcNow(); + lock (gate) + { + Purge(now); + if (consumed.Count >= MaxConsumed) + { + return null; + } + } + + var expiresAt = now + Lifetime; + var id = Convert.ToHexString(RandomNumberGenerator.GetBytes(16)); + var challenge = protector.Protect($"{id}.{expiresAt.ToUnixTimeMilliseconds()}"); + return new RegistrationChallenge(challenge, Difficulty, expiresAt); + } + + public bool TryConsume(string? challenge, string? nonce) + { + if (string.IsNullOrEmpty(challenge) || !IsNonce(nonce)) + { + return false; + } + + string id; + DateTimeOffset expiresAt; + try + { + var payload = protector.Unprotect(challenge).Split('.'); + if (payload.Length != 2) + { + return false; + } + + id = payload[0]; + 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.ContainsKey(id)) + { + return false; + } + + consumed[id] = expiresAt; + return true; + } + } + + private void Purge(DateTimeOffset now) + { + foreach (var id in consumed.Where(x => x.Value <= now).Select(x => x.Key).ToList()) + { + 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 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; + } +} diff --git a/src/Bifrost.Web/Features/Registration/TrainingNotice.cs b/src/Bifrost.Web/Features/Registration/TrainingNotice.cs new file mode 100644 index 0000000..7aaa679 --- /dev/null +++ b/src/Bifrost.Web/Features/Registration/TrainingNotice.cs @@ -0,0 +1,17 @@ +namespace Bifrost.Web.Features.Registration; + +/// +/// The training notice a registrant accepts. DRAFT wording: Eureka replaces . Changing the wording +/// in substance means a new (a positive integer, no leading zeros), because a receipt records the +/// version the person accepted. +/// +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."; +} diff --git a/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs b/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs index 6be28cb..fa74c50 100644 --- a/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs +++ b/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs @@ -13,7 +13,8 @@ public sealed class StartupConfigurationValidator( IOptions stripeOptions, IOptions hostOptions, IOptions conceptVoteOptions, - IOptions deskOptions) + IOptions deskOptions, + IOptions registrationOptions) { public void Validate() { @@ -48,6 +49,12 @@ 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}."); + } + if (hostEnvironment.IsDevelopment() || hostEnvironment.IsEnvironment("Testing")) { Refuse(failures); diff --git a/src/Bifrost.Web/Program.cs b/src/Bifrost.Web/Program.cs index e19b8d0..adb3b18 100644 --- a/src/Bifrost.Web/Program.cs +++ b/src/Bifrost.Web/Program.cs @@ -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,8 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddOptions().BindConfiguration(MindIntakeOptions.SectionName); builder.Services.AddSingleton(); // TimeProvider and options resolve from the container +builder.Services.AddOptions().BindConfiguration(RegistrationOptions.SectionName); +builder.Services.AddSingleton(); builder.Services.AddHostedService(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -386,13 +389,28 @@ app.MapPost("/auth/bifrost/register", async ( NativeBifrostRegistrationRequest request, HttpContext httpContext, BifrostIdentityService bifrostIdentityService, + RegistrationChallengeService challenges, CancellationToken cancellationToken) => { + // 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 +432,46 @@ app.MapPost("/auth/bifrost/register", async ( } }).AllowAnonymous(); +app.MapGet("/auth/bifrost/challenge", (HttpContext httpContext, RegistrationChallengeService challenges) => +{ + 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); + 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 +850,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, diff --git a/tests/Bifrost.Web.Tests/DeskOptionsTests.cs b/tests/Bifrost.Web.Tests/DeskOptionsTests.cs index 8b09244..693950a 100644 --- a/tests/Bifrost.Web.Tests/DeskOptionsTests.cs +++ b/tests/Bifrost.Web.Tests/DeskOptionsTests.cs @@ -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 values, string expectedFragment) diff --git a/tests/Bifrost.Web.Tests/HostTrustTests.cs b/tests/Bifrost.Web.Tests/HostTrustTests.cs index 48902ee..717062d 100644 --- a/tests/Bifrost.Web.Tests/HostTrustTests.cs +++ b/tests/Bifrost.Web.Tests/HostTrustTests.cs @@ -363,7 +363,8 @@ public sealed class HostTrustTests : IClassFixture 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 diff --git a/tests/Bifrost.Web.Tests/MigrationDiscoveryTests.cs b/tests/Bifrost.Web.Tests/MigrationDiscoveryTests.cs index 3d4b738..cd46628 100644 --- a/tests/Bifrost.Web.Tests/MigrationDiscoveryTests.cs +++ b/tests/Bifrost.Web.Tests/MigrationDiscoveryTests.cs @@ -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. diff --git a/tests/Bifrost.Web.Tests/OidcIssuerTests.cs b/tests/Bifrost.Web.Tests/OidcIssuerTests.cs index 68617b8..2b698f0 100644 --- a/tests/Bifrost.Web.Tests/OidcIssuerTests.cs +++ b/tests/Bifrost.Web.Tests/OidcIssuerTests.cs @@ -838,7 +838,7 @@ public sealed class OidcIssuerTests : IClassFixture private static async Task 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(); } diff --git a/tests/Bifrost.Web.Tests/PlatformEndpointsTests.cs b/tests/Bifrost.Web.Tests/PlatformEndpointsTests.cs index dd7543b..7597ea0 100644 --- a/tests/Bifrost.Web.Tests/PlatformEndpointsTests.cs +++ b/tests/Bifrost.Web.Tests/PlatformEndpointsTests.cs @@ -45,16 +45,8 @@ public sealed class PlatformEndpointsTests : IClassFixture(); @@ -79,15 +71,8 @@ public sealed class PlatformEndpointsTests : IClassFixture + { + services.RemoveAll(); + services.AddSingleton(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 +{ + 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(); + 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("/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 }); + } + + [Theory] + [InlineData("")] + [InlineData("-1")] + [InlineData("1e3")] + [InlineData("123456789012345678901")] + public async Task A_malformed_nonce_is_refused(string nonce) + { + using var client = Client(); + var (challenge, _) = await SolvedAsync(client); + + 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(); + 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("/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(); + 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 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(); + } + + 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(); + 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(); + Assert.DoesNotContain(db.ConsentReceipts, x => x.WithdrawnAtUtc != null && x.UserAccount!.NormalizedBifrostIdentity == identity); + } + + [Fact] + public async Task The_notice_endpoint_names_the_version_and_the_discord_exclusion() + { + using var client = Client(); + + var notice = await client.GetFromJsonAsync("/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(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!; + } +} diff --git a/tests/Bifrost.Web.Tests/ReservedIdentityTests.cs b/tests/Bifrost.Web.Tests/ReservedIdentityTests.cs index 035a1e0..1a820e9 100644 --- a/tests/Bifrost.Web.Tests/ReservedIdentityTests.cs +++ b/tests/Bifrost.Web.Tests/ReservedIdentityTests.cs @@ -28,7 +28,7 @@ public sealed class ReservedIdentityTests { var service = NewService(out var db); - var ex = await Assert.ThrowsAsync(() => service.RegisterNativeAsync(name, null, default)); + var ex = await Assert.ThrowsAsync(() => 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(() => service.RegisterNativeAsync(name, null, default)); + await Assert.ThrowsAsync(() => 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(() => service.RegisterNativeAsync(name, null, default)); + var ex = await Assert.ThrowsAsync(() => 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(() => service.RegisterNativeAsync(input, null, default)); + await Assert.ThrowsAsync(() => service.RegisterNativeAsync(input, null, 1, default)); Assert.Empty(db.UserAccounts); var account = new UserAccount { DisplayName = input }; diff --git a/tests/Bifrost.Web.Tests/Support/OidcLogoutKit.cs b/tests/Bifrost.Web.Tests/Support/OidcLogoutKit.cs index b7425f6..582fa04 100644 --- a/tests/Bifrost.Web.Tests/Support/OidcLogoutKit.cs +++ b/tests/Bifrost.Web.Tests/Support/OidcLogoutKit.cs @@ -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); } diff --git a/tests/Bifrost.Web.Tests/Support/RegistrationKit.cs b/tests/Bifrost.Web.Tests/Support/RegistrationKit.cs new file mode 100644 index 0000000..7f15ff3 --- /dev/null +++ b/tests/Bifrost.Web.Tests/Support/RegistrationKit.cs @@ -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; + +/// Registers through the real gate: fetch a challenge, solve it, post with the notice version. +public static class RegistrationKit +{ + public static async Task<(string Challenge, string Nonce)> SolveAsync(HttpClient client) + { + var issued = await client.GetFromJsonAsync("/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 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 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); + } +} diff --git a/tests/Bifrost.Web.Tests/Support/TestAuthHandler.cs b/tests/Bifrost.Web.Tests/Support/TestAuthHandler.cs index 298642e..8bcb594 100644 --- a/tests/Bifrost.Web.Tests/Support/TestAuthHandler.cs +++ b/tests/Bifrost.Web.Tests/Support/TestAuthHandler.cs @@ -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"; diff --git a/tests/Bifrost.Web.Tests/Support/TestWebApplicationFactory.cs b/tests/Bifrost.Web.Tests/Support/TestWebApplicationFactory.cs index 563aee2..d152dbf 100644 --- a/tests/Bifrost.Web.Tests/Support/TestWebApplicationFactory.cs +++ b/tests/Bifrost.Web.Tests/Support/TestWebApplicationFactory.cs @@ -38,6 +38,7 @@ public class TestWebApplicationFactory : WebApplicationFactory ["ConceptVotes:CastPermits"] = "100000", ["ConceptVotes:MintPermits"] = "100000", ["MindIntake:Permits"] = "100000", + ["Bifrost:Registration:Captcha:Difficulty"] = "4", ["Host:PublicBaseUrl"] = "https://localhost", ["Host:ExpectedHost"] = "localhost", ["Host:RequireStrictHostValidation"] = "false", -- 2.47.3 From 3d385ecb739de97b0eeebab90f26b2a7b0066ce6 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Wed, 11 Nov 2026 08:56:35 +0000 Subject: [PATCH 2/5] Make the malformed-nonce test independent of the proof of work Co-Authored-By: Claude Sonnet 5.5 --- tests/Bifrost.Web.Tests/RegistrationGateTests.cs | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/tests/Bifrost.Web.Tests/RegistrationGateTests.cs b/tests/Bifrost.Web.Tests/RegistrationGateTests.cs index fe74115..c448b8e 100644 --- a/tests/Bifrost.Web.Tests/RegistrationGateTests.cs +++ b/tests/Bifrost.Web.Tests/RegistrationGateTests.cs @@ -140,15 +140,20 @@ public sealed class RegistrationGateTests(RegistrationGateFactory factory) : ICl 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("")] - [InlineData("-1")] - [InlineData("1e3")] - [InlineData("123456789012345678901")] - public async Task A_malformed_nonce_is_refused(string nonce) + [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, -- 2.47.3 From 0001c9d6d6a4dd6a233f02a435b5901803a65636 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Wed, 11 Nov 2026 09:00:39 +0000 Subject: [PATCH 3/5] Registration gates scope: CAPTCHA, consent receipt, email verification Co-Authored-By: Claude Sonnet 5.5 --- docs/registration-gates.md | 83 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 docs/registration-gates.md diff --git a/docs/registration-gates.md b/docs/registration-gates.md new file mode 100644 index 0000000..791a1e4 --- /dev/null +++ b/docs/registration-gates.md @@ -0,0 +1,83 @@ +# 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. +- 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. + +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 `