Files
logos-protocol/cpp/module_proxy.cpp
T
Dario LipicarandClaude Opus 5 c1b0a0f554 fix(startup): publish a token-only handshake surface before a module initializes (#42)
* fix(startup): publish a token-only handshake surface before a module initializes

A module's initializer is synchronous and routinely calls out — a Qt module's
initLogos, a cdylib's context-ready hook — 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 once the initializer
returns, so that push had nothing to reach: capability waited for a source that
could not appear until the initializer returned, and the initializer could not
return until capability answered. On Linux this wedged UI startup until the
standalone app's 10s ui-host deadline expired and the view never rendered.

Adds a second, deliberately tiny surface — ModuleHandshakeProxy, published
under logos::handshakeObjectName(name) — carrying informModuleToken and nothing
else. It forwards to the ModuleProxy that owns the token store, so a grant
delivered early is the one the business object honours later, with the same
authorization.

The business object's publish timing is UNCHANGED, which is the point: a caller
of a real method still blocks at acquire until the module is genuinely ready,
exactly as it always has. An earlier attempt published the business object early
and refused calls during init; that quietly turned a call that used to wait and
succeed into one that returned empty, which old consumers cannot even detect.

informModuleToken_module now tries the handshake surface first (short probe) and
falls back to the business object, so modules built before this surface existed
are reached exactly as they are today. It also reuses the cached handle instead
of acquiring a fresh replica per grant, and takes a timeout (default unchanged).

No wire change, no ABI change, no reply-shape change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(startup): do not treat a handshake refusal as the final answer

The handshake surface is published before the target's initializer runs, so a
target whose token store is seeded BY that initializer refuses a push that
arrives first. Returning that refusal to the caller handed it an empty grant it
could not distinguish from a real denial: measured on Linux, the first
requestModule for wallet_backend_module came back empty in 29 of 34 runs, and
never once in the pre-surface baseline.

Fall through to the business object instead, which is what the caller got before
this surface existed. The business object is published only once the initializer
has returned, by which point the store is populated. The wait is bounded by the
caller's own budget -- capability_module passes 3000 ms, not the 20 s default
that made the original deadlock fatal -- so this cannot reintroduce the wedge.

The companion change in logos-qt-sdk seeds the trust anchor before publishing,
which removes the refusal at its source; this is the safety net for hosts and
modules that do not.

Also adds the regression test that would have caught this: the existing case
seeds "core" before pushing, which is exactly the state that does NOT hold in
the window the surface covers, so it asserted the surface works under a
precondition production never met.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(startup): marshal the token push, and stop re-probing a missing handshake

Two review findings from Copilot, both verified against the code before acting.

1. Thread affinity. informModuleToken_module was one of only two entry points in
   LogosAPIClient that did not wrap in logos::runOnOwnerThread -- requestObject,
   both invokeRemoteMethod forms and onEvent all do. The missing marshal is
   inherited, but THIS change is what made it reachable: the method used to take
   an uncached requestObject() + release() and touch no shared state, and routing
   it through acquireCachedObject put it on m_objectCache, which is declared
   single-threaded and holds thread-affine QtRO handles. Now marshalled, matching
   its four siblings.

   The 3-arg informModuleToken has the same gap but still uses an uncached handle
   and predates this work, so it is deliberately left alone rather than widened
   into this fix; noted at the call site.

2. No negative cache on the handshake probe. acquireCachedObject caches successes
   only, so a module built before the handshake surface existed failed the probe
   on EVERY grant -- and on QtRO that failure is a blocking waitForSource, i.e.
   250 ms of dead time per token, forever. Remember the absence and go straight to
   the business object; cleared by clearObjectCache() so a reconnect, or a module
   reloaded from a build that has the surface, is re-probed rather than written
   off permanently.

   (The review attributed this cost to the Local/Plain adapters rejecting a
   non-ModuleProxy object. Checked per transport: plain is unaffected -- its token
   push is nameless fire-and-forget and it never had the acquire deadlock -- and
   on qt_local requestObject ignores timeoutMs entirely, so the cost there is a
   spurious warning, not 250 ms. The real cost is the missing negative cache, on
   QtRO.)

The same review's ABI-break and name-collision findings were measured and do not
apply: logos_protocol is a static archive with zero undefined imports of these
symbols anywhere in the built stack, and object names are scoped to a per-module
socket rather than a global registry. Both answered in-thread.

290/290 protocol tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(startup): exercise the handshake surface over a real transport

The existing handshake cases call ModuleHandshakeProxy directly, with no
transport underneath. That is what let a whole class of defect through: the
surface is only useful if a transport will PUBLISH a token-only QObject and a
consumer can ACQUIRE it by the derived name, and a direct-call test can see
neither half. The adapter survey prompted by review found qt_local silently
rejects a non-ModuleProxy on acquire while still reporting a successful publish
-- invisible to every test in the suite.

These run on the transport the production stack actually uses (QtRO, the
LogosTransportConfig default), and model the startup window honestly: the
handshake object is published and the business object deliberately is NOT,
because it does not exist until the initializer returns. That window is the
entire reason the surface exists and is the one state the direct-call tests
could never represent.

  TokenReachesAModuleWhoseBusinessObjectIsNotPublishedYet
      the pre-init window end to end: publish -> probe by derived name ->
      acquire -> push lands on the provider.
  AnUnseededAnchorRefusesEvenThoughTheSurfaceIsReachable
      the transport-level twin of the gate test: proves the refusal measured in
      production (29 of 34 app runs) is the gate rejecting the push, not the
      transport failing to deliver it -- the provider is never reached.
  ALegacyModuleFallsBackAndIsNotReProbed
      a module with no handshake surface still gets its token, and the missing
      surface is probed ONCE. Timed rather than functional, so it was falsified
      before being trusted: with the negative cache removed the suite fails on
      exactly this case and no other.

293/293 protocol tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 23:58:41 -03:00

276 lines
11 KiB
C++

#include "module_proxy.h"
#include "logos_provider_interface.h"
#include "token_manager.h"
#include "logos_rpc_status.h"
#include <QDebug>
#include <QByteArray>
#include <QJsonObject>
#include <QJsonValue>
#include <algorithm>
ModuleProxy::ModuleProxy(LogosProviderObject* provider, QObject* parent)
: QObject(parent)
, m_provider(provider)
{
if (m_provider) {
m_provider->setEventListener([this](const QString& eventName, const QVariantList& data) {
qDebug() << "[LogosProviderObject] ModuleProxy: forwarding event" << eventName << "as Qt signal";
// Events may be fired from any thread (e.g. a module's worker/FFI
// thread), but this object is the QtRemoteObjects source and must be
// driven from its own thread. Emitting directly from a foreign
// thread runs QtRO's source serialization there, racing the source
// socket against a reply being sent from the source thread, which
// can silently drop the reply.
//
// We *always* queue the emission to this object's own thread, never
// emit inline — even for a same-thread caller. A module that emits an
// event from inside an async-call-completion callback (e.g. a
// gather/fan-out completion firing `balances_updated` from within the
// `__logos_call_complete__` reply dispatch) is on the source thread,
// so an AutoConnection would run QtRO's source serialization for the
// event *re-entrantly*, while a reply is still being marshalled on the
// same stack — corrupting the source and crashing (SIGSEGV). A queued
// connection defers the emit to the next event-loop turn, after the
// reply has been sent, so events and replies stay serialized on the
// thread QtRO owns. Passing `this` as the context also cancels a
// queued emission if this object is destroyed first.
QMetaObject::invokeMethod(this, [this, eventName, data]() {
emit eventResponse(eventName, data);
}, Qt::QueuedConnection);
});
qDebug() << "[LogosProviderObject] ModuleProxy: created, wrapping LogosProviderObject"
<< m_provider->providerName();
}
}
ModuleProxy::~ModuleProxy()
{
qDebug() << "ModuleProxy: destroyed";
}
bool ModuleProxy::saveToken(const QString& from_module_name, const QString& token)
{
if (from_module_name.isEmpty()) {
qWarning() << "ModuleProxy: Cannot save token with empty module name";
return false;
}
if (token.isEmpty()) {
qWarning() << "ModuleProxy: Cannot save empty token for module:" << from_module_name;
return false;
}
m_tokens[from_module_name] = token;
qDebug() << "ModuleProxy: Token saved for module:" << from_module_name;
return true;
}
void ModuleProxy::setTokenValidator(TokenValidator validator)
{
m_validator = std::move(validator);
}
// QtRO / local path: RemoteTransportHost only ever serves a local socket, so
// the wire is "local". Forwards to the transport-aware overload.
QVariant ModuleProxy::callRemoteMethod(const QString& authToken, const QString& methodName, const QVariantList& args)
{
return callRemoteMethod(authToken, methodName, args, QStringLiteral("local"));
}
QVariant ModuleProxy::callRemoteMethod(const QString& authToken, const QString& methodName, const QVariantList& args, const QString& transportProtocol)
{
if (!m_provider) {
qWarning() << "ModuleProxy: Cannot call method on null provider:" << methodName;
return QVariant();
}
if (methodName.isEmpty()) {
qWarning() << "ModuleProxy: Method name cannot be empty";
return QVariant();
}
if (methodName == "getPluginMethods" && args.isEmpty()) {
return QVariant(getPluginMethods());
}
if (methodName == "getPluginEvents" && args.isEmpty()) {
return QVariant(getPluginEvents());
}
if (methodName == "getPluginInterface" && args.isEmpty()) {
return QVariant(getPluginInterface());
}
// NOTE: the three getPlugin* introspection calls above intentionally stay
// ungated. They expose only the method/event signatures (no business logic
// or state) and are needed before any token exists — a caller discovers a
// module's interface as part of the connection handshake, ahead of the
// capability_module token exchange. Everything past this point is a real
// business-method dispatch and MUST be authorized.
if (!isAuthorized(authToken, transportProtocol)) {
qWarning() << "ModuleProxy: rejecting unauthorized call to" << methodName
<< "- auth token not recognized";
// Structured rejection instead of a bare QVariant() so a NEW consumer can
// drop its stale token and re-exchange (see logos_rpc_status.h /
// LogosAPIClient::invokeRemoteMethod). OLD consumers convert this to the
// same empty/default they already got from QVariant(), so it's backward
// compatible.
return logos::makeUnauthorizedSentinel();
}
// SECURITY: never log call arguments — they routinely carry secrets
// (mnemonics, passwords, tokens, key material). Log only the method name and
// the argument count, matching the other transport call sites.
qDebug() << "ModuleProxy: callRemoteMethod" << methodName << "args:" << args.size();
return m_provider->callMethod(methodName, args);
}
namespace {
// note: this is to ensure comparison is constant time to prevent timing attacks
// Length-independent constant-time comparison of two tokens. Returns true only
// when both byte sequences are identical. We compare over the longer of the two
// lengths (folding any length difference into the result) so the running time
// does not reveal a correct prefix or the secret's length.
bool constantTimeEquals(const QString& a, const QString& b)
{
const QByteArray ba = a.toUtf8();
const QByteArray bb = b.toUtf8();
const int n = std::max(ba.size(), bb.size());
// A different length is a mismatch, but keep scanning to stay constant-time.
int diff = ba.size() ^ bb.size();
for (int i = 0; i < n; ++i) {
const unsigned char ca = i < ba.size() ? static_cast<unsigned char>(ba[i]) : 0;
const unsigned char cb = i < bb.size() ? static_cast<unsigned char>(bb[i]) : 0;
diff |= (ca ^ cb);
}
return diff == 0;
}
} // namespace
bool ModuleProxy::informModuleToken(const QString& authToken, const QString& moduleName, const QString& token)
{
if (!m_provider) {
qWarning() << "ModuleProxy: Cannot inform token on null provider";
return false;
}
const QString coreToken = TokenManager::instance().getToken(QStringLiteral("core"));
const QString capToken = TokenManager::instance().getToken(QStringLiteral("capability_module"));
const bool callerIsTrusted =
(!coreToken.isEmpty() && constantTimeEquals(authToken, coreToken)) ||
(!capToken.isEmpty() && constantTimeEquals(authToken, capToken));
if (authToken.isEmpty() || !callerIsTrusted) {
qWarning() << "ModuleProxy: rejecting informModuleToken for" << moduleName
<< "- caller is not the trusted core/capability_module channel";
return false;
}
if (moduleName.isEmpty()) {
qWarning() << "ModuleProxy: Cannot inform token with empty module name";
return false;
}
if (token.isEmpty()) {
qWarning() << "ModuleProxy: Cannot inform empty token for module:" << moduleName;
return false;
}
return m_provider->informModuleToken(moduleName, token);
}
bool ModuleProxy::isAuthorized(const QString& authToken, const QString& transportProtocol) const
{
// Fail closed: an empty token is never valid, even if some empty value
// somehow ended up in a token store.
if (authToken.isEmpty()) {
return false;
}
// A token is valid only if THIS module actually issued it to some caller.
// Two stores hold issued tokens:
// * m_tokens — legacy per-proxy store (LogosAPIProvider::saveToken)
// * TokenManager — the capability-flow store that informModuleToken
// writes when capability_module mints a token for a
// (caller, target) pair.
// We scan every issued token with a constant-time compare and never early
// out, so neither a match position nor the number of issued tokens leaks
// through timing.
bool authorized = false;
for (auto it = m_tokens.constBegin(); it != m_tokens.constEnd(); ++it) {
authorized |= constantTimeEquals(authToken, it.value());
}
for (const QString& key : TokenManager::instance().getTokenKeys()) {
authorized |= constantTimeEquals(authToken, TokenManager::instance().getToken(key));
}
if (authorized) {
return true;
}
// Not one of our own issued tokens — give a host-installed validator the
// chance to accept it for this transport. This is how operator-issued named
// tokens (validated against the daemon's TokenStore, with expiry and
// local_only enforced by `transportProtocol`) authorize a call without
// being pre-registered in the in-process stores above.
if (m_validator) {
return m_validator(authToken, transportProtocol);
}
return false;
}
namespace {
// getMethods() returns the module's full interface — both methods and events,
// each tagged with a "type" ("method"/"event"). Split it back out. An entry
// with no "type" counts as a method, so modules built against the pre-events
// SDK (whose getMethods() contains no events) report zero events, not a crash.
QJsonArray filterInterface(const QJsonArray& interface, bool keepEvents)
{
QJsonArray out;
for (const QJsonValue& v : interface) {
const bool isEvent =
v.toObject().value(QStringLiteral("type")).toString() == QStringLiteral("event");
if (isEvent == keepEvents) out.append(v);
}
return out;
}
} // namespace
QJsonArray ModuleProxy::getPluginInterface()
{
if (!m_provider) return QJsonArray();
qDebug() << "[LogosProviderObject] ModuleProxy: calling LogosProviderObject::getMethods()";
return m_provider->getMethods();
}
QJsonArray ModuleProxy::getPluginMethods()
{
return filterInterface(getPluginInterface(), /*keepEvents=*/false);
}
QJsonArray ModuleProxy::getPluginEvents()
{
return filterInterface(getPluginInterface(), /*keepEvents=*/true);
}
#include "moc_module_proxy.cpp"
// ── ModuleHandshakeProxy ─────────────────────────────────────────────────────
ModuleHandshakeProxy::ModuleHandshakeProxy(ModuleProxy* proxy, QObject* parent)
: QObject(parent)
, m_proxy(proxy)
{
}
bool ModuleHandshakeProxy::informModuleToken(const QString& authToken,
const QString& moduleName,
const QString& token)
{
if (!m_proxy) {
qWarning() << "ModuleHandshakeProxy: no module proxy to deliver the token for"
<< moduleName;
return false;
}
// Same authorization and same store as the business object — this is only a
// different door onto it, reachable earlier.
return m_proxy->informModuleToken(authToken, moduleName, token);
}