Merge feat/dummy-key: persist anti-enumeration dummy key across restarts
# Conflicts: # RSYNC_COMPAT.md
This commit is contained in:
@@ -522,11 +522,17 @@ deterministic per-username dummy challenge, so probing the daemon cannot
|
|||||||
enumerate users. Store lines are generated with
|
enumerate users. Store lines are generated with
|
||||||
`fastsync-server --hash-credentials <plaintext-file>` (see `RSYNC_COMPAT.md`);
|
`fastsync-server --hash-credentials <plaintext-file>` (see `RSYNC_COMPAT.md`);
|
||||||
redirect that output to an owner-only (mode 0600) file, and note that legacy
|
redirect that output to an owner-only (mode 0600) file, and note that legacy
|
||||||
`user:SHA256HEX` stores are rejected. Two residuals are accepted: the dummy salt
|
`user:SHA256HEX` stores are rejected. FastSync also maintains an owner-only
|
||||||
is stable within one daemon lifetime but changes across restarts, so a
|
(mode 0600) `<store>.dummykey` sidecar next to the store: it holds the store-wide
|
||||||
restart-gated enumeration channel remains (persisting a dummy key is out of
|
dummy key, is auto-created on first load, and must be preserved across daemon
|
||||||
scope); and the store iteration count is observable pre-auth by design, since
|
restarts so the dummy challenge for an unknown user stays stable (the key is
|
||||||
the miss path must match a hit.
|
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.
|
||||||
|
|
||||||
An `auth users` module accepts credentials only when one of two conditions
|
An `auth users` module accepts credentials only when one of two conditions
|
||||||
holds: (a) the connection is an encrypted, verified TLS connection whose client
|
holds: (a) the connection is an encrypted, verified TLS connection whose client
|
||||||
|
|||||||
+2
-2
@@ -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.
|
- **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.
|
- **`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.
|
- **`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. Two residuals are accepted: the dummy salt is stable within one daemon lifetime but changes across restarts, leaving a restart-gated enumeration channel (persisting the dummy key is out of scope); and the store iteration count is observable pre-auth by design, since the miss path must match a hit. **Transport policy (hardening A7-3/S1):** an auth-required module accepts credentials only when either (a) the connection is an encrypted, verified TLS connection whose client certificate matches `--client-cn`, or (b) the connection is plaintext from a loopback TCP peer **and** the operator explicitly passed `--allow-unauthenticated`. A remote plaintext peer, and a loopback plaintext peer without that flag, are refused at the config gate before any challenge is sent; `--allow-unauthenticated` never permits remote plaintext auth (remote peers still require verified TLS). Daemon modules are a `--daemon`-only feature — the SSH `--stdio` path never loads a daemon config and is not an auth transport for them. Because the loopback allowance trusts whichever peer the kernel reports as `127.0.0.1`, it assumes nothing relays remote connections to the daemon: a local TCP forwarder or TLS-terminating proxy in front of an auth-module listener makes remote clients appear as loopback and bypasses the mutual-TLS identity check, so do not front an auth-module listener with such a relay.
|
- **`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 `<store_path>.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. **Transport policy (hardening A7-3/S1):** an auth-required module accepts credentials only when either (a) the connection is an encrypted, verified TLS connection whose client certificate matches `--client-cn`, or (b) the connection is plaintext from a loopback TCP peer **and** the operator explicitly passed `--allow-unauthenticated`. A remote plaintext peer, and a loopback plaintext peer without that flag, are refused at the config gate before any challenge is sent; `--allow-unauthenticated` never permits remote plaintext auth (remote peers still require verified TLS). Daemon modules are a `--daemon`-only feature — the SSH `--stdio` path never loads a daemon config and is not an auth transport for them. Because the loopback allowance trusts whichever peer the kernel reports as `127.0.0.1`, it assumes nothing relays remote connections to the daemon: a local TCP forwarder or TLS-terminating proxy in front of an auth-module listener makes remote clients appear as loopback and bypasses the mutual-TLS identity check, so do not front an auth-module listener with such a relay.
|
||||||
- **Credential store format:** server `--password-file`/`--early-input` files are line-based `user:$fastsync$1$pbkdf2-sha256$<iters>$<salt_b64>$<stored_key_b64>$<server_key_b64>`, 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.
|
- **Credential store format:** server `--password-file`/`--early-input` files are line-based `user:$fastsync$1$pbkdf2-sha256$<iters>$<salt_b64>$<stored_key_b64>$<server_key_b64>`, 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 `<store_path>.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:** an auth-required module is refused, **before any challenge is sent**, unless the connection is encrypted and verified TLS whose client certificate matches the server's `--client-cn`, or it is plaintext from a loopback TCP peer **and** the operator passed `--allow-unauthenticated`. A remote plaintext peer, and a loopback plaintext peer without that flag, never receive a challenge, and `--allow-unauthenticated` never permits remote plaintext auth (remote peers still require verified TLS). On the loopback plaintext transport that remains permitted, a local sniffer could still read the challenge and response and mount an **offline dictionary attack** against a weak password, so use `--tls` for any real deployment. `--client-cn` matches the certificate CN only (not a subjectAltName), which is acceptable for a private CA. Clients sending daemon credentials with `--password-file` to a non-loopback daemon must use `--tls`; the client rejects such a destination before any network I/O. 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). TLS client-CN (`--client-cn`) is an independent transport identity check and composes with password auth: both may be required on the same connection.
|
- **Plaintext caveat:** an auth-required module is refused, **before any challenge is sent**, unless the connection is encrypted and verified TLS whose client certificate matches the server's `--client-cn`, or it is plaintext from a loopback TCP peer **and** the operator passed `--allow-unauthenticated`. A remote plaintext peer, and a loopback plaintext peer without that flag, never receive a challenge, and `--allow-unauthenticated` never permits remote plaintext auth (remote peers still require verified TLS). On the loopback plaintext transport that remains permitted, a local sniffer could still read the challenge and response and mount an **offline dictionary attack** against a weak password, so use `--tls` for any real deployment. `--client-cn` matches the certificate CN only (not a subjectAltName), which is acceptable for a private CA. Clients sending daemon credentials with `--password-file` to a non-loopback daemon must use `--tls`; the client rejects such a destination before any network I/O. 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). 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.
|
- **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.
|
- **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.
|
||||||
|
|||||||
+329
-11
@@ -1,4 +1,5 @@
|
|||||||
#include "credentials.h"
|
#include "credentials.h"
|
||||||
|
#include "log.h"
|
||||||
#include "utils.h"
|
#include "utils.h"
|
||||||
#include <ctype.h>
|
#include <ctype.h>
|
||||||
#include <errno.h>
|
#include <errno.h>
|
||||||
@@ -35,16 +36,22 @@ struct CredentialStore {
|
|||||||
* challenged with the same count as a hit and the count itself never leaks
|
* challenged with the same count as a hit and the count itself never leaks
|
||||||
* membership. Unused (0) for an empty store. */
|
* membership. Unused (0) for an empty store. */
|
||||||
uint32_t iters;
|
uint32_t iters;
|
||||||
/* Random secret generated once at load. The dummy salt handed out for an
|
/* Store-wide secret loaded from (or created in) the exact-mode-0600
|
||||||
* unknown/off-list user is HMAC-SHA256(dummy_key, username)[:SALT_LEN], so
|
* `<store_path>.dummykey` sidecar, so it also survives a daemon restart. The
|
||||||
* repeated probes of the same username always see an identical challenge
|
* dummy salt handed out for an unknown/off-list user is
|
||||||
* while different usernames differ -- with no fresh-random tell. */
|
* HMAC-SHA256(dummy_key, username)[:SALT_LEN], so repeated probes of the same
|
||||||
|
* username always see an identical challenge while different usernames differ
|
||||||
|
* -- with no fresh-random tell, and cross-restart stability hides the
|
||||||
|
* restart-gated enumeration oracle. */
|
||||||
uint8_t dummy_key[CREDENTIAL_KEY_LEN];
|
uint8_t dummy_key[CREDENTIAL_KEY_LEN];
|
||||||
};
|
};
|
||||||
|
|
||||||
/* Exact marker prefix of the new store verifier field. */
|
/* Exact marker prefix of the new store verifier field. */
|
||||||
#define CREDENTIAL_STORE_PREFIX "$fastsync$1$pbkdf2-sha256$"
|
#define CREDENTIAL_STORE_PREFIX "$fastsync$1$pbkdf2-sha256$"
|
||||||
#define CREDENTIAL_AUTH_PREFIX "FastSync-Auth-v1"
|
#define CREDENTIAL_AUTH_PREFIX "FastSync-Auth-v1"
|
||||||
|
/* Exact-mode-0600 sidecar holding the persistent store-wide dummy key, placed
|
||||||
|
* next to the credential store (`<store_path>.dummykey`). */
|
||||||
|
#define CREDENTIAL_DUMMY_KEY_SUFFIX ".dummykey"
|
||||||
|
|
||||||
/* Fixed dummy keys used when a user is unknown or off the module's list. They
|
/* Fixed dummy keys used when a user is unknown or off the module's list. They
|
||||||
* can never authenticate because acceptance additionally requires found=true. */
|
* can never authenticate because acceptance additionally requires found=true. */
|
||||||
@@ -66,7 +73,10 @@ static bool is_comment_char(char c) {
|
|||||||
|
|
||||||
/* Open a --password-file / --early-input after verifying the EXACT inode we
|
/* Open a --password-file / --early-input after verifying the EXACT inode we
|
||||||
* will read: it must be owned by the effective user and grant no group/other
|
* will read: it must be owned by the effective user and grant no group/other
|
||||||
* permission bit (mode 0600), mirroring the TLS private-key check. We open by
|
* permission bit (so 0600 and stricter modes such as 0400 are accepted),
|
||||||
|
* mirroring the TLS private-key check. This only rejects group/other bits,
|
||||||
|
* deliberately unlike the dummy-key sidecar which requires EXACT mode 0600. We
|
||||||
|
* open by
|
||||||
* path and then fstat the resulting fd (rather than stat()ing the path first
|
* path and then fstat the resulting fd (rather than stat()ing the path first
|
||||||
* and reopening it), so the permission decision is made on the same inode that
|
* and reopening it), so the permission decision is made on the same inode that
|
||||||
* is read and cannot be raced by swapping the path between check and open.
|
* is read and cannot be raced by swapping the path between check and open.
|
||||||
@@ -583,6 +593,311 @@ static CredentialStore* load_store_file(const char* path, char* err, size_t err_
|
|||||||
return store;
|
return store;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Validate and read an already-open `<store>.dummykey` sidecar. Fails closed on
|
||||||
|
* anything that is not an exact-mode-0600 regular file of exactly
|
||||||
|
* CREDENTIAL_KEY_LEN bytes, so a loosened, swapped or truncated file can never
|
||||||
|
* silently change the dummy challenge. */
|
||||||
|
static bool read_dummy_key_fd(int fd, const char* path, uint8_t out[CREDENTIAL_KEY_LEN], char* err,
|
||||||
|
size_t err_size) {
|
||||||
|
struct stat st;
|
||||||
|
if (fstat(fd, &st) != 0) {
|
||||||
|
set_error(err, err_size, "cannot stat dummy key file '%s': %s", path, strerror(errno));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!S_ISREG(st.st_mode) || st.st_uid != geteuid() || (st.st_mode & 07777) != 0600 ||
|
||||||
|
st.st_size != (off_t)CREDENTIAL_KEY_LEN) {
|
||||||
|
set_error(err, err_size,
|
||||||
|
"refusing to read dummy key file '%s': it must be an owned regular file with exact "
|
||||||
|
"mode 0600 and exactly %d bytes",
|
||||||
|
path, CREDENTIAL_KEY_LEN);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
size_t got = 0;
|
||||||
|
while (got < CREDENTIAL_KEY_LEN) {
|
||||||
|
ssize_t n = read(fd, out + got, CREDENTIAL_KEY_LEN - got);
|
||||||
|
if (n < 0) {
|
||||||
|
if (errno == EINTR)
|
||||||
|
continue;
|
||||||
|
set_error(err, err_size, "cannot read dummy key file '%s': %s", path, strerror(errno));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (n == 0)
|
||||||
|
break;
|
||||||
|
got += (size_t)n;
|
||||||
|
}
|
||||||
|
if (got != CREDENTIAL_KEY_LEN) {
|
||||||
|
set_error(err, err_size, "dummy key file '%s' is truncated", path);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* fsync the directory containing `path` (best effort). After publishing the
|
||||||
|
* sidecar with link(2), syncing the directory makes the new name durable so a
|
||||||
|
* crash cannot leave a restart without the key it just started using. */
|
||||||
|
static void fsync_containing_dir(const char* path) {
|
||||||
|
char* dir = str_dup(path);
|
||||||
|
if (!dir)
|
||||||
|
return;
|
||||||
|
char* slash = strrchr(dir, '/');
|
||||||
|
if (!slash) {
|
||||||
|
free(dir);
|
||||||
|
dir = str_dup(".");
|
||||||
|
if (!dir)
|
||||||
|
return;
|
||||||
|
} else if (slash == dir) {
|
||||||
|
slash[1] = '\0'; /* keep the leading '/' */
|
||||||
|
} else {
|
||||||
|
*slash = '\0';
|
||||||
|
}
|
||||||
|
int dfd = open(dir, O_RDONLY | O_DIRECTORY | O_CLOEXEC);
|
||||||
|
free(dir);
|
||||||
|
if (dfd < 0)
|
||||||
|
return;
|
||||||
|
fsync(dfd);
|
||||||
|
close(dfd);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Load the persistent dummy key for `store_path` from its `<store_path>.dummykey`
|
||||||
|
* sidecar, creating it (exact mode 0600, 32 random bytes) if absent. A NULL
|
||||||
|
* store_path (empty store) yields a fresh ephemeral key. Reading an existing
|
||||||
|
* sidecar fails CLOSED on any validation error; only the CREATE path degrades
|
||||||
|
* to an ephemeral key (with a warning) when the filesystem cannot hold the
|
||||||
|
* sidecar (e.g. read-only mount), so a daemon still starts.
|
||||||
|
*
|
||||||
|
* Creation is ATOMIC: the key is written to a private same-directory temp file
|
||||||
|
* and hard-linked into place, so a concurrent starter (or reader) never observes
|
||||||
|
* a partial/zero sidecar that would fail the load closed. Returns false only
|
||||||
|
* when the CSPRNG itself fails (or a present-but-invalid sidecar is found). */
|
||||||
|
static bool load_or_create_dummy_key(const char* store_path, uint8_t out[CREDENTIAL_KEY_LEN],
|
||||||
|
char* err, size_t err_size) {
|
||||||
|
if (!store_path) {
|
||||||
|
if (!credentials_random_bytes(out, CREDENTIAL_KEY_LEN)) {
|
||||||
|
set_error(err, err_size, "failed to generate the credential store dummy key");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
size_t path_len = strlen(store_path);
|
||||||
|
size_t suffix_len = sizeof(CREDENTIAL_DUMMY_KEY_SUFFIX); /* includes the NUL */
|
||||||
|
if (path_len > SIZE_MAX - suffix_len) {
|
||||||
|
set_error(err, err_size, "credential store path is too long to build a dummy key path");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
char* sidecar = malloc(path_len + suffix_len);
|
||||||
|
if (!sidecar) {
|
||||||
|
set_error(err, err_size, "out of memory building the dummy key path");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
int n = snprintf(sidecar, path_len + suffix_len, "%s%s", store_path, CREDENTIAL_DUMMY_KEY_SUFFIX);
|
||||||
|
if (n < 0 || (size_t)n >= path_len + suffix_len) {
|
||||||
|
set_error(err, err_size, "credential store path is too long to build a dummy key path");
|
||||||
|
free(sidecar);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Readers reject a planted symlink (O_NOFOLLOW) and never block on a planted
|
||||||
|
* FIFO (O_NONBLOCK; fstat rejects the non-regular file before any data read).
|
||||||
|
* Any open error other than ENOENT fails closed. */
|
||||||
|
int fd = open(sidecar, O_RDONLY | O_NOFOLLOW | O_NONBLOCK | O_CLOEXEC);
|
||||||
|
if (fd >= 0) {
|
||||||
|
bool ok = read_dummy_key_fd(fd, sidecar, out, err, err_size);
|
||||||
|
close(fd);
|
||||||
|
free(sidecar);
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
if (errno != ENOENT) {
|
||||||
|
/* The sidecar exists but cannot be opened for reading (EACCES, or ELOOP
|
||||||
|
* from a symlink): fail closed rather than substituting a different key. */
|
||||||
|
set_error(err, err_size, "cannot open dummy key file '%s': %s", sidecar, strerror(errno));
|
||||||
|
free(sidecar);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Publish atomically: write a private same-directory temp file, fsync it,
|
||||||
|
* then hard-link it into place. A concurrent reader therefore only ever
|
||||||
|
* sees a complete 32-byte sidecar (or none), never a partial/zero file.
|
||||||
|
*
|
||||||
|
* The temp name carries both the pid and a fresh random suffix, so it is not
|
||||||
|
* predictable. If the name nevertheless already exists (a SIGKILL/crash
|
||||||
|
* leftover, pid reuse, or a planted file) the stale temp is removed and the
|
||||||
|
* O_EXCL create is retried once, so it can never silently defeat persistence
|
||||||
|
* for this pid. */
|
||||||
|
uint8_t fresh[CREDENTIAL_KEY_LEN];
|
||||||
|
if (!credentials_random_bytes(fresh, CREDENTIAL_KEY_LEN)) {
|
||||||
|
set_error(err, err_size, "failed to generate the credential store dummy key");
|
||||||
|
free(sidecar);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint8_t name_rand[8];
|
||||||
|
if (!credentials_random_bytes(name_rand, sizeof(name_rand))) {
|
||||||
|
set_error(err, err_size, "failed to generate the dummy key temp name");
|
||||||
|
free(sidecar);
|
||||||
|
credentials_burn((char*)fresh, sizeof(fresh));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
char name_hex[sizeof(name_rand) * 2 + 1];
|
||||||
|
static const char hex_digits[] = "0123456789abcdef";
|
||||||
|
for (size_t i = 0; i < sizeof(name_rand); i++) {
|
||||||
|
name_hex[2 * i] = hex_digits[name_rand[i] >> 4];
|
||||||
|
name_hex[2 * i + 1] = hex_digits[name_rand[i] & 0x0f];
|
||||||
|
}
|
||||||
|
name_hex[sizeof(name_hex) - 1] = '\0';
|
||||||
|
|
||||||
|
char tmp_suffix[64];
|
||||||
|
int pn = snprintf(tmp_suffix, sizeof(tmp_suffix), ".tmp.%ld.%s", (long)getpid(), name_hex);
|
||||||
|
if (pn < 0 || (size_t)pn >= sizeof(tmp_suffix)) {
|
||||||
|
set_error(err, err_size, "failed to build the dummy key temp path");
|
||||||
|
free(sidecar);
|
||||||
|
credentials_burn((char*)fresh, sizeof(fresh));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
size_t sidecar_len = (size_t)n;
|
||||||
|
size_t tmp_len = sidecar_len + (size_t)pn;
|
||||||
|
char* tmp = malloc(tmp_len + 1);
|
||||||
|
if (!tmp) {
|
||||||
|
set_error(err, err_size, "out of memory building the dummy key temp path");
|
||||||
|
free(sidecar);
|
||||||
|
credentials_burn((char*)fresh, sizeof(fresh));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
snprintf(tmp, tmp_len + 1, "%s%s", sidecar, tmp_suffix);
|
||||||
|
|
||||||
|
/* Bounded create: at most one unlink+retry on EEXIST. The retry keeps
|
||||||
|
* O_EXCL, so only a stale name is reclaimed and a live peer's temp is never
|
||||||
|
* truncated. */
|
||||||
|
int create_errno = 0;
|
||||||
|
for (int attempt = 0; attempt < 2; attempt++) {
|
||||||
|
fd = open(tmp, O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC, 0600);
|
||||||
|
if (fd >= 0)
|
||||||
|
break;
|
||||||
|
create_errno = errno;
|
||||||
|
if (create_errno != EEXIST || attempt == 1)
|
||||||
|
break;
|
||||||
|
unlink(tmp);
|
||||||
|
}
|
||||||
|
/* umask can clear owner bits from the 0600 create mode while the reader
|
||||||
|
* requires an exact 0600, so force the mode on the fd before publishing; a
|
||||||
|
* failure here is treated like any other create failure (warning + ephemeral
|
||||||
|
* key) so the published sidecar is always exactly 0600. */
|
||||||
|
if (fd >= 0 && fchmod(fd, 0600) != 0) {
|
||||||
|
create_errno = errno;
|
||||||
|
close(fd);
|
||||||
|
unlink(tmp);
|
||||||
|
fd = -1;
|
||||||
|
}
|
||||||
|
if (fd < 0) {
|
||||||
|
/* Creation failed (read-only filesystem, missing directory, fchmod, ...).
|
||||||
|
* Warn and fall back to an ephemeral key: unknown-user challenges stay
|
||||||
|
* deterministic within this daemon lifetime but will change on restart. */
|
||||||
|
char* escaped = output_escape(tmp, log_get_8_bit_output());
|
||||||
|
log_message(LOG_LEVEL_WARNING,
|
||||||
|
"cannot create dummy key file %s: %s; using a transient dummy key so unknown-user "
|
||||||
|
"challenges will change across restarts",
|
||||||
|
escaped ? escaped : tmp, strerror(create_errno));
|
||||||
|
free(escaped);
|
||||||
|
memcpy(out, fresh, CREDENTIAL_KEY_LEN);
|
||||||
|
free(tmp);
|
||||||
|
free(sidecar);
|
||||||
|
credentials_burn((char*)fresh, sizeof(fresh));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
size_t written = 0;
|
||||||
|
bool write_ok = true;
|
||||||
|
int write_errno = 0;
|
||||||
|
while (written < CREDENTIAL_KEY_LEN) {
|
||||||
|
ssize_t w = write(fd, fresh + written, CREDENTIAL_KEY_LEN - written);
|
||||||
|
if (w < 0) {
|
||||||
|
if (errno == EINTR)
|
||||||
|
continue;
|
||||||
|
write_ok = false;
|
||||||
|
write_errno = errno;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (w == 0) {
|
||||||
|
/* A zero-length write is not a system error; errno is stale here, so
|
||||||
|
* report a clear short-write instead of a bogus strerror(errno). */
|
||||||
|
write_ok = false;
|
||||||
|
write_errno = 0;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
written += (size_t)w;
|
||||||
|
}
|
||||||
|
if (write_ok && fsync(fd) != 0) {
|
||||||
|
write_ok = false;
|
||||||
|
write_errno = errno;
|
||||||
|
}
|
||||||
|
close(fd);
|
||||||
|
if (!write_ok) {
|
||||||
|
/* Do not leave a truncated temp file behind; fall back to an ephemeral key
|
||||||
|
* instead of failing closed on the next restart. */
|
||||||
|
unlink(tmp);
|
||||||
|
char* escaped = output_escape(tmp, log_get_8_bit_output());
|
||||||
|
const char* why = write_errno != 0 ? strerror(write_errno) : "short write";
|
||||||
|
log_message(LOG_LEVEL_WARNING,
|
||||||
|
"cannot write dummy key file %s: %s; using a transient dummy key so unknown-user "
|
||||||
|
"challenges will change across restarts",
|
||||||
|
escaped ? escaped : tmp, why);
|
||||||
|
free(escaped);
|
||||||
|
memcpy(out, fresh, CREDENTIAL_KEY_LEN);
|
||||||
|
free(tmp);
|
||||||
|
free(sidecar);
|
||||||
|
credentials_burn((char*)fresh, sizeof(fresh));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (link(tmp, sidecar) != 0) {
|
||||||
|
int link_errno = errno;
|
||||||
|
if (link_errno == EEXIST) {
|
||||||
|
/* A concurrent starter published first; adopt its key. Read it back
|
||||||
|
* through the same hardened path (no symlink, no block, exact mode). */
|
||||||
|
int rfd = open(sidecar, O_RDONLY | O_NOFOLLOW | O_NONBLOCK | O_CLOEXEC);
|
||||||
|
if (rfd < 0) {
|
||||||
|
set_error(err, err_size, "cannot open dummy key file '%s': %s", sidecar, strerror(errno));
|
||||||
|
unlink(tmp);
|
||||||
|
free(tmp);
|
||||||
|
free(sidecar);
|
||||||
|
credentials_burn((char*)fresh, sizeof(fresh));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
bool ok = read_dummy_key_fd(rfd, sidecar, out, err, err_size);
|
||||||
|
close(rfd);
|
||||||
|
unlink(tmp);
|
||||||
|
free(tmp);
|
||||||
|
free(sidecar);
|
||||||
|
credentials_burn((char*)fresh, sizeof(fresh));
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
/* Linking failed for another reason (e.g. no hard-link support on this
|
||||||
|
* filesystem). Warn and fall back to an ephemeral key. */
|
||||||
|
unlink(tmp);
|
||||||
|
char* escaped = output_escape(sidecar, log_get_8_bit_output());
|
||||||
|
log_message(LOG_LEVEL_WARNING,
|
||||||
|
"cannot publish dummy key file %s: %s; using a transient dummy key so unknown-user "
|
||||||
|
"challenges will change across restarts",
|
||||||
|
escaped ? escaped : sidecar, strerror(link_errno));
|
||||||
|
free(escaped);
|
||||||
|
memcpy(out, fresh, CREDENTIAL_KEY_LEN);
|
||||||
|
free(tmp);
|
||||||
|
free(sidecar);
|
||||||
|
credentials_burn((char*)fresh, sizeof(fresh));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Published: make the new directory entry durable, then drop the private
|
||||||
|
* temp name (the sidecar keeps the inode alive). */
|
||||||
|
fsync_containing_dir(sidecar);
|
||||||
|
unlink(tmp);
|
||||||
|
memcpy(out, fresh, CREDENTIAL_KEY_LEN);
|
||||||
|
free(tmp);
|
||||||
|
free(sidecar);
|
||||||
|
credentials_burn((char*)fresh, sizeof(fresh));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
CredentialStore* credentials_load(const char* password_file, const char* early_input_file,
|
CredentialStore* credentials_load(const char* password_file, const char* early_input_file,
|
||||||
char* err, size_t err_size) {
|
char* err, size_t err_size) {
|
||||||
if (err && err_size)
|
if (err && err_size)
|
||||||
@@ -590,12 +905,15 @@ CredentialStore* credentials_load(const char* password_file, const char* early_i
|
|||||||
CredentialStore* store = load_store_file(password_file, err, err_size);
|
CredentialStore* store = load_store_file(password_file, err, err_size);
|
||||||
if (!store)
|
if (!store)
|
||||||
return NULL;
|
return NULL;
|
||||||
/* Generate the store-wide dummy key once for the final (possibly merged)
|
/* Load (or create) the store-wide dummy key once for the final (possibly
|
||||||
* store. It makes an unknown-user challenge deterministic, so fail the load
|
* merged) store. It makes an unknown-user challenge deterministic AND
|
||||||
* if the CSPRNG is unavailable rather than degrading the anti-enumeration
|
* stable across daemon restarts, so a restart cannot be used as a
|
||||||
* property. */
|
* username-enumeration oracle. It is persisted in an exact-mode-0600 sidecar
|
||||||
if (!credentials_random_bytes(store->dummy_key, sizeof(store->dummy_key))) {
|
* next to the credential store; a NULL store path (empty store) keeps it
|
||||||
set_error(err, err_size, "failed to generate the credential store dummy key");
|
* ephemeral. Fail the load if the CSPRNG is unavailable rather than
|
||||||
|
* degrading the anti-enumeration property. */
|
||||||
|
const char* store_path = password_file ? password_file : early_input_file;
|
||||||
|
if (!load_or_create_dummy_key(store_path, store->dummy_key, err, err_size)) {
|
||||||
credentials_free(store);
|
credentials_free(store);
|
||||||
return NULL;
|
return NULL;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,17 @@
|
|||||||
* hard-rejected with an actionable "legacy" error; there is no auto-upgrade.
|
* hard-rejected with an actionable "legacy" error; there is no auto-upgrade.
|
||||||
* Use `fastsync-server --hash-credentials` to generate new-format lines.
|
* Use `fastsync-server --hash-credentials` to generate new-format lines.
|
||||||
*
|
*
|
||||||
|
* Alongside the store, credentials_load maintains an exact-mode-0600
|
||||||
|
* `<store_path>.dummykey` sidecar holding the store-wide random dummy key. It
|
||||||
|
* is auto-created on first load and MUST be preserved across restarts: it makes
|
||||||
|
* the dummy challenge for an unknown user stable for the life of the store, so
|
||||||
|
* a daemon restart cannot be used as a username-enumeration oracle. A sidecar
|
||||||
|
* that is not an exact-mode-0600 regular file of exactly 32 bytes fails the load
|
||||||
|
* (fail closed); if it cannot be created (e.g. a read-only mount or a restrictive
|
||||||
|
* umask the fchmod cannot repair) the daemon warns and uses a transient per-run
|
||||||
|
* key instead. NOTE: the sidecar requires EXACT 0600, whereas the store /
|
||||||
|
* password files only reject group/other bits (a deliberate difference).
|
||||||
|
*
|
||||||
* Client --password-file format: the FIRST meaningful (non-comment, non-blank)
|
* Client --password-file format: the FIRST meaningful (non-comment, non-blank)
|
||||||
* line is `user:password`, holding the literal password. The client keeps it
|
* line is `user:password`, holding the literal password. The client keeps it
|
||||||
* only for the duration of the handshake and wipes it at teardown; the file
|
* only for the duration of the handshake and wipes it at teardown; the file
|
||||||
@@ -156,8 +167,9 @@ bool credentials_verify_response(const CredentialVerifier* v, const char* user,
|
|||||||
bool credentials_hash_store_line(const char* user, const char* password, uint32_t iters, char* out,
|
bool credentials_hash_store_line(const char* user, const char* password, uint32_t iters, char* out,
|
||||||
size_t out_sz, char* err, size_t err_size);
|
size_t out_sz, char* err, size_t err_size);
|
||||||
|
|
||||||
/* Read `user:password` lines from `path` (the same owner-only check as the
|
/* Read `user:password` lines from `path` (the same no-group/other-bits check as
|
||||||
* other secret files) and write one new-format store line per entry to `out`.
|
* the other secret files) and write one new-format store line per entry to
|
||||||
|
* `out`.
|
||||||
* Blank/comment lines are skipped; a malformed line fails the whole run.
|
* Blank/comment lines are skipped; a malformed line fails the whole run.
|
||||||
* Returns 0 on success, -1 on error (err filled). Used by
|
* Returns 0 on success, -1 on error (err filled). Used by
|
||||||
* `--hash-credentials`. */
|
* `--hash-credentials`. */
|
||||||
|
|||||||
+307
-2
@@ -3,6 +3,7 @@
|
|||||||
#include "test_utils.h"
|
#include "test_utils.h"
|
||||||
#include "utils.h"
|
#include "utils.h"
|
||||||
#include <errno.h>
|
#include <errno.h>
|
||||||
|
#include <glob.h>
|
||||||
#include <stdio.h>
|
#include <stdio.h>
|
||||||
#include <stdlib.h>
|
#include <stdlib.h>
|
||||||
#include <string.h>
|
#include <string.h>
|
||||||
@@ -70,8 +71,43 @@ static char* make_tmp_file(const char* contents) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static void rm_temp(const char* path) {
|
static void rm_temp(const char* path) {
|
||||||
if (path)
|
if (!path)
|
||||||
unlink(path);
|
return;
|
||||||
|
unlink(path);
|
||||||
|
/* Every successfully loaded store auto-creates an exact-mode-0600
|
||||||
|
* `<store>.dummykey` sidecar; remove it too so tests leave no stray key. The
|
||||||
|
* atomic-publish temps carry a random suffix, so glob them all and remove any
|
||||||
|
* that a failing path may have left behind. */
|
||||||
|
size_t n = strlen(path) + strlen(".dummykey") + 1;
|
||||||
|
char* sidecar = malloc(n);
|
||||||
|
if (sidecar) {
|
||||||
|
snprintf(sidecar, n, "%s.dummykey", path);
|
||||||
|
unlink(sidecar);
|
||||||
|
free(sidecar);
|
||||||
|
}
|
||||||
|
n = strlen(path) + strlen(".dummykey.tmp.*") + 1;
|
||||||
|
char* pattern = malloc(n);
|
||||||
|
if (pattern) {
|
||||||
|
snprintf(pattern, n, "%s.dummykey.tmp.*", path);
|
||||||
|
glob_t matches;
|
||||||
|
memset(&matches, 0, sizeof(matches));
|
||||||
|
if (glob(pattern, 0, NULL, &matches) == 0) {
|
||||||
|
for (size_t i = 0; i < matches.gl_pathc; i++)
|
||||||
|
unlink(matches.gl_pathv[i]);
|
||||||
|
}
|
||||||
|
globfree(&matches);
|
||||||
|
free(pattern);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* `<store>.dummykey` sidecar path (caller frees). */
|
||||||
|
static char* dummy_sidecar_path(const char* store_path) {
|
||||||
|
size_t n = strlen(store_path) + strlen(".dummykey") + 1;
|
||||||
|
char* out = malloc(n);
|
||||||
|
if (!out)
|
||||||
|
return NULL;
|
||||||
|
snprintf(out, n, "%s.dummykey", store_path);
|
||||||
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Build a valid new-format line for user/password at iters. */
|
/* Build a valid new-format line for user/password at iters. */
|
||||||
@@ -745,6 +781,269 @@ static void test_credentials_rejects_group_or_other_accessible() {
|
|||||||
free(path);
|
free(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Loading a store auto-creates an owner-only `<store>.dummykey` sidecar whose
|
||||||
|
* key is stable across reloads, so an unknown-user dummy salt is identical
|
||||||
|
* across two loads (the anti-restart enumeration property). */
|
||||||
|
static void test_credentials_dummy_key_persisted() {
|
||||||
|
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 err[512];
|
||||||
|
CredentialStore* store = credentials_load(path, NULL, err, sizeof(err));
|
||||||
|
EXPECT_NOT_NULL(store);
|
||||||
|
|
||||||
|
struct stat st;
|
||||||
|
EXPECT_EQ_INT(stat(sidecar, &st), 0);
|
||||||
|
EXPECT_TRUE(S_ISREG(st.st_mode));
|
||||||
|
EXPECT_TRUE((st.st_mode & (S_IRWXG | S_IRWXO)) == 0);
|
||||||
|
EXPECT_EQ_INT((int)(st.st_mode & 07777), 0600);
|
||||||
|
EXPECT_EQ_INT((int)st.st_size, CREDENTIAL_KEY_LEN);
|
||||||
|
|
||||||
|
CredentialVerifier v1;
|
||||||
|
EXPECT_TRUE(credentials_get_verifier(store, "unknown-user", NULL, 0, &v1));
|
||||||
|
EXPECT_FALSE(v1.found);
|
||||||
|
credentials_free(store);
|
||||||
|
|
||||||
|
store = credentials_load(path, NULL, err, sizeof(err));
|
||||||
|
EXPECT_NOT_NULL(store);
|
||||||
|
CredentialVerifier v2;
|
||||||
|
EXPECT_TRUE(credentials_get_verifier(store, "unknown-user", NULL, 0, &v2));
|
||||||
|
EXPECT_FALSE(v2.found);
|
||||||
|
EXPECT_TRUE(memcmp(v1.salt, v2.salt, sizeof(v1.salt)) == 0);
|
||||||
|
credentials_free(store);
|
||||||
|
|
||||||
|
rm_temp(path);
|
||||||
|
free(path);
|
||||||
|
free(sidecar);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A restrictive umask must not leave the freshly published sidecar with owner
|
||||||
|
* bits cleared: creation forces exact 0600 with fchmod (the reader requires an
|
||||||
|
* exact 0600), so the daemon cannot lock itself out on the next restart. */
|
||||||
|
static void test_credentials_dummy_key_exact_mode_under_umask() {
|
||||||
|
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);
|
||||||
|
|
||||||
|
/* Clear every permission bit the O_CREAT mode would otherwise provide; only
|
||||||
|
* the explicit fchmod can restore the exact 0600 the reader demands. */
|
||||||
|
mode_t old_umask = umask(0777);
|
||||||
|
char err[512];
|
||||||
|
CredentialStore* store = credentials_load(path, NULL, err, sizeof(err));
|
||||||
|
umask(old_umask);
|
||||||
|
EXPECT_NOT_NULL(store);
|
||||||
|
|
||||||
|
struct stat st;
|
||||||
|
EXPECT_EQ_INT(stat(sidecar, &st), 0);
|
||||||
|
EXPECT_EQ_INT((int)(st.st_mode & 07777), 0600);
|
||||||
|
EXPECT_EQ_INT((int)st.st_size, CREDENTIAL_KEY_LEN);
|
||||||
|
credentials_free(store);
|
||||||
|
|
||||||
|
rm_temp(path);
|
||||||
|
free(path);
|
||||||
|
free(sidecar);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A sidecar that is group/other accessible, the wrong size, or not a regular
|
||||||
|
* file must fail the load closed. */
|
||||||
|
static void test_credentials_dummy_key_rejects_bad_sidecar() {
|
||||||
|
char err[512];
|
||||||
|
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);
|
||||||
|
|
||||||
|
/* Group/other permission bits on the sidecar. */
|
||||||
|
char* path = make_tmp_file(contents);
|
||||||
|
EXPECT_NOT_NULL(path);
|
||||||
|
char* sidecar = dummy_sidecar_path(path);
|
||||||
|
EXPECT_NOT_NULL(sidecar);
|
||||||
|
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, 0640), 0);
|
||||||
|
EXPECT_NULL(credentials_load(path, NULL, err, sizeof(err)));
|
||||||
|
EXPECT_TRUE(err[0] != '\0');
|
||||||
|
rm_temp(path);
|
||||||
|
free(path);
|
||||||
|
free(sidecar);
|
||||||
|
|
||||||
|
/* Wrong size (not exactly 32 bytes). */
|
||||||
|
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, CREDENTIAL_SALT_LEN, fp) == CREDENTIAL_SALT_LEN);
|
||||||
|
fclose(fp);
|
||||||
|
EXPECT_EQ_INT(chmod(sidecar, 0600), 0);
|
||||||
|
EXPECT_NULL(credentials_load(path, NULL, err, sizeof(err)));
|
||||||
|
EXPECT_TRUE(err[0] != '\0');
|
||||||
|
rm_temp(path);
|
||||||
|
free(path);
|
||||||
|
free(sidecar);
|
||||||
|
|
||||||
|
/* Non-regular file (a directory at the sidecar path). */
|
||||||
|
path = make_tmp_file(contents);
|
||||||
|
EXPECT_NOT_NULL(path);
|
||||||
|
sidecar = dummy_sidecar_path(path);
|
||||||
|
EXPECT_NOT_NULL(sidecar);
|
||||||
|
EXPECT_EQ_INT(mkdir(sidecar, 0700), 0);
|
||||||
|
EXPECT_NULL(credentials_load(path, NULL, err, sizeof(err)));
|
||||||
|
EXPECT_TRUE(err[0] != '\0');
|
||||||
|
rmdir(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
|
||||||
|
* ephemeral key (and creates no sidecar). */
|
||||||
|
static void test_credentials_dummy_key_null_store_ephemeral() {
|
||||||
|
char err[512];
|
||||||
|
CredentialStore* store = credentials_load(NULL, NULL, err, sizeof(err));
|
||||||
|
EXPECT_NOT_NULL(store);
|
||||||
|
CredentialVerifier v1;
|
||||||
|
CredentialVerifier v1b;
|
||||||
|
EXPECT_TRUE(credentials_get_verifier(store, "nobody", NULL, 0, &v1));
|
||||||
|
EXPECT_FALSE(v1.found);
|
||||||
|
/* Within one store the dummy challenge is still deterministic. */
|
||||||
|
EXPECT_TRUE(credentials_get_verifier(store, "nobody", NULL, 0, &v1b));
|
||||||
|
EXPECT_TRUE(memcmp(v1.salt, v1b.salt, sizeof(v1.salt)) == 0);
|
||||||
|
credentials_free(store);
|
||||||
|
|
||||||
|
store = credentials_load(NULL, NULL, err, sizeof(err));
|
||||||
|
EXPECT_NOT_NULL(store);
|
||||||
|
CredentialVerifier v2;
|
||||||
|
EXPECT_TRUE(credentials_get_verifier(store, "nobody", NULL, 0, &v2));
|
||||||
|
/* No persistence path, so the second load's random key differs (and with it
|
||||||
|
* the dummy salt). */
|
||||||
|
EXPECT_TRUE(memcmp(v1.salt, v2.salt, sizeof(v1.salt)) != 0);
|
||||||
|
credentials_free(store);
|
||||||
|
}
|
||||||
|
|
||||||
static void test_credentials_burn() {
|
static void test_credentials_burn() {
|
||||||
char secret[32];
|
char secret[32];
|
||||||
memcpy(secret, "supersecretvalue", 17);
|
memcpy(secret, "supersecretvalue", 17);
|
||||||
@@ -777,5 +1076,11 @@ void test_credentials(void) {
|
|||||||
test_credentials_read_secret_file_bad();
|
test_credentials_read_secret_file_bad();
|
||||||
test_credentials_hash_file();
|
test_credentials_hash_file();
|
||||||
test_credentials_rejects_group_or_other_accessible();
|
test_credentials_rejects_group_or_other_accessible();
|
||||||
|
test_credentials_dummy_key_persisted();
|
||||||
|
test_credentials_dummy_key_exact_mode_under_umask();
|
||||||
|
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();
|
test_credentials_burn();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user