Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
378d881ca7 | ||
|
|
cc27ee1b83 | ||
|
|
d653c3e151 | ||
|
|
1b90ee2449 | ||
|
|
89b967f29a | ||
|
|
8f06ae5262 | ||
|
|
ac3c4c7c72 | ||
|
|
d53614d06b | ||
|
|
f0381a6b8e | ||
|
|
a7a1930e88 | ||
|
|
42f01c0968 | ||
|
|
2489d422e5 | ||
|
|
e2ddc0c07f | ||
|
|
1480716304 | ||
|
|
1de1376e54 | ||
|
|
eaf67f6257 | ||
|
|
8c94ec9886 | ||
|
|
87585e9881 |
No files matched your search
@@ -0,0 +1,65 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to FastSync are documented here. Versions match
|
||||
`PROTOCOL_VERSION` (printed by `fastsync --version`); the client and server must
|
||||
run the same version because the handshake is strict.
|
||||
|
||||
## [2.19.0] - 2026-09-12
|
||||
|
||||
### Security
|
||||
|
||||
- **Daemon authentication rewritten as SCRAM-SHA-256 challenge/response**
|
||||
(`STATUS_AUTH_CHALLENGE` → `STATUS_AUTH_RESPONSE` → `STATUS_AUTH_OK`/`STATUS_AUTH_FAILED`),
|
||||
replacing the old replayable static `SHA-256(password)` bearer credential.
|
||||
Each proof is bound to a fresh per-connection server nonce plus a client
|
||||
nonce, so a captured response can never be reused.
|
||||
- **Salted verifier store.** `--password-file`/`--early-input` now hold
|
||||
`user:$fastsync$1$pbkdf2-sha256$<iters>$<salt>$<stored_key>$<server_key>`
|
||||
(PBKDF2-HMAC-SHA256, default 600000 iterations, range 100000–10000000). The
|
||||
legacy `user:SHA256HEX` form is hard-rejected; there is no auto-upgrade.
|
||||
Generate stores offline with `fastsync-server --hash-credentials FILE
|
||||
[--iterations N]`.
|
||||
- **Username-enumeration hardening.** Unknown/off-list users are answered with a
|
||||
dummy verifier whose salt is a deterministic per-username value
|
||||
(`HMAC-SHA256(dummy_key, username)`), using the store-wide uniform iteration
|
||||
count and a constant-time full-length membership scan. The dummy key is
|
||||
persisted in an owner-only `<store>.dummykey` sidecar (atomic publish, exact
|
||||
mode 0600) so challenges are stable across restarts.
|
||||
- **Verified transport for auth-required modules.** A module with `auth users`
|
||||
accepts credentials only over verified TLS whose client certificate matches
|
||||
`--client-cn`, or — when `--allow-unauthenticated` is explicitly set —
|
||||
plaintext from a loopback peer. Remote plaintext is refused before any
|
||||
challenge. Clients must use `--tls` to send `--password-file` credentials to a
|
||||
non-loopback daemon; `--client-cn` is mandatory with `--tls`.
|
||||
- **Secret hygiene.** The plaintext password, derived keys, nonces/proofs and
|
||||
the dummy key are wiped from memory on every path and never logged.
|
||||
- Carried-over hardening: `-K` TOCTOU-safe directory walk
|
||||
(`openat(O_NOFOLLOW)` per component), always shell-quoted SSH remote path,
|
||||
TLS compression/renegotiation disabled, race-free (open-then-`fstat`)
|
||||
`--password-file`/`--early-input` checks, log-injection escaping, and lazy
|
||||
protocol debug escaping.
|
||||
|
||||
### Added
|
||||
|
||||
- `fastsync-server --hash-credentials FILE [--iterations N]` offline tool.
|
||||
- `<store>.dummykey` sidecar (auto-created, owner-only, 0600).
|
||||
- Integration tests for auth replay rejection, malformed frames, legacy-store
|
||||
refusal, and the loopback/TLS transport policy; fuzz targets for config
|
||||
receive and daemon-auth parsing.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Protocol version 2.18.0 → 2.19.0 (breaking).** The config-frame auth block
|
||||
is now `[present][username]` (digest removed) and the auth challenge/response
|
||||
frames are interleaved between the config frame and its `STATUS_OK`. A 2.19.0
|
||||
client and a 2.18.0 server (or vice versa) fail cleanly at the handshake.
|
||||
- Daemon modules declaring `auth users` require a configured credential store at
|
||||
startup (fail closed); operators regenerate stores from plaintext with
|
||||
`--hash-credentials`.
|
||||
|
||||
### Notes
|
||||
|
||||
- First tagged release. FastSync implements rsync-compatible file
|
||||
synchronization over TCP and SSH with TLS (OpenSSL), streaming zstd
|
||||
compression, multithreaded transfers, and incremental sync. See
|
||||
[RSYNC_COMPAT.md](RSYNC_COMPAT.md) for the flag-parity matrix.
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
cmake_minimum_required(VERSION 3.22)
|
||||
|
||||
project(FastFileTransfer)
|
||||
project(FastFileTransfer VERSION 2.19.0)
|
||||
|
||||
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
|
||||
set(CMAKE_C_STANDARD 11)
|
||||
|
||||
@@ -6,6 +6,10 @@ source/destination model and rsync-style options while adding optional
|
||||
multithreading, streaming zstd compression, chunking, zero-copy TCP transfers,
|
||||
and native TCP/TLS transports.
|
||||
|
||||
The release version is FastSync's client/server protocol version (printed by
|
||||
`fastsync --version`); client and server must match. See
|
||||
[CHANGELOG.md](CHANGELOG.md) for the history.
|
||||
|
||||
The compatibility target is straightforward:
|
||||
|
||||
- Existing rsync commands should keep the same meaning.
|
||||
@@ -145,7 +149,7 @@ partial, alternate, and planned behavior.
|
||||
| `--cert <path>` | TLS certificate file (PEM) |
|
||||
| `--key <path>` | TLS private key file (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
|
||||
|
||||
@@ -159,7 +163,7 @@ partial, alternate, and planned behavior.
|
||||
| `--ca <path>` | TLS CA certificate file for verification (PEM) |
|
||||
| `--destination-root <path>` | Authorized destination root (default: `.`) |
|
||||
| `--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 |
|
||||
| `--help` | Show help |
|
||||
|
||||
@@ -507,13 +511,58 @@ defaults to the current directory. |
|
||||
|
||||
## Protocol and Security
|
||||
|
||||
FastSync protocol version `2.18.0` is shared by the client and server. The
|
||||
FastSync protocol version `2.19.0` is shared by the client and server. The
|
||||
current protocol is sender-driven and includes configuration negotiation,
|
||||
including the maximum allocation limit, incremental checks, checksums,
|
||||
manifests, keep-alives, abort handling, per-file remove-source results, and
|
||||
FastSync-native delta messages.
|
||||
Client and server versions must currently match exactly.
|
||||
|
||||
Daemon modules that declare `auth users` authenticate with a SCRAM-SHA-256-style
|
||||
challenge/response against a salted PBKDF2 verifier store: no password and no
|
||||
replayable bearer credential crosses the wire or is stored on the daemon. All
|
||||
store entries share one iteration count, and an unknown user is answered with a
|
||||
deterministic per-username dummy challenge, so probing the daemon cannot
|
||||
enumerate users. Store lines are generated with
|
||||
`fastsync-server --hash-credentials <plaintext-file>` (see `RSYNC_COMPAT.md`);
|
||||
redirect that output to an owner-only (mode 0600) file, and note that legacy
|
||||
`user:SHA256HEX` stores are rejected. FastSync also maintains an owner-only
|
||||
(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
|
||||
restarts so the dummy challenge for an unknown user stays stable (the key is
|
||||
never regenerated while the sidecar exists). The sidecar is secret material and
|
||||
must be protected like the credential store: keep it owner-only (mode 0600) and
|
||||
include it with the store in backups and credential rotation. If the sidecar
|
||||
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, 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
|
||||
a hit.
|
||||
|
||||
An `auth users` module accepts credentials only when one of two conditions
|
||||
holds: (a) the connection is an encrypted, verified TLS connection whose client
|
||||
certificate matches the server's `--client-cn`, or (b) the connection is
|
||||
plaintext from a loopback peer **and** the operator explicitly passed
|
||||
`--allow-unauthenticated`. A remote plaintext peer is refused before any
|
||||
challenge is sent, and `--allow-unauthenticated` never permits remote plaintext
|
||||
auth: remote peers still require verified TLS regardless of the flag. Clients
|
||||
sending daemon credentials with `--password-file` to a non-loopback daemon must
|
||||
therefore use `--tls`; the client rejects a non-local plaintext credential
|
||||
destination before any network I/O. 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 a 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. `--tls` always
|
||||
mandates `--client-cn`, so a TLS connection to an auth-required module always
|
||||
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
|
||||
verification; without it, traffic is encrypted but peer identity is not
|
||||
verified. Use certificate verification for deployments where authentication
|
||||
|
||||
+12
-11
@@ -629,22 +629,23 @@ now transmits targets (the prior behavior was broken/partial); its status moved
|
||||
| `--config=FILE` | Alternate rsyncd.conf file | ✅ Implemented | Wave A: selects the daemon config file. Default when omitted (in `--daemon` mode): `~/.config/fastsync/fastsyncd.conf` if it exists, else `/etc/fastsyncd.conf`. The grammar is FastSync-native (documented in the Daemon Mode notes below) and strictly rejects unknown keys so a typo can never silently change what a module serves; requires `--daemon` |
|
||||
| `--dparam=OVERRIDE` | Override global daemon config | ✅ Implemented | Wave A: overrides one global scalar from the command line (`--dparam port=8734` and `--dparam=KEY=VALUE` both work). Limited to the global scalar keys the grammar defines (`port`, `motd file`, `address`); keys are case-insensitive and unknown keys/invalid values are rejected. Requires `--daemon` |
|
||||
| `--no-detach` | Don't detach from parent | ✅ Implemented | Wave A: with `--daemon`, keeps the listener in the foreground (what integration tests use). Without it the daemonizes (fork/setsid, stdio redirected to /dev/null) after the listening socket is bound. Requires `--daemon` |
|
||||
| `--password-file=FILE` | Read daemon password from file | ✅ Implemented | Wave B daemon auth. Client: `--password-file` supplies `user:password` for a `host::module/path` destination (the username is taken from this file, so `user@host::module` stays rejected). Server (`fastsync-server --daemon --password-file FILE`): the credential store that modules with `auth users` are verified against. Only a SHA-256 digest of the password ever crosses the wire or is stored server-side; the literal password never appears in logs. The file must be private to its owner: both the client and server verify the exact inode they read (open-then-`fstat`, so the check cannot be raced) and refuse a `--password-file`/`--early-input` that is not owned by the current user or grants any group/other permission bit (mode 0600), mirroring the TLS private-key check. A process-substitution pipe (`--early-input <(vault ...)`) is still accepted when it satisfies those checks. See the Daemon Mode notes below for the file formats and the plaintext/TLS caveat |
|
||||
| `--early-input=FILE` | Use FILE for daemon early exec | ✅ Implemented | Server-only (requires `--daemon`): a second credential-store file, same `user:SHA256HEX` grammar as `--password-file`, read before the listener accepts connections (a secrets-manager / process-substitution source). Its entries layer over `--password-file`: identical entries dedupe, a conflicting secret for the same user is a startup error. A daemon whose modules declare `auth users` must be given at least one of the two, or it refuses to start (fail closed) |
|
||||
| `--password-file=FILE` | Read daemon password from file | ✅ Implemented | A7 daemon auth. Client: `--password-file` supplies `user:password` for a `host::module/path` destination (the username is taken from this file, so `user@host::module` stays rejected); the literal password is held client-side only for the SCRAM handshake and wiped at teardown. Server (`fastsync-server --daemon --password-file FILE`): the salted-PBKDF2 verifier store that modules with `auth users` are verified against. **Neither the password nor any replayable bearer value crosses the wire or is stored server-side** — the store holds a per-user salt plus derived keys, and the daemon proves the secret with a per-connection nonce challenge. The file must be private to its owner: both the client and server verify the exact inode they read (open-then-`fstat`, so the check cannot be raced) and refuse a `--password-file`/`--early-input` that is not owned by the current user or grants any group/other permission bit (mode 0600), mirroring the TLS private-key check. A process-substitution pipe (`--early-input <(vault ...)`) is still accepted when it satisfies those checks. See the Daemon Mode notes below for the file formats and the plaintext/TLS caveat |
|
||||
| `--early-input=FILE` | Use FILE for daemon early exec | ✅ Implemented | Server-only (requires `--daemon`): a second credential-store file, same new-format grammar as `--password-file`, read before the listener accepts connections (a secrets-manager / process-substitution source). Its entries layer over `--password-file`: byte-identical verifiers dedupe, a conflicting verifier for the same user is a startup error. A daemon whose modules declare `auth users` must be given at least one of the two, or it refuses to start (fail closed) |
|
||||
| `--hash-credentials=FILE`, `--iterations N` | Hash a plaintext credential file | ✅ Implemented | Server-only offline tool (A7): reads the `user:password` lines of FILE (same owner-only 0600 check) and prints one new-format store line per entry to stdout, then exits. `--iterations` sets the PBKDF2 work factor (default 600000, range 100000–10000000). Dependency-free and does not run a listener. Use its output as `--password-file` for `--daemon`. There is no auto-upgrade: a legacy store line is hard-rejected by the loader and must be regenerated |
|
||||
|
||||
**Daemon Mode notes (Wave A, protocol 2.15.0; Wave B auth, Wave C MOTD, no bump):** FastSync daemon mode is supported in FastSync's own protocol/config grammar, not rsync's SMB/daemon option encoding.
|
||||
**Daemon Mode notes (Wave A protocol 2.15.0; A7 auth protocol 2.19.0; MOTD no bump):** FastSync daemon mode is supported in FastSync's own protocol/config grammar, not rsync's SMB/daemon option encoding.
|
||||
|
||||
- **Config grammar** (`fastsyncd.conf`): line-based; an implicit global section first, then `[module]` sections. Keys are case-insensitive, values are trimmed and may be wrapped in one layer of double quotes (`path = "/srv/my dir"`). `#` and `;` at the start of a line (after leading whitespace) are full-line comments; inline comments and `\` continuations are not supported. Lines are bounded (4096 chars). Global keys: `port` (default 873), `motd file` (the daemon sends its bounded, escaped content to a client after the module gate/auth accepts, unless the client passes `--no-motd`), `address` (optional bind address). Module keys: `path` (required; the daemon-side authorized root for that module), `read only` (yes/no/true/false/1/0, default no), `client owner` (yes/no/true/false/1/0, default no; opts the module into client-chosen ownership — see below), `auth users` (comma list). **Unknown keys and malformed lines are parse-and-reject errors** (never silently ignored), so a typo cannot change what a module serves.
|
||||
- **Module selection & confinement:** the client requests a module with an rsync-style `host::module[/path]` destination. The module name crosses the wire as a trailing string on the config frame (bumping `PROTOCOL_VERSION` 2.14.0 → 2.15.0; the bump is required because the config-frame layout changed and the strict same-version handshake is what prevents a peer from desynchronizing on the new trailing field). The daemon looks the module up in ITS OWN config and uses the module's `path` as the authorized root through the exact same `configure_authorization` confinement the standalone server applies to `--destination-root` (`file_open_secure_parent`, `has_path_traversal`, `path_is_within`); the client never supplies the root, every client-chosen-ownership/super-user request is refused unless the module declares `client owner = yes` (the daemon's per-module opt-in, see below), and the operator `--no-super` veto forces super-user activities off for every daemon connection. The client's `/path` part is relative inside the module and is rejected if absolute or if it contains `..`. Unknown modules are refused before any data moves (the run fails cleanly at the config handshake). An absolute destination and a module request against a non-daemon server are also refused.
|
||||
- **`client owner` (client-chosen-ownership opt-in):** by default a daemon module refuses every request that would let the client pick an owner or ask for super-user activities — `--numeric-ids`, `--chown`, `--usermap`/`--groupmap`, `--fake-super`, `--copy-as`, and an explicit `--super` — at the config handshake (before `STATUS_OK`), because a daemon has no per-module opt-in for client-chosen ownership and any anonymous client could otherwise force arbitrary owner ids inside the module root. `client owner = yes` opts a single module in, allowing those requests within that module's root (the standalone listener and the SSH `--stdio` server always honor them for their single operator-authorized root). Without the opt-in the daemon also forces super-user **device** activity off for that connection — char/block device-node creation (`--devices`) and `--write-devices` — even under the default `AUTO` mode, so a non-opted module can never be made to `mknod` or write a raw device; those entries are skipped (not refused) so an ordinary `-a` push still succeeds without device nodes. The opt-in does **not** lift the privilege requirement: `--copy-as` still needs a root receiver, and the operator `--no-super` veto still forces super-user activities off for every connection. The daemon logs a prominent startup warning for each `client owner = yes` module so the operator's deliberate choice is visible.
|
||||
- **`read only` safe default:** every network transfer FastSync currently supports is a push that writes under the module root, so a `read only` module refuses the connection (clear server log "module is read only"; the client exits non-zero, nothing is transferred). A future pull/list operation can be opened up when it exists; the knob is already stored.
|
||||
- **`auth users` (Wave B password authentication):** a module that declares `auth users` requires the client to present credentials. The client sends a username + the lowercase hex SHA-256 of the password (never the literal password) in the config frame; the daemon accepts a connection only when the presented username is **on the module's `auth users` list** AND the presented digest matches that user's credential-store entry. Verification is constant-time (username present/absent both take the same comparison work, so there is no timing oracle distinguishing "unknown user" from "wrong password"), and the daemon logs the username but **never the digest or the password**. A module WITHOUT `auth users` stays open (legitimate rsync configuration); credentials sent to such a module are ignored. Read-only is orthogonal: even a correctly authenticated push to a `read only` module is still refused (all FastSync network transfers write). Fail-closed policy: a daemon whose config declares `auth users` on any module refuses to start unless a credential store was given (`--password-file` and/or `--early-input`); a missing or empty store is never silently treated as "open".
|
||||
- **Credential store format:** server `--password-file`/`--early-input` files are line-based `user:SHA256HEX`, one per line, where `SHA256HEX` is the lowercase hex SHA-256 of the user's password (exactly what the client transmits). Blank lines and lines starting with `#`/`;` are comments; the parser is strict (a malformed line fails the whole load, so a typo can never let a different set of users in). The client `--password-file` holds `user:password` on its first meaningful line (the literal password, hashed client-side then wiped from memory); keep both files readable only by their owner (mode 0600) since the client file holds the password and the server file holds the equivalent credential. Per-username wire length is bounded (256 chars) and digests are validated to be exactly 64 lowercase hex on receive.
|
||||
- **Plaintext caveat:** over a plaintext (non-TLS) daemon, a sniffer can capture the transmitted digest and replay it (the exchange is challenge-less, like rsync), and it sees the same value that is already stored in the server's own credential file — so use `--tls` to protect the exchange. The daemon logs a warning when an auth-required module is reached over plaintext. TLS client-CN (`--client-cn`) is an independent transport identity check and composes with password auth: both may be required on the same connection.
|
||||
- **Wire/protocol:** the auth payload is two trailing config-frame strings (username + digest) behind a presence int, sent after the Wave A module string and before the STATUS_OK/STATUS_ERROR ack. Because both peers of a 2.15.0 build always parse the same full frame (the strict same-version handshake rejects any other version before any byte is parsed), this is NOT a new frame layout and does **not** require a `PROTOCOL_VERSION` bump — the 2.15.0 release ships Wave A + Wave B together (see the NOTE in `src/shared/config.h`).
|
||||
- **Client side:** `host::module/path` selects the TCP transport and connects to `--server-port`; `host:path` stays the SSH transport; plain paths stay local TCP. The daemon username comes from `--password-file` (first `user:password` line), and `--password-file` without a `host::module/path` destination is a client error (fail fast). A `user@host::module` form is rejected with a pointer to `--password-file`.
|
||||
- **`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, 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; 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.
|
||||
- **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.
|
||||
- **Merge note:** later daemon waves (auth, MOTD) must not bump `PROTOCOL_VERSION` again — the module-selection bump is owned by Wave A (see the NOTE in `src/shared/config.h`).
|
||||
- **Merge note:** the Wave A module bump (2.15.0) and the MOTD wave did not bump the version, but the A7 auth redesign is a genuine wire-layout change and owns the 2.18.0 → 2.19.0 bump (see the A7 note in `src/shared/config.h`).
|
||||
|
||||
## 15. Safety & Security
|
||||
|
||||
@@ -676,7 +677,7 @@ now transmits targets (the prior behavior was broken/partial); its status moved
|
||||
| `--stop-after=MINS` | Stop after N minutes | ✅ Implemented | Client-only sender stop deadline (Phase 6): computing `--stop-after=MINS` (a positive minute count; 0/negative/garbage rejected) and `--stop-at=TIME` (`HH:MM`, `HH:MM:SS`, or `now+N[smhd]`; a past time stops immediately). The transfer stops ELEGANTLY at the next chunk boundary: everything already fully sent is kept and applied, the run returns 0, and --delete (late/delete-after timing) does NOT wipe the destination — when the scan is cut short the partial keep-set manifest is suppressed with a warning (the delete walk is skipped rather than acting on an incomplete keep-set, so unscanned source mirrors survive). `--delete-before`/`--delete-during` still run their complete pre-scan (which ignores the deadline). Local client-only fields: never serialized into the wire config frame, so no PROTOCOL_VERSION bump. `--stop-after` uses CLOCK_MONOTONIC; `--stop-at` uses the wall clock. Works single-threaded and under `-j`/`--threads` (multithreaded). Divergence: rsync computes `--stop-after` from the run start; FastSync likewise. When both are given, the earlier of the two deadlines wins (checked per iteration). See the Phase-6 stop notes below |
|
||||
| `--stop-at=TIME` | Stop at specified time | ✅ Implemented | Same feature as `--stop-after` (deadline transfer stop), absolute wall-clock form (`HH:MM[:SS]` or `now+N[smhd]`). See the row above and the Phase-6 stop notes |
|
||||
| `--fsync` | Fsync every written file before publication | ✅ Implemented | |
|
||||
| `--protocol=NUM` | Force older protocol version | ✅ Implemented | Forces the wire protocol version for this transfer. FastSync has exactly ONE wire format (`PROTOCOL_VERSION`, currently 2.18.0) with no downgrade/backward-compat code paths, so `--protocol=2.18.0` is accepted (it sets the version claim the client sends, which the server already requires to match exactly) and **every other value is rejected up front** with a clear error before any connection — it does not and cannot speak an older or virtual wire format. Divergence from rsync (which negotiates a range and downgrades to an integer 0..31): FastSync's honest contract is force-to-the-one-supported-value; a genuine downgrade would require a per-version compatibility layer that does not exist. Client-only; the server-side exact-match check is unchanged. `--protocol=2.18`/`2.17.0`/`2.16.0`/`2.15.0`/`216`/`31`/garbage are all rejected. See the Phase-6 protocol note below |
|
||||
| `--protocol=NUM` | Force older protocol version | ✅ Implemented | Forces the wire protocol version for this transfer. FastSync has exactly ONE wire format (`PROTOCOL_VERSION`, currently 2.19.0) with no downgrade/backward-compat code paths, so `--protocol=2.19.0` is accepted (it sets the version claim the client sends, which the server already requires to match exactly) and **every other value is rejected up front** with a clear error before any connection — it does not and cannot speak an older or virtual wire format. Divergence from rsync (which negotiates a range and downgrades to an integer 0..31): FastSync's honest contract is force-to-the-one-supported-value; a genuine downgrade would require a per-version compatibility layer that does not exist. Client-only; the server-side exact-match check is unchanged. `--protocol=2.18.0`/`2.18`/`2.17.0`/`2.16.0`/`2.15.0`/`216`/`31`/garbage are all rejected. See the Phase-6 protocol note below |
|
||||
| `--iconv=CONVERT_SPEC` | Charset conversion | ✅ Implemented | Charset conversion of FILE NAMES (not content) at the protocol boundary via iconv(3): `--iconv=LOCAL[,REMOTE]` — the sender converts each local filename LOCAL→REMOTE before transmitting, and the receiver converts each wire filename REMOTE→LOCAL before creating/writing. The full CONVERT_SPEC is serialized into the config frame as a new trailing string field so the peer knows the wire charset; **PROTOCOL_VERSION bumped 2.15.0 → 2.16.0**. `LOCAL[,REMOTE]` parse: single charset ⇒ LOCAL==REMOTE (identity both ways); garbage rejected up front. Validation probes BOTH directions (a spec that only opens one way is refused, as is a NUL-emitting target charset like utf-16/utf-32/ucs-2, since filenames cannot contain NUL). An unrepresentable name (EILSEQ/EINVAL) fails that path cleanly with a logged `--iconv: cannot convert file name ...` and is never written mangled/truncated. Conversion is applied at EVERY wire-path site (regular/MKDIR/hardlink path+target/symlink path+target/SPECIAL, the delete manifest, the incremental-check path, and the `-s`/`chunk_serialize` embedded blob path), on both client and server (`--iconv` is also a server/daemon option). Zero overhead when unset. See the Phase-6 iconv notes below |
|
||||
| `--checksum-seed=NUM` | Set checksum seed | ✅ Implemented | Sets the seed for FastSync's whole-file xxHash64 digest (full 64-bit seed) and for the delta path's per-block xxHash32 strong checksum (low 32 bits of the seed). An explicit seed deterministically changes every computed digest on BOTH endpoints (sender and receiver share the seed via the config frame, protocol 2.10.0), so identical runs with the same seed skip the same files and a changed seed changes the digests — the explicit-seed path that makes xxHash comparisons deterministic. `--checksum-choice=md5` has no seed and ignores it (documented). The value is a strict decimal 0..2⁶⁴-1 (blank, signed, or non-numeric values are rejected). Like rsync, a seed only matters where a digest is actually computed (`--checksum` or a basis-dir run, or a delta transfer); it does not by itself enable `--checksum`/`--delta`. Divergence from rsync: the default is seed 0, and FastSync never randomizes the seed (rsync uses a random per-transfer seed when `--checksum-seed` is unset); FastSync's unset default therefore reproduces its historical byte-for-byte behavior |
|
||||
| `--secluded-args`, `-s` | Use protocol to send args | ⛔ Impossible/Divergence | Accepted for CLI compatibility (including the rsync short `-s`, Phase 7 Wave A) but a documented **no-op / divergence**. rsync's `-s` protects arguments from shell expansion by shipping them over the protocol; FastSync never passes remote arguments through a shell expansion boundary in the first place — its SSH transport builds the remote argv as **single-quote-escaped shell words** (`ssh_build_remote_command`), so the injection/leak that `-s` guards against does not exist and there is nothing to "seclude". Implementing a true arg-send protocol would mean replacing the argv-based SSH launch with an in-band argument channel, a large redesign of the transport that buys no security here. Chunk serialization remains the long-only `--chunk-serialization`. |
|
||||
@@ -792,7 +793,7 @@ These are the hardest compatibility items because they require durable formats o
|
||||
|
||||
**Phase 6, Wave B (iconv) shipping note (PROTOCOL 2.15.0 → 2.16.0):** `--iconv=LOCAL[,REMOTE]` converts file NAMES at the wire boundary (never content). The full CONVERT_SPEC is serialized into the config frame as a new trailing string field (empty→NULL canonicalized), so both ends share the same wire charset interpretation; this required the PROTOCOL bump because the frame is a strict ordered sequence and a peer that does not parse the new trailing field would desynchronize. Each end derives LOCAL (its own charset) and REMOTE (the wire charset): the sender opens LOCAL→REMOTE and converts every transmitted filename; the receiver opens REMOTE→LOCAL and converts every received filename before creating/writing. Conversion is applied at every wire-path site (regular/MKDIR/hardlink path+target/symlink path+target/SPECIAL, the delete manifest keep/protected/missing entries, the incremental-check path, and the embedded `-s`/chunk-blob path). A name it cannot convert (EILSEQ/EINVAL) is failed cleanly with a logged `--iconv: cannot convert file name ...` and is never written truncated/mangled. Validation probes both directions up front (both the sender local→remote and the receiver remote→local, and, for a server/daemon with its own `--iconv`, the client-REMOTE→server-LOCAL pair) so an unusable spec is rejected before the connection rather than mid-transfer, and NUL-emitting target charsets (utf-16/utf-32/ucs-2) are refused because filenames cannot contain NUL. Divergence documented upstream: the receiver does NOT half-swap; the wire charset always comes from the sender's REMOTE half, so a server whose local charset differs from the client's LOCAL must declare it with its own `--iconv`. Conversion is process-global and runs on a single thread per process (sender thread / receiver-loop thread), initialized before worker threads start and freed after they join.
|
||||
|
||||
**Phase 6, Wave C (protocol-version) shipping note (no PROTOCOL_VERSION change):** `--protocol=NUM` lets the client force the wire protocol version for a transfer. FastSync's protocol is a single lockstep format: the config frame is a strict ordered sequence and the server requires the client's version string to equal `PROTOCOL_VERSION` exactly (`config_receive_with_validate`, src/shared/config.c) — there are no older-format code paths and no downgrade/negotiation machinery, so a lower/higher/virtual version can never be spoken. The honest contract is therefore: `--protocol=2.18.0` (the current `PROTOCOL_VERSION`, as of the P7 Wave E privilege bump) is accepted and stored into the client's `version` claim (which `config_send` already transmits), and every other value — `2.18`, `2.17.0`, `2.16.0`, `2.15.0`, `3.0.0`, rsync-integer spellings like `216`/`31`, garbage, empty — is rejected up front in `validate_config()` before any connection, with a clear error that FastSync supports only its current wire protocol and cannot speak an older or virtual one. Implementation is client-only: a server-side `--protocol` is intentionally not added because the server has no negotiation (it only enforces exact match), and it could only ever be the current version. This preserves (and slightly tightens) existing validation: the client now also refuses to launch with a version it cannot actually speak, rather than only the server rejecting it later. A genuine downgrade would require a per-version compatibility layer for every frame/feature added since (append 2.10, preallocate 2.11, hardlinks 2.12, devices/specials/symlink-trust/xattr 2.13, remote-option 2.14, daemon module/auth 2.15, iconv 2.16, dir/symlink times 2.17, privilege flags --super/--copy-as 2.18) and is intentionally out of scope — documented divergences from rsync's integer-negotiated downgrade remain.
|
||||
**Phase 6, Wave C (protocol-version) shipping note (no PROTOCOL_VERSION change):** `--protocol=NUM` lets the client force the wire protocol version for a transfer. FastSync's protocol is a single lockstep format: the config frame is a strict ordered sequence and the server requires the client's version string to equal `PROTOCOL_VERSION` exactly (`config_receive_with_validate`, src/shared/config.c) — there are no older-format code paths and no downgrade/negotiation machinery, so a lower/higher/virtual version can never be spoken. The honest contract is therefore: `--protocol=2.19.0` (the current `PROTOCOL_VERSION`, as of the A7 auth redesign) is accepted and stored into the client's `version` claim (which `config_send` already transmits), and every other value — `2.18.0`, `2.18`, `2.17.0`, `2.16.0`, `2.15.0`, `3.0.0`, rsync-integer spellings like `216`/`31`, garbage, empty — is rejected up front in `validate_config()` before any connection, with a clear error that FastSync supports only its current wire protocol and cannot speak an older or virtual one. Implementation is client-only: a server-side `--protocol` is intentionally not added because the server has no negotiation (it only enforces exact match), and it could only ever be the current version. This preserves (and slightly tightens) existing validation: the client now also refuses to launch with a version it cannot actually speak, rather than only the server rejecting it later. A genuine downgrade would require a per-version compatibility layer for every frame/feature added since (append 2.10, preallocate 2.11, hardlinks 2.12, devices/specials/symlink-trust/xattr 2.13, remote-option 2.14, daemon module/auth 2.15, iconv 2.16, dir/symlink times 2.17, privilege flags --super/--copy-as 2.18, SCRAM daemon auth 2.19) and is intentionally out of scope — documented divergences from rsync's integer-negotiated downgrade remain.
|
||||
|
||||
**Phase-1/2 selection-and-update status correction (docs):** `-I/--ignore-times`, `--size-only`, `-@/--modify-window`, `--existing`, `--ignore-existing`, `-u/--update`, `-W/--whole-file`, and `--compress-threads` were previously listed as not-implemented in this document but are in fact fully implemented and tested on `dev`. This pass corrects the matrix to match the code. The realistic model of these is that FastSync is a *sender-driven* whole-tree copy, so the size+mtime quick-check and all three receiver-policy skips (`--existing`, `--ignore-existing`, `-u`) are evaluated against the **destination** on the receiver side, and their booleans cross the wire in the config frame. `-I`/`--size-only`/`--modify-window` modify the `--incremental` per-file `STATUS_CHECK` handshake's match predicate (`-I` disables the mtime leg and forces transfer; `--size-only` drops only the mtime leg; `--modify-window` adds tolerance to `metadata_mtime_matches`); they require `--incremental` (or a basis dir) to have a handshake to affect, mirroring how they only matter where a quick-check exists in rsync. `--existing`/`--ignore-existing`/`-u` are receiver write-time policies (skipping the write / newer-destination guard) applied across the regular-file, `--delay-updates`-staged, hardlink-sibling, and special/device paths; `-u` implies `-M` metadata and uses a second-then-nanosecond strict `>` newer check; both correctly influence `--remove-source-files` (a skipped source is not removed). `-W/--whole-file` disables block-level delta (opt-in via `--delta`), folded into the wire `use_delta` so no protocol bump was needed, and makes `--fuzzy` inert; `--append`/`--append-verify` are rejected with `-W`. `--compress-threads=NUM` (1..64, client-only, never crosses the wire) sizes the zstd compression worker pool. No code was changed by this correction; the implementation had landed in earlier merge waves (feat/ignore-times, feat/ignore-existing via the newer `file_to_disk_secure_no_replace`/`linkat EEXIST` path, feat/size-only, feat/modify-window, feat/whole-file, feat/update, compression-threads).
|
||||
|
||||
|
||||
+10
-27
@@ -1573,14 +1573,14 @@ static int read_patterns_from_file(const char* filepath, char*** patterns, int*
|
||||
}
|
||||
|
||||
#ifndef FASTSYNC_TEST_BUILD
|
||||
/* Daemon auth (Wave B): read --password-file and derive the wire credentials
|
||||
* (username + SHA-256 hex digest of the password). Runs once the destination
|
||||
* form is known: the credentials only make sense for a daemon
|
||||
* (host::module/path) destination, so a --password-file without one is a hard
|
||||
* error here rather than a silently-ignored flag. The literal password is
|
||||
* hashed immediately and wiped from memory; only the digest (and username) are
|
||||
* kept on the Config for config_send. Returns 0 on success, -1 on error (the
|
||||
* reason is logged; neither the password nor its digest is ever logged). */
|
||||
/* Daemon auth (A7, protocol 2.19.0): read --password-file and keep the
|
||||
* username plus the LITERAL password (client-only, never serialized). Runs
|
||||
* once the destination form is known: the credentials only make sense for a
|
||||
* daemon (host::module/path) destination, so a --password-file without one is a
|
||||
* hard error here rather than a silently-ignored flag. The password is handed
|
||||
* to the SCRAM challenge/response in config_send and burned by
|
||||
* config_burn_auth/config_delete at teardown. Returns 0 on success, -1 on
|
||||
* error (the reason is logged; the password is never logged). */
|
||||
static int load_daemon_credentials(Config* config) {
|
||||
if (!config->password_file)
|
||||
return 0;
|
||||
@@ -1597,27 +1597,10 @@ static int load_daemon_credentials(Config* config) {
|
||||
log_message(LOG_LEVEL_ERROR, "%s", err);
|
||||
return -1;
|
||||
}
|
||||
char hash[CREDENTIAL_HASH_HEX_LEN + 1];
|
||||
if (!credentials_hash_password(password, hash)) {
|
||||
log_message(LOG_LEVEL_ERROR, "failed to hash the password from '%s'", config->password_file);
|
||||
credentials_burn(password, strlen(password));
|
||||
free(password);
|
||||
free(user);
|
||||
return -1;
|
||||
}
|
||||
credentials_burn(password, strlen(password));
|
||||
free(password);
|
||||
|
||||
free(config->auth_user);
|
||||
free(config->auth_password_hash);
|
||||
config_burn_auth(config);
|
||||
config->auth_user = user;
|
||||
config->auth_password_hash = str_dup(hash);
|
||||
if (!config->auth_password_hash) {
|
||||
log_message(LOG_LEVEL_ERROR, "memory allocation failed reading '%s'", config->password_file);
|
||||
free(config->auth_user);
|
||||
config->auth_user = NULL;
|
||||
return -1;
|
||||
}
|
||||
config->auth_password = password;
|
||||
log_info_message(LOG_INFO_MISC, "Loaded daemon credentials for user '%s'", config->auth_user);
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#include "delay_updates.h"
|
||||
#include "log.h"
|
||||
#include "usage.h"
|
||||
#include "utils.h"
|
||||
#include <string.h>
|
||||
#include <stdio.h>
|
||||
|
||||
@@ -136,6 +137,15 @@ bool validate_config(const Config* config) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
/* Daemon credentials (A7, protocol 2.19.0): a --password-file would send the
|
||||
username in the clear and derive a SCRAM proof a network sniffer could
|
||||
attack offline, so it is only allowed over TLS (which itself mandates a
|
||||
verified --cert/--key/--ca set above) or to a loopback destination. A
|
||||
remote plaintext daemon is refused here, before any network I/O. */
|
||||
if (config->password_file && !config->use_tls && !utils_host_is_loopback(config->server_host)) {
|
||||
log_message(LOG_LEVEL_ERROR, "sending daemon credentials to a non-local server requires --tls");
|
||||
return false;
|
||||
}
|
||||
if (config->delay_updates && config->inplace) {
|
||||
log_message(LOG_LEVEL_ERROR, "--delay-updates does not work with --inplace");
|
||||
return false;
|
||||
|
||||
+170
-26
@@ -60,12 +60,100 @@ static CredentialStore* g_credentials = NULL;
|
||||
* SUPER_MODE_OFF here and the handler applies it exactly once after acceptance. */
|
||||
typedef struct ModuleGateContext {
|
||||
SSL* ssl;
|
||||
/* The connection descriptor, so the gate can drive the SCRAM auth handshake
|
||||
* while it still owns the config-frame exchange (before the STATUS_OK ack). */
|
||||
int fd;
|
||||
/* SUPER_MODE_OFF when this connection must not attempt any super-user
|
||||
activity (operator --no-super, or a daemon module without the
|
||||
`client owner = yes` opt-in); -1 when the config's own mode stands. */
|
||||
int super_mode_override;
|
||||
} ModuleGateContext;
|
||||
|
||||
/* Server half of the SCRAM challenge/response (A7 remediation, protocol
|
||||
* 2.19.0). Sends STATUS_AUTH_CHALLENGE (iteration count, base64 salt, base64
|
||||
* server nonce), expects STATUS_AUTH_RESPONSE (base64 client nonce, base64
|
||||
* ClientProof), verifies the proof constant-time and answers STATUS_AUTH_OK
|
||||
* with the base64 ServerSignature. On any failure BEFORE the success response
|
||||
* it sends exactly one generic STATUS_AUTH_FAILED and returns false; a failure
|
||||
* while writing the success signature cannot send a status and just drops an
|
||||
* already-broken connection. The verifier for an unknown/off-list
|
||||
* user is a dummy (deterministic per-username salt, store-wide iterations, dummy
|
||||
* keys, found=false) so the same math runs and no user-enumeration/timing oracle
|
||||
* is exposed. */
|
||||
static bool server_auth_handshake(int fd, const Config* config, const DaemonModule* module) {
|
||||
bool result = false;
|
||||
CredentialVerifier verifier;
|
||||
memset(&verifier, 0, sizeof(verifier));
|
||||
uint8_t snonce[CREDENTIAL_NONCE_LEN] = {0};
|
||||
char salt_b64[25] = {0};
|
||||
char snonce_b64[45] = {0};
|
||||
char* cnonce_b64 = NULL;
|
||||
char* proof_b64 = NULL;
|
||||
uint8_t cnonce[CREDENTIAL_NONCE_LEN] = {0};
|
||||
uint8_t proof[CREDENTIAL_KEY_LEN] = {0};
|
||||
uint8_t server_sig[CREDENTIAL_KEY_LEN] = {0};
|
||||
char sig_b64[45] = {0};
|
||||
size_t cnonce_len = 0;
|
||||
size_t proof_len = 0;
|
||||
|
||||
if (!config->auth_user)
|
||||
goto fail; /* no username: generic failure, no challenge */
|
||||
if (!credentials_get_verifier(g_credentials, config->auth_user,
|
||||
(const char* const*)module->auth_users, module->auth_user_count,
|
||||
&verifier))
|
||||
goto fail; /* a crypto failure still owes the gate a terminal frame */
|
||||
if (!(credentials_random_bytes(snonce, sizeof(snonce)) &&
|
||||
credentials_b64_encode(verifier.salt, CREDENTIAL_SALT_LEN, salt_b64, sizeof(salt_b64)) &&
|
||||
credentials_b64_encode(snonce, sizeof(snonce), snonce_b64, sizeof(snonce_b64))))
|
||||
goto fail;
|
||||
if (!(send_status(fd, STATUS_AUTH_CHALLENGE) && send_int(fd, (int)verifier.iters) &&
|
||||
send_str(fd, salt_b64) && send_str(fd, snonce_b64)))
|
||||
goto fail;
|
||||
|
||||
Status status = STATUS_ERROR;
|
||||
if (!(receive_status(fd, &status) && status == STATUS_AUTH_RESPONSE))
|
||||
goto fail;
|
||||
cnonce_b64 = receive_str_redacted(fd);
|
||||
proof_b64 = receive_str_redacted(fd);
|
||||
if (!(cnonce_b64 && proof_b64 &&
|
||||
credentials_b64_decode(cnonce_b64, cnonce, sizeof(cnonce), &cnonce_len) &&
|
||||
cnonce_len == CREDENTIAL_NONCE_LEN &&
|
||||
credentials_b64_decode(proof_b64, proof, sizeof(proof), &proof_len) &&
|
||||
proof_len == CREDENTIAL_KEY_LEN))
|
||||
goto fail;
|
||||
if (!credentials_verify_response(&verifier, config->auth_user, snonce, cnonce, proof, server_sig))
|
||||
goto fail;
|
||||
|
||||
/* Success writes exactly one terminal frame (STATUS_AUTH_OK). A broken pipe
|
||||
* while sending the signature just drops the connection; it must never emit a
|
||||
* second terminal status. */
|
||||
result = credentials_b64_encode(server_sig, sizeof(server_sig), sig_b64, sizeof(sig_b64)) &&
|
||||
send_status(fd, STATUS_AUTH_OK) && send_str_redacted(fd, sig_b64);
|
||||
goto cleanup;
|
||||
|
||||
fail:
|
||||
/* Every failure path writes exactly one generic terminal status, satisfying
|
||||
* the gate's CONFIG_VALIDATE_ALREADY_TERMINATED contract. */
|
||||
send_status(fd, STATUS_AUTH_FAILED);
|
||||
|
||||
cleanup:
|
||||
credentials_burn(cnonce_b64, cnonce_b64 ? strlen(cnonce_b64) : 0);
|
||||
credentials_burn(proof_b64, proof_b64 ? strlen(proof_b64) : 0);
|
||||
free(cnonce_b64);
|
||||
free(proof_b64);
|
||||
credentials_burn((char*)snonce, sizeof(snonce));
|
||||
credentials_burn(salt_b64, sizeof(salt_b64));
|
||||
credentials_burn(snonce_b64, sizeof(snonce_b64));
|
||||
credentials_burn((char*)cnonce, sizeof(cnonce));
|
||||
credentials_burn((char*)proof, sizeof(proof));
|
||||
credentials_burn((char*)server_sig, sizeof(server_sig));
|
||||
credentials_burn(sig_b64, sizeof(sig_b64));
|
||||
credentials_burn((char*)verifier.salt, sizeof(verifier.salt));
|
||||
credentials_burn((char*)verifier.stored_key, sizeof(verifier.stored_key));
|
||||
credentials_burn((char*)verifier.server_key, sizeof(verifier.server_key));
|
||||
return result;
|
||||
}
|
||||
|
||||
/* Aggregate payload bytes the multithreaded receiver may buffer ahead of the
|
||||
slow disk writer. Receiving one more chunk adds up to ~2 * MAX_CHUNK_SIZE
|
||||
of transient wire/decompression buffers on top of the queued payloads, so
|
||||
@@ -85,7 +173,7 @@ static bool tls_client_identity_allowed(SSL* ssl) {
|
||||
size_t required_length = strlen(required_client_cn);
|
||||
bool allowed = length >= 0 && (size_t)length == required_length &&
|
||||
required_length < sizeof(common_name) &&
|
||||
memcmp(common_name, required_client_cn, required_length) == 0;
|
||||
credentials_secure_equal(common_name, required_client_cn, required_length);
|
||||
X509_free(certificate);
|
||||
return allowed;
|
||||
}
|
||||
@@ -279,10 +367,13 @@ static const char* server_module_gate(const Config* config, void* context) {
|
||||
gate_ctx->super_mode_override = SUPER_MODE_OFF;
|
||||
}
|
||||
if (module->auth_user_count > 0) {
|
||||
/* Auth-required module (Wave B): verify the presented credentials against
|
||||
* the store BEFORE the module root is installed and before any data moves.
|
||||
* Fail closed: no store -> refuse; no/invalid credentials -> refuse. The
|
||||
* username may be logged (never the digest/password). */
|
||||
/* Auth-required module (A7, protocol 2.19.0): run the SCRAM challenge/
|
||||
* response BEFORE the module root is installed and before any data moves.
|
||||
* Fail closed: no store -> refuse (server misconfiguration, STATUS_ERROR);
|
||||
* a handshake that fails before the success response writes exactly one
|
||||
* STATUS_AUTH_FAILED before signalling ALREADY_TERMINATED (a failure while
|
||||
* writing the success signature instead just drops the broken connection).
|
||||
* The username may be logged (never the password or any derived proof). */
|
||||
if (g_credentials == NULL) {
|
||||
log_message(LOG_LEVEL_ERROR,
|
||||
"daemon module '%s' requires authentication but no credential store is "
|
||||
@@ -291,28 +382,46 @@ static const char* server_module_gate(const Config* config, void* context) {
|
||||
return "requested daemon module requires authentication and no credential "
|
||||
"store is configured";
|
||||
}
|
||||
if (!config->auth_user || !config->auth_password_hash) {
|
||||
/* Transport policy (A7-3/S1): an auth-required module only accepts
|
||||
* credentials over (a) an encrypted, verified TLS connection whose client
|
||||
* certificate matches --client-cn, or (b) an actual PLAINTEXT connection
|
||||
* from a loopback peer that the operator explicitly opted into with
|
||||
* --allow-unauthenticated. A remote plaintext peer, an un-flagged loopback
|
||||
* plaintext peer, and a loopback TLS peer whose certificate does not match
|
||||
* --client-cn are all refused HERE, before the challenge is sent, so an
|
||||
* unverified client never receives a nonce: the loopback allowance requires
|
||||
* !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 &&
|
||||
tls_client_identity_allowed(gate_ctx->ssl);
|
||||
bool local_ok = allow_unauthenticated && gate_ctx && !gate_ctx->ssl && gate_ctx->fd >= 0 &&
|
||||
utils_fd_peer_is_local(gate_ctx->fd);
|
||||
if (!tls_ok && !local_ok) {
|
||||
log_message(LOG_LEVEL_ERROR,
|
||||
"daemon module '%s' requires authentication; the client "
|
||||
"presented no credentials",
|
||||
"daemon module '%s' requires authentication over an encrypted, verified TLS "
|
||||
"connection (or an opted-in loopback plaintext transport); refusing",
|
||||
config->module);
|
||||
return "requested daemon module requires authentication";
|
||||
return "daemon module requires authentication over an encrypted, verified TLS "
|
||||
"connection";
|
||||
}
|
||||
if (gate_ctx && !gate_ctx->ssl) {
|
||||
log_message(LOG_LEVEL_WARNING,
|
||||
"daemon module '%s' is authenticating over a plaintext connection (no --tls); "
|
||||
"the credential exchange is not encrypted",
|
||||
/* 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) {
|
||||
log_message(LOG_LEVEL_ERROR, "daemon module '%s': no auth transport available",
|
||||
config->module);
|
||||
}
|
||||
if (!credentials_gate_allows(g_credentials, (const char* const*)module->auth_users,
|
||||
module->auth_user_count, config->auth_user,
|
||||
config->auth_password_hash)) {
|
||||
char* escaped_user = output_escape(config->auth_user, config->eight_bit_output);
|
||||
log_message(LOG_LEVEL_ERROR, "daemon module '%s': authentication failed for user '%s'",
|
||||
config->module, escaped_user ? escaped_user : "<allocation failed>");
|
||||
free(escaped_user);
|
||||
return "authentication failed for the requested daemon module";
|
||||
}
|
||||
if (!server_auth_handshake(gate_ctx->fd, config, module)) {
|
||||
char* escaped_user =
|
||||
config->auth_user ? output_escape(config->auth_user, config->eight_bit_output) : NULL;
|
||||
log_message(LOG_LEVEL_ERROR, "daemon module '%s': authentication failed for user '%s'",
|
||||
config->module, escaped_user ? escaped_user : "(none)");
|
||||
free(escaped_user);
|
||||
return CONFIG_VALIDATE_ALREADY_TERMINATED;
|
||||
}
|
||||
char* escaped_user = output_escape(config->auth_user, config->eight_bit_output);
|
||||
log_message(LOG_LEVEL_INFO, "daemon module '%s': user '%s' authenticated", config->module,
|
||||
escaped_user ? escaped_user : "<allocation failed>");
|
||||
@@ -334,6 +443,7 @@ void handler(int file_descriptor) {
|
||||
protocol_session_bind(&session);
|
||||
ModuleGateContext gate_ctx;
|
||||
gate_ctx.ssl = ssl;
|
||||
gate_ctx.fd = file_descriptor;
|
||||
gate_ctx.super_mode_override = -1;
|
||||
Config* config = config_receive_with_validate(file_descriptor, server_module_gate, &gate_ctx);
|
||||
if (config == NULL) {
|
||||
@@ -638,10 +748,12 @@ static void print_server_usage(void) {
|
||||
printf(" --no-detach Stay in the foreground (default detaches to\n");
|
||||
printf(" background when running --daemon)\n");
|
||||
printf(" --password-file=FILE Credential store for modules that declare\n");
|
||||
printf(" 'auth users' (line format: user:SHA256HEX where\n");
|
||||
printf(" SHA256HEX is the lowercase hex SHA-256 of the\n");
|
||||
printf(" user's password). Requires --daemon; an auth-\n");
|
||||
printf(" required module with no store refuses to start\n");
|
||||
printf(" 'auth users' (line format:\n");
|
||||
printf(" user:$fastsync$1$pbkdf2-sha256$iters$salt$stored$server,\n");
|
||||
printf(" generated by --hash-credentials). Legacy\n");
|
||||
printf(" user:SHA256HEX lines are rejected. Requires\n");
|
||||
printf(" --daemon; an auth-required module with no store\n");
|
||||
printf(" refuses to start\n");
|
||||
printf(" --early-input=FILE Second credential store layered over\n");
|
||||
printf(" --password-file (same format); usually a secrets-\n");
|
||||
printf(" manager/process-substitution file. Requires --daemon\n");
|
||||
@@ -650,7 +762,7 @@ static void print_server_usage(void) {
|
||||
printf(" --cert <path> TLS certificate file (PEM)\n");
|
||||
printf(" --key <path> TLS private key 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(" --address <addr> Bind the listening socket to this address\n");
|
||||
printf(" -4, --ipv4 Bind an IPv4 socket (default)\n");
|
||||
@@ -666,6 +778,15 @@ static void print_server_usage(void) {
|
||||
printf(" client's CONVERT_SPEC). A name that cannot be\n");
|
||||
printf(" represented fails the run cleanly\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(" PBKDF2 credential-store lines to stdout, then exit.\n");
|
||||
printf(" Use the output as --password-file for --daemon;\n");
|
||||
printf(" redirect it to an owner-only (0600) file\n");
|
||||
printf(" --iterations N PBKDF2 iteration count for --hash-credentials\n");
|
||||
printf(" (default %u, range %u-%u)\n", CREDENTIAL_DEFAULT_ITERS,
|
||||
CREDENTIAL_MIN_ITERS, CREDENTIAL_MAX_ITERS);
|
||||
printf(" -v, --verbose Enable debug logging\n");
|
||||
printf(" --help Show this help\n");
|
||||
}
|
||||
@@ -735,6 +856,29 @@ int main(int argc, char* argv[]) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
/* --hash-credentials: standalone offline tool; read user:password lines and
|
||||
* emit new-format credential-store lines, then exit. */
|
||||
if (opts.hash_credentials_file) {
|
||||
uint32_t iters = opts.hash_iterations_set ? opts.hash_iterations : CREDENTIAL_DEFAULT_ITERS;
|
||||
/* The output is secret material: if it is redirected to a regular file,
|
||||
* warn when that file is group/other-accessible (the store must be 0600). */
|
||||
struct stat out_st;
|
||||
if (fstat(STDOUT_FILENO, &out_st) == 0 && S_ISREG(out_st.st_mode) &&
|
||||
(out_st.st_mode & (S_IRWXG | S_IRWXO)) != 0)
|
||||
fprintf(stderr,
|
||||
"Warning: credential-store output is a group/other-accessible file; restrict it to "
|
||||
"mode 0600 (chmod 600)\n");
|
||||
char hash_err[512];
|
||||
if (credentials_hash_file(opts.hash_credentials_file, iters, stdout, hash_err,
|
||||
sizeof(hash_err)) != 0) {
|
||||
fprintf(stderr, "Error: %s\n", hash_err);
|
||||
server_cli_options_free(&opts);
|
||||
return 1;
|
||||
}
|
||||
server_cli_options_free(&opts);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int exit_code = 0;
|
||||
signal(SIGPIPE, SIG_IGN);
|
||||
if (opts.verbose) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#include "server_cli.h"
|
||||
#include "charset.h"
|
||||
#include "credentials.h"
|
||||
#include "utils.h"
|
||||
#include <limits.h>
|
||||
#include <stdarg.h>
|
||||
@@ -127,6 +128,33 @@ int server_cli_parse(int argc, char* argv[], ServerCliOptions* opts, char* err,
|
||||
inline_value = argv[++i];
|
||||
}
|
||||
opts->early_input_file = inline_value;
|
||||
} else if (arg_has_value(argv[i], "--hash-credentials", &inline_value)) {
|
||||
if (!inline_value) {
|
||||
if (i + 1 >= argc) {
|
||||
set_error(err, err_size, "missing argument for --hash-credentials");
|
||||
return -1;
|
||||
}
|
||||
inline_value = argv[++i];
|
||||
}
|
||||
opts->hash_credentials_file = inline_value;
|
||||
} else if (arg_has_value(argv[i], "--iterations", &inline_value)) {
|
||||
if (!inline_value) {
|
||||
if (i + 1 >= argc) {
|
||||
set_error(err, err_size, "missing argument for --iterations");
|
||||
return -1;
|
||||
}
|
||||
inline_value = argv[++i];
|
||||
}
|
||||
char* end = NULL;
|
||||
long n = strtol(inline_value, &end, 10);
|
||||
if (!end || *end != '\0' || n < (long)CREDENTIAL_MIN_ITERS ||
|
||||
n > (long)CREDENTIAL_MAX_ITERS) {
|
||||
set_error(err, err_size, "--iterations must be in [%u,%u], got '%s'", CREDENTIAL_MIN_ITERS,
|
||||
CREDENTIAL_MAX_ITERS, inline_value);
|
||||
return -1;
|
||||
}
|
||||
opts->hash_iterations = (uint32_t)n;
|
||||
opts->hash_iterations_set = true;
|
||||
} else if (arg_is(argv[i], "--address")) {
|
||||
if (i + 1 >= argc) {
|
||||
set_error(err, err_size, "missing argument for --address");
|
||||
@@ -227,6 +255,14 @@ int server_cli_parse(int argc, char* argv[], ServerCliOptions* opts, char* err,
|
||||
"--daemon");
|
||||
return -1;
|
||||
}
|
||||
if (opts->hash_credentials_file != NULL && (opts->daemon_mode || opts->stdio_mode)) {
|
||||
set_error(err, err_size, "--hash-credentials cannot be combined with --daemon or --stdio");
|
||||
return -1;
|
||||
}
|
||||
if (opts->hash_iterations_set && opts->hash_credentials_file == NULL) {
|
||||
set_error(err, err_size, "--iterations requires --hash-credentials");
|
||||
return -1;
|
||||
}
|
||||
/* --iconv: reject a malformed CONVERT_SPEC or an unsupported charset name at
|
||||
startup (a probe iconv_open is attempted). */
|
||||
if (opts->iconv_spec != NULL && !charset_spec_valid(opts->iconv_spec)) {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
/* Parsed fastsync-server command line. All string members are borrowed
|
||||
* pointers into the original argv (valid for the life of the argv array the
|
||||
@@ -26,7 +27,13 @@ typedef struct ServerCliOptions {
|
||||
const char* config_path; /* --config value, or NULL */
|
||||
const char* password_file; /* --password-file value, or NULL (daemon) */
|
||||
const char* early_input_file; /* --early-input value, or NULL (daemon) */
|
||||
const char** dparams; /* raw --dparam override strings */
|
||||
/* --hash-credentials=FILE: read `user:password` lines from FILE and print
|
||||
* new-format credential-store lines to stdout, then exit. Standalone mode
|
||||
* (mutually exclusive with --daemon/--stdio). */
|
||||
const char* hash_credentials_file;
|
||||
bool hash_iterations_set; /* an explicit --iterations was given */
|
||||
uint32_t hash_iterations; /* --iterations value (default CREDENTIAL_DEFAULT_ITERS) */
|
||||
const char** dparams; /* raw --dparam override strings */
|
||||
int dparam_count;
|
||||
const char* bind_address; /* --address */
|
||||
int bind_family; /* AF_UNSPEC / AF_INET / AF_INET6 */
|
||||
|
||||
+130
-29
@@ -41,7 +41,7 @@ static void config_set_defaults(Config* config) {
|
||||
config->ssh_destination = NULL;
|
||||
config->module = NULL;
|
||||
config->auth_user = NULL;
|
||||
config->auth_password_hash = NULL;
|
||||
config->auth_password = NULL;
|
||||
config->password_file = NULL;
|
||||
config->iconv_spec = NULL;
|
||||
config->fastsync_server_path = NULL;
|
||||
@@ -630,6 +630,20 @@ void config_parse_ssh_dest(Config* config) {
|
||||
config->receive_root_directory = path;
|
||||
}
|
||||
|
||||
void config_burn_auth(Config* config) {
|
||||
if (!config)
|
||||
return;
|
||||
if (config->auth_password) {
|
||||
credentials_burn(config->auth_password, strlen(config->auth_password));
|
||||
free(config->auth_password);
|
||||
config->auth_password = NULL;
|
||||
}
|
||||
if (config->auth_user) {
|
||||
free(config->auth_user);
|
||||
config->auth_user = NULL;
|
||||
}
|
||||
}
|
||||
|
||||
void config_delete(Config* config) {
|
||||
if (config == NULL)
|
||||
return;
|
||||
@@ -642,8 +656,7 @@ void config_delete(Config* config) {
|
||||
free(config->receive_root_directory);
|
||||
free(config->ssh_destination);
|
||||
free(config->module);
|
||||
free(config->auth_user);
|
||||
free(config->auth_password_hash);
|
||||
config_burn_auth(config);
|
||||
free(config->password_file);
|
||||
free(config->iconv_spec);
|
||||
free(config->write_batch);
|
||||
@@ -1128,23 +1141,18 @@ static bool receive_daemon_module(int fd, Config* c) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/* Daemon password credentials (Wave B, within protocol 2.15.0 -- see the
|
||||
* PROTOCOL_VERSION note in config.h: this rides the Wave A trailing-string
|
||||
* area, symmetric sender+receiver in every 2.15.0 build, so it is not a frame
|
||||
* layout that needs its own bump). A single presence int is followed, when
|
||||
* set, by the username and the SHA-256 hex digest of the password. The
|
||||
* literal password never crosses the wire. */
|
||||
/* Daemon password credentials (A7 remediation, protocol 2.19.0). A single
|
||||
* presence int is followed, when set, by ONLY the username; the password is
|
||||
* never serialized. The daemon answers an auth-required module with the SCRAM
|
||||
* challenge (see the auth exchange below). */
|
||||
static bool send_daemon_auth(int fd, const Config* c) {
|
||||
bool present = c->auth_user != NULL && c->auth_password_hash != NULL && c->auth_user[0] != '\0' &&
|
||||
c->auth_password_hash[0] != '\0';
|
||||
bool present = c->auth_user != NULL && c->auth_user[0] != '\0';
|
||||
if (!send_int(fd, present ? 1 : 0))
|
||||
return false;
|
||||
if (!present)
|
||||
return true;
|
||||
/* Redacted send: the username and hard-wired digest must never reach a
|
||||
* --verbose debug log (they are replayable), while normal protocol strings
|
||||
* keep their debug trace. */
|
||||
return send_str_redacted(fd, c->auth_user) && send_str_redacted(fd, c->auth_password_hash);
|
||||
/* Redacted send: the username must never reach a --verbose debug log. */
|
||||
return send_str_redacted(fd, c->auth_user);
|
||||
}
|
||||
|
||||
static bool receive_daemon_auth(int fd, Config* c) {
|
||||
@@ -1153,27 +1161,107 @@ static bool receive_daemon_auth(int fd, Config* c) {
|
||||
return false;
|
||||
if (!present)
|
||||
return true;
|
||||
/* Redacted receive: never log the incoming username/digest bodies. */
|
||||
/* Redacted receive: never log the incoming username body. */
|
||||
char* user = receive_str_redacted(fd);
|
||||
char* hash = receive_str_redacted(fd);
|
||||
if (!user || !hash) {
|
||||
free(user);
|
||||
free(hash);
|
||||
if (!user)
|
||||
return false;
|
||||
}
|
||||
size_t user_len = strlen(user);
|
||||
bool valid = user_len > 0 && user_len <= CREDENTIAL_MAX_USER_LEN && credentials_hash_valid(hash);
|
||||
if (!valid) {
|
||||
if (!credentials_username_valid(user)) {
|
||||
free(user);
|
||||
free(hash);
|
||||
log_message(LOG_LEVEL_WARNING, "Daemon client sent malformed auth credentials");
|
||||
return false;
|
||||
}
|
||||
c->auth_user = user;
|
||||
c->auth_password_hash = hash;
|
||||
return true;
|
||||
}
|
||||
|
||||
/* Client half of the SCRAM challenge/response (A7 remediation). Called by
|
||||
* config_send after the config frame is written and the server answered
|
||||
* STATUS_AUTH_CHALLENGE. The plaintext password lives only in
|
||||
* config->auth_password and every derived buffer is wiped on the way out. */
|
||||
static bool client_auth_exchange(int fd, const Config* c) {
|
||||
if (!c->auth_user || !c->auth_password)
|
||||
return false;
|
||||
int iters = 0;
|
||||
if (!receive_int(fd, &iters))
|
||||
return false;
|
||||
if (iters < (int)CREDENTIAL_MIN_ITERS || iters > (int)CREDENTIAL_MAX_ITERS) {
|
||||
log_message(LOG_LEVEL_ERROR, "Daemon sent an out-of-range auth iteration count");
|
||||
return false;
|
||||
}
|
||||
char* salt_b64 = receive_str(fd);
|
||||
char* snonce_b64 = receive_str(fd);
|
||||
uint8_t salt[CREDENTIAL_SALT_LEN];
|
||||
uint8_t snonce[CREDENTIAL_NONCE_LEN];
|
||||
uint8_t cnonce[CREDENTIAL_NONCE_LEN];
|
||||
size_t salt_len = 0;
|
||||
size_t snonce_len = 0;
|
||||
bool ok = salt_b64 && snonce_b64 &&
|
||||
credentials_b64_decode(salt_b64, salt, sizeof(salt), &salt_len) &&
|
||||
salt_len == CREDENTIAL_SALT_LEN &&
|
||||
credentials_b64_decode(snonce_b64, snonce, sizeof(snonce), &snonce_len) &&
|
||||
snonce_len == CREDENTIAL_NONCE_LEN && credentials_random_bytes(cnonce, sizeof(cnonce));
|
||||
credentials_burn(salt_b64, salt_b64 ? strlen(salt_b64) : 0);
|
||||
credentials_burn(snonce_b64, snonce_b64 ? strlen(snonce_b64) : 0);
|
||||
free(salt_b64);
|
||||
free(snonce_b64);
|
||||
if (!ok) {
|
||||
log_message(LOG_LEVEL_ERROR, "Daemon sent a malformed auth challenge");
|
||||
return false;
|
||||
}
|
||||
uint8_t client_key[CREDENTIAL_KEY_LEN];
|
||||
uint8_t stored_key[CREDENTIAL_KEY_LEN];
|
||||
uint8_t server_key[CREDENTIAL_KEY_LEN];
|
||||
uint8_t auth_msg[CREDENTIAL_AUTH_MESSAGE_MAX];
|
||||
size_t msg_len = 0;
|
||||
uint8_t proof[CREDENTIAL_KEY_LEN];
|
||||
uint8_t expected_sig[CREDENTIAL_KEY_LEN];
|
||||
ok = credentials_compute_keys(c->auth_password, salt, (uint32_t)iters, client_key, stored_key,
|
||||
server_key) &&
|
||||
credentials_build_auth_message(c->auth_user, snonce, cnonce, auth_msg, sizeof(auth_msg),
|
||||
&msg_len) &&
|
||||
credentials_client_proof(client_key, stored_key, server_key, auth_msg, msg_len, proof,
|
||||
expected_sig);
|
||||
char cnonce_b64[45];
|
||||
char proof_b64[45];
|
||||
if (ok)
|
||||
ok = credentials_b64_encode(cnonce, sizeof(cnonce), cnonce_b64, sizeof(cnonce_b64)) &&
|
||||
credentials_b64_encode(proof, sizeof(proof), proof_b64, sizeof(proof_b64));
|
||||
if (!ok) {
|
||||
log_message(LOG_LEVEL_ERROR, "Failed to compute the daemon auth response");
|
||||
} else {
|
||||
ok = send_status(fd, STATUS_AUTH_RESPONSE) && send_str_redacted(fd, cnonce_b64) &&
|
||||
send_str_redacted(fd, proof_b64);
|
||||
}
|
||||
if (ok) {
|
||||
Status status = STATUS_ERROR;
|
||||
char* sig_b64 = NULL;
|
||||
uint8_t sig[CREDENTIAL_KEY_LEN];
|
||||
size_t sig_len = 0;
|
||||
ok = receive_status(fd, &status) && status == STATUS_AUTH_OK &&
|
||||
(sig_b64 = receive_str_redacted(fd)) != NULL &&
|
||||
credentials_b64_decode(sig_b64, sig, sizeof(sig), &sig_len) &&
|
||||
sig_len == CREDENTIAL_KEY_LEN &&
|
||||
credentials_secure_equal((const char*)sig, (const char*)expected_sig, CREDENTIAL_KEY_LEN);
|
||||
if (!ok)
|
||||
log_message(LOG_LEVEL_ERROR, "Daemon authentication failed");
|
||||
credentials_burn(sig_b64, sig_b64 ? strlen(sig_b64) : 0);
|
||||
credentials_burn((char*)sig, sizeof(sig));
|
||||
free(sig_b64);
|
||||
}
|
||||
credentials_burn((char*)client_key, sizeof(client_key));
|
||||
credentials_burn((char*)stored_key, sizeof(stored_key));
|
||||
credentials_burn((char*)server_key, sizeof(server_key));
|
||||
credentials_burn((char*)auth_msg, sizeof(auth_msg));
|
||||
credentials_burn((char*)proof, sizeof(proof));
|
||||
credentials_burn((char*)expected_sig, sizeof(expected_sig));
|
||||
credentials_burn((char*)salt, sizeof(salt));
|
||||
credentials_burn((char*)snonce, sizeof(snonce));
|
||||
credentials_burn((char*)cnonce, sizeof(cnonce));
|
||||
credentials_burn(cnonce_b64, sizeof(cnonce_b64));
|
||||
credentials_burn(proof_b64, sizeof(proof_b64));
|
||||
return ok;
|
||||
}
|
||||
|
||||
/* --iconv CONVERT_SPEC (protocol 2.16.0). Trailing string on the config frame,
|
||||
* sent after the Wave A/B daemon-auth block and before the ack, so the
|
||||
* receiver knows the wire charset before the first file name arrives. The full
|
||||
@@ -1268,6 +1356,14 @@ bool config_send(int file_descriptor, const Config* config) {
|
||||
Status status;
|
||||
if (!receive_status(file_descriptor, &status))
|
||||
return false;
|
||||
if (status == STATUS_AUTH_CHALLENGE) {
|
||||
/* Daemon auth (protocol 2.19.0): run the SCRAM exchange, then wait for the
|
||||
* ordinary STATUS_OK the server sends once authentication succeeded. */
|
||||
if (!client_auth_exchange(file_descriptor, config))
|
||||
return false;
|
||||
if (!receive_status(file_descriptor, &status))
|
||||
return false;
|
||||
}
|
||||
if (status != STATUS_OK) {
|
||||
log_message(LOG_LEVEL_ERROR, "Error transmitting config");
|
||||
return false;
|
||||
@@ -1329,9 +1425,14 @@ Config* config_receive_with_validate(int file_descriptor, ConfigValidateFunc val
|
||||
if (rejection != NULL) {
|
||||
/* Daemon module gate (unknown module / read-only module / auth-required
|
||||
* module): refuse BEFORE the STATUS_OK so the client aborts at the
|
||||
* config handshake and no file data is ever exchanged. */
|
||||
fprintf(stderr, "%s\n", rejection);
|
||||
send_status(file_descriptor, STATUS_ERROR);
|
||||
* config handshake and no file data is ever exchanged. The auth
|
||||
* handshake already sent STATUS_AUTH_FAILED when it failed, signalled by
|
||||
* the CONFIG_VALIDATE_ALREADY_TERMINATED sentinel, so no second status is
|
||||
* written. */
|
||||
if (rejection != CONFIG_VALIDATE_ALREADY_TERMINATED) {
|
||||
fprintf(stderr, "%s\n", rejection);
|
||||
send_status(file_descriptor, STATUS_ERROR);
|
||||
}
|
||||
goto error;
|
||||
}
|
||||
}
|
||||
|
||||
+47
-19
@@ -96,19 +96,18 @@ typedef struct Config {
|
||||
* string so the daemon can look the module up in its own config and confine
|
||||
* the connection to the module's root (never a client-chosen root). */
|
||||
char* module;
|
||||
/* Daemon password authentication (Wave B, protocol 2.15.0, WITHIN the Wave A
|
||||
* frame layout -- see the PROTOCOL_VERSION note below for why this is not a
|
||||
* bump). Client-composed from a --password-file whose first meaningful line
|
||||
* is `user:password`: the client sends ONLY the username and a SHA-256 hex
|
||||
* digest of the password (auth_user + auth_password_hash), never the literal
|
||||
* password. Both are NULL when the client has no credentials to present; a
|
||||
* module WITHOUT `auth users` stays open and the server ignores any
|
||||
* credentials that do arrive (the client sends them opportunistically and
|
||||
* the server decides). */
|
||||
/* Daemon password authentication (A7 remediation, protocol 2.19.0).
|
||||
* Client-composed from a --password-file whose first meaningful line is
|
||||
* `user:password`: the client sends ONLY the username in the config frame
|
||||
* (auth_user); the literal password is kept in auth_password CLIENT-SIDE for
|
||||
* the duration of the SCRAM challenge/response and is NEVER serialized. Both
|
||||
* are NULL when the client has no credentials to present; a module WITHOUT
|
||||
* `auth users` stays open and the server ignores any credentials that do
|
||||
* arrive (the client sends them opportunistically and the server decides). */
|
||||
char* auth_user;
|
||||
char* auth_password_hash;
|
||||
char* auth_password;
|
||||
/* Client-only path of --password-file (never crosses the wire; it is read to
|
||||
* populate auth_user/auth_password_hash before connecting). */
|
||||
* populate auth_user/auth_password before connecting). */
|
||||
char* password_file;
|
||||
char* fastsync_server_path;
|
||||
/* --iconv=CONVERT_SPEC (protocol 2.16.0, rsync compatibility): convert the
|
||||
@@ -546,8 +545,8 @@ typedef struct Config {
|
||||
* is what keeps a 2.15 client and a 2.14 server from ever reaching that state.
|
||||
*
|
||||
* NOTE: daemon module-selection bump owned by Wave A (2.15.0); later daemon
|
||||
* waves (auth, motd) must not bump PROTOCOL_VERSION. Wave B (auth) adds the
|
||||
* credential fields (auth_user/auth_password_hash) as further trailing
|
||||
* waves (auth, motd) must not bump PROTOCOL_VERSION. Wave B (auth) added the
|
||||
* credential fields (auth_user + password digest) as further trailing
|
||||
* config-frame strings AFTER the Wave A module string, with a presence int
|
||||
* prefix. This is not a new frame version: sender and receiver of a 2.15.0
|
||||
* build always read and write the same full layout (the strict same-version
|
||||
@@ -617,8 +616,22 @@ typedef struct Config {
|
||||
* (config_receive rejects a mismatched version before parsing anything else) is
|
||||
* what keeps a 2.18 client and a 2.17 server from ever reaching that state.
|
||||
* --super never elevates privileges; it only permits a confined attempt, and
|
||||
* --copy-as never switches process credentials (see RSYNC_COMPAT.md). */
|
||||
#define PROTOCOL_VERSION "2.18.0"
|
||||
* --copy-as never switches process credentials (see RSYNC_COMPAT.md).
|
||||
*
|
||||
* A7 Auth Wave: 2.18.0 -> 2.19.0.
|
||||
*
|
||||
* WHY the bump, grounded in the wire: the daemon auth block on the config frame
|
||||
* loses the hard-wired password digest (it becomes `[int present][str_redacted
|
||||
* username]`), and the frame stream gains the SCRAM challenge/response
|
||||
* (STATUS_AUTH_CHALLENGE -> STATUS_AUTH_RESPONSE -> STATUS_AUTH_OK) between the
|
||||
* config frame and the STATUS_OK ack. A 2.18 peer would desynchronize on both
|
||||
* the shorter auth block and the new status frames, so the strict same-version
|
||||
* handshake (config_receive rejects a mismatched version before parsing
|
||||
* anything else) is what keeps a 2.19 client and a 2.18 server from ever
|
||||
* reaching that state. SECURITY: a 2.19 store holds a salted PBKDF2 verifier
|
||||
* and cannot verify (and refuses to load) a legacy unsalted-SHA-256 store line,
|
||||
* so an old bearer digest can never be replayed against a 2.19 daemon. */
|
||||
#define PROTOCOL_VERSION "2.19.0"
|
||||
#define DEFAULT_CHUNK_SIZE (10 * 1024 * 1024)
|
||||
/* Upper bound on total basis-dir entries (rsync caps --link-dest at 20). */
|
||||
#define MAX_BASIS_DIRS 64
|
||||
@@ -642,21 +655,36 @@ typedef struct Config {
|
||||
|
||||
Config* config_create(void);
|
||||
void config_delete(Config* config);
|
||||
|
||||
/* Wipe the client-side plaintext auth password (and username) from a Config
|
||||
* before it is freed or handed off. Safe on a NULL/empty Config and idempotent
|
||||
* (it clears the pointers after burning). config_delete calls this
|
||||
* automatically; a caller that drops a Config earlier may call it explicitly. */
|
||||
void config_burn_auth(Config* config);
|
||||
|
||||
bool config_send(int file_descriptor, const Config* config);
|
||||
Config* config_receive(int file_descriptor);
|
||||
bool config_is_remote_dest(const char* s);
|
||||
void config_parse_ssh_dest(Config* config);
|
||||
|
||||
/* A ConfigValidateFunc may return this sentinel to tell
|
||||
* config_receive_with_validate that the callback ALREADY sent a terminal status
|
||||
* frame (e.g. STATUS_AUTH_FAILED, then closed) and the frame must be abandoned
|
||||
* without an additional STATUS_ERROR. A normal rejection returns a message
|
||||
* string (logged, then STATUS_ERROR); NULL accepts. */
|
||||
#define CONFIG_VALIDATE_ALREADY_TERMINATED ((const char*)-1)
|
||||
|
||||
/* Server-side config-frame gate (daemon module selection, Wave A). A server
|
||||
* that needs to make an accept/reject decision about a received Config BEFORE
|
||||
* it sends the STATUS_OK ack (so a rejected connection is refused cleanly with
|
||||
* no data transferred) passes a callback here; it runs after the frame parses
|
||||
* and validates but before the STATUS_OK/STATUS_ERROR ack. Return NULL to
|
||||
* accept the connection; return a non-NULL message to reject it (the message
|
||||
* is logged server-side and STATUS_ERROR is sent in place of STATUS_OK). The
|
||||
* callback runs in the connection's own process, so it may set up per-module
|
||||
* process state (e.g. the authorized root). context is an opaque caller
|
||||
* pointer. */
|
||||
* is logged server-side and STATUS_ERROR is sent in place of STATUS_OK), or the
|
||||
* CONFIG_VALIDATE_ALREADY_TERMINATED sentinel when the callback already sent
|
||||
* its own terminal status. The callback runs in the connection's own process,
|
||||
* so it may set up per-module process state (e.g. the authorized root) and
|
||||
* drive the daemon auth handshake. context is an opaque caller pointer. */
|
||||
typedef const char* (*ConfigValidateFunc)(const Config* config, void* context);
|
||||
Config* config_receive_with_validate(int file_descriptor, ConfigValidateFunc validate,
|
||||
void* context);
|
||||
|
||||
+918
-108
File diff suppressed because it is too large.
Load diff
+151
-73
@@ -3,46 +3,83 @@
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
|
||||
/* Daemon password authentication (Wave B).
|
||||
/* Daemon password authentication (A7 remediation, protocol 2.19.0).
|
||||
*
|
||||
* FastSync authenticates a daemon connection with a username plus a SHA-256
|
||||
* hex digest of that username's password. The digest is what crosses the
|
||||
* wire: a challenge-less credential exchange, so the literal password is never
|
||||
* transmitted (and never stored on the daemon host). A module that declares
|
||||
* `auth users` demands that the presented username is on its list AND that the
|
||||
* presented digest matches the credential store's entry for that username.
|
||||
* The digest comparison is constant-time; a module with `auth users` whose
|
||||
* store is missing/misconfigured fails CLOSED (never falls open).
|
||||
* FastSync authenticates a daemon connection with a SCRAM-SHA-256-style
|
||||
* challenge/response handshake. The daemon stores only a salted PBKDF2
|
||||
* verifier (never the password, and never a value that can be replayed as a
|
||||
* bearer credential): the client proves knowledge of the password against a
|
||||
* per-connection server nonce, and the server proves the same shared secret
|
||||
* back. See credentials.c for the exact derivation.
|
||||
*
|
||||
* Credential store format (server --password-file and --early-input): one
|
||||
* `user:SHA256HEX` entry per line. SHA256HEX is the lowercase hex SHA-256 of
|
||||
* the user's password -- the exact value a FastSync client transmits. Blank
|
||||
* lines and lines whose first non-space character is '#' or ';' are comments.
|
||||
* The parser is STRICT: a malformed line (no ':', an empty/whitespace user, a
|
||||
* secret that is not 64 lowercase hex chars, a line longer than
|
||||
* CREDENTIAL_MAX_LINE) fails the whole load so a typo can never silently
|
||||
* change who may log in.
|
||||
* Server credential store format (--password-file and --early-input): one line
|
||||
* per entry,
|
||||
* user:$fastsync$1$pbkdf2-sha256$<iters>$<salt_b64>$<stored_key_b64>$<server_key_b64>
|
||||
* with standard base64, a 16-byte salt and 32-byte keys, and iters in
|
||||
* [CREDENTIAL_MIN_ITERS, CREDENTIAL_MAX_ITERS]. Blank lines and lines whose
|
||||
* first non-space character is '#' or ';' are comments. The parser is STRICT:
|
||||
* a malformed line fails the whole load so a typo can never silently change who
|
||||
* may log in. A line holding the legacy (unsalted SHA-256 hex) secret is
|
||||
* hard-rejected with an actionable "legacy" error; there is no auto-upgrade.
|
||||
* 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); creation forces exact 0600 with fchmod (so a restrictive umask
|
||||
* cannot leave the sidecar unreadable), and only a create/write/fsync/link or
|
||||
* fchmod failure degrades to a transient per-run key with a warning. 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)
|
||||
* line is `user:password`, holding the literal password. The client hashes it
|
||||
* and sends only the digest; the file should be mode 0600 and readable only by
|
||||
* its owner.
|
||||
*/
|
||||
* 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
|
||||
* should be mode 0600 and readable only by its owner. */
|
||||
|
||||
/* Lowercase hex length of a SHA-256 digest (what travels on the wire and what
|
||||
* the server store holds). */
|
||||
#define CREDENTIAL_HASH_HEX_LEN 64
|
||||
/* Longest accepted credential-file line (excluding the trailing newline). */
|
||||
#define CREDENTIAL_MAX_LINE 4096
|
||||
/* Upper bound on a username in a credential file and on the wire. Kept well
|
||||
* below MAX_STRING_SIZE so a wire username can never exhaust anything. */
|
||||
#define CREDENTIAL_MAX_USER_LEN 256
|
||||
/* Upper bound on a client-file password (before hashing). */
|
||||
/* Upper bound on a client-file password (before derivation). */
|
||||
#define CREDENTIAL_MAX_PASSWORD_LEN 1024
|
||||
|
||||
/* SCRAM-SHA-256 parameters. Salt and client nonce sizes are fixed by the
|
||||
* shared-auth-message framing; keys are always 32 bytes (SHA-256). */
|
||||
#define CREDENTIAL_SALT_LEN 16
|
||||
#define CREDENTIAL_NONCE_LEN 32
|
||||
#define CREDENTIAL_KEY_LEN 32
|
||||
#define CREDENTIAL_DEFAULT_ITERS 600000u
|
||||
#define CREDENTIAL_MIN_ITERS 100000u
|
||||
#define CREDENTIAL_MAX_ITERS 10000000u
|
||||
/* Buffer size for the full AuthMessage (prefix + three length-prefixed fields).
|
||||
* Worst case: 16 + 4 + 256 + 4 + 32 + 4 + 32. */
|
||||
#define CREDENTIAL_AUTH_MESSAGE_MAX \
|
||||
(16 + 4 + CREDENTIAL_MAX_USER_LEN + 4 + CREDENTIAL_NONCE_LEN + 4 + CREDENTIAL_NONCE_LEN)
|
||||
|
||||
typedef struct CredentialStore CredentialStore;
|
||||
|
||||
/* One resolved verifier. `found` is false for an unknown user or a user not on
|
||||
* a module's auth list; the remaining fields then hold a deterministic dummy
|
||||
* salt (HMAC of the store-wide dummy key over the username), the store-wide
|
||||
* uniform iteration count (default for an empty store) and fixed dummy keys, so
|
||||
* the server can run the same challenge/response math with no enumeration or
|
||||
* timing oracle. */
|
||||
typedef struct {
|
||||
uint8_t salt[CREDENTIAL_SALT_LEN];
|
||||
uint32_t iters;
|
||||
uint8_t stored_key[CREDENTIAL_KEY_LEN];
|
||||
uint8_t server_key[CREDENTIAL_KEY_LEN];
|
||||
bool found;
|
||||
} CredentialVerifier;
|
||||
|
||||
/* Load the daemon credential store.
|
||||
*
|
||||
* password_file and early_input_file are both NULL-or-path, matching the
|
||||
@@ -50,72 +87,113 @@ typedef struct CredentialStore CredentialStore;
|
||||
* opened or that fails the strict grammar is a hard error (err filled, NULL
|
||||
* returned) -- the daemon fails CLOSED rather than serving an auth-required
|
||||
* module with a partial store. Both files may be NULL, which yields an empty
|
||||
* store (every auth-required module then refuses connections). When both are
|
||||
* given, the --early-input file is layered over --password-file: a duplicate
|
||||
* username whose secret matches is deduplicated; one whose secret differs is
|
||||
* an error (the two sources disagree), never a silent pick.
|
||||
* store (every auth-required module then refuses connections). Every entry in
|
||||
* the resulting store must agree on the iteration count; entries that disagree
|
||||
* (within one file or across the two layered sources) are rejected. When both
|
||||
* are given, the --early-input file is layered over --password-file: a duplicate
|
||||
* username whose verifier matches is deduplicated; one whose verifier differs
|
||||
* is an error (the two sources disagree), never a silent pick.
|
||||
*
|
||||
* The returned store is heap-owned; free it with credentials_free. */
|
||||
CredentialStore* credentials_load(const char* password_file, const char* early_input_file,
|
||||
char* err, size_t err_size);
|
||||
|
||||
/* Wipe every stored key/salt and free the store. */
|
||||
void credentials_free(CredentialStore* store);
|
||||
|
||||
/* True when `hash_hex` is exactly CREDENTIAL_HASH_HEX_LEN lowercase hex digits
|
||||
* (the wire/store digest form). Used to reject a malformed presented digest
|
||||
* before it reaches the comparison. */
|
||||
bool credentials_hash_valid(const char* hash_hex);
|
||||
/* True when `user` is a single bounded token free of whitespace/control bytes
|
||||
* (the rule applied to store users, client-file users and the module list). */
|
||||
bool credentials_username_valid(const char* user);
|
||||
|
||||
/* Compute the lowercase hex SHA-256 of `password` into out_hex, which must
|
||||
* hold at least CREDENTIAL_HASH_HEX_LEN + 1 bytes. Returns false on a NULL
|
||||
* password or a hashing failure. The output is NUL-terminated. */
|
||||
bool credentials_hash_password(const char* password, char* out_hex);
|
||||
/* Standard base64. encode writes NUL-terminated output to out (size out_sz).
|
||||
* decode writes the raw bytes to out (capacity out_sz) and stores the length;
|
||||
* the input must be a well-formed padded base64 string. Both return false on
|
||||
* NULL arguments, a bad character/length, or insufficient output space. */
|
||||
bool credentials_b64_encode(const uint8_t* in, size_t n, char* out, size_t out_sz);
|
||||
bool credentials_b64_decode(const char* in, uint8_t* out, size_t out_sz, size_t* out_len);
|
||||
|
||||
/* Fill out[0..n) from the CSPRNG (RAND_bytes). Returns false on failure. */
|
||||
bool credentials_random_bytes(uint8_t* out, size_t n);
|
||||
|
||||
/* Resolve `user` against the store AND the module's auth-user list. The list
|
||||
* scan is a constant-time full-length comparison with no early break. On a
|
||||
* miss, *out is filled with a dummy verifier (a deterministic per-username salt
|
||||
* derived from the store's dummy key, the store-wide uniform iteration count,
|
||||
* fixed dummy keys, found=false). Returns false on invalid arguments or an
|
||||
* HMAC/crypto primitive failure. */
|
||||
bool credentials_get_verifier(const CredentialStore* store, const char* user,
|
||||
const char* const* module_users, int n, CredentialVerifier* out);
|
||||
|
||||
/* Derive the SCRAM keys from a plaintext password:
|
||||
* K = PBKDF2-HMAC-SHA256(password, salt, iters, 32)
|
||||
* ClientKey = HMAC-SHA256(K, "Client Key"); StoredKey = SHA256(ClientKey)
|
||||
* ServerKey = HMAC-SHA256(K, "Server Key")
|
||||
* Any of client_key/stored_key/server_key may be NULL when not needed.
|
||||
* `iters` must lie in [CREDENTIAL_MIN_ITERS, CREDENTIAL_MAX_ITERS]. */
|
||||
bool credentials_compute_keys(const char* password, const uint8_t salt[CREDENTIAL_SALT_LEN],
|
||||
uint32_t iters, uint8_t client_key[CREDENTIAL_KEY_LEN],
|
||||
uint8_t stored_key[CREDENTIAL_KEY_LEN],
|
||||
uint8_t server_key[CREDENTIAL_KEY_LEN]);
|
||||
|
||||
/* Serialize the shared AuthMessage:
|
||||
* "FastSync-Auth-v1" || be32(len(user)) || user
|
||||
* || be32(32) || server_nonce
|
||||
* || be32(32) || client_nonce
|
||||
* out must hold at least CREDENTIAL_AUTH_MESSAGE_MAX bytes. *out_len receives
|
||||
* the number of bytes written. */
|
||||
bool credentials_build_auth_message(const char* user, const uint8_t* snonce, const uint8_t* cnonce,
|
||||
uint8_t* out, size_t out_sz, size_t* out_len);
|
||||
|
||||
/* Client side: ClientProof = ClientKey XOR HMAC(StoredKey, AuthMessage), and
|
||||
* the expected ServerSignature = HMAC(ServerKey, AuthMessage). */
|
||||
bool credentials_client_proof(const uint8_t client_key[CREDENTIAL_KEY_LEN],
|
||||
const uint8_t stored_key[CREDENTIAL_KEY_LEN],
|
||||
const uint8_t server_key[CREDENTIAL_KEY_LEN], const uint8_t* auth_msg,
|
||||
size_t msg_len, uint8_t proof[CREDENTIAL_KEY_LEN],
|
||||
uint8_t server_sig[CREDENTIAL_KEY_LEN]);
|
||||
|
||||
/* Server side: recompute ClientSig' = HMAC(StoredKey, AuthMessage) and
|
||||
* ClientKey' = proof XOR ClientSig', then accept iff v->found AND
|
||||
* SHA256(ClientKey') equals StoredKey (constant-time over the 32-byte keys).
|
||||
* Always computes server_sig_out = HMAC(ServerKey, AuthMessage). Returns the
|
||||
* accept decision. */
|
||||
bool credentials_verify_response(const CredentialVerifier* v, const char* user,
|
||||
const uint8_t* snonce, const uint8_t* cnonce,
|
||||
const uint8_t proof[CREDENTIAL_KEY_LEN],
|
||||
uint8_t server_sig_out[CREDENTIAL_KEY_LEN]);
|
||||
|
||||
/* Derive a new-format store line for `user`/`password` and write it (without a
|
||||
* trailing newline) into out. A random 16-byte salt is used. On failure err is
|
||||
* filled. Used by --hash-credentials and by tests. */
|
||||
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);
|
||||
|
||||
/* Read `user:password` lines from `path` (the same no-group/other-bits check as
|
||||
* 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.
|
||||
* Returns 0 on success, -1 on error (err filled). Used by
|
||||
* `--hash-credentials`. */
|
||||
int credentials_hash_file(const char* path, uint32_t iters, FILE* out, char* err, size_t err_size);
|
||||
|
||||
/* Read the CLIENT-side secret file: the first meaningful line is
|
||||
* `user:password` (the literal password). *user_out and *password_out are
|
||||
* freshly allocated on success (password is plaintext -- the caller hashes it
|
||||
* and then burns/frees it); both are NULL on error. Returns 0 on success, -1
|
||||
* on failure (err filled: the path is named, never the credential itself).
|
||||
* Only the line's trailing CR/LF are stripped: the password's bytes are
|
||||
* otherwise preserved exactly, so a password with leading/trailing whitespace
|
||||
* (after the ':') is kept usable. The username is trimmed of surrounding
|
||||
* space/tabs. */
|
||||
* freshly allocated on success (password is plaintext -- the caller derives the
|
||||
* proof and then burns/frees it); both are NULL on error. Returns 0 on
|
||||
* success, -1 on failure (err filled: the path is named, never the credential
|
||||
* itself). Only the line's trailing CR/LF are stripped: the password's bytes
|
||||
* are otherwise preserved exactly, so a password with leading/trailing
|
||||
* whitespace (after the ':') is kept usable. The username is trimmed of
|
||||
* surrounding space/tabs. */
|
||||
int credentials_read_secret_file(const char* path, char** user_out, char** password_out, char* err,
|
||||
size_t err_size);
|
||||
|
||||
/* Constant-time equality over exactly len bytes. Returns true when the two
|
||||
* buffers match. No early exit: the whole length is always scanned, so a
|
||||
* timing side-channel cannot reveal how many leading bytes matched. */
|
||||
/* Constant-time equality over exactly len bytes. */
|
||||
bool credentials_secure_equal(const char* a, const char* b, size_t len);
|
||||
|
||||
/* Overwrite secret[0..len) with zeros (best-effort wipe of a plaintext
|
||||
* password that is about to be freed). */
|
||||
/* Overwrite secret[0..len) with zeros (best-effort wipe). */
|
||||
void credentials_burn(char* secret, size_t len);
|
||||
|
||||
/* Verify a presented (user, digest) against the store. Returns true only when
|
||||
* the store holds an entry for `user` whose stored digest equals the presented
|
||||
* one. A NULL store, NULL user/digest, unknown user and wrong digest all
|
||||
* return false. The digest comparison runs over a fixed dummy whenever the
|
||||
* user is absent, and the username lookup is a single constant-time
|
||||
* full-length compare (no byte-wise early exit), so neither "unknown user" vs
|
||||
* "wrong password" nor a username prefix match can be distinguished by timing
|
||||
* (no user-enumeration oracle in the comparison path). */
|
||||
bool credentials_verify(const CredentialStore* store, const char* user,
|
||||
const char* presented_hash_hex);
|
||||
|
||||
/* The daemon's per-module auth decision, in one pure, unit-testable function.
|
||||
* `module_users`/`module_user_count` are the module's `auth users` list; a
|
||||
* module that declares auth users requires the presented user to be ON that
|
||||
* list AND to verify against the store. Returns false (fail closed) when the
|
||||
* store is NULL, when no credential was presented, when the user is not on the
|
||||
* module's list, or when verification fails. This is the single decision the
|
||||
* server_module_gate seam applies to an auth-required module. Like
|
||||
* credentials_verify, username matches here use a constant-time full-length
|
||||
* compare rather than a byte-wise-short-circuiting strcmp. */
|
||||
bool credentials_gate_allows(const CredentialStore* store, const char* const* module_users,
|
||||
int module_user_count, const char* presented_user,
|
||||
const char* presented_hash_hex);
|
||||
|
||||
/* Number of entries currently in the store (tests/introspection). */
|
||||
int credentials_store_size(const CredentialStore* store);
|
||||
|
||||
|
||||
@@ -27,6 +27,10 @@ uint32_t get_log_debug_flags(void) {
|
||||
return current_debug_flags;
|
||||
}
|
||||
|
||||
bool log_debug_enabled(LogDebugFlag flag) {
|
||||
return current_log_level <= LOG_LEVEL_DEBUG && (current_debug_flags & flag) != 0;
|
||||
}
|
||||
|
||||
void set_log_info_flags(uint32_t flags) {
|
||||
info_flags = flags;
|
||||
info_flags_explicit = true;
|
||||
|
||||
@@ -29,6 +29,10 @@ void log_perror(const char* context);
|
||||
void set_log_level(LogLevel level);
|
||||
void set_log_debug_flags(uint32_t flags);
|
||||
uint32_t get_log_debug_flags(void);
|
||||
/* True when a log_debug_message() call with the same flag would actually emit:
|
||||
* the debug log level is enabled AND the flag is selected. Hot paths use this
|
||||
* to skip expensive message formatting/escaping when the line is filtered. */
|
||||
bool log_debug_enabled(LogDebugFlag flag);
|
||||
void log_debug_message(LogDebugFlag flag, const char* message, ...);
|
||||
void set_log_info_flags(uint32_t flags);
|
||||
uint32_t get_log_info_flags(void);
|
||||
|
||||
+16
-6
@@ -413,15 +413,24 @@ static const char* status_to_string(Status status) {
|
||||
return "SPECIAL";
|
||||
case STATUS_DIR_TIMES:
|
||||
return "DIR_TIMES";
|
||||
case STATUS_AUTH_CHALLENGE:
|
||||
return "AUTH_CHALLENGE";
|
||||
case STATUS_AUTH_RESPONSE:
|
||||
return "AUTH_RESPONSE";
|
||||
case STATUS_AUTH_OK:
|
||||
return "AUTH_OK";
|
||||
case STATUS_AUTH_FAILED:
|
||||
return "AUTH_FAILED";
|
||||
default:
|
||||
return "UNKNOWN";
|
||||
}
|
||||
}
|
||||
|
||||
/* Shared string send/receive implementation. `redact` selects whether the
|
||||
* payload body is written to the LOG_DEBUG_PROTO debug log: secrets (daemon
|
||||
* auth username/digest) set it so a --verbose log never captures a replayable
|
||||
* credential, while every other string keeps its normal debug trace. */
|
||||
* payload body is written to the LOG_DEBUG_PROTO debug log: daemon auth material
|
||||
* (the username and the proof/signature fields) sets it so a --verbose log never
|
||||
* captures a replayable credential, while every other string keeps its normal
|
||||
* debug trace. */
|
||||
static bool protocol_send_str_impl(ProtocolSession* session, const char* data, bool redact) {
|
||||
if (data == NULL)
|
||||
return false;
|
||||
@@ -432,7 +441,7 @@ static bool protocol_send_str_impl(ProtocolSession* session, const char* data, b
|
||||
return false;
|
||||
if (redact) {
|
||||
log_debug_message(LOG_DEBUG_PROTO, "Send String: <redacted>");
|
||||
} else {
|
||||
} else if (log_debug_enabled(LOG_DEBUG_PROTO)) {
|
||||
char* escaped_data = output_escape(data, log_get_8_bit_output());
|
||||
log_debug_message(LOG_DEBUG_PROTO, "Send String: %s",
|
||||
escaped_data ? escaped_data : "<allocation failed>");
|
||||
@@ -465,7 +474,7 @@ static char* protocol_receive_str_impl(ProtocolSession* session, bool redact) {
|
||||
data[size] = '\0';
|
||||
if (redact) {
|
||||
log_debug_message(LOG_DEBUG_PROTO, "Received String: <redacted>");
|
||||
} else {
|
||||
} else if (log_debug_enabled(LOG_DEBUG_PROTO)) {
|
||||
char* escaped_data = output_escape(data, log_get_8_bit_output());
|
||||
log_debug_message(LOG_DEBUG_PROTO, "Received String: %s",
|
||||
escaped_data ? escaped_data : "<allocation failed>");
|
||||
@@ -594,7 +603,8 @@ char* receive_str(int fd) {
|
||||
return protocol_receive_str(legacy_session(fd, -1));
|
||||
}
|
||||
/* Redacted variants: identical framing, but the string body is never written to
|
||||
the debug protocol log. Used for the daemon auth username/digest. */
|
||||
the debug protocol log. Used for daemon auth material (username, proof,
|
||||
signature). */
|
||||
bool send_str_redacted(int fd, const char* data) {
|
||||
return protocol_send_str_redacted(legacy_session(-1, fd), data);
|
||||
}
|
||||
|
||||
+17
-3
@@ -113,7 +113,20 @@ enum NET_STATUS {
|
||||
* than MAX_MANIFEST_ENTRIES is split across repeated frames. The receiver
|
||||
* defers the actual utimensat until its own delete/publish phase has
|
||||
* committed, then skips the whole set when -O/--omit-dir-times is set. */
|
||||
STATUS_DIR_TIMES
|
||||
STATUS_DIR_TIMES,
|
||||
/* Daemon SCRAM-SHA-256 authentication (A7 remediation, protocol 2.19.0).
|
||||
* STATUS_AUTH_CHALLENGE: the server requires auth and is about to send the
|
||||
* iteration count, the base64 salt and the base64 server nonce.
|
||||
* STATUS_AUTH_RESPONSE: the client's reply, followed by the base64 client
|
||||
* nonce and the base64 ClientProof. STATUS_AUTH_OK: the client proof
|
||||
* verified, followed by the base64 ServerSignature. STATUS_AUTH_FAILED:
|
||||
* a single generic refusal (unknown user, off-list user, wrong proof,
|
||||
* missing/malformed credentials) after which the server closes without
|
||||
* writing any data. */
|
||||
STATUS_AUTH_CHALLENGE,
|
||||
STATUS_AUTH_RESPONSE,
|
||||
STATUS_AUTH_OK,
|
||||
STATUS_AUTH_FAILED
|
||||
};
|
||||
|
||||
void io_set_fds(int read_fd, int write_fd);
|
||||
@@ -138,8 +151,9 @@ bool protocol_send_str(ProtocolSession* session, const char* data);
|
||||
char* protocol_receive_str(ProtocolSession* session);
|
||||
/* Redacted string variants: identical wire framing to protocol_send_str /
|
||||
* protocol_receive_str, but the payload body is replaced by `<redacted>` in the
|
||||
* LOG_DEBUG_PROTO debug log. Used for secrets (daemon auth username/digest) so
|
||||
* a --verbose log can never capture a replayable credential. */
|
||||
* LOG_DEBUG_PROTO debug log. Used for daemon auth material (the username and
|
||||
* the proof/signature fields) so a --verbose log can never capture a credential
|
||||
* that could be replayed. */
|
||||
bool protocol_send_str_redacted(ProtocolSession* session, const char* data);
|
||||
char* protocol_receive_str_redacted(ProtocolSession* session);
|
||||
bool protocol_send_data(ProtocolSession* session, const Data* data);
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
#include "utils.h"
|
||||
#include "array_list.h"
|
||||
#include "log.h"
|
||||
#include <arpa/inet.h>
|
||||
#include <dirent.h>
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <netinet/in.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <stdint.h>
|
||||
#include <sys/socket.h>
|
||||
#include <sys/stat.h>
|
||||
#include <unistd.h>
|
||||
|
||||
@@ -543,3 +546,69 @@ bool append_tail_length(unsigned long long old_size, unsigned long long check_si
|
||||
*tail_out = check_size - old_size;
|
||||
return true;
|
||||
}
|
||||
|
||||
/* True when a bound/peer socket address is on the loopback interface: any
|
||||
127.0.0.0/8 IPv4 address, IPv6 ::1, or an IPv4-mapped ::ffff:127.x.x.x. This
|
||||
is the transport-local test the daemon auth gate uses to decide whether a
|
||||
plaintext connection is a trustworthy local/SSH channel. */
|
||||
bool utils_sockaddr_is_loopback(const struct sockaddr* addr) {
|
||||
if (!addr)
|
||||
return false;
|
||||
if (addr->sa_family == AF_INET) {
|
||||
const struct sockaddr_in* v4 = (const struct sockaddr_in*)addr;
|
||||
uint32_t host = ntohl(v4->sin_addr.s_addr);
|
||||
return (host & 0xff000000u) == 0x7f000000u;
|
||||
}
|
||||
if (addr->sa_family == AF_INET6) {
|
||||
const struct sockaddr_in6* v6 = (const struct sockaddr_in6*)addr;
|
||||
if (IN6_IS_ADDR_LOOPBACK(&v6->sin6_addr))
|
||||
return true;
|
||||
/* An IPv4-mapped ::ffff:127.x.x.x is loopback too. */
|
||||
if (IN6_IS_ADDR_V4MAPPED(&v6->sin6_addr) && v6->sin6_addr.s6_addr[12] == 127)
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/* True when the fd's peer is provably a loopback TCP peer: getpeername must
|
||||
succeed AND the returned address must classify as loopback. Everything else
|
||||
is NOT local, including a non-socket descriptor (pipe/socketpair): a failed
|
||||
getpeername (ENOTSOCK, ENOTCONN, ...) fails closed. The daemon auth gate
|
||||
must not treat "I cannot tell" as "trusted", and daemon auth modules are
|
||||
daemon-only anyway (the --stdio path never loads a daemon config). */
|
||||
bool utils_fd_peer_is_local(int fd) {
|
||||
if (fd < 0)
|
||||
return false;
|
||||
struct sockaddr_storage peer;
|
||||
socklen_t length = sizeof(peer);
|
||||
if (getpeername(fd, (struct sockaddr*)&peer, &length) != 0)
|
||||
return false;
|
||||
return utils_sockaddr_is_loopback((const struct sockaddr*)&peer);
|
||||
}
|
||||
|
||||
/* True when a client-supplied host string names a loopback destination:
|
||||
"localhost", any 127.0.0.0/8 literal, "::1", or "[::1]". */
|
||||
bool utils_host_is_loopback(const char* host) {
|
||||
if (!host || host[0] == '\0')
|
||||
return false;
|
||||
if (strcmp(host, "localhost") == 0)
|
||||
return true;
|
||||
struct in_addr v4;
|
||||
if (inet_pton(AF_INET, host, &v4) == 1)
|
||||
return (ntohl(v4.s_addr) & 0xff000000u) == 0x7f000000u;
|
||||
struct in6_addr addr6;
|
||||
if (host[0] == '[') {
|
||||
size_t len = strlen(host);
|
||||
if (len < 3 || host[len - 1] != ']')
|
||||
return false;
|
||||
/* inet_pton needs the bare address, not the bracketed form. */
|
||||
char bare[INET6_ADDRSTRLEN];
|
||||
if (len - 2 >= sizeof(bare))
|
||||
return false;
|
||||
memcpy(bare, host + 1, len - 2);
|
||||
bare[len - 2] = '\0';
|
||||
return inet_pton(AF_INET6, bare, &addr6) == 1 && IN6_IS_ADDR_LOOPBACK(&addr6);
|
||||
}
|
||||
return inet_pton(AF_INET6, host, &addr6) == 1 && IN6_IS_ADDR_LOOPBACK(&addr6);
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
#include "array_list.h"
|
||||
#include <stddef.h>
|
||||
#include <stdbool.h>
|
||||
#include <sys/socket.h>
|
||||
|
||||
char* str_dup(const char* string);
|
||||
char* output_escape(const char* string, bool eight_bit_output);
|
||||
@@ -66,5 +67,15 @@ bool format_human_bytes(unsigned long long bytes, char* buffer, size_t buffer_si
|
||||
bool append_resume_eligible(unsigned long long old_size, unsigned long long check_size);
|
||||
bool append_tail_length(unsigned long long old_size, unsigned long long check_size,
|
||||
unsigned long long* tail_out);
|
||||
/* Loopback / local-transport classification for the daemon auth gate and the
|
||||
client credential rule. utils_sockaddr_is_loopback accepts 127.0.0.0/8,
|
||||
IPv6 ::1 and IPv4-mapped ::ffff:127.x.x.x; utils_host_is_loopback additionally
|
||||
accepts the literal "localhost". utils_fd_peer_is_local is fail-closed: it is
|
||||
true only when getpeername SUCCEEDS and reports a loopback peer -- a non-socket
|
||||
descriptor (pipe/socketpair) or any getpeername error yields false. See
|
||||
utils.c for the exact accepted forms. */
|
||||
bool utils_sockaddr_is_loopback(const struct sockaddr* addr);
|
||||
bool utils_fd_peer_is_local(int fd);
|
||||
bool utils_host_is_loopback(const char* host);
|
||||
|
||||
#endif
|
||||
@@ -26,10 +26,12 @@
|
||||
* pre-loaded into a second pipe so a single thread suffices.
|
||||
*/
|
||||
#include "config.h"
|
||||
#include "credentials.h"
|
||||
#include "protocol.h"
|
||||
#include "utils.h"
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <openssl/evp.h>
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
@@ -50,6 +52,8 @@ static unsigned char* g_frame;
|
||||
static size_t g_frame_len;
|
||||
static size_t g_version_len; /* length of the leading version-string frame */
|
||||
static size_t g_usermap_count_off; /* offset of the usermap count int, 0 = unknown */
|
||||
static size_t g_auth_off; /* offset of the auth presence int, 0 = unknown */
|
||||
static bool g_auth_found; /* whether g_auth_off is valid */
|
||||
static bool g_frame_ready;
|
||||
|
||||
/* Read the canonical frame from the send peer. The producer shuts down its
|
||||
@@ -97,6 +101,10 @@ static void build_canonical_frame(void) {
|
||||
return;
|
||||
cfg->send_directory = str_dup("/src");
|
||||
cfg->receive_root_directory = str_dup("/dst");
|
||||
/* Force the shortened auth block (`[present][username]`) to be present so the
|
||||
* fuzzer can mutate it. */
|
||||
cfg->auth_user = str_dup("alice");
|
||||
cfg->auth_password = str_dup("alice-s3cret");
|
||||
/* Force the three P8 tail fields to be present (copy-as requires metadata). */
|
||||
cfg->copy_as_set = true;
|
||||
cfg->copy_as_uid = 0;
|
||||
@@ -171,6 +179,97 @@ out:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Locate the auth username string (a size_t length followed by its bytes);
|
||||
* the presence int sits one int before the length. The username bytes cannot
|
||||
* start before sizeof(size_t)+sizeof(int) without the presence-int offset
|
||||
* underflowing, so begin the scan there. */
|
||||
const char* auth_name = "alice";
|
||||
size_t auth_name_len = strlen(auth_name);
|
||||
if (g_frame_len >= sizeof(size_t) + auth_name_len + sizeof(int)) {
|
||||
for (size_t i = sizeof(size_t) + sizeof(int); i + auth_name_len <= g_frame_len; i++) {
|
||||
if (memcmp(g_frame + i, auth_name, auth_name_len) != 0)
|
||||
continue;
|
||||
size_t found_len = 0;
|
||||
memcpy(&found_len, g_frame + i - sizeof(size_t), sizeof(size_t));
|
||||
if (found_len == auth_name_len) {
|
||||
g_auth_off = i - sizeof(size_t) - sizeof(int);
|
||||
g_auth_found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Fuzz the A7 auth crypto primitives directly: arbitrary bytes through the
|
||||
* base64 decoder, plus a self-consistent SCRAM property (a proof built from a
|
||||
* chosen client key must verify, while a tampered proof, a proof replayed
|
||||
* against a different nonce, and a not-found verifier must all be refused). */
|
||||
static uint8_t pick_byte(const uint8_t* data, size_t size, size_t index) {
|
||||
return size ? data[index % size] : 0;
|
||||
}
|
||||
|
||||
static void fuzz_credentials(const uint8_t* data, size_t size) {
|
||||
char b64[300];
|
||||
size_t n = size < sizeof(b64) - 1 ? size : sizeof(b64) - 1;
|
||||
memcpy(b64, data, n);
|
||||
b64[n] = '\0';
|
||||
uint8_t decoded[64];
|
||||
size_t decoded_len = 0;
|
||||
(void)credentials_b64_decode(b64, decoded, sizeof(decoded), &decoded_len);
|
||||
|
||||
uint8_t client_key[CREDENTIAL_KEY_LEN];
|
||||
uint8_t stored_key[CREDENTIAL_KEY_LEN];
|
||||
uint8_t server_key[CREDENTIAL_KEY_LEN];
|
||||
uint8_t snonce[CREDENTIAL_NONCE_LEN];
|
||||
uint8_t cnonce[CREDENTIAL_NONCE_LEN];
|
||||
for (size_t i = 0; i < CREDENTIAL_KEY_LEN; i++) {
|
||||
client_key[i] = pick_byte(data, size, i);
|
||||
server_key[i] = pick_byte(data, size, i + CREDENTIAL_KEY_LEN);
|
||||
}
|
||||
for (size_t i = 0; i < CREDENTIAL_NONCE_LEN; i++) {
|
||||
snonce[i] = pick_byte(data, size, i + 2 * CREDENTIAL_KEY_LEN);
|
||||
cnonce[i] = pick_byte(data, size, i + 2 * CREDENTIAL_KEY_LEN + CREDENTIAL_NONCE_LEN);
|
||||
}
|
||||
unsigned int stored_len = 0;
|
||||
if (EVP_Digest(client_key, sizeof(client_key), stored_key, &stored_len, EVP_sha256(), NULL) !=
|
||||
1 ||
|
||||
stored_len != CREDENTIAL_KEY_LEN)
|
||||
return;
|
||||
uint8_t auth_msg[CREDENTIAL_AUTH_MESSAGE_MAX];
|
||||
size_t msg_len = 0;
|
||||
if (!credentials_build_auth_message("alice", snonce, cnonce, auth_msg, sizeof(auth_msg),
|
||||
&msg_len))
|
||||
return;
|
||||
uint8_t proof[CREDENTIAL_KEY_LEN];
|
||||
uint8_t server_sig[CREDENTIAL_KEY_LEN];
|
||||
if (!credentials_client_proof(client_key, stored_key, server_key, auth_msg, msg_len, proof,
|
||||
server_sig))
|
||||
return;
|
||||
CredentialVerifier verifier;
|
||||
memset(&verifier, 0, sizeof(verifier));
|
||||
verifier.found = true;
|
||||
verifier.iters = CREDENTIAL_DEFAULT_ITERS;
|
||||
memcpy(verifier.stored_key, stored_key, CREDENTIAL_KEY_LEN);
|
||||
memcpy(verifier.server_key, server_key, CREDENTIAL_KEY_LEN);
|
||||
uint8_t out_sig[CREDENTIAL_KEY_LEN];
|
||||
if (!credentials_verify_response(&verifier, "alice", snonce, cnonce, proof, out_sig))
|
||||
abort();
|
||||
if (memcmp(out_sig, server_sig, CREDENTIAL_KEY_LEN) != 0)
|
||||
abort();
|
||||
uint8_t bad_proof[CREDENTIAL_KEY_LEN];
|
||||
memcpy(bad_proof, proof, CREDENTIAL_KEY_LEN);
|
||||
bad_proof[pick_byte(data, size, 0) % CREDENTIAL_KEY_LEN] ^= 0x01;
|
||||
if (credentials_verify_response(&verifier, "alice", snonce, cnonce, bad_proof, out_sig))
|
||||
abort();
|
||||
uint8_t other_cnonce[CREDENTIAL_NONCE_LEN];
|
||||
memcpy(other_cnonce, cnonce, CREDENTIAL_NONCE_LEN);
|
||||
other_cnonce[pick_byte(data, size, 1) % CREDENTIAL_NONCE_LEN] ^= 0x80;
|
||||
if (credentials_verify_response(&verifier, "alice", snonce, other_cnonce, proof, out_sig))
|
||||
abort();
|
||||
verifier.found = false;
|
||||
if (credentials_verify_response(&verifier, "alice", snonce, cnonce, proof, out_sig))
|
||||
abort();
|
||||
}
|
||||
|
||||
/* Best-effort non-blocking write: an oversized fuzz input is truncated rather
|
||||
@@ -219,6 +318,8 @@ static void receive_stream(const unsigned char* prefix, size_t prefix_len, const
|
||||
}
|
||||
|
||||
int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) {
|
||||
fuzz_credentials(data, size);
|
||||
|
||||
if (!g_frame_ready)
|
||||
build_canonical_frame();
|
||||
|
||||
@@ -229,6 +330,10 @@ int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) {
|
||||
/* Keep the valid version prefix, fuzz everything after it. */
|
||||
receive_stream(g_frame, g_version_len, data, size);
|
||||
|
||||
/* Keep the valid frame up to the shortened auth block, fuzz it. */
|
||||
if (g_auth_found)
|
||||
receive_stream(g_frame, g_auth_off, data, size);
|
||||
|
||||
/* Keep the valid frame up to the P8 tail, fuzz super_mode + copy-as. */
|
||||
if (g_frame_len > P8_TAIL_BYTES)
|
||||
receive_stream(g_frame, g_frame_len - P8_TAIL_BYTES, data, size);
|
||||
|
||||
@@ -5,18 +5,25 @@ started with --daemon reads a FastSync-native module config file, the client
|
||||
asks for a module with a host::module/path destination, and the transfer lands
|
||||
in the configured module root only. Read-only modules, unknown modules, and
|
||||
auth-required modules without valid credentials are all refused cleanly before
|
||||
any data moves. Wave B (daemon authentication) adds the real credential
|
||||
round-trips exercised in TestDaemonAuthentication: modules that declare
|
||||
`auth users` accept only a client whose --password-file presents a username on
|
||||
the module's list with a matching password (verified as a SHA-256 digest), and
|
||||
the daemon refuses to start when such a module has no credential store.
|
||||
any data moves. The A7 auth wave adds the real credential round-trips exercised
|
||||
in TestDaemonAuthentication: modules that declare `auth users` accept only a
|
||||
client whose --password-file presents a username on the module's list, proven
|
||||
through a SCRAM-SHA-256-style challenge/response against a salted PBKDF2
|
||||
verifier. The daemon refuses to start when such a module has no credential
|
||||
store, a legacy SHA-256 store line is hard-rejected, and a replayed response
|
||||
from another connection is refused.
|
||||
"""
|
||||
import base64
|
||||
import glob
|
||||
import hashlib
|
||||
import hmac
|
||||
import os
|
||||
import select
|
||||
import shutil
|
||||
import signal
|
||||
import socket
|
||||
import stat
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
@@ -58,9 +65,39 @@ ALICE_PASS = "alice-s3cret"
|
||||
BOB_PASS = "bob-s3cret"
|
||||
WRONG_PASS = "wrong-password"
|
||||
|
||||
# The store holds a salted PBKDF2 verifier (A7 SCRAM); this is the exact
|
||||
# derivation the C implementation performs, recomputed here so the tests are an
|
||||
# independent reference. 100000 keeps the module import fast while staying at
|
||||
# the validation minimum.
|
||||
CRED_ITERS = 100000
|
||||
|
||||
def _pw_hash(password):
|
||||
return hashlib.sha256(password.encode()).hexdigest()
|
||||
|
||||
def _verifier(password, salt, iters=CRED_ITERS):
|
||||
key = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, iters, 32)
|
||||
client_key = hmac.new(key, b"Client Key", hashlib.sha256).digest()
|
||||
stored_key = hashlib.sha256(client_key).digest()
|
||||
server_key = hmac.new(key, b"Server Key", hashlib.sha256).digest()
|
||||
return stored_key, server_key
|
||||
|
||||
|
||||
def _store_line(user, password, iters=CRED_ITERS, salt=None):
|
||||
if salt is None:
|
||||
salt = os.urandom(16)
|
||||
stored_key, server_key = _verifier(password, salt, iters)
|
||||
return "%s:$fastsync$1$pbkdf2-sha256$%d$%s$%s$%s" % (
|
||||
user, iters, base64.b64encode(salt).decode(),
|
||||
base64.b64encode(stored_key).decode(), base64.b64encode(server_key).decode())
|
||||
|
||||
|
||||
def _store_secrets(line):
|
||||
"""The base64 stored_key/server_key fields of a store line (the values that
|
||||
must never appear in a log)."""
|
||||
parts = line.split("$")
|
||||
return parts[-2], parts[-1]
|
||||
|
||||
|
||||
ALICE_LINE = _store_line("alice", ALICE_PASS)
|
||||
BOB_LINE = _store_line("bob", BOB_PASS)
|
||||
|
||||
|
||||
def _write_client_password_file(path, user, password):
|
||||
@@ -159,12 +196,12 @@ def daemon_env():
|
||||
os.makedirs(d, exist_ok=True)
|
||||
generate_test_files(SOURCE_DIR, full=False)
|
||||
|
||||
# Server-side credential store: alice and bob (password digests only; the
|
||||
# plaintext passwords never appear on the daemon host or in any log).
|
||||
# Server-side credential store: alice and bob (salted PBKDF2 verifiers only;
|
||||
# the plaintext passwords never appear on the daemon host or in any log).
|
||||
with open(CRED_FILE, "w") as f:
|
||||
f.write("# daemon credential store (Wave B)\n")
|
||||
f.write("alice:%s\n" % _pw_hash(ALICE_PASS))
|
||||
f.write("bob:%s\n" % _pw_hash(BOB_PASS))
|
||||
f.write("# daemon credential store (A7 SCRAM)\n")
|
||||
f.write(ALICE_LINE + "\n")
|
||||
f.write(BOB_LINE + "\n")
|
||||
os.chmod(CRED_FILE, 0o600)
|
||||
|
||||
# The config's port is a free port chosen per worker; the `daemon` fixture
|
||||
@@ -518,6 +555,139 @@ class TestDaemonRejection:
|
||||
d.stop()
|
||||
|
||||
|
||||
# Numeric status values (must match the enum order in src/shared/protocol.h).
|
||||
STATUS_AUTH_CHALLENGE = 21
|
||||
STATUS_AUTH_RESPONSE = 22
|
||||
_AUTH_FRAME_MAX = 1 << 20
|
||||
|
||||
|
||||
def _wire_string_frame_len(buf, off):
|
||||
"""Return the total byte length of the wire string at buf[off], or None when
|
||||
more bytes are needed."""
|
||||
if len(buf) < off + 8:
|
||||
return None
|
||||
(length,) = struct.unpack_from("<Q", buf, off)
|
||||
if length > _AUTH_FRAME_MAX:
|
||||
raise ValueError("oversized auth frame string")
|
||||
if len(buf) < off + 8 + length:
|
||||
return None
|
||||
return 8 + length
|
||||
|
||||
|
||||
def _client_cmd(dest, port, cred_path):
|
||||
return CLIENT_CMD + ["--source-dir", SOURCE_DIR, "--dest-dir", dest,
|
||||
"--save-to-disk", "--server-port", str(port),
|
||||
"--password-file", cred_path]
|
||||
|
||||
|
||||
class _AuthReplayProxy:
|
||||
"""A one-connection-at-a-time TCP relay in front of the daemon.
|
||||
|
||||
The capture connection records the client's STATUS_AUTH_RESPONSE frame (the
|
||||
status, the client nonce string and the proof string); the replay connection
|
||||
substitutes that recorded frame for its own response, so the daemon sees a
|
||||
proof bound to the FIRST connection's challenge nonce."""
|
||||
|
||||
def __init__(self, backend_port):
|
||||
self.backend = ("127.0.0.1", backend_port)
|
||||
self.server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
self.server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
self.server.bind(("127.0.0.1", 0))
|
||||
self.server.listen(4)
|
||||
self.server.settimeout(20)
|
||||
self.port = self.server.getsockname()[1]
|
||||
self.stolen = None
|
||||
# Set when a relayed connection received a SCRAM challenge from the
|
||||
# backend; lets a test assert the daemon refused before any challenge.
|
||||
self.saw_challenge = False
|
||||
|
||||
def close(self):
|
||||
try:
|
||||
self.server.close()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def _run_connection(self, capture):
|
||||
client, _ = self.server.accept()
|
||||
backend = socket.create_connection(self.backend, timeout=20)
|
||||
client.settimeout(20)
|
||||
backend.settimeout(20)
|
||||
buf_c = b""
|
||||
buf_s = b""
|
||||
state = "config"
|
||||
try:
|
||||
while True:
|
||||
ready, _, _ = select.select([client, backend], [], [], 20)
|
||||
if not ready:
|
||||
break
|
||||
eof = False
|
||||
for sock in ready:
|
||||
data = sock.recv(65536)
|
||||
if not data:
|
||||
eof = True
|
||||
continue
|
||||
if sock is client:
|
||||
buf_c += data
|
||||
else:
|
||||
buf_s += data
|
||||
if state == "config":
|
||||
if buf_c:
|
||||
backend.sendall(buf_c)
|
||||
buf_c = b""
|
||||
if len(buf_s) >= 4:
|
||||
(status,) = struct.unpack_from("<i", buf_s, 0)
|
||||
if status == STATUS_AUTH_CHALLENGE:
|
||||
self.saw_challenge = True
|
||||
off = 4 + 4 # status int + iteration int
|
||||
for _ in range(2):
|
||||
frame = _wire_string_frame_len(buf_s, off)
|
||||
if frame is None:
|
||||
break
|
||||
off += frame
|
||||
else:
|
||||
client.sendall(buf_s[:off])
|
||||
buf_s = buf_s[off:]
|
||||
state = "auth"
|
||||
else:
|
||||
if buf_s:
|
||||
client.sendall(buf_s)
|
||||
buf_s = b""
|
||||
state = "relay"
|
||||
elif state == "auth":
|
||||
if len(buf_c) >= 4:
|
||||
off = 4
|
||||
for _ in range(2):
|
||||
frame = _wire_string_frame_len(buf_c, off)
|
||||
if frame is None:
|
||||
break
|
||||
off += frame
|
||||
else:
|
||||
response = buf_c[:off]
|
||||
buf_c = buf_c[off:]
|
||||
if capture:
|
||||
self.stolen = response
|
||||
backend.sendall(response)
|
||||
else:
|
||||
assert self.stolen is not None
|
||||
backend.sendall(self.stolen)
|
||||
state = "relay"
|
||||
if buf_s:
|
||||
client.sendall(buf_s)
|
||||
buf_s = b""
|
||||
else:
|
||||
if buf_c:
|
||||
backend.sendall(buf_c)
|
||||
buf_c = b""
|
||||
if buf_s:
|
||||
client.sendall(buf_s)
|
||||
buf_s = b""
|
||||
if eof:
|
||||
break
|
||||
finally:
|
||||
client.close()
|
||||
backend.close()
|
||||
|
||||
|
||||
class TestDaemonAuthentication:
|
||||
"""Wave B password authentication round-trips on the shared daemon (its
|
||||
config declares `locked` with `auth users = alice` and `team` with
|
||||
@@ -600,6 +770,61 @@ class TestDaemonAuthentication:
|
||||
finally:
|
||||
os.unlink(cred_path)
|
||||
|
||||
@pytest.mark.ci
|
||||
def test_remote_plaintext_credentials_rejected_client_side(self):
|
||||
"""A7-3/S1: sending daemon credentials to a clearly non-local daemon
|
||||
WITHOUT --tls is refused by the client itself, before any network I/O
|
||||
(192.0.2.0/24 is TEST-NET-1 and never reachable, so a network attempt
|
||||
would time out instead of failing fast)."""
|
||||
cred_path = os.path.join(TEST_DATA_DIR, "client_remote.pw")
|
||||
_write_client_password_file(cred_path, "alice", ALICE_PASS)
|
||||
try:
|
||||
cmd = CLIENT_CMD + ["--source-dir", SOURCE_DIR,
|
||||
"--dest-dir", "192.0.2.1::files",
|
||||
"--save-to-disk", "--password-file", cred_path,
|
||||
"--server-port", "873"]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=15)
|
||||
assert result.returncode != 0
|
||||
combined = (result.stderr or "") + (result.stdout or "")
|
||||
assert "--tls" in combined, combined
|
||||
finally:
|
||||
os.unlink(cred_path)
|
||||
|
||||
@pytest.mark.ci
|
||||
def test_loopback_plaintext_refused_before_challenge_without_flag(self):
|
||||
"""A7-3/S1: an auth-required module reached over loopback plaintext is
|
||||
refused at the config gate -- before any SCRAM challenge is sent -- when
|
||||
the operator did NOT pass --allow-unauthenticated. That flag is the
|
||||
explicit opt-in that makes loopback plaintext an accepted auth
|
||||
transport; it never permits remote plaintext auth. A relay records the
|
||||
daemon's first status frame so a challenge is directly observable."""
|
||||
d = DaemonManager()
|
||||
port = _find_free_port()
|
||||
log_path = os.path.join(TEST_DATA_DIR, "fastsyncd_noauth_auth.log")
|
||||
log = open(log_path, "w")
|
||||
cmd = SERVER_CMD + ["--daemon", "--config", CONF_FILE, "--no-detach",
|
||||
"--password-file", CRED_FILE, "--dparam", f"port={port}"]
|
||||
d._proc = subprocess.Popen(cmd, stdout=log, stderr=log, stdin=subprocess.DEVNULL,
|
||||
start_new_session=True)
|
||||
d._port = port
|
||||
_wait_for_port(port, timeout=10)
|
||||
proxy = _AuthReplayProxy(port)
|
||||
try:
|
||||
before = _tree_file_count(AUTH_MODULE)
|
||||
cred = os.path.join(TEST_DATA_DIR, "noauth_loopback.pw")
|
||||
_write_client_password_file(cred, "alice", ALICE_PASS)
|
||||
proc = subprocess.Popen(_client_cmd("127.0.0.1::locked", proxy.port, cred),
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
||||
proxy._run_connection(capture=True)
|
||||
out, err = proc.communicate(timeout=30)
|
||||
assert proc.returncode != 0, "auth over unflagged loopback plaintext must be refused"
|
||||
assert not proxy.saw_challenge, "daemon sent a SCRAM challenge before the refusal"
|
||||
assert _tree_file_count(AUTH_MODULE) == before, "a refused connection wrote data"
|
||||
os.unlink(cred)
|
||||
finally:
|
||||
proxy.close()
|
||||
d.stop()
|
||||
|
||||
def test_client_empty_password_file_rejected(self):
|
||||
"""Client-side: an empty --password-file is rejected (no credentials)."""
|
||||
cred_path = os.path.join(TEST_DATA_DIR, "client_empty.pw")
|
||||
@@ -640,8 +865,63 @@ class TestDaemonAuthentication:
|
||||
finally:
|
||||
d.stop()
|
||||
|
||||
@pytest.mark.ci
|
||||
def test_replayed_auth_response_rejected(self, daemon):
|
||||
"""A7 replay defense: an auth response captured from one connection is
|
||||
refused on a second connection (the proof is bound to the challenge
|
||||
nonce), and nothing is written to the module root."""
|
||||
proxy = _AuthReplayProxy(daemon.port)
|
||||
try:
|
||||
cred_a = os.path.join(TEST_DATA_DIR, "replay_a.pw")
|
||||
_write_client_password_file(cred_a, "alice", ALICE_PASS)
|
||||
proc_a = subprocess.Popen(_client_cmd("127.0.0.1::locked", proxy.port, cred_a),
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
||||
proxy._run_connection(capture=True)
|
||||
out_a, err_a = proc_a.communicate(timeout=30)
|
||||
assert proc_a.returncode == 0, err_a or out_a
|
||||
assert proxy.stolen is not None
|
||||
os.unlink(cred_a)
|
||||
|
||||
before = _tree_file_count(AUTH_MODULE)
|
||||
cred_b = os.path.join(TEST_DATA_DIR, "replay_b.pw")
|
||||
_write_client_password_file(cred_b, "alice", ALICE_PASS)
|
||||
proc_b = subprocess.Popen(_client_cmd("127.0.0.1::locked", proxy.port, cred_b),
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
||||
proxy._run_connection(capture=False)
|
||||
out_b, err_b = proc_b.communicate(timeout=30)
|
||||
assert proc_b.returncode != 0, "a replayed auth response must be refused"
|
||||
assert _tree_file_count(AUTH_MODULE) == before, \
|
||||
"a replayed auth response wrote data"
|
||||
os.unlink(cred_b)
|
||||
finally:
|
||||
proxy.close()
|
||||
|
||||
def test_legacy_store_refuses_to_start(self):
|
||||
"""A legacy `user:SHA256HEX` store is hard-rejected: the daemon must not
|
||||
start and must never accept a replayable bearer digest."""
|
||||
legacy = os.path.join(TEST_DATA_DIR, "fastsyncd_legacy.passwd")
|
||||
with open(legacy, "w") as f:
|
||||
f.write("alice:9b90e524e94995ee4aeae2ee3c428a53405d1e8db147f44facc46797d0caf4c3\n")
|
||||
os.chmod(legacy, 0o600)
|
||||
conf = os.path.join(TEST_DATA_DIR, "fastsyncd_legacy.conf")
|
||||
port = _find_free_port()
|
||||
with open(conf, "w") as f:
|
||||
f.write("port = %d\n\n[locked]\npath = %s\nauth users = alice\n" % (port, AUTH_MODULE))
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
SERVER_CMD + ["--daemon", "--config", conf, "--no-detach",
|
||||
"--password-file", legacy],
|
||||
capture_output=True, text=True, timeout=15)
|
||||
assert proc.returncode != 0
|
||||
combined = (proc.stderr or "") + (proc.stdout or "")
|
||||
assert "legacy" in combined
|
||||
assert "alice" in combined
|
||||
finally:
|
||||
os.unlink(legacy)
|
||||
os.unlink(conf)
|
||||
|
||||
def test_auth_log_does_not_leak_password(self, daemon):
|
||||
"""The daemon log must never contain the password or its digest."""
|
||||
"""The daemon log must never contain the password or the store verifier."""
|
||||
log_path = os.path.join(TEST_DATA_DIR, "fastsyncd.log")
|
||||
before = os.path.getsize(log_path) if os.path.exists(log_path) else 0
|
||||
_push_with_creds("127.0.0.1::locked", daemon.port, "alice", WRONG_PASS)
|
||||
@@ -652,15 +932,15 @@ class TestDaemonAuthentication:
|
||||
tail = f.read().decode("utf-8", "replace")
|
||||
assert ALICE_PASS not in tail
|
||||
assert WRONG_PASS not in tail
|
||||
assert _pw_hash(ALICE_PASS) not in tail
|
||||
assert _pw_hash(WRONG_PASS) not in tail
|
||||
for secret in _store_secrets(ALICE_LINE):
|
||||
assert secret not in tail
|
||||
assert "$fastsync$" not in tail
|
||||
|
||||
def test_auth_digest_not_logged_at_debug_level(self):
|
||||
def test_auth_secrets_not_logged_at_debug_level(self):
|
||||
"""Under --verbose the daemon enables LOG_DEBUG_ALL, which normally
|
||||
traces every protocol string -- the auth username/digest must NOT leak
|
||||
into that trace even then. The redacted marker is logged instead, and
|
||||
the digest/username/password never appear while debug protocol logging
|
||||
is actually proving itself active."""
|
||||
traces every protocol string -- the auth username/proof/signature must
|
||||
NOT leak into that trace even then. The redacted marker is logged
|
||||
instead, while debug protocol logging is actually proving itself active."""
|
||||
d = DaemonManager()
|
||||
port = _find_free_port()
|
||||
try:
|
||||
@@ -680,8 +960,9 @@ class TestDaemonAuthentication:
|
||||
# The secret-worthy fields must never appear, at any log level.
|
||||
assert ALICE_PASS not in log
|
||||
assert WRONG_PASS not in log
|
||||
assert _pw_hash(ALICE_PASS) not in log
|
||||
assert _pw_hash(WRONG_PASS) not in log
|
||||
for secret in _store_secrets(ALICE_LINE):
|
||||
assert secret not in log
|
||||
assert "$fastsync$" not in log
|
||||
|
||||
|
||||
class TestDaemonMotd:
|
||||
@@ -779,23 +1060,30 @@ class TestDaemonMotd:
|
||||
d.stop()
|
||||
|
||||
|
||||
def _generate_tls_certs(cert_dir):
|
||||
"""Generate a self-signed CA, server cert (with 127.0.0.1 SAN) and a client
|
||||
cert signed by that CA, for the TLS+auth composition test."""
|
||||
def _generate_tls_certs(cert_dir, extra_san_ips=None):
|
||||
"""Generate a self-signed CA, server cert (with 127.0.0.1 SAN plus any
|
||||
extra_san_ips) and two client certs signed by that CA: one with the
|
||||
expected CN (fastsync-client) and one with a WRONG CN, for the TLS+auth
|
||||
composition and wrong-identity tests."""
|
||||
os.makedirs(cert_dir, exist_ok=True)
|
||||
ca_key, ca_cert = os.path.join(cert_dir, "ca.key"), os.path.join(cert_dir, "ca.pem")
|
||||
server_key = os.path.join(cert_dir, "server.key")
|
||||
server_cert = os.path.join(cert_dir, "server.pem")
|
||||
client_key = os.path.join(cert_dir, "client.key")
|
||||
client_cert = os.path.join(cert_dir, "client.pem")
|
||||
wrong_client_key = os.path.join(cert_dir, "wrong_client.key")
|
||||
wrong_client_cert = os.path.join(cert_dir, "wrong_client.pem")
|
||||
subprocess.run(["openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes",
|
||||
"-keyout", ca_key, "-out", ca_cert, "-days", "1",
|
||||
"-subj", "/CN=FastSync Test CA"], check=True, capture_output=True)
|
||||
san = os.path.join(cert_dir, "san.conf")
|
||||
san_ips = ["IP.1 = 127.0.0.1"]
|
||||
for index, ip in enumerate(extra_san_ips or [], start=2):
|
||||
san_ips.append("IP.%d = %s" % (index, ip))
|
||||
with open(san, "w") as f:
|
||||
f.write("[req]\ndistinguished_name = dn\nreq_extensions = v3_req\n\n"
|
||||
"[dn]\nCN = localhost\n\n[v3_req]\nsubjectAltName = @an\n\n"
|
||||
"[an]\nDNS.1 = localhost\nIP.1 = 127.0.0.1\n")
|
||||
"[an]\nDNS.1 = localhost\n" + "\n".join(san_ips) + "\n")
|
||||
subprocess.run(["openssl", "req", "-newkey", "rsa:2048", "-nodes",
|
||||
"-keyout", server_key, "-out", os.path.join(cert_dir, "server.csr"),
|
||||
"-subj", "/CN=localhost", "-config", san], check=True, capture_output=True)
|
||||
@@ -809,12 +1097,20 @@ def _generate_tls_certs(cert_dir):
|
||||
subprocess.run(["openssl", "x509", "-req", "-in", os.path.join(cert_dir, "client.csr"),
|
||||
"-CA", ca_cert, "-CAkey", ca_key, "-CAcreateserial",
|
||||
"-out", client_cert, "-days", "1"], check=True, capture_output=True)
|
||||
subprocess.run(["openssl", "req", "-newkey", "rsa:2048", "-nodes",
|
||||
"-keyout", wrong_client_key, "-out", os.path.join(cert_dir, "wrong_client.csr"),
|
||||
"-subj", "/CN=wrong-client"], check=True, capture_output=True)
|
||||
subprocess.run(["openssl", "x509", "-req", "-in", os.path.join(cert_dir, "wrong_client.csr"),
|
||||
"-CA", ca_cert, "-CAkey", ca_key, "-CAcreateserial",
|
||||
"-out", wrong_client_cert, "-days", "1"], check=True, capture_output=True)
|
||||
return {
|
||||
"ca": ca_cert,
|
||||
"server_cert": server_cert,
|
||||
"server_key": server_key,
|
||||
"client_cert": client_cert,
|
||||
"client_key": client_key,
|
||||
"wrong_client_cert": wrong_client_cert,
|
||||
"wrong_client_key": wrong_client_key,
|
||||
}
|
||||
|
||||
|
||||
@@ -855,3 +1151,45 @@ class TestDaemonTLSAuth:
|
||||
d.stop()
|
||||
os.unlink(client_creds)
|
||||
shutil.rmtree(cert_dir, ignore_errors=True)
|
||||
|
||||
@pytest.mark.ci
|
||||
def test_wrong_client_cn_refused_before_auth_challenge(self):
|
||||
"""A7-3/S1: with --tls AND --allow-unauthenticated, a loopback TLS peer
|
||||
whose CA-valid client certificate does not match --client-cn is still
|
||||
refused at the config gate -- before any SCRAM challenge is sent and
|
||||
before any file data moves. The --allow-unauthenticated flag only opts
|
||||
in loopback PLAINTEXT; it must never turn a wrong-CN TLS peer into an
|
||||
accepted auth transport. Runs over 127.0.0.1 so it is deterministic and
|
||||
never skips; the gate log line (emitted before server_auth_handshake)
|
||||
plus the unchanged module tree prove the refusal preceded any challenge."""
|
||||
cert_dir = os.path.join(TEST_DATA_DIR, "daemon_tls_certs_wrong")
|
||||
certs = _generate_tls_certs(cert_dir)
|
||||
client_creds = os.path.join(TEST_DATA_DIR, "daemon_tls_wrong_client.pw")
|
||||
_write_client_password_file(client_creds, "alice", ALICE_PASS)
|
||||
d = DaemonManager()
|
||||
port = _find_free_port()
|
||||
log_path = os.path.join(TEST_DATA_DIR, "fastsyncd.log")
|
||||
try:
|
||||
d.start(CONF_FILE, port_override=port, extra_args=[
|
||||
"--tls", "--cert", certs["server_cert"], "--key", certs["server_key"],
|
||||
"--ca", certs["ca"], "--client-cn", "fastsync-client",
|
||||
"--password-file", CRED_FILE])
|
||||
before_files = _tree_file_count(AUTH_MODULE)
|
||||
log_before = os.path.getsize(log_path) if os.path.exists(log_path) else 0
|
||||
tls_flags = ["--tls",
|
||||
"--cert", certs["wrong_client_cert"], "--key",
|
||||
certs["wrong_client_key"], "--ca", certs["ca"]]
|
||||
result, _ = run_client(SOURCE_DIR, "127.0.0.1::locked", port=port,
|
||||
flags=tls_flags, extra_args=["--password-file", client_creds])
|
||||
assert result.returncode != 0, "a wrong client CN must be refused"
|
||||
assert _tree_file_count(AUTH_MODULE) == before_files, \
|
||||
"a refused connection wrote file data"
|
||||
with open(log_path, "rb") as f:
|
||||
f.seek(log_before)
|
||||
tail = f.read().decode("utf-8", "replace")
|
||||
assert "requires authentication over an encrypted, verified TLS connection" in tail, \
|
||||
tail[-400:]
|
||||
finally:
|
||||
d.stop()
|
||||
os.unlink(client_creds)
|
||||
shutil.rmtree(cert_dir, ignore_errors=True)
|
||||
@@ -94,14 +94,14 @@ def _seed_protocol_source(source):
|
||||
class TestProtocol:
|
||||
@pytest.mark.ci
|
||||
def test_protocol_current_version_accepted(self, shared_server):
|
||||
"""--protocol=2.18.0 (the current PROTOCOL_VERSION) is accepted and the
|
||||
"""--protocol=2.19.0 (the current PROTOCOL_VERSION) is accepted and the
|
||||
transfer completes normally."""
|
||||
source = os.path.join(TEST_DATA_DIR, "proto_ok_src")
|
||||
dest = os.path.join(TEST_DATA_DIR, "proto_ok_dst")
|
||||
shutil.rmtree(dest, ignore_errors=True)
|
||||
os.makedirs(dest)
|
||||
_seed_protocol_source(source)
|
||||
result, _ = run_client(source, dest, flags=["--protocol=2.18.0"],
|
||||
result, _ = run_client(source, dest, flags=["--protocol=2.19.0"],
|
||||
port=shared_server.port)
|
||||
assert result.returncode == 0, \
|
||||
f"--protocol current run failed: {(result.stderr or result.stdout)[:400]}"
|
||||
@@ -118,7 +118,7 @@ class TestProtocol:
|
||||
shutil.rmtree(dest, ignore_errors=True)
|
||||
os.makedirs(dest)
|
||||
_seed_protocol_source(source)
|
||||
for bad in ("2.17.0", "2.15.0", "2.16.0", "216", "31"):
|
||||
for bad in ("2.18.0", "2.17.0", "2.15.0", "2.16.0", "216", "31"):
|
||||
result, _ = run_client(source, dest, flags=[f"--protocol={bad}"],
|
||||
port=shared_server.port)
|
||||
assert result.returncode != 0, f"--protocol={bad} should be rejected"
|
||||
|
||||
+41
-4
@@ -69,6 +69,42 @@ static void test_validate_config_tls_requirements() {
|
||||
config_delete(cfg);
|
||||
}
|
||||
|
||||
/* A7-3/S1: --password-file sends daemon credentials, so it is only allowed
|
||||
over TLS (which itself mandates a verified --cert/--key/--ca) or to a
|
||||
loopback destination. A remote plaintext daemon is refused up front. */
|
||||
static void test_validate_config_credentials_require_tls_or_loopback() {
|
||||
/* Default host is 127.0.0.1 (loopback), so plaintext credentials are fine. */
|
||||
Config* cfg = valid_client_config();
|
||||
cfg->password_file = str_dup("creds.pw");
|
||||
EXPECT_TRUE(validate_config(cfg));
|
||||
|
||||
/* localhost is loopback too. */
|
||||
free(cfg->server_host);
|
||||
cfg->server_host = str_dup("localhost");
|
||||
EXPECT_TRUE(validate_config(cfg));
|
||||
|
||||
/* A clearly remote host over plaintext is refused before any network I/O. */
|
||||
free(cfg->server_host);
|
||||
cfg->server_host = str_dup("192.0.2.1");
|
||||
EXPECT_FALSE(validate_config(cfg));
|
||||
|
||||
/* TLS makes the remote destination acceptable (cert/key/ca are required). */
|
||||
cfg->use_tls = true;
|
||||
EXPECT_FALSE(validate_config(cfg));
|
||||
cfg->tls_cert = str_dup("cert.pem");
|
||||
cfg->tls_key = str_dup("key.pem");
|
||||
cfg->tls_ca = str_dup("ca.pem");
|
||||
EXPECT_TRUE(validate_config(cfg));
|
||||
|
||||
/* No credentials: the remote plaintext rule does not apply. */
|
||||
cfg->use_tls = false;
|
||||
char* creds = cfg->password_file;
|
||||
cfg->password_file = NULL;
|
||||
EXPECT_TRUE(validate_config(cfg));
|
||||
cfg->password_file = creds;
|
||||
config_delete(cfg);
|
||||
}
|
||||
|
||||
static void test_validate_config_delta_sendfile_constraints() {
|
||||
Config* cfg = valid_client_config();
|
||||
cfg->use_delta = true;
|
||||
@@ -223,7 +259,7 @@ static void test_parse_args_protocol_accept_current() {
|
||||
Config* cfg = valid_client_config();
|
||||
EXPECT_NOT_NULL(cfg);
|
||||
char* argv_equals[] = {"fastsync", "--source-dir", "/src",
|
||||
"--dest-dir", "/dst", "--protocol=2.18.0"};
|
||||
"--dest-dir", "/dst", "--protocol=2.19.0"};
|
||||
int positional_args[2];
|
||||
int positional_count = 0;
|
||||
EXPECT_EQ_INT(parse_args(cfg, 6, argv_equals, positional_args, &positional_count), 0);
|
||||
@@ -233,7 +269,7 @@ static void test_parse_args_protocol_accept_current() {
|
||||
cfg = valid_client_config();
|
||||
EXPECT_NOT_NULL(cfg);
|
||||
char* argv_space[] = {"fastsync", "--source-dir", "/src", "--dest-dir",
|
||||
"/dst", "--protocol", "2.18.0"};
|
||||
"/dst", "--protocol", "2.19.0"};
|
||||
positional_count = 0;
|
||||
EXPECT_EQ_INT(parse_args(cfg, 7, argv_space, positional_args, &positional_count), 0);
|
||||
EXPECT_EQ_STR(cfg->version, PROTOCOL_VERSION);
|
||||
@@ -243,8 +279,8 @@ static void test_parse_args_protocol_accept_current() {
|
||||
/* Any --protocol value other than the current PROTOCOL_VERSION must end in
|
||||
* failure (parse_args simply stores it; validate_config rejects it up front). */
|
||||
static void test_parse_args_protocol_rejects_other_versions() {
|
||||
static const char* const bad_versions[] = {"2.17", "2.16", "2.15.0", "2.16.0", "2.17.0",
|
||||
"216", "31", "abc", ""};
|
||||
static const char* const bad_versions[] = {"2.17", "2.16", "2.15.0", "2.16.0", "2.17.0",
|
||||
"2.18.0", "216", "31", "abc", ""};
|
||||
for (size_t i = 0; i < sizeof(bad_versions) / sizeof(bad_versions[0]); i++) {
|
||||
Config* cfg = valid_client_config();
|
||||
EXPECT_NOT_NULL(cfg);
|
||||
@@ -3117,6 +3153,7 @@ void test_client_cli() {
|
||||
test_validate_config_append_verify_rejects_whole_file();
|
||||
test_validate_config_incompatible_options();
|
||||
test_validate_config_tls_requirements();
|
||||
test_validate_config_credentials_require_tls_or_loopback();
|
||||
test_validate_config_delta_sendfile_constraints();
|
||||
test_cli_help();
|
||||
test_cli_archive_flags();
|
||||
|
||||
+8
-11
@@ -246,9 +246,9 @@ static void test_config_module_wire_empty_canonicalizes_to_null() {
|
||||
}
|
||||
}
|
||||
|
||||
/* Daemon auth credentials (Wave B) ride the config frame: username + SHA-256
|
||||
* hex digest are present together, or both are absent. Round-trip a present
|
||||
* pair. */
|
||||
/* Daemon auth credentials (A7, protocol 2.19.0) ride the config frame as the
|
||||
* username ONLY; the literal password never crosses the wire. Round-trip a
|
||||
* present username. */
|
||||
static void test_config_daemon_auth_wire_roundtrip() {
|
||||
Config* send_cfg = config_create();
|
||||
EXPECT_NOT_NULL(send_cfg);
|
||||
@@ -256,8 +256,7 @@ static void test_config_daemon_auth_wire_roundtrip() {
|
||||
send_cfg->receive_root_directory = str_dup("rel/path");
|
||||
send_cfg->module = str_dup("backup");
|
||||
send_cfg->auth_user = str_dup("alice");
|
||||
send_cfg->auth_password_hash =
|
||||
str_dup("9b90e524e94995ee4aeae2ee3c428a53405d1e8db147f44facc46797d0caf4c3");
|
||||
send_cfg->auth_password = str_dup("alice-s3cret");
|
||||
|
||||
int p[2];
|
||||
EXPECT_EQ_INT(socketpair(AF_UNIX, SOCK_STREAM, 0, p), 0);
|
||||
@@ -269,10 +268,9 @@ static void test_config_daemon_auth_wire_roundtrip() {
|
||||
close(p[1]);
|
||||
io_set_fds(p[0], p[0]);
|
||||
Config* recv_cfg = config_receive(p[0]);
|
||||
/* The plaintext password is client-only: it is never serialized. */
|
||||
bool ok = recv_cfg != NULL && recv_cfg->auth_user != NULL &&
|
||||
strcmp(recv_cfg->auth_user, "alice") == 0 && recv_cfg->auth_password_hash != NULL &&
|
||||
strcmp(recv_cfg->auth_password_hash,
|
||||
"9b90e524e94995ee4aeae2ee3c428a53405d1e8db147f44facc46797d0caf4c3") == 0;
|
||||
strcmp(recv_cfg->auth_user, "alice") == 0 && recv_cfg->auth_password == NULL;
|
||||
config_delete(recv_cfg);
|
||||
close(p[0]);
|
||||
_exit(ok ? 0 : 1);
|
||||
@@ -289,7 +287,7 @@ static void test_config_daemon_auth_wire_roundtrip() {
|
||||
}
|
||||
}
|
||||
|
||||
/* The receive side validates the auth payload: a present-but-malformed digest
|
||||
/* The receive side validates the auth payload: a present-but-malformed username
|
||||
* is refused (config_receive returns NULL), so a hostile peer cannot slip a
|
||||
* garbage credential past the receive guard into the module gate. */
|
||||
static void test_config_daemon_auth_wire_rejects_malformed() {
|
||||
@@ -298,8 +296,7 @@ static void test_config_daemon_auth_wire_rejects_malformed() {
|
||||
send_cfg->send_directory = str_dup("/src");
|
||||
send_cfg->receive_root_directory = str_dup("/dst");
|
||||
send_cfg->module = str_dup("m");
|
||||
send_cfg->auth_user = str_dup("alice");
|
||||
send_cfg->auth_password_hash = str_dup("not-a-valid-sha256-hex-digest!!");
|
||||
send_cfg->auth_user = str_dup("bad user");
|
||||
|
||||
int p[2];
|
||||
EXPECT_EQ_INT(socketpair(AF_UNIX, SOCK_STREAM, 0, p), 0);
|
||||
|
||||
+809
-147
File diff suppressed because it is too large.
Load diff
@@ -128,6 +128,25 @@ static void test_log_message_formats() {
|
||||
EXPECT_TRUE(true);
|
||||
}
|
||||
|
||||
/* log_debug_enabled is the lazy-formatting gate for log_debug_message: it must
|
||||
* be true only at DEBUG level with the requested flag selected, exactly
|
||||
* mirroring the filter inside log_debug_message itself. */
|
||||
static void test_log_debug_enabled_matches_gate() {
|
||||
set_log_level(LOG_LEVEL_WARNING);
|
||||
set_log_debug_flags(LOG_DEBUG_ALL);
|
||||
EXPECT_FALSE(log_debug_enabled(LOG_DEBUG_PROTO));
|
||||
|
||||
set_log_level(LOG_LEVEL_DEBUG);
|
||||
set_log_debug_flags(LOG_DEBUG_PROTO);
|
||||
EXPECT_TRUE(log_debug_enabled(LOG_DEBUG_PROTO));
|
||||
EXPECT_FALSE(log_debug_enabled(LOG_DEBUG_IO));
|
||||
|
||||
set_log_debug_flags(0);
|
||||
EXPECT_FALSE(log_debug_enabled(LOG_DEBUG_PROTO));
|
||||
|
||||
set_log_debug_flags(LOG_DEBUG_ALL);
|
||||
}
|
||||
|
||||
void test_log() {
|
||||
test_log_message_debug();
|
||||
test_log_message_info();
|
||||
@@ -139,4 +158,5 @@ void test_log() {
|
||||
test_log_filtering();
|
||||
test_log_stderr_mode_all();
|
||||
test_log_message_formats();
|
||||
test_log_debug_enabled_matches_gate();
|
||||
}
|
||||
@@ -2,12 +2,15 @@
|
||||
#include "utils.h"
|
||||
#include "protocol.h"
|
||||
#include "test_utils.h"
|
||||
#include <arpa/inet.h>
|
||||
#include <dirent.h>
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <netinet/in.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/socket.h>
|
||||
#include <sys/stat.h>
|
||||
#include <threads.h>
|
||||
#include <unistd.h>
|
||||
@@ -269,12 +272,97 @@ static int escape_thread(void* arg) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* A7-3/S1 transport classification: the daemon auth gate and the client
|
||||
credential rule both key off these helpers, so cover the exact accepted
|
||||
forms plus the negative cases. */
|
||||
static void test_loopback_helpers() {
|
||||
/* Host strings. */
|
||||
EXPECT_TRUE(utils_host_is_loopback("localhost"));
|
||||
EXPECT_TRUE(utils_host_is_loopback("127.0.0.1"));
|
||||
EXPECT_TRUE(utils_host_is_loopback("127.255.255.254"));
|
||||
EXPECT_TRUE(utils_host_is_loopback("127.0.0.0"));
|
||||
EXPECT_TRUE(utils_host_is_loopback("::1"));
|
||||
EXPECT_TRUE(utils_host_is_loopback("[::1]"));
|
||||
EXPECT_FALSE(utils_host_is_loopback("128.0.0.1"));
|
||||
EXPECT_FALSE(utils_host_is_loopback("10.0.0.1"));
|
||||
EXPECT_FALSE(utils_host_is_loopback("0.0.0.0"));
|
||||
EXPECT_FALSE(utils_host_is_loopback("example.com"));
|
||||
EXPECT_FALSE(utils_host_is_loopback(""));
|
||||
EXPECT_FALSE(utils_host_is_loopback(NULL));
|
||||
|
||||
/* Raw sockaddr classification. */
|
||||
struct sockaddr_in v4;
|
||||
memset(&v4, 0, sizeof(v4));
|
||||
v4.sin_family = AF_INET;
|
||||
EXPECT_TRUE(inet_pton(AF_INET, "127.0.0.1", &v4.sin_addr) == 1);
|
||||
EXPECT_TRUE(utils_sockaddr_is_loopback((const struct sockaddr*)&v4));
|
||||
EXPECT_TRUE(inet_pton(AF_INET, "127.5.5.5", &v4.sin_addr) == 1);
|
||||
EXPECT_TRUE(utils_sockaddr_is_loopback((const struct sockaddr*)&v4));
|
||||
EXPECT_TRUE(inet_pton(AF_INET, "128.0.0.1", &v4.sin_addr) == 1);
|
||||
EXPECT_FALSE(utils_sockaddr_is_loopback((const struct sockaddr*)&v4));
|
||||
|
||||
struct sockaddr_in6 v6;
|
||||
memset(&v6, 0, sizeof(v6));
|
||||
v6.sin6_family = AF_INET6;
|
||||
EXPECT_TRUE(inet_pton(AF_INET6, "::1", &v6.sin6_addr) == 1);
|
||||
EXPECT_TRUE(utils_sockaddr_is_loopback((const struct sockaddr*)&v6));
|
||||
EXPECT_TRUE(inet_pton(AF_INET6, "::ffff:127.0.0.1", &v6.sin6_addr) == 1);
|
||||
EXPECT_TRUE(utils_sockaddr_is_loopback((const struct sockaddr*)&v6));
|
||||
EXPECT_TRUE(inet_pton(AF_INET6, "::ffff:127.255.255.254", &v6.sin6_addr) == 1);
|
||||
EXPECT_TRUE(utils_sockaddr_is_loopback((const struct sockaddr*)&v6));
|
||||
EXPECT_TRUE(inet_pton(AF_INET6, "::ffff:10.0.0.1", &v6.sin6_addr) == 1);
|
||||
EXPECT_FALSE(utils_sockaddr_is_loopback((const struct sockaddr*)&v6));
|
||||
|
||||
EXPECT_FALSE(utils_sockaddr_is_loopback(NULL));
|
||||
|
||||
/* A pipe has no socket peer: getpeername fails with ENOTSOCK. The helper is
|
||||
fail-closed, so an unprovable channel is NOT local (daemon auth modules are
|
||||
daemon-only and never run over the --stdio pipe). */
|
||||
int pipe_fds[2];
|
||||
EXPECT_EQ_INT(pipe(pipe_fds), 0);
|
||||
EXPECT_FALSE(utils_fd_peer_is_local(pipe_fds[0]));
|
||||
close(pipe_fds[0]);
|
||||
close(pipe_fds[1]);
|
||||
EXPECT_FALSE(utils_fd_peer_is_local(-1));
|
||||
|
||||
/* A connected AF_UNIX socketpair is a socket, but its peer is not a loopback
|
||||
IP address, so it is not local either. */
|
||||
int pair_fds[2];
|
||||
EXPECT_EQ_INT(socketpair(AF_UNIX, SOCK_STREAM, 0, pair_fds), 0);
|
||||
EXPECT_FALSE(utils_fd_peer_is_local(pair_fds[0]));
|
||||
close(pair_fds[0]);
|
||||
close(pair_fds[1]);
|
||||
|
||||
/* A real loopback TCP peer is local. */
|
||||
int listener = socket(AF_INET, SOCK_STREAM, 0);
|
||||
EXPECT_TRUE(listener >= 0);
|
||||
struct sockaddr_in bind_addr;
|
||||
memset(&bind_addr, 0, sizeof(bind_addr));
|
||||
bind_addr.sin_family = AF_INET;
|
||||
bind_addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
|
||||
bind_addr.sin_port = 0;
|
||||
EXPECT_EQ_INT(bind(listener, (const struct sockaddr*)&bind_addr, sizeof(bind_addr)), 0);
|
||||
EXPECT_EQ_INT(listen(listener, 1), 0);
|
||||
socklen_t addr_len = sizeof(bind_addr);
|
||||
EXPECT_EQ_INT(getsockname(listener, (struct sockaddr*)&bind_addr, &addr_len), 0);
|
||||
int dialer = socket(AF_INET, SOCK_STREAM, 0);
|
||||
EXPECT_TRUE(dialer >= 0);
|
||||
EXPECT_EQ_INT(connect(dialer, (const struct sockaddr*)&bind_addr, sizeof(bind_addr)), 0);
|
||||
int accepted = accept(listener, NULL, NULL);
|
||||
EXPECT_TRUE(accepted >= 0);
|
||||
EXPECT_TRUE(utils_fd_peer_is_local(accepted));
|
||||
close(accepted);
|
||||
close(dialer);
|
||||
close(listener);
|
||||
}
|
||||
|
||||
void test_shared_utils() {
|
||||
test_walker_removes_extras_keeps_manifest_and_protected();
|
||||
test_walker_max_delete_exceeded_deletes_nothing();
|
||||
test_walker_max_delete_exact_bound_deletes();
|
||||
test_walker_unlimited_deletes_all();
|
||||
test_walker_hard_bound_all_or_nothing();
|
||||
test_loopback_helpers();
|
||||
|
||||
/* --append / --append-verify tail-resume math: a resume is eligible only for
|
||||
a shorter existing destination, and the tail length is then the difference. */
|
||||
|
||||
Reference in new issue
Block a user