Skip to content

Operations

HTTP API surface

Codex has no web UI; everything is a JSON endpoint consumed by the Eon Phoenix client and eon-cli:

MethodPathAuthPurpose
GET/api/health—liveness
POST/api/mailauth/tokenHTTP Basic (mailbox password)mailbox-bound token (needs --dovecot-auth-socket)
POST/api/mailverify/start—directory mode: mail an enrollment code to a mailbox
POST/api/mailverify/confirm—directory mode: confirm the code, issue a mailauth token
POST/api/my/bundleBearerpublish a key bundle — identity plus its whole KEM key set; every per-key binding signature is verified before storage
POST/api/my/bundle/confirmBearerconfirm a staged key replacement with the code mailed to the mailbox
POST/api/my/bundle/denyBearerreport a staged replacement as not yours
GET/api/bundle/user/:username—discover a bundle (includes server_sig)
GET/.well-known/eon-bundle/:username—discovery alias
GET/api/server-key—Codex’s own signing key (for the DNSSEC anchor / fingerprint pin)

Codex also serves the endpoints behind the escrow tiers (see Escrow tiers); the Eon Phoenix client uses them directly.

Administrative API

The /api/admin/* surface is mounted only when the daemon is started with --admin-listen; without that flag the routes don’t exist at all, rather than existing and returning 401. Mount it on its own listener (loopback, fronted by your own proxy and access control) — it decides which public keys the world sees for the domain.

MethodPathPurpose
POST/api/admin/loginadministrator login, returns a bearer token (rate-limited)
GET/api/admin/whoamithe calling administrator
GET/api/admin/statsinstance counters
GET/api/admin/users, /api/admin/users/{username}list and inspect accounts
POST/api/admin/users/{username}/disabledisable an account
DELETE/api/admin/users/{username}delete an account
GET/api/admin/keys/{username}the published key set for an account
GET/api/admin/audit, /api/admin/audit/verifyread the hash-chained admin audit trail, and verify the chain
GET/api/admin/pendingthe four-eyes queue
POST/api/admin/pending/{id}/approve, /api/admin/pending/{id}/withdrawapprove (by a different administrator) or withdraw a queued action

Nothing on this surface touches key material, releases a key half, or changes escrow.

Publishing a key set, and replacing a key

A bundle is an identity (ML-DSA-87 + Ed25519) plus a set of hybrid KEM keys, one per algorithm, each carrying its own binding signature by that identity. Codex verifies every binding before storing it and can neither add nor swap a key of its own. Republishing the same identity with valid bindings updates the key set — that is how a client that gained a new algorithm gets it published, without a fingerprint change and without touching anyone’s pin.

Replacing an existing published key is a different operation, and the one an attacker wants: it makes every future correspondent encrypt to their key instead of yours. A replacement is therefore staged as pending, an 8-digit code is mailed to the mailbox, and nothing changes until the owner enters that code in their own client (/api/my/bundle/confirm). The code is entered in the client, never clicked as a link in mail.

A staged replacement expires if it is not confirmed, code attempts and replacement mails per account are limited, and after a denial (/api/my/bundle/deny) replacement stays blocked for a cooldown.

Republishing a key that is already staged reports as pending, not as an error. This flow needs --smtp-addr; an operator who runs no mail at all can fall back to the pre-confirmation behaviour with --allow-unconfirmed-key-change, which means a valid publish token is again enough to swap someone’s published key.

Confirmation does not beat mailbox takeover — an attacker who fully controls the mailbox reads the code too. It closes the window, raises the alarm and adds delay.

Mailauth: the two ways a client proves ownership of an address

Publishing a key bundle for an address requires proving control of that mailbox first. Codex supports two proofs, selected by how the instance is configured (see Configuration):

  • Dovecot mailbox auth (authoritative instances) — the client presents the mailbox’s IMAP/SMTP username and password to POST /api/mailauth/token; Codex checks them against Dovecot over the auth-client socket and, on success, issues a bearer token scoped to that address. This happens transparently the first time the desktop app logs into an account on a domain running its own Codex instance.

  • Email-challenge / “mailverify” (directory instances) — for mailboxes Codex doesn’t control the domain of (Gmail, Outlook, anything else):

    1. POST /api/mailverify/start {"mailbox": "..."} always returns {"status":"sent"} (never reveals whether the address exists) and emails an 8-digit code to that mailbox via the configured SMTP relay.
    2. POST /api/mailverify/confirm {"mailbox": "...", "code": "..."} returns {"token": "<jwt>"} on the right code within the window, or 401 on a wrong/expired one.
    3. Codes expire after a short window, are single-use, and allow a limited number of attempts. Expired challenges are cleared automatically; nothing manual is required.

    The issued JWT is the same kind of mailbox-ownership proof the Dovecot path produces, so once mailverify succeeds, every escrow tier (Apax, Confidential, dual-control, ephemeral) is unlocked the same way — they all sit behind the same bearer-token middleware.

Mail deliverability for the challenge sender

Because the directory instance only sends mail (it never receives), all the usual anti-spam plumbing has to be right for Gmail/Outlook to actually inbox the code:

  • Relay through your existing mail server rather than sending directly — set --smtp-addr to a null-client Postfix relay (or your MTA on :25, not :587/submission, since the keyserver sends unauthenticated) and --smtp-from to an address on your primary, already-warmed domain.
  • SPF must list the relay’s IP for the --smtp-from domain; DKIM must be signed by that same domain (check your MTA’s per-domain signing config — a catch-all “sign everything as the server’s own domain” rule will misalign DKIM for --smtp-from).
  • Force IPv4 to Gmail if your relay has IPv6 configured but lacks a matching PTR — Gmail rejects IPv6 senders that fail its stricter requirements there.
  • Even with SPF/DKIM/DMARC all passing, a cold sending domain can still get spam-foldered on reputation alone; this improves as the domain sends more legitimate mail over time, and recipients marking messages “not spam” helps immediately.

Key bundles

A published bundle is the recipient’s public material for one address: an ML-DSA-87 and an Ed25519 signature public key, plus one hybrid encapsulation key per KEM algorithm (ML-KEM-1024 and FrodoKEM-1344), each paired with an X25519 public key — never anything private. POST /api/my/bundle verifies the bundle is self-bound (signed by the identity it claims to belong to) before storing it, so a compromised transport can’t swap in an attacker’s key without the attacker also holding the user’s identity private keys. GET /api/bundle/user/:username (and its /.well-known/eon-bundle/:username alias) returns the stored bundle plus server_sig — Codex’s own ML-DSA signature over the bundle, so a client can verify the response actually came from the domain’s Codex instance and wasn’t tampered with in transit.

Escrow tiers

Beyond plain key discovery, Codex also implements the server-gated message tiers that Eon Phoenix’s composer exposes to end users as Apax (single-release), Confidential, and Dual-control (see the client’s Encrypted mail docs for what these mean to a sender). Mechanically, each stores only a key half, never a message body:

  • Apax — a single-release key half; released exactly once, then optionally deleted immediately (the “self-erase” variant).
  • Confidential — an online-only key half released on every authenticated read; the sender’s “unsend” deletes it, after which the message can no longer be opened. It can also be set to release once instead of on every read.
  • Dual-control — release requires both the recipient’s fetch and a named approver’s consent before the key half is handed over; the sender can delete it the same way as Apax and Confidential.
  • Ephemeral — the single-read escrow behind D-Mail’s self-erase modifier.

Every fetch is challenge-authenticated with ML-DSA — the reader proves control of their identity key before Codex releases its half. A deleted or expired row returns a uniform “gone” response with no information leak about whether it ever existed. Codex never sees the message body or the client-held half of the key in any of these tiers — compromising it yields key halves and public bundles, never message content.

Signing key and fingerprint pinning

Each Codex instance generates its own ML-DSA signing identity on first start (keyserver_signer.json in the data directory) and signs every discovery/bundle response with it. Clients need to know this key is genuinely the domain’s Codex instance and not an impostor:

  • Authoritative instances anchor the fingerprint in DNS: publish _eon-keysign.<domain> IN TXT "v=eon-keysign1; fpr=<fingerprint>" under DNSSEC. Clients cross-check the signing key against this record.
  • Directory instances have no domain of their own to anchor this way, so their fingerprint is pinned directly in the client instead (a compiled-in default, or a per-install override — see Configuration). Until a fingerprint is pinned somewhere, clients fail closed and simply refuse to trust that directory.

Get the current fingerprint two ways:

# online, once the service is running:
curl -s https://keys.<domain>/api/server-key

# offline / admin, no server needed — just reads the signer file:
eon-keyserver --data-dir /var/lib/eon-keys --print-fingerprint

Rotation. Deleting keyserver_signer.json from the data directory generates a fresh signing identity on next start — a new fingerprint. Every client that pinned the old one (via DNS anchor or compiled-in pin) needs that pin updated before it will trust the instance again, so treat this as a deliberate, coordinated operation: update the DNS TXT record (or ship a new client build with the new pinned fingerprint) at the same time you rotate.

Day-to-day service management

systemctl {status|restart|stop} eon-keyserver            # or eon-keyserver-auth / eon-keyserver-directory
journalctl -u eon-keyserver -f                           # logs
cat /etc/eon-keyserver/keyserver.env                      # config (0600)
ls /var/lib/eon-keys                                      # data: SQLCipher db + signing key

Upgrades (sudo ./upgrade.sh) replace only the binary and systemd unit and restart the service; they never touch the data directory, env file, secrets, or DNS, and database schema changes apply automatically (additive migrations) on restart — no manual migration step for routine version bumps. See Install for uninstall and troubleshooting.