12 KiB
Password Authentication (Argon2id-SCRAM)
Status: planned. Ships in two batches:
- Done:
lixenwraith/authgained SCRAM channel binding, and the mTLS layer was hardened (see mTLS Hardening). - Next: the SCRAM integration described here, pinned to the tagged
authrelease 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 thetcpandhttpsinks. - 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 authCLI.
Non-Goals
- Standard SASL SCRAM-SHA-256 interoperability. The
authexchange is logwisp-to-logwisp (or logwisp CLI) only. - Browser
EventSourceviewers under SCRAM: they cannot setAuthorizationor 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:
scramrequires TLS; a dialer also forbidsinsecure_skip_verify.- Listeners require
credentials_fileand rejectallow/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 rejectcredentials_file,allow*andidentity(server pinning stays an mTLS feature). identityon a listener requirestls.client_authand 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'sclientcert=verify-full). Without it the factors are independent: any CA-issued certificate plus any valid password (clientcert=verify-ca).token_lifetime_msonly on HTTP listeners; default 15 minutes./authis 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) whoseAcceptorRejectwrites 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,ClearTokenandVerifyConnectionserve HTTP. Start/Closebracket 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-usertakes the password from-password-filewhen 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-userrefuses to remove the last user.- Both print that changes apply on
SIGHUP:auto_reloaddoes not watch the credentials file. tokenprints a bearer token for curl (-H @<(...)keeps it out of argv);streamauthenticates to atcpsink 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,SIGHUPthe listener, deploy the new password file,SIGHUPthe 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_chainsink no longer follows redirects. Go's client resends the body on a 307/308, including fromhttpsto plaintexthttp. - Its response drain is bounded (64 KiB).
- The
tcpsink tracks connections from accept, soStopand 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 = trueused 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; aboutallow_patternsthat are not anchored at both ends of every alternative; and, once per path, about key files every local user can read. - An
httpsink with an auth policy no longer sendsAccess-Control-Allow-Origin: *.