1
// Backward compatibility interface for GNOME Keyring.
2
// This allows creating/unlocking collections without user prompts.
3

            
4
use oo7::{
5
    Secret,
6
    dbus::{
7
        ServiceError,
8
        api::{DBusSecret, DBusSecretInner, Properties},
9
    },
10
    file::Keyring,
11
};
12
use zbus::zvariant::{ObjectPath, OwnedObjectPath, OwnedValue};
13

            
14
use crate::{
15
    error::custom_service_error,
16
    prompt::{Prompt, PromptAction, PromptRole},
17
    service::Service,
18
};
19

            
20
pub const INTERNAL_INTERFACE_PATH: &str =
21
    "/org/gnome/keyring/InternalUnsupportedGuiltRiddenInterface";
22

            
23
#[derive(Clone)]
24
pub struct InternalInterface {
25
    service: Service,
26
}
27

            
28
impl InternalInterface {
29
31
    pub fn new(service: Service) -> Self {
30
        Self { service }
31
    }
32

            
33
16
    async fn decrypt_secret(&self, secret: DBusSecretInner) -> Result<oo7::Secret, ServiceError> {
34
4
        let session_path = &secret.0;
35

            
36
8
        let Some(session) = self.service.session(session_path).await else {
37
            return Err(ServiceError::NoSession(format!(
38
                "The session `{session_path}` does not exist."
39
            )));
40
        };
41

            
42
24
        let secret = DBusSecret::from_inner(self.service.connection(), secret)
43
12
            .await
44
4
            .map_err(|err| {
45
                custom_service_error(&format!("Failed to create session object {err}"))
46
            })?;
47

            
48
        secret
49
12
            .decrypt(session.aes_key().as_ref())
50
4
            .map_err(|err| custom_service_error(&format!("Failed to decrypt secret {err}")))
51
    }
52
}
53

            
54
#[zbus::interface(name = "org.gnome.keyring.InternalUnsupportedGuiltRiddenInterface")]
55
impl InternalInterface {
56
    /// Create a collection with a master password without prompting the user.
57
    #[zbus(name = "CreateWithMasterPassword")]
58
4
    async fn create_with_master_password(
59
        &self,
60
        properties: Properties,
61
        master: DBusSecretInner,
62
    ) -> Result<OwnedObjectPath, ServiceError> {
63
8
        let label = properties.label().to_owned();
64
8
        let secret = self.decrypt_secret(master).await?;
65

            
66
16
        let collection_path = self
67
            .service
68
8
            .create_collection_with_secret(&label, "", secret)
69
16
            .await?;
70

            
71
10
        tracing::info!(
72
            "Collection `{}` created with label '{}' via InternalUnsupportedGuiltRiddenInterface",
73
            collection_path,
74
            label
75
        );
76

            
77
4
        Ok(collection_path)
78
    }
79

            
80
    /// Unlock a collection with a master password.
81
    #[zbus(name = "UnlockWithMasterPassword")]
82
4
    async fn unlock_with_master_password(
83
        &self,
84
        collection: ObjectPath<'_>,
85
        master: DBusSecretInner,
86
    ) -> Result<(), ServiceError> {
87
8
        let secret = self.decrypt_secret(master).await?;
88

            
89
20
        let collection_obj = self
90
            .service
91
4
            .collection_from_path(&collection)
92
12
            .await
93
8
            .ok_or_else(|| ServiceError::NoSuchObject(collection.to_string()))?;
94

            
95
8
        collection_obj.set_locked(false, Some(secret)).await?;
96

            
97
8
        tracing::info!(
98
            "Collection `{}` unlocked via InternalUnsupportedGuiltRiddenInterface",
99
            collection
100
        );
101

            
102
4
        Ok(())
103
    }
104

            
105
    /// Change collection password with a master password.
106
    #[zbus(name = "ChangeWithMasterPassword")]
107
4
    async fn change_with_master_password(
108
        &self,
109
        collection: ObjectPath<'_>,
110
        original: DBusSecretInner,
111
        master: DBusSecretInner,
112
    ) -> Result<(), ServiceError> {
113
8
        let original_secret = self.decrypt_secret(original).await?;
114
8
        let new_secret = self.decrypt_secret(master).await?;
115

            
116
20
        let collection_obj = self
117
            .service
118
4
            .collection_from_path(&collection)
119
12
            .await
120
8
            .ok_or_else(|| ServiceError::NoSuchObject(collection.to_string()))?;
121

            
122
12
        collection_obj
123
4
            .set_locked(false, Some(original_secret))
124
12
            .await?;
125

            
126
4
        let keyring_guard = collection_obj.keyring.read().await;
127
12
        if let Some(Keyring::Unlocked(unlocked)) = keyring_guard.as_ref() {
128
16
            unlocked
129
4
                .change_secret(new_secret)
130
16
                .await
131
4
                .map_err(|err| custom_service_error(&format!("Failed to change secret: {err}")))?;
132
        } else {
133
            return Err(custom_service_error("Collection is not unlocked"));
134
        }
135

            
136
8
        tracing::info!(
137
            "Collection `{}` password changed via InternalUnsupportedGuiltRiddenInterface",
138
            collection
139
        );
140

            
141
4
        Ok(())
142
    }
143

            
144
    /// Change collection password with a prompt.
145
    #[zbus(name = "ChangeWithPrompt")]
146
4
    async fn change_with_prompt(
147
        &self,
148
        collection: ObjectPath<'_>,
149
    ) -> Result<OwnedObjectPath, ServiceError> {
150
20
        let collection_obj = self
151
            .service
152
4
            .collection_from_path(&collection)
153
12
            .await
154
8
            .ok_or_else(|| ServiceError::NoSuchObject(collection.to_string()))?;
155

            
156
8
        let label = collection_obj.label().await;
157

            
158
        let prompt = Prompt::new(
159
4
            self.service.clone(),
160
            PromptRole::ChangePassword,
161
4
            label,
162
4
            None,
163
        )
164
12
        .await;
165
8
        let prompt_path: OwnedObjectPath = prompt.path().to_owned().into();
166

            
167
8
        let service = self.service.clone();
168
4
        let collection_path = collection.to_owned();
169
8
        let action = PromptAction::new(move |new_secret: Secret| {
170
4
            let service = service.clone();
171
4
            let collection_path = collection_path.clone();
172
14
            async move {
173
20
                let collection = service
174
4
                    .collection_from_path(&collection_path)
175
12
                    .await
176
8
                    .ok_or_else(|| ServiceError::NoSuchObject(collection_path.to_string()))?;
177

            
178
8
                let keyring_guard = collection.keyring.read().await;
179
10
                if let Some(Keyring::Unlocked(unlocked)) = keyring_guard.as_ref() {
180
4
                    unlocked.change_secret(new_secret).await.map_err(|err| {
181
                        custom_service_error(&format!("Failed to change secret: {err}"))
182
                    })?;
183
                } else {
184
4
                    return Err(custom_service_error(
185
                        "Collection must be unlocked to change password",
186
                    ));
187
                }
188

            
189
2
                tracing::info!(
190
                    "Collection `{}` password changed via prompt",
191
                    collection_path
192
                );
193

            
194
4
                Ok(OwnedValue::from(ObjectPath::from_str_unchecked("/")))
195
            }
196
        });
197

            
198
4
        prompt.set_action(action).await;
199

            
200
16
        self.service
201
            .object_server()
202
4
            .at(prompt.path(), prompt.clone())
203
12
            .await?;
204

            
205
8
        self.service
206
4
            .register_prompt(prompt_path.clone(), prompt)
207
8
            .await;
208

            
209
8
        tracing::info!(
210
            "Created password change prompt for collection `{}`",
211
            collection
212
        );
213

            
214
4
        Ok(prompt_path)
215
    }
216
}
217

            
218
#[cfg(test)]
219
mod tests {
220
    use oo7::{Secret, dbus};
221
    use zbus::zvariant::{ObjectPath, OwnedObjectPath};
222

            
223
    use crate::tests::TestServiceSetup;
224

            
225
    /// Proxy for the InternalUnsupportedGuiltRiddenInterface
226
    #[zbus::proxy(
227
        interface = "org.gnome.keyring.InternalUnsupportedGuiltRiddenInterface",
228
        default_service = "org.freedesktop.secrets",
229
        default_path = "/org/gnome/keyring/InternalUnsupportedGuiltRiddenInterface",
230
        gen_blocking = false
231
    )]
232
    trait InternalInterfaceProxy {
233
        #[zbus(name = "CreateWithMasterPassword")]
234
        fn create_with_master_password(
235
            &self,
236
            properties: dbus::api::Properties,
237
            master: dbus::api::DBusSecretInner,
238
        ) -> zbus::Result<OwnedObjectPath>;
239

            
240
        #[zbus(name = "UnlockWithMasterPassword")]
241
        fn unlock_with_master_password(
242
            &self,
243
            collection: &ObjectPath<'_>,
244
            master: dbus::api::DBusSecretInner,
245
        ) -> zbus::Result<()>;
246

            
247
        #[zbus(name = "ChangeWithMasterPassword")]
248
        fn change_with_master_password(
249
            &self,
250
            collection: &ObjectPath<'_>,
251
            original: dbus::api::DBusSecretInner,
252
            master: dbus::api::DBusSecretInner,
253
        ) -> zbus::Result<()>;
254

            
255
        #[zbus(name = "ChangeWithPrompt")]
256
        fn change_with_prompt(&self, collection: &ObjectPath<'_>) -> zbus::Result<OwnedObjectPath>;
257
    }
258

            
259
    #[tokio::test]
260
    async fn test_create_with_master_password() -> Result<(), Box<dyn std::error::Error>> {
261
        let setup = TestServiceSetup::encrypted_session(false).await?;
262

            
263
        // Create proxy to the InternalInterface
264
        let internal_proxy = InternalInterfaceProxyProxy::builder(&setup.client_conn)
265
            .build()
266
            .await?;
267

            
268
        // Prepare properties for collection creation
269
        let label = "TestCollection";
270
        let properties = oo7::dbus::api::Properties::for_collection(label);
271

            
272
        // Prepare the master password secret
273
        let dbus_secret = setup.create_dbus_secret("my-master-password")?;
274
        let dbus_secret_inner = dbus_secret.into();
275

            
276
        // Call CreateWithMasterPassword via D-Bus
277
        let collection_path = internal_proxy
278
            .create_with_master_password(properties, dbus_secret_inner)
279
            .await?;
280

            
281
        // Verify the collection was created
282
        assert!(
283
            !collection_path.as_str().is_empty(),
284
            "Collection path should not be empty"
285
        );
286

            
287
        // Verify we can access the newly created collection via D-Bus
288
        let collection =
289
            oo7::dbus::api::Collection::new(&setup.client_conn, collection_path.clone()).await?;
290
        let label = collection.label().await?;
291
        assert_eq!(
292
            label, "TestCollection",
293
            "Collection should have the correct label"
294
        );
295
        Ok(())
296
    }
297

            
298
    #[tokio::test]
299
    async fn test_unlock_with_master_password() -> Result<(), Box<dyn std::error::Error>> {
300
        let setup = TestServiceSetup::encrypted_session(true).await?;
301
        let internal_proxy = InternalInterfaceProxyProxy::builder(&setup.client_conn)
302
            .build()
303
            .await?;
304

            
305
        // Get the default collection
306
        let default_collection = setup.default_collection().await?;
307
        let collection_path: zbus::zvariant::OwnedObjectPath =
308
            default_collection.inner().path().to_owned().into();
309

            
310
        // Lock the collection
311
        setup
312
            .service_api
313
            .lock(std::slice::from_ref(&collection_path), None)
314
            .await?;
315

            
316
        // Verify it's locked
317
        assert!(
318
            default_collection.is_locked().await?,
319
            "Collection should be locked"
320
        );
321

            
322
        // Prepare the unlock secret (use the keyring secret)
323
        let unlock_secret = setup.keyring_secret.clone().unwrap();
324
        let dbus_secret = setup.create_dbus_secret(unlock_secret)?;
325
        let dbus_secret_inner = dbus_secret.into();
326

            
327
        // Call UnlockWithMasterPassword via D-Bus
328
        internal_proxy
329
            .unlock_with_master_password(&collection_path.as_ref(), dbus_secret_inner)
330
            .await?;
331

            
332
        // Verify it's unlocked
333
        assert!(
334
            !default_collection.is_locked().await?,
335
            "Collection should be unlocked"
336
        );
337

            
338
        Ok(())
339
    }
340

            
341
    #[tokio::test]
342
    async fn test_change_with_master_password() -> Result<(), Box<dyn std::error::Error>> {
343
        let setup = TestServiceSetup::encrypted_session(true).await?;
344
        let internal_proxy = InternalInterfaceProxyProxy::builder(&setup.client_conn)
345
            .build()
346
            .await?;
347

            
348
        let default_collection = setup.default_collection().await?;
349
        let collection_path: zbus::zvariant::OwnedObjectPath =
350
            default_collection.inner().path().to_owned().into();
351

            
352
        // Prepare original and new secrets
353
        let original_secret = setup.keyring_secret.clone().unwrap();
354
        let new_secret = Secret::text("new-master-password");
355

            
356
        let original_dbus = setup.create_dbus_secret(original_secret)?;
357
        let new_dbus = setup.create_dbus_secret(new_secret.clone())?;
358

            
359
        // Call ChangeWithMasterPassword via D-Bus
360
        internal_proxy
361
            .change_with_master_password(
362
                &collection_path.as_ref(),
363
                original_dbus.into(),
364
                new_dbus.into(),
365
            )
366
            .await?;
367

            
368
        // Verify the password was changed by locking and unlocking with new password
369
        setup
370
            .service_api
371
            .lock(std::slice::from_ref(&collection_path), None)
372
            .await?;
373
        assert!(
374
            default_collection.is_locked().await?,
375
            "Collection should be locked"
376
        );
377

            
378
        // Unlock with new password via D-Bus
379
        let unlock_dbus = setup.create_dbus_secret(new_secret)?;
380
        internal_proxy
381
            .unlock_with_master_password(&collection_path.as_ref(), unlock_dbus.into())
382
            .await?;
383

            
384
        assert!(
385
            !default_collection.is_locked().await?,
386
            "Collection should be unlocked with new password"
387
        );
388

            
389
        Ok(())
390
    }
391

            
392
    #[tokio::test]
393
    async fn test_change_with_prompt() -> Result<(), Box<dyn std::error::Error>> {
394
        let setup = TestServiceSetup::encrypted_session(true).await?;
395
        setup.set_password_accept(true).await;
396

            
397
        let internal_proxy = InternalInterfaceProxyProxy::builder(&setup.client_conn)
398
            .build()
399
            .await?;
400

            
401
        let default_collection = setup.default_collection().await?;
402
        let collection_path: zbus::zvariant::OwnedObjectPath =
403
            default_collection.inner().path().to_owned().into();
404

            
405
        // Call ChangeWithPrompt via D-Bus
406
        let prompt_path = internal_proxy
407
            .change_with_prompt(&collection_path.as_ref())
408
            .await?;
409

            
410
        // Verify prompt was created
411
        assert!(
412
            !prompt_path.as_str().is_empty(),
413
            "Prompt path should not be empty"
414
        );
415

            
416
        // Get the prompt and complete it
417
        let prompt_proxy = dbus::api::Prompt::new(&setup.client_conn, prompt_path)
418
            .await?
419
            .unwrap();
420
        let new_password = Secret::text("new-password-from-prompt");
421
        setup.set_password_queue(vec![new_password.clone()]).await;
422

            
423
        prompt_proxy.prompt(None).await?;
424

            
425
        // Wait for prompt to complete
426
        tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
427

            
428
        // Verify the password was changed by locking and unlocking with new password
429
        setup
430
            .service_api
431
            .lock(std::slice::from_ref(&collection_path), None)
432
            .await?;
433
        assert!(
434
            default_collection.is_locked().await?,
435
            "Collection should be locked"
436
        );
437

            
438
        // Unlock with new password via D-Bus
439
        let unlock_dbus = setup.create_dbus_secret(new_password)?;
440
        internal_proxy
441
            .unlock_with_master_password(&collection_path.as_ref(), unlock_dbus.into())
442
            .await?;
443

            
444
        assert!(
445
            !default_collection.is_locked().await?,
446
            "Collection should be unlocked with new password"
447
        );
448

            
449
        Ok(())
450
    }
451

            
452
    #[tokio::test]
453
    async fn test_unlock_with_wrong_password() -> Result<(), Box<dyn std::error::Error>> {
454
        let setup = TestServiceSetup::encrypted_session(true).await?;
455
        let internal_proxy = InternalInterfaceProxyProxy::builder(&setup.client_conn)
456
            .build()
457
            .await?;
458

            
459
        let default_collection = setup.default_collection().await?;
460
        let collection_path: zbus::zvariant::OwnedObjectPath =
461
            default_collection.inner().path().to_owned().into();
462

            
463
        // Create an item first so that the unlock validation has something to validate
464
        let dbus_secret = setup.create_dbus_secret("item-secret")?;
465

            
466
        let mut attributes = std::collections::HashMap::new();
467
        attributes.insert("test".to_string(), "value".to_string());
468

            
469
        default_collection
470
            .create_item("Test Item", &attributes, &dbus_secret, false, None)
471
            .await?;
472

            
473
        // Lock the collection
474
        setup
475
            .service_api
476
            .lock(std::slice::from_ref(&collection_path), None)
477
            .await?;
478

            
479
        // Verify it's locked before attempting unlock
480
        assert!(
481
            default_collection.is_locked().await?,
482
            "Collection should be locked before unlock attempt"
483
        );
484

            
485
        // Try to unlock with wrong password via D-Bus
486
        let wrong_dbus_secret = setup.create_dbus_secret("wrong-password")?;
487

            
488
        let result = internal_proxy
489
            .unlock_with_master_password(&collection_path.as_ref(), wrong_dbus_secret.into())
490
            .await;
491

            
492
        // Should fail
493
        assert!(result.is_err(), "Unlocking with wrong password should fail");
494

            
495
        // Collection should remain locked
496
        assert!(
497
            default_collection.is_locked().await?,
498
            "Collection should remain locked after failed unlock"
499
        );
500

            
501
        Ok(())
502
    }
503
}