Every nats-server this module starts renders `auth_callout.xkey` or does not start, and the responder serves only that server, in the same container and generation. A request without `Nats-Server-Xkey` therefore comes from no supported server, so the plaintext framing is gone: such a request is logged at warn and gets no reply, and the server that sent it times out the connect as a denial. A signed denial would have had to go out unsealed, which is the path this removes. Also scopes two claims to what is true: the xkey's public half is read by the swarm-nats-auth policy only, and the metadata-delete gap in the replace-key procedure is about the two xkey paths, not all of `secret/metadata/`.
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. The responder
|
|
logs a warning for a request with no header, or one that doesn't open, and
|
|
sends no reply: the server it serves always seals, so no server waits on one.
|
|
|
|
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.
|