commit 26d0a7430657a27b217f198270ca84746bf29f1f parent 8f2747f04a99ae45c9210d1ef9d2478d48309ec8 Author: KEINOS <github+fork-qiita-news@keinos.com> Date: Mon, 22 Sep 2025 22:25:00 +0000 Merge remote-tracking branch 'upstream/master' Diffstat:
14 files changed, 545 insertions(+), 59 deletions(-)
diff --git a/.github/actions/spelling/expect.txt b/.github/actions/spelling/expect.txt @@ -18,6 +18,7 @@ AMap Amd amrutha ansistring +anyissuer Aot APARTMENTTHREADED apfn @@ -106,6 +107,7 @@ countryregion Cov CPIL createmanifestmetadata +crt cswinrt ctc CTL @@ -167,6 +169,7 @@ FECAFEB fedorapeople fileinuse filemode +Filetime Filtercriteria Finalizers fintimes @@ -452,6 +455,7 @@ removefile reparse repeatedkey REQS +requirenonleaf restsource RGBQUAD rgp @@ -538,6 +542,7 @@ thiscouldbeapc threehundred timespan Tlg +TLSCAs tombstoned transitioning trimstart diff --git a/src/AppInstallerCLITests/Certificates.cpp b/src/AppInstallerCLITests/Certificates.cpp @@ -18,7 +18,7 @@ TEST_CASE("Certificates_NoPinningSucceeds", "[certificates]") PinningDetails actual; actual.LoadCertificate(IDX_CERTIFICATE_STORE_LEAF_2, CERTIFICATE_RESOURCE_TYPE); - REQUIRE(expected.Validate(actual.GetCertificate())); + REQUIRE(CertificatePinningValidationResult::Accepted == expected.Validate(actual.GetCertificate(), CertificateChainPosition::Leaf)); } TEST_CASE("Certificates_PublicKeyMismatch", "[certificates]") @@ -29,7 +29,7 @@ TEST_CASE("Certificates_PublicKeyMismatch", "[certificates]") PinningDetails actual; actual.LoadCertificate(IDX_CERTIFICATE_STORE_LEAF_2, CERTIFICATE_RESOURCE_TYPE); - REQUIRE(!expected.Validate(actual.GetCertificate())); + REQUIRE(CertificatePinningValidationResult::Rejected == expected.Validate(actual.GetCertificate(), CertificateChainPosition::Leaf)); } TEST_CASE("Certificates_PublicKeyMatch", "[certificates]") @@ -40,7 +40,7 @@ TEST_CASE("Certificates_PublicKeyMatch", "[certificates]") PinningDetails actual; actual.LoadCertificate(IDX_CERTIFICATE_STORE_ROOT_2, CERTIFICATE_RESOURCE_TYPE); - REQUIRE(expected.Validate(actual.GetCertificate())); + REQUIRE(CertificatePinningValidationResult::Accepted == expected.Validate(actual.GetCertificate(), CertificateChainPosition::Root)); } TEST_CASE("Certificates_SubjectMismatch", "[certificates]") @@ -51,7 +51,7 @@ TEST_CASE("Certificates_SubjectMismatch", "[certificates]") PinningDetails actual; actual.LoadCertificate(IDX_CERTIFICATE_STORE_INTERMEDIATE_2, CERTIFICATE_RESOURCE_TYPE); - REQUIRE(!expected.Validate(actual.GetCertificate())); + REQUIRE(CertificatePinningValidationResult::Rejected == expected.Validate(actual.GetCertificate(), CertificateChainPosition::Intermediate)); } TEST_CASE("Certificates_SubjectMatch", "[certificates]") @@ -62,7 +62,7 @@ TEST_CASE("Certificates_SubjectMatch", "[certificates]") PinningDetails actual; actual.LoadCertificate(IDX_CERTIFICATE_STORE_INTERMEDIATE_2, CERTIFICATE_RESOURCE_TYPE); - REQUIRE(expected.Validate(actual.GetCertificate())); + REQUIRE(CertificatePinningValidationResult::Accepted == expected.Validate(actual.GetCertificate(), CertificateChainPosition::Intermediate)); } TEST_CASE("Certificates_IssuerMismatch", "[certificates]") @@ -73,7 +73,7 @@ TEST_CASE("Certificates_IssuerMismatch", "[certificates]") PinningDetails actual; actual.LoadCertificate(IDX_CERTIFICATE_STORE_LEAF_2, CERTIFICATE_RESOURCE_TYPE); - REQUIRE(!expected.Validate(actual.GetCertificate())); + REQUIRE(CertificatePinningValidationResult::Rejected == expected.Validate(actual.GetCertificate(), CertificateChainPosition::Leaf)); } TEST_CASE("Certificates_IssuerMatch", "[certificates]") @@ -84,7 +84,7 @@ TEST_CASE("Certificates_IssuerMatch", "[certificates]") PinningDetails actual; actual.LoadCertificate(IDX_CERTIFICATE_STORE_LEAF_2, CERTIFICATE_RESOURCE_TYPE); - REQUIRE(expected.Validate(actual.GetCertificate())); + REQUIRE(CertificatePinningValidationResult::Accepted == expected.Validate(actual.GetCertificate(), CertificateChainPosition::Leaf)); } TEST_CASE("Certificates_ChainLengthDiffers", "[certificates]") @@ -104,6 +104,122 @@ TEST_CASE("Certificates_ChainLengthDiffers", "[certificates]") REQUIRE(!config.Validate(details.GetCertificate())); } +TEST_CASE("Certificates_ChainLengthDiffers_Partial", "[certificates]") +{ + PinningChain chain; + auto chainElement = chain.Root(); + chainElement->LoadCertificate(IDX_CERTIFICATE_STORE_ROOT_2, CERTIFICATE_RESOURCE_TYPE).SetPinning(PinningVerificationType::PublicKey); + chainElement = chainElement.Next(); + chainElement->LoadCertificate(IDX_CERTIFICATE_STORE_INTERMEDIATE_2, CERTIFICATE_RESOURCE_TYPE).SetPinning(PinningVerificationType::Subject | PinningVerificationType::Issuer); + chain.PartialChain(); + + PinningConfiguration config; + config.AddChain(chain); + + PinningDetails details; + details.LoadCertificate(IDX_CERTIFICATE_STORE_LEAF_2, CERTIFICATE_RESOURCE_TYPE); + + REQUIRE(config.Validate(details.GetCertificate())); +} + +TEST_CASE("CertificateChain_AnyIssuer_Intermediate", "[certificates]") +{ + PinningChain chain; + auto chainElement = chain.Root(); + chainElement->LoadCertificate(IDX_CERTIFICATE_STORE_INTERMEDIATE_2, CERTIFICATE_RESOURCE_TYPE).SetPinning(PinningVerificationType::PublicKey | PinningVerificationType::AnyIssuer | PinningVerificationType::RequireNonLeaf); + chain.PartialChain(); + + PinningConfiguration config; + config.AddChain(chain); + + PinningDetails details; + details.LoadCertificate(IDX_CERTIFICATE_STORE_LEAF_2, CERTIFICATE_RESOURCE_TYPE); + + REQUIRE(config.Validate(details.GetCertificate())); +} + +TEST_CASE("CertificateChain_AnyIssuer_IntermediateDiffers", "[certificates]") +{ + PinningChain chain; + auto chainElement = chain.Root(); + chainElement->LoadCertificate(IDX_CERTIFICATE_STORE_INTERMEDIATE_1, CERTIFICATE_RESOURCE_TYPE).SetPinning(PinningVerificationType::PublicKey | PinningVerificationType::AnyIssuer | PinningVerificationType::RequireNonLeaf); + chain.PartialChain(); + + PinningConfiguration config; + config.AddChain(chain); + + PinningDetails details; + details.LoadCertificate(IDX_CERTIFICATE_STORE_LEAF_2, CERTIFICATE_RESOURCE_TYPE); + + REQUIRE(!config.Validate(details.GetCertificate())); +} + +TEST_CASE("CertificateChain_AnyIssuer_IntermediateAndLeaf", "[certificates]") +{ + PinningChain chain; + auto chainElement = chain.Root(); + chainElement->LoadCertificate(IDX_CERTIFICATE_STORE_INTERMEDIATE_2, CERTIFICATE_RESOURCE_TYPE).SetPinning(PinningVerificationType::PublicKey | PinningVerificationType::AnyIssuer | PinningVerificationType::RequireNonLeaf); + chainElement = chainElement.Next(); + chainElement->LoadCertificate(IDX_CERTIFICATE_STORE_LEAF_2, CERTIFICATE_RESOURCE_TYPE).SetPinning(PinningVerificationType::Subject | PinningVerificationType::Issuer); + chain.PartialChain(); + + PinningConfiguration config; + config.AddChain(chain); + + PinningDetails details; + details.LoadCertificate(IDX_CERTIFICATE_STORE_LEAF_2, CERTIFICATE_RESOURCE_TYPE); + + REQUIRE(config.Validate(details.GetCertificate())); +} + +TEST_CASE("CertificateChain_AnyIssuer_Leaf", "[certificates]") +{ + PinningChain chain; + auto chainElement = chain.Root(); + chainElement->LoadCertificate(IDX_CERTIFICATE_STORE_LEAF_2, CERTIFICATE_RESOURCE_TYPE).SetPinning(PinningVerificationType::PublicKey | PinningVerificationType::AnyIssuer); + chain.PartialChain(); + + PinningConfiguration config; + config.AddChain(chain); + + PinningDetails details; + details.LoadCertificate(IDX_CERTIFICATE_STORE_LEAF_2, CERTIFICATE_RESOURCE_TYPE); + + REQUIRE(config.Validate(details.GetCertificate())); +} + +TEST_CASE("CertificateChain_AnyIssuer_LeafDiffers", "[certificates]") +{ + PinningChain chain; + auto chainElement = chain.Root(); + chainElement->LoadCertificate(IDX_CERTIFICATE_STORE_LEAF_1, CERTIFICATE_RESOURCE_TYPE).SetPinning(PinningVerificationType::PublicKey | PinningVerificationType::AnyIssuer); + chain.PartialChain(); + + PinningConfiguration config; + config.AddChain(chain); + + PinningDetails details; + details.LoadCertificate(IDX_CERTIFICATE_STORE_LEAF_2, CERTIFICATE_RESOURCE_TYPE); + + REQUIRE(!config.Validate(details.GetCertificate())); +} + +TEST_CASE("CertificateChain_AnyIssuer_SameLeaf_RequireNonLeaf", "[certificates]") +{ + PinningChain chain; + auto chainElement = chain.Root(); + chainElement->LoadCertificate(IDX_CERTIFICATE_STORE_LEAF_2, CERTIFICATE_RESOURCE_TYPE).SetPinning(PinningVerificationType::PublicKey | PinningVerificationType::AnyIssuer | PinningVerificationType::RequireNonLeaf); + chain.PartialChain(); + + PinningConfiguration config; + config.AddChain(chain); + + PinningDetails details; + details.LoadCertificate(IDX_CERTIFICATE_STORE_LEAF_2, CERTIFICATE_RESOURCE_TYPE); + + REQUIRE(!config.Validate(details.GetCertificate())); +} + TEST_CASE("Certificates_EmptyChainRejects", "[certificates]") { PinningChain chain; @@ -183,3 +299,47 @@ TEST_CASE("Certificates_MultipleChains_Success", "[certificates]") REQUIRE(config.Validate(details.GetCertificate())); } + +TEST_CASE("CertificateChain_Position", "[certificates]") +{ + CertificateChainPosition positions[3]{ CertificateChainPosition::Unknown, CertificateChainPosition::Unknown, CertificateChainPosition::Unknown }; + + PinningChain chain; + auto chainElement = chain.Root(); + chainElement->LoadCertificate(IDX_CERTIFICATE_STORE_ROOT_2, CERTIFICATE_RESOURCE_TYPE).SetCustomValidationFunction([&](const PinningDetails&, PCCERT_CONTEXT, CertificateChainPosition position) { positions[0] = position; return true; }); + chainElement = chainElement.Next(); + chainElement->LoadCertificate(IDX_CERTIFICATE_STORE_INTERMEDIATE_2, CERTIFICATE_RESOURCE_TYPE).SetCustomValidationFunction([&](const PinningDetails&, PCCERT_CONTEXT, CertificateChainPosition position) { positions[1] = position; return true; }); + chainElement = chainElement.Next(); + chainElement->LoadCertificate(IDX_CERTIFICATE_STORE_LEAF_2, CERTIFICATE_RESOURCE_TYPE).SetCustomValidationFunction([&](const PinningDetails&, PCCERT_CONTEXT, CertificateChainPosition position) { positions[2] = position; return true; }); + + PinningConfiguration config; + config.AddChain(chain); + + PinningDetails details; + details.LoadCertificate(IDX_CERTIFICATE_STORE_LEAF_2, CERTIFICATE_RESOURCE_TYPE); + + REQUIRE(config.Validate(details.GetCertificate())); + + REQUIRE(CertificateChainPosition::Root == positions[0]); + REQUIRE(CertificateChainPosition::Intermediate == positions[1]); + REQUIRE(CertificateChainPosition::Leaf == positions[2]); +} + +TEST_CASE("CertificateChain_Position_SelfSigned", "[certificates]") +{ + CertificateChainPosition positions[1]{ CertificateChainPosition::Unknown }; + + PinningChain chain; + auto chainElement = chain.Root(); + chainElement->LoadCertificate(IDX_CERTIFICATE_STORE_ROOT_2, CERTIFICATE_RESOURCE_TYPE).SetCustomValidationFunction([&](const PinningDetails&, PCCERT_CONTEXT, CertificateChainPosition position) { positions[0] = position; return true; }); + + PinningConfiguration config; + config.AddChain(chain); + + PinningDetails details; + details.LoadCertificate(IDX_CERTIFICATE_STORE_ROOT_2, CERTIFICATE_RESOURCE_TYPE); + + REQUIRE(config.Validate(details.GetCertificate())); + + REQUIRE((CertificateChainPosition::Root | CertificateChainPosition::Leaf) == positions[0]); +} diff --git a/src/AppInstallerCLITests/Sources.cpp b/src/AppInstallerCLITests/Sources.cpp @@ -1339,3 +1339,24 @@ TEST_CASE("RepoSources_BuiltInDesktopFrameworkSourceAlwaysCreatable", "[sources] Source source(WellKnownSource::DesktopFrameworks); REQUIRE(source); } + +TEST_CASE("RepoSources_MicrosoftStore_CertificatePinningLifetimeCheck", "[sources]") +{ + TestHook_ClearSourceFactoryOverrides(); + + GroupPolicyTestOverride policies; + policies.SetState(TogglePolicy::Policy::BypassCertificatePinningForMicrosoftStore, PolicyState::Disabled); + Source source(WellKnownSource::MicrosoftStore); + REQUIRE_FALSE(source.GetDetails().CertificatePinningConfiguration.IsEmpty()); + + // The configuration's remaining lifetime is the *maximum* of the remaining lifetimes of the individual chains. + // A chain's remaining lifetime is the *minimum* of the remaining lifetimes of the individual certificates. + // A certificate's remaining lifetime is a value between 0.0 and 1.0 that is the ratio of remaining valid time to total valid time. + + // The goal of this test is to warn when the pinning configuration may be in danger of expiration; either via certificate validity or + // more likely by renewals causing the pinning to reject the new, correct certificates. It operates in percentage lifetime to normalize + // the values across the chain. + INFO("If this test has failed, the pinning certificates may be nearing expiration and should be investigated."); + double lifetimePercentage = source.GetDetails().CertificatePinningConfiguration.GetRemainingLifetimePercentage(); + REQUIRE(lifetimePercentage > 0.25); +} diff --git a/src/AppInstallerRepositoryCore/SourceList.cpp b/src/AppInstallerRepositoryCore/SourceList.cpp @@ -343,9 +343,22 @@ namespace AppInstaller::Repository chainElement2 = chainElement2.Next(); chainElement2->LoadCertificate(IDX_CERTIFICATE_STORE_LEAF_2, CERTIFICATE_RESOURCE_TYPE).SetPinning(PinningVerificationType::Subject | PinningVerificationType::Issuer); + // See https://aka.ms/AzureTLSCAs (internal) for the source of these CAs + PinningChain chain3; + chain3.PartialChain().Root()-> + LoadCertificate(IDX_CERTIFICATE_MS_TLS_ECC_ROOT_G2, CERTIFICATE_RESOURCE_TYPE). + SetPinning(PinningVerificationType::PublicKey | PinningVerificationType::AnyIssuer | PinningVerificationType::RequireNonLeaf); + + PinningChain chain4; + chain4.PartialChain().Root()-> + LoadCertificate(IDX_CERTIFICATE_MS_TLS_RSA_ROOT_G2, CERTIFICATE_RESOURCE_TYPE). + SetPinning(PinningVerificationType::PublicKey | PinningVerificationType::AnyIssuer | PinningVerificationType::RequireNonLeaf); + details.CertificatePinningConfiguration = PinningConfiguration("Microsoft Store Source"); details.CertificatePinningConfiguration.AddChain(std::move(chain)); details.CertificatePinningConfiguration.AddChain(std::move(chain2)); + details.CertificatePinningConfiguration.AddChain(std::move(chain3)); + details.CertificatePinningConfiguration.AddChain(std::move(chain4)); } return details; diff --git a/src/AppInstallerSharedLib/Certificates.cpp b/src/AppInstallerSharedLib/Certificates.cpp @@ -2,6 +2,7 @@ // Licensed under the MIT License. #include "pch.h" #include "winget/Certificates.h" +#include "AppInstallerDateTime.h" #include "AppInstallerLogging.h" #include "AppInstallerStrings.h" #include "winget/JsonUtil.h" @@ -11,11 +12,18 @@ namespace AppInstaller::Certificates { namespace { - std::string GetSimpleDisplayName(PCCERT_CONTEXT certContext) + std::string GetNameString(PCCERT_CONTEXT certContext, DWORD nameType, bool forIssuer, void* typeParam = nullptr) { - DWORD characterCount = CertGetNameStringW(certContext, CERT_NAME_SIMPLE_DISPLAY_TYPE, 0, nullptr, nullptr, 0); + if (!certContext) + { + return "<no certificate loaded>"; + } + + DWORD flags = forIssuer ? CERT_NAME_ISSUER_FLAG : 0; + + DWORD characterCount = CertGetNameStringW(certContext, nameType, flags, typeParam, nullptr, 0); std::wstring result(characterCount, L'\0'); - characterCount = CertGetNameStringW(certContext, CERT_NAME_SIMPLE_DISPLAY_TYPE, 0, nullptr, &result[0], characterCount); + characterCount = CertGetNameStringW(certContext, nameType, flags, typeParam, &result[0], characterCount); if (static_cast<size_t>(characterCount) == result.size()) { @@ -27,6 +35,23 @@ namespace AppInstaller::Certificates } } + std::string GetSimpleDisplayName(PCCERT_CONTEXT certContext, bool forIssuer = false) + { + return GetNameString(certContext, CERT_NAME_SIMPLE_DISPLAY_TYPE, forIssuer); + } + + std::string GetX500Name(PCCERT_CONTEXT certContext, bool forIssuer = false) + { + DWORD stringType = CERT_X500_NAME_STR; + return GetNameString(certContext, CERT_NAME_RDN_TYPE, forIssuer, &stringType); + } + + std::string GetCommonName(PCCERT_CONTEXT certContext, bool forIssuer = false) + { + std::string commonName = szOID_COMMON_NAME; + return GetNameString(certContext, CERT_NAME_ATTR_TYPE, forIssuer, &commonName[0]); + } + std::string GetDescriptionOfCertChain(PCCERT_CHAIN_CONTEXT chainContext) { PCCERT_SIMPLE_CHAIN chain = chainContext->rgpChain[0]; @@ -72,9 +97,105 @@ namespace AppInstaller::Certificates { return PinningVerificationType::Issuer; } + else if (lowerValue == "anyissuer") + { + return PinningVerificationType::AnyIssuer; + } + else if (lowerValue == "requirenonleaf") + { + return PinningVerificationType::RequireNonLeaf; + } return {}; } + + CertificateChainPosition GetCertificateChainPosition(DWORD index, DWORD count) + { + THROW_HR_IF(E_INVALIDARG, count == 0); + + CertificateChainPosition position = CertificateChainPosition::Unknown; + + if (index == 0) + { + position |= CertificateChainPosition::Root; + } + + if (index > 0 && index < (count - 1)) + { + position |= CertificateChainPosition::Intermediate; + } + + if (index == (count - 1)) + { + position |= CertificateChainPosition::Leaf; + } + + return position; + } + } + + std::ostream& operator<<(std::ostream& out, PinningVerificationType value) + { + if (value == PinningVerificationType::None) + { + out << "None"; + } + else + { + bool prepend = false; + + for (const auto& flag : std::initializer_list<std::pair<PinningVerificationType, std::string_view>>{ + { PinningVerificationType::PublicKey, "PublicKey" }, + { PinningVerificationType::Subject, "Subject" }, + { PinningVerificationType::Issuer, "Issuer" }, + { PinningVerificationType::AnyIssuer, "AnyIssuer" }, + { PinningVerificationType::RequireNonLeaf, "RequireNonLeaf" }, + }) + { + if (WI_IsAnyFlagSet(value, flag.first)) + { + if (prepend) + { + out << " | "; + } + out << flag.second; + prepend = true; + } + } + } + + return out; + } + + std::ostream& operator<<(std::ostream& out, CertificateChainPosition value) + { + if (value == CertificateChainPosition::Unknown) + { + out << "Unknown"; + } + else + { + bool prepend = false; + + for (const auto& flag : std::initializer_list<std::pair<CertificateChainPosition, std::string_view>>{ + { CertificateChainPosition::Root, "Root" }, + { CertificateChainPosition::Intermediate, "Intermediate" }, + { CertificateChainPosition::Leaf, "Leaf" }, + }) + { + if (WI_IsAnyFlagSet(value, flag.first)) + { + if (prepend) + { + out << " | "; + } + out << flag.second; + prepend = true; + } + } + } + + return out; } PinningDetails& PinningDetails::LoadCertificate(int resource, int resourceType) @@ -160,47 +281,104 @@ namespace AppInstaller::Certificates LoadCertificate(embeddedCertificateBytes); return true; + } - bool PinningDetails::Validate(PCCERT_CONTEXT certContext) const + CertificatePinningValidationResult PinningDetails::Validate(PCCERT_CONTEXT certContext, CertificateChainPosition position) const { + CertificatePinningValidationResult failResult = WI_IsFlagSet(m_pinning, PinningVerificationType::AnyIssuer) ? CertificatePinningValidationResult::Skipped : CertificatePinningValidationResult::Rejected; + + if (WI_IsFlagSet(m_pinning, PinningVerificationType::RequireNonLeaf) && + WI_IsFlagSet(position, CertificateChainPosition::Leaf)) + { + AICLI_LOG(Core, Verbose, << "Required non-leaf mismatch: Expected certificate [" << GetSimpleDisplayName(m_certificateContext.get()) << "], Actual certificate [" << GetSimpleDisplayName(certContext) << "] was " << position); + return CertificatePinningValidationResult::Rejected; + } + if (WI_IsFlagSet(m_pinning, PinningVerificationType::PublicKey)) { + THROW_HR_IF(E_NOT_VALID_STATE, !m_certificateContext); + if (!CertComparePublicKeyInfo( X509_ASN_ENCODING | PKCS_7_ASN_ENCODING, &m_certificateContext.get()->pCertInfo->SubjectPublicKeyInfo, &certContext->pCertInfo->SubjectPublicKeyInfo)) { AICLI_LOG(Core, Verbose, << "Public key mismatch: Expected certificate [" << GetSimpleDisplayName(m_certificateContext.get()) << "], Actual certificate [" << GetSimpleDisplayName(certContext) << "]"); - return false; + return failResult; } } if (WI_IsFlagSet(m_pinning, PinningVerificationType::Subject)) { + THROW_HR_IF(E_NOT_VALID_STATE, !m_certificateContext); + if (!CertCompareCertificateName( X509_ASN_ENCODING | PKCS_7_ASN_ENCODING, &m_certificateContext.get()->pCertInfo->Subject, &certContext->pCertInfo->Subject)) { AICLI_LOG(Core, Verbose, << "Subject mismatch: Expected certificate [" << GetSimpleDisplayName(m_certificateContext.get()) << "], Actual certificate [" << GetSimpleDisplayName(certContext) << "]"); - return false; + return failResult; } } if (WI_IsFlagSet(m_pinning, PinningVerificationType::Issuer)) { + THROW_HR_IF(E_NOT_VALID_STATE, !m_certificateContext); + if (!CertCompareCertificateName( X509_ASN_ENCODING | PKCS_7_ASN_ENCODING, &m_certificateContext.get()->pCertInfo->Issuer, &certContext->pCertInfo->Issuer)) { AICLI_LOG(Core, Verbose, << "Issuer mismatch: Expected certificate [" << GetSimpleDisplayName(m_certificateContext.get()) << "], Actual certificate [" << GetSimpleDisplayName(certContext) << "]"); - return false; + return failResult; } } - return true; +#ifndef AICLI_DISABLE_TEST_HOOKS + if (m_customValidation) + { + if (!m_customValidation(*this, certContext, position)) + { + AICLI_LOG(Core, Verbose, << "Custom validation returned false: Expected certificate [" << GetSimpleDisplayName(m_certificateContext.get()) << "], Actual certificate [" << GetSimpleDisplayName(certContext) << "]"); + return failResult; + } + } +#endif + + return CertificatePinningValidationResult::Accepted; + } + + void PinningDetails::OutputDescription(std::ostream& stream, std::string_view indent) const + { + stream << indent << GetSimpleDisplayName(m_certificateContext.get()) << " : " << m_pinning; + } + + double PinningDetails::GetRemainingLifetimePercentage() const + { + THROW_HR_IF(E_NOT_VALID_STATE, !m_certificateContext); + + auto notBefore = Utility::ConvertFiletimeToSystemClock(m_certificateContext.get()->pCertInfo->NotBefore); + auto notAfter = Utility::ConvertFiletimeToSystemClock(m_certificateContext.get()->pCertInfo->NotAfter); + THROW_HR_IF(E_NOT_VALID_STATE, notBefore > notAfter); + + auto now = std::chrono::system_clock::now(); + + if (now < notBefore) + { + return 1.0; + } + else if (now > notAfter) + { + return 0.0; + } + + auto totalTime = notAfter - notBefore; + auto remainingTime = notAfter - now; + + return static_cast<double>(remainingTime.count()) / static_cast<double>(totalTime.count()); } PinningChain::Node PinningChain::Node::Next() @@ -248,6 +426,12 @@ namespace AppInstaller::Certificates return { const_cast<std::vector<PinningDetails>&>(m_chain), 0 }; } + PinningChain& PinningChain::PartialChain(bool isPartial) + { + m_partial = isPartial; + return *this; + } + bool PinningChain::Validate(PCCERT_CHAIN_CONTEXT chainContext) const { if (m_chain.empty()) @@ -281,12 +465,14 @@ namespace AppInstaller::Certificates return false; } - if (static_cast<size_t>(chain->cElement) != m_chain.size()) + if (!m_partial && static_cast<size_t>(chain->cElement) != m_chain.size()) { AICLI_LOG(Core, Verbose, << "Rejecting simple chain context based on size: expected " << m_chain.size() << ", got " << chain->cElement); return false; } + size_t currentDetailsIndex = 0; + for (DWORD i = 0; i < chain->cElement; ++i) { PCCERT_CHAIN_ELEMENT element = chain->rgpElement[(chain->cElement - 1) - i]; @@ -297,13 +483,30 @@ namespace AppInstaller::Certificates return false; } - if (!m_chain[i].Validate(element->pCertContext)) + CertificatePinningValidationResult result = m_chain[currentDetailsIndex].Validate(element->pCertContext, GetCertificateChainPosition(i, chain->cElement)); + + if (result == CertificatePinningValidationResult::Rejected) { return false; } + else if (result == CertificatePinningValidationResult::Accepted) + { + ++currentDetailsIndex; + } + else + { + THROW_HR_IF(E_UNEXPECTED, !m_partial || result != CertificatePinningValidationResult::Skipped); + AICLI_LOG(Core, Verbose, << "Skipping [" << GetSimpleDisplayName(element->pCertContext) << "] in partial chain validation."); + } + + if (m_partial && m_chain.size() == currentDetailsIndex) + { + break; + } } - return true; + // Ensure that all chain elements have been accepted + return m_chain.size() == currentDetailsIndex; } std::string PinningChain::GetDescription() const @@ -322,47 +525,12 @@ namespace AppInstaller::Certificates { stream << std::endl; } - - stream << indent; - - if (details.GetCertificate()) - { - stream << GetSimpleDisplayName(details.GetCertificate()) << " : "; - } - - if (details.GetPinning() == PinningVerificationType::None) - { - stream << "<No verification>"; - } - - bool prepend = false; - - if (WI_IsFlagSet(details.GetPinning(), PinningVerificationType::PublicKey)) + else if (m_partial) { - stream << "PublicKey"; - prepend = true; - } - - if (WI_IsFlagSet(details.GetPinning(), PinningVerificationType::Subject)) - { - if (prepend) - { - stream << " | "; - } - stream << "Subject"; - prepend = true; - } - - if (WI_IsFlagSet(details.GetPinning(), PinningVerificationType::Issuer)) - { - if (prepend) - { - stream << " | "; - } - stream << "Issuer"; - prepend = true; + stream << "[Partial Chain Validation]" << std::endl; } + details.OutputDescription(stream, indent); indent.append(" "); } @@ -413,6 +581,18 @@ namespace AppInstaller::Certificates return true; } + double PinningChain::GetRemainingLifetimePercentage() const + { + double result = 1.0; + + for (const auto& details : m_chain) + { + result = std::min(result, details.GetRemainingLifetimePercentage()); + } + + return result; + } + PinningConfiguration::PinningConfiguration(std::string identifier) : m_identifier(identifier) { if (m_identifier.empty()) @@ -543,4 +723,16 @@ namespace AppInstaller::Certificates return true; } + + double PinningConfiguration::GetRemainingLifetimePercentage() const + { + double result = 0.0; + + for (const auto& chain : m_configuration) + { + result = std::max(result, chain.GetRemainingLifetimePercentage()); + } + + return result; + } } diff --git a/src/AppInstallerSharedLib/DateTime.cpp b/src/AppInstallerSharedLib/DateTime.cpp @@ -144,6 +144,23 @@ namespace AppInstaller::Utility return std::chrono::system_clock::from_time_t(static_cast<time_t>(epoch)); } + std::chrono::system_clock::time_point ConvertFiletimeToSystemClock(const FILETIME& fileTime) + { + // Windows epoch (1601) to Unix epoch (1970) offset in 100-nanosecond intervals + constexpr int64_t EPOCH_DIFFERENCE = 116444736000000000LL; + + // Combine FILETIME into a 64-bit value + uint64_t fileTimeValue = (static_cast<uint64_t>(fileTime.dwHighDateTime) << 32) | fileTime.dwLowDateTime; + + // Convert to 100-nanosecond intervals since Unix epoch + int64_t unixTime100ns = static_cast<int64_t>(fileTimeValue) - EPOCH_DIFFERENCE; + + // Convert to chrono duration (system_clock::duration is usually nanoseconds or microseconds) + return std::chrono::system_clock::time_point( + std::chrono::duration_cast<std::chrono::system_clock::duration>( + std::chrono::nanoseconds(unixTime100ns * 100))); + } + std::chrono::system_clock::time_point GetTimePointFromVersion(const UInt64Version& version) { // Our custom format for converting UTC into a version is: diff --git a/src/AppInstallerSharedLib/Public/AppInstallerDateTime.h b/src/AppInstallerSharedLib/Public/AppInstallerDateTime.h @@ -57,6 +57,9 @@ namespace AppInstaller::Utility // Converts the given unix epoch time to a system_clock::time_point. std::chrono::system_clock::time_point ConvertUnixEpochToSystemClock(int64_t epoch); + // Converts the given file time to a system_clock::time_point. + std::chrono::system_clock::time_point ConvertFiletimeToSystemClock(const FILETIME& fileTime); + // Converts the given package version into a time_point using our custom format. // Ensure that the package is expected to use this format, or you may get strange times. // If the version is not convertable, the minimum time is returned. diff --git a/src/AppInstallerSharedLib/Public/winget/Certificates.h b/src/AppInstallerSharedLib/Public/winget/Certificates.h @@ -7,6 +7,7 @@ #include <wil/resource.h> #include <functional> +#include <ostream> #include <vector> @@ -15,14 +16,53 @@ namespace AppInstaller::Certificates // Defines the types of certificate pinning to perform. enum class PinningVerificationType : uint32_t { + // No validation; accepts anything. None = 0x0, + // Validates that the public keys match; requires a certificate to be loaded. PublicKey = 0x1, + // Validates that the full subjects match; requires a certificate to be loaded. Subject = 0x2, + // Validates that the full issuers match; requires a certificate to be loaded. Issuer = 0x4, + // Allows for unknown certificates in the chain; will continue to consume certificates from the chain until it matches. + // Requires partial chain. + AnyIssuer = 0x8, + // Requires that the certificate is not a leaf. + RequireNonLeaf = 0x10, }; DEFINE_ENUM_FLAG_OPERATORS(PinningVerificationType); + std::ostream& operator<<(std::ostream& out, PinningVerificationType value); + + // The position within a chain that a certificate is located. + enum class CertificateChainPosition + { + Unknown = 0x0, + // The start of the chain. + Root = 0x1, + // An indeterminate intermediate along the chain. + Intermediate = 0x2, + // The final certificate of the chain. + Leaf = 0x4, + }; + + DEFINE_ENUM_FLAG_OPERATORS(CertificateChainPosition); + + std::ostream& operator<<(std::ostream& out, CertificateChainPosition value); + + // The result of validating a single certificate. + enum class CertificatePinningValidationResult + { + // The certificate was accepted as valid. + Accepted, + // The certificate was rejected as invalid. + Rejected, + // The next certificate in the chain should be validated against the current details. + // For use by partial chain validation that does not require exacting chain configurations. + Skipped, + }; + // Contains the specific information about a certificate to pin. struct PinningDetails { @@ -44,13 +84,24 @@ namespace AppInstaller::Certificates PinningVerificationType GetPinning() const { return m_pinning; } // Validates the given certificate against the pinning information. - // Returns true to indicate that the certificate meets the pinning configuration criteria. - // Returns false to indicate the it does not. - bool Validate(PCCERT_CONTEXT certContext) const; + CertificatePinningValidationResult Validate(PCCERT_CONTEXT certContext, CertificateChainPosition position) const; + + // Outputs a description of the pinning details. + void OutputDescription(std::ostream& stream, std::string_view indent) const; // Loads the pinning details from the given JSON. [[nodiscard]] bool LoadFrom(const Json::Value& configuration); + // Determines how far the certificate is through its lifespan. + double GetRemainingLifetimePercentage() const; + +#ifndef AICLI_DISABLE_TEST_HOOKS + using CustomValidationFunction = std::function<bool(const PinningDetails&, PCCERT_CONTEXT, CertificateChainPosition)>; + void SetCustomValidationFunction(CustomValidationFunction function) { m_customValidation = std::move(function); } + private: + CustomValidationFunction m_customValidation; +#endif + private: wil::shared_cert_context m_certificateContext; PinningVerificationType m_pinning = PinningVerificationType::None; @@ -97,6 +148,10 @@ namespace AppInstaller::Certificates Node Root(); const Node Root() const; + // A partial chain will validate success if all of its components are successful. + PinningChain& PartialChain(bool isPartial = true); + bool IsPartialChain() const { return m_partial; } + // Validates the given certificate chain against the configuration. // Returns true to indicate that the chain meets the pinning configuration criteria. // Returns false to indicate the it does not. @@ -108,8 +163,12 @@ namespace AppInstaller::Certificates // Loads the pinning chain from the given JSON. [[nodiscard]] bool LoadFrom(const Json::Value& configuration); + // Determines how far the certificate chain is through its lifespan (the minimum of all of its certificates). + double GetRemainingLifetimePercentage() const; + private: std::vector<PinningDetails> m_chain; + bool m_partial = false; }; // Holds the details about how a certificate chain is to be validated (aka "pinned"). @@ -138,6 +197,9 @@ namespace AppInstaller::Certificates // Loads the pinning configuration from the given JSON. [[nodiscard]] bool LoadFrom(const Json::Value& configuration); + // Determines how far the configuration is through its lifespan (the maximum of all of its chains). + double GetRemainingLifetimePercentage() const; + private: // The identifier used when logging. std::string m_identifier; diff --git a/src/CertificateResources/CertificateResources.h b/src/CertificateResources/CertificateResources.h @@ -10,3 +10,5 @@ #define IDX_CERTIFICATE_STORE_ROOT_2 404 #define IDX_CERTIFICATE_STORE_INTERMEDIATE_2 405 #define IDX_CERTIFICATE_STORE_LEAF_2 406 +#define IDX_CERTIFICATE_MS_TLS_ECC_ROOT_G2 407 +#define IDX_CERTIFICATE_MS_TLS_RSA_ROOT_G2 408 diff --git a/src/CertificateResources/CertificateResources.rc b/src/CertificateResources/CertificateResources.rc @@ -71,3 +71,6 @@ IDX_CERTIFICATE_STORE_LEAF_1 CERTIFICATE_RESOURCE_TYPE "StoreLeaf IDX_CERTIFICATE_STORE_ROOT_2 CERTIFICATE_RESOURCE_TYPE "StoreRoot2.cer" IDX_CERTIFICATE_STORE_INTERMEDIATE_2 CERTIFICATE_RESOURCE_TYPE "StoreIntermediate2.cer" IDX_CERTIFICATE_STORE_LEAF_2 CERTIFICATE_RESOURCE_TYPE "StoreLeaf2.cer" + +IDX_CERTIFICATE_MS_TLS_ECC_ROOT_G2 CERTIFICATE_RESOURCE_TYPE "Microsoft_TLS_ECC_Root_G2.crt" +IDX_CERTIFICATE_MS_TLS_RSA_ROOT_G2 CERTIFICATE_RESOURCE_TYPE "Microsoft_TLS_RSA_Root_G2.crt" diff --git a/src/CertificateResources/CertificateResources.vcxitems b/src/CertificateResources/CertificateResources.vcxitems @@ -14,6 +14,8 @@ <ProjectCapability Include="SourceItemsFromImports" /> </ItemGroup> <ItemGroup> + <None Include="$(MSBuildThisFileDirectory)Microsoft_TLS_ECC_Root_G2.crt" /> + <None Include="$(MSBuildThisFileDirectory)Microsoft_TLS_RSA_Root_G2.crt" /> <None Include="$(MSBuildThisFileDirectory)StoreRoot1.cer" /> <None Include="$(MSBuildThisFileDirectory)StoreIntermediate1.cer" /> <None Include="$(MSBuildThisFileDirectory)StoreLeaf1.cer" /> diff --git a/src/CertificateResources/CertificateResources.vcxitems.filters b/src/CertificateResources/CertificateResources.vcxitems.filters @@ -24,6 +24,12 @@ <None Include="$(MSBuildThisFileDirectory)StoreLeaf2.cer"> <Filter>Certificates</Filter> </None> + <None Include="$(MSBuildThisFileDirectory)Microsoft_TLS_ECC_Root_G2.crt"> + <Filter>Certificates</Filter> + </None> + <None Include="$(MSBuildThisFileDirectory)Microsoft_TLS_RSA_Root_G2.crt"> + <Filter>Certificates</Filter> + </None> </ItemGroup> <ItemGroup> <ClInclude Include="$(MSBuildThisFileDirectory)resource.h" /> diff --git a/src/CertificateResources/Microsoft_TLS_ECC_Root_G2.crt b/src/CertificateResources/Microsoft_TLS_ECC_Root_G2.crt Binary files differ. diff --git a/src/CertificateResources/Microsoft_TLS_RSA_Root_G2.crt b/src/CertificateResources/Microsoft_TLS_RSA_Root_G2.crt Binary files differ.