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
8
            .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
5
        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
10
                if let Some(Keyring::Unlocked(unlocked)) = keyring_guard.as_ref() {
177
2
                    if let Some(new_secret) = new_secret {
178
6
                        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
4
                    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
20
        self.service
204
            .object_server()
205
8
            .at(prompt.path(), prompt.clone())
206
16
            .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
372
        // password
373
        setup
374
            .service_api
375
            .lock(std::slice::from_ref(&collection_path), None)
376
            .await?;
377
        assert!(
378
            default_collection.is_locked().await?,
379
            "Collection should be locked"
380
        );
381

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

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

            
393
        Ok(())
394
    }
395

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

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

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

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

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

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

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

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

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

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

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

            
454
        Ok(())
455
    }
456

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

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

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

            
472
        let mut attributes = std::collections::HashMap::new();
473
        attributes.insert("test".to_string(), "value".to_string());
474

            
475
        default_collection
476
            .create_item("Test Item", &attributes, &dbus_secret, false, None)
477
            .await?;
478

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

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

            
491
        // Try to unlock with wrong password via D-Bus
492
        let wrong_dbus_secret = setup.create_dbus_secret("wrong-password")?;
493

            
494
        let result = internal_proxy
495
            .unlock_with_master_password(&collection_path.as_ref(), wrong_dbus_secret.into())
496
            .await;
497

            
498
        // Should fail
499
        assert!(result.is_err(), "Unlocking with wrong password should fail");
500

            
501
        // Collection should remain locked
502
        assert!(
503
            default_collection.is_locked().await?,
504
            "Collection should remain locked after failed unlock"
505
        );
506

            
507
        Ok(())
508
    }
509
}