85.1.44. pepsi-keys

manage the end-to-end key store: identities, peer keys and anchors

Manual section:

1

85.1.44.1.1. Name

pepsi-keys - generate, import, publish and retire end-to-end key material.

85.1.44.1.2. Synopsis

pepsi-keys [GLOBAL-OPTIONS] identity list [–address ADDRESS] [–json]

pepsi-keys [GLOBAL-OPTIONS] identity show IDENTITY-ID [–json]

pepsi-keys [GLOBAL-OPTIONS] identity generate ADDRESS –protocol PROTOCOL [–name NAME] [–days N] [–no-publish] [–csr] [–shared | –split]

pepsi-keys [GLOBAL-OPTIONS] identity import ADDRESS –protocol PROTOCOL –purpose PURPOSE –public FILE [–private FILE] [–algorithm NAME] [–primary] [–no-publish]

pepsi-keys [GLOBAL-OPTIONS] identity export IDENTITY-ID [–private] [–pem]

pepsi-keys [GLOBAL-OPTIONS] identity csr IDENTITY-ID

pepsi-keys [GLOBAL-OPTIONS] identity revoke IDENTITY-ID [–reason TEXT] [–upload]

pepsi-keys [GLOBAL-OPTIONS] identity forget-private IDENTITY-ID [-y]

pepsi-keys [GLOBAL-OPTIONS] identity set-primary IDENTITY-ID

pepsi-keys [GLOBAL-OPTIONS] identity publish [IDENTITY-ID] [–wkd] [–vks] [–retry [–dry-run]] [-y]

pepsi-keys [GLOBAL-OPTIONS] identity unpublish IDENTITY-ID

pepsi-keys [GLOBAL-OPTIONS] identity delete IDENTITY-ID [-y]

pepsi-keys [GLOBAL-OPTIONS] identity retire

pepsi-keys [GLOBAL-OPTIONS] peer list [–address ADDRESS] [–json]

pepsi-keys [GLOBAL-OPTIONS] peer show ADDRESS [–protocol PROTOCOL] [–json]

pepsi-keys [GLOBAL-OPTIONS] peer unclaimed [–address ADDRESS] [–all-sources] [–json]

pepsi-keys [GLOBAL-OPTIONS] peer import ADDRESS –protocol PROTOCOL –file FILE [–pin]

pepsi-keys [GLOBAL-OPTIONS] peer pin | unpin | remove PEER-KEY-ID

pepsi-keys [GLOBAL-OPTIONS] peer refresh [–address ADDRESS | –expire PEER-KEY-ID] [–expiring-within-days DAYS] [–limit N]

pepsi-keys [GLOBAL-OPTIONS] peer prune [–retain-days DAYS] [–dry-run]

pepsi-keys [GLOBAL-OPTIONS] ca list [–json]

pepsi-keys [GLOBAL-OPTIONS] ca add FILE [–label TEXT]

pepsi-keys [GLOBAL-OPTIONS] ca enable | disable | remove CA-ID

pepsi-keys [GLOBAL-OPTIONS] identity –otp CODE COMMAND …

pepsi-keys [GLOBAL-OPTIONS] otp status [ADDRESS] [–json]

pepsi-keys [GLOBAL-OPTIONS] otp enroll | remove ADDRESS [–otp CODE]

pepsi-keys [GLOBAL-OPTIONS] otp reset ADDRESS

pepsi-keys [GLOBAL-OPTIONS] wrap rotate [–dry-run]

pepsi-keys [GLOBAL-OPTIONS] dns ADDRESS

85.1.44.1.3. Description

pepsi-keys is the operator’s tool for Pepsi’s end-to-end cryptography key store — the three tables that hold every key OpenPGP and S/MIME processing needs:

pepsi.crypto_identity

The key pairs of the addresses we serve, private half included and wrapped at rest (custody = local, an MTA key). Used to sign outbound mail and to decrypt inbound mail. It also holds the users’ own keys, whose private half lives only in their mail client (custody = client, an MUA key; see MTA keys and MUA keys).

pepsi.peer_key

Remote correspondents’ public keys and certificates, however they were obtained, each with the source it came from, a validity verdict and a cache deadline. Used to encrypt outbound mail and to verify inbound signatures.

pepsi.ca_trust

The CA certificates an inbound S/MIME chain must reach for its signature to count as trusted.

The tool is not a stage. It manages rows directly, in the shape of pepsi-whitelist(1) and pepsi-settings(1), connecting to the same database as the other components through the shared [pepsi-postgres] section and reading the [pepsi] CRYPTO_* options and the [pepsi-crypto] section described in pepsi.conf(5).

It is a standalone binary rather than part of the unified pepsi executable for two reasons: it is the only program in the tree that generates key material, and a site may install it setuid (see Privileges).

85.1.44.1.3.1. Capability, not shape

An address’s material is stored as one row per capability, never as “the identity”. Each row’s purpose is sign, encrypt or both:

  • an OpenPGP identity is one both row — a transferable key whose sign-capable primary carries an encryption subkey, so the split is intrinsic and costs nothing;

  • an S/MIME identity is normally two rows, one certificate with digitalSignature and one with keyEncipherment/keyAgreement;

  • with [pepsi] CRYPTO_SMIME_SHARED_KEY = yes it is instead one both certificate carrying both key usages (RSA only).

That option decides only what newly created identities look like. Both shapes are supported permanently and may coexist for one address — an old shared certificate still valid alongside a freshly issued split pair — so every lookup matches a capability (sign or both; encrypt or both) rather than counting rows. Listings and identity show print the purpose for exactly this reason.

85.1.44.1.3.2. Retirement is asymmetric

Pepsi delivers plaintext to the mailbox, so a private decryption key is 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 at all: nothing ever re-signs old mail. So at expiry or revocation:

  • a sign row’s private half is destroyed, and only the public half is kept so old signatures can still be verified;

  • an encrypt row’s private half is retained;

  • a both row follows the decryption rule — destroying it would take the ability to decrypt with it — which is the price of shared-certificate mode: revoking a compromised signing key there also ends the ability to receive new encrypted mail.

private_purged_at records when material was dropped, so “deliberately destroyed” stays distinguishable from “never held”; both identity list and identity show report it.

85.1.44.1.3.3. MTA keys and MUA keys

An address may hold keys of two kinds. An MTA key (custody = local) is one whose private half Pepsi holds: pepsi-stage-encrypt(1) signs with it, pepsi-stage-decrypt(1) opens mail with it, and it is advertised in Autocrypt: headers. It is created by identity generate, by an import with –private, or automatically on a user’s first submission under [stage-encrypt] ENABLE_PEP. An MUA key (custody = client) is the user’s own key from their mail client: public-only for ever, never primary. Pepsi encrypts to it and passes mail encrypted to it through unopened, but can never sign or decrypt with it. It is created by an import without –private, by the web console or API, by the e-mail register command, or automatically from the Autocrypt: header of the user’s own submitted mail.

The public face of an address – what the Web Key Directory serves, what local mail to the user is encrypted to – is the newest active MUA key when there is one, else the primary active MTA key. The MTA key is not retired when an MUA key appears; it keeps decrypting mail from correspondents who still use it. identity show prints the custody and whether the identity is the public face.

85.1.44.1.3.4. Key-server uploads are requested per identity

Each identity records whether its upload to the key server was asked for (crypto_identity.vks_wanted). Automatically generated keys and automatically registered MUA keys start with it off: Autocrypt and the deployment’s own Web Key Directory are how they are found, and an upload cannot be withdrawn. Keys made by hand (identity generate, identity import, and the web and e-mail generate) take [pepsi-keys] VKS_PUBLISH as their default, and –no-publish keeps it off. An explicit request – identity publish –vks, the web console’s Upload to the key server, the e-mail publish command – sets it whatever VKS_PUBLISH says. identity publish –retry uploads exactly the rows where it is set, so “not uploaded” is recorded state, and identity show reports it.

85.1.44.1.3.5. Ownership

An identity is keyed on the lower-cased address. When pepsi-common’s locality rules ([pepsi-crypto] LOCAL_DOMAINS, RECIPIENT_DELIMITER and TARGETS, defaulting to [pepsi-ingress] ACCEPTED_DOMAINS and the /etc/login.defs uid range) resolve that address to a local account, the passwd login is recorded in crypto_identity.login; otherwise the column is NULL — a role address, a hosted domain, an Exchange-fronted deployment.

That column is the access rule. An unprivileged caller may see and change only identities whose login is their own, resolved from the real uid’s passwd entry — not $USER, not an argument, not the effective uid. An identity with no owning account belongs to nobody in particular and is therefore operator-only: treating “unowned” as “everybody’s” would make every role address writable by every local user.

root, and the pepsi-crypto, pepsi and pepsi-owner accounts, may manage every identity. Peer keys and trust anchors have no per-user namespace — they are somebody else’s key, or deployment-wide policy — so every peer and ca subcommand, list included, is operator-only, as are wrap rotate and identity retire.

85.1.44.1.3.6. Privileges

Reaching a private key takes two things, and they are guarded separately:

  • the database role pepsi-crypto, the only one granted the crypto_identity.private_wrapped column (apart from the schema owner pepsi-owner, which reads it by ownership but cannot read the key-encryption key). Every ordinary service role — including the pepsi account every ordinary stage worker runs as — holds a column-level grant that omits it, so a compromise elsewhere in the pipeline yields the public half of every identity and nothing more (see pepsi-setup(1));

  • the key-encryption key, which lives only in the filesystem, in secrets.d/pepsi-crypto.secret.

The database alone therefore yields nothing but ciphertext, and the key-encryption key alone yields nothing at all.

pepsi-keys assumes the pepsi-crypto identity only for the duration of each database call. Run as root it becomes that account for the call (root has no database role of its own) and returns afterwards; run as pepsi-crypto or pepsi-owner it simply connects as itself; run from a setuid install it raises its effective ids to the binary’s owner. PostgreSQL peer authentication keys off the effective uid, not the gid, so a setgid bit would grant the group that guards the secret fragment but not the database identity — a program that must be this role has to be this account.

As shipped the binary carries no setuid bit (mode 0755): one binary able to open every private key in a deployment is a far larger prize than pepsi-whitelist(1)’s single table, so the default is that only the operator runs it. A site that wants ordinary users to manage their own identities makes it setuid pepsi-crypto.

Which authority is in force differs between the two installs. The per-identity ownership rule above applies only when the binary actually carries the setuid bit: a caller is treated as privileged when it is root, when its login is one of the privileged accounts, or when the binary is not setuid at all. On the shipped 0755 install every caller is therefore privileged as far as this program is concerned, and only PostgreSQL stands between a user and another user’s identities — the caller authenticates as themselves and the database grants decide. That is the intended design, but it means granting somebody the pepsi-crypto database role hands them every identity in the deployment, whatever this program’s ownership rule would otherwise have said. When it is setuid, the same three precautions pepsi-whitelist(1) takes apply: the effective ids are dropped to the invoking user before anything else happens, the environment variables that steer configuration discovery (HOME, XDG_CONFIG_HOME), ${DATADIR} expansion (PATH) and libpq (PG*) are removed, and -c/–config is refused for unprivileged callers — the configuration names the database and the key-encryption key.

85.1.44.1.4. Commands

85.1.44.1.4.1. Identity commands

identity list [–address ADDRESS] [–json]

List identities as a table: id, address, protocol, purpose, status, whether the row is the primary one for its capability, whether it is published, the state of its private half (yes/no/purged, or client for the user’s own MUA key, whose private half Pepsi never held) and its expiry. An unprivileged caller sees only their own. –address restricts the listing to one address; –json emits the rows as JSON instead.

identity show IDENTITY-ID [–json]

Print one identity in full, including its fingerprint, algorithm, custody (local for an MTA key, client for the user’s own MUA key), whether it is the address’s public face (the key correspondents are given), wrap_key_id, timestamps, any revocation reason, the Web Key Directory local-part hash the address is served under, and its key-server state: one of verified at TIME, uploaded at TIME , awaiting confirmation, upload requested, or not uploaded (not requested).

identity generate ADDRESS –protocol openpgp|smime [OPTIONS]

Create key material for ADDRESS and store it, private half wrapped. The new material becomes the primary for its capability; nothing existing is rewritten or replaced, so the previous material stays usable for decrypting what was already sent to it.

OpenPGP produces one both row using [pepsi] CRYPTO_OPENPGP_ALGORITHM (ed25519, the default, or rsa at CRYPTO_GENERATE_RSA_BITS, which must be 2048, 3072 or 4096). S/MIME produces the signing/encryption pair using CRYPTO_SMIME_ALGORITHM (rsa, p256 or p384), each with a self-signed certificate for immediate use.

–name NAME

Display name for the OpenPGP User ID (Name <address>) or the certificate subject. Without it the User ID is the bare address.

–days N

Lifetime of the generated material. Defaults to [pepsi-crypto] IDENTITY_VALIDITY_DAYS (730, two years).

–no-publish

Do not serve the public half over the Web Key Directory or upload it. By default a new identity is published — a key nobody can fetch cannot be encrypted to — and its key-server upload is requested when [pepsi-keys] VKS_PUBLISH is on.

Generation on request is always allowed, even for an address that already has the user’s own MUA key or a revoked key; only automatic creation refuses an address with any identity.

–csr

Also print a PKCS#10 certificate signing request over each generated key, so a real CA can issue a replacement certificate for the same key without re-keying. S/MIME only; OpenPGP has no such object.

–shared

Force one S/MIME certificate carrying both key usages, whatever CRYPTO_SMIME_SHARED_KEY says. Refused with an elliptic-curve CRYPTO_SMIME_ALGORITHM: one EC key doing both ECDSA and ECDH is cross-algorithm key reuse that several S/MIME clients reject.

–split

Force the separate signing and encryption certificates. Mutually exclusive with –shared; with neither, the configured default applies.

identity import ADDRESS –protocol P –purpose PURPOSE –public FILE [OPTIONS]

Store material generated elsewhere. PURPOSE is sign, encrypt or both and must describe what the material can actually do — it is what every later lookup matches on. The public half is an OpenPGP transferable key or an X.509 certificate (DER or PEM, detected); - reads standard input. The fingerprint is derived from the material itself: the OpenPGP fingerprint, or the SHA-256 of the certificate DER.

–private FILE

The private half — an OpenPGP transferable secret key, or a PKCS#8 key — which is wrapped before it is stored, as an MTA key (custody = local). Without it only the public half is kept. For OpenPGP the row is then recorded as the user’s own MUA key (custody = client), which is never primary (–primary is ignored for it): Pepsi can publish and encrypt to it but never sign or decrypt with it. It pre-empts automatic key creation for the address and becomes the address’s public face; mail encrypted to it is passed to the mail client unopened. A public-only S/MIME import keeps custody = local – typically a CA-issued certificate over a key already in the store (see identity csr).

–algorithm NAME

Algorithm name to record. Informational; defaults to imported.

–primary

Make this the material used for new messages of its capability.

–no-publish

Do not publish the public half. Otherwise the key-server upload is requested when [pepsi-keys] VKS_PUBLISH is on.

identity export IDENTITY-ID [–private] [–pem]

Write the material to standard output in its own binary form — OpenPGP packets or X.509 DER — so it can be piped straight into another tool.

–private

Export the private half instead of the public one, unwrapping it first. Fails when the row holds none, naming which of “never held” and “purged” applies.

–pem

Wrap X.509 material in a PEM block. OpenPGP material has no PEM form and is refused with a pointer to gpg --enarmor.

identity csr IDENTITY-ID

Print a PKCS#10 certificate signing request over an identity’s existing private key, with the key usage its purpose implies. This is the path that matters once an identity is in service: the self-signed certificate gets the organisation going, and the same key — already published, already used to receive encrypted mail — is later presented to a real CA without re-keying. S/MIME identities only, and only while the private half is still held.

identity revoke IDENTITY-ID [–reason TEXT] [–upload]

Withdraw an identity. The row becomes revoked and stops being primary; the public half is kept so signatures made before the revocation can still be checked. A sign row’s private half is destroyed here and now; an encrypt or both row keeps its private half, and the command says so. TEXT is recorded with the row.

–upload additionally builds a revocation certificate — signed by the key it retires, with TEXT as its reason — and publishes it to the configured key server. That is the only remedy for a key already uploaded: a key server can be told a key is revoked, never told to forget it.

The upload happens before the local revocation, because revoking a sign identity destroys the private key the revocation certificate is signed with. If the upload fails, nothing is revoked, so the key-server problem can be fixed and the command re-run. Revoking without –upload and then wanting a published revocation is not recoverable.

identity forget-private IDENTITY-ID [-y, –yes]

Destroy an identity’s private key permanently, leaving the public half. This is the only way a decryption key leaves the store, and it is irreversible: anything still queued in pepsi.workqueue, anything in an encrypted archive and any message redelivered later from a backup becomes unreadable. Mail already delivered is unaffected. The command prints exactly that and asks for confirmation; –yes skips the question, and with no terminal and no –yes the answer is no, so an unattended run cannot destroy key material because nobody was there to object.

identity set-primary IDENTITY-ID

Make this the material used for new messages of its (address, protocol, purpose). Only an active identity may be made primary. The previous primary for the same triple is demoted.

identity publish IDENTITY-ID [–wkd] [–vks] [-y, –yes]

Serve the identity’s public half over the Web Key Directory. This is the reversible channel — our own server, our own domain — and it is what –wkd names; it is also the default, so the flag exists only to make the channel explicit. The material stays usable either way.

–vks additionally records the upload request (vks_wanted), uploads the key to the configured key server and asks it to mail the confirmation link to the address. Because the request is recorded before the attempt, a failed upload is retried by identity publish –retry rather than forgotten. That is irreversible: a key server can later be told the key is revoked, but never told to forget it, and the address becomes a permanent public record. The command prints exactly what cannot be taken back and asks for confirmation; –yes skips the question, and with no terminal and no –yes the answer is no. OpenPGP only — there is no key server for S/MIME, whose publication channel is the SMIMEA record dns prints.

identity publish –retry [–dry-run]

Work through every published, active OpenPGP identity whose upload was requested (vks_wanted) and that is not verified on the key server yet, instead of one. This is the form to run from cron (hourly is ample). It is not gated by [pepsi-keys] VKS_PUBLISH: that option only decides whether keys made by hand start out requested, and a user’s own request is honoured whatever it says. An automatically created key is never on the list unless its owner asked.

It exists because publication is a two-step protocol: an upload returns a token, only the token authorises the confirmation mail, and only a followed link makes the server serve the key by address. A key uploaded but never confirmed is stored yet unfindable. Each round first asks the key server whether the address is already served — so a confirmation followed by pepsi-stage-vks-confirm(1), by a human, or on an earlier run whose database write did not land all end the loop — and otherwise uploads and requests verification. Identities are spaced by VKS_RETRY_INTERVAL, at most VKS_BATCH per run, and abandoned after VKS_MAX_ATTEMPTS with the reason left on the row for identity show. –dry-run lists what would be published and changes nothing. Operator-only.

identity unpublish IDENTITY-ID

Stop serving the identity over the Web Key Directory, and forget its key-server bookkeeping so the retry job leaves it alone and a later re-publication starts from a clean slate.

It does not remove anything from a key server, because nothing can. When the identity had been uploaded the command says so and points at identity revoke –upload, which is the only way to tell the world the key is dead.

identity delete IDENTITY-ID [-y, –yes]

Remove the row outright, public half included. Confirmed like forget-private. Prefer revoke, which keeps the public half so old signatures remain checkable; a deleted identity leaves nothing behind at all.

identity retire

Run the retirement sweep over every identity: everything past its expires_at becomes expired, private signing material is destroyed and stamped, and decryption material (including a shared both row’s) is left alone. Intended for a periodic timer as much as for the command line; identity revoke applies the same rule inline for one identity. Operator-only.

85.1.44.1.4.2. Peer commands

peer list [–address ADDRESS] [–json]

List cached peer keys: id, address, protocol, source, validity, whether the lookup that produced it was DNSSEC-validated, whether it is pinned, and the fingerprint.

peer show ADDRESS [–protocol PROTOCOL] [–json]

Show the keys that would actually be used for ADDRESS, after the matching rule: an exact address match always wins, and a *@domain row is consulted only when there is no exact one (it is flagged as a domain-wide fallback in the output). PROTOCOL defaults to openpgp.

peer unclaimed [–address ADDRESS] [–all-sources] [–json]

List the addresses this deployment serves that hold no identity of ours, yet have a harvested or gossiped peer key — the keys a stranger can plant by sending us a message with a forged From:. Keys are harvested on the From: header, which the sender writes, and inbound authentication is fail-open, so on a deployment that does not enforce DMARC on its own domain such a row can name one of our own users. Where we hold an identity the row is inert (our own key pre-empts it); where we do not, it decides what mail to that user is encrypted with. That is deliberate — it is also the only way a user who runs GnuPG in their own client gets their real key here — so this is a worklist, not an alarm: confirm each key with its owner, then import it as an identity (identity import without –private), after which it is inert, or remove it (peer remove).

Served means the domain is one of [pepsi-ingress] ACCEPTED_DOMAINS or [pepsi-crypto] LOCAL_DOMAINS; no passwd account is required. No identity means no active identity able to encrypt, the same rule the encryption stage’s pre-emption applies, so an address whose only identity expired, was revoked or can only sign is listed; the IDS column counts its identities in any state. Harvested is source inbound (the sender’s own Autocrypt: header or attached key) or gossip (a third party’s Autocrypt-Gossip:); –all-sources includes discovered, manual and API keys as well, which is the set GET /api/v1/peers/unclaimed reports. A *@domain row is never listed. Read-only and operator-only. The exit status is 0 whether or not anything was found; with –json an empty report is [], which is what a periodic check should test for.

peer import ADDRESS –protocol P –file FILE [–pin]

Store a correspondent’s key by hand (- reads standard input). The row is recorded with source manual, which outranks every discovery mechanism, and is the only source permitted to carry a *@domain address — a database constraint, not a convention, so no discovery answer for one address can ever be generalised to a whole domain.

A key that conflicts with one already stored replaces it only if it outranks everything there; a pinned row is never displaced, and the command says which of stored, refreshed, replaced and kept happened. (The single exception to “only if it outranks” is the Autocrypt tier’s newest-wins rule, which never involves a manual key in either direction — see pepsi-keydisc(1).)

–pin

Pin the imported key, so discovery never replaces it.

peer pin PEER-KEY-ID, peer unpin PEER-KEY-ID

Set or clear the pin on one stored key.

peer refresh [–address ADDRESS] [–expire PEER-KEY-ID] [–expiring-within-days DAYS] [–limit N]

Re-validate cached peer keys. It only ever refreshes keys that already exist: it never discovers a key for an address that has none. Pepsi looks an address up when it has mail for it, which is why discovery is driven by the pipeline (pepsi-keydisc(1)) rather than by this command.

Three shapes, selected by the flags:

–address ADDRESS

Run the whole discovery fan-out for one address in this process — every method in [pepsi-keydiscovery] SOURCES at once, with the rank-grace stop rule — and store what it finds with the right source and DNSSEC flag. One line per method is printed: the protocol and fingerprint (marked (dnssec) for an AD-validated DANE answer), no key, or the error. This also settles the address’s discovery request, so it releases any message parked on it. An address excluded by ALLOW_DOMAINS/DENY_DOMAINS is refused rather than queried.

–expire PEER-KEY-ID

Clear one row’s cache deadline so the next message for that address re-fetches. No network I/O at all, and mutually exclusive with –address.

(neither flag)

Sweep: every key that is due — its refresh_after has passed, or its own expiry falls within –expiring-within-days (default 7) — is put through the same fan-out, up to –limit keys (default 100). Pinned and hand-entered (manual/api) keys are skipped, and so are *@domain rows: an operator put those there, and no discovery answer outranks that, so fetching would be pure cost. The number re-validated is printed. This is the natural body of a periodic timer.

peer prune [–retain-days DAYS] [–dry-run]

Delete expired peer keys and aged-out discovery requests — two tables, one job. A peer key that expired more than DAYS (default 7) ago is removed unless it is pinned. The expiry is the one the key itself states — an OpenPGP primary key’s current self-signature, an X.509 notAfter — recorded whenever a key is learnt, whether by discovery, harvesting or gossip (a key imported by hand records none, and a key that states none is never pruned); on the pepsi.key_request side both a settled row whose negative entry has aged out past the same window and a pending row abandoned that long past its deadline have nothing left to say and go too. –dry-run reports how many keys carry an expiry and are unpinned, and removes nothing.

peer remove PEER-KEY-ID

Delete one stored key. This is also how an operator lets through a correspondent’s newer Autocrypt key that ACCEPT_ROTATION = expired held (a key.peer.rotate.held audit row; see pepsi-stage-autocrypt-learn(1)): with the old row gone, the next message carrying the new key stores it as a first key.

85.1.44.1.4.3. Trust-anchor commands

ca list [–json]

List the trust anchors with their id, whether they are enabled, their label and their subject.

ca add FILE [–label TEXT]

Add a CA certificate (DER or PEM; - reads standard input) as a trust anchor. A certificate without basicConstraints CA:TRUE is refused: no chain could ever end at it. Idempotent on the certificate’s SHA-256 — adding the same anchor twice only updates the label.

An anchor is accepted here on its basicConstraints alone, but path building applies its constraints like any other CA’s (RFC 5937): a name-constrained anchor vouches only for names inside its nameConstraints, a CA that said pathlen:0 will not have a sub-CA accepted below it, and a requireExplicitPolicy or inhibitAnyPolicy it carries is honoured. A chain that breaks one fails with policy-rejected. So does one through a CA carrying a critical extension Pepsi cannot process at all (RFC 5280 §4.2) — such a CA is never used as an issuer.

ca enable CA-ID, ca disable CA-ID

Hand an anchor to the verifier, or stop doing so while keeping the row for the record.

ca remove CA-ID

Delete an anchor.

Pepsi ships no default anchor set, deliberately. 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” — and an operator who adds an anchor is making a decision rather than inheriting one.

85.1.44.1.4.4. Second-factor commands

An address may have a second factor: a TOTP secret (RFC 6238, SHA-1, six digits, 30-second steps) its owner keeps in an authenticator app. Once it exists, an unprivileged caller (a setuid install) must present a current code for every change to that address’s identities – identity generate, import, revoke, forget-private, set-primary, publish, unpublish, delete – and for identity export --private and identity csr, as the option –otp CODE of the identity group (pepsi-keys identity --otp 123456 revoke 7). Ownership is checked before the code, so a user cannot count failures against another user’s enrolment. The operator (root, pepsi-crypto, a non-setuid install) is never asked. The same second factor guards the e-mail key commands and the web console; see pepsi-stage-encrypt(1) and the key-management chapter of the manual.

A code is accepted once (a replay within its own 30 seconds is refused), one step of clock skew either way is tolerated, a wrong or replayed code counts as a failure and a right one resets the count, and ten failures in a row lock the second factor until the operator resets it.

otp status [ADDRESS] [–json]

Show an address’s enrolment: when it was enrolled and last used, and the consecutive failures (or LOCKED). The operator may omit ADDRESS to list every enrolment. Never shows a secret.

otp enroll ADDRESS [–otp CODE]

Create a fresh secret for ADDRESS and print it once, as base32 and as an otpauth:// URI (qrencode -t ansiutf8 turns that into a QR code to scan). Replaces an existing enrolment, starting again at zero failures; the owner needs a current code of the old one for that, the operator does not – which is how the operator hands a locked-out user a new secret.

otp remove ADDRESS [–otp CODE]

Remove the second factor; the owner needs a current code.

otp reset ADDRESS

Operator only: remove the second factor whatever its state, unlocking the address. Its owner enrols again (by e-mail, or with otp enroll).

The secret is stored wrapped under the key-encryption key, like a private key, in a table only the pepsi-crypto role may read; wrap rotate re-wraps it too.

85.1.44.1.4.5. Key-encryption-key commands

wrap rotate [–dry-run]

Re-wrap every stored private key, and every second-factor secret, under the current [pepsi-crypto] KEY_WRAP_KEY_ID. Rotation is incremental by construction, because each row records the key that sealed it: configure the new secret as KEY_WRAP_SECRET and keep the old one beside it as KEY_WRAP_SECRET_<OLD-ID>, point KEY_WRAP_KEY_ID at the new id, and run this. Between the configuration change and the sweep the deployment keeps working — new material is sealed under the new key, old material still opens under the retired one — and only afterwards may the retired secret be removed.

Rows whose key-encryption key has no configured secret are reported and left untouched rather than failing the run; so are rows re-wrapped concurrently (each update is guarded on the old key id, so a newer wrap is never written over).

–dry-run

List what would be re-wrapped, and change nothing.

85.1.44.1.4.6. DNS publication

dns ADDRESS

Print, in zone-file format, the DNS records that publish every published, active identity of ADDRESS: an OPENPGPKEY record (RFC 7929) for OpenPGP material and an SMIMEA record (RFC 8162, in the TLSA 3 0 0 — domain-issued certificate, full certificate, exact match — form) for S/MIME. Both are published under a hashed owner name, the hex of the first 28 octets of the SHA-256 of the local part, so the zone does not enumerate the addresses it serves.

Publish these only in a DNSSEC-signed zone, which the output repeats as a comment: an unsigned key record is a key from whoever can answer for the zone, which is no improvement on having no key at all.

The DNS owner-name hash is case-sensitive (RFC 7929 §3), unlike the Web Key Directory hash of the same local part, which lower-cases. The two are computed by different functions on purpose.

pepsi-setup(1) prints the same records for every published identity as part of its DNS output, capped at a readable number; this command is how to get one specific address in a large deployment.

85.1.44.1.5. Key-server configuration

Web Key Directory publication needs no configuration: it follows each identity’s published flag and is served by pepsi-httpd(1). Key-server publication does, because it cannot be undone, and its options live in [pepsi-keys]:

VKS_PUBLISH (default no)

Whether identities created or imported by hand start with their key-server upload requested, so that identity publish –retry uploads them and keeps retrying until the address is verified. An upload can be revoked but never withdrawn, so the default stays an operator’s decision. It does not gate the retry job, and it never applies to automatically created or registered keys; see Key-server uploads are requested per identity. With it off, identity publish –vks (or a user’s own request through the web console or by e-mail) remains available.

VKS_SERVER (default: the first [pepsi-keydiscovery] VKS_SERVERS entry)

Where uploads go; https:// only. One server, not a list: publishing the same key to several multiplies an irreversible act, and each then has to be kept up to date with revocations.

VKS_MAX_ATTEMPTS (default 5), VKS_RETRY_INTERVAL (default 6 h), VKS_BATCH (default 50)

How hard, how often and how many at a time identity publish –retry tries. Note that durations reject calendar units: 6 h parses, 1 d does not.

85.1.44.1.6. 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. Rejected for unprivileged callers when this program is installed setuid: the configuration names the database connection, every ${…}-expanded path and the key-encryption key itself.

-L LOGLEVEL, –log LOGLEVEL

Set the logging verbosity. LOGLEVEL is one of error, warn, info, debug or trace (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.

85.1.44.1.7. Exit Status

0

Successful completion. A destructive command that was declined at its confirmation prompt also exits 0, having changed nothing.

1

An error occurred: a malformed configuration file, an inadmissible option combination, an address that resolves to another user’s account, key material that cannot be parsed, a key-encryption key that is missing or does not open a row, or a failed database connection or query. The reason is written to the log.

85.1.44.1.8. Files

When –config is not given, the first existing file from the following list is used:

  • $XDG_CONFIG_HOME/pepsi.conf

  • $HOME/.config/pepsi.conf

  • /etc/pepsi/pepsi.conf

  • /etc/pepsi.conf

secrets.d/pepsi-crypto.secret

The key-encryption key, beside the configuration file and pulled into the [pepsi-crypto] section with an @inline-secret@ directive. It is kept out of the world-readable configuration because it is the one secret that opens every stored private key; pepsi-setup(1) gives it mode 0640 and hands it to the pepsi-crypto account on every run. Back it up separately — see the Key management chapter of the manual for what its loss costs.

No other file is read or written: all key material lives in the database.

85.1.44.1.9. Examples

Find the served addresses whose encryption is decided by a key somebody else supplied, and make a confirmed one the user’s own (an MUA key: no –private):

pepsi-keys -c /etc/pepsi/pepsi.conf peer unclaimed
pepsi-keys -c /etc/pepsi/pepsi.conf peer show bob@example.org
gpg --export bob@example.org > bob.pgp    # the key bob confirmed to you
pepsi-keys -c /etc/pepsi/pepsi.conf identity import bob@example.org \
    --protocol openpgp --purpose both --public bob.pgp

Generate an OpenPGP identity and check that GnuPG accepts the public half:

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

Generate the S/MIME pair, printing a certificate request over each key for a real CA:

pepsi-keys -c /etc/pepsi/pepsi.conf identity generate alice@example.org \
    --protocol smime --csr

Install the certificate that CA issued, over the key that is already in the store:

pepsi-keys -c /etc/pepsi/pepsi.conf identity import alice@example.org \
    --protocol smime --purpose sign --public /tmp/alice-sign.pem --primary

Publish the address in DNS (signed zone only):

pepsi-keys -c /etc/pepsi/pepsi.conf dns alice@example.org >> example.org.zone

Withdraw a compromised signing key, then confirm the private half is gone:

pepsi-keys -c /etc/pepsi/pepsi.conf identity revoke 1 --reason 'laptop stolen'
pepsi-keys -c /etc/pepsi/pepsi.conf identity show 1

Trust a correspondent’s key by hand and pin it against later discovery:

pepsi-keys -c /etc/pepsi/pepsi.conf peer import bob@example.com \
    --protocol openpgp --file bob.pgp --pin

Look one correspondent up with every enabled method and store the result:

pepsi-keys -c /etc/pepsi/pepsi.conf peer refresh --address bob@example.com

Keep the cache honest from a periodic timer: re-validate what is due, then drop what has aged out:

pepsi-keys -c /etc/pepsi/pepsi.conf peer refresh
pepsi-keys -c /etc/pepsi/pepsi.conf peer prune

Add an S/MIME trust anchor and see what is trusted:

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

Rotate the key-encryption key, looking first:

pepsi-keys -c /etc/pepsi/pepsi.conf wrap rotate --dry-run
pepsi-keys -c /etc/pepsi/pepsi.conf wrap rotate

Expire everything that is past its validity (from a periodic timer):

pepsi-keys -c /etc/pepsi/pepsi.conf identity retire

Publish one key to the key server, then check it arrived:

pepsi-keys -c /etc/pepsi/pepsi.conf identity publish 1 --vks
pepsi-keys -c /etc/pepsi/pepsi.conf identity show 1

Drive every pending key-server verification to completion (from cron):

pepsi-keys -c /etc/pepsi/pepsi.conf identity publish --retry

Stop publishing a key, and tell the world the uploaded one is dead:

pepsi-keys -c /etc/pepsi/pepsi.conf identity unpublish 1
pepsi-keys -c /etc/pepsi/pepsi.conf identity revoke 1 --upload \
    --reason 'address closed'

85.1.44.1.10. See Also

pepsi-keydisc(1), pepsi-httpd(1), pepsi-stage-vks-confirm(1), pepsi-setup(1), pepsi-config(1), pepsi-whitelist(1), pepsi-settings(1), pepsi.conf(5), pepsi.state(7)

85.1.44.1.11. Bugs

Report bugs to the Pepsi issue tracker.