Skip to main content

payjoin/core/
io.rs

1//! IO-related types and functions. Specifically, fetching OHTTP keys from a payjoin directory.
2use std::time::Duration;
3
4use http::header::ACCEPT;
5use reqwest::{Client, Proxy};
6
7use crate::into_url::IntoUrl;
8use crate::OhttpKeys;
9
10/// Fetch the ohttp keys from the specified payjoin directory via proxy.
11///
12/// * `ohttp_relay`: The http CONNECT method proxy to request the ohttp keys from a payjoin
13///   directory.  Proxying requests for ohttp keys ensures a client IP address is never revealed to
14///   the payjoin directory.
15///
16/// * `payjoin_directory`: The payjoin directory from which to fetch the ohttp keys.  This
17///   directory stores and forwards payjoin client payloads.
18pub async fn fetch_ohttp_keys(
19    ohttp_relay: impl IntoUrl,
20    payjoin_directory: impl IntoUrl,
21) -> Result<OhttpKeys, Error> {
22    let ohttp_keys_url = payjoin_directory.into_url()?.join("/.well-known/ohttp-gateway")?;
23    let proxy = Proxy::all(ohttp_relay.into_url()?.as_str())?;
24    let client = Client::builder().proxy(proxy).http1_only().build()?;
25    let res = client
26        .get(ohttp_keys_url.as_str())
27        .timeout(Duration::from_secs(10))
28        .header(ACCEPT, "application/ohttp-keys")
29        .send()
30        .await?;
31    parse_ohttp_keys_response(res).await
32}
33
34/// Fetch the ohttp keys from the specified payjoin directory via proxy.
35///
36/// * `ohttp_relay`: The http CONNECT method proxy to request the ohttp keys from a payjoin
37///   directory.  Proxying requests for ohttp keys ensures a client IP address is never revealed to
38///   the payjoin directory.
39///
40/// * `payjoin_directory`: The payjoin directory from which to fetch the ohttp keys.  This
41///   directory stores and forwards payjoin client payloads.
42///
43/// * `cert_der`: The DER-encoded certificate to use for local HTTPS connections.
44#[cfg(feature = "_manual-tls")]
45pub async fn fetch_ohttp_keys_with_cert(
46    ohttp_relay: impl IntoUrl,
47    payjoin_directory: impl IntoUrl,
48    cert_der: &[u8],
49) -> Result<OhttpKeys, Error> {
50    let ohttp_keys_url = payjoin_directory.into_url()?.join("/.well-known/ohttp-gateway")?;
51    let proxy = Proxy::all(ohttp_relay.into_url()?.as_str())?;
52    let client = Client::builder()
53        .use_rustls_tls()
54        .add_root_certificate(reqwest::tls::Certificate::from_der(cert_der)?)
55        .proxy(proxy)
56        .http1_only()
57        .build()?;
58    let res = client
59        .get(ohttp_keys_url.as_str())
60        .timeout(Duration::from_secs(10))
61        .header(ACCEPT, "application/ohttp-keys")
62        .send()
63        .await?;
64    parse_ohttp_keys_response(res).await
65}
66
67async fn parse_ohttp_keys_response(res: reqwest::Response) -> Result<OhttpKeys, Error> {
68    if !res.status().is_success() {
69        return Err(Error::UnexpectedStatusCode(res.status()));
70    }
71
72    let body = res.bytes().await?.to_vec();
73    OhttpKeys::decode(&body).map_err(|e| {
74        Error::Internal(InternalError(InternalErrorInner::InvalidOhttpKeys(e.to_string())))
75    })
76}
77
78#[derive(Debug)]
79#[non_exhaustive]
80pub enum Error {
81    /// When the payjoin directory returns an unexpected status code
82    UnexpectedStatusCode(http::StatusCode),
83    /// Internal errors that should not be pattern matched by users
84    #[doc(hidden)]
85    Internal(InternalError),
86}
87
88#[derive(Debug)]
89pub struct InternalError(InternalErrorInner);
90
91#[derive(Debug)]
92enum InternalErrorInner {
93    ParseUrl(crate::into_url::Error),
94    Reqwest(reqwest::Error),
95    Io(std::io::Error),
96    #[cfg(feature = "_manual-tls")]
97    Rustls(rustls::Error),
98    InvalidOhttpKeys(String),
99}
100
101impl From<crate::core::UrlParseError> for Error {
102    fn from(value: crate::core::UrlParseError) -> Self {
103        Self::Internal(InternalError(InternalErrorInner::ParseUrl(value.into())))
104    }
105}
106
107macro_rules! impl_from_error {
108    ($from:ty, $to:ident) => {
109        impl From<$from> for Error {
110            fn from(value: $from) -> Self {
111                Self::Internal(InternalError(InternalErrorInner::$to(value)))
112            }
113        }
114    };
115}
116
117impl_from_error!(crate::into_url::Error, ParseUrl);
118impl_from_error!(reqwest::Error, Reqwest);
119impl_from_error!(std::io::Error, Io);
120#[cfg(feature = "_manual-tls")]
121impl_from_error!(rustls::Error, Rustls);
122
123impl std::fmt::Display for Error {
124    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
125        match self {
126            Self::UnexpectedStatusCode(code) => {
127                write!(f, "Unexpected status code from payjoin directory: {code}")
128            }
129            Self::Internal(InternalError(e)) => e.fmt(f),
130        }
131    }
132}
133
134impl std::fmt::Display for InternalErrorInner {
135    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
136        use InternalErrorInner::*;
137
138        match &self {
139            Reqwest(e) => e.fmt(f),
140            ParseUrl(e) => e.fmt(f),
141            Io(e) => e.fmt(f),
142            InvalidOhttpKeys(e) => {
143                write!(f, "Invalid ohttp keys returned from payjoin directory: {e}")
144            }
145            #[cfg(feature = "_manual-tls")]
146            Rustls(e) => e.fmt(f),
147        }
148    }
149}
150
151impl std::error::Error for Error {
152    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
153        match self {
154            Self::Internal(InternalError(e)) => e.source(),
155            Self::UnexpectedStatusCode(_) => None,
156        }
157    }
158}
159
160impl std::error::Error for InternalErrorInner {
161    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
162        use InternalErrorInner::*;
163
164        match self {
165            Reqwest(e) => Some(e),
166            ParseUrl(e) => Some(e),
167            Io(e) => Some(e),
168            InvalidOhttpKeys(_) => None,
169            #[cfg(feature = "_manual-tls")]
170            Rustls(e) => Some(e),
171        }
172    }
173}
174
175impl From<InternalError> for Error {
176    fn from(value: InternalError) -> Self { Self::Internal(value) }
177}
178
179impl From<InternalErrorInner> for Error {
180    fn from(value: InternalErrorInner) -> Self { Self::Internal(InternalError(value)) }
181}
182
183#[cfg(test)]
184mod tests {
185    use http::StatusCode;
186    use reqwest::Response;
187
188    use super::*;
189
190    fn mock_response(status: StatusCode, body: Vec<u8>) -> Response {
191        Response::from(http::response::Response::builder().status(status).body(body).unwrap())
192    }
193
194    #[tokio::test]
195    async fn test_parse_success_response() {
196        let valid_keys = payjoin_test_utils::ohttp_key_config_bytes();
197
198        let response = mock_response(StatusCode::OK, valid_keys);
199        assert!(parse_ohttp_keys_response(response).await.is_ok(), "expected valid keys response");
200    }
201
202    #[tokio::test]
203    async fn test_parse_error_status_codes() {
204        let error_codes = [
205            StatusCode::BAD_REQUEST,
206            StatusCode::NOT_FOUND,
207            StatusCode::INTERNAL_SERVER_ERROR,
208            StatusCode::SERVICE_UNAVAILABLE,
209        ];
210
211        for status in error_codes {
212            let response = mock_response(status, vec![]);
213            match parse_ohttp_keys_response(response).await {
214                Err(Error::UnexpectedStatusCode(code)) => assert_eq!(code, status),
215                result => panic!(
216                    "Expected UnexpectedStatusCode error for status code: {status}, got: {result:?}"
217                ),
218            }
219        }
220    }
221
222    #[tokio::test]
223    async fn test_parse_invalid_keys() {
224        // Invalid OHTTP keys (not properly encoded)
225        let invalid_keys = vec![1, 2, 3, 4];
226
227        let response = mock_response(StatusCode::OK, invalid_keys);
228
229        assert!(
230            matches!(
231                parse_ohttp_keys_response(response).await,
232                Err(Error::Internal(InternalError(InternalErrorInner::InvalidOhttpKeys(_))))
233            ),
234            "expected InvalidOhttpKeys error"
235        );
236    }
237}