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:
25
README.md
25
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
|
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`.
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
25
include/nocal/sync/oauth_tokens.hpp
Normal file
25
include/nocal/sync/oauth_tokens.hpp
Normal 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
|
||||||
50
include/nocal/sync/secret_store.hpp
Normal file
50
include/nocal/sync/secret_store.hpp
Normal 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
|
||||||
17
meson.build
17
meson.build
@@ -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,
|
||||||
|
|||||||
@@ -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
188
src/sync/oauth_tokens.cpp
Normal 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
350
src/sync/secret_store.cpp
Normal file
@@ -0,0 +1,350 @@
|
|||||||
|
#include "nocal/sync/secret_store.hpp"
|
||||||
|
|
||||||
|
#include <libsecret/secret.h>
|
||||||
|
#include <nlohmann/json.hpp>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cstddef>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <exception>
|
||||||
|
#include <memory>
|
||||||
|
#include <optional>
|
||||||
|
#include <stdexcept>
|
||||||
|
#include <stop_token>
|
||||||
|
#include <string>
|
||||||
|
#include <string_view>
|
||||||
|
#include <unordered_set>
|
||||||
|
#include <utility>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace nocal::sync {
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
constexpr std::size_t maximum_secret_size = 64U * 1024U;
|
||||||
|
constexpr std::size_t maximum_account_id_size = 512U;
|
||||||
|
|
||||||
|
const SecretSchema oauth_schema = {
|
||||||
|
"dev.nomarchy.nocal.oauth",
|
||||||
|
SECRET_SCHEMA_NONE,
|
||||||
|
{
|
||||||
|
{"account", SECRET_SCHEMA_ATTRIBUTE_STRING},
|
||||||
|
{nullptr, SECRET_SCHEMA_ATTRIBUTE_STRING},
|
||||||
|
},
|
||||||
|
0,
|
||||||
|
nullptr,
|
||||||
|
nullptr,
|
||||||
|
nullptr,
|
||||||
|
nullptr,
|
||||||
|
nullptr,
|
||||||
|
nullptr,
|
||||||
|
nullptr,
|
||||||
|
};
|
||||||
|
|
||||||
|
[[nodiscard]] bool contains_nul_or_control(std::string_view value) {
|
||||||
|
return std::ranges::any_of(value, [](unsigned char character) {
|
||||||
|
return character == 0U || character < 0x20U || character == 0x7fU;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] bool field_size_is_bounded(const OAuthTokens& tokens) {
|
||||||
|
std::size_t total = 256;
|
||||||
|
for (const std::string* field : {&tokens.access_token, &tokens.refresh_token,
|
||||||
|
&tokens.token_type, &tokens.scope}) {
|
||||||
|
if (field->size() > maximum_secret_size || total > maximum_secret_size - field->size()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
total += field->size();
|
||||||
|
}
|
||||||
|
return total <= maximum_secret_size;
|
||||||
|
}
|
||||||
|
|
||||||
|
void validate_account(std::string_view account_id) {
|
||||||
|
if (account_id.empty() || account_id.size() > maximum_account_id_size
|
||||||
|
|| contains_nul_or_control(account_id)) {
|
||||||
|
throw SecretStoreError(
|
||||||
|
SecretStoreError::Kind::invalid_input, "invalid secret-store account identifier");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void validate_tokens(const OAuthTokens& tokens) {
|
||||||
|
const bool invalid_text = tokens.access_token.empty() || tokens.refresh_token.empty()
|
||||||
|
|| tokens.token_type != "Bearer"
|
||||||
|
|| contains_nul_or_control(tokens.access_token)
|
||||||
|
|| contains_nul_or_control(tokens.refresh_token)
|
||||||
|
|| contains_nul_or_control(tokens.token_type) || contains_nul_or_control(tokens.scope);
|
||||||
|
if (invalid_text || tokens.expires_at_epoch_seconds <= 0 || !field_size_is_bounded(tokens)) {
|
||||||
|
throw SecretStoreError(
|
||||||
|
SecretStoreError::Kind::invalid_input, "invalid OAuth credential set");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class DuplicateKey final : public std::exception {};
|
||||||
|
|
||||||
|
[[nodiscard]] nlohmann::json parse_unique_json(std::string_view serialized) {
|
||||||
|
std::vector<std::unordered_set<std::string>> object_keys;
|
||||||
|
const nlohmann::json::parser_callback_t callback =
|
||||||
|
[&object_keys](int, nlohmann::json::parse_event_t event, nlohmann::json& parsed) {
|
||||||
|
if (event == nlohmann::json::parse_event_t::object_start) {
|
||||||
|
object_keys.emplace_back();
|
||||||
|
} else if (event == nlohmann::json::parse_event_t::key) {
|
||||||
|
if (object_keys.empty()
|
||||||
|
|| !object_keys.back().insert(parsed.get<std::string>()).second) {
|
||||||
|
throw DuplicateKey{};
|
||||||
|
}
|
||||||
|
} else if (event == nlohmann::json::parse_event_t::object_end) {
|
||||||
|
if (object_keys.empty()) {
|
||||||
|
throw DuplicateKey{};
|
||||||
|
}
|
||||||
|
object_keys.pop_back();
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
nlohmann::json document = nlohmann::json::parse(
|
||||||
|
serialized.begin(), serialized.end(), callback, true, false);
|
||||||
|
if (!object_keys.empty()) {
|
||||||
|
throw DuplicateKey{};
|
||||||
|
}
|
||||||
|
return document;
|
||||||
|
}
|
||||||
|
|
||||||
|
void secure_zero(char* bytes, std::size_t size) noexcept {
|
||||||
|
volatile char* output = bytes;
|
||||||
|
while (size > 0) {
|
||||||
|
*output++ = 0;
|
||||||
|
--size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class SensitiveString {
|
||||||
|
public:
|
||||||
|
explicit SensitiveString(std::string value) : value_(std::move(value)) {}
|
||||||
|
~SensitiveString() { secure_zero(value_.data(), value_.size()); }
|
||||||
|
|
||||||
|
SensitiveString(const SensitiveString&) = delete;
|
||||||
|
SensitiveString& operator=(const SensitiveString&) = delete;
|
||||||
|
|
||||||
|
[[nodiscard]] const char* c_str() const noexcept { return value_.c_str(); }
|
||||||
|
[[nodiscard]] std::size_t size() const noexcept { return value_.size(); }
|
||||||
|
|
||||||
|
private:
|
||||||
|
std::string value_;
|
||||||
|
};
|
||||||
|
|
||||||
|
class ReturnedSecret {
|
||||||
|
public:
|
||||||
|
explicit ReturnedSecret(char* value) : value_(value) {}
|
||||||
|
~ReturnedSecret() {
|
||||||
|
if (value_ != nullptr) {
|
||||||
|
secure_zero(value_, std::char_traits<char>::length(value_));
|
||||||
|
secret_password_free(value_);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ReturnedSecret(const ReturnedSecret&) = delete;
|
||||||
|
ReturnedSecret& operator=(const ReturnedSecret&) = delete;
|
||||||
|
|
||||||
|
[[nodiscard]] const char* get() const noexcept { return value_; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
char* value_;
|
||||||
|
};
|
||||||
|
|
||||||
|
class ErrorHolder {
|
||||||
|
public:
|
||||||
|
~ErrorHolder() {
|
||||||
|
if (error_ != nullptr) {
|
||||||
|
g_error_free(error_);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ErrorHolder(const ErrorHolder&) = delete;
|
||||||
|
ErrorHolder& operator=(const ErrorHolder&) = delete;
|
||||||
|
ErrorHolder() = default;
|
||||||
|
|
||||||
|
[[nodiscard]] GError** output() noexcept { return &error_; }
|
||||||
|
[[nodiscard]] const GError* get() const noexcept { return error_; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
GError* error_{nullptr};
|
||||||
|
};
|
||||||
|
|
||||||
|
struct CancelCallback {
|
||||||
|
GCancellable* cancellable;
|
||||||
|
void operator()() const noexcept { g_cancellable_cancel(cancellable); }
|
||||||
|
};
|
||||||
|
|
||||||
|
class Cancellation {
|
||||||
|
public:
|
||||||
|
explicit Cancellation(std::stop_token stop) : cancellable_(g_cancellable_new()) {
|
||||||
|
if (cancellable_ == nullptr) {
|
||||||
|
throw SecretStoreError(
|
||||||
|
SecretStoreError::Kind::operation_failed, "secret-store operation failed");
|
||||||
|
}
|
||||||
|
callback_ = std::make_unique<std::stop_callback<CancelCallback>>(
|
||||||
|
stop, CancelCallback{cancellable_});
|
||||||
|
}
|
||||||
|
|
||||||
|
~Cancellation() {
|
||||||
|
callback_.reset();
|
||||||
|
g_object_unref(cancellable_);
|
||||||
|
}
|
||||||
|
|
||||||
|
Cancellation(const Cancellation&) = delete;
|
||||||
|
Cancellation& operator=(const Cancellation&) = delete;
|
||||||
|
|
||||||
|
[[nodiscard]] GCancellable* get() const noexcept { return cancellable_; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
GCancellable* cancellable_;
|
||||||
|
std::unique_ptr<std::stop_callback<CancelCallback>> callback_;
|
||||||
|
};
|
||||||
|
|
||||||
|
[[nodiscard]] SecretStoreError::Kind classify_error(
|
||||||
|
const GError* error, std::stop_token stop) {
|
||||||
|
if (stop.stop_requested()
|
||||||
|
|| (error != nullptr && g_error_matches(error, G_IO_ERROR, G_IO_ERROR_CANCELLED))) {
|
||||||
|
return SecretStoreError::Kind::cancelled;
|
||||||
|
}
|
||||||
|
if (error != nullptr
|
||||||
|
&& (error->domain == G_DBUS_ERROR
|
||||||
|
|| g_error_matches(error, G_IO_ERROR, G_IO_ERROR_NOT_FOUND)
|
||||||
|
|| g_error_matches(error, G_IO_ERROR, G_IO_ERROR_CONNECTION_REFUSED)
|
||||||
|
|| g_error_matches(error, G_IO_ERROR, G_IO_ERROR_CLOSED)
|
||||||
|
|| g_error_matches(error, G_IO_ERROR, G_IO_ERROR_TIMED_OUT)
|
||||||
|
|| g_error_matches(error, G_IO_ERROR, G_IO_ERROR_HOST_UNREACHABLE)
|
||||||
|
|| g_error_matches(error, G_IO_ERROR, G_IO_ERROR_NETWORK_UNREACHABLE))) {
|
||||||
|
return SecretStoreError::Kind::unavailable;
|
||||||
|
}
|
||||||
|
return SecretStoreError::Kind::operation_failed;
|
||||||
|
}
|
||||||
|
|
||||||
|
[[noreturn]] void throw_operation_error(const GError* error, std::stop_token stop) {
|
||||||
|
const SecretStoreError::Kind kind = classify_error(error, stop);
|
||||||
|
switch (kind) {
|
||||||
|
case SecretStoreError::Kind::cancelled:
|
||||||
|
throw SecretStoreError(kind, "secret-store operation cancelled");
|
||||||
|
case SecretStoreError::Kind::unavailable:
|
||||||
|
throw SecretStoreError(kind, "secret service unavailable");
|
||||||
|
default:
|
||||||
|
throw SecretStoreError(kind, "secret-store operation failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void reject_pre_cancelled(std::stop_token stop) {
|
||||||
|
if (stop.stop_requested()) {
|
||||||
|
throw SecretStoreError(
|
||||||
|
SecretStoreError::Kind::cancelled, "secret-store operation cancelled");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] SensitiveString serialize_tokens(const OAuthTokens& tokens) {
|
||||||
|
try {
|
||||||
|
nlohmann::json document = {
|
||||||
|
{"version", 1},
|
||||||
|
{"access_token", tokens.access_token},
|
||||||
|
{"refresh_token", tokens.refresh_token},
|
||||||
|
{"token_type", tokens.token_type},
|
||||||
|
{"scope", tokens.scope},
|
||||||
|
{"expires_at_epoch_seconds", tokens.expires_at_epoch_seconds},
|
||||||
|
};
|
||||||
|
std::string serialized = document.dump();
|
||||||
|
if (serialized.size() > maximum_secret_size) {
|
||||||
|
throw SecretStoreError(
|
||||||
|
SecretStoreError::Kind::invalid_input, "invalid OAuth credential set");
|
||||||
|
}
|
||||||
|
return SensitiveString{std::move(serialized)};
|
||||||
|
} catch (const SecretStoreError&) {
|
||||||
|
throw;
|
||||||
|
} catch (...) {
|
||||||
|
throw SecretStoreError(
|
||||||
|
SecretStoreError::Kind::invalid_input, "invalid OAuth credential set");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] OAuthTokens parse_tokens(std::string_view serialized) {
|
||||||
|
if (serialized.empty() || serialized.size() > maximum_secret_size) {
|
||||||
|
throw SecretStoreError(
|
||||||
|
SecretStoreError::Kind::corrupt_secret, "stored OAuth credential is invalid");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const nlohmann::json document = parse_unique_json(serialized);
|
||||||
|
if (!document.is_object() || document.size() != 6 || !document.contains("version")
|
||||||
|
|| !document["version"].is_number_integer()
|
||||||
|
|| document["version"].get<int>() != 1
|
||||||
|
|| !document.contains("access_token") || !document["access_token"].is_string()
|
||||||
|
|| !document.contains("refresh_token") || !document["refresh_token"].is_string()
|
||||||
|
|| !document.contains("token_type") || !document["token_type"].is_string()
|
||||||
|
|| !document.contains("scope") || !document["scope"].is_string()
|
||||||
|
|| !document.contains("expires_at_epoch_seconds")
|
||||||
|
|| !document["expires_at_epoch_seconds"].is_number_integer()) {
|
||||||
|
throw std::runtime_error("invalid document shape");
|
||||||
|
}
|
||||||
|
OAuthTokens tokens;
|
||||||
|
tokens.access_token = document["access_token"].get<std::string>();
|
||||||
|
tokens.refresh_token = document["refresh_token"].get<std::string>();
|
||||||
|
tokens.token_type = document["token_type"].get<std::string>();
|
||||||
|
tokens.scope = document["scope"].get<std::string>();
|
||||||
|
tokens.expires_at_epoch_seconds =
|
||||||
|
document["expires_at_epoch_seconds"].get<std::int64_t>();
|
||||||
|
validate_tokens(tokens);
|
||||||
|
return tokens;
|
||||||
|
} catch (...) {
|
||||||
|
throw SecretStoreError(
|
||||||
|
SecretStoreError::Kind::corrupt_secret, "stored OAuth credential is invalid");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
SecretStoreError::SecretStoreError(Kind kind, std::string message)
|
||||||
|
: std::runtime_error(std::move(message)), kind_(kind) {}
|
||||||
|
|
||||||
|
SecretStoreError::Kind SecretStoreError::kind() const noexcept { return kind_; }
|
||||||
|
|
||||||
|
void LibsecretOAuthSecretStore::store(
|
||||||
|
std::string account_id, const OAuthTokens& tokens, std::stop_token stop) {
|
||||||
|
validate_account(account_id);
|
||||||
|
validate_tokens(tokens);
|
||||||
|
reject_pre_cancelled(stop);
|
||||||
|
SensitiveString serialized = serialize_tokens(tokens);
|
||||||
|
Cancellation cancellation{stop};
|
||||||
|
ErrorHolder error;
|
||||||
|
const gboolean stored = secret_password_store_sync(&oauth_schema, SECRET_COLLECTION_DEFAULT,
|
||||||
|
account_id.c_str(), serialized.c_str(), cancellation.get(), error.output(), "account",
|
||||||
|
account_id.c_str(), nullptr);
|
||||||
|
if (!stored) {
|
||||||
|
throw_operation_error(error.get(), stop);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<OAuthTokens> LibsecretOAuthSecretStore::load(
|
||||||
|
std::string account_id, std::stop_token stop) {
|
||||||
|
validate_account(account_id);
|
||||||
|
reject_pre_cancelled(stop);
|
||||||
|
Cancellation cancellation{stop};
|
||||||
|
ErrorHolder error;
|
||||||
|
ReturnedSecret secret{secret_password_lookup_sync(
|
||||||
|
&oauth_schema, cancellation.get(), error.output(), "account", account_id.c_str(), nullptr)};
|
||||||
|
if (secret.get() == nullptr) {
|
||||||
|
if (error.get() != nullptr) {
|
||||||
|
throw_operation_error(error.get(), stop);
|
||||||
|
}
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
const std::size_t size = std::char_traits<char>::length(secret.get());
|
||||||
|
return parse_tokens(std::string_view{secret.get(), size});
|
||||||
|
}
|
||||||
|
|
||||||
|
void LibsecretOAuthSecretStore::erase(std::string account_id, std::stop_token stop) {
|
||||||
|
validate_account(account_id);
|
||||||
|
reject_pre_cancelled(stop);
|
||||||
|
Cancellation cancellation{stop};
|
||||||
|
ErrorHolder error;
|
||||||
|
static_cast<void>(secret_password_clear_sync(
|
||||||
|
&oauth_schema, cancellation.get(), error.output(), "account", account_id.c_str(), nullptr));
|
||||||
|
if (error.get() != nullptr) {
|
||||||
|
throw_operation_error(error.get(), stop);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace nocal::sync
|
||||||
220
tests/oauth_token_tests.cpp
Normal file
220
tests/oauth_token_tests.cpp
Normal 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
52
tests/run_secret_store_test.sh
Executable 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"
|
||||||
415
tests/secret_store_tests.cpp
Normal file
415
tests/secret_store_tests.cpp
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user