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:
+527
-108
@@ -3,7 +3,10 @@
|
||||
#include <ctype.h>
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <limits.h>
|
||||
#include <openssl/evp.h>
|
||||
#include <openssl/params.h>
|
||||
#include <openssl/rand.h>
|
||||
#include <stdarg.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
@@ -12,11 +15,15 @@
|
||||
#include <sys/stat.h>
|
||||
#include <unistd.h>
|
||||
|
||||
/* One store entry: a username and its password's SHA-256 hex digest. The
|
||||
* plaintext password never appears here (and never on the daemon host). */
|
||||
/* One store entry: a username and its salted PBKDF2 verifier. The plaintext
|
||||
* password never appears here (and never on the daemon host); the verifier is
|
||||
* not replayable because the proof is bound to a per-connection nonce. */
|
||||
typedef struct CredentialEntry {
|
||||
char* user;
|
||||
char* password_hex; /* CREDENTIAL_HASH_HEX_LEN lowercase hex chars */
|
||||
uint8_t salt[CREDENTIAL_SALT_LEN];
|
||||
uint32_t iters;
|
||||
uint8_t stored_key[CREDENTIAL_KEY_LEN];
|
||||
uint8_t server_key[CREDENTIAL_KEY_LEN];
|
||||
} CredentialEntry;
|
||||
|
||||
struct CredentialStore {
|
||||
@@ -25,6 +32,15 @@ struct CredentialStore {
|
||||
int capacity;
|
||||
};
|
||||
|
||||
/* Exact marker prefix of the new store verifier field. */
|
||||
#define CREDENTIAL_STORE_PREFIX "$fastsync$1$pbkdf2-sha256$"
|
||||
#define CREDENTIAL_AUTH_PREFIX "FastSync-Auth-v1"
|
||||
|
||||
/* Fixed dummy keys used when a user is unknown or off the module's list. They
|
||||
* can never authenticate because acceptance additionally requires found=true. */
|
||||
static const uint8_t k_dummy_stored_key[CREDENTIAL_KEY_LEN] = {0};
|
||||
static const uint8_t k_dummy_server_key[CREDENTIAL_KEY_LEN] = {0};
|
||||
|
||||
static void set_error(char* err, size_t err_size, const char* fmt, ...) {
|
||||
if (!err || err_size == 0)
|
||||
return;
|
||||
@@ -106,6 +122,10 @@ static bool username_wellformed(const char* user) {
|
||||
return true;
|
||||
}
|
||||
|
||||
bool credentials_username_valid(const char* user) {
|
||||
return username_wellformed(user);
|
||||
}
|
||||
|
||||
static int hex_value(char c) {
|
||||
if (c >= '0' && c <= '9')
|
||||
return c - '0';
|
||||
@@ -114,17 +134,235 @@ static int hex_value(char c) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
bool credentials_hash_valid(const char* hash_hex) {
|
||||
if (!hash_hex)
|
||||
/* True for the OLD `user:SHA256HEX` secret form: exactly 64 lowercase hex
|
||||
* digits. Such a line is refused loudly (and never accepted) so an operator
|
||||
* cannot keep a replayable bearer digest in place after the protocol bump. */
|
||||
static bool secret_is_legacy_hex(const char* s) {
|
||||
if (!s)
|
||||
return false;
|
||||
for (int i = 0; i < CREDENTIAL_HASH_HEX_LEN; i++) {
|
||||
if (hex_value(hash_hex[i]) < 0)
|
||||
for (int i = 0; i < 64; i++) {
|
||||
if (hex_value(s[i]) < 0)
|
||||
return false;
|
||||
}
|
||||
return hash_hex[CREDENTIAL_HASH_HEX_LEN] == '\0';
|
||||
return s[64] == '\0';
|
||||
}
|
||||
|
||||
static bool append_entry(CredentialStore* store, const char* user, const char* password_hex) {
|
||||
bool credentials_b64_encode(const uint8_t* in, size_t n, char* out, size_t out_sz) {
|
||||
if (!in || !out)
|
||||
return false;
|
||||
if (n > (size_t)INT_MAX)
|
||||
return false;
|
||||
size_t encoded_len = 4 * ((n + 2) / 3);
|
||||
if (out_sz < encoded_len + 1)
|
||||
return false;
|
||||
int written = EVP_EncodeBlock((unsigned char*)out, in, (int)n);
|
||||
if (written < 0 || (size_t)written != encoded_len)
|
||||
return false;
|
||||
out[encoded_len] = '\0';
|
||||
return true;
|
||||
}
|
||||
|
||||
bool credentials_b64_decode(const char* in, uint8_t* out, size_t out_sz, size_t* out_len) {
|
||||
if (!in || !out || !out_len)
|
||||
return false;
|
||||
size_t len = strlen(in);
|
||||
/* Every value we decode is short (a 32-byte key is 44 chars); refusing long
|
||||
* input keeps the scratch buffer fixed and bounds a hostile frame. */
|
||||
if (len == 0 || (len % 4) != 0 || len > 256)
|
||||
return false;
|
||||
size_t padded_len = (len / 4) * 3;
|
||||
size_t decoded_len = padded_len;
|
||||
if (in[len - 1] == '=')
|
||||
decoded_len--;
|
||||
if (len >= 2 && in[len - 2] == '=')
|
||||
decoded_len--;
|
||||
if (decoded_len > out_sz)
|
||||
return false;
|
||||
/* EVP_DecodeBlock writes the full (padded) quantum, so decode into a scratch
|
||||
* buffer sized for it and copy only the real bytes out. */
|
||||
uint8_t scratch[192];
|
||||
int n = EVP_DecodeBlock(scratch, (const unsigned char*)in, (int)len);
|
||||
if (n < 0 || (size_t)n != padded_len)
|
||||
return false;
|
||||
memcpy(out, scratch, decoded_len);
|
||||
credentials_burn((char*)scratch, sizeof(scratch));
|
||||
*out_len = decoded_len;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool credentials_random_bytes(uint8_t* out, size_t n) {
|
||||
if (!out || n == 0 || n > (size_t)INT_MAX)
|
||||
return false;
|
||||
return RAND_bytes(out, (int)n) == 1;
|
||||
}
|
||||
|
||||
/* HMAC-SHA256 via the OpenSSL 3 EVP_MAC API (HMAC() is deprecated). */
|
||||
static bool hmac_sha256(const uint8_t* key, size_t key_len, const uint8_t* data, size_t data_len,
|
||||
uint8_t out[CREDENTIAL_KEY_LEN]) {
|
||||
EVP_MAC* mac = EVP_MAC_fetch(NULL, "HMAC", NULL);
|
||||
if (!mac)
|
||||
return false;
|
||||
EVP_MAC_CTX* ctx = EVP_MAC_CTX_new(mac);
|
||||
EVP_MAC_free(mac);
|
||||
if (!ctx)
|
||||
return false;
|
||||
OSSL_PARAM params[2];
|
||||
params[0] = OSSL_PARAM_construct_utf8_string("digest", (char*)"SHA256", 0);
|
||||
params[1] = OSSL_PARAM_construct_end();
|
||||
size_t out_len = 0;
|
||||
bool ok =
|
||||
EVP_MAC_init(ctx, key, key_len, params) == 1 && EVP_MAC_update(ctx, data, data_len) == 1 &&
|
||||
EVP_MAC_final(ctx, out, &out_len, CREDENTIAL_KEY_LEN) == 1 && out_len == CREDENTIAL_KEY_LEN;
|
||||
EVP_MAC_CTX_free(ctx);
|
||||
return ok;
|
||||
}
|
||||
|
||||
static bool sha256(const uint8_t* data, size_t len, uint8_t out[CREDENTIAL_KEY_LEN]) {
|
||||
unsigned int out_len = 0;
|
||||
if (EVP_Digest(data, len, out, &out_len, EVP_sha256(), NULL) != 1)
|
||||
return false;
|
||||
return out_len == CREDENTIAL_KEY_LEN;
|
||||
}
|
||||
|
||||
bool credentials_compute_keys(const char* password, const uint8_t salt[CREDENTIAL_SALT_LEN],
|
||||
uint32_t iters, uint8_t client_key[CREDENTIAL_KEY_LEN],
|
||||
uint8_t stored_key[CREDENTIAL_KEY_LEN],
|
||||
uint8_t server_key[CREDENTIAL_KEY_LEN]) {
|
||||
if (!password || !salt)
|
||||
return false;
|
||||
/* The caller (store parser / client clamp) is responsible for the
|
||||
* [MIN,MAX] policy; this primitive only refuses a zero/unbounded work
|
||||
* factor. Tests exercise the known-answer vector at a smaller count. */
|
||||
if (iters == 0 || iters > CREDENTIAL_MAX_ITERS)
|
||||
return false;
|
||||
size_t password_len = strlen(password);
|
||||
if (password_len > CREDENTIAL_MAX_PASSWORD_LEN || password_len > (size_t)INT_MAX)
|
||||
return false;
|
||||
uint8_t k[CREDENTIAL_KEY_LEN];
|
||||
if (PKCS5_PBKDF2_HMAC(password, (int)password_len, salt, CREDENTIAL_SALT_LEN, (int)iters,
|
||||
EVP_sha256(), CREDENTIAL_KEY_LEN, k) != 1) {
|
||||
credentials_burn((char*)k, sizeof(k));
|
||||
return false;
|
||||
}
|
||||
uint8_t derived_client[CREDENTIAL_KEY_LEN];
|
||||
uint8_t derived_server[CREDENTIAL_KEY_LEN];
|
||||
bool ok = hmac_sha256(k, sizeof(k), (const uint8_t*)"Client Key", 10, derived_client) &&
|
||||
hmac_sha256(k, sizeof(k), (const uint8_t*)"Server Key", 10, derived_server);
|
||||
if (ok && stored_key)
|
||||
ok = sha256(derived_client, sizeof(derived_client), stored_key);
|
||||
if (ok && client_key)
|
||||
memcpy(client_key, derived_client, CREDENTIAL_KEY_LEN);
|
||||
if (ok && server_key)
|
||||
memcpy(server_key, derived_server, CREDENTIAL_KEY_LEN);
|
||||
credentials_burn((char*)k, sizeof(k));
|
||||
credentials_burn((char*)derived_client, sizeof(derived_client));
|
||||
credentials_burn((char*)derived_server, sizeof(derived_server));
|
||||
return ok;
|
||||
}
|
||||
|
||||
static void write_be32(uint8_t* out, uint32_t value) {
|
||||
out[0] = (uint8_t)(value >> 24);
|
||||
out[1] = (uint8_t)(value >> 16);
|
||||
out[2] = (uint8_t)(value >> 8);
|
||||
out[3] = (uint8_t)value;
|
||||
}
|
||||
|
||||
bool credentials_build_auth_message(const char* user, const uint8_t* snonce, const uint8_t* cnonce,
|
||||
uint8_t* out, size_t out_sz, size_t* out_len) {
|
||||
if (!user || !snonce || !cnonce || !out || !out_len)
|
||||
return false;
|
||||
size_t user_len = strlen(user);
|
||||
if (user_len > CREDENTIAL_MAX_USER_LEN)
|
||||
return false;
|
||||
size_t total = 16 + 4 + user_len + 4 + CREDENTIAL_NONCE_LEN + 4 + CREDENTIAL_NONCE_LEN;
|
||||
if (out_sz < total)
|
||||
return false;
|
||||
size_t off = 0;
|
||||
memcpy(out + off, CREDENTIAL_AUTH_PREFIX, 16);
|
||||
off += 16;
|
||||
write_be32(out + off, (uint32_t)user_len);
|
||||
off += 4;
|
||||
memcpy(out + off, user, user_len);
|
||||
off += user_len;
|
||||
write_be32(out + off, CREDENTIAL_NONCE_LEN);
|
||||
off += 4;
|
||||
memcpy(out + off, snonce, CREDENTIAL_NONCE_LEN);
|
||||
off += CREDENTIAL_NONCE_LEN;
|
||||
write_be32(out + off, CREDENTIAL_NONCE_LEN);
|
||||
off += 4;
|
||||
memcpy(out + off, cnonce, CREDENTIAL_NONCE_LEN);
|
||||
off += CREDENTIAL_NONCE_LEN;
|
||||
*out_len = off;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool credentials_client_proof(const uint8_t client_key[CREDENTIAL_KEY_LEN],
|
||||
const uint8_t stored_key[CREDENTIAL_KEY_LEN],
|
||||
const uint8_t server_key[CREDENTIAL_KEY_LEN], const uint8_t* auth_msg,
|
||||
size_t msg_len, uint8_t proof[CREDENTIAL_KEY_LEN],
|
||||
uint8_t server_sig[CREDENTIAL_KEY_LEN]) {
|
||||
if (!client_key || !stored_key || !server_key || !auth_msg || !proof || !server_sig)
|
||||
return false;
|
||||
uint8_t client_sig[CREDENTIAL_KEY_LEN];
|
||||
bool ok = hmac_sha256(stored_key, CREDENTIAL_KEY_LEN, auth_msg, msg_len, client_sig);
|
||||
if (ok) {
|
||||
for (size_t i = 0; i < CREDENTIAL_KEY_LEN; i++)
|
||||
proof[i] = client_key[i] ^ client_sig[i];
|
||||
ok = hmac_sha256(server_key, CREDENTIAL_KEY_LEN, auth_msg, msg_len, server_sig);
|
||||
}
|
||||
credentials_burn((char*)client_sig, sizeof(client_sig));
|
||||
return ok;
|
||||
}
|
||||
|
||||
bool credentials_verify_response(const CredentialVerifier* v, const char* user,
|
||||
const uint8_t* snonce, const uint8_t* cnonce,
|
||||
const uint8_t proof[CREDENTIAL_KEY_LEN],
|
||||
uint8_t server_sig_out[CREDENTIAL_KEY_LEN]) {
|
||||
if (!v || !user || !snonce || !cnonce || !proof || !server_sig_out)
|
||||
return false;
|
||||
uint8_t auth_msg[CREDENTIAL_AUTH_MESSAGE_MAX];
|
||||
size_t msg_len = 0;
|
||||
if (!credentials_build_auth_message(user, snonce, cnonce, auth_msg, sizeof(auth_msg), &msg_len))
|
||||
return false;
|
||||
uint8_t client_sig[CREDENTIAL_KEY_LEN];
|
||||
uint8_t client_key[CREDENTIAL_KEY_LEN];
|
||||
uint8_t recovered[CREDENTIAL_KEY_LEN];
|
||||
uint8_t server_sig[CREDENTIAL_KEY_LEN];
|
||||
bool computed = hmac_sha256(v->stored_key, CREDENTIAL_KEY_LEN, auth_msg, msg_len, client_sig);
|
||||
if (computed) {
|
||||
for (size_t i = 0; i < CREDENTIAL_KEY_LEN; i++)
|
||||
client_key[i] = proof[i] ^ client_sig[i];
|
||||
computed = sha256(client_key, CREDENTIAL_KEY_LEN, recovered);
|
||||
}
|
||||
if (computed)
|
||||
computed = hmac_sha256(v->server_key, CREDENTIAL_KEY_LEN, auth_msg, msg_len, server_sig);
|
||||
if (computed)
|
||||
memcpy(server_sig_out, server_sig, CREDENTIAL_KEY_LEN);
|
||||
/* Constant-time compare over the fixed 32-byte keys; a tampered nonce
|
||||
* changes the AuthMessage and so the recovered key. */
|
||||
bool accept = computed && v->found &&
|
||||
credentials_secure_equal((const char*)recovered, (const char*)v->stored_key,
|
||||
CREDENTIAL_KEY_LEN);
|
||||
credentials_burn((char*)auth_msg, sizeof(auth_msg));
|
||||
credentials_burn((char*)client_sig, sizeof(client_sig));
|
||||
credentials_burn((char*)client_key, sizeof(client_key));
|
||||
credentials_burn((char*)recovered, sizeof(recovered));
|
||||
credentials_burn((char*)server_sig, sizeof(server_sig));
|
||||
return accept;
|
||||
}
|
||||
|
||||
static bool entries_equal(const CredentialEntry* a, const CredentialEntry* b) {
|
||||
return a->iters == b->iters &&
|
||||
credentials_secure_equal((const char*)a->salt, (const char*)b->salt,
|
||||
CREDENTIAL_SALT_LEN) &&
|
||||
credentials_secure_equal((const char*)a->stored_key, (const char*)b->stored_key,
|
||||
CREDENTIAL_KEY_LEN) &&
|
||||
credentials_secure_equal((const char*)a->server_key, (const char*)b->server_key,
|
||||
CREDENTIAL_KEY_LEN);
|
||||
}
|
||||
|
||||
static bool append_entry(CredentialStore* store, const char* user, const uint8_t* salt,
|
||||
uint32_t iters, const uint8_t* stored_key, const uint8_t* server_key) {
|
||||
if (store->count == store->capacity) {
|
||||
int new_capacity = store->capacity == 0 ? 8 : store->capacity * 2;
|
||||
CredentialEntry* grown =
|
||||
@@ -134,15 +372,15 @@ static bool append_entry(CredentialStore* store, const char* user, const char* p
|
||||
store->entries = grown;
|
||||
store->capacity = new_capacity;
|
||||
}
|
||||
store->entries[store->count].user = str_dup(user);
|
||||
store->entries[store->count].password_hex = str_dup(password_hex);
|
||||
if (!store->entries[store->count].user || !store->entries[store->count].password_hex) {
|
||||
free(store->entries[store->count].user);
|
||||
free(store->entries[store->count].password_hex);
|
||||
store->entries[store->count].user = NULL;
|
||||
store->entries[store->count].password_hex = NULL;
|
||||
CredentialEntry* entry = &store->entries[store->count];
|
||||
memset(entry, 0, sizeof(*entry));
|
||||
entry->user = str_dup(user);
|
||||
if (!entry->user)
|
||||
return false;
|
||||
}
|
||||
memcpy(entry->salt, salt, CREDENTIAL_SALT_LEN);
|
||||
entry->iters = iters;
|
||||
memcpy(entry->stored_key, stored_key, CREDENTIAL_KEY_LEN);
|
||||
memcpy(entry->server_key, server_key, CREDENTIAL_KEY_LEN);
|
||||
store->count++;
|
||||
return true;
|
||||
}
|
||||
@@ -155,9 +393,75 @@ static int find_user(const CredentialStore* store, const char* user) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* Parse one credential store file (user:SHA256HEX per line) into a fresh
|
||||
* store. Duplicate usernames WITHIN one file are an error (ambiguous). A
|
||||
* NULL path yields an empty store. */
|
||||
/* Parse the new `$fastsync$1$pbkdf2-sha256$...` verifier field in place. */
|
||||
static bool parse_verifier_secret(char* secret, CredentialEntry* entry, const char* path,
|
||||
int line_no, const char* user, char* err, size_t err_size) {
|
||||
if (secret_is_legacy_hex(secret)) {
|
||||
set_error(err, err_size,
|
||||
"credential file '%s' line %d: legacy unsalted SHA-256 secret for user '%s' is not "
|
||||
"accepted (protocol 2.19.0 uses a salted PBKDF2 verifier); regenerate the store "
|
||||
"with --hash-credentials",
|
||||
path, line_no, user);
|
||||
return false;
|
||||
}
|
||||
const char* prefix = CREDENTIAL_STORE_PREFIX;
|
||||
size_t prefix_len = strlen(prefix);
|
||||
if (strncmp(secret, prefix, prefix_len) != 0) {
|
||||
set_error(err, err_size,
|
||||
"credential file '%s' line %d: expected a '%s...' verifier for user '%s' (regenerate "
|
||||
"a legacy line with --hash-credentials)",
|
||||
path, line_no, prefix, user);
|
||||
return false;
|
||||
}
|
||||
char* cursor = secret + prefix_len;
|
||||
const char* iters_str = cursor;
|
||||
char* sep = strchr(cursor, '$');
|
||||
if (!sep)
|
||||
goto malformed;
|
||||
*sep = '\0';
|
||||
const char* salt_str = sep + 1;
|
||||
sep = strchr(salt_str, '$');
|
||||
if (!sep)
|
||||
goto malformed;
|
||||
*sep = '\0';
|
||||
const char* stored_str = sep + 1;
|
||||
sep = strchr(stored_str, '$');
|
||||
if (!sep)
|
||||
goto malformed;
|
||||
*sep = '\0';
|
||||
const char* server_str = sep + 1;
|
||||
if (*iters_str == '\0' || *salt_str == '\0' || *stored_str == '\0' || *server_str == '\0')
|
||||
goto malformed;
|
||||
|
||||
char* end = NULL;
|
||||
unsigned long parsed = strtoul(iters_str, &end, 10);
|
||||
if (!end || *end != '\0' || parsed < CREDENTIAL_MIN_ITERS || parsed > CREDENTIAL_MAX_ITERS)
|
||||
goto malformed;
|
||||
entry->iters = (uint32_t)parsed;
|
||||
|
||||
size_t decoded = 0;
|
||||
if (!credentials_b64_decode(salt_str, entry->salt, CREDENTIAL_SALT_LEN, &decoded) ||
|
||||
decoded != CREDENTIAL_SALT_LEN)
|
||||
goto malformed;
|
||||
if (!credentials_b64_decode(stored_str, entry->stored_key, CREDENTIAL_KEY_LEN, &decoded) ||
|
||||
decoded != CREDENTIAL_KEY_LEN)
|
||||
goto malformed;
|
||||
if (!credentials_b64_decode(server_str, entry->server_key, CREDENTIAL_KEY_LEN, &decoded) ||
|
||||
decoded != CREDENTIAL_KEY_LEN)
|
||||
goto malformed;
|
||||
return true;
|
||||
|
||||
malformed:
|
||||
set_error(err, err_size,
|
||||
"credential file '%s' line %d: malformed verifier for user '%s' (expected "
|
||||
"'%s<iters>$<salt_b64>$<stored_key_b64>$<server_key_b64>')",
|
||||
path, line_no, user, prefix);
|
||||
return false;
|
||||
}
|
||||
|
||||
/* Parse one credential store file into a fresh store. Duplicate usernames
|
||||
* WITHIN one file are an error (ambiguous). A NULL path yields an empty
|
||||
* store. */
|
||||
static CredentialStore* load_store_file(const char* path, char* err, size_t err_size) {
|
||||
CredentialStore* store = calloc(1, sizeof(CredentialStore));
|
||||
if (!store) {
|
||||
@@ -200,14 +504,14 @@ static CredentialStore* load_store_file(const char* path, char* err, size_t err_
|
||||
char* colon = strchr(cursor, ':');
|
||||
if (!colon) {
|
||||
set_error(err, err_size,
|
||||
"credential file '%s' line %d: expected 'user:SHA256HEX' (no ':' found)", path,
|
||||
"credential file '%s' line %d: expected 'user:$fastsync$...' (no ':' found)", path,
|
||||
line_no);
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
*colon = '\0';
|
||||
const char* user = trim_space(cursor);
|
||||
const char* secret = trim_space(colon + 1);
|
||||
char* secret = trim_space(colon + 1);
|
||||
if (!username_wellformed(user)) {
|
||||
set_error(err, err_size,
|
||||
"credential file '%s' line %d: invalid username (must be 1-%d "
|
||||
@@ -216,11 +520,9 @@ static CredentialStore* load_store_file(const char* path, char* err, size_t err_
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
if (!credentials_hash_valid(secret)) {
|
||||
set_error(err, err_size,
|
||||
"credential file '%s' line %d: secret for user '%s' must be %d "
|
||||
"lowercase hex characters (the SHA-256 of the password)",
|
||||
path, line_no, user, CREDENTIAL_HASH_HEX_LEN);
|
||||
CredentialEntry parsed;
|
||||
memset(&parsed, 0, sizeof(parsed));
|
||||
if (!parse_verifier_secret(secret, &parsed, path, line_no, user, err, err_size)) {
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
@@ -230,7 +532,8 @@ static CredentialStore* load_store_file(const char* path, char* err, size_t err_
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
if (!append_entry(store, user, secret)) {
|
||||
if (!append_entry(store, user, parsed.salt, parsed.iters, parsed.stored_key,
|
||||
parsed.server_key)) {
|
||||
set_error(err, err_size, "out of memory reading credential file '%s'", path);
|
||||
ok = false;
|
||||
break;
|
||||
@@ -242,6 +545,7 @@ static CredentialStore* load_store_file(const char* path, char* err, size_t err_
|
||||
ok = false;
|
||||
}
|
||||
fclose(fp);
|
||||
credentials_burn(line, sizeof(line));
|
||||
if (!ok) {
|
||||
credentials_free(store);
|
||||
return NULL;
|
||||
@@ -264,14 +568,14 @@ CredentialStore* credentials_load(const char* password_file, const char* early_i
|
||||
credentials_free(store);
|
||||
return NULL;
|
||||
}
|
||||
/* Layer early input over the password file: same secret dedupes, a differing
|
||||
* secret for the same user is ambiguous and fails closed. */
|
||||
/* Layer early input over the password file: an identical verifier dedupes, a
|
||||
* differing verifier for the same user is ambiguous and fails closed. */
|
||||
for (int i = 0; i < early->count; i++) {
|
||||
int existing = find_user(store, early->entries[i].user);
|
||||
if (existing >= 0) {
|
||||
if (strcmp(store->entries[existing].password_hex, early->entries[i].password_hex) != 0) {
|
||||
if (!entries_equal(&store->entries[existing], &early->entries[i])) {
|
||||
set_error(err, err_size,
|
||||
"credential file '%s' and early-input file '%s' disagree on the secret for "
|
||||
"credential file '%s' and early-input file '%s' disagree on the verifier for "
|
||||
"user '%s'",
|
||||
password_file, early_input_file, early->entries[i].user);
|
||||
credentials_free(early);
|
||||
@@ -280,7 +584,9 @@ CredentialStore* credentials_load(const char* password_file, const char* early_i
|
||||
}
|
||||
continue; /* identical; nothing to merge */
|
||||
}
|
||||
if (!append_entry(store, early->entries[i].user, early->entries[i].password_hex)) {
|
||||
if (!append_entry(store, early->entries[i].user, early->entries[i].salt,
|
||||
early->entries[i].iters, early->entries[i].stored_key,
|
||||
early->entries[i].server_key)) {
|
||||
set_error(err, err_size, "out of memory merging early-input credentials");
|
||||
credentials_free(early);
|
||||
credentials_free(store);
|
||||
@@ -295,8 +601,11 @@ void credentials_free(CredentialStore* store) {
|
||||
if (!store)
|
||||
return;
|
||||
for (int i = 0; i < store->count; i++) {
|
||||
/* Wipe the derived keys before releasing the entry (A7-4). */
|
||||
credentials_burn((char*)store->entries[i].salt, CREDENTIAL_SALT_LEN);
|
||||
credentials_burn((char*)store->entries[i].stored_key, CREDENTIAL_KEY_LEN);
|
||||
credentials_burn((char*)store->entries[i].server_key, CREDENTIAL_KEY_LEN);
|
||||
free(store->entries[i].user);
|
||||
free(store->entries[i].password_hex);
|
||||
}
|
||||
free(store->entries);
|
||||
free(store);
|
||||
@@ -317,24 +626,197 @@ bool credentials_secure_equal(const char* a, const char* b, size_t len) {
|
||||
return diff == 0;
|
||||
}
|
||||
|
||||
bool credentials_hash_password(const char* password, char* out_hex) {
|
||||
if (!password || !out_hex)
|
||||
/* Constant-time equality over two usernames. Compares a fixed
|
||||
* CREDENTIAL_MAX_USER_LEN-byte window (padding with zeros past each string's
|
||||
* own length) and folds the length difference into the accumulator, so no byte
|
||||
* returns early. This closes the byte-wise username-enumeration timing oracle
|
||||
* that a plain strcmp (which short-circuits on the first differing byte)
|
||||
* would otherwise expose. Over-long inputs are refused (length differs), which
|
||||
* is a non-secret branch: usernames are bounded in every caller anyway. */
|
||||
static bool username_secure_equal(const char* a, const char* b) {
|
||||
size_t alen = strlen(a);
|
||||
size_t blen = strlen(b);
|
||||
if (alen > CREDENTIAL_MAX_USER_LEN || blen > CREDENTIAL_MAX_USER_LEN)
|
||||
return false;
|
||||
uint8_t digest[EVP_MAX_MD_SIZE];
|
||||
unsigned int digest_len = 0;
|
||||
if (EVP_Digest(password, strlen(password), digest, &digest_len, EVP_sha256(), NULL) != 1)
|
||||
size_t diff = alen ^ blen;
|
||||
for (size_t i = 0; i < CREDENTIAL_MAX_USER_LEN; i++) {
|
||||
unsigned char ac = i < alen ? (unsigned char)a[i] : 0u;
|
||||
unsigned char bc = i < blen ? (unsigned char)b[i] : 0u;
|
||||
diff |= (size_t)(ac ^ bc);
|
||||
}
|
||||
return diff == 0;
|
||||
}
|
||||
|
||||
bool credentials_get_verifier(const CredentialStore* store, const char* user,
|
||||
const char* const* module_users, int n, CredentialVerifier* out) {
|
||||
if (!out)
|
||||
return false;
|
||||
if (digest_len != 32)
|
||||
memset(out, 0, sizeof(*out));
|
||||
/* Start from the dummy verifier: a fresh random salt and the default
|
||||
* iteration count, so a miss is shaped exactly like a hit. */
|
||||
if (!credentials_random_bytes(out->salt, CREDENTIAL_SALT_LEN))
|
||||
return false;
|
||||
static const char hex[] = "0123456789abcdef";
|
||||
for (unsigned int i = 0; i < digest_len; i++) {
|
||||
out_hex[2 * i] = hex[digest[i] >> 4];
|
||||
out_hex[2 * i + 1] = hex[digest[i] & 0x0f];
|
||||
out->iters = CREDENTIAL_DEFAULT_ITERS;
|
||||
memcpy(out->stored_key, k_dummy_stored_key, CREDENTIAL_KEY_LEN);
|
||||
memcpy(out->server_key, k_dummy_server_key, CREDENTIAL_KEY_LEN);
|
||||
out->found = false;
|
||||
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;
|
||||
}
|
||||
if (!on_list)
|
||||
return true;
|
||||
/* Store lookup is also a constant-time full scan. */
|
||||
const CredentialEntry* match = NULL;
|
||||
for (int i = 0; i < store->count; i++) {
|
||||
if (username_secure_equal(store->entries[i].user, user))
|
||||
match = &store->entries[i];
|
||||
}
|
||||
if (match) {
|
||||
memcpy(out->salt, match->salt, CREDENTIAL_SALT_LEN);
|
||||
out->iters = match->iters;
|
||||
memcpy(out->stored_key, match->stored_key, CREDENTIAL_KEY_LEN);
|
||||
memcpy(out->server_key, match->server_key, CREDENTIAL_KEY_LEN);
|
||||
out->found = true;
|
||||
}
|
||||
out_hex[2 * digest_len] = '\0';
|
||||
return true;
|
||||
}
|
||||
|
||||
bool credentials_hash_store_line(const char* user, const char* password, uint32_t iters, char* out,
|
||||
size_t out_sz, char* err, size_t err_size) {
|
||||
if (err && err_size)
|
||||
err[0] = '\0';
|
||||
if (!username_wellformed(user)) {
|
||||
set_error(err, err_size, "invalid username (1-%d non-whitespace characters)",
|
||||
CREDENTIAL_MAX_USER_LEN);
|
||||
return false;
|
||||
}
|
||||
if (!password || !out || out_sz == 0) {
|
||||
set_error(err, err_size, "missing password or output buffer");
|
||||
return false;
|
||||
}
|
||||
if (strlen(password) > CREDENTIAL_MAX_PASSWORD_LEN) {
|
||||
set_error(err, err_size, "password exceeds %d characters", CREDENTIAL_MAX_PASSWORD_LEN);
|
||||
return false;
|
||||
}
|
||||
if (iters < CREDENTIAL_MIN_ITERS || iters > CREDENTIAL_MAX_ITERS) {
|
||||
set_error(err, err_size, "iterations %u out of range [%u,%u]", iters, CREDENTIAL_MIN_ITERS,
|
||||
CREDENTIAL_MAX_ITERS);
|
||||
return false;
|
||||
}
|
||||
uint8_t salt[CREDENTIAL_SALT_LEN];
|
||||
uint8_t client_key[CREDENTIAL_KEY_LEN];
|
||||
uint8_t stored_key[CREDENTIAL_KEY_LEN];
|
||||
uint8_t server_key[CREDENTIAL_KEY_LEN];
|
||||
char salt_b64[25];
|
||||
char stored_b64[45];
|
||||
char server_b64[45];
|
||||
bool ok =
|
||||
credentials_random_bytes(salt, sizeof(salt)) &&
|
||||
credentials_compute_keys(password, salt, iters, client_key, stored_key, server_key) &&
|
||||
credentials_b64_encode(salt, sizeof(salt), salt_b64, sizeof(salt_b64)) &&
|
||||
credentials_b64_encode(stored_key, sizeof(stored_key), stored_b64, sizeof(stored_b64)) &&
|
||||
credentials_b64_encode(server_key, sizeof(server_key), server_b64, sizeof(server_b64));
|
||||
int written = -1;
|
||||
if (ok) {
|
||||
written = snprintf(out, out_sz, "%s:%s%u$%s$%s$%s", user, CREDENTIAL_STORE_PREFIX, iters,
|
||||
salt_b64, stored_b64, server_b64);
|
||||
}
|
||||
credentials_burn((char*)client_key, sizeof(client_key));
|
||||
credentials_burn((char*)stored_key, sizeof(stored_key));
|
||||
credentials_burn((char*)server_key, sizeof(server_key));
|
||||
credentials_burn((char*)salt, sizeof(salt));
|
||||
if (!ok)
|
||||
return false;
|
||||
if (written < 0 || (size_t)written >= out_sz) {
|
||||
set_error(err, err_size, "output buffer too small for the credential line");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
int credentials_hash_file(const char* path, uint32_t iters, FILE* out, char* err, size_t err_size) {
|
||||
if (err && err_size)
|
||||
err[0] = '\0';
|
||||
if (!path || !out) {
|
||||
set_error(err, err_size, "missing plaintext file or output stream");
|
||||
return -1;
|
||||
}
|
||||
if (iters < CREDENTIAL_MIN_ITERS || iters > CREDENTIAL_MAX_ITERS) {
|
||||
set_error(err, err_size, "iterations %u out of range [%u,%u]", iters, CREDENTIAL_MIN_ITERS,
|
||||
CREDENTIAL_MAX_ITERS);
|
||||
return -1;
|
||||
}
|
||||
FILE* fp = secret_file_open(path, err, err_size);
|
||||
if (!fp)
|
||||
return -1;
|
||||
int line_no = 0;
|
||||
int result = 0;
|
||||
char line[CREDENTIAL_MAX_LINE + 2];
|
||||
while (fgets(line, sizeof(line), fp)) {
|
||||
line_no++;
|
||||
size_t len = strlen(line);
|
||||
if (len == CREDENTIAL_MAX_LINE + 1 && line[len - 1] != '\n' && !feof(fp)) {
|
||||
set_error(err, err_size, "plaintext file '%s' line %d exceeds the %d-byte limit", path,
|
||||
line_no, CREDENTIAL_MAX_LINE);
|
||||
result = -1;
|
||||
break;
|
||||
}
|
||||
while (len > 0 && (line[len - 1] == '\n' || line[len - 1] == '\r'))
|
||||
line[--len] = '\0';
|
||||
char* cursor = line;
|
||||
while (*cursor == ' ' || *cursor == '\t')
|
||||
cursor++;
|
||||
if (*cursor == '\0' || is_comment_char(*cursor))
|
||||
continue;
|
||||
char* colon = strchr(cursor, ':');
|
||||
if (!colon) {
|
||||
set_error(err, err_size, "plaintext file '%s' line %d: expected 'user:password'", path,
|
||||
line_no);
|
||||
result = -1;
|
||||
break;
|
||||
}
|
||||
*colon = '\0';
|
||||
const char* user = trim_space(cursor);
|
||||
const char* password = colon + 1;
|
||||
if (!username_wellformed(user)) {
|
||||
set_error(err, err_size, "plaintext file '%s' line %d: invalid username", path, line_no);
|
||||
result = -1;
|
||||
break;
|
||||
}
|
||||
if (*password == '\0') {
|
||||
set_error(err, err_size, "plaintext file '%s' line %d: empty password", path, line_no);
|
||||
result = -1;
|
||||
break;
|
||||
}
|
||||
char store_line[CREDENTIAL_MAX_LINE];
|
||||
if (!credentials_hash_store_line(user, password, iters, store_line, sizeof(store_line), err,
|
||||
err_size)) {
|
||||
result = -1;
|
||||
break;
|
||||
}
|
||||
if (fprintf(out, "%s\n", store_line) < 0) {
|
||||
set_error(err, err_size, "cannot write hashed credentials: %s", strerror(errno));
|
||||
credentials_burn(store_line, sizeof(store_line));
|
||||
result = -1;
|
||||
break;
|
||||
}
|
||||
credentials_burn(store_line, sizeof(store_line));
|
||||
}
|
||||
if (result == 0 && ferror(fp)) {
|
||||
set_error(err, err_size, "error reading plaintext file '%s': %s", path, strerror(errno));
|
||||
result = -1;
|
||||
}
|
||||
credentials_burn(line, sizeof(line));
|
||||
fclose(fp);
|
||||
return result;
|
||||
}
|
||||
|
||||
int credentials_read_secret_file(const char* path, char** user_out, char** password_out, char* err,
|
||||
size_t err_size) {
|
||||
if (user_out)
|
||||
@@ -447,66 +929,3 @@ void credentials_burn(char* secret, size_t len) {
|
||||
for (size_t i = 0; i < len; i++)
|
||||
p[i] = '\0';
|
||||
}
|
||||
|
||||
/* Constant-time equality over two usernames. Compares a fixed
|
||||
* CREDENTIAL_MAX_USER_LEN-byte window (padding with zeros past each string's
|
||||
* own length) and folds the length difference into the accumulator, so no byte
|
||||
* returns early. This closes the byte-wise username-enumeration timing oracle
|
||||
* that a plain strcmp (which short-circuits on the first differing byte)
|
||||
* would otherwise expose. Over-long inputs are refused (length differs), which
|
||||
* is a non-secret branch: usernames are bounded in every caller anyway. */
|
||||
static bool username_secure_equal(const char* a, const char* b) {
|
||||
size_t alen = strlen(a);
|
||||
size_t blen = strlen(b);
|
||||
if (alen > CREDENTIAL_MAX_USER_LEN || blen > CREDENTIAL_MAX_USER_LEN)
|
||||
return false;
|
||||
size_t diff = alen ^ blen;
|
||||
for (size_t i = 0; i < CREDENTIAL_MAX_USER_LEN; i++) {
|
||||
unsigned char ac = i < alen ? (unsigned char)a[i] : 0u;
|
||||
unsigned char bc = i < blen ? (unsigned char)b[i] : 0u;
|
||||
diff |= (size_t)(ac ^ bc);
|
||||
}
|
||||
return diff == 0;
|
||||
}
|
||||
|
||||
/* Fixed 64-lowercase-hex dummy used for a constant-time digest comparison when
|
||||
* the presented user is unknown, so the verify path takes the same time for an
|
||||
* unknown user and a wrong password. Value chosen arbitrarily; it can never
|
||||
* authenticate because a real store entry is preferred when it exists. */
|
||||
static const char k_dummy_hash[CREDENTIAL_HASH_HEX_LEN + 1] =
|
||||
"0000000000000000000000000000000000000000000000000000000000000000";
|
||||
|
||||
bool credentials_verify(const CredentialStore* store, const char* user,
|
||||
const char* presented_hash_hex) {
|
||||
if (!store || !user || !presented_hash_hex || !credentials_hash_valid(presented_hash_hex))
|
||||
return false;
|
||||
const char* stored = k_dummy_hash;
|
||||
for (int i = 0; i < store->count; i++) {
|
||||
/* Constant-time username match: no early return, so time depends on the
|
||||
* fixed compare window and a byte-wise prefix match cannot be observed. */
|
||||
if (username_secure_equal(store->entries[i].user, user))
|
||||
stored = store->entries[i].password_hex;
|
||||
}
|
||||
return credentials_secure_equal(presented_hash_hex, stored, CREDENTIAL_HASH_HEX_LEN);
|
||||
}
|
||||
|
||||
bool credentials_gate_allows(const CredentialStore* store, const char* const* module_users,
|
||||
int module_user_count, const char* presented_user,
|
||||
const char* presented_hash_hex) {
|
||||
if (!store || module_user_count < 0)
|
||||
return false; /* fail closed: an auth-required module without a store refuses */
|
||||
if (!presented_user || !presented_hash_hex)
|
||||
return false; /* no credentials presented */
|
||||
bool on_module_list = false;
|
||||
for (int i = 0; i < module_user_count; i++) {
|
||||
/* Constant-time match against the module's auth-users list, for the same
|
||||
* reason as credentials_verify, so the list is not an enumeration oracle. */
|
||||
if (module_users[i] && username_secure_equal(module_users[i], presented_user)) {
|
||||
on_module_list = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!on_module_list)
|
||||
return false;
|
||||
return credentials_verify(store, presented_user, presented_hash_hex);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user