/* 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 #include #include "gtest/gtest.h" #include "mozilla/SpinEventLoopUntil.h" #include "mozilla/gtest/MozAssertions.h" #include "LockstoreService.h" #include "nsString.h" #include "nsTArray.h" #include "nsThreadUtils.h" using namespace mozilla; using namespace mozilla::security::lockstore; namespace { nsCString UniqueCollection(const char* aPrefix) { // Service-level state is a process-wide singleton bound to the test // profile, so we suffix every collection with a counter to keep tests // independent within the same binary run. static uint32_t sCounter = 0; nsCString out; out.AppendASCII(aPrefix); out.AppendLiteral("-"); out.AppendInt(++sCounter); return out; } // Runs `aFn` on a background task and spins the main loop until it // completes. Used to drive the off-main-thread sync `Do*` methods from // gtest, which runs on the main thread. template void RunOnBackground(Fn&& aFn) { // `done` is touched only on the main thread (set by the completion // runnable, read by the predicate), so it needs no synchronization. bool done = false; MOZ_ALWAYS_SUCCEEDS(NS_DispatchBackgroundTask(NS_NewRunnableFunction( "TestLockstoreService::RunOnBackground", [&done, fn = std::forward(aFn)]() mutable { fn(); // The predicate is satisfied from this background task, so post // completion back to the main thread. This wakes the blocked // NS_ProcessNextEvent inside SpinEventLoopUntil; otherwise the spin // only re-checks `done` when unrelated event-loop traffic happens to // wake the main thread, and those incidental wakeups grow sparse as // the process quiesces -- stretching each wait until it trips the // gtest no-output watchdog. NS_DispatchToMainThread(NS_NewRunnableFunction( "TestLockstoreService::RunOnBackground::Done", [&done] { done = true; })); }))); MOZ_ALWAYS_TRUE( SpinEventLoopUntil("RunOnBackground"_ns, [&done]() { return done; })); } nsTArray Bytes(const char* aLiteral) { nsTArray out; out.AppendElements(reinterpret_cast(aLiteral), strlen(aLiteral)); return out; } } // namespace class LockstoreServiceTest : public ::testing::Test { protected: void SetUp() override { mService = LockstoreService::GetSingleton(); ASSERT_TRUE(mService) << "LockstoreService singleton must be obtainable"; // Mint two fresh LocalKey kek_refs for this test. Every test gets // independent kek_refs so collection lifetimes don't interfere // across tests sharing the process-wide service singleton, and // tests that need "a different KEK" (wrong-kek decrypt, addKek) // have a second one to reach for. RunOnBackground([&]() { auto k1 = mService->DoCreateKek("local"_ns, /*identifier=*/""_ns, ""_ns, /*cacheTimeoutMs=*/0); ASSERT_TRUE(k1.isOk()) << "DoCreateKek(local) must succeed"; mLocalKek = k1.unwrap(); ASSERT_FALSE(mLocalKek.IsEmpty()) << "DoCreateKek(local) must mint a non-empty kek_ref"; auto k2 = mService->DoCreateKek("local"_ns, /*identifier=*/""_ns, ""_ns, /*cacheTimeoutMs=*/0); ASSERT_TRUE(k2.isOk()) << "DoCreateKek(local) must succeed"; mOtherKek = k2.unwrap(); ASSERT_FALSE(mOtherKek.IsEmpty()) << "DoCreateKek(local) must mint a non-empty kek_ref"; }); } void TearDown() override { // The service singleton persists across the gtest binary, so any KEKs // left behind accumulate in the store. Drop the per-test KEKs to keep // each test's footprint bounded. Best-effort: a test that already // deleted its KEKs (or never finished SetUp) sees the second call no-op. if (mLocalKek.IsEmpty() && mOtherKek.IsEmpty()) { return; } RunOnBackground([&]() { if (!mLocalKek.IsEmpty()) { mService->DoDeleteKek(mLocalKek); } if (!mOtherKek.IsEmpty()) { mService->DoDeleteKek(mOtherKek); } }); } // Fresh-per-test LocalKey kek_refs. nsCString mLocalKek; nsCString mOtherKek; RefPtr mService; }; // --------------------------------------------------------------------------- // Singleton / lifecycle // --------------------------------------------------------------------------- TEST_F(LockstoreServiceTest, SingletonIdentity) { RefPtr a = LockstoreService::GetSingleton(); RefPtr b = LockstoreService::GetSingleton(); EXPECT_EQ(a.get(), b.get()) << "GetSingleton must return the same instance"; } // --------------------------------------------------------------------------- // createDek / listCollections / deleteDek // --------------------------------------------------------------------------- TEST_F(LockstoreServiceTest, CreateAndDeleteDek) { nsCString coll = UniqueCollection("create-delete"); RunOnBackground([&]() { EXPECT_NS_SUCCEEDED( mService->DoCreateDek(coll, mLocalKek, /*extractable=*/false)); auto collectionsResult = mService->DoListDeks(); ASSERT_TRUE(collectionsResult.isOk()); auto collections = collectionsResult.unwrap(); bool found = false; for (const auto& c : collections) { if (c == coll) { found = true; break; } } EXPECT_TRUE(found) << "Created collection should appear in listCollections"; EXPECT_NS_SUCCEEDED(mService->DoDeleteDek(coll)); // Second call rejects since the DEK is gone. EXPECT_EQ(mService->DoDeleteDek(coll), NS_ERROR_NOT_AVAILABLE); }); } TEST_F(LockstoreServiceTest, CreateDek_DuplicateRejects) { nsCString coll = UniqueCollection("dup"); RunOnBackground([&]() { EXPECT_NS_SUCCEEDED( mService->DoCreateDek(coll, mLocalKek, /*extractable=*/false)); EXPECT_EQ(mService->DoCreateDek(coll, mLocalKek, /*extractable=*/false), NS_ERROR_FAILURE) << "createDek on an existing collection must reject"; mService->DoDeleteDek(coll); }); } TEST_F(LockstoreServiceTest, CreateDek_RejectsEmptyCollection) { RunOnBackground([&]() { EXPECT_EQ(mService->DoCreateDek(""_ns, mLocalKek, /*extractable=*/false), NS_ERROR_INVALID_ARG); }); } TEST_F(LockstoreServiceTest, CreateDek_RejectsEmptyKekRef) { nsCString coll = UniqueCollection("empty-kek"); RunOnBackground([&]() { EXPECT_EQ(mService->DoCreateDek(coll, ""_ns, /*extractable=*/false), NS_ERROR_INVALID_ARG); }); } TEST_F(LockstoreServiceTest, DeleteDek_RejectsEmptyArg) { RunOnBackground( [&]() { EXPECT_EQ(mService->DoDeleteDek(""_ns), NS_ERROR_INVALID_ARG); }); } TEST_F(LockstoreServiceTest, ListDeks_ContainsCreated) { // Create three uniquely-named collections; listCollections must // include all three. The list may also include collections from // unrelated tests, so we only assert subset, not equality. nsCString a = UniqueCollection("list-a"); nsCString b = UniqueCollection("list-b"); nsCString c = UniqueCollection("list-c"); RunOnBackground([&]() { EXPECT_NS_SUCCEEDED( mService->DoCreateDek(a, mLocalKek, /*extractable=*/false)); EXPECT_NS_SUCCEEDED( mService->DoCreateDek(b, mLocalKek, /*extractable=*/false)); EXPECT_NS_SUCCEEDED( mService->DoCreateDek(c, mLocalKek, /*extractable=*/false)); auto listResult = mService->DoListDeks(); ASSERT_TRUE(listResult.isOk()); auto list = listResult.unwrap(); std::set names; for (const auto& n : list) { names.insert(n); } EXPECT_TRUE(names.count(a)) << "listCollections missing " << a.get(); EXPECT_TRUE(names.count(b)) << "listCollections missing " << b.get(); EXPECT_TRUE(names.count(c)) << "listCollections missing " << c.get(); mService->DoDeleteDek(a); mService->DoDeleteDek(b); mService->DoDeleteDek(c); }); } // --------------------------------------------------------------------------- // listKeks // --------------------------------------------------------------------------- TEST_F(LockstoreServiceTest, ListKeks_ReflectsCreateDek) { nsCString coll = UniqueCollection("keks-create"); RunOnBackground([&]() { EXPECT_NS_SUCCEEDED( mService->DoCreateDek(coll, mLocalKek, /*extractable=*/false)); auto refsResult = mService->DoListKeks(coll); ASSERT_TRUE(refsResult.isOk()); auto refs = refsResult.unwrap(); ASSERT_EQ(refs.Length(), 1u); EXPECT_EQ(refs[0], mLocalKek); mService->DoDeleteDek(coll); }); } TEST_F(LockstoreServiceTest, ListKeks_RejectsNoDek) { nsCString coll = UniqueCollection("keks-missing"); RunOnBackground([&]() { auto refsResult = mService->DoListKeks(coll); EXPECT_TRUE(refsResult.isErr()); EXPECT_EQ(refsResult.unwrapErr(), NS_ERROR_NOT_AVAILABLE); }); } TEST_F(LockstoreServiceTest, ListKeks_RejectsEmptyCollection) { RunOnBackground([&]() { auto refsResult = mService->DoListKeks(""_ns); EXPECT_TRUE(refsResult.isErr()); // Empty / unknown collection surfaces as NS_ERROR_NOT_AVAILABLE: the // keystore layer rejects the lookup with `NotFound`, which the FFI // maps via `error_to_nsresult`. The FFI no longer pre-rejects empty // strings at the boundary. EXPECT_EQ(refsResult.unwrapErr(), NS_ERROR_NOT_AVAILABLE); }); } // --------------------------------------------------------------------------- // encrypt / decrypt // --------------------------------------------------------------------------- TEST_F(LockstoreServiceTest, EncryptDecryptRoundtrip) { nsCString coll = UniqueCollection("roundtrip"); RunOnBackground([&]() { EXPECT_NS_SUCCEEDED( mService->DoCreateDek(coll, mLocalKek, /*extractable=*/false)); auto ctResult = mService->DoEncrypt(coll, mLocalKek, Bytes("hello world")); ASSERT_TRUE(ctResult.isOk()); auto ciphertext = ctResult.unwrap(); EXPECT_GT(ciphertext.Length(), 0u); auto ptResult = mService->DoDecrypt(coll, mLocalKek, ciphertext); ASSERT_TRUE(ptResult.isOk()); auto plaintext = ptResult.unwrap(); nsCString joined; for (uint8_t b : plaintext) { joined.Append(static_cast(b)); } EXPECT_STREQ(joined.get(), "hello world"); mService->DoDeleteDek(coll); }); } TEST_F(LockstoreServiceTest, Encrypt_YieldsUniqueCiphertexts) { nsCString coll = UniqueCollection("nonce"); RunOnBackground([&]() { EXPECT_NS_SUCCEEDED( mService->DoCreateDek(coll, mLocalKek, /*extractable=*/false)); auto a = mService->DoEncrypt(coll, mLocalKek, Bytes("same")); auto b = mService->DoEncrypt(coll, mLocalKek, Bytes("same")); ASSERT_TRUE(a.isOk()); ASSERT_TRUE(b.isOk()); auto ctA = a.unwrap(); auto ctB = b.unwrap(); EXPECT_NE(ctA, ctB) << "Repeated encrypts of the same plaintext must " "yield distinct ciphertexts (random nonce)"; mService->DoDeleteDek(coll); }); } TEST_F(LockstoreServiceTest, Decrypt_CorruptedCiphertextRejects) { nsCString coll = UniqueCollection("corrupt"); RunOnBackground([&]() { EXPECT_NS_SUCCEEDED( mService->DoCreateDek(coll, mLocalKek, /*extractable=*/false)); auto ctResult = mService->DoEncrypt(coll, mLocalKek, Bytes("payload")); ASSERT_TRUE(ctResult.isOk()); auto ct = ctResult.unwrap(); // Flip a byte in the middle to corrupt the AEAD tag. if (ct.Length() > 0) { ct[ct.Length() / 2] ^= 0xff; } auto ptResult = mService->DoDecrypt(coll, mLocalKek, ct); EXPECT_TRUE(ptResult.isErr()); mService->DoDeleteDek(coll); }); } TEST_F(LockstoreServiceTest, Decrypt_TruncatedCiphertextRejects) { nsCString coll = UniqueCollection("trunc"); RunOnBackground([&]() { EXPECT_NS_SUCCEEDED( mService->DoCreateDek(coll, mLocalKek, /*extractable=*/false)); auto ctResult = mService->DoEncrypt(coll, mLocalKek, Bytes("payload")); ASSERT_TRUE(ctResult.isOk()); auto ct = ctResult.unwrap(); if (ct.Length() > 8) { ct.SetLength(ct.Length() / 2); } auto ptResult = mService->DoDecrypt(coll, mLocalKek, ct); EXPECT_TRUE(ptResult.isErr()); mService->DoDeleteDek(coll); }); } TEST_F(LockstoreServiceTest, Decrypt_WrongKekRejects) { nsCString coll = UniqueCollection("wrong-kek"); RunOnBackground([&]() { EXPECT_NS_SUCCEEDED( mService->DoCreateDek(coll, mLocalKek, /*extractable=*/false)); auto ctResult = mService->DoEncrypt(coll, mLocalKek, Bytes("payload")); ASSERT_TRUE(ctResult.isOk()); auto ct = ctResult.unwrap(); auto ptResult = mService->DoDecrypt(coll, mOtherKek, ct); EXPECT_TRUE(ptResult.isErr()); mService->DoDeleteDek(coll); }); } TEST_F(LockstoreServiceTest, Encrypt_NoDekRejects) { nsCString coll = UniqueCollection("no-dek"); RunOnBackground([&]() { auto ctResult = mService->DoEncrypt(coll, mLocalKek, Bytes("payload")); EXPECT_TRUE(ctResult.isErr()); }); } TEST_F(LockstoreServiceTest, Encrypt_RejectsEmptyArgs) { nsCString coll = UniqueCollection("empty-enc"); RunOnBackground([&]() { EXPECT_NS_SUCCEEDED( mService->DoCreateDek(coll, mLocalKek, /*extractable=*/false)); EXPECT_TRUE(mService->DoEncrypt(""_ns, mLocalKek, Bytes("x")).isErr()); EXPECT_TRUE(mService->DoEncrypt(coll, ""_ns, Bytes("x")).isErr()); mService->DoDeleteDek(coll); }); } TEST_F(LockstoreServiceTest, Decrypt_NoDekRejects) { nsCString coll = UniqueCollection("no-dek-dec"); RunOnBackground([&]() { nsTArray bogus; bogus.AppendElements(static_cast( reinterpret_cast("\0\0\0\0\0\0")), 6); auto ptResult = mService->DoDecrypt(coll, mLocalKek, bogus); EXPECT_TRUE(ptResult.isErr()); }); } TEST_F(LockstoreServiceTest, Decrypt_RejectsEmptyArgs) { nsCString coll = UniqueCollection("empty-dec"); RunOnBackground([&]() { EXPECT_NS_SUCCEEDED( mService->DoCreateDek(coll, mLocalKek, /*extractable=*/false)); auto ctResult = mService->DoEncrypt(coll, mLocalKek, Bytes("x")); ASSERT_TRUE(ctResult.isOk()); auto ct = ctResult.unwrap(); EXPECT_TRUE(mService->DoDecrypt(""_ns, mLocalKek, ct).isErr()); EXPECT_TRUE(mService->DoDecrypt(coll, ""_ns, ct).isErr()); mService->DoDeleteDek(coll); }); } // --------------------------------------------------------------------------- // addKek / removeKek // --------------------------------------------------------------------------- TEST_F(LockstoreServiceTest, AddKek_RejectsMissingCollection) { nsCString coll = UniqueCollection("addkek-missing"); RunOnBackground([&]() { EXPECT_EQ(mService->DoAddKek(coll, mLocalKek, mOtherKek), NS_ERROR_NOT_AVAILABLE); }); } TEST_F(LockstoreServiceTest, AddKek_RejectsEmptyArgs) { nsCString coll = UniqueCollection("addkek-empty"); RunOnBackground([&]() { EXPECT_NS_SUCCEEDED( mService->DoCreateDek(coll, mLocalKek, /*extractable=*/false)); EXPECT_EQ(mService->DoAddKek(""_ns, mLocalKek, mOtherKek), NS_ERROR_INVALID_ARG); EXPECT_EQ(mService->DoAddKek(coll, ""_ns, mOtherKek), NS_ERROR_INVALID_ARG); EXPECT_EQ(mService->DoAddKek(coll, mLocalKek, ""_ns), NS_ERROR_INVALID_ARG); mService->DoDeleteDek(coll); }); } TEST_F(LockstoreServiceTest, RemoveKek_RejectsEmptyArgs) { nsCString coll = UniqueCollection("rmkek-empty"); RunOnBackground([&]() { EXPECT_NS_SUCCEEDED( mService->DoCreateDek(coll, mLocalKek, /*extractable=*/false)); EXPECT_EQ(mService->DoRemoveKek(""_ns, mLocalKek), NS_ERROR_INVALID_ARG); EXPECT_EQ(mService->DoRemoveKek(coll, ""_ns), NS_ERROR_INVALID_ARG); mService->DoDeleteDek(coll); }); } TEST_F(LockstoreServiceTest, RemoveKek_LastWrappingRejects) { nsCString coll = UniqueCollection("rmkek-last"); RunOnBackground([&]() { EXPECT_NS_SUCCEEDED( mService->DoCreateDek(coll, mLocalKek, /*extractable=*/false)); // Removing the last KEK wrapping must be rejected — otherwise the // DEK would become unrecoverable. EXPECT_EQ(mService->DoRemoveKek(coll, mLocalKek), NS_ERROR_FAILURE); mService->DoDeleteDek(coll); }); } // --------------------------------------------------------------------------- // Threading: concurrent dispatches all complete with `mMutex` // serialising under the hood. // --------------------------------------------------------------------------- TEST_F(LockstoreServiceTest, ConcurrentEncryptsAllResolveUnique) { // Dispatch N encrypts in parallel on independent background tasks. // The service's `mMutex` guarantees they execute one at a time; // each must produce a distinct ciphertext (random nonce). nsCString coll = UniqueCollection("concurrent"); RunOnBackground([&]() { EXPECT_NS_SUCCEEDED( mService->DoCreateDek(coll, mLocalKek, /*extractable=*/false)); }); constexpr size_t N = 8; nsTArray> results; results.SetLength(N); std::atomic doneCount{0}; for (size_t i = 0; i < N; ++i) { MOZ_ALWAYS_SUCCEEDS(NS_DispatchBackgroundTask(NS_NewRunnableFunction( "ConcurrentEncryptsAllResolveUnique::worker", [this, &coll, &results, &doneCount, i]() { auto r = mService->DoEncrypt(coll, mLocalKek, Bytes("same-input")); if (r.isOk()) { results[i] = r.unwrap(); } // Record completion on the main thread so the spin loop is woken // promptly (see RunOnBackground). NS_DispatchToMainThread( NS_NewRunnableFunction("ConcurrentEncryptsAllResolveUnique::done", [&doneCount]() { ++doneCount; })); }))); } MOZ_ALWAYS_TRUE( SpinEventLoopUntil("ConcurrentEncryptsAllResolveUnique"_ns, [&doneCount]() { return doneCount.load() == N; })); std::set seen; for (const auto& ct : results) { EXPECT_GT(ct.Length(), 0u); nsCString joined; for (uint8_t b : ct) { joined.AppendInt(static_cast(b)); joined.AppendLiteral(","); } seen.insert(std::move(joined)); } EXPECT_EQ(seen.size(), N) << "Every concurrent encrypt must produce a unique ciphertext"; RunOnBackground([&]() { mService->DoDeleteDek(coll); }); } TEST_F(LockstoreServiceTest, ConcurrentMixedOpsAllComplete) { // Mix createDek / encrypt-decrypt / deleteDek across multiple // collections concurrently. All ops must complete without deadlock, // and listCollections at the end must reflect the post-cleanup state. constexpr size_t N = 4; nsTArray colls; for (size_t i = 0; i < N; ++i) { colls.AppendElement(UniqueCollection("mix")); } // Concurrent creates. std::atomic createDone{0}; for (size_t i = 0; i < N; ++i) { const nsCString& c = colls[i]; MOZ_ALWAYS_SUCCEEDS(NS_DispatchBackgroundTask(NS_NewRunnableFunction( "ConcurrentMixedOps::create", [this, &c, &createDone]() { EXPECT_NS_SUCCEEDED( mService->DoCreateDek(c, mLocalKek, /*extractable=*/false)); NS_DispatchToMainThread( NS_NewRunnableFunction("ConcurrentMixedOps::create-done", [&createDone]() { ++createDone; })); }))); } MOZ_ALWAYS_TRUE( SpinEventLoopUntil("ConcurrentMixedOps::create-wait"_ns, [&createDone]() { return createDone.load() == N; })); // Concurrent encrypt + decrypt round-trips per collection. std::atomic roundtripDone{0}; for (size_t i = 0; i < N; ++i) { const nsCString& c = colls[i]; MOZ_ALWAYS_SUCCEEDS(NS_DispatchBackgroundTask(NS_NewRunnableFunction( "ConcurrentMixedOps::roundtrip", [this, &c, &roundtripDone]() { auto ctResult = mService->DoEncrypt(c, mLocalKek, Bytes("payload")); EXPECT_TRUE(ctResult.isOk()); if (ctResult.isOk()) { auto ct = ctResult.unwrap(); EXPECT_GT(ct.Length(), 0u); auto ptResult = mService->DoDecrypt(c, mLocalKek, ct); EXPECT_TRUE(ptResult.isOk()); if (ptResult.isOk()) { EXPECT_EQ(ptResult.unwrap().Length(), strlen("payload")); } } NS_DispatchToMainThread( NS_NewRunnableFunction("ConcurrentMixedOps::roundtrip-done", [&roundtripDone]() { ++roundtripDone; })); }))); } MOZ_ALWAYS_TRUE(SpinEventLoopUntil( "ConcurrentMixedOps::roundtrip-wait"_ns, [&roundtripDone]() { return roundtripDone.load() == N; })); // Concurrent deletes. std::atomic deleteDone{0}; for (size_t i = 0; i < N; ++i) { const nsCString& c = colls[i]; MOZ_ALWAYS_SUCCEEDS(NS_DispatchBackgroundTask(NS_NewRunnableFunction( "ConcurrentMixedOps::delete", [this, &c, &deleteDone]() { EXPECT_NS_SUCCEEDED(mService->DoDeleteDek(c)); NS_DispatchToMainThread( NS_NewRunnableFunction("ConcurrentMixedOps::delete-done", [&deleteDone]() { ++deleteDone; })); }))); } MOZ_ALWAYS_TRUE( SpinEventLoopUntil("ConcurrentMixedOps::delete-wait"_ns, [&deleteDone]() { return deleteDone.load() == N; })); // Verify listCollections no longer contains any of them. RunOnBackground([&]() { auto remainingResult = mService->DoListDeks(); ASSERT_TRUE(remainingResult.isOk()); auto remaining = remainingResult.unwrap(); for (const auto& c : colls) { for (const auto& r : remaining) { EXPECT_NE(r, c) << "Collection " << c.get() << " should be gone but is still listed"; } } }); }