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
106
            action: Box::new(move |secret| Box::pin(f(secret))),
69
        }
70
    }
71

            
72
    /// Execute the action with the provided secret
73
50
    pub async fn execute(self, secret: Option<Secret>) -> Result<OwnedValue, ServiceError> {
74
35
        (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
16
    pub async fn prompt(&self, window_id: Optional<&str>) -> Result<(), ServiceError> {
106
8
        let window_id = (*window_id).and_then(|w| ashpd::WindowIdentifierType::from_str(w).ok());
107

            
108
8
        match self.service.prompter_type().await {
109
            #[cfg(any(feature = "plasma_native_crypto", feature = "plasma_openssl_crypto"))]
110
8
            PrompterType::Plasma => self.prompt_plasma(window_id).await,
111
            #[cfg(any(feature = "gnome_native_crypto", feature = "gnome_openssl_crypto"))]
112
8
            PrompterType::GNOME => self.prompt_gnome(window_id).await,
113
            PrompterType::Cli => self.prompt_cli().await,
114
            #[allow(unreachable_patterns)]
115
            _ => Err(custom_service_error(
116
                "No prompt backend available in the current environment.",
117
            )),
118
        }
119
    }
120

            
121
16
    pub async fn dismiss(&self) -> Result<(), ServiceError> {
122
        #[cfg(any(feature = "plasma_native_crypto", feature = "plasma_openssl_crypto"))]
123
8
        if let Some(callback) = self.plasma_callback.get() {
124
            let emitter = SignalEmitter::from_parts(
125
                self.service.connection().clone(),
126
                callback.path().clone(),
127
            );
128
            PlasmaPrompterCallback::dismiss(&emitter).await?;
129
        }
130

            
131
        #[cfg(any(feature = "gnome_native_crypto", feature = "gnome_openssl_crypto"))]
132
8
        if let Some(_callback) = self.gnome_callback.get() {
133
            // TODO: figure out if we should destroy the un-export the callback
134
            // here?
135
        }
136

            
137
16
        self.service
138
            .object_server()
139
4
            .remove::<Self, _>(&self.path)
140
12
            .await?;
141
4
        self.service.remove_prompt(&self.path).await;
142

            
143
4
        Ok(())
144
    }
145

            
146
    #[zbus(signal, name = "Completed")]
147
    pub async fn completed(
148
5
        signal_emitter: &SignalEmitter<'_>,
149
5
        dismissed: bool,
150
10
        result: OwnedValue,
151
    ) -> zbus::Result<()>;
152
}
153

            
154
impl Prompt {
155
14
    pub async fn new(
156
        service: Service,
157
        role: PromptRole,
158
        label: String,
159
        collection: Option<crate::collection::Collection>,
160
    ) -> Self {
161
26
        let index = service.prompt_index().await;
162
        Self {
163
12
            path: OwnedObjectPath::try_from(format!("/org/freedesktop/secrets/prompt/p{index}"))
164
                .unwrap(),
165
            service,
166
            role,
167
            label,
168
            collection,
169
            #[cfg(any(feature = "gnome_native_crypto", feature = "gnome_openssl_crypto"))]
170
25
            gnome_callback: Default::default(),
171
            #[cfg(any(feature = "plasma_native_crypto", feature = "plasma_openssl_crypto"))]
172
25
            plasma_callback: Default::default(),
173
25
            action: Arc::new(Mutex::new(None)),
174
        }
175
    }
176

            
177
13
    pub fn path(&self) -> &ObjectPath<'_> {
178
12
        &self.path
179
    }
180

            
181
10
    pub fn role(&self) -> PromptRole {
182
12
        self.role
183
    }
184

            
185
10
    pub fn label(&self) -> &str {
186
12
        &self.label
187
    }
188

            
189
9
    fn collection(&self) -> Option<&crate::collection::Collection> {
190
9
        self.collection.as_ref()
191
    }
192

            
193
    /// Set the action to execute when the prompt completes
194
50
    pub async fn set_action(&self, action: PromptAction) {
195
13
        *self.action.lock().await = Some(action);
196
    }
197

            
198
    /// Take the action, consuming it so it can only be executed once
199
48
    async fn take_action(&self) -> Option<PromptAction> {
200
24
        self.action.lock().await.take()
201
    }
202

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

            
206
        // Get the collection to validate the secret
207
8
        let collection = self.collection().expect("Unlock requires a collection");
208
9
        let label = self.label();
209

            
210
9
        let is_valid = if let Some(ref secret) = secret {
211
            // Validate the secret using the already-open keyring
212
18
            let keyring_guard = collection.keyring.read().await;
213
44
            let valid = keyring_guard
214
                .as_ref()
215
                .unwrap()
216
9
                .validate_secret(secret)
217
26
                .await
218
17
                .map_err(|err| {
219
                    custom_service_error(&format!(
220
                        "Failed to validate secret for {label} keyring: {err}."
221
                    ))
222
                })?;
223
9
            drop(keyring_guard);
224
9
            valid
225
        } else {
226
            // No secret means unencrypted -> validate items are plaintext
227
            let keyring_guard = collection.keyring.read().await;
228
            let valid = keyring_guard
229
                .as_ref()
230
                .unwrap()
231
                .validate_unencrypted()
232
                .await
233
                .map_err(|err| {
234
                    custom_service_error(&format!(
235
                        "Failed to validate unencrypted keyring {label}: {err}."
236
                    ))
237
                })?;
238
            drop(keyring_guard);
239
            valid
240
        };
241

            
242
21
        if is_valid {
243
20
            tracing::debug!("Keyring secret matches for {label}.");
244

            
245
18
            let Some(action) = self.take_action().await else {
246
                return Err(custom_service_error(
247
                    "Prompt action was already executed or not set",
248
                ));
249
            };
250

            
251
            // Execute the unlock action after successful validation
252
22
            let result_value = action.execute(secret).await?;
253

            
254
20
            let prompt_path = self.path().to_owned();
255
20
            let signal_emitter = self.service.signal_emitter(&prompt_path)?;
256
34
            tokio::spawn(async move {
257
21
                tracing::debug!("Unlock prompt completed.");
258
17
                let _ = Prompt::completed(&signal_emitter, false, result_value).await;
259
            });
260
8
            Ok(true)
261
        } else {
262
10
            tracing::error!("Keyring {label} failed to unlock, incorrect secret.");
263

            
264
4
            Ok(false)
265
        }
266
    }
267

            
268
45
    pub async fn on_create_collection(&self, secret: Option<Secret>) -> Result<(), ServiceError> {
269
        debug_assert_eq!(self.role, PromptRole::CreateCollection);
270

            
271
9
        let Some(action) = self.take_action().await else {
272
            return Err(custom_service_error(
273
                "Prompt action was already executed or not set",
274
            ));
275
        };
276

            
277
        // Execute the collection creation action with the secret
278
27
        match action.execute(secret).await {
279
10
            Ok(collection_path_value) => {
280
22
                tracing::info!("CreateCollection action completed successfully");
281

            
282
20
                let signal_emitter = self.service.signal_emitter(self.path().to_owned())?;
283

            
284
42
                tokio::spawn(async move {
285
27
                    tracing::debug!("CreateCollection prompt completed.");
286
22
                    let _ = Prompt::completed(&signal_emitter, false, collection_path_value).await;
287
                });
288
10
                Ok(())
289
            }
290
            Err(err) => Err(custom_service_error(&format!(
291
                "Failed to create collection: {err}."
292
            ))),
293
        }
294
    }
295

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

            
299
4
        let Some(action) = self.take_action().await else {
300
            return Err(custom_service_error(
301
                "Prompt action was already executed or not set",
302
            ));
303
        };
304

            
305
        // Execute the change password action with the new secret
306
11
        match action.execute(secret).await {
307
2
            Ok(result) => {
308
4
                tracing::info!("ChangePassword action completed successfully");
309

            
310
4
                let signal_emitter = self.service.signal_emitter(self.path().to_owned())?;
311

            
312
8
                tokio::spawn(async move {
313
4
                    tracing::debug!("ChangePassword prompt completed.");
314
4
                    let _ = Prompt::completed(&signal_emitter, false, result).await;
315
                });
316
2
                Ok(())
317
            }
318
3
            Err(err) => Err(custom_service_error(&format!(
319
                "Failed to change password: {err}."
320
            ))),
321
        }
322
    }
323

            
324
    #[cfg(any(feature = "plasma_native_crypto", feature = "plasma_openssl_crypto"))]
325
4
    async fn prompt_plasma(
326
        &self,
327
        window_id: Option<ashpd::WindowIdentifierType>,
328
    ) -> Result<(), ServiceError> {
329
8
        if self.plasma_callback.get().is_some() {
330
8
            return Err(custom_service_error(
331
                "A prompt callback is ongoing already.",
332
            ));
333
        }
334

            
335
4
        let callback = PlasmaPrompterCallback::new(self.service.clone(), self.path.clone()).await;
336
8
        let path = OwnedObjectPath::from(callback.path().clone());
337

            
338
8
        let _ = self.plasma_callback.set(callback.clone());
339
16
        self.service
340
            .object_server()
341
4
            .at(&path, callback.clone())
342
12
            .await?;
343
8
        tracing::debug!("Prompt `{}` created.", self.path);
344

            
345
4
        callback.start(&self.role, window_id, &self.label).await
346
    }
347

            
348
    #[cfg(any(feature = "gnome_native_crypto", feature = "gnome_openssl_crypto"))]
349
13
    async fn prompt_gnome(
350
        &self,
351
        window_id: Option<ashpd::WindowIdentifierType>,
352
    ) -> Result<(), ServiceError> {
353
25
        if self.gnome_callback.get().is_some() {
354
8
            return Err(custom_service_error(
355
                "A GNOME prompt callback is ongoing already.",
356
            ));
357
        };
358

            
359
62
        let callback =
360
            GNOMEPrompterCallback::new(window_id, self.service.clone(), self.path.clone())
361
36
                .await
362
12
                .map_err(|err| {
363
                    custom_service_error(&format!("Failed to create GNOMEPrompterCallback {err}."))
364
                })?;
365

            
366
22
        let path = OwnedObjectPath::from(callback.path().clone());
367

            
368
22
        let _ = self.gnome_callback.set(callback.clone());
369
10
        self.service.object_server().at(&path, callback).await?;
370
16
        tracing::debug!("Prompt `{}` created.", self.path);
371

            
372
23
        let prompter = GNOMEPrompterProxy::new(self.service.connection()).await?;
373
42
        tokio::spawn(async move { prompter.begin_prompting(&path).await });
374

            
375
10
        Ok(())
376
    }
377

            
378
    async fn prompt_cli(&self) -> Result<(), ServiceError> {
379
        let proxy = CliPrompterProxy::new(self.service.connection())
380
            .await
381
            .map_err(|e| custom_service_error(&format!("CLI prompter not available: {e}")))?;
382

            
383
        let label = &self.label;
384
        let description = match self.role {
385
            PromptRole::Unlock => formatx!(
386
                gettext("An application wants access to the keyring '{}', but it is locked"),
387
                label,
388
            )
389
            .expect("Wrong format in translatable string"),
390
            PromptRole::CreateCollection => formatx!(
391
                gettext("An application wants to create a new keyring called '{}'. Choose the password you want to use for it."),
392
                label,
393
            )
394
            .expect("Wrong format in translatable string"),
395
            PromptRole::ChangePassword => formatx!(
396
                gettext("An application wants to change the password for the '{}' keyring. Choose the new password you want to use for it."),
397
                label,
398
            )
399
            .expect("Wrong format in translatable string"),
400
        };
401

            
402
        match proxy.prompt(&self.label, &description).await {
403
            Ok((fd, true)) => {
404
                let std_stream = std::os::unix::net::UnixStream::from(
405
                    fd.as_fd().try_clone_to_owned().expect("Failed to clone fd"),
406
                );
407
                std_stream.set_nonblocking(true).map_err(|e| {
408
                    custom_service_error(&format!("Failed to set non-blocking: {e}"))
409
                })?;
410
                let mut stream = tokio::net::UnixStream::from_std(std_stream)
411
                    .expect("Failed to create Tokio UnixStream");
412
                let mut buffer = String::new();
413
                stream.read_to_string(&mut buffer).await.map_err(|e| {
414
                    custom_service_error(&format!("Failed to read secret from CLI prompter: {e}"))
415
                })?;
416
                let secret = if buffer.is_empty() {
417
                    None
418
                } else {
419
                    Some(Secret::from(buffer))
420
                };
421

            
422
                match self.role {
423
                    PromptRole::Unlock => {
424
                        self.on_unlock_collection(secret).await?;
425
                    }
426
                    PromptRole::CreateCollection => {
427
                        self.on_create_collection(secret).await?;
428
                    }
429
                    PromptRole::ChangePassword => {
430
                        self.on_change_password(secret).await?;
431
                    }
432
                }
433
                Ok(())
434
            }
435
            Ok((_, false)) => {
436
                tracing::info!("CLI prompter dismissed by user.");
437
                Err(custom_service_error("Prompt dismissed by user."))
438
            }
439
            Err(e) => Err(custom_service_error(&format!("CLI prompter failed: {e}"))),
440
        }
441
    }
442
}
443

            
444
#[cfg(test)]
445
mod tests;