Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ jobs:

- name: Run cmake (Unix)
if: runner.os != 'Windows'
run: cmake . -DBUILD_TESTS=yes -DCMAKE_BUILD_TYPE=Release
run: cmake . -DBUILD_TESTS=yes -DSIMULATE_FAILURES=yes -DCMAKE_BUILD_TYPE=Release

- name: Run cmake (Windows)
if: runner.os == 'Windows'
Expand Down Expand Up @@ -87,7 +87,7 @@ jobs:
run: brew install gcovr

- name: Run cmake
run: cmake . -DBUILD_TESTS=yes -DCODE_COVERAGE=yes -DCMAKE_BUILD_TYPE=Debug
run: cmake . -DBUILD_TESTS=yes -DCODE_COVERAGE=yes -DSIMULATE_FAILURES=yes -DCMAKE_BUILD_TYPE=Debug

- name: Build and run tests (Linux)
if: runner.os == 'Linux'
Expand Down
9 changes: 9 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ cmake_minimum_required(VERSION 3.12.4)
project(keychain)

option(BUILD_TESTS "Build tests for ${PROJECT_NAME}" OFF)
option(SIMULATE_FAILURES "Enable simulated failures in tests for ${PROJECT_NAME}" OFF)

add_library(${PROJECT_NAME})
target_include_directories(${PROJECT_NAME}
Expand Down Expand Up @@ -88,4 +89,12 @@ install(TARGETS ${PROJECT_NAME}

if (BUILD_TESTS)
add_subdirectory("test")
if (SIMULATE_FAILURES)
target_compile_definitions(${PROJECT_NAME}
PRIVATE
-DSIMULATE_FAILURES=1)
target_compile_definitions(${PROJECT_NAME}-test
PRIVATE
-DSIMULATE_FAILURES=1)
endif ()
endif ()
14 changes: 14 additions & 0 deletions include/keychain/keychain.h
Original file line number Diff line number Diff line change
Expand Up @@ -84,11 +84,25 @@ void setPassword(const std::string &package, const std::string &service,
void deletePassword(const std::string &package, const std::string &service,
const std::string &user, Error &err);

/*! \brief Check if the keychain is available
*
* This function checks whether the platform's credential store is available
* and functional. On Linux and macOS, this probes the underlying service
* (SecretService or Keychain Services). On Windows, Credential Manager is
* a built-in OS component that is always available, so this is a no-op
* that always returns true.
*
* \param err Output parameter communicating success or error details
* \return true if the credential store is available, false otherwise
*/
bool isAvailable(Error &err);

enum class ErrorType {
// update CATCH_REGISTER_ENUM in tests.cpp when changing this
NoError = 0,
GenericError,
NotFound,
Unavailable,
// OS-specific errors
PasswordTooLong = 10, // Windows only
AccessDenied, // macOS only
Expand Down
29 changes: 29 additions & 0 deletions src/keychain_linux.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -158,4 +158,33 @@ void deletePassword(const std::string &package, const std::string &service,
}
}

bool isAvailable(Error &err) {
err = Error{};

#ifdef SIMULATE_FAILURES
// TEST HOOK: Simulate failure to create SecretService
if (getenv("KEYCHAIN_TEST_SIMULATED_FAILURE")) {
err.type = ErrorType::Unavailable;
err.message = "Simulated failure: SecretService unavailable";
err.code = -1;
return false;
}
#endif

GError *error = NULL;
SecretService *svc =
secret_service_get_sync((SecretServiceFlags)0, NULL, &error);

if (error != NULL || svc == NULL) {
err.type = ErrorType::Unavailable;
err.message = error ? error->message : "SecretService unavailable";
err.code = error ? error->code : -1;
if (error)
g_error_free(error);
return false;
}
g_object_unref(svc);
return true;
}

} // namespace keychain
45 changes: 45 additions & 0 deletions src/keychain_mac.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -270,4 +270,49 @@ void deletePassword(const std::string &package, const std::string &service,
updateError(err, SecItemDelete(query.get()));
}

bool isAvailable(Error &err) {
err = Error{};

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wouldn't consider any runtime failure here as an indicator of the keychain not being available, and I wouldn't shadow the lower-level errors with the Unavailable error type.

Can we use createQuery to make the query as usual, fail with any errors it might produce, and then only look at whether or not SecItemCopyMatching succeeds as an indicator of isAvailable?

I think we can do without the specific test cases for KEYCHAIN_TEST_FAIL_DICT and KEYCHAIN_TEST_FAIL_STRING too.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree that runtime errors should not be masked with Unavailable actually. I am not sure what I was thinking there. However, createQuery can block forever if no keychain exists at the time. This is an attempt to detect the potential of this happening beforehand.

I am okay with removing these test hooks

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I needed to go back and review I do feel like you're right for everything

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My only real thing is that the way I did it is more of a proactive probe... it's confirming the keychain is usable and can help indicate if ceateQuery is not safe to use (due to how it may hang).

By breaking it down to the lowest level parts and testing each step in isolation we can help improve the use experience

auto query = createCFMutableDictionary(err);
if (!query) {
err.type = ErrorType::Unavailable;
err.message = "Failed to create query dictionary";
return false;
}

CFDictionaryAddValue(query.get(), kSecClass, kSecClassGenericPassword);

auto service =
createCFStringWithCString("keychain_availability_check_service", err);
auto account =
createCFStringWithCString("keychain_availability_check_account", err);
if (!service || !account) {
err.type = ErrorType::Unavailable;
err.message = "Failed to create service/account string";
return false;
}

CFDictionaryAddValue(query.get(), kSecAttrService, service.get());
CFDictionaryAddValue(query.get(), kSecAttrAccount, account.get());
CFDictionaryAddValue(query.get(), kSecReturnData, kCFBooleanFalse);

#ifdef SIMULATE_FAILURES
// TEST HOOK: Simulate SecItemCopyMatching failure
if (getenv("KEYCHAIN_TEST_SIMULATED_FAILURE")) {
err.type = ErrorType::Unavailable;
err.message = "Simulated failure: SecItemCopyMatching";
return false;
}
#endif

OSStatus status = SecItemCopyMatching(query.get(), nullptr);

if (status == errSecSuccess || status == errSecItemNotFound) {
return true;
} else {
err.type = ErrorType::Unavailable;
err.message = errorStatusToString(status);
return false;
}
}
} // namespace keychain
7 changes: 7 additions & 0 deletions src/keychain_win.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -250,4 +250,11 @@ void deletePassword(const std::string &package, const std::string &service,
}
}

bool isAvailable(Error &err) {
// Credential Manager is always present on Windows;
// any runtime errors will surface in get/set/delete.
err = Error{};
return true;
}

} // namespace keychain
35 changes: 35 additions & 0 deletions test/tests.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ CATCH_REGISTER_ENUM(keychain::ErrorType,
keychain::ErrorType::NoError,
keychain::ErrorType::GenericError,
keychain::ErrorType::NotFound,
keychain::ErrorType::Unavailable,
keychain::ErrorType::PasswordTooLong,
keychain::ErrorType::AccessDenied)
// clang-format on
Expand Down Expand Up @@ -111,4 +112,38 @@ TEST_CASE("Keychain", "[keychain]") {
deletePassword(package, service, user, ec);
check_no_error(ec);
}

#if defined(KEYCHAIN_MACOS) && defined(SIMULATE_FAILURES)
SECTION("isAvailable fails at SecItemCopyMatching") {
setenv("KEYCHAIN_TEST_SIMULATED_FAILURE", "1", 1);
Error ec{};
bool available = isAvailable(ec);
CHECK_FALSE(available);
CHECK(ec.type == ErrorType::Unavailable);
CHECK(ec.message.find("Simulated failure: SecItemCopyMatching") !=
std::string::npos);
unsetenv("KEYCHAIN_TEST_SIMULATED_FAILURE");
}
#endif

#if defined(KEYCHAIN_LINUX) && defined(SIMULATE_FAILURES)
SECTION("isAvailable fails at SecretService creation") {
setenv("KEYCHAIN_TEST_SIMULATED_FAILURE", "1", 1);
Error ec{};
bool available = isAvailable(ec);
CHECK_FALSE(available);
CHECK(ec.type == ErrorType::Unavailable);
CHECK(ec.message.find("Simulated failure: SecretService unavailable") !=
std::string::npos);
unsetenv("KEYCHAIN_TEST_SIMULATED_FAILURE");
}
#endif

SECTION("isAvailable returns true and no error on supported platforms") {
Error ec{};
bool available = isAvailable(ec);

REQUIRE(available);
check_no_error(ec);
}
}
Loading