PST-014: Authenticator secrets and recovery-code seeds are stored unencrypted in the database #743

Closed
opened 2026-10-10 10:55:52 +00:00 by tiagoagueda · 1 comment
Owner

Found in the security audit of 2026-10-10 (SECURITY-AUDIT-2026-10-10.md), run against main at c30851b from 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.


  • Severity: Low
  • Category: cryptographic storage
  • Location: src/postulo/accounts/mfa_adapter.py (23-40)
  • Found by: completeness review: chains

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.

  1. Read accounts' Authenticator rows: type=totp data={'secret': <base32 seed>}; type=recovery_codes data={'seed': <hex>, 'used_mask': n}.
  2. Generate valid TOTP codes, or derive the unused recovery codes from the seed and mask (allauth derives them with HMAC from the seed).
  3. Combine with an offline-cracked or reused password to sign in to the live instance, including administrator accounts, with the second factor satisfied.
    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.

Scratch test output: `totp {'secret': 'YCOFNO6WDBYGL5VLQLAOKVMMNSWQBFFS'}` and `recovery_codes {'seed': '4827888a...', 'used_mask': 0}`. allauth adapter.py:106 `def encrypt(self, text): ... return text`.

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.

Found in the security audit of 2026-10-10 (`SECURITY-AUDIT-2026-10-10.md`), run against `main` at `c30851b` from 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. --- - **Severity:** Low - **Category:** cryptographic storage - **Location:** `src/postulo/accounts/mfa_adapter.py` (23-40) - **Found by:** completeness review: chains **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.** 1. Read accounts' Authenticator rows: type=totp data={'secret': &lt;base32 seed>}; type=recovery_codes data={'seed': &lt;hex>, 'used_mask': n}. 2. Generate valid TOTP codes, or derive the unused recovery codes from the seed and mask (allauth derives them with HMAC from the seed). 3. Combine with an offline-cracked or reused password to sign in to the live instance, including administrator accounts, with the second factor satisfied. 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.** ```text Scratch test output: `totp {'secret': 'YCOFNO6WDBYGL5VLQLAOKVMMNSWQBFFS'}` and `recovery_codes {'seed': '4827888a...', 'used_mask': 0}`. allauth adapter.py:106 `def encrypt(self, text): ... return text`. ``` **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.
tiagoagueda added this to the 0.6.0 milestone 2026-10-10 10:57:25 +00:00
Author
Owner

Fixed in d3bd87e35 on main.

Fixed in d3bd87e35 on main.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Postulo/postulo#743
No description provided.