fix(a7-3/s1): require TLS or local transport for daemon auth
Daemon modules that declare 'auth users' no longer accept credentials over a remote plaintext connection: server_module_gate refuses at the config gate, before any SCRAM challenge is sent, unless the connection is verified TLS with a client certificate matching --client-cn, or a local/SSH transport (loopback TCP peer or the --stdio pipe). --allow-unauthenticated does not relax this. The TLS client-CN comparison now uses credentials_secure_equal (S2). Clients sending --password-file to a non-loopback daemon must use --tls; validate_config rejects the plaintext case before any network I/O. Adds utils_sockaddr_is_loopback / utils_fd_peer_is_local / utils_host_is_loopback helpers with unit tests, a client validation unit test, and integration tests for the client-side plaintext rejection and the wrong-CN gate refusal.
This commit is contained in:
@@ -766,6 +766,26 @@ class TestDaemonAuthentication:
|
||||
finally:
|
||||
os.unlink(cred_path)
|
||||
|
||||
@pytest.mark.ci
|
||||
def test_remote_plaintext_credentials_rejected_client_side(self):
|
||||
"""A7-3/S1: sending daemon credentials to a clearly non-local daemon
|
||||
WITHOUT --tls is refused by the client itself, before any network I/O
|
||||
(192.0.2.0/24 is TEST-NET-1 and never reachable, so a network attempt
|
||||
would time out instead of failing fast)."""
|
||||
cred_path = os.path.join(TEST_DATA_DIR, "client_remote.pw")
|
||||
_write_client_password_file(cred_path, "alice", ALICE_PASS)
|
||||
try:
|
||||
cmd = CLIENT_CMD + ["--source-dir", SOURCE_DIR,
|
||||
"--dest-dir", "192.0.2.1::files",
|
||||
"--save-to-disk", "--password-file", cred_path,
|
||||
"--server-port", "873"]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=15)
|
||||
assert result.returncode != 0
|
||||
combined = (result.stderr or "") + (result.stdout or "")
|
||||
assert "--tls" in combined, combined
|
||||
finally:
|
||||
os.unlink(cred_path)
|
||||
|
||||
def test_client_empty_password_file_rejected(self):
|
||||
"""Client-side: an empty --password-file is rejected (no credentials)."""
|
||||
cred_path = os.path.join(TEST_DATA_DIR, "client_empty.pw")
|
||||
@@ -1001,23 +1021,30 @@ class TestDaemonMotd:
|
||||
d.stop()
|
||||
|
||||
|
||||
def _generate_tls_certs(cert_dir):
|
||||
"""Generate a self-signed CA, server cert (with 127.0.0.1 SAN) and a client
|
||||
cert signed by that CA, for the TLS+auth composition test."""
|
||||
def _generate_tls_certs(cert_dir, extra_san_ips=None):
|
||||
"""Generate a self-signed CA, server cert (with 127.0.0.1 SAN plus any
|
||||
extra_san_ips) and two client certs signed by that CA: one with the
|
||||
expected CN (fastsync-client) and one with a WRONG CN, for the TLS+auth
|
||||
composition and wrong-identity tests."""
|
||||
os.makedirs(cert_dir, exist_ok=True)
|
||||
ca_key, ca_cert = os.path.join(cert_dir, "ca.key"), os.path.join(cert_dir, "ca.pem")
|
||||
server_key = os.path.join(cert_dir, "server.key")
|
||||
server_cert = os.path.join(cert_dir, "server.pem")
|
||||
client_key = os.path.join(cert_dir, "client.key")
|
||||
client_cert = os.path.join(cert_dir, "client.pem")
|
||||
wrong_client_key = os.path.join(cert_dir, "wrong_client.key")
|
||||
wrong_client_cert = os.path.join(cert_dir, "wrong_client.pem")
|
||||
subprocess.run(["openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes",
|
||||
"-keyout", ca_key, "-out", ca_cert, "-days", "1",
|
||||
"-subj", "/CN=FastSync Test CA"], check=True, capture_output=True)
|
||||
san = os.path.join(cert_dir, "san.conf")
|
||||
san_ips = ["IP.1 = 127.0.0.1"]
|
||||
for index, ip in enumerate(extra_san_ips or [], start=2):
|
||||
san_ips.append("IP.%d = %s" % (index, ip))
|
||||
with open(san, "w") as f:
|
||||
f.write("[req]\ndistinguished_name = dn\nreq_extensions = v3_req\n\n"
|
||||
"[dn]\nCN = localhost\n\n[v3_req]\nsubjectAltName = @an\n\n"
|
||||
"[an]\nDNS.1 = localhost\nIP.1 = 127.0.0.1\n")
|
||||
"[an]\nDNS.1 = localhost\n" + "\n".join(san_ips) + "\n")
|
||||
subprocess.run(["openssl", "req", "-newkey", "rsa:2048", "-nodes",
|
||||
"-keyout", server_key, "-out", os.path.join(cert_dir, "server.csr"),
|
||||
"-subj", "/CN=localhost", "-config", san], check=True, capture_output=True)
|
||||
@@ -1031,12 +1058,20 @@ def _generate_tls_certs(cert_dir):
|
||||
subprocess.run(["openssl", "x509", "-req", "-in", os.path.join(cert_dir, "client.csr"),
|
||||
"-CA", ca_cert, "-CAkey", ca_key, "-CAcreateserial",
|
||||
"-out", client_cert, "-days", "1"], check=True, capture_output=True)
|
||||
subprocess.run(["openssl", "req", "-newkey", "rsa:2048", "-nodes",
|
||||
"-keyout", wrong_client_key, "-out", os.path.join(cert_dir, "wrong_client.csr"),
|
||||
"-subj", "/CN=wrong-client"], check=True, capture_output=True)
|
||||
subprocess.run(["openssl", "x509", "-req", "-in", os.path.join(cert_dir, "wrong_client.csr"),
|
||||
"-CA", ca_cert, "-CAkey", ca_key, "-CAcreateserial",
|
||||
"-out", wrong_client_cert, "-days", "1"], check=True, capture_output=True)
|
||||
return {
|
||||
"ca": ca_cert,
|
||||
"server_cert": server_cert,
|
||||
"server_key": server_key,
|
||||
"client_cert": client_cert,
|
||||
"client_key": client_key,
|
||||
"wrong_client_cert": wrong_client_cert,
|
||||
"wrong_client_key": wrong_client_key,
|
||||
}
|
||||
|
||||
|
||||
@@ -1077,3 +1112,48 @@ class TestDaemonTLSAuth:
|
||||
d.stop()
|
||||
os.unlink(client_creds)
|
||||
shutil.rmtree(cert_dir, ignore_errors=True)
|
||||
|
||||
@pytest.mark.ci
|
||||
def test_wrong_client_cn_refused_before_auth_challenge(self):
|
||||
"""A7-3/S1: over a NON-local TLS connection an auth-required module is
|
||||
refused at the config gate when the CA-valid client certificate does not
|
||||
match --client-cn -- before any SCRAM challenge is sent and before any
|
||||
file data moves. The daemon is started WITH --allow-unauthenticated to
|
||||
prove that flag does not relax the auth-module transport policy."""
|
||||
try:
|
||||
remote_ip = socket.gethostbyname(socket.gethostname())
|
||||
except OSError:
|
||||
pytest.skip("hostname does not resolve")
|
||||
if remote_ip.startswith("127."):
|
||||
pytest.skip("host resolves to loopback; no non-loopback interface")
|
||||
cert_dir = os.path.join(TEST_DATA_DIR, "daemon_tls_certs_wrong")
|
||||
certs = _generate_tls_certs(cert_dir, extra_san_ips=[remote_ip])
|
||||
client_creds = os.path.join(TEST_DATA_DIR, "daemon_tls_wrong_client.pw")
|
||||
_write_client_password_file(client_creds, "alice", ALICE_PASS)
|
||||
d = DaemonManager()
|
||||
port = _find_free_port()
|
||||
log_path = os.path.join(TEST_DATA_DIR, "fastsyncd.log")
|
||||
try:
|
||||
d.start(CONF_FILE, port_override=port, extra_args=[
|
||||
"--tls", "--cert", certs["server_cert"], "--key", certs["server_key"],
|
||||
"--ca", certs["ca"], "--client-cn", "fastsync-client",
|
||||
"--password-file", CRED_FILE])
|
||||
before_files = _tree_file_count(AUTH_MODULE)
|
||||
log_before = os.path.getsize(log_path) if os.path.exists(log_path) else 0
|
||||
tls_flags = ["--tls",
|
||||
"--cert", certs["wrong_client_cert"], "--key",
|
||||
certs["wrong_client_key"], "--ca", certs["ca"]]
|
||||
result, _ = run_client(SOURCE_DIR, "%s::locked" % remote_ip, port=port,
|
||||
flags=tls_flags, extra_args=["--password-file", client_creds])
|
||||
assert result.returncode != 0, "a wrong client CN must be refused"
|
||||
assert _tree_file_count(AUTH_MODULE) == before_files, \
|
||||
"a refused connection wrote file data"
|
||||
with open(log_path, "rb") as f:
|
||||
f.seek(log_before)
|
||||
tail = f.read().decode("utf-8", "replace")
|
||||
assert "requires authentication over an encrypted, verified TLS connection" in tail, \
|
||||
tail[-400:]
|
||||
finally:
|
||||
d.stop()
|
||||
os.unlink(client_creds)
|
||||
shutil.rmtree(cert_dir, ignore_errors=True)
|
||||
|
||||
Reference in New Issue
Block a user