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:
2026-09-12 19:02:05 +02:00
parent 2489d422e5
commit a7a1930e88
9 changed files with 312 additions and 11 deletions
+84 -4
View File
@@ -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)
+37
View File
@@ -69,6 +69,42 @@ static void test_validate_config_tls_requirements() {
config_delete(cfg);
}
/* A7-3/S1: --password-file sends daemon credentials, so it is only allowed
over TLS (which itself mandates a verified --cert/--key/--ca) or to a
loopback destination. A remote plaintext daemon is refused up front. */
static void test_validate_config_credentials_require_tls_or_loopback() {
/* Default host is 127.0.0.1 (loopback), so plaintext credentials are fine. */
Config* cfg = valid_client_config();
cfg->password_file = str_dup("creds.pw");
EXPECT_TRUE(validate_config(cfg));
/* localhost is loopback too. */
free(cfg->server_host);
cfg->server_host = str_dup("localhost");
EXPECT_TRUE(validate_config(cfg));
/* A clearly remote host over plaintext is refused before any network I/O. */
free(cfg->server_host);
cfg->server_host = str_dup("192.0.2.1");
EXPECT_FALSE(validate_config(cfg));
/* TLS makes the remote destination acceptable (cert/key/ca are required). */
cfg->use_tls = true;
EXPECT_FALSE(validate_config(cfg));
cfg->tls_cert = str_dup("cert.pem");
cfg->tls_key = str_dup("key.pem");
cfg->tls_ca = str_dup("ca.pem");
EXPECT_TRUE(validate_config(cfg));
/* No credentials: the remote plaintext rule does not apply. */
cfg->use_tls = false;
char* creds = cfg->password_file;
cfg->password_file = NULL;
EXPECT_TRUE(validate_config(cfg));
cfg->password_file = creds;
config_delete(cfg);
}
static void test_validate_config_delta_sendfile_constraints() {
Config* cfg = valid_client_config();
cfg->use_delta = true;
@@ -3117,6 +3153,7 @@ void test_client_cli() {
test_validate_config_append_verify_rejects_whole_file();
test_validate_config_incompatible_options();
test_validate_config_tls_requirements();
test_validate_config_credentials_require_tls_or_loopback();
test_validate_config_delta_sendfile_constraints();
test_cli_help();
test_cli_archive_flags();
+79
View File
@@ -2,12 +2,15 @@
#include "utils.h"
#include "protocol.h"
#include "test_utils.h"
#include <arpa/inet.h>
#include <dirent.h>
#include <errno.h>
#include <fcntl.h>
#include <netinet/in.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/stat.h>
#include <threads.h>
#include <unistd.h>
@@ -269,12 +272,88 @@ static int escape_thread(void* arg) {
return 0;
}
/* A7-3/S1 transport classification: the daemon auth gate and the client
credential rule both key off these helpers, so cover the exact accepted
forms plus the negative cases. */
static void test_loopback_helpers() {
/* Host strings. */
EXPECT_TRUE(utils_host_is_loopback("localhost"));
EXPECT_TRUE(utils_host_is_loopback("127.0.0.1"));
EXPECT_TRUE(utils_host_is_loopback("127.255.255.254"));
EXPECT_TRUE(utils_host_is_loopback("127.0.0.0"));
EXPECT_TRUE(utils_host_is_loopback("::1"));
EXPECT_TRUE(utils_host_is_loopback("[::1]"));
EXPECT_FALSE(utils_host_is_loopback("128.0.0.1"));
EXPECT_FALSE(utils_host_is_loopback("10.0.0.1"));
EXPECT_FALSE(utils_host_is_loopback("0.0.0.0"));
EXPECT_FALSE(utils_host_is_loopback("example.com"));
EXPECT_FALSE(utils_host_is_loopback(""));
EXPECT_FALSE(utils_host_is_loopback(NULL));
/* Raw sockaddr classification. */
struct sockaddr_in v4;
memset(&v4, 0, sizeof(v4));
v4.sin_family = AF_INET;
EXPECT_TRUE(inet_pton(AF_INET, "127.0.0.1", &v4.sin_addr) == 1);
EXPECT_TRUE(utils_sockaddr_is_loopback((const struct sockaddr*)&v4));
EXPECT_TRUE(inet_pton(AF_INET, "127.5.5.5", &v4.sin_addr) == 1);
EXPECT_TRUE(utils_sockaddr_is_loopback((const struct sockaddr*)&v4));
EXPECT_TRUE(inet_pton(AF_INET, "128.0.0.1", &v4.sin_addr) == 1);
EXPECT_FALSE(utils_sockaddr_is_loopback((const struct sockaddr*)&v4));
struct sockaddr_in6 v6;
memset(&v6, 0, sizeof(v6));
v6.sin6_family = AF_INET6;
EXPECT_TRUE(inet_pton(AF_INET6, "::1", &v6.sin6_addr) == 1);
EXPECT_TRUE(utils_sockaddr_is_loopback((const struct sockaddr*)&v6));
EXPECT_TRUE(inet_pton(AF_INET6, "::ffff:127.0.0.1", &v6.sin6_addr) == 1);
EXPECT_TRUE(utils_sockaddr_is_loopback((const struct sockaddr*)&v6));
EXPECT_TRUE(inet_pton(AF_INET6, "::ffff:127.255.255.254", &v6.sin6_addr) == 1);
EXPECT_TRUE(utils_sockaddr_is_loopback((const struct sockaddr*)&v6));
EXPECT_TRUE(inet_pton(AF_INET6, "::ffff:10.0.0.1", &v6.sin6_addr) == 1);
EXPECT_FALSE(utils_sockaddr_is_loopback((const struct sockaddr*)&v6));
EXPECT_FALSE(utils_sockaddr_is_loopback(NULL));
/* A pipe has no socket peer: getpeername fails with ENOTSOCK, which is the
--stdio/SSH case and must count as local. */
int pipe_fds[2];
EXPECT_EQ_INT(pipe(pipe_fds), 0);
EXPECT_TRUE(utils_fd_peer_is_local(pipe_fds[0]));
close(pipe_fds[0]);
close(pipe_fds[1]);
EXPECT_FALSE(utils_fd_peer_is_local(-1));
/* A real loopback TCP peer is local. */
int listener = socket(AF_INET, SOCK_STREAM, 0);
EXPECT_TRUE(listener >= 0);
struct sockaddr_in bind_addr;
memset(&bind_addr, 0, sizeof(bind_addr));
bind_addr.sin_family = AF_INET;
bind_addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
bind_addr.sin_port = 0;
EXPECT_EQ_INT(bind(listener, (const struct sockaddr*)&bind_addr, sizeof(bind_addr)), 0);
EXPECT_EQ_INT(listen(listener, 1), 0);
socklen_t addr_len = sizeof(bind_addr);
EXPECT_EQ_INT(getsockname(listener, (struct sockaddr*)&bind_addr, &addr_len), 0);
int dialer = socket(AF_INET, SOCK_STREAM, 0);
EXPECT_TRUE(dialer >= 0);
EXPECT_EQ_INT(connect(dialer, (const struct sockaddr*)&bind_addr, sizeof(bind_addr)), 0);
int accepted = accept(listener, NULL, NULL);
EXPECT_TRUE(accepted >= 0);
EXPECT_TRUE(utils_fd_peer_is_local(accepted));
close(accepted);
close(dialer);
close(listener);
}
void test_shared_utils() {
test_walker_removes_extras_keeps_manifest_and_protected();
test_walker_max_delete_exceeded_deletes_nothing();
test_walker_max_delete_exact_bound_deletes();
test_walker_unlimited_deletes_all();
test_walker_hard_bound_all_or_nothing();
test_loopback_helpers();
/* --append / --append-verify tail-resume math: a resume is eligible only for
a shorter existing destination, and the tail length is then the difference. */