Operations
HTTP API surface
Codex has no web UI; everything is a JSON endpoint consumed by the Eon
Phoenix client and eon-cli:
| Method | Path | Auth | Purpose |
|---|---|---|---|
| GET | /api/health | — | liveness |
| POST | /api/auth/register, /api/auth/login | — | standalone account (fallback when Dovecot isn’t wired) |
| POST | /api/mailauth/token | HTTP 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/bundle | Bearer | publish a key bundle — identity plus its whole KEM key set; every per-key binding signature is verified before storage |
| POST | /api/my/bundle/confirm | Bearer | confirm a staged key replacement with the code mailed to the mailbox |
| POST | /api/my/bundle/deny | Bearer | report 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) |
| GET/POST | /api/apax/{challenge,fetch}, /api/apax/escrow (Bearer), /api/apax/burn (Bearer) | mixed | single-release (“Apax”) escrow |
| GET/POST | /api/cmail/{challenge,fetch}, /api/cmail/escrow (Bearer), /api/cmail/burn (Bearer) | mixed | online-only (“Confidential”) escrow |
| GET/POST | /api/dctrl/{challenge,consent,fetch}, /api/dctrl/escrow (Bearer), /api/dctrl/burn (Bearer) | mixed | dual-control escrow |
| GET/POST/DELETE | /api/ephemeral/{challenge,fetch}, /api/ephemeral/escrow (Bearer), /api/ephemeral/:keyId (Bearer, DELETE) | mixed | single-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.
| Method | Path | Purpose |
|---|---|---|
| POST | /api/admin/login | administrator login, returns a bearer token (rate-limited) |
| GET | /api/admin/whoami | the calling administrator |
| GET | /api/admin/stats | instance counters |
| GET | /api/admin/users, /api/admin/users/{username} | list and inspect accounts |
| POST | /api/admin/users/{username}/disable | disable 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/verify | read the hash-chained admin audit trail, and verify the chain |
| GET | /api/admin/pending | the four-eyes queue |
| POST | /api/admin/pending/{id}/approve, /api/admin/pending/{id}/withdraw | approve (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.
| Bound | Value |
|---|---|
| Staged replacement expires after | 24 hours |
| Code attempts | 5 |
| Replacement mails per account per day | 3 |
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 theauth-clientsocket 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):
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.POST /api/mailverify/confirm {"mailbox": "...", "code": "..."}returns{"token": "<jwt>"}on the right code within the window, or401on a wrong/expired one.- 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.
404from 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-addrto a null-client Postfix relay (or your MTA on:25, not:587/submission, since the keyserver sends unauthenticated) and--smtp-fromto an address on your primary, already-warmed domain. - SPF must list the relay’s IP for the
--smtp-fromdomain; 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 viachallenge→fetch, then optionally destroyed immediately (the “self-erase”/burner variant). - Confidential (
cmail_recipients) — an online-only key half released on every authenticated read;burnsecure-deletes it (the sender’s “unsend”), killing every copy everywhere instantly. Anerase_afterflag 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;burnsecure-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
(challenge → fetch). 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-fingerprintRotation. 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 keyUpgrades (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.