11. Key management

End-to-end cryptography — OpenPGP and S/MIME — needs somewhere to keep keys. Pepsi keeps them in the database, in three tables that together are the key store, and manages them with one program, pepsi-keys. This chapter covers what is stored, how the private half is protected, who may reach it, and the two decisions that most often surprise people: why signing and encryption material are separate, and why an address that only ever receives mail ends up with no key at all.

The key store is not the DKIM/ARC key store. Those are per-domain keys, held as files under [pepsi] KEY_DIR and read by the signing stages; see Installation. What follows is about per-address end-to-end material, which has an entirely different lifecycle.

11.1. Three kinds of material

pepsi.crypto_identity — our own addresses

The key pairs of the addresses this deployment serves, private half included, wrapped at rest. This is what signs outbound mail and decrypts inbound mail. Each row records the address, the protocol (openpgp or smime), what the material is capable of, its algorithm and fingerprint, the public material itself, the wrapped private material and the id of the key that wrapped it, plus the lifecycle columns (status, is_primary, published, expires_at, revoked_at, private_purged_at, vks_wanted). It also holds the users’ own keys – public-only rows whose private half lives in their mail client (custody = client; see Two keys per user).

pepsi.peer_key — other people’s addresses

Remote correspondents’ public keys and certificates, cached however they were obtained. This is what encrypts outbound mail and verifies inbound signatures. Each row carries the source it came from, whether the lookup that produced it was DNSSEC-validated, the last validity verdict reached about it, and a cache deadline.

pepsi.ca_trust — trust anchors

The CA certificates an inbound S/MIME chain must reach for its signature to count as trusted. Pepsi ships no default anchor set: a signature that chains to a CA nobody chose is reported untrusted rather than bad, so an empty store degrades to “we cannot vouch for this” instead of to “this is forged”, and an operator who adds an anchor is making a decision rather than inheriting one.

11.2. Custody: how a private key is stored

The private half of an identity lives in the database, but never in the clear. It is sealed with AES-256-GCM under a key-encryption key (KEK) derived from a secret that lives only in the filesystem, in a secrets.d fragment named by [pepsi-crypto] KEY_WRAP_SECRET.

The property this buys:

The database alone never yields a private key. A stolen dump, a replica, a backup tape or an SQL-injection foothold in an unrelated component produces ciphertext and nothing else. The key that opens it is a second, differently guarded artefact and is never a database value.

The stored blob is a version octet, a 96-bit random nonce and the AES-GCM ciphertext. The version octet exists so a future construction can coexist with this one; an unrecognised version is an error, never a silent fallback.

The additional authenticated data binds the ciphertext to the row it belongs to — the address, the protocol and the purpose, length-prefixed. Without that binding, an attacker with write access to the table but not the KEK could move Alice’s wrapped signing key into Bob’s row and have the pipeline sign Bob’s mail with it. The AAD is authenticated but not stored, so such a blob simply fails to open in the wrong place, exactly as a tampered one does.

The KEK itself is derived from the configured secret with HMAC-SHA-256 under a fixed label, mixing in the key id. Two consequences follow: the secret may be any string (a passphrase, base64, hex — the derivation does not care), and every distinct KEY_WRAP_KEY_ID yields an independent 256-bit key. There is deliberately no start-up passphrase: unattended restart and socket activation have to keep working.

Warning

pepsi-setup refuses a KEY_WRAP_SECRET shorter than 16 characters. It is the single key that opens every stored private key in the deployment; generate it from a real entropy source rather than typing one.

11.3. The privilege boundary

Wrapping is one half of the custody model. The other half is a database privilege boundary, provisioned by pepsi-setup and re-asserted on every pepsi-setup run:

  • a login role pepsi-crypto is created with the ordinary pipeline access (SELECT/INSERT/UPDATE/DELETE across the schema, sequences, functions) plus the crypto_identity.private_wrapped column – the only role granted it, apart from the schema owner pepsi-owner, which can read it by ownership but cannot read the key-encryption key, so nothing else holds both;

  • pepsi (the dispatcher and every stage worker) and pepsi-httpd have their table-level grant on crypto_identity revoked and replaced with a column-level grant listing every column except private_wrapped; pepsi-ingress and pepsi-telemetry have no access to the table at all (see The privilege split).

So a compromise of an unrelated stage — relay, bounce, aliases — yields the public half of every identity and nothing more, even with full database access under that role. The column list is read back from information_schema rather than hard-coded, so a column added by a later change is covered automatically and the grant cannot go quietly stale against the schema.

DELETE has no column granularity in PostgreSQL and stays whole: a stage that can delete an identity row can deny service, but it still cannot read a key.

Important

PostgreSQL peer authentication keys off the effective uid, not the gid. A setgid bit grants group membership — which is what the mode of the KEK fragment tests — but not a database identity. A program that must be the pepsi-crypto role has to be that account: run as it, or setuid to it. pepsi-keys does exactly that, assuming the identity for the duration of each database call and giving it back afterwards, in the same shape pepsi-whitelist uses for its own account.

Reaching a private key therefore takes both things: the database role and the key-encryption key. Neither alone is enough, and they are guarded by different mechanisms — a PostgreSQL grant and a file mode.

11.3.1. Who may manage what

An identity is keyed on the lower-cased address, and the passwd login that owns that address is recorded alongside it when the locality rules ([pepsi-crypto] LOCAL_DOMAINS, RECIPIENT_DELIMITER, TARGETS, named and defaulted like local delivery’s options) resolve one. They are the only options that decide it: pepsi-keys, the setup applier and the encrypt stage’s automatic creation and key registration all read [pepsi-crypto], so an identity has the same owner whichever of them made it. That column is the access rule: an unprivileged caller of pepsi-keys may see and change only identities whose login is their own, taken from the real uid’s passwd entry. An identity with no owning account — a role address, a hosted domain, an Exchange-fronted deployment — belongs to nobody in particular and is operator-only; treating “unowned” as “everybody’s” would make every role address writable by every local user.

Peer keys and trust anchors have no per-user namespace at all. A peer key is somebody else’s key, cached for the whole deployment, and the trust store is policy; both are operator-only, listing included.

pepsi-keys ships without a setuid bit, unlike pepsi-whitelist. One binary able to open every private key in a deployment is a far larger prize than a single whitelist table, so by default only the operator runs it. A site that wants users to manage their own identities installs it setuid pepsi-crypto, and the ownership rule above takes effect as soon as it is, so that is a site decision and not a code change. (Without the bit every caller connects as themselves and PostgreSQL’s grants are the only authority; see pepsi-keys(1).)

11.4. What custody protects against

The threat model states the claims per custody mode (Gateway custody (custody = local, the MTA key), Client custody (custody = client, the MUA key)); this is how they map onto the key store.

Somebody who has…

An MTA key (custody = local)

An MUA key (custody = client)

a copy of the database or a backup

Wrapped ciphertext only; the KEK is not in it.

A public key; there is no private half anywhere on the server.

the secrets.d KEK fragment alone

Nothing: the blobs are in the database.

Nothing.

code execution in an ordinary stage

Not the key. But the use of it: rewriting a queued submission’s From: gets a message signed as that user (A compromised component).

Nothing it can sign or open. It can still read mail that arrived in cleartext before it was encrypted to the user.

code execution in pepsi-stage-encrypt/-decrypt

Every MTA key, in the clear, and the ability to carry them away. Rotate the KEK and re-key after such a compromise.

Nothing: mail encrypted to an MUA key passes through unopened, and a copy already filed re-sealed to it (pepsi-stage-reencrypt) cannot be opened either.

write access to crypto_identity without the KEK

Cannot move a wrapped key to another address: the ciphertext is bound to its row. Can delete rows (denial of service).

Can replace the public key, redirecting future mail to a key of the attacker’s choosing — the same power a malicious operator has. Only a correspondent who verified the fingerprint out of band is protected.

Two further attacks concern which key is used rather than who holds it:

  • Planting a key for one of our users. Harvesting and gossip learn keys from mail anybody can send, so a forged From: alice@our-domain can seed a cache row for Alice. For any address we hold an identity for, our own key pre-empts the whole cache (Our own users’ keys are not discovered), so the planted key is never used; for a served address with no identity it is trust-on-first-use, which Finding the users who bring their own key is the remedy for.

  • Substituting a correspondent’s key. Only as far as the trust ladder allows: a better-ranked source always wins a conflict, a third party’s gossip can never displace what the owner advertised, and MIN_TRUST decides how far down the ladder encryption is still used (Keys of correspondents).

11.5. Signing and encryption are separate

The store splits signing material from encryption material wherever the protocol allows it. The reason is an asymmetry:

Pepsi delivers plaintext to the mailbox. A private decryption key is therefore needed only for mail still in flight and for whatever ciphertext an operator has archived — never to read one’s own mailbox. A private signing key, once retired, can do nothing legitimate whatever: no honest process ever re-signs old mail. Keeping it is pure forgery liability.

So retirement — at expiry, and identically at revocation — does two different things:

  • a signing row’s private half is destroyed, leaving the public half so signatures made while the key was valid can still be verified;

  • an encryption row’s private half is retained, so in-flight and archived ciphertext stays readable.

private_purged_at records when material was dropped, which keeps “deliberately destroyed” distinguishable from “never held”.

For OpenPGP the split is intrinsic and free: a transferable key is a sign-capable primary with an encryption subkey, so one row of purpose both represents it. For S/MIME it means two certificates, one with digitalSignature and one with keyEncipherment/keyAgreement. Both the self-signed and the certificate-request paths handle the pair naturally — identity generate creates both, and --csr emits a request over each.

11.5.1. Single-certificate S/MIME, and what it costs

Two certificates per identity is a real expense when certificates are bought from a CA per address. [pepsi] CRYPTO_SMIME_SHARED_KEY = yes therefore issues one certificate carrying both key usages instead. It is an administrator-only INI option — a security/cost trade, not a per-user preference, and structurally out of reach of the per-address override layer.

The trade:

  • the forgery-liability benefit is given up. A shared certificate’s private half follows the decryption rule at retirement, because destroying it would take the ability to decrypt with it; it leaves the store only through an explicit pepsi-keys identity forget-private;

  • revocation becomes all-or-nothing. Revoking a compromised signing key also ends the ability to receive new encrypted mail at that certificate.

Shared mode is RSA only. pepsi-setup refuses it together with an elliptic-curve CRYPTO_SMIME_ALGORITHM rather than silently producing a certificate half the world will not accept: one EC key doing both ECDSA and ECDH is cross-algorithm key reuse that several S/MIME clients reject outright.

11.5.2. Capability, never shape

CRYPTO_SMIME_SHARED_KEY decides only what newly created identities look like. Toggling it never rewrites, re-keys or invalidates anything already issued, so at any moment the table may legitimately hold split identities, shared identities, and both for the same address — an old shared certificate still valid while new split ones are in use.

Every lookup therefore resolves by capability:

signing      ->  purpose IN ('sign',    'both')
encryption   ->  purpose IN ('encrypt', 'both')

and there is deliberately no query that returns “the identity” for an address. Code that assumed there were exactly two rows, or exactly one, would become a bug the moment an operator flipped the switch. When reading pepsi-keys identity list output, read the PURPOSE column the same way.

11.6. Creating identities

There are four ways material enters crypto_identity.

Generation. pepsi-keys identity generate creates the key pair here. OpenPGP defaults to Ed25519 ([pepsi] CRYPTO_OPENPGP_ALGORITHM), which is instantaneous to generate; RSA is available at 2048, 3072 or 4096 bits. S/MIME defaults to RSA at CRYPTO_GENERATE_RSA_BITS and can use NIST P-256 or P-384. Each generated certificate is self-signed, which is enough to start immediately, and --csr additionally emits a PKCS#10 request over the same key so a real CA can issue a replacement without re-keying.

Pepsi does not run a certificate authority of its own: that would make it hold a key far more valuable than any single user’s, with its own custody and revocation problem.

Import. pepsi-keys identity import stores material generated elsewhere — the certificate a CA issued over an existing key, or a key pair a user already had. The declared --purpose is what every later lookup matches on, so it must describe what the material can actually do.

Automatic creation. [pepsi-crypto] AUTO_CREATE_IDENTITY (on by default) permits an identity to be generated for a served address without an operator asking. When it fires is the encrypt stage’s ENABLE_PEP:

  • Eagerly, under ENABLE_PEP = yes (the default): on a sender’s first locally submitted message, whether or not it asked for protection. See pEp-style automatic keys below, which also covers what that costs.

  • Lazily, under ENABLE_PEP = no: the first time a local sender explicitly asks for protection — a marker in the subject, or the equivalent header from a mail client add-in, whether it asks for a signature or only for encryption — and never merely because an address appeared in an envelope. A user who never uses the feature therefore never has key material and is never exposed by it. SIGN = always counts as such a request on every message, so under it a sender gets a key on their first submission as under the preset.

Either way, automatic creation happens only for an address at a [pepsi-ingress] ACCEPTED_DOMAINS domain that has no identity row of any kind – not a revoked one, and not the user’s own key registered from their mail client. If somebody revoked a key, Pepsi does not quietly mint a replacement; and a user who brought their own key does not get a second one behind their back. Creation on request (pepsi-keys identity generate, the web console, the e-mail generate command) is not guarded this way. Rows created automatically are marked auto_created.

Warning

The one surprising consequence. The trigger is on the outbound path. An address that only ever receives mail therefore stays keyless, gets no Web Key Directory entry and publishes no DNS record — so outsiders have nothing to encrypt to it with, and never will, however long they wait.

An operator who wants inbound coverage must pre-provision those addresses:

pepsi-keys identity generate role@example.org --protocol openpgp

Creation for every address ever seen would mint key material – and publish it – for people who never sent anything through this server.

Registration of the user’s own key. A public key with no private half – pepsi-keys identity import without --private, a key registered through the web console or by e-mail, or one learnt from the Autocrypt: header of the user’s own submitted mail – is stored as the user’s MUA key; see Two keys per user.

11.7. pEp-style automatic keys

[stage-encrypt] ENABLE_PEP (default yes) makes Pepsi behave like the pretty Easy privacy (pEp) project: every local sender gets a key, mail is encrypted whenever a recipient’s key is known or discoverable, and the sender’s public key goes out with every message – in an Autocrypt: header by default, and additionally as an attached file with ATTACH_KEYS_AS_FILES (see Microsoft Exchange as a gateway). It is a preset of defaults for the encrypt stage, not a forcing mode; the full table is in pepsi-stage-encrypt(1). What it changes:

  • Eager key creation. The first locally submitted message from an address at a served domain generates an OpenPGP key for it, on every submission route including trusted relays (MYNETWORKS, client certificates), so a Pepsi deployed as a proxy behind an MTA that authenticates the users works the same. The domain test is what makes a key we mint useful: mail for that domain comes in through us, so the decrypt stage can open replies encrypted to it. A web application sending as noreply@ a domain we do not receive mail for gets no key.

  • Cleartext is not signed. The default SIGN becomes encrypted-only: a signature lives inside the ciphertext, and cleartext carries the key but no signature, so recipients who do not use cryptography never see a signature.asc. [sign] or X-Pepsi-Sign: yes still signs cleartext.

  • No key-server upload. Automatic keys are served by our own Web Key Directory and advertised by Autocrypt; they are never uploaded to a key server unless their owner asks (Key-server uploads happen on request).

Nothing else changes: discovery still parks the first message to a new correspondent for up to its timeout, the messages are standard PGP/MIME and Autocrypt, and there is no pEp wire format (no X-pEp-Version header, no pEp 2.x wrapping, no trustwords or handshake, no key synchronisation). The preset concerns only mail users send – cleartext goes out unsigned, and senders get keys they did not ask for; nothing about what anyone can receive depends on it.

Warning

Eager creation widens exposure, and it is the default. Under lazy creation a user who never uses cryptography has no key material on the server. Under ENABLE_PEP, every sender at a served domain has a wrapped private key in the database and a public key in the deployment’s Web Key Directory after their first message. The wrapping and the privilege boundary above still apply, but the set of keys a compromise of the key-encryption key would expose is everybody who sends mail, not just those who asked.

The site vetoes are [pepsi-crypto] AUTO_CREATE_IDENTITY = no (an administrator-only section, so no per-user setting can override it) and [stage-encrypt] ENABLE_PEP = no. A user opts out by registering their own key before sending (an address with any identity never gets an automatic one), or by setting ENABLE_PEP = no in their own pepsi.settings where the operator’s EDITABLE_STAGES allows it (pepsi-stage-edit-settings). pepsi-setup warns when the preset is on but AUTO_CREATE_IDENTITY is off or no KEY_WRAP_SECRET is configured, since the preset then quietly does less than it says.

The setup wizard (and the browser setup, which asks the same question) puts this to the operator explicitly, stating the exposure before asking, and writes the answer – yes as well as no – into the encrypt stage’s section of pepsi.conf, where it takes effect; no config.d file sets it.

11.8. Two keys per user

An address may hold two kinds of key:

MTA key (custody = local)

Pepsi holds the private half. The encrypt stage signs with it, the decrypt stage opens mail with it, and it is advertised in Autocrypt: headers. Generated automatically or on request, or imported with its private half.

MUA key (custody = client)

The user’s own key, whose private half lives only in their mail client. The row is public-only for ever and never primary. Pepsi encrypts to it and recognises mail encrypted to it, but can never open, sign with or advertise it.

The public face of an address is the key a correspondent should use: the newest active MUA key when there is one, otherwise the primary active MTA key. The Web Key Directory, the Autocrypt: header, the key file and local encryption all read it through one ordering, so they cannot disagree:

  • the Web Key Directory serves only the MUA key once there is one;

  • mail from one local user to another is encrypted to it;

  • Pepsi adds no Autocrypt: header and attaches no key file when it is an MUA key: the mail client advertises its own key, and Pepsi advertising a different one on some of the user’s messages would make correspondents’ key state flip under Autocrypt’s newest-wins rule.

The MTA key is not retired when an MUA key appears. It keeps decrypting mail from correspondents who still use it, and it still signs, inside the ciphertext, cleartext mail that Pepsi encrypts on the user’s behalf (a correspondent who lacks it sees an unverifiable signature, which is harmless).

Mail encrypted to an MUA key is passed to the mail client unopened, even when an MTA key is among its recipients too: the user registered their own key to get end-to-end protection, and a plaintext copy in the mailbox would silently undo that. It is not a decryption failure (ON_DECRYPT_FAILURE does not apply) and it flows on down the pipeline, so the spam and whitelist stages still decide its fate. See pepsi-stage-decrypt(1). Mail that arrived encrypted to the MTA key instead is opened, read by those stages, and then — with pepsi-stage-reencrypt in the pipeline — sealed to the MUA key before it is filed, so the mailbox copy is protected the same way.

Note

Server-side features go blind for end-to-end mail. A message passed through to an MUA key is ciphertext to every later stage: language detection cannot read it, nothing was verified so state.signature_verified is never set, and a whitelist entry with signature_required does not match it. That is the direct consequence of not opening it.

Likewise, a message the user’s mail client already encrypted is left alone on the way out – no second encryption, no signature, no Autocrypt: header or key file of Pepsi’s – and one it already signed may be encrypted but gets no second signature.

11.8.1. Registering the user’s own key

An MUA key arrives in one of these ways:

  • Automatically, from the user’s own mail. A submitted message whose Autocrypt: header (addr= equal to the From:) carries a key the address does not have yet registers it; for the address’s very first key an attached application/pgp-keys file naming the address also counts. This happens only when the submitter may speak for the From: address: a trusted relay (MYNETWORKS, a client certificate), or an authenticated account whose From: matched a concrete, wildcard-free USERNAME_MAP entry or its default login@HOSTNAME (state.origin.from_bound). An account granted *@example.org may send as its colleagues but never registers a key for them; pepsi-setup lists such accounts.

  • By the user, through the web console or by e-mail (below).

  • By the operator, with pepsi-keys identity import without --private.

A fingerprint the address already has, retired or not, is never registered again, so a retired key does not come back because an old device still sends it. A user with two devices, each holding a key, ends up with two MUA keys; the newest is the public face, and both are recognised on inbound mail.

When a key is registered automatically for an address that already had another identity, the user is sent a notice (“a new encryption key was registered for your address”, with its fingerprint and how to retire it; template key-registered.<lang>.body) at the encrypt stage’s RESPONSE_STAGE.

Warning

A stolen password can register a key. Anybody who can submit mail as a user can make their own key that user’s public face. The notice is the mitigation, and a partial one: an attacker who can submit mail can often read the mailbox over IMAP and delete the notice too. The damage is bounded – mail encrypted to the planted key is still delivered only to the victim’s mailbox, so it is the ability to read future mail the attacker can already reach, plus signatures that verify as the user – and the remedy is retire, available by e-mail as well. A deployment with no RESPONSE_STAGE sends no notice at all; pepsi-setup warns about it.

A user closes this road by enrolling a second factor (below): from then on a key is registered only by a command that carries a current code, and an ordinary message’s Autocrypt: header registers nothing.

11.8.2. Self-service on three surfaces

One set of operations – status, generate (a server-managed key, always allowed on request), register (the user’s own key), publish (request a key-server upload) and retire – is available on three surfaces, each confining a user to their own address the way it already does:

pepsi-keys

identity show, identity generate, identity import (without --private), identity publish --vks, identity revoke, and otp for the second factor. See pepsi-keys(1); an unprivileged user reaches only their own identities on a setuid install.

The web console and the API

For an operator or a principal holding own:<address>: the identities page offers Generate a server-managed key and Register my own key (paste an armoured public key), and an identity’s page Upload to the key server and revoke. The web tier still never acts on key material: generating, registering and revoking enqueue generate-identity / register-client-key / revoke-identity tasks that pepsi-setup apply re-validates and performs as the pepsi-crypto role – revoking too, because it destroys a signing-only key’s private half. A revocation requested there therefore takes effect when the applier has run, with the same retirement rule as pepsi-keys identity revoke. Without the pepsi-httpd-admin package those three degrade to read-only like every other applier path. See The administrative API and The administration console.

E-mail

A message from the user to pepsi-keys@<their domain> ([stage-encrypt] KEYS_CONTROL_LOCAL_PART) with the command in the Subject: – status, generate, register (taking the key from this message’s Autocrypt: header or attachment), publish [FPR], retire FPR, otp enroll, otp remove – is consumed rather than relayed and answered with the list of the address’s keys. With a second factor enrolled, every command but status also carries the current code (A second factor for key changes). It needs RESPONSE_STAGE to be set, and works only when the submitter may speak for the From: address (as above). It is handled in the encrypt stage itself, which already runs as pepsi-crypto; a stage worker must never be able to enqueue work for the root applier.

11.8.3. A second factor for key changes

A user can protect everything above with a second factor: a TOTP secret (RFC 6238 – six digits, a new code every 30 seconds, SHA-1, which is what every authenticator app implements) enrolled for their address. Once it exists, every change to the address’s keys that a user can make needs the app’s current code:

  • by e-mail, every command but status – the code is a six-digit word anywhere in the Subject:, e.g. register 123456 or retire 89ABCDEF01234567 otp=123456. A message’s Autocrypt: header or attached key file no longer registers a key for that address; register with a code does;

  • in the web console or the API, generating, registering and revoking under own:<address> take the code in an otp field (the console’s forms have one), which the setup applier checks – pepsi-httpd itself can neither read nor check a second factor;

  • with an unprivileged pepsi-keys, pepsi-keys identity --otp CODE <command>.

Enrolling, replacing and removing it are commands of their own, and replacing or removing one needs a code too:

otp enroll (e-mail) / pepsi-keys otp enroll ADDRESS

Creates a fresh secret and answers with it once: an otpauth:// URI, the secret in base32 for typing in by hand, and (by e-mail) a QR code to scan. Delete that reply once the secret is in the app: anybody who can read it can produce the codes.

otp remove CODE / pepsi-keys otp remove ADDRESS --otp CODE

Removes it.

status shows whether the address has one, and whether it is locked.

Wrong codes and replays. A code is accepted once: the same code again, even within its 30 seconds, is refused as a replay. Clocks may differ by one step either way. A wrong or replayed code counts as a failure, a right one resets the count, and ten failures in a row lock the second factor – every later attempt is refused without being checked. A command that carries no code at all is refused without being counted.

The operator can always let a user back in: pepsi-keys otp reset ADDRESS (or DELETE /api/v1/otp/{address} with keys:write, which asks the setup applier) removes the enrolment whatever its state, and pepsi-keys otp enroll ADDRESS run by the operator replaces it without a code. A new enrolment always starts at zero failures. pepsi-keys otp status lists every enrolment. An operator is never asked for a code: they can reset it anyway.

What it does not cover, stated in Threat model: the first enrolment needs no code, so somebody who has the password first can enrol their own (the operator’s reset is then the remedy); the lock can be triggered by anybody who can send as the user; and the web console’s publication, key-server and primary-key switches, which pepsi-httpd performs directly, are not gated. The secret is wrapped under the key store’s key-encryption key like a private key, the table is readable by the pepsi-crypto role only, and pepsi-keys wrap rotate re-wraps it with the keys.

A generated or imported identity is published by default (--no-publish opts out, and identity unpublish clears the flag later). A key nobody can fetch cannot be encrypted to, so publication is the useful default; the flag is the per-address escape hatch.

11.9. Publishing our users’ keys

The mirror image of “Finding a correspondent’s key” below: how other people find our users’ keys. There are three channels, and they behave so differently that treating them as one setting would be a mistake.

Channel

Who serves it

Reversible?

Turned on by

Web Key Directory

us (pepsi-httpd)

yes, immediately

the identity’s published flag — on by default

Key server

a third party

no

the identity’s vks_wanted flag, set by an explicit request or, for keys made by hand, by [pepsi-keys] VKS_PUBLISH – off by default

DNS (OPENPGPKEY / SMIMEA)

us (our zone)

yes, on the next zone push

publishing the record by hand

Every identity is published by default, however it was created. The opt-out is per address, not per identity, because that is the unit a user reasons about (“am I discoverable?”) — and because a per-identity opt-out permits a useless half-state in which the signing certificate is published and the encryption certificate is not, so correspondents can verify but cannot encrypt.

11.9.1. The Web Key Directory

pepsi-httpd answers the four Web Key Directory paths for every domain in [pepsi-ingress] ACCEPTED_DOMAINS:

GET /.well-known/openpgpkey/hu/<hash>              direct   (on <domain>)
GET /.well-known/openpgpkey/policy                 direct
GET /.well-known/openpgpkey/<domain>/hu/<hash>     advanced (on openpgpkey.<domain>)
GET /.well-known/openpgpkey/<domain>/policy        advanced

Each identity carries the WKD local-part hash of its address (z-base-32 of the SHA-1 of the lower-cased local part), stored and indexed together with the domain, so a request is answered with one lookup rather than by hashing every candidate row. The SHA-1 there is a naming function fixed by the specification, not a security one, so it is not gated by CRYPTO_ALLOW_WEAK_DIGESTS.

The response is the binary transferable public key — not armor — with Content-Type: application/octet-stream and Access-Control-Allow-Origin: *, which the specification requires so a browser-based client can fetch a key from a domain other than its own origin. The policy file is a zero-length 200: it carries no flags, but it has to exist, because GnuPG reads its absence as “this domain runs no Web Key Directory” and gives up before asking for a key.

The ?l=<local-part> parameter a client may send is read and thrown away. Honouring it would turn the endpoint into a lookup by caller-supplied address, which is a much broader thing than answering a hash the caller already had to know.

Deployment takes one DNS record and one certificate name per served domain. The advanced method — the one every current client tries first — is fetched from openpgpkey.<domain>, so that name must resolve to the pepsi-httpd host and the certificate it serves must cover it. Both are the same listener: openpgpkey.<domain> and the apex are two SNI names on one HTTPS listener, not two listeners. pepsi-setup run adds openpgpkey.<domain> to the names it asks certbot for and reminds you about any that does not resolve; a domain that already publishes keys is flagged, because for it the advanced lookup is failing today rather than merely unconfigured. The direct method on the apex works without any of this, so a missing openpgpkey host is a degradation, not a breakage.

Important

The endpoint serves our own published identities only. It reads crypto_identity, the table of addresses we hold key material for, and never peer_key, the cache of correspondents’ keys that discovery filled in. Serving those would make Pepsi a key server for material it never verified and does not vouch for, published under its own domain name. There is no configuration option that changes this.

Note

A Web Key Directory answers an unlimited number of guesses, and Pepsi does not pretend otherwise. There is no rate limiter on the endpoint and no uniform-404 shaping: a request for an address that publishes a key gets the key, and a request for one that does not gets a 404, promptly and as often as anybody cares to ask.

The protocol is a public oracle by construction — its whole purpose is that anybody may ask whether an address publishes a key — the hash covers only the local part, so it answers guesses rather than enumerating a list, and the addresses are on the outside of every message the domain sends anyway. Shaping the 404s would cost a client the ability to tell “no key” from “server broken” in order to buy an obstacle that a wordlist walks straight past. If you need the addresses to be secret, a key directory is the wrong thing to be running.

11.9.2. Key-server uploads happen on request

keys.openpgp.org and the servers that speak its protocol accept a key permanently. They can later be told the key is revoked; they can never be told to forget it, and neither can the address it was uploaded for.

That is why nothing is uploaded unless somebody asked, and why the request is recorded: each identity carries vks_wanted, and “not uploaded” is state, shown by pepsi-keys identity show, the web console and the e-mail status reply, rather than an absence.

  • Automatically created keys and automatically registered MUA keys start with it off. Autocrypt and our own Web Key Directory (which is not an upload) are how they are found.

  • Keys made by hand – pepsi-keys identity generate/import, the web and e-mail generate – take [pepsi-keys] VKS_PUBLISH as their default, an operator-level switch that is off by default: the person who understands that an upload cannot be withdrawn is the operator, not whoever happened to generate a key.

  • An explicit request is honoured whatever VKS_PUBLISH says: pepsi-keys identity publish 42 --vks (which prints a confirmation naming exactly what cannot be taken back), the web console’s Upload to the key server, PATCH /api/v1/identities/{id} with vks_wanted, or the e-mail publish command.

Publication is a two-step protocol, and that is why there is a retry job. The upload returns a token; only the token authorises asking the server to mail a confirmation link to the address; and only after that link is followed does the server serve the key by address. A key that was uploaded but never confirmed is stored yet unfindable — the failure that looks like success. So:

pepsi-keys identity publish --retry      # from cron, e.g. hourly

works through every published, active OpenPGP identity whose upload was requested and that is not verified yet – it is not gated by VKS_PUBLISH, so a user’s request takes effect on the job’s next run – spaced by VKS_RETRY_INTERVAL and giving up after VKS_MAX_ATTEMPTS with the reason left in the row for you to read (pepsi-keys identity show). Each round asks the key server whether the address is already served before uploading again, so a confirmation followed by anybody — the stage below, a human reading the mailbox, an earlier run whose database write did not land — ends the loop.

The confirmation mail itself is followed by pepsi-stage-vks-confirm, a small stage on the inbound path. It is a separate program because what it does — fetch a URL found in inbound mail — is a genuinely dangerous primitive, and isolating it means the restrictions are in one small place whose position in the pipeline is visible in the configuration. That program’s page lists them; the short version is that the mail must come from the configured key-server host, be authenticated by SPF or DMARC, link only to that host, and be addressed to somebody we are actually waiting for a confirmation for. Anything else is delivered to the user, who can click the link themselves. So is a mail whose links were followed but after which the key server still does not serve our key: it is discarded only once at least one identity is verified as published.

11.9.3. Unpublishing, and what it cannot do

pepsi-keys identity unpublish 42

stops the Web Key Directory serving the identity at once, and clears its key-server bookkeeping so the retry job forgets it.

It does not remove anything from a key server, because nothing can. If the key was uploaded, the command says so and points at the only real remedy:

pepsi-keys identity revoke 42 --upload

which builds a revocation certificate — signed by the key it retires — and publishes it on top of the uploaded key. Note the ordering inside that command: the upload happens first, because revoking a signing identity destroys the private key the revocation certificate needs. If the upload fails, nothing is revoked, so you can fix the key server and try again.

11.9.4. DNS: OPENPGPKEY and SMIMEA

pepsi-keys dns <address> prints the DNS records that publish an address’s published, active identities: OPENPGPKEY (RFC 7929) for OpenPGP material and SMIMEA (RFC 8162) for S/MIME certificates, both under a hashed owner name so the zone does not enumerate the addresses it serves. pepsi-setup run prints the same records for every published identity, alongside the DKIM, SPF and MTA-STS ones (capped at a readable number — for a large deployment, ask per address).

S/MIME has no Web Key Directory equivalent and no key server, so SMIMEA is its publication channel.

Warning

Publish key records only in a DNSSEC-signed zone. An unsigned OPENPGPKEY record is a key handed out by whoever can answer for the zone, which is not an improvement on having no key at all — and unlike a WKD answer, a DNS answer carries no other evidence. Both commands repeat this as a comment in their own output.

The DNS owner name and the WKD hash are not interchangeable: the DNS one is SHA-256 truncated to 28 octets, hex-encoded, over the case-sensitive local part (RFC 7929 §3), while the WKD one is z-base-32 of SHA-1 over the lower-cased local part. Confusing them produces records nothing ever queries, which is why they are separate functions with a test asserting they differ.

11.10. Backing up and rotating the key-encryption key

Danger

If the key-encryption key is lost, every stored private key is gone. Back up secrets.d/pepsi-crypto.secret separately from the database, and treat it with the care its contents deserve.

The blow is softened by the same asymmetry that governs retirement: mailboxes hold plaintext, so losing the KEK does not make anybody’s mail unreadable. What is lost is any ciphertext still in flight or archived — and, far more disruptively, every published identity is invalidated at once. Correspondents still hold public keys the deployment can no longer use, so the whole organisation has to re-key and re-publish. Plan for that as a disaster-recovery scenario, not as an inconvenience.

Rotation, by contrast, is routine and incremental by construction, because each row records the id of the key that sealed it:

  1. put the new secret in secrets.d/pepsi-crypto.secret as KEY_WRAP_SECRET, and keep the old one beside it as KEY_WRAP_SECRET_<OLD-ID> (the suffix is the old wrap_key_id, upper-cased);

  2. point [pepsi-crypto] KEY_WRAP_KEY_ID at the new id;

  3. run pepsi-keys wrap rotate.

Between steps 2 and 3 the deployment keeps working: new material is sealed under the new key while old material still opens under the retired one. Only after step 3 reports everything rotated may the retired secret be removed. Each re-wrap is a guarded update keyed on the old id, so a concurrent rotation — or a key re-generated while the sweep ran — is never written over; rows whose key has no configured secret are reported and skipped rather than failing the run. --dry-run lists the work first.

11.11. Peer keys

A peer key is cached with the source it came from, and the source decides what happens when a newly discovered key contradicts a stored one. The order of those sources is the trust ladder tabulated in Finding a correspondent’s key below; it lives in a database function so it can be re-ordered by re-running the procedures file rather than by a schema patch, and the conflict rule is applied server-side, in the same statement that stores the key, so no client can skip it.

A key that outranks everything stored for an address replaces it; at equal or lower rank the stored key is kept and the new one is not inserted, so a weak source can never quietly displace a better-attested one. A pinned row is never displaced, whatever the rank — pinning is how an operator makes a trust-on-first-use decision permanent.

One exception sits between those two rules, and it is confined to the Autocrypt tier (the inbound and gossip rungs): when the incoming key comes from that tier, every stored row for the address and protocol is from that tier too, the incoming rank is no worse than the stored one, and the message’s Autocrypt effective date is strictly newer than the newest one stored at the best rank, then the stored key is replaced at equal rank. That is Autocrypt Level 1’s own youngest-wins peer-state update: without it the second Autocrypt: key ever seen for an address would be refused for ever, and a correspondent who reinstalls their client or rotates their key would go on receiving mail encrypted to a key they cannot read. The test is on the source name, not the rank number, and the no worse than term keeps the exception inside the tier in both directions — gossip still never displaces what an address’s owner said about themselves, and neither ever displaces a harvested or better-attested key.

A gossiped key its owner confirms is promoted

A gossiped key does not stay on the bottom rung for ever. When the address’s owner later advertises the same key themselves — in their own Autocrypt: header, or as an attached key naming them — the row is promoted to the inbound rung (Autocrypt Level 1 §5.3’s move from gossip to the peer’s own state). The key has not changed, so this is not a rotation: no key.peer.rotate row and no notice to anybody, but one key.peer.promote row (severity info) in the audit log, written by the same statement. The row then defends itself at the owner’s rank, so a later gossip of a different key is refused against it, and MIN_TRUST = harvested now admits it. Its effective date becomes the owner’s own, not the gossiping message’s, so a later rotation by the owner is judged against what the owner said. What a signature checked against it can reach does not change: it is still trust on first use, reported valid-untrusted at best. A different key from the owner is not a promotion either; it simply outranks the gossip and replaces it. A network source (WKD, DANE, …) or an operator that re-sees a gossiped key takes the row over as an ordinary refresh.

A rotation is never silent

The exception is keyed on the From: header, which the sender writes, and inbound authentication is fail-open, so a message that merely claims to come from a correspondent can change the key we encrypt to them with. Autocrypt accepts that trade (it defends against passive collection, not an active attacker on the path) and so does Pepsi, but it leaves three traces:

  • the same statement that replaces the key writes a key.peer.rotate row to the audit log — the address, the protocol, the displaced and the new fingerprints and both effective dates — so there is no rotation without its record; pepsi-status summarises the last 30 days of them;

  • with RESPONSE_STAGE set on pepsi-stage-autocrypt-learn, each local recipient of the message that caused it is mailed a notice naming the correspondent and both fingerprints (the key-rotation.<lang>.body template), so the person about to reply knows to check before sending anything confidential;

  • an autocrypt-rotation telemetry count and a log line.

An operator who would rather refuse a genuine rotation than accept a forged one sets ACCEPT_ROTATION = expired on that stage: the newer key is then taken only when every stored key for the address is dead — recorded as revoked or expired, or past its expires_at. Otherwise the offer is held: nothing changes, a key.peer.rotate.held warning row is written, and the recipients are told the new key was not accepted — which matters, because a correspondent who really did change keys cannot read what they are sent until an operator removes the old row (pepsi-keys peer remove) or imports the new key by hand. A learnt key is stored with the expiry its own material states (an OpenPGP primary key’s self-signature, an X.509 notAfter), so a key that has lapsed counts as dead without anything marking it; one that states no expiry is displaced only once it is marked revoked or expired, or by hand.

Two rules bound how broad an entry can be:

  • an entry may name a whole domain (*@domain), but only when it was entered by hand. That is enforced by a database constraint, not by convention, so no discovery mechanism can generalise one address’s answer to a domain;

  • an exact address match always wins. A *@domain row is consulted only when no exact row exists, and pepsi-keys peer show marks it as the fallback it is.

11.12. Finding a correspondent’s key

Everything above is about keys once they are in the store. Getting them there for someone else’s address is key discovery: given an e-mail address, find the public key or certificate, record where it came from and how much that source is worth, and cache it. The program is pepsi-keydisc, and the whole feature is configured by [pepsi-keydiscovery].

Finding a key is easy. Knowing whether to believe it is the entire problem, which is why the source and the DNSSEC flag are stored with every key.

11.12.1. Where a key can come from

dane

DNS OPENPGPKEY (RFC 7929) and SMIMEA (RFC 8162), under a hashed local part. Refused without the resolver’s DNSSEC AD bit — see When there is no validating resolver.

wkd-advanced

The Web Key Directory at openpgpkey.<domain> — a host the domain had to delegate deliberately.

wkd-direct

The Web Key Directory at the domain itself, <domain>/.well-known/openpgpkey.

ldap

A configured directory. Optional, and compiled in only with the ldap cargo feature, because a deployment with no directory should not link one.

vks

A verifying key server (keys.openpgp.org by default, or any server speaking the same paths). Note that this is VKS, not the older unverified HKP: an HKP server will serve a key anybody uploaded for anybody’s address.

inbound

Harvested from a message that already arrived — an application/pgp-keys part, an S/MIME signer certificate, or an Autocrypt: header. Only material claiming the sender’s own address is kept. Not a discovery service: it runs where the message already is and costs no network I/O, which is why it may not appear in SOURCES. Written by pepsi-stage-autocrypt-learn, not by the decrypt stage.

gossip

Read out of the Autocrypt-Gossip: fields inside a message pepsi-stage-decrypt decrypted: the other recipients’ keys, which the sender attached so that a reply-all can be encrypted to somebody one has never had direct mail from (Autocrypt Level 1 §5.3). A field counts only for an address the message’s own To:, Cc: or Reply-To: names — §5.3’s own receiver rule. The only route by which one correspondent may speak about another, and hence its own rung at the bottom of the ladder. Like inbound it is not a service and may not appear in SOURCES; see pepsi-stage-autocrypt-learn, which is where both inline rungs are written, for the rest of the rules that decide whether a field counts.

manual / api

An operator running pepsi-keys peer import, or an authenticated submission. Not discovered at all; listed here because it is the top of the same ladder.

11.12.2. The trust ladder

This is the order everything is judged by — best first. It decides which key wins a conflict, what MIN_TRUST compares against, and how much extra time a still-running method is granted by the stop rule below.

Rank

Source

Why there

0

own

Not discovered at all, and not really a rung: the public half of an identity this deployment holds for the address. It pre-empts the ladder rather than topping it — see Our own users’ keys are not discovered — so it clears every floor and no peer_key row for that address is consulted.

1

manual / api

An operator who typed a fingerprint in meant it. Its manual spelling is also the only source the database constraint permits to enter a *@domain row.

2

dane with the AD bit

The only source with cryptographic provenance: the domain published it and DNSSEC proves the answer was not rewritten in transit.

3

wkd (advanced)

Fetched over verified HTTPS from openpgpkey.<domain>, a host the domain set up on purpose.

4

wkd-direct

The same, from the domain itself. A weaker signal of intent than a dedicated host, hence its own rank.

5

ldap

A directory the operator configured, and therefore semi-trusted — but still a directory somebody else runs.

6

vks

Proves only that somebody controlling that mailbox uploaded it: the server sent a confirmation mail and somebody clicked it. A real check, and a much weaker one than “the domain publishes this”.

7

inbound (harvested, Autocrypt)

Trust on first use, of what the address’s owner said about their own address. Also the weight a dane row carries without the AD bit: a DNS record nobody signed is worth no more than a scrape. Discovery refuses such an answer outright (When there is no validating resolver), so this only applies to a row that entered another way — and such a row can never make a signature valid either.

8

gossip (Autocrypt-Gossip)

One correspondent’s introduction of another (Autocrypt Level 1 §5.3), read out of the ciphertext of a message this host decrypted. The same material as rank 7 and a different claim: not “this is my key” but “this is somebody else’s”. §5.3 keeps the two in separate peer states precisely so that a third party’s introduction can never displace what an owner vouched for, and that is what a rung of its own buys.

[pepsi-keydiscovery] MIN_TRUST is the floor, and it defaults to any — the bottom rung, whatever the bottom currently is, so gossiped, harvested and Autocrypt keys are all accepted. The alternative to encrypting to a trust-on-first-use key is not encrypting to a better key, it is sending cleartext. Opportunistic encryption to an unauthenticated key still defeats passive interception, which is the threat most mail actually faces. The ranking is not thereby pointless — it arbitrates conflicts, it is recorded per key, and an operator who would rather send nothing than send to an unauthenticated key has one option to raise.

The default tracks the bottom rather than naming a number. MIN_TRUST = harvested names rank 7 exactly, so it accepts what a correspondent advertised about themselves and refuses a stranger’s introduction of them. The introductions are still stored either way; the floor decides only whether they may be encrypted to.

Every rung’s name is accepted as a floor (own, manual, dane, wkd, wkd-direct, ldap, vks, inbound, gossip), together with the aliases any/none for the bottom, harvested/autocrypt for rank 7, and owner-confirmed — which names rank 6 by what the evidence means rather than by the method that produced it: at vks and above, somebody who could prove control of the mailbox or the domain published the key on purpose; below it, nobody proved anything.

11.12.3. Our own users’ keys are not discovered

There is one address for which the whole ladder is the wrong question: one this deployment made the keys for. If pepsi-keys issued an identity for alice@example.org, then Alice’s key is not something to be found, weighed and compared — it is on the shelf, and it is the only key her mail can be encrypted to that she will be able to open.

That matters because peer_key is not a trusted table. Part of what fills it is harvesting from inbound mail, and harvesting keys on the From: header, which the sender writes. Inbound authentication is deliberately fail-open — only a definite DMARC failure under DMARC_ENFORCE rejects — so on a deployment that does not enforce DMARC on its own domain, a message claiming From: alice@example.org and carrying an Autocrypt: header will seed a peer key for Alice with a stranger’s key in it. Two things then go wrong, and they are the reason this is a defence and not a tidiness rule: mail from one of our users to another would be encrypted to the planted key, and a signature made with it would verify, setting state.signature_verified for an outsider.

So both crypto stages ask “is this one of ours” before they ask “what did discovery find”. When the answer is yes, our own key is the answer: the correspondent’s peer_key rows are not consulted for that address at all, whatever their rank and however they arrived. state.crypto records it as own.

Important

The rule is conditional on holding a key, not on serving the address, and the difference is a deployment rather than a technicality. A user who runs GnuPG in their own client, has never asked Pepsi to encrypt anything and has never uploaded a key to us has no identity here — and their real key is one this host can only ever learn from discovery, harvesting or gossip. A blanket “never believe a discovered key for an address we serve” rule would send that user cleartext for ever. So: if we made the keys, they are the ones that get used; if we did not, the address is treated like any other correspondent’s, trust-on-first-use exposure included.

The corollary is the cost: for an address we do hold an identity for, a signature made with the user’s own separate key does not verify, however well-attested that key is – until that key is itself registered as the user’s MUA key (Two keys per user). Registering it (pepsi-keys identity import without --private, the web console, the e-mail register command, or automatically from the user’s own Autocrypt: header) is how it becomes believed, and also how the user starts receiving mail encrypted to it.

11.12.3.1. Finding the users who bring their own key

The rule above leaves a residue by design: a served address we hold no identity for is still trust-on-first-use, because that is the only channel by which a bring-your-own-GnuPG user’s real key can reach this host. What an operator can do about it is not a setting — it is an introduction. Import that user’s public key as an identity and the exposure is over for that address: from then on our own row pre-empts whatever the cache holds.

So the deployment needs a way to notice them, and that is pepsi-keys peer unclaimed at the terminal, or GET /api/v1/peers/unclaimed (see The administrative API): the addresses that are

  • served by this deployment (their domain is one of [pepsi-ingress] ACCEPTED_DOMAINS; the command also counts [pepsi-crypto] LOCAL_DOMAINS),

  • held by no active encryption identity of ours, and

  • nevertheless present in peer_key — usually harvested from their own outbound mail’s Autocrypt: header, which is exactly what a user running GnuPG in their own client produces.

Each row names the cached keys with their fingerprint and source, so an operator can tell a key the user themselves advertised (inbound) from a third party’s gossiped introduction (gossip) before acting on it, plus a count of how many identities — of any purpose or lifecycle state — the address does have. The report is read-only and changes nothing; it is a worklist, not an alarm, and an empty one is the ordinary state of a deployment whose users all hold identities here. The console’s Correspondent keys page (/ui/peers) lists the whole peer_key cache, not this subset.

The two differ in one default. The command lists only harvested keys — source inbound or gossip, the two a stranger can plant just by sending us a message with a forged From: — because that is the exposure the report exists for; --all-sources widens it to discovered, manual and API keys, which is what the endpoint always returns:

pepsi-keys peer unclaimed                  # harvested and gossiped keys
pepsi-keys peer unclaimed --all-sources    # every cached key
pepsi-keys peer unclaimed --json           # [] when there is nothing to do

For each entry, confirm the key with its owner and then either register it as theirs (pepsi-keys identity import ADDRESS --protocol P --purpose U --public FILE, no --private) or drop it (pepsi-keys peer remove ID).

Note

Ask the user before importing. The key in the cache is one somebody advertised; confirming out of band that it is really theirs is what turns a trust-on-first-use row into a trusted one, and is the entire value of the exercise.

Which lifecycle states count differs between the two uses, for the reason expiry exists:

  • Encrypting to the address counts active identities only. A retired key must not receive new mail, and a deployment that retired a user’s key without issuing another is in the same position as one that never held a key for them — so discovery takes over.

  • Verifying a signature from the address counts everything but revoked. A signature made last year was made with last year’s key; refusing to look at it would report our own users’ mail as unverifiable every time a key rolled over. revoked is excluded because that is the operator saying the material must not be used at all.

11.12.4. The pipeline never blocks on a key server

Discovery is asynchronous and outside the stages. A stage that needs a key it does not have does no network I/O at all: it commits whatever it has already produced, pauses the message, and enqueues a request keyed on the address. A pepsi-keydisc instance answers the request and, in the same round-trip that stores the key, releases every message parked on that address.

Three consequences follow, each intended rather than a side effect:

  • A slow key server cannot hold a stage worker. That problem is removed rather than bounded — there is no worker waiting on it to bound.

  • A hundred messages to one correspondent cost one lookup. The request is keyed on the address, so they deduplicate onto it and are released together.

  • A request that settles without finding a key still releases its waiters. The services find keys; the stages decide about mail. Nothing in the discovery layer ever decides a message’s fate.

The methods run in parallel: every method in SOURCES starts at once, each as its own process (pepsi-keydisc@dane, @wkd-advanced, @wkd-direct, @vks, @ldap). The two Web Key Directory methods are separate ranks and therefore genuinely concurrent, not a fallback chain — running direct only “if advanced fails” would silently accept the weaker answer whenever the stronger one was merely slow. One process per method also means a hung LDAP bind cannot stall WKD, and that turning a method off is systemctl mask --now pepsi-keydisc@<method> (mask, because pepsi.target starts the four default instances and would start a merely disabled one again).

Keep SOURCES and the enabled units in step. SOURCES is the roster the stop rule waits for, so an instance whose method it does not list refuses to start, and a method it lists with nothing running costs every request the full TIMEOUT before it gives up.

11.12.5. RANK_GRACE: the speed-versus-thoroughness dial

Running everything at once raises one question: an answer has arrived from a mediocre source, and a better one is still running. Wait, or go?

RANK_GRACE is the single knob that answers it. When a lookup succeeds, every still-running method that ranks better is granted

RANK_GRACE × (how many ranks better it is)

of additional time, measured from the moment the answer in hand arrived. When that window expires, the best result in hand wins. The knob spans the whole range of reasonable policies:

Setting

Behaviour

0

The first usable answer wins outright. Fastest; the ranking then only arbitrates conflicts in the store, never the race.

5 s (default)

One rank up gets 5 s, three ranks up gets 15 s. A slow WKD-advanced server cannot hold a message behind a key-server hit that already arrived, but a nearly-as-fast better source still gets to win.

≥ TIMEOUT

Every method is waited for and the ranking applies in full. Slowest and most thorough; pepsi-setup notes this configuration rather than objecting to it, because it is a legitimate choice.

Two other deadlines bound the same fan-out: METHOD_TIMEOUT (15 s) is one method’s own network budget, and TIMEOUT (60 s) ends the whole request whatever is still running. A request that ends with nothing is not an error; it is a correspondent with no key.

11.12.6. The negative cache

When a request settles without a key, that outcome is remembered: for NEGATIVE_TTL (24 h) after an authoritative “there is no key here”, and for the much shorter ERROR_TTL (1 h) after a network failure — “the server was down” is worth re-trying sooner than “there is no key”.

The rule that follows:

A message for an address with a fresh negative entry never pauses. It takes the stage’s no-key path immediately.

Without that, every message to a correspondent who simply has no key would pause for the full TIMEOUT to re-learn an answer already known — turning one absent key into a permanent per-message delay for that whole correspondence. The accepted cost is the other direction: a key published five minutes ago stays invisible until the entry ages out. Lower NEGATIVE_TTL if that trade is wrong for your deployment; that is exactly what the option is for.

The same short-circuit fires when the deadline of an outstanding request has passed with nobody answering it — the signature of a deployment running no discovery service at all. The message takes the no-key path rather than parking again, which is what stops it parking for ever.

11.12.7. Lookup privacy

Looking an address up tells somebody that you are about to write to it. Which somebody depends on the method: DNS tells the domain’s authoritative servers and your resolver’s path, WKD tells the correspondent’s own domain, and a key server tells its operator.

Two controls exist, and they are not interchangeable:

[pepsi-keydiscovery] ALLOW_DOMAINS / DENY_DOMAINS

The operator’s, and the recipient-side one. A non-empty ALLOW_DOMAINS is exclusive — only those domains are ever looked up — and DENY_DOMAINS always wins over it. This is the control to reach for when the question is “never query that domain”.

[stage-encrypt] DISCOVERY = no

The per-user one, overridable per address through the pepsi.settings layer. Cached keys are still used; a cache miss goes straight to the no-key path instead of parking.

Important

The option’s name invites the wrong reading. The per-address settings layer resolves an outbound message on its envelope sender. So setting DISCOVERY = no for an address means

  • “this user’s mail never triggers a lookup” —

and not

  • “never look this correspondent up”.

There is deliberately no per-address recipient denylist: it would need a new column and a second list to stay consistent with DENY_DOMAINS, and two overlapping denylists that can disagree are worse than one that cannot. The recipient-side control is the operator’s, above.

11.12.8. When there is no validating resolver

DANE discovery is AD-gated: an OPENPGPKEY or SMIMEA answer the resolver did not DNSSEC-validate is refused outright. This is not the same policy as TLS Reporting, where a forged rua costs an attacker a copy of a report. An unvalidated key record is a key from whoever can answer for the zone, which is not an improvement on having no key at all — so an unvalidated answer is not a weaker answer, it is no answer.

Pepsi trusts a validating resolver rather than validating DNSSEC itself, the same operational model its DANE/TLSA support uses for transport security. The consequence:

Warning

A deployment without a validating resolver gets no DANE results at all — silently, because the method dutifully answers “no key” for every address, and the best-ranked source simply never wins. pepsi-setup probes for this and reports it, but the fix is yours: point Pepsi at a validating resolver (unbound, or systemd-resolved with DNSSEC=yes over a trusted path), or drop dane from SOURCES so nothing waits for it.

11.12.9. Inbound mail waits too

Verification pauses on the same mechanism as encryption: when an inbound signature is from a signer whose key is not cached, the message parks while discovery runs, so the first message from a new correspondent gets a real verdict instead of unverifiable.

That costs a real delay, on exactly the mail somebody is waiting for. Three things blunt it, and the first is the one to reach for:

  • INBOUND_TIMEOUT is separately tunable. An operator who feels this delay can drop it to a few seconds without touching the outbound TIMEOUT at all. pepsi-setup warns above five minutes.

  • The negative cache means the second and later messages from an unknown signer never pause — only the first pays.

  • Requests deduplicate per address, so a flood from one signer costs one lookup rather than one per message.

Whether the parked row holds plaintext depends on the protocol. Under the layering rules an inbound signature is inside the ciphertext, so the decryption has to happen before the signer — and therefore the missing key — is even known. What may then be committed differs:

  • An S/MIME signature nested inside an envelope survives decryption as an ordinary MIME layer, so the message is parked with the plaintext committed and is decrypted exactly once.

  • An OpenPGP signature lives only inside the packet stream and is gone the moment the plaintext is out. Committing the plaintext would destroy the very evidence the key is being fetched to check, so such a message is parked unchanged and decrypted again on resume. The second decryption is the price of a correct verdict.

Note

One exposure. A message parked with its plaintext committed — the S/MIME case above — sits in pepsi.workqueue decrypted for up to INBOUND_TIMEOUT.

This is the same exposure delivery would create a moment later anyway: the mailbox gets plaintext. It is a longer window, not a new one. If that window matters to you, it is INBOUND_TIMEOUT that sets its length. See pepsi-stage-decrypt for the rule as the stage applies it.

11.12.10. Who uses the store

pepsi-keys peer refresh --address exercises the whole discovery fan-out — the sources, the ranking, the request queue and the negative cache — end to end.

pepsi-stage-encrypt signs locally submitted mail as its From: author and encrypts it per recipient, parking a message whose recipient key is not cached until a discovery service settles the request. pepsi-stage-decrypt is its inbound counterpart: it opens mail addressed to recipients this host serves — trying every non-revoked identity of theirs, including expired ones, because old mail must stay readable — verifies the signature against the peer_key cache and the ca_trust anchors, and reads whatever key material the message itself carries. It parks on the signer’s key the same way, so even the first message from a new correspondent gets a real verdict rather than unverifiable. What it does not do is write to the store: the certificates and Autocrypt: headers a message carries are used in memory to check that message’s own signature and then dropped. pepsi-stage-autocrypt-learn is the stage that learns them.

Re-encryption for storage. A message Pepsi decrypts is filed in the mailbox as plaintext by default, which is what makes server-side decryption useful at all – the gateway model assumes the mail store is trusted. For a user who registered their own MUA key, pepsi-stage-reencrypt closes that gap: placed immediately before local delivery, after every stage that reads the content, it seals a message the decrypt stage opened with the MTA key to the user’s newest active MUA key, so the mailbox holds ciphertext only their client can open. It adds no signature (the decrypt stage’s verdict stays in X-Pepsi-Crypto), needs no privilege (the key is public), and leaves mail that arrived in cleartext alone. For a user without an MUA key, [stage-reencrypt] ON_NO_CLIENT_KEY chooses: plaintext (the default) files it as before; bounce refuses it with a DSN that returns neither body nor subject. The option is per recipient through pepsi-settings, so one user can insist on it while the site default stays lenient:

pepsi-settings set bob@example.org reencrypt ON_NO_CLIENT_KEY bounce

11.13. A CLI walk-through

Give an address an OpenPGP identity, look at it, and check that GnuPG agrees the public half is a key:

pepsi-keys identity generate alice@example.org \
    --protocol openpgp --name 'Alice Example'
pepsi-keys identity list --address alice@example.org
pepsi-keys identity show 1
pepsi-keys identity export 1 | gpg --import

Give the same address S/MIME material — a signing and an encryption certificate, each self-signed for immediate use, with a certificate request over each key to send to a CA:

pepsi-keys identity generate alice@example.org --protocol smime --csr

When the CA answers, store its certificate over the key that is already in the store and make it the primary signing material. Nothing is re-keyed and nothing already sent stops being readable:

pepsi-keys identity import alice@example.org \
    --protocol smime --purpose sign --public alice-sign.pem --primary

Publish the address in a signed zone:

pepsi-keys dns alice@example.org >> example.org.zone

Withdraw a signing key after a laptop is stolen. The private half is destroyed at once; the public half stays so last month’s signatures still verify:

pepsi-keys identity revoke 1 --reason 'laptop stolen'
pepsi-keys identity show 1        # private material: purged at …

Retire everything that has passed its validity — the same rule, applied in bulk, and the natural body of a periodic timer:

pepsi-keys identity retire

Trust a correspondent’s key by hand and pin it, so a later discovery answer cannot replace it:

pepsi-keys peer import bob@example.com --protocol openpgp --file bob.pgp --pin
pepsi-keys peer show bob@example.com

Decide which CAs an inbound S/MIME signature may chain to:

pepsi-keys ca add /etc/ssl/certs/our-ca.pem --label 'Our CA'
pepsi-keys ca list

And, once a year or after an incident, roll the key-encryption key:

pepsi-keys wrap rotate --dry-run
pepsi-keys wrap rotate

11.14. Configuration summary

The algorithm and security policy live in [pepsi] and are administrator-only: they are security decisions with secure defaults, not preferences, and because the per-address override layer only overrides [stage-<name>] sections they are structurally out of reach of pepsi-stage-edit-settings and any web-admin surface.

Those options are CRYPTO_ALLOW_DOWNGRADE, CRYPTO_ALLOW_WEAK_DIGESTS, CRYPTO_MIN_RSA_BITS, CRYPTO_GENERATE_RSA_BITS, CRYPTO_INLINE_PGP, CRYPTO_OPENPGP_ALGORITHM, CRYPTO_SMIME_ALGORITHM and CRYPTO_SMIME_SHARED_KEY.

The key store’s own custody and lifecycle settings live in [pepsi-crypto]: KEY_WRAP_SECRET and KEY_WRAP_KEY_ID (plus KEY_WRAP_SECRET_<ID> for a retired key), AUTO_CREATE_IDENTITY, IDENTITY_VALIDITY_DAYS (730 by default), and the locality options LOCAL_DOMAINS / RECIPIENT_DELIMITER / TARGETS that decide which account an address belongs to. A deployment that does no end-to-end cryptography can omit the section entirely; as soon as it says anything, pepsi-setup insists on a usable KEY_WRAP_SECRET, since a key store that cannot store keys is a configuration mistake rather than a preference. The exhaustive reference is pepsi.conf(5).

The pEp-style behaviour and the self-service are [stage-encrypt] options, per-address overridable: ENABLE_PEP (default yes), ATTACH_KEYS_AS_FILES (default no), KEYS_CONTROL_LOCAL_PART (default pepsi-keys) and RESPONSE_STAGE (unset means no notices and no e-mail commands).

Key-server publication has its own section, [pepsi-keys]: VKS_PUBLISH (off by default; whether keys made by hand start with their upload requested), VKS_SERVER (defaulting to the first [pepsi-keydiscovery] VKS_SERVERS entry, so a deployment that already chose a key server to read from does not name it twice), and the retry knobs VKS_MAX_ATTEMPTS, VKS_RETRY_INTERVAL and VKS_BATCH. Web Key Directory publication needs no configuration at all: it follows each identity’s published flag.

11.15. See also

The two stages that use this store are pepsi-stage-encrypt (outbound) and pepsi-stage-decrypt (inbound); The secure-link fallback portal is what happens when a correspondent has no key at all, and Client interoperability records which third-party clients have actually been measured against what those stages emit. pepsi-keydisc is the discovery service, pepsi-keys the operator CLI and pepsi-stage-vks-confirm the key-server confirmation follow-up. RFC Index maps each standard named here to the module that realises it, and Microsoft Exchange as a gateway is the deployment shape this store was built for.

pepsi-keys, pepsi-setup, Architecture, Configuration, pepsi-keys(1), pepsi.conf(5), pepsi-setup(1).