Authentication¶
RunVault uses a two-step PKI flow. The long-lived project API key is used only at registration. After that, every outbound LLM call is authenticated with a fresh EdDSA-signed JWT plus a CA-signed certificate — neither of which can be replayed for more than five minutes.
The flow at a glance¶
┌──────────────┐ rv.register_agent() ┌──────────────┐
│ Project │ ───── api_key ─────► │ Backend │
│ API key │ │ signs cert │
└──────────────┘ ◄── cert + keypair ──┴──────────────┘
┌──────────────┐ every LLM call ┌──────────────┐
│ Identity │ ── X-RV-Certificate ►│ RunVault │
│ priv key in │ ── X-RV-Agent-JWT ──►│ proxy │
│ memory │ ◄────── response ────┴──────────────┘
Step 1 — registration¶
rv.register_agent(...) performs a single POST /auth/agents/runs:
- The backend mints an Ed25519 keypair and signs a certificate with the project CA.
- The SDK persists both to
~/.runvault/<agent_id>/with0600permissions. - If
RV_CA_PUBLIC_KEYis set, the SDK verifies the certificate's CA signature before trusting it. (Absent ⇒ logs a warning and proceeds; acceptable for local dev, never for production.)
Registration is idempotent: a second call for the same agent_id reuses the on-disk credentials. If the backend reports the cert was rotated (410 CERT_ROTATION_REQUIRED), the SDK transparently calls /credentials/refresh.
Step 2 — per-request signing¶
Every outbound LLM call goes through a custom httpx transport that:
- Reads the active
Runfrom aContextVar. No run ⇒NoActiveRunError. - Verifies the request is aimed at the configured proxy host (see Host allowlist).
- Mints a fresh JWT with these claims:
aud: "runvault-proxy"iat/exp— 5-minute TTLjti— fresh UUID, lets the proxy de-duplicateagent_id,run_id
- Signs it with the agent's private key using EdDSA.
- Attaches:
X-RV-Certificate— base64-encoded certificate (CA-signed)X-RV-Agent-JWT— the JWT
- Forwards the request.
The proxy verifies the CA signature on the certificate, then verifies the JWT signature using the public key inside that certificate, then checks the budget and forwards to the upstream provider.
Credential rotation¶
When an administrator rotates an agent's certificate, the proxy's revocation cache picks up the change within ≤30 s and starts returning 401 CERTIFICATE_REVOKED to in-flight requests. The transport handles this transparently:
- Detect the exact code.
- Drain the response body.
- Call
identity.refresh_credentials()— round-trips to/auth/agents/credentials/refresh, mints a new keypair + cert, updates the identity in place, and re-persists to disk. - Re-sign and retry the request once.
The retry is deliberately one-shot. If the second attempt also fails, the error propagates normally. If the agent has been suspended, refresh_credentials() raises AgentSuspendedError and no retry is attempted.
Host allowlist¶
A wired httpx.Client could in principle be aimed at any URL by user code, exfiltrating valid signed JWTs. The transport refuses to attach RunVault credentials to any host other than the bound identity's proxy. Requests to other hosts raise UntrustedHostError before any signing happens.
Cross-identity guard¶
Each wired LLM is bound to the identity that built it. On every outbound request, the transport compares the bound identity to the active run's identity:
identity_a = rv.register_agent(agent_id="a", name="A")
identity_b = rv.register_agent(agent_id="b", name="B")
llm_a = identity_a.build_llm(ChatOpenAI)(model="gpt-4o-mini")
llm_b = identity_b.build_llm(ChatOpenAI)(model="gpt-4o-mini")
with identity_a.run():
llm_b.invoke("...") # ← bound to B, run owned by A ⇒ CrossIdentityError
Set the policy at registration (security_policy="hard" | "soft", default "hard") and override per run if you need to:
with identity_a.run(security_policy="soft"):
...
In "soft" mode the SDK emits CrossIdentityWarning and proceeds — the bound identity is billed (the transport can only sign with the key it holds).
What's on disk¶
~/.runvault/<agent_id>/
├── private.key 0600 raw 32-byte Ed25519 private key
└── certificate.json 0600 CA-signed cert (JSON)
The directory is 0700. Anyone who can read private.key can impersonate the agent and spend its budget — treat the file like an SSH private key.
Threat model in brief¶
The SDK is best-effort against accidents, not a security boundary against in-process adversaries. Python has no module-level isolation: any dependency in the process can read the private key, monkey-patch the transport, or bypass the guards. Defenses are layered:
| Threat | Caught at |
|---|---|
| Accidental cross-wiring | SDK — cross-identity guard |
| Exfiltration to attacker URL | SDK — host allowlist |
| JWT replay | Proxy — 5-min exp + jti window |
| Compromised dependency reads the private key | Not defendable in-process. Damage capped by the per-agent budget at the proxy. |
For hostile multi-tenancy, use process isolation. One identity = one trust zone.