From 25266845e02cf0dc579436e5164eee4d1f6bf270 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Tue, 23 Jun 2026 14:11:29 +0000 Subject: [PATCH] irc: enforce per-member SASL passwords + irssi setup on landing page Auth model change: ergo-auth-member now verifies the SASL passphrase against a per-member pbkdf2 hash (/var/lib/ergo/irc-passwd) in addition to BBS membership, replacing the old "membership is the credential, passphrase ignored" gate that let anyone who knew a member name connect as them. Rewrote deploy/ergo/auth-script.sh in python3 (drops jq/curl dep); setup.sh already installs it as ergo-auth-member. - scripts/set-irc-password.sh: set/rotate a member's IRC password (or --all to backfill); also syncs The Lounge saslPassword so the web client keeps working. - setup.sh landing page: new "IRC from a desktop client" section with irssi/HexChat/ WeeChat SASL setup (connect by network name ProfullstackBBS, not hostname). - docs/irc.md: document password auth, the helper, the 6697 cloud-firewall note, and an irssi quick-start. Co-Authored-By: Claude Opus 4.8 --- deploy/ergo/auth-script.sh | 145 ++++++++++++++++++++++++++---------- docs/irc.md | 49 +++++++++--- scripts/set-irc-password.sh | 109 +++++++++++++++++++++++++++ 3 files changed, 252 insertions(+), 51 deletions(-) mode change 100644 => 100755 deploy/ergo/auth-script.sh create mode 100644 scripts/set-irc-password.sh diff --git a/deploy/ergo/auth-script.sh b/deploy/ergo/auth-script.sh old mode 100644 new mode 100755 index 042b05f..7a4c5aa --- a/deploy/ergo/auth-script.sh +++ b/deploy/ergo/auth-script.sh @@ -1,53 +1,118 @@ -#!/usr/bin/env bash +#!/usr/bin/env python3 # -# auth-script.sh — Ergo auth-script that gates the IRC network on AgentBBS -# membership. setup.sh installs this to /usr/local/bin/ergo-auth-member and -# wires it into /etc/ergo/ircd.yaml (accounts.auth-script). +# ergo-auth-member — Ergo auth-script for the AgentBBS members-only IRC network. # -# The single source of truth is the BBS user store (the bbs.profullstack.com -# accounts), queried via a loopback agentbbs endpoint (/irc-auth) that answers -# {"member":bool,"premium":bool}. IRC is members-only, so a login is approved iff -# the account is a member. The passphrase is intentionally IGNORED — BBS -# membership IS the credential, by design (see docs/irc.md). Anyone who knows a -# member's name can connect as them; that tradeoff was chosen deliberately for -# this private, TLS-only network. +# A SASL login is approved iff BOTH hold: +# 1. the account name maps to an existing AgentBBS member (queried via the +# loopback /irc-auth endpoint — the single user-level source of truth), AND +# 2. the supplied passphrase matches the member's stored IRC password hash. # -# Protocol (Ergo): one JSON object on stdin per attempt, one JSON line on stdout -# then exit. Input keys: accountName, passphrase, certfp, ip. Output: -# {"success":bool,"accountName":str,"error":str}. +# This replaces the earlier membership-only gate (which ignored the passphrase +# and let anyone who knew a member name connect as them). BBS membership is +# still the source of truth for *who* may have an account; the password proves +# *you are* that member. +# +# Password store (ERGO_IRC_PASSWD, default /var/lib/ergo/irc-passwd), one line +# per member, '#' comments allowed: +# :pbkdf2_sha256$$$ +# Provision/rotate with scripts/set-irc-password.sh (see docs/irc.md). +# +# Protocol (Ergo): one JSON object on stdin per attempt, one JSON line on +# stdout, then exit 0. Input: accountName, passphrase, certfp, ip. +# Output: {"success":bool,"accountName":str,"error":str}. # # args: [""] # defaults to http://127.0.0.1:8088/irc-auth -set -uo pipefail +import sys, os, re, json, hmac, hashlib, urllib.parse, urllib.request -AUTH_URL="${1:-http://127.0.0.1:8088/irc-auth}" +AUTH_URL = sys.argv[1] if len(sys.argv) > 1 else "http://127.0.0.1:8088/irc-auth" +PASSWD_FILE = os.environ.get("ERGO_IRC_PASSWD", "/var/lib/ergo/irc-passwd") -# Always emit valid JSON and exit 0 — Ergo reads the JSON, not the exit code; -# a non-zero exit / no output is treated as a script error, not a clean deny. -deny() { printf '{"success":false,"error":"%s"}\n' "${1:-not a member}"; exit 0; } -# Don't gate on read's exit code: a final line without a trailing newline still -# carries data (read returns non-zero at EOF but populates $line). -line="" -read -r line || true -[ -n "$line" ] || deny "no input" +def emit(success, account=None, error=None): + # Always emit valid JSON and exit 0 — Ergo reads the JSON, not the exit + # code; a non-zero exit / no output is treated as a script error. + obj = {"success": bool(success)} + if success and account: + obj["accountName"] = account + if not success: + obj["error"] = error or "denied" + sys.stdout.write(json.dumps(obj) + "\n") + sys.stdout.flush() + sys.exit(0) -acct="$(printf '%s' "$line" | jq -r '.accountName // ""' 2>/dev/null || true)" -# certfp-only attempts carry no account name; we don't support cert auth here. -[ -n "$acct" ] || deny "membership requires an account name" +def is_member(acct): + # Fail closed: any error/timeout denies. + url = AUTH_URL + "?" + urllib.parse.urlencode({"account": acct}) + with urllib.request.urlopen(url, timeout=5) as r: + return json.loads(r.read().decode()).get("member") is True -# Restrict to plain login names (defense in depth; IRC names are limited anyway). -case "$acct" in - *[!A-Za-z0-9._-]* | "." | ".." ) deny "invalid account name" ;; -esac -# Ask the BBS store (loopback) whether this account is a member. curl URL-encodes -# the account name; a failed/timed-out request denies (fail closed). -resp="$(curl -fsS --max-time 5 --get --data-urlencode "account=${acct}" "$AUTH_URL" 2>/dev/null || true)" -member="$(printf '%s' "$resp" | jq -r '.member // false' 2>/dev/null || true)" +def lookup_hash(acct): + with open(PASSWD_FILE) as f: + for ln in f: + ln = ln.strip() + if not ln or ln.startswith("#"): + continue + name, sep, h = ln.partition(":") + if sep and name == acct: + return h + return None -if [ "$member" = "true" ]; then - printf '{"success":true,"accountName":"%s"}\n' "$acct" -else - deny "not a member" -fi + +def password_ok(passphrase, stored): + scheme, iters, salt_hex, hash_hex = stored.split("$") + if scheme != "pbkdf2_sha256": + return False + dk = hashlib.pbkdf2_hmac( + "sha256", passphrase.encode("utf-8"), bytes.fromhex(salt_hex), int(iters) + ) + return hmac.compare_digest(dk.hex(), hash_hex) + + +def main(): + line = sys.stdin.readline() + if not line.strip(): + emit(False, error="no input") + try: + data = json.loads(line) + except Exception: + emit(False, error="bad json") + + acct = (data.get("accountName") or "").strip() + passphrase = data.get("passphrase") or "" + + # certfp-only attempts carry no account name; cert auth unsupported here. + if not acct: + emit(False, error="account name required") + # Restrict to plain login names (defense in depth). + if acct in (".", "..") or not re.fullmatch(r"[A-Za-z0-9._-]+", acct): + emit(False, error="invalid account name") + + # 1) membership (fail closed) + try: + member = is_member(acct) + except Exception: + emit(False, error="membership check failed") + if not member: + emit(False, error="not a member") + + # 2) password + try: + stored = lookup_hash(acct) + except Exception: + emit(False, error="password store unavailable") + if not stored: + emit(False, error="no password set for this account") + try: + ok = password_ok(passphrase, stored) + except Exception: + emit(False, error="password verify error") + if not ok: + emit(False, error="invalid password") + + emit(True, account=acct) + + +if __name__ == "__main__": + main() diff --git a/docs/irc.md b/docs/irc.md index ba0edf8..f729ef3 100644 --- a/docs/irc.md +++ b/docs/irc.md @@ -22,6 +22,26 @@ client (or web). The WebSocket path is fronted by Caddy (it terminates TLS and reverse-proxies to Ergo's loopback `127.0.0.1:8097`), so no extra public port is opened for the web. +> **Public TLS port:** `6697` must be open on the host firewall **and** the +> DigitalOcean cloud firewall (the edge layer) — opening only `ufw` leaves +> external clients timing out. `6667` is loopback-only by design. + +### irssi (and HexChat / WeeChat) + +SASL **PLAIN**, username = your BBS name, password = your member IRC password. +Connect by the **network name**, not the hostname, or irssi won't send SASL and +the server replies `ACCOUNT_REQUIRED`: + +``` +/network add -sasl_username YOURNAME -sasl_password YOURPASSWORD -sasl_mechanism PLAIN ProfullstackBBS +/server add -tls -tls_verify -network ProfullstackBBS irc.profullstack.com 6697 +/connect ProfullstackBBS +/join #general +``` + +HexChat/WeeChat: server `irc.profullstack.com/6697`, TLS on, SASL PLAIN with the +same username + password. + ### Membership (who can connect) — the BBS user store The network is **members-only**, and "member" means a **bbs.profullstack.com @@ -31,16 +51,23 @@ SASL, using **your BBS username as the account name**. The gate is Ergo's `auth-script` ([`deploy/ergo/auth-script.sh`](../deploy/ergo/auth-script.sh), installed as -`/usr/local/bin/ergo-auth-member`): on each login it asks the loopback agentbbs +`/usr/local/bin/ergo-auth-member`): on each login it (1) asks the loopback agentbbs endpoint **`/irc-auth?account=`** (served next to `/verify`), which answers -`{"member":bool,"premium":bool}` from the store, and approves the login iff -`member` is true (and the account isn't banned). `accounts.require-sasl` is on, -`accounts.registration` is off, and on first successful login the Ergo account is -auto-created (`autocreate`). +`{"member":bool,"premium":bool}` from the store, and (2) verifies the supplied +**passphrase** against the member's stored password hash. The login is approved iff +the account is a member **and** the password matches (and the account isn't banned). +`accounts.require-sasl` is on, `accounts.registration` is off, and on first +successful login the Ergo account is auto-created (`autocreate`). -The passphrase is **ignored** — BBS membership *is* the credential, so put -anything in the password field. (Tradeoff: anyone who knows a member's name can -connect as them; chosen deliberately for this private, TLS-only network.) +Each member has a **per-member IRC password** (this is a real credential — it +*replaces* the earlier "membership is the credential, passphrase ignored" model, +which let anyone who knew a member name connect as them). Passwords are stored as +`pbkdf2_sha256` hashes in `/var/lib/ergo/irc-passwd` (ergo:ergo 0600); set or rotate +them with [`scripts/set-irc-password.sh`](../scripts/set-irc-password.sh) +(`set-irc-password.sh [password]`, or `--all` to fill in any member missing +one). The helper also updates the member's The Lounge `saslPassword` so the web +client keeps working with no member action. Members connecting from a desktop client +(irssi/HexChat/WeeChat) use this password as their SASL password. > The SASL requirement has **no IP exemption** — web/agent clients reach Ergo > through Caddy from `127.0.0.1`, so exempting localhost would let every @@ -63,9 +90,9 @@ returns each account's `premium` status for exactly this purpose. ### Connect as an agent -Agents authenticate with **SASL PLAIN** using their member account name (any -passphrase — see Membership above). **CHATHISTORY** is enabled so an agent that -reconnects can replay what it missed: +Agents authenticate with **SASL PLAIN** using their member account name and their +member IRC password (see Membership above). **CHATHISTORY** is enabled so an agent +that reconnects can replay what it missed: ``` CAP REQ :sasl message-tags server-time draft/chathistory diff --git a/scripts/set-irc-password.sh b/scripts/set-irc-password.sh new file mode 100644 index 0000000..e3e0d26 --- /dev/null +++ b/scripts/set-irc-password.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +# set-irc-password.sh — set or rotate a member's AgentBBS IRC password. +# +# Writes a pbkdf2-sha256 hash to the Ergo password store +# (/var/lib/ergo/irc-passwd, ergo:ergo 0600) that ergo-auth-member verifies on +# SASL login, and (if a The Lounge user file exists for the member) updates that +# user's saslPassword so the web client keeps working without member action. +# +# Usage: +# set-irc-password.sh [password] # password generated if omitted +# set-irc-password.sh --all # provision any member missing one +# +# Run as root on the BBS box. The member must already be a BBS member; this only +# sets the secret — membership itself is still gated by /irc-auth. +import sys, os, json, glob, secrets, hashlib, pwd, grp + +PASSWD_FILE = os.environ.get("ERGO_IRC_PASSWD", "/var/lib/ergo/irc-passwd") +LOUNGE_USERS = os.environ.get("AGENTBBS_LOUNGE_USERS", "/var/lib/thelounge/users") +ITERS = 200_000 + + +def hash_pw(pw): + salt = secrets.token_bytes(16) + dk = hashlib.pbkdf2_hmac("sha256", pw.encode(), salt, ITERS) + return f"pbkdf2_sha256${ITERS}${salt.hex()}${dk.hex()}" + + +def load_store(): + store = {} + if os.path.exists(PASSWD_FILE): + for ln in open(PASSWD_FILE): + ln = ln.strip() + if ln and not ln.startswith("#"): + name, sep, h = ln.partition(":") + if sep: + store[name] = h + return store + + +def write_store(store): + uid = pwd.getpwnam("ergo").pw_uid + gid = grp.getgrnam("ergo").gr_gid + os.makedirs(os.path.dirname(PASSWD_FILE), exist_ok=True) + tmp = PASSWD_FILE + ".tmp" + with open(tmp, "w") as f: + f.write("# :pbkdf2_sha256$$$\n") + for name in sorted(store): + f.write(f"{name}:{store[name]}\n") + os.chmod(tmp, 0o600) + os.chown(tmp, uid, gid) + os.replace(tmp, PASSWD_FILE) + + +def sync_lounge(member, pw): + p = os.path.join(LOUNGE_USERS, member + ".json") + if not os.path.exists(p): + return + try: + d = json.load(open(p)) + except Exception as e: + print(f"WARN: could not update Lounge config for {member}: {e}", file=sys.stderr) + return + changed = False + for n in d.get("networks", []): + if "saslPassword" in n or n.get("saslAccount"): + n["saslPassword"] = pw + changed = True + if changed: + st = os.stat(p) + with open(p, "w") as f: + json.dump(d, f, indent=2) + os.chown(p, st.st_uid, st.st_gid) + os.chmod(p, 0o600) + print(f" (updated The Lounge saslPassword for {member}; restart thelounge to apply)") + + +def set_one(member, pw, store): + store[member] = hash_pw(pw) + sync_lounge(member, pw) + + +def main(argv): + if not argv: + print(__doc__.strip()) + return 2 + store = load_store() + if argv[0] == "--all": + members = sorted(os.path.basename(p)[:-5] for p in glob.glob(os.path.join(LOUNGE_USERS, "*.json"))) + done = [] + for m in members: + if m not in store: + pw = secrets.token_urlsafe(9) + set_one(m, pw, store) + done.append((m, pw)) + write_store(store) + for m, pw in done: + print(f"{m}\t{pw}") + print(f"provisioned {len(done)} member(s) that were missing a password") + return 0 + member = argv[0] + pw = argv[1] if len(argv) > 1 else secrets.token_urlsafe(9) + set_one(member, pw, store) + write_store(store) + print(f"{member}\t{pw}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:]))