Skip to main content

keeper_secrets_manager_core/
caching.rs

1// -*- coding: utf-8 -*-
2//  _  __
3// | |/ /___ ___ _ __  ___ _ _ (R)
4// | ' </ -_) -_) '_ \/ -_) '_|
5// |_|\_\___\___| .__/\___|_|
6//              |_|
7//
8// Keeper Secrets Manager
9// Copyright 2024 Keeper Security Inc.
10// Contact: sm@keepersecurity.com
11//
12
13//! Caching post function for disaster recovery
14//!
15//! This module provides a drop-in replacement for the default HTTP post function
16//! that automatically caches successful API responses. On network failure, it falls
17//! back to cached data to enable offline operation.
18//!
19//! # Usage
20//!
21//! ```rust,no_run
22//! use keeper_secrets_manager_core::core::{ClientOptions, SecretsManager};
23//! use keeper_secrets_manager_core::storage::FileKeyValueStorage;
24//! use keeper_secrets_manager_core::caching;
25//!
26//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
27//! // Build the client once outside any async context (safe for spawn_blocking)
28//! let client = reqwest::blocking::Client::builder().build()?;
29//! let config = FileKeyValueStorage::new_config_storage("config.json".to_string())?;
30//! let mut client_options = ClientOptions::new_client_options(config);
31//! client_options.set_custom_post_function(caching::make_caching_post_function(client));
32//!
33//! let mut secrets_manager = SecretsManager::new(client_options)?;
34//!
35//! // First call saves to cache; if network fails, falls back to cached data
36//! let secrets = secrets_manager.get_secrets(Vec::new())?;
37//! # Ok(())
38//! # }
39//! ```
40
41use crate::crypto::CryptoUtils;
42use crate::custom_error::KSMRError;
43use crate::dto::{EncryptedPayload, KsmHttpResponse, TransmissionKey};
44use log::{debug, warn};
45use reqwest::blocking::Client;
46use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
47use std::env;
48use std::fs::{File, OpenOptions};
49use std::io::{Read, Write};
50use std::path::{Path, PathBuf};
51use std::str::FromStr;
52
53/// Default cache file name
54const DEFAULT_CACHE_FILE: &str = "ksm_cache.bin";
55
56/// Get the cache file path from environment or default
57pub fn get_cache_file_path() -> PathBuf {
58    let cache_dir = env::var("KSM_CACHE_DIR").unwrap_or_else(|_| ".".to_string());
59    Path::new(&cache_dir).join(DEFAULT_CACHE_FILE)
60}
61
62/// Save cache data to disk
63///
64/// # Arguments
65/// * `data` - The data to cache (transmission key + encrypted response)
66///
67/// # Errors
68/// Silently fails on write errors (doesn't break the application)
69pub fn save_cache(data: &[u8]) -> Result<(), KSMRError> {
70    let cache_path = get_cache_file_path();
71
72    let mut file = OpenOptions::new()
73        .write(true)
74        .create(true)
75        .truncate(true)
76        .open(&cache_path)
77        .map_err(|e| KSMRError::CacheSaveError(format!("Failed to open cache file: {}", e)))?;
78
79    file.write_all(data)
80        .map_err(|e| KSMRError::CacheSaveError(format!("Failed to write cache: {}", e)))?;
81
82    // Explicitly sync to disk to ensure file is visible immediately (important for tests)
83    file.sync_all()
84        .map_err(|e| KSMRError::CacheSaveError(format!("Failed to sync cache: {}", e)))?;
85
86    debug!("Cache saved to {:?}", cache_path);
87    Ok(())
88}
89
90/// Load cache data from disk
91///
92/// # Returns
93/// * `Option<Vec<u8>>` - Cached data if available, None otherwise
94pub fn get_cached_data() -> Option<Vec<u8>> {
95    let cache_path = get_cache_file_path();
96
97    if !cache_path.exists() {
98        return None;
99    }
100
101    let mut file = File::open(&cache_path).ok()?;
102    let mut data = Vec::new();
103    file.read_to_end(&mut data).ok()?;
104
105    debug!("Cache loaded from {:?}", cache_path);
106    Some(data)
107}
108
109/// Clear the cache file
110pub fn clear_cache() -> Result<(), KSMRError> {
111    let cache_path = get_cache_file_path();
112
113    if cache_path.exists() {
114        std::fs::remove_file(&cache_path)
115            .map_err(|e| KSMRError::CacheRetrieveError(format!("Failed to delete cache: {}", e)))?;
116    }
117
118    Ok(())
119}
120
121/// Check if cache file exists
122pub fn cache_exists() -> bool {
123    get_cache_file_path().exists()
124}
125
126/// Build a caching post function that captures a pre-built HTTP client.
127///
128/// Returns a closure suitable for use with `ClientOptions::set_custom_post_function`.
129/// The returned closure reuses the provided `reqwest::blocking::Client` on every call,
130/// so it is safe to invoke from inside `tokio::task::spawn_blocking`. Construct the
131/// client and the closure outside any async context, then pass it to
132/// `ClientOptions::set_custom_post_function` before calling `SecretsManager::new()`.
133///
134/// # Example
135/// ```rust,no_run
136/// use keeper_secrets_manager_core::core::{ClientOptions, SecretsManager};
137/// use keeper_secrets_manager_core::storage::FileKeyValueStorage;
138/// use keeper_secrets_manager_core::caching;
139///
140/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
141/// let client = reqwest::blocking::Client::builder().build()?;
142/// let config = FileKeyValueStorage::new_config_storage("config.json".to_string())?;
143/// let mut client_options = ClientOptions::new_client_options(config);
144/// client_options.set_custom_post_function(caching::make_caching_post_function(client));
145/// let mut secrets_manager = SecretsManager::new(client_options)?;
146/// # Ok(())
147/// # }
148/// ```
149pub fn make_caching_post_function(
150    client: reqwest::blocking::Client,
151) -> impl Fn(String, TransmissionKey, EncryptedPayload) -> Result<KsmHttpResponse, KSMRError>
152       + Send
153       + Sync
154       + 'static {
155    move |url, transmission_key, encrypted_payload| {
156        run_caching_logic(url, transmission_key, encrypted_payload, &client)
157    }
158}
159
160/// Caching post function for disaster recovery.
161///
162/// **Warning**: This bare function builds a new `reqwest::blocking::Client` on every
163/// call. Calling it from inside `tokio::task::spawn_blocking` will panic with
164/// *"Cannot drop a runtime in a context where blocking is not allowed"*.
165/// Use [`make_caching_post_function`] instead, which captures a pre-built client.
166///
167/// # Arguments
168/// * `url` - The API endpoint URL
169/// * `transmission_key` - The transmission key for encryption
170/// * `encrypted_payload` - The encrypted payload with signature
171///
172/// # Returns
173/// * `Result<KsmHttpResponse, KSMRError>` - Response object (from network or cache)
174pub fn caching_post_function(
175    url: String,
176    transmission_key: TransmissionKey,
177    encrypted_payload: EncryptedPayload,
178) -> Result<KsmHttpResponse, KSMRError> {
179    let proxy_url = std::env::var("KSM_PROXY_URL").ok();
180    let mut client_builder = Client::builder();
181    if let Some(ref proxy) = proxy_url {
182        if let Ok(p) = reqwest::Proxy::all(proxy) {
183            client_builder = client_builder.proxy(p);
184        }
185    }
186    let client = client_builder
187        .build()
188        .map_err(|e| KSMRError::HTTPError(format!("Failed to build client: {}", e)))?;
189    run_caching_logic(url, transmission_key, encrypted_payload, &client)
190}
191
192fn run_caching_logic(
193    url: String,
194    transmission_key: TransmissionKey,
195    encrypted_payload: EncryptedPayload,
196    client: &Client,
197) -> Result<KsmHttpResponse, KSMRError> {
198    // Try network request first
199    match make_http_request(client, url, transmission_key.clone(), encrypted_payload) {
200        Ok(response) if response.status_code == 200 => {
201            // On success, save to cache (transmission key + encrypted response body)
202            let mut cache_data = transmission_key.key.clone();
203            cache_data.extend_from_slice(&response.data);
204
205            // Silently fail on cache write errors
206            if let Err(e) = save_cache(&cache_data) {
207                warn!("Failed to save cache: {}", e);
208            }
209
210            Ok(response)
211        }
212        Ok(response) => {
213            // Non-200 response - don't cache, return error response
214            Ok(response)
215        }
216        Err(network_error) => {
217            // Network failed - try to load from cache
218            warn!(
219                "Network request failed: {}, attempting to use cached data",
220                network_error
221            );
222
223            if let Some(cached_data) = get_cached_data() {
224                if cached_data.len() > 32 {
225                    // Extract cached transmission key and response data
226                    // First 32 bytes are the transmission key, rest is encrypted response
227                    let cached_transmission_key = cached_data[0..32].to_vec();
228                    let cached_response_data = cached_data[32..].to_vec();
229
230                    debug!("Using cached data ({} bytes)", cached_response_data.len());
231
232                    // Decrypt cached response with cached transmission key
233                    let decrypted_data =
234                        CryptoUtils::decrypt_aes(&cached_response_data, &cached_transmission_key)
235                            .map_err(|e| {
236                            warn!("Failed to decrypt cached data: {}", e);
237                            KSMRError::CryptoError(format!("Cache decryption failed: {}", e))
238                        })?;
239
240                    // Re-encrypt with current transmission key so caller can decrypt it
241                    let re_encrypted_data =
242                        CryptoUtils::encrypt_aes_gcm(&decrypted_data, &transmission_key.key, None)?;
243
244                    debug!(
245                        "Successfully decrypted cached data and re-encrypted with current transmission key"
246                    );
247
248                    // Return re-encrypted cached response
249                    return Ok(KsmHttpResponse {
250                        status_code: 200,
251                        data: re_encrypted_data,
252                        http_response: Some("Cached response (re-encrypted)".to_string()),
253                    });
254                }
255            }
256
257            // No cache available - re-raise the original error
258            Err(network_error)
259        }
260    }
261}
262
263fn make_http_request(
264    client: &Client,
265    url: String,
266    transmission_key: TransmissionKey,
267    encrypted_payload: EncryptedPayload,
268) -> Result<KsmHttpResponse, KSMRError> {
269    // Build headers
270    let mut headers = HeaderMap::new();
271    headers.insert(
272        HeaderName::from_str("Content-Type").unwrap(),
273        HeaderValue::from_str("application/octet-stream").unwrap(),
274    );
275    headers.insert(
276        HeaderName::from_str("PublicKeyId").unwrap(),
277        HeaderValue::from_str(&transmission_key.public_key_id).unwrap(),
278    );
279    headers.insert(
280        HeaderName::from_str("TransmissionKey").unwrap(),
281        HeaderValue::from_str(&crate::utils::bytes_to_base64(
282            &transmission_key.encrypted_key,
283        ))
284        .unwrap(),
285    );
286    headers.insert(
287        HeaderName::from_str("Authorization").unwrap(),
288        HeaderValue::from_str(&format!(
289            "Signature {}",
290            crate::utils::bytes_to_base64(&encrypted_payload.signature.to_bytes())
291        ))
292        .unwrap(),
293    );
294
295    // Make POST request
296    let response = client
297        .post(&url)
298        .headers(headers)
299        .body(encrypted_payload.encrypted_payload.clone())
300        .send()
301        .map_err(|e| KSMRError::HTTPError(format!("HTTP request failed: {}", e)))?;
302
303    let status_code = response.status().as_u16();
304    let response_body = response
305        .bytes()
306        .map_err(|e| KSMRError::HTTPError(format!("Failed to read response: {}", e)))?
307        .to_vec();
308
309    Ok(KsmHttpResponse {
310        status_code,
311        data: response_body,
312        http_response: None,
313    })
314}
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319
320    #[test]
321    fn test_cache_file_path() {
322        let path = get_cache_file_path();
323        assert!(path.to_str().unwrap().contains("ksm_cache.bin"));
324    }
325
326    #[test]
327    fn test_cache_operations() {
328        // Clear any existing cache
329        let _ = clear_cache();
330
331        // Initially no cache
332        assert!(!cache_exists());
333        assert!(get_cached_data().is_none());
334
335        // Save some test data
336        let test_data = b"test cache data";
337        save_cache(test_data).ok();
338
339        // Cache should now exist
340        assert!(cache_exists());
341
342        // Load cache
343        let loaded = get_cached_data();
344        assert!(loaded.is_some());
345        assert_eq!(loaded.unwrap(), test_data);
346
347        // Clear cache
348        clear_cache().ok();
349        assert!(!cache_exists());
350    }
351}