Skip to content

External auth: verify the gateway's actor context header - #597

Merged
pufit merged 1 commit into
mainfrom
pufit/actor-context-verify
Oct 31, 2026
Merged

pufit merged 1 commit into
mainfrom
pufit/actor-context-verify

Conversation

@pufit

@pufit pufit commented Oct 30, 2026

Copy link
Copy Markdown
Member

What

With NERVE_AUTH_MODE=external and the new auth.external.gateway_jwks_file setting, Nerve verifies the gateway's X-Nerve-Actor-Context header 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 doctor now 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 calls read_actor_context().

How

Configuration

auth:
  external:
    gateway_jwks_file: /etc/nerve/actor-keys.json   # an absolute path
    tenant_id: <lower-case UUID>
    agent_id: <lower-case UUID>
    verification: require                            # require (default) or log
  • The mode itself still comes only from NERVE_AUTH_MODE. An absent auth.external section means no check.
  • ExternalAuthConfig.problems() follows HostedChannelsConfig.problems(): an absolute path, canonical lower-case UUIDs, and require or log.
  • nerve config validate knows the keys, and refuses . as the path like the other path settings.

Verifier (nerve/gateway/actor_context.py, built like the stream token verifier in nerve/channels/hosted/auth.py)

  • The key file is a JWK Set of Ed25519 public keys (kty OKP, crv Ed25519, alg EdDSA, use sig, a unique kid), all required. Nerve reads only keys and 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 unknown kid. A later read that fails keeps the last good keys. Nerve never fetches keys over the network.
  • The compact JWS is verified directly with cryptography's Ed25519PublicKey. The protected header must have typ nerve-actor-context+jws, alg EdDSA and a known kid, and no jwk, jku, x5c, x5u or crit.
  • The claims: version "1"; tenant_id and agent_id equal the configured IDs; signer_id and audience equal urn:nerve:gateway:actor:<tenant>:<agent> and urn:nerve:runtime:<tenant>:<agent>; actor_kind human; request_binding.kind is http, or websocket with a gateway_connection_id; method and target equal 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_at at most 30 seconds ahead, expires_at at most 30 seconds behind, and a lifetime of 1 to 30 seconds.
  • assertion_id is 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.
  • After the checks, read_actor_context() returns what decode_actor_context() returns, so principal_id handling and the data model do not change. The schema trigger still refuses the system actor's ID. The verifier module does not import nerve/gateway/auth.py.

Requests

  • HTTP: FastAPI reads the request body before it runs 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 in require_auth.
  • A failed check gives 401 with a fixed detail. The reason, the kid and the request ID go to the log, never the header. No usable key set (a file that cannot be read or used, or an invalid auth.external setting) gives 503.
  • /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 with 4001, like the other refusals.
  • verification: log logs 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 and verification (an info line for require, a warning for log), and reads the file.
  • reload_all() reads the key file again (actor_context in the summary). Changed auth.external settings take effect without a restart; a new key file is read once.
  • In external mode, nerve doctor reads the key file and reports auth.external problems, as the hosted channel checks do. It warns when the header is not verified and when verification is log.

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

  • The keys come from a local file. To rotate, add the new public key to the file before the gateway signs with it, and remove the old key only after every instance has the new file.
  • The replay cache is per process and in memory. A restart forgets it; an assertion is valid for 30 seconds.
  • Per-key validity bounds (not_before, not_after) are not read.
  • Without 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.
  • The test files of the other changed modules and of the readers of the header (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.
  • New tests/test_actor_context_verify.py covers:
    • golden vectors from the gateway's signer at a fixed clock, with throw-away keys (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;
    • a changed payload or signature, another key under a known kid, each header and claim check, the clock skew limits, the request-target rule, single use and the cache limit;
    • the key file: the reread limit, a rotated key, the last good keys after a failed read, private key members and unusable keys;
    • HTTP and WebSocket requests, 401 and 503, log mode, and the unchanged behavior without the setting;
    • the check at arrival: an upload to the real upload route whose transfer moves the clock past the assertion's lifetime is accepted, the same assertion in a second request is refused, and a request with two headers is still refused;
    • problems(), the startup log, reload and nerve doctor.
  • tests/test_external_auth.py is unchanged.

Generated by Nerve

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.
@nerveai

nerveai commented Oct 30, 2026

Copy link
Copy Markdown

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 X-Nerve-Actor-Context and act as any person, require_person routes included. The approach fits the codebase. The verifier follows the hosted-channel GatewayTokenVerifier pattern (a local JWK file, keep-last-good, a rate-limited reread on an unknown kid), and every reader of the header now goes through read_actor_context (I found only require_auth and authenticate_websocket at the head). Checking HTTP requests in an ASGI middleware before the body is read is the right call: FastAPI reads the body before it runs dependencies, so a check there would fail slow uploads. An app without the middleware still checks in require_auth, so the check cannot be skipped. Both the middleware and the check fail closed: 401 on a bad header, 503 on bad settings or no keys, 4001 on /ws. The claims (gateway_connection_id at top level, raw target, single-use assertion_id) match the gateway's golden vectors. The feature is off by default, so nothing changes until the platform writes auth.external and the key file.

Completeness: 64 new test functions (vectors, replay, slow upload, two headers, real /ws, reload, doctor, startup log); docs/config.md (Auth table, reload table) and docs/accounts.md updated; cryptography was already a dependency, and the PR adds no migration.

Checked: full suite in an offline sandbox at 6d9eb23 (3 chunks): 8175 passed, 39 skipped; test_slack_integration.py excluded (it hangs offline on main too); CI green. Read nerve/gateway/actor_context.py and auth.py in full, the middleware order in server.py, config_reload.reload_all, and uvicorn's per-request scope["state"] copy · automated review by nerveai. Mention @nerveai to ask a follow-up.

@pufit
pufit merged commit 9d4263b into main Oct 31, 2026
4 checks passed
@pufit
pufit deleted the pufit/actor-context-verify branch October 31, 2026 16:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants