#include "nocal/sync/secret_store.hpp" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include namespace { using nocal::sync::LibsecretOAuthSecretStore; using nocal::sync::OAuthTokens; using nocal::sync::SecretStoreError; void require(bool condition, std::string_view message) { if (!condition) { throw std::runtime_error(std::string{message}); } } template void require_error(Function&& function, SecretStoreError::Kind kind, std::string_view message, std::span sensitive = {}) { try { function(); } catch (const SecretStoreError& error) { require(error.kind() == kind, std::string{message}.append(": wrong error kind")); for (const auto value : sensitive) { require(value.empty() || std::string_view{error.what()}.find(value) == std::string_view::npos, std::string{message}.append(": sensitive value leaked in exception")); } return; } catch (const std::exception&) { throw std::runtime_error(std::string{message}.append(": wrong exception type")); } throw std::runtime_error(std::string{message}.append(": no exception")); } class TestSchema { public: TestSchema() : schema_(secret_schema_new("dev.nomarchy.nocal.oauth", SECRET_SCHEMA_NONE, "account", SECRET_SCHEMA_ATTRIBUTE_STRING, nullptr)) { require(schema_ != nullptr, "could not allocate test secret schema"); } ~TestSchema() { secret_schema_unref(schema_); } TestSchema(const TestSchema&) = delete; TestSchema& operator=(const TestSchema&) = delete; [[nodiscard]] const SecretSchema* get() const noexcept { return schema_; } private: SecretSchema* schema_; }; class ErrorHolder { public: ~ErrorHolder() { if (error_ != nullptr) { g_error_free(error_); } } [[nodiscard]] GError** output() noexcept { return &error_; } [[nodiscard]] const GError* get() const noexcept { return error_; } private: GError* error_{nullptr}; }; void secure_zero(char* bytes, std::size_t size) noexcept { volatile char* output = bytes; while (size > 0) { *output++ = 0; --size; } } class RawSecret { public: explicit RawSecret(char* value) : value_(value) {} ~RawSecret() { if (value_ != nullptr) { secure_zero(value_, std::char_traits::length(value_)); secret_password_free(value_); } } [[nodiscard]] const char* get() const noexcept { return value_; } private: char* value_; }; [[nodiscard]] OAuthTokens tokens(std::string marker) { return OAuthTokens{"access-" + marker, "refresh-" + marker, "Bearer", "openid offline_access Calendars.ReadWrite", 2'000'000'000}; } void inject_secret(std::string_view account, std::string_view value) { TestSchema schema; ErrorHolder error; const gboolean stored = secret_password_store_sync(schema.get(), SECRET_COLLECTION_DEFAULT, std::string{account}.c_str(), std::string{value}.c_str(), nullptr, error.output(), "account", std::string{account}.c_str(), nullptr); require(stored && error.get() == nullptr, "could not inject raw libsecret fixture"); } [[nodiscard]] std::string lookup_raw(std::string_view account) { TestSchema schema; ErrorHolder error; const std::string owned_account{account}; RawSecret secret{secret_password_lookup_sync( schema.get(), nullptr, error.output(), "account", owned_account.c_str(), nullptr)}; require(error.get() == nullptr && secret.get() != nullptr, "could not inspect stored JSON secret"); return secret.get(); } void test_crud_isolation_and_versioned_secret(std::string_view run_id) { LibsecretOAuthSecretStore store; const std::string account_a = "account-a-" + std::string{run_id}; const std::string account_b = "account-b-" + std::string{run_id}; store.erase(account_a); store.erase(account_b); require(!store.load(account_a).has_value(), "missing secret did not load as nullopt"); store.erase(account_a); const OAuthTokens first = tokens("first-" + std::string{run_id}); const OAuthTokens second = tokens("second-" + std::string{run_id}); store.store(account_a, first); store.store(account_b, second); require(store.load(account_a) == std::optional{first}, "stored account A did not round-trip"); require(store.load(account_b) == std::optional{second}, "stored account B did not round-trip"); const std::string serialized = lookup_raw(account_a); require(serialized.size() <= 64U * 1024U, "serialized secret exceeds its bound"); const nlohmann::json document = nlohmann::json::parse(serialized); require(document.is_object() && document.size() == 6 && document.at("version") == 1, "stored secret is not the exact versioned JSON shape"); require(document.at("access_token") == first.access_token && document.at("refresh_token") == first.refresh_token, "stored JSON did not contain the expected token payload"); OAuthTokens updated = tokens("updated-\"\\-" + std::string{run_id}); updated.scope.clear(); store.store(account_a, updated); require(store.load(account_a) == std::optional{updated}, "credential update or valid empty scope did not round-trip"); require(store.load(account_b) == std::optional{second}, "account update crossed account boundary"); store.erase(account_a); require(!store.load(account_a).has_value(), "erase did not remove credential"); require(store.load(account_b) == std::optional{second}, "erase crossed account boundary"); store.erase(account_a); store.erase(account_b); } void test_label_and_attributes(std::string_view run_id) { LibsecretOAuthSecretStore store; const std::string account = "opaque-account-" + std::string{run_id}; const OAuthTokens value = tokens("attribute-secret-" + std::string{run_id}); store.store(account, value); TestSchema schema; ErrorHolder error; SecretService* service = secret_service_get_sync(SECRET_SERVICE_LOAD_COLLECTIONS, nullptr, error.output()); require(service != nullptr && error.get() == nullptr, "could not inspect Secret Service"); GHashTable* query = secret_attributes_build(schema.get(), "account", account.c_str(), nullptr); require(query != nullptr, "could not build Secret Service attribute query"); GList* items = secret_service_search_sync(service, schema.get(), query, SECRET_SEARCH_ALL, nullptr, error.output()); g_hash_table_unref(query); require(error.get() == nullptr && g_list_length(items) == 1, "stored credential did not have one matching Secret Service item"); SecretItem* item = SECRET_ITEM(items->data); char* label = secret_item_get_label(item); require(label != nullptr && std::string_view{label} == account, "Secret Service label contains more than the opaque account ID"); require(std::string_view{label}.find(value.access_token) == std::string_view::npos, "token leaked into Secret Service label"); g_free(label); GHashTable* attributes = secret_item_get_attributes(item); require(attributes != nullptr, "could not inspect Secret Service attributes"); GHashTableIter iterator; gpointer key = nullptr; gpointer attribute_value = nullptr; g_hash_table_iter_init(&iterator, attributes); while (g_hash_table_iter_next(&iterator, &key, &attribute_value)) { const std::string_view name{static_cast(key)}; const std::string_view stored_value{static_cast(attribute_value)}; require(name.find(value.access_token) == std::string_view::npos && stored_value.find(value.access_token) == std::string_view::npos, "token leaked into Secret Service attributes"); if (name == "account") { require(stored_value == account, "account attribute has the wrong value"); } } g_hash_table_unref(attributes); g_list_free_full(items, g_object_unref); g_object_unref(service); store.erase(account); } void test_invalid_inputs_and_preservation(std::string_view run_id) { LibsecretOAuthSecretStore store; const std::string account = "validation-account-" + std::string{run_id}; const OAuthTokens original = tokens("original-" + std::string{run_id}); store.store(account, original); const std::array invalid_accounts{ "", std::string{"bad\0account", 11}, "bad\naccount"}; for (const auto& invalid : invalid_accounts) { const std::array sensitive{account, original.access_token}; require_error([&] { store.store(invalid, original); }, SecretStoreError::Kind::invalid_input, "invalid account accepted", sensitive); require_error([&] { static_cast(store.load(invalid)); }, SecretStoreError::Kind::invalid_input, "invalid account load accepted", sensitive); require_error([&] { store.erase(invalid); }, SecretStoreError::Kind::invalid_input, "invalid account erase accepted", sensitive); } std::vector invalid_tokens; for (int field = 0; field < 3; ++field) { OAuthTokens invalid = original; if (field == 0) { invalid.access_token.clear(); } else if (field == 1) { invalid.refresh_token.clear(); } else { invalid.token_type.clear(); } invalid_tokens.push_back(std::move(invalid)); } OAuthTokens wrong_type = original; wrong_type.token_type = "bearer"; invalid_tokens.push_back(std::move(wrong_type)); OAuthTokens invalid_expiry = original; invalid_expiry.expires_at_epoch_seconds = 0; invalid_tokens.push_back(std::move(invalid_expiry)); OAuthTokens negative_expiry = original; negative_expiry.expires_at_epoch_seconds = -1; invalid_tokens.push_back(std::move(negative_expiry)); OAuthTokens control = original; control.access_token = std::string{"secret\0suffix", 13}; invalid_tokens.push_back(std::move(control)); OAuthTokens scope_control = original; scope_control.scope = "openid\noffline_access"; invalid_tokens.push_back(std::move(scope_control)); OAuthTokens oversized = original; oversized.access_token.assign(65U * 1024U, 'x'); invalid_tokens.push_back(std::move(oversized)); for (const auto& invalid : invalid_tokens) { const std::array sensitive{account, original.access_token}; require_error([&] { store.store(account, invalid); }, SecretStoreError::Kind::invalid_input, "invalid token set accepted", sensitive); require(store.load(account) == std::optional{original}, "invalid update changed the previous credential"); } store.erase(account); } void test_cancellation_preserves_value(std::string_view run_id) { LibsecretOAuthSecretStore store; const std::string account = "cancel-account-" + std::string{run_id}; const OAuthTokens original = tokens("cancel-original-" + std::string{run_id}); const OAuthTokens replacement = tokens("cancel-replacement-" + std::string{run_id}); store.store(account, original); std::stop_source cancelled; cancelled.request_stop(); const std::array sensitive{ account, original.access_token, replacement.access_token}; require_error([&] { store.store(account, replacement, cancelled.get_token()); }, SecretStoreError::Kind::cancelled, "pre-cancelled update was accepted", sensitive); require(store.load(account) == std::optional{original}, "pre-cancelled update changed the previous credential"); require_error([&] { static_cast(store.load(account, cancelled.get_token())); }, SecretStoreError::Kind::cancelled, "pre-cancelled load was accepted", sensitive); require_error([&] { store.erase(account, cancelled.get_token()); }, SecretStoreError::Kind::cancelled, "pre-cancelled erase was accepted", sensitive); require(store.load(account) == std::optional{original}, "pre-cancelled erase removed the credential"); store.erase(account); } void test_corrupt_secret_redaction(std::string_view run_id) { LibsecretOAuthSecretStore store; const std::string account = "corrupt-account-" + std::string{run_id}; const std::string corrupt = "{SENSITIVE_CORRUPT_SECRET_" + std::string{run_id}; inject_secret(account, corrupt); const std::array sensitive{account, corrupt}; require_error([&] { static_cast(store.load(account)); }, SecretStoreError::Kind::corrupt_secret, "corrupt stored secret was accepted", sensitive); inject_secret(account, R"({"version":1,"access_token":"","refresh_token":"r","token_type":"Bearer","scope":"s","expires_at_epoch_seconds":1})"); require_error([&] { static_cast(store.load(account)); }, SecretStoreError::Kind::corrupt_secret, "incomplete stored token was accepted", sensitive); inject_secret(account, R"({"version":1,"version":1,"access_token":"a","refresh_token":"r","token_type":"Bearer","scope":"","expires_at_epoch_seconds":1})"); require_error([&] { static_cast(store.load(account)); }, SecretStoreError::Kind::corrupt_secret, "duplicate stored JSON key was accepted", sensitive); store.erase(account); } [[nodiscard]] std::string read_binary_file(const std::filesystem::path& path) { std::ifstream input(path, std::ios::binary); if (!input) { return {}; } return {std::istreambuf_iterator{input}, {}}; } void test_token_not_in_files_environment_or_argv(std::string_view run_id) { LibsecretOAuthSecretStore store; const std::string account = "leak-account-" + std::string{run_id}; OAuthTokens value = tokens("PLAINTEXT_LEAK_MARKER_" + std::string{run_id}); store.store(account, value); const std::string marker = value.access_token; for (const auto proc_file : {"/proc/self/environ", "/proc/self/cmdline"}) { require(read_binary_file(proc_file).find(marker) == std::string::npos, "token leaked into process environment or argv"); } const char* home = std::getenv("HOME"); require(home != nullptr && *home != '\0', "test wrapper did not isolate HOME"); for (const auto& entry : std::filesystem::recursive_directory_iterator( home, std::filesystem::directory_options::skip_permission_denied)) { std::error_code error; if (!entry.is_regular_file(error) || error || entry.file_size(error) > 16U * 1024U * 1024U) { continue; } require(read_binary_file(entry.path()).find(marker) == std::string::npos, "token appeared in plaintext in a Secret Service backing file"); } store.erase(account); } int unavailable_helper() { const std::string account = "SENSITIVE_UNAVAILABLE_ACCOUNT"; try { LibsecretOAuthSecretStore store; static_cast(store.load(account)); } catch (const SecretStoreError& error) { if (error.kind() == SecretStoreError::Kind::unavailable && std::string_view{error.what()}.find(account) == std::string_view::npos) { return 0; } } catch (...) { } return 1; } void test_unavailable_service_redaction() { const pid_t child = ::fork(); require(child >= 0, "could not fork unavailable-service helper"); if (child == 0) { static_cast(::setenv( "DBUS_SESSION_BUS_ADDRESS", "unix:path=/tmp/nocal-nonexistent-secret-bus", 1)); ::execl("/proc/self/exe", "/proc/self/exe", "--unavailable-helper", nullptr); _exit(99); } int status = 0; while (::waitpid(child, &status, 0) < 0) { if (errno != EINTR) { throw std::runtime_error("could not wait for unavailable-service helper"); } } require(WIFEXITED(status) && WEXITSTATUS(status) == 0, "unavailable Secret Service was not mapped to a redacted typed error"); } } // namespace int main(int argc, char** argv) { if (argc == 2 && std::string_view{argv[1]} == "--unavailable-helper") { return unavailable_helper(); } try { const std::string run_id = std::to_string(static_cast(::getpid())); test_crud_isolation_and_versioned_secret(run_id); test_label_and_attributes(run_id); test_invalid_inputs_and_preservation(run_id); test_cancellation_preserves_value(run_id); test_corrupt_secret_redaction(run_id); test_token_not_in_files_environment_or_argv(run_id); test_unavailable_service_redaction(); } catch (const std::exception& error) { std::cerr << "secret store test failure: " << error.what() << '\n'; return 1; } std::cout << "secret store tests passed\n"; return 0; }