diff --git a/swarm-nats-auth/README.md b/swarm-nats-auth/README.md new file mode 100644 index 00000000..9f154139 --- /dev/null +++ b/swarm-nats-auth/README.md @@ -0,0 +1,66 @@ +# 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//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.