Dario Gabriel LipicarandClaude Opus 5 e514c53ef5 fix(tokens): separate the INBOUND and OUTBOUND stores — a grant one way was a grant both ways
TokenManager was one flat map with no direction tag, written from both
sides of every relationship: the client stored the token it PRESENTS to a
callee under the CALLEE's name, and a token RECEIVED from a caller was
stored under the CALLER's name. Same namespace, last write wins.

This was documented as safe by topology — separate images keep the two
directions apart — and pending only for the shared-runtime migration. That
was wrong. It is live in the DEFAULT out-of-process configuration, and the
chain needs no attacker:

  1. capability_module mints one token so M may call B.
  2. It pushes it to B  -> B's store gets ["M"] = T   (inbound)
  3. It returns it to M -> M's store gets ["B"] = T   (outbound)
  4. B then calls M. B's client looks in its OWN store for something to
     present to M, finds T because step 2 put it there, and therefore SKIPS
     requestModule entirely.
  5. M authorizes T, having cached the identical value in step 3.

One grant M->B silently produces B->M, with no handshake, nothing logged,
and capability_module's access policy never consulted. Reproduced on the
pre-split tree, both halves red:

    B's outbound lookup for M returned the token M was issued for calling B
       Actual: false  Expected: true
    B authorized at M using the token minted for M -> B
       Actual: true   Expected: false

A second defect from the same cause: an inbound push CLOBBERS the outbound
cache under the same key, so M's next call to B goes out carrying B's own
inbound token, is rejected, and burns the single re-exchange recovering
from a collision it caused itself.

THREE ROLES, TWO MAPS AND A SCALAR:

  m_outbound   callee -> what I present when calling out
  m_inbound    caller -> what I issued to that caller
  m_credential MY OWN host-issued credential

The anchor is what made a two-way split look impossible: it is genuinely
both directions, presented outbound to capability_module and compared
against inbound. But it is not a MAP. It is one value under two role labels
— "core" and "capability_module" — that collide with module names by
accident, and adoptCredential already wrote a single credential under every
bootstrap key. As a scalar it has no key namespace, so no reverse lookup
can produce a name from it. A key living in both maps would have been a
rename, not a split.

The hoped-for cheap route did not exist: logos_module_accept_token forwards
to lp_token_save in BOTH backends, and the Qt glue calls that one export
for an inbound caller token in one place and the module's own anchor in
another. The doors were never divided, so this could not be done by routing
two existing C ABI doors to two maps — but it needs no new door either.

Four detector tests red before, green after. Constant time survives at the
instruction level and the comparison count stays input-independent.

KNOWN GAP, deliberately not fixed here: the generated glue writes the same
inbound token through BOTH doors (lidl_gen_cdylib_glue.cpp:371-380). Safe
only while module cdylibs are separate images — which is exactly what the
shared-runtime migration collapses. Closing it means a dedicated inbound
export on the module-impl C ABI: a MINOR bump plus a definition in both
backends in the same wave. Price it before that migration lands, not after.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 12:07:42 -03:00
2026-06-09 23:33:17 -03:00

logos-protocol

The Logos protocol layer: transports, token exchange, and the language-neutral lp_* C ABI (cpp/logos_protocol.h) that every Logos SDK builds on.

Extracted from logos-cpp-sdk so that non-C++ SDKs (Rust, …) consume the same transports, capability/token flow and wire behavior through one stable, versioned boundary instead of re-wrapping the C++/Qt SDK.

What lives here

  • Public C ABIcpp/logos_protocol.h: consumer surface (lp_client_*, lp_invoke[_async], lp_subscribe, tokens, lp_get_methods), provider groundwork (lp_provider_*), the trust-root surface (lp_grant_host_services and the two functions it gates), per-identity token stores (lp_token_isolate_identity, lp_token_get_for, lp_token_save_for, lp_token_reset_identity, lp_token_identity_is_isolated), and the protocol version (LOGOS_PROTOCOL_VERSION_*, lp_protocol_version(), lp_protocol_abi_major()). JSON-in-strings data model; bytes cross the boundary as {"_bytes":"<base64url>"} (lossless, NUL-safe).
  • Transports — plain TCP / TCP+TLS (Boost.Asio + OpenSSL + nlohmann, Qt-free), qt_local, in-memory mock, and Qt Remote Objects (qt_remote — the only Qt-bearing transport).
  • Consumer coreLogosAPIClient / LogosAPIConsumer including the automatic capability_module.requestModule token-fetch flow (behind the protocol boundary: every language gets it for free).
  • Provider-side plumbingModuleProxy (auth gate the transports publish) and the abstract LogosProviderObject interface (logos_provider_interface.h).
  • Token manager, transport/registry factories, mode config (remote/local/mock), and the canonical QVariant↔JSON conversion used at the QRO boundary.

Per-identity token stores

TokenManager::instance() is the image's store. In a host that loads several modules in one image it is also an ambient ring: the host writes name -> that module's root auth token for every module it loads, and a client presents a cached token before it ever mints one — so any module in that image can reach any other with authority it was never granted, and no requestModule appears in the log. Per-module origin strings do not change that, because origin was never consulted on the path taken.

TokenManager::forIdentity(origin) makes origin select the store instead of 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 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 instance(), which stays correct as-is.

logos-cpp-sdk layers the typed C++ developer API (LogosAPI, module context, code generator, provider base classes) on top of this repo.

Versioning

This repo carries the logos-protocol semver — the single number that governs Logos load/call compatibility. Two participants (modules, hosts, SDKs in any language) interoperate iff they share the same MAJOR. MINOR is additive/back-compatible; PATCH never affects compatibility. SDKs must re-expose the version of the protocol they linked (never mint their own).

Building

# Via workspace
ws build logos-protocol

# Standalone
nix build

# Tests
nix build .#tests

Layering invariant

logos-protocol depends only on Qt / Boost / OpenSSL / nlohmann_json — it must NEVER depend on logos-cpp-sdk, logos-qt-sdk, logos-rust-sdk, liblogos or logos-lidl. Everything points inward.

S
Description
No description provided
Readme
1.6 MiB
Languages
C++ 91.5%
C 3.9%
CMake 2.7%
Nix 1.1%
Shell 0.8%