Source code

Revision control

Copy as Markdown

Other Tools

/* 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 <atomic>
#include <set>
#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 <typename Fn>
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<Fn>(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<uint8_t> Bytes(const char* aLiteral) {
nsTArray<uint8_t> out;
out.AppendElements(reinterpret_cast<const uint8_t*>(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<LockstoreService> mService;
};
// ---------------------------------------------------------------------------
// Singleton / lifecycle
// ---------------------------------------------------------------------------
TEST_F(LockstoreServiceTest, SingletonIdentity) {
RefPtr<LockstoreService> a = LockstoreService::GetSingleton();
RefPtr<LockstoreService> 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<nsCString> 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<char>(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<uint8_t> bogus;
bogus.AppendElements(static_cast<const uint8_t*>(
reinterpret_cast<const uint8_t*>("\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<nsTArray<uint8_t>> results;
results.SetLength(N);
std::atomic<size_t> 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<nsCString> seen;
for (const auto& ct : results) {
EXPECT_GT(ct.Length(), 0u);
nsCString joined;
for (uint8_t b : ct) {
joined.AppendInt(static_cast<uint32_t>(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<nsCString> colls;
for (size_t i = 0; i < N; ++i) {
colls.AppendElement(UniqueCollection("mix"));
}
// Concurrent creates.
std::atomic<size_t> 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<size_t> 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<size_t> 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";
}
}
});
}