Skip to main content

bitwarden_crypto/safe/
high_entropy_secret.rs

1//! A high-entropy secret is a wrapper around secret bytes that are guaranteed to be high-entropy,
2//! and therefore safe to use as input keying material for a cheap KDF (such as the one used by the
3//! [crate::safe::SecretProtectedKeyEnvelope]).
4//!
5//! Examples of high-entropy secrets are a random URL-fragment secret, a derived key, or random
6//! bytes. They are unlike low-entropy secrets such as PINs or passwords, which can be brute-forced
7//! and therefore require a memory- or compute-hard KDF.
8
9use std::str::FromStr;
10
11use bitwarden_encoding::{B64, FromStrVisitor};
12use bitwarden_sensitive_value::{ExposeSensitive, Sensitive, SensitiveSlice};
13use rand::Rng;
14use serde::{Deserialize, Serialize};
15use thiserror::Error;
16#[cfg(feature = "wasm")]
17use wasm_bindgen::convert::{FromWasmAbi, IntoWasmAbi, OptionFromWasmAbi};
18use zeroize::Zeroizing;
19
20/// Minimum accepted secret length in bytes. 16 bytes = 128 bits of headroom for a uniformly
21/// random secret, matching the security level a cheap KDF assumes.
22const MIN_SECRET_LENGTH: usize = 16;
23
24/// A high entropy secret, generated from a CSPRNG or derived from a high-entropy key. This MUST NOT
25/// be:
26/// - An UTF-8 or ASCII string, even if Base64-encoded or hex-encoded high-entropy bytes
27/// - A low-entropy secret
28/// - A static key
29///
30/// Approved sources are:
31/// - A PRF derived from windows-hello biometrics in Bitwarden-Desktop
32/// - A PRF output from a passkey with the hmac-secret extension
33/// - Key-connector-stored bytes
34/// - A high entropy secret generated with `HighEntropySecret::make()`
35pub trait HighEntropySecretSource {
36    /// Returns the secret bytes as a redacted [`SensitiveSlice`].
37    fn provide_high_entropy_bytes(&self) -> SensitiveSlice<'_>;
38}
39
40/// A secret that is guaranteed to be high-entropy, and therefore safe to use as input keying
41/// material for a cheap KDF.
42#[derive(Clone)]
43pub struct HighEntropySecret {
44    secret: Zeroizing<Vec<u8>>,
45}
46
47impl HighEntropySecret {
48    /// Constructs a `HighEntropySecret` from any [`HighEntropySecretSource`], whose implementation
49    /// guarantees the provided bytes are high-entropy.
50    pub fn from<T: HighEntropySecretSource>(secret: T) -> Self {
51        // EXPOSE: This conversion is safe because the `HighEntropySecret` overrides the Debug
52        // implementation to never print the secret bytes.
53        Self::from_internal(secret.provide_high_entropy_bytes().expose_owned())
54    }
55
56    // Creates a new `HighEntropySecret` from the provided bytes, without validation. The caller is
57    // responsible for ensuring that the bytes are high-entropy and not derived from a low-entropy
58    // source!
59    pub(crate) fn from_internal(secret: &[u8]) -> Self {
60        Self {
61            secret: Zeroizing::new(secret.to_vec()),
62        }
63    }
64
65    /// Generates a new high-entropy secret of the desired size, using cryptographically secure
66    /// random bytes. The generated bytes are high-entropy by construction.
67    ///
68    /// `desired_size` should be at least 16 bytes to provide adequate security headroom.
69    pub fn make(desired_size: usize) -> Result<Self, HighEntropySecretError> {
70        if desired_size < MIN_SECRET_LENGTH {
71            return Err(HighEntropySecretError::TooShort);
72        }
73
74        let mut secret = Zeroizing::new(vec![0u8; desired_size]);
75        bitwarden_random::rng().fill_bytes(secret.as_mut_slice());
76        Ok(Self { secret })
77    }
78
79    /// Returns the secret bytes as a redacted [`SensitiveSlice`]. Not public since callers should
80    /// not handle the raw secret material directly.
81    // Consumed by in-crate KDF/envelope code that is not yet wired up on this branch.
82    #[allow(dead_code)]
83    pub(crate) fn as_bytes(&self) -> SensitiveSlice<'_> {
84        Sensitive::from(self.secret.as_slice())
85    }
86}
87
88impl FromStr for HighEntropySecret {
89    type Err = HighEntropySecretError;
90
91    fn from_str(s: &str) -> Result<Self, Self::Err> {
92        let bytes = B64::try_from(s).map_err(|_| HighEntropySecretError::Malformed)?;
93        if bytes.as_bytes().len() < MIN_SECRET_LENGTH {
94            return Err(HighEntropySecretError::TooShort);
95        }
96        Ok(Self::from_internal(bytes.as_bytes()))
97    }
98}
99
100impl From<HighEntropySecret> for String {
101    fn from(val: HighEntropySecret) -> Self {
102        B64::from(val.secret.as_slice()).to_string()
103    }
104}
105
106impl<'de> Deserialize<'de> for HighEntropySecret {
107    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
108    where
109        D: serde::Deserializer<'de>,
110    {
111        deserializer.deserialize_str(FromStrVisitor::new())
112    }
113}
114
115impl Serialize for HighEntropySecret {
116    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
117    where
118        S: serde::Serializer,
119    {
120        serializer.serialize_str(&B64::from(self.secret.as_slice()).to_string())
121    }
122}
123
124// Manually implemented so the secret material is never printed.
125impl std::fmt::Debug for HighEntropySecret {
126    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
127        f.debug_struct("HighEntropySecret").finish()
128    }
129}
130
131/// Errors that can occur when constructing a [`HighEntropySecret`].
132#[derive(Debug, Error)]
133pub enum HighEntropySecretError {
134    /// The provided secret is too short to be used as a high-entropy secret.
135    #[error("Secret is too short")]
136    TooShort,
137    /// The provided string could not be decoded as standardized base64.
138    #[error("Secret is not valid base64")]
139    Malformed,
140}
141
142#[cfg(feature = "wasm")]
143#[wasm_bindgen::prelude::wasm_bindgen(typescript_custom_section)]
144const TS_CUSTOM_TYPES: &'static str = r#"
145export type HighEntropySecret = Tagged<string, "HighEntropySecret">;
146"#;
147
148#[cfg(feature = "wasm")]
149impl wasm_bindgen::describe::WasmDescribe for HighEntropySecret {
150    fn describe() {
151        <String as wasm_bindgen::describe::WasmDescribe>::describe();
152    }
153}
154
155#[cfg(feature = "wasm")]
156impl FromWasmAbi for HighEntropySecret {
157    type Abi = <String as FromWasmAbi>::Abi;
158
159    unsafe fn from_abi(abi: Self::Abi) -> Self {
160        use wasm_bindgen::UnwrapThrowExt;
161        let string = unsafe { String::from_abi(abi) };
162        HighEntropySecret::from_str(&string).unwrap_throw()
163    }
164}
165
166#[cfg(feature = "wasm")]
167impl OptionFromWasmAbi for HighEntropySecret {
168    fn is_none(abi: &Self::Abi) -> bool {
169        <String as OptionFromWasmAbi>::is_none(abi)
170    }
171}
172
173#[cfg(feature = "wasm")]
174impl IntoWasmAbi for HighEntropySecret {
175    type Abi = <String as IntoWasmAbi>::Abi;
176
177    fn into_abi(self) -> Self::Abi {
178        let string: String = B64::from(self.secret.as_slice()).to_string();
179        string.into_abi()
180    }
181}
182
183#[cfg(test)]
184mod tests {
185    use bitwarden_sensitive_value::ExposeSensitive;
186
187    use super::*;
188
189    #[test]
190    fn test_debug_does_not_leak_secret() {
191        let secret = HighEntropySecret::make(32).unwrap();
192        let debug = format!("{secret:?}");
193        assert_eq!(debug, "HighEntropySecret");
194    }
195
196    #[test]
197    fn test_make_rejects_secret_below_minimum_length() {
198        for size in 0..MIN_SECRET_LENGTH {
199            assert!(
200                matches!(
201                    HighEntropySecret::make(size),
202                    Err(HighEntropySecretError::TooShort)
203                ),
204                "expected size {size} to be rejected as too short"
205            );
206        }
207    }
208
209    #[test]
210    fn test_make_accepts_secret_at_minimum_length() {
211        let secret = HighEntropySecret::make(MIN_SECRET_LENGTH).unwrap();
212        assert_eq!(secret.as_bytes().expose_owned().len(), MIN_SECRET_LENGTH);
213    }
214
215    #[test]
216    fn test_make_produces_secret_of_requested_length() {
217        for size in [16, 32, 64, 128] {
218            let secret = HighEntropySecret::make(size).unwrap();
219            assert_eq!(secret.as_bytes().expose_owned().len(), size);
220        }
221    }
222
223    #[test]
224    fn test_make_produces_distinct_secrets() {
225        let first = HighEntropySecret::make(32).unwrap();
226        let second = HighEntropySecret::make(32).unwrap();
227        // Two independently generated secrets are overwhelmingly unlikely to collide.
228        assert_ne!(
229            first.as_bytes().expose_owned(),
230            second.as_bytes().expose_owned()
231        );
232    }
233
234    #[test]
235    fn test_from_bytes_preserves_secret() {
236        let bytes = vec![7u8; 32];
237        let secret = HighEntropySecret::from_internal(bytes.as_slice());
238        assert_eq!(secret.as_bytes().expose_owned(), bytes.as_slice());
239    }
240
241    #[test]
242    fn test_from_bytes_accepts_secret_at_minimum_length() {
243        let secret = HighEntropySecret::from_internal(vec![1u8; MIN_SECRET_LENGTH].as_slice());
244        assert_eq!(secret.as_bytes().expose_owned().len(), MIN_SECRET_LENGTH);
245    }
246
247    #[test]
248    fn test_clone_preserves_secret_bytes() {
249        let secret = HighEntropySecret::make(32).unwrap();
250        let cloned = secret.clone();
251        assert_eq!(
252            secret.as_bytes().expose_owned(),
253            cloned.as_bytes().expose_owned()
254        );
255    }
256
257    #[test]
258    fn test_base64_round_trip_preserves_bytes() {
259        let secret = HighEntropySecret::make(32).unwrap();
260        let bytes = secret.as_bytes().expose_owned().to_vec();
261        let encoded = String::from(secret);
262        let decoded: HighEntropySecret = encoded.parse().unwrap();
263        assert_eq!(decoded.as_bytes().expose_owned(), bytes.as_slice());
264    }
265
266    #[test]
267    fn test_from_str_rejects_malformed_input() {
268        assert!(matches!(
269            "!!!not-base64!!!".parse::<HighEntropySecret>(),
270            Err(HighEntropySecretError::Malformed)
271        ));
272    }
273
274    #[test]
275    fn test_from_str_rejects_below_minimum_length() {
276        // Any successfully-decoded input shorter than MIN_SECRET_LENGTH bytes must be rejected
277        // so the type's length floor holds regardless of the constructor used.
278        for byte_len in 0..MIN_SECRET_LENGTH {
279            let bytes = vec![0u8; byte_len];
280            let encoded = B64::from(bytes.as_slice()).to_string();
281            assert!(
282                matches!(
283                    encoded.parse::<HighEntropySecret>(),
284                    Err(HighEntropySecretError::TooShort)
285                ),
286                "expected decoded length {byte_len} to be rejected as too short"
287            );
288        }
289    }
290
291    #[test]
292    fn test_from_str_accepts_input_at_minimum_length() {
293        let bytes = vec![7u8; MIN_SECRET_LENGTH];
294        let encoded = B64::from(bytes.as_slice()).to_string();
295        let secret: HighEntropySecret = encoded
296            .parse()
297            .expect("input at minimum length is accepted");
298        assert_eq!(secret.as_bytes().expose_owned().len(), MIN_SECRET_LENGTH);
299    }
300
301    #[test]
302    fn test_serde_json_round_trip_preserves_bytes() {
303        // Locks the serde impls directly. Without this, a regression in `Serialize` /
304        // `Deserialize` would only surface via a downstream crate's integration test.
305        let secret = HighEntropySecret::make(32).unwrap();
306        let bytes = secret.as_bytes().expose_owned().to_vec();
307        let json = serde_json::to_string(&secret).expect("serialize");
308        let round_tripped: HighEntropySecret = serde_json::from_str(&json).expect("deserialize");
309        assert_eq!(round_tripped.as_bytes().expose_owned(), bytes.as_slice());
310    }
311}