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;
}