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#[async_trait]
20pub(super) trait PasswordWriter: Send {
21 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#[derive(Debug, Default)]
34pub(super) struct UnsettledWrites(Mutex<HashMap<Uuid, DateTime<Utc>>>);
35
36impl UnsettledWrites {
37 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
59pub(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
87fn 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
99fn 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 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 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, b'A', 0x00, b'b', 0x00, b'1', 0x00, b'!', 0x00, 0x22, 0x00, ]
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 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 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 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}