Skip to content
Draft
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
5 changes: 5 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,23 @@ default-members = ["crates/attested-tls-proxy"]
resolver = "3"

[workspace.dependencies]
anyhow = "1.0.100"
bytes = "1.11.1"
clap = { version = "4.5.51", features = ["derive", "env"] }
h2 = "0.4.12"
http = "1.3.1"
http-body-util = "0.1.3"
hyper = "1.7.0"
hyper-util = "0.1.17"
rcgen = "0.14.5"
rustls-pemfile = "2.2.0"
serde_json = "1.0.145"
tempfile = "3.23.0"
thiserror = "2.0.17"
tokio = "1.48.0"
tokio-rustls = { version = "0.26.4", default-features = false }
tracing = "0.1.41"
tracing-subscriber = { version = "0.3.20", features = ["env-filter", "json"] }
webpki-roots = "1.0.4"
x509-parser = "0.18.0"

Expand Down
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,19 @@ Details of the remote-attested TLS protocol are in [crates/attested-tls/README.m

The proxy-client, on starting, immediately connects to the proxy-server and an attestation-verification exchange is made. This attested-TLS channel is then re-used for requests from that proxy-client instance. If the channel is lost, the client reconnects automatically and repeats the attestation exchange before forwarding subsequent requests.

It has five subcommands:
It has seven subcommands:

- `attested-tls-proxy server` - run a proxy server, which accepts TLS connections from a proxy client, sends an attestation and then forwards traffic to a target CVM service.
- `attested-tls-proxy client` - run a proxy client, which accepts connections from elsewhere, connects to and verifies the attestation from the proxy server, and then forwards traffic to it over TLS.
- `attested-tls-proxy get-tls-cert` - connect to a proxy server, verify its attestation, and, if successful, write its PEM-encoded TLS certificate chain to standard output. This can be used to make subsequent connections to services using this certificate over regular TLS.
- `attested-tls-proxy attested-file-server` - serve files from a local filesystem path over an attested TLS channel.
- `attested-tls-proxy attested-get` - connect to a proxy server, verify its attestation, make a single HTTP GET request, and write the response body to standard output.
- `attested-tls-proxy tcp-tunnel-client` - forward local TCP connections through an attested tunnel.
- `attested-tls-proxy tcp-tunnel-server` - accept attested tunnels and forward each to a fixed TCP target.

For opaque TCP forwarding, see [the TCP tunnel](crates/attested-tls-proxy/TCP_TUNNEL.md). The `tcp-tunnel-client` and `tcp-tunnel-server` commands and the `attested_tls_proxy::tcp_tunnel` library module provide one attested connection per source TCP connection, including support for gRPC.

All commands log at info level to stderr by default. Use `--log-debug` for debug logs and `--log-json` for structured logs. Command output, such as HTTP response bodies and certificates, is written to stdout.

### How it works

Expand Down
10 changes: 5 additions & 5 deletions crates/attested-tls-proxy/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,12 @@ tokio = { workspace = true, features = ["full"] }
tokio-rustls = { workspace = true, features = ["aws_lc_rs"] }
x509-parser = { workspace = true, features = ["verify"] }
thiserror.workspace = true
clap = { version = "4.5.51", features = ["derive", "env"] }
rustls-pemfile = "2.2.0"
anyhow = "1.0.100"
clap.workspace = true
rustls-pemfile.workspace = true
anyhow.workspace = true
pem-rfc7468 = { version = "0.7.0", features = ["std"] }
hyper = { workspace = true, features = ["server", "http2"] }
h2 = "0.4.12"
h2.workspace = true
hyper-util = { workspace = true, features = ["tokio"] }
http-body-util.workspace = true
bytes.workspace = true
Expand All @@ -31,7 +31,7 @@ reqwest = { version = "0.13.4", default-features = false, features = [
] }
webpki-roots.workspace = true
tracing.workspace = true
tracing-subscriber = { version = "0.3.20", features = ["env-filter", "json"] }
tracing-subscriber.workspace = true
axum = "0.8.8"
tower-http = { version = "0.6.7", features = ["fs"] }
rcgen.workspace = true
Expand Down
187 changes: 187 additions & 0 deletions crates/attested-tls-proxy/TCP_TUNNEL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
# TCP tunnel

An attested-TLS TCP tunnel in the `attested_tls_proxy::tcp_tunnel` module,
with `tcp-tunnel-client` and `tcp-tunnel-server` commands in `attested-tls-proxy`.
It forwards bytes without parsing any application protocol:

```text
source <-- TCP --> tunnel client <-- attested TLS TCP --> tunnel server <-- TCP --> target
```

Each source TCP connection opens a dedicated attested-TLS connection and target
TCP connection. Separate source connections get separate tunnels; no connections are
pooled. The server forwards to one configured target, which may itself dispatch
requests to a pool of workers.

## Local example

Build the executable:

```sh
cargo build -p attested-tls-proxy
```

The same binary, Docker image, and Debian package provide both HTTP proxy and TCP tunnel commands.

For a quick round trip, start a local HTTP service in one terminal:

```sh
python3 -m http.server 8000 --bind 127.0.0.1
```

Start the tunnel server in another terminal. With no certificate/key arguments,
it generates a self-signed certificate. This example explicitly disables local
attestation and accepts a client with no attestation:

```sh
target/debug/attested-tls-proxy tcp-tunnel-server \
--listen-addr 127.0.0.1:7000 \
--server-attestation-type none \
--allowed-remote-attestation-type none \
127.0.0.1:8000
```

Start the tunnel client in another terminal:

```sh
target/debug/attested-tls-proxy tcp-tunnel-client \
--listen-addr 127.0.0.1:6000 \
--client-attestation-type none \
--allowed-remote-attestation-type none \
--allow-self-signed \
127.0.0.1:7000
```

Then `curl http://127.0.0.1:6000/` reaches the target. These `none` policies are for
demonstrating transport without a CVM. For attested deployments, select the local
attestation type (or leave automatic detection enabled) and configure accepted
remote measurements using `--measurements-file`.

For gRPC, point the tunnel server at your gRPC service instead of port 8000 and
configure the gRPC client to use plaintext HTTP/2 at `127.0.0.1:6000`. Unary and all
streaming RPC types use the same forwarding path. RPC metadata, trailers,
cancellation, PINGs, and GOAWAY travel between the actual gRPC endpoints.

Application TLS also works: configure TLS in the gRPC client and target as usual.
The inner TLS session passes through unchanged. Configure the application's
server name/authority for its real service identity rather than the local tunnel
address. The tunnel's `--tls-*` settings configure only the outer attested TLS.

## Configuration

Both commands require exactly one of `--measurements-file <PATH_OR_URL>` and
`--allowed-remote-attestation-type <TYPE>`. Measurement policy and attestation
settings follow the [HTTP proxy CLI](../../README.md#measurements-file).

| Option | Default / behavior |
|---|---|
| `--listen-addr`, `-l` (`LISTEN_ADDR`) | Client `127.0.0.1:0`; server `0.0.0.0:0`. The actual bound address is logged. |
| Positional target | Client `host[:port]`, default port 443; server `host:port` with required port. IPv6 literals use brackets; numeric scope IDs are supported, e.g. `[fe80::1%3]:50051`. |
| `--setup-timeout-secs` | 60; includes DNS, connect, TLS, attestation, and the server's target connect. Applied independently at each endpoint. |
| `--max-connections` | 256 per listener, including connections still establishing; excess arrivals are immediately closed. |
| `--shutdown-grace-secs` | 30; stop accepting, drain, then close remaining tunnels. Zero closes immediately. |
| `--tls-private-key-path`, `--tls-certificate-path` | Must be supplied together; accept PKCS#8, RSA PKCS#1, and P-256 SEC1 PEM keys. |
| Client `--tls-ca-certificate` | Trust the first PEM certificate instead of public roots. |
| Client `--allow-self-signed` | Accept a self-signed server certificate; still verify attestation. Cannot combine with `--tls-ca-certificate`. Preserves any supplied client identity. |
| Server `--client-auth` | Require a TLS client certificate authenticated against public roots. Private client CAs can be configured through the Rust API. |
| `--client-attestation-type` / `--server-attestation-type` | Automatic local detection when omitted. |
| `--pccs-url`, `--dev-dummy-dcap` | Same meaning as in the HTTP proxy. |
| `--log-debug`, `--log-json`, `--log-dcap-quote` | Debug logs, structured logs, or DCAP quote dumps in `quotes/`. Logs go to stderr; the default level is info. Payload bytes are never logged. |
| `--override-azure-outdated-tcb` | Same Azure verification override as in the HTTP proxy. |

The existing environment names are supported: `MEASUREMENTS_FILE`,
`TLS_PRIVATE_KEY_PATH`, `TLS_CERTIFICATE_PATH`, `CLIENT_ATTESTATION_TYPE`,
`SERVER_ATTESTATION_TYPE`, and `OVERRIDE_AZURE_OUTDATED_TCB`.

Enable Azure support with `--features azure`; this requires the same TPM system
dependencies as the HTTP proxy. There is no health-check listener in this version.

## Lifecycle and trust

CLI startup binds the listener without contacting the remote service. Each accepted
connection gets one setup attempt. Failures close that connection and are logged
with the endpoint and phase; the tunnel does not send HTTP/gRPC error messages.
It does not retry, reconnect an established stream, or replay application data.
gRPC channels and applications own reconnection, retries, and stream recovery.

The client verifies the remote server before forwarding source bytes. The server
verifies the client and application protocol before connecting to its target.
The negotiated ALPN must be `flashbots-ratls/1+tcp-tunnel`; the base transport's
bare ALPN fallback is rejected. An HTTP proxy is not a compatible tunnel peer.
After the existing attestation exchange, there is no additional tunnel framing
or readiness acknowledgement. Client-side setup completion does not confirm
that the remote target has connected; a subsequent failure closes the stream.

The local TCP legs rely on their deployment's trust boundary unless the
application supplies its own TLS. Attestation is checked once per new tunnel;
it does not continuously re-attest a long-lived connection or identify each
application sharing a local proxy. The target sees the tunnel server's IP and
receives no injected attestation metadata.

Forwarding uses bounded buffers and preserves half-closes: a sender may finish
its upload while still receiving a response. There is no established-stream
idle timeout or lifetime limit. Configure RPC deadlines and keepalive in gRPC.
A transport error closes the affected tunnel, including its concurrent RPCs.

SIGINT/Ctrl-C and SIGTERM stop acceptance and trigger bounded draining. An opaque
tunnel cannot generate gRPC GOAWAY or drain individual RPCs. Long-lived streams
are interrupted if still active when the grace period expires. Blocking quote
generation cannot be canceled by dropping its async task; the executable uses
bounded runtime shutdown after draining sockets. The connection cap bounds live
connections, not quote-generation work that outlives a setup timeout.

## Rust API

`TunnelClient` and `TunnelServer` offer `new`, `new_with_tls_config`, `local_addr`,
and `serve_until`. `TunnelOptions` contains setup timeout, connection count, and
shutdown grace. The custom constructors accept Rustls configurations alongside
attestation generators/verifiers and matching certificate chains. They replace
ALPN with the tunnel protocol. Initialize a Rustls crypto provider before use.

```rust,no_run
use attested_tls::attestation::{AttestationGenerator, AttestationVerifier};
use attested_tls_proxy::tcp_tunnel::{TunnelClient, TunnelOptions};

# async fn example() -> Result<(), Box<dyn std::error::Error>> {
let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
let client = TunnelClient::new(
"127.0.0.1:6000",
"tunnel.example.com:443".into(),
None, // optional client TLS identity
AttestationGenerator::with_no_attestation(),
AttestationVerifier::expect_none(), // replace with your measurement policy
None, // use public CA roots
false, // No startup check.
TunnelOptions::default(),
).await?;
client.serve_until(async {
let _ = tokio::signal::ctrl_c().await;
}).await?;
# Ok(())
# }
```

Dropping a `serve_until` future aborts its connection tasks. For graceful shutdown,
resolve the supplied shutdown future and await completion instead. Embedding
applications own runtime shutdown, including outstanding blocking attestation
work. The shared `attested_tls_proxy::tls` module provides TLS configuration helpers, including self-signed
verification that retains client credentials.

Both client constructors accept a `startup_check` boolean before `options`.
When true, construction binds the listener, verifies an upstream connection
(TLS, attestation, and tunnel ALPN), and closes that probe before returning.
The check uses `setup_timeout`; a failure returns an error and drops the listener.
It may cause an empty connection to the server's target, but does not confirm
target connectivity because the protocol has no readiness acknowledgement.
The CLI passes false and has no startup-check option.

## Validation

```sh
cargo check -p attested-tls-proxy --all-targets
cargo test -p attested-tls-proxy --test tcp_tunnel
```

Tests use mock attestation only as a development dependency. The HTTP/2 fixture
tests gRPC message framing, metadata and status trailers, multiplexed streaming,
and cancellation under response flow control without a protobuf compiler.
48 changes: 48 additions & 0 deletions crates/attested-tls-proxy/src/cli/attestation.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
use anyhow::anyhow;
use attested_tls::attestation::{
AttestationType, AttestationVerifier, PccsMode, measurements::MeasurementPolicy,
};

pub(super) async fn build_verifier(
measurements_file: Option<String>,
allowed_remote_attestation_type: Option<String>,
pccs_url: Option<String>,
log_dcap_quote: bool,
override_azure_outdated_tcb: bool,
) -> anyhow::Result<AttestationVerifier> {
if log_dcap_quote {
tokio::fs::create_dir_all("quotes").await?;
}

let measurement_policy = match measurements_file {
Some(server_measurements) => {
MeasurementPolicy::from_file_or_url(server_measurements).await?
}
None => {
match allowed_remote_attestation_type
.ok_or(anyhow!(
"Either a measurements file or an allowed attestation type must be provided"
))?
.to_lowercase()
.as_str()
{
"tdx" => MeasurementPolicy::tdx(),
attestation_type => {
let allowed_server_attestation_type: AttestationType = serde_json::from_value(
serde_json::Value::String(attestation_type.to_string()),
)?;
MeasurementPolicy::single_attestation_type(allowed_server_attestation_type)
}
}
}
};

let mut attestation_verifier_builder = AttestationVerifier::builder(measurement_policy)
.with_pccs_mode(PccsMode::Lazy)
.with_dump_dcap_quotes(log_dcap_quote)
.with_override_azure_outdated_tcb(override_azure_outdated_tcb);
if let Some(pccs_url) = pccs_url {
attestation_verifier_builder = attestation_verifier_builder.with_pccs_url(pccs_url);
}
Ok(attestation_verifier_builder.build())
}
Loading
Loading