When nats-server has an auth_callout xkey it seals each request to that key and names its own public xkey in the Nats-Server-Xkey header. The responder now loads the callout xkey seed from the secret store before it connects, opens a sealed request with it, and seals its reply (grant or deny) back to the header's key. A request with no header is answered in plaintext, as before. A new `ensure-xkey` verb creates the pair when the store holds no seed: the seed at swarm/services/swarm-nats/callout/xkey-seed, the public half at swarm/public/swarm-nats/callout-xkey. Each write is a create-only (cas=0) write, and a run whose create fails reads the stored value back, so two concurrent runs end up with one key. The responder itself only reads the seed and refuses to start without it. swarm-secret-client gains SecretStore::create, the cas=0 write.
81 lines
4.1 KiB
Markdown
81 lines
4.1 KiB
Markdown
# 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.
|
|
|
|
## The sealed exchange
|
|
|
|
The server seals each auth request to the callout xkey and names its own
|
|
public xkey in the `Nats-Server-Xkey` header; the responder opens the request
|
|
with the xkey's seed and seals its reply to that header's key. It answers a
|
|
request with no header in plaintext, which is what a server with no xkey
|
|
configured reads.
|
|
|
|
The seed is in the swarm secret store, and the responder reads it before it
|
|
connects to the queue: a responder without it doesn't start. The
|
|
`ensure-xkey` verb creates the pair when the store holds none. It runs from
|
|
its own unit before the server, because the server needs the public half to
|
|
start and the responder needs the server. `docs/swarm/secrets.md` has the
|
|
paths and the boot order.
|
|
|
|
## 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.
|