29 Commits
Author SHA1 Message Date
TapTap 378d881ca7 Merge release/v2.19.0 into main: FastSync v2.19.0
CI / lint (push) Successful in 1m33s
CI / sanitizers (undefined) (push) Successful in 59s
CI / sanitizers (address) (push) Successful in 1m4s
CI / fuzz-build (push) Successful in 28s
CI / coverage (push) Successful in 49s
CI / valgrind (push) Successful in 2m10s
CI / build-and-test (push) Successful in 4m29s
2026-09-12 20:22:50 +02:00
TapTap cc27ee1b83 Release v2.19.0
- Protocol version 2.19.0 (SCRAM-SHA-256 daemon auth replacing the replayable digest)
- Salted PBKDF2 verifier store + --hash-credentials; legacy store hard-rejected
- Persistent anti-enumeration dummy key (<store>.dummykey)
- Verified TLS / opted-in loopback transport required for auth modules
- Secret wiping; carried-over hardening from the security phases
- Add CHANGELOG.md and set the CMake project version
2026-09-12 20:22:46 +02:00
TapTap d653c3e151 Merge feat/tls-dummy-integration: verified/Local-only transport for daemon auth + persistent dummy key
CI / lint (push) Successful in 1m38s
CI / sanitizers (undefined) (push) Successful in 1m1s
CI / sanitizers (address) (push) Successful in 1m7s
CI / fuzz-build (push) Successful in 30s
CI / coverage (push) Successful in 49s
CI / valgrind (push) Successful in 2m14s
CI / build-and-test (push) Successful in 4m32s
2026-09-12 19:55:28 +02:00
TapTap 1b90ee2449 fix(review): close loopback TLS auth bypass; align docs and wrong-CN test
- server gate: the --allow-unauthenticated loopback allowance now requires
  an actual plaintext connection (!gate_ctx->ssl), so a loopback TLS client
  whose cert fails the --client-cn check is refused before any SCRAM
  challenge instead of falling through the plaintext opt-in.  Keep the
  invalid-fd guard as belt-and-braces (unreachable after the policy check).
- test: rewrote test_wrong_client_cn_refused_before_auth_challenge to run
  deterministically over 127.0.0.1 with --tls + --allow-unauthenticated and
  a CA-valid wrong-CN client cert, asserting the gate refusal log and an
  unchanged module tree (no skip).
- docs: --client-cn is mandatory with --tls; dummykey sidecar is secret
  material; document all transient-fallback reasons; qualify
  --allow-unauthenticated in README and --help so it cannot read as
  permitting remote plaintext auth.
- credentials.h: drop stale restrictive-umask claim (fchmod forces exact
  0600; only create/write/fsync/link/fchmod failure degrades to ephemeral).
2026-09-12 19:50:52 +02:00
TapTap 89b967f29a Merge feat/dummy-key: persist anti-enumeration dummy key across restarts
# Conflicts:
#	RSYNC_COMPAT.md
2026-09-12 19:30:35 +02:00
TapTap 8f06ae5262 Merge feat/tls-auth: require verified/local transport for daemon auth modules 2026-09-12 19:29:35 +02:00
TapTap ac3c4c7c72 fix(a7-auth): harden dummy-key temp creation
- Make the atomic-publish temp name unpredictable by appending 16 random
  hex chars to the pid, so a leftover/planted temp cannot be targeted.
- On EEXIST, unlink the stale temp and retry the O_EXCL create once
  (bounded), so a crash leftover or reused pid cannot silently defeat
  sidecar persistence.
- fchmod the temp fd to 0600 after creation (umask can clear owner bits)
  and treat failure as a create failure, so the published sidecar is
  always exactly 0600.
- Clarify comments: the sidecar requires exact 0600 while the store and
  password files only reject group/other bits.
- Add a unit test that a restrictive umask still yields an exact 0600
  sidecar; clean random-suffixed temps in tests.
2026-09-12 19:29:23 +02:00
TapTap d53614d06b fix(a7-3/s1): fail closed on non-loopback peers; require plaintext opt-in before challenge
utils_fd_peer_is_local now returns true only when getpeername SUCCEEDS and the
peer address classifies as loopback. A non-socket descriptor (pipe/socketpair)
or any getpeername error is NOT local, so the daemon auth gate fails closed
instead of treating an untestable --stdio pipe as trusted (daemon auth modules
are --daemon-only and the stdio path never loads a daemon config).

server_module_gate now requires --allow-unauthenticated for the loopback
plaintext auth path: a plaintext loopback connection without the operator
opt-in is refused at the config gate BEFORE server_auth_handshake, so no SCRAM
challenge is sent. Remote peers still require verified TLS regardless of the
flag; the handler keeps its defense-in-depth checks.

Docs state the exact policy (verified TLS with matching --client-cn, or
operator-opted-in loopback plaintext), drop the SSH/stdio auth-transport claim
(they are daemon-only), and add the loopback trust-boundary relay caveat and
the CN-only (no SAN) residual. Adds a unit-test negative for pipe/socketpair
and an integration test where a relay observes no challenge when the flag is
absent.
2026-09-12 19:18:29 +02:00
TapTap f0381a6b8e fix(a7-auth): publish dummy-key sidecar atomically and harden reads
Address review findings on the persistent dummy-key sidecar:

- Publish atomically: write a private same-directory temp file
  (<store>.dummykey.tmp.<pid>, 0600), fsync, then link(2) into place;
  fsync the containing directory and drop the temp name. A concurrent
  starter can no longer observe a zero/partial sidecar and fail closed.
  On EEXIST adopt the winner's sidecar; otherwise warn and use a
  transient ephemeral key.
- Harden the read path (initial and EEXIST-adopt) with
  O_RDONLY|O_NOFOLLOW|O_NONBLOCK|O_CLOEXEC: reject planted symlinks
  (ELOOP fails closed) and never block on a planted FIFO.
- Require the exact owner-only mode (st_mode & 07777) == 0600 and make
  the rejection message truthful.
- Report a clear "short write" instead of a stale strerror(errno) when
  write() returns 0.
- Document the artifact and its creation-failure caveat (FIFO store
  path, read-only filesystem, missing directory) in README.md and
  RSYNC_COMPAT.md.
- Tests: known-key sidecar adoption (dummy salt KAT + reload), symlink
  rejection, and the exact-0600 rule (0400 now rejected).
2026-09-12 19:16:11 +02:00
TapTap a7a1930e88 fix(a7-3/s1): require TLS or local transport for daemon auth
Daemon modules that declare 'auth users' no longer accept credentials over a
remote plaintext connection: server_module_gate refuses at the config gate,
before any SCRAM challenge is sent, unless the connection is verified TLS with
a client certificate matching --client-cn, or a local/SSH transport (loopback
TCP peer or the --stdio pipe). --allow-unauthenticated does not relax this.

The TLS client-CN comparison now uses credentials_secure_equal (S2). Clients
sending --password-file to a non-loopback daemon must use --tls; validate_config
rejects the plaintext case before any network I/O.

Adds utils_sockaddr_is_loopback / utils_fd_peer_is_local / utils_host_is_loopback
helpers with unit tests, a client validation unit test, and integration tests
for the client-side plaintext rejection and the wrong-CN gate refusal.
2026-09-12 19:02:05 +02:00
TapTap 42f01c0968 fix(a7-auth): persist dummy key in owner-only sidecar
The store-wide dummy key was regenerated on every credentials_load, so an
unknown user's dummy salt changed across daemon restarts while a real user's
stored salt stayed stable -- a restart-gated username-enumeration oracle.

Persist the 32-byte key in a 0600 <store>.dummykey sidecar next to the
credential store.  An absent sidecar is created with O_EXCL and fsynced; a
present sidecar is read only when it is an owner-only regular file of exactly
32 bytes (otherwise the load fails closed).  If the sidecar cannot be created
(read-only mount, missing directory) fall back to a transient per-run key with
a warning.  A NULL store path keeps the key ephemeral.
2026-09-12 18:40:21 +02:00
TapTap 2489d422e5 Merge feat/a7-integration: SCRAM-SHA-256 daemon auth + lazy protocol debug escaping
CI / lint (push) Successful in 1m33s
CI / sanitizers (undefined) (push) Successful in 59s
CI / sanitizers (address) (push) Successful in 1m4s
CI / fuzz-build (push) Successful in 29s
CI / coverage (push) Successful in 50s
CI / valgrind (push) Successful in 1m54s
CI / build-and-test (push) Successful in 4m45s
2026-09-12 18:21:13 +02:00
TapTap e2ddc0c07f Merge feat/hardening-misc: lazy protocol debug escaping, log_debug_enabled 2026-09-12 18:08:56 +02:00
TapTap 1480716304 Merge feat/a7-auth: SCRAM-SHA-256 daemon auth replacing replayable static digest 2026-09-12 18:08:56 +02:00
TapTap 1de1376e54 fix(a7-auth): final hardening pass on SCRAM auth
- burn the store-wide dummy_key in credentials_free()
- burn the local mac on hmac_sha256 failure in credentials_get_verifier()
- always run the O(store) constant-time scan, even for off-list users, to
  close the pre-existing off-list timing channel; select the real verifier
  only when on_list && match
- clarify the server_auth_handshake STATUS_AUTH_FAILED comment (failure
  before success vs. a dropped broken connection while writing the signature)
- document accepted anti-enumeration residuals (restart-gated dummy salt;
  pre-auth-observable iteration count)
2026-09-12 18:08:46 +02:00
TapTap eaf67f6257 fix(a7-auth): address SCRAM auth review findings A-G
- tests: pass CREDENTIAL_KEY_LEN to unhex for the 32-byte KAT proof/sig
  (sizeof(expect) is 348, over-reading the 65-byte hex literal under ASan)
- credentials: close the username-enumeration oracle with a store-wide
  dummy_key and a deterministic per-username dummy salt; make the store's
  iteration count uniform (reject intra-file and layered disagreements) and
  answer a miss with the store-wide count; run the constant-time key compare
  even when found=false and fold the decision with bitwise AND
- credentials_compute_keys: enforce [CREDENTIAL_MIN_ITERS, CREDENTIAL_MAX_ITERS]
- tests: recompute the whole KAT independently at CREDENTIAL_DEFAULT_ITERS
  (600000) and pin the golden store line; add non-uniform-store rejection,
  bound and deterministic-dummy-salt assertions
- server: send exactly one generic STATUS_AUTH_FAILED on every failure path
  (including credentials_get_verifier failure); route all handshake exits
  through one burn path
- credentials/server: burn the base64 decoders' scratch on error, the
  hash_store_line base64/line buffers on failure, and all handshake key/proof
  material
- fuzz: guard the auth-offset scan against size_t underflow and use a found flag
- docs: drop stale digest wording, use CREDENTIAL_MIN_ITERS as the --iterations
  bound, document 0600 output for --hash-credentials (plus a stderr warning on
  a group/other-accessible stdout file), and describe the deterministic dummy
  salt in the no-oracle claims
2026-09-12 17:56:52 +02:00
TapTap 8c94ec9886 feat(a7): SCRAM-SHA-256 daemon auth to replace replayable digest
Replace the challenge-less static-SHA-256 daemon bearer credential with a
SCRAM-SHA-256-style challenge/response and a salted PBKDF2 verifier store.
PROTOCOL_VERSION 2.18.0 -> 2.19.0; legacy user:SHA256HEX stores hard-reject.

- credentials: b64/rand/PBKDF2/HMAC primitives, verifier store parser,
  constant-time proof verify + ServerSignature, --hash-credentials helper
- config: auth block is now [present][username]; client runs the challenge
  exchange; config_burn_auth wipes plaintext/derived secrets (A7-4)
- server: gate drives the challenge, dummy verifier for unknown/off-list users
- tests: independent Python KAT, replay + legacy integration tests, fuzz paths
- docs: new store format, --hash-credentials, 2.19.0 bump

TLS verification behavior (A7-3/S1) is intentionally unchanged.
2026-09-12 17:19:33 +02:00
TapTap 87585e9881 perf(protocol): skip string debug escaping when proto debug is off
output_escape() was called on every send_str/receive_str even when
LOG_DEBUG_PROTO logging was disabled, allocating and scanning the whole
payload for a line that log_debug_message() then discarded.  Add a
log_debug_enabled(flag) gate mirroring log_debug_message()'s own filter and
check it before escaping.  Redacted (secret) strings still log the same
<redacted> marker; no observable log output changes.
2026-09-12 16:54:43 +02:00
TapTap 1ba6372017 Merge feat/p8-integration: P8 hardening batch (fs/transport, identity/server, CLI quality, fuzz)
CI / lint (push) Successful in 1m28s
CI / sanitizers (undefined) (push) Successful in 57s
CI / sanitizers (address) (push) Successful in 1m0s
CI / fuzz-build (push) Successful in 27s
CI / coverage (push) Successful in 47s
CI / valgrind (push) Successful in 40s
CI / build-and-test (push) Successful in 5m24s
2026-09-12 15:55:49 +02:00
TapTap 9f74b21c64 test(p8h): assert a --no-super daemon still refuses client --super 2026-09-12 15:55:44 +02:00
TapTap 108fee1e41 fix(p8h): restore daemon --super refusal, race-free secret-file check, remaining log escapes
- server_module_gate: refuse client-chosen ownership against the ORIGINAL config
  so an explicit --super is still refused under an operator --no-super veto
  (the veto must not turn a refusal into an accept).
- credentials: open-then-fstat the exact secret inode, require current-user
  ownership and no group/other bits, but continue to allow process-substitution
  FIFOs; removes the stat->fopen TOCTOU.
- file.c preallocate + protocol.c send-string debug logs escape attacker paths.
- usage/RSYNC_COMPAT updated for --old-args no-op and secret-file rules.
2026-09-12 15:50:14 +02:00
TapTap e1bb2e9233 chore(p8h): reconcile ssh old-args docs, secret-file perms docs; fix test cppcheck 2026-09-12 15:33:54 +02:00
TapTap 81fed86748 Merge branch 'feat/p8h-fuzz' into feat/p8-integration 2026-09-12 15:22:00 +02:00
TapTap 5844648fb2 Merge branch 'feat/p8h-cli' into feat/p8-integration 2026-09-12 15:22:00 +02:00
TapTap 4331bc4a8d Merge branch 'feat/p8h-core' into feat/p8-integration 2026-09-12 15:22:00 +02:00
TapTap d97e3982b4 test(fuzz): add config-frame receive and identity parser fuzz targets
Add two libFuzzer harnesses (GLOBbed from tests/fuzz/*.c) and deterministic
P8 config-frame receive tests:

- fuzz_config_receive.c drives config_receive() from arbitrary bytes. It
  captures one canonical valid frame with the production sender and feeds the
  receiver four shapes: raw bytes, valid-version-prefix + fuzz bytes, valid
  frame minus the P8 tail (super_mode + copy-as) + fuzz bytes, and valid frame
  minus the usermap count + fuzz bytes. This reaches the --super/--copy-as and
  huge/negative map-count paths that random bytes cannot get through the
  preceding wire-bool gate.
- fuzz_identity_parse.c fuzzes identity_parse_copy_as/map/chown plus the
  identity_wire_valid/identity_ownership_requested predicates on a fresh
  config per input.
- test_fuzz_smoke.c gains deterministic malformed-frame cases: out-of-range
  super_mode, negative/extreme copy-as ids, non-bool copy-as presence, tail
  truncation, huge/negative usermap counts, version mismatch and a
  wrong-order field after the version gate.

Unit build (STRICT_WARNINGS) and the fuzz build are clean; both targets run
3000+ iterations with no crash. No production code changed.
2026-09-12 15:21:33 +02:00
TapTap 6d32bc795b fix(p8h-core): escape log paths, fail closed on identity activation, tidy server gate
- A6: escape attacker-controlled file paths and the receive root in log
  lines (file_receive, server, protocol DEBUG) with output_escape()
- A8: identity_set_active() returns bool and fails closed when a requested
  usermap/groupmap cannot be deep-copied; handler refuses the connection
- remove the const cast and duplicate super_mode clamp from
  server_module_gate via an explicit override the handler applies once
- release the identity snapshot on the queue_create failure path
- refactor identity_parse_copy_as to a single cleanup tail and drop the
  duplicated group error format specifier
2026-09-12 15:03:54 +02:00
TapTap abad1664ba security(shared): fix -K TOCTOU, ssh old-args quoting, TLS opts, secret-file perms, sparse dedup
- file: open -K dirlink referents via a race-safe relative O_NOFOLLOW walk
  from the authorized-root fd instead of re-opening an absolute realpath()
  result (removes the intermediate-symlink swap TOCTOU).
- transport_ssh: always single-quote the server path, including --old-args,
  so no mode can inject shell metacharacters.
- transport_tls: set SSL_OP_NO_COMPRESSION and (guarded) SSL_OP_NO_RENEGOTIATION.
- credentials: reject --password-file/--early-input with any group/other
  permission bit; chmod 0600 the affected test fixtures.
- file_store: export file_store_write_sparse() and remove the verbatim
  file.c duplicate.
2026-09-12 15:01:48 +02:00
TapTap 7e45891257 refactor(cli): dedupe server/client option parsing and tighten CLI tests
- server_cli: handle --password-file/--early-input/--iconv via arg_has_value
  in one place, removing the unreachable duplicate separate-form arms while
  keeping both --opt VALUE and --opt=VALUE working
- client_cli: factor the triplicated --delta-block/--block-size range check
  into set_delta_block_size(); drop the redundant use_metadata assignment
  after identity_parse_copy_as (the parser already forces it)
- tests: cover both spellings of --iconv/--delta-block, make the archive
  short-form test actually call parse_args, add delta-block invalid cases
2026-09-12 14:54:47 +02:00
45 changed files with 4405 additions and 784 deletions

No files matched your search

+65
View File
@@ -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
View File
@@ -1,6 +1,6 @@
cmake_minimum_required(VERSION 3.22) cmake_minimum_required(VERSION 3.22)
project(FastFileTransfer) project(FastFileTransfer VERSION 2.19.0)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON) set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
set(CMAKE_C_STANDARD 11) set(CMAKE_C_STANDARD 11)
+52 -3
View File
@@ -6,6 +6,10 @@ source/destination model and rsync-style options while adding optional
multithreading, streaming zstd compression, chunking, zero-copy TCP transfers, multithreading, streaming zstd compression, chunking, zero-copy TCP transfers,
and native TCP/TLS transports. 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: The compatibility target is straightforward:
- Existing rsync commands should keep the same meaning. - Existing rsync commands should keep the same meaning.
@@ -145,7 +149,7 @@ partial, alternate, and planned behavior.
| `--cert <path>` | TLS certificate file (PEM) | | `--cert <path>` | TLS certificate file (PEM) |
| `--key <path>` | TLS private key file (PEM) | | `--key <path>` | TLS private key file (PEM) |
| `--ca <path>` | TLS CA certificate file for verification (PEM) | | `--ca <path>` | TLS CA certificate file for verification (PEM) |
| `--client-cn <name>` | Required TLS client certificate common name | | `--client-cn <name>` | TLS client certificate common name; mandatory with `--tls` (a TLS connection always verifies the client CN) |
### Server ### Server
@@ -159,7 +163,7 @@ partial, alternate, and planned behavior.
| `--ca <path>` | TLS CA certificate file for verification (PEM) | | `--ca <path>` | TLS CA certificate file for verification (PEM) |
| `--destination-root <path>` | Authorized destination root (default: `.`) | | `--destination-root <path>` | Authorized destination root (default: `.`) |
| `--allow-delete` | Permit manifest deletion | | `--allow-delete` | Permit manifest deletion |
| `--allow-unauthenticated` | Permit plaintext TCP clients | | `--allow-unauthenticated` | Permit plaintext TCP clients. For an `auth users` module this opts in **loopback plaintext only**; remote auth still requires verified TLS, so the flag never permits remote plaintext auth. |
| `-v, --verbose` | Enable debug logging | | `-v, --verbose` | Enable debug logging |
| `--help` | Show help | | `--help` | Show help |
@@ -507,13 +511,58 @@ defaults to the current directory. |
## Protocol and Security ## 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, current protocol is sender-driven and includes configuration negotiation,
including the maximum allocation limit, incremental checks, checksums, including the maximum allocation limit, incremental checks, checksums,
manifests, keep-alives, abort handling, per-file remove-source results, and manifests, keep-alives, abort handling, per-file remove-source results, and
FastSync-native delta messages. FastSync-native delta messages.
Client and server versions must currently match exactly. 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 TLS provides encrypted TCP transport. Supplying `--ca` enables certificate
verification; without it, traffic is encrypted but peer identity is not verification; without it, traffic is encrypted but peer identity is not
verified. Use certificate verification for deployments where authentication verified. Use certificate verification for deployments where authentication
+14 -13
View File
@@ -611,7 +611,7 @@ now transmits targets (the prior behavior was broken/partial); its status moved
| Flag | Rsync Description | FastSync Status | Notes | | 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) | | `-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` | | `--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 | | `--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** | | `--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** |
@@ -629,22 +629,23 @@ now transmits targets (the prior behavior was broken/partial); its status moved
| `--config=FILE` | Alternate rsyncd.conf file | ✅ Implemented | Wave A: selects the daemon config file. Default when omitted (in `--daemon` mode): `~/.config/fastsync/fastsyncd.conf` if it exists, else `/etc/fastsyncd.conf`. The grammar is FastSync-native (documented in the Daemon Mode notes below) and strictly rejects unknown keys so a typo can never silently change what a module serves; requires `--daemon` | | `--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` | | `--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` | | `--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 | | `--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 `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) | | `--early-input=FILE` | Use FILE for daemon early exec | ✅ Implemented | Server-only (requires `--daemon`): a second credential-store file, same new-format grammar as `--password-file`, read before the listener accepts connections (a secrets-manager / process-substitution source). Its entries layer over `--password-file`: byte-identical verifiers dedupe, a conflicting verifier for the same user is a startup error. A daemon whose modules declare `auth users` must be given at least one of the two, or it refuses to start (fail closed) |
| `--hash-credentials=FILE`, `--iterations N` | Hash a plaintext credential file | ✅ Implemented | Server-only offline tool (A7): reads the `user:password` lines of FILE (same owner-only 0600 check) and prints one new-format store line per entry to stdout, then exits. `--iterations` sets the PBKDF2 work factor (default 600000, range 100000–10000000). Dependency-free and does not run a listener. Use its output as `--password-file` for `--daemon`. There is no auto-upgrade: a legacy store line is hard-rejected by the loader and must be regenerated |
**Daemon Mode notes (Wave A, protocol 2.15.0; Wave B auth, Wave C MOTD, no bump):** FastSync daemon mode is supported in FastSync's own protocol/config grammar, not rsync's SMB/daemon option encoding. **Daemon Mode notes (Wave A protocol 2.15.0; A7 auth protocol 2.19.0; MOTD no bump):** FastSync daemon mode is supported in FastSync's own protocol/config grammar, not rsync's SMB/daemon option encoding.
- **Config grammar** (`fastsyncd.conf`): line-based; an implicit global section first, then `[module]` sections. Keys are case-insensitive, values are trimmed and may be wrapped in one layer of double quotes (`path = "/srv/my dir"`). `#` and `;` at the start of a line (after leading whitespace) are full-line comments; inline comments and `\` continuations are not supported. Lines are bounded (4096 chars). Global keys: `port` (default 873), `motd file` (the daemon sends its bounded, escaped content to a client after the module gate/auth accepts, unless the client passes `--no-motd`), `address` (optional bind address). Module keys: `path` (required; the daemon-side authorized root for that module), `read only` (yes/no/true/false/1/0, default no), `client owner` (yes/no/true/false/1/0, default no; opts the module into client-chosen ownership — see below), `auth users` (comma list). **Unknown keys and malformed lines are parse-and-reject errors** (never silently ignored), so a typo cannot change what a module serves. - **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. - **Module selection & confinement:** the client requests a module with an rsync-style `host::module[/path]` destination. The module name crosses the wire as a trailing string on the config frame (bumping `PROTOCOL_VERSION` 2.14.0 → 2.15.0; the bump is required because the config-frame layout changed and the strict same-version handshake is what prevents a peer from desynchronizing on the new trailing field). The daemon looks the module up in ITS OWN config and uses the module's `path` as the authorized root through the exact same `configure_authorization` confinement the standalone server applies to `--destination-root` (`file_open_secure_parent`, `has_path_traversal`, `path_is_within`); the client never supplies the root, every client-chosen-ownership/super-user request is refused unless the module declares `client owner = yes` (the daemon's per-module opt-in, see below), and the operator `--no-super` veto forces super-user activities off for every daemon connection. The client's `/path` part is relative inside the module and is rejected if absolute or if it contains `..`. Unknown modules are refused before any data moves (the run fails cleanly at the config handshake). An absolute destination and a module request against a non-daemon server are also refused.
- **`client owner` (client-chosen-ownership opt-in):** by default a daemon module refuses every request that would let the client pick an owner or ask for super-user activities — `--numeric-ids`, `--chown`, `--usermap`/`--groupmap`, `--fake-super`, `--copy-as`, and an explicit `--super` — at the config handshake (before `STATUS_OK`), because a daemon has no per-module opt-in for client-chosen ownership and any anonymous client could otherwise force arbitrary owner ids inside the module root. `client owner = yes` opts a single module in, allowing those requests within that module's root (the standalone listener and the SSH `--stdio` server always honor them for their single operator-authorized root). Without the opt-in the daemon also forces super-user **device** activity off for that connection — char/block device-node creation (`--devices`) and `--write-devices` — even under the default `AUTO` mode, so a non-opted module can never be made to `mknod` or write a raw device; those entries are skipped (not refused) so an ordinary `-a` push still succeeds without device nodes. The opt-in does **not** lift the privilege requirement: `--copy-as` still needs a root receiver, and the operator `--no-super` veto still forces super-user activities off for every connection. The daemon logs a prominent startup warning for each `client owner = yes` module so the operator's deliberate choice is visible. - **`client owner` (client-chosen-ownership opt-in):** by default a daemon module refuses every request that would let the client pick an owner or ask for super-user activities — `--numeric-ids`, `--chown`, `--usermap`/`--groupmap`, `--fake-super`, `--copy-as`, and an explicit `--super` — at the config handshake (before `STATUS_OK`), because a daemon has no per-module opt-in for client-chosen ownership and any anonymous client could otherwise force arbitrary owner ids inside the module root. `client owner = yes` opts a single module in, allowing those requests within that module's root (the standalone listener and the SSH `--stdio` server always honor them for their single operator-authorized root). Without the opt-in the daemon also forces super-user **device** activity off for that connection — char/block device-node creation (`--devices`) and `--write-devices` — even under the default `AUTO` mode, so a non-opted module can never be made to `mknod` or write a raw device; those entries are skipped (not refused) so an ordinary `-a` push still succeeds without device nodes. The opt-in does **not** lift the privilege requirement: `--copy-as` still needs a root receiver, and the operator `--no-super` veto still forces super-user activities off for every connection. The daemon logs a prominent startup warning for each `client owner = yes` module so the operator's deliberate choice is visible.
- **`read only` safe default:** every network transfer FastSync currently supports is a push that writes under the module root, so a `read only` module refuses the connection (clear server log "module is read only"; the client exits non-zero, nothing is transferred). A future pull/list operation can be opened up when it exists; the knob is already stored. - **`read only` safe default:** every network transfer FastSync currently supports is a push that writes under the module root, so a `read only` module refuses the connection (clear server log "module is read only"; the client exits non-zero, nothing is transferred). A future pull/list operation can be opened up when it exists; the knob is already stored.
- **`auth users` (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". - **`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: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. - **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:** 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. - **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 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`). - **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`. - **Client side:** `host::module/path` selects the TCP transport and connects to `--server-port`; `host:path` stays the SSH transport; plain paths stay local TCP. The daemon username comes from `--password-file` (first `user:password` line), and `--password-file` without a `host::module/path` destination is a client error (fail fast). A `user@host::module` form is rejected with a pointer to `--password-file`. The client's plaintext password is wiped from memory (`config_burn_auth`) at transfer teardown.
- **MOTD (Wave C):** a daemon configured with a global `motd file` sends that file's content as the first server→client string frame after the config-frame STATUS_OK ack (rsync sends the MOTD as the first thing from the server at the start of a daemon connection). Only the daemon listener path (`host::module`) gets a MOTD; the `--stdio` SSH path never sends or reads one. The server reads the file bounded to 4096 bytes and treats an absent/unreadable file as "no MOTD" (an empty frame, never an error). The exchange is server→client only and does **not** bump `PROTOCOL_VERSION`: every 2.15.0 daemon client reads the frame after the ack, so sender and receiver stay in lockstep (see the Wave C note in `src/shared/config.h`). `--no-motd` is the client-side suppression switch: the client still reads (consumes) the frame to keep the stream in sync but does not display it. The MOTD is printed to stdout with control bytes (ESC included) escaped octal-style while newlines/tabs are preserved, so a hostile server cannot inject terminal escape sequences. - **MOTD (Wave C):** a daemon configured with a global `motd file` sends that file's content as the first server→client string frame after the config-frame STATUS_OK ack (rsync sends the MOTD as the first thing from the server at the start of a daemon connection). Only the daemon listener path (`host::module`) gets a MOTD; the `--stdio` SSH path never sends or reads one. The server reads the file bounded to 4096 bytes and treats an absent/unreadable file as "no MOTD" (an empty frame, never an error). The exchange is server→client only and does **not** bump `PROTOCOL_VERSION`: every 2.15.0 daemon client reads the frame after the ack, so sender and receiver stay in lockstep (see the Wave C note in `src/shared/config.h`). `--no-motd` is the client-side suppression switch: the client still reads (consumes) the frame to keep the stream in sync but does not display it. The MOTD is printed to stdout with control bytes (ESC included) escaped octal-style while newlines/tabs are preserved, so a hostile server cannot inject terminal escape sequences.
- **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 ## 15. Safety & Security
@@ -657,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` | | 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 | | `--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) | | `--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 | | `--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 | | `--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 |
@@ -676,7 +677,7 @@ now transmits targets (the prior behavior was broken/partial); its status moved
| `--stop-after=MINS` | Stop after N minutes | ✅ Implemented | Client-only sender stop deadline (Phase 6): computing `--stop-after=MINS` (a positive minute count; 0/negative/garbage rejected) and `--stop-at=TIME` (`HH:MM`, `HH:MM:SS`, or `now+N[smhd]`; a past time stops immediately). The transfer stops ELEGANTLY at the next chunk boundary: everything already fully sent is kept and applied, the run returns 0, and --delete (late/delete-after timing) does NOT wipe the destination — when the scan is cut short the partial keep-set manifest is suppressed with a warning (the delete walk is skipped rather than acting on an incomplete keep-set, so unscanned source mirrors survive). `--delete-before`/`--delete-during` still run their complete pre-scan (which ignores the deadline). Local client-only fields: never serialized into the wire config frame, so no PROTOCOL_VERSION bump. `--stop-after` uses CLOCK_MONOTONIC; `--stop-at` uses the wall clock. Works single-threaded and under `-j`/`--threads` (multithreaded). Divergence: rsync computes `--stop-after` from the run start; FastSync likewise. When both are given, the earlier of the two deadlines wins (checked per iteration). See the Phase-6 stop notes below | | `--stop-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 | | `--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 | | | `--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 | | `--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 | | `--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`. | | `--secluded-args`, `-s` | Use protocol to send args | ⛔ Impossible/Divergence | Accepted for CLI compatibility (including the rsync short `-s`, Phase 7 Wave A) but a documented **no-op / divergence**. rsync's `-s` protects arguments from shell expansion by shipping them over the protocol; FastSync never passes remote arguments through a shell expansion boundary in the first place — its SSH transport builds the remote argv as **single-quote-escaped shell words** (`ssh_build_remote_command`), so the injection/leak that `-s` guards against does not exist and there is nothing to "seclude". Implementing a true arg-send protocol would mean replacing the argv-based SSH launch with an in-band argument channel, a large redesign of the transport that buys no security here. Chunk serialization remains the long-only `--chunk-serialization`. |
@@ -792,7 +793,7 @@ These are the hardest compatibility items because they require durable formats o
**Phase 6, Wave B (iconv) shipping note (PROTOCOL 2.15.0 → 2.16.0):** `--iconv=LOCAL[,REMOTE]` converts file NAMES at the wire boundary (never content). The full CONVERT_SPEC is serialized into the config frame as a new trailing string field (empty→NULL canonicalized), so both ends share the same wire charset interpretation; this required the PROTOCOL bump because the frame is a strict ordered sequence and a peer that does not parse the new trailing field would desynchronize. Each end derives LOCAL (its own charset) and REMOTE (the wire charset): the sender opens LOCAL→REMOTE and converts every transmitted filename; the receiver opens REMOTE→LOCAL and converts every received filename before creating/writing. Conversion is applied at every wire-path site (regular/MKDIR/hardlink path+target/symlink path+target/SPECIAL, the delete manifest keep/protected/missing entries, the incremental-check path, and the embedded `-s`/chunk-blob path). A name it cannot convert (EILSEQ/EINVAL) is failed cleanly with a logged `--iconv: cannot convert file name ...` and is never written truncated/mangled. Validation probes both directions up front (both the sender local→remote and the receiver remote→local, and, for a server/daemon with its own `--iconv`, the client-REMOTE→server-LOCAL pair) so an unusable spec is rejected before the connection rather than mid-transfer, and NUL-emitting target charsets (utf-16/utf-32/ucs-2) are refused because filenames cannot contain NUL. Divergence documented upstream: the receiver does NOT half-swap; the wire charset always comes from the sender's REMOTE half, so a server whose local charset differs from the client's LOCAL must declare it with its own `--iconv`. Conversion is process-global and runs on a single thread per process (sender thread / receiver-loop thread), initialized before worker threads start and freed after they join. **Phase 6, Wave 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). **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).
+28 -47
View File
@@ -389,6 +389,21 @@ static int parse_ull_arg(const char* val, unsigned long long* out, const char* o
return 0; 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). /* 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 * 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. */ * 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) "--include") != 0)
return -1; return -1;
} else if (strncmp(argv[i], "--delta-block=", 14) == 0) { } else if (strncmp(argv[i], "--delta-block=", 14) == 0) {
unsigned long long val; if (set_delta_block_size(config, argv[i] + 14) != 0)
if (parse_ull_arg(argv[i] + 14, &val, "--block-size/--delta-block") != 0)
return -1; 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) { } else if (strncmp(argv[i], "--block-size=", 13) == 0) {
unsigned long long val; if (set_delta_block_size(config, argv[i] + 13) != 0)
if (parse_ull_arg(argv[i] + 13, &val, "--block-size/--delta-block") != 0)
return -1; 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")) { } else if (opt_is(argv[i], "--delta-block", "--block-size")) {
if (i + 1 >= argc) { if (i + 1 >= argc) {
log_message(LOG_LEVEL_ERROR, "missing argument for %s", argv[i]); log_message(LOG_LEVEL_ERROR, "missing argument for %s", argv[i]);
return -1; return -1;
} }
unsigned long long val; if (set_delta_block_size(config, argv[++i]) != 0)
if (parse_ull_arg(argv[++i], &val, "--block-size/--delta-block") != 0)
return -1; 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)) { } else if (opt_is(argv[i], "--delta-max", NULL)) {
if (i + 1 >= argc) { if (i + 1 >= argc) {
log_message(LOG_LEVEL_ERROR, "missing argument for %s", argv[i]); 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) { } else if (strncmp(argv[i], "--copy-as=", 10) == 0) {
if (identity_parse_copy_as(config, argv[i] + 10) != 0) if (identity_parse_copy_as(config, argv[i] + 10) != 0)
return -1; return -1;
config->use_metadata = true;
} else if (opt_is(argv[i], "--copy-as", NULL)) { } else if (opt_is(argv[i], "--copy-as", NULL)) {
if (i + 1 >= argc) { if (i + 1 >= argc) {
log_message(LOG_LEVEL_ERROR, "missing argument for %s", argv[i]); 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) if (identity_parse_copy_as(config, argv[++i]) != 0)
return -1; return -1;
config->use_metadata = true;
} else if (strncmp(argv[i], "--outbuf=", 9) == 0) { } else if (strncmp(argv[i], "--outbuf=", 9) == 0) {
if (set_outbuf_option(config, argv[i] + 9) != 0) if (set_outbuf_option(config, argv[i] + 9) != 0)
return -1; return -1;
@@ -1575,14 +1573,14 @@ static int read_patterns_from_file(const char* filepath, char*** patterns, int*
} }
#ifndef FASTSYNC_TEST_BUILD #ifndef FASTSYNC_TEST_BUILD
/* Daemon auth (Wave B): read --password-file and derive the wire credentials /* Daemon auth (A7, protocol 2.19.0): read --password-file and keep the
* (username + SHA-256 hex digest of the password). Runs once the destination * username plus the LITERAL password (client-only, never serialized). Runs
* form is known: the credentials only make sense for a daemon * once the destination form is known: the credentials only make sense for a
* (host::module/path) destination, so a --password-file without one is a hard * daemon (host::module/path) destination, so a --password-file without one is a
* error here rather than a silently-ignored flag. The literal password is * hard error here rather than a silently-ignored flag. The password is handed
* hashed immediately and wiped from memory; only the digest (and username) are * to the SCRAM challenge/response in config_send and burned by
* kept on the Config for config_send. Returns 0 on success, -1 on error (the * config_burn_auth/config_delete at teardown. Returns 0 on success, -1 on
* reason is logged; neither the password nor its digest is ever logged). */ * error (the reason is logged; the password is never logged). */
static int load_daemon_credentials(Config* config) { static int load_daemon_credentials(Config* config) {
if (!config->password_file) if (!config->password_file)
return 0; return 0;
@@ -1599,27 +1597,10 @@ static int load_daemon_credentials(Config* config) {
log_message(LOG_LEVEL_ERROR, "%s", err); log_message(LOG_LEVEL_ERROR, "%s", err);
return -1; 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); config_burn_auth(config);
free(config->auth_password_hash);
config->auth_user = user; config->auth_user = user;
config->auth_password_hash = str_dup(hash); config->auth_password = password;
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;
}
log_info_message(LOG_INFO_MISC, "Loaded daemon credentials for user '%s'", config->auth_user); log_info_message(LOG_INFO_MISC, "Loaded daemon credentials for user '%s'", config->auth_user);
return 0; return 0;
} }
+10
View File
@@ -3,6 +3,7 @@
#include "delay_updates.h" #include "delay_updates.h"
#include "log.h" #include "log.h"
#include "usage.h" #include "usage.h"
#include "utils.h"
#include <string.h> #include <string.h>
#include <stdio.h> #include <stdio.h>
@@ -136,6 +137,15 @@ bool validate_config(const Config* config) {
return false; 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) { if (config->delay_updates && config->inplace) {
log_message(LOG_LEVEL_ERROR, "--delay-updates does not work with --inplace"); log_message(LOG_LEVEL_ERROR, "--delay-updates does not work with --inplace");
return false; return false;
+2 -2
View File
@@ -246,8 +246,8 @@ void print_usage(void) {
printf(" -T, --temp-dir <dir> Scratch dir for temp files before atomic install\n"); printf(" -T, --temp-dir <dir> Scratch dir for temp files before atomic install\n");
printf(" --fastsync-server-path <path>\n"); printf(" --fastsync-server-path <path>\n");
printf(" Path to fastsync-server on remote (default: fastsync-server)\n"); printf(" Path to fastsync-server on remote (default: fastsync-server)\n");
printf( printf(" --old-args Accepted for rsync CLI compatibility; no effect (the\n");
" --old-args Disable safe SSH command argument quoting (legacy compatibility)\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(" -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(" (repeatable; each value is single-quote-escaped on the remote\n");
printf(" command line; empty values and values with control characters\n"); printf(" command line; empty values and values with control characters\n");
+219 -50
View File
@@ -55,11 +55,105 @@ static CredentialStore* g_credentials = NULL;
/* Opaque context threaded through to the config-frame gate: the connection's /* 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 * 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 { typedef struct ModuleGateContext {
SSL* ssl; 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; } 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 /* 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 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 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); size_t required_length = strlen(required_client_cn);
bool allowed = length >= 0 && (size_t)length == required_length && bool allowed = length >= 0 && (size_t)length == required_length &&
required_length < sizeof(common_name) && 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); X509_free(certificate);
return allowed; return allowed;
} }
@@ -182,11 +276,15 @@ static const char* server_module_gate(const Config* config, void* context) {
if (!config) if (!config)
return "missing config frame"; return "missing config frame";
/* Operator veto: --no-super forces SUPER_MODE_OFF for this connection before /* 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 the copy-as gate is evaluated. The received config is const, so the gates
again after this returns so the ownership/device gates see it too. */ below evaluate a shallow effective copy (only super_mode differs); the
Config* effective = (Config*)config; handler applies the recorded override to the accepted config exactly once. */
if (server_no_super) Config effective = *config;
effective->super_mode = SUPER_MODE_OFF; 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 /* --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 ownership of every written entry to the requested ids, which needs a
privileged (root) receiver. An unprivileged receiver REFUSES the whole privileged (root) receiver. An unprivileged receiver REFUSES the whole
@@ -195,7 +293,7 @@ static const char* server_module_gate(const Config* config, void* context) {
The daemon's per-module client-chosen-ownership refusal is enforced after 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 the module lookup below (it needs the module's opt-in) and covers --copy-as
like every other ownership flag. */ like every other ownership flag. */
if (identity_copy_as_refused(effective)) { if (identity_copy_as_refused(&effective)) {
if (geteuid() != 0) if (geteuid() != 0)
log_message(LOG_LEVEL_ERROR, "--copy-as requires a privileged receiver (root); refusing"); log_message(LOG_LEVEL_ERROR, "--copy-as requires a privileged receiver (root); refusing");
else else
@@ -247,9 +345,10 @@ static const char* server_module_gate(const Config* config, void* context) {
standalone/SSH server has a single operator-authorized root and keeps standalone/SSH server has a single operator-authorized root and keeps
honoring these. */ honoring these. */
if (!module->client_owner) { if (!module->client_owner) {
/* Ownership: refuse the whole transfer up front (a clear failure). Uses the /* Ownership: refuse the whole transfer up front (a clear failure).
original config so an explicit --super is caught even though super_mode is Evaluated against the ORIGINAL config so an explicit --super is refused
clamped to OFF below. */ 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)) { if (identity_ownership_requested(config)) {
log_message(LOG_LEVEL_ERROR, log_message(LOG_LEVEL_ERROR,
"daemon module '%s' refuses client-chosen ownership/super-user activities " "daemon module '%s' refuses client-chosen ownership/super-user activities "
@@ -258,19 +357,23 @@ static const char* server_module_gate(const Config* config, void* context) {
return "client-chosen ownership is not permitted by this daemon module"; return "client-chosen ownership is not permitted by this daemon module";
} }
/* Super-user DEVICE activities (char/block mknod and --write-devices) are /* Super-user DEVICE activities (char/block mknod and --write-devices) are
permitted under the default AUTO mode, so without this clamp a root daemon permitted under the default AUTO mode, so without this override a root
would still let a non-opted module create arbitrary device nodes and write daemon would still let a non-opted module create arbitrary device nodes
raw devices. Force them off for this connection: those entries are and write raw devices. Force them off for this connection: those entries
skipped (never mknod'ed) while an ordinary `-a` push still succeeds are skipped (never mknod'ed) while an ordinary `-a` push still succeeds
without device nodes, matching the operator's least-privilege choice. without device nodes, matching the operator's least-privilege choice.
The operator-level --no-super veto is already folded into this. */ The operator-level --no-super veto is already folded into this. */
effective->super_mode = SUPER_MODE_OFF; if (gate_ctx)
gate_ctx->super_mode_override = SUPER_MODE_OFF;
} }
if (module->auth_user_count > 0) { if (module->auth_user_count > 0) {
/* Auth-required module (Wave B): verify the presented credentials against /* Auth-required module (A7, protocol 2.19.0): run the SCRAM challenge/
* the store BEFORE the module root is installed and before any data moves. * response BEFORE the module root is installed and before any data moves.
* Fail closed: no store -> refuse; no/invalid credentials -> refuse. The * Fail closed: no store -> refuse (server misconfiguration, STATUS_ERROR);
* username may be logged (never the digest/password). */ * 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) { if (g_credentials == NULL) {
log_message(LOG_LEVEL_ERROR, log_message(LOG_LEVEL_ERROR,
"daemon module '%s' requires authentication but no credential store is " "daemon module '%s' requires authentication but no credential store is "
@@ -279,28 +382,46 @@ static const char* server_module_gate(const Config* config, void* context) {
return "requested daemon module requires authentication and no credential " return "requested daemon module requires authentication and no credential "
"store is configured"; "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, log_message(LOG_LEVEL_ERROR,
"daemon module '%s' requires authentication; the client " "daemon module '%s' requires authentication over an encrypted, verified TLS "
"presented no credentials", "connection (or an opted-in loopback plaintext transport); refusing",
config->module); 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) { /* Belt-and-braces: the transport policy above already guarantees a context
log_message(LOG_LEVEL_WARNING, * with a usable socket (verified TLS implies a live SSL object and loopback
"daemon module '%s' is authenticating over a plaintext connection (no --tls); " * allowance requires gate_ctx->fd >= 0), so this is unreachable today; keep
"the credential exchange is not encrypted", * 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); 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"; 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); 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, log_message(LOG_LEVEL_INFO, "daemon module '%s': user '%s' authenticated", config->module,
escaped_user ? escaped_user : "<allocation failed>"); escaped_user ? escaped_user : "<allocation failed>");
@@ -322,6 +443,8 @@ void handler(int file_descriptor) {
protocol_session_bind(&session); protocol_session_bind(&session);
ModuleGateContext gate_ctx; ModuleGateContext gate_ctx;
gate_ctx.ssl = ssl; 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); Config* config = config_receive_with_validate(file_descriptor, server_module_gate, &gate_ctx);
if (config == NULL) { if (config == NULL) {
log_message(LOG_LEVEL_ERROR, "Failed to receive config"); log_message(LOG_LEVEL_ERROR, "Failed to receive config");
@@ -329,12 +452,13 @@ void handler(int file_descriptor) {
protocol_session_unbind(); protocol_session_unbind();
return; return;
} }
/* Operator --no-super veto: clamp the accepted config so every downstream /* Apply the super-mode veto the gate decided on (operator --no-super, or a
* gate (identity_apply_ownership via privilege_super_permitted, device-node * daemon module without the `client owner = yes` opt-in) exactly once, so
* creation) sees SUPER_MODE_OFF even if the gate callback did not already * every downstream gate (identity_apply_ownership via privilege_super_permitted,
* mutate a copy of it. */ * device-node creation) sees SUPER_MODE_OFF. The gate never mutated the
if (server_no_super) * received config. */
config->super_mode = SUPER_MODE_OFF; if (gate_ctx.super_mode_override != -1)
config->super_mode = gate_ctx.super_mode_override;
protocol_set_8_bit_output(config->eight_bit_output); protocol_set_8_bit_output(config->eight_bit_output);
if (!authorized_root) { if (!authorized_root) {
log_message(LOG_LEVEL_ERROR, "No server-side destination root configured"); log_message(LOG_LEVEL_ERROR, "No server-side destination root configured");
@@ -417,8 +541,10 @@ void handler(int file_descriptor) {
before anything else; without it the root must pre-exist. A failure here before anything else; without it the root must pre-exist. A failure here
aborts the connection cleanly before any file data is exchanged. */ aborts the connection cleanly before any file data is exchanged. */
if (!ensure_receive_root(config)) { 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", 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); config_delete(config);
close(file_descriptor); close(file_descriptor);
protocol_session_unbind(); protocol_session_unbind();
@@ -440,8 +566,16 @@ void handler(int file_descriptor) {
} }
/* Preserve the negotiated identity policy for the fd-relative ownership /* Preserve the negotiated identity policy for the fd-relative ownership
apply path. Each connection is its own forked process, so this apply path. Each connection is its own forked process, so this
per-process snapshot never races another connection. */ per-process snapshot never races another connection. A failed deep copy
identity_set_active(config); (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, /* Persist the negotiated --keep-dirlinks policy once, here at config-accept,
before any multithreaded receiver/writer threads are spawned, so the before any multithreaded receiver/writer threads are spawned, so the
fd-walk reads a stable value during the whole transfer (and never bleeds fd-walk reads a stable value during the whole transfer (and never bleeds
@@ -485,6 +619,7 @@ void handler(int file_descriptor) {
config_delete(config); config_delete(config);
close(file_descriptor); close(file_descriptor);
protocol_session_unbind(); protocol_session_unbind();
identity_clear_active();
return; return;
} }
PipelineContextReceiver* context = PipelineContextReceiver* context =
@@ -613,10 +748,12 @@ static void print_server_usage(void) {
printf(" --no-detach Stay in the foreground (default detaches to\n"); printf(" --no-detach Stay in the foreground (default detaches to\n");
printf(" background when running --daemon)\n"); printf(" background when running --daemon)\n");
printf(" --password-file=FILE Credential store for modules that declare\n"); printf(" --password-file=FILE Credential store for modules that declare\n");
printf(" 'auth users' (line format: user:SHA256HEX where\n"); printf(" 'auth users' (line format:\n");
printf(" SHA256HEX is the lowercase hex SHA-256 of the\n"); printf(" user:$fastsync$1$pbkdf2-sha256$iters$salt$stored$server,\n");
printf(" user's password). Requires --daemon; an auth-\n"); printf(" generated by --hash-credentials). Legacy\n");
printf(" required module with no store refuses to start\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(" --early-input=FILE Second credential store layered over\n");
printf(" --password-file (same format); usually a secrets-\n"); printf(" --password-file (same format); usually a secrets-\n");
printf(" manager/process-substitution file. Requires --daemon\n"); printf(" manager/process-substitution file. Requires --daemon\n");
@@ -625,7 +762,7 @@ static void print_server_usage(void) {
printf(" --cert <path> TLS certificate file (PEM)\n"); printf(" --cert <path> TLS certificate file (PEM)\n");
printf(" --key <path> TLS private key file (PEM)\n"); printf(" --key <path> TLS private key file (PEM)\n");
printf(" --ca <path> TLS CA certificate file (PEM)\n"); printf(" --ca <path> TLS CA certificate file (PEM)\n");
printf(" --client-cn <name> Required TLS client certificate CN\n"); printf(" --client-cn <name> TLS client certificate CN (mandatory with --tls)\n");
printf(" --destination-root <path> Authorized destination root (default: .)\n"); printf(" --destination-root <path> Authorized destination root (default: .)\n");
printf(" --address <addr> Bind the listening socket to this address\n"); printf(" --address <addr> Bind the listening socket to this address\n");
printf(" -4, --ipv4 Bind an IPv4 socket (default)\n"); printf(" -4, --ipv4 Bind an IPv4 socket (default)\n");
@@ -641,6 +778,15 @@ static void print_server_usage(void) {
printf(" client's CONVERT_SPEC). A name that cannot be\n"); printf(" client's CONVERT_SPEC). A name that cannot be\n");
printf(" represented fails the run cleanly\n"); printf(" represented fails the run cleanly\n");
printf(" --allow-unauthenticated Allow plaintext/anonymous network clients\n"); printf(" --allow-unauthenticated Allow plaintext/anonymous network clients\n");
printf(" (an auth-required module still accepts only opted-in\n");
printf(" loopback plaintext; remote auth requires verified TLS)\n");
printf(" --hash-credentials <file> Read <file>'s user:password lines and print\n");
printf(" 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(" -v, --verbose Enable debug logging\n");
printf(" --help Show this help\n"); printf(" --help Show this help\n");
} }
@@ -710,6 +856,29 @@ int main(int argc, char* argv[]) {
return 1; 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; int exit_code = 0;
signal(SIGPIPE, SIG_IGN); signal(SIGPIPE, SIG_IGN);
if (opts.verbose) { if (opts.verbose) {
+61 -43
View File
@@ -1,5 +1,6 @@
#include "server_cli.h" #include "server_cli.h"
#include "charset.h" #include "charset.h"
#include "credentials.h"
#include "utils.h" #include "utils.h"
#include <limits.h> #include <limits.h>
#include <stdarg.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); server_cli_options_default(opts);
for (int i = 1; i < argc; i++) { for (int i = 1; i < argc; i++) {
const char* inline_value = NULL;
if (arg_is(argv[i], "--help")) { if (arg_is(argv[i], "--help")) {
opts->show_help = true; opts->show_help = true;
return 1; 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 = argv[++i];
opts->destination_root_set = true; opts->destination_root_set = true;
} else if (arg_is(argv[i], "--password-file")) { } else if (arg_has_value(argv[i], "--password-file", &inline_value)) {
if (i + 1 >= argc) { if (!inline_value) {
set_error(err, err_size, "missing argument for --password-file"); 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; return -1;
} }
opts->password_file = argv[++i]; opts->hash_iterations = (uint32_t)n;
} else if (arg_is(argv[i], "--early-input")) { opts->hash_iterations_set = true;
if (i + 1 >= argc) {
set_error(err, err_size, "missing argument for --early-input");
return -1;
}
opts->early_input_file = argv[++i];
} else if (arg_is(argv[i], "--address")) { } else if (arg_is(argv[i], "--address")) {
if (i + 1 >= argc) { if (i + 1 >= argc) {
set_error(err, err_size, "missing argument for --address"); 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; opts->no_super = true;
} else if (arg_is(argv[i], "--allow-unauthenticated")) { } else if (arg_is(argv[i], "--allow-unauthenticated")) {
opts->allow_unauthenticated = true; opts->allow_unauthenticated = true;
} else if (arg_is(argv[i], "--iconv")) { } else if (arg_has_value(argv[i], "--iconv", &inline_value)) {
if (i + 1 >= argc) { if (!inline_value) {
set_error(err, err_size, "missing argument for --iconv"); if (i + 1 >= argc) {
return -1; 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")) { } else if (arg_is(argv[i], "-p")) {
if (i + 1 >= argc) { if (i + 1 >= argc) {
set_error(err, err_size, "missing argument for -p"); 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) if (parse_port_arg(argv[++i], &opts->port, err, err_size) != 0)
return -1; return -1;
} else { } else {
const char* inline_value = NULL;
if (arg_has_value(argv[i], "--config", &inline_value)) { if (arg_has_value(argv[i], "--config", &inline_value)) {
if (!inline_value) { if (!inline_value) {
if (i + 1 >= argc) { if (i + 1 >= argc) {
@@ -171,33 +208,6 @@ int server_cli_parse(int argc, char* argv[], ServerCliOptions* opts, char* err,
inline_value = argv[++i]; inline_value = argv[++i];
} }
opts->config_path = inline_value; 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)) { } else if (arg_has_value(argv[i], "--dparam", &inline_value)) {
if (!inline_value) { if (!inline_value) {
if (i + 1 >= argc) { if (i + 1 >= argc) {
@@ -245,6 +255,14 @@ int server_cli_parse(int argc, char* argv[], ServerCliOptions* opts, char* err,
"--daemon"); "--daemon");
return -1; 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 /* --iconv: reject a malformed CONVERT_SPEC or an unsupported charset name at
startup (a probe iconv_open is attempted). */ startup (a probe iconv_open is attempted). */
if (opts->iconv_spec != NULL && !charset_spec_valid(opts->iconv_spec)) { if (opts->iconv_spec != NULL && !charset_spec_valid(opts->iconv_spec)) {
+8 -1
View File
@@ -3,6 +3,7 @@
#include <stdbool.h> #include <stdbool.h>
#include <stddef.h> #include <stddef.h>
#include <stdint.h>
/* Parsed fastsync-server command line. All string members are borrowed /* Parsed fastsync-server command line. All string members are borrowed
* pointers into the original argv (valid for the life of the argv array the * 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* config_path; /* --config value, or NULL */
const char* password_file; /* --password-file value, or NULL (daemon) */ const char* password_file; /* --password-file value, or NULL (daemon) */
const char* early_input_file; /* --early-input 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; int dparam_count;
const char* bind_address; /* --address */ const char* bind_address; /* --address */
int bind_family; /* AF_UNSPEC / AF_INET / AF_INET6 */ int bind_family; /* AF_UNSPEC / AF_INET / AF_INET6 */
+130 -29
View File
@@ -41,7 +41,7 @@ static void config_set_defaults(Config* config) {
config->ssh_destination = NULL; config->ssh_destination = NULL;
config->module = NULL; config->module = NULL;
config->auth_user = NULL; config->auth_user = NULL;
config->auth_password_hash = NULL; config->auth_password = NULL;
config->password_file = NULL; config->password_file = NULL;
config->iconv_spec = NULL; config->iconv_spec = NULL;
config->fastsync_server_path = NULL; config->fastsync_server_path = NULL;
@@ -630,6 +630,20 @@ void config_parse_ssh_dest(Config* config) {
config->receive_root_directory = path; 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) { void config_delete(Config* config) {
if (config == NULL) if (config == NULL)
return; return;
@@ -642,8 +656,7 @@ void config_delete(Config* config) {
free(config->receive_root_directory); free(config->receive_root_directory);
free(config->ssh_destination); free(config->ssh_destination);
free(config->module); free(config->module);
free(config->auth_user); config_burn_auth(config);
free(config->auth_password_hash);
free(config->password_file); free(config->password_file);
free(config->iconv_spec); free(config->iconv_spec);
free(config->write_batch); free(config->write_batch);
@@ -1128,23 +1141,18 @@ static bool receive_daemon_module(int fd, Config* c) {
return true; return true;
} }
/* Daemon password credentials (Wave B, within protocol 2.15.0 -- see the /* Daemon password credentials (A7 remediation, protocol 2.19.0). A single
* PROTOCOL_VERSION note in config.h: this rides the Wave A trailing-string * presence int is followed, when set, by ONLY the username; the password is
* area, symmetric sender+receiver in every 2.15.0 build, so it is not a frame * never serialized. The daemon answers an auth-required module with the SCRAM
* layout that needs its own bump). A single presence int is followed, when * challenge (see the auth exchange below). */
* set, by the username and the SHA-256 hex digest of the password. The
* literal password never crosses the wire. */
static bool send_daemon_auth(int fd, const Config* c) { 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' && bool present = c->auth_user != NULL && c->auth_user[0] != '\0';
c->auth_password_hash[0] != '\0';
if (!send_int(fd, present ? 1 : 0)) if (!send_int(fd, present ? 1 : 0))
return false; return false;
if (!present) if (!present)
return true; return true;
/* Redacted send: the username and hard-wired digest must never reach a /* Redacted send: the username must never reach a --verbose debug log. */
* --verbose debug log (they are replayable), while normal protocol strings return send_str_redacted(fd, c->auth_user);
* keep their debug trace. */
return send_str_redacted(fd, c->auth_user) && send_str_redacted(fd, c->auth_password_hash);
} }
static bool receive_daemon_auth(int fd, Config* c) { static bool receive_daemon_auth(int fd, Config* c) {
@@ -1153,27 +1161,107 @@ static bool receive_daemon_auth(int fd, Config* c) {
return false; return false;
if (!present) if (!present)
return true; 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* user = receive_str_redacted(fd);
char* hash = receive_str_redacted(fd); if (!user)
if (!user || !hash) {
free(user);
free(hash);
return false; return false;
} if (!credentials_username_valid(user)) {
size_t user_len = strlen(user);
bool valid = user_len > 0 && user_len <= CREDENTIAL_MAX_USER_LEN && credentials_hash_valid(hash);
if (!valid) {
free(user); free(user);
free(hash);
log_message(LOG_LEVEL_WARNING, "Daemon client sent malformed auth credentials"); log_message(LOG_LEVEL_WARNING, "Daemon client sent malformed auth credentials");
return false; return false;
} }
c->auth_user = user; c->auth_user = user;
c->auth_password_hash = hash;
return true; 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, /* --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 * 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 * 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; Status status;
if (!receive_status(file_descriptor, &status)) if (!receive_status(file_descriptor, &status))
return false; 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) { if (status != STATUS_OK) {
log_message(LOG_LEVEL_ERROR, "Error transmitting config"); log_message(LOG_LEVEL_ERROR, "Error transmitting config");
return false; return false;
@@ -1329,9 +1425,14 @@ Config* config_receive_with_validate(int file_descriptor, ConfigValidateFunc val
if (rejection != NULL) { if (rejection != NULL) {
/* Daemon module gate (unknown module / read-only module / auth-required /* Daemon module gate (unknown module / read-only module / auth-required
* module): refuse BEFORE the STATUS_OK so the client aborts at the * module): refuse BEFORE the STATUS_OK so the client aborts at the
* config handshake and no file data is ever exchanged. */ * config handshake and no file data is ever exchanged. The auth
fprintf(stderr, "%s\n", rejection); * handshake already sent STATUS_AUTH_FAILED when it failed, signalled by
send_status(file_descriptor, STATUS_ERROR); * 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; goto error;
} }
} }
+47 -19
View File
@@ -96,19 +96,18 @@ typedef struct Config {
* string so the daemon can look the module up in its own config and confine * 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). */ * the connection to the module's root (never a client-chosen root). */
char* module; char* module;
/* Daemon password authentication (Wave B, protocol 2.15.0, WITHIN the Wave A /* Daemon password authentication (A7 remediation, protocol 2.19.0).
* frame layout -- see the PROTOCOL_VERSION note below for why this is not a * Client-composed from a --password-file whose first meaningful line is
* bump). Client-composed from a --password-file whose first meaningful line * `user:password`: the client sends ONLY the username in the config frame
* is `user:password`: the client sends ONLY the username and a SHA-256 hex * (auth_user); the literal password is kept in auth_password CLIENT-SIDE for
* digest of the password (auth_user + auth_password_hash), never the literal * the duration of the SCRAM challenge/response and is NEVER serialized. Both
* password. Both are NULL when the client has no credentials to present; a * are NULL when the client has no credentials to present; a module WITHOUT
* module WITHOUT `auth users` stays open and the server ignores any * `auth users` stays open and the server ignores any credentials that do
* credentials that do arrive (the client sends them opportunistically and * arrive (the client sends them opportunistically and the server decides). */
* the server decides). */
char* auth_user; char* auth_user;
char* auth_password_hash; char* auth_password;
/* Client-only path of --password-file (never crosses the wire; it is read to /* 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* password_file;
char* fastsync_server_path; char* fastsync_server_path;
/* --iconv=CONVERT_SPEC (protocol 2.16.0, rsync compatibility): convert the /* --iconv=CONVERT_SPEC (protocol 2.16.0, rsync compatibility): convert the
@@ -546,8 +545,8 @@ typedef struct Config {
* is what keeps a 2.15 client and a 2.14 server from ever reaching that state. * 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 * 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 * waves (auth, motd) must not bump PROTOCOL_VERSION. Wave B (auth) added the
* credential fields (auth_user/auth_password_hash) as further trailing * credential fields (auth_user + password digest) as further trailing
* config-frame strings AFTER the Wave A module string, with a presence int * 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 * 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 * build always read and write the same full layout (the strict same-version
@@ -617,8 +616,22 @@ typedef struct Config {
* (config_receive rejects a mismatched version before parsing anything else) is * (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. * 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 * --super never elevates privileges; it only permits a confined attempt, and
* --copy-as never switches process credentials (see RSYNC_COMPAT.md). */ * --copy-as never switches process credentials (see RSYNC_COMPAT.md).
#define PROTOCOL_VERSION "2.18.0" *
* 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) #define DEFAULT_CHUNK_SIZE (10 * 1024 * 1024)
/* Upper bound on total basis-dir entries (rsync caps --link-dest at 20). */ /* Upper bound on total basis-dir entries (rsync caps --link-dest at 20). */
#define MAX_BASIS_DIRS 64 #define MAX_BASIS_DIRS 64
@@ -642,21 +655,36 @@ typedef struct Config {
Config* config_create(void); Config* config_create(void);
void config_delete(Config* config); 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); bool config_send(int file_descriptor, const Config* config);
Config* config_receive(int file_descriptor); Config* config_receive(int file_descriptor);
bool config_is_remote_dest(const char* s); bool config_is_remote_dest(const char* s);
void config_parse_ssh_dest(Config* config); 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 /* 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 * 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 * 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 * 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 * 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 * 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 * is logged server-side and STATUS_ERROR is sent in place of STATUS_OK), or the
* callback runs in the connection's own process, so it may set up per-module * CONFIG_VALIDATE_ALREADY_TERMINATED sentinel when the callback already sent
* process state (e.g. the authorized root). context is an opaque caller * its own terminal status. The callback runs in the connection's own process,
* pointer. */ * 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); typedef const char* (*ConfigValidateFunc)(const Config* config, void* context);
Config* config_receive_with_validate(int file_descriptor, ConfigValidateFunc validate, Config* config_receive_with_validate(int file_descriptor, ConfigValidateFunc validate,
void* context); void* context);
+963 -113
View File
File diff suppressed because it is too large. Load diff
+151 -73
View File
@@ -3,46 +3,83 @@
#include <stdbool.h> #include <stdbool.h>
#include <stddef.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 * FastSync authenticates a daemon connection with a SCRAM-SHA-256-style
* hex digest of that username's password. The digest is what crosses the * challenge/response handshake. The daemon stores only a salted PBKDF2
* wire: a challenge-less credential exchange, so the literal password is never * verifier (never the password, and never a value that can be replayed as a
* transmitted (and never stored on the daemon host). A module that declares * bearer credential): the client proves knowledge of the password against a
* `auth users` demands that the presented username is on its list AND that the * per-connection server nonce, and the server proves the same shared secret
* presented digest matches the credential store's entry for that username. * back. See credentials.c for the exact derivation.
* The digest comparison is constant-time; a module with `auth users` whose
* store is missing/misconfigured fails CLOSED (never falls open).
* *
* Credential store format (server --password-file and --early-input): one * Server credential store format (--password-file and --early-input): one line
* `user:SHA256HEX` entry per line. SHA256HEX is the lowercase hex SHA-256 of * per entry,
* the user's password -- the exact value a FastSync client transmits. Blank * user:$fastsync$1$pbkdf2-sha256$<iters>$<salt_b64>$<stored_key_b64>$<server_key_b64>
* lines and lines whose first non-space character is '#' or ';' are comments. * with standard base64, a 16-byte salt and 32-byte keys, and iters in
* The parser is STRICT: a malformed line (no ':', an empty/whitespace user, a * [CREDENTIAL_MIN_ITERS, CREDENTIAL_MAX_ITERS]. Blank lines and lines whose
* secret that is not 64 lowercase hex chars, a line longer than * first non-space character is '#' or ';' are comments. The parser is STRICT:
* CREDENTIAL_MAX_LINE) fails the whole load so a typo can never silently * a malformed line fails the whole load so a typo can never silently change who
* change who may log in. * 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) * Client --password-file format: the FIRST meaningful (non-comment, non-blank)
* line is `user:password`, holding the literal password. The client hashes it * line is `user:password`, holding the literal password. The client keeps it
* and sends only the digest; the file should be mode 0600 and readable only by * only for the duration of the handshake and wipes it at teardown; the file
* its owner. * 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). */ /* Longest accepted credential-file line (excluding the trailing newline). */
#define CREDENTIAL_MAX_LINE 4096 #define CREDENTIAL_MAX_LINE 4096
/* Upper bound on a username in a credential file and on the wire. Kept well /* 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. */ * below MAX_STRING_SIZE so a wire username can never exhaust anything. */
#define CREDENTIAL_MAX_USER_LEN 256 #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 #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; 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. /* Load the daemon credential store.
* *
* password_file and early_input_file are both NULL-or-path, matching the * 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 * 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 * 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 * 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 * store (every auth-required module then refuses connections). Every entry in
* given, the --early-input file is layered over --password-file: a duplicate * the resulting store must agree on the iteration count; entries that disagree
* username whose secret matches is deduplicated; one whose secret differs is * (within one file or across the two layered sources) are rejected. When both
* an error (the two sources disagree), never a silent pick. * 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. */ * The returned store is heap-owned; free it with credentials_free. */
CredentialStore* credentials_load(const char* password_file, const char* early_input_file, CredentialStore* credentials_load(const char* password_file, const char* early_input_file,
char* err, size_t err_size); char* err, size_t err_size);
/* Wipe every stored key/salt and free the store. */
void credentials_free(CredentialStore* store); void credentials_free(CredentialStore* store);
/* True when `hash_hex` is exactly CREDENTIAL_HASH_HEX_LEN lowercase hex digits /* True when `user` is a single bounded token free of whitespace/control bytes
* (the wire/store digest form). Used to reject a malformed presented digest * (the rule applied to store users, client-file users and the module list). */
* before it reaches the comparison. */ bool credentials_username_valid(const char* user);
bool credentials_hash_valid(const char* hash_hex);
/* Compute the lowercase hex SHA-256 of `password` into out_hex, which must /* Standard base64. encode writes NUL-terminated output to out (size out_sz).
* hold at least CREDENTIAL_HASH_HEX_LEN + 1 bytes. Returns false on a NULL * decode writes the raw bytes to out (capacity out_sz) and stores the length;
* password or a hashing failure. The output is NUL-terminated. */ * the input must be a well-formed padded base64 string. Both return false on
bool credentials_hash_password(const char* password, char* out_hex); * 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 /* Read the CLIENT-side secret file: the first meaningful line is
* `user:password` (the literal password). *user_out and *password_out are * `user:password` (the literal password). *user_out and *password_out are
* freshly allocated on success (password is plaintext -- the caller hashes it * freshly allocated on success (password is plaintext -- the caller derives the
* and then burns/frees it); both are NULL on error. Returns 0 on success, -1 * proof and then burns/frees it); both are NULL on error. Returns 0 on
* on failure (err filled: the path is named, never the credential itself). * success, -1 on failure (err filled: the path is named, never the credential
* Only the line's trailing CR/LF are stripped: the password's bytes are * itself). Only the line's trailing CR/LF are stripped: the password's bytes
* otherwise preserved exactly, so a password with leading/trailing whitespace * are otherwise preserved exactly, so a password with leading/trailing
* (after the ':') is kept usable. The username is trimmed of surrounding * whitespace (after the ':') is kept usable. The username is trimmed of
* space/tabs. */ * surrounding space/tabs. */
int credentials_read_secret_file(const char* path, char** user_out, char** password_out, char* err, int credentials_read_secret_file(const char* path, char** user_out, char** password_out, char* err,
size_t err_size); size_t err_size);
/* Constant-time equality over exactly len bytes. Returns true when the two /* Constant-time equality over exactly len bytes. */
* buffers match. No early exit: the whole length is always scanned, so a
* timing side-channel cannot reveal how many leading bytes matched. */
bool credentials_secure_equal(const char* a, const char* b, size_t len); 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 /* Overwrite secret[0..len) with zeros (best-effort wipe). */
* password that is about to be freed). */
void credentials_burn(char* secret, size_t len); 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). */ /* Number of entries currently in the store (tests/introspection). */
int credentials_store_size(const CredentialStore* store); int credentials_store_size(const CredentialStore* store);
+68 -57
View File
@@ -16,6 +16,7 @@
#include "data.h" #include "data.h"
#include "delta.h" #include "delta.h"
#include "file.h" #include "file.h"
#include "file_store.h"
#include "identity.h" #include "identity.h"
#include "log.h" #include "log.h"
#include "metadata.h" #include "metadata.h"
@@ -37,44 +38,6 @@ static bool write_all(int fd, const void* data, unsigned long long size) {
return true; 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). /* Preallocate `size` bytes on `fd` before any data is written (--preallocate).
* posix_fallocate reserves real disk blocks, so an out-of-space condition * posix_fallocate reserves real disk blocks, so an out-of-space condition
* (ENOSPC/EDQUOT) surfaces up front instead of partway through a transfer; * (ENOSPC/EDQUOT) surfaces up front instead of partway through a transfer;
@@ -515,6 +478,50 @@ out:
return ok; 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) { int file_open_secure_parent(const char* path, char** leaf_out, bool create_dirs) {
char* copy = str_dup(path); char* copy = str_dup(path);
if (!copy) if (!copy)
@@ -619,16 +626,13 @@ int file_open_secure_parent(const char* path, char** leaf_out, bool create_dirs)
(resolved[strlen(root)] == '/' || resolved[strlen(root)] == '\0')) { (resolved[strlen(root)] == '/' || resolved[strlen(root)] == '\0')) {
struct stat rst; struct stat rst;
if (stat(resolved, &rst) == 0 && S_ISDIR(rst.st_mode)) { if (stat(resolved, &rst) == 0 && S_ISDIR(rst.st_mode)) {
/* Re-open the resolved directory WITHOUT following a symlink and /* Open the resolved directory through a relative no-follow walk
re-verify it is still a directory inode, so a symlink swapped from the authorized-root fd instead of re-opening the
in between realpath() and open() (TOCTOU) cannot redirect this absolute `resolved` path: swapping an intermediate directory
fd outside the root. */ for a symlink between realpath() and open() (TOCTOU) then
next = open(resolved, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); merely fails the walk rather than redirecting the fd outside
struct stat ofst; the root. */
if (next >= 0 && (fstat(next, &ofst) != 0 || !S_ISDIR(ofst.st_mode))) { next = open_dir_beneath_root(resolved, root);
close(next);
next = -1;
}
} }
} }
} }
@@ -926,9 +930,12 @@ static bool file_to_disk_secure_impl(const char* path, const void* data,
int prealloc_rc = 0; int prealloc_rc = 0;
if (preallocate && !sparse && data_size > 0) { if (preallocate && !sparse && data_size > 0) {
prealloc_rc = preallocate_fd(fd, data_size); prealloc_rc = preallocate_fd(fd, data_size);
if (prealloc_rc != 0) if (prealloc_rc != 0) {
log_message(LOG_LEVEL_ERROR, "preallocate failed for '%s' (%s); transfer aborted", path, char* escaped_path = output_escape(path, log_get_8_bit_output());
strerror(prealloc_rc)); 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) { if (prealloc_rc == 0) {
/* posix_fallocate does not guarantee the fd's file offset is left /* posix_fallocate does not guarantee the fd's file offset is left
@@ -938,7 +945,7 @@ static bool file_to_disk_secure_impl(const char* path, const void* data,
ok = ftruncate(fd, (off_t)data_size) == 0; ok = ftruncate(fd, (off_t)data_size) == 0;
if (ok || !sparse || data_size == 0) if (ok || !sparse || data_size == 0)
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); : write_all(fd, data, data_size);
if (ok) if (ok)
ok = ftruncate(fd, (off_t)data_size) == 0; ok = ftruncate(fd, (off_t)data_size) == 0;
@@ -1033,9 +1040,12 @@ static bool file_to_disk_secure_impl(const char* path, const void* data,
int prealloc_rc = 0; int prealloc_rc = 0;
if (preallocate && !sparse && data_size > 0) { if (preallocate && !sparse && data_size > 0) {
prealloc_rc = preallocate_fd(fd, data_size); prealloc_rc = preallocate_fd(fd, data_size);
if (prealloc_rc != 0) if (prealloc_rc != 0) {
log_message(LOG_LEVEL_ERROR, "preallocate failed for '%s' (%s); transfer aborted", path, char* escaped_path = output_escape(path, log_get_8_bit_output());
strerror(prealloc_rc)); 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) { if (prealloc_rc == 0) {
lseek(fd, 0, SEEK_SET); lseek(fd, 0, SEEK_SET);
@@ -1046,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. */ failure may leave partial data that --partial retention can rename. */
if (ok || (!sparse || data_size == 0)) { if (ok || (!sparse || data_size == 0)) {
write_attempted = true; write_attempted = true;
ok = sparse && data_size > 0 ? write_all_sparse(fd, (const unsigned char*)data, data_size) ok = sparse && data_size > 0
: write_all(fd, data, data_size); ? file_store_write_sparse(fd, (const unsigned char*)data, data_size)
: write_all(fd, data, data_size);
} }
if (ok && metadata) if (ok && metadata)
ok = file_restore_metadata_fd(fd, metadata, preserve_executability); ok = file_restore_metadata_fd(fd, metadata, preserve_executability);
+30 -9
View File
@@ -350,7 +350,10 @@ static FileSaveResult file_save_special_to_disk(const char* root_directory, cons
} }
if (is_sock) { if (is_sock) {
/* No standard filesystem call recreates a socket; best-effort unsupported. */ /* 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; return FILE_SAVE_SKIPPED;
} }
if (is_char || is_blk) { if (is_char || is_blk) {
@@ -363,9 +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 so the policy does not depend on a prior identity_set_active(). Pure
FIFO creation is unprivileged and deliberately NOT gated here. */ FIFO creation is unprivileged and deliberately NOT gated here. */
if (!privilege_super_mode_permitted(config->super_mode)) { 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, log_message(LOG_LEVEL_WARNING,
"skipping %s: super-user device-node creation is not permitted on this receiver", "skipping %s: super-user device-node creation is not permitted on this receiver",
file->path); escaped_path ? escaped_path : "<allocation failed>");
free(escaped_path);
return FILE_SAVE_SKIPPED; return FILE_SAVE_SKIPPED;
} }
} else if (is_fifo) { } else if (is_fifo) {
@@ -438,18 +443,26 @@ static FileSaveResult file_save_special_to_disk(const char* root_directory, cons
free(destination); free(destination);
return FILE_SAVE_SKIPPED; 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)", 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) { } else if (errno == EPERM || errno == EACCES) {
/* Missing CAP_MKNOD / parent write permission: the environment cannot /* Missing CAP_MKNOD / parent write permission: the environment cannot
create the node, so skip instead of failing the whole run. */ 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, log_message(LOG_LEVEL_WARNING,
"skipping %s: cannot create %s node (%s)\n" "skipping %s: cannot create %s node (%s)\n"
" --devices/--specials node creation needs privilege (CAP_MKNOD)", " --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 { } 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)", 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); close(parent_fd);
free(leaf); free(leaf);
@@ -512,22 +525,28 @@ static FileSaveResult file_save_write_device(const char* root_directory, const F
close(parent_fd); close(parent_fd);
if (fd < 0) { if (fd < 0) {
free(destination); 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) { if (saved_errno == ENXIO || saved_errno == EAGAIN) {
/* A FIFO with no reader / an unreadable special: skip like every other /* A FIFO with no reader / an unreadable special: skip like every other
unusable write-devices target instead of blocking or failing. */ 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)); strerror(saved_errno));
} else { } 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)); strerror(saved_errno));
} }
free(escaped_path);
return FILE_SAVE_SKIPPED; return FILE_SAVE_SKIPPED;
} }
struct stat st; struct stat st;
if (fstat(fd, &st) != 0 || !(S_ISCHR(st.st_mode) || S_ISBLK(st.st_mode))) { if (fstat(fd, &st) != 0 || !(S_ISCHR(st.st_mode) || S_ISBLK(st.st_mode))) {
close(fd); close(fd);
free(destination); 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; return FILE_SAVE_SKIPPED;
} }
bool ok = true; bool ok = true;
@@ -596,10 +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). */ open below keeps its own confinement and best-effort skip semantics). */
if (config && config->write_devices) { if (config && config->write_devices) {
if (!privilege_super_mode_permitted(config->super_mode)) { 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, log_message(LOG_LEVEL_WARNING,
"write-devices: %s skipped: super-user activities are not permitted on this " "write-devices: %s skipped: super-user activities are not permitted on this "
"receiver", "receiver",
file->path ? file->path : "(null)"); escaped_path ? escaped_path : "(null)");
free(escaped_path);
return FILE_SAVE_SKIPPED; return FILE_SAVE_SKIPPED;
} }
return file_save_write_device(root_directory, file); return file_save_write_device(root_directory, file);
+5 -4
View File
@@ -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 * 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 * 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. */ * 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; unsigned long long i = 0;
while (i < size) { while (i < size) {
if (data[i] == 0) { 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 (fd >= 0) {
if (sparse && data_size > 0) { if (sparse && data_size > 0) {
if (ftruncate(fd, (off_t)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 { } else {
ok = write_all(fd, data, data_size); 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) if (sparse && data_size > 0)
ok = ftruncate(fd, (off_t)data_size) == 0; ok = ftruncate(fd, (off_t)data_size) == 0;
if (ok || (!sparse || data_size == 0)) if (ok || (!sparse || data_size == 0))
ok = (sparse && data_size > 0) ? write_all_sparse(fd, (const unsigned char*)data, data_size) ok = (sparse && data_size > 0)
: write_all(fd, data, data_size); ? file_store_write_sparse(fd, (const unsigned char*)data, data_size)
: write_all(fd, data, data_size);
if (ok && metadata) if (ok && metadata)
ok = file_restore_metadata_fd(fd, metadata, preserve_executability); ok = file_restore_metadata_fd(fd, metadata, preserve_executability);
if (close(fd) != 0) if (close(fd) != 0)
+7
View File
@@ -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 file_store_write_secure(const char* path, const void* data, unsigned long long data_size,
bool inplace, bool sparse, const FileMetadata* metadata, bool inplace, bool sparse, const FileMetadata* metadata,
bool preserve_executability); 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 #endif
+67 -58
View File
@@ -64,10 +64,10 @@ void identity_clear_active(void) {
identity_active_reset(); identity_active_reset();
} }
void identity_set_active(const Config* config) { bool identity_set_active(const Config* config) {
identity_active_reset(); identity_active_reset();
if (!config) if (!config)
return; return true;
g_identity.numeric_ids = config->numeric_ids; g_identity.numeric_ids = config->numeric_ids;
g_identity.chown_uid_set = config->chown_uid_set; g_identity.chown_uid_set = config->chown_uid_set;
g_identity.chown_uid = config->chown_uid; 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; g_identity.copy_as_gid = config->copy_as_gid;
if (config->usermap_count > 0) { if (config->usermap_count > 0) {
g_identity.usermap = calloc((size_t)config->usermap_count, sizeof(IdentityMap)); g_identity.usermap = calloc((size_t)config->usermap_count, sizeof(IdentityMap));
if (g_identity.usermap) { if (!g_identity.usermap)
memcpy(g_identity.usermap, config->usermap, goto alloc_failed;
(size_t)config->usermap_count * sizeof(IdentityMap)); memcpy(g_identity.usermap, config->usermap,
g_identity.usermap_count = config->usermap_count; (size_t)config->usermap_count * sizeof(IdentityMap));
} g_identity.usermap_count = config->usermap_count;
} }
if (config->groupmap_count > 0) { if (config->groupmap_count > 0) {
g_identity.groupmap = calloc((size_t)config->groupmap_count, sizeof(IdentityMap)); g_identity.groupmap = calloc((size_t)config->groupmap_count, sizeof(IdentityMap));
if (g_identity.groupmap) { if (!g_identity.groupmap)
memcpy(g_identity.groupmap, config->groupmap, goto alloc_failed;
(size_t)config->groupmap_count * sizeof(IdentityMap)); memcpy(g_identity.groupmap, config->groupmap,
g_identity.groupmap_count = config->groupmap_count; (size_t)config->groupmap_count * sizeof(IdentityMap));
} g_identity.groupmap_count = config->groupmap_count;
} }
g_identity.set = true; g_identity.set = true;
/* A root receiver would honor any client-supplied ownership request (a /* 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 " "--super requested but the receiver is not privileged; super-user "
"activities (ownership, device nodes) will be attempted but refused " "activities (ownership, device nodes) will be attempted but refused "
"by the kernel and skipped per entry"); "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) { bool privilege_super_permitted(void) {
@@ -440,6 +449,25 @@ static bool identity_id_fits_int32(unsigned long id) {
return id <= (unsigned long)INT32_MAX; 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) { int identity_parse_copy_as(Config* config, const char* value) {
if (!config || !value || *value == '\0') { if (!config || !value || *value == '\0') {
log_message(LOG_LEVEL_ERROR, "--copy-as requires USER[:GROUP]"); 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"); log_message(LOG_LEVEL_ERROR, "memory allocation failed for --copy-as");
return -1; return -1;
} }
char* user_token = spec; const char* user_token = spec;
const char* group_token = NULL; const char* group_token = NULL;
char* colon = strchr(spec, ':'); char* colon = strchr(spec, ':');
if (colon) { 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. */ * (8-bit-safe) so a control byte cannot forge a log line. */
char* escaped_spec = output_escape(value, false); char* escaped_spec = output_escape(value, false);
const char* shown = escaped_spec ? escaped_spec : "<allocation failed>"; const char* shown = escaped_spec ? escaped_spec : "<allocation failed>";
int ret = -1;
int32_t uid;
if (*user_token == '\0') { if (*user_token == '\0') {
log_message(LOG_LEVEL_ERROR, "--copy-as is missing the user (got '%s')", shown); log_message(LOG_LEVEL_ERROR, "--copy-as is missing the user (got '%s')", shown);
free(escaped_spec); goto done;
free(spec);
return -1;
} }
if (strcmp(user_token, "*") == 0) { bool overflow = false;
/* '*' means the current/root user: the client's euid. */ int32_t uid;
if (!identity_id_fits_int32((unsigned long)geteuid())) { 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", log_message(LOG_LEVEL_ERROR, "--copy-as: current user id %lu exceeds INT32_MAX",
(unsigned long)geteuid()); (unsigned long)geteuid());
free(escaped_spec); else
free(spec); log_message(LOG_LEVEL_ERROR,
return -1; "--copy-as could not resolve user (use a name that exists on the "
} "source, '*', or @N): %s",
uid = (int32_t)geteuid(); shown);
} else if (identity_resolve_token(user_token, false, &uid) != 0) { goto done;
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;
} }
int32_t gid; int32_t gid;
if (group_token) { if (group_token) {
if (*group_token == '\0') { if (*group_token == '\0') {
log_message(LOG_LEVEL_ERROR, "--copy-as group is empty (got '%s')", shown); log_message(LOG_LEVEL_ERROR, "--copy-as group is empty (got '%s')", shown);
free(escaped_spec); goto done;
free(spec);
return -1;
} }
if (strcmp(group_token, "*") == 0) { if (identity_resolve_copy_as_id(group_token, true, &gid, &overflow) != 0) {
if (!identity_id_fits_int32((unsigned long)getegid())) { if (overflow)
log_message(LOG_LEVEL_ERROR, "--copy-as: current group id %lu exceeds INT32_MAX", log_message(LOG_LEVEL_ERROR, "--copy-as: current group id %lu exceeds INT32_MAX",
(unsigned long)getegid()); (unsigned long)getegid());
free(escaped_spec); else
free(spec); log_message(LOG_LEVEL_ERROR, "--copy-as could not resolve group (got '%s')", shown);
return -1; goto done;
}
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 { } else {
/* Group omitted: use the user's primary gid. A numeric id with no local /* 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, log_message(LOG_LEVEL_ERROR,
"--copy-as: primary group id %lu for the requested user exceeds INT32_MAX", "--copy-as: primary group id %lu for the requested user exceeds INT32_MAX",
(unsigned long)pw->pw_gid); (unsigned long)pw->pw_gid);
free(escaped_spec); goto done;
free(spec);
return -1;
} }
gid = (int32_t)pw->pw_gid; gid = (int32_t)pw->pw_gid;
} else { } else {
@@ -553,12 +561,8 @@ int identity_parse_copy_as(Config* config, const char* value) {
* identity_resolve_token. */ * identity_resolve_token. */
if (uid < 0 || gid < 0) { if (uid < 0 || gid < 0) {
log_message(LOG_LEVEL_ERROR, "--copy-as resolved id does not fit in int32 (got '%s')", shown); log_message(LOG_LEVEL_ERROR, "--copy-as resolved id does not fit in int32 (got '%s')", shown);
free(escaped_spec); goto done;
free(spec);
return -1;
} }
free(escaped_spec);
free(spec);
config->copy_as_set = true; config->copy_as_set = true;
config->copy_as_uid = uid; 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 /* Ownership application needs the metadata path (the source uid/gid must be
* transmitted); imply it exactly like --chown/--usermap/--groupmap. */ * transmitted); imply it exactly like --chown/--usermap/--groupmap. */
config->use_metadata = true; config->use_metadata = true;
return 0; ret = 0;
done:
free(escaped_spec);
free(spec);
return ret;
} }
/* ---- Receiver-side ownership application ---- */ /* ---- Receiver-side ownership application ---- */
+7 -2
View File
@@ -67,8 +67,13 @@ bool identity_copy_as_active(void);
/* Receiver-side snapshot of the negotiated identity config. The server calls /* Receiver-side snapshot of the negotiated identity config. The server calls
* identity_set_active() once per connection (before any file write) using the * 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 * config received over the wire; the snapshot is a deep copy so the caller may
* free its Config immediately. identity_clear_active() releases it. */ * free its Config immediately. identity_clear_active() releases it.
void identity_set_active(const Config* config); *
* 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); void identity_clear_active(void);
/* True when any ownership-affecting identity option is present in the active /* True when any ownership-affecting identity option is present in the active
+4
View File
@@ -27,6 +27,10 @@ uint32_t get_log_debug_flags(void) {
return current_debug_flags; 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) { void set_log_info_flags(uint32_t flags) {
info_flags = flags; info_flags = flags;
info_flags_explicit = true; info_flags_explicit = true;
+4
View File
@@ -29,6 +29,10 @@ void log_perror(const char* context);
void set_log_level(LogLevel level); void set_log_level(LogLevel level);
void set_log_debug_flags(uint32_t flags); void set_log_debug_flags(uint32_t flags);
uint32_t get_log_debug_flags(void); 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 log_debug_message(LogDebugFlag flag, const char* message, ...);
void set_log_info_flags(uint32_t flags); void set_log_info_flags(uint32_t flags);
uint32_t get_log_info_flags(void); uint32_t get_log_info_flags(void);
+28 -10
View File
@@ -413,15 +413,24 @@ static const char* status_to_string(Status status) {
return "SPECIAL"; return "SPECIAL";
case STATUS_DIR_TIMES: case STATUS_DIR_TIMES:
return "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: default:
return "UNKNOWN"; return "UNKNOWN";
} }
} }
/* Shared string send/receive implementation. `redact` selects whether the /* Shared string send/receive implementation. `redact` selects whether the
* payload body is written to the LOG_DEBUG_PROTO debug log: secrets (daemon * payload body is written to the LOG_DEBUG_PROTO debug log: daemon auth material
* auth username/digest) set it so a --verbose log never captures a replayable * (the username and the proof/signature fields) sets it so a --verbose log never
* credential, while every other string keeps its normal debug trace. */ * 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) { static bool protocol_send_str_impl(ProtocolSession* session, const char* data, bool redact) {
if (data == NULL) if (data == NULL)
return false; return false;
@@ -430,10 +439,14 @@ static bool protocol_send_str_impl(ProtocolSession* session, const char* data, b
return false; return false;
if (!protocol_send_n_data(session, data, size)) if (!protocol_send_n_data(session, data, size))
return false; return false;
if (redact) if (redact) {
log_debug_message(LOG_DEBUG_PROTO, "Send String: <redacted>"); log_debug_message(LOG_DEBUG_PROTO, "Send String: <redacted>");
else } else if (log_debug_enabled(LOG_DEBUG_PROTO)) {
log_debug_message(LOG_DEBUG_PROTO, "Send String: %s", data); 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; return true;
} }
@@ -459,10 +472,14 @@ static char* protocol_receive_str_impl(ProtocolSession* session, bool redact) {
return NULL; return NULL;
} }
data[size] = '\0'; data[size] = '\0';
if (redact) if (redact) {
log_debug_message(LOG_DEBUG_PROTO, "Received String: <redacted>"); log_debug_message(LOG_DEBUG_PROTO, "Received String: <redacted>");
else } else if (log_debug_enabled(LOG_DEBUG_PROTO)) {
log_debug_message(LOG_DEBUG_PROTO, "Received String: %s", data); 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; return data;
} }
@@ -586,7 +603,8 @@ char* receive_str(int fd) {
return protocol_receive_str(legacy_session(fd, -1)); return protocol_receive_str(legacy_session(fd, -1));
} }
/* Redacted variants: identical framing, but the string body is never written to /* 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) { bool send_str_redacted(int fd, const char* data) {
return protocol_send_str_redacted(legacy_session(-1, fd), data); return protocol_send_str_redacted(legacy_session(-1, fd), data);
} }
+17 -3
View File
@@ -113,7 +113,20 @@ enum NET_STATUS {
* than MAX_MANIFEST_ENTRIES is split across repeated frames. The receiver * than MAX_MANIFEST_ENTRIES is split across repeated frames. The receiver
* defers the actual utimensat until its own delete/publish phase has * defers the actual utimensat until its own delete/publish phase has
* committed, then skips the whole set when -O/--omit-dir-times is set. */ * 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); 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); char* protocol_receive_str(ProtocolSession* session);
/* Redacted string variants: identical wire framing to protocol_send_str / /* Redacted string variants: identical wire framing to protocol_send_str /
* protocol_receive_str, but the payload body is replaced by `<redacted>` in the * 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 * LOG_DEBUG_PROTO debug log. Used for daemon auth material (the username and
* a --verbose log can never capture a replayable credential. */ * 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); bool protocol_send_str_redacted(ProtocolSession* session, const char* data);
char* protocol_receive_str_redacted(ProtocolSession* session); char* protocol_receive_str_redacted(ProtocolSession* session);
bool protocol_send_data(ProtocolSession* session, const Data* data); bool protocol_send_data(ProtocolSession* session, const Data* data);
+30 -40
View File
@@ -84,32 +84,29 @@ char* ssh_build_remote_command(const char* server_path, bool old_args, char* con
const char* suffix = " --stdio"; const char* suffix = " --stdio";
/* Each --remote-option=OPT is appended after " --stdio" as one shell word, /* 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 escaped with the SAME single-quote boundary used for the server path, so a
stays safe even in --old-args mode (which leaves the server path unquoted): value containing shell metacharacters (; & | ` $ ()) can never break out of
remote options are always single-quoted individually, so a value containing the quoting to inject an unrelated remote command. Values are already
shell metacharacters (; & | ` $ ()) can never break out of the quoting to validated at CLI parse time (non-empty, no control characters); this layer
inject an unrelated remote command. Values are already validated at CLI only adds the escaping boundary. */
parse time (non-empty, no control characters); this layer only adds the
escaping boundary. */
size_t path_len = strlen(path); size_t path_len = strlen(path);
size_t suffix_len = strlen(suffix); size_t suffix_len = strlen(suffix);
/* The base command (server path, quoted unless --old-args, then " --stdio"). */ /* The base command: the server path is ALWAYS quoted as one single-quoted
size_t command_len; shell word (remote options below reuse the same escaping), then
if (old_args) { " --stdio". Quoting the path is the only injection-safe construction: an
if (path_len > SIZE_MAX - suffix_len - 1) unquoted path would carry shell metacharacters straight into the remote
return NULL; shell command. --old-args is kept for CLI/ABI compatibility but no longer
command_len = path_len + suffix_len + 1; disables that protection. */
} else { (void)old_args;
size_t quote_count = 0; size_t quote_count = 0;
for (const char* p = path; *p; p++) for (const char* p = path; *p; p++)
if (*p == '\'') if (*p == '\'')
quote_count++; quote_count++;
if (path_len > SIZE_MAX - suffix_len - 4 || if (path_len > SIZE_MAX - suffix_len - 4 ||
quote_count > (SIZE_MAX - path_len - suffix_len - 4) / 4) quote_count > (SIZE_MAX - path_len - suffix_len - 4) / 4)
return NULL; return NULL;
command_len = path_len + quote_count * 4 + suffix_len + 4; size_t command_len = path_len + quote_count * 4 + suffix_len + 4;
}
/* Add each remote option, escaped as one single-quoted word: /* Add each remote option, escaped as one single-quoted word:
" '<body>'", i.e. 1 leading space + 1 open quote + body (len + 3 per " '<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) if (!command)
return NULL; return NULL;
char* out = command; char* out = command;
if (old_args) { *out++ = '\'';
memcpy(out, path, path_len); for (const char* p = path; *p; p++) {
out += path_len; if (*p == '\'') {
memcpy(out, suffix, suffix_len + 1); memcpy(out, "'\\''", 4);
out += suffix_len; out += 4;
} else { } else {
*out++ = '\''; *out++ = *p;
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++) { for (int i = 0; i < remote_option_count; i++) {
const char* opt = remote_options[i]; const char* opt = remote_options[i];
*out++ = ' '; *out++ = ' ';
+6 -3
View File
@@ -6,10 +6,13 @@
Client* client_connect_ssh(const char* destination, int port, const char* server_path, Client* client_connect_ssh(const char* destination, int port, const char* server_path,
bool old_args, const char* rsh_command, bool blocking_io, bool old_args, const char* rsh_command, bool blocking_io,
char* const* remote_options, int remote_option_count); char* const* remote_options, int remote_option_count);
/* Build the escaped remote-shell command string (the server program path quoted /* Build the escaped remote-shell command string (the server program path always
* as one remote-shell word unless --old-args, followed by ` --stdio` and each * quoted as one remote-shell word, followed by ` --stdio` and each
* --remote-option value appended as an individually single-quoted shell word). * --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. */ * 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, char* ssh_build_remote_command(const char* server_path, bool old_args, char* const* remote_options,
int remote_option_count); int remote_option_count);
+9
View File
@@ -48,6 +48,15 @@ static SSL_CTX* create_ssl_ctx(bool is_server, const char* cert, const char* key
return NULL; 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) { if (SSL_CTX_set_min_proto_version(ctx, TLS1_2_VERSION) != 1) {
SSL_CTX_free(ctx); SSL_CTX_free(ctx);
return NULL; return NULL;
+69
View File
@@ -1,13 +1,16 @@
#include "utils.h" #include "utils.h"
#include "array_list.h" #include "array_list.h"
#include "log.h" #include "log.h"
#include <arpa/inet.h>
#include <dirent.h> #include <dirent.h>
#include <errno.h> #include <errno.h>
#include <fcntl.h> #include <fcntl.h>
#include <netinet/in.h>
#include <stdio.h> #include <stdio.h>
#include <stdlib.h> #include <stdlib.h>
#include <string.h> #include <string.h>
#include <stdint.h> #include <stdint.h>
#include <sys/socket.h>
#include <sys/stat.h> #include <sys/stat.h>
#include <unistd.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; *tail_out = check_size - old_size;
return true; 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);
}
+11
View File
@@ -4,6 +4,7 @@
#include "array_list.h" #include "array_list.h"
#include <stddef.h> #include <stddef.h>
#include <stdbool.h> #include <stdbool.h>
#include <sys/socket.h>
char* str_dup(const char* string); char* str_dup(const char* string);
char* output_escape(const char* string, bool eight_bit_output); 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_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, bool append_tail_length(unsigned long long old_size, unsigned long long check_size,
unsigned long long* tail_out); 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 #endif
+346
View File
@@ -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;
}
+58
View File
@@ -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;
}
+388 -26
View File
@@ -5,18 +5,25 @@ started with --daemon reads a FastSync-native module config file, the client
asks for a module with a host::module/path destination, and the transfer lands 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 in the configured module root only. Read-only modules, unknown modules, and
auth-required modules without valid credentials are all refused cleanly before auth-required modules without valid credentials are all refused cleanly before
any data moves. Wave B (daemon authentication) adds the real credential any data moves. The A7 auth wave adds the real credential round-trips exercised
round-trips exercised in TestDaemonAuthentication: modules that declare in TestDaemonAuthentication: modules that declare `auth users` accept only a
`auth users` accept only a client whose --password-file presents a username on client whose --password-file presents a username on the module's list, proven
the module's list with a matching password (verified as a SHA-256 digest), and through a SCRAM-SHA-256-style challenge/response against a salted PBKDF2
the daemon refuses to start when such a module has no credential store. 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 glob
import hashlib import hashlib
import hmac
import os import os
import select
import shutil import shutil
import signal import signal
import socket
import stat import stat
import struct
import subprocess import subprocess
import sys import sys
import tempfile import tempfile
@@ -58,14 +65,45 @@ ALICE_PASS = "alice-s3cret"
BOB_PASS = "bob-s3cret" BOB_PASS = "bob-s3cret"
WRONG_PASS = "wrong-password" 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): def _write_client_password_file(path, user, password):
with open(path, "w") as f: with open(path, "w") as f:
f.write("%s:%s\n" % (user, password)) f.write("%s:%s\n" % (user, password))
os.chmod(path, 0o600)
return path return path
@@ -158,12 +196,13 @@ def daemon_env():
os.makedirs(d, exist_ok=True) os.makedirs(d, exist_ok=True)
generate_test_files(SOURCE_DIR, full=False) generate_test_files(SOURCE_DIR, full=False)
# Server-side credential store: alice and bob (password digests only; the # Server-side credential store: alice and bob (salted PBKDF2 verifiers only;
# plaintext passwords never appear on the daemon host or in any log). # the plaintext passwords never appear on the daemon host or in any log).
with open(CRED_FILE, "w") as f: with open(CRED_FILE, "w") as f:
f.write("# daemon credential store (Wave B)\n") f.write("# daemon credential store (A7 SCRAM)\n")
f.write("alice:%s\n" % _pw_hash(ALICE_PASS)) f.write(ALICE_LINE + "\n")
f.write("bob:%s\n" % _pw_hash(BOB_PASS)) f.write(BOB_LINE + "\n")
os.chmod(CRED_FILE, 0o600)
# The config's port is a free port chosen per worker; the `daemon` fixture # 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 # boots on it (the config-port path) and the --dparam override test boots a
@@ -381,6 +420,27 @@ class TestDaemonRejection:
refusal happens at the config handshake, before any data lands.""" refusal happens at the config handshake, before any data lands."""
self._assert_ownership_refused(daemon, "files", ["--super", "--preserve"]) 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")
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): def test_numeric_ids_refused_by_daemon(self, daemon):
"""P7 Wave E hardening (A1): the daemon ownership gate must cover the """P7 Wave E hardening (A1): the daemon ownership gate must cover the
pre-existing identity flags too, not only --copy-as/--super. A module pre-existing identity flags too, not only --copy-as/--super. A module
@@ -495,6 +555,139 @@ class TestDaemonRejection:
d.stop() 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: class TestDaemonAuthentication:
"""Wave B password authentication round-trips on the shared daemon (its """Wave B password authentication round-trips on the shared daemon (its
config declares `locked` with `auth users = alice` and `team` with config declares `locked` with `auth users = alice` and `team` with
@@ -577,11 +770,67 @@ class TestDaemonAuthentication:
finally: finally:
os.unlink(cred_path) 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): def test_client_empty_password_file_rejected(self):
"""Client-side: an empty --password-file is rejected (no credentials).""" """Client-side: an empty --password-file is rejected (no credentials)."""
cred_path = os.path.join(TEST_DATA_DIR, "client_empty.pw") cred_path = os.path.join(TEST_DATA_DIR, "client_empty.pw")
with open(cred_path, "w") as f: with open(cred_path, "w") as f:
f.write("# nothing here\n") f.write("# nothing here\n")
os.chmod(cred_path, 0o600)
try: try:
cmd = CLIENT_CMD + ["--source-dir", SOURCE_DIR, cmd = CLIENT_CMD + ["--source-dir", SOURCE_DIR,
"--dest-dir", "127.0.0.1::files", "--dest-dir", "127.0.0.1::files",
@@ -616,8 +865,63 @@ class TestDaemonAuthentication:
finally: finally:
d.stop() 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): 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") 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 = 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) _push_with_creds("127.0.0.1::locked", daemon.port, "alice", WRONG_PASS)
@@ -628,15 +932,15 @@ class TestDaemonAuthentication:
tail = f.read().decode("utf-8", "replace") tail = f.read().decode("utf-8", "replace")
assert ALICE_PASS not in tail assert ALICE_PASS not in tail
assert WRONG_PASS not in tail assert WRONG_PASS not in tail
assert _pw_hash(ALICE_PASS) not in tail for secret in _store_secrets(ALICE_LINE):
assert _pw_hash(WRONG_PASS) not in tail 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 """Under --verbose the daemon enables LOG_DEBUG_ALL, which normally
traces every protocol string -- the auth username/digest must NOT leak traces every protocol string -- the auth username/proof/signature must
into that trace even then. The redacted marker is logged instead, and NOT leak into that trace even then. The redacted marker is logged
the digest/username/password never appear while debug protocol logging instead, while debug protocol logging is actually proving itself active."""
is actually proving itself active."""
d = DaemonManager() d = DaemonManager()
port = _find_free_port() port = _find_free_port()
try: try:
@@ -656,8 +960,9 @@ class TestDaemonAuthentication:
# The secret-worthy fields must never appear, at any log level. # The secret-worthy fields must never appear, at any log level.
assert ALICE_PASS not in log assert ALICE_PASS not in log
assert WRONG_PASS not in log assert WRONG_PASS not in log
assert _pw_hash(ALICE_PASS) not in log for secret in _store_secrets(ALICE_LINE):
assert _pw_hash(WRONG_PASS) not in log assert secret not in log
assert "$fastsync$" not in log
class TestDaemonMotd: class TestDaemonMotd:
@@ -755,23 +1060,30 @@ class TestDaemonMotd:
d.stop() d.stop()
def _generate_tls_certs(cert_dir): def _generate_tls_certs(cert_dir, extra_san_ips=None):
"""Generate a self-signed CA, server cert (with 127.0.0.1 SAN) and a client """Generate a self-signed CA, server cert (with 127.0.0.1 SAN plus any
cert signed by that CA, for the TLS+auth composition test.""" 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) 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") 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_key = os.path.join(cert_dir, "server.key")
server_cert = os.path.join(cert_dir, "server.pem") server_cert = os.path.join(cert_dir, "server.pem")
client_key = os.path.join(cert_dir, "client.key") client_key = os.path.join(cert_dir, "client.key")
client_cert = os.path.join(cert_dir, "client.pem") 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", subprocess.run(["openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes",
"-keyout", ca_key, "-out", ca_cert, "-days", "1", "-keyout", ca_key, "-out", ca_cert, "-days", "1",
"-subj", "/CN=FastSync Test CA"], check=True, capture_output=True) "-subj", "/CN=FastSync Test CA"], check=True, capture_output=True)
san = os.path.join(cert_dir, "san.conf") 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: with open(san, "w") as f:
f.write("[req]\ndistinguished_name = dn\nreq_extensions = v3_req\n\n" f.write("[req]\ndistinguished_name = dn\nreq_extensions = v3_req\n\n"
"[dn]\nCN = localhost\n\n[v3_req]\nsubjectAltName = @an\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", subprocess.run(["openssl", "req", "-newkey", "rsa:2048", "-nodes",
"-keyout", server_key, "-out", os.path.join(cert_dir, "server.csr"), "-keyout", server_key, "-out", os.path.join(cert_dir, "server.csr"),
"-subj", "/CN=localhost", "-config", san], check=True, capture_output=True) "-subj", "/CN=localhost", "-config", san], check=True, capture_output=True)
@@ -785,12 +1097,20 @@ def _generate_tls_certs(cert_dir):
subprocess.run(["openssl", "x509", "-req", "-in", os.path.join(cert_dir, "client.csr"), subprocess.run(["openssl", "x509", "-req", "-in", os.path.join(cert_dir, "client.csr"),
"-CA", ca_cert, "-CAkey", ca_key, "-CAcreateserial", "-CA", ca_cert, "-CAkey", ca_key, "-CAcreateserial",
"-out", client_cert, "-days", "1"], check=True, capture_output=True) "-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 { return {
"ca": ca_cert, "ca": ca_cert,
"server_cert": server_cert, "server_cert": server_cert,
"server_key": server_key, "server_key": server_key,
"client_cert": client_cert, "client_cert": client_cert,
"client_key": client_key, "client_key": client_key,
"wrong_client_cert": wrong_client_cert,
"wrong_client_key": wrong_client_key,
} }
@@ -831,3 +1151,45 @@ class TestDaemonTLSAuth:
d.stop() d.stop()
os.unlink(client_creds) os.unlink(client_creds)
shutil.rmtree(cert_dir, ignore_errors=True) 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)
+3 -3
View File
@@ -94,14 +94,14 @@ def _seed_protocol_source(source):
class TestProtocol: class TestProtocol:
@pytest.mark.ci @pytest.mark.ci
def test_protocol_current_version_accepted(self, shared_server): 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.""" transfer completes normally."""
source = os.path.join(TEST_DATA_DIR, "proto_ok_src") source = os.path.join(TEST_DATA_DIR, "proto_ok_src")
dest = os.path.join(TEST_DATA_DIR, "proto_ok_dst") dest = os.path.join(TEST_DATA_DIR, "proto_ok_dst")
shutil.rmtree(dest, ignore_errors=True) shutil.rmtree(dest, ignore_errors=True)
os.makedirs(dest) os.makedirs(dest)
_seed_protocol_source(source) _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) port=shared_server.port)
assert result.returncode == 0, \ assert result.returncode == 0, \
f"--protocol current run failed: {(result.stderr or result.stdout)[:400]}" f"--protocol current run failed: {(result.stderr or result.stdout)[:400]}"
@@ -118,7 +118,7 @@ class TestProtocol:
shutil.rmtree(dest, ignore_errors=True) shutil.rmtree(dest, ignore_errors=True)
os.makedirs(dest) os.makedirs(dest)
_seed_protocol_source(source) _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}"], result, _ = run_client(source, dest, flags=[f"--protocol={bad}"],
port=shared_server.port) port=shared_server.port)
assert result.returncode != 0, f"--protocol={bad} should be rejected" assert result.returncode != 0, f"--protocol={bad} should be rejected"
+67 -13
View File
@@ -69,6 +69,42 @@ static void test_validate_config_tls_requirements() {
config_delete(cfg); 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() { static void test_validate_config_delta_sendfile_constraints() {
Config* cfg = valid_client_config(); Config* cfg = valid_client_config();
cfg->use_delta = true; cfg->use_delta = true;
@@ -104,18 +140,18 @@ static void test_cli_help() {
config_delete(cfg); config_delete(cfg);
} }
/* Test that --archive's config bundle matches rsync -rlptgoD semantics: /* Test that the -a short spelling applies --archive's config bundle, matching
* links + metadata + devices + specials, and NOT compression/multithreading. */ * 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() { static void test_cli_archive_flags() {
Config* cfg = config_create(); Config* cfg = config_create();
EXPECT_NOT_NULL(cfg); 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. */ EXPECT_EQ_INT(parse_args(cfg, 4, argv, positional_args, &positional_count), 0);
cfg->follow_symlinks = true;
cfg->use_metadata = true;
cfg->preserve_devices = true;
cfg->preserve_specials = true;
EXPECT_TRUE(cfg->follow_symlinks); EXPECT_TRUE(cfg->follow_symlinks);
EXPECT_TRUE(cfg->use_metadata); EXPECT_TRUE(cfg->use_metadata);
EXPECT_TRUE(cfg->preserve_devices); EXPECT_TRUE(cfg->preserve_devices);
@@ -223,7 +259,7 @@ static void test_parse_args_protocol_accept_current() {
Config* cfg = valid_client_config(); Config* cfg = valid_client_config();
EXPECT_NOT_NULL(cfg); EXPECT_NOT_NULL(cfg);
char* argv_equals[] = {"fastsync", "--source-dir", "/src", 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_args[2];
int positional_count = 0; int positional_count = 0;
EXPECT_EQ_INT(parse_args(cfg, 6, argv_equals, positional_args, &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(); cfg = valid_client_config();
EXPECT_NOT_NULL(cfg); EXPECT_NOT_NULL(cfg);
char* argv_space[] = {"fastsync", "--source-dir", "/src", "--dest-dir", char* argv_space[] = {"fastsync", "--source-dir", "/src", "--dest-dir",
"/dst", "--protocol", "2.18.0"}; "/dst", "--protocol", "2.19.0"};
positional_count = 0; positional_count = 0;
EXPECT_EQ_INT(parse_args(cfg, 7, argv_space, positional_args, &positional_count), 0); EXPECT_EQ_INT(parse_args(cfg, 7, argv_space, positional_args, &positional_count), 0);
EXPECT_EQ_STR(cfg->version, PROTOCOL_VERSION); 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 /* Any --protocol value other than the current PROTOCOL_VERSION must end in
* failure (parse_args simply stores it; validate_config rejects it up front). */ * failure (parse_args simply stores it; validate_config rejects it up front). */
static void test_parse_args_protocol_rejects_other_versions() { 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", static const char* const bad_versions[] = {"2.17", "2.16", "2.15.0", "2.16.0", "2.17.0",
"216", "31", "abc", ""}; "2.18.0", "216", "31", "abc", ""};
for (size_t i = 0; i < sizeof(bad_versions) / sizeof(bad_versions[0]); i++) { for (size_t i = 0; i < sizeof(bad_versions) / sizeof(bad_versions[0]); i++) {
Config* cfg = valid_client_config(); Config* cfg = valid_client_config();
EXPECT_NOT_NULL(cfg); 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(parse_args(cfg, 4, argv_eq, positional_args, &positional_count), 0);
EXPECT_EQ_INT((int)cfg->delta_block_size, 8192); 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; cfg->delta_block_size = DELTA_BLOCK_SIZE_DEFAULT;
char* argv_bad[] = {"fastsync", "--block-size", "1", "/src", "/dst"}; char* argv_bad[] = {"fastsync", "--block-size", "1", "/src", "/dst"};
positional_count = 0; positional_count = 0;
EXPECT_EQ_INT(parse_args(cfg, 5, argv_bad, positional_args, &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); 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 /* A non-default block size changes the number of signature blocks for
identical data: block_count = ceil(size / block_size). */ identical data: block_count = ceil(size / block_size). */
const char data[10000] = {0}; const char data[10000] = {0};
@@ -3100,6 +3153,7 @@ void test_client_cli() {
test_validate_config_append_verify_rejects_whole_file(); test_validate_config_append_verify_rejects_whole_file();
test_validate_config_incompatible_options(); test_validate_config_incompatible_options();
test_validate_config_tls_requirements(); test_validate_config_tls_requirements();
test_validate_config_credentials_require_tls_or_loopback();
test_validate_config_delta_sendfile_constraints(); test_validate_config_delta_sendfile_constraints();
test_cli_help(); test_cli_help();
test_cli_archive_flags(); test_cli_archive_flags();
+13 -16
View File
@@ -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 /* Daemon auth credentials (A7, protocol 2.19.0) ride the config frame as the
* hex digest are present together, or both are absent. Round-trip a present * username ONLY; the literal password never crosses the wire. Round-trip a
* pair. */ * present username. */
static void test_config_daemon_auth_wire_roundtrip() { static void test_config_daemon_auth_wire_roundtrip() {
Config* send_cfg = config_create(); Config* send_cfg = config_create();
EXPECT_NOT_NULL(send_cfg); 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->receive_root_directory = str_dup("rel/path");
send_cfg->module = str_dup("backup"); send_cfg->module = str_dup("backup");
send_cfg->auth_user = str_dup("alice"); send_cfg->auth_user = str_dup("alice");
send_cfg->auth_password_hash = send_cfg->auth_password = str_dup("alice-s3cret");
str_dup("9b90e524e94995ee4aeae2ee3c428a53405d1e8db147f44facc46797d0caf4c3");
int p[2]; int p[2];
EXPECT_EQ_INT(socketpair(AF_UNIX, SOCK_STREAM, 0, p), 0); 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]); close(p[1]);
io_set_fds(p[0], p[0]); io_set_fds(p[0], p[0]);
Config* recv_cfg = config_receive(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 && 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_user, "alice") == 0 && recv_cfg->auth_password == NULL;
strcmp(recv_cfg->auth_password_hash,
"9b90e524e94995ee4aeae2ee3c428a53405d1e8db147f44facc46797d0caf4c3") == 0;
config_delete(recv_cfg); config_delete(recv_cfg);
close(p[0]); close(p[0]);
_exit(ok ? 0 : 1); _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 * is refused (config_receive returns NULL), so a hostile peer cannot slip a
* garbage credential past the receive guard into the module gate. */ * garbage credential past the receive guard into the module gate. */
static void test_config_daemon_auth_wire_rejects_malformed() { 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->send_directory = str_dup("/src");
send_cfg->receive_root_directory = str_dup("/dst"); send_cfg->receive_root_directory = str_dup("/dst");
send_cfg->module = str_dup("m"); send_cfg->module = str_dup("m");
send_cfg->auth_user = str_dup("alice"); send_cfg->auth_user = str_dup("bad user");
send_cfg->auth_password_hash = str_dup("not-a-valid-sha256-hex-digest!!");
int p[2]; int p[2];
EXPECT_EQ_INT(socketpair(AF_UNIX, SOCK_STREAM, 0, p), 0); 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(); Config* c = config_create();
EXPECT_NOT_NULL(c); EXPECT_NOT_NULL(c);
c->super_mode = SUPER_MODE_OFF; c->super_mode = SUPER_MODE_OFF;
identity_set_active(c); EXPECT_TRUE(identity_set_active(c));
EXPECT_FALSE(privilege_super_permitted()); EXPECT_FALSE(privilege_super_permitted());
c->super_mode = SUPER_MODE_ON; c->super_mode = SUPER_MODE_ON;
identity_set_active(c); EXPECT_TRUE(identity_set_active(c));
EXPECT_TRUE(privilege_super_permitted()); EXPECT_TRUE(privilege_super_permitted());
c->super_mode = SUPER_MODE_AUTO; c->super_mode = SUPER_MODE_AUTO;
identity_set_active(c); EXPECT_TRUE(identity_set_active(c));
EXPECT_TRUE(privilege_super_permitted()); EXPECT_TRUE(privilege_super_permitted());
config_delete(c); config_delete(c);
@@ -1952,10 +1949,10 @@ static void test_super_does_not_imply_numeric() {
EXPECT_NOT_NULL(c); EXPECT_NOT_NULL(c);
c->super_mode = SUPER_MODE_ON; c->super_mode = SUPER_MODE_ON;
c->use_metadata = true; c->use_metadata = true;
identity_set_active(c); EXPECT_TRUE(identity_set_active(c));
EXPECT_FALSE(identity_active_enabled()); EXPECT_FALSE(identity_active_enabled());
c->numeric_ids = true; c->numeric_ids = true;
identity_set_active(c); EXPECT_TRUE(identity_set_active(c));
EXPECT_TRUE(identity_active_enabled()); EXPECT_TRUE(identity_active_enabled());
identity_clear_active(); identity_clear_active();
config_delete(c); config_delete(c);
+840 -133
View File
File diff suppressed because it is too large. Load diff
+121
View File
@@ -1370,6 +1370,126 @@ static void test_dir_time_list() {
rmdir(root); 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() { void test_file() {
test_file_create(); test_file_create();
test_file_special_rdev_valid(); test_file_special_rdev_valid();
@@ -1411,6 +1531,7 @@ void test_file() {
} }
test_file_metadata_create(); test_file_metadata_create();
test_dir_time_list(); test_dir_time_list();
test_keep_dirlinks_secure_open();
test_inplace_overwrite_clears_special_mode_bits(); test_inplace_overwrite_clears_special_mode_bits();
test_inplace_overwrite_metadata_strips_special_bits(); test_inplace_overwrite_metadata_strips_special_bits();
test_inplace_overwrite_truncates_shorter_payload(); test_inplace_overwrite_truncates_shorter_payload();
+277
View File
@@ -1,16 +1,24 @@
#include "test_fuzz_smoke.h" #include "test_fuzz_smoke.h"
#include "chunk.h" #include "chunk.h"
#include "compression.h" #include "compression.h"
#include "config.h"
#include "data.h" #include "data.h"
#include "delta.h" #include "delta.h"
#include "metadata.h" #include "metadata.h"
#include "protocol.h"
#include "test_utils.h" #include "test_utils.h"
#include "utils.h" #include "utils.h"
#include <errno.h>
#include <limits.h>
#include <stdint.h> #include <stdint.h>
#include <stdlib.h> #include <stdlib.h>
#include <string.h> #include <string.h>
#include <sys/socket.h>
#include <unistd.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 */ /* Smoke test for chunk_deserialize fuzz target */
static void test_fuzz_chunk_deserialize() { static void test_fuzz_chunk_deserialize() {
/* Create a minimal valid chunk to serialize and 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")); 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() { void test_fuzz_smoke() {
test_fuzz_chunk_deserialize(); test_fuzz_chunk_deserialize();
test_fuzz_compress_decompress(); test_fuzz_compress_decompress();
@@ -177,4 +451,7 @@ void test_fuzz_smoke() {
test_fuzz_metadata_from_buf(); test_fuzz_metadata_from_buf();
test_fuzz_delta_signature_deserialize(); test_fuzz_delta_signature_deserialize();
test_fuzz_glob_match(); test_fuzz_glob_match();
test_fuzz_config_receive_p8_tail();
test_fuzz_config_receive_huge_map_count();
test_fuzz_config_receive_version_gate();
} }
+20
View File
@@ -128,6 +128,25 @@ static void test_log_message_formats() {
EXPECT_TRUE(true); 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() { void test_log() {
test_log_message_debug(); test_log_message_debug();
test_log_message_info(); test_log_message_info();
@@ -139,4 +158,5 @@ void test_log() {
test_log_filtering(); test_log_filtering();
test_log_stderr_mode_all(); test_log_stderr_mode_all();
test_log_message_formats(); test_log_message_formats();
test_log_debug_enabled_matches_gate();
} }
+38
View File
@@ -2,6 +2,7 @@
#include "config.h" #include "config.h"
#include "delta.h" #include "delta.h"
#include "file.h" #include "file.h"
#include "log.h"
#include "protocol.h" #include "protocol.h"
#include "test_utils.h" #include "test_utils.h"
#include "utils.h" #include "utils.h"
@@ -724,7 +725,44 @@ static void test_receiver_pending_commits_missing_args() {
free(root); 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() { void test_server() {
test_special_socket_path_log_escaped();
if (!is_running_under_valgrind()) { if (!is_running_under_valgrind()) {
test_receive_files_finished(); test_receive_files_finished();
test_receive_files_single_file(); test_receive_files_single_file();
+13 -6
View File
@@ -164,19 +164,26 @@ static void test_server_cli_invalid() {
} }
static void test_server_cli_password_and_early_input() { static void test_server_cli_password_and_early_input() {
const char* args[] = {"s", "--daemon", "--password-file=/etc/fast.pw", "--early-input", const char* args[] = {"s",
"/run/secrets"}; "--daemon",
"--password-file=/etc/fast.pw",
"--early-input",
"/run/secrets",
"--iconv=utf-8"};
ServerCliOptions opts; 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.password_file, "/etc/fast.pw");
EXPECT_EQ_STR(opts.early_input_file, "/run/secrets"); 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", const char* args2[] = {
"--early-input=/secrets"}; "s", "--daemon", "--password-file", "/etc/fast.pw", "--early-input=/secrets",
"--iconv", "utf-8,iso-8859-1"};
ServerCliOptions opts2; 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.password_file, "/etc/fast.pw");
EXPECT_EQ_STR(opts2.early_input_file, "/secrets"); 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(&opts);
server_cli_options_free(&opts2); server_cli_options_free(&opts2);
} }
+88
View File
@@ -2,12 +2,15 @@
#include "utils.h" #include "utils.h"
#include "protocol.h" #include "protocol.h"
#include "test_utils.h" #include "test_utils.h"
#include <arpa/inet.h>
#include <dirent.h> #include <dirent.h>
#include <errno.h> #include <errno.h>
#include <fcntl.h> #include <fcntl.h>
#include <netinet/in.h>
#include <stdio.h> #include <stdio.h>
#include <stdlib.h> #include <stdlib.h>
#include <string.h> #include <string.h>
#include <sys/socket.h>
#include <sys/stat.h> #include <sys/stat.h>
#include <threads.h> #include <threads.h>
#include <unistd.h> #include <unistd.h>
@@ -269,12 +272,97 @@ static int escape_thread(void* arg) {
return 0; 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() { void test_shared_utils() {
test_walker_removes_extras_keeps_manifest_and_protected(); test_walker_removes_extras_keeps_manifest_and_protected();
test_walker_max_delete_exceeded_deletes_nothing(); test_walker_max_delete_exceeded_deletes_nothing();
test_walker_max_delete_exact_bound_deletes(); test_walker_max_delete_exact_bound_deletes();
test_walker_unlimited_deletes_all(); test_walker_unlimited_deletes_all();
test_walker_hard_bound_all_or_nothing(); test_walker_hard_bound_all_or_nothing();
test_loopback_helpers();
/* --append / --append-verify tail-resume math: a resume is eligible only for /* --append / --append-verify tail-resume math: a resume is eligible only for
a shorter existing destination, and the tail length is then the difference. */ a shorter existing destination, and the tail length is then the difference. */
+9 -3
View File
@@ -55,8 +55,14 @@ static void test_ssh_remote_command_argument_modes() {
EXPECT_EQ_STR(command, "'fast'\\''sync' --stdio"); EXPECT_EQ_STR(command, "'fast'\\''sync' --stdio");
free(command); 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); 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); free(command);
} }
@@ -131,9 +137,9 @@ static void test_ssh_remote_command_with_remote_options() {
free(command); free(command);
free(val); 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); 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); free(command);
} }
+7
View File
@@ -3,6 +3,7 @@
#include "test_utils.h" #include "test_utils.h"
#include "transport_tcp.h" #include "transport_tcp.h"
#include "transport_tls.h" #include "transport_tls.h"
#include <openssl/ssl.h>
#include <string.h> #include <string.h>
#include <unistd.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); bool ok = server_create_tls(s, NULL, NULL, NULL);
EXPECT_TRUE(ok); EXPECT_TRUE(ok);
EXPECT_NOT_NULL(s->ssl_ctx); 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); server_delete(&s);
EXPECT_NULL(s); EXPECT_NULL(s);
} }
+4 -4
View File
@@ -311,7 +311,7 @@ static void test_fake_super_owner_gate() {
/* --no-super: the owner leg is skipped even as root. */ /* --no-super: the owner leg is skipped even as root. */
c->super_mode = SUPER_MODE_OFF; c->super_mode = SUPER_MODE_OFF;
identity_set_active(c); EXPECT_TRUE(identity_set_active(c));
EXPECT_TRUE(fake_super_restore_fd(fd)); EXPECT_TRUE(fake_super_restore_fd(fd));
struct stat st; struct stat st;
EXPECT_EQ_INT(fstat(fd, &st), 0); EXPECT_EQ_INT(fstat(fd, &st), 0);
@@ -320,7 +320,7 @@ static void test_fake_super_owner_gate() {
/* AUTO with an identity policy: the recorded source owner is applied. */ /* AUTO with an identity policy: the recorded source owner is applied. */
c->super_mode = SUPER_MODE_AUTO; c->super_mode = SUPER_MODE_AUTO;
identity_set_active(c); EXPECT_TRUE(identity_set_active(c));
EXPECT_TRUE(fake_super_restore_fd(fd)); EXPECT_TRUE(fake_super_restore_fd(fd));
EXPECT_EQ_INT(fstat(fd, &st), 0); EXPECT_EQ_INT(fstat(fd, &st), 0);
EXPECT_EQ_INT((int)st.st_uid, 12345); EXPECT_EQ_INT((int)st.st_uid, 12345);
@@ -331,7 +331,7 @@ static void test_fake_super_owner_gate() {
EXPECT_EQ_INT(fchown(fd, 0, 0), 0); EXPECT_EQ_INT(fchown(fd, 0, 0), 0);
c->numeric_ids = false; c->numeric_ids = false;
c->super_mode = SUPER_MODE_ON; c->super_mode = SUPER_MODE_ON;
identity_set_active(c); EXPECT_TRUE(identity_set_active(c));
EXPECT_TRUE(fake_super_restore_fd(fd)); EXPECT_TRUE(fake_super_restore_fd(fd));
EXPECT_EQ_INT(fstat(fd, &st), 0); EXPECT_EQ_INT(fstat(fd, &st), 0);
EXPECT_EQ_INT((int)st.st_uid, 0); EXPECT_EQ_INT((int)st.st_uid, 0);
@@ -342,7 +342,7 @@ static void test_fake_super_owner_gate() {
c->copy_as_set = true; c->copy_as_set = true;
c->copy_as_uid = 777; c->copy_as_uid = 777;
c->copy_as_gid = 778; c->copy_as_gid = 778;
identity_set_active(c); EXPECT_TRUE(identity_set_active(c));
EXPECT_TRUE(fake_super_restore_fd(fd)); EXPECT_TRUE(fake_super_restore_fd(fd));
EXPECT_EQ_INT(fstat(fd, &st), 0); EXPECT_EQ_INT(fstat(fd, &st), 0);
EXPECT_EQ_INT((int)st.st_uid, 0); EXPECT_EQ_INT((int)st.st_uid, 0);