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