feat(a7): SCRAM-SHA-256 daemon auth to replace replayable digest

Replace the challenge-less static-SHA-256 daemon bearer credential with a
SCRAM-SHA-256-style challenge/response and a salted PBKDF2 verifier store.
PROTOCOL_VERSION 2.18.0 -> 2.19.0; legacy user:SHA256HEX stores hard-reject.

- credentials: b64/rand/PBKDF2/HMAC primitives, verifier store parser,
  constant-time proof verify + ServerSignature, --hash-credentials helper
- config: auth block is now [present][username]; client runs the challenge
  exchange; config_burn_auth wipes plaintext/derived secrets (A7-4)
- server: gate drives the challenge, dummy verifier for unknown/off-list users
- tests: independent Python KAT, replay + legacy integration tests, fuzz paths
- docs: new store format, --hash-credentials, 2.19.0 bump

TLS verification behavior (A7-3/S1) is intentionally unchanged.
This commit is contained in:
2026-09-12 17:19:33 +02:00
parent 1ba6372017
commit 8c94ec9886
18 changed files with 1828 additions and 477 deletions
+244 -22
View File
@@ -5,18 +5,25 @@ 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 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.
any data moves. The A7 auth wave 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, proven
through a SCRAM-SHA-256-style challenge/response against a salted PBKDF2
verifier. The daemon refuses to start when such a module has no credential
store, a legacy SHA-256 store line is hard-rejected, and a replayed response
from another connection is refused.
"""
import base64
import glob
import hashlib
import hmac
import os
import select
import shutil
import signal
import socket
import stat
import struct
import subprocess
import sys
import tempfile
@@ -58,9 +65,39 @@ ALICE_PASS = "alice-s3cret"
BOB_PASS = "bob-s3cret"
WRONG_PASS = "wrong-password"
# The store holds a salted PBKDF2 verifier (A7 SCRAM); this is the exact
# derivation the C implementation performs, recomputed here so the tests are an
# independent reference. 100000 keeps the module import fast while staying at
# the validation minimum.
CRED_ITERS = 100000
def _pw_hash(password):
return hashlib.sha256(password.encode()).hexdigest()
def _verifier(password, salt, iters=CRED_ITERS):
key = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, iters, 32)
client_key = hmac.new(key, b"Client Key", hashlib.sha256).digest()
stored_key = hashlib.sha256(client_key).digest()
server_key = hmac.new(key, b"Server Key", hashlib.sha256).digest()
return stored_key, server_key
def _store_line(user, password, iters=CRED_ITERS, salt=None):
if salt is None:
salt = os.urandom(16)
stored_key, server_key = _verifier(password, salt, iters)
return "%s:$fastsync$1$pbkdf2-sha256$%d$%s$%s$%s" % (
user, iters, base64.b64encode(salt).decode(),
base64.b64encode(stored_key).decode(), base64.b64encode(server_key).decode())
def _store_secrets(line):
"""The base64 stored_key/server_key fields of a store line (the values that
must never appear in a log)."""
parts = line.split("$")
return parts[-2], parts[-1]
ALICE_LINE = _store_line("alice", ALICE_PASS)
BOB_LINE = _store_line("bob", BOB_PASS)
def _write_client_password_file(path, user, password):
@@ -159,12 +196,12 @@ def daemon_env():
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).
# Server-side credential store: alice and bob (salted PBKDF2 verifiers 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))
f.write("# daemon credential store (A7 SCRAM)\n")
f.write(ALICE_LINE + "\n")
f.write(BOB_LINE + "\n")
os.chmod(CRED_FILE, 0o600)
# The config's port is a free port chosen per worker; the `daemon` fixture
@@ -518,6 +555,135 @@ class TestDaemonRejection:
d.stop()
# Numeric status values (must match the enum order in src/shared/protocol.h).
STATUS_AUTH_CHALLENGE = 21
STATUS_AUTH_RESPONSE = 22
_AUTH_FRAME_MAX = 1 << 20
def _wire_string_frame_len(buf, off):
"""Return the total byte length of the wire string at buf[off], or None when
more bytes are needed."""
if len(buf) < off + 8:
return None
(length,) = struct.unpack_from("<Q", buf, off)
if length > _AUTH_FRAME_MAX:
raise ValueError("oversized auth frame string")
if len(buf) < off + 8 + length:
return None
return 8 + length
def _client_cmd(dest, port, cred_path):
return CLIENT_CMD + ["--source-dir", SOURCE_DIR, "--dest-dir", dest,
"--save-to-disk", "--server-port", str(port),
"--password-file", cred_path]
class _AuthReplayProxy:
"""A one-connection-at-a-time TCP relay in front of the daemon.
The capture connection records the client's STATUS_AUTH_RESPONSE frame (the
status, the client nonce string and the proof string); the replay connection
substitutes that recorded frame for its own response, so the daemon sees a
proof bound to the FIRST connection's challenge nonce."""
def __init__(self, backend_port):
self.backend = ("127.0.0.1", backend_port)
self.server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
self.server.bind(("127.0.0.1", 0))
self.server.listen(4)
self.server.settimeout(20)
self.port = self.server.getsockname()[1]
self.stolen = None
def close(self):
try:
self.server.close()
except OSError:
pass
def _run_connection(self, capture):
client, _ = self.server.accept()
backend = socket.create_connection(self.backend, timeout=20)
client.settimeout(20)
backend.settimeout(20)
buf_c = b""
buf_s = b""
state = "config"
try:
while True:
ready, _, _ = select.select([client, backend], [], [], 20)
if not ready:
break
eof = False
for sock in ready:
data = sock.recv(65536)
if not data:
eof = True
continue
if sock is client:
buf_c += data
else:
buf_s += data
if state == "config":
if buf_c:
backend.sendall(buf_c)
buf_c = b""
if len(buf_s) >= 4:
(status,) = struct.unpack_from("<i", buf_s, 0)
if status == STATUS_AUTH_CHALLENGE:
off = 4 + 4 # status int + iteration int
for _ in range(2):
frame = _wire_string_frame_len(buf_s, off)
if frame is None:
break
off += frame
else:
client.sendall(buf_s[:off])
buf_s = buf_s[off:]
state = "auth"
else:
if buf_s:
client.sendall(buf_s)
buf_s = b""
state = "relay"
elif state == "auth":
if len(buf_c) >= 4:
off = 4
for _ in range(2):
frame = _wire_string_frame_len(buf_c, off)
if frame is None:
break
off += frame
else:
response = buf_c[:off]
buf_c = buf_c[off:]
if capture:
self.stolen = response
backend.sendall(response)
else:
assert self.stolen is not None
backend.sendall(self.stolen)
state = "relay"
if buf_s:
client.sendall(buf_s)
buf_s = b""
else:
if buf_c:
backend.sendall(buf_c)
buf_c = b""
if buf_s:
client.sendall(buf_s)
buf_s = b""
if eof:
break
finally:
client.close()
backend.close()
class TestDaemonAuthentication:
"""Wave B password authentication round-trips on the shared daemon (its
config declares `locked` with `auth users = alice` and `team` with
@@ -640,8 +806,63 @@ class TestDaemonAuthentication:
finally:
d.stop()
@pytest.mark.ci
def test_replayed_auth_response_rejected(self, daemon):
"""A7 replay defense: an auth response captured from one connection is
refused on a second connection (the proof is bound to the challenge
nonce), and nothing is written to the module root."""
proxy = _AuthReplayProxy(daemon.port)
try:
cred_a = os.path.join(TEST_DATA_DIR, "replay_a.pw")
_write_client_password_file(cred_a, "alice", ALICE_PASS)
proc_a = subprocess.Popen(_client_cmd("127.0.0.1::locked", proxy.port, cred_a),
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
proxy._run_connection(capture=True)
out_a, err_a = proc_a.communicate(timeout=30)
assert proc_a.returncode == 0, err_a or out_a
assert proxy.stolen is not None
os.unlink(cred_a)
before = _tree_file_count(AUTH_MODULE)
cred_b = os.path.join(TEST_DATA_DIR, "replay_b.pw")
_write_client_password_file(cred_b, "alice", ALICE_PASS)
proc_b = subprocess.Popen(_client_cmd("127.0.0.1::locked", proxy.port, cred_b),
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
proxy._run_connection(capture=False)
out_b, err_b = proc_b.communicate(timeout=30)
assert proc_b.returncode != 0, "a replayed auth response must be refused"
assert _tree_file_count(AUTH_MODULE) == before, \
"a replayed auth response wrote data"
os.unlink(cred_b)
finally:
proxy.close()
def test_legacy_store_refuses_to_start(self):
"""A legacy `user:SHA256HEX` store is hard-rejected: the daemon must not
start and must never accept a replayable bearer digest."""
legacy = os.path.join(TEST_DATA_DIR, "fastsyncd_legacy.passwd")
with open(legacy, "w") as f:
f.write("alice:9b90e524e94995ee4aeae2ee3c428a53405d1e8db147f44facc46797d0caf4c3\n")
os.chmod(legacy, 0o600)
conf = os.path.join(TEST_DATA_DIR, "fastsyncd_legacy.conf")
port = _find_free_port()
with open(conf, "w") as f:
f.write("port = %d\n\n[locked]\npath = %s\nauth users = alice\n" % (port, AUTH_MODULE))
try:
proc = subprocess.run(
SERVER_CMD + ["--daemon", "--config", conf, "--no-detach",
"--password-file", legacy],
capture_output=True, text=True, timeout=15)
assert proc.returncode != 0
combined = (proc.stderr or "") + (proc.stdout or "")
assert "legacy" in combined
assert "alice" in combined
finally:
os.unlink(legacy)
os.unlink(conf)
def test_auth_log_does_not_leak_password(self, daemon):
"""The daemon log must never contain the password or its digest."""
"""The daemon log must never contain the password or the store verifier."""
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)
@@ -652,15 +873,15 @@ class TestDaemonAuthentication:
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
for secret in _store_secrets(ALICE_LINE):
assert secret not in tail
assert "$fastsync$" not in tail
def test_auth_digest_not_logged_at_debug_level(self):
def test_auth_secrets_not_logged_at_debug_level(self):
"""Under --verbose the daemon enables LOG_DEBUG_ALL, which normally
traces every protocol string -- the auth username/digest must NOT leak
into that trace even then. The redacted marker is logged instead, and
the digest/username/password never appear while debug protocol logging
is actually proving itself active."""
traces every protocol string -- the auth username/proof/signature must
NOT leak into that trace even then. The redacted marker is logged
instead, while debug protocol logging is actually proving itself active."""
d = DaemonManager()
port = _find_free_port()
try:
@@ -680,8 +901,9 @@ class TestDaemonAuthentication:
# The secret-worthy fields must never appear, at any log level.
assert ALICE_PASS not in log
assert WRONG_PASS not in log
assert _pw_hash(ALICE_PASS) not in log
assert _pw_hash(WRONG_PASS) not in log
for secret in _store_secrets(ALICE_LINE):
assert secret not in log
assert "$fastsync$" not in log
class TestDaemonMotd:
+3 -3
View File
@@ -94,14 +94,14 @@ def _seed_protocol_source(source):
class TestProtocol:
@pytest.mark.ci
def test_protocol_current_version_accepted(self, shared_server):
"""--protocol=2.18.0 (the current PROTOCOL_VERSION) is accepted and the
"""--protocol=2.19.0 (the current PROTOCOL_VERSION) is accepted and the
transfer completes normally."""
source = os.path.join(TEST_DATA_DIR, "proto_ok_src")
dest = os.path.join(TEST_DATA_DIR, "proto_ok_dst")
shutil.rmtree(dest, ignore_errors=True)
os.makedirs(dest)
_seed_protocol_source(source)
result, _ = run_client(source, dest, flags=["--protocol=2.18.0"],
result, _ = run_client(source, dest, flags=["--protocol=2.19.0"],
port=shared_server.port)
assert result.returncode == 0, \
f"--protocol current run failed: {(result.stderr or result.stdout)[:400]}"
@@ -118,7 +118,7 @@ class TestProtocol:
shutil.rmtree(dest, ignore_errors=True)
os.makedirs(dest)
_seed_protocol_source(source)
for bad in ("2.17.0", "2.15.0", "2.16.0", "216", "31"):
for bad in ("2.18.0", "2.17.0", "2.15.0", "2.16.0", "216", "31"):
result, _ = run_client(source, dest, flags=[f"--protocol={bad}"],
port=shared_server.port)
assert result.returncode != 0, f"--protocol={bad} should be rejected"