feat: CalDAV credential persistence in Secret Service

Move CalDAV username/password from in-memory config to Secret Service
(gnome-keyring). CalDAVSyncProvider now loads credentials at sync time
and erases them on disconnect. OAuthSecretStore extended with
store/load/erase_caldav_credential methods using a separate libsecret
schema.
This commit is contained in:
2026-07-23 20:39:16 +01:00
parent 5efcfdafb9
commit 23d95e957c
8 changed files with 230 additions and 12 deletions

View File

@@ -5,7 +5,7 @@
namespace nocal::sync { namespace nocal::sync {
// CalDAV account configuration. // CalDAV account configuration (credentials loaded from Secret Service at runtime).
struct CalDAVAccountConfig { struct CalDAVAccountConfig {
std::string server_url; std::string server_url;
std::string username; std::string username;

View File

@@ -3,19 +3,28 @@
#include "nocal/sync/caldav_account.hpp" #include "nocal/sync/caldav_account.hpp"
#include "nocal/sync/http.hpp" #include "nocal/sync/http.hpp"
#include "nocal/sync/provider.hpp" #include "nocal/sync/provider.hpp"
#include "nocal/sync/secret_store.hpp"
#include <memory>
#include <string>
namespace nocal::storage { namespace nocal::storage {
class SyncCache; class SyncCache;
} }
#include <string>
namespace nocal::sync { 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 { class CalDAVSyncProvider final : public SyncProvider {
public: public:
CalDAVSyncProvider(HttpTransport& transport, storage::SyncCache& cache, CalDAVSyncProvider(HttpTransport& transport, storage::SyncCache& cache,
CalDAVAccountConfig config); OAuthSecretStore& secret_store, CalDAVServerConfig config);
std::string_view provider_id() const noexcept override { return "caldav"; } std::string_view provider_id() const noexcept override { return "caldav"; }
ConnectedAccount connect_account() override; ConnectedAccount connect_account() override;
@@ -25,7 +34,8 @@ public:
private: private:
HttpTransport& transport_; HttpTransport& transport_;
storage::SyncCache& cache_; storage::SyncCache& cache_;
CalDAVAccountConfig config_; OAuthSecretStore& secret_store_;
CalDAVServerConfig config_;
}; };
} // namespace nocal::sync } // namespace nocal::sync

View File

@@ -9,6 +9,13 @@
namespace nocal::sync { namespace nocal::sync {
struct CalDAVCredential {
std::string username;
std::string password;
bool operator==(const CalDAVCredential&) const = default;
};
class SecretStoreError : public std::runtime_error { class SecretStoreError : public std::runtime_error {
public: public:
enum class Kind { enum class Kind {
@@ -34,6 +41,13 @@ public:
[[nodiscard]] virtual std::optional<OAuthTokens> load( [[nodiscard]] virtual std::optional<OAuthTokens> load(
std::string account_id, std::stop_token stop = {}) = 0; std::string account_id, std::stop_token stop = {}) = 0;
virtual void erase(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<CalDAVCredential> 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 // Synchronous libsecret adapter. Call only from a worker thread: Secret Service
@@ -45,6 +59,13 @@ public:
[[nodiscard]] std::optional<OAuthTokens> load( [[nodiscard]] std::optional<OAuthTokens> load(
std::string account_id, std::stop_token stop = {}) override; std::string account_id, std::stop_token stop = {}) override;
void erase(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<CalDAVCredential> 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 } // namespace nocal::sync

View File

@@ -45,12 +45,28 @@ namespace {
CalDAVSyncProvider::CalDAVSyncProvider(HttpTransport& transport, CalDAVSyncProvider::CalDAVSyncProvider(HttpTransport& transport,
storage::SyncCache& cache, storage::SyncCache& cache,
CalDAVAccountConfig config) OAuthSecretStore& secret_store,
: transport_(transport), cache_(cache), config_(std::move(config)) {} CalDAVServerConfig config)
: transport_(transport),
cache_(cache),
secret_store_(secret_store),
config_(std::move(config)) {}
ConnectedAccount CalDAVSyncProvider::connect_account() { ConnectedAccount CalDAVSyncProvider::connect_account() {
// Account is configured via constructor. Verify connectivity. // Credentials are loaded from Secret Service.
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; 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(); sync.discover();
return {config_.account_id, config_.display_name}; 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{ observer.on_sync_progress(SyncProgressEvent{
SyncStage::authenticating, config_.account_id, {}, 0, 0}); 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{ observer.on_sync_progress(SyncProgressEvent{
SyncStage::discovering_calendars, config_.account_id, {}, 0, 0}); 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) { if (account_id != config_.account_id) {
throw std::runtime_error("CalDAV: account ID mismatch"); throw std::runtime_error("CalDAV: account ID mismatch");
} }
// Credentials are stored externally; nothing to erase in this provider. secret_store_.erase_caldav_credential(config_.account_id);
// The cache retains provider data (deterministic IDs make re-add idempotent).
} }
} // namespace nocal::sync } // namespace nocal::sync

View File

@@ -40,6 +40,23 @@ const SecretSchema oauth_schema = {
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) { [[nodiscard]] bool contains_nul_or_control(std::string_view value) {
return std::ranges::any_of(value, [](unsigned char character) { return std::ranges::any_of(value, [](unsigned char character) {
return character == 0U || character < 0x20U || character == 0x7fU; 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 {}; class DuplicateKey final : public std::exception {};
[[nodiscard]] nlohmann::json parse_unique_json(std::string_view serialized) { [[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<int>() != 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<std::string>();
credential.password = document["password"].get<std::string>();
validate_caldav_credential(credential);
return credential;
} catch (...) {
throw SecretStoreError(
SecretStoreError::Kind::corrupt_secret, "stored CalDAV credential is invalid");
}
}
} // namespace } // namespace
SecretStoreError::SecretStoreError(Kind kind, std::string message) 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<CalDAVCredential> 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<char>::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<void>(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 } // namespace nocal::sync

View File

@@ -35,6 +35,7 @@ using nocal::sync::MicrosoftAccountError;
using nocal::sync::OAuthClientConfig; using nocal::sync::OAuthClientConfig;
using nocal::sync::OAuthSecretStore; using nocal::sync::OAuthSecretStore;
using nocal::sync::OAuthTokens; using nocal::sync::OAuthTokens;
using nocal::sync::CalDAVCredential;
void require(bool condition, std::string_view message) { void require(bool condition, std::string_view message) {
if (!condition) { if (!condition) {
@@ -199,6 +200,18 @@ public:
++erase_calls; ++erase_calls;
store_data.erase(account_id); store_data.erase(account_id);
} }
void store_caldav_credential(std::string, const CalDAVCredential&,
std::stop_token = {}) override {
std::terminate();
}
[[nodiscard]] std::optional<CalDAVCredential> load_caldav_credential(
std::string, std::stop_token = {}) override {
std::terminate();
}
void erase_caldav_credential(std::string, std::stop_token = {}) override {
std::terminate();
}
}; };
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------

View File

@@ -40,6 +40,7 @@ using nocal::sync::HttpTransport;
using nocal::sync::OAuthClientConfig; using nocal::sync::OAuthClientConfig;
using nocal::sync::OAuthSecretStore; using nocal::sync::OAuthSecretStore;
using nocal::sync::OAuthTokens; using nocal::sync::OAuthTokens;
using nocal::sync::CalDAVCredential;
using nocal::sync::SyncObserver; using nocal::sync::SyncObserver;
using nocal::sync::SyncProgressEvent; using nocal::sync::SyncProgressEvent;
using nocal::sync::SyncStage; using nocal::sync::SyncStage;
@@ -223,6 +224,18 @@ public:
++erase_calls; ++erase_calls;
store_data.erase(account_id); store_data.erase(account_id);
} }
void store_caldav_credential(std::string, const CalDAVCredential&,
std::stop_token = {}) override {
std::terminate();
}
[[nodiscard]] std::optional<CalDAVCredential> load_caldav_credential(
std::string, std::stop_token = {}) override {
std::terminate();
}
void erase_caldav_credential(std::string, std::stop_token = {}) override {
std::terminate();
}
}; };
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------

View File

@@ -22,6 +22,7 @@ using nocal::sync::HttpTransport;
using nocal::sync::OAuthClientConfig; using nocal::sync::OAuthClientConfig;
using nocal::sync::OAuthSecretStore; using nocal::sync::OAuthSecretStore;
using nocal::sync::OAuthTokens; using nocal::sync::OAuthTokens;
using nocal::sync::CalDAVCredential;
void require(bool condition, std::string_view message) { void require(bool condition, std::string_view message) {
if (!condition) { if (!condition) {
@@ -92,6 +93,18 @@ public:
} }
store_data.erase(account_id); store_data.erase(account_id);
} }
void store_caldav_credential(std::string, const CalDAVCredential&,
std::stop_token = {}) override {
std::terminate();
}
[[nodiscard]] std::optional<CalDAVCredential> 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 // Fake HttpTransport with scripted responses