diff --git a/AGENTS.md b/AGENTS.md index c04beb662..566c8f00c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -476,6 +476,11 @@ observe-only local handshake/capability service, and a QtWidgets-free `resource.subscribe`/`resource.unsubscribe`, per-resource revisions, bounded coalescing/session resync, and an independent local-socket hard disconnect cap are live over the current-user local transport. +Sessions now require explicit trusted authorization; the local transport grants +observe permission, and reads/subscriptions enforce it. The revocation hook +discards pending observations and terminates local delivery; no wire or daemon +path invokes it yet. Credential verification/provisioning and control/transmit +grants are not implemented yet. Meters, read-only transmit state, authenticated non-TX control, and the desktop adapter have not landed; UI code still consumes models directly, and that remains correct. New resource fields belong in the adapter and the versioned diff --git a/docs/aetherd-control-protocol-v1-design.md b/docs/aetherd-control-protocol-v1-design.md index c6ffe2e3e..af00e2f3d 100644 --- a/docs/aetherd-control-protocol-v1-design.md +++ b/docs/aetherd-control-protocol-v1-design.md @@ -258,6 +258,35 @@ supplies a verifier, any supplied `auth` field is rejected with `auth.invalid`; credentials are never accepted and ignored. The `auth` shape above is reserved for a transport wired to the verifier described here. +The service now enforces an explicit, immutable authorization context on each +`ControlSession`. The default context is unauthenticated: after envelope +parsing, `hello` returns `auth.required` and requests closure before parameter +or version negotiation. Invalid envelopes still receive protocol errors. The +existing local transport supplies observer authorization only for connections +admitted through its current-user endpoint. Trusted in-process callers must +also provide their authorization explicitly. Client names, session IDs, and +JSON fields cannot grant access. + +An authenticated session with no grants can negotiate and call +`capabilities.get`, but advertises empty grants/resource capabilities and gets +`auth.grant_denied` for every resource method. Both the service and direct +subscription entry points enforce observe permission. No control or transmit +grant is representable in this implementation yet. + +The owning thread can revoke a session through `revokeAuthorization()`. It +clears subscriptions and queued frames before notifying the transport, stops +future events, and makes subsequent requests fail closed with `auth.invalid`. +The local transport aborts synchronously to discard its unwritten output; +already delivered bytes cannot be recalled. Revocation is idempotent and +terminal even before negotiation or while a resync notice is pending. A newly +verified connection creates a new session and must take a fresh baseline. +There is no wire revocation method, daemon caller of the revocation hook, or +credential provisioning in this slice; these are lifecycle hooks for subsequent +authenticated non-TX control. The no-grants context likewise has no production +producer yet. Output binding rejects duplicate bindings, missing callbacks, and +mismatched calling/endpoint threads in release builds as well as debug builds. +Both endpoints must remain on their owning thread after binding. + Remote WebSocket serving is disabled by default. When enabled it may bind to loopback, an explicitly selected WireGuard interface, or a TLS endpoint with certificate validation. Wildcard/plain-LAN binding is rejected. Origin diff --git a/docs/aetherd-control-resource-v1-catalogue.md b/docs/aetherd-control-resource-v1-catalogue.md index 67c4cf7aa..441690e0b 100644 --- a/docs/aetherd-control-resource-v1-catalogue.md +++ b/docs/aetherd-control-resource-v1-catalogue.md @@ -28,11 +28,18 @@ an omitted `id` as an all-current-and-future selector for `radioSession`, ## Methods -All methods require the negotiated session ID. The initial current-user local -endpoint grants `observe` to every negotiated session; because no other session -type exists yet, this slice has no separate per-request grant branch. Explicit -per-session grant mapping and checks arrive with authentication before another -grant or remote session is exposed. +All methods require the negotiated session ID and an active `observe` grant. +The current-user local endpoint supplies observer authorization when it creates +each session. The transport-neutral service defaults to unauthenticated and +refuses negotiation without trusted authorization. Authentication without the +observe grant permits `capabilities.get` only; resource methods return +`auth.grant_denied` before checking parameters or looking up resources. + +Revocation is terminal for that session: subscriptions and pending frames are +discarded, future resource events are suppressed, and the local transport +aborts its socket and unwritten output. Bytes already delivered cannot be +recalled. The client must establish a new authorized connection and negotiate +and subscribe again. A `hello` on a revoked session cannot restore access. ### `resource.get` diff --git a/src/core/control/ControlService.cpp b/src/core/control/ControlService.cpp index 9092a47cd..39667fddb 100644 --- a/src/core/control/ControlService.cpp +++ b/src/core/control/ControlService.cpp @@ -2,7 +2,6 @@ #include #include -#include #include @@ -104,17 +103,29 @@ ControlService::ControlService(ControlResourceStore* resources) ServiceReply ControlService::handle( const QByteArray& bytes, ControlSession* session) const { + if (!session || session->isRevoked()) { + return failure({}, + {QStringLiteral("auth.invalid"), + QStringLiteral("session authorization is unavailable"), {}, false}, + true); + } const ParseResult parsed = ControlProtocolCodec::parseRequest(bytes); if (!parsed.ok()) { return failure(parsed.requestId, parsed.error.value_or(ProtocolError{ QStringLiteral("protocol.invalid_envelope"), QStringLiteral("request could not be parsed"), {}, false}), - !session->negotiated); + !session->isNegotiated()); } const ProtocolRequest& request = *parsed.request; - if (!session->negotiated) { + if (!session->isNegotiated()) { + if (!session->isAuthenticated()) { + return failure(request.id, + {QStringLiteral("auth.required"), + QStringLiteral("authenticated transport context required"), {}, false}, + true); + } if (!request.isHello()) { return failure(request.id, {QStringLiteral("protocol.invalid_envelope"), @@ -133,8 +144,7 @@ ServiceReply ControlService::handle( true); } - session->sessionId = QUuid::createUuid().toString(QUuid::WithoutBraces); - session->negotiated = true; + session->completeNegotiation(); return {ControlProtocolCodec::successResponse( request.id, capabilities(*session)), false}; } @@ -144,7 +154,7 @@ ServiceReply ControlService::handle( {QStringLiteral("protocol.invalid_envelope"), QStringLiteral("hello may only be sent once"), {}, false}); } - if (request.sessionId != session->sessionId) { + if (request.sessionId != session->sessionId()) { return failure(request.id, {QStringLiteral("session.invalid"), QStringLiteral("session does not belong to this connection"), {}, false}); @@ -159,6 +169,9 @@ ServiceReply ControlService::handle( request.id, capabilities(*session)), false}; } if (request.method == QStringLiteral("resource.get")) { + if (const std::optional error = session->observationError()) { + return failure(request.id, *error); + } if (const std::optional keyError = onlyKeys( request.params, {QStringLiteral("resource")})) { return failure(request.id, *keyError); @@ -178,6 +191,9 @@ ServiceReply ControlService::handle( return {ControlProtocolCodec::successResponse(request.id, snapshot->toJson()), false}; } if (request.method == QStringLiteral("resource.subscribe")) { + if (const std::optional error = session->observationError()) { + return failure(request.id, *error); + } if (const std::optional keyError = onlyKeys( request.params, {QStringLiteral("resources")})) { return failure(request.id, *keyError); @@ -209,6 +225,9 @@ ServiceReply ControlService::handle( return {ControlProtocolCodec::successResponse(request.id, result), false}; } if (request.method == QStringLiteral("resource.unsubscribe")) { + if (const std::optional error = session->observationError()) { + return failure(request.id, *error); + } if (const std::optional keyError = onlyKeys( request.params, {QStringLiteral("subscription")})) { return failure(request.id, *keyError); @@ -238,21 +257,24 @@ ServiceReply ControlService::handle( QJsonObject ControlService::capabilities(const ControlSession& session) const { + const bool observe = session.canObserve(); + const QJsonArray grants = observe ? QJsonArray{QStringLiteral("observe")} : QJsonArray{}; + const QJsonArray available = observe ? QJsonArray{ + QStringLiteral("server.read"), + QStringLiteral("radioSession.read"), + QStringLiteral("slice.read"), + QStringLiteral("panadapter.read"), + QStringLiteral("resource.get"), + QStringLiteral("resource.subscribe"), + QStringLiteral("resource.unsubscribe")} : QJsonArray{}; return { - {QStringLiteral("sessionId"), session.sessionId}, + {QStringLiteral("sessionId"), session.sessionId()}, {QStringLiteral("version"), 1}, {QStringLiteral("server"), QJsonObject{ {QStringLiteral("name"), QStringLiteral("aetherd")}, {QStringLiteral("version"), QStringLiteral(AETHERSDR_VERSION)}}}, - {QStringLiteral("grants"), QJsonArray{QStringLiteral("observe")}}, - {QStringLiteral("capabilities"), QJsonArray{ - QStringLiteral("server.read"), - QStringLiteral("radioSession.read"), - QStringLiteral("slice.read"), - QStringLiteral("panadapter.read"), - QStringLiteral("resource.get"), - QStringLiteral("resource.subscribe"), - QStringLiteral("resource.unsubscribe")}}, + {QStringLiteral("grants"), grants}, + {QStringLiteral("capabilities"), available}, {QStringLiteral("limits"), QJsonObject{ {QStringLiteral("maxMessageBytes"), ProtocolLimits::kMaxMessageBytes}, {QStringLiteral("maxSubscriptions"), ControlSession::kMaxSubscriptions}, diff --git a/src/core/control/ControlService.h b/src/core/control/ControlService.h index b1d9a1c0f..fd82c1b38 100644 --- a/src/core/control/ControlService.h +++ b/src/core/control/ControlService.h @@ -16,7 +16,8 @@ struct ServiceReply { // Transport-neutral Stage-3 service kernel. The current surface is strictly // observe-only: negotiation, capability discovery, typed resource reads, and -// subscriptions. Non-TX control methods attach in a subsequent slice. +// subscriptions. A session's trusted transport context supplies authorization; +// hello cannot grant permissions. Non-TX methods attach in a subsequent slice. class ControlService final { public: explicit ControlService(ControlResourceStore* resources); diff --git a/src/core/control/ControlSession.cpp b/src/core/control/ControlSession.cpp index db64b5994..fd8fa7d8c 100644 --- a/src/core/control/ControlSession.cpp +++ b/src/core/control/ControlSession.cpp @@ -2,16 +2,24 @@ #include #include +#include +#include +#include +#include #include namespace AetherSDR::control { +Q_LOGGING_CATEGORY(lcControlSession, "aether.control.session") + ControlSession::ControlSession(ControlResourceStore* resources, qint64 maxQueuedOutputBytes, + SessionAuthorization authorization, QObject* parent) : QObject(parent), m_resources(resources), + m_authorization(authorization), m_maxQueuedOutputBytes(maxQueuedOutputBytes) { Q_ASSERT(m_resources); @@ -21,9 +29,107 @@ ControlSession::ControlSession(ControlResourceStore* resources, this, &ControlSession::onResourceRemoved); } +bool ControlSession::isAuthenticated() const +{ + return !m_revoked + && (m_authorization == SessionAuthorization::Observer + || m_authorization == SessionAuthorization::AuthenticatedWithoutGrants); +} + +bool ControlSession::canObserve() const +{ + return isAuthenticated() && isNegotiated() + && m_authorization == SessionAuthorization::Observer; +} + +void ControlSession::completeNegotiation() +{ + Q_ASSERT(isAuthenticated() && !isNegotiated()); + m_sessionId = QUuid::createUuid().toString(QUuid::WithoutBraces); +} + +std::optional ControlSession::observationError() const +{ + if (m_revoked) { + return ProtocolError{QStringLiteral("auth.invalid"), + QStringLiteral("session authorization was revoked"), {}, false}; + } + if (!isAuthenticated()) { + return ProtocolError{QStringLiteral("auth.required"), + QStringLiteral("authenticated transport context required"), {}, false}; + } + if (!isNegotiated()) { + return ProtocolError{QStringLiteral("session.invalid"), + QStringLiteral("session has not negotiated"), {}, false}; + } + if (!canObserve()) { + return ProtocolError{QStringLiteral("auth.grant_denied"), + QStringLiteral("observe grant required"), {}, false}; + } + return std::nullopt; +} + +void ControlSession::revokeAuthorization() +{ + if (m_revoked) { + return; + } + m_revoked = true; + m_subscriptions.clear(); + m_selectorsByType.clear(); + m_pending.clear(); + m_pendingBytes = 0; + emit authorizationRevoked(); +} + +void ControlSession::bindOutputTransport( + QObject* transportContext, + std::function writeFrame, + std::function abortTransport) +{ + // Reject invalid bindings in release builds too. In particular, never + // compensate for mismatched affinity by aborting a socket on another thread. + if (QThread::currentThread() != thread() + || !transportContext || transportContext->thread() != thread()) { + qCWarning(lcControlSession) << "Output transport must bind on the session's owning thread"; + return; + } + if (!writeFrame || !abortTransport || m_outputTransportBound) { + qCWarning(lcControlSession) << "Output transport requires callbacks and may only bind once"; + return; + } + m_outputTransportBound = true; + const QPointer session(this); + const QPointer transport(transportContext); + connect(this, &ControlSession::outputReady, transportContext, + [session, transport, writeFrame = std::move(writeFrame)] { + if (!session) { + return; + } + const QList frames = session->takePendingFrames(); + for (const QByteArray& frame : frames) { + // A write can revoke the session or destroy either endpoint. + if (!session || !transport || !session->canObserve() || !writeFrame(frame)) { + return; + } + } + }, Qt::QueuedConnection); + connect(this, &ControlSession::outputOverflow, + transportContext, abortTransport, Qt::QueuedConnection); + // Same owning thread: do not defer the abort behind an already queued flush. + connect(this, &ControlSession::authorizationRevoked, + transportContext, abortTransport); + if (isRevoked()) { + abortTransport(); + } +} + std::optional ControlSession::subscribe( const QList& selectors, QJsonObject* result) { + if (const std::optional error = observationError()) { + return error; + } if (!result || selectors.isEmpty()) { return ProtocolError{QStringLiteral("request.invalid_params"), QStringLiteral("resources must be a non-empty array"), {}, false}; @@ -58,6 +164,9 @@ std::optional ControlSession::subscribe( std::optional ControlSession::unsubscribe( const QString& subscriptionId, QJsonObject* result) { + if (const std::optional error = observationError()) { + return error; + } if (!result || subscriptionId.isEmpty()) { return ProtocolError{QStringLiteral("request.invalid_params"), QStringLiteral("subscription must be a non-empty string"), @@ -82,6 +191,13 @@ std::optional ControlSession::unsubscribe( QList ControlSession::takePendingFrames() { + if (!canObserve()) { + // Defence in depth: authorization is immutable and revocation already + // clears this queue. Do not retain charged bytes if that invariant changes. + m_pending.clear(); + m_pendingBytes = 0; + return {}; + } QList frames; frames.reserve(m_pending.size()); for (const PendingMessage& pending : std::as_const(m_pending)) { @@ -107,6 +223,11 @@ void ControlSession::rebuildSelectorIndex() bool ControlSession::observes(const ResourceAddress& address) const { + // Defence in depth: denied sessions cannot subscribe, and revocation clears + // the selector index. No supported state currently relies on this guard alone. + if (!canObserve()) { + return false; + } const auto bucket = m_selectorsByType.constFind(address.type); if (bucket == m_selectorsByType.constEnd()) { return false; @@ -140,7 +261,7 @@ void ControlSession::enqueueResourceEvent( quint64 revision, const QJsonObject& value) { QJsonObject message{{QStringLiteral("v"), 1}, - {QStringLiteral("sessionId"), sessionId}, + {QStringLiteral("sessionId"), m_sessionId}, {QStringLiteral("event"), event}, {QStringLiteral("sequence"), static_cast(++m_sequence)}, {QStringLiteral("resource"), address.toJson()}, @@ -195,7 +316,7 @@ void ControlSession::requireResync() m_pendingBytes = 0; const QJsonObject message{{QStringLiteral("v"), 1}, - {QStringLiteral("sessionId"), sessionId}, + {QStringLiteral("sessionId"), m_sessionId}, {QStringLiteral("event"), QStringLiteral("resource.resyncRequired")}, {QStringLiteral("sequence"), static_cast(++m_sequence)}, {QStringLiteral("subscriptionsInvalidated"), true}}; diff --git a/src/core/control/ControlSession.h b/src/core/control/ControlSession.h index 4b03d27d5..9c2f2645d 100644 --- a/src/core/control/ControlSession.h +++ b/src/core/control/ControlSession.h @@ -11,10 +11,20 @@ #include #include +#include #include namespace AetherSDR::control { +// Supplied by trusted embedding/transport code, never decoded from hello. +// The local endpoint supplies Observer after enforcing current-user access. +// No control or transmit grant exists until its handlers and guards land. +enum class SessionAuthorization { + Unauthenticated, + AuthenticatedWithoutGrants, + Observer, +}; + // Per-client protocol state. Resource events are session-sequenced and held in // a bounded, coalescing queue until the transport drains them. class ControlSession final : public QObject { @@ -25,10 +35,28 @@ class ControlSession final : public QObject { explicit ControlSession(ControlResourceStore* resources, qint64 maxQueuedOutputBytes, + SessionAuthorization authorization = SessionAuthorization::Unauthenticated, QObject* parent = nullptr); - QString sessionId; - bool negotiated{false}; + [[nodiscard]] const QString& sessionId() const { return m_sessionId; } + [[nodiscard]] bool isNegotiated() const { return !m_sessionId.isEmpty(); } + [[nodiscard]] bool isAuthenticated() const; + [[nodiscard]] bool canObserve() const; + [[nodiscard]] bool isRevoked() const { return m_revoked; } + // Terminal for this session. Clears subscriptions and queued frames before + // notifying the transport to abort its own output buffer. A new verified + // connection must construct a new session; hello cannot restore this one. + void revokeAuthorization(); + + // Bind once on the owning thread; neither endpoint may move threads afterward. + // Invalid/repeated binds log and leave the existing wiring unchanged. + // The transport context bounds callback + // lifetime; writeFrame accepts a complete frame and returns false on failure. + // abortTransport must synchronously discard transport-owned output. Keeping + // this wiring here lets socket-free tests exercise the production lifecycle. + void bindOutputTransport(QObject* transportContext, + std::function writeFrame, + std::function abortTransport); [[nodiscard]] std::optional subscribe( const QList& selectors, QJsonObject* result); @@ -44,8 +72,13 @@ class ControlSession final : public QObject { signals: void outputReady(); void outputOverflow(); + void authorizationRevoked(); private: + friend class ControlService; + void completeNegotiation(); + [[nodiscard]] std::optional observationError() const; + struct PendingMessage { QString coalesceKey; std::optional resource; @@ -67,6 +100,10 @@ class ControlSession final : public QObject { [[nodiscard]] static QByteArray encodeFrame(const QJsonObject& message); ControlResourceStore* m_resources{nullptr}; + const SessionAuthorization m_authorization; + QString m_sessionId; + bool m_revoked{false}; + bool m_outputTransportBound{false}; qint64 m_maxQueuedOutputBytes{0}; quint64 m_sequence{0}; quint64 m_drainedSequence{0}; diff --git a/src/core/control/LocalControlServer.cpp b/src/core/control/LocalControlServer.cpp index 683a419b5..a48995066 100644 --- a/src/core/control/LocalControlServer.cpp +++ b/src/core/control/LocalControlServer.cpp @@ -32,7 +32,9 @@ QJsonObject serverValue(const QString& localTransport) struct LocalControlServer::Client { Client(ControlResourceStore* resources, qint64 maxQueuedOutputBytes) - : session(std::make_unique(resources, maxQueuedOutputBytes)) + // Only created for sockets accepted by the current-user endpoint. + : session(std::make_unique( + resources, maxQueuedOutputBytes, SessionAuthorization::Observer)) { } @@ -163,11 +165,9 @@ void LocalControlServer::acceptConnections() }); connect(socket, &QLocalSocket::readyRead, this, [this, socket] { readClient(socket); }); - connect(client->session.get(), &ControlSession::outputReady, - this, [this, socket] { drainSessionOutput(socket); }, - Qt::QueuedConnection); - connect(client->session.get(), &ControlSession::outputOverflow, - socket, &QLocalSocket::abort, Qt::QueuedConnection); + client->session->bindOutputTransport(socket, + [this, socket](const QByteArray& frame) { return sendFrame(socket, frame); }, + [socket] { socket->abort(); }); connect(socket, &QLocalSocket::disconnected, this, [this, socket] { // QLocalSocket::abort() may emit disconnected synchronously @@ -213,7 +213,7 @@ void LocalControlServer::readClient(QLocalSocket* socket) } const ServiceReply reply = m_service.handle(frame, client->session.get()); - if (client->session->negotiated) { + if (client->session->isNegotiated()) { client->handshakeTimer.stop(); } if (!send(socket, reply.message)) { @@ -226,20 +226,6 @@ void LocalControlServer::readClient(QLocalSocket* socket) } } -void LocalControlServer::drainSessionOutput(QLocalSocket* socket) -{ - const auto clientIt = m_clients.find(socket); - if (clientIt == m_clients.end()) { - return; - } - const QList frames = clientIt->second->session->takePendingFrames(); - for (const QByteArray& frame : frames) { - if (!sendFrame(socket, frame)) { - return; - } - } -} - void LocalControlServer::dropClient(QLocalSocket* socket) { m_clients.erase(socket); diff --git a/src/core/control/LocalControlServer.h b/src/core/control/LocalControlServer.h index 2adb0da46..3e1ca203b 100644 --- a/src/core/control/LocalControlServer.h +++ b/src/core/control/LocalControlServer.h @@ -44,7 +44,6 @@ class LocalControlServer final : public QObject { void acceptConnections(); void readClient(QLocalSocket* socket); - void drainSessionOutput(QLocalSocket* socket); void dropClient(QLocalSocket* socket); [[nodiscard]] bool send(QLocalSocket* socket, const QJsonObject& message); // Writes a frame the session already encoded, so an event is serialized diff --git a/tests/control_authorization_test.cpp b/tests/control_authorization_test.cpp new file mode 100644 index 000000000..8e29c47d9 --- /dev/null +++ b/tests/control_authorization_test.cpp @@ -0,0 +1,511 @@ +#include "core/control/ControlService.h" + +#include +#include +#include +#include +#include + +#include +#include + +using namespace AetherSDR::control; + +namespace { + +bool check(bool condition, const char* message) +{ + if (!condition) { + std::fprintf(stderr, "%s\n", message); + } + return condition; +} + +QString errorCode(const ServiceReply& reply) +{ + return reply.message.value(QStringLiteral("error")).toObject() + .value(QStringLiteral("code")).toString(); +} + +ServiceReply invoke(ControlService& service, ControlSession& session, + const QString& method, const QJsonObject& params = {}) +{ + QJsonObject request{{QStringLiteral("v"), 1}, + {QStringLiteral("id"), QStringLiteral("test")}, + {QStringLiteral("method"), method}, + {QStringLiteral("params"), params}}; + if (method != QStringLiteral("hello")) { + request.insert(QStringLiteral("sessionId"), session.sessionId()); + } + return service.handle(QJsonDocument(request).toJson(QJsonDocument::Compact), &session); +} + +ServiceReply hello(ControlService& service, ControlSession& session) +{ + return invoke(service, session, QStringLiteral("hello"), + {{QStringLiteral("versions"), QJsonArray{1}}}); +} + +const ResourceAddress kServer{QStringLiteral("server"), {}, {}}; +const QList kSelectors{{QStringLiteral("server"), {}, {}}}; + +bool testAuthenticationAndGrants() +{ + ControlResourceStore store; + store.upsert(kServer, {{QStringLiteral("health"), QStringLiteral("ok")}}); + ControlService service(&store); + ControlSession untrusted(&store, 4096); + const ServiceReply denied = hello(service, untrusted); + if (!check(errorCode(denied) == QStringLiteral("auth.required") + && denied.closeAfterWrite && !untrusted.isNegotiated(), + "a session without trusted transport context must fail hello closed")) { + return false; + } + + ControlSession observer(&store, 4096, SessionAuthorization::Observer); + QJsonObject directResult; + const std::optional premature = observer.subscribe(kSelectors, &directResult); + if (!check(premature && premature->code == QStringLiteral("session.invalid") + && directResult.isEmpty(), + "transport authorization alone must not bypass negotiation")) { + return false; + } + const QJsonObject welcome = hello(service, observer).message + .value(QStringLiteral("result")).toObject(); + if (!check(welcome.value(QStringLiteral("grants")).toArray() + == QJsonArray{QStringLiteral("observe")} + && welcome.value(QStringLiteral("capabilities")).toArray().size() == 7 + && observer.canObserve(), + "the current-user observer must keep its seven read capabilities")) { + return false; + } + + ControlSession noGrants(&store, 4096, SessionAuthorization::AuthenticatedWithoutGrants); + const QJsonObject restricted = hello(service, noGrants).message + .value(QStringLiteral("result")).toObject(); + if (!check(noGrants.isNegotiated() && noGrants.isAuthenticated() && !noGrants.canObserve() + && restricted.value(QStringLiteral("grants")).toArray().isEmpty() + && restricted.value(QStringLiteral("capabilities")).toArray().isEmpty(), + "authentication must not imply the observe grant or advertise its methods")) { + return false; + } + const QJsonObject capabilities = invoke(service, noGrants, QStringLiteral("capabilities.get")) + .message.value(QStringLiteral("result")).toObject(); + if (!check(capabilities == restricted, + "capability refresh must retain the authenticated client's grant filter")) { + return false; + } + // Deliberately invalid params: authorization precedes schema processing or + // resource lookup, so denied callers learn no resource/subscription state. + for (const QString& method : {QStringLiteral("resource.get"), + QStringLiteral("resource.subscribe"), + QStringLiteral("resource.unsubscribe")}) { + const ServiceReply reply = invoke(service, noGrants, method); + if (!check(errorCode(reply) == QStringLiteral("auth.grant_denied") + && !reply.closeAfterWrite, + "every resource method must check observe before processing params")) { + return false; + } + } + const std::optional directDenied = noGrants.subscribe(kSelectors, &directResult); + if (!check(directDenied && directDenied->code == QStringLiteral("auth.grant_denied") + && directResult.isEmpty(), + "direct subscription calls must enforce the same grant boundary")) { + return false; + } + const ServiceReply read = invoke(service, observer, QStringLiteral("resource.get"), + {{QStringLiteral("resource"), kServer.toJson()}}); + if (!check(read.message.value(QStringLiteral("result")).toObject() + .value(QStringLiteral("value")).toObject() + .value(QStringLiteral("health")) == QStringLiteral("ok"), + "an authorized observer must read the real resource")) { + return false; + } + for (const QString& method : {QStringLiteral("slice.setFrequency"), + QStringLiteral("transmit.acquire"), + QStringLiteral("transmit.setMox")}) { + if (!check(errorCode(invoke(service, observer, method)) + == QStringLiteral("request.unknown_method"), + "authorization groundwork must not expose control or TX methods")) { + return false; + } + } + // A session id identifies one connection; it is not a bearer credential. + const QJsonObject stolenId{{QStringLiteral("v"), 1}, + {QStringLiteral("id"), QStringLiteral("stolen")}, + {QStringLiteral("sessionId"), observer.sessionId()}, + {QStringLiteral("method"), QStringLiteral("resource.get")}, + {QStringLiteral("params"), QJsonObject{ + {QStringLiteral("resource"), kServer.toJson()}}}}; + return check(errorCode(service.handle(QJsonDocument(stolenId).toJson(), &noGrants)) + == QStringLiteral("session.invalid"), + "another client's session id must not transfer its permissions"); +} + +bool testUntrustedHelloCannotGrantAccess() +{ + ControlResourceStore store; + ControlService service(&store); + for (const SessionAuthorization context : {SessionAuthorization::Unauthenticated, + SessionAuthorization::Observer}) { + for (const QJsonObject& claim : { + QJsonObject{{QStringLiteral("auth"), QJsonObject{ + {QStringLiteral("scheme"), QStringLiteral("bearer")}, + {QStringLiteral("token"), QStringLiteral("private-test-token")}}}}, + QJsonObject{{QStringLiteral("grants"), QJsonArray{QStringLiteral("transmit")}}}}) { + ControlSession session(&store, 4096, context); + QJsonObject params = claim; + params.insert(QStringLiteral("versions"), QJsonArray{1}); + const ServiceReply reply = invoke(service, session, QStringLiteral("hello"), params); + if (!check(!errorCode(reply).isEmpty() && reply.closeAfterWrite + && !session.isNegotiated() + && !QJsonDocument(reply.message).toJson().contains("private-test-token"), + "hello must reject credentials/grant claims without leaking their value")) { + return false; + } + } + } + return true; +} + +bool testHelloAuthorizationPrecedesParams() +{ + ControlResourceStore store; + ControlService service(&store); + for (const QJsonObject& params : { + QJsonObject{}, + QJsonObject{{QStringLiteral("versions"), QJsonArray{}}}, + QJsonObject{{QStringLiteral("versions"), QJsonArray{2}}}, + QJsonObject{{QStringLiteral("versions"), QJsonArray{1}}}}) { + ControlSession session(&store, 4096); + const ServiceReply reply = invoke(service, session, QStringLiteral("hello"), params); + if (!check(errorCode(reply) == QStringLiteral("auth.required") + && reply.closeAfterWrite && !session.isNegotiated() + && reply.message.value(QStringLiteral("error")).toObject() + .value(QStringLiteral("details")).toObject().isEmpty(), + "unauthenticated hello must not disclose parameter/version negotiation")) { + return false; + } + } + ControlSession observer(&store, 4096, SessionAuthorization::Observer); + return check(errorCode(invoke(service, observer, QStringLiteral("hello"), + {{QStringLiteral("versions"), QJsonArray{2}}})) + == QStringLiteral("protocol.version_unsupported"), + "trusted observers must retain version negotiation errors"); +} + +bool testOutputBindingContract() +{ + ControlResourceStore store; + ControlService service(&store); + ControlSession session(&store, 4096, SessionAuthorization::Observer); + QObject transport; + int writes = 0; + int aborts = 0; + int rejectedCallbacks = 0; + const auto rejectedWrite = [&](const QByteArray&) { ++rejectedCallbacks; return true; }; + const auto rejectedAbort = [&] { ++rejectedCallbacks; }; + + // These must be rejected without consuming the one permitted binding. + session.bindOutputTransport(nullptr, rejectedWrite, rejectedAbort); + session.bindOutputTransport(&transport, {}, rejectedAbort); + session.bindOutputTransport(&transport, rejectedWrite, {}); + + QThread worker; + QObject foreignTransport; + foreignTransport.moveToThread(&worker); + worker.start(); + session.bindOutputTransport(&foreignTransport, rejectedWrite, rejectedAbort); + // Same-affinity endpoints are insufficient if the call itself is off-thread. + QMetaObject::invokeMethod(&foreignTransport, [&] { + session.bindOutputTransport(&transport, rejectedWrite, rejectedAbort); + foreignTransport.moveToThread(transport.thread()); + }, Qt::BlockingQueuedConnection); + worker.quit(); + worker.wait(); + + session.bindOutputTransport(&transport, [&](const QByteArray&) { + ++writes; + return true; + }, [&] { ++aborts; }); + // Reject rebinds to both the same context and a different live context. + session.bindOutputTransport(&transport, rejectedWrite, rejectedAbort); + QObject duplicateTransport; + session.bindOutputTransport(&duplicateTransport, rejectedWrite, rejectedAbort); + hello(service, session); + QJsonObject baseline; + if (!check(!session.subscribe(kSelectors, &baseline), "binding observer must subscribe")) { + return false; + } + store.upsert(kServer, {{QStringLiteral("health"), QStringLiteral("ok")}}); + QCoreApplication::sendPostedEvents(nullptr, QEvent::MetaCall); + session.revokeAuthorization(); + QCoreApplication::sendPostedEvents(nullptr, QEvent::MetaCall); + return check(writes == 1 && aborts == 1 && rejectedCallbacks == 0, + "invalid and repeated bindings must install no callbacks in release builds"); +} + +bool testRevocationStopsDelivery() +{ + ControlResourceStore store; + ControlService service(&store); + ControlSession first(&store, 4096, SessionAuthorization::Observer); + ControlSession second(&store, 4096, SessionAuthorization::Observer); + hello(service, first); + hello(service, second); + QJsonObject firstBaseline; + QJsonObject secondBaseline; + if (!check(!first.subscribe(kSelectors, &firstBaseline) + && !second.subscribe(kSelectors, &secondBaseline), + "both observers must install independent subscriptions")) { + return false; + } + + // Exercise the production binding with a socket-free output sink. Revoke + // after enqueue, before the queued drain gets its event-loop turn. + QObject transport; + QList delivered; + int revokedCount = 0; + bool emptyAtRevocation = false; + first.bindOutputTransport(&transport, [&](const QByteArray& frame) { + delivered.append(frame); + return true; + }, [&] { + ++revokedCount; + emptyAtRevocation = first.takePendingFrames().isEmpty() && !first.canObserve(); + }); + store.upsert(kServer, {{QStringLiteral("health"), QStringLiteral("before-revoke")}}); + if (!check(first.sequence() == 1, "the revoked client must have had a pending event")) { + return false; + } + first.revokeAuthorization(); + first.revokeAuthorization(); + QCoreApplication::processEvents(); + store.upsert(kServer, {{QStringLiteral("health"), QStringLiteral("after-revoke")}}); + store.remove(kServer); + QCoreApplication::processEvents(); + if (!check(revokedCount == 1 && emptyAtRevocation && delivered.isEmpty() + && first.sequence() == 1 && first.takePendingFrames().isEmpty() + && second.sequence() == 3 && !second.takePendingFrames().isEmpty(), + "revocation must stop queued/new delivery without affecting another client")) { + return false; + } + QJsonObject result; + const std::optional resubscribe = first.subscribe(kSelectors, &result); + const std::optional unsubscribe = first.unsubscribe( + firstBaseline.value(QStringLiteral("subscription")).toString(), &result); + if (!check(resubscribe && unsubscribe + && resubscribe->code == QStringLiteral("auth.invalid") + && unsubscribe->code == QStringLiteral("auth.invalid") && result.isEmpty(), + "direct session operations must remain denied after revocation")) { + return false; + } + for (const QString& method : {QStringLiteral("hello"), QStringLiteral("capabilities.get"), + QStringLiteral("resource.get"), QStringLiteral("resource.subscribe")}) { + const ServiceReply reply = invoke(service, first, method); + if (!check(errorCode(reply) == QStringLiteral("auth.invalid") && reply.closeAfterWrite, + "revocation must be terminal for every method including renegotiation")) { + return false; + } + } + ControlSession fresh(&store, 4096, SessionAuthorization::Observer); + return check(hello(service, fresh).message.contains(QStringLiteral("result")) + && fresh.sessionId() != first.sessionId(), + "a new verified connection must get a fresh independent session"); +} + +bool testRevokeBeforeHelloAndDuringResync() +{ + ControlResourceStore store; + ControlService service(&store); + ControlSession beforeHello(&store, 4096, SessionAuthorization::Observer); + beforeHello.revokeAuthorization(); + if (!check(errorCode(hello(service, beforeHello)) == QStringLiteral("auth.invalid") + && !beforeHello.isNegotiated(), + "revocation before hello must not be undone by negotiation")) { + return false; + } + ControlSession overflowing(&store, 360, SessionAuthorization::Observer); + hello(service, overflowing); + QJsonObject baseline; + if (!check(!overflowing.subscribe(kSelectors, &baseline), + "resync test observer must subscribe")) { + return false; + } + store.upsert(kServer, {{QStringLiteral("data"), QString(1024, QLatin1Char('x'))}}); + if (!check(overflowing.sequence() == 2, "overflow must enqueue a resync notice")) { + return false; + } + overflowing.revokeAuthorization(); + return check(overflowing.takePendingFrames().isEmpty() + && errorCode(hello(service, overflowing)) == QStringLiteral("auth.invalid"), + "revocation must discard resync notices and forbid recovery on that session"); +} + +// An injected transport, not a radio peer. The session cannot reach or clear +// buffered bytes except through the same abort callback the local socket uses. +class BufferedTransport final : public QObject { +public: + void bind(ControlSession& session) + { + session.bindOutputTransport(this, [this](const QByteArray& frame) { + if (aborted) { + return false; + } + buffered.append(frame); + return true; + }, [this] { + ++abortCount; + aborted = true; + buffered.clear(); + }); + } + + void queueFlush() + { + QMetaObject::invokeMethod(this, [this] { + // Do not consult the session or suppress flushing after revocation: + // only an actual transport abort can purge this independent queue. + delivered.append(buffered); + buffered.clear(); + }, Qt::QueuedConnection); + } + + QList buffered; + QList delivered; + int abortCount{0}; + bool aborted{false}; +}; + +bool testBufferedTransportRevocation() +{ + ControlResourceStore store; + ControlService service(&store); + ControlSession first(&store, 4096, SessionAuthorization::Observer); + ControlSession second(&store, 4096, SessionAuthorization::Observer); + BufferedTransport firstTransport; + BufferedTransport secondTransport; + firstTransport.bind(first); + secondTransport.bind(second); + hello(service, first); + hello(service, second); + QJsonObject baseline; + if (!check(!first.subscribe(kSelectors, &baseline) + && !second.subscribe(kSelectors, &baseline), + "buffered observers must subscribe")) { + return false; + } + store.upsert(kServer, {{QStringLiteral("health"), QStringLiteral("buffered")}}); + QCoreApplication::sendPostedEvents(nullptr, QEvent::MetaCall); + if (!check(firstTransport.buffered.size() == 1 && secondTransport.buffered.size() == 1 + && firstTransport.delivered.isEmpty() && secondTransport.delivered.isEmpty() + && first.takePendingFrames().isEmpty(), + "production binding must hand pending observations to transport-owned buffers")) { + return false; + } + + // Both flushes predate revocation. Also leave a new session drain queued. + // A queued abort would lose to the first flush and leak the buffered frame. + firstTransport.queueFlush(); + secondTransport.queueFlush(); + store.upsert(kServer, {{QStringLiteral("health"), QStringLiteral("pending")}}); + first.revokeAuthorization(); + first.revokeAuthorization(); + if (!check(firstTransport.abortCount == 1 && firstTransport.aborted + && firstTransport.buffered.isEmpty() + && secondTransport.abortCount == 0 && secondTransport.buffered.size() == 1, + "revocation must synchronously abort and purge only its transport")) { + return false; + } + QCoreApplication::sendPostedEvents(nullptr, QEvent::MetaCall); + store.remove(kServer); + QCoreApplication::sendPostedEvents(nullptr, QEvent::MetaCall); + firstTransport.queueFlush(); + secondTransport.queueFlush(); + QCoreApplication::sendPostedEvents(nullptr, QEvent::MetaCall); + return check(firstTransport.buffered.isEmpty() && firstTransport.delivered.isEmpty() + && secondTransport.delivered.size() == 3, + "queued flush/drain and later events must not deliver revoked output"); +} + +bool testOutputTransportLifetime() +{ + ControlResourceStore store; + ControlService service(&store); + auto session = std::make_unique( + &store, 4096, SessionAuthorization::Observer); + BufferedTransport transport; + transport.bind(*session); + hello(service, *session); + QJsonObject baseline; + if (!check(!session->subscribe(kSelectors, &baseline), "lifetime observer must subscribe")) { + return false; + } + store.upsert(kServer, {{QStringLiteral("health"), QStringLiteral("pending")}}); + session.reset(); + QCoreApplication::sendPostedEvents(nullptr, QEvent::MetaCall); + if (!check(transport.buffered.isEmpty(), + "a queued drain must not dereference a destroyed session")) { + return false; + } + ControlSession revoked(&store, 4096, SessionAuthorization::Observer); + revoked.revokeAuthorization(); + BufferedTransport lateTransport; + lateTransport.bind(revoked); + return check(lateTransport.aborted && lateTransport.abortCount == 1, + "binding after revocation must abort immediately, not miss the signal"); +} + +bool testOutputCallbackStopsBatch() +{ + enum class Stop { Revoke, DestroySession, DestroyTransport, WriteFailure }; + for (const Stop stop : {Stop::Revoke, Stop::DestroySession, + Stop::DestroyTransport, Stop::WriteFailure}) { + ControlResourceStore store; + ControlService service(&store); + auto session = std::make_unique( + &store, 4096, SessionAuthorization::Observer); + auto transport = std::make_unique(); + int writes = 0; + session->bindOutputTransport(transport.get(), [&](const QByteArray&) { + ++writes; + switch (stop) { + case Stop::Revoke: session->revokeAuthorization(); break; + case Stop::DestroySession: session.reset(); break; + case Stop::DestroyTransport: transport.reset(); break; + case Stop::WriteFailure: return false; + } + return true; + }, [] {}); + hello(service, *session); + QJsonObject baseline; + if (!check(!session->subscribe({{QStringLiteral("server"), {}, {}}, + {QStringLiteral("radioSession"), {}, {}}}, &baseline), + "batch observer must subscribe to both resources")) { + return false; + } + store.upsert(kServer, {{QStringLiteral("health"), QStringLiteral("ok")}}); + store.upsert({QStringLiteral("radioSession"), {}, QStringLiteral("radio-1")}, {}); + QCoreApplication::sendPostedEvents(nullptr, QEvent::MetaCall); + if (!check(writes == 1, "a stopped transport callback must prevent the next batch write")) { + return false; + } + } + return true; +} + +} // namespace + +int main(int argc, char* argv[]) +{ + QCoreApplication app(argc, argv); + return testAuthenticationAndGrants() + && testUntrustedHelloCannotGrantAccess() + && testHelloAuthorizationPrecedesParams() + && testOutputBindingContract() + && testBufferedTransportRevocation() + && testRevocationStopsDelivery() + && testOutputTransportLifetime() + && testOutputCallbackStopsBatch() + && testRevokeBeforeHelloAndDuringResync() ? 0 : 1; +} diff --git a/tests/control_resource_service_test.cpp b/tests/control_resource_service_test.cpp index 5999286e6..7413b9ad9 100644 --- a/tests/control_resource_service_test.cpp +++ b/tests/control_resource_service_test.cpp @@ -67,7 +67,7 @@ QJsonObject invoke(ControlService* service, ControlSession* session, {QStringLiteral("method"), method}, {QStringLiteral("params"), params}}; if (method != QStringLiteral("hello")) { - request.insert(QStringLiteral("sessionId"), session->sessionId); + request.insert(QStringLiteral("sessionId"), session->sessionId()); } const QByteArray bytes = QJsonDocument(request).toJson(QJsonDocument::Compact); return service->handle(bytes, session).message; @@ -79,8 +79,8 @@ bool negotiate(ControlService* service, ControlSession* session) service, session, QStringLiteral("hello"), QStringLiteral("hello"), {{QStringLiteral("versions"), QJsonArray{1}}}); return reply.value(QStringLiteral("result")).toObject() - .value(QStringLiteral("sessionId")).toString() == session->sessionId - && session->negotiated; + .value(QStringLiteral("sessionId")).toString() == session->sessionId() + && session->isNegotiated(); } QString errorCode(const QJsonObject& response) @@ -162,8 +162,8 @@ bool testServiceSubscriptions() store.upsert(slice, {{QStringLiteral("frequencyHz"), 100}}); ControlService service(&store); - ControlSession first(&store, 4096); - ControlSession second(&store, 4096); + ControlSession first(&store, 4096, SessionAuthorization::Observer); + ControlSession second(&store, 4096, SessionAuthorization::Observer); if (!check(negotiate(&service, &first) && negotiate(&service, &second), "both clients must negotiate independent sessions")) { return false; @@ -284,7 +284,7 @@ bool testServiceSubscriptions() return false; } - ControlSession limited(&store, 4096); + ControlSession limited(&store, 4096, SessionAuthorization::Observer); if (!check(negotiate(&service, &limited), "subscription-limit client must negotiate")) { return false; @@ -312,7 +312,7 @@ bool testSubscribeSequenceBoundary() { ControlResourceStore store; ControlService service(&store); - ControlSession session(&store, 4096); + ControlSession session(&store, 4096, SessionAuthorization::Observer); if (!check(negotiate(&service, &session), "sequence-boundary client must negotiate")) { return false; @@ -358,7 +358,7 @@ bool testOverflowRequiresResync() { ControlResourceStore store; ControlService service(&store); - ControlSession session(&store, 360); + ControlSession session(&store, 360, SessionAuthorization::Observer); if (!check(negotiate(&service, &session), "overflow client must negotiate")) { return false; } @@ -430,7 +430,7 @@ bool testWireFramingIsCanonical() // the single framing newline, and nothing else. ControlResourceStore store; ControlService service(&store); - ControlSession session(&store, 4096); + ControlSession session(&store, 4096, SessionAuthorization::Observer); if (!check(negotiate(&service, &session), "framing client must negotiate")) { return false; } @@ -462,7 +462,7 @@ bool testWireFramingIsCanonical() == QStringLiteral("resource.changed") && message.value(QStringLiteral("sequence")).toInteger() == 1 && message.value(QStringLiteral("sessionId")).toString() - == session.sessionId, + == session.sessionId(), "a frame must carry the session-sequenced event envelope"); } @@ -628,7 +628,7 @@ bool testSimBackendEndToEnd() { ControlResourceStore store; ControlService service(&store); - ControlSession client(&store, 1024 * 1024); + ControlSession client(&store, 1024 * 1024, SessionAuthorization::Observer); RadioModel radio; RadioResourceAdapter adapter(&radio, &store, QStringLiteral("radio-1")); if (!check(negotiate(&service, &client), "sim observer must negotiate")) { diff --git a/tests/tests.cmake b/tests/tests.cmake index 229cd5d41..0b243ab9f 100644 --- a/tests/tests.cmake +++ b/tests/tests.cmake @@ -77,6 +77,21 @@ target_include_directories(control_protocol_codec_test PRIVATE src) target_link_libraries(control_protocol_codec_test PRIVATE Qt6::Core) add_test(NAME control_protocol_codec_test COMMAND control_protocol_codec_test) +# Socket-free session authorization and revocation; only the real protocol +# service/store/session are compiled. No sockets, radio models, or settings. +add_executable(control_authorization_test + tests/control_authorization_test.cpp + src/core/control/ControlProtocolCodec.cpp + src/core/control/ControlResourceStore.cpp + src/core/control/ControlService.cpp + src/core/control/ControlSession.cpp +) +target_include_directories(control_authorization_test PRIVATE src) +target_compile_definitions(control_authorization_test PRIVATE + AETHERSDR_VERSION="${PROJECT_VERSION}") +target_link_libraries(control_authorization_test PRIVATE Qt6::Core) +add_test(NAME control_authorization_test COMMAND control_authorization_test) + # Current-user local transport plus the first-request handshake. This test # binds the production QLocalServer socket and proves that the Stage-3 surface # grants observation only; no control or transmit capability may appear.