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 <noreply@anthropic.com>
This commit is contained in:
Anthony Ettinger 2026-06-23 14:11:29 +00:00
parent 57daf894b2
commit 25266845e0
3 changed files with 252 additions and 51 deletions

145
deploy/ergo/auth-script.sh Normal file → Executable file
View file

@ -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:
# <account>:pbkdf2_sha256$<iterations>$<salt_hex>$<hash_hex>
# 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: ["<auth-url>"] # 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()