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
+8 -11
View File
@@ -246,9 +246,9 @@ static void test_config_module_wire_empty_canonicalizes_to_null() {
}
}
/* Daemon auth credentials (Wave B) ride the config frame: username + SHA-256
* hex digest are present together, or both are absent. Round-trip a present
* pair. */
/* Daemon auth credentials (A7, protocol 2.19.0) ride the config frame as the
* username ONLY; the literal password never crosses the wire. Round-trip a
* present username. */
static void test_config_daemon_auth_wire_roundtrip() {
Config* send_cfg = config_create();
EXPECT_NOT_NULL(send_cfg);
@@ -256,8 +256,7 @@ static void test_config_daemon_auth_wire_roundtrip() {
send_cfg->receive_root_directory = str_dup("rel/path");
send_cfg->module = str_dup("backup");
send_cfg->auth_user = str_dup("alice");
send_cfg->auth_password_hash =
str_dup("9b90e524e94995ee4aeae2ee3c428a53405d1e8db147f44facc46797d0caf4c3");
send_cfg->auth_password = str_dup("alice-s3cret");
int p[2];
EXPECT_EQ_INT(socketpair(AF_UNIX, SOCK_STREAM, 0, p), 0);
@@ -269,10 +268,9 @@ static void test_config_daemon_auth_wire_roundtrip() {
close(p[1]);
io_set_fds(p[0], p[0]);
Config* recv_cfg = config_receive(p[0]);
/* The plaintext password is client-only: it is never serialized. */
bool ok = recv_cfg != NULL && recv_cfg->auth_user != NULL &&
strcmp(recv_cfg->auth_user, "alice") == 0 && recv_cfg->auth_password_hash != NULL &&
strcmp(recv_cfg->auth_password_hash,
"9b90e524e94995ee4aeae2ee3c428a53405d1e8db147f44facc46797d0caf4c3") == 0;
strcmp(recv_cfg->auth_user, "alice") == 0 && recv_cfg->auth_password == NULL;
config_delete(recv_cfg);
close(p[0]);
_exit(ok ? 0 : 1);
@@ -289,7 +287,7 @@ static void test_config_daemon_auth_wire_roundtrip() {
}
}
/* The receive side validates the auth payload: a present-but-malformed digest
/* The receive side validates the auth payload: a present-but-malformed username
* is refused (config_receive returns NULL), so a hostile peer cannot slip a
* garbage credential past the receive guard into the module gate. */
static void test_config_daemon_auth_wire_rejects_malformed() {
@@ -298,8 +296,7 @@ static void test_config_daemon_auth_wire_rejects_malformed() {
send_cfg->send_directory = str_dup("/src");
send_cfg->receive_root_directory = str_dup("/dst");
send_cfg->module = str_dup("m");
send_cfg->auth_user = str_dup("alice");
send_cfg->auth_password_hash = str_dup("not-a-valid-sha256-hex-digest!!");
send_cfg->auth_user = str_dup("bad user");
int p[2];
EXPECT_EQ_INT(socketpair(AF_UNIX, SOCK_STREAM, 0, p), 0);