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 addressesThe 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 (
openpgporsmime), 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 addressesRemote correspondents’ public keys and certificates, cached however they were obtained. This is what encrypts outbound mail and verifies inbound signatures. Each row carries the
sourceit 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 anchorsThe 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/DELETEacross the schema, sequences, functions) plus thecrypto_identity.private_wrappedcolumn – the only role granted it, apart from the schema ownerpepsi-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) andpepsi-httpdhave their table-level grant oncrypto_identityrevoked and replaced with a column-level grant listing every column exceptprivate_wrapped;pepsi-ingressandpepsi-telemetryhave 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 ( |
An MUA key ( |
|---|---|---|
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 |
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
|
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 |
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 |
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-domaincan 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_TRUSTdecides 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 = alwayscounts 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 asnoreply@a domain we do not receive mail for gets no key.Cleartext is not signed. The default
SIGNbecomesencrypted-only: a signature lives inside the ciphertext, and cleartext carries the key but no signature, so recipients who do not use cryptography never see asignature.asc.[sign]orX-Pepsi-Sign: yesstill 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 theFrom:) carries a key the address does not have yet registers it; for the address’s very first key an attachedapplication/pgp-keysfile naming the address also counts. This happens only when the submitter may speak for theFrom:address: a trusted relay (MYNETWORKS, a client certificate), or an authenticated account whoseFrom:matched a concrete, wildcard-freeUSERNAME_MAPentry or its defaultlogin@HOSTNAME(state.origin.from_bound). An account granted*@example.orgmay send as its colleagues but never registers a key for them;pepsi-setuplists such accounts.By the user, through the web console or by e-mail (below).
By the operator, with
pepsi-keys identity importwithout--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-keysidentity show,identity generate,identity import(without--private),identity publish --vks,identity revoke, andotpfor 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 enqueuegenerate-identity/register-client-key/revoke-identitytasks thatpepsi-setup applyre-validates and performs as thepepsi-cryptorole – 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 aspepsi-keys identity revoke. Without thepepsi-httpd-adminpackage those three degrade to read-only like every other applier path. See The administrative API and The administration console.A message from the user to
pepsi-keys@<their domain>([stage-encrypt] KEYS_CONTROL_LOCAL_PART) with the command in theSubject:–status,generate,register(taking the key from this message’sAutocrypt: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 butstatusalso carries the current code (A second factor for key changes). It needsRESPONSE_STAGEto be set, and works only when the submitter may speak for theFrom:address (as above). It is handled in the encrypt stage itself, which already runs aspepsi-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 theSubject:, e.g.register 123456orretire 89ABCDEF01234567 otp=123456. A message’sAutocrypt:header or attached key file no longer registers a key for that address;registerwith a code does;in the web console or the API, generating, registering and revoking under
own:<address>take the code in anotpfield (the console’s forms have one), which the setup applier checks –pepsi-httpditself 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 ADDRESSCreates 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 CODERemoves 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 ( |
yes, immediately |
the identity’s |
Key server |
a third party |
no |
the identity’s |
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-mailgenerate– take[pepsi-keys] VKS_PUBLISHas 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}withvks_wanted, or the e-mailpublishcommand.
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:
put the new secret in
secrets.d/pepsi-crypto.secretasKEY_WRAP_SECRET, and keep the old one beside it asKEY_WRAP_SECRET_<OLD-ID>(the suffix is the oldwrap_key_id, upper-cased);point
[pepsi-crypto] KEY_WRAP_KEY_IDat the new id;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.rotaterow 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-statussummarises the last 30 days of them;with
RESPONSE_STAGEset on pepsi-stage-autocrypt-learn, each local recipient of the message that caused it is mailed a notice naming the correspondent and both fingerprints (thekey-rotation.<lang>.bodytemplate), so the person about to reply knows to check before sending anything confidential;an
autocrypt-rotationtelemetry 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
*@domainrow is consulted only when no exact row exists, andpepsi-keys peer showmarks 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¶
daneDNS
OPENPGPKEY(RFC 7929) andSMIMEA(RFC 8162), under a hashed local part. Refused without the resolver’s DNSSEC AD bit — see When there is no validating resolver.wkd-advancedThe Web Key Directory at
openpgpkey.<domain>— a host the domain had to delegate deliberately.wkd-directThe Web Key Directory at the domain itself,
<domain>/.well-known/openpgpkey.ldapA configured directory. Optional, and compiled in only with the
ldapcargo feature, because a deployment with no directory should not link one.vksA verifying key server (
keys.openpgp.orgby 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.inboundHarvested from a message that already arrived — an
application/pgp-keyspart, an S/MIME signer certificate, or anAutocrypt: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 inSOURCES. Written by pepsi-stage-autocrypt-learn, not by the decrypt stage.gossipRead 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 ownTo:,Cc:orReply-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. Likeinboundit is not a service and may not appear inSOURCES; 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/apiAn 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 |
|
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 |
1 |
|
An operator who typed a fingerprint in meant it. Its |
2 |
|
The only source with cryptographic provenance: the domain published it and DNSSEC proves the answer was not rewritten in transit. |
3 |
|
Fetched over verified HTTPS from |
4 |
|
The same, from the domain itself. A weaker signal of intent than a dedicated host, hence its own rank. |
5 |
|
A directory the operator configured, and therefore semi-trusted — but still a directory somebody else runs. |
6 |
|
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 |
|
Trust on first use, of what the address’s owner said about their own
address. Also the weight a |
8 |
|
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’sAutocrypt: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
activeidentities 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.revokedis 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 |
|---|---|
|
The first usable answer wins outright. Fastest; the ranking then only arbitrates conflicts in the store, never the race. |
|
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. |
|
Every method is waited for and the ranking applies in full. Slowest and
most thorough; |
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_DOMAINSThe operator’s, and the recipient-side one. A non-empty
ALLOW_DOMAINSis exclusive — only those domains are ever looked up — andDENY_DOMAINSalways wins over it. This is the control to reach for when the question is “never query that domain”.[stage-encrypt] DISCOVERY = noThe per-user one, overridable per address through the
pepsi.settingslayer. 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_TIMEOUTis separately tunable. An operator who feels this delay can drop it to a few seconds without touching the outboundTIMEOUTat all.pepsi-setupwarns 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).