diff --git a/.gitignore b/.gitignore index dc72747..c6aa762 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,14 @@ +# don't check in Cargo.lock because this is a library project +# see https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html Cargo.lock +# don't check in .cargo directory because not every developer +# uses it; the typical use is to enable cross-compiling. +/.cargo/ +# don't check in the binary and testing artifacts target fuzz libtest.rmeta +# profiling +*.profdata +*.profraw + diff --git a/Cargo.toml b/Cargo.toml index 851e434..aabee8b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,10 +9,6 @@ repository = "https://github.com/hwchen/keyring-rs.git" version = "0.10.4" edition = "2018" -[features] -default = [] -macos-specify-keychain = [] - [target.'cfg(target_os = "macos")'.dependencies] security-framework = "2.4.2" @@ -24,10 +20,11 @@ byteorder = "1.2.1" winapi = { version = "0.3", features = ["wincred", "minwindef"] } [dev-dependencies] -clap = "2.33" rpassword = "5.0" rand = "0.8.4" -serial_test = "0.5.1" +doc-comment = "0.3.3" +structopt = "0.3.25" +whoami = "1.2.0" [target.'cfg(target_os = "macos")'.dev-dependencies] tempfile = "3.1.0" diff --git a/README.md b/README.md index 82c390d..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) @@ -15,122 +13,56 @@ __Currently supports Linux, macOS, and Windows.__ Please file issues if you have To use this library in your project add the following to your `Cargo.toml` file: -``` +```toml [dependencies] -keyring = "0.10.1" +keyring = "0.10" ``` This will give you access to the `keyring` crate in your code. Now you can use -the `new` function to get an instance of the `Keyring` struct. The `new` -function expects a `service` name and an `username` with which it accesses -the password. - -You can get a password from the OS keyring with the `get_password` function. - -```rust,no_run -extern crate keyring; - -use std::error::Error; - -fn main() -> Result<(), Box> { - let service = "my_application_name"; - let username = "username"; - - let keyring = keyring::Keyring::new(&service, &username); - - let password = keyring.get_password()?; - println!("The password is '{}'", password); +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. - Ok(()) -} -``` - -Passwords can also be added to the keyring using the `set_password` function. +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,no_run +```rust extern crate keyring; use std::error::Error; fn main() -> Result<(), Box> { - let service = "my_application_name"; - let username = "username"; + let service = "my_application"; + let username = "my_name"; + let entry = keyring::Entry::new(&service, &username); - let keyring = keyring::Keyring::new(&service, &username); + let password = "topS3cr3tP4$$w0rd"; + entry.set_password(&password)?; - let password = "topS3cr3tP4$$w0rd"; - keyring.set_password(&password)?; + let password = entry.get_password()?; + println!("My password is '{}'", password); - let password = keyring.get_password()?; - println!("The password is '{}'", password); + entry.delete_password()?; + println!("My password has been deleted"); - Ok(()) -} -``` - -And they can be deleted with the `delete_password` function. - -```rust,no_run -extern crate keyring; - -use std::error::Error; - -fn main() -> Result<(), Box> { - let service = "my_application_name"; - let username = "username"; - - let keyring = keyring::Keyring::new(&service, &username); - - keyring.delete_password()?; - - println!("The password has been deleted"); - - Ok(()) -} -``` - -On macOS, keychain object from specific path can be opened using `Keyring::use_keychain` which gives the flexibility to open non-default keychains. Note that this is currently feature-gated, and is considered unstable, and is subject to change without a semver major version change. - -In Cargo.toml, you need to turn the feature on: -```toml -keyring = { version = "0.10.0", features = ["macos-specify-keychain"] } -``` - -```rust,no_run -extern crate keyring; - -use std::error::Error; - -fn main() -> Result<(), Box> { - let service = "my_application_name"; - let username = "username"; - - let keyring = keyring::Keyring::use_keychain(Path::new("/Library/Keychains/System.keychain"), &service, &username); - - let password = "topS3cr3tP4$$w0rd"; - keyring.set_password(&password)?; - - let password = keyring.get_password()?; - println!("The password is '{}'", password); - - Ok(()) + Ok(()) } ``` ## Errors -The `get_password`, `set_password` and `delete_password` functions return a -`Result` which, if the operation was unsuccessful, can yield a `KeyringError`. +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. -The `KeyringError` struct implements the `error::Error` and `fmt::Display` -traits, so it can be queried for a cause and an description using methods of -the same name. +## Conventions and Caveats -## Caveats +* 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 -* The application name is hardcoded to be `rust-keyring`. +* 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 () @@ -140,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 credential name is currently hardcoded to be `username.service`, due to a [reported issue](https://github.com/jaraco/keyring/issues/47). This breaks compatibility with 3rd-party applications, and is being fixed. +* 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 keychain 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 @@ -178,8 +120,8 @@ Thanks to the following for helping make this library better, whether through co - @steveatinfincia - @bhkaminski - @MaikKlein +- @brotskydotcom ### 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 7f0d624..f0ada9d 100644 --- a/examples/cli.rs +++ b/examples/cli.rs @@ -1,88 +1,129 @@ -extern crate clap; +use rpassword::read_password_from_tty; +use structopt::StructOpt; + extern crate keyring; -extern crate rpassword; +use keyring::{Entry, Error}; -use clap::{App, Arg, SubCommand}; -use keyring::Keyring; -use rpassword::read_password_from_tty; +#[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))] + /// Specify once to retrieve all aspects of credentials on get. + /// Specify twice to provide structure print of all errors in addition to messages. + pub verbose: u8, -use std::error::Error; + #[structopt(short, long)] + /// The target for the entry. + pub target: Option, -fn main() -> Result<(), Box> { - let matches = App::new("keyring") - .version(env!("CARGO_PKG_VERSION")) - .author("Walther Chen ") - .about("Cross-platform utility to get and set passwords from system vault") - .subcommand( - SubCommand::with_name("set") - .about("For username, set password") - .arg( - Arg::with_name("username") - .help("Username") - .required(true) - .index(1), - ), - ) - .subcommand( - SubCommand::with_name("get") - .about("For username, get password") - .arg( - Arg::with_name("username") - .help("Username") - .required(true) - .index(1), - ), - ) - .subcommand( - SubCommand::with_name("delete") - .about("For username, delete password") - .arg( - Arg::with_name("username") - .help("Username") - .required(true) - .index(1), - ), - ) - .get_matches(); + #[structopt(short, long, default_value = "keyring-cli")] + /// The service name for the entry + pub service: String, - let service = "keyring-cli"; + #[structopt(short, long)] + /// The user name to store/retrieve the password for [default: user's login name] + pub username: Option, - if let Some(set) = matches.subcommand_matches("set") { - let username = set - .value_of("username") - .ok_or("You must specify a Username to set")?; - let keyring = Keyring::new(service, username); + #[structopt(subcommand)] + pub command: Command, +} - let password = read_password_from_tty(Some("Password: "))?; - match keyring.set_password(&password[..]) { - Ok(_) => println!("Password set for user \"{}\"", username), - Err(e) => eprintln!("Error setting password for user '{}': {}", username, e), - } - } +#[derive(Debug, StructOpt)] +pub enum Command { + /// Set the password in the secure store + Set { + /// The password to set. If not specified, the password + /// is collected interactively from the terminal + password: Option, + }, + /// Get the password from the secure store + Get, + /// Delete the entry from the secure store + Delete, +} - if let Some(get) = matches.subcommand_matches("get") { - let username = get - .value_of("username") - .ok_or("You must specify a Username to get")?; - let keyring = Keyring::new(service, username); +fn main() { + let args: Cli = Cli::from_args(); + execute_args(&args); +} - match keyring.get_password() { - Ok(password) => println!("The password for user '{}' is '{}'", username, password), - Err(e) => eprintln!("Error getting password for user '{}': {}", username, e), +fn execute_args(args: &Cli) { + 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(&username, args.verbose, &entry, password), + Command::Set { password: None } => { + if let Ok(password) = read_password_from_tty(Some("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(&username, args.verbose, &entry), + Command::Delete => execute_delete_password(&username, args.verbose, &entry), } +} - if let Some(delete) = matches.subcommand_matches("delete") { - let username = delete - .value_of("username") - .ok_or("You must specify a Username to delete")?; - let keyring = Keyring::new(service, username); +fn execute_set_password(username: &str, verbose: u8, entry: &Entry, password: &str) { + match entry.set_password(password) { + Ok(()) => println!("(Password for user '{}' set successfully)", username), + Err(err) => { + eprintln!("Couldn't set password for user '{}': {}", username, err); + if verbose > 1 { + eprintln!("Error details: {:?}", err); + } + } + } +} - match keyring.delete_password() { - Ok(_) => println!("Password deleted for user '{}'", username), - Err(e) => eprintln!("Error deleting password for user '{}': {}", username, e), +fn execute_get_password_and_credential(username: &str, verbose: u8, entry: &Entry) { + match entry.get_password_and_credential() { + Ok((password, credential)) => { + println!("The password for user '{}' is '{}'", username, &password); + if verbose > 0 { + println!("Credential is: {:?}", credential) + } + } + Err(Error::NoEntry) => { + eprintln!("(No password found for user '{}')", username); + if verbose > 1 { + eprintln!("Error details: {:?}", Error::NoEntry); + } + } + Err(err) => { + eprintln!("Couldn't get password for user '{}': {}", username, err); + if verbose > 1 { + eprintln!("Error details: {:?}", err); + } } } +} - Ok(()) +fn execute_delete_password(username: &str, verbose: u8, entry: &Entry) { + match entry.delete_password() { + Ok(()) => println!("(Password for user '{}' deleted)", username), + Err(Error::NoEntry) => { + eprintln!("(No password for user '{}' found)", username); + if verbose > 1 { + eprintln!("Error details: {:?}", Error::NoEntry); + } + } + Err(err) => { + eprintln!("Couldn't delete password for user '{}': {}", username, err); + if verbose > 1 { + eprintln!("Error details: {:?}", err); + } + } + } } diff --git a/examples/example.rs b/examples/example.rs deleted file mode 100644 index 274d4c6..0000000 --- a/examples/example.rs +++ /dev/null @@ -1,23 +0,0 @@ -extern crate keyring; - -use keyring::{Keyring, Result}; - -fn main() -> Result<()> { - let username = "example-username"; - let service = "example-service"; - let password = "example-password"; - let keyring = Keyring::new(service, username); - keyring.set_password(password)?; - let stored_password = keyring.get_password()?; - assert_eq!( - password, stored_password, - "Stored and retrieved passwords don't match" - ); - keyring.delete_password()?; - assert!( - keyring.get_password().is_err(), - "No error retrieving password after deletion" - ); - - Ok(()) -} diff --git a/src/credential.rs b/src/credential.rs new file mode 100644 index 0000000..5d1b65f --- /dev/null +++ b/src/credential.rs @@ -0,0 +1,195 @@ +/* +This crate has a very simple model of a keyring: it has any number +of items, each of which is identified by a pair, +has no other metadata, and has a UTF-8 string as its "password". +Furthermore, there is only one keyring. + +This crate runs on several different platforms, each of which has its +own secure storage system with its own model for what constitutes a +"generic" secure credential: where it is stored, how it is identified, +what metadata it has, and what kind of "password" it allows. These +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, 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. 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; + +#[derive(Debug)] +pub enum Platform { + Linux, + Windows, + MacOs, +} + +// Linux supports multiple credential stores, each named by a string. +// Credentials in a store are identified by an arbitrary collection +// of attributes, and each can have "label" metadata for use in +// graphical editors. +#[derive(Debug, Clone, PartialEq)] +pub struct LinuxCredential { + pub collection: String, + pub attributes: HashMap, + pub label: String, +} + +impl LinuxCredential { + // Using strings in the credential map makes managing the lifetime + // of the credential much easier. But since the secret service expects + // a map from &str to &str, we have this utility to transform the + // credential's map into one of the right form. + pub fn attributes(&self) -> HashMap<&str, &str> { + self.attributes + .iter() + .map(|(k, v)| (k.as_str(), v.as_str())) + .collect() + } +} + +// Windows has only one credential store, and each credential is identified +// by a single string called the "target name". But generic credentials +// also have three pieces of metadata with suggestive names. +#[derive(Debug, Clone, PartialEq)] +pub struct WinCredential { + pub username: String, + pub target_name: String, + pub target_alias: String, + pub comment: String, +} + +// MacOS supports multiple OS-provided credential stores, and used to support creating +// arbitrary new credential stores (but that has been deprecated). Credentials on +// Mac also can have "type" but we don't reflect that here because the type is actually +// opaque once set and is only used in the Keychain UI. +#[derive(Debug, Clone, PartialEq)] +pub struct MacCredential { + pub domain: MacKeychainDomain, + pub service: String, + pub account: String, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum MacKeychainDomain { + User, + System, + Common, + Dynamic, +} + +impl From<&str> for MacKeychainDomain { + fn from(keychain: &str) -> Self { + match keychain.to_ascii_lowercase().as_str() { + "system" => MacKeychainDomain::System, + "common" => MacKeychainDomain::Common, + "dynamic" => MacKeychainDomain::Dynamic, + _ => MacKeychainDomain::User, + } + } +} + +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), + Win(WinCredential), + Mac(MacCredential), +} + +impl PlatformCredential { + pub fn matches_platform(&self, os: &Platform) -> bool { + match self { + PlatformCredential::Linux(_) => matches!(os, Platform::Linux), + PlatformCredential::Mac(_) => matches!(os, Platform::MacOs), + PlatformCredential::Win(_) => matches!(os, Platform::Windows), + } + } +} + +// 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, + 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: 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: 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: target.into(), + service: service.to_string(), + account: username.to_string(), + }), + } +} diff --git a/src/error.rs b/src/error.rs index 4a159c9..6f2d187 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,90 +1,66 @@ -#[cfg(target_os = "linux")] -use secret_service::Error as SsError; -#[cfg(target_os = "macos")] -use security_framework::base::Error as SfError; -use std::error; -use std::fmt; -use std::string::FromUtf8Error; - -pub type Result = ::std::result::Result; - #[derive(Debug)] -pub enum KeyringError { - #[cfg(target_os = "macos")] - MacOsKeychainError(SfError), - #[cfg(target_os = "linux")] - SecretServiceError(SsError), - #[cfg(target_os = "windows")] - WindowsVaultError, - NoBackendFound, - NoPasswordFound, - Parse(FromUtf8Error), +pub enum Error { + // This indicates runtime failure in the underlying + // platform storage system. The details of the failure can + // be retrieved from the attached platform error. + PlatformFailure(crate::platform::Error), + // This indicates that the underlying secure storage + // holding saved items could not be accessed. Typically this + // is because of access rules in the platform; for example, it + // might be that the credential store is locked. The underlying + // platform error will typically give the reason. + NoStorageAccess(crate::platform::Error), + // This indicates that there is no underlying credential + // entry in the platform for this item. Either one was + // never set, or it was deleted. + NoEntry, + // This indicates that the retrieved password blob was not + // a UTF-8 string. The underlying bytes are available + // for examination in the attached value. + BadEncoding(Vec), + // This indicates that one of the underlying credential + // metadata values produced by the mapper exceeded a + // length limit for the underlying platform. The + // attached value give the name of the attribute and + // the platform length limit that was exceeded. + TooLong(String, u32), + // This indicates that the underlying mapper produced + // a credential specification for a different platform + // that you are running on. Since the mapper is only + // run when items are created, this can only be a status + // returned from `new_with_mapper`. + WrongCredentialPlatform, } -impl fmt::Display for KeyringError { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - match *self { - #[cfg(target_os = "macos")] - KeyringError::MacOsKeychainError(ref err) => { - write!(f, "Mac Os Keychain Error: {}", err) +pub type Result = std::result::Result; + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match self { + Error::WrongCredentialPlatform => { + write!(f, "CredentialMapper value doesn't match this platform") + } + Error::PlatformFailure(err) => write!(f, "Platform secure storage failure: {}", err), + Error::NoStorageAccess(err) => { + write!(f, "Couldn't access platform secure storage: {}", err) } - #[cfg(target_os = "linux")] - KeyringError::SecretServiceError(ref err) => write!(f, "Secret Service Error: {}", err), - #[cfg(target_os = "windows")] - KeyringError::WindowsVaultError => write!(f, "Windows Vault Error"), - KeyringError::NoBackendFound => write!(f, "Keyring error: No Backend Found"), - KeyringError::NoPasswordFound => write!(f, "Keyring Error: No Password Found"), - KeyringError::Parse(ref err) => write!(f, "Keyring Parse Error: {}", err), + Error::NoEntry => write!(f, "No matching entry found in secure storage"), + Error::BadEncoding(_) => write!(f, "Password cannot be UTF-8 encoded"), + Error::TooLong(name, len) => write!( + f, + "Attribute '{}' is longer than platform limit of {} chars", + name, len + ), } } } -impl error::Error for KeyringError { - // the description method on KeyringError is hard deprecated, - // but since we defined this impl before it was deprecated - // we are still using it. So we have to turn off the warning. - #[allow(deprecated)] - fn description(&self) -> &str { - match *self { - #[cfg(target_os = "macos")] - KeyringError::MacOsKeychainError(ref err) => err.description(), - #[cfg(target_os = "linux")] - KeyringError::SecretServiceError(ref err) => err.description(), - #[cfg(target_os = "windows")] - KeyringError::WindowsVaultError => "Windows Vault Error", - KeyringError::NoBackendFound => "No Backend Found", - KeyringError::NoPasswordFound => "No Password Found", - KeyringError::Parse(ref err) => err.description(), - } - } - - fn cause(&self) -> Option<&dyn error::Error> { - match *self { - #[cfg(target_os = "linux")] - KeyringError::SecretServiceError(ref err) => Some(err), - #[cfg(target_os = "macos")] - KeyringError::MacOsKeychainError(ref err) => Some(err), +impl std::error::Error for Error { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Error::PlatformFailure(err) => Some(err), + Error::NoStorageAccess(err) => Some(err), _ => None, } } } - -#[cfg(target_os = "linux")] -impl From for KeyringError { - fn from(err: SsError) -> KeyringError { - KeyringError::SecretServiceError(err) - } -} - -#[cfg(target_os = "macos")] -impl From for KeyringError { - fn from(err: SfError) -> KeyringError { - KeyringError::MacOsKeychainError(err) - } -} - -impl From for KeyringError { - fn from(err: FromUtf8Error) -> KeyringError { - KeyringError::Parse(err) - } -} diff --git a/src/lib.rs b/src/lib.rs index 75b2f7f..32d29a6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,77 +2,134 @@ //! //! Allows for setting and getting passwords on Linux, OSX, and Windows -// Configure for Linux -#[cfg(target_os = "linux")] -mod linux; -#[cfg(target_os = "linux")] -pub use linux::Keyring; - -// Configure for Windows -#[cfg(target_os = "windows")] -mod windows; -#[cfg(target_os = "windows")] -pub use windows::Keyring; - -// Configure for OSX -#[cfg(target_os = "macos")] -mod macos; -#[cfg(target_os = "macos")] -pub use macos::Keyring; - -mod error; -pub use error::{KeyringError, Result}; +pub mod credential; +pub mod error; + +use credential::{Platform, PlatformCredential}; +pub use error::{Error, Result}; + +// compile-time Platform known at runtime +pub fn platform() -> Platform { + platform::platform() +} + +// Platform-specific implementations +#[cfg_attr(target_os = "linux", path = "linux.rs")] +#[cfg_attr(target_os = "windows", path = "windows.rs")] +#[cfg_attr(target_os = "macos", path = "macos.rs")] +mod platform; + +#[derive(Debug)] +pub struct Entry { + target: PlatformCredential, +} + +impl Entry { + // Create an entry for the given service and username. + // This maps to a target credential in the default keychain. + pub fn new(service: &str, username: &str) -> Entry { + Entry { + target: credential::default_target(&platform(), None, service, username), + } + } + + // 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(), Some(target), service, username), + } + } + + // 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 { + target: target.clone(), + }) + } else { + Err(Error::WrongCredentialPlatform) + } + } + + // Set the password for this item. Any other platform-specific + // annotations are determined by the mapper that was used + // to create the credential. + pub fn set_password(&self, password: &str) -> Result<()> { + platform::set_password(&self.target, password) + } + + // Retrieve the password saved for this item. + // Returns a `NoEntry` error is there isn't one. + pub fn get_password(&self) -> Result { + let mut map = self.target.clone(); + platform::get_password(&mut map) + } + + // Retrieve the password and all the other fields + // set in the platform-specific credential. This + // 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(); + let password = platform::get_password(&mut map)?; + Ok((password, map)) + } + + // Delete the password for this item. (Although the item + // itself follows the Rust structure lifecycle, deleting + // the password deletes the platform credential from secure storage.) + pub fn delete_password(&self) -> Result<()> { + platform::delete_password(&self.target) + } +} #[cfg(test)] mod tests { use super::*; - use serial_test::serial; - - static TEST_SERVICE: &'static str = "test.keychain-rs.io"; - static TEST_USER: &'static str = "user@keychain-rs.io"; - static TEST_ASCII_PASSWORD: &'static str = "my_password"; - static TEST_NON_ASCII_PASSWORD: &'static str = "大根"; + use crate::credential::default_target; #[test] - #[serial] - fn test_empty_password_input() { - let pass = ""; - let keyring = Keyring::new("test", "test"); - keyring.set_password(pass).unwrap(); - let out = keyring.get_password().unwrap(); - assert_eq!(pass, out, "Stored and retrieved passwords don't match"); - keyring.delete_password().unwrap(); - assert!( - keyring.get_password().is_err(), - "Able to read a deleted password" - ) + fn test_default_initial_and_retrieved_map() { + let name = generate_random_string(); + 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(); + let (_, target) = entry.get_password_and_credential().unwrap(); + assert_eq!(target, expected_target); + // don't leave password around. + entry.delete_password().unwrap(); } #[test] - #[serial] - fn test_round_trip_ascii_password() { - let keyring = Keyring::new(TEST_SERVICE, TEST_USER); - keyring.set_password(TEST_ASCII_PASSWORD).unwrap(); - let stored_password = keyring.get_password().unwrap(); - assert_eq!(stored_password, TEST_ASCII_PASSWORD); - keyring.delete_password().unwrap(); - assert!( - keyring.get_password().is_err(), - "Able to read a deleted password" - ) + 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(); + } } - #[test] - #[serial] - fn test_round_trip_non_ascii_password() { - let keyring = Keyring::new(TEST_SERVICE, TEST_USER); - keyring.set_password(TEST_NON_ASCII_PASSWORD).unwrap(); - let stored_password = keyring.get_password().unwrap(); - assert_eq!(stored_password, TEST_NON_ASCII_PASSWORD); - keyring.delete_password().unwrap(); - assert!( - keyring.get_password().is_err(), - "Able to read a deleted password" - ) + fn generate_random_string() -> String { + // from the Rust Cookbook: + // https://rust-lang-nursery.github.io/rust-cookbook/algorithms/randomness.html + use rand::{distributions::Alphanumeric, thread_rng, Rng}; + thread_rng() + .sample_iter(&Alphanumeric) + .take(30) + .map(char::from) + .collect() } } diff --git a/src/linux.rs b/src/linux.rs index d853e8c..1437018 100644 --- a/src/linux.rs +++ b/src/linux.rs @@ -1,64 +1,118 @@ -use crate::error::{KeyringError, Result}; -use secret_service::{EncryptionType, SecretService}; -use std::collections::HashMap; +use secret_service::{Collection, EncryptionType, Item, SecretService}; -pub struct Keyring<'a> { - attributes: HashMap<&'a str, &'a str>, - service: &'a str, - username: &'a str, +use crate::{Error as ErrorCode, Platform, PlatformCredential, Result}; + +pub fn platform() -> Platform { + Platform::Linux } -// Eventually try to get collection into the Keyring struct? -impl<'a> Keyring<'a> { - pub fn new(service: &'a str, username: &'a str) -> Keyring<'a> { - let attributes = HashMap::from([("service", service), ("username", username)]); - Keyring { - attributes, - service, - username, - } +use crate::credential::LinuxCredential; +pub use secret_service::Error; + +fn get_collection<'a>(map: &LinuxCredential, ss: &'a SecretService) -> Result> { + let collection = ss + .get_collection_by_alias(map.collection.as_str()) + .map_err(decode_error)?; + if collection.is_locked().map_err(decode_error)? { + collection.unlock().map_err(decode_error)?; } + Ok(collection) +} - pub fn set_password(&self, password: &str) -> Result<()> { - let ss = SecretService::new(EncryptionType::Dh)?; - let collection = ss.get_default_collection()?; - if collection.is_locked()? { - collection.unlock()?; - } - let mut attrs = self.attributes.clone(); - attrs.insert("application", "rust-keyring"); - let label = &format!("Password for {} on {}", self.username, self.service)[..]; - collection.create_item( - label, - attrs, - password.as_bytes(), - true, // replace - "text/plain", - )?; +pub fn set_password(map: &PlatformCredential, password: &str) -> Result<()> { + if let PlatformCredential::Linux(map) = map { + let ss = SecretService::new(EncryptionType::Dh).map_err(ErrorCode::PlatformFailure)?; + let collection = get_collection(map, &ss)?; + collection + .create_item( + map.label.as_str(), + map.attributes(), + password.as_bytes(), + true, // replace + "text/plain", + ) + .map_err(ErrorCode::PlatformFailure)?; Ok(()) + } else { + Err(ErrorCode::WrongCredentialPlatform) } +} - pub fn get_password(&self) -> Result { - let ss = SecretService::new(EncryptionType::Dh)?; - let collection = ss.get_default_collection()?; - if collection.is_locked()? { - collection.unlock()?; - } - let search = collection.search_items(self.attributes.clone())?; - let item = search.get(0).ok_or(KeyringError::NoPasswordFound)?; - let secret_bytes = item.get_secret()?; - let secret = String::from_utf8(secret_bytes)?; - Ok(secret) +pub fn get_password(map: &mut PlatformCredential) -> Result { + if let PlatformCredential::Linux(map) = map { + let ss = SecretService::new(EncryptionType::Dh).map_err(decode_error)?; + let collection = get_collection(map, &ss)?; + let search = collection + .search_items(map.attributes()) + .map_err(decode_error)?; + let item = search.get(0).ok_or(ErrorCode::NoEntry)?; + let bytes = item.get_secret().map_err(decode_error)?; + // Linux keyring allows non-UTF8 values, but this library only supports adding UTF8 items + // to the keyring, so this should only fail if we are trying to retrieve a non-UTF8 + // password that was added to the keyring by another library + decode_attributes(map, item); + decode_password(bytes) + } else { + Err(ErrorCode::WrongCredentialPlatform) + } +} + +pub fn delete_password(map: &PlatformCredential) -> Result<()> { + if let PlatformCredential::Linux(map) = map { + let ss = SecretService::new(EncryptionType::Dh).map_err(decode_error)?; + let collection = get_collection(map, &ss)?; + let search = collection + .search_items(map.attributes()) + .map_err(decode_error)?; + let item = search.get(0).ok_or(ErrorCode::NoEntry)?; + item.delete().map_err(decode_error)?; + Ok(()) + } else { + Err(ErrorCode::WrongCredentialPlatform) } +} + +fn decode_password(bytes: Vec) -> Result { + String::from_utf8(bytes.clone()).map_err(|_| ErrorCode::BadEncoding(bytes)) +} + +fn decode_error(err: Error) -> ErrorCode { + match err { + Error::Crypto(_) => ErrorCode::PlatformFailure(err), + Error::Zbus(_) => ErrorCode::PlatformFailure(err), + Error::ZbusMsg(_) => ErrorCode::PlatformFailure(err), + Error::ZbusFdo(_) => ErrorCode::PlatformFailure(err), + Error::Zvariant(_) => ErrorCode::PlatformFailure(err), + Error::Locked => ErrorCode::NoStorageAccess(err), + Error::NoResult => ErrorCode::NoStorageAccess(err), + Error::Parse => ErrorCode::PlatformFailure(err), + Error::Prompt => ErrorCode::NoStorageAccess(err), + } +} + +fn decode_attributes(map: &mut LinuxCredential, item: &Item) { + if let Ok(label) = item.get_label() { + map.label = label + } +} + +#[cfg(test)] +mod tests { + use super::*; - pub fn delete_password(&self) -> Result<()> { - let ss = SecretService::new(EncryptionType::Dh)?; - let collection = ss.get_default_collection()?; - if collection.is_locked()? { - collection.unlock()?; + #[test] + fn test_bad_password() { + // malformed sequences here taken from: + // https://www.cl.cam.ac.uk/~mgk25/ucs/examples/UTF-8-test.txt + for bytes in [b"\x80".to_vec(), b"\xbf".to_vec(), b"\xed\xa0\xa0".to_vec()] { + match decode_password(bytes.clone()) { + Err(ErrorCode::BadEncoding(str)) => assert_eq!(str, bytes), + Err(other) => panic!( + "Bad password ({:?}) decode gave wrong error: {}", + bytes, other + ), + Ok(s) => panic!("Bad password ({:?}) decode gave results: {:?}", bytes, &s), + } } - let search = collection.search_items(self.attributes.clone())?; - let item = search.get(0).ok_or(KeyringError::NoPasswordFound)?; - Ok(item.delete()?) } } diff --git a/src/macos.rs b/src/macos.rs index 029d3d8..f5249e2 100644 --- a/src/macos.rs +++ b/src/macos.rs @@ -1,138 +1,97 @@ -use crate::error::Result; -use security_framework::os::macos::keychain::SecKeychain; +use security_framework::os::macos::keychain::{SecKeychain, SecPreferencesDomain}; use security_framework::os::macos::passwords::find_generic_password; -use std::path::Path; -pub struct Keyring<'a> { - service: &'a str, - username: &'a str, - path: Option<&'a Path>, -} +use crate::credential::{MacCredential, MacKeychainDomain}; +use crate::{Error as ErrorCode, Platform, PlatformCredential, Result}; -// Eventually try to get collection into the Keyring struct? -impl<'a> Keyring<'a> { - pub fn new(service: &'a str, username: &'a str) -> Keyring<'a> { - Keyring { - service, - username, - path: None, - } - } - - #[cfg(feature = "macos-specify-keychain")] - pub fn use_keychain(service: &'a str, username: &'a str, path: &'a Path) -> Keyring<'a> { - Keyring { - service, - username, - path: Some(path), - } - } +pub fn platform() -> Platform { + Platform::MacOs +} - fn get_keychain(&self) -> security_framework::base::Result { - match self.path { - Some(path) => SecKeychain::open(path), - _ => SecKeychain::default(), - } +pub use security_framework::base::Error; + +fn get_keychain(map: &MacCredential) -> Result { + let domain = match map.domain { + MacKeychainDomain::User => SecPreferencesDomain::User, + MacKeychainDomain::System => SecPreferencesDomain::System, + MacKeychainDomain::Common => SecPreferencesDomain::Common, + MacKeychainDomain::Dynamic => SecPreferencesDomain::Dynamic, + }; + match SecKeychain::default_for_domain(domain) { + Ok(keychain) => Ok(keychain), + Err(err) => Err(decode_error(err)), } +} - pub fn set_password(&self, password: &str) -> Result<()> { - self.get_keychain()?.set_generic_password( - self.service, - self.username, - password.as_bytes(), - )?; - +pub fn set_password(map: &PlatformCredential, password: &str) -> Result<()> { + if let PlatformCredential::Mac(map) = map { + get_keychain(map)? + .set_generic_password(&map.service, &map.account, password.as_bytes()) + .map_err(decode_error)?; Ok(()) + } else { + Err(ErrorCode::WrongCredentialPlatform) } +} - pub fn get_password(&self) -> Result { +pub fn get_password(map: &mut PlatformCredential) -> Result { + if let PlatformCredential::Mac(map) = map { let (password_bytes, _) = - find_generic_password(Some(&[self.get_keychain()?]), self.service, self.username)?; - - // Mac keychain allows non-UTF8 values, but this library only supports adding UTF8 items - // to the keychain, so this should only fail if we are trying to retrieve a non-UTF8 - // password that was added to the keychain by another library - - let password = String::from_utf8(password_bytes.to_vec())?; - - Ok(password) + find_generic_password(Some(&[get_keychain(map)?]), &map.service, &map.account) + .map_err(decode_error)?; + decode_password(password_bytes.to_vec()) + } else { + Err(ErrorCode::WrongCredentialPlatform) } +} - pub fn delete_password(&self) -> Result<()> { +pub fn delete_password(map: &PlatformCredential) -> Result<()> { + if let PlatformCredential::Mac(map) = map { let (_, item) = - find_generic_password(Some(&[self.get_keychain()?]), self.service, self.username)?; - + find_generic_password(Some(&[get_keychain(map)?]), &map.service, &map.account) + .map_err(decode_error)?; item.delete(); - Ok(()) + } else { + Err(ErrorCode::WrongCredentialPlatform) } } -#[cfg(test)] -#[cfg(target_os = "macos")] -mod test { - use super::*; - use serial_test::serial; - - #[test] - #[serial] - fn test_basic() { - let password_1 = "大根"; - let password_2 = "0xE5A4A7E6A0B9"; // Above in hex string - - let keyring = Keyring::new("testservice", "testuser"); - - keyring.set_password(password_1).unwrap(); - let res_1 = keyring.get_password().unwrap(); - assert_eq!( - res_1, password_1, - "Stored and retrieved passwords don't match" - ); - - keyring.set_password(password_2).unwrap(); - let res_2 = keyring.get_password().unwrap(); - assert_eq!( - res_2, password_2, - "Stored and retrieved passwords don't match" - ); +fn decode_password(bytes: Vec) -> Result { + // Mac keychain allows non-UTF8 values, passwords from 3rd parties may not be UTF-8. + String::from_utf8(bytes.clone()).map_err(|_| ErrorCode::BadEncoding(bytes)) +} - keyring.delete_password().unwrap(); - assert!( - keyring.get_password().is_err(), - "Able to read a deleted password" - ) +// The MacOS error codes used here are from: +// https://opensource.apple.com/source/libsecurity_keychain/libsecurity_keychain-78/lib/SecBase.h.auto.html +fn decode_error(err: Error) -> ErrorCode { + match err.code() { + -25291 => ErrorCode::NoStorageAccess(err), // errSecNotAvailable + -25292 => ErrorCode::NoStorageAccess(err), // errSecReadOnly + -25294 => ErrorCode::NoStorageAccess(err), // errSecNoSuchKeychain + -25295 => ErrorCode::NoStorageAccess(err), // errSecInvalidKeychain + -25300 => ErrorCode::NoEntry, // errSecItemNotFound + _ => ErrorCode::PlatformFailure(err), } +} - #[test] - #[ignore] - #[cfg(feature = "macos-specify-keychain")] - #[serial] - fn test_basic_with_features() { - use security_framework::os::macos::keychain; - use tempfile::tempdir; - - let password_1 = "大根"; - let password_2 = "0xE5A4A7E6A0B9"; // Above in hex string - - let dir = tempdir().unwrap(); - let temp_keychain_path = dir.path().join("Temporary.keychain"); - dbg!(&temp_keychain_path); - let temp_keychain = keychain::CreateOptions::new(); - temp_keychain - .create(&temp_keychain_path) - .expect("Could not create temp keychain"); - let keyring = Keyring::use_keychain("testservice", "testuser", &temp_keychain_path); - - keyring.set_password(password_1).unwrap(); - let res_1 = keyring.get_password().unwrap(); - println!("{}:{}", res_1, password_1); - assert_eq!(res_1, password_1); - - keyring.set_password(password_2).unwrap(); - let res_2 = keyring.get_password().unwrap(); - println!("{}:{}", res_2, password_2); - assert_eq!(res_2, password_2); +#[cfg(test)] +mod tests { + use super::*; - keyring.delete_password().unwrap(); + #[test] + fn test_bad_password() { + // malformed sequences here taken from: + // https://www.cl.cam.ac.uk/~mgk25/ucs/examples/UTF-8-test.txt + for bytes in [b"\x80".to_vec(), b"\xbf".to_vec(), b"\xed\xa0\xa0".to_vec()] { + match decode_password(bytes.clone()) { + Err(ErrorCode::BadEncoding(str)) => assert_eq!(str, bytes), + Err(other) => panic!( + "Bad password ({:?}) decode gave wrong error: {}", + bytes, other + ), + Ok(s) => panic!("Bad password ({:?}) decode gave results: {:?}", bytes, &s), + } + } } } diff --git a/src/windows.rs b/src/windows.rs index 42981a5..3f0e815 100644 --- a/src/windows.rs +++ b/src/windows.rs @@ -1,225 +1,332 @@ -use crate::error::{KeyringError, Result}; use byteorder::{ByteOrder, LittleEndian}; -use std::ffi::OsStr; use std::iter::once; use std::mem::MaybeUninit; -use std::os::windows::ffi::OsStrExt; use std::slice; use std::str; use winapi::shared::minwindef::FILETIME; -use winapi::shared::winerror::{ERROR_NOT_FOUND, ERROR_NO_SUCH_LOGON_SESSION}; +use winapi::shared::winerror::{ + ERROR_BAD_USERNAME, ERROR_INVALID_FLAGS, ERROR_INVALID_PARAMETER, ERROR_NOT_FOUND, + ERROR_NO_SUCH_LOGON_SESSION, +}; use winapi::um::errhandlingapi::GetLastError; use winapi::um::wincred::{ - CredDeleteW, CredFree, CredReadW, CredWriteW, CREDENTIALW, CRED_PERSIST_ENTERPRISE, - CRED_TYPE_GENERIC, PCREDENTIALW, PCREDENTIAL_ATTRIBUTEW, + CredDeleteW, CredFree, CredReadW, CredWriteW, CREDENTIALW, CRED_MAX_CREDENTIAL_BLOB_SIZE, + CRED_MAX_GENERIC_TARGET_NAME_LENGTH, CRED_MAX_STRING_LENGTH, CRED_MAX_USERNAME_LENGTH, + CRED_PERSIST_ENTERPRISE, CRED_TYPE_GENERIC, PCREDENTIALW, PCREDENTIAL_ATTRIBUTEW, }; -// DWORD is u32 -// LPCWSTR is *const u16 -// BOOL is i32 (false = 0, true = 1) -// PCREDENTIALW = *mut CREDENTIALW - -// Note: decision to concatenate user and service name -// to create target is because Windows assumes one user -// per service. See issue here: https://github.com/jaraco/keyring/issues/47 +use crate::credential::WinCredential; +use crate::{Error as ErrorCode, Platform, PlatformCredential, Result}; -pub struct Keyring<'a> { - service: &'a str, - username: &'a str, +pub fn platform() -> Platform { + Platform::Windows } -impl<'a> Keyring<'a> { - pub fn new(service: &'a str, username: &'a str) -> Keyring<'a> { - Keyring { service, username } +#[derive(Debug)] +pub struct Error(u32); // Windows error codes are long ints + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match self.0 { + ERROR_NO_SUCH_LOGON_SESSION => write!(f, "Windows ERROR_NO_SUCH_LOGON_SESSION"), + ERROR_NOT_FOUND => write!(f, "Windows ERROR_NOT_FOUND"), + ERROR_BAD_USERNAME => write!(f, "Windows ERROR_BAD_USERNAME"), + ERROR_INVALID_FLAGS => write!(f, "Windows ERROR_INVALID_FLAGS"), + ERROR_INVALID_PARAMETER => write!(f, "Windows ERROR_INVALID_PARAMETER"), + err => write!(f, "Windows error code {}", err), + } } +} - pub fn set_password(&self, password: &str) -> Result<()> { - // Setting values of credential +impl std::error::Error for Error { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + None + } +} +// DWORD is u32 +// LPCWSTR is *const u16 +// BOOL is i32 (false = 0, true = 1) +// PCREDENTIALW = *mut CREDENTIALW +pub fn set_password(map: &PlatformCredential, password: &str) -> Result<()> { + if let PlatformCredential::Win(map) = map { + validate_attributes(map, password)?; + let mut username = to_wstr(&map.username); + let mut target_name = to_wstr(&map.target_name); + let mut target_alias = to_wstr(&map.target_alias); + let mut comment = to_wstr(&map.comment); + // Password strings are converted to UTF-16, because that's the native + // charset for Windows strings. This allows editing of the password in + // the Windows native UI. But the storage for the credential is actually + // a little-endian blob, because passwords can contain anything. + let blob_u16 = to_wstr_no_null(password); + let mut blob = vec![0; blob_u16.len() * 2]; + LittleEndian::write_u16_into(&blob_u16, &mut blob); + let blob_len = blob.len() as u32; let flags = 0; let cred_type = CRED_TYPE_GENERIC; - let target_name: String = [self.username, self.service].join("."); - let mut target_name = to_wstr(&target_name); - - // empty string for comments, and target alias, - // I don't use here - let mut empty_str = to_wstr(""); - + let persist = CRED_PERSIST_ENTERPRISE; // Ignored by CredWriteW let last_written = FILETIME { dwLowDateTime: 0, dwHighDateTime: 0, }; - - // In order to allow editing of the password - // from within Windows, the password must be - // transformed into utf16. (but because it's a - // blob, it then needs to be passed to windows - // as an array of bytes). - let blob_u16 = to_wstr_no_null(password); - let mut blob = vec![0; blob_u16.len() * 2]; - LittleEndian::write_u16_into(&blob_u16, &mut blob); - - let blob_len = blob.len() as u32; - let persist = CRED_PERSIST_ENTERPRISE; + // TODO: Allow setting attributes on Windows credentials let attribute_count = 0; let attributes: PCREDENTIAL_ATTRIBUTEW = std::ptr::null_mut(); - let mut username = to_wstr(self.username); - let mut credential = CREDENTIALW { Flags: flags, Type: cred_type, TargetName: target_name.as_mut_ptr(), - Comment: empty_str.as_mut_ptr(), + Comment: comment.as_mut_ptr(), LastWritten: last_written, CredentialBlobSize: blob_len, CredentialBlob: blob.as_mut_ptr(), Persist: persist, AttributeCount: attribute_count, Attributes: attributes, - TargetAlias: empty_str.as_mut_ptr(), + TargetAlias: target_alias.as_mut_ptr(), UserName: username.as_mut_ptr(), }; // raw pointer to credential, is coerced from &mut let pcredential: PCREDENTIALW = &mut credential; - // Call windows API match unsafe { CredWriteW(pcredential, 0) } { - 0 => Err(KeyringError::WindowsVaultError), + 0 => Err(decode_error()), _ => Ok(()), } + } else { + Err(ErrorCode::WrongCredentialPlatform) } +} - pub fn get_password(&self) -> Result { +pub fn get_password(map: &mut PlatformCredential) -> Result { + if let PlatformCredential::Win(map) = map { + validate_attributes(map, "")?; + let target_name = to_wstr(&map.target_name); // passing uninitialized pcredential. - // Should be ok; it's freed by a windows api - // call CredFree. + // Should be ok; it's freed by a windows api call CredFree. let mut pcredential = MaybeUninit::uninit(); - - let target_name: String = [self.username, self.service].join("."); - let target_name = to_wstr(&target_name); - let cred_type = CRED_TYPE_GENERIC; - - // Windows api call - match unsafe { CredReadW(target_name.as_ptr(), cred_type, 0, pcredential.as_mut_ptr()) } { - 0 => unsafe { - match GetLastError() { - ERROR_NOT_FOUND => Err(KeyringError::NoPasswordFound), - ERROR_NO_SUCH_LOGON_SESSION => Err(KeyringError::NoBackendFound), - _ => Err(KeyringError::WindowsVaultError), - } - }, + let result = + unsafe { CredReadW(target_name.as_ptr(), cred_type, 0, pcredential.as_mut_ptr()) }; + match result { + 0 => Err(decode_error()), _ => { let pcredential = unsafe { pcredential.assume_init() }; // Dereferencing pointer to credential let credential: CREDENTIALW = unsafe { *pcredential }; - - // get blob by creating an array from the pointer - // and the length reported back from the credential - let blob_pointer: *const u8 = credential.CredentialBlob; - let blob_len: usize = credential.CredentialBlobSize as usize; - - // blob needs to be transformed from bytes to an - // array of u16, which will then be transformed into - // a utf8 string. As noted above, this is to allow - // editing of the password from within the vault order - // or other windows programs, which operate in utf16 - let blob: &[u8] = unsafe { slice::from_raw_parts(blob_pointer, blob_len) }; - let mut blob_u16 = vec![0; blob_len / 2]; - LittleEndian::read_u16_into(blob, &mut blob_u16); - - // Now can get utf8 string from the array - let password = - String::from_utf16(&blob_u16).map_err(|_| KeyringError::WindowsVaultError); - + decode_attributes(map, &credential); + let password = decode_password(&credential); // Free the credential unsafe { CredFree(pcredential as *mut _); } - password } } + } else { + Err(ErrorCode::WrongCredentialPlatform) } +} - pub fn delete_password(&self) -> Result<()> { - let target_name: String = [self.username, self.service].join("."); - +pub fn delete_password(map: &PlatformCredential) -> Result<()> { + if let PlatformCredential::Win(map) = map { + validate_attributes(map, "")?; + let target_name = to_wstr(&map.target_name); let cred_type = CRED_TYPE_GENERIC; - let target_name = to_wstr(&target_name); - match unsafe { CredDeleteW(target_name.as_ptr(), cred_type, 0) } { - 0 => unsafe { - match GetLastError() { - ERROR_NOT_FOUND => Err(KeyringError::NoPasswordFound), - ERROR_NO_SUCH_LOGON_SESSION => Err(KeyringError::NoBackendFound), - _ => Err(KeyringError::WindowsVaultError), - } - }, + 0 => Err(decode_error()), _ => Ok(()), } + } else { + Err(ErrorCode::WrongCredentialPlatform) + } +} + +fn validate_attributes(map: &WinCredential, password: &str) -> Result<()> { + if map.username.len() > CRED_MAX_USERNAME_LENGTH as usize { + return Err(ErrorCode::TooLong( + String::from("username"), + CRED_MAX_USERNAME_LENGTH, + )); + } + if map.target_name.len() > CRED_MAX_GENERIC_TARGET_NAME_LENGTH as usize { + return Err(ErrorCode::TooLong( + String::from("target name"), + CRED_MAX_GENERIC_TARGET_NAME_LENGTH, + )); + } + if map.target_alias.len() > CRED_MAX_STRING_LENGTH as usize { + return Err(ErrorCode::TooLong( + String::from("target alias"), + CRED_MAX_STRING_LENGTH, + )); + } + if map.comment.len() > CRED_MAX_STRING_LENGTH as usize { + return Err(ErrorCode::TooLong( + String::from("comment"), + CRED_MAX_STRING_LENGTH, + )); + } + if password.len() > CRED_MAX_CREDENTIAL_BLOB_SIZE as usize { + return Err(ErrorCode::TooLong( + String::from("password"), + CRED_MAX_CREDENTIAL_BLOB_SIZE, + )); + } + Ok(()) +} + +fn decode_error() -> ErrorCode { + match unsafe { GetLastError() } { + ERROR_NOT_FOUND => ErrorCode::NoEntry, + ERROR_NO_SUCH_LOGON_SESSION => { + ErrorCode::NoStorageAccess(Error(ERROR_NO_SUCH_LOGON_SESSION)) + } + err => ErrorCode::PlatformFailure(Error(err)), + } +} + +fn decode_attributes(map: &mut WinCredential, credential: &CREDENTIALW) { + map.username = unsafe { from_wstr(credential.UserName) }; + map.comment = unsafe { from_wstr(credential.Comment) }; + map.target_alias = unsafe { from_wstr(credential.TargetAlias) }; +} + +fn decode_password(credential: &CREDENTIALW) -> Result { + // get password blob + let blob_pointer: *const u8 = credential.CredentialBlob; + let blob_len: usize = credential.CredentialBlobSize as usize; + let blob = unsafe { slice::from_raw_parts(blob_pointer, blob_len) }; + // 3rd parties may write credential data with an odd number of bytes, + // so we make sure that we don't try to decode those as utf16 + if blob.len() % 2 != 0 { + let err = ErrorCode::BadEncoding(blob.to_vec()); + return Err(err); } + // Now we know this _can_ be a UTF-16 string, so convert it to + // as UTF-16 vector and then try to decode it. + let mut blob_u16 = vec![0; blob.len() / 2]; + LittleEndian::read_u16_into(&blob.to_vec(), &mut blob_u16); + String::from_utf16(&blob_u16).map_err(|_| ErrorCode::BadEncoding(blob.to_vec())) } -// helper function for turning utf8 strings to windows -// utf16 fn to_wstr(s: &str) -> Vec { - OsStr::new(s).encode_wide().chain(once(0)).collect() + s.encode_utf16().chain(once(0)).collect() } fn to_wstr_no_null(s: &str) -> Vec { - OsStr::new(s).encode_wide().collect() + s.encode_utf16().collect() +} + +unsafe fn from_wstr(ws: *const u16) -> String { + // null pointer case, return empty string + if ws.is_null() { + return String::new(); + } + // this code from https://stackoverflow.com/a/48587463/558006 + let len = (0..).take_while(|&i| *ws.offset(i) != 0).count(); + let slice = std::slice::from_raw_parts(ws, len); + String::from_utf16_lossy(slice) } #[cfg(test)] -#[cfg(target_os = "windows")] -mod test { +mod tests { use super::*; + use std::ptr::null_mut; #[test] - fn test_basic() { - let password_1 = "大根"; - let password_2 = "0xE5A4A7E6A0B9"; // Above in hex string - - let keyring = Keyring::new("testservice", "testuser"); - - keyring.set_password(password_1).unwrap(); - let res_1 = keyring.get_password().unwrap(); - assert_eq!( - res_1, password_1, - "Stored and retrieved passwords don't match" - ); - - keyring.set_password(password_2).unwrap(); - let res_2 = keyring.get_password().unwrap(); - assert_eq!( - res_2, password_2, - "Stored and retrieved passwords don't match" - ); + fn test_bad_password() { + // first malformed sequence can't be UTF-16 because it has an odd number of bytes + // second malformed sequence is a first surrogate marker (0xd801) in little-endian + // form, and it doesn't have a companion so it's invalid. + for bytes in [b"1".to_vec(), b"\x01\xd8".to_vec()] { + let credential = make_platform_credential(bytes.clone()); + match decode_password(&credential) { + Err(ErrorCode::BadEncoding(str)) => assert_eq!(str, bytes), + Err(other) => panic!( + "Bad password ({:?}) decode gave wrong error: {}", + bytes, other + ), + Ok(s) => panic!("Bad password ({:?}) decode gave results: {:?}", bytes, &s), + } + } + } - keyring.delete_password().unwrap(); - assert!( - keyring.get_password().is_err(), - "Able to read a deleted password" - ) + fn make_platform_credential(mut password: Vec) -> CREDENTIALW { + let last_written = FILETIME { + dwLowDateTime: 0, + dwHighDateTime: 0, + }; + let attribute_count = 0; + let attributes: PCREDENTIAL_ATTRIBUTEW = std::ptr::null_mut(); + CREDENTIALW { + Flags: 0, + Type: CRED_TYPE_GENERIC, + TargetName: null_mut(), + Comment: null_mut(), + LastWritten: last_written, + CredentialBlobSize: password.len() as u32, + CredentialBlob: password.as_mut_ptr(), + Persist: CRED_PERSIST_ENTERPRISE, + AttributeCount: attribute_count, + Attributes: attributes, + TargetAlias: null_mut(), + UserName: null_mut(), + } } #[test] - fn test_no_password() { - let keyring = Keyring::new("testservice", "test-no-password"); - let result = keyring.get_password(); - match result { - Ok(_) => panic!("expected KeyringError::NoPassword, got Ok"), - Err(KeyringError::NoPasswordFound) => (), - Err(e) => panic!("expected KeyringError::NoPassword, got {:}", e), + fn test_bad_inputs() { + let cred = WinCredential { + username: "username".to_string(), + target_name: "target_name".to_string(), + target_alias: "target_alias".to_string(), + comment: "comment".to_string(), + }; + for (attr, len) in [ + ("username", CRED_MAX_USERNAME_LENGTH), + ("target name", CRED_MAX_GENERIC_TARGET_NAME_LENGTH), + ("target alias", CRED_MAX_STRING_LENGTH), + ("comment", CRED_MAX_STRING_LENGTH), + ("password", CRED_MAX_CREDENTIAL_BLOB_SIZE), + ] { + let long_string = generate_random_string(1 + len as usize); + let mut bad_cred = cred.clone(); + let mut password = "password"; + match attr { + "username" => bad_cred.username = long_string.clone(), + "target name" => bad_cred.target_name = long_string.clone(), + "target alias" => bad_cred.target_alias = long_string.clone(), + "comment" => bad_cred.comment = long_string.clone(), + "password" => password = &long_string, + other => panic!("unexpected attribute: {}", other), + } + let map = PlatformCredential::Win(bad_cred); + validate_attribute_too_long(set_password(&map, password), attr, len); } + } - let result = keyring.delete_password(); + fn validate_attribute_too_long(result: Result<()>, attr: &str, len: u32) { match result { - Ok(_) => panic!("expected Err(KeyringError::NoPassword), got Ok()"), - Err(KeyringError::NoPasswordFound) => (), - Err(e) => panic!("expected KeyringError::NoPassword, got {:}", e), + Err(ErrorCode::TooLong(arg, val)) => { + assert_eq!(&arg, attr, "Error names wrong attribute"); + assert_eq!(val, len, "Error names wrong limit"); + } + Err(other) => panic!("Err not 'username too long': {}", other), + Ok(_) => panic!("No error when {} too long", attr), } } + + fn generate_random_string(len: usize) -> String { + // from the Rust Cookbook: + // https://rust-lang-nursery.github.io/rust-cookbook/algorithms/randomness.html + use rand::{distributions::Alphanumeric, thread_rng, Rng}; + thread_rng() + .sample_iter(&Alphanumeric) + .take(len) + .map(char::from) + .collect() + } } diff --git a/tests/basic.rs b/tests/basic.rs new file mode 100644 index 0000000..131f94f --- /dev/null +++ b/tests/basic.rs @@ -0,0 +1,97 @@ +use keyring::{credential::default_target, platform, Entry, Error}; + +doc_comment::doctest!("../README.md"); + +#[test] +fn test_empty_keyring() { + let name = generate_random_string(); + let entry = Entry::new(&name, &name); + assert!( + matches!(entry.get_password(), Err(Error::NoEntry)), + "Read a password from a non-existent platform item" + ) +} + +#[test] +fn test_empty_password_input() { + let name = generate_random_string(); + let entry = Entry::new(&name, &name); + let in_pass = ""; + entry.set_password(in_pass).unwrap(); + let out_pass = entry.get_password().unwrap(); + assert_eq!(in_pass, out_pass); + entry.delete_password().unwrap(); + assert!( + matches!(entry.get_password(), Err(Error::NoEntry)), + "Able to read a deleted password" + ) +} + +#[test] +fn test_round_trip_ascii_password() { + let name = generate_random_string(); + let entry = Entry::new(&name, &name); + let password = "test ascii password"; + entry.set_password(password).unwrap(); + let stored_password = entry.get_password().unwrap(); + assert_eq!(stored_password, password); + entry.delete_password().unwrap(); + assert!(matches!(entry.get_password(), Err(Error::NoEntry))) +} + +#[test] +fn test_round_trip_non_ascii_password() { + let name = generate_random_string(); + let entry = Entry::new(&name, &name); + let password = "このきれいな花は桜です"; + entry.set_password(password).unwrap(); + let stored_password = entry.get_password().unwrap(); + assert_eq!(stored_password, password); + entry.delete_password().unwrap(); + assert!(matches!(entry.get_password(), Err(Error::NoEntry))) +} + +#[test] +fn test_independent_credential_and_password() { + let name = generate_random_string(); + let entry = Entry::new(&name, &name); + let password = "このきれいな花は桜です"; + entry.set_password(password).unwrap(); + let (stored_password, credential1) = entry.get_password_and_credential().unwrap(); + assert_eq!(stored_password, password); + let password = "test ascii password"; + entry.set_password(password).unwrap(); + let (stored_password, credential2) = entry.get_password_and_credential().unwrap(); + assert_eq!(stored_password, password); + assert_eq!(credential1, credential2); + entry.delete_password().unwrap(); + assert!( + matches!(entry.get_password(), Err(Error::NoEntry)), + "Able to read a deleted password" + ) +} + +#[test] +fn test_same_target() { + let name = generate_random_string(); + let entry1 = Entry::new(&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(); + let password2 = entry2.get_password().unwrap(); + assert_eq!(password2, password1); + entry1.delete_password().unwrap(); + assert!(matches!(entry2.delete_password(), Err(Error::NoEntry))) +} + +fn generate_random_string() -> String { + // from the Rust Cookbook: + // https://rust-lang-nursery.github.io/rust-cookbook/algorithms/randomness.html + use rand::{distributions::Alphanumeric, thread_rng, Rng}; + thread_rng() + .sample_iter(&Alphanumeric) + .take(30) + .map(char::from) + .collect() +}