docs(runbook): correct the SECRET_KEY rotation warning and document the TOTP lockout #293
Inga granskare
Etiketter
Inga etiketter
area/backups
area/ci
area/control-panel
area/identity
area/infra
area/observability
area/payments
area/security
area/storage
area/web
blocked
needs-info
needs-triage
ready-for-implementation
type
bug
type
chore
type
docs
type
epic
type
feature
type
task
wontfix
Ingen milstolpe
Inget projekt
Inga tilldelade
1 deltagare
Notiser
Förfallodatum
Inget förfallodatum satt.
Beroenden
Inga beroenden satta
Referens
bitborg/bitborg-infra!293
Läser in…
Hänvisa till i nytt ärende
Ingen beskrivning angiven.
Ta bort grenen "docs/292-secret-key-totp-lockout"
Borttagning av en gren är permanent. Även om den borttagna grenen kan fortsätta existera en kort tid innan den faktiskt tas bort, kan det INTE ångras i de flesta fall. Vill du fortsätta?
Refs #292. Documentation only — no role, template or variable is touched.
What went wrong
The rotation procedure already carried the right warning about
SECRET_KEY, and it was writtenfrom a real incident (#73/#82, 2026-07-17). It named both casualties. Then it waved one of them
off:
That parenthetical is wrong, and it is the whole failure. "SSO-only" describes how people sign in
now; it says nothing about what is still sitting in the database from before. An account created
before the Kanidm cutover can still hold a Forgejo-native TOTP enrolment, and Forgejo walks into it
on the OIDC callback.
So the Actions-secret half of the warning was actioned at the time and the 2FA half was reasoned
away — and on 2026-07-31 it came due as a 500 on the TOTP prompt, with the account locked out:
Worth being precise about the failure mode, because it is easy to misread as user error: the AEAD
tag simply does not verify. It is not a wrong code, not clock skew, and not recoverable by trying
again with a better authenticator. No code can ever succeed.
Why it stayed hidden for two weeks
While a passkey is registered, Forgejo takes the WebAuthn branch and never decrypts the TOTP
secret. The breakage was total from the moment of rotation and completely invisible. Removing a
passkey is what forces the fallback — so the trigger looks unrelated to the cause, fourteen days
and two rotations (#216 as well) downstream.
That gap between cause and symptom is the argument for the diagram rather than another paragraph:
the point an operator needs is that one config change fans out to several ciphertext columns with
unrelated-looking symptoms.
What changed
Step 3 of "Rotate secrets" is reframed as a destructive migration of every ciphertext
column, not a config change — the old key is gone, so each affected row must be re-created or
deleted, never repaired. The two casualties are split into their own bullets so neither can be
skimmed past, the false reassurance is gone, and the 2FA bullet now carries the enumeration query
to run as part of rotating.
A new troubleshooting entry in the OIDC/login list, keyed on the symptom an operator actually
has in front of them — a 500 on the TOTP prompt after a successful OIDC sign-in — rather than on
the cause, which is the thing they do not know yet. It states plainly that no code will work, gives
the query that finds the row and the delete that clears it, and notes the two things easy to get
wrong afterwards:
git.gitborg.semust be cleared before signing in again, or the fix looks like it did not work.
It also records that a recovery code still works without DB access, because those are stored
hashed (
scratch_salt/scratch_hash) rather than encrypted — so that path never touches thebroken decrypt. Useful when the lockout happens to someone without shell access.
Why deleting is the fix, not disabling
The enrolment is not repairable and it is not wanted: sign-in is SSO-only, so MFA belongs to
Kanidm, and a native enrolment can only ever duplicate it while carrying this hazard. Deleting the
row is therefore the correct end state rather than a workaround — which is why the runbook says so
outright instead of leaving the operator to wonder whether they are papering over something.
Verification
markdownlint-cli2and Prettier both clean via the pre-commit hook (the first attempt failed onMD040 for the unlabelled log fence — fixed, not bypassed).
#rotate-secretsand#troubleshooting-lessons-learned-the-hard-way.rootless idiom (
G="sudo -u gitborg env XDG_RUNTIME_DIR=/run/user/2000",POSTGRES_USERreadfrom the container) already used by the runner-diagnosis section, so they are consistent with
what an operator has muscle memory for.
Follow-ups
Tracked in #292, deliberately out of scope here: auditing
two_factorfor other accounts holdinga pre-rotation enrolment (each one is a latent lockout waiting for its owner to drop a passkey),
teaching the 5.18 access review to flag them so this surfaces in a report instead of a lockout,
and the open question of whether native 2FA enrolment should be prevented outright now that
sign-in is SSO-only.