Compare commits
33
Commits
58409cee10
...
v2.19.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
378d881ca7 | ||
|
|
cc27ee1b83 | ||
|
|
d653c3e151 | ||
|
|
1b90ee2449 | ||
|
|
89b967f29a | ||
|
|
8f06ae5262 | ||
|
|
ac3c4c7c72 | ||
|
|
d53614d06b | ||
|
|
f0381a6b8e | ||
|
|
a7a1930e88 | ||
|
|
42f01c0968 | ||
|
|
2489d422e5 | ||
|
|
e2ddc0c07f | ||
|
|
1480716304 | ||
|
|
1de1376e54 | ||
|
|
eaf67f6257 | ||
|
|
8c94ec9886 | ||
|
|
87585e9881 | ||
|
|
1ba6372017 | ||
|
|
9f74b21c64 | ||
|
|
108fee1e41 | ||
|
|
e1bb2e9233 | ||
|
|
81fed86748 | ||
|
|
5844648fb2 | ||
|
|
4331bc4a8d | ||
|
|
d97e3982b4 | ||
|
|
6d32bc795b | ||
|
|
abad1664ba | ||
|
|
7e45891257 | ||
|
|
b8db810ee5 | ||
|
|
ea0a0e2eaf | ||
|
|
b216ed31fb | ||
|
|
e3840c8326 |
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
|
||||
|
||||
+23
-21
@@ -257,14 +257,14 @@ why plain `--append` works on the normal atomic path, not only with `--inplace`.
|
||||
| `-N`, `--crtimes` | Preserve create times | ⛔ Impossible/Divergence | Birth-times cannot be set by any portable filesystem call (`utimensat`/`futimens` only set atime/mtime), so this row is an explicit **Impossible/Divergence** (Phase 7 Wave B). Capture + transmit stays: `statx(STATX_BTIME)` on Linux records the source birth time as a wire field; the receiver logs a debug note that it cannot be applied and continues — never failing the transfer and never pretending it worked. On platforms without `statx` it parses as a documented no-op (flag accepted; nothing is captured). Implies metadata transmission. Wire: new `crtime` fields + a `preserve_crtimes` config boolean; `PROTOCOL_VERSION` bumped **2.11.0 → 2.12.0** (see the Phase-4 metadata-time notes) |
|
||||
| `-O`, `--omit-dir-times` | Omit dirs from --times | ✅ Implemented | Real modifier now that FastSync preserves directory times. With metadata on, the scanner captures every traversed source directory's mtime (and atime under `-U`) and the sender transmits them in trailing `STATUS_DIR_TIMES` frame(s) **after all file data and the optional delete manifest** (chunked at the receiver's `MAX_MANIFEST_ENTRIES` per-frame cap); a dir-time entry only RECORDS metadata and never creates the directory, so empty source directories stay untransferred. The receiver defers applying them until its delete / `--delay-updates` publication phases have committed, so writing or removing a child never clobbers a parent directory's mtime (rsync applies directory times at the end for exactly this reason). When `-O` is set (the boolean crosses the wire) the receiver does not apply any of them; without `-O` an `-a`/`--preserve` transfer now restores directory times (reversing the old "never preserves dir times" divergence). Wire change: the terminal `STATUS_DIR_TIMES` frame; `PROTOCOL_VERSION` bumped **2.16.0 → 2.17.0** |
|
||||
| `-J`, `--omit-link-times` | Omit symlinks from --times | ✅ Implemented | Real modifier now that FastSync preserves symlink times. Symlink entries already carried their metadata on `STATUS_SYMLINK`; the receiver now applies it with **no-follow primitives only** (`utimensat(..., AT_SYMLINK_NOFOLLOW)`, plus best-effort `fchmodat(..., AT_SYMLINK_NOFOLLOW)` and policy-gated `fchownat(..., AT_SYMLINK_NOFOLLOW)`), so the link itself is stamped without ever dereferencing it, confined fd-relative below the authorized receive root. A symlink has no children, so the times are applied immediately at creation. When `-J` is set (the boolean crosses the wire) the receiver skips the timestamps (mode/ownership are unaffected); without `-J` an `-a`/`-l` transfer restores symlink mtimes. Wire change alongside `-O`: the shared `STATUS_DIR_TIMES` frame; `PROTOCOL_VERSION` bumped **2.16.0 → 2.17.0** |
|
||||
| `--super` | Receiver attempts super-user activities | ✅ Implemented | Phase 7 Wave E: receiver-side **safe-subset + clear-refusal** privilege model, tri-state `super_mode` (auto/on/off). `--super` **permits** the receiver to attempt super-user activities — ownership application and char/block device-node creation — that are already confined fd-relative below the authorized receive root; `--no-super` **forbids** them even when the receiver is root; the default (`auto`) preserves the pre-existing **best-effort** behavior of *attempting* them (not only when already root: an unprivileged attempt is refused by the kernel and skipped per entry, matching FastSync's history). The server additionally accepts an operator-level `--no-super` veto that forces `OFF` for every connection it accepts (so it also refuses any client `--copy-as`/`--super`); the `--fake-super` owner replay and the `--write-devices` write path are gated by the same policy. **FastSync never elevates**: no `setuid`/`seteuid`/`setgid` is ever called, and `--super` never bypasses the confinement floor (`file_open_secure_parent`, `O_NOFOLLOW`, root checks) — it only permits an attempt that is already confined. With `--super` and **no** explicit identity policy (`--usermap`/`--groupmap`/`--chown`/`--numeric-ids`), ownership is treated as raw numeric-id preservation (as if `--numeric-ids`); an explicit identity policy still wins. A non-root receiver given `--super` logs exactly one warning at activation and each confined attempt is then refused by the kernel and skipped per entry (never aborts); `--no-super` suppresses ownership, char/block `mknod`, `--write-devices` and the fake-super owner replay, while unprivileged FIFO creation is unaffected. Wire: one trailing `super_mode` int on the config frame (validated 0..2), sent **before** the `--copy-as` block (fixed order: super int, then copy-as presence int + ids); `PROTOCOL_VERSION` bumped **2.17.0 → 2.18.0**. **Documented divergence from rsync:** rsync's `--super` runs the receiver with elevated privilege; FastSync only permits a confined attempt and never elevates |
|
||||
| `--fake-super` | Store/recover privileged attrs via xattrs | ✅ Implemented | Phase 7 Wave B: full record **and replay**. The receiver writes the source `uid:gid:mode:mtime_sec:mtime_nsec` into a reserved `user.fastsync.stat` xattr on each written file (best-effort, fd-relative, format unchanged), then immediately re-applies it via `fake_super_restore_fd`: `fchown` (only where privileged — a non-root EPERM/EACCES is skipped silently, matching FastSync's identity philosophy), `fchmod`, and `futimens`. The OWNER leg is additionally skipped when `--no-super` forbids super-user activities (even for root) or when an active `--copy-as` is authoritative, so the recorded source owner can never override a forced `--copy-as` owner; the xattr record is still stored/replayed for a later privileged restore and mode/mtime still apply, so unprivileged `--fake-super` keeps working. The restored mode goes through the same sanitization as the normal metadata path (group/other write bits are never granted, so a recorded 0666 restores as 0644), so fake-super replay can never grant group/other-write that plain `--preserve` would refuse. Absence or a malformed record is a silent no-op, never fatal. The recording format diverges from rsync's `user.rsync.%stat%`; no cross-tool conversion is attempted. Implies metadata transmission so the source uid/gid/mode/mtime are available. Both it and `-X`/`-A` are incompatible with `-s` (chunk serialization), rejected up front |
|
||||
| `--super` | Receiver attempts super-user activities | ✅ Implemented | Phase 7 Wave E: receiver-side **safe-subset + clear-refusal** privilege model, tri-state `super_mode` (auto/on/off). `--super` **permits** the receiver to attempt super-user activities — ownership application and char/block device-node creation — that are already confined fd-relative below the authorized receive root; `--no-super` **forbids** them even when the receiver is root; the default (`auto`) preserves the pre-existing **best-effort** behavior of *attempting* them (not only when already root: an unprivileged attempt is refused by the kernel and skipped per entry, matching FastSync's history). The server additionally accepts an operator-level `--no-super` veto that forces `OFF` for every connection it accepts (so it also refuses any client `--copy-as`/`--super`); the `--fake-super` owner replay and the `--write-devices` write path are gated by the same policy. **FastSync never elevates**: no `setuid`/`seteuid`/`setgid` is ever called, and `--super` never bypasses the confinement floor (`file_open_secure_parent`, `O_NOFOLLOW`, root checks) — it only permits an attempt that is already confined. `--super` does **not** imply `--numeric-ids` and never enables client-chosen ownership on its own: ownership is applied only when an explicit identity policy (`--usermap`/`--groupmap`/`--chown`/`--numeric-ids`/`--copy-as`) is also given. A non-root receiver given `--super` logs exactly one warning at activation and each confined attempt is then refused by the kernel and skipped per entry (never aborts); `--no-super` suppresses ownership, char/block `mknod`, `--write-devices` and the fake-super owner replay, while unprivileged FIFO creation is unaffected. Wire: one trailing `super_mode` int on the config frame (validated 0..2), sent **before** the `--copy-as` block (fixed order: super int, then copy-as presence int + ids); `PROTOCOL_VERSION` bumped **2.17.0 → 2.18.0**. **Documented divergence from rsync:** rsync's `--super` runs the receiver with elevated privilege; FastSync only permits a confined attempt and never elevates |
|
||||
| `--fake-super` | Store/recover privileged attrs via xattrs | ✅ Implemented | Phase 7 Wave B: full record **and replay**. The receiver writes the source `uid:gid:mode:mtime_sec:mtime_nsec` into a reserved `user.fastsync.stat` xattr on each written file (best-effort, fd-relative, format unchanged), then immediately re-applies it via `fake_super_restore_fd`: `fchown` (only where privileged — a non-root EPERM/EACCES is skipped silently, matching FastSync's identity philosophy), `fchmod`, and `futimens`. The OWNER leg is additionally skipped unless an explicit ownership identity policy (`--numeric-ids`/`--usermap`/`--groupmap`/`--chown`/`--copy-as`) is active — `--fake-super` on its own only *records* the source owner and must not act as an un-gated chown primitive — when `--no-super` forbids super-user activities (even for root), or when an active `--copy-as` is authoritative, so the recorded source owner can never override a forced `--copy-as` owner; the xattr record is still stored/replayed for a later privileged restore and mode/mtime still apply, so unprivileged `--fake-super` keeps working. The restored mode goes through the same sanitization as the normal metadata path (group/other write bits are never granted, so a recorded 0666 restores as 0644), so fake-super replay can never grant group/other-write that plain `--preserve` would refuse. Absence or a malformed record is a silent no-op, never fatal. The recording format diverges from rsync's `user.rsync.%stat%`; no cross-tool conversion is attempted. Implies metadata transmission so the source uid/gid/mode/mtime are available. Both it and `-X`/`-A` are incompatible with `-s` (chunk serialization), rejected up front |
|
||||
| `--open-noatime` | Avoid changing access time when opening files | ✅ Implemented | Sender-side policy: the sender opens source files with `O_NOATIME` (Linux) when reading them for transfer, so the open/read does NOT bump the source's on-disk access time. Degrades safely when `O_NOATIME` is unavailable (not defined) or refused (`EPERM`, since it needs `CAP_FOWNER` or file ownership): the code falls back to a normal open, so the data always transfers — only the atime-bump is skipped. It does not itself capture/preserve atime; it only avoids modifying it. **Client-only, never crosses the wire.** Exposed as `file_open_for_read()` and applied to both the buffered data path and the sendfile path |
|
||||
| `--numeric-ids` | Do not map uid/gid by name | ✅ Implemented | Ownership is applied through FastSync's opt-in identity path (see the Phase-4 identity notes below). `--numeric-ids` is a mapping-policy modifier: when applying ownership it uses the transmitted numeric uid/gid directly, skipping the name lookup. Without an ownership-affecting option it is inert (FastSync only applies ownership when the user opts in). It does not need `-M` to be parsed, but ownership is only applied when metadata (hence the source uid/gid) is actually transmitted (see the notes) |
|
||||
| `--usermap=STRING` | Map usernames | ✅ Implemented | Opt-in ownership application. rsync subset implemented: comma-separated `FROM:TO` rules evaluated in order, first match wins; `FROM`/`TO` are group/user names (resolved on the SOURCE machine at parse time), `*` (FROM matches any id / TO = the receiving process's current euid), and an `@N` or bare `N` numeric id. Rules are carried over the wire as resolved numeric id pairs; the receiver applies a matching rule (else falls back to `--chown`, `--numeric-ids`, then a best-effort name lookup) via an fd-relative `fchown`. Malformed/unresolvable specs are rejected with a clear error, never a silent no-op. Implies metadata preservation so the source uid/gid travel. Only effective when the receiver can actually change ownership (root or membership); otherwise it warns and continues |
|
||||
| `--groupmap=STRING` | Map group names | ✅ Implemented | Same rsync subset and semantics as `--usermap` but for the group (gid) side and the group databases. See the Phase-4 identity notes |
|
||||
| `--chown=USER:GROUP` | Map owner and group | ✅ Implemented | Opt-in ownership override applied receiver-side. Forms: `USER:GROUP`, `USER` (owner only), `:GROUP` (group only); a `*` for USER/GROUP means the current/root user or group as appropriate; an `@N`/bare `N` numeric id is accepted. A `:` inside a name may be escaped as `\:`. Equivalent to a trailing `*:*` usermap+groupmap rule (so an explicit `--usermap`/`--groupmap` match wins). Malformed or unresolvable specs are clear parse errors. Implies metadata preservation. Only effective when the receiver has permission to chown; otherwise it warns and continues (rsync parity) |
|
||||
| `--copy-as=USER[:GROUP]` | Perform the copy as another user/group | ✅ Implemented | Safe-subset implementation, an explicit divergence from rsync's **real identity switching**. rsync makes the receiving process actually assume USER/GROUP (setuid/setgid); FastSync's receiver is multithreaded, so a real credential drop would be unsafe and is never attempted — FastSync never calls `setuid`/`seteuid`/`setgid`. Instead the receiver FORCES the ownership of every entry it writes to `copy_as_uid`/`copy_as_gid` through the existing confined, fd-relative identity path (the same `fchown`/`fchownat` mechanism as `--chown`/`--usermap`/`--groupmap`; symlinks use `fchownat(..., AT_SYMLINK_NOFOLLOW)`, and directories — including intermediate parents created implicitly while writing a nested file — and char/block/FIFO nodes are owned no-follow too, so a directory never keeps the receiver's owner while its children get the target owner), with `--copy-as` at the **highest priority** — it beats usermap/groupmap/`--chown`/`--numeric-ids` and the best-effort name lookup. This REQUIRES a privileged (root) receiver: an unprivileged receiver REFUSES the whole transfer up front at the config handshake (`server_module_gate`, running inside `config_receive_with_validate` before the `STATUS_OK` ack) with a clear error and no file data exchanged — never a silent wrong-ownership result. A server running with an operator `--no-super` veto also refuses it, and a **daemon** refuses `--copy-as` outright even when root: FastSync has no per-module opt-in for client-chosen ownership, so a daemon must not honor an arbitrary client-selected owner (the standalone listener and SSH `--stdio` server keep honoring it for their single operator-authorized root). `--fake-super` interaction: `--copy-as` is authoritative, so the recorded source owner is never replayed over the forced target owner. If the ownership apply still fails with EPERM/EACCES (capability-restricted root, root-squash, read-only mount) the failure is logged at ERROR but remains non-fatal (the multithreaded receiver is never aborted). USER is resolved on the client against the user database (a name, an `@N`/bare `N` numeric id, or `*` meaning the client's current euid); when `:GROUP` is present it is resolved against the group database (`*` meaning the client's egid). **Group-default rule:** when the group is omitted FastSync uses the user's primary gid (`getpwuid(uid)->pw_gid`); a numeric id with no local passwd entry has no primary gid to look up, so `gid` falls back to `uid` (documented divergence). Malformed/empty/unresolvable specs are clear parse errors, never a silent no-op. Never elevates privileges and never bypasses the confined receive root. Implies metadata preservation (the source uid/gid must be transmitted). Wire: a new trailing config-frame block **sent after** the `--super` int (presence int, then the two int32 ids, both validated `>= 0` on receive; the ids are also rejected if they do not fit int32 at CLI parse time); `PROTOCOL_VERSION` bumped **2.17.0 → 2.18.0** |
|
||||
| `--copy-as=USER[:GROUP]` | Perform the copy as another user/group | ✅ Implemented | Safe-subset implementation, an explicit divergence from rsync's **real identity switching**. rsync makes the receiving process actually assume USER/GROUP (setuid/setgid); FastSync's receiver is multithreaded, so a real credential drop would be unsafe and is never attempted — FastSync never calls `setuid`/`seteuid`/`setgid`. Instead the receiver FORCES the ownership of every entry it writes to `copy_as_uid`/`copy_as_gid` through the existing confined, fd-relative identity path (the same `fchown`/`fchownat` mechanism as `--chown`/`--usermap`/`--groupmap`; symlinks use `fchownat(..., AT_SYMLINK_NOFOLLOW)`, and directories — including intermediate parents created implicitly while writing a nested file — and char/block/FIFO nodes are owned no-follow too, so a directory never keeps the receiver's owner while its children get the target owner), with `--copy-as` at the **highest priority** — it beats usermap/groupmap/`--chown`/`--numeric-ids` and the best-effort name lookup. This REQUIRES a privileged (root) receiver: an unprivileged receiver REFUSES the whole transfer up front at the config handshake (`server_module_gate`, running inside `config_receive_with_validate` before the `STATUS_OK` ack) with a clear error and no file data exchanged — never a silent wrong-ownership result. A server running with an operator `--no-super` veto also refuses it, and a **daemon** refuses `--copy-as`, like every other client-chosen-ownership request (`--numeric-ids`/`--chown`/`--usermap`/`--groupmap`/`--fake-super`/explicit `--super`), unless the selected module opts in with `client owner = yes`; without that per-module opt-in a daemon must not honor an arbitrary client-selected owner (the standalone listener and SSH `--stdio` server keep honoring these for their single operator-authorized root). `--fake-super` interaction: `--copy-as` is authoritative, so the recorded source owner is never replayed over the forced target owner. If the ownership apply still fails with EPERM/EACCES (capability-restricted root, root-squash, read-only mount) the failure is logged at ERROR and the **entry is reported as failed** rather than written with the wrong owner, which fails the transfer (fail-fast) so overall success is never reported with the wrong owner. USER is resolved on the client against the user database (a name, an `@N`/bare `N` numeric id, or `*` meaning the client's current euid); when `:GROUP` is present it is resolved against the group database (`*` meaning the client's egid). **Group-default rule:** when the group is omitted FastSync uses the user's primary gid (`getpwuid(uid)->pw_gid`); a numeric id with no local passwd entry has no primary gid to look up, so `gid` falls back to `uid` (documented divergence). Malformed/empty/unresolvable specs are clear parse errors, never a silent no-op. Never elevates privileges and never bypasses the confined receive root. Implies metadata preservation (the source uid/gid must be transmitted). Wire: a new trailing config-frame block **sent after** the `--super` int (presence int, then the two int32 ids, both validated `>= 0` on receive; the ids are also rejected if they do not fit int32 at CLI parse time); `PROTOCOL_VERSION` bumped **2.17.0 → 2.18.0** |
|
||||
|
||||
**Phase-4 metadata-time notes:** `-U/--atimes`, `-N/--crtimes`,
|
||||
`-O/--omit-dir-times`, `-J/--omit-link-times`, and `--open-noatime` are new.
|
||||
@@ -611,7 +611,7 @@ now transmits targets (the prior behavior was broken/partial); its status moved
|
||||
| Flag | Rsync Description | FastSync Status | Notes |
|
||||
|------|-------------------|-----------------|-------|
|
||||
| `-e`, `--rsh=COMMAND` | Remote shell to use | ✅ Implemented | `-e`/`--rsh` (and `--rsh=COMMAND`) select the remote-shell program used to build the SSH child argv, overriding the default `ssh`. The command is whitespace-split into the leading argv words so rsync's `-e "ssh -p 2222"` works; the standard `-o` family, an optional `-p` port, `user@host` and the quoted remote command (`fastsync-server --stdio`) follow. Stored in the `rsh_command` config field. **Client-only, never crosses the wire** (it is a launch concern, not a handshake property) |
|
||||
| `--rsync-path=PROGRAM` | rsync binary on remote | ✅ Implemented | Alias for `--fastsync-server-path`: both write the `fastsync_server_path` config field used as the remote-side server program (quoted as one remote-shell word unless `--old-args`), which CROSSES the wire as before. Kept separate from `--rsh`, which names the local connecting program |
|
||||
| `--rsync-path=PROGRAM` | rsync binary on remote | ✅ Implemented | Alias for `--fastsync-server-path`: both write the `fastsync_server_path` config field used as the remote-side server program (always quoted as one remote-shell word), which CROSSES the wire as before. Kept separate from `--rsh`, which names the local connecting program |
|
||||
| `--port=PORT` | Alternate daemon port | ✅ Implemented | rsync's daemon-port flag maps to the client-side `server_port` config field: a client connects to a TCP/TLS server (incl. `host::module/path` daemon destinations) with `--server-port`, and the `fastsync-server --daemon` listener's port is taken from its config's `port` key (default 873) or overridden by `--dparam port=` / `-p` |
|
||||
| `--sockopts=OPTIONS` | Custom TCP options | ✅ Implemented | Comma-separated allowlist of `OPT=VAL` applied via `setsockopt` after `socket()` before `connect()`/`bind()`. Only `TCP_NODELAY`, `SO_KEEPALIVE`, `SO_REUSEADDR` (0/1) and `SO_RCVBUF`/`SO_SNDBUF` (byte count) are accepted; an unknown option name or a bad value is rejected up front, never silently ignored. A value is required for every option (`OPT=VAL`; a bare name is an error). Applied to the outgoing TCP and TLS client socket; absent by default. `SockOptEntry`/`sockopts` config fields. Local socket concern: never crosses the wire |
|
||||
| `--blocking-io` | Use blocking I/O for remote shell | ✅ Implemented | With `--blocking-io` the SSH-transport socketpair socket is left without `SO_RCVTIMEO`/`SO_SNDTIMEO`, so the transfer blocks naturally; by default it gets the same read/write timeout as the TCP transport (see `--timeout`). `blocking_io` config bool. **Client-only, never crosses the wire** |
|
||||
@@ -625,25 +625,27 @@ now transmits targets (the prior behavior was broken/partial); its status moved
|
||||
|
||||
| Flag | Rsync Description | FastSync Status | Notes |
|
||||
|------|-------------------|-----------------|-------|
|
||||
| `--daemon` | Run as rsync daemon | ✅ Implemented | Wave A: a real persistent listener. `fastsync-server --daemon --config FILE` (plus `--no-detach` to stay foreground; without it the listener detaches to the background after binding) reads a FastSync-native module config file and serves each connection confined to the requested module's `path` root (never a client-chosen root; a client `--copy-as` is refused outright and the operator `--no-super` veto is honored). TCP/TLS via the existing `--tls` stack; plaintext still requires `--allow-unauthenticated` (same secure default as the standalone server). Client destinations use rsync's `host::module/path` form. Wire/protocol: the config frame gained a trailing daemon-module string and `PROTOCOL_VERSION` was bumped **2.14.0 → 2.15.0** (see the Daemon Mode notes below). Daemon mode is built in FastSync's own protocol/config grammar, not rsync's SMB/daemon option encoding |
|
||||
| `--daemon` | Run as rsync daemon | ✅ Implemented | Wave A: a real persistent listener. `fastsync-server --daemon --config FILE` (plus `--no-detach` to stay foreground; without it the listener detaches to the background after binding) reads a FastSync-native module config file and serves each connection confined to the requested module's `path` root (never a client-chosen root; every client-chosen-ownership/super-user request (`--numeric-ids`/`--chown`/`--usermap`/`--groupmap`/`--fake-super`/`--copy-as`/explicit `--super`) is refused unless the module opts in with `client owner = yes`, and the operator `--no-super` veto is honored). TCP/TLS via the existing `--tls` stack; plaintext still requires `--allow-unauthenticated` (same secure default as the standalone server). Client destinations use rsync's `host::module/path` form. Wire/protocol: the config frame gained a trailing daemon-module string and `PROTOCOL_VERSION` was bumped **2.14.0 → 2.15.0** (see the Daemon Mode notes below). Daemon mode is built in FastSync's own protocol/config grammar, not rsync's SMB/daemon option encoding |
|
||||
| `--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. 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), `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, a client `--copy-as` is refused outright (no per-module opt-in for client-chosen ownership), 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.
|
||||
- **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
|
||||
|
||||
@@ -656,7 +658,7 @@ now transmits targets (the prior behavior was broken/partial); its status moved
|
||||
| Per-connection memory limit | 1GB per connection | ✅ Implemented | `MAX_CONNECTION_MEMORY` |
|
||||
| `--max-alloc=SIZE` | Limit a single memory allocation | ✅ Implemented | Caps the largest single allocation; binary units, default 1G |
|
||||
| `--trust-sender` | Trust remote sender's file list | ✅ Implemented | Long-form-only, receiver-local policy that never crosses the wire. The receiver skips its redundant up-front re-validation of the incoming file list (empty/`..` path rejection and the escaping-symlink-target containment), trusting the sender instead of double-checking (fewer checks, faster, potentially unsafe, matching rsync). Off by default. The low-level fd-relative confinement primitives (`file_open_secure_parent`, the O_NOFOLLOW parent walk, leaf/destination confinement) are deliberately KEPT even under `--trust-sender`, so a hostile sender still cannot write or link outside the authorized root (see Phase-5 notes below) |
|
||||
| `--old-args` | Disable modern arg protection | ✅ Implemented | SSH-only legacy mode; restores raw remote command construction and permits shell interpretation of the configured server path |
|
||||
| `--old-args` | Disable modern arg protection | ✅ Implemented | SSH-only; accepted for CLI compatibility but is now a **documented no-op**: FastSync always single-quote-escapes the remote server path and each `--remote-option` value (`ssh_build_remote_command`), so a metacharacter-bearing `--rsync-path` can never be interpreted by the remote shell. The flag no longer disables that quoting (the old raw-construction behavior was an injection foot-gun and is removed); the safety-relevant behavior is identical either way |
|
||||
| `--ignore-missing-args` | Ignore missing source args | ✅ Implemented | FastSync has a single source-root argument (which always exists), so the "explicitly requested source arguments" are the `--files-from` entries and the flags only ever apply there (inert without `--files-from`, like `-R`). Without the flag a listed-but-missing entry stays a hard pre-transfer error (nothing is transferred). With it each missing entry is skipped: nothing is sent for it, it never enters the keep-set, and the run succeeds for the rest — an all-missing non-empty list succeeds transferring nothing, matching rsync. `--dirs` + `--files-from` missing entries are skipped the same way. Every skipped entry is logged and a per-run warning names the count, so the handling is never a silent no-op. Divergences: an EMPTY `--files-from` file stays a hard error in every mode (no argument was requested at all; rsync likewise reports "no source files specified"); missing-arg skipping only applies to the pre-transfer list validation, so an entry that is present at preflight and vanishes mid-transfer still fails (matching rsync, whose flag "does not affect subsequent vanished-file errors"); `--no-ignore-missing-args` is not a supported negation |
|
||||
| `--delete-missing-args` | Delete missing source args | ✅ Implemented | Implies `--ignore-missing-args` (order-independent) and additionally removes each missing entry's destination mirror receiver-side. The mirror is computed exactly like a present sibling's wire path: the bare relative entry under `-R`, otherwise the full source-mirror path below the destination root. rsync parity, verified against the man page: it does **not** imply `--delete` generally and is "independent of any other type of delete processing" — unrelated destination extras are untouched unless `--delete` is also present. Composition with `--delete` + timing: the exact-path deletions commit with the manifest, early for `--delete-before`/`--delete-during`, else only after a fully-successful transfer (delete-after/commit). A non-empty directory mirror is removed only when `--force` or `--delete` is in effect (otherwise it is left with a warning and the run continues, like rsync); an absent mirror is a no-op. An explicitly listed missing arg is a user request, not an excluded file: its deletion is never blocked by the filter-exclusion protection of excluded destination mirrors (a mirror sitting inside a filter-excluded directory is still removed). Safety/policy: gated by the server `--allow-delete` policy like `--delete`; the request paths cross the wire only in the delete-manifest frame and are confined by the same receiver validation as the keep-set (non-empty, relative, traversal-free, bounded by the per-section/per-frame manifest caps); the `--delay-updates` staging directory and basis snapshots are protected exactly as in the extras walker. Divergence: the missing-args deletions are not counted toward `--max-delete` (they are explicit per-path requests, not discovered extras). See the Phase-3 wire note below for the `PROTOCOL_VERSION` bump |
|
||||
|
||||
@@ -675,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`. |
|
||||
@@ -791,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).
|
||||
|
||||
@@ -828,9 +830,9 @@ These are the last compatibility items and the closing phase toward rsync flag p
|
||||
|
||||
**Wave E (LAST) — Privilege: `--super`/`--no-super` and `--copy-as=USER[:GROUP]` (✅ implemented).** FastSync adopts a **safe-subset + clear-refusal** privilege model: it never blind-elevates and never calls `setuid`/`seteuid`/`setgid`. All privileged operations remain fd-relative and confined below the authorized receive root.
|
||||
|
||||
`--super`/`--no-super` set a receiver-side tri-state `Config->super_mode` (`SUPER_MODE_AUTO`/`ON`/`OFF`). `privilege_super_permitted()` / `privilege_super_mode_permitted()` (src/shared/identity.c) return true for `ON` and `AUTO` (AUTO preserves FastSync's historical best-effort attempt, where the kernel refuses an unprivileged call and the caller skips it) and false only for `OFF`. The gate covers every super-user activity FastSync performs: ownership application (`identity_apply_ownership`/`_link`), char/block device-node creation (`file_save_special_to_disk`), writes into an existing device (`--write-devices`), and the `--fake-super` owner replay. Unprivileged FIFO creation is deliberately unaffected. With `ON` and no explicit identity policy, ownership falls back to raw numeric-id preservation (as `--numeric-ids`); explicit `--usermap`/`--groupmap`/`--chown`/`--numeric-ids` still win. `--no-super` suppresses those activities even for a root receiver. A non-root receiver given `--super` logs one warning at activation (`identity_set_active`); each confined attempt is then refused by the kernel and skipped, never aborting. The confinement floor is unchanged (`file_open_secure_parent`, `O_NOFOLLOW`, root/path checks). Operator control: the server CLI accepts `--no-super`, a veto that forces `OFF` for every connection and refuses client `--copy-as`/`--super`.
|
||||
`--super`/`--no-super` set a receiver-side tri-state `Config->super_mode` (`SUPER_MODE_AUTO`/`ON`/`OFF`). `privilege_super_permitted()` / `privilege_super_mode_permitted()` (src/shared/identity.c) return true for `ON` and `AUTO` (AUTO preserves FastSync's historical best-effort attempt, where the kernel refuses an unprivileged call and the caller skips it) and false only for `OFF`. The gate covers every super-user activity FastSync performs: ownership application (`identity_apply_ownership`/`_link`), char/block device-node creation (`file_save_special_to_disk`), writes into an existing device (`--write-devices`), and the `--fake-super` owner replay. Unprivileged FIFO creation is deliberately unaffected. `--super` does **not** imply `--numeric-ids`: ownership is applied only when an explicit identity policy (`--usermap`/`--groupmap`/`--chown`/`--numeric-ids`/`--copy-as`) is also given. `--no-super` suppresses those activities even for a root receiver. A non-root receiver given `--super` logs one warning at activation (`identity_set_active`); each confined attempt is then refused by the kernel and skipped, never aborting. The confinement floor is unchanged (`file_open_secure_parent`, `O_NOFOLLOW`, root/path checks). Operator control: the server CLI accepts `--no-super`, a veto that forces `OFF` for every connection, refuses any client `--copy-as`, and neutralizes an explicit `--super` (the connection is accepted but no super-user activity is attempted). On a daemon, a module that has not opted in with `client owner = yes` additionally has super-user device activity forced off (see the Daemon Mode notes).
|
||||
|
||||
`--copy-as=USER[:GROUP]` is the safe subset. FastSync's receiver is multithreaded, so a real credential switch is unsafe; instead the receiver forces the ownership of **every entry it writes** — regular files, symlinks, directories (including implicitly-created parents), and special nodes — to the resolved target ids through the confined fd-relative identity path. USER is resolved on the client (name, `@N`/bare N, or `*` = client euid); when `:GROUP` is omitted the user's primary gid is used (falling back to `gid == uid` for a numeric id with no local passwd entry). It requires a privileged (root) receiver: an unprivileged receiver refuses the whole transfer at the config handshake, before `STATUS_OK`, so no data is ever written with the wrong ownership. A `--copy-as` chown failure on a capability-restricted root is logged at ERROR (never silently downgraded). `--copy-as` implies metadata (`--no-preserve` is rejected) and `--fake-super` cannot override it. Daemon divergence: a `--daemon` receiver refuses `--copy-as` **and** `--super`=ON outright, because there is no per-module operator opt-in for client-chosen ownership (the standalone listener and the SSH-launched `--stdio` server, which each serve one operator-authorized root, honor them).
|
||||
`--copy-as=USER[:GROUP]` is the safe subset. FastSync's receiver is multithreaded, so a real credential switch is unsafe; instead the receiver forces the ownership of **every entry it writes** — regular files, symlinks, directories (including implicitly-created parents), and special nodes — to the resolved target ids through the confined fd-relative identity path. USER is resolved on the client (name, `@N`/bare N, or `*` = client euid); when `:GROUP` is omitted the user's primary gid is used (falling back to `gid == uid` for a numeric id with no local passwd entry). It requires a privileged (root) receiver: an unprivileged receiver refuses the whole transfer at the config handshake, before `STATUS_OK`, so no data is ever written with the wrong ownership. A `--copy-as` chown failure on a capability-restricted root is logged at ERROR (never silently downgraded). `--copy-as` implies metadata (`--no-preserve` is rejected) and `--fake-super` cannot override it. Daemon policy: a `--daemon` receiver refuses **every** client-chosen-ownership / super-user request — `--numeric-ids`, `--chown`, `--usermap`/`--groupmap`, `--fake-super`, `--copy-as`, and explicit `--super` — unless the selected module opts in with `client owner = yes`; without that per-module opt-in any client could force arbitrary ownership inside the module root (the standalone listener and the SSH-launched `--stdio` server, which each serve one operator-authorized root, honor these requests). A `--copy-as` chown failure on a capability-restricted root marks the entry as failed rather than reporting success with the wrong owner.
|
||||
|
||||
**Wire:** two trailing config-frame blocks after the `--iconv` spec, in fixed order — `send_privilege_options`/`receive_privilege_options` (one `super_mode` int, validated `0..2`), then `send_copy_as_options`/`receive_copy_as_options` (presence int + two int32 ids, validated `>= 0`, with `copy_as_set ⇒ use_metadata`). `PROTOCOL_VERSION` bumped **2.17.0 → 2.18.0**. **Divergences from rsync:** rsync's `--super` elevates the receiver and `--copy-as` actually switches its credentials; FastSync never elevates and only permits/forwards confined attempts, and `--copy-as` forces ownership rather than switching identity.
|
||||
|
||||
|
||||
+28
-47
@@ -389,6 +389,21 @@ static int parse_ull_arg(const char* val, unsigned long long* out, const char* o
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Apply a --delta-block/--block-size value (both spellings and both the inline
|
||||
* and separate argument forms share this one range check). An out-of-range
|
||||
* value warns once and leaves the configured default untouched. Returns 0 on
|
||||
* success, -1 on a non-numeric value. */
|
||||
static int set_delta_block_size(Config* config, const char* value) {
|
||||
unsigned long long val;
|
||||
if (parse_ull_arg(value, &val, "--block-size/--delta-block") != 0)
|
||||
return -1;
|
||||
if (val >= DELTA_BLOCK_SIZE_MIN && val <= DELTA_BLOCK_SIZE_MAX)
|
||||
config->delta_block_size = (uint32_t)val;
|
||||
else
|
||||
log_message(LOG_LEVEL_WARNING, "block size value %llu out of range, using default", val);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Parse a byte count with an optional single-letter binary suffix (K/M/G/T/P/E).
|
||||
* When allow_zero is false, a bare 0 is rejected (size limits use true, since 0
|
||||
* means "no limit"). Returns 0 on success, -1 on error. */
|
||||
@@ -1094,33 +1109,18 @@ int parse_args(Config* config, int argc, char* argv[], int* positional_args,
|
||||
"--include") != 0)
|
||||
return -1;
|
||||
} else if (strncmp(argv[i], "--delta-block=", 14) == 0) {
|
||||
unsigned long long val;
|
||||
if (parse_ull_arg(argv[i] + 14, &val, "--block-size/--delta-block") != 0)
|
||||
if (set_delta_block_size(config, argv[i] + 14) != 0)
|
||||
return -1;
|
||||
if (val >= DELTA_BLOCK_SIZE_MIN && val <= DELTA_BLOCK_SIZE_MAX)
|
||||
config->delta_block_size = (uint32_t)val;
|
||||
else
|
||||
log_message(LOG_LEVEL_WARNING, "block size value %llu out of range, using default", val);
|
||||
} else if (strncmp(argv[i], "--block-size=", 13) == 0) {
|
||||
unsigned long long val;
|
||||
if (parse_ull_arg(argv[i] + 13, &val, "--block-size/--delta-block") != 0)
|
||||
if (set_delta_block_size(config, argv[i] + 13) != 0)
|
||||
return -1;
|
||||
if (val >= DELTA_BLOCK_SIZE_MIN && val <= DELTA_BLOCK_SIZE_MAX)
|
||||
config->delta_block_size = (uint32_t)val;
|
||||
else
|
||||
log_message(LOG_LEVEL_WARNING, "block size value %llu out of range, using default", val);
|
||||
} else if (opt_is(argv[i], "--delta-block", "--block-size")) {
|
||||
if (i + 1 >= argc) {
|
||||
log_message(LOG_LEVEL_ERROR, "missing argument for %s", argv[i]);
|
||||
return -1;
|
||||
}
|
||||
unsigned long long val;
|
||||
if (parse_ull_arg(argv[++i], &val, "--block-size/--delta-block") != 0)
|
||||
if (set_delta_block_size(config, argv[++i]) != 0)
|
||||
return -1;
|
||||
if (val >= DELTA_BLOCK_SIZE_MIN && val <= DELTA_BLOCK_SIZE_MAX)
|
||||
config->delta_block_size = (uint32_t)val;
|
||||
else
|
||||
log_message(LOG_LEVEL_WARNING, "block size value %llu out of range, using default", val);
|
||||
} else if (opt_is(argv[i], "--delta-max", NULL)) {
|
||||
if (i + 1 >= argc) {
|
||||
log_message(LOG_LEVEL_ERROR, "missing argument for %s", argv[i]);
|
||||
@@ -1431,7 +1431,6 @@ int parse_args(Config* config, int argc, char* argv[], int* positional_args,
|
||||
} else if (strncmp(argv[i], "--copy-as=", 10) == 0) {
|
||||
if (identity_parse_copy_as(config, argv[i] + 10) != 0)
|
||||
return -1;
|
||||
config->use_metadata = true;
|
||||
} else if (opt_is(argv[i], "--copy-as", NULL)) {
|
||||
if (i + 1 >= argc) {
|
||||
log_message(LOG_LEVEL_ERROR, "missing argument for %s", argv[i]);
|
||||
@@ -1439,7 +1438,6 @@ int parse_args(Config* config, int argc, char* argv[], int* positional_args,
|
||||
}
|
||||
if (identity_parse_copy_as(config, argv[++i]) != 0)
|
||||
return -1;
|
||||
config->use_metadata = true;
|
||||
} else if (strncmp(argv[i], "--outbuf=", 9) == 0) {
|
||||
if (set_outbuf_option(config, argv[i] + 9) != 0)
|
||||
return -1;
|
||||
@@ -1575,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;
|
||||
@@ -1599,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;
|
||||
|
||||
+7
-7
@@ -169,11 +169,11 @@ void print_usage(void) {
|
||||
printf(" re-apply it (fd-relative) on a privileged run; the\n");
|
||||
printf(" recording format diverges from rsync's user.rsync.%%stat%%\n");
|
||||
printf(" --super Permit the receiver to attempt super-user activities\n");
|
||||
printf(" (ownership application, char/block device-node\n");
|
||||
printf(" creation) within the confined receive root. Never\n");
|
||||
printf(" elevates privileges and never bypasses confinement;\n");
|
||||
printf(" with no explicit identity policy, ownership follows\n");
|
||||
printf(" raw numeric ids (as if --numeric-ids)\n");
|
||||
printf(" (char/block device-node creation, --write-devices)\n");
|
||||
printf(" within the confined receive root. Never elevates\n");
|
||||
printf(" privileges and never bypasses confinement; ownership\n");
|
||||
printf(" is still applied only with an explicit identity flag\n");
|
||||
printf(" (--numeric-ids/--chown/--usermap/--groupmap/--copy-as)\n");
|
||||
printf(" --no-super Forbid those super-user activities even when the\n");
|
||||
printf(" receiver is running as root\n");
|
||||
printf(" --chmod <changes> Modify transferred permissions (rsync syntax)\n");
|
||||
@@ -246,8 +246,8 @@ void print_usage(void) {
|
||||
printf(" -T, --temp-dir <dir> Scratch dir for temp files before atomic install\n");
|
||||
printf(" --fastsync-server-path <path>\n");
|
||||
printf(" Path to fastsync-server on remote (default: fastsync-server)\n");
|
||||
printf(
|
||||
" --old-args Disable safe SSH command argument quoting (legacy compatibility)\n");
|
||||
printf(" --old-args Accepted for rsync CLI compatibility; no effect (the\n");
|
||||
printf(" remote server path is always safely quoted now)\n");
|
||||
printf(" -M, --remote-option=OPT Append OPT to the REMOTE server invocation over SSH\n");
|
||||
printf(" (repeatable; each value is single-quote-escaped on the remote\n");
|
||||
printf(" command line; empty values and values with control characters\n");
|
||||
|
||||
+259
-68
@@ -55,11 +55,105 @@ static CredentialStore* g_credentials = NULL;
|
||||
|
||||
/* Opaque context threaded through to the config-frame gate: the connection's
|
||||
* SSL object (NULL over plaintext) so the gate can warn when a credential
|
||||
* exchange is not encrypted. */
|
||||
* exchange is not encrypted, plus the super-mode override the gate decides on.
|
||||
* The gate never mutates the received (const) Config; it records a forced
|
||||
* 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
|
||||
@@ -79,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;
|
||||
}
|
||||
@@ -172,47 +266,34 @@ static bool configure_authorization(const char* root) {
|
||||
* --destination-root, but per-module and NEVER client-chosen. The module is
|
||||
* refused (with a clear log) when it is unknown, when it is `read only` (every
|
||||
* FastSync network transfer writes; there is no read-only wire operation yet),
|
||||
* or when the presented daemon credentials fail for a module that declares
|
||||
* `auth users`. Wave A refused every auth-required module (auth was not yet
|
||||
* implemented); Wave B authenticates the client instead (see below). */
|
||||
* when it requests client-chosen ownership without the module's
|
||||
* `client owner = yes` opt-in (P7 Wave E hardening), or when the presented
|
||||
* daemon credentials fail for a module that declares `auth users`. Wave A
|
||||
* refused every auth-required module (auth was not yet implemented); Wave B
|
||||
* authenticates the client instead (see below). */
|
||||
static const char* server_module_gate(const Config* config, void* context) {
|
||||
ModuleGateContext* gate_ctx = (ModuleGateContext*)context;
|
||||
if (!config)
|
||||
return "missing config frame";
|
||||
/* Operator veto: --no-super forces SUPER_MODE_OFF for this connection before
|
||||
the copy-as gate is evaluated. The received config is const, so the gates
|
||||
below evaluate a shallow effective copy (only super_mode differs); the
|
||||
handler applies the recorded override to the accepted config exactly once. */
|
||||
Config effective = *config;
|
||||
if (server_no_super) {
|
||||
effective.super_mode = SUPER_MODE_OFF;
|
||||
if (gate_ctx)
|
||||
gate_ctx->super_mode_override = SUPER_MODE_OFF;
|
||||
}
|
||||
/* --copy-as (P7 Wave E, protocol 2.18.0): FastSync's safe subset forces the
|
||||
ownership of every written entry to the requested ids, which needs a
|
||||
privileged (root) receiver. An unprivileged receiver REFUSES the whole
|
||||
transfer here, at the config handshake and BEFORE the STATUS_OK ack, so no
|
||||
file data is exchanged and there is never a silent wrong-ownership result.
|
||||
Placed first so it applies to the standalone server and daemon alike. */
|
||||
/* Daemon divergence (P7 Wave E): a daemon has no per-module opt-in for
|
||||
client-chosen ownership, so it refuses --copy-as outright even when running
|
||||
as root -- otherwise any anonymous client could pick an arbitrary owner.
|
||||
The standalone listener and the SSH-launched --stdio server keep honoring
|
||||
it (they serve exactly one operator-authorized root). */
|
||||
if (g_daemon_conf != NULL && config->copy_as_set) {
|
||||
log_message(LOG_LEVEL_ERROR, "--copy-as is refused by the daemon (no per-module opt-in for "
|
||||
"client-chosen ownership); refusing");
|
||||
return "--copy-as is not permitted by this daemon";
|
||||
}
|
||||
/* --super (SUPER_MODE_ON) with no explicit identity policy implies raw
|
||||
numeric-id ownership, i.e. a client-chosen owner. A daemon has no
|
||||
per-module opt-in, so refuse the explicit ON request for the same reason it
|
||||
refuses --copy-as; the pre-existing --numeric-ids/--chown/--usermap surfaces
|
||||
are unchanged (documented daemon trust model). --no-super still works. */
|
||||
if (g_daemon_conf != NULL && config->super_mode == SUPER_MODE_ON) {
|
||||
log_message(LOG_LEVEL_ERROR,
|
||||
"--super is refused by the daemon (no per-module opt-in for client-chosen "
|
||||
"ownership); refusing");
|
||||
return "--super is not permitted by this daemon";
|
||||
}
|
||||
/* Operator veto: --no-super forces SUPER_MODE_OFF for this connection before
|
||||
the copy-as gate is evaluated, and the caller clamps the accepted config
|
||||
again after this returns so the ownership/device gates see it too. */
|
||||
Config* effective = (Config*)config;
|
||||
if (server_no_super)
|
||||
effective->super_mode = SUPER_MODE_OFF;
|
||||
if (identity_copy_as_refused(effective)) {
|
||||
The daemon's per-module client-chosen-ownership refusal is enforced after
|
||||
the module lookup below (it needs the module's opt-in) and covers --copy-as
|
||||
like every other ownership flag. */
|
||||
if (identity_copy_as_refused(&effective)) {
|
||||
if (geteuid() != 0)
|
||||
log_message(LOG_LEVEL_ERROR, "--copy-as requires a privileged receiver (root); refusing");
|
||||
else
|
||||
@@ -256,11 +337,43 @@ static const char* server_module_gate(const Config* config, void* context) {
|
||||
config->module);
|
||||
return "requested daemon module is read only";
|
||||
}
|
||||
/* Client-chosen ownership / super-user policy (P7 Wave E hardening): a daemon
|
||||
module refuses EVERY ownership-affecting request (--numeric-ids, --chown,
|
||||
--usermap/--groupmap, --fake-super, --copy-as, explicit --super) unless the
|
||||
operator opted THIS module in with `client owner = yes`. Otherwise any
|
||||
client could force arbitrary ownership inside the module root. The
|
||||
standalone/SSH server has a single operator-authorized root and keeps
|
||||
honoring these. */
|
||||
if (!module->client_owner) {
|
||||
/* Ownership: refuse the whole transfer up front (a clear failure).
|
||||
Evaluated against the ORIGINAL config so an explicit --super is refused
|
||||
even when an operator --no-super veto already forced the effective copy
|
||||
to OFF (the veto must not silently convert a refusal into an accept). */
|
||||
if (identity_ownership_requested(config)) {
|
||||
log_message(LOG_LEVEL_ERROR,
|
||||
"daemon module '%s' refuses client-chosen ownership/super-user activities "
|
||||
"(no `client owner = yes` opt-in); refusing",
|
||||
config->module);
|
||||
return "client-chosen ownership is not permitted by this daemon module";
|
||||
}
|
||||
/* Super-user DEVICE activities (char/block mknod and --write-devices) are
|
||||
permitted under the default AUTO mode, so without this override a root
|
||||
daemon would still let a non-opted module create arbitrary device nodes
|
||||
and write raw devices. Force them off for this connection: those entries
|
||||
are skipped (never mknod'ed) while an ordinary `-a` push still succeeds
|
||||
without device nodes, matching the operator's least-privilege choice.
|
||||
The operator-level --no-super veto is already folded into this. */
|
||||
if (gate_ctx)
|
||||
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 "
|
||||
@@ -269,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>");
|
||||
@@ -312,6 +443,8 @@ 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) {
|
||||
log_message(LOG_LEVEL_ERROR, "Failed to receive config");
|
||||
@@ -319,12 +452,13 @@ void handler(int file_descriptor) {
|
||||
protocol_session_unbind();
|
||||
return;
|
||||
}
|
||||
/* Operator --no-super veto: clamp the accepted config so every downstream
|
||||
* gate (identity_apply_ownership via privilege_super_permitted, device-node
|
||||
* creation) sees SUPER_MODE_OFF even if the gate callback did not already
|
||||
* mutate a copy of it. */
|
||||
if (server_no_super)
|
||||
config->super_mode = SUPER_MODE_OFF;
|
||||
/* Apply the super-mode veto the gate decided on (operator --no-super, or a
|
||||
* daemon module without the `client owner = yes` opt-in) exactly once, so
|
||||
* every downstream gate (identity_apply_ownership via privilege_super_permitted,
|
||||
* device-node creation) sees SUPER_MODE_OFF. The gate never mutated the
|
||||
* received config. */
|
||||
if (gate_ctx.super_mode_override != -1)
|
||||
config->super_mode = gate_ctx.super_mode_override;
|
||||
protocol_set_8_bit_output(config->eight_bit_output);
|
||||
if (!authorized_root) {
|
||||
log_message(LOG_LEVEL_ERROR, "No server-side destination root configured");
|
||||
@@ -407,8 +541,10 @@ void handler(int file_descriptor) {
|
||||
before anything else; without it the root must pre-exist. A failure here
|
||||
aborts the connection cleanly before any file data is exchanged. */
|
||||
if (!ensure_receive_root(config)) {
|
||||
char* escaped_root = output_escape(config->receive_root_directory, log_get_8_bit_output());
|
||||
log_message(LOG_LEVEL_ERROR, "destination root is not available: %s",
|
||||
config->receive_root_directory);
|
||||
escaped_root ? escaped_root : "<allocation failed>");
|
||||
free(escaped_root);
|
||||
config_delete(config);
|
||||
close(file_descriptor);
|
||||
protocol_session_unbind();
|
||||
@@ -430,8 +566,16 @@ void handler(int file_descriptor) {
|
||||
}
|
||||
/* Preserve the negotiated identity policy for the fd-relative ownership
|
||||
apply path. Each connection is its own forked process, so this
|
||||
per-process snapshot never races another connection. */
|
||||
identity_set_active(config);
|
||||
per-process snapshot never races another connection. A failed deep copy
|
||||
(allocation failure) leaves the snapshot cleared, so refuse the connection
|
||||
rather than silently applying the wrong ownership policy. */
|
||||
if (!identity_set_active(config)) {
|
||||
log_message(LOG_LEVEL_ERROR, "Failed to activate identity policy");
|
||||
config_delete(config);
|
||||
close(file_descriptor);
|
||||
protocol_session_unbind();
|
||||
return;
|
||||
}
|
||||
/* Persist the negotiated --keep-dirlinks policy once, here at config-accept,
|
||||
before any multithreaded receiver/writer threads are spawned, so the
|
||||
fd-walk reads a stable value during the whole transfer (and never bleeds
|
||||
@@ -475,6 +619,7 @@ void handler(int file_descriptor) {
|
||||
config_delete(config);
|
||||
close(file_descriptor);
|
||||
protocol_session_unbind();
|
||||
identity_clear_active();
|
||||
return;
|
||||
}
|
||||
PipelineContextReceiver* context =
|
||||
@@ -603,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");
|
||||
@@ -615,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");
|
||||
@@ -631,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");
|
||||
}
|
||||
@@ -700,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) {
|
||||
@@ -766,6 +945,18 @@ int main(int argc, char* argv[]) {
|
||||
if (g_daemon_conf->module_count == 0)
|
||||
log_message(LOG_LEVEL_WARNING,
|
||||
"daemon config has no modules; every connection will be refused");
|
||||
/* Surface the operator's client-chosen-ownership opt-in prominently: an
|
||||
opted-in module lets its clients request arbitrary owner ids inside that
|
||||
module root. */
|
||||
for (int i = 0; i < g_daemon_conf->module_count; i++) {
|
||||
if (g_daemon_conf->modules[i].client_owner)
|
||||
log_message(LOG_LEVEL_WARNING,
|
||||
"daemon module '%s' allows client-chosen ownership and super-user device "
|
||||
"activities (`client owner = yes`); clients may request arbitrary owner ids "
|
||||
"and device nodes within that module root -- pair it with `auth users` "
|
||||
"unless the module is intentionally open to the network",
|
||||
g_daemon_conf->modules[i].name);
|
||||
}
|
||||
/* Daemon credential store (Wave B). --password-file and --early-input
|
||||
* feed the same store, loaded BEFORE the listener forks so every
|
||||
* connection child shares one read-only store. Fail closed at startup: a
|
||||
|
||||
+62
-44
@@ -1,5 +1,6 @@
|
||||
#include "server_cli.h"
|
||||
#include "charset.h"
|
||||
#include "credentials.h"
|
||||
#include "utils.h"
|
||||
#include <limits.h>
|
||||
#include <stdarg.h>
|
||||
@@ -64,6 +65,7 @@ int server_cli_parse(int argc, char* argv[], ServerCliOptions* opts, char* err,
|
||||
server_cli_options_default(opts);
|
||||
|
||||
for (int i = 1; i < argc; i++) {
|
||||
const char* inline_value = NULL;
|
||||
if (arg_is(argv[i], "--help")) {
|
||||
opts->show_help = true;
|
||||
return 1;
|
||||
@@ -108,18 +110,51 @@ int server_cli_parse(int argc, char* argv[], ServerCliOptions* opts, char* err,
|
||||
}
|
||||
opts->destination_root = argv[++i];
|
||||
opts->destination_root_set = true;
|
||||
} else if (arg_is(argv[i], "--password-file")) {
|
||||
if (i + 1 >= argc) {
|
||||
set_error(err, err_size, "missing argument for --password-file");
|
||||
} else if (arg_has_value(argv[i], "--password-file", &inline_value)) {
|
||||
if (!inline_value) {
|
||||
if (i + 1 >= argc) {
|
||||
set_error(err, err_size, "missing argument for --password-file");
|
||||
return -1;
|
||||
}
|
||||
inline_value = argv[++i];
|
||||
}
|
||||
opts->password_file = inline_value;
|
||||
} else if (arg_has_value(argv[i], "--early-input", &inline_value)) {
|
||||
if (!inline_value) {
|
||||
if (i + 1 >= argc) {
|
||||
set_error(err, err_size, "missing argument for --early-input");
|
||||
return -1;
|
||||
}
|
||||
inline_value = argv[++i];
|
||||
}
|
||||
opts->early_input_file = inline_value;
|
||||
} else if (arg_has_value(argv[i], "--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->password_file = argv[++i];
|
||||
} else if (arg_is(argv[i], "--early-input")) {
|
||||
if (i + 1 >= argc) {
|
||||
set_error(err, err_size, "missing argument for --early-input");
|
||||
return -1;
|
||||
}
|
||||
opts->early_input_file = argv[++i];
|
||||
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");
|
||||
@@ -146,12 +181,15 @@ int server_cli_parse(int argc, char* argv[], ServerCliOptions* opts, char* err,
|
||||
opts->no_super = true;
|
||||
} else if (arg_is(argv[i], "--allow-unauthenticated")) {
|
||||
opts->allow_unauthenticated = true;
|
||||
} else if (arg_is(argv[i], "--iconv")) {
|
||||
if (i + 1 >= argc) {
|
||||
set_error(err, err_size, "missing argument for --iconv");
|
||||
return -1;
|
||||
} else if (arg_has_value(argv[i], "--iconv", &inline_value)) {
|
||||
if (!inline_value) {
|
||||
if (i + 1 >= argc) {
|
||||
set_error(err, err_size, "missing argument for --iconv");
|
||||
return -1;
|
||||
}
|
||||
inline_value = argv[++i];
|
||||
}
|
||||
opts->iconv_spec = argv[++i];
|
||||
opts->iconv_spec = inline_value;
|
||||
} else if (arg_is(argv[i], "-p")) {
|
||||
if (i + 1 >= argc) {
|
||||
set_error(err, err_size, "missing argument for -p");
|
||||
@@ -161,7 +199,6 @@ int server_cli_parse(int argc, char* argv[], ServerCliOptions* opts, char* err,
|
||||
if (parse_port_arg(argv[++i], &opts->port, err, err_size) != 0)
|
||||
return -1;
|
||||
} else {
|
||||
const char* inline_value = NULL;
|
||||
if (arg_has_value(argv[i], "--config", &inline_value)) {
|
||||
if (!inline_value) {
|
||||
if (i + 1 >= argc) {
|
||||
@@ -171,33 +208,6 @@ int server_cli_parse(int argc, char* argv[], ServerCliOptions* opts, char* err,
|
||||
inline_value = argv[++i];
|
||||
}
|
||||
opts->config_path = inline_value;
|
||||
} else if (arg_has_value(argv[i], "--password-file", &inline_value)) {
|
||||
if (!inline_value) {
|
||||
if (i + 1 >= argc) {
|
||||
set_error(err, err_size, "missing argument for --password-file");
|
||||
return -1;
|
||||
}
|
||||
inline_value = argv[++i];
|
||||
}
|
||||
opts->password_file = inline_value;
|
||||
} else if (arg_has_value(argv[i], "--early-input", &inline_value)) {
|
||||
if (!inline_value) {
|
||||
if (i + 1 >= argc) {
|
||||
set_error(err, err_size, "missing argument for --early-input");
|
||||
return -1;
|
||||
}
|
||||
inline_value = argv[++i];
|
||||
}
|
||||
opts->early_input_file = inline_value;
|
||||
} else if (arg_has_value(argv[i], "--iconv", &inline_value)) {
|
||||
if (!inline_value) {
|
||||
if (i + 1 >= argc) {
|
||||
set_error(err, err_size, "missing argument for --iconv");
|
||||
return -1;
|
||||
}
|
||||
inline_value = argv[++i];
|
||||
}
|
||||
opts->iconv_spec = inline_value;
|
||||
} else if (arg_has_value(argv[i], "--dparam", &inline_value)) {
|
||||
if (!inline_value) {
|
||||
if (i + 1 >= argc) {
|
||||
@@ -245,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)) {
|
||||
@@ -260,4 +278,4 @@ void server_cli_options_free(ServerCliOptions* opts) {
|
||||
free((char**)opts->dparams);
|
||||
opts->dparams = NULL;
|
||||
opts->dparam_count = 0;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+55
-26
@@ -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
|
||||
@@ -404,18 +403,19 @@ typedef struct Config {
|
||||
|
||||
/* --super / --no-super (P7 Wave E, protocol 2.18.0): receiver-side privilege
|
||||
* policy for super-user activities confined below the authorized receive
|
||||
* root. SUPER_MODE_AUTO (default) preserves the pre-existing behavior: a
|
||||
* privileged operation is only attempted when the receiver is ALREADY root
|
||||
* (geteuid() == 0). SUPER_MODE_ON (--super) PERMITS the receiver to attempt
|
||||
* those activities (ownership application, char/block device-node creation)
|
||||
* even when it is not root -- the attempt is then confined exactly as before
|
||||
* and simply fails/skips if the kernel refuses it. SUPER_MODE_OFF
|
||||
* root. SUPER_MODE_AUTO (default) preserves the pre-existing best-effort
|
||||
* behavior: the confined super-user operation is ALWAYS attempted and an
|
||||
* unprivileged attempt is refused by the kernel and skipped per entry.
|
||||
* SUPER_MODE_ON (--super) explicitly REQUESTS those activities (char/block
|
||||
* device-node creation, --write-devices); it does NOT imply --numeric-ids and
|
||||
* never enables ownership application on its own. SUPER_MODE_OFF
|
||||
* (--no-super) FORBIDS them even when running as root. FastSync NEVER
|
||||
* elevates privileges (no setuid/seteuid/setgid) and never bypasses the
|
||||
* fd-relative confinement (file_open_secure_parent, O_NOFOLLOW, root checks);
|
||||
* --super only permits an attempt that is already confined. Crosses the wire
|
||||
* as a trailing int so the receiver can enforce the policy. See
|
||||
* privilege_super_permitted() in identity.h. */
|
||||
* privilege_super_permitted() and identity_ownership_requested() in
|
||||
* identity.h. */
|
||||
int super_mode;
|
||||
|
||||
// Receiver-side runtime staging registry for --delay-updates. Never sent
|
||||
@@ -545,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
|
||||
@@ -616,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
|
||||
@@ -641,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);
|
||||
|
||||
+963
-113
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);
|
||||
|
||||
|
||||
@@ -170,6 +170,17 @@ static bool apply_module_key(DaemonModule* module, char* key, char* value, char*
|
||||
module->read_only = parsed;
|
||||
return true;
|
||||
}
|
||||
if (key_equals(key, "client owner")) {
|
||||
bool parsed;
|
||||
if (!parse_bool_value(value, &parsed)) {
|
||||
set_error(err, err_size,
|
||||
"module '%s': 'client owner' must be yes/no (or true/false/1/0), got '%s'",
|
||||
module->name, value);
|
||||
return false;
|
||||
}
|
||||
module->client_owner = parsed;
|
||||
return true;
|
||||
}
|
||||
if (key_equals(key, "auth users")) {
|
||||
char* list = str_dup(value);
|
||||
if (!list) {
|
||||
|
||||
@@ -24,12 +24,16 @@
|
||||
* selects this module to this path (file_open_secure_parent /
|
||||
* has_path_traversal / path_is_within all keep the existing confinement, just
|
||||
* per-module). There is never any client-chosen root: a module path always
|
||||
* stays confined. A daemon also REFUSES a client --copy-as outright, because
|
||||
* there is no per-module opt-in for client-chosen ownership (unlike the
|
||||
* standalone/SSH server, which honors it for its single operator-authorized
|
||||
* root); the operator-level --no-super veto additionally forces super-user
|
||||
* activities off for every daemon connection. See server_module_gate in
|
||||
* server.c and RSYNC_COMPAT.md.
|
||||
* stays confined. A daemon REFUSES every client-chosen ownership / super-user
|
||||
* request by default -- --numeric-ids, --chown, --usermap/--groupmap,
|
||||
* --fake-super, --copy-as and an explicit --super -- because there is no
|
||||
* per-module opt-in unless the operator adds one. An operator opts a single
|
||||
* module in with `client owner = yes` (DaemonModule.client_owner), which allows
|
||||
* that client to choose ownership within that module's root (the standalone/SSH
|
||||
* server honors such requests for its single operator-authorized root). The
|
||||
* operator-level --no-super veto additionally forces super-user activities off
|
||||
* for every daemon connection, even an opted-in module. See server_module_gate
|
||||
* in server.c and RSYNC_COMPAT.md.
|
||||
*
|
||||
* `auth_users` is honored by Wave B daemon authentication: a module that
|
||||
* declares auth users accepts a connection only when the presented username is
|
||||
@@ -41,6 +45,11 @@ typedef struct DaemonModule {
|
||||
char* name; /* module name, as the client requests it */
|
||||
char* path; /* module root (daemon-side authorized root) */
|
||||
bool read_only; /* `read only = yes/no`; default no */
|
||||
bool client_owner; /* `client owner = yes/no`; default no. Per-module opt-in
|
||||
that lets this module's clients choose ownership
|
||||
(--numeric-ids/--chown/--usermap/--groupmap/--fake-super/
|
||||
--copy-as) and request explicit --super super-user
|
||||
activities. Without it the daemon refuses all of them. */
|
||||
char** auth_users; /* `auth users = a,b`; Wave B credential list */
|
||||
int auth_user_count;
|
||||
} DaemonModule;
|
||||
|
||||
+95
-60
@@ -16,6 +16,7 @@
|
||||
#include "data.h"
|
||||
#include "delta.h"
|
||||
#include "file.h"
|
||||
#include "file_store.h"
|
||||
#include "identity.h"
|
||||
#include "log.h"
|
||||
#include "metadata.h"
|
||||
@@ -37,44 +38,6 @@ static bool write_all(int fd, const void* data, unsigned long long size) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/* A run of NUL bytes at least this long is emitted as a hole (lseek) rather
|
||||
* than written, so the resulting file is genuinely sparse on the filesystem. */
|
||||
#define SPARSE_HOLE_MIN 4096U
|
||||
|
||||
/* Sparse-aware writer (--sparse/-S). Walks `data`; any all-zero run of at
|
||||
* least SPARSE_HOLE_MIN bytes is skipped with lseek(SEEK_CUR) so the block is
|
||||
* never allocated (a real hole on the destination); every other byte is written
|
||||
* normally. The file is pre-sized with ftruncate by the callers before this
|
||||
* runs, so holes are guaranteed and the offset bookkeeping stays correct
|
||||
* (each lseek advances the fd offset exactly as a write of that many bytes
|
||||
* would). After the final run, ftruncate(size) guarantees the logical size is
|
||||
* exactly `size` even when the tail was a hole. The full file image is in
|
||||
* memory, so no wire change is needed. Returns false on I/O error. */
|
||||
static bool write_all_sparse(int fd, const unsigned char* data, unsigned long long size) {
|
||||
unsigned long long i = 0;
|
||||
while (i < size) {
|
||||
if (data[i] == 0) {
|
||||
unsigned long long run_start = i;
|
||||
while (i < size && data[i] == 0)
|
||||
i++;
|
||||
unsigned long long run_len = i - run_start;
|
||||
if (run_len >= SPARSE_HOLE_MIN) {
|
||||
if (lseek(fd, (off_t)run_len, SEEK_CUR) < 0)
|
||||
return false;
|
||||
} else if (!write_all(fd, data + run_start, run_len)) {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
unsigned long long run_start = i;
|
||||
while (i < size && data[i] != 0)
|
||||
i++;
|
||||
if (!write_all(fd, data + run_start, i - run_start))
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return ftruncate(fd, (off_t)size) == 0;
|
||||
}
|
||||
|
||||
/* Preallocate `size` bytes on `fd` before any data is written (--preallocate).
|
||||
* posix_fallocate reserves real disk blocks, so an out-of-space condition
|
||||
* (ENOSPC/EDQUOT) surfaces up front instead of partway through a transfer;
|
||||
@@ -515,6 +478,50 @@ out:
|
||||
return ok;
|
||||
}
|
||||
|
||||
/* Open the directory named by canonical absolute `resolved`, which the caller
|
||||
* has already verified lies beneath `root` (the canonical authorized root).
|
||||
* Each component is opened relative to the authorized-root fd with O_NOFOLLOW,
|
||||
* so a directory swapped for a symlink after the realpath() check cannot
|
||||
* redirect the open outside the root -- the walk simply fails. This replaces
|
||||
* re-opening the absolute resolved path (TOCTOU). Returns an O_DIRECTORY fd,
|
||||
* or -1 (the root itself and any error are refused). */
|
||||
static int open_dir_beneath_root(const char* resolved, const char* root) {
|
||||
size_t root_len = strlen(root);
|
||||
const char* rel = resolved + root_len;
|
||||
while (*rel == '/')
|
||||
rel++;
|
||||
if (*rel == '\0')
|
||||
return -1;
|
||||
int fd = dup(authorized_root_fd);
|
||||
if (fd < 0)
|
||||
return -1;
|
||||
char* copy = str_dup(rel);
|
||||
if (!copy) {
|
||||
close(fd);
|
||||
return -1;
|
||||
}
|
||||
char* save = NULL;
|
||||
for (char* component = strtok_r(copy, "/", &save); component;
|
||||
component = strtok_r(NULL, "/", &save)) {
|
||||
if (strcmp(component, ".") == 0)
|
||||
continue;
|
||||
/* A canonical realpath() output never contains "." or ".."; refuse ".."
|
||||
defensively rather than let it climb toward the root. */
|
||||
int next = strcmp(component, "..") == 0
|
||||
? -1
|
||||
: openat(fd, component, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC);
|
||||
if (next < 0) {
|
||||
close(fd);
|
||||
free(copy);
|
||||
return -1;
|
||||
}
|
||||
close(fd);
|
||||
fd = next;
|
||||
}
|
||||
free(copy);
|
||||
return fd;
|
||||
}
|
||||
|
||||
int file_open_secure_parent(const char* path, char** leaf_out, bool create_dirs) {
|
||||
char* copy = str_dup(path);
|
||||
if (!copy)
|
||||
@@ -582,8 +589,20 @@ int file_open_secure_parent(const char* path, char** leaf_out, bool create_dirs)
|
||||
(a pre-existing destination directory is left alone, matching
|
||||
rsync's transferred-entry scope); the helper is a no-op unless an
|
||||
identity policy is active. */
|
||||
if (created && identity_copy_as_active())
|
||||
identity_apply_ownership_link(fd, component, 0, 0);
|
||||
if (created && identity_copy_as_active() &&
|
||||
!identity_apply_ownership_link(fd, component, 0, 0)) {
|
||||
/* A REQUIRED --copy-as ownership that cannot be applied to a
|
||||
directory this walk just created must fail the entry rather than
|
||||
leave that implicit parent owned by the receiver. Preserve the
|
||||
failing errno across the cleanup so the caller logs the real
|
||||
reason. */
|
||||
int saved_errno = errno;
|
||||
close(fd);
|
||||
free(copy);
|
||||
free(leaf);
|
||||
errno = saved_errno;
|
||||
return -1;
|
||||
}
|
||||
next = openat(fd, component, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC);
|
||||
}
|
||||
}
|
||||
@@ -607,16 +626,13 @@ int file_open_secure_parent(const char* path, char** leaf_out, bool create_dirs)
|
||||
(resolved[strlen(root)] == '/' || resolved[strlen(root)] == '\0')) {
|
||||
struct stat rst;
|
||||
if (stat(resolved, &rst) == 0 && S_ISDIR(rst.st_mode)) {
|
||||
/* Re-open the resolved directory WITHOUT following a symlink and
|
||||
re-verify it is still a directory inode, so a symlink swapped
|
||||
in between realpath() and open() (TOCTOU) cannot redirect this
|
||||
fd outside the root. */
|
||||
next = open(resolved, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC);
|
||||
struct stat ofst;
|
||||
if (next >= 0 && (fstat(next, &ofst) != 0 || !S_ISDIR(ofst.st_mode))) {
|
||||
close(next);
|
||||
next = -1;
|
||||
}
|
||||
/* Open the resolved directory through a relative no-follow walk
|
||||
from the authorized-root fd instead of re-opening the
|
||||
absolute `resolved` path: swapping an intermediate directory
|
||||
for a symlink between realpath() and open() (TOCTOU) then
|
||||
merely fails the walk rather than redirecting the fd outside
|
||||
the root. */
|
||||
next = open_dir_beneath_root(resolved, root);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -691,11 +707,23 @@ bool file_ensure_directory_secure(const char* path) {
|
||||
return false;
|
||||
|
||||
int dir_fd = openat(parent_fd, leaf, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC);
|
||||
bool created = false;
|
||||
if (dir_fd < 0 && errno == ENOENT) {
|
||||
if (mkdirat(parent_fd, leaf, 0755) == 0 || errno == EEXIST)
|
||||
if (mkdirat(parent_fd, leaf, 0755) == 0) {
|
||||
created = true;
|
||||
dir_fd = openat(parent_fd, leaf, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC);
|
||||
} else if (errno == EEXIST) {
|
||||
dir_fd = openat(parent_fd, leaf, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC);
|
||||
}
|
||||
}
|
||||
bool ok = dir_fd >= 0;
|
||||
/* --copy-as owns a directory this call just created (the final component;
|
||||
intermediate components were handled by file_open_secure_parent above). A
|
||||
failed REQUIRED ownership fails the call rather than leaving the directory
|
||||
owned by the receiver. */
|
||||
if (ok && created && identity_copy_as_active() &&
|
||||
!identity_apply_ownership_link(parent_fd, leaf, 0, 0))
|
||||
ok = false;
|
||||
if (dir_fd >= 0)
|
||||
close(dir_fd);
|
||||
close(parent_fd);
|
||||
@@ -902,9 +930,12 @@ static bool file_to_disk_secure_impl(const char* path, const void* data,
|
||||
int prealloc_rc = 0;
|
||||
if (preallocate && !sparse && data_size > 0) {
|
||||
prealloc_rc = preallocate_fd(fd, data_size);
|
||||
if (prealloc_rc != 0)
|
||||
log_message(LOG_LEVEL_ERROR, "preallocate failed for '%s' (%s); transfer aborted", path,
|
||||
strerror(prealloc_rc));
|
||||
if (prealloc_rc != 0) {
|
||||
char* escaped_path = output_escape(path, log_get_8_bit_output());
|
||||
log_message(LOG_LEVEL_ERROR, "preallocate failed for '%s' (%s); transfer aborted",
|
||||
escaped_path ? escaped_path : "<allocation failed>", strerror(prealloc_rc));
|
||||
free(escaped_path);
|
||||
}
|
||||
}
|
||||
if (prealloc_rc == 0) {
|
||||
/* posix_fallocate does not guarantee the fd's file offset is left
|
||||
@@ -914,7 +945,7 @@ static bool file_to_disk_secure_impl(const char* path, const void* data,
|
||||
ok = ftruncate(fd, (off_t)data_size) == 0;
|
||||
if (ok || !sparse || data_size == 0)
|
||||
ok = sparse && data_size > 0
|
||||
? write_all_sparse(fd, (const unsigned char*)data, data_size)
|
||||
? file_store_write_sparse(fd, (const unsigned char*)data, data_size)
|
||||
: write_all(fd, data, data_size);
|
||||
if (ok)
|
||||
ok = ftruncate(fd, (off_t)data_size) == 0;
|
||||
@@ -1009,9 +1040,12 @@ static bool file_to_disk_secure_impl(const char* path, const void* data,
|
||||
int prealloc_rc = 0;
|
||||
if (preallocate && !sparse && data_size > 0) {
|
||||
prealloc_rc = preallocate_fd(fd, data_size);
|
||||
if (prealloc_rc != 0)
|
||||
log_message(LOG_LEVEL_ERROR, "preallocate failed for '%s' (%s); transfer aborted", path,
|
||||
strerror(prealloc_rc));
|
||||
if (prealloc_rc != 0) {
|
||||
char* escaped_path = output_escape(path, log_get_8_bit_output());
|
||||
log_message(LOG_LEVEL_ERROR, "preallocate failed for '%s' (%s); transfer aborted",
|
||||
escaped_path ? escaped_path : "<allocation failed>", strerror(prealloc_rc));
|
||||
free(escaped_path);
|
||||
}
|
||||
}
|
||||
if (prealloc_rc == 0) {
|
||||
lseek(fd, 0, SEEK_SET);
|
||||
@@ -1022,8 +1056,9 @@ static bool file_to_disk_secure_impl(const char* path, const void* data,
|
||||
failure may leave partial data that --partial retention can rename. */
|
||||
if (ok || (!sparse || data_size == 0)) {
|
||||
write_attempted = true;
|
||||
ok = sparse && data_size > 0 ? write_all_sparse(fd, (const unsigned char*)data, data_size)
|
||||
: write_all(fd, data, data_size);
|
||||
ok = sparse && data_size > 0
|
||||
? file_store_write_sparse(fd, (const unsigned char*)data, data_size)
|
||||
: write_all(fd, data, data_size);
|
||||
}
|
||||
if (ok && metadata)
|
||||
ok = file_restore_metadata_fd(fd, metadata, preserve_executability);
|
||||
|
||||
+52
-20
@@ -350,7 +350,10 @@ static FileSaveResult file_save_special_to_disk(const char* root_directory, cons
|
||||
}
|
||||
if (is_sock) {
|
||||
/* No standard filesystem call recreates a socket; best-effort unsupported. */
|
||||
log_message(LOG_LEVEL_WARNING, "socket not recreated: %s (unsupported; skipped)", file->path);
|
||||
char* escaped_path = output_escape(file->path, log_get_8_bit_output());
|
||||
log_message(LOG_LEVEL_WARNING, "socket not recreated: %s (unsupported; skipped)",
|
||||
escaped_path ? escaped_path : "<allocation failed>");
|
||||
free(escaped_path);
|
||||
return FILE_SAVE_SKIPPED;
|
||||
}
|
||||
if (is_char || is_blk) {
|
||||
@@ -363,10 +366,11 @@ static FileSaveResult file_save_special_to_disk(const char* root_directory, cons
|
||||
so the policy does not depend on a prior identity_set_active(). Pure
|
||||
FIFO creation is unprivileged and deliberately NOT gated here. */
|
||||
if (!privilege_super_mode_permitted(config->super_mode)) {
|
||||
char* escaped_path = output_escape(file->path, log_get_8_bit_output());
|
||||
log_message(LOG_LEVEL_WARNING,
|
||||
"skipping %s: super-user device-node creation is not permitted "
|
||||
"(super-user activities disabled by --no-super)",
|
||||
file->path);
|
||||
"skipping %s: super-user device-node creation is not permitted on this receiver",
|
||||
escaped_path ? escaped_path : "<allocation failed>");
|
||||
free(escaped_path);
|
||||
return FILE_SAVE_SKIPPED;
|
||||
}
|
||||
} else if (is_fifo) {
|
||||
@@ -439,18 +443,26 @@ static FileSaveResult file_save_special_to_disk(const char* root_directory, cons
|
||||
free(destination);
|
||||
return FILE_SAVE_SKIPPED;
|
||||
}
|
||||
char* escaped_path = output_escape(file->path, log_get_8_bit_output());
|
||||
log_message(LOG_LEVEL_WARNING, "refusing to replace existing entry with %s: %s (skipped)",
|
||||
is_fifo ? "FIFO" : "device", file->path);
|
||||
is_fifo ? "FIFO" : "device", escaped_path ? escaped_path : "<allocation failed>");
|
||||
free(escaped_path);
|
||||
} else if (errno == EPERM || errno == EACCES) {
|
||||
/* Missing CAP_MKNOD / parent write permission: the environment cannot
|
||||
create the node, so skip instead of failing the whole run. */
|
||||
char* escaped_path = output_escape(file->path, log_get_8_bit_output());
|
||||
log_message(LOG_LEVEL_WARNING,
|
||||
"skipping %s: cannot create %s node (%s)\n"
|
||||
" --devices/--specials node creation needs privilege (CAP_MKNOD)",
|
||||
file->path, is_fifo ? "FIFO" : "device", strerror(errno));
|
||||
escaped_path ? escaped_path : "<allocation failed>", is_fifo ? "FIFO" : "device",
|
||||
strerror(errno));
|
||||
free(escaped_path);
|
||||
} else {
|
||||
char* escaped_path = output_escape(file->path, log_get_8_bit_output());
|
||||
log_message(LOG_LEVEL_WARNING, "failed to create %s %s: %s (skipped)",
|
||||
is_fifo ? "FIFO" : "device", file->path, strerror(errno));
|
||||
is_fifo ? "FIFO" : "device", escaped_path ? escaped_path : "<allocation failed>",
|
||||
strerror(errno));
|
||||
free(escaped_path);
|
||||
}
|
||||
close(parent_fd);
|
||||
free(leaf);
|
||||
@@ -468,13 +480,16 @@ static FileSaveResult file_save_special_to_disk(const char* root_directory, cons
|
||||
every entry (a char/block node path is already privilege-gated above). The
|
||||
no-follow helper changes the node's own ownership without dereferencing it;
|
||||
it is a no-op unless an identity policy is active. */
|
||||
bool owner_ok = true;
|
||||
if (identity_active_enabled())
|
||||
identity_apply_ownership_link(parent_fd, leaf, (int32_t)file->metadata->uid,
|
||||
(int32_t)file->metadata->gid);
|
||||
owner_ok = identity_apply_ownership_link(parent_fd, leaf, (int32_t)file->metadata->uid,
|
||||
(int32_t)file->metadata->gid);
|
||||
close(parent_fd);
|
||||
free(leaf);
|
||||
free(destination);
|
||||
return FILE_SAVE_WRITTEN;
|
||||
/* A failed required --copy-as ownership marks the node as failed; every other
|
||||
* identity policy stays best-effort. */
|
||||
return owner_ok ? FILE_SAVE_WRITTEN : FILE_SAVE_ERROR;
|
||||
}
|
||||
|
||||
/* --write-devices (receiver): write the received data directly into an EXISTING
|
||||
@@ -510,22 +525,28 @@ static FileSaveResult file_save_write_device(const char* root_directory, const F
|
||||
close(parent_fd);
|
||||
if (fd < 0) {
|
||||
free(destination);
|
||||
char* escaped_path = output_escape(file->path, log_get_8_bit_output());
|
||||
const char* shown_path = escaped_path ? escaped_path : "<allocation failed>";
|
||||
if (saved_errno == ENXIO || saved_errno == EAGAIN) {
|
||||
/* A FIFO with no reader / an unreadable special: skip like every other
|
||||
unusable write-devices target instead of blocking or failing. */
|
||||
log_message(LOG_LEVEL_WARNING, "write-devices: %s not writable (%s); skipped", file->path,
|
||||
log_message(LOG_LEVEL_WARNING, "write-devices: %s not writable (%s); skipped", shown_path,
|
||||
strerror(saved_errno));
|
||||
} else {
|
||||
log_message(LOG_LEVEL_WARNING, "write-devices: cannot open %s (%s); skipped", file->path,
|
||||
log_message(LOG_LEVEL_WARNING, "write-devices: cannot open %s (%s); skipped", shown_path,
|
||||
strerror(saved_errno));
|
||||
}
|
||||
free(escaped_path);
|
||||
return FILE_SAVE_SKIPPED;
|
||||
}
|
||||
struct stat st;
|
||||
if (fstat(fd, &st) != 0 || !(S_ISCHR(st.st_mode) || S_ISBLK(st.st_mode))) {
|
||||
close(fd);
|
||||
free(destination);
|
||||
log_message(LOG_LEVEL_WARNING, "write-devices: %s is not a device node; skipped", file->path);
|
||||
char* escaped_path = output_escape(file->path, log_get_8_bit_output());
|
||||
log_message(LOG_LEVEL_WARNING, "write-devices: %s is not a device node; skipped",
|
||||
escaped_path ? escaped_path : "<allocation failed>");
|
||||
free(escaped_path);
|
||||
return FILE_SAVE_SKIPPED;
|
||||
}
|
||||
bool ok = true;
|
||||
@@ -594,9 +615,12 @@ FileSaveResult file_save_to_disk_full(const char* root_directory, const File* fi
|
||||
open below keeps its own confinement and best-effort skip semantics). */
|
||||
if (config && config->write_devices) {
|
||||
if (!privilege_super_mode_permitted(config->super_mode)) {
|
||||
char* escaped_path = output_escape(file->path, log_get_8_bit_output());
|
||||
log_message(LOG_LEVEL_WARNING,
|
||||
"write-devices: %s skipped: super-user activities disabled by --no-super",
|
||||
file->path ? file->path : "(null)");
|
||||
"write-devices: %s skipped: super-user activities are not permitted on this "
|
||||
"receiver",
|
||||
escaped_path ? escaped_path : "(null)");
|
||||
free(escaped_path);
|
||||
return FILE_SAVE_SKIPPED;
|
||||
}
|
||||
return file_save_write_device(root_directory, file);
|
||||
@@ -626,9 +650,14 @@ FileSaveResult file_save_to_disk_full(const char* root_directory, const File* fi
|
||||
char* leaf = NULL;
|
||||
int parent_fd = file_open_secure_parent(dir_path, &leaf, false);
|
||||
if (parent_fd >= 0) {
|
||||
identity_apply_ownership_link(parent_fd, leaf, (int32_t)file->metadata->uid,
|
||||
(int32_t)file->metadata->gid);
|
||||
if (!identity_apply_ownership_link(parent_fd, leaf, (int32_t)file->metadata->uid,
|
||||
(int32_t)file->metadata->gid))
|
||||
ok = false;
|
||||
close(parent_fd);
|
||||
} else if (identity_copy_as_active()) {
|
||||
/* The directory exists (ok) but its required --copy-as ownership could
|
||||
not be applied because the confined parent could not be opened. */
|
||||
ok = false;
|
||||
}
|
||||
free(leaf);
|
||||
}
|
||||
@@ -675,17 +704,20 @@ FileSaveResult file_save_to_disk_full(const char* root_directory, const File* fi
|
||||
}
|
||||
char* parent = str_dup(link_path);
|
||||
if (parent) {
|
||||
file_ensure_directory_secure(dirname(parent));
|
||||
/* Propagate a failed --copy-as ownership of the parent directory this
|
||||
creates; every other failure mode stays best-effort as before. */
|
||||
ok = file_ensure_directory_secure(dirname(parent));
|
||||
free(parent);
|
||||
}
|
||||
ok = file_symlink_at_secure(link_path, target);
|
||||
if (ok)
|
||||
ok = file_symlink_at_secure(link_path, target);
|
||||
free(target);
|
||||
/* P7 Wave D: apply the symlink's own metadata with no-follow primitives
|
||||
(utimensat/lchown/fchmodat AT_SYMLINK_NOFOLLOW). -J/--omit-link-times
|
||||
suppresses the timestamps; ownership stays gated by the identity policy.
|
||||
A symlink has no children, so this can be applied immediately. */
|
||||
if (ok && config && config->use_metadata)
|
||||
file_restore_symlink_metadata(link_path, file->metadata, config->omit_link_times);
|
||||
ok = file_restore_symlink_metadata(link_path, file->metadata, config->omit_link_times);
|
||||
free(link_path);
|
||||
return ok ? FILE_SAVE_WRITTEN : FILE_SAVE_ERROR;
|
||||
}
|
||||
|
||||
@@ -152,7 +152,7 @@ static bool write_all(int fd, const void* data, unsigned long long size) {
|
||||
* would). After the final run, ftruncate(size) guarantees the logical size is
|
||||
* exactly `size` even when the tail was a hole. The full file image is in
|
||||
* memory, so no wire change is needed. Returns false on I/O error. */
|
||||
static bool write_all_sparse(int fd, const unsigned char* data, unsigned long long size) {
|
||||
bool file_store_write_sparse(int fd, const unsigned char* data, unsigned long long size) {
|
||||
unsigned long long i = 0;
|
||||
while (i < size) {
|
||||
if (data[i] == 0) {
|
||||
@@ -191,7 +191,7 @@ bool file_store_write_secure(const char* path, const void* data, unsigned long l
|
||||
if (fd >= 0) {
|
||||
if (sparse && data_size > 0) {
|
||||
if (ftruncate(fd, (off_t)data_size) == 0)
|
||||
ok = write_all_sparse(fd, data, data_size);
|
||||
ok = file_store_write_sparse(fd, data, data_size);
|
||||
} else {
|
||||
ok = write_all(fd, data, data_size);
|
||||
}
|
||||
@@ -219,8 +219,9 @@ bool file_store_write_secure(const char* path, const void* data, unsigned long l
|
||||
if (sparse && data_size > 0)
|
||||
ok = ftruncate(fd, (off_t)data_size) == 0;
|
||||
if (ok || (!sparse || data_size == 0))
|
||||
ok = (sparse && data_size > 0) ? write_all_sparse(fd, (const unsigned char*)data, data_size)
|
||||
: write_all(fd, data, data_size);
|
||||
ok = (sparse && data_size > 0)
|
||||
? file_store_write_sparse(fd, (const unsigned char*)data, data_size)
|
||||
: write_all(fd, data, data_size);
|
||||
if (ok && metadata)
|
||||
ok = file_restore_metadata_fd(fd, metadata, preserve_executability);
|
||||
if (close(fd) != 0)
|
||||
|
||||
@@ -10,5 +10,12 @@ bool file_store_rename_secure(const char* old_path, const char* new_path);
|
||||
bool file_store_write_secure(const char* path, const void* data, unsigned long long data_size,
|
||||
bool inplace, bool sparse, const FileMetadata* metadata,
|
||||
bool preserve_executability);
|
||||
/* Sparse-aware write (--sparse/-S): every all-zero run of at least
|
||||
* SPARSE_HOLE_MIN bytes is skipped with lseek(SEEK_CUR) so it becomes a real
|
||||
* hole; every other byte is written. The caller pre-sizes the file with
|
||||
* ftruncate; this function also ftruncate()s to `size` at the end so a trailing
|
||||
* hole keeps the exact logical length. Shared by the file_store and file write
|
||||
* paths. Returns false on write/lseek/ftruncate error. */
|
||||
bool file_store_write_sparse(int fd, const unsigned char* data, unsigned long long size);
|
||||
|
||||
#endif
|
||||
+108
-89
@@ -64,10 +64,10 @@ void identity_clear_active(void) {
|
||||
identity_active_reset();
|
||||
}
|
||||
|
||||
void identity_set_active(const Config* config) {
|
||||
bool identity_set_active(const Config* config) {
|
||||
identity_active_reset();
|
||||
if (!config)
|
||||
return;
|
||||
return true;
|
||||
g_identity.numeric_ids = config->numeric_ids;
|
||||
g_identity.chown_uid_set = config->chown_uid_set;
|
||||
g_identity.chown_uid = config->chown_uid;
|
||||
@@ -79,19 +79,19 @@ void identity_set_active(const Config* config) {
|
||||
g_identity.copy_as_gid = config->copy_as_gid;
|
||||
if (config->usermap_count > 0) {
|
||||
g_identity.usermap = calloc((size_t)config->usermap_count, sizeof(IdentityMap));
|
||||
if (g_identity.usermap) {
|
||||
memcpy(g_identity.usermap, config->usermap,
|
||||
(size_t)config->usermap_count * sizeof(IdentityMap));
|
||||
g_identity.usermap_count = config->usermap_count;
|
||||
}
|
||||
if (!g_identity.usermap)
|
||||
goto alloc_failed;
|
||||
memcpy(g_identity.usermap, config->usermap,
|
||||
(size_t)config->usermap_count * sizeof(IdentityMap));
|
||||
g_identity.usermap_count = config->usermap_count;
|
||||
}
|
||||
if (config->groupmap_count > 0) {
|
||||
g_identity.groupmap = calloc((size_t)config->groupmap_count, sizeof(IdentityMap));
|
||||
if (g_identity.groupmap) {
|
||||
memcpy(g_identity.groupmap, config->groupmap,
|
||||
(size_t)config->groupmap_count * sizeof(IdentityMap));
|
||||
g_identity.groupmap_count = config->groupmap_count;
|
||||
}
|
||||
if (!g_identity.groupmap)
|
||||
goto alloc_failed;
|
||||
memcpy(g_identity.groupmap, config->groupmap,
|
||||
(size_t)config->groupmap_count * sizeof(IdentityMap));
|
||||
g_identity.groupmap_count = config->groupmap_count;
|
||||
}
|
||||
g_identity.set = true;
|
||||
/* A root receiver would honor any client-supplied ownership request (a
|
||||
@@ -113,6 +113,15 @@ void identity_set_active(const Config* config) {
|
||||
"--super requested but the receiver is not privileged; super-user "
|
||||
"activities (ownership, device nodes) will be attempted but refused "
|
||||
"by the kernel and skipped per entry");
|
||||
return true;
|
||||
|
||||
alloc_failed:
|
||||
/* Never proceed with a partial (count-left-zero) map: that would silently
|
||||
apply the WRONG ownership policy. Fail closed and let the caller refuse
|
||||
the connection. */
|
||||
log_message(LOG_LEVEL_ERROR, "memory allocation failed while activating identity policy");
|
||||
identity_active_reset();
|
||||
return false;
|
||||
}
|
||||
|
||||
bool privilege_super_permitted(void) {
|
||||
@@ -128,29 +137,29 @@ bool privilege_super_mode_permitted(int mode) {
|
||||
return mode != SUPER_MODE_OFF;
|
||||
}
|
||||
|
||||
/* --super with NO explicit identity policy implies raw numeric-id preservation,
|
||||
* exactly as if --numeric-ids had been given. An explicit usermap/groupmap/
|
||||
* --chown/--numeric-ids always wins: identity_resolve_targets() checks those
|
||||
* before the numeric fallback, and this predicate is false whenever any of them
|
||||
* is present. In AUTO (the default) no implication is made, preserving the
|
||||
* opt-in-only behavior. */
|
||||
static bool identity_super_implies_numeric(void) {
|
||||
return g_identity.super_mode == SUPER_MODE_ON && !g_identity.numeric_ids &&
|
||||
!g_identity.chown_uid_set && !g_identity.chown_gid_set && g_identity.usermap_count == 0 &&
|
||||
g_identity.groupmap_count == 0;
|
||||
}
|
||||
|
||||
bool identity_active_enabled(void) {
|
||||
/* numeric_ids is included: this set only gates identity_apply_ownership,
|
||||
which runs only when metadata is present (a -M/--preserve transfer). A
|
||||
standalone --numeric-ids (no ownership-affecting flag) carries no
|
||||
metadata, never reaches identity_apply_ownership, and therefore correctly
|
||||
stays inert; combined with -M it activates raw-id application. --super
|
||||
with no explicit identity policy acts like --numeric-ids here. */
|
||||
stays inert; combined with -M it activates raw-id application. --super /
|
||||
--no-super does NOT enable ownership: it only permits or forbids the
|
||||
already-requested super-user activities, so a --super with no explicit
|
||||
identity flag must never silently apply client-chosen ownership. */
|
||||
return g_identity.set &&
|
||||
(g_identity.numeric_ids || g_identity.chown_uid_set || g_identity.chown_gid_set ||
|
||||
g_identity.usermap_count > 0 || g_identity.groupmap_count > 0 || g_identity.copy_as_set ||
|
||||
identity_super_implies_numeric());
|
||||
g_identity.usermap_count > 0 || g_identity.groupmap_count > 0 || g_identity.copy_as_set);
|
||||
}
|
||||
|
||||
bool identity_ownership_requested(const Config* config) {
|
||||
if (!config)
|
||||
return false;
|
||||
/* Every value that makes the receiver act on a client-chosen owner, plus an
|
||||
* explicit --super (super-user device-node activities). Pure config, so the
|
||||
* daemon gate can evaluate it before identity_set_active(). */
|
||||
return config->numeric_ids || config->chown_uid_set || config->chown_gid_set ||
|
||||
config->usermap_count > 0 || config->groupmap_count > 0 || config->copy_as_set ||
|
||||
config->fake_super || config->super_mode == SUPER_MODE_ON;
|
||||
}
|
||||
|
||||
bool identity_copy_as_active(void) {
|
||||
@@ -440,6 +449,25 @@ static bool identity_id_fits_int32(unsigned long id) {
|
||||
return id <= (unsigned long)INT32_MAX;
|
||||
}
|
||||
|
||||
/* Resolve one --copy-as id token. A '*' token means the caller's current
|
||||
* effective uid (user) or gid (group). Returns 0 on success. On failure sets
|
||||
* *overflow when a '*' id was wider than int32 so the caller can log the
|
||||
* specific message; otherwise the token was simply unresolvable. */
|
||||
static int identity_resolve_copy_as_id(const char* token, bool is_group, int32_t* out,
|
||||
bool* overflow) {
|
||||
*overflow = false;
|
||||
if (strcmp(token, "*") == 0) {
|
||||
unsigned long current = is_group ? (unsigned long)getegid() : (unsigned long)geteuid();
|
||||
if (!identity_id_fits_int32(current)) {
|
||||
*overflow = true;
|
||||
return -1;
|
||||
}
|
||||
*out = (int32_t)current;
|
||||
return 0;
|
||||
}
|
||||
return identity_resolve_token(token, is_group, out);
|
||||
}
|
||||
|
||||
int identity_parse_copy_as(Config* config, const char* value) {
|
||||
if (!config || !value || *value == '\0') {
|
||||
log_message(LOG_LEVEL_ERROR, "--copy-as requires USER[:GROUP]");
|
||||
@@ -465,7 +493,7 @@ int identity_parse_copy_as(Config* config, const char* value) {
|
||||
log_message(LOG_LEVEL_ERROR, "memory allocation failed for --copy-as");
|
||||
return -1;
|
||||
}
|
||||
char* user_token = spec;
|
||||
const char* user_token = spec;
|
||||
const char* group_token = NULL;
|
||||
char* colon = strchr(spec, ':');
|
||||
if (colon) {
|
||||
@@ -477,57 +505,39 @@ int identity_parse_copy_as(Config* config, const char* value) {
|
||||
* (8-bit-safe) so a control byte cannot forge a log line. */
|
||||
char* escaped_spec = output_escape(value, false);
|
||||
const char* shown = escaped_spec ? escaped_spec : "<allocation failed>";
|
||||
int ret = -1;
|
||||
|
||||
int32_t uid;
|
||||
if (*user_token == '\0') {
|
||||
log_message(LOG_LEVEL_ERROR, "--copy-as is missing the user (got '%s')", shown);
|
||||
free(escaped_spec);
|
||||
free(spec);
|
||||
return -1;
|
||||
goto done;
|
||||
}
|
||||
if (strcmp(user_token, "*") == 0) {
|
||||
/* '*' means the current/root user: the client's euid. */
|
||||
if (!identity_id_fits_int32((unsigned long)geteuid())) {
|
||||
bool overflow = false;
|
||||
int32_t uid;
|
||||
if (identity_resolve_copy_as_id(user_token, false, &uid, &overflow) != 0) {
|
||||
if (overflow)
|
||||
log_message(LOG_LEVEL_ERROR, "--copy-as: current user id %lu exceeds INT32_MAX",
|
||||
(unsigned long)geteuid());
|
||||
free(escaped_spec);
|
||||
free(spec);
|
||||
return -1;
|
||||
}
|
||||
uid = (int32_t)geteuid();
|
||||
} else if (identity_resolve_token(user_token, false, &uid) != 0) {
|
||||
log_message(LOG_LEVEL_ERROR,
|
||||
"--copy-as could not resolve user (use a name that exists on the "
|
||||
"source, '*', or @N): %s",
|
||||
shown);
|
||||
free(escaped_spec);
|
||||
free(spec);
|
||||
return -1;
|
||||
else
|
||||
log_message(LOG_LEVEL_ERROR,
|
||||
"--copy-as could not resolve user (use a name that exists on the "
|
||||
"source, '*', or @N): %s",
|
||||
shown);
|
||||
goto done;
|
||||
}
|
||||
|
||||
int32_t gid;
|
||||
if (group_token) {
|
||||
if (*group_token == '\0') {
|
||||
log_message(LOG_LEVEL_ERROR, "--copy-as group is empty (got '%s')", shown);
|
||||
free(escaped_spec);
|
||||
free(spec);
|
||||
return -1;
|
||||
goto done;
|
||||
}
|
||||
if (strcmp(group_token, "*") == 0) {
|
||||
if (!identity_id_fits_int32((unsigned long)getegid())) {
|
||||
if (identity_resolve_copy_as_id(group_token, true, &gid, &overflow) != 0) {
|
||||
if (overflow)
|
||||
log_message(LOG_LEVEL_ERROR, "--copy-as: current group id %lu exceeds INT32_MAX",
|
||||
(unsigned long)getegid());
|
||||
free(escaped_spec);
|
||||
free(spec);
|
||||
return -1;
|
||||
}
|
||||
gid = (int32_t)getegid();
|
||||
} else if (identity_resolve_token(group_token, true, &gid) != 0) {
|
||||
log_message(LOG_LEVEL_ERROR, "--copy-as could not resolve group (got '%s'): %s", shown,
|
||||
shown);
|
||||
free(escaped_spec);
|
||||
free(spec);
|
||||
return -1;
|
||||
else
|
||||
log_message(LOG_LEVEL_ERROR, "--copy-as could not resolve group (got '%s')", shown);
|
||||
goto done;
|
||||
}
|
||||
} else {
|
||||
/* Group omitted: use the user's primary gid. A numeric id with no local
|
||||
@@ -539,9 +549,7 @@ int identity_parse_copy_as(Config* config, const char* value) {
|
||||
log_message(LOG_LEVEL_ERROR,
|
||||
"--copy-as: primary group id %lu for the requested user exceeds INT32_MAX",
|
||||
(unsigned long)pw->pw_gid);
|
||||
free(escaped_spec);
|
||||
free(spec);
|
||||
return -1;
|
||||
goto done;
|
||||
}
|
||||
gid = (int32_t)pw->pw_gid;
|
||||
} else {
|
||||
@@ -553,12 +561,8 @@ int identity_parse_copy_as(Config* config, const char* value) {
|
||||
* identity_resolve_token. */
|
||||
if (uid < 0 || gid < 0) {
|
||||
log_message(LOG_LEVEL_ERROR, "--copy-as resolved id does not fit in int32 (got '%s')", shown);
|
||||
free(escaped_spec);
|
||||
free(spec);
|
||||
return -1;
|
||||
goto done;
|
||||
}
|
||||
free(escaped_spec);
|
||||
free(spec);
|
||||
|
||||
config->copy_as_set = true;
|
||||
config->copy_as_uid = uid;
|
||||
@@ -566,7 +570,12 @@ int identity_parse_copy_as(Config* config, const char* value) {
|
||||
/* Ownership application needs the metadata path (the source uid/gid must be
|
||||
* transmitted); imply it exactly like --chown/--usermap/--groupmap. */
|
||||
config->use_metadata = true;
|
||||
return 0;
|
||||
ret = 0;
|
||||
|
||||
done:
|
||||
free(escaped_spec);
|
||||
free(spec);
|
||||
return ret;
|
||||
}
|
||||
|
||||
/* ---- Receiver-side ownership application ---- */
|
||||
@@ -613,7 +622,7 @@ static bool identity_resolve_targets(const struct stat* st, int32_t source_uid,
|
||||
} else if (g_identity.chown_uid_set) {
|
||||
uid = g_identity.chown_uid == IDENTITY_CURRENT ? geteuid() : (uid_t)g_identity.chown_uid;
|
||||
set_uid = true;
|
||||
} else if (g_identity.numeric_ids || identity_super_implies_numeric()) {
|
||||
} else if (g_identity.numeric_ids) {
|
||||
uid = (uid_t)source_uid;
|
||||
set_uid = true;
|
||||
} else {
|
||||
@@ -637,7 +646,7 @@ static bool identity_resolve_targets(const struct stat* st, int32_t source_uid,
|
||||
} else if (g_identity.chown_gid_set) {
|
||||
gid = g_identity.chown_gid == IDENTITY_CURRENT ? getegid() : (gid_t)g_identity.chown_gid;
|
||||
set_gid = true;
|
||||
} else if (g_identity.numeric_ids || identity_super_implies_numeric()) {
|
||||
} else if (g_identity.numeric_ids) {
|
||||
gid = (gid_t)source_gid;
|
||||
set_gid = true;
|
||||
} else {
|
||||
@@ -676,9 +685,11 @@ static void identity_log_chown_failure(const char* what, uid_t uid, gid_t gid) {
|
||||
* --copy-as is different: the whole point of the flag is that the target
|
||||
* ownership is REQUIRED (the pre-flight gate already refused an unprivileged
|
||||
* receiver). If the chown still fails with EPERM/EACCES (a capability-
|
||||
* restricted root, root-squash, or a read-only mount) the run is silently
|
||||
* producing the WRONG ownership, so surface it at ERROR. It stays
|
||||
* non-fatal: never abort the multithreaded receiver mid-transfer. */
|
||||
* restricted root, root-squash, or a read-only mount) the run would be
|
||||
* silently producing the WRONG ownership, so surface it at ERROR. The
|
||||
* caller (identity_apply_ownership*) then reports the ENTRY as failed rather
|
||||
* than as written, which becomes a FILE_SAVE_ERROR and fails the transfer
|
||||
* (fail-fast) instead of reporting overall success with the wrong owner. */
|
||||
if (errno == EPERM || errno == EACCES) {
|
||||
if (identity_copy_as_active())
|
||||
log_message(LOG_LEVEL_ERROR,
|
||||
@@ -695,35 +706,43 @@ static void identity_log_chown_failure(const char* what, uid_t uid, gid_t gid) {
|
||||
}
|
||||
}
|
||||
|
||||
void identity_apply_ownership(int fd, int32_t source_uid, int32_t source_gid) {
|
||||
bool identity_apply_ownership(int fd, int32_t source_uid, int32_t source_gid) {
|
||||
/* Ownership application is OFF unless the client requested an identity flag.
|
||||
* This is the controlled gate: a default (or plain -M) transfer never changes
|
||||
* ownership, byte-for-byte preserving FastSync's existing behavior. --no-super
|
||||
* additionally forbids it even when the receiver is root. */
|
||||
if (!identity_active_enabled() || !privilege_super_permitted() || fd < 0)
|
||||
return;
|
||||
return true;
|
||||
struct stat st;
|
||||
if (fstat(fd, &st) != 0)
|
||||
return;
|
||||
return !identity_copy_as_active();
|
||||
uid_t uid;
|
||||
gid_t gid;
|
||||
if (!identity_resolve_targets(&st, source_uid, source_gid, &uid, &gid))
|
||||
return;
|
||||
if (fchown(fd, uid, gid) != 0)
|
||||
return true;
|
||||
if (fchown(fd, uid, gid) != 0) {
|
||||
identity_log_chown_failure("file", uid, gid);
|
||||
/* A required --copy-as ownership that did not land is a per-entry failure;
|
||||
* every other policy stays best-effort (rsync parity). */
|
||||
return !identity_copy_as_active();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void identity_apply_ownership_link(int parent_fd, const char* leaf, int32_t source_uid,
|
||||
bool identity_apply_ownership_link(int parent_fd, const char* leaf, int32_t source_uid,
|
||||
int32_t source_gid) {
|
||||
if (!identity_active_enabled() || !privilege_super_permitted() || parent_fd < 0 || !leaf)
|
||||
return;
|
||||
return true;
|
||||
struct stat st;
|
||||
if (fstatat(parent_fd, leaf, &st, AT_SYMLINK_NOFOLLOW) != 0)
|
||||
return;
|
||||
return !identity_copy_as_active();
|
||||
uid_t uid;
|
||||
gid_t gid;
|
||||
if (!identity_resolve_targets(&st, source_uid, source_gid, &uid, &gid))
|
||||
return;
|
||||
if (fchownat(parent_fd, leaf, uid, gid, AT_SYMLINK_NOFOLLOW) != 0)
|
||||
return true;
|
||||
if (fchownat(parent_fd, leaf, uid, gid, AT_SYMLINK_NOFOLLOW) != 0) {
|
||||
identity_log_chown_failure("no-follow entry", uid, gid);
|
||||
return !identity_copy_as_active();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
+36
-13
@@ -7,7 +7,7 @@
|
||||
#include <sys/types.h>
|
||||
|
||||
/*
|
||||
* Identity mapping: --numeric-ids / --usermap / --groupmap / --chown.
|
||||
* Identity mapping: --numeric-ids / --usermap / --groupmap / --chown / --copy-as.
|
||||
*
|
||||
* FastSync transmits uid/gid numerically (int32 on the wire) and, by design,
|
||||
* NEVER applies client-supplied ownership unless a user explicitly opts in with
|
||||
@@ -67,29 +67,52 @@ bool identity_copy_as_active(void);
|
||||
/* Receiver-side snapshot of the negotiated identity config. The server calls
|
||||
* identity_set_active() once per connection (before any file write) using the
|
||||
* config received over the wire; the snapshot is a deep copy so the caller may
|
||||
* free its Config immediately. identity_clear_active() releases it. */
|
||||
void identity_set_active(const Config* config);
|
||||
* free its Config immediately. identity_clear_active() releases it.
|
||||
*
|
||||
* Returns true on success. On an allocation failure while deep-copying a
|
||||
* requested usermap/groupmap it logs a LOG_LEVEL_ERROR, leaves the snapshot
|
||||
* cleared (never a partial/wrong policy) and returns false; the caller must
|
||||
* refuse the connection. */
|
||||
bool identity_set_active(const Config* config);
|
||||
void identity_clear_active(void);
|
||||
|
||||
/* True when any ownership-affecting identity option is present in the active
|
||||
* snapshot. Ownership stays OFF ("do not apply") for every transfer that
|
||||
* requests none of them, preserving FastSync's existing behavior. */
|
||||
* requests none of them, preserving FastSync's existing behavior. --super /
|
||||
* --no-super alone does NOT enable ownership; an explicit identity flag
|
||||
* (--numeric-ids / --chown / --usermap / --groupmap / --copy-as) is required. */
|
||||
bool identity_active_enabled(void);
|
||||
|
||||
/* Pure, config-only predicate: true when the client requested ANY
|
||||
* client-chosen ownership or super-user activity (--numeric-ids, --chown,
|
||||
* --usermap/--groupmap, --copy-as, --fake-super, or an explicit --super). Used
|
||||
* by the daemon module gate to decide whether a module's per-module opt-in is
|
||||
* required; it never reads the per-connection snapshot. */
|
||||
bool identity_ownership_requested(const Config* config);
|
||||
|
||||
/* Apply the negotiated ownership to an already-written file descriptor.
|
||||
* source_uid/source_gid are the transmitted numeric ids. Resolution order:
|
||||
* a matching usermap/groupmap rule, then --chown, then --numeric-ids (raw),
|
||||
* then a best-effort name lookup on the receiver's own databases (skipped when
|
||||
* the transmitted id has no name on this system). Only calls fchown() when the
|
||||
* result differs from the current value; EPERM/EACCES are logged and ignored,
|
||||
* never fatal (rsync parity: the transfer must not abort). */
|
||||
void identity_apply_ownership(int fd, int32_t source_uid, int32_t source_gid);
|
||||
* --copy-as (highest priority, forces both ids), then a matching
|
||||
* usermap/groupmap rule, then --chown, then --numeric-ids (raw), then a
|
||||
* best-effort name lookup on the receiver's own databases (skipped when the
|
||||
* transmitted id has no name on this system). Only calls fchown() when the
|
||||
* result differs from the current value.
|
||||
*
|
||||
* Returns false ONLY when an active --copy-as ownership application failed: its
|
||||
* forced ownership is REQUIRED, so the caller must treat the entry as failed
|
||||
* rather than reporting success with the wrong owner. For every other identity
|
||||
* policy an fchown EPERM/EACCES is logged and ignored and true is returned
|
||||
* (rsync parity: the transfer must not abort). A no-op when no identity policy
|
||||
* is active returns true. */
|
||||
bool identity_apply_ownership(int fd, int32_t source_uid, int32_t source_gid);
|
||||
|
||||
/* P7 Wave D: the no-follow (symlink) counterpart. Resolves the same
|
||||
* usermap/groupmap/chown/numeric-ids policy but applies it with
|
||||
* usermap/groupmap/chown/numeric-ids/copy-as policy but applies it with
|
||||
* fchownat(..., AT_SYMLINK_NOFOLLOW) so a symlink's own ownership is changed
|
||||
* without ever dereferencing it. A no-op unless an identity flag is active. */
|
||||
void identity_apply_ownership_link(int parent_fd, const char* leaf, int32_t source_uid,
|
||||
* without ever dereferencing it. A no-op unless an identity flag is active.
|
||||
* The return value follows identity_apply_ownership(): false only when an
|
||||
* active --copy-as application failed. */
|
||||
bool identity_apply_ownership_link(int parent_fd, const char* leaf, int32_t source_uid,
|
||||
int32_t source_gid);
|
||||
|
||||
/* Receiver-side wire validation of the resolved identity fields. */
|
||||
|
||||
@@ -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);
|
||||
|
||||
+17
-9
@@ -357,17 +357,20 @@ void file_restore_metadata(const char* path, const FileMetadata* metadata,
|
||||
}
|
||||
}
|
||||
|
||||
void file_restore_symlink_metadata(const char* path, const FileMetadata* metadata,
|
||||
bool file_restore_symlink_metadata(const char* path, const FileMetadata* metadata,
|
||||
bool omit_link_times) {
|
||||
if (path == NULL || metadata == NULL)
|
||||
return;
|
||||
return !identity_copy_as_active();
|
||||
char* leaf = NULL;
|
||||
int parent_fd = file_open_secure_parent(path, &leaf, false);
|
||||
if (parent_fd < 0)
|
||||
return;
|
||||
return !identity_copy_as_active();
|
||||
/* Ownership (only when the identity policy is active) via lchown semantics:
|
||||
fchownat with AT_SYMLINK_NOFOLLOW never dereferences the link. */
|
||||
identity_apply_ownership_link(parent_fd, leaf, (int32_t)metadata->uid, (int32_t)metadata->gid);
|
||||
fchownat with AT_SYMLINK_NOFOLLOW never dereferences the link. A failed
|
||||
REQUIRED --copy-as ownership marks the entry failed; every other policy is
|
||||
best-effort. */
|
||||
bool owned = identity_apply_ownership_link(parent_fd, leaf, (int32_t)metadata->uid,
|
||||
(int32_t)metadata->gid);
|
||||
/* Symlink mode: not settable on Linux (fchmodat AT_SYMLINK_NOFOLLOW returns
|
||||
EOPNOTSUPP/ENOTSUP); attempt it for platforms that support it and quietly
|
||||
ignore the unsupported case so the transfer never fails over it. */
|
||||
@@ -392,6 +395,7 @@ void file_restore_symlink_metadata(const char* path, const FileMetadata* metadat
|
||||
}
|
||||
close(parent_fd);
|
||||
free(leaf);
|
||||
return owned;
|
||||
}
|
||||
|
||||
bool file_restore_metadata_fd(int fd, const FileMetadata* metadata, bool preserve_executability) {
|
||||
@@ -409,10 +413,14 @@ bool file_restore_metadata_fd(int fd, const FileMetadata* metadata, bool preserv
|
||||
--groupmap / --chown). identity_apply_ownership is the controlled,
|
||||
privilege-gated path: it consults the negotiated policy, resolves the
|
||||
target ids, and applies them via an fd-relative fchown() that is confined
|
||||
to the just-written file (EPERM/EACCES are logged, never fatal). With no
|
||||
identity flag set it is a no-op, so a default or plain -M transfer keeps
|
||||
FastSync's existing behavior of never applying client ownership. */
|
||||
identity_apply_ownership(fd, (int32_t)metadata->uid, (int32_t)metadata->gid);
|
||||
to the just-written file (EPERM/EACCES are logged, never fatal) -- EXCEPT
|
||||
for an active --copy-as, whose forced ownership is REQUIRED: a failure
|
||||
marks this entry as failed instead of reporting a wrong-owner write as
|
||||
success. With no identity flag set it is a no-op, so a default or plain -M
|
||||
transfer keeps FastSync's existing behavior of never applying client
|
||||
ownership. */
|
||||
if (!identity_apply_ownership(fd, (int32_t)metadata->uid, (int32_t)metadata->gid))
|
||||
ok = false;
|
||||
struct timespec times[2] = {{.tv_sec = 0, .tv_nsec = UTIME_OMIT},
|
||||
{.tv_sec = metadata->mtime_sec, .tv_nsec = metadata->mtime_nsec}};
|
||||
if (metadata->atime_valid) {
|
||||
|
||||
@@ -44,8 +44,10 @@ bool file_restore_metadata_fd(int fd, const FileMetadata* metadata, bool preserv
|
||||
* under the authorized root. `omit_link_times` (-J/--omit-link-times)
|
||||
* suppresses the timestamps; the link's mode/ownership are still attempted
|
||||
* (ownership stays gated by the identity policy and by default is not applied).
|
||||
* A null metadata or an unfollowable parent is a harmless no-op. */
|
||||
void file_restore_symlink_metadata(const char* path, const FileMetadata* metadata,
|
||||
* A null metadata or an unfollowable parent is a harmless no-op. Returns false
|
||||
* only when a REQUIRED --copy-as ownership application failed, so the caller can
|
||||
* report the entry as failed instead of claiming a wrong-owner success. */
|
||||
bool file_restore_symlink_metadata(const char* path, const FileMetadata* metadata,
|
||||
bool omit_link_times);
|
||||
|
||||
/* Compare timestamps using rsync's whole-second modification window. */
|
||||
|
||||
+28
-10
@@ -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;
|
||||
@@ -430,10 +439,14 @@ static bool protocol_send_str_impl(ProtocolSession* session, const char* data, b
|
||||
return false;
|
||||
if (!protocol_send_n_data(session, data, size))
|
||||
return false;
|
||||
if (redact)
|
||||
if (redact) {
|
||||
log_debug_message(LOG_DEBUG_PROTO, "Send String: <redacted>");
|
||||
else
|
||||
log_debug_message(LOG_DEBUG_PROTO, "Send String: %s", data);
|
||||
} 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>");
|
||||
free(escaped_data);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -459,10 +472,14 @@ static char* protocol_receive_str_impl(ProtocolSession* session, bool redact) {
|
||||
return NULL;
|
||||
}
|
||||
data[size] = '\0';
|
||||
if (redact)
|
||||
if (redact) {
|
||||
log_debug_message(LOG_DEBUG_PROTO, "Received String: <redacted>");
|
||||
else
|
||||
log_debug_message(LOG_DEBUG_PROTO, "Received String: %s", data);
|
||||
} 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>");
|
||||
free(escaped_data);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -586,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);
|
||||
|
||||
+30
-40
@@ -84,32 +84,29 @@ char* ssh_build_remote_command(const char* server_path, bool old_args, char* con
|
||||
const char* suffix = " --stdio";
|
||||
|
||||
/* Each --remote-option=OPT is appended after " --stdio" as one shell word,
|
||||
escaped with the SAME single-quote boundary used for the server path. This
|
||||
stays safe even in --old-args mode (which leaves the server path unquoted):
|
||||
remote options are always single-quoted individually, so a value containing
|
||||
shell metacharacters (; & | ` $ ()) can never break out of the quoting to
|
||||
inject an unrelated remote command. Values are already validated at CLI
|
||||
parse time (non-empty, no control characters); this layer only adds the
|
||||
escaping boundary. */
|
||||
escaped with the SAME single-quote boundary used for the server path, so a
|
||||
value containing shell metacharacters (; & | ` $ ()) can never break out of
|
||||
the quoting to inject an unrelated remote command. Values are already
|
||||
validated at CLI parse time (non-empty, no control characters); this layer
|
||||
only adds the escaping boundary. */
|
||||
size_t path_len = strlen(path);
|
||||
size_t suffix_len = strlen(suffix);
|
||||
|
||||
/* The base command (server path, quoted unless --old-args, then " --stdio"). */
|
||||
size_t command_len;
|
||||
if (old_args) {
|
||||
if (path_len > SIZE_MAX - suffix_len - 1)
|
||||
return NULL;
|
||||
command_len = path_len + suffix_len + 1;
|
||||
} else {
|
||||
size_t quote_count = 0;
|
||||
for (const char* p = path; *p; p++)
|
||||
if (*p == '\'')
|
||||
quote_count++;
|
||||
if (path_len > SIZE_MAX - suffix_len - 4 ||
|
||||
quote_count > (SIZE_MAX - path_len - suffix_len - 4) / 4)
|
||||
return NULL;
|
||||
command_len = path_len + quote_count * 4 + suffix_len + 4;
|
||||
}
|
||||
/* The base command: the server path is ALWAYS quoted as one single-quoted
|
||||
shell word (remote options below reuse the same escaping), then
|
||||
" --stdio". Quoting the path is the only injection-safe construction: an
|
||||
unquoted path would carry shell metacharacters straight into the remote
|
||||
shell command. --old-args is kept for CLI/ABI compatibility but no longer
|
||||
disables that protection. */
|
||||
(void)old_args;
|
||||
size_t quote_count = 0;
|
||||
for (const char* p = path; *p; p++)
|
||||
if (*p == '\'')
|
||||
quote_count++;
|
||||
if (path_len > SIZE_MAX - suffix_len - 4 ||
|
||||
quote_count > (SIZE_MAX - path_len - suffix_len - 4) / 4)
|
||||
return NULL;
|
||||
size_t command_len = path_len + quote_count * 4 + suffix_len + 4;
|
||||
|
||||
/* Add each remote option, escaped as one single-quoted word:
|
||||
" '<body>'", i.e. 1 leading space + 1 open quote + body (len + 3 per
|
||||
@@ -141,25 +138,18 @@ char* ssh_build_remote_command(const char* server_path, bool old_args, char* con
|
||||
if (!command)
|
||||
return NULL;
|
||||
char* out = command;
|
||||
if (old_args) {
|
||||
memcpy(out, path, path_len);
|
||||
out += path_len;
|
||||
memcpy(out, suffix, suffix_len + 1);
|
||||
out += suffix_len;
|
||||
} else {
|
||||
*out++ = '\'';
|
||||
for (const char* p = path; *p; p++) {
|
||||
if (*p == '\'') {
|
||||
memcpy(out, "'\\''", 4);
|
||||
out += 4;
|
||||
} else {
|
||||
*out++ = *p;
|
||||
}
|
||||
*out++ = '\'';
|
||||
for (const char* p = path; *p; p++) {
|
||||
if (*p == '\'') {
|
||||
memcpy(out, "'\\''", 4);
|
||||
out += 4;
|
||||
} else {
|
||||
*out++ = *p;
|
||||
}
|
||||
*out++ = '\'';
|
||||
memcpy(out, suffix, suffix_len + 1);
|
||||
out += suffix_len;
|
||||
}
|
||||
*out++ = '\'';
|
||||
memcpy(out, suffix, suffix_len + 1);
|
||||
out += suffix_len;
|
||||
for (int i = 0; i < remote_option_count; i++) {
|
||||
const char* opt = remote_options[i];
|
||||
*out++ = ' ';
|
||||
|
||||
@@ -6,10 +6,13 @@
|
||||
Client* client_connect_ssh(const char* destination, int port, const char* server_path,
|
||||
bool old_args, const char* rsh_command, bool blocking_io,
|
||||
char* const* remote_options, int remote_option_count);
|
||||
/* Build the escaped remote-shell command string (the server program path quoted
|
||||
* as one remote-shell word unless --old-args, followed by ` --stdio` and each
|
||||
/* Build the escaped remote-shell command string (the server program path always
|
||||
* quoted as one remote-shell word, followed by ` --stdio` and each
|
||||
* --remote-option value appended as an individually single-quoted shell word).
|
||||
* Every --remote-option value is individually escaped with the '\'' sequence and
|
||||
* `old_args` is accepted for CLI/ABI compatibility but no longer disables
|
||||
* quoting: the path is always escaped so a metacharacter-bearing
|
||||
* --rsync-path can never be interpreted by the remote shell. Every
|
||||
* --remote-option value is individually escaped with the '\'' sequence and
|
||||
* values with empty/control characters are rejected at the CLI parse layer. */
|
||||
char* ssh_build_remote_command(const char* server_path, bool old_args, char* const* remote_options,
|
||||
int remote_option_count);
|
||||
|
||||
@@ -48,6 +48,15 @@ static SSL_CTX* create_ssl_ctx(bool is_server, const char* cert, const char* key
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* Harden the context: never negotiate TLS compression (the CRIME attack
|
||||
* vector) and never honour a post-handshake renegotiation request.
|
||||
* SSL_OP_NO_RENEGOTIATION is only available from OpenSSL 1.1.1, so it is
|
||||
* guarded to keep older headers building. */
|
||||
SSL_CTX_set_options(ctx, SSL_OP_NO_COMPRESSION);
|
||||
#ifdef SSL_OP_NO_RENEGOTIATION
|
||||
SSL_CTX_set_options(ctx, SSL_OP_NO_RENEGOTIATION);
|
||||
#endif
|
||||
|
||||
if (SSL_CTX_set_min_proto_version(ctx, TLS1_2_VERSION) != 1) {
|
||||
SSL_CTX_free(ctx);
|
||||
return NULL;
|
||||
|
||||
@@ -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
|
||||
+7
-2
@@ -340,7 +340,12 @@ void fake_super_store_fd(int fd, uint32_t uid, uint32_t gid, uint32_t mode, int6
|
||||
* mirroring the normal metadata identity path; other errors are logged) and
|
||||
* still applies mode/mtime where permitted.
|
||||
*
|
||||
* The OWNER leg additionally honors two policies:
|
||||
* The OWNER leg additionally honors three policies:
|
||||
* - an explicit ownership identity policy must be active (numeric-ids /
|
||||
* chown / usermap / groupmap / copy-as). --fake-super on its own only
|
||||
* RECORDS the source owner; replaying that owner as a live chown without an
|
||||
* explicit ownership opt-in would be an un-gated client-chosen-ownership
|
||||
* primitive.
|
||||
* - --no-super (privilege_super_permitted() false) suppresses it even for a
|
||||
* root receiver, exactly like the normal metadata identity path.
|
||||
* - an active --copy-as is AUTHORITATIVE: the identity path already forced the
|
||||
@@ -370,7 +375,7 @@ bool fake_super_restore_fd(int fd) {
|
||||
not hidden. --no-super suppresses the owner leg even for root, and an
|
||||
active --copy-as is authoritative so its forced owner must not be
|
||||
overwritten by the recorded source owner. */
|
||||
if (privilege_super_permitted() && !identity_copy_as_active() &&
|
||||
if (identity_active_enabled() && privilege_super_permitted() && !identity_copy_as_active() &&
|
||||
fchown(fd, (uid_t)ul_uid, (gid_t)ul_gid) != 0 && errno != EPERM && errno != EACCES)
|
||||
log_message(LOG_LEVEL_WARNING, "--fake-super: could not restore owner on destination file: %s",
|
||||
strerror(errno));
|
||||
|
||||
+7
-5
@@ -89,11 +89,13 @@ void fake_super_store_fd(int fd, uint32_t uid, uint32_t gid, uint32_t mode, int6
|
||||
/* --fake-super replay: parse the FAKESUPER_XATTR record previously written on
|
||||
* `fd` by fake_super_store_fd and re-apply uid/gid/mode/mtime fd-relative.
|
||||
* Best-effort: absence of the xattr or a malformed record is a silent no-op
|
||||
* that never fails the transfer; fchown is applied only when permitted (a
|
||||
* non-root EPERM/EACCES is skipped silently, matching FastSync's identity
|
||||
* philosophy), and the mode is sanitized exactly like the normal metadata path
|
||||
* (group/other write bits never granted). Returns true when the xattr was
|
||||
* present and parsed. */
|
||||
* that never fails the transfer. The OWNER leg is applied only when an explicit
|
||||
* ownership identity policy is active (numeric-ids/chown/usermap/groupmap/
|
||||
* copy-as), when super-user activities are permitted, and when --copy-as is not
|
||||
* authoritative; a non-root EPERM/EACCES is skipped silently, matching
|
||||
* FastSync's identity philosophy. The mode is sanitized exactly like the normal
|
||||
* metadata path (group/other write bits never granted). Returns true when the
|
||||
* xattr was present and parsed. */
|
||||
bool fake_super_restore_fd(int fd);
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,346 @@
|
||||
/*
|
||||
* Fuzz the binary config-frame receive path: Config* config_receive(int fd).
|
||||
*
|
||||
* The frame is a length-prefixed stream of strings/ints/bools, so the receiver
|
||||
* stops at the first malformed field. Feeding raw fuzz bytes alone therefore
|
||||
* almost never reaches the deep P8 trailing blocks (--super / --copy-as) or the
|
||||
* identity-map block, because every preceding wire bool must be exactly 0 or 1.
|
||||
*
|
||||
* To exercise those paths we first build one canonical, fully-valid frame with
|
||||
* the production sender and then feed the receiver four shapes:
|
||||
*
|
||||
* 1. raw : the raw fuzz bytes as the whole frame (version gate included).
|
||||
* 2. general : the valid version-string prefix + the raw fuzz bytes, so the
|
||||
* fuzzer can walk the early/core/selection blocks from arbitrary
|
||||
* input while staying past the version gate.
|
||||
* 3. tail : the valid frame up to its last P8_TAIL_BYTES (super_mode +
|
||||
* copy-as presence/uid/gid) + the raw fuzz bytes, so the fuzzer
|
||||
* directly mutates super_mode and the copy-as ids and truncates
|
||||
* the tail at any byte.
|
||||
* 4. map : the valid frame up to the --usermap count + the raw fuzz bytes,
|
||||
* so the fuzzer directly drives the map count (huge/extreme) and
|
||||
* the map entries.
|
||||
*
|
||||
* The canonical frame is captured by running config_send once, writing the
|
||||
* frame into a pipe whose read end is drained afterwards; the STATUS_OK ack is
|
||||
* 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>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/socket.h>
|
||||
#include <unistd.h>
|
||||
|
||||
/* super_mode (4) + copy-as presence (4) + uid (4) + gid (4) = the P8 tail. */
|
||||
#define P8_TAIL_BYTES 16
|
||||
|
||||
/* Distinctive --usermap entry used to locate the map-count field in the
|
||||
* canonical frame without duplicating the wire layout here. */
|
||||
#define MAP_FROM 0x11223344
|
||||
#define MAP_TO 0x55667788
|
||||
|
||||
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
|
||||
* write half first, so a blocking read drains the frame and then sees EOF. */
|
||||
static unsigned char* drain_frame(int fd, size_t* out_len) {
|
||||
size_t cap = 4096;
|
||||
size_t len = 0;
|
||||
unsigned char* buf = malloc(cap);
|
||||
if (!buf)
|
||||
return NULL;
|
||||
for (;;) {
|
||||
if (len == cap) {
|
||||
size_t grown = cap * 2;
|
||||
unsigned char* bigger = realloc(buf, grown);
|
||||
if (!bigger) {
|
||||
free(buf);
|
||||
return NULL;
|
||||
}
|
||||
buf = bigger;
|
||||
cap = grown;
|
||||
}
|
||||
ssize_t n = read(fd, buf + len, cap - len);
|
||||
if (n > 0) {
|
||||
len += (size_t)n;
|
||||
continue;
|
||||
}
|
||||
if (n < 0 && errno == EINTR)
|
||||
continue;
|
||||
break; /* 0 (EOF) or error */
|
||||
}
|
||||
*out_len = len;
|
||||
return buf;
|
||||
}
|
||||
|
||||
/* Serialize a valid Config with the real sender. The frame is written into a
|
||||
* pipe (64 KiB kernel buffer, far larger than one config frame) whose read end
|
||||
* is drained afterwards; the STATUS_OK ack is pre-loaded into a second pipe so
|
||||
* a single thread suffices (config_send writes the whole frame before it reads
|
||||
* the ack). */
|
||||
static void build_canonical_frame(void) {
|
||||
g_frame_ready = true;
|
||||
|
||||
Config* cfg = config_create();
|
||||
if (!cfg)
|
||||
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;
|
||||
cfg->copy_as_gid = 0;
|
||||
cfg->use_metadata = true;
|
||||
/* Force one usermap entry with a locatable sentinel. */
|
||||
cfg->usermap = malloc(sizeof(IdentityMap));
|
||||
if (cfg->usermap) {
|
||||
cfg->usermap_count = 1;
|
||||
cfg->usermap[0].from = MAP_FROM;
|
||||
cfg->usermap[0].to = MAP_TO;
|
||||
}
|
||||
if (!cfg->send_directory || !cfg->receive_root_directory || !cfg->usermap) {
|
||||
config_delete(cfg);
|
||||
return;
|
||||
}
|
||||
|
||||
int frame_pipe[2] = {-1, -1};
|
||||
int status_pipe[2] = {-1, -1};
|
||||
if (pipe(frame_pipe) != 0 || pipe(status_pipe) != 0)
|
||||
goto out;
|
||||
|
||||
int ack = STATUS_OK;
|
||||
if (write(status_pipe[1], &ack, sizeof(ack)) != (ssize_t)sizeof(ack))
|
||||
goto out;
|
||||
|
||||
io_set_fds(status_pipe[0], frame_pipe[1]);
|
||||
io_set_bwlimit(0);
|
||||
bool sent = config_send(frame_pipe[1], cfg);
|
||||
close(frame_pipe[1]);
|
||||
frame_pipe[1] = -1;
|
||||
close(status_pipe[0]);
|
||||
status_pipe[0] = -1;
|
||||
close(status_pipe[1]);
|
||||
status_pipe[1] = -1;
|
||||
|
||||
if (sent)
|
||||
g_frame = drain_frame(frame_pipe[0], &g_frame_len);
|
||||
|
||||
out:
|
||||
if (frame_pipe[0] != -1)
|
||||
close(frame_pipe[0]);
|
||||
if (frame_pipe[1] != -1)
|
||||
close(frame_pipe[1]);
|
||||
if (status_pipe[0] != -1)
|
||||
close(status_pipe[0]);
|
||||
if (status_pipe[1] != -1)
|
||||
close(status_pipe[1]);
|
||||
config_delete(cfg);
|
||||
if (!g_frame || g_frame_len == 0) {
|
||||
free(g_frame);
|
||||
g_frame = NULL;
|
||||
g_frame_len = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
g_version_len = sizeof(size_t) + strlen(PROTOCOL_VERSION);
|
||||
if (g_version_len > g_frame_len)
|
||||
g_version_len = g_frame_len;
|
||||
|
||||
/* Locate the usermap entry sentinel; its count int sits 4 bytes before it. */
|
||||
int32_t from = MAP_FROM;
|
||||
int32_t to = MAP_TO;
|
||||
unsigned char pattern[8];
|
||||
memcpy(pattern, &from, sizeof(from));
|
||||
memcpy(pattern + sizeof(from), &to, sizeof(to));
|
||||
if (g_frame_len >= sizeof(pattern)) {
|
||||
for (size_t i = 4; i + sizeof(pattern) <= g_frame_len; i++) {
|
||||
if (memcmp(g_frame + i, pattern, sizeof(pattern)) == 0) {
|
||||
g_usermap_count_off = i - sizeof(int32_t);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 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
|
||||
* than stalling the harness. */
|
||||
static void write_best_effort(int fd, const void* data, size_t size) {
|
||||
const unsigned char* p = data;
|
||||
size_t off = 0;
|
||||
while (off < size) {
|
||||
ssize_t n = write(fd, p + off, size - off);
|
||||
if (n > 0) {
|
||||
off += (size_t)n;
|
||||
continue;
|
||||
}
|
||||
if (n < 0 && errno == EINTR)
|
||||
continue;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/* Build prefix ++ data as a stream and drive config_receive over it. */
|
||||
static void receive_stream(const unsigned char* prefix, size_t prefix_len, const uint8_t* data,
|
||||
size_t size) {
|
||||
int sv[2];
|
||||
if (socketpair(AF_UNIX, SOCK_STREAM, 0, sv) != 0)
|
||||
return;
|
||||
|
||||
int flags = fcntl(sv[0], F_GETFL, 0);
|
||||
if (flags != -1)
|
||||
(void)fcntl(sv[0], F_SETFL, flags | O_NONBLOCK);
|
||||
|
||||
if (prefix_len > 0)
|
||||
write_best_effort(sv[0], prefix, prefix_len);
|
||||
if (size > 0)
|
||||
write_best_effort(sv[0], data, size);
|
||||
/* Signal EOF without closing the read half, so the receiver's STATUS_ERROR
|
||||
* replies do not hit EPIPE. */
|
||||
shutdown(sv[0], SHUT_WR);
|
||||
|
||||
io_set_fds(sv[1], sv[1]);
|
||||
io_set_bwlimit(0);
|
||||
Config* cfg = config_receive(sv[1]);
|
||||
config_delete(cfg);
|
||||
|
||||
close(sv[0]);
|
||||
close(sv[1]);
|
||||
}
|
||||
|
||||
int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) {
|
||||
fuzz_credentials(data, size);
|
||||
|
||||
if (!g_frame_ready)
|
||||
build_canonical_frame();
|
||||
|
||||
/* Raw bytes as the whole frame (version gate and all). */
|
||||
receive_stream(NULL, 0, data, size);
|
||||
|
||||
if (g_frame) {
|
||||
/* 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);
|
||||
|
||||
/* Keep the valid frame up to the usermap count, fuzz the count + entries. */
|
||||
if (g_usermap_count_off > 0)
|
||||
receive_stream(g_frame, g_usermap_count_off, data, size);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* Fuzz the CLI-time identity parsers (identity.h):
|
||||
* - identity_parse_copy_as
|
||||
* - identity_parse_map (user and group variants)
|
||||
* - identity_parse_chown
|
||||
*
|
||||
* Each parser mutates a Config, so every input gets a fresh config_create()
|
||||
* (freed afterwards). After a successful parse the shared wire validator and
|
||||
* the ownership predicate are also exercised on the mutated config. The input
|
||||
* is NUL-terminated; embedded NULs simply shorten the effective spec, which is
|
||||
* fine for a parser fuzzer.
|
||||
*/
|
||||
#include "config.h"
|
||||
#include "identity.h"
|
||||
#include <stdint.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
static void exercise(Config* c, const char* spec, int which) {
|
||||
if (!c)
|
||||
return;
|
||||
switch (which) {
|
||||
case 0:
|
||||
(void)identity_parse_copy_as(c, spec);
|
||||
break;
|
||||
case 1:
|
||||
(void)identity_parse_map(c, spec, false);
|
||||
break;
|
||||
case 2:
|
||||
(void)identity_parse_map(c, spec, true);
|
||||
break;
|
||||
default:
|
||||
(void)identity_parse_chown(c, spec);
|
||||
break;
|
||||
}
|
||||
(void)identity_wire_valid(c);
|
||||
(void)identity_ownership_requested(c);
|
||||
config_delete(c);
|
||||
}
|
||||
|
||||
int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) {
|
||||
if (size == 0)
|
||||
return 0;
|
||||
|
||||
char* spec = malloc(size + 1);
|
||||
if (!spec)
|
||||
return 0;
|
||||
memcpy(spec, data, size);
|
||||
spec[size] = '\0';
|
||||
|
||||
exercise(config_create(), spec, 0);
|
||||
exercise(config_create(), spec, 1);
|
||||
exercise(config_create(), spec, 2);
|
||||
exercise(config_create(), spec, 3);
|
||||
|
||||
free(spec);
|
||||
return 0;
|
||||
}
|
||||
@@ -5,19 +5,28 @@ 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
|
||||
import time
|
||||
|
||||
import pytest
|
||||
@@ -41,6 +50,7 @@ FILES_MODULE = os.path.join(MODULE_ROOT, "files")
|
||||
READONLY_MODULE = os.path.join(MODULE_ROOT, "readonly")
|
||||
AUTH_MODULE = os.path.join(MODULE_ROOT, "auth")
|
||||
TEAM_MODULE = os.path.join(MODULE_ROOT, "team")
|
||||
OWNER_MODULE = os.path.join(MODULE_ROOT, "owner")
|
||||
CONF_FILE = os.path.join(TEST_DATA_DIR, "fastsyncd.conf")
|
||||
CRED_FILE = os.path.join(TEST_DATA_DIR, "fastsyncd.passwd")
|
||||
STARTFAIL_CONF = os.path.join(TEST_DATA_DIR, "fastsyncd_startfail.conf")
|
||||
@@ -55,14 +65,45 @@ 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):
|
||||
with open(path, "w") as f:
|
||||
f.write("%s:%s\n" % (user, password))
|
||||
os.chmod(path, 0o600)
|
||||
return path
|
||||
|
||||
|
||||
@@ -149,17 +190,19 @@ def _config_port(config_path):
|
||||
|
||||
@pytest.fixture(scope="module", autouse=True)
|
||||
def daemon_env():
|
||||
for d in (MODULE_ROOT, FILES_MODULE, READONLY_MODULE, AUTH_MODULE, TEAM_MODULE, DETACH_MODULE):
|
||||
for d in (MODULE_ROOT, FILES_MODULE, READONLY_MODULE, AUTH_MODULE, TEAM_MODULE, OWNER_MODULE,
|
||||
DETACH_MODULE):
|
||||
shutil.rmtree(d, ignore_errors=True)
|
||||
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
|
||||
# boots on it (the config-port path) and the --dparam override test boots a
|
||||
@@ -184,7 +227,11 @@ def daemon_env():
|
||||
"[team]\n"
|
||||
"path = %s\n"
|
||||
"auth users = alice,bob\n"
|
||||
% (config_port, FILES_MODULE, READONLY_MODULE, AUTH_MODULE, TEAM_MODULE))
|
||||
"\n"
|
||||
"[owner]\n"
|
||||
"path = %s\n"
|
||||
"client owner = yes\n"
|
||||
% (config_port, FILES_MODULE, READONLY_MODULE, AUTH_MODULE, TEAM_MODULE, OWNER_MODULE))
|
||||
|
||||
# A dedicated config for the fail-closed startup check: an auth-required
|
||||
# module with no credential store must refuse to start. Its own free port
|
||||
@@ -238,6 +285,21 @@ def _tree_file_count(root):
|
||||
return sum(len(files) for _, _, files in os.walk(root)) if os.path.exists(root) else 0
|
||||
|
||||
|
||||
def _can_mknod():
|
||||
"""True when this process may create a char device (needs root/CAP_MKNOD)."""
|
||||
probe = os.path.join(tempfile.gettempdir(), "._fastsync_mknod_probe_%d" % os.getpid())
|
||||
try:
|
||||
os.mknod(probe, stat.S_IFCHR | 0o600, os.makedev(1, 3))
|
||||
os.unlink(probe)
|
||||
return True
|
||||
except (OSError, AttributeError):
|
||||
try:
|
||||
os.unlink(probe)
|
||||
except OSError:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
class TestDaemonModuleSelection:
|
||||
@pytest.mark.ci
|
||||
def test_module_transfer(self, daemon):
|
||||
@@ -320,48 +382,124 @@ class TestDaemonRejection:
|
||||
assert result.returncode != 0
|
||||
assert _tree_file_count(AUTH_MODULE) == 0
|
||||
|
||||
def test_copy_as_refused_by_daemon(self, daemon):
|
||||
"""P7 Wave E: a daemon refuses client-chosen ownership (--copy-as)
|
||||
outright. There is no per-module opt-in, so even a root daemon must not
|
||||
honor an arbitrary client-selected owner. The refusal happens at the
|
||||
config handshake, before any data lands."""
|
||||
def _assert_ownership_refused(self, daemon, module, flags,
|
||||
accept=("client-chosen ownership",)):
|
||||
"""A daemon module without `client owner = yes` refuses every
|
||||
client-chosen ownership / super-user request at the config handshake,
|
||||
before any data lands. `accept` lists the log phrases that count as the
|
||||
refusal (a non-root daemon refuses --copy-as earlier, at the privilege
|
||||
check, so the caller accepts that phrase too)."""
|
||||
log_path = os.path.join(TEST_DATA_DIR, "fastsyncd.log")
|
||||
before = os.path.getsize(log_path) if os.path.exists(log_path) else 0
|
||||
before_files = self._tree_files()
|
||||
result, _ = run_client(SOURCE_DIR, "127.0.0.1::files", port=daemon.port,
|
||||
flags=["--copy-as=@65534:@65534"])
|
||||
assert result.returncode != 0, "the daemon must refuse --copy-as"
|
||||
result, _ = run_client(SOURCE_DIR, f"127.0.0.1::{module}", port=daemon.port, flags=flags)
|
||||
assert result.returncode != 0, f"the daemon must refuse {flags}"
|
||||
assert self._tree_files() == before_files, \
|
||||
"--copy-as refusal wrote under the module root"
|
||||
f"{flags} refusal wrote under the module root"
|
||||
time.sleep(0.3)
|
||||
with open(log_path, "rb") as f:
|
||||
f.seek(before)
|
||||
tail = f.read().decode("utf-8", "replace")
|
||||
assert "copy-as is refused by the daemon" in tail, (
|
||||
f"daemon did not log the copy-as refusal: {tail[-400:]!r}"
|
||||
assert any(phrase in tail for phrase in accept), (
|
||||
f"daemon did not log the ownership refusal: {tail[-400:]!r}"
|
||||
)
|
||||
|
||||
def test_copy_as_refused_by_daemon(self, daemon):
|
||||
"""P7 Wave E hardening: a daemon refuses client-chosen ownership
|
||||
(--copy-as) outright unless the module opts in with `client owner = yes`,
|
||||
so even a root daemon must not honor an arbitrary client-selected owner
|
||||
by default. The refusal happens at the config handshake, before any data
|
||||
lands."""
|
||||
self._assert_ownership_refused(
|
||||
daemon, "files", ["--copy-as=@65534:@65534"],
|
||||
accept=("client-chosen ownership", "requires a privileged receiver"))
|
||||
|
||||
def test_super_refused_by_daemon(self, daemon):
|
||||
"""P7 Wave E: --super (SUPER_MODE_ON) implies raw numeric-id ownership
|
||||
with no explicit identity flag, so a daemon refuses it for the same
|
||||
reason it refuses --copy-as: there is no per-module opt-in for
|
||||
client-chosen ownership. The refusal happens at the config handshake,
|
||||
before any data lands."""
|
||||
"""An explicit --super is a super-user activity request, so a daemon
|
||||
module refuses it unless it opts in with `client owner = yes`. The
|
||||
refusal happens at the config handshake, before any data lands."""
|
||||
self._assert_ownership_refused(daemon, "files", ["--super", "--preserve"])
|
||||
|
||||
def test_super_refused_by_no_super_daemon(self):
|
||||
"""A daemon started with the operator --no-super veto must still REFUSE
|
||||
an explicit client --super on a non-opted module: the veto must not turn
|
||||
the refusal into a silent accept."""
|
||||
port = _find_free_port()
|
||||
d = DaemonManager()
|
||||
log_path = os.path.join(TEST_DATA_DIR, "fastsyncd.log")
|
||||
before = os.path.getsize(log_path) if os.path.exists(log_path) else 0
|
||||
before_files = self._tree_files()
|
||||
result, _ = run_client(SOURCE_DIR, "127.0.0.1::files", port=daemon.port,
|
||||
flags=["--super", "--preserve"])
|
||||
assert result.returncode != 0, "the daemon must refuse --super"
|
||||
assert self._tree_files() == before_files, \
|
||||
"--super refusal wrote under the module root"
|
||||
time.sleep(0.3)
|
||||
with open(log_path, "rb") as f:
|
||||
f.seek(before)
|
||||
tail = f.read().decode("utf-8", "replace")
|
||||
assert "super is refused by the daemon" in tail, (
|
||||
f"daemon did not log the --super refusal: {tail[-400:]!r}"
|
||||
)
|
||||
try:
|
||||
d.start(CONF_FILE, port_override=port,
|
||||
extra_args=["--password-file", CRED_FILE, "--no-super"])
|
||||
result, _ = run_client(SOURCE_DIR, "127.0.0.1::files", port=d.port,
|
||||
flags=["--super", "--preserve"])
|
||||
assert result.returncode != 0, "the --no-super daemon must refuse --super"
|
||||
with open(log_path, "rb") as f:
|
||||
tail = f.read().decode("utf-8", "replace")
|
||||
assert "client-chosen ownership" in tail, (
|
||||
f"daemon did not log the --super refusal: {tail[-400:]!r}"
|
||||
)
|
||||
finally:
|
||||
d.stop()
|
||||
|
||||
def test_numeric_ids_refused_by_daemon(self, daemon):
|
||||
"""P7 Wave E hardening (A1): the daemon ownership gate must cover the
|
||||
pre-existing identity flags too, not only --copy-as/--super. A module
|
||||
without `client owner = yes` refuses --numeric-ids at the handshake."""
|
||||
self._assert_ownership_refused(daemon, "files", ["--numeric-ids", "--preserve"])
|
||||
|
||||
def test_chown_refused_by_daemon(self, daemon):
|
||||
"""--chown is client-chosen ownership too and must be refused by a
|
||||
non-opted-in module."""
|
||||
self._assert_ownership_refused(daemon, "files", ["--chown=@65534:@65534", "--preserve"])
|
||||
|
||||
def test_owner_opt_in_allows_numeric_ids(self, daemon):
|
||||
"""A module that opts in with `client owner = yes` accepts the
|
||||
client-chosen ownership flags (here --numeric-ids); the transfer
|
||||
succeeds and lands inside that module root."""
|
||||
result, _ = run_client(SOURCE_DIR, "127.0.0.1::owner", port=daemon.port,
|
||||
flags=["--numeric-ids", "--preserve"])
|
||||
assert result.returncode == 0, result.stderr or result.stdout
|
||||
received = get_dest_received_dir(OWNER_MODULE, SOURCE_DIR)
|
||||
mismatches, missing = verify_transfer(SOURCE_DIR, received)
|
||||
assert not missing, f"missing: {missing[:5]}"
|
||||
assert not mismatches, f"mismatch: {mismatches[:5]}"
|
||||
|
||||
def _device_source(self, name):
|
||||
src = os.path.join(TEST_DATA_DIR, name)
|
||||
shutil.rmtree(src, ignore_errors=True)
|
||||
os.makedirs(src)
|
||||
with open(os.path.join(src, "f.txt"), "wb") as fh:
|
||||
fh.write(b"device gate\n")
|
||||
os.mknod(os.path.join(src, "null"), stat.S_IFCHR | 0o666, os.makedev(1, 3))
|
||||
return src
|
||||
|
||||
@pytest.mark.skipif(not _can_mknod(), reason="device nodes need root/CAP_MKNOD")
|
||||
def test_devices_skipped_without_owner_opt_in(self, daemon):
|
||||
"""H3: a non-opted daemon module must not create device nodes even under
|
||||
the default AUTO super mode (a root daemon would otherwise let any client
|
||||
mknod arbitrary devices). An ordinary -a push still succeeds; the device
|
||||
entry is skipped."""
|
||||
src = self._device_source("devsrc_noowner")
|
||||
os.makedirs(os.path.join(FILES_MODULE, "devskip"), exist_ok=True)
|
||||
result, _ = run_client(src, "127.0.0.1::files/devskip", port=daemon.port, flags=["-a"])
|
||||
assert result.returncode == 0, result.stderr or result.stdout
|
||||
received = get_dest_received_dir(os.path.join(FILES_MODULE, "devskip"), src)
|
||||
node = os.path.join(received, "null")
|
||||
assert not os.path.exists(node) or not stat.S_ISCHR(os.stat(node).st_mode), \
|
||||
"non-opted daemon module created a device node"
|
||||
|
||||
@pytest.mark.skipif(not _can_mknod(), reason="device nodes need root/CAP_MKNOD")
|
||||
def test_devices_created_with_owner_opt_in(self, daemon):
|
||||
"""Control: an opted-in module (`client owner = yes`) may create device
|
||||
nodes under -a, proving the clamp is specific to non-opted modules."""
|
||||
src = self._device_source("devsrc_owner")
|
||||
os.makedirs(os.path.join(OWNER_MODULE, "devok"), exist_ok=True)
|
||||
result, _ = run_client(src, "127.0.0.1::owner/devok", port=daemon.port, flags=["-a"])
|
||||
assert result.returncode == 0, result.stderr or result.stdout
|
||||
received = get_dest_received_dir(os.path.join(OWNER_MODULE, "devok"), src)
|
||||
node = os.path.join(received, "null")
|
||||
assert os.path.exists(node) and stat.S_ISCHR(os.stat(node).st_mode), \
|
||||
"opted-in daemon module did not create the device node"
|
||||
|
||||
@pytest.mark.daemon_detach
|
||||
def test_real_detach_path(self):
|
||||
@@ -417,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
|
||||
@@ -499,11 +770,67 @@ 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")
|
||||
with open(cred_path, "w") as f:
|
||||
f.write("# nothing here\n")
|
||||
os.chmod(cred_path, 0o600)
|
||||
try:
|
||||
cmd = CLIENT_CMD + ["--source-dir", SOURCE_DIR,
|
||||
"--dest-dir", "127.0.0.1::files",
|
||||
@@ -538,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)
|
||||
@@ -550,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:
|
||||
@@ -578,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:
|
||||
@@ -677,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)
|
||||
@@ -707,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,
|
||||
}
|
||||
|
||||
|
||||
@@ -753,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)
|
||||
@@ -4165,11 +4165,11 @@ class TestSuperPrivilege:
|
||||
f"--no-super must not apply ownership (uid={st.st_uid} gid={st.st_gid})"
|
||||
|
||||
@pytest.mark.skipif(os.geteuid() != 0, reason="only root can change ownership")
|
||||
def test_super_applies_ownership_as_root(self, shared_server):
|
||||
"""Control/proof the flag is not inert for root: --super with no explicit
|
||||
identity policy treats ownership as raw numeric ids (as --numeric-ids),
|
||||
applying the very ownership --no-super suppressed."""
|
||||
source, dest = self._seed("super")
|
||||
def test_super_alone_does_not_apply_ownership_as_root(self, shared_server):
|
||||
"""A3: --super no longer implies --numeric-ids, so --super alone must NOT
|
||||
apply client-chosen ownership even for root; the destination keeps the
|
||||
receiver's owner (the exact ownership --no-super would also suppress)."""
|
||||
source, dest = self._seed("superonly")
|
||||
os.chown(os.path.join(source, "f.txt"), 12345, 12346)
|
||||
result, _ = run_client(source, dest,
|
||||
flags=["--preserve", "--super"],
|
||||
@@ -4178,8 +4178,25 @@ class TestSuperPrivilege:
|
||||
f"exit {result.returncode}: {(result.stderr or '')[:300]}"
|
||||
received = get_dest_received_dir(dest, source)
|
||||
st = os.stat(os.path.join(received, "f.txt"))
|
||||
assert (st.st_uid, st.st_gid) != (12345, 12346), \
|
||||
f"--super alone must not apply ownership (uid={st.st_uid} gid={st.st_gid})"
|
||||
|
||||
@pytest.mark.skipif(os.geteuid() != 0, reason="only root can change ownership")
|
||||
def test_super_with_numeric_ids_applies_ownership_as_root(self, shared_server):
|
||||
"""Control: an explicit identity policy is what enables ownership, so
|
||||
--numeric-ids --super still applies the raw ids as root (the very
|
||||
ownership --no-super suppresses)."""
|
||||
source, dest = self._seed("supernumeric")
|
||||
os.chown(os.path.join(source, "f.txt"), 12345, 12346)
|
||||
result, _ = run_client(source, dest,
|
||||
flags=["--preserve", "--numeric-ids", "--super"],
|
||||
port=shared_server.port)
|
||||
assert result.returncode == 0, \
|
||||
f"exit {result.returncode}: {(result.stderr or '')[:300]}"
|
||||
received = get_dest_received_dir(dest, source)
|
||||
st = os.stat(os.path.join(received, "f.txt"))
|
||||
assert (st.st_uid, st.st_gid) == (12345, 12346), \
|
||||
f"--super should apply raw ids: uid={st.st_uid} gid={st.st_gid}"
|
||||
f"--numeric-ids --super should apply raw ids: uid={st.st_uid} gid={st.st_gid}"
|
||||
|
||||
@pytest.mark.ci
|
||||
@pytest.mark.skipif(os.geteuid() != 0, reason="only root can change ownership")
|
||||
|
||||
@@ -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"
|
||||
|
||||
+67
-13
@@ -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;
|
||||
@@ -104,18 +140,18 @@ static void test_cli_help() {
|
||||
config_delete(cfg);
|
||||
}
|
||||
|
||||
/* Test that --archive's config bundle matches rsync -rlptgoD semantics:
|
||||
* links + metadata + devices + specials, and NOT compression/multithreading. */
|
||||
/* Test that the -a short spelling applies --archive's config bundle, matching
|
||||
* rsync -rlptgoD semantics: links + metadata + devices + specials, and NOT
|
||||
* compression/multithreading. (--archive itself is covered by
|
||||
* test_parse_args_archive; this guards the short alias.) */
|
||||
static void test_cli_archive_flags() {
|
||||
Config* cfg = config_create();
|
||||
EXPECT_NOT_NULL(cfg);
|
||||
char* argv[] = {"fastsync", "-a", "/src", "/dst"};
|
||||
int positional_args[2];
|
||||
int positional_count = 0;
|
||||
|
||||
/* Simulate the --archive flag's implied bundle. */
|
||||
cfg->follow_symlinks = true;
|
||||
cfg->use_metadata = true;
|
||||
cfg->preserve_devices = true;
|
||||
cfg->preserve_specials = true;
|
||||
|
||||
EXPECT_EQ_INT(parse_args(cfg, 4, argv, positional_args, &positional_count), 0);
|
||||
EXPECT_TRUE(cfg->follow_symlinks);
|
||||
EXPECT_TRUE(cfg->use_metadata);
|
||||
EXPECT_TRUE(cfg->preserve_devices);
|
||||
@@ -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);
|
||||
@@ -3045,13 +3081,30 @@ static void test_parse_args_block_size() {
|
||||
EXPECT_EQ_INT(parse_args(cfg, 4, argv_eq, positional_args, &positional_count), 0);
|
||||
EXPECT_EQ_INT((int)cfg->delta_block_size, 8192);
|
||||
|
||||
/* Out of range: parsed, warned, and the default is kept. */
|
||||
cfg->delta_block_size = DELTA_BLOCK_SIZE_DEFAULT;
|
||||
char* argv_delta_eq[] = {"fastsync", "--delta-block=1024", "/src", "/dst"};
|
||||
positional_count = 0;
|
||||
EXPECT_EQ_INT(parse_args(cfg, 4, argv_delta_eq, positional_args, &positional_count), 0);
|
||||
EXPECT_EQ_INT((int)cfg->delta_block_size, 1024);
|
||||
|
||||
/* Out of range: parsed, warned, and the default is kept (both spellings). */
|
||||
cfg->delta_block_size = DELTA_BLOCK_SIZE_DEFAULT;
|
||||
char* argv_bad[] = {"fastsync", "--block-size", "1", "/src", "/dst"};
|
||||
positional_count = 0;
|
||||
EXPECT_EQ_INT(parse_args(cfg, 5, argv_bad, positional_args, &positional_count), 0);
|
||||
EXPECT_EQ_INT((int)cfg->delta_block_size, (int)DELTA_BLOCK_SIZE_DEFAULT);
|
||||
|
||||
cfg->delta_block_size = DELTA_BLOCK_SIZE_DEFAULT;
|
||||
char* argv_bad_inline[] = {"fastsync", "--delta-block=999999", "/src", "/dst"};
|
||||
positional_count = 0;
|
||||
EXPECT_EQ_INT(parse_args(cfg, 4, argv_bad_inline, positional_args, &positional_count), 0);
|
||||
EXPECT_EQ_INT((int)cfg->delta_block_size, (int)DELTA_BLOCK_SIZE_DEFAULT);
|
||||
|
||||
/* A non-numeric value is a hard error for both spellings. */
|
||||
char* argv_nan[] = {"fastsync", "--delta-block=abc", "/src", "/dst"};
|
||||
positional_count = 0;
|
||||
EXPECT_EQ_INT(parse_args(cfg, 4, argv_nan, positional_args, &positional_count), -1);
|
||||
|
||||
/* A non-default block size changes the number of signature blocks for
|
||||
identical data: block_count = ceil(size / block_size). */
|
||||
const char data[10000] = {0};
|
||||
@@ -3100,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();
|
||||
|
||||
+75
-14
@@ -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);
|
||||
@@ -1883,13 +1880,13 @@ static void test_privilege_super_permitted_modes() {
|
||||
Config* c = config_create();
|
||||
EXPECT_NOT_NULL(c);
|
||||
c->super_mode = SUPER_MODE_OFF;
|
||||
identity_set_active(c);
|
||||
EXPECT_TRUE(identity_set_active(c));
|
||||
EXPECT_FALSE(privilege_super_permitted());
|
||||
c->super_mode = SUPER_MODE_ON;
|
||||
identity_set_active(c);
|
||||
EXPECT_TRUE(identity_set_active(c));
|
||||
EXPECT_TRUE(privilege_super_permitted());
|
||||
c->super_mode = SUPER_MODE_AUTO;
|
||||
identity_set_active(c);
|
||||
EXPECT_TRUE(identity_set_active(c));
|
||||
EXPECT_TRUE(privilege_super_permitted());
|
||||
config_delete(c);
|
||||
|
||||
@@ -1899,6 +1896,68 @@ static void test_privilege_super_permitted_modes() {
|
||||
EXPECT_TRUE(privilege_super_permitted());
|
||||
}
|
||||
|
||||
/* P7 Wave E hardening (A1): identity_ownership_requested() is the pure,
|
||||
config-only predicate the daemon module gate uses. It must fire for every
|
||||
client-chosen ownership / super-user request and stay false for a plain
|
||||
transfer and for SUPER_MODE_AUTO (the default) alone. */
|
||||
static void test_identity_ownership_requested() {
|
||||
EXPECT_FALSE(identity_ownership_requested(NULL));
|
||||
|
||||
Config* c = config_create();
|
||||
EXPECT_NOT_NULL(c);
|
||||
EXPECT_FALSE(identity_ownership_requested(c));
|
||||
c->super_mode = SUPER_MODE_AUTO;
|
||||
EXPECT_FALSE(identity_ownership_requested(c)); /* AUTO alone is not ownership */
|
||||
c->super_mode = SUPER_MODE_ON;
|
||||
EXPECT_TRUE(identity_ownership_requested(c)); /* explicit --super is */
|
||||
c->super_mode = SUPER_MODE_AUTO;
|
||||
|
||||
c->numeric_ids = true;
|
||||
EXPECT_TRUE(identity_ownership_requested(c));
|
||||
c->numeric_ids = false;
|
||||
c->chown_uid_set = true;
|
||||
EXPECT_TRUE(identity_ownership_requested(c));
|
||||
c->chown_uid_set = false;
|
||||
c->chown_gid_set = true;
|
||||
EXPECT_TRUE(identity_ownership_requested(c));
|
||||
c->chown_gid_set = false;
|
||||
c->copy_as_set = true;
|
||||
EXPECT_TRUE(identity_ownership_requested(c));
|
||||
c->copy_as_set = false;
|
||||
c->fake_super = true;
|
||||
EXPECT_TRUE(identity_ownership_requested(c));
|
||||
config_delete(c);
|
||||
|
||||
Config* um = config_create();
|
||||
EXPECT_NOT_NULL(um);
|
||||
EXPECT_EQ_INT(identity_parse_map(um, "@1:@2", false), 0);
|
||||
EXPECT_TRUE(identity_ownership_requested(um));
|
||||
config_delete(um);
|
||||
|
||||
Config* gm = config_create();
|
||||
EXPECT_NOT_NULL(gm);
|
||||
EXPECT_EQ_INT(identity_parse_map(gm, "@1:@2", true), 0);
|
||||
EXPECT_TRUE(identity_ownership_requested(gm));
|
||||
config_delete(gm);
|
||||
}
|
||||
|
||||
/* P7 Wave E hardening (A3): --super no longer implies raw numeric-id
|
||||
preservation, so it must never enable ownership application on its own; an
|
||||
explicit identity flag is required. */
|
||||
static void test_super_does_not_imply_numeric() {
|
||||
Config* c = config_create();
|
||||
EXPECT_NOT_NULL(c);
|
||||
c->super_mode = SUPER_MODE_ON;
|
||||
c->use_metadata = true;
|
||||
EXPECT_TRUE(identity_set_active(c));
|
||||
EXPECT_FALSE(identity_active_enabled());
|
||||
c->numeric_ids = true;
|
||||
EXPECT_TRUE(identity_set_active(c));
|
||||
EXPECT_TRUE(identity_active_enabled());
|
||||
identity_clear_active();
|
||||
config_delete(c);
|
||||
}
|
||||
|
||||
void test_config() {
|
||||
test_config_lifecycle();
|
||||
test_config_ssh_dest();
|
||||
@@ -1953,6 +2012,8 @@ void test_config() {
|
||||
test_config_receive_with_validate_rejects();
|
||||
}
|
||||
test_identity_copy_as_refused();
|
||||
test_identity_ownership_requested();
|
||||
test_super_does_not_imply_numeric();
|
||||
test_privilege_super_permitted_modes();
|
||||
test_config_delete_timing_early_helper();
|
||||
test_config_is_remote_dest();
|
||||
|
||||
+840
-133
File diff suppressed because it is too large.
Load diff
@@ -43,6 +43,7 @@ static void test_daemon_conf_full_parse() {
|
||||
"[backup]\n"
|
||||
"path = /srv/backup\n"
|
||||
"read only = yes\n"
|
||||
"client owner = yes\n"
|
||||
"auth users = alice, bob\n",
|
||||
&path),
|
||||
0);
|
||||
@@ -57,6 +58,7 @@ static void test_daemon_conf_full_parse() {
|
||||
EXPECT_EQ_STR(conf->modules[0].name, "backup");
|
||||
EXPECT_EQ_STR(conf->modules[0].path, "/srv/backup");
|
||||
EXPECT_TRUE(conf->modules[0].read_only);
|
||||
EXPECT_TRUE(conf->modules[0].client_owner);
|
||||
EXPECT_EQ_INT(conf->modules[0].auth_user_count, 2);
|
||||
EXPECT_EQ_STR(conf->modules[0].auth_users[0], "alice");
|
||||
EXPECT_EQ_STR(conf->modules[0].auth_users[1], "bob");
|
||||
@@ -84,6 +86,10 @@ static void test_daemon_conf_comments_and_blank_lines() {
|
||||
EXPECT_EQ_INT(conf->module_count, 2);
|
||||
EXPECT_EQ_STR(conf->modules[0].name, "alpha");
|
||||
EXPECT_EQ_STR(conf->modules[1].name, "beta");
|
||||
/* `client owner` defaults to off: a module must opt in to client-chosen
|
||||
ownership. */
|
||||
EXPECT_FALSE(conf->modules[0].client_owner);
|
||||
EXPECT_FALSE(conf->modules[1].client_owner);
|
||||
daemon_conf_free(conf);
|
||||
}
|
||||
|
||||
@@ -207,6 +213,12 @@ static void test_daemon_conf_malformed_rejected() {
|
||||
EXPECT_NULL(conf);
|
||||
EXPECT_TRUE(strstr(err, "read only") != NULL);
|
||||
|
||||
EXPECT_EQ_INT(write_conf("[m]\npath = /x\nclient owner = maybe\n", &path), 0);
|
||||
conf = daemon_conf_load(path, err, sizeof(err));
|
||||
free(path);
|
||||
EXPECT_NULL(conf);
|
||||
EXPECT_TRUE(strstr(err, "client owner") != NULL);
|
||||
|
||||
EXPECT_EQ_INT(write_conf("= value\n", &path), 0);
|
||||
conf = daemon_conf_load(path, err, sizeof(err));
|
||||
free(path);
|
||||
|
||||
@@ -1370,6 +1370,126 @@ static void test_dir_time_list() {
|
||||
rmdir(root);
|
||||
}
|
||||
|
||||
/* -K/--keep-dirlinks secure open: with an authorized root, a destination path
|
||||
* component that is a symlink to an IN-ROOT directory is used as that directory
|
||||
* (its referent is opened through a relative O_NOFOLLOW walk from the root fd,
|
||||
* not by re-opening an absolute realpath() result), while a symlink resolving
|
||||
* OUTSIDE the root is rejected. With -K off, even the in-root link is not
|
||||
* followed. */
|
||||
static void test_keep_dirlinks_secure_open_impl() {
|
||||
const char* root = "test_keep_dirlinks_root";
|
||||
const char* real = "test_keep_dirlinks_root/realdir";
|
||||
const char* link = "test_keep_dirlinks_root/linkdir";
|
||||
const char* abslink = "test_keep_dirlinks_root/abslink";
|
||||
const char* escape = "test_keep_dirlinks_root/escape";
|
||||
const char* outside = "test_keep_dirlinks_outside";
|
||||
unlink(link);
|
||||
unlink(abslink);
|
||||
unlink(escape);
|
||||
rmdir(real);
|
||||
rmdir(root);
|
||||
rmdir(outside);
|
||||
EXPECT_EQ_INT(mkdir(root, 0755), 0);
|
||||
EXPECT_EQ_INT(mkdir(real, 0755), 0);
|
||||
EXPECT_EQ_INT(mkdir(outside, 0755), 0);
|
||||
|
||||
char root_abs[PATH_MAX];
|
||||
char real_abs[PATH_MAX];
|
||||
char outside_abs[PATH_MAX];
|
||||
EXPECT_NOT_NULL(realpath(root, root_abs));
|
||||
EXPECT_NOT_NULL(realpath(real, real_abs));
|
||||
EXPECT_NOT_NULL(realpath(outside, outside_abs));
|
||||
EXPECT_EQ_INT(symlink("realdir", link), 0); /* relative, in-root */
|
||||
EXPECT_EQ_INT(symlink(real_abs, abslink), 0); /* absolute, in-root */
|
||||
/* cppcheck-suppress knownConditionTrueFalse */
|
||||
EXPECT_EQ_INT(symlink(outside_abs, escape), 0); /* absolute, outside root */
|
||||
|
||||
int root_fd = open(root_abs, O_RDONLY | O_DIRECTORY | O_CLOEXEC);
|
||||
EXPECT_TRUE(root_fd >= 0);
|
||||
// cppcheck-suppress knownConditionTrueFalse
|
||||
if (root_fd < 0) {
|
||||
unlink(link);
|
||||
unlink(abslink);
|
||||
unlink(escape);
|
||||
rmdir(real);
|
||||
rmdir(root);
|
||||
rmdir(outside);
|
||||
return;
|
||||
}
|
||||
EXPECT_TRUE(file_set_authorized_root(root_fd, root_abs));
|
||||
file_set_keep_dirlinks(true);
|
||||
|
||||
struct stat real_st;
|
||||
EXPECT_EQ_INT(fstatat(root_fd, "realdir", &real_st, 0), 0);
|
||||
|
||||
/* Relative in-root symlink-to-directory: followed to the referent dir. */
|
||||
char path[PATH_MAX + 64];
|
||||
snprintf(path, sizeof(path), "%s/linkdir/file.txt", root_abs);
|
||||
char* leaf = NULL;
|
||||
int parent_fd = file_open_secure_parent(path, &leaf, false);
|
||||
EXPECT_TRUE(parent_fd >= 0);
|
||||
EXPECT_NOT_NULL(leaf);
|
||||
// cppcheck-suppress knownConditionTrueFalse
|
||||
if (leaf)
|
||||
EXPECT_EQ_STR(leaf, "file.txt");
|
||||
// cppcheck-suppress knownConditionTrueFalse
|
||||
if (parent_fd >= 0) {
|
||||
struct stat st;
|
||||
EXPECT_EQ_INT(fstat(parent_fd, &st), 0);
|
||||
EXPECT_TRUE(st.st_dev == real_st.st_dev && st.st_ino == real_st.st_ino);
|
||||
close(parent_fd);
|
||||
}
|
||||
free(leaf);
|
||||
|
||||
/* Absolute-but-in-root symlink-to-directory is followed the same way. */
|
||||
snprintf(path, sizeof(path), "%s/abslink/file.txt", root_abs);
|
||||
leaf = NULL;
|
||||
parent_fd = file_open_secure_parent(path, &leaf, false);
|
||||
EXPECT_TRUE(parent_fd >= 0);
|
||||
// cppcheck-suppress knownConditionTrueFalse
|
||||
if (parent_fd >= 0) {
|
||||
struct stat st;
|
||||
EXPECT_EQ_INT(fstat(parent_fd, &st), 0);
|
||||
EXPECT_TRUE(st.st_dev == real_st.st_dev && st.st_ino == real_st.st_ino);
|
||||
close(parent_fd);
|
||||
}
|
||||
free(leaf);
|
||||
|
||||
/* A symlink resolving outside the authorized root is rejected. */
|
||||
snprintf(path, sizeof(path), "%s/escape/file.txt", root_abs);
|
||||
leaf = NULL;
|
||||
EXPECT_EQ_INT(file_open_secure_parent(path, &leaf, false), -1);
|
||||
free(leaf);
|
||||
|
||||
/* With -K off the in-root symlink is not followed either. */
|
||||
file_set_keep_dirlinks(false);
|
||||
snprintf(path, sizeof(path), "%s/linkdir/file.txt", root_abs);
|
||||
leaf = NULL;
|
||||
EXPECT_EQ_INT(file_open_secure_parent(path, &leaf, false), -1);
|
||||
free(leaf);
|
||||
|
||||
file_set_keep_dirlinks(false);
|
||||
file_set_authorized_root(-1, NULL);
|
||||
close(root_fd);
|
||||
unlink(link);
|
||||
unlink(abslink);
|
||||
unlink(escape);
|
||||
rmdir(real);
|
||||
rmdir(root);
|
||||
rmdir(outside);
|
||||
}
|
||||
|
||||
/* Wrapper guarantees the process-wide keep-dirlinks/authorized-root policy is
|
||||
* cleared even when an EXPECT inside the body returns early (a failing EXPECT
|
||||
* returns from its own function, so the body's trailing resets may be skipped). */
|
||||
static void test_keep_dirlinks_secure_open() {
|
||||
file_set_authorized_root(-1, NULL);
|
||||
file_set_keep_dirlinks(false);
|
||||
test_keep_dirlinks_secure_open_impl();
|
||||
file_set_authorized_root(-1, NULL);
|
||||
file_set_keep_dirlinks(false);
|
||||
}
|
||||
|
||||
void test_file() {
|
||||
test_file_create();
|
||||
test_file_special_rdev_valid();
|
||||
@@ -1411,6 +1531,7 @@ void test_file() {
|
||||
}
|
||||
test_file_metadata_create();
|
||||
test_dir_time_list();
|
||||
test_keep_dirlinks_secure_open();
|
||||
test_inplace_overwrite_clears_special_mode_bits();
|
||||
test_inplace_overwrite_metadata_strips_special_bits();
|
||||
test_inplace_overwrite_truncates_shorter_payload();
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
#include "test_fuzz_smoke.h"
|
||||
#include "chunk.h"
|
||||
#include "compression.h"
|
||||
#include "config.h"
|
||||
#include "data.h"
|
||||
#include "delta.h"
|
||||
#include "metadata.h"
|
||||
#include "protocol.h"
|
||||
#include "test_utils.h"
|
||||
#include "utils.h"
|
||||
#include <errno.h>
|
||||
#include <limits.h>
|
||||
#include <stdint.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/socket.h>
|
||||
#include <unistd.h>
|
||||
|
||||
/* P8 config-frame tail: super_mode (4) + copy-as presence (4) + uid (4) + gid (4). */
|
||||
#define P8_TAIL_BYTES 16
|
||||
|
||||
/* Smoke test for chunk_deserialize fuzz target */
|
||||
static void test_fuzz_chunk_deserialize() {
|
||||
/* Create a minimal valid chunk to serialize and deserialize */
|
||||
@@ -170,6 +178,272 @@ static void test_fuzz_glob_match() {
|
||||
EXPECT_FALSE(glob_match("*.md", "readme.txt"));
|
||||
}
|
||||
|
||||
/* ---- Deterministic config-frame receive hardening (P8) ----
|
||||
*
|
||||
* The P8 tail (--super / --copy-as) and the identity-map count only parse after
|
||||
* the entire preceding frame validates, which random bytes almost never reach.
|
||||
* These tests capture one valid frame with the production sender and then
|
||||
* mutate/truncate the exact tail bytes. */
|
||||
|
||||
/* Serialize cfg with the production sender into a heap buffer. The frame is
|
||||
* written into a pipe (64 KiB kernel buffer, far larger than one config frame)
|
||||
* whose read end is drained afterwards; the required STATUS_OK ack is
|
||||
* pre-loaded into a second pipe, so a single thread suffices. */
|
||||
static bool capture_config_frame(const Config* cfg, unsigned char** out, size_t* out_len) {
|
||||
*out = NULL;
|
||||
*out_len = 0;
|
||||
|
||||
int frame_pipe[2];
|
||||
int status_pipe[2];
|
||||
if (pipe(frame_pipe) != 0)
|
||||
return false;
|
||||
if (pipe(status_pipe) != 0) {
|
||||
close(frame_pipe[0]);
|
||||
close(frame_pipe[1]);
|
||||
return false;
|
||||
}
|
||||
|
||||
int ack = STATUS_OK;
|
||||
bool ok = write(status_pipe[1], &ack, sizeof(ack)) == (ssize_t)sizeof(ack);
|
||||
if (ok) {
|
||||
io_set_fds(status_pipe[0], frame_pipe[1]);
|
||||
io_set_bwlimit(0);
|
||||
ok = config_send(frame_pipe[1], cfg);
|
||||
}
|
||||
close(frame_pipe[1]);
|
||||
close(status_pipe[0]);
|
||||
close(status_pipe[1]);
|
||||
|
||||
unsigned char* buf = NULL;
|
||||
if (ok) {
|
||||
size_t cap = 4096;
|
||||
size_t len = 0;
|
||||
buf = malloc(cap);
|
||||
if (!buf) {
|
||||
ok = false;
|
||||
}
|
||||
while (ok) {
|
||||
if (len == cap) {
|
||||
size_t grown = cap * 2;
|
||||
unsigned char* bigger = realloc(buf, grown);
|
||||
if (!bigger) {
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
buf = bigger;
|
||||
cap = grown;
|
||||
}
|
||||
ssize_t n = read(frame_pipe[0], buf + len, cap - len);
|
||||
if (n > 0) {
|
||||
len += (size_t)n;
|
||||
continue;
|
||||
}
|
||||
if (n < 0 && errno == EINTR)
|
||||
continue;
|
||||
break;
|
||||
}
|
||||
if (ok && len > 0) {
|
||||
*out = buf;
|
||||
*out_len = len;
|
||||
buf = NULL;
|
||||
}
|
||||
}
|
||||
close(frame_pipe[0]);
|
||||
free(buf);
|
||||
return *out != NULL;
|
||||
}
|
||||
|
||||
/* Feed a raw config frame to config_receive over a socketpair. The write half
|
||||
* is shut down (not closed) after the data so the receiver sees EOF but its
|
||||
* STATUS_ERROR replies do not hit a closed peer. */
|
||||
static bool receive_config_frame(const unsigned char* buf, size_t len) {
|
||||
int sv[2];
|
||||
if (socketpair(AF_UNIX, SOCK_STREAM, 0, sv) != 0)
|
||||
return false;
|
||||
|
||||
size_t off = 0;
|
||||
while (off < len) {
|
||||
ssize_t n = write(sv[0], buf + off, len - off);
|
||||
if (n > 0) {
|
||||
off += (size_t)n;
|
||||
continue;
|
||||
}
|
||||
if (n < 0 && errno == EINTR)
|
||||
continue;
|
||||
break;
|
||||
}
|
||||
shutdown(sv[0], SHUT_WR);
|
||||
io_set_fds(sv[1], sv[1]);
|
||||
io_set_bwlimit(0);
|
||||
Config* cfg = config_receive(sv[1]);
|
||||
bool accepted = cfg != NULL;
|
||||
config_delete(cfg);
|
||||
close(sv[0]);
|
||||
close(sv[1]);
|
||||
return accepted;
|
||||
}
|
||||
|
||||
static void put_i32(unsigned char* buf, size_t off, int32_t value) {
|
||||
memcpy(buf + off, &value, sizeof(value));
|
||||
}
|
||||
|
||||
static size_t find_bytes(const unsigned char* haystack, size_t haystack_len,
|
||||
const unsigned char* needle, size_t needle_len) {
|
||||
if (needle_len == 0 || haystack_len < needle_len)
|
||||
return SIZE_MAX;
|
||||
for (size_t i = 0; i + needle_len <= haystack_len; i++) {
|
||||
if (memcmp(haystack + i, needle, needle_len) == 0)
|
||||
return i;
|
||||
}
|
||||
return SIZE_MAX;
|
||||
}
|
||||
|
||||
static Config* make_copy_as_config(void) {
|
||||
Config* c = config_create();
|
||||
if (!c)
|
||||
return NULL;
|
||||
c->send_directory = str_dup("/src");
|
||||
c->receive_root_directory = str_dup("/dst");
|
||||
c->copy_as_set = true;
|
||||
c->copy_as_uid = 0;
|
||||
c->copy_as_gid = 0;
|
||||
c->use_metadata = true; /* --copy-as requires the metadata path */
|
||||
return c;
|
||||
}
|
||||
|
||||
/* The P8 tail must reject an out-of-range super_mode, a negative copy-as id and
|
||||
* any truncation inside the tail, while the untouched frame is accepted. */
|
||||
static void test_fuzz_config_receive_p8_tail() {
|
||||
Config* c = make_copy_as_config();
|
||||
EXPECT_NOT_NULL(c);
|
||||
|
||||
unsigned char* frame = NULL;
|
||||
size_t len = 0;
|
||||
bool captured = capture_config_frame(c, &frame, &len);
|
||||
config_delete(c);
|
||||
if (!captured || len <= P8_TAIL_BYTES) {
|
||||
free(frame);
|
||||
EXPECT_TRUE(false);
|
||||
return;
|
||||
}
|
||||
|
||||
/* Baseline: the untouched frame is accepted. */
|
||||
EXPECT_TRUE(receive_config_frame(frame, len));
|
||||
|
||||
unsigned char* mut = malloc(len);
|
||||
EXPECT_NOT_NULL(mut);
|
||||
|
||||
/* super_mode outside the 0..2 tri-state is refused. */
|
||||
memcpy(mut, frame, len);
|
||||
put_i32(mut, len - P8_TAIL_BYTES, 99);
|
||||
EXPECT_FALSE(receive_config_frame(mut, len));
|
||||
put_i32(mut, len - P8_TAIL_BYTES, -1);
|
||||
EXPECT_FALSE(receive_config_frame(mut, len));
|
||||
|
||||
/* A negative (sentinel) and an extreme copy-as uid/gid are refused. */
|
||||
memcpy(mut, frame, len);
|
||||
put_i32(mut, len - P8_TAIL_BYTES, SUPER_MODE_AUTO);
|
||||
put_i32(mut, len - P8_TAIL_BYTES + 4, 1);
|
||||
put_i32(mut, len - P8_TAIL_BYTES + 8, -1);
|
||||
put_i32(mut, len - P8_TAIL_BYTES + 12, 0);
|
||||
EXPECT_FALSE(receive_config_frame(mut, len));
|
||||
put_i32(mut, len - P8_TAIL_BYTES + 8, 0);
|
||||
put_i32(mut, len - P8_TAIL_BYTES + 12, INT32_MIN);
|
||||
EXPECT_FALSE(receive_config_frame(mut, len));
|
||||
|
||||
/* A presence int that is not a wire bool is refused. */
|
||||
memcpy(mut, frame, len);
|
||||
put_i32(mut, len - P8_TAIL_BYTES, SUPER_MODE_AUTO);
|
||||
put_i32(mut, len - P8_TAIL_BYTES + 4, 2);
|
||||
EXPECT_FALSE(receive_config_frame(mut, len));
|
||||
|
||||
/* Truncating anywhere inside the P8 tail is refused. */
|
||||
EXPECT_FALSE(receive_config_frame(frame, len - 2));
|
||||
EXPECT_FALSE(receive_config_frame(frame, len - P8_TAIL_BYTES));
|
||||
|
||||
free(mut);
|
||||
free(frame);
|
||||
}
|
||||
|
||||
/* A huge or negative --usermap count must be refused up front, never driving a
|
||||
* giant allocation. The count is located by searching for a sentinel entry. */
|
||||
static void test_fuzz_config_receive_huge_map_count() {
|
||||
Config* c = make_copy_as_config();
|
||||
EXPECT_NOT_NULL(c);
|
||||
int32_t sentinel_from = 0x11223344;
|
||||
int32_t sentinel_to = 0x55667788;
|
||||
c->usermap = malloc(sizeof(IdentityMap));
|
||||
if (!c->usermap) {
|
||||
config_delete(c);
|
||||
EXPECT_TRUE(false);
|
||||
return;
|
||||
}
|
||||
c->usermap_count = 1;
|
||||
c->usermap[0].from = sentinel_from;
|
||||
c->usermap[0].to = sentinel_to;
|
||||
|
||||
unsigned char* frame = NULL;
|
||||
size_t len = 0;
|
||||
bool captured = capture_config_frame(c, &frame, &len);
|
||||
config_delete(c);
|
||||
if (!captured) {
|
||||
EXPECT_TRUE(false);
|
||||
return;
|
||||
}
|
||||
|
||||
unsigned char pattern[8];
|
||||
memcpy(pattern, &sentinel_from, sizeof(sentinel_from));
|
||||
memcpy(pattern + sizeof(sentinel_from), &sentinel_to, sizeof(sentinel_to));
|
||||
size_t entry_off = find_bytes(frame, len, pattern, sizeof(pattern));
|
||||
if (entry_off == SIZE_MAX || entry_off < sizeof(int32_t)) {
|
||||
free(frame);
|
||||
EXPECT_TRUE(false);
|
||||
return;
|
||||
}
|
||||
size_t count_off = entry_off - sizeof(int32_t);
|
||||
|
||||
/* Baseline accepted. */
|
||||
EXPECT_TRUE(receive_config_frame(frame, len));
|
||||
|
||||
unsigned char* mut = malloc(len);
|
||||
EXPECT_NOT_NULL(mut);
|
||||
memcpy(mut, frame, len);
|
||||
put_i32(mut, count_off, INT_MAX);
|
||||
EXPECT_FALSE(receive_config_frame(mut, len));
|
||||
put_i32(mut, count_off, -1);
|
||||
EXPECT_FALSE(receive_config_frame(mut, len));
|
||||
put_i32(mut, count_off, MAX_IDENTITY_MAP + 1);
|
||||
EXPECT_FALSE(receive_config_frame(mut, len));
|
||||
|
||||
free(mut);
|
||||
free(frame);
|
||||
}
|
||||
|
||||
/* A mismatched version and a matching version followed by a wrong-order field
|
||||
* (an int that is not a wire bool) are both refused at/just after the gate. */
|
||||
static void test_fuzz_config_receive_version_gate() {
|
||||
unsigned char buf[64];
|
||||
|
||||
size_t off = 0;
|
||||
const char* bad_version = "1.2.3";
|
||||
size_t bad_len = strlen(bad_version);
|
||||
memcpy(buf + off, &bad_len, sizeof(bad_len));
|
||||
off += sizeof(bad_len);
|
||||
memcpy(buf + off, bad_version, bad_len);
|
||||
off += bad_len;
|
||||
EXPECT_FALSE(receive_config_frame(buf, off));
|
||||
|
||||
off = 0;
|
||||
size_t good_len = strlen(PROTOCOL_VERSION);
|
||||
memcpy(buf + off, &good_len, sizeof(good_len));
|
||||
off += sizeof(good_len);
|
||||
memcpy(buf + off, PROTOCOL_VERSION, good_len);
|
||||
off += good_len;
|
||||
put_i32(buf, off, -1);
|
||||
off += sizeof(int32_t);
|
||||
EXPECT_FALSE(receive_config_frame(buf, off));
|
||||
}
|
||||
|
||||
void test_fuzz_smoke() {
|
||||
test_fuzz_chunk_deserialize();
|
||||
test_fuzz_compress_decompress();
|
||||
@@ -177,4 +451,7 @@ void test_fuzz_smoke() {
|
||||
test_fuzz_metadata_from_buf();
|
||||
test_fuzz_delta_signature_deserialize();
|
||||
test_fuzz_glob_match();
|
||||
test_fuzz_config_receive_p8_tail();
|
||||
test_fuzz_config_receive_huge_map_count();
|
||||
test_fuzz_config_receive_version_gate();
|
||||
}
|
||||
@@ -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,6 +2,7 @@
|
||||
#include "config.h"
|
||||
#include "delta.h"
|
||||
#include "file.h"
|
||||
#include "log.h"
|
||||
#include "protocol.h"
|
||||
#include "test_utils.h"
|
||||
#include "utils.h"
|
||||
@@ -724,7 +725,44 @@ static void test_receiver_pending_commits_missing_args() {
|
||||
free(root);
|
||||
}
|
||||
|
||||
/* A6: an attacker-controlled file path appearing in a log line must be escaped
|
||||
so a control byte cannot forge a second log record. The socket special-node
|
||||
branch logs file->path before touching the filesystem, making it a cheap way
|
||||
to exercise an escaped site. The captured line must contain the escaped path
|
||||
(`\#012` for the newline), never the raw control byte. */
|
||||
static void test_special_socket_path_log_escaped() {
|
||||
set_log_level(LOG_LEVEL_WARNING);
|
||||
log_set_8_bit_output(false);
|
||||
|
||||
FILE* capture = tmpfile();
|
||||
EXPECT_NOT_NULL(capture);
|
||||
log_set_file(capture);
|
||||
|
||||
File* file = file_create("evil\npath");
|
||||
EXPECT_NOT_NULL(file);
|
||||
file->is_special = true;
|
||||
file->metadata = calloc(1, sizeof(FileMetadata));
|
||||
EXPECT_NOT_NULL(file->metadata);
|
||||
file->metadata->mode = S_IFSOCK | 0644;
|
||||
|
||||
FileSaveResult result = file_save_to_disk_full("/tmp/dst", file, NULL);
|
||||
EXPECT_EQ_INT(result, FILE_SAVE_SKIPPED);
|
||||
|
||||
fflush(capture);
|
||||
rewind(capture);
|
||||
char output[512] = {0};
|
||||
size_t length = fread(output, 1, sizeof(output) - 1, capture);
|
||||
output[length] = '\0';
|
||||
|
||||
log_set_file(NULL);
|
||||
fclose(capture);
|
||||
file_destroy(file);
|
||||
|
||||
EXPECT_NOT_NULL(strstr(output, "socket not recreated: evil\\#012path"));
|
||||
}
|
||||
|
||||
void test_server() {
|
||||
test_special_socket_path_log_escaped();
|
||||
if (!is_running_under_valgrind()) {
|
||||
test_receive_files_finished();
|
||||
test_receive_files_single_file();
|
||||
|
||||
+13
-6
@@ -164,19 +164,26 @@ static void test_server_cli_invalid() {
|
||||
}
|
||||
|
||||
static void test_server_cli_password_and_early_input() {
|
||||
const char* args[] = {"s", "--daemon", "--password-file=/etc/fast.pw", "--early-input",
|
||||
"/run/secrets"};
|
||||
const char* args[] = {"s",
|
||||
"--daemon",
|
||||
"--password-file=/etc/fast.pw",
|
||||
"--early-input",
|
||||
"/run/secrets",
|
||||
"--iconv=utf-8"};
|
||||
ServerCliOptions opts;
|
||||
EXPECT_EQ_INT(parse_ok(args, 5, &opts), 0);
|
||||
EXPECT_EQ_INT(parse_ok(args, 6, &opts), 0);
|
||||
EXPECT_EQ_STR(opts.password_file, "/etc/fast.pw");
|
||||
EXPECT_EQ_STR(opts.early_input_file, "/run/secrets");
|
||||
EXPECT_EQ_STR(opts.iconv_spec, "utf-8");
|
||||
|
||||
const char* args2[] = {"s", "--daemon", "--password-file", "/etc/fast.pw",
|
||||
"--early-input=/secrets"};
|
||||
const char* args2[] = {
|
||||
"s", "--daemon", "--password-file", "/etc/fast.pw", "--early-input=/secrets",
|
||||
"--iconv", "utf-8,iso-8859-1"};
|
||||
ServerCliOptions opts2;
|
||||
EXPECT_EQ_INT(parse_ok(args2, 5, &opts2), 0);
|
||||
EXPECT_EQ_INT(parse_ok(args2, 7, &opts2), 0);
|
||||
EXPECT_EQ_STR(opts2.password_file, "/etc/fast.pw");
|
||||
EXPECT_EQ_STR(opts2.early_input_file, "/secrets");
|
||||
EXPECT_EQ_STR(opts2.iconv_spec, "utf-8,iso-8859-1");
|
||||
server_cli_options_free(&opts);
|
||||
server_cli_options_free(&opts2);
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -55,8 +55,14 @@ static void test_ssh_remote_command_argument_modes() {
|
||||
EXPECT_EQ_STR(command, "'fast'\\''sync' --stdio");
|
||||
free(command);
|
||||
|
||||
/* --old-args no longer disables injection-safe quoting: the path is still one
|
||||
single-quoted word, even when it carries shell metacharacters. */
|
||||
command = ssh_build_remote_command("fast sync; touch /tmp/pwned", true, NULL, 0);
|
||||
EXPECT_EQ_STR(command, "fast sync; touch /tmp/pwned --stdio");
|
||||
EXPECT_EQ_STR(command, "'fast sync; touch /tmp/pwned' --stdio");
|
||||
free(command);
|
||||
|
||||
command = ssh_build_remote_command("fast'sync; rm -rf /", true, NULL, 0);
|
||||
EXPECT_EQ_STR(command, "'fast'\\''sync; rm -rf /' --stdio");
|
||||
free(command);
|
||||
}
|
||||
|
||||
@@ -131,9 +137,9 @@ static void test_ssh_remote_command_with_remote_options() {
|
||||
free(command);
|
||||
free(val);
|
||||
|
||||
/* --old-args leaves the server path unquoted but still quotes remote options. */
|
||||
/* --old-args still quotes both the server path and the remote options. */
|
||||
command = ssh_build_remote_command("srv", true, multi, 2);
|
||||
EXPECT_EQ_STR(command, "srv --stdio '-v' '--allow-delete'");
|
||||
EXPECT_EQ_STR(command, "'srv' --stdio '-v' '--allow-delete'");
|
||||
free(command);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#include "test_utils.h"
|
||||
#include "transport_tcp.h"
|
||||
#include "transport_tls.h"
|
||||
#include <openssl/ssl.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
|
||||
@@ -17,6 +18,12 @@ static void test_server_create_tls_without_certs() {
|
||||
bool ok = server_create_tls(s, NULL, NULL, NULL);
|
||||
EXPECT_TRUE(ok);
|
||||
EXPECT_NOT_NULL(s->ssl_ctx);
|
||||
/* The context must disable TLS compression (CRIME) and renegotiation. */
|
||||
SSL_CTX* ctx = (SSL_CTX*)s->ssl_ctx;
|
||||
EXPECT_TRUE((SSL_CTX_get_options(ctx) & SSL_OP_NO_COMPRESSION) != 0);
|
||||
#ifdef SSL_OP_NO_RENEGOTIATION
|
||||
EXPECT_TRUE((SSL_CTX_get_options(ctx) & SSL_OP_NO_RENEGOTIATION) != 0);
|
||||
#endif
|
||||
server_delete(&s);
|
||||
EXPECT_NULL(s);
|
||||
}
|
||||
|
||||
+19
-6
@@ -305,31 +305,44 @@ static void test_fake_super_owner_gate() {
|
||||
Config* c = config_create();
|
||||
EXPECT_NOT_NULL(c);
|
||||
|
||||
/* An explicit ownership policy is required before fake-super replay may
|
||||
chown; --fake-super alone only records the source owner (A2). */
|
||||
c->numeric_ids = true;
|
||||
|
||||
/* --no-super: the owner leg is skipped even as root. */
|
||||
c->super_mode = SUPER_MODE_OFF;
|
||||
identity_set_active(c);
|
||||
EXPECT_TRUE(identity_set_active(c));
|
||||
EXPECT_TRUE(fake_super_restore_fd(fd));
|
||||
struct stat st;
|
||||
EXPECT_EQ_INT(fstat(fd, &st), 0);
|
||||
EXPECT_EQ_INT((int)st.st_uid, 0);
|
||||
EXPECT_EQ_INT((int)st.st_gid, 0);
|
||||
|
||||
/* AUTO: the recorded source owner is applied. */
|
||||
/* AUTO with an identity policy: the recorded source owner is applied. */
|
||||
c->super_mode = SUPER_MODE_AUTO;
|
||||
identity_set_active(c);
|
||||
EXPECT_TRUE(identity_set_active(c));
|
||||
EXPECT_TRUE(fake_super_restore_fd(fd));
|
||||
EXPECT_EQ_INT(fstat(fd, &st), 0);
|
||||
EXPECT_EQ_INT((int)st.st_uid, 12345);
|
||||
EXPECT_EQ_INT((int)st.st_gid, 12346);
|
||||
|
||||
/* --super / --fake-super with NO explicit identity flag must NOT apply a
|
||||
client-chosen owner: super_mode alone never enables ownership. */
|
||||
EXPECT_EQ_INT(fchown(fd, 0, 0), 0);
|
||||
c->numeric_ids = false;
|
||||
c->super_mode = SUPER_MODE_ON;
|
||||
EXPECT_TRUE(identity_set_active(c));
|
||||
EXPECT_TRUE(fake_super_restore_fd(fd));
|
||||
EXPECT_EQ_INT(fstat(fd, &st), 0);
|
||||
EXPECT_EQ_INT((int)st.st_uid, 0);
|
||||
EXPECT_EQ_INT((int)st.st_gid, 0);
|
||||
|
||||
/* Active --copy-as is authoritative: the recorded source owner must not
|
||||
override it, even with AUTO/ON. */
|
||||
EXPECT_EQ_INT(fchown(fd, 0, 0), 0);
|
||||
c->super_mode = SUPER_MODE_ON;
|
||||
c->copy_as_set = true;
|
||||
c->copy_as_uid = 777;
|
||||
c->copy_as_gid = 778;
|
||||
identity_set_active(c);
|
||||
EXPECT_TRUE(identity_set_active(c));
|
||||
EXPECT_TRUE(fake_super_restore_fd(fd));
|
||||
EXPECT_EQ_INT(fstat(fd, &st), 0);
|
||||
EXPECT_EQ_INT((int)st.st_uid, 0);
|
||||
|
||||
Reference in new issue
Block a user