You are reading the documentation for the 2.0.0 release line. The current line is 3.0.0. Read this page in the current line

Identity and authorisation shipped

Documents the atlasd 2.0.0 release lineView as Markdown

Without a fleet list, an Atlas node is its transport key: the key is the node's id in the routing database, in the mesh roster and in every snapshot, and any admitted node may do anything. With [auth] provider = "fleet", a node is a record: a stable id and a name that hold keys for a while and carry the node's permissions. This page covers the record, how its keys rotate, the capability flags it carries, how a new node is admitted, how a node is removed — by name, by area or by a signed wipe — and how routing messages are authenticated.

The node record

Every record in the fleet list has the same fields:

FieldWhat it holds
node_idA UUID minted once by atlasd keygen --identity. On the wire the node id is BLAKE2s-256 of it; snapshots and the dashboard show the short form, e191..81b6.
name, rolesA DNS-safe name, unique in the fleet, and free-form roles (operator, uav, sensor).
allowed_ipsThe tunnel addresses the node may use.
[node.capabilities]The four capability flags.
[[node.keys.transport]]X25519 public keys, each with valid_from and valid_to in Unix seconds (0 = open-ended). At most two may be valid at any instant.
[[node.keys.signing]]Ed25519 public keys, same windows: they sign the node's dashboard token and, with config_author, configuration.

The records of a fleet travel together as one fleet list. atlasd fleet sign stamps it with a sequence number, a timestamp, the author's key and an Ed25519 signature over canonical bytes (sorted-key JSON of the parsed list, so the TOML and JSON forms sign identically). A node accepts a list signed by an anchor in config-authors.toml, the same anchors that sign config epochs, or by a config_author record of the list it already holds, and only when its sequence number is above the node's high-water mark for that fleet. The bootstrap copy and the cached copy are verified again on every load; a node that cannot verify its list, or whose own key has no record, refuses to start.

/etc/atlas/config.toml — a record-fleet member
[interface]
node_id     = "af2e580d-5365-4b5f-acfa-9a9da1f69e73"   # the record UUID
private_key = "…"                                      # current X25519 key
signing_key = "…"                                      # Ed25519 seed
# next_private_key = "…"                               # during a rotation

[auth]
provider   = "fleet"
fleet_list = "/etc/atlas/fleet-list.toml"              # bootstrap copy

The list travels over sessions. The file at fleet_list only seeds a node at start. After that, every session that comes up is offered the list its peer holds, and a node that adopts a newer list offers it to every other live peer, hop by hop. A list is read only from a live, admitted session, and adopted only when it verifies and is newer; the journal records identity.fleet_list_adopted or identity.fleet_list_rejected. To publish a new list, place it at the fleet_list path of any one node and restart that daemon; the rest of the fleet receives it from there.

No record, no entry. A key without a valid record gets no session, whether it came from a [[peer]] block, mesh discovery or nowhere. The handshake is refused, counted in identity.refused_no_record and journalled as identity.no_record_refused once a minute per key; its adverts are not accepted and nothing it sends is forwarded. A mesh member is accepted only as the record its key belongs to. Two rules protect routing in every fleet, with or without records: a link-state advert is processed only when it arrives on a live, admitted session (anything else counts lsa_no_session), and a link between two nodes is used only when both nodes' adverts list it (one-sided claims count lsa_one_sided_links).

The routing database, the mesh roster, epoch targeting, routes (routes[].next_hop) and every snapshot key on the record's node id. A peer is looked up by node id; its key is just what it proves in the handshake.

Rotation with overlap

The fleet-wide schedule — publish, activate, retire, with the retire step gated on the required nodes and a lifeline for stragglers — is under Fleet configuration: rotation; this section is the per-node mechanism it rests on.

A node holds two transport keys during a rotation: private_key, which it initiates handshakes with, and next_private_key. It answers a handshake addressed to either. Its record lists both public halves, and validation refuses a record with three valid at once:

$ atlasd fleet check --file fleet-list.toml
  error: node `drone`: 3 transport keys are valid at once around 0 — a node holds at most 2 (current and next)
fleet `field-1` seq 2: 4 records, 1 error(s), 0 warning(s), signature does not verify against the anchors

When the node starts initiating with its next key, the peer that receives the handshake finds the key in the same record and rebinds the existing peer entry to it rather than creating a new one: the same node id, endpoints and link-monitor history, journalled as identity.peer_key_changed. Routes and topology do not move. End-to-end sealing resolves the destination's node id to its record's transport key (the valid key with the earliest valid_from), and the node opens sealed traffic on either of its two keys through the overlap, so sealed traffic keeps flowing. In a record fleet only a key on the record can open traffic sealed to that node.

Signing keys rotate the same way on the record side: list the new key beside the old one, switch the node's signing_key, then drop the old entry. The node holds one signing key at a time.

The whole procedure, with commands, is in Rotate a node's key without a flag day.

Capability flags

Each record carries four flags. They belong to the record, not to a key, so a rotation keeps them. Every flag the record leaves out takes its least-privilege default, which is also what atlasd fleet add-node writes unless told otherwise.

FlagDefaultEnforced where
relayfalseRouting on every node, the node's own forwarding, and its neighbours' forwarding · counter caps.relay_refused
classesTelemetry, Position, BulkQoS ingress on the sending node and on its first neighbour · counter caps.class_clamped; and at every forwarding hop after them, against the origin in the 12-byte relay header · caps.class_clamped_transit, caps.origin_unrecorded for an origin no record covers; under [crypto] e2e the destination refuses an upgraded class or a changed origin · e2e.class_tampered
config_authorfalseSignature checks on config epochs and fleet lists
dashboardownThe web API's node-token check (none, own or fleet)

relay = false. Shortest-path routing reaches the node but never expands it, except as the origin of its own traffic (it still roots its own multicast trees), and top-K multipath never uses it as a first hop toward anyone else. Every node computes this from the same fleet list, so the whole mesh routes around it consistently. A node whose own record forbids relaying refuses to forward. A neighbour drops a relay-framed packet that a non-relay node forwarded — one whose hop count shows it was already relayed, or whose source is not that node (the sealed sender key, or the clear source address) — and counts it. Traffic the node itself originates is never affected and travels any number of hops.

classes. Any of Control, Voice, Position, Telemetry, Video, Bulk, highest priority first. At QoS ingress — the node's own classification, and the class byte of traffic a neighbour originated when the receiving node runs QoS — a class the record does not list is clamped to the highest-priority listed class below it, or to the lowest listed class when every listed class ranks above it. A sensor with the default classes that marks a packet Control sends it as Position; an observer allowed Voice and Position that sends Bulk sends it as Position. The check runs at the origin and at the first hop; nodes further along carry the class those two settled.

config_author. The record's valid signing keys are accepted as authors of config epochs and fleet lists, beside the anchors in config-authors.toml. atlasd fleet check refuses the flag on a record without a signing key and warns when the record's roles do not include operator.

dashboard. In a record fleet the node token is node-bound: n1. followed by base64url of the node id, the five-minute window and an Ed25519 signature by the node's signing_key, verified against the record's signing keys. The reading node's flag decides: fleet reads any member, own only the node that minted the token, none nothing. Mesh-secret tokens are not accepted in a record fleet. atlasd config node-token, the fleet proxy, fleet health and the crash pull mint the node-bound form automatically. The token matters where reads are gated, on a node running [web] protect = "all"; dashboard accounts and passkeys are a separate gate and are unchanged.

A fleet without a fleet list has no records: every node holds relay, every class and dashboard = "fleet", and configuration authors come from config-authors.toml alone, as before. Worked records for a ground station, a sensor and a partner are in A least-privilege fleet.

A worked deployment with records and with the command hook — two hubs, one network per customer, aircraft admitted from a ground station's own database and removed for good — is in A cellular fleet behind a web ground station.

Admission modes shipped

A record normally reaches the list from the operator host. Four admission modes let a node in without that step, each with its own trust root; they are configured in [auth] and worked through in Admit nodes by pairing code, certificate or approval.

ModeWhat admits the keyWhat the node gets
Invitation — fleet invite, [auth] invite_tokenA 40-byte token in the v=3 pairing code: id ‖ expiry ‖ BLAKE2s-MAC(invite secret, id ‖ expiry ‖ capabilities ‖ binding), the binding a digest of the hub's key, the fleet's anchors, the config authors and both tunnel addresses the code carries; verified and spent by the node that minted it, which then confirms the code over the session so the carried anchors countA record minted for the key its handshake proved, with its own UUID and signing key and the invitation's flags — least privilege unless raised
Certificate — fleet cert, [auth] cert_fileA record signed by an anchor or a config_author record, at most 940 bytes, optional expiryThe certificate's flags on every node that trusts the authority, with no list entry; never config_author
Key + PSK — [auth] psk, psk_file, a record's psk_idA psk_tag = BLAKE2s-MAC(psk, every init byte before it) ahead of mac2, and Noise IKpsk2Nothing on its own: it is a second factor in front of whatever else admits the key
Pending — [auth] pending = trueAn operator's approval of a parked knock (any provider)A record with the operator's flags and address

The hello. Every HandshakeInit carries a hello inside its encrypted payload — flags, the name the node calls itself, its record UUID, signing key and a signature proving it holds both, and the invitation token or certificate when it has one. A node that mints a record mints it with the key the handshake proved, the UUID and signing key the hello proved, and never anything the hello merely asserts.

Minting. Minting is serialised, so a single-use token presented from two keys, or on two links, at the same instant admits one node. A spent, expired or withdrawn token admits nothing (admission.expired), and a UUID or key the list holds or has revoked is refused without spending a use. A node that can sign the list — its own record is a config_author, or it holds the trusted config-author key — signs the next list with the new record and floods it; any other node keeps a local record that admits the node there and forwards it to an author.

Joining. A record-fleet node started with an invitation or a certificate but no fleet_list runs joining: it knows itself by its record id, signs what it floods, admits its configured peers until a list arrives, and adopts the first list signed by an anchor or by the config_author record whose transport key is the pairing code's k=. From then on it enforces records like every other member (identity.mode goes joining → fleet). A record-fleet node with neither a list nor an invitation or certificate refuses to start.

Certificates travel. The admitting node offers an accepted certificate on over its sessions (the fleet-list message carries {"cert": …}), so every member learns the record and verifies the certified node's signed adverts. Revocation is the list: a certificate whose id or keys the list revokes is refused (admission.cert_refused); fleet revoke --cert revokes one that has no list entry.

The PSK check. With only a fleet-wide PSK the tag is checked before any Diffie-Hellman; with named keys, right after the initiator — or the record its certificate carries — is known. A missing or wrong tag is dropped without any answer, not even a cookie, and counted in admission.counters.psk_dropped. A pair whose records name a psk_id uses the lower node id's key.

The queue. With pending = true, a key that fails every other check is parked with its source, claimed name, proved UUID, first and last seen and a count — rate-limited per source and capped at pending_max (admission.knock). Approve mints its record and the node's next retry gets a session without a restart; deny blocks the key for block_secs, dropped unanswered. Every decision is journalled with its operator: web:<user>, token:<name> or cli:<user>. The daemon is the only writer of the admission store; it and the invite secret are data-at-rest stores, encrypted under [storage] at_rest, and atlasd zeroize wipes psk and invite_token and shreds the invite secret.

Forwarded to an author. A node that cannot sign hands its queued records to a node whose record carries config_author, once a second and up to four per message: to a neighbouring author over the session on the fleet-list channel as {"admit": …}; to an author further away the same body goes end to end on the control channel (control type 0x10), routed and sealed like data, which needs [crypto] e2e on the forwarding node because an author takes a relayed forward only sealed. The author checks each record under its own policy: the sender holds a record in the signed list; the record has the shape a node mints (one transport key, at most one signing key, no lifeline, no psk_id); never config_author; no flag the sender lacks itself — relay, a wider dashboard role, a class, the operator role (exceeds_forwarder); no id, name or key already held by another record (collides); nothing the list revokes (revoked); and the next list must validate. Accepted records go into one next list, signed and flooded, about a second after the admission; the author answers {"admit_ack": …} per record — accepted with the sequence, held, refused with its reason, or retry — and the forwarding node drops its local copy once a list carrying the record is adopted. With no author reachable it retries with backoff, 5 s doubling to 5 minutes; a refused record stays local, still admitting its node there, and is not forwarded again. With [auth] accept_forwarded = false (default true) an author holds forwarded records for an operator: atlasd fleet pending list shows them as forwarded … awaiting approval, approve signs one exactly as minted, deny refuses it. The journal records admission.forwarded, forward_waiting, forward_accepted, forward_held, forward_refused and forward_listed on the forwarding node, admission.intake_accepted, intake_held, intake_refused and intake_rejected on the author, with matching counters in admission.counters; each local record carries its forward state.

Revocation shipped

atlasd fleet revoke on the operator host, or the dashboard's Remove nodes…, takes a record (or one key of it) out of the list and lists its node id and every key it held under revoked = [{ node_id | transport_key | signing_key, since, reason }]. It is for good: no later list may carry a revoked id or key; re-enrolment is a new UUID with new keys. The list is signed at the next sequence number and handed to one daemon, which floods it. Every node that adopts it does four things in the same intake:

  1. Re-offers the list to every peer, the removed node included, so it learns it is out.
  2. Evicts now every peer whose key now resolves to no record: sessions retired, link state cleared, its adverts and roster entry purged. A discovered peer is removed outright; a configured [[peer]] keeps its entry but stays unadmitted. Counted security.evicted_total, journalled security.peer_evicted and security.revoked.
  3. Switches to a rotated mesh secret. The list carries a fresh 32-byte secret sealed to each remaining record's transport key (X25519, HKDF-SHA256, ChaCha20-Poly1305, bound to the rotation id and the recipient). Gossip moves to it at once; the old one is still opened for 10 minutes so a node a hop behind is heard. The operator host keeps the newest secret beside its list (<file>.mesh-secret, mode 0600) so a later record gets its copy.
  4. Refuses the node from then on: no record, no handshake. With --push-relays or fleet push --relays the revoked transport keys also go to the relay registry; each relay polls it and drops the key's sessions within one poll.

The removed node shows identity.revoked_by = { seq, reason }, and atlasd status prints revoked by fleet seq N — no peer will accept this node; re-enrol with a new identity. A peer whose record simply disappears from a newer list is evicted the same way. Within a second of the push no node holds a session with the removed node. What it already received it keeps; nothing new reaches it, and traffic captured earlier stays unreadable because session keys are ephemeral. atlasd peer evict resets one peer's sessions on demand; a still-recorded peer reconnects. The full procedure is in Remove a compromised node.

Removal by area, geofences and remote zeroize shipped

Three tools built on the record and the revocation above. The procedures are in Remove nodes by area, set geofences, wipe a node remotely.

Last-known positions. Every node of a record fleet keeps the last position each node advertised, and keeps it after the advert ages out, so a node that went silent inside an area is still found there (up to 4096 entries, in memory; snapshot area.positions).

Removal by area. A polygon — [lat, lon] corners, or GeoJSON's [lon, lat] converted at the edge, 3 to 256 corners, crossing the antimeridian if it must, never winding around a pole — is resolved against those positions into a reviewable list: the records inside, each with fix_age_secs and whether its advert is still live, a count of those outside, and the records with no position, listed apart because a polygon cannot place them. The selection removes nothing. atlasd fleet select-area --revoke, or the dashboard's Revoke selected, then removes exactly the listed records through the revocation — never the node doing the selecting.

Geofence. A record may carry [node.geofence] polygon = [[lat, lon], …], grace_secs, action = "isolate" | "zeroize" (default isolate), signed in the list like every other record field. The node checks its own fix against its fence every 10 s. The first fix outside starts a grace timer (security.geofence_outside); back inside cancels it (security.geofence_inside); outside for grace_secs fires the action once:

  • isolate — the node refuses first, announces isolated in its own signed status entry and flushes that gossip to every peer, then tears every session down (security.geofence_isolated). While it holds, every handshake either way is refused (area.isolation_refused) and gossip stops; peers that hear the announcement drop their side at once. Back inside, the isolation lifts (security.geofence_released) and the sessions return.
  • zeroize — the zeroize routine below, cause geofence.

A node with no fix never fires its action and is flagged no_fix; a lost fix keeps the timer's start. The timer is persisted sealed at rest, so a restart outside the fence restores the state instead of handing out a fresh grace period; a changed fence re-arms the action but keeps the timer. Snapshot: area.geofence {state, action, grace_secs, outside_for_secs, grace_left_secs, fired}, area.isolated, and area.fences[] — every record's fence as this node sees it from that record's advertised position.

Remote zeroize. The fleet list carries commands = [{ node_id, cmd = "zeroize", not_before, issued_at, author, sig }]. Each command is signed on its own by a fleet author — an anchor or a config_author record — over the fleet id and its fields, so it cannot be lifted into another fleet's list or re-aimed, inside a list that is signed as a whole; one command per node, kept in later lists until cancelled. By default the same list also revokes the node — its id and keys, with a mesh-secret rotation, as fleet revoke does — so a wiped identity can never rejoin; --no-revoke (API: "revoke": false) keeps the record. The addressed node checks every second: an unknown signer is refused (security.command_rejected), a command not yet due is held (security.command_pending), a verified, due one runs — the node sets zeroized in its own signed status entry, flushes that gossip, destroys its store key, private and signing keys, mesh secret and web secrets, truncates every store (the geofence timer included) and exits, leaving one clear journal line, security.zeroized, with the cause, the signer, the fleet sequence and the times. A replayed older list never gets a command read: the high-water mark refuses the list first. Every node that hears a peer's verified zeroized announcement evicts it and purges its adverts and roster entry at once (security.peer_zeroized), and area.commands[] marks the command acknowledged.

The signed control plane shipped

Hop messages are authenticated by the session; what floods is signed by its originator. Every node on the current wire, protocol 4, works this way: a fleet upgrades together, and a node speaking wire 1, 2 or 3 gets no session (counted protocol_refused). Wire 4 adverts carry the prefixes a seam host imports for Interconnect prefix mode inside the signed body, which a wire-3 node could not verify.

  • Probes, acks and keepalives are sealed under the session's transport keys with the same nonce counter as data, with version, type and session id as associated data, and checked against the anti-replay window. One that does not authenticate moves no RTT, revives no link and points no endpoint anywhere. The 24 bytes of nonce and tag add about 6 ms to a probe round trip on a 64 kbit/s link.
  • Link-state adverts carry node_id, key_id and an Ed25519 sig over canonical bytes, checked against the originator's record before the routing database sees them. Unsigned, unknown key, bad signature or no record: dropped, counted security.lsa_sig_rejected.
  • Gossip carries its sender's key id and signature, and every config-status entry is signed by the node it describes, so holding the mesh secret does not let one member speak as another (security.gossip_sig_rejected).
  • Keys change only through the signed list. No message from a node can add, rotate or retire its own keys. A record fleet therefore needs [interface] signing_key on every node, listed in its record; the daemon refuses to start without it.
  • Handshakes are rate-limited before any Diffie-Hellman: per source (burst 32, 8/s; IPv6 by /64) and node-wide (burst 200, 100/s). Past the budget a node answers with an address-bound cookie and processes only inits that echo it, so a flood of forged handshakes cannot push out a recorded node.

Seeing it

  • atlasd identity show — this node's identity as the daemon resolves it at start: node id and UUID, record name, flags, each key with its validity and whether it is held here, and the fleet list's sequence number. atlasd fleet show -c <config> summarises the list the node runs with.
  • GET /api/identity and GET /api/fleet — the same over the REST API, with the refusal counters.
  • GET /api/admission — pending knocks, blocked keys, invitations without their tokens, records held outside the signed list, certified records and the admission.counters; atlasd fleet pending list and fleet invite --list on the command line; the dashboard's Peers tab opens with an Admission · Pending card.
  • The dashboard: Settings shows a Node record card (mode, node id, keys with validity and rotation state, flags, fleet list, refusals); Peers names each peer after its record, shows its flags, and marks an unadmitted key "No record — refused"; the Fleet tab opens with the fleet list joined to the live view by node id, so a node mid-rotation stays one row.
  • The Fleet tab marks removed nodes revoked (name struck through, reason, date) in the records table and both matrices; a revoked node shows a red banner; a strip under the list shows the control-plane counters.
  • The Topology map has an area tool and draws every record's geofence by state; the Fleet tab shows each record's fence with a state chip and grace countdown, pending zeroize commands, a red banner while this node is isolated, and Zeroize… beside Remove nodes. atlasd status prints the geofence state and any isolation.
  • Snapshot fields: identity.{mode, node_id, node_uuid, keys, capabilities, fleet, refused_no_record, revoked_by}, security.{protocol, lsa_sig_rejected, gossip_sig_rejected, evicted_total, handshake_rate_limited, handshake_cookie_sent, mesh_secret}, peers[].{node_id_short, record_name, capabilities, admitted}, routes[].next_hop, caps.{relay_refused, class_clamped, class_clamped_transit, origin_unrecorded}, e2e.class_tampered, lsa_no_session, lsa_one_sided_links.

Boundaries

  • A node's own node id is fixed when the daemon starts; a list that moves the node's key to a different record takes effect for it at the next restart.
  • A key whose record is removed or revoked is evicted when the list arrives: its sessions end and its handshakes are refused.
  • A dashboard removal signs on the node it runs on, so the operator host's authoring copy falls behind until it is pulled back with atlasd fleet show -c <config> --json.
  • Relays take revocations from the registry, polled every 30 s by default, or from [relay] revoked_keys_file.
  • A fleet without a fleet list has no keys to verify against: its adverts and gossip stay unsigned, and the underlay remains its trust boundary.
  • A few outer-header fields used for loss accounting sit outside the data packet's associated data, so an on-path attacker can disturb loss figures; nothing can be injected.
  • Signed config-status entries add about 120 bytes each, so a gossip round that carries every node's status can exceed one datagram and cross as IP fragments.
  • A fleet list travels as one datagram, about 600 bytes per record, which holds a fleet of about a hundred records.
  • An interconnect seam peer needs a record like any other key, and a seam node that re-originates mapped traffic needs relay = true or its mapped prefixes in allowed_ips.
  • Records, the dashboard token form and the fleet provider are one mode per fleet: every node in a fleet runs with the list, or none does.
  • Invitations and certificates admit into a record fleet. A node that cannot sign forwards the records it mints to an author; to an author that is not a neighbour only with [crypto] e2e. The node it admitted adopts the author's list as its first list only when an anchor of its config-authors.toml (or its inviter) signed it; until then it stays joining — known to every other node from the list, reaching its configured peers directly. A refused record stays on the node that minted it until an operator removes it or signs it by hand (atlasd fleet pull --with-queued).
  • Area selection and every fence state seen from another node use self-reported positions; a geofence is enforced by the node itself, so a device whose GPS is spoofed or whose software is altered does not enforce it — revoke it. Last-known positions live in memory; polygon edges are straight in latitude and longitude.
  • A joining node learns the mesh secret only from a rotation sealed to its record. A certificate carries no geofence.
  • The HandshakeInit is 162 bytes bare, 319 with an invitation and about 850 with a typical certificate and a token; the daemon checks at start that it stays within 1,200.
Esc
↑ ↓ navigateEnter — openEsc — close