API
Two protocol surfaces
EON Path has two distinct APIs, and it’s easy to conflate them:
- VPN authentication protocol — a raw TCP handshake that authenticates a client to the server and establishes a VPN session, using ML-DSA-87 challenge-response authentication and an ML-KEM-1024 key exchange. This is not an HTTP API.
- Management API — a REST/JSON API, served by
eonpath-api, for registering accounts, publishing and querying ML-DSA public keys, and managing VPN IP leases. It has its own authentication (username/password login returning a bearer token) that is unrelated to the VPN handshake.
The two touch at exactly one point: during the VPN handshake, the server calls the management API to confirm a connecting client’s public key is registered and active (see step 4 below).
VPN authentication protocol (ML-DSA-87 challenge-response)
- Transport: plain TCP to the WireGuard routing method’s key-exchange
listener —
EONPATH_WG_KEX_ADDRon the server, default0.0.0.0:9998(see Configuration: Server). - Framing: a custom, length-prefixed binary protocol (4-byte big-endian length, then the payload) — not HTTP, so there are no JSON request/response bodies for this part of the API.
- Why ML-DSA-87 and ML-KEM-1024: see Post-Quantum Cryptography.
Challenge request
The first phase authenticates the client’s identity key to the server:
| Step | Direction | Message | Description |
|---|---|---|---|
| 1 | Client → Server | Protocol version (1 byte) | Identifies the handshake version in use. |
| 2 | Client → Server | ML-DSA-87 public key (2,592 bytes) | The client’s identity key. |
| 3 | Client → Server | API server URL | Tells the server which management API to check the key against. |
| 4 | Server → Management API | GET /api/keys/fingerprint/:fingerprint | Server looks up the client’s key fingerprint to confirm it’s registered and active. |
| 5 | Server → Client | Auth result (1 byte) | 0x01 if the key is registered and active, 0x00 otherwise. The server closes the connection on 0x00. |
| 6 | Server → Client | Challenge nonce (32 random bytes) | |
| 7 | Client → Server | ML-DSA-87 signature (4,627 bytes) | The client signs a domain-separated hash of the protocol version, its own public key, and the nonce, using its ML-DSA-87 private key. |
| 8 | Server → Client | Verification result (1 byte) | 0x01 if the signature verifies against the public key from step 2, 0x00 otherwise. The server closes the connection on failure. |
Session establishment
Once the client is authenticated, the server proves its own identity and both sides derive the key material for the WireGuard tunnel:
| Step | Direction | Message | Description |
|---|---|---|---|
| 9 | Server → Client | Server ML-DSA-87 identity key, ephemeral ML-KEM-1024 public key, and a signature over both plus the prior transcript | Lets the client verify the key came from the server it expects, not an on-path attacker. |
| 10 | Client (local) | — | Client verifies the server’s ML-DSA-87 identity against a pinned hash or DNS TXT record configured for the endpoint. See Server identity pinning — this step fails closed if neither is configured. |
| 11 | Client (local) | — | Client verifies the signature from step 9, then encapsulates a shared secret against the server’s ML-KEM-1024 public key. |
| 12 | Client → Server | ML-KEM-1024 ciphertext | |
| 13 | Both sides (local) | — | Both sides derive the WireGuard pre-shared key from the shared secret, bound to a hash of the full handshake transcript (including the ciphertext from step 12). Any tampering with an earlier message produces mismatched keys on the two sides, so a tampered handshake fails closed instead of silently succeeding. |
| 14 | Both sides | WireGuard public keys | The two sides exchange WireGuard public keys; the server adds the client as a peer, and the WireGuard tunnel comes up using the derived pre-shared key. |
Management API
Base URL
http://localhost:8080/apiThe default address matches EONPATH_API_ENDPOINT in the server’s
environment file — see Configuration: Server.
Authentication
Most endpoints require a bearer token, obtained from /api/auth/login:
Authorization: Bearer <token>This token authenticates account operations (managing your profile, uploading keys, requesting IP leases) — it is not used anywhere in the VPN authentication protocol described above, which authenticates the VPN connection itself using ML-DSA-87 signatures instead of passwords or tokens.
Endpoints
| Method | Path | Auth | Purpose |
|---|---|---|---|
| GET | /api/health | none | Health check. |
| GET | /api/algorithms | none | List supported PQC algorithms. |
| POST | /api/auth/register | none | Create a new user account. |
| POST | /api/auth/login | none | Authenticate and receive a bearer token. |
| GET | /publickey/:userid | none | Get a user’s primary public key. |
| GET | /publickeys/:userid | none | Get all of a user’s public keys. |
| GET | /api/keys/user/:username | none | Get public keys by username. |
| GET | /api/keys/fingerprint/:fingerprint | none | Get a key by its fingerprint (used by the VPN handshake, see above). |
| GET | /api/keys/search | none | Search public keys by username or comment. |
| GET | /api/me | bearer | Get the current user’s profile. |
| PUT | /api/me | bearer | Update the current user’s profile. |
| POST | /api/me/password | bearer | Change the current user’s password. |
| GET | /api/my/keys | bearer | List the current user’s public keys. |
| POST | /api/my/keys | bearer | Upload a new public key. |
| PUT | /api/my/keys/:id | bearer | Update key metadata (for example, its comment). |
| POST | /api/my/keys/:id/revoke | bearer | Mark a key as revoked. |
| DELETE | /api/my/keys/:id | bearer | Permanently delete a key. |
| POST | /publickey | bearer | Upload a public key (legacy endpoint). |
| GET | /api/my/ip | bearer | Get the current user’s VPN IP lease. |
| POST | /api/my/ip | bearer | Request a new IP lease. |
| DELETE | /api/my/ip | bearer | Release the current IP lease. |
| POST | /endpoints | signed payload | Endpoint registration: a server pushes the endpoints it facilitates, signed with its ML-DSA-87 identity key and, on the first push, its licence key. |
| GET | /endpoints | none | List the endpoints clients may connect to. Standby endpoints are not in the list. |
| GET | /metrics | none | Prometheus metrics. |
| POST/GET | /legacy/publickey, /legacy/publickeys/:userid, /legacy/publickey/:userid[/:keyname] | mixed | File-based public-key endpoints kept for old clients; new clients use /api/keys/*. |
Endpoint registration
POST /endpoints replaced an older POST /endpoints/:name, which was
trust-on-first-use — whatever X-API-Key the first caller sent became the key
for that name. Registration is now a signed payload: the server’s ML-DSA-87
identity signs it, the first push also presents the licence key, and after
that the signature alone authenticates the server. The URL the server
registers with is part of what it signs, so it must match one of the API’s
EONPATH_API_AUDIENCES entries. Replays are bounded by a nonce cache.
An endpoint carries a state — standby or active — set on the server, not
in the API (see
Configuration: Server).
GET /endpoints returns only the active ones. Unlicensed endpoints in a push
are skipped; the rest of the push is accepted rather than the whole thing
being refused.
Administrative and billing routes
Two route groups exist for EON Core, not for tenants, and each is registered
only when its token is configured — without the token the routes don’t
exist rather than refusing. Both take the token in the X-Admin-Token header.
| Method | Path | Token | Purpose |
|---|---|---|---|
| POST/GET | /api/admin/organizations | admin | Create and list organizations. |
| POST/DELETE/GET | /api/admin/organizations/:id/prefixes[/:prefix] | admin | Reserve, release and list the endpoint-name prefixes an organization may register under. |
| POST/GET | /api/admin/licenses | admin | Issue and list licences. |
| PATCH | /api/admin/licenses/:id | admin | Amend a licence in place, instead of reissuing the key. |
| POST | /api/admin/licenses/:id/revoke | admin | Revoke a licence. |
| GET | /api/admin/endpoints | admin | List every registered endpoint. |
| DELETE | /api/admin/endpoints/:id | admin | Remove a registered endpoint. |
| GET | /api/admin/billing/summary | admin or billing | Billing digest over the endpoint transition log. |
| GET | /api/admin/billing/endpoints/:name/history | admin or billing | The transition history for one endpoint. |
The billing group accepts a second, read-only token (EONPATH_BILLING_TOKEN)
so an invoicing job never holds a token that can issue or revoke a licence.
Neither token replaces restricting /api/admin at the reverse proxy — a
shared secret on a published path is one leak away from a self-issued licence.
Register
POST /api/auth/registerRequest:
{
"username": "alice",
"email": "alice@example.com",
"password": "a-strong-password"
}Response (201 Created):
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"username": "alice",
"email": "alice@example.com",
"created_at": "2026-01-15T10:30:00Z"
}Login
POST /api/auth/loginRequest:
{
"username": "alice",
"password": "a-strong-password"
}Response (200 OK):
{
"token": "eyJhbGciOiJIUzI1NiIs...",
"expires_at": "2026-01-16T10:30:00Z",
"user": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"username": "alice"
}
}Errors
Error responses share a common shape:
{
"error": "error message here"
}| Code | Meaning |
|---|---|
| 400 | Bad request — invalid input |
| 401 | Unauthorized — missing or invalid token |
| 403 | Forbidden — insufficient permissions |
| 404 | Not found |
| 409 | Conflict — resource already exists |
| 429 | Too many requests — rate limit exceeded |
| 500 | Internal server error |
Authentication endpoints are rate-limited to 5 requests/minute; other endpoints to 30 requests/minute.