Skip to main content

bitwarden_pam/rotation/
validate.rs

1use bitwarden_pam_sealed::PasswordPolicyError;
2use thiserror::Error;
3
4use super::{configs::RotationConfigCreateRequest, schedule::is_likely_quartz_cron};
5
6/// Maximum length of a connector or target-system `name`, matching the server's column.
7const MAX_NAME_LENGTH: usize = 200;
8/// Maximum length of a config's `account_identity`, matching the server's column.
9const MAX_ACCOUNT_IDENTITY_LENGTH: usize = 500;
10
11/// Errors from validating a rotation request before it is sent to the server.
12#[derive(Debug, Error, PartialEq, Eq)]
13pub enum RotationValidationError {
14    /// A `name` was empty (after trimming) or exceeded the server's length limit.
15    #[error("Name must be between 1 and {MAX_NAME_LENGTH} characters")]
16    InvalidName,
17    /// `account_identity` was empty (after trimming) or exceeded the server's length limit.
18    #[error("Account identity must be between 1 and {MAX_ACCOUNT_IDENTITY_LENGTH} characters")]
19    InvalidAccountIdentity,
20    /// Password lengths out of order or outside the generator's range.
21    #[error("Password length bounds are outside the generator's supported range")]
22    InvalidPasswordLengthBounds,
23    /// A password policy enabled no character classes, so no credential could satisfy it.
24    #[error("A password policy must allow at least one character class")]
25    NoCharacterClasses,
26    /// `schedule_cron` was present but is not shaped like a Quartz cron expression.
27    #[error("Schedule must be a 6- or 7-field Quartz cron expression")]
28    InvalidCron,
29    /// `terminate_sessions` was requested but the target doesn't confirm
30    /// `supports_session_termination`.
31    #[error("The target system does not confirm it supports session termination")]
32    SessionTerminationUnsupported,
33    /// The rotation config's target system has a sealed blob that can't be verified.
34    #[error("The target system's sealed data is invalid")]
35    TargetSystemSealInvalid,
36    /// An update tried to change a target system's `method`.
37    #[error("Target system method cannot be changed")]
38    TargetSystemMethodMismatch,
39    /// An update tried to change a target system's sealed `kind`.
40    #[error("Target system kind cannot be changed")]
41    TargetSystemKindMismatch,
42    /// An update tried to change a rotation config's `cipher_id` or `target_system_id`.
43    #[error("Rotation config cipher or target system cannot be changed")]
44    RotationConfigImmutableFieldMismatch,
45}
46
47impl From<PasswordPolicyError> for RotationValidationError {
48    fn from(error: PasswordPolicyError) -> Self {
49        match error {
50            PasswordPolicyError::NoCharacterClasses => Self::NoCharacterClasses,
51            PasswordPolicyError::InvalidLengthBounds => Self::InvalidPasswordLengthBounds,
52        }
53    }
54}
55
56/// Validates a name against the server's length limit, counted in UTF-16 code units as the
57/// server's `nvarchar` column counts them.
58pub(super) fn validate_name(name: &str) -> Result<(), RotationValidationError> {
59    let trimmed = name.trim();
60    if trimmed.is_empty() || trimmed.encode_utf16().count() > MAX_NAME_LENGTH {
61        return Err(RotationValidationError::InvalidName);
62    }
63
64    Ok(())
65}
66
67fn validate_account_identity(identity: &str) -> Result<(), RotationValidationError> {
68    let trimmed = identity.trim();
69    if trimmed.is_empty() || trimmed.encode_utf16().count() > MAX_ACCOUNT_IDENTITY_LENGTH {
70        return Err(RotationValidationError::InvalidAccountIdentity);
71    }
72
73    Ok(())
74}
75
76/// Validates a set cron expression; `None` means no schedule. Only the shape is checked, since the
77/// server owns the minimum-interval floor.
78fn validate_schedule_cron(cron: Option<&String>) -> Result<(), RotationValidationError> {
79    match cron {
80        Some(cron) if !is_likely_quartz_cron(cron) => Err(RotationValidationError::InvalidCron),
81        _ => Ok(()),
82    }
83}
84
85/// Validates a rotation config request. `terminate_sessions` is checked against the target
86/// separately, since that needs a network call.
87pub(super) fn validate_config_request(
88    request: &RotationConfigCreateRequest,
89) -> Result<(), RotationValidationError> {
90    validate_account_identity(&request.account_identity)?;
91    validate_schedule_cron(request.schedule_cron.as_ref())
92}
93
94#[cfg(test)]
95mod tests {
96    use bitwarden_pam_sealed::{GENERATOR_MAX_LENGTH, GENERATOR_MIN_LENGTH};
97    use bitwarden_vault::CipherId;
98    use uuid::uuid;
99
100    use super::*;
101    use crate::{TargetSystemId, rotation::models::PasswordPolicy};
102
103    fn policy() -> PasswordPolicy {
104        PasswordPolicy {
105            min_length: 14,
106            max_length: 64,
107            include_uppercase: true,
108            include_lowercase: true,
109            include_digits: true,
110            include_symbols: true,
111        }
112    }
113
114    fn config_create(account_identity: &str, cron: Option<&str>) -> RotationConfigCreateRequest {
115        RotationConfigCreateRequest {
116            cipher_id: CipherId::new(uuid!("11111111-1111-1111-1111-111111111111")),
117            target_system_id: TargetSystemId::new(uuid!("22222222-2222-2222-2222-222222222222")),
118            account_identity: account_identity.to_string(),
119            terminate_sessions: false,
120            schedule_cron: cron.map(ToString::to_string),
121            rotate_on_access_end: false,
122        }
123    }
124
125    #[test]
126    fn a_well_formed_name_and_policy_pass() {
127        assert!(validate_name("Prod SQL").is_ok());
128        assert!(policy().validate().is_ok());
129    }
130
131    #[test]
132    fn a_blank_name_is_rejected() {
133        for name in ["", "   ", "\t\n"] {
134            assert_eq!(
135                validate_name(name),
136                Err(RotationValidationError::InvalidName),
137                "for {name:?}"
138            );
139        }
140    }
141
142    #[test]
143    fn a_name_at_the_limit_passes_and_one_over_is_rejected() {
144        let at_limit = "a".repeat(MAX_NAME_LENGTH);
145        assert!(validate_name(&at_limit).is_ok());
146
147        let over_limit = "a".repeat(MAX_NAME_LENGTH + 1);
148        assert_eq!(
149            validate_name(&over_limit),
150            Err(RotationValidationError::InvalidName)
151        );
152    }
153
154    /// An astral character costs two UTF-16 code units, so 100 of them fill the 200-unit limit and
155    /// 101 overflow it; a `char` count would pass both.
156    #[test]
157    fn name_length_is_counted_in_utf16_code_units() {
158        let at_limit = "😀".repeat(MAX_NAME_LENGTH / 2);
159        assert!(validate_name(&at_limit).is_ok());
160
161        let over_limit = "😀".repeat(MAX_NAME_LENGTH / 2 + 1);
162        assert_eq!(
163            validate_name(&over_limit),
164            Err(RotationValidationError::InvalidName)
165        );
166    }
167
168    /// Leading and trailing whitespace is trimmed before measuring, so padding cannot push a
169    /// valid name over the limit.
170    #[test]
171    fn surrounding_whitespace_does_not_count_towards_the_limit() {
172        let padded = format!("  {}  ", "a".repeat(MAX_NAME_LENGTH));
173        assert!(validate_name(&padded).is_ok());
174    }
175
176    #[test]
177    fn inverted_length_bounds_are_rejected() {
178        let policy = PasswordPolicy {
179            min_length: 64,
180            max_length: 14,
181            ..policy()
182        };
183
184        assert_eq!(
185            policy.validate().map_err(RotationValidationError::from),
186            Err(RotationValidationError::InvalidPasswordLengthBounds)
187        );
188    }
189
190    #[test]
191    fn length_bounds_outside_the_generator_range_are_rejected() {
192        for (min_length, max_length) in [
193            (0, 64),
194            (14, GENERATOR_MAX_LENGTH + 1),
195            (GENERATOR_MIN_LENGTH - 1, GENERATOR_MAX_LENGTH),
196        ] {
197            let policy = PasswordPolicy {
198                min_length,
199                max_length,
200                ..policy()
201            };
202
203            assert_eq!(
204                policy.validate().map_err(RotationValidationError::from),
205                Err(RotationValidationError::InvalidPasswordLengthBounds),
206                "for {min_length}..{max_length}"
207            );
208        }
209    }
210
211    #[test]
212    fn equal_length_bounds_are_accepted() {
213        let policy = PasswordPolicy {
214            min_length: 32,
215            max_length: 32,
216            ..policy()
217        };
218
219        assert!(
220            policy
221                .validate()
222                .map_err(RotationValidationError::from)
223                .is_ok()
224        );
225    }
226
227    /// A policy with every class off is unsatisfiable, and the connector's generator raises the
228    /// same failure at rotation time.
229    #[test]
230    fn a_policy_with_no_character_classes_is_rejected() {
231        let policy = PasswordPolicy {
232            include_uppercase: false,
233            include_lowercase: false,
234            include_digits: false,
235            include_symbols: false,
236            ..policy()
237        };
238
239        assert_eq!(
240            policy.validate().map_err(RotationValidationError::from),
241            Err(RotationValidationError::NoCharacterClasses)
242        );
243    }
244
245    #[test]
246    fn a_single_character_class_is_enough() {
247        let policy = PasswordPolicy {
248            include_uppercase: false,
249            include_lowercase: true,
250            include_digits: false,
251            include_symbols: false,
252            ..policy()
253        };
254
255        assert!(
256            policy
257                .validate()
258                .map_err(RotationValidationError::from)
259                .is_ok()
260        );
261    }
262
263    #[test]
264    fn a_config_with_no_schedule_is_valid() {
265        assert!(validate_config_request(&config_create("svc_rotation", None)).is_ok());
266    }
267
268    #[test]
269    fn a_config_with_a_well_formed_cron_is_valid() {
270        assert!(
271            validate_config_request(&config_create("svc_rotation", Some("0 0 0 * * ?"))).is_ok()
272        );
273    }
274
275    #[test]
276    fn a_malformed_cron_is_rejected() {
277        // A 5-field UNIX cron, the likeliest operator mistake.
278        assert_eq!(
279            validate_config_request(&config_create("svc_rotation", Some("0 0 * * *"))),
280            Err(RotationValidationError::InvalidCron)
281        );
282    }
283
284    #[test]
285    fn a_blank_account_identity_is_rejected() {
286        assert_eq!(
287            validate_config_request(&config_create("   ", None)),
288            Err(RotationValidationError::InvalidAccountIdentity)
289        );
290    }
291
292    #[test]
293    fn an_account_identity_at_the_limit_passes_and_one_over_is_rejected() {
294        let at_limit = "a".repeat(MAX_ACCOUNT_IDENTITY_LENGTH);
295        assert!(validate_config_request(&config_create(&at_limit, None)).is_ok());
296
297        let over_limit = "a".repeat(MAX_ACCOUNT_IDENTITY_LENGTH + 1);
298        assert_eq!(
299            validate_config_request(&config_create(&over_limit, None)),
300            Err(RotationValidationError::InvalidAccountIdentity)
301        );
302    }
303}