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
5 changes: 5 additions & 0 deletions crates/starknet_transaction_prover/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ pub const OHTTP_JSONRPSEE_BODY_BUILDER: fn(Full<Bytes>) -> HttpBody = HttpBody::
/// than to the OHTTP ciphertext envelope. `MapRequestBodyLayer`/`MapResponseBodyLayer` keep
/// `HttpBody` on both sides of OHTTP to satisfy its symmetric-body bound; `HttpBody::new` is a
/// zero-cost wrapper, so non-OHTTP requests still stream through unbuffered.
/// - `RequestSpanLayer` sits BELOW `OhttpLayer` so it spans the decapsulated inner request with a
/// fresh, envelope-unlinkable id (see `request_span`).
macro_rules! prover_http_middleware {
($cors_layer:expr, $ohttp_layer:expr $(,)?) => {
ServiceBuilder::new()
Expand All @@ -55,6 +57,7 @@ macro_rules! prover_http_middleware {
.option_layer($cors_layer)
.layer(MapRequestBodyLayer::new(HttpBody::new))
.option_layer($ohttp_layer)
.layer(RequestSpanLayer)
.layer(MapResponseBodyLayer::new(HttpBody::new))
.layer(CompressionLayer::new())
};
Expand All @@ -70,12 +73,14 @@ pub mod middleware_test_utils;
#[cfg(test)]
pub mod mock_rpc;
pub mod request_log;
pub mod request_span;
pub mod rpc_api;
pub mod rpc_impl;
pub mod tls;

pub use health::{HealthLayer, HEALTH_PATH};
pub use request_log::{RequestLogLayer, REQUEST_ID_HEADER};
pub use request_span::RequestSpanLayer;

#[cfg(test)]
mod rpc_spec_test;
Expand Down
132 changes: 102 additions & 30 deletions crates/starknet_transaction_prover/src/server/ohttp_integration_test.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
//! Integration tests for the sequencer's OHTTP wiring. These tests exercise
//! the `tower_ohttp::OhttpLayer` with `jsonrpsee::server::HttpBody` as the
//! response body type and the same middleware stack used in production
//! (`OhttpLayer` outermost, `CompressionLayer` between OHTTP and the inner
//! service).
//! response body type, and — where the layer order matters — the production
//! middleware chain itself via `prover_http_middleware!`.
//!
//! The body-type-agnostic layer behavior (method/path/status/content-type
//! preservation, error paths, body size limits, passthrough) is covered by
Expand All @@ -16,9 +15,9 @@ use std::io::Read;

use flate2::read::GzDecoder;
use http::header;
use http_body_util::{BodyExt, Full};
use http_body_util::BodyExt;
use jsonrpsee::server::HttpBody;
use tower::{BoxError, Layer, Service};
use tower::{BoxError, Layer, Service, ServiceBuilder};
use tower_http::compression::CompressionLayer;
use tower_http::cors::CorsLayer;
use tower_http::map_request_body::MapRequestBodyLayer;
Expand All @@ -30,15 +29,13 @@ use tower_ohttp::test_utils::{
};
use tower_ohttp::OhttpLayer;

use crate::server::request_log::{RequestLogLayer, REQUEST_ID_HEADER};
use crate::server::request_span::RequestSpanLayer;
use crate::server::{HealthLayer, OHTTP_JSONRPSEE_BODY_BUILDER};

const DEFAULT_BODY_LIMIT: usize = 102_400;
const KEY_CACHE_SECS: u64 = 3600;

/// Body builder for jsonrpsee's `HttpBody`. Returned as a `fn` pointer to
/// give `OhttpLayer` a sized, `Copy` closure type without an `as` cast.
fn body_builder() -> fn(Full<bytes::Bytes>) -> HttpBody {
HttpBody::new
}

/// Echo service with jsonrpsee's `HttpBody` on both sides — matches the
/// layer's new symmetric-body inner service bound.
async fn jsonrpsee_echo_service(
Expand Down Expand Up @@ -67,8 +64,12 @@ fn ohttp_http_request(encapsulated: Vec<u8>) -> http::Request<HttpBody> {
#[tokio::test]
async fn ohttp_round_trip_with_jsonrpsee_body() {
let gateway = test_gateway();
let layer =
OhttpLayer::new(gateway.clone(), DEFAULT_BODY_LIMIT, KEY_CACHE_SECS, body_builder());
let layer = OhttpLayer::new(
gateway.clone(),
DEFAULT_BODY_LIMIT,
KEY_CACHE_SECS,
OHTTP_JSONRPSEE_BODY_BUILDER,
);
let mut svc = layer.layer(tower::service_fn(jsonrpsee_echo_service));

let json_body = br#"{"jsonrpc":"2.0","method":"starknet_specVersion","id":1}"#;
Expand All @@ -85,24 +86,21 @@ async fn ohttp_round_trip_with_jsonrpsee_body() {
assert_eq!(decapsulated.body, json_body);
}

/// Verify the full production `ServiceBuilder` chain compresses the *inner*
/// JSON-RPC response and leaves the *outer* OHTTP envelope uncompressed.
/// Mirrors the exact chain in `server.rs`/`tls.rs`, so any drift in layer
/// order or a missing `MapResponseBodyLayer` will break this test.
/// Verify the production middleware chain compresses the *inner* JSON-RPC
/// response and leaves the *outer* OHTTP envelope uncompressed. Runs the
/// actual `prover_http_middleware!` chain, so any layer reorder that breaks
/// compress-then-encrypt fails here.
#[tokio::test]
async fn production_chain_compresses_inner_not_outer() {
let gateway = test_gateway();
let ohttp_layer =
OhttpLayer::new(gateway.clone(), DEFAULT_BODY_LIMIT, KEY_CACHE_SECS, body_builder());

// Replicates the production ServiceBuilder chain from `server.rs`/`tls.rs`.
// Must be kept in sync with those files.
let mut svc = tower::ServiceBuilder::new()
.option_layer(None::<CorsLayer>)
.layer(MapRequestBodyLayer::new(HttpBody::new))
.option_layer(Some(ohttp_layer))
.layer(MapResponseBodyLayer::new(HttpBody::new))
.layer(CompressionLayer::new())
let ohttp_layer = OhttpLayer::new(
gateway.clone(),
DEFAULT_BODY_LIMIT,
KEY_CACHE_SECS,
OHTTP_JSONRPSEE_BODY_BUILDER,
);

let mut svc = prover_http_middleware!(None::<CorsLayer>, Some(ohttp_layer))
.service(tower::service_fn(jsonrpsee_echo_service));

// Body must be large enough for gzip to actually compress.
Expand Down Expand Up @@ -158,8 +156,12 @@ async fn production_chain_compresses_inner_not_outer() {
#[tokio::test]
async fn non_ohttp_request_passes_through_jsonrpsee() {
let gateway = test_gateway();
let layer =
OhttpLayer::new(gateway.clone(), DEFAULT_BODY_LIMIT, KEY_CACHE_SECS, body_builder());
let layer = OhttpLayer::new(
gateway.clone(),
DEFAULT_BODY_LIMIT,
KEY_CACHE_SECS,
OHTTP_JSONRPSEE_BODY_BUILDER,
);
let mut svc = layer.layer(tower::service_fn(jsonrpsee_echo_service));

let json_body = br#"{"jsonrpc":"2.0","method":"starknet_specVersion","id":1}"#;
Expand All @@ -176,3 +178,73 @@ async fn non_ohttp_request_passes_through_jsonrpsee() {
let body = response.into_body().collect().await.unwrap().to_bytes();
assert_eq!(body.as_ref(), json_body);
}

/// End-to-end OHTTP unlinkability: the request-id echoed on the OUTER
/// (relay-visible) response must differ from the fresh id bound to the
/// decapsulated inner dispatch, and the client-supplied inner id must be
/// discarded — so no shared key links the relay's view to the gateway's.
/// Runs the actual `prover_http_middleware!` chain, so a layer reorder that
/// reverts either property fails here.
#[tokio::test]
async fn ohttp_inner_request_id_unlinkable_from_envelope() {
let gateway = test_gateway();
let ohttp_layer = OhttpLayer::new(
gateway.clone(),
DEFAULT_BODY_LIMIT,
KEY_CACHE_SECS,
OHTTP_JSONRPSEE_BODY_BUILDER,
);

// Inner service echoes the request-id it observes into the response body.
let echo_id = tower::service_fn(|req: http::Request<HttpBody>| async move {
let id = req.headers().get(REQUEST_ID_HEADER).map_or("", |v| v.to_str().unwrap());
Ok::<_, BoxError>(
http::Response::builder()
.status(http::StatusCode::OK)
.body(HttpBody::from(id.as_bytes().to_vec()))
.unwrap(),
)
});

let mut svc = prover_http_middleware!(None::<CorsLayer>, Some(ohttp_layer)).service(echo_id);

// The envelope carries a client-chosen inner id that must be discarded.
let (encapsulated, client_response) = encapsulate_bhttp_request(
&gateway,
"POST",
"/",
b"",
&[("x-request-id", b"inner-client-id")],
);

// The outer envelope request carries the relay-visible id.
let mut outer_request = ohttp_http_request(encapsulated);
outer_request
.headers_mut()
.insert(REQUEST_ID_HEADER, http::HeaderValue::from_static("envelope-relay-id"));

let response = svc.call(outer_request).await.unwrap();

// The outer (relay-visible) response echoes the envelope id.
let envelope_id =
response.headers().get(REQUEST_ID_HEADER).unwrap().to_str().unwrap().to_owned();
assert_eq!(envelope_id, "envelope-relay-id");

let encrypted_body = response.into_body().collect().await.unwrap().to_bytes();
let decapsulated = decapsulate_bhttp_response(client_response, &encrypted_body);
assert_eq!(decapsulated.status, 200);
let inner_id = String::from_utf8(decapsulated.body).expect("utf8 inner id");

assert_ne!(inner_id, envelope_id, "inner id must not equal the relay-visible envelope id");
assert_ne!(inner_id, "inner-client-id", "client-supplied inner id must be discarded");
assert!(
uuid::Uuid::parse_str(&inner_id).is_ok(),
"inner id must be a fresh UUID, got {inner_id:?}"
);
// No id is set on the inner *response*, so nothing — neither the envelope
// id nor the fresh content id — leaks into the encrypted reply's headers.
assert!(
decapsulated.bhttp_message.header().get(b"x-request-id").is_none(),
"inner OHTTP response must not carry an x-request-id header"
);
}
80 changes: 80 additions & 0 deletions crates/starknet_transaction_prover/src/server/request_span.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
//! tower middleware that binds an `http_request` tracing span over the
//! downstream dispatch. It sits BELOW the OHTTP layer, so it sees the
//! decapsulated inner request (or a plaintext pass-through), and picks the id:
//!
//! - **plaintext** — reuse the `x-request-id` the outer layer already assigned;
//! - **OHTTP-decapsulated** ([`tower_ohttp::Decapsulated`]) — mint a fresh id (any client-supplied
//! inner id was already stripped at decapsulation). The relay never observes it, so the
//! relay-visible envelope id and this content-log id share no join key. Note the residual: at low
//! traffic volume, timestamp proximity in persisted logs still permits probabilistic correlation
//! — an OHTTP traffic-analysis property id separation cannot eliminate.
//!
//! See [`super::request_log`] for why the envelope and content ids are kept
//! separate (OHTTP unlinkability).

use std::task::{Context, Poll};

use http::{Request, Response};
use tower::{Layer, Service};
use tower_ohttp::Decapsulated;
use tracing::instrument::Instrumented;
use tracing::{info_span, Instrument};

use crate::server::request_log::{
extract_or_generate_request_id,
new_request_id,
request_id_header_value,
REQUEST_ID_HEADER,
};

#[cfg(test)]
#[path = "request_span_test.rs"]
mod request_span_test;

/// tower [`Layer`] producing [`RequestSpanService`].
#[derive(Clone, Copy, Default)]
pub struct RequestSpanLayer;

impl<S> Layer<S> for RequestSpanLayer {
type Service = RequestSpanService<S>;

fn layer(&self, inner: S) -> Self::Service {
RequestSpanService { inner }
}
}

#[derive(Clone)]
pub struct RequestSpanService<S> {
inner: S,
}

impl<S, ReqB, RespB> Service<Request<ReqB>> for RequestSpanService<S>
where
S: Service<Request<ReqB>, Response = Response<RespB>>,
{
type Response = Response<RespB>;
type Error = S::Error;
type Future = Instrumented<S::Future>;

fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
self.inner.poll_ready(cx)
}

fn call(&mut self, mut request: Request<ReqB>) -> Self::Future {
let request_id = if request.extensions().get::<Decapsulated>().is_some() {
// Fresh id, distinct from the relay-visible envelope id (OHTTP
// unlinkability). Inserted so downstream readers see the id the
// span carries.
let fresh_id = new_request_id();
request.headers_mut().insert(REQUEST_ID_HEADER, request_id_header_value(&fresh_id));
fresh_id
} else {
// Re-derives, via the shared validator, the exact id
// `RequestLogLayer` already assigned — the header is left
// untouched. This also keeps the layer correct standalone,
// e.g. in unit tests without `RequestLogLayer` upstream.
extract_or_generate_request_id(&request)
};
self.inner.call(request).instrument(info_span!("http_request", request_id = %request_id))
}
}
70 changes: 70 additions & 0 deletions crates/starknet_transaction_prover/src/server/request_span_test.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
use bytes::Bytes;
use http::{Method, Request};
use http_body_util::Full;
use jsonrpsee::server::HttpBody;
use tower::{Layer, ServiceExt};
use tower_ohttp::Decapsulated;

use crate::server::middleware_test_utils::{echo_request_id_service, read_body_and_headers};
use crate::server::request_log::{RequestLogLayer, REQUEST_ID_HEADER};
use crate::server::request_span::RequestSpanLayer;

#[tokio::test]
async fn plaintext_reuses_inbound_request_id() {
let request = Request::builder()
.method(Method::POST)
.uri("/")
.header(REQUEST_ID_HEADER, "reused-xyz")
.body(HttpBody::new(Full::new(Bytes::new())))
.expect("static body is infallible");

let response =
RequestSpanLayer.layer(echo_request_id_service()).oneshot(request).await.unwrap();

let (body, _headers) = read_body_and_headers(response).await;
assert_eq!(body, "reused-xyz");
}

#[tokio::test]
async fn decapsulated_gets_fresh_id_discarding_inbound() {
let mut request = Request::builder()
.method(Method::POST)
.uri("/")
.header(REQUEST_ID_HEADER, "envelope-abc")
.body(HttpBody::new(Full::new(Bytes::new())))
.expect("static body is infallible");
request.extensions_mut().insert(Decapsulated);

let response =
RequestSpanLayer.layer(echo_request_id_service()).oneshot(request).await.unwrap();

let (id, _headers) = read_body_and_headers(response).await;
assert_ne!(id, "envelope-abc", "must discard the client-supplied inner id");
assert!(uuid::Uuid::parse_str(&id).is_ok(), "must mint a fresh UUID, got {id:?}");
}

/// The cross-layer plaintext contract: with `RequestLogLayer` (outer) stacked
/// over `RequestSpanLayer` (inner) and no inbound id, the id the outer layer
/// generates and echoes on the response must be the same id the inner layer
/// binds for the handler — one shared id end-to-end.
#[tokio::test]
async fn plaintext_log_and_span_layers_share_generated_id() {
let request = Request::builder()
.method(Method::POST)
.uri("/")
.body(HttpBody::new(Full::new(Bytes::new())))
.expect("static body is infallible");

let svc = RequestLogLayer.layer(RequestSpanLayer.layer(echo_request_id_service()));
let response = svc.oneshot(request).await.unwrap();

let (handler_id, headers) = read_body_and_headers(response).await;
let echoed_id =
headers.get(REQUEST_ID_HEADER).expect("response carries the id").to_str().unwrap();

assert_eq!(echoed_id, handler_id, "echoed response id must equal the id the handler saw");
assert!(
uuid::Uuid::parse_str(&handler_id).is_ok(),
"generated id must be a UUID, got {handler_id:?}"
);
}
2 changes: 1 addition & 1 deletion crates/starknet_transaction_prover/src/server/tls.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ use tower_http::map_request_body::MapRequestBodyLayer;
use tower_http::map_response_body::MapResponseBodyLayer;
use tracing::warn;

use crate::server::{HealthLayer, OhttpJsonrpseeLayer, RequestLogLayer};
use crate::server::{HealthLayer, OhttpJsonrpseeLayer, RequestLogLayer, RequestSpanLayer};

/// Maximum time allowed for a TLS handshake before the connection is dropped.
const TLS_HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(10);
Expand Down
Loading
Loading