Skip to main content

signstar_crypto/
passphrase.rs

1//! Passphrase handling.
2
3use std::{fmt::Display, fs::read_to_string, path::Path, str::FromStr};
4
5use rand::{Rng, distributions::Alphanumeric, thread_rng};
6use secrecy::{ExposeSecret, SecretString};
7use serde::{Deserialize, Serialize};
8
9/// An error that may occur when operating on users.
10#[derive(Debug, thiserror::Error)]
11pub enum Error {
12    /// Unable to convert string slice to Passphrase
13    #[error("Unable to convert string to passphrase")]
14    Passphrase,
15
16    /// A passphrase is shorter than its required length.
17    #[error(
18        "The passphrase should be at least {required_length} characters long, but is only {length} characters long."
19    )]
20    Length {
21        /// The length of the passphrase.
22        length: usize,
23
24        /// The required length of the passphrase.
25        required_length: usize,
26    },
27}
28
29/// A policy for [`Passphrase`].
30///
31/// Policies encode e.g. the minimum required length for a passphrase.
32#[derive(Clone, Debug)]
33pub struct PassphrasePolicy {
34    /// The minimum length a passphrase needs to have.
35    pub minimum_length: usize,
36}
37
38impl Default for PassphrasePolicy {
39    fn default() -> Self {
40        Self {
41            minimum_length: Passphrase::DEFAULT_LENGTH,
42        }
43    }
44}
45
46/// A secret passphrase
47///
48/// The passphrase is held by a [`SecretString`], which guarantees zeroing of memory on
49/// destruct.
50#[derive(Clone, Debug, Default, Deserialize)]
51pub struct Passphrase(SecretString);
52
53impl Passphrase {
54    /// The default passphrase length.
55    pub const DEFAULT_LENGTH: usize = 30;
56
57    /// Creates a new [`Passphrase`] from owned [`String`]
58    ///
59    /// # Examples
60    /// ```
61    /// use signstar_crypto::passphrase::Passphrase;
62    ///
63    /// let passphrase = Passphrase::new("passphrase".to_string());
64    /// ```
65    pub fn new(passphrase: String) -> Self {
66        Self(SecretString::new(passphrase.into()))
67    }
68
69    /// Creates a new [`Passphrase`] from owned [`String`], adhering to a [`PassphrasePolicy`].
70    ///
71    /// # Errors
72    ///
73    /// Returns an error if `passphrase` does not adhere to `policy`.
74    ///
75    /// # Examples
76    ///
77    /// ```
78    /// use signstar_crypto::passphrase::{Passphrase, PassphrasePolicy};
79    ///
80    /// # fn main() -> testresult::TestResult {
81    /// let passphrase = Passphrase::new_with_policy(
82    ///     "passphrase".to_string(),
83    ///     &PassphrasePolicy { minimum_length: 10 },
84    /// )?;
85    ///
86    /// // The passphrase "passphrase" is too short for the default policy.
87    /// assert!(
88    ///     Passphrase::new_with_policy("passphrase".to_string(), &PassphrasePolicy::default(),)
89    ///         .is_err()
90    /// );
91    /// # Ok(())
92    /// # }
93    /// ```
94    pub fn new_with_policy(
95        passphrase: String,
96        policy: &PassphrasePolicy,
97    ) -> Result<Self, crate::Error> {
98        let passphrase = Self::new(passphrase);
99        passphrase.check_against_policy(policy)?;
100
101        Ok(passphrase)
102    }
103
104    /// Generates a new [`Passphrase`].
105    ///
106    /// The generated passphrase will consist of alphanumeric characters.
107    /// The length of the passphrase can be adjusted using `length`, but is guaranteed to be at
108    /// least [`Self::DEFAULT_LENGTH`] characters long.
109    ///
110    /// # Examples
111    ///
112    /// ```
113    /// use signstar_crypto::passphrase::Passphrase;
114    ///
115    /// let passphrase = Passphrase::generate(None);
116    /// println!("{}", passphrase.expose_borrowed());
117    /// ```
118    pub fn generate(length: Option<usize>) -> Self {
119        let length = {
120            let mut length = length.unwrap_or(Self::DEFAULT_LENGTH);
121            if length < Self::DEFAULT_LENGTH {
122                length = Self::DEFAULT_LENGTH
123            }
124            length
125        };
126
127        Self::new(
128            thread_rng()
129                .sample_iter(&Alphanumeric)
130                .take(length)
131                .map(char::from)
132                .collect(),
133        )
134    }
135
136    /// Checks whether the passphrase adheres to a [`PassphrasePolicy`].
137    ///
138    /// # Errors
139    ///
140    /// Returns an error if `self` does not adhere to `policy`.
141    ///
142    /// # Examples
143    ///
144    /// ```
145    /// use signstar_crypto::passphrase::{Passphrase, PassphrasePolicy};
146    ///
147    /// # fn main() -> testresult::TestResult {
148    /// let passphrase = Passphrase::new_with_policy(
149    ///     "passphrase".to_string(),
150    ///     &PassphrasePolicy { minimum_length: 10 },
151    /// )?;
152    ///
153    /// // The passphrase "passphrase" matches a policy of ten characters.
154    /// assert!(
155    ///     Passphrase::new_with_policy(
156    ///         "passphrase".to_string(),
157    ///         &PassphrasePolicy { minimum_length: 10 }
158    ///     )
159    ///     .is_ok()
160    /// );
161    ///
162    /// // The passphrase "passphrase" is too short for the default policy.
163    /// assert!(
164    ///     Passphrase::new_with_policy("passphrase".to_string(), &PassphrasePolicy::default())
165    ///         .is_err()
166    /// );
167    /// # Ok(())
168    /// # }
169    /// ```
170    pub fn check_against_policy(&self, policy: &PassphrasePolicy) -> Result<(), crate::Error> {
171        if self.len() < policy.minimum_length {
172            return Err(Error::Length {
173                length: self.len(),
174                required_length: policy.minimum_length,
175            }
176            .into());
177        }
178
179        Ok(())
180    }
181
182    /// Exposes the secret passphrase as owned [`String`]
183    pub fn expose_owned(&self) -> String {
184        self.0.expose_secret().to_owned()
185    }
186
187    /// Exposes the secret passphrase as borrowed [`str`]
188    pub fn expose_borrowed(&self) -> &str {
189        self.0.expose_secret()
190    }
191
192    /// Returns the length of the passphrase.
193    pub fn len(&self) -> usize {
194        self.expose_borrowed().len()
195    }
196
197    /// Signals whether the passphrase is empty.
198    pub fn is_empty(&self) -> bool {
199        self.expose_borrowed().is_empty()
200    }
201}
202
203impl Display for Passphrase {
204    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
205        write!(f, "[REDACTED]")
206    }
207}
208
209impl FromStr for Passphrase {
210    type Err = crate::Error;
211
212    fn from_str(s: &str) -> Result<Self, Self::Err> {
213        Ok(Self(SecretString::from(s.to_string())))
214    }
215}
216
217impl Serialize for Passphrase {
218    /// Serializes a [`Passphrase`].
219    ///
220    /// # Warning
221    ///
222    /// This may be used to write a passphrase to file!
223    /// Take precautions so that passphrases can not leak to the environment.
224    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
225    where
226        S: serde::Serializer,
227    {
228        self.0.expose_secret().serialize(serializer)
229    }
230}
231
232impl TryFrom<&Path> for Passphrase {
233    type Error = crate::Error;
234
235    /// Creates a new [`Passphrase`] from the contents of a file.
236    ///
237    /// # Errors
238    ///
239    /// Returns an error if the contents of the file at `path` cannot be read to a valid UTF-8
240    /// string.
241    fn try_from(path: &Path) -> Result<Self, Self::Error> {
242        Passphrase::from_str(
243            &read_to_string(path).map_err(|source| crate::Error::IoPath {
244                path: path.to_path_buf(),
245                context: "reading a passphrase from the file",
246                source,
247            })?,
248        )
249    }
250}
251
252#[cfg(test)]
253mod tests {
254    use std::io::Write;
255
256    use rstest::rstest;
257    use tempfile::{NamedTempFile, TempDir};
258    use testresult::TestResult;
259
260    use super::*;
261
262    #[test]
263    fn passphrase_display() -> TestResult {
264        let passphrase = Passphrase::new("a-secret-passphrase".to_string());
265        assert_eq!(format!("{passphrase}"), "[REDACTED]");
266        Ok(())
267    }
268
269    #[rstest]
270    #[case::too_short_use_default(Some(20), 30)]
271    #[case::none_use_default(None, 30)]
272    #[case::longer_than_default(Some(31), 31)]
273    fn passphrase_generate(#[case] input_length: Option<usize>, #[case] output_length: usize) {
274        let passphrase = Passphrase::generate(input_length);
275        assert_eq!(passphrase.expose_borrowed().len(), output_length);
276    }
277
278    /// Ensures, that a [`Passphrase`] can be read from a plaintext file.
279    #[test]
280    fn passphrase_try_from_path_succeeds() -> TestResult {
281        let temp_file = {
282            let mut temp_file = NamedTempFile::new()?;
283            temp_file.write_all("passphrase".as_bytes())?;
284            temp_file
285        };
286        let _passphrase = Passphrase::try_from(temp_file.path())?;
287
288        Ok(())
289    }
290
291    /// Ensures, that a [`Passphrase`] cannot be read from a directory.
292    #[test]
293    fn passphrase_try_from_path_fails_on_path_is_dir() -> TestResult {
294        let temp_file = TempDir::new()?;
295        assert!(Passphrase::try_from(temp_file.path()).is_err());
296
297        Ok(())
298    }
299
300    #[rstest]
301    #[case::with_len(Passphrase::new("foo".to_string()), 3)]
302    #[case::empty(Passphrase::new("".to_string()), 0)]
303    fn passphrase_len(#[case] passphrase: Passphrase, #[case] len: usize) {
304        assert_eq!(passphrase.len(), len);
305    }
306
307    #[rstest]
308    #[case::with_len(Passphrase::new("foo".to_string()), false)]
309    #[case::empty(Passphrase::new("".to_string()), true)]
310    fn passphrase_is_empty(#[case] passphrase: Passphrase, #[case] is_empty: bool) {
311        assert_eq!(passphrase.is_empty(), is_empty);
312    }
313
314    #[rstest]
315    #[case::empty_policy_allows_empty("", PassphrasePolicy { minimum_length: 0 })]
316    #[case::longer_than_minimum_requirement("foobar", PassphrasePolicy { minimum_length: 3 })]
317    fn passphrase_new_with_policy_succeeds(
318        #[case] passphrase: &str,
319        #[case] policy: PassphrasePolicy,
320    ) -> TestResult {
321        match Passphrase::new_with_policy(passphrase.to_string(), &policy) {
322            Ok(_) => {}
323            Err(error) => panic!(
324                "Expected to successfully create a passphrase from input \"{passphrase}\" and policy {policy:?}, but got error: {error}"
325            ),
326        }
327
328        Ok(())
329    }
330
331    #[rstest]
332    #[case::empty_policy_allows_one_char("", PassphrasePolicy { minimum_length: 1 })]
333    #[case::shorter_than_minimum_requirement("foobar", PassphrasePolicy::default())]
334    fn passphrase_new_with_policy_fails_on_short_passphrase(
335        #[case] passphrase: &str,
336        #[case] policy: PassphrasePolicy,
337    ) -> TestResult {
338        match Passphrase::new_with_policy(passphrase.to_string(), &policy) {
339            Ok(_) => panic!("Expected to fail with an error, but succeeded instead."),
340            Err(crate::Error::Passphrase(Error::Length { .. })) => {}
341            Err(error) => panic!(
342                "Expected to fail with Error::Length, but failed with different error: {error}"
343            ),
344        }
345
346        Ok(())
347    }
348
349    #[test]
350    fn passphrase_check_against_policy() -> TestResult {
351        let passphrase = Passphrase::new("passphrase".to_string());
352        let policy = PassphrasePolicy { minimum_length: 10 };
353
354        match passphrase.check_against_policy(&policy) {
355            Ok(_) => {}
356            Err(error) => panic!(
357                "Expected to successfully check the passphrase against the policy {policy:?}, but got error: {error}"
358            ),
359        }
360
361        let policy = PassphrasePolicy::default();
362        match passphrase.check_against_policy(&policy) {
363            Ok(_) => panic!("Expected to fail with an error, but succeeded instead."),
364            Err(crate::Error::Passphrase(Error::Length { .. })) => {}
365            Err(error) => panic!(
366                "Expected to fail with Error::Length, but failed with different error: {error}"
367            ),
368        }
369
370        Ok(())
371    }
372}