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.
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 everyexecuteenvelope 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 (
Rationalclamps,DateTimerejects — see the respective specs), so a malformed payload is a defined outcome rather than undefined behaviour. - A wire-layer message-size cap:
wire::decoderejects any envelope larger thanwire::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-bodybound. - A negotiated protocol-version handshake (
wire::kind == "hello"), opt-in and exchanged before anyexecute, 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 nosessionand predates authorization — it is orthogonal to, not a substitute for,IAuthorizer.
morph does NOT provide (the application/transport must):
- Wire-layer transport security. The
wireenvelope carries no encryption and no per-request timeout (see wire.md).RemoteServeritself 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. RunRemoteServerbehind a transport that provides TLS. There is now one wire-layer bound:wire::decoderejects any envelope whose serialized form exceedswire::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 opaquebodystring, 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 innerbodyre-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.
registerauthorization and opaque ids are both opt-in, defaulting to today's permissive behaviour. Theregisterenvelope is gated by the optionalIAuthorizer::authorizeRegisterhook (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 —RemoteServerassigns them via a keyed 64-bit Feistel permutation over an internal counter (see backend.md,RemoteServer'sdetail::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 onexecute/deregisteris enforceable via the optionalIAuthorizer::authorizeInstancehook (see The per-instance ownership hook below) — a deployer can bind each instance to the principal that registered it and reject cross-tenantexecute/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 overridesauthorizeRegisterandauthorizeInstance.- Local-path authorization.
LocalBackend::executeinstalls the session context but never calls the authorizer (the authorizer is a remote-only gate,remote.hppdispatchExecuteis the sole call site). Security-critical checks must be enforced inside the model so they hold in both modes.
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::principalon 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::metadatais 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.
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.
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.
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.
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.
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};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 byMORPH_BUILD_HMAC_EXAMPLE_LIBSODIUM, defaultONonce 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 byMORPH_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);-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.
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 });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)returnstrueonly for a token with a valid signature and unexpired claims — and, if aPolicyis supplied, one the policy admits. An invalid/absent/expired token →false→ the server replieserr "unauthorized".authenticate(ctx)returns the verifiedprincipal.RemoteServer(dispatchExecute) calls it afterauthorizesucceeds and overwritesenv.session.principalwith the verified value before building theScopedContext. So a model readingsession::current()->principalsees 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).
authorizeandauthenticateeach verify the token independently against a fresh clock reading. A token can passauthorizeand then expire in the window beforeauthenticateruns, soauthenticatereturnsnullopt. 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
AllowAllAuthorizerwhoseauthenticateinherits thenulloptdefault) 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.
detail::base64UrlDecode decodes canonically: it is a bijection over valid
tokens, so exactly one token string maps to any given byte sequence. 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).
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.
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- At
register—RemoteServerrecords an owner principal for the new instance. The owner is the verified identity of the register call:RemoteServercalls_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 rawprincipalclaim. - On
execute— after the type-levelauthorizesucceeds and the verified principal has been stamped onto the session,RemoteServerconsultsauthorizeInstance(session, modelType, actionType, modelId, ownerPrincipal). Afalsereturn replieserr "unauthorized"and the action never dispatches. - On
deregister—RemoteServerconsultsauthorizeInstance(session, {}, {}, modelId, ownerPrincipal)(empty type/action ids) before destroying the instance. Afalsereturn replieserr "unauthorized"and the instance is left intact. This is the fix forderegisterpreviously being entirely unauthorized.
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.
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: allowOn every register envelope, in order:
- Reject an empty
typeId(err "register requires a typeId") — unchanged, checked before any authorization. - Authenticate.
_authorizer->authenticate(env.session)runs and its result is stamped ontoenv.session.principal— a verified value overwrites it,nulloptclears it — exactly asdispatchExecutedoes forexecute. SoauthorizeRegister(and the owner recorded below) key on the verified identity, never the client's raw claim. authorizeRegister(env.session, typeId). Afalsereturn replieserr "unauthorized"(with the request'scallId) and no instance is created —ModelRegistryFactory::createnever runs.- Only on
truedoes the server construct the instance and recordenv.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.
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;
}
};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.
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_deviceand 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.
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.
RemoteServer is transport-agnostic, but morph ships one concrete transport —
morph::qt::QtWebSocketServer / QtWebSocketBackend — and its trust properties
matter in practice:
- TLS is available. Passing a
QSslConfigurationputs the server inQWebSocketServer::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::bindAddressdefaults toQHostAddress::LocalHost(unchanged from before). Exposing the server beyond localhost means changingbindAddress— andlisten()now guards that change: binding a non-loopback address with no TLS configuration andallowPlaintextExposureleft at its defaultfalsemakeslisten()returnfalseand log atmorph::log::LogLevel::errorinstead of silently starting a plaintext, off-host-reachable server. Passing a TLS configuration, or explicitly settingallowPlaintextExposure = true, allows the bind. This is in addition to the authorization the baseRemoteServerdoes not enforce (control messages, model ids — see the threat model). - Client peer verification is the default-safe path.
qt_tls.hppships 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), andtlsInsecureNoVerify()(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 asQtWebSocketBackend'stlsargument. See the worked example inexamples/qt_tls_client/. - Transport-level resource limits are available, opt-in.
QtWebSocketServerConfigbounds connection count (maxConnections), per-frame size (maxMessageBytes, defaulting to the wire-layerkMaxEnvelopeBytescap), 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.
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); build the client's configuration withtlsVerifyingConfig()ortlsPinnedConfig()(qt_tls.hpp) rather thantlsInsecureNoVerify(), and rely onQtWebSocketServer::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_HMACin release builds. The referencehmacSha256is 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 theMacFunctionseam (see "Recommended production wiring" above) and turn onMORPH_REQUIRE_VETTED_HMACso 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'sQtWebSocketServerConfig(maxConnections,maxMessageBytes,messagesPerSecond,handshakeTimeout/idleTimeout) cover every gap this bullet used to call out. Both default to unbounded/off, so installing neither changes anything — a deployer exposingRemoteServerpublicly should configure both. 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/deregistercan be bound to the registering principal viaauthorizeInstance, andregisteritself can be bounded viaauthorizeRegister(both above) — but every hook defaults to allow-all. TreatRemoteServeras single-trust-domain unless you install an authorizer that overridesauthorizeRegisterandauthorizeInstance. Opaque model ids (above) reduce the value of guessing an id but are not a substitute for either hook.
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.