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..cb9f018 --- /dev/null +++ b/migrations/0007_ownership.sql @@ -0,0 +1,180 @@ +-- 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, + + -- 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, + + -- 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 + -- 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/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/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.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.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/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/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.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/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; + } +} 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)) 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);