feat: add CertFP authentication with SASL EXTERNAL

Per-network, per-nick client certificates (EC P-256, self-signed,
10-year validity) stored as combined PEM files. Authentication
cascade: SASL EXTERNAL > SASL PLAIN > NickServ IDENTIFY.

New commands: GENCERT, CERTFP, DELCERT. GENCERT auto-registers
the fingerprint with NickServ CERT ADD when the network is connected.

Includes email verification module for NickServ registration and
expanded NickServ interaction (IDENTIFY, REGISTER, VERIFY).
This commit is contained in:
user
2026-02-21 01:15:25 +01:00
parent e6b1ce4c6d
commit 2f40f5e508
14 changed files with 1912 additions and 49 deletions

View File

@@ -774,6 +774,185 @@ class TestDropCreds:
assert "Unknown network" in lines[0]
class TestGencert:
@pytest.mark.asyncio
async def test_gencert_no_data_dir(self) -> None:
commands.DATA_DIR = None
router = _make_router()
client = _make_client()
lines = await commands.dispatch("GENCERT libera", router, client)
assert "not available" in lines[0]
@pytest.mark.asyncio
async def test_gencert_missing_arg(self) -> None:
commands.DATA_DIR = Path("/tmp")
router = _make_router()
client = _make_client()
lines = await commands.dispatch("GENCERT", router, client)
assert "Usage" in lines[0]
@pytest.mark.asyncio
async def test_gencert_unknown_network(self) -> None:
commands.DATA_DIR = Path("/tmp")
router = _make_router()
client = _make_client()
lines = await commands.dispatch("GENCERT fakenet", router, client)
assert "Unknown network" in lines[0]
@pytest.mark.asyncio
async def test_gencert_with_nick(self, tmp_path: Path) -> None:
commands.DATA_DIR = tmp_path
net = _make_network("libera", State.READY, nick="fabesune")
router = _make_router(net)
client = _make_client()
lines = await commands.dispatch("GENCERT libera testnick", router, client)
assert "[GENCERT]" in lines[0]
assert "testnick" in lines[0]
assert any("fingerprint" in line for line in lines)
# Should auto-send CERT ADD since network is ready
net.send_raw.assert_awaited()
calls = net.send_raw.await_args_list
assert any(
c.args[0] == "PRIVMSG" and c.args[1] == "NickServ"
and "CERT ADD" in c.args[2]
for c in calls
)
@pytest.mark.asyncio
async def test_gencert_uses_current_nick(self, tmp_path: Path) -> None:
commands.DATA_DIR = tmp_path
net = _make_network("libera", State.READY, nick="fabesune")
router = _make_router(net)
client = _make_client()
lines = await commands.dispatch("GENCERT libera", router, client)
assert "[GENCERT]" in lines[0]
assert "fabesune" in lines[0]
@pytest.mark.asyncio
async def test_gencert_not_ready(self, tmp_path: Path) -> None:
commands.DATA_DIR = tmp_path
net = _make_network("libera", State.CONNECTING, nick="fabesune")
router = _make_router(net)
client = _make_client()
lines = await commands.dispatch("GENCERT libera", router, client)
assert "[GENCERT]" in lines[0]
assert any("not ready" in line or "manually" in line for line in lines)
@pytest.mark.asyncio
async def test_gencert_no_nick(self, tmp_path: Path) -> None:
commands.DATA_DIR = tmp_path
net = _make_network("libera", State.READY, nick="*")
router = _make_router(net)
router.backlog.get_nickserv_creds_by_network.return_value = None
client = _make_client()
lines = await commands.dispatch("GENCERT libera", router, client)
assert "No nick available" in lines[0]
class TestCertfp:
def test_certfp_no_data_dir(self) -> None:
commands.DATA_DIR = None
router = _make_router()
lines = _cmd_certfp_sync(router, None)
assert "not available" in lines[0]
def test_certfp_empty(self, tmp_path: Path) -> None:
commands.DATA_DIR = tmp_path
router = _make_router()
lines = _cmd_certfp_sync(router, None)
assert "no certificates" in lines[0]
def test_certfp_lists_certs(self, tmp_path: Path) -> None:
from bouncer.cert import generate_cert
commands.DATA_DIR = tmp_path
generate_cert(tmp_path, "libera", "fabesune")
net = _make_network("libera", State.READY)
router = _make_router(net)
lines = _cmd_certfp_sync(router, None)
assert lines[0] == "[CERTFP]"
assert any("libera" in line and "fabesune" in line for line in lines)
def test_certfp_filter_network(self, tmp_path: Path) -> None:
from bouncer.cert import generate_cert
commands.DATA_DIR = tmp_path
generate_cert(tmp_path, "libera", "nick1")
generate_cert(tmp_path, "oftc", "nick2")
libera = _make_network("libera", State.READY)
oftc = _make_network("oftc", State.READY)
router = _make_router(libera, oftc)
lines = _cmd_certfp_sync(router, "libera")
assert lines[0] == "[CERTFP]"
assert any("nick1" in line for line in lines)
assert not any("nick2" in line for line in lines)
def test_certfp_unknown_network(self, tmp_path: Path) -> None:
commands.DATA_DIR = tmp_path
router = _make_router()
lines = _cmd_certfp_sync(router, "fakenet")
assert "Unknown network" in lines[0]
class TestDelcert:
def test_delcert_no_data_dir(self) -> None:
commands.DATA_DIR = None
router = _make_router()
lines = _cmd_delcert_sync(router, "libera")
assert "not available" in lines[0]
def test_delcert_missing_arg(self) -> None:
commands.DATA_DIR = Path("/tmp")
router = _make_router()
lines = _cmd_delcert_sync(router, "")
assert "Usage" in lines[0]
def test_delcert_unknown_network(self) -> None:
commands.DATA_DIR = Path("/tmp")
router = _make_router()
lines = _cmd_delcert_sync(router, "fakenet")
assert "Unknown network" in lines[0]
def test_delcert_removes_cert(self, tmp_path: Path) -> None:
from bouncer.cert import generate_cert, has_cert
commands.DATA_DIR = tmp_path
generate_cert(tmp_path, "libera", "testnick")
assert has_cert(tmp_path, "libera", "testnick")
net = _make_network("libera", State.READY, nick="testnick")
router = _make_router(net)
lines = _cmd_delcert_sync(router, "libera testnick")
assert "[DELCERT]" in lines[0]
assert "deleted" in lines[0]
assert not has_cert(tmp_path, "libera", "testnick")
def test_delcert_nonexistent(self, tmp_path: Path) -> None:
commands.DATA_DIR = tmp_path
net = _make_network("libera", State.READY, nick="testnick")
router = _make_router(net)
lines = _cmd_delcert_sync(router, "libera testnick")
assert "no cert found" in lines[0]
def test_delcert_uses_current_nick(self, tmp_path: Path) -> None:
from bouncer.cert import generate_cert, has_cert
commands.DATA_DIR = tmp_path
generate_cert(tmp_path, "libera", "fabesune")
net = _make_network("libera", State.READY, nick="fabesune")
router = _make_router(net)
lines = _cmd_delcert_sync(router, "libera")
assert "deleted" in lines[0]
assert not has_cert(tmp_path, "libera", "fabesune")
def _cmd_certfp_sync(router: MagicMock, network_name: str | None) -> list[str]:
"""Call _cmd_certfp synchronously (it's not async)."""
from bouncer.commands import _cmd_certfp
return _cmd_certfp(router, network_name)
def _cmd_delcert_sync(router: MagicMock, arg: str) -> list[str]:
"""Call _cmd_delcert synchronously (it's not async)."""
from bouncer.commands import _cmd_delcert
return _cmd_delcert(router, arg)
class TestUnknownCommand:
@pytest.mark.asyncio
async def test_unknown_command(self) -> None: