#ifndef TOKEN_MANAGER_H #define TOKEN_MANAGER_H #include #include #include #include #include #include #include #include #include #include "logos_shared_api.h" /** * @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 "" 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(""); } 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. * * 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. */ class LOGOS_SHARED_API TokenManager : public QObject { Q_OBJECT public: /** * @brief Get the singleton instance of TokenManager * @return TokenManager& Reference to the singleton instance */ static TokenManager& instance(); /* ── 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); /** * @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 List of all token keys */ QList getTokenKeys() const; /** * @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 getTokenKeysStd() const; /** * @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 * * 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. */ explicit TokenManager(QObject *parent = nullptr); /** * @brief Private destructor * * 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. */ ~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 m_tokens; /** * @brief Mutex for thread-safe access to tokens */ mutable QMutex m_mutex; }; #endif // TOKEN_MANAGER_H