| 1 | /*++ |
| 2 | |
| 3 | Copyright (c) Microsoft. All rights reserved. |
| 4 | |
| 5 | Module Name: |
| 6 | |
| 7 | WSLCE2ETlsRegistryTests.cpp |
| 8 | |
| 9 | Abstract: |
| 10 | |
| 11 | End-to-end test for private-registry SSL trust. Stands up a registry that serves TLS with a |
| 12 | private CA, verifies that a push fails with an "unknown authority" error while the CA is not |
| 13 | trusted, then adds the CA to the machine's Trusted Root store and verifies that a push from a |
| 14 | freshly created session succeeds. |
| 15 | |
| 16 | --*/ |
| 17 | |
| 18 | #include "precomp.h" |
| 19 | #include "windows/Common.h" |
| 20 | #include "WSLCExecutor.h" |
| 21 | #include "WSLCE2EHelpers.h" |
| 22 | #include "TestImageRegistry.h" |
| 23 | #include <wslutil.h> |
| 24 | #include <ncrypt.h> |
| 25 | |
| 26 | namespace WSLCE2ETests { |
| 27 | using namespace wsl::shared; |
| 28 | |
| 29 | namespace wslutil = wsl::windows::common::wslutil; |
| 30 | |
| 31 | namespace { |
| 32 | // The bridge IP assigned to the first container started in a fresh session. The registry is always |
| 33 | // the first container we start in this test's session, so this is deterministic. |
| 34 | constexpr auto c_registryIp = "172.17.0.2"; |
| 35 | constexpr USHORT c_registryPort = 5000; |
| 36 | |
| 37 | // DER-encodes a certificate-extension value structure (e.g. CERT_ALT_NAME_INFO for the SAN OID). |
| 38 | std::vector<BYTE> EncodeCertExtensionValue(LPCSTR Oid, const void* StructInfo) |
| 39 | { |
| 40 | DWORD size = 0; |
| 41 | THROW_IF_WIN32_BOOL_FALSE(CryptEncodeObjectEx(X509_ASN_ENCODING, Oid, StructInfo, 0, nullptr, nullptr, &size)); |
| 42 | std::vector<BYTE> encoded(size); |
| 43 | THROW_IF_WIN32_BOOL_FALSE(CryptEncodeObjectEx(X509_ASN_ENCODING, Oid, StructInfo, 0, nullptr, encoded.data(), &size)); |
| 44 | encoded.resize(size); |
| 45 | return encoded; |
| 46 | } |
| 47 | |
| 48 | // Wraps DER bytes in a PEM block with the given label (e.g. "CERTIFICATE", "PRIVATE KEY"). |
| 49 | std::string ToPem(const BYTE* Data, DWORD Size, const std::string& Label) |
| 50 | { |
| 51 | DWORD base64Size = 0; |
| 52 | THROW_IF_WIN32_BOOL_FALSE(CryptBinaryToStringA(Data, Size, CRYPT_STRING_BASE64, nullptr, &base64Size)); |
| 53 | std::string base64(base64Size, '\0'); |
| 54 | THROW_IF_WIN32_BOOL_FALSE(CryptBinaryToStringA(Data, Size, CRYPT_STRING_BASE64, base64.data(), &base64Size)); |
| 55 | base64.resize(base64Size); |
| 56 | return std::format("-----BEGIN {}-----\n{}-----END {}-----\n", Label, base64, Label); |
| 57 | } |
| 58 | |
| 59 | // Generates a self-signed certificate usable as both a TLS server cert and a CA, |
| 60 | // writes server.crt and server.key (PEM) into OutDir for the registry to serve, |
| 61 | // and returns the certificate context so the test can add it to a trust store later. |
| 62 | wil::unique_cert_context GenerateRegistryTlsCertificate(const std::string& IpAddress, const std::filesystem::path& OutDir) |
| 63 | { |
| 64 | // Create an exportable RSA key |
| 65 | NCRYPT_PROV_HANDLE prov{}; |
| 66 | THROW_IF_FAILED(NCryptOpenStorageProvider(&prov, MS_KEY_STORAGE_PROVIDER, 0)); |
| 67 | auto freeProv = wil::scope_exit([&] { NCryptFreeObject(prov); }); |
| 68 | |
| 69 | GUID guid{}; |
| 70 | THROW_IF_FAILED(CoCreateGuid(&guid)); |
| 71 | const auto keyName = |
| 72 | L"WslcE2eTlsKey-" + wsl::shared::string::GuidToString<wchar_t>(guid, wsl::shared::string::GuidToStringFlags::None); |
| 73 | |
| 74 | NCRYPT_KEY_HANDLE key{}; |
| 75 | THROW_IF_FAILED(NCryptCreatePersistedKey(prov, &key, BCRYPT_RSA_ALGORITHM, keyName.c_str(), 0, 0)); |
| 76 | auto deleteKey = wil::scope_exit([&] { NCryptDeleteKey(key, 0); }); // also frees the handle |
| 77 | |
| 78 | DWORD keyLength = 2048; |
| 79 | THROW_IF_FAILED(NCryptSetProperty(key, NCRYPT_LENGTH_PROPERTY, reinterpret_cast<PBYTE>(&keyLength), sizeof(keyLength), 0)); |
| 80 | DWORD exportPolicy = NCRYPT_ALLOW_EXPORT_FLAG | NCRYPT_ALLOW_PLAINTEXT_EXPORT_FLAG; |
| 81 | THROW_IF_FAILED(NCryptSetProperty(key, NCRYPT_EXPORT_POLICY_PROPERTY, reinterpret_cast<PBYTE>(&exportPolicy), sizeof(exportPolicy), 0)); |
| 82 | THROW_IF_FAILED(NCryptFinalizeKey(key, 0)); |
| 83 | |
| 84 | // Build the extensions (SAN, basic constraints, key usage, extended key usage) |
| 85 | in_addr ipv4{}; |
| 86 | wslutil::ParseIpv4Address(IpAddress.c_str(), ipv4); |
| 87 | CERT_ALT_NAME_ENTRY altEntry{}; |
| 88 | altEntry.dwAltNameChoice = CERT_ALT_NAME_IP_ADDRESS; |
| 89 | altEntry.IPAddress.cbData = sizeof(ipv4); |
| 90 | altEntry.IPAddress.pbData = reinterpret_cast<BYTE*>(&ipv4); |
| 91 | CERT_ALT_NAME_INFO altInfo{1, &altEntry}; |
| 92 | auto sanEncoded = EncodeCertExtensionValue(szOID_SUBJECT_ALT_NAME2, &altInfo); |
| 93 | |
| 94 | CERT_BASIC_CONSTRAINTS2_INFO basicConstraints{}; |
| 95 | basicConstraints.fCA = TRUE; |
| 96 | auto bcEncoded = EncodeCertExtensionValue(szOID_BASIC_CONSTRAINTS2, &basicConstraints); |
| 97 | |
| 98 | BYTE keyUsageBits = CERT_DIGITAL_SIGNATURE_KEY_USAGE | CERT_KEY_ENCIPHERMENT_KEY_USAGE | CERT_KEY_CERT_SIGN_KEY_USAGE; |
| 99 | CRYPT_BIT_BLOB keyUsage{}; |
| 100 | keyUsage.cbData = 1; |
| 101 | keyUsage.pbData = &keyUsageBits; |
| 102 | auto kuEncoded = EncodeCertExtensionValue(szOID_KEY_USAGE, &keyUsage); |
| 103 | |
| 104 | LPSTR serverAuthOid = const_cast<LPSTR>(szOID_PKIX_KP_SERVER_AUTH); |
| 105 | CERT_ENHKEY_USAGE enhKeyUsage{1, &serverAuthOid}; |
| 106 | auto ekuEncoded = EncodeCertExtensionValue(szOID_ENHANCED_KEY_USAGE, &enhKeyUsage); |
| 107 | |
| 108 | CERT_EXTENSION extensions[4]{}; |
| 109 | extensions[0] = {const_cast<LPSTR>(szOID_SUBJECT_ALT_NAME2), FALSE, {static_cast<DWORD>(sanEncoded.size()), sanEncoded.data()}}; |
| 110 | extensions[1] = {const_cast<LPSTR>(szOID_BASIC_CONSTRAINTS2), TRUE, {static_cast<DWORD>(bcEncoded.size()), bcEncoded.data()}}; |
| 111 | extensions[2] = {const_cast<LPSTR>(szOID_KEY_USAGE), TRUE, {static_cast<DWORD>(kuEncoded.size()), kuEncoded.data()}}; |
| 112 | extensions[3] = {const_cast<LPSTR>(szOID_ENHANCED_KEY_USAGE), FALSE, {static_cast<DWORD>(ekuEncoded.size()), ekuEncoded.data()}}; |
| 113 | CERT_EXTENSIONS certExtensions{ARRAYSIZE(extensions), extensions}; |
| 114 | |
| 115 | // Subject / issuer name. |
| 116 | const auto subject = L"CN=" + wsl::shared::string::MultiByteToWide(IpAddress); |
| 117 | DWORD nameSize = 0; |
| 118 | THROW_IF_WIN32_BOOL_FALSE(CertStrToNameW(X509_ASN_ENCODING, subject.c_str(), CERT_X500_NAME_STR, nullptr, nullptr, &nameSize, nullptr)); |
| 119 | std::vector<BYTE> nameBlob(nameSize); |
| 120 | THROW_IF_WIN32_BOOL_FALSE( |
| 121 | CertStrToNameW(X509_ASN_ENCODING, subject.c_str(), CERT_X500_NAME_STR, nullptr, nameBlob.data(), &nameSize, nullptr)); |
| 122 | CERT_NAME_BLOB subjectBlob{nameSize, nameBlob.data()}; |
| 123 | |
| 124 | CRYPT_KEY_PROV_INFO keyProvInfo{}; |
| 125 | keyProvInfo.pwszContainerName = const_cast<LPWSTR>(keyName.c_str()); |
| 126 | keyProvInfo.pwszProvName = const_cast<LPWSTR>(MS_KEY_STORAGE_PROVIDER); |
| 127 | |
| 128 | CRYPT_ALGORITHM_IDENTIFIER signatureAlgorithm{}; |
| 129 | signatureAlgorithm.pszObjId = const_cast<LPSTR>(szOID_RSA_SHA256RSA); |
| 130 | |
| 131 | FILETIME nowFt{}; |
| 132 | GetSystemTimeAsFileTime(&nowFt); |
| 133 | ULARGE_INTEGER ticks{}; |
| 134 | ticks.LowPart = nowFt.dwLowDateTime; |
| 135 | ticks.HighPart = nowFt.dwHighDateTime; |
| 136 | ticks.QuadPart += 100ULL * 24 * 60 * 60 * 10'000'000; // 100 days, in 100-ns intervals |
| 137 | FILETIME endFt{ticks.LowPart, ticks.HighPart}; |
| 138 | SYSTEMTIME notAfter{}; |
| 139 | THROW_IF_WIN32_BOOL_FALSE(FileTimeToSystemTime(&endFt, ¬After)); |
| 140 | |
| 141 | wil::unique_cert_context cert{CertCreateSelfSignCertificate( |
| 142 | key, &subjectBlob, 0, &keyProvInfo, &signatureAlgorithm, nullptr, ¬After, &certExtensions)}; |
| 143 | THROW_LAST_ERROR_IF_NULL(cert); |
| 144 | |
| 145 | // Export server.crt and server.key |
| 146 | const auto certPem = ToPem(cert.get()->pbCertEncoded, cert.get()->cbCertEncoded, "CERTIFICATE"); |
| 147 | |
| 148 | DWORD keyBlobSize = 0; |
| 149 | THROW_IF_FAILED(NCryptExportKey(key, 0, NCRYPT_PKCS8_PRIVATE_KEY_BLOB, nullptr, nullptr, 0, &keyBlobSize, 0)); |
| 150 | std::vector<BYTE> keyBlob(keyBlobSize); |
| 151 | THROW_IF_FAILED(NCryptExportKey(key, 0, NCRYPT_PKCS8_PRIVATE_KEY_BLOB, nullptr, keyBlob.data(), keyBlobSize, &keyBlobSize, 0)); |
| 152 | const auto keyPem = ToPem(keyBlob.data(), keyBlobSize, "PRIVATE KEY"); |
| 153 | |
| 154 | const auto writeFile = [](const std::filesystem::path& path, const std::string& contents) { |
| 155 | std::ofstream stream(path, std::ios::binary | std::ios::trunc); |
| 156 | THROW_HR_IF_MSG(E_FAIL, !stream.is_open(), "Failed to open %ls for writing", path.c_str()); |
| 157 | stream.write(contents.data(), contents.size()); |
| 158 | }; |
| 159 | writeFile(OutDir / "server.crt", certPem); |
| 160 | writeFile(OutDir / "server.key", keyPem); |
| 161 | |
| 162 | return cert; |
| 163 | } |
| 164 | |
| 165 | // Helper that adds a certificate to the machine's Trusted Root store and removes it on destruction. |
| 166 | class TrustedRootCertificate |
| 167 | { |
| 168 | public: |
| 169 | NON_COPYABLE(TrustedRootCertificate); |
| 170 | NON_MOVABLE(TrustedRootCertificate); |
| 171 | |
| 172 | explicit TrustedRootCertificate(const CERT_CONTEXT& Cert) |
| 173 | { |
| 174 | m_store.reset(CertOpenStore(CERT_STORE_PROV_SYSTEM_W, 0, NULL, CERT_SYSTEM_STORE_LOCAL_MACHINE, L"ROOT")); |
| 175 | THROW_LAST_ERROR_IF_NULL(m_store); |
| 176 | |
| 177 | PCCERT_CONTEXT added{}; |
| 178 | THROW_IF_WIN32_BOOL_FALSE(CertAddEncodedCertificateToStore( |
| 179 | m_store.get(), X509_ASN_ENCODING, Cert.pbCertEncoded, Cert.cbCertEncoded, CERT_STORE_ADD_REPLACE_EXISTING, &added)); |
| 180 | m_added.reset(added); |
| 181 | } |
| 182 | |
| 183 | ~TrustedRootCertificate() |
| 184 | { |
| 185 | if (m_added) |
| 186 | { |
| 187 | // CertDeleteCertificateFromStore frees the context; the store is still open here. |
| 188 | LOG_IF_WIN32_BOOL_FALSE(CertDeleteCertificateFromStore(m_added.release())); |
| 189 | } |
| 190 | } |
| 191 | |
| 192 | private: |
| 193 | wil::unique_hcertstore m_store; |
| 194 | wil::unique_cert_context m_added; |
| 195 | }; |
| 196 | |
| 197 | } // namespace |
| 198 | |
| 199 | class WSLCE2ETlsRegistryTests |
| 200 | { |
| 201 | WSLC_TEST_CLASS(WSLCE2ETlsRegistryTests) |
| 202 | |
| 203 | // Verifies registry SSL trust end to end: a push to a registry serving a private-CA cert fails |
| 204 | // with "unknown authority" and succeeds after the CA is added to the host Trusted Root store. |
| 205 | WSLC_TEST_METHOD(WSLCE2E_Registry_PrivateCa_SslTrust) |
| 206 | { |
| 207 | const auto& image = AlpineTestImage(); |
| 208 | const auto registryImage = |
| 209 | std::format(L"{}:{}/{}", wsl::shared::string::MultiByteToWide(c_registryIp), c_registryPort, image.NameAndTag()); |
| 210 | |
| 211 | // Generate the registry's certs. |
| 212 | const auto certDir = std::filesystem::temp_directory_path() / std::format(L"wslc-tls-registry-{}", GetCurrentProcessId()); |
| 213 | std::filesystem::create_directories(certDir); |
| 214 | auto removeCertDir = wil::scope_exit([&] { |
| 215 | std::error_code ec; |
| 216 | std::filesystem::remove_all(certDir, ec); |
| 217 | }); |
| 218 | |
| 219 | auto caCert = GenerateRegistryTlsCertificate(c_registryIp, certDir); |
| 220 | |
| 221 | // CA NOT trusted: the push fails with unknown authority. |
| 222 | { |
| 223 | auto session = TestSession::Create(L"wslc-tls-untrusted"); |
| 224 | |
| 225 | auto cleanup = wil::scope_exit([&] { EnsureSessionIsTerminated(L"wslc-tls-untrusted"); }); |
| 226 | |
| 227 | TestImageRegistry::Instance().EnsureLoaded(image, session.Name()); |
| 228 | |
| 229 | auto [registry, address] = StartLocalRegistry(session.Session(), "", "", c_registryPort, certDir.wstring()); |
| 230 | VERIFY_ARE_EQUAL(std::format("{}:{}", c_registryIp, c_registryPort), address); |
| 231 | |
| 232 | RunWslcAndVerify( |
| 233 | std::format(L"--session \"{}\" image tag {} {}", session.Name(), image.NameAndTag(), registryImage), {.ExitCode = 0}); |
| 234 | |
| 235 | auto result = RunWslc(std::format(L"--session \"{}\" push {}", session.Name(), registryImage)); |
| 236 | VERIFY_ARE_EQUAL(1u, result.ExitCode.value_or(0), L"Push should fail while the CA is not trusted"); |
| 237 | VERIFY_IS_TRUE(result.Stderr.has_value()); |
| 238 | VERIFY_IS_TRUE( |
| 239 | result.Stderr->find(L"certificate signed by unknown authority") != std::wstring::npos, |
| 240 | L"Expected an untrusted-certificate error"); |
| 241 | } |
| 242 | |
| 243 | // CA trusted: push succeeds. |
| 244 | { |
| 245 | // Trust the CA on the host. |
| 246 | TrustedRootCertificate trustedCa{*caCert.get()}; |
| 247 | |
| 248 | auto session = TestSession::Create(L"wslc-tls-trusted"); |
| 249 | |
| 250 | auto cleanup = wil::scope_exit([&] { EnsureSessionIsTerminated(L"wslc-tls-trusted"); }); |
| 251 | |
| 252 | TestImageRegistry::Instance().EnsureLoaded(image, session.Name()); |
| 253 | |
| 254 | auto [registry, address] = StartLocalRegistry(session.Session(), "", "", c_registryPort, certDir.wstring()); |
| 255 | VERIFY_ARE_EQUAL(std::format("{}:{}", c_registryIp, c_registryPort), address); |
| 256 | |
| 257 | RunWslcAndVerify( |
| 258 | std::format(L"--session \"{}\" image tag {} {}", session.Name(), image.NameAndTag(), registryImage), {.ExitCode = 0}); |
| 259 | |
| 260 | auto result = RunWslc(std::format(L"--session \"{}\" push {}", session.Name(), registryImage)); |
| 261 | VERIFY_ARE_EQUAL(0u, result.ExitCode.value_or(1), L"Push should succeed once the CA is trusted"); |
| 262 | } |
| 263 | } |
| 264 | }; |
| 265 | |
| 266 | } // namespace WSLCE2ETests |