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:
@@ -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;
|
||||
|
||||
@@ -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 <memory>
|
||||
#include <string>
|
||||
|
||||
namespace nocal::storage {
|
||||
class SyncCache;
|
||||
}
|
||||
|
||||
#include <string>
|
||||
|
||||
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
|
||||
|
||||
@@ -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<OAuthTokens> 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<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
|
||||
@@ -45,6 +59,13 @@ public:
|
||||
[[nodiscard]] std::optional<OAuthTokens> 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<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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<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
|
||||
|
||||
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
|
||||
|
||||
@@ -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<CalDAVCredential> load_caldav_credential(
|
||||
std::string, std::stop_token = {}) override {
|
||||
std::terminate();
|
||||
}
|
||||
void erase_caldav_credential(std::string, std::stop_token = {}) override {
|
||||
std::terminate();
|
||||
}
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@@ -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<CalDAVCredential> load_caldav_credential(
|
||||
std::string, std::stop_token = {}) override {
|
||||
std::terminate();
|
||||
}
|
||||
void erase_caldav_credential(std::string, std::stop_token = {}) override {
|
||||
std::terminate();
|
||||
}
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@@ -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<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
|
||||
|
||||
Reference in New Issue
Block a user