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
#[derive(Clone)]
21
pub struct InternalInterface {
22
    service: Service,
23
}
24

            
25
impl InternalInterface {
26
9
    pub fn new(service: Service) -> Self {
27
        Self { service }
28
    }
29

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

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

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

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

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

            
63
16
        let collection_path = self
64
            .service
65
8
            .create_collection_with_secret(&label, "", Some(secret))
66
16
            .await?;
67

            
68
8
        tracing::info!(
69
            "Collection `{}` created with label '{}' via InternalUnsupportedGuiltRiddenInterface",
70
            collection_path,
71
            label
72
        );
73

            
74
4
        Ok(collection_path)
75
    }
76

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

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

            
92
8
        collection_obj.set_locked(false, Some(secret)).await?;
93

            
94
4
        tracing::info!(
95
            "Collection `{}` unlocked via InternalUnsupportedGuiltRiddenInterface",
96
            collection
97
        );
98

            
99
4
        Ok(())
100
    }
101

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

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

            
119
12
        collection_obj
120
4
            .set_locked(false, Some(original_secret))
121
12
            .await?;
122

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

            
133
4
        tracing::info!(
134
            "Collection `{}` password changed via InternalUnsupportedGuiltRiddenInterface",
135
            collection
136
        );
137

            
138
4
        Ok(())
139
    }
140

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

            
153
8
        let label = collection_obj.label().await;
154

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

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

            
175
8
                let keyring_guard = collection.keyring.read().await;
176
11
                if let Some(Keyring::Unlocked(unlocked)) = keyring_guard.as_ref() {
177
3
                    if let Some(new_secret) = new_secret {
178
9
                        unlocked.change_secret(new_secret).await.map_err(|err| {
179
                            custom_service_error(&format!("Failed to change secret: {err}"))
180
                        })?;
181
                    } else {
182
                        return Err(custom_service_error(
183
                            "Cannot change to empty password via this interface",
184
                        ));
185
                    }
186
                } else {
187
2
                    return Err(custom_service_error(
188
                        "Collection must be unlocked to change password",
189
                    ));
190
                }
191

            
192
2
                tracing::info!(
193
                    "Collection `{}` password changed via prompt",
194
                    collection_path
195
                );
196

            
197
4
                Ok(OwnedValue::from(ObjectPath::from_str_unchecked("/")))
198
            }
199
        });
200

            
201
4
        prompt.set_action(action).await;
202

            
203
16
        self.service
204
            .object_server()
205
4
            .at(prompt.path(), prompt.clone())
206
12
            .await?;
207

            
208
8
        self.service
209
4
            .register_prompt(prompt_path.clone(), prompt)
210
8
            .await;
211

            
212
4
        tracing::info!(
213
            "Created password change prompt for collection `{}`",
214
            collection
215
        );
216

            
217
4
        Ok(prompt_path)
218
    }
219
}
220

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

            
226
    use crate::tests::TestServiceSetup;
227

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

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

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

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

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

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

            
271
        // Prepare properties for collection creation
272
        let label = "TestCollection";
273
        let properties = oo7::dbus::api::Properties::for_collection(label);
274

            
275
        // Prepare the master password secret
276
        let dbus_secret = setup.create_dbus_secret("my-master-password")?;
277
        let dbus_secret_inner = dbus_secret.into();
278

            
279
        // Call CreateWithMasterPassword via D-Bus
280
        let collection_path = internal_proxy
281
            .create_with_master_password(properties, dbus_secret_inner)
282
            .await?;
283

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

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

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

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

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

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

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

            
330
        // Call UnlockWithMasterPassword via D-Bus
331
        internal_proxy
332
            .unlock_with_master_password(&collection_path.as_ref(), dbus_secret_inner)
333
            .await?;
334

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

            
341
        Ok(())
342
    }
343

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

            
351
        let default_collection = setup.default_collection().await?;
352
        let collection_path: zbus::zvariant::OwnedObjectPath =
353
            default_collection.inner().path().to_owned().into();
354

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

            
359
        let original_dbus = setup.create_dbus_secret(original_secret)?;
360
        let new_dbus = setup.create_dbus_secret(new_secret.clone())?;
361

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

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

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

            
387
        assert!(
388
            !default_collection.is_locked().await?,
389
            "Collection should be unlocked with new password"
390
        );
391

            
392
        Ok(())
393
    }
394

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

            
400
        let internal_proxy = InternalInterfaceProxyProxy::builder(&setup.client_conn)
401
            .build()
402
            .await?;
403

            
404
        let default_collection = setup.default_collection().await?;
405
        let collection_path: zbus::zvariant::OwnedObjectPath =
406
            default_collection.inner().path().to_owned().into();
407

            
408
        // Call ChangeWithPrompt via D-Bus
409
        let prompt_path = internal_proxy
410
            .change_with_prompt(&collection_path.as_ref())
411
            .await?;
412

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

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

            
426
        prompt_proxy.prompt(None).await?;
427

            
428
        // Wait for prompt to complete
429
        tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
430

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

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

            
447
        assert!(
448
            !default_collection.is_locked().await?,
449
            "Collection should be unlocked with new password"
450
        );
451

            
452
        Ok(())
453
    }
454

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

            
462
        let default_collection = setup.default_collection().await?;
463
        let collection_path: zbus::zvariant::OwnedObjectPath =
464
            default_collection.inner().path().to_owned().into();
465

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

            
469
        let mut attributes = std::collections::HashMap::new();
470
        attributes.insert("test".to_string(), "value".to_string());
471

            
472
        default_collection
473
            .create_item("Test Item", &attributes, &dbus_secret, false, None)
474
            .await?;
475

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

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

            
488
        // Try to unlock with wrong password via D-Bus
489
        let wrong_dbus_secret = setup.create_dbus_secret("wrong-password")?;
490

            
491
        let result = internal_proxy
492
            .unlock_with_master_password(&collection_path.as_ref(), wrong_dbus_secret.into())
493
            .await;
494

            
495
        // Should fail
496
        assert!(result.is_err(), "Unlocking with wrong password should fail");
497

            
498
        // Collection should remain locked
499
        assert!(
500
            default_collection.is_locked().await?,
501
            "Collection should remain locked after failed unlock"
502
        );
503

            
504
        Ok(())
505
    }
506
}