diff --git a/CMakeLists.txt b/CMakeLists.txt index c19f25a6f5..bb5284b275 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -31,6 +31,7 @@ set(SOURCE_FILES include/snowflake/logger.h include/snowflake/version.h include/snowflake/platform.h + include/snowflake/secure_storage.h lib/client.c lib/constants.h lib/cJSON.h @@ -63,7 +64,9 @@ set(SOURCE_FILES lib/chunk_downloader.h lib/chunk_downloader.c lib/mock_http_perform.h - lib/http_perform.c) + lib/http_perform.c + cpp/lib/CacheFile.cpp +) set (SOURCE_FILES_PUT_GET cpp/EncryptionProvider.cpp @@ -150,6 +153,7 @@ set(SOURCE_FILES_CPP_WRAPPER include/snowflake/CurlDesc.hpp include/snowflake/CurlDescPool.hpp include/snowflake/BindUploader.hpp + include/snowflake/SecureStorage.hpp cpp/lib/Exceptions.cpp cpp/lib/Connection.cpp cpp/lib/Statement.cpp @@ -173,14 +177,27 @@ set(SOURCE_FILES_CPP_WRAPPER cpp/lib/BindUploader.cpp cpp/lib/ClientBindUploader.hpp cpp/lib/ClientBindUploader.cpp + cpp/lib/CacheFile.cpp + cpp/lib/CacheFile.hpp + cpp/platform/secure_storage.cpp + cpp/platform/SecureStorage.cpp + cpp/platform/SecureStorageApple.cpp + cpp/platform/SecureStorageLinux.cpp + cpp/platform/SecureStorageWin.cpp + cpp/platform/FileLock.cpp + cpp/platform/FileLock.hpp cpp/util/SnowflakeCommon.cpp cpp/util/SFURL.cpp cpp/util/CurlDesc.cpp cpp/util/CurlDescPool.cpp + cpp/util/Sha256.cpp + cpp/util/Sha256.hpp + cpp/platform/SecureStorage.cpp lib/result_set.h lib/query_context_cache.h lib/curl_desc_pool.h - lib/authenticator.h) + lib/authenticator.h +) if (UNIX) if (LINUX) @@ -284,6 +301,7 @@ if (WIN32) find_library(BOOST_REGEX_LIB boost_regex-vc140-mt-gd.lib PATHS deps-build/${PLATFORM}/${VSDIR}/${CMAKE_BUILD_TYPE}/boost/lib/ REQUIRED NO_DEFAULT_PATH) find_library(BOOST_SYSTEM_LIB boost_system-vc140-mt-gd.lib PATHS deps-build/${PLATFORM}/${VSDIR}/${CMAKE_BUILD_TYPE}/boost/lib/ REQUIRED NO_DEFAULT_PATH) else() + message(deps-build/${PLATFORM}/${VSDIR}/${CMAKE_BUILD_TYPE}/boost/lib/) find_library(BOOST_FILESYSTEM_LIB boost_filesystem-vc140-mt.lib PATHS deps-build/${PLATFORM}/${VSDIR}/${CMAKE_BUILD_TYPE}/boost/lib/ REQUIRED NO_DEFAULT_PATH) find_library(BOOST_REGEX_LIB boost_regex-vc140-mt.lib PATHS deps-build/${PLATFORM}/${VSDIR}/${CMAKE_BUILD_TYPE}/boost/lib/ REQUIRED NO_DEFAULT_PATH) find_library(BOOST_SYSTEM_LIB boost_system-vc140-mt.lib PATHS deps-build/${PLATFORM}/${VSDIR}/${CMAKE_BUILD_TYPE}/boost/lib/ REQUIRED NO_DEFAULT_PATH) @@ -339,6 +357,7 @@ if (LINUX) deps-build/${PLATFORM}/${CMAKE_BUILD_TYPE}/azure/include deps-build/${PLATFORM}/${CMAKE_BUILD_TYPE}/cmocka/include deps-build/${PLATFORM}/${CMAKE_BUILD_TYPE}/uuid/include + deps-build/${PLATFORM}/${CMAKE_BUILD_TYPE}/picojson/include include lib) endif() @@ -354,6 +373,7 @@ if (APPLE) deps-build/${PLATFORM}/${CMAKE_BUILD_TYPE}/aws/include deps-build/${PLATFORM}/${CMAKE_BUILD_TYPE}/azure/include deps-build/${PLATFORM}/${CMAKE_BUILD_TYPE}/cmocka/include + deps-build/${PLATFORM}/${CMAKE_BUILD_TYPE}/picojson/include include lib) endif() @@ -368,6 +388,7 @@ if (WIN32) deps-build/${PLATFORM}/${VSDIR}/${CMAKE_BUILD_TYPE}/aws/include deps-build/${PLATFORM}/${VSDIR}/${CMAKE_BUILD_TYPE}/azure/include deps-build/${PLATFORM}/${VSDIR}/${CMAKE_BUILD_TYPE}/cmocka/include + deps-build/${PLATFORM}/${VSDIR}/${CMAKE_BUILD_TYPE}/picojson/include include lib) if (CMAKE_SIZEOF_VOID_P EQUAL 8) diff --git a/cpp/lib/CacheFile.cpp b/cpp/lib/CacheFile.cpp new file mode 100644 index 0000000000..220fd358a3 --- /dev/null +++ b/cpp/lib/CacheFile.cpp @@ -0,0 +1,262 @@ +/* + * File: CacheFile.cpp * + * Copyright (c) 2025 Snowflake Computing + */ + +#include "CacheFile.hpp" + +#if defined(__linux__) || defined(__APPLE__) +#include +#endif + +#include +#include +#include + +#include +#include + +#include "snowflake/platform.h" +#include "../logger/SFLogger.hpp" +#include "../util/Sha256.hpp" + +namespace { + using namespace Snowflake::Client; + const char* CREDENTIAL_FILE_NAME = "credential_cache_v1.json"; + + bool mkdirIfNotExists(const std::string& dir) + { + int result = sf_mkdir(dir.c_str()); + if (result == 0) + { + CXX_LOG_DEBUG("Created %s directory.", dir.c_str()); + return true; + } + + if (errno == EEXIST) + { + CXX_LOG_TRACE("Directory %s already exists.", dir.c_str()); + return true; + } + + CXX_LOG_ERROR("Failed to create %s directory. Error: %d", dir.c_str(), errno); + return false; + + } + + boost::optional getEnv(const std::string& envVar) + { + char *root = getenv(envVar.c_str()); + + if (root == nullptr) + { + return {}; + } + + return std::string(root); + } + + void ensureObject(picojson::value &val) + { + if (!val.is()) + { + val = picojson::value(picojson::object()); + } + } + + picojson::object& getTokens(picojson::value& cache) + { + ensureObject(cache); + auto &obj = cache.get(); + auto pair = obj.emplace("tokens", picojson::value(picojson::object())); + auto& tokens = pair.first->second; + ensureObject(tokens); + return tokens.get(); + } + +#if defined(__linux__) || defined(__APPLE__) + bool ensurePermissions(const std::string& path, mode_t mode) + { + if (chmod(path.c_str(), mode) == -1) + { + CXX_LOG_ERROR("Cannot ensure permissions. chmod(%s, %o) failed with errno=%d", path.c_str(), mode, errno); + return false; + } + + return true; + } +#else + bool ensurePermissions(const std::string& path, unsigned mode) + { + CXX_LOG_ERROR("Cannot ensure permissions on current platform"); + return false; + } +#endif +} + +namespace Snowflake { + +namespace Client { + boost::optional getCacheDir(const std::string& envVar, const std::vector& subPathSegments) + { +#ifdef __linux__ + auto envVarValueOpt = getEnv(envVar); + if (!envVarValueOpt) + { + return {}; + } + + const std::string& envVarValue = envVarValueOpt.get(); + + struct stat s = {}; + int err = stat(envVarValue.c_str(), &s); + + if (err != 0) + { + CXX_LOG_INFO("Failed to stat %s=%s, errno=%d. Skipping it in cache file location lookup.", envVar.c_str(), envVarValue.c_str(), errno); + return {}; + } + + if (!S_ISDIR(s.st_mode)) + { + CXX_LOG_INFO("%s=%s is not a directory. Skipping it in cache file location lookup.", envVar.c_str(), envVarValue.c_str()); + return {}; + } + + auto cacheDir = envVarValue; + for (const auto& segment: subPathSegments) + { + cacheDir.append(PATH_SEP + segment); + if (!mkdirIfNotExists(cacheDir)) + { + CXX_LOG_INFO("Could not create cache dir=%s. Skipping it in cache file location lookup.", cacheDir.c_str()); + return {}; + } + } + + if (!subPathSegments.empty()) + { + err = stat(cacheDir.c_str(), &s); + if (err != 0) + { + CXX_LOG_INFO("Failed to stat %s, errno=%d. Skipping it in cache file location lookup.", cacheDir.c_str(), errno); + return {}; + } + } + + if (s.st_uid != geteuid()) + { + CXX_LOG_INFO("%s=%s is not owned by current user. Skipping it in cache file location lookup.", envVar.c_str(), envVarValue.c_str()); + return {}; + } + + unsigned permissions = s.st_mode & 0777; + if (permissions != 0700) + { + CXX_LOG_INFO("Incorrect permissions=%o for cache dir %s. Changing permissions to 700.", permissions, cacheDir.c_str()) + if (chmod(cacheDir.c_str(), 0700) != 0) + { + CXX_LOG_WARN("Failed to change permissions for a cache dir %s, errno=%d. Skipping it in cache file location lookup.", cacheDir.c_str(), errno); + return {}; + } + } + return cacheDir; +#else + CXX_LOG_FATAL("Using NOOP implementation. This function is implemented only for linux."); + return {}; +#endif + } + + boost::optional getCredentialFilePath() + { + std::vector()>> lookupFunctions = + { + []() { return getCacheDir("SF_TEMPORARY_CREDENTIAL_CACHE_DIR", {}); }, +#ifdef __linux__ + [](){ return getCacheDir("XDG_CACHE_HOME", {"snowflake"}); }, +#endif + [](){ return getCacheDir("HOME", {".cache", "snowflake"}); }, + }; + + for (const auto& lf: lookupFunctions) { + boost::optional directory = lf(); + if (directory) + { + auto path = directory.get() + PATH_SEP + CREDENTIAL_FILE_NAME; + CXX_LOG_TRACE("Successfully found credential file path=%s", path.c_str()); + return path; + } + } + + return {}; + }; + + std::string readFile(const std::string &path, picojson::value &result) { + if (!boost::filesystem::exists(path)) + { + result = picojson::value(picojson::object()); + return {}; + } + + std::ifstream cacheFile(path); + if (!cacheFile.is_open()) + { + return "Failed to open the file(path=" + path + ")"; + } + + std::string error = picojson::parse(result, cacheFile); + if (!error.empty()) + { + return "Failed to parse the file: " + error; + } + return {}; + } + + std::string writeFile(const std::string &path, const picojson::value &result) { + std::ofstream cacheFile(path, std::ios_base::trunc); + if (!cacheFile.is_open()) + { + return "Failed to open the file"; + } + + if (!ensurePermissions(path, 0600)) + { + return "Cannot ensure correct permissions on a file"; + } + + cacheFile << result.serialize(true); + return {}; + } + + void cacheFileUpdate(picojson::value &cache, const std::string &key, const std::string &credential) + { + picojson::object& tokens = getTokens(cache); + tokens.emplace(key, credential); + } + + void cacheFileRemove(picojson::value &cache, const std::string &key) + { + picojson::object& tokens = getTokens(cache); + tokens.erase(key); + } + + boost::optional cacheFileGet(picojson::value &cache, const std::string &key) { + picojson::object& tokens = getTokens(cache); + auto it = tokens.find(key); + + if (it == tokens.end()) + { + return {}; + } + + if (!it->second.is()) + { + return {}; + } + + return it->second.get(); + } + +} + +} diff --git a/cpp/lib/CacheFile.hpp b/cpp/lib/CacheFile.hpp new file mode 100644 index 0000000000..5b1c3ff91b --- /dev/null +++ b/cpp/lib/CacheFile.hpp @@ -0,0 +1,32 @@ +#ifndef SNOWFLAKECLIENT_CACHEFILE_HPP +#define SNOWFLAKECLIENT_CACHEFILE_HPP + +#include +#include + +#include +#include + +namespace Snowflake { + +namespace Client { + + boost::optional getCacheDir(const std::string& envVar, const std::vector& subPathSegments); + + boost::optional getCredentialFilePath(); + + std::string readFile(const std::string &path, picojson::value &result); + + std::string writeFile(const std::string &path, const picojson::value &result); + + void cacheFileUpdate(picojson::value &cache, const std::string &key, const std::string &credential); + + void cacheFileRemove(picojson::value &cache, const std::string &key); + + boost::optional cacheFileGet(picojson::value &cache, const std::string &key); + +} + +} + +#endif // SNOWFLAKECLIENT_CACHEFILE_HPP diff --git a/cpp/platform/FileLock.cpp b/cpp/platform/FileLock.cpp new file mode 100644 index 0000000000..6a8dcbdfea --- /dev/null +++ b/cpp/platform/FileLock.cpp @@ -0,0 +1,91 @@ +/* + * File: FileLock.cpp * + * Copyright (c) 2025 Snowflake Computing + */ + +#include "FileLock.hpp" + +#include +#include +#include + +#include "../logger/SFLogger.hpp" + +namespace Snowflake { + +namespace Client { + + using Snowflake::Client::SFLogger; + + constexpr int MAX_LOCK_RETRIES = 10; + constexpr std::chrono::seconds lock_retry_sleep(1); + constexpr std::chrono::seconds lock_expired_timeout(60); + constexpr const char *lock_suffix = ".lck"; + + FileLock::FileLock(const std::string &path_) + : path{path_ + lock_suffix}, + locked(false) + { + for (int retries = 0; !locked && retries < MAX_LOCK_RETRIES; retries++) { + bool retry = try_lock(); + + if (!retry) { + break; + } + + std::this_thread::sleep_for(lock_retry_sleep); + } + if (locked) { + CXX_LOG_TRACE("Created file lock(path=%s)", path.c_str()) + } + } + + FileLock::~FileLock() { + if (locked) { + boost::system::error_code ec; + bool removed = boost::filesystem::remove(path, ec); + if (!removed || ec) { + CXX_LOG_ERROR("Failed to release file lock(path=%s)", path.c_str()); + } + else { + CXX_LOG_TRACE("Released file lock(path=%s)", path.c_str()); + } + } + } + + // Returns true when locking should be retried + bool FileLock::try_lock() { + boost::system::error_code ec; + bool created = boost::filesystem::create_directory(path, ec); + + if (created && !ec) + { + locked = true; + return false; + } + + CXX_LOG_ERROR("Failed to acquire file lock(path=%s) with error code: %d", path.c_str(), ec.value()); + std::time_t creation_time_epoch_seconds = boost::filesystem::creation_time(path, ec); + if (ec) + { + CXX_LOG_ERROR("Failed to get creation time for path=%s with error code: %d", path.c_str(), ec.value()); + return true; + } + + std::time_t now_epoch_seconds = time(nullptr); + if (std::chrono::seconds(creation_time_epoch_seconds) + lock_expired_timeout < std::chrono::seconds(now_epoch_seconds)) + { + CXX_LOG_INFO("Cleaning up stale lock.") + boost::filesystem::remove(path, ec); + if (ec) + { + CXX_LOG_ERROR("Failed to remove stale lock(path=%s) with error code: %d", path.c_str(), ec.value()); + } + } + + return true; + + } +} + +} diff --git a/cpp/platform/FileLock.hpp b/cpp/platform/FileLock.hpp new file mode 100644 index 0000000000..528a61e7fd --- /dev/null +++ b/cpp/platform/FileLock.hpp @@ -0,0 +1,43 @@ +/* + * File: FileLock.hpp * + * Copyright (c) 2025 Snowflake Computing + */ + +#ifndef SNOWFLAKECLIENT_FILELOCK_HPP +#define SNOWFLAKECLIENT_FILELOCK_HPP + +#include + +namespace Snowflake { + +namespace Client { + + class FileLock { + public: + explicit FileLock(const std::string& path); + + // Disable copying + FileLock(const FileLock &) = delete; + FileLock &operator=(const FileLock &) = delete; + + inline bool isLocked() const { + return locked; + } + + inline const std::string &getPath() const { + return path; + } + + ~FileLock(); + + private: + bool try_lock(); + + std::string path; + bool locked; + }; +} + +} + +#endif //SNOWFLAKECLIENT_FILELOCK_HPP diff --git a/cpp/platform/SecureStorage.cpp b/cpp/platform/SecureStorage.cpp new file mode 100644 index 0000000000..da5440cdd1 --- /dev/null +++ b/cpp/platform/SecureStorage.cpp @@ -0,0 +1,29 @@ +/* + * File: SecureStorageImpl.cpp + * Copyright (c) 2025 Snowflake Computing + */ + +#include "snowflake/SecureStorage.hpp" + +#include + +#include "../util/Sha256.hpp" + +namespace Snowflake { + +namespace Client { + +std::string SecureStorage::convertTarget(const SecureStorageKey& key) + { + std::stringstream ss; + ss << key.host << ":" << key.user << ":" << keyTypeToString(key.type); + auto plain_text = ss.str(); + auto sha = sha256(ss.str()); + return sha.get(); + } + +} + +} + + diff --git a/cpp/platform/SecureStorageApple.cpp b/cpp/platform/SecureStorageApple.cpp new file mode 100644 index 0000000000..425dd752f9 --- /dev/null +++ b/cpp/platform/SecureStorageApple.cpp @@ -0,0 +1,153 @@ +/* + * File: SecureStorageApple.cpp * + * Copyright (c) 2013-2025 Snowflake Computing + */ + +#ifdef __APPLE__ + +#include "snowflake/SecureStorage.hpp" + +#include +#include + +#include +#include + +#include "../logger/SFLogger.hpp" + +#define MAX_TOKEN_LEN 1024 +#define COLON_CHAR_LENGTH 1 +#define NULL_CHAR_LENGTH 1 + +namespace Snowflake +{ + +namespace Client +{ + + using Snowflake::Client::SFLogger; + + SecureStorageStatus SecureStorage::storeToken(const SecureStorageKey& key, const std::string& cred) + { + std::string target = convertTarget(key); + bool first_try = true; + + do { + CFTypeRef keys[4]; + CFTypeRef values[4]; + + keys[0] = kSecClass; + keys[1] = kSecAttrServer; + keys[2] = kSecAttrAccount; + keys[3] = kSecValueData; + + values[0] = kSecClassInternetPassword; + values[1] = CFStringCreateWithCString(kCFAllocatorDefault, target.c_str(), kCFStringEncodingUTF8); + values[2] = CFStringCreateWithCString(kCFAllocatorDefault, key.user.c_str(), kCFStringEncodingUTF8); + values[3] = CFStringCreateWithCString(kCFAllocatorDefault, cred.c_str(), kCFStringEncodingUTF8); + + CFDictionaryRef query = CFDictionaryCreate(kCFAllocatorDefault, (const void **) keys, (const void **) values, 4, NULL, NULL); + + OSStatus result = SecItemAdd(query, NULL); + + if (first_try && result == errSecDuplicateItem) { + CXX_LOG_DEBUG("Token already exists, updating"); + removeToken(key); + first_try = false; + continue; + } + + if (result != errSecSuccess) + { + CXX_LOG_ERROR("Failed to store secure token"); + return SecureStorageStatus::Error; + } + + CXX_LOG_DEBUG("Successfully stored secure token"); + return SecureStorageStatus::Success; + } + while(true); + } + + SecureStorageStatus SecureStorage::retrieveToken(const SecureStorageKey& key, std::string& cred) + { + std::string target = convertTarget(key); + + CFTypeRef keys[5]; + keys[0] = kSecClass; + keys[1] = kSecAttrServer; + keys[2] = kSecAttrAccount; + keys[3] = kSecReturnData; + keys[4] = kSecReturnAttributes; + + CFTypeRef values[5]; + values[0] = kSecClassInternetPassword; + values[1] = CFStringCreateWithCString(kCFAllocatorDefault, target.c_str(), kCFStringEncodingUTF8); + values[2] = CFStringCreateWithCString(kCFAllocatorDefault, key.user.c_str(), kCFStringEncodingUTF8); + values[3] = kCFBooleanTrue; + values[4] = kCFBooleanTrue; + + CFDictionaryRef query = CFDictionaryCreate(kCFAllocatorDefault, (const void**) keys, (const void**) values, 5, NULL, NULL); + CFDictionaryRef result; + OSStatus status = SecItemCopyMatching(query, reinterpret_cast(&result)); + + if (status == errSecItemNotFound) + { + cred = ""; + CXX_LOG_ERROR("Failed to retrieve secure token. Reason: token not found."); + return SecureStorageStatus::NotFound; + } + + if (status != errSecSuccess) + { + cred = ""; + CXX_LOG_ERROR("Failed to retrieve secure token"); + return SecureStorageStatus::Error; + } + + CXX_LOG_DEBUG("Successfully retrieved token"); + + auto val = reinterpret_cast(CFDictionaryGetValue(result, kSecValueData)); + cred = std::string(reinterpret_cast(CFDataGetBytePtr(val)), CFDataGetLength(val)); + return SecureStorageStatus::Success; + } + + SecureStorageStatus SecureStorage::removeToken(const SecureStorageKey& key) + { + CFTypeRef keys[4]; + CFTypeRef values[4]; + std::string target = convertTarget(key); + + keys[0] = kSecClass; + keys[1] = kSecAttrServer; + keys[2] = kSecAttrAccount; + keys[3] = kSecMatchLimit; + + values[0] = kSecClassInternetPassword; + values[1] = CFStringCreateWithCString(kCFAllocatorDefault, target.c_str(), kCFStringEncodingUTF8); + values[2] = CFStringCreateWithCString(kCFAllocatorDefault, key.user.c_str(), kCFStringEncodingUTF8); + values[3] = kSecMatchLimitOne; + + CFDictionaryRef extract_query = CFDictionaryCreate(kCFAllocatorDefault, (const void **)keys, + (const void **)values, 4, NULL, NULL); + OSStatus result = SecItemDelete(extract_query); + switch (result) + { + case errSecSuccess: + CXX_LOG_DEBUG("Successfully removed secure token"); + break; + case errSecItemNotFound: + CXX_LOG_WARN("Failed to remove token: not found.") + break; + default: + CXX_LOG_ERROR("Failed to remove secure token, %d", result); + return SecureStorageStatus::Error; + } + + return SecureStorageStatus::Success; + } +} + +} + +#endif diff --git a/cpp/platform/SecureStorageLinux.cpp b/cpp/platform/SecureStorageLinux.cpp new file mode 100644 index 0000000000..1f9aeccfbf --- /dev/null +++ b/cpp/platform/SecureStorageLinux.cpp @@ -0,0 +1,138 @@ +/* + * File: SecureStorageLinux.cpp * + * Copyright (c) 2013-2020 Snowflake Computing + */ + +#ifdef __linux__ + +#include "snowflake/SecureStorage.hpp" + +#include +#include +#include + +#include + +#include "../lib/CacheFile.hpp" +#include "../logger/SFLogger.hpp" +#include "../platform/FileLock.hpp" + + +namespace Snowflake { + +namespace Client { + + SecureStorageStatus SecureStorage::storeToken(const SecureStorageKey& key, const std::string& token) + { + auto pathOpt = getCredentialFilePath(); + if (!pathOpt) + { + CXX_LOG_ERROR("Cannot get path to credential file.") + return SecureStorageStatus::Error; + } + + const std::string path = pathOpt.value(); + + FileLock lock(path); + if (!lock.isLocked()) + { + CXX_LOG_ERROR("Failed to save token. Could not acquire file lock(path=%s)", lock.getPath().c_str()); + return SecureStorageStatus::Error; + } + + picojson::value contents; + std::string error = readFile(path, contents); + if (!error.empty()) { + CXX_LOG_WARN("Failed to read file(path=%s). Error: %s", path.c_str(), error.c_str()) + contents = picojson::value(picojson::object()); + } + + cacheFileUpdate(contents, convertTarget(key), token); + + error = writeFile(path, contents); + if (!error.empty()) { + CXX_LOG_ERROR("Failed to write file(path=%s). Error: %s", path.c_str(), error.c_str()); + return SecureStorageStatus::Error; + } + + CXX_LOG_DEBUG("Successfully stored token."); + return SecureStorageStatus::Success; + } + + SecureStorageStatus SecureStorage::retrieveToken(const SecureStorageKey& key, std::string& token) + { + auto pathOpt = getCredentialFilePath(); + if (!pathOpt) + { + CXX_LOG_ERROR("Cannot get path to credential file.") + return SecureStorageStatus::Error; + } + const std::string path = pathOpt.value(); + + FileLock lock(path); + if (!lock.isLocked()) + { + CXX_LOG_ERROR("Failed to get token. Could not acquire file lock(path=%s)", lock.getPath().c_str()); + return SecureStorageStatus::Error; + } + + picojson::value contents; + + std::string error = readFile(path, contents); + if (!error.empty()) { + CXX_LOG_WARN("Failed to read file(path=%s). Error: %s", path.c_str(), error.c_str()); + contents = picojson::value(picojson::object()); + } + + auto tokenOpt = cacheFileGet(contents, convertTarget(key)); + if (!tokenOpt) + { + return SecureStorageStatus::NotFound; + } + token = tokenOpt.get(); + + CXX_LOG_DEBUG("Successfully retrieved token."); + return SecureStorageStatus::Success; + } + + SecureStorageStatus SecureStorage::removeToken(const SecureStorageKey& key) + { + auto pathOpt = getCredentialFilePath(); + if (!pathOpt) + { + CXX_LOG_ERROR("Cannot get path to credential file.") + return SecureStorageStatus::Error; + } + const std::string path = pathOpt.value(); + + FileLock lock(path); + if (!lock.isLocked()) + { + CXX_LOG_ERROR("Failed to delete token. Could not acquire file lock(path=%s)", lock.getPath().c_str()); + return SecureStorageStatus::Error; + } + + picojson::value contents; + std::string error = readFile(path, contents); + if (!error.empty()) + { + CXX_LOG_WARN("Failed to read file(path=%s). Error: %s", path.c_str(), error.c_str()) + contents = picojson::value(picojson::object()); + } + + cacheFileRemove(contents, convertTarget(key)); + + error = writeFile(path, contents); + if (!error.empty()) { + CXX_LOG_ERROR("Failed to write file(path=%s). Error: %s", path.c_str(), error.c_str()); + return SecureStorageStatus::Error; + } + + CXX_LOG_DEBUG("Successfully removed token."); + return SecureStorageStatus::Success; + } +} + +} + +#endif diff --git a/cpp/platform/SecureStorageWin.cpp b/cpp/platform/SecureStorageWin.cpp new file mode 100644 index 0000000000..0a85811678 --- /dev/null +++ b/cpp/platform/SecureStorageWin.cpp @@ -0,0 +1,105 @@ +/* + * File: SecureStorageWin.cpp * + * Copyright (c) 2013-2020 Snowflake Computing + */ + +#ifdef _WIN32 + +#include "snowflake/SecureStorage.hpp" + +#include +#include +#include +#include + +#include +#include + +#include "../logger/SFLogger.hpp" + +#define MAX_TOKEN_LEN 1024 +#define DRIVER_NAME "SNOWFLAKE_ODBC_DRIVER" +#define COLON_CHAR_LENGTH 1 +#define NULL_CHAR_LENGTH 1 + +namespace Snowflake +{ + +namespace Client +{ + + SecureStorageStatus SecureStorage::storeToken(const SecureStorageKey& key, const std::string& token) + { + std::string target = convertTarget(key); + std::wstring wide_target = std::wstring(target.begin(), target.end()); + + CREDENTIALW creds = { 0 }; + creds.TargetName = (LPWSTR)wide_target.data(); + creds.CredentialBlobSize = token.size(); + creds.CredentialBlob = (LPBYTE)token.data(); + creds.Persist = CRED_PERSIST_LOCAL_MACHINE; + creds.Type = CRED_TYPE_GENERIC; + + if (!CredWriteW(&creds, 0)) + { + CXX_LOG_ERROR("Failed to store token."); + return SecureStorageStatus::Error; + } + else + { + CXX_LOG_DEBUG("Successfully stored token"); + return SecureStorageStatus::Success; + } + } + + SecureStorageStatus SecureStorage::retrieveToken(const SecureStorageKey& key, std::string& token) + { + std::string target = convertTarget(key); + std::wstring wide_target = std::wstring(target.begin(), target.end()); + PCREDENTIALW retcreds = nullptr; + + if (!CredReadW(wide_target.data(), CRED_TYPE_GENERIC, 0, &retcreds)) + { + CXX_LOG_ERROR("Failed to read target or could not find it"); + return SecureStorageStatus::Error; + } + + CXX_LOG_DEBUG("Read the token now copying it"); + + DWORD blobSize = retcreds->CredentialBlobSize; + if (!blobSize) + { + return SecureStorageStatus::Error; + } + + token = ""; + std::copy( + retcreds->CredentialBlob, + retcreds->CredentialBlob + blobSize, + std::back_insert_iterator(token) + ); + + CXX_LOG_DEBUG("Successfully retrieved token."); + + CredFree(retcreds); + return SecureStorageStatus::Success; + } + + SecureStorageStatus SecureStorage::removeToken(const SecureStorageKey& key) + { + std::string target = convertTarget(key); + std::wstring wide_target = std::wstring(target.begin(), target.end()); + + if (!CredDeleteW(wide_target.data(), CRED_TYPE_GENERIC, 0)) + { + return SecureStorageStatus::Error; + } + + CXX_LOG_DEBUG("Successfully removed id token"); + return SecureStorageStatus::Success; + } +} + +} + +#endif \ No newline at end of file diff --git a/cpp/platform/secure_storage.cpp b/cpp/platform/secure_storage.cpp new file mode 100644 index 0000000000..e3d2cc7163 --- /dev/null +++ b/cpp/platform/secure_storage.cpp @@ -0,0 +1,59 @@ +/* + * File: secure_storage.cpp * + * Copyright (c) 2025 Snowflake Computing + */ + +#include +#include + +#include "snowflake/secure_storage.h" +#include "snowflake/SecureStorage.hpp" + +#ifdef __cplusplus +extern "C" { + using namespace Snowflake::Client; +#endif + +secure_storage_ptr secure_storage_init() { + return new Snowflake::Client::SecureStorage(); +} + +char* secure_storage_get_credential(secure_storage_ptr tc, const char* host, const char* user, SecureStorageKeyType type) +{ + Snowflake::Client::SecureStorageKey key = { host, user, type }; + std::string token; + auto status = reinterpret_cast(tc)->retrieveToken(key, token); + if (status != SecureStorageStatus::Success) + { + return nullptr; + } + size_t result_size = token.size() + 1; + char* result = new char[result_size]; + strncpy(result, token.c_str(), result_size); + return result; +} + +void secure_storage_free_credential(char* cred) { + delete[] cred; +} + +bool secure_storage_save_credential(secure_storage_ptr tc, const char* host, const char* user, SecureStorageKeyType type, const char *cred) +{ + Snowflake::Client::SecureStorageKey key = { host, user, type }; + return reinterpret_cast(tc)->storeToken(key, std::string(cred)) == SecureStorageStatus::Success; +} + +bool secure_storage_remove_credential(secure_storage_ptr tc, const char* host, const char* user, SecureStorageKeyType type) +{ + Snowflake::Client::SecureStorageKey key = { host, user, type }; + return reinterpret_cast(tc)->removeToken(key) == SecureStorageStatus::Success; +} + +void secure_storage_term(secure_storage_ptr tc) { + delete reinterpret_cast(tc); +} + +#ifdef __cplusplus +}; +#endif + diff --git a/cpp/util/Sha256.cpp b/cpp/util/Sha256.cpp new file mode 100644 index 0000000000..675c5742a0 --- /dev/null +++ b/cpp/util/Sha256.cpp @@ -0,0 +1,49 @@ + +/* + * File: Sha256.cpp + * Copyright (c) 2025 Snowflake Computing + */ + +#include "Sha256.hpp" + +#include +#include +#include + +#include "boost/optional.hpp" +#include "openssl/sha.h" +#include "openssl/types.h" +#include "openssl/evp.h" + +namespace Snowflake { + + namespace Client { + boost::optional sha256(const std::string &str) { + auto mdctx = std::unique_ptr>(EVP_MD_CTX_new(), EVP_MD_CTX_free); + if (mdctx.get() == nullptr) { + return {}; + } + + if (EVP_DigestInit_ex(mdctx.get(), EVP_sha256(), nullptr) != 1) { + return {}; + } + + if (EVP_DigestUpdate(mdctx.get(), str.c_str(), str.length()) != 1) { + return {}; + } + + std::vector buf(EVP_MD_size(EVP_sha256())); + unsigned int size = 0; + if (EVP_DigestFinal_ex(mdctx.get(), buf.data(), &size) != 1) { + return {}; + } + + std::stringstream ss; + for (short b: buf) { + ss << std::hex << std::setw(2) << std::setfill('0') << b; + } + return ss.str(); + } + } + +} diff --git a/cpp/util/Sha256.hpp b/cpp/util/Sha256.hpp new file mode 100644 index 0000000000..ebdaf0f628 --- /dev/null +++ b/cpp/util/Sha256.hpp @@ -0,0 +1,21 @@ + +/* + * File: Sha256.hpp + * Copyright (c) 2025 Snowflake Computing + */ + +#ifndef SNOWFLAKECLIENT_SHA256_HPP +#define SNOWFLAKECLIENT_SHA256_HPP + +#include +#include + +#include + +namespace Snowflake { + namespace Client { + boost::optional sha256(const std::string &input); + } +} + +#endif //SNOWFLAKECLIENT_SHA256_HPP diff --git a/include/snowflake/SecureStorage.hpp b/include/snowflake/SecureStorage.hpp new file mode 100644 index 0000000000..69f747e329 --- /dev/null +++ b/include/snowflake/SecureStorage.hpp @@ -0,0 +1,95 @@ +/* + * File: SecureStorageApple.hpp * + * Copyright (c) 2013-2020 Snowflake Computing + */ + +#ifndef PROJECT_SECURESTORAGE_HPP +#define PROJECT_SECURESTORAGE_HPP + +#include + +#include "snowflake/secure_storage.h" + +namespace Snowflake { + +namespace Client { + enum class SecureStorageStatus + { + NotFound, + Error, + Success, + Unsupported + }; + + inline std::string keyTypeToString(SecureStorageKeyType type) { + switch (type) { + case SecureStorageKeyType::MFA_TOKEN: + return "MFA_TOKEN"; + case SecureStorageKeyType::SSO_TOKEN: + return "SSO_TOKEN"; + case SecureStorageKeyType::OAUTH_REFRESH_TOKEN: + return "OAUTH_REFRESH_TOKEN"; + case SecureStorageKeyType::OAUTH_ACCESS_TOKEN: + return "OAUTH_ACCESS_TOKEN"; + default: + return "UNKNOWN"; + } + } + struct SecureStorageKey { + std::string host; + std::string user; + SecureStorageKeyType type; + }; + + /** + * Class SecureStorage + */ + + class SecureStorage + { + + public: + static std::string convertTarget(const SecureStorageKey& key); + + /** + * storeToken + * + * API to secure store credential + * + * @param key - credential key + * @param cred - credential to be secured + * + * @return ERROR / SUCCESS + */ + SecureStorageStatus storeToken(const SecureStorageKey& key, + const std::string& cred); + + /** + * retrieveToken + * + * API to retrieve credential + * + * @param key - credential key + * @param cred - on succcess, retrieved credential will stored here + * @return NOT_FOUND, ERROR, SUCCESS + */ + SecureStorageStatus retrieveToken(const SecureStorageKey& key, + std::string& cred); + + /** + * remove + * + * API to remove a credential. + * + * @param key - credenetial key + * + * @return ERROR / SUCCESS + */ + SecureStorageStatus removeToken(const SecureStorageKey& key); + }; + +} + +} + +#endif //PROJECT_SECURESTORAGE_H diff --git a/include/snowflake/client.h b/include/snowflake/client.h index 02c0677a51..5a11be9d9a 100644 --- a/include/snowflake/client.h +++ b/include/snowflake/client.h @@ -14,6 +14,7 @@ extern "C" { #include "platform.h" #include "version.h" #include "logger.h" +#include "secure_storage.h" /** * API Name @@ -269,6 +270,7 @@ typedef enum SF_ATTRIBUTE { SF_CON_DISABLE_QUERY_CONTEXT_CACHE, SF_CON_INCLUDE_RETRY_REASON, SF_CON_RETRY_TIMEOUT, + SF_CON_CLIENT_REQUEST_MFA_TOKEN, SF_CON_MAX_RETRY, SF_CON_MAX_VARCHAR_SIZE, SF_CON_MAX_BINARY_SIZE, @@ -348,6 +350,7 @@ typedef struct SF_CONNECT { sf_bool insecure_mode; sf_bool ocsp_fail_open; sf_bool autocommit; + sf_bool client_request_mfa_token; char *timezone; char *service_name; char *query_result_format; @@ -385,6 +388,9 @@ typedef struct SF_CONNECT { // the pointer of qcc instance void * qcc; + // MFA Token Cache + secure_storage_ptr token_cache; + // whether to include retry reason in retry for query request sf_bool include_retry_reason; diff --git a/include/snowflake/secure_storage.h b/include/snowflake/secure_storage.h new file mode 100644 index 0000000000..ffffa71d06 --- /dev/null +++ b/include/snowflake/secure_storage.h @@ -0,0 +1,36 @@ +/* + * File: secure_storage.h * + * Copyright (c) 2025 Snowflake Computing + */ + + +#ifndef SNOWFLAKECLIENT_SECURE_STORAGE_H +#define SNOWFLAKECLIENT_SECURE_STORAGE_H + +#include + +typedef void* secure_storage_ptr; + +typedef enum { + MFA_TOKEN, + SSO_TOKEN, + OAUTH_REFRESH_TOKEN, + OAUTH_ACCESS_TOKEN +} SecureStorageKeyType; + +#ifdef __cplusplus +extern "C" { +#endif + +secure_storage_ptr secure_storage_init(); +char* secure_storage_get_credential(secure_storage_ptr tc, const char* host, const char* user, SecureStorageKeyType type); +void secure_storage_free_credential(char* cred); +bool secure_storage_save_credential(secure_storage_ptr tc, const char* host, const char* user, SecureStorageKeyType type, const char *cred); +bool secure_storage_remove_credential(secure_storage_ptr tc, const char* host, const char* user, SecureStorageKeyType type); +void secure_storage_term(secure_storage_ptr tc); + +#ifdef __cplusplus +}; +#endif + +#endif //SNOWFLAKECLIENT_SECURE_STORAGE_H diff --git a/lib/client.c b/lib/client.c index f58e983a4b..704894c7a9 100644 --- a/lib/client.c +++ b/lib/client.c @@ -747,6 +747,11 @@ SF_CONNECT *STDCALL snowflake_init() { sf->insecure_mode = SF_BOOLEAN_FALSE; sf->ocsp_fail_open = SF_BOOLEAN_TRUE; sf->autocommit = SF_BOOLEAN_TRUE; +#if defined(__APPLE__) || defined(_WIN32) + sf->client_request_mfa_token = SF_BOOLEAN_TRUE; +#else + sf->client_request_mfa_token = SF_BOOLEAN_FALSE; +#endif sf->qcc_disable = SF_BOOLEAN_FALSE; sf->include_retry_reason = SF_BOOLEAN_TRUE; sf->timezone = NULL; @@ -843,7 +848,10 @@ SF_STATUS STDCALL snowflake_term(SF_CONNECT *sf) { } auth_terminate(sf); - + // SNOW-715510: TODO Enable token cache +/* + cred_cache_term(sf->token_cache); +*/ qcc_terminate(sf); _mutex_term(&sf->mutex_sequence_counter); @@ -1023,6 +1031,14 @@ SF_STATUS STDCALL snowflake_connect(SF_CONNECT *sf) { goto cleanup; } + // SNOW-715510: TODO Enable token cache +/* + char* mfa_token = NULL; + if (json_copy_string(&mfa_token, data, "mfaToken") == SF_JSON_ERROR_NONE && sf->token_cache) { + cred_cache_save_credential(sf->token_cache, sf->host, sf->user, MFA_TOKEN, mfa_token); + } +*/ + _mutex_lock(&sf->mutex_parameters); ret = _set_parameters_session_info(sf, data); qcc_deserialize(sf, snowflake_cJSON_GetObjectItem(data, SF_QCC_RSP_KEY)); @@ -1276,6 +1292,9 @@ SF_STATUS STDCALL snowflake_set_attribute( case SF_CON_GET_THRESHOLD: sf->get_threshold = value ? *((int64 *)value) : SF_DEFAULT_GET_THRESHOLD; break; + case SF_CON_CLIENT_REQUEST_MFA_TOKEN: + sf->client_request_mfa_token = value ? *((sf_bool *) value): SF_BOOLEAN_TRUE; + break; case SF_CON_STAGE_BIND_THRESHOLD: if (value) { diff --git a/lib/connection.c b/lib/connection.c index 9b9861ba26..7f757cb575 100644 --- a/lib/connection.c +++ b/lib/connection.c @@ -25,7 +25,6 @@ static uint32 uimax(uint32 a, uint32 b) { return (a > b) ? a : b; } - cJSON *STDCALL create_auth_json_body(SF_CONNECT *sf, const char *application, const char *int_app_name, @@ -57,6 +56,7 @@ cJSON *STDCALL create_auth_json_body(SF_CONNECT *sf, autocommit == SF_BOOLEAN_TRUE ? SF_BOOLEAN_INTERNAL_TRUE_STR : SF_BOOLEAN_INTERNAL_FALSE_STR); + snowflake_cJSON_AddStringToObject(session_parameters, "TIMEZONE", timezone); //Create Request Data JSON blob @@ -86,10 +86,31 @@ cJSON *STDCALL create_auth_json_body(SF_CONNECT *sf, { snowflake_cJSON_AddStringToObject(data, "EXT_AUTHN_DUO_METHOD", "push"); } + + if (sf->client_request_mfa_token) { + snowflake_cJSON_AddBoolToObject( + session_parameters, + "CLIENT_REQUEST_MFA_TOKEN", + 1 + ); + + // SNOW-715510: TODO Enable token_cache +/* + if (sf->token_cache == NULL) { + sf->token_cache = cred_cache_init(); + } + + char* token = cred_cache_get_credential(sf->token_cache, sf->host, sf->user, MFA_TOKEN); + if (token != NULL) + { + snowflake_cJSON_AddStringToObject(data, "TOKEN", token); + cred_cache_free_credential(token); + } +*/ + } } snowflake_cJSON_AddItemToObject(data, "CLIENT_ENVIRONMENT", client_env); - snowflake_cJSON_AddItemToObject(data, "SESSION_PARAMETERS", - session_parameters); + snowflake_cJSON_AddItemToObject(data, "SESSION_PARAMETERS", session_parameters); //Create body body = snowflake_cJSON_CreateObject(); diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index f5c9cfb68d..dc3a9178e2 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -77,7 +77,9 @@ SET(TESTS_CXX test_unit_snowflake_types_to_string test_unit_azure_client test_unit_query_context_cache - test_unit_sfurl) + test_unit_sfurl + test_unit_secure_storage +) SET(TESTS_PUTGET test_include_aws @@ -91,7 +93,9 @@ SET(TESTS_PERF SET(TESTS_MOCK test_mock_service_name - test_mock_session_gone) + test_mock_session_gone + test_mock_mfa_token_caching +) set(SOURCE_UTILS utils/test_setup.c @@ -278,6 +282,14 @@ if (MOCK) --error-exitcode=1 --suppressions=${VALGRIND_SUPPRESSION} ./${T}) + set(MOCK_TEST_DATA + expected_first_mfa_request.json + expected_second_mfa_request.json + first_mfa_response.json + ) + FOREACH (MTD ${MOCK_TEST_DATA}) + configure_file("mock/test_data/${MTD}" "mock/test_data/${MTD}" COPYONLY) + ENDFOREACH () ENDFOREACH () else() FOREACH (T ${TESTS_C}) diff --git a/tests/mock/test_data/expected_first_mfa_request.json b/tests/mock/test_data/expected_first_mfa_request.json new file mode 100644 index 0000000000..a48a99556e --- /dev/null +++ b/tests/mock/test_data/expected_first_mfa_request.json @@ -0,0 +1,19 @@ +{ + "data": { + "CLIENT_APP_ID": "C API", + "CLIENT_APP_VERSION": "0.0.0", + "ACCOUNT_NAME": "account", + "LOGIN_NAME": "user", + "PASSWORD": "passwd", + "EXT_AUTHN_DUO_METHOD": "passcode", + "PASSCODE": "passcode", + "CLIENT_ENVIRONMENT": { + "OS": "Linux", + "OS_VERSION": "0" + }, + "SESSION_PARAMETERS": { + "AUTOCOMMIT": "TRUE", + "CLIENT_REQUEST_MFA_TOKEN": true + } + } +} \ No newline at end of file diff --git a/tests/mock/test_data/expected_second_mfa_request.json b/tests/mock/test_data/expected_second_mfa_request.json new file mode 100644 index 0000000000..d3bf53ea5b --- /dev/null +++ b/tests/mock/test_data/expected_second_mfa_request.json @@ -0,0 +1,20 @@ +{ + "data": { + "CLIENT_APP_ID": "C API", + "CLIENT_APP_VERSION": "0.0.0", + "ACCOUNT_NAME": "account", + "LOGIN_NAME": "user", + "PASSWORD": "passwd", + "EXT_AUTHN_DUO_METHOD": "passcode", + "PASSCODE": "passcode", + "TOKEN": "MFA_TOKEN", + "CLIENT_ENVIRONMENT": { + "OS": "Linux", + "OS_VERSION": "0" + }, + "SESSION_PARAMETERS": { + "AUTOCOMMIT": "TRUE", + "CLIENT_REQUEST_MFA_TOKEN": true + } + } +} \ No newline at end of file diff --git a/tests/mock/test_data/first_mfa_response.json b/tests/mock/test_data/first_mfa_response.json new file mode 100644 index 0000000000..35c1403767 --- /dev/null +++ b/tests/mock/test_data/first_mfa_response.json @@ -0,0 +1,16 @@ +{ + "data" : { + "token" : "TOKEN", + "masterToken" : "MASTER_TOKEN", + "mfaToken": "MFA_TOKEN", + "sessionInfo": { + "databaseName": "testdb", + "schemaName": "testschema", + "roleName": "testRole", + "warehouseName": "wh" + } + }, + "message" : null, + "code" : null, + "success" : true +} \ No newline at end of file diff --git a/tests/mock/test_mock_mfa_token_caching.c b/tests/mock/test_mock_mfa_token_caching.c new file mode 100644 index 0000000000..1c4ef3de94 --- /dev/null +++ b/tests/mock/test_mock_mfa_token_caching.c @@ -0,0 +1,136 @@ + +#include +#include "../utils/test_setup.h" +#include "../utils/mock_setup.h" +#include + +const size_t MAX_PATH_LEN = 1024; + +char* load_data(const char* filename) { + char path[MAX_PATH_LEN]; + snprintf(path, MAX_PATH_LEN, "./mock/test_data/%s", filename); + FILE *fp = fopen(path, "r"); + if (fp == NULL) { + log_error("Failed to open the file %s", path); + return NULL; + } + fseek(fp, 0, SEEK_END); + size_t fsize = ftell(fp); + fseek(fp, 0, SEEK_SET); + char *string = malloc(fsize + 1); + fread(string, fsize, 1, fp); + fclose(fp); + return string; +} + +char* first_mfa_request = NULL; +char* first_mfa_response = NULL; +char* second_mfa_request = NULL; + +void setup_mfa_connect_initial_request_mock() { + expect_string(__wrap_http_perform, url, "https://host:443/session/v1/login-request"); + expect_string(__wrap_http_perform, body, first_mfa_request); + expect_string(__wrap_http_perform, request_type_str, "POST"); + expect_value(__wrap_http_perform, header->header_service_name, NULL); + expect_value(__wrap_http_perform, header->header_token, NULL); + will_return(__wrap_http_perform, cast_ptr_to_largest_integral_type(first_mfa_response)); +} + +void setup_mfa_term_initial_request_mock() { + expect_string(__wrap_http_perform, url, "https://host:443/session"); + expect_value(__wrap_http_perform, body, NULL); + expect_string(__wrap_http_perform, request_type_str, "POST"); + expect_value(__wrap_http_perform, header->header_service_name, NULL); + expect_string(__wrap_http_perform, header->header_token, "Authorization: Snowflake Token=\"TOKEN\""); + will_return(__wrap_http_perform, "{}"); +} + +void setup_mfa_connect_cached_mfa_request_mock() { + expect_string(__wrap_http_perform, url, "https://host:443/session/v1/login-request"); + expect_string(__wrap_http_perform, body, second_mfa_request); + expect_string(__wrap_http_perform, request_type_str, "POST"); + expect_value(__wrap_http_perform, header->header_service_name, NULL); + expect_value(__wrap_http_perform, header->header_token, NULL); + will_return(__wrap_http_perform, cast_ptr_to_largest_integral_type(first_mfa_response)); +} + +void setup_mfa_term_cached_mfa_request_mock() { + expect_string(__wrap_http_perform, url, "https://host:443/session"); + expect_value(__wrap_http_perform, body, NULL); + expect_string(__wrap_http_perform, request_type_str, "POST"); + expect_value(__wrap_http_perform, header->header_service_name, NULL); + expect_string(__wrap_http_perform, header->header_token, "Authorization: Snowflake Token=\"TOKEN\""); + will_return(__wrap_http_perform, "{}"); +} + +#define ACCOUNT "account" +#define HOST "host" +#define USER "user" + +SF_CONNECT* sf_connect_init() { + SF_CONNECT* sf = snowflake_init(); + snowflake_set_attribute(sf, SF_CON_ACCOUNT,ACCOUNT); + snowflake_set_attribute(sf, SF_CON_HOST, HOST); + snowflake_set_attribute(sf, SF_CON_USER, USER); + snowflake_set_attribute(sf, SF_CON_PORT, "443"); + snowflake_set_attribute(sf, SF_CON_PROTOCOL, "https"); + return sf; +} + +void test_mfa_token_caching(void **unused) { + sf_setenv("SF_TEMPORARY_CREDENTIAL_CACHE_DIR", "."); + secure_storage_ptr ss = secure_storage_init(); + secure_storage_remove_credential(ss, HOST, USER, MFA_TOKEN); + + { + SF_CONNECT *sf = sf_connect_init(); + snowflake_set_attribute(sf, SF_CON_PASSWORD, "passwd"); + snowflake_set_attribute(sf, SF_CON_PASSCODE, "passcode"); + sf_bool client_request_mfa_token = 1; + snowflake_set_attribute(sf, SF_CON_CLIENT_REQUEST_MFA_TOKEN, &client_request_mfa_token); + setup_mfa_connect_initial_request_mock(); + SF_STATUS status = snowflake_connect(sf); + + if (status != SF_STATUS_SUCCESS) { + dump_error(&(sf->error)); + } + + assert_int_equal(status, SF_STATUS_SUCCESS); + setup_mfa_term_initial_request_mock(); + snowflake_term(sf); + } + + { + SF_CONNECT *sf = sf_connect_init(); + snowflake_set_attribute(sf, SF_CON_PASSWORD, "passwd"); + snowflake_set_attribute(sf, SF_CON_PASSCODE, "passcode"); + sf_bool client_request_mfa_token = 1; + snowflake_set_attribute(sf, SF_CON_CLIENT_REQUEST_MFA_TOKEN, &client_request_mfa_token); + setup_mfa_connect_cached_mfa_request_mock(); + SF_STATUS status = snowflake_connect(sf); + + if (status != SF_STATUS_SUCCESS) { + dump_error(&(sf->error)); + } + + assert_int_equal(status, SF_STATUS_SUCCESS); + setup_mfa_term_cached_mfa_request_mock(); + snowflake_term(sf); + } +} + + int main(void) { + first_mfa_request = load_data("expected_first_mfa_request.json"); + first_mfa_response = load_data("first_mfa_response.json"); + second_mfa_request = load_data("expected_second_mfa_request.json"); + initialize_test(SF_BOOLEAN_FALSE); + const struct CMUnitTest tests[] = { + cmocka_unit_test(test_mfa_token_caching), + }; + int ret = cmocka_run_group_tests(tests, NULL, NULL); + snowflake_global_term(); + free(first_mfa_request); + free(first_mfa_response); + free(second_mfa_request); + return ret; + } diff --git a/tests/test_manual_connect.c b/tests/test_manual_connect.c index 65ebf5a6c2..d28140102d 100644 --- a/tests/test_manual_connect.c +++ b/tests/test_manual_connect.c @@ -7,8 +7,9 @@ /** * Test connection with OAuth authentication. */ -void test_oauth_connect(void **unused) +void test_oauth_connect(void **unused) { + SF_UNUSED(unused); SF_CONNECT *sf = snowflake_init(); snowflake_set_attribute(sf, SF_CON_ACCOUNT, getenv("SNOWFLAKE_TEST_ACCOUNT")); @@ -49,6 +50,7 @@ void test_oauth_connect(void **unused) void test_mfa_connect_with_duo_push(void** unused) { + SF_UNUSED(unused); SF_CONNECT* sf = snowflake_init(); snowflake_set_attribute(sf, SF_CON_ACCOUNT, getenv("SNOWFLAKE_TEST_ACCOUNT")); @@ -83,6 +85,7 @@ void test_mfa_connect_with_duo_push(void** unused) void test_mfa_connect_with_duo_passcode(void** unused) { + SF_UNUSED(unused); SF_CONNECT* sf = snowflake_init(); snowflake_set_attribute(sf, SF_CON_ACCOUNT, getenv("SNOWFLAKE_TEST_ACCOUNT")); @@ -125,6 +128,7 @@ void test_mfa_connect_with_duo_passcode(void** unused) void test_mfa_connect_with_duo_passcodeInPassword(void** unused) { + SF_UNUSED(unused); SF_CONNECT* sf = snowflake_init(); snowflake_set_attribute(sf, SF_CON_ACCOUNT, getenv("SNOWFLAKE_TEST_ACCOUNT")); @@ -160,12 +164,60 @@ void test_mfa_connect_with_duo_passcodeInPassword(void** unused) snowflake_term(sf); } -void test_none(void** unused) {} +void test_mfa_connect_with_mfa_cache(void** unused) +{ + SF_UNUSED(unused); + /* + * Should trigger mfa push notification at most once. + * Make sure ALLOW_CLIENT_MFA_CACHING is set to true + * For more details refer to: https://docs.snowflake.com/en/user-guide/security-mfa#using-mfa-token-caching-to-minimize-the-number-of-prompts-during-authentication-optional + */ + for (int i = 0; i < 2; i++) { + SF_CONNECT *sf = snowflake_init(); + snowflake_set_attribute(sf, SF_CON_APPLICATION_NAME, "ODBC"); + snowflake_set_attribute(sf, SF_CON_APPLICATION_VERSION, "2.30.0"); + snowflake_set_attribute(sf, SF_CON_ACCOUNT, + getenv("SNOWFLAKE_TEST_ACCOUNT")); + snowflake_set_attribute(sf, SF_CON_USER, getenv("SNOWFLAKE_TEST_USER")); + snowflake_set_attribute(sf, SF_CON_PASSWORD, + getenv("SNOWFLAKE_TEST_PASSWORD")); + char *host, *port, *protocol, *passcode; + host = getenv("SNOWFLAKE_TEST_HOST"); + if (host) { + snowflake_set_attribute(sf, SF_CON_HOST, host); + } + port = getenv("SNOWFLAKE_TEST_PORT"); + if (port) { + snowflake_set_attribute(sf, SF_CON_PORT, port); + } + protocol = getenv("SNOWFLAKE_TEST_PROTOCOL"); + if (protocol) { + snowflake_set_attribute(sf, SF_CON_PROTOCOL, protocol); + } + passcode = getenv("SNOWFLAKE_TEST_PASSCODE"); + if (passcode) { + snowflake_set_attribute(sf, SF_CON_PASSCODE, passcode); + } else { + dump_error(&(sf->error)); + } + + SF_STATUS status = snowflake_connect(sf); + if (status != SF_STATUS_SUCCESS) { + dump_error(&(sf->error)); + } + assert_int_equal(status, SF_STATUS_SUCCESS); + snowflake_term(sf); + } +} + +void test_none(void** unused) { + SF_UNUSED(unused); +} int main(void) { - initialize_test(SF_BOOLEAN_FALSE); + initialize_test(SF_BOOLEAN_TRUE); struct CMUnitTest tests[1] = { cmocka_unit_test(test_none) }; @@ -187,6 +239,10 @@ int main(void) tests[0].name = "test_mfa_connect_with_duo_passcodeInPassword"; tests[0].test_func = test_mfa_connect_with_duo_passcodeInPassword; } + else if (strcmp(manual_test, "test_mfa_connect_with_mfa_cache") == 0) { + tests[0].name = "test_mfa_connect_with_mfa_cache"; + tests[0].test_func = test_mfa_connect_with_mfa_cache; + } else { printf("No matching test found for: %s\n", manual_test); } diff --git a/tests/test_unit_secure_storage.cpp b/tests/test_unit_secure_storage.cpp new file mode 100644 index 0000000000..e9825897c8 --- /dev/null +++ b/tests/test_unit_secure_storage.cpp @@ -0,0 +1,308 @@ +/* + * File: test_unit_secure_storage.cpp + * Copyright (c) 2025 Snowflake Computing + */ + +#include + +#include +#include + +#include "snowflake/SecureStorage.hpp" +#include "lib/CacheFile.hpp" + +#include "utils/test_setup.h" + +class EnvOverride +{ +public: + EnvOverride(const EnvOverride&) = delete; + void operator=(const EnvOverride&) = delete; + EnvOverride(std::string envVar_, const boost::optional& newValue_) + : envVar{std::move(envVar_)} + , oldValue{} + { + char buf[BUF_SIZE]; + char* oldValueCstr = sf_getenv_s(envVar.c_str(), buf, BUF_SIZE); + if (oldValueCstr != nullptr) + { + oldValue = oldValueCstr; + } + if (newValue_) + { + sf_setenv(envVar.c_str(), newValue_.get().c_str()); + } + else + { + sf_unsetenv(envVar.c_str()); + } + } + + EnvOverride(std::string envVar_, const std::string& newValue_) + : EnvOverride(std::move(envVar_), boost::make_optional(newValue_)) {} + + ~EnvOverride() + { + if (oldValue) + { + sf_setenv(envVar.c_str(), oldValue->c_str()); + } + else + { + sf_unsetenv(envVar.c_str()); + } + } + +private: + constexpr static size_t BUF_SIZE = 1024; + std::string envVar; + boost::optional oldValue; +}; + +constexpr const char* CACHE_FILENAME = "credential_cache_v1.json"; + +using namespace Snowflake::Client; + +void remove_file_if_exists(const std::string& path) +{ + boost::system::error_code ec; + boost::filesystem::remove_all(path, ec); + assert_true(!ec); +} + +void assert_permissions(const std::string& path, boost::filesystem::perms permissions) +{ + boost::filesystem::file_status s = boost::filesystem::status( path ); + assert_true((s.permissions() & boost::filesystem::perms_mask) == permissions); +} + +void test_secure_storage_simple(void **) +{ + remove_file_if_exists(CACHE_FILENAME); + EnvOverride override("SF_TEMPORARY_CREDENTIAL_CACHE_DIR", "."); + SecureStorage ss; + SecureStorageKey key { "host", "user", SecureStorageKeyType::MFA_TOKEN }; + + std::string token = "example_token"; + std::string retrievedToken; + assert_true(ss.storeToken(key, token) == SecureStorageStatus::Success); + assert_true(ss.retrieveToken(key, retrievedToken) == SecureStorageStatus::Success); + assert_true(retrievedToken == token); + assert_permissions(CACHE_FILENAME, boost::filesystem::owner_read | boost::filesystem::owner_write); + assert_permissions(".", boost::filesystem::owner_all); + + assert_true(ss.removeToken(key) == SecureStorageStatus::Success); + assert_true(ss.retrieveToken(key, retrievedToken) == SecureStorageStatus::NotFound); +} + +void test_secure_storage_malformed_cache(void **) +{ + remove_file_if_exists(CACHE_FILENAME); + EnvOverride override("SF_TEMPORARY_CREDENTIAL_CACHE_DIR", "."); + SecureStorage ss; + SecureStorageKey key { "host", "user", SecureStorageKeyType::MFA_TOKEN }; + + std::string token = "example_token"; + std::string retrievedToken; + assert_true(ss.storeToken(key, token) == SecureStorageStatus::Success); + assert_true(ss.retrieveToken(key, retrievedToken) == SecureStorageStatus::Success); + assert_true(retrievedToken == token); + assert_permissions(CACHE_FILENAME, boost::filesystem::owner_read | boost::filesystem::owner_write); + assert_permissions(".", boost::filesystem::owner_all); + + { + std::ofstream fs(CACHE_FILENAME, std::ios_base::trunc); + assert_true(fs.is_open()); + fs << "[]"; + } + assert_true(ss.retrieveToken(key, retrievedToken) == SecureStorageStatus::NotFound); + + assert_true(ss.storeToken(key, token) == SecureStorageStatus::Success); + assert_true(ss.retrieveToken(key, retrievedToken) == SecureStorageStatus::Success); + assert_true(retrievedToken == token); + + { + std::ofstream fs(CACHE_FILENAME, std::ios_base::trunc); + assert_true(fs.is_open()); + fs << "{]"; + } + assert_true(ss.retrieveToken(key, retrievedToken) == SecureStorageStatus::NotFound); + + assert_true(ss.storeToken(key, token) == SecureStorageStatus::Success); + assert_true(ss.retrieveToken(key, retrievedToken) == SecureStorageStatus::Success); + assert_true(retrievedToken == token); + + { + std::ofstream fs(CACHE_FILENAME, std::ios_base::trunc); + assert_true(fs.is_open()); + fs << "{ \"random field\": []}"; + } + assert_true(ss.retrieveToken(key, retrievedToken) == SecureStorageStatus::NotFound); + + assert_true(ss.storeToken(key, token) == SecureStorageStatus::Success); + assert_true(ss.retrieveToken(key, retrievedToken) == SecureStorageStatus::Success); + assert_true(retrievedToken == token); +} + +void test_secure_storage_two_keys(void **) +{ + remove_file_if_exists(CACHE_FILENAME); + EnvOverride override("SF_TEMPORARY_CREDENTIAL_CACHE_DIR", "."); + SecureStorage ss; + SecureStorageKey key1 { "host", "user1", SecureStorageKeyType::MFA_TOKEN }; + SecureStorageKey key2 { "host", "user2", SecureStorageKeyType::MFA_TOKEN }; + std::string token1 = "example_token"; + std::string token2 = "example_token"; + std::string retrievedToken; + + assert_true(ss.storeToken(key1, token1) == SecureStorageStatus::Success); + assert_true(ss.retrieveToken(key1, retrievedToken) == SecureStorageStatus::Success); + assert_true(retrievedToken == token1); + assert_permissions(CACHE_FILENAME, boost::filesystem::owner_read | boost::filesystem::owner_write); + assert_permissions(".", boost::filesystem::owner_all); + + assert_true(ss.storeToken(key2, token2) == SecureStorageStatus::Success); + assert_true(ss.retrieveToken(key2, retrievedToken) == SecureStorageStatus::Success); + assert_true(retrievedToken == token2); + + assert_true(ss.removeToken(key1) == SecureStorageStatus::Success); + assert_true(ss.retrieveToken(key1, retrievedToken) == SecureStorageStatus::NotFound); + assert_true(ss.retrieveToken(key2, retrievedToken) == SecureStorageStatus::Success); + assert_true(retrievedToken == token2); + + assert_true(ss.removeToken(key2) == SecureStorageStatus::Success); + assert_true(ss.retrieveToken(key1, retrievedToken) == SecureStorageStatus::NotFound); + assert_true(ss.retrieveToken(key2, retrievedToken) == SecureStorageStatus::NotFound); +} + +void test_secure_storage_home_dir(void **) +{ + boost::filesystem::remove_all("home"); + boost::filesystem::create_directory("home"); + EnvOverride override1("XDG_CACHE_HOME", boost::none); + EnvOverride override2("HOME", "home"); + SecureStorage ss; + SecureStorageKey key { "host", "user", SecureStorageKeyType::MFA_TOKEN }; + + std::string token = "example_token"; + std::string retrievedToken; + assert_true(ss.storeToken(key, token) == SecureStorageStatus::Success); + assert_true(ss.retrieveToken(key, retrievedToken) == SecureStorageStatus::Success); + assert_true(retrievedToken == token); + + assert_true(boost::filesystem::exists(std::string("home/.cache/snowflake/") + CACHE_FILENAME)); + assert_permissions("home/.cache/snowflake", boost::filesystem::owner_all); + assert_permissions(std::string("home/.cache/snowflake/") + CACHE_FILENAME, boost::filesystem::owner_read | boost::filesystem::owner_write); +} + +void test_secure_storage_xdg_cache_home(void **) +{ + boost::filesystem::remove_all("cache_dir"); + boost::filesystem::create_directory("cache_dir"); + EnvOverride override("XDG_CACHE_HOME", "cache_dir"); + SecureStorage ss; + SecureStorageKey key { "host", "user", SecureStorageKeyType::MFA_TOKEN }; + + std::string token = "example_token"; + std::string retrievedToken; + assert_true(ss.storeToken(key, token) == SecureStorageStatus::Success); + assert_true(ss.retrieveToken(key, retrievedToken) == SecureStorageStatus::Success); + assert_true(retrievedToken == token); + + assert_true(boost::filesystem::exists(std::string("cache_dir/snowflake/") + CACHE_FILENAME)); + assert_permissions("cache_dir/snowflake", boost::filesystem::owner_all); + assert_permissions(std::string("cache_dir/snowflake/") + CACHE_FILENAME, boost::filesystem::owner_read | boost::filesystem::owner_write); +} + +void test_secure_storage_fails_to_lock(void **) +{ + EnvOverride override("SF_TEMPORARY_CREDENTIAL_CACHE_DIR", "."); + SecureStorage ss; + SecureStorageKey key { "host", "user", SecureStorageKeyType::MFA_TOKEN }; + + std::string token = "example_token"; + std::string retrievedToken; + boost::filesystem::create_directory(std::string(CACHE_FILENAME) + ".lck"); + assert_true(ss.storeToken(key, token) == SecureStorageStatus::Error); + assert_true(ss.retrieveToken(key, retrievedToken) == SecureStorageStatus::Error); + assert_true(ss.removeToken(key) == SecureStorageStatus::Error); +} + +void test_secure_storage_fails_to_find_cache_path(void **) +{ + EnvOverride override1("SF_TEMPORARY_CREDENTIAL_CACHE_DIR", boost::none); + EnvOverride override2("XDG_CACHE_HOME", boost::none); + EnvOverride override3("HOME", boost::none); + SecureStorage ss; + SecureStorageKey key { "host", "user", SecureStorageKeyType::MFA_TOKEN }; + + std::string token = "example_token"; + std::string retrievedToken; + std::string lockPath = std::string(CACHE_FILENAME) + ".lck"; + boost::filesystem::create_directory(lockPath); + assert_true(ss.storeToken(key, token) == SecureStorageStatus::Error); + assert_true(ss.retrieveToken(key, retrievedToken) == SecureStorageStatus::Error); + assert_true(ss.removeToken(key) == SecureStorageStatus::Error); + boost::filesystem::remove(lockPath); +} + +void test_secure_storage_c_api(void **) +{ + EnvOverride override("SF_TEMPORARY_CREDENTIAL_CACHE_DIR", "."); + SecureStorageKey key{"host", "user", SecureStorageKeyType::MFA_TOKEN}; + std::string token = "example_token"; + + secure_storage_ptr ss = secure_storage_init(); + + assert_true(secure_storage_save_credential(ss, key.host.c_str(), key.user.c_str(), key.type, token.c_str())); + + char* cred = secure_storage_get_credential(ss, key.host.c_str(), key.user.c_str(), key.type); + assert_true(cred != nullptr); + assert_true(strcmp(cred, "example_token") == 0); + secure_storage_free_credential(cred); + + assert_true(secure_storage_remove_credential(ss, key.host.c_str(), key.user.c_str(), key.type)); + + cred = secure_storage_get_credential(ss, key.host.c_str(), key.user.c_str(), key.type); + assert_true(cred == nullptr); + secure_storage_free_credential(cred); + + secure_storage_term(ss); +} + +void test_get_cache_dir_bad_path(void **) +{ + EnvOverride override("ENV_VAR", "fakepath"); + assert_false(getCacheDir("ENV_VAR", {}).is_initialized()); +} + +void test_get_cache_dir_not_a_dir(void **) +{ + EnvOverride override("ENV_VAR", "file"); + std::ofstream file("file", std::ios_base::trunc); + assert_true(file.is_open()); + file << "contents"; + file.close(); + assert_false(getCacheDir("ENV_VAR", {}).is_initialized()); + boost::filesystem::remove("file"); +} + +int main(void) { + /* Testing only file based credential cache, available on linux */ +#ifndef __linux__ + return 0; +#endif + const struct CMUnitTest tests[] = { + cmocka_unit_test(test_secure_storage_simple), + cmocka_unit_test(test_secure_storage_malformed_cache), + cmocka_unit_test(test_secure_storage_two_keys), + cmocka_unit_test(test_secure_storage_xdg_cache_home), + cmocka_unit_test(test_secure_storage_home_dir), + cmocka_unit_test(test_secure_storage_c_api), + cmocka_unit_test(test_secure_storage_fails_to_lock), + cmocka_unit_test(test_secure_storage_fails_to_find_cache_path), + cmocka_unit_test(test_get_cache_dir_bad_path), + cmocka_unit_test(test_get_cache_dir_not_a_dir) + }; + return cmocka_run_group_tests(tests, NULL, NULL); +}