Skip to main content

bitwarden_pam/rotation/
claim.rs

1//! A claimed rotation job and its verification.
2//!
3//! [`WorkSnapshot::verify`] cross-checks a claim's two sealed values against each other and the
4//! plaintext claim. The pre-flight cipher read needs the network, so the access connector does it.
5//!
6//! Only [`WorkSnapshot::verify`] can construct a [`VerifiedJob`], so the connector never reads an
7//! unchecked claim field.
8
9use bitwarden_api_api::models::RotationClaimResponseModel;
10use bitwarden_core::{
11    MissingFieldError, OrganizationId,
12    key_management::{KeySlotIds, SymmetricKeySlotId},
13    require,
14};
15use bitwarden_crypto::{EncString, KeyStore, safe::DataEnvelope};
16use bitwarden_generators::PasswordGeneratorRequest;
17use bitwarden_pam_sealed::{
18    GENERATOR_MAX_LENGTH, GENERATOR_MIN_LENGTH, PasswordPolicyError, RotationConfigSealedV1,
19    SealedPasswordPolicy, SealedTargetSystemKind, SealedTargetSystemMethod, TargetSystemSealedV1,
20    unseal_rotation_config, unseal_target_system,
21};
22use chrono::{DateTime, Utc};
23use thiserror::Error;
24use uuid::Uuid;
25
26/// The work snapshot returned by a successful claim; [`WorkSnapshot::verify`] checks it before any
27/// field is trusted.
28#[derive(Debug, Clone)]
29pub struct WorkSnapshot {
30    /// Keys every attempt-scoped request (cipher read/write, outcome reports).
31    pub attempt_id: Uuid,
32    /// The rotation job.
33    pub job_id: Uuid,
34    /// The target system, as the server claims it.
35    pub target_system_id: Uuid,
36    /// For logging only.
37    pub target_system_name: String,
38    /// The cipher to rotate, as the server claims it.
39    pub cipher_id: Uuid,
40    /// The sealed target system envelope.
41    pub target_system_sealed_data: Option<String>,
42    /// The key wrapping the target system envelope.
43    pub target_system_wrapped_key: Option<String>,
44    /// The sealed rotation config envelope.
45    pub rotation_config_sealed_data: Option<String>,
46    /// The key wrapping the rotation config envelope.
47    pub rotation_config_wrapped_key: Option<String>,
48    /// Lease deadline: no target-side step starts after it.
49    pub execute_by: DateTime<Utc>,
50}
51
52/// Errors from parsing a claim response. They never include payload content.
53#[derive(Debug, Error)]
54pub enum WorkSnapshotError {
55    /// The response was missing a required field.
56    #[error(transparent)]
57    MissingField(#[from] MissingFieldError),
58    /// `executeBy` was not a valid timestamp.
59    #[error("invalid executeBy timestamp")]
60    InvalidExecuteBy,
61}
62
63impl TryFrom<RotationClaimResponseModel> for WorkSnapshot {
64    type Error = WorkSnapshotError;
65
66    fn try_from(model: RotationClaimResponseModel) -> Result<Self, Self::Error> {
67        let attempt_id = require!(model.attempt_id);
68        let job_id = require!(model.job_id);
69        let target_system_id = require!(model.target_system_id);
70        let target_system_name = require!(model.target_system_name);
71        let cipher_id = require!(model.cipher_id);
72
73        let execute_by = require!(model.execute_by)
74            .parse::<DateTime<Utc>>()
75            .map_err(|_| WorkSnapshotError::InvalidExecuteBy)?;
76
77        // The sealed halves are not required here: a missing one is rejected by `verify`.
78        Ok(Self {
79            attempt_id,
80            job_id,
81            target_system_id,
82            target_system_name,
83            cipher_id,
84            target_system_sealed_data: model.target_system_sealed_data,
85            target_system_wrapped_key: model.target_system_wrapped_key,
86            rotation_config_sealed_data: model.rotation_config_sealed_data,
87            rotation_config_wrapped_key: model.rotation_config_wrapped_key,
88            execute_by,
89        })
90    }
91}
92
93/// Why [`WorkSnapshot::verify`] refused a claim. Carries no claim or blob values, only which
94/// check failed.
95#[derive(Debug, Clone, Copy, PartialEq, Eq)]
96pub enum SealRejection {
97    /// The target system's sealed data or wrapped key is absent.
98    MissingTargetBlob,
99    /// The rotation config's sealed data or wrapped key is absent.
100    MissingConfigBlob,
101    /// The target system blob did not parse or unseal.
102    TargetBlobInvalid,
103    /// The rotation config blob did not parse or unseal.
104    ConfigBlobInvalid,
105    /// The claimed target system differs from one of the sealed blobs.
106    TargetSystemIdMismatch,
107    /// The claimed cipher differs from the sealed config.
108    CipherIdMismatch,
109    /// The sealed target system is not set to automatic rotation.
110    TargetMethodNotAutomatic,
111    /// The sealed target system has no kind, which an `Automatic` target always seals.
112    TargetKindMissing,
113    /// The sealed password policy is invalid.
114    PolicyRejected,
115}
116
117impl SealRejection {
118    /// A `'static` name, safe to report in logs and error details.
119    pub fn as_str(self) -> &'static str {
120        match self {
121            Self::MissingTargetBlob => "missing_target_blob",
122            Self::MissingConfigBlob => "missing_config_blob",
123            Self::TargetBlobInvalid => "target_blob_invalid",
124            Self::ConfigBlobInvalid => "config_blob_invalid",
125            Self::TargetSystemIdMismatch => "target_system_id_mismatch",
126            Self::CipherIdMismatch => "cipher_id_mismatch",
127            Self::TargetMethodNotAutomatic => "target_method_not_automatic",
128            Self::TargetKindMissing => "target_kind_missing",
129            Self::PolicyRejected => "policy_rejected",
130        }
131    }
132}
133
134/// A claim cross-checked by [`WorkSnapshot::verify`]; private fields keep it constructible only
135/// there.
136#[derive(Debug)]
137pub struct VerifiedJob {
138    attempt_id: Uuid,
139    job_id: Uuid,
140    target_system_id: Uuid,
141    target_system_name: String,
142    cipher_id: Uuid,
143    kind: SealedTargetSystemKind,
144    password_policy: SealedPasswordPolicy,
145    account_identity: String,
146    terminate_sessions: bool,
147    execute_by: DateTime<Utc>,
148}
149
150impl VerifiedJob {
151    /// The attempt every attempt-scoped request is keyed by.
152    pub fn attempt_id(&self) -> Uuid {
153        self.attempt_id
154    }
155
156    /// The rotation job.
157    pub fn job_id(&self) -> Uuid {
158        self.job_id
159    }
160
161    /// The target system, matching both sealed blobs.
162    pub fn target_system_id(&self) -> Uuid {
163        self.target_system_id
164    }
165
166    /// For logging only.
167    pub fn target_system_name(&self) -> &str {
168        &self.target_system_name
169    }
170
171    /// The cipher to rotate, matching the sealed config.
172    pub fn cipher_id(&self) -> Uuid {
173        self.cipher_id
174    }
175
176    /// The target-system kind, from the sealed blob only.
177    pub fn kind(&self) -> SealedTargetSystemKind {
178        self.kind
179    }
180
181    /// The sealed password policy, already validated.
182    pub fn password_policy(&self) -> &SealedPasswordPolicy {
183        &self.password_policy
184    }
185
186    /// Passed verbatim to integrations, which must treat it as untrusted input.
187    pub fn account_identity(&self) -> &str {
188        &self.account_identity
189    }
190
191    /// Whether to terminate sessions, ANDed with the target's support.
192    pub fn terminate_sessions(&self) -> bool {
193        self.terminate_sessions
194    }
195
196    /// No target-side step starts after this.
197    pub fn execute_by(&self) -> DateTime<Utc> {
198        self.execute_by
199    }
200
201    /// Converts the sealed password policy into a [`PasswordGeneratorRequest`] of length
202    /// `max_length`.
203    ///
204    /// Re-validates although [`WorkSnapshot::verify`] already did, so any call site that skips it
205    /// fails closed.
206    pub fn generator_request(&self) -> Result<PasswordGeneratorRequest, PasswordPolicyError> {
207        let policy = &self.password_policy;
208        policy.validate()?;
209
210        let length = policy
211            .max_length
212            .clamp(GENERATOR_MIN_LENGTH, GENERATOR_MAX_LENGTH);
213
214        // The generator's maximum length fits in a u8.
215        #[allow(clippy::cast_possible_truncation)]
216        let length_u8 = length as u8;
217
218        Ok(PasswordGeneratorRequest {
219            lowercase: policy.include_lowercase,
220            uppercase: policy.include_uppercase,
221            numbers: policy.include_digits,
222            special: policy.include_symbols,
223            length: length_u8,
224            avoid_ambiguous: false,
225            min_lowercase: None,
226            min_uppercase: None,
227            min_number: None,
228            min_special: None,
229            custom_required_chars: None,
230            custom_allowed_chars: None,
231            max_consecutive: None,
232        })
233    }
234}
235
236impl WorkSnapshot {
237    /// Cross-checks the claim against its sealed values, rejecting it with a [`SealRejection`] on
238    /// the first failure.
239    pub fn verify(
240        self,
241        key_store: &KeyStore<KeySlotIds>,
242        organization_id: OrganizationId,
243    ) -> Result<VerifiedJob, SealRejection> {
244        // Both halves of each sealed value must be present and parse.
245        let (target_envelope, target_key) = self
246            .target_system_sealed_data
247            .as_deref()
248            .zip(self.target_system_wrapped_key.as_deref())
249            .ok_or(SealRejection::MissingTargetBlob)?;
250        let (config_envelope, config_key) = self
251            .rotation_config_sealed_data
252            .as_deref()
253            .zip(self.rotation_config_wrapped_key.as_deref())
254            .ok_or(SealRejection::MissingConfigBlob)?;
255
256        let target_envelope: DataEnvelope = target_envelope
257            .parse()
258            .map_err(|_| SealRejection::TargetBlobInvalid)?;
259        let config_envelope: DataEnvelope = config_envelope
260            .parse()
261            .map_err(|_| SealRejection::ConfigBlobInvalid)?;
262
263        let mut ctx = key_store.context_mut();
264        let org_slot = SymmetricKeySlotId::Organization(organization_id);
265
266        let target: TargetSystemSealedV1 = unseal_target_system::<KeySlotIds>(
267            &target_envelope,
268            &EncString::from(target_key),
269            &org_slot,
270            &mut ctx,
271        )
272        .map_err(|_| SealRejection::TargetBlobInvalid)?;
273
274        let config: RotationConfigSealedV1 = unseal_rotation_config::<KeySlotIds>(
275            &config_envelope,
276            &EncString::from(config_key),
277            &org_slot,
278            &mut ctx,
279        )
280        .map_err(|_| SealRejection::ConfigBlobInvalid)?;
281
282        if self.target_system_id != target.target_system_id
283            || self.target_system_id != config.target_system_id
284        {
285            return Err(SealRejection::TargetSystemIdMismatch);
286        }
287
288        if self.cipher_id != config.cipher_id {
289            return Err(SealRejection::CipherIdMismatch);
290        }
291
292        // Trust only the sealed method; the server's plaintext copy is advisory.
293        if target.method != SealedTargetSystemMethod::Automatic {
294            return Err(SealRejection::TargetMethodNotAutomatic);
295        }
296
297        // Kind, policy and account identity come from the blobs only.
298        let kind = target.kind.ok_or(SealRejection::TargetKindMissing)?;
299
300        target
301            .password_policy
302            .validate()
303            .map_err(|_| SealRejection::PolicyRejected)?;
304
305        // Unsupported termination is ANDed to false, not a rejection.
306        let terminate_sessions =
307            config.terminate_sessions && target.supports_session_termination == Some(true);
308
309        Ok(VerifiedJob {
310            attempt_id: self.attempt_id,
311            job_id: self.job_id,
312            target_system_id: self.target_system_id,
313            target_system_name: self.target_system_name,
314            cipher_id: self.cipher_id,
315            kind,
316            password_policy: target.password_policy,
317            account_identity: config.account_identity,
318            terminate_sessions,
319            execute_by: self.execute_by,
320        })
321    }
322}
323
324/// Fixture builders for the tests below.
325#[cfg(test)]
326mod test_support {
327    use bitwarden_crypto::{SymmetricCryptoKey, SymmetricKeyAlgorithm};
328    use bitwarden_pam_sealed::{Sealed, seal_rotation_config, seal_target_system};
329
330    use super::*;
331
332    /// `(sealedData, wrappedKey)` as sent over the wire.
333    pub(super) type SealedStrings = (String, String);
334
335    fn org_id() -> OrganizationId {
336        "3f1a5c4e-7b2d-4f8a-9c6e-1d2b3a4c5d6e"
337            .parse()
338            .expect("valid uuid")
339    }
340
341    fn org_slot() -> SymmetricKeySlotId {
342        SymmetricKeySlotId::Organization(org_id())
343    }
344
345    /// Wire form of a [`Sealed`].
346    fn strings(sealed: Sealed) -> SealedStrings {
347        (sealed.envelope.to_string(), sealed.wrapped_key.to_string())
348    }
349
350    pub(super) fn all_classes(min_length: u32, max_length: u32) -> SealedPasswordPolicy {
351        SealedPasswordPolicy {
352            min_length,
353            max_length,
354            include_uppercase: true,
355            include_lowercase: true,
356            include_digits: true,
357            include_symbols: true,
358        }
359    }
360
361    pub(super) fn no_classes() -> SealedPasswordPolicy {
362        SealedPasswordPolicy {
363            include_uppercase: false,
364            include_lowercase: false,
365            include_digits: false,
366            include_symbols: false,
367            ..all_classes(20, 20)
368        }
369    }
370
371    /// A key store holding a fresh organization key, and the IDs a well-formed claim agrees on.
372    pub(super) struct Fixture {
373        store: KeyStore<KeySlotIds>,
374        pub(super) target_system_id: Uuid,
375        pub(super) cipher_id: Uuid,
376    }
377
378    impl Fixture {
379        pub(super) fn new() -> Self {
380            let store = KeyStore::default();
381            #[allow(deprecated)]
382            store
383                .context_mut()
384                .set_symmetric_key(
385                    org_slot(),
386                    SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac),
387                )
388                .expect("set_symmetric_key");
389            Self {
390                store,
391                target_system_id: Uuid::new_v4(),
392                cipher_id: Uuid::new_v4(),
393            }
394        }
395
396        pub(super) fn target(&self) -> TargetSystemSealedV1 {
397            TargetSystemSealedV1 {
398                target_system_id: self.target_system_id,
399                method: SealedTargetSystemMethod::Automatic,
400                kind: Some(SealedTargetSystemKind::CustomScript),
401                password_policy: all_classes(20, 20),
402                supports_session_termination: Some(true),
403            }
404        }
405
406        pub(super) fn config(&self) -> RotationConfigSealedV1 {
407            RotationConfigSealedV1 {
408                target_system_id: self.target_system_id,
409                cipher_id: self.cipher_id,
410                account_identity: "[email protected]".to_owned(),
411                terminate_sessions: true,
412            }
413        }
414
415        pub(super) fn seal_target(&self, target: TargetSystemSealedV1) -> SealedStrings {
416            let mut ctx = self.store.context_mut();
417            strings(seal_target_system::<KeySlotIds>(target, &org_slot(), &mut ctx).expect("seal"))
418        }
419
420        pub(super) fn seal_config(&self, config: RotationConfigSealedV1) -> SealedStrings {
421            let mut ctx = self.store.context_mut();
422            strings(
423                seal_rotation_config::<KeySlotIds>(config, &org_slot(), &mut ctx).expect("seal"),
424            )
425        }
426
427        /// A claim carrying the fixture's IDs and the given blobs.
428        pub(super) fn snapshot(
429            &self,
430            target_blob: Option<SealedStrings>,
431            config_blob: Option<SealedStrings>,
432        ) -> WorkSnapshot {
433            let (target_data, target_key) = target_blob.unzip();
434            let (config_data, config_key) = config_blob.unzip();
435            WorkSnapshot {
436                attempt_id: Uuid::new_v4(),
437                job_id: Uuid::new_v4(),
438                target_system_id: self.target_system_id,
439                target_system_name: "test-system".to_owned(),
440                cipher_id: self.cipher_id,
441                target_system_sealed_data: target_data,
442                target_system_wrapped_key: target_key,
443                rotation_config_sealed_data: config_data,
444                rotation_config_wrapped_key: config_key,
445                execute_by: Utc::now() + chrono::Duration::minutes(5),
446            }
447        }
448
449        /// A claim carrying `target` and `config`, both sealed under the fixture's org key.
450        pub(super) fn sealed_snapshot(
451            &self,
452            target: TargetSystemSealedV1,
453            config: RotationConfigSealedV1,
454        ) -> WorkSnapshot {
455            self.snapshot(
456                Some(self.seal_target(target)),
457                Some(self.seal_config(config)),
458            )
459        }
460
461        /// A claim that passes every check.
462        pub(super) fn valid_snapshot(&self) -> WorkSnapshot {
463            self.sealed_snapshot(self.target(), self.config())
464        }
465
466        pub(super) fn verify(&self, snapshot: WorkSnapshot) -> Result<VerifiedJob, SealRejection> {
467            snapshot.verify(&self.store, org_id())
468        }
469
470        pub(super) fn reject(&self, snapshot: WorkSnapshot) -> SealRejection {
471            self.verify(snapshot).expect_err("claim should be rejected")
472        }
473    }
474}
475
476#[cfg(test)]
477mod tests {
478    use super::{test_support::*, *};
479
480    #[test]
481    fn happy_path_produces_verified_job() {
482        let f = Fixture::new();
483        let snapshot = f.valid_snapshot();
484        let (attempt_id, job_id) = (snapshot.attempt_id, snapshot.job_id);
485
486        let job = f.verify(snapshot).expect("well-formed claim should verify");
487
488        assert_eq!(job.attempt_id(), attempt_id);
489        assert_eq!(job.job_id(), job_id);
490        assert_eq!(job.target_system_id(), f.target_system_id);
491        assert_eq!(job.cipher_id(), f.cipher_id);
492        assert_eq!(job.kind(), SealedTargetSystemKind::CustomScript);
493        assert_eq!(job.account_identity(), "[email protected]");
494        assert!(job.terminate_sessions());
495    }
496
497    #[test]
498    fn missing_target_blob_is_rejected() {
499        let f = Fixture::new();
500        let snapshot = f.snapshot(None, Some(f.seal_config(f.config())));
501        assert_eq!(f.reject(snapshot), SealRejection::MissingTargetBlob);
502    }
503
504    #[test]
505    fn missing_config_blob_is_rejected() {
506        let f = Fixture::new();
507        let snapshot = f.snapshot(Some(f.seal_target(f.target())), None);
508        assert_eq!(f.reject(snapshot), SealRejection::MissingConfigBlob);
509    }
510
511    #[test]
512    fn target_sealed_data_without_wrapped_key_is_rejected() {
513        let f = Fixture::new();
514        let mut snapshot = f.valid_snapshot();
515        snapshot.target_system_wrapped_key = None;
516        assert_eq!(f.reject(snapshot), SealRejection::MissingTargetBlob);
517    }
518
519    #[test]
520    fn config_sealed_data_without_wrapped_key_is_rejected() {
521        let f = Fixture::new();
522        let mut snapshot = f.valid_snapshot();
523        snapshot.rotation_config_wrapped_key = None;
524        assert_eq!(f.reject(snapshot), SealRejection::MissingConfigBlob);
525    }
526
527    #[test]
528    fn unparseable_wrapped_key_is_rejected() {
529        let f = Fixture::new();
530        let mut snapshot = f.valid_snapshot();
531        snapshot.target_system_wrapped_key = Some("not a wrapped key".to_owned());
532        assert_eq!(f.reject(snapshot), SealRejection::TargetBlobInvalid);
533    }
534
535    #[test]
536    fn wrapped_key_from_a_different_seal_is_rejected() {
537        // Envelope from one seal, wrapped key from another: the key does not open the envelope.
538        let f = Fixture::new();
539        let (_, other_key) = f.seal_target(f.target());
540        let mut snapshot = f.valid_snapshot();
541        snapshot.target_system_wrapped_key = Some(other_key);
542        assert_eq!(f.reject(snapshot), SealRejection::TargetBlobInvalid);
543    }
544
545    #[test]
546    fn garbage_target_blob_is_rejected() {
547        let f = Fixture::new();
548        let mut snapshot = f.valid_snapshot();
549        snapshot.target_system_sealed_data = Some("not a sealed blob".to_owned());
550        assert_eq!(f.reject(snapshot), SealRejection::TargetBlobInvalid);
551    }
552
553    #[test]
554    fn target_blob_sealed_under_a_different_org_key_is_rejected() {
555        let f = Fixture::new();
556        let other_key = Fixture::new();
557        let snapshot = f.snapshot(
558            Some(other_key.seal_target(f.target())),
559            Some(f.seal_config(f.config())),
560        );
561        assert_eq!(f.reject(snapshot), SealRejection::TargetBlobInvalid);
562    }
563
564    #[test]
565    fn config_blob_from_a_different_config_is_rejected() {
566        // A config swapped in from another rotation config: same target, different cipher.
567        let f = Fixture::new();
568        let other_config = RotationConfigSealedV1 {
569            cipher_id: Uuid::new_v4(),
570            ..f.config()
571        };
572        let snapshot = f.sealed_snapshot(f.target(), other_config);
573        assert_eq!(f.reject(snapshot), SealRejection::CipherIdMismatch);
574    }
575
576    #[test]
577    fn target_blob_from_a_different_target_is_rejected() {
578        let f = Fixture::new();
579        let other_target = TargetSystemSealedV1 {
580            target_system_id: Uuid::new_v4(),
581            ..f.target()
582        };
583        let snapshot = f.sealed_snapshot(other_target, f.config());
584        assert_eq!(f.reject(snapshot), SealRejection::TargetSystemIdMismatch);
585    }
586
587    #[test]
588    fn claim_target_id_differs_from_sealed_blobs_is_rejected() {
589        let f = Fixture::new();
590        let mut snapshot = f.valid_snapshot();
591        snapshot.target_system_id = Uuid::new_v4();
592        assert_eq!(f.reject(snapshot), SealRejection::TargetSystemIdMismatch);
593    }
594
595    #[test]
596    fn claim_cipher_id_differs_from_config_blob_is_rejected() {
597        let f = Fixture::new();
598        let mut snapshot = f.valid_snapshot();
599        snapshot.cipher_id = Uuid::new_v4();
600        assert_eq!(f.reject(snapshot), SealRejection::CipherIdMismatch);
601    }
602
603    #[test]
604    fn manual_target_method_is_rejected() {
605        let f = Fixture::new();
606        let manual_target = TargetSystemSealedV1 {
607            method: SealedTargetSystemMethod::Manual,
608            kind: None,
609            supports_session_termination: None,
610            ..f.target()
611        };
612        let snapshot = f.sealed_snapshot(manual_target, f.config());
613        assert_eq!(f.reject(snapshot), SealRejection::TargetMethodNotAutomatic);
614    }
615
616    #[test]
617    fn automatic_target_with_missing_kind_is_rejected() {
618        let f = Fixture::new();
619        let malformed_target = TargetSystemSealedV1 {
620            kind: None,
621            ..f.target()
622        };
623        let snapshot = f.sealed_snapshot(malformed_target, f.config());
624        assert_eq!(f.reject(snapshot), SealRejection::TargetKindMissing);
625    }
626
627    #[test]
628    fn policy_with_no_character_classes_is_rejected() {
629        let f = Fixture::new();
630        let invalid_target = TargetSystemSealedV1 {
631            password_policy: no_classes(),
632            ..f.target()
633        };
634        let snapshot = f.sealed_snapshot(invalid_target, f.config());
635        assert_eq!(f.reject(snapshot), SealRejection::PolicyRejected);
636    }
637
638    #[test]
639    fn terminate_requested_but_unsupported_is_anded_to_false_not_rejected() {
640        let f = Fixture::new();
641        let target = TargetSystemSealedV1 {
642            supports_session_termination: Some(false),
643            ..f.target()
644        };
645        let config = RotationConfigSealedV1 {
646            terminate_sessions: true,
647            ..f.config()
648        };
649
650        let job = f
651            .verify(f.sealed_snapshot(target, config))
652            .expect("an unsupported termination request must not be a rejection");
653        assert!(!job.terminate_sessions());
654    }
655}
656
657#[cfg(test)]
658mod generator_request_tests {
659    use bitwarden_core::Client;
660    use bitwarden_generators::GeneratorClientsExt as _;
661
662    use super::{test_support::*, *};
663
664    fn to_generator_request(
665        policy: &SealedPasswordPolicy,
666    ) -> Result<PasswordGeneratorRequest, PasswordPolicyError> {
667        VerifiedJob {
668            attempt_id: Uuid::new_v4(),
669            job_id: Uuid::new_v4(),
670            target_system_id: Uuid::new_v4(),
671            target_system_name: "test-system".to_owned(),
672            cipher_id: Uuid::new_v4(),
673            kind: SealedTargetSystemKind::CustomScript,
674            password_policy: policy.clone(),
675            account_identity: "[email protected]".to_owned(),
676            terminate_sessions: false,
677            execute_by: Utc::now(),
678        }
679        .generator_request()
680    }
681
682    fn password(req: PasswordGeneratorRequest) -> String {
683        Client::new(None)
684            .generator()
685            .password(req)
686            .expect("generate password")
687    }
688
689    #[test]
690    fn length_is_taken_from_max_length() {
691        for (min, max) in [
692            (5, 20),
693            (16, 16),
694            (5, GENERATOR_MAX_LENGTH),
695            (GENERATOR_MIN_LENGTH, GENERATOR_MIN_LENGTH),
696        ] {
697            let req = to_generator_request(&all_classes(min, max)).unwrap();
698            assert_eq!(u32::from(req.length), max, "min={min} max={max}");
699        }
700    }
701
702    #[test]
703    fn out_of_range_lengths_are_rejected() {
704        for (min, max) in [
705            (30, 20),
706            (5, GENERATOR_MAX_LENGTH + 1),
707            (GENERATOR_MIN_LENGTH - 1, 20),
708        ] {
709            assert_eq!(
710                to_generator_request(&all_classes(min, max)).unwrap_err(),
711                PasswordPolicyError::InvalidLengthBounds,
712                "min={min} max={max}"
713            );
714        }
715    }
716
717    #[test]
718    fn no_character_classes_is_rejected() {
719        let err = to_generator_request(&no_classes()).unwrap_err();
720        assert_eq!(err, PasswordPolicyError::NoCharacterClasses);
721    }
722
723    #[test]
724    fn single_class_enabled_is_sufficient() {
725        for policy in [
726            SealedPasswordPolicy {
727                include_uppercase: true,
728                ..no_classes()
729            },
730            SealedPasswordPolicy {
731                include_lowercase: true,
732                ..no_classes()
733            },
734            SealedPasswordPolicy {
735                include_digits: true,
736                ..no_classes()
737            },
738            SealedPasswordPolicy {
739                include_symbols: true,
740                ..no_classes()
741            },
742        ] {
743            assert!(to_generator_request(&policy).is_ok(), "{policy:?}");
744        }
745    }
746
747    #[test]
748    fn include_flags_map_correctly() {
749        let policy = SealedPasswordPolicy {
750            include_lowercase: false,
751            include_symbols: false,
752            ..all_classes(30, 30)
753        };
754        let req = to_generator_request(&policy).unwrap();
755        assert!(req.uppercase);
756        assert!(!req.lowercase);
757        assert!(req.numbers);
758        assert!(!req.special);
759        assert!(req.min_uppercase.is_none());
760        assert!(req.min_lowercase.is_none());
761        assert!(req.min_number.is_none());
762        assert!(req.min_special.is_none());
763        assert!(!req.avoid_ambiguous);
764        assert!(req.custom_required_chars.is_none());
765        assert!(req.custom_allowed_chars.is_none());
766        assert!(req.max_consecutive.is_none());
767    }
768
769    #[test]
770    fn generated_password_uses_only_enabled_charset() {
771        let policy = SealedPasswordPolicy {
772            include_digits: true,
773            ..no_classes()
774        };
775        let pwd = password(to_generator_request(&policy).unwrap());
776        assert!(
777            pwd.chars().all(|c| c.is_ascii_digit()),
778            "expected only digits in {pwd:?}"
779        );
780    }
781
782    #[test]
783    fn generated_password_at_maximum_length() {
784        let req = to_generator_request(&all_classes(5, GENERATOR_MAX_LENGTH)).unwrap();
785        let pwd = password(req);
786        assert_eq!(pwd.len() as u32, GENERATOR_MAX_LENGTH);
787    }
788}
789
790#[cfg(test)]
791mod parse_tests {
792    use super::*;
793
794    fn claim() -> RotationClaimResponseModel {
795        RotationClaimResponseModel {
796            attempt_id: Some(Uuid::new_v4()),
797            job_id: Some(Uuid::new_v4()),
798            source: None,
799            target_system_id: Some(Uuid::new_v4()),
800            target_system_name: Some("test-system".to_owned()),
801            target_system_sealed_data: Some("opaque-target-blob".to_owned()),
802            target_system_wrapped_key: Some("opaque-target-key".to_owned()),
803            cipher_id: Some(Uuid::new_v4()),
804            rotation_config_sealed_data: Some("opaque-config-blob".to_owned()),
805            rotation_config_wrapped_key: Some("opaque-config-key".to_owned()),
806            execute_by: Some(Utc::now().to_rfc3339()),
807        }
808    }
809
810    #[test]
811    fn converts_correctly() {
812        let snap = WorkSnapshot::try_from(claim()).expect("parse");
813        assert_eq!(
814            snap.target_system_sealed_data.as_deref(),
815            Some("opaque-target-blob")
816        );
817        assert_eq!(
818            snap.target_system_wrapped_key.as_deref(),
819            Some("opaque-target-key")
820        );
821        assert_eq!(
822            snap.rotation_config_sealed_data.as_deref(),
823            Some("opaque-config-blob")
824        );
825        assert_eq!(
826            snap.rotation_config_wrapped_key.as_deref(),
827            Some("opaque-config-key")
828        );
829    }
830
831    #[test]
832    fn missing_sealed_blobs_still_parses() {
833        let model = RotationClaimResponseModel {
834            target_system_sealed_data: None,
835            target_system_wrapped_key: None,
836            rotation_config_sealed_data: None,
837            rotation_config_wrapped_key: None,
838            ..claim()
839        };
840
841        let snap = WorkSnapshot::try_from(model).expect("parse");
842        assert!(snap.target_system_sealed_data.is_none());
843        assert!(snap.target_system_wrapped_key.is_none());
844        assert!(snap.rotation_config_sealed_data.is_none());
845        assert!(snap.rotation_config_wrapped_key.is_none());
846    }
847
848    #[test]
849    fn missing_field_is_an_error() {
850        let err = WorkSnapshot::try_from(RotationClaimResponseModel::new()).expect_err("fails");
851        assert!(matches!(err, WorkSnapshotError::MissingField(_)));
852    }
853
854    #[test]
855    fn bad_execute_by_is_an_error() {
856        let model = RotationClaimResponseModel {
857            execute_by: Some("NOT-A-DATE".to_owned()),
858            ..claim()
859        };
860
861        let err = WorkSnapshot::try_from(model).expect_err("fails");
862        assert!(matches!(err, WorkSnapshotError::InvalidExecuteBy));
863    }
864}