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:
+126
-22
@@ -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) {
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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 */
|
||||
|
||||
Reference in New Issue
Block a user