Registration: sign-up form with proof-of-work and a no-JS time-lock fallback (#56)

This commit is contained in:
Heimdall 2026-11-11 11:17:25 +00:00
commit 028bc2fdf6
16 changed files with 1674 additions and 121 deletions

View file

@ -59,7 +59,7 @@ There are no development or ephemeral keys in the host. Key files are provisione
| `preferred_username` | `NormalizedBifrostIdentity` (minted once; there is no rename path) |
| `name` | `DisplayName`, else `BifrostIdentity` |
No e-mail, no Heimdall id, no GitHub id, and no membership detail leaves Bifrost.
No e-mail, no Heimdall id, no GitHub id, and no membership detail leaves Bifrost. A native account's verified address, once that is built, stays inside Bifrost as sign-in recovery and is never a claim (`registration-gates.md`, Email verification).
## Who may sign in

View file

@ -41,10 +41,39 @@ server verifies the protection, the expiry and the hash, and spends the id once.
(`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.
### The sign-up form and the no-JS fallback
`GET /auth/bifrost/register` renders the form: identity, optional display name, the notice in full beside an unchecked
consent box (the box is the consent act), and a challenge. With JavaScript the page solves the proof of work in the
background and reports progress in a polite live region. Without JavaScript the form still submits and succeeds, by a
time-lock:
- The challenge carries its issue time. A submission that says it waited is accepted only once `FallbackWaitSeconds`
(default 30) have passed since issue; earlier, the page comes back saying how many seconds remain and the challenge is
not spent (a fresh challenge would restart the clock). The same holds for a refused identity (taken, reserved or
malformed): those rules are checked before any permit or challenge is spent, and the still-valid challenge is kept. Every
other failure re-renders with a fresh challenge, the consent box unchecked again (tick it again) and the identity kept.
`FallbackWaitSeconds` is validated at 5 to under 300, since a challenge lives 300 seconds; `Captcha:Difficulty` at 1 to 24.
- Fallback submissions have their own, stricter per-address budget (`Limits:FallbackPermits`, default 3 per
`FallbackWindow`, default one hour; over it is 429 with Retry-After).
- A submission names a nonce or says it waited, never both.
- The page has a second submit button, always enabled, that abandons a solve in progress and submits on the time-lock, so
a slow device is never stuck behind the proof of work.
- `POST /auth/bifrost/register/form` refuses a request that did not come from the site itself (`RequestOriginGuard`: `Origin`
equal to the site origin, else `Sec-Fetch-Site: same-origin`), 403, before it reads the body or spends anything, so a
third-party page cannot register an account and replace a visitor's session. A request with neither header and no
session cookie is allowed, because text browsers send neither and are the people the fallback is for. The body is capped
at 16 KB (413) and the per-address budget is spent before it is read.
- Whether a name is taken is answerable without a proof, bounded by the per-address budget. Names are public (they are
chat localparts), so that leaks nothing.
Tradeoff, stated plainly. The time-lock is accessible to everyone who can use a browser: nothing to see, hear, read or
solve, no script, no third party. It is also weaker than the proof of work. It costs an attacker wall-clock time, not CPU,
and an attacker can start many challenges at once and wait once, so what bounds the fallback is the per-address budget
and nothing else. An attacker with many addresses passes it at the price of the wait. We accept that because a
registration buys little: the account is `Authenticated`, in Users, with no vote and no member surface, and the other
gates (consent, verification once built, reserved names, suspension) still apply. If fallback registrations are abused,
raise the wait or lower the budget; do not add a visual or audio puzzle, which excludes the people the fallback is for.
## Consent receipt
@ -65,22 +94,35 @@ decision (`admission-is-bifrost-membership`).
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.
consent step on the completion page and is the next cut. Existing accounts are not re-asked.
## Email verification
Not built. It cannot be built in Bifrost alone:
Not built. Decided (steward, 2026-11-11): Bifrost keeps a verified address, as sign-in recovery.
- 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.
Why: a native account has no credential and no sign-in route (`concept-mill-votes-target.md`, claim 12). A person who
loses the session cookie loses the account, and the chat localpart minted from it is permanent. An address that was
verified is the one way back, and the usual way (MAS, Matrix homeservers and most accounts hold one). Hashing it instead
cannot send mail, so it recovers nothing.
Failure it prevents: accounts for addresses nobody holds, and unrecoverable accounts. Owed: a mail relay from ops, and the
ruling on keeping the address.
Privacy handling, which the build must honour:
- Stored only for native accounts, one verified address per account, unique across accounts (a keyed hash of the
normalized address is the lookup; the address itself is encrypted with Data Protection, since sending needs it).
- Never in a token or claim (the issuer doc's claim table is unchanged), never in a log line, a dashboard, a Verse
document, a training export, or Heimdall. The account page shows it only to its owner.
- Used for two things: the verification mail and a single-use sign-in link (15 minutes, spent on use, per-address and
per-account send budgets). No notices, no newsletters, no sharing.
- The owner can remove it (then there is no recovery) or change it (a new verification first). Deleting the account
deletes it.
- Existing accounts and provider accounts are not asked.
The build cannot start until ops provides a transport: there is no mail sender anywhere in Bifrost, Heimdall or the chat
stack. Bifrost's side is an `IEmailSender` port with the relay's settings as secret files; the relay, its sending domain
and SPF/DKIM are ops infrastructure.
Shape: 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, the receipt and the stored address
together. An unverified account then cannot exist, so it cannot chat.
Failure it prevents: accounts for addresses nobody holds, and unrecoverable accounts.

View file

@ -7,7 +7,11 @@ public sealed class RegistrationOptions
public const int MinDifficulty = 1;
public const int MaxDifficulty = 30;
public const int MaxDifficulty = 24;
public const int MinFallbackWaitSeconds = 5;
public const int MaxFallbackWaitSeconds = 300;
public RegistrationCaptchaOptions Captcha { get; init; } = new();
@ -22,10 +26,18 @@ public sealed class RegistrationLimitsOptions
public TimeSpan Window { get; init; } = TimeSpan.FromMinutes(1);
public int MaxTrackedKeys { get; init; } = 10_000;
/// <summary>Time-lock submissions (no script, no proof of work) per address per <see cref="FallbackWindow"/>; far tighter than <see cref="PerAddressPermits"/> because they cost the client nothing.</summary>
public int FallbackPermits { get; init; } = 3;
public TimeSpan FallbackWindow { get; init; } = TimeSpan.FromHours(1);
}
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;
/// <summary>Seconds after the challenge was issued before a <c>waited</c> (no proof of work) submission is accepted.</summary>
public int FallbackWaitSeconds { get; init; } = 30;
}

View file

@ -1,29 +0,0 @@
using Bifrost.Web.Configuration;
using Microsoft.AspNetCore.Http;
namespace Bifrost.Web.Features.ConceptVotes;
// The one place that decides whether a state-changing request to the vote page came from the site itself. The anonymous
// vote flow carries no antiforgery token and no antiforgery cookie, so a cross-site form post is refused here instead
// (OWASP Fetch Metadata): the Origin header must equal the configured site origin byte for byte, or, when the browser
// sent no Origin, Sec-Fetch-Site must be exactly same-origin. Same-site, cross-site, none, absent and a repeated header
// are all refused. Header values are compared, never echoed.
public static class CastRequestGuard
{
public static bool Allows(HttpRequest request, BifrostHostOptions host)
{
if (!Uri.TryCreate(host.PublicBaseUrl, UriKind.Absolute, out var site))
{
return false;
}
var origin = request.Headers.Origin;
if (request.Headers.ContainsKey("Origin"))
{
return origin.Count == 1 && string.Equals(origin[0], site.GetLeftPart(UriPartial.Authority), StringComparison.Ordinal);
}
var fetchSite = request.Headers["Sec-Fetch-Site"];
return fetchSite.Count == 1 && string.Equals(fetchSite[0], "same-origin", StringComparison.Ordinal);
}
}

View file

@ -12,11 +12,11 @@ public sealed partial class BifrostIdentityService(
BifrostDbContext dbContext,
TimeProvider timeProvider)
{
public async Task<UserAccount> RegisterNativeAsync(
string identity,
string? displayName,
int noticeVersion,
CancellationToken cancellationToken)
/// <summary>
/// The one rule set for a new native identity: well formed, not reserved, not taken. Returns the normalized identity or
/// throws <see cref="BifrostIdentityException"/>. Spends nothing; the unique index at insert stays the race backstop.
/// </summary>
public async Task<string> CheckAvailableAsync(string identity, CancellationToken cancellationToken)
{
var normalizedIdentity = NormalizeIdentity(identity);
if (IsReserved(normalizedIdentity))
@ -24,14 +24,22 @@ public sealed partial class BifrostIdentityService(
throw new BifrostIdentityException("That Bifrost identity is reserved.");
}
var exists = await dbContext.UserAccounts.AnyAsync(
x => x.NormalizedBifrostIdentity == normalizedIdentity,
cancellationToken);
if (exists)
if (await dbContext.UserAccounts.AnyAsync(x => x.NormalizedBifrostIdentity == normalizedIdentity, cancellationToken))
{
throw new BifrostIdentityException("That Bifrost identity is already registered.");
}
return normalizedIdentity;
}
public async Task<UserAccount> RegisterNativeAsync(
string identity,
string? displayName,
int noticeVersion,
CancellationToken cancellationToken)
{
var normalizedIdentity = await CheckAvailableAsync(identity, cancellationToken);
var now = timeProvider.GetUtcNow();
var publicIdentity = ToPublicIdentity(normalizedIdentity);
var resolvedDisplayName = string.IsNullOrWhiteSpace(displayName)
@ -66,10 +74,31 @@ public sealed partial class BifrostIdentityService(
NoticeVersion = noticeVersion,
AcceptedAtUtc = now
});
await dbContext.SaveChangesAsync(cancellationToken);
try
{
await dbContext.SaveChangesAsync(cancellationToken);
}
catch (DbUpdateException)
{
// Only a lost race on the unique index is the caller's mistake; anything else is a fault and propagates.
if (!await IdentityTakenAsync(normalizedIdentity))
{
throw;
}
throw new BifrostIdentityException("That Bifrost identity is already registered.");
}
return userAccount;
}
// A lost race on the unique index: the tracked insert is dropped and the identity is read fresh.
private async Task<bool> IdentityTakenAsync(string normalizedIdentity)
{
dbContext.ChangeTracker.Clear();
return await dbContext.UserAccounts.AnyAsync(x => x.NormalizedBifrostIdentity == normalizedIdentity);
}
public async Task EnsureIdentityAsync(
UserAccount userAccount,
string preferredIdentity,

View file

@ -0,0 +1,123 @@
using Bifrost.Web.Domain;
using Bifrost.Web.Features.Membership;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
namespace Bifrost.Web.Features.Registration;
/// <summary>What a client sent to register. <see cref="Waited"/> claims the time-lock path instead of a proof of work.</summary>
public sealed record RegistrationAttempt(
string? Identity,
string? DisplayName,
string? Challenge,
string? Nonce,
bool Waited,
int? AcceptedNoticeVersion);
/// <summary>Which input a refusal is about, so the form can mark it invalid.</summary>
public enum RegistrationField { None, Identity, Consent }
/// <summary>
/// Either a signed-in <see cref="Account"/>, or a <see cref="Refusal"/> to return as is (the rate limits), or a rejection
/// with a status and message. <see cref="KeepChallenge"/> is set when the challenge was not spent and a retry needs only a correction, so the form should carry it on (the time-lock clock must not restart).
/// </summary>
public sealed record RegistrationOutcome(
UserAccount? Account,
IResult? Refusal,
int Status,
string? Message,
RegistrationField Field,
bool KeepChallenge)
{
public static RegistrationOutcome Reject(int status, string message, RegistrationField field = RegistrationField.None, bool keepChallenge = false) =>
new(null, null, status, message, field, keepChallenge);
}
/// <summary>
/// The one registration gate, run by the JSON endpoint and the form alike after each has spent the general address budget.
/// Order: consent, one proof only, the identity rules (which spend nothing), the challenge (proof of work, or the
/// time-lock plus its tighter budget), then the account and receipt in one save, then the cookie. A refusal at any step
/// creates nothing, and a refusal before the challenge step spends neither the challenge nor a fallback permit.
/// </summary>
public sealed class NativeRegistration(
BifrostIdentityService identities,
RegistrationChallengeService challenges,
RegistrationRateLimit rateLimit)
{
public async Task<RegistrationOutcome> RunAsync(HttpContext http, RegistrationAttempt attempt, CancellationToken cancellationToken)
{
// Consent first, then the proof; a refusal here creates nothing.
if (attempt.AcceptedNoticeVersion != TrainingNotice.Version)
{
return RegistrationOutcome.Reject(
StatusCodes.Status400BadRequest,
$"Registration requires accepting the training notice, current version {TrainingNotice.Version}.",
RegistrationField.Consent);
}
var hasNonce = !string.IsNullOrEmpty(attempt.Nonce);
if (hasNonce && attempt.Waited)
{
return RegistrationOutcome.Reject(StatusCodes.Status400BadRequest, "Send a nonce or waited, not both.");
}
try
{
await identities.CheckAvailableAsync(attempt.Identity ?? "", cancellationToken);
}
catch (BifrostIdentityException error)
{
return RegistrationOutcome.Reject(StatusCodes.Status400BadRequest, error.Message, RegistrationField.Identity, keepChallenge: true);
}
var failed = RegistrationOutcome.Reject(StatusCodes.Status400BadRequest, "Registration challenge failed.");
if (hasNonce)
{
if (!challenges.TryConsume(attempt.Challenge, attempt.Nonce))
{
return failed;
}
}
else if (attempt.Waited)
{
if (challenges.WaitRemaining(attempt.Challenge) is not { } remaining)
{
return failed;
}
if (remaining > TimeSpan.Zero)
{
var seconds = (int)Math.Ceiling(remaining.TotalSeconds);
return RegistrationOutcome.Reject(
StatusCodes.Status400BadRequest,
$"Too early: {seconds} more second{(seconds == 1 ? "" : "s")} to wait after the page opened. Your challenge is still good; submit again then.",
keepChallenge: true);
}
if (rateLimit.RefuseFallback(http) is { } fallbackRefused)
{
return new(null, fallbackRefused, 0, null, RegistrationField.None, false);
}
if (!challenges.TryConsumeWaited(attempt.Challenge))
{
return failed;
}
}
else
{
return failed;
}
try
{
var account = await identities.RegisterNativeAsync(attempt.Identity ?? "", attempt.DisplayName, TrainingNotice.Version, cancellationToken);
await http.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, identities.BuildPrincipal(account));
return new(account, null, StatusCodes.Status200OK, null, RegistrationField.None, false);
}
catch (BifrostIdentityException error)
{
return RegistrationOutcome.Reject(StatusCodes.Status400BadRequest, error.Message, RegistrationField.Identity);
}
}
}

View file

@ -50,58 +50,101 @@ public sealed class RegistrationChallengeService(
var expiresAt = now + Lifetime;
var difficulty = Difficulty;
var id = Convert.ToHexString(RandomNumberGenerator.GetBytes(16));
var challenge = protector.Protect($"{id}.{expiresAt.ToUnixTimeMilliseconds()}.{difficulty}");
var challenge = protector.Protect($"{id}.{expiresAt.ToUnixTimeMilliseconds()}.{difficulty}.{now.ToUnixTimeMilliseconds()}");
return new RegistrationChallenge(challenge, difficulty, expiresAt);
}
/// <summary>Proof-of-work path: the nonce must meet the difficulty the challenge was issued with. Spends the challenge.</summary>
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)
if (!IsNonce(nonce) || Open(challenge) is not { } open)
{
return false;
}
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(challenge + ":" + nonce));
if (LeadingZeroBits(hash) < difficulty)
return LeadingZeroBits(hash) >= open.Difficulty && Spend(open);
}
/// <summary>
/// Time-lock path, step one: how long until a <c>waited</c> submission of this challenge is accepted. Null when the
/// token is not a live challenge of ours; zero when the wait is over. Spends nothing.
/// </summary>
public TimeSpan? WaitRemaining(string? challenge)
{
if (Open(challenge) is not { } open)
{
return null;
}
var remaining = open.IssuedAt + TimeSpan.FromSeconds(options.Value.Captcha.FallbackWaitSeconds) - clock.GetUtcNow();
return remaining > TimeSpan.Zero ? remaining : TimeSpan.Zero;
}
/// <summary>Time-lock path, step two: spends the challenge if its wait is over. No proof of work is checked.</summary>
public bool TryConsumeWaited(string? challenge) =>
WaitRemaining(challenge) == TimeSpan.Zero && Open(challenge) is { } open && Spend(open);
/// <summary>True when the token is ours, unexpired and not yet spent, so a form may carry it into its next attempt.</summary>
public bool IsLive(string? challenge)
{
if (Open(challenge) is not { } open)
{
return false;
}
lock (gate)
{
return !consumed.Contains(open.Id);
}
}
private sealed record OpenChallenge(string Id, int Difficulty, DateTimeOffset ExpiresAt, DateTimeOffset IssuedAt);
/// <summary>The payload of a challenge that we protected and that has not expired; null otherwise.</summary>
private OpenChallenge? Open(string? challenge)
{
if (string.IsNullOrEmpty(challenge))
{
return null;
}
OpenChallenge open;
try
{
var payload = protector.Unprotect(challenge).Split('.');
if (payload.Length != 4)
{
return null;
}
var culture = System.Globalization.CultureInfo.InvariantCulture;
open = new OpenChallenge(
payload[0],
int.Parse(payload[2], culture),
DateTimeOffset.FromUnixTimeMilliseconds(long.Parse(payload[1], culture)),
DateTimeOffset.FromUnixTimeMilliseconds(long.Parse(payload[3], culture)));
}
catch (Exception)
{
return null;
}
return clock.GetUtcNow() >= open.ExpiresAt ? null : open;
}
private bool Spend(OpenChallenge open)
{
var now = clock.GetUtcNow();
lock (gate)
{
Purge(now);
if (consumed.Count >= MaxConsumed || !consumed.Add(id))
if (consumed.Count >= MaxConsumed || !consumed.Add(open.Id))
{
return false;
}
expiries.Enqueue(id, expiresAt);
expiries.Enqueue(open.Id, open.ExpiresAt);
return true;
}
}

View file

@ -0,0 +1,176 @@
using System.Net;
using System.Text;
namespace Bifrost.Web.Features.Registration;
/// <summary>What the sign-up page shows. Everything user-supplied is HTML-encoded on the way out.</summary>
public sealed record RegistrationPageModel(
string ReturnPath,
string Challenge,
int Difficulty,
int WaitSeconds,
string Identity = "",
string DisplayName = "",
string? Error = null,
RegistrationField ErrorField = RegistrationField.None);
/// <summary>
/// The server-rendered sign-up form. It works with no script (time-lock path); the inline script, when it can, replaces the
/// wait by solving the proof of work. No external asset, no cookie, no third party.
/// </summary>
public static class RegistrationPage
{
// Hash rule is RegistrationChallengeService's: SHA-256 of challenge + ":" + nonce, leading zero bits >= difficulty.
// Hashes run in batches that yield to the UI between them. If the browser has no crypto.subtle, nothing here runs
// and the form submits on the time-lock path.
public const string Script = """
(function () {
var form = document.getElementById("register");
var status = document.getElementById("status");
if (!form || !status || !window.crypto || !window.crypto.subtle || !window.TextEncoder) { return; }
var BATCH = 256;
var busy = false;
var solved = false;
var skipped = false;
function zeroBits(bytes) {
var bits = 0;
for (var i = 0; i < bytes.length; i++) {
if (bytes[i] === 0) { bits += 8; continue; }
return bits + Math.clz32(bytes[i]) - 24;
}
return bits;
}
async function solve(challenge, difficulty) {
var encoder = new TextEncoder();
for (var start = 0; !skipped; start += BATCH) {
var tries = [];
for (var n = start; n < start + BATCH; n++) {
tries.push(crypto.subtle.digest("SHA-256", encoder.encode(challenge + ":" + n)));
}
var hashes = await Promise.all(tries);
for (var i = 0; i < hashes.length; i++) {
if (zeroBits(new Uint8Array(hashes[i])) >= difficulty) { return String(start + i); }
}
await new Promise(function (resolve) { setTimeout(resolve, 0); });
}
return null;
}
form.addEventListener("submit", function (event) {
if (event.submitter && event.submitter.id === "skip") {
// The way out of a slow solve: stop it and let this submit go on the time-lock path.
skipped = true;
solved = true;
form.elements.nonce.value = "";
form.elements.waited.disabled = false;
status.textContent = "Skipping the automatic check.";
return;
}
if (solved) { return; }
event.preventDefault();
if (busy) { return; }
busy = true;
status.textContent = "Checking. This can take longer on a slow device; press Skip the automatic check to wait " + form.getAttribute("data-wait") + " seconds instead.";
solve(form.elements.challenge.value, parseInt(form.getAttribute("data-difficulty"), 10)).then(function (nonce) {
if (skipped || nonce === null) { return; }
form.elements.nonce.value = nonce;
form.elements.waited.disabled = true;
solved = true;
status.textContent = "Creating your account";
form.submit();
}, function () {
solved = true;
busy = false;
status.textContent = "The automatic check did not run in this browser. Press Skip the automatic check and wait as described below.";
});
});
})();
""";
public static string Render(RegistrationPageModel model)
{
static string E(string? text) => WebUtility.HtmlEncode(text ?? "");
var notice = TrainingNotice.Version.ToString(System.Globalization.CultureInfo.InvariantCulture);
var summary = new StringBuilder();
if (model.Error is not null)
{
var link = model.ErrorField switch
{
RegistrationField.Identity => """<ul><li><a href="#identity">Identity name</a></li></ul>""",
RegistrationField.Consent => """<ul><li><a href="#consent">I have read this notice and consent</a></li></ul>""",
_ => ""
};
summary.Append($"""<div id="errors" role="alert" tabindex="-1"><strong>Registration did not go through.</strong><p>{E(model.Error)}</p>{link}</div>""");
}
string Invalid(RegistrationField field) => model.Error is not null && model.ErrorField == field ? " aria-invalid=\"true\" aria-describedby=\"errors\"" : "";
var wait = model.WaitSeconds.ToString(System.Globalization.CultureInfo.InvariantCulture);
return $$"""
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Create an account - Bifrost</title>
<style>
body { margin: 0; min-height: 100svh; display: grid; place-items: center; background: #090908; color: #fff8ef; font-family: system-ui, sans-serif; line-height: 1.5; }
main { width: min(92vw, 600px); margin: 24px 0; padding: 28px; border: 1px solid rgba(255,248,239,.16); border-radius: 8px; background: #141110; }
h1 { margin: 0 0 10px; font-size: 2rem; line-height: 1.1; }
p { color: #c7b8ad; }
fieldset { margin: 20px 0 0; padding: 14px 16px; border: 1px solid #8a7d73; border-radius: 6px; }
legend { padding: 0 6px; color: #d9ad66; font-weight: 750; }
label { display: block; margin-top: 12px; font-weight: 650; }
.hint { margin: 2px 0 0; font-size: .9rem; }
input[type=text] { box-sizing: border-box; width: 100%; min-height: 44px; margin-top: 4px; padding: 0 10px; border: 1px solid #8a7d73; border-radius: 6px; background: #0d0b0a; color: #fff8ef; font: inherit; }
input[type=checkbox] { width: 22px; height: 22px; margin: 0 8px 0 0; vertical-align: middle; accent-color: #c43e4f; }
label.check { display: flex; align-items: center; margin-top: 14px; }
button.secondary { margin-left: 10px; background: #2a2522; border: 1px solid #8a7d73; }
button { margin-top: 20px; min-height: 46px; padding: 0 18px; border: 0; border-radius: 6px; background: #c43e4f; color: #fff; font: inherit; font-weight: 750; cursor: pointer; }
:focus-visible { outline: 3px solid #ffd27a; outline-offset: 2px; }
#errors { margin-top: 18px; padding: 10px 14px; border: 2px solid #ff9aa6; border-radius: 6px; color: #ffc2c9; }
#errors p { margin: 4px 0 0; color: #ffc2c9; }
[aria-invalid=true] { border-color: #ff9aa6; }
#status { min-height: 1.5em; color: #fff8ef; }
small { color: #d9ad66; }
</style>
</head>
<body>
<main>
<small>Public registration</small>
<h1>Create a Bifrost account</h1>
<p>A new account can chat. It has no vote and opens no member or governance surface.</p>
{{summary}}
<form id="register" method="post" action="/auth/bifrost/register/form" data-difficulty="{{model.Difficulty}}" data-wait="{{wait}}">
<fieldset>
<legend>Your account</legend>
<label for="identity">Identity name</label>
<input type="text" id="identity" name="identity" value="{{E(model.Identity)}}" required autocomplete="username" autocapitalize="none" spellcheck="false"{{Invalid(RegistrationField.Identity)}} />
<label for="displayName">Display name (optional)</label>
<input type="text" id="displayName" name="displayName" value="{{E(model.DisplayName)}}" autocomplete="nickname" />
</fieldset>
<fieldset>
<legend>Training notice, version {{notice}}</legend>
<p id="notice">{{E(TrainingNotice.Text)}}</p>
<label class="check" for="consent"><input type="checkbox" id="consent" name="consent" value="yes" required aria-describedby="notice"{{Invalid(RegistrationField.Consent)}} />I have read this notice and consent</label>
<input type="hidden" name="acceptedNoticeVersion" value="{{notice}}" />
</fieldset>
<input type="hidden" name="challenge" value="{{E(model.Challenge)}}" />
<input type="hidden" name="returnUrl" value="{{E(model.ReturnPath)}}" />
<input type="hidden" name="nonce" value="" />
<input type="hidden" name="waited" value="true" />
<p id="status" aria-live="polite"></p>
<button type="submit" id="create">Create account</button>
<button type="submit" id="skip" class="secondary">Skip the automatic check and wait {{wait}} seconds instead</button>
</form>
<p class="hint" id="wait-note">Without a script, or if the automatic check is slow, wait {{wait}} seconds after the page opened and press the button to skip it (or Create account when there is no script). Pressed sooner, the page comes back saying how many seconds remain and keeps your names; the consent box comes back unchecked, so tick the box again before you submit. Without the automatic check only a few registrations per hour are accepted from one address.</p>
<p><small>After registering, Bifrost returns you to {{E(model.ReturnPath)}}.</small></p>
</main>
<script>{{Script}}</script>
</body>
</html>
""";
}
}

View file

@ -17,18 +17,31 @@ public sealed class RegistrationRateLimit(IOptions<RegistrationOptions> options,
options.Value.Limits.MaxTrackedKeys,
clock);
// Time-lock submissions cost the client no work, so they get their own, much tighter budget per address.
private readonly FixedWindowBudget fallbacks = new(
options.Value.Limits.FallbackPermits,
options.Value.Limits.FallbackWindow,
options.Value.Limits.MaxTrackedKeys,
clock);
/// <summary>Null when the request may proceed; otherwise the refusal to return.</summary>
public IResult? Refuse(HttpContext http)
public IResult? Refuse(HttpContext http) => Refuse(http, addresses, "Too many registration requests from this address.");
/// <summary>The stricter budget for a submission that passed the time-lock instead of the proof of work.</summary>
public IResult? RefuseFallback(HttpContext http) =>
Refuse(http, fallbacks, "Too many registrations without the quick check from this address; try again later or let the page run its script.");
private IResult? Refuse(HttpContext http, FixedWindowBudget budget, string message)
{
try
{
if (addresses.Spend(ClientAddress.BudgetKey(http.Connection.RemoteIpAddress)) is not { } denial)
if (budget.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);
return Results.Text(message, statusCode: StatusCodes.Status429TooManyRequests);
}
catch (Exception exception) when (exception is not OperationCanceledException)
{

View file

@ -0,0 +1,41 @@
using Bifrost.Web.Configuration;
using Microsoft.AspNetCore.Http;
namespace Bifrost.Web.Features.Shared;
// The one place that decides whether a state-changing request to an anonymous form (the vote page, the sign-up form) came from
// the site itself. These flows carry no antiforgery token and no antiforgery cookie, so a cross-site form post is refused here instead
// (OWASP Fetch Metadata): the Origin header must equal the configured site origin byte for byte, or, when the browser
// sent no Origin, Sec-Fetch-Site must be exactly same-origin. Same-site, cross-site, none, absent and a repeated header
// are all refused. Header values are compared, never echoed.
public static class RequestOriginGuard
{
public static bool Allows(HttpRequest request, BifrostHostOptions host)
{
if (!Uri.TryCreate(host.PublicBaseUrl, UriKind.Absolute, out var site))
{
return false;
}
var origin = request.Headers.Origin;
if (request.Headers.ContainsKey("Origin"))
{
return origin.Count == 1 && string.Equals(origin[0], site.GetLeftPart(UriPartial.Authority), StringComparison.Ordinal);
}
var fetchSite = request.Headers["Sec-Fetch-Site"];
return fetchSite.Count == 1 && string.Equals(fetchSite[0], "same-origin", StringComparison.Ordinal);
}
/// <summary>
/// For the no-script sign-up form only. Text browsers (Lynx, w3m, Links) send neither Origin nor Sec-Fetch-Site, so a
/// request with neither is let through when it carries no Bifrost session cookie: there is no session to replace.
/// Any Origin or Sec-Fetch-Site present is judged exactly as <see cref="Allows"/> judges it, and a headerless request
/// that does carry a session cookie is refused.
/// </summary>
public static bool AllowsFormWithoutFetchMetadata(HttpRequest request, BifrostHostOptions host) =>
Allows(request, host) ||
(!request.Headers.ContainsKey("Origin") &&
!request.Headers.ContainsKey("Sec-Fetch-Site") &&
!request.Cookies.Keys.Any(name => name.StartsWith(BifrostCookies.Session, StringComparison.Ordinal)));
}

View file

@ -1,5 +1,6 @@
using Bifrost.Web.Configuration;
using Bifrost.Web.Features.ConceptVotes;
using Bifrost.Web.Features.Registration;
using Microsoft.Extensions.Options;
namespace Bifrost.Web.Features.Shared;
@ -55,12 +56,24 @@ public sealed class StartupConfigurationValidator(
failures.Add($"Bifrost:Registration:Captcha:Difficulty must be between {RegistrationOptions.MinDifficulty} and {RegistrationOptions.MaxDifficulty}.");
}
var wait = registrationOptions.Value.Captcha.FallbackWaitSeconds;
if (wait < RegistrationOptions.MinFallbackWaitSeconds || wait > RegistrationOptions.MaxFallbackWaitSeconds ||
wait >= RegistrationChallengeService.Lifetime.TotalSeconds)
{
failures.Add($"Bifrost:Registration:Captcha:FallbackWaitSeconds must be between {RegistrationOptions.MinFallbackWaitSeconds} and {RegistrationOptions.MaxFallbackWaitSeconds} and shorter than the challenge lifetime ({RegistrationChallengeService.Lifetime.TotalSeconds:0} seconds).");
}
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 (limits.FallbackPermits < 1 || limits.FallbackWindow <= TimeSpan.Zero)
{
failures.Add("Bifrost:Registration:Limits:FallbackPermits must be at least 1 and FallbackWindow must be positive.");
}
if (hostEnvironment.IsDevelopment() || hostEnvironment.IsEnvironment("Testing"))
{
Refuse(failures);

View file

@ -29,7 +29,7 @@ public sealed class VoteModel(
// The single gate before any handler runs. A request that selects no handler (a POST with no or an unknown handler,
// any other method) is a client error, never a view over a null subject. A cast is accepted only from the site's own
// origin (CastRequestGuard); no antiforgery cookie or token exists on this flow.
// origin (RequestOriginGuard); no antiforgery cookie or token exists on this flow.
public override async Task OnPageHandlerExecutionAsync(PageHandlerExecutingContext context, PageHandlerExecutionDelegate next)
{
var method = context.HttpContext.Request.Method;
@ -50,7 +50,7 @@ public sealed class VoteModel(
return;
}
if (HttpMethods.IsPost(method) && !CastRequestGuard.Allows(context.HttpContext.Request, host.Value))
if (HttpMethods.IsPost(method) && !RequestOriginGuard.Allows(context.HttpContext.Request, host.Value))
{
context.Result = new StatusCodeResult(StatusCodes.Status403Forbidden);
return;

View file

@ -135,6 +135,7 @@ builder.Services.AddSingleton<MindIntakeRateLimit>(); // TimeProvider and option
builder.Services.AddOptions<RegistrationOptions>().BindConfiguration(RegistrationOptions.SectionName);
builder.Services.AddSingleton<RegistrationChallengeService>();
builder.Services.AddSingleton<RegistrationRateLimit>();
builder.Services.AddScoped<NativeRegistration>();
builder.Services.AddHostedService<SealedBallotCloser>();
builder.Services.AddScoped<MotionEveSurfaceService>();
builder.Services.AddScoped<PatronageService>();
@ -389,8 +390,7 @@ app.MapGet("/auth/sign-in", async (HttpContext httpContext, IOptions<GitHubOAuth
app.MapPost("/auth/bifrost/register", async (
NativeBifrostRegistrationRequest request,
HttpContext httpContext,
BifrostIdentityService bifrostIdentityService,
RegistrationChallengeService challenges,
NativeRegistration registration,
RegistrationRateLimit rateLimit,
CancellationToken cancellationToken) =>
{
@ -399,44 +399,142 @@ app.MapPost("/auth/bifrost/register", async (
return refused;
}
// Consent first, then the proof of work; a refusal here creates nothing.
if (request.AcceptedNoticeVersion != TrainingNotice.Version)
var outcome = await registration.RunAsync(
httpContext,
new RegistrationAttempt(request.Identity, request.DisplayName, request.Challenge, request.Nonce, request.Waited == true, request.AcceptedNoticeVersion),
cancellationToken);
if (outcome.Refusal is not null)
{
return Results.Text(
$"Registration requires accepting the training notice, current version {TrainingNotice.Version}.",
statusCode: StatusCodes.Status400BadRequest);
return outcome.Refusal;
}
if (!challenges.TryConsume(request.Challenge, request.Nonce))
if (outcome.Account is not { } userAccount)
{
return Results.Text("Registration challenge failed.", statusCode: StatusCodes.Status400BadRequest);
return Results.Text(outcome.Message!, statusCode: outcome.Status);
}
var returnUrl = string.IsNullOrWhiteSpace(request.ReturnUrl) ? "/App" : request.ReturnUrl;
return JsonResult(
new NativeBifrostRegistrationResponse(
userAccount.Id,
userAccount.BifrostIdentity,
userAccount.DisplayName,
userAccount.Membership?.Status.ToString() ?? MembershipStatus.Authenticated.ToString(),
returnUrl),
appJsonSerializerOptions);
}).AllowAnonymous();
// GET issues a challenge, so it spends the same per-address budget as /auth/bifrost/challenge.
app.MapGet("/auth/bifrost/register", (
HttpContext httpContext,
RegistrationChallengeService challenges,
RegistrationRateLimit rateLimit,
IOptions<RegistrationOptions> registrationOptions) =>
{
if (rateLimit.Refuse(httpContext) is { } refused)
{
return refused;
}
var returnPath = LocalReturnPath.Parse(httpContext.Request.Query["returnUrl"].ToString());
return RegistrationFormResult(httpContext, challenges, registrationOptions.Value, new RegistrationPageModel(returnPath.Value, "", 0, registrationOptions.Value.Captcha.FallbackWaitSeconds), null, StatusCodes.Status200OK);
}).AllowAnonymous();
// No antiforgery token or cookie exists for an anonymous visitor, so a cross-site post is refused by origin instead
// (RequestOriginGuard, the same Fetch Metadata check as the vote page, except that a request with no Origin and no
// Sec-Fetch-Site and no session cookie passes: text browsers send neither, and there is no session to replace). Without it a foreign page could post here with
// a challenge of its own and replace the visitor's session cookie with an empty account holding a forged consent.
// Order: origin, then the general address budget, then the body cap, then the form; a refusal sets no cookie.
app.MapPost("/auth/bifrost/register/form", async (
HttpContext httpContext,
NativeRegistration registration,
RegistrationChallengeService challenges,
RegistrationRateLimit rateLimit,
IOptions<BifrostHostOptions> hostOptions,
IOptions<RegistrationOptions> registrationOptions,
CancellationToken cancellationToken) =>
{
if (!RequestOriginGuard.AllowsFormWithoutFetchMetadata(httpContext.Request, hostOptions.Value))
{
return Results.Text("Cross-site registration is refused.", statusCode: StatusCodes.Status403Forbidden);
}
if (rateLimit.Refuse(httpContext) is { } refused)
{
return refused;
}
if (!(httpContext.Request.ContentType ?? "").StartsWith("application/x-www-form-urlencoded", StringComparison.OrdinalIgnoreCase))
{
return Results.Text("Send the form as application/x-www-form-urlencoded.", statusCode: StatusCodes.Status415UnsupportedMediaType);
}
// The form is a handful of short fields: refuse a big body before reading any of it.
const int MaxFormBytes = 16 * 1024;
if (httpContext.Request.ContentLength > MaxFormBytes)
{
return Results.Text("The form is too large.", statusCode: StatusCodes.Status413PayloadTooLarge);
}
if (httpContext.Features.Get<Microsoft.AspNetCore.Http.Features.IHttpMaxRequestBodySizeFeature>() is { IsReadOnly: false } limit)
{
limit.MaxRequestBodySize = MaxFormBytes;
}
IFormCollection form;
try
{
var userAccount = await bifrostIdentityService.RegisterNativeAsync(
request.Identity,
request.DisplayName,
TrainingNotice.Version,
cancellationToken);
await httpContext.SignInAsync(
CookieAuthenticationDefaults.AuthenticationScheme,
bifrostIdentityService.BuildPrincipal(userAccount));
var returnUrl = string.IsNullOrWhiteSpace(request.ReturnUrl) ? "/App" : request.ReturnUrl;
return JsonResult(
new NativeBifrostRegistrationResponse(
userAccount.Id,
userAccount.BifrostIdentity,
userAccount.DisplayName,
userAccount.Membership?.Status.ToString() ?? MembershipStatus.Authenticated.ToString(),
returnUrl),
appJsonSerializerOptions);
form = await httpContext.Request.ReadFormAsync(cancellationToken);
}
catch (BifrostIdentityException error)
catch (BadHttpRequestException error) when (error.StatusCode == StatusCodes.Status413PayloadTooLarge)
{
return Results.Text(error.Message, statusCode: StatusCodes.Status400BadRequest);
return Results.Text("The form is too large.", statusCode: StatusCodes.Status413PayloadTooLarge);
}
catch (Exception error) when (error is InvalidDataException or BadHttpRequestException)
{
return Results.Text("The form could not be read.", statusCode: StatusCodes.Status400BadRequest);
}
string Field(string name) => form[name].FirstOrDefault() ?? "";
var returnPath = LocalReturnPath.Parse(Field("returnUrl"));
// The checkbox is the consent act: without it the posted notice version is not taken as accepted.
int? accepted = Field("consent") == "yes" && int.TryParse(Field("acceptedNoticeVersion"), System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out var version)
? version
: null;
var identity = Field("identity");
var displayName = Field("displayName");
var challenge = Field("challenge");
var outcome = await registration.RunAsync(
httpContext,
new RegistrationAttempt(identity, displayName, challenge, Field("nonce"), Field("waited") == "true", accepted),
cancellationToken);
if (outcome.Refusal is not null)
{
return outcome.Refusal;
}
if (outcome.Account is not null)
{
// A Location header takes ASCII only; anything else is not a path worth keeping.
var target = returnPath.Value.All(c => c > ' ' && c < '\u007f') ? returnPath.Value : LocalReturnPath.Default.Value;
httpContext.Response.Headers.CacheControl = "no-store";
httpContext.Response.Headers.Location = target;
return Results.StatusCode(StatusCodes.Status303SeeOther);
}
// A refusal that spent nothing and needs only a correction (a taken identity, a too-early time-lock) keeps its
// challenge, whose clock must not restart. Any other failure gets a fresh challenge. The consent box always comes back unchecked.
var options = registrationOptions.Value;
return RegistrationFormResult(
httpContext,
challenges,
options,
new RegistrationPageModel(returnPath.Value, "", 0, options.Captcha.FallbackWaitSeconds, Truncate(identity), Truncate(displayName), outcome.Message, outcome.Field),
outcome.KeepChallenge && challenges.IsLive(challenge) ? challenge : null,
outcome.Status);
static string Truncate(string text) => text.Length <= 256 ? text : text[..256];
}).AllowAnonymous();
app.MapGet("/auth/bifrost/challenge", (HttpContext httpContext, RegistrationChallengeService challenges, RegistrationRateLimit rateLimit) =>
@ -814,6 +912,36 @@ static async Task<IFormCollection?> ReadFormOrNullAsync(HttpRequest request, Can
}
}
// Renders the sign-up page with a challenge: the kept one when given, else a fresh one (null when the issuing set is full).
static IResult RegistrationFormResult(
HttpContext httpContext,
RegistrationChallengeService challenges,
RegistrationOptions options,
RegistrationPageModel model,
string? keepChallenge,
int statusCode)
{
var token = keepChallenge;
var difficulty = options.Captcha.Difficulty;
if (token is null)
{
var issued = challenges.Issue();
if (issued is null)
{
return Results.Text("Registration is busy; try again shortly.", statusCode: StatusCodes.Status503ServiceUnavailable);
}
token = issued.Challenge;
difficulty = issued.Difficulty;
}
httpContext.Response.Headers.CacheControl = "no-store";
return Results.Content(
RegistrationPage.Render(model with { Challenge = token, Difficulty = difficulty }),
"text/html; charset=utf-8",
statusCode: statusCode);
}
static async Task WriteSignInChooserAsync(HttpContext httpContext, string returnUrl)
{
httpContext.Response.ContentType = "text/html; charset=utf-8";
@ -870,7 +998,8 @@ public sealed record NativeBifrostRegistrationRequest(
string? ReturnUrl,
string? Challenge,
string? Nonce,
int? AcceptedNoticeVersion);
int? AcceptedNoticeVersion,
bool? Waited = null);
public sealed record NativeBifrostRegistrationResponse(
Guid UserAccountId,

View file

@ -0,0 +1,64 @@
// Runs the sign-up page's real inline script against a simulated browser and prints what it did as JSON.
// Usage: node registration-page-harness.cjs <page.html>
const fs = require("fs");
const vm = require("vm");
const { webcrypto } = require("crypto");
const html = fs.readFileSync(process.argv[2], "utf8");
const scripts = [...html.matchAll(/<script>([\s\S]*?)<\/script>/g)];
if (scripts.length !== 1) {
throw new Error(`expected exactly one inline script, found ${scripts.length}`);
}
const script = scripts[0][1];
const challenge = html.match(/<input type="hidden" name="challenge" value="([^"]*)"/)[1];
const difficulty = html.match(/data-difficulty="(\d+)"/)[1];
async function run(name, { withCrypto, submits, skipAfter = false }) {
const statuses = [];
let handler = null;
let submitted = 0;
let sawSubmit;
const done = new Promise((resolve) => (sawSubmit = resolve));
const form = {
elements: { challenge: { value: challenge }, nonce: { value: "" }, waited: { disabled: false } },
getAttribute: (n) => (n === "data-difficulty" ? difficulty : n === "data-wait" ? "30" : null),
addEventListener: (type, fn) => {
if (type === "submit") handler = fn;
},
submit() {
submitted++;
sawSubmit();
},
};
const status = {
set textContent(v) {
statuses.push(v);
},
};
const document = { getElementById: (id) => (id === "register" ? form : id === "status" ? status : null) };
const window = withCrypto ? { crypto: webcrypto, TextEncoder } : {};
vm.runInNewContext(script, { window, document, crypto: webcrypto, TextEncoder, Uint8Array, Promise, setTimeout, Math, parseInt, String });
if (!handler) return { name, listening: false, statuses, submitted, nonce: "", waitedDisabled: false, prevented: [], challenge, difficulty };
const prevented = [];
for (let i = 0; i < submits; i++) handler({ preventDefault: () => prevented.push(true) });
if (skipAfter) {
// The skip button pressed while the solve is in flight: the script must let this submit through and drop the solve.
const skipPrevented = [];
handler({ preventDefault: () => skipPrevented.push(true), submitter: { id: "skip" } });
await new Promise((r) => setTimeout(r, 200));
return { name, listening: true, statuses, submitted, nonce: form.elements.nonce.value, waitedDisabled: form.elements.waited.disabled, prevented: skipPrevented, challenge, difficulty };
}
await done;
await new Promise((r) => setTimeout(r, 20));
return { name, listening: true, statuses, submitted, nonce: form.elements.nonce.value, waitedDisabled: form.elements.waited.disabled, prevented, challenge, difficulty };
}
(async () => {
const out = [
await run("solves", { withCrypto: true, submits: 1 }),
await run("double-press", { withCrypto: true, submits: 2 }),
await run("skip-during-solve", { withCrypto: true, submits: 1, skipAfter: true }),
await run("no-subtle", { withCrypto: false, submits: 0 }),
];
console.log(JSON.stringify(out));
})();

View file

@ -0,0 +1,897 @@
using System.ComponentModel;
using System.Diagnostics;
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.RegularExpressions;
using Bifrost.Web.Configuration;
using Bifrost.Web.Data;
using Bifrost.Web.Features.ConceptVotes;
using Bifrost.Web.Features.Registration;
using Bifrost.Web.Features.Shared;
using Bifrost.Web.Tests.Support;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
namespace Bifrost.Web.Tests;
public class RegistrationFormFactory : TestWebApplicationFactory
{
public ManualClock Time { get; } = new();
protected virtual string FallbackPermits => "100";
protected override void ConfigureWebHost(Microsoft.AspNetCore.Hosting.IWebHostBuilder builder)
{
base.ConfigureWebHost(builder);
builder.ConfigureAppConfiguration((_, configuration) =>
configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
["Bifrost:Registration:Captcha:FallbackWaitSeconds"] = "30",
["Bifrost:Registration:Limits:FallbackPermits"] = FallbackPermits
}));
builder.ConfigureServices(services =>
{
services.RemoveAll<TimeProvider>();
services.AddSingleton<TimeProvider>(Time);
});
}
}
public sealed class RegistrationFallbackBudgetFactory : RegistrationFormFactory
{
protected override string FallbackPermits => "2";
}
internal static class FormKit
{
public static string Hidden(string html, string name)
{
var match = Regex.Match(html, $"<input type=\"hidden\" name=\"{name}\" value=\"([^\"]*)\"");
Assert.True(match.Success, $"no hidden field {name}");
return WebUtility.HtmlDecode(match.Groups[1].Value);
}
public static async Task<string> PageAsync(HttpClient client, string query = "")
{
using var response = await client.GetAsync("/auth/bifrost/register" + query);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
return await response.Content.ReadAsStringAsync();
}
public const string SiteOrigin = "https://localhost";
// A browser on the site's own page sends its Origin; the guard refuses a post without one.
public static Task<HttpResponseMessage> PostAsync(HttpClient client, Dictionary<string, string> fields, string? origin = SiteOrigin, string? peer = null)
{
var request = new HttpRequestMessage(HttpMethod.Post, "/auth/bifrost/register/form") { Content = new FormUrlEncodedContent(fields) };
if (origin is not null)
{
request.Headers.Add("Origin", origin);
}
if (peer is not null)
{
request.Headers.Add("X-Test-Peer", peer);
}
return client.SendAsync(request);
}
public static Dictionary<string, string> Fields(string html, string identity, bool consent = true, string? nonce = null, bool waited = false, string? returnUrl = null)
{
var fields = new Dictionary<string, string>
{
["identity"] = identity,
["displayName"] = "",
["challenge"] = Hidden(html, "challenge"),
["acceptedNoticeVersion"] = Hidden(html, "acceptedNoticeVersion"),
["returnUrl"] = returnUrl ?? Hidden(html, "returnUrl")
};
if (consent)
{
fields["consent"] = "yes";
}
if (nonce is not null)
{
fields["nonce"] = nonce;
}
if (waited)
{
fields["waited"] = "true";
}
return fields;
}
public static string Solved(string html) => RegistrationKit.Solve(Hidden(html, "challenge"), int.Parse(Regex.Match(html, "data-difficulty=\"(\\d+)\"").Groups[1].Value));
}
public sealed class RegistrationFormTests(RegistrationFormFactory factory) : IClassFixture<RegistrationFormFactory>
{
private static string NewIdentity() => $"form-{Guid.NewGuid():N}";
private HttpClient Client() => factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false });
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 bool Exists(string identity)
{
using var scope = factory.Services.CreateScope();
return scope.ServiceProvider.GetRequiredService<BifrostDbContext>().UserAccounts.Any(x => x.NormalizedBifrostIdentity == identity);
}
private (bool Account, bool Receipt) Created(string identity)
{
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
var account = db.UserAccounts.SingleOrDefault(x => x.NormalizedBifrostIdentity == identity);
return (account is not null, account is not null && db.ConsentReceipts.Any(x => x.UserAccountId == account.Id && x.NoticeVersion == TrainingNotice.Version));
}
[Fact]
public async Task The_page_shows_the_whole_notice_beside_an_unchecked_consent_and_touches_nothing_outside()
{
using var client = Client();
using var response = await client.GetAsync("/auth/bifrost/register");
var html = await response.Content.ReadAsStringAsync();
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.False(response.Headers.Contains("Set-Cookie"));
Assert.Contains(WebUtility.HtmlEncode(TrainingNotice.Text), html);
Assert.Contains($"version {TrainingNotice.Version}", html);
Assert.Contains("<html lang=\"en\">", html);
Assert.Matches("<input type=\"checkbox\" id=\"consent\" name=\"consent\" value=\"yes\" required [^>]*/>", html);
Assert.DoesNotMatch("<input type=\"checkbox\"[^>]*\\bchecked\\b", html);
Assert.Contains("I have read this notice and consent", html);
foreach (var id in new[] { "identity", "displayName", "consent" })
{
Assert.Matches($"<label[^>]*for=\"{id}\"", html);
}
Assert.Contains("<fieldset>", html);
Assert.Contains("<legend>", html);
Assert.Contains("aria-live=\"polite\"", html);
Assert.Equal(TrainingNotice.Version.ToString(), FormKit.Hidden(html, "acceptedNoticeVersion"));
Assert.False(string.IsNullOrWhiteSpace(FormKit.Hidden(html, "challenge")));
Assert.Matches("wait \\d+ seconds", html);
Assert.Matches("<button type=\"submit\" id=\"skip\"[^>]*>Skip the automatic check and wait \\d+ seconds instead</button>", html);
Assert.DoesNotContain("a few seconds", html);
Assert.DoesNotContain("nothing is lost", html);
Assert.Contains("tick the box again", html);
Assert.DoesNotContain("<noscript", html);
Assert.DoesNotContain("http://", html);
Assert.DoesNotContain("https://", html);
foreach (Match url in Regex.Matches(html, "(?:src|href|action)=\"([^\"]*)\""))
{
Assert.Matches("^(/(?!/)|#)", url.Groups[1].Value);
}
}
[Fact]
public async Task A_solved_form_creates_the_account_and_receipt_signs_in_and_redirects_to_the_return_path()
{
using var client = Client();
var html = await FormKit.PageAsync(client, "?returnUrl=%2FTasks%3Fx%3D1");
Assert.Equal("/Tasks?x=1", FormKit.Hidden(html, "returnUrl"));
var identity = NewIdentity();
using var response = await FormKit.PostAsync(client, FormKit.Fields(html, identity, nonce: FormKit.Solved(html)));
Assert.Equal(HttpStatusCode.SeeOther, response.StatusCode);
Assert.Equal("/Tasks?x=1", response.Headers.Location!.OriginalString);
Assert.True(response.Headers.Contains("Set-Cookie"));
Assert.Equal((true, true), Created(identity));
}
[Fact]
public async Task The_default_return_path_is_App()
{
using var client = Client();
var html = await FormKit.PageAsync(client);
using var response = await FormKit.PostAsync(client, FormKit.Fields(html, NewIdentity(), nonce: FormKit.Solved(html), returnUrl: ""));
Assert.Equal(HttpStatusCode.SeeOther, response.StatusCode);
Assert.Equal("/App", response.Headers.Location!.OriginalString);
}
[Theory]
[InlineData("//evil.example")]
[InlineData("https://evil.example/App")]
[InlineData("/\\evil.example")]
[InlineData("\\\\evil.example")]
public async Task A_return_path_that_leaves_the_site_lands_on_App(string probe)
{
using var client = Client();
var page = await FormKit.PageAsync(client, "?returnUrl=" + Uri.EscapeDataString(probe));
Assert.Equal("/App", FormKit.Hidden(page, "returnUrl"));
var html = await FormKit.PageAsync(client);
using var response = await FormKit.PostAsync(client, FormKit.Fields(html, NewIdentity(), nonce: FormKit.Solved(html), returnUrl: probe));
Assert.Equal(HttpStatusCode.SeeOther, response.StatusCode);
Assert.Equal("/App", response.Headers.Location!.OriginalString);
}
[Fact]
public async Task A_non_ASCII_return_path_never_reaches_the_Location_header()
{
using var client = Client();
var html = await FormKit.PageAsync(client);
using var response = await FormKit.PostAsync(client, FormKit.Fields(html, NewIdentity(), nonce: FormKit.Solved(html), returnUrl: "/Tasks\u00e9"));
Assert.Equal(HttpStatusCode.SeeOther, response.StatusCode);
Assert.Equal("/App", response.Headers.Location!.OriginalString);
}
[Fact]
public async Task Without_the_consent_box_the_page_returns_with_an_error_a_fresh_challenge_and_nothing_created()
{
using var client = Client();
var html = await FormKit.PageAsync(client);
var challenge = FormKit.Hidden(html, "challenge");
var identity = NewIdentity();
var before = Counts();
using var response = await FormKit.PostAsync(client, FormKit.Fields(html, identity, consent: false, nonce: FormKit.Solved(html)));
var again = await response.Content.ReadAsStringAsync();
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Matches("role=\"alert\"", again);
Assert.Contains("requires accepting the training notice", again);
Assert.Matches("id=\"consent\"[^>]*aria-invalid=\"true\" aria-describedby=\"errors\"", again);
Assert.Contains("<a href=\"#consent\">", again);
Assert.DoesNotMatch("<input type=\"checkbox\"[^>]*\\bchecked\\b", again);
Assert.Contains($"value=\"{identity}\"", again);
Assert.NotEqual(challenge, FormKit.Hidden(again, "challenge"));
Assert.DoesNotContain(challenge, again);
Assert.Equal(before, Counts());
}
[Fact]
public async Task A_wrong_nonce_fails_the_challenge_and_the_error_never_echoes_the_challenge_or_nonce()
{
using var client = Client();
var html = await FormKit.PageAsync(client);
var challenge = FormKit.Hidden(html, "challenge");
var identity = NewIdentity();
using var response = await FormKit.PostAsync(client, FormKit.Fields(html, identity, nonce: "7777777777"));
var again = await response.Content.ReadAsStringAsync();
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Contains("Registration challenge failed.", again);
Assert.DoesNotContain(challenge, again);
Assert.DoesNotContain("7777777777", again);
Assert.False(Exists(identity));
}
[Fact]
public async Task An_identity_with_markup_is_escaped_when_the_page_comes_back()
{
using var client = Client();
var html = await FormKit.PageAsync(client);
const string hostile = "<script>alert(1)</script>\"><img src=x>";
using var response = await FormKit.PostAsync(client, FormKit.Fields(html, hostile, consent: false));
var again = await response.Content.ReadAsStringAsync();
Assert.DoesNotContain("<script>alert(1)", again);
Assert.DoesNotContain("<img src=x>", again);
Assert.Contains("&lt;script&gt;alert(1)&lt;/script&gt;", again);
Assert.Single(Regex.Matches(again, "<script>"));
}
[Fact]
public async Task A_taken_identity_marks_the_identity_field_invalid()
{
using var client = Client();
var identity = NewIdentity();
var first = await FormKit.PageAsync(client);
using (await FormKit.PostAsync(client, FormKit.Fields(first, identity, nonce: FormKit.Solved(first)))) { }
var second = await FormKit.PageAsync(client);
using var response = await FormKit.PostAsync(client, FormKit.Fields(second, identity, nonce: FormKit.Solved(second)));
var again = await response.Content.ReadAsStringAsync();
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Contains("already registered", again);
Assert.Matches("id=\"identity\"[^>]*aria-invalid=\"true\" aria-describedby=\"errors\"", again);
Assert.Contains($"value=\"{identity}\"", again);
Assert.Contains("<a href=\"#identity\">", again);
Assert.Equal(FormKit.Hidden(second, "challenge"), FormKit.Hidden(again, "challenge"));
}
[Fact]
public async Task The_time_lock_refuses_before_the_wait_with_the_seconds_left_keeps_the_challenge_and_accepts_after()
{
using var client = Client();
var html = await FormKit.PageAsync(client);
var challenge = FormKit.Hidden(html, "challenge");
var identity = NewIdentity();
var before = Counts();
using var early = await FormKit.PostAsync(client, FormKit.Fields(html, identity, waited: true));
var earlyPage = await early.Content.ReadAsStringAsync();
Assert.Equal(HttpStatusCode.BadRequest, early.StatusCode);
Assert.Contains("30 more seconds", earlyPage);
Assert.Equal(challenge, FormKit.Hidden(earlyPage, "challenge"));
Assert.Equal(before, Counts());
factory.Time.Advance(TimeSpan.FromSeconds(10));
using var stillEarly = await FormKit.PostAsync(client, FormKit.Fields(earlyPage, identity, waited: true));
Assert.Contains("20 more seconds", await stillEarly.Content.ReadAsStringAsync());
Assert.Equal(before, Counts());
factory.Time.Advance(TimeSpan.FromSeconds(20));
using var ready = await FormKit.PostAsync(client, FormKit.Fields(earlyPage, identity, waited: true));
Assert.Equal(HttpStatusCode.SeeOther, ready.StatusCode);
Assert.Equal((true, true), Created(identity));
}
[Fact]
public async Task A_spent_time_lock_challenge_cannot_be_used_again_by_either_path()
{
using var client = Client();
var html = await FormKit.PageAsync(client);
factory.Time.Advance(TimeSpan.FromSeconds(31));
using (var first = await FormKit.PostAsync(client, FormKit.Fields(html, NewIdentity(), waited: true)))
{
Assert.Equal(HttpStatusCode.SeeOther, first.StatusCode);
}
var replayWaited = NewIdentity();
using var again = await FormKit.PostAsync(client, FormKit.Fields(html, replayWaited, waited: true));
var replayPow = NewIdentity();
using var viaWork = await FormKit.PostAsync(client, FormKit.Fields(html, replayPow, nonce: FormKit.Solved(html)));
Assert.Equal(HttpStatusCode.BadRequest, again.StatusCode);
Assert.Equal(HttpStatusCode.BadRequest, viaWork.StatusCode);
Assert.False(Exists(replayWaited));
Assert.False(Exists(replayPow));
}
[Fact]
public async Task A_nonce_together_with_waited_is_refused_and_spends_nothing()
{
using var client = Client();
var html = await FormKit.PageAsync(client);
var identity = NewIdentity();
factory.Time.Advance(TimeSpan.FromSeconds(31));
var nonce = FormKit.Solved(html);
using var both = await FormKit.PostAsync(client, FormKit.Fields(html, identity, nonce: nonce, waited: true));
Assert.Equal(HttpStatusCode.BadRequest, both.StatusCode);
Assert.Contains("not both", await both.Content.ReadAsStringAsync());
Assert.False(Exists(identity));
using var json = await RegistrationKit.PostAsync(client, new
{
identity,
challenge = FormKit.Hidden(html, "challenge"),
nonce,
waited = true,
acceptedNoticeVersion = TrainingNotice.Version
}, anonymous: true);
Assert.Equal(HttpStatusCode.BadRequest, json.StatusCode);
Assert.False(Exists(identity));
// The challenge was not spent by either refusal.
using var ok = await RegistrationKit.PostAsync(client, new
{
identity,
challenge = FormKit.Hidden(html, "challenge"),
nonce,
acceptedNoticeVersion = TrainingNotice.Version
}, anonymous: true);
Assert.Equal(HttpStatusCode.OK, ok.StatusCode);
}
[Fact]
public async Task The_JSON_endpoint_takes_waited_true_with_the_same_time_lock()
{
using var client = Client();
var issued = await client.GetFromJsonAsync<JsonElement>("/auth/bifrost/challenge");
var challenge = issued.GetProperty("challenge").GetString()!;
var identity = NewIdentity();
object Body() => new { identity, challenge, waited = true, acceptedNoticeVersion = TrainingNotice.Version };
using var early = await RegistrationKit.PostAsync(client, Body(), anonymous: true);
Assert.Equal(HttpStatusCode.BadRequest, early.StatusCode);
Assert.Contains("more second", await early.Content.ReadAsStringAsync());
Assert.False(Exists(identity));
factory.Time.Advance(TimeSpan.FromSeconds(30));
using var ready = await RegistrationKit.PostAsync(client, Body(), anonymous: true);
Assert.Equal(HttpStatusCode.OK, ready.StatusCode);
Assert.Equal((true, true), Created(identity));
}
[Fact]
public async Task A_tampered_challenge_fails_the_time_lock()
{
using var client = Client();
var html = await FormKit.PageAsync(client);
var challenge = FormKit.Hidden(html, "challenge");
var fields = FormKit.Fields(html, NewIdentity(), waited: true);
fields["challenge"] = challenge[..^2] + (challenge[^2] == 'A' ? 'B' : 'A') + challenge[^1];
factory.Time.Advance(TimeSpan.FromSeconds(31));
using var response = await FormKit.PostAsync(client, fields);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Contains("Registration challenge failed.", await response.Content.ReadAsStringAsync());
Assert.False(Exists(fields["identity"]));
}
[Fact]
public async Task A_form_must_be_urlencoded()
{
using var client = Client();
using var request = new HttpRequestMessage(HttpMethod.Post, "/auth/bifrost/register/form") { Content = JsonContent.Create(new { identity = "x" }) };
request.Headers.Add("Origin", FormKit.SiteOrigin);
using var response = await client.SendAsync(request);
Assert.Equal(HttpStatusCode.UnsupportedMediaType, response.StatusCode);
}
public static TheoryData<string?, string?> CrossSite => new()
{
{ "https://evil.example", null },
{ "https://evil.example", "same-origin" },
{ "null", null },
{ "https://localhost.evil.example", null },
{ null, "cross-site" },
{ null, "same-site" },
{ null, "none" }
};
[Theory]
[MemberData(nameof(CrossSite))]
public async Task A_cross_site_or_unattributed_post_is_refused_before_anything_is_read_or_spent(string? origin, string? fetchSite)
{
using var client = Client();
var html = await FormKit.PageAsync(client);
var identity = NewIdentity();
var before = Counts();
factory.Time.Advance(TimeSpan.FromSeconds(31));
var request = new HttpRequestMessage(HttpMethod.Post, "/auth/bifrost/register/form")
{
Content = new FormUrlEncodedContent(FormKit.Fields(html, identity, waited: true))
};
if (origin is not null)
{
request.Headers.Add("Origin", origin);
}
if (fetchSite is not null)
{
request.Headers.Add("Sec-Fetch-Site", fetchSite);
}
using var response = await client.SendAsync(request);
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
Assert.False(response.Headers.Contains("Set-Cookie"));
Assert.Equal(before, Counts());
// The challenge was not spent by the refusal.
using var ok = await FormKit.PostAsync(client, FormKit.Fields(html, identity, waited: true));
Assert.Equal(HttpStatusCode.SeeOther, ok.StatusCode);
}
private async Task<HttpResponseMessage> PostWithAsync(HttpClient client, string html, string identity, string? origin, string? fetchSite, string? cookie)
{
var request = new HttpRequestMessage(HttpMethod.Post, "/auth/bifrost/register/form")
{
Content = new FormUrlEncodedContent(FormKit.Fields(html, identity, nonce: FormKit.Solved(html)))
};
if (origin is not null) request.Headers.Add("Origin", origin);
if (fetchSite is not null) request.Headers.Add("Sec-Fetch-Site", fetchSite);
if (cookie is not null) request.Headers.Add("Cookie", cookie);
return await client.SendAsync(request);
}
[Fact]
public async Task A_text_browser_sending_neither_header_is_accepted_only_without_a_session_cookie()
{
const string session = "__Host-bifrost_session=abc";
using var client = Client();
var html = await FormKit.PageAsync(client);
var plain = NewIdentity();
using (var ok = await PostWithAsync(client, html, plain, null, null, null))
{
Assert.Equal(HttpStatusCode.SeeOther, ok.StatusCode);
Assert.Equal((true, true), Created(plain));
}
var second = await FormKit.PageAsync(client);
var withSession = NewIdentity();
using (var refused = await PostWithAsync(client, second, withSession, null, null, session))
{
Assert.Equal(HttpStatusCode.Forbidden, refused.StatusCode);
Assert.False(refused.Headers.Contains("Set-Cookie"));
Assert.False(Exists(withSession));
}
// Any header present is judged as before, cookie or not; same-origin with a session passes.
using (var crossNoCookie = await PostWithAsync(client, second, withSession, null, "cross-site", null))
{
Assert.Equal(HttpStatusCode.Forbidden, crossNoCookie.StatusCode);
}
using var sameOrigin = await PostWithAsync(client, second, withSession, FormKit.SiteOrigin, null, session);
Assert.Equal(HttpStatusCode.SeeOther, sameOrigin.StatusCode);
}
[Fact]
public async Task An_identity_error_carries_the_challenge_on_only_while_it_is_live()
{
using var client = Client();
var html = await FormKit.PageAsync(client);
var live = FormKit.Hidden(html, "challenge");
// Live: kept.
using (var kept = await FormKit.PostAsync(client, FormKit.Fields(html, "a!", nonce: "1")))
{
Assert.Equal(live, FormKit.Hidden(await kept.Content.ReadAsStringAsync(), "challenge"));
}
// Garbage: replaced by a fresh live one, never echoed.
var garbage = FormKit.Fields(html, "a!", nonce: "1");
garbage["challenge"] = "garbage-token";
using (var replaced = await FormKit.PostAsync(client, garbage))
{
var page = await replaced.Content.ReadAsStringAsync();
Assert.DoesNotContain("garbage-token", page);
Assert.True(factory.Services.GetRequiredService<RegistrationChallengeService>().IsLive(FormKit.Hidden(page, "challenge")));
}
// Spent: replaced.
var spentPage = await FormKit.PageAsync(client);
using (var spend = await FormKit.PostAsync(client, FormKit.Fields(spentPage, NewIdentity(), nonce: FormKit.Solved(spentPage))))
{
Assert.Equal(HttpStatusCode.SeeOther, spend.StatusCode);
}
using (var afterSpend = await FormKit.PostAsync(client, FormKit.Fields(spentPage, "a!", nonce: "1")))
{
Assert.NotEqual(FormKit.Hidden(spentPage, "challenge"), FormKit.Hidden(await afterSpend.Content.ReadAsStringAsync(), "challenge"));
}
// Expired: replaced.
factory.Time.Advance(RegistrationChallengeService.Lifetime + TimeSpan.FromSeconds(1));
using var afterExpiry = await FormKit.PostAsync(client, FormKit.Fields(html, "a!", nonce: "1"));
var expiredPage = await afterExpiry.Content.ReadAsStringAsync();
Assert.NotEqual(live, FormKit.Hidden(expiredPage, "challenge"));
Assert.True(factory.Services.GetRequiredService<RegistrationChallengeService>().IsLive(FormKit.Hidden(expiredPage, "challenge")));
}
[Fact]
public async Task A_same_origin_post_without_an_Origin_header_is_accepted_on_Sec_Fetch_Site()
{
using var client = Client();
var html = await FormKit.PageAsync(client);
var request = new HttpRequestMessage(HttpMethod.Post, "/auth/bifrost/register/form")
{
Content = new FormUrlEncodedContent(FormKit.Fields(html, NewIdentity(), nonce: FormKit.Solved(html)))
};
request.Headers.Add("Sec-Fetch-Site", "same-origin");
using var response = await client.SendAsync(request);
Assert.Equal(HttpStatusCode.SeeOther, response.StatusCode);
}
[Fact]
public async Task A_form_body_over_the_cap_is_refused_with_413_and_creates_nothing()
{
using var client = Client();
var html = await FormKit.PageAsync(client);
var fields = FormKit.Fields(html, NewIdentity(), nonce: FormKit.Solved(html));
fields["padding"] = new string('x', 17 * 1024);
var before = Counts();
using var response = await FormKit.PostAsync(client, fields);
Assert.Equal(HttpStatusCode.RequestEntityTooLarge, response.StatusCode);
Assert.Equal(before, Counts());
}
[Theory]
[InlineData("support", "reserved")]
[InlineData("a!", "3-80 characters")]
public async Task A_bad_identity_is_refused_without_spending_the_challenge(string identity, string fragment)
{
using var client = Client();
var html = await FormKit.PageAsync(client);
var nonce = FormKit.Solved(html);
using var bad = await FormKit.PostAsync(client, FormKit.Fields(html, identity, nonce: nonce));
var again = await bad.Content.ReadAsStringAsync();
Assert.Equal(HttpStatusCode.BadRequest, bad.StatusCode);
Assert.Contains(fragment, again);
Assert.Equal(FormKit.Hidden(html, "challenge"), FormKit.Hidden(again, "challenge"));
var good = NewIdentity();
using var ok = await FormKit.PostAsync(client, FormKit.Fields(html, good, nonce: nonce));
Assert.Equal(HttpStatusCode.SeeOther, ok.StatusCode);
Assert.Equal((true, true), Created(good));
}
[Theory]
[InlineData(3)]
[InlineData(5)]
public async Task A_challenge_payload_of_another_shape_is_refused(int parts)
{
using var client = Client();
var protector = factory.Services.GetRequiredService<Microsoft.AspNetCore.DataProtection.IDataProtectionProvider>()
.CreateProtector(RegistrationChallengeService.Purpose);
var now = factory.Time.GetUtcNow();
var expiry = (now + TimeSpan.FromMinutes(1)).ToUnixTimeMilliseconds();
var issued = (now - TimeSpan.FromMinutes(1)).ToUnixTimeMilliseconds();
var fields = new[] { Guid.NewGuid().ToString("N"), expiry.ToString(), "1", issued.ToString(), "extra" };
var challenge = protector.Protect(string.Join('.', fields.Take(parts)));
var identity = NewIdentity();
using var pow = await RegistrationKit.PostAsync(client, new { identity, challenge, nonce = RegistrationKit.Solve(challenge, 1), acceptedNoticeVersion = TrainingNotice.Version }, anonymous: true);
using var waited = await RegistrationKit.PostAsync(client, new { identity, challenge, waited = true, acceptedNoticeVersion = TrainingNotice.Version }, anonymous: true);
Assert.Equal(HttpStatusCode.BadRequest, pow.StatusCode);
Assert.Equal(HttpStatusCode.BadRequest, waited.StatusCode);
Assert.False(Exists(identity));
}
[Fact]
public async Task The_page_solver_script_finds_a_nonce_the_server_accepts_under_node()
{
using var client = Client();
var html = await FormKit.PageAsync(client);
var pagePath = Path.Combine(Path.GetTempPath(), $"bifrost-register-{Guid.NewGuid():N}.html");
await File.WriteAllTextAsync(pagePath, html);
try
{
var start = new ProcessStartInfo("node") { RedirectStandardOutput = true, RedirectStandardError = true };
start.ArgumentList.Add(Path.Combine(AppContext.BaseDirectory, "Fixtures", "registration-page-harness.cjs"));
start.ArgumentList.Add(pagePath);
Process process;
try
{
process = Process.Start(start)!;
}
catch (Win32Exception error)
{
throw new InvalidOperationException("Node must be on PATH to run the sign-up page's script: " + error.Message, error);
}
using (process)
{
var output = process.StandardOutput.ReadToEndAsync();
var errors = process.StandardError.ReadToEndAsync();
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(60));
await process.WaitForExitAsync(timeout.Token);
Assert.True(process.ExitCode == 0, await errors);
var runs = JsonDocument.Parse(await output).RootElement.EnumerateArray().ToDictionary(x => x.GetProperty("name").GetString()!);
var solved = runs["solves"];
Assert.Equal(1, solved.GetProperty("submitted").GetInt32());
Assert.True(solved.GetProperty("waitedDisabled").GetBoolean());
var first = solved.GetProperty("statuses")[0].GetString()!;
Assert.StartsWith("Checking.", first);
Assert.Contains("Skip the automatic check", first);
Assert.Contains("slow device", first);
Assert.DoesNotContain("a few seconds", first);
var nonce = solved.GetProperty("nonce").GetString()!;
var challenge = FormKit.Hidden(html, "challenge");
Assert.True(RegistrationChallengeService.LeadingZeroBits(System.Security.Cryptography.SHA256.HashData(
System.Text.Encoding.UTF8.GetBytes(challenge + ":" + nonce))) >= int.Parse(solved.GetProperty("difficulty").GetString()!));
// The server accepts the script's own nonce.
var identity = NewIdentity();
using var response = await FormKit.PostAsync(client, FormKit.Fields(html, identity, nonce: nonce));
Assert.Equal(HttpStatusCode.SeeOther, response.StatusCode);
Assert.Equal((true, true), Created(identity));
var skip = runs["skip-during-solve"];
Assert.Equal(0, skip.GetProperty("submitted").GetInt32());
Assert.Equal("", skip.GetProperty("nonce").GetString());
Assert.False(skip.GetProperty("waitedDisabled").GetBoolean());
Assert.Equal(0, skip.GetProperty("prevented").GetArrayLength());
Assert.Equal(1, runs["double-press"].GetProperty("submitted").GetInt32());
Assert.False(runs["no-subtle"].GetProperty("listening").GetBoolean());
}
}
finally
{
File.Delete(pagePath);
}
}
}
public sealed class RegistrationFallbackBudgetTests(RegistrationFallbackBudgetFactory factory) : IClassFixture<RegistrationFallbackBudgetFactory>
{
[Fact]
public async Task Past_the_fallback_budget_the_time_lock_gets_429_with_retry_after_and_work_still_passes()
{
using var client = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false });
var pages = new List<string>();
for (var i = 0; i < 4; i++)
{
pages.Add(await FormKit.PageAsync(client));
}
factory.Time.Advance(TimeSpan.FromSeconds(31));
var created = new List<string>();
var statuses = new List<HttpStatusCode>();
HttpResponseMessage? third = null;
for (var i = 0; i < 3; i++)
{
var identity = $"fb-{Guid.NewGuid():N}";
created.Add(identity);
var response = await FormKit.PostAsync(client, FormKit.Fields(pages[i], identity, waited: true));
statuses.Add(response.StatusCode);
third = response;
}
Assert.Equal([HttpStatusCode.SeeOther, HttpStatusCode.SeeOther, HttpStatusCode.TooManyRequests], statuses);
Assert.True(third!.Headers.RetryAfter is not null);
using (var scope = factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
Assert.Equal(2, db.UserAccounts.Count(x => created.Contains(x.NormalizedBifrostIdentity!)));
}
// The refused challenge was not spent, and proof of work is not under the fallback budget.
var viaWork = $"fb-{Guid.NewGuid():N}";
using var ok = await FormKit.PostAsync(client, FormKit.Fields(pages[2], viaWork, nonce: FormKit.Solved(pages[2])));
Assert.Equal(HttpStatusCode.SeeOther, ok.StatusCode);
}
}
public sealed class RegistrationPageBudgetTests(RegistrationBudgetFactory factory) : IClassFixture<RegistrationBudgetFactory>
{
[Fact]
public async Task The_page_spends_the_same_per_address_budget_as_the_challenge_endpoint()
{
using var client = factory.CreateClient();
var statuses = new List<HttpStatusCode>();
foreach (var path in new[] { "/auth/bifrost/register", "/auth/bifrost/challenge", "/auth/bifrost/register", "/auth/bifrost/register" })
{
using var request = new HttpRequestMessage(HttpMethod.Get, path);
request.Headers.Add("X-Test-Peer", "203.0.113.99");
using var response = await client.SendAsync(request);
statuses.Add(response.StatusCode);
}
Assert.Equal([HttpStatusCode.OK, HttpStatusCode.OK, HttpStatusCode.OK, HttpStatusCode.TooManyRequests], statuses);
}
}
public sealed class RegistrationFallbackOptionsTests
{
private sealed class StubEnvironment(string name) : Microsoft.Extensions.Hosting.IHostEnvironment
{
public string EnvironmentName { get; set; } = name;
public string ApplicationName { get; set; } = "Bifrost.Web";
public string ContentRootPath { get; set; } = string.Empty;
public Microsoft.Extensions.FileProviders.IFileProvider ContentRootFileProvider { get; set; } = null!;
}
private static void Validate(RegistrationOptions options) =>
new StartupConfigurationValidator(
new ConfigurationBuilder().Build(),
new StubEnvironment("Testing"),
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();
[Fact]
public void The_defaults_pass() => Validate(new RegistrationOptions());
[Theory]
[InlineData(4)]
[InlineData(301)]
[InlineData(300)]
public void A_wait_outside_5_to_299_seconds_is_refused(int seconds)
{
var options = new RegistrationOptions { Captcha = new RegistrationCaptchaOptions { FallbackWaitSeconds = seconds } };
Assert.Contains("FallbackWaitSeconds", Assert.Throws<InvalidOperationException>(() => Validate(options)).Message);
}
[Theory]
[InlineData(0, 3600)]
[InlineData(3, 0)]
public void A_fallback_budget_that_cannot_be_spent_is_refused(int permits, int windowSeconds)
{
var options = new RegistrationOptions { Limits = new RegistrationLimitsOptions { FallbackPermits = permits, FallbackWindow = TimeSpan.FromSeconds(windowSeconds) } };
Assert.Contains("FallbackPermits", Assert.Throws<InvalidOperationException>(() => Validate(options)).Message);
}
}
public sealed class RegistrationValidationBudgetFactory : RegistrationFormFactory
{
protected override string FallbackPermits => "2";
}
public sealed class RegistrationValidationBudgetTests(RegistrationValidationBudgetFactory factory) : IClassFixture<RegistrationValidationBudgetFactory>
{
[Fact]
public async Task Identity_refusals_spend_neither_a_fallback_permit_nor_the_challenge()
{
using var client = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false });
var first = await FormKit.PageAsync(client);
var second = await FormKit.PageAsync(client);
var third = await FormKit.PageAsync(client);
factory.Time.Advance(TimeSpan.FromSeconds(31));
var taken = $"vb-{Guid.NewGuid():N}";
using (var one = await FormKit.PostAsync(client, FormKit.Fields(first, taken, waited: true)))
{
Assert.Equal(HttpStatusCode.SeeOther, one.StatusCode);
}
foreach (var bad in new[] { taken, "support", "a!" })
{
using var refused = await FormKit.PostAsync(client, FormKit.Fields(second, bad, waited: true));
Assert.Equal(HttpStatusCode.BadRequest, refused.StatusCode);
}
using (var fresh = await FormKit.PostAsync(client, FormKit.Fields(second, $"vb-{Guid.NewGuid():N}", waited: true)))
{
Assert.Equal(HttpStatusCode.SeeOther, fresh.StatusCode);
}
// Two permits, two registrations: the three refusals above cost nothing.
using var over = await FormKit.PostAsync(client, FormKit.Fields(third, $"vb-{Guid.NewGuid():N}", waited: true));
Assert.Equal(HttpStatusCode.TooManyRequests, over.StatusCode);
}
}
public sealed class RegistrationFormBudgetTests(RegistrationBudgetFactory factory) : IClassFixture<RegistrationBudgetFactory>
{
[Fact]
public async Task The_general_budget_is_spent_before_the_body_is_read_and_cross_site_posts_spend_nothing()
{
using var client = factory.CreateClient();
const string peer = "203.0.113.150";
var fields = new Dictionary<string, string> { ["identity"] = "x", ["padding"] = new string('x', 17 * 1024) };
for (var i = 0; i < 5; i++)
{
using var crossSite = await FormKit.PostAsync(client, fields, origin: "https://evil.example", peer: peer);
Assert.Equal(HttpStatusCode.Forbidden, crossSite.StatusCode);
}
// Three permits: the over-cap posts spend them (413), the next is 429 whatever its body.
for (var i = 0; i < 3; i++)
{
using var big = await FormKit.PostAsync(client, fields, peer: peer);
Assert.Equal(HttpStatusCode.RequestEntityTooLarge, big.StatusCode);
}
using var refused = await FormKit.PostAsync(client, fields, peer: peer);
Assert.Equal(HttpStatusCode.TooManyRequests, refused.StatusCode);
}
}

View file

@ -328,9 +328,9 @@ public sealed class RegistrationGateTests(RegistrationGateFactory factory) : ICl
[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)
[InlineData(24, true)]
[InlineData(25, false)]
public void Difficulty_outside_one_to_twenty_four_is_refused_at_startup(int difficulty, bool valid)
{
var options = new RegistrationOptions { Captcha = new RegistrationCaptchaOptions { Difficulty = difficulty } };
Action validate = () => new StartupConfigurationValidator(