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.

On this page

  • The flow

  • What this protects, and what it does not

  • Replying

  • Where to put the portal

  • Storage and sizing

  • Operating it

  • Branding

  • See also

10.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.

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 --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.

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.

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.

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, 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.

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 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.

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, /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.

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).

Pepsi

Navigation

Contents

  • 1. Introduction
  • 2. Getting started on a cheap VPS
  • 3. Installation
  • 4. Debian packages
  • 5. The Wizard
  • 6. Configuration
  • 7. Supported Features
  • 8. SMTP Protocol Extensions
  • 9. Key management
  • 10. The secure-link fallback portal
    • 10.1. The flow
    • 10.2. What this protects, and what it does not
    • 10.3. Replying
    • 10.4. Where to put the portal
    • 10.5. Storage and sizing
    • 10.6. Operating it
    • 10.7. Branding
    • 10.8. See also
  • 11. Client interoperability
  • 12. Security model
  • 13. Microsoft Exchange as a gateway
  • 14. The administrative API
  • 15. The administration console
  • 16. Architecture
  • 17. The message state
  • 18. Extending the Pipeline
  • 19. Test Suite
  • 20. Benchmark Suite
  • 21. Performance
  • 22. pepsi-ingress
  • 23. pepsi-dispatch
  • 24. pepsi-httpd
  • 25. pepsi-stage-arc
  • 26. pepsi-stage-srs
  • 27. pepsi-stage-encrypt
  • 28. pepsi-stage-decrypt
  • 29. pepsi-stage-dkim-sign
  • 30. pepsi-stage-bounce
  • 31. pepsi-stage-aliases
  • 32. pepsi-stage-relay-to-internet
  • 33. pepsi-stage-relay-to-smarthost
  • 34. pepsi-stage-relay-to-maildir
  • 35. pepsi-stage-dot-forward
  • 36. pepsi-stage-relay-to-lmtp
  • 37. pepsi-stage-discard
  • 38. pepsi-stage-anti-spam
  • 39. pepsi-stage-auto-pay
  • 40. pepsi-stage-check-whitelist
  • 41. pepsi-stage-auto-whitelist
  • 42. pepsi-stage-autocrypt-learn
  • 43. pepsi-stage-detect-language
  • 44. pepsi-detect-language
  • 45. pepsi-stage-block-language
  • 46. pepsi-stage-vacation
  • 47. pepsi-stage-edit-settings
  • 48. pepsi-stage-if
  • 49. pepsi-stage-milter
  • 50. pepsi-stage-route
  • 51. pepsi-stage-vks-confirm
  • 52. pepsi-stage-secure-link
  • 53. pepsi-setup
  • 54. pepsi-queue
  • 55. pepsi-status
  • 56. pepsi-sendmail
  • 57. pepsi-whitelist
  • 58. pepsi-keys
  • 59. pepsi-keydisc
  • 60. pepsi-settings
  • 61. pepsi-tlsrpt
  • 62. pepsi-secure-link
  • 63. pepsi-failure-bouncer
  • 64. pepsi-quota
  • 65. pepsi-helper-token-refresh
  • 66. pepsi-telemetry
  • 67. pepsi-telemetry-client
  • 68. pepsi-config
  • 69. Feature stability
  • 70. Manual pages
  • 71. RFC Index

Related Topics

  • Documentation overview
    • Previous: 9. Key management
    • Next: 11. Client interoperability

Quick search

©2026, Pepsi contributors. | Powered by Sphinx 8.1.3 & Alabaster 0.7.16 | Page source