From 3e7b4f477cad9cb9d1cf448a10bd6370b365d000 Mon Sep 17 00:00:00 2001 From: Yosuke Shimizu Date: Thu, 20 Aug 2026 15:33:32 +0900 Subject: [PATCH] wolfsshd: enforce shadow password and account aging - IsShadowExpired() in auth.c returns 1 when a shadow entry's sp_expire date has arrived, its sp_lstchg is 0, or the day is at or past sp_lstchg + sp_max. Negative fields leave the matching check off; a negative day count, standing for an unavailable clock, denies the entries that carry aging. WSSHD_SECS_PER_DAY converts WTIME() into the unit those fields use. The helper is compiled under HAVE_SHADOW and !WOLFSSH_USE_PAM, as its caller is. - CheckPasswordUnix() runs the shadow entry it looked up through the helper and, after an otherwise successful hash compare, logs the denial and returns WSSHD_AUTH_FAILURE. - auth.h declares IsShadowExpired() for the unit test build. - test_configuration.c adds test_IsShadowExpired() over a table of aging fields and day counts, and test_CheckPasswordUnix_expired() for the denial of a correct password. - The CheckPasswordUnix() tests share one driver, wsshd_test_CheckPasswordUnixCase(), with the crypt() setup in wsshd_test_LoadShadowHash() and the three fail-closed shadow lookups gathered into test_CheckPasswordUnix_failClosed(). Issue: F-10577 --- apps/wolfsshd/auth.c | 53 +++++ apps/wolfsshd/auth.h | 4 + apps/wolfsshd/test/test_configuration.c | 272 ++++++++++++++++-------- 3 files changed, 240 insertions(+), 89 deletions(-) diff --git a/apps/wolfsshd/auth.c b/apps/wolfsshd/auth.c index 1b7a74155..d0ffc2a17 100644 --- a/apps/wolfsshd/auth.c +++ b/apps/wolfsshd/auth.c @@ -508,6 +508,43 @@ static void ScanShadowFile(WFILE* f) } } +#ifndef WOLFSSH_USE_PAM +/* Shadow aging fields count days since the epoch. */ +#define WSSHD_SECS_PER_DAY (24L * 60L * 60L) + +/* Return 1 when the shadow aging fields make the account unusable for login. + * A negative today means the system clock is unavailable, which denies only + * the accounts that actually have aging configured. */ +#ifdef WOLFSSHD_UNIT_TEST +int IsShadowExpired(const struct spwd* sp, long today) +#else +static int IsShadowExpired(const struct spwd* sp, long today) +#endif +{ + int expired = 0; + + if (sp != NULL) { + /* Account expiration date, effective on the date itself. */ + if (sp->sp_expire >= 0 && + (today < 0 || today >= (long)sp->sp_expire)) { + expired = 1; + } + /* Password change forced at next login. */ + if (expired == 0 && sp->sp_lstchg == 0) { + expired = 1; + } + /* Password aged out. Subtraction avoids overflowing the sum. */ + if (expired == 0 && sp->sp_lstchg > 0 && sp->sp_max >= 0 && + (today < 0 || today - (long)sp->sp_lstchg >= + (long)sp->sp_max)) { + expired = 1; + } + } + + return expired; +} +#endif /* !WOLFSSH_USE_PAM */ + #ifdef WOLFSSHD_UNIT_TEST /* Test-only hook to seed cachedFakeHash without a real shadow file entry. */ void wolfSSHD_SetCachedFakeHashForTest(const char* tmpl) @@ -767,6 +804,8 @@ static int CheckPasswordUnix(const char* usr, const byte* pw, word32 pwSz, WOLFS struct passwd* pwInfo; #ifdef HAVE_SHADOW struct spwd* shadowInfo; + time_t now; + int expired = 0; /* getspnam() returns a static buffer; copy immediately before it can * be overwritten by any subsequent call. */ char hashBuf[WSSHD_FAKE_HASH_SZ]; @@ -838,6 +877,10 @@ static int CheckPasswordUnix(const char* usr, const byte* pw, word32 pwSz, WOLFS XSTRNCPY(hashBuf, shadowInfo->sp_pwdp, sizeof(hashBuf)); hashBuf[sizeof(hashBuf) - 1] = '\0'; storedHash = hashBuf; + now = WTIME(NULL); + expired = IsShadowExpired(shadowInfo, + (now == (time_t)-1) ? -1 : + (long)(now / WSSHD_SECS_PER_DAY)); } } else @@ -867,6 +910,16 @@ static int CheckPasswordUnix(const char* usr, const byte* pw, word32 pwSz, WOLFS #endif } +#ifdef HAVE_SHADOW + /* Deny after the hash compare so an expired account costs the same as a + * live one. */ + if (expired && ret == WSSHD_AUTH_SUCCESS) { + wolfSSH_Log(WS_LOG_INFO, + "[SSHD] Password or account expired for user %s", usr); + ret = WSSHD_AUTH_FAILURE; + } +#endif + if (pwStr != NULL) { WS_FORCEZERO(pwStr, pwSz + 1); WFREE(pwStr, NULL, DYNTYPE_STRING); diff --git a/apps/wolfsshd/auth.h b/apps/wolfsshd/auth.h index 637cdda7b..ffa42e374 100644 --- a/apps/wolfsshd/auth.h +++ b/apps/wolfsshd/auth.h @@ -149,6 +149,10 @@ int wolfSSHD_GetCachedFakeHashCountForTest(void); void AddShadowLineToFakeHashCache(char* line); /* Reads a shadow file stream line by line into the fake-hash cache. */ void ScanShadowFile(WFILE* f); +#ifndef WOLFSSH_USE_PAM +/* Returns 1 when the shadow aging fields deny the account a login. */ +int IsShadowExpired(const struct spwd* sp, long today); +#endif #endif #endif /* Not shadow-specific in auth.c, so not excluded on OSX/APPLE. */ diff --git a/apps/wolfsshd/test/test_configuration.c b/apps/wolfsshd/test/test_configuration.c index 326843663..c1e96c114 100644 --- a/apps/wolfsshd/test/test_configuration.c +++ b/apps/wolfsshd/test/test_configuration.c @@ -2075,25 +2075,28 @@ static int test_CheckPasswordUnix_unknownUser(void) 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) +/* Runs CheckPasswordUnix() against a synthetic shadow entry and checks the + * result code it returns. */ +static int wsshd_test_CheckPasswordUnixCase(struct spwd* (*stub)(const char*), + const byte* pw, word32 pwSz, int expected, const char* scenario) { 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; + wsshd_getspnam_cb = stub; - 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"); + Log(" Testing scenario: %s.", scenario); + rc = CheckPasswordUnix("shadow_branch_test_user", pw, pwSz, NULL); + if (rc == expected) { + Log(" PASSED.\n"); + } + else { + Log(" FAILED, expected %d got %d.\n", expected, rc); ret = WS_FATAL_ERROR; } @@ -2102,119 +2105,215 @@ static int test_CheckPasswordUnix_shadowLookupFails(void) 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) +#if defined(WOLFSSH_HAVE_LIBCRYPT) || defined(WOLFSSH_HAVE_LIBLOGIN) +/* Fills stub_shadow_test_hash with a real crypt() hash of pw. Returns 0 when + * the platform's crypt() ignores the modular $6$ salt, as macOS and the BSDs + * do; see test_CheckPasswordHashUnix. */ +static int wsshd_test_LoadShadowHash(const byte* pw) { - 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; + const char* salt = "$6$wolfsshtestsalt$"; + char* hash; - 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; + hash = crypt((const char*)pw, salt); + if (hash == NULL || hash[0] == '*' || WSTRLEN(hash) == 0 || + WSTRNCMP(hash, "$6$", 3) != 0) { + return 0; } - - wsshd_getpwnam_cb = savedGetpwnam; - wsshd_getspnam_cb = savedGetspnam; - return ret; + if (WSTRLEN(hash) >= sizeof(stub_shadow_test_hash)) { + return 0; + } + WMEMCPY(stub_shadow_test_hash, hash, WSTRLEN(hash) + 1); + return 1; } +#endif /* WOLFSSH_HAVE_LIBCRYPT || WOLFSSH_HAVE_LIBLOGIN */ -/* 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) +/* Shadow lookups that cannot yield a usable hash. Each must fail closed + * rather than fall through to the "*" default or a truncated hash. */ +static const struct { + const char* scenario; + struct spwd* (*stub)(const char*); +} shadowFailClosedCases[] = { + { "getspnam() fails, e.g. SSHD not run as root", stub_getspnam_null }, + { "shadow hash too long for the copy buffer", + stub_getspnam_oversizedHash }, + { "shadow entry with no password field", stub_getspnam_nullPassword } +}; + +static int test_CheckPasswordUnix_failClosed(void) { int ret = WS_SUCCESS; - int rc; - struct passwd* (*savedGetpwnam)(const char*); - struct spwd* (*savedGetspnam)(const char*); + word32 i; 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; + for (i = 0; + i < sizeof(shadowFailClosedCases)/sizeof(shadowFailClosedCases[0]); + i++) { + if (wsshd_test_CheckPasswordUnixCase(shadowFailClosedCases[i].stub, + pw, (word32)(sizeof(pw) - 1), WS_FATAL_ERROR, + shadowFailClosedCases[i].scenario) != WS_SUCCESS) { + ret = WS_FATAL_ERROR; + } } - wsshd_getpwnam_cb = savedGetpwnam; - wsshd_getspnam_cb = savedGetspnam; return ret; } +/* Days since the epoch, the unit the shadow aging fields use. */ +static long wsshd_test_TodayDays(void) +{ + return (long)((word64)WTIME(NULL) / (24L * 60L * 60L)); +} + +/* Aging fields of an account that never expires. */ +static void wsshd_test_SetLiveAging(struct spwd* sp) +{ + sp->sp_lstchg = wsshd_test_TodayDays(); + sp->sp_min = -1; + sp->sp_max = -1; + sp->sp_warn = -1; + sp->sp_inact = -1; + sp->sp_expire = -1; +} + 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; + /* A zeroed spwd would read as "password change forced". */ + wsshd_test_SetLiveAging(&stub_shadow_test_sp); return &stub_shadow_test_sp; } -/* Copy-then-succeed path: a normal-length shadow hash copied into +/* Copy-then-compare 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) { + if (wsshd_test_LoadShadowHash(correctPw) == 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; + + ret = wsshd_test_CheckPasswordUnixCase(stub_getspnam_validHash, correctPw, + (word32)(sizeof(correctPw) - 1), WSSHD_AUTH_SUCCESS, + "correct password against copied shadow hash"); + if (ret == WS_SUCCESS) { + ret = wsshd_test_CheckPasswordUnixCase(stub_getspnam_validHash, + wrongPw, (word32)(sizeof(wrongPw) - 1), WSSHD_AUTH_FAILURE, + "wrong password against copied shadow hash"); } - WMEMCPY(stub_shadow_test_hash, hash, WSTRLEN(hash) + 1); +#else + (void)stub_getspnam_validHash; + Log(" Skipping test: password hash checking not compiled in.\n"); +#endif + return ret; +} - savedGetpwnam = wsshd_getpwnam_cb; - savedGetspnam = wsshd_getspnam_cb; - wsshd_getpwnam_cb = stub_getpwnam_shadowUser; - wsshd_getspnam_cb = stub_getspnam_validHash; +/* Account whose expiration date passed yesterday; nothing else about the + * entry can deny the login. */ +static struct spwd* stub_getspnam_expiredAccount(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; + wsshd_test_SetLiveAging(&stub_shadow_test_sp); + stub_shadow_test_sp.sp_expire = wsshd_test_TodayDays() - 1; + return &stub_shadow_test_sp; +} - 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"); +/* A correct password must still be denied once the aging fields expire the + * account. test_IsShadowExpired covers the individual aging rules. */ +static int test_CheckPasswordUnix_expired(void) +{ + int ret = WS_SUCCESS; +#if defined(WOLFSSH_HAVE_LIBCRYPT) || defined(WOLFSSH_HAVE_LIBLOGIN) + static const byte correctPw[] = "guessme"; + + if (wsshd_test_LoadShadowHash(correctPw) == 0) { + Log(" crypt() did not honor $6$ SHA-512, skipping.\n"); + return WS_SUCCESS; } - else { - Log(" FAILED.\n"); - ret = WS_FATAL_ERROR; + + ret = wsshd_test_CheckPasswordUnixCase(stub_getspnam_expiredAccount, + correctPw, (word32)(sizeof(correctPw) - 1), WSSHD_AUTH_FAILURE, + "correct password on an expired account"); +#else + (void)stub_getspnam_expiredAccount; + Log(" Skipping test: password hash checking not compiled in.\n"); +#endif + return ret; +} + +/* Aging rules, evaluated against a fixed day so the cases are stable. + * A negative "today" stands in for an unavailable system clock. */ +static const struct { + const char* scenario; + long lstchg; + long max; + long expire; + long today; + int expected; +} shadowExpiryCases[] = { + { "no aging configured", 19000, -1, -1, 20000, 0 }, + { "account expired yesterday", 19000, -1, 19999, 20000, 1 }, + { "account expires today", 19000, -1, 20000, 20000, 1 }, + { "account expires tomorrow", 19000, -1, 20001, 20000, 0 }, + { "account expires next year", 19000, -1, 20365, 20000, 0 }, + { "account expiry disabled", 19000, -1, -1, 20000, 0 }, + { "change forced at next login", 0, -1, -1, 20000, 1 }, + { "password aged out", 19900, 30, -1, 20000, 1 }, + { "password ages out today", 19970, 30, -1, 20000, 1 }, + { "password ages out tomorrow", 19971, 30, -1, 20000, 0 }, + { "password aging disabled", 1, -1, -1, 20000, 0 }, + { "last change unset", -1, 30, -1, 20000, 0 }, + { "no clock, no aging configured", 19000, -1, -1, -1, 0 }, + { "no clock, account expiry set", 19000, -1, 20365, -1, 1 }, + { "no clock, password aging set", 19000, 30, -1, -1, 1 } +}; + +/* Direct coverage of the shadow aging rules, independent of crypt(). */ +static int test_IsShadowExpired(void) +{ + int ret = WS_SUCCESS; + word32 i; + int rc; + struct spwd sp; + + for (i = 0; i < sizeof(shadowExpiryCases)/sizeof(shadowExpiryCases[0]); + i++) { + WMEMSET(&sp, 0, sizeof(sp)); + sp.sp_namp = (char*)"shadow_branch_test_user"; + sp.sp_pwdp = (char*)"$6$wolfsshtestsalt$hash"; + sp.sp_lstchg = shadowExpiryCases[i].lstchg; + sp.sp_min = -1; + sp.sp_max = shadowExpiryCases[i].max; + sp.sp_warn = -1; + sp.sp_inact = -1; + sp.sp_expire = shadowExpiryCases[i].expire; + + Log(" Testing IsShadowExpired: %s.", + shadowExpiryCases[i].scenario); + rc = IsShadowExpired(&sp, shadowExpiryCases[i].today); + if (rc == shadowExpiryCases[i].expected) { + Log(" PASSED.\n"); + } + else { + Log(" FAILED, expected %d got %d.\n", + shadowExpiryCases[i].expected, rc); + 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(" Testing IsShadowExpired: NULL entry."); + if (IsShadowExpired(NULL, 20000) == 0) { Log(" PASSED.\n"); } else { @@ -2223,11 +2322,6 @@ static int test_CheckPasswordUnix_shadowLookupSucceeds(void) } } - wsshd_getpwnam_cb = savedGetpwnam; - wsshd_getspnam_cb = savedGetspnam; -#else - Log(" Skipping test: password hash checking not compiled in.\n"); -#endif return ret; } @@ -6219,11 +6313,11 @@ const TEST_CASE testCases[] = { #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_failClosed), TEST_DECL(test_CheckPasswordUnix_shadowLookupSucceeds), TEST_DECL(test_CheckPasswordUnix_unknownUser), + TEST_DECL(test_IsShadowExpired), + TEST_DECL(test_CheckPasswordUnix_expired), TEST_DECL(test_DoFakePasswordCheck_pubkeyUnionSafety), TEST_DECL(test_AuthInit), TEST_DECL(test_AuthInit_degradedMode),