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
23 changes: 23 additions & 0 deletions src/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src/integration/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ hex = { workspace = true }
visualsign-solana = { path = "../chain_parsers/visualsign-solana" }
tracing = { workspace = true }
reqwest = { version = "0.12", default-features = false, features = ["json"] }
turnkey_api_key_stamper = "0.10"

[lints]
workspace = true
71 changes: 71 additions & 0 deletions src/integration/tests/http_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ use std::time::Duration;

use integration::{ChildWrapper, find_free_port, wait_until_port_is_bound};
use qos_p256::P256Pair;
use turnkey_api_key_stamper::{Stamp, TurnkeyP256ApiKey};

// Same Ethereum signed legacy transaction used by
// `integration::tests::parser_ethereum_native_transfer_e2e`.
Expand All @@ -23,6 +24,12 @@ struct RunningServer {

impl RunningServer {
async fn start() -> Self {
Self::start_with_args(&[]).await
}

/// Same as `start`, but with extra CLI args appended (e.g.
/// `--allowed-stamp-pubkeys-hex <csv>`).
async fn start_with_args(extra_args: &[&str]) -> Self {
let test_id = format!("{:?}", rand::random::<u64>());
let work_dir = format!("./{test_id}-http-server-workdir");
let enclave_dir = format!("{work_dir}/local-enclave");
Expand All @@ -46,6 +53,7 @@ impl RunningServer {
let mut child = Command::new(binary)
.arg("--port")
.arg(port.to_string())
.args(extra_args)
.current_dir(&work_dir)
.spawn()
.expect("failed to spawn parser_http_server");
Expand Down Expand Up @@ -187,3 +195,66 @@ async fn http_server_serves_health_parse_and_errors() {
malformed_keys.sort();
assert_eq!(malformed_keys, expected_keys);
}

#[tokio::test]
async fn http_server_enforces_x_stamp_when_allowlist_is_configured() {
let allowed = TurnkeyP256ApiKey::generate();
let other = TurnkeyP256ApiKey::generate();
let allowlist_hex = hex::encode(allowed.compressed_public_key());

let server =
RunningServer::start_with_args(&["--allowed-stamp-pubkeys-hex", &allowlist_hex]).await;
let client = reqwest::Client::new();

let body = serde_json::json!({
"request": {
"chain": "CHAIN_ETHEREUM",
"unsigned_payload": ETH_TX_HEX,
}
});
// Sign the exact bytes sent over the wire, not a re-serialized copy:
// this is what the pivot verifies against.
let raw_body = serde_json::to_vec(&body).expect("failed to serialize body");

// 1. No X-Stamp header: 401 with bootProof present.
let unstamped = client
.post(format!("{}/visualsign/api/v1/parse", server.base_url))
.header("content-type", "application/json")
.body(raw_body.clone())
.send()
.await
.expect("unstamped request failed");
assert_eq!(unstamped.status(), reqwest::StatusCode::UNAUTHORIZED);
let unstamped_value: serde_json::Value = unstamped
.json()
.await
.expect("unstamped response was not valid JSON");
assert!(
unstamped_value.get("bootProof").is_some(),
"401 response must still carry bootProof"
);

// 2. Stamped by a listed key: 200.
let stamp = allowed.stamp(&raw_body).expect("failed to stamp body");
let listed = client
.post(format!("{}/visualsign/api/v1/parse", server.base_url))
.header("content-type", "application/json")
.header(stamp.name, stamp.value)
.body(raw_body.clone())
.send()
.await
.expect("listed-key request failed");
assert_eq!(listed.status(), reqwest::StatusCode::OK);

// 3. Stamped by an unlisted key: 401.
let other_stamp = other.stamp(&raw_body).expect("failed to stamp body");
let unlisted = client
.post(format!("{}/visualsign/api/v1/parse", server.base_url))
.header("content-type", "application/json")
.header(other_stamp.name, other_stamp.value)
.body(raw_body)
.send()
.await
.expect("unlisted-key request failed");
assert_eq!(unlisted.status(), reqwest::StatusCode::UNAUTHORIZED);
}
10 changes: 10 additions & 0 deletions src/parser/http-server/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,20 @@ vsock = ["qos_core/vm"]
parser_app = { path = "../app", default-features = false }
generated = { path = "../../generated", features = ["tonic_types", "serde_derive"] }
host_primitives = { path = "../../host_primitives" }
visualsign = { path = "../../visualsign" }

# Crypto / hashing / manifest re-encoding for the boot-proof seam.
qos_core = { workspace = true }
qos_hex = { workspace = true }
qos_p256 = { workspace = true }
borsh = { version = "1", features = ["std", "derive"], default-features = false }

# X-Stamp verification (see src/stamp.rs).
p256 = { version = "0.13", default-features = false, features = ["ecdsa", "std"] }
k256 = { version = "0.13", default-features = false, features = ["ecdsa", "std"] }
subtle = { version = "2", default-features = false }
hex = { workspace = true }

# HTTP + serialization.
axum = { version = "0.8", features = ["http1", "http2", "tokio", "json"], default-features = false }
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "signal", "time", "net"] }
Expand All @@ -49,6 +56,9 @@ base64 = { workspace = true }
# `env = "..."` attribute keeps compose + integration tests working unchanged.
clap = { version = "4.0", features = ["derive", "env"] }

[dev-dependencies]
turnkey_api_key_stamper = "0.10"

[lints]
workspace = true

Expand Down
57 changes: 49 additions & 8 deletions src/parser/http-server/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,12 @@
//! a non-canonical path, bind-mount it instead.

mod boot_proof;
mod stamp;

use axum::{
Json, Router,
extract::State,
http::StatusCode,
http::{HeaderMap, StatusCode},
routing::{get, post},
};
use base64::Engine as _;
Expand All @@ -51,6 +52,7 @@ use host_primitives::turnkey::{
use parser_app::routes::parse::parse;
use qos_core::handles::EphemeralKeyHandle;
use qos_p256::P256Pair;
use stamp::Allowlist;
use std::net::SocketAddr;
use std::sync::Arc;

Expand All @@ -68,36 +70,48 @@ struct Args {
/// Deployment label reported in every response's `bootProof`.
#[arg(long, env = "DEPLOYMENT_LABEL", default_value = "")]
deployment_label: String,

/// Comma-separated compressed SEC1 hex pubkeys allowed to call the parse
/// routes. Absent means the routes stay open (today's behavior);
/// present means every request must carry a valid X-Stamp from a listed
/// key. Delivered via `pivotArgs` at deploy time.
#[arg(long, env = "ALLOWED_STAMP_PUBKEYS_HEX")]
allowed_stamp_pubkeys_hex: Option<String>,
}

#[derive(Clone)]
struct AppState {
ephemeral_key: Arc<P256Pair>,
boot_proof: Arc<dyn BootProofSource + Send + Sync>,
allowlist: Option<Arc<Allowlist>>,
}

async fn health() -> StatusCode {
StatusCode::OK
}

// Handlers take raw bytes, never `Json<T>`. A later PR verifies an X-Stamp
// signature over the exact request bytes; a `Json<T>` extractor re-serializes
// Handlers take raw bytes, never `Json<T>`. The X-Stamp signature is verified
// against the exact request bytes; a `Json<T>` extractor re-serializes
// before the handler body runs, changing key order / whitespace / unicode
// escaping and invalidating every signature. Both routes share one body.
// `headers` comes before `body` because axum requires body-consuming
// extractors last.
async fn parse_v1(
State(state): State<AppState>,
headers: HeaderMap,
body: axum::body::Bytes,
) -> (StatusCode, Json<TurnkeyResponseWrapper>) {
handle_parse(&state, &body)
handle_parse(&state, &headers, &body)
}

/// v2 is byte-identical to v1 in this PR. Registering it now keeps the
/// deployed URL stable across the stack as later PRs add enforcement here.
/// deployed URL stable across the stack as later PRs add payment enforcement here.
async fn parse_v2(
State(state): State<AppState>,
headers: HeaderMap,
body: axum::body::Bytes,
) -> (StatusCode, Json<TurnkeyResponseWrapper>) {
handle_parse(&state, &body)
handle_parse(&state, &headers, &body)
}

/// Deserialize the envelope from the original bytes. Kept separate so the
Expand All @@ -106,8 +120,27 @@ fn parse_envelope(body: &[u8]) -> Result<TurnkeyRequestWrapper, serde_json::Erro
serde_json::from_slice(body)
}

fn handle_parse(state: &AppState, body: &[u8]) -> (StatusCode, Json<TurnkeyResponseWrapper>) {
// A later PR inserts the X-Stamp check here, before anything else touches `body`.
fn handle_parse(
state: &AppState,
headers: &HeaderMap,
body: &[u8],
) -> (StatusCode, Json<TurnkeyResponseWrapper>) {
if let Some(allowlist) = state.allowlist.as_deref() {
if let Err(e) = stamp::verify(headers, body, allowlist) {
eprintln!("rejected request: {e:?}");
// Deliberately coarse: the client learns "not authenticated", not
// which check failed, so the error text cannot be used to probe
// the allowlist.
return (
StatusCode::UNAUTHORIZED,
Json(error_response(
"invalid or missing X-Stamp".to_string(),
state.boot_proof.boot_proof(),
)),
);
}
}

let wrapper = match parse_envelope(body) {
Ok(w) => w,
Err(e) => {
Expand Down Expand Up @@ -239,9 +272,17 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
args.deployment_label,
);

// Absent means the routes stay open (today's behavior); present means
// every request must carry a valid X-Stamp from a listed key.
let allowlist = args
.allowed_stamp_pubkeys_hex
.map(|csv| Allowlist::from_hex_list(&csv).expect("invalid --allowed-stamp-pubkeys-hex"))
.map(Arc::new);

let state = AppState {
ephemeral_key: Arc::new(ephemeral_key),
boot_proof: Arc::new(boot_proof),
allowlist,
};

// 64 KiB caps every parse-request body the TVC pivot will accept.
Expand Down
Loading
Loading