mirror of
https://github.com/logos-co/logos-protocol.git
synced 2026-08-31 05:51:08 +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>
381 lines
18 KiB
C++
381 lines
18 KiB
C++
// DIRECTION. One image, one store, both directions writing the SAME name.
|
|
//
|
|
// This file is the detector for the split described in token_manager.h's
|
|
// m_tokens comment. Every test here is RED on the tree it was written against
|
|
// (c698402, "a private store is created EMPTY"); the numbers are at the bottom.
|
|
//
|
|
// THE SHAPE IT REPRODUCES IS PRODUCTION, NOT A HYPOTHETICAL. A module loaded by
|
|
// logos-module-loader-qt runs in its own process, and in THAT process one
|
|
// TokenManager takes all three writes:
|
|
//
|
|
// * OUTBOUND LogosAPIClient caches the token capability_module minted for
|
|
// <me -> B> under B's name (logos_api_client.cpp:201, async twin
|
|
// :357), reading it back at :124 / :313.
|
|
// * INBOUND LogosProviderBase::informModuleToken saves the token a caller
|
|
// will present under the CALLER's name (logos-plugin-qt
|
|
// logos_provider_object.cpp:53) — into
|
|
// LogosAPI::getTokenManager(), the same object.
|
|
// * ANCHOR the module's own host-issued credential, under BOTH
|
|
// bootstrapKeys() (module_initializer.cpp:169-170; for a cdylib
|
|
// the generated glue's logos_module_accept_token("core") /
|
|
// ("capability_module"), lidl_gen_cdylib_glue.cpp:412-413).
|
|
//
|
|
// ModuleProxy::authorize scans that whole map (module_proxy.cpp:419-424) and
|
|
// accepts ANY value in it. So an OUTBOUND token authorizes an INBOUND call.
|
|
//
|
|
// WHAT THAT COSTS, CONCRETELY: every capability grant is silently BIDIRECTIONAL.
|
|
// capability_module mints one value, hands it to M (which caches it outbound
|
|
// under "B") and pushes it to B (which records it inbound under "M"). B may then
|
|
// call M with it — M finds it in its own outbound cache and authorizes — though
|
|
// nothing ever granted B -> M. The grant graph the access policy
|
|
// (capability_module_plugin.cpp:99-106) is written against is directed; the
|
|
// enforcement is not.
|
|
#include <gtest/gtest.h>
|
|
|
|
#include "logos_provider_interface.h"
|
|
#include "logos_rpc_status.h"
|
|
#include "module_proxy.h"
|
|
#include "token_manager.h"
|
|
|
|
#include <QCoreApplication>
|
|
#include <QJsonArray>
|
|
#include <QJsonObject>
|
|
#include <QString>
|
|
#include <QVariantList>
|
|
|
|
namespace {
|
|
|
|
QCoreApplication* ensureDirectionApp() {
|
|
static int argc = 0;
|
|
static char* argv[] = { nullptr };
|
|
if (!QCoreApplication::instance())
|
|
new QCoreApplication(argc, argv);
|
|
return QCoreApplication::instance();
|
|
}
|
|
|
|
// Stands in for LogosProviderBase::informModuleToken, which writes the SAME
|
|
// store the proxy authorizes against. That identity is the whole point: a
|
|
// provider that wrote somewhere else would not reproduce the collision.
|
|
//
|
|
// THE ONE LINE THAT MOVED, AND THE COMPANION CHANGE IT MIRRORS. The real body
|
|
// was `getTokenManager()->saveToken(moduleName, token)` — the OUTBOUND door,
|
|
// keyed by the CALLEE — and that mis-spelling is half of what this file
|
|
// detects. It is now `saveInboundToken`, and logos-plugin-qt owes the identical
|
|
// one-line move in cpp/logos_provider_object.cpp:53 and
|
|
// cpp/qt_provider_object.cpp:497 in the same wave.
|
|
//
|
|
// So this stand-in is NOT quietly moving the goalposts, and it is worth being
|
|
// exact about which assertions depend on it. Tests 1 and 5-half-two are red on
|
|
// the old tree with this provider written EITHER way: they are about the scan
|
|
// no longer seeing the outbound map, which is a logos-protocol change alone.
|
|
// Tests 2c, 3 and 5-half-one are the ones that need the door to move, and they
|
|
// are precisely the assertions ABOUT the door: an inbound push must not land in
|
|
// the outbound cache. Without the companion change those three stay red, which
|
|
// is the correct signal — they are the thing that says logos-plugin-qt has not
|
|
// landed yet.
|
|
class DirectionProvider : public LogosProviderObject {
|
|
public:
|
|
explicit DirectionProvider(TokenManager* store) : m_store(store) {}
|
|
|
|
QVariant callMethod(const QString& method, const QVariantList&) override {
|
|
if (method == QLatin1String("work")) return QStringLiteral("worked");
|
|
return QVariant();
|
|
}
|
|
bool informModuleToken(const QString& moduleName, const QString& token) override {
|
|
if (m_store) m_store->saveInboundToken(moduleName, token);
|
|
return true;
|
|
}
|
|
QJsonArray getMethods() override {
|
|
QJsonObject work;
|
|
work["name"] = QStringLiteral("work");
|
|
work["type"] = QStringLiteral("method");
|
|
return QJsonArray{ work };
|
|
}
|
|
void setEventListener(EventCallback) override {}
|
|
void init(void*) override {}
|
|
QString providerName() const override { return QStringLiteral("direction_module"); }
|
|
QString providerVersion() const override { return QStringLiteral("1.0.0"); }
|
|
|
|
private:
|
|
TokenManager* m_store;
|
|
};
|
|
|
|
bool callSucceeds(ModuleProxy& proxy, const QString& token) {
|
|
const QVariant r = proxy.callRemoteMethod(token, QStringLiteral("work"), {});
|
|
return !logos::isUnauthorizedSentinel(r) && r.toString() == QStringLiteral("worked");
|
|
}
|
|
|
|
// The ONE store a module process has: isolated so it is a distinct object from
|
|
// the suite-wide ambient ring (forIdentity returns instance() until a name is
|
|
// isolated), then given its OWN credential exactly as logos::admitConsumer does.
|
|
struct ModuleImage {
|
|
explicit ModuleImage(const QString& identity, const QString& credential)
|
|
: store(nullptr), cred(credential)
|
|
{
|
|
EXPECT_TRUE(TokenManager::isolateIdentity(identity));
|
|
store = &TokenManager::forIdentity(identity);
|
|
EXPECT_NE(store, &TokenManager::instance());
|
|
store->adoptCredential(credential);
|
|
}
|
|
TokenManager* store;
|
|
QString cred;
|
|
};
|
|
|
|
} // namespace
|
|
|
|
// ── 1. THE DETECTOR ─────────────────────────────────────────────────────────
|
|
//
|
|
// An OUTBOUND cache entry must not authorize an INBOUND call.
|
|
//
|
|
// Nothing else in this suite asserts it. test_call_caller's
|
|
// ATokenFoundOnlyInTheDirectionMixedStoreNamesNobody covers the NAMING half —
|
|
// such a token resolves to Unknown — and deliberately asserts that it still
|
|
// AUTHORIZES. That is the line this test moves.
|
|
TEST(TokenDirection, AnOutboundTokenDoesNotAuthorizeAnInboundCall)
|
|
{
|
|
ensureDirectionApp();
|
|
ModuleImage m(QStringLiteral("direction_outbound_only"),
|
|
QStringLiteral("direction-own-credential-1"));
|
|
DirectionProvider provider(m.store);
|
|
ModuleProxy proxy(&provider, nullptr, m.store);
|
|
|
|
// Exactly logos_api_client.cpp:201 — the token capability_module minted for
|
|
// <this module -> peer_b>, cached under the CALLEE's name. peer_b holds this
|
|
// value too; it is the value peer_b was informed of.
|
|
m.store->saveToken(QStringLiteral("peer_b"),
|
|
QStringLiteral("minted-for-me-to-call-peer_b"));
|
|
|
|
EXPECT_FALSE(callSucceeds(proxy, QStringLiteral("minted-for-me-to-call-peer_b")))
|
|
<< "an outbound per-target token authorized an inbound call: the grant "
|
|
"me->peer_b silently also grants peer_b->me";
|
|
}
|
|
|
|
// ── 2. the same name from both sides, inbound written first ─────────────────
|
|
//
|
|
// The mutual-call case: I call B and B calls me. Two DIFFERENT values, one key.
|
|
// Post-split each must survive under its own direction and only its own
|
|
// direction; today one map means the second write wins and the first value is
|
|
// simply gone.
|
|
//
|
|
// Written inbound-first so the outbound value is the survivor, which is what
|
|
// makes the third expectation a detector rather than an artefact of clobbering.
|
|
TEST(TokenDirection, TheSameNameInBothDirectionsKeepsBothValues)
|
|
{
|
|
ensureDirectionApp();
|
|
ModuleImage m(QStringLiteral("direction_both_ways"),
|
|
QStringLiteral("direction-own-credential-2"));
|
|
DirectionProvider provider(m.store);
|
|
ModuleProxy proxy(&provider, nullptr, m.store);
|
|
|
|
const QString inbound = QStringLiteral("peer_b-presents-this-to-me");
|
|
const QString outbound = QStringLiteral("i-present-this-to-peer_b");
|
|
|
|
// INBOUND first: capability_module pushes over the trusted channel, which
|
|
// this store's own credential satisfies (module_proxy.cpp:287-291).
|
|
ASSERT_TRUE(proxy.informModuleToken(m.cred, QStringLiteral("peer_b"), inbound));
|
|
// OUTBOUND second.
|
|
m.store->saveToken(QStringLiteral("peer_b"), outbound);
|
|
|
|
// (a) the inbound token still authorizes — the pin.
|
|
EXPECT_TRUE(callSucceeds(proxy, inbound))
|
|
<< "the token peer_b was issued no longer authorizes it";
|
|
|
|
// (b) the outbound token does not — the detector.
|
|
EXPECT_FALSE(callSucceeds(proxy, outbound))
|
|
<< "the token I present to peer_b authorized an inbound call";
|
|
|
|
// (c) the outbound value is readable back under peer_b's name — the round
|
|
// trip logos_api_client.cpp:124 makes on the next call.
|
|
EXPECT_EQ(m.store->getToken(QStringLiteral("peer_b")), outbound);
|
|
}
|
|
|
|
// ── 3. the same pair, outbound written first ────────────────────────────────
|
|
//
|
|
// The order that shows the FUNCTIONAL half, independent of any security claim:
|
|
// today the inbound push CLOBBERS the outbound cache, so this module's next call
|
|
// to peer_b goes out carrying peer_b's own inbound token. peer_b rejects it, and
|
|
// the client burns its one re-exchange (logos_api_client.cpp:136-143) recovering
|
|
// from a collision it caused itself.
|
|
TEST(TokenDirection, AnInboundPushDoesNotClobberTheOutboundCache)
|
|
{
|
|
ensureDirectionApp();
|
|
ModuleImage m(QStringLiteral("direction_clobber"),
|
|
QStringLiteral("direction-own-credential-3"));
|
|
DirectionProvider provider(m.store);
|
|
ModuleProxy proxy(&provider, nullptr, m.store);
|
|
|
|
const QString outbound = QStringLiteral("cached-for-calling-peer_c");
|
|
const QString inbound = QStringLiteral("peer_c-presents-this-to-me");
|
|
|
|
m.store->saveToken(QStringLiteral("peer_c"), outbound);
|
|
ASSERT_TRUE(proxy.informModuleToken(m.cred, QStringLiteral("peer_c"), inbound));
|
|
|
|
EXPECT_EQ(m.store->getToken(QStringLiteral("peer_c")), outbound)
|
|
<< "an inbound push overwrote the outbound per-target cache";
|
|
}
|
|
|
|
// ── 4. PINS. Everything the split must NOT break. ───────────────────────────
|
|
|
|
// The identity's own credential still authorizes inbound (the host arm), and is
|
|
// still what the trusted-channel gate compares against.
|
|
TEST(TokenDirection, TheOwnCredentialStillAuthorizesAndStillGatesPushes)
|
|
{
|
|
ensureDirectionApp();
|
|
ModuleImage m(QStringLiteral("direction_pin_anchor"),
|
|
QStringLiteral("direction-own-credential-4"));
|
|
DirectionProvider provider(m.store);
|
|
ModuleProxy proxy(&provider, nullptr, m.store);
|
|
|
|
EXPECT_TRUE(callSucceeds(proxy, m.cred));
|
|
EXPECT_TRUE(proxy.informModuleToken(m.cred, QStringLiteral("peer_d"),
|
|
QStringLiteral("granted-to-peer_d")));
|
|
EXPECT_FALSE(proxy.informModuleToken(QStringLiteral("not-the-credential"),
|
|
QStringLiteral("peer_e"),
|
|
QStringLiteral("granted-to-peer_e")));
|
|
}
|
|
|
|
// An inbound token authorizes, from a store nobody wrote outbound.
|
|
TEST(TokenDirection, AnInboundTokenStillAuthorizes)
|
|
{
|
|
ensureDirectionApp();
|
|
ModuleImage m(QStringLiteral("direction_pin_inbound"),
|
|
QStringLiteral("direction-own-credential-5"));
|
|
DirectionProvider provider(m.store);
|
|
ModuleProxy proxy(&provider, nullptr, m.store);
|
|
|
|
ASSERT_TRUE(proxy.informModuleToken(m.cred, QStringLiteral("peer_f"),
|
|
QStringLiteral("granted-to-peer_f")));
|
|
EXPECT_TRUE(callSucceeds(proxy, QStringLiteral("granted-to-peer_f")));
|
|
}
|
|
|
|
// The outbound read path is untouched: what the client cached is what the client
|
|
// reads back. A split that broke this would break every second cross-module call.
|
|
TEST(TokenDirection, TheOutboundCacheStillReadsBack)
|
|
{
|
|
ensureDirectionApp();
|
|
ModuleImage m(QStringLiteral("direction_pin_outbound"),
|
|
QStringLiteral("direction-own-credential-6"));
|
|
|
|
m.store->saveToken(QStringLiteral("peer_g"), QStringLiteral("tok-g"));
|
|
EXPECT_EQ(m.store->getToken(QStringLiteral("peer_g")), QStringLiteral("tok-g"));
|
|
EXPECT_TRUE(m.store->hasToken(QStringLiteral("peer_g")));
|
|
EXPECT_TRUE(m.store->getTokenKeys().contains(QStringLiteral("peer_g")));
|
|
}
|
|
|
|
// ── 5. THE WHOLE MECHANISM, both halves, in the shape production builds it ──
|
|
//
|
|
// capability_module mints ONE value and it lands in two places
|
|
// (capability_module_plugin.cpp:108-131):
|
|
//
|
|
// * pushed to B as "the token M will present" -> B's store, keyed "M"
|
|
// * returned to M and cached as "what I present to B"
|
|
// -> M's store, keyed "B"
|
|
//
|
|
// B's client then reads its OWN store for a token to present to M
|
|
// (logos_api_client.cpp:124 -> m_token_manager->getToken("M"), and that
|
|
// m_token_manager is the same object LogosProviderBase::informModuleToken wrote,
|
|
// logos-plugin-qt logos_api.cpp:53 / logos_provider_object.cpp:46). It finds a
|
|
// non-empty value, so it SKIPS requestModule entirely — and M authorizes it,
|
|
// because M cached the identical value outbound.
|
|
//
|
|
// So one grant M -> B silently produces B -> M, with no handshake, nothing
|
|
// logged, and capability_module's access policy
|
|
// (capability_module_plugin.cpp:99-106) never consulted. Both halves are
|
|
// asserted, because either one alone would close it.
|
|
TEST(TokenDirection, AGrantOneWayIsNotAGrantTheOtherWay)
|
|
{
|
|
ensureDirectionApp();
|
|
ModuleImage mi(QStringLiteral("direction_pair_m"),
|
|
QStringLiteral("direction-own-credential-m"));
|
|
ModuleImage bi(QStringLiteral("direction_pair_b"),
|
|
QStringLiteral("direction-own-credential-b"));
|
|
DirectionProvider providerM(mi.store);
|
|
DirectionProvider providerB(bi.store);
|
|
ModuleProxy proxyM(&providerM, nullptr, mi.store);
|
|
ModuleProxy proxyB(&providerB, nullptr, bi.store);
|
|
|
|
const QString minted = QStringLiteral("capability-minted-for-M-calling-B");
|
|
|
|
// capability_module -> B: "M may present this".
|
|
ASSERT_TRUE(proxyB.informModuleToken(bi.cred, QStringLiteral("direction_pair_m"),
|
|
minted));
|
|
// capability_module -> M: the return value of requestModule, cached.
|
|
mi.store->saveToken(QStringLiteral("direction_pair_b"), minted);
|
|
|
|
// Half one: B's client must find NOTHING to present to M, so it is forced
|
|
// through requestModule — where the access policy lives.
|
|
EXPECT_TRUE(bi.store->getToken(QStringLiteral("direction_pair_m")).isEmpty())
|
|
<< "B's outbound lookup for M returned the token M was issued for "
|
|
"calling B; B's next call to M skips requestModule entirely";
|
|
|
|
// Half two: even handed the value, M must refuse it. M never issued it.
|
|
EXPECT_FALSE(callSucceeds(proxyM, minted))
|
|
<< "B authorized at M using the token minted for M -> B: the grant is "
|
|
"bidirectional and the access policy was never consulted";
|
|
}
|
|
|
|
// ── 6. THE SPLIT ITSELF: neither half may read the other ────────────────────
|
|
//
|
|
// The two accessors are the mechanism, so the mechanism gets its own assertion.
|
|
// A read-side fall-through — `inbound().token(x)` answering from the outbound
|
|
// map when it comes up empty, or getToken(x) answering from the inbound one —
|
|
// is the single change that would put the collision back while looking like a
|
|
// convenience, and it is the change this test exists to fail on.
|
|
TEST(TokenDirection, NeitherHalfAnswersForTheOther)
|
|
{
|
|
ensureDirectionApp();
|
|
ModuleImage m(QStringLiteral("direction_no_fallthrough"),
|
|
QStringLiteral("direction-own-credential-7"));
|
|
|
|
m.store->saveToken(QStringLiteral("only_outbound"), QStringLiteral("tok-out"));
|
|
ASSERT_TRUE(m.store->saveInboundToken(QStringLiteral("only_inbound"),
|
|
QStringLiteral("tok-in")));
|
|
|
|
EXPECT_TRUE(m.store->inbound().token(QStringLiteral("only_outbound")).isEmpty())
|
|
<< "the inbound read fell through to the outbound map";
|
|
EXPECT_FALSE(m.store->inbound().contains(QStringLiteral("only_outbound")));
|
|
|
|
EXPECT_TRUE(m.store->getToken(QStringLiteral("only_inbound")).isEmpty())
|
|
<< "the outbound read fell through to the inbound map: a module would "
|
|
"present a peer's own credential as its own";
|
|
EXPECT_FALSE(m.store->hasToken(QStringLiteral("only_inbound")));
|
|
EXPECT_FALSE(m.store->getTokenKeys().contains(QStringLiteral("only_inbound")));
|
|
|
|
// The credential is a VALUE, not a key in either map, so no reverse lookup
|
|
// over either map can produce a name from it.
|
|
EXPECT_EQ(m.store->credential(), m.cred);
|
|
EXPECT_FALSE(m.store->inbound().keys().contains(QStringLiteral("core")));
|
|
EXPECT_FALSE(m.store->inbound().keys().contains(QStringLiteral("capability_module")));
|
|
|
|
// An empty inbound value is refused rather than stored: it would read as
|
|
// PRESENT to contains() while authorizing nothing.
|
|
EXPECT_FALSE(m.store->saveInboundToken(QStringLiteral("empty_peer"), QString()));
|
|
EXPECT_FALSE(m.store->inbound().contains(QStringLiteral("empty_peer")));
|
|
}
|
|
|
|
// ── HOW THIS WAS RUN, AND WHAT IT SAID ──────────────────────────────────────
|
|
//
|
|
// BEFORE — UNMODIFIED c698402 plus this file only (with DirectionProvider still
|
|
// spelling its write `saveToken`, as logos-plugin-qt does today), so the reds
|
|
// below are the shipped behaviour and not a neutered build.
|
|
// `nix build .#checks.x86_64-linux.tests`, x86_64-linux, 24 cores.
|
|
//
|
|
// 99% tests passed, 4 tests failed out of 513 (160.7s)
|
|
//
|
|
// 422 - AnOutboundTokenDoesNotAuthorizeAnInboundCall Actual: true, want false
|
|
// 423 - TheSameNameInBothDirectionsKeepsBothValues Actual: true, want false
|
|
// 424 - AnInboundPushDoesNotClobberTheOutboundCache got the INBOUND value
|
|
// under the callee's key
|
|
// 428 - AGrantOneWayIsNotAGrantTheOtherWay BOTH halves red:
|
|
// B's outbound lookup for M returned M's token (want empty), and
|
|
// M authorized B with it (want refused)
|
|
//
|
|
// 425, 426, 427 PASSED — the three pins. They are green here and must stay
|
|
// green after the split; read none of them as evidence of anything it adds.
|
|
//
|
|
// The other 509 are untouched, which is the second half of the claim: the
|
|
// collision is reachable from a store nothing else in the suite disturbs.
|
|
//
|
|
// AFTER — see the run recorded at the top of cpp/token_manager.cpp's DIRECTION
|
|
// note and in the change's own report.
|