Demo and support tooling for an mTLS HTTP/2 CONNECT proxy that authorizes connections based on custom X.509 certificate extensions.
The proxy accepts incoming mTLS connections, extracts a custom extension value from the client certificate, and checks a PostgreSQL-backed signed permission registry to decide whether the client may connect to the requested destination. If allowed, it opens a raw TCP connection to the destination and tunnels data bidirectionally. The client is responsible for establishing its own TLS session to the destination through the tunnel.
cargo build -p agent_gateway_sidecar
The sidecar uses a simulated TPM identity. Install the native TPM stack before building or running it:
sudo apt-get install libtss2-dev swtpm tpm2-tools pkg-configThe scripted prompt demo also requires Claude Code to be installed, in PATH and
authenticated, because ./demo/demo-agent.sh prompt launches the claude CLI.
export AGENT_GATEWAY_DEMO_GATEWAY_IMAGE=ghcr.io/sl5taskforce/agent-gateway:main
./demo/demo.sh setupThe setup command generates demo TLS material, creates config.toml when needed,
starts Postgres and static HTTPS mock services with Podman Compose or Docker Compose, applies the
database migration, starts the configured gateway image, enrolls a demo principal,
grants access to docstore and messaging, creates a demo agent handle, and
verifies that Claude Code can fetch https://docstore/health through the
gateway.
demo/generate-server-certs.sh creates gateway TLS material (server-ca.pem,
server.pem, ...) and mock HTTPS service TLS material (mock-ca.pem,
mock-services.pem, ...). The local demo enrolls with ./demo/demo-agent.sh,
which starts a local swtpm, creates a persistent P-256 signing key in that
simulated TPM, and prepares machine-client.pem as a certificate carrier for
that public key and identity extension. The gateway does not trust a client CA
bundle; it authorizes the exact subject public key recorded in signed Postgres
permission rows.
demo/demo.sh setup keeps its tpm2-pkcs11 state under the demo state directory by
default. Override AGENT_GATEWAY_DEMO_TPM2_PKCS11_STORE only when you
intentionally want the demo principal to use another store.
After setup, prompt the demo agent:
./demo/demo-agent.sh prompt agent-alpha \
--prompt "Access https://docstore/documents using curl"On later runs, start the gateway first and use ./demo/demo-agent.sh prompt.
./demo/demo-agent.sh prepares machine-client.pem for the current simulated
TPM key and identity whenever it creates or restarts the local sidecar runtime.
Delete the demo agent when finished; this stops the local sidecar and swtpm,
revokes its database permissions, and removes local state:
./demo/demo-agent.sh delete agent-alphaReset all demo services, volumes, generated certificates, config.toml, and
local demo state with:
./demo/demo.sh teardownPass a custom policy identity when creating an agent with
./demo/demo-agent.sh create --identity agent-beta .... The identity must match
permission_registry.subject_identity in an active signed permission row.
The simulated TPM state lives under $AGENT_STATE/client/swtpm/. By default,
the sidecar uses TCTI swtpm:host=127.0.0.1,port=2321 and persistent handle
0x81010004; override the simulator data port with
AGENT_GATEWAY_DEMO_SWTPM_PORT and the handle with
AGENT_GATEWAY_DEMO_TPM_HANDLE. The swtpm control port is always the data port
plus one, which matches the TSS swtpm TCTI convention.
The default Postgres password and TPM PINs used by ./demo/demo.sh setup are
local demo values, not production guidance. Override AGENT_GATEWAY_DATABASE_URL,
AGENT_GATEWAY_TPM_USER_PIN, and AGENT_GATEWAY_TPM_SO_PIN for any environment
that is not an isolated local demo.
certs/mock-ca.pem and certs/server-ca.pem are generated demo trust roots, and
their private keys live under certs/ until teardown. The demo passes
CURL_CA_BUNDLE, SSL_CERT_FILE, and NODE_EXTRA_CA_CERTS only to subprocesses
that need the mock service CA; do not export those variables system-wide. Run
./demo/demo.sh teardown when you are done to remove generated certificates,
config.toml, containers, volumes, and local demo state.
Copy config.example.toml to config.toml and edit it. Key sections:
[server] -- Listen address and TLS material.
| Field | Required | Description |
|---|---|---|
listen_addr |
yes | host:port to bind (defaults to 127.0.0.1:8443; use non-loopback only when intentionally exposing the gateway) |
tls_cert_path |
yes | PEM server certificate |
tls_key_path |
yes | PEM private key for the server cert |
[policy] -- Configures the certificate identity extension and Postgres registry access.
| Field | Description |
|---|---|
client_ext_oid |
OID of the custom X.509 extension to extract (dotted notation) |
database_url |
Postgres URL for the authorization registry. Prefer database_url_env outside local development. |
database_url_env |
Environment variable containing the Postgres URL. |
max_connections |
Maximum Postgres pool connections. Defaults to 5. |
connect_timeout_ms |
Postgres connection timeout. Defaults to 5000. |
pool_acquire_timeout_ms |
Pool acquire timeout per authorization check. Defaults to 1000. |
query_timeout_ms |
Query timeout per registry lookup. Defaults to 500. |
[observability] -- Logging and tracing.
| Field | Required | Description |
|---|---|---|
log_level |
yes | tracing filter (e.g. info, debug, agent_gateway=debug) |
otlp_endpoint |
no | OTLP gRPC endpoint for distributed tracing |
Set AGENT_GATEWAY_LOG_STDOUT=false to disable stdout/stderr formatting while
leaving OTLP export enabled. The sidecar reads observability settings from
environment variables. Use RUST_LOG for its tracing filter and set
OTEL_EXPORTER_OTLP_ENDPOINT (for example, http://localhost:4317) to export
sidecar spans over OTLP. When enabled, the sidecar injects W3C trace-context
headers into the CONNECT request it sends to the gateway, and the gateway
continues the same trace.
agent_gateway --config config.toml
Run migrations explicitly before starting the gateway:
psql "$AGENT_GATEWAY_DATABASE_URL" -f migrations/0001_signed_authorization_registry.sqlGateway startup verifies the authorization registry schema version and fails fast if the database is not migrated. The runtime gateway database role should be read-only for authorization tables; use a separate admin role for migrations and registry writes.
Shut down cleanly with Ctrl-C.
Register a principal signing key from the TPM owner machine with:
./registry-cli/register-principal-key.sh org-aliceThe script creates or reuses a non-exportable TPM-backed P-256 key through tpm2_ptool and PKCS#11, stores only the public key in principal_signing_keys, and uses the friendly key_id (org-alice, org-bob, etc.) for the registry row. Run it on the machine that owns the TPM, with AGENT_GATEWAY_DATABASE_URL or DATABASE_URL pointing at Postgres.
For a manual demo without ./demo/demo.sh setup, use three windows:
# Principal shell: enroll the principal TPM public key.
./registry-cli/register-principal-key.sh org-alice
# Admin shell: grant destination delegation authority to that principal.
./registry-cli/grant-principal-scope.sh org-alice docstore messaging api.anthropic.com
# Principal shell: create a local agent handle with initial signed permissions.
AGENT_HANDLE="$(./demo/demo-agent.sh create \
--identity agent-alpha \
--grant docstore \
--grant messaging)"
# Principal shell: send the first prompt through that agent.
./demo/demo-agent.sh prompt "$AGENT_HANDLE" \
--prompt "Access https://docstore/documents with curl."
# Principal shell: delete the agent when finished.
./demo/demo-agent.sh delete "$AGENT_HANDLE"The gateway authorizes a CONNECT only when all of these checks pass:
- The mTLS client certificate has the configured UTF8String identity extension.
- The requested authority normalizes to a
host:portdestination. - Postgres contains an active
permission_registryrow for that identity, destination, and the leaf certificate's exact SubjectPublicKeyInfo DER. - The row's
signatureverifies over the canonical permission row fields with the referenced active principal signing key. principal_key_permissionsconfirms that the signing key was allowed to delegate that destination.
The authorization registry has three main tables:
| Table | Key Columns | Purpose |
|---|---|---|
principal_signing_keys |
key_id, algorithm, public_key_spki_der, not_before, not_after, revoked_at |
Stores trusted P-256 public keys that may sign permissions. |
principal_key_permissions |
signing_key_id, destination, not_before, not_after, revoked_at |
Defines which destinations each signing key is allowed to delegate. |
permission_registry |
permission_id, signing_key_id, subject_identity, subject_public_key_spki_der, destination, not_before, not_after, revoked_at, signature |
Stores signed permissions that authorize a subject identity and exact subject key to reach a normalized destination. |
principal_key_permissions.signing_key_id and permission_registry.signing_key_id both reference principal_signing_keys.key_id. A permission is usable only when the permission row is active, the signing key is active, the signature verifies over the canonical row fields, and the signing key has a matching destination delegation row.
The signed bytes are the following UTF-8 text, with fields in this exact order and timestamps formatted as UTC RFC 3339 with six fractional digits:
./registry-cli/agent-permissions.sh grant inserts signed agent permission rows,
and ./registry-cli/agent-permissions.sh delete revokes rows for an agent
identity and subject public key.
agent-gateway-permission-v1
permission_id=perm-1
signing_key_id=org-alice
subject_identity=agent-alpha
subject_public_key_spki_der=3059301306072a8648ce3d020106082a8648ce3d03010703420004...
destination=api.example.com:443
not_before=2026-05-01T00:00:00.000000Z
not_after=2026-06-01T00:00:00.000000Z
Destination strings are normalized with the same rules used for CONNECT requests: hostnames are lowercased, omitted ports default to 443, and IPv6 destinations use bracketed host:port form.
Clients must:
-
Connect over HTTP/2 with mTLS. Present a structurally valid client certificate and prove possession of the certificate private key during the TLS handshake. The certificate must contain a custom X.509 extension at the OID configured in
policy.client_ext_oid, with a DER-encoded UTF8String value. The full leaf SubjectPublicKeyInfo DER must match an active signed permission row. -
Use the HTTP CONNECT method. The request authority must be
host:port(port defaults to 443 if omitted). Example usinghyper:let req = Request::builder() .method(Method::CONNECT) .uri("api.example.com:443") .body(Empty::<Bytes>::new())?;
-
Handle TLS to the destination. After receiving
200 OK, the tunnel is an opaque TCP pipe. The client must perform its own TLS handshake with the destination through the tunnel.
| Status | Meaning |
|---|---|
200 |
Tunnel established -- begin sending data |
400 |
Malformed request (missing/invalid authority) |
403 |
Policy denied the connection |
405 |
Non-CONNECT method used |
502 |
Could not reach the destination |
The extension value is matched exactly (case-sensitive) against signed permission rows. The extension must be an X.509 extension at the configured OID containing a single DER-encoded ASN.1 UTF8String.
Example certificate generation with rcgen:
use rcgen::{CertificateParams, CustomExtension};
let oid: &[u64] = &[1, 3, 6, 1, 4, 1, 57264, 1, 1];
let value = der_encode_utf8_string("agent-alpha");
params.custom_extensions.push(
CustomExtension::from_oid_content(oid, value),
);cargo test
Database-backed policy and e2e tests require TEST_DATABASE_URL to point at a Postgres database that the test process can migrate and write to. The tests cover policy evaluation, signed-permission verification, signer delegation scope enforcement, destination normalization, config validation, TLS PKI generation, and proxy request parsing.
This project is licensed under the MIT License. See LICENSE.