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:
2026-07-18 11:56:04 +01:00
parent 295acbc125
commit 3560a7d23d
13 changed files with 1405 additions and 27 deletions

View File

@@ -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 an HTTP `127.0.0.1` redirect requires manifest editing, while IPv6 loopback
redirects are currently unsupported. redirects are currently unsupported.
This remains foundation, not working Office 365 integration. There is no token The token and Secret Service boundary parses token responses with strict size
JSON parser, Secret Service backend, Graph call, account UI, or usable account bounds. Initial authorization must return a refresh
setup yet. OAuth tokens and other secrets will never be stored in SQLite. The token; when Microsoft rotates it, the replacement is retained, while a refresh
synchronous libsecret API may block, so its concrete adapter will run off the response that omits one keeps the previously stored refresh token. Tokens live
render thread in the next phase. Read-only Microsoft Graph calendar discovery and delta only in a versioned libsecret payload keyed by an opaque local account ID. They
synchronization follows that token and secret-storage work. 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`.

View File

@@ -215,8 +215,32 @@ the application manifest, and IPv6 loopback redirect addresses are currently
unsupported. The advertised `localhost` URL therefore remains distinct from the unsupported. The advertised `localhost` URL therefore remains distinct from the
IPv4-only listener binding. IPv4-only listener binding.
Concrete token JSON parsing, Secret Service storage, Graph requests, account UI, ### Token and Secret Service boundary
and usable account setup are still absent. libsecret's synchronous calls may
block; the next adapter isolates them behind an off-render-thread boundary The token decoder is strict and
rather than calling them from the TUI loop. Read-only Graph discovery and delta bounded: oversized or structurally invalid JSON, wrong field types, and invalid
follow token parsing and secret persistence. 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`.

View File

@@ -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 when matching localhost redirects. Literal HTTP `127.0.0.1` registration needs
a manifest edit, and IPv6 loopback redirects are currently unsupported. a manifest edit, and IPv6 loopback redirects are currently unsupported.
These adapters still do not make Office 365 usable. They include no token JSON The token and Secret Service boundary strictly parses
parser, Secret Service backend, Graph call, or account UI. libsecret exposes bounded token JSON and requires a refresh token at initial authorization. A
synchronous operations that may block, so the next phase isolates its adapter rotated refresh token replaces the stored value; if a refresh response omits
off the render thread. Token parsing and secure persistence come before one, Nocal retains the prior refresh token. Access and refresh tokens are stored
read-only Graph calendar discovery and delta synchronization. Remote writes only as a versioned libsecret secret keyed by an opaque local account ID. They
remain gated on the durability and conflict paths being exercised end to end. 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 ## Local file interchange

View File

@@ -57,11 +57,23 @@ Completed concrete Linux adapters:
while literal HTTP `127.0.0.1` needs a manifest edit and IPv6 loopback is not while literal HTTP `127.0.0.1` needs a manifest edit and IPv6 loopback is not
supported 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: Next provider-boundary slices:
- Token JSON parsing and a Secret Service adapter isolated off the render thread - Microsoft `/me` identity and read-only calendar discovery
because synchronous libsecret calls may block - Read-only Graph delta synchronization using delegated `Calendars.Read`
- OAuth secrets in Secret Service, never SQLite
- Generic `SyncProvider` contract and observable sync status - Generic `SyncProvider` contract and observable sync status
## 0.3 — CalDAV ## 0.3 — CalDAV
@@ -78,10 +90,10 @@ Next provider-boundary slices:
- Account/calendar management UI and per-calendar ANSI identity - Account/calendar management UI and per-calendar ANSI identity
The cache, generic HTTP/OAuth boundary, and concrete Linux adapters are The cache, generic HTTP/OAuth boundary, and concrete Linux adapters are
prerequisites, not usable Microsoft 365 integration. There is still no token prerequisites, not usable Microsoft 365 integration. The token/Secret
parser, Secret Service backend, Graph call, or account UI. The immediate sequence is token parsing plus an Service phase still provides no Graph call, account UI, or usable account setup.
off-render-thread Secret Service adapter, then read-only Microsoft Graph After it passes verification, the immediate sequence is Microsoft `/me`,
discovery and delta synchronization. read-only calendar discovery, and Graph delta synchronization.
## 1.0 — distribution quality ## 1.0 — distribution quality

View File

@@ -0,0 +1,25 @@
#pragma once
#include <cstdint>
#include <string>
#include <string_view>
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

View File

@@ -0,0 +1,50 @@
#pragma once
#include "nocal/sync/oauth_tokens.hpp"
#include <optional>
#include <stdexcept>
#include <stop_token>
#include <string>
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<OAuthTokens> 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<OAuthTokens> load(
std::string account_id, std::stop_token stop = {}) override;
void erase(std::string account_id, std::stop_token stop = {}) override;
};
} // namespace nocal::sync

View File

@@ -15,7 +15,10 @@ inc = include_directories('include')
sqlite = dependency('sqlite3') sqlite = dependency('sqlite3')
libcrypto = dependency('libcrypto') libcrypto = dependency('libcrypto')
libcurl = dependency('libcurl') libcurl = dependency('libcurl')
libsecret = dependency('libsecret-1')
nlohmann_json = dependency('nlohmann_json')
threads = dependency('threads') threads = dependency('threads')
shell = find_program('sh')
nocal_sources = files( nocal_sources = files(
'src/domain/calendar_transfer.cpp', 'src/domain/calendar_transfer.cpp',
'src/domain/date.cpp', 'src/domain/date.cpp',
@@ -26,15 +29,17 @@ nocal_sources = files(
'src/sync/curl_http.cpp', 'src/sync/curl_http.cpp',
'src/sync/desktop_oauth.cpp', 'src/sync/desktop_oauth.cpp',
'src/sync/oauth.cpp', 'src/sync/oauth.cpp',
'src/sync/oauth_tokens.cpp',
'src/sync/secret_store.cpp',
'src/tui/Screen.cpp', 'src/tui/Screen.cpp',
'src/tui/EventEditor.cpp', 'src/tui/EventEditor.cpp',
'src/tui/Terminal.cpp', 'src/tui/Terminal.cpp',
'src/tui/TuiApp.cpp', 'src/tui/TuiApp.cpp',
) )
nocal_lib = static_library('nocal-core', nocal_sources, include_directories: inc, 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, 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', nocal_executable = executable('nocal', 'src/main.cpp',
dependencies: nocal_dep, install: true) dependencies: nocal_dep, install: true)
@@ -63,6 +68,13 @@ test('curl-http', curl_http_tests)
desktop_oauth_tests = executable('desktop-oauth-tests', desktop_oauth_tests = executable('desktop-oauth-tests',
'tests/desktop_oauth_tests.cpp', dependencies: [nocal_dep, threads]) 'tests/desktop_oauth_tests.cpp', dependencies: [nocal_dep, threads])
test('desktop-oauth', desktop_oauth_tests) 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', tui_tests = executable('tui-tests', 'tests/tui_tests.cpp',
dependencies: nocal_dep) dependencies: nocal_dep)
test('tui', tui_tests) test('tui', tui_tests)
@@ -73,7 +85,6 @@ editor_tests = executable('editor-tests', 'tests/editor_tests.cpp',
dependencies: nocal_dep) dependencies: nocal_dep)
test('editor', editor_tests) test('editor', editor_tests)
shell = find_program('sh')
test('cli-recovery', shell, test('cli-recovery', shell,
args: [files('tests/cli_recovery_test.sh'), nocal_executable]) args: [files('tests/cli_recovery_test.sh'), nocal_executable])
test('cli-transfer', shell, test('cli-transfer', shell,

View File

@@ -9,7 +9,11 @@ pkgs.mkShell {
buildInputs = with pkgs; [ buildInputs = with pkgs; [
curl curl
dbus
gcc gcc
gnome-keyring
libsecret
nlohmann_json
openssl openssl
sqlite sqlite
xdg-utils xdg-utils

188
src/sync/oauth_tokens.cpp Normal file
View File

@@ -0,0 +1,188 @@
#include "nocal/sync/oauth_tokens.hpp"
#include <nlohmann/json.hpp>
#include <algorithm>
#include <cstdint>
#include <limits>
#include <stdexcept>
#include <string>
#include <string_view>
#include <unordered_set>
#include <utility>
#include <vector>
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<unsigned char>(value + ('a' - 'A'))
: value;
};
if (lower(static_cast<unsigned char>(left[index]))
!= lower(static_cast<unsigned char>(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<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;
};
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<std::string>();
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<std::string>();
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<std::uint64_t>();
if (value == 0) {
invalid_response();
}
return value;
}
if (found->is_number_integer()) {
const std::int64_t value = found->get<std::int64_t>();
if (value <= 0) {
invalid_response();
}
return static_cast<std::uint64_t>(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<std::int64_t>::max();
if (now_epoch_seconds >= 0) {
const auto room = static_cast<std::uint64_t>(maximum - now_epoch_seconds);
if (expires_in > room) {
invalid_response();
}
return now_epoch_seconds + static_cast<std::int64_t>(expires_in);
}
const std::uint64_t negative_magnitude =
static_cast<std::uint64_t>(-(now_epoch_seconds + 1)) + 1U;
const std::uint64_t room = static_cast<std::uint64_t>(maximum) + negative_magnitude;
if (expires_in > room) {
invalid_response();
}
if (expires_in >= negative_magnitude) {
return static_cast<std::int64_t>(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<std::int64_t>::min();
}
return -static_cast<std::int64_t>(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

350
src/sync/secret_store.cpp Normal file
View 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

220
tests/oauth_token_tests.cpp Normal file
View File

@@ -0,0 +1,220 @@
#include "nocal/sync/oauth_tokens.hpp"
#include <cstdint>
#include <exception>
#include <iostream>
#include <limits>
#include <stdexcept>
#include <string>
#include <string_view>
#include <vector>
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 <typename Action>
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<std::int64_t>::max();
const auto minimum = std::numeric_limits<std::int64_t>::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<std::string> 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<std::string> 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<std::string> invalid = {"{", "not-json"};
invalid.push_back(std::string("{\"access_token\":\"") + static_cast<char>(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;
}

52
tests/run_secret_store_test.sh Executable file
View File

@@ -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"

View File

@@ -0,0 +1,415 @@
#include "nocal/sync/secret_store.hpp"
#include <libsecret/secret.h>
#include <nlohmann/json.hpp>
#include <algorithm>
#include <array>
#include <cerrno>
#include <cstddef>
#include <cstdint>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <iostream>
#include <iterator>
#include <optional>
#include <stdexcept>
#include <stop_token>
#include <string>
#include <string_view>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#include <utility>
#include <vector>
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 <typename Function>
void require_error(Function&& function, SecretStoreError::Kind kind,
std::string_view message, std::span<const std::string_view> 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<char>::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<const char*>(key)};
const std::string_view stored_value{static_cast<const char*>(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<std::string, 3> invalid_accounts{
"", std::string{"bad\0account", 11}, "bad\naccount"};
for (const auto& invalid : invalid_accounts) {
const std::array<std::string_view, 2> sensitive{account, original.access_token};
require_error([&] { store.store(invalid, original); },
SecretStoreError::Kind::invalid_input, "invalid account accepted", sensitive);
require_error([&] { static_cast<void>(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<OAuthTokens> 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<std::string_view, 2> 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<std::string_view, 3> 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<void>(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<std::string_view, 2> sensitive{account, corrupt};
require_error([&] { static_cast<void>(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<void>(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<void>(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<char>{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<void>(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<void>(::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<long long>(::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;
}