Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32643dae36 | ||
|
|
9b35be2a24 | ||
|
|
5be2918559 | ||
|
|
b22f0ecaa1 | ||
|
|
188e27478a | ||
|
|
207fc4d2a6 | ||
|
|
3a75c54bcb |
11 changed files with 1245 additions and 10 deletions
200
Cargo.lock
generated
200
Cargo.lock
generated
|
|
@ -192,6 +192,38 @@ dependencies = [
|
|||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-nats"
|
||||
version = "0.50.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d83a251fa1a4c9d0fe6e816b7acd60549e473e08d14f27a1d992c2675abff05f"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"bytes",
|
||||
"futures-util",
|
||||
"memchr",
|
||||
"nkeys",
|
||||
"pin-project",
|
||||
"portable-atomic",
|
||||
"rand 0.10.2",
|
||||
"regex",
|
||||
"ring",
|
||||
"rustls-native-certs",
|
||||
"rustls-pki-types",
|
||||
"rustls-webpki",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_repr",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tokio-stream",
|
||||
"tokio-util",
|
||||
"tokio-websockets",
|
||||
"tracing",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-once-cell"
|
||||
version = "0.5.4"
|
||||
|
|
@ -449,6 +481,9 @@ name = "bytes"
|
|||
version = "1.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bytesize"
|
||||
|
|
@ -958,6 +993,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
|
||||
dependencies = [
|
||||
"const-oid 0.9.6",
|
||||
"pem-rfc7468",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
|
|
@ -1091,6 +1127,7 @@ dependencies = [
|
|||
"rand_core 0.6.4",
|
||||
"serde",
|
||||
"sha2 0.10.9",
|
||||
"signature",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
|
@ -1150,7 +1187,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -2018,7 +2055,7 @@ dependencies = [
|
|||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower-service",
|
||||
"webpki-roots",
|
||||
"webpki-roots 1.0.8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -2637,7 +2674,7 @@ dependencies = [
|
|||
"url",
|
||||
"urlencoding",
|
||||
"vodozemac",
|
||||
"webpki-roots",
|
||||
"webpki-roots 1.0.8",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
|
|
@ -2903,12 +2940,40 @@ dependencies = [
|
|||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nats-jwt"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e51104ed9bd9e7d1f9117603e1186a56a3a437ceaf0761383910180f5879b801"
|
||||
dependencies = [
|
||||
"data-encoding",
|
||||
"nkeys",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "new_debug_unreachable"
|
||||
version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
||||
|
||||
[[package]]
|
||||
name = "nkeys"
|
||||
version = "0.4.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "879011babc47a1c7fdf5a935ae3cfe94f34645ca0cac1c7f6424b36fc743d1bf"
|
||||
dependencies = [
|
||||
"data-encoding",
|
||||
"ed25519",
|
||||
"ed25519-dalek",
|
||||
"getrandom 0.2.17",
|
||||
"log",
|
||||
"rand 0.8.7",
|
||||
"signatory",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nu-ansi-term"
|
||||
version = "0.50.3"
|
||||
|
|
@ -3120,6 +3185,15 @@ dependencies = [
|
|||
"digest 0.10.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pem-rfc7468"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "percent-encoding"
|
||||
version = "2.3.2"
|
||||
|
|
@ -3176,6 +3250,26 @@ dependencies = [
|
|||
"siphasher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project"
|
||||
version = "1.1.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924"
|
||||
dependencies = [
|
||||
"pin-project-internal",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-internal"
|
||||
version = "1.1.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.17"
|
||||
|
|
@ -3407,7 +3501,7 @@ dependencies = [
|
|||
"once_cell",
|
||||
"socket2",
|
||||
"tracing",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -3648,7 +3742,7 @@ dependencies = [
|
|||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"webpki-roots",
|
||||
"webpki-roots 1.0.8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -3681,6 +3775,7 @@ dependencies = [
|
|||
"rustls-platform-verifier",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
|
|
@ -3693,7 +3788,7 @@ dependencies = [
|
|||
"wasm-bindgen-futures",
|
||||
"wasm-streams",
|
||||
"web-sys",
|
||||
"webpki-roots",
|
||||
"webpki-roots 1.0.8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -3945,7 +4040,7 @@ dependencies = [
|
|||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -4004,7 +4099,7 @@ dependencies = [
|
|||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -4226,6 +4321,17 @@ dependencies = [
|
|||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_repr"
|
||||
version = "0.1.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_spanned"
|
||||
version = "1.1.1"
|
||||
|
|
@ -4305,12 +4411,25 @@ dependencies = [
|
|||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "signatory"
|
||||
version = "0.27.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c1e303f8205714074f6068773f0e29527e0453937fe837c9717d066635b65f31"
|
||||
dependencies = [
|
||||
"pkcs8",
|
||||
"rand_core 0.6.4",
|
||||
"signature",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "signature"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
|
||||
dependencies = [
|
||||
"digest 0.10.7",
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
|
|
@ -4437,6 +4556,26 @@ dependencies = [
|
|||
"utoipa-axum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "swarm-nats-auth"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-nats",
|
||||
"clap",
|
||||
"data-encoding",
|
||||
"futures-util",
|
||||
"nats-jwt",
|
||||
"nkeys",
|
||||
"reqwest 0.13.1",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "swarmctl"
|
||||
version = "0.1.0"
|
||||
|
|
@ -4471,6 +4610,17 @@ dependencies = [
|
|||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sync_wrapper"
|
||||
version = "1.0.2"
|
||||
|
|
@ -4522,7 +4672,7 @@ dependencies = [
|
|||
"getrandom 0.4.3",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -4712,6 +4862,27 @@ dependencies = [
|
|||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-websockets"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f591660438b3038dd04d16c938271c79e7e06260ad2ea2885a4861bfb238605d"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"http",
|
||||
"httparse",
|
||||
"rand 0.8.7",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tokio-util",
|
||||
"webpki-roots 0.26.11",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "1.1.3+spec-1.1.0"
|
||||
|
|
@ -5288,6 +5459,15 @@ dependencies = [
|
|||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "0.26.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
|
||||
dependencies = [
|
||||
"webpki-roots 1.0.8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.8"
|
||||
|
|
@ -5325,7 +5505,7 @@ version = "0.1.11"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
22
Cargo.toml
22
Cargo.toml
|
|
@ -22,6 +22,7 @@ members = [
|
|||
"hive-types",
|
||||
"hivectl",
|
||||
"swarm-controller",
|
||||
"swarm-nats-auth",
|
||||
"swarmctl",
|
||||
]
|
||||
|
||||
|
|
@ -112,6 +113,10 @@ tokio-stream = { version = "0.1", features = ["sync"] }
|
|||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
reqwest = { version = "0.13", default-features = false, features = [
|
||||
# RFC 7662 introspection posts an urlencoded body; without this,
|
||||
# `.form()` does not exist and the alternative is percent-encoding a
|
||||
# credential by hand.
|
||||
"form",
|
||||
"json",
|
||||
"rustls",
|
||||
] }
|
||||
|
|
@ -135,5 +140,22 @@ matrix-sdk = { version = "0.18", default-features = false, features = [
|
|||
futures-util = "0.3"
|
||||
hmac = "0.13"
|
||||
sha2 = "0.11"
|
||||
# The NATS protocol client, for the swarm queue's auth-callout responder.
|
||||
# `default-features = false` because the default set is broad - jetstream, kv,
|
||||
# object-store, websockets, service - and a callout responder speaks none of
|
||||
# them. What is named is the whole requirement: the server generation we
|
||||
# deploy, nkey auth, and a TLS backend.
|
||||
async-nats = { version = "0.50", default-features = false, features = [
|
||||
"server_2_14",
|
||||
"nkeys",
|
||||
"ring",
|
||||
] }
|
||||
data-encoding = "2"
|
||||
# The nkey *format* - ed25519 + base32 + CRC16. The primitives are already in
|
||||
# the tree; the format is not, and hand-rolling a key format on an auth path
|
||||
# is how you get a CRC bug nobody reviews.
|
||||
nkeys = "0.4"
|
||||
# A TEST ORACLE, not a runtime dependency - see swarm-nats-auth's respond.rs.
|
||||
nats-jwt = "0.3"
|
||||
utoipa = { version = "5", features = ["axum_extras", "chrono"] }
|
||||
utoipa-axum = "0.2"
|
||||
|
|
|
|||
|
|
@ -151,6 +151,9 @@
|
|||
services.hyperhive.swarm.ui.package =
|
||||
lib.mkDefault
|
||||
self.packages.${pkgs.stdenv.hostPlatform.system}.swarm-ui;
|
||||
services.hyperhive.swarm.nats.authPackage =
|
||||
lib.mkDefault
|
||||
self.packages.${pkgs.stdenv.hostPlatform.system}.swarm-nats-auth;
|
||||
services.hyperhive.gateway.swaggerUiTheme =
|
||||
lib.mkDefault
|
||||
self.packages.${pkgs.stdenv.hostPlatform.system}.swagger-ui-theme;
|
||||
|
|
|
|||
|
|
@ -16,6 +16,22 @@ let
|
|||
# authorizes.
|
||||
calloutAccount = "AUTH";
|
||||
clientAccount = "APP";
|
||||
|
||||
machine = "swarm-nats";
|
||||
# Where the responder's credentials live *inside* the container, and the
|
||||
# host path that resolves to. Two names for one location, because the
|
||||
# host is the only place both filesystems are addressable.
|
||||
secretDirInContainer = "/var/lib/swarm-nats-auth";
|
||||
inContainer = name: "${secretDirInContainer}/${name}";
|
||||
secretDir = "/var/lib/nixos-containers/${machine}${secretDirInContainer}";
|
||||
hostPath = name: "${secretDir}/${name}";
|
||||
|
||||
# The responder needs all three credentials. Gating on them rather than
|
||||
# on `cfg.enable` keeps a half-configured hive at "queue up, denying
|
||||
# everyone" instead of "unit crash-looping on a missing file".
|
||||
responderConfigured = cfg.calloutUserSeedFile != "" && cfg.calloutIssuerSeedFile != "";
|
||||
clientSecretSource = "${autheliaCfg.hostClientSecretDir}/${cfg.clientId}.secret";
|
||||
introspectionUrl = "${toString autheliaUrl}/api/oidc/introspection";
|
||||
in
|
||||
{
|
||||
# The swarm's message queue: one NATS server, reached by every hive.
|
||||
|
|
@ -116,6 +132,55 @@ in
|
|||
rather than on purpose, so it fails at eval instead.
|
||||
'';
|
||||
};
|
||||
|
||||
authPackage = lib.mkOption {
|
||||
type = lib.types.package;
|
||||
defaultText = lib.literalExpression "hyperhive.packages.\${system}.swarm-nats-auth";
|
||||
description = ''
|
||||
The auth-callout responder package.
|
||||
|
||||
⚠️ Named `authPackage`, not `package`, on purpose: this module
|
||||
deliberately has **no** `package` option for the server itself
|
||||
(see the note above — upstream's `services.nats` resolves
|
||||
`pkgs.nats-server` on its own), so a bare `package` here would
|
||||
read as "the NATS package" and mean something else entirely.
|
||||
'';
|
||||
};
|
||||
|
||||
calloutUserSeedFile = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "";
|
||||
example = "/run/secrets/swarm-nats-callout-user.seed";
|
||||
description = ''
|
||||
Absolute host path to the **seed** whose public half is
|
||||
`calloutUserPublicKey`. The responder authenticates to the queue
|
||||
with it.
|
||||
|
||||
A `str` rather than a `path`, and the reason is not style: a
|
||||
`path`-typed literal is hash-copied into the world-readable nix
|
||||
store at eval time, which is the opposite of what a seed wants.
|
||||
Same discipline as `otel.headersCredential`.
|
||||
|
||||
Until this is set the responder cannot start, and the queue
|
||||
stays in its fail-closed state — which is the correct behaviour,
|
||||
not a gap.
|
||||
'';
|
||||
};
|
||||
|
||||
calloutIssuerSeedFile = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "";
|
||||
example = "/run/secrets/swarm-nats-issuer.seed";
|
||||
description = ''
|
||||
Absolute host path to the **account** seed whose public half is
|
||||
`calloutIssuerPublicKey`. The responder signs the user JWTs it
|
||||
issues with it, so possession of this file is the authority to
|
||||
admit anyone to the queue.
|
||||
|
||||
A `str` for the same store-leak reason as
|
||||
`calloutUserSeedFile`.
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
|
|
@ -276,6 +341,51 @@ in
|
|||
};
|
||||
};
|
||||
|
||||
# The auth-callout responder: the half that lets the server
|
||||
# above say *yes*. Without it the `auth_callout` block is a
|
||||
# door nobody can open, which is the deliberate interim state.
|
||||
#
|
||||
# ⚠️ It is gated on the seeds being configured rather than on
|
||||
# `cfg.enable`, so a half-configured hive gets a running,
|
||||
# refusing queue instead of a unit that crash-loops on a
|
||||
# missing file. A queue that denies everyone is a legible
|
||||
# failure; a restart loop is not.
|
||||
systemd.services.swarm-nats-auth = lib.mkIf responderConfigured {
|
||||
description = "swarm queue auth-callout responder";
|
||||
after = [ "nats.service" ];
|
||||
requires = [ "nats.service" ];
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
serviceConfig = {
|
||||
ExecStart = lib.concatStringsSep " " [
|
||||
"${cfg.authPackage}/bin/swarm-nats-auth"
|
||||
"--nats-url nats://127.0.0.1:${toString cfg.port}"
|
||||
"--user-seed-file \${CREDENTIALS_DIRECTORY}/callout-user.seed"
|
||||
"--issuer-seed-file \${CREDENTIALS_DIRECTORY}/issuer.seed"
|
||||
"--client-secret-file \${CREDENTIALS_DIRECTORY}/oidc-client.secret"
|
||||
"--client-id ${lib.escapeShellArg cfg.clientId}"
|
||||
# The account admitted clients land in, by NAME: in
|
||||
# server-config mode the server resolves `aud` against its
|
||||
# own `accounts` block, so this and the block above have to
|
||||
# be the same string — which is why both come from one let.
|
||||
"--account ${lib.escapeShellArg clientAccount}"
|
||||
"--introspection-url ${lib.escapeShellArg introspectionUrl}"
|
||||
];
|
||||
# Every credential arrives by `LoadCredential` and is named
|
||||
# on the command line only as a **path** — `argv` is
|
||||
# world-readable via /proc/<pid>/cmdline, so a value there
|
||||
# would be readable by every process on the host netns.
|
||||
LoadCredential = [
|
||||
"callout-user.seed:${inContainer "callout-user.seed"}"
|
||||
"issuer.seed:${inContainer "issuer.seed"}"
|
||||
"oidc-client.secret:${inContainer "oidc-client.secret"}"
|
||||
];
|
||||
DynamicUser = true;
|
||||
Restart = "on-failure";
|
||||
RestartSec = "5s";
|
||||
SyslogIdentifier = "swarm-nats-auth";
|
||||
};
|
||||
};
|
||||
|
||||
# The server binary, so an operator with a shell in here can
|
||||
# run `nats-server -t` against the generated config. The unit
|
||||
# resolves ExecStart through the store path and puts nothing
|
||||
|
|
@ -283,5 +393,33 @@ in
|
|||
environment.systemPackages = [ pkgs.nats-server ];
|
||||
};
|
||||
};
|
||||
|
||||
# Deliver the responder's three credentials into the container before
|
||||
# it starts. Same shape as `hive-matrix-oidc-secret`, and for the same
|
||||
# reason it is a copy rather than a `bindMounts` entry: nixos-container
|
||||
# refuses to start when a bind source is missing, so one absent seed
|
||||
# would take down the **whole container including the queue**, not
|
||||
# merely the responder. A far larger blast radius than the fault.
|
||||
systemd.services.swarm-nats-auth-secrets = lib.mkIf responderConfigured {
|
||||
description = "deliver the swarm queue responder's credentials";
|
||||
before = [ "container@swarm-nats.service" ];
|
||||
wantedBy = [ "container@swarm-nats.service" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
SyslogIdentifier = "swarm-nats-auth-secrets";
|
||||
};
|
||||
path = [ pkgs.coreutils ];
|
||||
script = ''
|
||||
set -euo pipefail
|
||||
install -d -m 0700 ${lib.escapeShellArg secretDir}
|
||||
install -m 0400 ${lib.escapeShellArg cfg.calloutUserSeedFile} \
|
||||
${lib.escapeShellArg (hostPath "callout-user.seed")}
|
||||
install -m 0400 ${lib.escapeShellArg cfg.calloutIssuerSeedFile} \
|
||||
${lib.escapeShellArg (hostPath "issuer.seed")}
|
||||
install -m 0400 ${lib.escapeShellArg clientSecretSource} \
|
||||
${lib.escapeShellArg (hostPath "oidc-client.secret")}
|
||||
'';
|
||||
};
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -150,6 +150,12 @@ in
|
|||
# Uses the same per-bin extractor, just bound on its own.
|
||||
swarm-controller = mkBinPackage "swarm-controller" "hyperhive swarm-level controller daemon";
|
||||
|
||||
# The queue's auth-callout responder. Out of `daemonBins` for the same
|
||||
# reason as the two above and one more: it runs *inside* the swarm-nats
|
||||
# container, not on the host, so it belongs in that container's closure
|
||||
# rather than every hive's.
|
||||
swarm-nats-auth = mkBinPackage "swarm-nats-auth" "hyperhive swarm queue auth-callout responder";
|
||||
|
||||
# The swarm operator's CLI, out of `daemonBins` for the same reason as
|
||||
# the daemon above: it is installed by the swarm-controller module on
|
||||
# the one host that runs the controller, and belongs in that hive's
|
||||
|
|
|
|||
41
swarm-nats-auth/Cargo.toml
Normal file
41
swarm-nats-auth/Cargo.toml
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
[package]
|
||||
name = "swarm-nats-auth"
|
||||
version.workspace = true
|
||||
readme = "README.md"
|
||||
edition.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "swarm-nats-auth"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
async-nats.workspace = true
|
||||
clap.workspace = true
|
||||
# base64url for decoding the inbound request JWT.
|
||||
data-encoding.workspace = true
|
||||
# StreamExt::next on the subscription: async-nats returns a Stream.
|
||||
futures-util.workspace = true
|
||||
nkeys.workspace = true
|
||||
reqwest.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
# The jti digest: base32hex(sha256(claims)) over every JWT this crate signs.
|
||||
sha2.workspace = true
|
||||
tokio.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
# A TEST ORACLE, not part of the production path. Neither JWT this crate emits
|
||||
# is expressible through it - `Claims` has no `aud`, which the response wrapper
|
||||
# needs (the server id) and the user token needs (the account name), and
|
||||
# `Token::new_user` always sets `issuer_account`, which a non-operator server
|
||||
# rejects outright. So both are hand-built, and this crate is what the encoder
|
||||
# is checked *against*: `respond::tests::hand_built_matches_the_reference`
|
||||
# builds a user token both ways and requires byte equality, on the one shape
|
||||
# nats-jwt does model.
|
||||
nats-jwt.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
66
swarm-nats-auth/README.md
Normal file
66
swarm-nats-auth/README.md
Normal file
|
|
@ -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/<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.
|
||||
109
swarm-nats-auth/src/introspect.rs
Normal file
109
swarm-nats-auth/src/introspect.rs
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
//! Validating a presented bearer token against the `IdP`.
|
||||
//!
|
||||
//! RFC 7662 token introspection: POST the token to authelia, which answers
|
||||
//! `{"active": true|false, ...}`. `active` is the whole verdict — this
|
||||
//! responder does not second-guess it, and does not inspect scopes, because a
|
||||
//! second place that decides who may connect is a second place to get it
|
||||
//! wrong.
|
||||
//!
|
||||
//! # Everything that is not an explicit `active: true` is a denial
|
||||
//!
|
||||
//! Network error, timeout, non-2xx, unparseable body, `active: false` — all
|
||||
//! deny. That is not defensive coding, it is the only shape that is safe: the
|
||||
//! failure modes of an HTTP call are exactly the conditions under which an
|
||||
//! attacker would most like this to fall open.
|
||||
//!
|
||||
//! # The timeout is not a tuning knob
|
||||
//!
|
||||
//! `swarm-nats.nix` sets `authorization.timeout = "2s"`, so the server stops
|
||||
//! waiting after two seconds and denies the connection anyway. An
|
||||
//! introspection call allowed to run longer than that cannot produce a useful
|
||||
//! answer — it can only hold a task open past the point where the result
|
||||
//! matters. The budget below is deliberately under the server's.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::Deserialize;
|
||||
|
||||
/// Kept under `swarm-nats.nix`'s `authorization.timeout = "2s"` — see the
|
||||
/// module docs. A slower answer is not a late success, it is a denial that
|
||||
/// already happened.
|
||||
pub const INTROSPECTION_TIMEOUT: Duration = Duration::from_millis(1500);
|
||||
|
||||
/// The one field this responder acts on. Authelia returns more (`sub`,
|
||||
/// `scope`, `exp`); modelling them would imply we check them.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct IntrospectionResponse {
|
||||
active: bool,
|
||||
}
|
||||
|
||||
/// Ask the `IdP` whether `token` is currently valid.
|
||||
///
|
||||
/// Returns `Ok(true)` **only** on a 2xx whose body says `active: true`.
|
||||
/// Every other outcome is `Ok(false)` with the reason logged, or `Err` when
|
||||
/// the call could not be made at all — callers must treat both as a denial.
|
||||
///
|
||||
/// The token is never logged, not even truncated: a prefix is enough to
|
||||
/// correlate across logs, and a credential that is 80% redacted is still a
|
||||
/// credential in a journal.
|
||||
pub async fn is_active(
|
||||
http: &reqwest::Client,
|
||||
url: &str,
|
||||
client_id: &str,
|
||||
client_secret: &str,
|
||||
token: &str,
|
||||
) -> Result<bool> {
|
||||
let resp = http
|
||||
.post(url)
|
||||
.basic_auth(client_id, Some(client_secret))
|
||||
.form(&[("token", token)])
|
||||
.timeout(INTROSPECTION_TIMEOUT)
|
||||
.send()
|
||||
.await
|
||||
.context("introspection request")?;
|
||||
|
||||
let status = resp.status();
|
||||
if !status.is_success() {
|
||||
tracing::warn!(%status, "introspection returned non-2xx; denying");
|
||||
return Ok(false);
|
||||
}
|
||||
let body: IntrospectionResponse = match resp.json().await {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = ?e, "introspection body did not parse; denying");
|
||||
return Ok(false);
|
||||
}
|
||||
};
|
||||
Ok(body.active)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_timeout_stays_under_the_servers() {
|
||||
// `swarm-nats.nix` sets `authorization.timeout = "2s"`. If someone
|
||||
// raises this constant past that, the extra time buys nothing: the
|
||||
// server has already denied the connection. This test is the only
|
||||
// thing tying the two numbers together, since they live in different
|
||||
// languages in different files.
|
||||
assert!(
|
||||
INTROSPECTION_TIMEOUT < Duration::from_secs(2),
|
||||
"introspection budget must stay under the server's auth_callout timeout"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_active_true_deserializes_to_a_grant() {
|
||||
let yes: IntrospectionResponse = serde_json::from_str(r#"{"active":true}"#).unwrap();
|
||||
assert!(yes.active);
|
||||
let no: IntrospectionResponse = serde_json::from_str(r#"{"active":false}"#).unwrap();
|
||||
assert!(!no.active);
|
||||
// A body that omits `active` is not a grant with a default - it is a
|
||||
// response we do not understand, and it must fail to parse rather
|
||||
// than deserialize to `false` quietly under some future `#[serde(default)]`.
|
||||
assert!(serde_json::from_str::<IntrospectionResponse>(r#"{"sub":"someone"}"#).is_err());
|
||||
}
|
||||
}
|
||||
181
swarm-nats-auth/src/main.rs
Normal file
181
swarm-nats-auth/src/main.rs
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
//! Auth-callout responder for the swarm's NATS queue.
|
||||
//!
|
||||
//! `nix/host-modules/swarm-nats.nix` configures `nats-server` with an
|
||||
//! `auth_callout` block and no responder, which is the fail-closed state: the
|
||||
//! server answers `"auth_required":true` and admits nobody. This binary is what
|
||||
//! makes it able to say *yes*.
|
||||
//!
|
||||
//! It connects as the one callout-exempt user (by nkey, never by name — the
|
||||
//! server refuses to start if that entry carries a username), subscribes to
|
||||
//! `$SYS.REQ.USER.AUTH`, validates the presented bearer token against
|
||||
//! authelia's introspection endpoint, and answers with a NATS user JWT signed
|
||||
//! by the account key. A rejection is answered explicitly: silence is
|
||||
//! indistinguishable from the responder being down, and the queue is the
|
||||
//! swarm's control path.
|
||||
//!
|
||||
//! # Secrets
|
||||
//!
|
||||
//! Every credential is taken as a **path**, never a value. Two reasons, both
|
||||
//! previously learned the hard way here: a value in nix config is rendered into
|
||||
//! the world-readable store, and a value in `argv` is readable by anyone via
|
||||
//! `/proc/<pid>/cmdline`, which is `0444`. Paths are not secrets, so passing
|
||||
//! them as flags is fine.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::Context;
|
||||
use clap::Parser;
|
||||
use futures_util::StreamExt;
|
||||
|
||||
mod introspect;
|
||||
mod request;
|
||||
mod respond;
|
||||
|
||||
/// Subject the NATS server publishes authorization requests on.
|
||||
const AUTH_SUBJECT: &str = "$SYS.REQ.USER.AUTH";
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(
|
||||
name = "swarm-nats-auth",
|
||||
about = "Auth-callout responder for the swarm NATS queue"
|
||||
)]
|
||||
struct Args {
|
||||
/// NATS server to connect to.
|
||||
#[arg(long, default_value = "nats://127.0.0.1:4222")]
|
||||
nats_url: String,
|
||||
|
||||
/// Path to the seed of the callout-exempt user this responder connects as.
|
||||
/// Its public half is `services.hyperhive.swarm.nats.calloutUserPublicKey`.
|
||||
#[arg(long)]
|
||||
user_seed_file: PathBuf,
|
||||
|
||||
/// Path to the account signing seed used to sign issued user JWTs. Its
|
||||
/// public half is `services.hyperhive.swarm.nats.calloutIssuerPublicKey`.
|
||||
#[arg(long)]
|
||||
issuer_seed_file: PathBuf,
|
||||
|
||||
/// Authelia's OIDC introspection endpoint.
|
||||
#[arg(long)]
|
||||
introspection_url: String,
|
||||
|
||||
/// `OAuth2` client id this responder introspects as. Must match
|
||||
/// `services.hyperhive.swarm.nats.clientId`, whose default this mirrors.
|
||||
#[arg(long, default_value = "swarm-nats")]
|
||||
client_id: String,
|
||||
|
||||
/// Account an admitted client is placed in. Must name an entry in the
|
||||
/// server's own `accounts` block — in server-config mode the account is
|
||||
/// resolved by *name*, so a value the server does not know is a grant it
|
||||
/// refuses. The module passes its `clientAccount`; the default mirrors it.
|
||||
#[arg(long, default_value = "APP")]
|
||||
account: String,
|
||||
|
||||
/// Path to this responder's own OIDC client secret.
|
||||
#[arg(long)]
|
||||
client_secret_file: PathBuf,
|
||||
}
|
||||
|
||||
/// Read a secret file and strip surrounding whitespace.
|
||||
///
|
||||
/// The trim matters: an `echo`-created seed file ends in a newline, and an
|
||||
/// nkey seed with a trailing byte is not a seed — it fails at parse with a
|
||||
/// message about encoding rather than about the file, which sends you looking
|
||||
/// in the wrong place. The value is never logged, and the error deliberately
|
||||
/// names only the path.
|
||||
fn read_secret(path: &std::path::Path) -> anyhow::Result<String> {
|
||||
let raw = std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
anyhow::bail!("{} is empty", path.display());
|
||||
}
|
||||
Ok(trimmed.to_owned())
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
|
||||
)
|
||||
.init();
|
||||
let args = Args::parse();
|
||||
|
||||
let client_secret = read_secret(&args.client_secret_file)?;
|
||||
let http = reqwest::Client::new();
|
||||
let issuer = nkeys::KeyPair::from_seed(&read_secret(&args.issuer_seed_file)?)
|
||||
.context("parse the account signing seed")?;
|
||||
let user_seed = read_secret(&args.user_seed_file)?;
|
||||
let client = async_nats::ConnectOptions::with_nkey(user_seed)
|
||||
.name("swarm-nats-auth")
|
||||
.connect(&args.nats_url)
|
||||
.await
|
||||
.with_context(|| format!("connect to {}", args.nats_url))?;
|
||||
let mut requests = client
|
||||
.subscribe(AUTH_SUBJECT)
|
||||
.await
|
||||
.with_context(|| format!("subscribe to {AUTH_SUBJECT}"))?;
|
||||
tracing::info!(
|
||||
nats_url = %args.nats_url,
|
||||
subject = AUTH_SUBJECT,
|
||||
"swarm-nats-auth: connected, awaiting authorization requests"
|
||||
);
|
||||
|
||||
while let Some(msg) = requests.next().await {
|
||||
// Decode failures are logged and dropped, never propagated: this loop
|
||||
// is the swarm's login path, and exiting on one malformed payload
|
||||
// would let any client take authentication down for everyone.
|
||||
let req = match request::decode(&msg.payload) {
|
||||
Ok(req) => req,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = ?e, "undecodable auth request, ignoring");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
// No token is a denial, not an error: an anonymous connect is a
|
||||
// normal thing for a client to attempt and an abnormal thing to
|
||||
// grant. Introspection is only reached once something was presented.
|
||||
let granted = match &req.connect_opts.auth_token {
|
||||
Some(token) => introspect::is_active(
|
||||
&http,
|
||||
&args.introspection_url,
|
||||
&args.client_id,
|
||||
&client_secret,
|
||||
token,
|
||||
)
|
||||
.await
|
||||
// An introspection that could not be *made* is a denial too. The
|
||||
// failure modes of an HTTP call are exactly the conditions under
|
||||
// which an attacker would most like this to fall open.
|
||||
.unwrap_or_else(|e| {
|
||||
tracing::warn!(error = ?e, "introspection failed; denying");
|
||||
false
|
||||
}),
|
||||
None => false,
|
||||
};
|
||||
tracing::info!(
|
||||
user_nkey = %req.user_nkey,
|
||||
server_id = %req.server_id.id,
|
||||
granted,
|
||||
"auth request"
|
||||
);
|
||||
|
||||
// Always reply, including on a denial. A server that hears nothing
|
||||
// cannot tell a refusing responder from a dead one, so silence turns
|
||||
// every rejection into a 2s timeout and hides an outage inside what
|
||||
// looks like ordinary denials.
|
||||
let Some(reply_to) = msg.reply.clone() else {
|
||||
tracing::warn!("auth request had no reply subject; dropping");
|
||||
continue;
|
||||
};
|
||||
let token = if granted {
|
||||
respond::grant(&issuer, &args.account, &req.server_id.id, &req.user_nkey)
|
||||
} else {
|
||||
respond::deny(&issuer, &req.server_id.id, &req.user_nkey)
|
||||
};
|
||||
if let Err(e) = client.publish(reply_to, token.into()).await {
|
||||
tracing::warn!(error = ?e, "failed to publish auth response");
|
||||
}
|
||||
}
|
||||
anyhow::bail!("subscription to {AUTH_SUBJECT} ended")
|
||||
}
|
||||
134
swarm-nats-auth/src/request.rs
Normal file
134
swarm-nats-auth/src/request.rs
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
//! Decoding the server's authorization request.
|
||||
//!
|
||||
//! The payload on `$SYS.REQ.USER.AUTH` is a NATS JWT: three base64url
|
||||
//! (no-pad) segments separated by `.`. The middle segment is the claim set,
|
||||
//! and its `nats` object carries what the client presented.
|
||||
//!
|
||||
//! Decoding is deliberately hand-rolled while *minting* is not. The asymmetry
|
||||
//! is the point: producing a signed artifact wrongly is silent — the server
|
||||
//! rejects it and the reason is on the far side — whereas mis-parsing an
|
||||
//! inbound one fails loudly on the very next field. `nats-jwt` is also
|
||||
//! encode-only, so there is nothing to reuse here.
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use serde::Deserialize;
|
||||
|
||||
/// The subset of an `authorization_request` claim this responder acts on.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AuthRequest {
|
||||
/// Everything the connecting client sent in its `CONNECT` line.
|
||||
pub connect_opts: ConnectOpts,
|
||||
/// Ephemeral user nkey the server minted for this connection. The user
|
||||
/// JWT we issue must be addressed to it, not to any name the client
|
||||
/// chose — a client-supplied identity is a request, not a fact.
|
||||
pub user_nkey: String,
|
||||
/// Server this request came from; echoed back in the response so a
|
||||
/// reply cannot be replayed at a different server in the cluster.
|
||||
pub server_id: ServerId,
|
||||
}
|
||||
|
||||
/// The client-controlled half of the request. Every field here is attacker
|
||||
/// input: it is whatever the connecting client typed, not something the
|
||||
/// server vouches for.
|
||||
///
|
||||
/// The wire object also carries `user` / `pass` when a client authenticates
|
||||
/// that way. They are deliberately **not** modelled: the swarm authenticates
|
||||
/// against the `IdP`, and a field that exists in this struct is a field
|
||||
/// someone will eventually branch on.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ConnectOpts {
|
||||
/// Bearer token, when the client presented one. The only field this
|
||||
/// responder acts on.
|
||||
#[serde(default)]
|
||||
pub auth_token: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ServerId {
|
||||
pub id: String,
|
||||
}
|
||||
|
||||
/// Split a NATS JWT and deserialize its claim set's `nats` object.
|
||||
///
|
||||
/// The signature is **not** verified here — see the module docs on
|
||||
/// `main.rs` for why that is a decision and not an oversight.
|
||||
pub fn decode(payload: &[u8]) -> Result<AuthRequest> {
|
||||
/// Only the `nats` object is modelled; `iss`/`sub`/`exp` are the server's
|
||||
/// business, and claiming to understand fields we do not act on would
|
||||
/// invite someone to trust them.
|
||||
#[derive(Deserialize)]
|
||||
struct Claims {
|
||||
nats: AuthRequest,
|
||||
}
|
||||
|
||||
let text = std::str::from_utf8(payload).context("auth request is not utf-8")?;
|
||||
let mut parts = text.split('.');
|
||||
// Exactly three segments: a fourth means this is not the shape we think
|
||||
// it is, and a lenient split is how a parser starts accepting things the
|
||||
// producer never meant to send.
|
||||
let (Some(_header), Some(claims), Some(_sig), None) =
|
||||
(parts.next(), parts.next(), parts.next(), parts.next())
|
||||
else {
|
||||
bail!("auth request is not a three-segment JWT")
|
||||
};
|
||||
let raw = base64_url_decode(claims).context("claim segment is not base64url")?;
|
||||
|
||||
let claims: Claims =
|
||||
serde_json::from_slice(&raw).context("claim segment is not the expected JSON")?;
|
||||
Ok(claims.nats)
|
||||
}
|
||||
|
||||
/// base64url without padding, as JWT uses. Written out rather than pulled in:
|
||||
/// `data-encoding` is already in the tree via `nkeys`, but its no-pad URL
|
||||
/// alphabet spec is one line either way and this keeps the direct dependency
|
||||
/// list honest about what this crate actually needs.
|
||||
fn base64_url_decode(s: &str) -> Result<Vec<u8>> {
|
||||
data_encoding::BASE64URL_NOPAD
|
||||
.decode(s.as_bytes())
|
||||
.context("base64url decode")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn jwt_with(claims: &str) -> String {
|
||||
let b = |s: &str| data_encoding::BASE64URL_NOPAD.encode(s.as_bytes());
|
||||
format!("{}.{}.{}", b("{}"), b(claims), b("sig"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_a_token_request() {
|
||||
let req = decode(
|
||||
jwt_with(
|
||||
r#"{"nats":{"connect_opts":{"auth_token":"t0k"},
|
||||
"user_nkey":"UABC","server_id":{"id":"NDEADBEEF"}}}"#,
|
||||
)
|
||||
.as_bytes(),
|
||||
)
|
||||
.expect("decode");
|
||||
assert_eq!(req.connect_opts.auth_token.as_deref(), Some("t0k"));
|
||||
assert_eq!(req.user_nkey, "UABC");
|
||||
assert_eq!(req.server_id.id, "NDEADBEEF");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_request_without_a_token_is_still_well_formed() {
|
||||
// An anonymous connect must parse, then be *rejected* by policy —
|
||||
// not fail to parse. Conflating "malformed" with "unauthorized"
|
||||
// loses the ability to tell a broken server from an attacker.
|
||||
let req = decode(
|
||||
jwt_with(r#"{"nats":{"connect_opts":{},"user_nkey":"U","server_id":{"id":"N"}}}"#)
|
||||
.as_bytes(),
|
||||
)
|
||||
.expect("decode");
|
||||
assert!(req.connect_opts.auth_token.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_a_payload_that_is_not_a_jwt() {
|
||||
assert!(decode(b"not-a-jwt").is_err());
|
||||
assert!(decode(b"two.parts").is_err());
|
||||
assert!(decode(b"four.parts.here.too").is_err());
|
||||
}
|
||||
}
|
||||
355
swarm-nats-auth/src/respond.rs
Normal file
355
swarm-nats-auth/src/respond.rs
Normal file
|
|
@ -0,0 +1,355 @@
|
|||
//! Minting the reply the server expects on `msg.reply`.
|
||||
//!
|
||||
//! Both JWTs are hand-built because neither is expressible through
|
||||
//! `nats-jwt`: its `Claims` has no `aud`, which the `authorization_response`
|
||||
//! wrapper needs (the asking server's id, so a captured reply cannot be
|
||||
//! replayed elsewhere in the cluster) and the user token needs (the account
|
||||
//! name). Its `IntoNatsClaims` also returns a closed
|
||||
//! `NatsClaims { User, Account }`.
|
||||
//!
|
||||
//! ⛔ **`issuer_account` must be ABSENT from the user token.** It is an
|
||||
//! operator-mode field, and `nix/host-modules/swarm-nats.nix` renders
|
||||
//! *server-config* mode, where its mere presence makes the server refuse the
|
||||
//! client — `Error non operator mode account "AUTH": attempted to use
|
||||
//! issuer_account`, with the responder having answered `granted=true`.
|
||||
//! `nats_jwt::Token::new_user` always sets it, which is why reaching for that
|
||||
//! constructor again would reintroduce the bug.
|
||||
//!
|
||||
//! Signing follows `nats-jwt`'s own `sign()`: serialise the claims with `jti`
|
||||
//! **empty**, sha256 that, `BASE32HEX_NOPAD` the digest into `jti`,
|
||||
//! re-serialise, sign `"<b64url header>.<b64url claims>"` with the account
|
||||
//! key. ⚠️ `BASE32HEX`, not `BASE32` — one word, and the only symptom is a
|
||||
//! token the server rejects without saying why.
|
||||
//!
|
||||
//! `nats-jwt` is therefore a **dev-dependency, not a runtime one**: it is this
|
||||
//! module's test oracle: `tests::hand_built_matches_the_reference` builds a
|
||||
//! user token both ways and requires byte equality, so the encoder is checked
|
||||
//! against the reference on the one shape that crate does model.
|
||||
|
||||
use data_encoding::{BASE32HEX_NOPAD, BASE64URL_NOPAD};
|
||||
use nkeys::KeyPair;
|
||||
use serde::Serialize;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
/// Header every NATS JWT carries. Byte-identical to `nats-jwt`'s, and the
|
||||
/// equality test would catch it drifting.
|
||||
const JWT_HEADER: &str = r#"{"typ":"JWT","alg":"ed25519-nkey"}"#;
|
||||
|
||||
/// A NATS JWT claim set, with `aud` — which the callout response needs and
|
||||
/// `nats-jwt`'s own `Claims` lacks.
|
||||
#[derive(Serialize)]
|
||||
struct Claims<T: Serialize> {
|
||||
iat: i64,
|
||||
iss: String,
|
||||
jti: String,
|
||||
sub: String,
|
||||
name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
aud: Option<String>,
|
||||
nats: T,
|
||||
}
|
||||
|
||||
/// The `nats` object of an `authorization_response`.
|
||||
///
|
||||
/// Exactly one of `jwt` / `error` is set. They are `Option`s rather than an
|
||||
/// enum so the serialised shape matches the wire format directly, and the
|
||||
/// constructors below are the only way to build one — a caller cannot
|
||||
/// accidentally produce a response that carries both, which a server would be
|
||||
/// free to read either way.
|
||||
#[derive(Serialize)]
|
||||
struct AuthResponse {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
jwt: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<String>,
|
||||
#[serde(rename = "type")]
|
||||
kind: &'static str,
|
||||
version: i64,
|
||||
}
|
||||
|
||||
/// Sign a claim set the way `nats-jwt` does. `now` is injected so the test
|
||||
/// can pin it; production passes the wall clock.
|
||||
fn sign<T: Serialize>(mut claims: Claims<T>, key: &KeyPair) -> String {
|
||||
claims.jti = String::new();
|
||||
let unhashed = serde_json::to_string(&claims).expect("claims serialisation cannot fail");
|
||||
let digest = Sha256::digest(unhashed.as_bytes());
|
||||
claims.jti = BASE32HEX_NOPAD.encode(&digest);
|
||||
|
||||
let body = serde_json::to_string(&claims).expect("claims serialisation cannot fail");
|
||||
let half = format!(
|
||||
"{}.{}",
|
||||
BASE64URL_NOPAD.encode(JWT_HEADER.as_bytes()),
|
||||
BASE64URL_NOPAD.encode(body.as_bytes())
|
||||
);
|
||||
let sig = key
|
||||
.sign(half.as_bytes())
|
||||
.expect("ed25519 signing cannot fail");
|
||||
format!("{half}.{}", BASE64URL_NOPAD.encode(&sig))
|
||||
}
|
||||
|
||||
/// Wrap a decision in an `authorization_response` addressed to the server
|
||||
/// that asked.
|
||||
///
|
||||
/// 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 behind
|
||||
/// what looks like normal denials.
|
||||
fn response(
|
||||
now: i64,
|
||||
issuer: &KeyPair,
|
||||
server_id: &str,
|
||||
user_nkey: &str,
|
||||
verdict: Result<String, String>,
|
||||
) -> String {
|
||||
let (jwt, error) = match verdict {
|
||||
Ok(user_jwt) => (Some(user_jwt), None),
|
||||
Err(reason) => (None, Some(reason)),
|
||||
};
|
||||
sign(
|
||||
Claims {
|
||||
iat: now,
|
||||
iss: issuer.public_key(),
|
||||
jti: String::new(),
|
||||
sub: user_nkey.to_owned(),
|
||||
name: user_nkey.to_owned(),
|
||||
aud: Some(server_id.to_owned()),
|
||||
nats: AuthResponse {
|
||||
jwt,
|
||||
error,
|
||||
kind: "authorization_response",
|
||||
version: 2,
|
||||
},
|
||||
},
|
||||
issuer,
|
||||
)
|
||||
}
|
||||
|
||||
/// Current unix seconds, as the JWT `iat`.
|
||||
fn now_secs() -> i64 {
|
||||
i64::try_from(
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.expect("system time is after the unix epoch")
|
||||
.as_secs(),
|
||||
)
|
||||
.expect("seconds since the epoch fit in an i64")
|
||||
}
|
||||
|
||||
/// The `nats` object of a **user** JWT, server-config mode.
|
||||
///
|
||||
/// Field-for-field what `nats-jwt` emits **minus `issuer_account`**, whose
|
||||
/// presence a non-operator server rejects outright (see the module docs). The
|
||||
/// account is named by the enclosing claims' `aud` instead.
|
||||
///
|
||||
/// The `-1`s are NATS' "unlimited": no cap on subscriptions, payload size or
|
||||
/// total data. Limits belong on the account, which the module owns; putting
|
||||
/// them here would mean every issued token silently disagreed with the
|
||||
/// deployed policy the moment that policy changed.
|
||||
#[derive(Serialize)]
|
||||
struct UserNats {
|
||||
#[serde(rename = "type")]
|
||||
kind: &'static str,
|
||||
subs: i64,
|
||||
data: i64,
|
||||
payload: i64,
|
||||
bearer_token: bool,
|
||||
version: i64,
|
||||
}
|
||||
|
||||
/// Mint the user JWT an admitted client presents.
|
||||
///
|
||||
/// `account` is the account **name** from the server's `accounts` block (the
|
||||
/// module's `clientAccount`), not a public key — in config mode the server
|
||||
/// resolves `aud` against its own config rather than against a key.
|
||||
fn user_jwt(now: i64, issuer: &KeyPair, account: &str, user_nkey: &str) -> String {
|
||||
sign(
|
||||
Claims {
|
||||
iat: now,
|
||||
iss: issuer.public_key(),
|
||||
jti: String::new(),
|
||||
sub: user_nkey.to_owned(),
|
||||
name: user_nkey.to_owned(),
|
||||
aud: Some(account.to_owned()),
|
||||
nats: UserNats {
|
||||
kind: "user",
|
||||
subs: -1,
|
||||
data: -1,
|
||||
payload: -1,
|
||||
bearer_token: false,
|
||||
version: 2,
|
||||
},
|
||||
},
|
||||
issuer,
|
||||
)
|
||||
}
|
||||
|
||||
/// Grant: mint a user JWT placing the client in `account` and wrap it.
|
||||
pub fn grant(issuer: &KeyPair, account: &str, server_id: &str, user_nkey: &str) -> String {
|
||||
let now = now_secs();
|
||||
let jwt = user_jwt(now, issuer, account, user_nkey);
|
||||
response(now, issuer, server_id, user_nkey, Ok(jwt))
|
||||
}
|
||||
|
||||
/// Deny, with a reason the server can log.
|
||||
///
|
||||
/// The reason is deliberately coarse (`"unauthorized"`), not a description of
|
||||
/// *why*: this string reaches an unauthenticated peer, and a precise one turns
|
||||
/// the auth path into an oracle for which tokens exist.
|
||||
pub fn deny(issuer: &KeyPair, server_id: &str, user_nkey: &str) -> String {
|
||||
response(
|
||||
now_secs(),
|
||||
issuer,
|
||||
server_id,
|
||||
user_nkey,
|
||||
Err("unauthorized".to_owned()),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Mirror of what `nats-jwt` puts in a user token, **in its field order**.
|
||||
///
|
||||
/// Deliberately *not* [`super::UserNats`]: this one carries
|
||||
/// `issuer_account`, because the reference does. Production must not, and
|
||||
/// keeping the two structs separate is what lets one test pin the encoder
|
||||
/// against `nats-jwt` while another asserts production diverges from it in
|
||||
/// exactly one field.
|
||||
///
|
||||
/// 🩸 The first version of the equality test round-tripped `nats` through
|
||||
/// `serde_json::Value` and failed — not because the encoder was wrong, but
|
||||
/// because `Value` is a `BTreeMap`, so re-serialising sorted the keys and
|
||||
/// changed the bytes. The gate's *method* was the defect. Worth keeping the
|
||||
/// scar: `jti` is a hash over serialised claims, so anything that reorders
|
||||
/// fields between hashing and signing is a live hazard here, not merely a
|
||||
/// test artefact.
|
||||
#[derive(serde::Serialize)]
|
||||
struct ReferenceUserNats {
|
||||
#[serde(rename = "type")]
|
||||
kind: &'static str,
|
||||
issuer_account: String,
|
||||
subs: i64,
|
||||
data: i64,
|
||||
payload: i64,
|
||||
bearer_token: bool,
|
||||
version: i64,
|
||||
}
|
||||
|
||||
/// The gate that makes the hand-built encoder trustworthy: build a *user*
|
||||
/// token with `nats-jwt` and with this module's `sign`, and require the
|
||||
/// bytes to be identical.
|
||||
///
|
||||
/// It works because `nats-jwt`'s `sign()` derives `iat` from the wall
|
||||
/// clock and everything else from its inputs, so signing the same claims
|
||||
/// in the same second must produce the same string. If this module's
|
||||
/// header, field order, `jti` alphabet or signing input ever drifts from
|
||||
/// the reference, this fails — including the `BASE32HEX`-vs-`BASE32`
|
||||
/// distinction, which is otherwise invisible until a server refuses a
|
||||
/// token.
|
||||
#[test]
|
||||
fn hand_built_matches_the_reference() {
|
||||
let account = KeyPair::new_account();
|
||||
let user = KeyPair::new_user();
|
||||
let reference =
|
||||
nats_jwt::Token::new_user(account.public_key(), user.public_key()).sign(&account);
|
||||
|
||||
// Mirror what `nats-jwt` puts in a user token **as a struct**, in its
|
||||
// field order.
|
||||
//
|
||||
// 🩸 The first version of this test round-tripped `nats` through
|
||||
// `serde_json::Value` and failed — not because the encoder was wrong,
|
||||
// but because `Value` is a `BTreeMap`, so re-serialising sorted the
|
||||
// keys and changed the bytes. The gate's *method* was the defect, and
|
||||
// it is worth keeping the scar: `jti` is a hash over serialised
|
||||
// claims, so anything that reorders fields between hashing and
|
||||
// signing is a live hazard here, not just a test artefact.
|
||||
let decoded = decode_claims(&reference);
|
||||
let mine = sign(
|
||||
Claims {
|
||||
iat: decoded["iat"].as_i64().expect("iat"),
|
||||
iss: account.public_key(),
|
||||
jti: String::new(),
|
||||
sub: user.public_key(),
|
||||
name: user.public_key(),
|
||||
aud: None,
|
||||
nats: ReferenceUserNats {
|
||||
kind: "user",
|
||||
issuer_account: account.public_key(),
|
||||
subs: -1,
|
||||
data: -1,
|
||||
payload: -1,
|
||||
bearer_token: false,
|
||||
version: 2,
|
||||
},
|
||||
},
|
||||
&account,
|
||||
);
|
||||
assert_eq!(mine, reference, "hand-built encoder drifted from nats-jwt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_denial_is_signed_and_carries_no_jwt() {
|
||||
let account = KeyPair::new_account();
|
||||
let token = deny(&account, "NSERVER", "UCLIENT");
|
||||
let claims = decode_claims(&token);
|
||||
assert_eq!(claims["nats"]["type"], "authorization_response");
|
||||
assert_eq!(claims["nats"]["error"], "unauthorized");
|
||||
assert!(
|
||||
claims["nats"]["jwt"].is_null(),
|
||||
"a denial must not carry a user jwt"
|
||||
);
|
||||
// `aud` binds the reply to the asking server; without it a captured
|
||||
// response is replayable at any other server in the cluster.
|
||||
assert_eq!(claims["aud"], "NSERVER");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_grant_carries_a_user_jwt_and_no_error() {
|
||||
let account = KeyPair::new_account();
|
||||
let claims = decode_claims(&grant(&account, "APP", "NSERVER", "UCLIENT"));
|
||||
assert!(claims["nats"]["error"].is_null());
|
||||
assert!(
|
||||
claims["nats"]["jwt"]
|
||||
.as_str()
|
||||
.is_some_and(|j| j.contains('.'))
|
||||
);
|
||||
}
|
||||
|
||||
/// 🩸 THE REGRESSION TEST FOR THE BUG THE UNIT TESTS COULD NOT SEE.
|
||||
///
|
||||
/// The first version of this module minted the user JWT with
|
||||
/// `nats_jwt::Token::new_user`, which always sets `issuer_account`. Every
|
||||
/// test passed, the responder logged `granted=true`, and the server still
|
||||
/// refused every client: `Error non operator mode account "AUTH":
|
||||
/// attempted to use issuer_account`.
|
||||
///
|
||||
/// The lesson is in what this asserts — an **absence**. The suite was full
|
||||
/// of assertions about fields that had to be *present*, and a field that
|
||||
/// must not exist is invisible to all of them. It took running a real
|
||||
/// server to find, so the cheap check goes here to keep it found.
|
||||
#[test]
|
||||
fn the_issued_user_jwt_names_its_account_by_aud_and_sets_no_issuer_account() {
|
||||
let account = KeyPair::new_account();
|
||||
let wrapper = decode_claims(&grant(&account, "APP", "NSERVER", "UCLIENT"));
|
||||
let user = decode_claims(wrapper["nats"]["jwt"].as_str().expect("a user jwt"));
|
||||
|
||||
// In server-config mode the account is named by `aud`...
|
||||
assert_eq!(user["aud"], "APP", "the user jwt must name its account");
|
||||
// ...and `issuer_account` is an operator-mode field whose mere
|
||||
// presence makes the server reject the client.
|
||||
assert!(
|
||||
user["nats"]["issuer_account"].is_null(),
|
||||
"issuer_account is fatal in non-operator mode"
|
||||
);
|
||||
assert_eq!(user["nats"]["type"], "user");
|
||||
assert_eq!(user["sub"], "UCLIENT");
|
||||
assert_eq!(user["iss"], account.public_key());
|
||||
}
|
||||
|
||||
fn decode_claims(jwt: &str) -> serde_json::Value {
|
||||
let body = jwt.split('.').nth(1).expect("claims segment");
|
||||
serde_json::from_slice(&BASE64URL_NOPAD.decode(body.as_bytes()).expect("base64url"))
|
||||
.expect("claims json")
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue