2026-06-12 18:59:01 -03:00
|
|
|
#ifndef TOKEN_MANAGER_H
|
|
|
|
|
#define TOKEN_MANAGER_H
|
|
|
|
|
|
|
|
|
|
#include <QObject>
|
|
|
|
|
#include <QString>
|
2026-08-18 22:58:56 -03:00
|
|
|
#include <QStringList>
|
2026-06-12 18:59:01 -03:00
|
|
|
#include <QHash>
|
|
|
|
|
#include <QMutex>
|
|
|
|
|
#include <QByteArray>
|
|
|
|
|
#include <QCryptographicHash>
|
|
|
|
|
#include <string>
|
2026-08-18 22:58:56 -03:00
|
|
|
#include <vector>
|
2026-06-12 18:59:01 -03:00
|
|
|
|
2026-08-11 09:44:25 -03:00
|
|
|
#include "logos_shared_api.h"
|
|
|
|
|
|
2026-06-12 18:59:01 -03:00
|
|
|
/**
|
|
|
|
|
* @brief Render a capability/auth token safe to write to logs.
|
|
|
|
|
*
|
|
|
|
|
* Tokens gate all cross-module RPC and are accepted by value (see
|
|
|
|
|
* ModuleProxy::isAuthorized), so a raw token recovered from a log line is
|
|
|
|
|
* directly replayable. This collapses a token to a non-reversible, non-
|
|
|
|
|
* replayable fingerprint — a fixed prefix plus the first bytes of its SHA-256 —
|
|
|
|
|
* suitable for correlating log lines without exposing the secret. The
|
|
|
|
|
* "redacted:" prefix signals to anyone reading the log that this is a
|
|
|
|
|
* deliberately non-replayable fingerprint, not a truncated real token. Empty
|
|
|
|
|
* tokens render as "<none>" so missing-token cases stay greppable.
|
|
|
|
|
*
|
|
|
|
|
* Always pass tokens through this before logging; never log the raw value.
|
|
|
|
|
*/
|
|
|
|
|
inline QString redactToken(const QString& token)
|
|
|
|
|
{
|
|
|
|
|
if (token.isEmpty()) {
|
|
|
|
|
return QStringLiteral("<none>");
|
|
|
|
|
}
|
|
|
|
|
const QByteArray digest =
|
|
|
|
|
QCryptographicHash::hash(token.toUtf8(), QCryptographicHash::Sha256);
|
|
|
|
|
return QStringLiteral("redacted:") + QString::fromLatin1(digest.toHex().left(8)) + QStringLiteral("…");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @brief TokenManager provides a singleton interface for managing authentication tokens
|
|
|
|
|
*
|
|
|
|
|
* This class manages a collection of tokens identified by keys, providing thread-safe
|
|
|
|
|
* access to store, retrieve, and manage tokens throughout the application lifecycle.
|
2026-08-11 09:44:25 -03:00
|
|
|
*
|
|
|
|
|
* LOGOS_SHARED_API: the singleton below is a function-local static, so it is one
|
|
|
|
|
* per copy of the code. On PE that means one per IMAGE unless the consumer
|
|
|
|
|
* imports it from liblogos_core.dll — which is exactly the token-invisibility
|
|
|
|
|
* bug described in logos_shared_api.h. Off Windows, and inside the provider,
|
|
|
|
|
* this expands to nothing.
|
2026-06-12 18:59:01 -03:00
|
|
|
*/
|
2026-08-11 09:44:25 -03:00
|
|
|
class LOGOS_SHARED_API TokenManager : public QObject
|
2026-06-12 18:59:01 -03:00
|
|
|
{
|
|
|
|
|
Q_OBJECT
|
|
|
|
|
|
|
|
|
|
public:
|
|
|
|
|
/**
|
|
|
|
|
* @brief Get the singleton instance of TokenManager
|
|
|
|
|
* @return TokenManager& Reference to the singleton instance
|
|
|
|
|
*/
|
|
|
|
|
static TokenManager& instance();
|
|
|
|
|
|
2026-08-18 22:58:56 -03:00
|
|
|
/* ── per-identity token stores ──────────────────────────────────────────
|
|
|
|
|
*
|
|
|
|
|
* WHAT THIS EXISTS TO CLOSE. instance() is the IMAGE's store, and in a host
|
|
|
|
|
* that loads plugins in-process it is also an ambient ring: the host writes
|
|
|
|
|
* `name -> that module's root auth token` for EVERY loaded module. On the
|
|
|
|
|
* hot path a client asserts no identity at all — LogosAPIClient::
|
|
|
|
|
* invokeRemoteMethod reads the store first and only mints on a miss — so
|
|
|
|
|
* whichever plugin asks for target X finds X's own root token sitting there
|
|
|
|
|
* and presents it. The provider accepts any token in its image's store, so
|
|
|
|
|
* the call authorizes and no `requestModule` is ever logged. Every plugin in
|
|
|
|
|
* that image therefore holds every other module's authority, and giving a
|
|
|
|
|
* plugin its own ORIGIN STRING does not change that by one byte, because
|
|
|
|
|
* origin is never consulted on the path taken.
|
|
|
|
|
*
|
|
|
|
|
* The fix is to make origin SELECT THE STORE rather than merely label the
|
|
|
|
|
* caller. forIdentity(x) is "the store to present tokens from when I am x".
|
|
|
|
|
*
|
|
|
|
|
* ADDITIVE BY CONSTRUCTION, and that is not a promise but the shape of the
|
|
|
|
|
* code: forIdentity() returns instance() — the same object, pointer-identical
|
|
|
|
|
* — for every identity until someone calls isolateIdentity() on that exact
|
|
|
|
|
* name. A host that knows nothing about any of this sees no change at all.
|
|
|
|
|
* These are static member FUNCTIONS: no data member, no virtual, nothing moc
|
|
|
|
|
* sees, so the layout every statically-linked plugin was compiled against is
|
|
|
|
|
* untouched.
|
|
|
|
|
*
|
|
|
|
|
* PER-IMAGE IS NOT PER-CLIENT, and both are needed. A module cdylib links
|
|
|
|
|
* its own copy of this library and therefore has its own instance(); that
|
|
|
|
|
* per-image store is correct and stays. This adds a second axis INSIDE one
|
|
|
|
|
* image, for the case the per-image split cannot reach: several identities
|
|
|
|
|
* hosted by one process.
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @brief The token store for calling-identity `identity`.
|
|
|
|
|
*
|
|
|
|
|
* Returns instance() unless `identity` has been isolated, in which case it
|
|
|
|
|
* returns that identity's private store. The address is stable for the
|
|
|
|
|
* lifetime of the image, so a client may hold it by raw pointer (which
|
|
|
|
|
* LogosAPIClient does, and dereferences from async continuations that can
|
|
|
|
|
* outlive their caller).
|
|
|
|
|
*
|
|
|
|
|
* Calling this for a name that is NOT isolated records that a shared store
|
|
|
|
|
* was handed out under it — see isolateIdentity().
|
|
|
|
|
*/
|
|
|
|
|
static TokenManager& forIdentity(const QString& identity);
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @brief Declare `identity` to have a PRIVATE, initially bootstrap-only store.
|
|
|
|
|
*
|
|
|
|
|
* Idempotent: isolating an already-isolated identity returns true and
|
|
|
|
|
* changes nothing.
|
|
|
|
|
*
|
|
|
|
|
* Returns FALSE — and changes nothing — if forIdentity() already handed the
|
|
|
|
|
* SHARED store out under this name. Callers must treat that as fatal for the
|
|
|
|
|
* identity rather than continuing. The refusal is not defensive padding:
|
|
|
|
|
* LogosAPIClient captures its store as a raw pointer at construction and
|
|
|
|
|
* dereferences it on the hot path, so isolating after a client for the name
|
|
|
|
|
* exists leaves one client on the ambient ring and another on the private
|
|
|
|
|
* store — the precise "looks fixed, isn't" outcome this whole change exists
|
|
|
|
|
* to avoid. Isolate before constructing anything for the identity.
|
|
|
|
|
*
|
|
|
|
|
* Returns false for an empty identity: "" is the not-an-identity value that
|
|
|
|
|
* every un-named caller passes, and isolating it would isolate all of them
|
|
|
|
|
* into one shared pseudo-store, which is worse than leaving them ambient.
|
|
|
|
|
*/
|
|
|
|
|
static bool isolateIdentity(const QString& identity);
|
|
|
|
|
|
|
|
|
|
/** @brief Whether `identity` has a private store. */
|
|
|
|
|
static bool isIsolated(const QString& identity);
|
|
|
|
|
|
|
|
|
|
/** @brief Every isolated identity. Diagnostics only. */
|
|
|
|
|
static QStringList isolatedIdentities();
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @brief The keys a private store is seeded with: the trust-root bootstrap.
|
|
|
|
|
*
|
|
|
|
|
* A private store starts empty of everything a caller could escalate with,
|
|
|
|
|
* but NOT empty: a module's first call to an unknown target runs
|
|
|
|
|
* `capability_module.requestModule`, and that call is itself authenticated
|
|
|
|
|
* with the token stored under "capability_module" (and "core" for the
|
|
|
|
|
* host-side channel). Withhold those and the very first exchange fails, so
|
|
|
|
|
* an isolated identity could never obtain any token at all.
|
|
|
|
|
*
|
|
|
|
|
* These two keys, and only these two, are copied from instance() into a
|
|
|
|
|
* private store when it is created. Every other module's root token — the
|
|
|
|
|
* thing that made the ambient ring an escalation — is not.
|
|
|
|
|
*/
|
|
|
|
|
static QStringList bootstrapKeys();
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @brief Copy the bootstrap tokens from instance() into `identity`'s private
|
|
|
|
|
* store, for keys it does not already hold.
|
|
|
|
|
*
|
|
|
|
|
* Runs automatically when a private store is created, which is the ordering
|
|
|
|
|
* a host already satisfies (bootstrap tokens are seeded before any module
|
|
|
|
|
* loads). Exposed for the host that learns a bootstrap token later.
|
|
|
|
|
*
|
|
|
|
|
* @return the number of keys copied; 0 for a non-isolated identity.
|
|
|
|
|
*/
|
|
|
|
|
static int seedBootstrapTokens(const QString& identity);
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @brief Clear an isolated identity's private store and re-seed the
|
|
|
|
|
* bootstrap, e.g. when the plugin behind it is unloaded.
|
|
|
|
|
*
|
|
|
|
|
* The store OBJECT is immortal — a client mid-flight must never dereference
|
|
|
|
|
* freed memory — so what has a lifetime is its CONTENTS. After a reload the
|
|
|
|
|
* identity would otherwise present per-target tokens minted for its previous
|
|
|
|
|
* incarnation; the provider rejects them and the existing rejection-driven
|
|
|
|
|
* re-exchange heals it in one retry, so skipping this costs latency and log
|
|
|
|
|
* noise rather than correctness. Do it anyway.
|
|
|
|
|
*
|
|
|
|
|
* Deliberately a no-op returning false for a NON-isolated identity: that
|
|
|
|
|
* store is the shared ring, and clearing it would take every other
|
|
|
|
|
* identity's tokens — including the host's — with it.
|
|
|
|
|
*/
|
|
|
|
|
static bool resetIdentity(const QString& identity);
|
|
|
|
|
|
2026-06-12 18:59:01 -03:00
|
|
|
/**
|
|
|
|
|
* @brief Save a token with the given key
|
|
|
|
|
* @param key The identifier for the token
|
|
|
|
|
* @param token The token value to store
|
|
|
|
|
*/
|
|
|
|
|
void saveToken(const QString& key, const QString& token);
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @brief Save a token — const char* overload (resolves ambiguity, delegates to QString)
|
|
|
|
|
*/
|
|
|
|
|
void saveToken(const char* key, const char* token)
|
|
|
|
|
{ saveToken(QString(key), QString(token)); }
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @brief Save a token with the given key (std::string overload)
|
|
|
|
|
*/
|
|
|
|
|
void saveToken(const std::string& key, const std::string& token);
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @brief Retrieve a token by key
|
|
|
|
|
* @param key The identifier for the token
|
|
|
|
|
* @return QString The token value, or empty string if not found
|
|
|
|
|
*/
|
|
|
|
|
QString getToken(const QString& key) const;
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @brief Retrieve a token — const char* overload (resolves ambiguity, delegates to QString)
|
|
|
|
|
*/
|
|
|
|
|
QString getToken(const char* key) const
|
|
|
|
|
{ return getToken(QString(key)); }
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @brief Retrieve a token by key (std::string overload)
|
|
|
|
|
* @return std::string The token value, or empty string if not found
|
|
|
|
|
*/
|
|
|
|
|
std::string getToken(const std::string& key) const;
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @brief Check if a token exists for the given key
|
|
|
|
|
* @param key The identifier to check
|
|
|
|
|
* @return bool True if token exists, false otherwise
|
|
|
|
|
*/
|
|
|
|
|
bool hasToken(const QString& key) const;
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @brief hasToken — const char* overload (resolves ambiguity, delegates to QString)
|
|
|
|
|
*/
|
|
|
|
|
bool hasToken(const char* key) const
|
|
|
|
|
{ return hasToken(QString(key)); }
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @brief Check if a token exists for the given key (std::string overload)
|
|
|
|
|
*/
|
|
|
|
|
bool hasToken(const std::string& key) const;
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @brief Remove a token by key
|
|
|
|
|
* @param key The identifier for the token to remove
|
|
|
|
|
* @return bool True if token was removed, false if it didn't exist
|
|
|
|
|
*/
|
|
|
|
|
bool removeToken(const QString& key);
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @brief removeToken — const char* overload (resolves ambiguity, delegates to QString)
|
|
|
|
|
*/
|
|
|
|
|
bool removeToken(const char* key)
|
|
|
|
|
{ return removeToken(QString(key)); }
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @brief Remove a token by key (std::string overload)
|
|
|
|
|
*/
|
|
|
|
|
bool removeToken(const std::string& key);
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @brief Clear all tokens
|
|
|
|
|
*/
|
|
|
|
|
void clearAllTokens();
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @brief Get all token keys
|
|
|
|
|
* @return QList<QString> List of all token keys
|
|
|
|
|
*/
|
|
|
|
|
QList<QString> getTokenKeys() const;
|
|
|
|
|
|
2026-08-18 22:58:56 -03:00
|
|
|
/**
|
|
|
|
|
* @brief Get all token keys (std::string flavour)
|
|
|
|
|
*
|
|
|
|
|
* Named rather than overloaded because C++ cannot overload on return type
|
|
|
|
|
* alone. Exists for the Qt-free callers — lp_token_keys() and, through it,
|
|
|
|
|
* any language SDK — so the conversion lives here next to the store rather
|
|
|
|
|
* than being retyped at each boundary.
|
|
|
|
|
*/
|
|
|
|
|
std::vector<std::string> getTokenKeysStd() const;
|
|
|
|
|
|
2026-06-12 18:59:01 -03:00
|
|
|
/**
|
|
|
|
|
* @brief Get the number of stored tokens
|
|
|
|
|
* @return int Number of tokens stored
|
|
|
|
|
*/
|
|
|
|
|
int tokenCount() const;
|
|
|
|
|
|
|
|
|
|
signals:
|
|
|
|
|
/**
|
|
|
|
|
* @brief Emitted when a token is saved
|
|
|
|
|
* @param key The key of the saved token
|
|
|
|
|
*/
|
|
|
|
|
void tokenSaved(const QString& key);
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @brief Emitted when a token is removed
|
|
|
|
|
* @param key The key of the removed token
|
|
|
|
|
*/
|
|
|
|
|
void tokenRemoved(const QString& key);
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @brief Emitted when all tokens are cleared
|
|
|
|
|
*/
|
|
|
|
|
void allTokensCleared();
|
|
|
|
|
|
|
|
|
|
private:
|
|
|
|
|
/**
|
|
|
|
|
* @brief Private constructor for singleton pattern
|
|
|
|
|
* @param parent Parent QObject
|
2026-08-18 22:58:56 -03:00
|
|
|
*
|
|
|
|
|
* Still private with per-identity stores in the picture, and that is what
|
|
|
|
|
* forces forIdentity()/isolateIdentity() to be static MEMBERS rather than
|
|
|
|
|
* free functions or a helper struct: only a member can construct one. A
|
|
|
|
|
* public constructor would also defeat the purpose — nothing would make two
|
|
|
|
|
* callers naming the same identity find the SAME store, and lp_client_create's
|
|
|
|
|
* frozen signature has no way to be handed one.
|
2026-06-12 18:59:01 -03:00
|
|
|
*/
|
|
|
|
|
explicit TokenManager(QObject *parent = nullptr);
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @brief Private destructor
|
2026-08-18 22:58:56 -03:00
|
|
|
*
|
|
|
|
|
* Private, so a per-identity store cannot be deleted from outside — which is
|
|
|
|
|
* exactly the intended lifetime. Private stores are heap-allocated and never
|
|
|
|
|
* destroyed: LogosAPIClient holds its store by raw pointer and touches it
|
|
|
|
|
* from async continuations that can fire after their caller returned, and
|
|
|
|
|
* lp_client_destroy already defers client teardown to the owner thread and
|
|
|
|
|
* documents that the client simply leaks if that loop never runs again. A
|
|
|
|
|
* refcounted store would put destruction order on that path for a security
|
|
|
|
|
* object. One QHash per isolated identity is not a cost worth that.
|
2026-06-12 18:59:01 -03:00
|
|
|
*/
|
|
|
|
|
~TokenManager();
|
|
|
|
|
|
|
|
|
|
// Delete copy constructor and assignment operator to enforce singleton
|
|
|
|
|
TokenManager(const TokenManager&) = delete;
|
|
|
|
|
TokenManager& operator=(const TokenManager&) = delete;
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @brief Hash map storing tokens by key
|
|
|
|
|
*/
|
|
|
|
|
QHash<QString, QString> m_tokens;
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @brief Mutex for thread-safe access to tokens
|
|
|
|
|
*/
|
|
|
|
|
mutable QMutex m_mutex;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
#endif // TOKEN_MANAGER_H
|