fix(a7-auth): address SCRAM auth review findings A-G

- tests: pass CREDENTIAL_KEY_LEN to unhex for the 32-byte KAT proof/sig
  (sizeof(expect) is 348, over-reading the 65-byte hex literal under ASan)
- credentials: close the username-enumeration oracle with a store-wide
  dummy_key and a deterministic per-username dummy salt; make the store's
  iteration count uniform (reject intra-file and layered disagreements) and
  answer a miss with the store-wide count; run the constant-time key compare
  even when found=false and fold the decision with bitwise AND
- credentials_compute_keys: enforce [CREDENTIAL_MIN_ITERS, CREDENTIAL_MAX_ITERS]
- tests: recompute the whole KAT independently at CREDENTIAL_DEFAULT_ITERS
  (600000) and pin the golden store line; add non-uniform-store rejection,
  bound and deterministic-dummy-salt assertions
- server: send exactly one generic STATUS_AUTH_FAILED on every failure path
  (including credentials_get_verifier failure); route all handshake exits
  through one burn path
- credentials/server: burn the base64 decoders' scratch on error, the
  hash_store_line base64/line buffers on failure, and all handshake key/proof
  material
- fuzz: guard the auth-offset scan against size_t underflow and use a found flag
- docs: drop stale digest wording, use CREDENTIAL_MIN_ITERS as the --iterations
  bound, document 0600 output for --hash-credentials (plus a stderr warning on
  a group/other-accessible stdout file), and describe the deterministic dummy
  salt in the no-oracle claims
This commit is contained in:
2026-09-12 17:56:52 +02:00
parent 8c94ec9886
commit eaf67f6257
10 changed files with 302 additions and 112 deletions
+7 -3
View File
@@ -516,9 +516,13 @@ Client and server versions must currently match exactly.
Daemon modules that declare `auth users` authenticate with a SCRAM-SHA-256-style 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 challenge/response against a salted PBKDF2 verifier store: no password and no
replayable bearer credential crosses the wire or is stored on the daemon. Store replayable bearer credential crosses the wire or is stored on the daemon. All
lines are generated with `fastsync-server --hash-credentials <plaintext-file>` store entries share one iteration count, and an unknown user is answered with a
(see `RSYNC_COMPAT.md`); legacy `user:SHA256HEX` stores are rejected. 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.
TLS provides encrypted TCP transport. Supplying `--ca` enables certificate TLS provides encrypted TCP transport. Supplying `--ca` enables certificate
verification; without it, traffic is encrypted but peer identity is not verification; without it, traffic is encrypted but peer identity is not
+2 -2
View File
@@ -639,8 +639,8 @@ now transmits targets (the prior behavior was broken/partial); its status moved
- **Module selection & confinement:** the client requests a module with an rsync-style `host::module[/path]` destination. The module name crosses the wire as a trailing string on the config frame (bumping `PROTOCOL_VERSION` 2.14.0 → 2.15.0; the bump is required because the config-frame layout changed and the strict same-version handshake is what prevents a peer from desynchronizing on the new trailing field). The daemon looks the module up in ITS OWN config and uses the module's `path` as the authorized root through the exact same `configure_authorization` confinement the standalone server applies to `--destination-root` (`file_open_secure_parent`, `has_path_traversal`, `path_is_within`); the client never supplies the root, every client-chosen-ownership/super-user request is refused unless the module declares `client owner = yes` (the daemon's per-module opt-in, see below), and the operator `--no-super` veto forces super-user activities off for every daemon connection. The client's `/path` part is relative inside the module and is rejected if absolute or if it contains `..`. Unknown modules are refused before any data moves (the run fails cleanly at the config handshake). An absolute destination and a module request against a non-daemon server are also refused. - **Module selection & confinement:** the client requests a module with an rsync-style `host::module[/path]` destination. The module name crosses the wire as a trailing string on the config frame (bumping `PROTOCOL_VERSION` 2.14.0 → 2.15.0; the bump is required because the config-frame layout changed and the strict same-version handshake is what prevents a peer from desynchronizing on the new trailing field). The daemon looks the module up in ITS OWN config and uses the module's `path` as the authorized root through the exact same `configure_authorization` confinement the standalone server applies to `--destination-root` (`file_open_secure_parent`, `has_path_traversal`, `path_is_within`); the client never supplies the root, every client-chosen-ownership/super-user request is refused unless the module declares `client owner = yes` (the daemon's per-module opt-in, see below), and the operator `--no-super` veto forces super-user activities off for every daemon connection. The client's `/path` part is relative inside the module and is rejected if absolute or if it contains `..`. Unknown modules are refused before any data moves (the run fails cleanly at the config handshake). An absolute destination and a module request against a non-daemon server are also refused.
- **`client owner` (client-chosen-ownership opt-in):** by default a daemon module refuses every request that would let the client pick an owner or ask for super-user activities — `--numeric-ids`, `--chown`, `--usermap`/`--groupmap`, `--fake-super`, `--copy-as`, and an explicit `--super` — at the config handshake (before `STATUS_OK`), because a daemon has no per-module opt-in for client-chosen ownership and any anonymous client could otherwise force arbitrary owner ids inside the module root. `client owner = yes` opts a single module in, allowing those requests within that module's root (the standalone listener and the SSH `--stdio` server always honor them for their single operator-authorized root). Without the opt-in the daemon also forces super-user **device** activity off for that connection — char/block device-node creation (`--devices`) and `--write-devices` — even under the default `AUTO` mode, so a non-opted module can never be made to `mknod` or write a raw device; those entries are skipped (not refused) so an ordinary `-a` push still succeeds without device nodes. The opt-in does **not** lift the privilege requirement: `--copy-as` still needs a root receiver, and the operator `--no-super` veto still forces super-user activities off for every connection. The daemon logs a prominent startup warning for each `client owner = yes` module so the operator's deliberate choice is visible. - **`client owner` (client-chosen-ownership opt-in):** by default a daemon module refuses every request that would let the client pick an owner or ask for super-user activities — `--numeric-ids`, `--chown`, `--usermap`/`--groupmap`, `--fake-super`, `--copy-as`, and an explicit `--super` — at the config handshake (before `STATUS_OK`), because a daemon has no per-module opt-in for client-chosen ownership and any anonymous client could otherwise force arbitrary owner ids inside the module root. `client owner = yes` opts a single module in, allowing those requests within that module's root (the standalone listener and the SSH `--stdio` server always honor them for their single operator-authorized root). Without the opt-in the daemon also forces super-user **device** activity off for that connection — char/block device-node creation (`--devices`) and `--write-devices` — even under the default `AUTO` mode, so a non-opted module can never be made to `mknod` or write a raw device; those entries are skipped (not refused) so an ordinary `-a` push still succeeds without device nodes. The opt-in does **not** lift the privilege requirement: `--copy-as` still needs a root receiver, and the operator `--no-super` veto still forces super-user activities off for every connection. The daemon logs a prominent startup warning for each `client owner = yes` module so the operator's deliberate choice is visible.
- **`read only` safe default:** every network transfer FastSync currently supports is a push that writes under the module root, so a `read only` module refuses the connection (clear server log "module is read only"; the client exits non-zero, nothing is transferred). A future pull/list operation can be opened up when it exists; the knob is already stored. - **`read only` safe default:** every network transfer FastSync currently supports is a push that writes under the module root, so a `read only` module refuses the connection (clear server log "module is read only"; the client exits non-zero, nothing is transferred). A future pull/list operation can be opened up when it exists; the knob is already stored.
- **`auth users` (A7 SCRAM-SHA-256 authentication):** a module that declares `auth users` requires the client to present credentials. The config frame carries ONLY the username; the daemon answers an auth-required module with `STATUS_AUTH_CHALLENGE` (PBKDF2 iteration count, 16-byte salt, 32-byte server nonce), the client answers with `STATUS_AUTH_RESPONSE` (fresh 32-byte client nonce + a 32-byte ClientProof), and the daemon accepts only when the proof verifies **and** the username is **on the module's `auth users` list** and has a store entry, replying `STATUS_AUTH_OK` with a 32-byte ServerSignature the client verifies before proceeding. Verification is constant-time over fixed 32-byte keys, 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 (fresh random salt, default iterations, dummy keys), 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. - **`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.
- **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). Generate lines with `fastsync-server --hash-credentials FILE [--iterations N]`. 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. - **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. - **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. - **Wire/protocol:** the config-frame auth block is now `[int present][str_redacted username]` (the old digest field is gone), and the frame stream gains the challenge/response (`STATUS_AUTH_CHALLENGE` → `STATUS_AUTH_RESPONSE` → `STATUS_AUTH_OK`/`STATUS_AUTH_FAILED`) between the config frame and the `STATUS_OK` ack. Both are wire-layout changes, so `PROTOCOL_VERSION` is bumped **2.18.0 → 2.19.0** (see the A7 note in `src/shared/config.h`); the strict same-version handshake keeps a 2.19 client and a 2.18 server from desynchronizing.
- **Client side:** `host::module/path` selects the TCP transport and connects to `--server-port`; `host:path` stays the SSH transport; plain paths stay local TCP. The daemon username comes from `--password-file` (first `user:password` line), and `--password-file` without a `host::module/path` destination is a client error (fail fast). A `user@host::module` form is rejected with a pointer to `--password-file`. The client's plaintext password is wiped from memory (`config_burn_auth`) at transfer teardown. - **Client side:** `host::module/path` selects the TCP transport and connects to `--server-port`; `host:path` stays the SSH transport; plain paths stay local TCP. The daemon username comes from `--password-file` (first `user:password` line), and `--password-file` without a `host::module/path` destination is a client error (fail fast). A `user@host::module` form is rejected with a pointer to `--password-file`. The client's plaintext password is wiped from memory (`config_burn_auth`) at transfer teardown.
+61 -47
View File
@@ -73,65 +73,68 @@ typedef struct ModuleGateContext {
* 2.19.0). Sends STATUS_AUTH_CHALLENGE (iteration count, base64 salt, base64 * 2.19.0). Sends STATUS_AUTH_CHALLENGE (iteration count, base64 salt, base64
* server nonce), expects STATUS_AUTH_RESPONSE (base64 client nonce, base64 * server nonce), expects STATUS_AUTH_RESPONSE (base64 client nonce, base64
* ClientProof), verifies the proof constant-time and answers STATUS_AUTH_OK * ClientProof), verifies the proof constant-time and answers STATUS_AUTH_OK
* with the base64 ServerSignature. On any failure it sends a single generic * with the base64 ServerSignature. On any failure it sends exactly one generic
* STATUS_AUTH_FAILED and returns false. The verifier for an unknown/off-list * STATUS_AUTH_FAILED and returns false. The verifier for an unknown/off-list
* user is a dummy (random salt, dummy keys, found=false) so the same math runs * user is a dummy (deterministic per-username salt, store-wide iterations, dummy
* and no user-enumeration/timing oracle is exposed. */ * 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) { static bool server_auth_handshake(int fd, const Config* config, const DaemonModule* module) {
if (!config->auth_user) { bool result = false;
send_status(fd, STATUS_AUTH_FAILED);
return false;
}
CredentialVerifier verifier; 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, if (!credentials_get_verifier(g_credentials, config->auth_user,
(const char* const*)module->auth_users, module->auth_user_count, (const char* const*)module->auth_users, module->auth_user_count,
&verifier)) &verifier))
return false; goto fail; /* a crypto failure still owes the gate a terminal frame */
uint8_t snonce[CREDENTIAL_NONCE_LEN]; if (!(credentials_random_bytes(snonce, sizeof(snonce)) &&
char salt_b64[25];
char snonce_b64[45];
bool ok =
credentials_random_bytes(snonce, sizeof(snonce)) &&
credentials_b64_encode(verifier.salt, CREDENTIAL_SALT_LEN, salt_b64, sizeof(salt_b64)) && credentials_b64_encode(verifier.salt, CREDENTIAL_SALT_LEN, salt_b64, sizeof(salt_b64)) &&
credentials_b64_encode(snonce, sizeof(snonce), snonce_b64, sizeof(snonce_b64)); credentials_b64_encode(snonce, sizeof(snonce), snonce_b64, sizeof(snonce_b64))))
if (!ok) { goto fail;
send_status(fd, STATUS_AUTH_FAILED); if (!(send_status(fd, STATUS_AUTH_CHALLENGE) && send_int(fd, (int)verifier.iters) &&
return false; send_str(fd, salt_b64) && send_str(fd, snonce_b64)))
} goto fail;
ok = send_status(fd, STATUS_AUTH_CHALLENGE) && send_int(fd, (int)verifier.iters) &&
send_str(fd, salt_b64) && send_str(fd, snonce_b64);
Status status = STATUS_ERROR; Status status = STATUS_ERROR;
char* cnonce_b64 = NULL; if (!(receive_status(fd, &status) && status == STATUS_AUTH_RESPONSE))
char* proof_b64 = NULL; goto fail;
uint8_t cnonce[CREDENTIAL_NONCE_LEN];
uint8_t proof[CREDENTIAL_KEY_LEN];
uint8_t server_sig[CREDENTIAL_KEY_LEN];
size_t cnonce_len = 0;
size_t proof_len = 0;
bool verified = false;
if (ok) {
ok = receive_status(fd, &status) && status == STATUS_AUTH_RESPONSE;
if (ok) {
cnonce_b64 = receive_str_redacted(fd); cnonce_b64 = receive_str_redacted(fd);
proof_b64 = receive_str_redacted(fd); proof_b64 = receive_str_redacted(fd);
ok = cnonce_b64 && proof_b64 && if (!(cnonce_b64 && proof_b64 &&
credentials_b64_decode(cnonce_b64, cnonce, sizeof(cnonce), &cnonce_len) && credentials_b64_decode(cnonce_b64, cnonce, sizeof(cnonce), &cnonce_len) &&
cnonce_len == CREDENTIAL_NONCE_LEN && cnonce_len == CREDENTIAL_NONCE_LEN &&
credentials_b64_decode(proof_b64, proof, sizeof(proof), &proof_len) && credentials_b64_decode(proof_b64, proof, sizeof(proof), &proof_len) &&
proof_len == CREDENTIAL_KEY_LEN; proof_len == CREDENTIAL_KEY_LEN))
} goto fail;
verified = ok && credentials_verify_response(&verifier, config->auth_user, snonce, cnonce, if (!credentials_verify_response(&verifier, config->auth_user, snonce, cnonce, proof, server_sig))
proof, server_sig); goto fail;
}
if (verified) { /* Success writes exactly one terminal frame (STATUS_AUTH_OK). A broken pipe
char sig_b64[45]; * while sending the signature just drops the connection; it must never emit a
ok = credentials_b64_encode(server_sig, sizeof(server_sig), sig_b64, sizeof(sig_b64)) && * 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); send_status(fd, STATUS_AUTH_OK) && send_str_redacted(fd, sig_b64);
credentials_burn(sig_b64, sizeof(sig_b64)); goto cleanup;
} else {
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); send_status(fd, STATUS_AUTH_FAILED);
ok = false;
} cleanup:
credentials_burn(cnonce_b64, cnonce_b64 ? strlen(cnonce_b64) : 0); credentials_burn(cnonce_b64, cnonce_b64 ? strlen(cnonce_b64) : 0);
credentials_burn(proof_b64, proof_b64 ? strlen(proof_b64) : 0); credentials_burn(proof_b64, proof_b64 ? strlen(proof_b64) : 0);
free(cnonce_b64); free(cnonce_b64);
@@ -142,10 +145,11 @@ static bool server_auth_handshake(int fd, const Config* config, const DaemonModu
credentials_burn((char*)cnonce, sizeof(cnonce)); credentials_burn((char*)cnonce, sizeof(cnonce));
credentials_burn((char*)proof, sizeof(proof)); credentials_burn((char*)proof, sizeof(proof));
credentials_burn((char*)server_sig, sizeof(server_sig)); 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.salt, sizeof(verifier.salt));
credentials_burn((char*)verifier.stored_key, sizeof(verifier.stored_key)); credentials_burn((char*)verifier.stored_key, sizeof(verifier.stored_key));
credentials_burn((char*)verifier.server_key, sizeof(verifier.server_key)); credentials_burn((char*)verifier.server_key, sizeof(verifier.server_key));
return verified && ok; return result;
} }
/* Aggregate payload bytes the multithreaded receiver may buffer ahead of the /* Aggregate payload bytes the multithreaded receiver may buffer ahead of the
@@ -364,7 +368,8 @@ static const char* server_module_gate(const Config* config, void* context) {
/* Auth-required module (A7, protocol 2.19.0): run the SCRAM challenge/ /* Auth-required module (A7, protocol 2.19.0): run the SCRAM challenge/
* response BEFORE the module root is installed and before any data moves. * response BEFORE the module root is installed and before any data moves.
* Fail closed: no store -> refuse (server misconfiguration, STATUS_ERROR); * Fail closed: no store -> refuse (server misconfiguration, STATUS_ERROR);
* a failed handshake already sent STATUS_AUTH_FAILED. The username may be * a failed handshake writes exactly one STATUS_AUTH_FAILED (on every
* failure path) before signalling ALREADY_TERMINATED. The username may be
* logged (never the password or any derived proof). */ * logged (never the password or any derived proof). */
if (g_credentials == NULL) { if (g_credentials == NULL) {
log_message(LOG_LEVEL_ERROR, log_message(LOG_LEVEL_ERROR,
@@ -751,7 +756,8 @@ static void print_server_usage(void) {
printf(" --allow-unauthenticated Allow plaintext/anonymous network clients\n"); printf(" --allow-unauthenticated Allow plaintext/anonymous network clients\n");
printf(" --hash-credentials <file> Read <file>'s user:password lines and print\n"); printf(" --hash-credentials <file> Read <file>'s user:password lines and print\n");
printf(" PBKDF2 credential-store lines to stdout, then exit.\n"); printf(" PBKDF2 credential-store lines to stdout, then exit.\n");
printf(" Use the output as --password-file for --daemon\n"); printf(" Use the output as --password-file for --daemon;\n");
printf(" redirect it to an owner-only (0600) file\n");
printf(" --iterations N PBKDF2 iteration count for --hash-credentials\n"); printf(" --iterations N PBKDF2 iteration count for --hash-credentials\n");
printf(" (default %u, range %u-%u)\n", CREDENTIAL_DEFAULT_ITERS, printf(" (default %u, range %u-%u)\n", CREDENTIAL_DEFAULT_ITERS,
CREDENTIAL_MIN_ITERS, CREDENTIAL_MAX_ITERS); CREDENTIAL_MIN_ITERS, CREDENTIAL_MAX_ITERS);
@@ -828,6 +834,14 @@ int main(int argc, char* argv[]) {
* emit new-format credential-store lines, then exit. */ * emit new-format credential-store lines, then exit. */
if (opts.hash_credentials_file) { if (opts.hash_credentials_file) {
uint32_t iters = opts.hash_iterations_set ? opts.hash_iterations : CREDENTIAL_DEFAULT_ITERS; 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]; char hash_err[512];
if (credentials_hash_file(opts.hash_credentials_file, iters, stdout, hash_err, if (credentials_hash_file(opts.hash_credentials_file, iters, stdout, hash_err,
sizeof(hash_err)) != 0) { sizeof(hash_err)) != 0) {
+5 -2
View File
@@ -1,5 +1,6 @@
#include "server_cli.h" #include "server_cli.h"
#include "charset.h" #include "charset.h"
#include "credentials.h"
#include "utils.h" #include "utils.h"
#include <limits.h> #include <limits.h>
#include <stdarg.h> #include <stdarg.h>
@@ -146,8 +147,10 @@ int server_cli_parse(int argc, char* argv[], ServerCliOptions* opts, char* err,
} }
char* end = NULL; char* end = NULL;
long n = strtol(inline_value, &end, 10); long n = strtol(inline_value, &end, 10);
if (!end || *end != '\0' || n < 0 || n > 10000000L) { if (!end || *end != '\0' || n < (long)CREDENTIAL_MIN_ITERS ||
set_error(err, err_size, "invalid --iterations '%s'", inline_value); n > (long)CREDENTIAL_MAX_ITERS) {
set_error(err, err_size, "--iterations must be in [%u,%u], got '%s'", CREDENTIAL_MIN_ITERS,
CREDENTIAL_MAX_ITERS, inline_value);
return -1; return -1;
} }
opts->hash_iterations = (uint32_t)n; opts->hash_iterations = (uint32_t)n;
+87 -19
View File
@@ -30,6 +30,16 @@ struct CredentialStore {
CredentialEntry* entries; CredentialEntry* entries;
int count; int count;
int capacity; 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. */ /* Exact marker prefix of the new store verifier field. */
@@ -179,15 +189,20 @@ bool credentials_b64_decode(const char* in, uint8_t* out, size_t out_sz, size_t*
if (decoded_len > out_sz) if (decoded_len > out_sz)
return false; return false;
/* EVP_DecodeBlock writes the full (padded) quantum, so decode into a scratch /* EVP_DecodeBlock writes the full (padded) quantum, so decode into a scratch
* buffer sized for it and copy only the real bytes out. */ * buffer sized for it and copy only the real bytes out. The single `done`
uint8_t scratch[192]; * 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); int n = EVP_DecodeBlock(scratch, (const unsigned char*)in, (int)len);
if (n < 0 || (size_t)n != padded_len) if (n < 0 || (size_t)n != padded_len)
return false; goto done;
memcpy(out, scratch, decoded_len); memcpy(out, scratch, decoded_len);
credentials_burn((char*)scratch, sizeof(scratch));
*out_len = decoded_len; *out_len = decoded_len;
return true; ok = true;
done:
credentials_burn((char*)scratch, sizeof(scratch));
return ok;
} }
bool credentials_random_bytes(uint8_t* out, size_t n) { bool credentials_random_bytes(uint8_t* out, size_t n) {
@@ -230,10 +245,9 @@ bool credentials_compute_keys(const char* password, const uint8_t salt[CREDENTIA
uint8_t server_key[CREDENTIAL_KEY_LEN]) { uint8_t server_key[CREDENTIAL_KEY_LEN]) {
if (!password || !salt) if (!password || !salt)
return false; return false;
/* The caller (store parser / client clamp) is responsible for the /* Enforce the full [MIN,MAX] policy here so no caller can derive a verifier
* [MIN,MAX] policy; this primitive only refuses a zero/unbounded work * with a work factor outside the validated store range. */
* factor. Tests exercise the known-answer vector at a smaller count. */ if (iters < CREDENTIAL_MIN_ITERS || iters > CREDENTIAL_MAX_ITERS)
if (iters == 0 || iters > CREDENTIAL_MAX_ITERS)
return false; return false;
size_t password_len = strlen(password); size_t password_len = strlen(password);
if (password_len > CREDENTIAL_MAX_PASSWORD_LEN || password_len > (size_t)INT_MAX) if (password_len > CREDENTIAL_MAX_PASSWORD_LEN || password_len > (size_t)INT_MAX)
@@ -338,11 +352,15 @@ bool credentials_verify_response(const CredentialVerifier* v, const char* user,
computed = hmac_sha256(v->server_key, CREDENTIAL_KEY_LEN, auth_msg, msg_len, server_sig); computed = hmac_sha256(v->server_key, CREDENTIAL_KEY_LEN, auth_msg, msg_len, server_sig);
if (computed) if (computed)
memcpy(server_sig_out, server_sig, CREDENTIAL_KEY_LEN); memcpy(server_sig_out, server_sig, CREDENTIAL_KEY_LEN);
/* Constant-time compare over the fixed 32-byte keys; a tampered nonce /* Always run the constant-time key compare (even when `found` is false) and
* changes the AuthMessage and so the recovered key. */ * fold the accept decision with bitwise AND so no short-circuit reveals
bool accept = computed && v->found && * whether the user was found. A tampered nonce changes the AuthMessage and
credentials_secure_equal((const char*)recovered, (const char*)v->stored_key, * 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); CREDENTIAL_KEY_LEN);
bool accept = computed & v->found & key_match;
credentials_burn((char*)auth_msg, sizeof(auth_msg)); credentials_burn((char*)auth_msg, sizeof(auth_msg));
credentials_burn((char*)client_sig, sizeof(client_sig)); credentials_burn((char*)client_sig, sizeof(client_sig));
credentials_burn((char*)client_key, sizeof(client_key)); credentials_burn((char*)client_key, sizeof(client_key));
@@ -526,6 +544,18 @@ static CredentialStore* load_store_file(const char* path, char* err, size_t err_
ok = false; ok = false;
break; 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: iteration count %u disagrees with the store-wide %u "
"(the store must be uniform)",
path, line_no, parsed.iters, store->iters);
ok = false;
break;
}
if (find_user(store, user) >= 0) { if (find_user(store, user) >= 0) {
set_error(err, err_size, "credential file '%s' line %d: duplicate entry for user '%.*s'", set_error(err, err_size, "credential file '%s' line %d: duplicate entry for user '%.*s'",
path, line_no, (int)strlen(user), user); path, line_no, (int)strlen(user), user);
@@ -560,6 +590,15 @@ CredentialStore* credentials_load(const char* password_file, const char* early_i
CredentialStore* store = load_store_file(password_file, err, err_size); CredentialStore* store = load_store_file(password_file, err, err_size);
if (!store) if (!store)
return NULL; 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) if (!early_input_file)
return store; return store;
@@ -568,6 +607,18 @@ CredentialStore* credentials_load(const char* password_file, const char* early_i
credentials_free(store); credentials_free(store);
return NULL; return NULL;
} }
/* 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 /* Layer early input over the password file: an identical verifier dedupes, a
* differing verifier for the same user is ambiguous and fails closed. */ * differing verifier for the same user is ambiguous and fails closed. */
for (int i = 0; i < early->count; i++) { for (int i = 0; i < early->count; i++) {
@@ -652,14 +703,23 @@ bool credentials_get_verifier(const CredentialStore* store, const char* user,
if (!out) if (!out)
return false; return false;
memset(out, 0, sizeof(*out)); memset(out, 0, sizeof(*out));
/* Start from the dummy verifier: a fresh random salt and the default const char* uname = user ? user : "";
* iteration count, so a miss is shaped exactly like a hit. */ /* The dummy verifier is shaped exactly like a hit: the store-wide uniform
if (!credentials_random_bytes(out->salt, CREDENTIAL_SALT_LEN)) * iteration count (default for an empty store) and fixed dummy keys. */
return false; out->iters = (store && store->count > 0) ? store->iters : CREDENTIAL_DEFAULT_ITERS;
out->iters = CREDENTIAL_DEFAULT_ITERS;
memcpy(out->stored_key, k_dummy_stored_key, CREDENTIAL_KEY_LEN); memcpy(out->stored_key, k_dummy_stored_key, CREDENTIAL_KEY_LEN);
memcpy(out->server_key, k_dummy_server_key, CREDENTIAL_KEY_LEN); memcpy(out->server_key, k_dummy_server_key, CREDENTIAL_KEY_LEN);
out->found = false; 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))
return false;
memcpy(out->salt, mac, CREDENTIAL_SALT_LEN);
credentials_burn((char*)mac, sizeof(mac));
if (!store || !user || n < 0) if (!store || !user || n < 0)
return true; return true;
/* Module-list membership: constant-time full scan, no early break, so the /* Module-list membership: constant-time full scan, no early break, so the
@@ -731,10 +791,17 @@ bool credentials_hash_store_line(const char* user, const char* password, uint32_
credentials_burn((char*)stored_key, sizeof(stored_key)); credentials_burn((char*)stored_key, sizeof(stored_key));
credentials_burn((char*)server_key, sizeof(server_key)); credentials_burn((char*)server_key, sizeof(server_key));
credentials_burn((char*)salt, sizeof(salt)); credentials_burn((char*)salt, sizeof(salt));
if (!ok) /* 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; return false;
}
if (written < 0 || (size_t)written >= out_sz) { if (written < 0 || (size_t)written >= out_sz) {
set_error(err, err_size, "output buffer too small for the credential line"); set_error(err, err_size, "output buffer too small for the credential line");
credentials_burn(out, out_sz);
return false; return false;
} }
return true; return true;
@@ -797,6 +864,7 @@ int credentials_hash_file(const char* path, uint32_t iters, FILE* out, char* err
char store_line[CREDENTIAL_MAX_LINE]; char store_line[CREDENTIAL_MAX_LINE];
if (!credentials_hash_store_line(user, password, iters, store_line, sizeof(store_line), err, if (!credentials_hash_store_line(user, password, iters, store_line, sizeof(store_line), err,
err_size)) { err_size)) {
credentials_burn(store_line, sizeof(store_line));
result = -1; result = -1;
break; break;
} }
+15 -9
View File
@@ -55,9 +55,11 @@
typedef struct CredentialStore CredentialStore; typedef struct CredentialStore CredentialStore;
/* One resolved verifier. `found` is false for an unknown user or a user not on /* 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 fresh random salt, the * a module's auth list; the remaining fields then hold a deterministic dummy
* default iteration count and fixed dummy keys, so the server can run the same * salt (HMAC of the store-wide dummy key over the username), the store-wide
* challenge/response math with no enumeration/timing oracle. */ * 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 { typedef struct {
uint8_t salt[CREDENTIAL_SALT_LEN]; uint8_t salt[CREDENTIAL_SALT_LEN];
uint32_t iters; uint32_t iters;
@@ -73,8 +75,10 @@ typedef struct {
* opened or that fails the strict grammar is a hard error (err filled, NULL * opened or that fails the strict grammar is a hard error (err filled, NULL
* returned) -- the daemon fails CLOSED rather than serving an auth-required * returned) -- the daemon fails CLOSED rather than serving an auth-required
* module with a partial store. Both files may be NULL, which yields an empty * module with a partial store. Both files may be NULL, which yields an empty
* store (every auth-required module then refuses connections). When both are * store (every auth-required module then refuses connections). Every entry in
* given, the --early-input file is layered over --password-file: a duplicate * the resulting store must agree on the iteration count; entries that disagree
* (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 * username whose verifier matches is deduplicated; one whose verifier differs
* is an error (the two sources disagree), never a silent pick. * is an error (the two sources disagree), never a silent pick.
* *
@@ -101,9 +105,10 @@ bool credentials_random_bytes(uint8_t* out, size_t n);
/* Resolve `user` against the store AND the module's auth-user list. The list /* 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 * scan is a constant-time full-length comparison with no early break. On a
* miss, *out is filled with a dummy verifier (fresh random salt, default * miss, *out is filled with a dummy verifier (a deterministic per-username salt
* iterations, fixed dummy keys, found=false). Returns false only on invalid * derived from the store's dummy key, the store-wide uniform iteration count,
* arguments/allocation failure. */ * 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, bool credentials_get_verifier(const CredentialStore* store, const char* user,
const char* const* module_users, int n, CredentialVerifier* out); const char* const* module_users, int n, CredentialVerifier* out);
@@ -111,7 +116,8 @@ bool credentials_get_verifier(const CredentialStore* store, const char* user,
* K = PBKDF2-HMAC-SHA256(password, salt, iters, 32) * K = PBKDF2-HMAC-SHA256(password, salt, iters, 32)
* ClientKey = HMAC-SHA256(K, "Client Key"); StoredKey = SHA256(ClientKey) * ClientKey = HMAC-SHA256(K, "Client Key"); StoredKey = SHA256(ClientKey)
* ServerKey = HMAC-SHA256(K, "Server Key") * ServerKey = HMAC-SHA256(K, "Server Key")
* Any of client_key/stored_key/server_key may be NULL when not needed. */ * 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], bool credentials_compute_keys(const char* password, const uint8_t salt[CREDENTIAL_SALT_LEN],
uint32_t iters, uint8_t client_key[CREDENTIAL_KEY_LEN], uint32_t iters, uint8_t client_key[CREDENTIAL_KEY_LEN],
uint8_t stored_key[CREDENTIAL_KEY_LEN], uint8_t stored_key[CREDENTIAL_KEY_LEN],
+6 -4
View File
@@ -427,9 +427,10 @@ static const char* status_to_string(Status status) {
} }
/* Shared string send/receive implementation. `redact` selects whether the /* Shared string send/receive implementation. `redact` selects whether the
* payload body is written to the LOG_DEBUG_PROTO debug log: secrets (daemon * payload body is written to the LOG_DEBUG_PROTO debug log: daemon auth material
* auth username/digest) set it so a --verbose log never captures a replayable * (the username and the proof/signature fields) sets it so a --verbose log never
* credential, while every other string keeps its normal debug trace. */ * captures a replayable credential, while every other string keeps its normal
* debug trace. */
static bool protocol_send_str_impl(ProtocolSession* session, const char* data, bool redact) { static bool protocol_send_str_impl(ProtocolSession* session, const char* data, bool redact) {
if (data == NULL) if (data == NULL)
return false; return false;
@@ -602,7 +603,8 @@ char* receive_str(int fd) {
return protocol_receive_str(legacy_session(fd, -1)); return protocol_receive_str(legacy_session(fd, -1));
} }
/* Redacted variants: identical framing, but the string body is never written to /* Redacted variants: identical framing, but the string body is never written to
the debug protocol log. Used for the daemon auth username/digest. */ the debug protocol log. Used for daemon auth material (username, proof,
signature). */
bool send_str_redacted(int fd, const char* data) { bool send_str_redacted(int fd, const char* data) {
return protocol_send_str_redacted(legacy_session(-1, fd), data); return protocol_send_str_redacted(legacy_session(-1, fd), data);
} }
+3 -2
View File
@@ -151,8 +151,9 @@ bool protocol_send_str(ProtocolSession* session, const char* data);
char* protocol_receive_str(ProtocolSession* session); char* protocol_receive_str(ProtocolSession* session);
/* Redacted string variants: identical wire framing to protocol_send_str / /* Redacted string variants: identical wire framing to protocol_send_str /
* protocol_receive_str, but the payload body is replaced by `<redacted>` in the * protocol_receive_str, but the payload body is replaced by `<redacted>` in the
* LOG_DEBUG_PROTO debug log. Used for secrets (daemon auth username/digest) so * LOG_DEBUG_PROTO debug log. Used for daemon auth material (the username and
* a --verbose log can never capture a replayable credential. */ * the proof/signature fields) so a --verbose log can never capture a credential
* that could be replayed. */
bool protocol_send_str_redacted(ProtocolSession* session, const char* data); bool protocol_send_str_redacted(ProtocolSession* session, const char* data);
char* protocol_receive_str_redacted(ProtocolSession* session); char* protocol_receive_str_redacted(ProtocolSession* session);
bool protocol_send_data(ProtocolSession* session, const Data* data); bool protocol_send_data(ProtocolSession* session, const Data* data);
+7 -3
View File
@@ -53,6 +53,7 @@ static size_t g_frame_len;
static size_t g_version_len; /* length of the leading version-string frame */ 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_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 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; static bool g_frame_ready;
/* Read the canonical frame from the send peer. The producer shuts down its /* Read the canonical frame from the send peer. The producer shuts down its
@@ -180,17 +181,20 @@ out:
} }
/* Locate the auth username string (a size_t length followed by its bytes); /* Locate the auth username string (a size_t length followed by its bytes);
* the presence int sits one int before the length. */ * 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"; const char* auth_name = "alice";
size_t auth_name_len = strlen(auth_name); size_t auth_name_len = strlen(auth_name);
if (g_frame_len >= sizeof(size_t) + auth_name_len + sizeof(int)) { if (g_frame_len >= sizeof(size_t) + auth_name_len + sizeof(int)) {
for (size_t i = sizeof(size_t); i + auth_name_len <= g_frame_len; i++) { 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) if (memcmp(g_frame + i, auth_name, auth_name_len) != 0)
continue; continue;
size_t found_len = 0; size_t found_len = 0;
memcpy(&found_len, g_frame + i - sizeof(size_t), sizeof(size_t)); memcpy(&found_len, g_frame + i - sizeof(size_t), sizeof(size_t));
if (found_len == auth_name_len) { if (found_len == auth_name_len) {
g_auth_off = i - sizeof(size_t) - sizeof(int); g_auth_off = i - sizeof(size_t) - sizeof(int);
g_auth_found = true;
break; break;
} }
} }
@@ -327,7 +331,7 @@ int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) {
receive_stream(g_frame, g_version_len, data, size); receive_stream(g_frame, g_version_len, data, size);
/* Keep the valid frame up to the shortened auth block, fuzz it. */ /* Keep the valid frame up to the shortened auth block, fuzz it. */
if (g_auth_off > 0) if (g_auth_found)
receive_stream(g_frame, g_auth_off, data, size); receive_stream(g_frame, g_auth_off, data, size);
/* Keep the valid frame up to the P8 tail, fuzz super_mode + copy-as. */ /* Keep the valid frame up to the P8 tail, fuzz super_mode + copy-as. */
+101 -13
View File
@@ -11,17 +11,21 @@
#include <unistd.h> #include <unistd.h>
/* Known-answer vector, independently recomputed with Python /* Known-answer vector, independently recomputed with Python
* (hashlib.pbkdf2_hmac / hmac / hashlib.sha256). */ * (hashlib.pbkdf2_hmac / hmac / hashlib.sha256) at the default work factor. */
#define KAT_PASSWORD "alice-s3cret" #define KAT_PASSWORD "alice-s3cret"
#define KAT_USER "alice" #define KAT_USER "alice"
#define KAT_ITERS 4096u #define KAT_ITERS CREDENTIAL_DEFAULT_ITERS
#define KAT_CLIENT_KEY "80f0e0af43e34e8aeec1738609c5d1eac8646601b4f244cef5a04a9f13563cf8" #define KAT_CLIENT_KEY "845891d65ab3c9807f7ae5c123ab70714cc8b56173fccfce6a758993e858e17c"
#define KAT_STORED_KEY "5b3b489437085a11fe594ab99154da4cb4ebab4ae8c2edf51fbaa9277e9f9099" #define KAT_STORED_KEY "d192f6da1c54bf73768f0a7c713995212d303c46f809de1b2e407fb3ad1c206b"
#define KAT_SERVER_KEY "38f668736210bd4dbcb5193b9a514c5b1047174eff5f5a80ee4c2b1e8b2c76a1" #define KAT_SERVER_KEY "508ad587574f59700c2d0bbec8417d6fe94bf8e39669adbabe7cbbd8700f86ce"
#define KAT_CLIENT_PROOF "c9b0d397b853176b842a751b9af327270ae0c5e286cb77d16e59fa42245270f6" #define KAT_CLIENT_PROOF "e0cb4b894a7438d75cbb3066aa135d10200b76eea78137c5c04059895eed9242"
#define KAT_SERVER_SIG "93c0d94b9ee29798ffd42734a9becb2168bad1f69e492d2ccb042a43db13bffc" #define KAT_SERVER_SIG "f564e00fa6368e78d35b7116c7624d6cb047a950d87e3799e4e6e8c8954b618a"
#define KAT_SALT_B64 "AAECAwQFBgcICQoLDA0ODw==" #define KAT_SALT_B64 "AAECAwQFBgcICQoLDA0ODw=="
#define KAT_NAME_PREFIX "$fastsync$1$pbkdf2-sha256$" #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; static int g_file_counter = 0;
@@ -133,6 +137,10 @@ static void test_credentials_compute_keys_kat() {
unhex(KAT_SERVER_KEY, expect, sizeof(expect)); unhex(KAT_SERVER_KEY, expect, sizeof(expect));
EXPECT_TRUE(memcmp(server_key, expect, sizeof(expect)) == 0); 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, 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)); EXPECT_FALSE(credentials_compute_keys(NULL, salt, KAT_ITERS, client_key, stored_key, server_key));
} }
@@ -166,9 +174,9 @@ static void test_credentials_auth_message_and_proof_kat() {
uint8_t server_sig[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, EXPECT_TRUE(credentials_client_proof(client_key, stored_key, server_key, auth_msg, msg_len, proof,
server_sig)); server_sig));
unhex(KAT_CLIENT_PROOF, expect, sizeof(expect)); unhex(KAT_CLIENT_PROOF, expect, CREDENTIAL_KEY_LEN);
EXPECT_TRUE(memcmp(proof, expect, CREDENTIAL_KEY_LEN) == 0); EXPECT_TRUE(memcmp(proof, expect, CREDENTIAL_KEY_LEN) == 0);
unhex(KAT_SERVER_SIG, expect, sizeof(expect)); unhex(KAT_SERVER_SIG, expect, CREDENTIAL_KEY_LEN);
EXPECT_TRUE(memcmp(server_sig, expect, CREDENTIAL_KEY_LEN) == 0); EXPECT_TRUE(memcmp(server_sig, expect, CREDENTIAL_KEY_LEN) == 0);
} }
@@ -274,6 +282,35 @@ static void test_credentials_hash_store_line_roundtrip() {
free(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() { static void test_credentials_store_parse_valid() {
char line_alice[CREDENTIAL_MAX_LINE]; char line_alice[CREDENTIAL_MAX_LINE];
char line_bob[CREDENTIAL_MAX_LINE]; char line_bob[CREDENTIAL_MAX_LINE];
@@ -308,14 +345,27 @@ static void test_credentials_store_parse_valid() {
EXPECT_TRUE(credentials_get_verifier(store, "mallory", module_users, 1, &v)); 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.stored_key, zero, CREDENTIAL_KEY_LEN) == 0);
EXPECT_TRUE(memcmp(v.server_key, zero, CREDENTIAL_KEY_LEN) == 0); EXPECT_TRUE(memcmp(v.server_key, zero, CREDENTIAL_KEY_LEN) == 0);
/* Miss salt is fresh random on each call. */ /* 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_a[CREDENTIAL_SALT_LEN];
uint8_t salt_b[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)); EXPECT_TRUE(credentials_get_verifier(store, "mallory", module_users, 1, &v));
memcpy(salt_a, v.salt, sizeof(salt_a)); memcpy(salt_a, v.salt, sizeof(salt_a));
miss_iters_a = v.iters;
EXPECT_TRUE(credentials_get_verifier(store, "mallory", module_users, 1, &v)); EXPECT_TRUE(credentials_get_verifier(store, "mallory", module_users, 1, &v));
memcpy(salt_b, v.salt, sizeof(salt_b)); memcpy(salt_b, v.salt, sizeof(salt_b));
EXPECT_TRUE(memcmp(salt_a, salt_b, sizeof(salt_a)) != 0); 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); credentials_free(store);
rm_temp(path); rm_temp(path);
@@ -357,8 +407,8 @@ static void test_credentials_store_parse_rejects_malformed() {
short_key, short_key,
empty_field, empty_field,
"ali " "ali "
"ce:$fastsync$1$pbkdf2-sha256$600000$AAECAwQFBgcICQoLDA0ODw==$WztIlDcIWhH+" "ce:$fastsync$1$pbkdf2-sha256$600000$AAECAwQFBgcICQoLDA0ODw==$0ZL22hxUv3N2jwp8"
"WUq5kVTaTLTrq0rowu31H7qpJ36fkJk=$OPZoc2IQvU28tRk7mlFMWxBHF07/X1qA7kwrHossdqE=\n", "cTmVIS0wPEb4Cd4bLkB/s60cIGs=$UIrVh1dPWXAMLQu+yEF9b+lL+OOWaa26vny72HAPhs4=\n",
}; };
for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) { for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) {
char* path = make_tmp_file(cases[i]); char* path = make_tmp_file(cases[i]);
@@ -401,6 +451,25 @@ static void test_credentials_store_duplicate_rejected() {
free(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() { static void test_credentials_store_parse_missing_file() {
char err[512]; char err[512];
const CredentialStore* store = const CredentialStore* store =
@@ -414,6 +483,11 @@ static void test_credentials_store_empty_and_null() {
CredentialStore* store = credentials_load(NULL, NULL, err, sizeof(err)); CredentialStore* store = credentials_load(NULL, NULL, err, sizeof(err));
EXPECT_NOT_NULL(store); EXPECT_NOT_NULL(store);
EXPECT_EQ_INT(credentials_store_size(store), 0); EXPECT_EQ_INT(credentials_store_size(store), 0);
/* 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); credentials_free(store);
char* path = make_tmp_file("# nothing here\n; nor here\n"); char* path = make_tmp_file("# nothing here\n; nor here\n");
@@ -449,19 +523,24 @@ static void test_credentials_early_input_merge() {
char alice_file[CREDENTIAL_MAX_LINE + 2]; char alice_file[CREDENTIAL_MAX_LINE + 2];
char bob_file[CREDENTIAL_MAX_LINE + 2]; char bob_file[CREDENTIAL_MAX_LINE + 2];
char alice_other[CREDENTIAL_MAX_LINE]; char alice_other[CREDENTIAL_MAX_LINE];
char bob_other_iters[CREDENTIAL_MAX_LINE];
snprintf(alice_file, sizeof(alice_file), "%s\n", alice); snprintf(alice_file, sizeof(alice_file), "%s\n", alice);
snprintf(bob_file, sizeof(bob_file), "%s\n", bob); snprintf(bob_file, sizeof(bob_file), "%s\n", bob);
EXPECT_TRUE(make_store_line("alice", "different-s3cret", CREDENTIAL_MIN_ITERS, alice_other, EXPECT_TRUE(make_store_line("alice", "different-s3cret", CREDENTIAL_MIN_ITERS, alice_other,
sizeof(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* pw = make_tmp_file(alice_file);
char* early = make_tmp_file(bob_file); char* early = make_tmp_file(bob_file);
char* early_same = make_tmp_file(alice_file); /* byte-identical verifier dedupes */ char* early_same = make_tmp_file(alice_file); /* byte-identical verifier dedupes */
char* early_diff = make_tmp_file(alice_other); char* early_diff = make_tmp_file(alice_other);
char* early_iters = make_tmp_file(bob_other_iters);
EXPECT_NOT_NULL(pw); EXPECT_NOT_NULL(pw);
EXPECT_NOT_NULL(early); EXPECT_NOT_NULL(early);
EXPECT_NOT_NULL(early_same); EXPECT_NOT_NULL(early_same);
EXPECT_NOT_NULL(early_diff); EXPECT_NOT_NULL(early_diff);
EXPECT_NOT_NULL(early_iters);
char err[512]; char err[512];
/* A second file adds a new user. */ /* A second file adds a new user. */
@@ -483,14 +562,21 @@ static void test_credentials_early_input_merge() {
EXPECT_NULL(store); EXPECT_NULL(store);
EXPECT_TRUE(err[0] != '\0'); 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(pw);
rm_temp(early); rm_temp(early);
rm_temp(early_same); rm_temp(early_same);
rm_temp(early_diff); rm_temp(early_diff);
rm_temp(early_iters);
free(pw); free(pw);
free(early); free(early);
free(early_same); free(early_same);
free(early_diff); free(early_diff);
free(early_iters);
} }
static void test_credentials_read_secret_file() { static void test_credentials_read_secret_file() {
@@ -677,10 +763,12 @@ void test_credentials(void) {
test_credentials_verify_response_kat(); test_credentials_verify_response_kat();
test_credentials_username_valid(); test_credentials_username_valid();
test_credentials_hash_store_line_roundtrip(); test_credentials_hash_store_line_roundtrip();
test_credentials_store_line_golden();
test_credentials_store_parse_valid(); test_credentials_store_parse_valid();
test_credentials_store_parse_rejects_malformed(); test_credentials_store_parse_rejects_malformed();
test_credentials_store_rejects_legacy_hex(); test_credentials_store_rejects_legacy_hex();
test_credentials_store_duplicate_rejected(); test_credentials_store_duplicate_rejected();
test_credentials_store_rejects_nonuniform_iters();
test_credentials_store_parse_missing_file(); test_credentials_store_parse_missing_file();
test_credentials_store_empty_and_null(); test_credentials_store_empty_and_null();
test_credentials_store_overlong_line_rejected(); test_credentials_store_overlong_line_rejected();