Skip to content

Commit

Permalink
Merge pull request #212 from brotskydotcom/attributes
Browse files Browse the repository at this point in the history
Add support for credential-store attributes.  Fixes #208.
  • Loading branch information
brotskydotcom committed Sep 18, 2024
2 parents d80de84 + 0fff59b commit 11ace18
Show file tree
Hide file tree
Showing 13 changed files with 689 additions and 157 deletions.
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,25 @@
## Version 3.3.0
- Add support for credential-store attributes other than those used by this crate. This allows the creation of credentials that are more compatible with 3rd-party clients, such as the OS-provided GUIs over credentials.
- Make the textual descriptions of entries consistently follow the form `user@service` (or `user@service:target` if a target was specified).

## Version 3.2.1
- Re-enable access to v1 credentials. The fixes of version 3.2 meant that legacy credentials with no target attribute couldn't be accessed.

## Version 3.2.0
- Improve secret-service handling of targets, so that searches on locked items distinguish items with different targets properly.

## Version 3.1.0
- enhance the CLI to allow empty user names and better info about `Ambiguous` credentials.

## Version 3.0.5
- updated docs and clean up dead code. No code changes.

## Version 3.0.4
- expose a cross-platform module alias via the `default` module.

## Version 3.0.3
- fix feature `linux-native`, which was causing compile errors.

## Version 3.0.2
- add missing implementations for iOS `set_secret` and `get_secret`

Expand Down
3 changes: 2 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ keywords = ["password", "credential", "keychain", "keyring", "cross-platform"]
license = "MIT OR Apache-2.0"
name = "keyring"
repository = "https://github.com/hwchen/keyring-rs.git"
version = "3.2.1"
version = "3.3.0"
rust-version = "1.75"
edition = "2021"
exclude = [".github/"]
Expand Down Expand Up @@ -64,6 +64,7 @@ path = "examples/cli.rs"
base64 = "0.22"
clap = { version = "4", features = ["derive", "wrap_help"] }
rpassword = "7"
rprompt = "2"
rand = "0.8"
doc-comment = "0.3"
whoami = "1"
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ Thanks to the following for helping make this library better, whether through co
- @russellbanks
- @ryanavella
- @samuela
- @ShaunSHamilton
- @stankec
- @steveatinfincia
- @Sytten
Expand Down
8 changes: 4 additions & 4 deletions build-xplat-docs.sh
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
#!/bin/bash
cargo doc --no-deps --target aarch64-unknown-linux-musl $OPEN_DOCS
cargo doc --no-deps --target aarch64-pc-windows-msvc $OPEN_DOCS
cargo doc --no-deps --target aarch64-apple-darwin $OPEN_DOCS
cargo doc --no-deps --target aarch64-apple-ios $OPEN_DOCS
cargo doc --no-deps --features=linux-native --target aarch64-unknown-linux-musl $OPEN_DOCS
cargo doc --no-deps --features=windows-native --target aarch64-pc-windows-msvc $OPEN_DOCS
cargo doc --no-deps --features=apple-native --target aarch64-apple-darwin $OPEN_DOCS
cargo doc --no-deps --features=apple-native --target aarch64-apple-ios $OPEN_DOCS
230 changes: 175 additions & 55 deletions examples/cli.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
extern crate keyring;

use clap::Parser;
use clap::{Args, Parser};
use std::collections::HashMap;

use keyring::{Entry, Error, Result};

Expand All @@ -21,40 +22,52 @@ fn main() {
};
match &args.command {
Command::Set { .. } => {
let (secret, password) = args.get_password();
if let Some(secret) = secret {
match entry.set_secret(&secret) {
Ok(()) => args.success_message_for(Some(&secret), None),
let value = args.get_password_and_attributes();
match &value {
Value::Secret(secret) => match entry.set_secret(secret) {
Ok(()) => args.success_message_for(&value),
Err(err) => args.error_message_for(err),
}
} else if let Some(password) = password {
match entry.set_password(&password) {
Ok(()) => args.success_message_for(None, Some(&password)),
},
Value::Password(password) => match entry.set_password(password) {
Ok(()) => args.success_message_for(&value),
Err(err) => args.error_message_for(err),
},
Value::Attributes(attributes) => {
let attrs: HashMap<&str, &str> = attributes
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect();
match entry.update_attributes(&attrs) {
Ok(()) => args.success_message_for(&value),
Err(err) => args.error_message_for(err),
}
}
} else {
if args.verbose {
eprintln!("You must provide a password to the set command");
}
std::process::exit(1)
_ => panic!("Can't set without a value"),
}
}
Command::Password => match entry.get_password() {
Ok(password) => {
println!("{password}");
args.success_message_for(None, Some(&password));
args.success_message_for(&Value::Password(password));
}
Err(err) => args.error_message_for(err),
},
Command::Secret => match entry.get_secret() {
Ok(secret) => {
println!("{}", secret_string(&secret));
args.success_message_for(Some(&secret), None);
args.success_message_for(&Value::Secret(secret));
}
Err(err) => args.error_message_for(err),
},
Command::Attributes => match entry.get_attributes() {
Ok(attributes) => {
println!("{}", attributes_string(&attributes));
args.success_message_for(&Value::Attributes(attributes));
}
Err(err) => args.error_message_for(err),
},
Command::Delete => match entry.delete_credential() {
Ok(()) => args.success_message_for(None, None),
Ok(()) => args.success_message_for(&Value::None),
Err(err) => args.error_message_for(err),
},
}
Expand Down Expand Up @@ -87,32 +100,58 @@ pub struct Cli {

#[derive(Debug, Parser)]
pub enum Command {
/// Set the password in the secure store
/// Set the password or update the attributes in the secure store
Set {
#[command(flatten)]
what: What,

#[clap(value_parser)]
/// The password to set into the secure store.
/// If it's a valid base64 encoding (with padding),
/// it will be decoded and used to set the binary secret.
/// Otherwise, it will be interpreted as a string password.
/// If no password is specified, it will be
/// collected interactively (without echo)
/// from the terminal.
password: Option<String>,
/// The input to parse. If not specified, it will be
/// read interactively from the terminal. Password/secret
/// input will not be echoed.
input: Option<String>,
},
/// Retrieve the (string) password from the secure store
/// and write it to the standard output.
Password,
/// Retrieve the (binary) secret from the secure store
/// and write it in base64 encoding to the standard output.
Secret,
/// Delete the underlying credential from the secure store.
/// Retrieve attributes available in the secure store.
Attributes,
/// Delete the credential from the secure store.
Delete,
}

#[derive(Debug, Args)]
#[group(multiple = false, required = true)]
pub struct What {
#[clap(short, long, action, help = "The input is a password")]
password: bool,

#[clap(short, long, action, help = "The input is a base64-encoded secret")]
secret: bool,

#[clap(
short,
long,
action,
help = "The input is comma-separated, key=val attribute pairs"
)]
attributes: bool,
}

enum Value {
Secret(Vec<u8>),
Password(String),
Attributes(HashMap<String, String>),
None,
}

impl Cli {
fn description(&self) -> String {
if let Some(target) = &self.target {
format!("[{target}]{}@{}", &self.user, &self.service)
format!("{}@{}:{target}", &self.user, &self.service)
} else {
format!("{}@{}", &self.user, &self.service)
}
Expand Down Expand Up @@ -146,6 +185,9 @@ impl Cli {
Command::Secret => {
eprintln!("Couldn't get secret for '{description}': {err}");
}
Command::Attributes => {
eprintln!("Couldn't get attributes for '{description}': {err}");
}
Command::Delete => {
eprintln!("Couldn't delete credential for '{description}': {err}");
}
Expand All @@ -155,46 +197,69 @@ impl Cli {
std::process::exit(1)
}

fn success_message_for(&self, secret: Option<&[u8]>, password: Option<&str>) {
fn success_message_for(&self, value: &Value) {
if !self.verbose {
return;
}
let description = self.description();
match self.command {
Command::Set { .. } => {
if let Some(pw) = password {
eprintln!("Set password for '{description}' to '{pw}'");
}
if let Some(secret) = secret {
Command::Set { .. } => match value {
Value::Secret(secret) => {
let secret = secret_string(secret);
eprintln!("Set secret for '{description}' to decode of '{secret}'");
}
}
Value::Password(password) => {
eprintln!("Set password for '{description}' to '{password}'");
}
Value::Attributes(attributes) => {
eprintln!("The following attributes for '{description}' were sent for update:");
eprint_attributes(attributes);
}
_ => panic!("Can't set without a value"),
},
Command::Password => {
let pw = password.unwrap();
eprintln!("Password for '{description}' is '{pw}'");
}
Command::Secret => {
let secret = secret_string(secret.unwrap());
eprintln!("Secret for '{description}' encodes as {secret}");
match value {
Value::Password(password) => {
eprintln!("Password for '{description}' is '{password}'");
}
_ => panic!("Wrong value type for command"),
};
}
Command::Secret => match value {
Value::Secret(secret) => {
let encoded = secret_string(secret);
eprintln!("Secret for '{description}' encodes as {encoded}");
}
_ => panic!("Wrong value type for command"),
},
Command::Attributes => match value {
Value::Attributes(attributes) => {
if attributes.is_empty() {
eprintln!("No attributes found for '{description}'");
} else {
eprintln!("Attributes for '{description}' are:");
eprint_attributes(attributes);
}
}
_ => panic!("Wrong value type for command"),
},
Command::Delete => {
eprintln!("Successfully deleted credential for '{description}'");
}
}
}

fn get_password(&self) -> (Option<Vec<u8>>, Option<String>) {
match &self.command {
Command::Set { password: Some(pw) } => password_or_secret(pw),
Command::Set { password: None } => {
if let Ok(password) = rpassword::prompt_password("Password: ") {
password_or_secret(&password)
} else {
(None, None)
}
fn get_password_and_attributes(&self) -> Value {
if let Command::Set { what, input } = &self.command {
if what.password {
Value::Password(read_password(input))
} else if what.secret {
Value::Secret(decode_secret(input))
} else {
Value::Attributes(parse_attributes(input))
}
_ => (None, None),
} else {
panic!("Can't happen: asking for password and attributes on non-set command")
}
}
}
Expand All @@ -205,11 +270,66 @@ fn secret_string(secret: &[u8]) -> String {
BASE64_STANDARD.encode(secret)
}

fn password_or_secret(input: &str) -> (Option<Vec<u8>>, Option<String>) {
fn eprint_attributes(attributes: &HashMap<String, String>) {
for (key, value) in attributes {
println!(" {key}: {value}");
}
}

fn decode_secret(input: &Option<String>) -> Vec<u8> {
use base64::prelude::*;

match BASE64_STANDARD.decode(input) {
Ok(secret) => (Some(secret), None),
Err(_) => (None, Some(input.to_string())),
let encoded = if let Some(input) = input {
input.clone()
} else {
rpassword::prompt_password("Base64 encoding: ").unwrap_or_else(|_| String::new())
};
if encoded.is_empty() {
return Vec::new();
}
match BASE64_STANDARD.decode(encoded) {
Ok(secret) => secret,
Err(err) => {
eprintln!("Sorry, the provided secret data is not base64-encoded: {err}");
std::process::exit(1);
}
}
}

fn read_password(input: &Option<String>) -> String {
if let Some(input) = input {
input.clone()
} else {
rpassword::prompt_password("Password: ").unwrap_or_else(|_| String::new())
}
}

fn attributes_string(attributes: &HashMap<String, String>) -> String {
let strings = attributes
.iter()
.map(|(k, v)| format!("{}={}", k, v))
.collect::<Vec<_>>();
strings.join(",")
}

fn parse_attributes(input: &Option<String>) -> HashMap<String, String> {
let input = if let Some(input) = input {
input.clone()
} else {
rprompt::prompt_reply("Attributes: ").unwrap_or_else(|_| String::new())
};
if input.is_empty() {
eprintln!("You must specify at least one key=value attribute pair to set")
}
let mut attributes = HashMap::new();
let parts = input.split(',');
for s in parts.into_iter() {
let parts: Vec<&str> = s.split("=").collect();
if parts.len() != 2 || parts[0].is_empty() {
eprintln!("Sorry, this part of the attributes string is not a key=val pair: {s}");
std::process::exit(1);
}
attributes.insert(parts[0].to_string(), parts[1].to_string());
}
attributes
}
Loading

0 comments on commit 11ace18

Please sign in to comment.