diff --git a/README.md b/README.md index 6adff74..8e8afb6 100644 --- a/README.md +++ b/README.md @@ -146,9 +146,22 @@ is ignored when matching a `localhost` redirect, allowing a fresh local port; an HTTP `127.0.0.1` redirect requires manifest editing, while IPv6 loopback redirects are currently unsupported. -This remains foundation, not working Office 365 integration. There is no token -JSON parser, Secret Service backend, Graph call, account UI, or usable account -setup yet. OAuth tokens and other secrets will never be stored in SQLite. The -synchronous libsecret API may block, so its concrete adapter will run off the -render thread in the next phase. Read-only Microsoft Graph calendar discovery and delta -synchronization follows that token and secret-storage work. +The token and Secret Service boundary parses token responses with strict size +bounds. Initial authorization must return a refresh +token; when Microsoft rotates it, the replacement is retained, while a refresh +response that omits one keeps the previously stored refresh token. Tokens live +only in a versioned libsecret payload keyed by an opaque local account ID. They +never enter SQLite, logs, command-line arguments, or environment variables. + +libsecret operations are synchronous but cancellable and must run on a worker +thread, never the render thread. Isolated D-Bus and gnome-keyring tests exercise +the real adapter. A missing secret means the account is disconnected; locked, +cancelled, and storage failures remain distinct errors and never fall back to +another storage channel. Nocal does not mark an account connected until the +complete secret has been stored successfully. + +This remains foundation, not working Office 365 integration. There is still no +Microsoft Graph call, account UI, or usable account setup. The next slice is a +Microsoft adapter that calls `/me`, discovers +calendars, and performs read-only delta synchronization with delegated +`Calendars.Read`. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 6f51fef..470f097 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -215,8 +215,32 @@ the application manifest, and IPv6 loopback redirect addresses are currently unsupported. The advertised `localhost` URL therefore remains distinct from the IPv4-only listener binding. -Concrete token JSON parsing, Secret Service storage, Graph requests, account UI, -and usable account setup are still absent. libsecret's synchronous calls may -block; the next adapter isolates them behind an off-render-thread boundary -rather than calling them from the TUI loop. Read-only Graph discovery and delta -follow token parsing and secret persistence. +### Token and Secret Service boundary + +The token decoder is strict and +bounded: oversized or structurally invalid JSON, wrong field types, and invalid +token lifetimes fail before any credential is exposed to account state. Initial +authorization requires a refresh token. A later refresh response replaces it +when Microsoft supplies a rotated token and otherwise retains the previously +stored refresh token. + +Access and refresh tokens are serialized together in a versioned libsecret +secret payload, addressed by an opaque local account identifier. SQLite stores +neither token material nor a copy of the secret payload. Token values also stay +out of logs, command-line arguments, and environment variables. Schema/version +validation prevents an incompatible secret from being mistaken for current +credentials. + +The libsecret adapter exposes synchronous, cancellable operations and is +worker-thread-only because a D-Bus call or keyring unlock may block. Tests run +the real adapter against an isolated D-Bus session and gnome-keyring rather than +claiming integration from an in-memory fake alone. Missing credentials are a +normal disconnected result. A locked collection, cancellation, parse error, or +read/write/delete failure remains an explicit failure; none triggers plaintext +fallback or partial connected state. An account becomes connected only after +the complete versioned payload has been stored successfully. + +Graph requests, account UI, and usable account setup remain absent. After this +boundary passes verification, the Microsoft adapter will call `/me`, discover +calendars, and apply read-only delta pages through the durable cache using only +delegated `Calendars.Read`. diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md index 46ff0d0..aef65b8 100644 --- a/docs/PRODUCT.md +++ b/docs/PRODUCT.md @@ -154,12 +154,26 @@ client with `http://localhost/nocal/oauth/callback`. Microsoft ignores the port when matching localhost redirects. Literal HTTP `127.0.0.1` registration needs a manifest edit, and IPv6 loopback redirects are currently unsupported. -These adapters still do not make Office 365 usable. They include no token JSON -parser, Secret Service backend, Graph call, or account UI. libsecret exposes -synchronous operations that may block, so the next phase isolates its adapter -off the render thread. Token parsing and secure persistence come before -read-only Graph calendar discovery and delta synchronization. Remote writes -remain gated on the durability and conflict paths being exercised end to end. +The token and Secret Service boundary strictly parses +bounded token JSON and requires a refresh token at initial authorization. A +rotated refresh token replaces the stored value; if a refresh response omits +one, Nocal retains the prior refresh token. Access and refresh tokens are stored +only as a versioned libsecret secret keyed by an opaque local account ID. They +never appear in SQLite, logs, command lines, or environment variables. + +Secret Service operations are synchronous but cancellable and run only on a +worker thread so a D-Bus or keyring wait cannot block rendering. The real +adapter is exercised against isolated D-Bus and gnome-keyring services. Missing +credentials leave an account disconnected. Locked-keyring, cancellation, +parsing, and storage failures are reported distinctly, with no plaintext +fallback and no partially connected account. Connection state is published +only after the complete secret store succeeds. + +This phase still does not make Office 365 usable. It contains no Microsoft Graph +call, account UI, or usable account setup. After verification, the next product +slice calls `/me`, discovers calendars, and performs read-only Graph delta +synchronization using delegated `Calendars.Read`. Remote writes remain gated on +the durability and conflict paths being exercised end to end. ## Local file interchange diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index b7af389..f0d9c7c 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -57,11 +57,23 @@ Completed concrete Linux adapters: while literal HTTP `127.0.0.1` needs a manifest edit and IPv6 loopback is not supported +Completed token/secret boundary: + +- Strict, bounded token JSON parsing with a required initial refresh token +- Refresh-token rotation with retention of the previous token when a refresh + response omits a replacement +- Versioned libsecret payloads keyed by opaque local account IDs +- Synchronous, cancellable Secret Service calls restricted to worker threads +- Real isolated D-Bus/gnome-keyring integration tests +- Explicit missing, locked, cancelled, and failure results; no connected account + until the complete secret store succeeds +- OAuth secrets only in Secret Service, never SQLite, logs, CLI arguments, or + environment variables + Next provider-boundary slices: -- Token JSON parsing and a Secret Service adapter isolated off the render thread - because synchronous libsecret calls may block -- OAuth secrets in Secret Service, never SQLite +- Microsoft `/me` identity and read-only calendar discovery +- Read-only Graph delta synchronization using delegated `Calendars.Read` - Generic `SyncProvider` contract and observable sync status ## 0.3 — CalDAV @@ -78,10 +90,10 @@ Next provider-boundary slices: - Account/calendar management UI and per-calendar ANSI identity The cache, generic HTTP/OAuth boundary, and concrete Linux adapters are -prerequisites, not usable Microsoft 365 integration. There is still no token -parser, Secret Service backend, Graph call, or account UI. The immediate sequence is token parsing plus an -off-render-thread Secret Service adapter, then read-only Microsoft Graph -discovery and delta synchronization. +prerequisites, not usable Microsoft 365 integration. The token/Secret +Service phase still provides no Graph call, account UI, or usable account setup. +After it passes verification, the immediate sequence is Microsoft `/me`, +read-only calendar discovery, and Graph delta synchronization. ## 1.0 — distribution quality diff --git a/include/nocal/sync/oauth_tokens.hpp b/include/nocal/sync/oauth_tokens.hpp new file mode 100644 index 0000000..45a0b21 --- /dev/null +++ b/include/nocal/sync/oauth_tokens.hpp @@ -0,0 +1,25 @@ +#pragma once + +#include +#include +#include + +namespace nocal::sync { + +struct OAuthTokens { + std::string access_token; + std::string refresh_token; + std::string token_type; + std::string scope; + std::int64_t expires_at_epoch_seconds{0}; + + bool operator==(const OAuthTokens&) const = default; +}; + +// Parses a successful OAuth token response. A refresh response may omit its +// refresh token only when retained_refresh_token is supplied. Token material is +// never included in an exception message. +[[nodiscard]] OAuthTokens parse_oauth_token_response(std::string_view response_body, + std::int64_t now_epoch_seconds, std::string_view retained_refresh_token = {}); + +} // namespace nocal::sync diff --git a/include/nocal/sync/secret_store.hpp b/include/nocal/sync/secret_store.hpp new file mode 100644 index 0000000..9c90629 --- /dev/null +++ b/include/nocal/sync/secret_store.hpp @@ -0,0 +1,50 @@ +#pragma once + +#include "nocal/sync/oauth_tokens.hpp" + +#include +#include +#include +#include + +namespace nocal::sync { + +class SecretStoreError : public std::runtime_error { +public: + enum class Kind { + invalid_input, + unavailable, + cancelled, + corrupt_secret, + operation_failed, + }; + + SecretStoreError(Kind kind, std::string message); + [[nodiscard]] Kind kind() const noexcept; + +private: + Kind kind_; +}; + +class OAuthSecretStore { +public: + virtual ~OAuthSecretStore() = default; + virtual void store(std::string account_id, const OAuthTokens& tokens, + std::stop_token stop = {}) = 0; + [[nodiscard]] virtual std::optional load( + std::string account_id, std::stop_token stop = {}) = 0; + virtual void erase(std::string account_id, std::stop_token stop = {}) = 0; +}; + +// Synchronous libsecret adapter. Call only from a worker thread: Secret Service +// activation, unlocking, and user prompts can block independently of the TUI. +class LibsecretOAuthSecretStore final : public OAuthSecretStore { +public: + void store(std::string account_id, const OAuthTokens& tokens, + std::stop_token stop = {}) override; + [[nodiscard]] std::optional load( + std::string account_id, std::stop_token stop = {}) override; + void erase(std::string account_id, std::stop_token stop = {}) override; +}; + +} // namespace nocal::sync diff --git a/meson.build b/meson.build index d847f4e..c4f7ce9 100644 --- a/meson.build +++ b/meson.build @@ -15,7 +15,10 @@ inc = include_directories('include') sqlite = dependency('sqlite3') libcrypto = dependency('libcrypto') libcurl = dependency('libcurl') +libsecret = dependency('libsecret-1') +nlohmann_json = dependency('nlohmann_json') threads = dependency('threads') +shell = find_program('sh') nocal_sources = files( 'src/domain/calendar_transfer.cpp', 'src/domain/date.cpp', @@ -26,15 +29,17 @@ nocal_sources = files( 'src/sync/curl_http.cpp', 'src/sync/desktop_oauth.cpp', 'src/sync/oauth.cpp', + 'src/sync/oauth_tokens.cpp', + 'src/sync/secret_store.cpp', 'src/tui/Screen.cpp', 'src/tui/EventEditor.cpp', 'src/tui/Terminal.cpp', 'src/tui/TuiApp.cpp', ) nocal_lib = static_library('nocal-core', nocal_sources, include_directories: inc, - dependencies: [sqlite, libcrypto, libcurl]) + dependencies: [sqlite, libcrypto, libcurl, libsecret, nlohmann_json]) nocal_dep = declare_dependency(include_directories: inc, link_with: nocal_lib, - dependencies: [sqlite, libcrypto, libcurl]) + dependencies: [sqlite, libcrypto, libcurl, libsecret, nlohmann_json]) nocal_executable = executable('nocal', 'src/main.cpp', dependencies: nocal_dep, install: true) @@ -63,6 +68,13 @@ test('curl-http', curl_http_tests) desktop_oauth_tests = executable('desktop-oauth-tests', 'tests/desktop_oauth_tests.cpp', dependencies: [nocal_dep, threads]) test('desktop-oauth', desktop_oauth_tests) +oauth_token_tests = executable('oauth-token-tests', + 'tests/oauth_token_tests.cpp', dependencies: nocal_dep) +test('oauth-tokens', oauth_token_tests) +secret_store_tests = executable('secret-store-tests', + 'tests/secret_store_tests.cpp', dependencies: nocal_dep) +test('secret-store', shell, + args: [files('tests/run_secret_store_test.sh'), secret_store_tests]) tui_tests = executable('tui-tests', 'tests/tui_tests.cpp', dependencies: nocal_dep) test('tui', tui_tests) @@ -73,7 +85,6 @@ editor_tests = executable('editor-tests', 'tests/editor_tests.cpp', dependencies: nocal_dep) test('editor', editor_tests) -shell = find_program('sh') test('cli-recovery', shell, args: [files('tests/cli_recovery_test.sh'), nocal_executable]) test('cli-transfer', shell, diff --git a/shell.nix b/shell.nix index f781cf8..18948f2 100644 --- a/shell.nix +++ b/shell.nix @@ -9,7 +9,11 @@ pkgs.mkShell { buildInputs = with pkgs; [ curl + dbus gcc + gnome-keyring + libsecret + nlohmann_json openssl sqlite xdg-utils diff --git a/src/sync/oauth_tokens.cpp b/src/sync/oauth_tokens.cpp new file mode 100644 index 0000000..9897deb --- /dev/null +++ b/src/sync/oauth_tokens.cpp @@ -0,0 +1,188 @@ +#include "nocal/sync/oauth_tokens.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace nocal::sync { +namespace { + +constexpr std::size_t maximum_response_size = 1024U * 1024U; + +class DuplicateKey final : public std::exception {}; + +[[noreturn]] void invalid_response() { + throw std::invalid_argument("invalid OAuth token response"); +} + +[[nodiscard]] bool contains_ascii_control(std::string_view value) { + return std::ranges::any_of(value, [](unsigned char character) { + return character < 0x20U || character == 0x7fU; + }); +} + +[[nodiscard]] bool ascii_iequals(std::string_view left, std::string_view right) { + if (left.size() != right.size()) { + return false; + } + for (std::size_t index = 0; index < left.size(); ++index) { + const auto lower = [](unsigned char value) { + return value >= 'A' && value <= 'Z' ? static_cast(value + ('a' - 'A')) + : value; + }; + if (lower(static_cast(left[index])) + != lower(static_cast(right[index]))) { + return false; + } + } + return true; +} + +[[nodiscard]] nlohmann::json parse_unique_object(std::string_view response_body) { + if (response_body.empty() || response_body.size() > maximum_response_size) { + invalid_response(); + } + std::vector> 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()).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; + }; + try { + nlohmann::json parsed = nlohmann::json::parse( + response_body.begin(), response_body.end(), callback, true, false); + if (!object_keys.empty() || !parsed.is_object()) { + invalid_response(); + } + return parsed; + } catch (const DuplicateKey&) { + invalid_response(); + } catch (const nlohmann::json::exception&) { + invalid_response(); + } +} + +[[nodiscard]] std::string required_nonempty_string( + const nlohmann::json& object, std::string_view name) { + const auto found = object.find(std::string(name)); + if (found == object.end() || !found->is_string()) { + invalid_response(); + } + const std::string value = found->get(); + if (value.empty() || contains_ascii_control(value)) { + invalid_response(); + } + return value; +} + +[[nodiscard]] std::string optional_nonempty_string( + const nlohmann::json& object, std::string_view name) { + const auto found = object.find(std::string(name)); + if (found == object.end()) { + return {}; + } + if (!found->is_string()) { + invalid_response(); + } + const std::string value = found->get(); + if (value.empty() || contains_ascii_control(value)) { + invalid_response(); + } + return value; +} + +[[nodiscard]] std::uint64_t positive_integral_seconds(const nlohmann::json& object) { + const auto found = object.find("expires_in"); + if (found == object.end()) { + invalid_response(); + } + if (found->is_number_unsigned()) { + const std::uint64_t value = found->get(); + if (value == 0) { + invalid_response(); + } + return value; + } + if (found->is_number_integer()) { + const std::int64_t value = found->get(); + if (value <= 0) { + invalid_response(); + } + return static_cast(value); + } + invalid_response(); +} + +[[nodiscard]] std::int64_t add_expiration( + std::int64_t now_epoch_seconds, std::uint64_t expires_in) { + constexpr std::int64_t maximum = std::numeric_limits::max(); + if (now_epoch_seconds >= 0) { + const auto room = static_cast(maximum - now_epoch_seconds); + if (expires_in > room) { + invalid_response(); + } + return now_epoch_seconds + static_cast(expires_in); + } + + const std::uint64_t negative_magnitude = + static_cast(-(now_epoch_seconds + 1)) + 1U; + const std::uint64_t room = static_cast(maximum) + negative_magnitude; + if (expires_in > room) { + invalid_response(); + } + if (expires_in >= negative_magnitude) { + return static_cast(expires_in - negative_magnitude); + } + const std::uint64_t remaining_magnitude = negative_magnitude - expires_in; + if (remaining_magnitude == (std::uint64_t{1} << 63U)) { + return std::numeric_limits::min(); + } + return -static_cast(remaining_magnitude); +} + +} // namespace + +OAuthTokens parse_oauth_token_response(std::string_view response_body, + std::int64_t now_epoch_seconds, std::string_view retained_refresh_token) { + const nlohmann::json object = parse_unique_object(response_body); + OAuthTokens result; + result.access_token = required_nonempty_string(object, "access_token"); + const std::string token_type = required_nonempty_string(object, "token_type"); + if (!ascii_iequals(token_type, "Bearer")) { + invalid_response(); + } + result.token_type = "Bearer"; + result.scope = optional_nonempty_string(object, "scope"); + result.refresh_token = optional_nonempty_string(object, "refresh_token"); + if (result.refresh_token.empty()) { + if (retained_refresh_token.empty() || contains_ascii_control(retained_refresh_token)) { + invalid_response(); + } + result.refresh_token = std::string(retained_refresh_token); + } + result.expires_at_epoch_seconds = + add_expiration(now_epoch_seconds, positive_integral_seconds(object)); + return result; +} + +} // namespace nocal::sync diff --git a/src/sync/secret_store.cpp b/src/sync/secret_store.cpp new file mode 100644 index 0000000..6f3222d --- /dev/null +++ b/src/sync/secret_store.cpp @@ -0,0 +1,350 @@ +#include "nocal/sync/secret_store.hpp" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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> 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()).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::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>( + 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> 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() != 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(); + tokens.refresh_token = document["refresh_token"].get(); + tokens.token_type = document["token_type"].get(); + tokens.scope = document["scope"].get(); + tokens.expires_at_epoch_seconds = + document["expires_at_epoch_seconds"].get(); + 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 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::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(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 diff --git a/tests/oauth_token_tests.cpp b/tests/oauth_token_tests.cpp new file mode 100644 index 0000000..a57c7e7 --- /dev/null +++ b/tests/oauth_token_tests.cpp @@ -0,0 +1,220 @@ +#include "nocal/sync/oauth_tokens.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using nocal::sync::OAuthTokens; +using nocal::sync::parse_oauth_token_response; + +constexpr std::size_t maximum_response_size = 1024U * 1024U; + +void require(bool condition, std::string_view message) { + if (!condition) { + throw std::runtime_error(std::string(message)); + } +} + +template +std::string expect_failure(Action&& action) { + try { + action(); + } catch (const std::exception& error) { + return error.what(); + } + throw std::runtime_error("invalid OAuth token response was accepted"); +} + +[[nodiscard]] std::string token_body(std::string_view extra = {}) { + return std::string{"{\"access_token\":\"access\",\"refresh_token\":\"refresh\","} + + "\"token_type\":\"Bearer\",\"expires_in\":3600" + std::string(extra) + "}"; +} + +void test_success_and_unknown_fields() { + const OAuthTokens tokens = parse_oauth_token_response( + token_body(",\"scope\":\"openid offline_access\",\"unknown\":{\"nested\":true}"), + 1'000); + require(tokens + == OAuthTokens{"access", "refresh", "Bearer", "openid offline_access", 4'600}, + "valid token response was not parsed exactly"); + + const OAuthTokens lower = parse_oauth_token_response( + "{\"access_token\":\"a\",\"refresh_token\":\"r\",\"token_type\":\"bEaReR\"," + "\"expires_in\":1}", + -2); + require(lower.token_type == "Bearer" && lower.expires_at_epoch_seconds == -1, + "Bearer comparison/canonicalization or negative epoch arithmetic failed"); +} + +void test_retained_and_rotated_refresh_tokens() { + const OAuthTokens retained = parse_oauth_token_response( + "{\"access_token\":\"new-access\",\"token_type\":\"Bearer\",\"expires_in\":60}", + 100, "old-refresh"); + require(retained.refresh_token == "old-refresh" && retained.expires_at_epoch_seconds == 160, + "refresh response did not retain the prior refresh token"); + + const OAuthTokens rotated = parse_oauth_token_response( + "{\"access_token\":\"new-access\",\"refresh_token\":\"new-refresh\"," + "\"token_type\":\"Bearer\",\"expires_in\":60}", + 100, "old-refresh"); + require(rotated.refresh_token == "new-refresh", + "rotated refresh token did not replace the retained token"); + + expect_failure([&] { + (void)parse_oauth_token_response( + "{\"access_token\":\"a\",\"token_type\":\"Bearer\",\"expires_in\":1}", + 0); + }); + expect_failure([&] { + (void)parse_oauth_token_response( + "{\"access_token\":\"a\",\"refresh_token\":\"\"," + "\"token_type\":\"Bearer\",\"expires_in\":1}", + 0, "retained"); + }); +} + +void test_expiration_boundaries_and_integral_types() { + const auto maximum = std::numeric_limits::max(); + const auto minimum = std::numeric_limits::min(); + require(parse_oauth_token_response(token_body(",\"scope\":\"s\""), maximum - 3'600) + .expires_at_epoch_seconds + == maximum, + "maximum signed expiration boundary was rejected"); + require(parse_oauth_token_response( + "{\"access_token\":\"a\",\"refresh_token\":\"r\"," + "\"token_type\":\"Bearer\",\"expires_in\":18446744073709551615}", + minimum) + .expires_at_epoch_seconds + == maximum, + "full unsigned expiration boundary was not added safely"); + require(parse_oauth_token_response( + "{\"access_token\":\"a\",\"refresh_token\":\"r\"," + "\"token_type\":\"Bearer\",\"expires_in\":1}", + minimum) + .expires_at_epoch_seconds + == minimum + 1, + "minimum signed epoch boundary was not added safely"); + + expect_failure([&] { (void)parse_oauth_token_response(token_body(), maximum); }); + for (const std::string expires : {"0", "-1", "1.0", "1e3", "\"1\"", "true", "null", + "18446744073709551616"}) { + const std::string body = + "{\"access_token\":\"a\",\"refresh_token\":\"r\"," + "\"token_type\":\"Bearer\",\"expires_in\":" + + expires + "}"; + expect_failure([&] { (void)parse_oauth_token_response(body, 0); }); + } +} + +void test_wrong_types_and_required_values() { + const std::vector invalid = {"{}", "[]", "null", "true", + "{\"access_token\":1,\"refresh_token\":\"r\",\"token_type\":\"Bearer\"," + "\"expires_in\":1}", + "{\"access_token\":\"\",\"refresh_token\":\"r\",\"token_type\":\"Bearer\"," + "\"expires_in\":1}", + "{\"access_token\":\"a\",\"refresh_token\":1,\"token_type\":\"Bearer\"," + "\"expires_in\":1}", + "{\"access_token\":\"a\",\"refresh_token\":\"r\",\"token_type\":1," + "\"expires_in\":1}", + "{\"access_token\":\"a\",\"refresh_token\":\"r\",\"token_type\":\"Basic\"," + "\"expires_in\":1}", + "{\"access_token\":\"a\",\"refresh_token\":\"r\",\"token_type\":\"Bearer\"," + "\"expires_in\":1,\"scope\":\"\"}", + "{\"access_token\":\"a\",\"refresh_token\":\"r\",\"token_type\":\"Bearer\"," + "\"expires_in\":1,\"scope\":[]}"}; + for (const std::string& body : invalid) { + expect_failure([&] { (void)parse_oauth_token_response(body, 0); }); + } + expect_failure([&] { + (void)parse_oauth_token_response( + "{\"access_token\":\"a\\n\",\"refresh_token\":\"r\"," + "\"token_type\":\"Bearer\",\"expires_in\":1}", 0); + }); +} + +void test_duplicate_keys_at_every_depth() { + const std::vector duplicates = { + "{\"access_token\":\"first\",\"access_token\":\"second\"," + "\"refresh_token\":\"r\",\"token_type\":\"Bearer\",\"expires_in\":1}", + "{\"access_token\":\"a\",\"refresh_token\":\"r\"," + "\"token_type\":\"Bearer\",\"expires_in\":1,\"unknown\":1,\"unknown\":2}", + "{\"access_token\":\"a\",\"refresh_token\":\"r\"," + "\"token_type\":\"Bearer\",\"expires_in\":1," + "\"unknown\":{\"duplicate\":1,\"duplicate\":2}}"}; + for (const std::string& body : duplicates) { + expect_failure([&] { (void)parse_oauth_token_response(body, 0); }); + } +} + +void test_invalid_json_utf8_size_and_redaction() { + std::vector invalid = {"{", "not-json"}; + invalid.push_back(std::string("{\"access_token\":\"") + static_cast(0xff) + + "\",\"refresh_token\":\"r\",\"token_type\":\"Bearer\",\"expires_in\":1}"); + for (const std::string& body : invalid) { + const std::string message = expect_failure( + [&] { (void)parse_oauth_token_response(body, 0); }); + require(message.find(body) == std::string::npos, + "parser error leaked the invalid response body"); + } + + const std::string prefix = + "{\"access_token\":\"BOUNDARY_ACCESS_TOKEN\"," + "\"refresh_token\":\"BOUNDARY_REFRESH_TOKEN\"," + "\"token_type\":\"Bearer\",\"expires_in\":1,\"padding\":\""; + const std::string suffix = "\"}"; + std::string exact = prefix; + exact.append(maximum_response_size - prefix.size() - suffix.size(), 'x'); + exact += suffix; + require(parse_oauth_token_response(exact, 0).access_token == "BOUNDARY_ACCESS_TOKEN", + "exactly 1 MiB token response was rejected"); + + std::string oversized = exact; + oversized.push_back(' '); + const std::string oversized_message = + expect_failure([&] { (void)parse_oauth_token_response(oversized, 0); }); + require(oversized_message.find("BOUNDARY") == std::string::npos, + "size parser error leaked token material"); + + const std::string malicious_body = + "{\"access_token\":\"MALICIOUS_ACCESS_TOKEN\"," + "\"refresh_token\":\"MALICIOUS_REFRESH_TOKEN\"," + "\"token_type\":\"Basic\",\"expires_in\":1}"; + const std::string malicious_message = + expect_failure([&] { (void)parse_oauth_token_response(malicious_body, 0); }); + require(malicious_message.find("MALICIOUS") == std::string::npos, + "semantic parser error leaked token material"); + + const std::string retained_secret = "MALICIOUS_RETAINED_TOKEN"; + const std::string retained_message = expect_failure([&] { + (void)parse_oauth_token_response( + "{\"access_token\":\"a\",\"token_type\":\"Basic\",\"expires_in\":1}", + 0, retained_secret); + }); + require(retained_message.find(retained_secret) == std::string::npos, + "parser error leaked retained refresh token"); +} + +} // namespace + +int main() { + try { + test_success_and_unknown_fields(); + test_retained_and_rotated_refresh_tokens(); + test_expiration_boundaries_and_integral_types(); + test_wrong_types_and_required_values(); + test_duplicate_keys_at_every_depth(); + test_invalid_json_utf8_size_and_redaction(); + } catch (const std::exception& error) { + std::cerr << "OAuth token tests failed: " << error.what() << '\n'; + return 1; + } + std::cout << "OAuth token tests passed\n"; + return 0; +} diff --git a/tests/run_secret_store_test.sh b/tests/run_secret_store_test.sh new file mode 100755 index 0000000..303f67a --- /dev/null +++ b/tests/run_secret_store_test.sh @@ -0,0 +1,52 @@ +#!/bin/sh + +set -eu + +if [ "$#" -ne 1 ] || [ ! -x "$1" ]; then + echo "secret-store test wrapper requires one executable" >&2 + exit 2 +fi + +test_binary=$1 + +exec dbus-run-session -- sh -eu -c ' +test_binary=$1 +test_root=$(mktemp -d "${TMPDIR:-/tmp}/nocal-secret-store.XXXXXX") +test_home=$test_root/home +test_runtime=$test_root/runtime +mkdir -m 700 "$test_home" "$test_runtime" + +keyring_pid= +cleanup() { + if [ -n "$keyring_pid" ]; then + kill "$keyring_pid" 2>/dev/null || true + wait "$keyring_pid" 2>/dev/null || true + fi + rm -rf "$test_root" +} +trap cleanup EXIT HUP INT TERM + +export HOME=$test_home +export XDG_CONFIG_HOME=$test_home/.config +export XDG_DATA_HOME=$test_home/.local/share +export XDG_CACHE_HOME=$test_home/.cache +export XDG_RUNTIME_DIR=$test_runtime +mkdir -p "$XDG_CONFIG_HOME" "$XDG_DATA_HOME/keyrings" "$XDG_CACHE_HOME" + +password_pipe=$test_root/keyring-password +mkfifo "$password_pipe" +gnome-keyring-daemon --foreground --unlock --components=secrets \ + <"$password_pipe" >"$test_root/keyring.out" 2>"$test_root/keyring.err" & +keyring_pid=$! +printf "\n" >"$password_pipe" +rm "$password_pipe" + +if ! gdbus wait --session --timeout 5 org.freedesktop.secrets \ + || ! kill -0 "$keyring_pid" 2>/dev/null; then + sed -n "1,80p" "$test_root/keyring.err" >&2 || true + echo "gnome-keyring Secret Service failed to start" >&2 + exit 1 +fi + +"$test_binary" +' sh "$test_binary" diff --git a/tests/secret_store_tests.cpp b/tests/secret_store_tests.cpp new file mode 100644 index 0000000..d16e8a2 --- /dev/null +++ b/tests/secret_store_tests.cpp @@ -0,0 +1,415 @@ +#include "nocal/sync/secret_store.hpp" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using nocal::sync::LibsecretOAuthSecretStore; +using nocal::sync::OAuthTokens; +using nocal::sync::SecretStoreError; + +void require(bool condition, std::string_view message) { + if (!condition) { + throw std::runtime_error(std::string{message}); + } +} + +template +void require_error(Function&& function, SecretStoreError::Kind kind, + std::string_view message, std::span sensitive = {}) { + try { + function(); + } catch (const SecretStoreError& error) { + require(error.kind() == kind, std::string{message}.append(": wrong error kind")); + for (const auto value : sensitive) { + require(value.empty() || std::string_view{error.what()}.find(value) == std::string_view::npos, + std::string{message}.append(": sensitive value leaked in exception")); + } + return; + } catch (const std::exception&) { + throw std::runtime_error(std::string{message}.append(": wrong exception type")); + } + throw std::runtime_error(std::string{message}.append(": no exception")); +} + +class TestSchema { +public: + TestSchema() + : schema_(secret_schema_new("dev.nomarchy.nocal.oauth", SECRET_SCHEMA_NONE, "account", + SECRET_SCHEMA_ATTRIBUTE_STRING, nullptr)) { + require(schema_ != nullptr, "could not allocate test secret schema"); + } + + ~TestSchema() { secret_schema_unref(schema_); } + TestSchema(const TestSchema&) = delete; + TestSchema& operator=(const TestSchema&) = delete; + + [[nodiscard]] const SecretSchema* get() const noexcept { return schema_; } + +private: + SecretSchema* schema_; +}; + +class ErrorHolder { +public: + ~ErrorHolder() { + if (error_ != nullptr) { + g_error_free(error_); + } + } + [[nodiscard]] GError** output() noexcept { return &error_; } + [[nodiscard]] const GError* get() const noexcept { return error_; } + +private: + GError* error_{nullptr}; +}; + +void secure_zero(char* bytes, std::size_t size) noexcept { + volatile char* output = bytes; + while (size > 0) { + *output++ = 0; + --size; + } +} + +class RawSecret { +public: + explicit RawSecret(char* value) : value_(value) {} + ~RawSecret() { + if (value_ != nullptr) { + secure_zero(value_, std::char_traits::length(value_)); + secret_password_free(value_); + } + } + [[nodiscard]] const char* get() const noexcept { return value_; } + +private: + char* value_; +}; + +[[nodiscard]] OAuthTokens tokens(std::string marker) { + return OAuthTokens{"access-" + marker, "refresh-" + marker, "Bearer", + "openid offline_access Calendars.ReadWrite", 2'000'000'000}; +} + +void inject_secret(std::string_view account, std::string_view value) { + TestSchema schema; + ErrorHolder error; + const gboolean stored = secret_password_store_sync(schema.get(), SECRET_COLLECTION_DEFAULT, + std::string{account}.c_str(), std::string{value}.c_str(), nullptr, error.output(), "account", + std::string{account}.c_str(), nullptr); + require(stored && error.get() == nullptr, "could not inject raw libsecret fixture"); +} + +[[nodiscard]] std::string lookup_raw(std::string_view account) { + TestSchema schema; + ErrorHolder error; + const std::string owned_account{account}; + RawSecret secret{secret_password_lookup_sync( + schema.get(), nullptr, error.output(), "account", owned_account.c_str(), nullptr)}; + require(error.get() == nullptr && secret.get() != nullptr, + "could not inspect stored JSON secret"); + return secret.get(); +} + +void test_crud_isolation_and_versioned_secret(std::string_view run_id) { + LibsecretOAuthSecretStore store; + const std::string account_a = "account-a-" + std::string{run_id}; + const std::string account_b = "account-b-" + std::string{run_id}; + store.erase(account_a); + store.erase(account_b); + require(!store.load(account_a).has_value(), "missing secret did not load as nullopt"); + store.erase(account_a); + + const OAuthTokens first = tokens("first-" + std::string{run_id}); + const OAuthTokens second = tokens("second-" + std::string{run_id}); + store.store(account_a, first); + store.store(account_b, second); + require(store.load(account_a) == std::optional{first}, "stored account A did not round-trip"); + require(store.load(account_b) == std::optional{second}, "stored account B did not round-trip"); + + const std::string serialized = lookup_raw(account_a); + require(serialized.size() <= 64U * 1024U, "serialized secret exceeds its bound"); + const nlohmann::json document = nlohmann::json::parse(serialized); + require(document.is_object() && document.size() == 6 && document.at("version") == 1, + "stored secret is not the exact versioned JSON shape"); + require(document.at("access_token") == first.access_token + && document.at("refresh_token") == first.refresh_token, + "stored JSON did not contain the expected token payload"); + + OAuthTokens updated = tokens("updated-\"\\-" + std::string{run_id}); + updated.scope.clear(); + store.store(account_a, updated); + require(store.load(account_a) == std::optional{updated}, + "credential update or valid empty scope did not round-trip"); + require(store.load(account_b) == std::optional{second}, "account update crossed account boundary"); + + store.erase(account_a); + require(!store.load(account_a).has_value(), "erase did not remove credential"); + require(store.load(account_b) == std::optional{second}, "erase crossed account boundary"); + store.erase(account_a); + store.erase(account_b); +} + +void test_label_and_attributes(std::string_view run_id) { + LibsecretOAuthSecretStore store; + const std::string account = "opaque-account-" + std::string{run_id}; + const OAuthTokens value = tokens("attribute-secret-" + std::string{run_id}); + store.store(account, value); + + TestSchema schema; + ErrorHolder error; + SecretService* service = secret_service_get_sync(SECRET_SERVICE_LOAD_COLLECTIONS, nullptr, + error.output()); + require(service != nullptr && error.get() == nullptr, "could not inspect Secret Service"); + GHashTable* query = + secret_attributes_build(schema.get(), "account", account.c_str(), nullptr); + require(query != nullptr, "could not build Secret Service attribute query"); + GList* items = secret_service_search_sync(service, schema.get(), query, SECRET_SEARCH_ALL, + nullptr, error.output()); + g_hash_table_unref(query); + require(error.get() == nullptr && g_list_length(items) == 1, + "stored credential did not have one matching Secret Service item"); + SecretItem* item = SECRET_ITEM(items->data); + char* label = secret_item_get_label(item); + require(label != nullptr && std::string_view{label} == account, + "Secret Service label contains more than the opaque account ID"); + require(std::string_view{label}.find(value.access_token) == std::string_view::npos, + "token leaked into Secret Service label"); + g_free(label); + + GHashTable* attributes = secret_item_get_attributes(item); + require(attributes != nullptr, "could not inspect Secret Service attributes"); + GHashTableIter iterator; + gpointer key = nullptr; + gpointer attribute_value = nullptr; + g_hash_table_iter_init(&iterator, attributes); + while (g_hash_table_iter_next(&iterator, &key, &attribute_value)) { + const std::string_view name{static_cast(key)}; + const std::string_view stored_value{static_cast(attribute_value)}; + require(name.find(value.access_token) == std::string_view::npos + && stored_value.find(value.access_token) == std::string_view::npos, + "token leaked into Secret Service attributes"); + if (name == "account") { + require(stored_value == account, "account attribute has the wrong value"); + } + } + g_hash_table_unref(attributes); + g_list_free_full(items, g_object_unref); + g_object_unref(service); + store.erase(account); +} + +void test_invalid_inputs_and_preservation(std::string_view run_id) { + LibsecretOAuthSecretStore store; + const std::string account = "validation-account-" + std::string{run_id}; + const OAuthTokens original = tokens("original-" + std::string{run_id}); + store.store(account, original); + + const std::array invalid_accounts{ + "", std::string{"bad\0account", 11}, "bad\naccount"}; + for (const auto& invalid : invalid_accounts) { + const std::array sensitive{account, original.access_token}; + require_error([&] { store.store(invalid, original); }, + SecretStoreError::Kind::invalid_input, "invalid account accepted", sensitive); + require_error([&] { static_cast(store.load(invalid)); }, + SecretStoreError::Kind::invalid_input, "invalid account load accepted", sensitive); + require_error([&] { store.erase(invalid); }, SecretStoreError::Kind::invalid_input, + "invalid account erase accepted", sensitive); + } + + std::vector invalid_tokens; + for (int field = 0; field < 3; ++field) { + OAuthTokens invalid = original; + if (field == 0) { + invalid.access_token.clear(); + } else if (field == 1) { + invalid.refresh_token.clear(); + } else { + invalid.token_type.clear(); + } + invalid_tokens.push_back(std::move(invalid)); + } + OAuthTokens wrong_type = original; + wrong_type.token_type = "bearer"; + invalid_tokens.push_back(std::move(wrong_type)); + OAuthTokens invalid_expiry = original; + invalid_expiry.expires_at_epoch_seconds = 0; + invalid_tokens.push_back(std::move(invalid_expiry)); + OAuthTokens negative_expiry = original; + negative_expiry.expires_at_epoch_seconds = -1; + invalid_tokens.push_back(std::move(negative_expiry)); + OAuthTokens control = original; + control.access_token = std::string{"secret\0suffix", 13}; + invalid_tokens.push_back(std::move(control)); + OAuthTokens scope_control = original; + scope_control.scope = "openid\noffline_access"; + invalid_tokens.push_back(std::move(scope_control)); + OAuthTokens oversized = original; + oversized.access_token.assign(65U * 1024U, 'x'); + invalid_tokens.push_back(std::move(oversized)); + + for (const auto& invalid : invalid_tokens) { + const std::array sensitive{account, original.access_token}; + require_error([&] { store.store(account, invalid); }, + SecretStoreError::Kind::invalid_input, "invalid token set accepted", sensitive); + require(store.load(account) == std::optional{original}, + "invalid update changed the previous credential"); + } + store.erase(account); +} + +void test_cancellation_preserves_value(std::string_view run_id) { + LibsecretOAuthSecretStore store; + const std::string account = "cancel-account-" + std::string{run_id}; + const OAuthTokens original = tokens("cancel-original-" + std::string{run_id}); + const OAuthTokens replacement = tokens("cancel-replacement-" + std::string{run_id}); + store.store(account, original); + std::stop_source cancelled; + cancelled.request_stop(); + const std::array sensitive{ + account, original.access_token, replacement.access_token}; + require_error([&] { store.store(account, replacement, cancelled.get_token()); }, + SecretStoreError::Kind::cancelled, "pre-cancelled update was accepted", sensitive); + require(store.load(account) == std::optional{original}, + "pre-cancelled update changed the previous credential"); + require_error([&] { static_cast(store.load(account, cancelled.get_token())); }, + SecretStoreError::Kind::cancelled, "pre-cancelled load was accepted", sensitive); + require_error([&] { store.erase(account, cancelled.get_token()); }, + SecretStoreError::Kind::cancelled, "pre-cancelled erase was accepted", sensitive); + require(store.load(account) == std::optional{original}, + "pre-cancelled erase removed the credential"); + store.erase(account); +} + +void test_corrupt_secret_redaction(std::string_view run_id) { + LibsecretOAuthSecretStore store; + const std::string account = "corrupt-account-" + std::string{run_id}; + const std::string corrupt = "{SENSITIVE_CORRUPT_SECRET_" + std::string{run_id}; + inject_secret(account, corrupt); + const std::array sensitive{account, corrupt}; + require_error([&] { static_cast(store.load(account)); }, + SecretStoreError::Kind::corrupt_secret, "corrupt stored secret was accepted", sensitive); + + inject_secret(account, + R"({"version":1,"access_token":"","refresh_token":"r","token_type":"Bearer","scope":"s","expires_at_epoch_seconds":1})"); + require_error([&] { static_cast(store.load(account)); }, + SecretStoreError::Kind::corrupt_secret, "incomplete stored token was accepted", sensitive); + inject_secret(account, + R"({"version":1,"version":1,"access_token":"a","refresh_token":"r","token_type":"Bearer","scope":"","expires_at_epoch_seconds":1})"); + require_error([&] { static_cast(store.load(account)); }, + SecretStoreError::Kind::corrupt_secret, "duplicate stored JSON key was accepted", sensitive); + store.erase(account); +} + +[[nodiscard]] std::string read_binary_file(const std::filesystem::path& path) { + std::ifstream input(path, std::ios::binary); + if (!input) { + return {}; + } + return {std::istreambuf_iterator{input}, {}}; +} + +void test_token_not_in_files_environment_or_argv(std::string_view run_id) { + LibsecretOAuthSecretStore store; + const std::string account = "leak-account-" + std::string{run_id}; + OAuthTokens value = tokens("PLAINTEXT_LEAK_MARKER_" + std::string{run_id}); + store.store(account, value); + const std::string marker = value.access_token; + + for (const auto proc_file : {"/proc/self/environ", "/proc/self/cmdline"}) { + require(read_binary_file(proc_file).find(marker) == std::string::npos, + "token leaked into process environment or argv"); + } + const char* home = std::getenv("HOME"); + require(home != nullptr && *home != '\0', "test wrapper did not isolate HOME"); + for (const auto& entry : std::filesystem::recursive_directory_iterator( + home, std::filesystem::directory_options::skip_permission_denied)) { + std::error_code error; + if (!entry.is_regular_file(error) || error || entry.file_size(error) > 16U * 1024U * 1024U) { + continue; + } + require(read_binary_file(entry.path()).find(marker) == std::string::npos, + "token appeared in plaintext in a Secret Service backing file"); + } + store.erase(account); +} + +int unavailable_helper() { + const std::string account = "SENSITIVE_UNAVAILABLE_ACCOUNT"; + try { + LibsecretOAuthSecretStore store; + static_cast(store.load(account)); + } catch (const SecretStoreError& error) { + if (error.kind() == SecretStoreError::Kind::unavailable + && std::string_view{error.what()}.find(account) == std::string_view::npos) { + return 0; + } + } catch (...) { + } + return 1; +} + +void test_unavailable_service_redaction() { + const pid_t child = ::fork(); + require(child >= 0, "could not fork unavailable-service helper"); + if (child == 0) { + static_cast(::setenv( + "DBUS_SESSION_BUS_ADDRESS", "unix:path=/tmp/nocal-nonexistent-secret-bus", 1)); + ::execl("/proc/self/exe", "/proc/self/exe", "--unavailable-helper", nullptr); + _exit(99); + } + int status = 0; + while (::waitpid(child, &status, 0) < 0) { + if (errno != EINTR) { + throw std::runtime_error("could not wait for unavailable-service helper"); + } + } + require(WIFEXITED(status) && WEXITSTATUS(status) == 0, + "unavailable Secret Service was not mapped to a redacted typed error"); +} + +} // namespace + +int main(int argc, char** argv) { + if (argc == 2 && std::string_view{argv[1]} == "--unavailable-helper") { + return unavailable_helper(); + } + try { + const std::string run_id = std::to_string(static_cast(::getpid())); + test_crud_isolation_and_versioned_secret(run_id); + test_label_and_attributes(run_id); + test_invalid_inputs_and_preservation(run_id); + test_cancellation_preserves_value(run_id); + test_corrupt_secret_redaction(run_id); + test_token_not_in_files_environment_or_argv(run_id); + test_unavailable_service_redaction(); + } catch (const std::exception& error) { + std::cerr << "secret store test failure: " << error.what() << '\n'; + return 1; + } + std::cout << "secret store tests passed\n"; + return 0; +}