1
// org.freedesktop.Secret.Prompt
2

            
3
use std::{future::Future, os::fd::AsFd, pin::Pin, str::FromStr, sync::Arc};
4

            
5
use formatx::formatx;
6
use gettextrs::gettext;
7
use oo7::{Secret, dbus::ServiceError};
8
use tokio::{
9
    io::AsyncReadExt,
10
    sync::{Mutex, OnceCell},
11
};
12
use zbus::{
13
    interface,
14
    object_server::SignalEmitter,
15
    zvariant::{ObjectPath, Optional, OwnedObjectPath, OwnedValue},
16
};
17

            
18
#[cfg(any(feature = "gnome_native_crypto", feature = "gnome_openssl_crypto"))]
19
use crate::gnome::prompter::{GNOMEPrompterCallback, GNOMEPrompterProxy};
20
#[cfg(any(feature = "plasma_native_crypto", feature = "plasma_openssl_crypto"))]
21
use crate::plasma::prompter::PlasmaPrompterCallback;
22
use crate::{
23
    error::custom_service_error,
24
    service::{PrompterType, Service},
25
};
26

            
27
#[zbus::proxy(
28
    interface = "org.freedesktop.secrets.CliPrompter",
29
    default_service = "org.freedesktop.secrets.CliPrompter",
30
    default_path = "/org/freedesktop/secrets/CliPrompter"
31
)]
32
trait CliPrompter {
33
    #[zbus(no_autostart)]
34
    async fn prompt(
35
        &self,
36
        label: &str,
37
        description: &str,
38
    ) -> zbus::Result<(zbus::zvariant::OwnedFd, bool)>;
39
}
40

            
41
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42
pub enum PromptRole {
43
    Unlock,
44
    CreateCollection,
45
    ChangePassword,
46
}
47

            
48
/// A boxed future that represents the action to be taken when a prompt
49
/// completes
50
pub type PromptActionFuture =
51
    Pin<Box<dyn Future<Output = Result<OwnedValue, ServiceError>> + Send + 'static>>;
52

            
53
/// Represents the action to be taken when a prompt completes
54
pub struct PromptAction {
55
    /// The async function to execute when the prompt is accepted
56
    action: Box<dyn FnOnce(Option<Secret>) -> PromptActionFuture + Send>,
57
}
58

            
59
impl PromptAction {
60
    /// Create a new prompt action from a closure that takes an optional secret
61
    /// and returns a future
62
35
    pub fn new<F, Fut>(f: F) -> Self
63
    where
64
        F: FnOnce(Option<Secret>) -> Fut + Send + 'static,
65
        Fut: Future<Output = Result<OwnedValue, ServiceError>> + Send + 'static,
66
    {
67
        Self {
68
100
            action: Box::new(move |secret| Box::pin(f(secret))),
69
        }
70
    }
71

            
72
    /// Execute the action with the provided secret
73
46
    pub async fn execute(self, secret: Option<Secret>) -> Result<OwnedValue, ServiceError> {
74
33
        (self.action)(secret).await
75
    }
76
}
77

            
78
#[derive(Clone)]
79
pub struct Prompt {
80
    service: Service,
81
    role: PromptRole,
82
    path: OwnedObjectPath,
83
    /// The label of the collection/keyring being prompted for
84
    label: String,
85
    /// The collection for Unlock prompts (needed for secret validation)
86
    collection: Option<crate::collection::Collection>,
87
    /// GNOME Specific
88
    #[cfg(any(feature = "gnome_native_crypto", feature = "gnome_openssl_crypto"))]
89
    gnome_callback: Arc<OnceCell<GNOMEPrompterCallback>>,
90
    /// KDE Plasma Specific
91
    #[cfg(any(feature = "plasma_native_crypto", feature = "plasma_openssl_crypto"))]
92
    plasma_callback: Arc<OnceCell<PlasmaPrompterCallback>>,
93
    /// The action to execute when the prompt completes
94
    action: Arc<Mutex<Option<PromptAction>>>,
95
}
96

            
97
#[cfg(any(
98
    feature = "gnome_openssl_crypto",
99
    feature = "gnome_native_crypto",
100
    feature = "plasma_native_crypto",
101
    feature = "plasma_openssl_crypto"
102
))] // User has to enable at least one prompt backend
103
#[interface(name = "org.freedesktop.Secret.Prompt")]
104
impl Prompt {
105
4
    pub async fn prompt(
106
        &self,
107
        window_id: Optional<&str>,
108
        #[zbus(header)] header: zbus::message::Header<'_>,
109
    ) -> Result<(), ServiceError> {
110
8
        let window_id = (*window_id).and_then(|w| ashpd::WindowIdentifierType::from_str(w).ok());
111
8
        let peer_info = match header.sender() {
112
            Some(sender) => self
113
                .service
114
                .session_from_sender(sender)
115
                .await
116
                .and_then(|s| s.peer_info().cloned()),
117
4
            None => None,
118
        };
119

            
120
8
        match self.service.prompter_type(peer_info.as_ref()).await {
121
            #[cfg(any(feature = "plasma_native_crypto", feature = "plasma_openssl_crypto"))]
122
8
            PrompterType::Plasma => self.prompt_plasma(window_id).await,
123
            #[cfg(any(feature = "gnome_native_crypto", feature = "gnome_openssl_crypto"))]
124
8
            PrompterType::GNOME => self.prompt_gnome(window_id).await,
125
            PrompterType::Cli => self.prompt_cli().await,
126
            #[allow(unreachable_patterns)]
127
            _ => Err(custom_service_error(
128
                "No prompt backend available in the current environment.",
129
            )),
130
        }
131
    }
132

            
133
16
    pub async fn dismiss(&self) -> Result<(), ServiceError> {
134
        #[cfg(any(feature = "plasma_native_crypto", feature = "plasma_openssl_crypto"))]
135
8
        if let Some(callback) = self.plasma_callback.get() {
136
            let emitter = SignalEmitter::from_parts(
137
                self.service.connection().clone(),
138
                callback.path().clone(),
139
            );
140
            PlasmaPrompterCallback::dismiss(&emitter).await?;
141
        }
142

            
143
        #[cfg(any(feature = "gnome_native_crypto", feature = "gnome_openssl_crypto"))]
144
8
        if let Some(_callback) = self.gnome_callback.get() {
145
            // TODO: figure out if we should destroy the un-export the callback
146
            // here?
147
        }
148

            
149
16
        self.service
150
            .object_server()
151
4
            .remove::<Self, _>(&self.path)
152
12
            .await?;
153
4
        self.service.remove_prompt(&self.path).await;
154

            
155
4
        Ok(())
156
    }
157

            
158
    #[zbus(signal, name = "Completed")]
159
    pub async fn completed(
160
4
        signal_emitter: &SignalEmitter<'_>,
161
4
        dismissed: bool,
162
8
        result: OwnedValue,
163
    ) -> zbus::Result<()>;
164
}
165

            
166
impl Prompt {
167
12
    pub async fn new(
168
        service: Service,
169
        role: PromptRole,
170
        label: String,
171
        collection: Option<crate::collection::Collection>,
172
    ) -> Self {
173
23
        let index = service.prompt_index();
174
        Self {
175
12
            path: OwnedObjectPath::try_from(format!("/org/freedesktop/secrets/prompt/p{index}"))
176
                .unwrap(),
177
            service,
178
            role,
179
            label,
180
            collection,
181
            #[cfg(any(feature = "gnome_native_crypto", feature = "gnome_openssl_crypto"))]
182
22
            gnome_callback: Default::default(),
183
            #[cfg(any(feature = "plasma_native_crypto", feature = "plasma_openssl_crypto"))]
184
22
            plasma_callback: Default::default(),
185
22
            action: Arc::new(Mutex::new(None)),
186
        }
187
    }
188

            
189
10
    pub fn path(&self) -> &ObjectPath<'_> {
190
12
        &self.path
191
    }
192

            
193
13
    pub fn role(&self) -> PromptRole {
194
12
        self.role
195
    }
196

            
197
14
    pub fn label(&self) -> &str {
198
12
        &self.label
199
    }
200

            
201
8
    fn collection(&self) -> Option<&crate::collection::Collection> {
202
8
        self.collection.as_ref()
203
    }
204

            
205
    /// Set the action to execute when the prompt completes
206
44
    pub async fn set_action(&self, action: PromptAction) {
207
10
        *self.action.lock().await = Some(action);
208
    }
209

            
210
    /// Take the action, consuming it so it can only be executed once
211
47
    async fn take_action(&self) -> Option<PromptAction> {
212
24
        self.action.lock().await.take()
213
    }
214

            
215
36
    pub async fn on_unlock_collection(&self, secret: Option<Secret>) -> Result<bool, ServiceError> {
216
        debug_assert_eq!(self.role, PromptRole::Unlock);
217

            
218
        // Get the collection to validate the secret
219
8
        let collection = self.collection().expect("Unlock requires a collection");
220
9
        let label = self.label();
221

            
222
9
        let is_valid = if let Some(ref secret) = secret {
223
            // Validate the secret using the already-open keyring
224
18
            let keyring_guard = collection.keyring.read().await;
225
43
            let valid = keyring_guard
226
                .as_ref()
227
                .unwrap()
228
9
                .validate_secret(secret)
229
27
                .await
230
16
                .map_err(|err| {
231
                    custom_service_error(&format!(
232
                        "Failed to validate secret for {label} keyring: {err}."
233
                    ))
234
                })?;
235
8
            drop(keyring_guard);
236
8
            valid
237
        } else {
238
            // No secret means unencrypted -> validate items are plaintext
239
            let keyring_guard = collection.keyring.read().await;
240
            let valid = keyring_guard
241
                .as_ref()
242
                .unwrap()
243
                .validate_unencrypted()
244
                .await
245
                .map_err(|err| {
246
                    custom_service_error(&format!(
247
                        "Failed to validate unencrypted keyring {label}: {err}."
248
                    ))
249
                })?;
250
            drop(keyring_guard);
251
            valid
252
        };
253

            
254
21
        if is_valid {
255
16
            tracing::debug!("Keyring secret matches for {label}.");
256

            
257
16
            let Some(action) = self.take_action().await else {
258
                return Err(custom_service_error(
259
                    "Prompt action was already executed or not set",
260
                ));
261
            };
262

            
263
            // Execute the unlock action after successful validation
264
21
            let result_value = action.execute(secret).await?;
265

            
266
16
            let prompt_path = self.path().to_owned();
267
16
            let signal_emitter = self.service.signal_emitter(&prompt_path)?;
268
33
            tokio::spawn(async move {
269
17
                tracing::debug!("Unlock prompt completed.");
270
16
                let _ = Prompt::completed(&signal_emitter, false, result_value).await;
271
            });
272
8
            Ok(true)
273
        } else {
274
11
            tracing::error!("Keyring {label} failed to unlock, incorrect secret.");
275

            
276
4
            Ok(false)
277
        }
278
    }
279

            
280
44
    pub async fn on_create_collection(&self, secret: Option<Secret>) -> Result<(), ServiceError> {
281
        debug_assert_eq!(self.role, PromptRole::CreateCollection);
282

            
283
10
        let Some(action) = self.take_action().await else {
284
            return Err(custom_service_error(
285
                "Prompt action was already executed or not set",
286
            ));
287
        };
288

            
289
        // Execute the collection creation action with the secret
290
29
        match action.execute(secret).await {
291
11
            Ok(collection_path_value) => {
292
25
                tracing::info!("CreateCollection action completed successfully");
293

            
294
26
                let signal_emitter = self.service.signal_emitter(self.path().to_owned())?;
295

            
296
46
                tokio::spawn(async move {
297
31
                    tracing::debug!("CreateCollection prompt completed.");
298
30
                    let _ = Prompt::completed(&signal_emitter, false, collection_path_value).await;
299
                });
300
14
                Ok(())
301
            }
302
            Err(err) => Err(custom_service_error(&format!(
303
                "Failed to create collection: {err}."
304
            ))),
305
        }
306
    }
307

            
308
18
    pub async fn on_change_password(&self, secret: Option<Secret>) -> Result<(), ServiceError> {
309
        debug_assert_eq!(self.role, PromptRole::ChangePassword);
310

            
311
4
        let Some(action) = self.take_action().await else {
312
            return Err(custom_service_error(
313
                "Prompt action was already executed or not set",
314
            ));
315
        };
316

            
317
        // Execute the change password action with the new secret
318
11
        match action.execute(secret).await {
319
2
            Ok(result) => {
320
4
                tracing::info!("ChangePassword action completed successfully");
321

            
322
4
                let signal_emitter = self.service.signal_emitter(self.path().to_owned())?;
323

            
324
8
                tokio::spawn(async move {
325
4
                    tracing::debug!("ChangePassword prompt completed.");
326
4
                    let _ = Prompt::completed(&signal_emitter, false, result).await;
327
                });
328
2
                Ok(())
329
            }
330
3
            Err(err) => Err(custom_service_error(&format!(
331
                "Failed to change password: {err}."
332
            ))),
333
        }
334
    }
335

            
336
    #[cfg(any(feature = "plasma_native_crypto", feature = "plasma_openssl_crypto"))]
337
4
    async fn prompt_plasma(
338
        &self,
339
        window_id: Option<ashpd::WindowIdentifierType>,
340
    ) -> Result<(), ServiceError> {
341
8
        if self.plasma_callback.get().is_some() {
342
8
            return Err(custom_service_error(
343
                "A prompt callback is ongoing already.",
344
            ));
345
        }
346

            
347
4
        let callback = PlasmaPrompterCallback::new(self.service.clone(), self.path.clone()).await;
348
8
        let path = OwnedObjectPath::from(callback.path().clone());
349

            
350
8
        let _ = self.plasma_callback.set(callback.clone());
351
16
        self.service
352
            .object_server()
353
4
            .at(&path, callback.clone())
354
12
            .await?;
355
4
        tracing::debug!("Prompt `{}` created.", self.path);
356

            
357
4
        callback.start(&self.role, window_id, &self.label).await
358
    }
359

            
360
    #[cfg(any(feature = "gnome_native_crypto", feature = "gnome_openssl_crypto"))]
361
12
    async fn prompt_gnome(
362
        &self,
363
        window_id: Option<ashpd::WindowIdentifierType>,
364
    ) -> Result<(), ServiceError> {
365
25
        if self.gnome_callback.get().is_some() {
366
8
            return Err(custom_service_error(
367
                "A GNOME prompt callback is ongoing already.",
368
            ));
369
        };
370

            
371
62
        let callback =
372
            GNOMEPrompterCallback::new(window_id, self.service.clone(), self.path.clone())
373
37
                .await
374
13
                .map_err(|err| {
375
                    custom_service_error(&format!("Failed to create GNOMEPrompterCallback {err}."))
376
                })?;
377

            
378
25
        let path = OwnedObjectPath::from(callback.path().clone());
379

            
380
25
        let _ = self.gnome_callback.set(callback.clone());
381
11
        self.service.object_server().at(&path, callback).await?;
382
11
        tracing::debug!("Prompt `{}` created.", self.path);
383

            
384
22
        let prompter = GNOMEPrompterProxy::new(self.service.connection()).await?;
385
44
        tokio::spawn(async move { prompter.begin_prompting(&path).await });
386

            
387
11
        Ok(())
388
    }
389

            
390
    async fn prompt_cli(&self) -> Result<(), ServiceError> {
391
        let proxy = CliPrompterProxy::new(self.service.connection())
392
            .await
393
            .map_err(|e| custom_service_error(&format!("CLI prompter not available: {e}")))?;
394

            
395
        let label = &self.label;
396
        let description = match self.role {
397
            PromptRole::Unlock => formatx!(
398
                gettext("An application wants access to the keyring “{}”, but it is locked"),
399
                label,
400
            )
401
            .expect("Wrong format in translatable string"),
402
            PromptRole::CreateCollection => formatx!(
403
                gettext("An application wants to create a new keyring called “{}”. Choose the password you want to use for it."),
404
                label,
405
            )
406
            .expect("Wrong format in translatable string"),
407
            PromptRole::ChangePassword => formatx!(
408
                gettext("An application wants to change the password for the “{}” keyring. Choose the new password you want to use for it."),
409
                label,
410
            )
411
            .expect("Wrong format in translatable string"),
412
        };
413

            
414
        match proxy.prompt(&self.label, &description).await {
415
            Ok((fd, true)) => {
416
                let std_stream = std::os::unix::net::UnixStream::from(
417
                    fd.as_fd().try_clone_to_owned().expect("Failed to clone fd"),
418
                );
419
                std_stream.set_nonblocking(true).map_err(|e| {
420
                    custom_service_error(&format!("Failed to set non-blocking: {e}"))
421
                })?;
422
                let mut stream = tokio::net::UnixStream::from_std(std_stream)
423
                    .expect("Failed to create Tokio UnixStream");
424
                let mut buffer = String::new();
425
                stream.read_to_string(&mut buffer).await.map_err(|e| {
426
                    custom_service_error(&format!("Failed to read secret from CLI prompter: {e}"))
427
                })?;
428
                let secret = if buffer.is_empty() {
429
                    None
430
                } else {
431
                    Some(Secret::from(buffer))
432
                };
433

            
434
                match self.role {
435
                    PromptRole::Unlock => {
436
                        self.on_unlock_collection(secret).await?;
437
                    }
438
                    PromptRole::CreateCollection => {
439
                        self.on_create_collection(secret).await?;
440
                    }
441
                    PromptRole::ChangePassword => {
442
                        self.on_change_password(secret).await?;
443
                    }
444
                }
445
                Ok(())
446
            }
447
            Ok((_, false)) => {
448
                tracing::info!("CLI prompter dismissed by user.");
449
                Err(custom_service_error("Prompt dismissed by user."))
450
            }
451
            Err(e) => Err(custom_service_error(&format!("CLI prompter failed: {e}"))),
452
        }
453
    }
454
}
455

            
456
#[cfg(test)]
457
mod tests;