Skip to content

Latest commit

 

History

History
824 lines (714 loc) · 46 KB

File metadata and controls

824 lines (714 loc) · 46 KB

Security & the trust model

Cross-cutting spec covering morph's trust boundaries and the authenticated session layer (session.hpp Context/IAuthorizer, session_auth.hpp SessionToken/TokenIssuer/TokenVerifier/SigningAuthorizer, and the RemoteServer enforcement points in remote.hpp). Read this before deploying a RemoteServer on anything but a trusted local socket.

Related specs: session.md (the Context/IAuthorizer types), wire.md (the envelope the session travels in), backend.md (RemoteServer/LocalBackend dispatch), error_handling.md (how a rejected request surfaces), and testing_strategy.md (the fuzz/soak/load/adversarial suites that exercise this trust model over distributions and time, rather than single-shot examples). The shipped morph::qt WebSocket transport supplies the TLS layer discussed below.

Threat model — what morph does and does not defend

morph is a typed bridge, not a security product. Its built-in guarantees are deliberately small; everything else is delegated to the transport and the application. Be explicit about the boundary:

morph provides:

  • A single choke point (IAuthorizer) consulted on every execute envelope before dispatch, on the remote path.
  • An opt-in, stateless authentication layer (session_auth.hpp): signed bearer tokens whose principal the server can verify rather than trust, and make authoritative for model code.
  • Untrusted-wire-input hardening in the value codecs (Rational clamps, DateTime rejects — see the respective specs), so a malformed payload is a defined outcome rather than undefined behaviour.
  • A wire-layer message-size cap: wire::decode rejects any envelope larger than wire::kMaxEnvelopeBytes (8 MiB) before parsing, bounding a single message's allocation and parse cost (see wire.md). This is a coarse per-message backstop only — not a rate limit, timeout, or inner-body bound.
  • A negotiated protocol-version handshake (wire::kind == "hello"), opt-in and exchanged before any execute, so an incompatible peer is refused with a clear diagnostic instead of failing per-request later (see wire.md, "Protocol version negotiation"). The handshake carries no session and predates authorization — it is orthogonal to, not a substitute for, IAuthorizer.

morph does NOT provide (the application/transport must):

  • Wire-layer transport security. The wire envelope carries no encryption and no per-request timeout (see wire.md). RemoteServer itself is transport-agnostic and sees only decoded envelopes; confidentiality and timeouts are the transport's job. The shipped Qt transport does offer TLS (see below), but a transport that does not is plaintext. Run RemoteServer behind a transport that provides TLS. There is now one wire-layer bound: wire::decode rejects any envelope whose serialized form exceeds wire::kMaxEnvelopeBytes (8 MiB) before parsing (see wire.md — "Parsing guarantees and hardening"). This caps the peak allocation and parse cost of a single message — including deeply-nested JSON smuggled inside the opaque body string, which the double-parse would otherwise only surface on the inner re-parse. It is a coarse per-message backstop, not a substitute for transport-level bounds: it does not cap the number of messages, the per-connection rate, or the inner body re-parse's own limits, so a transport that also bounds message size and rate is still recommended.
  • Authentication of the transport peer. A bearer token proves the caller holds a validly-signed token; it does not bind the token to a connection. Without transport-level TLS a stolen token can be replayed.
  • register authorization and opaque ids are both opt-in, defaulting to today's permissive behaviour. The register envelope is gated by the optional IAuthorizer::authorizeRegister hook (see The register-authorization hook below), consulted after authentication and before the instance is created; its default allows everything, so an unconfigured server still lets any client that can send envelopes create model instances. Model ids are no longer sequential — RemoteServer assigns them via a keyed 64-bit Feistel permutation over an internal counter (see backend.md, RemoteServer's detail::OpaqueIdGenerator) — but opaque ids are defence-in-depth, not authorization: a caller who independently learns a valid id can still target it. Per-instance ownership on execute/deregister is enforceable via the optional IAuthorizer::authorizeInstance hook (see The per-instance ownership hook below) — a deployer can bind each instance to the principal that registered it and reject cross-tenant execute/deregister. All three hooks (authorizeRegister, authorize, authorizeInstance) default to allow-all, so an unconfigured server still behaves as a single-trust-domain server: it is not a hardened multi-tenant public-internet server unless you install an authorizer that overrides authorizeRegister and authorizeInstance.
  • Local-path authorization. LocalBackend::execute installs the session context but never calls the authorizer (the authorizer is a remote-only gate, remote.hpp dispatchExecute is the sole call site). Security-critical checks must be enforced inside the model so they hold in both modes.

The trust boundary: Context is untrusted input

session::Context (principal, token, requestId, locale, metadata) is populated by the client and, on the remote path, deserialised verbatim from the wire envelope's session field. Every field is therefore attacker-controlled input until something verifies it:

  • Context::principal on its own is a claim, not an identity. Model code must not treat it as authenticated unless a verifying authorizer is installed (see below), which makes it authoritative.
  • Context::metadata is an unbounded map decoded from the wire; treat its size and contents as untrusted.

On the local path the same Context travels in-memory via ActionCall and is whatever the caller set — trusted to the extent the process trusts itself.

Authentication: signed bearer tokens (session_auth.hpp)

session_auth.hpp is an opt-in header (include it only when you want authentication) that turns Context::principal from a claim into a verified identity. The mechanism is a stateless signed bearer token.

Token format

A token is base64url(claimsJson) "." base64url(mac), where mac = MAC(secret, payload) and payload is the base64url claims segment. The claims are a SessionToken:

Field Meaning
principal Authenticated user/principal id.
issuedAtMs Issue time, ms since epoch. If positive, enforces a not-before check (see below); 0 (unset) disables it.
expiresAtMs Expiry, ms since epoch. Must be strictly positive — a 0/negative value is treated as already expired, never as "eternal".
roles Coarse-grained roles an authorization policy can key on.

The claims are JSON (Glaze); adding application claims is compatible because unknown fields are ignored on read. TokenIssuer::issue writes the claims with the same control-byte-escaping option morph::wire::encode uses (detail::EscapingWriteOpts, duplicated locally in session_auth.hpp rather than shared, to keep this header free of a core/ dependency): a raw ASCII control byte (0x00-0x1F) in principal/roles — most plausibly one embedded in an application-supplied identity string — round-trips through TokenVerifier::verify instead of producing JSON that either fails to decode outright or, when the same string also holds an escaped \/", is silently corrupted before signing. issue throws TokenIssuanceError on encode failure, though SessionToken's flat shape (strings/integers only) makes that not realistically reachable in practice.

Expiry is mandatory — expiresAtMs == 0 is expired, not eternal

TokenVerifier::verify requires a strictly-positive expiresAtMs. A token whose expiry is 0 (the struct default) or negative is rejected with AuthError::Expired, exactly as if its expiry were in the past. This closes the gap where a default-constructed or zeroed token would otherwise be an unbounded bearer credential: there is no "never expires" mode, so every valid token carries a real deadline. When minting a token you must set expiresAtMs to a future timestamp (nowMs() + lifetimeMs); the login-flow example below does.

Not-before / issued-at check

If issuedAtMs is positive, verify also rejects a token whose issue time is more than kClockSkewMs (60s) in the future, returning AuthError::NotYetValid — a token minted against a clock ahead of the verifier's beyond the tolerated skew is not yet valid. The 60s tolerance keeps a token minted a moment ahead of the verifier's clock from being spuriously rejected. An unset (0) or non-positive issuedAtMs skips this check — issue time is optional and purely informational when omitted.

The MAC primitive is pluggable

using MacFunction = std::function<std::string(std::string_view key, std::string_view message)>;

MacFunction returns the raw MAC bytes. The default is hmacSha256, a self-contained reference HMAC-SHA256 (so morph has no crypto dependency), verified against the FIPS 180-4 / RFC 4231 test vectors in tests/test_session_auth.cpp. The reference implementation is correct but is not hardened (no side-channel engineering beyond a constant-time MAC compare); security-sensitive deployments should inject a vetted library's HMAC:

morph::session::MacFunction mac =
    [](std::string_view key, std::string_view msg) { return myLibsodiumHmac(key, msg); };
morph::session::SigningAuthorizer authz{sharedSecret, mac};

Recommended production wiring: a vetted library (libsodium / OpenSSL)

Two ready-to-copy adapters ship under examples/vetted_hmac/ (built only when -DMORPH_BUILD_EXAMPLES=ON -DMORPH_BUILD_HMAC_EXAMPLES=ON, each with its own sub-option so a host missing one library can still build the other):

  • libsodium (examples/vetted_hmac/libsodium_adapter.hpp, gated by MORPH_BUILD_HMAC_EXAMPLE_LIBSODIUM, default ON once the parent option is on):

    morph::session::MacFunction sodiumHmacSha256 =
        [](std::string_view key, std::string_view msg) -> std::string {
            unsigned char out[crypto_auth_hmacsha256_BYTES];
            crypto_auth_hmacsha256_state st;
            crypto_auth_hmacsha256_init(&st,
                reinterpret_cast<const unsigned char*>(key.data()), key.size());
            crypto_auth_hmacsha256_update(&st,
                reinterpret_cast<const unsigned char*>(msg.data()), msg.size());
            crypto_auth_hmacsha256_final(&st, out);
            return std::string(reinterpret_cast<char*>(out), sizeof out);
        };
  • OpenSSL (examples/vetted_hmac/openssl_adapter.hpp, gated by MORPH_BUILD_HMAC_EXAMPLE_OPENSSL):

    morph::session::MacFunction opensslHmacSha256 =
        [](std::string_view key, std::string_view msg) -> std::string {
            unsigned char out[EVP_MAX_MD_SIZE];
            unsigned int outLen = 0;
            HMAC(EVP_sha256(), key.data(), static_cast<int>(key.size()),
                 reinterpret_cast<const unsigned char*>(msg.data()), msg.size(), out, &outLen);
            return std::string(reinterpret_cast<char*>(out), outLen);
        };

Both adapters return the same raw MAC bytes as hmacSha256 on every RFC 4231 vector (examples/vetted_hmac/test_libsodium_adapter.cpp, test_openssl_adapter.cpp) and interoperate with it in both directions — a token issued with the reference impl verifies under either adapter and vice versa — so swapping the injected MacFunction is drop-in. Wiring is the same constructor argument shown above:

auto authz = std::make_shared<morph::session::SigningAuthorizer>(sharedSecret, sodiumHmacSha256);

MORPH_REQUIRE_VETTED_HMAC — failing the build on the reference default

-DMORPH_REQUIRE_VETTED_HMAC=ON (default OFF) removes the mac = hmacSha256 default argument from TokenIssuer, TokenVerifier, and SigningAuthorizer (via #ifdef in session_auth.hpp), so any construction that relied on the default fails to compile — the deployer must pass an explicit MacFunction (e.g. one of the two adapters above). The option is opt-in and keys on a build configuration the deployer chooses, not on CMAKE_BUILD_TYPE: a zero-dependency local/low-stakes deployment can keep shipping the reference impl by leaving it off. It has no effect on code that already passes a MacFunction explicitly.

The guard is a deployment switch aimed at application call sites, so it does not apply to morph's own suite: several test files (test_session_auth.cpp, test_security_fixes.cpp, test_policy_hardening.cpp, test_register_authorization.cpp) deliberately exercise the reference hmacSha256 through its default argument, and tests/CMakeLists.txt undoes the inherited definition for the morph_tests target alone (a -U in that target's compile options, which CMake expands after the interface -D). MORPH_BUILD_TESTS=ON and MORPH_REQUIRE_VETTED_HMAC=ON therefore compose: CI builds and tests exactly that combination, which is what keeps the option from rotting. The guard's own correctness — that it blocks the default, still allows an explicit MacFunction, and is a no-op when off — is proven at configure time by three try_compile checks in tests/CMakeLists.txt (see "Testing" below), which set or omit the macro per probe and so run independently of both the top-level option's value and that -U.

Issuing tokens — the login flow

morph ships no Login action; login is an ordinary application action. The app validates credentials however it likes, then mints a token with TokenIssuer:

// server side, inside a Login action's handler:
morph::session::TokenIssuer issuer{sharedSecret};   // default hmacSha256
LoginResult execute(const Login& a) {
    if (!checkPassword(a.user, a.password)) throw std::runtime_error("bad credentials");
    return { .token = issuer.issue({ .principal = a.user,
                                     .issuedAtMs  = nowMs(),
                                     .expiresAtMs = nowMs() + 15 * 60'000,   // 15 min
                                     .roles = rolesFor(a.user) }) };
}

The client attaches the returned token to every subsequent call via the default session:

bridge.setDefaultSession({ .principal = user, .token = result.token });

Verifying tokens and making the principal authoritative

Install a SigningAuthorizer on the server; it verifies the token on every execute:

auto authz  = std::make_shared<morph::session::SigningAuthorizer>(sharedSecret);
// The authorizer is the *second* constructor argument (dispatcher/registry follow
// and default to the process singletons):
auto server = std::make_shared<morph::backend::RemoteServer>(pool, authz);

SigningAuthorizer implements both IAuthorizer entry points:

  • authorize(ctx, modelType, actionType) returns true only for a token with a valid signature and unexpired claims — and, if a Policy is supplied, one the policy admits. An invalid/absent/expired token → false → the server replies err "unauthorized".
  • authenticate(ctx) returns the verified principal. RemoteServer (dispatchExecute) calls it after authorize succeeds and overwrites env.session.principal with the verified value before building the ScopedContext. So a model reading session::current()->principal sees the authenticated identity, not the client's claim.

The principal is never passed through unverified. When authenticate(ctx) returns nullopt — the authorizer cannot vouch for the caller — dispatchExecute clears env.session.principal to the empty string before dispatch rather than leaving the client-supplied claim in place. Two cases depend on this:

  • TOCTOU (time-of-check/time-of-use). authorize and authenticate each verify the token independently against a fresh clock reading. A token can pass authorize and then expire in the window before authenticate runs, so authenticate returns nullopt. Without the clear, the request would have been dispatched carrying the client's asserted principal as if it were authoritative. With the clear, the worst case is an empty principal — never the attacker's chosen value.
  • Authorize-only / allow-all authorizers. An authorizer that admits calls but never authenticates (a custom authorize-only policy, or the default AllowAllAuthorizer whose authenticate inherits the nullopt default) now results in an empty principal at the model. This preserves the "authentication is optional" contract — the call still dispatches — while ensuring an unauthenticated principal is never presented to model code as trustworthy. Apps that want a trusted principal must install a verifying authorizer (SigningAuthorizer).

TokenVerifier::verify checks the MAC before parsing the claims JSON, so untrusted input is never handed to the parser until authenticity is established, and uses a constant-time comparison (detail::constantTimeEquals) to avoid MAC timing leaks. It returns std::expected<SessionToken, AuthError>:

AuthError Cause
Malformed Not payload.sig, bad/non-canonical base64url, or unparseable claims.
BadSignature MAC mismatch — forged or tampered.
Expired expiresAtMs is missing/non-positive, or in the past relative to the supplied clock.
NotYetValid issuedAtMs is set and more than kClockSkewMs (60s) in the future.

The clock is injectable (Clock, defaulting to systemClockMs) so expiry is testable without wall-clock dependence.

Canonical base64url — no signature malleability

detail::base64UrlDecode decodes canonically: it is a bijection over valid tokens, so exactly one token string maps to any given byte sequence. It is session_auth.hpp's own, and deliberately not core-cpp's core::base64, which morph::net's WebSocket handshake uses: the handshake only encodes, with the standard alphabet, whereas a token decoder must refuse every non-canonical input below, a promise core::base64::decode does not make. base64url is a bit-oriented encoding, and a naive decoder that silently discards the leftover bits of the final symbol would let several distinct strings decode to the same MAC — a token-string malleability that lets an attacker perturb the trailing character without invalidating the signature. The decoder rejects such input:

  • A length % 4 == 1 (impossible for real base64url output) is rejected.
  • The leftover bits that do not form a whole output byte (2 bits for a 1-byte-remainder group, 4 bits for a 2-byte-remainder group) must be zero; a nonzero remainder is a non-canonical encoding and is rejected rather than truncated.

Both signature and payload segments are decoded through this path, so a mutated trailing character in either segment fails verification (Malformed, or BadSignature if it survives decoding but changes the MAC).

Role-based policy

SigningAuthorizer's optional Policy runs over the verified claims:

morph::session::SigningAuthorizer authz{
    secret, morph::session::hmacSha256, morph::session::systemClockMs,
    [](const morph::session::SessionToken& t, std::string_view modelType, std::string_view actionType) {
        return std::ranges::find(t.roles, "admin") != t.roles.end();  // admin-only
    }};

The default (no policy) admits any validly-signed, unexpired token.

The per-instance ownership hook (authorizeInstance)

authorize sees only the model type, so it cannot answer "may this caller touch this instance?". Because model instances on a RemoteServer are addressable by guessable sequential ids, without an instance check any authenticated caller can execute/deregister against an id it did not create — a cross-tenant targeting gap. IAuthorizer closes it with an optional third method:

[[nodiscard]] virtual bool authorizeInstance(
    const Context& ctx,
    std::string_view modelType,      // empty for deregister
    std::string_view actionType,     // empty for deregister
    std::uint64_t modelId,
    std::string_view ownerPrincipal  // recorded at register time; empty if none
) const { return true; }             // DEFAULT: allow

How ownership is recorded and enforced

  • At register — RemoteServer records an owner principal for the new instance. The owner is the verified identity of the register call: RemoteServer calls _authorizer->authenticate(env.session) and stores the returned principal (empty if the authorizer does not authenticate, e.g. allow-all, so the instance is unowned). It is never the client's raw principal claim.
  • On execute — after the type-level authorize succeeds and the verified principal has been stamped onto the session, RemoteServer consults authorizeInstance(session, modelType, actionType, modelId, ownerPrincipal). A false return replies err "unauthorized" and the action never dispatches.
  • On deregister — RemoteServer first stamps the verified principal onto the session, exactly as for execute, then calls authorize(session, {}, {}) (a deregister names no type, so both ids are empty), then consults authorizeInstance(session, {}, {}, modelId, ownerPrincipal) before destroying the instance. A false from either replies err "unauthorized" and the instance is left intact. The stamp is what makes the ownership comparison sound: without it, a caller could write the owner's name into principal and release the owner's instance with any token, or none.
  • On cancel — RemoteServer stamps the verified principal onto the cancel's session and looks the target execute up only among the calls of the connection the cancel arrived on. It then requires the cancel's verified principal to equal the one the execute was admitted under, and runs the execute's own authorize(session, modelType, actionType) and authorizeInstance(session, modelType, actionType, modelId, ownerPrincipal) as the cancel's caller. Only then is the call stopped. Every refusal answers the same ok an unknown or finished call gets, so a cancel cannot probe for other callers' calls. See wire.md.

Backward compatibility

The default authorizeInstance returns true, so an authorizer that does not override it — including AllowAllAuthorizer and a plain SigningAuthorizer — imposes no per-instance restriction and the server behaves exactly as before. A deployer opts into enforcement by overriding the hook, typically comparing the recorded owner against the authenticated caller:

struct OwnershipAuthorizer : morph::session::SigningAuthorizer {
    using SigningAuthorizer::SigningAuthorizer;
    bool authorizeInstance(const morph::session::Context& ctx, std::string_view,
                           std::string_view, std::uint64_t,
                           std::string_view ownerPrincipal) const override {
        // Unowned instances stay open; owned instances only to their owner.
        return ownerPrincipal.empty() || ownerPrincipal == ctx.principal;
    }
};

Because the owner is captured from the verified principal at register time, this is only meaningful with a verifying authorizer installed; with allow-all every instance is unowned and the hook (if overridden as above) admits all. Register itself remains type-unauthorized — bounding who may create instances is still the transport's/app's responsibility.

The register-authorization hook (authorizeRegister)

authorize and authorizeInstance both act on an instance that already exists; neither can answer "may this caller create one at all?". IAuthorizer closes that gap with a fourth optional method:

[[nodiscard]] virtual bool authorizeRegister(
    const Context& ctx,
    std::string_view modelType
) const { return true; }             // DEFAULT: allow

Enforcement order in RemoteServer

On every register envelope, in order:

  1. Reject an empty typeId (err "register requires a typeId") — unchanged, checked before any authorization.
  2. Authenticate. _authorizer->authenticate(env.session) runs and its result is stamped onto env.session.principal — a verified value overwrites it, nullopt clears it — exactly as dispatchExecute does for execute. So authorizeRegister (and the owner recorded below) key on the verified identity, never the client's raw claim.
  3. authorizeRegister(env.session, typeId). A false return replies err "unauthorized" (with the request's callId) and no instance is created — ModelRegistryFactory::create never runs.
  4. Only on true does the server construct the instance and record env.session.principal (already verified) as its owner, exactly as before.

handleInline — the synchronous control path SimulatedRemoteBackend uses for register — runs through the same dispatchMessage code path, so the gate holds identically on both entry points.

Composing with authorizeInstance

authorizeRegister and authorizeInstance answer different questions: registration decides whether an instance may be created and by whom; the owner recorded at that same register call then drives per-instance execute/deregister decisions. A deployer typically installs both on one authorizer subclassing SigningAuthorizer:

struct TenantAuthorizer : morph::session::SigningAuthorizer {
    using SigningAuthorizer::SigningAuthorizer;
    bool authorizeRegister(const morph::session::Context& ctx, std::string_view) const override {
        return !ctx.principal.empty();  // only authenticated callers may register
    }
    bool authorizeInstance(const morph::session::Context& ctx, std::string_view, std::string_view,
                           std::uint64_t, std::string_view ownerPrincipal) const override {
        return ownerPrincipal.empty() || ownerPrincipal == ctx.principal;
    }
};

Backward compatibility

The default returns true. AllowAllAuthorizer and a plain SigningAuthorizer (neither overrides authorizeRegister) impose no register restriction, so an unconfigured server registers any known model type exactly as before. Register over the local path is unaffected — there is no authorizer on LocalBackend; its factory closure constructs the instance directly.

Opaque model ids

RemoteServer no longer assigns model ids from a bare sequential counter. Each id is now the result of running an internal monotonic counter through morph::backend::detail::OpaqueIdGenerator — a 4-round Feistel network over the 64-bit space, keyed once at construction from std::random_device (see backend.md for the construction). Two properties matter:

  • Uniqueness is unconditional. A Feistel network is a bijection over its full domain for any round function, so distinct counter values always produce distinct ids — there is no collision risk short of the practically-unreachable 2^64 counter wraparound.
  • Opacity depends on the key, not on the algorithm being secret. The per-round keys are drawn once from std::random_device and never exposed; without them, an observed id cannot be inverted to recover the counter or predict the next one. An unkeyed public mixing function would not have this property — anyone who reads the (public) source could invert it.

Opaque ids are defence-in-depth, not the authorization boundary. They remove cheap sequential enumeration (1, 2, 3, …) as an attack, but a caller who independently learns a valid id — its own prior register, a leaked log line, a referrer header — can still target it; authorizeInstance (above) is what actually decides whether that targeting is allowed. A deployer relying on id opacity instead of an ownership-enforcing authorizer has not closed the cross-tenant gap, only made it more expensive to find.

The default is fail-open — change it in production

RemoteServer's ordinary constructor defaults to allowAllAuthorizer(), and the explicit-authorizer constructor silently falls back to allow-all on a nullptr argument. An unconfigured server therefore authorizes everything. This is convenient for local/simulated development and wrong for production. Always install a SigningAuthorizer (or a deny-by-default custom authorizer) before exposing a server, and never pass nullptr.

Transport security: the shipped WebSocket transports

RemoteServer is transport-agnostic, but morph ships two concrete transports, and they do not have the same trust properties. Read the one you actually build against: everything below about QtWebSocketServerConfig is a property of the Qt transport alone, and a deployer who picked morph::net because it needs no Qt gets none of it.

The Qt WebSocket transport (morph::qt)

morph::qt::QtWebSocketServer / QtWebSocketBackend:

  • TLS is available. Passing a QSslConfiguration puts the server in QWebSocketServer::SecureMode (wss://) and the client into a TLS socket. Absent a config, both run plaintext (ws://). TLS here provides the transport confidentiality and peer authentication the wire layer does not — this is the intended way to protect bearer tokens against capture and replay.
  • The server binds to loopback by default, and refuses silent plaintext exposure. QtWebSocketServerConfig::bindAddress defaults to QHostAddress::LocalHost (unchanged from before). Exposing the server beyond localhost means changing bindAddress — and listen() now guards that change: binding a non-loopback address with no TLS configuration and allowPlaintextExposure left at its default false makes listen() return false and log at morph::log::LogLevel::error instead of silently starting a plaintext, off-host-reachable server. Passing a TLS configuration, or explicitly setting allowPlaintextExposure = true, allows the bind. This is in addition to the authorization the base RemoteServer does not enforce (control messages, model ids — see the threat model).
  • Client peer verification is the default-safe path. qt_tls.hpp ships three factory helpers: tlsVerifyingConfig() (verify against the system/CA trust store — the recommended production default), tlsPinnedConfig(cert) (verify against one pinned certificate — the correct choice for a self-signed deployment), and tlsInsecureNoVerify() (QSslSocket::VerifyNone — encrypts but does not authenticate the server, so it is MITM-vulnerable; local development and tests only, named so it can be grepped for in a security review). Pass the result of one of these as QtWebSocketBackend's tls argument. See the worked example in examples/qt_tls_client/.
  • Transport-level resource limits are available, opt-in. QtWebSocketServerConfig bounds connection count (maxConnections), per-frame size (maxMessageBytes, defaulting to the wire-layer kMaxEnvelopeBytes cap), per-connection message rate (messagesPerSecond, token bucket), and handshake/idle time (handshakeTimeout/idleTimeout). All default to unbounded/disabled, so an unconfigured server behaves exactly as before. See backend.md.

The Qt-free reference transport (morph::net)

morph::net::SocketServer / SocketBackend (MORPH_BUILD_NET=ON, off by default) speak the same RFC 6455 framing over raw POSIX sockets. They are a reference transport for a trusted network, and the difference from the Qt transport above is not a matter of degree:

  • There is no TLS, and no way to add one. SocketBackend/SocketServer speak plaintext ws:// only; parseWsUrl throws immediately on a wss:// URL. Bearer tokens and payloads therefore travel in the clear on every connection, so a captured token can be replayed until it expires. Nothing in this transport can satisfy the "use TLS and verify the peer" item in the checklist below — that item needs the Qt transport, or a TLS-terminating proxy in front of SocketServer.

  • There are no transport-level resource limits. SocketServerConfig has exactly one field (backlog). There is no connection cap, no per-frame size limit beyond the wire layer's own kMaxEnvelopeBytes, no message rate limit, and no handshake or idle timeout — the four things QtWebSocketServerConfig offers. An unauthenticated peer can therefore hold connections open, and open as many as the process has descriptors for. RemoteServer::LimitPolicy still applies (it sits above the transport), so executeTimeout, maxLiveModels and maxInFlightExecutes are available here; the connection-level limits are not. SocketBackendConfig's connectTimeout/handshakeTimeout/sendTimeout are not a counter-example: they bound the client's own I/O loop against a stalled server, and give a SocketServer nothing against a stalled client.

  • The server is loopback-only, and not by configuration. SocketServer::listen() binds 127.0.0.1 unconditionally — there is no bindAddress, so there is also no equivalent of the Qt transport's plaintext-exposure guard, because there is nothing to expose. Reaching a SocketServer from another host means deliberately fronting it with a proxy, which puts the confidentiality and peer-authentication decision in that proxy.

  • Frame-level input validation is strict. WsFrameReader enforces RFC 6455's masking rule in both directions, rejects RSV bits, reserved opcodes, oversized or fragmented control frames, invalid Close status codes, non-minimal length encodings, and invalid UTF-8 in text messages and Close reasons, and the outgoing mask key is drawn per frame from a thread-local std::random_device. A frame that fails any of these drops the connection (without a Close status code — see backend.md). This is the one area where the two transports are comparable; it is also the only one.

  • Socket errors are rendered with a thread-safe formatter. Every throw site in net/detail/tcp_socket.hpp builds its message on whichever thread hit the error. This transport's sockets run on an exec::IoLoop's thread, but an application may run several loops, and the socket helpers can be called from the caller's own thread, so more than one thread can be rendering an errno at the same moment. They go through std::system_category().message(), which returns an owned std::string and carries the library's ordinary "shall not introduce a data race" guarantee, rather than std::strerror, which is permitted to hand every caller a pointer to one shared static buffer. Stated precisely, because the distinction matters: what this avoids is the data race the specification of std::strerror permits, inferred from that specification rather than observed. No interleaved or corrupted message has been seen, and on the glibc/Linux configuration this project tests, the two spellings render an errno to identical bytes. The property gained is that the guarantee holds by specification rather than by the implementation happening to be safe. TcpSocket::connect's ::gai_strerror is deliberately left as it is, for two reasons that are worth keeping apart. The first is that no substitution exists: it renders EAI_* resolver codes, which are not errno values, so std::system_category().message() would describe them confidently and wrongly. The second is that it does not have std::strerror's defect in the first place — measured, not assumed. On glibc 2.44 it returns a pointer to a string literal in libc's own read-only mapping, distinct per code and stable across calls:

    EAI_AGAIN ptr=0x7fa71e9b62f2 "Temporary failure in name resolution"
    EAI_FAIL  ptr=0x7fa71e9b632e "Non-recoverable failure in name resolution"
    dladdr -> /usr/lib/libc.so.6, anonymous (a literal in a data section)
    7fa71e99f000-7fa71ea15000 r--p ... /usr/lib/libc.so.6
    

    The r--p is the load-bearing part: a shared scratch buffer of the kind std::strerror is permitted to return would have to be writable. Even an unrecognised code yields a constant ("Unknown error") rather than a formatted one, so there is no per-call buffer on any path. The musl implementation the WASM build links is a static const char msgs[] table by inspection, with the same property, and TcpSocket is POSIX-only, so Winsock's documented-unsafe gai_strerrorA is never compiled. What is not established: no other libc was checked, and POSIX itself is not read as requiring this — so the entry is a measurement on the configurations morph builds, not a portability guarantee. Re-check if a platform with a different libc joins CI.

Residual limitations & hardening checklist

Even with SigningAuthorizer installed, the following remain the deployer's responsibility:

  • Use TLS and verify the peer — now the documented default. Bearer tokens and payloads travel in plaintext otherwise, and a captured token can be replayed until it expires. There is no envelope-level confidentiality or replay protection. The Qt transport supports wss:// (above) — the morph::net transport does not support it at all; build the client's configuration with tlsVerifyingConfig() or tlsPinnedConfig() (qt_tls.hpp) rather than tlsInsecureNoVerify(), and rely on QtWebSocketServer::listen()'s exposure guard to catch an accidental plaintext off-host bind.
  • Keep expiry short and rotate the secret. A leaked secret forges any identity; a leaked token is valid until expiresAtMs.
  • Inject a vetted HMAC and enable MORPH_REQUIRE_VETTED_HMAC in release builds. The reference hmacSha256 is correct (RFC 4231/FIPS 180-4 test-vector-verified) but is not side-channel-hardened beyond the constant-time MAC compare. Wire in libsodium or OpenSSL via the MacFunction seam (see "Recommended production wiring" above) and turn on MORPH_REQUIRE_VETTED_HMAC so a build that forgets to inject one fails to compile rather than shipping the reference impl silently.
  • Bound message size, rate, and add timeouts — now available, still opt-in. RemoteServer::LimitPolicy (executeTimeout, maxLiveModels, maxInFlightExecutes) and the Qt transport's QtWebSocketServerConfig (maxConnections, maxMessageBytes, messagesPerSecond, handshakeTimeout/idleTimeout) cover every gap this bullet used to call out on the Qt transport. Both default to unbounded/off, so installing neither changes anything — a deployer exposing RemoteServer publicly should configure both. On morph::net only the LimitPolicy half exists: SocketServerConfig has no connection, size, rate or timeout limits at all (above). See backend.md.
  • Do not rely on the authorizer for correctness inside models. It runs only on the remote path and only for execute. Enforce invariants in the model so they also hold locally and for control messages.
  • All three authorization hooks are opt-in. execute/deregister can be bound to the registering principal via authorizeInstance, and register itself can be bounded via authorizeRegister (both above) — but every hook defaults to allow-all. Treat RemoteServer as single-trust-domain unless you install an authorizer that overrides authorizeRegister and authorizeInstance. Opaque model ids (above) reduce the value of guessing an id but are not a substitute for either hook.

Testing

tests/test_session_auth.cpp covers the SHA-256/HMAC known-answer vectors, base64url round-tripping, token issue/verify, and rejection of tampering, wrong secret, expiry, and malformed input, plus SigningAuthorizer authorization, the no-token denial path, and role-policy enforcement.

tests/test_policy_hardening.cpp covers the policy fixes in this spec: a token with expiresAtMs == 0 or negative is rejected as Expired (never eternal) and one with a real positive expiry still verifies; a token issued far in the future is rejected NotYetValid while one within the 60s skew (or with an unset issuedAtMs) is accepted; base64UrlDecode rejects impossible lengths and non-canonical trailing bits, and a token with a mutated trailing signature character fails; and, with an ownership authorizer installed, principal B cannot execute or deregister principal A's instance while A can, whereas with the default authorizer any principal can (backward compatible).

tests/test_register_authorization.cpp covers authorizeRegister: an authorizer denying a specific model type (or an unauthenticated caller) receives err "unauthorized" on register and creates no instance — a subsequent execute against an arbitrary id still reports err "model not found"; the default authorizer and a plain SigningAuthorizer (neither overrides the hook) continue to register any known type, unchanged from before.

tests/test_opaque_model_ids.cpp covers the id-opacity change directly: unit tests on morph::backend::detail::OpaqueIdGenerator confirm it is a bijection (20000 counters → 20000 distinct outputs), that its output is not sequential, and that two independently-constructed instances (independent random keys) disagree on the same counter; integration tests against RemoteServer confirm two successive registers return non-adjacent ids, a 2000-round register churn produces zero collisions, and a returned id still round-trips through execute/deregister (the only contract ids ever guaranteed — no test asserts a literal id value).

The test TLS material in tests/certs/ (server.crt/server.key and mitm.crt/mitm.key, used only by tests/qt/test_qt_websocket.cpp) is a pair of throwaway self-signed pairs with the deliberately loud CNs MORPH-TEST-DO-NOT-USE and MORPH-TEST-MITM-DO-NOT-USE. Both carry a subjectAltName=IP:127.0.0.1 extension so Qt's hostname check passes when connecting to the loopback address the tests use. Their private keys are committed in plaintext and must be assumed public — they grant no trust anywhere and must never be used in production or copied elsewhere. See tests/certs/README.md.

tests/test_server_limits.cpp exercises the untrusted-input hardening claim: a 1 MiB action payload round-trips intact, a 5000-deep nested-JSON envelope and a lone-continuation-byte (malformed UTF-8) body each produce a defined ok/err reply rather than a crash or hang, and a 200-round register/deregister churn completes cleanly. These confirm resilience; the payloads there stay under the wire-layer size cap.

tests/test_wire_hardening.cpp covers the wire-layer parsing guarantees directly: wire::decode accepts an envelope under kMaxEnvelopeBytes, rejects an oversized one (including one whose bulk is deeply-nested JSON inside the opaque body string) with std::runtime_error before parsing, and — pinning the honest, non-guaranteed behavior — accepts duplicate JSON keys (top-level and nested session) with last-wins rather than rejecting them, since glaze 7.2.1 offers no option to error on duplicates. A per-request timeout and a rate/ message-count cap are now available via LimitPolicy and QtWebSocketServerConfig (both opt-in — see above).

tests/qt/test_qt_websocket.cpp (with tests/certs/server.crt/server.key and tests/certs/mitm.crt/mitm.key) covers the TLS transport: wss:// request/reply, TLS error propagation, refusal of a plaintext client against a wss:// server, a cross-process TLS handshake, tlsPinnedConfig accepting the real server and rejecting one presenting a different certificate, the exposure guard on QtWebSocketServer::listen() (non-loopback + no TLS refuses; allowPlaintextExposure or a TLS configuration allows it; loopback + no TLS is unaffected), and tlsInsecureNoVerify connecting to both — pinning the contrast the "Transport security" section above describes.

tests/test_limit_policy.cpp covers LimitPolicy (maxLiveModels, maxInFlightExecutes, executeTimeout including the once-flag discard of a late strand result and TimeoutError surfacing through SimulatedRemoteBackend); tests/qt/test_qt_websocket.cpp covers QtWebSocketServerConfig (maxConnections, maxMessageBytes, messagesPerSecond, handshakeTimeout/idleTimeout) and their composition with LimitPolicy.

examples/vetted_hmac/test_libsodium_adapter.cpp and test_openssl_adapter.cpp (built only with MORPH_BUILD_HMAC_EXAMPLES=ON and their respective sub-option) cover the vetted-HMAC adapters: byte-identical output to hmacSha256 on the RFC 4231 vectors, issue/verify interop in both directions, and SigningAuthorizer authorize/reject parity with the reference impl. tests/CMakeLists.txt additionally runs three try_compile checks proving the MORPH_REQUIRE_VETTED_HMAC default-argument guard: it blocks a construction relying on the default, still allows one with an explicit MacFunction, and leaves the default working when the option is off.

Four opt-in suites generalise this coverage from single-shot examples to distributions of input and time: tests/fuzz/fuzz_wire_decode.cpp and tests/fuzz/fuzz_dispatch_execute.cpp (MORPH_BUILD_FUZZERS=ON) coverage-fuzz wire::decode, the inner body re-parse, and RemoteServer::dispatchMessage; tests/soak/ (MORPH_BUILD_LOAD_TESTS=ON) cycles switchBackend and the NetworkMonitor/ReconnectCoordinator/SyncWorker pipeline for hundreds of cycles, checking resource stability rather than a single transition; tests/bench/bench_dispatch_latency.cpp baselines dispatch throughput/latency; and tests/qt/test_qt_websocket_adversarial.cpp drives a hostile client (oversized frames, a message flood, a duplicate-key envelope, a stalled connection) against a real QtWebSocketServer and confirms honest clients are unaffected. See testing_strategy.md.