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:
+8
-5
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user