Files
logos-protocol/tests/protocol/test_token_manager.cpp
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

215 lines
8.3 KiB
C++

#include <gtest/gtest.h>
#include <QtTest/QSignalSpy>
#include "token_manager.h"
class TokenManagerTest : public ::testing::Test {
protected:
void SetUp() override
{
TokenManager::instance().clearAllTokens();
}
};
TEST_F(TokenManagerTest, SaveAndGetToken)
{
TokenManager::instance().saveToken("mod_a", "token_a");
EXPECT_EQ(TokenManager::instance().getToken("mod_a"), "token_a");
}
TEST_F(TokenManagerTest, GetMissingTokenReturnsEmpty)
{
EXPECT_TRUE(TokenManager::instance().getToken("nonexistent").isEmpty());
}
TEST_F(TokenManagerTest, HasToken)
{
EXPECT_FALSE(TokenManager::instance().hasToken("key"));
TokenManager::instance().saveToken("key", "val");
EXPECT_TRUE(TokenManager::instance().hasToken("key"));
}
TEST_F(TokenManagerTest, RemoveToken)
{
TokenManager::instance().saveToken("key", "val");
EXPECT_TRUE(TokenManager::instance().removeToken("key"));
EXPECT_FALSE(TokenManager::instance().hasToken("key"));
}
TEST_F(TokenManagerTest, RemoveNonexistentTokenReturnsFalse)
{
EXPECT_FALSE(TokenManager::instance().removeToken("missing"));
}
TEST_F(TokenManagerTest, ClearAllTokens)
{
TokenManager::instance().saveToken("a", "1");
TokenManager::instance().saveToken("b", "2");
TokenManager::instance().clearAllTokens();
EXPECT_EQ(TokenManager::instance().tokenCount(), 0);
}
TEST_F(TokenManagerTest, TokenCount)
{
EXPECT_EQ(TokenManager::instance().tokenCount(), 0);
TokenManager::instance().saveToken("x", "1");
EXPECT_EQ(TokenManager::instance().tokenCount(), 1);
TokenManager::instance().saveToken("y", "2");
EXPECT_EQ(TokenManager::instance().tokenCount(), 2);
}
TEST_F(TokenManagerTest, GetTokenKeys)
{
TokenManager::instance().saveToken("alpha", "1");
TokenManager::instance().saveToken("beta", "2");
QList<QString> keys = TokenManager::instance().getTokenKeys();
EXPECT_EQ(keys.size(), 2);
EXPECT_TRUE(keys.contains("alpha"));
EXPECT_TRUE(keys.contains("beta"));
}
TEST_F(TokenManagerTest, OverwriteToken)
{
TokenManager::instance().saveToken("key", "old");
TokenManager::instance().saveToken("key", "new");
EXPECT_EQ(TokenManager::instance().getToken("key"), "new");
EXPECT_EQ(TokenManager::instance().tokenCount(), 1);
}
TEST_F(TokenManagerTest, SignalTokenSaved)
{
QSignalSpy spy(&TokenManager::instance(), &TokenManager::tokenSaved);
TokenManager::instance().saveToken("k", "v");
EXPECT_EQ(spy.count(), 1);
EXPECT_EQ(spy.at(0).at(0).toString(), "k");
}
TEST_F(TokenManagerTest, SignalTokenRemoved)
{
TokenManager::instance().saveToken("k", "v");
QSignalSpy spy(&TokenManager::instance(), &TokenManager::tokenRemoved);
TokenManager::instance().removeToken("k");
EXPECT_EQ(spy.count(), 1);
EXPECT_EQ(spy.at(0).at(0).toString(), "k");
}
TEST_F(TokenManagerTest, SignalAllTokensCleared)
{
TokenManager::instance().saveToken("k", "v");
QSignalSpy spy(&TokenManager::instance(), &TokenManager::allTokensCleared);
TokenManager::instance().clearAllTokens();
EXPECT_EQ(spy.count(), 1);
}
// --- redactToken: log-safe token fingerprinting (F-007) ---
//
// Tokens gate all cross-module RPC and are accepted by value, so a raw token
// recovered from a log line is directly replayable. redactToken() is the
// guard that keeps the cleartext secret out of logs; these tests pin the
// security-relevant properties: the raw value never appears, the output is
// stable for correlation, and distinct tokens map to distinct fingerprints.
// ── the store mechanics of the DIRECTION split ──────────────────────────────
//
// The behavioural consequences live in test_token_direction.cpp; these are the
// three pieces of bookkeeping that hold the two names and the one value
// together, and that nothing else exercises.
TEST_F(TokenManagerTest, TheInboundOverloadsAllReachTheSameMap)
{
// The const char* overload is not sugar: without it a literal pair is
// AMBIGUOUS between the QString and std::string forms and the call does not
// compile at all. This test is therefore as much a compile-time assertion
// as a runtime one — it was added because building logos-qt-sdk against
// this header failed on exactly that.
EXPECT_TRUE(TokenManager::instance().saveInboundToken("peer_lit", "tok-lit"));
EXPECT_TRUE(TokenManager::instance().saveInboundToken(
QStringLiteral("peer_q"), QStringLiteral("tok-q")));
EXPECT_TRUE(TokenManager::instance().saveInboundToken(
std::string("peer_std"), std::string("tok-std")));
const TokenManager::InboundView in = TokenManager::instance().inbound();
EXPECT_EQ(in.token(QStringLiteral("peer_lit")), QStringLiteral("tok-lit"));
EXPECT_EQ(in.token(QStringLiteral("peer_q")), QStringLiteral("tok-q"));
EXPECT_EQ(in.token(QStringLiteral("peer_std")), QStringLiteral("tok-std"));
EXPECT_EQ(in.count(), 3);
// And none of them is visible to the outbound surface.
EXPECT_EQ(TokenManager::instance().tokenCount(), 0);
EXPECT_FALSE(TokenManager::instance().hasToken("peer_lit"));
}
TEST_F(TokenManagerTest, RemovingABootstrapKeyDoesNotStrandTheCredential)
{
// One value under two names. Dropping ONE name must leave the credential
// asserting the value the store still holds under the other; dropping BOTH
// must leave no credential at all. Reachable from ordinary traffic:
// LogosAPIClient::removeToken() runs on the re-exchange path.
TokenManager::instance().adoptCredential(QStringLiteral("the-credential"));
ASSERT_EQ(TokenManager::instance().credential(), QStringLiteral("the-credential"));
ASSERT_TRUE(TokenManager::instance().removeToken("core"));
EXPECT_EQ(TokenManager::instance().credential(), QStringLiteral("the-credential"))
<< "dropping one of the two bootstrap names cleared a credential the "
"store still holds under the other";
ASSERT_TRUE(TokenManager::instance().removeToken("capability_module"));
EXPECT_TRUE(TokenManager::instance().credential().isEmpty())
<< "the credential outlived every bootstrap key it was installed under";
}
TEST_F(TokenManagerTest, ClearAllTokensClearsAllThree)
{
// resetIdentity() is documented to clear the credential too — a reload
// re-mints and re-registers, so a surviving credential is a locked-out
// reload that looks live. The inbound record goes for the same reason: it
// names the callers of the PREVIOUS incarnation.
TokenManager::instance().saveToken("callee", "out-tok");
TokenManager::instance().saveInboundToken("caller", "in-tok");
TokenManager::instance().adoptCredential(QStringLiteral("cred"));
TokenManager::instance().clearAllTokens();
EXPECT_EQ(TokenManager::instance().tokenCount(), 0);
EXPECT_EQ(TokenManager::instance().inbound().count(), 0);
EXPECT_TRUE(TokenManager::instance().credential().isEmpty());
}
TEST(RedactTokenTest, NeverContainsRawTokenValue)
{
const QString secret = "3f2a9c00-dead-beef-cafe-0123456789ab";
const QString redacted = redactToken(secret);
EXPECT_FALSE(redacted.contains(secret));
// A replayer must not be able to recover the secret from any substring:
// the redaction is a one-way hash, so the cleartext is absent entirely.
EXPECT_EQ(redacted.indexOf(secret), -1);
}
TEST(RedactTokenTest, EmptyTokenRendersAsNone)
{
EXPECT_EQ(redactToken(QString()), QStringLiteral("<none>"));
EXPECT_EQ(redactToken(""), QStringLiteral("<none>"));
}
TEST(RedactTokenTest, IsDeterministicForCorrelation)
{
// Same token must always fingerprint identically so operators can still
// correlate log lines referring to the same credential.
const QString token = "some-capability-token";
EXPECT_EQ(redactToken(token), redactToken(token));
}
TEST(RedactTokenTest, DistinctTokensProduceDistinctFingerprints)
{
EXPECT_NE(redactToken("token-one"), redactToken("token-two"));
}
TEST(RedactTokenTest, HasStableFingerprintShape)
{
// Fixed prefix + 8 hex chars of SHA-256 + ellipsis, e.g. "redacted:1a2b3c4d…".
const QString redacted = redactToken("abc");
EXPECT_TRUE(redacted.startsWith(QStringLiteral("redacted:")));
EXPECT_TRUE(redacted.endsWith(QStringLiteral("…")));
// SHA-256("abc") = ba7816bf8f01cfea... → first 8 hex chars are "ba7816bf".
EXPECT_EQ(redacted, QStringLiteral("redacted:ba7816bf…"));
}