diff --git a/include/nocal/sync/caldav_account.hpp b/include/nocal/sync/caldav_account.hpp index 5e89c69..1cc9e35 100644 --- a/include/nocal/sync/caldav_account.hpp +++ b/include/nocal/sync/caldav_account.hpp @@ -5,7 +5,7 @@ namespace nocal::sync { -// CalDAV account configuration. +// CalDAV account configuration (credentials loaded from Secret Service at runtime). struct CalDAVAccountConfig { std::string server_url; std::string username; diff --git a/include/nocal/sync/caldav_sync_provider.hpp b/include/nocal/sync/caldav_sync_provider.hpp index 3efee06..709603d 100644 --- a/include/nocal/sync/caldav_sync_provider.hpp +++ b/include/nocal/sync/caldav_sync_provider.hpp @@ -3,19 +3,28 @@ #include "nocal/sync/caldav_account.hpp" #include "nocal/sync/http.hpp" #include "nocal/sync/provider.hpp" +#include "nocal/sync/secret_store.hpp" + +#include +#include namespace nocal::storage { class SyncCache; } -#include - namespace nocal::sync { +// CalDAV server configuration (no credentials — those live in Secret Service). +struct CalDAVServerConfig { + std::string server_url; + std::string account_id; + std::string display_name; +}; + class CalDAVSyncProvider final : public SyncProvider { public: CalDAVSyncProvider(HttpTransport& transport, storage::SyncCache& cache, - CalDAVAccountConfig config); + OAuthSecretStore& secret_store, CalDAVServerConfig config); std::string_view provider_id() const noexcept override { return "caldav"; } ConnectedAccount connect_account() override; @@ -25,7 +34,8 @@ public: private: HttpTransport& transport_; storage::SyncCache& cache_; - CalDAVAccountConfig config_; + OAuthSecretStore& secret_store_; + CalDAVServerConfig config_; }; } // namespace nocal::sync diff --git a/include/nocal/sync/secret_store.hpp b/include/nocal/sync/secret_store.hpp index 9c90629..e761174 100644 --- a/include/nocal/sync/secret_store.hpp +++ b/include/nocal/sync/secret_store.hpp @@ -9,6 +9,13 @@ namespace nocal::sync { +struct CalDAVCredential { + std::string username; + std::string password; + + bool operator==(const CalDAVCredential&) const = default; +}; + class SecretStoreError : public std::runtime_error { public: enum class Kind { @@ -34,6 +41,13 @@ public: [[nodiscard]] virtual std::optional load( std::string account_id, std::stop_token stop = {}) = 0; virtual void erase(std::string account_id, std::stop_token stop = {}) = 0; + + virtual void store_caldav_credential(std::string account_id, + const CalDAVCredential& credential, std::stop_token stop = {}) = 0; + [[nodiscard]] virtual std::optional load_caldav_credential( + std::string account_id, std::stop_token stop = {}) = 0; + virtual void erase_caldav_credential(std::string account_id, + std::stop_token stop = {}) = 0; }; // Synchronous libsecret adapter. Call only from a worker thread: Secret Service @@ -45,6 +59,13 @@ public: [[nodiscard]] std::optional load( std::string account_id, std::stop_token stop = {}) override; void erase(std::string account_id, std::stop_token stop = {}) override; + + void store_caldav_credential(std::string account_id, + const CalDAVCredential& credential, std::stop_token stop = {}) override; + [[nodiscard]] std::optional load_caldav_credential( + std::string account_id, std::stop_token stop = {}) override; + void erase_caldav_credential(std::string account_id, + std::stop_token stop = {}) override; }; } // namespace nocal::sync diff --git a/src/sync/caldav_sync_provider.cpp b/src/sync/caldav_sync_provider.cpp index 916e8a8..0d5dd7a 100644 --- a/src/sync/caldav_sync_provider.cpp +++ b/src/sync/caldav_sync_provider.cpp @@ -45,12 +45,28 @@ namespace { CalDAVSyncProvider::CalDAVSyncProvider(HttpTransport& transport, storage::SyncCache& cache, - CalDAVAccountConfig config) - : transport_(transport), cache_(cache), config_(std::move(config)) {} + OAuthSecretStore& secret_store, + CalDAVServerConfig config) + : transport_(transport), + cache_(cache), + secret_store_(secret_store), + config_(std::move(config)) {} ConnectedAccount CalDAVSyncProvider::connect_account() { - // Account is configured via constructor. Verify connectivity. - CalDAVSync sync(transport_, cache_, config_); + // Credentials are loaded from Secret Service. + const auto credential = secret_store_.load_caldav_credential(config_.account_id); + if (!credential) { + throw std::runtime_error("CalDAV: no credentials stored; run `nocal account connect` " + "first"); + } + + CalDAVAccountConfig account_config; + account_config.server_url = config_.server_url; + account_config.account_id = config_.account_id; + account_config.username = credential->username; + account_config.password = credential->password; + + CalDAVSync sync(transport_, cache_, account_config); sync.discover(); return {config_.account_id, config_.display_name}; } @@ -64,7 +80,18 @@ void CalDAVSyncProvider::sync_account(const std::string_view account_id, observer.on_sync_progress(SyncProgressEvent{ SyncStage::authenticating, config_.account_id, {}, 0, 0}); - CalDAVSync sync(transport_, cache_, config_); + const auto credential = secret_store_.load_caldav_credential(config_.account_id); + if (!credential) { + throw std::runtime_error("CalDAV: no credentials stored; account may be disconnected"); + } + + CalDAVAccountConfig account_config; + account_config.server_url = config_.server_url; + account_config.account_id = config_.account_id; + account_config.username = credential->username; + account_config.password = credential->password; + + CalDAVSync sync(transport_, cache_, account_config); observer.on_sync_progress(SyncProgressEvent{ SyncStage::discovering_calendars, config_.account_id, {}, 0, 0}); @@ -99,8 +126,7 @@ void CalDAVSyncProvider::disconnect_account(const std::string_view account_id) { if (account_id != config_.account_id) { throw std::runtime_error("CalDAV: account ID mismatch"); } - // Credentials are stored externally; nothing to erase in this provider. - // The cache retains provider data (deterministic IDs make re-add idempotent). + secret_store_.erase_caldav_credential(config_.account_id); } } // namespace nocal::sync diff --git a/src/sync/secret_store.cpp b/src/sync/secret_store.cpp index 6f3222d..2476638 100644 --- a/src/sync/secret_store.cpp +++ b/src/sync/secret_store.cpp @@ -40,6 +40,23 @@ const SecretSchema oauth_schema = { 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; @@ -78,6 +95,17 @@ void validate_tokens(const OAuthTokens& tokens) { } } +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) { @@ -294,6 +322,53 @@ void reject_pre_cancelled(std::stop_token stop) { } } +[[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) @@ -347,4 +422,51 @@ void LibsecretOAuthSecretStore::erase(std::string account_id, std::stop_token st } } +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 diff --git a/tests/microsoft_account_tests.cpp b/tests/microsoft_account_tests.cpp index d18a5f1..8ec5318 100644 --- a/tests/microsoft_account_tests.cpp +++ b/tests/microsoft_account_tests.cpp @@ -35,6 +35,7 @@ using nocal::sync::MicrosoftAccountError; using nocal::sync::OAuthClientConfig; using nocal::sync::OAuthSecretStore; using nocal::sync::OAuthTokens; +using nocal::sync::CalDAVCredential; void require(bool condition, std::string_view message) { if (!condition) { @@ -199,6 +200,18 @@ public: ++erase_calls; store_data.erase(account_id); } + + void store_caldav_credential(std::string, const CalDAVCredential&, + std::stop_token = {}) override { + std::terminate(); + } + [[nodiscard]] std::optional load_caldav_credential( + std::string, std::stop_token = {}) override { + std::terminate(); + } + void erase_caldav_credential(std::string, std::stop_token = {}) override { + std::terminate(); + } }; // ----------------------------------------------------------------------- diff --git a/tests/microsoft_sync_provider_tests.cpp b/tests/microsoft_sync_provider_tests.cpp index a5eff7b..50d55c1 100644 --- a/tests/microsoft_sync_provider_tests.cpp +++ b/tests/microsoft_sync_provider_tests.cpp @@ -40,6 +40,7 @@ using nocal::sync::HttpTransport; using nocal::sync::OAuthClientConfig; using nocal::sync::OAuthSecretStore; using nocal::sync::OAuthTokens; +using nocal::sync::CalDAVCredential; using nocal::sync::SyncObserver; using nocal::sync::SyncProgressEvent; using nocal::sync::SyncStage; @@ -223,6 +224,18 @@ public: ++erase_calls; store_data.erase(account_id); } + + void store_caldav_credential(std::string, const CalDAVCredential&, + std::stop_token = {}) override { + std::terminate(); + } + [[nodiscard]] std::optional load_caldav_credential( + std::string, std::stop_token = {}) override { + std::terminate(); + } + void erase_caldav_credential(std::string, std::stop_token = {}) override { + std::terminate(); + } }; // ----------------------------------------------------------------------- diff --git a/tests/token_broker_tests.cpp b/tests/token_broker_tests.cpp index ad787b4..d7d224f 100644 --- a/tests/token_broker_tests.cpp +++ b/tests/token_broker_tests.cpp @@ -22,6 +22,7 @@ using nocal::sync::HttpTransport; using nocal::sync::OAuthClientConfig; using nocal::sync::OAuthSecretStore; using nocal::sync::OAuthTokens; +using nocal::sync::CalDAVCredential; void require(bool condition, std::string_view message) { if (!condition) { @@ -92,6 +93,18 @@ public: } store_data.erase(account_id); } + + void store_caldav_credential(std::string, const CalDAVCredential&, + std::stop_token = {}) override { + std::terminate(); + } + [[nodiscard]] std::optional load_caldav_credential( + std::string, std::stop_token = {}) override { + std::terminate(); + } + void erase_caldav_credential(std::string, std::stop_token = {}) override { + std::terminate(); + } }; // Fake HttpTransport with scripted responses