Skip to content

Latest commit

 

History

History
137 lines (104 loc) · 7 KB

File metadata and controls

137 lines (104 loc) · 7 KB

ADR 0034: Self-Service Password Recovery

Status: Accepted (decisions made 2026-08-05; implementation pending)

There is no recovery flow. accountPassword requires the current password and an authenticated session, so it serves someone changing a password, not someone locked out. set-password covers the operator console case only. The gap is fine while the operator is the only account holder and becomes a support problem the week a second person joins.

Four decisions were taken before implementation so the token design has something to satisfy.

A reset is a password change with a different proof of identity

This is the framing the rest follows from (operator, 2026-08-05). A reset and a user-initiated change are the same operation. They differ only in how the requester is shown to be the account holder:

proof of identity
change the current password, plus an authenticated session
reset a single-use token sent to the address on file

After that proof, both do exactly one thing: write the password hashes so the new password works. Nothing else — no session changes, no side effects, no extra state. All the security work is in the proof, which is why the token design gets the care and the password-setting does not need any beyond writing both digests.

That primitive already exists. identity.SetPassword was added on 2026-08-05 for the set-password console tool: it writes the bcrypt and viewer MD5 digests for a named account and touches nothing else. Both flows should sit on it rather than growing their own write paths — an account that can sign in to the website but not a viewer is worse than one that cannot sign in at all, and one write path is how that stays impossible.

A reset does not end existing sessions

The common default is to invalidate every session on reset, on the theory that a reset signals compromise. Here it does not, and the reasoning is worth keeping because it is a deliberate departure.

In this product a reset overwhelmingly means the password was forgotten or a saved credential was lost, not that an account was taken. The operator hit exactly that case on 2026-08-05 by deleting Firestorm's stored credentials: the password was gone from where it was kept, the account was never at risk, and a live viewer session had every right to continue. Ending sessions would have punished the ordinary case to guard against a rarer one.

This also follows from the framing above: a user changing their own password does not expect to be logged out of anything, and a reset is the same operation.

This is a judgement about this product rather than a general security position. If the threat model changes — paid balances, higher-value accounts, evidence of account theft — revisit it, and expect the answer to flip.

The browser-facing API gets its own document, written before the code

The contract goes first, and it goes in api/openapi-public.yaml — a new document for the browser-facing API, distinct from openapi.yaml, which describes the internal grid API used by regions and tools (operator, 2026-08-05).

This corrects a false premise. Contract-first was agreed on the understanding that openapi.yaml was the browser-facing contract. It is not: it is titled Homeworldz Internal API and covers only the internal routes. The 14 routes the management site depends on — /v1/account, /v1/registrations, /v1/tokens, /v1/client/*, /v1/admin/* and the rest — were documented nowhere. The premise came from another session and was passed on here without being checked, which is the same fault this repository spent the day correcting.

The new document describes the two reset endpoints only, and says so in its own description: a route missing from it is undocumented rather than unimplemented. An incomplete spec that admits its incompleteness is honest; one that reads as a full surface is worse than none. Documenting the remaining routes is separate work, and it means describing behaviour that already exists — which is the risky kind.

The reset endpoints were safe to specify now because they are being defined here rather than described from code that already runs.

Reset is independent of login

A successful reset issues no session and no token (operator, 2026-08-05). The person is told the password changed and sent to the login page to sign in with it, like anyone else.

This differs from verification, which does log the new account in, and the difference is the point: verification proves someone controls an address and is setting up this browser to be theirs, while a reset proves only that they could read the email. Those are not the same claim, and signing in the browser that happened to open the link infers the second from the first.

Recorded because the opposite looks like a courtesy. "They just proved it is their account, so log them in" is a plausible improvement someone will propose, and it would couple recovery to session creation for a saved click.

Rate limiting and lockout are in scope from the start

A public endpoint that sends mail on request is an abuse vector even when its reply reveals nothing. Throttling is part of the first implementation rather than a hardening pass, because "add limits later" leaves a working amplifier in the meantime.

The reset link goes only to the address on file

Not negotiable. The request endpoint also answers identically whether or not an address is known, so it cannot be used to enumerate accounts.

The reset URL defaults to the management origin, in the code as well as the file

reset_url must default to https://my.homeworldz.com/reset in config.go, not only in a deployed grid.ini. The website session raised this and the precedent is theirs (2026-08-05, verified here).

verification_url was once repointed on the live grid while config.go still defaulted to https://homeworldz.com/verify. A fresh deployment without the setting would have fallen back to the website origin and kept working, because public/_redirects on homeworldz.com 302s /verify to the management site. A silent dependency on a redirect in another repository, invisible because nothing failed. Fixed in baadf82; the default is now the my. origin.

So the reset default starts there. The website can add a /reset redirect rule on request, but that is a safety net for mail already in flight — never the intended path, and not a substitute for the default being right.

Shape of the work

A single-use reset token with a short expiry, compared in constant time; a public request endpoint; a consume-and-set endpoint; the mail send, modelled on the existing two-step registration flow and the Cloudflare mail path ([mail] verification_url in grid.ini suggests a sibling reset_url); and two screens plus a link on the login page, which currently offers only registration (web/src/pages/LoginPage.jsx:80).

Account recovery is the mechanism by which an account is taken over, so it is worth more care than its endpoint count suggests. The happy path is small; the failure paths are the feature.