What this does #
The relying-party id was a settings field written once, by setup (the wizard prefills it from the node's PTR). It still lives in the record and the wizard still asks for it — but the console can now move it afterwards:
- Passkey records carry the rpId they were registered under (
packages/records):Passkey.rpId?, stamped by the registration ceremony, checked at every assertion. A credential enrolled under a domain the install no longer answers as is refused with the actionable answer — both domains named — instead of a bare rpIdHash mismatch. Records enrolled before the field existed carry none, and for those the ceremony's rpIdHash check remains the only test, exactly as before. - auth applies the change live:
/internal/settings/reloadpushes the record's rpId into its webauthn service (setRpId), so registrations begin on the new domain without a restart, on every node the nudge reaches. The origins never change with it — the rpId is validated to be the origins' host or a parent of it, so the same origins keep answering every ceremony. - The console's passkey-domain action (organisation settings page): step-up protected; validates the new domain with auth's own
checkRelyingPartyagainst EVERY origin the install answers on (a second origin outside the new domain would strand the credentials enrolled on it); refuses a no-op; writes theauthsection; rebuilds the console's service graph on the new rpId; nudges the peers; then serves the enrolment ceremony for the administrator's own replacement passkey — gated by the single-use, user-bound challenge that the verified step-up minted.
Why the re-enrolment rides the action #
The step-up is a fresh passkey assertion, and the browser only offers credentials under the rpId being asserted against. After the change the administrator's old passkey can never assert again, so the ordinary portal add-passkey flow is unreachable for exactly the person who must run it. The action therefore takes the step-up BEFORE the write — the old passkey's last working assertion — and bridges to the enrolment with the challenge: proof → write → challenge → ceremony. A session thief cannot mint the challenge (the step-up refuses them) and cannot spend it without a genuine user-verified ceremony. Live sessions stay valid throughout; a session is not a passkey. An administrator who abandons the enrolment holds no stepping-up credential either; the way back in is the break-glass path (svc-auth recover-admin approving a passkey registered on the first-passkey page), which is the suite's existing answer for an administrator with no working passkey.
The migration procedure #
- Sign in on the current host with the working passkey. Organisation settings → Passkey domain: enter the apex (
ordner.fyiwhen the install answers aslab.ordner.fyi), tick the confirmation, submit — the form takes the step-up prompt. - The page answers with the enrolment ceremony: create the new passkey — the browser runs it under the new rpId at the current origin, which WebAuthn allows (the rpId may be any registrable-domain suffix of the origin). The done page confirms; the old passkey can be removed from the account page afterwards.
- Every other user's passkey stops matching at its next use — the authenticator scopes credentials to the rpId they were registered under — and they sign in through the existing
/recoverprocedure and enrol replacements. - The administrators, now holding apex-bound passkeys, approve the recovery requests on the approvals page as usual (§5.3).
Validation and security notes #
checkRpId+checkRelyingPartyrun at the console action against every origin: the new rpId must be a registrable domain the origin's host equals or sits under — an unrelated rpId is rejected loudly, because a wrong one would break every login. The wizard keeps its existing validation.- The generic settings form still refuses
rpIdas a key it does not model (its error no longer claims the field is written once); the action is the only writer, and it is inSTEP_UP_ROUTES— the source-scan test holds that list to the handlers that callrequireStepUpAdmin. - The enrolment challenge is purpose-bound (
register), user-bound, single-use, five-minute TTL, HMAC-sealed in the record store — the same store the portal uses — so a challenge minted by the console cannot be spent anywhere else, and the portal's finish leg still demands its own fresh step-up. - The stored-rpId check fails fast, before the challenge is consumed, so a stale credential does not burn a challenge.
- The ceremony's
excludeCredentialsand the credid claim (before the user record is written) are unchanged; the enrolment audits aspasskey-addedthrough the console graph's own audit sink. - Rollback: an older auth ignores the new field on read, and the rpIdHash backstop refuses what the field does not name.
Tests #
Run locally on the CI's Node line (26.11.1-compatible 26.10):
auth— 1750 pass: the ceremony stamps the rpId on enrolment; a credential under the moved domain is refused with both domains named; a legacy record without the field still asserts under the old rpId and is refused by the hash check after the change; a registration aftersetRpIdruns under the new rpId and asserts.admin— 680 pass: the console flow runs both legs against the real service graph with the forged authenticator; a missing proof is 428 and writes nothing; an unrelated domain is refused loudly; a no-op change is refused; an enrolment without a ceremony the flow issued is refused with the break-glass pointer; the page renders the action instead of a read-only rpId row; the step-up source-scan still holds; the i18n catalogs carry the new keys in every language.packages/records— 145 pass:parsePasskeykeeps the field, tolerates its absence, refuses a non-domain value.- Whole workspace:
npm run build,npm run check(biome),npm run typecheckall clean;check:citationsclean.
Docs #
docs/design/identity.md§3.1 — the rpId semantics: settable, apex over subdomain, the per-credential rpId, the migration flow.docs/operations/install.md§7 — the wizard step explains the choice and its cost instead of "bound permanently".docs/areas/administration.md— §6.5 describes the action; §1's step-up note no longer claims the settings never touch the WebAuthn RP; the wizard's rpId note is rewritten in every language.