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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 13 additions & 6 deletions docs/accounts.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,8 +136,15 @@ and names the person behind each request. See
| Backend and external MCP token | The system actor |

The header is a compact JWS. Nerve reads `principal_id` and
`profile.display_name` from its payload. It does not check the signature or
any other claim. `principal_id` must be a UUID, and Nerve uses its lowercase
`profile.display_name` from its payload. With `auth.external.gateway_jwks_file`
set, Nerve first checks the gateway's signature with a key from that file, and
the claims: this tenant and agent, the method and target of this request, the
issue and expiry times, and that the assertion was not used before. It checks
a request when the request arrives, before it reads the body. A failed check
gives `401`, and a missing key set gives `503`. See
[Actor context verification](config.md#actor-context-verification). Without
that setting, Nerve does not check the signature or any other claim.
`principal_id` must be a UUID, and Nerve uses its lowercase
form as the actor ID. `profile.display_name` must be a string, `null`, or
absent. A header that Nerve cannot read, a header that names the system actor,
and a request with more than one header give `401`. When the header is
Expand Down Expand Up @@ -182,10 +189,10 @@ In external mode:
actor for Nerve's own model calls. See
[Inference principal header](config.md#inference-principal-header).

> **Warning:** Nerve trusts the header. Use external mode only when the
> gateway is the only caller that can reach Nerve. A caller that can send the
> header can act as any person, also as the actor of an account from an earlier
> local mode.
> **Warning:** Without `auth.external.gateway_jwks_file`, Nerve trusts the
> header. Then use external mode only when the gateway is the only caller that
> can reach Nerve. A caller that can send the header can act as any person,
> also as the actor of an account from an earlier local mode.

### The system actor in external mode

Expand Down
78 changes: 73 additions & 5 deletions docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,7 @@ A reload is always explicit. Two things cause one:
| Skills (`skills/`, and the platform skills in `skills/` in the config directory) | ✅ re-scanned |
| `lockdown` | ✅ the write guards and the layer stack both follow |
| `setup.local_secrets` | ✅ read on every request of the setup page |
| Web gateway auth (`auth.*`) | partly. `auth.jwt_expiry_hours` follows a reload for newly issued or refreshed tokens. The deprecated `auth.password_hash` follows a reload only for accounts without a local password. `auth.jwt_secret` requires a restart (see the restart table) |
| Web gateway auth (`auth.*`) | partly. `auth.jwt_expiry_hours` follows a reload for newly issued or refreshed tokens. The deprecated `auth.password_hash` follows a reload only for accounts without a local password. `auth.external.*` follows a reload, which also reads the file that `gateway_jwks_file` names again. `auth.jwt_secret` requires a restart (see the restart table) |
| `gateway.drain_timeout_seconds` | ✅ read when the shutdown signal arrives (see [Shutdown Sequence](architecture.md#shutdown-sequence)) |
| `notifications.*` | ✅ read per notification |
| `workspace_sync.*` | ✅ from the next sync cycle |
Expand Down Expand Up @@ -2182,6 +2182,10 @@ a server or a tool that the organization removed.
|-----|------|---------|-------------|
| `auth.password_hash` | string | - | Deprecated compatibility setting. Manage passwords from the Accounts page. If neither this setting nor the sole account has a password, anyone who can reach the gateway can act as the owner. See [Accounts and identity](accounts.md) |
| `auth.jwt_secret` | string | - | JWT signing secret. When unset, Nerve generates one and stores it in `nerve.db`. Changing it requires a restart and signs users out. See [Accounts and identity](accounts.md) |
| `auth.external.gateway_jwks_file` | path | - | External mode only. An absolute path to the gateway's public actor signing keys, a JWK Set file. When set, Nerve verifies the `X-Nerve-Actor-Context` header. See [Actor context verification](#actor-context-verification) |
| `auth.external.tenant_id` | string | - | The tenant of this instance, a lower-case UUID. Required with `gateway_jwks_file` |
| `auth.external.agent_id` | string | - | The agent of this instance, a lower-case UUID. Required with `gateway_jwks_file` |
| `auth.external.verification` | string | `require` | `require` refuses a header that fails the check. `log` logs the failure and uses the header anyway, for a first rollout |

### Authentication mode

Expand All @@ -2201,10 +2205,74 @@ error. `nerve restart` checks the value before it stops the running daemon.
runs `nerve doctor`. It does not ask the running server, so the mode of a
daemon that started with a different environment can be different.

> **Warning:** In external mode, Nerve trusts the `X-Nerve-Actor-Context`
> header and does not check its signature. A caller that can send a request to
> Nerve can act as any person. Use external mode only when the gateway is the
> only caller that can reach Nerve.
#### Actor context verification

The gateway signs the `X-Nerve-Actor-Context` header with an Ed25519 key. To
check it, give Nerve the gateway's public keys and the identity of this
instance in `auth.external`. The mode itself still comes only from
`NERVE_AUTH_MODE`.

```yaml
auth:
external:
gateway_jwks_file: /etc/nerve/actor-keys.json # an absolute path
tenant_id: 00000000-0000-0000-0000-000000000000
agent_id: 00000000-0000-0000-0000-000000000000
verification: require # or log
```

The file is a JWK Set of Ed25519 public keys (`kty` `OKP`, `crv` `Ed25519`,
`alg` `EdDSA`, `use` `sig`, a unique `kid`, and `x`). Nerve reads only `keys`
and ignores the other members of the document. A file with a private key
member is refused. Nerve reads the file at startup and on each reload, and
again, at most once a minute, when a header names an unknown `kid`. When a
later read fails, the keys of the last good read stay in use. Nerve never
fetches keys over the network.

With the file set, Nerve accepts the header of an HTTP request or a WebSocket
upgrade only when all of these are true:

- The header is a compact JWS. Its protected header has `typ`
`nerve-actor-context+jws`, `alg` `EdDSA` and a `kid` from the file, and no
`jwk`, `jku`, `x5c`, `x5u` or `crit`. The signature verifies with that key.
- The payload has `version` `"1"`, the configured `tenant_id` and `agent_id`,
`signer_id` `urn:nerve:gateway:actor:<tenant_id>:<agent_id>`, `audience`
`urn:nerve:runtime:<tenant_id>:<agent_id>`, `actor_kind` `human`, and a UUID
in `principal_id`.
- `request_binding` names this request. `kind` is `http`, or `websocket` for an
upgrade, which also needs a `gateway_connection_id`. `method` is the request
method. `target` is the request-target that Nerve received: the raw path, and
`?` and the query when there is a query. Without a query, `/path?` also
matches.
- `issued_at` is at most 30 seconds in the future, `expires_at` is at most 30
seconds in the past, and `expires_at` is 1 to 30 seconds after `issued_at`.
- Nerve has not accepted the same `assertion_id` before. Nerve keeps accepted
IDs in memory until they expire, at most 10,000; when it is full, it forgets
the oldest.

Nerve checks the header of an HTTP request when the request arrives, before it
reads the body, and the header of a WebSocket upgrade when it accepts the
socket.

| Result | Response |
|---|---|
| All checks pass | The request acts as `principal_id`, with `profile.display_name`, as without the check |
| A check fails | `401`. The reason, the `kid` and the request ID go to the log, never the header |
| No usable key set: the file cannot be read or used, or an `auth.external` setting is invalid | `503` |
| A check fails, or no usable key set, with `verification: log` | Nerve logs the failure and reads the header without the check |

The server accepts a WebSocket before it authenticates it, so a failed check
and a missing key set both close the socket with `4001`.

A reload applies changed `auth.external` settings and reads the file again.
In external mode, `nerve doctor` reads the file, reports invalid settings, and
warns when `verification` is `log`. Startup logs the key file and the
`verification` value, as a warning when it is `log`.

> **Warning:** Without `auth.external.gateway_jwks_file`, Nerve trusts the
> `X-Nerve-Actor-Context` header and does not check its signature. A caller
> that can send a request to Nerve can act as any person. Then use external
> mode only when the gateway is the only caller that can reach Nerve.

#### Inference principal header

Expand Down
34 changes: 34 additions & 0 deletions nerve/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -1685,6 +1685,38 @@ def _hosted_github_lines(config, errors: list[str], warnings: list[str]) -> list
return lines


def _actor_context_lines(config, errors: list[str], warnings: list[str]) -> list[str]:
"""Doctor lines for the actor context check of external mode (``auth.external``)."""
from nerve.config import EXTERNAL_VERIFICATION_LOG
from nerve.gateway.actor_context import KeyFileError, read_key_file

settings = config.auth.external
if settings.gateway_jwks_file is None:
warnings.append(
"[WARN] Actor context: the X-Nerve-Actor-Context header is not verified "
"(auth.external.gateway_jwks_file is not set), so only the gateway must "
"be able to reach Nerve"
)
return []
problems = settings.problems()
if not problems:
try:
keys = read_key_file(settings.gateway_jwks_file)
except KeyFileError as error:
problems = [f"auth.external.gateway_jwks_file: {error}"]
if problems:
errors.extend(f"[ERR] Actor context: {problem}" for problem in problems)
return []
source = f"agent {settings.agent_id}, {len(keys)} gateway key(s) from {settings.gateway_jwks_file}"
if settings.verification == EXTERNAL_VERIFICATION_LOG:
warnings.append(
"[WARN] Actor context: auth.external.verification is log, so a header "
f"that fails the check is used anyway ({source})"
)
return []
return [f"[OK] Actor context verified (verification: {settings.verification}): {source}"]


def _github_repos_lines(config, warnings: list[str]) -> list[str]:
"""The doctor line of the repository watch (``sync.github_repos``).

Expand Down Expand Up @@ -2258,6 +2290,8 @@ def doctor_report(config, config_source: str = "", check_api: bool = False) -> s
else:
lines.append(f"[OK] Auth mode: {mode} ({mode_source})")
external = mode == AUTH_MODE_EXTERNAL
if external:
lines.extend(_actor_context_lines(config, errors, warnings))

configured = bool(config.auth.password_hash)
identity_state = inspect_bootstrap_state(paths.db_path())
Expand Down
62 changes: 62 additions & 0 deletions nerve/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -2960,6 +2960,66 @@ def from_dict(cls, d: dict) -> RetentionConfig:
)


EXTERNAL_VERIFICATION_REQUIRE = "require"
EXTERNAL_VERIFICATION_LOG = "log"
EXTERNAL_VERIFICATION_MODES = (EXTERNAL_VERIFICATION_REQUIRE, EXTERNAL_VERIFICATION_LOG)


@dataclass
class ExternalAuthConfig:
"""The check of the gateway's actor context in external mode.

With ``gateway_jwks_file`` set, Nerve accepts an ``X-Nerve-Actor-Context``
header only with a signature from a key in that local JWK Set file, and
with claims for exactly ``tenant_id``, ``agent_id`` and the request (see
:mod:`nerve.gateway.actor_context`). ``verification: log`` logs a failed
check and uses the header anyway, for a first rollout. Without
``gateway_jwks_file``, Nerve reads the header without a check. The mode
itself comes only from ``NERVE_AUTH_MODE``.
"""

gateway_jwks_file: Path | None = None
tenant_id: str = ""
agent_id: str = ""
verification: str = EXTERNAL_VERIFICATION_REQUIRE

@classmethod
@_coerced
def from_dict(cls, d: dict) -> ExternalAuthConfig:
return cls(
gateway_jwks_file=_expand_path(d.get("gateway_jwks_file")),
tenant_id=str(d.get("tenant_id") or "").strip(),
agent_id=str(d.get("agent_id") or "").strip(),
verification=str(
d.get("verification") or EXTERNAL_VERIFICATION_REQUIRE,
).strip().lower(),
)

def problems(self) -> list[str]:
"""Why these settings cannot check the actor context. Empty when they
can, and when ``gateway_jwks_file`` is unset (no check).

The key file itself is read by the verifier and by ``nerve doctor``.
"""
if self.gateway_jwks_file is None:
return []
found: list[str] = []
if not self.gateway_jwks_file.is_absolute():
# The daemon and `nerve doctor` can start in different directories.
found.append("auth.external.gateway_jwks_file must be an absolute path")
for key in ("tenant_id", "agent_id"):
value = getattr(self, key)
try:
canonical = str(uuid.UUID(value)) == value
except ValueError:
canonical = False
if not canonical:
found.append(f"auth.external.{key} must be a lowercase UUID")
if self.verification not in EXTERNAL_VERIFICATION_MODES:
found.append("auth.external.verification must be require or log")
return found


@dataclass
class AuthConfig:
password_hash: str = ""
Expand All @@ -2973,6 +3033,7 @@ class AuthConfig:
# logged out mid-work. Only a tab left untouched for the whole window
# comes back to a password prompt.
jwt_expiry_hours: int = 720 # 30 days
external: ExternalAuthConfig = field(default_factory=ExternalAuthConfig)

@classmethod
@_coerced
Expand All @@ -2981,6 +3042,7 @@ def from_dict(cls, d: dict) -> AuthConfig:
password_hash=d.get("password_hash", ""),
jwt_secret=d.get("jwt_secret", ""),
jwt_expiry_hours=max(1, _lenient_int(d.get("jwt_expiry_hours"), 720)),
external=ExternalAuthConfig.from_dict(d.get("external") or {}),
)


Expand Down
20 changes: 20 additions & 0 deletions nerve/config_reload.py
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,23 @@ def _reload_hosted_keys(engine) -> str | None:
return f"{_ERROR_PREFIX}{detail}"


def _reload_actor_context_keys() -> str | None:
"""Read the gateway key file of the actor context check again.

``None`` unless external mode checks the actor context
(``auth.external.gateway_jwks_file``). A changed key file, tenant or
agent takes effect here as well.
"""
try:
from nerve.gateway.auth import reload_actor_context_keys

return reload_actor_context_keys()
except Exception as e: # noqa: BLE001 - report the subsystem and continue
detail = str(e) or type(e).__name__
logger.warning("Actor context key reload failed: %s", detail)
return f"{_ERROR_PREFIX}{detail}"


async def _republish_hosted_subscriptions(engine) -> str | None:
"""Send the hosted source subscriptions to the gateway again.

Expand Down Expand Up @@ -426,6 +443,9 @@ async def reload_all(engine, cron_service, config_dir: Path) -> dict:
hosted = _reload_hosted_keys(engine)
if hosted is not None:
summary["hosted"] = hosted
actor_keys = _reload_actor_context_keys()
if actor_keys is not None:
summary["actor_context"] = actor_keys

# 2. Cron jobs + sources.
if cron_service is not None:
Expand Down
4 changes: 4 additions & 0 deletions nerve/config_validate.py
Original file line number Diff line number Diff line change
Expand Up @@ -472,6 +472,10 @@ def _is_unresolved(value: Any) -> bool:
"hosted channels would try to read a directory as the gateway's public "
"key file, so they do not start and every stream upgrade is refused"
),
("auth", "external", "gateway_jwks_file"): (
"external mode would have no key to check the actor context header with, "
"so every request that carries the header is refused"
),
("workspace",): (
"the entire workspace (settings.yaml, the cron config, memory, "
"everything nerve syncs) would be read from and written to whatever "
Expand Down
Loading
Loading