feat(d5-daemon-auth): password auth, --password-file, --early-input
This commit is contained in:
@@ -4,9 +4,15 @@ These exercise the Wave A daemon foundation end to end: a fastsync-server
|
||||
started with --daemon reads a FastSync-native module config file, the client
|
||||
asks for a module with a host::module/path destination, and the transfer lands
|
||||
in the configured module root only. Read-only modules, unknown modules, and
|
||||
auth-required modules are all refused cleanly before any data moves.
|
||||
auth-required modules without valid credentials are all refused cleanly before
|
||||
any data moves. Wave B (daemon authentication) adds the real credential
|
||||
round-trips exercised in TestDaemonAuthentication: modules that declare
|
||||
`auth users` accept only a client whose --password-file presents a username on
|
||||
the module's list with a matching password (verified as a SHA-256 digest), and
|
||||
the daemon refuses to start when such a module has no credential store.
|
||||
"""
|
||||
import glob
|
||||
import hashlib
|
||||
import os
|
||||
import shutil
|
||||
import signal
|
||||
@@ -34,11 +40,31 @@ MODULE_ROOT = os.path.join(TEST_DATA_DIR, "daemon_modules")
|
||||
FILES_MODULE = os.path.join(MODULE_ROOT, "files")
|
||||
READONLY_MODULE = os.path.join(MODULE_ROOT, "readonly")
|
||||
AUTH_MODULE = os.path.join(MODULE_ROOT, "auth")
|
||||
TEAM_MODULE = os.path.join(MODULE_ROOT, "team")
|
||||
CONF_FILE = os.path.join(TEST_DATA_DIR, "fastsyncd.conf")
|
||||
CRED_FILE = os.path.join(TEST_DATA_DIR, "fastsyncd.passwd")
|
||||
STARTFAIL_CONF = os.path.join(TEST_DATA_DIR, "fastsyncd_startfail.conf")
|
||||
STARTFAIL_PORT = None
|
||||
DETACH_MODULE = os.path.join(MODULE_ROOT, "detach")
|
||||
DETACH_CONF = os.path.join(TEST_DATA_DIR, "fastsyncd_detach.conf")
|
||||
DETACH_PORT = None
|
||||
|
||||
# Passwords are never sent as plaintext and never logged; these literals are
|
||||
# only hashed into the server credential file / client password file.
|
||||
ALICE_PASS = "alice-s3cret"
|
||||
BOB_PASS = "bob-s3cret"
|
||||
WRONG_PASS = "wrong-password"
|
||||
|
||||
|
||||
def _pw_hash(password):
|
||||
return hashlib.sha256(password.encode()).hexdigest()
|
||||
|
||||
|
||||
def _write_client_password_file(path, user, password):
|
||||
with open(path, "w") as f:
|
||||
f.write("%s:%s\n" % (user, password))
|
||||
return path
|
||||
|
||||
|
||||
def _kill_by_cmdline_marker(marker):
|
||||
"""Send SIGTERM to every running process whose cmdline contains `marker`
|
||||
@@ -67,7 +93,7 @@ class DaemonManager:
|
||||
self._proc = None
|
||||
self._port = None
|
||||
|
||||
def start(self, config_path, port_override=None):
|
||||
def start(self, config_path, port_override=None, extra_args=None):
|
||||
self.stop()
|
||||
# When no override is given the daemon binds the config file's `port`
|
||||
# (the plain config-port path); with an override the --dparam path.
|
||||
@@ -76,6 +102,8 @@ class DaemonManager:
|
||||
"--no-detach"])
|
||||
if port_override is not None:
|
||||
cmd += ["--dparam", f"port={port_override}"]
|
||||
if extra_args:
|
||||
cmd += extra_args
|
||||
log_path = os.path.join(TEST_DATA_DIR, "fastsyncd.log")
|
||||
log = open(log_path, "w")
|
||||
self._proc = subprocess.Popen(
|
||||
@@ -121,11 +149,18 @@ def _config_port(config_path):
|
||||
|
||||
@pytest.fixture(scope="module", autouse=True)
|
||||
def daemon_env():
|
||||
for d in (MODULE_ROOT, FILES_MODULE, READONLY_MODULE, AUTH_MODULE, DETACH_MODULE):
|
||||
for d in (MODULE_ROOT, FILES_MODULE, READONLY_MODULE, AUTH_MODULE, TEAM_MODULE, DETACH_MODULE):
|
||||
shutil.rmtree(d, ignore_errors=True)
|
||||
os.makedirs(d, exist_ok=True)
|
||||
generate_test_files(SOURCE_DIR, full=False)
|
||||
|
||||
# Server-side credential store: alice and bob (password digests only; the
|
||||
# plaintext passwords never appear on the daemon host or in any log).
|
||||
with open(CRED_FILE, "w") as f:
|
||||
f.write("# daemon credential store (Wave B)\n")
|
||||
f.write("alice:%s\n" % _pw_hash(ALICE_PASS))
|
||||
f.write("bob:%s\n" % _pw_hash(BOB_PASS))
|
||||
|
||||
# The config's port is a free port chosen per worker; the `daemon` fixture
|
||||
# boots on it (the config-port path) and the --dparam override test boots a
|
||||
# second daemon on a different port.
|
||||
@@ -145,7 +180,20 @@ def daemon_env():
|
||||
"[locked]\n"
|
||||
"path = %s\n"
|
||||
"auth users = alice\n"
|
||||
% (config_port, FILES_MODULE, READONLY_MODULE, AUTH_MODULE))
|
||||
"\n"
|
||||
"[team]\n"
|
||||
"path = %s\n"
|
||||
"auth users = alice,bob\n"
|
||||
% (config_port, FILES_MODULE, READONLY_MODULE, AUTH_MODULE, TEAM_MODULE))
|
||||
|
||||
# A dedicated config for the fail-closed startup check: an auth-required
|
||||
# module with no credential store must refuse to start. Its own free port
|
||||
# keeps it independent of the running daemon.
|
||||
global STARTFAIL_PORT
|
||||
STARTFAIL_PORT = _find_free_port()
|
||||
with open(STARTFAIL_CONF, "w") as f:
|
||||
f.write("port = %d\n\n[locked]\npath = %s\nauth users = alice\n"
|
||||
% (STARTFAIL_PORT, AUTH_MODULE))
|
||||
|
||||
# A dedicated config for the real (double-fork) detach test: an unique path
|
||||
# lets cleanup identify and kill the orphaned background daemon by cmdline.
|
||||
@@ -163,7 +211,7 @@ def daemon_env():
|
||||
@pytest.fixture(scope="module")
|
||||
def daemon():
|
||||
d = DaemonManager()
|
||||
d.start(CONF_FILE)
|
||||
d.start(CONF_FILE, extra_args=["--password-file", CRED_FILE])
|
||||
yield d
|
||||
d.stop()
|
||||
|
||||
@@ -173,6 +221,23 @@ def _push(dest, port):
|
||||
return result
|
||||
|
||||
|
||||
def _push_with_creds(dest, port, user, password):
|
||||
"""Push using a --password-file carrying user:password (a fresh temp file
|
||||
each call so tests never share mutable state)."""
|
||||
cred_path = os.path.join(TEST_DATA_DIR, f"client_{user}_{os.getpid()}_{time.time_ns()}.pw")
|
||||
_write_client_password_file(cred_path, user, password)
|
||||
try:
|
||||
result, _ = run_client(SOURCE_DIR, dest, port=port,
|
||||
extra_args=["--password-file", cred_path])
|
||||
return result
|
||||
finally:
|
||||
os.unlink(cred_path)
|
||||
|
||||
|
||||
def _tree_file_count(root):
|
||||
return sum(len(files) for _, _, files in os.walk(root)) if os.path.exists(root) else 0
|
||||
|
||||
|
||||
class TestDaemonModuleSelection:
|
||||
@pytest.mark.ci
|
||||
def test_module_transfer(self, daemon):
|
||||
@@ -211,8 +276,7 @@ class TestDaemonRejection:
|
||||
def test_read_only_module_blocked(self, daemon):
|
||||
result = _push("127.0.0.1::readonly", daemon.port)
|
||||
assert result.returncode != 0
|
||||
file_count = sum(len(files) for _, _, files in os.walk(READONLY_MODULE))
|
||||
assert file_count == 0, "read-only module must not receive any file"
|
||||
assert _tree_file_count(READONLY_MODULE) == 0, "read-only module must not receive a file"
|
||||
|
||||
def test_read_only_no_write_anywhere(self, daemon):
|
||||
"""A refused read-only transfer must not add a single file anywhere under
|
||||
@@ -249,11 +313,12 @@ class TestDaemonRejection:
|
||||
result = _push("127.0.0.1::files/../..", daemon.port)
|
||||
assert result.returncode != 0
|
||||
|
||||
def test_auth_required_module_rejected(self, daemon):
|
||||
def test_auth_module_without_credentials_rejected(self, daemon):
|
||||
"""Wave B: an auth-required module refuses a client that presents no
|
||||
credentials (the daemon does not fall open)."""
|
||||
result = _push("127.0.0.1::locked", daemon.port)
|
||||
assert result.returncode != 0
|
||||
file_count = sum(len(files) for _, _, files in os.walk(AUTH_MODULE))
|
||||
assert file_count == 0
|
||||
assert _tree_file_count(AUTH_MODULE) == 0
|
||||
|
||||
@pytest.mark.daemon_detach
|
||||
def test_real_detach_path(self):
|
||||
@@ -282,6 +347,7 @@ class TestDaemonRejection:
|
||||
log_path = os.path.join(TEST_DATA_DIR, "fastsyncd_noauth.log")
|
||||
log = open(log_path, "w")
|
||||
cmd = SERVER_CMD + ["--daemon", "--config", CONF_FILE, "--no-detach",
|
||||
"--password-file", CRED_FILE,
|
||||
"--dparam", f"port={port}"]
|
||||
d._proc = subprocess.Popen(cmd, stdout=log, stderr=log, stdin=subprocess.DEVNULL,
|
||||
start_new_session=True)
|
||||
@@ -297,7 +363,7 @@ class TestDaemonRejection:
|
||||
"""--dparam port=N overrides the config's port and the daemon serves on N."""
|
||||
override = _find_free_port()
|
||||
d = DaemonManager()
|
||||
d.start(CONF_FILE, port_override=override)
|
||||
d.start(CONF_FILE, port_override=override, extra_args=["--password-file", CRED_FILE])
|
||||
try:
|
||||
result = _push("127.0.0.1::files", override)
|
||||
assert result.returncode == 0, result.stderr or result.stdout
|
||||
@@ -305,4 +371,219 @@ class TestDaemonRejection:
|
||||
_, missing = verify_transfer(SOURCE_DIR, received)
|
||||
assert not missing, f"missing: {missing[:5]}"
|
||||
finally:
|
||||
d.stop()
|
||||
d.stop()
|
||||
|
||||
|
||||
class TestDaemonAuthentication:
|
||||
"""Wave B password authentication round-trips on the shared daemon (its
|
||||
config declares `locked` with `auth users = alice` and `team` with
|
||||
`auth users = alice,bob`; the server runs with CRED_FILE holding alice and
|
||||
bob digest entries)."""
|
||||
|
||||
def test_correct_password_succeeds(self, daemon):
|
||||
result = _push_with_creds("127.0.0.1::locked", daemon.port, "alice", ALICE_PASS)
|
||||
assert result.returncode == 0, result.stderr or result.stdout
|
||||
received = get_dest_received_dir(AUTH_MODULE, SOURCE_DIR)
|
||||
mismatches, missing = verify_transfer(SOURCE_DIR, received)
|
||||
assert not missing, f"missing: {missing[:5]}"
|
||||
assert not mismatches, f"mismatch: {mismatches[:5]}"
|
||||
|
||||
def test_wrong_password_rejected_no_data(self, daemon):
|
||||
before = _tree_file_count(AUTH_MODULE)
|
||||
result = _push_with_creds("127.0.0.1::locked", daemon.port, "alice", WRONG_PASS)
|
||||
assert result.returncode != 0
|
||||
assert _tree_file_count(AUTH_MODULE) == before, "wrong password must not write a file"
|
||||
|
||||
def test_unknown_user_rejected(self, daemon):
|
||||
"""A user with a valid-shaped password but no store entry is refused
|
||||
(the daemon must not fall open for unknown users)."""
|
||||
before = _tree_file_count(AUTH_MODULE)
|
||||
result = _push_with_creds("127.0.0.1::locked", daemon.port, "mallory", WRONG_PASS)
|
||||
assert result.returncode != 0
|
||||
assert _tree_file_count(AUTH_MODULE) == before
|
||||
|
||||
def test_user_not_on_module_list_rejected(self, daemon):
|
||||
"""bob's credentials verify against the store, but bob is not on the
|
||||
`locked` module's auth users list, so the connection is refused."""
|
||||
before = _tree_file_count(AUTH_MODULE)
|
||||
result = _push_with_creds("127.0.0.1::locked", daemon.port, "bob", BOB_PASS)
|
||||
assert result.returncode != 0
|
||||
assert _tree_file_count(AUTH_MODULE) == before
|
||||
|
||||
def test_second_module_user_succeeds(self, daemon):
|
||||
"""bob IS on the `team` module's list, so his correct password works
|
||||
there (module list + credential store both gate)."""
|
||||
result = _push_with_creds("127.0.0.1::team", daemon.port, "bob", BOB_PASS)
|
||||
assert result.returncode == 0, result.stderr or result.stdout
|
||||
received = get_dest_received_dir(TEAM_MODULE, SOURCE_DIR)
|
||||
mismatches, missing = verify_transfer(SOURCE_DIR, received)
|
||||
assert not missing, f"missing: {missing[:5]}"
|
||||
assert not mismatches, f"mismatch: {mismatches[:5]}"
|
||||
|
||||
def test_missing_password_file_rejected(self, daemon):
|
||||
"""A client with no --password-file at all is refused by an auth-required
|
||||
module (no credentials on the wire)."""
|
||||
result = _push("127.0.0.1::locked", daemon.port)
|
||||
assert result.returncode != 0
|
||||
|
||||
def test_open_module_ignores_credentials(self, daemon):
|
||||
"""A module WITHOUT `auth users` stays open: credentials sent
|
||||
opportunistically (even wrong ones) are ignored, not required."""
|
||||
result = _push_with_creds("127.0.0.1::files", daemon.port, "alice", WRONG_PASS)
|
||||
assert result.returncode == 0, result.stderr or result.stdout
|
||||
|
||||
def test_read_only_still_refuses_authenticated_client(self, daemon):
|
||||
"""Read-only is orthogonal to auth: an authenticated push to a read-only
|
||||
module is still refused with no data written (Wave A behavior)."""
|
||||
before = _tree_file_count(READONLY_MODULE)
|
||||
result = _push_with_creds("127.0.0.1::readonly", daemon.port, "alice", ALICE_PASS)
|
||||
assert result.returncode != 0
|
||||
assert _tree_file_count(READONLY_MODULE) == before
|
||||
|
||||
def test_password_file_requires_daemon_dest(self, daemon):
|
||||
"""Client-side: --password-file without a host::module/path destination is
|
||||
a client error (fail fast), not a silently ignored flag."""
|
||||
cred_path = os.path.join(TEST_DATA_DIR, "client_local.pw")
|
||||
_write_client_password_file(cred_path, "alice", ALICE_PASS)
|
||||
try:
|
||||
# A plain (non-::) destination with --password-file is rejected client-side.
|
||||
cmd = CLIENT_CMD + ["--source-dir", SOURCE_DIR, "--dest-dir", "/tmp/local-dest-xyz",
|
||||
"--save-to-disk", "--password-file", cred_path,
|
||||
"--server-port", str(daemon.port)]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
assert result.returncode != 0
|
||||
assert "host::module/path" in (result.stderr or result.stdout)
|
||||
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")
|
||||
with open(cred_path, "w") as f:
|
||||
f.write("# nothing here\n")
|
||||
try:
|
||||
cmd = CLIENT_CMD + ["--source-dir", SOURCE_DIR,
|
||||
"--dest-dir", "127.0.0.1::files",
|
||||
"--save-to-disk", "--password-file", cred_path]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
assert result.returncode != 0
|
||||
assert "no 'user:password'" in (result.stderr or result.stdout)
|
||||
finally:
|
||||
os.unlink(cred_path)
|
||||
|
||||
def test_daemon_fails_closed_without_credential_store(self):
|
||||
"""Fail-closed startup: a config with an auth-required module but no
|
||||
--password-file/--early-input refuses to start (never serves open)."""
|
||||
proc = subprocess.run(
|
||||
SERVER_CMD + ["--daemon", "--config", STARTFAIL_CONF, "--no-detach"],
|
||||
capture_output=True, text=True, timeout=15)
|
||||
assert proc.returncode != 0
|
||||
assert "fail closed" in (proc.stderr or proc.stdout)
|
||||
|
||||
def test_daemon_early_input_feeds_credential_store(self):
|
||||
"""--early-input is an alternative credential store source: a daemon
|
||||
started with --early-input (and no --password-file) authenticates alice."""
|
||||
d = DaemonManager()
|
||||
port = _find_free_port()
|
||||
try:
|
||||
d.start(CONF_FILE, port_override=port, extra_args=["--early-input", CRED_FILE])
|
||||
result = _push_with_creds("127.0.0.1::locked", port, "alice", ALICE_PASS)
|
||||
assert result.returncode == 0, result.stderr or result.stdout
|
||||
# Wrong password over the early-input store is still rejected.
|
||||
result = _push_with_creds("127.0.0.1::locked", port, "alice", WRONG_PASS)
|
||||
assert result.returncode != 0
|
||||
finally:
|
||||
d.stop()
|
||||
|
||||
def test_auth_log_does_not_leak_password(self, daemon):
|
||||
"""The daemon log must never contain the password or its digest."""
|
||||
log_path = os.path.join(TEST_DATA_DIR, "fastsyncd.log")
|
||||
before = os.path.getsize(log_path) if os.path.exists(log_path) else 0
|
||||
_push_with_creds("127.0.0.1::locked", daemon.port, "alice", WRONG_PASS)
|
||||
_push_with_creds("127.0.0.1::locked", daemon.port, "alice", ALICE_PASS)
|
||||
time.sleep(0.3)
|
||||
with open(log_path, "rb") as f:
|
||||
f.seek(before)
|
||||
tail = f.read().decode("utf-8", "replace")
|
||||
assert ALICE_PASS not in tail
|
||||
assert WRONG_PASS not in tail
|
||||
assert _pw_hash(ALICE_PASS) not in tail
|
||||
assert _pw_hash(WRONG_PASS) not in tail
|
||||
|
||||
|
||||
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."""
|
||||
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")
|
||||
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")
|
||||
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")
|
||||
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)
|
||||
subprocess.run(["openssl", "x509", "-req", "-in", os.path.join(cert_dir, "server.csr"),
|
||||
"-CA", ca_cert, "-CAkey", ca_key, "-CAcreateserial",
|
||||
"-out", server_cert, "-days", "1",
|
||||
"-extfile", san, "-extensions", "v3_req"], check=True, capture_output=True)
|
||||
subprocess.run(["openssl", "req", "-newkey", "rsa:2048", "-nodes",
|
||||
"-keyout", client_key, "-out", os.path.join(cert_dir, "client.csr"),
|
||||
"-subj", "/CN=fastsync-client"], check=True, capture_output=True)
|
||||
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)
|
||||
return {
|
||||
"ca": ca_cert,
|
||||
"server_cert": server_cert,
|
||||
"server_key": server_key,
|
||||
"client_cert": client_cert,
|
||||
"client_key": client_key,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.skipif(shutil.which("openssl") is None,
|
||||
reason="openssl CLI required to mint test certificates")
|
||||
class TestDaemonTLSAuth:
|
||||
"""TLS + password-auth composition: --client-cn (TLS client identity) and
|
||||
the module password credential check are independent; both can be required
|
||||
on the same auth-required module. Env-dependent: needs the openssl CLI."""
|
||||
|
||||
def test_tls_and_password_auth_compose(self):
|
||||
cert_dir = os.path.join(TEST_DATA_DIR, "daemon_tls_certs")
|
||||
certs = _generate_tls_certs(cert_dir)
|
||||
client_creds = os.path.join(TEST_DATA_DIR, "daemon_tls_client.pw")
|
||||
_write_client_password_file(client_creds, "alice", ALICE_PASS)
|
||||
d = DaemonManager()
|
||||
port = _find_free_port()
|
||||
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])
|
||||
tls_flags = ["--tls",
|
||||
"--cert", certs["client_cert"], "--key", certs["client_key"],
|
||||
"--ca", certs["ca"]]
|
||||
# Correct password over TLS, with the right client CN: succeeds.
|
||||
result, _ = run_client(SOURCE_DIR, "127.0.0.1::locked", port=port,
|
||||
flags=tls_flags, extra_args=["--password-file", client_creds])
|
||||
assert result.returncode == 0, (result.stderr or result.stdout)[:300]
|
||||
# Wrong password over TLS is still refused by the credential check.
|
||||
bad_creds = os.path.join(TEST_DATA_DIR, "daemon_tls_client_bad.pw")
|
||||
_write_client_password_file(bad_creds, "alice", WRONG_PASS)
|
||||
result, _ = run_client(SOURCE_DIR, "127.0.0.1::locked", port=port,
|
||||
flags=tls_flags, extra_args=["--password-file", bad_creds])
|
||||
assert result.returncode != 0
|
||||
os.unlink(bad_creds)
|
||||
finally:
|
||||
d.stop()
|
||||
os.unlink(client_creds)
|
||||
shutil.rmtree(cert_dir, ignore_errors=True)
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
#include "test_client_cli.h"
|
||||
#include "test_compression.h"
|
||||
#include "test_config.h"
|
||||
#include "test_credentials.h"
|
||||
#include "test_data.h"
|
||||
#include "test_daemon_conf.h"
|
||||
#include "test_delay_updates.h"
|
||||
@@ -48,6 +49,7 @@ int main() {
|
||||
RUN_TEST(test_chunk);
|
||||
RUN_TEST(test_change_list);
|
||||
RUN_TEST(test_config);
|
||||
RUN_TEST(test_credentials);
|
||||
RUN_TEST(test_compression);
|
||||
RUN_TEST(test_scanner);
|
||||
RUN_TEST(test_checksum);
|
||||
|
||||
@@ -2772,6 +2772,26 @@ static void test_parse_args_remote_option_no_short_M() {
|
||||
config_delete(cfg);
|
||||
}
|
||||
|
||||
/* --password-file stores its path on the config (the file is read later, once
|
||||
* the destination form is known). */
|
||||
static void test_parse_args_password_file() {
|
||||
Config* cfg = valid_client_config();
|
||||
EXPECT_NOT_NULL(cfg);
|
||||
char* argv[] = {"fastsync", "--source-dir", "/src",
|
||||
"--dest-dir", "/dst", "--password-file=/etc/fast.pw"};
|
||||
int positional_args[2];
|
||||
int positional_count = 0;
|
||||
EXPECT_EQ_INT(parse_args(cfg, 6, argv, positional_args, &positional_count), 0);
|
||||
EXPECT_EQ_STR(cfg->password_file, "/etc/fast.pw");
|
||||
|
||||
char* argv2[] = {"fastsync", "--source-dir", "/src", "--dest-dir",
|
||||
"/dst", "--password-file", "/etc/other.pw"};
|
||||
positional_count = 0;
|
||||
EXPECT_EQ_INT(parse_args(cfg, 7, argv2, positional_args, &positional_count), 0);
|
||||
EXPECT_EQ_STR(cfg->password_file, "/etc/other.pw");
|
||||
config_delete(cfg);
|
||||
}
|
||||
|
||||
void test_client_cli() {
|
||||
test_validate_config_required_paths();
|
||||
test_parse_args_numeric_ids();
|
||||
@@ -2914,4 +2934,5 @@ void test_client_cli() {
|
||||
test_parse_args_remote_option_missing_value();
|
||||
test_parse_args_remote_option_rejects_bad_values();
|
||||
test_parse_args_remote_option_no_short_M();
|
||||
test_parse_args_password_file();
|
||||
}
|
||||
|
||||
@@ -245,6 +245,88 @@ static void test_config_module_wire_empty_canonicalizes_to_null() {
|
||||
}
|
||||
}
|
||||
|
||||
/* Daemon auth credentials (Wave B) ride the config frame: username + SHA-256
|
||||
* hex digest are present together, or both are absent. Round-trip a present
|
||||
* pair. */
|
||||
static void test_config_daemon_auth_wire_roundtrip() {
|
||||
Config* send_cfg = config_create();
|
||||
EXPECT_NOT_NULL(send_cfg);
|
||||
send_cfg->send_directory = str_dup("/src");
|
||||
send_cfg->receive_root_directory = str_dup("rel/path");
|
||||
send_cfg->module = str_dup("backup");
|
||||
send_cfg->auth_user = str_dup("alice");
|
||||
send_cfg->auth_password_hash =
|
||||
str_dup("9b90e524e94995ee4aeae2ee3c428a53405d1e8db147f44facc46797d0caf4c3");
|
||||
|
||||
int p[2];
|
||||
EXPECT_EQ_INT(socketpair(AF_UNIX, SOCK_STREAM, 0, p), 0);
|
||||
io_set_fds(p[0], p[1]);
|
||||
io_set_bwlimit(0);
|
||||
|
||||
pid_t pid = fork();
|
||||
if (pid == 0) {
|
||||
close(p[1]);
|
||||
io_set_fds(p[0], p[0]);
|
||||
Config* recv_cfg = config_receive(p[0]);
|
||||
bool ok = recv_cfg != NULL && recv_cfg->auth_user != NULL &&
|
||||
strcmp(recv_cfg->auth_user, "alice") == 0 && recv_cfg->auth_password_hash != NULL &&
|
||||
strcmp(recv_cfg->auth_password_hash,
|
||||
"9b90e524e94995ee4aeae2ee3c428a53405d1e8db147f44facc46797d0caf4c3") == 0;
|
||||
config_delete(recv_cfg);
|
||||
close(p[0]);
|
||||
_exit(ok ? 0 : 1);
|
||||
} else {
|
||||
close(p[0]);
|
||||
io_set_fds(p[1], p[1]);
|
||||
bool sent = config_send(p[1], send_cfg);
|
||||
int status;
|
||||
waitpid(pid, &status, 0);
|
||||
close(p[1]);
|
||||
config_delete(send_cfg);
|
||||
EXPECT_TRUE(sent);
|
||||
EXPECT_TRUE(WIFEXITED(status) && WEXITSTATUS(status) == 0);
|
||||
}
|
||||
}
|
||||
|
||||
/* The receive side validates the auth payload: a present-but-malformed digest
|
||||
* is refused (config_receive returns NULL), so a hostile peer cannot slip a
|
||||
* garbage credential past the receive guard into the module gate. */
|
||||
static void test_config_daemon_auth_wire_rejects_malformed() {
|
||||
Config* send_cfg = config_create();
|
||||
EXPECT_NOT_NULL(send_cfg);
|
||||
send_cfg->send_directory = str_dup("/src");
|
||||
send_cfg->receive_root_directory = str_dup("/dst");
|
||||
send_cfg->module = str_dup("m");
|
||||
send_cfg->auth_user = str_dup("alice");
|
||||
send_cfg->auth_password_hash = str_dup("not-a-valid-sha256-hex-digest!!");
|
||||
|
||||
int p[2];
|
||||
EXPECT_EQ_INT(socketpair(AF_UNIX, SOCK_STREAM, 0, p), 0);
|
||||
io_set_fds(p[0], p[1]);
|
||||
io_set_bwlimit(0);
|
||||
|
||||
pid_t pid = fork();
|
||||
if (pid == 0) {
|
||||
close(p[1]);
|
||||
io_set_fds(p[0], p[0]);
|
||||
Config* recv_cfg = config_receive(p[0]);
|
||||
bool ok = recv_cfg == NULL;
|
||||
config_delete(recv_cfg);
|
||||
close(p[0]);
|
||||
_exit(ok ? 0 : 1);
|
||||
} else {
|
||||
close(p[0]);
|
||||
io_set_fds(p[1], p[1]);
|
||||
bool sent = config_send(p[1], send_cfg);
|
||||
int status;
|
||||
waitpid(pid, &status, 0);
|
||||
close(p[1]);
|
||||
config_delete(send_cfg);
|
||||
EXPECT_FALSE(sent);
|
||||
EXPECT_TRUE(WIFEXITED(status) && WEXITSTATUS(status) == 0);
|
||||
}
|
||||
}
|
||||
|
||||
/* A module gate that rejects any connection that names a module. */
|
||||
static const char* reject_named_module_gate(const Config* config, void* context) {
|
||||
(void)context;
|
||||
@@ -1524,6 +1606,8 @@ void test_config() {
|
||||
test_config_phase4_xattr_wire_roundtrip();
|
||||
test_config_module_wire_roundtrip();
|
||||
test_config_module_wire_empty_canonicalizes_to_null();
|
||||
test_config_daemon_auth_wire_roundtrip();
|
||||
test_config_daemon_auth_wire_rejects_malformed();
|
||||
test_config_receive_with_validate_rejects();
|
||||
}
|
||||
test_config_delete_timing_early_helper();
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
#include "test_credentials.h"
|
||||
#include "credentials.h"
|
||||
#include "test_utils.h"
|
||||
#include <errno.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/types.h>
|
||||
#include <unistd.h>
|
||||
|
||||
/* Known SHA-256 vectors pin the digest derivation to real SHA-256 so a change
|
||||
* in the hashing (or a wire/store format change) is observable. */
|
||||
#define SHA256_EMPTY "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
#define SHA256_SECRET "2bb80d537b1da3e38bd30361aa855686bde0eacd7162fef6a25fe97bf527a25b"
|
||||
#define SHA256_ALICE_PASS "9b90e524e94995ee4aeae2ee3c428a53405d1e8db147f44facc46797d0caf4c3"
|
||||
|
||||
static int g_file_counter = 0;
|
||||
|
||||
/* Write `contents` to a uniquely-named temp file and return a malloc'd path
|
||||
* (the caller frees it; the file is removed at the end of the test process or
|
||||
* on request via rm_temp). */
|
||||
static char* make_tmp_file(const char* contents) {
|
||||
char path[256];
|
||||
snprintf(path, sizeof(path), "/tmp/fs_cred_test_%d_%d", (int)getpid(), g_file_counter++);
|
||||
FILE* fp = fopen(path, "w");
|
||||
if (!fp)
|
||||
return NULL;
|
||||
size_t n = strlen(contents);
|
||||
if (n > 0 && fwrite(contents, 1, n, fp) != n) {
|
||||
fclose(fp);
|
||||
unlink(path);
|
||||
return NULL;
|
||||
}
|
||||
fclose(fp);
|
||||
return strdup(path);
|
||||
}
|
||||
|
||||
static void rm_temp(const char* path) {
|
||||
if (path)
|
||||
unlink(path);
|
||||
}
|
||||
|
||||
static void test_credentials_hash_vectors() {
|
||||
char out[CREDENTIAL_HASH_HEX_LEN + 1];
|
||||
EXPECT_TRUE(credentials_hash_password("", out));
|
||||
EXPECT_EQ_STR(out, SHA256_EMPTY);
|
||||
EXPECT_TRUE(credentials_hash_password("secret", out));
|
||||
EXPECT_EQ_STR(out, SHA256_SECRET);
|
||||
EXPECT_TRUE(credentials_hash_password("alice-pass", out));
|
||||
EXPECT_EQ_STR(out, SHA256_ALICE_PASS);
|
||||
EXPECT_FALSE(credentials_hash_password(NULL, out));
|
||||
EXPECT_FALSE(credentials_hash_password("x", NULL));
|
||||
}
|
||||
|
||||
static void test_credentials_hash_valid() {
|
||||
EXPECT_TRUE(credentials_hash_valid(SHA256_SECRET));
|
||||
EXPECT_FALSE(credentials_hash_valid(NULL));
|
||||
EXPECT_FALSE(credentials_hash_valid(""));
|
||||
/* Wrong length. */
|
||||
EXPECT_FALSE(credentials_hash_valid("abc"));
|
||||
EXPECT_FALSE(
|
||||
credentials_hash_valid("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"));
|
||||
EXPECT_FALSE(
|
||||
credentials_hash_valid("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"));
|
||||
/* Uppercase hex and non-hex are rejected. */
|
||||
EXPECT_FALSE(
|
||||
credentials_hash_valid("2BB80D537B1DA3E38BD30361AA855686BDE0EACD7162FEF6A25FE97BF527A25B"));
|
||||
EXPECT_FALSE(
|
||||
credentials_hash_valid("gbb80d537b1da3e38bd30361aa855686bde0eacd7162fef6a25fe97bf527a25b"));
|
||||
}
|
||||
|
||||
static void test_credentials_secure_equal() {
|
||||
EXPECT_TRUE(credentials_secure_equal("abc", "abc", 3));
|
||||
EXPECT_TRUE(credentials_secure_equal("", "", 0));
|
||||
EXPECT_FALSE(credentials_secure_equal("abc", "abd", 3));
|
||||
EXPECT_TRUE(credentials_secure_equal("abc", "ab", 2));
|
||||
/* Same prefix, difference at the very last byte must still be detected. */
|
||||
EXPECT_FALSE(credentials_secure_equal(SHA256_SECRET, SHA256_ALICE_PASS, CREDENTIAL_HASH_HEX_LEN));
|
||||
}
|
||||
|
||||
static void test_credentials_store_parse_valid() {
|
||||
char* path = make_tmp_file(
|
||||
"# server credential store\n"
|
||||
"; another comment style\n"
|
||||
"\n"
|
||||
"alice:9b90e524e94995ee4aeae2ee3c428a53405d1e8db147f44facc46797d0caf4c3\n"
|
||||
" bob : 2bb80d537b1da3e38bd30361aa855686bde0eacd7162fef6a25fe97bf527a25b \n"
|
||||
"carol:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\r\n");
|
||||
EXPECT_NOT_NULL(path);
|
||||
char err[512];
|
||||
CredentialStore* store = credentials_load(path, NULL, err, sizeof(err));
|
||||
EXPECT_NOT_NULL(store);
|
||||
EXPECT_EQ_INT(credentials_store_size(store), 3);
|
||||
EXPECT_TRUE(credentials_store_has(store, "alice"));
|
||||
EXPECT_TRUE(credentials_store_has(store, "bob"));
|
||||
EXPECT_TRUE(credentials_store_has(store, "carol"));
|
||||
EXPECT_FALSE(credentials_store_has(store, "mallory"));
|
||||
EXPECT_FALSE(credentials_store_has(store, "ALICE"));
|
||||
EXPECT_TRUE(credentials_verify(store, "alice", SHA256_ALICE_PASS));
|
||||
EXPECT_TRUE(credentials_verify(store, "bob", SHA256_SECRET));
|
||||
EXPECT_TRUE(credentials_verify(store, "carol", SHA256_EMPTY));
|
||||
EXPECT_FALSE(credentials_verify(store, "alice", SHA256_SECRET));
|
||||
EXPECT_FALSE(credentials_verify(store, "mallory", SHA256_ALICE_PASS));
|
||||
credentials_free(store);
|
||||
rm_temp(path);
|
||||
free(path);
|
||||
}
|
||||
|
||||
static void test_credentials_store_parse_rejects_malformed() {
|
||||
const char* cases[] = {
|
||||
/* no colon */
|
||||
"alice\n",
|
||||
/* empty user */
|
||||
":9b90e524e94995ee4aeae2ee3c428a53405d1e8db147f44facc46797d0caf4c3\n",
|
||||
/* empty secret */
|
||||
"alice:\n",
|
||||
/* secret too short */
|
||||
"alice:8ce9c8b52c5\n",
|
||||
/* secret not hex */
|
||||
"alice:zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz\n",
|
||||
/* uppercase hex rejected (strict) */
|
||||
"alice:2BB80D537B1DA3E38BD30361AA855686BDE0EACD7162FEF6A25FE97BF527A25B\n",
|
||||
/* whitespace inside the username */
|
||||
"ali ce:2bb80d537b1da3e38bd30361aa855686bde0eacd7162fef6a25fe97bf527a25b\n",
|
||||
/* duplicate user within one file */
|
||||
"alice:9b90e524e94995ee4aeae2ee3c428a53405d1e8db147f44facc46797d0caf4c3\n"
|
||||
"alice:2bb80d537b1da3e38bd30361aa855686bde0eacd7162fef6a25fe97bf527a25b\n",
|
||||
};
|
||||
for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) {
|
||||
char* path = make_tmp_file(cases[i]);
|
||||
EXPECT_NOT_NULL(path);
|
||||
char err[512];
|
||||
const CredentialStore* store = credentials_load(path, NULL, err, sizeof(err));
|
||||
EXPECT_NULL(store);
|
||||
EXPECT_TRUE(err[0] != '\0');
|
||||
rm_temp(path);
|
||||
free(path);
|
||||
}
|
||||
}
|
||||
|
||||
static void test_credentials_store_parse_missing_file() {
|
||||
char err[512];
|
||||
const CredentialStore* store =
|
||||
credentials_load("/nonexistent/cred-file-xyz", NULL, err, sizeof(err));
|
||||
EXPECT_NULL(store);
|
||||
EXPECT_TRUE(strstr(err, "cannot open") != NULL);
|
||||
}
|
||||
|
||||
static void test_credentials_store_empty_and_null() {
|
||||
char err[512];
|
||||
/* A NULL path is a valid (empty) store: no module can authenticate, which is
|
||||
* the fail-closed state the startup check turns into a refusal to start. */
|
||||
CredentialStore* store = credentials_load(NULL, NULL, err, sizeof(err));
|
||||
EXPECT_NOT_NULL(store);
|
||||
EXPECT_EQ_INT(credentials_store_size(store), 0);
|
||||
EXPECT_FALSE(credentials_verify(store, "alice", SHA256_ALICE_PASS));
|
||||
credentials_free(store);
|
||||
|
||||
/* A blank/comment-only file is an empty store too (not an error). */
|
||||
char* path = make_tmp_file("# nothing here\n; nor here\n");
|
||||
EXPECT_NOT_NULL(path);
|
||||
store = credentials_load(path, NULL, err, sizeof(err));
|
||||
EXPECT_NOT_NULL(store);
|
||||
EXPECT_EQ_INT(credentials_store_size(store), 0);
|
||||
credentials_free(store);
|
||||
rm_temp(path);
|
||||
free(path);
|
||||
}
|
||||
|
||||
static void test_credentials_store_overlong_line_rejected() {
|
||||
char big[CREDENTIAL_MAX_LINE + 80];
|
||||
int n = snprintf(big, sizeof(big), "alice:%s", SHA256_SECRET);
|
||||
memset(big + n, 'a', sizeof(big) - (size_t)n - 1);
|
||||
big[sizeof(big) - 1] = '\n';
|
||||
char* path = make_tmp_file(big);
|
||||
EXPECT_NOT_NULL(path);
|
||||
char err[512];
|
||||
const CredentialStore* store = credentials_load(path, NULL, err, sizeof(err));
|
||||
EXPECT_NULL(store);
|
||||
rm_temp(path);
|
||||
free(path);
|
||||
}
|
||||
|
||||
static void test_credentials_early_input_merge() {
|
||||
char* pw =
|
||||
make_tmp_file("alice:9b90e524e94995ee4aeae2ee3c428a53405d1e8db147f44facc46797d0caf4c3\n");
|
||||
EXPECT_NOT_NULL(pw);
|
||||
char err[512];
|
||||
|
||||
/* A second file adds a new user. */
|
||||
char* early =
|
||||
make_tmp_file("bob:2bb80d537b1da3e38bd30361aa855686bde0eacd7162fef6a25fe97bf527a25b\n");
|
||||
EXPECT_NOT_NULL(early);
|
||||
CredentialStore* store = credentials_load(pw, early, err, sizeof(err));
|
||||
EXPECT_NOT_NULL(store);
|
||||
EXPECT_EQ_INT(credentials_store_size(store), 2);
|
||||
EXPECT_TRUE(credentials_verify(store, "alice", SHA256_ALICE_PASS));
|
||||
EXPECT_TRUE(credentials_verify(store, "bob", SHA256_SECRET));
|
||||
credentials_free(store);
|
||||
|
||||
/* The same user with the SAME secret dedupes. */
|
||||
char* early_same =
|
||||
make_tmp_file("alice:9b90e524e94995ee4aeae2ee3c428a53405d1e8db147f44facc46797d0caf4c3\n");
|
||||
EXPECT_NOT_NULL(early_same);
|
||||
store = credentials_load(pw, early_same, err, sizeof(err));
|
||||
EXPECT_NOT_NULL(store);
|
||||
EXPECT_EQ_INT(credentials_store_size(store), 1);
|
||||
credentials_free(store);
|
||||
|
||||
/* The same user with a DIFFERENT secret fails closed (ambiguous). */
|
||||
char* early_diff =
|
||||
make_tmp_file("alice:2bb80d537b1da3e38bd30361aa855686bde0eacd7162fef6a25fe97bf527a25b\n");
|
||||
EXPECT_NOT_NULL(early_diff);
|
||||
store = credentials_load(pw, early_diff, err, sizeof(err));
|
||||
EXPECT_NULL(store);
|
||||
EXPECT_TRUE(err[0] != '\0');
|
||||
|
||||
rm_temp(pw);
|
||||
rm_temp(early);
|
||||
rm_temp(early_same);
|
||||
rm_temp(early_diff);
|
||||
free(pw);
|
||||
free(early);
|
||||
free(early_same);
|
||||
free(early_diff);
|
||||
}
|
||||
|
||||
static void test_credentials_read_secret_file() {
|
||||
char err[512];
|
||||
char* user = NULL;
|
||||
char* password = NULL;
|
||||
|
||||
/* Leading comments/blanks skipped; first real line wins. */
|
||||
char* path = make_tmp_file("# password file\n"
|
||||
"\n"
|
||||
"alice:correct horse battery staple\n"
|
||||
"ignored:second line\n");
|
||||
EXPECT_NOT_NULL(path);
|
||||
EXPECT_EQ_INT(credentials_read_secret_file(path, &user, &password, err, sizeof(err)), 0);
|
||||
EXPECT_EQ_STR(user, "alice");
|
||||
EXPECT_EQ_STR(password, "correct horse battery staple");
|
||||
free(user);
|
||||
free(password);
|
||||
user = password = NULL;
|
||||
rm_temp(path);
|
||||
free(path);
|
||||
|
||||
/* CRLF and surrounding whitespace are tolerated. */
|
||||
path = make_tmp_file(" bob : s3cret \r\n");
|
||||
EXPECT_NOT_NULL(path);
|
||||
EXPECT_EQ_INT(credentials_read_secret_file(path, &user, &password, err, sizeof(err)), 0);
|
||||
EXPECT_EQ_STR(user, "bob");
|
||||
EXPECT_EQ_STR(password, "s3cret");
|
||||
free(user);
|
||||
free(password);
|
||||
user = password = NULL;
|
||||
rm_temp(path);
|
||||
free(path);
|
||||
|
||||
/* Empty file / comment-only file rejected. */
|
||||
path = make_tmp_file("");
|
||||
EXPECT_NOT_NULL(path);
|
||||
EXPECT_EQ_INT(credentials_read_secret_file(path, &user, &password, err, sizeof(err)), -1);
|
||||
EXPECT_NULL(user);
|
||||
EXPECT_NULL(password);
|
||||
EXPECT_TRUE(strstr(err, "no 'user:password'") != NULL);
|
||||
rm_temp(path);
|
||||
free(path);
|
||||
}
|
||||
|
||||
static void test_credentials_read_secret_file_bad() {
|
||||
char err[512];
|
||||
const char* cases[] = {
|
||||
/* no colon */
|
||||
"alicepassword\n",
|
||||
/* empty user */
|
||||
":password\n",
|
||||
/* empty password */
|
||||
"alice:\n",
|
||||
/* empty password after whitespace */
|
||||
"alice: \n",
|
||||
};
|
||||
for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) {
|
||||
char* path = make_tmp_file(cases[i]);
|
||||
EXPECT_NOT_NULL(path);
|
||||
char* user = (char*)1;
|
||||
char* password = (char*)1;
|
||||
EXPECT_EQ_INT(credentials_read_secret_file(path, &user, &password, err, sizeof(err)), -1);
|
||||
EXPECT_NULL(user);
|
||||
EXPECT_NULL(password);
|
||||
EXPECT_TRUE(err[0] != '\0');
|
||||
rm_temp(path);
|
||||
free(path);
|
||||
}
|
||||
|
||||
char* missing = "/nonexistent/password-file-xyz";
|
||||
EXPECT_EQ_INT(credentials_read_secret_file(missing, NULL, NULL, err, sizeof(err)), -1);
|
||||
}
|
||||
|
||||
static void test_credentials_gate_allows() {
|
||||
char* path =
|
||||
make_tmp_file("alice:9b90e524e94995ee4aeae2ee3c428a53405d1e8db147f44facc46797d0caf4c3\n"
|
||||
"bob:2bb80d537b1da3e38bd30361aa855686bde0eacd7162fef6a25fe97bf527a25b\n");
|
||||
EXPECT_NOT_NULL(path);
|
||||
char err[512];
|
||||
CredentialStore* store = credentials_load(path, NULL, err, sizeof(err));
|
||||
EXPECT_NOT_NULL(store);
|
||||
|
||||
const char* module_users[] = {"alice", "bob"};
|
||||
|
||||
/* Matching user + digest passes. */
|
||||
EXPECT_TRUE(credentials_gate_allows(store, module_users, 2, "alice", SHA256_ALICE_PASS));
|
||||
EXPECT_TRUE(credentials_gate_allows(store, module_users, 2, "bob", SHA256_SECRET));
|
||||
/* Wrong digest for a listed user fails. */
|
||||
EXPECT_FALSE(credentials_gate_allows(store, module_users, 2, "alice", SHA256_SECRET));
|
||||
/* A store user that is not on the module's list fails. */
|
||||
EXPECT_FALSE(credentials_gate_allows(store, module_users, 2, "alice", SHA256_ALICE_PASS) &&
|
||||
credentials_gate_allows(store, module_users, 1, "bob", SHA256_SECRET));
|
||||
EXPECT_TRUE(credentials_gate_allows(store, module_users, 1, "alice", SHA256_ALICE_PASS));
|
||||
EXPECT_FALSE(credentials_gate_allows(store, module_users, 1, "bob", SHA256_SECRET));
|
||||
/* No credentials presented fails. */
|
||||
EXPECT_FALSE(credentials_gate_allows(store, module_users, 2, NULL, NULL));
|
||||
EXPECT_FALSE(credentials_gate_allows(store, module_users, 2, "alice", NULL));
|
||||
/* Unknown user fails. */
|
||||
EXPECT_FALSE(credentials_gate_allows(store, module_users, 2, "mallory", SHA256_ALICE_PASS));
|
||||
/* Fail closed: a NULL store refuses even with correct credentials. */
|
||||
EXPECT_FALSE(credentials_gate_allows(NULL, module_users, 2, "alice", SHA256_ALICE_PASS));
|
||||
/* An empty module list refuses everyone. */
|
||||
EXPECT_FALSE(credentials_gate_allows(store, NULL, 0, "alice", SHA256_ALICE_PASS));
|
||||
|
||||
credentials_free(store);
|
||||
rm_temp(path);
|
||||
free(path);
|
||||
}
|
||||
|
||||
static void test_credentials_burn() {
|
||||
char secret[32];
|
||||
memcpy(secret, "supersecretvalue", 17);
|
||||
credentials_burn(secret, 16);
|
||||
for (int i = 0; i < 16; i++)
|
||||
EXPECT_EQ_INT(secret[i], 0);
|
||||
credentials_burn(NULL, 0); /* must not crash */
|
||||
}
|
||||
|
||||
void test_credentials(void) {
|
||||
test_credentials_hash_vectors();
|
||||
test_credentials_hash_valid();
|
||||
test_credentials_secure_equal();
|
||||
test_credentials_store_parse_valid();
|
||||
test_credentials_store_parse_rejects_malformed();
|
||||
test_credentials_store_parse_missing_file();
|
||||
test_credentials_store_empty_and_null();
|
||||
test_credentials_store_overlong_line_rejected();
|
||||
test_credentials_early_input_merge();
|
||||
test_credentials_read_secret_file();
|
||||
test_credentials_read_secret_file_bad();
|
||||
test_credentials_gate_allows();
|
||||
test_credentials_burn();
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
#ifndef TEST_CREDENTIALS_H
|
||||
#define TEST_CREDENTIALS_H
|
||||
|
||||
void test_credentials();
|
||||
|
||||
#endif
|
||||
+38
-1
@@ -141,6 +141,41 @@ static void test_server_cli_invalid() {
|
||||
server_cli_options_free(&opts);
|
||||
}
|
||||
|
||||
static void test_server_cli_password_and_early_input() {
|
||||
const char* args[] = {"s", "--daemon", "--password-file=/etc/fast.pw", "--early-input",
|
||||
"/run/secrets"};
|
||||
ServerCliOptions opts;
|
||||
EXPECT_EQ_INT(parse_ok(args, 5, &opts), 0);
|
||||
EXPECT_EQ_STR(opts.password_file, "/etc/fast.pw");
|
||||
EXPECT_EQ_STR(opts.early_input_file, "/run/secrets");
|
||||
|
||||
const char* args2[] = {"s", "--daemon", "--password-file", "/etc/fast.pw",
|
||||
"--early-input=/secrets"};
|
||||
ServerCliOptions opts2;
|
||||
EXPECT_EQ_INT(parse_ok(args2, 5, &opts2), 0);
|
||||
EXPECT_EQ_STR(opts2.password_file, "/etc/fast.pw");
|
||||
EXPECT_EQ_STR(opts2.early_input_file, "/secrets");
|
||||
server_cli_options_free(&opts);
|
||||
server_cli_options_free(&opts2);
|
||||
}
|
||||
|
||||
static void test_server_cli_password_requires_daemon() {
|
||||
char err[256];
|
||||
ServerCliOptions opts;
|
||||
const char* a1[] = {"s", "--password-file", "/etc/fast.pw"};
|
||||
EXPECT_EQ_INT(server_cli_parse(3, (char**)a1, &opts, err, sizeof(err)), -1);
|
||||
EXPECT_TRUE(strstr(err, "require --daemon") != NULL);
|
||||
|
||||
const char* a2[] = {"s", "--early-input", "/secrets"};
|
||||
EXPECT_EQ_INT(server_cli_parse(3, (char**)a2, &opts, err, sizeof(err)), -1);
|
||||
EXPECT_TRUE(strstr(err, "require --daemon") != NULL);
|
||||
|
||||
const char* a3[] = {"s", "--daemon", "--password-file"};
|
||||
EXPECT_EQ_INT(server_cli_parse(3, (char**)a3, &opts, err, sizeof(err)), -1);
|
||||
EXPECT_TRUE(strstr(err, "missing argument") != NULL);
|
||||
server_cli_options_free(&opts);
|
||||
}
|
||||
|
||||
static void test_server_cli_help() {
|
||||
char err[256];
|
||||
const char* a1[] = {"s", "--help"};
|
||||
@@ -157,5 +192,7 @@ void test_server_cli() {
|
||||
test_server_cli_preserves_existing_flags();
|
||||
test_server_cli_conflicts();
|
||||
test_server_cli_invalid();
|
||||
test_server_cli_password_and_early_input();
|
||||
test_server_cli_password_requires_daemon();
|
||||
test_server_cli_help();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user