mirror of
https://github.com/logos-co/logos-protocol.git
synced 2026-08-30 13:31:12 +00:00
* feat: the host-services C ABI a trust-root module needs
capability_module is the last legacy Qt Q_INVOKABLE provider, and it cannot
become an ordinary `interface: universal` module while the two things it does
have no C entry point: reading the token store, and pushing a token to an
ARBITRARY target. This adds both, plus the grant that gates them. Purely
additive — no existing symbol changes behaviour.
lp_token_keys() the module names THIS image's TokenManager
holds. NULL means REFUSED, never "empty" — a
granted call with no tokens answers "[]", and a
known-caller gate needs to tell those apart.
lp_inform_module_token_to() routes to LogosAPIClient::informModuleToken_module,
the 5-arg form. Note the existing
lp_inform_module_token is the WRONG DIRECTION
for this: it reaches a consumer path that
hardcodes requestObject("capability_module"),
i.e. core -> capability, not capability ->
target. That 5-arg method had no C entry point.
lp_grant_host_services() sets the in-image grant over the closed set
{token_registry, token_delivery}. Replaces
rather than merges; NULL/""/"[]" clears. An
unknown name is rejected wholesale and leaves
the existing grant untouched, so a typo can
never silently drop a service.
Why the gate is per-IMAGE, which looks like an odd choice until it doesn't:
the host binary and a module's cdylib each link their own copy of this library,
so they have separate process-global state. A gate "simplified" into the host
would be checked against state the calling image can never set, and would read
as ungranted forever. The grant therefore crosses the module-impl C ABI the
same way the auth token already does — hence the logos_module_grant_host_services
declaration added to logos_module_impl.h, whose generated body and host-side
call land in logos-cpp-sdk and logos-module-loader-qt respectively.
MINOR 2 -> 3; MAJOR unchanged, so the equal-MAJOR compatibility rule is
unaffected. 387/387 tests pass, including 6 new ones covering both gates
closed, both opened, clearing re-closing them, and the unknown-name rejection.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(tokens): a per-CLIENT token store, selected by origin
TokenManager::instance() is the IMAGE's store, and in a host that loads
plugins in-process it is also an ambient ring: the host writes
`name -> that module's root auth token` for EVERY module it loads. On the
hot path a client asserts no identity at all — invokeRemoteMethod reads
the store first and only mints on a miss — so a plugin asking for target X
finds X's own root token sitting there and presents it. The provider
accepts any token in its image's store, so the call authorizes and no
requestModule is ever logged. Every plugin in that image holds every other
module's authority, and giving a plugin its own ORIGIN STRING changes none
of it, because origin was never consulted on the path taken.
This makes origin SELECT THE STORE rather than merely label the caller.
TokenManager::forIdentity(x) the store to present tokens from when I am x
TokenManager::isolateIdentity(x) give x a private store (host opt-in)
isIsolated / isolatedIdentities / bootstrapKeys / seedBootstrapTokens /
resetIdentity
ADDITIVE BY CONSTRUCTION, not by promise: forIdentity() returns the SAME
OBJECT instance() returns — pointer-identical — for every name until
someone isolates that exact name, so a host that knows nothing about this
is byte-for-byte unchanged. All seven are static member FUNCTIONS: no data
member, no virtual, nothing moc sees. Measured, not asserted: the exported
symbol table of liblogos_protocol.dylib gains exactly 12 names (7 statics +
5 lp_*) and LOSES NONE (736 -> 748). Neither ABI-sensitive private layout
(LogosAPIClient, LogosAPIConsumer) was touched at all.
Construction paths in this repo:
* LogosAPIClient / LogosAPIConsumer: an explicit store still wins; a NULL
store now resolves to forIdentity(origin) instead of being a guaranteed
crash on the first getToken().
* lp_client_create: &TokenManager::forIdentity(origin), not instance().
This is the whole answer to that function's frozen signature — the store
cannot be handed to it, so the origin it already takes must select it.
Bootstrap (constraint 4) survives because a private store is created seeded
with "core" and "capability_module" copied from instance(), and with
NOTHING else — the two keys the first requestModule authenticates with, not
a copy of the ring. resetIdentity() is the unload hook: it clears the
contents and re-seeds, while the store OBJECT stays immortal because a
client holds it by raw pointer from continuations that outlive their caller.
The trust root (constraint 3) is unaffected, and it is checked rather than
argued: lp_token_keys() still reads instance(), isolation only ADDS stores,
and the one thing that moves — an isolated identity's consumer-side CACHE
write — is keyed by TARGET while the known-caller gate consults ORIGIN
names, which the HOST writes and this change never touches.
C ABI grows five additive symbols, each carrying LP_API:
lp_token_isolate_identity, lp_token_identity_is_isolated, lp_token_get_for,
lp_token_save_for, lp_token_reset_identity. Protocol version 0.3.0 -> 0.4.0
(MINOR: additive).
Tests: 439/439 before, 469/469 after. The 30 new cases were validated as
DETECTORS the way this suite requires — against a throwaway build with
forIdentity()'s isolation branch neutered to `if (true)`, i.e. origin as a
label again. 15 go RED there (the walled identity holds the target's root
token; the handshake count is 0 instead of 2; lp_token_keys() lists the
identity's private mint), and the other 15 are pins of behaviour that must
be identical either way. Every escalation case carries an ambient CONTROL
asserting the token IS reachable without isolation.
Hosts are deliberately NOT changed here.
* feat(codec): shape-check the untyped containers
`[any]` and `{tstr:any}` both spell `nlohmann::json` in C++ — LogosList and
LogosMap are aliases of it — so no Codec<T> specialization can tell them apart
and fromJson<T> has nothing to dispatch on. Their SHAPE is still declared,
though, and array-ness / object-ness is the whole of the declared type at that
layer.
jsonRequireArray / jsonRequireObject check exactly that and hand the value on
UNCHANGED, throwing through the codec's own detail::typeError so the message is
the one every other surface already produces ("expected array at arg0, got
string"). The value is not rebuilt from JSON: that would retype nested elements
for no validation gain, which is the same reasoning logos_qt_arg_decode.h gives
for the Qt surface.
This is what logos_codec.h:36 already promised and these two types quietly did
not honour — "shape mismatches throw CodecError … rather than silently
substituting a default, silent defaults are how a mangled value reaches business
logic."
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
718 lines
36 KiB
C++
718 lines
36 KiB
C++
// 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 <gtest/gtest.h>
|
|
|
|
#include "logos_api_client.h"
|
|
#include "logos_mock.h"
|
|
#include "logos_protocol.h"
|
|
#include "token_manager.h"
|
|
|
|
#include <QByteArray>
|
|
#include <QString>
|
|
#include <QStringList>
|
|
#include <QVariant>
|
|
#include <QVariantList>
|
|
|
|
#include <algorithm>
|
|
#include <string>
|
|
#include <vector>
|
|
|
|
#include <nlohmann/json.hpp>
|
|
|
|
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 -> <target's own root
|
|
// token>` 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<std::string> names;
|
|
for (const nlohmann::json& e : parsed) names.push_back(e.get<std::string>());
|
|
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);
|
|
}
|