Skip to main content

bitwarden_importers/importers/onepassword/access/
device.rs

1//! The device identity presented to 1Password and its registration.
2
3use rand::Rng;
4use serde_json::{Value, json};
5
6use super::{
7    error::OnePasswordError,
8    identity::{HTTP_LIB, PLATFORM, VERSION},
9    rest::RestClient,
10    wire::SuccessStatus,
11};
12
13const BASE32_ALPHABET: &[u8; 32] = b"abcdefghijklmnopqrstuvwxyz234567";
14const DEVICE_UUID_LENGTH: usize = 26;
15const DEVICE_ENDPOINT: &str = "v1/device";
16
17/// Generates a 26-character 1Password device id from the lowercase base32 alphabet.
18///
19/// A fresh id per import is expected: the login registers it with the account and nothing uses it
20/// afterwards.
21pub fn generate_device_uuid() -> String {
22    let mut rng = bitwarden_random::rng();
23    (0..DEVICE_UUID_LENGTH)
24        .map(|_| BASE32_ALPHABET[(rng.next_u32() % 32) as usize] as char)
25        .collect()
26}
27
28/// Client identity headers sent with every request.
29pub(super) struct ClientInfo {
30    client_name: String,
31    client_version: String,
32    pub user_agent: String,
33    pub op_user_agent: String,
34    pub device_uuid: String,
35}
36
37impl ClientInfo {
38    /// Impersonates the 1Password desktop client for the current platform.
39    pub(super) fn for_desktop(device_uuid: &str) -> ClientInfo {
40        let platform = PLATFORM;
41
42        ClientInfo {
43            client_name: format!("1Password for {}", platform.os),
44            client_version: VERSION.to_string(),
45            user_agent: format!("1Password for {}/{VERSION}", platform.os),
46            op_user_agent: format!(
47                "1|{}|{VERSION}|{device_uuid}|||{HTTP_LIB}|{}",
48                platform.op_code, platform.os_suffix
49            ),
50            device_uuid: device_uuid.to_string(),
51        }
52    }
53
54    pub(super) fn client_id(&self) -> String {
55        format!("{}/{}", self.client_name, self.client_version)
56    }
57
58    /// The device descriptor sent to `v1/device` and inside `v2/auth/complete`.
59    ///
60    /// The real 1Password clients also send `model` and `osVersion`. The server accepted their
61    /// removal when this was tested, so they are left out, but add them back if it starts
62    /// rejecting the request.
63    pub(super) fn device_body(&self) -> Value {
64        json!({
65            "uuid": self.device_uuid,
66            "clientName": self.client_name,
67            "clientVersion": self.client_version,
68            // Shown in the account's device list, so it names us rather than a 1Password client.
69            "name": "Bitwarden",
70            "osName": PLATFORM.os_name,
71            "userAgent": self.user_agent,
72        })
73    }
74}
75
76/// Registers the device with the server.
77pub(super) async fn register_device(
78    client_info: &ClientInfo,
79    rest: &RestClient,
80) -> Result<(), OnePasswordError> {
81    let response: SuccessStatus = rest
82        .post_json(DEVICE_ENDPOINT, client_info.device_body())
83        .await?;
84    check_success(response, "register", client_info)
85}
86
87/// Reauthorizes a previously deleted device.
88pub(super) async fn reauthorize_device(
89    client_info: &ClientInfo,
90    rest: &RestClient,
91) -> Result<(), OnePasswordError> {
92    let response: SuccessStatus = rest
93        .put(&format!(
94            "{DEVICE_ENDPOINT}/{}/reauthorize",
95            client_info.device_uuid
96        ))
97        .await?;
98    check_success(response, "reauthorize", client_info)
99}
100
101fn check_success(
102    response: SuccessStatus,
103    action: &str,
104    client_info: &ClientInfo,
105) -> Result<(), OnePasswordError> {
106    if response.success != 1 {
107        return Err(OnePasswordError::Internal(format!(
108            "failed to {action} the device '{}'",
109            client_info.device_uuid
110        )));
111    }
112    Ok(())
113}
114
115#[cfg(test)]
116mod tests {
117    use bitwarden_api_base::new_http_client;
118    use wiremock::{Mock, MockServer, ResponseTemplate, matchers};
119
120    use super::*;
121
122    fn client(server: &MockServer) -> RestClient {
123        let info = ClientInfo::for_desktop("device-uuid");
124        RestClient::new(
125            new_http_client(),
126            format!("http://{}/api", server.address()),
127            &info.client_id(),
128            &info.user_agent,
129            &info.op_user_agent,
130        )
131        .expect("valid headers")
132    }
133
134    #[test]
135    fn generated_uuid_has_expected_shape() {
136        let uuid = generate_device_uuid();
137        assert_eq!(uuid.len(), DEVICE_UUID_LENGTH);
138        assert!(uuid.bytes().all(|b| BASE32_ALPHABET.contains(&b)));
139        assert_ne!(uuid, generate_device_uuid());
140    }
141
142    #[test]
143    fn client_info_builds_identity_headers() {
144        let info = ClientInfo::for_desktop("device-uuid");
145        assert_eq!(info.client_id(), format!("{}/81210036", info.client_name));
146        assert!(info.op_user_agent.contains("device-uuid"));
147        assert!(info.user_agent.starts_with("1Password for "));
148    }
149
150    #[test]
151    fn device_body_carries_the_device_descriptor() {
152        let info = ClientInfo::for_desktop("device-uuid");
153        let body = info.device_body();
154        assert_eq!(body["uuid"], "device-uuid");
155        assert_eq!(body["clientVersion"], "81210036");
156        assert_eq!(body["osName"], PLATFORM.os_name);
157        assert_eq!(body["clientName"], format!("1Password for {}", PLATFORM.os));
158    }
159
160    #[tokio::test]
161    async fn registers_and_reauthorizes_the_device() {
162        let server = MockServer::start().await;
163        server
164            .register(
165                Mock::given(matchers::path("/api/v1/device"))
166                    .and(matchers::method("POST"))
167                    .and(matchers::body_partial_json(json!({"uuid": "device-uuid"})))
168                    .respond_with(ResponseTemplate::new(200).set_body_json(json!({"success": 1})))
169                    .expect(1),
170            )
171            .await;
172        server
173            .register(
174                Mock::given(matchers::path("/api/v1/device/device-uuid/reauthorize"))
175                    .and(matchers::method("PUT"))
176                    .respond_with(ResponseTemplate::new(200).set_body_json(json!({"success": 1})))
177                    .expect(1),
178            )
179            .await;
180
181        let rest = client(&server);
182        let info = ClientInfo::for_desktop("device-uuid");
183        register_device(&info, &rest).await.expect("registers");
184        reauthorize_device(&info, &rest)
185            .await
186            .expect("reauthorizes");
187
188        server.verify().await;
189    }
190
191    #[tokio::test]
192    async fn reports_a_failed_registration() {
193        let server = MockServer::start().await;
194        server
195            .register(
196                Mock::given(matchers::path("/api/v1/device"))
197                    .respond_with(ResponseTemplate::new(200).set_body_json(json!({"success": 0})))
198                    .expect(1),
199            )
200            .await;
201
202        let error = register_device(&ClientInfo::for_desktop("device-uuid"), &client(&server))
203            .await
204            .expect_err("registration is rejected");
205
206        assert!(error.to_string().contains("failed to register the device"));
207        server.verify().await;
208    }
209}