feat(tokens): a private store is created EMPTY, not seeded with the host anchor

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>
This commit is contained in:
Dario Gabriel Lipicar
2026-08-23 00:47:28 -03:00
co-authored by Claude Opus 5
parent 6c24fcb132
commit c698402c06
14 changed files with 1033 additions and 159 deletions
+8 -3
View File
@@ -48,9 +48,14 @@ merely labelling the caller, and `isolateIdentity(origin)` is how a host opts a
name in (`lp_token_isolate_identity` and friends from C). Both are additive and
inert by default: until a name is isolated, `forIdentity()` returns the *same
object* `instance()` returns, so a host that knows nothing about this is
unchanged. A private store is seeded with the trust-root bootstrap (`core`,
`capability_module`) so first-call `requestModule` still works, and with nothing
else.
unchanged. A private store is created **empty** — it does *not* inherit this
image's `core` / `capability_module` tokens, which are the *host's* credential
and would let the identity authorize as the host. The host mints a credential
for the identity, registers it with `capability_module`, and installs it under
the bootstrap keys with `TokenManager::adoptCredentialFor` /
`lp_token_adopt_credential`, which is what makes first-call `requestModule` work
— as that identity rather than as the host. `logos::admitConsumer`
(logos-plugin-qt) is the one place that performs those three steps in order.
This is a second axis, not a replacement for the per-**image** split: a module
cdylib links its own copy of this library and therefore has its own
+25
View File
@@ -162,6 +162,31 @@ QString LogosAPIClient::mintAndCacheToken(const QString& objectName, Timeout tim
{
qDebug() << "LogosAPIClient: calling requestModule for" << objectName;
const QString capabilityToken = getToken(QStringLiteral("capability_module"));
// A NAMED DIAGNOSTIC for the one way this whole path fails silently.
//
// A private token store is created empty; the host is what puts the
// identity's own credential in it. A host that isolates an identity and
// never adopts a credential for it produces an empty capability token here,
// which ModuleProxy::authorize refuses at its empty-token check — so
// requestModule returns "", the real call goes out with no token, the one
// re-exchange fails identically, and the caller sees an empty QVariant. That
// reads as "the target returned nothing", which is the wrong bug to chase.
//
// Warned once per client rather than per call: the failure repeats on every
// call and the message is about the host's wiring, not about this call.
// Only for an ISOLATED store — the ambient ring legitimately starts without
// a capability token in plenty of tests and single-module processes.
if (capabilityToken.isEmpty() && m_token_manager
&& m_token_manager != &TokenManager::instance() && !m_warnedNoCredential) {
m_warnedNoCredential = true;
qWarning() << "LogosAPIClient: identity" << m_origin_module
<< "has an isolated token store with no credential, so its"
" requestModule handshake for" << objectName
<< "will be refused. The host must admit this identity"
" (logos::admitConsumer / TokenManager::adoptCredentialFor)"
" before it can call anything.";
}
const QString token = QString::fromStdString(
m_capability_consumer->requestModule(capabilityToken.toStdString(),
m_origin_module.toStdString(),
+6
View File
@@ -410,6 +410,12 @@ private:
// owner thread (invokeRemoteMethodAsync marshals there), so it needs no
// lock. Appended last per the ABI note above; defaults to empty.
QMap<QString, std::vector<std::function<void(const QString&)>>> m_pendingHandshakes;
// One-shot latch for the "isolated store with no credential" warning in
// mintAndCacheToken(). The condition repeats on every call and the message
// is about the HOST's wiring, so it is worth saying once and not N times.
// Appended last per the ABI note above; defaults to false.
bool m_warnedNoCredential = false;
};
#endif // LOGOS_API_CLIENT_H
+9 -2
View File
@@ -113,8 +113,15 @@ std::string callerUnknownJson();
*
* CARRIES NO NAME, and must not gain one. "core" and "capability_module" hold
* the same token VALUE under two keys by construction (TokenManager::
* bootstrapKeys() is seeded from one host secret), so a name on this arm would
* be a coin flip presented as a fact. */
* adoptCredential writes ONE credential under every bootstrapKeys() key), so a
* name on this arm would be a coin flip presented as a fact.
*
* AND IT IS THE HOST'S OWN CREDENTIAL, not any identity's. An isolated identity
* carries its own credential under those keys, which the callee's proxy finds
* in its caller-keyed inbound record and answers with the module arm below.
* Until logos-protocol 0.7 a private store was born holding a COPY of the
* host's, so an in-process view resolved HERE — the host arm — which is the
* elevation adoptCredentialFor exists to close. */
std::string callerHostAnchorJson();
/* {"kind":"module","name":"<name>"} — a named module, token-bound.
+14
View File
@@ -648,6 +648,20 @@ int lp_token_reset_identity(const char* identity)
: LP_ERR_UNSUPPORTED;
}
int lp_token_adopt_credential(const char* identity, const char* credential)
{
if (!identity || !*identity || !credential || !*credential)
return LP_ERR_INVALID_ARG;
// LP_ERR_UNSUPPORTED covers both refusals TokenManager makes — a
// non-isolated identity and a credential equal to this image's host anchor
// — because both mean the same thing to a caller: nothing was written and
// retrying with the same arguments will not change that.
return TokenManager::adoptCredentialFor(QString::fromUtf8(identity),
QString::fromUtf8(credential))
? LP_OK
: LP_ERR_UNSUPPORTED;
}
int lp_inform_module_token(lp_client* client,
const char* auth_token,
const char* module_name,
+56 -10
View File
@@ -141,9 +141,28 @@
// the provider/host ABI is UNCHANGED, so same-MAJOR hosts (incl. 0.1 daemons)
// load and forward multi modules without modification. A pre-0.2 *consumer*
// would see the raw sentinel rather than awaiting it — graceful, not a crash.
#define LOGOS_PROTOCOL_VERSION_MINOR 6
// 0.7: an isolated identity's OWN credential — lp_token_adopt_credential(),
// and the behaviour change that makes it necessary: a private token store is
// created EMPTY instead of inheriting this image's "core"/"capability_module"
// tokens. That inheritance handed every isolated identity the HOST's credential,
// which authorized as the host at any callee (the caller document came back
// {"kind":"host"}) and satisfied ModuleProxy::informModuleToken's
// trusted-channel gate — a write into another module's token map, reachable
// with three public calls and no generated glue.
//
// ADDITIVE AT THE ABI, BREAKING FOR ISOLATED IDENTITIES, and the two halves have
// to be said separately. No symbol changes signature, no module-impl export is
// added, and a host that never calls lp_token_isolate_identity /
// TokenManager::isolateIdentity is bit-for-bit unaffected: forIdentity() still
// returns instance() pointer-identically for every un-isolated name. A host that
// DOES isolate and does not adopt is broken loudly and immediately — its first
// outbound call dies at ModuleProxy::authorize's empty-token check with
// "auth token not recognized" — which is the intended failure mode for a change
// that removes a credential nobody was entitled to. Ship protocol, then
// logos-plugin-qt, then the hosts, with matching flake.locks.
#define LOGOS_PROTOCOL_VERSION_MINOR 7
#define LOGOS_PROTOCOL_VERSION_PATCH 0
#define LOGOS_PROTOCOL_VERSION_STRING "0.6.0"
#define LOGOS_PROTOCOL_VERSION_STRING "0.7.0"
/* ---------------------------------------------------------------------------
* Export marking.
@@ -410,9 +429,12 @@ LP_API int lp_token_save(const char* module_name, const char* token);
* and every lp_client_create behaves exactly as before, on the same store.
* ------------------------------------------------------------------------- */
/** Give `identity` a private token store, seeded with the trust-root bootstrap
* ("core" and "capability_module", copied from this image's store) so its first
* call can still run the `requestModule` handshake.
/** Give `identity` a private token store. The store is created EMPTY — it does
* NOT inherit this image's "core" / "capability_module" tokens, which are the
* HOST's credential and would let the identity authorize as the host. The host
* must give the identity its OWN credential with lp_token_adopt_credential
* before it can call anything; until then every call it makes is refused.
*
*
* Idempotent. Returns LP_ERR_UNSUPPORTED — changing nothing — if a client for
* this identity was already created against the shared store; isolating then
@@ -432,7 +454,10 @@ LP_API int lp_token_identity_is_isolated(const char* identity);
LP_API char* lp_token_get_for(const char* identity, const char* module_name);
/** Store a token in `identity`'s store — how a host seeds an isolated identity
* with the tokens it is actually entitled to. Writes this image's shared store
* with the tokens it is actually entitled to. For the identity's OWN
* credential, use lp_token_adopt_credential instead: it owns the bootstrap key
* set, so a binding never has to spell "core"/"capability_module" itself.
* Writes this image's shared store
* for an identity that has not been isolated, which is almost certainly not
* what the caller meant: the token becomes visible to every other non-isolated
* caller, and lp_token_isolate_identity then refuses the name rather than
@@ -440,12 +465,33 @@ LP_API char* lp_token_get_for(const char* identity, const char* module_name);
LP_API int lp_token_save_for(const char* identity, const char* module_name,
const char* token);
/** Clear an isolated identity's store and re-seed the bootstrap — the unload
* hook, so a reloaded module does not present tokens minted for its previous
* incarnation. Returns LP_ERR_UNSUPPORTED for a non-isolated identity, whose
* store is shared and must not be cleared out from under everyone else. */
/** Clear an isolated identity's store — the unload hook, so a reloaded module
* does not present tokens minted for its previous incarnation. The identity's
* CREDENTIAL goes with it: a reload re-mints and re-registers, which
* invalidates the old credential at the target, so the caller must follow this
* with lp_token_adopt_credential for the new one. Returns LP_ERR_UNSUPPORTED
* for a non-isolated identity, whose store is shared and must not be cleared
* out from under everyone else. */
LP_API int lp_token_reset_identity(const char* identity);
/** Install `credential` as `identity`'s OWN credential in its private store:
* its value under every bootstrap key ("core", "capability_module"). This is
* what makes an isolated identity able to speak at all — it is the token
* presented to `capability_module.requestModule`, and the token
* capability_module pushes back with.
*
* The host mints `credential`, registers it with capability_module
* (lp_inform_module_token / informModuleToken over the trusted channel) and
* only THEN calls this. Register-before-adopt, so at no instant does the
* identity hold a credential capability_module has not yet accepted.
*
* Returns LP_ERR_INVALID_ARG for a NULL/empty argument, and LP_ERR_UNSUPPORTED
* — writing nothing — when `identity` is not isolated (the store would be the
* shared one, handing the credential to every un-isolated caller) or when
* `credential` is this image's own host anchor (adopting the host's credential
* as your own is the elevation this whole surface exists to prevent). */
LP_API int lp_token_adopt_credential(const char* identity, const char* credential);
/** The module names this image's token store holds, as a JSON array. Caller
* frees via lp_string_free.
*
+19 -6
View File
@@ -265,12 +265,25 @@ bool ModuleProxy::informModuleToken(const QString& authToken, const QString& mod
// store isAuthorized scans, so the proxy has exactly one notion of who it
// trusts. Identical objects until a host isolates the provider's identity.
//
// A HOST THAT PASSES AN ISOLATED STORE MUST SEED THE ANCHOR INTO IT.
// logos-plugin-qt's LogosAPIProvider::seedHandshakeTrustAnchor writes "core"
// and "capability_module" into TokenManager::instance() by name; against an
// isolated store that seeding would be invisible here and every token push
// would be refused during the handshake window. Moving that write to the
// same store is part of wiring this parameter up, not a separate cleanup.
// A HOST THAT PASSES AN ISOLATED STORE MUST INSTALL THAT IDENTITY'S OWN
// CREDENTIAL IN IT (TokenManager::adoptCredentialFor / lp_token_adopt_
// credential), because a private store is now created EMPTY. It must NOT be
// the host's anchor: capability_module pushes to a provider identity using
// getToken(moduleName), which is that identity's own credential, so the gate
// below still passes on the identity's own value and no longer requires a
// copy of the host's. logos-plugin-qt's LogosAPIProvider::
// seedHandshakeTrustAnchor does exactly this for a module IMAGE, writing the
// host-issued `authToken` property under both keys; logos::admitConsumer
// does it for an in-process consumer.
//
// ONE GAP SURVIVES, and it is here rather than in either of those:
// seedHandshakeTrustAnchor still writes to TokenManager::instance() BY NAME
// (logos-plugin-qt cpp/logos_api_provider.cpp:185-190), and is the last
// site that spells these two key strings itself. Against an isolated store
// it therefore seeds the wrong object — invisibly, because the write
// succeeds and only the read comes up empty. It is unreached today: that
// function runs in a module IMAGE, whose ring is the process ring. It stops
// being unreached the moment a provider identity is isolated in-process.
const QString coreToken = m_store->getToken(QStringLiteral("core"));
const QString capToken = m_store->getToken(QStringLiteral("capability_module"));
const bool callerIsTrusted =
+95 -33
View File
@@ -38,11 +38,33 @@ StoreRegistry& registry()
QStringList TokenManager::bootstrapKeys()
{
// "capability_module" authenticates the requestModule handshake itself;
// "core" is the host-side channel ModuleProxy::informModuleToken accepts as
// trusted. Both are pre-seeded by every host before any module loads.
// "core" is the channel ModuleProxy::informModuleToken accepts as trusted.
// These are the keys an identity's OWN credential is installed under by
// adoptCredential(); they are no longer copied from anywhere.
return QStringList{QStringLiteral("core"), QStringLiteral("capability_module")};
}
namespace {
// Is `value` the HOST's anchor — the value instance() holds under a bootstrap
// key? Used to refuse an adoption that would re-create the copy this change
// removes, and to report identities that acquired it some other way.
//
// A plain == rather than a constant-time compare, deliberately: this is a
// host-side administrative check between two values the HOST already holds, not
// an authorization decision on an attacker-supplied token. The constant-time
// path is ModuleProxy::authorize and stays there.
bool isHostAnchorValue(const QString& value)
{
if (value.isEmpty()) return false;
for (const QString& key : TokenManager::bootstrapKeys()) {
if (TokenManager::instance().getToken(key) == value) return true;
}
return false;
}
} // namespace
TokenManager& TokenManager::forIdentity(const QString& identity)
{
QMutexLocker locker(&registry().mutex);
@@ -56,20 +78,26 @@ TokenManager& TokenManager::forIdentity(const QString& identity)
auto it = registry().stores.find(identity);
if (it == registry().stores.end()) {
// Seed BEFORE publishing into the hash, so no caller can ever observe a
// private store that has not been bootstrapped yet — and so the only
// saveToken() issued under the registry lock is on an object nobody else
// holds a handle to. That second part matters: saveToken emits
// tokenSaved, and an emit under a lock is a re-entrancy hazard in
// general. Here it cannot be one, because a brand-new unpublished store
// has no connections for the emit to reach.
TokenManager* store = new TokenManager(nullptr);
for (const QString& key : bootstrapKeys()) {
const QString token = instance().getToken(key);
if (!token.isEmpty())
store->saveToken(key, token);
}
it = registry().stores.insert(identity, store);
// BORN EMPTY, and that is the whole of the fix on this side.
//
// This used to copy instance()'s tokens for bootstrapKeys() into the
// new store. Those values are the HOST's, so every isolated identity
// came into existence holding the host's anchor: it authorized as the
// host at any callee (ModuleProxy::authorize -> callerHostAnchorJson)
// and satisfied informModuleToken's trusted-channel gate, which is a
// write into another module's token map. Isolating a name is the host
// declaring that this caller must NOT have the host's authority; the
// seed handed it exactly that.
//
// An empty store is a store that cannot call anything until the host
// gives the identity its OWN credential — see adoptCredentialFor(), and
// logos::admitConsumer in logos-plugin-qt, which mints it, registers it
// with capability_module and adopts it in that order.
//
// No saveToken() under the registry lock any more, which also retires
// the re-entrancy note that used to be here: saveToken emits
// tokenSaved, and an emit under a lock is a hazard in general.
it = registry().stores.insert(identity, new TokenManager(nullptr));
}
return *it.value();
}
@@ -99,29 +127,60 @@ QStringList TokenManager::isolatedIdentities()
return out;
}
int TokenManager::seedBootstrapTokens(const QString& identity)
void TokenManager::adoptCredential(const QString& credential)
{
// Empty is a no-op: an empty value under a bootstrap key reads as PRESENT
// to hasToken() and authorizes nothing, which is the worst of both.
if (credential.isEmpty()) return;
for (const QString& key : bootstrapKeys())
saveToken(key, credential);
}
bool TokenManager::adoptCredentialFor(const QString& identity, const QString& credential)
{
if (identity.isEmpty() || credential.isEmpty()) return false;
// Check isolation BEFORE calling forIdentity: for a name that is not
// isolated, forIdentity would record a shared vend and thereby make future
// isolation of that name impossible. Seeding is a no-op there anyway, and a
// no-op must not have that side effect.
if (!isIsolated(identity)) return 0;
// isolation of that name impossible. A refusal must not have that side
// effect (same rule the old seedBootstrapTokens followed).
if (!isIsolated(identity)) return false;
// Refusing the host's own anchor is the sanctioned-API half of closing the
// elevation. Without it, `adoptCredentialFor(x, hostAnchor)` would be a
// one-line way to reintroduce exactly what forIdentity stopped doing.
if (isHostAnchorValue(credential)) return false;
// Resolving the store creates and seeds it on the first call; the loop then
// tops up any bootstrap key it is still missing, which is the case a host
// that learned a bootstrap token late needs.
TokenManager& store = forIdentity(identity);
if (&store == &instance()) return 0; // belt and braces
if (&store == &instance()) return false; // belt and braces
store.adoptCredential(credential);
return true;
}
int copied = 0;
for (const QString& key : bootstrapKeys()) {
if (store.hasToken(key)) continue;
const QString token = instance().getToken(key);
if (token.isEmpty()) continue;
store.saveToken(key, token);
++copied;
QStringList TokenManager::identitiesSharingHostAnchor()
{
QStringList names;
{
QMutexLocker locker(&registry().mutex);
names = QStringList(registry().isolated.constBegin(),
registry().isolated.constEnd());
}
return copied;
// forIdentity() takes the registry lock itself, so the snapshot above is
// released first rather than recursing on a non-recursive QMutex.
QStringList out;
for (const QString& name : names) {
TokenManager& store = forIdentity(name);
if (&store == &instance()) continue;
for (const QString& key : bootstrapKeys()) {
if (isHostAnchorValue(store.getToken(key))) {
out.append(name);
break;
}
}
}
out.sort();
return out;
}
bool TokenManager::resetIdentity(const QString& identity)
@@ -129,8 +188,11 @@ bool TokenManager::resetIdentity(const QString& identity)
if (!isIsolated(identity)) return false;
TokenManager& store = forIdentity(identity);
if (&store == &instance()) return false; // belt and braces
// No re-seed. The credential is cleared along with everything else, and the
// caller must adopt the NEW one it just minted and registered — a reload
// that re-registers invalidates the previous credential at the target, so
// keeping it here would be a locked-out reload that looks like a live one.
store.clearAllTokens();
seedBootstrapTokens(identity);
return true;
}
+89 -22
View File
@@ -134,43 +134,110 @@ public:
static QStringList isolatedIdentities();
/**
* @brief The keys a private store is seeded with: the trust-root bootstrap.
* @brief The keys an identity's CREDENTIAL is installed under: the
* trust-root bootstrap.
*
* A private store starts empty of everything a caller could escalate with,
* but NOT empty: a module's first call to an unknown target runs
* `capability_module.requestModule`, and that call is itself authenticated
* with the token stored under "capability_module" (and "core" for the
* host-side channel). Withhold those and the very first exchange fails, so
* an isolated identity could never obtain any token at all.
* A private store is born EMPTY, and it must not STAY empty: a caller's
* first call to an unknown target runs `capability_module.requestModule`,
* and that call is itself authenticated with the token stored under
* "capability_module". "core" is the other half — the channel
* ModuleProxy::informModuleToken accepts as trusted, which an isolated
* PROVIDER identity needs in order to be told about its own callers.
* Withhold both and the very first exchange fails at
* ModuleProxy::authorize's empty-token check, so an isolated identity could
* never obtain any token at all: isolation would be a LOCKOUT.
*
* These two keys, and only these two, are copied from instance() into a
* private store when it is created. Every other module's root token — the
* thing that made the ambient ring an escalation — is not.
* WHAT USED TO HAPPEN HERE, AND WHY IT WAS WRONG. These two keys were
* COPIED from instance() when a private store was created. That value is
* the HOST's. A consumer presenting it hits an anchor key at the callee's
* proxy, so ModuleProxy::authorize answers logos::callerHostAnchorJson() —
* a sandboxed in-process view wearing the host's authority. And the half
* that was never latent: informModuleToken's trusted-channel gate compares
* against these same two keys, so any holder of the copy could push
* arbitrary (name, token) pairs into another module's token map with three
* public calls and no generated glue. The credential an identity presents
* must be ITS OWN.
*
* So these are the keys adoptCredential() writes, and the only keys any
* store is bootstrapped with. This function is their single owner: no host,
* binding or language backend should spell the pair a second time.
*/
static QStringList bootstrapKeys();
/**
* @brief Copy the bootstrap tokens from instance() into `identity`'s private
* store, for keys it does not already hold.
* @brief Install `credential` as THIS store's identity credential — its
* value under every bootstrapKeys() key.
*
* Runs automatically when a private store is created, which is the ordering
* a host already satisfies (bootstrap tokens are seeded before any module
* loads). Exposed for the host that learns a bootstrap token later.
* THE RULE, and it is not new: an identity's store carries THAT IDENTITY'S
* OWN host-issued credential under the bootstrap keys. Every other image in
* the system already works this way. A module image writes its own
* `authToken` under both keys (LogosAPIProvider::seedHandshakeTrustAnchor);
* a ui-host process writes the per-spawn credential its parent minted and
* registered for it. The in-process private store was the ONE store in the
* system seeded with somebody else's credential.
*
* @return the number of keys copied; 0 for a non-isolated identity.
* Both directions fall out of the one write:
* * OUTBOUND — the identity presents the credential to
* capability_module, whose proxy finds it in its caller-keyed inbound
* record (written by informModuleToken) rather than on an anchor key,
* so the caller resolves to {"kind":"module","name":<identity>} instead
* of {"kind":"host"}.
* * INBOUND — capability_module pushes to a provider identity using
* `tokenManager->getToken(moduleName)`, which IS this credential, so
* informModuleToken's trusted-channel gate still passes.
*
* An empty credential is a no-op: writing one would leave a value that
* reads as present to hasToken() while authorizing nothing.
*/
static int seedBootstrapTokens(const QString& identity);
void adoptCredential(const QString& credential);
/**
* @brief Clear an isolated identity's private store and re-seed the
* bootstrap, e.g. when the plugin behind it is unloaded.
* @brief adoptCredential() for an isolated identity's private store.
*
* REFUSES — returns false and writes nothing — in two cases, and both
* refusals are the mechanism rather than defensive padding:
*
* * `identity` is NOT isolated. That store is the ambient ring, and
* installing a credential there would hand it to every un-isolated
* caller in the image, the host included.
* * `credential` equals instance()'s value under any bootstrap key, i.e.
* it IS the host's anchor. Adopting the host's anchor as your own is
* exactly the bug this replaces, and it must not be reachable through
* the sanctioned API.
*
* Deliberately does not vend the store for a non-isolated name: a refusal
* must not make later isolation impossible (see isolateIdentity()).
*/
static bool adoptCredentialFor(const QString& identity, const QString& credential);
/**
* @brief Isolated identities whose store holds a bootstrap value equal to
* instance()'s.
*
* Diagnostics, and a one-line assertion for a host's CI: after every
* consumer has been admitted this must be EMPTY. adoptCredentialFor()
* closes the sanctioned route to the host anchor; a host that copies the
* anchor by hand still can, and this is the instrument that sees it.
*/
static QStringList identitiesSharingHostAnchor();
/**
* @brief Clear an isolated identity's private store, e.g. when the plugin
* behind it is unloaded.
*
* The store OBJECT is immortal — a client mid-flight must never dereference
* freed memory — so what has a lifetime is its CONTENTS. After a reload the
* identity would otherwise present per-target tokens minted for its previous
* incarnation; the provider rejects them and the existing rejection-driven
* re-exchange heals it in one retry, so skipping this costs latency and log
* noise rather than correctness. Do it anyway.
* incarnation.
*
* CLEARS THE CREDENTIAL TOO, and that is a deliberate change from the
* version of this that re-seeded the bootstrap from instance(). A reload
* re-mints and re-registers, which invalidates the previous credential at
* the target (ModuleProxy::saveToken overwrites m_tokens[name]); leaving a
* stale credential in the store would therefore be a locked-out reload that
* looks like a working one. The caller must adopt the NEW credential after
* this — see logos::reissueConsumerCredential in logos-plugin-qt, which is
* the one place that sequences mint, register, reset and adopt.
*
* Deliberately a no-op returning false for a NON-isolated identity: that
* store is the shared ring, and clearing it would take every other
+1 -1
View File
@@ -12,7 +12,7 @@ in
pname = "logos-protocol";
inherit isWindows;
# Tracks LOGOS_PROTOCOL_VERSION_STRING in cpp/logos_protocol.h.
version = "0.6.0";
version = "0.7.0";
# Common native build inputs
nativeBuildInputs = [
+5
View File
@@ -364,6 +364,11 @@ add_executable(protocol_tests
# direction-mixed-store case is the one an obvious simplification
# would delete.
test_call_caller.cpp
# An isolated identity's OWN credential: the store is born empty, the host
# mints/registers/adopts, and both halves — no host anchor, and no lockout —
# are asserted against real ModuleProxy authorization. See the file header
# for the two neutered builds that make each half a detector.
test_consumer_credential.cpp
# test_plain_transport_tcp.cpp — entirely #if 0 (see the note inside:
# in-process Qt-event-loop deadlock between consumer + provider under
# nix's test sandbox; exercised cross-process by the integration matrix).
+499
View File
@@ -0,0 +1,499 @@
// 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"));
}
+34 -17
View File
@@ -260,7 +260,7 @@ TEST(InboundTokenStore, AnUntrustedPushGrantsNothing)
TEST(InboundTokenStore, AnIsolatedProviderIdentityStillAuthorizesInboundCalls)
{
ensureInboundApp();
const QString anchor = seedTrustAnchor();
const QString hostAnchor = seedTrustAnchor();
const QString identity = QStringLiteral("inbound_store_isolated_provider");
ASSERT_TRUE(TokenManager::isolateIdentity(identity));
@@ -270,15 +270,30 @@ TEST(InboundTokenStore, AnIsolatedProviderIdentityStillAuthorizesInboundCalls)
RecordingProvider provider(&isolated);
ModuleProxy proxy(&provider, /*parent=*/nullptr, &isolated);
// The trust anchor reaches an isolated store through the bootstrap seed
// ("core" is one of TokenManager::bootstrapKeys()), which is what lets the
// push authorize at all.
ASSERT_EQ(isolated.getToken(QStringLiteral("core")), anchor);
// WHAT LETS THE PUSH AUTHORIZE, and it is no longer the host's anchor.
//
// A private store is created EMPTY. The host installs THIS identity's own
// credential under the bootstrap keys (TokenManager::adoptCredentialFor),
// and capability_module pushes using `tokenManager->getToken(moduleName)`,
// which IS that credential — so the trusted-channel gate matches on the
// identity's own value. The store used to be seeded with a COPY of
// instance()'s anchor, which authorized this push and also let any holder
// of the copy authorize as the host at every other module.
ASSERT_TRUE(isolated.getToken(QStringLiteral("core")).isEmpty())
<< "a private store must not inherit the host's credential";
const QString credential = QStringLiteral("inbound-own-credential");
ASSERT_TRUE(TokenManager::adoptCredentialFor(identity, credential));
ASSERT_EQ(isolated.getToken(QStringLiteral("core")), credential);
ASSERT_NE(credential, hostAnchor);
const QString granted = QStringLiteral("inbound-token-isolated");
ASSERT_TRUE(proxy.informModuleToken(anchor, QStringLiteral("caller_delta"), granted));
ASSERT_TRUE(proxy.informModuleToken(credential, QStringLiteral("caller_delta"), granted));
EXPECT_TRUE(callSucceeds(proxy, granted));
// The other half of the same rule: the HOST's anchor is not a key to this
// door. An isolated provider trusts its own credential and nothing else.
EXPECT_FALSE(proxy.informModuleToken(hostAnchor, QStringLiteral("caller_impostor"),
QStringLiteral("inbound-token-impostor")));
}
// ── 5. the injected store is the one that is scanned ─────────────────────────
@@ -298,8 +313,10 @@ TEST(InboundTokenStore, AnAmbientTokenDoesNotAuthorizeAnIsolatedProxy)
TokenManager& isolated = TokenManager::forIdentity(identity);
ASSERT_NE(&isolated, &TokenManager::instance());
// Planted in the AMBIENT ring only, under a key that is not a bootstrap key
// (a bootstrap key would legitimately be copied into the private store).
// Planted in the AMBIENT ring only. The key is deliberately not a bootstrap
// key: those now hold the identity's OWN credential, which is a different
// value from the host's by construction, so a bootstrap key would prove the
// same thing less directly.
const QString ambientOnly = QStringLiteral("inbound-token-ambient-only");
TokenManager::instance().saveToken(QStringLiteral("some_other_module"), ambientOnly);
ASSERT_TRUE(isolated.getToken(QStringLiteral("some_other_module")).isEmpty());
@@ -365,10 +382,11 @@ TEST(InboundTokenStore, AnEmptyTokenIsRefusedEvenAgainstAnEmptyStoredValue)
// is not this proxy's anchor.
//
// It also states the precondition the logos-plugin-qt wiring has to satisfy.
// LogosAPIProvider::seedHandshakeTrustAnchor writes "core"/"capability_module"
// into TokenManager::instance() by name; hand this proxy an isolated store
// without moving that write and every push during the handshake window is
// refused. That is why the parameter is opt-in and defaulted.
// LogosAPIProvider::seedHandshakeTrustAnchor writes the module image's own
// host-issued authToken under "core"/"capability_module"; an in-process
// consumer gets the equivalent from logos::admitConsumer. Hand this proxy an
// isolated store that nobody credentialed and every push is refused — which is
// exactly what a private store now looks like until the host admits it.
TEST(InboundTokenStore, TheTrustAnchorIsReadFromTheProxysOwnStore)
{
ensureInboundApp();
@@ -379,7 +397,7 @@ TEST(InboundTokenStore, TheTrustAnchorIsReadFromTheProxysOwnStore)
TokenManager& isolated = TokenManager::forIdentity(identity);
ASSERT_NE(&isolated, &TokenManager::instance());
// Overwrite the seeded copy, so the two stores disagree about "core".
// This identity's own credential, so the two stores disagree about "core".
const QString privateAnchor = QStringLiteral("inbound-test-private-anchor");
isolated.saveToken(QStringLiteral("core"), privateAnchor);
ASSERT_NE(isolated.getToken(QStringLiteral("core")),
@@ -449,9 +467,8 @@ TEST(InboundTokenStore, TheComparisonCountDependsOnStoreSizeOnly)
TokenManager& store = TokenManager::forIdentity(identity);
ASSERT_NE(&store, &TokenManager::instance());
// The anchor goes straight into this proxy's own store: a private store
// copies the bootstrap keys when it is CREATED, so seeding instance()
// afterwards would never reach it.
// This identity's own credential, written straight into its store: a
// private store is born empty and inherits nothing from instance().
const QString anchor = QStringLiteral("ct-scan-anchor-%1").arg(n);
store.saveToken(QStringLiteral("core"), anchor);
+173 -65
View File
@@ -38,10 +38,9 @@
// 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:
// That measurement was taken before an identity's OWN credential replaced the
// copied host anchor, so the case NAMES below have moved; the readings have not.
// Re-do the neutering if you change what these assert. The three sharpest:
//
// AnIsolatedIdentityCannotReachTheAmbientRing
// forIdentity("walled").hasToken("target_module") is TRUE on the neutered
@@ -51,6 +50,11 @@
// 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.
// APrivateStoreIsBornEmpty / AdoptingWritesTheCredentialUnderEveryBootstrapKey
// the store-level statement of the SECOND fix: a private store no longer
// inherits the host's "core"/"capability_module" values, and carries the
// identity's own credential instead. Measured separately in
// test_consumer_credential.cpp, which owns that pair of readings.
// 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
@@ -268,95 +272,145 @@ TEST_F(TokenStoreIdentityTest, IsolatedIdentitiesAreListedForDiagnostics)
}
// ─────────────────────────────────────────────────────────────────────────────
// Bootstrap seeding — the first-call path must survive isolation
// The identity's OWN credential — the first-call path must survive isolation
// WITHOUT inheriting the host's authority
// ─────────────────────────────────────────────────────────────────────────────
//
// A caller's first call to an unknown target runs
// `capability_module.requestModule`, and that call authenticates with the token
// stored under "capability_module". Withhold ANY value there and the identity
// can never obtain a token at all — isolation becomes a lockout.
//
// The value used to be COPIED from instance(), i.e. the HOST's credential, which
// made every isolated identity authorize as the host. The value is now the
// identity's OWN credential, minted and registered by the host and installed
// here by adoptCredential(). See test_consumer_credential.cpp for the
// end-to-end statement of both halves; these are the store-level cases.
// 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)
// A private store is BORN EMPTY. Nothing of the host's crosses into it.
TEST_F(TokenStoreIdentityTest, APrivateStoreIsBornEmpty)
{
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"));
ASSERT_TRUE(TokenManager::isolateIdentity(id("born_empty")));
TokenManager& store = TokenManager::forIdentity(id("born_empty"));
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("core").isEmpty());
EXPECT_TRUE(store.getToken("capability_module").isEmpty());
EXPECT_TRUE(store.getToken("unrelated_module").isEmpty());
EXPECT_EQ(store.tokenCount(), 2);
EXPECT_EQ(store.tokenCount(), 0);
// The control: all three are sitting in the ring for the taking, and the
// private store took none of them.
EXPECT_EQ(TokenManager::instance().tokenCount(), 3);
}
// Adoption writes the identity's own credential under EVERY bootstrap key, and
// under nothing else. bootstrapKeys() is the single owner of that key set.
TEST_F(TokenStoreIdentityTest, AdoptingWritesTheCredentialUnderEveryBootstrapKey)
{
TokenManager::instance().saveToken("core", "host-anchor");
TokenManager::instance().saveToken("capability_module", "host-anchor");
ASSERT_TRUE(TokenManager::isolateIdentity(id("adopted")));
ASSERT_TRUE(TokenManager::adoptCredentialFor(id("adopted"), "its-own-credential"));
TokenManager& store = TokenManager::forIdentity(id("adopted"));
EXPECT_EQ(TokenManager::bootstrapKeys().size(), 2);
for (const QString& key : TokenManager::bootstrapKeys())
EXPECT_EQ(store.getToken(key), QStringLiteral("its-own-credential"));
EXPECT_EQ(store.tokenCount(), TokenManager::bootstrapKeys().size());
// ...and it is NOT the host's.
EXPECT_NE(store.getToken("capability_module"),
TokenManager::instance().getToken("capability_module"));
EXPECT_FALSE(TokenManager::identitiesSharingHostAnchor().contains(id("adopted")));
}
TEST_F(TokenStoreIdentityTest, SeedingCopiesOnlyTheKeysThatExistInTheRing)
// The sanctioned API is not a way back to the bug.
TEST_F(TokenStoreIdentityTest, AdoptingTheHostAnchorIsRefusedAndWritesNothing)
{
// 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");
TokenManager::instance().saveToken("core", "the-host-anchor");
TokenManager::instance().saveToken("capability_module", "the-host-anchor");
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);
ASSERT_TRUE(TokenManager::isolateIdentity(id("anchor_refused")));
EXPECT_FALSE(TokenManager::adoptCredentialFor(id("anchor_refused"), "the-host-anchor"));
EXPECT_EQ(TokenManager::forIdentity(id("anchor_refused")).tokenCount(), 0);
// Refused under EITHER bootstrap key's value, since the host may hold two
// different secrets there.
TokenManager::instance().saveToken("core", "core-only-anchor");
EXPECT_FALSE(TokenManager::adoptCredentialFor(id("anchor_refused"), "core-only-anchor"));
EXPECT_EQ(TokenManager::forIdentity(id("anchor_refused")).tokenCount(), 0);
// An empty credential is a no-op rather than a value that reads as present.
EXPECT_FALSE(TokenManager::adoptCredentialFor(id("anchor_refused"), QString()));
EXPECT_EQ(TokenManager::forIdentity(id("anchor_refused")).tokenCount(), 0);
}
// 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)
// A host may adopt at any point after isolation — including long after the store
// object exists, which is the ordering a lazily-built identity produces.
TEST_F(TokenStoreIdentityTest, ACredentialCanBeAdoptedAfterTheStoreExists)
{
ASSERT_TRUE(TokenManager::isolateIdentity(id("late_seed")));
TokenManager& store = TokenManager::forIdentity(id("late_seed"));
ASSERT_TRUE(TokenManager::isolateIdentity(id("late_adopt")));
TokenManager& store = TokenManager::forIdentity(id("late_adopt"));
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"));
EXPECT_TRUE(TokenManager::adoptCredentialFor(id("late_adopt"), "late-credential"));
EXPECT_EQ(store.getToken("capability_module"), QStringLiteral("late-credential"));
// 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"));
// Rotation overwrites rather than accumulating: one credential at a time.
EXPECT_TRUE(TokenManager::adoptCredentialFor(id("late_adopt"), "rotated-credential"));
EXPECT_EQ(store.getToken("capability_module"), QStringLiteral("rotated-credential"));
EXPECT_EQ(store.getToken("core"), QStringLiteral("rotated-credential"));
EXPECT_EQ(store.tokenCount(), 2);
}
// 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)
// A refusal must not have side effects. adoptCredentialFor() on a name nobody
// isolated has to avoid vending the shared store for it, or "adopt then
// isolate" would silently become impossible — and it must never write the
// credential into the ambient ring, which would hand it to every caller.
TEST_F(TokenStoreIdentityTest, AdoptingANonIsolatedIdentityIsRefusedAndDoesNotBlockLaterIsolation)
{
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());
const int before = TokenManager::instance().tokenCount();
EXPECT_FALSE(TokenManager::adoptCredentialFor(id("adopt_then_iso"), "some-credential"));
EXPECT_EQ(TokenManager::instance().tokenCount(), before)
<< "the credential must never land in the ambient ring";
EXPECT_TRUE(TokenManager::instance().getToken("capability_module").isEmpty());
EXPECT_TRUE(TokenManager::isolateIdentity(id("adopt_then_iso")));
EXPECT_NE(&TokenManager::forIdentity(id("adopt_then_iso")), &TokenManager::instance());
}
// ─────────────────────────────────────────────────────────────────────────────
// resetIdentity — the plugin-unload hook
// ─────────────────────────────────────────────────────────────────────────────
TEST_F(TokenStoreIdentityTest, ResetClearsIssuedTokensAndKeepsTheBootstrap)
// The credential goes too, and that is the change from the version of this that
// re-seeded the bootstrap: a reload re-mints and re-registers, so the previous
// credential is dead at the target the moment the new one is registered.
// Leaving it here would be a locked-out reload that looks like a working one.
TEST_F(TokenStoreIdentityTest, ResetClearsTheIssuedTokensAndTheCredentialWithThem)
{
TokenManager::instance().saveToken("capability_module", "cap-tok");
ASSERT_TRUE(TokenManager::isolateIdentity(id("reset_me")));
ASSERT_TRUE(TokenManager::adoptCredentialFor(id("reset_me"), "first-credential"));
TokenManager& store = TokenManager::forIdentity(id("reset_me"));
store.saveToken("target", "minted-for-previous-incarnation");
ASSERT_EQ(store.tokenCount(), 2);
ASSERT_EQ(store.tokenCount(), 3);
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"));
EXPECT_EQ(store.tokenCount(), 0);
// The caller adopts the NEW credential; that is the whole re-admission.
EXPECT_TRUE(TokenManager::adoptCredentialFor(id("reset_me"), "second-credential"));
EXPECT_EQ(store.getToken("capability_module"), QStringLiteral("second-credential"));
}
// Clearing the shared ring would take the host's tokens and every other
@@ -435,6 +489,13 @@ TEST_F(TokenStoreClientTest, EachIsolatedIdentityMintsAndCachesItsOwnToken)
ASSERT_TRUE(TokenManager::isolateIdentity(id("mint_alpha")));
ASSERT_TRUE(TokenManager::isolateIdentity(id("mint_beta")));
// Each identity gets ITS OWN credential — what a host mints per consumer and
// registers with capability_module before handing it over. This used to be a
// copy of the host's anchor, which is what made every isolated caller
// authorize as the host; see test_consumer_credential.cpp.
ASSERT_TRUE(TokenManager::adoptCredentialFor(id("mint_alpha"), "credential-alpha"));
ASSERT_TRUE(TokenManager::adoptCredentialFor(id("mint_beta"), "credential-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.
@@ -451,15 +512,18 @@ TEST_F(TokenStoreClientTest, EachIsolatedIdentityMintsAndCachesItsOwnToken)
LogosAPIClient beta(QStringLiteral("shared_target"), id("mint_beta"),
&TokenManager::forIdentity(id("mint_beta")));
// The bootstrap reached the CLIENT, not just the store: mintAndCacheToken
// The credential 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.
// returns, so an isolated identity with no credential could never obtain any
// token at all. This is the first-call guarantee, checked where it is
// actually consumed — and it is EACH IDENTITY'S OWN value, not one shared
// secret and emphatically not the host's.
EXPECT_EQ(alpha.getToken(QStringLiteral("capability_module")),
QStringLiteral("mock-token-capability_module"));
QStringLiteral("credential-alpha"));
EXPECT_EQ(beta.getToken(QStringLiteral("capability_module")),
QStringLiteral("mock-token-capability_module"));
QStringLiteral("credential-beta"));
EXPECT_NE(alpha.getToken(QStringLiteral("capability_module")),
TokenManager::instance().getToken(QStringLiteral("capability_module")));
EXPECT_EQ(alpha.invokeRemoteMethod(QStringLiteral("shared_target"),
QStringLiteral("ping"), QVariantList{}).toString(),
@@ -552,16 +616,18 @@ TEST_F(TokenStoreAbiTest, PerIdentityGetAndSaveDoNotTouchTheImageStore)
EXPECT_EQ(lp_token_get_for(who.constData(), "never_stored"), nullptr);
}
TEST_F(TokenStoreAbiTest, ResetIdentityClearsTheIssuedTokensOnly)
TEST_F(TokenStoreAbiTest, ResetIdentityClearsTheStoreIncludingTheCredential)
{
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_adopt_credential(who.constData(), "own-credential"), 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");
// The credential goes with it; the caller adopts the newly-registered one.
EXPECT_EQ(lp_token_get_for(who.constData(), "capability_module"), nullptr);
// Refused for a shared store rather than silently clearing everyone's.
EXPECT_EQ(lp_token_reset_identity(id("abi_not_isolated").toUtf8().constData()),
@@ -570,6 +636,45 @@ TEST_F(TokenStoreAbiTest, ResetIdentityClearsTheIssuedTokensOnly)
EXPECT_EQ(lp_token_reset_identity(nullptr), LP_ERR_INVALID_ARG);
}
// The Qt-free half of the fix: a binding installs an identity's credential
// through one call that owns the bootstrap key set, rather than spelling
// "core"/"capability_module" for itself in every language.
TEST_F(TokenStoreAbiTest, AdoptCredentialInstallsTheIdentitysOwnCredential)
{
ASSERT_EQ(lp_token_save("core", "abi-host-anchor"), LP_OK);
ASSERT_EQ(lp_token_save("capability_module", "abi-host-anchor"), LP_OK);
const QByteArray who = id("abi_adopt").toUtf8();
ASSERT_EQ(lp_token_isolate_identity(who.constData()), LP_OK);
// Born empty.
EXPECT_EQ(lp_token_get_for(who.constData(), "capability_module"), nullptr);
EXPECT_EQ(lp_token_adopt_credential(who.constData(), "abi-own-credential"), LP_OK);
EXPECT_EQ(takeString(lp_token_get_for(who.constData(), "capability_module")),
"abi-own-credential");
EXPECT_EQ(takeString(lp_token_get_for(who.constData(), "core")), "abi-own-credential");
// The ambient ring is untouched.
EXPECT_EQ(takeString(lp_token_get("capability_module")), "abi-host-anchor");
// The two refusals, and both write nothing.
EXPECT_EQ(lp_token_adopt_credential(who.constData(), "abi-host-anchor"),
LP_ERR_UNSUPPORTED);
EXPECT_EQ(takeString(lp_token_get_for(who.constData(), "capability_module")),
"abi-own-credential");
const QByteArray plain = id("abi_adopt_plain").toUtf8();
EXPECT_EQ(lp_token_adopt_credential(plain.constData(), "would-be-ambient"),
LP_ERR_UNSUPPORTED);
EXPECT_EQ(takeString(lp_token_get("capability_module")), "abi-host-anchor");
// ...and the refusal did not vend the shared store under that name.
EXPECT_EQ(lp_token_isolate_identity(plain.constData()), LP_OK);
EXPECT_EQ(lp_token_adopt_credential(nullptr, "x"), LP_ERR_INVALID_ARG);
EXPECT_EQ(lp_token_adopt_credential(who.constData(), nullptr), LP_ERR_INVALID_ARG);
EXPECT_EQ(lp_token_adopt_credential("", "x"), LP_ERR_INVALID_ARG);
EXPECT_EQ(lp_token_adopt_credential(who.constData(), ""), LP_ERR_INVALID_ARG);
}
TEST_F(TokenStoreAbiTest, IsolationIsRefusedOnceAClientForThatOriginExists)
{
// lp_client_create resolves the store through the origin, so creating one
@@ -657,10 +762,13 @@ TEST_F(TokenStoreAbiTest, AnLpClientForANonIsolatedOriginKeepsUsingTheImageStore
// 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).
// 4. The bootstrap survives because the HOST gives each isolated identity its
// own credential under "core"/"capability_module"
// (TokenManager::adoptCredentialFor), so the identity's first requestModule
// authenticates — as ITSELF rather than as the host, which is the fix
// test_consumer_credential.cpp states end to end (asserted in the adoption
// cases above, and at the client in
// EachIsolatedIdentityMintsAndCachesItsOwnToken).
class TokenStoreTrustRootTest : public ::testing::Test {
protected: