mirror of
https://github.com/logos-co/logos-protocol.git
synced 2026-08-30 21:41:10 +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>
529 lines
26 KiB
C++
529 lines
26 KiB
C++
// The INBOUND token store: what a module accepted FROM a caller, keyed by that
|
|
// caller, and nothing else.
|
|
//
|
|
// WHY THIS FILE EXISTS. ModuleProxy authorizes an inbound call by scanning two
|
|
// stores, and until now only one of them was written by production code:
|
|
//
|
|
// * m_tokens — caller-keyed and inbound-only BY CONSTRUCTION (the only
|
|
// writer is saveToken(from_module_name, token)), but with
|
|
// ZERO production writers anywhere in the workspace. In a
|
|
// real process it was permanently empty.
|
|
// * TokenManager — direction-MIXED AT THE TIME. LogosAPIClient wrote the
|
|
// token it will PRESENT to a callee under the CALLEE's name
|
|
// (logos_api_client.cpp:201) into the same flat
|
|
// QHash<QString,QString> that took a token RECEIVED from a
|
|
// caller under the CALLER's name. Last write won. That map
|
|
// is now two maps and a credential — see the DIRECTION note
|
|
// in cpp/token_manager.h — but everything below was written
|
|
// against the mixed version and is described as it was.
|
|
//
|
|
// So every inbound authorization decision rested on a store that also holds
|
|
// outbound tokens, and the store that could not hold an outbound token was
|
|
// empty. Two consequences, and each has a test below:
|
|
//
|
|
// 1. A REVERSE LOOKUP THERE CANNOT NAME A CALLER. A value in TokenManager may
|
|
// be one we hold to CALL x, not one x holds to call US. Naming x as the
|
|
// caller from that store would be affirmatively wrong — worse than
|
|
// declining to answer. Item 5 (logos::currentCaller()) needs an oracle; it
|
|
// has to be m_tokens, and m_tokens has to be complete first.
|
|
// 2. AN ISOLATED PROVIDER IDENTITY REJECTED EVERY INBOUND CALL. Inbound
|
|
// tokens are written by LogosProviderBase::informModuleToken into
|
|
// LogosAPI::getTokenManager() — which is TokenManager::forIdentity(name),
|
|
// NOT instance() (logos-plugin-qt logos_api.cpp:37-38). isAuthorized
|
|
// scanned instance() unconditionally. Identical objects for a name nobody
|
|
// isolated, so nothing was visibly broken; the first host to isolate a
|
|
// PROVIDER identity would have had every inbound call rejected with no
|
|
// diagnostic. The generated Qt glue even asserts the opposite in a comment
|
|
// ("ModuleProxy validates INBOUND calls against the host's TokenManager").
|
|
//
|
|
// HOW THIS WAS VALIDATED — three builds, each missing one mechanism, each run.
|
|
// Not a build flag and not a switch in this tree: a throwaway local edit, made
|
|
// and thrown away, exactly as the note at the top of tests/protocol/
|
|
// CMakeLists.txt prescribes.
|
|
//
|
|
// (a) NEITHER mechanism — the store parameter present but never consulted,
|
|
// informModuleToken not recording. 4 passed, 3 FAILED:
|
|
// AnInformedTokenLandsInTheProxysOwnStore (got false, want true)
|
|
// AnIsolatedProviderIdentityStillAuthorizesInboundCalls(got false, want true)
|
|
// AnAmbientTokenDoesNotAuthorizeAnIsolatedProxy (got true, want false)
|
|
// (b) THE INBOUND RECORD ONLY — isAuthorized still scanning
|
|
// TokenManager::instance(). 6 passed, 1 FAILED:
|
|
// AnAmbientTokenDoesNotAuthorizeAnIsolatedProxy (got true, want false)
|
|
// That single survivor is what makes it the detector for the STORE SCAN
|
|
// rather than for the record: every other case is satisfied by either
|
|
// mechanism alone, so only this one distinguishes them.
|
|
// (c) THE ANCHOR READ reverted to TokenManager::instance() with everything
|
|
// else in place. 1 FAILED:
|
|
// TheTrustAnchorIsReadFromTheProxysOwnStore — accepted the ambient
|
|
// anchor (want false) AND refused the proxy's own (want true), i.e. it
|
|
// detects the seam in both directions.
|
|
//
|
|
// ARefusedPushGrantsNothing is a detector too, and it caught a real mistake
|
|
// rather than a hypothetical one: the first draft recorded BEFORE forwarding to
|
|
// the provider and this test went red on run (b).
|
|
// AnUntrustedPushGrantsNothing, TheDefaultStoreIsStillTheAmbientRing and
|
|
// AnEmptyTokenIsRefusedEvenAgainstAnEmptyStoredValue are PINS — they are green
|
|
// on every build above, including (a). Do not read them as evidence of anything
|
|
// this change added.
|
|
//
|
|
// WHAT USED TO BE DELIBERATELY NOT HERE, AND WHERE IT WENT. This file
|
|
// originally declined to assert that a token found ONLY in TokenManager is
|
|
// refused, on the grounds that taking the mixed store out of the scan would be
|
|
// a behaviour break rather than a bug fix. It was both: the direction split
|
|
// (tests/protocol/test_token_direction.cpp) took the OUTBOUND half out, and the
|
|
// two halves that remain — the store's own inbound record and its credential —
|
|
// are what the cases below exercise. The store this file's RecordingProvider
|
|
// writes is now the inbound half, which is why nothing here needed to change
|
|
// beyond the door it spells.
|
|
|
|
#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* ensureInboundApp() {
|
|
static int argc = 0;
|
|
static char* argv[] = { nullptr };
|
|
if (!QCoreApplication::instance())
|
|
new QCoreApplication(argc, argv);
|
|
return QCoreApplication::instance();
|
|
}
|
|
|
|
// A provider that records the tokens pushed at it and answers one method.
|
|
//
|
|
// It writes into a store handed to it at construction, which is what makes the
|
|
// isolation case reproducible in-process: this is the stand-in for
|
|
// LogosProviderBase::informModuleToken, whose store is
|
|
// TokenManager::forIdentity(<this module's name>). It writes through the
|
|
// INBOUND door, which is the door that repo owes the same move to; see the note
|
|
// on DirectionProvider in test_token_direction.cpp.
|
|
class RecordingProvider : public LogosProviderObject {
|
|
public:
|
|
explicit RecordingProvider(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);
|
|
++informs;
|
|
return acceptPushes;
|
|
}
|
|
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("recording_module"); }
|
|
QString providerVersion() const override { return QStringLiteral("1.0.0"); }
|
|
|
|
int informs = 0;
|
|
bool acceptPushes = true;
|
|
|
|
private:
|
|
TokenManager* m_store;
|
|
};
|
|
|
|
// True when a business dispatch went through. An unauthorized call never
|
|
// reaches the provider: it comes back as the structured rejection sentinel.
|
|
bool callSucceeds(ModuleProxy& proxy, const QString& token) {
|
|
const QVariant r = proxy.callRemoteMethod(token, QStringLiteral("work"), {});
|
|
return !logos::isUnauthorizedSentinel(r) && r.toString() == QStringLiteral("worked");
|
|
}
|
|
|
|
// A store that is genuinely NOT instance(). forIdentity() is additive by
|
|
// design — it hands back instance() for every name until that name has been
|
|
// isolated — so a test that wants a second store has to isolate first, and must
|
|
// do so before anything else asks for the name (isolateIdentity refuses once a
|
|
// shared store has been vended under it).
|
|
TokenManager& isolatedStore(const QString& identity) {
|
|
EXPECT_TRUE(TokenManager::isolateIdentity(identity));
|
|
TokenManager& store = TokenManager::forIdentity(identity);
|
|
EXPECT_NE(&store, &TokenManager::instance());
|
|
return store;
|
|
}
|
|
|
|
// The host anchor: informModuleToken accepts ONLY a caller holding "core" or
|
|
// "capability_module" out of TokenManager::instance(). Seed it once per process
|
|
// — instance() is a singleton and these tests share it.
|
|
QString seedTrustAnchor() {
|
|
const QString anchor = QStringLiteral("inbound-test-host-anchor");
|
|
TokenManager::instance().saveToken(QStringLiteral("core"), anchor);
|
|
return anchor;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
// ── 1. the inbound record is actually written ────────────────────────────────
|
|
//
|
|
// FAILS BEFORE THE FIX: informModuleToken forwarded to the provider and wrote
|
|
// nothing of its own, so m_tokens stayed empty in every real process. The
|
|
// assertion is indirect but exact — the proxy's own store is private, so the
|
|
// only way to observe it is that authorization now works from a store the test
|
|
// can empty independently.
|
|
TEST(InboundTokenStore, AnInformedTokenLandsInTheProxysOwnStore)
|
|
{
|
|
ensureInboundApp();
|
|
const QString anchor = seedTrustAnchor();
|
|
|
|
// The provider writes into a store that is NOT the one isAuthorized scans,
|
|
// so nothing the provider does can make this call authorize. Only the
|
|
// proxy's own record can. It has to be ISOLATED to be a different object:
|
|
// forIdentity() on a name nobody isolated returns instance() itself, which
|
|
// would make this test pass on the unfixed tree for the wrong reason.
|
|
TokenManager& elsewhere = isolatedStore(
|
|
QStringLiteral("inbound_store_test_provider_side"));
|
|
RecordingProvider provider(&elsewhere);
|
|
ModuleProxy proxy(&provider);
|
|
|
|
const QString granted = QStringLiteral("inbound-token-alpha");
|
|
ASSERT_TRUE(proxy.informModuleToken(anchor, QStringLiteral("caller_alpha"), granted));
|
|
EXPECT_EQ(provider.informs, 1);
|
|
|
|
EXPECT_TRUE(callSucceeds(proxy, granted));
|
|
}
|
|
|
|
// ── 2. the proxy's own record follows the provider's verdict ─────────────────
|
|
//
|
|
// A DETECTOR, and it earned its place: the first draft recorded BEFORE
|
|
// forwarding — on the theory that a module might call back into us from inside
|
|
// the push — and this test failed. There is no such window (the push reaches
|
|
// module code only as far as a store write), so the record goes after.
|
|
//
|
|
// READ THE SCOPE EXACTLY. This is not "a refused push leaves the token
|
|
// unusable". It cannot be: the generated Qt glue saves to the host stack BEFORE
|
|
// forwarding across the C ABI and returns hostOk && implOk, so a cdylib-side
|
|
// failure returns false with the host store already holding the token. The
|
|
// claim is only that THE PROXY adds no grant of its own to a push the provider
|
|
// rejected — which is why the provider here writes somewhere isAuthorized does
|
|
// not scan.
|
|
TEST(InboundTokenStore, ARefusedPushGrantsNothing)
|
|
{
|
|
ensureInboundApp();
|
|
const QString anchor = seedTrustAnchor();
|
|
|
|
TokenManager& elsewhere = isolatedStore(
|
|
QStringLiteral("inbound_store_test_refuse_side"));
|
|
RecordingProvider provider(&elsewhere);
|
|
provider.acceptPushes = false;
|
|
ModuleProxy proxy(&provider);
|
|
|
|
const QString refused = QStringLiteral("inbound-token-refused");
|
|
EXPECT_FALSE(proxy.informModuleToken(anchor, QStringLiteral("caller_beta"), refused));
|
|
EXPECT_EQ(provider.informs, 1);
|
|
|
|
EXPECT_FALSE(callSucceeds(proxy, refused));
|
|
}
|
|
|
|
// ── 3. an untrusted push still grants nothing ────────────────────────────────
|
|
//
|
|
// A pin, not a detector: the trusted-channel gate already ran before the new
|
|
// write. It is here because the new write sits next to that gate, and the
|
|
// obvious way to get the ordering wrong is to record before checking.
|
|
TEST(InboundTokenStore, AnUntrustedPushGrantsNothing)
|
|
{
|
|
ensureInboundApp();
|
|
seedTrustAnchor();
|
|
|
|
TokenManager& elsewhere = isolatedStore(
|
|
QStringLiteral("inbound_store_test_untrusted_side"));
|
|
RecordingProvider provider(&elsewhere);
|
|
ModuleProxy proxy(&provider);
|
|
|
|
const QString smuggled = QStringLiteral("inbound-token-smuggled");
|
|
EXPECT_FALSE(proxy.informModuleToken(QStringLiteral("not-the-anchor"),
|
|
QStringLiteral("caller_gamma"), smuggled));
|
|
EXPECT_EQ(provider.informs, 0);
|
|
|
|
EXPECT_FALSE(callSucceeds(proxy, smuggled));
|
|
}
|
|
|
|
// ── 4. an isolated provider identity still authorizes ────────────────────────
|
|
//
|
|
// THE LATENT BUG, reproduced. isAuthorized hardcoded TokenManager::instance()
|
|
// while the provider's inbound write goes to forIdentity(<own name>). For a
|
|
// name nobody isolated those are pointer-identical and nothing is visible; the
|
|
// moment a host isolates a PROVIDER identity, the two diverge and every inbound
|
|
// call is rejected.
|
|
//
|
|
// FAILS BEFORE THE FIX in both halves: without the injected store the scan
|
|
// misses the isolated store, and without the proxy's own record there is
|
|
// nothing else to match.
|
|
TEST(InboundTokenStore, AnIsolatedProviderIdentityStillAuthorizesInboundCalls)
|
|
{
|
|
ensureInboundApp();
|
|
const QString hostAnchor = seedTrustAnchor();
|
|
|
|
const QString identity = QStringLiteral("inbound_store_isolated_provider");
|
|
ASSERT_TRUE(TokenManager::isolateIdentity(identity));
|
|
TokenManager& isolated = TokenManager::forIdentity(identity);
|
|
ASSERT_NE(&isolated, &TokenManager::instance());
|
|
|
|
RecordingProvider provider(&isolated);
|
|
ModuleProxy proxy(&provider, /*parent=*/nullptr, &isolated);
|
|
|
|
// WHAT LETS THE PUSH AUTHORIZE, and it is no longer the host's anchor.
|
|
//
|
|
// A private store is created EMPTY. The host installs THIS identity's own
|
|
// credential under the bootstrap keys (TokenManager::adoptCredentialFor),
|
|
// and capability_module pushes using `tokenManager->getToken(moduleName)`,
|
|
// which IS that credential — so the trusted-channel gate matches on the
|
|
// identity's own value. The store used to be seeded with a COPY of
|
|
// instance()'s anchor, which authorized this push and also let any holder
|
|
// of the copy authorize as the host at every other module.
|
|
ASSERT_TRUE(isolated.getToken(QStringLiteral("core")).isEmpty())
|
|
<< "a private store must not inherit the host's credential";
|
|
const QString credential = QStringLiteral("inbound-own-credential");
|
|
ASSERT_TRUE(TokenManager::adoptCredentialFor(identity, credential));
|
|
ASSERT_EQ(isolated.getToken(QStringLiteral("core")), credential);
|
|
ASSERT_NE(credential, hostAnchor);
|
|
|
|
const QString granted = QStringLiteral("inbound-token-isolated");
|
|
ASSERT_TRUE(proxy.informModuleToken(credential, QStringLiteral("caller_delta"), granted));
|
|
EXPECT_TRUE(callSucceeds(proxy, granted));
|
|
|
|
// The other half of the same rule: the HOST's anchor is not a key to this
|
|
// door. An isolated provider trusts its own credential and nothing else.
|
|
EXPECT_FALSE(proxy.informModuleToken(hostAnchor, QStringLiteral("caller_impostor"),
|
|
QStringLiteral("inbound-token-impostor")));
|
|
}
|
|
|
|
// ── 5. the injected store is the one that is scanned ─────────────────────────
|
|
//
|
|
// The other direction of the same wiring, and the reason the parameter exists
|
|
// rather than a lazy forIdentity(providerName()) lookup: a token that is ONLY
|
|
// in the ambient ring must not authorize a proxy bound to a private store.
|
|
// Without this, test 4 would pass on an implementation that scans BOTH stores,
|
|
// which would silently re-open the ambient ring the isolation work closed.
|
|
TEST(InboundTokenStore, AnAmbientTokenDoesNotAuthorizeAnIsolatedProxy)
|
|
{
|
|
ensureInboundApp();
|
|
seedTrustAnchor();
|
|
|
|
const QString identity = QStringLiteral("inbound_store_isolated_scan_only");
|
|
ASSERT_TRUE(TokenManager::isolateIdentity(identity));
|
|
TokenManager& isolated = TokenManager::forIdentity(identity);
|
|
ASSERT_NE(&isolated, &TokenManager::instance());
|
|
|
|
// Planted in the AMBIENT ring only, through the INBOUND door — this test is
|
|
// about WHICH STORE is scanned, not about direction, so it plants the kind
|
|
// of token that authorizes. Written with saveToken() it would now be
|
|
// refused by both proxies for the unrelated reason that an outbound entry
|
|
// never authorizes (test_token_direction.cpp), and the control below would
|
|
// stop being a control.
|
|
//
|
|
// The key is deliberately not a bootstrap key: those now hold the
|
|
// identity's OWN credential, which is a different value from the host's by
|
|
// construction, so a bootstrap key would prove the same thing less
|
|
// directly.
|
|
const QString ambientOnly = QStringLiteral("inbound-token-ambient-only");
|
|
ASSERT_TRUE(TokenManager::instance().saveInboundToken(
|
|
QStringLiteral("some_other_module"), ambientOnly));
|
|
ASSERT_TRUE(isolated.inbound().token(QStringLiteral("some_other_module")).isEmpty());
|
|
|
|
RecordingProvider provider(&isolated);
|
|
ModuleProxy proxy(&provider, /*parent=*/nullptr, &isolated);
|
|
|
|
EXPECT_FALSE(callSucceeds(proxy, ambientOnly));
|
|
|
|
// CONTROL. The same value DOES authorize a proxy left on the default store,
|
|
// so the assertion above is about which store is scanned and not about the
|
|
// token having failed to be planted.
|
|
RecordingProvider ambientProvider(&TokenManager::instance());
|
|
ModuleProxy ambientProxy(&ambientProvider);
|
|
EXPECT_TRUE(callSucceeds(ambientProxy, ambientOnly));
|
|
}
|
|
|
|
// ── 6. the default is still the ambient ring ─────────────────────────────────
|
|
//
|
|
// The back-compatibility claim, stated as a test rather than as a comment: a
|
|
// two-argument ModuleProxy — which is every construction site in the fleet —
|
|
// scans exactly what it scanned before.
|
|
TEST(InboundTokenStore, TheDefaultStoreIsStillTheAmbientRing)
|
|
{
|
|
ensureInboundApp();
|
|
seedTrustAnchor();
|
|
|
|
// Through the inbound door, for the reason spelled out in test 5: the claim
|
|
// here is about which STORE a defaulted ModuleProxy scans, and it would be
|
|
// masked by the direction refusal if the token were planted outbound.
|
|
const QString planted = QStringLiteral("inbound-token-default-store");
|
|
ASSERT_TRUE(TokenManager::instance().saveInboundToken(
|
|
QStringLiteral("planted_module"), planted));
|
|
|
|
RecordingProvider provider(&TokenManager::instance());
|
|
ModuleProxy proxy(&provider);
|
|
|
|
EXPECT_TRUE(callSucceeds(proxy, planted));
|
|
}
|
|
|
|
// ── 7. the empty token is still refused ──────────────────────────────────────
|
|
//
|
|
// A pin on the fail-closed guard at the top of isAuthorized, which is
|
|
// load-bearing beyond its own line: constantTimeEquals("", "") is TRUE, so
|
|
// without the guard an empty token would match any empty value that ever
|
|
// reached either store. Deleting it looks like a cleanup.
|
|
TEST(InboundTokenStore, AnEmptyTokenIsRefusedEvenAgainstAnEmptyStoredValue)
|
|
{
|
|
ensureInboundApp();
|
|
seedTrustAnchor();
|
|
|
|
RecordingProvider provider(&TokenManager::instance());
|
|
ModuleProxy proxy(&provider);
|
|
|
|
// saveToken refuses an empty value, so plant it the only way a store can
|
|
// hold one: through the proxy's own inbound record, which refuses it too.
|
|
EXPECT_FALSE(proxy.saveToken(QStringLiteral("empty_caller"), QString()));
|
|
|
|
EXPECT_FALSE(callSucceeds(proxy, QString()));
|
|
}
|
|
|
|
// ── 8. the trust anchor comes from the same store ────────────────────────────
|
|
//
|
|
// informModuleToken's gate and isAuthorized's scan have to agree on WHICH store
|
|
// defines trust, or a proxy ends up with two notions of who it trusts. This
|
|
// pins the gate to the injected store: an anchor held only in the ambient ring
|
|
// is not this proxy's anchor.
|
|
//
|
|
// It also states the precondition the logos-plugin-qt wiring has to satisfy.
|
|
// LogosAPIProvider::seedHandshakeTrustAnchor writes the module image's own
|
|
// host-issued authToken under "core"/"capability_module"; an in-process
|
|
// consumer gets the equivalent from logos::admitConsumer. Hand this proxy an
|
|
// isolated store that nobody credentialed and every push is refused — which is
|
|
// exactly what a private store now looks like until the host admits it.
|
|
TEST(InboundTokenStore, TheTrustAnchorIsReadFromTheProxysOwnStore)
|
|
{
|
|
ensureInboundApp();
|
|
const QString ambientAnchor = seedTrustAnchor();
|
|
|
|
const QString identity = QStringLiteral("inbound_store_isolated_anchor");
|
|
ASSERT_TRUE(TokenManager::isolateIdentity(identity));
|
|
TokenManager& isolated = TokenManager::forIdentity(identity);
|
|
ASSERT_NE(&isolated, &TokenManager::instance());
|
|
|
|
// This identity's own credential, so the two stores disagree about "core".
|
|
const QString privateAnchor = QStringLiteral("inbound-test-private-anchor");
|
|
isolated.saveToken(QStringLiteral("core"), privateAnchor);
|
|
ASSERT_NE(isolated.getToken(QStringLiteral("core")),
|
|
TokenManager::instance().getToken(QStringLiteral("core")));
|
|
|
|
RecordingProvider provider(&isolated);
|
|
ModuleProxy proxy(&provider, /*parent=*/nullptr, &isolated);
|
|
|
|
EXPECT_FALSE(proxy.informModuleToken(ambientAnchor,
|
|
QStringLiteral("caller_epsilon"),
|
|
QStringLiteral("tok-from-ambient-anchor")));
|
|
EXPECT_EQ(provider.informs, 0);
|
|
|
|
EXPECT_TRUE(proxy.informModuleToken(privateAnchor,
|
|
QStringLiteral("caller_zeta"),
|
|
QStringLiteral("tok-from-private-anchor")));
|
|
EXPECT_EQ(provider.informs, 1);
|
|
}
|
|
|
|
// ── 9. the scan's shape does not depend on the answer ────────────────────────
|
|
//
|
|
// WHAT THIS ASSERTS, exactly: the number of times constantTimeEquals runs for
|
|
// one inbound call is a function of the STORE SIZES only — never of where the
|
|
// matching token sits in the iteration, nor of whether anything matched at all.
|
|
// logos::tokenComparisonCount() is instrumentation for that and nothing else.
|
|
//
|
|
// WHAT IT DOES NOT ASSERT, and this matters more than what it does: it makes NO
|
|
// claim about wall-clock time, and it is not a constant-time proof. A timing
|
|
// test here would be theatre. constantTimeEquals opens with two
|
|
// QString::toUtf8() heap allocations and the loop is preceded by a QHash walk,
|
|
// both orders of magnitude noisier than the byte fold being defended; -O2 is
|
|
// free to lower the fold's mask-merge to a conditional select OR to a branch and
|
|
// nothing in a runCommand can see which it chose; separating the signal needs
|
|
// dudect-scale sampling on a shared, frequency-scaled CI runner. And the threat
|
|
// would be recovering a 122-bit random UUID by timing, which is not the threat
|
|
// this code has.
|
|
//
|
|
// The invocation-count invariant, by contrast, is deterministic, portable, and
|
|
// is precisely what an accidental `break` or early `return` destroys — which is
|
|
// the realistic regression, especially now that the loop body does more than
|
|
// OR a bool. That is the property worth pinning, so it is the one pinned.
|
|
//
|
|
// RED BEFORE: with `if (authorized) break;` added at the end of the m_tokens
|
|
// loop — the natural "we already know the answer" optimisation — the cost
|
|
// becomes the match's POSITION in the iteration. Measured:
|
|
//
|
|
// n=1 0 of 1 measurements differ. Undetectable, and that is the reason the
|
|
// sweep exists: with one token the break fires after the only
|
|
// comparison, so the cost is identical to a miss.
|
|
// n=2 1 of 2 differ — 4 against a miss cost of 5.
|
|
// n=50 49 of 50 differ, ranging 52..100 against a miss cost of 101. The one
|
|
// that agrees is whichever token QHash happens to iterate last.
|
|
//
|
|
// Removing the early exit again returns every one of the 53 measurements to its
|
|
// store-size cost. The n=50 spread is also the leak stated plainly: the number
|
|
// of comparisons is the presented token's position among the issued ones.
|
|
TEST(InboundTokenStore, TheComparisonCountDependsOnStoreSizeOnly)
|
|
{
|
|
ensureInboundApp();
|
|
|
|
unsigned long long previousTotal = 0;
|
|
|
|
for (const int n : { 1, 2, 50 }) {
|
|
const QString identity =
|
|
QStringLiteral("inbound_store_ct_scan_%1").arg(n);
|
|
ASSERT_TRUE(TokenManager::isolateIdentity(identity));
|
|
TokenManager& store = TokenManager::forIdentity(identity);
|
|
ASSERT_NE(&store, &TokenManager::instance());
|
|
|
|
// This identity's own credential, written straight into its store: a
|
|
// private store is born empty and inherits nothing from instance().
|
|
const QString anchor = QStringLiteral("ct-scan-anchor-%1").arg(n);
|
|
store.saveToken(QStringLiteral("core"), anchor);
|
|
|
|
RecordingProvider provider(&store);
|
|
ModuleProxy proxy(&provider, /*parent=*/nullptr, &store);
|
|
|
|
QStringList issued;
|
|
for (int i = 0; i < n; ++i) {
|
|
const QString token = QStringLiteral("ct-scan-token-%1-%2").arg(n).arg(i);
|
|
ASSERT_TRUE(proxy.informModuleToken(
|
|
anchor, QStringLiteral("ct_caller_%1").arg(i), token));
|
|
issued << token;
|
|
}
|
|
|
|
// The reference: a token that matches NOTHING, so the scan runs to the
|
|
// end of both stores with no early exit available to it.
|
|
const unsigned long long beforeMiss = logos::tokenComparisonCount();
|
|
EXPECT_FALSE(callSucceeds(proxy, QStringLiteral("ct-scan-no-such-token")));
|
|
const unsigned long long missCost =
|
|
logos::tokenComparisonCount() - beforeMiss;
|
|
|
|
// EVERY issued token, not a sampled first and last. QHash iteration
|
|
// order is unspecified, so "the match at index 0" is not something a
|
|
// test can arrange — measuring all n makes the claim order-independent
|
|
// and covers whichever position each token actually lands in.
|
|
for (const QString& token : issued) {
|
|
const unsigned long long before = logos::tokenComparisonCount();
|
|
EXPECT_TRUE(callSucceeds(proxy, token));
|
|
EXPECT_EQ(logos::tokenComparisonCount() - before, missCost)
|
|
<< "n=" << n << " token=" << token.toStdString();
|
|
}
|
|
|
|
// Anti-vacuity, both directions. A counter stuck at zero, or one the
|
|
// scan stopped feeding, would satisfy every equality above.
|
|
EXPECT_GE(missCost, static_cast<unsigned long long>(n));
|
|
EXPECT_GT(missCost, previousTotal);
|
|
previousTotal = missCost;
|
|
}
|
|
}
|