PST-082: The backup manifest publishes an unsalted SHA-256 of the secret key, and the field-encryption key is a single unsalted hash #811

Closed
opened 2026-10-10 10:56:50 +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-082. Severity: Informational.

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: Informational
  • Category: weak-crypto / secret-exposure
  • Location: src/postulo/plugins/secrets.py (30-31,44-53; 44-53; 34-36,63-76)
  • Found by: file handling, secrets and cryptography
  • Related: PST-043 (#772)

Who can do it, and what they need. Someone who obtains a backup archive (the threat model's own row): a leaked off-site copy, a stolen disk, a mis-shared download. Precondition: the operator did not set POSTULO_FIELD_KEY (the default), so the fingerprint is of Django's SECRET_KEY, and that key is a human-chosen value that passed the …

How it works.

  1. Open the archive and read manifest.json -> secrets.field_key_sha256.
  2. The value is sha256(b"postulo-field-key-fingerprint:" + SECRET_KEY): a fixed public prefix, no salt, no work factor.
  3. Run a mask/dictionary/rule attack at SHA-256 speed (billions per second on commodity GPUs) against candidate keys that satisfy the 50-char/5-distinct rule.
  4. With SECRET_KEY recovered: forge session cookies and password-reset / email-confirmation tokens for any account including administrators on the live instance (the backup is otherwise only data), and derive the Fernet key (_key() is sha256("postulo-connection-secrets:" + material)) to decrypt every connection secret, the mail password, OAuth refresh tokens and the instance mailbox credentials in the same archive.

Impact. Turns possession of a backup into a route back into the running instance and into every encrypted secret, whenever the key is guessable. For a 64-byte token_urlsafe key the hash is uncrackable, so the exposure is bounded by key quality; the threat model acknowledges a weak key would be guessable from it, but the gate it relies on only checks length and character variety.

Verification (verdict: confirmed; severity after verification: Informational; first-pass claim: Low). Real but not incremental: the cracking oracle also exists in the DB inside the same archive (session MAC, Fernet tokens), and the threat model documents the fingerprint caveat. Drop the 'forge session cookies' impact (sessions are server-side, unsigned random ids); keep 'forge signed reset/confirmation tokens and decrypt Fernet secrets' for a guessable SECRET_KEY. Cite src/postulo/plugins/secrets.py:44-53 and src/postulo/core/backup.py:462. Only mitigation that matters is key quality (the gate checks length and variety only, as keys.py:88-91 itself admits); a slow KDF for the fingerprint would not close the session/Fernet oracles.

Suggested remediation. Make the mark expensive or keyed: store a random 16-byte salt in the manifest and scrypt/PBKDF2-HMAC-SHA256 (high iteration count) of the material under it, or an HMAC under a key derived with HKDF so equality still verifies at restore. Alternatively drop the fingerprint and answer the restore question by trial-decrypting one stored secret. Document that setting POSTULO_FIELD_KEY to a random value keeps SECRET_KEY out of every backup.

Also covered by this issue.

  • Backup manifest fingerprint is an unsalted, unstretched SHA-256 of the raw SECRET_KEY, so any copy of a backup is an offline verifier for the session-signing key (src/postulo/plugins/secrets.py (44-53); Informational). Nothing beyond what the threat model concedes for a weak key; recorded because the fingerprint is of the whole master secret (not only the derived field key) and costs one hash per guess.
  • Field-encryption key is one unsalted SHA-256 of the secret with no stretching, no per-instance salt and no re-encryption path for rotation (src/postulo/plugins/secrets.py (34-36,63-76); Informational). A leaked master secret cannot be fully retired; weak keys are cheap to test. No direct attack on a strong key.
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-082**. Severity: **Informational**. 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:** Informational - **Category:** weak-crypto / secret-exposure - **Location:** `src/postulo/plugins/secrets.py` (30-31,44-53; 44-53; 34-36,63-76) - **Found by:** file handling, secrets and cryptography - **Related:** PST-043 (#772) **Who can do it, and what they need.** Someone who obtains a backup archive (the threat model's own row): a leaked off-site copy, a stolen disk, a mis-shared download. Precondition: the operator did not set POSTULO_FIELD_KEY (the default), so the fingerprint is of Django's SECRET_KEY, and that key is a human-chosen value that passed the … **How it works.** 1. Open the archive and read `manifest.json` -> `secrets.field_key_sha256`. 2. The value is `sha256(b"postulo-field-key-fingerprint:" + SECRET_KEY)`: a fixed public prefix, no salt, no work factor. 3. Run a mask/dictionary/rule attack at SHA-256 speed (billions per second on commodity GPUs) against candidate keys that satisfy the 50-char/5-distinct rule. 4. With SECRET_KEY recovered: forge session cookies and password-reset / email-confirmation tokens for any account including administrators on the live instance (the backup is otherwise only data), and derive the Fernet key (`_key()` is `sha256("postulo-connection-secrets:" + material)`) to decrypt every connection secret, the mail password, OAuth refresh tokens and the instance mailbox credentials in the same archive. **Impact.** Turns possession of a backup into a route back into the running instance and into every encrypted secret, whenever the key is guessable. For a 64-byte `token_urlsafe` key the hash is uncrackable, so the exposure is bounded by key quality; the threat model acknowledges a weak key would be guessable from it, but the gate it relies on only checks length and character variety. **Verification** (verdict: confirmed; severity after verification: Informational; first-pass claim: Low). Real but not incremental: the cracking oracle also exists in the DB inside the same archive (session MAC, Fernet tokens), and the threat model documents the fingerprint caveat. Drop the 'forge session cookies' impact (sessions are server-side, unsigned random ids); keep 'forge signed reset/confirmation tokens and decrypt Fernet secrets' for a guessable SECRET_KEY. Cite src/postulo/plugins/secrets.py:44-53 and src/postulo/core/backup.py:462. Only mitigation that matters is key quality (the gate checks length and variety only, as keys.py:88-91 itself admits); a slow KDF for the fingerprint would not close the session/Fernet oracles. **Suggested remediation.** Make the mark expensive or keyed: store a random 16-byte salt in the manifest and `scrypt`/`PBKDF2-HMAC-SHA256` (high iteration count) of the material under it, or an HMAC under a key derived with HKDF so equality still verifies at restore. Alternatively drop the fingerprint and answer the restore question by trial-decrypting one stored secret. Document that setting POSTULO_FIELD_KEY to a random value keeps SECRET_KEY out of every backup. **Also covered by this issue.** - **Backup manifest fingerprint is an unsalted, unstretched SHA-256 of the raw SECRET_KEY, so any copy of a backup is an offline verifier for the session-signing key** (`src/postulo/plugins/secrets.py` (44-53); Informational). Nothing beyond what the threat model concedes for a weak key; recorded because the fingerprint is of the whole master secret (not only the derived field key) and costs one hash per guess. - **Field-encryption key is one unsalted SHA-256 of the secret with no stretching, no per-instance salt and no re-encryption path for rotation** (`src/postulo/plugins/secrets.py` (34-36,63-76); Informational). A leaked master secret cannot be fully retired; weak keys are cheap to test. No direct attack on a strong key.
tiagoagueda added this to the 1.0.0 milestone 2026-10-10 10:58:14 +00:00
Author
Owner

Fixed in 3f6fa6211 on main.

Fixed in 3f6fa6211 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#811
No description provided.