Skip to main content

foctet_http/
lib.rs

1//! High-level HTTP integration for `application/foctet` body envelopes.
2//!
3//! `foctet-http` adapts HTTP requests and responses onto the body-complete
4//! envelope format.
5//!
6//! Foctet HTTP integration encrypts and authenticates the body bytes only. The
7//! outer HTTP method, URI, status code, and headers remain visible to the
8//! surrounding transport and should be protected by an authenticated outer
9//! channel such as HTTPS, authenticated WebTransport, or an authenticated
10//! Foctet transport session.
11//!
12//! # Layers
13//!
14//! - Recommended high-level API:
15//!   [`HttpSealer`] and [`HttpOpener`]
16//! - Framework adapters:
17//!   `axum` and `workers`
18//! - Lower-level helpers:
19//!   [`raw`]
20//!
21//! Sealed requests and responses also carry an advisory
22//! `x-foctet-scope: body-only` header so downstream systems can distinguish
23//! Foctet body envelopes from full-message protection.
24//!
25
26/// Re-export of the `http` crate used by this adapter.
27pub use http;
28
29mod config;
30mod error;
31pub mod raw;
32
33#[cfg(feature = "axum")]
34pub mod axum;
35#[cfg(all(feature = "workers", target_arch = "wasm32"))]
36pub mod workers;
37
38use http::{
39    Request, Response,
40    header::{self},
41};
42
43pub use config::{HttpConfig, HttpOpenOptions, HttpSealOptions};
44pub use error::HttpError;
45
46/// Foctet HTTP media type.
47pub const CONTENT_TYPE: &str = "application/foctet";
48/// Advisory header name describing the Foctet protection scope.
49pub const SCOPE_HEADER: &str = "x-foctet-scope";
50/// Advisory header value indicating that only the HTTP body is protected.
51pub const BODY_ONLY_SCOPE: &str = "body-only";
52
53/// High-level helper for sealing HTTP bodies, requests, and responses.
54#[derive(Clone, Debug)]
55pub struct HttpSealer {
56    options: HttpSealOptions,
57    config: HttpConfig,
58}
59
60/// High-level helper for opening HTTP bodies, requests, and responses.
61#[derive(Clone, Debug)]
62pub struct HttpOpener {
63    options: HttpOpenOptions,
64    config: HttpConfig,
65}
66
67impl HttpSealer {
68    /// Creates a sealer with default HTTP behavior.
69    pub fn new(options: HttpSealOptions) -> Self {
70        Self {
71            options,
72            config: HttpConfig::default(),
73        }
74    }
75
76    /// Creates a sealer with explicit HTTP behavior.
77    pub fn with_config(options: HttpSealOptions, config: HttpConfig) -> Self {
78        Self { options, config }
79    }
80
81    /// Returns the sealing options.
82    pub fn options(&self) -> &HttpSealOptions {
83        &self.options
84    }
85
86    /// Returns the HTTP behavior config.
87    pub fn config(&self) -> &HttpConfig {
88        &self.config
89    }
90
91    /// Seals raw plaintext bytes into an `application/foctet` body.
92    pub fn seal_body(&self, plaintext: &[u8]) -> Result<Vec<u8>, HttpError> {
93        match self.options.limits() {
94            Some(limits) => raw::seal_http_body_with_limits(
95                plaintext,
96                self.options.recipient_public_key(),
97                self.options.recipient_key_id(),
98                limits,
99            ),
100            None => raw::seal_http_body(
101                plaintext,
102                self.options.recipient_public_key(),
103                self.options.recipient_key_id(),
104            ),
105        }
106    }
107
108    /// Seals a plaintext request and sets `Content-Type: application/foctet`.
109    ///
110    /// By default this also adds the advisory `x-foctet-scope: body-only`
111    /// header so downstream consumers do not mistake body protection for
112    /// full HTTP message protection.
113    pub fn seal_request(&self, request: Request<Vec<u8>>) -> Result<Request<Vec<u8>>, HttpError> {
114        let (mut parts, body) = request.into_parts();
115        let sealed = self.seal_body(&body)?;
116        raw::set_foctet_content_type(&mut parts.headers);
117        if self.config.set_scope_header_on_seal() {
118            raw::set_foctet_scope_header(&mut parts.headers);
119        }
120        Ok(Request::from_parts(parts, sealed))
121    }
122
123    /// Seals a plaintext response and sets `Content-Type: application/foctet`.
124    ///
125    /// By default this also adds the advisory `x-foctet-scope: body-only`
126    /// header so downstream consumers do not mistake body protection for
127    /// full HTTP message protection.
128    pub fn seal_response(
129        &self,
130        response: Response<Vec<u8>>,
131    ) -> Result<Response<Vec<u8>>, HttpError> {
132        let (mut parts, body) = response.into_parts();
133        let sealed = self.seal_body(&body)?;
134        raw::set_foctet_content_type(&mut parts.headers);
135        if self.config.set_scope_header_on_seal() {
136            raw::set_foctet_scope_header(&mut parts.headers);
137        }
138        Ok(Response::from_parts(parts, sealed))
139    }
140}
141
142impl HttpOpener {
143    /// Creates an opener with default HTTP behavior.
144    pub fn new(options: HttpOpenOptions) -> Self {
145        Self {
146            options,
147            config: HttpConfig::default(),
148        }
149    }
150
151    /// Creates an opener with explicit HTTP behavior.
152    pub fn with_config(options: HttpOpenOptions, config: HttpConfig) -> Self {
153        Self { options, config }
154    }
155
156    /// Returns the opening options.
157    pub fn options(&self) -> &HttpOpenOptions {
158        &self.options
159    }
160
161    /// Returns the HTTP behavior config.
162    pub fn config(&self) -> &HttpConfig {
163        &self.config
164    }
165
166    /// Opens an `application/foctet` body into plaintext bytes.
167    pub fn open_body(&self, envelope: &[u8]) -> Result<Vec<u8>, HttpError> {
168        match self.options.limits() {
169            Some(limits) => raw::open_http_body_with_limits(
170                envelope,
171                self.options.recipient_secret_key(),
172                limits,
173            ),
174            None => raw::open_http_body(envelope, self.options.recipient_secret_key()),
175        }
176    }
177
178    /// Opens an encrypted request body into plaintext bytes.
179    pub fn open_request(&self, request: Request<Vec<u8>>) -> Result<Request<Vec<u8>>, HttpError> {
180        let (mut parts, body) = request.into_parts();
181        raw::ensure_foctet_content_type(&parts.headers)?;
182        let plain = self.open_body(&body)?;
183        if self.config.strip_content_type_on_open() {
184            parts.headers.remove(header::CONTENT_TYPE);
185        }
186        Ok(Request::from_parts(parts, plain))
187    }
188
189    /// Opens an encrypted response body into plaintext bytes.
190    pub fn open_response(
191        &self,
192        response: Response<Vec<u8>>,
193    ) -> Result<Response<Vec<u8>>, HttpError> {
194        let (mut parts, body) = response.into_parts();
195        raw::ensure_foctet_content_type(&parts.headers)?;
196        let plain = self.open_body(&body)?;
197        if self.config.strip_content_type_on_open() {
198            parts.headers.remove(header::CONTENT_TYPE);
199        }
200        Ok(Response::from_parts(parts, plain))
201    }
202}
203
204#[cfg(test)]
205mod tests {
206    use foctet_core::BodyEnvelopeLimits;
207    use http::{Request, Response, StatusCode, Version, header};
208    use rand_core::OsRng;
209    use x25519_dalek::{PublicKey, StaticSecret};
210
211    use super::*;
212
213    #[test]
214    fn sealer_and_opener_roundtrip_request_and_response() {
215        let recipient_priv = StaticSecret::random_from_rng(OsRng);
216        let recipient_pub = PublicKey::from(&recipient_priv).to_bytes();
217
218        let sealer = HttpSealer::new(HttpSealOptions::new(recipient_pub, b"kid"));
219        let opener = HttpOpener::new(HttpOpenOptions::new(recipient_priv.to_bytes()));
220
221        let request = Request::builder()
222            .method("POST")
223            .uri("https://example.com/submit")
224            .version(Version::HTTP_11)
225            .header("x-trace-id", "abc123")
226            .body(b"request payload".to_vec())
227            .expect("request");
228
229        let sealed_request = sealer.seal_request(request).expect("seal request");
230        assert_eq!(sealed_request.headers()[SCOPE_HEADER], BODY_ONLY_SCOPE);
231        let opened_request = opener.open_request(sealed_request).expect("open request");
232
233        assert_eq!(opened_request.method(), "POST");
234        assert_eq!(opened_request.uri().path(), "/submit");
235        assert_eq!(opened_request.headers()["x-trace-id"], "abc123");
236        assert_eq!(opened_request.headers()[SCOPE_HEADER], BODY_ONLY_SCOPE);
237        assert!(!opened_request.headers().contains_key(header::CONTENT_TYPE));
238        assert_eq!(opened_request.body(), b"request payload");
239
240        let response = Response::builder()
241            .status(StatusCode::CREATED)
242            .version(Version::HTTP_2)
243            .header("x-server", "foctet")
244            .body(b"response payload".to_vec())
245            .expect("response");
246
247        let sealed_response = sealer.seal_response(response).expect("seal response");
248        assert_eq!(sealed_response.headers()[SCOPE_HEADER], BODY_ONLY_SCOPE);
249        let opened_response = opener
250            .open_response(sealed_response)
251            .expect("open response");
252
253        assert_eq!(opened_response.status(), StatusCode::CREATED);
254        assert_eq!(opened_response.version(), Version::HTTP_2);
255        assert_eq!(opened_response.headers()["x-server"], "foctet");
256        assert_eq!(opened_response.headers()[SCOPE_HEADER], BODY_ONLY_SCOPE);
257        assert_eq!(opened_response.body(), b"response payload");
258    }
259
260    #[test]
261    fn opener_respects_explicit_config() {
262        let recipient_priv = StaticSecret::random_from_rng(OsRng);
263        let recipient_pub = PublicKey::from(&recipient_priv).to_bytes();
264
265        let sealer = HttpSealer::new(HttpSealOptions::new(recipient_pub, b"kid"));
266        let opener = HttpOpener::with_config(
267            HttpOpenOptions::new(recipient_priv.to_bytes()),
268            HttpConfig::default().with_strip_content_type_on_open(false),
269        );
270
271        let response = Response::builder()
272            .status(StatusCode::OK)
273            .body(b"payload".to_vec())
274            .expect("response");
275        let sealed = sealer.seal_response(response).expect("seal");
276        let opened = opener.open_response(sealed).expect("open");
277
278        assert!(opened.headers().contains_key(header::CONTENT_TYPE));
279        assert_eq!(opened.headers()[SCOPE_HEADER], BODY_ONLY_SCOPE);
280    }
281
282    #[test]
283    fn scope_header_can_be_disabled() {
284        let recipient_priv = StaticSecret::random_from_rng(OsRng);
285        let recipient_pub = PublicKey::from(&recipient_priv).to_bytes();
286
287        let sealer = HttpSealer::with_config(
288            HttpSealOptions::new(recipient_pub, b"kid"),
289            HttpConfig::default().with_scope_header_on_seal(false),
290        );
291
292        let response = Response::builder()
293            .status(StatusCode::OK)
294            .body(b"payload".to_vec())
295            .expect("response");
296        let sealed = sealer.seal_response(response).expect("seal");
297
298        assert!(!sealed.headers().contains_key(SCOPE_HEADER));
299    }
300
301    #[test]
302    fn options_support_explicit_limits() {
303        let limits = BodyEnvelopeLimits {
304            max_payload_len: 1024,
305            ..BodyEnvelopeLimits::default()
306        };
307        let options = HttpSealOptions::new([1u8; 32], b"kid").with_limits(limits.clone());
308        assert_eq!(options.limits(), Some(&limits));
309    }
310}