v0.20.0 mtls hardening, fail-closed config, startup warnings, sink fixes

This commit is contained in:
lixen committed 2026-10-02 05:00:33 -04:00
1 parent 815ef345ea
commit f192c26fa2
37 files changed
+1042 -152

No files matched your search

+243
View File
@@ -0,0 +1,243 @@
# Password Authentication (Argon2id-SCRAM)
**Status:** planned. Ships in two batches:
1. **Done:** `lixenwraith/auth` gained SCRAM channel binding, and the mTLS layer
was hardened (see [mTLS Hardening](#mtls-hardening)).
2. **Next:** the SCRAM integration described here, pinned to the tagged `auth`
release that carries channel binding.
**Scope:** optional username/password authentication on every network plugin,
next to the existing certificate method, without weakening what mTLS gives.
## Goals
- Password authentication for chain links (`tcp_chain`, `http_chain`: sources
verify, sinks present) and for viewers of the `tcp` and `http` sinks.
- No password or password-equivalent on the wire, no KDF work on listeners, and
no credential relay through a TLS-terminating MITM.
- Default stays `auth.type = "none"`; existing configurations are unaffected.
- Config in TOML, credential management and viewer clients in a `logwisp auth`
CLI.
## Non-Goals
- Standard SASL SCRAM-SHA-256 interoperability. The `auth` exchange is
logwisp-to-logwisp (or logwisp CLI) only.
- Browser `EventSource` viewers under SCRAM: they cannot set `Authorization` or
run Argon2. mTLS remains the browser path.
- Multiple backends behind a non-sticky L7 balancer: handshake state and the
token key live in one process.
## Mechanism
`lixenwraith/auth` Argon2id-SCRAM. The listener stores a verifier per user:
`StoredKey = SHA-256(HMAC(K, "Client Key"))`, `ServerKey = HMAC(K, "Server Key")`,
`K = Argon2id(password, salt)`. The Argon2id digest itself is never stored: it is
the salted password, from which both keys derive. Verifying a proof is HMAC only;
Argon2 runs on the dialer or CLI (64 MiB, t=3, the `auth` defaults).
**Channel binding.** Both peers pass `auth.WithChannelBinding(SHA-256(server leaf
DER))` at the proof step. The server hashes its configured certificate; the
client hashes the certificate it saw. A relay presenting any other certificate,
even one the CA trusts, makes the server reject the proof, so it never obtains a
link or a token. A mismatch is indistinguishable from a wrong password by design;
the client also sends its hash as a diagnostic field so the listener can log
"TLS interception or terminating proxy" and count it. TLS must terminate at
logwisp.
**HTTP pinning.** HTTP auth takes two requests and every later request may use a
new pooled connection, so binding the exchange is not enough. After step 1 the
dialer pins the bound hash on its `VerifyConnection`: step 2 and every ingest
request must present the same leaf. A mismatch clears token and pin and
re-authenticates, which also covers certificate rotation. A token handed to curl
by the CLI is unpinned.
## Configuration
```toml
# listener (tcp_chain/http_chain source, tcp/http sink)
[pipelines.plugin_sources.config.auth]
type = "scram"
credentials_file = "/etc/logwisp/users.toml"
token_lifetime_ms = 900000 # http sink and http_chain source only
node_binding = "force" # chain sources; binds to the username
# dialer (tcp_chain/http_chain sink)
[pipelines.plugin_sinks.config.auth]
type = "scram"
username = "edge-01"
password_file = "/etc/logwisp/edge-01.pass"
```
Validation at construction:
- `scram` requires TLS; a dialer also forbids `insecure_skip_verify`.
- Listeners require `credentials_file` and reject `allow`/`allow_patterns`: the
credentials file is the allow list. Use one file per listener when listeners
admit different users.
- Dialers require `username` + `password_file`, and reject `credentials_file`,
`allow*` and `identity` (server pinning stays an mTLS feature).
- `identity` on a listener requires `tls.client_auth` and **binds the certificate
to the user**: the certificate's identity field must equal the SCRAM username,
so a peer needs its own certificate and its own password (PostgreSQL's
`clientcert=verify-full`). Without it the factors are independent: any
CA-issued certificate plus any valid password (`clientcert=verify-ca`).
- `token_lifetime_ms` only on HTTP listeners; default 15 minutes.
- `/auth` is reserved; no configured path may equal it.
**Credentials file** (TOML, written by the CLI, mode 0600):
```toml
decoy_key = "<base64, 32 random bytes>"
[[users]]
username = "edge-01"
salt = "<base64>"
argon_time = 3
argon_memory = 65536
argon_threads = 4
stored_key = "<base64>"
server_key = "<base64>"
```
`decoy_key` keeps unknown-user challenges stable across restarts and edits, so
probing usernames reveals nothing. It is created by the first `add-user` and
preserved by every rewrite. An empty user set, a duplicate user, a missing
`decoy_key` or mixed KDF profiles are configuration errors. The file is read at
construction; the SCRAM server starts in the plugin's `Start` and stops in `Stop`,
so a rejected reload leaks nothing.
## Wire Protocol
`chain.Hello` gains `scram` (raw JSON, decoded by `authz`); the protocol version
stays 1 because the field is additive. Every message after the hello is one
`authStep`: `error`, `challenge`, `proof` (+ `binding`), `final` (+ `token`,
`expires_in` on HTTP). Pre-auth lines are capped at 4 KiB.
**TCP** (`tcp_chain`, `tcp` sink): hello carrying the client-first message,
challenge, proof, final — all under one deadline (`hello_timeout_ms` on the
`tcp_chain` source, 10 s on the `tcp` sink and every dialer, with the dialer's
context able to cut it short). The final is written only after every admission
check (certificate binding, node binding); the stream then continues on the same
buffered reader. A source without SCRAM answers a hello carrying credentials with
`error: authentication not enabled`; only an old binary stays silent, and the
dialer's deadline covers that.
**HTTP** (`http_chain` source, `http` sink): `POST /auth`, outside the auth
middleware, with its own 10 s read/write deadline and a 4 KiB body limit.
| Step | Request body | Success | Failure |
|------|--------------|---------|---------|
| 1 | hello | `200` challenge | `429` throttled, `503` handshake table full, `400` malformed |
| 2 | proof | `200` final, token, expires_in | `401 {"error":"authentication failed"}` |
Protected endpoints take `Authorization: Bearer <token>`: missing or invalid is
`401` with `WWW-Authenticate: Bearer realm="logwisp"`, a certificate-binding miss
is `403`. Tokens are HS256 JWTs from `auth.NewJWT` with a random per-instance key,
so every reload revokes them. An SSE stream is checked at connect and outlives its
token; a reload ends it.
**Dialer outcomes** (`http_chain` sink): every `/auth` failure is transient (the
batch is held under backoff); `404`/`405` from `/auth` logs "no auth endpoint:
older logwisp or auth.type is not scram"; an ingest `401` clears the token and is
retried; `403` drops the batch; a pin mismatch clears token and pin. Rejections
are logged at WARN once per change of state and counted in `auth_failures` /
`last_auth_error`.
## Throttling
Per remote IP (the socket address, never a forwarded header), on handshake
starts: a token bucket (burst 10, 1/s) refunded on success, and at most 4
unfinished exchanges. The table is bounded and fails closed when full. Abandoned
TCP exchanges release their slot in the `auth` handshake table immediately.
Counters: `auth_allowed` and `auth_rejected` once per exchange outcome;
`auth_throttled`, `auth_busy` and `auth_binding_mismatch` separately.
## The `authz` Seam
Listeners never call `Authorize` directly any more; one entry point per transport
decides, and `Authorize` itself refuses under `scram` so a missed call site fails
closed.
- `Admit(conn, cs, wantHello, timeout)` returns an admission (identity, hello,
reader) whose `Accept` or `Reject` writes the final message.
- `AuthorizeRequest(r)` covers mTLS certificates and bearer tokens;
`ServeAuth(w, r)` serves `/auth`.
- Dialers: `Greet(ctx, conn, node)` writes the hello (plain or SCRAM);
`Authenticate`, `SetAuthorization`, `ClearToken` and `VerifyConnection` serve
HTTP.
- `Start`/`Close` bracket the SCRAM server and are nil-safe like everything else.
## CLI
```
logwisp auth add-user -credentials F -user U [-password-file P] [-generate]
logwisp auth remove-user -credentials F -user U
logwisp auth token -url https://host:port -user U -password-file P [TLS flags]
logwisp auth stream -addr host:port -user U -password-file P [TLS flags]
```
- `add-user` takes the password from `-password-file` when it exists; a new user
without one gets a generated 130-bit password, written to that file (0600) or
printed once. Rotating an existing user needs the file or `-generate`, so a
mistyped path cannot replace a password. The file's KDF profile and decoy key
are reused, the result is validated with the daemon's own loader, and the
rewrite (temp file in the same directory, rename) keeps the existing mode and
owner.
- `remove-user` refuses to remove the last user.
- Both print that changes apply on `SIGHUP`: `auto_reload` does not watch the
credentials file.
- `token` prints a bearer token for curl (`-H @<(...)` keeps it out of argv);
`stream` authenticates to a `tcp` sink and copies the stream to stdout.
- TLS flags: `-ca-file`, `-server-name`, `-cert-file`, `-key-file`. There is no
insecure flag.
## Rollout and Rotation
- Turning SCRAM on is per listener and cuts off unconfigured dialers. To migrate
without a gap, upgrade binaries, add a second SCRAM listener, move dialers,
then remove the old listener.
- Password rotation: `add-user -generate`, `SIGHUP` the listener, deploy the new
password file, `SIGHUP` the dialer. The dialer retries under backoff in
between.
- Revocation: `remove-user`, `SIGHUP`. All tokens die with the reload.
## Verification
Go tests, one per rule: binding (match, mismatch, one-sided, length) in `auth`;
in `authz` the validation table, credential loading, decoy stability across
edits, TCP exchange over `net.Pipe` (success, wrong password equals unknown
user, binding mismatch, missing hello, silent peer within the deadline,
abandoned exchange frees its slot, final and first entry in one write, assert
mismatch gets an error rather than a final), HTTP exchange on a TLS test server
(token accepted, garbage and foreign tokens `401`, forged final yields no token,
a different certificate after step 1 refused before the body is sent), the
limiter bound; `http_chain` sink re-authentication and outcome table; CLI
add/remove/rotate, mode preservation, last-user refusal, password trimming.
`test/scram-chain-test.sh --auto` (ports 15821-15825): a relay with SCRAM on all
four listener types plus one `client_auth` + SCRAM port; authorized edges over
both chain transports; node labels forced to usernames; wrong password, unknown
user and no-auth edges deliver nothing; a `tcp` sink viewer through `logwisp auth
stream` held past the exchange deadline; a raw TLS client without a hello gets
nothing; the `http` sink through `logwisp auth token` and curl; missing and bad
tokens get `401`; `remove-user` + `SIGHUP` revokes token and login; throttling
last. The existing scripts keep passing.
## mTLS Hardening
Shipped with this plan, independent of SCRAM:
- The `http_chain` sink no longer follows redirects. Go's client resends the
body on a 307/308, including from `https` to plaintext `http`.
- Its response drain is bounded (64 KiB).
- The `tcp` sink tracks connections from accept, so `Stop` and reloads no longer
wait out the 10 s handshake timeout of a silent peer.
- Unknown keys in any plugin's config, at any depth, fail construction: a typo
such as `[...tls] enabeld = true` used to switch protection off silently.
- Startup and every reload warn about certificates that expired, are not yet
valid, or expire within 30 days; about `insecure_skip_verify`; about
`allow_patterns` that are not anchored at both ends of every alternative; and,
once per path, about key files every local user can read.
- An `http` sink with an auth policy no longer sends
`Access-Control-Allow-Origin: *`.