feat: store OAuth tokens securely
Add bounded token response parsing with refresh-token retention and a cancellable worker-thread libsecret adapter using versioned credential payloads. Verify CRUD, isolation, cancellation, corruption, unavailable service, redaction, and cleanup against an isolated D-Bus and gnome-keyring Secret Service in normal, warning-as-error, and sanitizer profiles.
This commit is contained in:
350
src/sync/secret_store.cpp
Normal file
350
src/sync/secret_store.cpp
Normal file
@@ -0,0 +1,350 @@
|
||||
#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,
|
||||
};
|
||||
|
||||
[[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");
|
||||
}
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
} // 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);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace nocal::sync
|
||||
Reference in New Issue
Block a user