Merge feat/a7-auth: SCRAM-SHA-256 daemon auth replacing replayable static digest

This commit is contained in:
2026-09-12 18:08:56 +02:00
18 changed files with 2037 additions and 484 deletions
+15 -1
View File
@@ -507,13 +507,27 @@ defaults to the current directory. |
## Protocol and Security
FastSync protocol version `2.18.0` is shared by the client and server. The
FastSync protocol version `2.19.0` is shared by the client and server. The
current protocol is sender-driven and includes configuration negotiation,
including the maximum allocation limit, incremental checks, checksums,
manifests, keep-alives, abort handling, per-file remove-source results, and
FastSync-native delta messages.
Client and server versions must currently match exactly.
Daemon modules that declare `auth users` authenticate with a SCRAM-SHA-256-style
challenge/response against a salted PBKDF2 verifier store: no password and no
replayable bearer credential crosses the wire or is stored on the daemon. All
store entries share one iteration count, and an unknown user is answered with a
deterministic per-username dummy challenge, so probing the daemon cannot
enumerate users. Store lines are generated with
`fastsync-server --hash-credentials <plaintext-file>` (see `RSYNC_COMPAT.md`);
redirect that output to an owner-only (mode 0600) file, and note that legacy
`user:SHA256HEX` stores are rejected. Two residuals are accepted: the dummy salt
is stable within one daemon lifetime but changes across restarts, so a
restart-gated enumeration channel remains (persisting a dummy key is out of
scope); and the store iteration count is observable pre-auth by design, since
the miss path must match a hit.
TLS provides encrypted TCP transport. Supplying `--ca` enables certificate
verification; without it, traffic is encrypted but peer identity is not
verified. Use certificate verification for deployments where authentication
+12 -11
View File
@@ -629,22 +629,23 @@ now transmits targets (the prior behavior was broken/partial); its status moved
| `--config=FILE` | Alternate rsyncd.conf file | ✅ Implemented | Wave A: selects the daemon config file. Default when omitted (in `--daemon` mode): `~/.config/fastsync/fastsyncd.conf` if it exists, else `/etc/fastsyncd.conf`. The grammar is FastSync-native (documented in the Daemon Mode notes below) and strictly rejects unknown keys so a typo can never silently change what a module serves; requires `--daemon` |
| `--dparam=OVERRIDE` | Override global daemon config | ✅ Implemented | Wave A: overrides one global scalar from the command line (`--dparam port=8734` and `--dparam=KEY=VALUE` both work). Limited to the global scalar keys the grammar defines (`port`, `motd file`, `address`); keys are case-insensitive and unknown keys/invalid values are rejected. Requires `--daemon` |
| `--no-detach` | Don't detach from parent | ✅ Implemented | Wave A: with `--daemon`, keeps the listener in the foreground (what integration tests use). Without it the daemonizes (fork/setsid, stdio redirected to /dev/null) after the listening socket is bound. Requires `--daemon` |
| `--password-file=FILE` | Read daemon password from file | ✅ Implemented | Wave B daemon auth. Client: `--password-file` supplies `user:password` for a `host::module/path` destination (the username is taken from this file, so `user@host::module` stays rejected). Server (`fastsync-server --daemon --password-file FILE`): the credential store that modules with `auth users` are verified against. Only a SHA-256 digest of the password ever crosses the wire or is stored server-side; the literal password never appears in logs. The file must be private to its owner: both the client and server verify the exact inode they read (open-then-`fstat`, so the check cannot be raced) and refuse a `--password-file`/`--early-input` that is not owned by the current user or grants any group/other permission bit (mode 0600), mirroring the TLS private-key check. A process-substitution pipe (`--early-input <(vault ...)`) is still accepted when it satisfies those checks. See the Daemon Mode notes below for the file formats and the plaintext/TLS caveat |
| `--early-input=FILE` | Use FILE for daemon early exec | ✅ Implemented | Server-only (requires `--daemon`): a second credential-store file, same `user:SHA256HEX` grammar as `--password-file`, read before the listener accepts connections (a secrets-manager / process-substitution source). Its entries layer over `--password-file`: identical entries dedupe, a conflicting secret for the same user is a startup error. A daemon whose modules declare `auth users` must be given at least one of the two, or it refuses to start (fail closed) |
| `--password-file=FILE` | Read daemon password from file | ✅ Implemented | A7 daemon auth. Client: `--password-file` supplies `user:password` for a `host::module/path` destination (the username is taken from this file, so `user@host::module` stays rejected); the literal password is held client-side only for the SCRAM handshake and wiped at teardown. Server (`fastsync-server --daemon --password-file FILE`): the salted-PBKDF2 verifier store that modules with `auth users` are verified against. **Neither the password nor any replayable bearer value crosses the wire or is stored server-side** — the store holds a per-user salt plus derived keys, and the daemon proves the secret with a per-connection nonce challenge. The file must be private to its owner: both the client and server verify the exact inode they read (open-then-`fstat`, so the check cannot be raced) and refuse a `--password-file`/`--early-input` that is not owned by the current user or grants any group/other permission bit (mode 0600), mirroring the TLS private-key check. A process-substitution pipe (`--early-input <(vault ...)`) is still accepted when it satisfies those checks. See the Daemon Mode notes below for the file formats and the plaintext/TLS caveat |
| `--early-input=FILE` | Use FILE for daemon early exec | ✅ Implemented | Server-only (requires `--daemon`): a second credential-store file, same new-format grammar as `--password-file`, read before the listener accepts connections (a secrets-manager / process-substitution source). Its entries layer over `--password-file`: byte-identical verifiers dedupe, a conflicting verifier for the same user is a startup error. A daemon whose modules declare `auth users` must be given at least one of the two, or it refuses to start (fail closed) |
| `--hash-credentials=FILE`, `--iterations N` | Hash a plaintext credential file | ✅ Implemented | Server-only offline tool (A7): reads the `user:password` lines of FILE (same owner-only 0600 check) and prints one new-format store line per entry to stdout, then exits. `--iterations` sets the PBKDF2 work factor (default 600000, range 100000–10000000). Dependency-free and does not run a listener. Use its output as `--password-file` for `--daemon`. There is no auto-upgrade: a legacy store line is hard-rejected by the loader and must be regenerated |
**Daemon Mode notes (Wave A, protocol 2.15.0; Wave B auth, Wave C MOTD, no bump):** FastSync daemon mode is supported in FastSync's own protocol/config grammar, not rsync's SMB/daemon option encoding.
**Daemon Mode notes (Wave A protocol 2.15.0; A7 auth protocol 2.19.0; MOTD no bump):** FastSync daemon mode is supported in FastSync's own protocol/config grammar, not rsync's SMB/daemon option encoding.
- **Config grammar** (`fastsyncd.conf`): line-based; an implicit global section first, then `[module]` sections. Keys are case-insensitive, values are trimmed and may be wrapped in one layer of double quotes (`path = "/srv/my dir"`). `#` and `;` at the start of a line (after leading whitespace) are full-line comments; inline comments and `\` continuations are not supported. Lines are bounded (4096 chars). Global keys: `port` (default 873), `motd file` (the daemon sends its bounded, escaped content to a client after the module gate/auth accepts, unless the client passes `--no-motd`), `address` (optional bind address). Module keys: `path` (required; the daemon-side authorized root for that module), `read only` (yes/no/true/false/1/0, default no), `client owner` (yes/no/true/false/1/0, default no; opts the module into client-chosen ownership — see below), `auth users` (comma list). **Unknown keys and malformed lines are parse-and-reject errors** (never silently ignored), so a typo cannot change what a module serves.
- **Module selection & confinement:** the client requests a module with an rsync-style `host::module[/path]` destination. The module name crosses the wire as a trailing string on the config frame (bumping `PROTOCOL_VERSION` 2.14.0 → 2.15.0; the bump is required because the config-frame layout changed and the strict same-version handshake is what prevents a peer from desynchronizing on the new trailing field). The daemon looks the module up in ITS OWN config and uses the module's `path` as the authorized root through the exact same `configure_authorization` confinement the standalone server applies to `--destination-root` (`file_open_secure_parent`, `has_path_traversal`, `path_is_within`); the client never supplies the root, every client-chosen-ownership/super-user request is refused unless the module declares `client owner = yes` (the daemon's per-module opt-in, see below), and the operator `--no-super` veto forces super-user activities off for every daemon connection. The client's `/path` part is relative inside the module and is rejected if absolute or if it contains `..`. Unknown modules are refused before any data moves (the run fails cleanly at the config handshake). An absolute destination and a module request against a non-daemon server are also refused.
- **`client owner` (client-chosen-ownership opt-in):** by default a daemon module refuses every request that would let the client pick an owner or ask for super-user activities — `--numeric-ids`, `--chown`, `--usermap`/`--groupmap`, `--fake-super`, `--copy-as`, and an explicit `--super` — at the config handshake (before `STATUS_OK`), because a daemon has no per-module opt-in for client-chosen ownership and any anonymous client could otherwise force arbitrary owner ids inside the module root. `client owner = yes` opts a single module in, allowing those requests within that module's root (the standalone listener and the SSH `--stdio` server always honor them for their single operator-authorized root). Without the opt-in the daemon also forces super-user **device** activity off for that connection — char/block device-node creation (`--devices`) and `--write-devices` — even under the default `AUTO` mode, so a non-opted module can never be made to `mknod` or write a raw device; those entries are skipped (not refused) so an ordinary `-a` push still succeeds without device nodes. The opt-in does **not** lift the privilege requirement: `--copy-as` still needs a root receiver, and the operator `--no-super` veto still forces super-user activities off for every connection. The daemon logs a prominent startup warning for each `client owner = yes` module so the operator's deliberate choice is visible.
- **`read only` safe default:** every network transfer FastSync currently supports is a push that writes under the module root, so a `read only` module refuses the connection (clear server log "module is read only"; the client exits non-zero, nothing is transferred). A future pull/list operation can be opened up when it exists; the knob is already stored.
- **`auth users` (Wave B password authentication):** a module that declares `auth users` requires the client to present credentials. The client sends a username + the lowercase hex SHA-256 of the password (never the literal password) in the config frame; the daemon accepts a connection only when the presented username is **on the module's `auth users` list** AND the presented digest matches that user's credential-store entry. Verification is constant-time (username present/absent both take the same comparison work, so there is no timing oracle distinguishing "unknown user" from "wrong password"), and the daemon logs the username but **never the digest or the password**. A module WITHOUT `auth users` stays open (legitimate rsync configuration); credentials sent to such a module are ignored. Read-only is orthogonal: even a correctly authenticated push to a `read only` module is still refused (all FastSync network transfers write). Fail-closed policy: a daemon whose config declares `auth users` on any module refuses to start unless a credential store was given (`--password-file` and/or `--early-input`); a missing or empty store is never silently treated as "open".
- **Credential store format:** server `--password-file`/`--early-input` files are line-based `user:SHA256HEX`, one per line, where `SHA256HEX` is the lowercase hex SHA-256 of the user's password (exactly what the client transmits). Blank lines and lines starting with `#`/`;` are comments; the parser is strict (a malformed line fails the whole load, so a typo can never let a different set of users in). The client `--password-file` holds `user:password` on its first meaningful line (the literal password, hashed client-side then wiped from memory); keep both files readable only by their owner (mode 0600) since the client file holds the password and the server file holds the equivalent credential. Per-username wire length is bounded (256 chars) and digests are validated to be exactly 64 lowercase hex on receive.
- **Plaintext caveat:** over a plaintext (non-TLS) daemon, a sniffer can capture the transmitted digest and replay it (the exchange is challenge-less, like rsync), and it sees the same value that is already stored in the server's own credential file — so use `--tls` to protect the exchange. The daemon logs a warning when an auth-required module is reached over plaintext. TLS client-CN (`--client-cn`) is an independent transport identity check and composes with password auth: both may be required on the same connection.
- **Wire/protocol:** the auth payload is two trailing config-frame strings (username + digest) behind a presence int, sent after the Wave A module string and before the STATUS_OK/STATUS_ERROR ack. Because both peers of a 2.15.0 build always parse the same full frame (the strict same-version handshake rejects any other version before any byte is parsed), this is NOT a new frame layout and does **not** require a `PROTOCOL_VERSION` bump — the 2.15.0 release ships Wave A + Wave B together (see the NOTE in `src/shared/config.h`).
- **Client side:** `host::module/path` selects the TCP transport and connects to `--server-port`; `host:path` stays the SSH transport; plain paths stay local TCP. The daemon username comes from `--password-file` (first `user:password` line), and `--password-file` without a `host::module/path` destination is a client error (fail fast). A `user@host::module` form is rejected with a pointer to `--password-file`.
- **`auth users` (A7 SCRAM-SHA-256 authentication):** a module that declares `auth users` requires the client to present credentials. The config frame carries ONLY the username; the daemon answers an auth-required module with `STATUS_AUTH_CHALLENGE` (PBKDF2 iteration count, 16-byte salt, 32-byte server nonce), the client answers with `STATUS_AUTH_RESPONSE` (fresh 32-byte client nonce + a 32-byte ClientProof), and the daemon accepts only when the proof verifies **and** the username is **on the module's `auth users` list** and has a store entry, replying `STATUS_AUTH_OK` with a 32-byte ServerSignature the client verifies before proceeding. Verification is constant-time over fixed 32-byte keys (the compare runs even for a miss), username membership uses a constant-time full-length scan, and an unknown/off-list user still receives a challenge and runs the same math against a dummy verifier: a deterministic per-username salt (`HMAC-SHA256(store dummy key, username)`), the store-wide uniform iteration count and dummy keys. Re-probing the same unknown username therefore yields an identical salt and iteration count while a different username yields a different salt, so there is no user-enumeration or timing oracle. The daemon logs the username but **never the password, proof or keys**. A module WITHOUT `auth users` stays open (legitimate rsync configuration); credentials sent to such a module are ignored. Read-only is orthogonal: even a correctly authenticated push to a `read only` module is still refused (all FastSync network transfers write). Fail-closed policy: a daemon whose config declares `auth users` on any module refuses to start unless a credential store was given (`--password-file` and/or `--early-input`); a missing or empty store is never silently treated as "open". A failed handshake (missing credentials, unknown/off-list user, wrong proof or malformed data) yields a single generic `STATUS_AUTH_FAILED` and the daemon closes before any data moves. Two residuals are accepted: the dummy salt is stable within one daemon lifetime but changes across restarts, leaving a restart-gated enumeration channel (persisting the dummy key is out of scope); and the store iteration count is observable pre-auth by design, since the miss path must match a hit.
- **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.
- **Plaintext caveat:** over a plaintext (non-TLS) daemon a sniffer can read the challenge and response and mount an **offline dictionary attack** against a weak password, so use `--tls` for any real deployment. 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). The daemon logs a warning when an auth-required module is reached over plaintext. TLS client-CN (`--client-cn`) is an independent transport identity check and composes with password auth: both may be required on the same connection.
- **Wire/protocol:** the config-frame auth block is now `[int present][str_redacted username]` (the old digest field is gone), and the frame stream gains the challenge/response (`STATUS_AUTH_CHALLENGE` → `STATUS_AUTH_RESPONSE` → `STATUS_AUTH_OK`/`STATUS_AUTH_FAILED`) between the config frame and the `STATUS_OK` ack. Both are wire-layout changes, so `PROTOCOL_VERSION` is bumped **2.18.0 → 2.19.0** (see the A7 note in `src/shared/config.h`); the strict same-version handshake keeps a 2.19 client and a 2.18 server from desynchronizing.
- **Client side:** `host::module/path` selects the TCP transport and connects to `--server-port`; `host:path` stays the SSH transport; plain paths stay local TCP. The daemon username comes from `--password-file` (first `user:password` line), and `--password-file` without a `host::module/path` destination is a client error (fail fast). A `user@host::module` form is rejected with a pointer to `--password-file`. The client's plaintext password is wiped from memory (`config_burn_auth`) at transfer teardown.
- **MOTD (Wave C):** a daemon configured with a global `motd file` sends that file's content as the first server→client string frame after the config-frame STATUS_OK ack (rsync sends the MOTD as the first thing from the server at the start of a daemon connection). Only the daemon listener path (`host::module`) gets a MOTD; the `--stdio` SSH path never sends or reads one. The server reads the file bounded to 4096 bytes and treats an absent/unreadable file as "no MOTD" (an empty frame, never an error). The exchange is server→client only and does **not** bump `PROTOCOL_VERSION`: every 2.15.0 daemon client reads the frame after the ack, so sender and receiver stay in lockstep (see the Wave C note in `src/shared/config.h`). `--no-motd` is the client-side suppression switch: the client still reads (consumes) the frame to keep the stream in sync but does not display it. The MOTD is printed to stdout with control bytes (ESC included) escaped octal-style while newlines/tabs are preserved, so a hostile server cannot inject terminal escape sequences.
- **Merge note:** later daemon waves (auth, MOTD) must not bump `PROTOCOL_VERSION` again — the module-selection bump is owned by Wave A (see the NOTE in `src/shared/config.h`).
- **Merge note:** the Wave A module bump (2.15.0) and the MOTD wave did not bump the version, but the A7 auth redesign is a genuine wire-layout change and owns the 2.18.0 → 2.19.0 bump (see the A7 note in `src/shared/config.h`).
## 15. Safety & Security
@@ -676,7 +677,7 @@ now transmits targets (the prior behavior was broken/partial); its status moved
| `--stop-after=MINS` | Stop after N minutes | ✅ Implemented | Client-only sender stop deadline (Phase 6): computing `--stop-after=MINS` (a positive minute count; 0/negative/garbage rejected) and `--stop-at=TIME` (`HH:MM`, `HH:MM:SS`, or `now+N[smhd]`; a past time stops immediately). The transfer stops ELEGANTLY at the next chunk boundary: everything already fully sent is kept and applied, the run returns 0, and --delete (late/delete-after timing) does NOT wipe the destination — when the scan is cut short the partial keep-set manifest is suppressed with a warning (the delete walk is skipped rather than acting on an incomplete keep-set, so unscanned source mirrors survive). `--delete-before`/`--delete-during` still run their complete pre-scan (which ignores the deadline). Local client-only fields: never serialized into the wire config frame, so no PROTOCOL_VERSION bump. `--stop-after` uses CLOCK_MONOTONIC; `--stop-at` uses the wall clock. Works single-threaded and under `-j`/`--threads` (multithreaded). Divergence: rsync computes `--stop-after` from the run start; FastSync likewise. When both are given, the earlier of the two deadlines wins (checked per iteration). See the Phase-6 stop notes below |
| `--stop-at=TIME` | Stop at specified time | ✅ Implemented | Same feature as `--stop-after` (deadline transfer stop), absolute wall-clock form (`HH:MM[:SS]` or `now+N[smhd]`). See the row above and the Phase-6 stop notes |
| `--fsync` | Fsync every written file before publication | ✅ Implemented | |
| `--protocol=NUM` | Force older protocol version | ✅ Implemented | Forces the wire protocol version for this transfer. FastSync has exactly ONE wire format (`PROTOCOL_VERSION`, currently 2.18.0) with no downgrade/backward-compat code paths, so `--protocol=2.18.0` is accepted (it sets the version claim the client sends, which the server already requires to match exactly) and **every other value is rejected up front** with a clear error before any connection — it does not and cannot speak an older or virtual wire format. Divergence from rsync (which negotiates a range and downgrades to an integer 0..31): FastSync's honest contract is force-to-the-one-supported-value; a genuine downgrade would require a per-version compatibility layer that does not exist. Client-only; the server-side exact-match check is unchanged. `--protocol=2.18`/`2.17.0`/`2.16.0`/`2.15.0`/`216`/`31`/garbage are all rejected. See the Phase-6 protocol note below |
| `--protocol=NUM` | Force older protocol version | ✅ Implemented | Forces the wire protocol version for this transfer. FastSync has exactly ONE wire format (`PROTOCOL_VERSION`, currently 2.19.0) with no downgrade/backward-compat code paths, so `--protocol=2.19.0` is accepted (it sets the version claim the client sends, which the server already requires to match exactly) and **every other value is rejected up front** with a clear error before any connection — it does not and cannot speak an older or virtual wire format. Divergence from rsync (which negotiates a range and downgrades to an integer 0..31): FastSync's honest contract is force-to-the-one-supported-value; a genuine downgrade would require a per-version compatibility layer that does not exist. Client-only; the server-side exact-match check is unchanged. `--protocol=2.18.0`/`2.18`/`2.17.0`/`2.16.0`/`2.15.0`/`216`/`31`/garbage are all rejected. See the Phase-6 protocol note below |
| `--iconv=CONVERT_SPEC` | Charset conversion | ✅ Implemented | Charset conversion of FILE NAMES (not content) at the protocol boundary via iconv(3): `--iconv=LOCAL[,REMOTE]` — the sender converts each local filename LOCAL→REMOTE before transmitting, and the receiver converts each wire filename REMOTE→LOCAL before creating/writing. The full CONVERT_SPEC is serialized into the config frame as a new trailing string field so the peer knows the wire charset; **PROTOCOL_VERSION bumped 2.15.0 → 2.16.0**. `LOCAL[,REMOTE]` parse: single charset ⇒ LOCAL==REMOTE (identity both ways); garbage rejected up front. Validation probes BOTH directions (a spec that only opens one way is refused, as is a NUL-emitting target charset like utf-16/utf-32/ucs-2, since filenames cannot contain NUL). An unrepresentable name (EILSEQ/EINVAL) fails that path cleanly with a logged `--iconv: cannot convert file name ...` and is never written mangled/truncated. Conversion is applied at EVERY wire-path site (regular/MKDIR/hardlink path+target/symlink path+target/SPECIAL, the delete manifest, the incremental-check path, and the `-s`/`chunk_serialize` embedded blob path), on both client and server (`--iconv` is also a server/daemon option). Zero overhead when unset. See the Phase-6 iconv notes below |
| `--checksum-seed=NUM` | Set checksum seed | ✅ Implemented | Sets the seed for FastSync's whole-file xxHash64 digest (full 64-bit seed) and for the delta path's per-block xxHash32 strong checksum (low 32 bits of the seed). An explicit seed deterministically changes every computed digest on BOTH endpoints (sender and receiver share the seed via the config frame, protocol 2.10.0), so identical runs with the same seed skip the same files and a changed seed changes the digests — the explicit-seed path that makes xxHash comparisons deterministic. `--checksum-choice=md5` has no seed and ignores it (documented). The value is a strict decimal 0..2⁶⁴-1 (blank, signed, or non-numeric values are rejected). Like rsync, a seed only matters where a digest is actually computed (`--checksum` or a basis-dir run, or a delta transfer); it does not by itself enable `--checksum`/`--delta`. Divergence from rsync: the default is seed 0, and FastSync never randomizes the seed (rsync uses a random per-transfer seed when `--checksum-seed` is unset); FastSync's unset default therefore reproduces its historical byte-for-byte behavior |
| `--secluded-args`, `-s` | Use protocol to send args | ⛔ Impossible/Divergence | Accepted for CLI compatibility (including the rsync short `-s`, Phase 7 Wave A) but a documented **no-op / divergence**. rsync's `-s` protects arguments from shell expansion by shipping them over the protocol; FastSync never passes remote arguments through a shell expansion boundary in the first place — its SSH transport builds the remote argv as **single-quote-escaped shell words** (`ssh_build_remote_command`), so the injection/leak that `-s` guards against does not exist and there is nothing to "seclude". Implementing a true arg-send protocol would mean replacing the argv-based SSH launch with an in-band argument channel, a large redesign of the transport that buys no security here. Chunk serialization remains the long-only `--chunk-serialization`. |
@@ -792,7 +793,7 @@ These are the hardest compatibility items because they require durable formats o
**Phase 6, Wave B (iconv) shipping note (PROTOCOL 2.15.0 → 2.16.0):** `--iconv=LOCAL[,REMOTE]` converts file NAMES at the wire boundary (never content). The full CONVERT_SPEC is serialized into the config frame as a new trailing string field (empty→NULL canonicalized), so both ends share the same wire charset interpretation; this required the PROTOCOL bump because the frame is a strict ordered sequence and a peer that does not parse the new trailing field would desynchronize. Each end derives LOCAL (its own charset) and REMOTE (the wire charset): the sender opens LOCAL→REMOTE and converts every transmitted filename; the receiver opens REMOTE→LOCAL and converts every received filename before creating/writing. Conversion is applied at every wire-path site (regular/MKDIR/hardlink path+target/symlink path+target/SPECIAL, the delete manifest keep/protected/missing entries, the incremental-check path, and the embedded `-s`/chunk-blob path). A name it cannot convert (EILSEQ/EINVAL) is failed cleanly with a logged `--iconv: cannot convert file name ...` and is never written truncated/mangled. Validation probes both directions up front (both the sender local→remote and the receiver remote→local, and, for a server/daemon with its own `--iconv`, the client-REMOTE→server-LOCAL pair) so an unusable spec is rejected before the connection rather than mid-transfer, and NUL-emitting target charsets (utf-16/utf-32/ucs-2) are refused because filenames cannot contain NUL. Divergence documented upstream: the receiver does NOT half-swap; the wire charset always comes from the sender's REMOTE half, so a server whose local charset differs from the client's LOCAL must declare it with its own `--iconv`. Conversion is process-global and runs on a single thread per process (sender thread / receiver-loop thread), initialized before worker threads start and freed after they join.
**Phase 6, Wave C (protocol-version) shipping note (no PROTOCOL_VERSION change):** `--protocol=NUM` lets the client force the wire protocol version for a transfer. FastSync's protocol is a single lockstep format: the config frame is a strict ordered sequence and the server requires the client's version string to equal `PROTOCOL_VERSION` exactly (`config_receive_with_validate`, src/shared/config.c) — there are no older-format code paths and no downgrade/negotiation machinery, so a lower/higher/virtual version can never be spoken. The honest contract is therefore: `--protocol=2.18.0` (the current `PROTOCOL_VERSION`, as of the P7 Wave E privilege bump) is accepted and stored into the client's `version` claim (which `config_send` already transmits), and every other value — `2.18`, `2.17.0`, `2.16.0`, `2.15.0`, `3.0.0`, rsync-integer spellings like `216`/`31`, garbage, empty — is rejected up front in `validate_config()` before any connection, with a clear error that FastSync supports only its current wire protocol and cannot speak an older or virtual one. Implementation is client-only: a server-side `--protocol` is intentionally not added because the server has no negotiation (it only enforces exact match), and it could only ever be the current version. This preserves (and slightly tightens) existing validation: the client now also refuses to launch with a version it cannot actually speak, rather than only the server rejecting it later. A genuine downgrade would require a per-version compatibility layer for every frame/feature added since (append 2.10, preallocate 2.11, hardlinks 2.12, devices/specials/symlink-trust/xattr 2.13, remote-option 2.14, daemon module/auth 2.15, iconv 2.16, dir/symlink times 2.17, privilege flags --super/--copy-as 2.18) and is intentionally out of scope — documented divergences from rsync's integer-negotiated downgrade remain.
**Phase 6, Wave C (protocol-version) shipping note (no PROTOCOL_VERSION change):** `--protocol=NUM` lets the client force the wire protocol version for a transfer. FastSync's protocol is a single lockstep format: the config frame is a strict ordered sequence and the server requires the client's version string to equal `PROTOCOL_VERSION` exactly (`config_receive_with_validate`, src/shared/config.c) — there are no older-format code paths and no downgrade/negotiation machinery, so a lower/higher/virtual version can never be spoken. The honest contract is therefore: `--protocol=2.19.0` (the current `PROTOCOL_VERSION`, as of the A7 auth redesign) is accepted and stored into the client's `version` claim (which `config_send` already transmits), and every other value — `2.18.0`, `2.18`, `2.17.0`, `2.16.0`, `2.15.0`, `3.0.0`, rsync-integer spellings like `216`/`31`, garbage, empty — is rejected up front in `validate_config()` before any connection, with a clear error that FastSync supports only its current wire protocol and cannot speak an older or virtual one. Implementation is client-only: a server-side `--protocol` is intentionally not added because the server has no negotiation (it only enforces exact match), and it could only ever be the current version. This preserves (and slightly tightens) existing validation: the client now also refuses to launch with a version it cannot actually speak, rather than only the server rejecting it later. A genuine downgrade would require a per-version compatibility layer for every frame/feature added since (append 2.10, preallocate 2.11, hardlinks 2.12, devices/specials/symlink-trust/xattr 2.13, remote-option 2.14, daemon module/auth 2.15, iconv 2.16, dir/symlink times 2.17, privilege flags --super/--copy-as 2.18, SCRAM daemon auth 2.19) and is intentionally out of scope — documented divergences from rsync's integer-negotiated downgrade remain.
**Phase-1/2 selection-and-update status correction (docs):** `-I/--ignore-times`, `--size-only`, `-@/--modify-window`, `--existing`, `--ignore-existing`, `-u/--update`, `-W/--whole-file`, and `--compress-threads` were previously listed as not-implemented in this document but are in fact fully implemented and tested on `dev`. This pass corrects the matrix to match the code. The realistic model of these is that FastSync is a *sender-driven* whole-tree copy, so the size+mtime quick-check and all three receiver-policy skips (`--existing`, `--ignore-existing`, `-u`) are evaluated against the **destination** on the receiver side, and their booleans cross the wire in the config frame. `-I`/`--size-only`/`--modify-window` modify the `--incremental` per-file `STATUS_CHECK` handshake's match predicate (`-I` disables the mtime leg and forces transfer; `--size-only` drops only the mtime leg; `--modify-window` adds tolerance to `metadata_mtime_matches`); they require `--incremental` (or a basis dir) to have a handshake to affect, mirroring how they only matter where a quick-check exists in rsync. `--existing`/`--ignore-existing`/`-u` are receiver write-time policies (skipping the write / newer-destination guard) applied across the regular-file, `--delay-updates`-staged, hardlink-sibling, and special/device paths; `-u` implies `-M` metadata and uses a second-then-nanosecond strict `>` newer check; both correctly influence `--remove-source-files` (a skipped source is not removed). `-W/--whole-file` disables block-level delta (opt-in via `--delta`), folded into the wire `use_delta` so no protocol bump was needed, and makes `--fuzzy` inert; `--append`/`--append-verify` are rejected with `-W`. `--compress-threads=NUM` (1..64, client-only, never crosses the wire) sizes the zstd compression worker pool. No code was changed by this correction; the implementation had landed in earlier merge waves (feat/ignore-times, feat/ignore-existing via the newer `file_to_disk_secure_no_replace`/`linkat EEXIST` path, feat/size-only, feat/modify-window, feat/whole-file, feat/update, compression-threads).
+10 -27
View File
@@ -1573,14 +1573,14 @@ static int read_patterns_from_file(const char* filepath, char*** patterns, int*
}
#ifndef FASTSYNC_TEST_BUILD
/* Daemon auth (Wave B): read --password-file and derive the wire credentials
* (username + SHA-256 hex digest of the password). Runs once the destination
* form is known: the credentials only make sense for a daemon
* (host::module/path) destination, so a --password-file without one is a hard
* error here rather than a silently-ignored flag. The literal password is
* hashed immediately and wiped from memory; only the digest (and username) are
* kept on the Config for config_send. Returns 0 on success, -1 on error (the
* reason is logged; neither the password nor its digest is ever logged). */
/* Daemon auth (A7, protocol 2.19.0): read --password-file and keep the
* username plus the LITERAL password (client-only, never serialized). Runs
* once the destination form is known: the credentials only make sense for a
* daemon (host::module/path) destination, so a --password-file without one is a
* hard error here rather than a silently-ignored flag. The password is handed
* to the SCRAM challenge/response in config_send and burned by
* config_burn_auth/config_delete at teardown. Returns 0 on success, -1 on
* error (the reason is logged; the password is never logged). */
static int load_daemon_credentials(Config* config) {
if (!config->password_file)
return 0;
@@ -1597,27 +1597,10 @@ static int load_daemon_credentials(Config* config) {
log_message(LOG_LEVEL_ERROR, "%s", err);
return -1;
}
char hash[CREDENTIAL_HASH_HEX_LEN + 1];
if (!credentials_hash_password(password, hash)) {
log_message(LOG_LEVEL_ERROR, "failed to hash the password from '%s'", config->password_file);
credentials_burn(password, strlen(password));
free(password);
free(user);
return -1;
}
credentials_burn(password, strlen(password));
free(password);
free(config->auth_user);
free(config->auth_password_hash);
config_burn_auth(config);
config->auth_user = user;
config->auth_password_hash = str_dup(hash);
if (!config->auth_password_hash) {
log_message(LOG_LEVEL_ERROR, "memory allocation failed reading '%s'", config->password_file);
free(config->auth_user);
config->auth_user = NULL;
return -1;
}
config->auth_password = password;
log_info_message(LOG_INFO_MISC, "Loaded daemon credentials for user '%s'", config->auth_user);
return 0;
}
+143 -22
View File
@@ -60,12 +60,100 @@ static CredentialStore* g_credentials = NULL;
* SUPER_MODE_OFF here and the handler applies it exactly once after acceptance. */
typedef struct ModuleGateContext {
SSL* ssl;
/* The connection descriptor, so the gate can drive the SCRAM auth handshake
* while it still owns the config-frame exchange (before the STATUS_OK ack). */
int fd;
/* SUPER_MODE_OFF when this connection must not attempt any super-user
activity (operator --no-super, or a daemon module without the
`client owner = yes` opt-in); -1 when the config's own mode stands. */
int super_mode_override;
} ModuleGateContext;
/* Server half of the SCRAM challenge/response (A7 remediation, protocol
* 2.19.0). Sends STATUS_AUTH_CHALLENGE (iteration count, base64 salt, base64
* server nonce), expects STATUS_AUTH_RESPONSE (base64 client nonce, base64
* ClientProof), verifies the proof constant-time and answers STATUS_AUTH_OK
* with the base64 ServerSignature. On any failure BEFORE the success response
* it sends exactly one generic STATUS_AUTH_FAILED and returns false; a failure
* while writing the success signature cannot send a status and just drops an
* already-broken connection. The verifier for an unknown/off-list
* user is a dummy (deterministic per-username salt, store-wide iterations, dummy
* keys, found=false) so the same math runs and no user-enumeration/timing oracle
* is exposed. */
static bool server_auth_handshake(int fd, const Config* config, const DaemonModule* module) {
bool result = false;
CredentialVerifier verifier;
memset(&verifier, 0, sizeof(verifier));
uint8_t snonce[CREDENTIAL_NONCE_LEN] = {0};
char salt_b64[25] = {0};
char snonce_b64[45] = {0};
char* cnonce_b64 = NULL;
char* proof_b64 = NULL;
uint8_t cnonce[CREDENTIAL_NONCE_LEN] = {0};
uint8_t proof[CREDENTIAL_KEY_LEN] = {0};
uint8_t server_sig[CREDENTIAL_KEY_LEN] = {0};
char sig_b64[45] = {0};
size_t cnonce_len = 0;
size_t proof_len = 0;
if (!config->auth_user)
goto fail; /* no username: generic failure, no challenge */
if (!credentials_get_verifier(g_credentials, config->auth_user,
(const char* const*)module->auth_users, module->auth_user_count,
&verifier))
goto fail; /* a crypto failure still owes the gate a terminal frame */
if (!(credentials_random_bytes(snonce, sizeof(snonce)) &&
credentials_b64_encode(verifier.salt, CREDENTIAL_SALT_LEN, salt_b64, sizeof(salt_b64)) &&
credentials_b64_encode(snonce, sizeof(snonce), snonce_b64, sizeof(snonce_b64))))
goto fail;
if (!(send_status(fd, STATUS_AUTH_CHALLENGE) && send_int(fd, (int)verifier.iters) &&
send_str(fd, salt_b64) && send_str(fd, snonce_b64)))
goto fail;
Status status = STATUS_ERROR;
if (!(receive_status(fd, &status) && status == STATUS_AUTH_RESPONSE))
goto fail;
cnonce_b64 = receive_str_redacted(fd);
proof_b64 = receive_str_redacted(fd);
if (!(cnonce_b64 && proof_b64 &&
credentials_b64_decode(cnonce_b64, cnonce, sizeof(cnonce), &cnonce_len) &&
cnonce_len == CREDENTIAL_NONCE_LEN &&
credentials_b64_decode(proof_b64, proof, sizeof(proof), &proof_len) &&
proof_len == CREDENTIAL_KEY_LEN))
goto fail;
if (!credentials_verify_response(&verifier, config->auth_user, snonce, cnonce, proof, server_sig))
goto fail;
/* Success writes exactly one terminal frame (STATUS_AUTH_OK). A broken pipe
* while sending the signature just drops the connection; it must never emit a
* second terminal status. */
result = credentials_b64_encode(server_sig, sizeof(server_sig), sig_b64, sizeof(sig_b64)) &&
send_status(fd, STATUS_AUTH_OK) && send_str_redacted(fd, sig_b64);
goto cleanup;
fail:
/* Every failure path writes exactly one generic terminal status, satisfying
* the gate's CONFIG_VALIDATE_ALREADY_TERMINATED contract. */
send_status(fd, STATUS_AUTH_FAILED);
cleanup:
credentials_burn(cnonce_b64, cnonce_b64 ? strlen(cnonce_b64) : 0);
credentials_burn(proof_b64, proof_b64 ? strlen(proof_b64) : 0);
free(cnonce_b64);
free(proof_b64);
credentials_burn((char*)snonce, sizeof(snonce));
credentials_burn(salt_b64, sizeof(salt_b64));
credentials_burn(snonce_b64, sizeof(snonce_b64));
credentials_burn((char*)cnonce, sizeof(cnonce));
credentials_burn((char*)proof, sizeof(proof));
credentials_burn((char*)server_sig, sizeof(server_sig));
credentials_burn(sig_b64, sizeof(sig_b64));
credentials_burn((char*)verifier.salt, sizeof(verifier.salt));
credentials_burn((char*)verifier.stored_key, sizeof(verifier.stored_key));
credentials_burn((char*)verifier.server_key, sizeof(verifier.server_key));
return result;
}
/* Aggregate payload bytes the multithreaded receiver may buffer ahead of the
slow disk writer. Receiving one more chunk adds up to ~2 * MAX_CHUNK_SIZE
of transient wire/decompression buffers on top of the queued payloads, so
@@ -279,10 +367,13 @@ static const char* server_module_gate(const Config* config, void* context) {
gate_ctx->super_mode_override = SUPER_MODE_OFF;
}
if (module->auth_user_count > 0) {
/* Auth-required module (Wave B): verify the presented credentials against
* the store BEFORE the module root is installed and before any data moves.
* Fail closed: no store -> refuse; no/invalid credentials -> refuse. The
* username may be logged (never the digest/password). */
/* Auth-required module (A7, protocol 2.19.0): run the SCRAM challenge/
* response BEFORE the module root is installed and before any data moves.
* Fail closed: no store -> refuse (server misconfiguration, STATUS_ERROR);
* a handshake that fails before the success response writes exactly one
* STATUS_AUTH_FAILED before signalling ALREADY_TERMINATED (a failure while
* writing the success signature instead just drops the broken connection).
* The username may be logged (never the password or any derived proof). */
if (g_credentials == NULL) {
log_message(LOG_LEVEL_ERROR,
"daemon module '%s' requires authentication but no credential store is "
@@ -291,28 +382,25 @@ static const char* server_module_gate(const Config* config, void* context) {
return "requested daemon module requires authentication and no credential "
"store is configured";
}
if (!config->auth_user || !config->auth_password_hash) {
log_message(LOG_LEVEL_ERROR,
"daemon module '%s' requires authentication; the client "
"presented no credentials",
config->module);
return "requested daemon module requires authentication";
}
if (gate_ctx && !gate_ctx->ssl) {
log_message(LOG_LEVEL_WARNING,
"daemon module '%s' is authenticating over a plaintext connection (no --tls); "
"the credential exchange is not encrypted",
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);
if (!gate_ctx || gate_ctx->fd < 0) {
log_message(LOG_LEVEL_ERROR, "daemon module '%s': no auth transport available",
config->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);
log_message(LOG_LEVEL_INFO, "daemon module '%s': user '%s' authenticated", config->module,
escaped_user ? escaped_user : "<allocation failed>");
@@ -334,6 +422,7 @@ void handler(int file_descriptor) {
protocol_session_bind(&session);
ModuleGateContext gate_ctx;
gate_ctx.ssl = ssl;
gate_ctx.fd = file_descriptor;
gate_ctx.super_mode_override = -1;
Config* config = config_receive_with_validate(file_descriptor, server_module_gate, &gate_ctx);
if (config == NULL) {
@@ -638,10 +727,12 @@ static void print_server_usage(void) {
printf(" --no-detach Stay in the foreground (default detaches to\n");
printf(" background when running --daemon)\n");
printf(" --password-file=FILE Credential store for modules that declare\n");
printf(" 'auth users' (line format: user:SHA256HEX where\n");
printf(" SHA256HEX is the lowercase hex SHA-256 of the\n");
printf(" user's password). Requires --daemon; an auth-\n");
printf(" required module with no store refuses to start\n");
printf(" 'auth users' (line format:\n");
printf(" user:$fastsync$1$pbkdf2-sha256$iters$salt$stored$server,\n");
printf(" generated by --hash-credentials). Legacy\n");
printf(" user:SHA256HEX lines are rejected. Requires\n");
printf(" --daemon; an auth-required module with no store\n");
printf(" refuses to start\n");
printf(" --early-input=FILE Second credential store layered over\n");
printf(" --password-file (same format); usually a secrets-\n");
printf(" manager/process-substitution file. Requires --daemon\n");
@@ -666,6 +757,13 @@ static void print_server_usage(void) {
printf(" client's CONVERT_SPEC). A name that cannot be\n");
printf(" represented fails the run cleanly\n");
printf(" --allow-unauthenticated Allow plaintext/anonymous network clients\n");
printf(" --hash-credentials <file> Read <file>'s user:password lines and print\n");
printf(" PBKDF2 credential-store lines to stdout, then exit.\n");
printf(" Use the output as --password-file for --daemon;\n");
printf(" redirect it to an owner-only (0600) file\n");
printf(" --iterations N PBKDF2 iteration count for --hash-credentials\n");
printf(" (default %u, range %u-%u)\n", CREDENTIAL_DEFAULT_ITERS,
CREDENTIAL_MIN_ITERS, CREDENTIAL_MAX_ITERS);
printf(" -v, --verbose Enable debug logging\n");
printf(" --help Show this help\n");
}
@@ -735,6 +833,29 @@ int main(int argc, char* argv[]) {
return 1;
}
/* --hash-credentials: standalone offline tool; read user:password lines and
* emit new-format credential-store lines, then exit. */
if (opts.hash_credentials_file) {
uint32_t iters = opts.hash_iterations_set ? opts.hash_iterations : CREDENTIAL_DEFAULT_ITERS;
/* The output is secret material: if it is redirected to a regular file,
* warn when that file is group/other-accessible (the store must be 0600). */
struct stat out_st;
if (fstat(STDOUT_FILENO, &out_st) == 0 && S_ISREG(out_st.st_mode) &&
(out_st.st_mode & (S_IRWXG | S_IRWXO)) != 0)
fprintf(stderr,
"Warning: credential-store output is a group/other-accessible file; restrict it to "
"mode 0600 (chmod 600)\n");
char hash_err[512];
if (credentials_hash_file(opts.hash_credentials_file, iters, stdout, hash_err,
sizeof(hash_err)) != 0) {
fprintf(stderr, "Error: %s\n", hash_err);
server_cli_options_free(&opts);
return 1;
}
server_cli_options_free(&opts);
return 0;
}
int exit_code = 0;
signal(SIGPIPE, SIG_IGN);
if (opts.verbose) {
+36
View File
@@ -1,5 +1,6 @@
#include "server_cli.h"
#include "charset.h"
#include "credentials.h"
#include "utils.h"
#include <limits.h>
#include <stdarg.h>
@@ -127,6 +128,33 @@ int server_cli_parse(int argc, char* argv[], ServerCliOptions* opts, char* err,
inline_value = argv[++i];
}
opts->early_input_file = inline_value;
} else if (arg_has_value(argv[i], "--hash-credentials", &inline_value)) {
if (!inline_value) {
if (i + 1 >= argc) {
set_error(err, err_size, "missing argument for --hash-credentials");
return -1;
}
inline_value = argv[++i];
}
opts->hash_credentials_file = inline_value;
} else if (arg_has_value(argv[i], "--iterations", &inline_value)) {
if (!inline_value) {
if (i + 1 >= argc) {
set_error(err, err_size, "missing argument for --iterations");
return -1;
}
inline_value = argv[++i];
}
char* end = NULL;
long n = strtol(inline_value, &end, 10);
if (!end || *end != '\0' || n < (long)CREDENTIAL_MIN_ITERS ||
n > (long)CREDENTIAL_MAX_ITERS) {
set_error(err, err_size, "--iterations must be in [%u,%u], got '%s'", CREDENTIAL_MIN_ITERS,
CREDENTIAL_MAX_ITERS, inline_value);
return -1;
}
opts->hash_iterations = (uint32_t)n;
opts->hash_iterations_set = true;
} else if (arg_is(argv[i], "--address")) {
if (i + 1 >= argc) {
set_error(err, err_size, "missing argument for --address");
@@ -227,6 +255,14 @@ int server_cli_parse(int argc, char* argv[], ServerCliOptions* opts, char* err,
"--daemon");
return -1;
}
if (opts->hash_credentials_file != NULL && (opts->daemon_mode || opts->stdio_mode)) {
set_error(err, err_size, "--hash-credentials cannot be combined with --daemon or --stdio");
return -1;
}
if (opts->hash_iterations_set && opts->hash_credentials_file == NULL) {
set_error(err, err_size, "--iterations requires --hash-credentials");
return -1;
}
/* --iconv: reject a malformed CONVERT_SPEC or an unsupported charset name at
startup (a probe iconv_open is attempted). */
if (opts->iconv_spec != NULL && !charset_spec_valid(opts->iconv_spec)) {
+7
View File
@@ -3,6 +3,7 @@
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
/* Parsed fastsync-server command line. All string members are borrowed
* pointers into the original argv (valid for the life of the argv array the
@@ -26,6 +27,12 @@ typedef struct ServerCliOptions {
const char* config_path; /* --config value, or NULL */
const char* password_file; /* --password-file value, or NULL (daemon) */
const char* early_input_file; /* --early-input value, or NULL (daemon) */
/* --hash-credentials=FILE: read `user:password` lines from FILE and print
* new-format credential-store lines to stdout, then exit. Standalone mode
* (mutually exclusive with --daemon/--stdio). */
const char* hash_credentials_file;
bool hash_iterations_set; /* an explicit --iterations was given */
uint32_t hash_iterations; /* --iterations value (default CREDENTIAL_DEFAULT_ITERS) */
const char** dparams; /* raw --dparam override strings */
int dparam_count;
const char* bind_address; /* --address */
+128 -27
View File
@@ -41,7 +41,7 @@ static void config_set_defaults(Config* config) {
config->ssh_destination = NULL;
config->module = NULL;
config->auth_user = NULL;
config->auth_password_hash = NULL;
config->auth_password = NULL;
config->password_file = NULL;
config->iconv_spec = NULL;
config->fastsync_server_path = NULL;
@@ -630,6 +630,20 @@ void config_parse_ssh_dest(Config* config) {
config->receive_root_directory = path;
}
void config_burn_auth(Config* config) {
if (!config)
return;
if (config->auth_password) {
credentials_burn(config->auth_password, strlen(config->auth_password));
free(config->auth_password);
config->auth_password = NULL;
}
if (config->auth_user) {
free(config->auth_user);
config->auth_user = NULL;
}
}
void config_delete(Config* config) {
if (config == NULL)
return;
@@ -642,8 +656,7 @@ void config_delete(Config* config) {
free(config->receive_root_directory);
free(config->ssh_destination);
free(config->module);
free(config->auth_user);
free(config->auth_password_hash);
config_burn_auth(config);
free(config->password_file);
free(config->iconv_spec);
free(config->write_batch);
@@ -1128,23 +1141,18 @@ static bool receive_daemon_module(int fd, Config* c) {
return true;
}
/* Daemon password credentials (Wave B, within protocol 2.15.0 -- see the
* PROTOCOL_VERSION note in config.h: this rides the Wave A trailing-string
* area, symmetric sender+receiver in every 2.15.0 build, so it is not a frame
* layout that needs its own bump). A single presence int is followed, when
* set, by the username and the SHA-256 hex digest of the password. The
* literal password never crosses the wire. */
/* Daemon password credentials (A7 remediation, protocol 2.19.0). A single
* presence int is followed, when set, by ONLY the username; the password is
* never serialized. The daemon answers an auth-required module with the SCRAM
* challenge (see the auth exchange below). */
static bool send_daemon_auth(int fd, const Config* c) {
bool present = c->auth_user != NULL && c->auth_password_hash != NULL && c->auth_user[0] != '\0' &&
c->auth_password_hash[0] != '\0';
bool present = c->auth_user != NULL && c->auth_user[0] != '\0';
if (!send_int(fd, present ? 1 : 0))
return false;
if (!present)
return true;
/* Redacted send: the username and hard-wired digest must never reach a
* --verbose debug log (they are replayable), while normal protocol strings
* keep their debug trace. */
return send_str_redacted(fd, c->auth_user) && send_str_redacted(fd, c->auth_password_hash);
/* Redacted send: the username must never reach a --verbose debug log. */
return send_str_redacted(fd, c->auth_user);
}
static bool receive_daemon_auth(int fd, Config* c) {
@@ -1153,27 +1161,107 @@ static bool receive_daemon_auth(int fd, Config* c) {
return false;
if (!present)
return true;
/* Redacted receive: never log the incoming username/digest bodies. */
/* Redacted receive: never log the incoming username body. */
char* user = receive_str_redacted(fd);
char* hash = receive_str_redacted(fd);
if (!user || !hash) {
free(user);
free(hash);
if (!user)
return false;
}
size_t user_len = strlen(user);
bool valid = user_len > 0 && user_len <= CREDENTIAL_MAX_USER_LEN && credentials_hash_valid(hash);
if (!valid) {
if (!credentials_username_valid(user)) {
free(user);
free(hash);
log_message(LOG_LEVEL_WARNING, "Daemon client sent malformed auth credentials");
return false;
}
c->auth_user = user;
c->auth_password_hash = hash;
return true;
}
/* Client half of the SCRAM challenge/response (A7 remediation). Called by
* config_send after the config frame is written and the server answered
* STATUS_AUTH_CHALLENGE. The plaintext password lives only in
* config->auth_password and every derived buffer is wiped on the way out. */
static bool client_auth_exchange(int fd, const Config* c) {
if (!c->auth_user || !c->auth_password)
return false;
int iters = 0;
if (!receive_int(fd, &iters))
return false;
if (iters < (int)CREDENTIAL_MIN_ITERS || iters > (int)CREDENTIAL_MAX_ITERS) {
log_message(LOG_LEVEL_ERROR, "Daemon sent an out-of-range auth iteration count");
return false;
}
char* salt_b64 = receive_str(fd);
char* snonce_b64 = receive_str(fd);
uint8_t salt[CREDENTIAL_SALT_LEN];
uint8_t snonce[CREDENTIAL_NONCE_LEN];
uint8_t cnonce[CREDENTIAL_NONCE_LEN];
size_t salt_len = 0;
size_t snonce_len = 0;
bool ok = salt_b64 && snonce_b64 &&
credentials_b64_decode(salt_b64, salt, sizeof(salt), &salt_len) &&
salt_len == CREDENTIAL_SALT_LEN &&
credentials_b64_decode(snonce_b64, snonce, sizeof(snonce), &snonce_len) &&
snonce_len == CREDENTIAL_NONCE_LEN && credentials_random_bytes(cnonce, sizeof(cnonce));
credentials_burn(salt_b64, salt_b64 ? strlen(salt_b64) : 0);
credentials_burn(snonce_b64, snonce_b64 ? strlen(snonce_b64) : 0);
free(salt_b64);
free(snonce_b64);
if (!ok) {
log_message(LOG_LEVEL_ERROR, "Daemon sent a malformed auth challenge");
return false;
}
uint8_t client_key[CREDENTIAL_KEY_LEN];
uint8_t stored_key[CREDENTIAL_KEY_LEN];
uint8_t server_key[CREDENTIAL_KEY_LEN];
uint8_t auth_msg[CREDENTIAL_AUTH_MESSAGE_MAX];
size_t msg_len = 0;
uint8_t proof[CREDENTIAL_KEY_LEN];
uint8_t expected_sig[CREDENTIAL_KEY_LEN];
ok = credentials_compute_keys(c->auth_password, salt, (uint32_t)iters, client_key, stored_key,
server_key) &&
credentials_build_auth_message(c->auth_user, snonce, cnonce, auth_msg, sizeof(auth_msg),
&msg_len) &&
credentials_client_proof(client_key, stored_key, server_key, auth_msg, msg_len, proof,
expected_sig);
char cnonce_b64[45];
char proof_b64[45];
if (ok)
ok = credentials_b64_encode(cnonce, sizeof(cnonce), cnonce_b64, sizeof(cnonce_b64)) &&
credentials_b64_encode(proof, sizeof(proof), proof_b64, sizeof(proof_b64));
if (!ok) {
log_message(LOG_LEVEL_ERROR, "Failed to compute the daemon auth response");
} else {
ok = send_status(fd, STATUS_AUTH_RESPONSE) && send_str_redacted(fd, cnonce_b64) &&
send_str_redacted(fd, proof_b64);
}
if (ok) {
Status status = STATUS_ERROR;
char* sig_b64 = NULL;
uint8_t sig[CREDENTIAL_KEY_LEN];
size_t sig_len = 0;
ok = receive_status(fd, &status) && status == STATUS_AUTH_OK &&
(sig_b64 = receive_str_redacted(fd)) != NULL &&
credentials_b64_decode(sig_b64, sig, sizeof(sig), &sig_len) &&
sig_len == CREDENTIAL_KEY_LEN &&
credentials_secure_equal((const char*)sig, (const char*)expected_sig, CREDENTIAL_KEY_LEN);
if (!ok)
log_message(LOG_LEVEL_ERROR, "Daemon authentication failed");
credentials_burn(sig_b64, sig_b64 ? strlen(sig_b64) : 0);
credentials_burn((char*)sig, sizeof(sig));
free(sig_b64);
}
credentials_burn((char*)client_key, sizeof(client_key));
credentials_burn((char*)stored_key, sizeof(stored_key));
credentials_burn((char*)server_key, sizeof(server_key));
credentials_burn((char*)auth_msg, sizeof(auth_msg));
credentials_burn((char*)proof, sizeof(proof));
credentials_burn((char*)expected_sig, sizeof(expected_sig));
credentials_burn((char*)salt, sizeof(salt));
credentials_burn((char*)snonce, sizeof(snonce));
credentials_burn((char*)cnonce, sizeof(cnonce));
credentials_burn(cnonce_b64, sizeof(cnonce_b64));
credentials_burn(proof_b64, sizeof(proof_b64));
return ok;
}
/* --iconv CONVERT_SPEC (protocol 2.16.0). Trailing string on the config frame,
* sent after the Wave A/B daemon-auth block and before the ack, so the
* receiver knows the wire charset before the first file name arrives. The full
@@ -1268,6 +1356,14 @@ bool config_send(int file_descriptor, const Config* config) {
Status status;
if (!receive_status(file_descriptor, &status))
return false;
if (status == STATUS_AUTH_CHALLENGE) {
/* Daemon auth (protocol 2.19.0): run the SCRAM exchange, then wait for the
* ordinary STATUS_OK the server sends once authentication succeeded. */
if (!client_auth_exchange(file_descriptor, config))
return false;
if (!receive_status(file_descriptor, &status))
return false;
}
if (status != STATUS_OK) {
log_message(LOG_LEVEL_ERROR, "Error transmitting config");
return false;
@@ -1329,9 +1425,14 @@ Config* config_receive_with_validate(int file_descriptor, ConfigValidateFunc val
if (rejection != NULL) {
/* Daemon module gate (unknown module / read-only module / auth-required
* module): refuse BEFORE the STATUS_OK so the client aborts at the
* config handshake and no file data is ever exchanged. */
* config handshake and no file data is ever exchanged. The auth
* handshake already sent STATUS_AUTH_FAILED when it failed, signalled by
* the CONFIG_VALIDATE_ALREADY_TERMINATED sentinel, so no second status is
* written. */
if (rejection != CONFIG_VALIDATE_ALREADY_TERMINATED) {
fprintf(stderr, "%s\n", rejection);
send_status(file_descriptor, STATUS_ERROR);
}
goto error;
}
}
+47 -19
View File
@@ -96,19 +96,18 @@ typedef struct Config {
* string so the daemon can look the module up in its own config and confine
* the connection to the module's root (never a client-chosen root). */
char* module;
/* Daemon password authentication (Wave B, protocol 2.15.0, WITHIN the Wave A
* frame layout -- see the PROTOCOL_VERSION note below for why this is not a
* bump). Client-composed from a --password-file whose first meaningful line
* is `user:password`: the client sends ONLY the username and a SHA-256 hex
* digest of the password (auth_user + auth_password_hash), never the literal
* password. Both are NULL when the client has no credentials to present; a
* module WITHOUT `auth users` stays open and the server ignores any
* credentials that do arrive (the client sends them opportunistically and
* the server decides). */
/* Daemon password authentication (A7 remediation, protocol 2.19.0).
* Client-composed from a --password-file whose first meaningful line is
* `user:password`: the client sends ONLY the username in the config frame
* (auth_user); the literal password is kept in auth_password CLIENT-SIDE for
* the duration of the SCRAM challenge/response and is NEVER serialized. Both
* are NULL when the client has no credentials to present; a module WITHOUT
* `auth users` stays open and the server ignores any credentials that do
* arrive (the client sends them opportunistically and the server decides). */
char* auth_user;
char* auth_password_hash;
char* auth_password;
/* Client-only path of --password-file (never crosses the wire; it is read to
* populate auth_user/auth_password_hash before connecting). */
* populate auth_user/auth_password before connecting). */
char* password_file;
char* fastsync_server_path;
/* --iconv=CONVERT_SPEC (protocol 2.16.0, rsync compatibility): convert the
@@ -546,8 +545,8 @@ typedef struct Config {
* is what keeps a 2.15 client and a 2.14 server from ever reaching that state.
*
* NOTE: daemon module-selection bump owned by Wave A (2.15.0); later daemon
* waves (auth, motd) must not bump PROTOCOL_VERSION. Wave B (auth) adds the
* credential fields (auth_user/auth_password_hash) as further trailing
* waves (auth, motd) must not bump PROTOCOL_VERSION. Wave B (auth) added the
* credential fields (auth_user + password digest) as further trailing
* config-frame strings AFTER the Wave A module string, with a presence int
* prefix. This is not a new frame version: sender and receiver of a 2.15.0
* build always read and write the same full layout (the strict same-version
@@ -617,8 +616,22 @@ typedef struct Config {
* (config_receive rejects a mismatched version before parsing anything else) is
* what keeps a 2.18 client and a 2.17 server from ever reaching that state.
* --super never elevates privileges; it only permits a confined attempt, and
* --copy-as never switches process credentials (see RSYNC_COMPAT.md). */
#define PROTOCOL_VERSION "2.18.0"
* --copy-as never switches process credentials (see RSYNC_COMPAT.md).
*
* A7 Auth Wave: 2.18.0 -> 2.19.0.
*
* WHY the bump, grounded in the wire: the daemon auth block on the config frame
* loses the hard-wired password digest (it becomes `[int present][str_redacted
* username]`), and the frame stream gains the SCRAM challenge/response
* (STATUS_AUTH_CHALLENGE -> STATUS_AUTH_RESPONSE -> STATUS_AUTH_OK) between the
* config frame and the STATUS_OK ack. A 2.18 peer would desynchronize on both
* the shorter auth block and the new status frames, so the strict same-version
* handshake (config_receive rejects a mismatched version before parsing
* anything else) is what keeps a 2.19 client and a 2.18 server from ever
* reaching that state. SECURITY: a 2.19 store holds a salted PBKDF2 verifier
* and cannot verify (and refuses to load) a legacy unsalted-SHA-256 store line,
* so an old bearer digest can never be replayed against a 2.19 daemon. */
#define PROTOCOL_VERSION "2.19.0"
#define DEFAULT_CHUNK_SIZE (10 * 1024 * 1024)
/* Upper bound on total basis-dir entries (rsync caps --link-dest at 20). */
#define MAX_BASIS_DIRS 64
@@ -642,21 +655,36 @@ typedef struct Config {
Config* config_create(void);
void config_delete(Config* config);
/* Wipe the client-side plaintext auth password (and username) from a Config
* before it is freed or handed off. Safe on a NULL/empty Config and idempotent
* (it clears the pointers after burning). config_delete calls this
* automatically; a caller that drops a Config earlier may call it explicitly. */
void config_burn_auth(Config* config);
bool config_send(int file_descriptor, const Config* config);
Config* config_receive(int file_descriptor);
bool config_is_remote_dest(const char* s);
void config_parse_ssh_dest(Config* config);
/* A ConfigValidateFunc may return this sentinel to tell
* config_receive_with_validate that the callback ALREADY sent a terminal status
* frame (e.g. STATUS_AUTH_FAILED, then closed) and the frame must be abandoned
* without an additional STATUS_ERROR. A normal rejection returns a message
* string (logged, then STATUS_ERROR); NULL accepts. */
#define CONFIG_VALIDATE_ALREADY_TERMINATED ((const char*)-1)
/* Server-side config-frame gate (daemon module selection, Wave A). A server
* that needs to make an accept/reject decision about a received Config BEFORE
* it sends the STATUS_OK ack (so a rejected connection is refused cleanly with
* no data transferred) passes a callback here; it runs after the frame parses
* and validates but before the STATUS_OK/STATUS_ERROR ack. Return NULL to
* accept the connection; return a non-NULL message to reject it (the message
* is logged server-side and STATUS_ERROR is sent in place of STATUS_OK). The
* callback runs in the connection's own process, so it may set up per-module
* process state (e.g. the authorized root). context is an opaque caller
* pointer. */
* is logged server-side and STATUS_ERROR is sent in place of STATUS_OK), or the
* CONFIG_VALIDATE_ALREADY_TERMINATED sentinel when the callback already sent
* its own terminal status. The callback runs in the connection's own process,
* so it may set up per-module process state (e.g. the authorized root) and
* drive the daemon auth handshake. context is an opaque caller pointer. */
typedef const char* (*ConfigValidateFunc)(const Config* config, void* context);
Config* config_receive_with_validate(int file_descriptor, ConfigValidateFunc validate,
void* context);
+599 -107
View File
@@ -3,7 +3,10 @@
#include <ctype.h>
#include <errno.h>
#include <fcntl.h>
#include <limits.h>
#include <openssl/evp.h>
#include <openssl/params.h>
#include <openssl/rand.h>
#include <stdarg.h>
#include <stdint.h>
#include <stdio.h>
@@ -12,19 +15,42 @@
#include <sys/stat.h>
#include <unistd.h>
/* One store entry: a username and its password's SHA-256 hex digest. The
* plaintext password never appears here (and never on the daemon host). */
/* One store entry: a username and its salted PBKDF2 verifier. The plaintext
* password never appears here (and never on the daemon host); the verifier is
* not replayable because the proof is bound to a per-connection nonce. */
typedef struct CredentialEntry {
char* user;
char* password_hex; /* CREDENTIAL_HASH_HEX_LEN lowercase hex chars */
uint8_t salt[CREDENTIAL_SALT_LEN];
uint32_t iters;
uint8_t stored_key[CREDENTIAL_KEY_LEN];
uint8_t server_key[CREDENTIAL_KEY_LEN];
} CredentialEntry;
struct CredentialStore {
CredentialEntry* entries;
int count;
int capacity;
/* Store-wide uniform PBKDF2 iteration count. Every entry must agree on it
* (the parser refuses a store whose entries disagree), so a miss can be
* challenged with the same count as a hit and the count itself never leaks
* membership. Unused (0) for an empty store. */
uint32_t iters;
/* Random secret generated once at load. The dummy salt handed out for an
* unknown/off-list user is HMAC-SHA256(dummy_key, username)[:SALT_LEN], so
* repeated probes of the same username always see an identical challenge
* while different usernames differ -- with no fresh-random tell. */
uint8_t dummy_key[CREDENTIAL_KEY_LEN];
};
/* Exact marker prefix of the new store verifier field. */
#define CREDENTIAL_STORE_PREFIX "$fastsync$1$pbkdf2-sha256$"
#define CREDENTIAL_AUTH_PREFIX "FastSync-Auth-v1"
/* Fixed dummy keys used when a user is unknown or off the module's list. They
* can never authenticate because acceptance additionally requires found=true. */
static const uint8_t k_dummy_stored_key[CREDENTIAL_KEY_LEN] = {0};
static const uint8_t k_dummy_server_key[CREDENTIAL_KEY_LEN] = {0};
static void set_error(char* err, size_t err_size, const char* fmt, ...) {
if (!err || err_size == 0)
return;
@@ -106,6 +132,10 @@ static bool username_wellformed(const char* user) {
return true;
}
bool credentials_username_valid(const char* user) {
return username_wellformed(user);
}
static int hex_value(char c) {
if (c >= '0' && c <= '9')
return c - '0';
@@ -114,17 +144,243 @@ static int hex_value(char c) {
return -1;
}
bool credentials_hash_valid(const char* hash_hex) {
if (!hash_hex)
/* True for the OLD `user:SHA256HEX` secret form: exactly 64 lowercase hex
* digits. Such a line is refused loudly (and never accepted) so an operator
* cannot keep a replayable bearer digest in place after the protocol bump. */
static bool secret_is_legacy_hex(const char* s) {
if (!s)
return false;
for (int i = 0; i < CREDENTIAL_HASH_HEX_LEN; i++) {
if (hex_value(hash_hex[i]) < 0)
for (int i = 0; i < 64; i++) {
if (hex_value(s[i]) < 0)
return false;
}
return hash_hex[CREDENTIAL_HASH_HEX_LEN] == '\0';
return s[64] == '\0';
}
static bool append_entry(CredentialStore* store, const char* user, const char* password_hex) {
bool credentials_b64_encode(const uint8_t* in, size_t n, char* out, size_t out_sz) {
if (!in || !out)
return false;
if (n > (size_t)INT_MAX)
return false;
size_t encoded_len = 4 * ((n + 2) / 3);
if (out_sz < encoded_len + 1)
return false;
int written = EVP_EncodeBlock((unsigned char*)out, in, (int)n);
if (written < 0 || (size_t)written != encoded_len)
return false;
out[encoded_len] = '\0';
return true;
}
bool credentials_b64_decode(const char* in, uint8_t* out, size_t out_sz, size_t* out_len) {
if (!in || !out || !out_len)
return false;
size_t len = strlen(in);
/* Every value we decode is short (a 32-byte key is 44 chars); refusing long
* input keeps the scratch buffer fixed and bounds a hostile frame. */
if (len == 0 || (len % 4) != 0 || len > 256)
return false;
size_t padded_len = (len / 4) * 3;
size_t decoded_len = padded_len;
if (in[len - 1] == '=')
decoded_len--;
if (len >= 2 && in[len - 2] == '=')
decoded_len--;
if (decoded_len > out_sz)
return false;
/* EVP_DecodeBlock writes the full (padded) quantum, so decode into a scratch
* buffer sized for it and copy only the real bytes out. The single `done`
* path burns the scratch on failure as well as success, so no partial secret
* survives an early return. */
uint8_t scratch[192] = {0};
bool ok = false;
int n = EVP_DecodeBlock(scratch, (const unsigned char*)in, (int)len);
if (n < 0 || (size_t)n != padded_len)
goto done;
memcpy(out, scratch, decoded_len);
*out_len = decoded_len;
ok = true;
done:
credentials_burn((char*)scratch, sizeof(scratch));
return ok;
}
bool credentials_random_bytes(uint8_t* out, size_t n) {
if (!out || n == 0 || n > (size_t)INT_MAX)
return false;
return RAND_bytes(out, (int)n) == 1;
}
/* HMAC-SHA256 via the OpenSSL 3 EVP_MAC API (HMAC() is deprecated). */
static bool hmac_sha256(const uint8_t* key, size_t key_len, const uint8_t* data, size_t data_len,
uint8_t out[CREDENTIAL_KEY_LEN]) {
EVP_MAC* mac = EVP_MAC_fetch(NULL, "HMAC", NULL);
if (!mac)
return false;
EVP_MAC_CTX* ctx = EVP_MAC_CTX_new(mac);
EVP_MAC_free(mac);
if (!ctx)
return false;
OSSL_PARAM params[2];
params[0] = OSSL_PARAM_construct_utf8_string("digest", (char*)"SHA256", 0);
params[1] = OSSL_PARAM_construct_end();
size_t out_len = 0;
bool ok =
EVP_MAC_init(ctx, key, key_len, params) == 1 && EVP_MAC_update(ctx, data, data_len) == 1 &&
EVP_MAC_final(ctx, out, &out_len, CREDENTIAL_KEY_LEN) == 1 && out_len == CREDENTIAL_KEY_LEN;
EVP_MAC_CTX_free(ctx);
return ok;
}
static bool sha256(const uint8_t* data, size_t len, uint8_t out[CREDENTIAL_KEY_LEN]) {
unsigned int out_len = 0;
if (EVP_Digest(data, len, out, &out_len, EVP_sha256(), NULL) != 1)
return false;
return out_len == CREDENTIAL_KEY_LEN;
}
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]) {
if (!password || !salt)
return false;
/* Enforce the full [MIN,MAX] policy here so no caller can derive a verifier
* with a work factor outside the validated store range. */
if (iters < CREDENTIAL_MIN_ITERS || iters > CREDENTIAL_MAX_ITERS)
return false;
size_t password_len = strlen(password);
if (password_len > CREDENTIAL_MAX_PASSWORD_LEN || password_len > (size_t)INT_MAX)
return false;
uint8_t k[CREDENTIAL_KEY_LEN];
if (PKCS5_PBKDF2_HMAC(password, (int)password_len, salt, CREDENTIAL_SALT_LEN, (int)iters,
EVP_sha256(), CREDENTIAL_KEY_LEN, k) != 1) {
credentials_burn((char*)k, sizeof(k));
return false;
}
uint8_t derived_client[CREDENTIAL_KEY_LEN];
uint8_t derived_server[CREDENTIAL_KEY_LEN];
bool ok = hmac_sha256(k, sizeof(k), (const uint8_t*)"Client Key", 10, derived_client) &&
hmac_sha256(k, sizeof(k), (const uint8_t*)"Server Key", 10, derived_server);
if (ok && stored_key)
ok = sha256(derived_client, sizeof(derived_client), stored_key);
if (ok && client_key)
memcpy(client_key, derived_client, CREDENTIAL_KEY_LEN);
if (ok && server_key)
memcpy(server_key, derived_server, CREDENTIAL_KEY_LEN);
credentials_burn((char*)k, sizeof(k));
credentials_burn((char*)derived_client, sizeof(derived_client));
credentials_burn((char*)derived_server, sizeof(derived_server));
return ok;
}
static void write_be32(uint8_t* out, uint32_t value) {
out[0] = (uint8_t)(value >> 24);
out[1] = (uint8_t)(value >> 16);
out[2] = (uint8_t)(value >> 8);
out[3] = (uint8_t)value;
}
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) {
if (!user || !snonce || !cnonce || !out || !out_len)
return false;
size_t user_len = strlen(user);
if (user_len > CREDENTIAL_MAX_USER_LEN)
return false;
size_t total = 16 + 4 + user_len + 4 + CREDENTIAL_NONCE_LEN + 4 + CREDENTIAL_NONCE_LEN;
if (out_sz < total)
return false;
size_t off = 0;
memcpy(out + off, CREDENTIAL_AUTH_PREFIX, 16);
off += 16;
write_be32(out + off, (uint32_t)user_len);
off += 4;
memcpy(out + off, user, user_len);
off += user_len;
write_be32(out + off, CREDENTIAL_NONCE_LEN);
off += 4;
memcpy(out + off, snonce, CREDENTIAL_NONCE_LEN);
off += CREDENTIAL_NONCE_LEN;
write_be32(out + off, CREDENTIAL_NONCE_LEN);
off += 4;
memcpy(out + off, cnonce, CREDENTIAL_NONCE_LEN);
off += CREDENTIAL_NONCE_LEN;
*out_len = off;
return true;
}
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]) {
if (!client_key || !stored_key || !server_key || !auth_msg || !proof || !server_sig)
return false;
uint8_t client_sig[CREDENTIAL_KEY_LEN];
bool ok = hmac_sha256(stored_key, CREDENTIAL_KEY_LEN, auth_msg, msg_len, client_sig);
if (ok) {
for (size_t i = 0; i < CREDENTIAL_KEY_LEN; i++)
proof[i] = client_key[i] ^ client_sig[i];
ok = hmac_sha256(server_key, CREDENTIAL_KEY_LEN, auth_msg, msg_len, server_sig);
}
credentials_burn((char*)client_sig, sizeof(client_sig));
return ok;
}
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]) {
if (!v || !user || !snonce || !cnonce || !proof || !server_sig_out)
return false;
uint8_t auth_msg[CREDENTIAL_AUTH_MESSAGE_MAX];
size_t msg_len = 0;
if (!credentials_build_auth_message(user, snonce, cnonce, auth_msg, sizeof(auth_msg), &msg_len))
return false;
uint8_t client_sig[CREDENTIAL_KEY_LEN];
uint8_t client_key[CREDENTIAL_KEY_LEN];
uint8_t recovered[CREDENTIAL_KEY_LEN];
uint8_t server_sig[CREDENTIAL_KEY_LEN];
bool computed = hmac_sha256(v->stored_key, CREDENTIAL_KEY_LEN, auth_msg, msg_len, client_sig);
if (computed) {
for (size_t i = 0; i < CREDENTIAL_KEY_LEN; i++)
client_key[i] = proof[i] ^ client_sig[i];
computed = sha256(client_key, CREDENTIAL_KEY_LEN, recovered);
}
if (computed)
computed = hmac_sha256(v->server_key, CREDENTIAL_KEY_LEN, auth_msg, msg_len, server_sig);
if (computed)
memcpy(server_sig_out, server_sig, CREDENTIAL_KEY_LEN);
/* Always run the constant-time key compare (even when `found` is false) and
* fold the accept decision with bitwise AND so no short-circuit reveals
* whether the user was found. A tampered nonce changes the AuthMessage and
* so the recovered key. */
bool key_match = false;
if (computed)
key_match = credentials_secure_equal((const char*)recovered, (const char*)v->stored_key,
CREDENTIAL_KEY_LEN);
bool accept = computed & v->found & key_match;
credentials_burn((char*)auth_msg, sizeof(auth_msg));
credentials_burn((char*)client_sig, sizeof(client_sig));
credentials_burn((char*)client_key, sizeof(client_key));
credentials_burn((char*)recovered, sizeof(recovered));
credentials_burn((char*)server_sig, sizeof(server_sig));
return accept;
}
static bool entries_equal(const CredentialEntry* a, const CredentialEntry* b) {
return a->iters == b->iters &&
credentials_secure_equal((const char*)a->salt, (const char*)b->salt,
CREDENTIAL_SALT_LEN) &&
credentials_secure_equal((const char*)a->stored_key, (const char*)b->stored_key,
CREDENTIAL_KEY_LEN) &&
credentials_secure_equal((const char*)a->server_key, (const char*)b->server_key,
CREDENTIAL_KEY_LEN);
}
static bool append_entry(CredentialStore* store, const char* user, const uint8_t* salt,
uint32_t iters, const uint8_t* stored_key, const uint8_t* server_key) {
if (store->count == store->capacity) {
int new_capacity = store->capacity == 0 ? 8 : store->capacity * 2;
CredentialEntry* grown =
@@ -134,15 +390,15 @@ static bool append_entry(CredentialStore* store, const char* user, const char* p
store->entries = grown;
store->capacity = new_capacity;
}
store->entries[store->count].user = str_dup(user);
store->entries[store->count].password_hex = str_dup(password_hex);
if (!store->entries[store->count].user || !store->entries[store->count].password_hex) {
free(store->entries[store->count].user);
free(store->entries[store->count].password_hex);
store->entries[store->count].user = NULL;
store->entries[store->count].password_hex = NULL;
CredentialEntry* entry = &store->entries[store->count];
memset(entry, 0, sizeof(*entry));
entry->user = str_dup(user);
if (!entry->user)
return false;
}
memcpy(entry->salt, salt, CREDENTIAL_SALT_LEN);
entry->iters = iters;
memcpy(entry->stored_key, stored_key, CREDENTIAL_KEY_LEN);
memcpy(entry->server_key, server_key, CREDENTIAL_KEY_LEN);
store->count++;
return true;
}
@@ -155,9 +411,75 @@ static int find_user(const CredentialStore* store, const char* user) {
return -1;
}
/* Parse one credential store file (user:SHA256HEX per line) into a fresh
* store. Duplicate usernames WITHIN one file are an error (ambiguous). A
* NULL path yields an empty store. */
/* Parse the new `$fastsync$1$pbkdf2-sha256$...` verifier field in place. */
static bool parse_verifier_secret(char* secret, CredentialEntry* entry, const char* path,
int line_no, const char* user, char* err, size_t err_size) {
if (secret_is_legacy_hex(secret)) {
set_error(err, err_size,
"credential file '%s' line %d: legacy unsalted SHA-256 secret for user '%s' is not "
"accepted (protocol 2.19.0 uses a salted PBKDF2 verifier); regenerate the store "
"with --hash-credentials",
path, line_no, user);
return false;
}
const char* prefix = CREDENTIAL_STORE_PREFIX;
size_t prefix_len = strlen(prefix);
if (strncmp(secret, prefix, prefix_len) != 0) {
set_error(err, err_size,
"credential file '%s' line %d: expected a '%s...' verifier for user '%s' (regenerate "
"a legacy line with --hash-credentials)",
path, line_no, prefix, user);
return false;
}
char* cursor = secret + prefix_len;
const char* iters_str = cursor;
char* sep = strchr(cursor, '$');
if (!sep)
goto malformed;
*sep = '\0';
const char* salt_str = sep + 1;
sep = strchr(salt_str, '$');
if (!sep)
goto malformed;
*sep = '\0';
const char* stored_str = sep + 1;
sep = strchr(stored_str, '$');
if (!sep)
goto malformed;
*sep = '\0';
const char* server_str = sep + 1;
if (*iters_str == '\0' || *salt_str == '\0' || *stored_str == '\0' || *server_str == '\0')
goto malformed;
char* end = NULL;
unsigned long parsed = strtoul(iters_str, &end, 10);
if (!end || *end != '\0' || parsed < CREDENTIAL_MIN_ITERS || parsed > CREDENTIAL_MAX_ITERS)
goto malformed;
entry->iters = (uint32_t)parsed;
size_t decoded = 0;
if (!credentials_b64_decode(salt_str, entry->salt, CREDENTIAL_SALT_LEN, &decoded) ||
decoded != CREDENTIAL_SALT_LEN)
goto malformed;
if (!credentials_b64_decode(stored_str, entry->stored_key, CREDENTIAL_KEY_LEN, &decoded) ||
decoded != CREDENTIAL_KEY_LEN)
goto malformed;
if (!credentials_b64_decode(server_str, entry->server_key, CREDENTIAL_KEY_LEN, &decoded) ||
decoded != CREDENTIAL_KEY_LEN)
goto malformed;
return true;
malformed:
set_error(err, err_size,
"credential file '%s' line %d: malformed verifier for user '%s' (expected "
"'%s<iters>$<salt_b64>$<stored_key_b64>$<server_key_b64>')",
path, line_no, user, prefix);
return false;
}
/* Parse one credential store file into a fresh store. Duplicate usernames
* WITHIN one file are an error (ambiguous). A NULL path yields an empty
* store. */
static CredentialStore* load_store_file(const char* path, char* err, size_t err_size) {
CredentialStore* store = calloc(1, sizeof(CredentialStore));
if (!store) {
@@ -200,14 +522,14 @@ static CredentialStore* load_store_file(const char* path, char* err, size_t err_
char* colon = strchr(cursor, ':');
if (!colon) {
set_error(err, err_size,
"credential file '%s' line %d: expected 'user:SHA256HEX' (no ':' found)", path,
"credential file '%s' line %d: expected 'user:$fastsync$...' (no ':' found)", path,
line_no);
ok = false;
break;
}
*colon = '\0';
const char* user = trim_space(cursor);
const char* secret = trim_space(colon + 1);
char* secret = trim_space(colon + 1);
if (!username_wellformed(user)) {
set_error(err, err_size,
"credential file '%s' line %d: invalid username (must be 1-%d "
@@ -216,11 +538,21 @@ static CredentialStore* load_store_file(const char* path, char* err, size_t err_
ok = false;
break;
}
if (!credentials_hash_valid(secret)) {
CredentialEntry parsed;
memset(&parsed, 0, sizeof(parsed));
if (!parse_verifier_secret(secret, &parsed, path, line_no, user, err, err_size)) {
ok = false;
break;
}
/* Every entry must agree on the iteration count, so a miss can be answered
* with the store-wide count without leaking membership. */
if (store->count == 0) {
store->iters = parsed.iters;
} else if (store->iters != parsed.iters) {
set_error(err, err_size,
"credential file '%s' line %d: secret for user '%s' must be %d "
"lowercase hex characters (the SHA-256 of the password)",
path, line_no, user, CREDENTIAL_HASH_HEX_LEN);
"credential file '%s' line %d: iteration count %u disagrees with the store-wide %u "
"(the store must be uniform)",
path, line_no, parsed.iters, store->iters);
ok = false;
break;
}
@@ -230,7 +562,8 @@ static CredentialStore* load_store_file(const char* path, char* err, size_t err_
ok = false;
break;
}
if (!append_entry(store, user, secret)) {
if (!append_entry(store, user, parsed.salt, parsed.iters, parsed.stored_key,
parsed.server_key)) {
set_error(err, err_size, "out of memory reading credential file '%s'", path);
ok = false;
break;
@@ -242,6 +575,7 @@ static CredentialStore* load_store_file(const char* path, char* err, size_t err_
ok = false;
}
fclose(fp);
credentials_burn(line, sizeof(line));
if (!ok) {
credentials_free(store);
return NULL;
@@ -256,6 +590,15 @@ CredentialStore* credentials_load(const char* password_file, const char* early_i
CredentialStore* store = load_store_file(password_file, err, err_size);
if (!store)
return NULL;
/* Generate the store-wide dummy key once for the final (possibly merged)
* store. It makes an unknown-user challenge deterministic, so fail the load
* if the CSPRNG is unavailable rather than degrading the anti-enumeration
* property. */
if (!credentials_random_bytes(store->dummy_key, sizeof(store->dummy_key))) {
set_error(err, err_size, "failed to generate the credential store dummy key");
credentials_free(store);
return NULL;
}
if (!early_input_file)
return store;
@@ -264,14 +607,26 @@ CredentialStore* credentials_load(const char* password_file, const char* early_i
credentials_free(store);
return NULL;
}
/* Layer early input over the password file: same secret dedupes, a differing
* secret for the same user is ambiguous and fails closed. */
/* A layered store must stay uniform too. */
if (store->count > 0 && early->count > 0 && store->iters != early->iters) {
set_error(err, err_size,
"credential file '%s' and early-input file '%s' disagree on the iteration count "
"(%u vs %u); the store must be uniform",
password_file, early_input_file, store->iters, early->iters);
credentials_free(early);
credentials_free(store);
return NULL;
}
if (store->count == 0 && early->count > 0)
store->iters = early->iters;
/* Layer early input over the password file: an identical verifier dedupes, a
* differing verifier for the same user is ambiguous and fails closed. */
for (int i = 0; i < early->count; i++) {
int existing = find_user(store, early->entries[i].user);
if (existing >= 0) {
if (strcmp(store->entries[existing].password_hex, early->entries[i].password_hex) != 0) {
if (!entries_equal(&store->entries[existing], &early->entries[i])) {
set_error(err, err_size,
"credential file '%s' and early-input file '%s' disagree on the secret for "
"credential file '%s' and early-input file '%s' disagree on the verifier for "
"user '%s'",
password_file, early_input_file, early->entries[i].user);
credentials_free(early);
@@ -280,7 +635,9 @@ CredentialStore* credentials_load(const char* password_file, const char* early_i
}
continue; /* identical; nothing to merge */
}
if (!append_entry(store, early->entries[i].user, early->entries[i].password_hex)) {
if (!append_entry(store, early->entries[i].user, early->entries[i].salt,
early->entries[i].iters, early->entries[i].stored_key,
early->entries[i].server_key)) {
set_error(err, err_size, "out of memory merging early-input credentials");
credentials_free(early);
credentials_free(store);
@@ -295,9 +652,15 @@ void credentials_free(CredentialStore* store) {
if (!store)
return;
for (int i = 0; i < store->count; i++) {
/* Wipe the derived keys before releasing the entry (A7-4). */
credentials_burn((char*)store->entries[i].salt, CREDENTIAL_SALT_LEN);
credentials_burn((char*)store->entries[i].stored_key, CREDENTIAL_KEY_LEN);
credentials_burn((char*)store->entries[i].server_key, CREDENTIAL_KEY_LEN);
free(store->entries[i].user);
free(store->entries[i].password_hex);
}
/* The store-wide dummy key is secret (it shapes the miss challenge), so wipe
* it before releasing the store. */
credentials_burn((char*)store->dummy_key, sizeof(store->dummy_key));
free(store->entries);
free(store);
}
@@ -317,24 +680,216 @@ bool credentials_secure_equal(const char* a, const char* b, size_t len) {
return diff == 0;
}
bool credentials_hash_password(const char* password, char* out_hex) {
if (!password || !out_hex)
/* Constant-time equality over two usernames. Compares a fixed
* CREDENTIAL_MAX_USER_LEN-byte window (padding with zeros past each string's
* own length) and folds the length difference into the accumulator, so no byte
* returns early. This closes the byte-wise username-enumeration timing oracle
* that a plain strcmp (which short-circuits on the first differing byte)
* would otherwise expose. Over-long inputs are refused (length differs), which
* is a non-secret branch: usernames are bounded in every caller anyway. */
static bool username_secure_equal(const char* a, const char* b) {
size_t alen = strlen(a);
size_t blen = strlen(b);
if (alen > CREDENTIAL_MAX_USER_LEN || blen > CREDENTIAL_MAX_USER_LEN)
return false;
uint8_t digest[EVP_MAX_MD_SIZE];
unsigned int digest_len = 0;
if (EVP_Digest(password, strlen(password), digest, &digest_len, EVP_sha256(), NULL) != 1)
size_t diff = alen ^ blen;
for (size_t i = 0; i < CREDENTIAL_MAX_USER_LEN; i++) {
unsigned char ac = i < alen ? (unsigned char)a[i] : 0u;
unsigned char bc = i < blen ? (unsigned char)b[i] : 0u;
diff |= (size_t)(ac ^ bc);
}
return diff == 0;
}
bool credentials_get_verifier(const CredentialStore* store, const char* user,
const char* const* module_users, int n, CredentialVerifier* out) {
if (!out)
return false;
if (digest_len != 32)
memset(out, 0, sizeof(*out));
const char* uname = user ? user : "";
/* The dummy verifier is shaped exactly like a hit: the store-wide uniform
* iteration count (default for an empty store) and fixed dummy keys. */
out->iters = (store && store->count > 0) ? store->iters : CREDENTIAL_DEFAULT_ITERS;
memcpy(out->stored_key, k_dummy_stored_key, CREDENTIAL_KEY_LEN);
memcpy(out->server_key, k_dummy_server_key, CREDENTIAL_KEY_LEN);
out->found = false;
/* Deterministic per-username dummy salt: HMAC-SHA256(dummy_key, username)
* truncated to the salt length. Two probes of the same unknown username see
* an identical challenge; distinct usernames differ. A NULL store (never
* reached in production) falls back to the all-zero static key. */
const uint8_t* dummy_key = store ? store->dummy_key : k_dummy_stored_key;
uint8_t mac[CREDENTIAL_KEY_LEN];
if (!hmac_sha256(dummy_key, CREDENTIAL_KEY_LEN, (const uint8_t*)uname, strlen(uname), mac)) {
credentials_burn((char*)mac, sizeof(mac));
return false;
static const char hex[] = "0123456789abcdef";
for (unsigned int i = 0; i < digest_len; i++) {
out_hex[2 * i] = hex[digest[i] >> 4];
out_hex[2 * i + 1] = hex[digest[i] & 0x0f];
}
memcpy(out->salt, mac, CREDENTIAL_SALT_LEN);
credentials_burn((char*)mac, sizeof(mac));
/* Module-list membership: constant-time full scan, no early break, so the
* list is not a username-enumeration oracle. */
bool on_list = false;
for (int i = 0; i < n; i++) {
const char* listed = (module_users && user) ? module_users[i] : NULL;
on_list |= listed ? username_secure_equal(listed, user) : false;
}
/* Store lookup is an unconditional constant-time full scan, executed even for
* an off-list user so a probe that is not on the module list still pays the
* same O(store) cost as one that is; skipping it would reopen an off-list
* timing channel. The real verifier is selected only when the user is both
* on the list and matched in the store. */
const CredentialEntry* match = NULL;
for (int i = 0; store && user && i < store->count; i++) {
if (username_secure_equal(store->entries[i].user, user))
match = &store->entries[i];
}
if (on_list && match) {
memcpy(out->salt, match->salt, CREDENTIAL_SALT_LEN);
out->iters = match->iters;
memcpy(out->stored_key, match->stored_key, CREDENTIAL_KEY_LEN);
memcpy(out->server_key, match->server_key, CREDENTIAL_KEY_LEN);
out->found = true;
}
out_hex[2 * digest_len] = '\0';
return true;
}
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) {
if (err && err_size)
err[0] = '\0';
if (!username_wellformed(user)) {
set_error(err, err_size, "invalid username (1-%d non-whitespace characters)",
CREDENTIAL_MAX_USER_LEN);
return false;
}
if (!password || !out || out_sz == 0) {
set_error(err, err_size, "missing password or output buffer");
return false;
}
if (strlen(password) > CREDENTIAL_MAX_PASSWORD_LEN) {
set_error(err, err_size, "password exceeds %d characters", CREDENTIAL_MAX_PASSWORD_LEN);
return false;
}
if (iters < CREDENTIAL_MIN_ITERS || iters > CREDENTIAL_MAX_ITERS) {
set_error(err, err_size, "iterations %u out of range [%u,%u]", iters, CREDENTIAL_MIN_ITERS,
CREDENTIAL_MAX_ITERS);
return false;
}
uint8_t salt[CREDENTIAL_SALT_LEN];
uint8_t client_key[CREDENTIAL_KEY_LEN];
uint8_t stored_key[CREDENTIAL_KEY_LEN];
uint8_t server_key[CREDENTIAL_KEY_LEN];
char salt_b64[25];
char stored_b64[45];
char server_b64[45];
bool ok =
credentials_random_bytes(salt, sizeof(salt)) &&
credentials_compute_keys(password, salt, iters, client_key, stored_key, server_key) &&
credentials_b64_encode(salt, sizeof(salt), salt_b64, sizeof(salt_b64)) &&
credentials_b64_encode(stored_key, sizeof(stored_key), stored_b64, sizeof(stored_b64)) &&
credentials_b64_encode(server_key, sizeof(server_key), server_b64, sizeof(server_b64));
int written = -1;
if (ok) {
written = snprintf(out, out_sz, "%s:%s%u$%s$%s$%s", user, CREDENTIAL_STORE_PREFIX, iters,
salt_b64, stored_b64, server_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*)salt, sizeof(salt));
/* The base64 encodings of the salt/keys are secret material too (A7-4). */
credentials_burn(salt_b64, sizeof(salt_b64));
credentials_burn(stored_b64, sizeof(stored_b64));
credentials_burn(server_b64, sizeof(server_b64));
if (!ok) {
credentials_burn(out, out_sz);
return false;
}
if (written < 0 || (size_t)written >= out_sz) {
set_error(err, err_size, "output buffer too small for the credential line");
credentials_burn(out, out_sz);
return false;
}
return true;
}
int credentials_hash_file(const char* path, uint32_t iters, FILE* out, char* err, size_t err_size) {
if (err && err_size)
err[0] = '\0';
if (!path || !out) {
set_error(err, err_size, "missing plaintext file or output stream");
return -1;
}
if (iters < CREDENTIAL_MIN_ITERS || iters > CREDENTIAL_MAX_ITERS) {
set_error(err, err_size, "iterations %u out of range [%u,%u]", iters, CREDENTIAL_MIN_ITERS,
CREDENTIAL_MAX_ITERS);
return -1;
}
FILE* fp = secret_file_open(path, err, err_size);
if (!fp)
return -1;
int line_no = 0;
int result = 0;
char line[CREDENTIAL_MAX_LINE + 2];
while (fgets(line, sizeof(line), fp)) {
line_no++;
size_t len = strlen(line);
if (len == CREDENTIAL_MAX_LINE + 1 && line[len - 1] != '\n' && !feof(fp)) {
set_error(err, err_size, "plaintext file '%s' line %d exceeds the %d-byte limit", path,
line_no, CREDENTIAL_MAX_LINE);
result = -1;
break;
}
while (len > 0 && (line[len - 1] == '\n' || line[len - 1] == '\r'))
line[--len] = '\0';
char* cursor = line;
while (*cursor == ' ' || *cursor == '\t')
cursor++;
if (*cursor == '\0' || is_comment_char(*cursor))
continue;
char* colon = strchr(cursor, ':');
if (!colon) {
set_error(err, err_size, "plaintext file '%s' line %d: expected 'user:password'", path,
line_no);
result = -1;
break;
}
*colon = '\0';
const char* user = trim_space(cursor);
const char* password = colon + 1;
if (!username_wellformed(user)) {
set_error(err, err_size, "plaintext file '%s' line %d: invalid username", path, line_no);
result = -1;
break;
}
if (*password == '\0') {
set_error(err, err_size, "plaintext file '%s' line %d: empty password", path, line_no);
result = -1;
break;
}
char store_line[CREDENTIAL_MAX_LINE];
if (!credentials_hash_store_line(user, password, iters, store_line, sizeof(store_line), err,
err_size)) {
credentials_burn(store_line, sizeof(store_line));
result = -1;
break;
}
if (fprintf(out, "%s\n", store_line) < 0) {
set_error(err, err_size, "cannot write hashed credentials: %s", strerror(errno));
credentials_burn(store_line, sizeof(store_line));
result = -1;
break;
}
credentials_burn(store_line, sizeof(store_line));
}
if (result == 0 && ferror(fp)) {
set_error(err, err_size, "error reading plaintext file '%s': %s", path, strerror(errno));
result = -1;
}
credentials_burn(line, sizeof(line));
fclose(fp);
return result;
}
int credentials_read_secret_file(const char* path, char** user_out, char** password_out, char* err,
size_t err_size) {
if (user_out)
@@ -447,66 +1002,3 @@ void credentials_burn(char* secret, size_t len) {
for (size_t i = 0; i < len; i++)
p[i] = '\0';
}
/* Constant-time equality over two usernames. Compares a fixed
* CREDENTIAL_MAX_USER_LEN-byte window (padding with zeros past each string's
* own length) and folds the length difference into the accumulator, so no byte
* returns early. This closes the byte-wise username-enumeration timing oracle
* that a plain strcmp (which short-circuits on the first differing byte)
* would otherwise expose. Over-long inputs are refused (length differs), which
* is a non-secret branch: usernames are bounded in every caller anyway. */
static bool username_secure_equal(const char* a, const char* b) {
size_t alen = strlen(a);
size_t blen = strlen(b);
if (alen > CREDENTIAL_MAX_USER_LEN || blen > CREDENTIAL_MAX_USER_LEN)
return false;
size_t diff = alen ^ blen;
for (size_t i = 0; i < CREDENTIAL_MAX_USER_LEN; i++) {
unsigned char ac = i < alen ? (unsigned char)a[i] : 0u;
unsigned char bc = i < blen ? (unsigned char)b[i] : 0u;
diff |= (size_t)(ac ^ bc);
}
return diff == 0;
}
/* Fixed 64-lowercase-hex dummy used for a constant-time digest comparison when
* the presented user is unknown, so the verify path takes the same time for an
* unknown user and a wrong password. Value chosen arbitrarily; it can never
* authenticate because a real store entry is preferred when it exists. */
static const char k_dummy_hash[CREDENTIAL_HASH_HEX_LEN + 1] =
"0000000000000000000000000000000000000000000000000000000000000000";
bool credentials_verify(const CredentialStore* store, const char* user,
const char* presented_hash_hex) {
if (!store || !user || !presented_hash_hex || !credentials_hash_valid(presented_hash_hex))
return false;
const char* stored = k_dummy_hash;
for (int i = 0; i < store->count; i++) {
/* Constant-time username match: no early return, so time depends on the
* fixed compare window and a byte-wise prefix match cannot be observed. */
if (username_secure_equal(store->entries[i].user, user))
stored = store->entries[i].password_hex;
}
return credentials_secure_equal(presented_hash_hex, stored, CREDENTIAL_HASH_HEX_LEN);
}
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) {
if (!store || module_user_count < 0)
return false; /* fail closed: an auth-required module without a store refuses */
if (!presented_user || !presented_hash_hex)
return false; /* no credentials presented */
bool on_module_list = false;
for (int i = 0; i < module_user_count; i++) {
/* Constant-time match against the module's auth-users list, for the same
* reason as credentials_verify, so the list is not an enumeration oracle. */
if (module_users[i] && username_secure_equal(module_users[i], presented_user)) {
on_module_list = true;
break;
}
}
if (!on_module_list)
return false;
return credentials_verify(store, presented_user, presented_hash_hex);
}
+138 -73
View File
@@ -3,46 +3,71 @@
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
/* Daemon password authentication (Wave B).
/* Daemon password authentication (A7 remediation, protocol 2.19.0).
*
* FastSync authenticates a daemon connection with a username plus a SHA-256
* hex digest of that username's password. The digest is what crosses the
* wire: a challenge-less credential exchange, so the literal password is never
* transmitted (and never stored on the daemon host). A module that declares
* `auth users` demands that the presented username is on its list AND that the
* presented digest matches the credential store's entry for that username.
* The digest comparison is constant-time; a module with `auth users` whose
* store is missing/misconfigured fails CLOSED (never falls open).
* FastSync authenticates a daemon connection with a SCRAM-SHA-256-style
* challenge/response handshake. The daemon stores only a salted PBKDF2
* verifier (never the password, and never a value that can be replayed as a
* bearer credential): the client proves knowledge of the password against a
* per-connection server nonce, and the server proves the same shared secret
* back. See credentials.c for the exact derivation.
*
* Credential store format (server --password-file and --early-input): one
* `user:SHA256HEX` entry per line. SHA256HEX is the lowercase hex SHA-256 of
* the user's password -- the exact value a FastSync client transmits. Blank
* lines and lines whose first non-space character is '#' or ';' are comments.
* The parser is STRICT: a malformed line (no ':', an empty/whitespace user, a
* secret that is not 64 lowercase hex chars, a line longer than
* CREDENTIAL_MAX_LINE) fails the whole load so a typo can never silently
* change who may log in.
* Server credential store format (--password-file and --early-input): one line
* per entry,
* user:$fastsync$1$pbkdf2-sha256$<iters>$<salt_b64>$<stored_key_b64>$<server_key_b64>
* with standard base64, a 16-byte salt and 32-byte keys, and iters in
* [CREDENTIAL_MIN_ITERS, CREDENTIAL_MAX_ITERS]. Blank lines and lines whose
* first non-space character is '#' or ';' are comments. The parser is STRICT:
* a malformed line fails the whole load so a typo can never silently change who
* may log in. A line holding the legacy (unsalted SHA-256 hex) secret is
* hard-rejected with an actionable "legacy" error; there is no auto-upgrade.
* Use `fastsync-server --hash-credentials` to generate new-format lines.
*
* Client --password-file format: the FIRST meaningful (non-comment, non-blank)
* line is `user:password`, holding the literal password. The client hashes it
* and sends only the digest; the file should be mode 0600 and readable only by
* its owner.
*/
* line is `user:password`, holding the literal password. The client keeps it
* only for the duration of the handshake and wipes it at teardown; the file
* should be mode 0600 and readable only by its owner. */
/* Lowercase hex length of a SHA-256 digest (what travels on the wire and what
* the server store holds). */
#define CREDENTIAL_HASH_HEX_LEN 64
/* Longest accepted credential-file line (excluding the trailing newline). */
#define CREDENTIAL_MAX_LINE 4096
/* Upper bound on a username in a credential file and on the wire. Kept well
* below MAX_STRING_SIZE so a wire username can never exhaust anything. */
#define CREDENTIAL_MAX_USER_LEN 256
/* Upper bound on a client-file password (before hashing). */
/* Upper bound on a client-file password (before derivation). */
#define CREDENTIAL_MAX_PASSWORD_LEN 1024
/* SCRAM-SHA-256 parameters. Salt and client nonce sizes are fixed by the
* shared-auth-message framing; keys are always 32 bytes (SHA-256). */
#define CREDENTIAL_SALT_LEN 16
#define CREDENTIAL_NONCE_LEN 32
#define CREDENTIAL_KEY_LEN 32
#define CREDENTIAL_DEFAULT_ITERS 600000u
#define CREDENTIAL_MIN_ITERS 100000u
#define CREDENTIAL_MAX_ITERS 10000000u
/* Buffer size for the full AuthMessage (prefix + three length-prefixed fields).
* Worst case: 16 + 4 + 256 + 4 + 32 + 4 + 32. */
#define CREDENTIAL_AUTH_MESSAGE_MAX \
(16 + 4 + CREDENTIAL_MAX_USER_LEN + 4 + CREDENTIAL_NONCE_LEN + 4 + CREDENTIAL_NONCE_LEN)
typedef struct CredentialStore CredentialStore;
/* One resolved verifier. `found` is false for an unknown user or a user not on
* a module's auth list; the remaining fields then hold a deterministic dummy
* salt (HMAC of the store-wide dummy key over the username), the store-wide
* uniform iteration count (default for an empty store) and fixed dummy keys, so
* the server can run the same challenge/response math with no enumeration or
* timing oracle. */
typedef struct {
uint8_t salt[CREDENTIAL_SALT_LEN];
uint32_t iters;
uint8_t stored_key[CREDENTIAL_KEY_LEN];
uint8_t server_key[CREDENTIAL_KEY_LEN];
bool found;
} CredentialVerifier;
/* Load the daemon credential store.
*
* password_file and early_input_file are both NULL-or-path, matching the
@@ -50,72 +75,112 @@ typedef struct CredentialStore CredentialStore;
* opened or that fails the strict grammar is a hard error (err filled, NULL
* returned) -- the daemon fails CLOSED rather than serving an auth-required
* module with a partial store. Both files may be NULL, which yields an empty
* store (every auth-required module then refuses connections). When both are
* given, the --early-input file is layered over --password-file: a duplicate
* username whose secret matches is deduplicated; one whose secret differs is
* an error (the two sources disagree), never a silent pick.
* store (every auth-required module then refuses connections). Every entry in
* the resulting store must agree on the iteration count; entries that disagree
* (within one file or across the two layered sources) are rejected. When both
* are given, the --early-input file is layered over --password-file: a duplicate
* username whose verifier matches is deduplicated; one whose verifier differs
* is an error (the two sources disagree), never a silent pick.
*
* The returned store is heap-owned; free it with credentials_free. */
CredentialStore* credentials_load(const char* password_file, const char* early_input_file,
char* err, size_t err_size);
/* Wipe every stored key/salt and free the store. */
void credentials_free(CredentialStore* store);
/* True when `hash_hex` is exactly CREDENTIAL_HASH_HEX_LEN lowercase hex digits
* (the wire/store digest form). Used to reject a malformed presented digest
* before it reaches the comparison. */
bool credentials_hash_valid(const char* hash_hex);
/* True when `user` is a single bounded token free of whitespace/control bytes
* (the rule applied to store users, client-file users and the module list). */
bool credentials_username_valid(const char* user);
/* Compute the lowercase hex SHA-256 of `password` into out_hex, which must
* hold at least CREDENTIAL_HASH_HEX_LEN + 1 bytes. Returns false on a NULL
* password or a hashing failure. The output is NUL-terminated. */
bool credentials_hash_password(const char* password, char* out_hex);
/* Standard base64. encode writes NUL-terminated output to out (size out_sz).
* decode writes the raw bytes to out (capacity out_sz) and stores the length;
* the input must be a well-formed padded base64 string. Both return false on
* NULL arguments, a bad character/length, or insufficient output space. */
bool credentials_b64_encode(const uint8_t* in, size_t n, char* out, size_t out_sz);
bool credentials_b64_decode(const char* in, uint8_t* out, size_t out_sz, size_t* out_len);
/* Fill out[0..n) from the CSPRNG (RAND_bytes). Returns false on failure. */
bool credentials_random_bytes(uint8_t* out, size_t n);
/* Resolve `user` against the store AND the module's auth-user list. The list
* scan is a constant-time full-length comparison with no early break. On a
* miss, *out is filled with a dummy verifier (a deterministic per-username salt
* derived from the store's dummy key, the store-wide uniform iteration count,
* fixed dummy keys, found=false). Returns false on invalid arguments or an
* HMAC/crypto primitive failure. */
bool credentials_get_verifier(const CredentialStore* store, const char* user,
const char* const* module_users, int n, CredentialVerifier* out);
/* Derive the SCRAM keys from a plaintext password:
* K = PBKDF2-HMAC-SHA256(password, salt, iters, 32)
* ClientKey = HMAC-SHA256(K, "Client Key"); StoredKey = SHA256(ClientKey)
* ServerKey = HMAC-SHA256(K, "Server Key")
* Any of client_key/stored_key/server_key may be NULL when not needed.
* `iters` must lie in [CREDENTIAL_MIN_ITERS, CREDENTIAL_MAX_ITERS]. */
bool credentials_compute_keys(const char* password, const uint8_t salt[CREDENTIAL_SALT_LEN],
uint32_t iters, uint8_t client_key[CREDENTIAL_KEY_LEN],
uint8_t stored_key[CREDENTIAL_KEY_LEN],
uint8_t server_key[CREDENTIAL_KEY_LEN]);
/* Serialize the shared AuthMessage:
* "FastSync-Auth-v1" || be32(len(user)) || user
* || be32(32) || server_nonce
* || be32(32) || client_nonce
* out must hold at least CREDENTIAL_AUTH_MESSAGE_MAX bytes. *out_len receives
* the number of bytes written. */
bool credentials_build_auth_message(const char* user, const uint8_t* snonce, const uint8_t* cnonce,
uint8_t* out, size_t out_sz, size_t* out_len);
/* Client side: ClientProof = ClientKey XOR HMAC(StoredKey, AuthMessage), and
* the expected ServerSignature = HMAC(ServerKey, AuthMessage). */
bool credentials_client_proof(const uint8_t client_key[CREDENTIAL_KEY_LEN],
const uint8_t stored_key[CREDENTIAL_KEY_LEN],
const uint8_t server_key[CREDENTIAL_KEY_LEN], const uint8_t* auth_msg,
size_t msg_len, uint8_t proof[CREDENTIAL_KEY_LEN],
uint8_t server_sig[CREDENTIAL_KEY_LEN]);
/* Server side: recompute ClientSig' = HMAC(StoredKey, AuthMessage) and
* ClientKey' = proof XOR ClientSig', then accept iff v->found AND
* SHA256(ClientKey') equals StoredKey (constant-time over the 32-byte keys).
* Always computes server_sig_out = HMAC(ServerKey, AuthMessage). Returns the
* accept decision. */
bool credentials_verify_response(const CredentialVerifier* v, const char* user,
const uint8_t* snonce, const uint8_t* cnonce,
const uint8_t proof[CREDENTIAL_KEY_LEN],
uint8_t server_sig_out[CREDENTIAL_KEY_LEN]);
/* Derive a new-format store line for `user`/`password` and write it (without a
* trailing newline) into out. A random 16-byte salt is used. On failure err is
* filled. Used by --hash-credentials and by tests. */
bool credentials_hash_store_line(const char* user, const char* password, uint32_t iters, char* out,
size_t out_sz, char* err, size_t err_size);
/* Read `user:password` lines from `path` (the same owner-only check as the
* other secret files) and write one new-format store line per entry to `out`.
* Blank/comment lines are skipped; a malformed line fails the whole run.
* Returns 0 on success, -1 on error (err filled). Used by
* `--hash-credentials`. */
int credentials_hash_file(const char* path, uint32_t iters, FILE* out, char* err, size_t err_size);
/* Read the CLIENT-side secret file: the first meaningful line is
* `user:password` (the literal password). *user_out and *password_out are
* freshly allocated on success (password is plaintext -- the caller hashes it
* and then burns/frees it); both are NULL on error. Returns 0 on success, -1
* on failure (err filled: the path is named, never the credential itself).
* Only the line's trailing CR/LF are stripped: the password's bytes are
* otherwise preserved exactly, so a password with leading/trailing whitespace
* (after the ':') is kept usable. The username is trimmed of surrounding
* space/tabs. */
* freshly allocated on success (password is plaintext -- the caller derives the
* proof and then burns/frees it); both are NULL on error. Returns 0 on
* success, -1 on failure (err filled: the path is named, never the credential
* itself). Only the line's trailing CR/LF are stripped: the password's bytes
* are otherwise preserved exactly, so a password with leading/trailing
* whitespace (after the ':') is kept usable. The username is trimmed of
* surrounding space/tabs. */
int credentials_read_secret_file(const char* path, char** user_out, char** password_out, char* err,
size_t err_size);
/* Constant-time equality over exactly len bytes. Returns true when the two
* buffers match. No early exit: the whole length is always scanned, so a
* timing side-channel cannot reveal how many leading bytes matched. */
/* Constant-time equality over exactly len bytes. */
bool credentials_secure_equal(const char* a, const char* b, size_t len);
/* Overwrite secret[0..len) with zeros (best-effort wipe of a plaintext
* password that is about to be freed). */
/* Overwrite secret[0..len) with zeros (best-effort wipe). */
void credentials_burn(char* secret, size_t len);
/* Verify a presented (user, digest) against the store. Returns true only when
* the store holds an entry for `user` whose stored digest equals the presented
* one. A NULL store, NULL user/digest, unknown user and wrong digest all
* return false. The digest comparison runs over a fixed dummy whenever the
* user is absent, and the username lookup is a single constant-time
* full-length compare (no byte-wise early exit), so neither "unknown user" vs
* "wrong password" nor a username prefix match can be distinguished by timing
* (no user-enumeration oracle in the comparison path). */
bool credentials_verify(const CredentialStore* store, const char* user,
const char* presented_hash_hex);
/* The daemon's per-module auth decision, in one pure, unit-testable function.
* `module_users`/`module_user_count` are the module's `auth users` list; a
* module that declares auth users requires the presented user to be ON that
* list AND to verify against the store. Returns false (fail closed) when the
* store is NULL, when no credential was presented, when the user is not on the
* module's list, or when verification fails. This is the single decision the
* server_module_gate seam applies to an auth-required module. Like
* credentials_verify, username matches here use a constant-time full-length
* compare rather than a byte-wise-short-circuiting strcmp. */
bool credentials_gate_allows(const CredentialStore* store, const char* const* module_users,
int module_user_count, const char* presented_user,
const char* presented_hash_hex);
/* Number of entries currently in the store (tests/introspection). */
int credentials_store_size(const CredentialStore* store);
+14 -4
View File
@@ -413,15 +413,24 @@ static const char* status_to_string(Status status) {
return "SPECIAL";
case STATUS_DIR_TIMES:
return "DIR_TIMES";
case STATUS_AUTH_CHALLENGE:
return "AUTH_CHALLENGE";
case STATUS_AUTH_RESPONSE:
return "AUTH_RESPONSE";
case STATUS_AUTH_OK:
return "AUTH_OK";
case STATUS_AUTH_FAILED:
return "AUTH_FAILED";
default:
return "UNKNOWN";
}
}
/* Shared string send/receive implementation. `redact` selects whether the
* payload body is written to the LOG_DEBUG_PROTO debug log: secrets (daemon
* auth username/digest) set it so a --verbose log never captures a replayable
* credential, while every other string keeps its normal debug trace. */
* payload body is written to the LOG_DEBUG_PROTO debug log: daemon auth material
* (the username and the proof/signature fields) sets it so a --verbose log never
* captures a replayable credential, while every other string keeps its normal
* debug trace. */
static bool protocol_send_str_impl(ProtocolSession* session, const char* data, bool redact) {
if (data == NULL)
return false;
@@ -594,7 +603,8 @@ char* receive_str(int fd) {
return protocol_receive_str(legacy_session(fd, -1));
}
/* Redacted variants: identical framing, but the string body is never written to
the debug protocol log. Used for the daemon auth username/digest. */
the debug protocol log. Used for daemon auth material (username, proof,
signature). */
bool send_str_redacted(int fd, const char* data) {
return protocol_send_str_redacted(legacy_session(-1, fd), data);
}
+17 -3
View File
@@ -113,7 +113,20 @@ enum NET_STATUS {
* than MAX_MANIFEST_ENTRIES is split across repeated frames. The receiver
* defers the actual utimensat until its own delete/publish phase has
* committed, then skips the whole set when -O/--omit-dir-times is set. */
STATUS_DIR_TIMES
STATUS_DIR_TIMES,
/* Daemon SCRAM-SHA-256 authentication (A7 remediation, protocol 2.19.0).
* STATUS_AUTH_CHALLENGE: the server requires auth and is about to send the
* iteration count, the base64 salt and the base64 server nonce.
* STATUS_AUTH_RESPONSE: the client's reply, followed by the base64 client
* nonce and the base64 ClientProof. STATUS_AUTH_OK: the client proof
* verified, followed by the base64 ServerSignature. STATUS_AUTH_FAILED:
* a single generic refusal (unknown user, off-list user, wrong proof,
* missing/malformed credentials) after which the server closes without
* writing any data. */
STATUS_AUTH_CHALLENGE,
STATUS_AUTH_RESPONSE,
STATUS_AUTH_OK,
STATUS_AUTH_FAILED
};
void io_set_fds(int read_fd, int write_fd);
@@ -138,8 +151,9 @@ bool protocol_send_str(ProtocolSession* session, const char* data);
char* protocol_receive_str(ProtocolSession* session);
/* Redacted string variants: identical wire framing to protocol_send_str /
* protocol_receive_str, but the payload body is replaced by `<redacted>` in the
* LOG_DEBUG_PROTO debug log. Used for secrets (daemon auth username/digest) so
* a --verbose log can never capture a replayable credential. */
* LOG_DEBUG_PROTO debug log. Used for daemon auth material (the username and
* the proof/signature fields) so a --verbose log can never capture a credential
* that could be replayed. */
bool protocol_send_str_redacted(ProtocolSession* session, const char* data);
char* protocol_receive_str_redacted(ProtocolSession* session);
bool protocol_send_data(ProtocolSession* session, const Data* data);
+105
View File
@@ -26,10 +26,12 @@
* pre-loaded into a second pipe so a single thread suffices.
*/
#include "config.h"
#include "credentials.h"
#include "protocol.h"
#include "utils.h"
#include <errno.h>
#include <fcntl.h>
#include <openssl/evp.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
@@ -50,6 +52,8 @@ static unsigned char* g_frame;
static size_t g_frame_len;
static size_t g_version_len; /* length of the leading version-string frame */
static size_t g_usermap_count_off; /* offset of the usermap count int, 0 = unknown */
static size_t g_auth_off; /* offset of the auth presence int, 0 = unknown */
static bool g_auth_found; /* whether g_auth_off is valid */
static bool g_frame_ready;
/* Read the canonical frame from the send peer. The producer shuts down its
@@ -97,6 +101,10 @@ static void build_canonical_frame(void) {
return;
cfg->send_directory = str_dup("/src");
cfg->receive_root_directory = str_dup("/dst");
/* Force the shortened auth block (`[present][username]`) to be present so the
* fuzzer can mutate it. */
cfg->auth_user = str_dup("alice");
cfg->auth_password = str_dup("alice-s3cret");
/* Force the three P8 tail fields to be present (copy-as requires metadata). */
cfg->copy_as_set = true;
cfg->copy_as_uid = 0;
@@ -171,6 +179,97 @@ out:
}
}
}
/* Locate the auth username string (a size_t length followed by its bytes);
* the presence int sits one int before the length. The username bytes cannot
* start before sizeof(size_t)+sizeof(int) without the presence-int offset
* underflowing, so begin the scan there. */
const char* auth_name = "alice";
size_t auth_name_len = strlen(auth_name);
if (g_frame_len >= sizeof(size_t) + auth_name_len + sizeof(int)) {
for (size_t i = sizeof(size_t) + sizeof(int); i + auth_name_len <= g_frame_len; i++) {
if (memcmp(g_frame + i, auth_name, auth_name_len) != 0)
continue;
size_t found_len = 0;
memcpy(&found_len, g_frame + i - sizeof(size_t), sizeof(size_t));
if (found_len == auth_name_len) {
g_auth_off = i - sizeof(size_t) - sizeof(int);
g_auth_found = true;
break;
}
}
}
}
/* Fuzz the A7 auth crypto primitives directly: arbitrary bytes through the
* base64 decoder, plus a self-consistent SCRAM property (a proof built from a
* chosen client key must verify, while a tampered proof, a proof replayed
* against a different nonce, and a not-found verifier must all be refused). */
static uint8_t pick_byte(const uint8_t* data, size_t size, size_t index) {
return size ? data[index % size] : 0;
}
static void fuzz_credentials(const uint8_t* data, size_t size) {
char b64[300];
size_t n = size < sizeof(b64) - 1 ? size : sizeof(b64) - 1;
memcpy(b64, data, n);
b64[n] = '\0';
uint8_t decoded[64];
size_t decoded_len = 0;
(void)credentials_b64_decode(b64, decoded, sizeof(decoded), &decoded_len);
uint8_t client_key[CREDENTIAL_KEY_LEN];
uint8_t stored_key[CREDENTIAL_KEY_LEN];
uint8_t server_key[CREDENTIAL_KEY_LEN];
uint8_t snonce[CREDENTIAL_NONCE_LEN];
uint8_t cnonce[CREDENTIAL_NONCE_LEN];
for (size_t i = 0; i < CREDENTIAL_KEY_LEN; i++) {
client_key[i] = pick_byte(data, size, i);
server_key[i] = pick_byte(data, size, i + CREDENTIAL_KEY_LEN);
}
for (size_t i = 0; i < CREDENTIAL_NONCE_LEN; i++) {
snonce[i] = pick_byte(data, size, i + 2 * CREDENTIAL_KEY_LEN);
cnonce[i] = pick_byte(data, size, i + 2 * CREDENTIAL_KEY_LEN + CREDENTIAL_NONCE_LEN);
}
unsigned int stored_len = 0;
if (EVP_Digest(client_key, sizeof(client_key), stored_key, &stored_len, EVP_sha256(), NULL) !=
1 ||
stored_len != CREDENTIAL_KEY_LEN)
return;
uint8_t auth_msg[CREDENTIAL_AUTH_MESSAGE_MAX];
size_t msg_len = 0;
if (!credentials_build_auth_message("alice", snonce, cnonce, auth_msg, sizeof(auth_msg),
&msg_len))
return;
uint8_t proof[CREDENTIAL_KEY_LEN];
uint8_t server_sig[CREDENTIAL_KEY_LEN];
if (!credentials_client_proof(client_key, stored_key, server_key, auth_msg, msg_len, proof,
server_sig))
return;
CredentialVerifier verifier;
memset(&verifier, 0, sizeof(verifier));
verifier.found = true;
verifier.iters = CREDENTIAL_DEFAULT_ITERS;
memcpy(verifier.stored_key, stored_key, CREDENTIAL_KEY_LEN);
memcpy(verifier.server_key, server_key, CREDENTIAL_KEY_LEN);
uint8_t out_sig[CREDENTIAL_KEY_LEN];
if (!credentials_verify_response(&verifier, "alice", snonce, cnonce, proof, out_sig))
abort();
if (memcmp(out_sig, server_sig, CREDENTIAL_KEY_LEN) != 0)
abort();
uint8_t bad_proof[CREDENTIAL_KEY_LEN];
memcpy(bad_proof, proof, CREDENTIAL_KEY_LEN);
bad_proof[pick_byte(data, size, 0) % CREDENTIAL_KEY_LEN] ^= 0x01;
if (credentials_verify_response(&verifier, "alice", snonce, cnonce, bad_proof, out_sig))
abort();
uint8_t other_cnonce[CREDENTIAL_NONCE_LEN];
memcpy(other_cnonce, cnonce, CREDENTIAL_NONCE_LEN);
other_cnonce[pick_byte(data, size, 1) % CREDENTIAL_NONCE_LEN] ^= 0x80;
if (credentials_verify_response(&verifier, "alice", snonce, other_cnonce, proof, out_sig))
abort();
verifier.found = false;
if (credentials_verify_response(&verifier, "alice", snonce, cnonce, proof, out_sig))
abort();
}
/* Best-effort non-blocking write: an oversized fuzz input is truncated rather
@@ -219,6 +318,8 @@ static void receive_stream(const unsigned char* prefix, size_t prefix_len, const
}
int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) {
fuzz_credentials(data, size);
if (!g_frame_ready)
build_canonical_frame();
@@ -229,6 +330,10 @@ int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) {
/* Keep the valid version prefix, fuzz everything after it. */
receive_stream(g_frame, g_version_len, data, size);
/* Keep the valid frame up to the shortened auth block, fuzz it. */
if (g_auth_found)
receive_stream(g_frame, g_auth_off, data, size);
/* Keep the valid frame up to the P8 tail, fuzz super_mode + copy-as. */
if (g_frame_len > P8_TAIL_BYTES)
receive_stream(g_frame, g_frame_len - P8_TAIL_BYTES, data, size);
+244 -22
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
in the configured module root only. Read-only modules, unknown modules, and
auth-required modules without valid credentials are all refused cleanly before
any data moves. Wave B (daemon authentication) adds the real credential
round-trips exercised in TestDaemonAuthentication: modules that declare
`auth users` accept only a client whose --password-file presents a username on
the module's list with a matching password (verified as a SHA-256 digest), and
the daemon refuses to start when such a module has no credential store.
any data moves. The A7 auth wave adds the real credential round-trips exercised
in TestDaemonAuthentication: modules that declare `auth users` accept only a
client whose --password-file presents a username on the module's list, proven
through a SCRAM-SHA-256-style challenge/response against a salted PBKDF2
verifier. The daemon refuses to start when such a module has no credential
store, a legacy SHA-256 store line is hard-rejected, and a replayed response
from another connection is refused.
"""
import base64
import glob
import hashlib
import hmac
import os
import select
import shutil
import signal
import socket
import stat
import struct
import subprocess
import sys
import tempfile
@@ -58,9 +65,39 @@ ALICE_PASS = "alice-s3cret"
BOB_PASS = "bob-s3cret"
WRONG_PASS = "wrong-password"
# The store holds a salted PBKDF2 verifier (A7 SCRAM); this is the exact
# derivation the C implementation performs, recomputed here so the tests are an
# independent reference. 100000 keeps the module import fast while staying at
# the validation minimum.
CRED_ITERS = 100000
def _pw_hash(password):
return hashlib.sha256(password.encode()).hexdigest()
def _verifier(password, salt, iters=CRED_ITERS):
key = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, iters, 32)
client_key = hmac.new(key, b"Client Key", hashlib.sha256).digest()
stored_key = hashlib.sha256(client_key).digest()
server_key = hmac.new(key, b"Server Key", hashlib.sha256).digest()
return stored_key, server_key
def _store_line(user, password, iters=CRED_ITERS, salt=None):
if salt is None:
salt = os.urandom(16)
stored_key, server_key = _verifier(password, salt, iters)
return "%s:$fastsync$1$pbkdf2-sha256$%d$%s$%s$%s" % (
user, iters, base64.b64encode(salt).decode(),
base64.b64encode(stored_key).decode(), base64.b64encode(server_key).decode())
def _store_secrets(line):
"""The base64 stored_key/server_key fields of a store line (the values that
must never appear in a log)."""
parts = line.split("$")
return parts[-2], parts[-1]
ALICE_LINE = _store_line("alice", ALICE_PASS)
BOB_LINE = _store_line("bob", BOB_PASS)
def _write_client_password_file(path, user, password):
@@ -159,12 +196,12 @@ def daemon_env():
os.makedirs(d, exist_ok=True)
generate_test_files(SOURCE_DIR, full=False)
# Server-side credential store: alice and bob (password digests only; the
# plaintext passwords never appear on the daemon host or in any log).
# Server-side credential store: alice and bob (salted PBKDF2 verifiers only;
# the plaintext passwords never appear on the daemon host or in any log).
with open(CRED_FILE, "w") as f:
f.write("# daemon credential store (Wave B)\n")
f.write("alice:%s\n" % _pw_hash(ALICE_PASS))
f.write("bob:%s\n" % _pw_hash(BOB_PASS))
f.write("# daemon credential store (A7 SCRAM)\n")
f.write(ALICE_LINE + "\n")
f.write(BOB_LINE + "\n")
os.chmod(CRED_FILE, 0o600)
# The config's port is a free port chosen per worker; the `daemon` fixture
@@ -518,6 +555,135 @@ class TestDaemonRejection:
d.stop()
# Numeric status values (must match the enum order in src/shared/protocol.h).
STATUS_AUTH_CHALLENGE = 21
STATUS_AUTH_RESPONSE = 22
_AUTH_FRAME_MAX = 1 << 20
def _wire_string_frame_len(buf, off):
"""Return the total byte length of the wire string at buf[off], or None when
more bytes are needed."""
if len(buf) < off + 8:
return None
(length,) = struct.unpack_from("<Q", buf, off)
if length > _AUTH_FRAME_MAX:
raise ValueError("oversized auth frame string")
if len(buf) < off + 8 + length:
return None
return 8 + length
def _client_cmd(dest, port, cred_path):
return CLIENT_CMD + ["--source-dir", SOURCE_DIR, "--dest-dir", dest,
"--save-to-disk", "--server-port", str(port),
"--password-file", cred_path]
class _AuthReplayProxy:
"""A one-connection-at-a-time TCP relay in front of the daemon.
The capture connection records the client's STATUS_AUTH_RESPONSE frame (the
status, the client nonce string and the proof string); the replay connection
substitutes that recorded frame for its own response, so the daemon sees a
proof bound to the FIRST connection's challenge nonce."""
def __init__(self, backend_port):
self.backend = ("127.0.0.1", backend_port)
self.server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
self.server.bind(("127.0.0.1", 0))
self.server.listen(4)
self.server.settimeout(20)
self.port = self.server.getsockname()[1]
self.stolen = None
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:
off = 4 + 4 # status int + iteration int
for _ in range(2):
frame = _wire_string_frame_len(buf_s, off)
if frame is None:
break
off += frame
else:
client.sendall(buf_s[:off])
buf_s = buf_s[off:]
state = "auth"
else:
if buf_s:
client.sendall(buf_s)
buf_s = b""
state = "relay"
elif state == "auth":
if len(buf_c) >= 4:
off = 4
for _ in range(2):
frame = _wire_string_frame_len(buf_c, off)
if frame is None:
break
off += frame
else:
response = buf_c[:off]
buf_c = buf_c[off:]
if capture:
self.stolen = response
backend.sendall(response)
else:
assert self.stolen is not None
backend.sendall(self.stolen)
state = "relay"
if buf_s:
client.sendall(buf_s)
buf_s = b""
else:
if buf_c:
backend.sendall(buf_c)
buf_c = b""
if buf_s:
client.sendall(buf_s)
buf_s = b""
if eof:
break
finally:
client.close()
backend.close()
class TestDaemonAuthentication:
"""Wave B password authentication round-trips on the shared daemon (its
config declares `locked` with `auth users = alice` and `team` with
@@ -640,8 +806,63 @@ class TestDaemonAuthentication:
finally:
d.stop()
@pytest.mark.ci
def test_replayed_auth_response_rejected(self, daemon):
"""A7 replay defense: an auth response captured from one connection is
refused on a second connection (the proof is bound to the challenge
nonce), and nothing is written to the module root."""
proxy = _AuthReplayProxy(daemon.port)
try:
cred_a = os.path.join(TEST_DATA_DIR, "replay_a.pw")
_write_client_password_file(cred_a, "alice", ALICE_PASS)
proc_a = subprocess.Popen(_client_cmd("127.0.0.1::locked", proxy.port, cred_a),
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
proxy._run_connection(capture=True)
out_a, err_a = proc_a.communicate(timeout=30)
assert proc_a.returncode == 0, err_a or out_a
assert proxy.stolen is not None
os.unlink(cred_a)
before = _tree_file_count(AUTH_MODULE)
cred_b = os.path.join(TEST_DATA_DIR, "replay_b.pw")
_write_client_password_file(cred_b, "alice", ALICE_PASS)
proc_b = subprocess.Popen(_client_cmd("127.0.0.1::locked", proxy.port, cred_b),
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
proxy._run_connection(capture=False)
out_b, err_b = proc_b.communicate(timeout=30)
assert proc_b.returncode != 0, "a replayed auth response must be refused"
assert _tree_file_count(AUTH_MODULE) == before, \
"a replayed auth response wrote data"
os.unlink(cred_b)
finally:
proxy.close()
def test_legacy_store_refuses_to_start(self):
"""A legacy `user:SHA256HEX` store is hard-rejected: the daemon must not
start and must never accept a replayable bearer digest."""
legacy = os.path.join(TEST_DATA_DIR, "fastsyncd_legacy.passwd")
with open(legacy, "w") as f:
f.write("alice:9b90e524e94995ee4aeae2ee3c428a53405d1e8db147f44facc46797d0caf4c3\n")
os.chmod(legacy, 0o600)
conf = os.path.join(TEST_DATA_DIR, "fastsyncd_legacy.conf")
port = _find_free_port()
with open(conf, "w") as f:
f.write("port = %d\n\n[locked]\npath = %s\nauth users = alice\n" % (port, AUTH_MODULE))
try:
proc = subprocess.run(
SERVER_CMD + ["--daemon", "--config", conf, "--no-detach",
"--password-file", legacy],
capture_output=True, text=True, timeout=15)
assert proc.returncode != 0
combined = (proc.stderr or "") + (proc.stdout or "")
assert "legacy" in combined
assert "alice" in combined
finally:
os.unlink(legacy)
os.unlink(conf)
def test_auth_log_does_not_leak_password(self, daemon):
"""The daemon log must never contain the password or its digest."""
"""The daemon log must never contain the password or the store verifier."""
log_path = os.path.join(TEST_DATA_DIR, "fastsyncd.log")
before = os.path.getsize(log_path) if os.path.exists(log_path) else 0
_push_with_creds("127.0.0.1::locked", daemon.port, "alice", WRONG_PASS)
@@ -652,15 +873,15 @@ class TestDaemonAuthentication:
tail = f.read().decode("utf-8", "replace")
assert ALICE_PASS not in tail
assert WRONG_PASS not in tail
assert _pw_hash(ALICE_PASS) not in tail
assert _pw_hash(WRONG_PASS) not in tail
for secret in _store_secrets(ALICE_LINE):
assert secret not in tail
assert "$fastsync$" not in tail
def test_auth_digest_not_logged_at_debug_level(self):
def test_auth_secrets_not_logged_at_debug_level(self):
"""Under --verbose the daemon enables LOG_DEBUG_ALL, which normally
traces every protocol string -- the auth username/digest must NOT leak
into that trace even then. The redacted marker is logged instead, and
the digest/username/password never appear while debug protocol logging
is actually proving itself active."""
traces every protocol string -- the auth username/proof/signature must
NOT leak into that trace even then. The redacted marker is logged
instead, while debug protocol logging is actually proving itself active."""
d = DaemonManager()
port = _find_free_port()
try:
@@ -680,8 +901,9 @@ class TestDaemonAuthentication:
# The secret-worthy fields must never appear, at any log level.
assert ALICE_PASS not in log
assert WRONG_PASS not in log
assert _pw_hash(ALICE_PASS) not in log
assert _pw_hash(WRONG_PASS) not in log
for secret in _store_secrets(ALICE_LINE):
assert secret not in log
assert "$fastsync$" not in log
class TestDaemonMotd:
+3 -3
View File
@@ -94,14 +94,14 @@ def _seed_protocol_source(source):
class TestProtocol:
@pytest.mark.ci
def test_protocol_current_version_accepted(self, shared_server):
"""--protocol=2.18.0 (the current PROTOCOL_VERSION) is accepted and the
"""--protocol=2.19.0 (the current PROTOCOL_VERSION) is accepted and the
transfer completes normally."""
source = os.path.join(TEST_DATA_DIR, "proto_ok_src")
dest = os.path.join(TEST_DATA_DIR, "proto_ok_dst")
shutil.rmtree(dest, ignore_errors=True)
os.makedirs(dest)
_seed_protocol_source(source)
result, _ = run_client(source, dest, flags=["--protocol=2.18.0"],
result, _ = run_client(source, dest, flags=["--protocol=2.19.0"],
port=shared_server.port)
assert result.returncode == 0, \
f"--protocol current run failed: {(result.stderr or result.stdout)[:400]}"
@@ -118,7 +118,7 @@ class TestProtocol:
shutil.rmtree(dest, ignore_errors=True)
os.makedirs(dest)
_seed_protocol_source(source)
for bad in ("2.17.0", "2.15.0", "2.16.0", "216", "31"):
for bad in ("2.18.0", "2.17.0", "2.15.0", "2.16.0", "216", "31"):
result, _ = run_client(source, dest, flags=[f"--protocol={bad}"],
port=shared_server.port)
assert result.returncode != 0, f"--protocol={bad} should be rejected"
+3 -3
View File
@@ -223,7 +223,7 @@ static void test_parse_args_protocol_accept_current() {
Config* cfg = valid_client_config();
EXPECT_NOT_NULL(cfg);
char* argv_equals[] = {"fastsync", "--source-dir", "/src",
"--dest-dir", "/dst", "--protocol=2.18.0"};
"--dest-dir", "/dst", "--protocol=2.19.0"};
int positional_args[2];
int positional_count = 0;
EXPECT_EQ_INT(parse_args(cfg, 6, argv_equals, positional_args, &positional_count), 0);
@@ -233,7 +233,7 @@ static void test_parse_args_protocol_accept_current() {
cfg = valid_client_config();
EXPECT_NOT_NULL(cfg);
char* argv_space[] = {"fastsync", "--source-dir", "/src", "--dest-dir",
"/dst", "--protocol", "2.18.0"};
"/dst", "--protocol", "2.19.0"};
positional_count = 0;
EXPECT_EQ_INT(parse_args(cfg, 7, argv_space, positional_args, &positional_count), 0);
EXPECT_EQ_STR(cfg->version, PROTOCOL_VERSION);
@@ -244,7 +244,7 @@ static void test_parse_args_protocol_accept_current() {
* failure (parse_args simply stores it; validate_config rejects it up front). */
static void test_parse_args_protocol_rejects_other_versions() {
static const char* const bad_versions[] = {"2.17", "2.16", "2.15.0", "2.16.0", "2.17.0",
"216", "31", "abc", ""};
"2.18.0", "216", "31", "abc", ""};
for (size_t i = 0; i < sizeof(bad_versions) / sizeof(bad_versions[0]); i++) {
Config* cfg = valid_client_config();
EXPECT_NOT_NULL(cfg);
+8 -11
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
* hex digest are present together, or both are absent. Round-trip a present
* pair. */
/* Daemon auth credentials (A7, protocol 2.19.0) ride the config frame as the
* username ONLY; the literal password never crosses the wire. Round-trip a
* present username. */
static void test_config_daemon_auth_wire_roundtrip() {
Config* send_cfg = config_create();
EXPECT_NOT_NULL(send_cfg);
@@ -256,8 +256,7 @@ static void test_config_daemon_auth_wire_roundtrip() {
send_cfg->receive_root_directory = str_dup("rel/path");
send_cfg->module = str_dup("backup");
send_cfg->auth_user = str_dup("alice");
send_cfg->auth_password_hash =
str_dup("9b90e524e94995ee4aeae2ee3c428a53405d1e8db147f44facc46797d0caf4c3");
send_cfg->auth_password = str_dup("alice-s3cret");
int p[2];
EXPECT_EQ_INT(socketpair(AF_UNIX, SOCK_STREAM, 0, p), 0);
@@ -269,10 +268,9 @@ static void test_config_daemon_auth_wire_roundtrip() {
close(p[1]);
io_set_fds(p[0], p[0]);
Config* recv_cfg = config_receive(p[0]);
/* The plaintext password is client-only: it is never serialized. */
bool ok = recv_cfg != NULL && recv_cfg->auth_user != NULL &&
strcmp(recv_cfg->auth_user, "alice") == 0 && recv_cfg->auth_password_hash != NULL &&
strcmp(recv_cfg->auth_password_hash,
"9b90e524e94995ee4aeae2ee3c428a53405d1e8db147f44facc46797d0caf4c3") == 0;
strcmp(recv_cfg->auth_user, "alice") == 0 && recv_cfg->auth_password == NULL;
config_delete(recv_cfg);
close(p[0]);
_exit(ok ? 0 : 1);
@@ -289,7 +287,7 @@ static void test_config_daemon_auth_wire_roundtrip() {
}
}
/* The receive side validates the auth payload: a present-but-malformed digest
/* The receive side validates the auth payload: a present-but-malformed username
* is refused (config_receive returns NULL), so a hostile peer cannot slip a
* garbage credential past the receive guard into the module gate. */
static void test_config_daemon_auth_wire_rejects_malformed() {
@@ -298,8 +296,7 @@ static void test_config_daemon_auth_wire_rejects_malformed() {
send_cfg->send_directory = str_dup("/src");
send_cfg->receive_root_directory = str_dup("/dst");
send_cfg->module = str_dup("m");
send_cfg->auth_user = str_dup("alice");
send_cfg->auth_password_hash = str_dup("not-a-valid-sha256-hex-digest!!");
send_cfg->auth_user = str_dup("bad user");
int p[2];
EXPECT_EQ_INT(socketpair(AF_UNIX, SOCK_STREAM, 0, p), 0);
+504 -147
View File
@@ -1,6 +1,7 @@
#include "test_credentials.h"
#include "credentials.h"
#include "test_utils.h"
#include "utils.h"
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
@@ -9,17 +10,46 @@
#include <sys/types.h>
#include <unistd.h>
/* Known SHA-256 vectors pin the digest derivation to real SHA-256 so a change
* in the hashing (or a wire/store format change) is observable. */
#define SHA256_EMPTY "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
#define SHA256_SECRET "2bb80d537b1da3e38bd30361aa855686bde0eacd7162fef6a25fe97bf527a25b"
#define SHA256_ALICE_PASS "9b90e524e94995ee4aeae2ee3c428a53405d1e8db147f44facc46797d0caf4c3"
/* Known-answer vector, independently recomputed with Python
* (hashlib.pbkdf2_hmac / hmac / hashlib.sha256) at the default work factor. */
#define KAT_PASSWORD "alice-s3cret"
#define KAT_USER "alice"
#define KAT_ITERS CREDENTIAL_DEFAULT_ITERS
#define KAT_CLIENT_KEY "845891d65ab3c9807f7ae5c123ab70714cc8b56173fccfce6a758993e858e17c"
#define KAT_STORED_KEY "d192f6da1c54bf73768f0a7c713995212d303c46f809de1b2e407fb3ad1c206b"
#define KAT_SERVER_KEY "508ad587574f59700c2d0bbec8417d6fe94bf8e39669adbabe7cbbd8700f86ce"
#define KAT_CLIENT_PROOF "e0cb4b894a7438d75cbb3066aa135d10200b76eea78137c5c04059895eed9242"
#define KAT_SERVER_SIG "f564e00fa6368e78d35b7116c7624d6cb047a950d87e3799e4e6e8c8954b618a"
#define KAT_SALT_B64 "AAECAwQFBgcICQoLDA0ODw=="
#define KAT_NAME_PREFIX "$fastsync$1$pbkdf2-sha256$"
/* The exact store line for the KAT user/password at the KAT salt/count. */
#define KAT_STORE_LINE \
"alice:$fastsync$1$pbkdf2-sha256$600000$AAECAwQFBgcICQoLDA0ODw==$" \
"0ZL22hxUv3N2jwp8cTmVIS0wPEb4Cd4bLkB/s60cIGs=$UIrVh1dPWXAMLQu+yEF9b+lL+OOWaa26vny72HAPhs4="
static int g_file_counter = 0;
/* Write `contents` to a uniquely-named temp file and return a malloc'd path
* (the caller frees it; the file is removed at the end of the test process or
* on request via rm_temp). */
static int hex_nibble(char c) {
if (c >= '0' && c <= '9')
return c - '0';
if (c >= 'a' && c <= 'f')
return c - 'a' + 10;
if (c >= 'A' && c <= 'F')
return c - 'A' + 10;
return -1;
}
static void unhex(const char* hex, uint8_t* out, size_t out_len) {
for (size_t i = 0; i < out_len; i++)
out[i] = (uint8_t)((hex_nibble(hex[2 * i]) << 4) | hex_nibble(hex[2 * i + 1]));
}
/* Fill deterministic nonces: out[i] = first + i. */
static void ramp(uint8_t* out, size_t len, uint8_t first) {
for (size_t i = 0; i < len; i++)
out[i] = (uint8_t)(first + i);
}
static char* make_tmp_file(const char* contents) {
char path[256];
snprintf(path, sizeof(path), "/tmp/fs_cred_test_%d_%d", (int)getpid(), g_file_counter++);
@@ -36,7 +66,7 @@ static char* make_tmp_file(const char* contents) {
/* Credential/password files are owner-only; the reader rejects group/other
* permission bits, so create temp files 0600 like the real ones. */
chmod(path, 0600);
return strdup(path);
return str_dup(path);
}
static void rm_temp(const char* path) {
@@ -44,33 +74,11 @@ static void rm_temp(const char* path) {
unlink(path);
}
static void test_credentials_hash_vectors() {
char out[CREDENTIAL_HASH_HEX_LEN + 1];
EXPECT_TRUE(credentials_hash_password("", out));
EXPECT_EQ_STR(out, SHA256_EMPTY);
EXPECT_TRUE(credentials_hash_password("secret", out));
EXPECT_EQ_STR(out, SHA256_SECRET);
EXPECT_TRUE(credentials_hash_password("alice-pass", out));
EXPECT_EQ_STR(out, SHA256_ALICE_PASS);
EXPECT_FALSE(credentials_hash_password(NULL, out));
EXPECT_FALSE(credentials_hash_password("x", NULL));
}
static void test_credentials_hash_valid() {
EXPECT_TRUE(credentials_hash_valid(SHA256_SECRET));
EXPECT_FALSE(credentials_hash_valid(NULL));
EXPECT_FALSE(credentials_hash_valid(""));
/* Wrong length. */
EXPECT_FALSE(credentials_hash_valid("abc"));
EXPECT_FALSE(
credentials_hash_valid("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"));
EXPECT_FALSE(
credentials_hash_valid("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"));
/* Uppercase hex and non-hex are rejected. */
EXPECT_FALSE(
credentials_hash_valid("2BB80D537B1DA3E38BD30361AA855686BDE0EACD7162FEF6A25FE97BF527A25B"));
EXPECT_FALSE(
credentials_hash_valid("gbb80d537b1da3e38bd30361aa855686bde0eacd7162fef6a25fe97bf527a25b"));
/* Build a valid new-format line for user/password at iters. */
static bool make_store_line(const char* user, const char* password, uint32_t iters, char* out,
size_t out_sz) {
char err[256];
return credentials_hash_store_line(user, password, iters, out, out_sz, err, sizeof(err));
}
static void test_credentials_secure_equal() {
@@ -78,57 +86,329 @@ static void test_credentials_secure_equal() {
EXPECT_TRUE(credentials_secure_equal("", "", 0));
EXPECT_FALSE(credentials_secure_equal("abc", "abd", 3));
EXPECT_TRUE(credentials_secure_equal("abc", "ab", 2));
/* Same prefix, difference at the very last byte must still be detected. */
EXPECT_FALSE(credentials_secure_equal(SHA256_SECRET, SHA256_ALICE_PASS, CREDENTIAL_HASH_HEX_LEN));
EXPECT_FALSE(credentials_secure_equal("ab", "ac", 2));
}
static void test_credentials_store_parse_valid() {
char* path = make_tmp_file(
"# server credential store\n"
"; another comment style\n"
"\n"
"alice:9b90e524e94995ee4aeae2ee3c428a53405d1e8db147f44facc46797d0caf4c3\n"
" bob : 2bb80d537b1da3e38bd30361aa855686bde0eacd7162fef6a25fe97bf527a25b \n"
"carol:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\r\n");
static void test_credentials_b64() {
uint8_t salt[CREDENTIAL_SALT_LEN];
ramp(salt, sizeof(salt), 0x00);
char encoded[25];
EXPECT_TRUE(credentials_b64_encode(salt, sizeof(salt), encoded, sizeof(encoded)));
EXPECT_EQ_STR(encoded, KAT_SALT_B64);
uint8_t decoded[CREDENTIAL_SALT_LEN];
size_t decoded_len = 0;
EXPECT_TRUE(credentials_b64_decode(encoded, decoded, sizeof(decoded), &decoded_len));
EXPECT_EQ_INT((int)decoded_len, CREDENTIAL_SALT_LEN);
EXPECT_TRUE(memcmp(decoded, salt, sizeof(salt)) == 0);
/* Malformed input is refused: bad length, bad alphabet, missing buffer. */
EXPECT_FALSE(credentials_b64_decode("abc", decoded, sizeof(decoded), &decoded_len));
EXPECT_FALSE(credentials_b64_decode("!!!!", decoded, sizeof(decoded), &decoded_len));
EXPECT_FALSE(credentials_b64_decode("", decoded, sizeof(decoded), &decoded_len));
EXPECT_FALSE(credentials_b64_decode(KAT_SALT_B64, decoded, 4, &decoded_len));
EXPECT_FALSE(credentials_b64_decode(NULL, decoded, sizeof(decoded), &decoded_len));
EXPECT_FALSE(credentials_b64_encode(NULL, 3, encoded, sizeof(encoded)));
EXPECT_FALSE(credentials_b64_encode(salt, sizeof(salt), encoded, 3));
}
static void test_credentials_random_bytes() {
uint8_t a[CREDENTIAL_NONCE_LEN];
uint8_t b[CREDENTIAL_NONCE_LEN];
EXPECT_TRUE(credentials_random_bytes(a, sizeof(a)));
EXPECT_TRUE(credentials_random_bytes(b, sizeof(b)));
EXPECT_TRUE(memcmp(a, b, sizeof(a)) != 0);
EXPECT_FALSE(credentials_random_bytes(NULL, 4));
}
static void test_credentials_compute_keys_kat() {
uint8_t salt[CREDENTIAL_SALT_LEN];
ramp(salt, sizeof(salt), 0x00);
uint8_t client_key[CREDENTIAL_KEY_LEN];
uint8_t stored_key[CREDENTIAL_KEY_LEN];
uint8_t server_key[CREDENTIAL_KEY_LEN];
EXPECT_TRUE(
credentials_compute_keys(KAT_PASSWORD, salt, KAT_ITERS, client_key, stored_key, server_key));
uint8_t expect[CREDENTIAL_KEY_LEN];
unhex(KAT_CLIENT_KEY, expect, sizeof(expect));
EXPECT_TRUE(memcmp(client_key, expect, sizeof(expect)) == 0);
unhex(KAT_STORED_KEY, expect, sizeof(expect));
EXPECT_TRUE(memcmp(stored_key, expect, sizeof(expect)) == 0);
unhex(KAT_SERVER_KEY, expect, sizeof(expect));
EXPECT_TRUE(memcmp(server_key, expect, sizeof(expect)) == 0);
EXPECT_FALSE(credentials_compute_keys(KAT_PASSWORD, salt, 0, client_key, stored_key, server_key));
EXPECT_FALSE(credentials_compute_keys(KAT_PASSWORD, salt, CREDENTIAL_MIN_ITERS - 1, client_key,
stored_key, server_key));
EXPECT_FALSE(credentials_compute_keys(KAT_PASSWORD, salt, CREDENTIAL_MAX_ITERS + 1, client_key,
stored_key, server_key));
EXPECT_FALSE(credentials_compute_keys(NULL, salt, KAT_ITERS, client_key, stored_key, server_key));
}
static void test_credentials_auth_message_and_proof_kat() {
uint8_t snonce[CREDENTIAL_NONCE_LEN];
uint8_t cnonce[CREDENTIAL_NONCE_LEN];
ramp(snonce, sizeof(snonce), 0xa0);
ramp(cnonce, sizeof(cnonce), 0x10);
uint8_t auth_msg[CREDENTIAL_AUTH_MESSAGE_MAX];
size_t msg_len = 0;
EXPECT_TRUE(credentials_build_auth_message(KAT_USER, snonce, cnonce, auth_msg, sizeof(auth_msg),
&msg_len));
const char* expect_msg =
"4661737453796e632d417574682d763100000005616c69636500000020a0a1a2a3a4a5a6a7a8a9aaabac"
"adaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf00000020101112131415161718191a1b1c1d1e1f2021"
"22232425262728292a2b2c2d2e2f";
uint8_t expect[CREDENTIAL_AUTH_MESSAGE_MAX];
size_t expect_len = strlen(expect_msg) / 2;
unhex(expect_msg, expect, expect_len);
EXPECT_EQ_INT((int)msg_len, (int)expect_len);
EXPECT_TRUE(memcmp(auth_msg, expect, expect_len) == 0);
uint8_t client_key[CREDENTIAL_KEY_LEN];
uint8_t stored_key[CREDENTIAL_KEY_LEN];
uint8_t server_key[CREDENTIAL_KEY_LEN];
unhex(KAT_CLIENT_KEY, client_key, sizeof(client_key));
unhex(KAT_STORED_KEY, stored_key, sizeof(stored_key));
unhex(KAT_SERVER_KEY, server_key, sizeof(server_key));
uint8_t proof[CREDENTIAL_KEY_LEN];
uint8_t server_sig[CREDENTIAL_KEY_LEN];
EXPECT_TRUE(credentials_client_proof(client_key, stored_key, server_key, auth_msg, msg_len, proof,
server_sig));
unhex(KAT_CLIENT_PROOF, expect, CREDENTIAL_KEY_LEN);
EXPECT_TRUE(memcmp(proof, expect, CREDENTIAL_KEY_LEN) == 0);
unhex(KAT_SERVER_SIG, expect, CREDENTIAL_KEY_LEN);
EXPECT_TRUE(memcmp(server_sig, expect, CREDENTIAL_KEY_LEN) == 0);
}
static CredentialVerifier kat_verifier(void) {
CredentialVerifier v;
memset(&v, 0, sizeof(v));
ramp(v.salt, sizeof(v.salt), 0x00);
v.iters = KAT_ITERS;
unhex(KAT_STORED_KEY, v.stored_key, sizeof(v.stored_key));
unhex(KAT_SERVER_KEY, v.server_key, sizeof(v.server_key));
v.found = true;
return v;
}
static void test_credentials_verify_response_kat() {
CredentialVerifier v = kat_verifier();
uint8_t snonce[CREDENTIAL_NONCE_LEN];
uint8_t cnonce[CREDENTIAL_NONCE_LEN];
ramp(snonce, sizeof(snonce), 0xa0);
ramp(cnonce, sizeof(cnonce), 0x10);
uint8_t proof[CREDENTIAL_KEY_LEN];
uint8_t expect_sig[CREDENTIAL_KEY_LEN];
unhex(KAT_CLIENT_PROOF, proof, sizeof(proof));
unhex(KAT_SERVER_SIG, expect_sig, sizeof(expect_sig));
uint8_t server_sig[CREDENTIAL_KEY_LEN];
EXPECT_TRUE(credentials_verify_response(&v, KAT_USER, snonce, cnonce, proof, server_sig));
EXPECT_TRUE(memcmp(server_sig, expect_sig, CREDENTIAL_KEY_LEN) == 0);
/* Tampered proof refused. */
uint8_t bad[CREDENTIAL_KEY_LEN];
memcpy(bad, proof, sizeof(bad));
bad[0] ^= 0x01;
EXPECT_FALSE(credentials_verify_response(&v, KAT_USER, snonce, cnonce, bad, server_sig));
/* Unit replay: the same proof bound to a different client nonce is refused. */
uint8_t other[CREDENTIAL_NONCE_LEN];
memcpy(other, cnonce, sizeof(other));
other[0] ^= 0x01;
EXPECT_FALSE(credentials_verify_response(&v, KAT_USER, snonce, other, proof, server_sig));
/* Different server nonce too. */
uint8_t other_server[CREDENTIAL_NONCE_LEN];
memcpy(other_server, snonce, sizeof(other_server));
other_server[0] ^= 0x01;
EXPECT_FALSE(credentials_verify_response(&v, KAT_USER, other_server, cnonce, proof, server_sig));
/* Wrong user changes the AuthMessage and fails. */
EXPECT_FALSE(credentials_verify_response(&v, "bob", snonce, cnonce, proof, server_sig));
/* found=false never accepts. */
v.found = false;
EXPECT_FALSE(credentials_verify_response(&v, KAT_USER, snonce, cnonce, proof, server_sig));
/* NULL arguments fail closed. */
v.found = true;
EXPECT_FALSE(credentials_verify_response(NULL, KAT_USER, snonce, cnonce, proof, server_sig));
EXPECT_FALSE(credentials_verify_response(&v, NULL, snonce, cnonce, proof, server_sig));
EXPECT_FALSE(credentials_verify_response(&v, KAT_USER, NULL, cnonce, proof, server_sig));
EXPECT_FALSE(credentials_verify_response(&v, KAT_USER, snonce, cnonce, NULL, server_sig));
}
static void test_credentials_username_valid() {
EXPECT_TRUE(credentials_username_valid("alice"));
EXPECT_TRUE(credentials_username_valid("a"));
EXPECT_FALSE(credentials_username_valid(NULL));
EXPECT_FALSE(credentials_username_valid(""));
EXPECT_FALSE(credentials_username_valid("bad user"));
EXPECT_FALSE(credentials_username_valid("tab\there"));
EXPECT_FALSE(credentials_username_valid("nul\nhere"));
}
/* A generated line round-trips through the store parser and verifies with the
* same password. */
static void test_credentials_hash_store_line_roundtrip() {
char line[CREDENTIAL_MAX_LINE];
EXPECT_TRUE(make_store_line("alice", KAT_PASSWORD, CREDENTIAL_MIN_ITERS, line, sizeof(line)));
EXPECT_TRUE(strncmp(line, "alice:", 6) == 0);
EXPECT_TRUE(strstr(line, KAT_NAME_PREFIX) != NULL);
char* path = make_tmp_file(line);
EXPECT_NOT_NULL(path);
char err[512];
CredentialStore* store = credentials_load(path, NULL, err, sizeof(err));
EXPECT_NOT_NULL(store);
EXPECT_EQ_INT(credentials_store_size(store), 3);
EXPECT_EQ_INT(credentials_store_size(store), 1);
EXPECT_TRUE(credentials_store_has(store, "alice"));
CredentialVerifier v;
const char* module_users[] = {"alice"};
EXPECT_TRUE(credentials_get_verifier(store, "alice", module_users, 1, &v));
EXPECT_TRUE(v.found);
EXPECT_EQ_INT((int)v.iters, (int)CREDENTIAL_MIN_ITERS);
uint8_t client_key[CREDENTIAL_KEY_LEN];
uint8_t stored_key[CREDENTIAL_KEY_LEN];
uint8_t server_key[CREDENTIAL_KEY_LEN];
EXPECT_TRUE(
credentials_compute_keys(KAT_PASSWORD, v.salt, v.iters, client_key, stored_key, server_key));
EXPECT_TRUE(memcmp(stored_key, v.stored_key, CREDENTIAL_KEY_LEN) == 0);
EXPECT_TRUE(memcmp(server_key, v.server_key, CREDENTIAL_KEY_LEN) == 0);
credentials_free(store);
rm_temp(path);
free(path);
}
/* The golden store line (KAT user/password/salt/count) parses back to exactly
* the KAT verifier keys, pinning the on-disk encoding independently. */
static void test_credentials_store_line_golden() {
char* path = make_tmp_file(KAT_STORE_LINE "\n");
EXPECT_NOT_NULL(path);
char err[512];
CredentialStore* store = credentials_load(path, NULL, err, sizeof(err));
EXPECT_NOT_NULL(store);
EXPECT_TRUE(credentials_store_has(store, "alice"));
CredentialVerifier v;
const char* module_users[] = {"alice"};
EXPECT_TRUE(credentials_get_verifier(store, "alice", module_users, 1, &v));
EXPECT_TRUE(v.found);
EXPECT_EQ_INT((int)v.iters, (int)KAT_ITERS);
uint8_t expect[CREDENTIAL_KEY_LEN];
unhex(KAT_STORED_KEY, expect, CREDENTIAL_KEY_LEN);
EXPECT_TRUE(memcmp(v.stored_key, expect, CREDENTIAL_KEY_LEN) == 0);
unhex(KAT_SERVER_KEY, expect, CREDENTIAL_KEY_LEN);
EXPECT_TRUE(memcmp(v.server_key, expect, CREDENTIAL_KEY_LEN) == 0);
uint8_t salt[CREDENTIAL_SALT_LEN];
ramp(salt, sizeof(salt), 0x00);
EXPECT_TRUE(memcmp(v.salt, salt, sizeof(salt)) == 0);
credentials_free(store);
rm_temp(path);
free(path);
}
static void test_credentials_store_parse_valid() {
char line_alice[CREDENTIAL_MAX_LINE];
char line_bob[CREDENTIAL_MAX_LINE];
EXPECT_TRUE(
make_store_line("alice", KAT_PASSWORD, CREDENTIAL_MIN_ITERS, line_alice, sizeof(line_alice)));
EXPECT_TRUE(
make_store_line("bob", "bob-s3cret", CREDENTIAL_MIN_ITERS, line_bob, sizeof(line_bob)));
char contents[2 * CREDENTIAL_MAX_LINE + 64];
snprintf(contents, sizeof(contents), "# server credential store\n; comment\n\n%s\n%s\n",
line_alice, line_bob);
char* path = make_tmp_file(contents);
EXPECT_NOT_NULL(path);
char err[512];
CredentialStore* store = credentials_load(path, NULL, err, sizeof(err));
EXPECT_NOT_NULL(store);
EXPECT_EQ_INT(credentials_store_size(store), 2);
EXPECT_TRUE(credentials_store_has(store, "alice"));
EXPECT_TRUE(credentials_store_has(store, "bob"));
EXPECT_TRUE(credentials_store_has(store, "carol"));
EXPECT_FALSE(credentials_store_has(store, "mallory"));
EXPECT_FALSE(credentials_store_has(store, "ALICE"));
EXPECT_TRUE(credentials_verify(store, "alice", SHA256_ALICE_PASS));
EXPECT_TRUE(credentials_verify(store, "bob", SHA256_SECRET));
EXPECT_TRUE(credentials_verify(store, "carol", SHA256_EMPTY));
EXPECT_FALSE(credentials_verify(store, "alice", SHA256_SECRET));
EXPECT_FALSE(credentials_verify(store, "mallory", SHA256_ALICE_PASS));
/* Unknown user and off-list user both yield a not-found dummy. */
const char* module_users[] = {"alice"};
CredentialVerifier v;
EXPECT_TRUE(credentials_get_verifier(store, "mallory", module_users, 1, &v));
EXPECT_FALSE(v.found);
EXPECT_TRUE(credentials_get_verifier(store, "bob", module_users, 1, &v));
EXPECT_FALSE(v.found);
EXPECT_TRUE(credentials_get_verifier(store, "alice", module_users, 1, &v));
EXPECT_TRUE(v.found);
/* The dummy keys are fixed (all zero) so they can never authenticate. */
const uint8_t zero[CREDENTIAL_KEY_LEN] = {0};
EXPECT_TRUE(credentials_get_verifier(store, "mallory", module_users, 1, &v));
EXPECT_TRUE(memcmp(v.stored_key, zero, CREDENTIAL_KEY_LEN) == 0);
EXPECT_TRUE(memcmp(v.server_key, zero, CREDENTIAL_KEY_LEN) == 0);
/* Deterministic dummy challenge: the same unknown username always yields the
* same salt and iteration count, while different usernames differ, so probing
* the store twice cannot reveal membership. */
uint8_t salt_a[CREDENTIAL_SALT_LEN];
uint8_t salt_b[CREDENTIAL_SALT_LEN];
uint8_t salt_c[CREDENTIAL_SALT_LEN];
uint32_t miss_iters_a = 0;
uint32_t miss_iters_b = 0;
EXPECT_TRUE(credentials_get_verifier(store, "mallory", module_users, 1, &v));
memcpy(salt_a, v.salt, sizeof(salt_a));
miss_iters_a = v.iters;
EXPECT_TRUE(credentials_get_verifier(store, "mallory", module_users, 1, &v));
memcpy(salt_b, v.salt, sizeof(salt_b));
miss_iters_b = v.iters;
EXPECT_TRUE(memcmp(salt_a, salt_b, sizeof(salt_a)) == 0);
EXPECT_EQ_INT((int)miss_iters_a, (int)miss_iters_b);
/* A miss is answered with the store-wide uniform iteration count. */
EXPECT_EQ_INT((int)miss_iters_a, (int)CREDENTIAL_MIN_ITERS);
EXPECT_TRUE(credentials_get_verifier(store, "trudy", module_users, 1, &v));
memcpy(salt_c, v.salt, sizeof(salt_c));
EXPECT_TRUE(memcmp(salt_a, salt_c, sizeof(salt_a)) != 0);
credentials_free(store);
rm_temp(path);
free(path);
}
static void test_credentials_store_parse_rejects_malformed() {
/* A valid salt (16 bytes -> 24 b64 chars) / keys (32 bytes -> 44 chars). */
uint8_t sixteen[CREDENTIAL_SALT_LEN] = {0};
uint8_t thirtytwo[CREDENTIAL_KEY_LEN] = {0};
char salt_b64[25];
char key_b64[45];
credentials_b64_encode(sixteen, sizeof(sixteen), salt_b64, sizeof(salt_b64));
credentials_b64_encode(thirtytwo, sizeof(thirtytwo), key_b64, sizeof(key_b64));
char below_min[CREDENTIAL_MAX_LINE];
char above_max[CREDENTIAL_MAX_LINE];
char short_salt[CREDENTIAL_MAX_LINE];
char short_key[CREDENTIAL_MAX_LINE];
char empty_field[CREDENTIAL_MAX_LINE];
snprintf(below_min, sizeof(below_min), "alice:$fastsync$1$pbkdf2-sha256$99$%s$%s$%s\n", salt_b64,
key_b64, key_b64);
snprintf(above_max, sizeof(above_max), "alice:$fastsync$1$pbkdf2-sha256$99999999$%s$%s$%s\n",
salt_b64, key_b64, key_b64);
snprintf(short_salt, sizeof(short_salt), "alice:$fastsync$1$pbkdf2-sha256$600000$AAAA$%s$%s\n",
key_b64, key_b64);
snprintf(short_key, sizeof(short_key), "alice:$fastsync$1$pbkdf2-sha256$600000$%s$AAAA$%s\n",
salt_b64, key_b64);
snprintf(empty_field, sizeof(empty_field), "alice:$fastsync$1$pbkdf2-sha256$600000$%s$%s$\n",
salt_b64, key_b64);
const char* cases[] = {
/* no colon */
"alice\n",
/* empty user */
":9b90e524e94995ee4aeae2ee3c428a53405d1e8db147f44facc46797d0caf4c3\n",
/* empty secret */
"alice:\n",
/* secret too short */
"alice:8ce9c8b52c5\n",
/* secret not hex */
"alice:zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz\n",
/* uppercase hex rejected (strict) */
"alice:2BB80D537B1DA3E38BD30361AA855686BDE0EACD7162FEF6A25FE97BF527A25B\n",
/* whitespace inside the username */
"ali ce:2bb80d537b1da3e38bd30361aa855686bde0eacd7162fef6a25fe97bf527a25b\n",
/* duplicate user within one file */
"alice:9b90e524e94995ee4aeae2ee3c428a53405d1e8db147f44facc46797d0caf4c3\n"
"alice:2bb80d537b1da3e38bd30361aa855686bde0eacd7162fef6a25fe97bf527a25b\n",
":anything\n",
"alice:not-a-verifier\n",
below_min,
above_max,
short_salt,
short_key,
empty_field,
"ali "
"ce:$fastsync$1$pbkdf2-sha256$600000$AAECAwQFBgcICQoLDA0ODw==$0ZL22hxUv3N2jwp8"
"cTmVIS0wPEb4Cd4bLkB/s60cIGs=$UIrVh1dPWXAMLQu+yEF9b+lL+OOWaa26vny72HAPhs4=\n",
};
for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) {
char* path = make_tmp_file(cases[i]);
@@ -142,6 +422,54 @@ static void test_credentials_store_parse_rejects_malformed() {
}
}
static void test_credentials_store_rejects_legacy_hex() {
const char* secret = "9b90e524e94995ee4aeae2ee3c428a53405d1e8db147f44facc46797d0caf4c3";
char contents[CREDENTIAL_MAX_LINE];
snprintf(contents, sizeof(contents), "alice:%s\n", secret);
char* path = make_tmp_file(contents);
EXPECT_NOT_NULL(path);
char err[512];
const CredentialStore* store = credentials_load(path, NULL, err, sizeof(err));
EXPECT_NULL(store);
EXPECT_TRUE(strstr(err, "legacy") != NULL);
EXPECT_TRUE(strstr(err, "alice") != NULL);
rm_temp(path);
free(path);
}
static void test_credentials_store_duplicate_rejected() {
char line[CREDENTIAL_MAX_LINE];
EXPECT_TRUE(make_store_line("alice", KAT_PASSWORD, CREDENTIAL_MIN_ITERS, line, sizeof(line)));
char contents[2 * CREDENTIAL_MAX_LINE + 8];
snprintf(contents, sizeof(contents), "%s\n%s\n", line, line);
char* path = make_tmp_file(contents);
EXPECT_NOT_NULL(path);
char err[512];
EXPECT_NULL(credentials_load(path, NULL, err, sizeof(err)));
EXPECT_TRUE(strstr(err, "duplicate") != NULL);
rm_temp(path);
free(path);
}
/* A store must be uniform in its iteration count so a miss can be challenged
* with the store-wide count without leaking membership. */
static void test_credentials_store_rejects_nonuniform_iters() {
char line_a[CREDENTIAL_MAX_LINE];
char line_b[CREDENTIAL_MAX_LINE];
EXPECT_TRUE(make_store_line("alice", KAT_PASSWORD, CREDENTIAL_MIN_ITERS, line_a, sizeof(line_a)));
EXPECT_TRUE(
make_store_line("bob", "bob-s3cret", CREDENTIAL_MIN_ITERS * 2, line_b, sizeof(line_b)));
char contents[2 * CREDENTIAL_MAX_LINE + 8];
snprintf(contents, sizeof(contents), "%s\n%s\n", line_a, line_b);
char* path = make_tmp_file(contents);
EXPECT_NOT_NULL(path);
char err[512];
EXPECT_NULL(credentials_load(path, NULL, err, sizeof(err)));
EXPECT_TRUE(strstr(err, "uniform") != NULL);
rm_temp(path);
free(path);
}
static void test_credentials_store_parse_missing_file() {
char err[512];
const CredentialStore* store =
@@ -152,15 +480,16 @@ static void test_credentials_store_parse_missing_file() {
static void test_credentials_store_empty_and_null() {
char err[512];
/* A NULL path is a valid (empty) store: no module can authenticate, which is
* the fail-closed state the startup check turns into a refusal to start. */
CredentialStore* store = credentials_load(NULL, NULL, err, sizeof(err));
EXPECT_NOT_NULL(store);
EXPECT_EQ_INT(credentials_store_size(store), 0);
EXPECT_FALSE(credentials_verify(store, "alice", SHA256_ALICE_PASS));
/* An empty store answers a miss with the default work factor. */
CredentialVerifier v;
EXPECT_TRUE(credentials_get_verifier(store, "nobody", NULL, 0, &v));
EXPECT_FALSE(v.found);
EXPECT_EQ_INT((int)v.iters, (int)CREDENTIAL_DEFAULT_ITERS);
credentials_free(store);
/* A blank/comment-only file is an empty store too (not an error). */
char* path = make_tmp_file("# nothing here\n; nor here\n");
EXPECT_NOT_NULL(path);
store = credentials_load(path, NULL, err, sizeof(err));
@@ -172,12 +501,8 @@ static void test_credentials_store_empty_and_null() {
}
static void test_credentials_store_overlong_line_rejected() {
/* A line longer than CREDENTIAL_MAX_LINE must be rejected. Fill the buffer
* fully so the line really is overlong, but keep both a trailing newline and
* a NUL terminator at known indices: make_tmp_file does strlen(contents), so
* an unterminated stack buffer would be an out-of-bounds read (ASan). */
char big[CREDENTIAL_MAX_LINE + 80];
int n = snprintf(big, sizeof(big), "alice:%s", SHA256_SECRET);
int n = snprintf(big, sizeof(big), "alice:%s", KAT_NAME_PREFIX);
memset(big + n, 'a', sizeof(big) - (size_t)n - 1);
big[sizeof(big) - 2] = '\n';
big[sizeof(big) - 1] = '\0';
@@ -191,47 +516,67 @@ static void test_credentials_store_overlong_line_rejected() {
}
static void test_credentials_early_input_merge() {
char* pw =
make_tmp_file("alice:9b90e524e94995ee4aeae2ee3c428a53405d1e8db147f44facc46797d0caf4c3\n");
char alice[CREDENTIAL_MAX_LINE];
char bob[CREDENTIAL_MAX_LINE];
EXPECT_TRUE(make_store_line("alice", KAT_PASSWORD, CREDENTIAL_MIN_ITERS, alice, sizeof(alice)));
EXPECT_TRUE(make_store_line("bob", "bob-s3cret", CREDENTIAL_MIN_ITERS, bob, sizeof(bob)));
char alice_file[CREDENTIAL_MAX_LINE + 2];
char bob_file[CREDENTIAL_MAX_LINE + 2];
char alice_other[CREDENTIAL_MAX_LINE];
char bob_other_iters[CREDENTIAL_MAX_LINE];
snprintf(alice_file, sizeof(alice_file), "%s\n", alice);
snprintf(bob_file, sizeof(bob_file), "%s\n", bob);
EXPECT_TRUE(make_store_line("alice", "different-s3cret", CREDENTIAL_MIN_ITERS, alice_other,
sizeof(alice_other)));
EXPECT_TRUE(make_store_line("carol", "carol-s3cret", CREDENTIAL_MIN_ITERS * 2, bob_other_iters,
sizeof(bob_other_iters)));
char* pw = make_tmp_file(alice_file);
char* early = make_tmp_file(bob_file);
char* early_same = make_tmp_file(alice_file); /* byte-identical verifier dedupes */
char* early_diff = make_tmp_file(alice_other);
char* early_iters = make_tmp_file(bob_other_iters);
EXPECT_NOT_NULL(pw);
EXPECT_NOT_NULL(early);
EXPECT_NOT_NULL(early_same);
EXPECT_NOT_NULL(early_diff);
EXPECT_NOT_NULL(early_iters);
char err[512];
/* A second file adds a new user. */
char* early =
make_tmp_file("bob:2bb80d537b1da3e38bd30361aa855686bde0eacd7162fef6a25fe97bf527a25b\n");
EXPECT_NOT_NULL(early);
CredentialStore* store = credentials_load(pw, early, err, sizeof(err));
EXPECT_NOT_NULL(store);
EXPECT_EQ_INT(credentials_store_size(store), 2);
EXPECT_TRUE(credentials_verify(store, "alice", SHA256_ALICE_PASS));
EXPECT_TRUE(credentials_verify(store, "bob", SHA256_SECRET));
EXPECT_TRUE(credentials_store_has(store, "alice"));
EXPECT_TRUE(credentials_store_has(store, "bob"));
credentials_free(store);
/* The same user with the SAME secret dedupes. */
char* early_same =
make_tmp_file("alice:9b90e524e94995ee4aeae2ee3c428a53405d1e8db147f44facc46797d0caf4c3\n");
EXPECT_NOT_NULL(early_same);
/* The same user with the SAME verifier dedupes. */
store = credentials_load(pw, early_same, err, sizeof(err));
EXPECT_NOT_NULL(store);
EXPECT_EQ_INT(credentials_store_size(store), 1);
credentials_free(store);
/* The same user with a DIFFERENT secret fails closed (ambiguous). */
char* early_diff =
make_tmp_file("alice:2bb80d537b1da3e38bd30361aa855686bde0eacd7162fef6a25fe97bf527a25b\n");
EXPECT_NOT_NULL(early_diff);
/* The same user with a DIFFERENT verifier fails closed (ambiguous). */
store = credentials_load(pw, early_diff, err, sizeof(err));
EXPECT_NULL(store);
EXPECT_TRUE(err[0] != '\0');
/* A layered store must stay uniform in its iteration count. */
store = credentials_load(pw, early_iters, err, sizeof(err));
EXPECT_NULL(store);
EXPECT_TRUE(strstr(err, "uniform") != NULL);
rm_temp(pw);
rm_temp(early);
rm_temp(early_same);
rm_temp(early_diff);
rm_temp(early_iters);
free(pw);
free(early);
free(early_same);
free(early_diff);
free(early_iters);
}
static void test_credentials_read_secret_file() {
@@ -239,7 +584,6 @@ static void test_credentials_read_secret_file() {
char* user = NULL;
char* password = NULL;
/* Leading comments/blanks skipped; first real line wins. */
char* path = make_tmp_file("# password file\n"
"\n"
"alice:correct horse battery staple\n"
@@ -254,8 +598,6 @@ static void test_credentials_read_secret_file() {
rm_temp(path);
free(path);
/* CRLF is tolerated; the username is trimmed but the password's exact bytes
* (edge spaces included) are preserved so a whitespace password stays usable. */
path = make_tmp_file(" bob : s3cret \r\n");
EXPECT_NOT_NULL(path);
EXPECT_EQ_INT(credentials_read_secret_file(path, &user, &password, err, sizeof(err)), 0);
@@ -267,8 +609,6 @@ static void test_credentials_read_secret_file() {
rm_temp(path);
free(path);
/* A whitespace-only password (no characters) is still a real password and is
* preserved exactly, not mistaken for an empty line. */
path = make_tmp_file("carol: \n");
EXPECT_NOT_NULL(path);
EXPECT_EQ_INT(credentials_read_secret_file(path, &user, &password, err, sizeof(err)), 0);
@@ -280,7 +620,6 @@ static void test_credentials_read_secret_file() {
rm_temp(path);
free(path);
/* Empty file / comment-only file rejected. */
path = make_tmp_file("");
EXPECT_NOT_NULL(path);
EXPECT_EQ_INT(credentials_read_secret_file(path, &user, &password, err, sizeof(err)), -1);
@@ -294,13 +633,9 @@ static void test_credentials_read_secret_file() {
static void test_credentials_read_secret_file_bad() {
char err[512];
const char* cases[] = {
/* no colon */
"alicepassword\n",
/* empty user */
":password\n",
/* empty password */
"alice:\n",
/* empty password after CR-only line ending */
"alice:\r\n",
};
for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) {
@@ -320,62 +655,77 @@ static void test_credentials_read_secret_file_bad() {
EXPECT_EQ_INT(credentials_read_secret_file(missing, NULL, NULL, err, sizeof(err)), -1);
}
static void test_credentials_gate_allows() {
char* path =
make_tmp_file("alice:9b90e524e94995ee4aeae2ee3c428a53405d1e8db147f44facc46797d0caf4c3\n"
"bob:2bb80d537b1da3e38bd30361aa855686bde0eacd7162fef6a25fe97bf527a25b\n");
EXPECT_NOT_NULL(path);
static void test_credentials_hash_file() {
char* plaintext = make_tmp_file("# comment\n\n alice :" KAT_PASSWORD "\nbob:bob-s3cret\n");
EXPECT_NOT_NULL(plaintext);
FILE* out = tmpfile();
EXPECT_NOT_NULL(out);
char err[512];
CredentialStore* store = credentials_load(path, NULL, err, sizeof(err));
EXPECT_EQ_INT(credentials_hash_file(plaintext, CREDENTIAL_MIN_ITERS, out, err, sizeof(err)), 0);
rewind(out);
char line1[CREDENTIAL_MAX_LINE];
char line2[CREDENTIAL_MAX_LINE];
EXPECT_NOT_NULL(fgets(line1, sizeof(line1), out));
EXPECT_NOT_NULL(fgets(line2, sizeof(line2), out));
EXPECT_NULL(fgets(err, sizeof(err), out)); /* exactly two entries */
size_t n1 = strlen(line1);
if (n1 > 0 && line1[n1 - 1] == '\n')
line1[--n1] = '\0';
size_t n2 = strlen(line2);
if (n2 > 0 && line2[n2 - 1] == '\n')
line2[--n2] = '\0';
EXPECT_TRUE(strncmp(line1, "alice:", 6) == 0);
EXPECT_TRUE(strncmp(line2, "bob:", 4) == 0);
EXPECT_TRUE(strstr(line1, KAT_NAME_PREFIX) != NULL);
fclose(out);
/* The generated lines load as a valid store. */
char contents[2 * CREDENTIAL_MAX_LINE + 8];
snprintf(contents, sizeof(contents), "%s\n%s\n", line1, line2);
char* store_path = make_tmp_file(contents);
EXPECT_NOT_NULL(store_path);
CredentialStore* store = credentials_load(store_path, NULL, err, sizeof(err));
EXPECT_NOT_NULL(store);
const char* module_users[] = {"alice", "bob"};
/* Matching user + digest passes. */
EXPECT_TRUE(credentials_gate_allows(store, module_users, 2, "alice", SHA256_ALICE_PASS));
EXPECT_TRUE(credentials_gate_allows(store, module_users, 2, "bob", SHA256_SECRET));
/* Wrong digest for a listed user fails. */
EXPECT_FALSE(credentials_gate_allows(store, module_users, 2, "alice", SHA256_SECRET));
/* A store user that is not on the module's list fails. */
EXPECT_FALSE(credentials_gate_allows(store, module_users, 2, "alice", SHA256_ALICE_PASS) &&
credentials_gate_allows(store, module_users, 1, "bob", SHA256_SECRET));
EXPECT_TRUE(credentials_gate_allows(store, module_users, 1, "alice", SHA256_ALICE_PASS));
EXPECT_FALSE(credentials_gate_allows(store, module_users, 1, "bob", SHA256_SECRET));
/* No credentials presented fails. */
EXPECT_FALSE(credentials_gate_allows(store, module_users, 2, NULL, NULL));
EXPECT_FALSE(credentials_gate_allows(store, module_users, 2, "alice", NULL));
/* Unknown user fails. */
EXPECT_FALSE(credentials_gate_allows(store, module_users, 2, "mallory", SHA256_ALICE_PASS));
/* Fail closed: a NULL store refuses even with correct credentials. */
EXPECT_FALSE(credentials_gate_allows(NULL, module_users, 2, "alice", SHA256_ALICE_PASS));
/* An empty module list refuses everyone. */
EXPECT_FALSE(credentials_gate_allows(store, NULL, 0, "alice", SHA256_ALICE_PASS));
EXPECT_EQ_INT(credentials_store_size(store), 2);
credentials_free(store);
rm_temp(path);
free(path);
rm_temp(store_path);
free(store_path);
rm_temp(plaintext);
free(plaintext);
/* An invalid iteration count is refused up front. */
char* p2 = make_tmp_file("alice:pw\n");
EXPECT_NOT_NULL(p2);
FILE* out2 = tmpfile();
EXPECT_NOT_NULL(out2);
EXPECT_EQ_INT(credentials_hash_file(p2, 10, out2, err, sizeof(err)), -1);
EXPECT_TRUE(err[0] != '\0');
fclose(out2);
rm_temp(p2);
free(p2);
}
static void test_credentials_rejects_group_or_other_accessible() {
char err[512];
char* path =
make_tmp_file("alice:9b90e524e94995ee4aeae2ee3c428a53405d1e8db147f44facc46797d0caf4c3\n");
char line[CREDENTIAL_MAX_LINE];
EXPECT_TRUE(make_store_line("alice", KAT_PASSWORD, CREDENTIAL_MIN_ITERS, line, sizeof(line)));
char contents[CREDENTIAL_MAX_LINE + 2];
snprintf(contents, sizeof(contents), "%s\n", line);
char* path = make_tmp_file(contents);
EXPECT_NOT_NULL(path);
/* 0600 is accepted by the server store loader. */
EXPECT_EQ_INT(chmod(path, 0600), 0);
CredentialStore* store = credentials_load(path, NULL, err, sizeof(err));
EXPECT_NOT_NULL(store);
credentials_free(store);
/* Group-readable and world-readable are both refused, with a clear error. */
EXPECT_EQ_INT(chmod(path, 0640), 0);
EXPECT_NULL(credentials_load(path, NULL, err, sizeof(err)));
EXPECT_TRUE(strstr(err, "owner-only") != NULL);
EXPECT_EQ_INT(chmod(path, 0604), 0);
EXPECT_NULL(credentials_load(path, NULL, err, sizeof(err)));
/* The client --password-file reader enforces the same rule. */
EXPECT_EQ_INT(chmod(path, 0644), 0);
char* user = NULL;
char* password = NULL;
@@ -384,9 +734,7 @@ static void test_credentials_rejects_group_or_other_accessible() {
EXPECT_NULL(password);
EXPECT_TRUE(strstr(err, "owner-only") != NULL);
/* An --early-input file is checked too. */
char* pw =
make_tmp_file("bob:2bb80d537b1da3e38bd30361aa855686bde0eacd7162fef6a25fe97bf527a25b\n");
char* pw = make_tmp_file("bob:bob-s3cret\n");
EXPECT_NOT_NULL(pw);
EXPECT_EQ_INT(chmod(path, 0644), 0);
EXPECT_NULL(credentials_load(pw, path, err, sizeof(err)));
@@ -403,22 +751,31 @@ static void test_credentials_burn() {
credentials_burn(secret, 16);
for (int i = 0; i < 16; i++)
EXPECT_EQ_INT(secret[i], 0);
credentials_burn(NULL, 0); /* must not crash */
credentials_burn(NULL, 0);
}
void test_credentials(void) {
test_credentials_hash_vectors();
test_credentials_hash_valid();
test_credentials_secure_equal();
test_credentials_b64();
test_credentials_random_bytes();
test_credentials_compute_keys_kat();
test_credentials_auth_message_and_proof_kat();
test_credentials_verify_response_kat();
test_credentials_username_valid();
test_credentials_hash_store_line_roundtrip();
test_credentials_store_line_golden();
test_credentials_store_parse_valid();
test_credentials_store_parse_rejects_malformed();
test_credentials_store_rejects_legacy_hex();
test_credentials_store_duplicate_rejected();
test_credentials_store_rejects_nonuniform_iters();
test_credentials_store_parse_missing_file();
test_credentials_store_empty_and_null();
test_credentials_store_overlong_line_rejected();
test_credentials_early_input_merge();
test_credentials_read_secret_file();
test_credentials_read_secret_file_bad();
test_credentials_hash_file();
test_credentials_rejects_group_or_other_accessible();
test_credentials_gate_allows();
test_credentials_burn();
}