.. 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. You should have received a copy of the GNU Affero General Public License along with PEPSI; see the file COPYING. If not, see . @author Pepsi contributors .. _secure-link: The secure-link fallback portal =============================== End-to-end encryption stops at the first correspondent who publishes no key, and most of them do not. A policy of ``ENCRYPT = required`` then leaves an operator with two bad answers: bounce the message, or send it in the clear anyway. The secure-link portal is the third: hold the message on the server, encrypted, and let the recipient read it in a browser after entering a PIN that reached them by another route. This chapter describes the flow, states plainly what it does and does not protect, and lists the configuration. It is the third branch of :doc:`programs/pepsi-stage-encrypt`'s ``ON_NO_KEY`` policy; the other two, and the key store that decides which branch is taken, are :doc:`key-management`. .. contents:: On this page :local: :depth: 1 The flow -------- 1. A local user sends a confidential message. ``pepsi-stage-encrypt`` finds no usable key for the recipient and, because ``ON_NO_KEY = secure-link``, routes it to ``pepsi-stage-secure-link`` instead of relaying it. 2. That stage generates a PIN and a 192-bit URL token, derives a content key from them and the server pepper, stores **only the ciphertext**, and deletes the queue row. 3. The recipient receives a notification mail with a link. The **sender** receives the PIN (the default ``PIN_DELIVERY = sender``) and passes it on by telephone, text message or in person. 4. The recipient opens the link, enters the PIN, and reads the message. Attachments can be downloaded; if ``REPLY_STAGE`` is configured, a reply can be written and sent back to the sender. 5. After ``EXPIRY_DAYS`` the message stops being readable, and ``pepsi-secure-link prune`` deletes it. The pipeline looks like this:: [stage-encrypt] PROGRAM = pepsi-stage-encrypt ENCRYPT = required SECURE_LINK_STAGE = secure-link NEXT_STAGE = srs [stage-secure-link] PROGRAM = pepsi-stage-secure-link [pepsi-secure-link] BASE_URL = https://secure.example.org NOTIFY_STAGE = srs ORGANIZATION = Example Ltd # PEPPER is written to secrets.d/pepsi-secure-link.secret by pepsi-setup. ``NOTIFY_STAGE`` is on the *outbound* path, because the notification and PIN mails are messages the deployment originates and must sign and relay normally. .. _secure-link-threat-model: What this protects, and what it does not ---------------------------------------- The stored message ~~~~~~~~~~~~~~~~~~ The content key is derived with **Argon2id** (RFC 9106) from three inputs kept in three different places. Argon2id rather than a plain hash or PBKDF2 because the secret being stretched is a short numeric PIN: what has to be made expensive is a parallel offline search, and only a memory-hard function raises the cost of the hardware an attacker would use rather than merely the time. The three inputs: * the **PIN**, which reaches the recipient out of band and is stored nowhere; * a per-message **salt**, which is a database column (a salt is not a secret — it is what stops one precomputation covering every message); * a server-side **pepper**, which lives only in ``secrets.d/pepsi-secure-link.secret``. Only the AES-256-GCM ciphertext is stored — an AEAD, so the stored message is authenticated as well as confidential and a tampered ciphertext fails to open rather than decrypting to something an attacker chose. Consequently: * **A stolen database is not a mailbox.** A dump, a replica or a backup tape yields ciphertext and a salt, and neither the PIN nor the pepper is in it. * **A compromised server is not a mailbox either.** An attacker who also takes the pepper still has to find the PIN, and a 10-character PIN under Argon2id is not something to search. * **There is no password verifier.** Verifying a PIN and decrypting with it are the same operation — the AEAD tag is the check — so nothing stored can be cracked offline into a PIN. * **The administrator cannot read a message.** Not with the database, not with the pepper, not with both. There is no ``pepsi-secure-link read``, no ``--decrypt`` and no escrow key, and their absence is the design. The cost of that last property is stated here rather than discovered during a support call: **a lost PIN is a lost message.** The recovery path is that the sender sends it again, which issues a new PIN and a new link. Expect to be asked for an escrow key; adding one would give back exactly the capability this design exists to remove. Losing the pepper has the same shape at a larger scale: every stored message becomes unreadable at once. Back it up with the rest of ``secrets.d``, and treat rotating it as invalidating every outstanding link. What is *not* protected: the metadata ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Deliberately in the clear, as ordinary database columns: * the **sender** and the **recipient** — the notification and the second factor have to be delivered, and "did this reach them?" has to be answerable; * **timestamps** — created, expires, first read; * **lifecycle counters** — read count, failed PIN attempts, lockouts, replies; * the **access log** — when each attempt happened, from which address, and how it ended. The **subject is not** in that list. It is usually the most revealing single field — often it *is* the message ("Biopsy results", "Termination letter") — and nothing in routing or expiry needs it, so it lives inside the ciphertext along with the body. So an operator console lists **who and when, never what**, and so does the read receipt. Support is correspondingly harder: "the message you sent to Bob at 09:14 was read at 11:02" is the most anyone here can say, and there is no way to confirm what was in it. That is the trade this feature makes. The access log records the peer address and nothing else — no user agent, and no geolocation. (The prototype this feature is modelled on called an unencrypted third-party geo-IP service on every read; that is a privacy leak we do not reproduce.) The second factor ~~~~~~~~~~~~~~~~~ The whole construction rests on the PIN travelling by a channel the recipient's mailbox does not control. ``PIN_DELIVERY`` chooses it: ``sender`` (default) The PIN is mailed to the **sender**, who relays it. No external dependency, it works everywhere, and the channel is one the two correspondents already trust. It does depend on the sender actually using a second channel — mailing the PIN on to the same address would defeat it, and the PIN mail says so. ``command`` The PIN is handed to a configured program (an SMS gateway, a corporate messaging hook) on standard input. The strongest option when a real second channel exists. ``none`` **Materially weaker, and it should be chosen deliberately.** The link itself carries the PIN, so anybody who can read the recipient's mailbox can read the message — which is what the feature exists to prevent. It also puts the PIN in a URL, and therefore potentially in a browser history or an intermediary's logs. Use it only where the mailbox genuinely is the factor. Brute force ~~~~~~~~~~~ A PIN short enough to read aloud is short enough to guess, so guessing is bounded twice. Per token, ``MAX_ATTEMPTS`` wrong PINs (10 by default) lock it for ``LOCKOUT`` (15 minutes), and the window doubles with each further lockout; during a lockout even the *correct* PIN is refused. Per source, ``RATE_LIMIT`` caps requests per minute across every portal route, which also keeps the deliberately expensive key derivation from becoming a way to spend the server's memory. Only an actual guess is counted. A form submitted with the PIN field empty — or carrying nothing that normalisation keeps — is refused without touching the counter and without running the derivation. It tells an attacker nothing they did not already have, and counting it would let any client that re-submits the form empty (a double submit, a prefetch, a redirect follower that keeps the method) lock a legitimate recipient out of their own message. A held session is not subject to the lockout, so a third party guessing at the same token cannot lock a legitimate reader out mid-read. The browser ~~~~~~~~~~~ Rendering attacker-controlled mail in a browser is the largest risk this feature carries, and it is addressed by not doing it: * **The message is rendered as text, never as HTML.** An HTML-only message is folded to text; markup does not survive. There is therefore no HTML sanitiser to be bypassed, and no remote content — the policy can say ``img-src 'none'`` and mean it, so a tracking pixel cannot report that the message was opened. Formatting and inline images are lost; a recipient who needs the original can download the parts. * Every dynamic value on a page — subject, sender, date, filename, organisation name — is HTML-escaped at the point it is written into the page. * Every response carries ``Content-Security-Policy: default-src 'none'`` with a per-response nonce for the one inline stylesheet and **no script source named at all**, plus ``Referrer-Policy: no-referrer``, ``X-Content-Type-Options: nosniff``, ``X-Frame-Options: DENY`` and ``Cache-Control: no-store``. * An attachment is always served ``application/octet-stream`` as an ``attachment`` with a sandboxing policy, whatever type the message claimed. Replying -------- A read-only portal is a frustrating experience: the recipient can read confidentially but must answer in the clear, giving back much of what the feature bought. So replying is supported — and because it is a message-injection endpoint on the public web, it ships with constraints that are structural rather than procedural: * **The reply goes only to the original sender**, and its envelope sender is forced to the stored recipient. Neither is an input, so there is nothing to abuse — this is what keeps the portal from being an open relay. * **The injected message does not carry** ``state.local_origin``. It enters at ``REPLY_STAGE`` as what it is: inbound mail that happens to have originated at our own web server. It therefore cannot take the submission path — no relay-as-us, no auto-whitelisting, no SRS-as-local-origin. * **It is not DKIM-signed as one of your domains.** The author is an external party; ``pepsi-setup`` warns if ``REPLY_STAGE`` resolves to a signing stage. The reply carries an ``X-Pepsi-Secure-Link-Reply: …; author-unverified`` header so the recipient can tell how it arrived. * **The body is text/plain**, whatever was typed. Composing HTML would mean sanitising outbound HTML as well as inbound. * Attachments are allowed and capped — total size, file count, and replies per message — with the size cap enforced *while the upload streams*, so a client that misstates ``Content-Length`` gains nothing. Uploads are never written to disk: they go straight into the injected message, so there is no second storage lifetime and nothing left behind if the injection fails. * Every reply is recorded in the message's access log. Replying is **off** unless ``REPLY_STAGE`` is set. Where to put the portal ----------------------- A **dedicated hostname is the recommendation** — one DNS record and one certbot SAN buy real origin separation — but it is not required, and a shared origin is safe by construction rather than by discipline: * the session cookie is ``Path=/secure/``-scoped, so it is never sent to ``/metrics``, ``/resume`` or the Web Key Directory even on one host; * its value is an AEAD sealed under the server pepper, so a cookie planted by a sibling subdomain does not open — which is what would otherwise have called for a ``__Host-`` prefix (a prefix that RFC 6265bis makes incompatible with path scoping, since it requires ``Path=/``); * it is ``HttpOnly``, ``Secure``, ``SameSite=Strict`` and host-only (no ``Domain``); * the security headers are set **per route**, not per host, so the portal's tight policy applies wherever it is mounted. The portal must be reachable over HTTPS from the browser: the session cookie carries ``Secure``. A TLS-terminating reverse proxy in front is fine. Storage and sizing ------------------ The ciphertext lives in the database as ``BYTEA``. That keeps everything transactional — creating a secure message is one insert, pruning is a ``DELETE``, and backup and restore cover the content automatically, with no orphaned files and no second thing to get the permissions right on. The cost is database size and WAL churn proportional to portal traffic. A message is stored **once per keyless recipient** (each with its own PIN and ciphertext, so one recipient's PIN never opens another's copy), and ``[stage-encrypt] MAX_SIZE`` (25 MB by default) bounds each one. Size a deployment accordingly, and run ``pepsi-secure-link prune`` daily — the portal already refuses an expired token, but nothing deletes the bytes until the job runs. Operating it ------------ ``pepsi-secure-link list`` shows what is outstanding; ``show`` adds one message's access log; ``revoke`` destroys a message (it does not put the mail back in the queue — it withdraws it); ``prune`` deletes what has expired. See **pepsi-secure-link**\ (1). The database boundary is narrower than the rest of the schema and is verified on every ``pepsi-setup run``: only the ``pepsi-httpd`` role can read ``secure_message.ciphertext``. The ``pepsi`` role — under which every stage worker runs, including the ones that parse hostile mail — can create a message, list the metadata and delete a row, and cannot read one back. ``pepsi-ingress`` and the rest cannot touch the tables at all. Branding -------- ``ORGANIZATION``, ``BRAND_COLOR`` and ``LOGO_FILE`` set the appearance of the pages and are named in the notification mails. The colour must be a hexadecimal colour and the logo must be a local raster image, both for the same reason: they are interpolated into a page, and a value that could be arbitrary text or an SVG would widen the page's attack surface. The three notification mails themselves are Mustache templates (``secure-link``, ``secure-pin``, ``secure-receipt``) under ``[pepsi] TEMPLATE_DIR`` and can be rewritten wholesale. See also -------- :doc:`key-management` (the store and the discovery layer that decide whether a recipient needs this fallback at all), :doc:`programs/pepsi-stage-encrypt` (``ON_NO_KEY = secure-link``), :doc:`programs/pepsi-stage-secure-link`, :doc:`programs/pepsi-secure-link` (the operator CLI), :doc:`programs/pepsi-httpd` (which serves the portal), :manpage:`pepsi.conf(5)`.