// Per-identity token stores: TokenManager::forIdentity / isolateIdentity, the // construction paths that resolve a store, and the lp_* C ABI over them. // // WHAT THIS IS FOR. A host that loads plugins in-process writes `name -> that // module's root auth token` into TokenManager::instance() for EVERY loaded // module, and a client presents a cached token before it ever mints one // (LogosAPIClient::invokeRemoteMethod reads the store first). So plugin A asking // for module B finds B's own root token already sitting in the shared store, // presents it, and the provider accepts — B's full authority, with no // requestModule anywhere in the log. Giving each plugin its own ORIGIN STRING // does not move that by one byte, because origin was never consulted on the path // taken. forIdentity() is what makes origin SELECT the store. // // The file is in two halves, and both matter: // // * OLD BEHAVIOUR INTACT. Every assertion that forIdentity() returns the SAME // OBJECT instance() returns — pointer identity, not equal contents — for a // name nobody isolated. That is the entire back-compatibility argument: // unless a host opts a specific name in, there is one store and it is the // one that was always there. (test_token_manager.cpp separately pins that // instance()'s own semantics are unchanged.) // // * NEW BEHAVIOUR. Two identities do not see each other's tokens, and an // isolated identity cannot reach a token it was never given — asserted // against an ambient CONTROL in the same shape, because a test that only // shows the isolated case is empty of information: it would pass just as // well if the token had never been reachable in the first place. Every // escalation case here has that control, and the control fails the isolated // assertion, which is what makes these detectors rather than pins. // // WHICH OF THESE ARE DETECTORS, checked the way this suite's CMakeLists demands // — by running them against code that does not have the mechanism, not by // reasoning about them. The neutering was a throwaway edit made in a throwaway // checkout: forIdentity()'s isolation branch replaced by `if (true)`, so origin // is a LABEL again and never selects a store. That is exactly the measured dead // end this change exists to escape (a per-plugin origin STRING with one shared // store underneath), and everything else — isolateIdentity's bookkeeping, the // registry, the C ABI — was left intact so only the tests that assert a SEPARATE // store can notice. // // 15 of the 30 cases here go RED on that build; the other 15 are PINS of // behaviour that is meant to be identical either way (the image store, the // refusals, the diagnostics, the version) and correctly stay green. The three // sharpest readings: // // AnIsolatedIdentityCannotReachTheAmbientRing // forIdentity("walled").hasToken("target_module") is TRUE on the neutered // build — the walled identity is holding the target's own root token, // which is the escalation in one line. // EachIsolatedIdentityMintsAndCachesItsOwnToken // requestModule handshake count is 0 instead of 2: BOTH identities found // the target's ambient token and neither ever asked capability_module for // anything. Not "one handshake shared" — none at all. // TheKnownCallerGateStillSeesEveryHostWrittenName // lp_token_keys() lists "private_target" on the neutered build: the // identity's own minted token went straight into the shared ring, which is // both the leak and the reason the trust root's view has to be checked // rather than assumed. // // Do not read a green run here as evidence on its own; re-do the neutering if // you change what these assert. #include #include "logos_api_client.h" #include "logos_mock.h" #include "logos_protocol.h" #include "token_manager.h" #include #include #include #include #include #include #include #include #include namespace { // Every test uses identity names of its own. The registry is process-global and // isolation is deliberately irreversible (see isolateIdentity's refusal rule), // so there is no reset hook to call in SetUp — distinct names are what keeps the // cases independent even when the whole binary is run in one process. QString id(const char* suffix) { return QStringLiteral("tsi_") + QString::fromLatin1(suffix); } struct LpClientGuard { explicit LpClientGuard(lp_client* c) : client(c) {} ~LpClientGuard() { lp_client_destroy(client); } lp_client* client; }; // Read an lp_* string return into a std::string and free it. Empty for NULL, // which every lp_token_get* uses for "absent". std::string takeString(char* s) { if (!s) return {}; std::string out(s); lp_string_free(s); return out; } } // namespace class TokenStoreIdentityTest : public ::testing::Test { protected: void SetUp() override { TokenManager::instance().clearAllTokens(); } void TearDown() override { TokenManager::instance().clearAllTokens(); } }; // ───────────────────────────────────────────────────────────────────────────── // Old behaviour intact // ───────────────────────────────────────────────────────────────────────────── // The load-bearing back-compat claim: same OBJECT, not merely same contents. TEST_F(TokenStoreIdentityTest, ANeverIsolatedIdentityGetsTheImageStoreItself) { EXPECT_EQ(&TokenManager::forIdentity(id("plain_a")), &TokenManager::instance()); EXPECT_FALSE(TokenManager::isIsolated(id("plain_a"))); } TEST_F(TokenStoreIdentityTest, EveryNonIsolatedIdentitySharesTheOneStore) { EXPECT_EQ(&TokenManager::forIdentity(id("share_a")), &TokenManager::forIdentity(id("share_b"))); } TEST_F(TokenStoreIdentityTest, WritesThroughTheImageStoreAreVisibleThroughForIdentity) { TokenManager::instance().saveToken("some_module", "tok-ambient"); EXPECT_EQ(TokenManager::forIdentity(id("rw_a")).getToken("some_module"), QStringLiteral("tok-ambient")); // ...and back the other way: it is one store, so a write through // forIdentity() lands in instance(). TokenManager::forIdentity(id("rw_a")).saveToken("other_module", "tok-via-identity"); EXPECT_EQ(TokenManager::instance().getToken("other_module"), QStringLiteral("tok-via-identity")); } // "" is what every un-named caller passes. Isolating it would put all of them in // ONE shared pseudo-store, which is strictly worse than leaving them ambient. TEST_F(TokenStoreIdentityTest, TheEmptyIdentityIsTheImageStoreAndCannotBeIsolated) { EXPECT_EQ(&TokenManager::forIdentity(QString()), &TokenManager::instance()); EXPECT_FALSE(TokenManager::isolateIdentity(QString())); EXPECT_FALSE(TokenManager::isolateIdentity(QStringLiteral(""))); EXPECT_EQ(&TokenManager::forIdentity(QString()), &TokenManager::instance()); } // ───────────────────────────────────────────────────────────────────────────── // New behaviour: isolation // ───────────────────────────────────────────────────────────────────────────── TEST_F(TokenStoreIdentityTest, AnIsolatedIdentityGetsAStoreOfItsOwn) { ASSERT_TRUE(TokenManager::isolateIdentity(id("own"))); EXPECT_TRUE(TokenManager::isIsolated(id("own"))); EXPECT_NE(&TokenManager::forIdentity(id("own")), &TokenManager::instance()); } // The address a client captures at construction must stay valid and stay the // same — LogosAPIClient holds it by raw pointer and dereferences it from async // continuations that can outlive their caller. TEST_F(TokenStoreIdentityTest, TheStoreAddressIsStableAcrossLookups) { ASSERT_TRUE(TokenManager::isolateIdentity(id("stable"))); TokenManager* first = &TokenManager::forIdentity(id("stable")); for (int i = 0; i < 100; ++i) EXPECT_EQ(&TokenManager::forIdentity(id("stable")), first); } // Two clients with separate stores do not see each other's tokens. TEST_F(TokenStoreIdentityTest, TwoIsolatedIdentitiesDoNotSeeEachOthersTokens) { ASSERT_TRUE(TokenManager::isolateIdentity(id("alpha"))); ASSERT_TRUE(TokenManager::isolateIdentity(id("beta"))); TokenManager& alpha = TokenManager::forIdentity(id("alpha")); TokenManager& beta = TokenManager::forIdentity(id("beta")); ASSERT_NE(&alpha, &beta); alpha.saveToken("target", "tok-for-alpha"); beta.saveToken("target", "tok-for-beta"); EXPECT_EQ(alpha.getToken("target"), QStringLiteral("tok-for-alpha")); EXPECT_EQ(beta.getToken("target"), QStringLiteral("tok-for-beta")); EXPECT_FALSE(alpha.getToken("target") == beta.getToken("target")); // Neither leaks into the image store, so a third, non-isolated caller does // not inherit either of them. EXPECT_TRUE(TokenManager::instance().getToken("target").isEmpty()); EXPECT_TRUE(TokenManager::forIdentity(id("gamma")).getToken("target").isEmpty()); } // THE ESCALATION, at the store layer. `target_module -> ` is exactly what a host writes for every loaded module; an isolated // identity must not be able to read it. The AMBIENT CONTROL in the same test // is what proves the assertion has content: without isolation, that token is // right there. TEST_F(TokenStoreIdentityTest, AnIsolatedIdentityCannotReachTheAmbientRing) { TokenManager::instance().saveToken("target_module", "targets-root-token"); // Control: an identity nobody isolated sees it, which is today's behaviour // and the reason this change exists. EXPECT_EQ(TokenManager::forIdentity(id("ambient_ctl")).getToken("target_module"), QStringLiteral("targets-root-token")); ASSERT_TRUE(TokenManager::isolateIdentity(id("walled"))); EXPECT_TRUE(TokenManager::forIdentity(id("walled")).getToken("target_module").isEmpty()); EXPECT_FALSE(TokenManager::forIdentity(id("walled")).hasToken("target_module")); EXPECT_FALSE(TokenManager::forIdentity(id("walled")).getTokenKeys() .contains(QStringLiteral("target_module"))); } // A token minted later for a DIFFERENT identity must not become visible either: // isolation is not just a snapshot taken at creation. TEST_F(TokenStoreIdentityTest, TokensAddedToTheRingLaterStayInvisible) { ASSERT_TRUE(TokenManager::isolateIdentity(id("late"))); TokenManager& walled = TokenManager::forIdentity(id("late")); TokenManager::instance().saveToken("appears_later", "root-token-added-after"); EXPECT_TRUE(walled.getToken("appears_later").isEmpty()); EXPECT_EQ(TokenManager::instance().getToken("appears_later"), QStringLiteral("root-token-added-after")); } TEST_F(TokenStoreIdentityTest, IsolationIsIdempotent) { ASSERT_TRUE(TokenManager::isolateIdentity(id("idem"))); TokenManager* first = &TokenManager::forIdentity(id("idem")); EXPECT_TRUE(TokenManager::isolateIdentity(id("idem"))); EXPECT_EQ(&TokenManager::forIdentity(id("idem")), first); } // The refusal that keeps a half-isolated identity from existing. A client // captured the shared store at construction; isolating now would leave one // client on the ambient ring and one on the private store — "looks fixed, // isn't", which is the failure mode this whole change exists to avoid. TEST_F(TokenStoreIdentityTest, IsolationIsRefusedOnceTheSharedStoreWasVended) { TokenManager& vended = TokenManager::forIdentity(id("too_late")); ASSERT_EQ(&vended, &TokenManager::instance()); EXPECT_FALSE(TokenManager::isolateIdentity(id("too_late"))); EXPECT_FALSE(TokenManager::isIsolated(id("too_late"))); // ...and nothing changed: the name still resolves to the store its existing // clients are already pointing at. EXPECT_EQ(&TokenManager::forIdentity(id("too_late")), &TokenManager::instance()); } TEST_F(TokenStoreIdentityTest, IsolatedIdentitiesAreListedForDiagnostics) { ASSERT_TRUE(TokenManager::isolateIdentity(id("listed_one"))); ASSERT_TRUE(TokenManager::isolateIdentity(id("listed_two"))); const QStringList listed = TokenManager::isolatedIdentities(); EXPECT_TRUE(listed.contains(id("listed_one"))); EXPECT_TRUE(listed.contains(id("listed_two"))); EXPECT_FALSE(listed.contains(id("never_isolated"))); } // ───────────────────────────────────────────────────────────────────────────── // Bootstrap seeding — the first-call path must survive isolation // ───────────────────────────────────────────────────────────────────────────── // A module's first call to an unknown target runs capability_module.requestModule, // and that call authenticates with the token stored under "capability_module". // Withhold it and an isolated identity could never obtain any token at all. TEST_F(TokenStoreIdentityTest, APrivateStoreIsSeededWithTheBootstrapTokens) { TokenManager::instance().saveToken("core", "core-tok"); TokenManager::instance().saveToken("capability_module", "cap-tok"); TokenManager::instance().saveToken("unrelated_module", "unrelated-root-tok"); ASSERT_TRUE(TokenManager::isolateIdentity(id("seeded"))); TokenManager& store = TokenManager::forIdentity(id("seeded")); EXPECT_EQ(store.getToken("core"), QStringLiteral("core-tok")); EXPECT_EQ(store.getToken("capability_module"), QStringLiteral("cap-tok")); // ...and NOTHING else. The bootstrap is the trust root, not a copy of the // ring: every other module's root token — the thing that made the ring an // escalation — stays behind. EXPECT_TRUE(store.getToken("unrelated_module").isEmpty()); EXPECT_EQ(store.tokenCount(), 2); EXPECT_EQ(TokenManager::bootstrapKeys().size(), 2); } TEST_F(TokenStoreIdentityTest, SeedingCopiesOnlyTheKeysThatExistInTheRing) { // Host seeded capability_module but not core: the private store gets what // there is, and no empty placeholder for what there isn't. TokenManager::instance().saveToken("capability_module", "cap-only"); ASSERT_TRUE(TokenManager::isolateIdentity(id("partial"))); TokenManager& store = TokenManager::forIdentity(id("partial")); EXPECT_EQ(store.getToken("capability_module"), QStringLiteral("cap-only")); EXPECT_FALSE(store.hasToken("core")); EXPECT_EQ(store.tokenCount(), 1); } // The ordering escape hatch: a host that learns a bootstrap token AFTER the // private store already existed can top it up. TEST_F(TokenStoreIdentityTest, BootstrapTokensCanBeSeededLate) { ASSERT_TRUE(TokenManager::isolateIdentity(id("late_seed"))); TokenManager& store = TokenManager::forIdentity(id("late_seed")); ASSERT_EQ(store.tokenCount(), 0); TokenManager::instance().saveToken("capability_module", "cap-late"); EXPECT_EQ(TokenManager::seedBootstrapTokens(id("late_seed")), 1); EXPECT_EQ(store.getToken("capability_module"), QStringLiteral("cap-late")); // Idempotent: a second pass copies nothing and does not clobber a token the // identity has since been given directly. store.saveToken("capability_module", "cap-identity-specific"); EXPECT_EQ(TokenManager::seedBootstrapTokens(id("late_seed")), 0); EXPECT_EQ(store.getToken("capability_module"), QStringLiteral("cap-identity-specific")); } // A no-op must not have side effects. seedBootstrapTokens() on a name nobody // isolated has to avoid vending the shared store for it, or "seed then isolate" // would silently become impossible. TEST_F(TokenStoreIdentityTest, SeedingANonIsolatedIdentityDoesNotBlockLaterIsolation) { TokenManager::instance().saveToken("capability_module", "cap-tok"); EXPECT_EQ(TokenManager::seedBootstrapTokens(id("seed_then_iso")), 0); EXPECT_TRUE(TokenManager::isolateIdentity(id("seed_then_iso"))); EXPECT_NE(&TokenManager::forIdentity(id("seed_then_iso")), &TokenManager::instance()); } // ───────────────────────────────────────────────────────────────────────────── // resetIdentity — the plugin-unload hook // ───────────────────────────────────────────────────────────────────────────── TEST_F(TokenStoreIdentityTest, ResetClearsIssuedTokensAndKeepsTheBootstrap) { TokenManager::instance().saveToken("capability_module", "cap-tok"); ASSERT_TRUE(TokenManager::isolateIdentity(id("reset_me"))); TokenManager& store = TokenManager::forIdentity(id("reset_me")); store.saveToken("target", "minted-for-previous-incarnation"); ASSERT_EQ(store.tokenCount(), 2); EXPECT_TRUE(TokenManager::resetIdentity(id("reset_me"))); // The store OBJECT survives — a client mid-flight holds it by raw pointer — // and only its CONTENTS have a lifetime. EXPECT_EQ(&TokenManager::forIdentity(id("reset_me")), &store); EXPECT_TRUE(store.getToken("target").isEmpty()); EXPECT_EQ(store.getToken("capability_module"), QStringLiteral("cap-tok")); } // Clearing the shared ring would take the host's tokens and every other // identity's with it, so the non-isolated case must refuse rather than obey. TEST_F(TokenStoreIdentityTest, ResetRefusesANonIsolatedIdentityAndTouchesNothing) { TokenManager::instance().saveToken("capability_module", "cap-tok"); TokenManager::instance().saveToken("some_module", "root-tok"); EXPECT_FALSE(TokenManager::resetIdentity(id("not_isolated"))); EXPECT_EQ(TokenManager::instance().tokenCount(), 2); EXPECT_EQ(TokenManager::instance().getToken("some_module"), QStringLiteral("root-tok")); } // ───────────────────────────────────────────────────────────────────────────── // The construction paths: which store a client ends up holding // ───────────────────────────────────────────────────────────────────────────── class TokenStoreClientTest : public ::testing::Test { protected: void SetUp() override { m_mock = new LogosMockSetup(); } void TearDown() override { delete m_mock; } LogosMockSetup* m_mock = nullptr; }; // The escalation at the CLIENT layer, which is the layer that actually reads the // store: LogosAPIClient::getToken is the hot-path lookup that finds a cached // token and skips minting. An isolated client must come up empty for a module it // was never given — with the ambient client in the same test showing it // otherwise comes up full. TEST_F(TokenStoreClientTest, AnIsolatedClientCannotPresentAnotherModulesRootToken) { // Exactly what a host writes for every module it loads. TokenManager::instance().saveToken("victim_module", "victims-root-token"); LogosAPIClient ambient(QStringLiteral("victim_module"), id("client_ambient"), &TokenManager::forIdentity(id("client_ambient"))); EXPECT_EQ(ambient.getToken(QStringLiteral("victim_module")), QStringLiteral("victims-root-token")) << "control failed: without isolation the ambient ring must still hand " "over the victim's root token, or this test proves nothing"; ASSERT_TRUE(TokenManager::isolateIdentity(id("client_walled"))); LogosAPIClient walled(QStringLiteral("victim_module"), id("client_walled"), &TokenManager::forIdentity(id("client_walled"))); EXPECT_TRUE(walled.getToken(QStringLiteral("victim_module")).isEmpty()); } // A NULL store is the construction path lp_client_create is stuck with (its // signature cannot be handed one), so it must mean "the store for the identity I // said I am" rather than a crash on first use. TEST_F(TokenStoreClientTest, ANullStoreResolvesToTheOriginsStore) { ASSERT_TRUE(TokenManager::isolateIdentity(id("null_store"))); TokenManager::forIdentity(id("null_store")).saveToken("t", "private-tok"); TokenManager::instance().saveToken("t", "ambient-tok"); LogosAPIClient client(QStringLiteral("t"), id("null_store"), nullptr); EXPECT_EQ(client.getTokenManager(), &TokenManager::forIdentity(id("null_store"))); EXPECT_EQ(client.getToken(QStringLiteral("t")), QStringLiteral("private-tok")); LogosAPIClient ambient(QStringLiteral("t"), id("null_store_ambient"), nullptr); EXPECT_EQ(ambient.getTokenManager(), &TokenManager::instance()); EXPECT_EQ(ambient.getToken(QStringLiteral("t")), QStringLiteral("ambient-tok")); } // End to end through invokeRemoteMethod: each isolated identity runs its OWN // requestModule handshake and caches the result in its OWN store, and neither // mint reaches the shared ring. The handshake COUNT is the observable that // separates the two worlds — on the shared store the second identity finds the // first one's cached token and never handshakes at all. TEST_F(TokenStoreClientTest, EachIsolatedIdentityMintsAndCachesItsOwnToken) { ASSERT_TRUE(TokenManager::isolateIdentity(id("mint_alpha"))); ASSERT_TRUE(TokenManager::isolateIdentity(id("mint_beta"))); // when() also seeds instance() with a dummy token for the module, which is // what makes the shared ring look exactly like a host's: a token for the // target is sitting there for the taking. m_mock->when("shared_target", "ping").thenReturn(QVariant("pong")); m_mock->when("capability_module", "requestModule") .withArgs(QVariantList{id("mint_alpha"), QStringLiteral("shared_target")}) .thenReturn(QVariant("minted-for-alpha")); m_mock->when("capability_module", "requestModule") .withArgs(QVariantList{id("mint_beta"), QStringLiteral("shared_target")}) .thenReturn(QVariant("minted-for-beta")); LogosAPIClient alpha(QStringLiteral("shared_target"), id("mint_alpha"), &TokenManager::forIdentity(id("mint_alpha"))); LogosAPIClient beta(QStringLiteral("shared_target"), id("mint_beta"), &TokenManager::forIdentity(id("mint_beta"))); // The bootstrap reached the CLIENT, not just the store: mintAndCacheToken // authenticates its requestModule with whatever getToken("capability_module") // returns, so an isolated identity that did not inherit this token could // never obtain any token at all. This is the first-call guarantee, checked // where it is actually consumed. EXPECT_EQ(alpha.getToken(QStringLiteral("capability_module")), QStringLiteral("mock-token-capability_module")); EXPECT_EQ(beta.getToken(QStringLiteral("capability_module")), QStringLiteral("mock-token-capability_module")); EXPECT_EQ(alpha.invokeRemoteMethod(QStringLiteral("shared_target"), QStringLiteral("ping"), QVariantList{}).toString(), QStringLiteral("pong")); EXPECT_EQ(beta.invokeRemoteMethod(QStringLiteral("shared_target"), QStringLiteral("ping"), QVariantList{}).toString(), QStringLiteral("pong")); EXPECT_EQ(m_mock->callCount("capability_module", "requestModule"), 2) << "each isolated identity must run its own handshake; a shared store " "would let the second caller reuse the first's cached token (or the " "target's own root token) and handshake zero more times"; EXPECT_EQ(TokenManager::forIdentity(id("mint_alpha")).getToken("shared_target"), QStringLiteral("minted-for-alpha")); EXPECT_EQ(TokenManager::forIdentity(id("mint_beta")).getToken("shared_target"), QStringLiteral("minted-for-beta")); // The minted tokens stayed out of the shared ring, so a third caller does // not inherit either identity's authority. EXPECT_EQ(TokenManager::instance().getToken("shared_target"), QStringLiteral("mock-token-shared_target")); } // The control for the test above, run separately so the handshake count is its // own: with nothing isolated, the ambient token is found and NO handshake fires. // This is the behaviour being preserved for every host that does not opt in. TEST_F(TokenStoreClientTest, ANonIsolatedIdentityStillShortCircuitsOnTheAmbientToken) { m_mock->when("shared_target", "ping").thenReturn(QVariant("pong")); m_mock->when("capability_module", "requestModule").thenReturn(QVariant("should-not-mint")); LogosAPIClient client(QStringLiteral("shared_target"), id("ambient_mint"), &TokenManager::forIdentity(id("ambient_mint"))); EXPECT_EQ(client.invokeRemoteMethod(QStringLiteral("shared_target"), QStringLiteral("ping"), QVariantList{}).toString(), QStringLiteral("pong")); EXPECT_EQ(m_mock->callCount("capability_module", "requestModule"), 0); EXPECT_EQ(TokenManager::instance().getToken("shared_target"), QStringLiteral("mock-token-shared_target")); } // ───────────────────────────────────────────────────────────────────────────── // The C ABI // ───────────────────────────────────────────────────────────────────────────── class TokenStoreAbiTest : public ::testing::Test { protected: void SetUp() override { m_mock = new LogosMockSetup(); } void TearDown() override { delete m_mock; } LogosMockSetup* m_mock = nullptr; }; TEST_F(TokenStoreAbiTest, IsolateAndQueryRoundTrip) { EXPECT_EQ(lp_token_identity_is_isolated(id("abi_iso").toUtf8().constData()), 0); EXPECT_EQ(lp_token_isolate_identity(id("abi_iso").toUtf8().constData()), LP_OK); EXPECT_EQ(lp_token_identity_is_isolated(id("abi_iso").toUtf8().constData()), 1); // Idempotent, same as the C++ twin. EXPECT_EQ(lp_token_isolate_identity(id("abi_iso").toUtf8().constData()), LP_OK); EXPECT_EQ(lp_token_isolate_identity(nullptr), LP_ERR_INVALID_ARG); EXPECT_EQ(lp_token_isolate_identity(""), LP_ERR_INVALID_ARG); EXPECT_EQ(lp_token_identity_is_isolated(nullptr), LP_ERR_INVALID_ARG); } TEST_F(TokenStoreAbiTest, PerIdentityGetAndSaveDoNotTouchTheImageStore) { ASSERT_EQ(lp_token_isolate_identity(id("abi_rw").toUtf8().constData()), LP_OK); const QByteArray who = id("abi_rw").toUtf8(); ASSERT_EQ(lp_token_save("mod", "ambient-tok"), LP_OK); ASSERT_EQ(lp_token_save_for(who.constData(), "mod", "private-tok"), LP_OK); EXPECT_EQ(takeString(lp_token_get("mod")), "ambient-tok"); EXPECT_EQ(takeString(lp_token_get_for(who.constData(), "mod")), "private-tok"); // An identity nobody isolated reads and writes the image store — the // unchanged path. const QByteArray plain = id("abi_plain").toUtf8(); EXPECT_EQ(takeString(lp_token_get_for(plain.constData(), "mod")), "ambient-tok"); EXPECT_EQ(lp_token_save_for(nullptr, "mod", "x"), LP_ERR_INVALID_ARG); EXPECT_EQ(lp_token_save_for(who.constData(), nullptr, "x"), LP_ERR_INVALID_ARG); EXPECT_EQ(lp_token_save_for(who.constData(), "mod", nullptr), LP_ERR_INVALID_ARG); EXPECT_EQ(lp_token_get_for(nullptr, "mod"), nullptr); EXPECT_EQ(lp_token_get_for(who.constData(), nullptr), nullptr); // Absent, not refused — NULL is the documented "no token here". EXPECT_EQ(lp_token_get_for(who.constData(), "never_stored"), nullptr); } TEST_F(TokenStoreAbiTest, ResetIdentityClearsTheIssuedTokensOnly) { ASSERT_EQ(lp_token_save("capability_module", "cap-tok"), LP_OK); const QByteArray who = id("abi_reset").toUtf8(); ASSERT_EQ(lp_token_isolate_identity(who.constData()), LP_OK); ASSERT_EQ(lp_token_save_for(who.constData(), "target", "stale-tok"), LP_OK); EXPECT_EQ(lp_token_reset_identity(who.constData()), LP_OK); EXPECT_EQ(lp_token_get_for(who.constData(), "target"), nullptr); EXPECT_EQ(takeString(lp_token_get_for(who.constData(), "capability_module")), "cap-tok"); // Refused for a shared store rather than silently clearing everyone's. EXPECT_EQ(lp_token_reset_identity(id("abi_not_isolated").toUtf8().constData()), LP_ERR_UNSUPPORTED); EXPECT_EQ(takeString(lp_token_get("capability_module")), "cap-tok"); EXPECT_EQ(lp_token_reset_identity(nullptr), LP_ERR_INVALID_ARG); } TEST_F(TokenStoreAbiTest, IsolationIsRefusedOnceAClientForThatOriginExists) { // lp_client_create resolves the store through the origin, so creating one // vends the shared store for that name — after which isolating it would // split the identity in half. The ABI must report that, not swallow it. m_mock->when("t", "ping").thenReturn(QVariant("pong")); const QByteArray who = id("abi_too_late").toUtf8(); lp_client* client = lp_client_create("t", who.constData(), nullptr, nullptr); ASSERT_NE(client, nullptr); LpClientGuard guard(client); EXPECT_EQ(lp_token_isolate_identity(who.constData()), LP_ERR_UNSUPPORTED); EXPECT_EQ(lp_token_identity_is_isolated(who.constData()), 0); } // lp_client_create's store selection, end to end: an isolated origin's client // cannot use the ambient token, mints its own, and caches it in its own store. TEST_F(TokenStoreAbiTest, AnLpClientForAnIsolatedOriginUsesThatIdentitysStore) { const QByteArray who = id("abi_client_iso").toUtf8(); ASSERT_EQ(lp_token_isolate_identity(who.constData()), LP_OK); m_mock->when("lp_target", "ping").thenReturn(QVariant("pong")); m_mock->when("capability_module", "requestModule").thenReturn(QVariant("minted-for-lp")); lp_client* client = lp_client_create("lp_target", who.constData(), nullptr, nullptr); ASSERT_NE(client, nullptr); LpClientGuard guard(client); char* result = nullptr; ASSERT_EQ(lp_invoke(client, "ping", nullptr, 0, &result, nullptr), LP_OK); ASSERT_NE(result, nullptr); lp_string_free(result); EXPECT_EQ(m_mock->callCount("capability_module", "requestModule"), 1) << "an isolated origin must not find the target's ambient token"; EXPECT_EQ(takeString(lp_token_get_for(who.constData(), "lp_target")), "minted-for-lp"); // The ambient ring is untouched — still the dummy the harness seeded. EXPECT_EQ(takeString(lp_token_get("lp_target")), "mock-token-lp_target"); } // The matching control: a non-isolated origin behaves exactly as it did before // this change — ambient token found, no handshake. TEST_F(TokenStoreAbiTest, AnLpClientForANonIsolatedOriginKeepsUsingTheImageStore) { m_mock->when("lp_target", "ping").thenReturn(QVariant("pong")); m_mock->when("capability_module", "requestModule").thenReturn(QVariant("should-not-mint")); const QByteArray who = id("abi_client_ambient").toUtf8(); lp_client* client = lp_client_create("lp_target", who.constData(), nullptr, nullptr); ASSERT_NE(client, nullptr); LpClientGuard guard(client); char* result = nullptr; ASSERT_EQ(lp_invoke(client, "ping", nullptr, 0, &result, nullptr), LP_OK); ASSERT_NE(result, nullptr); lp_string_free(result); EXPECT_EQ(m_mock->callCount("capability_module", "requestModule"), 0); EXPECT_EQ(takeString(lp_token_get("lp_target")), "mock-token-lp_target"); } // ───────────────────────────────────────────────────────────────────────────── // The trust root keeps working // ───────────────────────────────────────────────────────────────────────────── // // capability_module's known-caller gate reads lp_token_keys() under the // "token_registry" host service, and REFUSES an unknown origin. If per-identity // stores blinded that gate, every first call in the system would be refused and // the bootstrap would deadlock — so this is the constraint worth proving rather // than asserting. // // The argument in full, of which the case below is the mechanical half: // // 1. lp_token_keys() reads TokenManager::instance() and is UNCHANGED. No // isolation path writes to it, removes from it, or redirects it. // 2. When capability_module runs in its own image (its own process, or a // cdylib with its own copy of this library), it has its own instance() and // a host image's registry cannot reach it at all. Nothing to prove. // 3. When it runs IN the host image, instance() is the shared ring. Isolation // only ADDS private stores; it never removes an entry. The single thing that // moves is a consumer-side CACHE write by an isolated identity, which now // lands in that identity's store — and those writes are keyed by the TARGET // module, while the gate consults ORIGIN names. Origin names are in // instance() because the HOST wrote `name -> root token` for every module it // loaded, and this change does not touch host writes. // 4. The bootstrap survives because a private store is seeded with "core" and // "capability_module", so the identity's first requestModule authenticates // exactly as it did before (asserted in the seeding cases above, and at the // client in EachIsolatedIdentityMintsAndCachesItsOwnToken). class TokenStoreTrustRootTest : public ::testing::Test { protected: void SetUp() override { m_mock = new LogosMockSetup(); ASSERT_EQ(lp_grant_host_services(R"(["token_registry"])"), LP_OK); } void TearDown() override { lp_grant_host_services(nullptr); // re-close the gate for later cases delete m_mock; } LogosMockSetup* m_mock = nullptr; }; TEST_F(TokenStoreTrustRootTest, TheKnownCallerGateStillSeesEveryHostWrittenName) { // What a host writes for every module it loads — and what the gate reads. TokenManager::instance().saveToken("loaded_mod_a", "root-a"); TokenManager::instance().saveToken("loaded_mod_b", "root-b"); // An isolated identity that has since cached a token of its own. ASSERT_TRUE(TokenManager::isolateIdentity(id("trust_iso"))); TokenManager::forIdentity(id("trust_iso")).saveToken("private_target", "minted-privately"); const std::string keys = takeString(lp_token_keys()); ASSERT_FALSE(keys.empty()) << "the grant was not in effect"; const nlohmann::json parsed = nlohmann::json::parse(keys, nullptr, false); ASSERT_TRUE(parsed.is_array()); std::vector names; for (const nlohmann::json& e : parsed) names.push_back(e.get()); auto has = [&names](const char* n) { return std::find(names.begin(), names.end(), n) != names.end(); }; // The gate's inputs are intact: both host-written names are still there. EXPECT_TRUE(has("loaded_mod_a")); EXPECT_TRUE(has("loaded_mod_b")); // And an isolated identity's private cache is NOT folded into the trust // root's view — isolation neither blinds the gate nor widens it. EXPECT_FALSE(has("private_target")); } // The version this surface shipped in. lp_token_isolate_identity and friends are // purely additive, so MINOR moves and MAJOR does not — an older host stays // compatible with this library and simply never isolates anything. TEST(TokenStoreIdentityVersion, TheAdditiveSurfaceMovedMinorNotMajor) { EXPECT_EQ(lp_protocol_abi_major(), 0); EXPECT_GE(LOGOS_PROTOCOL_VERSION_MINOR, 4); }