15. Security model¶
This chapter says what Pepsi defends against, what it does not, and what an attacker gets from each thing they might have taken — so it is as useful after a compromise as before one.
Pepsi is an MTA that holds private keys and can read mail, which makes it worth attacking. 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 rather than left to be discovered.
The claims — which assets, against which adversaries, in which custody mode — are stated in Threat model; this chapter is the mechanisms behind them.
15.1. What Pepsi assumes¶
If one of these 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.
15.2. The privilege split¶
Upholds
A compromised component (what each compromised account reaches), A local shell user (no role reachable from an ordinary account) and Somebody holding a copy of the database (what a service role can write).
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.
The database side draws two kinds of line, and it is worth being exact about both.
The pipeline role is broad on purpose. pepsi (the dispatcher and every
ordinary stage) and pepsi-crypto hold one blanket grant: SELECT,
INSERT, UPDATE and DELETE on every table in the pepsi schema, use
of every sequence, EXECUTE on every function, and the same as default
privileges for objects created later. Every stage reads and writes the queue, and
between them the stages use every pipeline table, so least privilege per stage
would need an account per stage; the consequence is stated in
Threat model rather than hidden. The grant is then taken back where the
pipeline has no business:
crypto_identity:SELECT/INSERT/UPDATEonly on the columns other thanprivate_wrapped(DELETEhas no column granularity and stays);pepsi-cryptoalone holds the column;config_override:SELECTonly — writing it is thepepsi-configrole’s (pepsi-httpdwrites through a[pepsi-admin] CONFIG_DBconnection that authenticates as that role);setup_taskandsetup_task_log: nothing; onlypepsi-configmayINSERTa task;secure_messageandsecure_access:INSERT,DELETEandSELECTof every column butciphertextonsecure_message;SELECTandDELETEonsecure_access;admin_account,admin_sessionandapi_token: nothing;event_logandmail_log: append-only (noUPDATE/DELETE).
The services that are not the pipeline get what they use. An audit of every
statement each can issue — its own crate and every library it links, following
calls into stored functions — sets their grants, and pepsi-setup refuses to
finish if PostgreSQL disagrees:
Role |
Granted |
Notably not |
|---|---|---|
|
|
Any other message in the queue, settings, whitelists, keys, anything about a list beyond its address. A parser bug in the process listening on port 25 reaches one table, to add to it. |
|
Its |
Everything else, so a collector sharing a database with a pipeline cannot reach the mail. |
|
The administrative credential tables, the portal, the key tables (not
|
A queued message’s headers or body — the console lists envelopes and
has no read path to content, nor can it repoint a message’s |
A table a later schema patch adds is not in pepsi-ingress’s or
pepsi-telemetry’s reach until a patch grants it: their default privileges
are revoked too.
The roles outside this set are narrower still: pepsi-keydisc,
pepsi-whitelist and pepsi-config each hold grants on a few named tables
only. pepsi-crypto holds the blanket grant
plus private_wrapped. The schema owner, pepsi-owner, which
pepsi-setup itself connects as, holds everything by ownership.
Component |
Runs as |
What it can reach |
|---|---|---|
|
|
|
|
|
The pipeline grant above: the queue, settings, statistics, whitelist,
and the secure-link tables without their |
|
|
The ordinary pipeline access every stage has, plus the wrapped
private key material and the key-encryption key. Nothing else holds
both: the schema owner |
|
the invoking operator |
Installed |
|
|
The tables in the second table above — read and write, not
read-mostly: its handlers change queue routing and key metadata
directly. No message headers or bodies, no private key material, no
settings or whitelists; no ability to write the configuration overlay or
enqueue a setup task except through its |
|
|
The discovery request queue and |
|
invoking user (setuid) |
|
|
|
|
Mailbox quota keeps its measuring and its refusing apart. 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: 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.
15.2.1. Every program carrying a bit¶
Upholds
A local shell user: a local account gains no Pepsi role, and each helper acts with the target user’s authority only (boundary B4).
A multi-call binary cannot carry a per-program setuid or setgid bit, so each of
these is installed as its own executable rather than as a symlink into the
shared pepsi binary (the authoritative list is STANDALONE_BINARIES in
the Makefile; the modes are applied by make install and, on Debian, by
dpkg-statoverride from the postinst). Everything not listed here is
unprivileged.
Program |
Owner and mode |
Why |
|---|---|---|
|
|
Becomes the recipient to write into a |
|
|
The setgid bit is the sole gate on exec’ing that helper, so the mode is
|
|
|
Same helper, so a |
|
|
Drops fully to the user before reading their |
|
|
The same gate, for that helper. |
|
|
Drives the wallet as the account that owns it. |
|
|
The same gate, for that helper. |
|
|
Reads the group-restricted OAuth token files
|
|
|
World-executable on purpose: any local user may manage their own namespace, and what stops them touching another’s is the rule inside the program, not the file mode. |
|
|
Peer authentication keys off the effective uid, so the stage has to
be the role granted |
|
|
See the table above. A site may make it setuid |
|
|
The unprivileged half of the |
15.3. The private-key custody boundary¶
Upholds
Gateway custody (custody = local, the MTA key) (keys never disclosed to a passive reader or an ordinary stage; boundary B5).
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 service account, pepsi-httpd included; apart
from pepsi-crypto, only the schema owner pepsi-owner can read it, by
ownership. 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. A listing query cannot report “does
this identity have a private half?” as private_wrapped IS NOT NULL: that
is refused for every role but pepsi-crypto, and PostgreSQL’s error,
permission denied for table crypto_identity, names no column. The listing
derives the flag from wrap_key_id instead, which a table CHECK keeps
exactly in step.
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 — not by
pepsi-owner, so the one other role that can read the column still holds
nothing it can open. Moving a blob
between rows fails to authenticate.
15.4. A second factor for key management¶
Upholds
Someone holding a user’s mail password (a stolen mail password cannot register a key for a user who enrolled a second factor).
A user may enrol a TOTP secret (RFC 6238: HMAC-SHA-1, six digits, 30-second steps — what every authenticator app implements) for their address; from then on every change to their keys that they can make needs a current code:
By e-mail, every key command but
status—generate,register,publish,retireandotp enroll/otp removethemselves — takes the code as a six-digit word in theSubject:. An ordinary message’sAutocrypt:header or attached key no longer registers anything for that address.In the web console and the API, an
own:<address>principal’sgenerate-identity,register-client-keyandrevoke-identityrequests carry anotpfield.pepsi-httpdcannot check it — it holds no grant on the table at all — and passes it through;pepsi-setup applychecks it before acting, for a task admitted onown:<address>alone. An operator holdingkeys:writeis not asked.An unprivileged
pepsi-keys(a setuid install) needs--otp CODEfor every identity change and private-key export; ownership is checked first, so one user can never count failures against another’s enrolment.
Custody. The secret is wrapped exactly like a private key — AES-256-GCM
under the key store’s KEK, bound to (address, totp, second-factor) — and
the otp_key table is granted to the pepsi-crypto role alone:
pepsi-setup revokes it from every other role, the pipeline’s pepsi
included, and verifies that against PostgreSQL. A stage that parses hostile mail
can therefore neither read a secret nor reset a counter nor delete an
enrolment (which would switch the protection off). pepsi-keys wrap rotate
re-wraps the secrets with the private keys.
Attempts. The code is checked by the process that can unwrap the secret;
the decision is the otp_attempt SQL function’s, under the row lock. A code
matching a time step later than the last accepted one is accepted and resets
the failure counter to zero; the same code again, or an older one, is a replay
and counts as a failure, as does a wrong code. At ten consecutive failures
the enrolment is locked and every attempt is refused unchecked. A command with
no code at all is refused without being counted. Each enrolment has a
never-reused serial, so an attempt checked against a secret that was replaced
meanwhile is discarded rather than recorded against the new one.
The operator clears a lock by removing the enrolment (pepsi-keys otp
reset, or DELETE /api/v1/otp/{address}, which queues a reset-otp task
that only keys:write may ask for) or replacing it (pepsi-keys otp enroll
as the operator needs no code); a new enrolment always starts at zero failures.
What it does not do. It does not protect the first enrolment (there is no
code to ask for yet), nor an enrolment reply the user left in the mailbox. It
does not gate the web console’s synchronous flag changes (WKD publication, a
key-server upload request, the primary MTA key), which pepsi-httpd performs
itself. And it adds nothing against the operator, who can reset it.
15.5. An attacker who has the database¶
Upholds
Somebody holding a copy of 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.workqueueholds theheadersandpepsi.workqueue_bodythe 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,auto_pay_spend,payment_request_replyanddns_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, nor any second-factor secret. The wrapped blobs are 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.
15.6. An attacker who has the configuration file¶
Upholds
Somebody holding the configuration or a secret.
/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, or the list unsubscribe key. That is why they are split: 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.
15.7. An attacker who compromises a stage worker¶
Upholds
A compromised component, including its signing residual.
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 the wrapped end-to-end private keys, and it cannot become another account.
It does, however, get the ability to have mail signed, and this is worth being blunt about because it is the largest residual in the design:
pepsi-stage-dkim-signruns aspepsiand reads the domain DKIM keys through grouppepsi, so anypepsiprocess can sign as every served domain — directly, or by editing a queued row before the signing stage sees it.pepsi-stage-encryptsigns as the row’sFrom:author. Ingress enforcesUSERNAME_MAPat submission, but the queue row is writable by everypepsistage afterwards and nothing downstream can tell whether ingress made that decision. A compromisedpepsistage can therefore rewrite a queued submission’sFrom:(or inject a message withworkqueue_inject) and have it signed with any local user’s end-to-end key.
So the integrity of a Pepsi-made signature — DKIM or per-user OpenPGP/S/MIME — is
only as strong as the whole pepsi pipeline role, not as strong as
pepsi-crypto. What the split does bound is key disclosure: a
compromised language detector can get messages signed while it is running, but
it cannot carry a private key away, so the damage ends when the stage is fixed
and does not require re-keying. See Threat model for where this sits
among the other guarantees.
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-ingress`` sees every message as it arrives and decides who submitted it, so it can admit mail as any local user. Its database role can add to the queue and read nothing of it, so what is already queued, the settings, the whitelists and the keys stay out of its reach.
``pepsi-httpd`` gets the web surface and its database grants — envelopes and
routing of the queue, never a message’s headers or body. 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 made absolute, which is the right posture 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.
15.8. Specific defences¶
15.8.1. The submission identity¶
Upholds
A local shell user and A mail-only user (boundary B2): a submitter sends only as the identities the operator mapped to them.
Ingress decides who submitted a message by one of four means — SASL through the
configured backend, a TLS client certificate, an address in MYNETWORKS, or
the SO_PEERCRED credentials of the local submission socket, which is how
sendmail authenticates without a password and why that socket may be
world-writable. Whichever it is, USERNAME_MAP then decides which From:
and envelope addresses that identity may use (RFC 6409 §6/§8.1), and an identity
mapped to nothing may authenticate but not send. Speaking for an address —
registering a key for it, or operating its keys by e-mail — needs more than being
permitted to send as it: the From: must be exactly one mailbox named by a
concrete (wildcard-free) entry of the map (state.origin.from_bound). A
*@example.org grant lets an account send as every colleague, never plant a
key for them.
The decision is recorded in the message’s state (local_origin, the
authenticated identity) and believed by every later stage — which is exactly
why A compromised component treats a compromised pepsi stage as able to
forge it.
15.8.2. Re-sealing decrypted mail for storage¶
Upholds
Client custody (custody = client, the MUA key) (mail the gateway opened is filed readable only by the user’s own client) and The guarantees at a glance (the Filed mail column).
pepsi-stage-decrypt has to produce plaintext: the spam, language, whitelist
and key-learning stages after it read the content. pepsi-stage-reencrypt
runs once they have, immediately before local delivery, and seals the message
to the recipient’s newest active custody = client key. The mechanism rests
on four choices:
It needs no privilege. Sealing to a public key uses no private material, so the stage runs as
pepsi, is folded into the multi-call binary, and reads only the public columns ofcrypto_identityunder the ordinary pipeline grant. It never namesprivate_wrapped, and gaining thepepsi-cryptoidentity would give it nothing it uses.It signs nothing. A gateway signature would only say “the gateway sealed this”, indistinguishable in a client from authorship, and would need the setuid identity. The sender-signature verdict is the one decrypt recorded, in
state.crypto.inand inX-Pepsi-Crypto— which stays on the outer block and which decrypt strips from every inbound message before writing its own, so it is still ours when the client reads it.It acts only on what decrypt opened (
state.crypto.insays encrypted and decrypted), and recordsstate.crypto.store, which also stops it from ever sealing a row twice. Mail that arrived in cleartext is not sealed.A refusal returns nothing that was encrypted. Under
ON_NO_CLIENT_KEY = bouncethe DSN’s header block is cut to the addressing fields, the body is emptied andRET=HDRSforced, since the sender encrypted the message and a DSN travels in clear. It bounces rather than defers, because deferring would keep the plaintext queued longer.
The residual is the one A compromised component already states: a compromised
pepsi stage — the re-encryption stage included — sees the plaintext while it
is queued and can route a message past the stage, so the guarantee is about
copies already filed, which nothing on the server can open.
15.8.3. What an S/MIME trust anchor vouches for¶
Upholds
What a security indicator means, against A remote sender: a CA the operator trusts
for one namespace cannot make valid appear for another.
An organisation that exchanges signed mail with a partner often trusts the
partner’s CA only for the partner’s own domain — by cross-signing it with
nameConstraints, or by anchoring a partner sub-CA that carries them. If the
bound were ignored, that partner CA could issue a certificate for one of this
deployment’s users and the decrypt stage would call the signature valid.
pepsi-crypto::trust therefore runs RFC 5280 §6.1’s constraint state down
every chain it builds:
Name constraints apply to the subject DN, every subject alternative name, and every
emailAddressattribute in the subject DN — the last always, because that attribute is what a message is bound to when the SAN names no mailbox. Permitted subtrees intersect down the chain (two CAs permitting disjoint domains leave no mailbox permitted, not every one); excluded subtrees accumulate. A sub-CA cannot widen what its parent delegated.The anchor’s own constraints bind it (RFC 5937), so anchoring the partner’s sub-CA directly is as safe as cross-signing it.
Policy constraints (
requireExplicitPolicy,inhibitPolicyMapping,inhibitAnyPolicy) are evaluated over the full policy tree, in a deduplicated form whose size is polynomial in the policies a certificate names, and capped (64 policies or mappings per certificate) — the tree that grows exponentially in the naive algorithm is the OpenSSL CVE-2023-0464 denial of service. Name checking is capped at 220 comparisons per certificate for the same reason.What cannot be honoured is refused. An unprocessable constraint — a subtree form Pepsi does not match, when a name of that form appears below it; a
minimumormaximum; an undecodable extension, critical or not — makes the chainpolicy-rejectedrather than valid without the bound. Where Pepsi departs from RFC 5280 or from OpenSSL it is always in that direction: a wildcarddNSNamethat can stand for an excluded host is refused, and a host-less URI under a URI constraint is refused.
The NIST PKITS conformance suite (sections 4.6 and 4.8–4.13, every input
setting it lists) runs in cargo test, alongside a corpus of our own for
the forms PKITS does not cover (IP addresses, unimplemented forms, a
name-constrained anchor).
15.8.4. Header forgery¶
Upholds
What a security indicator means, against A remote sender.
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.
15.8.5. Unknown recipients and backscatter¶
Upholds
Assets (sending identity and reputation), against A remote sender.
A bounce goes to whatever envelope sender a message claims, and spam forges it. An MTA that accepts mail for addresses it does not have and bounces it later therefore mails strangers on a spammer’s behalf. That is backscatter, and blocklists act on it quickly. Pepsi closes this in three layers, each a net under the one before:
Refuse at RCPT.
pepsi-ingressanswers550 5.1.1for an address at a served domain that nothing on the host vouches for: a local account, an alias, a list, a service address, or the MDA or backend asked withRCPT TO. The sources are derived from the stage graph (pepsi-recipients). Every uncertainty accepts, so an outage never turns into refused mail.Report only to a sender that proved itself.
pepsi-stage-bouncesends a DSN for an inbound message only when SPF passed for the envelope sender’s domain or an aligned DKIM signature did (BOUNCE_UNAUTHENTICATED = drop, the default). Anything else is dropped and logged.Refuse a loop. A message that has already been through this host’s ingress
MAX_SELF_HOPStimes is refused as a routing loop. An address on our own domain relayed to our own MX therefore costs one bounce, not a trip round the pipeline for every hop the relay stages allow.
Answering RCPT truthfully tells a sender which addresses exist, as every MTA
that rejects unknown users does. MAX_INVALID_RECIPIENTS closes a session
after ten wrong guesses, so harvesting costs connections, which the connection
limits meter. Probes of the MDA or backend are cached and capped at 16 in flight
(beyond that the answer is “unsure”, which accepts). A flood of guesses
therefore cannot be turned against the mail store either. VRFY still
answers 252 for everything.
The recipient check needs one grant that pepsi-ingress did not have: the name
and host columns of mailing_list, which together are a list’s public
address and nothing more.
15.8.6. A correspondent’s key changing¶
Upholds
Keys of correspondents, against A remote sender: a harvested key may be replaced by a newer one, but never silently.
Inside the Autocrypt tier (the inbound and gossip rungs) the conflict
rule is Autocrypt Level 1’s newest-wins, keyed on the From: header a sender
writes and on a message that inbound authentication accepted fail-open. So a
message that only claims to come from a correspondent can replace the key we
encrypt to them with. The mechanisms that bound this:
The replacement is recorded by the statement that makes it.
crypto_peer_key_upsertwrites akey.peer.rotaterow topepsi.event_log(the append-only audit log no mail-handling role canUPDATEorDELETE) in the same transaction as the swap, naming the address, both fingerprint sets and both effective dates. No caller can rotate a key without the row, and a compromised stage that wanted to hide one would have to be the kind of adversary A compromised component already concedes the queue to.The people about to reply are told.
pepsi-stage-autocrypt-learnmails each local envelope recipient of the rotating message (RESPONSE_STAGE, thekey-rotationtemplate) with both fingerprints, so a forged rotation is something the targeted user can notice before sending anything confidential. Only recipients at served domains are mailed, so the notice cannot be turned into backscatter, and at most one per recipient per message.An operator can refuse rotation over a live key.
ACCEPT_ROTATION = expiredaccepts the newer key only when every stored one is recorded as revoked or expired, or is past the expiry its own material states (stored when the key was learnt, and for OpenPGP only ever moved later, so a replayed older copy of the certificate cannot backdate it); otherwise the offer is held, audited askey.peer.rotate.heldand reported to the recipients. It trades a forged rotation for a genuine one refused until an operator intervenes.The tier stays confined. None of this widens what may be replaced: nothing a domain, a directory, a key server or an operator published, and no pinned row, can be touched by a message, and an address we hold an identity for is pre-empted by our own key regardless. A served address we hold no identity for is not pre-empted, and
pepsi-keys peer unclaimedlists every such address with a harvested or gossiped key (Finding the users who bring their own key).Promotion is not rotation. When a gossiped key’s owner advertises the same key themselves,
crypto_peer_key_upsertmoves the row from thegossipto theinboundrung and writes akey.peer.promoterow (neverkey.peer.rotate, and no notice, since the key did not change). It is decided under the same per-address lock, keyed on the source names, and maps to the sameTrustSource::Autocrypt/Untrustedverdict as any harvested key (A gossiped key its owner confirms is promoted).
pepsi-status lists the last 30 days of both kinds of row.
15.8.7. Key-discovery SSRF¶
Upholds
A remote sender and Keys of correspondents: an address somebody writes to cannot aim the server at its own network.
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.
15.8.8. The secure-link portal¶
Upholds
Somebody holding a copy of the database and the stored-message claim of The secure-link fallback 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.
15.8.9. The administrative API¶
Upholds
A delegated administrator and boundaries B6/B7.
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.
15.8.10. Switching feature telemetry on from the console¶
Upholds
Assets (privacy toward third parties: nothing the operator did not
opt into) and A compromised component (pepsi-httpd).
[pepsi] SHARE_TELEMETRY lives in the configuration file, so the console
changes it only the way it changes anything there: a write-config request
that root’s applier performs. What makes that safe to offer is a precondition
the operator sets up by hand and the console can only observe:
pepsi-telemetry-client must be running (dormant while telemetry is off) and
[pepsi] SYSTEM_ID must be present. The daemon reports both in the one-row
pepsi.telemetry_client table, which pepsi-httpd may read and not write —
so a console cannot forge “ready” — and the row carries a boolean for the
identifier, never its value. Without it the question is disabled with
directions and an API request to switch on is refused (409
telemetry_client_not_ready); switching off is never refused.
That gate is a statement of usefulness, not the boundary. The boundary is the
daemon: it believes only the file (through the single reader of the switch),
re-reading it on the telemetry_changed notification, on SIGHUP and every
minute; a notification cannot turn it on. A compromised pepsi-httpd holding
CONFIG_DB could already request any configuration, this switch included, and
that is unchanged; what it cannot do is make a deployment whose operator never
started the daemon submit anything. Nothing in this path mints an identifier the
installation did not already have: the applier carries an existing
SYSTEM_ID across a console save instead, and pepsi-setup generates one
only on a yes. Switching off discards what the daemon had gathered and not yet
sent.
15.8.11. Resource limits¶
Upholds
Availability: a stranger cannot make one message, one connection or one address cost the server without bound.
The limits themselves are tabulated in Availability; what matters here is where each is enforced and why it cannot be stepped around.
Connections are bounded per source, not only in total.
pepsi-ingresscounts open connections per source range (MAX_CONNECTIONS_PER_IP) and, for the UNIX-socket peers that are exempt from its rate limit and never evicted, for all of them together (MAX_LOCAL_CONNECTIONS);pepsi-httpddoes the same per client address. The check happens before a connection takes a slot of the global ceiling, so a source at its share cannot also queue for more.Guessing and flooding are counted across sessions. A per-range token bucket (
AUTH_FAILURES_PER_HOUR) is spent by every failedAUTH, and once empty the nextAUTHis refused before the SASL backend is asked; a per-uid bucket keyed on the kernel-reportedSO_PEERCREDuid (LOCAL_MESSAGES_PER_MINUTE) meters local submission, and a session may start onlyMAX_MESSAGES_PER_SESSIONtransactions before it must reconnect into the rate limit.One sender’s burst does not delay everybody else.
pepsi-dispatchclaims each stage’s work by least virtual runtime per sender rather than oldest first (FAIR_SCHEDULING, on by default). The sender is the generatedworkqueue.sender_key: the authenticated account for a submission, otherwise the connecting address — an IPv6 peer by its /64, since a single host is routinely given one — and never the envelope domain of mail from outside, which the sender chooses per message. Worker time is charged to the sender that caused it, a newcomer starts level with the least-served sender so silence banks no credit, and a reserved oldest-first share (FAIR_FIFO_PERCENT) bounds any one message’s wait. The accounting is in the dispatcher’s memory only; it grants nothing and changes no grant.Inbound verification has a work bound that is not a way around DMARC. At most
MAX_DKIM_SIGNATURESsignatures are verified, those that can align with theFrom:domain first, and SPF and each signature run concurrently underVERIFY_TIMEOUT, with DMARC under a second deadline. A check that runs out of time becomestemperrorfor that check: DMARC consults a temporary error only on an identifier that aligns with theFrom:domain, and the DNS DMARC itself reads is that domain’s own, so a forger who slows their own DNS down gains nothing but their owntemperror.Key material from strangers has a size ceiling. Every OpenPGP certificate that arrives from outside — WKD, a key server, DANE, a harvested or gossiped header, an attached key, a pasted one — passes through one parser that refuses an RSA key above 8192 bits, and the verifier drops such a certificate from its trust store as well. S/MIME has the same bound at 4096 bits, from the
rsacrate.Retention does not depend on the web tier. The audit and mail logs are pruned by
pepsi-httpd prunefrompepsi-log-prune.timer— as thepepsi-httpdaccount, the one service role holdingDELETEon them, which keeps the logs append-only for everything that handles mail — and TLS-report counters and expired MX cache rows by their own timers, all named bypepsi.target.The queue has a ceiling, and the check cannot be starved or bypassed by splitting.
pepsi-ingressrefuses with452 4.3.1atRCPT,DATA/BDATand after the end of data while the queue is at[pepsi] MAX_QUEUE_ROWS/MAX_QUEUE_BYTESor the database’s filesystem belowMIN_FREE_SPACE. The measurement (pepsi.queue_usage()) reads only two generated size columns, so the least-privileged ingress role gains no access to queued mail for it, and it is cached per process (QUEUE_CHECK_INTERVAL), so a flood ofRCPTcommands costs no queries. Bytes are counted per stored body: bodies live inpepsi.workqueue_body, shared by every row a split, clone or list fan-out makes, so one large message to many recipients can no longer multiply its body across the queue — the amplification the per-recipient split used to offer. The one amplifier left under a sender’s control, a post to a large list, holds its fan-out back (pepsi-stage-list-post,QUEUE_THROTTLE_DELAY) while the queue is full. Deleting the last row that references a body deletes it (a trigger), and the foreign key means no role — the web tier’sDELETEonworkqueue_bodyincluded — can delete a body a queued message still carries.Mailing lists bound what one sender can make them do. A sender’s accepted posts to a list are counted per hour (
SENDER_POSTS_PER_HOUR) in one SQL statement that decides and records, so parallel workers cannot both take the last slot; the excess is held rather than multiplied by the membership. The moderation queue is capped per list (MAX_HELD_MESSAGES) by discarding the new post, so a flood cannot flush out the posts a moderator has yet to see.Automatic spending has an aggregate, and it cannot be raced.
pepsi-stage-auto-paybooks a demand against two budgets in the oneauto_pay_try_spendcall that also records it: the message’s (MAX_TOTAL, on theorigin_noncerow, lockedFOR UPDATE) and the paying wallet’s rolling 24 hours (MAX_TOTAL_PER_DAY, theauto_pay_spendledger, under a transaction-scoped advisory lock keyed on the wallet — demands for different messages share no row, so without it each would read the same sum). A refusal by either writes nothing to the other; a payment that fails to settle is credited back to both byauto_pay_refund, which takes only the first lock, so the two cannot deadlock. The wallet key is the one the helper pays from (uid:<n>, or the shared account and wallet database), so two senders cannot share a day unless they share a wallet.A payment request answers a sender once per window. The request is an automatic reply to an unverified envelope sender, so besides RFC 3834 suppression
pepsi-stage-anti-spamclaims it throughpayment_request_should_send—INSERT … ON CONFLICT DO UPDATE … WHEREdeciding and recording in one statement, asvacation_should_replydoes — keyed on the protected mailbox and the sender, withBLOCK_RESPONSE_SUPPRESSas the window. A failure to claim withholds the reply; the message is held either way.
15.9. What Pepsi does not defend against¶
The list is kept in one place, What Pepsi does not protect, together with the residuals each adversary section states: root and a malicious operator, traffic analysis and metadata, plaintext in the queue, signatures against a compromised pipeline, correspondents’ own compromise, and a network attacker who can also lie in DNS.
15.10. Reporting a vulnerability¶
Please report security issues privately by e-mail to Christian Grothoff at
grothoff@gnunet.org, encrypted to the OpenPGP key published at
https://grothoff.org/christian/grothoff.asc, whose fingerprint is
D842 3BCB 326C 7907 0339 29C7 939E 6BE1 E29F C3CC. Do not use the public
bug tracker for a vulnerability, and allow time for a fix before disclosure.
SECURITY.md in the source tree states the full policy.
Ordinary bugs go to the public bug tracker at https://bugs.gnunet.org/view_all_bug_page.php?project_id=35.
15.11. 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.