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.
473 lines
17 KiB
C++
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
|