mirror of
https://github.com/logos-co/logos-protocol.git
synced 2026-08-30 21:41:10 +00:00
TokenManager::forIdentity seeded every new private store by COPYING the
host's tokens for bootstrapKeys() = {core, capability_module}. Those values
are the HOST's, so an isolated in-process consumer presented basecamp's
anchor and ModuleProxy::resolveCaller answered HostAnchor: a sandboxed view
wearing the host's authority.
Half of that was LIVE, not latent. informModuleToken's trusted-channel gate
compares against the SAME two keys, so anything holding an isolated
LogosAPI* could read getToken("capability_module") and call
informModuleToken on capability_module — three public calls, no glue — and
write into the map that is both its known-caller gate and its moduleToken
source. Reading a caller needs generated glue; writing one did not.
The copy could not simply be deleted. Measured: removing it alone turns 5
of 495 protocol tests red, and two are behavioural — an isolated identity
cannot reach capability_module.requestModule (it dies at ModuleProxy's
`authToken.isEmpty()`), and an isolated PROVIDER can never be told about a
caller. Isolation without a credential is a lockout.
The credential already existed and was being thrown away. All five host
registration sites minted a per-spawn UUID, registered it with
capability_module, and then dropped it: the identity was registered under a
token nobody held, and it worked only because the store presented the
copied anchor. The anchor copy was masking that at every site, which is why
neither could be fixed alone.
So: a private store starts empty, and an identity's store carries THAT
IDENTITY's own host-issued credential under the bootstrap keys —
adoptCredentialFor, which refuses the host anchor by construction. This is
not a new rule. ui-host already does exactly it for the out-of-process half
(saveToken(core/capability_module, its own authToken)), and
LogosAPIProvider::seedHandshakeTrustAnchor does it for a module image. The
in-process private store was the only store in the system seeded with
somebody else's credential.
`core` is not part of it for a CONSUMER: every reader of a store's "core"
entry is provider-side, and in a real host instance() has no "core" key at
all — the host ring is written only under module names, and no module is
named core.
Closing the elevation also makes the consumer NAMEABLE in the same change:
it now resolves as {"kind":"module","name":<identity>} at capability_module
and at ordinary modules, instead of {"kind":"host"}.
NOTE FOR CONSUMERS OF THE C ABI: lp_token_reset_identity changed meaning on
an existing exported symbol — it no longer re-seeds, so an out-of-tree
caller that reset and kept going is now locked out. No in-workspace caller
exists; carried by the MINOR bump to 0.7.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
500 lines
25 KiB
C++
500 lines
25 KiB
C++
// AN ISOLATED IDENTITY'S OWN CREDENTIAL — the two properties that pull in
|
|
// opposite directions, and why neither is evidence without the other.
|
|
//
|
|
// A host that loads several callers into ONE process gives each of them a
|
|
// private token store (TokenManager::isolateIdentity). Until this change that
|
|
// store was CREATED by copying instance()'s tokens for bootstrapKeys() —
|
|
// "core" and "capability_module" — which are the HOST's credential. Two things
|
|
// followed, and only the first of them was latent:
|
|
//
|
|
// * ELEVATION AT NAMING. Presenting the host's anchor hits an anchor key in
|
|
// the callee's m_store, so ModuleProxy::authorize answers
|
|
// logos::callerHostAnchorJson(): a sandboxed in-process view is reported as
|
|
// the host. Latent only because both anchor targets are legacy Q_INVOKABLE
|
|
// plugins with no generated glue to read a caller.
|
|
// * ELEVATION AT AUTHORITY, which is live. informModuleToken's
|
|
// trusted-channel gate compares the presenting token against the SAME two
|
|
// keys, so any holder of the copy can push arbitrary (name, token) pairs
|
|
// into another module's token map — three public calls, no glue.
|
|
//
|
|
// THE COPY EXISTED FOR A REAL REASON and deleting it alone is not a fix. The
|
|
// credential under "capability_module" is what authenticates
|
|
// `capability_module.requestModule`; without SOME value there,
|
|
// ModuleProxy::authorize refuses at its empty-token check and the identity can
|
|
// never obtain a token for anything. Isolation becomes a LOCKOUT, which is
|
|
// worse than the elevation it replaces.
|
|
//
|
|
// So the fix is not "remove the seed" but "seed the RIGHT value": the identity's
|
|
// OWN host-issued credential, minted by the host and registered with
|
|
// capability_module before it is adopted. Every other image in the system
|
|
// already does exactly this (LogosAPIProvider::seedHandshakeTrustAnchor for a
|
|
// module image; ui-host for a view process). The in-process private store was
|
|
// the only store seeded with somebody else's credential.
|
|
//
|
|
// THE TWO TESTS THAT MATTER, and each fails without the other's half:
|
|
//
|
|
// NO ANCHOR AnIsolatedIdentityHoldsNoValueOfTheHosts
|
|
// AnAdmittedConsumerIsNamedAsItselfAndNotAsTheHost
|
|
// AnIsolatedIdentityWithNoCredentialIsRefusedEverywhere
|
|
// NO LOCKOUT AnAdmittedConsumerCompletesTheHandshakeAndReachesAnOrdinaryModule
|
|
// AnAdmittedProviderIdentityStillAcceptsAPushFromCapability
|
|
//
|
|
// HOW THIS WAS VALIDATED — three builds, each run in full (506 tests), each
|
|
// missing something the real tree has. Throwaway local edits, made and thrown
|
|
// away, exactly as the note at the top of tests/protocol/CMakeLists.txt
|
|
// prescribes; not a build flag and not a switch in this tree.
|
|
//
|
|
// (M0) TODAY'S BEHAVIOUR — forIdentity() seeding a new private store from
|
|
// instance()'s bootstrap keys, AND adoptCredentialFor() reduced to
|
|
// `return true` without writing. That pair is exactly master: the copy
|
|
// exists, and no host anywhere hands an identity a credential of its own
|
|
// (all five registration sites mint a UUID, register it, and drop it).
|
|
// 16 of 506 FAILED. The NO ANCHOR set is red, and the sharpest single
|
|
// reading is
|
|
// AnAdmittedConsumerIsNamedAsItselfAndNotAsTheHost
|
|
// — {"kind":"host"} where {"kind":"module","name":...} is wanted.
|
|
// That is the elevation in one line: a sandboxed in-process view
|
|
// reported as basecamp.
|
|
// So is the outcome-shaped statement of the same thing,
|
|
// AnIsolatedIdentityWithNoCredentialIsRefusedEverywhere
|
|
// — an identity nobody credentialed calls capability_module
|
|
// successfully, because it inherited the right to.
|
|
//
|
|
// (M1) THE COPY, WITH ADOPTION WORKING — only forIdentity()'s seeding
|
|
// restored. 7 of 506 FAILED, and WHICH seven is the interesting part:
|
|
// AnAdmittedConsumerIsNamedAsItselfAndNotAsTheHost stays GREEN, because
|
|
// adoption overwrites the copy for any identity that is admitted. The
|
|
// copy is only observable on an identity nobody adopted — which is why
|
|
// APrivateStoreIsBornEmpty and
|
|
// AnIsolatedIdentityWithNoCredentialIsRefusedEverywhere have to exist
|
|
// as separate cases rather than being folded into the admitted ones.
|
|
//
|
|
// (M2) THE COPY REMOVED AND ADOPTION NEUTERED — the "just delete the seed"
|
|
// change, on its own. 10 of 506 FAILED: every NO ANCHOR case goes GREEN
|
|
// and every NO LOCKOUT case goes RED —
|
|
// AnAdmittedConsumerCompletesTheHandshakeAndReachesAnOrdinaryModule
|
|
// — requestModule refused; the consumer has nothing to present
|
|
// AnAdmittedProviderIdentityStillAcceptsAPushFromCapability
|
|
// — informModuleToken refused; the identity can never be told
|
|
// about any caller
|
|
// InboundTokenStore.AnIsolatedProviderIdentityStillAuthorizesInboundCalls
|
|
// TokenStoreClientTest.EachIsolatedIdentityMintsAndCachesItsOwnToken
|
|
// That is the live outage this file exists to make unshippable by
|
|
// accident: passing NO ANCHOR alone is not progress, it is a different
|
|
// and worse bug.
|
|
//
|
|
// AnAdmittedConsumerIsNamedAsItselfAndNotAsTheHost is red on M0 AND on M2 and
|
|
// green only on the real tree, which makes it the one case that detects each
|
|
// half's absence on its own. AHostThatNeverIsolatesSeesNoChangeAtAll is a PIN —
|
|
// green on all three — and must not be read as evidence of anything this change
|
|
// added; it is the compatibility claim, and its job is to stay green.
|
|
|
|
#include <gtest/gtest.h>
|
|
|
|
#include "logos_caller_scope.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 <QStringList>
|
|
#include <QVariantList>
|
|
|
|
#include <string>
|
|
|
|
namespace {
|
|
|
|
QCoreApplication* ensureCredApp() {
|
|
static int argc = 0;
|
|
static char* argv[] = { nullptr };
|
|
if (!QCoreApplication::instance())
|
|
new QCoreApplication(argc, argv);
|
|
return QCoreApplication::instance();
|
|
}
|
|
|
|
const char* kHostDoc = R"({"kind":"host"})";
|
|
std::string moduleDoc(const QString& name) {
|
|
return std::string(R"({"kind":"module","name":")") + name.toStdString() + R"("})";
|
|
}
|
|
|
|
// A stand-in for a real module behind a ModuleProxy: it answers one method,
|
|
// records who the dispatch said was calling, and mirrors
|
|
// LogosProviderBase::informModuleToken by writing the pushed token into the
|
|
// store it was handed (which for the Qt stack is
|
|
// TokenManager::forIdentity(<this module's name>)).
|
|
class ProbeProvider : public LogosProviderObject {
|
|
public:
|
|
ProbeProvider(QString name, TokenManager* store)
|
|
: m_name(std::move(name)), m_store(store) {}
|
|
|
|
QVariant callMethod(const QString& method, const QVariantList&) override {
|
|
++calls;
|
|
seen = logos::currentInboundCallerJson();
|
|
if (method == QLatin1String("work")) return QStringLiteral("ok");
|
|
if (method == QLatin1String("requestModule")) return QStringLiteral("ok");
|
|
return QVariant();
|
|
}
|
|
bool informModuleToken(const QString& moduleName, const QString& token) override {
|
|
if (m_store) m_store->saveToken(moduleName, token);
|
|
++informs;
|
|
return true;
|
|
}
|
|
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 m_name; }
|
|
QString providerVersion() const override { return QStringLiteral("1.0.0"); }
|
|
|
|
int calls = 0;
|
|
int informs = 0;
|
|
std::string seen;
|
|
|
|
private:
|
|
QString m_name;
|
|
TokenManager* m_store = nullptr;
|
|
};
|
|
|
|
bool dispatched(const QVariant& r) {
|
|
return !logos::isUnauthorizedSentinel(r) && r.toString() == QStringLiteral("ok");
|
|
}
|
|
|
|
// Every test names its own identities: the store registry is process-global and
|
|
// isolation is deliberately irreversible, so distinct names are what keeps the
|
|
// cases independent inside one binary.
|
|
QString cid(const char* suffix) {
|
|
return QStringLiteral("cc_") + QString::fromLatin1(suffix);
|
|
}
|
|
|
|
// The host's own credential, written where a real host writes it: into
|
|
// instance(), under both bootstrap keys, before anything is isolated. This is
|
|
// the value an isolated store USED to be born holding.
|
|
QString seedHostAnchor(const char* value) {
|
|
const QString anchor = QString::fromLatin1(value);
|
|
TokenManager::instance().adoptCredential(anchor);
|
|
return anchor;
|
|
}
|
|
|
|
// THE MECHANISM UNDER TEST, spelled out at the protocol layer so this file does
|
|
// not depend on logos-plugin-qt. logos::admitConsumer() is the Qt-side wrapper
|
|
// around exactly these three steps in exactly this order.
|
|
//
|
|
// REGISTER BEFORE ADOPT. The identity must never hold a credential
|
|
// capability_module has not yet accepted; doing it the other way round leaves a
|
|
// window in which the consumer's first call presents a token the trust root has
|
|
// never heard of, and the whole point of the synchronous registration both hosts
|
|
// already perform is to have no such window.
|
|
bool admit(const QString& identity, const QString& credential,
|
|
ModuleProxy& capabilityProxy, const QString& hostAnchor)
|
|
{
|
|
if (!TokenManager::isolateIdentity(identity)) return false;
|
|
if (!capabilityProxy.informModuleToken(hostAnchor, identity, credential)) return false;
|
|
return TokenManager::adoptCredentialFor(identity, credential);
|
|
}
|
|
|
|
} // namespace
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// NO ANCHOR
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
// The one-line statement of the bug. A private store must not come into
|
|
// existence holding a value the host holds.
|
|
//
|
|
// RED ON (M1): both EXPECT_EQ fail with the host anchor, and
|
|
// identitiesSharingHostAnchor() lists the identity.
|
|
TEST(ConsumerCredential, AnIsolatedIdentityHoldsNoValueOfTheHosts)
|
|
{
|
|
ensureCredApp();
|
|
const QString anchor = seedHostAnchor("cc-host-anchor-born-empty");
|
|
|
|
const QString identity = cid("born_empty");
|
|
ASSERT_TRUE(TokenManager::isolateIdentity(identity));
|
|
TokenManager& store = TokenManager::forIdentity(identity);
|
|
ASSERT_NE(&store, &TokenManager::instance());
|
|
|
|
EXPECT_TRUE(store.getToken(QStringLiteral("core")).isEmpty());
|
|
EXPECT_TRUE(store.getToken(QStringLiteral("capability_module")).isEmpty());
|
|
EXPECT_EQ(store.tokenCount(), 0);
|
|
|
|
// The control that makes the assertion above mean something: the anchor IS
|
|
// reachable, it is sitting in instance() under both keys, and the private
|
|
// store simply did not take it.
|
|
ASSERT_EQ(TokenManager::instance().getToken(QStringLiteral("capability_module")), anchor);
|
|
|
|
EXPECT_FALSE(TokenManager::identitiesSharingHostAnchor().contains(identity));
|
|
}
|
|
|
|
// The diagnostic a host's CI asserts on. It has to be able to SEE the bad state,
|
|
// or "it is empty" is not evidence of anything.
|
|
TEST(ConsumerCredential, TheHostAnchorDiagnosticSeesAHandCopiedAnchor)
|
|
{
|
|
ensureCredApp();
|
|
const QString anchor = seedHostAnchor("cc-host-anchor-diagnostic");
|
|
|
|
const QString clean = cid("diag_clean");
|
|
ASSERT_TRUE(TokenManager::isolateIdentity(clean));
|
|
ASSERT_TRUE(TokenManager::adoptCredentialFor(clean, QStringLiteral("cc-own-cred-clean")));
|
|
EXPECT_FALSE(TokenManager::identitiesSharingHostAnchor().contains(clean));
|
|
|
|
// A host that copies the anchor by hand — the unsanctioned route the
|
|
// library can no longer take for it — is still visible here.
|
|
const QString dirty = cid("diag_dirty");
|
|
ASSERT_TRUE(TokenManager::isolateIdentity(dirty));
|
|
TokenManager::forIdentity(dirty).saveToken(QStringLiteral("capability_module"), anchor);
|
|
EXPECT_TRUE(TokenManager::identitiesSharingHostAnchor().contains(dirty));
|
|
}
|
|
|
|
// The sanctioned API must not be a one-line way back to the bug.
|
|
TEST(ConsumerCredential, AdoptingTheHostsOwnAnchorIsRefused)
|
|
{
|
|
ensureCredApp();
|
|
const QString anchor = seedHostAnchor("cc-host-anchor-refused");
|
|
|
|
const QString identity = cid("refuse_anchor");
|
|
ASSERT_TRUE(TokenManager::isolateIdentity(identity));
|
|
|
|
EXPECT_FALSE(TokenManager::adoptCredentialFor(identity, anchor));
|
|
EXPECT_EQ(TokenManager::forIdentity(identity).tokenCount(), 0)
|
|
<< "a refusal must write nothing at all";
|
|
|
|
// ... and the identity is still adoptable with a credential of its own.
|
|
EXPECT_TRUE(TokenManager::adoptCredentialFor(identity, QStringLiteral("cc-own-cred")));
|
|
}
|
|
|
|
// THE NAMING HALF, at the layer that decides it. A consumer presenting its own
|
|
// credential is a MODULE named after itself, not the host.
|
|
//
|
|
// RED ON (M1): {"kind":"host"} — the isolated view wearing basecamp's authority.
|
|
TEST(ConsumerCredential, AnAdmittedConsumerIsNamedAsItselfAndNotAsTheHost)
|
|
{
|
|
ensureCredApp();
|
|
const QString anchor = seedHostAnchor("cc-host-anchor-naming");
|
|
|
|
// capability_module's own proxy, authorizing against the HOST store, which
|
|
// is where the host anchor lives. This is the real topology: capability is
|
|
// a legacy in-process plugin on the host's identity.
|
|
ProbeProvider capability(QStringLiteral("capability_module"), &TokenManager::instance());
|
|
ModuleProxy capProxy(&capability, nullptr, &TokenManager::instance());
|
|
|
|
const QString identity = cid("named_view");
|
|
const QString credential = QStringLiteral("cc-cred-named-view");
|
|
ASSERT_TRUE(admit(identity, credential, capProxy, anchor));
|
|
|
|
// The consumer presents whatever ITS OWN store says it presents to
|
|
// capability_module — read here rather than passed in, because "what the
|
|
// store hands the client" is exactly the thing under test.
|
|
const QString presented =
|
|
TokenManager::forIdentity(identity).getToken(QStringLiteral("capability_module"));
|
|
EXPECT_EQ(presented, credential);
|
|
|
|
ASSERT_TRUE(dispatched(capProxy.callRemoteMethod(presented, QStringLiteral("work"), {})));
|
|
EXPECT_EQ(capability.seen, moduleDoc(identity));
|
|
EXPECT_NE(capability.seen, kHostDoc);
|
|
|
|
// The control, in the same test, so a green run cannot be a green run of
|
|
// nothing: the host itself still authorizes AND still reads as the host.
|
|
ASSERT_TRUE(dispatched(capProxy.callRemoteMethod(anchor, QStringLiteral("work"), {})));
|
|
EXPECT_EQ(capability.seen, kHostDoc);
|
|
}
|
|
|
|
// An identity nobody credentialed must be able to do NOTHING. On the copying
|
|
// build it can do everything the host can, which is the escalation stated as an
|
|
// outcome rather than as a store contents.
|
|
//
|
|
// RED ON (M1): the call authorizes.
|
|
TEST(ConsumerCredential, AnIsolatedIdentityWithNoCredentialIsRefusedEverywhere)
|
|
{
|
|
ensureCredApp();
|
|
const QString anchor = seedHostAnchor("cc-host-anchor-uncredentialed");
|
|
|
|
ProbeProvider capability(QStringLiteral("capability_module"), &TokenManager::instance());
|
|
ModuleProxy capProxy(&capability, nullptr, &TokenManager::instance());
|
|
|
|
const QString identity = cid("uncredentialed");
|
|
ASSERT_TRUE(TokenManager::isolateIdentity(identity));
|
|
TokenManager& store = TokenManager::forIdentity(identity);
|
|
|
|
// Whatever the store hands it for capability_module is what it can present.
|
|
const QString presented = store.getToken(QStringLiteral("capability_module"));
|
|
EXPECT_TRUE(presented.isEmpty());
|
|
EXPECT_TRUE(logos::isUnauthorizedSentinel(
|
|
capProxy.callRemoteMethod(presented, QStringLiteral("work"), {})));
|
|
EXPECT_EQ(capability.calls, 0);
|
|
|
|
// The control: the host's own anchor still works, so this test is about the
|
|
// identity and not about the proxy being broken.
|
|
ASSERT_TRUE(dispatched(capProxy.callRemoteMethod(anchor, QStringLiteral("work"), {})));
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// NO LOCKOUT
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
// THE WHOLE HANDSHAKE, end to end, in one process: the consumer authenticates
|
|
// requestModule at capability_module with its own credential, capability pushes
|
|
// the minted token to the target, and the consumer reaches the target with it.
|
|
// Every hop is a real ModuleProxy::authorize.
|
|
//
|
|
// RED ON (M2): the first callRemoteMethod returns the unauthorized sentinel —
|
|
// the consumer has no credential to present, so nothing downstream happens.
|
|
TEST(ConsumerCredential, AnAdmittedConsumerCompletesTheHandshakeAndReachesAnOrdinaryModule)
|
|
{
|
|
ensureCredApp();
|
|
const QString anchor = seedHostAnchor("cc-host-anchor-handshake");
|
|
|
|
ProbeProvider capability(QStringLiteral("capability_module"), &TokenManager::instance());
|
|
ModuleProxy capProxy(&capability, nullptr, &TokenManager::instance());
|
|
|
|
// An ordinary target module, on its own identity and its own store, exactly
|
|
// as an out-of-process module's own image would be.
|
|
const QString targetName = cid("ordinary_target");
|
|
ASSERT_TRUE(TokenManager::isolateIdentity(targetName));
|
|
TokenManager& targetStore = TokenManager::forIdentity(targetName);
|
|
const QString targetCredential = QStringLiteral("cc-cred-ordinary-target");
|
|
ASSERT_TRUE(TokenManager::adoptCredentialFor(targetName, targetCredential));
|
|
ProbeProvider target(targetName, &targetStore);
|
|
ModuleProxy targetProxy(&target, nullptr, &targetStore);
|
|
|
|
const QString identity = cid("handshake_view");
|
|
const QString credential = QStringLiteral("cc-cred-handshake-view");
|
|
ASSERT_TRUE(admit(identity, credential, capProxy, anchor));
|
|
|
|
// 1. The consumer authenticates requestModule with its own credential.
|
|
const QString presented =
|
|
TokenManager::forIdentity(identity).getToken(QStringLiteral("capability_module"));
|
|
ASSERT_FALSE(presented.isEmpty()) << "the consumer has nothing to present";
|
|
ASSERT_TRUE(dispatched(capProxy.callRemoteMethod(
|
|
presented, QStringLiteral("requestModule"), QVariantList{identity, targetName})))
|
|
<< "requestModule refused: an admitted consumer is locked out";
|
|
|
|
// 2. capability_module mints a per-(caller,target) token and pushes it to
|
|
// the target over the trusted channel, authenticating with the token the
|
|
// target itself accepts — getToken(targetName) in capability's store,
|
|
// which informModuleToken wrote when the target was admitted.
|
|
const QString minted = QStringLiteral("cc-minted-view-to-target");
|
|
ASSERT_TRUE(targetProxy.informModuleToken(targetCredential, identity, minted));
|
|
|
|
// 3. The consumer reaches the target with it, and is NAMED there.
|
|
ASSERT_TRUE(dispatched(targetProxy.callRemoteMethod(minted, QStringLiteral("work"), {})));
|
|
EXPECT_EQ(target.seen, moduleDoc(identity));
|
|
}
|
|
|
|
// The inbound direction of the same rule. An isolated PROVIDER identity has to
|
|
// be tellable about its own callers, and the token capability_module presents
|
|
// when it pushes is `tokenManager->getToken(moduleName)` — that identity's own
|
|
// credential. This is the case the bootstrap copy was really protecting, and it
|
|
// survives the copy's removal because the credential is in the store.
|
|
//
|
|
// RED ON (M2): informModuleToken is refused — the store holds nothing for the
|
|
// trusted-channel gate to match.
|
|
TEST(ConsumerCredential, AnAdmittedProviderIdentityStillAcceptsAPushFromCapability)
|
|
{
|
|
ensureCredApp();
|
|
seedHostAnchor("cc-host-anchor-inbound");
|
|
|
|
const QString identity = cid("inbound_provider");
|
|
const QString credential = QStringLiteral("cc-cred-inbound-provider");
|
|
ASSERT_TRUE(TokenManager::isolateIdentity(identity));
|
|
TokenManager& store = TokenManager::forIdentity(identity);
|
|
ASSERT_TRUE(TokenManager::adoptCredentialFor(identity, credential));
|
|
|
|
ProbeProvider provider(identity, &store);
|
|
ModuleProxy proxy(&provider, nullptr, &store);
|
|
|
|
const QString granted = QStringLiteral("cc-granted-inbound");
|
|
ASSERT_TRUE(proxy.informModuleToken(credential, cid("some_caller"), granted))
|
|
<< "the provider identity cannot be told about any caller: it is deaf";
|
|
EXPECT_TRUE(dispatched(proxy.callRemoteMethod(granted, QStringLiteral("work"), {})));
|
|
EXPECT_EQ(provider.seen, moduleDoc(cid("some_caller")));
|
|
|
|
// The host's anchor is NOT a key to this door any more, which is the other
|
|
// half of "no anchor": an isolated provider trusts its own credential and
|
|
// nothing else.
|
|
EXPECT_FALSE(proxy.informModuleToken(
|
|
TokenManager::instance().getToken(QStringLiteral("capability_module")),
|
|
cid("impostor"), QStringLiteral("cc-impostor-token")));
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Rotation — the ordering hazard the fix introduces, handled rather than left
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
// A reload re-mints and re-registers, and ModuleProxy::saveToken overwrites
|
|
// m_tokens[name], so the PREVIOUS credential stops working at the target the
|
|
// moment the new one is registered. Under the old copying scheme that never
|
|
// mattered — the store presented a never-rotating anchor. Now it does: a store
|
|
// left holding the stale credential is a locked-out reload that looks live.
|
|
// resetIdentity therefore clears the credential too, and the caller adopts the
|
|
// new one.
|
|
TEST(ConsumerCredential, AReloadRotatesTheCredentialAndTheOldOneStopsWorking)
|
|
{
|
|
ensureCredApp();
|
|
const QString anchor = seedHostAnchor("cc-host-anchor-reload");
|
|
|
|
ProbeProvider capability(QStringLiteral("capability_module"), &TokenManager::instance());
|
|
ModuleProxy capProxy(&capability, nullptr, &TokenManager::instance());
|
|
|
|
const QString identity = cid("reloaded_view");
|
|
const QString first = QStringLiteral("cc-cred-reload-first");
|
|
ASSERT_TRUE(admit(identity, first, capProxy, anchor));
|
|
ASSERT_TRUE(dispatched(capProxy.callRemoteMethod(first, QStringLiteral("work"), {})));
|
|
|
|
// A per-target token minted for the PREVIOUS incarnation.
|
|
TokenManager& store = TokenManager::forIdentity(identity);
|
|
store.saveToken(cid("some_target"), QStringLiteral("cc-stale-per-target"));
|
|
|
|
// The reload: mint, register, reset, adopt.
|
|
const QString second = QStringLiteral("cc-cred-reload-second");
|
|
ASSERT_TRUE(capProxy.informModuleToken(anchor, identity, second));
|
|
ASSERT_TRUE(TokenManager::resetIdentity(identity));
|
|
EXPECT_EQ(store.tokenCount(), 0) << "reset must drop the credential too";
|
|
ASSERT_TRUE(TokenManager::adoptCredentialFor(identity, second));
|
|
|
|
EXPECT_EQ(store.getToken(QStringLiteral("capability_module")), second);
|
|
EXPECT_TRUE(store.getToken(cid("some_target")).isEmpty())
|
|
<< "a token minted for the previous incarnation must not survive";
|
|
|
|
// The store object is the same one a client mid-flight holds by raw pointer.
|
|
EXPECT_EQ(&TokenManager::forIdentity(identity), &store);
|
|
|
|
ASSERT_TRUE(dispatched(capProxy.callRemoteMethod(second, QStringLiteral("work"), {})));
|
|
EXPECT_TRUE(logos::isUnauthorizedSentinel(
|
|
capProxy.callRemoteMethod(first, QStringLiteral("work"), {})))
|
|
<< "the superseded credential must stop working at the target";
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// The host that has NOT adopted the mechanism
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
// The compatibility claim, tested rather than asserted in a comment: a host that
|
|
// never isolates anything is completely unaffected. forIdentity() returns
|
|
// instance() pointer-identically, the ambient ring still holds every token the
|
|
// host wrote, and calls authorize exactly as they did.
|
|
TEST(ConsumerCredential, AHostThatNeverIsolatesSeesNoChangeAtAll)
|
|
{
|
|
ensureCredApp();
|
|
const QString anchor = seedHostAnchor("cc-host-anchor-untouched");
|
|
TokenManager::instance().saveToken(cid("legacy_module"), QStringLiteral("cc-legacy-root"));
|
|
|
|
EXPECT_EQ(&TokenManager::forIdentity(cid("never_isolated_a")), &TokenManager::instance());
|
|
EXPECT_EQ(&TokenManager::forIdentity(cid("never_isolated_b")), &TokenManager::instance());
|
|
|
|
ProbeProvider provider(cid("legacy_module"), &TokenManager::instance());
|
|
ModuleProxy proxy(&provider, nullptr, &TokenManager::instance());
|
|
|
|
EXPECT_TRUE(dispatched(proxy.callRemoteMethod(anchor, QStringLiteral("work"), {})));
|
|
EXPECT_EQ(provider.seen, kHostDoc);
|
|
EXPECT_EQ(TokenManager::instance().getToken(cid("legacy_module")),
|
|
QStringLiteral("cc-legacy-root"));
|
|
}
|