feat(d5-daemon-auth): password auth, --password-file, --early-input
This commit is contained in:
+9
-6
@@ -626,16 +626,19 @@ now transmits targets (the prior behavior was broken/partial); its status moved
|
|||||||
| `--config=FILE` | Alternate rsyncd.conf file | ✅ Implemented | Wave A: selects the daemon config file. Default when omitted (in `--daemon` mode): `~/.config/fastsync/fastsyncd.conf` if it exists, else `/etc/fastsyncd.conf`. The grammar is FastSync-native (documented in the Daemon Mode notes below) and strictly rejects unknown keys so a typo can never silently change what a module serves; requires `--daemon` |
|
| `--config=FILE` | Alternate rsyncd.conf file | ✅ Implemented | Wave A: selects the daemon config file. Default when omitted (in `--daemon` mode): `~/.config/fastsync/fastsyncd.conf` if it exists, else `/etc/fastsyncd.conf`. The grammar is FastSync-native (documented in the Daemon Mode notes below) and strictly rejects unknown keys so a typo can never silently change what a module serves; requires `--daemon` |
|
||||||
| `--dparam=OVERRIDE` | Override global daemon config | ✅ Implemented | Wave A: overrides one global scalar from the command line (`--dparam port=8734` and `--dparam=KEY=VALUE` both work). Limited to the global scalar keys the grammar defines (`port`, `motd file`, `address`); keys are case-insensitive and unknown keys/invalid values are rejected. Requires `--daemon` |
|
| `--dparam=OVERRIDE` | Override global daemon config | ✅ Implemented | Wave A: overrides one global scalar from the command line (`--dparam port=8734` and `--dparam=KEY=VALUE` both work). Limited to the global scalar keys the grammar defines (`port`, `motd file`, `address`); keys are case-insensitive and unknown keys/invalid values are rejected. Requires `--daemon` |
|
||||||
| `--no-detach` | Don't detach from parent | ✅ Implemented | Wave A: with `--daemon`, keeps the listener in the foreground (what integration tests use). Without it the daemonizes (fork/setsid, stdio redirected to /dev/null) after the listening socket is bound. Requires `--daemon` |
|
| `--no-detach` | Don't detach from parent | ✅ Implemented | Wave A: with `--daemon`, keeps the listener in the foreground (what integration tests use). Without it the daemonizes (fork/setsid, stdio redirected to /dev/null) after the listening socket is bound. Requires `--daemon` |
|
||||||
| `--password-file=FILE` | Read daemon password from file | ❌ Not Implemented | |
|
| `--password-file=FILE` | Read daemon password from file | ✅ Implemented | Wave B daemon auth. Client: `--password-file` supplies `user:password` for a `host::module/path` destination (the username is taken from this file, so `user@host::module` stays rejected). Server (`fastsync-server --daemon --password-file FILE`): the credential store that modules with `auth users` are verified against. Only a SHA-256 digest of the password ever crosses the wire or is stored server-side; the literal password never appears in logs. See the Daemon Mode notes below for the file formats and the plaintext/TLS caveat |
|
||||||
| `--early-input=FILE` | Use FILE for daemon early exec | ❌ Not Implemented | |
|
| `--early-input=FILE` | Use FILE for daemon early exec | ✅ Implemented | Server-only (requires `--daemon`): a second credential-store file, same `user:SHA256HEX` grammar as `--password-file`, read before the listener accepts connections (a secrets-manager / process-substitution source). Its entries layer over `--password-file`: identical entries dedupe, a conflicting secret for the same user is a startup error. A daemon whose modules declare `auth users` must be given at least one of the two, or it refuses to start (fail closed) |
|
||||||
|
|
||||||
**Daemon Mode notes (Wave A, protocol 2.15.0):** FastSync daemon mode is supported in FastSync's own protocol/config grammar, not rsync's SMB/daemon option encoding.
|
**Daemon Mode notes (Wave A, protocol 2.15.0; Wave B auth, no bump):** FastSync daemon mode is supported in FastSync's own protocol/config grammar, not rsync's SMB/daemon option encoding.
|
||||||
|
|
||||||
- **Config grammar** (`fastsyncd.conf`): line-based; an implicit global section first, then `[module]` sections. Keys are case-insensitive, values are trimmed and may be wrapped in one layer of double quotes (`path = "/srv/my dir"`). `#` and `;` at the start of a line (after leading whitespace) are full-line comments; inline comments and `\` continuations are not supported. Lines are bounded (4096 chars). Global keys: `port` (default 873), `motd file` (parsed/stored now; MOTD display is Wave C), `address` (optional bind address). Module keys: `path` (required; the daemon-side authorized root for that module), `read only` (yes/no/true/false/1/0, default no), `auth users` (comma list, stored for Wave B). **Unknown keys and malformed lines are parse-and-reject errors** (never silently ignored), so a typo cannot change what a module serves.
|
- **Config grammar** (`fastsyncd.conf`): line-based; an implicit global section first, then `[module]` sections. Keys are case-insensitive, values are trimmed and may be wrapped in one layer of double quotes (`path = "/srv/my dir"`). `#` and `;` at the start of a line (after leading whitespace) are full-line comments; inline comments and `\` continuations are not supported. Lines are bounded (4096 chars). Global keys: `port` (default 873), `motd file` (parsed/stored now; MOTD display is Wave C), `address` (optional bind address). Module keys: `path` (required; the daemon-side authorized root for that module), `read only` (yes/no/true/false/1/0, default no), `auth users` (comma list). **Unknown keys and malformed lines are parse-and-reject errors** (never silently ignored), so a typo cannot change what a module serves.
|
||||||
- **Module selection & confinement:** the client requests a module with an rsync-style `host::module[/path]` destination. The module name crosses the wire as a trailing string on the config frame (bumping `PROTOCOL_VERSION` 2.14.0 → 2.15.0; the bump is required because the config-frame layout changed and the strict same-version handshake is what prevents a peer from desynchronizing on the new trailing field). The daemon looks the module up in ITS OWN config and uses the module's `path` as the authorized root through the exact same `configure_authorization` confinement the standalone server applies to `--destination-root` (`file_open_secure_parent`, `has_path_traversal`, `path_is_within`); the client never supplies the root and there is no `--super`/`--copy-as`. The client's `/path` part is relative inside the module and is rejected if absolute or if it contains `..`. Unknown modules are refused before any data moves (the run fails cleanly at the config handshake). An absolute destination and a module request against a non-daemon server are also refused.
|
- **Module selection & confinement:** the client requests a module with an rsync-style `host::module[/path]` destination. The module name crosses the wire as a trailing string on the config frame (bumping `PROTOCOL_VERSION` 2.14.0 → 2.15.0; the bump is required because the config-frame layout changed and the strict same-version handshake is what prevents a peer from desynchronizing on the new trailing field). The daemon looks the module up in ITS OWN config and uses the module's `path` as the authorized root through the exact same `configure_authorization` confinement the standalone server applies to `--destination-root` (`file_open_secure_parent`, `has_path_traversal`, `path_is_within`); the client never supplies the root and there is no `--super`/`--copy-as`. The client's `/path` part is relative inside the module and is rejected if absolute or if it contains `..`. Unknown modules are refused before any data moves (the run fails cleanly at the config handshake). An absolute destination and a module request against a non-daemon server are also refused.
|
||||||
- **`read only` safe default:** every network transfer FastSync currently supports is a push that writes under the module root, so a `read only` module refuses the connection (clear server log "module is read only"; the client exits non-zero, nothing is transferred). A future pull/list operation can be opened up when it exists; the knob is already stored.
|
- **`read only` safe default:** every network transfer FastSync currently supports is a push that writes under the module root, so a `read only` module refuses the connection (clear server log "module is read only"; the client exits non-zero, nothing is transferred). A future pull/list operation can be opened up when it exists; the knob is already stored.
|
||||||
- **`auth users` safe default:** because FastSync daemon authentication is Wave B, a module that declares `auth users` refuses every connection this wave (the daemon cannot verify a claimed user yet). The list is parsed and stored for Wave B to honor; refusing is deliberate so an admin who expected a credential list is never silently left wide open (no auth-bypass path is shipped).
|
- **`auth users` (Wave B password authentication):** a module that declares `auth users` requires the client to present credentials. The client sends a username + the lowercase hex SHA-256 of the password (never the literal password) in the config frame; the daemon accepts a connection only when the presented username is **on the module's `auth users` list** AND the presented digest matches that user's credential-store entry. Verification is constant-time (username present/absent both take the same comparison work, so there is no timing oracle distinguishing "unknown user" from "wrong password"), and the daemon logs the username but **never the digest or the password**. A module WITHOUT `auth users` stays open (legitimate rsync configuration); credentials sent to such a module are ignored. Read-only is orthogonal: even a correctly authenticated push to a `read only` module is still refused (all FastSync network transfers write). Fail-closed policy: a daemon whose config declares `auth users` on any module refuses to start unless a credential store was given (`--password-file` and/or `--early-input`); a missing or empty store is never silently treated as "open".
|
||||||
- **Client side:** `host::module/path` selects the TCP transport and connects to `--server-port`; `host:path` stays the SSH transport; plain paths stay local TCP. A `user@host::module` form is rejected until auth exists.
|
- **Credential store format:** server `--password-file`/`--early-input` files are line-based `user:SHA256HEX`, one per line, where `SHA256HEX` is the lowercase hex SHA-256 of the user's password (exactly what the client transmits). Blank lines and lines starting with `#`/`;` are comments; the parser is strict (a malformed line fails the whole load, so a typo can never let a different set of users in). The client `--password-file` holds `user:password` on its first meaningful line (the literal password, hashed client-side then wiped from memory); keep both files readable only by their owner (mode 0600) since the client file holds the password and the server file holds the equivalent credential. Per-username wire length is bounded (256 chars) and digests are validated to be exactly 64 lowercase hex on receive.
|
||||||
|
- **Plaintext caveat:** over a plaintext (non-TLS) daemon, a sniffer can capture the transmitted digest and replay it (the exchange is challenge-less, like rsync), and it sees the same value that is already stored in the server's own credential file — so use `--tls` to protect the exchange. The daemon logs a warning when an auth-required module is reached over plaintext. TLS client-CN (`--client-cn`) is an independent transport identity check and composes with password auth: both may be required on the same connection.
|
||||||
|
- **Wire/protocol:** the auth payload is two trailing config-frame strings (username + digest) behind a presence int, sent after the Wave A module string and before the STATUS_OK/STATUS_ERROR ack. Because both peers of a 2.15.0 build always parse the same full frame (the strict same-version handshake rejects any other version before any byte is parsed), this is NOT a new frame layout and does **not** require a `PROTOCOL_VERSION` bump — the 2.15.0 release ships Wave A + Wave B together (see the NOTE in `src/shared/config.h`).
|
||||||
|
- **Client side:** `host::module/path` selects the TCP transport and connects to `--server-port`; `host:path` stays the SSH transport; plain paths stay local TCP. The daemon username comes from `--password-file` (first `user:password` line), and `--password-file` without a `host::module/path` destination is a client error (fail fast). A `user@host::module` form is rejected with a pointer to `--password-file`.
|
||||||
- **Merge note:** later daemon waves (auth, MOTD) must not bump `PROTOCOL_VERSION` again — the module-selection bump is owned by Wave A (see the NOTE in `src/shared/config.h`).
|
- **Merge note:** later daemon waves (auth, MOTD) must not bump `PROTOCOL_VERSION` again — the module-selection bump is owned by Wave A (see the NOTE in `src/shared/config.h`).
|
||||||
|
|
||||||
## 15. Safety & Security
|
## 15. Safety & Security
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
#include "chmod.h"
|
#include "chmod.h"
|
||||||
#include "compression.h"
|
#include "compression.h"
|
||||||
#include "config.h"
|
#include "config.h"
|
||||||
|
#include "credentials.h"
|
||||||
#include "delta.h"
|
#include "delta.h"
|
||||||
#include "file.h"
|
#include "file.h"
|
||||||
#include "file_list.h"
|
#include "file_list.h"
|
||||||
@@ -584,6 +585,11 @@ static const OptionEntry OPTION_TABLE[] = {
|
|||||||
{"--old-d", NULL, OPT_FLAG, offsetof(Config, dirs)},
|
{"--old-d", NULL, OPT_FLAG, offsetof(Config, dirs)},
|
||||||
{"--relative", "-R", OPT_FLAG, offsetof(Config, relative)},
|
{"--relative", "-R", OPT_FLAG, offsetof(Config, relative)},
|
||||||
{"--mkpath", NULL, OPT_FLAG, offsetof(Config, mkpath)},
|
{"--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-before", NULL, OPT_FLAG, offsetof(Config, delete_before)},
|
||||||
{"--delete-during", "--del", OPT_FLAG, offsetof(Config, delete_during)},
|
{"--delete-during", "--del", OPT_FLAG, offsetof(Config, delete_during)},
|
||||||
{"--delete-delay", NULL, OPT_FLAG, offsetof(Config, delete_delay)},
|
{"--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
|
#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[]) {
|
int main(int argc, char* argv[]) {
|
||||||
/* The server may close a connection mid-stream (e.g. when it rejects an
|
/* The server may close a connection mid-stream (e.g. when it rejects an
|
||||||
oversized delta). Ignore SIGPIPE so that a broken TCP connection
|
oversized delta). Ignore SIGPIPE so that a broken TCP connection
|
||||||
@@ -1519,6 +1574,13 @@ int main(int argc, char* argv[]) {
|
|||||||
goto cleanup;
|
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)) {
|
if (!validate_config(config)) {
|
||||||
exit_code = 1;
|
exit_code = 1;
|
||||||
goto cleanup;
|
goto cleanup;
|
||||||
|
|||||||
@@ -170,6 +170,10 @@ void print_usage(void) {
|
|||||||
printf(" --save-to-disk Write received files to disk\n");
|
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-host <ip> Server IP address (default: 127.0.0.1)\n");
|
||||||
printf(" --server-port <n> Server port (default: 8080)\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(" --bwlimit <KB/s> Bandwidth limit in kilobytes per second\n");
|
||||||
printf(" --tls Enable TLS encryption\n");
|
printf(" --tls Enable TLS encryption\n");
|
||||||
printf(" --cert <path> TLS certificate file (PEM)\n");
|
printf(" --cert <path> TLS certificate file (PEM)\n");
|
||||||
|
|||||||
+114
-11
@@ -1,4 +1,5 @@
|
|||||||
#include "config.h"
|
#include "config.h"
|
||||||
|
#include "credentials.h"
|
||||||
#include "daemon_conf.h"
|
#include "daemon_conf.h"
|
||||||
#include "delay_updates.h"
|
#include "delay_updates.h"
|
||||||
#include "file.h"
|
#include "file.h"
|
||||||
@@ -35,6 +36,20 @@ static const char* required_client_cn;
|
|||||||
* connection child (and their threads). */
|
* connection child (and their threads). */
|
||||||
static DaemonConf* g_daemon_conf = NULL;
|
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
|
/* 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
|
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
|
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
|
* --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
|
* 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),
|
* FastSync network transfer writes; there is no read-only wire operation yet),
|
||||||
* or when it declares `auth users` (FastSync cannot authenticate a claimed user
|
* or when the presented daemon credentials fail for a module that declares
|
||||||
* this wave, so a module whose admin expected a credential list is refused
|
* `auth users`. Wave A refused every auth-required module (auth was not yet
|
||||||
* rather than silently opened up -- auth is Wave B and will honor the list). */
|
* implemented); Wave B authenticates the client instead (see below). */
|
||||||
static const char* server_module_gate(const Config* config, void* context) {
|
static const char* server_module_gate(const Config* config, void* context) {
|
||||||
(void)context;
|
ModuleGateContext* gate_ctx = (ModuleGateContext*)context;
|
||||||
if (!config)
|
if (!config)
|
||||||
return "missing config frame";
|
return "missing config frame";
|
||||||
bool is_daemon = g_daemon_conf != NULL;
|
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";
|
return "requested daemon module is read only";
|
||||||
}
|
}
|
||||||
if (module->auth_user_count > 0) {
|
if (module->auth_user_count > 0) {
|
||||||
log_message(LOG_LEVEL_ERROR,
|
/* Auth-required module (Wave B): verify the presented credentials against
|
||||||
"daemon module '%s' requires authentication (auth users), which this "
|
* the store BEFORE the module root is installed and before any data moves.
|
||||||
"daemon version does not implement; refusing",
|
* Fail closed: no store -> refuse; no/invalid credentials -> refuse. The
|
||||||
config->module);
|
* username may be logged (never the digest/password). */
|
||||||
return "requested daemon module requires authentication that is not yet "
|
if (g_credentials == NULL) {
|
||||||
"supported";
|
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)) {
|
if (!configure_authorization(module->path)) {
|
||||||
log_message(LOG_LEVEL_ERROR, "daemon module '%s' path '%s' is not usable", config->module,
|
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_init(&session, file_descriptor, file_descriptor);
|
||||||
protocol_session_set_ssl(&session, ssl);
|
protocol_session_set_ssl(&session, ssl);
|
||||||
protocol_session_bind(&session);
|
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) {
|
if (config == NULL) {
|
||||||
log_message(LOG_LEVEL_ERROR, "Failed to receive config");
|
log_message(LOG_LEVEL_ERROR, "Failed to receive config");
|
||||||
close(file_descriptor);
|
close(file_descriptor);
|
||||||
@@ -424,6 +473,8 @@ static void cleanup(int sig) {
|
|||||||
server_delete(&g_server);
|
server_delete(&g_server);
|
||||||
daemon_conf_free(g_daemon_conf);
|
daemon_conf_free(g_daemon_conf);
|
||||||
g_daemon_conf = NULL;
|
g_daemon_conf = NULL;
|
||||||
|
credentials_free(g_credentials);
|
||||||
|
g_credentials = NULL;
|
||||||
_exit(0);
|
_exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -440,6 +491,14 @@ static void print_server_usage(void) {
|
|||||||
printf(" (port, motd file, address)\n");
|
printf(" (port, motd file, address)\n");
|
||||||
printf(" --no-detach Stay in the foreground (default detaches to\n");
|
printf(" --no-detach Stay in the foreground (default detaches to\n");
|
||||||
printf(" background when running --daemon)\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(" -p <port> TCP port (default: 8080, range: 1-65535)\n");
|
||||||
printf(" --tls Enable TLS encryption\n");
|
printf(" --tls Enable TLS encryption\n");
|
||||||
printf(" --cert <path> TLS certificate file (PEM)\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)
|
if (g_daemon_conf->module_count == 0)
|
||||||
log_message(LOG_LEVEL_WARNING,
|
log_message(LOG_LEVEL_WARNING,
|
||||||
"daemon config has no modules; every connection will be refused");
|
"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 {
|
} else {
|
||||||
if (!configure_authorization(opts.destination_root)) {
|
if (!configure_authorization(opts.destination_root)) {
|
||||||
char* escaped = output_escape(opts.destination_root, false);
|
char* escaped = output_escape(opts.destination_root, false);
|
||||||
@@ -648,6 +749,8 @@ int main(int argc, char* argv[]) {
|
|||||||
out:
|
out:
|
||||||
daemon_conf_free(g_daemon_conf);
|
daemon_conf_free(g_daemon_conf);
|
||||||
g_daemon_conf = NULL;
|
g_daemon_conf = NULL;
|
||||||
|
credentials_free(g_credentials);
|
||||||
|
g_credentials = NULL;
|
||||||
server_cli_options_free(&opts);
|
server_cli_options_free(&opts);
|
||||||
return exit_code;
|
return exit_code;
|
||||||
}
|
}
|
||||||
|
|||||||
+35
-2
@@ -107,6 +107,18 @@ int server_cli_parse(int argc, char* argv[], ServerCliOptions* opts, char* err,
|
|||||||
}
|
}
|
||||||
opts->destination_root = argv[++i];
|
opts->destination_root = argv[++i];
|
||||||
opts->destination_root_set = true;
|
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")) {
|
} else if (arg_is(argv[i], "--address")) {
|
||||||
if (i + 1 >= argc) {
|
if (i + 1 >= argc) {
|
||||||
set_error(err, err_size, "missing argument for --address");
|
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];
|
inline_value = argv[++i];
|
||||||
}
|
}
|
||||||
opts->config_path = inline_value;
|
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)) {
|
} else if (arg_has_value(argv[i], "--dparam", &inline_value)) {
|
||||||
if (!inline_value) {
|
if (!inline_value) {
|
||||||
if (i + 1 >= argc) {
|
if (i + 1 >= argc) {
|
||||||
@@ -190,8 +220,11 @@ int server_cli_parse(int argc, char* argv[], ServerCliOptions* opts, char* err,
|
|||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
if (!opts->daemon_mode &&
|
if (!opts->daemon_mode &&
|
||||||
(opts->config_path != NULL || opts->dparam_count > 0 || opts->no_detach)) {
|
(opts->config_path != NULL || opts->dparam_count > 0 || opts->no_detach ||
|
||||||
set_error(err, err_size, "--config, --dparam, and --no-detach require --daemon");
|
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 -1;
|
||||||
}
|
}
|
||||||
return 0;
|
return 0;
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ typedef struct ServerCliOptions {
|
|||||||
bool port_set; /* an explicit -p was given */
|
bool port_set; /* an explicit -p was given */
|
||||||
int port; /* -p value (default 8080 when unset) */
|
int port; /* -p value (default 8080 when unset) */
|
||||||
const char* config_path; /* --config value, or NULL */
|
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 */
|
const char** dparams; /* raw --dparam override strings */
|
||||||
int dparam_count;
|
int dparam_count;
|
||||||
const char* bind_address; /* --address */
|
const char* bind_address; /* --address */
|
||||||
|
|||||||
+60
-8
@@ -1,5 +1,6 @@
|
|||||||
#include "config.h"
|
#include "config.h"
|
||||||
#include "chmod.h"
|
#include "chmod.h"
|
||||||
|
#include "credentials.h"
|
||||||
#include "daemon_conf.h"
|
#include "daemon_conf.h"
|
||||||
#include "delay_updates.h"
|
#include "delay_updates.h"
|
||||||
#include "delta.h"
|
#include "delta.h"
|
||||||
@@ -38,6 +39,9 @@ static void config_set_defaults(Config* config) {
|
|||||||
config->transport = TRANSPORT_TCP;
|
config->transport = TRANSPORT_TCP;
|
||||||
config->ssh_destination = NULL;
|
config->ssh_destination = NULL;
|
||||||
config->module = NULL;
|
config->module = NULL;
|
||||||
|
config->auth_user = NULL;
|
||||||
|
config->auth_password_hash = NULL;
|
||||||
|
config->password_file = NULL;
|
||||||
config->fastsync_server_path = NULL;
|
config->fastsync_server_path = NULL;
|
||||||
config->exclude_patterns = NULL;
|
config->exclude_patterns = NULL;
|
||||||
config->exclude_count = 0;
|
config->exclude_count = 0;
|
||||||
@@ -508,13 +512,15 @@ int config_parse_daemon_dest(Config* config) {
|
|||||||
return 0;
|
return 0;
|
||||||
|
|
||||||
const char* colon = strchr(dest, ':');
|
const char* colon = strchr(dest, ':');
|
||||||
/* user@host::module names a daemon auth user, which this daemon version
|
/* user@host::module names a daemon auth user. FastSync takes the username
|
||||||
* cannot verify: reject it rather than silently ignoring the user (auth is
|
* from the --password-file (its first user:password line) so there is a
|
||||||
* Wave B). */
|
* 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)
|
if (memchr(dest, '@', (size_t)(colon - dest)) != NULL)
|
||||||
return daemon_dest_parse_error("daemon destination user@host::module is not supported: user "
|
return daemon_dest_parse_error(
|
||||||
"authentication is not implemented by this daemon version",
|
"daemon destination user@host::module is not supported: supply the username with "
|
||||||
dest);
|
"--password-file (first line: user:password)",
|
||||||
|
dest);
|
||||||
const char* host_start = dest;
|
const char* host_start = dest;
|
||||||
|
|
||||||
const char* module_and_path = colon + 2;
|
const char* module_and_path = colon + 2;
|
||||||
@@ -609,6 +615,9 @@ void config_delete(Config* config) {
|
|||||||
free(config->receive_root_directory);
|
free(config->receive_root_directory);
|
||||||
free(config->ssh_destination);
|
free(config->ssh_destination);
|
||||||
free(config->module);
|
free(config->module);
|
||||||
|
free(config->auth_user);
|
||||||
|
free(config->auth_password_hash);
|
||||||
|
free(config->password_file);
|
||||||
free(config->fastsync_server_path);
|
free(config->fastsync_server_path);
|
||||||
for (int i = 0; i < config->exclude_count; i++)
|
for (int i = 0; i < config->exclude_count; i++)
|
||||||
free(config->exclude_patterns[i]);
|
free(config->exclude_patterns[i]);
|
||||||
@@ -1088,6 +1097,48 @@ static bool receive_daemon_module(int fd, Config* c) {
|
|||||||
return true;
|
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) {
|
bool config_send(int file_descriptor, const Config* config) {
|
||||||
protocol_session_set_max_alloc(NULL, config->max_alloc);
|
protocol_session_set_max_alloc(NULL, config->max_alloc);
|
||||||
if (!send_core_fields(file_descriptor, config) || !send_delta_fields(file_descriptor, config) ||
|
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_metadata_times_options(file_descriptor, config) ||
|
||||||
!send_symlink_trust_options(file_descriptor, config) ||
|
!send_symlink_trust_options(file_descriptor, config) ||
|
||||||
!send_phase4_xattr_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;
|
return false;
|
||||||
Status status;
|
Status status;
|
||||||
if (!receive_status(file_descriptor, &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_metadata_times_options(file_descriptor, config) ||
|
||||||
!receive_symlink_trust_options(file_descriptor, config) ||
|
!receive_symlink_trust_options(file_descriptor, config) ||
|
||||||
!receive_phase4_xattr_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;
|
goto error;
|
||||||
if (config->compress_choice[0] != '\0' && strcmp(config->compress_choice, "zstd") != 0 &&
|
if (config->compress_choice[0] != '\0' && strcmp(config->compress_choice, "zstd") != 0 &&
|
||||||
strcmp(config->compress_choice, "none") != 0) {
|
strcmp(config->compress_choice, "none") != 0) {
|
||||||
|
|||||||
+22
-1
@@ -95,6 +95,20 @@ typedef struct Config {
|
|||||||
* string so the daemon can look the module up in its own config and confine
|
* 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). */
|
* the connection to the module's root (never a client-chosen root). */
|
||||||
char* module;
|
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* fastsync_server_path;
|
||||||
char** exclude_patterns;
|
char** exclude_patterns;
|
||||||
int exclude_count;
|
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.
|
* 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
|
* 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 PROTOCOL_VERSION "2.15.0"
|
||||||
#define DEFAULT_CHUNK_SIZE (10 * 1024 * 1024)
|
#define DEFAULT_CHUNK_SIZE (10 * 1024 * 1024)
|
||||||
/* Upper bound on total basis-dir entries (rsync caps --link-dest at 20). */
|
/* Upper bound on total basis-dir entries (rsync caps --link-dest at 20). */
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -26,11 +26,12 @@
|
|||||||
* per-module). There is never any client-chosen root and no --super /
|
* per-module). There is never any client-chosen root and no --super /
|
||||||
* --copy-as: a module path always stays confined.
|
* --copy-as: a module path always stays confined.
|
||||||
*
|
*
|
||||||
* `auth_users` is parsed and stored now (Wave A) for Wave B to honor, but the
|
* `auth_users` is honored by Wave B daemon authentication: a module that
|
||||||
* presence of auth users is already enforced with a SAFE default this wave:
|
* declares auth users accepts a connection only when the presented username is
|
||||||
* because FastSync cannot yet authenticate a claimed user, a module that
|
* on this list AND verifies against the daemon's credential store
|
||||||
* declares auth users refuses every connection (see server.c). Auth is never
|
* (--password-file / --early-input). An auth-required module with no usable
|
||||||
* bypassed by ignoring the list. */
|
* store refuses (fail closed) rather than falling open; see server.c. Auth is
|
||||||
|
* never bypassed by ignoring the list. */
|
||||||
typedef struct DaemonModule {
|
typedef struct DaemonModule {
|
||||||
char* name; /* module name, as the client requests it */
|
char* name; /* module name, as the client requests it */
|
||||||
char* path; /* module root (daemon-side authorized root) */
|
char* path; /* module root (daemon-side authorized root) */
|
||||||
|
|||||||
@@ -4,9 +4,15 @@ These exercise the Wave A daemon foundation end to end: a fastsync-server
|
|||||||
started with --daemon reads a FastSync-native module config file, the client
|
started with --daemon reads a FastSync-native module config file, the client
|
||||||
asks for a module with a host::module/path destination, and the transfer lands
|
asks for a module with a host::module/path destination, and the transfer lands
|
||||||
in the configured module root only. Read-only modules, unknown modules, and
|
in the configured module root only. Read-only modules, unknown modules, and
|
||||||
auth-required modules are all refused cleanly before any data moves.
|
auth-required modules without valid credentials are all refused cleanly before
|
||||||
|
any data moves. Wave B (daemon authentication) adds the real credential
|
||||||
|
round-trips exercised in TestDaemonAuthentication: modules that declare
|
||||||
|
`auth users` accept only a client whose --password-file presents a username on
|
||||||
|
the module's list with a matching password (verified as a SHA-256 digest), and
|
||||||
|
the daemon refuses to start when such a module has no credential store.
|
||||||
"""
|
"""
|
||||||
import glob
|
import glob
|
||||||
|
import hashlib
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import signal
|
import signal
|
||||||
@@ -34,11 +40,31 @@ MODULE_ROOT = os.path.join(TEST_DATA_DIR, "daemon_modules")
|
|||||||
FILES_MODULE = os.path.join(MODULE_ROOT, "files")
|
FILES_MODULE = os.path.join(MODULE_ROOT, "files")
|
||||||
READONLY_MODULE = os.path.join(MODULE_ROOT, "readonly")
|
READONLY_MODULE = os.path.join(MODULE_ROOT, "readonly")
|
||||||
AUTH_MODULE = os.path.join(MODULE_ROOT, "auth")
|
AUTH_MODULE = os.path.join(MODULE_ROOT, "auth")
|
||||||
|
TEAM_MODULE = os.path.join(MODULE_ROOT, "team")
|
||||||
CONF_FILE = os.path.join(TEST_DATA_DIR, "fastsyncd.conf")
|
CONF_FILE = os.path.join(TEST_DATA_DIR, "fastsyncd.conf")
|
||||||
|
CRED_FILE = os.path.join(TEST_DATA_DIR, "fastsyncd.passwd")
|
||||||
|
STARTFAIL_CONF = os.path.join(TEST_DATA_DIR, "fastsyncd_startfail.conf")
|
||||||
|
STARTFAIL_PORT = None
|
||||||
DETACH_MODULE = os.path.join(MODULE_ROOT, "detach")
|
DETACH_MODULE = os.path.join(MODULE_ROOT, "detach")
|
||||||
DETACH_CONF = os.path.join(TEST_DATA_DIR, "fastsyncd_detach.conf")
|
DETACH_CONF = os.path.join(TEST_DATA_DIR, "fastsyncd_detach.conf")
|
||||||
DETACH_PORT = None
|
DETACH_PORT = None
|
||||||
|
|
||||||
|
# Passwords are never sent as plaintext and never logged; these literals are
|
||||||
|
# only hashed into the server credential file / client password file.
|
||||||
|
ALICE_PASS = "alice-s3cret"
|
||||||
|
BOB_PASS = "bob-s3cret"
|
||||||
|
WRONG_PASS = "wrong-password"
|
||||||
|
|
||||||
|
|
||||||
|
def _pw_hash(password):
|
||||||
|
return hashlib.sha256(password.encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _write_client_password_file(path, user, password):
|
||||||
|
with open(path, "w") as f:
|
||||||
|
f.write("%s:%s\n" % (user, password))
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
def _kill_by_cmdline_marker(marker):
|
def _kill_by_cmdline_marker(marker):
|
||||||
"""Send SIGTERM to every running process whose cmdline contains `marker`
|
"""Send SIGTERM to every running process whose cmdline contains `marker`
|
||||||
@@ -67,7 +93,7 @@ class DaemonManager:
|
|||||||
self._proc = None
|
self._proc = None
|
||||||
self._port = None
|
self._port = None
|
||||||
|
|
||||||
def start(self, config_path, port_override=None):
|
def start(self, config_path, port_override=None, extra_args=None):
|
||||||
self.stop()
|
self.stop()
|
||||||
# When no override is given the daemon binds the config file's `port`
|
# When no override is given the daemon binds the config file's `port`
|
||||||
# (the plain config-port path); with an override the --dparam path.
|
# (the plain config-port path); with an override the --dparam path.
|
||||||
@@ -76,6 +102,8 @@ class DaemonManager:
|
|||||||
"--no-detach"])
|
"--no-detach"])
|
||||||
if port_override is not None:
|
if port_override is not None:
|
||||||
cmd += ["--dparam", f"port={port_override}"]
|
cmd += ["--dparam", f"port={port_override}"]
|
||||||
|
if extra_args:
|
||||||
|
cmd += extra_args
|
||||||
log_path = os.path.join(TEST_DATA_DIR, "fastsyncd.log")
|
log_path = os.path.join(TEST_DATA_DIR, "fastsyncd.log")
|
||||||
log = open(log_path, "w")
|
log = open(log_path, "w")
|
||||||
self._proc = subprocess.Popen(
|
self._proc = subprocess.Popen(
|
||||||
@@ -121,11 +149,18 @@ def _config_port(config_path):
|
|||||||
|
|
||||||
@pytest.fixture(scope="module", autouse=True)
|
@pytest.fixture(scope="module", autouse=True)
|
||||||
def daemon_env():
|
def daemon_env():
|
||||||
for d in (MODULE_ROOT, FILES_MODULE, READONLY_MODULE, AUTH_MODULE, DETACH_MODULE):
|
for d in (MODULE_ROOT, FILES_MODULE, READONLY_MODULE, AUTH_MODULE, TEAM_MODULE, DETACH_MODULE):
|
||||||
shutil.rmtree(d, ignore_errors=True)
|
shutil.rmtree(d, ignore_errors=True)
|
||||||
os.makedirs(d, exist_ok=True)
|
os.makedirs(d, exist_ok=True)
|
||||||
generate_test_files(SOURCE_DIR, full=False)
|
generate_test_files(SOURCE_DIR, full=False)
|
||||||
|
|
||||||
|
# Server-side credential store: alice and bob (password digests only; the
|
||||||
|
# plaintext passwords never appear on the daemon host or in any log).
|
||||||
|
with open(CRED_FILE, "w") as f:
|
||||||
|
f.write("# daemon credential store (Wave B)\n")
|
||||||
|
f.write("alice:%s\n" % _pw_hash(ALICE_PASS))
|
||||||
|
f.write("bob:%s\n" % _pw_hash(BOB_PASS))
|
||||||
|
|
||||||
# The config's port is a free port chosen per worker; the `daemon` fixture
|
# The config's port is a free port chosen per worker; the `daemon` fixture
|
||||||
# boots on it (the config-port path) and the --dparam override test boots a
|
# boots on it (the config-port path) and the --dparam override test boots a
|
||||||
# second daemon on a different port.
|
# second daemon on a different port.
|
||||||
@@ -145,7 +180,20 @@ def daemon_env():
|
|||||||
"[locked]\n"
|
"[locked]\n"
|
||||||
"path = %s\n"
|
"path = %s\n"
|
||||||
"auth users = alice\n"
|
"auth users = alice\n"
|
||||||
% (config_port, FILES_MODULE, READONLY_MODULE, AUTH_MODULE))
|
"\n"
|
||||||
|
"[team]\n"
|
||||||
|
"path = %s\n"
|
||||||
|
"auth users = alice,bob\n"
|
||||||
|
% (config_port, FILES_MODULE, READONLY_MODULE, AUTH_MODULE, TEAM_MODULE))
|
||||||
|
|
||||||
|
# A dedicated config for the fail-closed startup check: an auth-required
|
||||||
|
# module with no credential store must refuse to start. Its own free port
|
||||||
|
# keeps it independent of the running daemon.
|
||||||
|
global STARTFAIL_PORT
|
||||||
|
STARTFAIL_PORT = _find_free_port()
|
||||||
|
with open(STARTFAIL_CONF, "w") as f:
|
||||||
|
f.write("port = %d\n\n[locked]\npath = %s\nauth users = alice\n"
|
||||||
|
% (STARTFAIL_PORT, AUTH_MODULE))
|
||||||
|
|
||||||
# A dedicated config for the real (double-fork) detach test: an unique path
|
# A dedicated config for the real (double-fork) detach test: an unique path
|
||||||
# lets cleanup identify and kill the orphaned background daemon by cmdline.
|
# lets cleanup identify and kill the orphaned background daemon by cmdline.
|
||||||
@@ -163,7 +211,7 @@ def daemon_env():
|
|||||||
@pytest.fixture(scope="module")
|
@pytest.fixture(scope="module")
|
||||||
def daemon():
|
def daemon():
|
||||||
d = DaemonManager()
|
d = DaemonManager()
|
||||||
d.start(CONF_FILE)
|
d.start(CONF_FILE, extra_args=["--password-file", CRED_FILE])
|
||||||
yield d
|
yield d
|
||||||
d.stop()
|
d.stop()
|
||||||
|
|
||||||
@@ -173,6 +221,23 @@ def _push(dest, port):
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _push_with_creds(dest, port, user, password):
|
||||||
|
"""Push using a --password-file carrying user:password (a fresh temp file
|
||||||
|
each call so tests never share mutable state)."""
|
||||||
|
cred_path = os.path.join(TEST_DATA_DIR, f"client_{user}_{os.getpid()}_{time.time_ns()}.pw")
|
||||||
|
_write_client_password_file(cred_path, user, password)
|
||||||
|
try:
|
||||||
|
result, _ = run_client(SOURCE_DIR, dest, port=port,
|
||||||
|
extra_args=["--password-file", cred_path])
|
||||||
|
return result
|
||||||
|
finally:
|
||||||
|
os.unlink(cred_path)
|
||||||
|
|
||||||
|
|
||||||
|
def _tree_file_count(root):
|
||||||
|
return sum(len(files) for _, _, files in os.walk(root)) if os.path.exists(root) else 0
|
||||||
|
|
||||||
|
|
||||||
class TestDaemonModuleSelection:
|
class TestDaemonModuleSelection:
|
||||||
@pytest.mark.ci
|
@pytest.mark.ci
|
||||||
def test_module_transfer(self, daemon):
|
def test_module_transfer(self, daemon):
|
||||||
@@ -211,8 +276,7 @@ class TestDaemonRejection:
|
|||||||
def test_read_only_module_blocked(self, daemon):
|
def test_read_only_module_blocked(self, daemon):
|
||||||
result = _push("127.0.0.1::readonly", daemon.port)
|
result = _push("127.0.0.1::readonly", daemon.port)
|
||||||
assert result.returncode != 0
|
assert result.returncode != 0
|
||||||
file_count = sum(len(files) for _, _, files in os.walk(READONLY_MODULE))
|
assert _tree_file_count(READONLY_MODULE) == 0, "read-only module must not receive a file"
|
||||||
assert file_count == 0, "read-only module must not receive any file"
|
|
||||||
|
|
||||||
def test_read_only_no_write_anywhere(self, daemon):
|
def test_read_only_no_write_anywhere(self, daemon):
|
||||||
"""A refused read-only transfer must not add a single file anywhere under
|
"""A refused read-only transfer must not add a single file anywhere under
|
||||||
@@ -249,11 +313,12 @@ class TestDaemonRejection:
|
|||||||
result = _push("127.0.0.1::files/../..", daemon.port)
|
result = _push("127.0.0.1::files/../..", daemon.port)
|
||||||
assert result.returncode != 0
|
assert result.returncode != 0
|
||||||
|
|
||||||
def test_auth_required_module_rejected(self, daemon):
|
def test_auth_module_without_credentials_rejected(self, daemon):
|
||||||
|
"""Wave B: an auth-required module refuses a client that presents no
|
||||||
|
credentials (the daemon does not fall open)."""
|
||||||
result = _push("127.0.0.1::locked", daemon.port)
|
result = _push("127.0.0.1::locked", daemon.port)
|
||||||
assert result.returncode != 0
|
assert result.returncode != 0
|
||||||
file_count = sum(len(files) for _, _, files in os.walk(AUTH_MODULE))
|
assert _tree_file_count(AUTH_MODULE) == 0
|
||||||
assert file_count == 0
|
|
||||||
|
|
||||||
@pytest.mark.daemon_detach
|
@pytest.mark.daemon_detach
|
||||||
def test_real_detach_path(self):
|
def test_real_detach_path(self):
|
||||||
@@ -282,6 +347,7 @@ class TestDaemonRejection:
|
|||||||
log_path = os.path.join(TEST_DATA_DIR, "fastsyncd_noauth.log")
|
log_path = os.path.join(TEST_DATA_DIR, "fastsyncd_noauth.log")
|
||||||
log = open(log_path, "w")
|
log = open(log_path, "w")
|
||||||
cmd = SERVER_CMD + ["--daemon", "--config", CONF_FILE, "--no-detach",
|
cmd = SERVER_CMD + ["--daemon", "--config", CONF_FILE, "--no-detach",
|
||||||
|
"--password-file", CRED_FILE,
|
||||||
"--dparam", f"port={port}"]
|
"--dparam", f"port={port}"]
|
||||||
d._proc = subprocess.Popen(cmd, stdout=log, stderr=log, stdin=subprocess.DEVNULL,
|
d._proc = subprocess.Popen(cmd, stdout=log, stderr=log, stdin=subprocess.DEVNULL,
|
||||||
start_new_session=True)
|
start_new_session=True)
|
||||||
@@ -297,7 +363,7 @@ class TestDaemonRejection:
|
|||||||
"""--dparam port=N overrides the config's port and the daemon serves on N."""
|
"""--dparam port=N overrides the config's port and the daemon serves on N."""
|
||||||
override = _find_free_port()
|
override = _find_free_port()
|
||||||
d = DaemonManager()
|
d = DaemonManager()
|
||||||
d.start(CONF_FILE, port_override=override)
|
d.start(CONF_FILE, port_override=override, extra_args=["--password-file", CRED_FILE])
|
||||||
try:
|
try:
|
||||||
result = _push("127.0.0.1::files", override)
|
result = _push("127.0.0.1::files", override)
|
||||||
assert result.returncode == 0, result.stderr or result.stdout
|
assert result.returncode == 0, result.stderr or result.stdout
|
||||||
@@ -305,4 +371,219 @@ class TestDaemonRejection:
|
|||||||
_, missing = verify_transfer(SOURCE_DIR, received)
|
_, missing = verify_transfer(SOURCE_DIR, received)
|
||||||
assert not missing, f"missing: {missing[:5]}"
|
assert not missing, f"missing: {missing[:5]}"
|
||||||
finally:
|
finally:
|
||||||
d.stop()
|
d.stop()
|
||||||
|
|
||||||
|
|
||||||
|
class TestDaemonAuthentication:
|
||||||
|
"""Wave B password authentication round-trips on the shared daemon (its
|
||||||
|
config declares `locked` with `auth users = alice` and `team` with
|
||||||
|
`auth users = alice,bob`; the server runs with CRED_FILE holding alice and
|
||||||
|
bob digest entries)."""
|
||||||
|
|
||||||
|
def test_correct_password_succeeds(self, daemon):
|
||||||
|
result = _push_with_creds("127.0.0.1::locked", daemon.port, "alice", ALICE_PASS)
|
||||||
|
assert result.returncode == 0, result.stderr or result.stdout
|
||||||
|
received = get_dest_received_dir(AUTH_MODULE, SOURCE_DIR)
|
||||||
|
mismatches, missing = verify_transfer(SOURCE_DIR, received)
|
||||||
|
assert not missing, f"missing: {missing[:5]}"
|
||||||
|
assert not mismatches, f"mismatch: {mismatches[:5]}"
|
||||||
|
|
||||||
|
def test_wrong_password_rejected_no_data(self, daemon):
|
||||||
|
before = _tree_file_count(AUTH_MODULE)
|
||||||
|
result = _push_with_creds("127.0.0.1::locked", daemon.port, "alice", WRONG_PASS)
|
||||||
|
assert result.returncode != 0
|
||||||
|
assert _tree_file_count(AUTH_MODULE) == before, "wrong password must not write a file"
|
||||||
|
|
||||||
|
def test_unknown_user_rejected(self, daemon):
|
||||||
|
"""A user with a valid-shaped password but no store entry is refused
|
||||||
|
(the daemon must not fall open for unknown users)."""
|
||||||
|
before = _tree_file_count(AUTH_MODULE)
|
||||||
|
result = _push_with_creds("127.0.0.1::locked", daemon.port, "mallory", WRONG_PASS)
|
||||||
|
assert result.returncode != 0
|
||||||
|
assert _tree_file_count(AUTH_MODULE) == before
|
||||||
|
|
||||||
|
def test_user_not_on_module_list_rejected(self, daemon):
|
||||||
|
"""bob's credentials verify against the store, but bob is not on the
|
||||||
|
`locked` module's auth users list, so the connection is refused."""
|
||||||
|
before = _tree_file_count(AUTH_MODULE)
|
||||||
|
result = _push_with_creds("127.0.0.1::locked", daemon.port, "bob", BOB_PASS)
|
||||||
|
assert result.returncode != 0
|
||||||
|
assert _tree_file_count(AUTH_MODULE) == before
|
||||||
|
|
||||||
|
def test_second_module_user_succeeds(self, daemon):
|
||||||
|
"""bob IS on the `team` module's list, so his correct password works
|
||||||
|
there (module list + credential store both gate)."""
|
||||||
|
result = _push_with_creds("127.0.0.1::team", daemon.port, "bob", BOB_PASS)
|
||||||
|
assert result.returncode == 0, result.stderr or result.stdout
|
||||||
|
received = get_dest_received_dir(TEAM_MODULE, SOURCE_DIR)
|
||||||
|
mismatches, missing = verify_transfer(SOURCE_DIR, received)
|
||||||
|
assert not missing, f"missing: {missing[:5]}"
|
||||||
|
assert not mismatches, f"mismatch: {mismatches[:5]}"
|
||||||
|
|
||||||
|
def test_missing_password_file_rejected(self, daemon):
|
||||||
|
"""A client with no --password-file at all is refused by an auth-required
|
||||||
|
module (no credentials on the wire)."""
|
||||||
|
result = _push("127.0.0.1::locked", daemon.port)
|
||||||
|
assert result.returncode != 0
|
||||||
|
|
||||||
|
def test_open_module_ignores_credentials(self, daemon):
|
||||||
|
"""A module WITHOUT `auth users` stays open: credentials sent
|
||||||
|
opportunistically (even wrong ones) are ignored, not required."""
|
||||||
|
result = _push_with_creds("127.0.0.1::files", daemon.port, "alice", WRONG_PASS)
|
||||||
|
assert result.returncode == 0, result.stderr or result.stdout
|
||||||
|
|
||||||
|
def test_read_only_still_refuses_authenticated_client(self, daemon):
|
||||||
|
"""Read-only is orthogonal to auth: an authenticated push to a read-only
|
||||||
|
module is still refused with no data written (Wave A behavior)."""
|
||||||
|
before = _tree_file_count(READONLY_MODULE)
|
||||||
|
result = _push_with_creds("127.0.0.1::readonly", daemon.port, "alice", ALICE_PASS)
|
||||||
|
assert result.returncode != 0
|
||||||
|
assert _tree_file_count(READONLY_MODULE) == before
|
||||||
|
|
||||||
|
def test_password_file_requires_daemon_dest(self, daemon):
|
||||||
|
"""Client-side: --password-file without a host::module/path destination is
|
||||||
|
a client error (fail fast), not a silently ignored flag."""
|
||||||
|
cred_path = os.path.join(TEST_DATA_DIR, "client_local.pw")
|
||||||
|
_write_client_password_file(cred_path, "alice", ALICE_PASS)
|
||||||
|
try:
|
||||||
|
# A plain (non-::) destination with --password-file is rejected client-side.
|
||||||
|
cmd = CLIENT_CMD + ["--source-dir", SOURCE_DIR, "--dest-dir", "/tmp/local-dest-xyz",
|
||||||
|
"--save-to-disk", "--password-file", cred_path,
|
||||||
|
"--server-port", str(daemon.port)]
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
|
assert result.returncode != 0
|
||||||
|
assert "host::module/path" in (result.stderr or result.stdout)
|
||||||
|
finally:
|
||||||
|
os.unlink(cred_path)
|
||||||
|
|
||||||
|
def test_client_empty_password_file_rejected(self):
|
||||||
|
"""Client-side: an empty --password-file is rejected (no credentials)."""
|
||||||
|
cred_path = os.path.join(TEST_DATA_DIR, "client_empty.pw")
|
||||||
|
with open(cred_path, "w") as f:
|
||||||
|
f.write("# nothing here\n")
|
||||||
|
try:
|
||||||
|
cmd = CLIENT_CMD + ["--source-dir", SOURCE_DIR,
|
||||||
|
"--dest-dir", "127.0.0.1::files",
|
||||||
|
"--save-to-disk", "--password-file", cred_path]
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
|
assert result.returncode != 0
|
||||||
|
assert "no 'user:password'" in (result.stderr or result.stdout)
|
||||||
|
finally:
|
||||||
|
os.unlink(cred_path)
|
||||||
|
|
||||||
|
def test_daemon_fails_closed_without_credential_store(self):
|
||||||
|
"""Fail-closed startup: a config with an auth-required module but no
|
||||||
|
--password-file/--early-input refuses to start (never serves open)."""
|
||||||
|
proc = subprocess.run(
|
||||||
|
SERVER_CMD + ["--daemon", "--config", STARTFAIL_CONF, "--no-detach"],
|
||||||
|
capture_output=True, text=True, timeout=15)
|
||||||
|
assert proc.returncode != 0
|
||||||
|
assert "fail closed" in (proc.stderr or proc.stdout)
|
||||||
|
|
||||||
|
def test_daemon_early_input_feeds_credential_store(self):
|
||||||
|
"""--early-input is an alternative credential store source: a daemon
|
||||||
|
started with --early-input (and no --password-file) authenticates alice."""
|
||||||
|
d = DaemonManager()
|
||||||
|
port = _find_free_port()
|
||||||
|
try:
|
||||||
|
d.start(CONF_FILE, port_override=port, extra_args=["--early-input", CRED_FILE])
|
||||||
|
result = _push_with_creds("127.0.0.1::locked", port, "alice", ALICE_PASS)
|
||||||
|
assert result.returncode == 0, result.stderr or result.stdout
|
||||||
|
# Wrong password over the early-input store is still rejected.
|
||||||
|
result = _push_with_creds("127.0.0.1::locked", port, "alice", WRONG_PASS)
|
||||||
|
assert result.returncode != 0
|
||||||
|
finally:
|
||||||
|
d.stop()
|
||||||
|
|
||||||
|
def test_auth_log_does_not_leak_password(self, daemon):
|
||||||
|
"""The daemon log must never contain the password or its digest."""
|
||||||
|
log_path = os.path.join(TEST_DATA_DIR, "fastsyncd.log")
|
||||||
|
before = os.path.getsize(log_path) if os.path.exists(log_path) else 0
|
||||||
|
_push_with_creds("127.0.0.1::locked", daemon.port, "alice", WRONG_PASS)
|
||||||
|
_push_with_creds("127.0.0.1::locked", daemon.port, "alice", ALICE_PASS)
|
||||||
|
time.sleep(0.3)
|
||||||
|
with open(log_path, "rb") as f:
|
||||||
|
f.seek(before)
|
||||||
|
tail = f.read().decode("utf-8", "replace")
|
||||||
|
assert ALICE_PASS not in tail
|
||||||
|
assert WRONG_PASS not in tail
|
||||||
|
assert _pw_hash(ALICE_PASS) not in tail
|
||||||
|
assert _pw_hash(WRONG_PASS) not in tail
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_tls_certs(cert_dir):
|
||||||
|
"""Generate a self-signed CA, server cert (with 127.0.0.1 SAN) and a client
|
||||||
|
cert signed by that CA, for the TLS+auth composition test."""
|
||||||
|
os.makedirs(cert_dir, exist_ok=True)
|
||||||
|
ca_key, ca_cert = os.path.join(cert_dir, "ca.key"), os.path.join(cert_dir, "ca.pem")
|
||||||
|
server_key = os.path.join(cert_dir, "server.key")
|
||||||
|
server_cert = os.path.join(cert_dir, "server.pem")
|
||||||
|
client_key = os.path.join(cert_dir, "client.key")
|
||||||
|
client_cert = os.path.join(cert_dir, "client.pem")
|
||||||
|
subprocess.run(["openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes",
|
||||||
|
"-keyout", ca_key, "-out", ca_cert, "-days", "1",
|
||||||
|
"-subj", "/CN=FastSync Test CA"], check=True, capture_output=True)
|
||||||
|
san = os.path.join(cert_dir, "san.conf")
|
||||||
|
with open(san, "w") as f:
|
||||||
|
f.write("[req]\ndistinguished_name = dn\nreq_extensions = v3_req\n\n"
|
||||||
|
"[dn]\nCN = localhost\n\n[v3_req]\nsubjectAltName = @an\n\n"
|
||||||
|
"[an]\nDNS.1 = localhost\nIP.1 = 127.0.0.1\n")
|
||||||
|
subprocess.run(["openssl", "req", "-newkey", "rsa:2048", "-nodes",
|
||||||
|
"-keyout", server_key, "-out", os.path.join(cert_dir, "server.csr"),
|
||||||
|
"-subj", "/CN=localhost", "-config", san], check=True, capture_output=True)
|
||||||
|
subprocess.run(["openssl", "x509", "-req", "-in", os.path.join(cert_dir, "server.csr"),
|
||||||
|
"-CA", ca_cert, "-CAkey", ca_key, "-CAcreateserial",
|
||||||
|
"-out", server_cert, "-days", "1",
|
||||||
|
"-extfile", san, "-extensions", "v3_req"], check=True, capture_output=True)
|
||||||
|
subprocess.run(["openssl", "req", "-newkey", "rsa:2048", "-nodes",
|
||||||
|
"-keyout", client_key, "-out", os.path.join(cert_dir, "client.csr"),
|
||||||
|
"-subj", "/CN=fastsync-client"], check=True, capture_output=True)
|
||||||
|
subprocess.run(["openssl", "x509", "-req", "-in", os.path.join(cert_dir, "client.csr"),
|
||||||
|
"-CA", ca_cert, "-CAkey", ca_key, "-CAcreateserial",
|
||||||
|
"-out", client_cert, "-days", "1"], check=True, capture_output=True)
|
||||||
|
return {
|
||||||
|
"ca": ca_cert,
|
||||||
|
"server_cert": server_cert,
|
||||||
|
"server_key": server_key,
|
||||||
|
"client_cert": client_cert,
|
||||||
|
"client_key": client_key,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(shutil.which("openssl") is None,
|
||||||
|
reason="openssl CLI required to mint test certificates")
|
||||||
|
class TestDaemonTLSAuth:
|
||||||
|
"""TLS + password-auth composition: --client-cn (TLS client identity) and
|
||||||
|
the module password credential check are independent; both can be required
|
||||||
|
on the same auth-required module. Env-dependent: needs the openssl CLI."""
|
||||||
|
|
||||||
|
def test_tls_and_password_auth_compose(self):
|
||||||
|
cert_dir = os.path.join(TEST_DATA_DIR, "daemon_tls_certs")
|
||||||
|
certs = _generate_tls_certs(cert_dir)
|
||||||
|
client_creds = os.path.join(TEST_DATA_DIR, "daemon_tls_client.pw")
|
||||||
|
_write_client_password_file(client_creds, "alice", ALICE_PASS)
|
||||||
|
d = DaemonManager()
|
||||||
|
port = _find_free_port()
|
||||||
|
try:
|
||||||
|
d.start(CONF_FILE, port_override=port, extra_args=[
|
||||||
|
"--tls", "--cert", certs["server_cert"], "--key", certs["server_key"],
|
||||||
|
"--ca", certs["ca"], "--client-cn", "fastsync-client",
|
||||||
|
"--password-file", CRED_FILE])
|
||||||
|
tls_flags = ["--tls",
|
||||||
|
"--cert", certs["client_cert"], "--key", certs["client_key"],
|
||||||
|
"--ca", certs["ca"]]
|
||||||
|
# Correct password over TLS, with the right client CN: succeeds.
|
||||||
|
result, _ = run_client(SOURCE_DIR, "127.0.0.1::locked", port=port,
|
||||||
|
flags=tls_flags, extra_args=["--password-file", client_creds])
|
||||||
|
assert result.returncode == 0, (result.stderr or result.stdout)[:300]
|
||||||
|
# Wrong password over TLS is still refused by the credential check.
|
||||||
|
bad_creds = os.path.join(TEST_DATA_DIR, "daemon_tls_client_bad.pw")
|
||||||
|
_write_client_password_file(bad_creds, "alice", WRONG_PASS)
|
||||||
|
result, _ = run_client(SOURCE_DIR, "127.0.0.1::locked", port=port,
|
||||||
|
flags=tls_flags, extra_args=["--password-file", bad_creds])
|
||||||
|
assert result.returncode != 0
|
||||||
|
os.unlink(bad_creds)
|
||||||
|
finally:
|
||||||
|
d.stop()
|
||||||
|
os.unlink(client_creds)
|
||||||
|
shutil.rmtree(cert_dir, ignore_errors=True)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
#include "test_client_cli.h"
|
#include "test_client_cli.h"
|
||||||
#include "test_compression.h"
|
#include "test_compression.h"
|
||||||
#include "test_config.h"
|
#include "test_config.h"
|
||||||
|
#include "test_credentials.h"
|
||||||
#include "test_data.h"
|
#include "test_data.h"
|
||||||
#include "test_daemon_conf.h"
|
#include "test_daemon_conf.h"
|
||||||
#include "test_delay_updates.h"
|
#include "test_delay_updates.h"
|
||||||
@@ -48,6 +49,7 @@ int main() {
|
|||||||
RUN_TEST(test_chunk);
|
RUN_TEST(test_chunk);
|
||||||
RUN_TEST(test_change_list);
|
RUN_TEST(test_change_list);
|
||||||
RUN_TEST(test_config);
|
RUN_TEST(test_config);
|
||||||
|
RUN_TEST(test_credentials);
|
||||||
RUN_TEST(test_compression);
|
RUN_TEST(test_compression);
|
||||||
RUN_TEST(test_scanner);
|
RUN_TEST(test_scanner);
|
||||||
RUN_TEST(test_checksum);
|
RUN_TEST(test_checksum);
|
||||||
|
|||||||
@@ -2772,6 +2772,26 @@ static void test_parse_args_remote_option_no_short_M() {
|
|||||||
config_delete(cfg);
|
config_delete(cfg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --password-file stores its path on the config (the file is read later, once
|
||||||
|
* the destination form is known). */
|
||||||
|
static void test_parse_args_password_file() {
|
||||||
|
Config* cfg = valid_client_config();
|
||||||
|
EXPECT_NOT_NULL(cfg);
|
||||||
|
char* argv[] = {"fastsync", "--source-dir", "/src",
|
||||||
|
"--dest-dir", "/dst", "--password-file=/etc/fast.pw"};
|
||||||
|
int positional_args[2];
|
||||||
|
int positional_count = 0;
|
||||||
|
EXPECT_EQ_INT(parse_args(cfg, 6, argv, positional_args, &positional_count), 0);
|
||||||
|
EXPECT_EQ_STR(cfg->password_file, "/etc/fast.pw");
|
||||||
|
|
||||||
|
char* argv2[] = {"fastsync", "--source-dir", "/src", "--dest-dir",
|
||||||
|
"/dst", "--password-file", "/etc/other.pw"};
|
||||||
|
positional_count = 0;
|
||||||
|
EXPECT_EQ_INT(parse_args(cfg, 7, argv2, positional_args, &positional_count), 0);
|
||||||
|
EXPECT_EQ_STR(cfg->password_file, "/etc/other.pw");
|
||||||
|
config_delete(cfg);
|
||||||
|
}
|
||||||
|
|
||||||
void test_client_cli() {
|
void test_client_cli() {
|
||||||
test_validate_config_required_paths();
|
test_validate_config_required_paths();
|
||||||
test_parse_args_numeric_ids();
|
test_parse_args_numeric_ids();
|
||||||
@@ -2914,4 +2934,5 @@ void test_client_cli() {
|
|||||||
test_parse_args_remote_option_missing_value();
|
test_parse_args_remote_option_missing_value();
|
||||||
test_parse_args_remote_option_rejects_bad_values();
|
test_parse_args_remote_option_rejects_bad_values();
|
||||||
test_parse_args_remote_option_no_short_M();
|
test_parse_args_remote_option_no_short_M();
|
||||||
|
test_parse_args_password_file();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -245,6 +245,88 @@ static void test_config_module_wire_empty_canonicalizes_to_null() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Daemon auth credentials (Wave B) ride the config frame: username + SHA-256
|
||||||
|
* hex digest are present together, or both are absent. Round-trip a present
|
||||||
|
* pair. */
|
||||||
|
static void test_config_daemon_auth_wire_roundtrip() {
|
||||||
|
Config* send_cfg = config_create();
|
||||||
|
EXPECT_NOT_NULL(send_cfg);
|
||||||
|
send_cfg->send_directory = str_dup("/src");
|
||||||
|
send_cfg->receive_root_directory = str_dup("rel/path");
|
||||||
|
send_cfg->module = str_dup("backup");
|
||||||
|
send_cfg->auth_user = str_dup("alice");
|
||||||
|
send_cfg->auth_password_hash =
|
||||||
|
str_dup("9b90e524e94995ee4aeae2ee3c428a53405d1e8db147f44facc46797d0caf4c3");
|
||||||
|
|
||||||
|
int p[2];
|
||||||
|
EXPECT_EQ_INT(socketpair(AF_UNIX, SOCK_STREAM, 0, p), 0);
|
||||||
|
io_set_fds(p[0], p[1]);
|
||||||
|
io_set_bwlimit(0);
|
||||||
|
|
||||||
|
pid_t pid = fork();
|
||||||
|
if (pid == 0) {
|
||||||
|
close(p[1]);
|
||||||
|
io_set_fds(p[0], p[0]);
|
||||||
|
Config* recv_cfg = config_receive(p[0]);
|
||||||
|
bool ok = recv_cfg != NULL && recv_cfg->auth_user != NULL &&
|
||||||
|
strcmp(recv_cfg->auth_user, "alice") == 0 && recv_cfg->auth_password_hash != NULL &&
|
||||||
|
strcmp(recv_cfg->auth_password_hash,
|
||||||
|
"9b90e524e94995ee4aeae2ee3c428a53405d1e8db147f44facc46797d0caf4c3") == 0;
|
||||||
|
config_delete(recv_cfg);
|
||||||
|
close(p[0]);
|
||||||
|
_exit(ok ? 0 : 1);
|
||||||
|
} else {
|
||||||
|
close(p[0]);
|
||||||
|
io_set_fds(p[1], p[1]);
|
||||||
|
bool sent = config_send(p[1], send_cfg);
|
||||||
|
int status;
|
||||||
|
waitpid(pid, &status, 0);
|
||||||
|
close(p[1]);
|
||||||
|
config_delete(send_cfg);
|
||||||
|
EXPECT_TRUE(sent);
|
||||||
|
EXPECT_TRUE(WIFEXITED(status) && WEXITSTATUS(status) == 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The receive side validates the auth payload: a present-but-malformed digest
|
||||||
|
* is refused (config_receive returns NULL), so a hostile peer cannot slip a
|
||||||
|
* garbage credential past the receive guard into the module gate. */
|
||||||
|
static void test_config_daemon_auth_wire_rejects_malformed() {
|
||||||
|
Config* send_cfg = config_create();
|
||||||
|
EXPECT_NOT_NULL(send_cfg);
|
||||||
|
send_cfg->send_directory = str_dup("/src");
|
||||||
|
send_cfg->receive_root_directory = str_dup("/dst");
|
||||||
|
send_cfg->module = str_dup("m");
|
||||||
|
send_cfg->auth_user = str_dup("alice");
|
||||||
|
send_cfg->auth_password_hash = str_dup("not-a-valid-sha256-hex-digest!!");
|
||||||
|
|
||||||
|
int p[2];
|
||||||
|
EXPECT_EQ_INT(socketpair(AF_UNIX, SOCK_STREAM, 0, p), 0);
|
||||||
|
io_set_fds(p[0], p[1]);
|
||||||
|
io_set_bwlimit(0);
|
||||||
|
|
||||||
|
pid_t pid = fork();
|
||||||
|
if (pid == 0) {
|
||||||
|
close(p[1]);
|
||||||
|
io_set_fds(p[0], p[0]);
|
||||||
|
Config* recv_cfg = config_receive(p[0]);
|
||||||
|
bool ok = recv_cfg == NULL;
|
||||||
|
config_delete(recv_cfg);
|
||||||
|
close(p[0]);
|
||||||
|
_exit(ok ? 0 : 1);
|
||||||
|
} else {
|
||||||
|
close(p[0]);
|
||||||
|
io_set_fds(p[1], p[1]);
|
||||||
|
bool sent = config_send(p[1], send_cfg);
|
||||||
|
int status;
|
||||||
|
waitpid(pid, &status, 0);
|
||||||
|
close(p[1]);
|
||||||
|
config_delete(send_cfg);
|
||||||
|
EXPECT_FALSE(sent);
|
||||||
|
EXPECT_TRUE(WIFEXITED(status) && WEXITSTATUS(status) == 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* A module gate that rejects any connection that names a module. */
|
/* A module gate that rejects any connection that names a module. */
|
||||||
static const char* reject_named_module_gate(const Config* config, void* context) {
|
static const char* reject_named_module_gate(const Config* config, void* context) {
|
||||||
(void)context;
|
(void)context;
|
||||||
@@ -1524,6 +1606,8 @@ void test_config() {
|
|||||||
test_config_phase4_xattr_wire_roundtrip();
|
test_config_phase4_xattr_wire_roundtrip();
|
||||||
test_config_module_wire_roundtrip();
|
test_config_module_wire_roundtrip();
|
||||||
test_config_module_wire_empty_canonicalizes_to_null();
|
test_config_module_wire_empty_canonicalizes_to_null();
|
||||||
|
test_config_daemon_auth_wire_roundtrip();
|
||||||
|
test_config_daemon_auth_wire_rejects_malformed();
|
||||||
test_config_receive_with_validate_rejects();
|
test_config_receive_with_validate_rejects();
|
||||||
}
|
}
|
||||||
test_config_delete_timing_early_helper();
|
test_config_delete_timing_early_helper();
|
||||||
|
|||||||
@@ -0,0 +1,360 @@
|
|||||||
|
#include "test_credentials.h"
|
||||||
|
#include "credentials.h"
|
||||||
|
#include "test_utils.h"
|
||||||
|
#include <errno.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include <sys/stat.h>
|
||||||
|
#include <sys/types.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
|
||||||
|
/* Known SHA-256 vectors pin the digest derivation to real SHA-256 so a change
|
||||||
|
* in the hashing (or a wire/store format change) is observable. */
|
||||||
|
#define SHA256_EMPTY "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||||
|
#define SHA256_SECRET "2bb80d537b1da3e38bd30361aa855686bde0eacd7162fef6a25fe97bf527a25b"
|
||||||
|
#define SHA256_ALICE_PASS "9b90e524e94995ee4aeae2ee3c428a53405d1e8db147f44facc46797d0caf4c3"
|
||||||
|
|
||||||
|
static int g_file_counter = 0;
|
||||||
|
|
||||||
|
/* Write `contents` to a uniquely-named temp file and return a malloc'd path
|
||||||
|
* (the caller frees it; the file is removed at the end of the test process or
|
||||||
|
* on request via rm_temp). */
|
||||||
|
static char* make_tmp_file(const char* contents) {
|
||||||
|
char path[256];
|
||||||
|
snprintf(path, sizeof(path), "/tmp/fs_cred_test_%d_%d", (int)getpid(), g_file_counter++);
|
||||||
|
FILE* fp = fopen(path, "w");
|
||||||
|
if (!fp)
|
||||||
|
return NULL;
|
||||||
|
size_t n = strlen(contents);
|
||||||
|
if (n > 0 && fwrite(contents, 1, n, fp) != n) {
|
||||||
|
fclose(fp);
|
||||||
|
unlink(path);
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
fclose(fp);
|
||||||
|
return strdup(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void rm_temp(const char* path) {
|
||||||
|
if (path)
|
||||||
|
unlink(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_credentials_hash_vectors() {
|
||||||
|
char out[CREDENTIAL_HASH_HEX_LEN + 1];
|
||||||
|
EXPECT_TRUE(credentials_hash_password("", out));
|
||||||
|
EXPECT_EQ_STR(out, SHA256_EMPTY);
|
||||||
|
EXPECT_TRUE(credentials_hash_password("secret", out));
|
||||||
|
EXPECT_EQ_STR(out, SHA256_SECRET);
|
||||||
|
EXPECT_TRUE(credentials_hash_password("alice-pass", out));
|
||||||
|
EXPECT_EQ_STR(out, SHA256_ALICE_PASS);
|
||||||
|
EXPECT_FALSE(credentials_hash_password(NULL, out));
|
||||||
|
EXPECT_FALSE(credentials_hash_password("x", NULL));
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_credentials_hash_valid() {
|
||||||
|
EXPECT_TRUE(credentials_hash_valid(SHA256_SECRET));
|
||||||
|
EXPECT_FALSE(credentials_hash_valid(NULL));
|
||||||
|
EXPECT_FALSE(credentials_hash_valid(""));
|
||||||
|
/* Wrong length. */
|
||||||
|
EXPECT_FALSE(credentials_hash_valid("abc"));
|
||||||
|
EXPECT_FALSE(
|
||||||
|
credentials_hash_valid("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"));
|
||||||
|
EXPECT_FALSE(
|
||||||
|
credentials_hash_valid("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"));
|
||||||
|
/* Uppercase hex and non-hex are rejected. */
|
||||||
|
EXPECT_FALSE(
|
||||||
|
credentials_hash_valid("2BB80D537B1DA3E38BD30361AA855686BDE0EACD7162FEF6A25FE97BF527A25B"));
|
||||||
|
EXPECT_FALSE(
|
||||||
|
credentials_hash_valid("gbb80d537b1da3e38bd30361aa855686bde0eacd7162fef6a25fe97bf527a25b"));
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_credentials_secure_equal() {
|
||||||
|
EXPECT_TRUE(credentials_secure_equal("abc", "abc", 3));
|
||||||
|
EXPECT_TRUE(credentials_secure_equal("", "", 0));
|
||||||
|
EXPECT_FALSE(credentials_secure_equal("abc", "abd", 3));
|
||||||
|
EXPECT_TRUE(credentials_secure_equal("abc", "ab", 2));
|
||||||
|
/* Same prefix, difference at the very last byte must still be detected. */
|
||||||
|
EXPECT_FALSE(credentials_secure_equal(SHA256_SECRET, SHA256_ALICE_PASS, CREDENTIAL_HASH_HEX_LEN));
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_credentials_store_parse_valid() {
|
||||||
|
char* path = make_tmp_file(
|
||||||
|
"# server credential store\n"
|
||||||
|
"; another comment style\n"
|
||||||
|
"\n"
|
||||||
|
"alice:9b90e524e94995ee4aeae2ee3c428a53405d1e8db147f44facc46797d0caf4c3\n"
|
||||||
|
" bob : 2bb80d537b1da3e38bd30361aa855686bde0eacd7162fef6a25fe97bf527a25b \n"
|
||||||
|
"carol:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\r\n");
|
||||||
|
EXPECT_NOT_NULL(path);
|
||||||
|
char err[512];
|
||||||
|
CredentialStore* store = credentials_load(path, NULL, err, sizeof(err));
|
||||||
|
EXPECT_NOT_NULL(store);
|
||||||
|
EXPECT_EQ_INT(credentials_store_size(store), 3);
|
||||||
|
EXPECT_TRUE(credentials_store_has(store, "alice"));
|
||||||
|
EXPECT_TRUE(credentials_store_has(store, "bob"));
|
||||||
|
EXPECT_TRUE(credentials_store_has(store, "carol"));
|
||||||
|
EXPECT_FALSE(credentials_store_has(store, "mallory"));
|
||||||
|
EXPECT_FALSE(credentials_store_has(store, "ALICE"));
|
||||||
|
EXPECT_TRUE(credentials_verify(store, "alice", SHA256_ALICE_PASS));
|
||||||
|
EXPECT_TRUE(credentials_verify(store, "bob", SHA256_SECRET));
|
||||||
|
EXPECT_TRUE(credentials_verify(store, "carol", SHA256_EMPTY));
|
||||||
|
EXPECT_FALSE(credentials_verify(store, "alice", SHA256_SECRET));
|
||||||
|
EXPECT_FALSE(credentials_verify(store, "mallory", SHA256_ALICE_PASS));
|
||||||
|
credentials_free(store);
|
||||||
|
rm_temp(path);
|
||||||
|
free(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_credentials_store_parse_rejects_malformed() {
|
||||||
|
const char* cases[] = {
|
||||||
|
/* no colon */
|
||||||
|
"alice\n",
|
||||||
|
/* empty user */
|
||||||
|
":9b90e524e94995ee4aeae2ee3c428a53405d1e8db147f44facc46797d0caf4c3\n",
|
||||||
|
/* empty secret */
|
||||||
|
"alice:\n",
|
||||||
|
/* secret too short */
|
||||||
|
"alice:8ce9c8b52c5\n",
|
||||||
|
/* secret not hex */
|
||||||
|
"alice:zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz\n",
|
||||||
|
/* uppercase hex rejected (strict) */
|
||||||
|
"alice:2BB80D537B1DA3E38BD30361AA855686BDE0EACD7162FEF6A25FE97BF527A25B\n",
|
||||||
|
/* whitespace inside the username */
|
||||||
|
"ali ce:2bb80d537b1da3e38bd30361aa855686bde0eacd7162fef6a25fe97bf527a25b\n",
|
||||||
|
/* duplicate user within one file */
|
||||||
|
"alice:9b90e524e94995ee4aeae2ee3c428a53405d1e8db147f44facc46797d0caf4c3\n"
|
||||||
|
"alice:2bb80d537b1da3e38bd30361aa855686bde0eacd7162fef6a25fe97bf527a25b\n",
|
||||||
|
};
|
||||||
|
for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) {
|
||||||
|
char* path = make_tmp_file(cases[i]);
|
||||||
|
EXPECT_NOT_NULL(path);
|
||||||
|
char err[512];
|
||||||
|
const CredentialStore* store = credentials_load(path, NULL, err, sizeof(err));
|
||||||
|
EXPECT_NULL(store);
|
||||||
|
EXPECT_TRUE(err[0] != '\0');
|
||||||
|
rm_temp(path);
|
||||||
|
free(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_credentials_store_parse_missing_file() {
|
||||||
|
char err[512];
|
||||||
|
const CredentialStore* store =
|
||||||
|
credentials_load("/nonexistent/cred-file-xyz", NULL, err, sizeof(err));
|
||||||
|
EXPECT_NULL(store);
|
||||||
|
EXPECT_TRUE(strstr(err, "cannot open") != NULL);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_credentials_store_empty_and_null() {
|
||||||
|
char err[512];
|
||||||
|
/* A NULL path is a valid (empty) store: no module can authenticate, which is
|
||||||
|
* the fail-closed state the startup check turns into a refusal to start. */
|
||||||
|
CredentialStore* store = credentials_load(NULL, NULL, err, sizeof(err));
|
||||||
|
EXPECT_NOT_NULL(store);
|
||||||
|
EXPECT_EQ_INT(credentials_store_size(store), 0);
|
||||||
|
EXPECT_FALSE(credentials_verify(store, "alice", SHA256_ALICE_PASS));
|
||||||
|
credentials_free(store);
|
||||||
|
|
||||||
|
/* A blank/comment-only file is an empty store too (not an error). */
|
||||||
|
char* path = make_tmp_file("# nothing here\n; nor here\n");
|
||||||
|
EXPECT_NOT_NULL(path);
|
||||||
|
store = credentials_load(path, NULL, err, sizeof(err));
|
||||||
|
EXPECT_NOT_NULL(store);
|
||||||
|
EXPECT_EQ_INT(credentials_store_size(store), 0);
|
||||||
|
credentials_free(store);
|
||||||
|
rm_temp(path);
|
||||||
|
free(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_credentials_store_overlong_line_rejected() {
|
||||||
|
char big[CREDENTIAL_MAX_LINE + 80];
|
||||||
|
int n = snprintf(big, sizeof(big), "alice:%s", SHA256_SECRET);
|
||||||
|
memset(big + n, 'a', sizeof(big) - (size_t)n - 1);
|
||||||
|
big[sizeof(big) - 1] = '\n';
|
||||||
|
char* path = make_tmp_file(big);
|
||||||
|
EXPECT_NOT_NULL(path);
|
||||||
|
char err[512];
|
||||||
|
const CredentialStore* store = credentials_load(path, NULL, err, sizeof(err));
|
||||||
|
EXPECT_NULL(store);
|
||||||
|
rm_temp(path);
|
||||||
|
free(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_credentials_early_input_merge() {
|
||||||
|
char* pw =
|
||||||
|
make_tmp_file("alice:9b90e524e94995ee4aeae2ee3c428a53405d1e8db147f44facc46797d0caf4c3\n");
|
||||||
|
EXPECT_NOT_NULL(pw);
|
||||||
|
char err[512];
|
||||||
|
|
||||||
|
/* A second file adds a new user. */
|
||||||
|
char* early =
|
||||||
|
make_tmp_file("bob:2bb80d537b1da3e38bd30361aa855686bde0eacd7162fef6a25fe97bf527a25b\n");
|
||||||
|
EXPECT_NOT_NULL(early);
|
||||||
|
CredentialStore* store = credentials_load(pw, early, err, sizeof(err));
|
||||||
|
EXPECT_NOT_NULL(store);
|
||||||
|
EXPECT_EQ_INT(credentials_store_size(store), 2);
|
||||||
|
EXPECT_TRUE(credentials_verify(store, "alice", SHA256_ALICE_PASS));
|
||||||
|
EXPECT_TRUE(credentials_verify(store, "bob", SHA256_SECRET));
|
||||||
|
credentials_free(store);
|
||||||
|
|
||||||
|
/* The same user with the SAME secret dedupes. */
|
||||||
|
char* early_same =
|
||||||
|
make_tmp_file("alice:9b90e524e94995ee4aeae2ee3c428a53405d1e8db147f44facc46797d0caf4c3\n");
|
||||||
|
EXPECT_NOT_NULL(early_same);
|
||||||
|
store = credentials_load(pw, early_same, err, sizeof(err));
|
||||||
|
EXPECT_NOT_NULL(store);
|
||||||
|
EXPECT_EQ_INT(credentials_store_size(store), 1);
|
||||||
|
credentials_free(store);
|
||||||
|
|
||||||
|
/* The same user with a DIFFERENT secret fails closed (ambiguous). */
|
||||||
|
char* early_diff =
|
||||||
|
make_tmp_file("alice:2bb80d537b1da3e38bd30361aa855686bde0eacd7162fef6a25fe97bf527a25b\n");
|
||||||
|
EXPECT_NOT_NULL(early_diff);
|
||||||
|
store = credentials_load(pw, early_diff, err, sizeof(err));
|
||||||
|
EXPECT_NULL(store);
|
||||||
|
EXPECT_TRUE(err[0] != '\0');
|
||||||
|
|
||||||
|
rm_temp(pw);
|
||||||
|
rm_temp(early);
|
||||||
|
rm_temp(early_same);
|
||||||
|
rm_temp(early_diff);
|
||||||
|
free(pw);
|
||||||
|
free(early);
|
||||||
|
free(early_same);
|
||||||
|
free(early_diff);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_credentials_read_secret_file() {
|
||||||
|
char err[512];
|
||||||
|
char* user = NULL;
|
||||||
|
char* password = NULL;
|
||||||
|
|
||||||
|
/* Leading comments/blanks skipped; first real line wins. */
|
||||||
|
char* path = make_tmp_file("# password file\n"
|
||||||
|
"\n"
|
||||||
|
"alice:correct horse battery staple\n"
|
||||||
|
"ignored:second line\n");
|
||||||
|
EXPECT_NOT_NULL(path);
|
||||||
|
EXPECT_EQ_INT(credentials_read_secret_file(path, &user, &password, err, sizeof(err)), 0);
|
||||||
|
EXPECT_EQ_STR(user, "alice");
|
||||||
|
EXPECT_EQ_STR(password, "correct horse battery staple");
|
||||||
|
free(user);
|
||||||
|
free(password);
|
||||||
|
user = password = NULL;
|
||||||
|
rm_temp(path);
|
||||||
|
free(path);
|
||||||
|
|
||||||
|
/* CRLF and surrounding whitespace are tolerated. */
|
||||||
|
path = make_tmp_file(" bob : s3cret \r\n");
|
||||||
|
EXPECT_NOT_NULL(path);
|
||||||
|
EXPECT_EQ_INT(credentials_read_secret_file(path, &user, &password, err, sizeof(err)), 0);
|
||||||
|
EXPECT_EQ_STR(user, "bob");
|
||||||
|
EXPECT_EQ_STR(password, "s3cret");
|
||||||
|
free(user);
|
||||||
|
free(password);
|
||||||
|
user = password = NULL;
|
||||||
|
rm_temp(path);
|
||||||
|
free(path);
|
||||||
|
|
||||||
|
/* Empty file / comment-only file rejected. */
|
||||||
|
path = make_tmp_file("");
|
||||||
|
EXPECT_NOT_NULL(path);
|
||||||
|
EXPECT_EQ_INT(credentials_read_secret_file(path, &user, &password, err, sizeof(err)), -1);
|
||||||
|
EXPECT_NULL(user);
|
||||||
|
EXPECT_NULL(password);
|
||||||
|
EXPECT_TRUE(strstr(err, "no 'user:password'") != NULL);
|
||||||
|
rm_temp(path);
|
||||||
|
free(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_credentials_read_secret_file_bad() {
|
||||||
|
char err[512];
|
||||||
|
const char* cases[] = {
|
||||||
|
/* no colon */
|
||||||
|
"alicepassword\n",
|
||||||
|
/* empty user */
|
||||||
|
":password\n",
|
||||||
|
/* empty password */
|
||||||
|
"alice:\n",
|
||||||
|
/* empty password after whitespace */
|
||||||
|
"alice: \n",
|
||||||
|
};
|
||||||
|
for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) {
|
||||||
|
char* path = make_tmp_file(cases[i]);
|
||||||
|
EXPECT_NOT_NULL(path);
|
||||||
|
char* user = (char*)1;
|
||||||
|
char* password = (char*)1;
|
||||||
|
EXPECT_EQ_INT(credentials_read_secret_file(path, &user, &password, err, sizeof(err)), -1);
|
||||||
|
EXPECT_NULL(user);
|
||||||
|
EXPECT_NULL(password);
|
||||||
|
EXPECT_TRUE(err[0] != '\0');
|
||||||
|
rm_temp(path);
|
||||||
|
free(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
char* missing = "/nonexistent/password-file-xyz";
|
||||||
|
EXPECT_EQ_INT(credentials_read_secret_file(missing, NULL, NULL, err, sizeof(err)), -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_credentials_gate_allows() {
|
||||||
|
char* path =
|
||||||
|
make_tmp_file("alice:9b90e524e94995ee4aeae2ee3c428a53405d1e8db147f44facc46797d0caf4c3\n"
|
||||||
|
"bob:2bb80d537b1da3e38bd30361aa855686bde0eacd7162fef6a25fe97bf527a25b\n");
|
||||||
|
EXPECT_NOT_NULL(path);
|
||||||
|
char err[512];
|
||||||
|
CredentialStore* store = credentials_load(path, NULL, err, sizeof(err));
|
||||||
|
EXPECT_NOT_NULL(store);
|
||||||
|
|
||||||
|
const char* module_users[] = {"alice", "bob"};
|
||||||
|
|
||||||
|
/* Matching user + digest passes. */
|
||||||
|
EXPECT_TRUE(credentials_gate_allows(store, module_users, 2, "alice", SHA256_ALICE_PASS));
|
||||||
|
EXPECT_TRUE(credentials_gate_allows(store, module_users, 2, "bob", SHA256_SECRET));
|
||||||
|
/* Wrong digest for a listed user fails. */
|
||||||
|
EXPECT_FALSE(credentials_gate_allows(store, module_users, 2, "alice", SHA256_SECRET));
|
||||||
|
/* A store user that is not on the module's list fails. */
|
||||||
|
EXPECT_FALSE(credentials_gate_allows(store, module_users, 2, "alice", SHA256_ALICE_PASS) &&
|
||||||
|
credentials_gate_allows(store, module_users, 1, "bob", SHA256_SECRET));
|
||||||
|
EXPECT_TRUE(credentials_gate_allows(store, module_users, 1, "alice", SHA256_ALICE_PASS));
|
||||||
|
EXPECT_FALSE(credentials_gate_allows(store, module_users, 1, "bob", SHA256_SECRET));
|
||||||
|
/* No credentials presented fails. */
|
||||||
|
EXPECT_FALSE(credentials_gate_allows(store, module_users, 2, NULL, NULL));
|
||||||
|
EXPECT_FALSE(credentials_gate_allows(store, module_users, 2, "alice", NULL));
|
||||||
|
/* Unknown user fails. */
|
||||||
|
EXPECT_FALSE(credentials_gate_allows(store, module_users, 2, "mallory", SHA256_ALICE_PASS));
|
||||||
|
/* Fail closed: a NULL store refuses even with correct credentials. */
|
||||||
|
EXPECT_FALSE(credentials_gate_allows(NULL, module_users, 2, "alice", SHA256_ALICE_PASS));
|
||||||
|
/* An empty module list refuses everyone. */
|
||||||
|
EXPECT_FALSE(credentials_gate_allows(store, NULL, 0, "alice", SHA256_ALICE_PASS));
|
||||||
|
|
||||||
|
credentials_free(store);
|
||||||
|
rm_temp(path);
|
||||||
|
free(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_credentials_burn() {
|
||||||
|
char secret[32];
|
||||||
|
memcpy(secret, "supersecretvalue", 17);
|
||||||
|
credentials_burn(secret, 16);
|
||||||
|
for (int i = 0; i < 16; i++)
|
||||||
|
EXPECT_EQ_INT(secret[i], 0);
|
||||||
|
credentials_burn(NULL, 0); /* must not crash */
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_credentials(void) {
|
||||||
|
test_credentials_hash_vectors();
|
||||||
|
test_credentials_hash_valid();
|
||||||
|
test_credentials_secure_equal();
|
||||||
|
test_credentials_store_parse_valid();
|
||||||
|
test_credentials_store_parse_rejects_malformed();
|
||||||
|
test_credentials_store_parse_missing_file();
|
||||||
|
test_credentials_store_empty_and_null();
|
||||||
|
test_credentials_store_overlong_line_rejected();
|
||||||
|
test_credentials_early_input_merge();
|
||||||
|
test_credentials_read_secret_file();
|
||||||
|
test_credentials_read_secret_file_bad();
|
||||||
|
test_credentials_gate_allows();
|
||||||
|
test_credentials_burn();
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
#ifndef TEST_CREDENTIALS_H
|
||||||
|
#define TEST_CREDENTIALS_H
|
||||||
|
|
||||||
|
void test_credentials();
|
||||||
|
|
||||||
|
#endif
|
||||||
+38
-1
@@ -141,6 +141,41 @@ static void test_server_cli_invalid() {
|
|||||||
server_cli_options_free(&opts);
|
server_cli_options_free(&opts);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void test_server_cli_password_and_early_input() {
|
||||||
|
const char* args[] = {"s", "--daemon", "--password-file=/etc/fast.pw", "--early-input",
|
||||||
|
"/run/secrets"};
|
||||||
|
ServerCliOptions opts;
|
||||||
|
EXPECT_EQ_INT(parse_ok(args, 5, &opts), 0);
|
||||||
|
EXPECT_EQ_STR(opts.password_file, "/etc/fast.pw");
|
||||||
|
EXPECT_EQ_STR(opts.early_input_file, "/run/secrets");
|
||||||
|
|
||||||
|
const char* args2[] = {"s", "--daemon", "--password-file", "/etc/fast.pw",
|
||||||
|
"--early-input=/secrets"};
|
||||||
|
ServerCliOptions opts2;
|
||||||
|
EXPECT_EQ_INT(parse_ok(args2, 5, &opts2), 0);
|
||||||
|
EXPECT_EQ_STR(opts2.password_file, "/etc/fast.pw");
|
||||||
|
EXPECT_EQ_STR(opts2.early_input_file, "/secrets");
|
||||||
|
server_cli_options_free(&opts);
|
||||||
|
server_cli_options_free(&opts2);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_server_cli_password_requires_daemon() {
|
||||||
|
char err[256];
|
||||||
|
ServerCliOptions opts;
|
||||||
|
const char* a1[] = {"s", "--password-file", "/etc/fast.pw"};
|
||||||
|
EXPECT_EQ_INT(server_cli_parse(3, (char**)a1, &opts, err, sizeof(err)), -1);
|
||||||
|
EXPECT_TRUE(strstr(err, "require --daemon") != NULL);
|
||||||
|
|
||||||
|
const char* a2[] = {"s", "--early-input", "/secrets"};
|
||||||
|
EXPECT_EQ_INT(server_cli_parse(3, (char**)a2, &opts, err, sizeof(err)), -1);
|
||||||
|
EXPECT_TRUE(strstr(err, "require --daemon") != NULL);
|
||||||
|
|
||||||
|
const char* a3[] = {"s", "--daemon", "--password-file"};
|
||||||
|
EXPECT_EQ_INT(server_cli_parse(3, (char**)a3, &opts, err, sizeof(err)), -1);
|
||||||
|
EXPECT_TRUE(strstr(err, "missing argument") != NULL);
|
||||||
|
server_cli_options_free(&opts);
|
||||||
|
}
|
||||||
|
|
||||||
static void test_server_cli_help() {
|
static void test_server_cli_help() {
|
||||||
char err[256];
|
char err[256];
|
||||||
const char* a1[] = {"s", "--help"};
|
const char* a1[] = {"s", "--help"};
|
||||||
@@ -157,5 +192,7 @@ void test_server_cli() {
|
|||||||
test_server_cli_preserves_existing_flags();
|
test_server_cli_preserves_existing_flags();
|
||||||
test_server_cli_conflicts();
|
test_server_cli_conflicts();
|
||||||
test_server_cli_invalid();
|
test_server_cli_invalid();
|
||||||
|
test_server_cli_password_and_early_input();
|
||||||
|
test_server_cli_password_requires_daemon();
|
||||||
test_server_cli_help();
|
test_server_cli_help();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user