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 {
|
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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user