10. 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
pepsi-stage-encrypt’s ON_NO_KEY policy; the other two, and
the key store that decides which branch is taken, are Key management.
10.1. The flow¶
A local user sends a confidential message.
pepsi-stage-encryptfinds no usable key for the recipient and, becauseON_NO_KEY = secure-link, routes it topepsi-stage-secure-linkinstead of relaying it.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.
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.The recipient opens the link, enters the PIN, and reads the message. Attachments can be downloaded; if
REPLY_STAGEis configured, a reply can be written and sent back to the sender.After
EXPIRY_DAYSthe message stops being readable, andpepsi-secure-link prunedeletes 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.
10.2. What this protects, and what it does not¶
10.2.1. 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--decryptand 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.
10.2.2. 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.)
10.2.3. 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.
commandThe 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.
noneMaterially 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.
10.2.4. 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.
10.2.5. 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, plusReferrer-Policy: no-referrer,X-Content-Type-Options: nosniff,X-Frame-Options: DENYandCache-Control: no-store.An attachment is always served
application/octet-streamas anattachmentwith a sandboxing policy, whatever type the message claimed.
10.3. 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 atREPLY_STAGEas 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-setupwarns ifREPLY_STAGEresolves to a signing stage. The reply carries anX-Pepsi-Secure-Link-Reply: …; author-unverifiedheader 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-Lengthgains 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.
10.4. 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,/resumeor 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 requiresPath=/);it is
HttpOnly,Secure,SameSite=Strictand host-only (noDomain);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.
10.5. 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.
10.6. 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.
10.7. 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.
10.8. See also¶
Key management (the store and the discovery layer that decide whether a
recipient needs this fallback at all), pepsi-stage-encrypt
(ON_NO_KEY = secure-link), pepsi-stage-secure-link,
pepsi-secure-link (the operator CLI), pepsi-httpd
(which serves the portal), pepsi.conf(5).