Repository navigation
External auth: verify the gateway's actor context header - #597
Conversation
With auth.external.gateway_jwks_file set, external mode checks the X-Nerve-Actor-Context JWS before it acts as the person: the gateway's Ed25519 signature with a key from that local JWK Set file, the tenant, agent, signer and audience, the method and target of the request, the issue and expiry times, and one use per assertion. An HTTP request is checked when it arrives, before its body is read. A failed check gives 401, a missing key set 503, and verification: log only logs the failure. Without the setting, requests are handled as before; nerve doctor warns.
|
Nerve PR review · ✅ Ready to merge This closes the gap that #482 left open in external mode: anything that could reach the port could send an unsigned Completeness: 64 new test functions (vectors, replay, slow upload, two headers, real Checked: full suite in an offline sandbox at |
What
With
NERVE_AUTH_MODE=externaland the newauth.external.gateway_jwks_filesetting, Nerve verifies the gateway'sX-Nerve-Actor-Contextheader before it acts as the person that the header names. It checks the Ed25519 signature with a key from a local public key file, and the claims against this instance and this request. An HTTP request is checked when it arrives, before its body is read. Without the setting, requests are handled as before: Nerve decodes the header without a check, as it has since #482, and the startup warning stays;nerve doctornow warns about it.#482 decodes the header in
decode_actor_context()and does not check the signature. This PR keeps that function as the decoder and puts the check in front of it: every reader of the header now callsread_actor_context().How
Configuration
NERVE_AUTH_MODE. An absentauth.externalsection means no check.ExternalAuthConfig.problems()followsHostedChannelsConfig.problems(): an absolute path, canonical lower-case UUIDs, andrequireorlog.nerve config validateknows the keys, and refuses.as the path like the other path settings.Verifier (
nerve/gateway/actor_context.py, built like the stream token verifier innerve/channels/hosted/auth.py)ktyOKP,crvEd25519,algEdDSA,usesig, a uniquekid), all required. Nerve reads onlykeysand ignores the other members. A private key member refuses the whole file. Nerve reads the file at startup and on a config reload, and again, at most once a minute, when a header names an unknownkid. A later read that fails keeps the last good keys. Nerve never fetches keys over the network.cryptography'sEd25519PublicKey. The protected header must havetypnerve-actor-context+jws,algEdDSAand a knownkid, and nojwk,jku,x5c,x5uorcrit.version"1";tenant_idandagent_idequal the configured IDs;signer_idandaudienceequalurn:nerve:gateway:actor:<tenant>:<agent>andurn:nerve:runtime:<tenant>:<agent>;actor_kindhuman;request_binding.kindishttp, orwebsocketwith agateway_connection_id;methodandtargetequal the request that Nerve received (the raw path, and?and the query when there is one; without a query/path?also matches, because the server drops a bare?);issued_atat most 30 seconds ahead,expires_atat most 30 seconds behind, and a lifetime of 1 to 30 seconds.assertion_idis accepted once. Nerve keeps accepted IDs in memory until they expire, at most 10,000. When the cache is full, it forgets every expired ID first, then the oldest.read_actor_context()returns whatdecode_actor_context()returns, soprincipal_idhandling and the data model do not change. The schema trigger still refuses the system actor's ID. The verifier module does not importnerve/gateway/auth.py.Requests
require_auth, so a check there would refuse an upload whose transfer outlasts the assertion.create_app()therefore adds a small ASGI middleware in external mode,ActorContextMiddleware, that checks a request with exactly one header when it arrives, before the body is read, and leaves the outcome in the scope state.read_actor_context()takes that outcome, so the assertion is used once, at arrival. An app without the middleware checks inrequire_auth.401with a fixed detail. The reason, thekidand the request ID go to the log, never the header. No usable key set (a file that cannot be read or used, or an invalidauth.externalsetting) gives503./ws: an upgrade has no body and is checked when the socket is accepted. The server accepts the socket before it authenticates it, so both cases close it with4001, like the other refusals.verification: loglogs the failure and uses the decoded header. It is meant for a first rollout.Startup, reload and doctor
create_app()keeps the warning when the key file is not set. When it is set, it logs the key file andverification(an info line forrequire, a warning forlog), and reads the file.reload_all()reads the key file again (actor_contextin the summary). Changedauth.externalsettings take effect without a restart; a new key file is read once.nerve doctorreads the key file and reportsauth.externalproblems, as the hosted channel checks do. It warns when the header is not verified and whenverificationislog.Docs
docs/config.md: the keys in the Auth table, "Actor context verification" under Authentication mode, and the reload table.docs/accounts.md: External mode.Why
#482 is safe only when the gateway is the only caller that can reach Nerve. A network boundary gives that against other hosts, but not against other processes on the same machine. The gateway already signs every request, so Nerve can check the signature and the claims itself.
Limits
not_before,not_after) are not read.auth.external.gateway_jwks_file, the behavior and the warning of Add external auth mode that trusts the gateway actor header #482 stay.Testing
.venv/bin/pytest tests/test_actor_context_verify.py tests/test_external_auth.py -q: 242 passed, 0 failed.test_config_validate,test_config_reload,test_hosted_channel_config,test_auth_mode,test_auth_secret_resolution,test_config_credential_migration,test_cli_reload,test_system_principal,test_cli_init_slim,test_cause_attribution,test_notification_plan_attribution,test_setup_api,test_workflow_attribution): 683 passed, 0 failed.tests/test_actor_context_verify.pycovers:tests/fixtures/actor_context_vectors.json): every valid vector is accepted with its principal and display name, from a plain key set and from a document with other members, and every invalid vector is refused;kid, each header and claim check, the clock skew limits, the request-target rule, single use and the cache limit;401and503, log mode, and the unchanged behavior without the setting;problems(), the startup log, reload andnerve doctor.tests/test_external_auth.pyis unchanged.