integrationos_domain/algebra/
crypto.rs

1use crate::{secrets::SecretsConfig, IntegrationOSError, InternalError, SecretVersion};
2use async_trait::async_trait;
3use base64::{prelude::BASE64_STANDARD, Engine};
4use chacha20poly1305::aead::generic_array::typenum::Unsigned;
5use chacha20poly1305::aead::generic_array::GenericArray;
6use chacha20poly1305::aead::{Aead, AeadCore, KeyInit, OsRng};
7use chacha20poly1305::ChaCha20Poly1305;
8use google_cloud_kms::{
9    client::{Client, ClientConfig},
10    grpc::kms::v1::DecryptRequest,
11};
12use secrecy::ExposeSecret;
13use tracing::debug;
14
15#[async_trait]
16pub trait CryptoExt {
17    async fn encrypt(&self, encrypted_secret: String) -> Result<String, IntegrationOSError>;
18
19    async fn decrypt(
20        &self,
21        data: String,
22        version: Option<SecretVersion>,
23    ) -> Result<String, IntegrationOSError>;
24}
25
26type NonceSize = <ChaCha20Poly1305 as AeadCore>::NonceSize;
27
28#[derive(Debug, Clone)]
29pub struct IOSCrypto {
30    key: Vec<u8>,
31}
32
33#[async_trait]
34impl CryptoExt for IOSCrypto {
35    async fn encrypt(&self, encrypted_secret: String) -> Result<String, IntegrationOSError> {
36        self.encrypt(encrypted_secret).await
37    }
38
39    async fn decrypt(
40        &self,
41        data: String,
42        _: Option<SecretVersion>,
43    ) -> Result<String, IntegrationOSError> {
44        self.decrypt(data).await
45    }
46}
47
48impl IOSCrypto {
49    pub fn new(config: SecretsConfig) -> Result<Self, IntegrationOSError> {
50        let len = config.ios_crypto_secret.expose_secret().as_bytes().len();
51
52        if len != 32 {
53            return Err(InternalError::invalid_argument(
54                "The provided value is not a valid UTF-8 string",
55                None,
56            ));
57        }
58
59        let key: [u8; 32] = config
60            .ios_crypto_secret
61            .expose_secret()
62            .as_bytes()
63            .iter()
64            .take(32)
65            .map(|b| b.to_owned())
66            .collect::<Vec<_>>()
67            .try_into()
68            .map_err(|_| {
69                InternalError::invalid_argument(
70                    "The provided value is not a valid UTF-8 string",
71                    None,
72                )
73            })?;
74
75        Ok(Self { key: key.to_vec() })
76    }
77
78    async fn decrypt(&self, encrypted_secret: String) -> Result<String, IntegrationOSError> {
79        let obsf = hex::decode(encrypted_secret).map_err(|_| {}).map_err(|_| {
80            InternalError::deserialize_error("The provided value is not a valid UTF-8 string", None)
81        })?;
82        let cipher = ChaCha20Poly1305::new(GenericArray::from_slice(&self.key));
83        let (nonce, ciphertext) = obsf.split_at(NonceSize::to_usize());
84        let nonce = GenericArray::from_slice(nonce);
85        let plaintext = cipher.decrypt(nonce, ciphertext).map_err(|_| {
86            InternalError::deserialize_error("The provided value is not a valid UTF-8 string", None)
87        })?;
88        let plaintext = String::from_utf8(plaintext).map_err(|_| {
89            InternalError::deserialize_error("The provided value is not a valid UTF-8 string", None)
90        })?;
91
92        Ok(plaintext)
93    }
94
95    async fn encrypt(&self, secret: String) -> Result<String, IntegrationOSError> {
96        let cipher = ChaCha20Poly1305::new(GenericArray::from_slice(&self.key));
97        let nonce = ChaCha20Poly1305::generate_nonce(&mut OsRng);
98        let mut obsf = cipher.encrypt(&nonce, secret.as_bytes()).map_err(|_| {
99            InternalError::serialize_error("The provided value is not a valid UTF-8 string", None)
100        })?;
101        obsf.splice(..0, nonce.iter().copied());
102
103        Ok(hex::encode(obsf))
104    }
105}
106
107#[derive(Debug, Clone)]
108pub struct GoogleCryptoKms {
109    client: Client,
110    config: SecretsConfig,
111    fallback: IOSCrypto,
112}
113
114#[async_trait]
115impl CryptoExt for GoogleCryptoKms {
116    async fn encrypt(&self, encrypted_secret: String) -> Result<String, IntegrationOSError> {
117        self.encrypt(encrypted_secret).await
118    }
119
120    async fn decrypt(
121        &self,
122        data: String,
123        version: Option<SecretVersion>,
124    ) -> Result<String, IntegrationOSError> {
125        self.decrypt(data, version).await
126    }
127}
128
129impl GoogleCryptoKms {
130    pub async fn new(secrets_config: &SecretsConfig) -> Result<Self, IntegrationOSError> {
131        let fallback = IOSCrypto::new(secrets_config.clone())?;
132        let config = ClientConfig::default().with_auth().await.map_err(|e| {
133            InternalError::connection_error(
134                &format!("Failed to create GoogleCryptoKms client: {e}"),
135                Some("Failed to create client"),
136            )
137        })?;
138        let client = Client::new(config).await.map_err(|e| {
139            InternalError::connection_error(
140                &format!("Failed to create GoogleCryptoKms client: {e}"),
141                Some("Failed to create client"),
142            )
143        })?;
144
145        Ok(Self {
146            client,
147            config: secrets_config.clone(),
148            fallback,
149        })
150    }
151
152    async fn decrypt(
153        &self,
154        encrypted_secret: String,
155        version: Option<SecretVersion>,
156    ) -> Result<String, IntegrationOSError> {
157        match version {
158            Some(SecretVersion::V2) => self.fallback.decrypt(encrypted_secret).await,
159            Some(SecretVersion::V1) | None => {
160                let request = DecryptRequest {
161                    name: format!(
162                        "projects/{project_id}/locations/{location_id}/keyRings/{key_ring_id}/cryptoKeys/{key_id}",
163                        project_id = self.config.google_kms_project_id,
164                        location_id = self.config.google_kms_location_id,
165                        key_ring_id = self.config.google_kms_key_ring_id,
166                        key_id = self.config.google_kms_key_id,
167                    ),
168                    ciphertext: BASE64_STANDARD.decode(encrypted_secret.as_bytes())
169                        .map_err(|e| {
170                            debug!("Error decoding secret: {e}");
171                            InternalError::deserialize_error("The provided value is not a valid UTF-8 string", None)
172                        })?,
173                    ..Default::default()
174                };
175
176                let decriptes_bytes = self.client.decrypt(request, None).await.map_err(|e| {
177                    debug!("Error decrypting secret: {e}");
178                    InternalError::connection_error(
179                        "The provided value is not a valid UTF-8 string",
180                        None,
181                    )
182                })?;
183
184                let plaintext = String::from_utf8(decriptes_bytes.plaintext).map_err(|e| {
185                    debug!("Error converting decrypted secret to string: {e}");
186                    InternalError::deserialize_error(
187                        "The provided value is not a valid UTF-8 string",
188                        None,
189                    )
190                })?;
191
192                Ok(plaintext)
193            }
194        }
195    }
196
197    async fn encrypt(&self, secret: String) -> Result<String, IntegrationOSError> {
198        // This is semantically incorrect. But support for Google encryption will be removed in the future, hence the lack of support for V1 encryption.
199        self.fallback.encrypt(secret).await
200    }
201}
202
203#[cfg(test)]
204mod tests {
205
206    use crate::secrets::SecretServiceProvider;
207
208    use super::*;
209
210    #[tokio::test]
211    async fn should_encrypt_and_decrypt_data() {
212        let config = SecretsConfig::default().with_provider(SecretServiceProvider::IosKms);
213        let crypto = IOSCrypto::new(config).expect("Failed to create IOSCrypto client");
214
215        let data = "lorem_ipsum-dolor_sit-amet";
216        let encrypted = crypto
217            .encrypt(data.to_owned())
218            .await
219            .expect("Failed to encrypt data");
220        let decrypted = crypto
221            .decrypt(encrypted.to_owned())
222            .await
223            .expect("Failed to decrypt data");
224
225        assert_eq!(data, decrypted);
226    }
227
228    #[tokio::test]
229    async fn should_fail_to_decrypt_if_the_key_is_different() {
230        let config = SecretsConfig::default().with_provider(SecretServiceProvider::IosKms);
231        let crypto = IOSCrypto::new(config).expect("Failed to create IOSCrypto client");
232
233        let data = "lorem_ipsum-dolor_sit-amet";
234        let encrypted = crypto
235            .encrypt(data.to_owned())
236            .await
237            .expect("Failed to encrypt data");
238
239        let config = SecretsConfig::new()
240            .with_secret("lorem_ipsum-dolor_sit_amet-neque".into())
241            .with_provider(SecretServiceProvider::IosKms);
242        let crypto = IOSCrypto::new(config).expect("Failed to create IOSCrypto client");
243
244        let decrypted = crypto.decrypt(encrypted).await;
245
246        assert!(decrypted.is_err());
247    }
248
249    #[tokio::test]
250    async fn should_fail_to_decrypt_if_the_data_is_tampered() {
251        let config = SecretsConfig::default().with_provider(SecretServiceProvider::IosKms);
252        let crypto = IOSCrypto::new(config).expect("Failed to create IOSCrypto client");
253
254        let data = "lorem_ipsum-dolor_sit-amet";
255        let encrypted = crypto
256            .encrypt(data.to_owned())
257            .await
258            .expect("Failed to encrypt data");
259
260        let mut obsf = hex::decode(encrypted).expect("Failed to decode encrypted data");
261        obsf[0] = 0;
262        let tampered = hex::encode(obsf);
263
264        let decrypted = crypto.decrypt(tampered).await;
265
266        assert!(decrypted.is_err());
267    }
268}