mirror of
https://github.com/logos-co/logos-protocol.git
synced 2026-08-31 14:01:14 +00:00
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>
221 lines
11 KiB
C++
221 lines
11 KiB
C++
#ifndef MODULE_PROXY_H
|
|
#define MODULE_PROXY_H
|
|
|
|
#include <QObject>
|
|
#include <QVariant>
|
|
#include <QVariantList>
|
|
#include <QHash>
|
|
#include <QString>
|
|
#include <QJsonArray>
|
|
#include <QPointer>
|
|
|
|
#include <functional>
|
|
#include <string>
|
|
#include <utility>
|
|
|
|
#include "token_manager.h"
|
|
|
|
class LogosProviderObject;
|
|
|
|
namespace logos {
|
|
|
|
/**
|
|
* @brief How many constant-time token comparisons this image has performed.
|
|
*
|
|
* INSTRUMENTATION, not a knob and not a diagnostic anyone should act on. It
|
|
* exists so a test can assert the one timing property that is deterministic
|
|
* enough to be worth asserting: the number of comparisons ModuleProxy performs
|
|
* for an inbound call is a function of the STORE SIZES only — never of where
|
|
* the matching token sits, nor of whether there was a match at all. An
|
|
* accidental `break` or early `return` in either scan loop is exactly what that
|
|
* catches, and is exactly what the constant-time compare exists to prevent.
|
|
*
|
|
* It asserts nothing about wall-clock time and no test here should claim it
|
|
* does; see the note in tests/protocol/test_inbound_token_store.cpp.
|
|
*
|
|
* Always on rather than behind a build flag: a relaxed atomic increment is
|
|
* unmeasurable next to the two heap allocations QString::toUtf8() already makes
|
|
* on every one of these comparisons, and a check that only exists in a test
|
|
* build is a check that stops matching the shipped code.
|
|
*/
|
|
unsigned long long tokenComparisonCount();
|
|
|
|
// The name a module's handshake surface is published under.
|
|
//
|
|
// A module's initializer is synchronous and routinely calls out — including
|
|
// capability_module's requestModule, which capability answers by pushing a
|
|
// token back to that same module. The module's BUSINESS object is published
|
|
// only after the initializer returns (so a caller keeps waiting at acquire
|
|
// until the module is genuinely ready, which is the long-standing contract).
|
|
// That left the push unsatisfiable: capability waited for a source that could
|
|
// not appear until the initializer returned, and the initializer could not
|
|
// return until capability answered.
|
|
//
|
|
// The handshake object is published BEFORE the initializer runs and carries
|
|
// token delivery only. capability can therefore always reach a module, while
|
|
// callers of real methods still block at acquire exactly as they always have.
|
|
inline QString handshakeObjectName(const QString& moduleName)
|
|
{
|
|
return moduleName + QStringLiteral("__handshake");
|
|
}
|
|
} // namespace logos
|
|
|
|
/**
|
|
* @brief ModuleProxy wraps a LogosProviderObject and exposes it as a QObject
|
|
* so that Qt Remote Objects can publish it.
|
|
*
|
|
* All method dispatch, introspection, and event forwarding is delegated
|
|
* to the underlying LogosProviderObject*. For legacy QObject-based plugins,
|
|
* that provider is a QtProviderObject adapter; for new-API plugins it is
|
|
* the plugin's own LogosProviderObject subclass.
|
|
*/
|
|
class ModuleProxy : public QObject
|
|
{
|
|
Q_OBJECT
|
|
|
|
public:
|
|
// A host-installed extra authorizer. Returns true if `token` is valid for a
|
|
// call arriving over `transportProtocol` ("local" | "tcp" | "tcp_ssl").
|
|
// Consulted IN ADDITION to the built-in issued-token scan, so installing one
|
|
// only ever grants access to tokens the built-in scan wouldn't (e.g. the
|
|
// daemon backs it with TokenStore::lookupByToken to make operator-issued
|
|
// named tokens work, with per-token expiry and local_only enforced by the
|
|
// transport it's handed).
|
|
using TokenValidator = std::function<bool(const QString& token,
|
|
const QString& transportProtocol)>;
|
|
|
|
// `token_store` is the store this proxy AUTHORIZES AGAINST — specifically
|
|
// its INBOUND half and its CREDENTIAL; the outbound half is never consulted
|
|
// (TokenManager's DIRECTION note explains what that closed).
|
|
// It must be the same store the provider's own informModuleToken writes to,
|
|
// which for the Qt stack is LogosAPI::getTokenManager() ==
|
|
// TokenManager::forIdentity(<this module's name>), NOT the ambient
|
|
// instance(). Those are the same object until a host isolates the identity;
|
|
// after that they diverge and hardcoding instance() means two different
|
|
// failures at once — every token the host seeded privately is invisible
|
|
// (inbound calls rejected with no diagnostic), and every token in the
|
|
// ambient ring is still accepted (the escalation isolation exists to close).
|
|
//
|
|
// Defaulted to &TokenManager::instance() so every existing two-argument
|
|
// construction keeps scanning exactly what it scanned before. A null
|
|
// pointer means the same thing.
|
|
explicit ModuleProxy(LogosProviderObject* provider, QObject* parent = nullptr,
|
|
TokenManager* token_store = nullptr);
|
|
~ModuleProxy();
|
|
|
|
void setTokenValidator(TokenValidator validator);
|
|
|
|
// Two explicit Q_INVOKABLE overloads rather than one with a defaulted
|
|
// transport arg: the Qt meta-object system matches by full parameter list
|
|
// and does not apply C++ default arguments, so the existing QtRO/local
|
|
// 3-arg call must remain a real 3-arg method. It forwards to the
|
|
// transport-aware 4-arg form with "local" (RemoteTransportHost is always
|
|
// local); remote hosts that know their wire (PlainTransportHost) call the
|
|
// 4-arg form so a transport-sensitive validator (local_only tokens) can
|
|
// enforce it.
|
|
Q_INVOKABLE QVariant callRemoteMethod(const QString& authToken, const QString& methodName, const QVariantList& args = QVariantList());
|
|
Q_INVOKABLE QVariant callRemoteMethod(const QString& authToken, const QString& methodName, const QVariantList& args, const QString& transportProtocol);
|
|
Q_INVOKABLE bool informModuleToken(const QString& authToken, const QString& moduleName, const QString& token);
|
|
bool saveToken(const QString& from_module_name, const QString& token);
|
|
// getPluginInterface() returns the module's whole interface (methods AND
|
|
// events, each tagged with a "type"); getPluginMethods()/getPluginEvents()
|
|
// are the type-filtered views. All three derive from the provider's single
|
|
// getMethods() call — there is no separate getEvents() vtable method, which
|
|
// is what keeps the provider ABI stable across SDK versions.
|
|
Q_INVOKABLE QJsonArray getPluginMethods();
|
|
Q_INVOKABLE QJsonArray getPluginEvents();
|
|
Q_INVOKABLE QJsonArray getPluginInterface();
|
|
|
|
signals:
|
|
void eventResponse(const QString& eventName, const QVariantList& data);
|
|
|
|
private:
|
|
// Returns true when authToken matches a token THIS module has been told
|
|
// about (via saveToken / informModuleToken), or one in the INBOUND half of
|
|
// this proxy's token store, or this identity's own credential, OR the
|
|
// host-installed validator accepts it for `transportProtocol`. The store's
|
|
// OUTBOUND half is deliberately not among them — see scanIssuedTokens().
|
|
// Empty/unknown tokens are rejected. The built-in comparison is constant-time
|
|
// and never early-outs, so neither a correct prefix nor the number of issued
|
|
// tokens leaks through timing.
|
|
//
|
|
// Kept as the two-argument spelling every existing call site and comment in
|
|
// the fleet names; it forwards to authorize() below with no caller-out.
|
|
bool isAuthorized(const QString& authToken, const QString& transportProtocol) const;
|
|
|
|
// The same decision, PLUS who made it.
|
|
//
|
|
// Fused into one scan rather than added as a second pass, for two reasons
|
|
// and the second is the important one. It costs zero extra comparisons:
|
|
// deciding whether a presented token matches an issued one is already a walk
|
|
// over every issued token, and the key is right there. And it keeps the
|
|
// constant-time property in ONE place — a separate "now find the name" loop
|
|
// is a second scan whose early-out looks obviously harmless and would
|
|
// reintroduce, in three lines, exactly the leak constantTimeEquals exists to
|
|
// close.
|
|
//
|
|
// On `true`, *callerJson (when non-null) receives the caller document
|
|
// described in logos_caller_scope.h — always a valid document, never empty,
|
|
// Unknown where the caller cannot be named honestly. Untouched on `false`
|
|
// beyond the Unknown it is initialised to: an unauthorized call has no
|
|
// caller because it has no dispatch.
|
|
bool authorize(const QString& authToken, const QString& transportProtocol,
|
|
std::string* callerJson) const;
|
|
|
|
LogosProviderObject* m_provider;
|
|
// THE INBOUND STORE: caller name -> the token that caller may present to us.
|
|
// Direction-pure by construction — the only writers are saveToken() and
|
|
// informModuleToken(), both of which key by the CALLER — which is what makes
|
|
// it the only store here that can honestly NAME a caller.
|
|
//
|
|
// NOT the only inbound record any more, and still the only NAMING one.
|
|
// TokenManager now has an inbound half of its own (m_store->inbound()),
|
|
// written by the provider; authorize() scans it but takes no name from it,
|
|
// because in the Qt stack the same (caller, token) pair lands in both and
|
|
// folding both would make every ordinary caller ambiguous. See
|
|
// scanIssuedTokens() in module_proxy.cpp.
|
|
//
|
|
// Never reverse-look-up m_store's OUTBOUND half for a caller name — and
|
|
// note that authorize() is no longer given anything that could: the
|
|
// outbound map holds the token we will PRESENT to a callee, filed under the
|
|
// CALLEE's name (logos_api_client.cpp:201), so a hit there would name a
|
|
// module we CALL as the module CALLING us.
|
|
QHash<QString, QString> m_tokens;
|
|
// Never null after construction; see the constructor comment.
|
|
//
|
|
// A new data member here is safe in a way one in LogosAPI is not (see the
|
|
// warning at logos_api.h:329). Nothing hands a ModuleProxy across an image
|
|
// boundary: it is constructed by the Qt host (LogosAPIProvider) and reached
|
|
// only through QMetaObject dispatch or, from a module cdylib, not at all —
|
|
// the type that crosses is the LogosProviderObject vtable, which is
|
|
// untouched.
|
|
TokenManager* m_store;
|
|
TokenValidator m_validator;
|
|
};
|
|
|
|
/**
|
|
* @brief The token-delivery-only surface described by logos::handshakeObjectName.
|
|
*
|
|
* Deliberately tiny: it exposes informModuleToken and nothing else, so
|
|
* publishing it early cannot expose business methods on a module that has not
|
|
* finished initializing. It forwards to the ModuleProxy that owns it, so a
|
|
* token delivered here lands in exactly the same store the business object
|
|
* consults later.
|
|
*/
|
|
class ModuleHandshakeProxy : public QObject
|
|
{
|
|
Q_OBJECT
|
|
|
|
public:
|
|
explicit ModuleHandshakeProxy(ModuleProxy* proxy, QObject* parent = nullptr);
|
|
|
|
Q_INVOKABLE bool informModuleToken(const QString& authToken,
|
|
const QString& moduleName,
|
|
const QString& token);
|
|
|
|
private:
|
|
QPointer<ModuleProxy> m_proxy;
|
|
};
|
|
|
|
#endif // MODULE_PROXY_H
|