From 9964e8788a0cee6a7d2d0778d201a96821041e90 Mon Sep 17 00:00:00 2001 From: Daniel Brotsky Date: Tue, 23 Nov 2021 20:37:32 -0800 Subject: [PATCH] Clean up based on usage and experimentation. 1. Rename `new_in_keychain` to `new_with_target` and use the target on all platforms. 2. Enhacen tests to test the new functionality. 3. Revamp the CLI example to use new functionality, maintaining backward compatibility. 4. Revamp the docs and the readme to talk about what happens on each platform. 5. Test to make sure there is backward compatibility with earlier releases. --- README.md | 32 +++++++++----- examples/cli.rs | 84 +++++++++++++++++-------------------- src/credential.rs | 103 +++++++++++++++++++++++++++++----------------- src/lib.rs | 36 ++++++++++++---- tests/basic.rs | 2 +- 5 files changed, 154 insertions(+), 103 deletions(-) diff --git a/README.md b/README.md index 0a3a14c..fc33a4b 100644 --- a/README.md +++ b/README.md @@ -3,9 +3,7 @@ [![Crates.io](https://img.shields.io/crates/v/keyring.svg?style=flat-square)](https://crates.io/crates/keyring) [![API Documentation on docs.rs](https://docs.rs/keyring/badge.svg)](https://docs.rs/keyring) -A cross-platorm library and utility to manage passwords. - -Online [docs](https://docs.rs/keyring) are currently limited to linux, as cross-platform autogenerated docs are not a thing yet. For osx or windows, try `cargo doc -p keyring --open`. +A cross-platorm library to manage passwords, with a fully-developed example that provides a command-line interface. Published on [crates.io](https://crates.io/crates/keyring) @@ -25,7 +23,7 @@ the `new` function to create a new `Entry` on the keyring. The `new` function expects a `service` name and an `username` which together identify the item. -Passwords can be added to an item using its `set_password` method. They can then be read back using the `get_password` method, and deleted using the `delete_password` method. (Note that persistence of the `Entry` is determined via Rust rules, so deleting the password doesn't delete the item.) +Passwords can be added to an item using its `set_password` method. They can then be read back using the `get_password` method, and deleted using the `delete_password` method. (Note that persistence of the `Entry` is determined via Rust rules, so deleting the password doesn't delete the entry, but it does delete the platform credential.) ```rust extern crate keyring; @@ -54,12 +52,17 @@ fn main() -> Result<(), Box> { The `get_password`, `set_password` and `delete_password` functions return a `Result` which, if the operation was unsuccessful, can yield a `keyring::Error` with a platform-independent code that describes the error. -## Caveats +## Conventions and Caveats -* This module manipulates passwords as UTF-8 encoded strings, so if a 3rd party has stored a non-Unicode password then retrieving that password will return an error. The error in that case will have the raw bytes attached, so you can access them. +* This module uses platform-native credential managers: secret service on Linux, the Credential Manager on Windows, and the Secure Keychain on Mac. Each keyring `Entry` (identified by service and username) is mapped to a specific platform credential using conventions described below. +* To facilitate interoperability with third-party software, there are alternate constructors for keyring entries - `Entry::new_with_target` and `Entry::new_with_credential` - that use different conventions to map entries to credentials. See below and the module documentation for how they work. In addition, the `get_password_and_credential` method on an entry can be used retrieve the underlying credential information. +* This module manipulates passwords as UTF-8 encoded strings, so if a third party has stored an arbitrary byte string then retrieving that password will return an error. The error in that case will have the raw bytes attached, so you can access them. ### Linux +* Secret-service groups credentials into collections, and identifies each credential in a collection using a set of key-value pairs (called _attributes_). In addition, secret-service allows for a label on each credential for use in UI-based clients. +* For a given service/username pair, `Entry::new` maps to a credential in the default (login) secret-service collection. This credential has matching `service` and `username` attributes, and an additional `application` attribute of `rust-keyring`. +* You can map an entry to non-default secret-service collection by passing the collection's name as the `target` parameter to `Entry::new_with_target`. This module doesn't ever create collections, so trying to access an entry in a named collection before externally creating and unlocking it will result in a `NoStorageAccess` error. * If you are running on a headless linux box, you will need to unlock the Gnome login keyring before you can use it. The following `bash` function may be very helpful. ```shell function unlock-keyring () @@ -69,19 +72,29 @@ function unlock-keyring () unset pass } ``` +* Trying to access a locked keychain on a headless box often returns the platform error that displays as `SS error: prompt dismissed`. This refers to the fact that there is no GUI running that can be used to prompt for a keychain unlock. ### Windows -* The default Windows approach to storing credentials doesn't allow storing passwords for different users against the same service. You can override this approach, see the `IdentityMapper` type for details. +* There is only one credential store on Windows. Generic credentials in this store are identified by a single string (called the _target name_). They also have a number of non-identifying but manipulable attributes: a username, a comment, and a target alias. +* For a given service/username pair, this module uses the concatenated string `username.service` as the mapped credential's target name. (This allows multiple users to store passwords for the same service.) It also fills the usrename and comment fields with appropriate strings. +* Because the Windows credential manager doesn't support multiple keychains, and because many Windows programs use _only_ the service name as the credential target name, the `Entry::new_with_target` call uses the target parameter as the credential's target name rather than concatenating the username and service. So if you have a custom algorithm you want to use for computing the Windows target name (such as just the service name), you can specify the target name directly (along with the usual service and username values). ### MacOS -* Accessing the same keychain entry from multiple threads simultaneously is generally a bad idea, and can cause deadlocks. +* MacOS credential stores are called keychains, and the OS automatically creates three of them (or four if removable media is being used). Generic credentials on Mac can be identified by a large number of _key/value_ attributes; this module (currently) uses only the _account_ and _name_ attributes. +* For a given service/username pair, this module uses a generic credential in the User (login) keychain whose _account_ is the username and and whose _name_ is the service. In the _Keychain Access_ UI, generic credentials created by this module show up in the passwords area (with their _where_ field equal to their _name_), but _Note_ entries on Mac are also generic credentials and can be accessed by this module if you know their _account_ value (which is not displayed by _Keychain Access_). +* You can specify targeting a different keychain by passing the keychain's (case-insensitive) name as the target parameter to `Entry::new_with_target`. Any name other than one of the OS-supplied keychains (User, Common, System, and Dynamic) will be mapped to `User`. (_N.B._ The latest versions of the MacOS SDK no longer support creation of file-based keychains, so this module's experimental support for those has been removed.) +* Accessing the same keychain entry from multiple threads simultaneously is generally a bad idea, and can cause deadlocks. This is because MacOS serializes all access and does so in unpredicatable ways. There is no issue with accessing different entries from multiple threads. + +## Sample Application + +The keychain-rs project contains a sample application: a command-line interface to the keychain in `cli.rs` in the examples directory. This can be a great way to explore how the library is used, and it allows experimentation with the use of different service names, user names, and targets. When run in "singly verbose" mode (-v), it outputs the retrieved credentials on each `get` run. When run in "doubly verbose" mode (-vv), it also outputs any errors returned. This can be a great way to see which errors result from which conditions on each platform. ## Dev Notes * We build using GitHub CI. -* Each tag is built on Ubuntu x64, Win 10 x64, and Mac x64. The `cli` example executable is posted with the tag. +* Each tag is built on Ubuntu x64, Win 10 x64, and Mac intel x64. The `cli` sample executable is posted for all platforms with the tag. ## License @@ -112,4 +125,3 @@ Thanks to the following for helping make this library better, whether through co ### Contribution Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions. - diff --git a/examples/cli.rs b/examples/cli.rs index a1cf4f0..f0ada9d 100644 --- a/examples/cli.rs +++ b/examples/cli.rs @@ -5,6 +5,7 @@ extern crate keyring; use keyring::{Entry, Error}; #[derive(Debug, StructOpt)] +#[structopt(author = "github.com/hwchen/keyring-rs")] /// Keyring CLI: A command-line interface to platform secure storage pub struct Cli { #[structopt(short, parse(from_occurrences))] @@ -12,12 +13,12 @@ pub struct Cli { /// Specify twice to provide structure print of all errors in addition to messages. pub verbose: u8, - #[structopt(short, long, default_value = "default")] - /// The keychain to use, if the platform supports more than one. - pub keychain: String, + #[structopt(short, long)] + /// The target for the entry. + pub target: Option, - #[structopt(short, long, default_value = "keyring")] - /// The service name to store/retrieve the password for. + #[structopt(short, long, default_value = "keyring-cli")] + /// The service name for the entry pub service: String, #[structopt(short, long)] @@ -48,90 +49,79 @@ fn main() { } fn execute_args(args: &Cli) { - let keychain = args.keychain.clone(); - let username = args.username.clone().unwrap_or_else(whoami::username); - let entry = Entry::new_in_keychain(&keychain, &args.service, &username); + let username = if let Some(username) = &args.username { + username.clone() + } else { + whoami::username() + }; + let entry = if let Some(target) = args.target.as_ref() { + Entry::new_with_target(target, &args.service, &username) + } else { + Entry::new(&args.service, &username) + }; match &args.command { Command::Set { password: Some(password), - } => execute_set_password(args, &entry, password), + } => execute_set_password(&username, args.verbose, &entry, password), Command::Set { password: None } => { if let Ok(password) = read_password_from_tty(Some("Password: ")) { - execute_set_password(args, &entry, &password) + execute_set_password(&username, args.verbose, &entry, &password) } else { eprintln!("(Failed to read password, so none set.)") } } - Command::Get => execute_get_password_and_credential(args, &entry), - Command::Delete => execute_delete_password(args, &entry), + Command::Get => execute_get_password_and_credential(&username, args.verbose, &entry), + Command::Delete => execute_delete_password(&username, args.verbose, &entry), } } -fn execute_set_password(args: &Cli, entry: &Entry, password: &str) { +fn execute_set_password(username: &str, verbose: u8, entry: &Entry, password: &str) { match entry.set_password(password) { - Ok(()) => println!("Password set successfully"), - Err(Error::NoStorageAccess(err)) => { - eprintln!("Couldn't set the password: {}", err); - if args.verbose > 1 { - eprintln!("Error details: {:?}", err); - } - } + Ok(()) => println!("(Password for user '{}' set successfully)", username), Err(err) => { - eprintln!("Unexpected error setting the password: {}", err); - if args.verbose > 1 { + eprintln!("Couldn't set password for user '{}': {}", username, err); + if verbose > 1 { eprintln!("Error details: {:?}", err); } } } } -fn execute_get_password_and_credential(args: &Cli, entry: &Entry) { +fn execute_get_password_and_credential(username: &str, verbose: u8, entry: &Entry) { match entry.get_password_and_credential() { Ok((password, credential)) => { - println!("Password is '{}'", &password); - if args.verbose > 0 { + println!("The password for user '{}' is '{}'", username, &password); + if verbose > 0 { println!("Credential is: {:?}", credential) } } Err(Error::NoEntry) => { - eprintln!("(No password found)"); - if args.verbose > 1 { + eprintln!("(No password found for user '{}')", username); + if verbose > 1 { eprintln!("Error details: {:?}", Error::NoEntry); } } - Err(Error::NoStorageAccess(err)) => { - eprintln!("Couldn't retrieve the password: {}", err); - if args.verbose > 1 { - eprintln!("Error details: {:?}", err); - } - } Err(err) => { - eprintln!("Unexpected error retrieving the password: {}", err); - if args.verbose > 1 { + eprintln!("Couldn't get password for user '{}': {}", username, err); + if verbose > 1 { eprintln!("Error details: {:?}", err); } } } } -fn execute_delete_password(args: &Cli, entry: &Entry) { +fn execute_delete_password(username: &str, verbose: u8, entry: &Entry) { match entry.delete_password() { - Ok(()) => println!("(Password deleted)"), + Ok(()) => println!("(Password for user '{}' deleted)", username), Err(Error::NoEntry) => { - eprintln!("(No password found)"); - if args.verbose > 1 { + eprintln!("(No password for user '{}' found)", username); + if verbose > 1 { eprintln!("Error details: {:?}", Error::NoEntry); } } - Err(Error::NoStorageAccess(err)) => { - eprintln!("Couldn't delete the password: {}", err); - if args.verbose > 1 { - eprintln!("Error details: {:?}", err); - } - } Err(err) => { - eprintln!("Unexpected error retrieving the password: {}", err); - if args.verbose > 1 { + eprintln!("Couldn't delete password for user '{}': {}", username, err); + if verbose > 1 { eprintln!("Error details: {:?}", err); } } diff --git a/src/credential.rs b/src/credential.rs index 7f39a7b..5d1b65f 100644 --- a/src/credential.rs +++ b/src/credential.rs @@ -13,25 +13,24 @@ platform credentials provide the persistence for keyring items. In order to bridge the gap between the keyring item model and each platform's credential model, this crate uses a "credential mapper": a function which maps from keyring items to platform credentials. -The inputs to a credential mapper are the platform, username, and -service of the keyring item; its output is a platform-specific -"recipe" for identifying and annotating the credential which the -crate will use to store the item's password. +The inputs to a credential mapper are the platform, optional target +specification, service, and username, of the keyring item; its output +is a platform-specific "recipe" for identifying and annotating the +platform credential which the crate will use for this item. This module provides a credential model for each supported platform, -and a credential mapper which the crate uses by default. Clients -who want to use a different credential mapper can provide their own, -which allows this crate to operate compatibly with the conventions -used by third-party applications. For example: - -* On Windows, generic credentials are identified by an arbitrary string, -and this crate uses "service.username" as that string. Most 3rd party -applications, on the other hand, use the service name as the identifying -string and keep the username as a metadata attribute on the credential. - -* On Linux and Mac, there are multiple credential stores for each OS user. -Some 3rd party applications don't use the "default" store for their data. - +and a credential mapper which the crate uses by default. The default +credential mapper can be "advised" by providing a suggested "target" +when creating an entry: on Linux and Mac this target is interpreted +as the collection/keychain to put the credential in; on Windows this +target is taken literally as the "target name" of the credential. + +Clients who want to use a different algorithm for mapping service/username +pairs to platform credentials can also provide the specific credential spec +they want to use when creating the entry. + +See the top-level README for the project for more information about the +platform-specific credential mapping. Or read the code here :). */ use std::collections::HashMap; @@ -108,6 +107,15 @@ impl From<&str> for MacKeychainDomain { } } +impl From> for MacKeychainDomain { + fn from(keychain: Option<&str>) -> Self { + match keychain { + None => MacKeychainDomain::User, + Some(str) => str.into(), + } + } +} + #[derive(Debug, Clone, PartialEq)] pub enum PlatformCredential { Linux(LinuxCredential), @@ -125,40 +133,61 @@ impl PlatformCredential { } } -// Create the default target credential for a keyring item. +// Create the default target credential for a keyring item. The caller +// can provide a target parameter to influence the mapping. pub fn default_target( platform: &Platform, - keychain: &str, + target: Option<&str>, service: &str, username: &str, ) -> PlatformCredential { + const VERSION: &str = env!("CARGO_PKG_VERSION"); + let custom = if target.is_none() { + "entry" + } else { + "custom entry" + }; + let metadata = format!( + "keyring-rs v{} {} for service '{}', user '{}'", + VERSION, custom, service, username + ); match platform { Platform::Linux => PlatformCredential::Linux(LinuxCredential { - collection: keychain.to_string(), + collection: target.unwrap_or("default").to_string(), attributes: HashMap::from([ ("service".to_string(), service.to_string()), ("username".to_string(), username.to_string()), ("application".to_string(), "rust-keyring".to_string()), ]), - label: format!( - "keyring-rs credential for service '{}', user '{}'", - service, username - ), - }), - Platform::Windows => PlatformCredential::Win(WinCredential { - // Note: default concatenation of user and service name is - // needed because windows identity is on target_name only - // See issue here: https://github.com/jaraco/keyring/issues/47 - username: username.to_string(), - target_name: format!("{}.{}", username, service), - target_alias: String::new(), - comment: format!( - "keyring-rs credential for service '{}', user '{}'", - service, username - ), + label: metadata, }), + Platform::Windows => { + if let Some(keychain) = target { + PlatformCredential::Win(WinCredential { + // Note: Since Windows doesn't support multiple keychains, + // and since it's nice for clients to have control over + // the target_name directly, we use the `keychain` value + // as the target name if it's specified non-default. + username: username.to_string(), + target_name: keychain.to_string(), + target_alias: String::new(), + comment: metadata, + }) + } else { + PlatformCredential::Win(WinCredential { + // Note: default concatenation of user and service name is + // used because windows uses target_name as sole identifier. + // See the README for more rationale. Also see this issue + // for Python: https://github.com/jaraco/keyring/issues/47 + username: username.to_string(), + target_name: format!("{}.{}", username, service), + target_alias: String::new(), + comment: metadata, + }) + } + } Platform::MacOs => PlatformCredential::Mac(MacCredential { - domain: keychain.into(), + domain: target.into(), service: service.to_string(), account: username.to_string(), }), diff --git a/src/lib.rs b/src/lib.rs index de62a25..32d29a6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -29,19 +29,23 @@ impl Entry { // This maps to a target credential in the default keychain. pub fn new(service: &str, username: &str) -> Entry { Entry { - target: credential::default_target(&platform(), "default", service, username), + target: credential::default_target(&platform(), None, service, username), } } - // Create an entry for the given keychain, service, and username. - // If the platform doesn't support multiple keychains, this is the same as `new`. - pub fn new_in_keychain(keychain: &str, service: &str, username: &str) -> Entry { + // Create an entry for the given target, service, and username. + // On Linux and Mac, the target is interpreted as naming the collection/keychain + // to store the credential. On Windows, the target is used directly as + // the _target name_ of the credential. + pub fn new_with_target(target: &str, service: &str, username: &str) -> Entry { Entry { - target: credential::default_target(&platform(), keychain, service, username), + target: credential::default_target(&platform(), Some(target), service, username), } } - // Create an entry that uses the given credential for storage. + // Create an entry that uses the given credential for storage. Callers can use + // their own algorithm to produce a platform-specific credential spec for the + // given service and username and then call this entry with that value. pub fn new_with_credential(target: &PlatformCredential) -> Result { if target.matches_platform(&platform()) { Ok(Entry { @@ -68,7 +72,7 @@ impl Entry { // Retrieve the password and all the other fields // set in the platform-specific credential. This - // allows retrieving metdata on the credential that + // allows retrieving metadata on the credential that // were saved by external applications. pub fn get_password_and_credential(&self) -> Result<(String, PlatformCredential)> { let mut map = self.target.clone(); @@ -92,7 +96,7 @@ mod tests { #[test] fn test_default_initial_and_retrieved_map() { let name = generate_random_string(); - let expected_target = default_target(&platform(), "default", &name, &name); + let expected_target = default_target(&platform(), None, &name, &name); let entry = Entry::new(&name, &name); assert_eq!(entry.target, expected_target); entry.set_password("ignored").unwrap(); @@ -102,6 +106,22 @@ mod tests { entry.delete_password().unwrap(); } + #[test] + fn test_targeted_initial_and_retrieved_map() { + let name = generate_random_string(); + let expected_target = default_target(&platform(), Some(&name), &name, &name); + let entry = Entry::new_with_target(&name, &name, &name); + assert_eq!(entry.target, expected_target); + // can only test targeted credentials on Windows + if matches!(platform(), Platform::Windows) { + entry.set_password("ignored").unwrap(); + let (_, target) = entry.get_password_and_credential().unwrap(); + assert_eq!(target, expected_target); + // don't leave password around. + entry.delete_password().unwrap(); + } + } + fn generate_random_string() -> String { // from the Rust Cookbook: // https://rust-lang-nursery.github.io/rust-cookbook/algorithms/randomness.html diff --git a/tests/basic.rs b/tests/basic.rs index b97c5dd..131f94f 100644 --- a/tests/basic.rs +++ b/tests/basic.rs @@ -75,7 +75,7 @@ fn test_independent_credential_and_password() { fn test_same_target() { let name = generate_random_string(); let entry1 = Entry::new(&name, &name); - let credential = default_target(&platform(), "default", &name, &name); + let credential = default_target(&platform(), None, &name, &name); let entry2 = Entry::new_with_credential(&credential).unwrap(); let password1 = generate_random_string(); entry1.set_password(&password1).unwrap();