From f0d755dc6e013b9f2eb3757f830425b8baf07b0e Mon Sep 17 00:00:00 2001
From: Sebastião Guerra <sebastiao.luiz.guerra@gmail.com>
Date: Sun, 18 Oct 2026 15:23:14 -0300
Subject: Fix TLS peer identity matching with subjectAltName
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Signed-off-by: Sebastião Guerra <sebastiao.luiz.guerra@gmail.com>
---
 ConfigureChecks.cmake  |   3 +
 INSTALL                |   5 ++
 src/tls/CMakeLists.txt |   2 +
 src/tls/tqca-tls.cpp   | 178 ++++++++++++++++++++++++++++++++++---------------
 4 files changed, 136 insertions(+), 52 deletions(-)

diff --git a/ConfigureChecks.cmake b/ConfigureChecks.cmake
index 450cdb4..34271f7 100644
--- a/ConfigureChecks.cmake
+++ b/ConfigureChecks.cmake
@@ -35,6 +35,9 @@ endif( BUILD_TQCA )
 
 
 if( BUILD_TQCA_TLS )
+##### check for libidn2
+pkg_search_module( IDN2 REQUIRED libidn2>=2.2.0 )
+
 ##### check for openssl
 pkg_search_module( SSL openssl>=1.1.0 )
 
diff --git a/INSTALL b/INSTALL
index c42fedd..dda1b04 100644
--- a/INSTALL
+++ b/INSTALL
@@ -16,3 +16,8 @@ Requirements:
 =============
 
 - tqtinterface
+- libidn2 >= 2.2.0 (required when BUILD_TQCA_TLS is enabled)
+
+The TLS plugin converts DNS reference names to A-labels using IDNA2008,
+including NFC normalization and A-label round-trip checks. TR46 compatibility
+mappings are disabled.
diff --git a/src/tls/CMakeLists.txt b/src/tls/CMakeLists.txt
index 45fcf10..1331e91 100644
--- a/src/tls/CMakeLists.txt
+++ b/src/tls/CMakeLists.txt
@@ -4,6 +4,7 @@ include_directories(
   ${CMAKE_BINARY_DIR}
   ${CMAKE_SOURCE_DIR}
   ${TQT_INCLUDE_DIRS}
+  ${IDN2_INCLUDE_DIRS}
 )
 
 link_directories(
@@ -22,6 +23,7 @@ tde_add_library( tqca-tls SHARED NO_LIBTOOL_FILE AUTOMOC
   LINK
      ${TQT_LIBRARIES}
      ${SSL_LIBRARIES}
+     ${IDN2_LIBRARIES}
 
   DESTINATION ${TQT_PLUGINS_CRYPTO_DIR}
 )
diff --git a/src/tls/tqca-tls.cpp b/src/tls/tqca-tls.cpp
index 059a01e..2fa6fca 100644
--- a/src/tls/tqca-tls.cpp
+++ b/src/tls/tqca-tls.cpp
@@ -21,6 +21,7 @@
 #include "tqca-tls.h"
 
 #include <tqregexp.h>
+#include <idn2.h>
 
 #include <openssl/sha.h>
 #include <openssl/md5.h>
@@ -679,63 +680,81 @@ auq_err:
 // (adapted from tdelibs) -- Justin
 static bool cnMatchesAddress(const TQString &_cn, const TQString &peerHost)
 {
-	TQString cn = _cn.stripWhiteSpace().lower();
-	TQRegExp rx;
-
-	// Check for invalid characters
-	if(TQRegExp("[^a-zA-Z0-9\\.\\*\\-]").search(cn) >= 0)
+	TQString pattern = _cn.lower();
+	TQString host = peerHost.lower();
+	TQRegExp invalidDns("[^a-z0-9.\\-]");
+	if(pattern.isEmpty() || host.isEmpty() ||
+	   invalidDns.search(host) >= 0)
 		return false;
 
-	// Domains can legally end with '.'s.  We don't need them though.
-	while(cn.endsWith("."))
-		cn.truncate(cn.length()-1);
+	if(!pattern.contains('*'))
+		return invalidDns.search(pattern) < 0 && pattern == host;
 
-	// Do not let empty CN's get by!!
-	if(cn.isEmpty())
+	// RFC 9525 permits a wildcard only as the complete left-most label.
+	if(!pattern.startsWith("*.") || pattern.mid(2).contains('*') ||
+	   invalidDns.search(pattern.mid(2)) >= 0)
 		return false;
 
-	// Check for IPv4 address
-	rx.setPattern("[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}");
-	if(rx.exactMatch(peerHost))
-		return peerHost == cn;
-
-	// Check for IPv6 address here...
-	rx.setPattern("^\\[.*\\]$");
-	if(rx.exactMatch(peerHost))
-		return peerHost == cn;
-
-	if(cn.contains('*')) {
-		// First make sure that there are at least two valid parts
-		// after the wildcard (*).
-		TQStringList parts = TQStringList::split('.', cn, false);
+	TQString suffix = "." + pattern.mid(2);
+	if(!host.endsWith(suffix))
+		return false;
 
-		while(parts.count() > 2)
-			parts.remove(parts.begin());
+	TQString leftmost = host.left(host.length() - suffix.length());
+	return !leftmost.isEmpty() && !leftmost.contains('.');
+}
 
-		if(parts.count() != 2) {
-			return false;  // we don't allow *.root - that's bad
-		}
+static bool domainToAscii(const TQString &host, TQString *asciiHost)
+{
+	if(host.isEmpty())
+		return false;
 
-		if(parts[0].contains('*') || parts[1].contains('*')) {
+	for(uint i = 0; i < host.length(); ++i) {
+		TQChar c = host[i];
+		if(c.unicode() == 0)
 			return false;
-		}
+		if(c.unicode() < 0x80 &&
+		   !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
+		     (c >= '0' && c <= '9') || c == '.' || c == '-'))
+			return false;
+	}
 
-		// RFC2818 says that *.example.com should match against
-		// foo.example.com but not bar.foo.example.com
-		// (ie. they must have the same number of parts)
-		if(TQRegExp(cn, false, true).exactMatch(peerHost) &&
-			TQStringList::split('.', cn, false).count() ==
-			TQStringList::split('.', peerHost, false).count())
-			return true;
+	// RFC 9525 compares DNS-IDs as A-labels. Do not apply TR46 compatibility mappings.
+	TQCString utf8Host = host.lower().utf8();
+	uint8_t *ascii = nullptr;
+	int result = idn2_lookup_u8(reinterpret_cast<const uint8_t *>(utf8Host.data()), &ascii,
+		IDN2_NFC_INPUT | IDN2_NO_TR46 | IDN2_ALABEL_ROUNDTRIP);
+	if(result != IDN2_OK || !ascii) {
+		if(ascii)
+			idn2_free(ascii);
+		return false;
+	}
 
+	TQString converted = TQString::fromLatin1(reinterpret_cast<const char *>(ascii));
+	idn2_free(ascii);
+	if(converted.endsWith("."))
+		converted.truncate(converted.length() - 1);
+	if(converted.isEmpty() || converted.length() > 253 || converted.endsWith("."))
 		return false;
+
+	int labelLength = 0;
+	for(uint i = 0; i <= converted.length(); ++i) {
+		if(i < converted.length() && converted[i] != '.') {
+			TQChar c = converted[i].lower();
+			if(!((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-'))
+				return false;
+			++labelLength;
+			if(labelLength > 63 || (labelLength == 1 && c == '-'))
+				return false;
+		}
+		else {
+			if(labelLength == 0 || converted[i - 1] == '-')
+				return false;
+			labelLength = 0;
+		}
 	}
 
-	// We must have an exact match in this case (insensitive though)
-	// (note we already did .lower())
-	if(cn == peerHost)
-		return true;
-	return false;
+	*asciiHost = converted.lower();
+	return true;
 }
 
 class CertContext : public TQCA_CertContext
@@ -892,19 +911,74 @@ public:
 	bool matchesAddress(const TQString &realHost) const
 	{
 		TQString peerHost = realHost.stripWhiteSpace();
-		while(peerHost.endsWith("."))
+		if(peerHost.endsWith("."))
 			peerHost.truncate(peerHost.length()-1);
-		peerHost = peerHost.lower();
+		if(peerHost.endsWith("."))
+			return false;
 
-		TQString cn;
-		for(TQValueList<TQCA_CertProperty>::ConstIterator it = cp_subject.begin(); it != cp_subject.end(); ++it) {
-			if((*it).var == "CN") {
-				cn = (*it).val;
-				break;
+		// IP address identities are valid only in iPAddress subjectAltName entries.
+		TQString ipText = peerHost.lower();
+		if(ipText.startsWith("[") && ipText.endsWith("]"))
+			ipText = ipText.mid(1, ipText.length()-2);
+		bool isIPLiteral = ipText.contains(":") || TQRegExp("^[0-9]+(\\.[0-9]+)+$").exactMatch(ipText);
+		ASN1_OCTET_STRING *peerIP = isIPLiteral ? a2i_IPADDRESS(ipText.latin1()) : nullptr;
+		if(isIPLiteral) {
+			if(!peerIP)
+				return false;
+			int critical = -1;
+			GENERAL_NAMES *names = reinterpret_cast<GENERAL_NAMES *>(X509_get_ext_d2i(x, NID_subject_alt_name, &critical, nullptr));
+			bool matched = false;
+			if(names) {
+				for(int i = 0; i < sk_GENERAL_NAME_num(names); ++i) {
+					GENERAL_NAME *name = sk_GENERAL_NAME_value(names, i);
+					if(name->type != GEN_IPADD)
+						continue;
+					const ASN1_OCTET_STRING *address = name->d.iPAddress;
+					if(ASN1_STRING_length(address) == ASN1_STRING_length(peerIP) &&
+					   memcmp(ASN1_STRING_get0_data(address), ASN1_STRING_get0_data(peerIP), ASN1_STRING_length(peerIP)) == 0) {
+						matched = true;
+						break;
+					}
+				}
+				GENERAL_NAMES_free(names);
 			}
+			ASN1_OCTET_STRING_free(peerIP);
+			return matched;
 		}
-		if(cnMatchesAddress(cn, peerHost))
-			return true;
+
+		TQString asciiHost;
+		if(!domainToAscii(peerHost, &asciiHost))
+			return false;
+		peerHost = asciiHost;
+
+		// A DNS-ID in subjectAltName takes precedence over the legacy CN-ID.
+		int critical = -1;
+		GENERAL_NAMES *names = reinterpret_cast<GENERAL_NAMES *>(X509_get_ext_d2i(x, NID_subject_alt_name, &critical, nullptr));
+		if(!names && critical != -1)
+			return false; // malformed or duplicated subjectAltName: fail closed
+		bool hasDNSName = false;
+		bool matched = false;
+		if(names) {
+			for(int i = 0; i < sk_GENERAL_NAME_num(names); ++i) {
+				GENERAL_NAME *name = sk_GENERAL_NAME_value(names, i);
+				if(name->type != GEN_DNS)
+					continue;
+				hasDNSName = true;
+				const ASN1_IA5STRING *dnsName = name->d.dNSName;
+				const unsigned char *data = ASN1_STRING_get0_data(dnsName);
+				int length = ASN1_STRING_length(dnsName);
+				if(length > 0 && !memchr(data, '\0', length) &&
+				   cnMatchesAddress(TQString::fromLatin1(reinterpret_cast<const char *>(data), length), peerHost)) {
+					matched = true;
+					break;
+				}
+			}
+			GENERAL_NAMES_free(names);
+		}
+		if(hasDNSName)
+			return matched;
+
+		// Service identity must be represented by a supported subjectAltName.
 		return false;
 	}
 
-- 
cgit v1.2.3

