PST-014: Authenticator secrets and recovery-code seeds are stored unencrypted in the database #743
Labels
No labels
accessibility
authentication
breaking change
bug
documentation
enhancement
interface
internationalisation
observability
performance
security
tier
1
tier
2
tier
3
tier/4
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
Postulo/postulo#743
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Found in the security audit of 2026-10-10 (
SECURITY-AUDIT-2026-10-10.md), run againstmainatc30851bfrom 8 to 10 October 2026, as PST-014. Severity: Low.The text below is the audit's entry, unchanged apart from links to the other issues it names. The finding was produced by automated review and checked by a second automated pass; re-check it before acting on it.
src/postulo/accounts/mfa_adapter.py(23-40)Who can do it, and what they need. Someone holding a database copy, a backup archive (backups include the whole database; backup upload and download exist), a read-only SQL exposure, or the PostgreSQL container, which receives the whole .env. They also have, or can crack offline, the PBKDF2 password hash of a target account.
How it works.
Verified: after TOTP.activate and RecoveryCodes.activate, Authenticator.data holds the plaintext secret and seed.
Impact. The second factor gives no protection against a leaked backup or database copy. Postulo's threat model lists that attacker explicitly and says the secrets that matter are encrypted under a key held outside the database. The infrastructure for that (plugins/secrets.py) exists, but the one secret that is the second factor of every MFA user is not covered. allauth stores these values as given unless the adapter overrides its encrypt hook, and Postulo's adapter does not.
Evidence.
Verification (verdict: confirmed; severity after verification: Low). Keep it low. Drop the claim that the README or a review states TOTP secrets are encrypted, since no such text exists in the repo. The precondition is a database copy or backup plus the target's password, and for fresh copies the db-backed session table already hands over live sessions. Real impact: a stale backup or dump plus a cracked password defeats the second factor permanently, because the seed never rotates. Related aggravation (verified, low): allauth registers AuthenticatorAdmin, so when an operator mounts the Django admin (off by default), a superuser can read any account's TOTP secret and recovery seed in clear. That conflicts with the spirit of #368 (nobody's records in the admin), and the walk test only checks OwnedModel subclasses, so it misses allauth's Authenticator. The superuser is a trusted operator, so this adds little beyond the base finding. Remediation: override MFAAdapter.encrypt/decrypt with Fernet under POSTULO_FIELD_KEY or SECRET_KEY, with a data migration or lazy re-encryption for existing rows, and unregister Authenticator from the admin or hide its data field.
Suggested remediation. Override MFAAdapter.encrypt/decrypt with plugins.secrets (Fernet under POSTULO_FIELD_KEY or SECRET_KEY), with a data migration to encrypt existing rows. Note that the trust-cookie fingerprint reads authenticator seeds and must use the decrypted form.
Fixed in
d3bd87e35on main.