Skip to main content

rest/
content_encryption.rs

1use actix_web::{
2    body::{self, BoxBody, MessageBody},
3    dev::{Service, ServiceRequest, ServiceResponse, Transform},
4    http::{header, Method, StatusCode, Uri},
5    web::{Bytes, BytesMut},
6    Error, HttpMessage, HttpResponse,
7};
8use base64::{engine::general_purpose::STANDARD, Engine};
9use futures::{future::LocalBoxFuture, future::Ready, Stream, StreamExt};
10use ring::{
11    aead::{self, Aad, LessSafeKey, Nonce, UnboundKey},
12    rand::{SecureRandom, SystemRandom},
13};
14use std::{
15    collections::HashMap,
16    fmt,
17    pin::Pin,
18    rc::Rc,
19    sync::Arc,
20    task::{Context, Poll},
21};
22
23pub const CONTENT_ENCRYPTION_HEADER: &str = "x-content-encryption";
24pub const CONTENT_KEY_ID_HEADER: &str = "x-content-key-id";
25const ALGORITHM: &str = "aes-256-gcm-v1";
26const MAGIC: &[u8; 4] = b"RZC1";
27const NONCE_LEN: usize = 12;
28const TAG_LEN: usize = 16;
29
30/// A named AES-256-GCM key. Debug output deliberately omits the key material.
31#[derive(Clone, PartialEq, Eq)]
32pub struct ContentEncryptionKey {
33    id: String,
34    bytes: [u8; 32],
35}
36
37impl ContentEncryptionKey {
38    pub fn new(id: impl Into<String>, bytes: [u8; 32]) -> Result<Self, ContentEncryptionError> {
39        let id = id.into();
40        if id.trim().is_empty() || id.len() > 128 || !id.bytes().all(is_key_id_byte) {
41            return Err(ContentEncryptionError::InvalidKeyId);
42        }
43        Ok(Self { id, bytes })
44    }
45
46    pub fn id(&self) -> &str {
47        &self.id
48    }
49
50    /// Encodes a request body using the middleware's documented wire format.
51    pub fn encrypt_request(
52        &self,
53        method: &Method,
54        uri: &Uri,
55        plaintext: &[u8],
56    ) -> Result<String, ContentEncryptionError> {
57        self.seal(&request_aad(method, uri), plaintext)
58    }
59
60    pub fn decrypt_request(
61        &self,
62        method: &Method,
63        uri: &Uri,
64        encoded: &[u8],
65    ) -> Result<Bytes, ContentEncryptionError> {
66        self.open(&request_aad(method, uri), encoded)
67    }
68
69    pub fn encrypt_response(
70        &self,
71        method: &Method,
72        uri: &Uri,
73        status: StatusCode,
74        plaintext: &[u8],
75    ) -> Result<String, ContentEncryptionError> {
76        self.seal(&response_aad(method, uri, status), plaintext)
77    }
78
79    pub fn decrypt_response(
80        &self,
81        method: &Method,
82        uri: &Uri,
83        status: StatusCode,
84        encoded: &[u8],
85    ) -> Result<Bytes, ContentEncryptionError> {
86        self.open(&response_aad(method, uri, status), encoded)
87    }
88
89    fn seal(&self, aad: &[u8], plaintext: &[u8]) -> Result<String, ContentEncryptionError> {
90        let key = less_safe_key(&self.bytes)?;
91        let mut nonce = [0_u8; NONCE_LEN];
92        SystemRandom::new()
93            .fill(&mut nonce)
94            .map_err(|_| ContentEncryptionError::RandomnessUnavailable)?;
95        let mut ciphertext = plaintext.to_vec();
96        key.seal_in_place_append_tag(
97            Nonce::assume_unique_for_key(nonce),
98            Aad::from(aad),
99            &mut ciphertext,
100        )
101        .map_err(|_| ContentEncryptionError::EncryptionFailed)?;
102
103        let mut envelope = Vec::with_capacity(MAGIC.len() + nonce.len() + ciphertext.len());
104        envelope.extend_from_slice(MAGIC);
105        envelope.extend_from_slice(&nonce);
106        envelope.extend_from_slice(&ciphertext);
107        Ok(STANDARD.encode(envelope))
108    }
109
110    fn open(&self, aad: &[u8], encoded: &[u8]) -> Result<Bytes, ContentEncryptionError> {
111        let mut envelope = STANDARD
112            .decode(encoded)
113            .map_err(|_| ContentEncryptionError::InvalidEnvelope)?;
114        if envelope.len() < MAGIC.len() + NONCE_LEN + TAG_LEN || &envelope[..MAGIC.len()] != MAGIC {
115            return Err(ContentEncryptionError::InvalidEnvelope);
116        }
117        let nonce_start = MAGIC.len();
118        let ciphertext_start = nonce_start + NONCE_LEN;
119        let nonce: [u8; NONCE_LEN] = envelope[nonce_start..ciphertext_start]
120            .try_into()
121            .map_err(|_| ContentEncryptionError::InvalidEnvelope)?;
122        let key = less_safe_key(&self.bytes)?;
123        let plaintext = key
124            .open_in_place(
125                Nonce::assume_unique_for_key(nonce),
126                Aad::from(aad),
127                &mut envelope[ciphertext_start..],
128            )
129            .map_err(|_| ContentEncryptionError::AuthenticationFailed)?;
130        Ok(Bytes::copy_from_slice(plaintext))
131    }
132}
133
134impl fmt::Debug for ContentEncryptionKey {
135    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
136        formatter
137            .debug_struct("ContentEncryptionKey")
138            .field("id", &self.id)
139            .field("bytes", &"[REDACTED]")
140            .finish()
141    }
142}
143
144/// Supplies the current response key and retained request-decryption keys.
145///
146/// Providers should return immutable key snapshots and perform remote refreshes outside the
147/// request path. Rotation is safe when old keys remain available until all clients have moved to
148/// the new `current_key` ID.
149pub trait ContentKeyProvider: Send + Sync + 'static {
150    fn current_key(&self) -> Option<ContentEncryptionKey>;
151    fn key(&self, id: &str) -> Option<ContentEncryptionKey>;
152}
153
154/// In-memory provider useful for configuration-backed current/previous key rotation.
155#[derive(Debug, Clone)]
156pub struct StaticContentKeyProvider {
157    current_id: String,
158    keys: HashMap<String, ContentEncryptionKey>,
159}
160
161impl StaticContentKeyProvider {
162    pub fn new(current: ContentEncryptionKey) -> Self {
163        let current_id = current.id.clone();
164        Self {
165            current_id,
166            keys: HashMap::from([(current.id.clone(), current)]),
167        }
168    }
169
170    pub fn with_decryption_key(
171        mut self,
172        key: ContentEncryptionKey,
173    ) -> Result<Self, ContentEncryptionError> {
174        if self.keys.insert(key.id.clone(), key).is_some() {
175            return Err(ContentEncryptionError::DuplicateKeyId);
176        }
177        Ok(self)
178    }
179}
180
181impl ContentKeyProvider for StaticContentKeyProvider {
182    fn current_key(&self) -> Option<ContentEncryptionKey> {
183        self.keys.get(&self.current_id).cloned()
184    }
185
186    fn key(&self, id: &str) -> Option<ContentEncryptionKey> {
187        self.keys.get(id).cloned()
188    }
189}
190
191#[derive(Debug, Clone, Copy, PartialEq, Eq)]
192pub enum ContentEncryptionError {
193    InvalidKeyId,
194    DuplicateKeyId,
195    InvalidEnvelope,
196    AuthenticationFailed,
197    EncryptionFailed,
198    RandomnessUnavailable,
199}
200
201impl fmt::Display for ContentEncryptionError {
202    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
203        formatter.write_str(match self {
204            Self::InvalidKeyId => "invalid content-encryption key ID",
205            Self::DuplicateKeyId => "duplicate content-encryption key ID",
206            Self::InvalidEnvelope => "invalid encrypted-content envelope",
207            Self::AuthenticationFailed => "encrypted-content authentication failed",
208            Self::EncryptionFailed => "content encryption failed",
209            Self::RandomnessUnavailable => "secure randomness is unavailable",
210        })
211    }
212}
213
214impl std::error::Error for ContentEncryptionError {}
215
216/// Opt-in authenticated request decryption and response encryption middleware.
217///
218/// Non-empty requests must contain `x-content-encryption: aes-256-gcm-v1` and a
219/// `x-content-key-id`. Envelopes are standard base64 over `RZC1 || nonce || ciphertext || tag`.
220/// Authentication additionally binds the method, path/query, direction, and response status.
221#[derive(Clone)]
222pub struct ContentEncryption {
223    provider: Option<Arc<dyn ContentKeyProvider>>,
224    max_request_bytes: usize,
225    max_response_bytes: usize,
226}
227
228impl ContentEncryption {
229    pub fn new<P>(provider: P, max_body_bytes: usize) -> Self
230    where
231        P: ContentKeyProvider,
232    {
233        assert!(
234            max_body_bytes > 0,
235            "encrypted body limit must be greater than zero"
236        );
237        Self {
238            provider: Some(Arc::new(provider)),
239            max_request_bytes: max_body_bytes,
240            max_response_bytes: max_body_bytes,
241        }
242    }
243
244    pub fn with_response_limit(mut self, max_response_bytes: usize) -> Self {
245        assert!(
246            max_response_bytes > 0,
247            "encrypted response limit must be greater than zero"
248        );
249        self.max_response_bytes = max_response_bytes;
250        self
251    }
252
253    pub(crate) fn disabled(max_body_bytes: usize) -> Self {
254        Self {
255            provider: None,
256            max_request_bytes: max_body_bytes,
257            max_response_bytes: max_body_bytes,
258        }
259    }
260}
261
262impl<S, B> Transform<S, ServiceRequest> for ContentEncryption
263where
264    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static,
265    S::Future: 'static,
266    B: MessageBody + 'static,
267{
268    type Response = ServiceResponse<BoxBody>;
269    type Error = Error;
270    type Transform = ContentEncryptionMiddleware<S>;
271    type InitError = ();
272    type Future = Ready<Result<Self::Transform, Self::InitError>>;
273
274    fn new_transform(&self, service: S) -> Self::Future {
275        futures::future::ready(Ok(ContentEncryptionMiddleware {
276            service: Rc::new(service),
277            provider: self.provider.clone(),
278            max_request_bytes: self.max_request_bytes,
279            max_response_bytes: self.max_response_bytes,
280        }))
281    }
282}
283
284pub struct ContentEncryptionMiddleware<S> {
285    service: Rc<S>,
286    provider: Option<Arc<dyn ContentKeyProvider>>,
287    max_request_bytes: usize,
288    max_response_bytes: usize,
289}
290
291impl<S, B> Service<ServiceRequest> for ContentEncryptionMiddleware<S>
292where
293    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static,
294    S::Future: 'static,
295    B: MessageBody + 'static,
296{
297    type Response = ServiceResponse<BoxBody>;
298    type Error = Error;
299    type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
300
301    fn poll_ready(&self, context: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
302        self.service.poll_ready(context)
303    }
304
305    fn call(&self, mut request: ServiceRequest) -> Self::Future {
306        let service = Rc::clone(&self.service);
307        let provider = self.provider.clone();
308        let max_request_bytes = self.max_request_bytes;
309        let max_response_bytes = self.max_response_bytes;
310
311        Box::pin(async move {
312            let Some(provider) = provider else {
313                return Ok(service.call(request).await?.map_into_boxed_body());
314            };
315            let method = request.method().clone();
316            let uri = request.uri().clone();
317            let mut payload = request.take_payload();
318            let mut encoded = BytesMut::new();
319            while let Some(chunk) = payload.next().await {
320                let chunk = chunk.map_err(actix_web::error::ErrorBadRequest)?;
321                if encoded.len().saturating_add(chunk.len()) > max_request_bytes {
322                    return Ok(error_response(request, StatusCode::PAYLOAD_TOO_LARGE));
323                }
324                encoded.extend_from_slice(&chunk);
325            }
326
327            if !encoded.is_empty() {
328                let algorithm = request
329                    .headers()
330                    .get(CONTENT_ENCRYPTION_HEADER)
331                    .and_then(|value| value.to_str().ok());
332                if algorithm != Some(ALGORITHM) {
333                    return Ok(error_response(request, StatusCode::UNSUPPORTED_MEDIA_TYPE));
334                }
335                let Some(key_id) = request
336                    .headers()
337                    .get(CONTENT_KEY_ID_HEADER)
338                    .and_then(|value| value.to_str().ok())
339                else {
340                    return Ok(error_response(request, StatusCode::BAD_REQUEST));
341                };
342                let Some(key) = provider.key(key_id) else {
343                    return Ok(error_response(request, StatusCode::BAD_REQUEST));
344                };
345                let plaintext = match key.decrypt_request(&method, &uri, &encoded) {
346                    Ok(plaintext) => plaintext,
347                    Err(_) => return Ok(error_response(request, StatusCode::BAD_REQUEST)),
348                };
349                request.headers_mut().remove(CONTENT_ENCRYPTION_HEADER);
350                request.headers_mut().remove(CONTENT_KEY_ID_HEADER);
351                request.headers_mut().remove(header::CONTENT_LENGTH);
352                set_payload(&mut request, plaintext);
353            } else {
354                set_payload(&mut request, Bytes::new());
355            }
356
357            let response = service.call(request).await?.map_into_boxed_body();
358            let status = response.status();
359            if response
360                .headers()
361                .get(header::CONTENT_TYPE)
362                .and_then(|value| value.to_str().ok())
363                .is_some_and(|value| value.starts_with("text/event-stream"))
364                || response
365                    .headers()
366                    .get(header::CONTENT_ENCODING)
367                    .and_then(|value| value.to_str().ok())
368                    .is_some_and(|value| !value.eq_ignore_ascii_case("identity"))
369            {
370                let (request, _) = response.into_parts();
371                return Ok(error_from_request(
372                    request,
373                    StatusCode::INTERNAL_SERVER_ERROR,
374                ));
375            }
376
377            let (request, response) = response.into_parts();
378            let response_headers = response.headers().clone();
379            let plaintext =
380                match body::to_bytes_limited(response.into_body(), max_response_bytes).await {
381                    Ok(Ok(body)) => body,
382                    _ => {
383                        return Ok(error_from_request(
384                            request,
385                            StatusCode::INTERNAL_SERVER_ERROR,
386                        ))
387                    }
388                };
389            let Some(key) = provider.current_key() else {
390                return Ok(error_from_request(
391                    request,
392                    StatusCode::INTERNAL_SERVER_ERROR,
393                ));
394            };
395            let encoded = match key.encrypt_response(&method, &uri, status, &plaintext) {
396                Ok(encoded) => encoded,
397                Err(_) => {
398                    return Ok(error_from_request(
399                        request,
400                        StatusCode::INTERNAL_SERVER_ERROR,
401                    ))
402                }
403            };
404            let mut builder = HttpResponse::build(status);
405            for (name, value) in response_headers.iter() {
406                if name != header::CONTENT_LENGTH && name != header::TRANSFER_ENCODING {
407                    builder.append_header((name.clone(), value.clone()));
408                }
409            }
410            builder.insert_header((CONTENT_ENCRYPTION_HEADER, ALGORITHM));
411            builder.insert_header((CONTENT_KEY_ID_HEADER, key.id()));
412            Ok(ServiceResponse::new(request, builder.body(encoded)))
413        })
414    }
415}
416
417fn set_payload(request: &mut ServiceRequest, body: Bytes) {
418    let payload =
419        futures::stream::once(async move { Ok::<_, actix_web::error::PayloadError>(body) });
420    let payload: Pin<
421        Box<dyn Stream<Item = Result<actix_web::web::Bytes, actix_web::error::PayloadError>>>,
422    > = Box::pin(payload);
423    request.set_payload(payload.into());
424}
425
426fn error_response(request: ServiceRequest, status: StatusCode) -> ServiceResponse<BoxBody> {
427    request.into_response(HttpResponse::build(status).finish().map_into_boxed_body())
428}
429
430fn error_from_request(
431    request: actix_web::HttpRequest,
432    status: StatusCode,
433) -> ServiceResponse<BoxBody> {
434    ServiceResponse::new(request, HttpResponse::build(status).finish())
435}
436
437fn less_safe_key(bytes: &[u8; 32]) -> Result<LessSafeKey, ContentEncryptionError> {
438    UnboundKey::new(&aead::AES_256_GCM, bytes)
439        .map(LessSafeKey::new)
440        .map_err(|_| ContentEncryptionError::EncryptionFailed)
441}
442
443fn request_aad(method: &Method, uri: &Uri) -> Vec<u8> {
444    format!("rust-zero-content-v1\0request\0{method}\0{uri}").into_bytes()
445}
446
447fn response_aad(method: &Method, uri: &Uri, status: StatusCode) -> Vec<u8> {
448    format!(
449        "rust-zero-content-v1\0response\0{method}\0{uri}\0{}",
450        status.as_u16()
451    )
452    .into_bytes()
453}
454
455fn is_key_id_byte(byte: u8) -> bool {
456    byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')
457}
458
459#[cfg(test)]
460mod tests {
461    use super::*;
462    use actix_web::{test as actix_test, web, App};
463
464    fn key(id: &str, byte: u8) -> ContentEncryptionKey {
465        ContentEncryptionKey::new(id, [byte; 32]).unwrap()
466    }
467
468    #[test]
469    fn envelope_authenticates_context_and_ciphertext() {
470        let key = key("primary", 7);
471        let method = Method::POST;
472        let uri: Uri = "/records?tenant=one".parse().unwrap();
473        let encoded = key.encrypt_request(&method, &uri, b"secret").unwrap();
474        assert_eq!(
475            key.decrypt_request(&method, &uri, encoded.as_bytes())
476                .unwrap(),
477            Bytes::from_static(b"secret")
478        );
479        assert_eq!(
480            key.decrypt_request(&Method::PUT, &uri, encoded.as_bytes()),
481            Err(ContentEncryptionError::AuthenticationFailed)
482        );
483
484        let mut tampered = STANDARD.decode(encoded).unwrap();
485        *tampered.last_mut().unwrap() ^= 1;
486        assert_eq!(
487            key.decrypt_request(&method, &uri, STANDARD.encode(tampered).as_bytes()),
488            Err(ContentEncryptionError::AuthenticationFailed)
489        );
490    }
491
492    #[actix_rt::test]
493    async fn middleware_decrypts_request_and_encrypts_response_with_rotated_key() {
494        let previous = key("previous", 3);
495        let current = key("current", 9);
496        let provider = StaticContentKeyProvider::new(current.clone())
497            .with_decryption_key(previous.clone())
498            .unwrap();
499        let app = actix_test::init_service(
500            App::new()
501                .wrap(ContentEncryption::new(provider, 4096))
502                .route("/echo", web::post().to(|body: Bytes| async move { body })),
503        )
504        .await;
505        let method = Method::POST;
506        let uri: Uri = "/echo".parse().unwrap();
507        let encrypted = previous.encrypt_request(&method, &uri, b"hello").unwrap();
508        let request = actix_test::TestRequest::post()
509            .uri("/echo")
510            .insert_header((CONTENT_ENCRYPTION_HEADER, ALGORITHM))
511            .insert_header((CONTENT_KEY_ID_HEADER, previous.id()))
512            .set_payload(encrypted)
513            .to_request();
514        let response = actix_test::call_service(&app, request).await;
515        assert_eq!(response.status(), StatusCode::OK);
516        assert_eq!(
517            response.headers().get(CONTENT_KEY_ID_HEADER).unwrap(),
518            current.id()
519        );
520        let body = actix_test::read_body(response).await;
521        assert_eq!(
522            current
523                .decrypt_response(&method, &uri, StatusCode::OK, &body)
524                .unwrap(),
525            Bytes::from_static(b"hello")
526        );
527    }
528
529    #[actix_rt::test]
530    async fn malformed_or_unknown_ciphertext_fails_before_handler() {
531        let provider = StaticContentKeyProvider::new(key("current", 9));
532        let app = actix_test::init_service(
533            App::new()
534                .wrap(ContentEncryption::new(provider, 4096))
535                .route("/", web::post().to(HttpResponse::Ok)),
536        )
537        .await;
538        let response = actix_test::call_service(
539            &app,
540            actix_test::TestRequest::post()
541                .uri("/")
542                .insert_header((CONTENT_ENCRYPTION_HEADER, ALGORITHM))
543                .insert_header((CONTENT_KEY_ID_HEADER, "missing"))
544                .set_payload("not ciphertext")
545                .to_request(),
546        )
547        .await;
548        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
549    }
550
551    #[actix_rt::test]
552    async fn middleware_rejects_streaming_and_oversized_responses() {
553        let provider = StaticContentKeyProvider::new(key("current", 9));
554        let app = actix_test::init_service(
555            App::new()
556                .wrap(ContentEncryption::new(provider, 4096).with_response_limit(4))
557                .route(
558                    "/events",
559                    web::get().to(|| async {
560                        HttpResponse::Ok()
561                            .content_type("text/event-stream")
562                            .streaming(futures::stream::iter([Ok::<_, Error>(Bytes::from_static(
563                                b"data: ready\n\n",
564                            ))]))
565                    }),
566                )
567                .route(
568                    "/large",
569                    web::get().to(|| async { HttpResponse::Ok().body("12345") }),
570                ),
571        )
572        .await;
573
574        for uri in ["/events", "/large"] {
575            let response = actix_test::call_service(
576                &app,
577                actix_test::TestRequest::get().uri(uri).to_request(),
578            )
579            .await;
580            assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
581            assert!(!response.headers().contains_key(CONTENT_ENCRYPTION_HEADER));
582        }
583    }
584}