From f5568232e0964f100b27dcc25c86770314c03e3e Mon Sep 17 00:00:00 2001 From: "pepe.figueira" Date: Thu, 6 Aug 2026 13:23:23 +0000 Subject: [PATCH] feat(parser_http_server): validate X-Stamp in the enclave Turnkey's gateway authenticates callers against their DB and we lose that when the pivot becomes the front door. Keep the X-Stamp wire shape, move validation into the pivot against a pubkey allowlist pinned via pivotArgs (same delivery as --gateway-signing-pubkey-hex, so rotation is a redeploy and nothing new to build). Verification runs against the raw body bytes. A Json round-trip re-serializes and changes them, so the seam PR 3 cut (handlers take Bytes) is what makes this correct; there is a test that pins it. What we give up versus the DB check: per-org identity, activity policy, instant revocation. Acceptable while the caller set is small and known; the signed-allowlist option is the follow-up if rotation gets painful. Co-Authored-By: Claude --- src/Cargo.lock | 23 +++ src/integration/Cargo.toml | 1 + src/integration/tests/http_server.rs | 71 ++++++++ src/parser/http-server/Cargo.toml | 10 ++ src/parser/http-server/src/main.rs | 57 ++++++- src/parser/http-server/src/stamp.rs | 240 +++++++++++++++++++++++++++ 6 files changed, 394 insertions(+), 8 deletions(-) create mode 100644 src/parser/http-server/src/stamp.rs diff --git a/src/Cargo.lock b/src/Cargo.lock index 38476b71..bb2e3caf 100644 --- a/src/Cargo.lock +++ b/src/Cargo.lock @@ -4749,6 +4749,7 @@ dependencies = [ "tonic 0.10.2", "tracing", "tracing-test", + "turnkey_api_key_stamper", "visualsign-solana", ] @@ -6600,14 +6601,20 @@ dependencies = [ "borsh 1.6.0", "clap", "generated", + "hex", "host_primitives", + "k256 0.13.4", + "p256", "parser_app", "qos_core", "qos_hex", "qos_p256", "serde", "serde_json", + "subtle", "tokio", + "turnkey_api_key_stamper", + "visualsign", ] [[package]] @@ -13829,6 +13836,22 @@ dependencies = [ "utf-8", ] +[[package]] +name = "turnkey_api_key_stamper" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c0ca5df1b0c48a971fc24dfd83323b8f75a2671776bb4842e07c9a87e31b874" +dependencies = [ + "base64 0.22.1", + "hex", + "k256 0.13.4", + "p256", + "rand_core 0.6.4", + "serde", + "serde_json", + "thiserror 2.0.17", +] + [[package]] name = "typed-store-error" version = "0.4.0" diff --git a/src/integration/Cargo.toml b/src/integration/Cargo.toml index fc1ee28b..c0cea9d8 100644 --- a/src/integration/Cargo.toml +++ b/src/integration/Cargo.toml @@ -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 diff --git a/src/integration/tests/http_server.rs b/src/integration/tests/http_server.rs index 3f68139c..d2203afb 100644 --- a/src/integration/tests/http_server.rs +++ b/src/integration/tests/http_server.rs @@ -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`. @@ -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 `). + async fn start_with_args(extra_args: &[&str]) -> Self { let test_id = format!("{:?}", rand::random::()); let work_dir = format!("./{test_id}-http-server-workdir"); let enclave_dir = format!("{work_dir}/local-enclave"); @@ -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"); @@ -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); +} diff --git a/src/parser/http-server/Cargo.toml b/src/parser/http-server/Cargo.toml index bc9a52b3..16d58c17 100644 --- a/src/parser/http-server/Cargo.toml +++ b/src/parser/http-server/Cargo.toml @@ -31,6 +31,7 @@ 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 } @@ -38,6 +39,12 @@ 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"] } @@ -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 diff --git a/src/parser/http-server/src/main.rs b/src/parser/http-server/src/main.rs index 6b6f319b..0e6c1cb6 100644 --- a/src/parser/http-server/src/main.rs +++ b/src/parser/http-server/src/main.rs @@ -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 _; @@ -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; @@ -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, } #[derive(Clone)] struct AppState { ephemeral_key: Arc, boot_proof: Arc, + allowlist: Option>, } async fn health() -> StatusCode { StatusCode::OK } -// Handlers take raw bytes, never `Json`. A later PR verifies an X-Stamp -// signature over the exact request bytes; a `Json` extractor re-serializes +// Handlers take raw bytes, never `Json`. The X-Stamp signature is verified +// against the exact request bytes; a `Json` 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, + headers: HeaderMap, body: axum::body::Bytes, ) -> (StatusCode, Json) { - 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, + headers: HeaderMap, body: axum::body::Bytes, ) -> (StatusCode, Json) { - handle_parse(&state, &body) + handle_parse(&state, &headers, &body) } /// Deserialize the envelope from the original bytes. Kept separate so the @@ -106,8 +120,27 @@ fn parse_envelope(body: &[u8]) -> Result (StatusCode, Json) { - // 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) { + 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) => { @@ -239,9 +272,17 @@ async fn main() -> Result<(), Box> { 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. diff --git a/src/parser/http-server/src/stamp.rs b/src/parser/http-server/src/stamp.rs new file mode 100644 index 00000000..3696b03d --- /dev/null +++ b/src/parser/http-server/src/stamp.rs @@ -0,0 +1,240 @@ +//! Validates the `X-Stamp` header Turnkey's stamper attaches to a request. +//! +//! The stamp covers the raw request body only: no timestamp, no nonce, no +//! method or path. Verification must run against the exact bytes the client +//! sent, never a re-serialized form (see `handle_parse` in `main.rs` and the +//! `signature_is_checked_against_raw_bytes_not_reserialized_json` test below). + +use axum::http::HeaderMap; +use base64::Engine as _; +use base64::prelude::BASE64_URL_SAFE_NO_PAD; +use serde::Deserialize; +use subtle::ConstantTimeEq; + +/// Header and scheme names are fixed by Turnkey's stamper +/// (turnkey_api_key_stamper 0.10: API_KEY_STAMP_HEADER_NAME, +/// SIGNATURE_SCHEME_P256, SIGNATURE_SCHEME_SECP256K1). +const STAMP_HEADER: &str = "X-Stamp"; +const SCHEME_P256: &str = "SIGNATURE_SCHEME_TK_API_P256"; +const SCHEME_SECP256K1: &str = "SIGNATURE_SCHEME_TK_API_SECP256K1"; + +/// `Malformed` and `UnsupportedScheme` carry context read only via `{e:?}` +/// in `main.rs`'s deliberately coarse client-facing error (see `verify`'s +/// callsite); same pattern as `boot_proof::BootProofError`. +#[derive(Debug)] +#[allow(dead_code)] +pub enum StampError { + Missing, + Malformed(String), + UnsupportedScheme(String), + UnknownKey, + BadSignature, +} + +/// Wire form of the header value: base64url-no-pad JSON. +/// +/// Deliberately NOT `deny_unknown_fields`: the producer is Turnkey's stamper, +/// and a field added on their side would otherwise fail every request. The +/// three fields we read are the ones the signature scheme is defined over. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct ApiStamp { + public_key: String, + signature: String, + scheme: String, +} + +/// Compressed SEC1 pubkeys permitted to call the parse routes. Delivered via +/// `pivotArgs` at deploy time, the same mechanism that pins the gateway signing +/// key, so rotation costs a redeploy but no rebuild. A signed allowlist +/// document (option c in PRS-581) is the follow-up if that hurts. +pub struct Allowlist { + keys: Vec>, +} + +impl Allowlist { + pub fn from_hex_list(csv: &str) -> Result { + let mut keys = Vec::new(); + for entry in csv.split(',').map(str::trim).filter(|s| !s.is_empty()) { + let bytes = visualsign::encodings::decode_hex(entry) + .map_err(|e| StampError::Malformed(format!("allowlist entry {entry}: {e}")))?; + if bytes.len() != 33 { + return Err(StampError::Malformed(format!( + "allowlist entry {entry} is {} bytes, expected 33 (compressed SEC1)", + bytes.len() + ))); + } + keys.push(bytes); + } + if keys.is_empty() { + return Err(StampError::Malformed("allowlist is empty".to_string())); + } + Ok(Self { keys }) + } + + /// Constant-time membership: a timing signal here would leak which keys are + /// allowlisted. Same posture as parser_gateway's auth/attestation compares. + fn contains(&self, candidate: &[u8]) -> bool { + let mut found = subtle::Choice::from(0u8); + for key in &self.keys { + found |= key.as_slice().ct_eq(candidate); + } + found.into() + } +} + +/// Verify the `X-Stamp` header against the **raw** request bytes. +/// +/// The stamp covers the body only: no timestamp, no nonce, no method or path. +/// It is replayable by design, which is acceptable for a stateless read-only +/// parse. Combined with x402, a replayed body plus its original VPM is a free +/// re-parse of the same transaction; the VPM commits to request_hash, so it +/// cannot be redirected at a different one. +pub fn verify(headers: &HeaderMap, body: &[u8], allowlist: &Allowlist) -> Result<(), StampError> { + let raw = headers.get(STAMP_HEADER).ok_or(StampError::Missing)?; + let decoded = BASE64_URL_SAFE_NO_PAD + .decode(raw.as_bytes()) + .map_err(|e| StampError::Malformed(format!("base64url: {e}")))?; + let stamp: ApiStamp = serde_json::from_slice(&decoded) + .map_err(|e| StampError::Malformed(format!("stamp json: {e}")))?; + + let pubkey = visualsign::encodings::decode_hex(&stamp.public_key) + .map_err(|e| StampError::Malformed(format!("publicKey hex: {e}")))?; + if !allowlist.contains(&pubkey) { + return Err(StampError::UnknownKey); + } + let sig_der = visualsign::encodings::decode_hex(&stamp.signature) + .map_err(|e| StampError::Malformed(format!("signature hex: {e}")))?; + + match stamp.scheme.as_str() { + SCHEME_P256 => { + use p256::ecdsa::{DerSignature, VerifyingKey, signature::Verifier}; + let key = VerifyingKey::from_sec1_bytes(&pubkey) + .map_err(|e| StampError::Malformed(format!("p256 pubkey: {e}")))?; + let sig = DerSignature::from_bytes(&sig_der) + .map_err(|e| StampError::Malformed(format!("p256 der: {e}")))?; + key.verify(body, &sig).map_err(|_| StampError::BadSignature) + } + SCHEME_SECP256K1 => { + use k256::ecdsa::{DerSignature, VerifyingKey, signature::Verifier}; + let key = VerifyingKey::from_sec1_bytes(&pubkey) + .map_err(|e| StampError::Malformed(format!("k256 pubkey: {e}")))?; + let sig = DerSignature::from_bytes(&sig_der) + .map_err(|e| StampError::Malformed(format!("k256 der: {e}")))?; + key.verify(body, &sig).map_err(|_| StampError::BadSignature) + } + other => Err(StampError::UnsupportedScheme(other.to_string())), + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod tests { + use super::*; + use axum::http::{HeaderMap, HeaderValue}; + use turnkey_api_key_stamper::{Stamp, TurnkeyP256ApiKey}; + + fn headers_for(key: &TurnkeyP256ApiKey, body: &[u8]) -> HeaderMap { + let stamp = key.stamp(body).unwrap(); + let mut headers = HeaderMap::new(); + headers.insert("X-Stamp", HeaderValue::from_str(&stamp.value).unwrap()); + headers + } + + #[test] + fn accepts_a_stamp_from_an_allowlisted_key() { + let key = TurnkeyP256ApiKey::generate(); + let allowlist = + Allowlist::from_hex_list(&hex::encode(key.compressed_public_key())).unwrap(); + let body = br#"{"request":{"chain":"CHAIN_ETHEREUM","unsigned_payload":"0x02"}}"#; + verify(&headers_for(&key, body), body, &allowlist).unwrap(); + } + + #[test] + fn rejects_a_stamp_from_an_unlisted_key() { + let signer = TurnkeyP256ApiKey::generate(); + let other = TurnkeyP256ApiKey::generate(); + let allowlist = + Allowlist::from_hex_list(&hex::encode(other.compressed_public_key())).unwrap(); + let body = br#"{"request":{}}"#; + let err = verify(&headers_for(&signer, body), body, &allowlist).unwrap_err(); + assert!(matches!(err, StampError::UnknownKey)); + } + + #[test] + fn signature_is_checked_against_raw_bytes_not_reserialized_json() { + // The acceptance criterion for this PR. The stamp is signed over the + // exact bytes; serde's output differs in key order and whitespace, so + // verifying the re-serialized form must fail. + let key = TurnkeyP256ApiKey::generate(); + let allowlist = + Allowlist::from_hex_list(&hex::encode(key.compressed_public_key())).unwrap(); + // `serde_json::Value` preserves key insertion order in this workspace + // (some dependency turns on serde_json's `preserve_order` feature, + // and Cargo unifies it for every user of the crate), so a re-ordered + // fixture round-trips byte-identical and would make this test pass + // for the wrong reason. The whitespace this fixture adds is the part + // `serde_json::to_vec`'s compact output always drops, so the + // round-trip is guaranteed to differ regardless of that feature. + let raw = br#"{"request": {"chain":"CHAIN_ETHEREUM", "unsigned_payload": "0x02"}}"#; + let headers = headers_for(&key, raw); + + verify(&headers, raw, &allowlist).unwrap(); + + let value: serde_json::Value = serde_json::from_slice(raw).unwrap(); + let reserialized = serde_json::to_vec(&value).unwrap(); + assert_ne!( + reserialized.as_slice(), + raw.as_slice(), + "fixture must actually differ" + ); + let err = verify(&headers, &reserialized, &allowlist).unwrap_err(); + assert!(matches!(err, StampError::BadSignature)); + } + + #[test] + fn rejects_missing_header_and_malformed_encodings() { + let key = TurnkeyP256ApiKey::generate(); + let allowlist = + Allowlist::from_hex_list(&hex::encode(key.compressed_public_key())).unwrap(); + let body = br#"{}"#; + assert!(matches!( + verify(&HeaderMap::new(), body, &allowlist), + Err(StampError::Missing) + )); + + let mut bad_b64 = HeaderMap::new(); + bad_b64.insert("X-Stamp", HeaderValue::from_static("!!!not-base64url!!!")); + assert!(matches!( + verify(&bad_b64, body, &allowlist), + Err(StampError::Malformed(_)) + )); + + let mut bad_json = HeaderMap::new(); + let payload = base64::prelude::BASE64_URL_SAFE_NO_PAD.encode(b"{\"nope\":1}"); + bad_json.insert("X-Stamp", HeaderValue::from_str(&payload).unwrap()); + assert!(matches!( + verify(&bad_json, body, &allowlist), + Err(StampError::Malformed(_)) + )); + } + + #[test] + fn rejects_an_unsupported_scheme() { + let key = TurnkeyP256ApiKey::generate(); + let allowlist = + Allowlist::from_hex_list(&hex::encode(key.compressed_public_key())).unwrap(); + let stamp = serde_json::json!({ + "publicKey": hex::encode(key.compressed_public_key()), + "signature": "3006020100020100", + "scheme": "SIGNATURE_SCHEME_TK_API_ED25519", + }); + let mut headers = HeaderMap::new(); + let value = base64::prelude::BASE64_URL_SAFE_NO_PAD.encode(stamp.to_string()); + headers.insert("X-Stamp", HeaderValue::from_str(&value).unwrap()); + assert!(matches!( + verify(&headers, b"{}", &allowlist), + Err(StampError::UnsupportedScheme(_)) + )); + } +}