Files
FastSync/src/shared/credentials.c
T
TapTap 80c1ff321c fix(credentials): length-check before legacy-hex scan (C9)
secret_is_legacy_hex indexed s[0..63] without first checking the string
length, reading out of bounds for a shorter secret.  Require
strlen(s) == 64 before scanning, and add a unit test that short and
63-hex-digit secrets are rejected as ordinary malformed verifiers (never
misreported as legacy).
2026-09-14 16:09:25 +02:00

1323 lines
49 KiB
C

#include "credentials.h"
#include "log.h"
#include "utils.h"
#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>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>
/* 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;
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 {
CredentialEntry* entries;
int count;
int capacity;
/* Store-wide uniform PBKDF2 iteration count. Every entry must agree on it
* (the parser refuses a store whose entries disagree), so a miss can be
* challenged with the same count as a hit and the count itself never leaks
* membership. Unused (0) for an empty store. */
uint32_t iters;
/* Store-wide secret loaded from (or created in) the exact-mode-0600
* `<store_path>.dummykey` sidecar, so it also survives a daemon restart. The
* dummy salt handed out for an unknown/off-list user is
* HMAC-SHA256(dummy_key, username)[:SALT_LEN], so repeated probes of the same
* username always see an identical challenge while different usernames differ
* -- with no fresh-random tell, and cross-restart stability hides the
* restart-gated enumeration oracle. */
uint8_t dummy_key[CREDENTIAL_KEY_LEN];
};
/* Exact marker prefix of the new store verifier field. */
#define CREDENTIAL_STORE_PREFIX "$fastsync$1$pbkdf2-sha256$"
#define CREDENTIAL_AUTH_PREFIX "FastSync-Auth-v1"
/* Exact-mode-0600 sidecar holding the persistent store-wide dummy key, placed
* next to the credential store (`<store_path>.dummykey`). */
#define CREDENTIAL_DUMMY_KEY_SUFFIX ".dummykey"
/* 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;
va_list args;
va_start(args, fmt);
vsnprintf(err, err_size, fmt, args);
va_end(args);
}
static bool is_comment_char(char c) {
return c == '#' || c == ';';
}
/* Open a --password-file / --early-input after verifying the EXACT inode we
* will read: it must be owned by the effective user and grant no group/other
* permission bit (so 0600 and stricter modes such as 0400 are accepted),
* mirroring the TLS private-key check. This only rejects group/other bits,
* deliberately unlike the dummy-key sidecar which requires EXACT mode 0600. We
* open by
* path and then fstat the resulting fd (rather than stat()ing the path first
* and reopening it), so the permission decision is made on the same inode that
* is read and cannot be raced by swapping the path between check and open.
* The path may be a process-substitution pipe (`<(...)` -> /dev/fd/N), so
* regular files and FIFOs are accepted when the ownership/mode checks pass.
*
* Returns a FILE* the caller must fclose, or NULL with `err` filled. */
static FILE* secret_file_open(const char* path, char* err, size_t err_size) {
int fd = open(path, O_RDONLY | O_CLOEXEC);
if (fd < 0) {
set_error(err, err_size, "cannot open secret file '%s': %s", path, strerror(errno));
return NULL;
}
struct stat st;
if (fstat(fd, &st) != 0) {
set_error(err, err_size, "cannot stat secret file '%s': %s", path, strerror(errno));
close(fd);
return NULL;
}
bool is_readable_kind = S_ISREG(st.st_mode) || S_ISFIFO(st.st_mode);
if (!is_readable_kind || st.st_uid != geteuid() || (st.st_mode & (S_IRWXG | S_IRWXO)) != 0) {
set_error(err, err_size,
"refusing to read secret file '%s': it must be owned by the current user and "
"owner-only (0600), not accessible to group/other",
path);
close(fd);
return NULL;
}
FILE* fp = fdopen(fd, "r");
if (!fp) {
set_error(err, err_size, "cannot read secret file '%s': %s", path, strerror(errno));
close(fd);
return NULL;
}
return fp;
}
/* Trim leading/trailing ASCII space and tab in place; returns the new start. */
static char* trim_space(char* s) {
while (*s == ' ' || *s == '\t')
s++;
size_t len = strlen(s);
while (len > 0 && (s[len - 1] == ' ' || s[len - 1] == '\t'))
s[--len] = '\0';
return s;
}
/* A username is a single token: non-empty, bounded, and free of whitespace and
* control characters. The same rule is applied to store users, client-file
* users and the module `auth users` gate so an exact strcmp can never be
* confused by invisible characters. */
static bool username_wellformed(const char* user) {
if (!user || *user == '\0')
return false;
size_t len = strlen(user);
if (len > CREDENTIAL_MAX_USER_LEN)
return false;
for (size_t i = 0; i < len; i++) {
unsigned char c = (unsigned char)user[i];
if (c <= 0x20 || c == 0x7f)
return false;
}
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';
if (c >= 'a' && c <= 'f')
return c - 'a' + 10;
return -1;
}
/* 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 || strlen(s) != 64)
return false;
for (int i = 0; i < 64; i++) {
if (hex_value(s[i]) < 0)
return false;
}
return true;
}
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. The single `done`
* path burns the scratch on failure as well as success, so no partial secret
* survives an early return. */
uint8_t scratch[192] = {0};
bool ok = false;
int n = EVP_DecodeBlock(scratch, (const unsigned char*)in, (int)len);
if (n < 0 || (size_t)n != padded_len)
goto done;
memcpy(out, scratch, decoded_len);
*out_len = decoded_len;
ok = true;
done:
credentials_burn((char*)scratch, sizeof(scratch));
return ok;
}
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;
/* Enforce the full [MIN,MAX] policy here so no caller can derive a verifier
* with a work factor outside the validated store range. */
if (iters < CREDENTIAL_MIN_ITERS || 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);
/* Always run the constant-time key compare (even when `found` is false) and
* fold the accept decision with bitwise AND so no short-circuit reveals
* whether the user was found. A tampered nonce changes the AuthMessage and
* so the recovered key. */
bool key_match = false;
if (computed)
key_match = credentials_secure_equal((const char*)recovered, (const char*)v->stored_key,
CREDENTIAL_KEY_LEN);
bool accept = computed & v->found & key_match;
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 =
realloc(store->entries, (size_t)new_capacity * sizeof(CredentialEntry));
if (!grown)
return false;
store->entries = grown;
store->capacity = new_capacity;
}
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;
}
static int find_user(const CredentialStore* store, const char* user) {
for (int i = 0; i < store->count; i++) {
if (strcmp(store->entries[i].user, user) == 0)
return i;
}
return -1;
}
/* 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) {
set_error(err, err_size, "out of memory allocating credential store");
return NULL;
}
if (!path)
return store;
FILE* fp = secret_file_open(path, err, err_size);
if (!fp) {
credentials_free(store);
return NULL;
}
int line_no = 0;
char line[CREDENTIAL_MAX_LINE + 2];
bool ok = true;
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, "credential file '%s' line %d exceeds the %d-byte limit", path,
line_no, CREDENTIAL_MAX_LINE);
ok = false;
break;
}
if (len > 0 && line[len - 1] == '\n')
line[--len] = '\0';
if (len > 0 && line[len - 1] == '\r')
line[--len] = '\0';
char* cursor = line;
while (*cursor == ' ' || *cursor == '\t')
cursor++;
if (*cursor == '\0' || is_comment_char(*cursor))
continue; /* blank or comment */
char* colon = strchr(cursor, ':');
if (!colon) {
set_error(err, err_size,
"credential file '%s' line %d: expected 'user:$fastsync$...' (no ':' found)", path,
line_no);
ok = false;
break;
}
*colon = '\0';
const char* user = trim_space(cursor);
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 "
"non-whitespace characters)",
path, line_no, CREDENTIAL_MAX_USER_LEN);
ok = false;
break;
}
CredentialEntry parsed;
memset(&parsed, 0, sizeof(parsed));
if (!parse_verifier_secret(secret, &parsed, path, line_no, user, err, err_size)) {
ok = false;
break;
}
/* Every entry must agree on the iteration count, so a miss can be answered
* with the store-wide count without leaking membership. */
if (store->count == 0) {
store->iters = parsed.iters;
} else if (store->iters != parsed.iters) {
set_error(err, err_size,
"credential file '%s' line %d: iteration count %u disagrees with the store-wide %u "
"(the store must be uniform)",
path, line_no, parsed.iters, store->iters);
ok = false;
break;
}
if (find_user(store, user) >= 0) {
set_error(err, err_size, "credential file '%s' line %d: duplicate entry for user '%.*s'",
path, line_no, (int)strlen(user), user);
ok = false;
break;
}
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;
}
}
if (ok && ferror(fp)) {
set_error(err, err_size, "error reading credential file '%s': %s", path, strerror(errno));
ok = false;
}
fclose(fp);
credentials_burn(line, sizeof(line));
if (!ok) {
credentials_free(store);
return NULL;
}
return store;
}
/* Validate and read an already-open `<store>.dummykey` sidecar. Fails closed on
* anything that is not an exact-mode-0600 regular file of exactly
* CREDENTIAL_KEY_LEN bytes, so a loosened, swapped or truncated file can never
* silently change the dummy challenge. */
static bool read_dummy_key_fd(int fd, const char* path, uint8_t out[CREDENTIAL_KEY_LEN], char* err,
size_t err_size) {
struct stat st;
if (fstat(fd, &st) != 0) {
set_error(err, err_size, "cannot stat dummy key file '%s': %s", path, strerror(errno));
return false;
}
if (!S_ISREG(st.st_mode) || st.st_uid != geteuid() || (st.st_mode & 07777) != 0600 ||
st.st_size != (off_t)CREDENTIAL_KEY_LEN) {
set_error(err, err_size,
"refusing to read dummy key file '%s': it must be an owned regular file with exact "
"mode 0600 and exactly %d bytes",
path, CREDENTIAL_KEY_LEN);
return false;
}
size_t got = 0;
while (got < CREDENTIAL_KEY_LEN) {
ssize_t n = read(fd, out + got, CREDENTIAL_KEY_LEN - got);
if (n < 0) {
if (errno == EINTR)
continue;
set_error(err, err_size, "cannot read dummy key file '%s': %s", path, strerror(errno));
return false;
}
if (n == 0)
break;
got += (size_t)n;
}
if (got != CREDENTIAL_KEY_LEN) {
set_error(err, err_size, "dummy key file '%s' is truncated", path);
return false;
}
return true;
}
/* fsync the directory containing `path` (best effort). After publishing the
* sidecar with link(2), syncing the directory makes the new name durable so a
* crash cannot leave a restart without the key it just started using. */
static void fsync_containing_dir(const char* path) {
char* dir = str_dup(path);
if (!dir)
return;
char* slash = strrchr(dir, '/');
if (!slash) {
free(dir);
dir = str_dup(".");
if (!dir)
return;
} else if (slash == dir) {
slash[1] = '\0'; /* keep the leading '/' */
} else {
*slash = '\0';
}
int dfd = open(dir, O_RDONLY | O_DIRECTORY | O_CLOEXEC);
free(dir);
if (dfd < 0)
return;
fsync(dfd);
close(dfd);
}
/* Load the persistent dummy key for `store_path` from its `<store_path>.dummykey`
* sidecar, creating it (exact mode 0600, 32 random bytes) if absent. A NULL
* store_path (empty store) yields a fresh ephemeral key. Reading an existing
* sidecar fails CLOSED on any validation error; only the CREATE path degrades
* to an ephemeral key (with a warning) when the filesystem cannot hold the
* sidecar (e.g. read-only mount), so a daemon still starts.
*
* Creation is ATOMIC: the key is written to a private same-directory temp file
* and hard-linked into place, so a concurrent starter (or reader) never observes
* a partial/zero sidecar that would fail the load closed. Returns false only
* when the CSPRNG itself fails (or a present-but-invalid sidecar is found). */
static bool load_or_create_dummy_key(const char* store_path, uint8_t out[CREDENTIAL_KEY_LEN],
char* err, size_t err_size) {
if (!store_path) {
if (!credentials_random_bytes(out, CREDENTIAL_KEY_LEN)) {
set_error(err, err_size, "failed to generate the credential store dummy key");
return false;
}
return true;
}
size_t path_len = strlen(store_path);
size_t suffix_len = sizeof(CREDENTIAL_DUMMY_KEY_SUFFIX); /* includes the NUL */
if (path_len > SIZE_MAX - suffix_len) {
set_error(err, err_size, "credential store path is too long to build a dummy key path");
return false;
}
char* sidecar = malloc(path_len + suffix_len);
if (!sidecar) {
set_error(err, err_size, "out of memory building the dummy key path");
return false;
}
int n = snprintf(sidecar, path_len + suffix_len, "%s%s", store_path, CREDENTIAL_DUMMY_KEY_SUFFIX);
if (n < 0 || (size_t)n >= path_len + suffix_len) {
set_error(err, err_size, "credential store path is too long to build a dummy key path");
free(sidecar);
return false;
}
/* Readers reject a planted symlink (O_NOFOLLOW) and never block on a planted
* FIFO (O_NONBLOCK; fstat rejects the non-regular file before any data read).
* Any open error other than ENOENT fails closed. */
int fd = open(sidecar, O_RDONLY | O_NOFOLLOW | O_NONBLOCK | O_CLOEXEC);
if (fd >= 0) {
bool ok = read_dummy_key_fd(fd, sidecar, out, err, err_size);
close(fd);
free(sidecar);
return ok;
}
if (errno != ENOENT) {
/* The sidecar exists but cannot be opened for reading (EACCES, or ELOOP
* from a symlink): fail closed rather than substituting a different key. */
set_error(err, err_size, "cannot open dummy key file '%s': %s", sidecar, strerror(errno));
free(sidecar);
return false;
}
/* Publish atomically: write a private same-directory temp file, fsync it,
* then hard-link it into place. A concurrent reader therefore only ever
* sees a complete 32-byte sidecar (or none), never a partial/zero file.
*
* The temp name carries both the pid and a fresh random suffix, so it is not
* predictable. If the name nevertheless already exists (a SIGKILL/crash
* leftover, pid reuse, or a planted file) the stale temp is removed and the
* O_EXCL create is retried once, so it can never silently defeat persistence
* for this pid. */
uint8_t fresh[CREDENTIAL_KEY_LEN];
if (!credentials_random_bytes(fresh, CREDENTIAL_KEY_LEN)) {
set_error(err, err_size, "failed to generate the credential store dummy key");
free(sidecar);
return false;
}
uint8_t name_rand[8];
if (!credentials_random_bytes(name_rand, sizeof(name_rand))) {
set_error(err, err_size, "failed to generate the dummy key temp name");
free(sidecar);
credentials_burn((char*)fresh, sizeof(fresh));
return false;
}
char name_hex[sizeof(name_rand) * 2 + 1];
static const char hex_digits[] = "0123456789abcdef";
for (size_t i = 0; i < sizeof(name_rand); i++) {
name_hex[2 * i] = hex_digits[name_rand[i] >> 4];
name_hex[2 * i + 1] = hex_digits[name_rand[i] & 0x0f];
}
name_hex[sizeof(name_hex) - 1] = '\0';
char tmp_suffix[64];
int pn = snprintf(tmp_suffix, sizeof(tmp_suffix), ".tmp.%ld.%s", (long)getpid(), name_hex);
if (pn < 0 || (size_t)pn >= sizeof(tmp_suffix)) {
set_error(err, err_size, "failed to build the dummy key temp path");
free(sidecar);
credentials_burn((char*)fresh, sizeof(fresh));
return false;
}
size_t sidecar_len = (size_t)n;
size_t tmp_len = sidecar_len + (size_t)pn;
char* tmp = malloc(tmp_len + 1);
if (!tmp) {
set_error(err, err_size, "out of memory building the dummy key temp path");
free(sidecar);
credentials_burn((char*)fresh, sizeof(fresh));
return false;
}
snprintf(tmp, tmp_len + 1, "%s%s", sidecar, tmp_suffix);
/* Bounded create: at most one unlink+retry on EEXIST. The retry keeps
* O_EXCL, so only a stale name is reclaimed and a live peer's temp is never
* truncated. */
int create_errno = 0;
for (int attempt = 0; attempt < 2; attempt++) {
fd = open(tmp, O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC, 0600);
if (fd >= 0)
break;
create_errno = errno;
if (create_errno != EEXIST || attempt == 1)
break;
unlink(tmp);
}
/* umask can clear owner bits from the 0600 create mode while the reader
* requires an exact 0600, so force the mode on the fd before publishing; a
* failure here is treated like any other create failure (warning + ephemeral
* key) so the published sidecar is always exactly 0600. */
if (fd >= 0 && fchmod(fd, 0600) != 0) {
create_errno = errno;
close(fd);
unlink(tmp);
fd = -1;
}
if (fd < 0) {
/* Creation failed (read-only filesystem, missing directory, fchmod, ...).
* Warn and fall back to an ephemeral key: unknown-user challenges stay
* deterministic within this daemon lifetime but will change on restart. */
char* escaped = output_escape(tmp, log_get_8_bit_output());
log_message(LOG_LEVEL_WARNING,
"cannot create dummy key file %s: %s; using a transient dummy key so unknown-user "
"challenges will change across restarts",
escaped ? escaped : tmp, strerror(create_errno));
free(escaped);
memcpy(out, fresh, CREDENTIAL_KEY_LEN);
free(tmp);
free(sidecar);
credentials_burn((char*)fresh, sizeof(fresh));
return true;
}
size_t written = 0;
bool write_ok = true;
int write_errno = 0;
while (written < CREDENTIAL_KEY_LEN) {
ssize_t w = write(fd, fresh + written, CREDENTIAL_KEY_LEN - written);
if (w < 0) {
if (errno == EINTR)
continue;
write_ok = false;
write_errno = errno;
break;
}
if (w == 0) {
/* A zero-length write is not a system error; errno is stale here, so
* report a clear short-write instead of a bogus strerror(errno). */
write_ok = false;
write_errno = 0;
break;
}
written += (size_t)w;
}
if (write_ok && fsync(fd) != 0) {
write_ok = false;
write_errno = errno;
}
close(fd);
if (!write_ok) {
/* Do not leave a truncated temp file behind; fall back to an ephemeral key
* instead of failing closed on the next restart. */
unlink(tmp);
char* escaped = output_escape(tmp, log_get_8_bit_output());
const char* why = write_errno != 0 ? strerror(write_errno) : "short write";
log_message(LOG_LEVEL_WARNING,
"cannot write dummy key file %s: %s; using a transient dummy key so unknown-user "
"challenges will change across restarts",
escaped ? escaped : tmp, why);
free(escaped);
memcpy(out, fresh, CREDENTIAL_KEY_LEN);
free(tmp);
free(sidecar);
credentials_burn((char*)fresh, sizeof(fresh));
return true;
}
if (link(tmp, sidecar) != 0) {
int link_errno = errno;
if (link_errno == EEXIST) {
/* A concurrent starter published first; adopt its key. Read it back
* through the same hardened path (no symlink, no block, exact mode). */
int rfd = open(sidecar, O_RDONLY | O_NOFOLLOW | O_NONBLOCK | O_CLOEXEC);
if (rfd < 0) {
set_error(err, err_size, "cannot open dummy key file '%s': %s", sidecar, strerror(errno));
unlink(tmp);
free(tmp);
free(sidecar);
credentials_burn((char*)fresh, sizeof(fresh));
return false;
}
bool ok = read_dummy_key_fd(rfd, sidecar, out, err, err_size);
close(rfd);
unlink(tmp);
free(tmp);
free(sidecar);
credentials_burn((char*)fresh, sizeof(fresh));
return ok;
}
/* Linking failed for another reason (e.g. no hard-link support on this
* filesystem). Warn and fall back to an ephemeral key. */
unlink(tmp);
char* escaped = output_escape(sidecar, log_get_8_bit_output());
log_message(LOG_LEVEL_WARNING,
"cannot publish dummy key file %s: %s; using a transient dummy key so unknown-user "
"challenges will change across restarts",
escaped ? escaped : sidecar, strerror(link_errno));
free(escaped);
memcpy(out, fresh, CREDENTIAL_KEY_LEN);
free(tmp);
free(sidecar);
credentials_burn((char*)fresh, sizeof(fresh));
return true;
}
/* Published: make the new directory entry durable, then drop the private
* temp name (the sidecar keeps the inode alive). */
fsync_containing_dir(sidecar);
unlink(tmp);
memcpy(out, fresh, CREDENTIAL_KEY_LEN);
free(tmp);
free(sidecar);
credentials_burn((char*)fresh, sizeof(fresh));
return true;
}
CredentialStore* credentials_load(const char* password_file, const char* early_input_file,
char* err, size_t err_size) {
if (err && err_size)
err[0] = '\0';
CredentialStore* store = load_store_file(password_file, err, err_size);
if (!store)
return NULL;
/* Load (or create) the store-wide dummy key once for the final (possibly
* merged) store. It makes an unknown-user challenge deterministic AND
* stable across daemon restarts, so a restart cannot be used as a
* username-enumeration oracle. It is persisted in an exact-mode-0600 sidecar
* next to the credential store; a NULL store path (empty store) keeps it
* ephemeral. Fail the load if the CSPRNG is unavailable rather than
* degrading the anti-enumeration property. */
const char* store_path = password_file ? password_file : early_input_file;
if (!load_or_create_dummy_key(store_path, store->dummy_key, err, err_size)) {
credentials_free(store);
return NULL;
}
if (!early_input_file)
return store;
CredentialStore* early = load_store_file(early_input_file, err, err_size);
if (!early) {
credentials_free(store);
return NULL;
}
/* A layered store must stay uniform too. */
if (store->count > 0 && early->count > 0 && store->iters != early->iters) {
set_error(err, err_size,
"credential file '%s' and early-input file '%s' disagree on the iteration count "
"(%u vs %u); the store must be uniform",
password_file, early_input_file, store->iters, early->iters);
credentials_free(early);
credentials_free(store);
return NULL;
}
if (store->count == 0 && early->count > 0)
store->iters = early->iters;
/* 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 (!entries_equal(&store->entries[existing], &early->entries[i])) {
set_error(err, err_size,
"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);
credentials_free(store);
return NULL;
}
continue; /* identical; nothing to merge */
}
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);
return NULL;
}
}
credentials_free(early);
return store;
}
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);
}
/* 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);
}
int credentials_store_size(const CredentialStore* store) {
return store ? store->count : 0;
}
bool credentials_store_has(const CredentialStore* store, const char* user) {
return store && find_user(store, user) >= 0;
}
bool credentials_secure_equal(const char* a, const char* b, size_t len) {
unsigned char diff = 0;
for (size_t i = 0; i < len; i++)
diff |= (unsigned char)a[i] ^ (unsigned char)b[i];
return diff == 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;
}
bool credentials_get_verifier(const CredentialStore* store, const char* user,
const char* const* module_users, int n, CredentialVerifier* out) {
if (!out)
return false;
memset(out, 0, sizeof(*out));
const char* uname = user ? user : "";
/* The dummy verifier is shaped exactly like a hit: the store-wide uniform
* iteration count (default for an empty store) and fixed dummy keys. */
out->iters = (store && store->count > 0) ? store->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;
/* Deterministic per-username dummy salt: HMAC-SHA256(dummy_key, username)
* truncated to the salt length. Two probes of the same unknown username see
* an identical challenge; distinct usernames differ. A NULL store (never
* 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)) {
credentials_burn((char*)mac, sizeof(mac));
return false;
}
memcpy(out->salt, mac, CREDENTIAL_SALT_LEN);
credentials_burn((char*)mac, sizeof(mac));
/* 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++) {
const char* listed = (module_users && user) ? module_users[i] : NULL;
on_list |= listed ? username_secure_equal(listed, user) : false;
}
/* 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; store && user && i < store->count; i++) {
if (username_secure_equal(store->entries[i].user, user))
match = &store->entries[i];
}
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);
memcpy(out->server_key, match->server_key, CREDENTIAL_KEY_LEN);
out->found = true;
}
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));
/* The base64 encodings of the salt/keys are secret material too (A7-4). */
credentials_burn(salt_b64, sizeof(salt_b64));
credentials_burn(stored_b64, sizeof(stored_b64));
credentials_burn(server_b64, sizeof(server_b64));
if (!ok) {
credentials_burn(out, out_sz);
return false;
}
if (written < 0 || (size_t)written >= out_sz) {
set_error(err, err_size, "output buffer too small for the credential line");
credentials_burn(out, out_sz);
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)) {
credentials_burn(store_line, sizeof(store_line));
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)
*user_out = NULL;
if (password_out)
*password_out = NULL;
if (err && err_size)
err[0] = '\0';
if (!path) {
set_error(err, err_size, "no --password-file path");
return -1;
}
FILE* fp = secret_file_open(path, err, err_size);
if (!fp)
return -1;
int line_no = 0;
char line[CREDENTIAL_MAX_LINE + 2];
int result = -1;
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, "password file '%s' line %d exceeds the %d-byte limit", path,
line_no, CREDENTIAL_MAX_LINE);
goto done;
}
if (len > 0 && line[len - 1] == '\n')
line[--len] = '\0';
if (len > 0 && line[len - 1] == '\r')
line[--len] = '\0';
char* cursor = line;
while (*cursor == ' ' || *cursor == '\t')
cursor++;
if (*cursor == '\0' || is_comment_char(*cursor))
continue; /* skip blank/comment lines; the first real line is the secret */
char* colon = strchr(cursor, ':');
if (!colon) {
set_error(err, err_size,
"password file '%s' line %d: expected 'user:password' (no ':' found)", path,
line_no);
goto done;
}
*colon = '\0';
const char* user = trim_space(cursor);
/* Preserve the password's exact bytes: only the line's trailing CR/LF was
* already stripped above. Trimming leading/trailing space here would make
* a password that legitimately begins or ends with whitespace unusable. */
const char* password = colon + 1;
if (!username_wellformed(user)) {
set_error(err, err_size,
"password file '%s' line %d: invalid username (must be 1-%d "
"non-whitespace characters)",
path, line_no, CREDENTIAL_MAX_USER_LEN);
goto done;
}
if (*password == '\0') {
set_error(err, err_size, "password file '%s' line %d: empty password", path, line_no);
goto done;
}
if (strlen(password) > CREDENTIAL_MAX_PASSWORD_LEN) {
set_error(err, err_size, "password file '%s' line %d: password exceeds %d characters", path,
line_no, CREDENTIAL_MAX_PASSWORD_LEN);
goto done;
}
char* user_dup = str_dup(user);
char* password_dup = str_dup(password);
if (!user_dup || !password_dup) {
free(user_dup);
free(password_dup);
set_error(err, err_size, "out of memory reading password file '%s'", path);
goto done;
}
if (user_out)
*user_out = user_dup;
else
free(user_dup);
if (password_out)
*password_out = password_dup;
else
free(password_dup);
result = 0;
goto done;
}
if (ferror(fp)) {
set_error(err, err_size, "error reading password file '%s': %s", path, strerror(errno));
goto done;
}
/* Reached end of file with no meaningful line: the file is empty (or only
* comments), which the client policy rejects. */
set_error(err, err_size, "password file '%s' contains no 'user:password' line", path);
done:
/* Wipe the stack line (which may hold the literal password) before return.
* user/password were str_dup'd into their outputs on success, so the stack
* copy is the only remaining plaintext. */
credentials_burn(line, sizeof(line));
fclose(fp);
return result;
}
void credentials_burn(char* secret, size_t len) {
if (!secret)
return;
volatile char* p = (volatile char*)secret;
for (size_t i = 0; i < len; i++)
p[i] = '\0';
}