From f0381a6b8e588d28136ab89fbe5620ef0e038f3d Mon Sep 17 00:00:00 2001 From: TapTap Date: Sat, 12 Sep 2026 19:16:11 +0200 Subject: [PATCH] fix(a7-auth): publish dummy-key sidecar atomically and harden reads Address review findings on the persistent dummy-key sidecar: - Publish atomically: write a private same-directory temp file (.dummykey.tmp., 0600), fsync, then link(2) into place; fsync the containing directory and drop the temp name. A concurrent starter can no longer observe a zero/partial sidecar and fail closed. On EEXIST adopt the winner's sidecar; otherwise warn and use a transient ephemeral key. - Harden the read path (initial and EEXIST-adopt) with O_RDONLY|O_NOFOLLOW|O_NONBLOCK|O_CLOEXEC: reject planted symlinks (ELOOP fails closed) and never block on a planted FIFO. - Require the exact owner-only mode (st_mode & 07777) == 0600 and make the rejection message truthful. - Report a clear "short write" instead of a stale strerror(errno) when write() returns 0. - Document the artifact and its creation-failure caveat (FIFO store path, read-only filesystem, missing directory) in README.md and RSYNC_COMPAT.md. - Tests: known-key sidecar adoption (dummy salt KAT + reload), symlink rejection, and the exact-0600 rule (0400 now rejected). --- README.md | 6 +- RSYNC_COMPAT.md | 4 +- src/shared/credentials.c | 168 ++++++++++++++++++++++++++++++--------- tests/test_credentials.c | 119 ++++++++++++++++++++++++++- 4 files changed, 254 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index 3919f51..4d98112 100644 --- a/README.md +++ b/README.md @@ -526,7 +526,11 @@ redirect that output to an owner-only (mode 0600) file, and note that legacy (mode 0600) `.dummykey` sidecar next to the store: it holds the store-wide dummy key, is auto-created on first load, and must be preserved across daemon restarts so the dummy challenge for an unknown user stays stable (the key is -never regenerated while the sidecar exists). One residual is accepted: the store +never regenerated while the sidecar exists). If the sidecar cannot be created +(process-substitution/FIFO store path such as `/dev/fd/N`, a read-only +filesystem, or a missing directory), the daemon logs a warning and uses a +transient key, so the cross-restart guarantee does not hold for those +deployments. One residual is accepted: the store iteration count is observable pre-auth by design, since the miss path must match a hit. diff --git a/RSYNC_COMPAT.md b/RSYNC_COMPAT.md index 1383666..33cdf38 100644 --- a/RSYNC_COMPAT.md +++ b/RSYNC_COMPAT.md @@ -639,8 +639,8 @@ now transmits targets (the prior behavior was broken/partial); its status moved - **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, every client-chosen-ownership/super-user request is refused unless the module declares `client owner = yes` (the daemon's per-module opt-in, see below), and the operator `--no-super` veto forces super-user activities off for every daemon connection. 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. - **`client owner` (client-chosen-ownership opt-in):** by default a daemon module refuses every request that would let the client pick an owner or ask for super-user activities — `--numeric-ids`, `--chown`, `--usermap`/`--groupmap`, `--fake-super`, `--copy-as`, and an explicit `--super` — at the config handshake (before `STATUS_OK`), because a daemon has no per-module opt-in for client-chosen ownership and any anonymous client could otherwise force arbitrary owner ids inside the module root. `client owner = yes` opts a single module in, allowing those requests within that module's root (the standalone listener and the SSH `--stdio` server always honor them for their single operator-authorized root). Without the opt-in the daemon also forces super-user **device** activity off for that connection — char/block device-node creation (`--devices`) and `--write-devices` — even under the default `AUTO` mode, so a non-opted module can never be made to `mknod` or write a raw device; those entries are skipped (not refused) so an ordinary `-a` push still succeeds without device nodes. The opt-in does **not** lift the privilege requirement: `--copy-as` still needs a root receiver, and the operator `--no-super` veto still forces super-user activities off for every connection. The daemon logs a prominent startup warning for each `client owner = yes` module so the operator's deliberate choice is visible. - **`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` (A7 SCRAM-SHA-256 authentication):** a module that declares `auth users` requires the client to present credentials. The config frame carries ONLY the username; the daemon answers an auth-required module with `STATUS_AUTH_CHALLENGE` (PBKDF2 iteration count, 16-byte salt, 32-byte server nonce), the client answers with `STATUS_AUTH_RESPONSE` (fresh 32-byte client nonce + a 32-byte ClientProof), and the daemon accepts only when the proof verifies **and** the username is **on the module's `auth users` list** and has a store entry, replying `STATUS_AUTH_OK` with a 32-byte ServerSignature the client verifies before proceeding. Verification is constant-time over fixed 32-byte keys (the compare runs even for a miss), username membership uses a constant-time full-length scan, and an unknown/off-list user still receives a challenge and runs the same math against a dummy verifier: a deterministic per-username salt (`HMAC-SHA256(store dummy key, username)`), the store-wide uniform iteration count and dummy keys. Re-probing the same unknown username therefore yields an identical salt and iteration count while a different username yields a different salt, so there is no user-enumeration or timing oracle. The daemon logs the username but **never the password, proof or keys**. 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". A failed handshake (missing credentials, unknown/off-list user, wrong proof or malformed data) yields a single generic `STATUS_AUTH_FAILED` and the daemon closes before any data moves. The dummy key is persisted in an owner-only `.dummykey` sidecar (auto-created on first load, mode 0600) so the dummy salt stays stable across daemon restarts, closing the restart-gated enumeration channel. One residual is accepted: the store iteration count is observable pre-auth by design, since the miss path must match a hit. -- **Credential store format:** server `--password-file`/`--early-input` files are line-based `user:$fastsync$1$pbkdf2-sha256$$$$`, one per line (standard base64; 16-byte salt, 32-byte keys; `iters` in `[100000, 10000000]`, default 600000). Every entry in the resulting store must agree on `iters` (a store whose entries disagree, or where a layered `--early-input` disagrees with `--password-file`, is rejected). Generate lines with `fastsync-server --hash-credentials FILE [--iterations N]`; the emitted lines are secret material, so redirect them to an owner-only (mode 0600) file (the tool warns on stderr if stdout is a group/other-accessible regular file). 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 legacy `user:SHA256HEX` form is hard-rejected** with an actionable "legacy" error; there is no auto-upgrade, so a replayable bearer digest can never be loaded by a 2.19.0 daemon. The client `--password-file` holds `user:password` on its first meaningful line (the literal password, used only for the handshake then burned); keep both files readable only by their owner (mode 0600). Per-username wire length is bounded (256 chars) and every decoded salt/key length is validated. Loading the store also maintains an owner-only `.dummykey` sidecar (auto-created, mode 0600, exactly 32 bytes) holding the store-wide dummy key that shapes unknown-user challenges; persist it across daemon restarts so those challenges stay stable, and treat a sidecar with the wrong owner, permissions, size or type as a fatal load error (fail closed). +- **`auth users` (A7 SCRAM-SHA-256 authentication):** a module that declares `auth users` requires the client to present credentials. The config frame carries ONLY the username; the daemon answers an auth-required module with `STATUS_AUTH_CHALLENGE` (PBKDF2 iteration count, 16-byte salt, 32-byte server nonce), the client answers with `STATUS_AUTH_RESPONSE` (fresh 32-byte client nonce + a 32-byte ClientProof), and the daemon accepts only when the proof verifies **and** the username is **on the module's `auth users` list** and has a store entry, replying `STATUS_AUTH_OK` with a 32-byte ServerSignature the client verifies before proceeding. Verification is constant-time over fixed 32-byte keys (the compare runs even for a miss), username membership uses a constant-time full-length scan, and an unknown/off-list user still receives a challenge and runs the same math against a dummy verifier: a deterministic per-username salt (`HMAC-SHA256(store dummy key, username)`), the store-wide uniform iteration count and dummy keys. Re-probing the same unknown username therefore yields an identical salt and iteration count while a different username yields a different salt, so there is no user-enumeration or timing oracle. The daemon logs the username but **never the password, proof or keys**. 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". A failed handshake (missing credentials, unknown/off-list user, wrong proof or malformed data) yields a single generic `STATUS_AUTH_FAILED` and the daemon closes before any data moves. The dummy key is persisted in an owner-only `.dummykey` sidecar (auto-created on first load, mode 0600) so the dummy salt stays stable across daemon restarts, closing the restart-gated enumeration channel. The sidecar must be preserved across restarts for that guarantee; if it cannot be created (a process-substitution/FIFO store path such as `/dev/fd/N`, a read-only filesystem, or a missing directory), the daemon logs a warning and uses a transient per-run key, so unknown-user challenges change across restarts and the cross-restart guarantee does not hold for that deployment. One residual is accepted: the store iteration count is observable pre-auth by design, since the miss path must match a hit. +- **Credential store format:** server `--password-file`/`--early-input` files are line-based `user:$fastsync$1$pbkdf2-sha256$$$$`, one per line (standard base64; 16-byte salt, 32-byte keys; `iters` in `[100000, 10000000]`, default 600000). Every entry in the resulting store must agree on `iters` (a store whose entries disagree, or where a layered `--early-input` disagrees with `--password-file`, is rejected). Generate lines with `fastsync-server --hash-credentials FILE [--iterations N]`; the emitted lines are secret material, so redirect them to an owner-only (mode 0600) file (the tool warns on stderr if stdout is a group/other-accessible regular file). 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 legacy `user:SHA256HEX` form is hard-rejected** with an actionable "legacy" error; there is no auto-upgrade, so a replayable bearer digest can never be loaded by a 2.19.0 daemon. The client `--password-file` holds `user:password` on its first meaningful line (the literal password, used only for the handshake then burned); keep both files readable only by their owner (mode 0600). Per-username wire length is bounded (256 chars) and every decoded salt/key length is validated. Loading the store also maintains an owner-only `.dummykey` sidecar (auto-created, mode 0600, exactly 32 bytes) holding the store-wide dummy key that shapes unknown-user challenges; persist it across daemon restarts so those challenges stay stable, and treat a sidecar with the wrong owner, a mode other than exactly 0600, the wrong size or the wrong type as a fatal load error (fail closed). If the sidecar cannot be created (e.g. a process-substitution store path such as `/dev/fd/N`, a read-only filesystem, or a missing directory), the daemon logs a warning and uses a transient per-run key, so the cross-restart stability guarantee does not hold there. - **Plaintext caveat:** over a plaintext (non-TLS) daemon a sniffer can read the challenge and response and mount an **offline dictionary attack** against a weak password, so use `--tls` for any real deployment. Unlike the old challenge-less exchange there is **no replay**: the proof is bound to the fresh per-connection server nonce, so a captured `STATUS_AUTH_RESPONSE` cannot be reused on another connection (an integration test proxies the daemon and proves this). 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 config-frame auth block is now `[int present][str_redacted username]` (the old digest field is gone), and the frame stream gains the challenge/response (`STATUS_AUTH_CHALLENGE` → `STATUS_AUTH_RESPONSE` → `STATUS_AUTH_OK`/`STATUS_AUTH_FAILED`) between the config frame and the `STATUS_OK` ack. Both are wire-layout changes, so `PROTOCOL_VERSION` is bumped **2.18.0 → 2.19.0** (see the A7 note in `src/shared/config.h`); the strict same-version handshake keeps a 2.19 client and a 2.18 server from desynchronizing. - **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`. The client's plaintext password is wiped from memory (`config_burn_auth`) at transfer teardown. diff --git a/src/shared/credentials.c b/src/shared/credentials.c index 36dd004..3dbc3e5 100644 --- a/src/shared/credentials.c +++ b/src/shared/credentials.c @@ -601,11 +601,11 @@ static bool read_dummy_key_fd(int fd, const char* path, uint8_t out[CREDENTIAL_K set_error(err, err_size, "cannot stat dummy key file '%s': %s", path, strerror(errno)); return false; } - if (!S_ISREG(st.st_mode) || st.st_uid != geteuid() || (st.st_mode & (S_IRWXG | S_IRWXO)) != 0 || + if (!S_ISREG(st.st_mode) || st.st_uid != geteuid() || (st.st_mode & 07777) != 0600 || st.st_size != (off_t)CREDENTIAL_KEY_LEN) { set_error(err, err_size, - "refusing to read dummy key file '%s': it must be an owner-only (0600) regular file " - "of exactly %d bytes", + "refusing to read dummy key file '%s': it must be an owned regular file with exact " + "mode 0600 and exactly %d bytes", path, CREDENTIAL_KEY_LEN); return false; } @@ -629,12 +629,42 @@ static bool read_dummy_key_fd(int fd, const char* path, uint8_t out[CREDENTIAL_K return true; } +/* fsync the directory containing `path` (best effort). After publishing the + * sidecar with link(2), syncing the directory makes the new name durable so a + * crash cannot leave a restart without the key it just started using. */ +static void fsync_containing_dir(const char* path) { + char* dir = str_dup(path); + if (!dir) + return; + char* slash = strrchr(dir, '/'); + if (!slash) { + free(dir); + dir = str_dup("."); + if (!dir) + return; + } else if (slash == dir) { + slash[1] = '\0'; /* keep the leading '/' */ + } else { + *slash = '\0'; + } + int dfd = open(dir, O_RDONLY | O_DIRECTORY | O_CLOEXEC); + free(dir); + if (dfd < 0) + return; + fsync(dfd); + close(dfd); +} + /* Load the persistent dummy key for `store_path` from its `.dummykey` * sidecar, creating it (mode 0600, 32 random bytes) if absent. A NULL * store_path (empty store) yields a fresh ephemeral key. Reading an existing * sidecar fails CLOSED on any validation error; only the CREATE path degrades * to an ephemeral key (with a warning) when the filesystem cannot hold the - * sidecar (e.g. read-only mount), so a daemon still starts. Returns false only + * sidecar (e.g. read-only mount), so a daemon still starts. + * + * Creation is ATOMIC: the key is written to a private same-directory temp file + * and hard-linked into place, so a concurrent starter (or reader) never observes + * a partial/zero sidecar that would fail the load closed. Returns false only * when the CSPRNG itself fails (or a present-but-invalid sidecar is found). */ static bool load_or_create_dummy_key(const char* store_path, uint8_t out[CREDENTIAL_KEY_LEN], char* err, size_t err_size) { @@ -664,7 +694,10 @@ static bool load_or_create_dummy_key(const char* store_path, uint8_t out[CREDENT return false; } - int fd = open(sidecar, O_RDONLY | O_CLOEXEC); + /* Readers reject a planted symlink (O_NOFOLLOW) and never block on a planted + * FIFO (O_NONBLOCK; fstat rejects the non-regular file before any data read). + * Any open error other than ENOENT fails closed. */ + int fd = open(sidecar, O_RDONLY | O_NOFOLLOW | O_NONBLOCK | O_CLOEXEC); if (fd >= 0) { bool ok = read_dummy_key_fd(fd, sidecar, out, err, err_size); close(fd); @@ -672,48 +705,55 @@ static bool load_or_create_dummy_key(const char* store_path, uint8_t out[CREDENT return ok; } if (errno != ENOENT) { - /* The sidecar exists but cannot be opened for reading (e.g. EACCES): fail - * closed rather than substituting a different key. */ + /* The sidecar exists but cannot be opened for reading (EACCES, or ELOOP + * from a symlink): fail closed rather than substituting a different key. */ set_error(err, err_size, "cannot open dummy key file '%s': %s", sidecar, strerror(errno)); free(sidecar); return false; } - uint8_t fresh[CREDENTIAL_KEY_LEN]; - if (!credentials_random_bytes(fresh, CREDENTIAL_KEY_LEN)) { - set_error(err, err_size, "failed to generate the credential store dummy key"); + /* Publish atomically: write a private same-directory temp file, fsync it, + * then hard-link it into place. A concurrent reader therefore only ever + * sees a complete 32-byte sidecar (or none), never a partial/zero file. */ + char pid_suffix[32]; + int pn = snprintf(pid_suffix, sizeof(pid_suffix), ".tmp.%ld", (long)getpid()); + if (pn < 0 || (size_t)pn >= sizeof(pid_suffix)) { + set_error(err, err_size, "failed to build the dummy key temp path"); free(sidecar); return false; } - fd = open(sidecar, O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC, 0600); + size_t sidecar_len = (size_t)n; + size_t tmp_len = sidecar_len + (size_t)pn; + char* tmp = malloc(tmp_len + 1); + if (!tmp) { + set_error(err, err_size, "out of memory building the dummy key temp path"); + free(sidecar); + return false; + } + snprintf(tmp, tmp_len + 1, "%s%s", sidecar, pid_suffix); + + uint8_t fresh[CREDENTIAL_KEY_LEN]; + if (!credentials_random_bytes(fresh, CREDENTIAL_KEY_LEN)) { + set_error(err, err_size, "failed to generate the credential store dummy key"); + free(tmp); + free(sidecar); + return false; + } + + fd = open(tmp, O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC, 0600); if (fd < 0) { - int open_errno = errno; - if (open_errno == EEXIST) { - /* A racing instance created the sidecar first; adopt its key. */ - int rfd = open(sidecar, O_RDONLY | O_CLOEXEC); - if (rfd < 0) { - set_error(err, err_size, "cannot open dummy key file '%s': %s", sidecar, strerror(errno)); - free(sidecar); - credentials_burn((char*)fresh, sizeof(fresh)); - return false; - } - bool ok = read_dummy_key_fd(rfd, sidecar, out, err, err_size); - close(rfd); - free(sidecar); - credentials_burn((char*)fresh, sizeof(fresh)); - return ok; - } - /* Creation failed for another reason (read-only filesystem, missing - * directory, ...). Warn and fall back to an ephemeral key: unknown-user - * challenges stay deterministic within this daemon lifetime but will change - * on the next restart. */ - char* escaped = output_escape(sidecar, log_get_8_bit_output()); + /* Creation failed (read-only filesystem, missing directory, ...). Warn and + * fall back to an ephemeral key: unknown-user challenges stay deterministic + * within this daemon lifetime but will change on the next restart. */ + int create_errno = errno; + char* escaped = output_escape(tmp, log_get_8_bit_output()); log_message(LOG_LEVEL_WARNING, "cannot create dummy key file %s: %s; using a transient dummy key so unknown-user " "challenges will change across restarts", - escaped ? escaped : sidecar, strerror(open_errno)); + escaped ? escaped : tmp, strerror(create_errno)); free(escaped); memcpy(out, fresh, CREDENTIAL_KEY_LEN); + free(tmp); free(sidecar); credentials_burn((char*)fresh, sizeof(fresh)); return true; @@ -721,42 +761,92 @@ static bool load_or_create_dummy_key(const char* store_path, uint8_t out[CREDENT size_t written = 0; bool write_ok = true; + int write_errno = 0; while (written < CREDENTIAL_KEY_LEN) { ssize_t w = write(fd, fresh + written, CREDENTIAL_KEY_LEN - written); if (w < 0) { if (errno == EINTR) continue; write_ok = false; + write_errno = errno; break; } if (w == 0) { + /* A zero-length write is not a system error; errno is stale here, so + * report a clear short-write instead of a bogus strerror(errno). */ write_ok = false; + write_errno = 0; break; } written += (size_t)w; } - int write_errno = errno; if (write_ok && fsync(fd) != 0) { write_ok = false; write_errno = errno; } close(fd); if (!write_ok) { - /* Do not leave a truncated sidecar behind that would fail-closed a later - * restart; fall back to an ephemeral key instead. */ - unlink(sidecar); - char* escaped = output_escape(sidecar, log_get_8_bit_output()); + /* Do not leave a truncated temp file behind; fall back to an ephemeral key + * instead of failing closed on the next restart. */ + unlink(tmp); + char* escaped = output_escape(tmp, log_get_8_bit_output()); + const char* why = write_errno != 0 ? strerror(write_errno) : "short write"; log_message(LOG_LEVEL_WARNING, "cannot write dummy key file %s: %s; using a transient dummy key so unknown-user " "challenges will change across restarts", - escaped ? escaped : sidecar, strerror(write_errno)); + escaped ? escaped : tmp, why); free(escaped); memcpy(out, fresh, CREDENTIAL_KEY_LEN); + free(tmp); free(sidecar); credentials_burn((char*)fresh, sizeof(fresh)); return true; } + + if (link(tmp, sidecar) != 0) { + int link_errno = errno; + if (link_errno == EEXIST) { + /* A concurrent starter published first; adopt its key. Read it back + * through the same hardened path (no symlink, no block, exact mode). */ + int rfd = open(sidecar, O_RDONLY | O_NOFOLLOW | O_NONBLOCK | O_CLOEXEC); + if (rfd < 0) { + set_error(err, err_size, "cannot open dummy key file '%s': %s", sidecar, strerror(errno)); + unlink(tmp); + free(tmp); + free(sidecar); + credentials_burn((char*)fresh, sizeof(fresh)); + return false; + } + bool ok = read_dummy_key_fd(rfd, sidecar, out, err, err_size); + close(rfd); + unlink(tmp); + free(tmp); + free(sidecar); + credentials_burn((char*)fresh, sizeof(fresh)); + return ok; + } + /* Linking failed for another reason (e.g. no hard-link support on this + * filesystem). Warn and fall back to an ephemeral key. */ + unlink(tmp); + char* escaped = output_escape(sidecar, log_get_8_bit_output()); + log_message(LOG_LEVEL_WARNING, + "cannot publish dummy key file %s: %s; using a transient dummy key so unknown-user " + "challenges will change across restarts", + escaped ? escaped : sidecar, strerror(link_errno)); + free(escaped); + memcpy(out, fresh, CREDENTIAL_KEY_LEN); + free(tmp); + free(sidecar); + credentials_burn((char*)fresh, sizeof(fresh)); + return true; + } + + /* Published: make the new directory entry durable, then drop the private + * temp name (the sidecar keeps the inode alive). */ + fsync_containing_dir(sidecar); + unlink(tmp); memcpy(out, fresh, CREDENTIAL_KEY_LEN); + free(tmp); free(sidecar); credentials_burn((char*)fresh, sizeof(fresh)); return true; diff --git a/tests/test_credentials.c b/tests/test_credentials.c index 658cc9c..8832bd1 100644 --- a/tests/test_credentials.c +++ b/tests/test_credentials.c @@ -74,7 +74,8 @@ static void rm_temp(const char* path) { return; unlink(path); /* Every successfully loaded store auto-creates an owner-only - * `.dummykey` sidecar; remove it too so tests leave no stray key. */ + * `.dummykey` sidecar; remove it too so tests leave no stray key. The + * atomic-publish temp name is also removed defensively. */ size_t n = strlen(path) + strlen(".dummykey") + 1; char* sidecar = malloc(n); if (sidecar) { @@ -82,6 +83,13 @@ static void rm_temp(const char* path) { unlink(sidecar); free(sidecar); } + n = strlen(path) + strlen(".dummykey.tmp.") + 32; + char* tmp = malloc(n); + if (tmp) { + snprintf(tmp, n, "%s.dummykey.tmp.%ld", path, (long)getpid()); + unlink(tmp); + free(tmp); + } } /* `.dummykey` sidecar path (caller frees). */ @@ -862,6 +870,113 @@ static void test_credentials_dummy_key_rejects_bad_sidecar() { rm_temp(path); free(path); free(sidecar); + + /* Exact-mode rule: 0400 has no group/other bits but is not 0600, so it is + * rejected now (the mode must be exactly owner read+write). */ + path = make_tmp_file(contents); + EXPECT_NOT_NULL(path); + sidecar = dummy_sidecar_path(path); + EXPECT_NOT_NULL(sidecar); + fp = fopen(sidecar, "wb"); + EXPECT_NOT_NULL(fp); + EXPECT_TRUE(fwrite(key, 1, sizeof(key), fp) == sizeof(key)); + fclose(fp); + EXPECT_EQ_INT(chmod(sidecar, 0400), 0); + EXPECT_NULL(credentials_load(path, NULL, err, sizeof(err))); + EXPECT_TRUE(err[0] != '\0'); + rm_temp(path); + free(path); + free(sidecar); +} + +/* A pre-existing valid sidecar is adopted verbatim (no regeneration): the + * unknown-user dummy salt must equal HMAC-SHA256(known key, username), and a + * reload must yield the same salt. */ +static void test_credentials_dummy_key_existing_sidecar_adopted() { + char line[CREDENTIAL_MAX_LINE]; + EXPECT_TRUE(make_store_line("alice", KAT_PASSWORD, CREDENTIAL_MIN_ITERS, line, sizeof(line))); + char contents[CREDENTIAL_MAX_LINE + 2]; + snprintf(contents, sizeof(contents), "%s\n", line); + char* path = make_tmp_file(contents); + EXPECT_NOT_NULL(path); + char* sidecar = dummy_sidecar_path(path); + EXPECT_NOT_NULL(sidecar); + + /* Pre-create a valid owner-only sidecar with a known key. */ + uint8_t key[CREDENTIAL_KEY_LEN]; + memset(key, 0x5a, sizeof(key)); + FILE* fp = fopen(sidecar, "wb"); + EXPECT_NOT_NULL(fp); + EXPECT_TRUE(fwrite(key, 1, sizeof(key), fp) == sizeof(key)); + fclose(fp); + EXPECT_EQ_INT(chmod(sidecar, 0600), 0); + + char err[512]; + CredentialStore* store = credentials_load(path, NULL, err, sizeof(err)); + EXPECT_NOT_NULL(store); + CredentialVerifier v1; + EXPECT_TRUE(credentials_get_verifier(store, "unknown-user", NULL, 0, &v1)); + EXPECT_FALSE(v1.found); + /* HMAC-SHA256(0x5a * 32, "unknown-user")[:16], computed independently. */ + uint8_t expect[CREDENTIAL_SALT_LEN]; + unhex("4b0d2e6b73025cc2fcb41d0a710ff469", expect, sizeof(expect)); + EXPECT_TRUE(memcmp(v1.salt, expect, sizeof(expect)) == 0); + credentials_free(store); + + /* The adopted sidecar still holds exactly the pre-created key (not a fresh + * random one). */ + uint8_t readback[CREDENTIAL_KEY_LEN]; + fp = fopen(sidecar, "rb"); + EXPECT_NOT_NULL(fp); + EXPECT_TRUE(fread(readback, 1, sizeof(readback), fp) == sizeof(readback)); + fclose(fp); + EXPECT_TRUE(memcmp(readback, key, sizeof(key)) == 0); + + /* Persisted across a reload. */ + CredentialVerifier v2; + store = credentials_load(path, NULL, err, sizeof(err)); + EXPECT_NOT_NULL(store); + EXPECT_TRUE(credentials_get_verifier(store, "unknown-user", NULL, 0, &v2)); + EXPECT_TRUE(memcmp(v1.salt, v2.salt, sizeof(v1.salt)) == 0); + credentials_free(store); + + rm_temp(path); + free(path); + free(sidecar); +} + +/* A symlink planted at the sidecar path must fail the load closed (O_NOFOLLOW), + * even when it resolves to a valid owner-only key file. */ +static void test_credentials_dummy_key_symlink_rejected() { + char line[CREDENTIAL_MAX_LINE]; + EXPECT_TRUE(make_store_line("alice", KAT_PASSWORD, CREDENTIAL_MIN_ITERS, line, sizeof(line))); + char contents[CREDENTIAL_MAX_LINE + 2]; + snprintf(contents, sizeof(contents), "%s\n", line); + char* path = make_tmp_file(contents); + EXPECT_NOT_NULL(path); + char* sidecar = dummy_sidecar_path(path); + EXPECT_NOT_NULL(sidecar); + + char target[256]; + snprintf(target, sizeof(target), "/tmp/fs_cred_key_%d_%d", (int)getpid(), g_file_counter++); + uint8_t key[CREDENTIAL_KEY_LEN]; + memset(key, 0x5a, sizeof(key)); + FILE* fp = fopen(target, "wb"); + EXPECT_NOT_NULL(fp); + EXPECT_TRUE(fwrite(key, 1, sizeof(key), fp) == sizeof(key)); + fclose(fp); + EXPECT_EQ_INT(chmod(target, 0600), 0); + EXPECT_EQ_INT(symlink(target, sidecar), 0); + + char err[512]; + EXPECT_NULL(credentials_load(path, NULL, err, sizeof(err))); + EXPECT_TRUE(err[0] != '\0'); + + unlink(sidecar); /* remove the symlink itself, not its target */ + unlink(target); + rm_temp(path); + free(path); + free(sidecar); } /* A NULL store path has nowhere to persist a key, so each load gets a fresh @@ -923,6 +1038,8 @@ void test_credentials(void) { test_credentials_rejects_group_or_other_accessible(); test_credentials_dummy_key_persisted(); test_credentials_dummy_key_rejects_bad_sidecar(); + test_credentials_dummy_key_existing_sidecar_adopted(); + test_credentials_dummy_key_symlink_rejected(); test_credentials_dummy_key_null_store_ephemeral(); test_credentials_burn(); }