hyperhive/swarm-nats-auth
Repository files (latest commit first)
Filename Latest commit message Latest commit date
atlas 7a03ce096a hive-c0re: deliver an agent's credential from the store to its state dir
mara on #4015: "not merging code without callers", and on the same PR
"see issue, we decided what the first thing should be". #3726 decided it:
the controller writes a token to the store and tells the hive; the hive
reads it back and writes /agents/<agent>/state/matrix-token-<account> at
0600, where matrix.nix's existing systemd.paths glob re-fires the daemon.
So this is the hive half of that, and the library's first caller.

The notice names a credential and never carries one, and deploy_subject's
own doc is why: the auth-callout responder scopes publish and leaves sub
unrestricted, so a hive that wanted another's messages could subscribe to
them. A secret in that payload would be readable swarm-wide. The value is
read from the store under the reading hive's own certificate, where the
store's policy is what actually scopes it.

Two boundaries guard the two addresses, and they are not the same check.
`path::matrix_account` guards the address in the store. `Ident` guards the
address on disk -- `agent_state_dir` takes one, so an unvalidated name off
the queue cannot reach a directory. I had written the first and assumed it
covered both; the compiler refused the `&str` and was right. `token_path`
now takes the newtype so a call site cannot forget.

The write is atomic because the path-watcher fires on the file appearing:
written in place it would be visible while partial, and the daemon would
read a truncated credential exactly once, which is the hardest possible
failure to reproduce. The temp name is dot-prefixed so it cannot match the
`matrix-token*` glob on its way past.

The publish grant is here because without it the failure is invisible.
policy.rs already says why for its siblings: a refused publish reaches the
client as a timeout, so the symptom is a hive that never receives a
credential with nothing in either log naming a permission. Two tests: the
controller may publish, a hive may not -- its own subject included. A
forged notice leaks nothing, but it would make a hive fetch and overwrite
a token file for a name the forger chose.

Refs #3726
2026-09-03 00:29:52 +02:00
..
src hive-c0re: deliver an agent's credential from the store to its state dir 2026-09-03 00:29:52 +02:00
Cargo.toml swarm-nats-auth: grant every hive the shared hive-notices stream subjects 2026-08-24 14:34:37 +02:00
README.md treefmt: apply prettier 2026-09-02 15:25:07 +02:00

swarm-nats-auth

The auth-callout responder for the swarm's NATS queue — the half that lets the server say yes.

nix/host-modules/swarm-nats.nix configures nats-server with an auth_callout block. That block with no responder is the fail-closed state, and it is the measured one rather than the obvious one: on the pinned nats-server, both authorization { } and authorization { users: [] } answer PONG to an anonymous client, while an auth_callout block sets auth_required and refuses every client no responder has approved. So the module ships the final config from the start and this crate only adds the ability to approve. No interim hole is ever opened.

Why a crate and not more config

The reply is a signed NATS user JWT. Signing needs the account nkey seed and the JWT framing, which is program work — which is why the container and its config landed first and this arrived separately, rather than the pair being one change.

The invariant that is easy to break

issuer_account must be absent from the issued user token. It is an operator-mode field. The module renders server-config mode (accounts { AUTH, APP }, no operator), where its mere presence makes the server refuse the client — Error non operator mode account "AUTH": attempted to use issuer_account — while the responder cheerfully reports granted=true. The account is named by the claims' aud instead.

nats_jwt::Token::new_user always sets it, so reaching for that constructor reintroduces the bug. nats-jwt is a dev-dependency: it cannot express aud on either token, and its role here is as the encoder's test oracle, not part of the path that runs.

Rules the code follows

  • A denial is a signed response carrying an error, never silence. The server cannot tell an absent responder from a refusing one, so staying quiet turns every rejection into a timeout and hides an outage inside what looks like ordinary denials.
  • Anything that is not an explicit {"active": true} denies — including an introspection call that could not be made. The failure modes of an HTTP call are exactly the conditions under which an attacker would most like this to fall open.
  • Every credential is a path, never a value. A value in nix config lands in the world-readable store; a value in argv is readable via /proc/<pid>/cmdline, which is 0444. Paths are not secrets, so passing them as flags is fine.
  • The introspection timeout is pinned below the server's authorization. timeout, with a test asserting the relation — a responder that answers after the server gave up is indistinguishable from one that never answered.

What the tests do and do not cover

Unit tests cover the JWT framing, including byte-equality against nats-jwt on the one shape that crate models. They are not sufficient on their own: this crate's shape is decided by a server that parses what it emits, so the change was also driven against a real nats-server — every refusal repeated with the responder live, because a responder that says yes to everyone passes "a client can connect" perfectly. That harness lives outside this repo; the PR that added this crate links it.

⚠️ Its introspection endpoint is a stub. Those runs prove this crate's own behaviour and nothing about the real authelia integration, which needs a deployment.