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
+10 -27
View File
@@ -1573,14 +1573,14 @@ static int read_patterns_from_file(const char* filepath, char*** patterns, int*
}
#ifndef FASTSYNC_TEST_BUILD
/* Daemon auth (Wave B): read --password-file and derive the wire credentials
* (username + SHA-256 hex digest of the password). Runs once the destination
* form is known: the credentials only make sense for a daemon
* (host::module/path) destination, so a --password-file without one is a hard
* error here rather than a silently-ignored flag. The literal password is
* hashed immediately and wiped from memory; only the digest (and username) are
* kept on the Config for config_send. Returns 0 on success, -1 on error (the
* reason is logged; neither the password nor its digest is ever logged). */
/* Daemon auth (A7, protocol 2.19.0): read --password-file and keep the
* username plus the LITERAL password (client-only, never serialized). Runs
* once the destination form is known: the credentials only make sense for a
* daemon (host::module/path) destination, so a --password-file without one is a
* hard error here rather than a silently-ignored flag. The password is handed
* to the SCRAM challenge/response in config_send and burned by
* config_burn_auth/config_delete at teardown. Returns 0 on success, -1 on
* error (the reason is logged; the password is never logged). */
static int load_daemon_credentials(Config* config) {
if (!config->password_file)
return 0;
@@ -1597,27 +1597,10 @@ static int load_daemon_credentials(Config* config) {
log_message(LOG_LEVEL_ERROR, "%s", err);
return -1;
}
char hash[CREDENTIAL_HASH_HEX_LEN + 1];
if (!credentials_hash_password(password, hash)) {
log_message(LOG_LEVEL_ERROR, "failed to hash the password from '%s'", config->password_file);
credentials_burn(password, strlen(password));
free(password);
free(user);
return -1;
}
credentials_burn(password, strlen(password));
free(password);
free(config->auth_user);
free(config->auth_password_hash);
config_burn_auth(config);
config->auth_user = user;
config->auth_password_hash = str_dup(hash);
if (!config->auth_password_hash) {
log_message(LOG_LEVEL_ERROR, "memory allocation failed reading '%s'", config->password_file);
free(config->auth_user);
config->auth_user = NULL;
return -1;
}
config->auth_password = password;
log_info_message(LOG_INFO_MISC, "Loaded daemon credentials for user '%s'", config->auth_user);
return 0;
}
+126 -22
View File
@@ -60,12 +60,94 @@ static CredentialStore* g_credentials = NULL;
* SUPER_MODE_OFF here and the handler applies it exactly once after acceptance. */
typedef struct ModuleGateContext {
SSL* ssl;
/* The connection descriptor, so the gate can drive the SCRAM auth handshake
* while it still owns the config-frame exchange (before the STATUS_OK ack). */
int fd;
/* SUPER_MODE_OFF when this connection must not attempt any super-user
activity (operator --no-super, or a daemon module without the
`client owner = yes` opt-in); -1 when the config's own mode stands. */
int super_mode_override;
} ModuleGateContext;
/* Server half of the SCRAM challenge/response (A7 remediation, protocol
* 2.19.0). Sends STATUS_AUTH_CHALLENGE (iteration count, base64 salt, base64
* server nonce), expects STATUS_AUTH_RESPONSE (base64 client nonce, base64
* ClientProof), verifies the proof constant-time and answers STATUS_AUTH_OK
* with the base64 ServerSignature. On any failure it sends a single generic
* STATUS_AUTH_FAILED and returns false. The verifier for an unknown/off-list
* user is a dummy (random salt, dummy keys, found=false) so the same math runs
* and no user-enumeration/timing oracle is exposed. */
static bool server_auth_handshake(int fd, const Config* config, const DaemonModule* module) {
if (!config->auth_user) {
send_status(fd, STATUS_AUTH_FAILED);
return false;
}
CredentialVerifier verifier;
if (!credentials_get_verifier(g_credentials, config->auth_user,
(const char* const*)module->auth_users, module->auth_user_count,
&verifier))
return false;
uint8_t snonce[CREDENTIAL_NONCE_LEN];
char salt_b64[25];
char snonce_b64[45];
bool ok =
credentials_random_bytes(snonce, sizeof(snonce)) &&
credentials_b64_encode(verifier.salt, CREDENTIAL_SALT_LEN, salt_b64, sizeof(salt_b64)) &&
credentials_b64_encode(snonce, sizeof(snonce), snonce_b64, sizeof(snonce_b64));
if (!ok) {
send_status(fd, STATUS_AUTH_FAILED);
return false;
}
ok = send_status(fd, STATUS_AUTH_CHALLENGE) && send_int(fd, (int)verifier.iters) &&
send_str(fd, salt_b64) && send_str(fd, snonce_b64);
Status status = STATUS_ERROR;
char* cnonce_b64 = NULL;
char* proof_b64 = NULL;
uint8_t cnonce[CREDENTIAL_NONCE_LEN];
uint8_t proof[CREDENTIAL_KEY_LEN];
uint8_t server_sig[CREDENTIAL_KEY_LEN];
size_t cnonce_len = 0;
size_t proof_len = 0;
bool verified = false;
if (ok) {
ok = receive_status(fd, &status) && status == STATUS_AUTH_RESPONSE;
if (ok) {
cnonce_b64 = receive_str_redacted(fd);
proof_b64 = receive_str_redacted(fd);
ok = cnonce_b64 && proof_b64 &&
credentials_b64_decode(cnonce_b64, cnonce, sizeof(cnonce), &cnonce_len) &&
cnonce_len == CREDENTIAL_NONCE_LEN &&
credentials_b64_decode(proof_b64, proof, sizeof(proof), &proof_len) &&
proof_len == CREDENTIAL_KEY_LEN;
}
verified = ok && credentials_verify_response(&verifier, config->auth_user, snonce, cnonce,
proof, server_sig);
}
if (verified) {
char sig_b64[45];
ok = credentials_b64_encode(server_sig, sizeof(server_sig), sig_b64, sizeof(sig_b64)) &&
send_status(fd, STATUS_AUTH_OK) && send_str_redacted(fd, sig_b64);
credentials_burn(sig_b64, sizeof(sig_b64));
} else {
send_status(fd, STATUS_AUTH_FAILED);
ok = false;
}
credentials_burn(cnonce_b64, cnonce_b64 ? strlen(cnonce_b64) : 0);
credentials_burn(proof_b64, proof_b64 ? strlen(proof_b64) : 0);
free(cnonce_b64);
free(proof_b64);
credentials_burn((char*)snonce, sizeof(snonce));
credentials_burn(salt_b64, sizeof(salt_b64));
credentials_burn(snonce_b64, sizeof(snonce_b64));
credentials_burn((char*)cnonce, sizeof(cnonce));
credentials_burn((char*)proof, sizeof(proof));
credentials_burn((char*)server_sig, sizeof(server_sig));
credentials_burn((char*)verifier.salt, sizeof(verifier.salt));
credentials_burn((char*)verifier.stored_key, sizeof(verifier.stored_key));
credentials_burn((char*)verifier.server_key, sizeof(verifier.server_key));
return verified && ok;
}
/* Aggregate payload bytes the multithreaded receiver may buffer ahead of the
slow disk writer. Receiving one more chunk adds up to ~2 * MAX_CHUNK_SIZE
of transient wire/decompression buffers on top of the queued payloads, so
@@ -279,10 +361,11 @@ static const char* server_module_gate(const Config* config, void* context) {
gate_ctx->super_mode_override = SUPER_MODE_OFF;
}
if (module->auth_user_count > 0) {
/* Auth-required module (Wave B): verify the presented credentials against
* the store BEFORE the module root is installed and before any data moves.
* Fail closed: no store -> refuse; no/invalid credentials -> refuse. The
* username may be logged (never the digest/password). */
/* Auth-required module (A7, protocol 2.19.0): run the SCRAM challenge/
* response BEFORE the module root is installed and before any data moves.
* Fail closed: no store -> refuse (server misconfiguration, STATUS_ERROR);
* a failed handshake already sent STATUS_AUTH_FAILED. The username may be
* logged (never the password or any derived proof). */
if (g_credentials == NULL) {
log_message(LOG_LEVEL_ERROR,
"daemon module '%s' requires authentication but no credential store is "
@@ -291,28 +374,25 @@ static const char* server_module_gate(const Config* config, void* context) {
return "requested daemon module requires authentication and no credential "
"store is configured";
}
if (!config->auth_user || !config->auth_password_hash) {
log_message(LOG_LEVEL_ERROR,
"daemon module '%s' requires authentication; the client "
"presented no credentials",
config->module);
return "requested daemon module requires authentication";
}
if (gate_ctx && !gate_ctx->ssl) {
log_message(LOG_LEVEL_WARNING,
"daemon module '%s' is authenticating over a plaintext connection (no --tls); "
"the credential exchange is not encrypted",
config->module);
}
if (!credentials_gate_allows(g_credentials, (const char* const*)module->auth_users,
module->auth_user_count, config->auth_user,
config->auth_password_hash)) {
char* escaped_user = output_escape(config->auth_user, config->eight_bit_output);
log_message(LOG_LEVEL_ERROR, "daemon module '%s': authentication failed for user '%s'",
config->module, escaped_user ? escaped_user : "<allocation failed>");
free(escaped_user);
if (!gate_ctx || gate_ctx->fd < 0) {
log_message(LOG_LEVEL_ERROR, "daemon module '%s': no auth transport available",
config->module);
return "authentication failed for the requested daemon module";
}
if (!server_auth_handshake(gate_ctx->fd, config, module)) {
char* escaped_user =
config->auth_user ? output_escape(config->auth_user, config->eight_bit_output) : NULL;
log_message(LOG_LEVEL_ERROR, "daemon module '%s': authentication failed for user '%s'",
config->module, escaped_user ? escaped_user : "(none)");
free(escaped_user);
return CONFIG_VALIDATE_ALREADY_TERMINATED;
}
char* escaped_user = output_escape(config->auth_user, config->eight_bit_output);
log_message(LOG_LEVEL_INFO, "daemon module '%s': user '%s' authenticated", config->module,
escaped_user ? escaped_user : "<allocation failed>");
@@ -334,6 +414,7 @@ void handler(int file_descriptor) {
protocol_session_bind(&session);
ModuleGateContext gate_ctx;
gate_ctx.ssl = ssl;
gate_ctx.fd = file_descriptor;
gate_ctx.super_mode_override = -1;
Config* config = config_receive_with_validate(file_descriptor, server_module_gate, &gate_ctx);
if (config == NULL) {
@@ -638,10 +719,12 @@ static void print_server_usage(void) {
printf(" --no-detach Stay in the foreground (default detaches to\n");
printf(" background when running --daemon)\n");
printf(" --password-file=FILE Credential store for modules that declare\n");
printf(" 'auth users' (line format: user:SHA256HEX where\n");
printf(" SHA256HEX is the lowercase hex SHA-256 of the\n");
printf(" user's password). Requires --daemon; an auth-\n");
printf(" required module with no store refuses to start\n");
printf(" 'auth users' (line format:\n");
printf(" user:$fastsync$1$pbkdf2-sha256$iters$salt$stored$server,\n");
printf(" generated by --hash-credentials). Legacy\n");
printf(" user:SHA256HEX lines are rejected. Requires\n");
printf(" --daemon; an auth-required module with no store\n");
printf(" refuses to start\n");
printf(" --early-input=FILE Second credential store layered over\n");
printf(" --password-file (same format); usually a secrets-\n");
printf(" manager/process-substitution file. Requires --daemon\n");
@@ -666,6 +749,12 @@ static void print_server_usage(void) {
printf(" client's CONVERT_SPEC). A name that cannot be\n");
printf(" represented fails the run cleanly\n");
printf(" --allow-unauthenticated Allow plaintext/anonymous network clients\n");
printf(" --hash-credentials <file> Read <file>'s user:password lines and print\n");
printf(" PBKDF2 credential-store lines to stdout, then exit.\n");
printf(" Use the output as --password-file for --daemon\n");
printf(" --iterations N PBKDF2 iteration count for --hash-credentials\n");
printf(" (default %u, range %u-%u)\n", CREDENTIAL_DEFAULT_ITERS,
CREDENTIAL_MIN_ITERS, CREDENTIAL_MAX_ITERS);
printf(" -v, --verbose Enable debug logging\n");
printf(" --help Show this help\n");
}
@@ -735,6 +824,21 @@ int main(int argc, char* argv[]) {
return 1;
}
/* --hash-credentials: standalone offline tool; read user:password lines and
* emit new-format credential-store lines, then exit. */
if (opts.hash_credentials_file) {
uint32_t iters = opts.hash_iterations_set ? opts.hash_iterations : CREDENTIAL_DEFAULT_ITERS;
char hash_err[512];
if (credentials_hash_file(opts.hash_credentials_file, iters, stdout, hash_err,
sizeof(hash_err)) != 0) {
fprintf(stderr, "Error: %s\n", hash_err);
server_cli_options_free(&opts);
return 1;
}
server_cli_options_free(&opts);
return 0;
}
int exit_code = 0;
signal(SIGPIPE, SIG_IGN);
if (opts.verbose) {
+33
View File
@@ -127,6 +127,31 @@ int server_cli_parse(int argc, char* argv[], ServerCliOptions* opts, char* err,
inline_value = argv[++i];
}
opts->early_input_file = inline_value;
} else if (arg_has_value(argv[i], "--hash-credentials", &inline_value)) {
if (!inline_value) {
if (i + 1 >= argc) {
set_error(err, err_size, "missing argument for --hash-credentials");
return -1;
}
inline_value = argv[++i];
}
opts->hash_credentials_file = inline_value;
} else if (arg_has_value(argv[i], "--iterations", &inline_value)) {
if (!inline_value) {
if (i + 1 >= argc) {
set_error(err, err_size, "missing argument for --iterations");
return -1;
}
inline_value = argv[++i];
}
char* end = NULL;
long n = strtol(inline_value, &end, 10);
if (!end || *end != '\0' || n < 0 || n > 10000000L) {
set_error(err, err_size, "invalid --iterations '%s'", inline_value);
return -1;
}
opts->hash_iterations = (uint32_t)n;
opts->hash_iterations_set = true;
} else if (arg_is(argv[i], "--address")) {
if (i + 1 >= argc) {
set_error(err, err_size, "missing argument for --address");
@@ -227,6 +252,14 @@ int server_cli_parse(int argc, char* argv[], ServerCliOptions* opts, char* err,
"--daemon");
return -1;
}
if (opts->hash_credentials_file != NULL && (opts->daemon_mode || opts->stdio_mode)) {
set_error(err, err_size, "--hash-credentials cannot be combined with --daemon or --stdio");
return -1;
}
if (opts->hash_iterations_set && opts->hash_credentials_file == NULL) {
set_error(err, err_size, "--iterations requires --hash-credentials");
return -1;
}
/* --iconv: reject a malformed CONVERT_SPEC or an unsupported charset name at
startup (a probe iconv_open is attempted). */
if (opts->iconv_spec != NULL && !charset_spec_valid(opts->iconv_spec)) {
+8 -1
View File
@@ -3,6 +3,7 @@
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
/* Parsed fastsync-server command line. All string members are borrowed
* pointers into the original argv (valid for the life of the argv array the
@@ -26,7 +27,13 @@ typedef struct ServerCliOptions {
const char* config_path; /* --config value, or NULL */
const char* password_file; /* --password-file value, or NULL (daemon) */
const char* early_input_file; /* --early-input value, or NULL (daemon) */
const char** dparams; /* raw --dparam override strings */
/* --hash-credentials=FILE: read `user:password` lines from FILE and print
* new-format credential-store lines to stdout, then exit. Standalone mode
* (mutually exclusive with --daemon/--stdio). */
const char* hash_credentials_file;
bool hash_iterations_set; /* an explicit --iterations was given */
uint32_t hash_iterations; /* --iterations value (default CREDENTIAL_DEFAULT_ITERS) */
const char** dparams; /* raw --dparam override strings */
int dparam_count;
const char* bind_address; /* --address */
int bind_family; /* AF_UNSPEC / AF_INET / AF_INET6 */
+130 -29
View File
@@ -41,7 +41,7 @@ static void config_set_defaults(Config* config) {
config->ssh_destination = NULL;
config->module = NULL;
config->auth_user = NULL;
config->auth_password_hash = NULL;
config->auth_password = NULL;
config->password_file = NULL;
config->iconv_spec = NULL;
config->fastsync_server_path = NULL;
@@ -630,6 +630,20 @@ void config_parse_ssh_dest(Config* config) {
config->receive_root_directory = path;
}
void config_burn_auth(Config* config) {
if (!config)
return;
if (config->auth_password) {
credentials_burn(config->auth_password, strlen(config->auth_password));
free(config->auth_password);
config->auth_password = NULL;
}
if (config->auth_user) {
free(config->auth_user);
config->auth_user = NULL;
}
}
void config_delete(Config* config) {
if (config == NULL)
return;
@@ -642,8 +656,7 @@ void config_delete(Config* config) {
free(config->receive_root_directory);
free(config->ssh_destination);
free(config->module);
free(config->auth_user);
free(config->auth_password_hash);
config_burn_auth(config);
free(config->password_file);
free(config->iconv_spec);
free(config->write_batch);
@@ -1128,23 +1141,18 @@ static bool receive_daemon_module(int fd, Config* c) {
return true;
}
/* Daemon password credentials (Wave B, within protocol 2.15.0 -- see the
* PROTOCOL_VERSION note in config.h: this rides the Wave A trailing-string
* area, symmetric sender+receiver in every 2.15.0 build, so it is not a frame
* layout that needs its own bump). A single presence int is followed, when
* set, by the username and the SHA-256 hex digest of the password. The
* literal password never crosses the wire. */
/* Daemon password credentials (A7 remediation, protocol 2.19.0). A single
* presence int is followed, when set, by ONLY the username; the password is
* never serialized. The daemon answers an auth-required module with the SCRAM
* challenge (see the auth exchange below). */
static bool send_daemon_auth(int fd, const Config* c) {
bool present = c->auth_user != NULL && c->auth_password_hash != NULL && c->auth_user[0] != '\0' &&
c->auth_password_hash[0] != '\0';
bool present = c->auth_user != NULL && c->auth_user[0] != '\0';
if (!send_int(fd, present ? 1 : 0))
return false;
if (!present)
return true;
/* Redacted send: the username and hard-wired digest must never reach a
* --verbose debug log (they are replayable), while normal protocol strings
* keep their debug trace. */
return send_str_redacted(fd, c->auth_user) && send_str_redacted(fd, c->auth_password_hash);
/* Redacted send: the username must never reach a --verbose debug log. */
return send_str_redacted(fd, c->auth_user);
}
static bool receive_daemon_auth(int fd, Config* c) {
@@ -1153,27 +1161,107 @@ static bool receive_daemon_auth(int fd, Config* c) {
return false;
if (!present)
return true;
/* Redacted receive: never log the incoming username/digest bodies. */
/* Redacted receive: never log the incoming username body. */
char* user = receive_str_redacted(fd);
char* hash = receive_str_redacted(fd);
if (!user || !hash) {
free(user);
free(hash);
if (!user)
return false;
}
size_t user_len = strlen(user);
bool valid = user_len > 0 && user_len <= CREDENTIAL_MAX_USER_LEN && credentials_hash_valid(hash);
if (!valid) {
if (!credentials_username_valid(user)) {
free(user);
free(hash);
log_message(LOG_LEVEL_WARNING, "Daemon client sent malformed auth credentials");
return false;
}
c->auth_user = user;
c->auth_password_hash = hash;
return true;
}
/* Client half of the SCRAM challenge/response (A7 remediation). Called by
* config_send after the config frame is written and the server answered
* STATUS_AUTH_CHALLENGE. The plaintext password lives only in
* config->auth_password and every derived buffer is wiped on the way out. */
static bool client_auth_exchange(int fd, const Config* c) {
if (!c->auth_user || !c->auth_password)
return false;
int iters = 0;
if (!receive_int(fd, &iters))
return false;
if (iters < (int)CREDENTIAL_MIN_ITERS || iters > (int)CREDENTIAL_MAX_ITERS) {
log_message(LOG_LEVEL_ERROR, "Daemon sent an out-of-range auth iteration count");
return false;
}
char* salt_b64 = receive_str(fd);
char* snonce_b64 = receive_str(fd);
uint8_t salt[CREDENTIAL_SALT_LEN];
uint8_t snonce[CREDENTIAL_NONCE_LEN];
uint8_t cnonce[CREDENTIAL_NONCE_LEN];
size_t salt_len = 0;
size_t snonce_len = 0;
bool ok = salt_b64 && snonce_b64 &&
credentials_b64_decode(salt_b64, salt, sizeof(salt), &salt_len) &&
salt_len == CREDENTIAL_SALT_LEN &&
credentials_b64_decode(snonce_b64, snonce, sizeof(snonce), &snonce_len) &&
snonce_len == CREDENTIAL_NONCE_LEN && credentials_random_bytes(cnonce, sizeof(cnonce));
credentials_burn(salt_b64, salt_b64 ? strlen(salt_b64) : 0);
credentials_burn(snonce_b64, snonce_b64 ? strlen(snonce_b64) : 0);
free(salt_b64);
free(snonce_b64);
if (!ok) {
log_message(LOG_LEVEL_ERROR, "Daemon sent a malformed auth challenge");
return false;
}
uint8_t client_key[CREDENTIAL_KEY_LEN];
uint8_t stored_key[CREDENTIAL_KEY_LEN];
uint8_t server_key[CREDENTIAL_KEY_LEN];
uint8_t auth_msg[CREDENTIAL_AUTH_MESSAGE_MAX];
size_t msg_len = 0;
uint8_t proof[CREDENTIAL_KEY_LEN];
uint8_t expected_sig[CREDENTIAL_KEY_LEN];
ok = credentials_compute_keys(c->auth_password, salt, (uint32_t)iters, client_key, stored_key,
server_key) &&
credentials_build_auth_message(c->auth_user, snonce, cnonce, auth_msg, sizeof(auth_msg),
&msg_len) &&
credentials_client_proof(client_key, stored_key, server_key, auth_msg, msg_len, proof,
expected_sig);
char cnonce_b64[45];
char proof_b64[45];
if (ok)
ok = credentials_b64_encode(cnonce, sizeof(cnonce), cnonce_b64, sizeof(cnonce_b64)) &&
credentials_b64_encode(proof, sizeof(proof), proof_b64, sizeof(proof_b64));
if (!ok) {
log_message(LOG_LEVEL_ERROR, "Failed to compute the daemon auth response");
} else {
ok = send_status(fd, STATUS_AUTH_RESPONSE) && send_str_redacted(fd, cnonce_b64) &&
send_str_redacted(fd, proof_b64);
}
if (ok) {
Status status = STATUS_ERROR;
char* sig_b64 = NULL;
uint8_t sig[CREDENTIAL_KEY_LEN];
size_t sig_len = 0;
ok = receive_status(fd, &status) && status == STATUS_AUTH_OK &&
(sig_b64 = receive_str_redacted(fd)) != NULL &&
credentials_b64_decode(sig_b64, sig, sizeof(sig), &sig_len) &&
sig_len == CREDENTIAL_KEY_LEN &&
credentials_secure_equal((const char*)sig, (const char*)expected_sig, CREDENTIAL_KEY_LEN);
if (!ok)
log_message(LOG_LEVEL_ERROR, "Daemon authentication failed");
credentials_burn(sig_b64, sig_b64 ? strlen(sig_b64) : 0);
credentials_burn((char*)sig, sizeof(sig));
free(sig_b64);
}
credentials_burn((char*)client_key, sizeof(client_key));
credentials_burn((char*)stored_key, sizeof(stored_key));
credentials_burn((char*)server_key, sizeof(server_key));
credentials_burn((char*)auth_msg, sizeof(auth_msg));
credentials_burn((char*)proof, sizeof(proof));
credentials_burn((char*)expected_sig, sizeof(expected_sig));
credentials_burn((char*)salt, sizeof(salt));
credentials_burn((char*)snonce, sizeof(snonce));
credentials_burn((char*)cnonce, sizeof(cnonce));
credentials_burn(cnonce_b64, sizeof(cnonce_b64));
credentials_burn(proof_b64, sizeof(proof_b64));
return ok;
}
/* --iconv CONVERT_SPEC (protocol 2.16.0). Trailing string on the config frame,
* sent after the Wave A/B daemon-auth block and before the ack, so the
* receiver knows the wire charset before the first file name arrives. The full
@@ -1268,6 +1356,14 @@ bool config_send(int file_descriptor, const Config* config) {
Status status;
if (!receive_status(file_descriptor, &status))
return false;
if (status == STATUS_AUTH_CHALLENGE) {
/* Daemon auth (protocol 2.19.0): run the SCRAM exchange, then wait for the
* ordinary STATUS_OK the server sends once authentication succeeded. */
if (!client_auth_exchange(file_descriptor, config))
return false;
if (!receive_status(file_descriptor, &status))
return false;
}
if (status != STATUS_OK) {
log_message(LOG_LEVEL_ERROR, "Error transmitting config");
return false;
@@ -1329,9 +1425,14 @@ Config* config_receive_with_validate(int file_descriptor, ConfigValidateFunc val
if (rejection != NULL) {
/* Daemon module gate (unknown module / read-only module / auth-required
* module): refuse BEFORE the STATUS_OK so the client aborts at the
* config handshake and no file data is ever exchanged. */
fprintf(stderr, "%s\n", rejection);
send_status(file_descriptor, STATUS_ERROR);
* config handshake and no file data is ever exchanged. The auth
* handshake already sent STATUS_AUTH_FAILED when it failed, signalled by
* the CONFIG_VALIDATE_ALREADY_TERMINATED sentinel, so no second status is
* written. */
if (rejection != CONFIG_VALIDATE_ALREADY_TERMINATED) {
fprintf(stderr, "%s\n", rejection);
send_status(file_descriptor, STATUS_ERROR);
}
goto error;
}
}
+47 -19
View File
@@ -96,19 +96,18 @@ typedef struct Config {
* string so the daemon can look the module up in its own config and confine
* the connection to the module's root (never a client-chosen root). */
char* module;
/* Daemon password authentication (Wave B, protocol 2.15.0, WITHIN the Wave A
* frame layout -- see the PROTOCOL_VERSION note below for why this is not a
* bump). Client-composed from a --password-file whose first meaningful line
* is `user:password`: the client sends ONLY the username and a SHA-256 hex
* digest of the password (auth_user + auth_password_hash), never the literal
* password. Both are NULL when the client has no credentials to present; a
* module WITHOUT `auth users` stays open and the server ignores any
* credentials that do arrive (the client sends them opportunistically and
* the server decides). */
/* Daemon password authentication (A7 remediation, protocol 2.19.0).
* Client-composed from a --password-file whose first meaningful line is
* `user:password`: the client sends ONLY the username in the config frame
* (auth_user); the literal password is kept in auth_password CLIENT-SIDE for
* the duration of the SCRAM challenge/response and is NEVER serialized. Both
* are NULL when the client has no credentials to present; a module WITHOUT
* `auth users` stays open and the server ignores any credentials that do
* arrive (the client sends them opportunistically and the server decides). */
char* auth_user;
char* auth_password_hash;
char* auth_password;
/* Client-only path of --password-file (never crosses the wire; it is read to
* populate auth_user/auth_password_hash before connecting). */
* populate auth_user/auth_password before connecting). */
char* password_file;
char* fastsync_server_path;
/* --iconv=CONVERT_SPEC (protocol 2.16.0, rsync compatibility): convert the
@@ -546,8 +545,8 @@ typedef struct Config {
* is what keeps a 2.15 client and a 2.14 server from ever reaching that state.
*
* NOTE: daemon module-selection bump owned by Wave A (2.15.0); later daemon
* waves (auth, motd) must not bump PROTOCOL_VERSION. Wave B (auth) adds the
* credential fields (auth_user/auth_password_hash) as further trailing
* waves (auth, motd) must not bump PROTOCOL_VERSION. Wave B (auth) added the
* credential fields (auth_user + password digest) as further trailing
* config-frame strings AFTER the Wave A module string, with a presence int
* prefix. This is not a new frame version: sender and receiver of a 2.15.0
* build always read and write the same full layout (the strict same-version
@@ -617,8 +616,22 @@ typedef struct Config {
* (config_receive rejects a mismatched version before parsing anything else) is
* what keeps a 2.18 client and a 2.17 server from ever reaching that state.
* --super never elevates privileges; it only permits a confined attempt, and
* --copy-as never switches process credentials (see RSYNC_COMPAT.md). */
#define PROTOCOL_VERSION "2.18.0"
* --copy-as never switches process credentials (see RSYNC_COMPAT.md).
*
* A7 Auth Wave: 2.18.0 -> 2.19.0.
*
* WHY the bump, grounded in the wire: the daemon auth block on the config frame
* loses the hard-wired password digest (it becomes `[int present][str_redacted
* username]`), and the frame stream gains the SCRAM challenge/response
* (STATUS_AUTH_CHALLENGE -> STATUS_AUTH_RESPONSE -> STATUS_AUTH_OK) between the
* config frame and the STATUS_OK ack. A 2.18 peer would desynchronize on both
* the shorter auth block and the new status frames, so the strict same-version
* handshake (config_receive rejects a mismatched version before parsing
* anything else) is what keeps a 2.19 client and a 2.18 server from ever
* reaching that state. SECURITY: a 2.19 store holds a salted PBKDF2 verifier
* and cannot verify (and refuses to load) a legacy unsalted-SHA-256 store line,
* so an old bearer digest can never be replayed against a 2.19 daemon. */
#define PROTOCOL_VERSION "2.19.0"
#define DEFAULT_CHUNK_SIZE (10 * 1024 * 1024)
/* Upper bound on total basis-dir entries (rsync caps --link-dest at 20). */
#define MAX_BASIS_DIRS 64
@@ -642,21 +655,36 @@ typedef struct Config {
Config* config_create(void);
void config_delete(Config* config);
/* Wipe the client-side plaintext auth password (and username) from a Config
* before it is freed or handed off. Safe on a NULL/empty Config and idempotent
* (it clears the pointers after burning). config_delete calls this
* automatically; a caller that drops a Config earlier may call it explicitly. */
void config_burn_auth(Config* config);
bool config_send(int file_descriptor, const Config* config);
Config* config_receive(int file_descriptor);
bool config_is_remote_dest(const char* s);
void config_parse_ssh_dest(Config* config);
/* A ConfigValidateFunc may return this sentinel to tell
* config_receive_with_validate that the callback ALREADY sent a terminal status
* frame (e.g. STATUS_AUTH_FAILED, then closed) and the frame must be abandoned
* without an additional STATUS_ERROR. A normal rejection returns a message
* string (logged, then STATUS_ERROR); NULL accepts. */
#define CONFIG_VALIDATE_ALREADY_TERMINATED ((const char*)-1)
/* Server-side config-frame gate (daemon module selection, Wave A). A server
* that needs to make an accept/reject decision about a received Config BEFORE
* it sends the STATUS_OK ack (so a rejected connection is refused cleanly with
* no data transferred) passes a callback here; it runs after the frame parses
* and validates but before the STATUS_OK/STATUS_ERROR ack. Return NULL to
* accept the connection; return a non-NULL message to reject it (the message
* is logged server-side and STATUS_ERROR is sent in place of STATUS_OK). The
* callback runs in the connection's own process, so it may set up per-module
* process state (e.g. the authorized root). context is an opaque caller
* pointer. */
* is logged server-side and STATUS_ERROR is sent in place of STATUS_OK), or the
* CONFIG_VALIDATE_ALREADY_TERMINATED sentinel when the callback already sent
* its own terminal status. The callback runs in the connection's own process,
* so it may set up per-module process state (e.g. the authorized root) and
* drive the daemon auth handshake. context is an opaque caller pointer. */
typedef const char* (*ConfigValidateFunc)(const Config* config, void* context);
Config* config_receive_with_validate(int file_descriptor, ConfigValidateFunc validate,
void* context);
+527 -108
View File
@@ -3,7 +3,10 @@
#include <ctype.h>
#include <errno.h>
#include <fcntl.h>
#include <limits.h>
#include <openssl/evp.h>
#include <openssl/params.h>
#include <openssl/rand.h>
#include <stdarg.h>
#include <stdint.h>
#include <stdio.h>
@@ -12,11 +15,15 @@
#include <sys/stat.h>
#include <unistd.h>
/* One store entry: a username and its password's SHA-256 hex digest. The
* plaintext password never appears here (and never on the daemon host). */
/* One store entry: a username and its salted PBKDF2 verifier. The plaintext
* password never appears here (and never on the daemon host); the verifier is
* not replayable because the proof is bound to a per-connection nonce. */
typedef struct CredentialEntry {
char* user;
char* password_hex; /* CREDENTIAL_HASH_HEX_LEN lowercase hex chars */
uint8_t salt[CREDENTIAL_SALT_LEN];
uint32_t iters;
uint8_t stored_key[CREDENTIAL_KEY_LEN];
uint8_t server_key[CREDENTIAL_KEY_LEN];
} CredentialEntry;
struct CredentialStore {
@@ -25,6 +32,15 @@ struct CredentialStore {
int capacity;
};
/* Exact marker prefix of the new store verifier field. */
#define CREDENTIAL_STORE_PREFIX "$fastsync$1$pbkdf2-sha256$"
#define CREDENTIAL_AUTH_PREFIX "FastSync-Auth-v1"
/* Fixed dummy keys used when a user is unknown or off the module's list. They
* can never authenticate because acceptance additionally requires found=true. */
static const uint8_t k_dummy_stored_key[CREDENTIAL_KEY_LEN] = {0};
static const uint8_t k_dummy_server_key[CREDENTIAL_KEY_LEN] = {0};
static void set_error(char* err, size_t err_size, const char* fmt, ...) {
if (!err || err_size == 0)
return;
@@ -106,6 +122,10 @@ static bool username_wellformed(const char* user) {
return true;
}
bool credentials_username_valid(const char* user) {
return username_wellformed(user);
}
static int hex_value(char c) {
if (c >= '0' && c <= '9')
return c - '0';
@@ -114,17 +134,235 @@ static int hex_value(char c) {
return -1;
}
bool credentials_hash_valid(const char* hash_hex) {
if (!hash_hex)
/* True for the OLD `user:SHA256HEX` secret form: exactly 64 lowercase hex
* digits. Such a line is refused loudly (and never accepted) so an operator
* cannot keep a replayable bearer digest in place after the protocol bump. */
static bool secret_is_legacy_hex(const char* s) {
if (!s)
return false;
for (int i = 0; i < CREDENTIAL_HASH_HEX_LEN; i++) {
if (hex_value(hash_hex[i]) < 0)
for (int i = 0; i < 64; i++) {
if (hex_value(s[i]) < 0)
return false;
}
return hash_hex[CREDENTIAL_HASH_HEX_LEN] == '\0';
return s[64] == '\0';
}
static bool append_entry(CredentialStore* store, const char* user, const char* password_hex) {
bool credentials_b64_encode(const uint8_t* in, size_t n, char* out, size_t out_sz) {
if (!in || !out)
return false;
if (n > (size_t)INT_MAX)
return false;
size_t encoded_len = 4 * ((n + 2) / 3);
if (out_sz < encoded_len + 1)
return false;
int written = EVP_EncodeBlock((unsigned char*)out, in, (int)n);
if (written < 0 || (size_t)written != encoded_len)
return false;
out[encoded_len] = '\0';
return true;
}
bool credentials_b64_decode(const char* in, uint8_t* out, size_t out_sz, size_t* out_len) {
if (!in || !out || !out_len)
return false;
size_t len = strlen(in);
/* Every value we decode is short (a 32-byte key is 44 chars); refusing long
* input keeps the scratch buffer fixed and bounds a hostile frame. */
if (len == 0 || (len % 4) != 0 || len > 256)
return false;
size_t padded_len = (len / 4) * 3;
size_t decoded_len = padded_len;
if (in[len - 1] == '=')
decoded_len--;
if (len >= 2 && in[len - 2] == '=')
decoded_len--;
if (decoded_len > out_sz)
return false;
/* EVP_DecodeBlock writes the full (padded) quantum, so decode into a scratch
* buffer sized for it and copy only the real bytes out. */
uint8_t scratch[192];
int n = EVP_DecodeBlock(scratch, (const unsigned char*)in, (int)len);
if (n < 0 || (size_t)n != padded_len)
return false;
memcpy(out, scratch, decoded_len);
credentials_burn((char*)scratch, sizeof(scratch));
*out_len = decoded_len;
return true;
}
bool credentials_random_bytes(uint8_t* out, size_t n) {
if (!out || n == 0 || n > (size_t)INT_MAX)
return false;
return RAND_bytes(out, (int)n) == 1;
}
/* HMAC-SHA256 via the OpenSSL 3 EVP_MAC API (HMAC() is deprecated). */
static bool hmac_sha256(const uint8_t* key, size_t key_len, const uint8_t* data, size_t data_len,
uint8_t out[CREDENTIAL_KEY_LEN]) {
EVP_MAC* mac = EVP_MAC_fetch(NULL, "HMAC", NULL);
if (!mac)
return false;
EVP_MAC_CTX* ctx = EVP_MAC_CTX_new(mac);
EVP_MAC_free(mac);
if (!ctx)
return false;
OSSL_PARAM params[2];
params[0] = OSSL_PARAM_construct_utf8_string("digest", (char*)"SHA256", 0);
params[1] = OSSL_PARAM_construct_end();
size_t out_len = 0;
bool ok =
EVP_MAC_init(ctx, key, key_len, params) == 1 && EVP_MAC_update(ctx, data, data_len) == 1 &&
EVP_MAC_final(ctx, out, &out_len, CREDENTIAL_KEY_LEN) == 1 && out_len == CREDENTIAL_KEY_LEN;
EVP_MAC_CTX_free(ctx);
return ok;
}
static bool sha256(const uint8_t* data, size_t len, uint8_t out[CREDENTIAL_KEY_LEN]) {
unsigned int out_len = 0;
if (EVP_Digest(data, len, out, &out_len, EVP_sha256(), NULL) != 1)
return false;
return out_len == CREDENTIAL_KEY_LEN;
}
bool credentials_compute_keys(const char* password, const uint8_t salt[CREDENTIAL_SALT_LEN],
uint32_t iters, uint8_t client_key[CREDENTIAL_KEY_LEN],
uint8_t stored_key[CREDENTIAL_KEY_LEN],
uint8_t server_key[CREDENTIAL_KEY_LEN]) {
if (!password || !salt)
return false;
/* The caller (store parser / client clamp) is responsible for the
* [MIN,MAX] policy; this primitive only refuses a zero/unbounded work
* factor. Tests exercise the known-answer vector at a smaller count. */
if (iters == 0 || iters > CREDENTIAL_MAX_ITERS)
return false;
size_t password_len = strlen(password);
if (password_len > CREDENTIAL_MAX_PASSWORD_LEN || password_len > (size_t)INT_MAX)
return false;
uint8_t k[CREDENTIAL_KEY_LEN];
if (PKCS5_PBKDF2_HMAC(password, (int)password_len, salt, CREDENTIAL_SALT_LEN, (int)iters,
EVP_sha256(), CREDENTIAL_KEY_LEN, k) != 1) {
credentials_burn((char*)k, sizeof(k));
return false;
}
uint8_t derived_client[CREDENTIAL_KEY_LEN];
uint8_t derived_server[CREDENTIAL_KEY_LEN];
bool ok = hmac_sha256(k, sizeof(k), (const uint8_t*)"Client Key", 10, derived_client) &&
hmac_sha256(k, sizeof(k), (const uint8_t*)"Server Key", 10, derived_server);
if (ok && stored_key)
ok = sha256(derived_client, sizeof(derived_client), stored_key);
if (ok && client_key)
memcpy(client_key, derived_client, CREDENTIAL_KEY_LEN);
if (ok && server_key)
memcpy(server_key, derived_server, CREDENTIAL_KEY_LEN);
credentials_burn((char*)k, sizeof(k));
credentials_burn((char*)derived_client, sizeof(derived_client));
credentials_burn((char*)derived_server, sizeof(derived_server));
return ok;
}
static void write_be32(uint8_t* out, uint32_t value) {
out[0] = (uint8_t)(value >> 24);
out[1] = (uint8_t)(value >> 16);
out[2] = (uint8_t)(value >> 8);
out[3] = (uint8_t)value;
}
bool credentials_build_auth_message(const char* user, const uint8_t* snonce, const uint8_t* cnonce,
uint8_t* out, size_t out_sz, size_t* out_len) {
if (!user || !snonce || !cnonce || !out || !out_len)
return false;
size_t user_len = strlen(user);
if (user_len > CREDENTIAL_MAX_USER_LEN)
return false;
size_t total = 16 + 4 + user_len + 4 + CREDENTIAL_NONCE_LEN + 4 + CREDENTIAL_NONCE_LEN;
if (out_sz < total)
return false;
size_t off = 0;
memcpy(out + off, CREDENTIAL_AUTH_PREFIX, 16);
off += 16;
write_be32(out + off, (uint32_t)user_len);
off += 4;
memcpy(out + off, user, user_len);
off += user_len;
write_be32(out + off, CREDENTIAL_NONCE_LEN);
off += 4;
memcpy(out + off, snonce, CREDENTIAL_NONCE_LEN);
off += CREDENTIAL_NONCE_LEN;
write_be32(out + off, CREDENTIAL_NONCE_LEN);
off += 4;
memcpy(out + off, cnonce, CREDENTIAL_NONCE_LEN);
off += CREDENTIAL_NONCE_LEN;
*out_len = off;
return true;
}
bool credentials_client_proof(const uint8_t client_key[CREDENTIAL_KEY_LEN],
const uint8_t stored_key[CREDENTIAL_KEY_LEN],
const uint8_t server_key[CREDENTIAL_KEY_LEN], const uint8_t* auth_msg,
size_t msg_len, uint8_t proof[CREDENTIAL_KEY_LEN],
uint8_t server_sig[CREDENTIAL_KEY_LEN]) {
if (!client_key || !stored_key || !server_key || !auth_msg || !proof || !server_sig)
return false;
uint8_t client_sig[CREDENTIAL_KEY_LEN];
bool ok = hmac_sha256(stored_key, CREDENTIAL_KEY_LEN, auth_msg, msg_len, client_sig);
if (ok) {
for (size_t i = 0; i < CREDENTIAL_KEY_LEN; i++)
proof[i] = client_key[i] ^ client_sig[i];
ok = hmac_sha256(server_key, CREDENTIAL_KEY_LEN, auth_msg, msg_len, server_sig);
}
credentials_burn((char*)client_sig, sizeof(client_sig));
return ok;
}
bool credentials_verify_response(const CredentialVerifier* v, const char* user,
const uint8_t* snonce, const uint8_t* cnonce,
const uint8_t proof[CREDENTIAL_KEY_LEN],
uint8_t server_sig_out[CREDENTIAL_KEY_LEN]) {
if (!v || !user || !snonce || !cnonce || !proof || !server_sig_out)
return false;
uint8_t auth_msg[CREDENTIAL_AUTH_MESSAGE_MAX];
size_t msg_len = 0;
if (!credentials_build_auth_message(user, snonce, cnonce, auth_msg, sizeof(auth_msg), &msg_len))
return false;
uint8_t client_sig[CREDENTIAL_KEY_LEN];
uint8_t client_key[CREDENTIAL_KEY_LEN];
uint8_t recovered[CREDENTIAL_KEY_LEN];
uint8_t server_sig[CREDENTIAL_KEY_LEN];
bool computed = hmac_sha256(v->stored_key, CREDENTIAL_KEY_LEN, auth_msg, msg_len, client_sig);
if (computed) {
for (size_t i = 0; i < CREDENTIAL_KEY_LEN; i++)
client_key[i] = proof[i] ^ client_sig[i];
computed = sha256(client_key, CREDENTIAL_KEY_LEN, recovered);
}
if (computed)
computed = hmac_sha256(v->server_key, CREDENTIAL_KEY_LEN, auth_msg, msg_len, server_sig);
if (computed)
memcpy(server_sig_out, server_sig, CREDENTIAL_KEY_LEN);
/* Constant-time compare over the fixed 32-byte keys; a tampered nonce
* changes the AuthMessage and so the recovered key. */
bool accept = computed && v->found &&
credentials_secure_equal((const char*)recovered, (const char*)v->stored_key,
CREDENTIAL_KEY_LEN);
credentials_burn((char*)auth_msg, sizeof(auth_msg));
credentials_burn((char*)client_sig, sizeof(client_sig));
credentials_burn((char*)client_key, sizeof(client_key));
credentials_burn((char*)recovered, sizeof(recovered));
credentials_burn((char*)server_sig, sizeof(server_sig));
return accept;
}
static bool entries_equal(const CredentialEntry* a, const CredentialEntry* b) {
return a->iters == b->iters &&
credentials_secure_equal((const char*)a->salt, (const char*)b->salt,
CREDENTIAL_SALT_LEN) &&
credentials_secure_equal((const char*)a->stored_key, (const char*)b->stored_key,
CREDENTIAL_KEY_LEN) &&
credentials_secure_equal((const char*)a->server_key, (const char*)b->server_key,
CREDENTIAL_KEY_LEN);
}
static bool append_entry(CredentialStore* store, const char* user, const uint8_t* salt,
uint32_t iters, const uint8_t* stored_key, const uint8_t* server_key) {
if (store->count == store->capacity) {
int new_capacity = store->capacity == 0 ? 8 : store->capacity * 2;
CredentialEntry* grown =
@@ -134,15 +372,15 @@ static bool append_entry(CredentialStore* store, const char* user, const char* p
store->entries = grown;
store->capacity = new_capacity;
}
store->entries[store->count].user = str_dup(user);
store->entries[store->count].password_hex = str_dup(password_hex);
if (!store->entries[store->count].user || !store->entries[store->count].password_hex) {
free(store->entries[store->count].user);
free(store->entries[store->count].password_hex);
store->entries[store->count].user = NULL;
store->entries[store->count].password_hex = NULL;
CredentialEntry* entry = &store->entries[store->count];
memset(entry, 0, sizeof(*entry));
entry->user = str_dup(user);
if (!entry->user)
return false;
}
memcpy(entry->salt, salt, CREDENTIAL_SALT_LEN);
entry->iters = iters;
memcpy(entry->stored_key, stored_key, CREDENTIAL_KEY_LEN);
memcpy(entry->server_key, server_key, CREDENTIAL_KEY_LEN);
store->count++;
return true;
}
@@ -155,9 +393,75 @@ static int find_user(const CredentialStore* store, const char* user) {
return -1;
}
/* Parse one credential store file (user:SHA256HEX per line) into a fresh
* store. Duplicate usernames WITHIN one file are an error (ambiguous). A
* NULL path yields an empty store. */
/* Parse the new `$fastsync$1$pbkdf2-sha256$...` verifier field in place. */
static bool parse_verifier_secret(char* secret, CredentialEntry* entry, const char* path,
int line_no, const char* user, char* err, size_t err_size) {
if (secret_is_legacy_hex(secret)) {
set_error(err, err_size,
"credential file '%s' line %d: legacy unsalted SHA-256 secret for user '%s' is not "
"accepted (protocol 2.19.0 uses a salted PBKDF2 verifier); regenerate the store "
"with --hash-credentials",
path, line_no, user);
return false;
}
const char* prefix = CREDENTIAL_STORE_PREFIX;
size_t prefix_len = strlen(prefix);
if (strncmp(secret, prefix, prefix_len) != 0) {
set_error(err, err_size,
"credential file '%s' line %d: expected a '%s...' verifier for user '%s' (regenerate "
"a legacy line with --hash-credentials)",
path, line_no, prefix, user);
return false;
}
char* cursor = secret + prefix_len;
const char* iters_str = cursor;
char* sep = strchr(cursor, '$');
if (!sep)
goto malformed;
*sep = '\0';
const char* salt_str = sep + 1;
sep = strchr(salt_str, '$');
if (!sep)
goto malformed;
*sep = '\0';
const char* stored_str = sep + 1;
sep = strchr(stored_str, '$');
if (!sep)
goto malformed;
*sep = '\0';
const char* server_str = sep + 1;
if (*iters_str == '\0' || *salt_str == '\0' || *stored_str == '\0' || *server_str == '\0')
goto malformed;
char* end = NULL;
unsigned long parsed = strtoul(iters_str, &end, 10);
if (!end || *end != '\0' || parsed < CREDENTIAL_MIN_ITERS || parsed > CREDENTIAL_MAX_ITERS)
goto malformed;
entry->iters = (uint32_t)parsed;
size_t decoded = 0;
if (!credentials_b64_decode(salt_str, entry->salt, CREDENTIAL_SALT_LEN, &decoded) ||
decoded != CREDENTIAL_SALT_LEN)
goto malformed;
if (!credentials_b64_decode(stored_str, entry->stored_key, CREDENTIAL_KEY_LEN, &decoded) ||
decoded != CREDENTIAL_KEY_LEN)
goto malformed;
if (!credentials_b64_decode(server_str, entry->server_key, CREDENTIAL_KEY_LEN, &decoded) ||
decoded != CREDENTIAL_KEY_LEN)
goto malformed;
return true;
malformed:
set_error(err, err_size,
"credential file '%s' line %d: malformed verifier for user '%s' (expected "
"'%s<iters>$<salt_b64>$<stored_key_b64>$<server_key_b64>')",
path, line_no, user, prefix);
return false;
}
/* Parse one credential store file into a fresh store. Duplicate usernames
* WITHIN one file are an error (ambiguous). A NULL path yields an empty
* store. */
static CredentialStore* load_store_file(const char* path, char* err, size_t err_size) {
CredentialStore* store = calloc(1, sizeof(CredentialStore));
if (!store) {
@@ -200,14 +504,14 @@ static CredentialStore* load_store_file(const char* path, char* err, size_t err_
char* colon = strchr(cursor, ':');
if (!colon) {
set_error(err, err_size,
"credential file '%s' line %d: expected 'user:SHA256HEX' (no ':' found)", path,
"credential file '%s' line %d: expected 'user:$fastsync$...' (no ':' found)", path,
line_no);
ok = false;
break;
}
*colon = '\0';
const char* user = trim_space(cursor);
const char* secret = trim_space(colon + 1);
char* secret = trim_space(colon + 1);
if (!username_wellformed(user)) {
set_error(err, err_size,
"credential file '%s' line %d: invalid username (must be 1-%d "
@@ -216,11 +520,9 @@ static CredentialStore* load_store_file(const char* path, char* err, size_t err_
ok = false;
break;
}
if (!credentials_hash_valid(secret)) {
set_error(err, err_size,
"credential file '%s' line %d: secret for user '%s' must be %d "
"lowercase hex characters (the SHA-256 of the password)",
path, line_no, user, CREDENTIAL_HASH_HEX_LEN);
CredentialEntry parsed;
memset(&parsed, 0, sizeof(parsed));
if (!parse_verifier_secret(secret, &parsed, path, line_no, user, err, err_size)) {
ok = false;
break;
}
@@ -230,7 +532,8 @@ static CredentialStore* load_store_file(const char* path, char* err, size_t err_
ok = false;
break;
}
if (!append_entry(store, user, secret)) {
if (!append_entry(store, user, parsed.salt, parsed.iters, parsed.stored_key,
parsed.server_key)) {
set_error(err, err_size, "out of memory reading credential file '%s'", path);
ok = false;
break;
@@ -242,6 +545,7 @@ static CredentialStore* load_store_file(const char* path, char* err, size_t err_
ok = false;
}
fclose(fp);
credentials_burn(line, sizeof(line));
if (!ok) {
credentials_free(store);
return NULL;
@@ -264,14 +568,14 @@ CredentialStore* credentials_load(const char* password_file, const char* early_i
credentials_free(store);
return NULL;
}
/* Layer early input over the password file: same secret dedupes, a differing
* secret for the same user is ambiguous and fails closed. */
/* Layer early input over the password file: an identical verifier dedupes, a
* differing verifier for the same user is ambiguous and fails closed. */
for (int i = 0; i < early->count; i++) {
int existing = find_user(store, early->entries[i].user);
if (existing >= 0) {
if (strcmp(store->entries[existing].password_hex, early->entries[i].password_hex) != 0) {
if (!entries_equal(&store->entries[existing], &early->entries[i])) {
set_error(err, err_size,
"credential file '%s' and early-input file '%s' disagree on the secret for "
"credential file '%s' and early-input file '%s' disagree on the verifier for "
"user '%s'",
password_file, early_input_file, early->entries[i].user);
credentials_free(early);
@@ -280,7 +584,9 @@ CredentialStore* credentials_load(const char* password_file, const char* early_i
}
continue; /* identical; nothing to merge */
}
if (!append_entry(store, early->entries[i].user, early->entries[i].password_hex)) {
if (!append_entry(store, early->entries[i].user, early->entries[i].salt,
early->entries[i].iters, early->entries[i].stored_key,
early->entries[i].server_key)) {
set_error(err, err_size, "out of memory merging early-input credentials");
credentials_free(early);
credentials_free(store);
@@ -295,8 +601,11 @@ void credentials_free(CredentialStore* store) {
if (!store)
return;
for (int i = 0; i < store->count; i++) {
/* Wipe the derived keys before releasing the entry (A7-4). */
credentials_burn((char*)store->entries[i].salt, CREDENTIAL_SALT_LEN);
credentials_burn((char*)store->entries[i].stored_key, CREDENTIAL_KEY_LEN);
credentials_burn((char*)store->entries[i].server_key, CREDENTIAL_KEY_LEN);
free(store->entries[i].user);
free(store->entries[i].password_hex);
}
free(store->entries);
free(store);
@@ -317,24 +626,197 @@ bool credentials_secure_equal(const char* a, const char* b, size_t len) {
return diff == 0;
}
bool credentials_hash_password(const char* password, char* out_hex) {
if (!password || !out_hex)
/* Constant-time equality over two usernames. Compares a fixed
* CREDENTIAL_MAX_USER_LEN-byte window (padding with zeros past each string's
* own length) and folds the length difference into the accumulator, so no byte
* returns early. This closes the byte-wise username-enumeration timing oracle
* that a plain strcmp (which short-circuits on the first differing byte)
* would otherwise expose. Over-long inputs are refused (length differs), which
* is a non-secret branch: usernames are bounded in every caller anyway. */
static bool username_secure_equal(const char* a, const char* b) {
size_t alen = strlen(a);
size_t blen = strlen(b);
if (alen > CREDENTIAL_MAX_USER_LEN || blen > CREDENTIAL_MAX_USER_LEN)
return false;
uint8_t digest[EVP_MAX_MD_SIZE];
unsigned int digest_len = 0;
if (EVP_Digest(password, strlen(password), digest, &digest_len, EVP_sha256(), NULL) != 1)
size_t diff = alen ^ blen;
for (size_t i = 0; i < CREDENTIAL_MAX_USER_LEN; i++) {
unsigned char ac = i < alen ? (unsigned char)a[i] : 0u;
unsigned char bc = i < blen ? (unsigned char)b[i] : 0u;
diff |= (size_t)(ac ^ bc);
}
return diff == 0;
}
bool credentials_get_verifier(const CredentialStore* store, const char* user,
const char* const* module_users, int n, CredentialVerifier* out) {
if (!out)
return false;
if (digest_len != 32)
memset(out, 0, sizeof(*out));
/* Start from the dummy verifier: a fresh random salt and the default
* iteration count, so a miss is shaped exactly like a hit. */
if (!credentials_random_bytes(out->salt, CREDENTIAL_SALT_LEN))
return false;
static const char hex[] = "0123456789abcdef";
for (unsigned int i = 0; i < digest_len; i++) {
out_hex[2 * i] = hex[digest[i] >> 4];
out_hex[2 * i + 1] = hex[digest[i] & 0x0f];
out->iters = CREDENTIAL_DEFAULT_ITERS;
memcpy(out->stored_key, k_dummy_stored_key, CREDENTIAL_KEY_LEN);
memcpy(out->server_key, k_dummy_server_key, CREDENTIAL_KEY_LEN);
out->found = false;
if (!store || !user || n < 0)
return true;
/* Module-list membership: constant-time full scan, no early break, so the
* list is not a username-enumeration oracle. */
bool on_list = false;
for (int i = 0; i < n; i++) {
if (module_users && module_users[i] && username_secure_equal(module_users[i], user))
on_list = true;
}
if (!on_list)
return true;
/* Store lookup is also a constant-time full scan. */
const CredentialEntry* match = NULL;
for (int i = 0; i < store->count; i++) {
if (username_secure_equal(store->entries[i].user, user))
match = &store->entries[i];
}
if (match) {
memcpy(out->salt, match->salt, CREDENTIAL_SALT_LEN);
out->iters = match->iters;
memcpy(out->stored_key, match->stored_key, CREDENTIAL_KEY_LEN);
memcpy(out->server_key, match->server_key, CREDENTIAL_KEY_LEN);
out->found = true;
}
out_hex[2 * digest_len] = '\0';
return true;
}
bool credentials_hash_store_line(const char* user, const char* password, uint32_t iters, char* out,
size_t out_sz, char* err, size_t err_size) {
if (err && err_size)
err[0] = '\0';
if (!username_wellformed(user)) {
set_error(err, err_size, "invalid username (1-%d non-whitespace characters)",
CREDENTIAL_MAX_USER_LEN);
return false;
}
if (!password || !out || out_sz == 0) {
set_error(err, err_size, "missing password or output buffer");
return false;
}
if (strlen(password) > CREDENTIAL_MAX_PASSWORD_LEN) {
set_error(err, err_size, "password exceeds %d characters", CREDENTIAL_MAX_PASSWORD_LEN);
return false;
}
if (iters < CREDENTIAL_MIN_ITERS || iters > CREDENTIAL_MAX_ITERS) {
set_error(err, err_size, "iterations %u out of range [%u,%u]", iters, CREDENTIAL_MIN_ITERS,
CREDENTIAL_MAX_ITERS);
return false;
}
uint8_t salt[CREDENTIAL_SALT_LEN];
uint8_t client_key[CREDENTIAL_KEY_LEN];
uint8_t stored_key[CREDENTIAL_KEY_LEN];
uint8_t server_key[CREDENTIAL_KEY_LEN];
char salt_b64[25];
char stored_b64[45];
char server_b64[45];
bool ok =
credentials_random_bytes(salt, sizeof(salt)) &&
credentials_compute_keys(password, salt, iters, client_key, stored_key, server_key) &&
credentials_b64_encode(salt, sizeof(salt), salt_b64, sizeof(salt_b64)) &&
credentials_b64_encode(stored_key, sizeof(stored_key), stored_b64, sizeof(stored_b64)) &&
credentials_b64_encode(server_key, sizeof(server_key), server_b64, sizeof(server_b64));
int written = -1;
if (ok) {
written = snprintf(out, out_sz, "%s:%s%u$%s$%s$%s", user, CREDENTIAL_STORE_PREFIX, iters,
salt_b64, stored_b64, server_b64);
}
credentials_burn((char*)client_key, sizeof(client_key));
credentials_burn((char*)stored_key, sizeof(stored_key));
credentials_burn((char*)server_key, sizeof(server_key));
credentials_burn((char*)salt, sizeof(salt));
if (!ok)
return false;
if (written < 0 || (size_t)written >= out_sz) {
set_error(err, err_size, "output buffer too small for the credential line");
return false;
}
return true;
}
int credentials_hash_file(const char* path, uint32_t iters, FILE* out, char* err, size_t err_size) {
if (err && err_size)
err[0] = '\0';
if (!path || !out) {
set_error(err, err_size, "missing plaintext file or output stream");
return -1;
}
if (iters < CREDENTIAL_MIN_ITERS || iters > CREDENTIAL_MAX_ITERS) {
set_error(err, err_size, "iterations %u out of range [%u,%u]", iters, CREDENTIAL_MIN_ITERS,
CREDENTIAL_MAX_ITERS);
return -1;
}
FILE* fp = secret_file_open(path, err, err_size);
if (!fp)
return -1;
int line_no = 0;
int result = 0;
char line[CREDENTIAL_MAX_LINE + 2];
while (fgets(line, sizeof(line), fp)) {
line_no++;
size_t len = strlen(line);
if (len == CREDENTIAL_MAX_LINE + 1 && line[len - 1] != '\n' && !feof(fp)) {
set_error(err, err_size, "plaintext file '%s' line %d exceeds the %d-byte limit", path,
line_no, CREDENTIAL_MAX_LINE);
result = -1;
break;
}
while (len > 0 && (line[len - 1] == '\n' || line[len - 1] == '\r'))
line[--len] = '\0';
char* cursor = line;
while (*cursor == ' ' || *cursor == '\t')
cursor++;
if (*cursor == '\0' || is_comment_char(*cursor))
continue;
char* colon = strchr(cursor, ':');
if (!colon) {
set_error(err, err_size, "plaintext file '%s' line %d: expected 'user:password'", path,
line_no);
result = -1;
break;
}
*colon = '\0';
const char* user = trim_space(cursor);
const char* password = colon + 1;
if (!username_wellformed(user)) {
set_error(err, err_size, "plaintext file '%s' line %d: invalid username", path, line_no);
result = -1;
break;
}
if (*password == '\0') {
set_error(err, err_size, "plaintext file '%s' line %d: empty password", path, line_no);
result = -1;
break;
}
char store_line[CREDENTIAL_MAX_LINE];
if (!credentials_hash_store_line(user, password, iters, store_line, sizeof(store_line), err,
err_size)) {
result = -1;
break;
}
if (fprintf(out, "%s\n", store_line) < 0) {
set_error(err, err_size, "cannot write hashed credentials: %s", strerror(errno));
credentials_burn(store_line, sizeof(store_line));
result = -1;
break;
}
credentials_burn(store_line, sizeof(store_line));
}
if (result == 0 && ferror(fp)) {
set_error(err, err_size, "error reading plaintext file '%s': %s", path, strerror(errno));
result = -1;
}
credentials_burn(line, sizeof(line));
fclose(fp);
return result;
}
int credentials_read_secret_file(const char* path, char** user_out, char** password_out, char* err,
size_t err_size) {
if (user_out)
@@ -447,66 +929,3 @@ void credentials_burn(char* secret, size_t len) {
for (size_t i = 0; i < len; i++)
p[i] = '\0';
}
/* Constant-time equality over two usernames. Compares a fixed
* CREDENTIAL_MAX_USER_LEN-byte window (padding with zeros past each string's
* own length) and folds the length difference into the accumulator, so no byte
* returns early. This closes the byte-wise username-enumeration timing oracle
* that a plain strcmp (which short-circuits on the first differing byte)
* would otherwise expose. Over-long inputs are refused (length differs), which
* is a non-secret branch: usernames are bounded in every caller anyway. */
static bool username_secure_equal(const char* a, const char* b) {
size_t alen = strlen(a);
size_t blen = strlen(b);
if (alen > CREDENTIAL_MAX_USER_LEN || blen > CREDENTIAL_MAX_USER_LEN)
return false;
size_t diff = alen ^ blen;
for (size_t i = 0; i < CREDENTIAL_MAX_USER_LEN; i++) {
unsigned char ac = i < alen ? (unsigned char)a[i] : 0u;
unsigned char bc = i < blen ? (unsigned char)b[i] : 0u;
diff |= (size_t)(ac ^ bc);
}
return diff == 0;
}
/* Fixed 64-lowercase-hex dummy used for a constant-time digest comparison when
* the presented user is unknown, so the verify path takes the same time for an
* unknown user and a wrong password. Value chosen arbitrarily; it can never
* authenticate because a real store entry is preferred when it exists. */
static const char k_dummy_hash[CREDENTIAL_HASH_HEX_LEN + 1] =
"0000000000000000000000000000000000000000000000000000000000000000";
bool credentials_verify(const CredentialStore* store, const char* user,
const char* presented_hash_hex) {
if (!store || !user || !presented_hash_hex || !credentials_hash_valid(presented_hash_hex))
return false;
const char* stored = k_dummy_hash;
for (int i = 0; i < store->count; i++) {
/* Constant-time username match: no early return, so time depends on the
* fixed compare window and a byte-wise prefix match cannot be observed. */
if (username_secure_equal(store->entries[i].user, user))
stored = store->entries[i].password_hex;
}
return credentials_secure_equal(presented_hash_hex, stored, CREDENTIAL_HASH_HEX_LEN);
}
bool credentials_gate_allows(const CredentialStore* store, const char* const* module_users,
int module_user_count, const char* presented_user,
const char* presented_hash_hex) {
if (!store || module_user_count < 0)
return false; /* fail closed: an auth-required module without a store refuses */
if (!presented_user || !presented_hash_hex)
return false; /* no credentials presented */
bool on_module_list = false;
for (int i = 0; i < module_user_count; i++) {
/* Constant-time match against the module's auth-users list, for the same
* reason as credentials_verify, so the list is not an enumeration oracle. */
if (module_users[i] && username_secure_equal(module_users[i], presented_user)) {
on_module_list = true;
break;
}
}
if (!on_module_list)
return false;
return credentials_verify(store, presented_user, presented_hash_hex);
}
+130 -71
View File
@@ -3,46 +3,69 @@
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
/* Daemon password authentication (Wave B).
/* Daemon password authentication (A7 remediation, protocol 2.19.0).
*
* FastSync authenticates a daemon connection with a username plus a SHA-256
* hex digest of that username's password. The digest is what crosses the
* wire: a challenge-less credential exchange, so the literal password is never
* transmitted (and never stored on the daemon host). A module that declares
* `auth users` demands that the presented username is on its list AND that the
* presented digest matches the credential store's entry for that username.
* The digest comparison is constant-time; a module with `auth users` whose
* store is missing/misconfigured fails CLOSED (never falls open).
* FastSync authenticates a daemon connection with a SCRAM-SHA-256-style
* challenge/response handshake. The daemon stores only a salted PBKDF2
* verifier (never the password, and never a value that can be replayed as a
* bearer credential): the client proves knowledge of the password against a
* per-connection server nonce, and the server proves the same shared secret
* back. See credentials.c for the exact derivation.
*
* Credential store format (server --password-file and --early-input): one
* `user:SHA256HEX` entry per line. SHA256HEX is the lowercase hex SHA-256 of
* the user's password -- the exact value a FastSync client transmits. Blank
* lines and lines whose first non-space character is '#' or ';' are comments.
* The parser is STRICT: a malformed line (no ':', an empty/whitespace user, a
* secret that is not 64 lowercase hex chars, a line longer than
* CREDENTIAL_MAX_LINE) fails the whole load so a typo can never silently
* change who may log in.
* Server credential store format (--password-file and --early-input): one line
* per entry,
* user:$fastsync$1$pbkdf2-sha256$<iters>$<salt_b64>$<stored_key_b64>$<server_key_b64>
* with standard base64, a 16-byte salt and 32-byte keys, and iters in
* [CREDENTIAL_MIN_ITERS, CREDENTIAL_MAX_ITERS]. Blank lines and lines whose
* first non-space character is '#' or ';' are comments. The parser is STRICT:
* a malformed line fails the whole load so a typo can never silently change who
* may log in. A line holding the legacy (unsalted SHA-256 hex) secret is
* hard-rejected with an actionable "legacy" error; there is no auto-upgrade.
* Use `fastsync-server --hash-credentials` to generate new-format lines.
*
* Client --password-file format: the FIRST meaningful (non-comment, non-blank)
* line is `user:password`, holding the literal password. The client hashes it
* and sends only the digest; the file should be mode 0600 and readable only by
* its owner.
*/
* line is `user:password`, holding the literal password. The client keeps it
* only for the duration of the handshake and wipes it at teardown; the file
* should be mode 0600 and readable only by its owner. */
/* Lowercase hex length of a SHA-256 digest (what travels on the wire and what
* the server store holds). */
#define CREDENTIAL_HASH_HEX_LEN 64
/* Longest accepted credential-file line (excluding the trailing newline). */
#define CREDENTIAL_MAX_LINE 4096
/* Upper bound on a username in a credential file and on the wire. Kept well
* below MAX_STRING_SIZE so a wire username can never exhaust anything. */
#define CREDENTIAL_MAX_USER_LEN 256
/* Upper bound on a client-file password (before hashing). */
/* Upper bound on a client-file password (before derivation). */
#define CREDENTIAL_MAX_PASSWORD_LEN 1024
/* SCRAM-SHA-256 parameters. Salt and client nonce sizes are fixed by the
* shared-auth-message framing; keys are always 32 bytes (SHA-256). */
#define CREDENTIAL_SALT_LEN 16
#define CREDENTIAL_NONCE_LEN 32
#define CREDENTIAL_KEY_LEN 32
#define CREDENTIAL_DEFAULT_ITERS 600000u
#define CREDENTIAL_MIN_ITERS 100000u
#define CREDENTIAL_MAX_ITERS 10000000u
/* Buffer size for the full AuthMessage (prefix + three length-prefixed fields).
* Worst case: 16 + 4 + 256 + 4 + 32 + 4 + 32. */
#define CREDENTIAL_AUTH_MESSAGE_MAX \
(16 + 4 + CREDENTIAL_MAX_USER_LEN + 4 + CREDENTIAL_NONCE_LEN + 4 + CREDENTIAL_NONCE_LEN)
typedef struct CredentialStore CredentialStore;
/* One resolved verifier. `found` is false for an unknown user or a user not on
* a module's auth list; the remaining fields then hold a fresh random salt, the
* default iteration count and fixed dummy keys, so the server can run the same
* challenge/response math with no enumeration/timing oracle. */
typedef struct {
uint8_t salt[CREDENTIAL_SALT_LEN];
uint32_t iters;
uint8_t stored_key[CREDENTIAL_KEY_LEN];
uint8_t server_key[CREDENTIAL_KEY_LEN];
bool found;
} CredentialVerifier;
/* Load the daemon credential store.
*
* password_file and early_input_file are both NULL-or-path, matching the
@@ -52,70 +75,106 @@ typedef struct CredentialStore CredentialStore;
* module with a partial store. Both files may be NULL, which yields an empty
* store (every auth-required module then refuses connections). When both are
* given, the --early-input file is layered over --password-file: a duplicate
* username whose secret matches is deduplicated; one whose secret differs is
* an error (the two sources disagree), never a silent pick.
* username whose verifier matches is deduplicated; one whose verifier differs
* is an error (the two sources disagree), never a silent pick.
*
* The returned store is heap-owned; free it with credentials_free. */
CredentialStore* credentials_load(const char* password_file, const char* early_input_file,
char* err, size_t err_size);
/* Wipe every stored key/salt and free the store. */
void credentials_free(CredentialStore* store);
/* True when `hash_hex` is exactly CREDENTIAL_HASH_HEX_LEN lowercase hex digits
* (the wire/store digest form). Used to reject a malformed presented digest
* before it reaches the comparison. */
bool credentials_hash_valid(const char* hash_hex);
/* True when `user` is a single bounded token free of whitespace/control bytes
* (the rule applied to store users, client-file users and the module list). */
bool credentials_username_valid(const char* user);
/* Compute the lowercase hex SHA-256 of `password` into out_hex, which must
* hold at least CREDENTIAL_HASH_HEX_LEN + 1 bytes. Returns false on a NULL
* password or a hashing failure. The output is NUL-terminated. */
bool credentials_hash_password(const char* password, char* out_hex);
/* Standard base64. encode writes NUL-terminated output to out (size out_sz).
* decode writes the raw bytes to out (capacity out_sz) and stores the length;
* the input must be a well-formed padded base64 string. Both return false on
* NULL arguments, a bad character/length, or insufficient output space. */
bool credentials_b64_encode(const uint8_t* in, size_t n, char* out, size_t out_sz);
bool credentials_b64_decode(const char* in, uint8_t* out, size_t out_sz, size_t* out_len);
/* Fill out[0..n) from the CSPRNG (RAND_bytes). Returns false on failure. */
bool credentials_random_bytes(uint8_t* out, size_t n);
/* Resolve `user` against the store AND the module's auth-user list. The list
* scan is a constant-time full-length comparison with no early break. On a
* miss, *out is filled with a dummy verifier (fresh random salt, default
* iterations, fixed dummy keys, found=false). Returns false only on invalid
* arguments/allocation failure. */
bool credentials_get_verifier(const CredentialStore* store, const char* user,
const char* const* module_users, int n, CredentialVerifier* out);
/* Derive the SCRAM keys from a plaintext password:
* K = PBKDF2-HMAC-SHA256(password, salt, iters, 32)
* ClientKey = HMAC-SHA256(K, "Client Key"); StoredKey = SHA256(ClientKey)
* ServerKey = HMAC-SHA256(K, "Server Key")
* Any of client_key/stored_key/server_key may be NULL when not needed. */
bool credentials_compute_keys(const char* password, const uint8_t salt[CREDENTIAL_SALT_LEN],
uint32_t iters, uint8_t client_key[CREDENTIAL_KEY_LEN],
uint8_t stored_key[CREDENTIAL_KEY_LEN],
uint8_t server_key[CREDENTIAL_KEY_LEN]);
/* Serialize the shared AuthMessage:
* "FastSync-Auth-v1" || be32(len(user)) || user
* || be32(32) || server_nonce
* || be32(32) || client_nonce
* out must hold at least CREDENTIAL_AUTH_MESSAGE_MAX bytes. *out_len receives
* the number of bytes written. */
bool credentials_build_auth_message(const char* user, const uint8_t* snonce, const uint8_t* cnonce,
uint8_t* out, size_t out_sz, size_t* out_len);
/* Client side: ClientProof = ClientKey XOR HMAC(StoredKey, AuthMessage), and
* the expected ServerSignature = HMAC(ServerKey, AuthMessage). */
bool credentials_client_proof(const uint8_t client_key[CREDENTIAL_KEY_LEN],
const uint8_t stored_key[CREDENTIAL_KEY_LEN],
const uint8_t server_key[CREDENTIAL_KEY_LEN], const uint8_t* auth_msg,
size_t msg_len, uint8_t proof[CREDENTIAL_KEY_LEN],
uint8_t server_sig[CREDENTIAL_KEY_LEN]);
/* Server side: recompute ClientSig' = HMAC(StoredKey, AuthMessage) and
* ClientKey' = proof XOR ClientSig', then accept iff v->found AND
* SHA256(ClientKey') equals StoredKey (constant-time over the 32-byte keys).
* Always computes server_sig_out = HMAC(ServerKey, AuthMessage). Returns the
* accept decision. */
bool credentials_verify_response(const CredentialVerifier* v, const char* user,
const uint8_t* snonce, const uint8_t* cnonce,
const uint8_t proof[CREDENTIAL_KEY_LEN],
uint8_t server_sig_out[CREDENTIAL_KEY_LEN]);
/* Derive a new-format store line for `user`/`password` and write it (without a
* trailing newline) into out. A random 16-byte salt is used. On failure err is
* filled. Used by --hash-credentials and by tests. */
bool credentials_hash_store_line(const char* user, const char* password, uint32_t iters, char* out,
size_t out_sz, char* err, size_t err_size);
/* Read `user:password` lines from `path` (the same owner-only check as the
* other secret files) and write one new-format store line per entry to `out`.
* Blank/comment lines are skipped; a malformed line fails the whole run.
* Returns 0 on success, -1 on error (err filled). Used by
* `--hash-credentials`. */
int credentials_hash_file(const char* path, uint32_t iters, FILE* out, char* err, size_t err_size);
/* Read the CLIENT-side secret file: the first meaningful line is
* `user:password` (the literal password). *user_out and *password_out are
* freshly allocated on success (password is plaintext -- the caller hashes it
* and then burns/frees it); both are NULL on error. Returns 0 on success, -1
* on failure (err filled: the path is named, never the credential itself).
* Only the line's trailing CR/LF are stripped: the password's bytes are
* otherwise preserved exactly, so a password with leading/trailing whitespace
* (after the ':') is kept usable. The username is trimmed of surrounding
* space/tabs. */
* freshly allocated on success (password is plaintext -- the caller derives the
* proof and then burns/frees it); both are NULL on error. Returns 0 on
* success, -1 on failure (err filled: the path is named, never the credential
* itself). Only the line's trailing CR/LF are stripped: the password's bytes
* are otherwise preserved exactly, so a password with leading/trailing
* whitespace (after the ':') is kept usable. The username is trimmed of
* surrounding space/tabs. */
int credentials_read_secret_file(const char* path, char** user_out, char** password_out, char* err,
size_t err_size);
/* Constant-time equality over exactly len bytes. Returns true when the two
* buffers match. No early exit: the whole length is always scanned, so a
* timing side-channel cannot reveal how many leading bytes matched. */
/* Constant-time equality over exactly len bytes. */
bool credentials_secure_equal(const char* a, const char* b, size_t len);
/* Overwrite secret[0..len) with zeros (best-effort wipe of a plaintext
* password that is about to be freed). */
/* Overwrite secret[0..len) with zeros (best-effort wipe). */
void credentials_burn(char* secret, size_t len);
/* Verify a presented (user, digest) against the store. Returns true only when
* the store holds an entry for `user` whose stored digest equals the presented
* one. A NULL store, NULL user/digest, unknown user and wrong digest all
* return false. The digest comparison runs over a fixed dummy whenever the
* user is absent, and the username lookup is a single constant-time
* full-length compare (no byte-wise early exit), so neither "unknown user" vs
* "wrong password" nor a username prefix match can be distinguished by timing
* (no user-enumeration oracle in the comparison path). */
bool credentials_verify(const CredentialStore* store, const char* user,
const char* presented_hash_hex);
/* The daemon's per-module auth decision, in one pure, unit-testable function.
* `module_users`/`module_user_count` are the module's `auth users` list; a
* module that declares auth users requires the presented user to be ON that
* list AND to verify against the store. Returns false (fail closed) when the
* store is NULL, when no credential was presented, when the user is not on the
* module's list, or when verification fails. This is the single decision the
* server_module_gate seam applies to an auth-required module. Like
* credentials_verify, username matches here use a constant-time full-length
* compare rather than a byte-wise-short-circuiting strcmp. */
bool credentials_gate_allows(const CredentialStore* store, const char* const* module_users,
int module_user_count, const char* presented_user,
const char* presented_hash_hex);
/* Number of entries currently in the store (tests/introspection). */
int credentials_store_size(const CredentialStore* store);
+8
View File
@@ -413,6 +413,14 @@ static const char* status_to_string(Status status) {
return "SPECIAL";
case STATUS_DIR_TIMES:
return "DIR_TIMES";
case STATUS_AUTH_CHALLENGE:
return "AUTH_CHALLENGE";
case STATUS_AUTH_RESPONSE:
return "AUTH_RESPONSE";
case STATUS_AUTH_OK:
return "AUTH_OK";
case STATUS_AUTH_FAILED:
return "AUTH_FAILED";
default:
return "UNKNOWN";
}
+14 -1
View File
@@ -113,7 +113,20 @@ enum NET_STATUS {
* than MAX_MANIFEST_ENTRIES is split across repeated frames. The receiver
* defers the actual utimensat until its own delete/publish phase has
* committed, then skips the whole set when -O/--omit-dir-times is set. */
STATUS_DIR_TIMES
STATUS_DIR_TIMES,
/* Daemon SCRAM-SHA-256 authentication (A7 remediation, protocol 2.19.0).
* STATUS_AUTH_CHALLENGE: the server requires auth and is about to send the
* iteration count, the base64 salt and the base64 server nonce.
* STATUS_AUTH_RESPONSE: the client's reply, followed by the base64 client
* nonce and the base64 ClientProof. STATUS_AUTH_OK: the client proof
* verified, followed by the base64 ServerSignature. STATUS_AUTH_FAILED:
* a single generic refusal (unknown user, off-list user, wrong proof,
* missing/malformed credentials) after which the server closes without
* writing any data. */
STATUS_AUTH_CHALLENGE,
STATUS_AUTH_RESPONSE,
STATUS_AUTH_OK,
STATUS_AUTH_FAILED
};
void io_set_fds(int read_fd, int write_fd);