diff --git a/manpages/wolfssl-s_client.1 b/manpages/wolfssl-s_client.1 index 3bfaa562..c17eae3d 100644 --- a/manpages/wolfssl-s_client.1 +++ b/manpages/wolfssl-s_client.1 @@ -4,11 +4,12 @@ .SH NAME wolfssl-s_client, s_client \- basic TLS client for testing connections .SH SYNOPSIS -wolfssl s_client -connect : [-starttls proto] [-CAfile file] [-verify_return_error] [-disable_stdin_check] [-noservername] [-help] +wolfssl s_client -connect : [-starttls proto] [-CAfile file] [-verify_return_error] [-verify_hostname name] [-verify_ip ip] [-disable_stdin_check] [-noservername] [-help] .SH DESCRIPTION Opens a TLS connection to a server for testing. Server Name Indication is -sent by default. Certificate verification is disabled by default and can be -enabled with -verify_return_error. +sent by default. Certificate verification is off by default. When enabled +it checks the certificate chain, not that the certificate was issued for +the host being connected to; that is asked for separately. .SH OPTIONS -connect host:port address and port to connect to. IPv6 addresses use .br @@ -20,14 +21,24 @@ enabled with -verify_return_error. .LP -CAfile file CA certificate file. Has no effect on whether the .br - connection succeeds or fails unless + connection succeeds or fails unless certificate .br - \-verify_return_error is also given (see NOTES). + verification is enabled (see NOTES). .br .LP -verify_return_error close the connection on a verification error. .br .LP +-verify_hostname name check the peer certificate against the DNS name +.br + \fIname\fR. +.br +.LP +-verify_ip ip check the peer certificate against the IP address +.br + \fIip\fR. +.br +.LP -disable_stdin_check do not wait for or read input on stdin; useful when .br scripting the client. @@ -48,6 +59,16 @@ Connect and verify the server's certificate: wolfssl s_client -connect example.com:443 -CAfile ca-cert.pem -verify_return_error .RE .LP +Connect and require the certificate to be issued for the host: +.RS +wolfssl s_client -connect example.com:443 -CAfile ca-cert.pem -verify_hostname example.com +.RE +.LP +Connect and require the certificate to carry the address dialed: +.RS +wolfssl s_client -connect 127.0.0.1:11111 -CAfile ca-cert.pem -verify_ip 127.0.0.1 +.RE +.LP Connect to an SMTP server using STARTTLS: .RS wolfssl s_client -connect mail.example.com:25 -starttls smtp @@ -59,8 +80,9 @@ Available only when wolfSSL is built with filesystem support. .LP The client negotiates the highest TLS version both peers support. CRL checking is not performed. The server certificate is only verified when -\-verify_return_error is given; without it the connection proceeds -unverified (with a warning) even if \-CAfile is supplied. +\-verify_return_error, \-verify_hostname or \-verify_ip is given; each of +them enables verification on its own. Without one of them the connection +proceeds unverified (with a warning) even if \-CAfile is supplied. .SH BUGS No known bugs at this time. .SH AUTHOR diff --git a/src/client/client.c b/src/client/client.c index e4364b33..ad6ed9d7 100644 --- a/src/client/client.c +++ b/src/client/client.c @@ -2181,6 +2181,8 @@ THREAD_RETURN WOLFSSL_THREAD client_test(void* args) because can't tell if we're really going there to detect old chacha-poly */ + const char* checkDomain = NULL; + const char* checkIpAddr = NULL; #ifndef WOLFSSL_VXWORKS int ch; static const struct mygetopt_long_config long_options[] = { @@ -2194,6 +2196,8 @@ THREAD_RETURN WOLFSSL_THREAD client_test(void* args) { "pqc", 1, 259 }, #endif { "disable_stdin_check", 0, 260 }, + { "verify_hostname", 1, 300 }, + { "verify_ip", 1, 301 }, { 0, 0, 0 } }; #endif @@ -2214,6 +2218,7 @@ THREAD_RETURN WOLFSSL_THREAD client_test(void* args) int dtlsSCTP = 0; int doMcast = 0; int matchName = 0; + int matchIpAddr = 0; int doPeerCheck = 1; int nonBlocking = 0; int simulateWantWrite = 0; @@ -2404,6 +2409,16 @@ THREAD_RETURN WOLFSSL_THREAD client_test(void* args) disable_stdin_chk = 1; break; + case 300 : + matchName = 1; + checkDomain = myoptarg; + break; + + case 301 : + matchIpAddr = 1; + checkIpAddr = myoptarg; + break; + case 'g' : sendGET = 1; break; @@ -3877,8 +3892,23 @@ THREAD_RETURN WOLFSSL_THREAD client_test(void* args) SetupAtomicUser(ctx, ssl); #endif - if (matchName && doPeerCheck) - wolfSSL_check_domain_name(ssl, domain); + if (matchName && doPeerCheck) { + if (wolfSSL_check_domain_name(ssl, + checkDomain != NULL ? checkDomain : domain) + != WOLFSSL_SUCCESS) { + wolfSSL_free(ssl); ssl = NULL; + wolfSSL_CTX_free(ctx); ctx = NULL; + err_sys("can't set domain name to check"); + } + } + + if (matchIpAddr && doPeerCheck) { + if (wolfSSL_check_ip_address(ssl, checkIpAddr) != WOLFSSL_SUCCESS) { + wolfSSL_free(ssl); ssl = NULL; + wolfSSL_CTX_free(ctx); ctx = NULL; + err_sys("can't set IP address to check"); + } + } #ifndef WOLFSSL_CALLBACKS if (nonBlocking) { #ifdef WOLFSSL_DTLS diff --git a/src/client/clu_client_setup.c b/src/client/clu_client_setup.c index e836723d..b4734548 100644 --- a/src/client/clu_client_setup.c +++ b/src/client/clu_client_setup.c @@ -37,6 +37,8 @@ static const struct option client_options[] = { {"-verify_return_error", no_argument, 0, WOLFCLU_VERIFY_RETURN_ERROR}, {"-disable_stdin_check", no_argument, 0, WOLFCLU_DISABLE_STDINCHK }, {"-noservername", no_argument, 0, WOLFCLU_NOSERVERNAME }, + {"-verify_hostname", required_argument, 0, WOLFCLU_VERIFY_HOSTNAME }, + {"-verify_ip", required_argument, 0, WOLFCLU_VERIFY_IP }, {"-help", no_argument, 0, WOLFCLU_HELP }, {"-h", no_argument, 0, WOLFCLU_HELP }, @@ -58,6 +60,11 @@ static void wolfCLU_ClientHelp(void) WOLFCLU_LOG(WOLFCLU_L0, "\t-verify_return_error close connection on verification error"); WOLFCLU_LOG(WOLFCLU_L0, "\t-disable_stdin_check "); WOLFCLU_LOG(WOLFCLU_L0, "\t-noservername do not send Server Name Indication"); + WOLFCLU_LOG(WOLFCLU_L0, "\t-verify_hostname check the peer certificate" + " against "); + WOLFCLU_LOG(WOLFCLU_L0, "\t-verify_ip check the peer certificate against" + " "); + WOLFCLU_LOG(WOLFCLU_L0, "\t\teither one turns on peer verification"); } static const char hostFlag[] = "-h"; @@ -69,6 +76,8 @@ static const char noClientCert[] = "-x"; static const char startTLSFlag[] = "-M"; static const char disableCRLFlag[] = "-C"; static const char sniFlag[] = "-S"; +static const char verifyHostFlag[] = "--verify_hostname"; +static const char verifyIpFlag[] = "--verify_ip"; int myoptind = 0; char* myoptarg = NULL; @@ -105,6 +114,8 @@ int wolfCLU_Client(int argc, char** argv) int verify = 0; int noservername = 0; char* ipv6 = NULL; + char* verifyHost = NULL; + char* verifyIp = NULL; int clientArgc = 0; const char* clientArgv[MAX_CLIENT_ARGS]; @@ -236,6 +247,14 @@ int wolfCLU_Client(int argc, char** argv) noservername = 1; break; + case WOLFCLU_VERIFY_HOSTNAME: + verifyHost = optarg; + break; + + case WOLFCLU_VERIFY_IP: + verifyIp = optarg; + break; + case ARG_FOUND_TWICE: wolfCLU_LogError("Found duplicate argument"); return WOLFCLU_FATAL_ERROR; @@ -265,10 +284,27 @@ int wolfCLU_Client(int argc, char** argv) } } + if (ret == WOLFCLU_SUCCESS && verifyHost != NULL) { + verify = 1; + ret = _addClientArg(clientArgv, verifyHostFlag, &clientArgc); + if (ret == WOLFCLU_SUCCESS) { + ret = _addClientArg(clientArgv, verifyHost, &clientArgc); + } + } + + if (ret == WOLFCLU_SUCCESS && verifyIp != NULL) { + verify = 1; + ret = _addClientArg(clientArgv, verifyIpFlag, &clientArgc); + if (ret == WOLFCLU_SUCCESS) { + ret = _addClientArg(clientArgv, verifyIp, &clientArgc); + } + } + if (ret == WOLFCLU_SUCCESS && !verify) { ret = _addClientArg(clientArgv, noVerifyFlag, &clientArgc); - WOLFCLU_LOG(WOLFCLU_L0, "\nWarning: -verify_return_error not specified." + WOLFCLU_LOG(WOLFCLU_L0, "\nWarning: none of -verify_return_error," + " -verify_hostname or -verify_ip specified." " Defaulting to NOT verifying peer."); } diff --git a/tests/client/client-test.py b/tests/client/client-test.py index 4fbca71d..60e180ad 100644 --- a/tests/client/client-test.py +++ b/tests/client/client-test.py @@ -65,6 +65,10 @@ def test_client_help(self): r = run_wolfssl("s_client", "-help") self.assertEqual(r.returncode, 0, r.stderr) self.assertIn("s_client" , r.stderr, "help menu was not printed") + self.assertIn("-verify_hostname", r.stderr, + "-verify_hostname missing from help menu") + self.assertIn("-verify_ip", r.stderr, + "-verify_ip missing from help menu") class ShellInjectionTest(unittest.TestCase): """Regression tests for shell command injection via hostname. diff --git a/tests/server/server-test.py b/tests/server/server-test.py index 4e795f05..05967cde 100644 --- a/tests/server/server-test.py +++ b/tests/server/server-test.py @@ -11,6 +11,10 @@ from wolfclu_test import WOLFSSL_BIN, CERTS_DIR, test_main, find_free_port +NAME_MISMATCH = b"peer subject name mismatch" +IP_MISMATCH = b"peer ip address mismatch" + + class ServerClientTest(unittest.TestCase): @classmethod @@ -89,6 +93,130 @@ def test_server_client(self): server.kill() server.wait() + def _run_identity_check(self, extra_args): + """Start s_server on 127.0.0.1 and connect with the given extra + s_client arguments. Returns the s_client CompletedProcess.""" + readyfile = "readyfile_identity" + if os.path.exists(readyfile): + os.remove(readyfile) + + port = find_free_port() + + server = subprocess.Popen( + [WOLFSSL_BIN, "s_server", "-port", str(port), + "-key", os.path.join(CERTS_DIR, "server-key.pem"), + "-cert", os.path.join(CERTS_DIR, "server-cert.pem"), + "-CAfile", os.path.join(CERTS_DIR, "ca-cert.pem"), + "-version", "3", "-naccept", "1", "-www", + "-noVerify", "-readyFile", readyfile], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + stdin=subprocess.DEVNULL, + ) + + try: + for _ in range(200): + if os.path.exists(readyfile): + break + time.sleep(0.01) + else: + self.fail("s_server did not become ready") + + os.remove(readyfile) + + return subprocess.run( + [WOLFSSL_BIN, "s_client", "-connect", + "127.0.0.1:{}".format(port), + "-CAfile", os.path.join(CERTS_DIR, "ca-cert.pem"), + "-disable_stdin_check"] + extra_args, + capture_output=True, stdin=subprocess.DEVNULL, timeout=30, + ) + finally: + server.terminate() + try: + server.wait(timeout=5) + except subprocess.TimeoutExpired: + server.kill() + server.wait() + + def _assert_rejected(self, r, reason, message): + """Fail unless s_client rejected the peer for the stated reason. + + A non-zero exit alone can come from an unready server or a dropped + connection, which would let a real regression pass. + """ + self.assertNotEqual(r.returncode, 0, message) + output = r.stdout + r.stderr + self.assertIn(reason, output, + f"{message} -- rejected, but not for that reason: " + f"{output}") + + def test_verify_hostname_match(self): + """-verify_hostname accepts a name the certificate carries. + + server-cert.pem has SAN DNS:example.com. Neither this nor the + mismatch test passes -verify_return_error, so they also cover + -verify_hostname enabling peer verification on its own. + """ + r = self._run_identity_check(["-verify_hostname", "example.com"]) + self.assertEqual(r.returncode, 0, + f"s_client rejected a matching name: {r.stderr}") + + def test_verify_hostname_mismatch(self): + """-verify_hostname rejects a CA-trusted cert issued for another name. + + server-cert.pem is signed by ca-cert.pem and so passes chain + verification, but carries no name matching attacker.example. + """ + r = self._run_identity_check(["-verify_hostname", "attacker.example"]) + self._assert_rejected(r, NAME_MISMATCH, + "SECURITY FAILURE: s_client accepted a " + "certificate issued for a different host") + + def test_verify_ip_match(self): + """-verify_ip accepts an address the certificate carries. + + server-cert.pem has SAN IP:127.0.0.1. + """ + r = self._run_identity_check(["-verify_ip", "127.0.0.1"]) + self.assertEqual(r.returncode, 0, + f"s_client rejected a matching IP: {r.stderr}") + + def test_verify_ip_mismatch(self): + """-verify_ip rejects a certificate without that IP SAN.""" + r = self._run_identity_check(["-verify_ip", "10.0.0.1"]) + self._assert_rejected(r, IP_MISMATCH, + "SECURITY FAILURE: s_client accepted a " + "certificate issued for a different address") + + def test_verify_ip_unmatched_values(self): + """-verify_ip fails closed on an IPv6 address the cert lacks and on + a value that is not an address at all.""" + for value in ("::1", "not-an-ip"): + with self.subTest(value=value): + r = self._run_identity_check(["-verify_ip", value]) + self._assert_rejected(r, IP_MISMATCH, + "SECURITY FAILURE: s_client accepted a " + f"certificate for -verify_ip {value}") + + def test_verify_hostname_and_ip_together(self): + """Both options at once: each is checked, either one can reject.""" + r = self._run_identity_check(["-verify_hostname", "example.com", + "-verify_ip", "127.0.0.1"]) + self.assertEqual(r.returncode, 0, + f"s_client rejected a matching name and IP: {r.stderr}") + + r = self._run_identity_check(["-verify_hostname", "attacker.example", + "-verify_ip", "127.0.0.1"]) + self._assert_rejected(r, NAME_MISMATCH, + "SECURITY FAILURE: a matching -verify_ip masked " + "a mismatched -verify_hostname") + + r = self._run_identity_check(["-verify_hostname", "example.com", + "-verify_ip", "10.0.0.1"]) + self._assert_rejected(r, IP_MISMATCH, + "SECURITY FAILURE: a matching -verify_hostname " + "masked a mismatched -verify_ip") + if __name__ == "__main__": test_main() diff --git a/wolfclu/clu_optargs.h b/wolfclu/clu_optargs.h index ef77a8fd..2a8724b6 100644 --- a/wolfclu/clu_optargs.h +++ b/wolfclu/clu_optargs.h @@ -117,6 +117,8 @@ enum { WOLFCLU_VERIFY_RETURN_ERROR, WOLFCLU_DISABLE_STDINCHK, WOLFCLU_NOSERVERNAME, + WOLFCLU_VERIFY_HOSTNAME, + WOLFCLU_VERIFY_IP, WOLFCLU_NOCRYPT, WOLFCLU_TOPKCS8, WOLFCLU_HMAC,