Files
logwisp/doc/scram-auth-plan.md
T

12 KiB

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).
  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

# 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):

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: *.