mirror of
https://github.com/logos-co/logos-protocol.git
synced 2026-09-01 06:21:06 +00:00
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"));
|
||
|
|
}
|