Files
Dario Gabriel LipicarandClaude Opus 5 42460e5b2a fix(tokens): separate INBOUND from OUTBOUND, without moving a single byte
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>
2026-08-24 11:16:33 -03:00

278 lines
13 KiB
C++

// THE INBOUND DOOR ACROSS THE C ABI — lp_token_save_inbound, protocol 0.8.
//
// WHAT IT IS FOR. The generated Qt glue's informModuleToken used to write the
// SAME value through two doors:
//
// LogosProviderBase::informModuleToken(moduleName, token); // HOST image
// logos_module_accept_token(moduleName, token); // THIS image
//
// The value is a CALLER's token — capability_module saying "moduleName may call
// you" — and the second door forwarded to lp_token_save, the OUTBOUND family.
// So a cdylib filed every caller's token as a credential to PRESENT BACK to
// that caller. That is the direction collision logos-protocol's store split
// removed from the host image, reconstituted one image deeper, and it was
// measured end to end on shipped artifacts: after capability_module minted
// <A -> B> and pushed it to B, B's own LogosAPIClient found the value under "A",
// logged "Found token", SKIPPED requestModule, presented it to A, was rejected
// ("auth token not recognized"), and re-exchanged. Every call of every two-way
// pair paid a rejection plus a full extra round trip, permanently, and the
// caller saw only success.
//
// HOW THESE ARE DETECTORS. Three neutered builds of lp_token_save_inbound were
// run against this file; the counts below are measured, not predicted, and the
// restored control is 7/7 green.
//
// A. point it at TokenManager::saveToken — i.e. spell it the way the glue
// used to. 6 of 7 RED. This is the bug, exactly:
// AnInboundPushIsNotAnOutboundCredential
// "Which is: 0x955010 / Which is: (nullptr)" — lp_token_get answered
// AnInboundPushDoesNotClobberAnExistingOutboundCache
// "T-peer-presents-to-me" vs "T-i-present-to-peer" — the cache was
// overwritten by the inbound push, which is the direction collision
// (and the four carve-out / argument cases, which lose the inbound half
// entirely)
//
// B. delete the hostServiceGranted(ServiceTokenRegistry) carve-out. 2 RED:
// ATokenRegistryStillGetsItsOutboundRoster (roster empty, token "")
// RevokingTheGrantStopsTheCarveOut
// This is the OPPOSITE failure and it is fleet-fatal: capability_module
// reads lp_token_keys() for its known-caller gate and lp_token_get() for
// the credential it presents when pushing, so an inbound-only door empties
// its roster and every requestModule is refused with "rejecting request
// from unknown module identity" — fail-closed, and total, at the first
// cross-module call.
//
// C. make the carve-out unconditional. 4 RED, including
// AnInboundPushIsNotAnOutboundCredential
// AnUngrantedImageGetsNoOutboundEntry
// Together B and C pin the carve-out to the GRANT rather than to nothing
// or to everyone.
#include <gtest/gtest.h>
#include "logos_protocol.h"
#include "token_manager.h"
#include <QCoreApplication>
#include <QString>
#include <algorithm>
#include <string>
#include <nlohmann/json.hpp>
namespace {
QCoreApplication* ensureInboundApp()
{
static int argc = 0;
static char* argv[] = { nullptr };
if (!QCoreApplication::instance())
new QCoreApplication(argc, argv);
return QCoreApplication::instance();
}
std::string takeString(char* owned)
{
if (!owned) return {};
const std::string out = owned;
lp_string_free(owned);
return out;
}
bool rosterContains(const std::string& dump, const std::string& key)
{
const nlohmann::json j = nlohmann::json::parse(dump, nullptr, /*allow_exceptions=*/false);
if (!j.is_array()) return false;
return std::any_of(j.begin(), j.end(), [&](const nlohmann::json& e) {
return e.is_string() && e.get<std::string>() == key;
});
}
class InboundDoor : public ::testing::Test {
protected:
void SetUp() override
{
ensureInboundApp();
// The grant and the image store are both process-global, so every case
// starts ungranted and empty rather than from whatever ran before it.
ASSERT_EQ(lp_grant_host_services(nullptr), LP_OK);
TokenManager::instance().clearAllTokens();
}
void TearDown() override
{
lp_grant_host_services(nullptr);
TokenManager::instance().clearAllTokens();
}
};
} // namespace
// ── the bug the door exists to close ────────────────────────────────────────
TEST_F(InboundDoor, AnInboundPushIsNotAnOutboundCredential)
{
ASSERT_EQ(lp_token_save_inbound("caller_a", "T-issued-to-a"), LP_OK);
// The claim, in the words of the failure it prevents: this module must not
// be able to present, to caller_a, the very token caller_a was issued to
// call THIS module.
EXPECT_EQ(lp_token_get("caller_a"), nullptr)
<< "an inbound grant became an outbound credential — the collision, one "
"image below ModuleProxy::authorize";
EXPECT_FALSE(TokenManager::instance().hasToken(QStringLiteral("caller_a")));
// ...and it did land where it belongs.
EXPECT_EQ(TokenManager::instance().inbound().token(QStringLiteral("caller_a")),
QStringLiteral("T-issued-to-a"));
}
TEST_F(InboundDoor, AnInboundPushDoesNotClobberAnExistingOutboundCache)
{
// The other half of the same key collision, and the one that produced the
// measured rejection-plus-re-exchange on every call: a cached per-target
// token for peer P, then an inbound push naming P.
ASSERT_EQ(lp_token_save("peer", "T-i-present-to-peer"), LP_OK);
ASSERT_EQ(lp_token_save_inbound("peer", "T-peer-presents-to-me"), LP_OK);
EXPECT_EQ(takeString(lp_token_get("peer")), std::string("T-i-present-to-peer"))
<< "the inbound push overwrote the outbound cache for the same peer";
EXPECT_EQ(TokenManager::instance().inbound().token(QStringLiteral("peer")),
QStringLiteral("T-peer-presents-to-me"));
}
TEST_F(InboundDoor, TheOutboundDoorIsStillTheAnchorSeedingDoor)
{
// logos_module_accept_token keeps its meaning, and the glue's onInit keeps
// using it: the module's own host-issued credential, under both bootstrap
// keys. Nothing about adding an inbound door may change this.
ASSERT_EQ(lp_token_save("core", "my-anchor"), LP_OK);
ASSERT_EQ(lp_token_save("capability_module", "my-anchor"), LP_OK);
EXPECT_EQ(TokenManager::instance().credential(), QStringLiteral("my-anchor"));
EXPECT_EQ(takeString(lp_token_get("capability_module")), std::string("my-anchor"));
}
// ── the carve-out, in both directions ───────────────────────────────────────
TEST_F(InboundDoor, ATokenRegistryStillGetsItsOutboundRoster)
{
ASSERT_EQ(lp_grant_host_services(R"(["token_registry"])"), LP_OK);
// This is the shape of what capability_module receives: core telling it
// "module_x is loaded, here is its token". To the registry that message is
// OUTBOUND — the credential it will present when it pushes to module_x —
// and it is also the roster entry its known-caller gate reads.
ASSERT_EQ(lp_token_save_inbound("module_x", "T-x"), LP_OK);
const std::string roster = takeString(lp_token_keys());
ASSERT_FALSE(roster.empty()) << "the granted roster must be readable";
EXPECT_TRUE(rosterContains(roster, "module_x"))
<< "capability_module's known-caller gate reads exactly this; empty here "
"means every requestModule in the fleet is refused with 'rejecting "
"request from unknown module identity'";
EXPECT_EQ(takeString(lp_token_get("module_x")), std::string("T-x"))
<< "capability_module authenticates its push to module_x with this value";
// The inbound half is written too: the registry is also a provider, and a
// module presenting its own anchor to capability_module is an inbound call.
EXPECT_EQ(TokenManager::instance().inbound().token(QStringLiteral("module_x")),
QStringLiteral("T-x"));
}
TEST_F(InboundDoor, AnUngrantedImageGetsNoOutboundEntry)
{
// Same call, no grant. An ordinary module is not a registry, and for it the
// message means only "this caller may call you".
ASSERT_EQ(lp_token_save_inbound("module_x", "T-x"), LP_OK);
EXPECT_EQ(lp_token_get("module_x"), nullptr)
<< "the carve-out fired for an image that was never granted the registry "
"role — that is the original bug with an extra step";
EXPECT_EQ(lp_token_keys(), nullptr) << "and the roster stays closed";
EXPECT_EQ(TokenManager::instance().inbound().token(QStringLiteral("module_x")),
QStringLiteral("T-x"));
}
TEST_F(InboundDoor, RevokingTheGrantStopsTheCarveOut)
{
ASSERT_EQ(lp_grant_host_services(R"(["token_registry"])"), LP_OK);
ASSERT_EQ(lp_token_save_inbound("early", "T-early"), LP_OK);
ASSERT_EQ(lp_grant_host_services(nullptr), LP_OK);
ASSERT_EQ(lp_token_save_inbound("late", "T-late"), LP_OK);
// The grant is read at the moment of the write, not cached at load: an image
// that loses the role stops filing new pushes outbound.
EXPECT_EQ(takeString(lp_token_get("early")), std::string("T-early"));
EXPECT_EQ(lp_token_get("late"), nullptr);
}
// ── argument handling ───────────────────────────────────────────────────────
TEST_F(InboundDoor, RefusesNullEmptyAndForgedNames)
{
EXPECT_EQ(lp_token_save_inbound(nullptr, "t"), LP_ERR_INVALID_ARG);
EXPECT_EQ(lp_token_save_inbound("caller", nullptr), LP_ERR_INVALID_ARG);
EXPECT_EQ(lp_token_save_inbound("", "t"), LP_ERR_INVALID_ARG);
// An empty token would read as PRESENT to inbound().contains() while
// authorizing nothing, which is the worst of both.
EXPECT_EQ(lp_token_save_inbound("caller", ""), LP_ERR_INVALID_ARG);
EXPECT_FALSE(TokenManager::instance().inbound().contains(QStringLiteral("caller")));
// The caller name arrives over RPC, named by capability_module. A name
// carrying the reserved direction-namespace character must not be able to
// address any key but its own.
EXPECT_EQ(lp_token_save_inbound("\001in\001victim", "forged"), LP_ERR_INVALID_ARG);
EXPECT_EQ(TokenManager::instance().inbound().token(QStringLiteral("victim")), QString());
}
TEST_F(InboundDoor, TheOutboundDoorReportsARefusedForgedNameToo)
{
// SYMMETRY, and it is a report the caller can act on rather than a log line
// only an operator can read. lp_token_save_inbound already answers
// LP_ERR_INVALID_ARG for a name carrying the reserved direction namespace;
// lp_token_save answered LP_OK for the identical refusal, because
// TokenManager::saveToken returns void and the C door had nothing to
// forward. A module tripping the guard therefore saw rc=0 and went on
// believing it held a credential it does not hold — the failure mode this
// whole split exists to make loud.
//
// RED BEFORE (measured, at this commit):
// Expected equality of these values:
// lp_token_save("\001in\001victim", "forged")
// Which is: 0
// LP_ERR_INVALID_ARG
// Which is: -1
EXPECT_EQ(lp_token_save("\001in\001victim", "forged"), LP_ERR_INVALID_ARG);
EXPECT_EQ(TokenManager::instance().inbound().token(QStringLiteral("victim")),
QString())
<< "an outbound write addressed the inbound namespace";
// A PIN ON THE SECOND GUARD, and it is stated in the only accessor that
// could see the failure. hasToken(), tokenCount() and getTokenKeys() all
// FILTER reserved keys, so each answers "nothing there" whether or not the
// write landed; inbound().count() is the one that counts the namespace.
//
// Removing the C-door guard alone leaves this GREEN (measured): the write
// is refused a second time inside TokenManager::saveToken, so the only
// defect was the return code — which is exactly the finding. This fires
// when BOTH guards go, which is the state the door was one line away from.
EXPECT_EQ(TokenManager::instance().inbound().count(), 0)
<< "the refused key landed in the reserved namespace, invisible to every "
"outbound accessor";
// The per-identity twin is the same door with a store selector in front of
// it, so it owes the same answer. Kept in the same case because a fix that
// reaches one and not the other is the shape this finding already is.
EXPECT_EQ(lp_token_save_for("some_identity", "\001in\001victim", "forged"),
LP_ERR_INVALID_ARG);
// ...and the refusal is about the NAMESPACE, not about the door: an
// ordinary name still succeeds, so a fix that simply fails everything
// cannot pass this.
EXPECT_EQ(lp_token_save("peer", "T-peer"), LP_OK);
EXPECT_EQ(takeString(lp_token_get("peer")), std::string("T-peer"));
EXPECT_EQ(lp_token_save_for("some_identity", "peer2", "T-peer2"), LP_OK);
}