Skip to main content

oximedia_cloud/
security.rs

1//! Security features including credentials management and encryption
2
3use hmac::{Hmac, Mac};
4use serde::{Deserialize, Serialize};
5use sha2::Sha256;
6use std::collections::HashMap;
7
8use crate::error::{CloudError, Result};
9
10/// Cloud credentials
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct Credentials {
13    /// Access key ID
14    pub access_key: String,
15    /// Secret access key
16    pub secret_key: String,
17    /// Session token (optional, for temporary credentials)
18    pub session_token: Option<String>,
19    /// Additional provider-specific credentials
20    pub extra: HashMap<String, String>,
21}
22
23impl Credentials {
24    /// Create new credentials
25    #[must_use]
26    pub fn new(access_key: String, secret_key: String) -> Self {
27        Self {
28            access_key,
29            secret_key,
30            session_token: None,
31            extra: HashMap::new(),
32        }
33    }
34
35    /// Create credentials with session token
36    #[must_use]
37    pub fn with_session_token(
38        access_key: String,
39        secret_key: String,
40        session_token: String,
41    ) -> Self {
42        Self {
43            access_key,
44            secret_key,
45            session_token: Some(session_token),
46            extra: HashMap::new(),
47        }
48    }
49
50    /// Validate credentials
51    pub fn validate(&self) -> Result<()> {
52        if self.access_key.is_empty() {
53            return Err(CloudError::InvalidConfig("Access key is empty".to_string()));
54        }
55        if self.secret_key.is_empty() {
56            return Err(CloudError::InvalidConfig("Secret key is empty".to_string()));
57        }
58        Ok(())
59    }
60
61    /// Check if credentials are temporary (have session token)
62    #[must_use]
63    pub fn is_temporary(&self) -> bool {
64        self.session_token.is_some()
65    }
66}
67
68/// Encryption configuration
69#[derive(Debug, Clone)]
70pub struct EncryptionConfig {
71    /// Encryption algorithm
72    pub algorithm: EncryptionAlgorithm,
73    /// KMS configuration (if using KMS)
74    pub kms_config: Option<KmsConfig>,
75    /// Customer-provided key (if using client-side encryption)
76    pub customer_key: Option<Vec<u8>>,
77}
78
79impl Default for EncryptionConfig {
80    fn default() -> Self {
81        Self {
82            algorithm: EncryptionAlgorithm::AES256,
83            kms_config: None,
84            customer_key: None,
85        }
86    }
87}
88
89/// Encryption algorithms
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91pub enum EncryptionAlgorithm {
92    /// AES-256
93    AES256,
94    /// AWS KMS
95    AwsKms,
96    /// Azure Key Vault
97    AzureKeyVault,
98    /// GCP KMS
99    GcpKms,
100}
101
102/// KMS (Key Management Service) configuration
103#[derive(Debug, Clone)]
104pub struct KmsConfig {
105    /// KMS key ID or ARN
106    pub key_id: String,
107    /// KMS endpoint (optional)
108    pub endpoint: Option<String>,
109    /// Additional context
110    pub context: HashMap<String, String>,
111}
112
113impl KmsConfig {
114    /// Create new KMS configuration
115    #[must_use]
116    pub fn new(key_id: String) -> Self {
117        Self {
118            key_id,
119            endpoint: None,
120            context: HashMap::new(),
121        }
122    }
123
124    /// Add encryption context
125    pub fn add_context(&mut self, key: String, value: String) {
126        self.context.insert(key, value);
127    }
128}
129
130/// IAM role configuration for AWS
131#[derive(Debug, Clone)]
132pub struct IamRoleConfig {
133    /// Role ARN
134    pub role_arn: String,
135    /// Session name
136    pub session_name: String,
137    /// External ID (for cross-account access)
138    pub external_id: Option<String>,
139    /// Session duration in seconds
140    pub duration_secs: u32,
141}
142
143impl IamRoleConfig {
144    /// Create new IAM role configuration
145    #[must_use]
146    pub fn new(role_arn: String, session_name: String) -> Self {
147        Self {
148            role_arn,
149            session_name,
150            external_id: None,
151            duration_secs: 3600, // 1 hour default
152        }
153    }
154
155    /// Set external ID
156    #[must_use]
157    pub fn with_external_id(mut self, external_id: String) -> Self {
158        self.external_id = Some(external_id);
159        self
160    }
161
162    /// Set session duration
163    #[must_use]
164    pub fn with_duration(mut self, duration_secs: u32) -> Self {
165        self.duration_secs = duration_secs;
166        self
167    }
168}
169
170/// Service principal configuration for Azure
171#[derive(Debug, Clone)]
172pub struct ServicePrincipalConfig {
173    /// Tenant ID
174    pub tenant_id: String,
175    /// Client ID
176    pub client_id: String,
177    /// Client secret
178    pub client_secret: String,
179}
180
181impl ServicePrincipalConfig {
182    /// Create new service principal configuration
183    #[must_use]
184    pub fn new(tenant_id: String, client_id: String, client_secret: String) -> Self {
185        Self {
186            tenant_id,
187            client_id,
188            client_secret,
189        }
190    }
191}
192
193/// Service account configuration for GCP
194#[derive(Debug, Clone)]
195pub struct ServiceAccountConfig {
196    /// Project ID
197    pub project_id: String,
198    /// Service account email
199    pub email: String,
200    /// Private key (PEM format)
201    pub private_key: String,
202}
203
204impl ServiceAccountConfig {
205    /// Create new service account configuration
206    #[must_use]
207    pub fn new(project_id: String, email: String, private_key: String) -> Self {
208        Self {
209            project_id,
210            email,
211            private_key,
212        }
213    }
214}
215
216/// Credential rotation manager
217pub struct CredentialRotation {
218    /// Current credentials
219    current: Credentials,
220    /// Rotation interval in seconds
221    rotation_interval_secs: u64,
222    /// Last rotation timestamp
223    last_rotation: std::time::Instant,
224}
225
226impl CredentialRotation {
227    /// Create new credential rotation manager
228    #[must_use]
229    pub fn new(credentials: Credentials, rotation_interval_secs: u64) -> Self {
230        Self {
231            current: credentials,
232            rotation_interval_secs,
233            last_rotation: std::time::Instant::now(),
234        }
235    }
236
237    /// Check if rotation is needed
238    #[must_use]
239    pub fn needs_rotation(&self) -> bool {
240        self.last_rotation.elapsed().as_secs() >= self.rotation_interval_secs
241    }
242
243    /// Get current credentials
244    #[must_use]
245    pub fn current(&self) -> &Credentials {
246        &self.current
247    }
248
249    /// Update credentials
250    pub fn rotate(&mut self, new_credentials: Credentials) {
251        self.current = new_credentials;
252        self.last_rotation = std::time::Instant::now();
253    }
254}
255
256/// Access Control List (ACL) options
257#[derive(Debug, Clone, Copy, PartialEq, Eq)]
258pub enum Acl {
259    /// Private (owner only)
260    Private,
261    /// Public read
262    PublicRead,
263    /// Public read-write
264    PublicReadWrite,
265    /// Authenticated read
266    AuthenticatedRead,
267    /// Bucket owner read
268    BucketOwnerRead,
269    /// Bucket owner full control
270    BucketOwnerFullControl,
271}
272
273impl Acl {
274    /// Convert to AWS S3 ACL string
275    #[must_use]
276    pub fn to_s3_string(&self) -> &str {
277        match self {
278            Acl::Private => "private",
279            Acl::PublicRead => "public-read",
280            Acl::PublicReadWrite => "public-read-write",
281            Acl::AuthenticatedRead => "authenticated-read",
282            Acl::BucketOwnerRead => "bucket-owner-read",
283            Acl::BucketOwnerFullControl => "bucket-owner-full-control",
284        }
285    }
286}
287
288// ── Signed URL generation ────────────────────────────────────────────────────
289
290type HmacSha256 = Hmac<Sha256>;
291
292/// Generates HMAC-SHA256 signed URLs for pre-authorised object access.
293///
294/// The signed URL encodes the bucket, key, expiry, a timestamp, and an
295/// HMAC-SHA256 signature computed over those components using the provided
296/// secret.  The format mimics the AWS S3 pre-signed URL query-parameter style.
297pub struct SignedUrl;
298
299impl SignedUrl {
300    /// Generate a signed URL for the given `bucket`/`key` pair.
301    ///
302    /// Parameters:
303    /// - `bucket`      – cloud storage bucket or container name.
304    /// - `key`         – object key / path within the bucket.
305    /// - `expiry_secs` – how many seconds the URL remains valid.
306    /// - `secret`      – the HMAC secret used to sign the URL.
307    ///
308    /// The returned URL contains:
309    /// - `X-Amz-Expires`         – expiry window in seconds.
310    /// - `X-Amz-Date`            – approximate creation date (Unix epoch).
311    /// - `X-Amz-SignedHeaders`   – fixed value `host`.
312    /// - `X-Amz-Signature`       – hex-encoded HMAC-SHA256 signature.
313    ///
314    /// The string that is signed is:
315    /// `"{bucket}\n{key}\n{expiry_secs}\n{epoch}"`
316    #[must_use]
317    pub fn generate(bucket: &str, key: &str, expiry_secs: u64, secret: &[u8]) -> String {
318        let epoch = std::time::SystemTime::now()
319            .duration_since(std::time::UNIX_EPOCH)
320            .map(|d| d.as_secs())
321            .unwrap_or(0);
322
323        let string_to_sign = format!("{bucket}\n{key}\n{expiry_secs}\n{epoch}");
324
325        let mut mac = HmacSha256::new_from_slice(secret).expect("HMAC accepts any key length");
326        mac.update(string_to_sign.as_bytes());
327        let signature = hex::encode(mac.finalize().into_bytes());
328
329        // URL-encode the key (replace '/' with '%2F' for path segments)
330        let encoded_key = urlencoding::encode(key);
331        let encoded_bucket = urlencoding::encode(bucket);
332
333        format!(
334            "https://s3.amazonaws.com/{encoded_bucket}/{encoded_key}\
335            ?X-Amz-Expires={expiry_secs}\
336            &X-Amz-Date={epoch}\
337            &X-Amz-SignedHeaders=host\
338            &X-Amz-Signature={signature}"
339        )
340    }
341
342    /// Verify whether a signed URL is still valid and has the correct signature.
343    ///
344    /// Returns `true` if the signature matches and the URL has not expired.
345    #[must_use]
346    pub fn verify(url: &str, secret: &[u8]) -> bool {
347        // Parse query parameters from the URL
348        let (base, query) = match url.split_once('?') {
349            Some(pair) => pair,
350            None => return false,
351        };
352
353        // Extract path components: /bucket/key
354        let path = base.trim_start_matches("https://s3.amazonaws.com/");
355        let (encoded_bucket, encoded_key) = match path.split_once('/') {
356            Some(pair) => pair,
357            None => return false,
358        };
359        let bucket = urlencoding::decode(encoded_bucket).unwrap_or_default();
360        let key = urlencoding::decode(encoded_key).unwrap_or_default();
361
362        // Parse query params
363        let mut expiry_secs: Option<u64> = None;
364        let mut date_epoch: Option<u64> = None;
365        let mut provided_sig: Option<String> = None;
366
367        for param in query.split('&') {
368            if let Some(val) = param.strip_prefix("X-Amz-Expires=") {
369                expiry_secs = val.parse().ok();
370            } else if let Some(val) = param.strip_prefix("X-Amz-Date=") {
371                date_epoch = val.parse().ok();
372            } else if let Some(val) = param.strip_prefix("X-Amz-Signature=") {
373                provided_sig = Some(val.to_string());
374            }
375        }
376
377        let (Some(expiry), Some(epoch), Some(sig)) = (expiry_secs, date_epoch, provided_sig) else {
378            return false;
379        };
380
381        // Check expiry
382        let now = std::time::SystemTime::now()
383            .duration_since(std::time::UNIX_EPOCH)
384            .map(|d| d.as_secs())
385            .unwrap_or(0);
386        if now > epoch.saturating_add(expiry) {
387            return false;
388        }
389
390        // Re-compute expected signature
391        let string_to_sign = format!("{bucket}\n{key}\n{expiry}\n{epoch}");
392        let mut mac = HmacSha256::new_from_slice(secret).expect("HMAC accepts any key length");
393        mac.update(string_to_sign.as_bytes());
394        let expected_sig = hex::encode(mac.finalize().into_bytes());
395
396        expected_sig == sig
397    }
398}
399
400#[cfg(test)]
401mod tests {
402    use super::*;
403
404    #[test]
405    fn test_credentials_validation() {
406        let valid = Credentials::new("access".to_string(), "secret".to_string());
407        assert!(valid.validate().is_ok());
408
409        let invalid = Credentials::new("".to_string(), "secret".to_string());
410        assert!(invalid.validate().is_err());
411    }
412
413    #[test]
414    fn test_credentials_temporary() {
415        let permanent = Credentials::new("access".to_string(), "secret".to_string());
416        assert!(!permanent.is_temporary());
417
418        let temporary = Credentials::with_session_token(
419            "access".to_string(),
420            "secret".to_string(),
421            "token".to_string(),
422        );
423        assert!(temporary.is_temporary());
424    }
425
426    #[test]
427    fn test_kms_config() {
428        let mut kms = KmsConfig::new("key-id".to_string());
429        kms.add_context("env".to_string(), "prod".to_string());
430        assert_eq!(kms.context.len(), 1);
431    }
432
433    #[test]
434    fn test_iam_role_config() {
435        let role = IamRoleConfig::new(
436            "arn:aws:iam::123456789012:role/test".to_string(),
437            "session".to_string(),
438        )
439        .with_external_id("external".to_string())
440        .with_duration(7200);
441
442        assert_eq!(role.external_id, Some("external".to_string()));
443        assert_eq!(role.duration_secs, 7200);
444    }
445
446    #[test]
447    fn test_credential_rotation() {
448        let creds = Credentials::new("access".to_string(), "secret".to_string());
449        let mut rotation = CredentialRotation::new(creds.clone(), 60);
450
451        assert!(!rotation.needs_rotation());
452
453        let new_creds = Credentials::new("new_access".to_string(), "new_secret".to_string());
454        rotation.rotate(new_creds);
455        assert_eq!(rotation.current().access_key, "new_access");
456    }
457
458    #[test]
459    fn test_acl_to_string() {
460        assert_eq!(Acl::Private.to_s3_string(), "private");
461        assert_eq!(Acl::PublicRead.to_s3_string(), "public-read");
462    }
463
464    // ── SignedUrl tests ───────────────────────────────────────────────────────
465
466    #[test]
467    fn test_signed_url_contains_required_params() {
468        let url = SignedUrl::generate("my-bucket", "videos/file.mp4", 3600, b"supersecret");
469        assert!(
470            url.contains("X-Amz-Expires=3600"),
471            "URL must include expiry"
472        );
473        assert!(url.contains("X-Amz-Date="), "URL must include date");
474        assert!(
475            url.contains("X-Amz-Signature="),
476            "URL must include signature"
477        );
478        assert!(
479            url.contains("X-Amz-SignedHeaders=host"),
480            "URL must include signed headers"
481        );
482    }
483
484    #[test]
485    fn test_signed_url_contains_bucket_and_key() {
486        let url = SignedUrl::generate("my-bucket", "path/to/object.mp4", 300, b"key");
487        assert!(url.contains("my-bucket"), "URL must include bucket");
488        // Key is URL-encoded
489        assert!(url.contains("path"), "URL must include key path");
490    }
491
492    #[test]
493    fn test_signed_url_different_secrets_produce_different_signatures() {
494        let url1 = SignedUrl::generate("bucket", "key", 300, b"secret1");
495        let url2 = SignedUrl::generate("bucket", "key", 300, b"secret2");
496        // Extract signatures
497        let sig1 = url1.split("X-Amz-Signature=").nth(1).unwrap_or("");
498        let sig2 = url2.split("X-Amz-Signature=").nth(1).unwrap_or("");
499        assert_ne!(
500            sig1, sig2,
501            "Different secrets must produce different signatures"
502        );
503    }
504
505    #[test]
506    fn test_signed_url_verify_valid() {
507        let secret = b"test-signing-secret";
508        let url = SignedUrl::generate("bucket", "key/file.mp4", 3600, secret);
509        assert!(
510            SignedUrl::verify(&url, secret),
511            "Freshly generated URL must verify"
512        );
513    }
514
515    #[test]
516    fn test_signed_url_verify_wrong_secret_fails() {
517        let url = SignedUrl::generate("bucket", "key.mp4", 3600, b"correct-secret");
518        assert!(
519            !SignedUrl::verify(&url, b"wrong-secret"),
520            "Wrong secret must not verify"
521        );
522    }
523
524    #[test]
525    fn test_signed_url_verify_malformed_url_fails() {
526        assert!(!SignedUrl::verify("not-a-valid-url", b"secret"));
527        assert!(!SignedUrl::verify(
528            "https://s3.amazonaws.com/bucket/key",
529            b"secret"
530        ));
531    }
532}