Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 29 additions & 0 deletions docs/aetherd-control-protocol-v1-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 12 additions & 5 deletions docs/aetherd-control-resource-v1-catalogue.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down
54 changes: 38 additions & 16 deletions src/core/control/ControlService.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

#include <QJsonArray>
#include <QSet>
#include <QUuid>

#include <cmath>

Expand Down Expand Up @@ -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"),
Expand All @@ -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};
}
Expand All @@ -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});
Expand All @@ -159,6 +169,9 @@ ServiceReply ControlService::handle(
request.id, capabilities(*session)), false};
}
if (request.method == QStringLiteral("resource.get")) {
if (const std::optional<ProtocolError> error = session->observationError()) {
return failure(request.id, *error);
}
if (const std::optional<ProtocolError> keyError = onlyKeys(
request.params, {QStringLiteral("resource")})) {
return failure(request.id, *keyError);
Expand All @@ -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<ProtocolError> error = session->observationError()) {
return failure(request.id, *error);
}
if (const std::optional<ProtocolError> keyError = onlyKeys(
request.params, {QStringLiteral("resources")})) {
return failure(request.id, *keyError);
Expand Down Expand Up @@ -209,6 +225,9 @@ ServiceReply ControlService::handle(
return {ControlProtocolCodec::successResponse(request.id, result), false};
}
if (request.method == QStringLiteral("resource.unsubscribe")) {
if (const std::optional<ProtocolError> error = session->observationError()) {
return failure(request.id, *error);
}
if (const std::optional<ProtocolError> keyError = onlyKeys(
request.params, {QStringLiteral("subscription")})) {
return failure(request.id, *keyError);
Expand Down Expand Up @@ -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},
Expand Down
3 changes: 2 additions & 1 deletion src/core/control/ControlService.h
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
125 changes: 123 additions & 2 deletions src/core/control/ControlSession.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,24 @@

#include <QJsonArray>
#include <QJsonDocument>
#include <QLoggingCategory>
#include <QPointer>
#include <QThread>
#include <QUuid>

#include <utility>

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);
Expand All @@ -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<ProtocolError> 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<bool(const QByteArray&)> writeFrame,
std::function<void()> 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<ControlSession> session(this);
const QPointer<QObject> transport(transportContext);
connect(this, &ControlSession::outputReady, transportContext,
[session, transport, writeFrame = std::move(writeFrame)] {
if (!session) {
return;
}
const QList<QByteArray> 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,
Comment thread
rfoust marked this conversation as resolved.
transportContext, abortTransport);
if (isRevoked()) {
abortTransport();
}
}

std::optional<ProtocolError> ControlSession::subscribe(
const QList<ResourceSelector>& selectors, QJsonObject* result)
{
if (const std::optional<ProtocolError> error = observationError()) {
return error;
}
if (!result || selectors.isEmpty()) {
return ProtocolError{QStringLiteral("request.invalid_params"),
QStringLiteral("resources must be a non-empty array"), {}, false};
Expand Down Expand Up @@ -58,6 +164,9 @@ std::optional<ProtocolError> ControlSession::subscribe(
std::optional<ProtocolError> ControlSession::unsubscribe(
const QString& subscriptionId, QJsonObject* result)
{
if (const std::optional<ProtocolError> error = observationError()) {
return error;
}
if (!result || subscriptionId.isEmpty()) {
return ProtocolError{QStringLiteral("request.invalid_params"),
QStringLiteral("subscription must be a non-empty string"),
Expand All @@ -82,6 +191,13 @@ std::optional<ProtocolError> ControlSession::unsubscribe(

QList<QByteArray> ControlSession::takePendingFrames()
{
if (!canObserve()) {
Comment thread
rfoust marked this conversation as resolved.
// 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<QByteArray> frames;
frames.reserve(m_pending.size());
for (const PendingMessage& pending : std::as_const(m_pending)) {
Expand All @@ -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;
Expand Down Expand Up @@ -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<qint64>(++m_sequence)},
{QStringLiteral("resource"), address.toJson()},
Expand Down Expand Up @@ -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<qint64>(++m_sequence)},
{QStringLiteral("subscriptionsInvalidated"), true}};
Expand Down
Loading
Loading