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
pull/1171/head
Yosuke Shimizu 2026-08-20 15:33:32 +09:00 committed by John Safranek
parent 3aefadc102
commit ad28e21221
3 changed files with 243 additions and 92 deletions

View File

@ -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);

View File

@ -150,6 +150,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. */

View File

@ -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,59 +2105,74 @@ 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)
{
const char* salt = "$6$wolfsshtestsalt$";
char* hash;
hash = crypt((const char*)pw, salt);
if (hash == NULL || hash[0] == '*' || WSTRLEN(hash) == 0 ||
WSTRNCMP(hash, "$6$", 3) != 0) {
return 0;
}
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 */
/* 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_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;
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;
}
/* 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)
/* Days since the epoch, the unit the shadow aging fields use. */
static long wsshd_test_TodayDays(void)
{
int ret = WS_SUCCESS;
int rc;
struct passwd* (*savedGetpwnam)(const char*);
struct spwd* (*savedGetspnam)(const char*);
static const byte pw[] = "guessme";
return (long)((word64)WTIME(NULL) / (24L * 60L * 60L));
}
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;
/* 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)
@ -2163,58 +2181,139 @@ static struct spwd* stub_getspnam_validHash(const char* 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;
}
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");
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");
}
else {
Log(" FAILED.\n");
ret = WS_FATAL_ERROR;
#else
(void)stub_getspnam_validHash;
Log(" Skipping test: password hash checking not compiled in.\n");
#endif
return ret;
}
/* 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;
}
/* 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;
}
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),