fix(review): close loopback TLS auth bypass; align docs and wrong-CN test
- server gate: the --allow-unauthenticated loopback allowance now requires an actual plaintext connection (!gate_ctx->ssl), so a loopback TLS client whose cert fails the --client-cn check is refused before any SCRAM challenge instead of falling through the plaintext opt-in. Keep the invalid-fd guard as belt-and-braces (unreachable after the policy check). - test: rewrote test_wrong_client_cn_refused_before_auth_challenge to run deterministically over 127.0.0.1 with --tls + --allow-unauthenticated and a CA-valid wrong-CN client cert, asserting the gate refusal log and an unchanged module tree (no skip). - docs: --client-cn is mandatory with --tls; dummykey sidecar is secret material; document all transient-fallback reasons; qualify --allow-unauthenticated in README and --help so it cannot read as permitting remote plaintext auth. - credentials.h: drop stale restrictive-umask claim (fchmod forces exact 0600; only create/write/fsync/link/fchmod failure degrades to ephemeral).
This commit is contained in:
@@ -145,7 +145,7 @@ partial, alternate, and planned behavior.
|
|||||||
| `--cert <path>` | TLS certificate file (PEM) |
|
| `--cert <path>` | TLS certificate file (PEM) |
|
||||||
| `--key <path>` | TLS private key file (PEM) |
|
| `--key <path>` | TLS private key file (PEM) |
|
||||||
| `--ca <path>` | TLS CA certificate file for verification (PEM) |
|
| `--ca <path>` | TLS CA certificate file for verification (PEM) |
|
||||||
| `--client-cn <name>` | Required TLS client certificate common name |
|
| `--client-cn <name>` | TLS client certificate common name; mandatory with `--tls` (a TLS connection always verifies the client CN) |
|
||||||
|
|
||||||
### Server
|
### Server
|
||||||
|
|
||||||
@@ -159,7 +159,7 @@ partial, alternate, and planned behavior.
|
|||||||
| `--ca <path>` | TLS CA certificate file for verification (PEM) |
|
| `--ca <path>` | TLS CA certificate file for verification (PEM) |
|
||||||
| `--destination-root <path>` | Authorized destination root (default: `.`) |
|
| `--destination-root <path>` | Authorized destination root (default: `.`) |
|
||||||
| `--allow-delete` | Permit manifest deletion |
|
| `--allow-delete` | Permit manifest deletion |
|
||||||
| `--allow-unauthenticated` | Permit plaintext TCP clients |
|
| `--allow-unauthenticated` | Permit plaintext TCP clients. For an `auth users` module this opts in **loopback plaintext only**; remote auth still requires verified TLS, so the flag never permits remote plaintext auth. |
|
||||||
| `-v, --verbose` | Enable debug logging |
|
| `-v, --verbose` | Enable debug logging |
|
||||||
| `--help` | Show help |
|
| `--help` | Show help |
|
||||||
|
|
||||||
@@ -526,11 +526,14 @@ redirect that output to an owner-only (mode 0600) file, and note that legacy
|
|||||||
(mode 0600) `<store>.dummykey` sidecar next to the store: it holds the store-wide
|
(mode 0600) `<store>.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
|
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
|
restarts so the dummy challenge for an unknown user stays stable (the key is
|
||||||
never regenerated while the sidecar exists). If the sidecar cannot be created
|
never regenerated while the sidecar exists). The sidecar is secret material and
|
||||||
(process-substitution/FIFO store path such as `/dev/fd/N`, a read-only
|
must be protected like the credential store: keep it owner-only (mode 0600) and
|
||||||
filesystem, or a missing directory), the daemon logs a warning and uses a
|
include it with the store in backups and credential rotation. If the sidecar
|
||||||
transient key, so the cross-restart guarantee does not hold for those
|
cannot be created (a process-substitution/FIFO store path such as `/dev/fd/N`, a
|
||||||
deployments. One residual is accepted: the store
|
read-only filesystem, a missing directory, or a create, write, fsync, link, or
|
||||||
|
fchmod failure), 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
|
iteration count is observable pre-auth by design, since the miss path must match
|
||||||
a hit.
|
a hit.
|
||||||
|
|
||||||
@@ -551,9 +554,10 @@ 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
|
`127.0.0.1`, it assumes nothing relays remote connections to the daemon. A local
|
||||||
TCP forwarder or a TLS-terminating proxy in front of an auth-module listener
|
TCP forwarder or a TLS-terminating proxy in front of an auth-module listener
|
||||||
makes remote clients appear as loopback and bypasses the mutual-TLS identity
|
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. Note also that
|
check, so do not front an auth-module listener with such a relay. `--tls` always
|
||||||
`--client-cn` matches the certificate's CN only (not a subjectAltName), which is
|
mandates `--client-cn`, so a TLS connection to an auth-required module always
|
||||||
acceptable for a private CA.
|
has its client CN verified (`--client-cn` matches the certificate's CN only, not
|
||||||
|
a subjectAltName, which is acceptable for a private CA).
|
||||||
|
|
||||||
TLS provides encrypted TCP transport. Supplying `--ca` enables certificate
|
TLS provides encrypted TCP transport. Supplying `--ca` enables certificate
|
||||||
verification; without it, traffic is encrypted but peer identity is not
|
verification; without it, traffic is encrypted but peer identity is not
|
||||||
|
|||||||
+3
-3
@@ -639,9 +639,9 @@ 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. 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.
|
- **`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 is secret material and must be protected like the credential store (owner-only 0600, included with the store in backups and rotation). It 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, a missing directory, or a create/write/fsync/link/fchmod failure), 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. 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.
|
- **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, a missing directory, or a create/write/fsync/link/fchmod failure), 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; because `--tls` already mandates `--client-cn`, a TLS auth connection always verifies the client CN, so both checks necessarily apply together on such a 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.
|
||||||
- **MOTD (Wave C):** a daemon configured with a global `motd file` sends that file's content as the first server→client string frame after the config-frame STATUS_OK ack (rsync sends the MOTD as the first thing from the server at the start of a daemon connection). Only the daemon listener path (`host::module`) gets a MOTD; the `--stdio` SSH path never sends or reads one. The server reads the file bounded to 4096 bytes and treats an absent/unreadable file as "no MOTD" (an empty frame, never an error). The exchange is server→client only and does **not** bump `PROTOCOL_VERSION`: every 2.15.0 daemon client reads the frame after the ack, so sender and receiver stay in lockstep (see the Wave C note in `src/shared/config.h`). `--no-motd` is the client-side suppression switch: the client still reads (consumes) the frame to keep the stream in sync but does not display it. The MOTD is printed to stdout with control bytes (ESC included) escaped octal-style while newlines/tabs are preserved, so a hostile server cannot inject terminal escape sequences.
|
- **MOTD (Wave C):** a daemon configured with a global `motd file` sends that file's content as the first server→client string frame after the config-frame STATUS_OK ack (rsync sends the MOTD as the first thing from the server at the start of a daemon connection). Only the daemon listener path (`host::module`) gets a MOTD; the `--stdio` SSH path never sends or reads one. The server reads the file bounded to 4096 bytes and treats an absent/unreadable file as "no MOTD" (an empty frame, never an error). The exchange is server→client only and does **not** bump `PROTOCOL_VERSION`: every 2.15.0 daemon client reads the frame after the ack, so sender and receiver stay in lockstep (see the Wave C note in `src/shared/config.h`). `--no-motd` is the client-side suppression switch: the client still reads (consumes) the frame to keep the stream in sync but does not display it. The MOTD is printed to stdout with control bytes (ESC included) escaped octal-style while newlines/tabs are preserved, so a hostile server cannot inject terminal escape sequences.
|
||||||
|
|||||||
+17
-9
@@ -384,16 +384,18 @@ static const char* server_module_gate(const Config* config, void* context) {
|
|||||||
}
|
}
|
||||||
/* Transport policy (A7-3/S1): an auth-required module only accepts
|
/* Transport policy (A7-3/S1): an auth-required module only accepts
|
||||||
* credentials over (a) an encrypted, verified TLS connection whose client
|
* credentials over (a) an encrypted, verified TLS connection whose client
|
||||||
* certificate matches --client-cn, or (b) a plaintext connection from a
|
* certificate matches --client-cn, or (b) an actual PLAINTEXT connection
|
||||||
* loopback peer that the operator explicitly opted into with
|
* from a loopback peer that the operator explicitly opted into with
|
||||||
* --allow-unauthenticated. A remote plaintext peer and an un-flagged
|
* --allow-unauthenticated. A remote plaintext peer, an un-flagged loopback
|
||||||
* loopback plaintext peer are both refused HERE, before the challenge is
|
* plaintext peer, and a loopback TLS peer whose certificate does not match
|
||||||
* sent, so an unverified client never receives a nonce. The operator flag
|
* --client-cn are all refused HERE, before the challenge is sent, so an
|
||||||
* never permits REMOTE plaintext auth: remote peers still require verified
|
* unverified client never receives a nonce: the loopback allowance requires
|
||||||
* TLS regardless of the flag. */
|
* !gate_ctx->ssl, so --tls + --allow-unauthenticated can never be used to
|
||||||
|
* bypass the client-CN check. The operator flag never permits REMOTE
|
||||||
|
* plaintext auth: remote peers still require verified TLS regardless. */
|
||||||
bool tls_ok = gate_ctx && gate_ctx->ssl && SSL_get_verify_result(gate_ctx->ssl) == X509_V_OK &&
|
bool tls_ok = gate_ctx && gate_ctx->ssl && SSL_get_verify_result(gate_ctx->ssl) == X509_V_OK &&
|
||||||
tls_client_identity_allowed(gate_ctx->ssl);
|
tls_client_identity_allowed(gate_ctx->ssl);
|
||||||
bool local_ok = allow_unauthenticated && gate_ctx && gate_ctx->fd >= 0 &&
|
bool local_ok = allow_unauthenticated && gate_ctx && !gate_ctx->ssl && gate_ctx->fd >= 0 &&
|
||||||
utils_fd_peer_is_local(gate_ctx->fd);
|
utils_fd_peer_is_local(gate_ctx->fd);
|
||||||
if (!tls_ok && !local_ok) {
|
if (!tls_ok && !local_ok) {
|
||||||
log_message(LOG_LEVEL_ERROR,
|
log_message(LOG_LEVEL_ERROR,
|
||||||
@@ -403,6 +405,10 @@ static const char* server_module_gate(const Config* config, void* context) {
|
|||||||
return "daemon module requires authentication over an encrypted, verified TLS "
|
return "daemon module requires authentication over an encrypted, verified TLS "
|
||||||
"connection";
|
"connection";
|
||||||
}
|
}
|
||||||
|
/* Belt-and-braces: the transport policy above already guarantees a context
|
||||||
|
* with a usable socket (verified TLS implies a live SSL object and loopback
|
||||||
|
* allowance requires gate_ctx->fd >= 0), so this is unreachable today; keep
|
||||||
|
* the guard so the handshake can never be driven over an invalid fd. */
|
||||||
if (!gate_ctx || gate_ctx->fd < 0) {
|
if (!gate_ctx || gate_ctx->fd < 0) {
|
||||||
log_message(LOG_LEVEL_ERROR, "daemon module '%s': no auth transport available",
|
log_message(LOG_LEVEL_ERROR, "daemon module '%s': no auth transport available",
|
||||||
config->module);
|
config->module);
|
||||||
@@ -756,7 +762,7 @@ static void print_server_usage(void) {
|
|||||||
printf(" --cert <path> TLS certificate file (PEM)\n");
|
printf(" --cert <path> TLS certificate file (PEM)\n");
|
||||||
printf(" --key <path> TLS private key file (PEM)\n");
|
printf(" --key <path> TLS private key file (PEM)\n");
|
||||||
printf(" --ca <path> TLS CA certificate file (PEM)\n");
|
printf(" --ca <path> TLS CA certificate file (PEM)\n");
|
||||||
printf(" --client-cn <name> Required TLS client certificate CN\n");
|
printf(" --client-cn <name> TLS client certificate CN (mandatory with --tls)\n");
|
||||||
printf(" --destination-root <path> Authorized destination root (default: .)\n");
|
printf(" --destination-root <path> Authorized destination root (default: .)\n");
|
||||||
printf(" --address <addr> Bind the listening socket to this address\n");
|
printf(" --address <addr> Bind the listening socket to this address\n");
|
||||||
printf(" -4, --ipv4 Bind an IPv4 socket (default)\n");
|
printf(" -4, --ipv4 Bind an IPv4 socket (default)\n");
|
||||||
@@ -772,6 +778,8 @@ static void print_server_usage(void) {
|
|||||||
printf(" client's CONVERT_SPEC). A name that cannot be\n");
|
printf(" client's CONVERT_SPEC). A name that cannot be\n");
|
||||||
printf(" represented fails the run cleanly\n");
|
printf(" represented fails the run cleanly\n");
|
||||||
printf(" --allow-unauthenticated Allow plaintext/anonymous network clients\n");
|
printf(" --allow-unauthenticated Allow plaintext/anonymous network clients\n");
|
||||||
|
printf(" (an auth-required module still accepts only opted-in\n");
|
||||||
|
printf(" loopback plaintext; remote auth requires verified TLS)\n");
|
||||||
printf(" --hash-credentials <file> Read <file>'s user:password lines and print\n");
|
printf(" --hash-credentials <file> Read <file>'s user:password lines and print\n");
|
||||||
printf(" PBKDF2 credential-store lines to stdout, then exit.\n");
|
printf(" PBKDF2 credential-store lines to stdout, then exit.\n");
|
||||||
printf(" Use the output as --password-file for --daemon;\n");
|
printf(" Use the output as --password-file for --daemon;\n");
|
||||||
|
|||||||
@@ -32,10 +32,11 @@
|
|||||||
* the dummy challenge for an unknown user stable for the life of the store, so
|
* 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
|
* 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
|
* 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
|
* (fail closed); creation forces exact 0600 with fchmod (so a restrictive umask
|
||||||
* umask the fchmod cannot repair) the daemon warns and uses a transient per-run
|
* cannot leave the sidecar unreadable), and only a create/write/fsync/link or
|
||||||
* key instead. NOTE: the sidecar requires EXACT 0600, whereas the store /
|
* fchmod failure degrades to a transient per-run key with a warning. NOTE: the
|
||||||
* password files only reject group/other bits (a deliberate difference).
|
* 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
|
||||||
|
|||||||
@@ -1154,20 +1154,16 @@ class TestDaemonTLSAuth:
|
|||||||
|
|
||||||
@pytest.mark.ci
|
@pytest.mark.ci
|
||||||
def test_wrong_client_cn_refused_before_auth_challenge(self):
|
def test_wrong_client_cn_refused_before_auth_challenge(self):
|
||||||
"""A7-3/S1: over a NON-local TLS connection an auth-required module is
|
"""A7-3/S1: with --tls AND --allow-unauthenticated, a loopback TLS peer
|
||||||
refused at the config gate when the CA-valid client certificate does not
|
whose CA-valid client certificate does not match --client-cn is still
|
||||||
match --client-cn -- before any SCRAM challenge is sent and before any
|
refused at the config gate -- before any SCRAM challenge is sent and
|
||||||
file data moves. The daemon is started WITH --allow-unauthenticated to
|
before any file data moves. The --allow-unauthenticated flag only opts
|
||||||
prove that flag never relaxes the remote auth-module transport policy
|
in loopback PLAINTEXT; it must never turn a wrong-CN TLS peer into an
|
||||||
(it only opts in plaintext from a loopback peer)."""
|
accepted auth transport. Runs over 127.0.0.1 so it is deterministic and
|
||||||
try:
|
never skips; the gate log line (emitted before server_auth_handshake)
|
||||||
remote_ip = socket.gethostbyname(socket.gethostname())
|
plus the unchanged module tree prove the refusal preceded any challenge."""
|
||||||
except OSError:
|
|
||||||
pytest.skip("hostname does not resolve")
|
|
||||||
if remote_ip.startswith("127."):
|
|
||||||
pytest.skip("host resolves to loopback; no non-loopback interface")
|
|
||||||
cert_dir = os.path.join(TEST_DATA_DIR, "daemon_tls_certs_wrong")
|
cert_dir = os.path.join(TEST_DATA_DIR, "daemon_tls_certs_wrong")
|
||||||
certs = _generate_tls_certs(cert_dir, extra_san_ips=[remote_ip])
|
certs = _generate_tls_certs(cert_dir)
|
||||||
client_creds = os.path.join(TEST_DATA_DIR, "daemon_tls_wrong_client.pw")
|
client_creds = os.path.join(TEST_DATA_DIR, "daemon_tls_wrong_client.pw")
|
||||||
_write_client_password_file(client_creds, "alice", ALICE_PASS)
|
_write_client_password_file(client_creds, "alice", ALICE_PASS)
|
||||||
d = DaemonManager()
|
d = DaemonManager()
|
||||||
@@ -1183,7 +1179,7 @@ class TestDaemonTLSAuth:
|
|||||||
tls_flags = ["--tls",
|
tls_flags = ["--tls",
|
||||||
"--cert", certs["wrong_client_cert"], "--key",
|
"--cert", certs["wrong_client_cert"], "--key",
|
||||||
certs["wrong_client_key"], "--ca", certs["ca"]]
|
certs["wrong_client_key"], "--ca", certs["ca"]]
|
||||||
result, _ = run_client(SOURCE_DIR, "%s::locked" % remote_ip, port=port,
|
result, _ = run_client(SOURCE_DIR, "127.0.0.1::locked", port=port,
|
||||||
flags=tls_flags, extra_args=["--password-file", client_creds])
|
flags=tls_flags, extra_args=["--password-file", client_creds])
|
||||||
assert result.returncode != 0, "a wrong client CN must be refused"
|
assert result.returncode != 0, "a wrong client CN must be refused"
|
||||||
assert _tree_file_count(AUTH_MODULE) == before_files, \
|
assert _tree_file_count(AUTH_MODULE) == before_files, \
|
||||||
|
|||||||
Reference in New Issue
Block a user