The registration rate limit is keyed on the client IP only, three per
hour by default, so a team invited together from behind one NAT could
only accept three invites an hour. An invite registers only the one
username it pins, and is minted by an admin client for one address, so
it can't be used to register in bulk. The email authentication rate
limits still apply to it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CZdGaj9bJcn5QeeUw8YXoF
A registration token which is passwordless and pins both a username and
an email address is an invite for one person. Its registration form
offered the upstream providers too, so an external invitee could pick
the company's single sign-on by mistake. The form now offers none for
an invite. The sign-in link stays, and the sign-in page keeps its own
providers, for an invitee who already has an account.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CZdGaj9bJcn5QeeUw8YXoF
The registration form doesn't render a username pinned by the invite,
and shows a pinned email address as text, so errors the server reports
on either field weren't shown at all: the form just came back. The
invitee never chose these values, so "This username is already taken"
would mean nothing to them either. Each pinned field with an error now
gets its own message above the form, saying the invite can't be used
and to let whoever sent it know: a policy which refuses the address or
the username would refuse a new invite just the same.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CZdGaj9bJcn5QeeUw8YXoF
With password registration disabled, opening an invite link whose token
is unknown, revoked, expired or used redirected to the upstream
provider or to `/login`, so the invitee never learned that the link was
the problem. The page now stays, with the "invite invalid" alert above
the provider buttons and the sign-in link. A valid token which isn't
passwordless can't register there, so it still redirects.
Posting a token which has become invalid re-renders the page with only
the token's error, instead of validating the rest of the form, which
would tell whether a username exists even with registration closed. The
page submits a token only when it resolved, and the re-rendered form
drops it, so that the next submission is an ordinary registration
rather than the same error.
A blank token is no token, on the link and on the form alike, and
spaces around one are ignored.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CZdGaj9bJcn5QeeUw8YXoF
`POST /register` checks that a registration matches the username and
email address its token pins, but the token step could attach any valid
token to a registration afterwards. Registering as anyone, then
attaching a pinned token, registered a username the token doesn't pin.
Swapping a passwordless token for one that isn't left a registration
with no password at all.
The token step now refuses a token that doesn't fit the registration:
one that pins another username or another email address than the
registration's, or one that isn't passwordless for a registration
without a password or an upstream link. Finishing a registration checks
the same against the verified email address, and refuses a passwordless
registration without one. Only a tampered registration gets that far,
so these fail like the other last-minute checks there.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CZdGaj9bJcn5QeeUw8YXoF
`/register?token=…` now resolves the invite code server-side and hands what it
was issued for to the form island through a `data-invite` attribute: a pinned
email address is shown as the line announcing where the verification code goes
rather than as an input, a pinned username isn't shown at all, and a
`passwordless` code replaces the password fields with a notice and relabels the
submit button. A code the server refused only reports that, as an error above
the form.
Because a passwordless code is a way to register on its own, the shortcuts
which send the page to the upstream provider or to `/login` when password
registration is disabled are skipped when the link carries one.
The code itself travels back on submission through a hidden field, so a form
which comes back with errors keeps its shape, and so does the redirect the page
sends the user to when the CSRF token has expired.
The registration form can now carry an invite code, in a hidden `token` field,
and the code is resolved before anything else, so that:
- an unknown, revoked, expired or exhausted code is rejected with an error on
the field, and re-rendering the form keeps the code
- a code which pins a username or an email address only lets that identity
register, an empty field meaning the user accepted what the code pins
- a `passwordless` code skips the password entirely and requires an email
address to verify, even when the server otherwise doesn't, and is a way to
register on its own when password registration is disabled
The code is also consumed whenever one was used, not only when the server
requires one.
A registration token can now be marked `passwordless`, which will let the
user register without picking a password: their identity is established by
the email verification code instead.
This only adds the column and plumbs it through the storage layer and the
admin API; the registration flow itself is unchanged for now.
Registration tokens now report the `/register?token=…` deep link alongside the
`username` and `email` they were issued for, and the create endpoint accepts
both. All the token endpoints now take the `UrlBuilder` so they can build that
link, and `Register` carries the token in its query string.
A registration token can now optionally carry the username and/or the email
address the account it is used for has to be registered with. This only adds
the columns and plumbs them through the storage layer and the CLI; nothing
enforces them yet.
Once the username input settles, the field queries `usernameAvailable`
and reports next to it whether the name is available, taken or rejected
by the registration policy. In the two-step layout, continuing is held
back while a settled verdict says the name can't be used. The island now
declares the GraphQL endpoint on its mount node and brings its own query
client.
Exposes a new query telling whether a given username is available for
registration, and if not, why: already taken, or rejected by the
registration policy. A username reserved on the homeserver side is
deliberately reported as taken, mirroring the ambiguity of the
registration form, which doesn't tell the two apart either.
Because the query reveals whether a username exists, it is rate-limited
per requester, configurable through the new
`rate_limiting.username_availability` option.
The server-rendered registration form is replaced by a React island mounted
on `/register`, which renders the CAPTCHA in-page, gives live password
complexity feedback, and lays out the upstream provider buttons and the
sign-in link the way `/login` does. The island owns the whole form, so every
variant of the page is rendered from the mount data alone, and the user fills
it in one step. Server-side validation errors are threaded back into it
through the mount node, so a rejected submission re-renders with the errors
attached to the fields they belong to, until the user edits them.
`RegisterContext` resolves the provider names and the sign-in link server-side
so that the island only has to render them. The sign-in link is derived from
the post-auth action in the query string instead of the `PostAuthContext`
loaded from the database, which drops a round trip from both the GET and the
invalid-form re-render.
Registering now requires JavaScript, including on the variant with upstream
providers only and on the one without a CAPTCHA: the <noscript> block shows a
notice instead of a duplicate form, which could not work anyway when a CAPTCHA
is configured. While the script loads, the page shows a skeleton of the form
to avoid layout shifts, and a hidden error notice is revealed if it fails to
boot. The server-rendered CAPTCHA component and its entrypoint are removed
with their only consumer.
The class becomes `or-separator`, as the global `.separator` also matched the
footer's separator. The module is imported by `templates.css` for the SSR
`field.separator()` macro.
`POST /register` trims and lowercases the username before either the password
or the upstream provider path looks at it, since Matrix localparts are
lowercase.
The page imported it on its own as well, which is what broke the CAPTCHA rendering fixed by #5992 on the old password registration page. Also test that the page renders with a CAPTCHA configured.
The registration page had two steps: `/register` picked between a password
and an upstream provider, then `/register/password` collected the username,
email and password. Both now live on `/register`, whose `POST` handler runs
the provider branch first and otherwise hands the form to the registration
body, which stays in `password.rs` as a helper.
The page renders the username, email, password and terms-of-service fields,
the captcha and the provider buttons in a single form. The provider buttons
submit that same form, so they are marked `formnovalidate` to skip the
validation of the fields they do not use, and come after the primary submit
button so that Enter still registers.
`/register/password` is gone; a `GET` on it redirects to `/register` with the
query string it was given, so bookmarks and links keep working. Its
`?username=` prefill has no producer left and is dropped.
`GET /register` now renders the page whenever password registration is
enabled, instead of redirecting to `/register/password` when there is no
upstream provider. Without password registration, a single provider still
gets redirected to directly, and no provider at all now redirects to
`/login`, where `/register/password` used to send that case.
An unknown or disabled provider gives a 404 through an inline `GenericError`
rather than the route-wide `RouteError`, and an expired CSRF token on the
password path now sends the user back to `/register` instead of failing the
request.
The registration page is about to render the password registration form
itself, so its context needs the form state and the captcha configuration
`PasswordRegisterContext` carries today.
ulid 3 moves to `rand` 0.10, so it no longer pulls the `rand` 0.9
generation. Only `opentelemetry_sdk` and `sentry` still need it.
ULID generation keeps going through `UlidExt`, whose bit layout matches
ulid's own `from_datetime_with_source`. The only API change affecting
MAS is the rename of `Ulid::new` to `Ulid::generate`, which the clippy
ban list now targets.
`rand` 0.10 and the new RustCrypto generation both build on `rand_core`
0.10, so they have to move together:
- `rand`, `rand_chacha` and `rand_core` 0.10
- `digest` 0.11, `sha2` 0.11, `hmac` 0.13, `const-oid` 0.10
- `elliptic-curve` 0.14, `ecdsa` 0.17, `p256`, `p384` and `k256` 0.14,
`signature` 3
- `der`, `spki` and `sec1` 0.8, `pkcs8` 0.11, `pem-rfc7468` 1.0
- `rsa` 0.10.0-rc.19 and `pkcs1` 0.8.0-rc.5, which have no stable
release yet. They are pinned exactly, because release candidates
break their API between releases.
- `argon2` 0.6 and `pbkdf2` 0.13
On the `rand` side, `RngCore` becomes `Rng` and the old `Rng` becomes
`RngExt`. `gen_*` becomes `random_*`, `thread_rng` becomes `rng`,
`distributions` becomes `distr`, and `from_rng` can no longer fail. On
the crypto side, `mas-jose` and `mas-keystore` move to `BoxedUint` for
RSA, to the `pkcs1` and `pkcs8` reference types, and to the `Generate`
trait for key generation, and `mas-jose` no longer depends on
`generic-array`. Password hashing goes through `hash_password_with_rng`.
Stored data is unaffected. The password hashing snapshots are unchanged,
so bcrypt, Argon2 and PBKDF2 produce the same hashes for the same input
and salt, and `pbkdf2` 0.13 keeps the same defaults (SHA-256, 600 000
rounds). The keystore snapshots change only because keys generated from
a seeded RNG differ. The snapshots of keys loaded from files are
unchanged.
`opa-wasm` 0.3.3 already uses this generation, so most of the duplicate
crates it pulled in go away. The `deny.toml` skip list now names what
still pulls the older versions: cookie, sqlx, headers, wasmtime and pest
for RustCrypto, and opentelemetry_sdk, sentry and ulid for `rand` 0.9.
It moves from `generic-array` to `hybrid-array`, so `Encrypter` builds
its key and nonce with the `Key` and `Nonce` aliases, and `mas-keystore`
no longer depends on `generic-array`. Cookie (via `aes-gcm`) is still on
`aead` 0.5, so the older `aead` and `universal-hash` go on the cargo-deny
skip list.
The hashes it produces are unchanged. It pulls `blowfish` 0.10, which is
on `cipher` 0.5 while the rest of the tree is on 0.4, so the older
`cipher` and `inout` go on the cargo-deny skip list.
The `cargo deny` bans check fails on main because two dependency bumps
introduced duplicate crate versions:
- `opa-wasm` 0.3.3 (#6029) uses `rand` 0.10, `base64` 0.23 and the
`digest` 0.11 generation of the RustCrypto crates, while the rest of
the tree is still on `rand` 0.9, `base64` 0.22 and `digest` 0.10.
- The icu bump (#6015) pulls `zerovec-derive` 0.11.6, which uses `syn` 3,
while everything else is on `syn` 2.
Add the older versions to the skip list until the rest of the tree
catches up.