Presence API

Documents the atlasd 3.0.0 release lineView as Markdown

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.

Base URL. 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

POST/v1/presence

Publish one record. Authenticated by the signature in the body; no account or token.

FieldTypeRule
idstringExactly 64 lowercase hex characters.
blobstringCanonical padded base64 of at most 2048 bytes. Never decrypted by the server.
fromstringCanonical padded base64 of a 32-byte Ed25519 verifying key.
sigstringCanonical padded base64 of a 64-byte Ed25519 signature over the signed bytes.
expintegerUnix seconds, now < exp ≤ now + 600 (+30 s skew).
Request
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
Response
{ "ok": true }
StatusBodyMeaning
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.
409ownedThe unexpired record under this id belongs to another poster key. It can be claimed once it expires.
409staleSame 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.
413body_too_largeBody over 8 KiB.
429rate_limitedPer poster key or per IP; see rate limits.
500store_failedThe 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.

POST/v1/presence/lookup

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>"}.

Request
curl -X POST -H "Content-Type: application/json" \
     -d '{"id":"3f6c…e1a0"}' \
     https://nexusatlas.net/api/v1/presence/lookup
Response
{ "blob": "q1Zp…AA==", "exp": 1792252190 }
StatusBodyMeaning
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).
413body_too_largeBody over 1 KiB.
429rate_limitedPer IP, the same bucket as the GET below.
500read_failedThe 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.

GET/v1/presence/{id}

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

RouteKeyLimit
POST /v1/presenceposter key (from)60 per minute
POST /v1/presencecaller IP240 per minute
lookup and GET togethercaller IP240 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:

FieldContent
blobThe sealed blob as posted. The server never opens it, so no relay name, address or region is visible to it.
expThe expiry, as a timestamp. A time-to-live policy deletes expired records; reads treat exp ≤ now as absent regardless.
updatedAtWhen the record was last written.
fromHashHMAC-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.

Esc
↑ ↓ navigateEnter — openEsc — close