mirror of
https://github.com/logos-co/logos-cpp-sdk.git
synced 2026-08-31 17:51:07 +00:00
Phase C1. A Qt-free, header-only wrapper over the three trust-root lp_* calls A3 added, so capability_module can become an ordinary universal module instead of a hand-written Qt plugin reaching into TokenManager directly. Deliberately FREE FUNCTIONS, not a LogosModuleContext seam as the plan sketched. The grant is process-global per IMAGE (host binary and module cdylib each link their own logos-protocol, so each has its own grant state and its own TokenManager), so the gate lives in the caller's own image and there is nothing per-instance to inject; a context seam would imply the privilege is a property of one impl object, which it is not. It is also markedly cheaper: a seam would need a new module-impl C ABI export plus lockstep changes in BOTH codegen paths (the Qt provider glue and the cdylib wrapper). constantTimeEquals lives here rather than in each caller: the natural spelling (a == b) leaks the matching-prefix length through timing, and a trust root comparing tokens with == is the exact bug this file exists to prevent. Ported from capability_module's own implementation to std::string. Two things the tests caught that reading had not: * lp_inform_module_token_to takes SIX arguments (client, auth_token, origin_module, module_name, token, timeout_ms), not the three I first wrote. The wrapper now mirrors it exactly, with the protocol's own default-timeout semantics documented. * sdk_tests compiles against logos_headers alone, which carries no protocol include path. It now resolves logos_protocol.h from LOGOS_PROTOCOL_ROOT, accepting either the source layout (cpp/) or a package layout (include/) and failing loudly on neither, rather than hard-coding the one in use today. The suite deliberately does NOT link logos-protocol: the lp_*-calling wrappers are `inline` and never ODR-used by these tests, so no protocol symbol is referenced. That is itself the assertion — the veneer must not drag the protocol library into a header-only consumer. A future test that calls one will fail to LINK rather than silently pull it in. Also documents a real gap found while writing it: lp_token_get performs NO host-service check, so "token_registry" gates ENUMERATION only. The plan claims that service covers `lp_token_get(any)`; it does not. Flagged at the call site rather than papered over — if lookup should be gated, the gate belongs in lp_token_get. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
171 lines
7.3 KiB
C++
171 lines
7.3 KiB
C++
#pragma once
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// logos_host_services.h — the C++ veneer over the privileged host services.
|
|
//
|
|
// These are the operations an ordinary module must NOT have: enumerating the
|
|
// token store, and pushing an auth token to an arbitrary target. Exactly one
|
|
// module in a normal deployment needs them — capability_module, the trust root
|
|
// — which is why they are a declared, host-granted privilege rather than part
|
|
// of the ambient SDK surface.
|
|
//
|
|
// A module declares what it needs in metadata.json:
|
|
//
|
|
// "host_services": ["token_registry", "token_delivery"]
|
|
//
|
|
// and the HOST decides whether to grant it, pushing the grant in over the
|
|
// module-impl C ABI (logos_module_grant_host_services). Until that happens
|
|
// every call here fails closed with LP_ERR_UNSUPPORTED — the declaration is
|
|
// advisory, the grant is authority.
|
|
//
|
|
// Qt-free by construction: this is a header-only wrapper over lp_* and
|
|
// nlohmann::json, so a universal/cdylib module can use it from translation
|
|
// units that never see Qt.
|
|
//
|
|
// ── Why these are free functions and not a LogosModuleContext seam ───────────
|
|
// The grant is process-global *per image* (see logos_protocol.h: the host
|
|
// binary and a module cdylib each link their own copy of logos-protocol, so
|
|
// each has its own grant state and its own TokenManager). The gate these
|
|
// functions check therefore lives in the caller's own image, and there is
|
|
// nothing per-instance to inject. Adding a context seam would imply the
|
|
// privilege is a property of one impl object, which it is not.
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
#include "logos_protocol.h" // lp_* C ABI
|
|
|
|
#include <nlohmann/json.hpp>
|
|
|
|
#include <string>
|
|
#include <utility>
|
|
#include <vector>
|
|
|
|
namespace logos {
|
|
namespace host {
|
|
|
|
/// Outcome of a privileged call. `ok == false` with `code == LP_ERR_UNSUPPORTED`
|
|
/// is the ordinary, expected answer for a module that was never granted the
|
|
/// service — callers should treat it as "not permitted", not as a failure to
|
|
/// retry.
|
|
struct Status {
|
|
bool ok = false;
|
|
int code = 0;
|
|
|
|
explicit operator bool() const { return ok; }
|
|
|
|
static Status fromCode(int c) { return Status{c == LP_OK, c}; }
|
|
/// True when the call was refused for want of a grant, as opposed to
|
|
/// failing on its own terms.
|
|
bool ungranted() const { return !ok && code == LP_ERR_UNSUPPORTED; }
|
|
};
|
|
|
|
namespace detail {
|
|
|
|
/// lp_* returns heap `char*` that the caller must release through
|
|
/// lp_string_free — never free() or delete. Owning it here keeps every exit
|
|
/// path (including the parse-failure ones) from leaking.
|
|
class OwnedString {
|
|
public:
|
|
explicit OwnedString(char* p) : m_p(p) {}
|
|
~OwnedString() { if (m_p) lp_string_free(m_p); }
|
|
OwnedString(const OwnedString&) = delete;
|
|
OwnedString& operator=(const OwnedString&) = delete;
|
|
OwnedString(OwnedString&& o) noexcept : m_p(o.m_p) { o.m_p = nullptr; }
|
|
|
|
const char* get() const { return m_p; }
|
|
explicit operator bool() const { return m_p != nullptr; }
|
|
|
|
private:
|
|
char* m_p = nullptr;
|
|
};
|
|
|
|
} // namespace detail
|
|
|
|
/// The module names this image's token store holds.
|
|
///
|
|
/// Requires the "token_registry" service. Returns an empty vector both when the
|
|
/// service was not granted and when the store is genuinely empty; pass
|
|
/// `status` when the difference matters — it is the whole point of the gate.
|
|
inline std::vector<std::string> tokenKeys(Status* status = nullptr)
|
|
{
|
|
detail::OwnedString raw(lp_token_keys());
|
|
if (!raw) {
|
|
// The C surface signals refusal by returning null rather than a code,
|
|
// so map it onto the same "ungranted" answer the other call reports.
|
|
if (status) *status = Status{false, LP_ERR_UNSUPPORTED};
|
|
return {};
|
|
}
|
|
|
|
nlohmann::json parsed = nlohmann::json::parse(raw.get(), nullptr,
|
|
/*allow_exceptions=*/false);
|
|
if (parsed.is_discarded() || !parsed.is_array()) {
|
|
if (status) *status = Status{false, LP_ERR_INVALID_ARG};
|
|
return {};
|
|
}
|
|
|
|
std::vector<std::string> keys;
|
|
keys.reserve(parsed.size());
|
|
for (const nlohmann::json& e : parsed) {
|
|
if (e.is_string()) keys.push_back(e.get<std::string>());
|
|
}
|
|
if (status) *status = Status{true, LP_OK};
|
|
return keys;
|
|
}
|
|
|
|
/// The token this image holds for `moduleName`, or empty when there is none.
|
|
///
|
|
/// ⚠️ NOT gated. `lp_token_get` performs no host-service check
|
|
/// (logos_protocol.cpp), so ANY module in this image can look up ANY name it
|
|
/// can guess — "token_registry" gates ENUMERATION (lp_token_keys) only, which
|
|
/// is what stops a module discovering names it was never told. Do not read the
|
|
/// presence of this function as a privilege boundary; if lookup is meant to be
|
|
/// gated too, that gate belongs in lp_token_get, not here.
|
|
inline std::string tokenFor(const std::string& moduleName)
|
|
{
|
|
detail::OwnedString raw(lp_token_get(moduleName.c_str()));
|
|
return raw ? std::string(raw.get()) : std::string();
|
|
}
|
|
|
|
/// Deliver the token for `moduleName` TO `originModule`, over `client`.
|
|
///
|
|
/// Requires the "token_delivery" service. This is the PROVIDER half of the
|
|
/// exchange — the direction capability_module pushes — and is deliberately not
|
|
/// `lp_inform_module_token`, which always lands on capability_module whatever
|
|
/// the client's target is.
|
|
///
|
|
/// `authToken` is the caller's own token for the target, as usual.
|
|
/// `timeoutMs <= 0` selects the protocol default (20s), which bounds the
|
|
/// handshake-surface fallback and the call together.
|
|
inline Status informModuleTokenTo(lp_client* client,
|
|
const std::string& authToken,
|
|
const std::string& originModule,
|
|
const std::string& moduleName,
|
|
const std::string& token,
|
|
int timeoutMs = 0)
|
|
{
|
|
return Status::fromCode(
|
|
lp_inform_module_token_to(client, authToken.c_str(), originModule.c_str(),
|
|
moduleName.c_str(), token.c_str(), timeoutMs));
|
|
}
|
|
|
|
/// Constant-time string comparison, for validating a presented token against a
|
|
/// stored one.
|
|
///
|
|
/// Deliberately provided HERE rather than left to each caller: the natural
|
|
/// spelling (`a == b`) leaks the length of the matching prefix through timing,
|
|
/// and a trust root that compares tokens with `==` is the exact bug this file
|
|
/// exists to prevent. Compares length first — the lengths of these tokens are
|
|
/// not secret — then every remaining byte with no early exit.
|
|
inline bool constantTimeEquals(const std::string& a, const std::string& b)
|
|
{
|
|
if (a.size() != b.size()) return false;
|
|
unsigned char diff = 0;
|
|
for (std::size_t i = 0; i < a.size(); ++i) {
|
|
diff = static_cast<unsigned char>(
|
|
diff | (static_cast<unsigned char>(a[i]) ^ static_cast<unsigned char>(b[i])));
|
|
}
|
|
return diff == 0;
|
|
}
|
|
|
|
} // namespace host
|
|
} // namespace logos
|