From 02d95e2ce446ded714d6a510061f185cf0f6b894 Mon Sep 17 00:00:00 2001 From: Harry Cordewener Date: Fri, 31 Jul 2026 09:35:03 -0500 Subject: [PATCH 1/3] Claiming, as far as the socket: accounts, tokens, and what a beacon means MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec §8 rewritten around one decision -- sign in first, then claim -- and the storage and crawler halves built against it. The web half (passkeys, the pages) is not here yet. The ordering is not a convenience. We ask an operator to publish the token on a connect screen or in an MSSP field, where every anonymous connection reads it, so a design in which holding the token confers the claim is broken the instant it succeeds. The token is a NONCE: it proves somebody with write access to that server published it, and the account column answers the separate question of who asked. Mallory reading Alice's token off the connect screen can do nothing with it, and there is a test that says so by name. That also answers what happens between minting and verifying. The pending claim is durable server-side state shown on the claimant's dashboard, so nobody has to write a token down or finish in one sitting -- a scheme that punished the person who closed the tab would put a transcription error between an owner and their listing. Sign-in will be passkeys and nothing else (§8.2). No passwords, no email, no federated provider. The usual blocker for passwordless is account recovery, and it does not apply here: the root of trust is the server the operator controls, so losing every device is recoverable by publishing a fresh token. Two consequences are written down rather than discovered later -- sign-in will be the only part of this site needing JavaScript, and a passkey is bound to a domain, which gives §15.1's open domain question a deadline. §8.4 is the other rule with teeth: presence establishes, absence never revokes. Two timestamps, because they are two facts. Absence-revokes would hand revocation to any transient failure, and this project has watched MCCP swallow a connection's payload whole -- a silent unclaiming on that basis is indistinguishable from an owner walking away. Three guarantees are in the schema rather than in a handler, because a constraint that fires is a guarantee and a branch in C# is an intention: a claim carries a NOT NULL account, one account holds at most one pending claim per game (partial unique index), and a token is unique across the table -- without which one game's published token could complete another game's claim. DNS TXT is deferred, and not for want of a resolver: a TXT record proves control of a hostname, and a hostname is not a game. MU* hosting routinely puts many unrelated games on one domain separated by port, so the host's operator could claim all of them and a game on somebody else's domain could not use the channel at all. Also here, because it was in the way: PostgresFixture ran Postgres at its default 100 connections, and this suite passed that the day it grew past roughly that many tests. The failure was not a clean one -- whichever tests happened to be starting failed with 53300, in twenty unrelated places, reading as flakiness rather than as a ceiling. Co-Authored-By: Claude Opus 5 --- docs/specs/2026-07-30-mu-directory-design.md | 129 ++++++- migrations/0007_ownership.sql | 170 +++++++++ src/MUI.Catalog/Claims.cs | 209 +++++++++++ src/MUI.Catalog/Persistence/ClaimService.cs | 208 +++++++++++ .../Persistence/NpgsqlClaimStore.cs | 253 +++++++++++++ .../Persistence/NpgsqlGameStore.cs | 13 + src/MUI.Catalog/Persistence/SqlEnums.cs | 46 +++ src/MUI.Catalog/Persistence/Stores.cs | 10 + src/MUI.Crawler.Cli/Program.cs | 3 + src/MUI.Crawler/CrawlCycle.cs | 39 ++ src/MUI.Discovery/Identity.cs | 23 +- .../Persistence/ClaimPostgresTests.cs | 342 ++++++++++++++++++ .../Support/InMemoryPersistence.cs | 10 + .../Support/InMemoryCatalogue.cs | 10 + 14 files changed, 1450 insertions(+), 15 deletions(-) create mode 100644 migrations/0007_ownership.sql create mode 100644 src/MUI.Catalog/Claims.cs create mode 100644 src/MUI.Catalog/Persistence/ClaimService.cs create mode 100644 src/MUI.Catalog/Persistence/NpgsqlClaimStore.cs create mode 100644 tests/MUI.Catalog.Tests/Persistence/ClaimPostgresTests.cs diff --git a/docs/specs/2026-07-30-mu-directory-design.md b/docs/specs/2026-07-30-mu-directory-design.md index 693fd68..7d17b64 100644 --- a/docs/specs/2026-07-30-mu-directory-design.md +++ b/docs/specs/2026-07-30-mu-directory-design.md @@ -703,21 +703,126 @@ so, and §11's opt-out is the honest way to say it entirely. ## 8. Claiming and ownership -The site issues a token. The owner proves control by emitting it in one of three places — an MSSP -field, a line on the connect screen, or a DNS TXT record on the hostname. All three require server -or DNS access; all three are verified by the crawler that already exists; none requires the site to -send mail or trust a third-party registry. - -The claim token doubles as a permanent identity beacon (§7.3), which gives owners a concrete -technical reason to claim beyond editing their listing. - -Owner dashboard: enrichment fields (fandom/IP, RP enforcement, application process, consent tools), -connect-screen suppression, WHO-format override, opt-out, and the MSSP linter scorecard — -continuous rather than one-shot, flagging missing fields, wrong types and non-standard values. -Multi-owner, transfer, and an audit log. +### 8.1 The order is: sign in, then claim + +An account exists **before** any token does, and the claim binds to the account rather than to the +token. That ordering is not a convenience; it is what makes the scheme sound. + +1. The visitor signs in. A session cookie now identifies a durable account. +2. They press *claim this game*. The site mints a token and stores a **pending claim** keyed + `(account, game)`. +3. They publish the token where a probe can read it. +4. The next probe compares. On a match the claim completes, bound to **the account that minted the + token** — never to whoever holds it. + +**The token is a nonce, not a credential, and it cannot be anything else.** We ask an operator to +publish it on a connect screen or in an MSSP field, which every anonymous connection reads — +including every other crawler. A bearer-secret model, where holding the token confers the claim, is +therefore broken the instant it succeeds. What the token proves is that *somebody with write access +to that server published it*; the account binding answers the separate question of *who asked*. + +Mallory reading Alice's token off the connect screen can do nothing with it: it verifies only against +Alice's pending claim. To take the game she must publish her own token on that server, which is +precisely the control being tested. + +**Nobody has to write anything down.** The pending claim is durable server-side state, shown on the +claimant's dashboard for as long as it is pending, with each channel's exact line ready to copy. Close +the tab, come back next week, it is still there. A token that had to be captured in one sitting would +put a transcription error between an owner and their listing. + +Bounds: one pending token per `(account, game)`, expiring after 30 days — otherwise abandoned tokens +accumulate on connect screens and linger as identity beacons for claims nobody completed. Verification +is idempotent and re-runnable; a non-match is *not yet* rather than a failure, and the page says when +we last looked and when we will look again. + +**Verification is asked for, not polled at.** A claimant may request one on-demand probe per pending +claim per few minutes — enough that an operator who has just edited `mush.cnf` is not waiting on the +scheduler, and bounded so that the button is not a free way to make us dial a stranger. `CRAWL DELAY` +still binds, and the target must already be one we crawl. + +### 8.2 Sign-in is passkeys, and v1 has nothing else + +**Passkeys only** (WebAuthn/FIDO2, native to ASP.NET Core Identity in .NET 10). No passwords, no +email, no federated provider, no third party of any kind. We hold a public key; the private key never +leaves the operator's authenticator. + +Three properties earned rather than assumed. There is **no password database to breach** — what we +store is public by construction. Sign-in is **phishing-resistant structurally**, because the browser +binds the credential to our domain and will not release a signature to a look-alike. And replay is +caught by the credential's own signature counter. + +**The hard part of passwordless does not apply here.** Account recovery is what usually forces a +password, an email flow or recovery codes onto a passkey deployment. Our recovery path is: make a new +account, publish a fresh token on your game, verify. **The root of trust is the server the operator +controls, not the credential** — so losing every device is recoverable without us knowing an email +address, and an account is worth almost nothing to steal. + +Three consequences to hold onto: + +- **Sign-in requires JavaScript, and it is the only thing on this site that does.** `navigator + .credentials` has no scripting-off path. The public catalogue — listing, game pages, archive, + plain mode, the API — stays fully functional without scripting, and that boundary is a design + constraint rather than an accident: the part that requires JS is the part used by people who + administer a game server. +- **A passkey is bound to a domain**, and §15.1's open domain question therefore has a deadline. + `IdentityPasskeyOptions.ServerDomain` is set explicitly rather than inferred from the host header + (the inference is a credential-scoping risk), and no untrusted content is ever served on a + subdomain of it. Passkeys registered before the domain settles must be re-registered after a move; + either settle it before claiming opens or accept a one-time re-enrolment and say so on the page. +- **Enrolment is still a minority behaviour** across the web — a reason to expect federated options + to be added later, and not a reason to add them now. Every person who can complete a claim already + has shell access to a MU\* server; this is the audience most able to use a passkey. + +Federated sign-in (Discord, a forge, the fediverse) is a **later** addition, and one that also +restores a scripting-free login path, since OAuth is redirects. It is deliberately out of v1. + +### 8.3 The channels a token may be published in + +**MSSP** (`MUINDEX CLAIM`, with `MUINDEX_CLAIM` and `CONTACT_TOKEN` also accepted — an MSSP variable +name does not reliably survive a config file, and an operator who did exactly what they were told must +not be told their claim failed) and **the connect screen** (`MUINDEX-CLAIM: muidx-…`). Both are read +by the probe that already exists. + +**DNS TXT is deferred, and not merely for lack of a resolver.** A TXT record proves control of a +*hostname*, and a hostname is not a game: MU\* hosting routinely puts many unrelated games on one +domain, separated only by port. The host's operator could claim all of them, and a game running on +somebody else's domain could never use the channel at all. If it returns it needs a port qualifier. +The two channels above prove control of *that listener*, which is the thing being claimed. + +### 8.4 Presence establishes; absence never revokes + +A verified claim survives the token being removed. The alternative — absence revokes — hands +revocation to any transient failure: a server restart, an MSSP hiccup, a compression bug eating a +subnegotiation. This project has already watched MCCP swallow a connection's payload whole, and a +silent unclaiming on that basis would be indistinguishable from an owner walking away. + +So two timestamps, because they are two facts: `claimed_at`, written once when verification succeeds, +and `beacon_last_seen_at`, updated whenever a probe still sees the token. Revocation is explicit, or +the consequence of a **counter-claim** — a different account proving control *now* — which is also +the correct handling of a game changing hands. The published token keeps earning its keep meanwhile +as §7.3's decisive identity signal, which is the concrete technical reason to leave it in place. + +### 8.5 What a claim grants, and the line it may not cross + +Enrichment fields (fandom/IP, RP enforcement, application process, consent tools), connect-screen +suppression, `WHO`-format override, opt-out, and the MSSP linter scorecard — continuous rather than +one-shot, flagging missing fields, wrong types and non-standard values. Multi-owner, transfer, and an +audit log; one account may hold many games, and a game may have several owners, each having verified +a token of their own. + +**An owner may never edit a measurement.** They can add `FANDOM`; they cannot touch a player count, a +capability matrix, or a reachability history. The writable set *is* the field registry's +`OwnerEnrichable` flag, and a write to any other field is refused out loud rather than dropped — a +silent no-op teaches an owner that the site is broken, and a successful one would make the whole site +a self-report with extra steps. Owner-published outputs: a live player-count SVG badge and a JSON endpoint for the game's own site. +**Claiming lights up two paths that are currently unreachable**, and that is worth knowing when +testing it: nothing sets `game.is_claimed` today, so the `claimed` badge in the listing and +`ArchivePolicy`'s ceiling-grace-for-claimed-games (§7.5) have never once been exercised against real +data. + ## 9. Site surface, v1 **Game listing.** Faceted search over the MSSP taxonomy plus derived facets: activity band, diff --git a/migrations/0007_ownership.sql b/migrations/0007_ownership.sql new file mode 100644 index 0000000..f342824 --- /dev/null +++ b/migrations/0007_ownership.sql @@ -0,0 +1,170 @@ +-- spec §8 — accounts, passkeys, and the claims that bind one to a game. +-- +-- Everything before this migration is what a probe produced or what decides who gets probed. This is +-- the first table a *person* writes to, and the ordering of §8.1 is enforced here rather than trusted +-- in a handler: a claim carries a NOT NULL account, so a token that verified without anybody having +-- asked for it has nowhere to go. + +-- §8.2 — an account, and deliberately almost nothing about a person. +-- +-- NO EMAIL COLUMN, NO PASSWORD HASH, AND NEITHER IS AN OVERSIGHT. Sign-in is passkeys only, so there +-- is no password to store and no address to recover to; §8.2's recovery path is to make a new account +-- and re-verify through the game, because the root of trust is the server the operator controls. An +-- account here is a durable handle to hang a claim on, and it is worth almost nothing to steal. +-- +-- §11 refuses to persist player names. An owner is not a player, but the same default applies: what +-- is stored is what the site cannot work without. +CREATE TABLE app_user ( + id uuid PRIMARY KEY, + + -- What the account calls itself. Chosen by the account holder, shown only to them unless they + -- publish a contact on a game they own. Not an identity claim and never verified as one. + display_name text NOT NULL, + + -- Identity's own case/diacritic-insensitive lookup key. Unique so two accounts cannot collide on + -- a name a human would read as the same. + normalised_name text NOT NULL, + + -- Identity's optimistic-concurrency token. Ours to store, not ours to interpret. + security_stamp text NOT NULL, + concurrency_stamp text NOT NULL, + + created_at timestamptz NOT NULL, + last_signed_in_at timestamptz, + + CONSTRAINT app_user_name_is_not_blank CHECK (btrim(display_name) <> ''), + CONSTRAINT app_user_normalised_name_is_canonical CHECK ( + normalised_name = upper(normalised_name) AND normalised_name = btrim(normalised_name)) +); + +CREATE UNIQUE INDEX app_user_normalised_name_idx ON app_user (normalised_name); + +-- §8.2 — one WebAuthn credential. +-- +-- The public key is public by construction, which is the property that makes this table uninteresting +-- to steal and is the whole argument for passkeys over passwords here. +-- +-- The full attestation object is kept because it carries the AAGUID identifying the authenticator +-- model: when a model is found to be compromised, the affected credentials have to be findable. We do +-- not validate attestation statements (ASP.NET Core Identity does not by default, and for a consumer +-- site that is the right default), so the AAGUID is evidence rather than proof — which is a reason to +-- keep it, not a reason to discard it. +CREATE TABLE user_passkey ( + credential_id bytea PRIMARY KEY, + user_id uuid NOT NULL REFERENCES app_user (id) ON DELETE CASCADE, + + public_key bytea NOT NULL, + + -- Replay protection. A credential that presents a counter no higher than the stored one has been + -- cloned or replayed; the authenticator is expected to increment it. + sign_count bigint NOT NULL DEFAULT 0, + + -- Whether the credential is synced to a provider (backed up) or lives on one device. A passkey + -- that is NOT backed up is one lost phone away from being gone, which is what the dashboard reads + -- to suggest adding a second one. + is_backed_up boolean NOT NULL DEFAULT false, + is_backup_eligible boolean NOT NULL DEFAULT false, + + transports text, + attestation_object bytea, + attestation_format text, + + -- "My phone", "the yubikey in the drawer". A person with three passkeys needs to know which is + -- which before they can revoke one, so this is bounded rather than free — see §8's note on + -- resource limits. + name text, + + created_at timestamptz NOT NULL, + last_used_at timestamptz, + + CONSTRAINT user_passkey_name_is_bounded CHECK (name IS NULL OR length(name) <= 64), + CONSTRAINT user_passkey_sign_count_is_not_negative CHECK (sign_count >= 0) +); + +CREATE INDEX user_passkey_user_idx ON user_passkey (user_id); + +-- §8.1 — a claim, pending or verified, always bound to the account that started it. +-- +-- ONE ROW COVERS BOTH STATES ON PURPOSE. A pending claim and a verified one are the same fact at two +-- moments, and splitting them into two tables would make "did this account already ask?" a question +-- with two places to look — which is how a second pending token gets minted while the first is still +-- printed on somebody's connect screen. +-- +-- THE TOKEN IS NOT A SECRET AND MUST NEVER BE TREATED AS ONE (§8.1). We ask an operator to publish it +-- where every anonymous connection reads it. It is stored in the clear because hashing it would imply +-- a confidentiality it cannot have, and because the crawler compares what it read against what we +-- issued. What the token proves is that somebody with write access to that server published it; the +-- account column answers who asked. +CREATE TABLE game_claim ( + id uuid PRIMARY KEY, + game_id uuid NOT NULL REFERENCES game (id), + user_id uuid NOT NULL REFERENCES app_user (id), + + token text NOT NULL, + + -- Written once, when a probe first matched. NULL means pending. + claimed_at timestamptz, + + -- §8.4 — a second timestamp because there are two facts. Absence of the beacon never revokes a + -- claim; a transient MSSP failure or a compression bug eating a subnegotiation would otherwise + -- unclaim somebody silently. This says only when we last still saw it. + beacon_last_seen_at timestamptz, + + -- Which channel the token was read from, for the audit trail and for telling an owner what we + -- actually saw. NULL while pending. + verified_via text, + + issued_at timestamptz NOT NULL, + + -- §8.1 — a pending token expires so that abandoned tokens do not accumulate on connect screens + -- and linger as identity beacons for claims nobody completed. A *verified* claim does not expire. + expires_at timestamptz NOT NULL, + + -- Explicit revocation, or the loser of a counter-claim (§8.4). Never written because a beacon + -- went missing. + revoked_at timestamptz, + revoked_reason text, + + -- When the claimant last asked us to look, so the on-demand check can be rate-limited per claim + -- rather than per source address, which is the bound that actually matters (§8.1). + last_checked_at timestamptz, + + CONSTRAINT game_claim_token_is_not_blank CHECK (btrim(token) <> ''), + CONSTRAINT game_claim_verified_names_its_channel CHECK ( + (claimed_at IS NULL AND verified_via IS NULL) OR + (claimed_at IS NOT NULL AND verified_via IS NOT NULL)), + CONSTRAINT game_claim_channel_vocabulary CHECK ( + verified_via IS NULL OR verified_via IN ('mssp', 'connect_screen')), + CONSTRAINT game_claim_expires_after_it_is_issued CHECK (expires_at > issued_at) +); + +-- §8.1 — one pending token per (account, game). Partial, so the same account may hold a verified +-- claim and nothing else: a second attempt while one is outstanding must reuse the token already +-- published rather than mint a rival. +CREATE UNIQUE INDEX game_claim_one_pending_per_account_idx + ON game_claim (game_id, user_id) + WHERE claimed_at IS NULL AND revoked_at IS NULL; + +-- The token the crawler compares against. Unique across the table, because a collision would let one +-- game's published token complete another game's claim. +CREATE UNIQUE INDEX game_claim_token_idx ON game_claim (token); + +CREATE INDEX game_claim_game_idx ON game_claim (game_id); +CREATE INDEX game_claim_user_idx ON game_claim (user_id); + +-- §8.5 — the audit log. Append-only by convention: every row is something that happened, and nothing +-- here is ever updated or deleted, for the same reason §7.4 never deletes a game. +CREATE TABLE claim_event ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + claim_id uuid NOT NULL REFERENCES game_claim (id), + at timestamptz NOT NULL, + + kind text NOT NULL, + detail text, + + CONSTRAINT claim_event_kind_vocabulary CHECK (kind IN ( + 'issued', 'reissued', 'verified', 'beacon_seen', 'beacon_missing', 'revoked', 'expired', + 'counter_claimed', 'check_requested')) +); + +CREATE INDEX claim_event_claim_idx ON claim_event (claim_id, at); diff --git a/src/MUI.Catalog/Claims.cs b/src/MUI.Catalog/Claims.cs new file mode 100644 index 0000000..4983861 --- /dev/null +++ b/src/MUI.Catalog/Claims.cs @@ -0,0 +1,209 @@ +namespace MUI.Catalog; + +/// +/// Where a claim token was read from. Only channels a probe can see are here. +/// +/// +/// DNS is deliberately absent (spec §8.3), and not merely for want of a resolver: a TXT record proves +/// control of a hostname, and a hostname is not a game. MU* hosting routinely puts many +/// unrelated games on one domain separated only by port, so the host's operator could claim all of +/// them and a game on somebody else's domain could use the channel not at all. Both members here +/// prove control of that listener, which is the thing being claimed. +/// +public enum ClaimChannel +{ + Mssp, + ConnectScreen, +} + +/// +/// A claim on a game by an account: pending while is null, verified after. +/// +/// +/// +/// One record covers both states on purpose. A pending claim and a verified one are the same +/// fact at two moments, and splitting them would make "has this account already asked?" a question +/// with two places to look — which is how a second token gets minted while the first is still printed +/// on somebody's connect screen. +/// +/// +/// is a nonce, not a credential. We ask an operator to publish it where +/// every anonymous connection reads it (spec §8.1), so holding it can never confer anything. It +/// proves that somebody with write access to that server published it; answers +/// the separate question of who asked. +/// +/// +public sealed record GameClaim +{ + public required Guid Id { get; init; } + + public required Guid GameId { get; init; } + + /// The account the claim binds to — never whoever holds the token. + public required Guid UserId { get; init; } + + public required string Token { get; init; } + + public required DateTimeOffset IssuedAt { get; init; } + + /// + /// When a pending token stops being offered. A verified claim does not expire. + /// + public required DateTimeOffset ExpiresAt { get; init; } + + /// Written once, when a probe first matched. Null means pending. + public DateTimeOffset? ClaimedAt { get; init; } + + /// + /// When a probe last still saw the token, which is a different fact from . + /// + /// + /// Spec §8.4: presence establishes, absence never revokes. Two timestamps exist so that "this + /// account proved control" and "the beacon is still up" can be told apart — collapsing them would + /// hand revocation to any transient failure, and this project has already watched MCCP swallow a + /// connection's payload whole. + /// + public DateTimeOffset? BeaconLastSeenAt { get; init; } + + public ClaimChannel? VerifiedVia { get; init; } + + public DateTimeOffset? RevokedAt { get; init; } + + public string? RevokedReason { get; init; } + + /// When the claimant last asked us to look, so the on-demand check can be bounded. + public DateTimeOffset? LastCheckedAt { get; init; } + + public bool IsVerified => ClaimedAt is not null && RevokedAt is null; + + public bool IsPending(DateTimeOffset now) => + ClaimedAt is null && RevokedAt is null && now < ExpiresAt; +} + +/// Something that happened to a claim. Append-only (spec §8.5). +public sealed record ClaimEvent(Guid ClaimId, DateTimeOffset At, ClaimEventKind Kind, string? Detail = null); + +public enum ClaimEventKind +{ + Issued, + Reissued, + Verified, + BeaconSeen, + BeaconMissing, + Revoked, + Expired, + CounterClaimed, + CheckRequested, +} + +/// +/// Mints the token an operator publishes. +/// +/// +/// +/// Randomness rather than a derivation of the game or the account, because a token an observer can +/// predict is one they can publish first. It is public once verified — that is the point — +/// but it must be unguessable until the operator chooses to put it somewhere. +/// +/// +/// The alphabet excludes the characters people confuse when reading a connect screen back to +/// themselves. The token is meant to be copied, never transcribed, but a scheme that punishes the +/// person who does transcribe it is a support mail waiting to happen. +/// +/// +public static class ClaimToken +{ + /// The prefix every token carries, so one is recognisable in a config file at a glance. + public const string Prefix = "muidx-"; + + /// How long a pending token is offered before it expires (spec §8.1). + public static readonly TimeSpan PendingLifetime = TimeSpan.FromDays(30); + + /// + /// Digits and lower-case letters with 0/o, 1/l/i and u/v reduced to one + /// member each. + /// + private const string Alphabet = "23456789abcdefghjkmnpqrstwxyz"; + + /// Body length. 20 characters of this alphabet is ~97 bits. + private const int BodyLength = 20; + + public static string Mint() => Mint(System.Security.Cryptography.RandomNumberGenerator.GetBytes(BodyLength)); + + /// Deterministic overload, so a test can assert the shape without asserting the randomness. + public static string Mint(ReadOnlySpan entropy) + { + var body = new char[entropy.Length]; + + for (var i = 0; i < entropy.Length; i++) + { + body[i] = Alphabet[entropy[i] % Alphabet.Length]; + } + + return Prefix + new string(body); + } + + /// + /// Whether a string read off a server could be one of ours — a cheap filter, never a verification. + /// + /// + /// A token that passes this has still proved nothing. Verification is a lookup against an issued + /// pending claim and cannot be replaced by a shape check, however tempting that is at the call + /// site. + /// + public static bool LooksLikeOne(string? candidate) => + candidate is not null + && candidate.StartsWith(Prefix, StringComparison.Ordinal) + && candidate.Length == Prefix.Length + BodyLength; +} + +/// Reads and writes claims. Storage-agnostic by construction. +public interface IClaimStore +{ + Task FindAsync(Guid claimId, CancellationToken cancellationToken = default); + + /// Every claim on a game, verified and pending, newest first. + Task> ForGameAsync(Guid gameId, CancellationToken cancellationToken = default); + + /// Every claim an account holds. + Task> ForUserAsync(Guid userId, CancellationToken cancellationToken = default); + + /// + /// The live pending claim for whose token is , + /// or null. + /// + /// + /// Scoped to the game as well as the token. A token is unique across the table, so the game is + /// redundant for correctness — and it is passed anyway, because a lookup that would silently + /// complete a different game's claim if the uniqueness ever lapsed is one refactor away + /// from being a real hole. + /// + Task FindPendingByTokenAsync( + Guid gameId, + string token, + CancellationToken cancellationToken = default); + + Task InsertAsync(GameClaim claim, CancellationToken cancellationToken = default); + + Task UpdateAsync(GameClaim claim, CancellationToken cancellationToken = default); + + Task RecordEventAsync(ClaimEvent claimEvent, CancellationToken cancellationToken = default); + + Task> EventsAsync(Guid claimId, CancellationToken cancellationToken = default); +} + +/// What happened when a probe's beacon was offered to the claim store. +public enum ClaimVerdict +{ + /// No token was published, or none we issued. The common case, and not an error. + NothingToDo, + + /// A pending claim matched and is now verified. + Verified, + + /// An already-verified claim's beacon is still up (spec §8.4). + StillSeen, + + /// A token we issued, matching a claim that has expired or been revoked. + Stale, +} diff --git a/src/MUI.Catalog/Persistence/ClaimService.cs b/src/MUI.Catalog/Persistence/ClaimService.cs new file mode 100644 index 0000000..51bf7e7 --- /dev/null +++ b/src/MUI.Catalog/Persistence/ClaimService.cs @@ -0,0 +1,208 @@ +namespace MUI.Catalog.Persistence; + +/// +/// Issues claim tokens and decides what a beacon read off a server means (spec §8). +/// +/// +/// +/// The rules live here rather than in the crawler or a page handler because all three of the callers +/// that matter — the dashboard minting a token, the probe loop offering one it read, and an owner +/// pressing check now — must reach the same conclusions. A rule implemented twice is a rule +/// that will disagree with itself. +/// +/// +/// It takes no ProbeResult, and cannot. MUI.Catalog may not reference +/// MUI.Crawl: the writers that consume a probe must not know a socket exists. The crawler reads +/// the beacon (ClaimTokenBeacon, which knows about MSSP variables and connect screens) and +/// hands this a string and a channel. +/// +/// +public sealed class ClaimService(IClaimStore claims, IGameStore games, TimeProvider time) +{ + /// + /// How often a claimant may ask us to look again (spec §8.1). + /// + /// + /// Short enough that an operator who has just edited mush.cnf is not left waiting on the + /// scheduler, long enough that the button is not a way to make us dial a stranger repeatedly. The + /// bound is per claim rather than per source address, because the claim is the thing that must + /// already exist — an attacker cannot create one for a game they have not been offered. + /// + public static readonly TimeSpan RecheckInterval = TimeSpan.FromMinutes(3); + + /// + /// The token should publish for , minting one + /// if they have none outstanding. + /// + /// + /// An existing pending claim is returned rather than replaced. The previous token may + /// already be printed on a connect screen, and minting a rival would invalidate what the operator + /// has just finished publishing — the most annoying possible failure, because it looks like the + /// site not working. A token is only replaced once it has expired. + /// + public async Task IssueAsync( + Guid gameId, + Guid userId, + CancellationToken cancellationToken = default) + { + var now = time.GetUtcNow(); + var existing = await claims.ForUserAsync(userId, cancellationToken); + + if (existing.FirstOrDefault(c => c.GameId == gameId && c.IsPending(now)) is { } pending) + { + return pending; + } + + var claim = new GameClaim + { + Id = Guid.CreateVersion7(), + GameId = gameId, + UserId = userId, + Token = ClaimToken.Mint(), + IssuedAt = now, + ExpiresAt = now + ClaimToken.PendingLifetime, + }; + + await claims.InsertAsync(claim, cancellationToken); + + var reissue = existing.Any(c => c.GameId == gameId); + await claims.RecordEventAsync( + new ClaimEvent(claim.Id, now, reissue ? ClaimEventKind.Reissued : ClaimEventKind.Issued), + cancellationToken); + + return claim; + } + + /// + /// Offers a token read off to the claim store. + /// + /// + /// + /// Called on every probe that read a beacon at all, which is why + /// is the ordinary answer rather than a failure. A game publishing somebody else's token, or a + /// token from a claim that expired, is a normal state of the world and not a problem to report. + /// + /// + /// An already-verified claim is refreshed, never re-verified (§8.4). Seeing the beacon + /// updates beacon_last_seen_at and nothing else; not seeing it does nothing at all, which + /// is the point — absence must never revoke. + /// + /// + public async Task OfferBeaconAsync( + Guid gameId, + string? token, + ClaimChannel channel, + CancellationToken cancellationToken = default) + { + if (!ClaimToken.LooksLikeOne(token)) + { + return ClaimVerdict.NothingToDo; + } + + var now = time.GetUtcNow(); + + if (await claims.FindPendingByTokenAsync(gameId, token!, cancellationToken) is { } pending) + { + await claims.UpdateAsync( + pending with + { + ClaimedAt = now, + BeaconLastSeenAt = now, + VerifiedVia = channel, + }, + cancellationToken); + + await claims.RecordEventAsync( + new ClaimEvent(pending.Id, now, ClaimEventKind.Verified, channel.ToString()), + cancellationToken); + + // The listing badge and §7.5's ceiling grace both read this, and neither has ever been + // reachable before now. + await games.SetClaimedAsync(gameId, true, cancellationToken); + + return ClaimVerdict.Verified; + } + + var onGame = await claims.ForGameAsync(gameId, cancellationToken); + var match = onGame.FirstOrDefault(c => string.Equals(c.Token, token, StringComparison.Ordinal)); + + if (match is null) + { + // A token-shaped string we never issued. Says nothing about anybody. + return ClaimVerdict.NothingToDo; + } + + if (!match.IsVerified) + { + return ClaimVerdict.Stale; + } + + await claims.UpdateAsync(match with { BeaconLastSeenAt = now }, cancellationToken); + await claims.RecordEventAsync( + new ClaimEvent(match.Id, now, ClaimEventKind.BeaconSeen), + cancellationToken); + + return ClaimVerdict.StillSeen; + } + + /// Whether may ask for an on-demand probe yet. + public bool MayRecheck(GameClaim claim) + { + ArgumentNullException.ThrowIfNull(claim); + + return claim.LastCheckedAt is not { } last || time.GetUtcNow() - last >= RecheckInterval; + } + + /// Records that a check was asked for. The dialling itself belongs to the crawler. + public async Task RequestCheckAsync(Guid claimId, CancellationToken cancellationToken = default) + { + if (await claims.FindAsync(claimId, cancellationToken) is not { } claim || !MayRecheck(claim)) + { + return false; + } + + var now = time.GetUtcNow(); + + await claims.UpdateAsync(claim with { LastCheckedAt = now }, cancellationToken); + await claims.RecordEventAsync( + new ClaimEvent(claim.Id, now, ClaimEventKind.CheckRequested), + cancellationToken); + + return true; + } + + /// + /// Withdraws a claim, explicitly. + /// + /// + /// The only way a verified claim ends, along with a counter-claim. Never called because a + /// beacon went missing — see and §8.4. The game stops being + /// claimed only when no verified claim is left, because §8.5 allows several owners and one + /// walking away does not unclaim the game for the others. + /// + public async Task RevokeAsync(Guid claimId, string reason, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(reason); + + if (await claims.FindAsync(claimId, cancellationToken) is not { } claim) + { + return; + } + + var now = time.GetUtcNow(); + + await claims.UpdateAsync( + claim with { RevokedAt = now, RevokedReason = reason }, + cancellationToken); + await claims.RecordEventAsync( + new ClaimEvent(claim.Id, now, ClaimEventKind.Revoked, reason), + cancellationToken); + + var remaining = await claims.ForGameAsync(claim.GameId, cancellationToken); + + if (!remaining.Any(c => c.Id != claim.Id && c.IsVerified)) + { + await games.SetClaimedAsync(claim.GameId, false, cancellationToken); + } + } +} diff --git a/src/MUI.Catalog/Persistence/NpgsqlClaimStore.cs b/src/MUI.Catalog/Persistence/NpgsqlClaimStore.cs new file mode 100644 index 0000000..d38a736 --- /dev/null +++ b/src/MUI.Catalog/Persistence/NpgsqlClaimStore.cs @@ -0,0 +1,253 @@ +using Dapper; + +using Npgsql; + +namespace MUI.Catalog.Persistence; + +/// +/// The game_claim and claim_event tables (spec §8). +/// +/// +/// Nothing here decides whether a claim is legitimate. It stores what the layer above concluded, and +/// the two guarantees it does enforce are the ones a handler cannot be trusted with: a claim always +/// carries the account that asked for it (the column is NOT NULL), and one account holds at +/// most one pending claim per game (a partial unique index). Both are §8.1's ordering made +/// unavoidable rather than remembered. +/// +public sealed class NpgsqlClaimStore(NpgsqlDataSource source) : IClaimStore +{ + private const string Columns = """ + id AS Id, game_id AS GameId, user_id AS UserId, token AS Token, + issued_at AS IssuedAt, expires_at AS ExpiresAt, claimed_at AS ClaimedAt, + beacon_last_seen_at AS BeaconLastSeenAt, verified_via AS VerifiedVia, + revoked_at AS RevokedAt, revoked_reason AS RevokedReason, last_checked_at AS LastCheckedAt + """; + + public async Task FindAsync(Guid claimId, CancellationToken cancellationToken = default) + { + await using var connection = await source.OpenConnectionAsync(cancellationToken); + + var row = await connection.QuerySingleOrDefaultAsync(new CommandDefinition( + $"SELECT {Columns} FROM game_claim WHERE id = @claimId", + new { claimId }, + cancellationToken: cancellationToken)); + + return row?.ToRecord(); + } + + public async Task> ForGameAsync( + Guid gameId, + CancellationToken cancellationToken = default) + { + await using var connection = await source.OpenConnectionAsync(cancellationToken); + + var rows = await connection.QueryAsync(new CommandDefinition( + $"SELECT {Columns} FROM game_claim WHERE game_id = @gameId ORDER BY issued_at DESC", + new { gameId }, + cancellationToken: cancellationToken)); + + return [.. rows.Select(r => r.ToRecord())]; + } + + public async Task> ForUserAsync( + Guid userId, + CancellationToken cancellationToken = default) + { + await using var connection = await source.OpenConnectionAsync(cancellationToken); + + var rows = await connection.QueryAsync(new CommandDefinition( + $"SELECT {Columns} FROM game_claim WHERE user_id = @userId ORDER BY issued_at DESC", + new { userId }, + cancellationToken: cancellationToken)); + + return [.. rows.Select(r => r.ToRecord())]; + } + + /// + /// The live pending claim on holding . + /// + /// + /// Expiry is applied in SQL rather than by the caller. A token that outlived its window is not a + /// match, and leaving that to a downstream if would make an expired token complete a claim + /// on any path that forgot it. + /// + public async Task FindPendingByTokenAsync( + Guid gameId, + string token, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(token); + + await using var connection = await source.OpenConnectionAsync(cancellationToken); + + var row = await connection.QuerySingleOrDefaultAsync(new CommandDefinition( + $""" + SELECT {Columns} + FROM game_claim + WHERE game_id = @gameId + AND token = @token + AND claimed_at IS NULL + AND revoked_at IS NULL + AND expires_at > now() + """, + new { gameId, token }, + cancellationToken: cancellationToken)); + + return row?.ToRecord(); + } + + public async Task InsertAsync(GameClaim claim, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(claim); + + await using var connection = await source.OpenConnectionAsync(cancellationToken); + + await connection.ExecuteAsync(new CommandDefinition( + """ + INSERT INTO game_claim ( + id, game_id, user_id, token, issued_at, expires_at, + claimed_at, beacon_last_seen_at, verified_via, + revoked_at, revoked_reason, last_checked_at) + VALUES ( + @Id, @GameId, @UserId, @Token, @IssuedAt, @ExpiresAt, + @ClaimedAt, @BeaconLastSeenAt, @VerifiedVia, + @RevokedAt, @RevokedReason, @LastCheckedAt) + """, + Parameters(claim), + cancellationToken: cancellationToken)); + } + + public async Task UpdateAsync(GameClaim claim, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(claim); + + await using var connection = await source.OpenConnectionAsync(cancellationToken); + + await connection.ExecuteAsync(new CommandDefinition( + """ + UPDATE game_claim + SET expires_at = @ExpiresAt, + claimed_at = @ClaimedAt, + beacon_last_seen_at = @BeaconLastSeenAt, + verified_via = @VerifiedVia, + revoked_at = @RevokedAt, + revoked_reason = @RevokedReason, + last_checked_at = @LastCheckedAt + WHERE id = @Id + """, + Parameters(claim), + cancellationToken: cancellationToken)); + } + + public async Task RecordEventAsync(ClaimEvent claimEvent, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(claimEvent); + + await using var connection = await source.OpenConnectionAsync(cancellationToken); + + await connection.ExecuteAsync(new CommandDefinition( + """ + INSERT INTO claim_event (claim_id, at, kind, detail) + VALUES (@ClaimId, @At, @Kind, @Detail) + """, + new + { + claimEvent.ClaimId, + claimEvent.At, + Kind = SqlEnums.ToDb(claimEvent.Kind), + claimEvent.Detail, + }, + cancellationToken: cancellationToken)); + } + + public async Task> EventsAsync( + Guid claimId, + CancellationToken cancellationToken = default) + { + await using var connection = await source.OpenConnectionAsync(cancellationToken); + + var rows = await connection.QueryAsync(new CommandDefinition( + "SELECT claim_id AS ClaimId, at AS At, kind AS Kind, detail AS Detail " + + "FROM claim_event WHERE claim_id = @claimId ORDER BY at, id", + new { claimId }, + cancellationToken: cancellationToken)); + + return [.. rows.Select(r => new ClaimEvent( + r.ClaimId, r.At, SqlEnums.ToClaimEventKind(r.Kind), r.Detail))]; + } + + private static object Parameters(GameClaim claim) => new + { + claim.Id, + claim.GameId, + claim.UserId, + claim.Token, + claim.IssuedAt, + claim.ExpiresAt, + claim.ClaimedAt, + claim.BeaconLastSeenAt, + VerifiedVia = claim.VerifiedVia is { } via ? SqlEnums.ToDb(via) : null, + claim.RevokedAt, + claim.RevokedReason, + claim.LastCheckedAt, + }; + + // A class with settable properties rather than a positional record, matching every other store + // here. Dapper materialises a positional record by finding a constructor whose parameter types + // match the reader's, and Npgsql hands back `DateTime` for `timestamptz` — so the record wants a + // `DateTime` constructor and refuses to use a `DateTimeOffset` one. Properties are set + // individually, with the conversion applied per column. + private sealed class Row + { + public Guid Id { get; init; } + + public Guid GameId { get; init; } + + public Guid UserId { get; init; } + + public string Token { get; init; } = string.Empty; + + public DateTimeOffset IssuedAt { get; init; } + + public DateTimeOffset ExpiresAt { get; init; } + + public DateTimeOffset? ClaimedAt { get; init; } + + public DateTimeOffset? BeaconLastSeenAt { get; init; } + + public string? VerifiedVia { get; init; } + + public DateTimeOffset? RevokedAt { get; init; } + + public string? RevokedReason { get; init; } + + public DateTimeOffset? LastCheckedAt { get; init; } + + public GameClaim ToRecord() => new() + { + Id = Id, + GameId = GameId, + UserId = UserId, + Token = Token, + IssuedAt = IssuedAt, + ExpiresAt = ExpiresAt, + ClaimedAt = ClaimedAt, + BeaconLastSeenAt = BeaconLastSeenAt, + VerifiedVia = VerifiedVia is null ? null : SqlEnums.ToClaimChannel(VerifiedVia), + RevokedAt = RevokedAt, + RevokedReason = RevokedReason, + LastCheckedAt = LastCheckedAt, + }; + } + + private sealed class EventRow + { + public Guid ClaimId { get; init; } + + public DateTimeOffset At { get; init; } + + public string Kind { get; init; } = string.Empty; + + public string? Detail { get; init; } + } +} diff --git a/src/MUI.Catalog/Persistence/NpgsqlGameStore.cs b/src/MUI.Catalog/Persistence/NpgsqlGameStore.cs index 82a82a0..1445b92 100644 --- a/src/MUI.Catalog/Persistence/NpgsqlGameStore.cs +++ b/src/MUI.Catalog/Persistence/NpgsqlGameStore.cs @@ -89,6 +89,19 @@ UPDATE game cancellationToken: cancellationToken)); } + public async Task SetClaimedAsync( + Guid id, + bool isClaimed, + CancellationToken cancellationToken = default) + { + await using var connection = await source.OpenConnectionAsync(cancellationToken); + + await connection.ExecuteAsync(new CommandDefinition( + "UPDATE game SET is_claimed = @isClaimed WHERE id = @id", + new { id, isClaimed }, + cancellationToken: cancellationToken)); + } + public async Task MarkReachableAsync( Guid id, DateTimeOffset at, diff --git a/src/MUI.Catalog/Persistence/SqlEnums.cs b/src/MUI.Catalog/Persistence/SqlEnums.cs index 52fa249..898c069 100644 --- a/src/MUI.Catalog/Persistence/SqlEnums.cs +++ b/src/MUI.Catalog/Persistence/SqlEnums.cs @@ -33,6 +33,52 @@ public static class SqlEnums _ => throw Unread(value, nameof(FieldSource)), }; + /// + /// The channel a claim token was read from (spec §8.3). DNS is absent from the enum, not merely + /// unmapped here — a TXT record proves control of a hostname, and a hostname is not a game. + /// + public static string ToDb(ClaimChannel channel) => channel switch + { + ClaimChannel.Mssp => "mssp", + ClaimChannel.ConnectScreen => "connect_screen", + _ => throw Unmapped(channel), + }; + + public static ClaimChannel ToClaimChannel(string value) => value switch + { + "mssp" => ClaimChannel.Mssp, + "connect_screen" => ClaimChannel.ConnectScreen, + _ => throw Unread(value, nameof(ClaimChannel)), + }; + + public static string ToDb(ClaimEventKind kind) => kind switch + { + ClaimEventKind.Issued => "issued", + ClaimEventKind.Reissued => "reissued", + ClaimEventKind.Verified => "verified", + ClaimEventKind.BeaconSeen => "beacon_seen", + ClaimEventKind.BeaconMissing => "beacon_missing", + ClaimEventKind.Revoked => "revoked", + ClaimEventKind.Expired => "expired", + ClaimEventKind.CounterClaimed => "counter_claimed", + ClaimEventKind.CheckRequested => "check_requested", + _ => throw Unmapped(kind), + }; + + public static ClaimEventKind ToClaimEventKind(string value) => value switch + { + "issued" => ClaimEventKind.Issued, + "reissued" => ClaimEventKind.Reissued, + "verified" => ClaimEventKind.Verified, + "beacon_seen" => ClaimEventKind.BeaconSeen, + "beacon_missing" => ClaimEventKind.BeaconMissing, + "revoked" => ClaimEventKind.Revoked, + "expired" => ClaimEventKind.Expired, + "counter_claimed" => ClaimEventKind.CounterClaimed, + "check_requested" => ClaimEventKind.CheckRequested, + _ => throw Unread(value, nameof(ClaimEventKind)), + }; + public static string ToDb(AvailabilityState state) => state switch { // Reachable, never up (spec §5.8). We measured a socket from one vantage point; we did not diff --git a/src/MUI.Catalog/Persistence/Stores.cs b/src/MUI.Catalog/Persistence/Stores.cs index 23d8b69..71b8907 100644 --- a/src/MUI.Catalog/Persistence/Stores.cs +++ b/src/MUI.Catalog/Persistence/Stores.cs @@ -25,6 +25,16 @@ Task SetStateAsync( /// Records that the game answered, which is what §7.5's grace is measured from. Task MarkReachableAsync(Guid id, DateTimeOffset at, CancellationToken cancellationToken = default); + /// + /// Sets whether any account has proved control of this game (spec §8). + /// + /// + /// A cache of "does a verified claim exist", denormalised onto the game because the listing reads + /// it for every row and §7.5's grace reads it on every sweep. owns it; + /// nothing else may write it, or the flag and the claims it summarises will drift. + /// + Task SetClaimedAsync(Guid id, bool isClaimed, CancellationToken cancellationToken = default); + /// /// Games eligible for the archive sweep: everything not already archived. Deliberately not /// "everything dark" — the sweeper computes darkness from the availability series, and a diff --git a/src/MUI.Crawler.Cli/Program.cs b/src/MUI.Crawler.Cli/Program.cs index 7b0dd4e..64716e2 100644 --- a/src/MUI.Crawler.Cli/Program.cs +++ b/src/MUI.Crawler.Cli/Program.cs @@ -96,6 +96,9 @@ new HostGate(), discovery, time, + // §8 — a probe of a claimed game refreshes what we last saw, and a probe of a game whose owner + // has just published their token settles the claim. Both happen on the ordinary schedule. + new ClaimService(new NpgsqlClaimStore(source), games, time), loggerFactory.CreateLogger()); if (arguments.DryRun) diff --git a/src/MUI.Crawler/CrawlCycle.cs b/src/MUI.Crawler/CrawlCycle.cs index 1b7a4e3..796f8ae 100644 --- a/src/MUI.Crawler/CrawlCycle.cs +++ b/src/MUI.Crawler/CrawlCycle.cs @@ -1,3 +1,4 @@ +using MUI.Catalog.Persistence; using MUI.Catalog; using MUI.Crawl; using MUI.Discovery; @@ -47,6 +48,9 @@ public sealed class CrawlCycle( HostGate gate, DiscoveryOptions options, TimeProvider time, + // Optional, and null on every path that has no database behind it. A crawl that cannot settle + // claims is a crawl doing slightly less, not a crawl that should refuse to run. + ClaimService? claims = null, ILogger? logger = null) { /// Probes everything that is due, and returns what the pass did. @@ -206,6 +210,39 @@ private async Task UnresolvableAsync( await StoreAsync(target, result, tally, cancellationToken); } + /// + /// Offers whatever claim beacon this probe carried to the claim store (spec §8.1). + /// + /// + /// + /// This is the whole of the verification step, and it is deliberately this small: the crawler + /// reads the beacon and knows nothing about what it means, and decides + /// and knows nothing about sockets. Every probe of a claimed game passes through here, which is + /// also how beacon_last_seen_at stays current without a second schedule. + /// + /// + /// A probe that read no beacon does nothing at all, rather than reporting an absence. §8.4: + /// presence establishes, absence never revokes — and a silence here would be indistinguishable + /// from a compression bug eating the subnegotiation that carried it. + /// + /// + private async Task SettleClaimsAsync(Guid gameId, ProbeResult result, CancellationToken cancellationToken) + { + if (claims is null || ClaimTokenBeacon.Find(result) is not { } beacon) + { + return; + } + + var verdict = await claims.OfferBeaconAsync(gameId, beacon.Token, beacon.Channel, cancellationToken); + + if (verdict is ClaimVerdict.Verified) + { + logger?.LogInformation( + "{Host}:{Port} published a claim token we issued; the claim is verified via {Channel}", + result.Host, result.Port, beacon.Channel); + } + } + private async Task StoreAsync( CrawlTarget target, ProbeResult result, @@ -234,6 +271,8 @@ private async Task StoreAsync( var intake = await referrals.ApplyAsync( binding.GameId, target.Depth, result, cancellationToken); tally.Referred(intake); + + await SettleClaimsAsync(binding.GameId, result, cancellationToken); } } diff --git a/src/MUI.Discovery/Identity.cs b/src/MUI.Discovery/Identity.cs index 065a086..1b36641 100644 --- a/src/MUI.Discovery/Identity.cs +++ b/src/MUI.Discovery/Identity.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using MUI.Catalog; using MUI.Crawl; using MUI.Catalog.Persistence; @@ -187,7 +188,20 @@ public static class ClaimTokenBeacon [MsspVariable, "MUINDEX_CLAIM", "CONTACT_TOKEN"]; /// The token this probe carries, from any channel a probe can see, or null. - public static string? Read(ProbeResult result) + /// + /// The identity matcher wants the value and not the provenance — a token is decisive wherever it + /// was published. is the arm that also says which channel, which is what the + /// claim record stores so an owner can be told what we actually saw. + /// + public static string? Read(ProbeResult result) => Find(result)?.Token; + + /// The token and the channel it was read from, or null. + /// + /// MSSP is looked at before the connect screen because it is the channel we ask for first and the + /// one that survives a screen redesign. A server publishing the token in both is reported as MSSP, + /// which is true and is the more durable of the two. + /// + public static ClaimBeacon? Find(ProbeResult result) { ArgumentNullException.ThrowIfNull(result); @@ -195,7 +209,7 @@ public static class ClaimTokenBeacon { if (MsspReading.Value(result.Mssp, variable) is { } declared && !string.IsNullOrWhiteSpace(declared)) { - return declared.Trim(); + return new ClaimBeacon(declared.Trim(), ClaimChannel.Mssp); } } @@ -215,10 +229,13 @@ public static class ClaimTokenBeacon var rest = plain[(start + ConnectScreenPrefix.Length)..].TrimStart(); var labelled = new string(rest.TakeWhile(ch => !char.IsWhiteSpace(ch)).ToArray()); - return labelled.Length > 0 ? labelled : null; + return labelled.Length > 0 ? new ClaimBeacon(labelled, ClaimChannel.ConnectScreen) : null; } } +/// A claim token read off a server, and where it was published (spec §8.3). +public sealed record ClaimBeacon(string Token, ClaimChannel Channel); + /// /// Reading an MSSP report without ever treating a codebase default as an answer. /// diff --git a/tests/MUI.Catalog.Tests/Persistence/ClaimPostgresTests.cs b/tests/MUI.Catalog.Tests/Persistence/ClaimPostgresTests.cs new file mode 100644 index 0000000..bf63694 --- /dev/null +++ b/tests/MUI.Catalog.Tests/Persistence/ClaimPostgresTests.cs @@ -0,0 +1,342 @@ +using Dapper; + +using MUI.Catalog.Persistence; +using MUI.Catalog.Tests.Persistence.Support; + +using Npgsql; + +namespace MUI.Catalog.Tests.Persistence; + +/// +/// Spec §8 against a real database: who a verified claim belongs to, and what a beacon means. +/// +/// +/// The properties here are the ones a handler cannot be trusted with, so several are asserted against +/// the schema rather than against the service — a constraint that fires is a guarantee, and a branch +/// in C# is an intention. +/// +public class ClaimPostgresTests +{ + private static readonly DateTimeOffset Now = Seed.Now; + + /// + /// The whole point of §8.1: the claim binds to the account that asked, not to the token holder. + /// + /// + /// We ask an operator to publish the token where every anonymous connection reads it, so a design + /// in which holding it confers anything is broken the instant it succeeds. Mallory reads Alice's + /// token off the connect screen and can do nothing with it. + /// + [Test] + public async Task AVerifiedClaimBelongsToTheAccountThatMintedTheTokenNotWhoeverHoldsIt() + { + await using var db = await PostgresFixture.MigratedAsync(); + var game = await Seed.GameAsync(db); + var alice = await UserAsync(db, "alice"); + var mallory = await UserAsync(db, "mallory"); + var service = Service(db); + + var alices = await service.IssueAsync(game, alice); + + // Mallory has her own pending claim on the same game, and has read Alice's token. + var mallorys = await service.IssueAsync(game, mallory); + await Assert.That(mallorys.Token).IsNotEqualTo(alices.Token); + + var verdict = await service.OfferBeaconAsync(game, alices.Token, ClaimChannel.Mssp); + + await Assert.That(verdict).IsEqualTo(ClaimVerdict.Verified); + + var store = new NpgsqlClaimStore(db.DataSource); + var settled = (await store.ForGameAsync(game)).Single(c => c.Id == alices.Id); + + await Assert.That(settled.UserId).IsEqualTo(alice); + await Assert.That(settled.VerifiedVia).IsEqualTo(ClaimChannel.Mssp); + + var hers = (await store.ForGameAsync(game)).Single(c => c.Id == mallorys.Id); + await Assert.That(hers.IsVerified).IsFalse(); + } + + /// §8.4 — presence establishes, absence never revokes. + /// + /// Absence-revokes would hand revocation to any transient failure: a restart, an MSSP hiccup, a + /// compression bug eating a subnegotiation. This project has watched MCCP swallow a connection's + /// payload whole, and a silent unclaiming on that basis is indistinguishable from an owner walking + /// away. + /// + [Test] + public async Task AProbeThatSeesNoBeaconLeavesAVerifiedClaimAlone() + { + await using var db = await PostgresFixture.MigratedAsync(); + var game = await Seed.GameAsync(db); + var user = await UserAsync(db, "owner"); + var clock = new MovableClock(Now); + var service = Service(db, clock); + + var claim = await service.IssueAsync(game, user); + await service.OfferBeaconAsync(game, claim.Token, ClaimChannel.ConnectScreen); + + // Two probes later: one saw nothing at all, one saw a token we never issued. + clock.Advance(TimeSpan.FromDays(1)); + await service.OfferBeaconAsync(game, null, ClaimChannel.Mssp); + await service.OfferBeaconAsync(game, "muidx-aaaaaaaaaaaaaaaaaaaa", ClaimChannel.Mssp); + + var settled = (await new NpgsqlClaimStore(db.DataSource).ForGameAsync(game)).Single(); + + await Assert.That(settled.IsVerified).IsTrue(); + await Assert.That(settled.BeaconLastSeenAt).IsEqualTo(Now); + await Assert.That(await IsClaimedAsync(db, game)).IsTrue(); + } + + /// Seeing it again refreshes the second timestamp and touches nothing else. + [Test] + public async Task SeeingTheBeaconAgainMovesOnlyWhenWeLastSawIt() + { + await using var db = await PostgresFixture.MigratedAsync(); + var game = await Seed.GameAsync(db); + var user = await UserAsync(db, "owner"); + var clock = new MovableClock(Now); + var service = Service(db, clock); + + var claim = await service.IssueAsync(game, user); + await service.OfferBeaconAsync(game, claim.Token, ClaimChannel.Mssp); + + clock.Advance(TimeSpan.FromDays(3)); + var verdict = await service.OfferBeaconAsync(game, claim.Token, ClaimChannel.Mssp); + + await Assert.That(verdict).IsEqualTo(ClaimVerdict.StillSeen); + + var settled = (await new NpgsqlClaimStore(db.DataSource).ForGameAsync(game)).Single(); + + await Assert.That(settled.ClaimedAt).IsEqualTo(Now); + await Assert.That(settled.BeaconLastSeenAt).IsEqualTo(Now.AddDays(3)); + } + + /// + /// A second attempt returns the token already published rather than minting a rival. + /// + /// + /// Replacing it would invalidate what the operator has just finished pasting into + /// mush.cnf — the most annoying possible failure, because it looks like the site being + /// broken. The partial unique index makes it a schema guarantee rather than a service courtesy. + /// + [Test] + public async Task AskingTwiceReturnsTheTokenAlreadyPublished() + { + await using var db = await PostgresFixture.MigratedAsync(); + var game = await Seed.GameAsync(db); + var user = await UserAsync(db, "owner"); + var service = Service(db); + + var first = await service.IssueAsync(game, user); + var second = await service.IssueAsync(game, user); + + await Assert.That(second.Id).IsEqualTo(first.Id); + await Assert.That(second.Token).IsEqualTo(first.Token); + + var all = await new NpgsqlClaimStore(db.DataSource).ForGameAsync(game); + await Assert.That(all.Count).IsEqualTo(1); + } + + /// An expired token proves nothing, and expiry is applied by the query, not the caller. + [Test] + public async Task AnExpiredTokenDoesNotCompleteAClaim() + { + await using var db = await PostgresFixture.MigratedAsync(); + var game = await Seed.GameAsync(db); + var user = await UserAsync(db, "owner"); + var clock = new MovableClock(Now); + var service = Service(db, clock); + + var claim = await service.IssueAsync(game, user); + + clock.Advance(ClaimToken.PendingLifetime + TimeSpan.FromDays(1)); + await ExpireAsync(db, claim.Id); + + var verdict = await service.OfferBeaconAsync(game, claim.Token, ClaimChannel.Mssp); + + await Assert.That(verdict).IsEqualTo(ClaimVerdict.Stale); + await Assert.That(await IsClaimedAsync(db, game)).IsFalse(); + } + + /// §8.5 — several owners, and one leaving does not unclaim the game for the rest. + [Test] + public async Task RevokingOneOfTwoOwnersLeavesTheGameClaimed() + { + await using var db = await PostgresFixture.MigratedAsync(); + var game = await Seed.GameAsync(db); + var one = await UserAsync(db, "one"); + var two = await UserAsync(db, "two"); + var service = Service(db); + + var first = await service.IssueAsync(game, one); + await service.OfferBeaconAsync(game, first.Token, ClaimChannel.Mssp); + + var secondClaim = await service.IssueAsync(game, two); + await service.OfferBeaconAsync(game, secondClaim.Token, ClaimChannel.ConnectScreen); + + await service.RevokeAsync(first.Id, "handed the game over"); + + await Assert.That(await IsClaimedAsync(db, game)).IsTrue(); + + await service.RevokeAsync(secondClaim.Id, "closed the game"); + + await Assert.That(await IsClaimedAsync(db, game)).IsFalse(); + } + + /// The audit log is append-only and records what actually happened (§8.5). + [Test] + public async Task EveryStepOfAClaimIsInTheAuditLog() + { + await using var db = await PostgresFixture.MigratedAsync(); + var game = await Seed.GameAsync(db); + var user = await UserAsync(db, "owner"); + var service = Service(db); + var store = new NpgsqlClaimStore(db.DataSource); + + var claim = await service.IssueAsync(game, user); + await service.OfferBeaconAsync(game, claim.Token, ClaimChannel.Mssp); + await service.RevokeAsync(claim.Id, "moved house"); + + var kinds = (await store.EventsAsync(claim.Id)).Select(e => e.Kind).ToList(); + + await Assert.That(kinds).Contains(ClaimEventKind.Issued); + await Assert.That(kinds).Contains(ClaimEventKind.Verified); + await Assert.That(kinds).Contains(ClaimEventKind.Revoked); + } + + /// + /// The database refuses a claim with no account, whatever a handler above it believes. + /// + /// + /// §8.1's ordering — sign in, then claim — is a NOT NULL here rather than a rule someone + /// remembers, so a token that verified without anybody having asked has nowhere to go. + /// + [Test] + public async Task TheSchemaRefusesAClaimWithNoAccount() + { + await using var db = await PostgresFixture.MigratedAsync(); + var game = await Seed.GameAsync(db); + + await using var connection = await db.DataSource.OpenConnectionAsync(); + + await Assert.That(async () => await connection.ExecuteAsync( + """ + INSERT INTO game_claim (id, game_id, user_id, token, issued_at, expires_at) + VALUES (@id, @game, NULL, 'muidx-aaaaaaaaaaaaaaaaaaaa', @now, @later) + """, + new { id = Guid.CreateVersion7(), game, now = Now, later = Now.AddDays(30) })) + .Throws(); + } + + /// + /// One token cannot exist on two claims, or one game's published token would complete another's. + /// + [Test] + public async Task TheSchemaRefusesTheSameTokenTwice() + { + await using var db = await PostgresFixture.MigratedAsync(); + var first = await Seed.GameAsync(db, slug: "one", name: "One"); + var second = await Seed.GameAsync(db, slug: "two", name: "Two"); + var user = await UserAsync(db, "owner"); + var store = new NpgsqlClaimStore(db.DataSource); + + await store.InsertAsync(Claim(first, user, "muidx-aaaaaaaaaaaaaaaaaaaa")); + + await Assert.That(async () => await store.InsertAsync( + Claim(second, user, "muidx-aaaaaaaaaaaaaaaaaaaa"))) + .Throws(); + } + + /// A verified row must say which channel it was read from — half a record is not one. + [Test] + public async Task TheSchemaRefusesAVerifiedClaimWithNoChannel() + { + await using var db = await PostgresFixture.MigratedAsync(); + var game = await Seed.GameAsync(db); + var user = await UserAsync(db, "owner"); + + await using var connection = await db.DataSource.OpenConnectionAsync(); + + await Assert.That(async () => await connection.ExecuteAsync( + """ + INSERT INTO game_claim (id, game_id, user_id, token, issued_at, expires_at, claimed_at) + VALUES (@id, @game, @user, 'muidx-aaaaaaaaaaaaaaaaaaaa', @now, @later, @now) + """, + new + { + id = Guid.CreateVersion7(), + game, + user, + now = Now, + later = Now.AddDays(30), + })) + .Throws(); + } + + private static GameClaim Claim(Guid game, Guid user, string token) => new() + { + Id = Guid.CreateVersion7(), + GameId = game, + UserId = user, + Token = token, + IssuedAt = Now, + ExpiresAt = Now.AddDays(30), + }; + + private static ClaimService Service(TestDatabase db, TimeProvider? time = null) => + new( + new NpgsqlClaimStore(db.DataSource), + new NpgsqlGameStore(db.DataSource), + time ?? new MovableClock(Now)); + + private static async Task UserAsync(TestDatabase db, string name) + { + var id = Guid.CreateVersion7(); + + await using var connection = await db.DataSource.OpenConnectionAsync(); + + await connection.ExecuteAsync( + """ + INSERT INTO app_user (id, display_name, normalised_name, security_stamp, + concurrency_stamp, created_at) + VALUES (@id, @name, @normalised, @stamp, @stamp, @now) + """, + new + { + id, + name, + normalised = name.ToUpperInvariant(), + stamp = Guid.NewGuid().ToString(), + now = Now, + }); + + return id; + } + + private static async Task IsClaimedAsync(TestDatabase db, Guid game) + { + await using var connection = await db.DataSource.OpenConnectionAsync(); + + return await connection.ExecuteScalarAsync( + "SELECT is_claimed FROM game WHERE id = @game", new { game }); + } + + private static async Task ExpireAsync(TestDatabase db, Guid claim) + { + await using var connection = await db.DataSource.OpenConnectionAsync(); + + await connection.ExecuteAsync( + "UPDATE game_claim SET expires_at = now() - interval '1 day' WHERE id = @claim", + new { claim }); + } + + /// A clock a test can push forward, so "later" is a fact rather than a sleep. + private sealed class MovableClock(DateTimeOffset start) : TimeProvider + { + private DateTimeOffset _now = start; + + public override DateTimeOffset GetUtcNow() => _now; + + public void Advance(TimeSpan by) => _now += by; + } +} diff --git a/tests/MUI.Catalog.Tests/Persistence/Support/InMemoryPersistence.cs b/tests/MUI.Catalog.Tests/Persistence/Support/InMemoryPersistence.cs index 2227f1c..c586328 100644 --- a/tests/MUI.Catalog.Tests/Persistence/Support/InMemoryPersistence.cs +++ b/tests/MUI.Catalog.Tests/Persistence/Support/InMemoryPersistence.cs @@ -52,6 +52,16 @@ public Task SetStateAsync( return Task.CompletedTask; } + public Task SetClaimedAsync(Guid id, bool isClaimed, CancellationToken cancellationToken = default) + { + if (_games.TryGetValue(id, out var game)) + { + _games[id] = game with { IsClaimed = isClaimed }; + } + + return Task.CompletedTask; + } + public Task MarkReachableAsync(Guid id, DateTimeOffset at, CancellationToken cancellationToken = default) { if (_games.TryGetValue(id, out var game) diff --git a/tests/MUI.Crawler.Tests/Support/InMemoryCatalogue.cs b/tests/MUI.Crawler.Tests/Support/InMemoryCatalogue.cs index d76b723..904a21a 100644 --- a/tests/MUI.Crawler.Tests/Support/InMemoryCatalogue.cs +++ b/tests/MUI.Crawler.Tests/Support/InMemoryCatalogue.cs @@ -56,6 +56,16 @@ public Task SetStateAsync( return Task.CompletedTask; } + public Task SetClaimedAsync(Guid id, bool isClaimed, CancellationToken cancellationToken = default) + { + if (_games.TryGetValue(id, out var game)) + { + _games[id] = game with { IsClaimed = isClaimed }; + } + + return Task.CompletedTask; + } + public Task MarkReachableAsync(Guid id, DateTimeOffset at, CancellationToken cancellationToken = default) { if (_games.TryGetValue(id, out var game) && (game.LastReachableAt is null || game.LastReachableAt < at)) From ff81066be7507a6d6a6dfa64a8c1942b3dd4eaa1 Mon Sep 17 00:00:00 2001 From: Harry Cordewener Date: Fri, 31 Jul 2026 09:39:55 -0500 Subject: [PATCH 2/3] Identity over Dapper, so passkeys cost four tables and no second ORM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ASP.NET Core Identity's default store is EF Core. Bringing it in for four tables would be the wrong trade in a codebase that has kept its SQL visible and its migrations hand-numbered, and the whole of what Identity needs is two interfaces -- IUserStore and IUserPasskeyStore -- which is a page of SQL. migrations/0007 stays the single description of these tables. Only what this app uses is implemented. There is no IUserPasswordStore, no IUserEmailStore, no lockout and no two-factor store, because there are no passwords, no addresses and no second factor -- a passkey is a primary factor (§8.2). An unimplemented interface here is a feature we do not have rather than a gap: adding the password store would make UserManager start offering flows this site has no pages for. The schema gained two columns after reading Identity's actual contract rather than guessing at it: UserPasskeyInfo carries client_data_json and is_user_verified, and a store that drops half a record hands back something that is not what was registered. Transports is a text[] so a round trip cannot re-delimit what the authenticator reported. Four schema guarantees are now pinned where they are enforced. Deleting an account takes its passkeys and is refused while it still owns a claim -- the asymmetry is deliberate, because a passkey is a way in and a claim is what tells the world a game is owned. The pending-claim index is partial, so a verified or revoked claim does not lock an account out of a game it can still prove control of. And the audit vocabulary is checked in both directions: a CHECK that refuses a value the code can produce fails in production rather than in a test. Co-Authored-By: Claude Opus 5 --- migrations/0007_ownership.sql | 14 +- src/MUI.Web/Accounts/DapperUserStore.cs | 377 ++++++++++++++++++ src/MUI.Web/Accounts/MuiUser.cs | 37 ++ .../Persistence/OwnershipSchemaTests.cs | 186 +++++++++ 4 files changed, 612 insertions(+), 2 deletions(-) create mode 100644 src/MUI.Web/Accounts/DapperUserStore.cs create mode 100644 src/MUI.Web/Accounts/MuiUser.cs create mode 100644 tests/MUI.Catalog.Tests/Persistence/OwnershipSchemaTests.cs diff --git a/migrations/0007_ownership.sql b/migrations/0007_ownership.sql index f342824..cb9f018 100644 --- a/migrations/0007_ownership.sql +++ b/migrations/0007_ownership.sql @@ -65,9 +65,19 @@ CREATE TABLE user_passkey ( is_backed_up boolean NOT NULL DEFAULT false, is_backup_eligible boolean NOT NULL DEFAULT false, - transports text, + -- Identity hands these back as a string array; the column is one so that a round trip through + -- storage cannot silently reorder or re-delimit what the authenticator reported. + transports text[], + + -- Whether the authenticator verified a human at registration (biometric or PIN) rather than + -- merely detecting a touch. Identity carries it per credential, so it is stored per credential. + is_user_verified boolean NOT NULL DEFAULT false, + attestation_object bytea, - attestation_format text, + + -- Kept because Identity's UserPasskeyInfo carries it and a store that drops half a record hands + -- back something that is not what was registered. + client_data_json bytea, -- "My phone", "the yubikey in the drawer". A person with three passkeys needs to know which is -- which before they can revoke one, so this is bounded rather than free — see §8's note on diff --git a/src/MUI.Web/Accounts/DapperUserStore.cs b/src/MUI.Web/Accounts/DapperUserStore.cs new file mode 100644 index 0000000..01b67e3 --- /dev/null +++ b/src/MUI.Web/Accounts/DapperUserStore.cs @@ -0,0 +1,377 @@ +using Dapper; + +using Microsoft.AspNetCore.Identity; + +using Npgsql; + +namespace MUI.Web.Accounts; + +/// +/// ASP.NET Core Identity's user and passkey stores, over the same Dapper and plain SQL as everything +/// else here. +/// +/// +/// +/// Identity's default store is EF Core, and bringing EF Core in for four tables would be the +/// wrong trade in a codebase that has deliberately kept its SQL visible and its migrations +/// hand-numbered. The whole of what Identity needs from us is the interfaces below; implementing +/// them is a page of SQL, and it keeps `migrations/0007_ownership.sql` the single description of +/// these tables. +/// +/// +/// Only the interfaces this app actually uses are implemented. There is no +/// IUserPasswordStore, no IUserEmailStore, no lockout and no two-factor store, because +/// there are no passwords, no email addresses and no second factor — a passkey is a primary factor +/// (§8.2). An unimplemented interface here is a feature we do not have, not a gap: adding +/// IUserPasswordStore would make start offering password +/// flows this site has no pages for. +/// +/// +public sealed class DapperUserStore(NpgsqlDataSource source, TimeProvider time) + : IUserStore, IUserPasskeyStore +{ + private const string UserColumns = """ + id AS Id, display_name AS DisplayName, normalised_name AS NormalisedName, + security_stamp AS SecurityStamp, concurrency_stamp AS ConcurrencyStamp, + created_at AS CreatedAt, last_signed_in_at AS LastSignedInAt + """; + + private const string PasskeyColumns = """ + credential_id AS CredentialId, public_key AS PublicKey, sign_count AS SignCount, + is_backed_up AS IsBackedUp, is_backup_eligible AS IsBackupEligible, + is_user_verified AS IsUserVerified, transports AS Transports, + attestation_object AS AttestationObject, client_data_json AS ClientDataJson, + name AS Name, created_at AS CreatedAt + """; + + public Task GetUserIdAsync(MuiUser user, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(user); + + return Task.FromResult(user.Id.ToString()); + } + + public Task GetUserNameAsync(MuiUser user, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(user); + + return Task.FromResult(user.DisplayName); + } + + public Task SetUserNameAsync(MuiUser user, string? userName, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(user); + + user.DisplayName = userName ?? string.Empty; + + return Task.CompletedTask; + } + + public Task GetNormalizedUserNameAsync(MuiUser user, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(user); + + return Task.FromResult(user.NormalisedName); + } + + public Task SetNormalizedUserNameAsync( + MuiUser user, + string? normalizedName, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(user); + + user.NormalisedName = normalizedName ?? string.Empty; + + return Task.CompletedTask; + } + + public async Task CreateAsync(MuiUser user, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(user); + + user.CreatedAt = user.CreatedAt == default ? time.GetUtcNow() : user.CreatedAt; + + await using var connection = await source.OpenConnectionAsync(cancellationToken); + + try + { + await connection.ExecuteAsync(new CommandDefinition( + """ + INSERT INTO app_user (id, display_name, normalised_name, security_stamp, + concurrency_stamp, created_at, last_signed_in_at) + VALUES (@Id, @DisplayName, @NormalisedName, @SecurityStamp, + @ConcurrencyStamp, @CreatedAt, @LastSignedInAt) + """, + user, + cancellationToken: cancellationToken)); + } + catch (PostgresException error) when (error.SqlState == "23505") + { + // The unique index on the normalised name, which is the only way two accounts can + // collide. Reported as a validation failure rather than thrown, because it is a thing a + // person did and not a thing that went wrong. + return IdentityResult.Failed(new IdentityError + { + Code = "DuplicateUserName", + Description = $"The name '{user.DisplayName}' is taken.", + }); + } + + return IdentityResult.Success; + } + + public async Task UpdateAsync(MuiUser user, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(user); + + await using var connection = await source.OpenConnectionAsync(cancellationToken); + + // Guarded on the concurrency stamp we read, and a new one written in the same statement. + // Identity's own contract: a lost update here would silently discard whatever the other + // writer did. + var previous = user.ConcurrencyStamp; + user.ConcurrencyStamp = Guid.NewGuid().ToString(); + + var rows = await connection.ExecuteAsync(new CommandDefinition( + """ + UPDATE app_user + SET display_name = @DisplayName, + normalised_name = @NormalisedName, + security_stamp = @SecurityStamp, + concurrency_stamp = @ConcurrencyStamp, + last_signed_in_at = @LastSignedInAt + WHERE id = @Id AND concurrency_stamp = @Previous + """, + new + { + user.Id, + user.DisplayName, + user.NormalisedName, + user.SecurityStamp, + user.ConcurrencyStamp, + user.LastSignedInAt, + Previous = previous, + }, + cancellationToken: cancellationToken)); + + return rows == 1 + ? IdentityResult.Success + : IdentityResult.Failed(new IdentityError + { + Code = "ConcurrencyFailure", + Description = "This account was changed somewhere else. Reload and try again.", + }); + } + + public async Task DeleteAsync(MuiUser user, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(user); + + await using var connection = await source.OpenConnectionAsync(cancellationToken); + + // Passkeys cascade. Claims do not, and the foreign key refuses the delete while any exist — + // deliberately, because a claim is what tells the world a game is owned, and dropping the + // account behind it would leave `game.is_claimed` true with nobody attached. Revoke first. + await connection.ExecuteAsync(new CommandDefinition( + "DELETE FROM app_user WHERE id = @Id", + new { user.Id }, + cancellationToken: cancellationToken)); + + return IdentityResult.Success; + } + + public async Task FindByIdAsync(string userId, CancellationToken cancellationToken) + { + if (!Guid.TryParse(userId, out var id)) + { + return null; + } + + await using var connection = await source.OpenConnectionAsync(cancellationToken); + + return await connection.QuerySingleOrDefaultAsync(new CommandDefinition( + $"SELECT {UserColumns} FROM app_user WHERE id = @id", + new { id }, + cancellationToken: cancellationToken)); + } + + public async Task FindByNameAsync( + string normalizedUserName, + CancellationToken cancellationToken) + { + await using var connection = await source.OpenConnectionAsync(cancellationToken); + + return await connection.QuerySingleOrDefaultAsync(new CommandDefinition( + $"SELECT {UserColumns} FROM app_user WHERE normalised_name = @name", + new { name = normalizedUserName }, + cancellationToken: cancellationToken)); + } + + public async Task AddOrUpdatePasskeyAsync( + MuiUser user, + UserPasskeyInfo passkey, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(user); + ArgumentNullException.ThrowIfNull(passkey); + + await using var connection = await source.OpenConnectionAsync(cancellationToken); + + // Upsert, because Identity calls this both to register a credential and to write back the + // signature counter after every sign-in. Two methods here would mean a caller could update + // the counter of a credential that no longer exists, or register one twice. + await connection.ExecuteAsync(new CommandDefinition( + """ + INSERT INTO user_passkey ( + credential_id, user_id, public_key, sign_count, is_backed_up, is_backup_eligible, + is_user_verified, transports, attestation_object, client_data_json, name, + created_at, last_used_at) + VALUES ( + @CredentialId, @UserId, @PublicKey, @SignCount, @IsBackedUp, @IsBackupEligible, + @IsUserVerified, @Transports, @AttestationObject, @ClientDataJson, @Name, + @CreatedAt, @LastUsedAt) + ON CONFLICT (credential_id) DO UPDATE + SET sign_count = EXCLUDED.sign_count, + is_backed_up = EXCLUDED.is_backed_up, + is_backup_eligible = EXCLUDED.is_backup_eligible, + is_user_verified = EXCLUDED.is_user_verified, + name = EXCLUDED.name, + last_used_at = EXCLUDED.last_used_at + """, + new + { + passkey.CredentialId, + UserId = user.Id, + passkey.PublicKey, + SignCount = (long)passkey.SignCount, + passkey.IsBackedUp, + passkey.IsBackupEligible, + passkey.IsUserVerified, + Transports = passkey.Transports, + passkey.AttestationObject, + passkey.ClientDataJson, + Name = Trim(passkey.Name), + passkey.CreatedAt, + LastUsedAt = time.GetUtcNow(), + }, + cancellationToken: cancellationToken)); + } + + public async Task> GetPasskeysAsync( + MuiUser user, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(user); + + await using var connection = await source.OpenConnectionAsync(cancellationToken); + + var rows = await connection.QueryAsync(new CommandDefinition( + $"SELECT {PasskeyColumns} FROM user_passkey WHERE user_id = @id ORDER BY created_at", + new { id = user.Id }, + cancellationToken: cancellationToken)); + + return [.. rows.Select(r => r.ToInfo())]; + } + + public async Task FindByPasskeyIdAsync( + byte[] credentialId, + CancellationToken cancellationToken) + { + await using var connection = await source.OpenConnectionAsync(cancellationToken); + + return await connection.QuerySingleOrDefaultAsync(new CommandDefinition( + """ + SELECT u.id AS Id, u.display_name AS DisplayName, u.normalised_name AS NormalisedName, + u.security_stamp AS SecurityStamp, u.concurrency_stamp AS ConcurrencyStamp, + u.created_at AS CreatedAt, u.last_signed_in_at AS LastSignedInAt + FROM app_user u + JOIN user_passkey p ON p.user_id = u.id + WHERE p.credential_id = @credentialId + """, + new { credentialId }, + cancellationToken: cancellationToken)); + } + + public async Task FindPasskeyAsync( + MuiUser user, + byte[] credentialId, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(user); + + await using var connection = await source.OpenConnectionAsync(cancellationToken); + + var row = await connection.QuerySingleOrDefaultAsync(new CommandDefinition( + $"SELECT {PasskeyColumns} FROM user_passkey " + + "WHERE user_id = @id AND credential_id = @credentialId", + new { id = user.Id, credentialId }, + cancellationToken: cancellationToken)); + + return row?.ToInfo(); + } + + public async Task RemovePasskeyAsync( + MuiUser user, + byte[] credentialId, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(user); + + await using var connection = await source.OpenConnectionAsync(cancellationToken); + + await connection.ExecuteAsync(new CommandDefinition( + "DELETE FROM user_passkey WHERE user_id = @id AND credential_id = @credentialId", + new { id = user.Id, credentialId }, + cancellationToken: cancellationToken)); + } + + public void Dispose() + { + // The data source is owned by the container and outlives every store built over it. + } + + /// Bounded at the schema's own limit, so a long name is trimmed rather than refused. + private static string? Trim(string? name) => + name is null ? null : name.Length <= 64 ? name : name[..64]; + + private sealed class PasskeyRow + { + public byte[] CredentialId { get; init; } = []; + + public byte[] PublicKey { get; init; } = []; + + public long SignCount { get; init; } + + public bool IsBackedUp { get; init; } + + public bool IsBackupEligible { get; init; } + + public bool IsUserVerified { get; init; } + + public string[]? Transports { get; init; } + + public byte[]? AttestationObject { get; init; } + + public byte[]? ClientDataJson { get; init; } + + public string? Name { get; init; } + + public DateTimeOffset CreatedAt { get; init; } + + public UserPasskeyInfo ToInfo() => new( + CredentialId, + PublicKey, + CreatedAt, + (uint)SignCount, + Transports ?? [], + IsUserVerified, + IsBackupEligible, + IsBackedUp, + AttestationObject ?? [], + ClientDataJson ?? []) + { + Name = Name, + }; + } +} diff --git a/src/MUI.Web/Accounts/MuiUser.cs b/src/MUI.Web/Accounts/MuiUser.cs new file mode 100644 index 0000000..ab8d5d5 --- /dev/null +++ b/src/MUI.Web/Accounts/MuiUser.cs @@ -0,0 +1,37 @@ +namespace MUI.Web.Accounts; + +/// +/// An account, and deliberately almost nothing about a person (spec §8.2). +/// +/// +/// +/// No email, no password hash, and neither is an omission. Sign-in is passkeys only, so there +/// is no password to store; and §8.2's recovery path is to make a new account and re-verify through +/// the game, so there is no address to recover to. The root of trust is the server the operator +/// controls, which is why an account here is a durable handle to hang a claim on rather than an +/// identity worth defending. +/// +/// +/// It follows that this is a poor thing to steal, and that is the design working. Taking somebody's +/// account gets you the ability to edit four enrichment fields on games whose real owner can take +/// back in one probe by publishing a fresh token. +/// +/// +public sealed class MuiUser +{ + public Guid Id { get; init; } = Guid.CreateVersion7(); + + /// What the account calls itself. Never verified as a claim about anybody. + public string DisplayName { get; set; } = string.Empty; + + /// Identity's case-insensitive lookup key. Written by Identity, not by us. + public string NormalisedName { get; set; } = string.Empty; + + public string SecurityStamp { get; set; } = Guid.NewGuid().ToString(); + + public string ConcurrencyStamp { get; set; } = Guid.NewGuid().ToString(); + + public DateTimeOffset CreatedAt { get; set; } + + public DateTimeOffset? LastSignedInAt { get; set; } +} diff --git a/tests/MUI.Catalog.Tests/Persistence/OwnershipSchemaTests.cs b/tests/MUI.Catalog.Tests/Persistence/OwnershipSchemaTests.cs new file mode 100644 index 0000000..43f68c5 --- /dev/null +++ b/tests/MUI.Catalog.Tests/Persistence/OwnershipSchemaTests.cs @@ -0,0 +1,186 @@ +using Dapper; + +using MUI.Catalog.Persistence; +using MUI.Catalog.Tests.Persistence.Support; + +using Npgsql; + +namespace MUI.Catalog.Tests.Persistence; + +/// +/// The ownership schema's own guarantees (spec §8), asserted where they are enforced. +/// +/// +/// These are constraints rather than code, so a test that exercised a C# path would prove nothing +/// about them. Each one exists because a handler above it could otherwise get it wrong quietly. +/// +public class OwnershipSchemaTests +{ + private static readonly DateTimeOffset Now = Seed.Now; + + /// + /// Deleting an account takes its passkeys and refuses while it still owns a claim. + /// + /// + /// The asymmetry is the point. A passkey is a way in and means nothing once the account is gone; + /// a claim is what tells the world a game is owned, and dropping the account behind one would + /// leave game.is_claimed true with nobody attached. + /// + [Test] + public async Task AnAccountTakesItsPasskeysAndIsHeldByItsClaims() + { + await using var db = await PostgresFixture.MigratedAsync(); + var game = await Seed.GameAsync(db); + var user = await AccountAsync(db, "owner"); + + await using var connection = await db.DataSource.OpenConnectionAsync(); + + await connection.ExecuteAsync( + """ + INSERT INTO user_passkey (credential_id, user_id, public_key, created_at) + VALUES (@credential, @user, @key, @now) + """, + new { credential = new byte[] { 1, 2, 3 }, user, key = new byte[] { 4, 5, 6 }, now = Now }); + + await connection.ExecuteAsync( + """ + INSERT INTO game_claim (id, game_id, user_id, token, issued_at, expires_at) + VALUES (@id, @game, @user, 'muidx-aaaaaaaaaaaaaaaaaaaa', @now, @later) + """, + new { id = Guid.CreateVersion7(), game, user, now = Now, later = Now.AddDays(30) }); + + await Assert.That(async () => await connection.ExecuteAsync( + "DELETE FROM app_user WHERE id = @user", new { user })) + .Throws(); + + await connection.ExecuteAsync("DELETE FROM game_claim WHERE user_id = @user", new { user }); + await connection.ExecuteAsync("DELETE FROM app_user WHERE id = @user", new { user }); + + await Assert.That(await connection.ExecuteScalarAsync( + "SELECT count(*) FROM user_passkey WHERE user_id = @user", new { user })) + .IsEqualTo(0L); + } + + /// + /// One account cannot hold two live pending claims on one game. + /// + /// + /// A second token would invalidate the one the operator has just finished pasting into + /// mush.cnf. The service returns the existing claim; the partial index is what makes that a + /// guarantee rather than a courtesy. + /// + [Test] + public async Task TheSchemaRefusesASecondPendingClaimOnOneGameByOneAccount() + { + await using var db = await PostgresFixture.MigratedAsync(); + var game = await Seed.GameAsync(db); + var user = await AccountAsync(db, "owner"); + + await using var connection = await db.DataSource.OpenConnectionAsync(); + + await InsertClaimAsync(connection, game, user, "muidx-aaaaaaaaaaaaaaaaaaaa"); + + await Assert.That(async () => + await InsertClaimAsync(connection, game, user, "muidx-bbbbbbbbbbbbbbbbbbbb")) + .Throws(); + } + + /// + /// The index is partial, so a verified claim does not block the account asking again later. + /// + /// + /// A game changes hands, the new owner revokes, the old one wants back in. If the index covered + /// every row rather than only live pending ones, that account would be locked out of a game it + /// can still prove control of — by an index, silently. + /// + [Test] + public async Task AVerifiedOrRevokedClaimDoesNotBlockANewOne() + { + await using var db = await PostgresFixture.MigratedAsync(); + var game = await Seed.GameAsync(db); + var user = await AccountAsync(db, "owner"); + + await using var connection = await db.DataSource.OpenConnectionAsync(); + + await InsertClaimAsync(connection, game, user, "muidx-aaaaaaaaaaaaaaaaaaaa"); + await connection.ExecuteAsync( + "UPDATE game_claim SET claimed_at = @now, verified_via = 'mssp' WHERE user_id = @user", + new { now = Now, user }); + + await InsertClaimAsync(connection, game, user, "muidx-bbbbbbbbbbbbbbbbbbbb"); + await connection.ExecuteAsync( + "UPDATE game_claim SET revoked_at = @now WHERE token = 'muidx-bbbbbbbbbbbbbbbbbbbb'", + new { now = Now }); + + await InsertClaimAsync(connection, game, user, "muidx-cccccccccccccccccccc"); + + await Assert.That(await connection.ExecuteScalarAsync( + "SELECT count(*) FROM game_claim WHERE user_id = @user", new { user })) + .IsEqualTo(3L); + } + + /// An audit entry must name something that actually happens (§8.5). + [Test] + public async Task TheAuditLogHasAClosedVocabulary() + { + await using var db = await PostgresFixture.MigratedAsync(); + var game = await Seed.GameAsync(db); + var user = await AccountAsync(db, "owner"); + var claim = Guid.CreateVersion7(); + + await using var connection = await db.DataSource.OpenConnectionAsync(); + + await connection.ExecuteAsync( + """ + INSERT INTO game_claim (id, game_id, user_id, token, issued_at, expires_at) + VALUES (@claim, @game, @user, 'muidx-aaaaaaaaaaaaaaaaaaaa', @now, @later) + """, + new { claim, game, user, now = Now, later = Now.AddDays(30) }); + + await Assert.That(async () => await connection.ExecuteAsync( + "INSERT INTO claim_event (claim_id, at, kind) VALUES (@claim, @now, 'unclaimed')", + new { claim, now = Now })) + .Throws(); + + // And every kind the code can produce is accepted, which is the half a vocabulary test + // usually forgets — a CHECK that refuses a real value fails in production, not in a test. + foreach (var kind in Enum.GetValues()) + { + await connection.ExecuteAsync( + "INSERT INTO claim_event (claim_id, at, kind) VALUES (@claim, @now, @kind)", + new { claim, now = Now, kind = SqlEnums.ToDb(kind) }); + } + } + + private static Task InsertClaimAsync(NpgsqlConnection connection, Guid game, Guid user, string token) => + connection.ExecuteAsync( + """ + INSERT INTO game_claim (id, game_id, user_id, token, issued_at, expires_at) + VALUES (@id, @game, @user, @token, @now, @later) + """, + new { id = Guid.CreateVersion7(), game, user, token, now = Now, later = Now.AddDays(30) }); + + private static async Task AccountAsync(TestDatabase db, string name) + { + var id = Guid.CreateVersion7(); + + await using var connection = await db.DataSource.OpenConnectionAsync(); + + await connection.ExecuteAsync( + """ + INSERT INTO app_user (id, display_name, normalised_name, security_stamp, + concurrency_stamp, created_at) + VALUES (@id, @name, @normalised, @stamp, @stamp, @now) + """, + new + { + id, + name, + normalised = name.ToUpperInvariant(), + stamp = Guid.NewGuid().ToString(), + now = Now, + }); + + return id; + } +} From 4d06c878969b822ed412a3234ff7ff8e193aa564 Mon Sep 17 00:00:00 2001 From: Harry Cordewener Date: Fri, 31 Jul 2026 10:11:16 -0500 Subject: [PATCH 3/3] Claiming, end to end: sign in with a passkey, publish a token, be verified MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The web half of §8. Sign-in is passkeys and nothing else -- no passwords, no email, no federated provider -- over ASP.NET Core Identity's built-in WebAuthn support, with the Dapper stores from the previous commit behind it. Four endpoints and one script file. That script is the only JavaScript on this site, and the boundary is deliberate: navigator.credentials has no scripting-off path, so the part that needs it is the part used by people who administer a game server, while the catalogue, the game pages, the archive, plain mode and the API all keep working with scripting disabled. The claim page answers the question the design turns on -- what happens between minting a token and verifying it. Nothing needs writing down. The pending claim is durable server-side state shown for as long as it is pending, with each channel's line ready to copy, and IssueAsync returns the existing token rather than replacing it, so a refresh cannot invalidate what the operator has just finished pasting into mush.cnf. Everything to do with accounts is registered only when a connection string is, and the surfaces are absent rather than broken without one. Half a claim flow over invented games would be worse than none: an operator following it would publish a token on a real server for a listing that is not their game. Two things were missing and are now here rather than worked around. IGameQueries had no by-id lookup, so an owner page had to reach past the interface to a store or resolve a slug it was never given -- a claim is bound to the game, not to a name a rename can move. And the test harness had never rendered a whole page; Render.PageAsync wires the fixture and deliberately not the account services, which is the condition every claim-surface test is about. Verified against a real database rather than a build: migration 0007 applies, /account/sign-in, /g/{slug}/claim and /account all answer, the game page offers the claim only when there is somewhere to put it, and the assertion endpoint returns a real WebAuthn challenge. Five suites, 652 tests, zero warnings. Co-Authored-By: Claude Opus 5 --- .../Persistence/NpgsqlGameQueries.cs | 37 +++ src/MUI.Catalog/Views.cs | 11 + src/MUI.Web/Accounts/Passkeys.cs | 262 ++++++++++++++++++ src/MUI.Web/Components/Pages/Account.razor | 187 +++++++++++++ src/MUI.Web/Components/Pages/Claim.razor | 163 +++++++++++ src/MUI.Web/Components/Pages/Game.razor | 24 +- src/MUI.Web/Components/Pages/SignIn.razor | 93 +++++++ src/MUI.Web/Fixtures/FixtureGameQueries.cs | 6 + src/MUI.Web/Program.cs | 16 ++ src/MUI.Web/wwwroot/passkey.js | 136 +++++++++ tests/MUI.Web.Tests/ClaimSurfaceTests.cs | 98 +++++++ tests/MUI.Web.Tests/Render.cs | 29 +- 12 files changed, 1060 insertions(+), 2 deletions(-) create mode 100644 src/MUI.Web/Accounts/Passkeys.cs create mode 100644 src/MUI.Web/Components/Pages/Account.razor create mode 100644 src/MUI.Web/Components/Pages/Claim.razor create mode 100644 src/MUI.Web/Components/Pages/SignIn.razor create mode 100644 src/MUI.Web/wwwroot/passkey.js create mode 100644 tests/MUI.Web.Tests/ClaimSurfaceTests.cs diff --git a/src/MUI.Catalog/Persistence/NpgsqlGameQueries.cs b/src/MUI.Catalog/Persistence/NpgsqlGameQueries.cs index 34d8558..59a1ab9 100644 --- a/src/MUI.Catalog/Persistence/NpgsqlGameQueries.cs +++ b/src/MUI.Catalog/Persistence/NpgsqlGameQueries.cs @@ -224,6 +224,43 @@ FROM game_endpoint return [.. rows]; } + public async Task FindByIdAsync(Guid id, CancellationToken cancellationToken = default) + { + await using var connection = await source.OpenConnectionAsync(cancellationToken); + + var row = await connection.QuerySingleOrDefaultAsync(new CommandDefinition( + """ + SELECT id AS Id, slug AS Slug, name AS Name, tagline AS Tagline, state AS State, + is_claimed AS IsClaimed, last_reachable_at AS LastReachableAt + FROM game + WHERE id = @id + """, + new { id }, + cancellationToken: cancellationToken)); + + if (row is null) + { + return null; + } + + Guid[] ids = [row.Id]; + var fields = (await FieldsForAsync(connection, ids, cancellationToken)) + .GetValueOrDefault(row.Id, []); + var digest = (await PresenceDigestAsync(connection, ids, Clock(), cancellationToken)) + .GetValueOrDefault(row.Id, PresenceDigest.None); + + return new GameSummary( + row.Id, + row.Slug, + row.Name, + row.Tagline, + SqlEnums.ToLifecycleState(row.State), + row.IsClaimed, + digest.CountNow, + Winner(fields, "CODEBASE")?.Value, + MeasuredProtocolsOf(fields)); + } + public async Task FindAsync(string slug, CancellationToken cancellationToken = default) { var now = Clock(); diff --git a/src/MUI.Catalog/Views.cs b/src/MUI.Catalog/Views.cs index 2018d7b..a0f8b03 100644 --- a/src/MUI.Catalog/Views.cs +++ b/src/MUI.Catalog/Views.cs @@ -241,6 +241,17 @@ Task> ListAsync( Task FindAsync(string slug, CancellationToken cancellationToken = default); + /// + /// The listing entry for a game known by id, or null. + /// + /// + /// The public surfaces address a game by slug, because that is what a URL carries. The owner + /// surfaces address it by id, because a claim is bound to the game and not to a name a rename can + /// move — so this exists rather than having those pages reach past the interface to a store, or + /// resolve a slug they were never given. + /// + Task FindByIdAsync(Guid id, CancellationToken cancellationToken = default); + /// The three liveness feeds (spec §9) — the differentiator no incumbent can publish. Task FeedsAsync(CancellationToken cancellationToken = default); diff --git a/src/MUI.Web/Accounts/Passkeys.cs b/src/MUI.Web/Accounts/Passkeys.cs new file mode 100644 index 0000000..acb7618 --- /dev/null +++ b/src/MUI.Web/Accounts/Passkeys.cs @@ -0,0 +1,262 @@ +using Microsoft.AspNetCore.Identity; + +using MUI.Catalog; +using MUI.Catalog.Persistence; + +using Npgsql; + +namespace MUI.Web.Accounts; + +/// +/// Sign-in, and the endpoints WebAuthn needs on the way (spec §8.2). +/// +/// +/// +/// Passkeys only. No passwords, no email, no federated provider — we hold a public key and the +/// private key never leaves the operator's authenticator. What usually forces a password back into a +/// passwordless deployment is account recovery, and it does not apply here: §8.2's recovery path is +/// to make a new account and re-verify through the game, because the root of trust is the server the +/// operator controls rather than the credential. +/// +/// +/// These four endpoints are the only part of this site that needs JavaScript. +/// navigator.credentials has no scripting-off path, and rather than let that leak outwards the +/// boundary is drawn here: the catalogue, the game pages, the archive, plain mode and the API all +/// work with scripting disabled, and the part that does not is the part used by people who administer +/// a game server. +/// +/// +public static class Passkeys +{ + /// Where a signed-in operator lands, and where sign-in returns to. + public const string DashboardPath = "/account"; + + public static IServiceCollection AddMuiAccounts( + this IServiceCollection services, + IConfiguration configuration) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(configuration); + + services + .AddIdentityCore(options => + { + // A display name, not an address. Identity's default set excludes most punctuation + // and it is left as-is: this name appears beside a claim on a public page. + options.User.RequireUniqueEmail = false; + }) + .AddSignInManager() + .AddUserStore(); + + services.AddScoped>(s => new DapperUserStore( + s.GetRequiredService(), + s.GetRequiredService())); + + services.AddScoped(s => new ClaimService( + new NpgsqlClaimStore(s.GetRequiredService()), + new NpgsqlGameStore(s.GetRequiredService()), + s.GetRequiredService())); + + services.Configure(options => + { + // Set explicitly rather than inferred from the host header, which the ASP.NET Core docs + // call a credential-scoping risk. It is also the reason §15.1's open domain question has + // a deadline: a passkey is bound to this value, and every credential registered before + // the domain settles has to be registered again after it moves. + options.ServerDomain = configuration["Passkeys:ServerDomain"]; + options.UserVerificationRequirement = "required"; + options.ResidentKeyRequirement = "required"; + }); + + services + .AddAuthentication(IdentityConstants.ApplicationScheme) + .AddIdentityCookies(); + + // Needed by the one endpoint that calls RequireAuthorization — the on-demand claim check. + services.AddAuthorization(); + + services.ConfigureApplicationCookie(options => + { + options.LoginPath = "/account/sign-in"; + options.Cookie.HttpOnly = true; + options.Cookie.SameSite = SameSiteMode.Lax; + options.SlidingExpiration = true; + }); + + return services; + } + + /// + /// The WebAuthn ceremony, as four endpoints the page's script calls in order. + /// + /// + /// Minimal APIs rather than Razor handlers because the browser talks JSON here: the options go + /// out as the shapes PublicKeyCredential.parseCreationOptionsFromJSON expects, and the + /// credential comes back as the shape PerformPasskeyAttestationAsync reads. + /// + public static void MapMuiAccounts(this WebApplication app) + { + ArgumentNullException.ThrowIfNull(app); + + var accounts = app.MapGroup("/account"); + + accounts.MapPost("/passkey/registration-options", async ( + HttpContext context, + UserManager users, + SignInManager signIn, + string? name) => + { + // Two arms, and the difference is who is asking. A signed-in operator is adding a second + // credential to the account they have; anybody else is creating one, and the account does + // not exist until the credential comes back verified — so nothing is written here. + var user = await users.GetUserAsync(context.User); + + var entity = user is not null + ? new PasskeyUserEntity + { + Id = await users.GetUserIdAsync(user), + Name = user.DisplayName, + DisplayName = user.DisplayName, + } + : new PasskeyUserEntity + { + Id = Guid.CreateVersion7().ToString(), + Name = Naming.Clean(name), + DisplayName = Naming.Clean(name), + }; + + return TypedResults.Content( + await signIn.MakePasskeyCreationOptionsAsync(entity), + contentType: "application/json"); + }); + + accounts.MapPost("/passkey/register", async ( + HttpContext context, + UserManager users, + SignInManager signIn, + TimeProvider time, + PasskeySubmission submission) => + { + var attestation = await signIn.PerformPasskeyAttestationAsync(submission.Credential); + + if (!attestation.Succeeded) + { + return Results.BadRequest(attestation.Failure.Message); + } + + var user = await users.GetUserAsync(context.User); + + if (user is null) + { + // The account is created here, at the moment a credential exists to reach it with. + // Creating it earlier would leave unreachable accounts behind every abandoned + // registration. + user = new MuiUser + { + DisplayName = Naming.Clean(submission.Name), + CreatedAt = time.GetUtcNow(), + }; + + var created = await users.CreateAsync(user); + + if (!created.Succeeded) + { + return Results.BadRequest(string.Join(" ", created.Errors.Select(e => e.Description))); + } + } + + var stored = await users.AddOrUpdatePasskeyAsync(user, attestation.Passkey); + + if (!stored.Succeeded) + { + return Results.BadRequest("That passkey could not be stored."); + } + + await signIn.SignInAsync(user, isPersistent: true); + + return Results.Ok(new { redirect = DashboardPath }); + }); + + accounts.MapPost("/passkey/assertion-options", async (SignInManager signIn) => + TypedResults.Content( + // No user, so the browser offers whatever discoverable credential it holds for this + // domain. That is what makes sign-in a single click with nothing typed first, and it + // is why ResidentKeyRequirement is "required" at registration. + await signIn.MakePasskeyRequestOptionsAsync(user: null), + contentType: "application/json")); + + accounts.MapPost("/passkey/sign-in", async ( + SignInManager signIn, + PasskeySubmission submission) => + { + var result = await signIn.PasskeySignInAsync(submission.Credential); + + return result.Succeeded + ? Results.Ok(new { redirect = DashboardPath }) + : Results.Unauthorized(); + }); + + // §8.1's on-demand check. It does not dial anything itself — it records that the claimant + // asked, and the crawler's own scheduler is what brings the probe forward. Keeping the two + // apart is what stops a button on a public page becoming a way to make us connect to a + // stranger's server on demand: the rate limit is per claim, and a claim cannot exist for a + // game nobody has been offered. + app.MapPost("/g/{slug}/claim/check", async ( + HttpContext context, + UserManager users, + IGameQueries queries, + IClaimStore claims, + ClaimService service, + string slug) => + { + var user = await users.GetUserAsync(context.User); + + if (user is null || await queries.FindAsync(slug) is not { } page) + { + return Results.Redirect($"/g/{slug}/claim"); + } + + var mine = (await claims.ForUserAsync(user.Id)) + .FirstOrDefault(c => c.GameId == page.Summary.Id && c.RevokedAt is null); + + if (mine is not null) + { + await service.RequestCheckAsync(mine.Id); + } + + return Results.Redirect($"/g/{slug}/claim"); + }).RequireAuthorization(); + + accounts.MapPost("/sign-out", async (SignInManager signIn) => + { + await signIn.SignOutAsync(); + + return Results.Redirect("/"); + }); + } + + /// What the page posts back after the authenticator has answered. + public sealed record PasskeySubmission(string Credential, string? Name); + + /// + /// A display name is a label, and this is the whole of what we do to one. + /// + /// + /// Bounded and trimmed, with a default rather than a rejection: somebody registering a passkey + /// has already done the hard part, and refusing them over a blank field would be a poor moment to + /// start being strict. It is never treated as a claim about who anybody is. + /// + private static class Naming + { + private const int MaxLength = 40; + + public static string Clean(string? name) + { + var trimmed = name?.Trim(); + + return string.IsNullOrEmpty(trimmed) + ? $"operator-{Guid.CreateVersion7().ToString()[..8]}" + : trimmed.Length <= MaxLength ? trimmed : trimmed[..MaxLength]; + } + } +} diff --git a/src/MUI.Web/Components/Pages/Account.razor b/src/MUI.Web/Components/Pages/Account.razor new file mode 100644 index 0000000..7bca8a0 --- /dev/null +++ b/src/MUI.Web/Components/Pages/Account.razor @@ -0,0 +1,187 @@ +@page "/account" +@using Microsoft.AspNetCore.Identity +@using MUI.Catalog +@using MUI.Catalog.Persistence +@using MUI.Web.Accounts +@inject IGameQueries Queries +@inject IServiceProvider Services + +@* + An operator's own page: the games they have claimed, the ones still waiting on a token, and the + passkeys that reach the account. + + Deliberately thin. Everything editable from here is enrichment (spec §8.5) — an owner may add + what MSSP has no field for and may never touch a measurement, so there is nothing on this page + that could change a player count, a capability or a reachability history. +*@ + +Your games — mu*index + +@if (Users is null) +{ +
+

Your games

+

Accounts need a database behind them, and this site is running on the demo fixture.

+
+} +else if (User is null) +{ +
+

Your games

+

Sign in

+
+} +else +{ +
+

Your games

+

Signed in as @User.DisplayName.

+ + @if (Verified.Count == 0 && Pending.Count == 0) + { +

+ You have not claimed anything yet. Find your game in + the listing and press claim this game on its page. +

+ } + + @if (Verified.Count > 0) + { +

Claimed

+
    + @foreach (var (claim, game) in Verified) + { +
  • + @game.Name + + verified @claim.ClaimedAt!.Value.ToString("d MMM yyyy")@(BeaconNote(claim)) + +
  • + } +
+ } + + @if (Pending.Count > 0) + { +

Waiting on a token

+
    + @foreach (var (claim, game) in Pending) + { +
  • + @game.Name + + token issued @claim.IssuedAt.ToString("d MMM yyyy"), good until + @claim.ExpiresAt.ToString("d MMM yyyy") + +
  • + } +
+ } + +

Passkeys

+
    + @foreach (var key in Keys) + { +
  • + @(key.Name ?? "unnamed") + + added @key.CreatedAt.ToString("d MMM yyyy")@(key.IsBackedUp ? string.Empty : " · on one device only") + +
  • + } +
+ + @if (Keys.Any(k => !k.IsBackedUp) && Keys.Count == 1) + { +

+ That passkey lives on one device rather than syncing. If you lose the device you can + still get back in by publishing a fresh token on your game — but adding a second + passkey is quicker. +

+ } + +
+ +

+
+ +
+ + + +
+ + +} + +@code { + private UserManager? Users { get; set; } + + private MuiUser? User { get; set; } + + private List<(GameClaim Claim, GameSummary Game)> Verified { get; set; } = []; + + private List<(GameClaim Claim, GameSummary Game)> Pending { get; set; } = []; + + private IList Keys { get; set; } = []; + + [CascadingParameter] + private HttpContext? HttpContext { get; set; } + + protected override async Task OnInitializedAsync() + { + Users = Services.GetService>(); + + if (Users is null + || Services.GetService() is not { } claims + || HttpContext?.User is not { Identity.IsAuthenticated: true } principal) + { + return; + } + + User = await Users.GetUserAsync(principal); + + if (User is null) + { + return; + } + + Keys = await Users.GetPasskeysAsync(User); + + var now = DateTimeOffset.UtcNow; + + foreach (var claim in await claims.ForUserAsync(User.Id)) + { + // A claim names a game id; the listing names a slug. Resolved one at a time because an + // operator holds a handful of games, not a page of them — if that stops being true this + // wants a single query rather than a loop, and the shape of the fix is a batch read. + if (await Queries.FindByIdAsync(claim.GameId) is not { } game) + { + continue; + } + + if (claim.IsVerified) + { + Verified.Add((claim, game)); + } + else if (claim.IsPending(now)) + { + Pending.Add((claim, game)); + } + } + } + + /// + /// Says when the beacon was last seen, and never treats its absence as a problem. + /// + /// + /// Spec §8.4: presence establishes, absence never revokes. This is a note, not a warning — a + /// missing beacon means a probe did not read one, which happens for reasons that have nothing to + /// do with the owner, and alarming them about it would be the interface arguing for a rule the + /// system does not have. + /// + private static string BeaconNote(GameClaim claim) => + claim.BeaconLastSeenAt is { } seen + ? $", token last seen {seen:d MMM yyyy}" + : string.Empty; +} diff --git a/src/MUI.Web/Components/Pages/Claim.razor b/src/MUI.Web/Components/Pages/Claim.razor new file mode 100644 index 0000000..5e0a094 --- /dev/null +++ b/src/MUI.Web/Components/Pages/Claim.razor @@ -0,0 +1,163 @@ +@page "/g/{Slug}/claim" +@using Microsoft.AspNetCore.Identity +@using MUI.Catalog +@using MUI.Catalog.Persistence +@using MUI.Discovery +@using MUI.Web.Accounts +@inject IGameQueries Queries +@inject IServiceProvider Services +@inject TimeProvider Clock + +@* + Claiming a game: sign in, then publish a token where a probe can read it (spec §8.1). + + Nobody has to write anything down. What is on this page is durable server-side state, shown for + as long as the claim is pending, with each channel's exact line ready to copy — close the tab and + come back next week and it is unchanged. A scheme that had to be finished in one sitting would + put a transcription error between an owner and their listing. + + The token is a nonce and not a secret. We are asking for it to be published where every anonymous + connection reads it, so holding it can never confer anything: it proves somebody with write + access to that server published it, and the account this claim is bound to answers who asked. +*@ + +Claim @(Page?.Summary.Name ?? Slug) — mu*index + +@if (Page is null) +{ +

No such game

+} +else if (Claims is null) +{ +
+

Claim @Page.Summary.Name

+

Claiming needs a database behind it, and this site is running on the demo fixture.

+
+} +else if (User is null) +{ +
+

Claim @Page.Summary.Name

+

+ You need an account first, so that the claim has somewhere to belong. It takes a passkey + and a name, and nothing else. +

+

Sign in or create an account

+
+} +else +{ +
+

Claim @Page.Summary.Name

+ + @if (Pending!.IsVerified) + { +

+ Verified. We read your token from + @(Pending.VerifiedVia == ClaimChannel.Mssp ? "the game's MSSP report" : "the connect screen") + on @Pending.ClaimedAt!.Value.ToString("d MMMM yyyy"). +

+

+ Leave the token where it is. It doubles as a permanent identity signal, so this game + stays recognisably itself if it moves host or changes its name — and removing it + will never un-claim you. +

+

Your games

+ } + else + { +

+ Publish this token anywhere the game shows it to an anonymous connection, and the + next probe will pick it up. It proves you can write to that server, which is the + whole test. +

+ +

@Pending.Token

+ +

Either of these will do

+ +

An MSSP variable

+
@ClaimTokenBeacon.MsspVariable @Pending.Token
+

+ In PennMUSH that is a line in mush.cnf; every codebase with MSSP has an + equivalent. MUINDEX_CLAIM and CONTACT_TOKEN are accepted + too — an MSSP variable name does not always survive a config file, and you should not + be told your claim failed for doing exactly what you were told. +

+ +

A line on the connect screen

+
@ClaimTokenBeacon.ConnectScreenPrefix @Pending.Token
+

+ Anywhere in the screen, and colour codes around it are fine. +

+ +

Then

+

+ We check on the ordinary crawl schedule. This token is good until + @Pending.ExpiresAt.ToString("d MMMM yyyy"), and you can come back to this page any time + — nothing needs writing down. +

+ +
+ + + @if (!CanCheck) + { + + Just looked. You can ask again in a few minutes — the button dials a real + server, so it is rationed. + + } + + } +
+} + +@code { + [Parameter] + public string Slug { get; set; } = string.Empty; + + private GamePage? Page { get; set; } + + private ClaimService? Claims { get; set; } + + private MuiUser? User { get; set; } + + private GameClaim? Pending { get; set; } + + private bool CanCheck => Pending is not null && Claims!.MayRecheck(Pending); + + /// + /// Loads the page, and mints a token if this account has none outstanding for this game. + /// + /// + /// Minting on view rather than behind a second button is deliberate: arriving here is the ask. + /// returns an existing pending claim rather than replacing + /// it, so a refresh does not invalidate what the operator has just finished pasting into their + /// config — which would look exactly like the site being broken. + /// + protected override async Task OnInitializedAsync() + { + Page = await Queries.FindAsync(Slug); + Claims = Services.GetService(); + + if (Page is null || Claims is null) + { + return; + } + + if (Services.GetService>() is { } users + && HttpContext?.User is { Identity.IsAuthenticated: true } principal) + { + User = await users.GetUserAsync(principal); + } + + if (User is not null) + { + Pending = await Claims.IssueAsync(Page.Summary.Id, User.Id); + } + } + + [CascadingParameter] + private HttpContext? HttpContext { get; set; } +} diff --git a/src/MUI.Web/Components/Pages/Game.razor b/src/MUI.Web/Components/Pages/Game.razor index 728d86e..10784f5 100644 --- a/src/MUI.Web/Components/Pages/Game.razor +++ b/src/MUI.Web/Components/Pages/Game.razor @@ -3,6 +3,7 @@ @inject IGameQueries Queries @inject IAvailabilityHistory History @inject TimeProvider Clock +@inject IServiceProvider Services @if (Page is null) { @@ -54,7 +55,19 @@ else } @if (!Page.Summary.IsClaimed && !Archived) { -

Nobody has claimed this listing — everything here was measured.

+ @* + The invitation sits with the fact rather than in a banner. An owner arriving + at their own listing is the one reader for whom "nobody has claimed this" is + an action rather than a note — and it is a link, not a button, because + nothing happens until they are signed in (spec §8.1). + *@ +

+ Nobody has claimed this listing — everything here was measured. + @if (Claimable) + { + Run this game? + } +

}
@@ -150,6 +163,15 @@ else } @code { + /// + /// Whether claiming is available at all — it needs a database, and the demo has none. + /// + /// + /// Asked of the container rather than re-derived from a connection string, so the page and the + /// endpoints cannot disagree about whether accounts exist. + /// + private bool Claimable => Services.GetService() is not null; + [Parameter] public string Slug { get; set; } = string.Empty; [SupplyParameterFromQuery(Name = "plain")] private string? PlainFlag { get; set; } diff --git a/src/MUI.Web/Components/Pages/SignIn.razor b/src/MUI.Web/Components/Pages/SignIn.razor new file mode 100644 index 0000000..506d815 --- /dev/null +++ b/src/MUI.Web/Components/Pages/SignIn.razor @@ -0,0 +1,93 @@ +@page "/account/sign-in" +@using Microsoft.AspNetCore.Identity +@using MUI.Web.Accounts +@inject IServiceProvider Services + +@* + Sign-in, and the only page on this site that needs JavaScript (spec §8.2). + + Passkeys and nothing else: no password to store, no address to recover to, no third party. The + part of passwordless that usually forces one of those back in is account recovery, and it does + not apply — lose every device and the way back is to publish a fresh token on the game you + control, because the root of trust is the server rather than the credential. + + That also means an account here is worth almost nothing to steal, which is the design working + rather than a shortcut: taking one gets you the ability to edit four enrichment fields on games + whose real owner takes them back in a single probe. +*@ + +Sign in — mu*index + +@if (!Available) +{ +
+

Sign in

+

+ Claiming needs a database behind it, and this site is running on the demo fixture. + There is nothing here to sign in to. +

+
+} +else +{ +
+

Sign in

+ +

+ Sign-in is a passkey — your device or password manager holds a private + key and we hold only the public half. There is no password to forget and no email to + give us. +

+ +
+ +

+ This is the one page here that needs JavaScript. Passkeys have no way to work + without it. +

+
+ +

No account yet?

+ +

+ You need one only to claim a game you run. Pick a name to be known by — it is a label + beside your claim, not a real name, and we ask for nothing else. +

+ +
+ + + +

+
+ +

What we store

+ +
    +
  • The name you chose.
  • +
  • The public key of each passkey you register, and what your device called it.
  • +
  • Which games you have claimed, and when.
  • +
+ +

+ No email address, no password, no IP log tied to your account. If you lose every passkey, + publish a fresh claim token on your game and start again — the game is the proof, not + the account. +

+
+ + +} + +@code { + /// + /// Whether there is a database to sign in against. + /// + /// + /// Identity is only registered when a connection string is configured, so this asks the + /// container rather than re-deriving the condition — two places deciding whether accounts exist + /// is how a page ends up offering a button that throws. + /// + private bool Available => Services.GetService>() is not null; +} diff --git a/src/MUI.Web/Fixtures/FixtureGameQueries.cs b/src/MUI.Web/Fixtures/FixtureGameQueries.cs index 2776fe0..ca7b0c8 100644 --- a/src/MUI.Web/Fixtures/FixtureGameQueries.cs +++ b/src/MUI.Web/Fixtures/FixtureGameQueries.cs @@ -180,6 +180,12 @@ public async Task> ListAsync( _ => null, }; + /// + /// The demo has no accounts, so nothing here ever asks — but the interface is the interface. + /// + public Task FindByIdAsync(Guid id, CancellationToken cancellationToken = default) => + Task.FromResult(All.FirstOrDefault(g => g.Id == id)); + public Task FindAsync(string slug, CancellationToken cancellationToken = default) { var summary = All.FirstOrDefault(g => g.Slug == slug); diff --git a/src/MUI.Web/Program.cs b/src/MUI.Web/Program.cs index 7b3f5d2..2beddb7 100644 --- a/src/MUI.Web/Program.cs +++ b/src/MUI.Web/Program.cs @@ -1,4 +1,5 @@ using MUI.Catalog; +using MUI.Web.Accounts; using MUI.Web.Api; using MUI.Web.Components; using MUI.Web.Data; @@ -36,6 +37,14 @@ builder.Services.AddSingleton(new CatalogueSource(connectionString is not null)); +// Claiming needs a database: an account, a passkey and a claim are all rows (spec §8). Against the +// demo fixture the sign-in and claim surfaces are simply absent rather than present and broken — +// half a claim flow over invented games would be a worse answer than none. +if (connectionString is not null) +{ + builder.Services.AddMuiAccounts(builder.Configuration); +} + // Ages are relative to a clock, and a clock is a dependency like any other — the plain surface and // the rendered page must not each reach for DateTimeOffset.UtcNow and disagree by a tick. builder.Services.AddSingleton(TimeProvider.System); @@ -64,6 +73,13 @@ // a filter is a bookmarkable question, not a state change — so nothing here is token-protected. app.UseAntiforgery(); +if (connectionString is not null) +{ + app.UseAuthentication(); + app.UseAuthorization(); + app.MapMuiAccounts(); +} + app.MapRazorComponents(); app.MapMuiApi(); diff --git a/src/MUI.Web/wwwroot/passkey.js b/src/MUI.Web/wwwroot/passkey.js new file mode 100644 index 0000000..e8c33b7 --- /dev/null +++ b/src/MUI.Web/wwwroot/passkey.js @@ -0,0 +1,136 @@ +// The WebAuthn ceremony, and the only JavaScript this site has. +// +// Everything else here — the listing, the game pages, the archive, plain mode, the API, the facet +// panel — works with scripting disabled, and that is a design constraint rather than an accident. +// navigator.credentials has no scripting-off path, so the boundary is drawn at sign-in: the part +// that needs a script is the part used by people who administer a game server. +// +// No framework, no bundler, no external host. A page that asks somebody to prove they control a +// server should not also be asking them to trust a CDN. + +(() => { + const post = async (url, body) => { + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + + if (!response.ok) { + throw new Error((await response.text()) || response.statusText); + } + + return response.status === 204 ? null : await response.json(); + }; + + // Some password managers implement PublicKeyCredential.toJSON incorrectly, and JSON.stringify then + // throws "Illegal invocation" at the moment of registering — which reads to the operator as the + // site being broken. Microsoft documents this workaround; it serialises the credential by hand + // rather than relying on the browser's own toJSON. + const base64url = (value) => { + if (!value) { + return undefined; + } + + let bytes = value; + if (Array.isArray(bytes)) bytes = Uint8Array.from(bytes); + if (bytes instanceof ArrayBuffer) bytes = new Uint8Array(bytes); + + if (bytes instanceof Uint8Array) { + let binary = ''; + for (let i = 0; i < bytes.byteLength; i++) binary += String.fromCharCode(bytes[i]); + bytes = window.btoa(binary); + } + + if (typeof bytes !== 'string') { + throw new Error('Could not serialise the credential.'); + } + + return bytes.replace(/\+/g, '-').replace(/\//g, '_').replace(/=*$/g, ''); + }; + + const serialise = (credential) => JSON.stringify({ + authenticatorAttachment: credential.authenticatorAttachment, + clientExtensionResults: credential.getClientExtensionResults(), + id: credential.id, + rawId: base64url(credential.rawId), + response: { + attestationObject: base64url(credential.response.attestationObject), + authenticatorData: base64url( + credential.response.authenticatorData ?? + credential.response.getAuthenticatorData?.() ?? + undefined), + clientDataJSON: base64url(credential.response.clientDataJSON), + publicKey: base64url(credential.response.getPublicKey?.() ?? undefined), + publicKeyAlgorithm: credential.response.getPublicKeyAlgorithm?.() ?? undefined, + transports: credential.response.getTransports?.() ?? undefined, + signature: base64url(credential.response.signature), + userHandle: base64url(credential.response.userHandle), + }, + type: credential.type, + }); + + const say = (form, message) => { + const status = form.querySelector('[data-passkey-status]'); + if (status) { + status.textContent = message; + } + }; + + const run = async (form, action) => { + const name = form.querySelector('[name="name"]')?.value ?? ''; + const registering = action === 'register'; + + const optionsJson = registering + ? await post(`/account/passkey/registration-options?name=${encodeURIComponent(name)}`) + : await post('/account/passkey/assertion-options'); + + const credential = registering + ? await navigator.credentials.create({ + publicKey: PublicKeyCredential.parseCreationOptionsFromJSON(optionsJson), + }) + : await navigator.credentials.get({ + publicKey: PublicKeyCredential.parseRequestOptionsFromJSON(optionsJson), + }); + + const result = await post( + registering ? '/account/passkey/register' : '/account/passkey/sign-in', + { credential: serialise(credential), name }); + + window.location.assign(result.redirect); + }; + + document.querySelectorAll('form[data-passkey]').forEach((form) => { + const action = form.dataset.passkey; + + // The button is disabled in the markup and enabled here, so a browser with scripting off shows + // the explanation beside it rather than a control that does nothing when pressed. + const button = form.querySelector('button'); + if (button) { + button.disabled = false; + } + + form.addEventListener('submit', async (event) => { + event.preventDefault(); + say(form, 'Waiting for your device…'); + + try { + await run(form, action); + } catch (error) { + // NotAllowedError is the browser's word for "the person cancelled, or it timed out", and it + // is not a failure worth alarming anybody about. + say(form, error?.name === 'NotAllowedError' + ? 'No passkey was used. Try again when you are ready.' + : `That did not work: ${error?.message ?? error}`); + } + }); + }); + + // WebAuthn needs a secure context. Saying so is better than a button that fails on click. + if (!window.PublicKeyCredential) { + document.querySelectorAll('[data-passkey-status]').forEach((status) => { + status.textContent = + 'This browser has no passkey support, or the page is not being served over HTTPS.'; + }); + } +})(); diff --git a/tests/MUI.Web.Tests/ClaimSurfaceTests.cs b/tests/MUI.Web.Tests/ClaimSurfaceTests.cs new file mode 100644 index 0000000..104257b --- /dev/null +++ b/tests/MUI.Web.Tests/ClaimSurfaceTests.cs @@ -0,0 +1,98 @@ +using MUI.Catalog; +using MUI.Catalog.Persistence; +using MUI.Web.Accounts; +using MUI.Web.Components.Pages; +using MUI.Web.Fixtures; + +namespace MUI.Web.Tests; + +/// +/// What the claim surfaces do when there is no database, and what the token is made of. +/// +/// +/// The demo fixture has no accounts, no claims and no games anyone can prove they run. These pin the +/// half of §8 that can be asserted without one — that the surfaces are absent rather than +/// present and broken, and that a token could not be guessed by somebody watching a connect screen. +/// +public class ClaimSurfaceTests +{ + /// + /// A game page over the fixture does not invite a claim it cannot process. + /// + /// + /// Half a claim flow over invented games is a worse answer than none: an operator following it + /// would publish a token on a real server for a listing that is not their game and does not + /// exist. So the invitation is gated on the service being registered, which happens only when a + /// connection string does. + /// + [Test] + public async Task AGamePageOverTheFixtureDoesNotOfferToBeClaimed() + { + var page = await Render.PageAsync(new() { ["Slug"] = "m-u-s-h" }); + + await Assert.That(page).Contains("Nobody has claimed this listing"); + await Assert.That(page).DoesNotContain("Run this game?"); + } + + /// The sign-in page says why it cannot sign anybody in, rather than offering a button. + [Test] + public async Task SignInOverTheFixtureSaysThereIsNothingToSignInTo() + { + var page = await Render.PageAsync([]); + + await Assert.That(page).Contains("demo fixture"); + await Assert.That(page).DoesNotContain("Sign in with a passkey"); + } + + /// + /// A token is unguessable, and shaped so a person reading one back does not lose. + /// + /// + /// It is published where anyone can read it, so it need not stay secret once verified — but it + /// must be unguessable until the operator publishes it, or somebody watching a connect + /// screen could publish theirs first. Hence randomness rather than a derivation of the game. + /// + [Test] + public async Task ATokenIsPrefixedUnguessableAndFreeOfLookalikeCharacters() + { + var minted = Enumerable.Range(0, 200).Select(_ => ClaimToken.Mint()).ToList(); + + await Assert.That(minted.Distinct().Count()).IsEqualTo(minted.Count); + + foreach (var token in minted) + { + await Assert.That(ClaimToken.LooksLikeOne(token)).IsTrue(); + await Assert.That(token).StartsWith(ClaimToken.Prefix); + + // 0/o, 1/l/i and u/v each reduced to one member: the token is meant to be copied, but a + // scheme that punishes whoever transcribes it is a support mail waiting to happen. + var body = token[ClaimToken.Prefix.Length..]; + await Assert.That(body.Any(c => c is '0' or 'o' or '1' or 'l' or 'i' or 'u' or 'v')) + .IsFalse(); + } + } + + /// A shape check is not a verification, and must never be mistaken for one. + [Test] + public async Task LookingLikeATokenProvesNothing() + { + await Assert.That(ClaimToken.LooksLikeOne("muidx-22222222222222222222")).IsTrue(); + await Assert.That(ClaimToken.LooksLikeOne("muidx-short")).IsFalse(); + await Assert.That(ClaimToken.LooksLikeOne("not-ours-2222222222222222")).IsFalse(); + await Assert.That(ClaimToken.LooksLikeOne(null)).IsFalse(); + } + + /// + /// The channels a token may be published in are the two a probe can read, and DNS is not one. + /// + /// + /// §8.3: a TXT record proves control of a hostname, and MU* hosting routinely puts many unrelated + /// games on one domain separated only by port. Adding it to this enum without a port qualifier + /// would let a host's operator claim every game on it. + /// + [Test] + public async Task OnlyChannelsAProbeCanReadAreClaimChannels() + { + await Assert.That(Enum.GetNames()).IsEquivalentTo(new[] { "Mssp", "ConnectScreen" }); + } +} diff --git a/tests/MUI.Web.Tests/Render.cs b/tests/MUI.Web.Tests/Render.cs index 2515875..65b0c9f 100644 --- a/tests/MUI.Web.Tests/Render.cs +++ b/tests/MUI.Web.Tests/Render.cs @@ -1,3 +1,6 @@ +using MUI.Catalog; +using MUI.Web.Data; +using MUI.Web.Fixtures; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Web; using Microsoft.Extensions.DependencyInjection; @@ -17,11 +20,35 @@ namespace MUI.Web.Tests; /// public static class Render { - public static async Task ComponentAsync(Dictionary parameters) + /// + /// A whole routable page, with the services a page injects. + /// + /// + /// Wired to the same fixture the site falls back on, and deliberately without the + /// account services — which is the condition under test for anything about claiming: those are + /// registered only when a connection string is, so a page rendered here sees exactly what a + /// reader of the demo site sees. + /// + public static Task PageAsync(Dictionary parameters) + where TComponent : IComponent => + ComponentAsync(parameters, services => + { + var fixture = new FixtureGameQueries(); + + services.AddSingleton(fixture); + services.AddSingleton(fixture); + services.AddSingleton(TimeProvider.System); + services.AddSingleton(new CatalogueSource(IsMeasured: false)); + }); + + public static async Task ComponentAsync( + Dictionary parameters, + Action? configure = null) where TComponent : IComponent { var services = new ServiceCollection(); services.AddSingleton(NullLoggerFactory.Instance); + configure?.Invoke(services); await using var provider = services.BuildServiceProvider(); await using var renderer = new HtmlRenderer(provider, NullLoggerFactory.Instance);