mirror of
https://github.com/logos-co/logos-protocol.git
synced 2026-08-27 12:01:15 +00:00
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: LogosAPIClient
stored the token it will PRESENT to a callee under the CALLEE's name, and a
token RECEIVED from a caller was stored under the CALLER's name. Same key
namespace, last write wins.
Measured on the shipped fleet with two ordinary modules doing nothing unusual:
one grant A -> B leaves the SAME token value under both opposite-meaning keys,
and the never-granted B -> A call then succeeds. Silently.
A.callOther(B, ping) CALL_OK
T1 A holds token for B? val=7685c776-...
T1 B holds token for A? val=7685c776-... <-- one value, two meanings
B.callOther(A, ping) CALL_OK <-- never granted
WHY THE LAYOUT COULD NOT CHANGE. TokenManager's layout is a cross-package ABI:
the host ALLOCATES the object and module/UI-plugin images MUTATE it through
their own statically-linked accessors — and host and modules ship as separate
.lgx that mix versions at runtime by design. The header's ABI-safety note is
about ALLOCATION ("no consumer allocates one, none needs sizeof"); the hazard
is MUTATION.
Splitting into three members took sizeof 32 -> 64 and moved m_mutex 24 -> 56.
QMutex::fastTryLock() compare-exchanges at this+24, which in that layout is
m_inbound's QHash d-pointer. Empty, the old code silently borrows the hash's
pointer slot as a mutex and puts it back, so it LOOKS fine; non-empty, the
exchange fails and lockInternal() interprets the QHash Data* as a
QMutexPrivate* and futex-waits on it — hung forever, inside a token-store
write, on the module host's Qt main thread. No crash, no log line, no timeout
that recovers. Reproduced by calling the shipped 0.6 plugin's own saveToken on
a 0.7 object: exit=124.
So direction lives in the KEY NAMESPACE instead. Outbound is the bare peer name
(byte-identical to master); inbound is "\x01in\x01" + caller. m_tokens@16,
m_mutex@24, sizeof 32 — measured identical to master in every shipped image,
pinned by a static_assert against a reference struct that fires if a member is
added.
Two things a key namespace forces that separate members did not: every door
REFUSES a key carrying the namespace character, or a wire-supplied caller name
could forge across the direction boundary; and credential() is DERIVED from
bootstrapKeys() rather than cached, because a cached field reads empty on a
store another image wrote and then refuses every push.
AN ANCHOR KEY IS NO LONGER SPELLED AS A MODULE NAME. scanIssuedTokens' m_tokens
loop offered every matched key unconditionally while the m_store loop
deliberately never offers, so "an anchor must never name a caller" was enforced
on one side only. A module announcing itself as "core" — which logos-rust-sdk
did unprompted — therefore authorized as kind:module name:core. The rule
generalises: a store may only name a caller with a key it alone can write.
Implemented as a masked operand, so the comparison count is unchanged;
RefusingToNameAnAnchorKeyCostsNoComparison pins that via
logos::tokenComparisonCount().
lp_token_save / lp_token_save_for now return LP_ERR_INVALID_ARG on a reserved
key instead of LP_OK. Only the return code was wrong; saveToken already refused.
PROTOCOL 0.8: logos_module_accept_inbound_token joins the module-impl C ABI
(12 exports). onInit keeps logos_module_accept_token for the module's own
anchor — that one IS outbound, and merging the two paths is what reintroduces
the bug.
Supersedes the field-split approach; the semantics are unchanged from it.
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
|