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
+101
View File
@@ -26,10 +26,12 @@
* pre-loaded into a second pipe so a single thread suffices.
*/
#include "config.h"
#include "credentials.h"
#include "protocol.h"
#include "utils.h"
#include <errno.h>
#include <fcntl.h>
#include <openssl/evp.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
@@ -50,6 +52,7 @@ static unsigned char* g_frame;
static size_t g_frame_len;
static size_t g_version_len; /* length of the leading version-string frame */
static size_t g_usermap_count_off; /* offset of the usermap count int, 0 = unknown */
static size_t g_auth_off; /* offset of the auth presence int, 0 = unknown */
static bool g_frame_ready;
/* Read the canonical frame from the send peer. The producer shuts down its
@@ -97,6 +100,10 @@ static void build_canonical_frame(void) {
return;
cfg->send_directory = str_dup("/src");
cfg->receive_root_directory = str_dup("/dst");
/* Force the shortened auth block (`[present][username]`) to be present so the
* fuzzer can mutate it. */
cfg->auth_user = str_dup("alice");
cfg->auth_password = str_dup("alice-s3cret");
/* Force the three P8 tail fields to be present (copy-as requires metadata). */
cfg->copy_as_set = true;
cfg->copy_as_uid = 0;
@@ -171,6 +178,94 @@ out:
}
}
}
/* Locate the auth username string (a size_t length followed by its bytes);
* the presence int sits one int before the length. */
const char* auth_name = "alice";
size_t auth_name_len = strlen(auth_name);
if (g_frame_len >= sizeof(size_t) + auth_name_len + sizeof(int)) {
for (size_t i = sizeof(size_t); i + auth_name_len <= g_frame_len; i++) {
if (memcmp(g_frame + i, auth_name, auth_name_len) != 0)
continue;
size_t found_len = 0;
memcpy(&found_len, g_frame + i - sizeof(size_t), sizeof(size_t));
if (found_len == auth_name_len) {
g_auth_off = i - sizeof(size_t) - sizeof(int);
break;
}
}
}
}
/* Fuzz the A7 auth crypto primitives directly: arbitrary bytes through the
* base64 decoder, plus a self-consistent SCRAM property (a proof built from a
* chosen client key must verify, while a tampered proof, a proof replayed
* against a different nonce, and a not-found verifier must all be refused). */
static uint8_t pick_byte(const uint8_t* data, size_t size, size_t index) {
return size ? data[index % size] : 0;
}
static void fuzz_credentials(const uint8_t* data, size_t size) {
char b64[300];
size_t n = size < sizeof(b64) - 1 ? size : sizeof(b64) - 1;
memcpy(b64, data, n);
b64[n] = '\0';
uint8_t decoded[64];
size_t decoded_len = 0;
(void)credentials_b64_decode(b64, decoded, sizeof(decoded), &decoded_len);
uint8_t client_key[CREDENTIAL_KEY_LEN];
uint8_t stored_key[CREDENTIAL_KEY_LEN];
uint8_t server_key[CREDENTIAL_KEY_LEN];
uint8_t snonce[CREDENTIAL_NONCE_LEN];
uint8_t cnonce[CREDENTIAL_NONCE_LEN];
for (size_t i = 0; i < CREDENTIAL_KEY_LEN; i++) {
client_key[i] = pick_byte(data, size, i);
server_key[i] = pick_byte(data, size, i + CREDENTIAL_KEY_LEN);
}
for (size_t i = 0; i < CREDENTIAL_NONCE_LEN; i++) {
snonce[i] = pick_byte(data, size, i + 2 * CREDENTIAL_KEY_LEN);
cnonce[i] = pick_byte(data, size, i + 2 * CREDENTIAL_KEY_LEN + CREDENTIAL_NONCE_LEN);
}
unsigned int stored_len = 0;
if (EVP_Digest(client_key, sizeof(client_key), stored_key, &stored_len, EVP_sha256(), NULL) !=
1 ||
stored_len != CREDENTIAL_KEY_LEN)
return;
uint8_t auth_msg[CREDENTIAL_AUTH_MESSAGE_MAX];
size_t msg_len = 0;
if (!credentials_build_auth_message("alice", snonce, cnonce, auth_msg, sizeof(auth_msg),
&msg_len))
return;
uint8_t proof[CREDENTIAL_KEY_LEN];
uint8_t server_sig[CREDENTIAL_KEY_LEN];
if (!credentials_client_proof(client_key, stored_key, server_key, auth_msg, msg_len, proof,
server_sig))
return;
CredentialVerifier verifier;
memset(&verifier, 0, sizeof(verifier));
verifier.found = true;
verifier.iters = CREDENTIAL_DEFAULT_ITERS;
memcpy(verifier.stored_key, stored_key, CREDENTIAL_KEY_LEN);
memcpy(verifier.server_key, server_key, CREDENTIAL_KEY_LEN);
uint8_t out_sig[CREDENTIAL_KEY_LEN];
if (!credentials_verify_response(&verifier, "alice", snonce, cnonce, proof, out_sig))
abort();
if (memcmp(out_sig, server_sig, CREDENTIAL_KEY_LEN) != 0)
abort();
uint8_t bad_proof[CREDENTIAL_KEY_LEN];
memcpy(bad_proof, proof, CREDENTIAL_KEY_LEN);
bad_proof[pick_byte(data, size, 0) % CREDENTIAL_KEY_LEN] ^= 0x01;
if (credentials_verify_response(&verifier, "alice", snonce, cnonce, bad_proof, out_sig))
abort();
uint8_t other_cnonce[CREDENTIAL_NONCE_LEN];
memcpy(other_cnonce, cnonce, CREDENTIAL_NONCE_LEN);
other_cnonce[pick_byte(data, size, 1) % CREDENTIAL_NONCE_LEN] ^= 0x80;
if (credentials_verify_response(&verifier, "alice", snonce, other_cnonce, proof, out_sig))
abort();
verifier.found = false;
if (credentials_verify_response(&verifier, "alice", snonce, cnonce, proof, out_sig))
abort();
}
/* Best-effort non-blocking write: an oversized fuzz input is truncated rather
@@ -219,6 +314,8 @@ static void receive_stream(const unsigned char* prefix, size_t prefix_len, const
}
int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) {
fuzz_credentials(data, size);
if (!g_frame_ready)
build_canonical_frame();
@@ -229,6 +326,10 @@ int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) {
/* Keep the valid version prefix, fuzz everything after it. */
receive_stream(g_frame, g_version_len, data, size);
/* Keep the valid frame up to the shortened auth block, fuzz it. */
if (g_auth_off > 0)
receive_stream(g_frame, g_auth_off, data, size);
/* Keep the valid frame up to the P8 tail, fuzz super_mode + copy-as. */
if (g_frame_len > P8_TAIL_BYTES)
receive_stream(g_frame, g_frame_len - P8_TAIL_BYTES, data, size);
+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"
+4 -4
View File
@@ -223,7 +223,7 @@ static void test_parse_args_protocol_accept_current() {
Config* cfg = valid_client_config();
EXPECT_NOT_NULL(cfg);
char* argv_equals[] = {"fastsync", "--source-dir", "/src",
"--dest-dir", "/dst", "--protocol=2.18.0"};
"--dest-dir", "/dst", "--protocol=2.19.0"};
int positional_args[2];
int positional_count = 0;
EXPECT_EQ_INT(parse_args(cfg, 6, argv_equals, positional_args, &positional_count), 0);
@@ -233,7 +233,7 @@ static void test_parse_args_protocol_accept_current() {
cfg = valid_client_config();
EXPECT_NOT_NULL(cfg);
char* argv_space[] = {"fastsync", "--source-dir", "/src", "--dest-dir",
"/dst", "--protocol", "2.18.0"};
"/dst", "--protocol", "2.19.0"};
positional_count = 0;
EXPECT_EQ_INT(parse_args(cfg, 7, argv_space, positional_args, &positional_count), 0);
EXPECT_EQ_STR(cfg->version, PROTOCOL_VERSION);
@@ -243,8 +243,8 @@ static void test_parse_args_protocol_accept_current() {
/* Any --protocol value other than the current PROTOCOL_VERSION must end in
* failure (parse_args simply stores it; validate_config rejects it up front). */
static void test_parse_args_protocol_rejects_other_versions() {
static const char* const bad_versions[] = {"2.17", "2.16", "2.15.0", "2.16.0", "2.17.0",
"216", "31", "abc", ""};
static const char* const bad_versions[] = {"2.17", "2.16", "2.15.0", "2.16.0", "2.17.0",
"2.18.0", "216", "31", "abc", ""};
for (size_t i = 0; i < sizeof(bad_versions) / sizeof(bad_versions[0]); i++) {
Config* cfg = valid_client_config();
EXPECT_NOT_NULL(cfg);
+8 -11
View File
@@ -246,9 +246,9 @@ 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. */
/* Daemon auth credentials (A7, protocol 2.19.0) ride the config frame as the
* username ONLY; the literal password never crosses the wire. Round-trip a
* present username. */
static void test_config_daemon_auth_wire_roundtrip() {
Config* send_cfg = config_create();
EXPECT_NOT_NULL(send_cfg);
@@ -256,8 +256,7 @@ static void test_config_daemon_auth_wire_roundtrip() {
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");
send_cfg->auth_password = str_dup("alice-s3cret");
int p[2];
EXPECT_EQ_INT(socketpair(AF_UNIX, SOCK_STREAM, 0, p), 0);
@@ -269,10 +268,9 @@ static void test_config_daemon_auth_wire_roundtrip() {
close(p[1]);
io_set_fds(p[0], p[0]);
Config* recv_cfg = config_receive(p[0]);
/* The plaintext password is client-only: it is never serialized. */
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;
strcmp(recv_cfg->auth_user, "alice") == 0 && recv_cfg->auth_password == NULL;
config_delete(recv_cfg);
close(p[0]);
_exit(ok ? 0 : 1);
@@ -289,7 +287,7 @@ static void test_config_daemon_auth_wire_roundtrip() {
}
}
/* The receive side validates the auth payload: a present-but-malformed digest
/* The receive side validates the auth payload: a present-but-malformed username
* 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() {
@@ -298,8 +296,7 @@ static void test_config_daemon_auth_wire_rejects_malformed() {
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!!");
send_cfg->auth_user = str_dup("bad user");
int p[2];
EXPECT_EQ_INT(socketpair(AF_UNIX, SOCK_STREAM, 0, p), 0);
+416 -147
View File
@@ -1,6 +1,7 @@
#include "test_credentials.h"
#include "credentials.h"
#include "test_utils.h"
#include "utils.h"
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
@@ -9,17 +10,42 @@
#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"
/* Known-answer vector, independently recomputed with Python
* (hashlib.pbkdf2_hmac / hmac / hashlib.sha256). */
#define KAT_PASSWORD "alice-s3cret"
#define KAT_USER "alice"
#define KAT_ITERS 4096u
#define KAT_CLIENT_KEY "80f0e0af43e34e8aeec1738609c5d1eac8646601b4f244cef5a04a9f13563cf8"
#define KAT_STORED_KEY "5b3b489437085a11fe594ab99154da4cb4ebab4ae8c2edf51fbaa9277e9f9099"
#define KAT_SERVER_KEY "38f668736210bd4dbcb5193b9a514c5b1047174eff5f5a80ee4c2b1e8b2c76a1"
#define KAT_CLIENT_PROOF "c9b0d397b853176b842a751b9af327270ae0c5e286cb77d16e59fa42245270f6"
#define KAT_SERVER_SIG "93c0d94b9ee29798ffd42734a9becb2168bad1f69e492d2ccb042a43db13bffc"
#define KAT_SALT_B64 "AAECAwQFBgcICQoLDA0ODw=="
#define KAT_NAME_PREFIX "$fastsync$1$pbkdf2-sha256$"
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 int hex_nibble(char c) {
if (c >= '0' && c <= '9')
return c - '0';
if (c >= 'a' && c <= 'f')
return c - 'a' + 10;
if (c >= 'A' && c <= 'F')
return c - 'A' + 10;
return -1;
}
static void unhex(const char* hex, uint8_t* out, size_t out_len) {
for (size_t i = 0; i < out_len; i++)
out[i] = (uint8_t)((hex_nibble(hex[2 * i]) << 4) | hex_nibble(hex[2 * i + 1]));
}
/* Fill deterministic nonces: out[i] = first + i. */
static void ramp(uint8_t* out, size_t len, uint8_t first) {
for (size_t i = 0; i < len; i++)
out[i] = (uint8_t)(first + i);
}
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++);
@@ -36,7 +62,7 @@ static char* make_tmp_file(const char* contents) {
/* Credential/password files are owner-only; the reader rejects group/other
* permission bits, so create temp files 0600 like the real ones. */
chmod(path, 0600);
return strdup(path);
return str_dup(path);
}
static void rm_temp(const char* path) {
@@ -44,33 +70,11 @@ static void rm_temp(const char* 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"));
/* Build a valid new-format line for user/password at iters. */
static bool make_store_line(const char* user, const char* password, uint32_t iters, char* out,
size_t out_sz) {
char err[256];
return credentials_hash_store_line(user, password, iters, out, out_sz, err, sizeof(err));
}
static void test_credentials_secure_equal() {
@@ -78,57 +82,283 @@ static void test_credentials_secure_equal() {
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));
EXPECT_FALSE(credentials_secure_equal("ab", "ac", 2));
}
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");
static void test_credentials_b64() {
uint8_t salt[CREDENTIAL_SALT_LEN];
ramp(salt, sizeof(salt), 0x00);
char encoded[25];
EXPECT_TRUE(credentials_b64_encode(salt, sizeof(salt), encoded, sizeof(encoded)));
EXPECT_EQ_STR(encoded, KAT_SALT_B64);
uint8_t decoded[CREDENTIAL_SALT_LEN];
size_t decoded_len = 0;
EXPECT_TRUE(credentials_b64_decode(encoded, decoded, sizeof(decoded), &decoded_len));
EXPECT_EQ_INT((int)decoded_len, CREDENTIAL_SALT_LEN);
EXPECT_TRUE(memcmp(decoded, salt, sizeof(salt)) == 0);
/* Malformed input is refused: bad length, bad alphabet, missing buffer. */
EXPECT_FALSE(credentials_b64_decode("abc", decoded, sizeof(decoded), &decoded_len));
EXPECT_FALSE(credentials_b64_decode("!!!!", decoded, sizeof(decoded), &decoded_len));
EXPECT_FALSE(credentials_b64_decode("", decoded, sizeof(decoded), &decoded_len));
EXPECT_FALSE(credentials_b64_decode(KAT_SALT_B64, decoded, 4, &decoded_len));
EXPECT_FALSE(credentials_b64_decode(NULL, decoded, sizeof(decoded), &decoded_len));
EXPECT_FALSE(credentials_b64_encode(NULL, 3, encoded, sizeof(encoded)));
EXPECT_FALSE(credentials_b64_encode(salt, sizeof(salt), encoded, 3));
}
static void test_credentials_random_bytes() {
uint8_t a[CREDENTIAL_NONCE_LEN];
uint8_t b[CREDENTIAL_NONCE_LEN];
EXPECT_TRUE(credentials_random_bytes(a, sizeof(a)));
EXPECT_TRUE(credentials_random_bytes(b, sizeof(b)));
EXPECT_TRUE(memcmp(a, b, sizeof(a)) != 0);
EXPECT_FALSE(credentials_random_bytes(NULL, 4));
}
static void test_credentials_compute_keys_kat() {
uint8_t salt[CREDENTIAL_SALT_LEN];
ramp(salt, sizeof(salt), 0x00);
uint8_t client_key[CREDENTIAL_KEY_LEN];
uint8_t stored_key[CREDENTIAL_KEY_LEN];
uint8_t server_key[CREDENTIAL_KEY_LEN];
EXPECT_TRUE(
credentials_compute_keys(KAT_PASSWORD, salt, KAT_ITERS, client_key, stored_key, server_key));
uint8_t expect[CREDENTIAL_KEY_LEN];
unhex(KAT_CLIENT_KEY, expect, sizeof(expect));
EXPECT_TRUE(memcmp(client_key, expect, sizeof(expect)) == 0);
unhex(KAT_STORED_KEY, expect, sizeof(expect));
EXPECT_TRUE(memcmp(stored_key, expect, sizeof(expect)) == 0);
unhex(KAT_SERVER_KEY, expect, sizeof(expect));
EXPECT_TRUE(memcmp(server_key, expect, sizeof(expect)) == 0);
EXPECT_FALSE(credentials_compute_keys(KAT_PASSWORD, salt, 0, client_key, stored_key, server_key));
EXPECT_FALSE(credentials_compute_keys(NULL, salt, KAT_ITERS, client_key, stored_key, server_key));
}
static void test_credentials_auth_message_and_proof_kat() {
uint8_t snonce[CREDENTIAL_NONCE_LEN];
uint8_t cnonce[CREDENTIAL_NONCE_LEN];
ramp(snonce, sizeof(snonce), 0xa0);
ramp(cnonce, sizeof(cnonce), 0x10);
uint8_t auth_msg[CREDENTIAL_AUTH_MESSAGE_MAX];
size_t msg_len = 0;
EXPECT_TRUE(credentials_build_auth_message(KAT_USER, snonce, cnonce, auth_msg, sizeof(auth_msg),
&msg_len));
const char* expect_msg =
"4661737453796e632d417574682d763100000005616c69636500000020a0a1a2a3a4a5a6a7a8a9aaabac"
"adaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf00000020101112131415161718191a1b1c1d1e1f2021"
"22232425262728292a2b2c2d2e2f";
uint8_t expect[CREDENTIAL_AUTH_MESSAGE_MAX];
size_t expect_len = strlen(expect_msg) / 2;
unhex(expect_msg, expect, expect_len);
EXPECT_EQ_INT((int)msg_len, (int)expect_len);
EXPECT_TRUE(memcmp(auth_msg, expect, expect_len) == 0);
uint8_t client_key[CREDENTIAL_KEY_LEN];
uint8_t stored_key[CREDENTIAL_KEY_LEN];
uint8_t server_key[CREDENTIAL_KEY_LEN];
unhex(KAT_CLIENT_KEY, client_key, sizeof(client_key));
unhex(KAT_STORED_KEY, stored_key, sizeof(stored_key));
unhex(KAT_SERVER_KEY, server_key, sizeof(server_key));
uint8_t proof[CREDENTIAL_KEY_LEN];
uint8_t server_sig[CREDENTIAL_KEY_LEN];
EXPECT_TRUE(credentials_client_proof(client_key, stored_key, server_key, auth_msg, msg_len, proof,
server_sig));
unhex(KAT_CLIENT_PROOF, expect, sizeof(expect));
EXPECT_TRUE(memcmp(proof, expect, CREDENTIAL_KEY_LEN) == 0);
unhex(KAT_SERVER_SIG, expect, sizeof(expect));
EXPECT_TRUE(memcmp(server_sig, expect, CREDENTIAL_KEY_LEN) == 0);
}
static CredentialVerifier kat_verifier(void) {
CredentialVerifier v;
memset(&v, 0, sizeof(v));
ramp(v.salt, sizeof(v.salt), 0x00);
v.iters = KAT_ITERS;
unhex(KAT_STORED_KEY, v.stored_key, sizeof(v.stored_key));
unhex(KAT_SERVER_KEY, v.server_key, sizeof(v.server_key));
v.found = true;
return v;
}
static void test_credentials_verify_response_kat() {
CredentialVerifier v = kat_verifier();
uint8_t snonce[CREDENTIAL_NONCE_LEN];
uint8_t cnonce[CREDENTIAL_NONCE_LEN];
ramp(snonce, sizeof(snonce), 0xa0);
ramp(cnonce, sizeof(cnonce), 0x10);
uint8_t proof[CREDENTIAL_KEY_LEN];
uint8_t expect_sig[CREDENTIAL_KEY_LEN];
unhex(KAT_CLIENT_PROOF, proof, sizeof(proof));
unhex(KAT_SERVER_SIG, expect_sig, sizeof(expect_sig));
uint8_t server_sig[CREDENTIAL_KEY_LEN];
EXPECT_TRUE(credentials_verify_response(&v, KAT_USER, snonce, cnonce, proof, server_sig));
EXPECT_TRUE(memcmp(server_sig, expect_sig, CREDENTIAL_KEY_LEN) == 0);
/* Tampered proof refused. */
uint8_t bad[CREDENTIAL_KEY_LEN];
memcpy(bad, proof, sizeof(bad));
bad[0] ^= 0x01;
EXPECT_FALSE(credentials_verify_response(&v, KAT_USER, snonce, cnonce, bad, server_sig));
/* Unit replay: the same proof bound to a different client nonce is refused. */
uint8_t other[CREDENTIAL_NONCE_LEN];
memcpy(other, cnonce, sizeof(other));
other[0] ^= 0x01;
EXPECT_FALSE(credentials_verify_response(&v, KAT_USER, snonce, other, proof, server_sig));
/* Different server nonce too. */
uint8_t other_server[CREDENTIAL_NONCE_LEN];
memcpy(other_server, snonce, sizeof(other_server));
other_server[0] ^= 0x01;
EXPECT_FALSE(credentials_verify_response(&v, KAT_USER, other_server, cnonce, proof, server_sig));
/* Wrong user changes the AuthMessage and fails. */
EXPECT_FALSE(credentials_verify_response(&v, "bob", snonce, cnonce, proof, server_sig));
/* found=false never accepts. */
v.found = false;
EXPECT_FALSE(credentials_verify_response(&v, KAT_USER, snonce, cnonce, proof, server_sig));
/* NULL arguments fail closed. */
v.found = true;
EXPECT_FALSE(credentials_verify_response(NULL, KAT_USER, snonce, cnonce, proof, server_sig));
EXPECT_FALSE(credentials_verify_response(&v, NULL, snonce, cnonce, proof, server_sig));
EXPECT_FALSE(credentials_verify_response(&v, KAT_USER, NULL, cnonce, proof, server_sig));
EXPECT_FALSE(credentials_verify_response(&v, KAT_USER, snonce, cnonce, NULL, server_sig));
}
static void test_credentials_username_valid() {
EXPECT_TRUE(credentials_username_valid("alice"));
EXPECT_TRUE(credentials_username_valid("a"));
EXPECT_FALSE(credentials_username_valid(NULL));
EXPECT_FALSE(credentials_username_valid(""));
EXPECT_FALSE(credentials_username_valid("bad user"));
EXPECT_FALSE(credentials_username_valid("tab\there"));
EXPECT_FALSE(credentials_username_valid("nul\nhere"));
}
/* A generated line round-trips through the store parser and verifies with the
* same password. */
static void test_credentials_hash_store_line_roundtrip() {
char line[CREDENTIAL_MAX_LINE];
EXPECT_TRUE(make_store_line("alice", KAT_PASSWORD, CREDENTIAL_MIN_ITERS, line, sizeof(line)));
EXPECT_TRUE(strncmp(line, "alice:", 6) == 0);
EXPECT_TRUE(strstr(line, KAT_NAME_PREFIX) != NULL);
char* path = make_tmp_file(line);
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_EQ_INT(credentials_store_size(store), 1);
EXPECT_TRUE(credentials_store_has(store, "alice"));
CredentialVerifier v;
const char* module_users[] = {"alice"};
EXPECT_TRUE(credentials_get_verifier(store, "alice", module_users, 1, &v));
EXPECT_TRUE(v.found);
EXPECT_EQ_INT((int)v.iters, (int)CREDENTIAL_MIN_ITERS);
uint8_t client_key[CREDENTIAL_KEY_LEN];
uint8_t stored_key[CREDENTIAL_KEY_LEN];
uint8_t server_key[CREDENTIAL_KEY_LEN];
EXPECT_TRUE(
credentials_compute_keys(KAT_PASSWORD, v.salt, v.iters, client_key, stored_key, server_key));
EXPECT_TRUE(memcmp(stored_key, v.stored_key, CREDENTIAL_KEY_LEN) == 0);
EXPECT_TRUE(memcmp(server_key, v.server_key, CREDENTIAL_KEY_LEN) == 0);
credentials_free(store);
rm_temp(path);
free(path);
}
static void test_credentials_store_parse_valid() {
char line_alice[CREDENTIAL_MAX_LINE];
char line_bob[CREDENTIAL_MAX_LINE];
EXPECT_TRUE(
make_store_line("alice", KAT_PASSWORD, CREDENTIAL_MIN_ITERS, line_alice, sizeof(line_alice)));
EXPECT_TRUE(
make_store_line("bob", "bob-s3cret", CREDENTIAL_MIN_ITERS, line_bob, sizeof(line_bob)));
char contents[2 * CREDENTIAL_MAX_LINE + 64];
snprintf(contents, sizeof(contents), "# server credential store\n; comment\n\n%s\n%s\n",
line_alice, line_bob);
char* path = make_tmp_file(contents);
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), 2);
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));
/* Unknown user and off-list user both yield a not-found dummy. */
const char* module_users[] = {"alice"};
CredentialVerifier v;
EXPECT_TRUE(credentials_get_verifier(store, "mallory", module_users, 1, &v));
EXPECT_FALSE(v.found);
EXPECT_TRUE(credentials_get_verifier(store, "bob", module_users, 1, &v));
EXPECT_FALSE(v.found);
EXPECT_TRUE(credentials_get_verifier(store, "alice", module_users, 1, &v));
EXPECT_TRUE(v.found);
/* The dummy keys are fixed (all zero) so they can never authenticate. */
const uint8_t zero[CREDENTIAL_KEY_LEN] = {0};
EXPECT_TRUE(credentials_get_verifier(store, "mallory", module_users, 1, &v));
EXPECT_TRUE(memcmp(v.stored_key, zero, CREDENTIAL_KEY_LEN) == 0);
EXPECT_TRUE(memcmp(v.server_key, zero, CREDENTIAL_KEY_LEN) == 0);
/* Miss salt is fresh random on each call. */
uint8_t salt_a[CREDENTIAL_SALT_LEN];
uint8_t salt_b[CREDENTIAL_SALT_LEN];
EXPECT_TRUE(credentials_get_verifier(store, "mallory", module_users, 1, &v));
memcpy(salt_a, v.salt, sizeof(salt_a));
EXPECT_TRUE(credentials_get_verifier(store, "mallory", module_users, 1, &v));
memcpy(salt_b, v.salt, sizeof(salt_b));
EXPECT_TRUE(memcmp(salt_a, salt_b, sizeof(salt_a)) != 0);
credentials_free(store);
rm_temp(path);
free(path);
}
static void test_credentials_store_parse_rejects_malformed() {
/* A valid salt (16 bytes -> 24 b64 chars) / keys (32 bytes -> 44 chars). */
uint8_t sixteen[CREDENTIAL_SALT_LEN] = {0};
uint8_t thirtytwo[CREDENTIAL_KEY_LEN] = {0};
char salt_b64[25];
char key_b64[45];
credentials_b64_encode(sixteen, sizeof(sixteen), salt_b64, sizeof(salt_b64));
credentials_b64_encode(thirtytwo, sizeof(thirtytwo), key_b64, sizeof(key_b64));
char below_min[CREDENTIAL_MAX_LINE];
char above_max[CREDENTIAL_MAX_LINE];
char short_salt[CREDENTIAL_MAX_LINE];
char short_key[CREDENTIAL_MAX_LINE];
char empty_field[CREDENTIAL_MAX_LINE];
snprintf(below_min, sizeof(below_min), "alice:$fastsync$1$pbkdf2-sha256$99$%s$%s$%s\n", salt_b64,
key_b64, key_b64);
snprintf(above_max, sizeof(above_max), "alice:$fastsync$1$pbkdf2-sha256$99999999$%s$%s$%s\n",
salt_b64, key_b64, key_b64);
snprintf(short_salt, sizeof(short_salt), "alice:$fastsync$1$pbkdf2-sha256$600000$AAAA$%s$%s\n",
key_b64, key_b64);
snprintf(short_key, sizeof(short_key), "alice:$fastsync$1$pbkdf2-sha256$600000$%s$AAAA$%s\n",
salt_b64, key_b64);
snprintf(empty_field, sizeof(empty_field), "alice:$fastsync$1$pbkdf2-sha256$600000$%s$%s$\n",
salt_b64, key_b64);
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",
":anything\n",
"alice:not-a-verifier\n",
below_min,
above_max,
short_salt,
short_key,
empty_field,
"ali "
"ce:$fastsync$1$pbkdf2-sha256$600000$AAECAwQFBgcICQoLDA0ODw==$WztIlDcIWhH+"
"WUq5kVTaTLTrq0rowu31H7qpJ36fkJk=$OPZoc2IQvU28tRk7mlFMWxBHF07/X1qA7kwrHossdqE=\n",
};
for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) {
char* path = make_tmp_file(cases[i]);
@@ -142,6 +372,35 @@ static void test_credentials_store_parse_rejects_malformed() {
}
}
static void test_credentials_store_rejects_legacy_hex() {
const char* secret = "9b90e524e94995ee4aeae2ee3c428a53405d1e8db147f44facc46797d0caf4c3";
char contents[CREDENTIAL_MAX_LINE];
snprintf(contents, sizeof(contents), "alice:%s\n", secret);
char* path = make_tmp_file(contents);
EXPECT_NOT_NULL(path);
char err[512];
const CredentialStore* store = credentials_load(path, NULL, err, sizeof(err));
EXPECT_NULL(store);
EXPECT_TRUE(strstr(err, "legacy") != NULL);
EXPECT_TRUE(strstr(err, "alice") != NULL);
rm_temp(path);
free(path);
}
static void test_credentials_store_duplicate_rejected() {
char line[CREDENTIAL_MAX_LINE];
EXPECT_TRUE(make_store_line("alice", KAT_PASSWORD, CREDENTIAL_MIN_ITERS, line, sizeof(line)));
char contents[2 * CREDENTIAL_MAX_LINE + 8];
snprintf(contents, sizeof(contents), "%s\n%s\n", line, line);
char* path = make_tmp_file(contents);
EXPECT_NOT_NULL(path);
char err[512];
EXPECT_NULL(credentials_load(path, NULL, err, sizeof(err)));
EXPECT_TRUE(strstr(err, "duplicate") != NULL);
rm_temp(path);
free(path);
}
static void test_credentials_store_parse_missing_file() {
char err[512];
const CredentialStore* store =
@@ -152,15 +411,11 @@ static void test_credentials_store_parse_missing_file() {
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));
@@ -172,12 +427,8 @@ static void test_credentials_store_empty_and_null() {
}
static void test_credentials_store_overlong_line_rejected() {
/* A line longer than CREDENTIAL_MAX_LINE must be rejected. Fill the buffer
* fully so the line really is overlong, but keep both a trailing newline and
* a NUL terminator at known indices: make_tmp_file does strlen(contents), so
* an unterminated stack buffer would be an out-of-bounds read (ASan). */
char big[CREDENTIAL_MAX_LINE + 80];
int n = snprintf(big, sizeof(big), "alice:%s", SHA256_SECRET);
int n = snprintf(big, sizeof(big), "alice:%s", KAT_NAME_PREFIX);
memset(big + n, 'a', sizeof(big) - (size_t)n - 1);
big[sizeof(big) - 2] = '\n';
big[sizeof(big) - 1] = '\0';
@@ -191,35 +442,43 @@ static void test_credentials_store_overlong_line_rejected() {
}
static void test_credentials_early_input_merge() {
char* pw =
make_tmp_file("alice:9b90e524e94995ee4aeae2ee3c428a53405d1e8db147f44facc46797d0caf4c3\n");
char alice[CREDENTIAL_MAX_LINE];
char bob[CREDENTIAL_MAX_LINE];
EXPECT_TRUE(make_store_line("alice", KAT_PASSWORD, CREDENTIAL_MIN_ITERS, alice, sizeof(alice)));
EXPECT_TRUE(make_store_line("bob", "bob-s3cret", CREDENTIAL_MIN_ITERS, bob, sizeof(bob)));
char alice_file[CREDENTIAL_MAX_LINE + 2];
char bob_file[CREDENTIAL_MAX_LINE + 2];
char alice_other[CREDENTIAL_MAX_LINE];
snprintf(alice_file, sizeof(alice_file), "%s\n", alice);
snprintf(bob_file, sizeof(bob_file), "%s\n", bob);
EXPECT_TRUE(make_store_line("alice", "different-s3cret", CREDENTIAL_MIN_ITERS, alice_other,
sizeof(alice_other)));
char* pw = make_tmp_file(alice_file);
char* early = make_tmp_file(bob_file);
char* early_same = make_tmp_file(alice_file); /* byte-identical verifier dedupes */
char* early_diff = make_tmp_file(alice_other);
EXPECT_NOT_NULL(pw);
EXPECT_NOT_NULL(early);
EXPECT_NOT_NULL(early_same);
EXPECT_NOT_NULL(early_diff);
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));
EXPECT_TRUE(credentials_store_has(store, "alice"));
EXPECT_TRUE(credentials_store_has(store, "bob"));
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);
/* The same user with the SAME verifier dedupes. */
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);
/* The same user with a DIFFERENT verifier fails closed (ambiguous). */
store = credentials_load(pw, early_diff, err, sizeof(err));
EXPECT_NULL(store);
EXPECT_TRUE(err[0] != '\0');
@@ -239,7 +498,6 @@ static void test_credentials_read_secret_file() {
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"
@@ -254,8 +512,6 @@ static void test_credentials_read_secret_file() {
rm_temp(path);
free(path);
/* CRLF is tolerated; the username is trimmed but the password's exact bytes
* (edge spaces included) are preserved so a whitespace password stays usable. */
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);
@@ -267,8 +523,6 @@ static void test_credentials_read_secret_file() {
rm_temp(path);
free(path);
/* A whitespace-only password (no characters) is still a real password and is
* preserved exactly, not mistaken for an empty line. */
path = make_tmp_file("carol: \n");
EXPECT_NOT_NULL(path);
EXPECT_EQ_INT(credentials_read_secret_file(path, &user, &password, err, sizeof(err)), 0);
@@ -280,7 +534,6 @@ static void test_credentials_read_secret_file() {
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);
@@ -294,13 +547,9 @@ static void test_credentials_read_secret_file() {
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 CR-only line ending */
"alice:\r\n",
};
for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) {
@@ -320,62 +569,77 @@ static void test_credentials_read_secret_file_bad() {
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);
static void test_credentials_hash_file() {
char* plaintext = make_tmp_file("# comment\n\n alice :" KAT_PASSWORD "\nbob:bob-s3cret\n");
EXPECT_NOT_NULL(plaintext);
FILE* out = tmpfile();
EXPECT_NOT_NULL(out);
char err[512];
CredentialStore* store = credentials_load(path, NULL, err, sizeof(err));
EXPECT_EQ_INT(credentials_hash_file(plaintext, CREDENTIAL_MIN_ITERS, out, err, sizeof(err)), 0);
rewind(out);
char line1[CREDENTIAL_MAX_LINE];
char line2[CREDENTIAL_MAX_LINE];
EXPECT_NOT_NULL(fgets(line1, sizeof(line1), out));
EXPECT_NOT_NULL(fgets(line2, sizeof(line2), out));
EXPECT_NULL(fgets(err, sizeof(err), out)); /* exactly two entries */
size_t n1 = strlen(line1);
if (n1 > 0 && line1[n1 - 1] == '\n')
line1[--n1] = '\0';
size_t n2 = strlen(line2);
if (n2 > 0 && line2[n2 - 1] == '\n')
line2[--n2] = '\0';
EXPECT_TRUE(strncmp(line1, "alice:", 6) == 0);
EXPECT_TRUE(strncmp(line2, "bob:", 4) == 0);
EXPECT_TRUE(strstr(line1, KAT_NAME_PREFIX) != NULL);
fclose(out);
/* The generated lines load as a valid store. */
char contents[2 * CREDENTIAL_MAX_LINE + 8];
snprintf(contents, sizeof(contents), "%s\n%s\n", line1, line2);
char* store_path = make_tmp_file(contents);
EXPECT_NOT_NULL(store_path);
CredentialStore* store = credentials_load(store_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));
EXPECT_EQ_INT(credentials_store_size(store), 2);
credentials_free(store);
rm_temp(path);
free(path);
rm_temp(store_path);
free(store_path);
rm_temp(plaintext);
free(plaintext);
/* An invalid iteration count is refused up front. */
char* p2 = make_tmp_file("alice:pw\n");
EXPECT_NOT_NULL(p2);
FILE* out2 = tmpfile();
EXPECT_NOT_NULL(out2);
EXPECT_EQ_INT(credentials_hash_file(p2, 10, out2, err, sizeof(err)), -1);
EXPECT_TRUE(err[0] != '\0');
fclose(out2);
rm_temp(p2);
free(p2);
}
static void test_credentials_rejects_group_or_other_accessible() {
char err[512];
char* path =
make_tmp_file("alice:9b90e524e94995ee4aeae2ee3c428a53405d1e8db147f44facc46797d0caf4c3\n");
char line[CREDENTIAL_MAX_LINE];
EXPECT_TRUE(make_store_line("alice", KAT_PASSWORD, CREDENTIAL_MIN_ITERS, line, sizeof(line)));
char contents[CREDENTIAL_MAX_LINE + 2];
snprintf(contents, sizeof(contents), "%s\n", line);
char* path = make_tmp_file(contents);
EXPECT_NOT_NULL(path);
/* 0600 is accepted by the server store loader. */
EXPECT_EQ_INT(chmod(path, 0600), 0);
CredentialStore* store = credentials_load(path, NULL, err, sizeof(err));
EXPECT_NOT_NULL(store);
credentials_free(store);
/* Group-readable and world-readable are both refused, with a clear error. */
EXPECT_EQ_INT(chmod(path, 0640), 0);
EXPECT_NULL(credentials_load(path, NULL, err, sizeof(err)));
EXPECT_TRUE(strstr(err, "owner-only") != NULL);
EXPECT_EQ_INT(chmod(path, 0604), 0);
EXPECT_NULL(credentials_load(path, NULL, err, sizeof(err)));
/* The client --password-file reader enforces the same rule. */
EXPECT_EQ_INT(chmod(path, 0644), 0);
char* user = NULL;
char* password = NULL;
@@ -384,9 +648,7 @@ static void test_credentials_rejects_group_or_other_accessible() {
EXPECT_NULL(password);
EXPECT_TRUE(strstr(err, "owner-only") != NULL);
/* An --early-input file is checked too. */
char* pw =
make_tmp_file("bob:2bb80d537b1da3e38bd30361aa855686bde0eacd7162fef6a25fe97bf527a25b\n");
char* pw = make_tmp_file("bob:bob-s3cret\n");
EXPECT_NOT_NULL(pw);
EXPECT_EQ_INT(chmod(path, 0644), 0);
EXPECT_NULL(credentials_load(pw, path, err, sizeof(err)));
@@ -403,22 +665,29 @@ static void test_credentials_burn() {
credentials_burn(secret, 16);
for (int i = 0; i < 16; i++)
EXPECT_EQ_INT(secret[i], 0);
credentials_burn(NULL, 0); /* must not crash */
credentials_burn(NULL, 0);
}
void test_credentials(void) {
test_credentials_hash_vectors();
test_credentials_hash_valid();
test_credentials_secure_equal();
test_credentials_b64();
test_credentials_random_bytes();
test_credentials_compute_keys_kat();
test_credentials_auth_message_and_proof_kat();
test_credentials_verify_response_kat();
test_credentials_username_valid();
test_credentials_hash_store_line_roundtrip();
test_credentials_store_parse_valid();
test_credentials_store_parse_rejects_malformed();
test_credentials_store_rejects_legacy_hex();
test_credentials_store_duplicate_rejected();
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_hash_file();
test_credentials_rejects_group_or_other_accessible();
test_credentials_gate_allows();
test_credentials_burn();
}