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/healthliveness
POST/api/auth/register, /api/auth/loginstandalone account (fallback when Dovecot isn’t wired)
POST/api/mailauth/tokenHTTP Basic (mailbox password)mailbox-bound token (needs --dovecot-auth-socket)
POST/api/mailverify/startdirectory mode: mail an enrollment code to a mailbox
POST/api/mailverify/confirmdirectory 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/:usernamediscover a bundle (includes server_sig)
GET/.well-known/eon-bundle/:usernamediscovery alias
GET/api/server-keyCodex’s own signing key (for the DNSSEC anchor / fingerprint pin)
GET/POST/api/apax/{challenge,fetch}, /api/apax/escrow (Bearer), /api/apax/burn (Bearer)mixedsingle-release (“Apax”) escrow
GET/POST/api/cmail/{challenge,fetch}, /api/cmail/escrow (Bearer), /api/cmail/burn (Bearer)mixedonline-only (“Confidential”) escrow
GET/POST/api/dctrl/{challenge,consent,fetch}, /api/dctrl/escrow (Bearer), /api/dctrl/burn (Bearer)mixeddual-control escrow
GET/POST/DELETE/api/ephemeral/{challenge,fetch}, /api/ephemeral/escrow (Bearer), /api/ephemeral/:keyId (Bearer, DELETE)mixedsingle-read ephemeral escrow (backs D-Mail’s self-erase)

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.

BoundValue
Staged replacement expires after24 hours
Code attempts5
Replacement mails per account per day3
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 10 minutes, are single-use, and are capped at 5 attempts. A background sweep clears expired challenge rows periodically; nothing manual is required.
    4. 404 from either endpoint means the instance was started without --smtp-addr — it isn’t running in directory mode.

    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-KEM-1024 encapsulation public key, an X25519 public key, an ML-DSA-87 signature public key, and an Ed25519 signature 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 (apax_shares) — a single-release key half; released exactly once via challengefetch, then optionally destroyed immediately (the “self-erase”/burner variant).
  • Confidential (cmail_recipients) — an online-only key half released on every authenticated read; burn secure-deletes it (the sender’s “unsend”), killing every copy everywhere instantly. An erase_after flag makes it release-once instead of unlimited.
  • Dual-control (dctrl_shares) — release requires both the recipient’s fetch and a named approver’s consent (/api/dctrl/consent) before the key half is handed over; burn secure-deletes it the same way as Apax and Confidential.
  • Ephemeral (ephemeral_recipients) — 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 (challengefetch). A burned 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.