Skip to content

Recover a forgotten password without email or a shell - #66

Merged
Otitodev merged 6 commits into
mainfrom
feat/password-recovery
Oct 13, 2026
Merged

Otitodev merged 6 commits into
mainfrom
feat/password-recovery

Conversation

@Otitodev

Copy link
Copy Markdown
Owner

What and why

A forgotten dashboard password had no way back that works on the platforms SESKit is deployed to. Email cannot be the answer: a fresh instance may be unable to send any until someone can sign in and connect AWS, which is when a password gets forgotten. A command cannot be either: a deployment made by pushing to Git gives its owner no shell. So recovery rests on something the owner already holds.

Four commits, in order:

  • seskit reset-password (b17d75e, docs f13f0d3). For anyone with a shell. Hidden prompts, never an argument; ends the account's sessions. The 12 character minimum moves into core so sign-up and reset share it.
  • Recovery codes and RECOVERY_KEY (b9cf42c, docs b37f701):
    • A code like K7QD-4M2X-9PHB-TWE6 is shown once after sign-up (80 bits, SHA-256 stored, look-alike characters resolved). Using it replaces it in the same step.
    • RECOVERY_KEY is the operator's equivalent, accepted in the same field, for accounts with no code. Off when empty; the instance refuses to boot with one under 24 characters.
    • /forgot-password, and /settings (the sidebar link that went nowhere) to see status and make a new code. A new code asks for the password again, because a session is also what a stolen cookie looks like.
    • Every failure returns the same answer. Attempts are limited per email alone, not per address, since the address is whatever X-Forwarded-For claims; a test sends every guess from a different claimed address and expects the same cut-off.

One nullable column on users. Existing accounts have NULL and recover through RECOVERY_KEY, or by signing in and making a code from Settings.

Checks

No checkboxes: the template's three need things I could not do from this machine.

Run locally: ruff, ruff format, mypy and every pre-commit hook on each commit; the 17 recovery tests that need no database; an offline render of every new page, including an escaping check; and the recovery service driven against a stand-in session.

Not run: the 26 recovery tests and 7 reset-command tests that need Postgres and Redis, alembic upgrade head and tests/test_migrations.py for the new migration, mkdocs build --strict, and anything by hand in a browser. CI covers the first three; the last I have not done, so the sign-up and recovery pages have not been seen rendered with the real stylesheet.

🤖 Generated with Claude Code

Otitodev and others added 6 commits October 13, 2026 11:32
The login page has said "forgotten passwords are reset from the command line"
since it was written, and there was no command. An operator who forgot their
password had to hash one by hand and write it into the users table.

There is deliberately no emailed reset link. A fresh instance may be unable to
send email until somebody can sign in and connect AWS, and that is the moment a
password is most likely to be forgotten; a reset that depends on mail would
fail exactly there. Whoever can run this command already holds the database and
SECRET_KEY, so it adds no access, and it can say plainly when an address matches
no account.

The decision lives in core as `reset_password`, tested without HTTP like the rest
of the user logic. The command wraps it and ends the account's sessions, because
a reset means the password was lost or taken, and in the second case every
session already open is what is being reset away. The password is committed
before anything is revoked, so a Redis failure leaves a changed password with
old sessions standing, which running the command again repairs, rather than
revoked sessions and the old password still in force.

The password is read from a hidden prompt and is not an argument or an
environment variable; either would put it in shell history or a process listing.
It must meet the same 12 character floor as sign-up, which moved from the auth
route into core so the two cannot drift apart. A deactivated account is reset
but not reactivated, and the command says so.

Registered as the `seskit` script of apps/api, and runnable as
`python -m seskit_api.cli`. The SDK is a library with no script of its own, so
the name is free.

Tests cover the new password working and the old one not, address matching the
way login matches it, the account's sessions ending and another account's
surviving, a short password changing nothing, and an unknown address. Those need
Postgres and Redis and were not run locally; the prompt tests need neither and
were.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A section in the troubleshooting page for the command that the login page has
long promised. It says why there is no emailed link, since that is the question
a reader arrives with, that the password is typed at a prompt rather than passed,
and that a reset signs the account out everywhere.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A forgotten dashboard password had no way back that worked on the platforms
SESKit is deployed to. Email cannot be the answer: a fresh instance may be
unable to send any until somebody can sign in and connect AWS, which is when a
password is most likely to be forgotten. A command cannot be either: a
deployment made by pushing to Git gives its owner no shell to run one in. So
recovery rests on something the owner already holds, in two forms.

A recovery code is shown once, right after sign-up, as K7QD-4M2X-9PHB-TWE6: 80
bits from an alphabet without I, L, O and U, with the look-alikes mapped onto
what was meant so a code copied by eye or retyped in lowercase still works. Only
its SHA-256 is kept, for the reason api_keys.py gives at length - Argon2 exists
to make a guessable secret expensive, and 80 random bits are not guessable.
Using it replaces it in the same step, because an account that spent its only
code would be locked out of doing this again. Settings makes a new one, and asks
for the password first: a signed-in session is also what a stolen cookie looks
like, and an unguarded button would let the thief keep the account after the
owner changes their password.

RECOVERY_KEY is the operator's equivalent, accepted in the same form field. It
exists because a code only helps an account that has one, and every account made
before this change does not. Whoever sets the deployment's environment already
controls the instance, so accepting the key grants nothing they did not hold. It
is off when empty, and an instance refuses to boot with one shorter than 24
characters rather than quietly ignoring it, since an ignored key leaves the
operator believing they have a way back in.

Every way of being wrong - no such account, a deactivated one, no code on file,
a wrong code, a wrong key - returns the same answer and does the same work, so
the page cannot be used to find out which addresses have accounts. A success
signs the account out everywhere before starting the new session, because
someone recovering may be doing so as the result of a takeover.

Attempts are limited per email alone, not per email and address as login is. The
address is whatever X-Forwarded-For claims, so a limit that includes it is one
the attacker chooses the size of; a test sends every guess from a different
claimed address and expects the same cut-off. The cost is that someone can use
up a known email's allowance and delay that person's recovery for one window,
which is the right way round for a limit guarding account takeover: signing in
is unaffected and the delay ends by itself.

The code is the response to the POST rather than a redirect, since a redirect
would need somewhere to leave it, and is sent no-store.

/settings had been a link in the sidebar leading nowhere; it now holds the
recovery code status. The 12 character minimum, already moved to core for the
reset-password command, is shared with the new form.

The migration adds one nullable column. Existing users have NULL and recover
through RECOVERY_KEY, or by signing in and being issued a code from Settings.

The unit tests (generation, normalisation, matching, the key, the settings
check) and an offline render of every new page were run locally, along with the
service driven against a stand-in session. The twenty-six that need Postgres and
Redis were not; CI runs them.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The troubleshooting section now leads with what a deployment without a shell can
do - the recovery code, then RECOVERY_KEY - and keeps the command for people who
have one. It says what each costs and guarantees: a code works once and
announces its replacement, recovery signs the account out everywhere, attempts
pause for a window, and an instance refuses to start with a key under 24
characters.

The installation guide and the first-email walkthrough tell a new owner to save
the code at the one moment it is shown, which is the only place it can be said
usefully. RECOVERY_KEY gets its row in the configuration reference and a
commented example in .env.example.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Signing up now answers with the page that shows the recovery code, as a 200 with
the session cookie on it, instead of redirecting to the dashboard. Five helpers
asserted the redirect - the shared signed_in_client fixture and one _sign_in per
page test file - so 234 tests failed in setup for one reason. That was a change
this branch made on purpose and should have updated in the same commit as the
route.

The recovery tests had a second, separate fault: the keyed_client fixture asked
for app_client through getfixturevalue, which cannot start an async fixture from
inside another one and fails with "Runner.run() cannot be called from a running
event loop". It now takes the environment fixture and app_client as arguments,
which pytest sets up left to right, and which is the order that matters: the
settings fixture reads RECOVERY_KEY when it first runs.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The previous commit updated five helpers that asserted the sign-up redirect and
missed a sixth: test_aws.py has its own _sign_in. Thirty-three tests in that
file failed in CI for the same reason as the rest. I had searched for the
pattern in the four files that came to mind rather than across tests/, which is
how one was missed; a repository-wide search for the POST now shows every
remaining assertion on a 303 is a GET to /signup, which still redirects.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@Otitodev
Otitodev merged commit 34aa0c2 into main Oct 13, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant