70.1.36. pepsi-keydisc¶
find a correspondent’s public key or certificate
- Manual section:
1
70.1.36.1.1. Name¶
pepsi-keydisc - asynchronous key discovery, one service per method.
70.1.36.1.2. Synopsis¶
pepsi-keydisc [GLOBAL-OPTIONS] serve METHOD
pepsi-keydisc [GLOBAL-OPTIONS] probe ADDRESS
70.1.36.1.3. Description¶
pepsi-keydisc finds a correspondent’s OpenPGP key or S/MIME certificate,
records where it came from and how much that source is worth, and caches it
in pepsi.peer_key (see pepsi-keys(1)). Finding a key is easy; knowing
whether to believe it is the whole problem, so every result carries its source
and whether the lookup that produced it was DNSSEC-validated, and the consuming
stage’s policy decides what is good enough.
Discovery is asynchronous and lives outside the stages. A stage that needs a
key it does not have in cache never performs network I/O of its own: it commits
whatever work it may keep, pauses the message and enqueues a request in
pepsi.key_request; a pepsi-keydisc instance answers the request and, in
the same round-trip that stores the key, releases every message parked on that
address:
stage cache hit -> use the key
fresh negative -> the no-key path, at once
cache miss -> park: commit, pause, enqueue
pepsi-keydisc@... LISTEN key_request -> look up -> store the key AND
release every parked message
What the park commits is the stage’s decision, not this program’s. In particular pepsi-stage-decrypt(1) parks an S/MIME message with the decrypted plaintext committed — the nested signature survives as an ordinary MIME layer — but parks an OpenPGP one unchanged, because an OpenPGP signature lives only inside the packet stream and committing the plaintext would destroy the evidence the key is being fetched to check.
That removes “a slow key server holds a stage worker” entirely rather than bounding it. The request is keyed on the address, so a hundred messages to one correspondent cost one lookup and are released together. A request that settles without a key releases its waiters exactly like one that found a key: the services find keys, the stages decide about mail.
It is not a stage. It connects to the shared database through the
[pepsi-postgres] section and is configured entirely by
[pepsi-keydiscovery] (see pepsi.conf(5)).
70.1.36.1.4. One instance per method¶
There is one binary and one configuration section, but one process per
discovery method, started from the systemd template unit
pepsi-keydisc@.service:
pepsi-keydisc@daneDNS
OPENPGPKEY(RFC 7929) andSMIMEA(RFC 8162), under a hashed local part. Both are refused without the resolver’s DNSSEC AD bit.pepsi-keydisc@wkd-advancedThe Web Key Directory at
openpgpkey.<domain>, under/.well-known/openpgpkey/<domain>/hu/<hash>.pepsi-keydisc@wkd-directThe Web Key Directory at the domain itself, under
/.well-known/openpgpkey/hu/<hash>.pepsi-keydisc@vksThe
VKS_SERVERSverifying key servers, at/vks/v1/by-email/<address>.pepsi-keydisc@ldapThe configured LDAP directory; only in a build with the
ldapfeature.
Splitting them is the point: a hung LDAP bind cannot stall WKD, and turning a
method off is systemctl disable pepsi-keydisc@ldap rather than a
configuration edit and a restart of something shared. The two Web Key Directory
methods are separate ranks and therefore separate instances run
concurrently — never a fallback chain, since running direct only “if advanced
fails” would silently accept the weaker answer whenever the stronger one was
merely slow.
An instance whose method does not appear in [pepsi-keydiscovery]
SOURCES refuses to start, with a message naming what SOURCES does
list. That is not pedantry: the rank-grace stop rule waits only for the methods
SOURCES names, so an unlisted instance would be answering a request nobody
is waiting for.
Harvesting keys from inbound mail (application/pgp-keys parts, S/MIME
signer certificates, the Autocrypt: header) is not a discovery service
and has no instance. It runs inline where the message already is, costs no
network I/O, and writes through the same path — so a harvested key also
releases anything parked on that address. inbound is consequently rejected
in SOURCES.
The same goes for gossip, the keys pepsi-stage-decrypt(1) reads out of
the Autocrypt-Gossip: fields inside a message it has just decrypted
(Autocrypt Level 1 §5.3). It is a second inline rung rather than a variant of
inbound, because those keys are third parties’ rather than the sender’s own,
and the ladder has to be able to tell them apart.
70.1.36.1.5. Which key wins a conflict¶
When a discovered key differs from one already stored for the same address and protocol, the store decides in one server-side step, in this order:
a pinned row is never displaced, whatever arrives;
a strictly better-ranked source replaces everything stored — so WKD displaces a harvested key, an operator entry displaces WKD, and a gossiped key displaces nothing at all;
newest-wins inside the Autocrypt tier (below);
otherwise the stored key is kept and the new one is not stored, and the answer is reported as
rejected.
70.1.36.1.5.1. Newest-wins in the Autocrypt tier¶
Autocrypt Level 1’s peer-state update is youngest-wins: a client stores the
key from the youngest message it has seen carrying an Autocrypt: header.
Rule 2 alone cannot express that — it would refuse the second Autocrypt key
ever seen for an address and keep the first for ever, so a correspondent who
reinstalls their client, changes device or rotates their key would go on
receiving mail encrypted to a key they no longer hold, which is exactly the
unreadable-mail failure Autocrypt exists to avoid.
So rule 3 lets a newer key win, and it is confined so that it can only ever decide between claims of the same kind. It applies when all of:
the incoming source is
inboundorgossip; andevery key currently stored for that address and protocol is
inboundorgossiptoo — nothing an owner or an operator published is ever at stake; andthe incoming source ranks at least as high as the stored one, so
inboundreplacesinboundandgossipreplacesgossip, but a third party’s introduction can never take the row off what the address’s owner said about themselves; andthe incoming message’s effective date is strictly newer than the stored one.
The effective date is the message’s Date: header, clamped so that it can
never be in the future, and the time of receipt when the header is absent or
unparseable — Level 1’s own definition. Equal dates are therefore first-wins (a
redelivered message cannot flip the row), and a key that carries no effective
date at all — every network method, and every row stored before this rule
existed — never displaces anything.
What this hands an attacker is worth stating plainly, since it is the price of
the rule. Harvesting is keyed on the From: header, which the sender writes,
and inbound authentication is fail-open; before this rule a forged From:
could only fail to displace a key, and now it can displace one that was itself
only ever trust-on-first-use. That is Autocrypt’s own trade-off — it defends
against passive collection, not against an active attacker on the path — and it
is bounded on all four sides above: nothing published by the domain (WKD, DANE),
by a key server, by a directory or by the operator can be touched, and neither
can a pinned row. An operator who does not want it sets [pepsi-keydiscovery]
MIN_TRUST above the tier, or pins the keys that matter with
pepsi-keys peer pin (see pepsi-keys(1)); note that a key held for an
address in one of this deployment’s own served domains is pre-empted by the
identity anyway and never comes from the peer cache at all.
70.1.36.1.6. Commands¶
- serve METHOD
Run one method’s service instance until interrupted. METHOD is one of
dane,wkd-advanced,wkd-direct,vksorldap— the systemd template’s instance name — and must appear in[pepsi-keydiscovery] SOURCES.The instance
LISTENs on thekey_requestchannel, whose payload is the request id, answers each request this method still owes, and sweeps once on start-up and on every listener reconnect so nothing raised while it was away is lost. Between notifications it ticks a short settle sweep: a rank-grace window is a point in time, so something has to look at the clock, and a deployment running any instance at all therefore has the sweep. A single failing lookup is recorded on the request (which is what selects the shortERROR_TTLover the longNEGATIVE_TTL) and never tears the listener down.- probe ADDRESS
Run the whole fan-out for one address once and print what each method found — the protocol, the fingerprint, the size, and
dnssecfor an AD-validated DANE answer — or the error it hit, or that it found nothing.Deliberately read-only: it stores nothing, neither the key nor a negative-cache entry. An operator diagnosing “why does this correspondent have no key” wants to see each method’s answer, not to have a diagnostic write a negative entry that then suppresses the real lookup for a day. To look an address up and store the result, use
pepsi-keys peer refresh --address(see pepsi-keys(1)).An address excluded by
ALLOW_DOMAINS/DENY_DOMAINSis reported as such and not queried.
70.1.36.1.7. Privileges¶
The services run as their own unprivileged account, pepsi-keydisc — not the
pepsi service user and not pepsi-crypto. This daemon parses key material
fetched from the open internet (a hostile WKD body, a key-server answer, a DNS
record), which makes it the likeliest process here to be broken into, so
pepsi-setup(1) gives it the narrowest database role in the deployment:
SELECT/INSERT/UPDATE/DELETEonpepsi.peer_key— the cache filling it is its whole job, and the conflict rule has to be able to replace a row;SELECT/INSERT/UPDATEonpepsi.key_request— the work queue it answers;EXECUTEon thekeydisc_*functions and the twocrypto_*helpers they call;on
pepsi.ingress,SELECTplus a column-levelUPDATE (status, timeout)— and nothing else.
Those two columns are exactly enough to move a parked row from paused back
to pending, which happens inside keydisc_resolve/keydisc_sweep. With
a table-level grant this account could rewrite a message’s recipients or its
body; with these two columns it can only wake a message up. It has no reach into
pepsi.crypto_identity at all, and therefore none into any private key.
The program carries no setuid or setgid bit and is folded into the
multi-call pepsi binary. Started as root it becomes the
pepsi-keydisc account before connecting (the configuration, and any
@inline-secret@ fragment, is read first, as the invoking user), so it can be
launched from a root shell for testing without sudo -u.
70.1.36.1.8. LDAP is a compile-time feature¶
The LDAP client is behind the ldap cargo feature and is off by default:
it adds a dependency, and a deployment with no directory should not link one.
A build without the feature refuses to start pepsi-keydisc@ldap with a
message saying so, rather than silently answering “no key” for ever, and
pepsi-setup(1) rejects a configuration that lists ldap in SOURCES
on such a build — as it does one that lists ldap without LDAP_URL and
LDAP_BASE.
70.1.36.1.9. Global Options¶
These global options precede the subcommand (a trailing flag is rejected).
- -c FILE, –config FILE
Read the configuration from FILE instead of searching the default locations.
- -L LOGLEVEL, –log LOGLEVEL
Set the logging verbosity. LOGLEVEL is one of
error,warn,info,debugortrace(default:info).- -v, –verbose
Show log messages from all sources, including third-party libraries.
- -h, –help
Print a usage summary and exit.
- -V, –version
Print the version and exit.
70.1.36.1.10. Signals¶
- SIGINT, SIGTERM
Stop taking new requests and exit. Nothing is lost: an unanswered request stays
pendinginpepsi.key_requestand is picked up by the next instance’s start-up sweep, or settles on its own deadline.
70.1.36.1.11. Exit Status¶
- 0
Clean shutdown, or a completed probe.
- 1
An error occurred: a malformed configuration, a method that
[pepsi-keydiscovery] SOURCESdoes not list,ldapin a build without the feature, or a failed database connection. Individual lookup failures are not errors — they are recorded on the request and the service keeps running.
70.1.36.1.12. Examples¶
Enable the default set of methods (dane wkd-advanced wkd-direct vks):
systemctl enable --now pepsi-keydisc@dane pepsi-keydisc@wkd-advanced \
pepsi-keydisc@wkd-direct pepsi-keydisc@vks
Turn one method off without touching the configuration of the others:
systemctl disable --now pepsi-keydisc@vks
(then drop vks from [pepsi-keydiscovery] SOURCES, so the stop rule stops
waiting for an answer that will never come).
Find out what each method says about one address, changing nothing:
pepsi-keydisc -c /etc/pepsi/pepsi.conf probe bob@example.com
Run one instance in the foreground, with full logging, to see why an address yields no key:
pepsi-keydisc -c /etc/pepsi/pepsi.conf -L debug serve wkd-advanced
70.1.36.1.13. See Also¶
pepsi-keys(1), pepsi-setup(1), pepsi.conf(5), pepsi.state(7), pepsi-dispatch(1), pepsi-config(1)
70.1.36.1.14. Bugs¶
Report bugs to the Pepsi issue tracker.