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:
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