Skip to main content

bitwarden_access_connector/integrations/active_directory/
password_write.rs

1use std::{
2    collections::HashMap,
3    sync::{Mutex, MutexGuard, PoisonError},
4};
5
6use async_trait::async_trait;
7use chrono::{DateTime, Utc};
8use ldap3::{LdapError, result::LdapResult};
9use uuid::Uuid;
10
11use super::classify::{Phase, RC_INVALID_CREDENTIALS, classify_ldap_error};
12use crate::{
13    error::Retry,
14    integrations::{IntegrationError, RotateContext, TargetEffect},
15};
16
17/// The directory operations the write needs, so [`apply_password_write`] can be tested without a
18/// domain controller.
19#[async_trait]
20pub(super) trait PasswordWriter: Send {
21    /// A directory rejection comes back in the [`LdapResult`]; `Err` means there was no answer.
22    async fn replace_password(
23        &mut self,
24        account_dn: &str,
25        encoded: &[u8],
26    ) -> Result<LdapResult, LdapError>;
27
28    async fn bind_as_account(&mut self, account_dn: &str, password: &str) -> Result<(), LdapError>;
29}
30
31/// Targets whose last write got no answer, mapped to the start of the attempt that sent it, so a
32/// later attempt doesn't inherit the entry.
33#[derive(Debug, Default)]
34pub(super) struct UnsettledWrites(Mutex<HashMap<Uuid, DateTime<Utc>>>);
35
36impl UnsettledWrites {
37    /// Whether an earlier write in this attempt may have landed without an answer.
38    pub(super) fn contains(&self, ctx: &RotateContext) -> bool {
39        self.lock().get(&ctx.target_system_id) == Some(&ctx.rotation_started_at)
40    }
41
42    pub(super) fn record(&self, ctx: &RotateContext, outcome: &Result<(), IntegrationError>) {
43        let mut writes = self.lock();
44        match outcome {
45            Err(err) if err.effect == TargetEffect::Unknown => {
46                writes.insert(ctx.target_system_id, ctx.rotation_started_at);
47            }
48            _ => {
49                writes.remove(&ctx.target_system_id);
50            }
51        }
52    }
53
54    fn lock(&self) -> MutexGuard<'_, HashMap<Uuid, DateTime<Utc>>> {
55        self.0.lock().unwrap_or_else(PoisonError::into_inner)
56    }
57}
58
59/// A permanent rejection means the write didn't land, unless an earlier write in this attempt got
60/// no answer and may have. Only then is the effect settled by binding as the account with the new
61/// password, since every failed bind counts towards locking the account out.
62pub(super) async fn apply_password_write(
63    writer: &mut impl PasswordWriter,
64    account_dn: &str,
65    new_password: &str,
66    earlier_write_unsettled: bool,
67) -> Result<(), IntegrationError> {
68    let answer = writer
69        .replace_password(account_dn, &encode_unicode_pwd(new_password))
70        .await
71        .map_err(|e| classify_ldap_error(&e, Phase::Write))?;
72    let Err(rejection) = answer.success() else {
73        return Ok(());
74    };
75
76    let mut err = classify_ldap_error(&rejection, Phase::Write);
77    err.effect = match (err.retry, earlier_write_unsettled) {
78        (Retry::Permanent, true) => {
79            effect_from_probe(&writer.bind_as_account(account_dn, new_password).await)
80        }
81        (Retry::Permanent, false) => TargetEffect::NotApplied,
82        (Retry::Transient, _) => TargetEffect::Unknown,
83    };
84    Err(err)
85}
86
87/// Only `invalidCredentials` proves the write didn't land; any other answer says nothing. A locked
88/// out or disabled account also answers `invalidCredentials`, so it reads as not applied.
89fn effect_from_probe(probe: &Result<(), LdapError>) -> TargetEffect {
90    match probe {
91        Ok(()) => TargetEffect::Applied,
92        Err(LdapError::LdapResult { result }) if result.rc == RC_INVALID_CREDENTIALS => {
93            TargetEffect::NotApplied
94        }
95        Err(_) => TargetEffect::Unknown,
96    }
97}
98
99/// The `unicodePwd` value: the password in double quotes, as UTF-16LE with no byte-order mark.
100fn encode_unicode_pwd(password: &str) -> Vec<u8> {
101    const QUOTE: u16 = b'"' as u16;
102
103    let mut buf = Vec::with_capacity(password.len() * 2 + 4);
104    for unit in std::iter::once(QUOTE)
105        .chain(password.encode_utf16())
106        .chain(std::iter::once(QUOTE))
107    {
108        buf.extend_from_slice(&unit.to_le_bytes());
109    }
110    buf
111}
112
113#[cfg(test)]
114mod tests {
115    use bitwarden_pam::RotationFailureCode;
116
117    use super::*;
118    use crate::{
119        error::SafeDetail,
120        integrations::active_directory::test_support::{SENTINEL_PASSWORD, ldap_result},
121        resolver::ResolvedCredentials,
122    };
123
124    const ACCOUNT_DN: &str = "CN=svc-app,OU=Managed,DC=corp,DC=example";
125
126    /// Answers from a script and records each call, so a test can also assert whether the probe
127    /// was issued.
128    struct FakeDirectory {
129        write: Option<Result<LdapResult, LdapError>>,
130        probe: Option<Result<(), LdapError>>,
131        written: Vec<(String, Vec<u8>)>,
132        binds: Vec<(String, String)>,
133    }
134
135    impl FakeDirectory {
136        /// Any probe fails the test unless [`Self::probed_with`] scripts one.
137        fn answering(write: Result<LdapResult, LdapError>) -> Self {
138            Self {
139                write: Some(write),
140                probe: None,
141                written: Vec::new(),
142                binds: Vec::new(),
143            }
144        }
145
146        fn probed_with(mut self, probe: Result<(), LdapError>) -> Self {
147            self.probe = Some(probe);
148            self
149        }
150    }
151
152    #[async_trait]
153    impl PasswordWriter for FakeDirectory {
154        async fn replace_password(
155            &mut self,
156            account_dn: &str,
157            encoded: &[u8],
158        ) -> Result<LdapResult, LdapError> {
159            self.written
160                .push((account_dn.to_string(), encoded.to_vec()));
161            self.write
162                .take()
163                .expect("the write must be asked for exactly once")
164        }
165
166        async fn bind_as_account(
167            &mut self,
168            account_dn: &str,
169            password: &str,
170        ) -> Result<(), LdapError> {
171            self.binds
172                .push((account_dn.to_string(), password.to_string()));
173            self.probe
174                .take()
175                .expect("the probe was issued without a scripted answer")
176        }
177    }
178
179    #[test]
180    fn unicode_pwd_is_quoted_utf16le() {
181        let encoded = encode_unicode_pwd("Ab1!");
182        assert_eq!(
183            encoded.as_slice(),
184            &[
185                0x22, 0x00, // "
186                b'A', 0x00, b'b', 0x00, b'1', 0x00, b'!', 0x00, //
187                0x22, 0x00, // "
188            ]
189        );
190    }
191
192    #[test]
193    fn unicode_pwd_empty_password_is_two_quotes() {
194        let encoded = encode_unicode_pwd("");
195        assert_eq!(encoded.as_slice(), &[0x22, 0x00, 0x22, 0x00]);
196    }
197
198    #[test]
199    fn unicode_pwd_encodes_non_bmp_as_surrogate_pair() {
200        // U+1F510 CLOSED LOCK WITH KEY → surrogate pair D83D DD10, little-endian.
201        let encoded = encode_unicode_pwd("\u{1F510}");
202        assert_eq!(
203            encoded.as_slice(),
204            &[0x22, 0x00, 0x3D, 0xD8, 0x10, 0xDD, 0x22, 0x00]
205        );
206    }
207
208    #[test]
209    fn unicode_pwd_has_no_byte_order_mark() {
210        let encoded = encode_unicode_pwd("pw");
211        assert_ne!(&encoded[0..2], &[0xFF, 0xFE]);
212        assert_ne!(&encoded[0..2], &[0xFE, 0xFF]);
213    }
214
215    #[test]
216    fn unicode_pwd_never_contains_the_plain_utf8_password() {
217        let encoded = encode_unicode_pwd(SENTINEL_PASSWORD);
218        assert!(
219            encoded
220                .windows(SENTINEL_PASSWORD.len())
221                .all(|w| w != SENTINEL_PASSWORD.as_bytes()),
222            "password must not appear as plain UTF-8"
223        );
224    }
225
226    #[test]
227    fn a_rebind_that_succeeds_proves_the_rejected_write_had_landed() {
228        assert_eq!(effect_from_probe(&Ok(())), TargetEffect::Applied);
229    }
230
231    #[test]
232    fn a_rebind_answered_with_invalid_credentials_proves_the_write_did_not_land() {
233        let probe = Err(LdapError::LdapResult {
234            result: ldap_result(49, "invalid credentials"),
235        });
236        assert_eq!(effect_from_probe(&probe), TargetEffect::NotApplied);
237    }
238
239    #[test]
240    fn a_rebind_the_directory_declined_to_answer_leaves_the_effect_unknown() {
241        for rc in [1, 11, 51, 52, 53] {
242            let probe = Err(LdapError::LdapResult {
243                result: ldap_result(rc, ""),
244            });
245            assert_eq!(
246                effect_from_probe(&probe),
247                TargetEffect::Unknown,
248                "rc {rc} says nothing about whether the new password is live"
249            );
250        }
251    }
252
253    #[test]
254    fn a_rebind_that_cannot_reach_the_directory_leaves_the_effect_unknown() {
255        let probe = Err(LdapError::Io {
256            source: std::io::Error::from(std::io::ErrorKind::ConnectionReset),
257        });
258        assert_eq!(effect_from_probe(&probe), TargetEffect::Unknown);
259    }
260
261    #[tokio::test]
262    async fn an_accepted_write_succeeds_and_costs_no_bind_even_after_an_unanswered_one() {
263        let mut directory = FakeDirectory::answering(Ok(ldap_result(0, "")));
264        apply_password_write(&mut directory, ACCOUNT_DN, SENTINEL_PASSWORD, true)
265            .await
266            .expect("a write the directory accepted must succeed");
267        assert!(
268            directory.binds.is_empty(),
269            "an accepted write settles itself; probing it would spend a bind for nothing"
270        );
271    }
272
273    #[tokio::test]
274    async fn the_write_sends_the_encoded_password_to_the_located_account() {
275        let mut directory = FakeDirectory::answering(Ok(ldap_result(0, "")));
276        apply_password_write(&mut directory, ACCOUNT_DN, SENTINEL_PASSWORD, false)
277            .await
278            .expect("a write the directory accepted must succeed");
279
280        let (dn, value) = directory.written.first().expect("the write must be sent");
281        assert_eq!(dn, ACCOUNT_DN, "the write must target the located account");
282        assert_eq!(
283            value.as_slice(),
284            encode_unicode_pwd(SENTINEL_PASSWORD).as_slice(),
285            "the value must be the unicodePwd encoding, not the raw password"
286        );
287        assert!(
288            value
289                .windows(SENTINEL_PASSWORD.len())
290                .all(|w| w != SENTINEL_PASSWORD.as_bytes()),
291            "the plain password must not be what goes on the wire"
292        );
293    }
294
295    #[tokio::test]
296    async fn a_rejection_after_an_unanswered_write_is_probed_as_the_account_with_the_new_password()
297    {
298        let mut directory = FakeDirectory::answering(Ok(ldap_result(19, "constraint violation")))
299            .probed_with(Ok(()));
300
301        let err = apply_password_write(&mut directory, ACCOUNT_DN, SENTINEL_PASSWORD, true)
302            .await
303            .expect_err("a rejected write must not be reported as success");
304
305        assert_eq!(
306            directory.binds,
307            vec![(ACCOUNT_DN.to_string(), SENTINEL_PASSWORD.to_string())],
308            "the probe must rebind once, as the rotated account, with the new password"
309        );
310        assert_eq!(
311            err.effect,
312            TargetEffect::Applied,
313            "a probe the directory accepted proves the rejected write had landed"
314        );
315        assert_eq!(err.retry, Retry::Permanent);
316        assert_eq!(err.code, RotationFailureCode::TargetRejected);
317    }
318
319    #[tokio::test]
320    async fn a_permanent_rejection_with_no_unanswered_write_before_it_costs_no_bind() {
321        // constraintViolation, insufficientAccessRights, unwillingToPerform.
322        for rc in [19, 50, 53] {
323            let mut directory = FakeDirectory::answering(Ok(ldap_result(rc, "")));
324
325            let err = apply_password_write(&mut directory, ACCOUNT_DN, SENTINEL_PASSWORD, false)
326                .await
327                .expect_err("a rejected write must not be reported as success");
328
329            assert!(
330                directory.binds.is_empty(),
331                "rc {rc} repeats on every attempt, so probing it would lock the account out"
332            );
333            assert_eq!(err.effect, TargetEffect::NotApplied, "rc {rc}");
334            assert_eq!(err.retry, Retry::Permanent, "rc {rc}");
335        }
336    }
337
338    #[tokio::test]
339    async fn a_probe_refused_with_invalid_credentials_is_reported_not_applied() {
340        let mut directory = FakeDirectory::answering(Ok(ldap_result(19, ""))).probed_with(Err(
341            LdapError::LdapResult {
342                result: ldap_result(RC_INVALID_CREDENTIALS, ""),
343            },
344        ));
345
346        let err = apply_password_write(&mut directory, ACCOUNT_DN, SENTINEL_PASSWORD, true)
347            .await
348            .expect_err("a rejected write must not be reported as success");
349
350        assert_eq!(directory.binds.len(), 1);
351        assert_eq!(err.effect, TargetEffect::NotApplied);
352    }
353
354    #[tokio::test]
355    async fn a_probe_that_cannot_reach_the_directory_is_reported_unknown() {
356        let mut directory =
357            FakeDirectory::answering(Ok(ldap_result(19, ""))).probed_with(Err(LdapError::Io {
358                source: std::io::Error::from(std::io::ErrorKind::ConnectionReset),
359            }));
360
361        let err = apply_password_write(&mut directory, ACCOUNT_DN, SENTINEL_PASSWORD, true)
362            .await
363            .expect_err("a rejected write must not be reported as success");
364
365        assert_eq!(directory.binds.len(), 1);
366        assert_eq!(err.effect, TargetEffect::Unknown);
367    }
368
369    #[tokio::test]
370    async fn a_transiently_rejected_write_is_never_probed() {
371        // adminLimitExceeded, busy, unavailable.
372        for rc in [11, 51, 52] {
373            let mut directory = FakeDirectory::answering(Ok(ldap_result(rc, "")));
374
375            let err = apply_password_write(&mut directory, ACCOUNT_DN, SENTINEL_PASSWORD, true)
376                .await
377                .expect_err("a rejected write must not be reported as success");
378
379            assert!(
380                directory.binds.is_empty(),
381                "rc {rc} is retried, so probing it would spend a failed bind against \
382                 the managed account on every attempt"
383            );
384            assert_eq!(err.retry, Retry::Transient, "rc {rc}");
385            assert_eq!(err.effect, TargetEffect::Unknown, "rc {rc}");
386        }
387    }
388
389    #[tokio::test]
390    async fn a_write_that_got_no_answer_is_never_probed_and_stays_unknown() {
391        let mut directory = FakeDirectory::answering(Err(LdapError::Io {
392            source: std::io::Error::from(std::io::ErrorKind::ConnectionReset),
393        }));
394
395        let err = apply_password_write(&mut directory, ACCOUNT_DN, SENTINEL_PASSWORD, true)
396            .await
397            .expect_err("a write that got no answer must not be reported as success");
398
399        assert!(
400            directory.binds.is_empty(),
401            "the connection carrying the probe is the one that just failed"
402        );
403        assert_eq!(err.effect, TargetEffect::Unknown);
404        assert_eq!(err.retry, Retry::Transient);
405    }
406
407    #[tokio::test]
408    async fn a_rejected_write_reports_neither_the_password_nor_directory_text() {
409        let mut directory = FakeDirectory::answering(Ok(ldap_result(
410            53,
411            &format!("will not perform for {SENTINEL_PASSWORD}"),
412        )))
413        .probed_with(Ok(()));
414
415        let err = apply_password_write(&mut directory, ACCOUNT_DN, SENTINEL_PASSWORD, true)
416            .await
417            .expect_err("a rejected write must not be reported as success");
418
419        let rendered = err.to_string();
420        assert!(
421            !rendered.contains(SENTINEL_PASSWORD),
422            "password leaked: {rendered}"
423        );
424        assert!(
425            !rendered.contains("OU=Service Accounts"),
426            "matched DN leaked: {rendered}"
427        );
428        assert_eq!(err.detail.as_str(), "LDAP result code 53");
429    }
430
431    fn attempt(target_system_id: Uuid, rotation_started_at: DateTime<Utc>) -> RotateContext {
432        RotateContext {
433            target_system_id,
434            account_identity: "svc-app".to_string(),
435            new_password: SENTINEL_PASSWORD.to_string(),
436            creds: ResolvedCredentials::new(),
437            rotation_started_at,
438        }
439    }
440
441    fn failure(effect: TargetEffect) -> Result<(), IntegrationError> {
442        Err(IntegrationError::new(
443            Retry::Transient,
444            effect,
445            RotationFailureCode::TargetUnreachable,
446            SafeDetail::from_kind("ConnectionClosed"),
447        ))
448    }
449
450    #[test]
451    fn an_unanswered_write_is_remembered_for_its_own_attempt_only() {
452        let writes = UnsettledWrites::default();
453        let target = Uuid::new_v4();
454        let first = attempt(target, Utc::now());
455        let later = attempt(
456            target,
457            first.rotation_started_at + chrono::Duration::seconds(60),
458        );
459
460        writes.record(&first, &failure(TargetEffect::Unknown));
461
462        assert!(writes.contains(&first));
463        assert!(
464            !writes.contains(&later),
465            "a later attempt sends a fresh password, which the earlier write can't have set"
466        );
467        assert!(!writes.contains(&attempt(Uuid::new_v4(), first.rotation_started_at)));
468    }
469
470    #[test]
471    fn a_settled_outcome_clears_the_entry() {
472        for settled in [
473            Ok(()),
474            failure(TargetEffect::NotApplied),
475            failure(TargetEffect::Applied),
476        ] {
477            let writes = UnsettledWrites::default();
478            let ctx = attempt(Uuid::new_v4(), Utc::now());
479
480            writes.record(&ctx, &failure(TargetEffect::Unknown));
481            writes.record(&ctx, &settled);
482
483            assert!(!writes.contains(&ctx), "{settled:?}");
484        }
485    }
486}