etopay_sdk/types/
newtypes.rs

1//! This module contains newtype wrappers for sensitive values. There are several reasons for this:
2//! - We can make sure the information (such as passwords and pins) is not logged or sent to the
3//!   backend by mistake.
4//! - We control the creation and deletion of the objects such that we can ensure passwords and
5//!   pins are non-empty, and also clear / [`zeroize`] the values once they are Dropped.
6//! - We have control over the encrypt - decrypt lifetime of the password, making sure each value
7//!   can only be used where it is explicitly intended to be used, and the encryption is only
8//!   implemented and tested in one place.
9//!
10//! In general the implementation follows this pattern:
11//! - A `struct` that (privately) wraps the underlying value is defined.
12//! - We derive [`zeroize::Zeroize`] and [`zeroize::ZeroizeOnDrop`] to make sure the value is
13//!   overwritten with their zero value once `Drop`ed.
14//! - We implement [`core::fmt::Debug`] that does not print the value itself but rather a
15//! placeholder value. This ensures the value cannot be printed or logged.
16//! - We define (fallible) constructor functions that make sure the inner value is valid or return
17//!   an Error. Additionally `unsafe` constructors can be used for circumventing the validations
18//!   (eg. for use in tests).
19//! - We define functions to get the inner value in safe ways (eg. as a [`secrecy::Secret`], or functions
20//!   to convert (eg. for the encrypt / decrypt methods) to other newtypes.
21
22use super::{
23    crypto::Blake2b256,
24    error::{Result, TypeError},
25};
26use aes_gcm::{
27    Aes256Gcm, Nonce,
28    aead::{Aead, KeyInit},
29};
30use blake2::Digest;
31use log::warn;
32use rand::RngCore;
33use serde::{Deserialize, Serialize};
34use zxcvbn::{Score, zxcvbn};
35
36macro_rules! impl_redacted_debug {
37    ($type:ty) => {
38        impl core::fmt::Debug for $type {
39            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40                write!(f, "{}(<REDACTED>)", stringify!($type))
41            }
42        }
43    };
44}
45
46/// A password that is not encrypted and stored as plain text.
47#[derive(zeroize::Zeroize, zeroize::ZeroizeOnDrop, Clone)]
48pub struct PlainPassword(String);
49impl_redacted_debug!(PlainPassword);
50
51impl PlainPassword {
52    /// Try to construct a new [`PlainPassword`] from a [`String`]-like value.
53    pub fn try_from_string(password: impl Into<String>) -> Result<Self> {
54        let password: String = password.into();
55        if password.is_empty() {
56            return Err(TypeError::EmptyPassword);
57        }
58
59        // Validates the strength of the password.
60        // Any score less than 3 (can be cracked with 10^10 guesses or less) should be considered too weak. [Score](https://docs.rs/zxcvbn/latest/zxcvbn/enum.Score.html)
61        let password_strength = zxcvbn(password.as_str(), &[]).score();
62
63        if password_strength < Score::Three {
64            warn!("User attempted to set a weak password");
65            return Err(TypeError::WeakPassword);
66        }
67
68        Ok(Self(password))
69    }
70
71    /// Encrypt this password with the provided pin and salt.
72    pub fn encrypt(&self, pin: &EncryptionPin, salt: &EncryptionSalt) -> Result<EncryptedPassword> {
73        let key = Blake2b256::new()
74            .chain_update(pin.0.as_ref())
75            .chain_update(salt.0.as_ref())
76            .finalize();
77
78        let Ok(cipher) = Aes256Gcm::new_from_slice(&key) else {
79            return Err(TypeError::PasswordEncryption);
80        };
81
82        let nonce = Nonce::from_slice(salt.0.as_ref()); // 96-bits; unique per message
83        let Ok(cipher) = cipher.encrypt(nonce, self.0.as_ref()) else {
84            return Err(TypeError::PasswordEncryption);
85        };
86
87        Ok(EncryptedPassword(cipher.into()))
88    }
89
90    /// Helper function to convert into [`secrecy::Secret`] using cloning.
91    pub fn into_secret(&self) -> secrecy::SecretBox<[u8]> {
92        secrecy::SecretBox::new(self.0.as_bytes().into())
93    }
94
95    /// Helper function to convert into [`secrecy::Secret`] using cloning.
96    pub fn into_secret_string(&self) -> secrecy::SecretString {
97        self.0.clone().into()
98    }
99
100    /// Helper function to get the underlying string, use with caution!
101    pub fn as_str(&self) -> &str {
102        &self.0
103    }
104}
105
106impl TryFrom<String> for PlainPassword {
107    type Error = TypeError;
108    fn try_from(value: String) -> Result<Self> {
109        Self::try_from_string(value)
110    }
111}
112impl TryFrom<&str> for PlainPassword {
113    type Error = TypeError;
114    fn try_from(value: &str) -> Result<Self> {
115        Self::try_from_string(value)
116    }
117}
118
119/// An encrypted password.
120#[derive(zeroize::Zeroize, zeroize::ZeroizeOnDrop, Deserialize, Serialize, Clone)]
121#[cfg_attr(test, derive(PartialEq))]
122pub struct EncryptedPassword(Box<[u8]>);
123impl_redacted_debug!(EncryptedPassword);
124
125impl EncryptedPassword {
126    /// Decrypt this password with the provided pin and salt.
127    /// Returns an error if the pin or salt is incorrect.
128    pub fn decrypt(&self, pin: &EncryptionPin, salt: &EncryptionSalt) -> Result<PlainPassword> {
129        let key = Blake2b256::new()
130            .chain_update(pin.0.as_ref())
131            .chain_update(salt.0.as_ref())
132            .finalize();
133
134        let Ok(cipher) = Aes256Gcm::new_from_slice(&key) else {
135            return Err(TypeError::PasswordEncryption);
136        };
137
138        let nonce = Nonce::from_slice(salt.0.as_ref()); // 96-bits; unique per message
139        let Ok(plaintext) = cipher.decrypt(nonce, self.0.as_ref()) else {
140            return Err(TypeError::InvalidPinOrPassword);
141        };
142
143        Ok(PlainPassword(String::from_utf8_lossy(&plaintext).to_string()))
144    }
145
146    /// Create a new `EncryptedPassword` from raw bytes.
147    ///
148    /// # Safety
149    ///
150    /// This is `unsafe` since the bytes might not have come from an encryption step at all.
151    ///
152    pub unsafe fn new_unchecked(bytes: impl Into<Vec<u8>>) -> Self {
153        Self(bytes.into().into())
154    }
155}
156
157/// A non-empty pin used to encrypt the password.
158#[derive(zeroize::Zeroize, zeroize::ZeroizeOnDrop)]
159pub struct EncryptionPin(Box<[u8]>);
160impl_redacted_debug!(EncryptionPin);
161
162impl EncryptionPin {
163    /// Try to construct a new [`EncryptionPin`] from a [`String`]-like value.
164    pub fn try_from_string(pin: impl Into<String>) -> Result<Self> {
165        let pin: String = pin.into();
166        if pin.is_empty() {
167            return Err(TypeError::EmptyPin);
168        }
169
170        if pin.len() < 6 {
171            warn!("Pin is less than 6 digits");
172            return Err(TypeError::WeakPin);
173        }
174
175        if !pin.chars().all(|c| c.is_ascii_digit()) {
176            warn!("Pin contains non-numeric characters");
177            return Err(TypeError::NonNumericPin);
178        }
179
180        Ok(Self(pin.as_bytes().into()))
181    }
182}
183impl TryFrom<String> for EncryptionPin {
184    type Error = TypeError;
185    fn try_from(value: String) -> Result<Self> {
186        Self::try_from_string(value)
187    }
188}
189impl TryFrom<&str> for EncryptionPin {
190    type Error = TypeError;
191    fn try_from(value: &str) -> Result<Self> {
192        Self::try_from_string(value)
193    }
194}
195
196/// A salt used in the encryption process.
197/// Should be unique for each encryption but is not a secret.
198#[derive(zeroize::Zeroize, zeroize::ZeroizeOnDrop, Deserialize, Serialize, Clone)]
199#[cfg_attr(test, derive(PartialEq))]
200pub struct EncryptionSalt(Box<[u8; 12]>);
201impl_redacted_debug!(EncryptionSalt);
202
203impl EncryptionSalt {
204    /// Generate a new random [`EncryptionSalt`].
205    pub fn generate() -> Self {
206        let mut salt = [0u8; 12];
207        rand::rng().fill_bytes(&mut salt);
208        Self(Box::new(salt))
209    }
210}
211
212impl From<[u8; 12]> for EncryptionSalt {
213    fn from(value: [u8; 12]) -> Self {
214        Self(value.into())
215    }
216}
217
218/// Simple wrapper around a non-empty access token that cannot be printed or logged, and is
219/// automatically zeroized when dropped.
220#[derive(zeroize::Zeroize, zeroize::ZeroizeOnDrop, Deserialize, Serialize, Clone)]
221pub struct AccessToken(String);
222impl_redacted_debug!(AccessToken);
223
224impl AccessToken {
225    /// Construct a new [`AccessToken`] from a [`String`], returns an error if the string is empty.
226    pub fn try_from_string(token: impl Into<String>) -> Result<Self> {
227        let token: String = token.into();
228        if token.is_empty() {
229            return Err(TypeError::EmptyAccessToken);
230        }
231
232        Ok(Self(token))
233    }
234
235    /// Helper function to get access to the inner String. Use with caution!
236    pub fn as_str(&self) -> &str {
237        &self.0
238    }
239}
240
241impl TryFrom<String> for AccessToken {
242    type Error = TypeError;
243    fn try_from(value: String) -> Result<Self> {
244        Self::try_from_string(value)
245    }
246}
247impl TryFrom<&str> for AccessToken {
248    type Error = TypeError;
249    fn try_from(value: &str) -> Result<Self> {
250        Self::try_from_string(value)
251    }
252}
253
254#[cfg(test)]
255mod test {
256    use super::*;
257    use crate::types::newtypes::PlainPassword;
258
259    #[test]
260    fn test_debug_is_redacted() {
261        let debug = format!(
262            "{:?}",
263            PlainPassword::try_from_string("correcthorsebatterystaple").unwrap()
264        );
265        assert!(!debug.contains("correcthorsebatterystaple"));
266    }
267
268    #[test]
269    fn test_encrypt_password_success() {
270        let password = PlainPassword::try_from_string("strong_password").unwrap();
271        let pin = EncryptionPin::try_from_string("123456").unwrap();
272        let salt = EncryptionSalt::generate();
273
274        let encrypted_password = password.encrypt(&pin, &salt).unwrap();
275        assert!(!encrypted_password.0.is_empty());
276    }
277
278    #[test]
279    fn test_decrypt_password_success() {
280        let password = PlainPassword::try_from_string("strong_password").unwrap();
281        let pin = EncryptionPin::try_from_string("123456").unwrap();
282        let salt = EncryptionSalt::generate();
283
284        let encrypted_password = password.encrypt(&pin, &salt).unwrap();
285        let decrypted_password = encrypted_password.decrypt(&pin, &salt).unwrap();
286
287        assert_eq!(decrypted_password.0, password.0);
288    }
289
290    #[test]
291    fn test_encrypt_password_with_special_characters() {
292        let password = PlainPassword::try_from_string("strong_password!@#$%^&*()").unwrap();
293        let pin = EncryptionPin::try_from_string("123456").unwrap();
294        let salt = EncryptionSalt::generate();
295
296        let encrypted_password = password.encrypt(&pin, &salt).unwrap();
297        let decrypted_password = encrypted_password.decrypt(&pin, &salt).unwrap();
298
299        assert_eq!(decrypted_password.0, password.0);
300    }
301
302    #[test]
303    fn test_decrypt_password_failure_wrong_pin() {
304        let password = PlainPassword::try_from_string("strong_password").unwrap();
305        let pin = EncryptionPin::try_from_string("123456").unwrap();
306        let wrong_pin = EncryptionPin::try_from_string("654321").unwrap();
307        let salt = EncryptionSalt::generate();
308
309        let encrypted_password = password.encrypt(&pin, &salt).unwrap();
310        let decrypted_password = encrypted_password.decrypt(&wrong_pin, &salt);
311
312        decrypted_password.unwrap_err();
313    }
314
315    #[test]
316    fn test_decrypt_password_failure_invalid_data() {
317        let pin = EncryptionPin::try_from_string("123456").unwrap();
318        let salt = EncryptionSalt::generate();
319
320        // SAFETY: this is only for testing purposes to make sure an invalid encrypted password gives an error
321        let invalid_encrypted_password = unsafe { EncryptedPassword::new_unchecked(b"invalid_encrypted_password") };
322
323        let decrypted_password = invalid_encrypted_password.decrypt(&pin, &salt);
324
325        decrypted_password.unwrap_err();
326    }
327
328    #[test]
329    fn test_generate_salt() {
330        let salt = EncryptionSalt::generate();
331        assert_eq!(salt.0.len(), 12);
332    }
333
334    #[test]
335    fn test_empty_password_fails() {
336        let result = PlainPassword::try_from_string("");
337        assert!(matches!(result, Err(TypeError::EmptyPassword)));
338    }
339
340    #[test]
341    fn test_weak_password_fails() {
342        let result = PlainPassword::try_from_string("weak pass");
343        assert!(matches!(result, Err(TypeError::WeakPassword)));
344    }
345
346    #[test]
347    fn test_strong_password_succeeds() {
348        let result = PlainPassword::try_from_string("Str0ngP@ssw0rd123!");
349        result.unwrap();
350    }
351
352    #[test]
353    fn test_empty_pin_fails() {
354        let result = EncryptionPin::try_from_string("");
355        assert!(matches!(result, Err(TypeError::EmptyPin)));
356    }
357
358    #[test]
359    fn test_short_pin_fails() {
360        let result = EncryptionPin::try_from_string("123");
361        assert!(matches!(result, Err(TypeError::WeakPin)));
362    }
363
364    #[test]
365    fn test_non_numeric_pin_fails() {
366        let result = EncryptionPin::try_from_string("abc123");
367        assert!(matches!(result, Err(TypeError::NonNumericPin)));
368    }
369
370    #[test]
371    fn test_valid_pin_succeeds() {
372        let result = EncryptionPin::try_from_string("123456");
373        result.unwrap();
374    }
375}