Moderate firefox Logic Error 🔧 Commit mapped

Overview

Moderate
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
Impactmoderate
DescriptionCryptography misuse in Storage: Quota Manager component
ComponentDOM
Bug ClassLogic Error
Tracker2055694
Fix commit8c7497194250 (firefox) +224/-29
CISA KEVNot listed
CreditedMozilla
Disclosed2026-09-29

Changed Functions

FunctionChangeNotes
if
dom/quota/NSSCipherStrategy.cpp
modified
for
dom/quota/test/gtest/TestNSSCipherStrategy.cpp
modified

Files Changed

  • dom/quota/DummyCipherStrategy.h
  • dom/quota/EncryptingOutputStream_impl.h
  • dom/quota/NSSCipherStrategy.cpp
  • dom/quota/NSSCipherStrategy.h
  • dom/quota/test/gtest/TestNSSCipherStrategy.cpp
  • dom/quota/test/gtest/moz.build
  • storage/ObfuscatingVFS.cpp
diff --git a/dom/quota/DummyCipherStrategy.h b/dom/quota/DummyCipherStrategy.h
index ff936577d52..26ddb1e627e 100644
--- a/dom/quota/DummyCipherStrategy.h
+++ b/dom/quota/DummyCipherStrategy.h
@@ -30,8 +30,7 @@ struct DummyCipherStrategy {
 
   static Result<KeyType, nsresult> GenerateKey() { return KeyType{}; }
 
-  nsresult Init(CipherMode aCipherMode, Span<const uint8_t> aKey,
-                Span<const uint8_t> aInitialIv = Span<const uint8_t>{}) {
+  nsresult Init(CipherMode aCipherMode, Span<const uint8_t> aKey) {
     return NS_OK;
   }
 
diff --git a/dom/quota/EncryptingOutputStream_impl.h b/dom/quota/EncryptingOutputStream_impl.h
index 357fef61c4e..ebf481876a1 100644
--- a/dom/quota/EncryptingOutputStream_impl.h
+++ b/dom/quota/EncryptingOutputStream_impl.h
@@ -27,8 +27,7 @@ EncryptingOutputStream<CipherStrategy>::EncryptingOutputStream(
     : EncryptingOutputStreamBase(std::move(aBaseStream), aBlockSize) {
   // XXX Move this to a fallible init function.
   MOZ_ALWAYS_SUCCEEDS(mCipherStrategy.Init(CipherMode::Encrypt,
-                                           CipherStrategy::SerializeKey(aKey),
-                                           CipherStrategy::MakeBlockPrefix()));
+                                           CipherStrategy::SerializeKey(aKey)));
 
   MOZ_ASSERT(mBlockSize > 0);
   MOZ_ASSERT(mBlockSize % CipherStrategy::BasicBlockSize == 0);
diff --git a/dom/quota/NSSCipherStrategy.cpp b/dom/quota/NSSCipherStrategy.cpp
index 7ecd49ccdd7..20f2fcff77b 100644
--- a/dom/quota/NSSCipherStrategy.cpp
+++ b/dom/quota/NSSCipherStrategy.cpp
@@ -4,7 +4,6 @@
 
 #include "NSSCipherStrategy.h"
 
-#include <cstring>
 #include <utility>
 
 #include "CipherStrategyUtils.h"
@@ -28,16 +27,12 @@ Result<NSSCipherStrategy::KeyType, nsresult> NSSCipherStrategy::GenerateKey() {
 }
 
 nsresult NSSCipherStrategy::Init(const CipherMode aMode,
-                                 const Span<const uint8_t> aKey,
-                                 const Span<const uint8_t> aInitialIv) {
-  MOZ_ASSERT_IF(CipherMode::Encrypt == aMode, aInitialIv.Length() == 32);
+                                 const Span<const uint8_t> aKey) {
   MOZ_RELEASE_ASSERT(EnsureNSSInitializedChromeOrContent(),
                      "Could not initialize NSS.");
 
   mMode.init(aMode);
 
-  mIv.AppendElements(aInitialIv);
-
   const auto slot = UniquePK11SlotInfo{PK11_GetInternalSlot()};
   if (slot == nullptr) {
     return NS_ERROR_FAILURE;
@@ -70,11 +65,6 @@ nsresult NSSCipherStrategy::Init(const CipherMode aMode,
 nsresult NSSCipherStrategy::Cipher(const Span<uint8_t> aIv,
                                    const Span<const uint8_t> aIn,
                                    const Span<uint8_t> aOut) {
-  if (CipherMode::Encrypt == *mMode) {
-    MOZ_RELEASE_ASSERT(aIv.Length() == mIv.Length());
-    memcpy(aIv.Elements(), mIv.Elements(), aIv.Length());
-  }
-
   // XXX make tag a separate parameter
   constexpr size_t tagLen = 16;
   const auto tag = aIv.Last(tagLen);
@@ -83,18 +73,30 @@ nsresult NSSCipherStrategy::Cipher(const Span<uint8_t> aIv,
   const auto iv = aIv.First(12);
   MOZ_ASSERT(tag.Length() + iv.Length() <= aIv.Length());
 
+  if (CipherMode::Encrypt == *mMode) {
+    // Draw a fresh random nonce per message. PK11_AEADOp must not derive one:
+    // its counter is private to this PK11Context and restarts at zero, while
+    // callers create a context per SQLite file handle and per reopen under a
+    // single key, so those counters collide and repeat (key, nonce) pairs.
+    //
+    // This covers the whole prefix ahead of the tag, not just the nonce, so
+    // that no part of aIv depends on the caller having initialized it.
+    const auto generated = aIv.First(aIv.Length() - tagLen);
+    if (PK11_GenerateRandom(generated.Elements(),
+                            static_cast<int>(generated.Length())) !=
+        SECSuccess) {
+      return NS_ERROR_FAILURE;
+    }
+  }
+
   int outLen;
   // aIn and aOut may not overlap resp. be the same, so we can't do this
   // in-place.
   const SECStatus rv = PK11_AEADOp(
-      mPK11Context->get(), CKG_GENERATE_COUNTER, 0, iv.Elements(), iv.Length(),
+      mPK11Context->get(), CKG_NO_GENERATE, 0, iv.Elements(), iv.Length(),
       nullptr, 0, aOut.Elements(), &outLen, aOut.Length(), tag.Elements(),
       tag.Length(), aIn.Elements(), aIn.Length());
 
-  if (CipherMode::Encrypt == *mMode) {
-    memcpy(mIv.Elements(), aIv.Elements(), aIv.Length());
-  }
-
   return MapSECStatus(rv);
 }
 
diff --git a/dom/quota/NSSCipherStrategy.h b/dom/quota/NSSCipherStrategy.h
index 7f4ea9922f1..40ae78b8a88 100644
--- a/dom/quota/NSSCipherStrategy.h
+++ b/dom/quota/NSSCipherStrategy.h
@@ -15,7 +15,6 @@
 #include "mozilla/InitializedOnce.h"
 #include "mozilla/Result.h"
 #include "mozilla/Span.h"
-#include "nsTArray.h"
 
 namespace mozilla::dom::quota {
 
@@ -28,9 +27,13 @@ struct NSSCipherStrategy {
 
   static Result<KeyType, nsresult> GenerateKey();
 
-  nsresult Init(CipherMode aCipherMode, Span<const uint8_t> aKey,
-                Span<const uint8_t> aInitialIv = Span<const uint8_t>{});
+  nsresult Init(CipherMode aCipherMode, Span<const uint8_t> aKey);
 
+  // On encrypt the whole of aIv is written -- a freshly generated nonce in the
+  // leading 12 bytes, the authentication tag in the trailing 16, random filler
+  // in between -- and the caller must store all of it alongside the ciphertext.
+  // Its incoming contents are ignored. On decrypt aIv supplies that same stored
+  // nonce and tag.
   nsresult Cipher(Span<uint8_t> aIv, Span<const uint8_t> aIn,
                   Span<uint8_t> aOut);
 
@@ -45,7 +48,6 @@ struct NSSCipherStrategy {
   // XXX Remove EarlyDestructible, remove moving of the CipherStrategy.
   LazyInitializedOnceEarlyDestructible<const CipherMode> mMode;
   LazyInitializedOnceEarlyDestructible<const UniquePK11Context> mPK11Context;
-  nsTArray<uint8_t> mIv;
 };
 
 }  // namespace mozilla::dom::quota
diff --git a/storage/ObfuscatingVFS.cpp b/storage/ObfuscatingVFS.cpp
index 156d419c199..d79f5cae16e 100644
--- a/storage/ObfuscatingVFS.cpp
+++ b/storage/ObfuscatingVFS.cpp
@@ -271,9 +271,13 @@ static void* obfsEncode(ObfsFile* p, /* File containing page to be obfuscated */
   MOZ_ASSERT(payloadLength > 0);
   // XXX I guess this can be done in-place as well, then we don't need the
   // temporary page at all, I guess?
-  p->encryptCipherStrategy->Cipher(
-      Span{aIv}, Span{a + i, static_cast<unsigned>(payloadLength)},
-      Span{pOut + i, static_cast<unsigned>(payloadLength)});
+  // On failure pOut still holds uninitialized or previously encoded content,
+  // which obfsWrite would write out under a nonce that never encrypted it.
+  if (NS_WARN_IF(NS_FAILED(p->encryptCipherStrategy->Cipher(
+          Span{aIv}, Span{a + i, static_cast<unsigned>(payloadLength)},
+          Span{pOut + i, static_cast<unsigned>(payloadLength)})))) {
+    return nullptr;
+  }
   memcpy(pOut + nByte - kReservedBytes, aIv, kIvBytes);
 
   return pOut;
@@ -851,8 +855,7 @@ static int obfsOpen(sqlite3_vfs* pVfs, const char* zName, sqlite3_file* pFile,
   auto resetMethods = MakeScopeExit([pFile] { pFile->pMethods = nullptr; });
 
   if (NS_WARN_IF(NS_FAILED(encryptCipherStrategy->Init(
-          CipherMode::Encrypt, Span{aKey, sizeof(aKey)},
-          IPCStreamCipherStrategy::MakeBlockPrefix())))) {
+          CipherMode::Encrypt, Span{aKey, sizeof(aKey)})))) {
     return SQLITE_ERROR;
   }
 
diff --git a/dom/quota/test/gtest/TestNSSCipherStrategy.cpp b/dom/quota/test/gtest/TestNSSCipherStrategy.cpp
new file mode 100644
index 00000000000..4657c3467a4
--- /dev/null
+++ b/dom/quota/test/gtest/TestNSSCipherStrategy.cpp
@@ -0,0 +1,189 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include <array>
+#include <cstdint>
+#include <numeric>
+#include <set>
+#include <vector>
+
+#include "NSSCipherStrategy.h"
+#include "gtest/gtest.h"
+#include "mozilla/Span.h"
+
+namespace mozilla::dom::quota::test {
+
+namespace {
+
+constexpr size_t kPayloadLength = 64;
+constexpr size_t kNonceLength = 12;
+
+using IvArray = std::array<uint8_t, NSSCipherStrategy::BlockPrefixLength>;
+using Payload = std::array<uint8_t, kPayloadLength>;
+
+NSSCipherStrategy::KeyType MakeKey(uint8_t aInitialValue = 1) {
+  NSSCipherStrategy::KeyType key;
+  std::iota(key.begin(), key.end(), aInitialValue);
+  return key;
+}
+
+Payload MakePlaintext(uint8_t aInitialValue = 1) {
+  Payload plaintext;
+  std::iota(plaintext.begin(), plaintext.end(), aInitialValue);
+  return plaintext;
+}
+
+std::vector<uint8_t> NonceOf(const IvArray& aIv) {
+  return std::vector<uint8_t>(aIv.begin(), aIv.begin() + kNonceLength);
+}
+
+}  // namespace
+
+TEST(NSSCipherStrategyTest, NonceDiffersAcrossCallsInOneContext)
+{
+  const auto key = MakeKey();
+  const auto plaintext = MakePlaintext();
+
+  NSSCipherStrategy encrypt;
+  ASSERT_EQ(NS_OK, encrypt.Init(CipherMode::Encrypt,
+                                NSSCipherStrategy::SerializeKey(key)));
+
+  std::set<std::vector<uint8_t>> nonces;
+  for (size_t i = 0; i < 32; ++i) {
+    IvArray iv{};
+    Payload ciphertext{};
+    ASSERT_EQ(NS_OK,
+              encrypt.Cipher(Span{iv}, Span{plaintext}, Span{ciphertext}));
+    EXPECT_TRUE(nonces.insert(NonceOf(iv)).second)
+        << "nonce repeated within a single context on call " << i;
+  }
+}
+
+// Regression test for bug 2055694. ObfuscatingVFS creates a separate encrypt
+// context per SQLite file handle and per reopen, all sharing one key. When the
+// nonce came from a per-context counter starting at zero, those contexts
+// produced identical (key, nonce) pairs, so XOR of two ciphertexts disclosed
+// XOR of their plaintexts.
+TEST(NSSCipherStrategyTest, NonceDiffersAcrossContextsWithTheSameKey)
+{
+  const auto key = MakeKey();
+  const auto plaintext = MakePlaintext();
+
+  const auto encryptOnce = [&key, &plaintext](IvArray& aIv,
+                                              Payload& aCiphertext) {
+    NSSCipherStrategy encrypt;
+    ASSERT_EQ(NS_OK, encrypt.Init(CipherMode::Encrypt,
+                                  NSSCipherStrategy::SerializeKey(key)));
+    ASSERT_EQ(NS_OK,
+              encrypt.Cipher(Span{aIv}, Span{plaintext}, Span{aCiphertext}));
+  };
+
+  IvArray firstIv{};
+  Payload firstCiphertext{};
+  encryptOnce(firstIv, firstCiphertext);
+
+  IvArray secondIv{};
+  Payload secondCiphertext{};
+  encryptOnce(secondIv, secondCiphertext);
+
+  EXPECT_NE(NonceOf(firstIv), NonceOf(secondIv));
+  EXPECT_NE(firstCiphertext, secondCiphertext);
+}
+
+// Everything ahead of the tag is generated by Cipher, so a caller that leaves
+// the block prefix uninitialized cannot leak its contents to disk.
+TEST(NSSCipherStrategyTest, OverwritesTheWholePrefixRegardlessOfCallerInput)
+{
+  const auto key = MakeKey();
+  const auto plaintext = MakePlaintext();
+
+  NSSCipherStrategy encrypt;
+  ASSERT_EQ(NS_OK, encrypt.Init(CipherMode::Encrypt,
+                                NSSCipherStrategy::SerializeKey(key)));
+
+  constexpr size_t kTagLength = 16;
+  constexpr uint8_t kSentinel = 0xAA;
+
+  std::set<std::vector<uint8_t>> fillers;
+  for (size_t i = 0; i < 32; ++i) {
+    IvArray iv;
+    iv.fill(kSentinel);
+    Payload ciphertext{};
+    ASSERT_EQ(NS_OK,
+              encrypt.Cipher(Span{iv}, Span{plaintext}, Span{ciphertext}));
+
+    fillers.emplace(iv.begin() + kNonceLength, iv.end() - kTagLength);
+  }
+
+  // Had the filler bytes been left to the caller, every sample would still
+  // hold the sentinel and this set would have exactly one entry.
+  EXPECT_GT(fillers.size(), 1u);
+}
+
+TEST(NSSCipherStrategyTest, RoundTripsUsingTheStoredNonce)
+{
+  const auto key = MakeKey();
+  const auto plaintext = MakePlaintext();
+
+  IvArray iv{};
+  Payload ciphertext{};
+  {
+    NSSCipherStrategy encrypt;
+    ASSERT_EQ(NS_OK, encrypt.Init(CipherMode::Encrypt,
+                                  NSSCipherStrategy::SerializeKey(key)));
+    ASSERT_EQ(NS_OK,
+              encrypt.Cipher(Span{iv}, Span{plaintext}, Span{ciphertext}));
+  }
+  EXPECT_NE(plaintext, ciphertext);
+
+  Payload decrypted{};
+  {
+    NSSCipherStrategy decrypt;
+    ASSERT_EQ(NS_OK, decrypt.Init(CipherMode::Decrypt,
+                                  NSSCipherStrategy::SerializeKey(key)));
+    ASSERT_EQ(NS_OK,
+              decrypt.Cipher(Span{iv}, Span{ciphertext}, Span{decrypted}));
+  }
+  EXPECT_EQ(plaintext, decrypted);
+}
+
+// Decryption must depend only on the nonce stored alongside the ciphertext,
+// never on a context-local counter. This is what keeps blocks written by older
+// builds -- whose nonces were sequential -- readable.
+TEST(NSSCipherStrategyTest, DecryptionIsIndependentOfBlockOrder)
+{
+  const auto key = MakeKey();
+  const auto firstPlaintext = MakePlaintext(1);
+  const auto secondPlaintext = MakePlaintext(101);
+
+  IvArray firstIv{};
+  Payload firstCiphertext{};
+  IvArray secondIv{};
+  Payload secondCiphertext{};
+  {
+    NSSCipherStrategy encrypt;
+    ASSERT_EQ(NS_OK, encrypt.Init(CipherMode::Encrypt,
+                                  NSSCipherStrategy::SerializeKey(key)));
+    ASSERT_EQ(NS_OK, encrypt.Cipher(Span{firstIv}, Span{firstPlaintext},
+                                    Span{firstCiphertext}));
+    ASSERT_EQ(NS_OK, encrypt.Cipher(Span{secondIv}, Span{secondPlaintext},
+                                    Span{secondCiphertext}));
+  }
+
+  NSSCipherStrategy decrypt;
+  ASSERT_EQ(NS_OK, decrypt.Init(CipherMode::Decrypt,
+                                NSSCipherStrategy::SerializeKey(key)));
+
+  Payload secondDecrypted{};
+  ASSERT_EQ(NS_OK, decrypt.Cipher(Span{secondIv}, Span{secondCiphertext},
+                                  Span{secondDecrypted}));
+  EXPECT_EQ(secondPlaintext, secondDecrypted);
+
+  Payload firstDecrypted{};
+  ASSERT_EQ(NS_OK, decrypt.Cipher(Span{firstIv}, Span{firstCiphertext},
+                                  Span{firstDecrypted}));
+  EXPECT_EQ(firstPlaintext, firstDecrypted);
+}
+
+}  // namespace mozilla::dom::quota::test
diff --git a/dom/quota/test/gtest/moz.build b/dom/quota/test/gtest/moz.build
index 40fc5e90a4b..344a44804d1 100644
--- a/dom/quota/test/gtest/moz.build
+++ b/dom/quota/test/gtest/moz.build
@@ -30,6 +30,7 @@ UNIFIED_SOURCES = [
     "TestFlatten.cpp",
     "TestForwardDecls.cpp",
     "TestMozPromiseUtils.cpp",
+    "TestNSSCipherStrategy.cpp",
     "TestNSSRandomAccessCipherStrategy.cpp",
     "TestOpenClientDirectoryInfo.cpp",
     "TestOriginParser.cpp",
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/dom/quota/test/gtest/TestNSSCipherStrategy.cpp b/dom/quota/test/gtest/TestNSSCipherStrategy.cpp
new file mode 100644
index 00000000000..4657c3467a4
--- /dev/null
+++ b/dom/quota/test/gtest/TestNSSCipherStrategy.cpp
@@ -0,0 +1,189 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include <array>
+#include <cstdint>
+#include <numeric>
+#include <set>
+#include <vector>
+
+#include "NSSCipherStrategy.h"
+#include "gtest/gtest.h"
+#include "mozilla/Span.h"
+
+namespace mozilla::dom::quota::test {
+
+namespace {
+
+constexpr size_t kPayloadLength = 64;
+constexpr size_t kNonceLength = 12;
+
+using IvArray = std::array<uint8_t, NSSCipherStrategy::BlockPrefixLength>;
+using Payload = std::array<uint8_t, kPayloadLength>;
+
+NSSCipherStrategy::KeyType MakeKey(uint8_t aInitialValue = 1) {
+  NSSCipherStrategy::KeyType key;
+  std::iota(key.begin(), key.end(), aInitialValue);
+  return key;
+}
+
+Payload MakePlaintext(uint8_t aInitialValue = 1) {
+  Payload plaintext;
+  std::iota(plaintext.begin(), plaintext.end(), aInitialValue);
+  return plaintext;
+}
+
+std::vector<uint8_t> NonceOf(const IvArray& aIv) {
+  return std::vector<uint8_t>(aIv.begin(), aIv.begin() + kNonceLength);
+}
+
+}  // namespace
+
+TEST(NSSCipherStrategyTest, NonceDiffersAcrossCallsInOneContext)
+{
+  const auto key = MakeKey();
+  const auto plaintext = MakePlaintext();
+
+  NSSCipherStrategy encrypt;
+  ASSERT_EQ(NS_OK, encrypt.Init(CipherMode::Encrypt,
+                                NSSCipherStrategy::SerializeKey(key)));
+
+  std::set<std::vector<uint8_t>> nonces;
+  for (size_t i = 0; i < 32; ++i) {
+    IvArray iv{};
+    Payload ciphertext{};
+    ASSERT_EQ(NS_OK,
+              encrypt.Cipher(Span{iv}, Span{plaintext}, Span{ciphertext}));
+    EXPECT_TRUE(nonces.insert(NonceOf(iv)).second)
+        << "nonce repeated within a single context on call " << i;
+  }
+}
+
+// Regression test for bug 2055694. ObfuscatingVFS creates a separate encrypt
+// context per SQLite file handle and per reopen, all sharing one key. When the
+// nonce came from a per-context counter starting at zero, those contexts
+// produced identical (key, nonce) pairs, so XOR of two ciphertexts disclosed
+// XOR of their plaintexts.
+TEST(NSSCipherStrategyTest, NonceDiffersAcrossContextsWithTheSameKey)
+{
+  const auto key = MakeKey();
+  const auto plaintext = MakePlaintext();
+
+  const auto encryptOnce = [&key, &plaintext](IvArray& aIv,
+                                              Payload& aCiphertext) {
+    NSSCipherStrategy encrypt;
+    ASSERT_EQ(NS_OK, encrypt.Init(CipherMode::Encrypt,
+                                  NSSCipherStrategy::SerializeKey(key)));
+    ASSERT_EQ(NS_OK,
+              encrypt.Cipher(Span{aIv}, Span{plaintext}, Span{aCiphertext}));
+  };
+
+  IvArray firstIv{};
+  Payload firstCiphertext{};
+  encryptOnce(firstIv, firstCiphertext);
+
+  IvArray secondIv{};
+  Payload secondCiphertext{};
+  encryptOnce(secondIv, secondCiphertext);
+
+  EXPECT_NE(NonceOf(firstIv), NonceOf(secondIv));
+  EXPECT_NE(firstCiphertext, secondCiphertext);
+}
+
+// Everything ahead of the tag is generated by Cipher, so a caller that leaves
+// the block prefix uninitialized cannot leak its contents to disk.
+TEST(NSSCipherStrategyTest, OverwritesTheWholePrefixRegardlessOfCallerInput)
+{
+  const auto key = MakeKey();
+  const auto plaintext = MakePlaintext();
+
+  NSSCipherStrategy encrypt;
+  ASSERT_EQ(NS_OK, encrypt.Init(CipherMode::Encrypt,
+                                NSSCipherStrategy::SerializeKey(key)));
+
+  constexpr size_t kTagLength = 16;
+  constexpr uint8_t kSentinel = 0xAA;
+
+  std::set<std::vector<uint8_t>> fillers;
+  for (size_t i = 0; i < 32; ++i) {
+    IvArray iv;
+    iv.fill(kSentinel);
+    Payload ciphertext{};
+    ASSERT_EQ(NS_OK,
+              encrypt.Cipher(Span{iv}, Span{plaintext}, Span{ciphertext}));
+
+    fillers.emplace(iv.begin() + kNonceLength, iv.end() - kTagLength);
+  }
+
+  // Had the filler bytes been left to the caller, every sample would still
+  // hold the sentinel and this set would have exactly one entry.
+  EXPECT_GT(fillers.size(), 1u);
+}
+
+TEST(NSSCipherStrategyTest, RoundTripsUsingTheStoredNonce)
+{
+  const auto key = MakeKey();
+  const auto plaintext = MakePlaintext();
+
+  IvArray iv{};
+  Payload ciphertext{};
+  {
+    NSSCipherStrategy encrypt;
+    ASSERT_EQ(NS_OK, encrypt.Init(CipherMode::Encrypt,
+                                  NSSCipherStrategy::SerializeKey(key)));
+    ASSERT_EQ(NS_OK,
+              encrypt.Cipher(Span{iv}, Span{plaintext}, Span{ciphertext}));
+  }
+  EXPECT_NE(plaintext, ciphertext);
+
+  Payload decrypted{};
+  {
+    NSSCipherStrategy decrypt;
+    ASSERT_EQ(NS_OK, decrypt.Init(CipherMode::Decrypt,
+                                  NSSCipherStrategy::SerializeKey(key)));
+    ASSERT_EQ(NS_OK,
+              decrypt.Cipher(Span{iv}, Span{ciphertext}, Span{decrypted}));
+  }
+  EXPECT_EQ(plaintext, decrypted);
+}
+
+// Decryption must depend only on the nonce stored alongside the ciphertext,
+// never on a context-local counter. This is what keeps blocks written by older
+// builds -- whose nonces were sequential -- readable.
+TEST(NSSCipherStrategyTest, DecryptionIsIndependentOfBlockOrder)
+{
+  const auto key = MakeKey();
+  const auto firstPlaintext = MakePlaintext(1);
+  const auto secondPlaintext = MakePlaintext(101);
+
+  IvArray firstIv{};
+  Payload firstCiphertext{};
+  IvArray secondIv{};
+  Payload secondCiphertext{};
+  {
+    NSSCipherStrategy encrypt;
+    ASSERT_EQ(NS_OK, encrypt.Init(CipherMode::Encrypt,
+                                  NSSCipherStrategy::SerializeKey(key)));
+    ASSERT_EQ(NS_OK, encrypt.Cipher(Span{firstIv}, Span{firstPlaintext},
+                                    Span{firstCiphertext}));
+    ASSERT_EQ(NS_OK, encrypt.Cipher(Span{secondIv}, Span{secondPlaintext},
+                                    Span{secondCiphertext}));
+  }
+
+  NSSCipherStrategy decrypt;
+  ASSERT_EQ(NS_OK, decrypt.Init(CipherMode::Decrypt,
+                                NSSCipherStrategy::SerializeKey(key)));
+
+  Payload secondDecrypted{};
+  ASSERT_EQ(NS_OK, decrypt.Cipher(Span{secondIv}, Span{secondCiphertext},
+                                  Span{secondDecrypted}));
+  EXPECT_EQ(secondPlaintext, secondDecrypted);
+
+  Payload firstDecrypted{};
+  ASSERT_EQ(NS_OK, decrypt.Cipher(Span{firstIv}, Span{firstCiphertext},
+                                  Span{firstDecrypted}));
+  EXPECT_EQ(firstPlaintext, firstDecrypted);
+}
+
+}  // namespace mozilla::dom::quota::test
diff --git a/dom/quota/test/gtest/moz.build b/dom/quota/test/gtest/moz.build
index 40fc5e90a4b..344a44804d1 100644
--- a/dom/quota/test/gtest/moz.build
+++ b/dom/quota/test/gtest/moz.build
@@ -30,6 +30,7 @@ UNIFIED_SOURCES = [
     "TestFlatten.cpp",
     "TestForwardDecls.cpp",
     "TestMozPromiseUtils.cpp",
+    "TestNSSCipherStrategy.cpp",
     "TestNSSRandomAccessCipherStrategy.cpp",
     "TestOpenClientDirectoryInfo.cpp",
     "TestOriginParser.cpp",
Loading diff…