feat(d5-daemon-auth): password auth, --password-file, --early-input

This commit is contained in:
2026-09-10 12:50:56 +02:00
parent 958eddf414
commit dd5ae60459
18 changed files with 1675 additions and 46 deletions
+62
View File
@@ -3,6 +3,7 @@
#include "chmod.h"
#include "compression.h"
#include "config.h"
#include "credentials.h"
#include "delta.h"
#include "file.h"
#include "file_list.h"
@@ -584,6 +585,11 @@ static const OptionEntry OPTION_TABLE[] = {
{"--old-d", NULL, OPT_FLAG, offsetof(Config, dirs)},
{"--relative", "-R", OPT_FLAG, offsetof(Config, relative)},
{"--mkpath", NULL, OPT_FLAG, offsetof(Config, mkpath)},
/* --password-file: client-only path to a `user:password` secret file used
* to authenticate a daemon (host::module/path) destination. Stored as a
* path; main() reads it (after the destination form is known) and derives
* the wire credentials. Never crosses the wire. */
{"--password-file", NULL, OPT_STRING, offsetof(Config, password_file)},
{"--delete-before", NULL, OPT_FLAG, offsetof(Config, delete_before)},
{"--delete-during", "--del", OPT_FLAG, offsetof(Config, delete_during)},
{"--delete-delay", NULL, OPT_FLAG, offsetof(Config, delete_delay)},
@@ -1440,6 +1446,55 @@ static int read_patterns_from_file(const char* filepath, char*** patterns, int*
}
#ifndef FASTSYNC_TEST_BUILD
/* Daemon auth (Wave B): read --password-file and derive the wire credentials
* (username + SHA-256 hex digest of the password). Runs once the destination
* form is known: the credentials only make sense for a daemon
* (host::module/path) destination, so a --password-file without one is a hard
* error here rather than a silently-ignored flag. The literal password is
* hashed immediately and wiped from memory; only the digest (and username) are
* kept on the Config for config_send. Returns 0 on success, -1 on error (the
* reason is logged; neither the password nor its digest is ever logged). */
static int load_daemon_credentials(Config* config) {
if (!config->password_file)
return 0;
if (!config->module || config->module[0] == '\0') {
log_message(LOG_LEVEL_ERROR,
"--password-file requires a daemon destination (host::module/path)");
return -1;
}
char err[512];
char* user = NULL;
char* password = NULL;
if (credentials_read_secret_file(config->password_file, &user, &password, err, sizeof(err)) !=
0) {
log_message(LOG_LEVEL_ERROR, "%s", err);
return -1;
}
char hash[CREDENTIAL_HASH_HEX_LEN + 1];
if (!credentials_hash_password(password, hash)) {
log_message(LOG_LEVEL_ERROR, "failed to hash the password from '%s'", config->password_file);
credentials_burn(password, strlen(password));
free(password);
free(user);
return -1;
}
credentials_burn(password, strlen(password));
free(password);
free(config->auth_user);
free(config->auth_password_hash);
config->auth_user = user;
config->auth_password_hash = str_dup(hash);
if (!config->auth_password_hash) {
log_message(LOG_LEVEL_ERROR, "memory allocation failed reading '%s'", config->password_file);
free(config->auth_user);
config->auth_user = NULL;
return -1;
}
log_info_message(LOG_INFO_MISC, "Loaded daemon credentials for user '%s'", config->auth_user);
return 0;
}
int main(int argc, char* argv[]) {
/* The server may close a connection mid-stream (e.g. when it rejects an
oversized delta). Ignore SIGPIPE so that a broken TCP connection
@@ -1519,6 +1574,13 @@ int main(int argc, char* argv[]) {
goto cleanup;
}
/* Daemon auth: read --password-file (if any) into the wire credentials now
* that the destination's module is known. */
if (load_daemon_credentials(config) != 0) {
exit_code = 1;
goto cleanup;
}
if (!validate_config(config)) {
exit_code = 1;
goto cleanup;
+4
View File
@@ -170,6 +170,10 @@ void print_usage(void) {
printf(" --save-to-disk Write received files to disk\n");
printf(" --server-host <ip> Server IP address (default: 127.0.0.1)\n");
printf(" --server-port <n> Server port (default: 8080)\n");
printf(" --password-file <f> Authenticate a host::module/path daemon destination.\n");
printf(" The file's first user:password line supplies the\n");
printf(" username and password (only a SHA-256 digest of the\n");
printf(" password is sent; keep the file mode 0600)\n");
printf(" --bwlimit <KB/s> Bandwidth limit in kilobytes per second\n");
printf(" --tls Enable TLS encryption\n");
printf(" --cert <path> TLS certificate file (PEM)\n");
+114 -11
View File
@@ -1,4 +1,5 @@
#include "config.h"
#include "credentials.h"
#include "daemon_conf.h"
#include "delay_updates.h"
#include "file.h"
@@ -35,6 +36,20 @@ static const char* required_client_cn;
* connection child (and their threads). */
static DaemonConf* g_daemon_conf = NULL;
/* Daemon credential store (Wave B), loaded once in main from --password-file /
* --early-input and shared read-only by every forked connection child. When a
* module declares `auth users` but no store was configured, the daemon refuses
* to start (fail closed); the store is never NULL after a successful start when
* such a module exists. */
static CredentialStore* g_credentials = NULL;
/* Opaque context threaded through to the config-frame gate: the connection's
* SSL object (NULL over plaintext) so the gate can warn when a credential
* exchange is not encrypted. */
typedef struct ModuleGateContext {
SSL* ssl;
} ModuleGateContext;
/* Aggregate payload bytes the multithreaded receiver may buffer ahead of the
slow disk writer. Receiving one more chunk adds up to ~2 * MAX_CHUNK_SIZE
of transient wire/decompression buffers on top of the queued payloads, so
@@ -147,11 +162,11 @@ static bool configure_authorization(const char* root) {
* --destination-root, but per-module and NEVER client-chosen. The module is
* refused (with a clear log) when it is unknown, when it is `read only` (every
* FastSync network transfer writes; there is no read-only wire operation yet),
* or when it declares `auth users` (FastSync cannot authenticate a claimed user
* this wave, so a module whose admin expected a credential list is refused
* rather than silently opened up -- auth is Wave B and will honor the list). */
* or when the presented daemon credentials fail for a module that declares
* `auth users`. Wave A refused every auth-required module (auth was not yet
* implemented); Wave B authenticates the client instead (see below). */
static const char* server_module_gate(const Config* config, void* context) {
(void)context;
ModuleGateContext* gate_ctx = (ModuleGateContext*)context;
if (!config)
return "missing config frame";
bool is_daemon = g_daemon_conf != NULL;
@@ -181,12 +196,44 @@ static const char* server_module_gate(const Config* config, void* context) {
return "requested daemon module is read only";
}
if (module->auth_user_count > 0) {
log_message(LOG_LEVEL_ERROR,
"daemon module '%s' requires authentication (auth users), which this "
"daemon version does not implement; refusing",
config->module);
return "requested daemon module requires authentication that is not yet "
"supported";
/* Auth-required module (Wave B): verify the presented credentials against
* the store BEFORE the module root is installed and before any data moves.
* Fail closed: no store -> refuse; no/invalid credentials -> refuse. The
* username may be logged (never the digest/password). */
if (g_credentials == NULL) {
log_message(LOG_LEVEL_ERROR,
"daemon module '%s' requires authentication but no credential store is "
"configured (--password-file/--early-input); refusing",
config->module);
return "requested daemon module requires authentication and no credential "
"store is configured";
}
if (!config->auth_user || !config->auth_password_hash) {
log_message(LOG_LEVEL_ERROR,
"daemon module '%s' requires authentication; the client "
"presented no credentials",
config->module);
return "requested daemon module requires authentication";
}
if (gate_ctx && !gate_ctx->ssl) {
log_message(LOG_LEVEL_WARNING,
"daemon module '%s' is authenticating over a plaintext connection (no --tls); "
"the credential exchange is not encrypted",
config->module);
}
if (!credentials_gate_allows(g_credentials, (const char* const*)module->auth_users,
module->auth_user_count, config->auth_user,
config->auth_password_hash)) {
char* escaped_user = output_escape(config->auth_user, config->eight_bit_output);
log_message(LOG_LEVEL_ERROR, "daemon module '%s': authentication failed for user '%s'",
config->module, escaped_user ? escaped_user : "<allocation failed>");
free(escaped_user);
return "authentication failed for the requested daemon module";
}
char* escaped_user = output_escape(config->auth_user, config->eight_bit_output);
log_message(LOG_LEVEL_INFO, "daemon module '%s': user '%s' authenticated", config->module,
escaped_user ? escaped_user : "<allocation failed>");
free(escaped_user);
}
if (!configure_authorization(module->path)) {
log_message(LOG_LEVEL_ERROR, "daemon module '%s' path '%s' is not usable", config->module,
@@ -202,7 +249,9 @@ void handler(int file_descriptor) {
protocol_session_init(&session, file_descriptor, file_descriptor);
protocol_session_set_ssl(&session, ssl);
protocol_session_bind(&session);
Config* config = config_receive_with_validate(file_descriptor, server_module_gate, NULL);
ModuleGateContext gate_ctx;
gate_ctx.ssl = ssl;
Config* config = config_receive_with_validate(file_descriptor, server_module_gate, &gate_ctx);
if (config == NULL) {
log_message(LOG_LEVEL_ERROR, "Failed to receive config");
close(file_descriptor);
@@ -424,6 +473,8 @@ static void cleanup(int sig) {
server_delete(&g_server);
daemon_conf_free(g_daemon_conf);
g_daemon_conf = NULL;
credentials_free(g_credentials);
g_credentials = NULL;
_exit(0);
}
@@ -440,6 +491,14 @@ static void print_server_usage(void) {
printf(" (port, motd file, address)\n");
printf(" --no-detach Stay in the foreground (default detaches to\n");
printf(" background when running --daemon)\n");
printf(" --password-file=FILE Credential store for modules that declare\n");
printf(" 'auth users' (line format: user:SHA256HEX where\n");
printf(" SHA256HEX is the lowercase hex SHA-256 of the\n");
printf(" user's password). Requires --daemon; an auth-\n");
printf(" required module with no store refuses to start\n");
printf(" --early-input=FILE Second credential store layered over\n");
printf(" --password-file (same format); usually a secrets-\n");
printf(" manager/process-substitution file. Requires --daemon\n");
printf(" -p <port> TCP port (default: 8080, range: 1-65535)\n");
printf(" --tls Enable TLS encryption\n");
printf(" --cert <path> TLS certificate file (PEM)\n");
@@ -586,6 +645,48 @@ int main(int argc, char* argv[]) {
if (g_daemon_conf->module_count == 0)
log_message(LOG_LEVEL_WARNING,
"daemon config has no modules; every connection will be refused");
/* Daemon credential store (Wave B). --password-file and --early-input
* feed the same store, loaded BEFORE the listener forks so every
* connection child shares one read-only store. Fail closed at startup: a
* module that declares `auth users` without a store (or with an empty
* store) refuses to start rather than serving a module whose credentials
* can never be verified. */
g_credentials =
credentials_load(opts.password_file, opts.early_input_file, cli_err, sizeof(cli_err));
if (!g_credentials) {
server_cli_options_free(&opts);
fprintf(stderr, "Error: %s\n", cli_err);
return 1;
}
bool credential_source_given = opts.password_file != NULL || opts.early_input_file != NULL;
for (int i = 0; i < g_daemon_conf->module_count; i++) {
const DaemonModule* module = &g_daemon_conf->modules[i];
if (module->auth_user_count == 0)
continue;
if (!credential_source_given) {
fprintf(stderr,
"Error: module '%s' declares 'auth users' but no credential store was given "
"(--password-file or --early-input); refusing to start (fail closed)\n",
module->name);
server_cli_options_free(&opts);
return 1;
}
if (credentials_store_size(g_credentials) == 0) {
fprintf(stderr,
"Error: module '%s' declares 'auth users' but the credential store is empty; "
"refusing to start (fail closed)\n",
module->name);
server_cli_options_free(&opts);
return 1;
}
for (int j = 0; j < module->auth_user_count; j++) {
if (!credentials_store_has(g_credentials, module->auth_users[j]))
log_message(LOG_LEVEL_WARNING,
"daemon module '%s': auth user '%s' has no credential store entry; that "
"user can never authenticate",
module->name, module->auth_users[j]);
}
}
} else {
if (!configure_authorization(opts.destination_root)) {
char* escaped = output_escape(opts.destination_root, false);
@@ -648,6 +749,8 @@ int main(int argc, char* argv[]) {
out:
daemon_conf_free(g_daemon_conf);
g_daemon_conf = NULL;
credentials_free(g_credentials);
g_credentials = NULL;
server_cli_options_free(&opts);
return exit_code;
}
+35 -2
View File
@@ -107,6 +107,18 @@ int server_cli_parse(int argc, char* argv[], ServerCliOptions* opts, char* err,
}
opts->destination_root = argv[++i];
opts->destination_root_set = true;
} else if (arg_is(argv[i], "--password-file")) {
if (i + 1 >= argc) {
set_error(err, err_size, "missing argument for --password-file");
return -1;
}
opts->password_file = argv[++i];
} else if (arg_is(argv[i], "--early-input")) {
if (i + 1 >= argc) {
set_error(err, err_size, "missing argument for --early-input");
return -1;
}
opts->early_input_file = argv[++i];
} else if (arg_is(argv[i], "--address")) {
if (i + 1 >= argc) {
set_error(err, err_size, "missing argument for --address");
@@ -150,6 +162,24 @@ int server_cli_parse(int argc, char* argv[], ServerCliOptions* opts, char* err,
inline_value = argv[++i];
}
opts->config_path = inline_value;
} else if (arg_has_value(argv[i], "--password-file", &inline_value)) {
if (!inline_value) {
if (i + 1 >= argc) {
set_error(err, err_size, "missing argument for --password-file");
return -1;
}
inline_value = argv[++i];
}
opts->password_file = inline_value;
} else if (arg_has_value(argv[i], "--early-input", &inline_value)) {
if (!inline_value) {
if (i + 1 >= argc) {
set_error(err, err_size, "missing argument for --early-input");
return -1;
}
inline_value = argv[++i];
}
opts->early_input_file = inline_value;
} else if (arg_has_value(argv[i], "--dparam", &inline_value)) {
if (!inline_value) {
if (i + 1 >= argc) {
@@ -190,8 +220,11 @@ int server_cli_parse(int argc, char* argv[], ServerCliOptions* opts, char* err,
return -1;
}
if (!opts->daemon_mode &&
(opts->config_path != NULL || opts->dparam_count > 0 || opts->no_detach)) {
set_error(err, err_size, "--config, --dparam, and --no-detach require --daemon");
(opts->config_path != NULL || opts->dparam_count > 0 || opts->no_detach ||
opts->password_file != NULL || opts->early_input_file != NULL)) {
set_error(err, err_size,
"--config, --dparam, --no-detach, --password-file, and --early-input require "
"--daemon");
return -1;
}
return 0;
+2
View File
@@ -24,6 +24,8 @@ typedef struct ServerCliOptions {
bool port_set; /* an explicit -p was given */
int port; /* -p value (default 8080 when unset) */
const char* config_path; /* --config value, or NULL */
const char* password_file; /* --password-file value, or NULL (daemon) */
const char* early_input_file; /* --early-input value, or NULL (daemon) */
const char** dparams; /* raw --dparam override strings */
int dparam_count;
const char* bind_address; /* --address */
+60 -8
View File
@@ -1,5 +1,6 @@
#include "config.h"
#include "chmod.h"
#include "credentials.h"
#include "daemon_conf.h"
#include "delay_updates.h"
#include "delta.h"
@@ -38,6 +39,9 @@ static void config_set_defaults(Config* config) {
config->transport = TRANSPORT_TCP;
config->ssh_destination = NULL;
config->module = NULL;
config->auth_user = NULL;
config->auth_password_hash = NULL;
config->password_file = NULL;
config->fastsync_server_path = NULL;
config->exclude_patterns = NULL;
config->exclude_count = 0;
@@ -508,13 +512,15 @@ int config_parse_daemon_dest(Config* config) {
return 0;
const char* colon = strchr(dest, ':');
/* user@host::module names a daemon auth user, which this daemon version
* cannot verify: reject it rather than silently ignoring the user (auth is
* Wave B). */
/* user@host::module names a daemon auth user. FastSync takes the username
* from the --password-file (its first user:password line) so there is a
* single source of truth; an @user that could contradict it is rejected
* with a pointer to the supported form. */
if (memchr(dest, '@', (size_t)(colon - dest)) != NULL)
return daemon_dest_parse_error("daemon destination user@host::module is not supported: user "
"authentication is not implemented by this daemon version",
dest);
return daemon_dest_parse_error(
"daemon destination user@host::module is not supported: supply the username with "
"--password-file (first line: user:password)",
dest);
const char* host_start = dest;
const char* module_and_path = colon + 2;
@@ -609,6 +615,9 @@ void config_delete(Config* config) {
free(config->receive_root_directory);
free(config->ssh_destination);
free(config->module);
free(config->auth_user);
free(config->auth_password_hash);
free(config->password_file);
free(config->fastsync_server_path);
for (int i = 0; i < config->exclude_count; i++)
free(config->exclude_patterns[i]);
@@ -1088,6 +1097,48 @@ static bool receive_daemon_module(int fd, Config* c) {
return true;
}
/* Daemon password credentials (Wave B, within protocol 2.15.0 -- see the
* PROTOCOL_VERSION note in config.h: this rides the Wave A trailing-string
* area, symmetric sender+receiver in every 2.15.0 build, so it is not a frame
* layout that needs its own bump). A single presence int is followed, when
* set, by the username and the SHA-256 hex digest of the password. The
* literal password never crosses the wire. */
static bool send_daemon_auth(int fd, const Config* c) {
bool present = c->auth_user != NULL && c->auth_password_hash != NULL && c->auth_user[0] != '\0' &&
c->auth_password_hash[0] != '\0';
if (!send_int(fd, present ? 1 : 0))
return false;
if (!present)
return true;
return send_str(fd, c->auth_user) && send_str(fd, c->auth_password_hash);
}
static bool receive_daemon_auth(int fd, Config* c) {
int present;
if (!receive_int(fd, &present) || !valid_wire_bool(present))
return false;
if (!present)
return true;
char* user = receive_str(fd);
char* hash = receive_str(fd);
if (!user || !hash) {
free(user);
free(hash);
return false;
}
size_t user_len = strlen(user);
bool valid = user_len > 0 && user_len <= CREDENTIAL_MAX_USER_LEN && credentials_hash_valid(hash);
if (!valid) {
free(user);
free(hash);
log_message(LOG_LEVEL_WARNING, "Daemon client sent malformed auth credentials");
return false;
}
c->auth_user = user;
c->auth_password_hash = hash;
return true;
}
bool config_send(int file_descriptor, const Config* config) {
protocol_session_set_max_alloc(NULL, config->max_alloc);
if (!send_core_fields(file_descriptor, config) || !send_delta_fields(file_descriptor, config) ||
@@ -1100,7 +1151,7 @@ bool config_send(int file_descriptor, const Config* config) {
!send_metadata_times_options(file_descriptor, config) ||
!send_symlink_trust_options(file_descriptor, config) ||
!send_phase4_xattr_options(file_descriptor, config) ||
!send_daemon_module(file_descriptor, config))
!send_daemon_module(file_descriptor, config) || !send_daemon_auth(file_descriptor, config))
return false;
Status status;
if (!receive_status(file_descriptor, &status))
@@ -1141,7 +1192,8 @@ Config* config_receive_with_validate(int file_descriptor, ConfigValidateFunc val
!receive_metadata_times_options(file_descriptor, config) ||
!receive_symlink_trust_options(file_descriptor, config) ||
!receive_phase4_xattr_options(file_descriptor, config) ||
!receive_daemon_module(file_descriptor, config))
!receive_daemon_module(file_descriptor, config) ||
!receive_daemon_auth(file_descriptor, config))
goto error;
if (config->compress_choice[0] != '\0' && strcmp(config->compress_choice, "zstd") != 0 &&
strcmp(config->compress_choice, "none") != 0) {
+22 -1
View File
@@ -95,6 +95,20 @@ typedef struct Config {
* string so the daemon can look the module up in its own config and confine
* the connection to the module's root (never a client-chosen root). */
char* module;
/* Daemon password authentication (Wave B, protocol 2.15.0, WITHIN the Wave A
* frame layout -- see the PROTOCOL_VERSION note below for why this is not a
* bump). Client-composed from a --password-file whose first meaningful line
* is `user:password`: the client sends ONLY the username and a SHA-256 hex
* digest of the password (auth_user + auth_password_hash), never the literal
* password. Both are NULL when the client has no credentials to present; a
* module WITHOUT `auth users` stays open and the server ignores any
* credentials that do arrive (the client sends them opportunistically and
* the server decides). */
char* auth_user;
char* auth_password_hash;
/* Client-only path of --password-file (never crosses the wire; it is read to
* populate auth_user/auth_password_hash before connecting). */
char* password_file;
char* fastsync_server_path;
char** exclude_patterns;
int exclude_count;
@@ -458,7 +472,14 @@ typedef struct Config {
* is what keeps a 2.15 client and a 2.14 server from ever reaching that state.
*
* NOTE: daemon module-selection bump owned by Wave A (2.15.0); later daemon
* waves (auth, motd) must not bump PROTOCOL_VERSION. */
* waves (auth, motd) must not bump PROTOCOL_VERSION. Wave B (auth) adds the
* credential fields (auth_user/auth_password_hash) as further trailing
* config-frame strings AFTER the Wave A module string, with a presence int
* prefix. This is not a new frame version: sender and receiver of a 2.15.0
* build always read and write the same full layout (the strict same-version
* handshake rejects any other version before a byte of the frame is parsed),
* so a peer can never desynchronize on the added tail. The 2.15.0 release
* ships Wave A + Wave B together; the bump stays owned by Wave A. */
#define PROTOCOL_VERSION "2.15.0"
#define DEFAULT_CHUNK_SIZE (10 * 1024 * 1024)
/* Upper bound on total basis-dir entries (rsync caps --link-dest at 20). */
+440
View File
@@ -0,0 +1,440 @@
#include "credentials.h"
#include "utils.h"
#include <ctype.h>
#include <errno.h>
#include <openssl/evp.h>
#include <stdarg.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.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). */
typedef struct CredentialEntry {
char* user;
char* password_hex; /* CREDENTIAL_HASH_HEX_LEN lowercase hex chars */
} CredentialEntry;
struct CredentialStore {
CredentialEntry* entries;
int count;
int capacity;
};
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 == ';';
}
/* 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;
}
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;
}
bool credentials_hash_valid(const char* hash_hex) {
if (!hash_hex)
return false;
for (int i = 0; i < CREDENTIAL_HASH_HEX_LEN; i++) {
if (hex_value(hash_hex[i]) < 0)
return false;
}
return hash_hex[CREDENTIAL_HASH_HEX_LEN] == '\0';
}
static bool append_entry(CredentialStore* store, const char* user, const char* password_hex) {
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;
}
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;
return false;
}
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 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. */
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 = fopen(path, "r");
if (!fp) {
set_error(err, err_size, "cannot open credential file '%s': %s", path, strerror(errno));
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:SHA256HEX' (no ':' found)", path,
line_no);
ok = false;
break;
}
*colon = '\0';
const char* user = trim_space(cursor);
const 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;
}
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);
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, secret)) {
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);
if (!ok) {
credentials_free(store);
return NULL;
}
return store;
}
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;
if (!early_input_file)
return store;
CredentialStore* early = load_store_file(early_input_file, err, err_size);
if (!early) {
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. */
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) {
set_error(err, err_size,
"credential file '%s' and early-input file '%s' disagree on the secret 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].password_hex)) {
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++) {
free(store->entries[i].user);
free(store->entries[i].password_hex);
}
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;
}
bool credentials_hash_password(const char* password, char* out_hex) {
if (!password || !out_hex)
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)
return false;
if (digest_len != 32)
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_hex[2 * digest_len] = '\0';
return true;
}
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 = fopen(path, "r");
if (!fp) {
set_error(err, err_size, "cannot open password file '%s': %s", path, strerror(errno));
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);
const char* password = trim_space(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:
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';
}
/* 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++) {
if (strcmp(store->entries[i].user, user) == 0)
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++) {
if (module_users[i] && strcmp(module_users[i], presented_user) == 0) {
on_module_list = true;
break;
}
}
if (!on_module_list)
return false;
return credentials_verify(store, presented_user, presented_hash_hex);
}
+117
View File
@@ -0,0 +1,117 @@
#ifndef CREDENTIALS_H
#define CREDENTIALS_H
#include <stdbool.h>
#include <stddef.h>
/* Daemon password authentication (Wave B).
*
* FastSync authenticates a daemon connection with a username plus a SHA-256
* hex digest of that username's password. The digest is what crosses the
* wire: a challenge-less credential exchange, so the literal password is never
* transmitted (and never stored on the daemon host). A module that declares
* `auth users` demands that the presented username is on its list AND that the
* presented digest matches the credential store's entry for that username.
* The digest comparison is constant-time; a module with `auth users` whose
* store is missing/misconfigured fails CLOSED (never falls open).
*
* Credential store format (server --password-file and --early-input): one
* `user:SHA256HEX` entry per line. SHA256HEX is the lowercase hex SHA-256 of
* the user's password -- the exact value a FastSync client transmits. Blank
* lines and lines whose first non-space character is '#' or ';' are comments.
* The parser is STRICT: a malformed line (no ':', an empty/whitespace user, a
* secret that is not 64 lowercase hex chars, a line longer than
* CREDENTIAL_MAX_LINE) fails the whole load so a typo can never silently
* change who may log in.
*
* Client --password-file format: the FIRST meaningful (non-comment, non-blank)
* line is `user:password`, holding the literal password. The client hashes it
* and sends only the digest; the file should be mode 0600 and readable only by
* its owner.
*/
/* Lowercase hex length of a SHA-256 digest (what travels on the wire and what
* the server store holds). */
#define CREDENTIAL_HASH_HEX_LEN 64
/* Longest accepted credential-file line (excluding the trailing newline). */
#define CREDENTIAL_MAX_LINE 4096
/* Upper bound on a username in a credential file and on the wire. Kept well
* below MAX_STRING_SIZE so a wire username can never exhaust anything. */
#define CREDENTIAL_MAX_USER_LEN 256
/* Upper bound on a client-file password (before hashing). */
#define CREDENTIAL_MAX_PASSWORD_LEN 1024
typedef struct CredentialStore CredentialStore;
/* Load the daemon credential store.
*
* password_file and early_input_file are both NULL-or-path, matching the
* server's --password-file and --early-input options. A file that cannot be
* opened or that fails the strict grammar is a hard error (err filled, NULL
* returned) -- the daemon fails CLOSED rather than serving an auth-required
* module with a partial store. Both files may be NULL, which yields an empty
* store (every auth-required module then refuses connections). When both are
* given, the --early-input file is layered over --password-file: a duplicate
* username whose secret matches is deduplicated; one whose secret differs is
* an error (the two sources disagree), never a silent pick.
*
* The returned store is heap-owned; free it with credentials_free. */
CredentialStore* credentials_load(const char* password_file, const char* early_input_file,
char* err, size_t err_size);
void credentials_free(CredentialStore* store);
/* True when `hash_hex` is exactly CREDENTIAL_HASH_HEX_LEN lowercase hex digits
* (the wire/store digest form). Used to reject a malformed presented digest
* before it reaches the comparison. */
bool credentials_hash_valid(const char* hash_hex);
/* Compute the lowercase hex SHA-256 of `password` into out_hex, which must
* hold at least CREDENTIAL_HASH_HEX_LEN + 1 bytes. Returns false on a NULL
* password or a hashing failure. The output is NUL-terminated. */
bool credentials_hash_password(const char* password, char* out_hex);
/* Read the CLIENT-side secret file: the first meaningful line is
* `user:password` (the literal password). *user_out and *password_out are
* freshly allocated on success (password is plaintext -- the caller hashes it
* and then burns/frees it); both are NULL on error. Returns 0 on success, -1
* on failure (err filled: the path is named, never the credential itself). */
int credentials_read_secret_file(const char* path, char** user_out, char** password_out, char* err,
size_t err_size);
/* Constant-time equality over exactly len bytes. Returns true when the two
* buffers match. No early exit: the whole length is always scanned, so a
* timing side-channel cannot reveal how many leading bytes matched. */
bool credentials_secure_equal(const char* a, const char* b, size_t len);
/* Overwrite secret[0..len) with zeros (best-effort wipe of a plaintext
* password that is about to be freed). */
void credentials_burn(char* secret, size_t len);
/* Verify a presented (user, digest) against the store. Returns true only when
* the store holds an entry for `user` whose stored digest equals the presented
* one. A NULL store, NULL user/digest, unknown user and wrong digest all
* return false. The digest comparison runs over a fixed dummy whenever the
* user is absent, so "unknown user" and "wrong password" take the same time
* (no user-enumeration oracle in the comparison path). */
bool credentials_verify(const CredentialStore* store, const char* user,
const char* presented_hash_hex);
/* The daemon's per-module auth decision, in one pure, unit-testable function.
* `module_users`/`module_user_count` are the module's `auth users` list; a
* module that declares auth users requires the presented user to be ON that
* list AND to verify against the store. Returns false (fail closed) when the
* store is NULL, when no credential was presented, when the user is not on the
* module's list, or when verification fails. This is the single decision the
* server_module_gate seam applies to an auth-required module. */
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);
/* Number of entries currently in the store (tests/introspection). */
int credentials_store_size(const CredentialStore* store);
/* Whether the store contains an entry for `user` (tests/introspection). */
bool credentials_store_has(const CredentialStore* store, const char* user);
#endif
+6 -5
View File
@@ -26,11 +26,12 @@
* per-module). There is never any client-chosen root and no --super /
* --copy-as: a module path always stays confined.
*
* `auth_users` is parsed and stored now (Wave A) for Wave B to honor, but the
* presence of auth users is already enforced with a SAFE default this wave:
* because FastSync cannot yet authenticate a claimed user, a module that
* declares auth users refuses every connection (see server.c). Auth is never
* bypassed by ignoring the list. */
* `auth_users` is honored by Wave B daemon authentication: a module that
* declares auth users accepts a connection only when the presented username is
* on this list AND verifies against the daemon's credential store
* (--password-file / --early-input). An auth-required module with no usable
* store refuses (fail closed) rather than falling open; see server.c. Auth is
* never bypassed by ignoring the list. */
typedef struct DaemonModule {
char* name; /* module name, as the client requests it */
char* path; /* module root (daemon-side authorized root) */