quvyta-framework 0.1.34

A Rust framework for building terminal applications
//! The freedesktop Secret Service, spoken over D-Bus.
//!
//! The session is opened with the `plain` algorithm: the secret crosses the session bus as it is,
//! which only processes of the same user can listen to, the same trust the keyring itself rests on.

use std::collections::HashMap;
use std::sync::mpsc;
use std::time::Duration;

use zbus::blocking::{Connection, MessageIterator, connection};
use zbus::zvariant::{ObjectPath, OwnedObjectPath, OwnedValue, Value};
use zbus::{MatchRule, message};
use zeroize::Zeroizing;

use super::SecretError;

const DESTINATION: &str = "org.freedesktop.secrets";
const SERVICE_PATH: &str = "/org/freedesktop/secrets";
const SERVICE: &str = "org.freedesktop.Secret.Service";
const COLLECTION: &str = "org.freedesktop.Secret.Collection";
const ITEM: &str = "org.freedesktop.Secret.Item";
const PROMPT: &str = "org.freedesktop.Secret.Prompt";

/// How long one call may go unanswered. A keyring started on demand by the bus takes a moment on
/// its first call; one that has not answered in this long is not going to.
const CALL_WAIT: Duration = Duration::from_secs(25);

/// How long the keyring's own prompt may stay open: a person finding and typing their keyring
/// password. After this the prompt is dismissed and the keyring counts as locked.
const PROMPT_WAIT: Duration = Duration::from_secs(120);

/// The path the Secret Service uses to say "none": no prompt needed, no such collection.
const NONE: &str = "/";

/// A session with the Secret Service on one connection. Secrets cross it in `plain`.
#[derive(Clone)]
pub(super) struct Keyring {
    connection: Connection,
    session: OwnedObjectPath,
}

/// A secret as the Secret Service carries it: the session, the algorithm's parameters (none for
/// `plain`), the value and its content type.
type Secret = (OwnedObjectPath, Vec<u8>, Vec<u8>, String);

impl Keyring {
    /// A session with the Secret Service on the bus at `address`; an empty address is a session
    /// without D-Bus.
    pub(super) fn open(address: &str) -> Result<Self, SecretError> {
        if address.is_empty() {
            return Err(SecretError::Unavailable);
        }
        let connection = connection::Builder::address(address)
            .and_then(|builder| builder.method_timeout(CALL_WAIT).build())
            .map_err(|_| SecretError::Unavailable)?;
        Self::over(connection)
    }

    /// A session with whatever answers as the Secret Service on `connection`.
    pub(super) fn over(connection: Connection) -> Result<Self, SecretError> {
        let reply = connection
            .call_method(Some(DESTINATION), SERVICE_PATH, Some(SERVICE), "OpenSession", &("plain", Value::from("")))
            .map_err(answer)?;
        let (_, session): (OwnedValue, OwnedObjectPath) = reply.body().deserialize().map_err(answer)?;
        Ok(Self { connection, session })
    }

    pub(super) fn store(
        &self,
        application: &str,
        account: &str,
        label: &str,
        secret: &[u8],
    ) -> Result<(), SecretError> {
        let collection = self.default_collection()?;
        self.unlock(std::slice::from_ref(&collection))?;
        let mut properties: HashMap<&str, Value<'_>> = HashMap::new();
        properties.insert("org.freedesktop.Secret.Item.Label", Value::from(label));
        properties.insert("org.freedesktop.Secret.Item.Attributes", Value::from(attributes(application, account)));
        // Serialised straight from the caller's bytes; no copy of the secret is made here.
        let secret = (self.session.as_ref(), Vec::<u8>::new(), secret, "application/octet-stream");
        let (_, prompt): (OwnedObjectPath, OwnedObjectPath) =
            self.call(collection.as_ref(), COLLECTION, "CreateItem", &(properties, secret, true))?;
        self.prompt(&prompt).map(drop)
    }

    pub(super) fn lookup(&self, application: &str, account: &str) -> Result<Option<Zeroizing<Vec<u8>>>, SecretError> {
        let Some(item) = self.items(application, account)?.into_iter().next() else {
            return Ok(None);
        };
        let reply = self
            .connection
            .call_method(Some(DESTINATION), item.as_ref(), Some(ITEM), "GetSecret", &(self.session.as_ref(),))
            .map_err(answer)?;
        let (_, _, value, _): Secret = reply.body().deserialize().map_err(answer)?;
        Ok(Some(Zeroizing::new(value)))
    }

    pub(super) fn delete(&self, application: &str, account: &str) -> Result<(), SecretError> {
        for item in self.items(application, account)? {
            let prompt: OwnedObjectPath = self.call(item.as_ref(), ITEM, "Delete", &())?;
            self.prompt(&prompt)?;
        }
        Ok(())
    }

    /// Every item kept under the account, unlocked: the locked ones go through the keyring's
    /// prompt first.
    fn items(&self, application: &str, account: &str) -> Result<Vec<OwnedObjectPath>, SecretError> {
        let (mut unlocked, locked): (Vec<OwnedObjectPath>, Vec<OwnedObjectPath>) = self.call(
            SERVICE_PATH.try_into().map_err(answer)?,
            SERVICE,
            "SearchItems",
            &(attributes(application, account),),
        )?;
        if !locked.is_empty() {
            unlocked.extend(self.unlock(&locked)?);
        }
        Ok(unlocked)
    }

    /// The collection new items go into: the one the `default` alias names, made when there is
    /// none, as a keyring program would on its first run.
    fn default_collection(&self) -> Result<OwnedObjectPath, SecretError> {
        let service: ObjectPath<'_> = SERVICE_PATH.try_into().map_err(answer)?;
        let collection: OwnedObjectPath = self.call(service.clone(), SERVICE, "ReadAlias", &("default",))?;
        if collection.as_str() != NONE {
            return Ok(collection);
        }
        let mut properties: HashMap<&str, Value<'_>> = HashMap::new();
        properties.insert("org.freedesktop.Secret.Collection.Label", Value::from("Default keyring"));
        let (made, prompt): (OwnedObjectPath, OwnedObjectPath) =
            self.call(service, SERVICE, "CreateCollection", &(properties, "default"))?;
        if made.as_str() != NONE {
            return Ok(made);
        }
        let result = self.prompt(&prompt)?.ok_or(SecretError::Denied)?;
        OwnedObjectPath::try_from(result).map_err(answer)
    }

    /// Unlocks `objects`, through the keyring's prompt when it asks for one, and answers the ones
    /// that are unlocked now.
    fn unlock(&self, objects: &[OwnedObjectPath]) -> Result<Vec<OwnedObjectPath>, SecretError> {
        let (mut unlocked, prompt): (Vec<OwnedObjectPath>, OwnedObjectPath) =
            self.call(SERVICE_PATH.try_into().map_err(answer)?, SERVICE, "Unlock", &(objects,))?;
        if let Some(result) = self.prompt(&prompt)? {
            unlocked.extend(Vec::<OwnedObjectPath>::try_from(result).map_err(answer)?);
        }
        Ok(unlocked)
    }

    /// Runs the keyring's prompt at `prompt` and answers its result, or `None` when the keyring
    /// needed none (the path `/`).
    ///
    /// The prompt is the keyring's own window; this only starts it and waits, at most
    /// [`PROMPT_WAIT`], for it to say how it ended.
    fn prompt(&self, prompt: &OwnedObjectPath) -> Result<Option<OwnedValue>, SecretError> {
        if prompt.as_str() == NONE {
            return Ok(None);
        }
        let rule = MatchRule::builder()
            .msg_type(message::Type::Signal)
            .interface(PROMPT)
            .and_then(|rule| rule.member("Completed"))
            .and_then(|rule| rule.path(prompt.as_ref()))
            .map_err(answer)?
            .build();
        // Listening starts before the prompt does, so a prompt that ends at once is still heard.
        let completions = MessageIterator::for_match_rule(rule, &self.connection, None).map_err(answer)?;
        let (sender, received) = mpsc::channel();
        std::thread::spawn(move || {
            let completed = completions
                .into_iter()
                .flatten()
                .find_map(|signal| signal.body().deserialize::<(bool, OwnedValue)>().ok());
            // The waiting side may have given up; then there is nobody to tell.
            let _ = sender.send(completed);
        });
        let () = self.call(prompt.as_ref(), PROMPT, "Prompt", &("",))?;
        match received.recv_timeout(PROMPT_WAIT) {
            Ok(Some((false, result))) => Ok(Some(result)),
            Ok(Some((true, _))) => Err(SecretError::Denied),
            Ok(None) => Err(SecretError::Other("the keyring went away during its prompt".to_owned())),
            Err(_) => {
                // The prompt is left open no longer than the wait; its own answer to this is moot.
                let _: Result<(), SecretError> = self.call(prompt.as_ref(), PROMPT, "Dismiss", &());
                Err(SecretError::Locked)
            }
        }
    }

    /// Calls `method` of `interface` on the Secret Service's object at `path` and reads the reply.
    fn call<B, R>(&self, path: ObjectPath<'_>, interface: &str, method: &str, body: &B) -> Result<R, SecretError>
    where
        B: zbus::export::serde::Serialize + zbus::zvariant::DynamicType,
        R: for<'d> zbus::zvariant::DynamicDeserialize<'d>,
    {
        let reply =
            self.connection.call_method(Some(DESTINATION), path, Some(interface), method, body).map_err(answer)?;
        reply.body().deserialize().map_err(answer)
    }
}

/// The two attributes an item is kept and found under.
fn attributes<'a>(application: &'a str, account: &'a str) -> HashMap<&'a str, &'a str> {
    HashMap::from([("application", application), ("account", account)])
}

/// What a failed call means to the caller. The keyring's own error text is passed on as it is: it
/// names objects and reasons, never the secret, which no call here ever sends in an error's way.
fn answer(error: impl Into<zbus::Error>) -> SecretError {
    match error.into() {
        zbus::Error::MethodError(name, detail, _) => match name.as_str() {
            "org.freedesktop.DBus.Error.ServiceUnknown" | "org.freedesktop.DBus.Error.NameHasNoOwner" => {
                SecretError::Unavailable
            }
            "org.freedesktop.Secret.Error.IsLocked" => SecretError::Locked,
            "org.freedesktop.DBus.Error.AccessDenied" => SecretError::Denied,
            _ => SecretError::Other(detail.map_or_else(|| name.to_string(), |detail| format!("{name}: {detail}"))),
        },
        other => SecretError::Other(other.to_string()),
    }
}