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
GRANTkeyed on the effective uid; anosuidmount 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 |
|---|---|---|
|
|
Insert into |
|
|
The queue, settings, statistics, whitelist. Not
|
|
|
The wrapped private key material and the key-encryption key — and nothing else of consequence. |
|
|
Read-mostly access to the queue, key metadata, the portal tables. No private key material; no ability to write configuration directly. |
|
|
The discovery request queue and |
|
invoking user (setuid) |
|
|
|
|
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.ingressholdsheadersandbodyunencrypted. 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_nonceanddns_addresstables.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.ciphertextis 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.3. The secure-link portal¶
The portal renders attacker-influenced content (a subject line, a sender name) into HTML, and gates it on a PIN. The design decisions that matter:
the PIN is never stored, in any form. It is an input to the Argon2id derivation of the content key, so an attacker with the database cannot verify a guess without also having the server pepper;
the link and the PIN travel separately — the link to the recipient, the PIN to the sender to pass on by another channel. Compromising one mailbox yields neither the message nor a way to get it;
wrong PINs are counted and lock the message out for a configured interval, so the online guessing rate is bounded;
a reply may be addressed only to the original sender, recorded in the row. The portal is not a mail-sending surface.
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_TRUSTdecides how far down that ladder a deployment is willing to encrypt.A wholesale downgrade by a network attacker with a lying resolver. DANE in
strictdefers 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.