Skip to main content

bitwarden_shared_unlock/
message.rs

1//! The protocol's single message, and the timestamped lock state it carries.
2
3use bitwarden_core::UserId;
4use bitwarden_ipc::PayloadTypeName;
5use serde::{Deserialize, Serialize};
6
7use crate::LockState;
8
9/// A user's lock state, together with when the reporting device entered it.
10#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
11pub struct TimestampedLockState {
12    /// What lock state the device recorded
13    pub lock_state: LockState,
14    /// Milliseconds since the Unix epoch at which the device entered `lock_state`.
15    pub changed_at: u64,
16}
17
18impl Default for TimestampedLockState {
19    /// The state a peer advertises for a user it has not observed a transition for. The zero date
20    /// loses every comparison, so this can never overwrite another peer; it exists so the receiving
21    /// peer learns this one is alive and adds it to its active-peer map.
22    fn default() -> Self {
23        Self {
24            lock_state: LockState::Locked,
25            changed_at: 0,
26        }
27    }
28}
29
30impl TimestampedLockState {
31    /// Whether this state, as reported by a peer, supersedes what the receiving device has
32    /// recorded.
33    ///
34    /// A user the receiver has recorded nothing for counts as date `0`, so a peer's default state
35    /// is ignored rather than mistaken for an authoritative lock.
36    ///
37    /// Equal dates are ambiguous: two devices acting inside the same millisecond, neither having
38    /// seen the other yet. Those are broken toward `Locked`.
39    pub(crate) fn supersedes(&self, recorded: Option<&TimestampedLockState>) -> bool {
40        let recorded_at = recorded.map_or(0, |recorded| recorded.changed_at);
41        match self.changed_at.cmp(&recorded_at) {
42            std::cmp::Ordering::Greater => true,
43            std::cmp::Ordering::Less => false,
44            std::cmp::Ordering::Equal => {
45                matches!(self.lock_state, LockState::Locked)
46                    && matches!(
47                        recorded.map(|recorded| &recorded.lock_state),
48                        Some(LockState::Unlocked { .. })
49                    )
50            }
51        }
52    }
53}
54
55/// The only message in the protocol, sent in both directions.
56#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
57pub struct SharedUnlockSync {
58    /// User whose lock state is being synchronized.
59    pub user_id: UserId,
60    /// The sending device's lock state for that user.
61    pub state: TimestampedLockState,
62}
63
64impl PayloadTypeName for SharedUnlockSync {
65    const PAYLOAD_TYPE_NAME: &'static str = "password-manager.shared-unlock.sync";
66}
67#[cfg(test)]
68mod tests {
69    use bitwarden_crypto::SymmetricCryptoKey;
70    use bitwarden_encoding::B64;
71
72    use super::*;
73
74    fn key() -> SymmetricCryptoKey {
75        SymmetricCryptoKey::try_from(B64::from([1u8; 64].to_vec()))
76            .expect("A 64-byte key should be valid")
77    }
78
79    fn locked_at(changed_at: u64) -> TimestampedLockState {
80        TimestampedLockState {
81            lock_state: LockState::Locked,
82            changed_at,
83        }
84    }
85
86    fn unlocked_at(changed_at: u64) -> TimestampedLockState {
87        TimestampedLockState {
88            lock_state: LockState::Unlocked { user_key: key() },
89            changed_at,
90        }
91    }
92
93    #[test]
94    fn a_newer_date_supersedes() {
95        assert!(unlocked_at(2).supersedes(Some(&locked_at(1))));
96    }
97
98    #[test]
99    fn an_older_date_does_not() {
100        assert!(!unlocked_at(1).supersedes(Some(&locked_at(2))));
101    }
102
103    #[test]
104    fn nothing_recorded_counts_as_date_zero() {
105        assert!(unlocked_at(1).supersedes(None));
106        assert!(
107            !locked_at(0).supersedes(None),
108            "The default advertisement must not read as an authoritative lock"
109        );
110    }
111
112    #[test]
113    fn an_equal_date_resolves_toward_locked() {
114        assert!(
115            locked_at(5).supersedes(Some(&unlocked_at(5))),
116            "A tie must fail closed"
117        );
118        assert!(
119            !unlocked_at(5).supersedes(Some(&locked_at(5))),
120            "and must resolve the same way seen from the other side"
121        );
122    }
123
124    #[test]
125    fn an_equal_date_with_the_same_state_is_a_no_op() {
126        assert!(!locked_at(5).supersedes(Some(&locked_at(5))));
127        assert!(!unlocked_at(5).supersedes(Some(&unlocked_at(5))));
128    }
129}