12. Security model

This chapter says what Pepsi defends against, what it does not, and — most usefully when something has gone wrong — what an attacker gets from each thing they might have taken. It is written to be read after a compromise as well as before one.

Pepsi is an MTA that holds private keys and can read mail. That combination makes it worth attacking, so the design keeps asking one question: when this component is compromised, what is still out of reach? Where the answer is “nothing”, that is said here plainly rather than left to be discovered.

12.1. What Pepsi assumes

These are load-bearing. If one is false, the guarantees below do not hold:

  • The host is not already compromised. Nothing here defends against root on the machine. Root can read every key, every secret and every message.

  • PostgreSQL authenticates by peer credentials over a local socket. Each component connects as its own operating-system user, and the database grants that role only what the component needs. A deployment that switches to password authentication with one shared account throws away most of An attacker who has the database.

  • The operating system enforces file ownership and the setuid bit. The private-key boundary is a database GRANT keyed on the effective uid; a nosuid mount or a shared account collapses it.

  • A validating DNS resolver. Pepsi trusts the AD bit rather than validating DNSSEC itself (see pepsi-stage-relay-to-internet). A lying resolver defeats DANE and downgrades MTA-STS to trust-on-first-use.

  • TLS certificate material is not attacker-supplied. Certbot integration writes into /etc/letsencrypt; an attacker who can write there can impersonate the server.

12.2. The privilege split

Pepsi is not one program. Each component runs as its own account, and the separation is enforced by the database as much as by the filesystem:

Component

Runs as

What it can reach

pepsi-ingress

pepsi-ingress

Insert into pepsi.ingress; the SRS secret; TLS material. No private key material.

pepsi-dispatch and most stages

pepsi

The queue, settings, statistics, whitelist. Not crypto_identity.private_wrapped.

pepsi-stage-encrypt / -decrypt / pepsi-keys

pepsi-crypto (setuid)

The wrapped private key material and the key-encryption key — and nothing else of consequence.

pepsi-httpd

pepsi-httpd

Read-mostly access to the queue, key metadata, the portal tables. No private key material; no ability to write configuration directly.

pepsi-keydisc

pepsi-keydisc

The discovery request queue and peer_key. Never a general UPDATE on pepsi.ingress.

pepsi-whitelist

invoking user (setuid)

SELECT/INSERT/DELETE on pepsi.whitelist only.

pepsi-quota

pepsi (setgid pepsi-maildir)

pepsi.mailbox_quota, and — through the group — the setuid-root helper that measures a mailbox.

Mailbox quota keeps its measuring and its refusing apart, and that split is the design. Only pepsi-helper-maildir-writer can look inside a Maildir (mode 0700; it is the one program that becomes the user), and only members of pepsi-maildir may run it — a group pepsi-ingress is deliberately not in and pepsi-setup warns if anyone adds it to. So the process listening on port 25 can read a measurement somebody else took and refuse a recipient on it, and can do nothing else: pepsi-setup revokes its write access to pepsi.mailbox_quota, leaving SELECT. Both roads to the accounting are closed to it, and neither closure relies on the other.

The crypto stages are setuid, not setgid, and that is not a style choice: PostgreSQL peer authentication keys off the effective uid, so a stage that merely gained a group would still connect as pepsi and be refused the key column. It has to be pepsi-crypto.

12.3. The private-key custody boundary

Private key material lives in crypto_identity.private_wrapped, and is protected twice over:

By grant. pepsi-setup grants that one column to the pepsi-crypto role and revokes it from every other account, pepsi-httpd included. This is column-level and it is absolute: PostgreSQL refuses a statement that so much as names the column, even inside an IS NULL test, so there is no read path to narrow later.

Note

That absoluteness has a practical edge worth knowing. A listing query that wanted to report “does this identity have a private half?” as private_wrapped IS NOT NULL was refused for every role but pepsi-crypto — and PostgreSQL’s error, permission denied for table crypto_identity, names no column. The listing now derives the flag from wrap_key_id, which a table CHECK keeps exactly in step. The boundary held; it just held somewhere surprising.

By wrapping. What is stored is not a private key but an AEAD ciphertext, bound to that row’s (address, protocol, purpose) as associated data under a key-encryption key that lives outside the database, in a secrets.d/*.secret fragment readable only by pepsi-crypto. Moving a blob between rows fails to authenticate.

12.4. An attacker who has the database

Assume a full dump — a stolen backup, a replica, SQL injection into a read-only path.

They get:

  • every message currently in the queue, in the clear: pepsi.ingress holds headers and body unencrypted. Pepsi is a pipeline; a message in flight has to be readable by the stage that will process it. Delivered messages are deleted, so this is the backlog, not the history.

  • the full correspondence graph: senders, recipients, timestamps, per-address settings, whitelists, the origin_nonce and dns_address tables.

  • every public key and certificate, and all key metadata.

  • the ARC/DKIM/DMARC verdicts and TLS session outcomes.

They do not get:

  • any private key. The wrapped blob is AEAD ciphertext and the key-encryption key is not in the database.

  • any secure-link message body. secure_message.ciphertext is AES-256-GCM under a key derived by Argon2id from the recipient’s PIN, a per-message salt and a server-side pepper held in the configuration, not the database. The PIN itself is never stored in any form — not even a hash. A database thief must brute-force a PIN they cannot verify offline without also having the pepper.

  • any usable credential. Admin passwords are Argon2id hashes; API tokens are stored as digests, so a token cannot be recovered from a backup and is shown exactly once.

  • the DKIM signing keys, which live in the filesystem, and the SRS and Pepsi-Origin secrets, which live in secrets.d.

Warning

The queue is the exposure. An operator who considers mail-in-flight confidentiality important should encrypt the database at rest and keep backups accordingly — Pepsi cannot encrypt the queue to itself, because every stage would need the key, which is the same as not having one.

12.5. An attacker who has the configuration file

/etc/pepsi/pepsi.conf is mode 0644 on purpose: unprivileged stages read it. It therefore must contain no secrets, and does not — each secret lives in a secrets.d/<reader>.secret fragment pulled in by an @inline-secret@ directive and owned by the one account that needs it.

Read access to the config file yields the topology: hostnames, served domains, the pipeline graph, smarthost addresses, file paths, which features are on. That is reconnaissance, not compromise.

Read access to a ``secrets.d`` fragment yields exactly what its one reader holds, and no more — the SRS key, or the smarthost password, or the key-encryption key, or the secure-link pepper. This is the whole point of splitting them: there is no single file whose disclosure is total.

Write access to the config file is equivalent to root. It names the programs each stage runs. Treat it as a root-owned file, because it is one.

Warning

Two traps around @inline-secret@, both of which look like a working configuration:

  • the directive ends its section — anything written after it lands in no section at all;

  • an unreadable fragment only warns. The option then reads as unset, and a feature silently runs without its secret. Verify by stat-ing the fragment as the reader account, not by reading the log.

12.6. An attacker who compromises a stage worker

Say a parser bug gives code execution inside a stage — the realistic case, since stages parse hostile input for a living.

A ``pepsi``-user stage (most of them) gets the queue: read, modify, redirect or delete any message in flight, and read the whitelist and settings. It does not get private keys, and it cannot become another account. This is the blast radius the split exists to bound — a compromised language detector cannot sign mail as your domain.

A ``pepsi-crypto`` stage gets everything that account has: it can decrypt inbound mail addressed to the deployment and sign as any local identity. This is the highest-value target in the system, which is why the two crypto stages are small, do no network I/O at all (discovery is a separate program), and are the subject of the fuzzing in Test Suite.

``pepsi-httpd`` gets the web surface and its database grants. It notably cannot write configuration or run privileged setup steps: the browser wizard does not act, it inserts a row into pepsi.setup_task describing what should be true, and the separately-started, root-running pepsi-setup apply decides whether to do it. A compromised web tier can therefore request a reconfiguration but not perform one — and the applier validates each task rather than trusting its description.

That last defence can be turned into an absolute one, and it is worth doing on any deployment administered from a terminal. The applier’s two systemd units are their own Debian package, pepsi-httpd-admin (pepsi only Recommends it, so apt remove pepsi-httpd-admin succeeds and leaves the mail server running); a source install gets the same lever from make install INSTALL_ADMIN_UNITS=no. With them gone nothing drains pepsi.setup_task, so an insert into it is inert whatever happens to pepsi-httpd — the loop is broken at the mechanism rather than at the interface, and no code path in the web tier can start a root process. The console detects this, fails closed if it cannot tell, and serves its setup pages read-only. pepsi-setup stays in the base package, so administering the host by hand is unaffected. See The split as a hardening lever.

12.7. Specific defences

12.7.1. Header forgery

X-Pepsi-Crypto tells a user’s client what Pepsi concluded about a message’s cryptography, so a forged one is a lie told with our authority. Inbound mail has the header stripped before the decrypt stage can add its own, and outbound request headers are stripped by STRIP_REQUEST_HEADERS. A remote sender cannot make one appear.

The same reasoning covers Authentication-Results and Pepsi-Origin: a header that means “the local system verified this” is only meaningful if the local system is the only thing that can write it.

12.7.2. Key-discovery SSRF

Key discovery fetches URLs derived from an e-mail address — WKD, and HTTP key servers — which is a server-side request forgery primitive by construction. pepsi-keydisc resolves the hostname itself and refuses every candidate address in loopback, RFC 1918, RFC 6598 CGNAT, link-local or unique-local space, and re-applies the check on each redirect (the redirect is the hole an address-check-then-connect design leaves open). The check is disabled only by an explicit test seam, because a stub HTTPS server necessarily listens on loopback.

12.7.4. The administrative API

Three authentication mechanisms, one identity model: SO_PEERCRED over a UNIX socket (unforgeable, and how first-run bootstrap works with no accounts yet), bearer tokens for automation (digest-stored, constant-time compared), and password plus session cookie for browsers (Argon2id, HttpOnly and SameSite=Strict, CSRF token on every mutating request). Administrative routes mount only on listeners flagged ADMIN = yes, so a relay install that never enables such a listener does not expose the surface at all.

Responses never contain secrets: GET /api/v1/config masks credential-bearing options rather than omitting them, so an operator can see that a password is set without the endpoint becoming a way to read one back.

12.8. What Pepsi does not defend against

Stated so nobody has to infer it:

  • Traffic analysis. Who corresponds with whom, when, and how much is visible to anyone who can see the queue or the logs.

  • A malicious operator, or root. Both can read everything.

  • Metadata in end-to-end encrypted mail. Envelope addresses are not encrypted, and header protection covers only what the container allows.

  • Compromise of the correspondent. A key discovered over WKD or DANE is only as good as the domain serving it; MIN_TRUST decides how far down that ladder a deployment is willing to encrypt.

  • A wholesale downgrade by a network attacker with a lying resolver. DANE in strict defers rather than delivers, which converts the attack into a denial of service rather than a disclosure — that is the best available answer, not a complete one.

12.9. Reporting a vulnerability

Please report security issues privately to the address in debian/control rather than through a public tracker, and allow time for a fix before disclosure.

12.10. See also

Key management — identities, custody, discovery and the trust ladder. The secure-link fallback portal — the portal in full. The administrative API — the API surface and its scopes. Client interoperability — what other clients can actually read, which bounds what protection is achievable in practice. Test Suite — the fuzzing and adversarial corpora behind the parser claims.