Files
nocal/src/sync/secret_store.cpp
Bernardo Magri 23d95e957c 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.
2026-07-23 20:39:16 +01:00

473 lines
17 KiB
C++

#include "nocal/sync/secret_store.hpp"
#include <libsecret/secret.h>
#include <nlohmann/json.hpp>
#include <algorithm>
#include <cstddef>
#include <cstdint>
#include <exception>
#include <memory>
#include <optional>
#include <stdexcept>
#include <stop_token>
#include <string>
#include <string_view>
#include <unordered_set>
#include <utility>
#include <vector>
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<std::unordered_set<std::string>> 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<std::string>()).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<char>::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<std::stop_callback<CancelCallback>>(
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<std::stop_callback<CancelCallback>> 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<int>() != 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<std::string>();
tokens.refresh_token = document["refresh_token"].get<std::string>();
tokens.token_type = document["token_type"].get<std::string>();
tokens.scope = document["scope"].get<std::string>();
tokens.expires_at_epoch_seconds =
document["expires_at_epoch_seconds"].get<std::int64_t>();
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<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)
: 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<OAuthTokens> 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<char>::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<void>(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<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