From eca6900631aa7d284dabb366790a4fb50bcad87b Mon Sep 17 00:00:00 2001 From: Emma Stensland Date: Tue, 21 Jul 2026 11:24:30 -0600 Subject: [PATCH] Mitigate user enumeration timing oracle using dummy hash cache --- apps/wolfsshd/auth.c | 537 ++++++- apps/wolfsshd/auth.h | 35 + apps/wolfsshd/include.am | 2 +- apps/wolfsshd/test/test_configuration.c | 1801 ++++++++++++++++++++--- apps/wolfsshd/wolfsshd.c | 6 + 5 files changed, 2119 insertions(+), 262 deletions(-) diff --git a/apps/wolfsshd/auth.c b/apps/wolfsshd/auth.c index 9c70af57b..e673e666a 100644 --- a/apps/wolfsshd/auth.c +++ b/apps/wolfsshd/auth.c @@ -94,6 +94,15 @@ #if !defined(_WIN32) && !(defined(__OSX__) || defined(__APPLE__)) #include #define HAVE_SHADOW + +#if defined(_AIX) || defined(__TOS_AIX__) + #define WSSHD_SHADOW_FILE "/etc/security/passwd" +#elif defined(__FreeBSD__) || defined(__OpenBSD__) || defined(__NetBSD__) || defined(__DragonFly__) + #define WSSHD_SHADOW_FILE "/etc/master.passwd" +#else + #define WSSHD_SHADOW_FILE "/etc/shadow" +#endif + #endif #if defined(WOLFSSHD_UNIT_TEST) && !defined(_WIN32) @@ -111,6 +120,10 @@ int (*wsshd_seteuid_cb)(WUID_T) = seteuid; struct passwd* (*wsshd_getpwnam_cb)(const char*) = getpwnam; #define getpwnam wsshd_getpwnam_cb int (*wsshd_setgroups_cb)(int, const WGID_T*) = wsshd_setgroups_default; +#ifdef HAVE_SHADOW +struct spwd* (*wsshd_getspnam_cb)(const char*) = getspnam; +#define getspnam wsshd_getspnam_cb +#endif #endif #ifdef WOLFSSH_OSSH_CERTS @@ -318,6 +331,255 @@ static int CheckAuthKeysLine(char* line, word32 lineSz, const byte* key, return ret; } +#ifdef HAVE_SHADOW +/* Shared sizing for the dummy-hash buffers used to equalize crypt() timing + * across real and fake password checks. Also used in CheckPasswordUnix to + * size the real shadow hash copy buffer, so raising this changes both the + * fake-hash template capacity and the real-hash fail-closed threshold. */ +#define WSSHD_FAKE_HASH_SZ 256 +/* Modular crypt bcrypt format: "$2$$" prefix is 7 bytes, + * followed by a 22-byte base64-like salt, before the 31-byte digest. */ +#define WSSHD_BCRYPT_PREFIX_LEN 7 +#define WSSHD_BCRYPT_SALT_LEN 22 +/* Must be at least WSSHD_BCRYPT_SALT_LEN characters; indexed modulo its own + * length below so a mismatch can't read out of bounds. */ +#define WSSHD_DUMMY_SALT_ALPHABET "ABCDEFGHIJKLMNOPQRSTUV" + +#define WSSHD_MAX_FAKE_HASHES 8 +/* Oversized vs. real /etc/shadow lines so ordinary entries aren't truncated. */ +#define WSSHD_SHADOW_LINE_SZ 512 +/* Scratch buffer for draining an over-length line's remainder. */ +#define WSSHD_SHADOW_DUMP_SZ 256 +/* No lock needed: wolfsshd forks a fresh process per connection, so each + * process's copy is written once by AuthInit() before any auth attempt. */ +static char cachedFakeHashes[WSSHD_MAX_FAKE_HASHES][WSSHD_FAKE_HASH_SZ] = {{0}}; +static int numCachedFakeHashes = 0; + +#ifdef WOLFSSHD_UNIT_TEST +void GetFakeHashFromTemplate(const char* tmpl, char* out, word32 outSz) +#else +static void GetFakeHashFromTemplate(const char* tmpl, char* out, word32 outSz) +#endif +{ + word32 i; + word32 dollarCount = 0; + word32 lastDollarIdx = 0; + + if (tmpl == NULL || out == NULL || outSz < 3) return; + + /* Output will always be considered a "locked" account by prefixing '!' */ + out[0] = '!'; + + /* If it doesn't look like a modular crypt format, leave just "!" so + * CheckPasswordHashUnix's storedSz > 1 check is false and it falls + * through to the fixed-cost fakeHashSHA512 salt instead of reusing + * a bare "*" that fails crypt() immediately and skips that cost. */ + if (tmpl[0] != '$') { + out[1] = '\0'; + return; + } + + if (XSTRNCMP(tmpl, "$2", 2) == 0) { + /* bcrypt: copy only prefix+salt, excluding the digest. Legacy + * "$2$NN$" has no variant letter, so its prefix is 1 byte shorter + * than "$2a$NN$" etc. */ + word32 prefixLen = (tmpl[2] == '$') ? + WSSHD_BCRYPT_PREFIX_LEN - 1 : WSSHD_BCRYPT_PREFIX_LEN; + word32 saltEnd = prefixLen + WSSHD_BCRYPT_SALT_LEN; + word32 copyLen = (saltEnd < outSz - 1) ? saltEnd : (outSz - 2); + word32 tmplLen = (word32)XSTRLEN(tmpl); + + if (copyLen > tmplLen) { + copyLen = tmplLen; + } + + XMEMCPY(out + 1, tmpl, copyLen); + out[1 + copyLen] = '\0'; + for (i = prefixLen; i < copyLen; i++) { + /* Overwrite with a dummy alphanumeric salt */ + out[i + 1] = WSSHD_DUMMY_SALT_ALPHABET[ + (i - prefixLen) % (sizeof(WSSHD_DUMMY_SALT_ALPHABET) - 1)]; + } + } + else { + word32 prevDollarIdx = 0; + /* Others (MD5, SHA-256, SHA-512, yescrypt, etc.): salt ends at the + * last '$' before the digest. yescrypt's non-standard encoding may + * yield dollarCount < 3, producing '!*', which causes + * CheckPasswordHashUnix to fall back to the fixed-cost SHA-512 salt. */ + for (i = 0; tmpl[i] != '\0'; i++) { + if (tmpl[i] == '$') { + dollarCount++; + prevDollarIdx = lastDollarIdx; + lastDollarIdx = i; + } + } + + /* e.g., $6$rounds=5000$salt$hash -> prevDollarIdx is before 'salt' */ + if (dollarCount >= 3 && prevDollarIdx + 2 < outSz) { + XMEMCPY(out + 1, tmpl, prevDollarIdx + 1); + out[prevDollarIdx + 2] = '\0'; + XSTRNCAT(out, "wolfSSHFakeSalt$", outSz - XSTRLEN(out) - 1); + } + else { + XSTRNCPY(out + 1, "*", outSz - 1); + } + } +} + +/* Parses a "user:hash:..." shadow line and adds its fake-hash template to + * cachedFakeHashes, deduplicated and capped at WSSHD_MAX_FAKE_HASHES. + * Mutates 'line' in place. Exposed unconditionally so unit tests can drive + * it with synthetic lines. */ +#ifdef WOLFSSHD_UNIT_TEST +void AddShadowLineToFakeHashCache(char* line) +#else +static void AddShadowLineToFakeHashCache(char* line) +#endif +{ + char tmpl[WSSHD_FAKE_HASH_SZ]; + char* colon1; + char* colon2; + int duplicate; + int i; + + if (numCachedFakeHashes >= WSSHD_MAX_FAKE_HASHES) { + return; + } + + colon1 = WSTRCHR(line, ':'); + if (colon1 != NULL) { + colon2 = WSTRCHR(colon1 + 1, ':'); + if (colon2 != NULL) { + *colon2 = '\0'; + GetFakeHashFromTemplate(colon1 + 1, tmpl, sizeof(tmpl)); + + if (tmpl[0] != '\0' && tmpl[1] != '\0' && tmpl[1] != '*') { + duplicate = 0; + for (i = 0; i < numCachedFakeHashes; i++) { + if (XSTRNCMP(cachedFakeHashes[i], tmpl, sizeof(tmpl)) == 0) { + duplicate = 1; + break; + } + } + if (!duplicate) { + XSTRNCPY(cachedFakeHashes[numCachedFakeHashes], tmpl, + sizeof(cachedFakeHashes[0])); + numCachedFakeHashes++; + } + } + } + } +} + +/* Reads an already-open shadow file line by line into the fake-hash cache. + * Exposed unconditionally so unit tests can drive it with a synthetic + * stream instead of a real /etc/shadow. */ +#ifdef WOLFSSHD_UNIT_TEST +void ScanShadowFile(WFILE* f) +#else +static void ScanShadowFile(WFILE* f) +#endif +{ + char line[WSSHD_SHADOW_LINE_SZ]; + + while (WFGETS(line, sizeof(line), f) != NULL && + numCachedFakeHashes < WSSHD_MAX_FAKE_HASHES) { + /* If the line was truncated (no newline found), consume the remainder */ + if (WSTRCHR(line, '\n') == NULL) { + char dump[WSSHD_SHADOW_DUMP_SZ]; + while (WFGETS(dump, sizeof(dump), f) != NULL) { + if (WSTRCHR(dump, '\n') != NULL) break; + } + WS_FORCEZERO(dump, sizeof(dump)); + } + + AddShadowLineToFakeHashCache(line); + WS_FORCEZERO(line, sizeof(line)); + } +} + +#ifdef WOLFSSHD_UNIT_TEST +/* Test-only hook to seed cachedFakeHash without a real shadow file entry. */ +void wolfSSHD_SetCachedFakeHashForTest(const char* tmpl) +{ + if (tmpl == NULL) { + cachedFakeHashes[0][0] = '\0'; + numCachedFakeHashes = 0; + } + else { + XSTRNCPY(cachedFakeHashes[0], tmpl, sizeof(cachedFakeHashes[0])); + cachedFakeHashes[0][sizeof(cachedFakeHashes[0]) - 1] = '\0'; + numCachedFakeHashes = 1; + } +} + +/* Test-only accessor so tests can verify wolfSSHD_AuthInit() actually + * populated cachedFakeHash from a real shadow entry. */ +void wolfSSHD_GetCachedFakeHashForTest(char* out, word32 outSz) +{ + if (out == NULL || outSz == 0) return; + if (numCachedFakeHashes > 0) { + XSTRNCPY(out, cachedFakeHashes[0], outSz); + out[outSz - 1] = '\0'; + } + else { + out[0] = '\0'; + } +} + +/* Test-only accessor for the number of cached fake hashes, so tests can + * verify AddShadowLineToFakeHashCache()'s dedup and cap behavior. */ +int wolfSSHD_GetCachedFakeHashCountForTest(void) +{ + return numCachedFakeHashes; +} +#endif /* WOLFSSHD_UNIT_TEST */ +#endif /* HAVE_SHADOW */ + +void wolfSSHD_AuthInit(void) +{ +#ifdef HAVE_SHADOW + char tmpl[WSSHD_FAKE_HASH_SZ]; + struct spwd* rootShadow; + +#ifndef WOLFSSHD_UNIT_TEST + WFILE* f = NULL; +#endif + +#ifndef WOLFSSHD_UNIT_TEST + /* /etc/shadow is commonly root:shadow 0640; don't reject group-readable. */ + if (wolfSSHD_OpenSecureFile(WSSHD_SHADOW_FILE, 0 /* ownerUid: root */, + 0 /* rejectReadable */, NULL, &f) == WS_SUCCESS && f != NULL) { + ScanShadowFile(f); + WFCLOSE(NULL, f); + } +#endif + + if (numCachedFakeHashes == 0) { + rootShadow = getspnam("root"); + if (rootShadow != NULL && rootShadow->sp_pwdp != NULL) { + GetFakeHashFromTemplate(rootShadow->sp_pwdp, tmpl, sizeof(tmpl)); + if (tmpl[0] != '\0' && tmpl[1] != '\0' && tmpl[1] != '*') { + XSTRNCPY(cachedFakeHashes[0], tmpl, sizeof(cachedFakeHashes[0])); + numCachedFakeHashes = 1; + } + } + } + + if (numCachedFakeHashes == 0) { + wolfSSH_Log(WS_LOG_ERROR, + "[SSHD] Error getting root password info"); + wolfSSH_Log(WS_LOG_ERROR, + "[SSHD] Possibly permissions level error?" + " i.e SSHD not ran as sudo"); + wolfSSH_Log(WS_LOG_ERROR, + "[SSHD] Timing side-channel mitigation degraded: using a" + " fixed-cost fake hash instead of matching real crypt() cost"); + } +#endif +} + #ifndef _WIN32 #ifdef WOLFSSH_USE_PAM @@ -392,7 +654,20 @@ int CheckPasswordHashUnix(const char* input, const char* stored) static int CheckPasswordHashUnix(const char* input, const char* stored) #endif { + /* Fake salts for locked/empty accounts so crypt() is always invoked. + * fakeHashSHA512 uses rounds=5000 (glibc default); real cost is matched + * via the cached hash from wolfSSHD_AuthInit() on the normal path. + * These constants are only used in degraded mode (empty cache). */ + static const char fakeHashSHA512[] = + "$6$rounds=5000$wolfSSHdFakeSalt$"; + static const char fakeHashMD5[] = "$1$wolfSSHd$UkYLseEmSSXHYyxsWDQC80"; + static const char fakeHashDES[] = "wowolfSSHdUkYLs"; + /* Fallback salts tried in order when a locked-account salt is rejected + * by crypt() (e.g. libc lacks that algorithm). */ + static const char* const fakeHashFallbacks[] = + { fakeHashMD5, fakeHashDES }; int ret = WSSHD_AUTH_SUCCESS; + int locked = 0; char* hashedInput = NULL; word32 hashedInputSz = 0, storedSz = 0; @@ -400,7 +675,8 @@ static int CheckPasswordHashUnix(const char* input, const char* stored) ret = WS_BAD_ARGUMENT; } - /* empty password case */ + /* Fast return for genuine empty passwords. The dummy caller + * never passes an empty stored hash, avoiding a timing oracle. */ if (ret == WSSHD_AUTH_SUCCESS && stored[0] == 0 && WSTRLEN(input) == 0) { wolfSSH_Log(WS_LOG_INFO, "[SSHD] User logged in with empty password"); @@ -408,16 +684,56 @@ static int CheckPasswordHashUnix(const char* input, const char* stored) } if (ret == WSSHD_AUTH_SUCCESS) { - hashedInput = crypt(input, stored); + const char* salt = stored; + + storedSz = (word32)WSTRLEN(stored); + locked = (storedSz == 0 || stored[0] == '*' || stored[0] == '!'); + + if (locked) { + /* Try to reuse the salt from the locked hash, but only if it's + * a real modular crypt salt; otherwise crypt() fails on it + * immediately and skips the cost this mitigation relies on. */ + if (storedSz > 1 && stored[0] == '!' && stored[1] == '$') { + salt = stored + 1; + } +#ifdef HAVE_SHADOW + /* Prefer the cached system hash (populated by AuthInit from + * shadow file) so the work factor matches the real system. */ + else if (numCachedFakeHashes > 0 && + cachedFakeHashes[0][0] == '!' && + cachedFakeHashes[0][1] == '$') { + salt = cachedFakeHashes[0] + 1; + } +#endif + else { + salt = fakeHashSHA512; + } + } + + hashedInput = crypt(input, salt); + /* glibc signals an unsupported salt with a "*"-prefixed sentinel, + * not NULL; check both so the fallback engages on every libc. */ + if (locked) { + word32 fbIdx; + for (fbIdx = 0; + fbIdx < sizeof(fakeHashFallbacks) / sizeof(fakeHashFallbacks[0]) + && (hashedInput == NULL || hashedInput[0] == '*'); + fbIdx++) { + salt = fakeHashFallbacks[fbIdx]; + hashedInput = crypt(input, salt); + } + } + if (hashedInput == NULL) { ret = WS_FATAL_ERROR; } + else if (locked) { + ret = WSSHD_AUTH_FAILURE; + } else { hashedInputSz = (word32)WSTRLEN(hashedInput); - storedSz = (word32)WSTRLEN(stored); - if (storedSz == 0 || stored[0] == '*' || - hashedInputSz == 0 || hashedInput[0] == '*' || + if (hashedInputSz == 0 || hashedInput[0] == '*' || hashedInputSz != storedSz || ConstantCompare((const byte*)hashedInput, (const byte*)stored, storedSz) != 0) { @@ -425,21 +741,27 @@ static int CheckPasswordHashUnix(const char* input, const char* stored) } } } - return ret; } #endif /* WOLFSSH_HAVE_LIBCRYPT || WOLFSSH_HAVE_LIBLOGIN */ +#ifdef WOLFSSHD_UNIT_TEST +int CheckPasswordUnix(const char* usr, const byte* pw, word32 pwSz, WOLFSSHD_AUTH* authCtx) +#else static int CheckPasswordUnix(const char* usr, const byte* pw, word32 pwSz, WOLFSSHD_AUTH* authCtx) +#endif { int ret = WS_SUCCESS; char* pwStr = NULL; struct passwd* pwInfo; #ifdef HAVE_SHADOW struct spwd* shadowInfo; + /* getspnam() returns a static buffer; copy immediately before it can + * be overwritten by any subsequent call. */ + char hashBuf[WSSHD_FAKE_HASH_SZ]; #endif /* The hash of the user's password stored on the system. */ - char* storedHash; + const char* storedHash = "*"; char* storedHashCpy = NULL; /* Allow zero length passwords, but not NULL pointers. */ @@ -463,38 +785,56 @@ static int CheckPasswordUnix(const char* usr, const byte* pw, word32 pwSz, WOLFS if (ret == WS_SUCCESS) { pwInfo = getpwnam((const char*)usr); if (pwInfo == NULL) { - /* user name not found on system */ - ret = WS_FATAL_ERROR; - wolfSSH_Log(WS_LOG_ERROR, + /* User not found: use dummy hash to equalize timing. */ + wolfSSH_Log(WS_LOG_INFO, "[SSHD] User name not found on system"); } - } - - if (ret == WS_SUCCESS) { - #ifdef HAVE_SHADOW - if (pwInfo->pw_passwd[0] == 'x') { - #ifdef WOLFSSH_HAVE_LIBCRYPT - shadowInfo = getspnam((const char*)usr); - #else - shadowInfo = getspnam((char*)usr); - #endif - if (shadowInfo == NULL) { - wolfSSH_Log(WS_LOG_ERROR, - "[SSHD] Error getting user password info"); - wolfSSH_Log(WS_LOG_ERROR, - "[SSHD] Possibly permissions level error?" - " i.e SSHD not ran as sudo"); - ret = WS_FATAL_ERROR; + else { +#ifdef HAVE_SHADOW + if (pwInfo->pw_passwd[0] == 'x') { +#ifdef WOLFSSH_HAVE_LIBCRYPT + shadowInfo = getspnam((const char*)usr); +#else + shadowInfo = getspnam((char*)usr); +#endif + if (shadowInfo == NULL) { + wolfSSH_Log(WS_LOG_ERROR, + "[SSHD] Error getting user password info"); + wolfSSH_Log(WS_LOG_ERROR, + "[SSHD] Possibly permissions level error?" + " i.e SSHD not ran as sudo"); + /* Fail closed: RequestAuthentication's error-branch + * DoFakePasswordCheck() still equalizes timing for this + * case, same as it does for the oversized-hash case. */ + ret = WS_FATAL_ERROR; + } + else if (shadowInfo->sp_pwdp == NULL) { + /* Fail closed: some NSS backends (e.g. NIS/LDAP) can + * return a spwd entry with a NULL sp_pwdp. */ + wolfSSH_Log(WS_LOG_ERROR, + "[SSHD] Shadow entry missing password hash"); + ret = WS_FATAL_ERROR; + } + else if (WSTRLEN(shadowInfo->sp_pwdp) >= WSSHD_FAKE_HASH_SZ) { + /* Fail closed instead of silently truncating the hash. */ + wolfSSH_Log(WS_LOG_ERROR, + "[SSHD] Stored password hash too long for buffer"); + ret = WS_FATAL_ERROR; + } + else { + /* Copy before any subsequent getspnam() call + * overwrites the static buffer. */ + XSTRNCPY(hashBuf, shadowInfo->sp_pwdp, sizeof(hashBuf)); + hashBuf[sizeof(hashBuf) - 1] = '\0'; + storedHash = hashBuf; + } } - else { - storedHash = shadowInfo->sp_pwdp; + else +#endif + { + storedHash = pwInfo->pw_passwd; } } - else - #endif - { - storedHash = pwInfo->pw_passwd; - } } if (ret == WS_SUCCESS) { storedHashCpy = WSTRDUP(storedHash, NULL, DYNTYPE_STRING); @@ -507,6 +847,8 @@ static int CheckPasswordUnix(const char* usr, const byte* pw, word32 pwSz, WOLFS if (ret == WS_SUCCESS) { #if defined(WOLFSSH_HAVE_LIBCRYPT) || defined(WOLFSSH_HAVE_LIBLOGIN) + /* Nonexistent users use "*" hash, so CheckPasswordHashUnix fails. + * DoCheckUser() filters them earlier; this is defense-in-depth. */ ret = CheckPasswordHashUnix(pwStr, storedHashCpy); #else wolfSSH_Log(WS_LOG_ERROR, "[SSHD] No compiled in password check"); @@ -522,6 +864,9 @@ static int CheckPasswordUnix(const char* usr, const byte* pw, word32 pwSz, WOLFS WS_FORCEZERO(storedHashCpy, (word32)WSTRLEN(storedHashCpy) + 1); WFREE(storedHashCpy, NULL, DYNTYPE_STRING); } +#ifdef HAVE_SHADOW + WS_FORCEZERO(hashBuf, sizeof(hashBuf)); +#endif WOLFSSH_UNUSED(authCtx); return ret; @@ -1941,6 +2286,82 @@ WOLFSSHD_STATIC int MatchUPNToUser(const char* usr, const char* name, #endif /* WOLFSSL_FPKI || WOLFSSHD_UNIT_TEST */ +#ifdef WOLFSSHD_UNIT_TEST +/* Test-only spy to assert if DoFakePasswordCheck() ran. */ +static int fakePasswordCheckCallCount = 0; + +void wolfSSHD_ResetFakePasswordCheckCountForTest(void) +{ + fakePasswordCheckCallCount = 0; +} + +int wolfSSHD_GetFakePasswordCheckCountForTest(void) +{ + return fakePasswordCheckCallCount; +} +#endif + +/* Runs a fake crypt() to equalize timing on failure paths that skip the + * real password check. No-op under WOLFSSH_USE_PAM (PAM path needs its + * own mitigation). */ +#ifdef WOLFSSHD_UNIT_TEST +void DoFakePasswordCheck(WS_UserAuthData* authData) +#else +static void DoFakePasswordCheck(WS_UserAuthData* authData) +#endif +{ +#ifdef WOLFSSHD_UNIT_TEST + fakePasswordCheckCallCount++; +#endif +#if defined(WOLFSSH_HAVE_LIBCRYPT) || defined(WOLFSSH_HAVE_LIBLOGIN) + char* fakePwStr = NULL; + const byte* fakePw = NULL; + word32 fakePwSz = 0; + const char* fakeHash = "*"; + + if (authData->type == WOLFSSH_USERAUTH_PASSWORD) { + fakePw = authData->sf.password.password; + fakePwSz = authData->sf.password.passwordSz; + } + +#ifdef HAVE_SHADOW + if (numCachedFakeHashes > 0) { + /* Byte-sum selects which cached hash to use. Deterministic but + * attacker-predictable; impact is marginal on typical systems + * where all accounts share one algorithm. */ + word32 hashIdx = 0; + word32 i; + if (authData->username != NULL) { + for (i = 0; i < authData->usernameSz; i++) { + hashIdx += authData->username[i]; + } + } + fakeHash = cachedFakeHashes[hashIdx % numCachedFakeHashes]; + } +#endif + + fakePwStr = (char*)WMALLOC(fakePwSz + 1, NULL, DYNTYPE_STRING); + if (fakePwStr != NULL) { + if (fakePwSz > 0 && fakePw != NULL) { + XMEMCPY(fakePwStr, fakePw, fakePwSz); + } + fakePwStr[fakePwSz] = 0; + + /* Return value ignored: fake check must not influence auth. */ + CheckPasswordHashUnix(fakePwStr, fakeHash); + + WS_FORCEZERO(fakePwStr, fakePwSz + 1); + WFREE(fakePwStr, NULL, DYNTYPE_STRING); + } + else { + CheckPasswordHashUnix("", fakeHash); + } +#else + WOLFSSH_UNUSED(authData); +#endif +} + + /* * @TODO this will take a pipe or equivalent to talk to a privileged thread * rather than having WOLFSSHD_AUTH directly with privilege separation. @@ -1969,6 +2390,7 @@ static int RequestAuthentication(WS_UserAuthData* authData, int ret; int rc; int isRoot; + int needFakeCheck = 0; const char* usr; WOLFSSHD_CONFIG* usrConf = NULL; @@ -1986,27 +2408,22 @@ static int RequestAuthentication(WS_UserAuthData* authData, isRoot = IsRootUser(usr); ret = DoCheckUser(usr, authCtx, isRoot); + if (ret != WOLFSSH_USERAUTH_SUCCESS) { + needFakeCheck = 1; + } + /* temporarily elevate permissions */ if (ret == WOLFSSH_USERAUTH_SUCCESS && wolfSSHD_AuthRaisePermissions(authCtx) != WS_SUCCESS) { wolfSSH_Log(WS_LOG_ERROR, "[SSHD] Failure to raise permissions for auth"); ret = WOLFSSH_USERAUTH_FAILURE; + needFakeCheck = 1; } - /* Resolve the per-user configuration so that Match block overrides are - * honored. wolfSSHD_AuthGetUserConf defaults to the global config when no - * user-specific node applies, so this matches the existing behavior for - * non-Match users while enforcing Match restrictions. - * - * A NULL return here means the user's configuration could not be resolved - * (e.g. the user's group set could not be enumerated inside - * wolfSSHD_AuthGetUserConf). DoCheckUser has already confirmed the user - * exists, so this is a rare edge. Fail closed rather than fall back to the - * permissive global node: such a user cannot complete a session anyway - * (session setup in wolfsshd.c rejects an unresolvable user config with - * WS_FATAL_ERROR), so denying auth here is safe and avoids evaluating - * password/public-key authorization against the wrong config node. */ + /* Resolve per-user config to honor Match blocks. NULL means the group + * set couldn't be enumerated; fail closed rather than fall back to the + * global node and evaluate auth against the wrong config. */ if (ret == WOLFSSH_USERAUTH_SUCCESS) { usrConf = wolfSSHD_AuthGetUserConf(authCtx, usr, NULL, NULL, NULL, NULL, NULL); @@ -2015,6 +2432,7 @@ static int RequestAuthentication(WS_UserAuthData* authData, "[SSHD] Failure to get user configuration for auth (user=%s)", usr); ret = WOLFSSH_USERAUTH_FAILURE; + needFakeCheck = 1; } } @@ -2038,8 +2456,13 @@ static int RequestAuthentication(WS_UserAuthData* authData, wolfSSH_Log(WS_LOG_ERROR, "[SSHD] Empty passwords not allowed by " "configuration!"); ret = WOLFSSH_USERAUTH_FAILURE; + /* Set explicitly; the trailing else also fires here, but + * this makes the intent clear at the point of rejection. */ + needFakeCheck = 1; } - else { + + /* Only run password check when config allows it (avoids leaks). */ + if (ret == WOLFSSH_USERAUTH_SUCCESS) { rc = authCtx->checkPasswordCb(usr, authData->sf.password.password, authData->sf.password.passwordSz, authCtx); @@ -2060,8 +2483,18 @@ static int RequestAuthentication(WS_UserAuthData* authData, else { wolfSSH_Log(WS_LOG_ERROR, "[SSHD] Error checking password."); ret = WOLFSSH_USERAUTH_FAILURE; + needFakeCheck = 1; } } + else { + needFakeCheck = 1; + } + } + + /* Equalize crypt() cost for password auth across every failure path + * above that never reached the real checkPasswordCb crypt() call. */ + if (needFakeCheck && authData->type == WOLFSSH_USERAUTH_PASSWORD) { + DoFakePasswordCheck(authData); } @@ -2661,12 +3094,20 @@ int wolfSSHD_AuthReducePermissions(WOLFSSHD_AUTH* auth) if (flag == WOLFSSHD_PRIV_SEPARAT || flag == WOLFSSHD_PRIV_SANDBOX) { wolfSSH_Log(WS_LOG_INFO, "[SSHD] Lowering permissions level"); +#ifdef WOLFSSHD_UNIT_TEST + if (wsshd_setegid_cb(auth->gid) != 0) { +#else if (setegid(auth->gid) != 0) { +#endif wolfSSH_Log(WS_LOG_ERROR, "[SSHD] Error setting sshd gid"); ret = WS_FATAL_ERROR; } +#ifdef WOLFSSHD_UNIT_TEST + if (wsshd_seteuid_cb(auth->uid) != 0) { +#else if (seteuid(auth->uid) != 0) { +#endif wolfSSH_Log(WS_LOG_ERROR, "[SSHD] Error setting sshd uid"); ret = WS_FATAL_ERROR; } diff --git a/apps/wolfsshd/auth.h b/apps/wolfsshd/auth.h index 6d67bfb61..86f30156e 100644 --- a/apps/wolfsshd/auth.h +++ b/apps/wolfsshd/auth.h @@ -27,6 +27,12 @@ #define WOLFSSH_USER_GET_STRING(x) #x #define WOLFSSH_USER_STRING(x) WOLFSSH_USER_GET_STRING(x) +/* Mirrors the condition auth.c uses to define its (translation-unit-local) + * HAVE_SHADOW; kept as a single macro here so the two can't drift apart. */ +#if !defined(_WIN32) && !(defined(__OSX__) || defined(__APPLE__)) + #define WOLFSSHD_HAVE_SHADOW +#endif + #if 0 typedef struct USER_NODE USER_NODE; @@ -69,6 +75,7 @@ typedef int (*CallbackCheckPublicKey)(const char* usr, const char* authorizedKeysFile, WOLFSSHD_AUTH* authCtx); +void wolfSSHD_AuthInit(void); WOLFSSHD_AUTH* wolfSSHD_AuthCreateUser(void* heap, const WOLFSSHD_CONFIG* conf); int wolfSSHD_AuthFreeUser(WOLFSSHD_AUTH* auth); int wolfSSHD_AuthReducePermissions(WOLFSSHD_AUTH* auth); @@ -109,6 +116,9 @@ extern int (*wsshd_setegid_cb)(WGID_T); extern int (*wsshd_seteuid_cb)(WUID_T); extern struct passwd* (*wsshd_getpwnam_cb)(const char*); extern int (*wsshd_setgroups_cb)(int, const WGID_T*); +#ifdef WOLFSSHD_HAVE_SHADOW +extern struct spwd* (*wsshd_getspnam_cb)(const char*); +#endif extern int (*wsshd_getgrouplist_cb)(const char*, WGID_T, WGID_T*, int*); int wolfSSHD_GetUserGroupNames(void* heap, const char* usr, WGID_T primaryGid, char*** outNames, word32* outCount); @@ -121,6 +131,31 @@ int SearchForPubKey(const char* path, const char* authKeysFile, #if defined(WOLFSSH_HAVE_LIBCRYPT) || defined(WOLFSSH_HAVE_LIBLOGIN) int CheckPasswordHashUnix(const char* input, const char* stored); #endif +#ifdef WOLFSSHD_HAVE_SHADOW +void GetFakeHashFromTemplate(const char* tmpl, char* out, word32 outSz); +#ifdef WOLFSSHD_UNIT_TEST +/* Pure test hooks under HAVE_SHADOW && WOLFSSHD_UNIT_TEST. */ +void wolfSSHD_SetCachedFakeHashForTest(const char* hash); +void wolfSSHD_GetCachedFakeHashForTest(char* out, word32 outSz); +int wolfSSHD_GetCachedFakeHashCountForTest(void); +/* Parses one "user:hash:..." line into the fake-hash cache. */ +void AddShadowLineToFakeHashCache(char* line); +/* Reads a shadow file stream line by line into the fake-hash cache. */ +void ScanShadowFile(WFILE* f); +#endif +#endif +/* Not shadow-specific in auth.c, so not excluded on OSX/APPLE. */ +#if !defined(_WIN32) && !defined(WOLFSSH_USE_PAM) +/* Returns WSSHD_AUTH_* for auth result, or WS_* for system errors. */ +int CheckPasswordUnix(const char* usr, const byte* pw, word32 pwSz, + WOLFSSHD_AUTH* authCtx); +#endif +#ifdef WOLFSSHD_UNIT_TEST +void DoFakePasswordCheck(WS_UserAuthData* authData); +/* Pure test hooks under WOLFSSHD_UNIT_TEST. */ +void wolfSSHD_ResetFakePasswordCheckCountForTest(void); +int wolfSSHD_GetFakePasswordCheckCountForTest(void); +#endif int CheckAuthKeysLine(char* line, word32 lineSz, const byte* key, word32 keySz); int ResolveAuthKeysPath(const char* homeDir, const char* pattern, diff --git a/apps/wolfsshd/include.am b/apps/wolfsshd/include.am index 60ddf8d0f..c0b4e0b9c 100644 --- a/apps/wolfsshd/include.am +++ b/apps/wolfsshd/include.am @@ -9,7 +9,7 @@ apps_wolfsshd_wolfsshd_SOURCES = apps/wolfsshd/wolfsshd.c \ apps_wolfsshd_wolfsshd_LDADD = src/libwolfssh.la apps_wolfsshd_wolfsshd_DEPENDENCIES = src/libwolfssh.la -noinst_PROGRAMS += apps/wolfsshd/test/test_configuration +check_PROGRAMS += apps/wolfsshd/test/test_configuration apps_wolfsshd_test_test_configuration_SOURCES = apps/wolfsshd/test/test_configuration.c \ apps/wolfsshd/configuration.c \ apps/wolfsshd/configuration.h \ diff --git a/apps/wolfsshd/test/test_configuration.c b/apps/wolfsshd/test/test_configuration.c index 8d35c20f5..f67cf85b1 100644 --- a/apps/wolfsshd/test/test_configuration.c +++ b/apps/wolfsshd/test/test_configuration.c @@ -21,6 +21,9 @@ #include #include #endif +#if !defined(_WIN32) && !(defined(__OSX__) || defined(__APPLE__)) + #include +#endif #include #include @@ -1329,9 +1332,11 @@ static int test_ConfigFree(void) } #if defined(WOLFSSH_HAVE_LIBCRYPT) || defined(WOLFSSH_HAVE_LIBLOGIN) -/* Negative-path coverage for CheckPasswordHashUnix so mutation of the - * ConstantCompare clause (the only substantive check once crypt() has - * produced its fixed-length output) does not survive the test suite. */ +/* Negative-path coverage for CheckPasswordHashUnix to prevent mutation of + * the ConstantCompare clause. + * + * fakeHashMD5/fakeHashDES fallback is not covered because glibc crypt() + * returns "*0" rather than NULL, making the branch unreachable here. */ static int test_CheckPasswordHashUnix(void) { int ret = WS_SUCCESS; @@ -1451,11 +1456,11 @@ static int test_CheckPasswordHashUnix(void) if (ret == WS_SUCCESS) { char lockedWithSalt[130]; - /* A locked ('!') account with a valid hash must still fail auth, even if salt-reuse lets crypt() match. */ + /* A locked account starting with '!' exercises salt-reuse instead of + * fake hash fallback. It must fail auth even with a correct password. */ lockedWithSalt[0] = '!'; WMEMCPY(lockedWithSalt + 1, stored, WSTRLEN(stored) + 1); - /* Same NULL-tolerant reasoning as the empty-salt case above. */ Log(" Locked account with reusable '!' salt: "); rc = CheckPasswordHashUnix(correct, lockedWithSalt); if (rc == WSSHD_AUTH_FAILURE || rc == WS_FATAL_ERROR) { @@ -1467,6 +1472,7 @@ static int test_CheckPasswordHashUnix(void) } } + return ret; } @@ -1535,277 +1541,1047 @@ static int test_DefaultUserAuth_OOBRead(void) return ret; } -#endif /* WOLFSSH_HAVE_LIBCRYPT || WOLFSSH_HAVE_LIBLOGIN */ - -#ifdef WOLFSSL_BASE64_ENCODE -/* Build a mutable "ssh-rsa " line; WSTRTOK mutates in place. */ -static int BuildAuthKeysLine(const byte* key, word32 keySz, - char* lineOut, word32 lineOutSz) -{ - static const char prefix[] = "ssh-rsa "; - word32 prefixLen = (word32)(sizeof(prefix) - 1); - word32 b64Sz; - - if (lineOutSz <= prefixLen) { - return WS_BUFFER_E; - } - WMEMCPY(lineOut, prefix, prefixLen); - b64Sz = lineOutSz - prefixLen; - if (Base64_Encode_NoNl(key, keySz, (byte*)lineOut + prefixLen, &b64Sz) - != 0) { - return WS_FATAL_ERROR; - } - /* Base64_Encode_NoNl does not null-terminate; do it ourselves. */ - if (prefixLen + b64Sz >= lineOutSz) { - return WS_BUFFER_E; - } - lineOut[prefixLen + b64Sz] = '\0'; - return WS_SUCCESS; -} -/* Negative-path coverage for CheckAuthKeysLine's ConstantCompare clause. */ -static int test_CheckAuthKeysLine(void) +#ifdef WOLFSSHD_HAVE_SHADOW +/* Coverage for GetFakeHashFromTemplate's format-specific branches: bcrypt's + * fixed-width copy, the dollar-counting split used by SHA/MD5/yescrypt, and + * the buffer-bound fallbacks that must not overflow `out`. */ +static int test_GetFakeHashFromTemplate(void) { int ret = WS_SUCCESS; - /* keyALastByte differs from keyA only in the final byte, killing a - * dropped-ConstantCompare mutation that the length check alone would miss. */ - static const char keyAStr[] = "wolfssh-auth-key-test-A-AAAAAAA"; - static const char keyBStr[] = "wolfssh-auth-key-test-B-BBBBBBB"; - const byte* keyA = (const byte*)keyAStr; - const byte* keyB = (const byte*)keyBStr; - const word32 keySz = (word32)(sizeof(keyAStr) - 1); - byte keyALastByte[sizeof(keyAStr) - 1]; - char line[256]; - char lineCopy[320]; /* fits the longer unsupported-type scenario line */ - int rc; - - WMEMCPY(keyALastByte, keyA, keySz); - keyALastByte[keySz - 1] ^= 0x01; - - ret = BuildAuthKeysLine(keyA, keySz, line, sizeof(line)); - if (ret != WS_SUCCESS) { - return ret; - } - - Log(" Testing scenario: matching key authenticates."); - WMEMCPY(lineCopy, line, WSTRLEN(line) + 1); - rc = CheckAuthKeysLine(lineCopy, (word32)WSTRLEN(lineCopy), - keyA, keySz); - if (rc == WSSHD_AUTH_SUCCESS) { + char out[256]; + + Log(" Non modular-crypt-format template falls back to '!' alone: "); + WMEMSET(out, 0, sizeof(out)); + GetFakeHashFromTemplate("plaintextnotahash", out, sizeof(out)); + /* Must be "!" alone, not "!*": CheckPasswordHashUnix only reuses the + * stored salt when storedSz > 1, so a bare "!" correctly falls through + * to the fixed-cost fakeHashSHA512 salt instead of reusing a "*" that + * fails crypt() immediately and skips that cost. */ + if (out[0] == '!' && out[1] == '\0') { Log(" PASSED.\n"); } else { - Log(" FAILED (rc=%d).\n", rc); + Log(" FAILED.\n"); ret = WS_FATAL_ERROR; } if (ret == WS_SUCCESS) { - Log(" Testing scenario: different same-length key is rejected."); - WMEMCPY(lineCopy, line, WSTRLEN(line) + 1); - rc = CheckAuthKeysLine(lineCopy, (word32)WSTRLEN(lineCopy), - keyB, keySz); - if (rc == WSSHD_AUTH_FAILURE) { + /* bcrypt: prefix+cost (7) + salt (22) copied, never the 31-byte + * digest that follows. */ + const char* bcryptTmpl = + "$2b$12$abcdefghijklmnopqrstuvXYZZYXWVUTSRQPONMLKJIHGFEDCBA"; + + Log(" bcrypt template copies only prefix+salt, not digest: "); + WMEMSET(out, 0, sizeof(out)); + GetFakeHashFromTemplate(bcryptTmpl, out, sizeof(out)); + if (out[0] == '!' && WSTRLEN(out) == 1 + 7 + 22 && + WSTRNCMP(out + 1, bcryptTmpl, 4) == 0 && + WSTRSTR(out, "XYZZYXWVUTSRQPONMLKJIHGFEDCBA") == NULL && + WSTRSTR(out, "abcdefghijklmnopqrstuv") == NULL) { Log(" PASSED.\n"); } else { - Log(" FAILED (rc=%d).\n", rc); + Log(" FAILED.\n"); ret = WS_FATAL_ERROR; } } if (ret == WS_SUCCESS) { - Log(" Testing scenario: same-length key differing in last byte is " - "rejected."); - WMEMCPY(lineCopy, line, WSTRLEN(line) + 1); - rc = CheckAuthKeysLine(lineCopy, (word32)WSTRLEN(lineCopy), - keyALastByte, keySz); - if (rc == WSSHD_AUTH_FAILURE) { + /* Legacy "$2$NN$" bcrypt has a 6-byte prefix, one shorter than + * "$2b$NN$"'s 7. */ + const char* bcryptBareTmpl = + "$2$12$abcdefghijklmnopqrstuvXYZZYXWVUTSRQPONMLKJIHGFEDCBA"; + + Log(" Bare \"$2$NN$\" bcrypt template copies only prefix+salt, " + "not digest: "); + WMEMSET(out, 0, sizeof(out)); + GetFakeHashFromTemplate(bcryptBareTmpl, out, sizeof(out)); + if (out[0] == '!' && WSTRLEN(out) == 1 + 6 + 22 && + WSTRNCMP(out + 1, bcryptBareTmpl, 3) == 0 && + WSTRSTR(out, "XYZZYXWVUTSRQPONMLKJIHGFEDCBA") == NULL) { Log(" PASSED.\n"); } else { - Log(" FAILED (rc=%d).\n", rc); + Log(" FAILED.\n"); ret = WS_FATAL_ERROR; } } if (ret == WS_SUCCESS) { - /* An unsupported key type must be skipped (WSSHD_AUTH_FAILURE), not - * treated as a hard error, so SearchKeysFile keeps scanning later - * TrustedUserCAKeys entries. */ - Log(" Testing scenario: unsupported key type is skipped."); - WSNPRINTF(lineCopy, sizeof(lineCopy), "sk-ssh-ed25519@openssh.com %s", - line + WSTRLEN("ssh-rsa ")); - rc = CheckAuthKeysLine(lineCopy, (word32)WSTRLEN(lineCopy), - keyA, keySz); - if (rc == WSSHD_AUTH_FAILURE) { + const char* bcryptTruncated = "$2a$10$tooshort"; + + Log(" bcrypt template shorter than salt length is not overrun: "); + WMEMSET(out, 0, sizeof(out)); + GetFakeHashFromTemplate(bcryptTruncated, out, sizeof(out)); + if (out[0] == '!' && + WSTRLEN(out) == 1 + WSTRLEN(bcryptTruncated)) { Log(" PASSED.\n"); } else { - Log(" FAILED (rc=%d).\n", rc); + Log(" FAILED.\n"); ret = WS_FATAL_ERROR; } } - return ret; -} - -#ifndef _WIN32 -/* Drive SearchForPubKey against a temp authorized_keys file to cover the - * "no line matched -> WSSHD_AUTH_FAILURE" gate: the authorized key kills a - * condition inversion, the unauthorized key kills a deletion of the gate. */ -static int test_SearchForPubKey(void) -{ - int ret = WS_SUCCESS; - static const char keyAStr[] = "wolfssh-auth-key-test-A-AAAAAAA"; - static const char keyBStr[] = "wolfssh-auth-key-test-B-BBBBBBB"; - const byte* keyA = (const byte*)keyAStr; - const byte* keyB = (const byte*)keyBStr; - const word32 keySz = (word32)(sizeof(keyAStr) - 1); - char base[] = "/tmp/wolfsshd_pkXXXXXX"; - char keysPath[64] = ""; - char missPath[64] = ""; - char line[256]; - WS_UserAuthData_PublicKey pubKeyCtx; - WUID_T uid = getuid(); - FILE* f = NULL; - int rc; - - if (mkdtemp(base) == NULL) { - Log(" mkdtemp failed.\n"); - ret = WS_FATAL_ERROR; - } - if (ret == WS_SUCCESS) { - snprintf(keysPath, sizeof(keysPath), "%s/authorized_keys", base); - snprintf(missPath, sizeof(missPath), "%s/absent_keys", base); - ret = BuildAuthKeysLine(keyA, keySz, line, sizeof(line)); - } + /* $6$rounds=5000$salt$digest -- prevDollarIdx sits just before the + * real salt, so the copied prefix is "$6$rounds=5000$"; both the + * real salt and digest are dropped and replaced by a fixed dummy + * salt, keeping only the algorithm id and rounds cost. */ + const char* sha512Tmpl = + "$6$rounds=5000$realsaltvalue$realdigestshouldnotappearhere"; - if (ret == WS_SUCCESS) { - f = fopen(keysPath, "w"); - if (f == NULL) { - Log(" fopen of authorized_keys failed.\n"); - ret = WS_FATAL_ERROR; + Log(" SHA-512 template drops real salt and digest, keeps " + "prefix+rounds: "); + WMEMSET(out, 0, sizeof(out)); + GetFakeHashFromTemplate(sha512Tmpl, out, sizeof(out)); + if (WSTRCMP(out, "!$6$rounds=5000$wolfSSHFakeSalt$") == 0 && + WSTRSTR(out, "realsaltvalue") == NULL && + WSTRSTR(out, "realdigestshouldnotappearhere") == NULL) { + Log(" PASSED.\n"); } else { - fputs(line, f); - fputs("\n", f); - fclose(f); + Log(" FAILED.\n"); + ret = WS_FATAL_ERROR; } } - /* Force 0600 so the StrictModes secure-open is not tripped by a permissive - * umask leaving the file group or world writable. */ - if (ret == WS_SUCCESS && chmod(keysPath, S_IRUSR | S_IWUSR) != 0) { - Log(" chmod of authorized_keys failed.\n"); - ret = WS_FATAL_ERROR; - } - - WMEMSET(&pubKeyCtx, 0, sizeof(pubKeyCtx)); - pubKeyCtx.publicKeySz = keySz; - - /* StrictModes disabled so the check stays hermetic (no ownership gate). */ if (ret == WS_SUCCESS) { - Log(" Testing scenario: authorized key is accepted."); - pubKeyCtx.publicKey = keyA; - rc = SearchForPubKey(base, keysPath, "testuser", &pubKeyCtx, uid, 0); - if (rc == WSSHD_AUTH_SUCCESS) { + /* Only a single '$' in the whole template: dollarCount < 3, so this + * must fall back to '!*' rather than reading past the template. */ + const char* singleDollar = "$onlyonedollar"; + + Log(" Template with fewer than 3 salt dollars falls back: "); + WMEMSET(out, 0, sizeof(out)); + GetFakeHashFromTemplate(singleDollar, out, sizeof(out)); + if (out[0] == '!' && WSTRCMP(out + 1, "*") == 0) { Log(" PASSED.\n"); } else { - Log(" FAILED (rc=%d).\n", rc); + Log(" FAILED.\n"); ret = WS_FATAL_ERROR; } } - /* The temp file is user-owned with safe perms, so the StrictModes - * secure-open branch must also accept the authorized key. */ if (ret == WS_SUCCESS) { - Log(" Testing scenario: authorized key accepted under StrictModes."); - pubKeyCtx.publicKey = keyA; - rc = SearchForPubKey(base, keysPath, "testuser", &pubKeyCtx, uid, 1); - if (rc == WSSHD_AUTH_SUCCESS) { + /* A tiny output buffer must not be overrun regardless of template + * shape or which branch is taken. */ + char tiny[3]; + const char* sha512Tmpl = + "$6$rounds=5000$realsaltvalue$realdigestshouldnotappearhere"; + + Log(" Undersized output buffer is not overrun: "); + WMEMSET(tiny, 0, sizeof(tiny)); + GetFakeHashFromTemplate(sha512Tmpl, tiny, sizeof(tiny)); + if (tiny[0] == '!' && WSTRLEN(tiny) < sizeof(tiny)) { Log(" PASSED.\n"); } else { - Log(" FAILED (rc=%d).\n", rc); + Log(" FAILED.\n"); ret = WS_FATAL_ERROR; } } if (ret == WS_SUCCESS) { - Log(" Testing scenario: unauthorized key is rejected."); - pubKeyCtx.publicKey = keyB; - rc = SearchForPubKey(base, keysPath, "testuser", &pubKeyCtx, uid, 0); - if (rc == WSSHD_AUTH_FAILURE) { + Log(" NULL template/out and outSz < 3 are rejected without a crash: "); + GetFakeHashFromTemplate(NULL, out, sizeof(out)); + GetFakeHashFromTemplate("$6$a$b$c", NULL, sizeof(out)); + WMEMSET(out, 0, sizeof(out)); + GetFakeHashFromTemplate("$6$a$b$c", out, 2); + if (out[0] == '\0') { Log(" PASSED.\n"); } else { - Log(" FAILED (rc=%d).\n", rc); + Log(" FAILED.\n"); ret = WS_FATAL_ERROR; } } - /* A missing authorized_keys file is an error, not a silent accept. */ - if (ret == WS_SUCCESS) { - Log(" Testing scenario: missing keys file returns an error."); - pubKeyCtx.publicKey = keyA; - rc = SearchForPubKey(base, missPath, "testuser", &pubKeyCtx, uid, 0); - if (rc < 0) { + return ret; +} +#endif /* WOLFSSHD_HAVE_SHADOW */ + + +#ifdef WOLFSSHD_HAVE_SHADOW +/* wolfSSHD_AuthInit() (which populates cachedFakeHash) is never called by + * this suite, so seed it directly to exercise DoFakePasswordCheck(), which + * RequestAuthentication calls after DoCheckUser rejects the nonexistent + * user (CheckPasswordUnix is never reached for this username). */ +static int test_CachedFakeHashConsumption(void) +{ + int ret = WS_SUCCESS; + WOLFSSHD_CONFIG* conf; + WOLFSSHD_AUTH* authCtx; + WS_UserAuthData authData; + char* passwordHeap; + word32 passwordSz = 8; + int rc; + static const char line1[] = "UsePrivilegeSeparation no"; + + conf = wolfSSHD_ConfigNew(NULL); + if (conf == NULL) return WS_MEMORY_E; + + /* Privilege separation is irrelevant to this test's scenario and its + * real raise/reduce syscalls require resolving an actual "sshd" + * account; disable it so the test doesn't depend on host state and + * doesn't leave the test process's privileges altered for later + * tests. */ + if (ParseConfigLine(&conf, line1, (int)WSTRLEN(line1), 0) != WS_SUCCESS) { + wolfSSHD_ConfigFree(conf); + return WS_FATAL_ERROR; + } + + authCtx = wolfSSHD_AuthCreateUser(NULL, conf); + if (authCtx == NULL) { + wolfSSHD_ConfigFree(conf); + Log(" Skipping test: wolfSSHD_AuthCreateUser failed (likely missing 'sshd' user).\n"); + return WS_SUCCESS; + } + + wolfSSHD_SetCachedFakeHashForTest("!$6$wolfsshtestsalt$wolfSSHFakeSalt$"); + + passwordHeap = (char*)WMALLOC(passwordSz, NULL, DYNTYPE_STRING); + if (passwordHeap != NULL) { + WMEMCPY(passwordHeap, "guessme", passwordSz); + + WMEMSET(&authData, 0, sizeof(authData)); + authData.type = WOLFSSH_USERAUTH_PASSWORD; + authData.username = (const byte*)"nonexistent_test_user_xyz"; + authData.usernameSz = (word32)WSTRLEN((const char*)authData.username); + authData.sf.password.password = (const byte*)passwordHeap; + authData.sf.password.passwordSz = passwordSz; + + Log(" Testing scenario: nonexistent user checked against seeded " + "cachedFakeHash."); + rc = DefaultUserAuth(WOLFSSH_USERAUTH_PASSWORD, &authData, authCtx); + if (rc == WOLFSSH_USERAUTH_FAILURE || rc == WOLFSSH_USERAUTH_REJECTED || + rc == WOLFSSH_USERAUTH_INVALID_PASSWORD || + rc == WOLFSSH_USERAUTH_INVALID_USER) { Log(" PASSED.\n"); } else { - Log(" FAILED (rc=%d).\n", rc); + Log(" FAILED.\n"); ret = WS_FATAL_ERROR; } - } - if (keysPath[0] != '\0') { - unlink(keysPath); + WFREE(passwordHeap, NULL, DYNTYPE_STRING); + } + else { + ret = WS_MEMORY_E; } - rmdir(base); + + wolfSSHD_SetCachedFakeHashForTest(NULL); + wolfSSHD_AuthFreeUser(authCtx); + wolfSSHD_ConfigFree(conf); return ret; } -#endif /* !_WIN32 */ -#endif /* WOLFSSL_BASE64_ENCODE */ -#ifndef _WIN32 -static WGID_T s_setregid_arg0, s_setregid_arg1; -static WUID_T s_setreuid_arg0, s_setreuid_arg1; -static int s_setregid_ret; -static int s_setreuid_ret; -static int s_setregid_called; -static int s_setreuid_called; +/* Synthetic account used to drive CheckPasswordUnix's shadow-lookup + * branches without depending on the host's real shadow file contents. */ +static struct passwd stub_shadow_test_pw; +static struct spwd stub_shadow_test_sp; +static char stub_shadow_test_hash[300]; -static int stub_setregid(WGID_T rgid, WGID_T egid) +static struct passwd* stub_getpwnam_shadowUser(const char* name) { - s_setregid_called = 1; - s_setregid_arg0 = rgid; - s_setregid_arg1 = egid; - return s_setregid_ret; + if (name != NULL && WSTRCMP(name, "shadow_branch_test_user") == 0) { + WMEMSET(&stub_shadow_test_pw, 0, sizeof(stub_shadow_test_pw)); + stub_shadow_test_pw.pw_name = (char*)"shadow_branch_test_user"; + stub_shadow_test_pw.pw_uid = 1002; + stub_shadow_test_pw.pw_gid = 1002; + stub_shadow_test_pw.pw_passwd = (char*)"x"; + return &stub_shadow_test_pw; + } + return NULL; } -static int stub_setreuid(WUID_T ruid, WUID_T euid) +static struct passwd* stub_getpwnam_null(const char* name) { - s_setreuid_called = 1; - s_setreuid_arg0 = ruid; - s_setreuid_arg1 = euid; - return s_setreuid_ret; + (void)name; + return NULL; } -static void InstallPrivDropStubs(int regidRet, int reuidRet, - int (**savedRegid)(WGID_T, WGID_T), - int (**savedReuid)(WUID_T, WUID_T)) +static struct spwd* stub_getspnam_null(const char* name) { - *savedRegid = wsshd_setregid_cb; - *savedReuid = wsshd_setreuid_cb; - wsshd_setregid_cb = stub_setregid; - wsshd_setreuid_cb = stub_setreuid; - s_setregid_ret = regidRet; - s_setreuid_ret = reuidRet; - s_setregid_called = 0; - s_setreuid_called = 0; + (void)name; + return NULL; +} + +static struct spwd* stub_getspnam_oversizedHash(const char* name) +{ + (void)name; + WMEMSET(&stub_shadow_test_sp, 0, sizeof(stub_shadow_test_sp)); + stub_shadow_test_sp.sp_namp = (char*)"shadow_branch_test_user"; + /* hashBuf in CheckPasswordUnix is 256 bytes; this exceeds it. */ + WMEMSET(stub_shadow_test_hash, 'A', sizeof(stub_shadow_test_hash) - 1); + stub_shadow_test_hash[sizeof(stub_shadow_test_hash) - 1] = '\0'; + stub_shadow_test_sp.sp_pwdp = stub_shadow_test_hash; + return &stub_shadow_test_sp; +} + +/* getspnam() entry exists (e.g. NIS/LDAP-backed root account) but has no + * password field populated. */ +static struct spwd* stub_getspnam_nullPassword(const char* name) +{ + (void)name; + WMEMSET(&stub_shadow_test_sp, 0, sizeof(stub_shadow_test_sp)); + stub_shadow_test_sp.sp_namp = (char*)"root"; + stub_shadow_test_sp.sp_pwdp = NULL; + return &stub_shadow_test_sp; +} + +/* Unknown user must fall through to the "*" stored hash and fail via + * CheckPasswordHashUnix, not crash or succeed. */ +static int test_CheckPasswordUnix_unknownUser(void) +{ + int ret = WS_SUCCESS; +#if defined(WOLFSSH_HAVE_LIBCRYPT) || defined(WOLFSSH_HAVE_LIBLOGIN) + int rc; + struct passwd* (*savedGetpwnam)(const char*); + static const byte pw[] = "guessme"; + + savedGetpwnam = wsshd_getpwnam_cb; + wsshd_getpwnam_cb = stub_getpwnam_null; + + rc = CheckPasswordUnix("nonexistent_test_user_xyz", pw, + (word32)(sizeof(pw) - 1), NULL); + if (rc != WSSHD_AUTH_FAILURE) { + Log(" FAILED: expected WSSHD_AUTH_FAILURE for unknown user, " + "got %d.\n", rc); + ret = WS_FATAL_ERROR; + } + + wsshd_getpwnam_cb = savedGetpwnam; +#else + (void)stub_getpwnam_null; + Log(" Skipping test: password hash checking not compiled in.\n"); +#endif + return ret; +} + +/* getspnam() failing (e.g. SSHD not run as root) must fail closed rather + * than silently falling through to compare against the "*" default hash. */ +static int test_CheckPasswordUnix_shadowLookupFails(void) +{ + int ret = WS_SUCCESS; + int rc; + struct passwd* (*savedGetpwnam)(const char*); + struct spwd* (*savedGetspnam)(const char*); + static const byte pw[] = "guessme"; + + savedGetpwnam = wsshd_getpwnam_cb; + savedGetspnam = wsshd_getspnam_cb; + wsshd_getpwnam_cb = stub_getpwnam_shadowUser; + wsshd_getspnam_cb = stub_getspnam_null; + + rc = CheckPasswordUnix("shadow_branch_test_user", pw, + (word32)(sizeof(pw) - 1), NULL); + if (rc != WS_FATAL_ERROR) { + Log(" FAILED: expected WS_FATAL_ERROR when getspnam() fails.\n"); + ret = WS_FATAL_ERROR; + } + + wsshd_getpwnam_cb = savedGetpwnam; + wsshd_getspnam_cb = savedGetspnam; + return ret; +} + +/* A shadow hash too long for CheckPasswordUnix's fixed hashBuf must fail + * closed instead of being silently truncated. */ +static int test_CheckPasswordUnix_shadowHashTooLong(void) +{ + int ret = WS_SUCCESS; + int rc; + struct passwd* (*savedGetpwnam)(const char*); + struct spwd* (*savedGetspnam)(const char*); + static const byte pw[] = "guessme"; + + savedGetpwnam = wsshd_getpwnam_cb; + savedGetspnam = wsshd_getspnam_cb; + wsshd_getpwnam_cb = stub_getpwnam_shadowUser; + wsshd_getspnam_cb = stub_getspnam_oversizedHash; + + rc = CheckPasswordUnix("shadow_branch_test_user", pw, + (word32)(sizeof(pw) - 1), NULL); + if (rc != WS_FATAL_ERROR) { + Log(" FAILED: expected WS_FATAL_ERROR for oversized shadow hash.\n"); + ret = WS_FATAL_ERROR; + } + + wsshd_getpwnam_cb = savedGetpwnam; + wsshd_getspnam_cb = savedGetspnam; + return ret; +} + +/* A shadow entry with a NULL sp_pwdp (e.g. an NIS/LDAP-backed account) must + * fail closed instead of crashing on a NULL dereference in WSTRLEN(). */ +static int test_CheckPasswordUnix_shadowNullPassword(void) +{ + int ret = WS_SUCCESS; + int rc; + struct passwd* (*savedGetpwnam)(const char*); + struct spwd* (*savedGetspnam)(const char*); + static const byte pw[] = "guessme"; + + savedGetpwnam = wsshd_getpwnam_cb; + savedGetspnam = wsshd_getspnam_cb; + wsshd_getpwnam_cb = stub_getpwnam_shadowUser; + wsshd_getspnam_cb = stub_getspnam_nullPassword; + + rc = CheckPasswordUnix("shadow_branch_test_user", pw, + (word32)(sizeof(pw) - 1), NULL); + if (rc != WS_FATAL_ERROR) { + Log(" FAILED: expected WS_FATAL_ERROR for a shadow entry with a " + "NULL password field, got %d.\n", rc); + ret = WS_FATAL_ERROR; + } + + wsshd_getpwnam_cb = savedGetpwnam; + wsshd_getspnam_cb = savedGetspnam; + return ret; +} + +static struct spwd* stub_getspnam_validHash(const char* name) +{ + (void)name; + WMEMSET(&stub_shadow_test_sp, 0, sizeof(stub_shadow_test_sp)); + stub_shadow_test_sp.sp_namp = (char*)"shadow_branch_test_user"; + stub_shadow_test_sp.sp_pwdp = stub_shadow_test_hash; + return &stub_shadow_test_sp; +} + +/* Copy-then-succeed path: a normal-length shadow hash copied into + * CheckPasswordUnix's hashBuf, then compared for real. */ +static int test_CheckPasswordUnix_shadowLookupSucceeds(void) +{ + int ret = WS_SUCCESS; +#if defined(WOLFSSH_HAVE_LIBCRYPT) || defined(WOLFSSH_HAVE_LIBLOGIN) + int rc; + struct passwd* (*savedGetpwnam)(const char*); + struct spwd* (*savedGetspnam)(const char*); + static const byte correctPw[] = "guessme"; + static const byte wrongPw[] = "wrongpw"; + /* SHA-512 crypt salt; portable across glibc-based crypt() impls. */ + const char* salt = "$6$wolfsshtestsalt$"; + char* hash; + + hash = crypt((const char*)correctPw, salt); + /* See test_CheckPasswordHashUnix: some libc (macOS/BSD) ignore the + * modular salt and fall back to legacy DES, so skip there. */ + if (hash == NULL || hash[0] == '*' || WSTRLEN(hash) == 0 || + WSTRNCMP(hash, "$6$", 3) != 0) { + Log(" crypt() did not honor $6$ SHA-512, skipping.\n"); + return WS_SUCCESS; + } + if (WSTRLEN(hash) >= sizeof(stub_shadow_test_hash)) { + return WS_FATAL_ERROR; + } + WMEMCPY(stub_shadow_test_hash, hash, WSTRLEN(hash) + 1); + + savedGetpwnam = wsshd_getpwnam_cb; + savedGetspnam = wsshd_getspnam_cb; + wsshd_getpwnam_cb = stub_getpwnam_shadowUser; + wsshd_getspnam_cb = stub_getspnam_validHash; + + Log(" Testing scenario: correct password against copied shadow hash."); + rc = CheckPasswordUnix("shadow_branch_test_user", correctPw, + (word32)(sizeof(correctPw) - 1), NULL); + if (rc == WSSHD_AUTH_SUCCESS) { + Log(" PASSED.\n"); + } + else { + Log(" FAILED.\n"); + ret = WS_FATAL_ERROR; + } + + if (ret == WS_SUCCESS) { + Log(" Testing scenario: wrong password against copied shadow hash."); + rc = CheckPasswordUnix("shadow_branch_test_user", wrongPw, + (word32)(sizeof(wrongPw) - 1), NULL); + if (rc == WSSHD_AUTH_FAILURE) { + Log(" PASSED.\n"); + } + else { + Log(" FAILED.\n"); + ret = WS_FATAL_ERROR; + } + } + + wsshd_getpwnam_cb = savedGetpwnam; + wsshd_getspnam_cb = savedGetspnam; +#else + Log(" Skipping test: password hash checking not compiled in.\n"); +#endif + return ret; +} + +/* authData->sf is a union; for a non-password auth type DoFakePasswordCheck + * must not read the password/passwordSz members at all. Poison those exact + * bytes (invalid pointer, huge length) via the union before tagging the type + * as PUBLICKEY, so a regression that reinstates an unconditional read would + * dereference an invalid pointer here instead of quietly working by luck. */ +static int test_DoFakePasswordCheck_pubkeyUnionSafety(void) +{ + WS_UserAuthData authData; + + WMEMSET(&authData, 0, sizeof(authData)); + authData.sf.password.password = (const byte*)(size_t)1; + authData.sf.password.passwordSz = 0xFFFFFFFFU; + authData.type = WOLFSSH_USERAUTH_PUBLICKEY; + + Log(" Testing scenario: DoFakePasswordCheck with PUBLICKEY type and " + "poisoned password union fields."); + DoFakePasswordCheck(&authData); + Log(" PASSED.\n"); + + return WS_SUCCESS; +} + +/* Exercises wolfSSHD_AuthInit() itself, rather than just seeding + * cachedFakeHash through the test hook, to catch regressions in its + * getspnam("root")/sp_pwdp wiring (wrong struct field, wrong sizeof, etc). + * Requires read access to the shadow file; skips gracefully otherwise. */ +static int test_AuthInit(void) +{ + int ret = WS_SUCCESS; + struct spwd* rootShadow; + struct spwd* (*savedGetspnam)(const char*); + char expected[256]; + char actual[256]; + + wolfSSHD_SetCachedFakeHashForTest(NULL); + + /* Force the real getspnam(), independent of what earlier tests left + * this global as. */ + savedGetspnam = wsshd_getspnam_cb; + wsshd_getspnam_cb = getspnam; + + rootShadow = getspnam("root"); + if (rootShadow == NULL || rootShadow->sp_pwdp == NULL) { + Log(" Skipping test: no read access to the shadow file " + "(likely not running as root).\n"); + wsshd_getspnam_cb = savedGetspnam; + return WS_SUCCESS; + } + + WMEMSET(expected, 0, sizeof(expected)); + GetFakeHashFromTemplate(rootShadow->sp_pwdp, expected, sizeof(expected)); + + /* If the hash is not a valid modular crypt format (e.g. locked '!' or '*'), + * AuthInit intentionally skips it. Reflect that in expected. */ + if (expected[0] == '\0' || expected[1] == '\0' || expected[1] == '*') { + expected[0] = '\0'; + } + + wolfSSHD_AuthInit(); + + WMEMSET(actual, 0, sizeof(actual)); + wolfSSHD_GetCachedFakeHashForTest(actual, sizeof(actual)); + + if (WSTRNCMP(expected, actual, sizeof(expected)) != 0) { + printf("EXPECTED: %s\nACTUAL: %s\n", expected, actual); + Log(" FAILED: wolfSSHD_AuthInit() did not populate " + "cachedFakeHash as expected.\n"); + ret = WS_FATAL_ERROR; + } + + wolfSSHD_SetCachedFakeHashForTest(NULL); + wsshd_getspnam_cb = savedGetspnam; + + return ret; +} + +/* test_AuthInit only covers the getspnam("root") success path and skips + * when not root. Stub getspnam() to force the degraded-mode branch + * regardless of process privileges. */ +static int test_AuthInit_degradedMode(void) +{ + int ret = WS_SUCCESS; + struct spwd* (*savedGetspnam)(const char*); + char actual[256]; + + savedGetspnam = wsshd_getspnam_cb; + wsshd_getspnam_cb = stub_getspnam_null; + + /* Cache must be empty so AuthInit() reaches the getspnam() fallback. */ + wolfSSHD_SetCachedFakeHashForTest(NULL); + + Log(" Testing scenario: wolfSSHD_AuthInit() with getspnam() " + "failing."); + wolfSSHD_AuthInit(); + + /* Degraded mode must leave cachedFakeHash empty. */ + WMEMSET(actual, 0, sizeof(actual)); + wolfSSHD_GetCachedFakeHashForTest(actual, sizeof(actual)); + if (actual[0] != '\0') { + Log(" FAILED: cachedFakeHash was populated on getspnam() " + "failure.\n"); + ret = WS_FATAL_ERROR; + } + else { + Log(" PASSED.\n"); + } + + wolfSSHD_SetCachedFakeHashForTest(NULL); + wsshd_getspnam_cb = savedGetspnam; + + return ret; +} + +/* getspnam("root") succeeding but with a null sp_pwdp (e.g. NIS/LDAP-backed + * root accounts) must also degrade gracefully, not call + * GetFakeHashFromTemplate() with a NULL template. */ +static int test_AuthInit_nullPasswordField(void) +{ + int ret = WS_SUCCESS; + struct spwd* (*savedGetspnam)(const char*); + char actual[256]; + + savedGetspnam = wsshd_getspnam_cb; + wsshd_getspnam_cb = stub_getspnam_nullPassword; + + /* Cache must be empty so AuthInit() reaches the getspnam() fallback. */ + wolfSSHD_SetCachedFakeHashForTest(NULL); + + Log(" Testing scenario: wolfSSHD_AuthInit() with getspnam(\"root\") " + "returning a null sp_pwdp."); + wolfSSHD_AuthInit(); + + /* Degraded mode must leave cachedFakeHash empty. */ + WMEMSET(actual, 0, sizeof(actual)); + wolfSSHD_GetCachedFakeHashForTest(actual, sizeof(actual)); + if (actual[0] != '\0') { + Log(" FAILED: cachedFakeHash was populated when sp_pwdp was " + "NULL.\n"); + ret = WS_FATAL_ERROR; + } + else { + Log(" PASSED.\n"); + } + + wolfSSHD_SetCachedFakeHashForTest(NULL); + wsshd_getspnam_cb = savedGetspnam; + + return ret; +} + +/* Two users sharing a hash template must dedup to one cached entry. */ +static int test_AddShadowLineToFakeHashCache_dedup(void) +{ + int ret = WS_SUCCESS; + char line1[] = "alice:$6$samesalt$samedigest:19000:0:99999:7:::"; + char line2[] = "bob:$6$samesalt$samedigest:19000:0:99999:7:::"; + + wolfSSHD_SetCachedFakeHashForTest(NULL); + + Log(" Testing scenario: AddShadowLineToFakeHashCache() with two " + "users sharing the same hash template."); + AddShadowLineToFakeHashCache(line1); + AddShadowLineToFakeHashCache(line2); + + if (wolfSSHD_GetCachedFakeHashCountForTest() != 1) { + Log(" FAILED: duplicate hash template was cached twice.\n"); + ret = WS_FATAL_ERROR; + } + else { + Log(" PASSED.\n"); + } + + wolfSSHD_SetCachedFakeHashForTest(NULL); + + return ret; +} + +/* Distinct hash templates must stop accumulating once the fixed-size + * cachedFakeHashes array is full, rather than overrunning it. */ +static int test_AddShadowLineToFakeHashCache_cap(void) +{ + int ret = WS_SUCCESS; + int i; + char line[64]; + int count; + + wolfSSHD_SetCachedFakeHashForTest(NULL); + + Log(" Testing scenario: AddShadowLineToFakeHashCache() with more " + "distinct hashes than the cache can hold."); + for (i = 0; i < 20; i++) { + WSNPRINTF(line, sizeof(line), "user%d:$6$salt%d$digest%d:::::::", + i, i, i); + AddShadowLineToFakeHashCache(line); + } + + count = wolfSSHD_GetCachedFakeHashCountForTest(); + if (count <= 0 || count > 8 || count >= 20) { + Log(" FAILED: cache count %d did not stay within its fixed " + "capacity.\n", count); + ret = WS_FATAL_ERROR; + } + else { + Log(" PASSED.\n"); + } + + wolfSSHD_SetCachedFakeHashForTest(NULL); + + return ret; +} + +/* Drives ScanShadowFile() with a synthetic tmpfile() stream instead of a + * real /etc/shadow. */ +static int test_ScanShadowFile_multipleEntries(void) +{ + int ret = WS_SUCCESS; + WFILE* f; + + wolfSSHD_SetCachedFakeHashForTest(NULL); + + f = tmpfile(); + if (f == NULL) { + Log(" Skipping test: unable to create a tmpfile().\n"); + return WS_SUCCESS; + } + + /* GetFakeHashFromTemplate drops the real salt for plain $N$salt$hash, + * so templates only differ by algorithm id ($6$ vs $5$) here. */ + fputs("alice:$6$saltA$digestA:19000:0:99999:7:::\n", f); + fputs("bob:$5$saltB$digestB:19000:0:99999:7:::\n", f); + fputs("carol:$6$saltA$digestA:19000:0:99999:7:::\n", f); + rewind(f); + + Log(" Testing scenario: ScanShadowFile() over a synthetic multi-user " + "shadow stream."); + ScanShadowFile(f); + fclose(f); + + if (wolfSSHD_GetCachedFakeHashCountForTest() != 2) { + Log(" FAILED: expected 2 cached templates, got %d.\n", + wolfSSHD_GetCachedFakeHashCountForTest()); + ret = WS_FATAL_ERROR; + } + else { + Log(" PASSED.\n"); + } + + wolfSSHD_SetCachedFakeHashForTest(NULL); + + return ret; +} + +/* A line over WSSHD_SHADOW_LINE_SZ must have its remainder drained so the + * next line isn't swallowed as its continuation. */ +static int test_ScanShadowFile_truncatedLine(void) +{ + int ret = WS_SUCCESS; + WFILE* f; + char overlong[1024]; + + wolfSSHD_SetCachedFakeHashForTest(NULL); + + f = tmpfile(); + if (f == NULL) { + Log(" Skipping test: unable to create a tmpfile().\n"); + return WS_SUCCESS; + } + + WMEMSET(overlong, 'x', sizeof(overlong) - 1); + overlong[sizeof(overlong) - 1] = '\0'; + fputs(overlong, f); + fputc('\n', f); + fputs("dave:$6$saltD$digestD:19000:0:99999:7:::\n", f); + rewind(f); + + Log(" Testing scenario: ScanShadowFile() with a line longer than " + "its read buffer."); + ScanShadowFile(f); + fclose(f); + + if (wolfSSHD_GetCachedFakeHashCountForTest() != 1) { + Log(" FAILED: expected 1 cached template after the overlong line, " + "got %d.\n", wolfSSHD_GetCachedFakeHashCountForTest()); + ret = WS_FATAL_ERROR; + } + else { + Log(" PASSED.\n"); + } + + wolfSSHD_SetCachedFakeHashForTest(NULL); + + return ret; +} +#endif /* WOLFSSHD_HAVE_SHADOW */ +#endif /* WOLFSSH_HAVE_LIBCRYPT || WOLFSSH_HAVE_LIBLOGIN */ + +#ifdef WOLFSSL_BASE64_ENCODE +/* Build a mutable "ssh-rsa " line; WSTRTOK mutates in place. */ +static int BuildAuthKeysLine(const byte* key, word32 keySz, + char* lineOut, word32 lineOutSz) +{ + static const char prefix[] = "ssh-rsa "; + word32 prefixLen = (word32)(sizeof(prefix) - 1); + word32 b64Sz; + + if (lineOutSz <= prefixLen) { + return WS_BUFFER_E; + } + WMEMCPY(lineOut, prefix, prefixLen); + b64Sz = lineOutSz - prefixLen; + if (Base64_Encode_NoNl(key, keySz, (byte*)lineOut + prefixLen, &b64Sz) + != 0) { + return WS_FATAL_ERROR; + } + /* Base64_Encode_NoNl does not null-terminate; do it ourselves. */ + if (prefixLen + b64Sz >= lineOutSz) { + return WS_BUFFER_E; + } + lineOut[prefixLen + b64Sz] = '\0'; + return WS_SUCCESS; +} + +/* Negative-path coverage for CheckAuthKeysLine's ConstantCompare clause. */ +static int test_CheckAuthKeysLine(void) +{ + int ret = WS_SUCCESS; + /* keyALastByte differs from keyA only in the final byte, killing a + * dropped-ConstantCompare mutation that the length check alone would miss. */ + static const char keyAStr[] = "wolfssh-auth-key-test-A-AAAAAAA"; + static const char keyBStr[] = "wolfssh-auth-key-test-B-BBBBBBB"; + const byte* keyA = (const byte*)keyAStr; + const byte* keyB = (const byte*)keyBStr; + const word32 keySz = (word32)(sizeof(keyAStr) - 1); + byte keyALastByte[sizeof(keyAStr) - 1]; + char line[256]; + char lineCopy[320]; /* fits the longer unsupported-type scenario line */ + int rc; + + WMEMCPY(keyALastByte, keyA, keySz); + keyALastByte[keySz - 1] ^= 0x01; + + ret = BuildAuthKeysLine(keyA, keySz, line, sizeof(line)); + if (ret != WS_SUCCESS) { + return ret; + } + + Log(" Testing scenario: matching key authenticates."); + WMEMCPY(lineCopy, line, WSTRLEN(line) + 1); + rc = CheckAuthKeysLine(lineCopy, (word32)WSTRLEN(lineCopy), + keyA, keySz); + if (rc == WSSHD_AUTH_SUCCESS) { + Log(" PASSED.\n"); + } + else { + Log(" FAILED (rc=%d).\n", rc); + ret = WS_FATAL_ERROR; + } + + if (ret == WS_SUCCESS) { + Log(" Testing scenario: different same-length key is rejected."); + WMEMCPY(lineCopy, line, WSTRLEN(line) + 1); + rc = CheckAuthKeysLine(lineCopy, (word32)WSTRLEN(lineCopy), + keyB, keySz); + if (rc == WSSHD_AUTH_FAILURE) { + Log(" PASSED.\n"); + } + else { + Log(" FAILED (rc=%d).\n", rc); + ret = WS_FATAL_ERROR; + } + } + + if (ret == WS_SUCCESS) { + Log(" Testing scenario: same-length key differing in last byte is " + "rejected."); + WMEMCPY(lineCopy, line, WSTRLEN(line) + 1); + rc = CheckAuthKeysLine(lineCopy, (word32)WSTRLEN(lineCopy), + keyALastByte, keySz); + if (rc == WSSHD_AUTH_FAILURE) { + Log(" PASSED.\n"); + } + else { + Log(" FAILED (rc=%d).\n", rc); + ret = WS_FATAL_ERROR; + } + } + + if (ret == WS_SUCCESS) { + /* An unsupported key type must be skipped (WSSHD_AUTH_FAILURE), not + * treated as a hard error, so SearchKeysFile keeps scanning later + * TrustedUserCAKeys entries. */ + Log(" Testing scenario: unsupported key type is skipped."); + WSNPRINTF(lineCopy, sizeof(lineCopy), "sk-ssh-ed25519@openssh.com %s", + line + WSTRLEN("ssh-rsa ")); + rc = CheckAuthKeysLine(lineCopy, (word32)WSTRLEN(lineCopy), + keyA, keySz); + if (rc == WSSHD_AUTH_FAILURE) { + Log(" PASSED.\n"); + } + else { + Log(" FAILED (rc=%d).\n", rc); + ret = WS_FATAL_ERROR; + } + } + + return ret; +} + +#ifndef _WIN32 +/* Drive SearchForPubKey against a temp authorized_keys file to cover the + * "no line matched -> WSSHD_AUTH_FAILURE" gate: the authorized key kills a + * condition inversion, the unauthorized key kills a deletion of the gate. */ +static int test_SearchForPubKey(void) +{ + int ret = WS_SUCCESS; + static const char keyAStr[] = "wolfssh-auth-key-test-A-AAAAAAA"; + static const char keyBStr[] = "wolfssh-auth-key-test-B-BBBBBBB"; + const byte* keyA = (const byte*)keyAStr; + const byte* keyB = (const byte*)keyBStr; + const word32 keySz = (word32)(sizeof(keyAStr) - 1); + char base[] = "/tmp/wolfsshd_pkXXXXXX"; + char keysPath[64] = ""; + char missPath[64] = ""; + char line[256]; + WS_UserAuthData_PublicKey pubKeyCtx; + WUID_T uid = getuid(); + FILE* f = NULL; + int rc; + + if (mkdtemp(base) == NULL) { + Log(" mkdtemp failed.\n"); + ret = WS_FATAL_ERROR; + } + + if (ret == WS_SUCCESS) { + snprintf(keysPath, sizeof(keysPath), "%s/authorized_keys", base); + snprintf(missPath, sizeof(missPath), "%s/absent_keys", base); + ret = BuildAuthKeysLine(keyA, keySz, line, sizeof(line)); + } + + if (ret == WS_SUCCESS) { + f = fopen(keysPath, "w"); + if (f == NULL) { + Log(" fopen of authorized_keys failed.\n"); + ret = WS_FATAL_ERROR; + } + else { + fputs(line, f); + fputs("\n", f); + fclose(f); + } + } + + /* Force 0600 so the StrictModes secure-open is not tripped by a permissive + * umask leaving the file group or world writable. */ + if (ret == WS_SUCCESS && chmod(keysPath, S_IRUSR | S_IWUSR) != 0) { + Log(" chmod of authorized_keys failed.\n"); + ret = WS_FATAL_ERROR; + } + + WMEMSET(&pubKeyCtx, 0, sizeof(pubKeyCtx)); + pubKeyCtx.publicKeySz = keySz; + + /* StrictModes disabled so the check stays hermetic (no ownership gate). */ + if (ret == WS_SUCCESS) { + Log(" Testing scenario: authorized key is accepted."); + pubKeyCtx.publicKey = keyA; + rc = SearchForPubKey(base, keysPath, "testuser", &pubKeyCtx, uid, 0); + if (rc == WSSHD_AUTH_SUCCESS) { + Log(" PASSED.\n"); + } + else { + Log(" FAILED (rc=%d).\n", rc); + ret = WS_FATAL_ERROR; + } + } + + /* The temp file is user-owned with safe perms, so the StrictModes + * secure-open branch must also accept the authorized key. */ + if (ret == WS_SUCCESS) { + Log(" Testing scenario: authorized key accepted under StrictModes."); + pubKeyCtx.publicKey = keyA; + rc = SearchForPubKey(base, keysPath, "testuser", &pubKeyCtx, uid, 1); + if (rc == WSSHD_AUTH_SUCCESS) { + Log(" PASSED.\n"); + } + else { + Log(" FAILED (rc=%d).\n", rc); + ret = WS_FATAL_ERROR; + } + } + + if (ret == WS_SUCCESS) { + Log(" Testing scenario: unauthorized key is rejected."); + pubKeyCtx.publicKey = keyB; + rc = SearchForPubKey(base, keysPath, "testuser", &pubKeyCtx, uid, 0); + if (rc == WSSHD_AUTH_FAILURE) { + Log(" PASSED.\n"); + } + else { + Log(" FAILED (rc=%d).\n", rc); + ret = WS_FATAL_ERROR; + } + } + + /* A missing authorized_keys file is an error, not a silent accept. */ + if (ret == WS_SUCCESS) { + Log(" Testing scenario: missing keys file returns an error."); + pubKeyCtx.publicKey = keyA; + rc = SearchForPubKey(base, missPath, "testuser", &pubKeyCtx, uid, 0); + if (rc < 0) { + Log(" PASSED.\n"); + } + else { + Log(" FAILED (rc=%d).\n", rc); + ret = WS_FATAL_ERROR; + } + } + + if (keysPath[0] != '\0') { + unlink(keysPath); + } + rmdir(base); + + return ret; +} +#endif /* !_WIN32 */ +#endif /* WOLFSSL_BASE64_ENCODE */ + +#ifndef _WIN32 +static WGID_T s_setregid_arg0, s_setregid_arg1; +static WUID_T s_setreuid_arg0, s_setreuid_arg1; +static int s_setregid_ret; +static int s_setreuid_ret; +static int s_setregid_called; +static int s_setreuid_called; + +static int stub_setregid(WGID_T rgid, WGID_T egid) +{ + s_setregid_called = 1; + s_setregid_arg0 = rgid; + s_setregid_arg1 = egid; + return s_setregid_ret; +} + +static int stub_setreuid(WUID_T ruid, WUID_T euid) +{ + s_setreuid_called = 1; + s_setreuid_arg0 = ruid; + s_setreuid_arg1 = euid; + return s_setreuid_ret; +} + +static void InstallPrivDropStubs(int regidRet, int reuidRet, + int (**savedRegid)(WGID_T, WGID_T), + int (**savedReuid)(WUID_T, WUID_T)) +{ + *savedRegid = wsshd_setregid_cb; + *savedReuid = wsshd_setreuid_cb; + wsshd_setregid_cb = stub_setregid; + wsshd_setreuid_cb = stub_setreuid; + s_setregid_ret = regidRet; + s_setreuid_ret = reuidRet; + s_setregid_called = 0; + s_setreuid_called = 0; s_setregid_arg0 = s_setregid_arg1 = 0; s_setreuid_arg0 = s_setreuid_arg1 = 0; } @@ -2038,6 +2814,41 @@ static void InstallPrivRaiseStubs(int egidRet, int euidRet, s_seteuid_arg = 0; } +#if (defined(WOLFSSH_HAVE_LIBCRYPT) || defined(WOLFSSH_HAVE_LIBLOGIN)) && \ + defined(WOLFSSHD_HAVE_SHADOW) +/* Fails only its first invocation, then succeeds. RequestAuthentication() + * calls wolfSSHD_AuthReducePermissions() unconditionally after every auth + * attempt regardless of whether raising permissions succeeded, and a + * reduce failure is fatal (exit(1)). A stub that always fails would take + * down the test process the moment that unconditional reduce call runs; + * this lets the first (raise) call fail as intended while the later + * reduce call(s) succeed. */ +static int s_setegidFailOnceCalls; + +static int stub_setegid_failOnce(WGID_T egid) +{ + s_setegid_called = 1; + s_setegid_arg = egid; + s_setegidFailOnceCalls++; + return (s_setegidFailOnceCalls == 1) ? -1 : 0; +} + +static void InstallPrivRaiseFailOnceStubs(int (**savedEgid)(WGID_T), + int (**savedEuid)(WUID_T)) +{ + *savedEgid = wsshd_setegid_cb; + *savedEuid = wsshd_seteuid_cb; + wsshd_setegid_cb = stub_setegid_failOnce; + wsshd_seteuid_cb = stub_seteuid; + s_setegidFailOnceCalls = 0; + s_seteuid_ret = 0; + s_setegid_called = 0; + s_seteuid_called = 0; + s_setegid_arg = 0; + s_seteuid_arg = 0; +} +#endif + /* Synthetic "sshd" account used so privilege-separation tests don't depend * on the host actually having an sshd system user configured. */ static struct passwd stub_sshd_pw; @@ -2051,46 +2862,410 @@ static struct passwd* stub_getpwnam(const char* name) stub_sshd_pw.pw_gid = 1000; return &stub_sshd_pw; } - return NULL; + return NULL; +} + +static void InstallGetpwnamStub(struct passwd* (**savedGetpwnam)(const char*)) +{ + *savedGetpwnam = wsshd_getpwnam_cb; + wsshd_getpwnam_cb = stub_getpwnam; +} + +/* UsePrivilegeSeparation no must let SetDefaultUserID succeed without a + * configured sshd system user, since no uid/gid switching will ever happen. */ +static int test_AuthCreateUser_privSepOff(void) +{ + int ret = WS_SUCCESS; + WOLFSSHD_CONFIG* conf; + WOLFSSHD_AUTH* auth; + static const char line[] = "UsePrivilegeSeparation no"; + + conf = wolfSSHD_ConfigNew(NULL); + if (conf == NULL) { + return WS_MEMORY_E; + } + + if (ParseConfigLine(&conf, line, (int)WSTRLEN(line), 0) != WS_SUCCESS) { + ret = WS_FATAL_ERROR; + } + + if (ret == WS_SUCCESS) { + auth = wolfSSHD_AuthCreateUser(NULL, conf); + if (auth == NULL) { + ret = WS_FATAL_ERROR; + } + else { + wolfSSHD_AuthFreeUser(auth); + } + } + + wolfSSHD_ConfigFree(conf); + return ret; +} + +#if (defined(WOLFSSH_HAVE_LIBCRYPT) || defined(WOLFSSH_HAVE_LIBLOGIN)) && \ + defined(WOLFSSHD_HAVE_SHADOW) +/* PasswordAuthentication no must reject through RequestAuthentication's + * DoFakePasswordCheck() branch without ever calling checkPasswordCb. */ +static int test_RequestAuth_pwAuthNoRejectsBeforePasswordCheck(void) +{ + int ret = WS_SUCCESS; + int rc; + WOLFSSHD_CONFIG* conf; + WOLFSSHD_AUTH* authCtx; + WS_UserAuthData authData; + struct passwd* (*savedGetpwnam)(const char*); + int (*savedGrouplist)(const char*, WGID_T, WGID_T*, int*); + int (*savedSetgroups)(int, const WGID_T*); + static const byte pw[] = "guessme"; + static const char line1[] = "UsePrivilegeSeparation no"; + static const char line2[] = "PasswordAuthentication no"; + + conf = wolfSSHD_ConfigNew(NULL); + if (conf == NULL) return WS_MEMORY_E; + + if (ParseConfigLine(&conf, line1, (int)WSTRLEN(line1), 0) != WS_SUCCESS || + ParseConfigLine(&conf, line2, (int)WSTRLEN(line2), 0) != + WS_SUCCESS) { + wolfSSHD_ConfigFree(conf); + return WS_FATAL_ERROR; + } + + authCtx = wolfSSHD_AuthCreateUser(NULL, conf); + if (authCtx == NULL) { + wolfSSHD_ConfigFree(conf); + return WS_FATAL_ERROR; + } + + savedGetpwnam = wsshd_getpwnam_cb; + wsshd_getpwnam_cb = stub_getpwnam_shadowUser; + InstallGroupStubs(0, &savedGrouplist, &savedSetgroups); + + WMEMSET(&authData, 0, sizeof(authData)); + authData.type = WOLFSSH_USERAUTH_PASSWORD; + authData.username = (const byte*)"shadow_branch_test_user"; + authData.usernameSz = (word32)WSTRLEN((const char*)authData.username); + authData.sf.password.password = pw; + authData.sf.password.passwordSz = (word32)(sizeof(pw) - 1); + + Log(" Testing scenario: PasswordAuthentication no rejects."); + wolfSSHD_ResetFakePasswordCheckCountForTest(); + rc = DefaultUserAuth(WOLFSSH_USERAUTH_PASSWORD, &authData, authCtx); + if (rc == WOLFSSH_USERAUTH_REJECTED && + wolfSSHD_GetFakePasswordCheckCountForTest() != 0) { + Log(" PASSED.\n"); + } + else { + Log(" FAILED: got %d, fakeCheckCount=%d.\n", rc, + wolfSSHD_GetFakePasswordCheckCountForTest()); + ret = WS_FATAL_ERROR; + } + + wsshd_getpwnam_cb = savedGetpwnam; + wsshd_getgrouplist_cb = savedGrouplist; + wsshd_setgroups_cb = savedSetgroups; + wolfSSHD_AuthFreeUser(authCtx); + wolfSSHD_ConfigFree(conf); + + return ret; +} + +/* Test that denying an empty password with PermitEmptyPw=no fakes a + * password check to prevent timing leaks. */ +static int test_RequestAuth_permitEmptyPwDeniedFakesPasswordCheck(void) +{ + int ret = WS_SUCCESS; + int rc; + WOLFSSHD_CONFIG* conf; + WOLFSSHD_AUTH* authCtx; + WS_UserAuthData authData; + struct passwd* (*savedGetpwnam)(const char*); + int (*savedGrouplist)(const char*, WGID_T, WGID_T*, int*); + int (*savedSetgroups)(int, const WGID_T*); + static const char line1[] = "UsePrivilegeSeparation no"; + static const char line2[] = "PermitEmptyPasswords no"; + + conf = wolfSSHD_ConfigNew(NULL); + if (conf == NULL) return WS_MEMORY_E; + + if (ParseConfigLine(&conf, line1, (int)WSTRLEN(line1), 0) != WS_SUCCESS || + ParseConfigLine(&conf, line2, (int)WSTRLEN(line2), 0) != + WS_SUCCESS) { + wolfSSHD_ConfigFree(conf); + return WS_FATAL_ERROR; + } + + authCtx = wolfSSHD_AuthCreateUser(NULL, conf); + if (authCtx == NULL) { + wolfSSHD_ConfigFree(conf); + return WS_FATAL_ERROR; + } + + savedGetpwnam = wsshd_getpwnam_cb; + wsshd_getpwnam_cb = stub_getpwnam_shadowUser; + InstallGroupStubs(0, &savedGrouplist, &savedSetgroups); + + WMEMSET(&authData, 0, sizeof(authData)); + authData.type = WOLFSSH_USERAUTH_PASSWORD; + authData.username = (const byte*)"shadow_branch_test_user"; + authData.usernameSz = (word32)WSTRLEN((const char*)authData.username); + authData.sf.password.password = NULL; + authData.sf.password.passwordSz = 0; + + Log(" Testing scenario: PermitEmptyPw no denies empty password."); + wolfSSHD_ResetFakePasswordCheckCountForTest(); + rc = DefaultUserAuth(WOLFSSH_USERAUTH_PASSWORD, &authData, authCtx); + if (rc == WOLFSSH_USERAUTH_FAILURE && + wolfSSHD_GetFakePasswordCheckCountForTest() != 0) { + Log(" PASSED.\n"); + } + else { + Log(" FAILED: got %d, fakeCheckCount=%d.\n", rc, + wolfSSHD_GetFakePasswordCheckCountForTest()); + ret = WS_FATAL_ERROR; + } + + wsshd_getpwnam_cb = savedGetpwnam; + wsshd_getgrouplist_cb = savedGrouplist; + wsshd_setgroups_cb = savedSetgroups; + wolfSSHD_AuthFreeUser(authCtx); + wolfSSHD_ConfigFree(conf); + + return ret; +} + +/* checkPasswordCb returning something other than WSSHD_AUTH_SUCCESS/FAILURE + * (e.g. CheckPasswordUnix's oversized-shadow-hash fail-closed path) must + * surface as WOLFSSH_USERAUTH_FAILURE through RequestAuthentication, taking + * the DoFakePasswordCheck() branch rather than crashing or succeeding. */ +static int test_RequestAuth_checkPasswordCbErrorFailsClosed(void) +{ + int ret = WS_SUCCESS; + int rc; + WOLFSSHD_CONFIG* conf; + WOLFSSHD_AUTH* authCtx; + WS_UserAuthData authData; + struct passwd* (*savedGetpwnam)(const char*); + struct spwd* (*savedGetspnam)(const char*); + int (*savedGrouplist)(const char*, WGID_T, WGID_T*, int*); + int (*savedSetgroups)(int, const WGID_T*); + static const byte pw[] = "guessme"; + static const char line1[] = "UsePrivilegeSeparation no"; + + conf = wolfSSHD_ConfigNew(NULL); + if (conf == NULL) return WS_MEMORY_E; + + if (ParseConfigLine(&conf, line1, (int)WSTRLEN(line1), 0) != WS_SUCCESS) { + wolfSSHD_ConfigFree(conf); + return WS_FATAL_ERROR; + } + + authCtx = wolfSSHD_AuthCreateUser(NULL, conf); + if (authCtx == NULL) { + wolfSSHD_ConfigFree(conf); + return WS_FATAL_ERROR; + } + + savedGetpwnam = wsshd_getpwnam_cb; + savedGetspnam = wsshd_getspnam_cb; + wsshd_getpwnam_cb = stub_getpwnam_shadowUser; + wsshd_getspnam_cb = stub_getspnam_oversizedHash; + InstallGroupStubs(0, &savedGrouplist, &savedSetgroups); + + WMEMSET(&authData, 0, sizeof(authData)); + authData.type = WOLFSSH_USERAUTH_PASSWORD; + authData.username = (const byte*)"shadow_branch_test_user"; + authData.usernameSz = (word32)WSTRLEN((const char*)authData.username); + authData.sf.password.password = pw; + authData.sf.password.passwordSz = (word32)(sizeof(pw) - 1); + + Log(" Testing scenario: checkPasswordCb error fails closed."); + rc = DefaultUserAuth(WOLFSSH_USERAUTH_PASSWORD, &authData, authCtx); + if (rc == WOLFSSH_USERAUTH_FAILURE) { + Log(" PASSED.\n"); + } + else { + Log(" FAILED: got %d.\n", rc); + ret = WS_FATAL_ERROR; + } + + wsshd_getpwnam_cb = savedGetpwnam; + wsshd_getspnam_cb = savedGetspnam; + wsshd_getgrouplist_cb = savedGrouplist; + wsshd_setgroups_cb = savedSetgroups; + wolfSSHD_AuthFreeUser(authCtx); + wolfSSHD_ConfigFree(conf); + + return ret; } -static void InstallGetpwnamStub(struct passwd* (**savedGetpwnam)(const char*)) +/* wolfSSHD_AuthRaisePermissions() failing must equalize timing with a fake + * crypt() only for password auth; doing so for pubkey auth would itself be a + * timing oracle (see the comment on RequestAuthentication's DoCheckUser() + * call). Drives both auth types through the same failure so a regression + * that re-ungates the pubkey path is caught by an actual call count rather + * than by a return code that looks identical either way. */ +static int test_RequestAuth_raisePermissionsFailFakeCheckGating(void) { - *savedGetpwnam = wsshd_getpwnam_cb; - wsshd_getpwnam_cb = stub_getpwnam; + int ret = WS_SUCCESS; + int rc; + WOLFSSHD_CONFIG* conf; + WOLFSSHD_AUTH* authCtx; + WS_UserAuthData authData; + struct passwd* (*savedGetpwnam)(const char*); + int (*savedEgid)(WGID_T); + int (*savedEuid)(WUID_T); + static const byte pw[] = "guessme"; + + conf = wolfSSHD_ConfigNew(NULL); + if (conf == NULL) return WS_MEMORY_E; + + /* privilege separation defaults to on; stub getpwnam("sshd") so + * AuthCreateUser can resolve the saved uid/gid without a real system + * account. */ + InstallGetpwnamStub(&savedGetpwnam); + authCtx = wolfSSHD_AuthCreateUser(NULL, conf); + if (authCtx == NULL) { + wsshd_getpwnam_cb = savedGetpwnam; + wolfSSHD_ConfigFree(conf); + return WS_FATAL_ERROR; + } + + /* swap to the auth-target stub for the DoCheckUser() lookups below */ + wsshd_getpwnam_cb = stub_getpwnam_shadowUser; + InstallPrivRaiseFailOnceStubs(&savedEgid, &savedEuid); + + WMEMSET(&authData, 0, sizeof(authData)); + authData.username = (const byte*)"shadow_branch_test_user"; + authData.usernameSz = (word32)WSTRLEN((const char*)authData.username); + + Log(" Testing scenario: AuthRaisePermissions failure fakes password " + "check for PASSWORD auth."); + authData.type = WOLFSSH_USERAUTH_PASSWORD; + authData.sf.password.password = pw; + authData.sf.password.passwordSz = (word32)(sizeof(pw) - 1); + wolfSSHD_ResetFakePasswordCheckCountForTest(); + rc = DefaultUserAuth(WOLFSSH_USERAUTH_PASSWORD, &authData, authCtx); + if (rc != WOLFSSH_USERAUTH_FAILURE || + wolfSSHD_GetFakePasswordCheckCountForTest() == 0) { + Log(" FAILED: rc=%d, fakeCheckCount=%d.\n", rc, + wolfSSHD_GetFakePasswordCheckCountForTest()); + ret = WS_FATAL_ERROR; + } + else { + Log(" PASSED.\n"); + } + + Log(" Testing scenario: AuthRaisePermissions failure must not fake " + "password check for PUBLICKEY auth."); + /* Clear union fields before reinterpreting as sf.publicKey. */ + WMEMSET(&authData.sf, 0, sizeof(authData.sf)); + authData.type = WOLFSSH_USERAUTH_PUBLICKEY; + wolfSSHD_ResetFakePasswordCheckCountForTest(); + /* Re-arm the fail-once stub to hit the raise-permissions failure. */ + s_setegidFailOnceCalls = 0; + rc = DefaultUserAuth(WOLFSSH_USERAUTH_PUBLICKEY, &authData, authCtx); + if (rc != WOLFSSH_USERAUTH_FAILURE || + wolfSSHD_GetFakePasswordCheckCountForTest() != 0) { + Log(" FAILED: rc=%d, fakeCheckCount=%d.\n", rc, + wolfSSHD_GetFakePasswordCheckCountForTest()); + ret = WS_FATAL_ERROR; + } + else { + Log(" PASSED.\n"); + } + + wsshd_setegid_cb = savedEgid; + wsshd_seteuid_cb = savedEuid; + wsshd_getpwnam_cb = savedGetpwnam; + wolfSSHD_AuthFreeUser(authCtx); + wolfSSHD_ConfigFree(conf); + + return ret; } -/* UsePrivilegeSeparation no must let SetDefaultUserID succeed without a - * configured sshd system user, since no uid/gid switching will ever happen. */ -static int test_AuthCreateUser_privSepOff(void) +/* Test that AuthGetUserConf() NULL failure fakes a password check for + * password auth, using a mocked group failure. */ +static int test_RequestAuth_userConfNullFakeCheckGating(void) { int ret = WS_SUCCESS; + int rc; WOLFSSHD_CONFIG* conf; - WOLFSSHD_AUTH* auth; - static const char line[] = "UsePrivilegeSeparation no"; + WOLFSSHD_AUTH* authCtx; + WS_UserAuthData authData; + struct passwd* (*savedGetpwnam)(const char*); + int (*savedGrouplist)(const char*, WGID_T, WGID_T*, int*); + int (*savedSetgroups)(int, const WGID_T*); + static const byte pw[] = "guessme"; + static const char line1[] = "UsePrivilegeSeparation no"; conf = wolfSSHD_ConfigNew(NULL); - if (conf == NULL) { - return WS_MEMORY_E; + if (conf == NULL) return WS_MEMORY_E; + + if (ParseConfigLine(&conf, line1, (int)WSTRLEN(line1), 0) != WS_SUCCESS) { + wolfSSHD_ConfigFree(conf); + return WS_FATAL_ERROR; } - if (ParseConfigLine(&conf, line, (int)WSTRLEN(line), 0) != WS_SUCCESS) { + authCtx = wolfSSHD_AuthCreateUser(NULL, conf); + if (authCtx == NULL) { + wolfSSHD_ConfigFree(conf); + return WS_FATAL_ERROR; + } + + savedGetpwnam = wsshd_getpwnam_cb; + wsshd_getpwnam_cb = stub_getpwnam_shadowUser; + InstallGroupStubs(0, &savedGrouplist, &savedSetgroups); + s_grouplist_always_fail = 1; + + WMEMSET(&authData, 0, sizeof(authData)); + authData.username = (const byte*)"shadow_branch_test_user"; + authData.usernameSz = (word32)WSTRLEN((const char*)authData.username); + + Log(" Testing scenario: AuthGetUserConf NULL fakes password check " + "for PASSWORD auth."); + authData.type = WOLFSSH_USERAUTH_PASSWORD; + authData.sf.password.password = pw; + authData.sf.password.passwordSz = (word32)(sizeof(pw) - 1); + wolfSSHD_ResetFakePasswordCheckCountForTest(); + rc = DefaultUserAuth(WOLFSSH_USERAUTH_PASSWORD, &authData, authCtx); + if (rc != WOLFSSH_USERAUTH_FAILURE || + wolfSSHD_GetFakePasswordCheckCountForTest() == 0) { + Log(" FAILED: rc=%d, fakeCheckCount=%d.\n", rc, + wolfSSHD_GetFakePasswordCheckCountForTest()); ret = WS_FATAL_ERROR; } + else { + Log(" PASSED.\n"); + } - if (ret == WS_SUCCESS) { - auth = wolfSSHD_AuthCreateUser(NULL, conf); - if (auth == NULL) { - ret = WS_FATAL_ERROR; - } - else { - wolfSSHD_AuthFreeUser(auth); - } + Log(" Testing scenario: AuthGetUserConf NULL must not fake password " + "check for PUBLICKEY auth."); + /* Clear union fields before reinterpreting as sf.publicKey. */ + WMEMSET(&authData.sf, 0, sizeof(authData.sf)); + authData.type = WOLFSSH_USERAUTH_PUBLICKEY; + wolfSSHD_ResetFakePasswordCheckCountForTest(); + rc = DefaultUserAuth(WOLFSSH_USERAUTH_PUBLICKEY, &authData, authCtx); + if (rc != WOLFSSH_USERAUTH_FAILURE || + wolfSSHD_GetFakePasswordCheckCountForTest() != 0) { + Log(" FAILED: rc=%d, fakeCheckCount=%d.\n", rc, + wolfSSHD_GetFakePasswordCheckCountForTest()); + ret = WS_FATAL_ERROR; + } + else { + Log(" PASSED.\n"); } + wsshd_getgrouplist_cb = savedGrouplist; + wsshd_setgroups_cb = savedSetgroups; + wsshd_getpwnam_cb = savedGetpwnam; + wolfSSHD_AuthFreeUser(authCtx); wolfSSHD_ConfigFree(conf); + return ret; } +#endif /* (WOLFSSH_HAVE_LIBCRYPT || WOLFSSH_HAVE_LIBLOGIN) && + * WOLFSSHD_HAVE_SHADOW */ /* wolfSSHD_AuthRaisePermissions must not touch setegid/seteuid at all when * privilege separation is off, since the process never dropped privileges. */ @@ -2281,6 +3456,174 @@ static int test_AuthRaisePermissions_uidFail(void) /* Drives the supplementary-group drop with getgrouplist and setgroups mocked * so it is deterministic across platforms; asserts setgroups is invoked with * the resolved group count. */ + +static int test_AuthReducePermissions_offSkipsSyscalls(void) +{ + int ret = WS_SUCCESS; + WOLFSSHD_CONFIG* conf; + WOLFSSHD_AUTH* auth; + int (*savedEgid)(WGID_T); + int (*savedEuid)(WUID_T); + static const char line[] = "UsePrivilegeSeparation no"; + + conf = wolfSSHD_ConfigNew(NULL); + if (conf == NULL) { + return WS_MEMORY_E; + } + + if (ParseConfigLine(&conf, line, (int)WSTRLEN(line), 0) != WS_SUCCESS) { + ret = WS_FATAL_ERROR; + } + + if (ret == WS_SUCCESS) { + auth = wolfSSHD_AuthCreateUser(NULL, conf); + if (auth == NULL) { + ret = WS_FATAL_ERROR; + } + else { + InstallPrivRaiseStubs(0, 0, &savedEgid, &savedEuid); + + if (wolfSSHD_AuthReducePermissions(auth) != WS_SUCCESS) + ret = WS_FATAL_ERROR; + if (ret == WS_SUCCESS + && (s_setegid_called || s_seteuid_called)) + ret = WS_FATAL_ERROR; + + wsshd_setegid_cb = savedEgid; + wsshd_seteuid_cb = savedEuid; + wolfSSHD_AuthFreeUser(auth); + } + } + + wolfSSHD_ConfigFree(conf); + return ret; +} + +static int test_AuthReducePermissions_separateCallsSyscalls(void) +{ + int ret = WS_SUCCESS; + WOLFSSHD_CONFIG* conf; + WOLFSSHD_AUTH* auth; + int (*savedEgid)(WGID_T); + int (*savedEuid)(WUID_T); + struct passwd* (*savedGetpwnam)(const char*) = NULL; + + conf = wolfSSHD_ConfigNew(NULL); + if (conf == NULL) { + return WS_MEMORY_E; + } + + InstallGetpwnamStub(&savedGetpwnam); + + auth = wolfSSHD_AuthCreateUser(NULL, conf); + if (auth == NULL) { + ret = WS_FATAL_ERROR; + } + else { + InstallPrivRaiseStubs(0, 0, &savedEgid, &savedEuid); + + if (wolfSSHD_AuthReducePermissions(auth) != WS_SUCCESS) + ret = WS_FATAL_ERROR; + if (ret == WS_SUCCESS && (!s_setegid_called || !s_seteuid_called)) + ret = WS_FATAL_ERROR; + + wsshd_setegid_cb = savedEgid; + wsshd_seteuid_cb = savedEuid; + wolfSSHD_AuthFreeUser(auth); + } + + wsshd_getpwnam_cb = savedGetpwnam; + wolfSSHD_ConfigFree(conf); + return ret; +} + +static int test_AuthReducePermissions_nullArg(void) +{ + if (wolfSSHD_AuthReducePermissions(NULL) != WS_BAD_ARGUMENT) + return WS_FATAL_ERROR; + return WS_SUCCESS; +} + +static int test_AuthReducePermissions_gidFailContinuesToUid(void) +{ + int ret = WS_SUCCESS; + WOLFSSHD_CONFIG* conf; + WOLFSSHD_AUTH* auth; + int (*savedEgid)(WGID_T); + int (*savedEuid)(WUID_T); + struct passwd* (*savedGetpwnam)(const char*) = NULL; + + conf = wolfSSHD_ConfigNew(NULL); + if (conf == NULL) { + return WS_MEMORY_E; + } + + InstallGetpwnamStub(&savedGetpwnam); + + auth = wolfSSHD_AuthCreateUser(NULL, conf); + if (auth == NULL) { + ret = WS_FATAL_ERROR; + } + else { + InstallPrivRaiseStubs(-1, 0, &savedEgid, &savedEuid); + + if (wolfSSHD_AuthReducePermissions(auth) != WS_FATAL_ERROR) + ret = WS_FATAL_ERROR; + if (ret == WS_SUCCESS && !s_setegid_called) + ret = WS_FATAL_ERROR; + if (ret == WS_SUCCESS && !s_seteuid_called) + ret = WS_FATAL_ERROR; + + wsshd_setegid_cb = savedEgid; + wsshd_seteuid_cb = savedEuid; + wolfSSHD_AuthFreeUser(auth); + } + + wsshd_getpwnam_cb = savedGetpwnam; + wolfSSHD_ConfigFree(conf); + return ret; +} + +static int test_AuthReducePermissions_uidFail(void) +{ + int ret = WS_SUCCESS; + WOLFSSHD_CONFIG* conf; + WOLFSSHD_AUTH* auth; + int (*savedEgid)(WGID_T); + int (*savedEuid)(WUID_T); + struct passwd* (*savedGetpwnam)(const char*) = NULL; + + conf = wolfSSHD_ConfigNew(NULL); + if (conf == NULL) { + return WS_MEMORY_E; + } + + InstallGetpwnamStub(&savedGetpwnam); + + auth = wolfSSHD_AuthCreateUser(NULL, conf); + if (auth == NULL) { + ret = WS_FATAL_ERROR; + } + else { + InstallPrivRaiseStubs(0, -1, &savedEgid, &savedEuid); + + if (wolfSSHD_AuthReducePermissions(auth) != WS_FATAL_ERROR) + ret = WS_FATAL_ERROR; + if (ret == WS_SUCCESS && !s_setegid_called) + ret = WS_FATAL_ERROR; + if (ret == WS_SUCCESS && !s_seteuid_called) + ret = WS_FATAL_ERROR; + + wsshd_setegid_cb = savedEgid; + wsshd_seteuid_cb = savedEuid; + wolfSSHD_AuthFreeUser(auth); + } + + wsshd_getpwnam_cb = savedGetpwnam; + wolfSSHD_ConfigFree(conf); + return ret; +} + static int test_AuthSetGroups_ok(void) { int ret = WS_SUCCESS; @@ -4039,17 +5382,49 @@ const TEST_CASE testCases[] = { TEST_DECL(test_AuthReducePermissionsUser_gid_fail), TEST_DECL(test_AuthReducePermissionsUser_uid_fail), TEST_DECL(test_AuthCreateUser_privSepOff), +#if (defined(WOLFSSH_HAVE_LIBCRYPT) || defined(WOLFSSH_HAVE_LIBLOGIN)) && \ + defined(WOLFSSHD_HAVE_SHADOW) + TEST_DECL(test_RequestAuth_pwAuthNoRejectsBeforePasswordCheck), + TEST_DECL(test_RequestAuth_permitEmptyPwDeniedFakesPasswordCheck), + TEST_DECL(test_RequestAuth_checkPasswordCbErrorFailsClosed), + TEST_DECL(test_RequestAuth_raisePermissionsFailFakeCheckGating), + TEST_DECL(test_RequestAuth_userConfNullFakeCheckGating), +#endif TEST_DECL(test_AuthRaisePermissions_offSkipsSyscalls), TEST_DECL(test_AuthRaisePermissions_separateCallsSyscalls), TEST_DECL(test_AuthRaisePermissions_nullArg), TEST_DECL(test_AuthRaisePermissions_gidFailSkipsUid), TEST_DECL(test_AuthRaisePermissions_uidFail), + + TEST_DECL(test_AuthReducePermissions_offSkipsSyscalls), + TEST_DECL(test_AuthReducePermissions_separateCallsSyscalls), + TEST_DECL(test_AuthReducePermissions_nullArg), + TEST_DECL(test_AuthReducePermissions_gidFailContinuesToUid), + TEST_DECL(test_AuthReducePermissions_uidFail), + TEST_DECL(test_AuthSetGroups_ok), TEST_DECL(test_AuthSetGroups_setgroups_fail), TEST_DECL(test_AuthSetGroups_getgrouplist_fail), #endif #if defined(WOLFSSH_HAVE_LIBCRYPT) || defined(WOLFSSH_HAVE_LIBLOGIN) TEST_DECL(test_CheckPasswordHashUnix), +#ifdef WOLFSSHD_HAVE_SHADOW + TEST_DECL(test_GetFakeHashFromTemplate), + TEST_DECL(test_CachedFakeHashConsumption), + TEST_DECL(test_CheckPasswordUnix_shadowLookupFails), + TEST_DECL(test_CheckPasswordUnix_shadowHashTooLong), + TEST_DECL(test_CheckPasswordUnix_shadowNullPassword), + TEST_DECL(test_CheckPasswordUnix_shadowLookupSucceeds), + TEST_DECL(test_CheckPasswordUnix_unknownUser), + TEST_DECL(test_DoFakePasswordCheck_pubkeyUnionSafety), + TEST_DECL(test_AuthInit), + TEST_DECL(test_AuthInit_degradedMode), + TEST_DECL(test_AuthInit_nullPasswordField), + TEST_DECL(test_AddShadowLineToFakeHashCache_dedup), + TEST_DECL(test_AddShadowLineToFakeHashCache_cap), + TEST_DECL(test_ScanShadowFile_multipleEntries), + TEST_DECL(test_ScanShadowFile_truncatedLine), +#endif TEST_DECL(test_DefaultUserAuth_OOBRead), #endif #if defined(WOLFSSH_OSSH_CERTS) && !defined(_WIN32) diff --git a/apps/wolfsshd/wolfsshd.c b/apps/wolfsshd/wolfsshd.c index fb8111deb..584e25431 100644 --- a/apps/wolfsshd/wolfsshd.c +++ b/apps/wolfsshd/wolfsshd.c @@ -2815,6 +2815,12 @@ static int StartSSHD(int argc, char** argv) } } + /* Must run before privilege drop so the shadow file is accessible. + * Degrades to a fixed-cost fake hash if the shadow read fails. */ + if (ret == WS_SUCCESS && !testMode) { + wolfSSHD_AuthInit(); + } + if (ret == WS_SUCCESS) { ret = wolfSSHD_ConfigLoad(conf, configFile); if (ret != WS_SUCCESS) {