fix(a7-auth): final hardening pass on SCRAM auth

- burn the store-wide dummy_key in credentials_free()
- burn the local mac on hmac_sha256 failure in credentials_get_verifier()
- always run the O(store) constant-time scan, even for off-list users, to
  close the pre-existing off-list timing channel; select the real verifier
  only when on_list && match
- clarify the server_auth_handshake STATUS_AUTH_FAILED comment (failure
  before success vs. a dropped broken connection while writing the signature)
- document accepted anti-enumeration residuals (restart-gated dummy salt;
  pre-auth-observable iteration count)
This commit is contained in:
2026-09-12 18:08:46 +02:00
parent eaf67f6257
commit 1de1376e54
4 changed files with 29 additions and 17 deletions
+8 -5
View File
@@ -73,8 +73,10 @@ typedef struct ModuleGateContext {
* 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 exactly one generic
* STATUS_AUTH_FAILED and returns false. The verifier for an unknown/off-list
* with the base64 ServerSignature. On any failure BEFORE the success response
* it sends exactly one generic STATUS_AUTH_FAILED and returns false; a failure
* while writing the success signature cannot send a status and just drops an
* already-broken connection. The verifier for an unknown/off-list
* user is a dummy (deterministic per-username salt, store-wide iterations, dummy
* keys, found=false) so the same math runs and no user-enumeration/timing oracle
* is exposed. */
@@ -368,9 +370,10 @@ static const char* server_module_gate(const Config* config, void* context) {
/* 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 writes exactly one STATUS_AUTH_FAILED (on every
* failure path) before signalling ALREADY_TERMINATED. The username may be
* logged (never the password or any derived proof). */
* a handshake that fails before the success response writes exactly one
* STATUS_AUTH_FAILED before signalling ALREADY_TERMINATED (a failure while
* writing the success signature instead just drops the broken connection).
* 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 "
+15 -10
View File
@@ -658,6 +658,9 @@ void credentials_free(CredentialStore* store) {
credentials_burn((char*)store->entries[i].server_key, CREDENTIAL_KEY_LEN);
free(store->entries[i].user);
}
/* The store-wide dummy key is secret (it shapes the miss challenge), so wipe
* it before releasing the store. */
credentials_burn((char*)store->dummy_key, sizeof(store->dummy_key));
free(store->entries);
free(store);
}
@@ -716,28 +719,30 @@ bool credentials_get_verifier(const CredentialStore* store, const char* user,
* reached in production) falls back to the all-zero static key. */
const uint8_t* dummy_key = store ? store->dummy_key : k_dummy_stored_key;
uint8_t mac[CREDENTIAL_KEY_LEN];
if (!hmac_sha256(dummy_key, CREDENTIAL_KEY_LEN, (const uint8_t*)uname, strlen(uname), mac))
if (!hmac_sha256(dummy_key, CREDENTIAL_KEY_LEN, (const uint8_t*)uname, strlen(uname), mac)) {
credentials_burn((char*)mac, sizeof(mac));
return false;
}
memcpy(out->salt, mac, CREDENTIAL_SALT_LEN);
credentials_burn((char*)mac, sizeof(mac));
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;
const char* listed = (module_users && user) ? module_users[i] : NULL;
on_list |= listed ? username_secure_equal(listed, user) : false;
}
if (!on_list)
return true;
/* Store lookup is also a constant-time full scan. */
/* Store lookup is an unconditional constant-time full scan, executed even for
* an off-list user so a probe that is not on the module list still pays the
* same O(store) cost as one that is; skipping it would reopen an off-list
* timing channel. The real verifier is selected only when the user is both
* on the list and matched in the store. */
const CredentialEntry* match = NULL;
for (int i = 0; i < store->count; i++) {
for (int i = 0; store && user && i < store->count; i++) {
if (username_secure_equal(store->entries[i].user, user))
match = &store->entries[i];
}
if (match) {
if (on_list && match) {
memcpy(out->salt, match->salt, CREDENTIAL_SALT_LEN);
out->iters = match->iters;
memcpy(out->stored_key, match->stored_key, CREDENTIAL_KEY_LEN);