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:
@@ -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);
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user