From 9b7f0ed49134f27439707e517eb7250477cd51b2 Mon Sep 17 00:00:00 2001 From: John Safranek Date: Fri, 30 Oct 2026 10:41:04 -0700 Subject: [PATCH 1/3] internal: refuse secret auth over the none cipher With WOLFSSH_ALLOW_NONE_CIPHER, a server whose NEWKEYS installed the none cipher in either direction answers password and keyboard-interactive requests, and an INFO_RESPONSE after a rekey to none, with USERAUTH_FAILURE before the userauth callback sees a secret, and leaves both methods out of the method list. - DoNewKeys() and SendNewKeys() record a none cipher per direction - a refused request or response clears authId, so a later INFO_RESPONSE is rejected - unit tests drive both methods with each direction cleartext, and a rekey to none with a prompt outstanding Issue: F-10578 --- src/internal.c | 46 ++++++++ tests/unit.c | 274 +++++++++++++++++++++++++++++++++++++++++++++ wolfssh/internal.h | 4 + 3 files changed, 324 insertions(+) diff --git a/src/internal.c b/src/internal.c index f142c0db9..18ee3b58f 100644 --- a/src/internal.c +++ b/src/internal.c @@ -9463,6 +9463,9 @@ static int DoNewKeys(WOLFSSH* ssh, byte* buf, word32 len, word32* idx) ssh->peerMacSz = ssh->handshake->peerMacSz; ssh->peerAeadMode = ssh->handshake->peerAeadMode; WMEMCPY(&ssh->peerKeys, &ssh->handshake->peerKeys, sizeof(Keys)); +#ifdef WOLFSSH_ALLOW_NONE_CIPHER + ssh->noneCipherIn = (ssh->peerEncryptId == ID_NONE); +#endif ret = CipherSetKey(&ssh->decryptCipher, ssh->peerEncryptId, 0, ssh->peerKeys.iv, @@ -10250,6 +10253,15 @@ static int DoUserAuthRequestNone(WOLFSSH* ssh, WS_UserAuthData* authData, #endif +#ifdef WOLFSSH_ALLOW_NONE_CIPHER +/* Either direction negotiated the "none" cipher, so a secret sent now is in + * the clear. */ +static int IsTransportCleartext(const WOLFSSH* ssh) +{ + return ssh->noneCipherIn || ssh->noneCipherOut; +} +#endif + #ifdef WOLFSSH_KEYBOARD_INTERACTIVE static int DoUserAuthInfoResponse(WOLFSSH* ssh, byte* buf, word32 len, word32* idx) @@ -10282,6 +10294,19 @@ static int DoUserAuthInfoResponse(WOLFSSH* ssh, ret = WS_FATAL_ERROR; } +#ifdef WOLFSSH_ALLOW_NONE_CIPHER + if ((ret == WS_SUCCESS) && IsTransportCleartext(ssh)) { + /* A rekey to the none cipher came with a prompt outstanding. Refused + * before the callback sees a cleartext answer, and the exchange is + * over, so a later INFO_RESPONSE is fatal. */ + WLOG(WS_LOG_DEBUG, "DUARKB: response refused over the none cipher"); + ssh->authId = ID_NONE; + ssh->kbSetupPending = 0; + authFailure = 1; + ret = WS_USER_AUTH_E; + } +#endif + if (ret == WS_SUCCESS) { /* Response arrived; the exchange is answered. */ ssh->kbSetupPending = 0; @@ -12221,6 +12246,19 @@ static int DoUserAuthRequest(WOLFSSH* ssh, * disconnect is already sent; don't dispatch this request. */ begin = len; } +#ifdef WOLFSSH_ALLOW_NONE_CIPHER + else if ((authNameId == ID_USERAUTH_PASSWORD + || authNameId == ID_USERAUTH_KEYBOARD) + && IsTransportCleartext(ssh)) { + /* Refused before the callback sees a cleartext secret. */ + WLOG(WS_LOG_DEBUG, "DUAR: %s refused over the none cipher", + IdToName(authNameId)); + /* No exchange started; a later INFO_RESPONSE must not run. */ + ssh->authId = ID_NONE; + ret = SendUserAuthFailureCount(ssh, 0, 1); + begin = len; + } +#endif else if (authNameId == ID_USERAUTH_PASSWORD) ret = DoUserAuthRequestPassword(ssh, &authData, buf, len, &begin); #ifdef WOLFSSH_KEYBOARD_INTERACTIVE @@ -19468,6 +19506,9 @@ int SendNewKeys(WOLFSSH* ssh) ssh->macId = ssh->handshake->macId; ssh->aeadMode = ssh->handshake->aeadMode; WMEMCPY(&ssh->keys, &ssh->handshake->keys, sizeof(Keys)); +#ifdef WOLFSSH_ALLOW_NONE_CIPHER + ssh->noneCipherOut = (ssh->encryptId == ID_NONE); +#endif ret = CipherSetKey(&ssh->encryptCipher, ssh->encryptId, 1, ssh->keys.iv, @@ -23275,6 +23316,11 @@ static int GetAllowedAuth(WOLFSSH* ssh, char* authStr) if (ssh->ctx && ssh->ctx->userAuthTypesCb) { typeAllowed = ssh->ctx->userAuthTypesCb(ssh, ssh->userAuthCtx); } +#ifdef WOLFSSH_ALLOW_NONE_CIPHER + if (IsTransportCleartext(ssh)) { + typeAllowed &= ~(WOLFSSH_USERAUTH_PASSWORD | WOLFSSH_USERAUTH_KEYBOARD); + } +#endif if (typeAllowed & WOLFSSH_USERAUTH_PUBLICKEY) { WSTRNCAT(authStr, "publickey,", MAX_AUTH_STRING-1); } diff --git a/tests/unit.c b/tests/unit.c index ffc3517d6..e43200359 100644 --- a/tests/unit.c +++ b/tests/unit.c @@ -13255,6 +13255,267 @@ static int test_UserAuthRejectedSendsFailure(void) } +#ifdef WOLFSSH_ALLOW_NONE_CIPHER +static int s_noneCipherAuthCalls = 0; + +/* Counts the calls a cleartext secret would reach, and fails them. */ +static int UnitAuthCountFail(byte authType, WS_UserAuthData* authData, + void* ctx) +{ + (void)authType; + (void)authData; + (void)ctx; + s_noneCipherAuthCalls++; + return WOLFSSH_USERAUTH_INVALID_PASSWORD; +} + +/* Returns 1 when the captured USERAUTH_FAILURE name-list holds name. */ +static int CaptureFailureListHas(const char* name) +{ + word32 off = LENGTH_SZ + PAD_LENGTH_SZ + MSG_ID_SZ; + word32 listSz, nameSz, i, start; + + if (s_authSvcCaptureSz < off + LENGTH_SZ) + return 0; + listSz = ((word32)s_authSvcCapture[off] << 24) | + ((word32)s_authSvcCapture[off + 1] << 16) | + ((word32)s_authSvcCapture[off + 2] << 8) | + ((word32)s_authSvcCapture[off + 3]); + off += LENGTH_SZ; + if (s_authSvcCaptureSz < off + listSz) + return 0; + + nameSz = (word32)WSTRLEN(name); + start = 0; + for (i = 0; i <= listSz; i++) { + if (i == listSz || s_authSvcCapture[off + i] == ',') { + if (i - start == nameSz && + WMEMCMP(s_authSvcCapture + off + start, name, nameSz) == 0) + return 1; + start = i + 1; + } + } + return 0; +} + +/* Over the "none" cipher, in either direction, the server refuses password + * and keyboard-interactive before the callback sees a secret, and leaves + * both out of the method list it sends back. */ +static int test_UserAuthNoneCipherRefusesSecrets(void) +{ + byte request[128]; + word32 requestSz, idx; + word32 i, m; + WOLFSSH_CTX* ctx; + WOLFSSH* ssh; + int ret; + int result = 0; + + /* i: 0 inbound only, 1 outbound only, 2 both */ + for (i = 0; i < 3 && result == 0; i++) { + for (m = 0; m < 2 && result == 0; m++) { + if (m == 0) { + requestSz = BuildAuthPwRequest(request, + (word32)sizeof(request)); + } + else { +#ifdef WOLFSSH_KEYBOARD_INTERACTIVE + requestSz = BuildAuthMethodRequest(request, + (word32)sizeof(request), "ssh-connection", + "keyboard-interactive"); +#else + continue; +#endif + } + if (requestSz == 0) + return -1500; + + ctx = wolfSSH_CTX_new(WOLFSSH_ENDPOINT_SERVER, NULL); + if (ctx == NULL) + return -1501; + wolfSSH_SetUserAuth(ctx, UnitAuthCountFail); + wolfSSH_SetIOSend(ctx, CaptureIoSendAuthSvc); + ssh = wolfSSH_new(ctx); + if (ssh == NULL) { + wolfSSH_CTX_free(ctx); + return -1502; + } + + /* As DoNewKeys() and SendNewKeys() leave them. */ + ssh->noneCipherIn = (i != 1); + ssh->noneCipherOut = (i != 0); + s_noneCipherAuthCalls = 0; + s_authSvcCaptureSz = 0; + s_authSvcSendCount = 0; + idx = 0; + + ret = wolfSSH_TestDoUserAuthRequest(ssh, request, requestSz, + &idx); + if (ret != WS_SUCCESS) { + printf("NoneCipherRefusesSecrets[%u,%u]: ret=%d\n", + i, m, ret); + result = -1503; + } + else if (s_noneCipherAuthCalls != 0) { + printf("NoneCipherRefusesSecrets[%u,%u]: callback ran\n", + i, m); + result = -1504; + } + else if (s_authSvcSendCount != 1 || + CaptureMsgId(s_authSvcCapture, s_authSvcCaptureSz) + != MSGID_USERAUTH_FAILURE) { + printf("NoneCipherRefusesSecrets[%u,%u]: no FAILURE sent\n", + i, m); + result = -1505; + } + else if (CaptureFailureListHas("password") || + CaptureFailureListHas("keyboard-interactive")) { + printf("NoneCipherRefusesSecrets[%u,%u]: list offers a" + " secret method\n", i, m); + result = -1506; + } + else if (idx != requestSz) { + result = -1507; + } +#ifdef WOLFSSH_KEYBOARD_INTERACTIVE + else if (m == 1) { + /* A zero-answer INFO_RESPONSE matches the never-set prompt + * count; the refused request must not leave it a live + * exchange that reaches the callback. */ + byte infoRsp[UINT32_SZ]; + + PutU32BE(infoRsp, 0); + idx = 0; + ret = wolfSSH_TestDoUserAuthInfoResponse(ssh, infoRsp, + (word32)sizeof(infoRsp), &idx); + if (ret == WS_SUCCESS || s_noneCipherAuthCalls != 0) { + printf("NoneCipherRefusesSecrets[%u,%u]: INFO_RESPONSE" + " after refusal ret=%d, callback calls=%d\n", + i, m, ret, s_noneCipherAuthCalls); + result = -1508; + } + } +#endif + + wolfSSH_free(ssh); + wolfSSH_CTX_free(ctx); + } + } + + return result; +} + +#ifdef WOLFSSH_KEYBOARD_INTERACTIVE +static word32 s_noneKbPromptLen = 9; +static byte s_noneKbPromptEcho = 0; +static byte* s_noneKbPrompts[1] = { (byte*)"Password:" }; + +/* Sets up one prompt, and counts and fails each answer to it. */ +static int UnitAuthKbCountFail(byte authType, WS_UserAuthData* authData, + void* ctx) +{ + (void)ctx; + if (authType == WOLFSSH_USERAUTH_KEYBOARD_SETUP) { + authData->sf.keyboard.promptCount = 1; + authData->sf.keyboard.promptLengths = &s_noneKbPromptLen; + authData->sf.keyboard.prompts = s_noneKbPrompts; + authData->sf.keyboard.promptEcho = &s_noneKbPromptEcho; + return WOLFSSH_USERAUTH_SUCCESS; + } + s_noneCipherAuthCalls++; + return WOLFSSH_USERAUTH_INVALID_PASSWORD; +} + +/* A keyboard-interactive request starts under encryption, then a rekey + * installs the "none" cipher with the prompt outstanding. The cleartext + * answer is refused before the callback, and a second one is fatal. */ +static int test_UserAuthNoneCipherRekeyMidKb(void) +{ + byte request[128]; + byte infoRsp[UINT32_SZ * 2 + 1]; + word32 requestSz, idx; + word32 i; + WOLFSSH_CTX* ctx; + WOLFSSH* ssh; + int ret; + int result = 0; + + requestSz = BuildAuthMethodRequest(request, (word32)sizeof(request), + "ssh-connection", "keyboard-interactive"); + if (requestSz == 0) + return -1510; + PutU32BE(infoRsp, 1); + PutU32BE(infoRsp + UINT32_SZ, 1); + infoRsp[UINT32_SZ * 2] = 'x'; + + /* i: 0 inbound only, 1 outbound only, 2 both */ + for (i = 0; i < 3 && result == 0; i++) { + ctx = wolfSSH_CTX_new(WOLFSSH_ENDPOINT_SERVER, NULL); + if (ctx == NULL) + return -1511; + wolfSSH_SetUserAuth(ctx, UnitAuthKbCountFail); + wolfSSH_SetIOSend(ctx, CaptureIoSendAuthSvc); + ssh = wolfSSH_new(ctx); + if (ssh == NULL) { + wolfSSH_CTX_free(ctx); + return -1512; + } + s_noneCipherAuthCalls = 0; + + idx = 0; + ret = wolfSSH_TestDoUserAuthRequest(ssh, request, requestSz, &idx); + if (ret != WS_SUCCESS || ssh->authId != ID_USERAUTH_KEYBOARD) { + printf("NoneCipherRekeyMidKb[%u]: request ret=%d\n", i, ret); + result = -1513; + } + + if (result == 0) { + /* As DoNewKeys() and SendNewKeys() leave them after a rekey. */ + ssh->noneCipherIn = (i != 1); + ssh->noneCipherOut = (i != 0); + s_authSvcCaptureSz = 0; + s_authSvcSendCount = 0; + + idx = 0; + ret = wolfSSH_TestDoUserAuthInfoResponse(ssh, infoRsp, + (word32)sizeof(infoRsp), &idx); + if (ret != WS_SUCCESS || s_noneCipherAuthCalls != 0) { + printf("NoneCipherRekeyMidKb[%u]: response ret=%d, callback" + " calls=%d\n", i, ret, s_noneCipherAuthCalls); + result = -1514; + } + else if (s_authSvcSendCount != 1 || + CaptureMsgId(s_authSvcCapture, s_authSvcCaptureSz) + != MSGID_USERAUTH_FAILURE || + CaptureFailureListHas("password") || + CaptureFailureListHas("keyboard-interactive")) { + printf("NoneCipherRekeyMidKb[%u]: no FAILURE without the" + " secret methods\n", i); + result = -1515; + } + } + + if (result == 0) { + idx = 0; + ret = wolfSSH_TestDoUserAuthInfoResponse(ssh, infoRsp, + (word32)sizeof(infoRsp), &idx); + if (ret != WS_FATAL_ERROR || s_noneCipherAuthCalls != 0) { + printf("NoneCipherRekeyMidKb[%u]: second response ret=%d\n", + i, ret); + result = -1516; + } + } + + wolfSSH_free(ssh); + wolfSSH_CTX_free(ctx); + } + + return result; +} +#endif /* WOLFSSH_KEYBOARD_INTERACTIVE */ +#endif /* WOLFSSH_ALLOW_NONE_CIPHER */ + + /* Verify DoUserAuthRequest rejects non-"ssh-connection" service names per * RFC 4252 Section 5. For each case we assert: * 1. ret == WS_SUCCESS (connection stays open for retry) @@ -26767,6 +27028,19 @@ int wolfSSH_UnitTest(int argc, char** argv) (unitResult == 0 ? "SUCCESS" : "FAILED")); testResult = testResult || unitResult; +#ifdef WOLFSSH_ALLOW_NONE_CIPHER + unitResult = test_UserAuthNoneCipherRefusesSecrets(); + printf("UserAuthNoneCipherRefusesSecrets: %s\n", + (unitResult == 0 ? "SUCCESS" : "FAILED")); + testResult = testResult || unitResult; +#ifdef WOLFSSH_KEYBOARD_INTERACTIVE + unitResult = test_UserAuthNoneCipherRekeyMidKb(); + printf("UserAuthNoneCipherRekeyMidKb: %s\n", + (unitResult == 0 ? "SUCCESS" : "FAILED")); + testResult = testResult || unitResult; +#endif +#endif + #if !defined(WOLFSSH_NO_ECDSA_SHA2_NISTP256) && \ !defined(WOLFSSH_NO_ECDSA_SHA2_NISTP384) unitResult = test_EccUserAuthCurveMismatch(); diff --git a/wolfssh/internal.h b/wolfssh/internal.h index d9c550995..90bd4d351 100644 --- a/wolfssh/internal.h +++ b/wolfssh/internal.h @@ -1362,6 +1362,10 @@ struct WOLFSSH { byte peerMacId; byte peerMacSz; byte peerAeadMode; +#ifdef WOLFSSH_ALLOW_NONE_CIPHER + byte noneCipherIn; /* NEWKEYS installed the "none" cipher inbound */ + byte noneCipherOut; /* NEWKEYS installed the "none" cipher outbound */ +#endif #ifndef WOLFSSH_NO_DH word32 primeGroupSz; #endif From 56e6c22b331b31961460472de89fd06d8f77190d Mon Sep 17 00:00:00 2001 From: John Safranek Date: Fri, 30 Oct 2026 10:41:05 -0700 Subject: [PATCH 2/3] internal: pad client userauth secrets to 64 bytes The client pads a password USERAUTH_REQUEST and a keyboard-interactive INFO_RESPONSE up to a multiple of 64 bytes, as OpenSSH does, so the packet length no longer tracks the secret's length to the cipher block. - BundlePacketPad() adds whole blocks of padding up to a bucket - regress test checks 1 to 64 byte secrets over the none cipher, AES-CTR with HMAC, and AES-GCM Issue: F-13390 --- src/internal.c | 41 ++++++- tests/regress.c | 297 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 332 insertions(+), 6 deletions(-) diff --git a/src/internal.c b/src/internal.c index 18ee3b58f..0efe9bc28 100644 --- a/src/internal.c +++ b/src/internal.c @@ -15648,7 +15648,15 @@ static int PreparePacket(WOLFSSH* ssh, word32 payloadSz) return ret; } -static int BundlePacket(WOLFSSH* ssh) +/* Packets carrying a userauth secret pad up to a multiple of this, so their + * length hides the secret's length. OpenSSH uses 64. */ +#ifndef WOLFSSH_USERAUTH_PAD_BUCKET + #define WOLFSSH_USERAUTH_PAD_BUCKET 64 +#endif + +/* padBucket: 0 for minimum padding, else pad the packet up to a multiple of + * it. PreparePacket() must have reserved padBucket extra bytes. */ +static int BundlePacketPad(WOLFSSH* ssh, word32 padBucket) { byte* output = NULL; word32 idx = 0; @@ -15676,6 +15684,19 @@ static int BundlePacket(WOLFSSH* ssh) if (paddingSz < MIN_PAD_LENGTH) paddingSz += ssh->blockSz; + if (padBucket > 0) { + word32 alignSz, extraSz; + + /* Whole blocks, so the extra keeps the block alignment. */ + padBucket = ((padBucket + ssh->blockSz - 1) / ssh->blockSz) + * ssh->blockSz; + alignSz = (ssh->aeadMode ? 0 : LENGTH_SZ) + PAD_LENGTH_SZ + + payloadSz + paddingSz; + extraSz = (padBucket - (alignSz % padBucket)) % padBucket; + if (paddingSz + extraSz <= 0xFF) + paddingSz += (byte)extraSz; + } + packetSz = PAD_LENGTH_SZ + payloadSz + paddingSz; /* fill in the packetSz, paddingSz */ @@ -15762,6 +15783,11 @@ static int BundlePacket(WOLFSSH* ssh) return ret; } +static int BundlePacket(WOLFSSH* ssh) +{ + return BundlePacketPad(ssh, 0); +} + static void PurgePacket(WOLFSSH* ssh) { @@ -23037,7 +23063,7 @@ int SendUserAuthKeyboardResponse(WOLFSSH* ssh) ret = PrepareUserAuthResponseKeyboard(ssh, &payloadSz, &authData); } if (ret == WS_SUCCESS) { - ret = PreparePacket(ssh, payloadSz); + ret = PreparePacket(ssh, payloadSz + WOLFSSH_USERAUTH_PAD_BUCKET); } if (ret == WS_SUCCESS) { @@ -23050,7 +23076,7 @@ int SendUserAuthKeyboardResponse(WOLFSSH* ssh) if (ret == WS_SUCCESS) { ssh->outputBuffer.length = idx; - ret = BundlePacket(ssh); + ret = BundlePacketPad(ssh, WOLFSSH_USERAUTH_PAD_BUCKET); } if (ret == WS_SUCCESS) { @@ -23082,6 +23108,7 @@ int SendUserAuthRequest(WOLFSSH* ssh, byte authType, int addSig) WS_KeySignature *keySig_ptr = NULL; byte authId = ID_NONE; int delivered = 0; + word32 padBucket = 0; WOLFSSH_UNUSED(addSig); @@ -23188,8 +23215,10 @@ int SendUserAuthRequest(WOLFSSH* ssh, byte authType, int addSig) payloadSz = MSG_ID_SZ + (LENGTH_SZ * 3) + ssh->userNameSz + serviceNameSz + authNameSz; - if (authId == ID_USERAUTH_PASSWORD) + if (authId == ID_USERAUTH_PASSWORD) { + padBucket = WOLFSSH_USERAUTH_PAD_BUCKET; ret = PrepareUserAuthRequestPassword(ssh, &payloadSz, &authData); + } else if (authId == ID_USERAUTH_PUBLICKEY && !ssh->userAuthPkDone) { authData.sf.publicKey.hasSignature = 1; ssh->userAuthPkDone = 1; @@ -23205,7 +23234,7 @@ int SendUserAuthRequest(WOLFSSH* ssh, byte authType, int addSig) } if (ret == WS_SUCCESS) - ret = PreparePacket(ssh, payloadSz); + ret = PreparePacket(ssh, payloadSz + padBucket); if (ret == WS_SUCCESS) { byte* sigStart; @@ -23256,7 +23285,7 @@ int SendUserAuthRequest(WOLFSSH* ssh, byte authType, int addSig) if (ret == WS_SUCCESS) { ssh->outputBuffer.length = idx; - ret = BundlePacket(ssh); + ret = BundlePacketPad(ssh, padBucket); } } diff --git a/tests/regress.c b/tests/regress.c index ad9b79a94..82a54b4b5 100644 --- a/tests/regress.c +++ b/tests/regress.c @@ -43,6 +43,7 @@ #include #include +#include #include #include #include @@ -4361,6 +4362,301 @@ static void TestClientInfoRequestAfterHighwaterErrorAnswered(void) #endif /* WOLFSSH_KEYBOARD_INTERACTIVE */ +/* The secret length the padding callback answers with. */ +static word32 padSecretSz; + +/* Answers a password, or a single keyboard prompt, with padSecretSz bytes. */ +static int PadSecretUserAuthCb(byte authType, WS_UserAuthData* authData, + void* ctx) +{ + static byte secret[64]; +#ifdef WOLFSSH_KEYBOARD_INTERACTIVE + static byte* responses[1]; + static word32 responseLengths[1]; +#endif + + (void)ctx; + WMEMSET(secret, 'p', sizeof(secret)); + if (padSecretSz > sizeof(secret)) + return WOLFSSH_USERAUTH_FAILURE; + + if (authType == WOLFSSH_USERAUTH_PASSWORD) { + authData->sf.password.password = secret; + authData->sf.password.passwordSz = padSecretSz; + return WOLFSSH_USERAUTH_SUCCESS; + } +#ifdef WOLFSSH_KEYBOARD_INTERACTIVE + if (authType == WOLFSSH_USERAUTH_KEYBOARD && + authData->sf.keyboard.promptCount == 1) { + responses[0] = secret; + responseLengths[0] = padSecretSz; + authData->sf.keyboard.responseCount = 1; + authData->sf.keyboard.responses = responses; + authData->sf.keyboard.responseLengths = responseLengths; + return WOLFSSH_USERAUTH_SUCCESS; + } +#endif + return WOLFSSH_USERAUTH_INVALID_AUTHTYPE; +} + +/* Transports the padding test sends a secret over. */ +enum { + PAD_XPORT_NONE = 0, /* null cipher, no MAC: a fresh session */ + PAD_XPORT_CTR, /* aes256-ctr with hmac-sha2-256 */ + PAD_XPORT_GCM /* aes256-gcm, AEAD: length field in clear */ +}; + +#if !defined(WOLFSSH_NO_AES_CTR) && !defined(WOLFSSH_NO_HMAC_SHA2_256) + #define PAD_TEST_CTR +#endif +#if !defined(WOLFSSH_NO_AES_GCM) && !defined(WOLFSSH_NO_AEAD) + #define PAD_TEST_GCM +#endif + +#if defined(PAD_TEST_CTR) || defined(PAD_TEST_GCM) +static const byte padTestKey[AES_256_KEY_SIZE] = { + 0x5A, 0x5A, 0x5A, 0x5A, 0x5A, 0x5A, 0x5A, 0x5A, + 0x5A, 0x5A, 0x5A, 0x5A, 0x5A, 0x5A, 0x5A, 0x5A, + 0x5A, 0x5A, 0x5A, 0x5A, 0x5A, 0x5A, 0x5A, 0x5A, + 0x5A, 0x5A, 0x5A, 0x5A, 0x5A, 0x5A, 0x5A, 0x5A +}; +static const byte padTestIv[AES_BLOCK_SIZE] = { + 0x31, 0x31, 0x31, 0x31, 0x31, 0x31, 0x31, 0x31, + 0x31, 0x31, 0x31, 0x31, 0x31, 0x31, 0x31, 0x31 +}; +#ifdef PAD_TEST_CTR +static const byte padTestMacKey[WC_SHA256_DIGEST_SIZE] = { + 0x4B, 0x4B, 0x4B, 0x4B, 0x4B, 0x4B, 0x4B, 0x4B, + 0x4B, 0x4B, 0x4B, 0x4B, 0x4B, 0x4B, 0x4B, 0x4B, + 0x4B, 0x4B, 0x4B, 0x4B, 0x4B, 0x4B, 0x4B, 0x4B, + 0x4B, 0x4B, 0x4B, 0x4B, 0x4B, 0x4B, 0x4B, 0x4B +}; +#endif + +/* Sets the session's outbound direction as key exchange would leave it. */ +static void SetPadTestTransport(WOLFSSH* ssh, int xport) +{ + AssertIntEQ(wc_AesInit(&ssh->encryptCipher.aes, ssh->ctx->heap, + INVALID_DEVID), 0); + ssh->encryptCipher.isInit = 1; + ssh->blockSz = AES_BLOCK_SIZE; +#ifdef PAD_TEST_CTR + if (xport == PAD_XPORT_CTR) { + ssh->encryptCipher.cipherType = ID_AES256_CTR; + AssertIntEQ(wc_AesSetKey(&ssh->encryptCipher.aes, padTestKey, + sizeof(padTestKey), padTestIv, AES_ENCRYPTION), 0); + ssh->encryptId = ID_AES256_CTR; + ssh->aeadMode = 0; + ssh->macId = ID_HMAC_SHA2_256; + ssh->macSz = WC_SHA256_DIGEST_SIZE; + WMEMCPY(ssh->keys.macKey, padTestMacKey, sizeof(padTestMacKey)); + ssh->keys.macKeySz = sizeof(padTestMacKey); + } +#endif +#ifdef PAD_TEST_GCM + if (xport == PAD_XPORT_GCM) { + ssh->encryptCipher.cipherType = ID_AES256_GCM; + AssertIntEQ(wc_AesGcmSetKey(&ssh->encryptCipher.aes, padTestKey, + sizeof(padTestKey)), 0); + ssh->encryptId = ID_AES256_GCM; + ssh->aeadMode = 1; + ssh->macId = ID_NONE; + ssh->macSz = AES_BLOCK_SIZE; + WMEMCPY(ssh->keys.iv, padTestIv, AEAD_NONCE_SZ); + ssh->keys.ivSz = AEAD_NONCE_SZ; + } +#endif +} + +/* Decrypts and authenticates the one packet in out, sent with sequence + * number seq, into plain. Returns the size of the span the sender aligns to + * the cipher block: the whole packet without its MAC, or for AEAD without + * the clear length field either. */ +static word32 OpenPadTestPacket(int xport, word32 seq, const byte* out, + word32 outSz, byte* plain, word32 plainSz) +{ + word32 packetLen = 0; + word32 alignSz = 0; + Aes aes; + + AssertIntEQ(wc_AesInit(&aes, NULL, INVALID_DEVID), 0); +#ifdef PAD_TEST_CTR + if (xport == PAD_XPORT_CTR) { + Hmac hmac; + byte flatSeq[UINT32_SZ]; + byte mac[WC_SHA256_DIGEST_SIZE]; + + AssertTrue(outSz > WC_SHA256_DIGEST_SIZE); + alignSz = outSz - WC_SHA256_DIGEST_SIZE; + AssertTrue(alignSz <= plainSz); + AssertIntEQ(wc_AesSetKey(&aes, padTestKey, sizeof(padTestKey), + padTestIv, AES_ENCRYPTION), 0); + AssertIntEQ(wc_AesCtrEncrypt(&aes, plain, out, alignSz), 0); + packetLen = ReadUint32(plain); + AssertIntEQ(UINT32_SZ + packetLen, alignSz); + + flatSeq[0] = (byte)(seq >> 24); + flatSeq[1] = (byte)(seq >> 16); + flatSeq[2] = (byte)(seq >> 8); + flatSeq[3] = (byte)seq; + AssertIntEQ(wc_HmacInit(&hmac, NULL, INVALID_DEVID), 0); + AssertIntEQ(wc_HmacSetKey(&hmac, WC_SHA256, padTestMacKey, + sizeof(padTestMacKey)), 0); + AssertIntEQ(wc_HmacUpdate(&hmac, flatSeq, sizeof(flatSeq)), 0); + AssertIntEQ(wc_HmacUpdate(&hmac, plain, alignSz), 0); + AssertIntEQ(wc_HmacFinal(&hmac, mac), 0); + wc_HmacFree(&hmac); + AssertIntEQ(WMEMCMP(mac, out + alignSz, sizeof(mac)), 0); + } +#endif +#ifdef PAD_TEST_GCM + if (xport == PAD_XPORT_GCM) { + byte iv[AEAD_NONCE_SZ]; + + (void)seq; + packetLen = ReadUint32(out); + AssertIntEQ(UINT32_SZ + packetLen + AES_BLOCK_SIZE, outSz); + AssertTrue(UINT32_SZ + packetLen <= plainSz); + alignSz = packetLen; + /* The first packet under these keys uses the initial nonce. */ + WMEMCPY(iv, padTestIv, sizeof(iv)); + WMEMCPY(plain, out, UINT32_SZ); + AssertIntEQ(wc_AesGcmSetKey(&aes, padTestKey, sizeof(padTestKey)), 0); + AssertIntEQ(wc_AesGcmDecrypt(&aes, plain + UINT32_SZ, + out + UINT32_SZ, packetLen, iv, sizeof(iv), + out + UINT32_SZ + packetLen, AES_BLOCK_SIZE, + out, UINT32_SZ), 0); + } +#endif + wc_AesFree(&aes); + (void)packetLen; + return alignSz; +} +#endif /* PAD_TEST_CTR || PAD_TEST_GCM */ + +/* Sends the client's password request, or its keyboard INFO_RESPONSE, with a + * secret of secretSz bytes over the transport xport. Checks the packet opens + * to the secret, with padding in bounds, and returns the size of the span + * the sender aligns: the packet less its MAC, and for AEAD less its length + * field too. */ +static word32 SecretPacketSz(int xport, int keyboard, word32 secretSz) +{ + ChannelOpenHarness harness; + byte plain[512]; + word32 sz, seq, packetLen, payloadEnd, i; + byte padLen; + + InitChannelOpenHarnessClient(&harness, NULL, 0); + wolfSSH_SetUserAuth(harness.ctx, PadSecretUserAuthCb); + AssertIntEQ(wolfSSH_SetUsername(harness.ssh, "user"), WS_SUCCESS); + padSecretSz = secretSz; +#if defined(PAD_TEST_CTR) || defined(PAD_TEST_GCM) + if (xport != PAD_XPORT_NONE) + SetPadTestTransport(harness.ssh, xport); +#endif + seq = harness.ssh->seq; + + if (!keyboard) { + AssertIntEQ(SendUserAuthRequest(harness.ssh, + WOLFSSH_USERAUTH_PASSWORD, 0), WS_SUCCESS); + } +#ifdef WOLFSSH_KEYBOARD_INTERACTIVE + else { + void* heap = harness.ctx->heap; + byte** prompts; + + /* As DoUserAuthInfoRequest() leaves one hidden prompt. */ + prompts = (byte**)WMALLOC(sizeof(byte*), heap, DYNTYPE_STRING); + AssertNotNull(prompts); + prompts[0] = (byte*)WMALLOC(sizeof("Password: "), heap, + DYNTYPE_STRING); + AssertNotNull(prompts[0]); + WMEMCPY(prompts[0], "Password: ", sizeof("Password: ")); + harness.ssh->kbAuth.promptEcho = (byte*)WMALLOC(1, heap, + DYNTYPE_STRING); + AssertNotNull(harness.ssh->kbAuth.promptEcho); + harness.ssh->kbAuth.promptEcho[0] = 0; + harness.ssh->kbAuth.prompts = prompts; + harness.ssh->kbAuth.promptCount = 1; + + AssertIntEQ(SendUserAuthKeyboardResponse(harness.ssh), WS_SUCCESS); + } +#endif + + if (xport == PAD_XPORT_NONE) { + /* One packet, no MAC. */ + AssertIntEQ(NextPacketOffset(harness.io.out, harness.io.outSz), + harness.io.outSz); + AssertTrue(harness.io.outSz <= sizeof(plain)); + WMEMCPY(plain, harness.io.out, harness.io.outSz); + sz = harness.io.outSz; + } + else { +#if defined(PAD_TEST_CTR) || defined(PAD_TEST_GCM) + sz = OpenPadTestPacket(xport, seq, harness.io.out, harness.io.outSz, + plain, (word32)sizeof(plain)); +#else + sz = 0; + AssertTrue(0); +#endif + } + (void)seq; + + /* The secret is the payload's last string, right before the padding. */ + packetLen = ReadUint32(plain); + padLen = plain[UINT32_SZ]; + AssertTrue(padLen >= MIN_PAD_LENGTH); + AssertTrue(padLen < MIN_PAD_LENGTH + AES_BLOCK_SIZE + 64); + AssertTrue(packetLen > (word32)PAD_LENGTH_SZ + padLen + UINT32_SZ + + secretSz); + AssertIntEQ(plain[UINT32_SZ + PAD_LENGTH_SZ], keyboard ? + MSGID_USERAUTH_INFO_RESPONSE : MSGID_USERAUTH_REQUEST); + payloadEnd = UINT32_SZ + packetLen - padLen; + AssertIntEQ(ReadUint32(plain + payloadEnd - secretSz - UINT32_SZ), + secretSz); + for (i = payloadEnd - secretSz; i < payloadEnd; i++) + AssertIntEQ(plain[i], 'p'); + + FreeChannelOpenHarness(&harness); + return sz; +} + +/* The client pads a password request and a keyboard INFO_RESPONSE up to a + * 64-byte multiple, as OpenSSH does, so secrets of nearby lengths send + * packets of the same size. Under AEAD the clear length field is outside + * the alignment, so the bucket covers the rest of the packet. */ +static void TestClientUserAuthSecretPadding(void) +{ + int keyboard, xport; + word32 secretSz; + + for (xport = PAD_XPORT_NONE; xport <= PAD_XPORT_GCM; xport++) { +#ifndef PAD_TEST_CTR + if (xport == PAD_XPORT_CTR) + continue; +#endif +#ifndef PAD_TEST_GCM + if (xport == PAD_XPORT_GCM) + continue; +#endif + for (keyboard = 0; keyboard < 2; keyboard++) { +#ifndef WOLFSSH_KEYBOARD_INTERACTIVE + if (keyboard) + break; +#endif + for (secretSz = 1; secretSz <= 64; secretSz++) { + AssertIntEQ(SecretPacketSz(xport, keyboard, secretSz) % 64, + 0); + } + /* One and eight bytes land in different cipher blocks but the + * same bucket. */ + AssertIntEQ(SecretPacketSz(xport, keyboard, 1), + SecretPacketSz(xport, keyboard, 8)); + } + } +} + + /* Drive the whole receive path with a CHANNEL_OPEN sent from a pre-auth * connectState: no channel created, and the only thing sent back is the * disconnect RFC 4252 section 6 asks for. The connectState gate does the @@ -21772,6 +22068,7 @@ int main(int argc, char** argv) TestClientInfoRequestAfterBlockedRequestAnswered(); TestClientInfoRequestAfterHighwaterErrorAnswered(); #endif + TestClientUserAuthSecretPadding(); TestClientBannerBehindServiceAccept(); TestChannelOpenRejectedBeforeKex(CONNECT_CLIENT_KEXINIT_SENT); TestChannelOpenRejectedBeforeKex(CONNECT_CLIENT_KEXDH_INIT_SENT); From 599807dee1a61bedc9624bcf010eec87b379eeb5 Mon Sep 17 00:00:00 2001 From: John Safranek Date: Fri, 30 Oct 2026 10:41:05 -0700 Subject: [PATCH 3/3] wolfsshd: hold failed userauth to a minimum time DefaultUserAuth() holds a failed attempt to a per-user minimum, modeled on OpenSSH sshd: 5 ms plus up to 4.29 ms from a keyed hash of the username, doubled while the time already spent exceeds it, and skipped past 5 s. It sleeps and rechecks until the minimum has passed. The library itself stays non-blocking. - wolfSSHD_AuthInit() draws the per-daemon key before any fork - the hash is SHA-256, else SHA-512 or SHA-384; with none, the floor - Windows times the wait with QueryPerformanceCounter - tests for the delay range, the doubling, an early-waking sleep, and a failing request Issue: F-11938 --- apps/wolfsshd/auth.c | 148 +++++++++++++++++++++++ apps/wolfsshd/auth.h | 6 + apps/wolfsshd/test/test_configuration.c | 152 ++++++++++++++++++++++++ 3 files changed, 306 insertions(+) diff --git a/apps/wolfsshd/auth.c b/apps/wolfsshd/auth.c index 217d0aa46..0b5e83867 100644 --- a/apps/wolfsshd/auth.c +++ b/apps/wolfsshd/auth.c @@ -54,6 +54,8 @@ #include #include #include +#include +#include #if defined(WOLFSSH_CERTS) && (defined(WOLFSSL_FPKI) || defined(_WIN32)) /* Used to bind a client certificate to the requested user name: by UPN @@ -80,6 +82,7 @@ #include #include #include +#include #ifdef WOLFSSH_OSSH_CERTS #include /* in6_addr, AF_INET6 */ #include /* inet_pton for source-address matching */ @@ -611,6 +614,37 @@ int wolfSSHD_GetCachedFakeHashCountForTest(void) #endif /* WOLFSSHD_UNIT_TEST */ #endif /* HAVE_SHADOW */ +/* Failed userauth replies take at least a per-user minimum, as OpenSSH sshd + * does: the floor plus 0 to 4.29 ms from a keyed hash of the username. */ +#ifndef WOLFSSHD_MIN_FAIL_DELAY_US + #define WOLFSSHD_MIN_FAIL_DELAY_US 5000 +#endif +#ifndef WOLFSSHD_MAX_FAIL_DELAY_US + #define WOLFSSHD_MAX_FAIL_DELAY_US 5000000 +#endif + +/* Keys the per-user delay. Written once by wolfSSHD_AuthInit() before any + * connection is forked. */ +static byte failDelaySecret[16]; + +static void InitFailDelaySecret(void) +{ + WC_RNG rng; + int ret; + + ret = wc_InitRng(&rng); + if (ret == 0) { + ret = wc_RNG_GenerateBlock(&rng, failDelaySecret, + sizeof(failDelaySecret)); + wc_FreeRng(&rng); + } + if (ret != 0) { + /* The delay still holds, but each user's share is predictable. */ + wolfSSH_Log(WS_LOG_ERROR, + "[SSHD] Error generating the auth failure delay key"); + } +} + void wolfSSHD_AuthInit(void) { #ifdef HAVE_SHADOW @@ -654,6 +688,8 @@ void wolfSSHD_AuthInit(void) " fixed-cost fake hash instead of matching real crypt() cost"); } #endif + + InitFailDelaySecret(); } #ifndef _WIN32 @@ -3037,11 +3073,118 @@ static int RequestAuthentication(WS_UserAuthData* authData, } +/* Monotonic microseconds, modulo 2^32; only differences are used. */ +static word32 AuthNowUs(void) +{ +#ifdef _WIN32 + /* GetTickCount64() ticks in 10-16 ms, coarser than the delay. */ + LARGE_INTEGER cnt, freq; + + if (!QueryPerformanceFrequency(&freq) || freq.QuadPart <= 0 + || !QueryPerformanceCounter(&cnt)) { + return 0; + } + return (word32)((cnt.QuadPart / freq.QuadPart) * 1000000 + + (cnt.QuadPart % freq.QuadPart) * 1000000 / freq.QuadPart); +#else + struct timespec ts; + + if (clock_gettime(CLOCK_MONOTONIC, &ts) != 0) { + return 0; + } + return (word32)ts.tv_sec * 1000000U + (word32)(ts.tv_nsec / 1000); +#endif +} + + +static void AuthSleepUs(word32 us) +{ +#ifdef _WIN32 + Sleep((DWORD)((us + 999) / 1000)); +#else + struct timespec ts; + + ts.tv_sec = (time_t)(us / 1000000U); + ts.tv_nsec = (long)(us % 1000000U) * 1000; + while (nanosleep(&ts, &ts) != 0 && errno == EINTR) { + /* resume the remaining time */ + } +#endif +} + + +#ifdef WOLFSSHD_UNIT_TEST +void (*wsshd_authsleep_cb)(word32 us) = AuthSleepUs; +#define AuthSleepUs wsshd_authsleep_cb +#endif + + +/* The minimum time, in microseconds, a failed userauth for usr takes. */ +WOLFSSHD_STATIC word32 UserFailDelayUs(const byte* usr, word32 usrSz) +{ + word32 h = 0; +#if !defined(NO_SHA256) + #define FAIL_DELAY_HASH WC_HASH_TYPE_SHA256 +#elif defined(WOLFSSL_SHA512) + #define FAIL_DELAY_HASH WC_HASH_TYPE_SHA512 +#elif defined(WOLFSSL_SHA384) + #define FAIL_DELAY_HASH WC_HASH_TYPE_SHA384 +#endif +#ifdef FAIL_DELAY_HASH + wc_HashAlg hash; + byte digest[WC_MAX_DIGEST_SIZE]; + + if (wc_HashInit(&hash, FAIL_DELAY_HASH) == 0) { + if (wc_HashUpdate(&hash, FAIL_DELAY_HASH, failDelaySecret, + sizeof(failDelaySecret)) == 0 + && (usr == NULL + || wc_HashUpdate(&hash, FAIL_DELAY_HASH, usr, usrSz) == 0) + && wc_HashFinal(&hash, FAIL_DELAY_HASH, digest) == 0) { + h = ((word32)digest[0] << 24) | ((word32)digest[1] << 16) + | ((word32)digest[2] << 8) | (word32)digest[3]; + } + wc_HashFree(&hash, FAIL_DELAY_HASH); + WS_FORCEZERO(digest, sizeof(digest)); + } + #undef FAIL_DELAY_HASH +#else + /* No hash to key the per-user share; the floor alone holds. */ + (void)usr; + (void)usrSz; +#endif + + return WOLFSSHD_MIN_FAIL_DELAY_US + h / 1000000U; +} + + +/* Sleeps until delayUs has passed since startUs, doubling delayUs while the + * time already spent exceeds it. Past the maximum it returns at once. A sleep + * may wake early, so the elapsed time is rechecked after each one. */ +WOLFSSHD_STATIC void EnsureMinFailTime(word32 startUs, word32 delayUs) +{ + word32 elapsed = AuthNowUs() - startUs; + int tries; + + if (elapsed > WOLFSSHD_MAX_FAIL_DELAY_US) { + return; + } + while (delayUs < elapsed) { + delayUs *= 2; + } + /* The bound keeps a stalled clock from looping forever. */ + for (tries = 0; tries < 64 && elapsed < delayUs; tries++) { + AuthSleepUs(delayUs - elapsed); + elapsed = AuthNowUs() - startUs; + } +} + + /* return WOLFSSH_USERAUTH_SUCCESS on success */ int DefaultUserAuth(byte authType, WS_UserAuthData* authData, void* ctx) { int ret = WOLFSSH_USERAUTH_SUCCESS; WOLFSSHD_AUTH* authCtx; + word32 startUs = AuthNowUs(); if (ctx == NULL) { wolfSSH_Log(WS_LOG_ERROR, "[SSHD] No auth callbacks passed in"); @@ -3071,6 +3214,11 @@ int DefaultUserAuth(byte authType, WS_UserAuthData* authData, void* ctx) ret = RequestAuthentication(authData, authCtx); } + if (ret != WOLFSSH_USERAUTH_SUCCESS) { + EnsureMinFailTime(startUs, + UserFailDelayUs(authData->username, authData->usernameSz)); + } + return ret; } diff --git a/apps/wolfsshd/auth.h b/apps/wolfsshd/auth.h index 7f6a80401..0531db254 100644 --- a/apps/wolfsshd/auth.h +++ b/apps/wolfsshd/auth.h @@ -183,6 +183,12 @@ int IsRootLoginDenied(int isRoot, WOLFSSHD_CONFIG* usrConf); int IsRootPasswordAuthBlocked(int isRoot, WOLFSSHD_CONFIG* usrConf); int IsRootPubKeyForcedCmdMissing(int isRoot, WOLFSSHD_CONFIG* usrConf); int wolfSSHD_GetUserAuthTypes(const WOLFSSHD_CONFIG* usrConf); +word32 UserFailDelayUs(const byte* usr, word32 usrSz); +void EnsureMinFailTime(word32 startUs, word32 delayUs); +#ifdef WOLFSSHD_UNIT_TEST +/* EnsureMinFailTime() sleeps through this; a test may wake it early. */ +extern void (*wsshd_authsleep_cb)(word32 us); +#endif #if defined(WOLFSSH_OSSH_CERTS) && !defined(_WIN32) int OsshPrefixMatch(const byte* a, const byte* b, int bits, int len); int OsshSourceAddrMatch(const byte* list, word32 listSz, const char* peerIp); diff --git a/apps/wolfsshd/test/test_configuration.c b/apps/wolfsshd/test/test_configuration.c index 827ce2547..265b83b34 100644 --- a/apps/wolfsshd/test/test_configuration.c +++ b/apps/wolfsshd/test/test_configuration.c @@ -40,6 +40,7 @@ #include #include #include + #include #endif #ifndef WOLFSSH_DEFAULT_LOG_WIDTH @@ -4358,6 +4359,153 @@ static int test_AuthCreateUser_privSepOff(void) return ret; } +static word32 TestNowUs(void) +{ + struct timespec ts; + + if (clock_gettime(CLOCK_MONOTONIC, &ts) != 0) { + return 0; + } + return (word32)ts.tv_sec * 1000000U + (word32)(ts.tv_nsec / 1000); +} + +/* The per-user failure delay is stable for a user and stays within OpenSSH's + * range: a 5 ms floor plus at most 4.29 ms. */ +static int test_UserFailDelayUs(void) +{ + static const byte alice[] = "alice"; + static const byte bob[] = "bob"; + word32 a, b; + + a = UserFailDelayUs(alice, (word32)sizeof(alice) - 1); + b = UserFailDelayUs(bob, (word32)sizeof(bob) - 1); + + if (a < 5000 || a > 5000 + 4295 || b < 5000 || b > 5000 + 4295) { + Log(" delay out of range: %u, %u\n", a, b); + return WS_FATAL_ERROR; + } + if (UserFailDelayUs(alice, (word32)sizeof(alice) - 1) != a) { + Log(" delay not stable for one user\n"); + return WS_FATAL_ERROR; + } + if (UserFailDelayUs(NULL, 0) < 5000) { + return WS_FATAL_ERROR; + } + return WS_SUCCESS; +} + +/* Time already spent past the delay doubles it, as OpenSSH does: 7 ms spent + * against a 5 ms delay waits until 10 ms. */ +static int test_EnsureMinFailTime(void) +{ + word32 start, spent; + + start = TestNowUs() - 7000; + EnsureMinFailTime(start, 5000); + spent = TestNowUs() - start; + if (spent < 10000) { + Log(" returned after %u us, expected at least 10000\n", spent); + return WS_FATAL_ERROR; + } + + /* Past the maximum it does not wait. */ + start = TestNowUs() - 6000000; + EnsureMinFailTime(start, 5000); + spent = TestNowUs() - start; + if (spent >= 6000000 + 5000000) { + return WS_FATAL_ERROR; + } + return WS_SUCCESS; +} + +static int s_shortSleepCalls = 0; + +/* Sleeps a quarter of the request, as a sleep that wakes early would. */ +static void ShortSleepUs(word32 us) +{ + struct timespec ts; + + s_shortSleepCalls++; + us /= 4; + ts.tv_sec = (time_t)(us / 1000000U); + ts.tv_nsec = (long)(us % 1000000U) * 1000; + (void)nanosleep(&ts, NULL); +} + +/* A sleep that returns early is rechecked and topped up, so the minimum + * still holds. */ +static int test_EnsureMinFailTime_earlyWake(void) +{ + int ret = WS_SUCCESS; + void (*savedSleep)(word32) = wsshd_authsleep_cb; + word32 start, spent; + + wsshd_authsleep_cb = ShortSleepUs; + s_shortSleepCalls = 0; + start = TestNowUs(); + EnsureMinFailTime(start, 20000); + spent = TestNowUs() - start; + wsshd_authsleep_cb = savedSleep; + + if (spent < 20000) { + Log(" returned after %u us in %d sleeps, expected at least" + " 20000\n", spent, s_shortSleepCalls); + ret = WS_FATAL_ERROR; + } + return ret; +} + +/* A failed userauth reply is held to the minimum delay, even on a path that + * fails at once without looking the user up. */ +static int test_DefaultUserAuth_minFailDelay(void) +{ + int ret = WS_SUCCESS; + int rc; + WOLFSSHD_CONFIG* conf; + WOLFSSHD_AUTH* authCtx; + WS_UserAuthData authData; + word32 start, spent; + 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) { + wolfSSHD_ConfigFree(conf); + return WS_FATAL_ERROR; + } + authCtx = wolfSSHD_AuthCreateUser(NULL, conf); + if (authCtx == NULL) { + wolfSSHD_ConfigFree(conf); + return WS_FATAL_ERROR; + } + + WMEMSET(&authData, 0, sizeof(authData)); + authData.type = WOLFSSH_USERAUTH_KEYBOARD; + authData.username = (const byte*)"delay_test_user"; + authData.usernameSz = (word32)WSTRLEN((const char*)authData.username); + + start = TestNowUs(); + rc = DefaultUserAuth(WOLFSSH_USERAUTH_KEYBOARD, &authData, authCtx); + spent = TestNowUs() - start; + + if (rc != WOLFSSH_USERAUTH_INVALID_AUTHTYPE) { + Log(" unexpected result %d\n", rc); + ret = WS_FATAL_ERROR; + } + else if (spent < UserFailDelayUs(authData.username, + authData.usernameSz)) { + Log(" failure returned after %u us, expected at least %u\n", + spent, UserFailDelayUs(authData.username, authData.usernameSz)); + ret = WS_FATAL_ERROR; + } + + wolfSSHD_AuthFreeUser(authCtx); + 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 @@ -7811,6 +7959,10 @@ const TEST_CASE testCases[] = { TEST_DECL(test_AuthReducePermissionsUser_gid_fail), TEST_DECL(test_AuthReducePermissionsUser_uid_fail), TEST_DECL(test_AuthCreateUser_privSepOff), + TEST_DECL(test_UserFailDelayUs), + TEST_DECL(test_EnsureMinFailTime), + TEST_DECL(test_EnsureMinFailTime_earlyWake), + TEST_DECL(test_DefaultUserAuth_minFailDelay), #if (defined(WOLFSSH_HAVE_LIBCRYPT) || defined(WOLFSSH_HAVE_LIBLOGIN)) && \ defined(WOLFSSHD_HAVE_SHADOW) TEST_DECL(test_RequestAuth_pwAuthNoRejectsBeforePasswordCheck),