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

135
tests/test_cert.py Normal file
View File

@@ -0,0 +1,135 @@
"""Tests for client certificate management."""
from __future__ import annotations
from pathlib import Path
import pytest
from bouncer.cert import (
cert_path,
delete_cert,
fingerprint,
generate_cert,
has_cert,
list_certs,
)
@pytest.fixture
def data_dir(tmp_path: Path) -> Path:
"""Provide a temporary data directory."""
return tmp_path
class TestCertPath:
def test_standard_path(self, data_dir: Path) -> None:
p = cert_path(data_dir, "libera", "fabesune")
assert p == data_dir / "certs" / "libera" / "fabesune.pem"
class TestGenerateCert:
def test_creates_pem_file(self, data_dir: Path) -> None:
pem = generate_cert(data_dir, "libera", "testnick")
assert pem.is_file()
assert pem == cert_path(data_dir, "libera", "testnick")
def test_pem_contains_cert_and_key(self, data_dir: Path) -> None:
pem = generate_cert(data_dir, "libera", "testnick")
content = pem.read_text()
assert "BEGIN CERTIFICATE" in content
assert "BEGIN PRIVATE KEY" in content
def test_file_permissions(self, data_dir: Path) -> None:
pem = generate_cert(data_dir, "libera", "testnick")
mode = pem.stat().st_mode & 0o777
assert mode == 0o600
def test_overwrites_existing(self, data_dir: Path) -> None:
pem1 = generate_cert(data_dir, "libera", "testnick")
fp1 = fingerprint(pem1)
pem2 = generate_cert(data_dir, "libera", "testnick")
fp2 = fingerprint(pem2)
assert pem1 == pem2
assert fp1 != fp2 # New cert = new fingerprint
class TestFingerprint:
def test_format(self, data_dir: Path) -> None:
pem = generate_cert(data_dir, "libera", "testnick")
fp = fingerprint(pem)
parts = fp.split(":")
assert len(parts) == 32 # SHA-256 = 32 bytes
for part in parts:
assert len(part) == 2
int(part, 16) # Must be valid hex
def test_uppercase_hex(self, data_dir: Path) -> None:
pem = generate_cert(data_dir, "libera", "testnick")
fp = fingerprint(pem)
assert fp == fp.upper()
def test_deterministic_for_same_cert(self, data_dir: Path) -> None:
pem = generate_cert(data_dir, "libera", "testnick")
assert fingerprint(pem) == fingerprint(pem)
class TestHasCert:
def test_exists(self, data_dir: Path) -> None:
generate_cert(data_dir, "libera", "testnick")
assert has_cert(data_dir, "libera", "testnick") is True
def test_not_exists(self, data_dir: Path) -> None:
assert has_cert(data_dir, "libera", "testnick") is False
class TestDeleteCert:
def test_delete_existing(self, data_dir: Path) -> None:
generate_cert(data_dir, "libera", "testnick")
assert delete_cert(data_dir, "libera", "testnick") is True
assert has_cert(data_dir, "libera", "testnick") is False
def test_delete_nonexistent(self, data_dir: Path) -> None:
assert delete_cert(data_dir, "libera", "testnick") is False
def test_cleans_empty_dir(self, data_dir: Path) -> None:
generate_cert(data_dir, "libera", "testnick")
delete_cert(data_dir, "libera", "testnick")
assert not (data_dir / "certs" / "libera").exists()
class TestListCerts:
def test_empty(self, data_dir: Path) -> None:
assert list_certs(data_dir) == []
def test_list_all(self, data_dir: Path) -> None:
generate_cert(data_dir, "libera", "nick1")
generate_cert(data_dir, "oftc", "nick2")
certs = list_certs(data_dir)
assert len(certs) == 2
networks = {c[0] for c in certs}
assert networks == {"libera", "oftc"}
def test_list_by_network(self, data_dir: Path) -> None:
generate_cert(data_dir, "libera", "nick1")
generate_cert(data_dir, "oftc", "nick2")
certs = list_certs(data_dir, network="libera")
assert len(certs) == 1
assert certs[0][0] == "libera"
assert certs[0][1] == "nick1"
def test_list_multiple_per_network(self, data_dir: Path) -> None:
generate_cert(data_dir, "libera", "nick1")
generate_cert(data_dir, "libera", "nick2")
certs = list_certs(data_dir, network="libera")
assert len(certs) == 2
nicks = {c[1] for c in certs}
assert nicks == {"nick1", "nick2"}
def test_fingerprints_present(self, data_dir: Path) -> None:
generate_cert(data_dir, "libera", "testnick")
certs = list_certs(data_dir)
assert len(certs) == 1
_, _, fp = certs[0]
assert ":" in fp
assert len(fp.split(":")) == 32