From eaf67f625718f940db0f2287b096ac1dc2bc97c4 Mon Sep 17 00:00:00 2001 From: TapTap Date: Sat, 12 Sep 2026 17:56:52 +0200 Subject: [PATCH] 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 --- README.md | 10 ++- RSYNC_COMPAT.md | 4 +- src/server/server.c | 124 +++++++++++++++++-------------- src/server/server_cli.c | 7 +- src/shared/credentials.c | 106 +++++++++++++++++++++----- src/shared/credentials.h | 24 +++--- src/shared/protocol.c | 10 ++- src/shared/protocol.h | 5 +- tests/fuzz/fuzz_config_receive.c | 10 ++- tests/test_credentials.c | 114 ++++++++++++++++++++++++---- 10 files changed, 302 insertions(+), 112 deletions(-) diff --git a/README.md b/README.md index 589d3fb..17bf43f 100644 --- a/README.md +++ b/README.md @@ -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 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 -lines are generated with `fastsync-server --hash-credentials ` -(see `RSYNC_COMPAT.md`); legacy `user:SHA256HEX` stores are rejected. +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 ` (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 verification; without it, traffic is encrypted but peer identity is not diff --git a/RSYNC_COMPAT.md b/RSYNC_COMPAT.md index 5a0a2b4..1db9d7d 100644 --- a/RSYNC_COMPAT.md +++ b/RSYNC_COMPAT.md @@ -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. - **`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` (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. -- **Credential store format:** server `--password-file`/`--early-input` files are line-based `user:$fastsync$1$pbkdf2-sha256$$$$`, 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. +- **`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$$$$`, 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. diff --git a/src/server/server.c b/src/server/server.c index 3e6771d..3b1aa17 100644 --- a/src/server/server.c +++ b/src/server/server.c @@ -73,65 +73,68 @@ typedef struct ModuleGateContext { * 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 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 - * user is a dummy (random salt, dummy keys, found=false) so the same math runs - * and no user-enumeration/timing oracle is exposed. */ + * 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) { - if (!config->auth_user) { - send_status(fd, STATUS_AUTH_FAILED); - return false; - } + 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)) - return false; - uint8_t snonce[CREDENTIAL_NONCE_LEN]; - 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(snonce, sizeof(snonce), snonce_b64, sizeof(snonce_b64)); - if (!ok) { - send_status(fd, STATUS_AUTH_FAILED); - return false; - } - ok = send_status(fd, STATUS_AUTH_CHALLENGE) && send_int(fd, (int)verifier.iters) && - send_str(fd, salt_b64) && send_str(fd, snonce_b64); + 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; - char* cnonce_b64 = NULL; - char* proof_b64 = NULL; - 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); - proof_b64 = receive_str_redacted(fd); - ok = 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; - } - verified = ok && credentials_verify_response(&verifier, config->auth_user, snonce, cnonce, - proof, server_sig); - } - if (verified) { - char sig_b64[45]; - ok = 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); - credentials_burn(sig_b64, sizeof(sig_b64)); - } else { - send_status(fd, STATUS_AUTH_FAILED); - ok = false; - } + 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); @@ -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*)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 verified && ok; + return result; } /* 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/ * response BEFORE the module root is installed and before any data moves. * 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). */ if (g_credentials == NULL) { 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(" --hash-credentials Read '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(" 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); @@ -828,6 +834,14 @@ int main(int argc, char* argv[]) { * 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) { diff --git a/src/server/server_cli.c b/src/server/server_cli.c index 4b5b8d7..794a97a 100644 --- a/src/server/server_cli.c +++ b/src/server/server_cli.c @@ -1,5 +1,6 @@ #include "server_cli.h" #include "charset.h" +#include "credentials.h" #include "utils.h" #include #include @@ -146,8 +147,10 @@ int server_cli_parse(int argc, char* argv[], ServerCliOptions* opts, char* err, } char* end = NULL; long n = strtol(inline_value, &end, 10); - if (!end || *end != '\0' || n < 0 || n > 10000000L) { - set_error(err, err_size, "invalid --iterations '%s'", inline_value); + 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; diff --git a/src/shared/credentials.c b/src/shared/credentials.c index 7888bdd..e305032 100644 --- a/src/shared/credentials.c +++ b/src/shared/credentials.c @@ -30,6 +30,16 @@ 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. */ @@ -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) 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. */ - uint8_t scratch[192]; + * 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) - return false; + goto done; memcpy(out, scratch, decoded_len); - credentials_burn((char*)scratch, sizeof(scratch)); *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) { @@ -230,10 +245,9 @@ bool credentials_compute_keys(const char* password, const uint8_t salt[CREDENTIA uint8_t server_key[CREDENTIAL_KEY_LEN]) { if (!password || !salt) return false; - /* The caller (store parser / client clamp) is responsible for the - * [MIN,MAX] policy; this primitive only refuses a zero/unbounded work - * factor. Tests exercise the known-answer vector at a smaller count. */ - if (iters == 0 || iters > CREDENTIAL_MAX_ITERS) + /* 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) @@ -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); if (computed) memcpy(server_sig_out, server_sig, CREDENTIAL_KEY_LEN); - /* Constant-time compare over the fixed 32-byte keys; a tampered nonce - * changes the AuthMessage and so the recovered key. */ - bool accept = computed && v->found && - credentials_secure_equal((const char*)recovered, (const char*)v->stored_key, + /* 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)); @@ -526,6 +544,18 @@ static CredentialStore* load_store_file(const char* path, char* err, size_t err_ 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: 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) { set_error(err, err_size, "credential file '%s' line %d: duplicate entry for user '%.*s'", 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); 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; @@ -568,6 +607,18 @@ CredentialStore* credentials_load(const char* password_file, const char* early_i credentials_free(store); 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 * differing verifier for the same user is ambiguous and fails closed. */ for (int i = 0; i < early->count; i++) { @@ -652,14 +703,23 @@ bool credentials_get_verifier(const CredentialStore* store, const char* user, if (!out) return false; memset(out, 0, sizeof(*out)); - /* Start from the dummy verifier: a fresh random salt and the default - * iteration count, so a miss is shaped exactly like a hit. */ - if (!credentials_random_bytes(out->salt, CREDENTIAL_SALT_LEN)) - return false; - out->iters = CREDENTIAL_DEFAULT_ITERS; + 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)) + return false; + memcpy(out->salt, mac, CREDENTIAL_SALT_LEN); + credentials_burn((char*)mac, sizeof(mac)); if (!store || !user || n < 0) return true; /* 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*)server_key, sizeof(server_key)); 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; + } 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; @@ -797,6 +864,7 @@ int credentials_hash_file(const char* path, uint32_t iters, FILE* out, char* err 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; } diff --git a/src/shared/credentials.h b/src/shared/credentials.h index 5033dbf..9742ea9 100644 --- a/src/shared/credentials.h +++ b/src/shared/credentials.h @@ -55,9 +55,11 @@ 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 fresh random salt, the - * default iteration count and fixed dummy keys, so the server can run the same - * challenge/response math with no enumeration/timing oracle. */ + * 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; @@ -73,8 +75,10 @@ typedef struct { * 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 + * 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. * @@ -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 * 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 - * iterations, fixed dummy keys, found=false). Returns false only on invalid - * arguments/allocation failure. */ + * 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); @@ -111,7 +116,8 @@ bool credentials_get_verifier(const CredentialStore* store, const char* user, * 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. */ + * 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], diff --git a/src/shared/protocol.c b/src/shared/protocol.c index 709a9ef..24c7184 100644 --- a/src/shared/protocol.c +++ b/src/shared/protocol.c @@ -427,9 +427,10 @@ static const char* status_to_string(Status status) { } /* 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; @@ -602,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); } diff --git a/src/shared/protocol.h b/src/shared/protocol.h index 256c0db..e23a68f 100644 --- a/src/shared/protocol.h +++ b/src/shared/protocol.h @@ -151,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 `` 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); diff --git a/tests/fuzz/fuzz_config_receive.c b/tests/fuzz/fuzz_config_receive.c index 4359927..12c9c61 100644 --- a/tests/fuzz/fuzz_config_receive.c +++ b/tests/fuzz/fuzz_config_receive.c @@ -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_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 @@ -180,17 +181,20 @@ out: } /* 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"; 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); 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) 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; } } @@ -327,7 +331,7 @@ int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) { receive_stream(g_frame, g_version_len, data, size); /* 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); /* Keep the valid frame up to the P8 tail, fuzz super_mode + copy-as. */ diff --git a/tests/test_credentials.c b/tests/test_credentials.c index 54de363..425a715 100644 --- a/tests/test_credentials.c +++ b/tests/test_credentials.c @@ -11,17 +11,21 @@ #include /* 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_USER "alice" -#define KAT_ITERS 4096u -#define KAT_CLIENT_KEY "80f0e0af43e34e8aeec1738609c5d1eac8646601b4f244cef5a04a9f13563cf8" -#define KAT_STORED_KEY "5b3b489437085a11fe594ab99154da4cb4ebab4ae8c2edf51fbaa9277e9f9099" -#define KAT_SERVER_KEY "38f668736210bd4dbcb5193b9a514c5b1047174eff5f5a80ee4c2b1e8b2c76a1" -#define KAT_CLIENT_PROOF "c9b0d397b853176b842a751b9af327270ae0c5e286cb77d16e59fa42245270f6" -#define KAT_SERVER_SIG "93c0d94b9ee29798ffd42734a9becb2168bad1f69e492d2ccb042a43db13bffc" +#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; @@ -133,6 +137,10 @@ static void test_credentials_compute_keys_kat() { 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)); } @@ -166,9 +174,9 @@ static void test_credentials_auth_message_and_proof_kat() { 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, sizeof(expect)); + unhex(KAT_CLIENT_PROOF, expect, CREDENTIAL_KEY_LEN); 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); } @@ -274,6 +282,35 @@ static void test_credentials_hash_store_line_roundtrip() { 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]; @@ -308,14 +345,27 @@ static void test_credentials_store_parse_valid() { 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); - /* 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_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)); - 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); rm_temp(path); @@ -357,8 +407,8 @@ static void test_credentials_store_parse_rejects_malformed() { short_key, empty_field, "ali " - "ce:$fastsync$1$pbkdf2-sha256$600000$AAECAwQFBgcICQoLDA0ODw==$WztIlDcIWhH+" - "WUq5kVTaTLTrq0rowu31H7qpJ36fkJk=$OPZoc2IQvU28tRk7mlFMWxBHF07/X1qA7kwrHossdqE=\n", + "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]); @@ -401,6 +451,25 @@ static void test_credentials_store_duplicate_rejected() { 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 = @@ -414,6 +483,11 @@ static void test_credentials_store_empty_and_null() { CredentialStore* store = credentials_load(NULL, NULL, err, sizeof(err)); EXPECT_NOT_NULL(store); 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); 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 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. */ @@ -483,14 +562,21 @@ static void test_credentials_early_input_merge() { 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() { @@ -677,10 +763,12 @@ void test_credentials(void) { 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();