From e8c9a47afeef6b948b89c6f38ba60e3ef3a1088e Mon Sep 17 00:00:00 2001 From: Maximiliano Sandoval R Date: Wed, 15 Nov 2023 22:23:46 +0100 Subject: [PATCH] Add keyring-util Fixes: https://github.com/bilelmoussaoui/oo7/issues/48 --- .github/workflows/CI.yml | 27 +++- .gitignore | 1 + keyring-util/Cargo.toml | 11 ++ keyring-util/src/main.rs | 291 +++++++++++++++++++++++++++++++++++++++ src/lib.rs | 2 + 5 files changed, 329 insertions(+), 3 deletions(-) create mode 100644 keyring-util/Cargo.toml create mode 100644 keyring-util/src/main.rs diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 236d7c3e7..6757e2d3d 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -16,9 +16,15 @@ jobs: profile: minimal toolchain: stable override: true - - uses: actions-rs/cargo@v1 + - name: Check + uses: actions-rs/cargo@v1 with: command: check + - name: Check (keyring-util) + uses: actions-rs/cargo@v1 + with: + command: check + args: -p keyring-util test: name: Test Suite @@ -40,6 +46,9 @@ jobs: - name: Build and test (OpenSSL) run: | dbus-run-session -- cargo test --no-default-features --features async-std --features openssl_crypto + - name: Build and test keyring-util + run: | + cargo test -p keyring-util fmt: name: Rustfmt @@ -52,10 +61,16 @@ jobs: toolchain: nightly override: true - run: rustup component add rustfmt - - uses: actions-rs/cargo@v1 + - name: Rust Format + uses: actions-rs/cargo@v1 with: command: fmt args: --all -- --check + - name: Rust Format (keyring-util) + uses: actions-rs/cargo@v1 + with: + command: fmt + args: --all -p keyring-util -- --check clippy: name: Clippy @@ -68,7 +83,13 @@ jobs: toolchain: stable override: true - run: rustup component add clippy - - uses: actions-rs/cargo@v1 + - name: Clippy + uses: actions-rs/cargo@v1 with: command: clippy args: -- -D warnings + - name: Clippy (keyring-util) + uses: actions-rs/cargo@v1 + with: + command: clippy + args: -p keyring-util -- -D warnings diff --git a/.gitignore b/.gitignore index 96ef6c0b9..3e0525451 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /target Cargo.lock +keyring-util/target diff --git a/keyring-util/Cargo.toml b/keyring-util/Cargo.toml new file mode 100644 index 000000000..d3ec87053 --- /dev/null +++ b/keyring-util/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "keyring-util" +version = "0.1.0" +edition = "2021" + +[dependencies] +async-std = { version = "1.12.0", features = [ "attributes" ] } +chrono = { version = "0.4.31", default-features = false, features = ["alloc", "clock"] } +clap = { version = "4.4.6", features = [ "cargo", "derive" ] } +oo7 = { path = "../" } +rpassword = "7.2.0" diff --git a/keyring-util/src/main.rs b/keyring-util/src/main.rs new file mode 100644 index 000000000..6d51c7b30 --- /dev/null +++ b/keyring-util/src/main.rs @@ -0,0 +1,291 @@ +use std::{ + collections::HashMap, + fmt, + io::Write, + process::{ExitCode, Termination}, +}; + +use clap::{Command, CommandFactory, FromArgMatches, Parser}; +use oo7::{ + dbus::{Algorithm, Collection, Service}, + zbus, +}; + +struct Error(String); + +impl fmt::Debug for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl From for Error { + fn from(err: oo7::dbus::Error) -> Error { + Error(err.to_string()) + } +} + +impl Error { + fn new(s: &str) -> Self { + Self(String::from(s)) + } +} + +impl Termination for Error { + fn report(self) -> ExitCode { + ExitCode::FAILURE + } +} + +#[derive(Parser)] +#[command( + name = "store", + about = "Store a secret", + after_help = "The contents of the secret will be asked afterwards.\n\nExample:\n keyring-util store 'My Personal Mail' smtp-port 1025 imap-port 143" +)] +struct StoreArgs { + #[clap(help = "Description for the secret")] + label: String, + #[clap(help = "List of attributes. This is a space separated list of pairs of key value")] + attributes: Vec, +} + +#[derive(Parser)] +#[command( + name = "search", + about = "Search entries with matching attributes", + after_help = "Example:\n keyring-util search --all smtp-port 1025" +)] +struct SearchArgs { + #[clap(help = "List of attributes. This is a space separated list of pairs of key value")] + attributes: Vec, + #[clap( + short, + long, + help = "Whether to list all possible matches or only the first result" + )] + all: bool, +} + +#[derive(Parser)] +#[command( + name = "lookup", + about = "Retrieve a secret", + after_help = "Example:\n keyring-util lookup smtp-port 1025" +)] +struct LookupArgs { + #[clap(help = "List of attributes. This is a space separated list of pairs of key value")] + attributes: Vec, +} + +#[derive(Parser)] +#[command( + name = "delete", + about = "Delete a secret", + after_help = "Will delete all secrets with matching attributes.\n\nExample:\n keyring-util delete smtp-port 1025" +)] +struct DeleteArgs { + #[clap(help = "List of attributes. This is a space separated list of pairs of key value")] + attributes: Vec, +} + +#[async_std::main] +async fn main() -> Result<(), Error> { + let cmd = Command::new("keyring-util") + .bin_name("keyring-util") + .subcommand_required(true) + .subcommand(StoreArgs::command()) + .subcommand(LookupArgs::command()) + .subcommand(DeleteArgs::command()) + .subcommand(SearchArgs::command()) + .subcommand(Command::new("lock").about("Lock the keyring")) + .subcommand(Command::new("unlock").about("Unlock the keyring")); + let matches = cmd.get_matches(); + match matches.subcommand() { + Some(("store", matches)) => { + let args = + StoreArgs::from_arg_matches(matches).map_err(|e| Error::new(&e.to_string()))?; + let attributes = parse_attributes(&args.attributes)?; + + store(&args.label, attributes).await + } + Some(("lookup", matches)) => { + let args = + LookupArgs::from_arg_matches(matches).map_err(|e| Error::new(&e.to_string()))?; + let attributes = parse_attributes(&args.attributes)?; + + lookup(attributes).await + } + Some(("search", matches)) => { + let args = + SearchArgs::from_arg_matches(matches).map_err(|e| Error::new(&e.to_string()))?; + let attributes = parse_attributes(&args.attributes)?; + + search(attributes, args.all).await + } + Some(("delete", matches)) => { + let args = + LookupArgs::from_arg_matches(matches).map_err(|e| Error::new(&e.to_string()))?; + let attributes = parse_attributes(&args.attributes)?; + + delete(attributes).await + } + Some(("lock", _matches)) => lock().await, + Some(("unlock", _matches)) => unlock().await, + _ => unreachable!("clap should ensure we don't get here"), + } +} + +fn parse_attributes(attributes: &[String]) -> Result, Error> { + // Should this allow attribute-less secrets? + let mut attributes = attributes.iter(); + if attributes.len() == 0 { + return Err(Error(String::from( + "Need to specify at least one attribute", + ))); + } + let mut result = HashMap::new(); + while let (Some(k), Some(v)) = (attributes.next(), attributes.next()) { + result.insert(k.as_str(), v.as_str()); + } + match attributes.next() { + None => Ok(result), + Some(k) => Err(Error(String::from(&format!( + "Key '{k}' is missing a value" + )))), + } +} + +async fn store(label: &str, attributes: HashMap<&str, &str>) -> Result<(), Error> { + let collection = collection().await?; + + print!("Type a secret: "); + std::io::stdout() + .flush() + .map_err(|_| Error::new("Could not flush stdout"))?; + let secret = rpassword::read_password().map_err(|_| Error::new("Can't read password"))?; + + collection + .create_item(label, attributes, &secret, true, "text/plain") + .await?; + + Ok(()) +} + +async fn lookup(attributes: HashMap<&str, &str>) -> Result<(), Error> { + let collection = collection().await?; + let items = collection.search_items(attributes).await?; + + if let Some(item) = items.first() { + let bytes = item.secret().await?; + let secret = + std::str::from_utf8(&bytes).map_err(|_| Error::new("Secret is not valid utf-8"))?; + println!("{secret}"); + } + + Ok(()) +} + +async fn search(attributes: HashMap<&str, &str>, all: bool) -> Result<(), Error> { + let collection = collection().await?; + let items = collection.search_items(attributes).await?; + + if all { + for item in items { + print_item(&item).await?; + } + } else if let Some(item) = items.first() { + print_item(item).await?; + } + + Ok(()) +} + +async fn delete(attributes: HashMap<&str, &str>) -> Result<(), Error> { + let collection = collection().await?; + let items = collection.search_items(attributes).await?; + + for item in items { + item.delete().await?; + } + + Ok(()) +} + +async fn lock() -> Result<(), Error> { + let collection = collection().await?; + collection.lock().await?; + + Ok(()) +} + +async fn unlock() -> Result<(), Error> { + let collection = collection().await?; + collection.unlock().await?; + + Ok(()) +} + +async fn print_item<'a>(item: &oo7::dbus::Item<'a>) -> Result<(), Error> { + use std::fmt::Write; + + let label = item.label().await?; + let bytes = item.secret().await?; + // TODO Maybe show bytes in hex instead of failing? + let secret = + std::str::from_utf8(&bytes).map_err(|_| Error::new("Secret is not valid utf-8"))?; + let mut attributes = item.attributes().await?; + let created = item.created().await?; + let modified = item.modified().await?; + + let created = chrono::DateTime::::from_timestamp(created.as_secs() as i64, 0) + .unwrap() + .with_timezone(&chrono::Local); + let modified = chrono::DateTime::::from_timestamp(modified.as_secs() as i64, 0) + .unwrap() + .with_timezone(&chrono::Local); + + let mut result = format!("[{label}]\n"); + writeln!(&mut result, "secret = {secret}").unwrap(); + writeln!( + &mut result, + "created = {}", + created.format("%Y-%m-%d %H:%M:%S") + ) + .unwrap(); + writeln!( + &mut result, + "modified = {}", + modified.format("%Y-%m-%d %H:%M:%S") + ) + .unwrap(); + if let Some(schema) = attributes.remove("xdg:schema") { + writeln!(&mut result, "schema = {schema} ").unwrap(); + } + writeln!(&mut result, "attributes = {attributes:?} ").unwrap(); + print!("{result}"); + + Ok(()) +} + +async fn collection<'a>() -> Result, Error> { + let service = match Service::new(Algorithm::Encrypted).await { + Ok(service) => Ok(service), + Err(oo7::dbus::Error::Zbus(zbus::Error::MethodError(_, _, _))) => { + Service::new(Algorithm::Plain).await + } + Err(e) => Err(e), + }?; + + let collection = match service.default_collection().await { + Ok(c) => Ok(c), + Err(oo7::dbus::Error::NotFound(_)) => { + service + .create_collection("Login", Some(oo7::dbus::DEFAULT_COLLECTION)) + .await + } + Err(e) => Err(e), + }?; + + Ok(collection) +} diff --git a/src/lib.rs b/src/lib.rs index ac1de5b15..2b09c3a63 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -25,6 +25,8 @@ pub use error::{Error, Result}; pub use keyring::{Item, Keyring}; pub use migration::migrate; +pub use zbus; + /// Checks whether the application is sandboxed or not. pub async fn is_sandboxed() -> bool { helpers::is_flatpak().await || helpers::is_snap().await