#include "nocal/sync/secret_store.hpp" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include namespace nocal::sync { namespace { constexpr std::size_t maximum_secret_size = 64U * 1024U; constexpr std::size_t maximum_account_id_size = 512U; const SecretSchema oauth_schema = { "dev.nomarchy.nocal.oauth", SECRET_SCHEMA_NONE, { {"account", SECRET_SCHEMA_ATTRIBUTE_STRING}, {nullptr, SECRET_SCHEMA_ATTRIBUTE_STRING}, }, 0, nullptr, nullptr, nullptr, nullptr, nullptr, nullptr, nullptr, }; const SecretSchema caldav_schema = { "dev.nomarchy.nocal.caldav", SECRET_SCHEMA_NONE, { {"account", SECRET_SCHEMA_ATTRIBUTE_STRING}, {nullptr, SECRET_SCHEMA_ATTRIBUTE_STRING}, }, 0, nullptr, nullptr, nullptr, nullptr, nullptr, nullptr, nullptr, }; [[nodiscard]] bool contains_nul_or_control(std::string_view value) { return std::ranges::any_of(value, [](unsigned char character) { return character == 0U || character < 0x20U || character == 0x7fU; }); } [[nodiscard]] bool field_size_is_bounded(const OAuthTokens& tokens) { std::size_t total = 256; for (const std::string* field : {&tokens.access_token, &tokens.refresh_token, &tokens.token_type, &tokens.scope}) { if (field->size() > maximum_secret_size || total > maximum_secret_size - field->size()) { return false; } total += field->size(); } return total <= maximum_secret_size; } void validate_account(std::string_view account_id) { if (account_id.empty() || account_id.size() > maximum_account_id_size || contains_nul_or_control(account_id)) { throw SecretStoreError( SecretStoreError::Kind::invalid_input, "invalid secret-store account identifier"); } } void validate_tokens(const OAuthTokens& tokens) { const bool invalid_text = tokens.access_token.empty() || tokens.refresh_token.empty() || tokens.token_type != "Bearer" || contains_nul_or_control(tokens.access_token) || contains_nul_or_control(tokens.refresh_token) || contains_nul_or_control(tokens.token_type) || contains_nul_or_control(tokens.scope); if (invalid_text || tokens.expires_at_epoch_seconds <= 0 || !field_size_is_bounded(tokens)) { throw SecretStoreError( SecretStoreError::Kind::invalid_input, "invalid OAuth credential set"); } } void validate_caldav_credential(const CalDAVCredential& credential) { if (credential.username.empty() || credential.password.empty() || contains_nul_or_control(credential.username) || contains_nul_or_control(credential.password) || credential.username.size() > maximum_secret_size || credential.password.size() > maximum_secret_size) { throw SecretStoreError( SecretStoreError::Kind::invalid_input, "invalid CalDAV credential set"); } } class DuplicateKey final : public std::exception {}; [[nodiscard]] nlohmann::json parse_unique_json(std::string_view serialized) { std::vector> object_keys; const nlohmann::json::parser_callback_t callback = [&object_keys](int, nlohmann::json::parse_event_t event, nlohmann::json& parsed) { if (event == nlohmann::json::parse_event_t::object_start) { object_keys.emplace_back(); } else if (event == nlohmann::json::parse_event_t::key) { if (object_keys.empty() || !object_keys.back().insert(parsed.get()).second) { throw DuplicateKey{}; } } else if (event == nlohmann::json::parse_event_t::object_end) { if (object_keys.empty()) { throw DuplicateKey{}; } object_keys.pop_back(); } return true; }; nlohmann::json document = nlohmann::json::parse( serialized.begin(), serialized.end(), callback, true, false); if (!object_keys.empty()) { throw DuplicateKey{}; } return document; } void secure_zero(char* bytes, std::size_t size) noexcept { volatile char* output = bytes; while (size > 0) { *output++ = 0; --size; } } class SensitiveString { public: explicit SensitiveString(std::string value) : value_(std::move(value)) {} ~SensitiveString() { secure_zero(value_.data(), value_.size()); } SensitiveString(const SensitiveString&) = delete; SensitiveString& operator=(const SensitiveString&) = delete; [[nodiscard]] const char* c_str() const noexcept { return value_.c_str(); } [[nodiscard]] std::size_t size() const noexcept { return value_.size(); } private: std::string value_; }; class ReturnedSecret { public: explicit ReturnedSecret(char* value) : value_(value) {} ~ReturnedSecret() { if (value_ != nullptr) { secure_zero(value_, std::char_traits::length(value_)); secret_password_free(value_); } } ReturnedSecret(const ReturnedSecret&) = delete; ReturnedSecret& operator=(const ReturnedSecret&) = delete; [[nodiscard]] const char* get() const noexcept { return value_; } private: char* value_; }; class ErrorHolder { public: ~ErrorHolder() { if (error_ != nullptr) { g_error_free(error_); } } ErrorHolder(const ErrorHolder&) = delete; ErrorHolder& operator=(const ErrorHolder&) = delete; ErrorHolder() = default; [[nodiscard]] GError** output() noexcept { return &error_; } [[nodiscard]] const GError* get() const noexcept { return error_; } private: GError* error_{nullptr}; }; struct CancelCallback { GCancellable* cancellable; void operator()() const noexcept { g_cancellable_cancel(cancellable); } }; class Cancellation { public: explicit Cancellation(std::stop_token stop) : cancellable_(g_cancellable_new()) { if (cancellable_ == nullptr) { throw SecretStoreError( SecretStoreError::Kind::operation_failed, "secret-store operation failed"); } callback_ = std::make_unique>( stop, CancelCallback{cancellable_}); } ~Cancellation() { callback_.reset(); g_object_unref(cancellable_); } Cancellation(const Cancellation&) = delete; Cancellation& operator=(const Cancellation&) = delete; [[nodiscard]] GCancellable* get() const noexcept { return cancellable_; } private: GCancellable* cancellable_; std::unique_ptr> callback_; }; [[nodiscard]] SecretStoreError::Kind classify_error( const GError* error, std::stop_token stop) { if (stop.stop_requested() || (error != nullptr && g_error_matches(error, G_IO_ERROR, G_IO_ERROR_CANCELLED))) { return SecretStoreError::Kind::cancelled; } if (error != nullptr && (error->domain == G_DBUS_ERROR || g_error_matches(error, G_IO_ERROR, G_IO_ERROR_NOT_FOUND) || g_error_matches(error, G_IO_ERROR, G_IO_ERROR_CONNECTION_REFUSED) || g_error_matches(error, G_IO_ERROR, G_IO_ERROR_CLOSED) || g_error_matches(error, G_IO_ERROR, G_IO_ERROR_TIMED_OUT) || g_error_matches(error, G_IO_ERROR, G_IO_ERROR_HOST_UNREACHABLE) || g_error_matches(error, G_IO_ERROR, G_IO_ERROR_NETWORK_UNREACHABLE))) { return SecretStoreError::Kind::unavailable; } return SecretStoreError::Kind::operation_failed; } [[noreturn]] void throw_operation_error(const GError* error, std::stop_token stop) { const SecretStoreError::Kind kind = classify_error(error, stop); switch (kind) { case SecretStoreError::Kind::cancelled: throw SecretStoreError(kind, "secret-store operation cancelled"); case SecretStoreError::Kind::unavailable: throw SecretStoreError(kind, "secret service unavailable"); default: throw SecretStoreError(kind, "secret-store operation failed"); } } void reject_pre_cancelled(std::stop_token stop) { if (stop.stop_requested()) { throw SecretStoreError( SecretStoreError::Kind::cancelled, "secret-store operation cancelled"); } } [[nodiscard]] SensitiveString serialize_tokens(const OAuthTokens& tokens) { try { nlohmann::json document = { {"version", 1}, {"access_token", tokens.access_token}, {"refresh_token", tokens.refresh_token}, {"token_type", tokens.token_type}, {"scope", tokens.scope}, {"expires_at_epoch_seconds", tokens.expires_at_epoch_seconds}, }; std::string serialized = document.dump(); if (serialized.size() > maximum_secret_size) { throw SecretStoreError( SecretStoreError::Kind::invalid_input, "invalid OAuth credential set"); } return SensitiveString{std::move(serialized)}; } catch (const SecretStoreError&) { throw; } catch (...) { throw SecretStoreError( SecretStoreError::Kind::invalid_input, "invalid OAuth credential set"); } } [[nodiscard]] OAuthTokens parse_tokens(std::string_view serialized) { if (serialized.empty() || serialized.size() > maximum_secret_size) { throw SecretStoreError( SecretStoreError::Kind::corrupt_secret, "stored OAuth credential is invalid"); } try { const nlohmann::json document = parse_unique_json(serialized); if (!document.is_object() || document.size() != 6 || !document.contains("version") || !document["version"].is_number_integer() || document["version"].get() != 1 || !document.contains("access_token") || !document["access_token"].is_string() || !document.contains("refresh_token") || !document["refresh_token"].is_string() || !document.contains("token_type") || !document["token_type"].is_string() || !document.contains("scope") || !document["scope"].is_string() || !document.contains("expires_at_epoch_seconds") || !document["expires_at_epoch_seconds"].is_number_integer()) { throw std::runtime_error("invalid document shape"); } OAuthTokens tokens; tokens.access_token = document["access_token"].get(); tokens.refresh_token = document["refresh_token"].get(); tokens.token_type = document["token_type"].get(); tokens.scope = document["scope"].get(); tokens.expires_at_epoch_seconds = document["expires_at_epoch_seconds"].get(); validate_tokens(tokens); return tokens; } catch (...) { throw SecretStoreError( SecretStoreError::Kind::corrupt_secret, "stored OAuth credential is invalid"); } } [[nodiscard]] SensitiveString serialize_caldav_credential( const CalDAVCredential& credential) { try { nlohmann::json document = { {"version", 1}, {"username", credential.username}, {"password", credential.password}, }; std::string serialized = document.dump(); if (serialized.size() > maximum_secret_size) { throw SecretStoreError( SecretStoreError::Kind::invalid_input, "invalid CalDAV credential set"); } return SensitiveString{std::move(serialized)}; } catch (const SecretStoreError&) { throw; } catch (...) { throw SecretStoreError( SecretStoreError::Kind::invalid_input, "invalid CalDAV credential set"); } } [[nodiscard]] CalDAVCredential parse_caldav_credential(std::string_view serialized) { if (serialized.empty() || serialized.size() > maximum_secret_size) { throw SecretStoreError( SecretStoreError::Kind::corrupt_secret, "stored CalDAV credential is invalid"); } try { const nlohmann::json document = parse_unique_json(serialized); if (!document.is_object() || document.size() != 3 || !document.contains("version") || !document["version"].is_number_integer() || document["version"].get() != 1 || !document.contains("username") || !document["username"].is_string() || !document.contains("password") || !document["password"].is_string()) { throw std::runtime_error("invalid document shape"); } CalDAVCredential credential; credential.username = document["username"].get(); credential.password = document["password"].get(); validate_caldav_credential(credential); return credential; } catch (...) { throw SecretStoreError( SecretStoreError::Kind::corrupt_secret, "stored CalDAV credential is invalid"); } } } // namespace SecretStoreError::SecretStoreError(Kind kind, std::string message) : std::runtime_error(std::move(message)), kind_(kind) {} SecretStoreError::Kind SecretStoreError::kind() const noexcept { return kind_; } void LibsecretOAuthSecretStore::store( std::string account_id, const OAuthTokens& tokens, std::stop_token stop) { validate_account(account_id); validate_tokens(tokens); reject_pre_cancelled(stop); SensitiveString serialized = serialize_tokens(tokens); Cancellation cancellation{stop}; ErrorHolder error; const gboolean stored = secret_password_store_sync(&oauth_schema, SECRET_COLLECTION_DEFAULT, account_id.c_str(), serialized.c_str(), cancellation.get(), error.output(), "account", account_id.c_str(), nullptr); if (!stored) { throw_operation_error(error.get(), stop); } } std::optional LibsecretOAuthSecretStore::load( std::string account_id, std::stop_token stop) { validate_account(account_id); reject_pre_cancelled(stop); Cancellation cancellation{stop}; ErrorHolder error; ReturnedSecret secret{secret_password_lookup_sync( &oauth_schema, cancellation.get(), error.output(), "account", account_id.c_str(), nullptr)}; if (secret.get() == nullptr) { if (error.get() != nullptr) { throw_operation_error(error.get(), stop); } return std::nullopt; } const std::size_t size = std::char_traits::length(secret.get()); return parse_tokens(std::string_view{secret.get(), size}); } void LibsecretOAuthSecretStore::erase(std::string account_id, std::stop_token stop) { validate_account(account_id); reject_pre_cancelled(stop); Cancellation cancellation{stop}; ErrorHolder error; static_cast(secret_password_clear_sync( &oauth_schema, cancellation.get(), error.output(), "account", account_id.c_str(), nullptr)); if (error.get() != nullptr) { throw_operation_error(error.get(), stop); } } void LibsecretOAuthSecretStore::store_caldav_credential( std::string account_id, const CalDAVCredential& credential, std::stop_token stop) { validate_account(account_id); validate_caldav_credential(credential); reject_pre_cancelled(stop); SensitiveString serialized = serialize_caldav_credential(credential); Cancellation cancellation{stop}; ErrorHolder error; const gboolean stored = secret_password_store_sync(&caldav_schema, SECRET_COLLECTION_DEFAULT, account_id.c_str(), serialized.c_str(), cancellation.get(), error.output(), "account", account_id.c_str(), nullptr); if (!stored) { throw_operation_error(error.get(), stop); } } std::optional LibsecretOAuthSecretStore::load_caldav_credential( std::string account_id, std::stop_token stop) { validate_account(account_id); reject_pre_cancelled(stop); Cancellation cancellation{stop}; ErrorHolder error; ReturnedSecret secret{secret_password_lookup_sync( &caldav_schema, cancellation.get(), error.output(), "account", account_id.c_str(), nullptr)}; if (secret.get() == nullptr) { if (error.get() != nullptr) { throw_operation_error(error.get(), stop); } return std::nullopt; } const std::size_t size = std::char_traits::length(secret.get()); return parse_caldav_credential(std::string_view{secret.get(), size}); } void LibsecretOAuthSecretStore::erase_caldav_credential(std::string account_id, std::stop_token stop) { validate_account(account_id); reject_pre_cancelled(stop); Cancellation cancellation{stop}; ErrorHolder error; static_cast(secret_password_clear_sync( &caldav_schema, cancellation.get(), error.output(), "account", account_id.c_str(), nullptr)); if (error.get() != nullptr) { throw_operation_error(error.get(), stop); } } } // namespace nocal::sync