Presence API
The relay directory stores one thing: sealed presence records, each saying "I am on these relays" to exactly one peer, under an id only that pair of nodes can compute. The server holds opaque blobs under opaque ids — it never learns which relay a key is on, nor which peer a record is for. atlasd speaks this API itself when the directory is on; this page is the contract for anyone implementing or auditing it.
atlasd uses https://nexusatlas.net/api, overridden by [traversal] directory_url, else by relay_map_url. Every path below is relative to it, so the record endpoint is https://nexusatlas.net/api/v1/presence. Every response carries Cache-Control: no-store.The record
Node A, holding relay sessions, posts one record for each of its static peers B. All keys below are raw 32-byte values; the JSON carries standard, padded, canonical base64.
id
id = lowercase hex( SHA-256( "atlas-presence-v1" ‖ B_pub ‖ A_pub ) )
B = recipient, A = sender — both 32-byte raw X25519 transport keys
64 ASCII characters; id(A→B) ≠ id(B→A)
Only someone who knows both public keys can compute an id, so a record cannot be found by one key alone.
blob
blob = E_pub(32) ‖ ChaCha20-Poly1305(key, nonce = 0^12, aad = A_pub ‖ B_pub)(plaintext)
key = HKDF-SHA256( ikm = X25519(e, B_pub) ‖ X25519(A_static, B_pub),
salt = "nexus-atlas-presence-v1",
info = id as its 64 ASCII hex bytes,
L = 32 )
B opens with ikm = X25519(B_static, E_pub) ‖ X25519(B_static, A_pub)
e is a fresh ephemeral X25519 key per record. The static-static half of the key material means only the holder of A's private key (or B) can produce a blob B accepts — knowing both public keys is not enough. The raw blob is at most 2048 bytes.
plaintext = {"v":1,
"relays":[{"server":"ip:port","pubkey":"<b64>","region":"…"}],
"since":<unix>,
"ttl":<secs>}
right-padded with ASCII spaces to 1024 bytes (2000 if it does not fit)
The padding makes every ordinary blob 1072 bytes long (32 + 1024 + 16), so its length does not tell the server how many relays it names. A record lists at most 8 relays, and only relays the sender holds an established session with.
from and sig
from = base64( Ed25519 verifying key, 32 bytes )
sig = base64( Ed25519-sign( "nexus-atlas-presence-sig-v1" 0x00 28 bytes
‖ id 64 ASCII bytes, lowercase hex
‖ blob the raw decoded bytes
‖ exp 8 bytes, u64 big-endian ) )
Plain Ed25519 (RFC 8032, no pre-hash); 64-byte signature
The Ed25519 key is derived from the node's X25519 transport private key — HKDF-SHA256 with salt nexus-atlas-presence and info nexus-atlas-presence-v1/ed25519, expanded to a 32-byte seed — never the X25519 scalar itself. It needs no storage, and its public half is unlinkable to the transport key and to the node's fleet-record signing key, so from names nothing the server could join on. The server verifies sig against from alone; authenticity towards B comes from the static-static seal.
exp
Unix seconds, an integer. The server accepts now < exp ≤ now + 600, with a 30-second allowance for clock skew; atlasd sends now + 590.
Endpoints
Publish one record. Authenticated by the signature in the body; no account or token.
| Field | Type | Rule |
|---|---|---|
id | string | Exactly 64 lowercase hex characters. |
blob | string | Canonical padded base64 of at most 2048 bytes. Never decrypted by the server. |
from | string | Canonical padded base64 of a 32-byte Ed25519 verifying key. |
sig | string | Canonical padded base64 of a 64-byte Ed25519 signature over the signed bytes. |
exp | integer | Unix seconds, now < exp ≤ now + 600 (+30 s skew). |
curl -X POST -H "Content-Type: application/json" \
-d '{"id":"3f6c…e1a0","blob":"q1Zp…AA==","from":"Lr8n…Yk=","sig":"0x9d…Bg==","exp":1792252190}' \
https://nexusatlas.net/api/v1/presence
{ "ok": true }
| Status | Body | Meaning |
|---|---|---|
200 | {"ok":true} | Stored. |
400 | {"ok":false,"error":…} | invalid_json, invalid_body, invalid_id, invalid_blob, blob_too_large, invalid_from, invalid_sig, invalid_exp, exp_too_far, expired, bad_signature. |
409 | owned | The unexpired record under this id belongs to another poster key. It can be claimed once it expires. |
409 | stale | Same poster, but an older exp than the stored record — a replayed earlier post cannot roll a fresher record back. An equal exp is an idempotent retry and succeeds. |
413 | body_too_large | Body over 8 KiB. |
429 | rate_limited | Per poster key or per IP; see rate limits. |
500 | store_failed | The write did not complete; post again. |
atlasd counts every refusal in traversal.directory.publish_failures and posts again in the next round, 60 s later.
Fetch one record by id, with the id in the body so it never appears in a request path — and so never next to the caller's address in a request log. This is the lookup atlasd uses. Body: {"id": "<64 lowercase hex>"}.
curl -X POST -H "Content-Type: application/json" \
-d '{"id":"3f6c…e1a0"}' \
https://nexusatlas.net/api/v1/presence/lookup
{ "blob": "q1Zp…AA==", "exp": 1792252190 }
| Status | Body | Meaning |
|---|---|---|
200 | {"blob","exp"} | The blob exactly as posted, and its expiry in unix seconds. |
404 | {"error":"not_found"} | No record, or it has expired. |
400 | {"error":…} | invalid_json, invalid_body (not a JSON object), invalid_id, query_not_allowed (a query string was sent). |
413 | body_too_large | Body over 1 KiB. |
429 | rate_limited | Per IP, the same bucket as the GET below. |
500 | read_failed | The read did not complete; look up again. |
A GET on this path answers 405. When a control plane answers the lookup POST itself with 405, atlasd falls back to the GET below.
The same answer, validation and headers as the lookup, with the id in the path: 200 {"blob","exp"}, 404 not_found, 400 invalid_id or query_not_allowed, 429 rate_limited. There is no list, search or filter of any kind — the id must be exact.
Rate limits
| Route | Key | Limit |
|---|---|---|
POST /v1/presence | poster key (from) | 60 per minute |
POST /v1/presence | caller IP | 240 per minute |
| lookup and GET together | caller IP | 240 per minute |
Each server instance counts in fixed one-minute windows, keyed by a SHA-256 of the poster key or the IP that is held in memory for one window and never logged or persisted. A node posts one record per static peer every 5 minutes and on each relay change, and looks a missing peer up at most once a minute, which leaves room for fleets of a few dozen peers per node.
What the server stores — and what it does not
A record is stored as presence/{id} with exactly four fields:
| Field | Content |
|---|---|
blob | The sealed blob as posted. The server never opens it, so no relay name, address or region is visible to it. |
exp | The expiry, as a timestamp. A time-to-live policy deletes expired records; reads treat exp ≤ now as absent regardless. |
updatedAt | When the record was last written. |
fromHash | HMAC-SHA256 of from under a server-held secret. It binds a live record to its first poster without storing the key, and cannot be tested against candidate keys without the secret. |
Not stored, and not logged: the poster key from, the signature sig, the caller's IP address, the peer the record is for, and the relays it names. There is no endpoint that lists, searches or enumerates records; a record is reachable only by an id that takes both nodes' keys to compute.
The same rule covers relays: a relay's heartbeat names its client sessions only by a salted per-run hash, never by key — see heartbeat and map additions. Together, the control plane holds no record of which key is on which relay.