Files
logos-protocol/cpp/token_manager.cpp
Dario Gabriel LipicarandClaude Opus 5 e514c53ef5 fix(tokens): separate the INBOUND and OUTBOUND stores — a grant one way was a grant both ways
TokenManager was one flat map with no direction tag, written from both
sides of every relationship: the client stored the token it PRESENTS to a
callee under the CALLEE's name, and a token RECEIVED from a caller was
stored under the CALLER's name. Same namespace, last write wins.

This was documented as safe by topology — separate images keep the two
directions apart — and pending only for the shared-runtime migration. That
was wrong. It is live in the DEFAULT out-of-process configuration, and the
chain needs no attacker:

  1. capability_module mints one token so M may call B.
  2. It pushes it to B  -> B's store gets ["M"] = T   (inbound)
  3. It returns it to M -> M's store gets ["B"] = T   (outbound)
  4. B then calls M. B's client looks in its OWN store for something to
     present to M, finds T because step 2 put it there, and therefore SKIPS
     requestModule entirely.
  5. M authorizes T, having cached the identical value in step 3.

One grant M->B silently produces B->M, with no handshake, nothing logged,
and capability_module's access policy never consulted. Reproduced on the
pre-split tree, both halves red:

    B's outbound lookup for M returned the token M was issued for calling B
       Actual: false  Expected: true
    B authorized at M using the token minted for M -> B
       Actual: true   Expected: false

A second defect from the same cause: an inbound push CLOBBERS the outbound
cache under the same key, so M's next call to B goes out carrying B's own
inbound token, is rejected, and burns the single re-exchange recovering
from a collision it caused itself.

THREE ROLES, TWO MAPS AND A SCALAR:

  m_outbound   callee -> what I present when calling out
  m_inbound    caller -> what I issued to that caller
  m_credential MY OWN host-issued credential

The anchor is what made a two-way split look impossible: it is genuinely
both directions, presented outbound to capability_module and compared
against inbound. But it is not a MAP. It is one value under two role labels
— "core" and "capability_module" — that collide with module names by
accident, and adoptCredential already wrote a single credential under every
bootstrap key. As a scalar it has no key namespace, so no reverse lookup
can produce a name from it. A key living in both maps would have been a
rename, not a split.

The hoped-for cheap route did not exist: logos_module_accept_token forwards
to lp_token_save in BOTH backends, and the Qt glue calls that one export
for an inbound caller token in one place and the module's own anchor in
another. The doors were never divided, so this could not be done by routing
two existing C ABI doors to two maps — but it needs no new door either.

Four detector tests red before, green after. Constant time survives at the
instruction level and the comparison count stays input-independent.

KNOWN GAP, deliberately not fixed here: the generated glue writes the same
inbound token through BOTH doors (lidl_gen_cdylib_glue.cpp:371-380). Safe
only while module cdylibs are separate images — which is exactly what the
shared-runtime migration collapses. Closing it means a dedicated inbound
export on the module-impl C ABI: a MINOR bump plus a definition in both
backends in the same wave. Price it before that migration lands, not after.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 12:07:42 -03:00

373 lines
13 KiB
C++

#include "token_manager.h"
#include <QMutexLocker>
#include <QSet>
TokenManager& TokenManager::instance()
{
static TokenManager instance;
return instance;
}
// ── the per-identity store registry ─────────────────────────────────────────
//
// Process-wide (per-image, like instance() itself) and file-local: nothing
// outside this translation unit can reach a store except through forIdentity(),
// which is what keeps "same identity ⇒ same store" true.
//
// `vendedShared` is why the registry needs three sets rather than two. It
// records every name forIdentity() answered with instance(), so isolateIdentity()
// can refuse a name whose clients are already pointing at the ambient ring
// instead of silently producing a split brain.
namespace {
struct StoreRegistry {
QMutex mutex;
QSet<QString> isolated;
QSet<QString> vendedShared;
QHash<QString, TokenManager*> stores;
};
StoreRegistry& registry()
{
static StoreRegistry r;
return r;
}
} // namespace
QStringList TokenManager::bootstrapKeys()
{
// "capability_module" authenticates the requestModule handshake itself;
// "core" is the channel ModuleProxy::informModuleToken accepts as trusted.
// These are the keys an identity's OWN credential is installed under by
// adoptCredential(); they are no longer copied from anywhere.
return QStringList{QStringLiteral("core"), QStringLiteral("capability_module")};
}
namespace {
// Is `value` the HOST's anchor — the value instance() holds under a bootstrap
// key? Used to refuse an adoption that would re-create the copy this change
// removes, and to report identities that acquired it some other way.
//
// A plain == rather than a constant-time compare, deliberately: this is a
// host-side administrative check between two values the HOST already holds, not
// an authorization decision on an attacker-supplied token. The constant-time
// path is ModuleProxy::authorize and stays there.
bool isHostAnchorValue(const QString& value)
{
if (value.isEmpty()) return false;
// credential(), not getToken(bootstrapKey): the anchor is ONE value under
// two role labels, and asking for it by that name is what stops this from
// being a reverse lookup over a key namespace.
return TokenManager::instance().credential() == value;
}
} // namespace
TokenManager& TokenManager::forIdentity(const QString& identity)
{
QMutexLocker locker(&registry().mutex);
if (identity.isEmpty() || !registry().isolated.contains(identity)) {
// The default, and the whole reason this change is inert until a host
// opts a name in: the SAME object instance() returns, not a copy of it.
registry().vendedShared.insert(identity);
return instance();
}
auto it = registry().stores.find(identity);
if (it == registry().stores.end()) {
// BORN EMPTY, and that is the whole of the fix on this side.
//
// This used to copy instance()'s tokens for bootstrapKeys() into the
// new store. Those values are the HOST's, so every isolated identity
// came into existence holding the host's anchor: it authorized as the
// host at any callee (ModuleProxy::authorize -> callerHostAnchorJson)
// and satisfied informModuleToken's trusted-channel gate, which is a
// write into another module's token map. Isolating a name is the host
// declaring that this caller must NOT have the host's authority; the
// seed handed it exactly that.
//
// An empty store is a store that cannot call anything until the host
// gives the identity its OWN credential — see adoptCredentialFor(), and
// logos::admitConsumer in logos-plugin-qt, which mints it, registers it
// with capability_module and adopts it in that order.
//
// No saveToken() under the registry lock any more, which also retires
// the re-entrancy note that used to be here: saveToken emits
// tokenSaved, and an emit under a lock is a hazard in general.
it = registry().stores.insert(identity, new TokenManager(nullptr));
}
return *it.value();
}
bool TokenManager::isolateIdentity(const QString& identity)
{
if (identity.isEmpty()) return false;
QMutexLocker locker(&registry().mutex);
if (registry().isolated.contains(identity)) return true; // idempotent
if (registry().vendedShared.contains(identity)) return false; // too late
registry().isolated.insert(identity);
return true;
}
bool TokenManager::isIsolated(const QString& identity)
{
QMutexLocker locker(&registry().mutex);
return registry().isolated.contains(identity);
}
QStringList TokenManager::isolatedIdentities()
{
QMutexLocker locker(&registry().mutex);
QStringList out(registry().isolated.constBegin(), registry().isolated.constEnd());
out.sort();
return out;
}
void TokenManager::adoptCredential(const QString& credential)
{
// Empty is a no-op: an empty value under a bootstrap key reads as PRESENT
// to hasToken() and authorizes nothing, which is the worst of both.
if (credential.isEmpty()) return;
for (const QString& key : bootstrapKeys())
saveToken(key, credential);
}
bool TokenManager::adoptCredentialFor(const QString& identity, const QString& credential)
{
if (identity.isEmpty() || credential.isEmpty()) return false;
// Check isolation BEFORE calling forIdentity: for a name that is not
// isolated, forIdentity would record a shared vend and thereby make future
// isolation of that name impossible. A refusal must not have that side
// effect (same rule the old seedBootstrapTokens followed).
if (!isIsolated(identity)) return false;
// Refusing the host's own anchor is the sanctioned-API half of closing the
// elevation. Without it, `adoptCredentialFor(x, hostAnchor)` would be a
// one-line way to reintroduce exactly what forIdentity stopped doing.
if (isHostAnchorValue(credential)) return false;
TokenManager& store = forIdentity(identity);
if (&store == &instance()) return false; // belt and braces
store.adoptCredential(credential);
return true;
}
QStringList TokenManager::identitiesSharingHostAnchor()
{
QStringList names;
{
QMutexLocker locker(&registry().mutex);
names = QStringList(registry().isolated.constBegin(),
registry().isolated.constEnd());
}
// forIdentity() takes the registry lock itself, so the snapshot above is
// released first rather than recursing on a non-recursive QMutex.
QStringList out;
for (const QString& name : names) {
TokenManager& store = forIdentity(name);
if (&store == &instance()) continue;
if (isHostAnchorValue(store.credential()))
out.append(name);
}
out.sort();
return out;
}
bool TokenManager::resetIdentity(const QString& identity)
{
if (!isIsolated(identity)) return false;
TokenManager& store = forIdentity(identity);
if (&store == &instance()) return false; // belt and braces
// No re-seed. The credential is cleared along with everything else, and the
// caller must adopt the NEW one it just minted and registered — a reload
// that re-registers invalidates the previous credential at the target, so
// keeping it here would be a locked-out reload that looks like a live one.
store.clearAllTokens();
return true;
}
TokenManager::TokenManager(QObject *parent)
: QObject(parent)
{
}
TokenManager::~TokenManager()
{
}
void TokenManager::saveToken(const QString& key, const QString& token)
{
QMutexLocker locker(&m_mutex);
m_outbound[key] = token;
// THE CREDENTIAL SHIM. See the note at the declaration: this is what keeps
// every existing anchor writer working while the credential becomes a value
// of its own. It writes the credential IN ADDITION TO the outbound entry,
// never instead of it, because getToken("core") is still how
// ModuleProxy::informModuleToken's callers and LogosAPIClient's
// capability-handshake read it.
if (bootstrapKeys().contains(key)) m_credential = token;
emit tokenSaved(key);
}
void TokenManager::saveToken(const std::string& key, const std::string& token)
{
saveToken(QString::fromStdString(key), QString::fromStdString(token));
}
QString TokenManager::getToken(const QString& key) const
{
QMutexLocker locker(&m_mutex);
// m_outbound only. A fall-through to m_inbound here would hand every module
// its peers' credentials to present as its own — the same collision this
// split removes, running the other way.
return m_outbound.value(key, QString());
}
std::string TokenManager::getToken(const std::string& key) const
{
return getToken(QString::fromStdString(key)).toStdString();
}
bool TokenManager::hasToken(const QString& key) const
{
QMutexLocker locker(&m_mutex);
return m_outbound.contains(key);
}
bool TokenManager::hasToken(const std::string& key) const
{
return hasToken(QString::fromStdString(key));
}
bool TokenManager::removeToken(const QString& key)
{
QMutexLocker locker(&m_mutex);
if (m_outbound.contains(key)) {
m_outbound.remove(key);
// One value, two names: dropping one of them must not leave the
// credential asserting a value the store no longer holds under any
// bootstrap key. LogosAPIClient removes a rejected per-target token on
// the re-exchange path (logos_api_client.cpp:139/:293), so this is
// reachable from ordinary traffic rather than only from teardown.
if (bootstrapKeys().contains(key)) resyncCredentialLocked();
emit tokenRemoved(key);
return true;
}
return false;
}
bool TokenManager::removeToken(const std::string& key)
{
return removeToken(QString::fromStdString(key));
}
void TokenManager::clearAllTokens()
{
QMutexLocker locker(&m_mutex);
// ALL THREE. resetIdentity() documents that the credential goes too — a
// reload re-mints and re-registers, so a surviving credential would be a
// locked-out reload wearing the appearance of a working one — and the
// inbound record must go for the same reason: it names the callers of the
// PREVIOUS incarnation.
m_outbound.clear();
m_inbound.clear();
m_credential.clear();
emit allTokensCleared();
}
QList<QString> TokenManager::getTokenKeys() const
{
QMutexLocker locker(&m_mutex);
return m_outbound.keys();
}
std::vector<std::string> TokenManager::getTokenKeysStd() const
{
QMutexLocker locker(&m_mutex);
std::vector<std::string> keys;
keys.reserve(static_cast<size_t>(m_outbound.size()));
for (auto it = m_outbound.constBegin(); it != m_outbound.constEnd(); ++it)
keys.push_back(it.key().toStdString());
return keys;
}
int TokenManager::tokenCount() const
{
QMutexLocker locker(&m_mutex);
return m_outbound.size();
}
/* ── the INBOUND half ───────────────────────────────────────────────────────
*
* Separate bodies rather than a direction parameter on the existing ones: a
* parameter is a value a caller can get wrong (and default), while a separate
* name is a symbol that either resolves or does not.
*/
bool TokenManager::saveInboundToken(const QString& caller, const QString& token)
{
// Both refusals mirror ModuleProxy::saveToken. An empty token is the one
// that matters: it would read as PRESENT to inbound().contains() while
// authorizing nothing, which is the worst of both.
if (caller.isEmpty() || token.isEmpty()) return false;
QMutexLocker locker(&m_mutex);
m_inbound[caller] = token;
return true;
}
bool TokenManager::saveInboundToken(const std::string& caller, const std::string& token)
{
return saveInboundToken(QString::fromStdString(caller),
QString::fromStdString(token));
}
QString TokenManager::credential() const
{
QMutexLocker locker(&m_mutex);
return m_credential;
}
QString TokenManager::inboundValue(const QString& caller) const
{
QMutexLocker locker(&m_mutex);
// m_inbound only, and this is THE line the split exists to keep honest: no
// fall-through to m_outbound, however convenient it would look on the day
// some caller comes up empty here.
return m_inbound.value(caller, QString());
}
QStringList TokenManager::inboundKeyList() const
{
QMutexLocker locker(&m_mutex);
return QStringList(m_inbound.keyBegin(), m_inbound.keyEnd());
}
bool TokenManager::inboundContains(const QString& caller) const
{
QMutexLocker locker(&m_mutex);
return m_inbound.contains(caller);
}
int TokenManager::inboundCount() const
{
QMutexLocker locker(&m_mutex);
return m_inbound.size();
}
void TokenManager::resyncCredentialLocked()
{
for (const QString& key : bootstrapKeys()) {
const QString value = m_outbound.value(key, QString());
if (!value.isEmpty()) {
m_credential = value;
return;
}
}
m_credential.clear();
}