Bug 916629, Part 3: Unit tests for OCSP responses signed by a delegated OCSP responder for mozilla::pkix, r=keeler
authorBrian Smith <brian@briansmith.org>
Thu, 10 Jul 2014 22:14:57 -0700
changeset 216111 6dc520f8b95ea38f6f9805dc00b2f8a97bf5be30
parent 216110 a5c52b2046a53491f7d0cb51cfe1ececc12dfa91
child 216112 8700531ef25f26efc098566331774c214bfab793
push id515
push userraliiev@mozilla.com
push dateMon, 06 Oct 2014 12:51:51 +0000
treeherdermozilla-release@267c7a481bef [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewerskeeler
bugs916629
milestone33.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 916629, Part 3: Unit tests for OCSP responses signed by a delegated OCSP responder for mozilla::pkix, r=keeler
security/pkix/test/gtest/pkixocsp_VerifyEncodedOCSPResponse.cpp
security/pkix/test/lib/pkixtestutil.cpp
security/pkix/test/lib/pkixtestutil.h
--- a/security/pkix/test/gtest/pkixocsp_VerifyEncodedOCSPResponse.cpp
+++ b/security/pkix/test/gtest/pkixocsp_VerifyEncodedOCSPResponse.cpp
@@ -341,8 +341,453 @@ TEST_F(pkixocsp_VerifyEncodedResponse_su
   ASSERT_TRUE(response);
   bool expired;
   ASSERT_SECFailure(SEC_ERROR_OCSP_UNKNOWN_CERT,
                     VerifyEncodedOCSPResponse(trustDomain, *endEntityCertID, now,
                                               END_ENTITY_MAX_LIFETIME_IN_DAYS,
                                               *response, expired));
   ASSERT_FALSE(expired);
 }
+
+///////////////////////////////////////////////////////////////////////////////
+// indirect responses (signed by a delegated OCSP responder cert)
+
+class pkixocsp_VerifyEncodedResponse_DelegatedResponder
+  : public pkixocsp_VerifyEncodedResponse_successful
+{
+protected:
+  // certSubjectName should be unique for each call. This way, we avoid any
+  // issues with NSS caching the certificates internally. For the same reason,
+  // we generate a new keypair on each call. Either one of these should be
+  // sufficient to avoid issues with the NSS cache, but we do both to be
+  // cautious.
+  //
+  // signerName should be byKey to use the byKey ResponderID construction, or
+  // another value (usually equal to certSubjectName) to use the byName
+  // ResponderID construction.
+  //
+  // If signerEKU is omitted, then the certificate will have the
+  // id-kp-OCSPSigning EKU. If signerEKU is SEC_OID_UNKNOWN then it will not
+  // have any EKU extension. Otherwise, the certificate will have the given
+  // EKU.
+  //
+  // signerDEROut is owned by the arena
+  SECItem* CreateEncodedIndirectOCSPSuccessfulResponse(
+              const char* certSubjectName,
+              OCSPResponseContext::CertStatus certStatus,
+              const char* signerName,
+              SECOidTag signerEKU = SEC_OID_OCSP_RESPONDER,
+              /*optional, out*/ const SECItem** signerDEROut = nullptr)
+  {
+    PR_ASSERT(certSubjectName);
+
+    const SECItem* extensions[] = {
+      signerEKU != SEC_OID_UNKNOWN
+        ? CreateEncodedEKUExtension(arena.get(), &signerEKU, 1,
+                                    ExtensionCriticality::NotCritical)
+        : nullptr,
+      nullptr
+    };
+    ScopedSECKEYPrivateKey signerPrivateKey;
+    SECItem* signerDER(CreateEncodedCertificate(
+                          arena.get(), ++rootIssuedCount, rootName,
+                          oneDayBeforeNow, oneDayAfterNow, certSubjectName,
+                          signerEKU != SEC_OID_UNKNOWN ? extensions : nullptr,
+                          rootPrivateKey.get(), signerPrivateKey));
+    EXPECT_TRUE(signerDER);
+    if (!signerDER) {
+      return nullptr;
+    }
+
+    const SECItem* signerNameDER = nullptr;
+    if (signerName) {
+      signerNameDER = ASCIIToDERName(arena.get(), signerName);
+      if (!signerNameDER) {
+        return nullptr;
+      }
+    }
+    if (signerDEROut) {
+      *signerDEROut = signerDER;
+    }
+    SECItem const* const certs[] = { signerDER, nullptr };
+    return CreateEncodedOCSPSuccessfulResponse(certStatus, *endEntityCertID,
+                                               signerName, signerPrivateKey,
+                                               oneDayBeforeNow, oneDayBeforeNow,
+                                               &oneDayAfterNow, certs);
+  }
+
+  static SECItem* CreateEncodedCertificate(PLArenaPool* arena,
+                                           uint32_t serialNumber,
+                                           const char* issuer,
+                                           PRTime notBefore,
+                                           PRTime notAfter,
+                                           const char* subject,
+                              /*optional*/ SECItem const* const* extensions,
+                              /*optional*/ SECKEYPrivateKey* signerKey,
+                                   /*out*/ ScopedSECKEYPrivateKey& privateKey)
+  {
+    const SECItem* serialNumberDER(CreateEncodedSerialNumber(arena,
+                                                             serialNumber));
+    if (!serialNumberDER) {
+      return nullptr;
+    }
+    const SECItem* issuerDER(ASCIIToDERName(arena, issuer));
+    if (!issuerDER) {
+      return nullptr;
+    }
+    const SECItem* subjectDER(ASCIIToDERName(arena, subject));
+    if (!subjectDER) {
+      return nullptr;
+    }
+    return ::mozilla::pkix::test::CreateEncodedCertificate(
+                                    arena, v3,
+                                    SEC_OID_PKCS1_SHA256_WITH_RSA_ENCRYPTION,
+                                    serialNumberDER, issuerDER, notBefore,
+                                    notAfter, subjectDER, extensions,
+                                    signerKey, SEC_OID_SHA256, privateKey);
+  }
+};
+
+TEST_F(pkixocsp_VerifyEncodedResponse_DelegatedResponder, good_byKey)
+{
+  SECItem* response(CreateEncodedIndirectOCSPSuccessfulResponse(
+                      "CN=good_indirect_byKey", OCSPResponseContext::good,
+                      byKey));
+  ASSERT_TRUE(response);
+  bool expired;
+  ASSERT_SECSuccess(VerifyEncodedOCSPResponse(trustDomain, *endEntityCertID, now,
+                                              END_ENTITY_MAX_LIFETIME_IN_DAYS,
+                                              *response, expired));
+  ASSERT_FALSE(expired);
+}
+
+TEST_F(pkixocsp_VerifyEncodedResponse_DelegatedResponder, good_byName)
+{
+  SECItem* response(CreateEncodedIndirectOCSPSuccessfulResponse(
+                      "CN=good_indirect_byName", OCSPResponseContext::good,
+                      "CN=good_indirect_byName"));
+  ASSERT_TRUE(response);
+  bool expired;
+  ASSERT_SECSuccess(VerifyEncodedOCSPResponse(trustDomain, *endEntityCertID, now,
+                                              END_ENTITY_MAX_LIFETIME_IN_DAYS,
+                                              *response, expired));
+  ASSERT_FALSE(expired);
+}
+
+TEST_F(pkixocsp_VerifyEncodedResponse_DelegatedResponder,
+       good_byKey_missing_signer)
+{
+  ScopedSECKEYPublicKey missingSignerPublicKey;
+  ScopedSECKEYPrivateKey missingSignerPrivateKey;
+  ASSERT_SECSuccess(GenerateKeyPair(missingSignerPublicKey,
+                                    missingSignerPrivateKey));
+  SECItem* response(CreateEncodedOCSPSuccessfulResponse(
+                      OCSPResponseContext::good, *endEntityCertID, byKey,
+                      missingSignerPrivateKey, oneDayBeforeNow,
+                      oneDayBeforeNow, nullptr));
+  ASSERT_TRUE(response);
+  bool expired;
+  ASSERT_SECFailure(SEC_ERROR_OCSP_INVALID_SIGNING_CERT,
+                    VerifyEncodedOCSPResponse(trustDomain, *endEntityCertID, now,
+                                              END_ENTITY_MAX_LIFETIME_IN_DAYS,
+                                              *response, expired));
+  ASSERT_FALSE(expired);
+}
+
+TEST_F(pkixocsp_VerifyEncodedResponse_DelegatedResponder,
+       good_byName_missing_signer)
+{
+  ScopedSECKEYPublicKey missingSignerPublicKey;
+  ScopedSECKEYPrivateKey missingSignerPrivateKey;
+  ASSERT_SECSuccess(GenerateKeyPair(missingSignerPublicKey,
+                                    missingSignerPrivateKey));
+  SECItem* response(CreateEncodedOCSPSuccessfulResponse(
+                      OCSPResponseContext::good, *endEntityCertID, "CN=missing",
+                      missingSignerPrivateKey, oneDayBeforeNow,
+                      oneDayBeforeNow, nullptr));
+  ASSERT_TRUE(response);
+  bool expired;
+  ASSERT_SECFailure(SEC_ERROR_OCSP_INVALID_SIGNING_CERT,
+                    VerifyEncodedOCSPResponse(trustDomain, *endEntityCertID, now,
+                                              END_ENTITY_MAX_LIFETIME_IN_DAYS,
+                                              *response, expired));
+  ASSERT_FALSE(expired);
+}
+
+TEST_F(pkixocsp_VerifyEncodedResponse_DelegatedResponder, good_expired)
+{
+  static const SECOidTag signerEKU = SEC_OID_OCSP_RESPONDER;
+  static const char* signerName = "CN=good_indirect_expired";
+
+  const SECItem* extensions[] = {
+    CreateEncodedEKUExtension(arena.get(), &signerEKU, 1,
+                              ExtensionCriticality::NotCritical),
+    nullptr
+  };
+  ScopedSECKEYPrivateKey signerPrivateKey;
+  SECItem* signerDER(CreateEncodedCertificate(arena.get(), ++rootIssuedCount,
+                                              rootName,
+                                              now - (10 * ONE_DAY),
+                                              now - (2 * ONE_DAY),
+                                              signerName, extensions,
+                                              rootPrivateKey.get(),
+                                              signerPrivateKey));
+  ASSERT_TRUE(signerDER);
+
+  SECItem const* const certs[] = { signerDER, nullptr };
+  SECItem* response(CreateEncodedOCSPSuccessfulResponse(
+                      OCSPResponseContext::good, *endEntityCertID, signerName,
+                      signerPrivateKey, oneDayBeforeNow, oneDayBeforeNow,
+                      &oneDayAfterNow,
+                      certs));
+  ASSERT_TRUE(response);
+
+  bool expired;
+  ASSERT_SECFailure(SEC_ERROR_OCSP_INVALID_SIGNING_CERT,
+                    VerifyEncodedOCSPResponse(trustDomain, *endEntityCertID, now,
+                                              END_ENTITY_MAX_LIFETIME_IN_DAYS,
+                                              *response, expired));
+}
+
+TEST_F(pkixocsp_VerifyEncodedResponse_DelegatedResponder, good_future)
+{
+  static const SECOidTag signerEKU = SEC_OID_OCSP_RESPONDER;
+  static const char* signerName = "CN=good_indirect_future";
+
+  const SECItem* extensions[] = {
+    CreateEncodedEKUExtension(arena.get(), &signerEKU, 1,
+                              ExtensionCriticality::NotCritical),
+    nullptr
+  };
+  ScopedSECKEYPrivateKey signerPrivateKey;
+  SECItem* signerDER(CreateEncodedCertificate(arena.get(), ++rootIssuedCount,
+                                              rootName,
+                                              now + (2 * ONE_DAY),
+                                              now + (10 * ONE_DAY),
+                                              signerName, extensions,
+                                              rootPrivateKey.get(),
+                                              signerPrivateKey));
+  ASSERT_TRUE(signerDER);
+
+  SECItem const* const certs[] = { signerDER, nullptr };
+  SECItem* response(CreateEncodedOCSPSuccessfulResponse(
+                      OCSPResponseContext::good, *endEntityCertID,
+                      signerName, signerPrivateKey, oneDayBeforeNow,
+                      oneDayBeforeNow, &oneDayAfterNow, certs));
+  ASSERT_TRUE(response);
+
+  bool expired;
+  ASSERT_SECFailure(SEC_ERROR_OCSP_INVALID_SIGNING_CERT,
+                    VerifyEncodedOCSPResponse(trustDomain, *endEntityCertID, now,
+                                              END_ENTITY_MAX_LIFETIME_IN_DAYS,
+                                              *response, expired));
+  ASSERT_FALSE(expired);
+}
+
+TEST_F(pkixocsp_VerifyEncodedResponse_DelegatedResponder, good_no_eku)
+{
+  SECItem* response(CreateEncodedIndirectOCSPSuccessfulResponse(
+                      "CN=good_indirect_wrong_eku", OCSPResponseContext::good,
+                      byKey, SEC_OID_UNKNOWN));
+  ASSERT_TRUE(response);
+  bool expired;
+  ASSERT_SECFailure(SEC_ERROR_OCSP_INVALID_SIGNING_CERT,
+                    VerifyEncodedOCSPResponse(trustDomain, *endEntityCertID, now,
+                                              END_ENTITY_MAX_LIFETIME_IN_DAYS,
+                                              *response, expired));
+  ASSERT_FALSE(expired);
+}
+
+TEST_F(pkixocsp_VerifyEncodedResponse_DelegatedResponder,
+       good_indirect_wrong_eku)
+{
+  SECItem* response(CreateEncodedIndirectOCSPSuccessfulResponse(
+                      "CN=good_indirect_wrong_eku", OCSPResponseContext::good,
+                      byKey, SEC_OID_EXT_KEY_USAGE_SERVER_AUTH));
+  ASSERT_TRUE(response);
+  bool expired;
+  ASSERT_SECFailure(SEC_ERROR_OCSP_INVALID_SIGNING_CERT,
+                    VerifyEncodedOCSPResponse(trustDomain, *endEntityCertID, now,
+                                              END_ENTITY_MAX_LIFETIME_IN_DAYS,
+                                              *response, expired));
+  ASSERT_FALSE(expired);
+}
+
+// Test that signature of OCSP response signer cert is verified
+TEST_F(pkixocsp_VerifyEncodedResponse_DelegatedResponder, good_tampered_eku)
+{
+  SECItem* response(CreateEncodedIndirectOCSPSuccessfulResponse(
+                      "CN=good_indirect_tampered_eku",
+                      OCSPResponseContext::good, byKey,
+                      SEC_OID_EXT_KEY_USAGE_SERVER_AUTH));
+  ASSERT_TRUE(response);
+
+#define EKU_PREFIX \
+  0x06, 8, /* OBJECT IDENTIFIER, 8 bytes */ \
+  0x2B, 6, 1, 5, 5, 7, /* id-pkix */ \
+  0x03 /* id-kp */
+  static const uint8_t EKU_SERVER_AUTH[] = { EKU_PREFIX, 0x01 }; // serverAuth
+  static const uint8_t EKU_OCSP_SIGNER[] = { EKU_PREFIX, 0x09 }; // OCSPSigning
+#undef EKU_PREFIX
+  ASSERT_SECSuccess(TamperOnce(*response,
+                               EKU_SERVER_AUTH, PR_ARRAY_SIZE(EKU_SERVER_AUTH),
+                               EKU_OCSP_SIGNER, PR_ARRAY_SIZE(EKU_OCSP_SIGNER)));
+
+  bool expired;
+  ASSERT_SECFailure(SEC_ERROR_OCSP_INVALID_SIGNING_CERT,
+                    VerifyEncodedOCSPResponse(trustDomain, *endEntityCertID, now,
+                                              END_ENTITY_MAX_LIFETIME_IN_DAYS,
+                                              *response, expired));
+  ASSERT_FALSE(expired);
+}
+
+TEST_F(pkixocsp_VerifyEncodedResponse_DelegatedResponder, good_unknown_issuer)
+{
+  static const char* subCAName = "CN=good_indirect_unknown_issuer sub-CA";
+  static const char* signerName = "CN=good_indirect_unknown_issuer OCSP signer";
+
+  // unknown issuer
+  ScopedSECKEYPublicKey unknownPublicKey;
+  ScopedSECKEYPrivateKey unknownPrivateKey;
+  ASSERT_SECSuccess(GenerateKeyPair(unknownPublicKey, unknownPrivateKey));
+
+  // Delegated responder cert signed by unknown issuer
+  static const SECOidTag signerEKU = SEC_OID_OCSP_RESPONDER;
+  const SECItem* extensions[] = {
+    CreateEncodedEKUExtension(arena.get(), &signerEKU, 1,
+                              ExtensionCriticality::NotCritical),
+    nullptr
+  };
+  ScopedSECKEYPrivateKey signerPrivateKey;
+  SECItem* signerDER(CreateEncodedCertificate(arena.get(), 1,
+                        subCAName, oneDayBeforeNow, oneDayAfterNow,
+                        signerName, extensions, unknownPrivateKey.get(),
+                        signerPrivateKey));
+  ASSERT_TRUE(signerDER);
+
+  // OCSP response signed by that delegated responder
+  SECItem const* const certs[] = { signerDER, nullptr };
+  SECItem* response(CreateEncodedOCSPSuccessfulResponse(
+                        OCSPResponseContext::good, *endEntityCertID,
+                        signerName, signerPrivateKey, oneDayBeforeNow,
+                        oneDayBeforeNow, &oneDayAfterNow, certs));
+  ASSERT_TRUE(response);
+
+  bool expired;
+  ASSERT_SECFailure(SEC_ERROR_OCSP_INVALID_SIGNING_CERT,
+                    VerifyEncodedOCSPResponse(trustDomain, *endEntityCertID, now,
+                                              END_ENTITY_MAX_LIFETIME_IN_DAYS,
+                                              *response, expired));
+  ASSERT_FALSE(expired);
+}
+
+// The CA that issued the OCSP responder cert is a sub-CA of the issuer of
+// the certificate that the OCSP response is for. That sub-CA cert is included
+// in the OCSP response before the OCSP responder cert.
+TEST_F(pkixocsp_VerifyEncodedResponse_DelegatedResponder,
+       good_indirect_subca_1_first)
+{
+  static const char* subCAName = "CN=good_indirect_subca_1_first sub-CA";
+  static const char* signerName = "CN=good_indirect_subca_1_first OCSP signer";
+
+  // sub-CA of root (root is the direct issuer of endEntity)
+  const SECItem* subCAExtensions[] = {
+    CreateEncodedBasicConstraints(arena.get(), true, 0,
+                                  ExtensionCriticality::NotCritical),
+    nullptr
+  };
+  ScopedSECKEYPrivateKey subCAPrivateKey;
+  SECItem* subCADER(CreateEncodedCertificate(arena.get(), ++rootIssuedCount,
+                                             rootName,
+                                             oneDayBeforeNow, oneDayAfterNow,
+                                             subCAName, subCAExtensions,
+                                             rootPrivateKey.get(),
+                                             subCAPrivateKey));
+  ASSERT_TRUE(subCADER);
+
+  // Delegated responder cert signed by that sub-CA
+  static const SECOidTag signerEKU = SEC_OID_OCSP_RESPONDER;
+  const SECItem* extensions[] = {
+    CreateEncodedEKUExtension(arena.get(), &signerEKU, 1,
+                              ExtensionCriticality::NotCritical),
+    nullptr
+  };
+  ScopedSECKEYPrivateKey signerPrivateKey;
+  SECItem* signerDER(CreateEncodedCertificate(arena.get(), 1, subCAName,
+                                              oneDayBeforeNow, oneDayAfterNow,
+                                              signerName, extensions,
+                                              subCAPrivateKey.get(),
+                                              signerPrivateKey));
+  ASSERT_TRUE(signerDER);
+
+  // OCSP response signed by the delegated responder issued by the sub-CA
+  // that is trying to impersonate the root.
+  SECItem const* const certs[] = { subCADER, signerDER, nullptr };
+  SECItem* response(CreateEncodedOCSPSuccessfulResponse(
+                        OCSPResponseContext::good, *endEntityCertID, signerName,
+                        signerPrivateKey, oneDayBeforeNow, oneDayBeforeNow,
+                        &oneDayAfterNow,
+                        certs));
+  ASSERT_TRUE(response);
+
+  bool expired;
+  ASSERT_SECFailure(SEC_ERROR_OCSP_INVALID_SIGNING_CERT,
+                    VerifyEncodedOCSPResponse(trustDomain, *endEntityCertID, now,
+                                              END_ENTITY_MAX_LIFETIME_IN_DAYS,
+                                              *response, expired));
+  ASSERT_FALSE(expired);
+}
+
+// The CA that issued the OCSP responder cert is a sub-CA of the issuer of
+// the certificate that the OCSP response is for. That sub-CA cert is included
+// in the OCSP response after the OCSP responder cert.
+TEST_F(pkixocsp_VerifyEncodedResponse_DelegatedResponder,
+       good_indirect_subca_1_second)
+{
+  static const char* subCAName = "CN=good_indirect_subca_1_second sub-CA";
+  static const char* signerName = "CN=good_indirect_subca_1_second OCSP signer";
+
+  // sub-CA of root (root is the direct issuer of endEntity)
+  const SECItem* subCAExtensions[] = {
+    CreateEncodedBasicConstraints(arena.get(), true, 0,
+                                  ExtensionCriticality::NotCritical),
+    nullptr
+  };
+  ScopedSECKEYPrivateKey subCAPrivateKey;
+  SECItem* subCADER(CreateEncodedCertificate(arena.get(), ++rootIssuedCount,
+                                             rootName,
+                                             oneDayBeforeNow, oneDayAfterNow,
+                                             subCAName, subCAExtensions,
+                                             rootPrivateKey.get(),
+                                             subCAPrivateKey));
+  ASSERT_TRUE(subCADER);
+
+  // Delegated responder cert signed by that sub-CA
+  static const SECOidTag signerEKU = SEC_OID_OCSP_RESPONDER;
+  const SECItem* extensions[] = {
+    CreateEncodedEKUExtension(arena.get(), &signerEKU, 1,
+                              ExtensionCriticality::NotCritical),
+    nullptr
+  };
+  ScopedSECKEYPrivateKey signerPrivateKey;
+  SECItem* signerDER(CreateEncodedCertificate(arena.get(), 1, subCAName,
+                                              oneDayBeforeNow, oneDayAfterNow,
+                                              signerName, extensions,
+                                              subCAPrivateKey.get(),
+                                              signerPrivateKey));
+  ASSERT_TRUE(signerDER);
+
+  // OCSP response signed by the delegated responder issued by the sub-CA
+  // that is trying to impersonate the root.
+  SECItem const* const certs[] = { signerDER, subCADER, nullptr };
+  SECItem* response(CreateEncodedOCSPSuccessfulResponse(
+                        OCSPResponseContext::good, *endEntityCertID,
+                        signerName, signerPrivateKey, oneDayBeforeNow,
+                        oneDayBeforeNow, &oneDayAfterNow, certs));
+  ASSERT_TRUE(response);
+
+  bool expired;
+  ASSERT_SECFailure(SEC_ERROR_OCSP_INVALID_SIGNING_CERT,
+                    VerifyEncodedOCSPResponse(trustDomain, *endEntityCertID, now,
+                                              END_ENTITY_MAX_LIFETIME_IN_DAYS,
+                                              *response, expired));
+  ASSERT_FALSE(expired);
+}
--- a/security/pkix/test/lib/pkixtestutil.cpp
+++ b/security/pkix/test/lib/pkixtestutil.cpp
@@ -88,16 +88,69 @@ OpenFile(const char* dir, const char* fi
   if (!file) {
     // TODO: map errno to NSPR error code
     PR_SetError(PR_FILE_NOT_FOUND_ERROR, errno);
   }
 #endif
   return file.release();
 }
 
+SECStatus
+TamperOnce(SECItem& item,
+           const uint8_t* from, size_t fromLen,
+           const uint8_t* to, size_t toLen)
+{
+  if (!item.data || !from || !to || fromLen != toLen) {
+    PR_NOT_REACHED("invalid args to TamperOnce");
+    PR_SetError(SEC_ERROR_INVALID_ARGS, 0);
+    return SECFailure;
+  }
+
+  if (fromLen < 8) {
+    PR_NOT_REACHED("invalid parameter to TamperOnce; fromLen must be at least 8");
+    PR_SetError(SEC_ERROR_INVALID_ARGS, 0);
+    return SECFailure;
+  }
+
+  uint8_t* p = item.data;
+  size_t remaining = item.len;
+  bool alreadyFoundMatch = false;
+  for (;;) {
+    uint8_t* foundFirstByte = static_cast<uint8_t*>(memchr(p, from[0],
+                                                           remaining));
+    if (!foundFirstByte) {
+      if (alreadyFoundMatch) {
+        return SECSuccess;
+      }
+      PR_SetError(SEC_ERROR_BAD_DATA, 0);
+      return SECFailure;
+    }
+    remaining -= (foundFirstByte - p);
+    if (remaining < fromLen) {
+      if (alreadyFoundMatch) {
+        return SECSuccess;
+      }
+      PR_SetError(SEC_ERROR_BAD_DATA, 0);
+      return SECFailure;
+    }
+    if (!memcmp(foundFirstByte, from, fromLen)) {
+      if (alreadyFoundMatch) {
+        PR_SetError(SEC_ERROR_BAD_DATA, 0);
+        return SECFailure;
+      }
+      alreadyFoundMatch = true;
+      memmove(foundFirstByte, to, toLen);
+      p = foundFirstByte + toLen;
+    } else {
+      p = foundFirstByte + 1;
+      --remaining;
+    }
+  }
+}
+
 class Output
 {
 public:
   Output()
     : numItems(0)
     , length(0)
   {
   }
--- a/security/pkix/test/lib/pkixtestutil.h
+++ b/security/pkix/test/lib/pkixtestutil.h
@@ -70,16 +70,26 @@ PRTime YMDHMS(int16_t year, int16_t mont
               int16_t hour, int16_t minutes, int16_t seconds);
 
 SECStatus GenerateKeyPair(/*out*/ ScopedSECKEYPublicKey& publicKey,
                           /*out*/ ScopedSECKEYPrivateKey& privateKey);
 
 // The result will be owned by the arena
 const SECItem* ASCIIToDERName(PLArenaPool* arena, const char* cn);
 
+// Replace one substring in item with another of the same length, but only if
+// the substring was found exactly once. The "only once" restriction is helpful
+// for avoiding making multiple changes at once.
+//
+// The string to search for must be 8 or more bytes long so that it is
+// extremely unlikely that there will ever be any false positive matches
+// in digital signatures, keys, hashes, etc.
+SECStatus TamperOnce(SECItem& item, const uint8_t* from, size_t fromLen,
+                     const uint8_t* to, size_t toLen);
+
 ///////////////////////////////////////////////////////////////////////////////
 // Encode Certificates
 
 enum Version { v1 = 0, v2 = 1, v3 = 2 };
 
 // serialNumber is assumed to be the DER encoding of an INTEGER.
 //
 // If extensions is null, then no extensions will be encoded. Otherwise,