.. This file is part of PEPSI. Copyright (C) 2026 Pepsi contributors PEPSI is free software; you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation; either version 3, or (at your option) any later version. PEPSI is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. ================= 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. .. contents:: On this page :local: :depth: 2 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 :ref:`sec-db`. * **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 :doc:`programs/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. .. _sec-privilege: 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: .. list-table:: :header-rows: 1 :widths: 24 20 56 * - 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``. .. _sec-keys: 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. .. _sec-db: 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. .. _sec-config: 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/.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. .. _sec-worker: 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 :doc:`testing`. **``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 :ref:`packages-hardening`. Specific defences ================= 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. 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. 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. 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. 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. 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. See also ======== :doc:`key-management` — identities, custody, discovery and the trust ladder. :doc:`secure-link` — the portal in full. :doc:`admin-api` — the API surface and its scopes. :doc:`interoperability` — what other clients can actually read, which bounds what protection is achievable in practice. :doc:`testing` — the fuzzing and adversarial corpora behind the parser claims.