diff --git a/crates/cashu/src/nuts/mod.rs b/crates/cashu/src/nuts/mod.rs index 658887f5f..2659c8c76 100644 --- a/crates/cashu/src/nuts/mod.rs +++ b/crates/cashu/src/nuts/mod.rs @@ -25,6 +25,7 @@ pub mod nut19; pub mod nut20; pub mod nut23; pub mod nut25; +pub mod nut26; #[cfg(feature = "auth")] mod auth; @@ -69,3 +70,4 @@ pub use nut23::{ MintQuoteBolt11Response, QuoteState as MintQuoteState, }; pub use nut25::{MeltQuoteBolt12Request, MintQuoteBolt12Request, MintQuoteBolt12Response}; +pub use nut26::OhttpSettings; diff --git a/crates/cashu/src/nuts/nut06.rs b/crates/cashu/src/nuts/nut06.rs index 2ec993aa8..60e3e37c2 100644 --- a/crates/cashu/src/nuts/nut06.rs +++ b/crates/cashu/src/nuts/nut06.rs @@ -14,6 +14,7 @@ use super::nut19::CachedEndpoint; use super::{nut04, nut05, nut15, nut19, MppMethodSettings}; #[cfg(feature = "auth")] use super::{AuthRequired, BlindAuthSettings, ClearAuthSettings, ProtectedEndpoint}; +use crate::nut26::OhttpSettings; use crate::CurrencyUnit; /// Mint Version @@ -336,6 +337,10 @@ pub struct Nuts { #[serde(skip_serializing_if = "Option::is_none")] #[cfg(feature = "auth")] pub nut22: Option, + /// NUT26 Settings + #[serde(rename = "26")] + #[serde(skip_serializing_if = "Option::is_none")] + pub nut26: Option, } impl Nuts { @@ -453,6 +458,14 @@ impl Nuts { } } + /// Nut23 OHTTP settings + pub fn nut23(self, ohttp_settings: OhttpSettings) -> Self { + Self { + nut26: Some(ohttp_settings), + ..self + } + } + /// Units where minting is supported pub fn supported_mint_units(&self) -> Vec<&CurrencyUnit> { self.nut04 diff --git a/crates/cashu/src/nuts/nut26.rs b/crates/cashu/src/nuts/nut26.rs new file mode 100644 index 000000000..9d8d28db9 --- /dev/null +++ b/crates/cashu/src/nuts/nut26.rs @@ -0,0 +1,53 @@ +//! NUT-26: OHttp + +use serde::{Deserialize, Serialize}; + +use crate::MintInfo; + +/// NUT-26 OHTTP Settings +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))] +pub struct OhttpSettings { + /// Ohttp is enabled + pub supported: bool, + /// OHTTP gateway URL (actual destination, typically same as mint URL) + #[serde(skip_serializing_if = "Option::is_none")] + pub gateway_url: Option, +} + +impl OhttpSettings { + /// Create new [`OhttpSettings`] + pub fn new(supported: bool, gateway_url: Option) -> Self { + Self { + supported, + gateway_url, + } + } + + /// Validate OHTTP settings URLs + pub fn validate(&self) -> Result<(), String> { + use url::Url; + + if let Some(url) = self.gateway_url.as_ref() { + Url::parse(url).map_err(|_| format!("Invalid gateway URL: {}", url))?; + } + + Ok(()) + } +} + +impl MintInfo { + /// Check if mint supports OHTTP (NUT-26) + pub fn supports_ohttp(&self) -> bool { + self.nuts + .nut26 + .as_ref() + .map(|s| s.supported) + .unwrap_or_default() + } + + /// Get OHTTP configuration if supported + pub fn ohttp_config(&self) -> Option<&OhttpSettings> { + self.nuts.nut26.as_ref() + } +} diff --git a/crates/cdk-cli/Cargo.toml b/crates/cdk-cli/Cargo.toml index a2c2e2dde..31b3f168c 100644 --- a/crates/cdk-cli/Cargo.toml +++ b/crates/cdk-cli/Cargo.toml @@ -11,10 +11,11 @@ rust-version.workspace = true readme = "README.md" [features] -default = [] +default = ["ohttp"] sqlcipher = ["cdk-sqlite/sqlcipher"] # MSRV is not tracked with redb enabled redb = ["dep:cdk-redb"] +ohttp = ["cdk/ohttp"] [dependencies] anyhow.workspace = true diff --git a/crates/cdk-cli/README.md b/crates/cdk-cli/README.md index 169a47203..0fbc6a7c2 100644 --- a/crates/cdk-cli/README.md +++ b/crates/cdk-cli/README.md @@ -1,14 +1,111 @@ -> **Warning** -> This project is in early development, it does however work with real sats! Always use amounts you don't mind losing. +# CDK-CLI -cdk-cli is a CLI wallet implementation using of CDK(../cdk) +Cashu CLI wallet built on CDK. -## License +## Installation -Code is under the [MIT](../../LICENSE) +Build with OHTTP support (default): +```bash +cargo build --release +``` -## Contribution +Build without OHTTP support: +```bash +cargo build --release --no-default-features +``` -All contributions welcome. +## Usage -Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, shall be licensed as above, without any additional terms or conditions. +### Basic Commands + +Check wallet balance: +```bash +cdk-cli balance +``` + +Send tokens: +```bash +cdk-cli send --amount 100 +``` + +Receive tokens: +```bash +cdk-cli receive +``` + +### OHTTP Support + +The CLI supports OHTTP (Oblivious HTTP) for enhanced privacy when communicating with mints. OHTTP is enabled by default and automatically used when: + +1. The mint supports OHTTP (advertised in mint info) +2. You provide an OHTTP relay URL using `--ohttp-relay` + +#### OHTTP Usage + +To use OHTTP, simply provide a relay URL. The CLI will automatically detect if the mint supports OHTTP and configure the connection appropriately: + +```bash +# Use OHTTP relay - CLI auto-detects OHTTP support and gateway URL from mint +cdk-cli --ohttp-relay https://relay.example.com balance +cdk-cli --ohttp-relay https://relay.example.com send --amount 100 +cdk-cli --ohttp-relay https://relay.example.com receive +``` + +#### How OHTTP Works in CDK-CLI + +1. **Automatic Detection**: When `--ohttp-relay` is provided, the CLI checks if the mint supports OHTTP +2. **Gateway Discovery**: The gateway URL is automatically discovered from the mint's OHTTP configuration, or falls back to using the mint URL directly +3. **Transport Setup**: An OHTTP transport layer is created with the mint URL, relay, and gateway +4. **Privacy Protection**: Requests are routed through the relay, providing privacy from both the relay and the gateway + +#### OHTTP Arguments + +- `--ohttp-relay `: OHTTP relay URL for routing requests through a privacy relay + +#### Example OHTTP Usage + +```bash +# Standard OHTTP usage with relay +cdk-cli --ohttp-relay https://ohttp-relay.example.com balance + +# All commands work with OHTTP +cdk-cli --ohttp-relay https://relay.example.com send --amount 100 +cdk-cli --ohttp-relay https://relay.example.com receive +cdk-cli --ohttp-relay https://relay.example.com mint --amount 1000 +``` + +#### OHTTP vs Regular Proxy + +OHTTP provides significantly better privacy compared to regular HTTP proxies: + +- **Regular proxy:** `cdk-cli --proxy https://proxy.example.com balance` + - Proxy can see all request content and your IP +- **OHTTP relay:** `cdk-cli --ohttp-relay https://relay.example.com balance` + - Relay cannot see request content (cryptographically protected) + - Gateway cannot see your real IP address + - Provides true metadata protection + +#### Important Notes + +- OHTTP requires the mint to explicitly support it +- If you specify `--ohttp-relay` but the mint doesn't support OHTTP, you'll see a warning and fall back to regular HTTP +- Gateway URL is automatically determined from the mint's OHTTP configuration +- When OHTTP is used, WebSocket subscriptions are automatically disabled in favor of HTTP polling + +## Building + +### Features + +- `ohttp`: Enables OHTTP support for enhanced privacy +- `sqlcipher`: Enables SQLCipher support for encrypted databases +- `redb`: Enables redb as an alternative database backend + +### Examples + +```bash +# Build with all features +cargo build --features "ohttp,sqlcipher,redb" + +# Build with just OHTTP +cargo build --features ohttp +``` diff --git a/crates/cdk-cli/src/main.rs b/crates/cdk-cli/src/main.rs index 32a1c3ea7..1aa6e6385 100644 --- a/crates/cdk-cli/src/main.rs +++ b/crates/cdk-cli/src/main.rs @@ -3,13 +3,15 @@ use std::path::PathBuf; use std::str::FromStr; use std::sync::Arc; -use anyhow::{bail, Result}; +use anyhow::{anyhow, bail, Result}; use bip39::rand::{thread_rng, Rng}; use bip39::Mnemonic; use cdk::cdk_database; use cdk::cdk_database::WalletDatabase; use cdk::nuts::CurrencyUnit; -use cdk::wallet::{HttpClient, MultiMintWallet, Wallet, WalletBuilder}; +#[cfg(feature = "ohttp")] +use cdk::wallet::{ohttp_transport::OhttpTransport, BaseHttpClient}; +use cdk::wallet::{HttpClient, MintConnector, MultiMintWallet, Wallet, WalletBuilder}; #[cfg(feature = "redb")] use cdk_redb::WalletRedbDatabase; use cdk_sqlite::WalletSqliteDatabase; @@ -49,6 +51,10 @@ struct Cli { /// NWS Proxy #[arg(short, long)] proxy: Option, + /// OHTTP Relay URL for proxying requests (advanced usage) + #[cfg(feature = "ohttp")] + #[arg(long)] + ohttp_relay: Option, #[command(subcommand)] command: Commands, } @@ -179,10 +185,10 @@ async fn main() -> Result<()> { vec![CurrencyUnit::Sat] }; - let proxy_client = if let Some(proxy_url) = args.proxy.as_ref() { + let proxy_client = if args.ohttp_relay.is_none() && args.proxy.is_some() { Some(HttpClient::with_proxy( mint_url.clone(), - proxy_url.clone(), + args.proxy.as_ref().unwrap().clone(), None, true, )?) @@ -194,17 +200,55 @@ async fn main() -> Result<()> { for unit in units { let mint_url_clone = mint_url.clone(); - let mut builder = WalletBuilder::new() - .mint_url(mint_url_clone.clone()) - .unit(unit) - .localstore(localstore.clone()) - .seed(seed); - - if let Some(http_client) = &proxy_client { - builder = builder.client(http_client.clone()); - } + let client = HttpClient::new(mint_url.clone(), None); + + let mint_info = client.get_mint_info().await?; + + let wallet = { + #[allow(unused_mut)] // mut needed for ohttp feature + let mut builder = WalletBuilder::new() + .mint_url(mint_url_clone.clone()) + .unit(unit) + .localstore(localstore.clone()) + .seed(seed); + + // Configure client based on arguments + #[cfg(feature = "ohttp")] + if args.ohttp_relay.is_some() && mint_info.supports_ohttp() { + let mint_ohttp_settings = + mint_info.ohttp_config().expect("Checked its enabled"); + + let ohttp_relay = args + .ohttp_relay + .as_ref() + .ok_or(anyhow!("Relay url is invalid"))?; + + let gateway_url = mint_ohttp_settings + .gateway_url + .clone() + .unwrap_or(mint_url_clone.to_string()); + + let ohttp_transport = OhttpTransport::new( + mint_url_clone.to_string().parse()?, + ohttp_relay.clone(), + gateway_url.parse()?, + ); + + // Create HttpClient with OHTTP transport + let ohttp_client = BaseHttpClient::with_transport( + mint_url_clone.clone(), + ohttp_transport, + None, + ); + builder = builder.client(ohttp_client).use_http_subscription(); + } else if mint_info.supports_ohttp() { + tracing::warn!("This mint supports ohttp but you have not provided a relay"); + } else if let Some(client) = &proxy_client { + builder = builder.client(client.clone()); + } - let wallet = builder.build()?; + builder.build()? + }; let wallet_clone = wallet.clone(); diff --git a/crates/cdk-integration-tests/src/bin/start_regtest_mints.rs b/crates/cdk-integration-tests/src/bin/start_regtest_mints.rs index 67dc1f9ad..9b777a334 100644 --- a/crates/cdk-integration-tests/src/bin/start_regtest_mints.rs +++ b/crates/cdk-integration-tests/src/bin/start_regtest_mints.rs @@ -297,6 +297,7 @@ fn create_ldk_settings( mint_management_rpc: None, prometheus: None, auth: None, + ohttp_gateway: None, } } diff --git a/crates/cdk-integration-tests/src/shared.rs b/crates/cdk-integration-tests/src/shared.rs index c052ca9c7..a5aaad428 100644 --- a/crates/cdk-integration-tests/src/shared.rs +++ b/crates/cdk-integration-tests/src/shared.rs @@ -220,6 +220,7 @@ pub fn create_fake_wallet_settings( mint_management_rpc: None, auth: None, prometheus: Some(Default::default()), + ohttp_gateway: None, } } @@ -267,6 +268,7 @@ pub fn create_cln_settings( mint_management_rpc: None, auth: None, prometheus: Some(Default::default()), + ohttp_gateway: None, } } @@ -313,5 +315,6 @@ pub fn create_lnd_settings( mint_management_rpc: None, auth: None, prometheus: Some(Default::default()), + ohttp_gateway: None, } } diff --git a/crates/cdk-mintd/Cargo.toml b/crates/cdk-mintd/Cargo.toml index ae83b7515..edc403069 100644 --- a/crates/cdk-mintd/Cargo.toml +++ b/crates/cdk-mintd/Cargo.toml @@ -51,6 +51,7 @@ cdk-axum.workspace = true cdk-signatory.workspace = true cdk-mint-rpc = { workspace = true, optional = true } cdk-payment-processor = { workspace = true, optional = true } +ohttp-gateway = { path = "../ohttp-gateway" } config.workspace = true cdk-prometheus = { workspace = true, optional = true , features = ["system-metrics"]} clap.workspace = true @@ -68,5 +69,6 @@ lightning-invoice.workspace = true home.workspace = true utoipa = { workspace = true, optional = true } utoipa-swagger-ui = { version = "9.0.0", features = ["axum"], optional = true } +url.workspace = true [build-dependencies] diff --git a/crates/cdk-mintd/example.config.toml b/crates/cdk-mintd/example.config.toml index 6aa7510e5..724e24647 100644 --- a/crates/cdk-mintd/example.config.toml +++ b/crates/cdk-mintd/example.config.toml @@ -20,6 +20,10 @@ mnemonic = "" enabled = false # address = "127.0.0.1" # port = 8086 +# + +# [ohttp_gateway] +# enabled = false #[prometheus] #enabled = true diff --git a/crates/cdk-mintd/src/config.rs b/crates/cdk-mintd/src/config.rs index a04ab7dcb..fcc925dfe 100644 --- a/crates/cdk-mintd/src/config.rs +++ b/crates/cdk-mintd/src/config.rs @@ -454,6 +454,7 @@ pub struct Settings { pub auth: Option, #[cfg(feature = "prometheus")] pub prometheus: Option, + pub ohttp_gateway: Option, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] @@ -464,6 +465,15 @@ pub struct Prometheus { pub port: Option, } +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct OhttpGateway { + /// Whether OHTTP Gateway is enabled + #[serde(default)] + pub enabled: bool, + /// OHTTP gateway URL (if different from mint URL) + pub gateway_url: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct MintInfo { /// name of the mint and should be recognizable diff --git a/crates/cdk-mintd/src/env_vars/mod.rs b/crates/cdk-mintd/src/env_vars/mod.rs index 61501aabf..aa890c35a 100644 --- a/crates/cdk-mintd/src/env_vars/mod.rs +++ b/crates/cdk-mintd/src/env_vars/mod.rs @@ -8,6 +8,7 @@ mod database; mod info; mod ln; mod mint_info; +mod ohttp_gateway; #[cfg(feature = "auth")] mod auth; @@ -52,6 +53,7 @@ pub use lnd::*; #[cfg(feature = "management-rpc")] pub use management_rpc::*; pub use mint_info::*; +pub use ohttp_gateway::*; #[cfg(feature = "prometheus")] pub use prometheus::*; @@ -107,6 +109,9 @@ impl Settings { self.prometheus = Some(self.prometheus.clone().unwrap_or_default().from_env()); } + // Process OHTTP gateway configuration from environment variables + self.ohttp_gateway = Some(self.ohttp_gateway.clone().unwrap_or_default().from_env()); + match self.ln.ln_backend { #[cfg(feature = "cln")] LnBackend::Cln => { diff --git a/crates/cdk-mintd/src/env_vars/ohttp_gateway.rs b/crates/cdk-mintd/src/env_vars/ohttp_gateway.rs new file mode 100644 index 000000000..1f7170b53 --- /dev/null +++ b/crates/cdk-mintd/src/env_vars/ohttp_gateway.rs @@ -0,0 +1,23 @@ +//! Environment variables for OHTTP Gateway configuration + +use std::env; + +use crate::config::OhttpGateway; + +// Environment variable names +pub const OHTTP_GATEWAY_ENABLED_ENV_VAR: &str = "CDK_MINTD_OHTTP_GATEWAY_ENABLED"; +pub const OHTTP_GATEWAY_URL_ENV_VAR: &str = "CDK_MINTD_OHTTP_GATEWAY_URL"; + +impl OhttpGateway { + pub fn from_env(mut self) -> Self { + if let Ok(enabled_str) = env::var(OHTTP_GATEWAY_ENABLED_ENV_VAR) { + self.enabled = enabled_str.to_lowercase() == "true" || enabled_str == "1"; + } + + if let Ok(gateway_url) = env::var(OHTTP_GATEWAY_URL_ENV_VAR) { + self.gateway_url = Some(gateway_url); + } + + self + } +} diff --git a/crates/cdk-mintd/src/lib.rs b/crates/cdk-mintd/src/lib.rs index 80eb63376..8e8bd9a93 100644 --- a/crates/cdk-mintd/src/lib.rs +++ b/crates/cdk-mintd/src/lib.rs @@ -348,6 +348,9 @@ async fn configure_mint_builder( // Configure caching let mint_builder = configure_cache(settings, mint_builder); + // Configure OHTTP + let mint_builder = configure_ohttp(settings, mint_builder); + Ok(mint_builder) } @@ -626,6 +629,32 @@ fn configure_cache(settings: &config::Settings, mint_builder: MintBuilder) -> Mi mint_builder.with_cache(Some(cache.ttl.as_secs()), cached_endpoints) } +/// Configures OHTTP settings +fn configure_ohttp(settings: &config::Settings, mint_builder: MintBuilder) -> MintBuilder { + if let Some(ohttp_config) = &settings.ohttp_gateway { + if ohttp_config.enabled { + // Get the mint URL for comparison with gateway URL + let mint_url = format!( + "http://{}:{}", + settings.info.listen_host, settings.info.listen_port + ); + + tracing::info!("Configuring OHTTP support"); + tracing::debug!("OHTTP enabled: {}", ohttp_config.enabled); + tracing::debug!("Gateway URL: {:?}", ohttp_config.gateway_url); + tracing::debug!("Mint URL: {}", mint_url); + + return mint_builder.with_ohttp( + ohttp_config.enabled, + ohttp_config.gateway_url.clone(), + Some(mint_url), + ); + } + } + + mint_builder +} + #[cfg(feature = "auth")] async fn setup_authentication( settings: &config::Settings, @@ -989,7 +1018,7 @@ async fn start_services_with_shutdown( }; #[cfg(not(feature = "prometheus"))] - let prometheus_handle: Option> = None; + let _prometheus_handle: Option> = None; mint.start().await?; @@ -1068,6 +1097,21 @@ fn work_dir() -> Result { Ok(dir) } +/// Creates an OHTTP gateway router that forwards encapsulated requests to the mint +pub fn create_ohttp_gateway_router(settings: &config::Settings, work_dir: &Path) -> Result { + // Use the mint's own URL as the backend URL + let backend_url = format!( + "http://{}:{}", + settings.info.listen_host, settings.info.listen_port + ); + + // OHTTP keys are always stored in the work directory + let ohttp_keys_path = work_dir.join("ohttp_keys.json"); + + // Use the ohttp-gateway crate's router creation function + ohttp_gateway::create_ohttp_gateway_router(&backend_url, ohttp_keys_path) +} + /// The main entry point for the application when used as a library pub async fn run_mintd( work_dir: &Path, diff --git a/crates/cdk-mintd/src/main.rs b/crates/cdk-mintd/src/main.rs index b21b97a05..dfc91ed6b 100644 --- a/crates/cdk-mintd/src/main.rs +++ b/crates/cdk-mintd/src/main.rs @@ -26,13 +26,31 @@ fn main() -> Result<()> { #[cfg(not(feature = "sqlcipher"))] let password = None; + // Create OHTTP gateway router if enabled + let mut routers = vec![]; + + if let Some(ohttp_config) = &settings.ohttp_gateway { + if ohttp_config.enabled { + match cdk_mintd::create_ohttp_gateway_router(&settings, &work_dir) { + Ok(router) => { + tracing::info!("OHTTP gateway enabled and router created"); + routers.push(router); + } + Err(e) => { + tracing::error!("Failed to create OHTTP gateway router: {}", e); + return Err(e); + } + } + } + } + cdk_mintd::run_mintd( &work_dir, &settings, password, args.enable_logging, Some(rt_clone), - vec![], + routers, ) .await }) diff --git a/crates/cdk/Cargo.toml b/crates/cdk/Cargo.toml index 529dfebbd..56dd2e423 100644 --- a/crates/cdk/Cargo.toml +++ b/crates/cdk/Cargo.toml @@ -11,10 +11,11 @@ license.workspace = true [features] -default = ["mint", "wallet", "auth"] +default = ["mint", "wallet", "auth", "ohttp"] wallet = ["dep:futures", "dep:reqwest", "cdk-common/wallet", "dep:rustls"] mint = ["dep:futures", "dep:reqwest", "cdk-common/mint", "cdk-signatory"] auth = ["dep:jsonwebtoken", "cdk-common/auth", "cdk-common/auth"] +ohttp = ["wallet", "dep:ohttp-client"] bip353 = ["dep:trust-dns-resolver"] # We do not commit to a MSRV with swagger enabled swagger = ["mint", "dep:utoipa", "cdk-common/swagger"] @@ -45,6 +46,7 @@ uuid.workspace = true jsonwebtoken = { workspace = true, optional = true } trust-dns-resolver = { version = "0.23.2", optional = true } cdk-prometheus = {workspace = true, optional = true} +ohttp-client = { path = "../ohttp-client", optional = true } # -Z minimal-versions sync_wrapper = "0.1.2" bech32 = "0.9.1" diff --git a/crates/cdk/src/mint/builder.rs b/crates/cdk/src/mint/builder.rs index 765ecc9fa..1d62cf596 100644 --- a/crates/cdk/src/mint/builder.rs +++ b/crates/cdk/src/mint/builder.rs @@ -26,7 +26,7 @@ use crate::mint::Mint; use crate::nuts::ProtectedEndpoint; use crate::nuts::{ ContactInfo, CurrencyUnit, MeltMethodSettings, MintInfo, MintMethodSettings, MintVersion, - MppMethodSettings, PaymentMethod, + MppMethodSettings, OhttpSettings, PaymentMethod, }; use crate::types::PaymentProcessorKey; @@ -205,6 +205,24 @@ impl MintBuilder { self } + /// Add support for NUT26 OHTTP + /// If gateway_url is the same as the mint URL, it will be set to None + pub fn with_ohttp( + mut self, + enabled: bool, + gateway_url: Option, + mint_url: Option, + ) -> Self { + let final_gateway_url = match (gateway_url, mint_url) { + (Some(gateway), Some(mint)) if gateway == mint => None, + (gateway, _) => gateway, + }; + + let ohttp_settings = OhttpSettings::new(enabled, final_gateway_url); + self.mint_info.nuts.nut26 = Some(ohttp_settings); + self + } + /// Add payment processor pub async fn add_payment_processor( &mut self, @@ -353,3 +371,70 @@ impl MintMeltLimits { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_ohttp_url_logic_same_urls() { + // Test the logic for when gateway URL is same as mint URL + let gateway_url = Some("https://mint.example.com".to_string()); + let mint_url = Some("https://mint.example.com".to_string()); + + let final_gateway_url = match (gateway_url, mint_url) { + (Some(gateway), Some(mint)) if gateway == mint => None, + (gateway, _) => gateway, + }; + + // When gateway URL is same as mint URL, it should be set to None + assert!(final_gateway_url.is_none()); + } + + #[test] + fn test_ohttp_url_logic_different_urls() { + // Test the logic for when gateway URL is different from mint URL + let gateway_url = Some("https://gateway.example.com".to_string()); + let mint_url = Some("https://mint.example.com".to_string()); + let expected_gateway = gateway_url.clone(); + + let final_gateway_url = match (gateway_url, mint_url) { + (Some(gateway), Some(mint)) if gateway == mint => None, + (gateway, _) => gateway, + }; + + // When gateway URL is different from mint URL, it should be preserved + assert_eq!(final_gateway_url, expected_gateway); + } + + #[test] + fn test_ohttp_url_logic_no_gateway() { + // Test the logic for when no gateway URL is provided + let gateway_url: Option = None; + let mint_url = Some("https://mint.example.com".to_string()); + + let final_gateway_url = match (gateway_url, mint_url) { + (Some(gateway), Some(mint)) if gateway == mint => None, + (gateway, _) => gateway, + }; + + // When no gateway URL is provided, it should remain None + assert!(final_gateway_url.is_none()); + } + + #[test] + fn test_ohttp_settings_creation() { + // Test that OhttpSettings can be created correctly + let ohttp_settings = + OhttpSettings::new(true, Some("https://gateway.example.com".to_string())); + assert!(ohttp_settings.supported); + assert_eq!( + ohttp_settings.gateway_url, + Some("https://gateway.example.com".to_string()) + ); + + let ohttp_settings_no_gateway = OhttpSettings::new(true, None); + assert!(ohttp_settings_no_gateway.supported); + assert!(ohttp_settings_no_gateway.gateway_url.is_none()); + } +} diff --git a/crates/cdk/src/wallet/mint_connector/README.md b/crates/cdk/src/wallet/mint_connector/README.md new file mode 100644 index 000000000..e424a6f8d --- /dev/null +++ b/crates/cdk/src/wallet/mint_connector/README.md @@ -0,0 +1,140 @@ +# Mint Connectors + +This module provides different ways to connect wallets to Cashu mints. + +## HTTP Connector + +The standard `HttpClient` provides direct HTTP communication with mints: + +```rust +use cdk::wallet::mint_connector::HttpClient; +use cdk::mint_url::MintUrl; + +let mint_url = MintUrl::from("https://mint.example.com")?; +let client = HttpClient::new(mint_url); +``` + +## OHTTP Connector + +The OHTTP connector provides privacy-enhanced communication through OHTTP gateways or relays using the same `HttpClient` with an OHTTP transport: + +### Using an OHTTP Gateway + +```rust +use cdk::wallet::mint_connector::{http_client::HttpClient, ohttp_transport::OhttpTransport}; +use cdk::mint_url::MintUrl; +use url::Url; + +let mint_url = MintUrl::from("https://mint.example.com")?; +let gateway_url = Url::parse("https://gateway.example.com")?; +let keys_source_url = gateway_url.clone(); // Keys fetched from same gateway + +// Create OHTTP transport +let transport = OhttpTransport::new_with_gateway(gateway_url, keys_source_url); + +// Create HTTP client with OHTTP transport +let client = HttpClient::with_transport(mint_url, transport); +``` + +### Using an OHTTP Relay + +```rust +use cdk::wallet::mint_connector::{http_client::HttpClient, ohttp_transport::OhttpTransport}; +use cdk::mint_url::MintUrl; +use url::Url; + +let mint_url = MintUrl::from("https://mint.example.com")?; +let gateway_url = Url::parse("https://gateway.example.com")?; +let relay_url = Url::parse("https://relay.example.com")?; +let keys_source_url = gateway_url.clone(); + +// Create OHTTP transport with relay +let transport = OhttpTransport::new(mint_url.as_url().clone(), gateway_url, relay_url, keys_source_url); + +// Create HTTP client with OHTTP transport +let client = HttpClient::with_transport(mint_url, transport); +``` + +### Using Pre-loaded OHTTP Keys + +```rust +use cdk::wallet::mint_connector::{http_client::HttpClient, ohttp_transport::OhttpTransport}; +use cdk::mint_url::MintUrl; +use url::Url; + +let mint_url = MintUrl::from("https://mint.example.com")?; +let gateway_url = Url::parse("https://gateway.example.com")?; +let ohttp_keys = std::fs::read("ohttp_keys.bin")?; + +// Create OHTTP transport with pre-loaded keys +let transport = OhttpTransport::new_with_keys(gateway_url, ohttp_keys); + +// Create HTTP client with OHTTP transport +let client = HttpClient::with_transport(mint_url, transport); +``` + +### Convenient Type Alias + +For easier usage, you can also use the type alias: + +```rust +use cdk::wallet::mint_connector::OhttpHttpClient; +use cdk::mint_url::MintUrl; +use url::Url; + +let mint_url = MintUrl::from("https://mint.example.com")?; +let gateway_url = Url::parse("https://gateway.example.com")?; +let keys_source_url = gateway_url.clone(); + +// Create OHTTP transport +let transport = ohttp_transport::OhttpTransport::new_with_gateway(gateway_url, keys_source_url); + +// Use the convenient type alias +let client: OhttpHttpClient = HttpClient::with_transport(mint_url, transport); +``` + +## Usage + +All connectors implement the `MintConnector` trait, so they can be used interchangeably: + +```rust +use cdk::wallet::mint_connector::MintConnector; + +async fn mint_info(client: &dyn MintConnector) -> Result<(), Error> { + let info = client.get_mint_info().await?; + println!("Mint: {}", info.name.unwrap_or_default()); + Ok(()) +} +``` + +## Features + +- **HTTP Connector**: Direct, fast communication +- **OHTTP Connector**: Privacy-enhanced communication through OHTTP protocol + - Gateway mode: Direct connection to OHTTP gateway + - Relay mode: Connection through OHTTP relay to gateway + - Pre-loaded keys: Use cached OHTTP keys for faster initialization + +## Privacy Benefits of OHTTP + +The OHTTP (Oblivious HTTP) protocol provides: + +1. **Request Privacy**: The gateway cannot see request contents +2. **Response Privacy**: The gateway cannot see response contents +3. **Metadata Protection**: Connection metadata is separated from request data +4. **Forward Secrecy**: Each request uses fresh encryption keys + +This makes it much harder for network observers to correlate wallet activities with specific users. + +## Performance Considerations + +- OHTTP adds some latency due to encryption/decryption overhead +- Network topology (gateway/relay locations) affects performance +- Pre-loading OHTTP keys can reduce initialization time +- Consider caching strategies for frequently accessed data + +## Examples + +See the `examples/` directory for complete usage examples: + +- `ohttp_mint_connector.rs`: Basic OHTTP connector usage diff --git a/crates/cdk/src/wallet/mint_connector/http_client.rs b/crates/cdk/src/wallet/mint_connector/http_client.rs index 93cb752c9..ea8c86e16 100644 --- a/crates/cdk/src/wallet/mint_connector/http_client.rs +++ b/crates/cdk/src/wallet/mint_connector/http_client.rs @@ -86,6 +86,21 @@ where } } + /// Create new [`HttpClient`] with a pre-configured transport + pub fn with_transport( + mint_url: MintUrl, + transport: T, + #[cfg(feature = "auth")] auth_wallet: Option, + ) -> Self { + Self { + transport: transport.into(), + mint_url, + #[cfg(feature = "auth")] + auth_wallet: Arc::new(RwLock::new(auth_wallet)), + cache_support: Default::default(), + } + } + /// Create new [`HttpClient`] with a proxy for specific TLDs. /// Specifying `None` for `host_matcher` will use the proxy for all /// requests. diff --git a/crates/cdk/src/wallet/mint_connector/mod.rs b/crates/cdk/src/wallet/mint_connector/mod.rs index 8675d2941..99386a328 100644 --- a/crates/cdk/src/wallet/mint_connector/mod.rs +++ b/crates/cdk/src/wallet/mint_connector/mod.rs @@ -16,6 +16,8 @@ use crate::nuts::{ use crate::wallet::AuthWallet; pub mod http_client; +#[cfg(feature = "ohttp")] +pub mod ohttp_transport; pub mod transport; /// Auth HTTP Client with async transport @@ -24,6 +26,10 @@ pub type AuthHttpClient = http_client::AuthHttpClient; /// Http Client with async transport pub type HttpClient = http_client::HttpClient; +/// OHTTP Client using HttpClient with OHTTP transport +#[cfg(feature = "ohttp")] +pub type OhttpHttpClient = http_client::HttpClient; + /// Interface that connects a wallet to a mint. Typically represents an [HttpClient]. #[cfg_attr(target_arch = "wasm32", async_trait(?Send))] #[cfg_attr(not(target_arch = "wasm32"), async_trait)] diff --git a/crates/cdk/src/wallet/mint_connector/ohttp_transport.rs b/crates/cdk/src/wallet/mint_connector/ohttp_transport.rs new file mode 100644 index 000000000..47b4abdd2 --- /dev/null +++ b/crates/cdk/src/wallet/mint_connector/ohttp_transport.rs @@ -0,0 +1,163 @@ +//! OHTTP Transport implementation +use std::sync::Arc; + +use async_trait::async_trait; +use cdk_common::AuthToken; +use serde::de::DeserializeOwned; +use serde::Serialize; +use url::Url; + +use super::transport::Transport; +use super::Error; +use crate::error::ErrorResponse; + +/// OHTTP Transport for communicating through OHTTP gateways/relays +#[derive(Clone)] +pub struct OhttpTransport { + client: Arc, +} + +impl std::fmt::Debug for OhttpTransport { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("OhttpTransport") + .field("client", &"Arc") + .finish() + } +} + +impl OhttpTransport { + /// Create new OHTTP transport with gateway and relay URLs + /// + /// The request flow: + /// 1. Send to relay_url + /// 2. Relay forwards to gateway_url + /// 3. Gateway forwards to target_url (mint) + /// 4. Keys are fetched from keys_source_url (same as target) + pub fn new(target_url: Url, relay_url: Url, gateway_url: Url) -> Self { + let client = ohttp_client::OhttpClient::new(relay_url, None, gateway_url, target_url); + + Self { + client: Arc::new(client), + } + } +} + +impl Default for OhttpTransport { + fn default() -> Self { + // Provide a minimal default that won't panic, but won't work until properly configured + // This is needed for the Transport trait, but users should use ::new() instead + let dummy_url = Url::parse("http://localhost").expect("Invalid default URL"); + Self::new(dummy_url.clone(), dummy_url.clone(), dummy_url) + } +} + +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +impl Transport for OhttpTransport { + fn with_proxy( + &mut self, + _proxy: Url, + _host_matcher: Option<&str>, + _accept_invalid_certs: bool, + ) -> Result<(), Error> { + // OHTTP transport doesn't support traditional proxies since it already + // provides privacy through the OHTTP protocol + Err(Error::Custom( + "OHTTP transport does not support traditional proxies".to_string(), + )) + } + + async fn http_get(&self, url: Url, auth: Option) -> Result + where + R: DeserializeOwned, + { + // Extract path from URL + let path = url.path(); + + // Prepare headers + let mut headers = Vec::new(); + if let Some(auth_token) = auth { + headers.push((auth_token.header_key().to_string(), auth_token.to_string())); + } + + // Send GET request through OHTTP + let response = self + .client + .send_ohttp_request("GET", &[], &headers, path) + .await + .map_err(|e| Error::Custom(format!("OHTTP request failed: {}", e)))?; + + // Check for HTTP errors + if response.status >= 400 { + return Err(Error::HttpError( + Some(response.status), + format!("HTTP {} error", response.status), + )); + } + + // Parse response body + let response_text = response + .text() + .map_err(|e| Error::Custom(format!("Failed to decode response: {}", e)))?; + + serde_json::from_str::(&response_text).map_err(|err| { + tracing::warn!("OHTTP Response error: {}", err); + match ErrorResponse::from_json(&response_text) { + Ok(error_response) => error_response.into(), + Err(parse_err) => parse_err.into(), + } + }) + } + + async fn http_post( + &self, + url: Url, + auth_token: Option, + payload: &P, + ) -> Result + where + P: Serialize + ?Sized + Send + Sync, + R: DeserializeOwned, + { + // Extract path from URL + let path = url.path(); + + // Serialize payload to JSON + let body = serde_json::to_vec(payload) + .map_err(|e| Error::Custom(format!("Failed to serialize payload: {}", e)))?; + + // Prepare headers + let mut headers = vec![("Content-Type".to_string(), "application/json".to_string())]; + if let Some(auth) = auth_token { + headers.push((auth.header_key().to_string(), auth.to_string())); + } + + // Send POST request through OHTTP + let response = self + .client + .send_ohttp_request("POST", &body, &headers, path) + .await + .map_err(|e| Error::Custom(format!("OHTTP request failed: {}", e)))?; + + // Check for HTTP errors + if response.status >= 400 { + return Err(Error::HttpError( + Some(response.status), + format!("HTTP {} error", response.status), + )); + } + + // Parse response body + let response_text = response + .text() + .map_err(|e| Error::Custom(format!("Failed to decode response: {}", e)))?; + + serde_json::from_str::(&response_text).map_err(|err| { + tracing::warn!("OHTTP Response error: {}", err); + match ErrorResponse::from_json(&response_text) { + Ok(error_response) => error_response.into(), + Err(parse_err) => parse_err.into(), + } + }) + } +} diff --git a/crates/cdk/src/wallet/mod.rs b/crates/cdk/src/wallet/mod.rs index 3209be7db..17644d91f 100644 --- a/crates/cdk/src/wallet/mod.rs +++ b/crates/cdk/src/wallet/mod.rs @@ -56,9 +56,13 @@ pub use cdk_common::wallet as types; #[cfg(feature = "auth")] pub use mint_connector::http_client::AuthHttpClient as BaseAuthHttpClient; pub use mint_connector::http_client::HttpClient as BaseHttpClient; +#[cfg(feature = "ohttp")] +pub use mint_connector::ohttp_transport; pub use mint_connector::transport::Transport as HttpTransport; #[cfg(feature = "auth")] pub use mint_connector::AuthHttpClient; +#[cfg(feature = "ohttp")] +pub use mint_connector::OhttpHttpClient; pub use mint_connector::{HttpClient, MintConnector}; pub use multi_mint_wallet::MultiMintWallet; pub use receive::ReceiveOptions; diff --git a/crates/ohttp-client/Cargo.toml b/crates/ohttp-client/Cargo.toml new file mode 100644 index 000000000..bc25a5087 --- /dev/null +++ b/crates/ohttp-client/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "ohttp-client" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +homepage.workspace = true +repository.workspace = true +readme.workspace = true +description = "Generic OHTTP client for sending arbitrary data through OHTTP gateways" +keywords = ["bitcoin", "e-cash", "cashu", "ohttp"] +categories = ["cryptography::cryptocurrencies", "command-line-utilities"] + +[dependencies] +anyhow.workspace = true +bhttp = { version = "0.6.1", features = ["http"] } +bitcoin.workspace = true +bytes = "1.9.0" +clap = { workspace = true, features = ["derive", "env"] } +http = "1.2.0" +ohttp = { package = "bitcoin-ohttp", version = "0.6.0" } +reqwest.workspace = true +serde.workspace = true +serde_json.workspace = true +tokio = { workspace = true, features = ["full"] } +tracing.workspace = true +tracing-subscriber.workspace = true +base64 = "0.22.1" +url.workspace = true diff --git a/crates/ohttp-client/README.md b/crates/ohttp-client/README.md new file mode 100644 index 000000000..667a5c36b --- /dev/null +++ b/crates/ohttp-client/README.md @@ -0,0 +1,9 @@ +# OHTTP Client + +## Overview + +The OHTTP Client implements the [Oblivious HTTP specification](https://ietf-wg-ohai.github.io/oblivious-http/draft-ietf-ohai-ohttp.html) to enable private HTTP communications. It can work with: + +- **OHTTP Gateways**: Direct connection to gateways that decrypt and forward requests to backend services +- **OHTTP Relays**: Connection through relays that forward encrypted requests to gateways without seeing content + diff --git a/crates/ohttp-client/src/client.rs b/crates/ohttp-client/src/client.rs new file mode 100644 index 000000000..c69a4000e --- /dev/null +++ b/crates/ohttp-client/src/client.rs @@ -0,0 +1,541 @@ +use std::sync::Arc; + +use anyhow::{anyhow, Result}; +use http::HeaderMap; +use reqwest::Client; +use tokio::sync::RwLock; +use url::Url; + +/// OHTTP client for sending requests through gateways or relays +pub struct OhttpClient { + client: Client, + relay_url: Url, + ohttp_keys: Arc>>>, + gateway_url: Url, + target_url: Url, +} + +impl OhttpClient { + /// Create a new OHTTP client + /// + /// # Relay URL Construction + /// + /// When making requests, the gateway URL is normalized (scheme + authority only) + /// and appended as a path component to the relay URL. This provides privacy + /// protection by only revealing the gateway's base URL to the relay. + /// + /// ## Examples + /// + /// | Relay Base | Gateway URL | Final Relay URL | + /// |------------|-------------|-----------------| + /// | `https://relay.com` | `https://dir.com/session123` | `https://relay.com/https://dir.com/` | + /// | `https://relay.com/ohttp` | `https://payjoin.xyz:8080/api` | `https://relay.com/ohttp/https://payjoin.xyz:8080/` | + /// | `https://relay.com/` | `https://dir.com` | `https://relay.com/https://dir.com/` | + /// + /// # Arguments + /// + /// * `relay_url` - The OHTTP relay that will forward requests to the gateway + /// * `ohttp_keys` - Optional pre-fetched OHTTP keys (will fetch from gateway if None) + /// * `gateway_url` - The OHTTP gateway that will decrypt and forward to the target + /// * `target_url` - The final destination for the decrypted request + pub fn new( + relay_url: Url, + ohttp_keys: Option>, + gateway_url: Url, + target_url: Url, + ) -> Self { + let client = Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + .expect("Failed to build HTTP client"); + + Self { + client, + relay_url, + ohttp_keys: Arc::new(RwLock::new(ohttp_keys)), + gateway_url, + target_url, + } + } + + /// Fetch OHTTP keys from the keys source (can be different from target URL) + pub async fn fetch_keys(&self) -> Result> { + let keys_url = self.gateway_url.join("/.well-known/ohttp-gateway")?; + + tracing::debug!("Fetching OHTTP keys from: {}", keys_url); + + let response = self.client.get(keys_url).send().await?.error_for_status()?; + + let keys = response.bytes().await?; + tracing::debug!("Fetched OHTTP keys, size: {} bytes", keys.len()); + + let mut ohttp_keys = self.ohttp_keys.write().await; + + *ohttp_keys = Some(keys.to_vec()); + + Ok(keys.to_vec()) + } + + /// Construct the relay URL with normalized gateway URL as path component + /// + /// This implements the privacy protection mechanism where: + /// 1. Gateway URL is normalized to its base form (scheme + authority only) + /// 2. The normalized gateway base is appended as a path component to the relay URL + /// 3. Only scheme and authority are revealed to the relay, full path/query/fragments remain encrypted + fn construct_relay_url(&self) -> Result { + // Step 1: Normalize gateway URL to base form (scheme + authority only) + let gateway_base = self + .gateway_url + .join("/") + .map_err(|e| anyhow!("Failed to normalize gateway URL: {}", e))?; + + tracing::debug!( + "Normalized gateway URL from '{}' to '{}'", + self.gateway_url, + gateway_base + ); + + // Step 2: Manually construct the full relay URL to avoid URL.join() issues with absolute URLs + let mut full_relay_url = self.relay_url.clone(); + + // Ensure the relay path ends with a slash + let relay_path = if full_relay_url.path().ends_with('/') { + full_relay_url.path().to_string() + } else { + format!("{}/", full_relay_url.path()) + }; + + // Append the gateway URL as a path component + let new_path = format!("{}{}", relay_path, gateway_base); + full_relay_url.set_path(&new_path); + + tracing::debug!( + "Constructed relay URL: '{}' + '{}' = '{}'", + self.relay_url, + gateway_base, + full_relay_url + ); + + Ok(full_relay_url) + } + + /// Send a request using proper OHTTP encapsulation + pub async fn send_ohttp_request( + &self, + method: &str, + body: &[u8], + headers: &[(String, String)], + request_path: &str, + ) -> Result { + // Fetch OHTTP keys if not already available + let maybe_keys = { + let guard = self.ohttp_keys.read().await; + guard.clone() + }; + + let keys_data = match maybe_keys { + Some(keys) => keys, + None => self.fetch_keys().await?, + }; + + // Parse the OHTTP keys and create client request + let client_request = ohttp::ClientRequest::from_encoded_config(&keys_data) + .map_err(|e| anyhow!("Failed to decode OHTTP keys: {}", e))?; + + tracing::debug!("Created OHTTP client request"); + + // Create BHTTP request + let bhttp_request = self.create_bhttp_request(method, body, headers, request_path)?; + tracing::debug!("Created BHTTP request, size: {} bytes", bhttp_request.len()); + + // Encapsulate the request using OHTTP + let (ohttp_request, response_context) = client_request + .encapsulate(&bhttp_request) + .map_err(|e| anyhow!("Failed to encapsulate OHTTP request: {}", e))?; + + tracing::debug!( + "Encapsulated OHTTP request, size: {} bytes", + ohttp_request.len() + ); + + // Construct relay URL with normalized gateway URL as path component + let endpoint_url = self.construct_relay_url()?; + + tracing::debug!("Sending OHTTP request to: {}", endpoint_url); + + // Send the OHTTP request + let start_time = std::time::Instant::now(); + let response = self + .client + .post(endpoint_url) + .header("content-type", "message/ohttp-req") + .body(ohttp_request) + .send() + .await?; + + let elapsed = start_time.elapsed(); + + tracing::debug!( + "OHTTP response received in {:.2}ms: {} {}", + elapsed.as_millis(), + response.status(), + response.url() + ); + + // Check if we got the expected content type + let content_type = response + .headers() + .get("content-type") + .and_then(|ct| ct.to_str().ok()) + .unwrap_or(""); + + if content_type != "message/ohttp-res" { + tracing::debug!("Warning: Unexpected content type: {}", content_type); + } + + let _response_status = response.status().as_u16(); + let _response_headers = response.headers().clone(); + let ohttp_response_body = response.bytes().await?; + + tracing::debug!( + "OHTTP response body size: {} bytes", + ohttp_response_body.len() + ); + + // Decapsulate the OHTTP response + let bhttp_response = response_context + .decapsulate(&ohttp_response_body) + .map_err(|e| anyhow!("Failed to decapsulate OHTTP response: {}", e))?; + + tracing::debug!( + "Decapsulated BHTTP response, size: {} bytes", + bhttp_response.len() + ); + + // Parse the BHTTP response + let (status, headers, body) = self.parse_bhttp_response(&bhttp_response)?; + + Ok(OhttpResponse { + status, + headers, + body, + elapsed, + }) + } + + /// Create a BHTTP request from the given parameters + fn create_bhttp_request( + &self, + method: &str, + body: &[u8], + headers: &[(String, String)], + request_path: &str, + ) -> Result> { + use bhttp::Message; + + tracing::debug!("Creating BHTTP request: {} {}", method, request_path); + + // Extract proper authority from target URL (host:port only, no scheme) + let authority = if let Some(port) = self.target_url.port() { + format!( + "{}:{}", + self.target_url.host_str().unwrap_or("localhost"), + port + ) + } else { + self.target_url + .host_str() + .unwrap_or("localhost") + .to_string() + }; + + tracing::debug!( + "Using authority: {} for target: {}", + authority, + self.target_url + ); + + // Create the BHTTP message + let mut bhttp_msg = Message::request( + method.as_bytes().to_vec(), + self.target_url.scheme().as_bytes().to_vec(), // scheme from target URL + authority.as_bytes().to_vec(), // authority (host:port only) + request_path.as_bytes().to_vec(), // path + ); + + // Add headers + for (name, value) in headers { + bhttp_msg.put_header(name.as_bytes(), value.as_bytes()); + tracing::debug!("Added header: {}: {}", name, value); + } + + // Add body + if !body.is_empty() { + bhttp_msg.write_content(body); + tracing::debug!("Added body, size: {} bytes", body.len()); + } + + // Serialize to bytes + let mut bhttp_bytes = Vec::new(); + bhttp_msg + .write_bhttp(bhttp::Mode::KnownLength, &mut bhttp_bytes) + .map_err(|e| anyhow!("Failed to write BHTTP request: {}", e))?; + + Ok(bhttp_bytes) + } + + /// Parse a BHTTP response into status, headers, and body + fn parse_bhttp_response(&self, bhttp_bytes: &[u8]) -> Result<(u16, HeaderMap, Vec)> { + use bhttp::Message; + + tracing::debug!("Parsing BHTTP response, size: {} bytes", bhttp_bytes.len()); + + let mut cursor = std::io::Cursor::new(bhttp_bytes); + let bhttp_msg = Message::read_bhttp(&mut cursor) + .map_err(|e| anyhow!("Failed to read BHTTP response: {}", e))?; + + // Extract status + let status = bhttp_msg + .control() + .status() + .ok_or_else(|| anyhow!("Missing status in BHTTP response"))?; + + tracing::debug!("Parsed status: {}", u16::from(status)); + + // Extract headers + let mut headers = HeaderMap::new(); + for field in bhttp_msg.header().fields() { + let name = String::from_utf8_lossy(field.name()); + let value = String::from_utf8_lossy(field.value()); + + if let (Ok(header_name), Ok(header_value)) = ( + http::HeaderName::from_bytes(field.name()), + http::HeaderValue::from_bytes(field.value()), + ) { + headers.insert(header_name, header_value); + tracing::debug!("Parsed header: {}: {}", name, value); + } + } + + // Extract body + let body = bhttp_msg.content().to_vec(); + tracing::debug!("Parsed body, size: {} bytes", body.len()); + + Ok((status.into(), headers, body)) + } + + /// Get target information + pub async fn get_target_info(&self) -> Result { + let keys = self.fetch_keys().await?; + + Ok(TargetInfo { + target_url: self.relay_url.clone(), + keys_available: true, + keys_size: keys.len(), + }) + } +} + +#[derive(Debug)] +pub struct OhttpResponse { + pub status: u16, + pub headers: HeaderMap, + pub body: Vec, + pub elapsed: std::time::Duration, +} + +impl OhttpResponse { + /// Get response body as text + pub fn text(&self) -> Result { + String::from_utf8(self.body.clone()) + .map_err(|e| anyhow!("Failed to decode response as UTF-8: {}", e)) + } + + /// Get response body as JSON + pub fn json(&self) -> Result { + serde_json::from_slice(&self.body) + .map_err(|e| anyhow!("Failed to parse JSON response: {}", e)) + } + + /// Check if response is JSON + pub fn is_json(&self) -> bool { + self.headers + .get("content-type") + .and_then(|ct| ct.to_str().ok()) + .map(|ct| ct.contains("json")) + .unwrap_or(false) + } +} + +#[derive(Debug)] +pub struct GatewayInfo { + pub gateway_url: Url, + pub keys_available: bool, + pub keys_size: usize, +} + +#[derive(Debug)] +pub struct TargetInfo { + pub target_url: Url, + pub keys_available: bool, + pub keys_size: usize, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_authority_extraction() { + // Test with port + let target_url = Url::parse("http://127.0.0.1:8085").unwrap(); + let _client = OhttpClient::new( + target_url.clone(), + None, + target_url.clone(), + target_url.clone(), + ); + + let authority = if let Some(port) = target_url.port() { + format!("{}:{}", target_url.host_str().unwrap_or("localhost"), port) + } else { + target_url.host_str().unwrap_or("localhost").to_string() + }; + + assert_eq!(authority, "127.0.0.1:8085"); + + // Test without explicit port (default ports) + let target_url_no_port = Url::parse("https://example.com").unwrap(); + let authority_no_port = if let Some(port) = target_url_no_port.port() { + format!( + "{}:{}", + target_url_no_port.host_str().unwrap_or("localhost"), + port + ) + } else { + target_url_no_port + .host_str() + .unwrap_or("localhost") + .to_string() + }; + + assert_eq!(authority_no_port, "example.com"); + } + + #[test] + fn test_authority_does_not_include_scheme() { + let target_url = Url::parse("https://example.com:8443/some/path").unwrap(); + + let authority = if let Some(port) = target_url.port() { + format!("{}:{}", target_url.host_str().unwrap_or("localhost"), port) + } else { + target_url.host_str().unwrap_or("localhost").to_string() + }; + + // Authority should NOT include scheme or path + assert_eq!(authority, "example.com:8443"); + assert!(!authority.contains("https://")); + assert!(!authority.contains("/some/path")); + } + + #[test] + fn test_construct_relay_url() { + // Test case 1: Basic URL construction + let relay_url = Url::parse("https://relay.com").unwrap(); + let gateway_url = + Url::parse("https://payjoin-directory.com/session123?query=value#fragment").unwrap(); + let target_url = Url::parse("https://target.com").unwrap(); + + let client = OhttpClient::new(relay_url, None, gateway_url, target_url); + let result = client.construct_relay_url().unwrap(); + + assert_eq!( + result.to_string(), + "https://relay.com/https://payjoin-directory.com/" + ); + + // Test case 2: Relay with existing path + let relay_url = Url::parse("https://relay.com/ohttp").unwrap(); + let gateway_url = Url::parse("https://payjoin.xyz:8080/api").unwrap(); + let target_url = Url::parse("https://target.com").unwrap(); + + let client = OhttpClient::new(relay_url, None, gateway_url, target_url); + let result = client.construct_relay_url().unwrap(); + + assert_eq!( + result.to_string(), + "https://relay.com/ohttp/https://payjoin.xyz:8080/" + ); + + // Test case 3: Relay URL with trailing slash + let relay_url = Url::parse("https://relay.com/").unwrap(); + let gateway_url = Url::parse("https://dir.com").unwrap(); + let target_url = Url::parse("https://target.com").unwrap(); + + let client = OhttpClient::new(relay_url, None, gateway_url, target_url); + let result = client.construct_relay_url().unwrap(); + + assert_eq!(result.to_string(), "https://relay.com/https://dir.com/"); + } + + #[test] + fn test_gateway_url_normalization() { + // Test that gateway URL normalization strips path, query, and fragment + let relay_url = Url::parse("https://relay.example.com").unwrap(); + let target_url = Url::parse("https://target.com").unwrap(); + + // Test with complex gateway URL + let gateway_url = Url::parse( + "https://gateway.com:8443/some/deep/path?param1=value1¶m2=value2#section", + ) + .unwrap(); + let client = OhttpClient::new(relay_url.clone(), None, gateway_url, target_url.clone()); + let result = client.construct_relay_url().unwrap(); + + // Should normalize to just scheme + authority + assert_eq!( + result.to_string(), + "https://relay.example.com/https://gateway.com:8443/" + ); + + // Test with simple gateway URL + let gateway_url_simple = Url::parse("https://simple.gateway.com").unwrap(); + let client_simple = OhttpClient::new(relay_url, None, gateway_url_simple, target_url); + let result_simple = client_simple.construct_relay_url().unwrap(); + + assert_eq!( + result_simple.to_string(), + "https://relay.example.com/https://simple.gateway.com/" + ); + } + + #[test] + fn test_privacy_protection_verification() { + // Verify that sensitive information from gateway URL is NOT exposed to relay + let relay_url = Url::parse("https://relay.com").unwrap(); + let gateway_url = Url::parse( + "https://payjoin.com/sensitive/session/abc123?secret=token&user=alice#private", + ) + .unwrap(); + let target_url = Url::parse("https://target.com").unwrap(); + + let client = OhttpClient::new(relay_url, None, gateway_url, target_url); + let relay_request_url = client.construct_relay_url().unwrap(); + + let relay_url_str = relay_request_url.to_string(); + + // Verify only scheme and authority are included + assert!(relay_url_str.contains("https://payjoin.com/")); + + // Verify sensitive parts are NOT included + assert!(!relay_url_str.contains("sensitive")); + assert!(!relay_url_str.contains("session")); + assert!(!relay_url_str.contains("abc123")); + assert!(!relay_url_str.contains("secret=token")); + assert!(!relay_url_str.contains("user=alice")); + assert!(!relay_url_str.contains("#private")); + + // Expected format: https://relay.com/https://payjoin.com/ + assert_eq!(relay_url_str, "https://relay.com/https://payjoin.com/"); + } +} diff --git a/crates/ohttp-client/src/lib.rs b/crates/ohttp-client/src/lib.rs new file mode 100644 index 000000000..c8ab57bdc --- /dev/null +++ b/crates/ohttp-client/src/lib.rs @@ -0,0 +1,3 @@ +pub mod client; + +pub use client::*; diff --git a/crates/ohttp-gateway/Cargo.toml b/crates/ohttp-gateway/Cargo.toml new file mode 100644 index 000000000..7294195be --- /dev/null +++ b/crates/ohttp-gateway/Cargo.toml @@ -0,0 +1,39 @@ +[package] +name = "ohttp-gateway" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +homepage.workspace = true +repository.workspace = true +readme.workspace = true +description = "A generic OHTTP gateway that forwards requests to a configured backend without storing data" +keywords = ["bitcoin", "e-cash", "cashu", "ohttp"] +categories = ["cryptography::cryptocurrencies", "network-programming"] + +[[bin]] +name = "ohttp-gateway" +path = "src/bin/main.rs" + +[dependencies] +anyhow.workspace = true +axum = { workspace = true, features = ["tokio"] } +base64 = "0.22.1" +bitcoin.workspace = true +bhttp = { version = "0.6.1", features = ["http"] } +bytes = "1.9.0" +clap = { workspace = true, features = ["derive", "env"] } +futures.workspace = true +home.workspace = true +http-body-util = "0.1.3" +hyper = { version = "1.6.0", features = ["http1", "client"] } +hyper-util = { version = "0.1.16", features = ["client", "client-legacy", "tokio"] } +reqwest.workspace = true +ohttp = { package = "bitcoin-ohttp", version = "0.6.0" } +serde.workspace = true +serde_json.workspace = true +tokio = { workspace = true, features = ["full"] } +tower.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true +url.workspace = true diff --git a/crates/ohttp-gateway/README.md b/crates/ohttp-gateway/README.md new file mode 100644 index 000000000..2166c3554 --- /dev/null +++ b/crates/ohttp-gateway/README.md @@ -0,0 +1,64 @@ +# OHTTP Gateway + +A high-performance OHTTP (Oblivious HTTP) gateway that provides privacy-preserving HTTP proxying by decrypting OHTTP-encapsulated requests and forwarding them to backend services. Built with Axum for async performance and designed for production deployments. + +## Overview + +The OHTTP Gateway implements the [Oblivious HTTP specification](https://ietf-wg-ohai.github.io/oblivious-http/draft-ietf-ohai-ohttp.html) as a transparent proxy that: + +- **Decapsulates OHTTP requests**: Decrypts incoming encrypted HTTP requests +- **Forwards to backends**: Proxies decrypted requests to configured backend services +- **Encapsulates responses**: Encrypts backend responses for return to clients +- **Zero data storage**: Operates as a stateless proxy with no request logging or storage +- **High performance**: Built on Axum with async/await for concurrent request handling + +## Architecture + +``` +OHTTP Client → [OHTTP Gateway] → Backend Service + ↑ + (encrypt/decrypt with HPKE) +``` + +**Request Flow:** +1. Client sends encrypted request (`message/ohttp-req`) to `/.well-known/ohttp-gateway` +2. Gateway decrypts using private OHTTP keys +3. Gateway forwards plain HTTP request to configured backend +4. Backend processes request and returns response +5. Gateway encrypts response and returns as `message/ohttp-res` + +## Features + +- ✅ **RFC Compliant**: Full OHTTP specification implementation +- ✅ **Zero Storage**: Stateless operation with no request persistence +- ✅ **High Performance**: Async request handling with Axum +- ✅ **Automatic Key Management**: Generates and manages OHTTP keys +- ✅ **Flexible Backend**: Forward to any HTTP/HTTPS service +- ✅ **Health Monitoring**: Built-in health check endpoints +- ✅ **CORS Support**: Cross-origin resource sharing for web clients +- ✅ **Cashu Gateway Prober**: Support for gateway probing and purpose discovery + +## Endpoints + +- `POST /.well-known/ohttp-gateway` - Main OHTTP endpoint for encapsulated requests +- `GET /.well-known/ohttp-gateway` - Returns OHTTP public keys for clients +- `GET /.well-known/ohttp-gateway?allowed_purposes` - Gateway prober endpoint for Cashu opt-in detection +- `GET /ohttp-keys` - Alternative endpoint for OHTTP public keys + +## Gateway Prober Support + +The gateway implements Cashu gateway prober support. When a GET request is made to `/.well-known/ohttp-gateway?allowed_purposes`, the gateway responds with: + +- **Status**: 200 OK +- **Content-Type**: `application/x-ohttp-allowed-purposes` +- **Body**: TLS ALPN protocol list encoded containing the magic Cashu purpose string + +The encoding follows the same format as BIP77 - a U16BE count of strings followed by U8 length encoded strings: +- 2 bytes: Big-endian count of strings in the list (1 for Cashu) +- 1 byte: Length of the purpose string (42 bytes) +- 42 bytes: The magic Cashu purpose string `CASHU 2253f530-151f-4800-a58e-c852a8dc8cff` + +Example request: +```bash +curl "http://localhost:8080/.well-known/ohttp-gateway?allowed_purposes" +``` diff --git a/crates/ohttp-gateway/src/bin/main.rs b/crates/ohttp-gateway/src/bin/main.rs new file mode 100644 index 000000000..eb737b633 --- /dev/null +++ b/crates/ohttp-gateway/src/bin/main.rs @@ -0,0 +1,63 @@ +use anyhow::Result; +use axum::routing::{get, post}; +use axum::Router; +use clap::Parser; +use ohttp_gateway::cli::Cli; +use ohttp_gateway::{gateway, key_config}; +use tracing_subscriber::filter::LevelFilter; +use tracing_subscriber::EnvFilter; + +#[tokio::main] +async fn main() -> Result<()> { + init_logging(); + + let cli = Cli::parse(); + + // Get work directory and construct OHTTP keys path + let work_dir = cli.get_work_dir()?; + let ohttp_keys_path = work_dir.join("ohttp_keys.json"); + + // Load or generate OHTTP keys + let ohttp = key_config::load_or_generate_keys(&ohttp_keys_path)?; + + // HTTP client is set up within the gateway handlers + + // Create the Axum app + let app = Router::new() + .route( + "/.well-known/ohttp-gateway", + post(gateway::handle_ohttp_request).get(gateway::handle_gateway_get), + ) + .route("/ohttp-keys", get(gateway::handle_ohttp_keys)) + // Catch-all route to handle any path with OHTTP requests + .fallback(gateway::handle_ohttp_request) + .layer(axum::extract::Extension(ohttp)) + .layer(axum::extract::Extension(cli.backend_url.clone())); + + // Create TCP listener + let addr = format!("0.0.0.0:{}", cli.port); + + tracing::info!("OHTTP Gateway listening on: {}", addr); + tracing::info!("Forwarding requests to: {}", cli.backend_url); + + // Run the server + let listener = tokio::net::TcpListener::bind(&addr).await?; + axum::serve(listener, app).await?; + + Ok(()) +} + +fn init_logging() { + let env_filter = EnvFilter::builder() + .with_default_directive(LevelFilter::DEBUG.into()) + .from_env_lossy() + .add_directive("hyper=info".parse().unwrap()) + .add_directive("tower_http=debug".parse().unwrap()); + + tracing_subscriber::fmt() + .with_target(false) + .with_env_filter(env_filter) + .init(); + + tracing::info!("Logging initialized"); +} diff --git a/crates/ohttp-gateway/src/cli.rs b/crates/ohttp-gateway/src/cli.rs new file mode 100644 index 000000000..5ea84ce53 --- /dev/null +++ b/crates/ohttp-gateway/src/cli.rs @@ -0,0 +1,69 @@ +use std::path::PathBuf; + +use clap::{value_parser, Parser}; +use url::Url; + +#[derive(Debug, Parser)] +#[command( + version = env!("CARGO_PKG_VERSION"), + about = "OHTTP Gateway", + long_about = "A generic OHTTP gateway that forwards encapsulated requests to a configured backend without storing data", +)] +pub struct Cli { + /// The port to bind the gateway on + #[arg(long, short = 'p', env = "OHTTP_GATEWAY_PORT", default_value = "8080")] + pub port: u16, + + /// The backend URL to forward requests to + #[arg( + long, + env = "OHTTP_GATEWAY_BACKEND_URL", + help = "The backend URL to forward requests to", + default_value = "http://localhost:8080", + value_parser = validate_url + )] + pub backend_url: Url, + + /// The working directory where OHTTP keys will be stored + #[arg( + long = "work-dir", + env = "OHTTP_GATEWAY_WORK_DIR", + help = "The working directory where OHTTP keys will be stored", + value_parser = value_parser!(PathBuf) + )] + pub work_dir: Option, +} + +impl Cli { + /// Get the work directory, using default if not specified + pub fn get_work_dir(&self) -> anyhow::Result { + match &self.work_dir { + Some(dir) => Ok(dir.clone()), + None => { + let home_dir = home::home_dir() + .ok_or_else(|| anyhow::anyhow!("Unable to determine home directory"))?; + let dir = home_dir.join(".ohttp-gateway"); + std::fs::create_dir_all(&dir)?; + Ok(dir) + } + } + } +} + +/// Validate that the backend URL is well-formed +fn validate_url(s: &str) -> Result { + let url = Url::parse(s).map_err(|e| format!("Invalid URL '{}': {}", s, e))?; + + if url.scheme() != "http" && url.scheme() != "https" { + return Err(format!( + "URL must use http or https scheme, got: {}", + url.scheme() + )); + } + + if url.host().is_none() { + return Err("URL must have a host".to_string()); + } + + Ok(url) +} diff --git a/crates/ohttp-gateway/src/gateway.rs b/crates/ohttp-gateway/src/gateway.rs new file mode 100644 index 000000000..7e16456f7 --- /dev/null +++ b/crates/ohttp-gateway/src/gateway.rs @@ -0,0 +1,364 @@ +use std::str::FromStr; + +use axum::http::{StatusCode, Uri}; +use axum::response::{IntoResponse, Response}; +use bytes::Bytes; +use url::Url; +use {reqwest, serde_json}; + +use crate::key_config::OhttpConfig; + +pub type BoxError = Box; + +/// Magic Cashu purpose string for gateway prober +const MAGIC_CASHU_PURPOSE: &[u8] = b"CASHU 2253f530-151f-4800-a58e-c852a8dc8cff"; + +#[derive(Debug)] +struct BackendResponse { + status: u16, + headers: Vec<(reqwest::header::HeaderName, reqwest::header::HeaderValue)>, + body: Vec, +} + +/// Handle OHTTP gateway requests +pub async fn handle_ohttp_request( + axum::extract::Extension(ohttp): axum::extract::Extension, + axum::extract::Extension(backend_url): axum::extract::Extension, + body: Bytes, +) -> Result { + tracing::trace!("Received OHTTP request, size: {}", body.len()); + + // Decapsulate the OHTTP request + let (bhttp_req, response_context) = match ohttp.server.decapsulate(&body) { + Ok(result) => result, + Err(e) => { + tracing::error!("Failed to decapsulate OHTTP request: {}", e); + return Err(GatewayError::OhttpDecapsulation); + } + }; + + // Parse the inner BHTTP request + let inner_req = match parse_bhttp_request(&bhttp_req) { + Ok(req) => req, + Err(e) => { + tracing::error!("Failed to parse BHTTP request: {}", e); + return Err(GatewayError::InvalidRequest); + } + }; + + // Forward the request to the configured backend + let response = match forward_request(&backend_url, &inner_req).await { + Ok(resp) => resp, + Err(e) => { + tracing::error!("Failed to forward request: {}", e); + return Err(GatewayError::ForwardingFailed); + } + }; + + // Convert the response back to BHTTP format + let bhttp_resp = match convert_to_bhttp_response(&response).await { + Ok(resp) => resp, + Err(e) => { + tracing::error!("Failed to convert response to BHTTP: {}", e); + return Err(GatewayError::ResponseEncodingFailed); + } + }; + + // Re-encapsulate the response + let ohttp_resp = match response_context.encapsulate(&bhttp_resp) { + Ok(resp) => resp, + Err(e) => { + tracing::error!("Failed to re-encapsulate OHTTP response: {}", e); + return Err(GatewayError::OhttpEncapsulation); + } + }; + + tracing::trace!("Sending OHTTP response, size: {}", ohttp_resp.len()); + + Ok(Response::builder() + .status(StatusCode::OK) + .header("content-type", "message/ohttp-res") + .body(axum::body::Body::from(ohttp_resp)) + .unwrap()) +} + +/// Handle requests for OHTTP keys +pub async fn handle_ohttp_keys( + axum::extract::Extension(ohttp): axum::extract::Extension, +) -> Result { + let keys = match ohttp.server.config().encode() { + Ok(keys) => keys, + Err(e) => { + tracing::error!("Failed to encode OHTTP keys: {}", e); + return Err(GatewayError::KeyEncodingFailed); + } + }; + + Ok(Response::builder() + .status(StatusCode::OK) + .header("content-type", "application/ohttp-keys") + .body(axum::body::Body::from(keys)) + .unwrap()) +} + +/// Handle GET requests to /.well-known/ohttp-gateway +/// +/// This endpoint handles two scenarios: +/// 1. Without query params: returns OHTTP keys (standard behavior) +/// 2. With ?allowed_purposes: returns Cashu opt-in information (gateway prober) +pub async fn handle_gateway_get( + axum::extract::Extension(ohttp): axum::extract::Extension, + axum::extract::Query(params): axum::extract::Query>, +) -> Result { + tracing::debug!( + "Received GET request to /.well-known/ohttp-gateway with params: {:?}", + params + ); + + // Check if the allowed_purposes query parameter is present (gateway prober) + if params.contains_key("allowed_purposes") { + tracing::debug!("Received gateway prober request for allowed purposes"); + + // Encode the magic string in the same format as a TLS ALPN protocol list (a + // U16BE count of strings followed by U8 length encoded strings). + // + // The string is just "CASHU" followed by a UUID, that signals to relays + // that this OHTTP gateway will accept any requests associated with this + // purpose. + let mut alpn_encoded = Vec::new(); + + // Add 16-bit big-endian count of strings in the list + // We have 1 string + let num_strings = 1u16; + alpn_encoded.extend_from_slice(&num_strings.to_be_bytes()); + + // Add the Cashu purpose string with its length prefix + let purpose_len = MAGIC_CASHU_PURPOSE.len() as u8; + alpn_encoded.push(purpose_len); + alpn_encoded.extend_from_slice(MAGIC_CASHU_PURPOSE); + + tracing::debug!( + "Responding with Cashu opt-in, purpose string: {}", + String::from_utf8_lossy(MAGIC_CASHU_PURPOSE) + ); + + Ok(Response::builder() + .status(StatusCode::OK) + .header("content-type", "application/x-ohttp-allowed-purposes") + .body(axum::body::Body::from(alpn_encoded)) + .unwrap()) + } else { + // Standard OHTTP keys request + tracing::debug!("Returning OHTTP keys"); + + let keys = match ohttp.server.config().encode() { + Ok(keys) => keys, + Err(e) => { + tracing::error!("Failed to encode OHTTP keys: {}", e); + return Err(GatewayError::KeyEncodingFailed); + } + }; + + Ok(Response::builder() + .status(StatusCode::OK) + .header("content-type", "application/ohttp-keys") + .body(axum::body::Body::from(keys)) + .unwrap()) + } +} + +#[derive(Clone)] +pub struct InnerRequest { + pub method: String, + pub uri: String, + pub headers: Vec<(String, String)>, + pub body: Vec, +} + +#[derive(Debug)] +pub enum GatewayError { + OhttpDecapsulation, + OhttpEncapsulation, + InvalidRequest, + ForwardingFailed, + ResponseEncodingFailed, + KeyEncodingFailed, +} + +impl IntoResponse for GatewayError { + fn into_response(self) -> Response { + let (status, message) = match self { + GatewayError::OhttpDecapsulation => (StatusCode::BAD_REQUEST, "Invalid OHTTP request"), + GatewayError::OhttpEncapsulation => ( + StatusCode::INTERNAL_SERVER_ERROR, + "OHTTP encapsulation failed", + ), + GatewayError::InvalidRequest => (StatusCode::BAD_REQUEST, "Invalid inner request"), + GatewayError::ForwardingFailed => { + (StatusCode::BAD_GATEWAY, "Failed to forward request") + } + GatewayError::ResponseEncodingFailed => ( + StatusCode::INTERNAL_SERVER_ERROR, + "Response encoding failed", + ), + GatewayError::KeyEncodingFailed => { + (StatusCode::INTERNAL_SERVER_ERROR, "Key encoding failed") + } + }; + + Response::builder() + .status(status) + .header("content-type", "application/json") + .body(axum::body::Body::from( + serde_json::to_string(&serde_json::json!({ + "error": message, + "type": "gateway_error" + })) + .unwrap_or_else(|_| "{}".to_string()), + )) + .unwrap() + } +} + +fn parse_bhttp_request(bhttp_bytes: &[u8]) -> Result { + use bhttp::Message; + + tracing::trace!("Parsing BHTTP request, size: {} bytes", bhttp_bytes.len()); + + let mut cursor = std::io::Cursor::new(bhttp_bytes); + let req = Message::read_bhttp(&mut cursor)?; + + let method = + String::from_utf8_lossy(req.control().method().ok_or("Missing method")?).to_string(); + + let scheme = req.control().scheme().unwrap_or(b"https"); + let authority = req.control().authority().unwrap_or(b""); + let path = req.control().path().unwrap_or(b"/"); + + let uri = format!( + "{}://{}{}", + String::from_utf8_lossy(scheme), + String::from_utf8_lossy(authority), + String::from_utf8_lossy(path) + ); + + tracing::info!("Gateway request: {} {}", method, uri); + tracing::trace!( + "URI components - scheme: '{}', authority: '{}', path: '{}'", + String::from_utf8_lossy(scheme), + String::from_utf8_lossy(authority), + String::from_utf8_lossy(path) + ); + + let mut headers = Vec::new(); + for header in req.header().fields() { + headers.push(( + String::from_utf8_lossy(header.name()).to_string(), + String::from_utf8_lossy(header.value()).to_string(), + )); + } + + let body = req.content().to_vec(); + tracing::trace!("Inner request body size: {} bytes", body.len()); + + Ok(InnerRequest { + method, + uri, + headers, + body, + }) +} + +async fn forward_request( + backend_url: &Url, + inner_req: &InnerRequest, +) -> Result { + // Extract path from inner request's URI for forwarding + let inner_uri = Uri::from_str(&inner_req.uri)?; + let path_and_query = inner_uri + .path_and_query() + .map(|pq| pq.as_str()) + .unwrap_or("/"); + + // Construct backend URL with the path from the inner request + let mut backend_url_with_path = backend_url.clone(); + backend_url_with_path.set_path(path_and_query); + if let Some(query) = inner_uri.query() { + backend_url_with_path.set_query(Some(query)); + tracing::trace!("Added query parameters: '{}'", query); + } + + tracing::debug!( + "Forwarding {} {} to {}", + inner_req.method, + inner_req.uri, + backend_url_with_path + ); + tracing::trace!("Request headers: {:?}", inner_req.headers); + tracing::trace!("Request body size: {} bytes", inner_req.body.len()); + + // Use reqwest for the actual HTTP request (simpler than hyper's low-level API) + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .redirect(reqwest::redirect::Policy::limited(10)) + .build()?; + + let mut req_builder = client.request( + reqwest::Method::from_str(&inner_req.method)?, + backend_url_with_path.as_str(), + ); + + // Add headers from the inner request + for (name, value) in &inner_req.headers { + req_builder = req_builder.header(name, value); + } + + // Add body if present + let request = if inner_req.body.is_empty() { + req_builder.build()? + } else { + req_builder.body(inner_req.body.clone()).build()? + }; + + let response = client.execute(request).await?; + let status = response.status(); + let headers = response.headers().clone(); + let body_bytes = response.bytes().await?; + + tracing::debug!("Backend response: {}", status); + tracing::trace!("Response headers: {:?}", headers); + tracing::trace!("Response body size: {} bytes", body_bytes.len()); + + // Create a simple response structure for processing + let backend_response = BackendResponse { + status: status.as_u16(), + headers: headers + .into_iter() + .filter_map(|(k, v)| k.map(|key| (key, v.clone()))) + .collect(), + body: body_bytes.to_vec(), + }; + + Ok(backend_response) +} + +async fn convert_to_bhttp_response(resp: &BackendResponse) -> Result, BoxError> { + use bhttp::{Message, StatusCode as BhttpStatus}; + + let status_code = BhttpStatus::try_from(resp.status).map_err(|_| "Invalid status code")?; + + let mut bhttp_resp = Message::response(status_code); + + // Add response headers + for (name, value) in &resp.headers { + bhttp_resp.put_header(name.as_str(), value.to_str()?); + } + + // Write the response body + bhttp_resp.write_content(&resp.body); + + let mut bhttp_bytes = Vec::new(); + bhttp_resp.write_bhttp(bhttp::Mode::KnownLength, &mut bhttp_bytes)?; + + Ok(bhttp_bytes) +} diff --git a/crates/ohttp-gateway/src/key_config.rs b/crates/ohttp-gateway/src/key_config.rs new file mode 100644 index 000000000..6418821f2 --- /dev/null +++ b/crates/ohttp-gateway/src/key_config.rs @@ -0,0 +1,83 @@ +use std::fs::File; +use std::io::Write; +use std::path::Path; + +use anyhow::Result; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone)] +pub struct OhttpConfig { + pub server: ohttp::Server, +} + +#[derive(Serialize, Deserialize)] +struct KeyPair { + ikm: [u8; 32], +} + +impl OhttpConfig { + pub fn generate_new() -> Result { + let _ikm = bitcoin::key::rand::random::<[u8; 32]>(); + let config = ohttp::KeyConfig::new( + 1, + ohttp::hpke::Kem::K256Sha256, + vec![ohttp::SymmetricSuite::new( + ohttp::hpke::Kdf::HkdfSha256, + ohttp::hpke::Aead::ChaCha20Poly1305, + )], + )?; + Ok(OhttpConfig { + server: ohttp::Server::new(config)?, + }) + } + + pub fn load_from_file>(path: P) -> Result { + let data = std::fs::read_to_string(path)?; + let keys: KeyPair = serde_json::from_str(&data)?; + + let config = ohttp::KeyConfig::derive( + 1, + ohttp::hpke::Kem::K256Sha256, + vec![ohttp::SymmetricSuite::new( + ohttp::hpke::Kdf::HkdfSha256, + ohttp::hpke::Aead::ChaCha20Poly1305, + )], + &keys.ikm, + ) + .map_err(|e| anyhow::anyhow!("Failed to derive OHTTP keys from file: {}", e))?; + + Ok(OhttpConfig { + server: ohttp::Server::new(config)?, + }) + } + + pub fn save_to_file>(&self, path: P) -> Result<()> { + // For now, just save an empty IKM - we'll generate each time for simplicity + let _ikm = bitcoin::key::rand::random::<[u8; 32]>(); + let keys = KeyPair { ikm: _ikm }; + + let data = serde_json::to_string_pretty(&keys)?; + let mut file = File::create(path)?; + file.write_all(data.as_bytes())?; + + Ok(()) + } +} + +pub fn generate_and_save_keys>(key_file: P) -> Result { + let config = OhttpConfig::generate_new()?; + config.save_to_file(&key_file)?; + tracing::info!( + "Generated new OHTTP keys and saved to {:?}", + key_file.as_ref() + ); + Ok(config) +} + +pub fn load_or_generate_keys>(key_file: P) -> Result { + if key_file.as_ref().exists() { + OhttpConfig::load_from_file(&key_file) + } else { + generate_and_save_keys(key_file) + } +} diff --git a/crates/ohttp-gateway/src/lib.rs b/crates/ohttp-gateway/src/lib.rs new file mode 100644 index 000000000..10d20671c --- /dev/null +++ b/crates/ohttp-gateway/src/lib.rs @@ -0,0 +1,12 @@ +pub mod cli; +pub mod gateway; +pub mod key_config; +pub mod router; + +// Re-exports for easier access +pub use cli::*; +pub use gateway::*; +pub use key_config::*; +pub use router::*; + +pub type BoxError = Box; diff --git a/crates/ohttp-gateway/src/router.rs b/crates/ohttp-gateway/src/router.rs new file mode 100644 index 000000000..ecfa5c0e3 --- /dev/null +++ b/crates/ohttp-gateway/src/router.rs @@ -0,0 +1,38 @@ +use std::path::Path; + +use anyhow::{anyhow, Result}; +use axum::routing::post; +use axum::Router; +use url::Url; + +use crate::{gateway, key_config}; + +/// Creates an OHTTP gateway router that forwards encapsulated requests to the specified backend +pub fn create_ohttp_gateway_router>( + backend_url: &str, + ohttp_keys_path: P, +) -> Result { + // Parse and validate the backend URL + let backend_url = Url::parse(backend_url) + .map_err(|e| anyhow!("Failed to parse backend URL '{}': {}", backend_url, e))?; + + tracing::info!("Creating OHTTP gateway router"); + tracing::info!("Backend URL: {}", backend_url); + tracing::info!("OHTTP keys file: {:?}", ohttp_keys_path.as_ref()); + + // Load or generate OHTTP keys + let ohttp = key_config::load_or_generate_keys(&ohttp_keys_path)?; + + // Create the router with OHTTP gateway endpoints + let router = Router::new() + .route( + "/.well-known/ohttp-gateway", + post(gateway::handle_ohttp_request).get(gateway::handle_gateway_get), + ) + .layer(axum::extract::Extension(ohttp)) + .layer(axum::extract::Extension(backend_url)); + + tracing::info!("OHTTP gateway router created successfully"); + + Ok(router) +}