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
+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) {