use std::{fmt::Write as _, io::Write as _, os::unix::ffi::OsStrExt as _};
use crate::bin_error::{self, ContextExt as _};
const TOTP_DEFAULT_STEP: u64 = 30;
#[allow(clippy::many_single_char_names)]
fn format_rfc3339(t: std::time::SystemTime) -> String {
let dur = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
let secs = dur.as_secs();
let nanos = dur.subsec_nanos();
let days = secs / 86_400;
let rem = secs % 86_400;
let (hour, r) = (rem / 3600, rem % 3600);
let (minute, second) = (r / 60, r % 60);
let z = days + 719_468;
let era = z / 146_097;
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y0 = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let day = doy - (153 * mp + 2) / 5 + 1;
let month = if mp < 10 { mp + 3 } else { mp - 9 };
let year = if month <= 2 { y0 + 1 } else { y0 };
format!(
"{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}.{nanos:09}Z"
)
}
const MISSING_CONFIG_HELP: &str =
"Before using bwx, you must configure the email address you would like to \
use to log in to the server by running:\n\n \
bwx config set email <email>\n\n\
Additionally, if you are using a self-hosted installation, you should \
run:\n\n \
bwx config set base_url <url>\n\n\
and, if your server has a non-default identity url:\n\n \
bwx config set identity_url <url>\n";
#[derive(Debug, Clone)]
pub enum Needle {
Name(String),
Uri(url::Url),
Uuid(bwx::uuid::Uuid, String),
}
impl std::fmt::Display for Needle {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let value = match &self {
Self::Name(name) => name.clone(),
Self::Uri(uri) => uri.to_string(),
Self::Uuid(_, s) => s.clone(),
};
write!(f, "{value}")
}
}
#[allow(clippy::unnecessary_wraps)]
pub fn parse_needle(arg: &str) -> Result<Needle, std::convert::Infallible> {
if let Ok(uuid) = arg.parse::<bwx::uuid::Uuid>() {
return Ok(Needle::Uuid(uuid, arg.to_string()));
}
if let Ok(url) = url::Url::parse(arg) {
if url.is_special() {
return Ok(Needle::Uri(url));
}
}
Ok(Needle::Name(arg.to_string()))
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
enum Field {
Notes,
Username,
Password,
Totp,
Uris,
IdentityName,
City,
State,
PostalCode,
Country,
Phone,
Ssn,
License,
Passport,
CardNumber,
Expiration,
ExpMonth,
ExpYear,
Cvv,
Cardholder,
Brand,
Name,
Email,
Address,
Address1,
Address2,
Address3,
Fingerprint,
PublicKey,
PrivateKey,
Title,
FirstName,
MiddleName,
LastName,
}
impl std::str::FromStr for Field {
type Err = crate::bin_error::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(match s.to_lowercase().as_str() {
"notes" | "note" => Self::Notes,
"username" | "user" => Self::Username,
"password" => Self::Password,
"totp" | "code" => Self::Totp,
"uris" | "urls" | "sites" => Self::Uris,
"identityname" => Self::IdentityName,
"city" => Self::City,
"state" => Self::State,
"postcode" | "zipcode" | "zip" => Self::PostalCode,
"country" => Self::Country,
"phone" => Self::Phone,
"ssn" => Self::Ssn,
"license" => Self::License,
"passport" => Self::Passport,
"number" | "card" => Self::CardNumber,
"exp" => Self::Expiration,
"exp_month" | "month" => Self::ExpMonth,
"exp_year" | "year" => Self::ExpYear,
"cvv" => Self::Cvv,
"cardholder" | "cardholder_name" => Self::Cardholder,
"brand" | "type" => Self::Brand,
"name" => Self::Name,
"email" => Self::Email,
"address1" => Self::Address1,
"address2" => Self::Address2,
"address3" => Self::Address3,
"address" => Self::Address,
"fingerprint" => Self::Fingerprint,
"public_key" => Self::PublicKey,
"private_key" => Self::PrivateKey,
"title" => Self::Title,
"first_name" => Self::FirstName,
"middle_name" => Self::MiddleName,
"last_name" => Self::LastName,
_ => crate::bin_error::bail!("unknown field {s}"),
})
}
}
impl Field {
fn as_str(&self) -> &str {
match self {
Self::Notes => "notes",
Self::Username => "username",
Self::Password => "password",
Self::Totp => "totp",
Self::Uris => "uris",
Self::IdentityName => "identityname",
Self::City => "city",
Self::State => "state",
Self::PostalCode => "postcode",
Self::Country => "country",
Self::Phone => "phone",
Self::Ssn => "ssn",
Self::License => "license",
Self::Passport => "passport",
Self::CardNumber => "number",
Self::Expiration => "exp",
Self::ExpMonth => "exp_month",
Self::ExpYear => "exp_year",
Self::Cvv => "cvv",
Self::Cardholder => "cardholder",
Self::Brand => "brand",
Self::Name => "name",
Self::Email => "email",
Self::Address1 => "address1",
Self::Address2 => "address2",
Self::Address3 => "address3",
Self::Address => "address",
Self::Fingerprint => "fingerprint",
Self::PublicKey => "public_key",
Self::PrivateKey => "private_key",
Self::Title => "title",
Self::FirstName => "first_name",
Self::MiddleName => "middle_name",
Self::LastName => "last_name",
}
}
}
impl std::fmt::Display for Field {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, serde::Serialize)]
struct DecryptedListCipher {
id: String,
name: Option<String>,
user: Option<String>,
folder: Option<String>,
uris: Option<Vec<String>>,
#[serde(rename = "type")]
entry_type: Option<String>,
}
#[derive(Debug, Clone, serde::Serialize)]
#[cfg_attr(test, derive(Eq, PartialEq))]
struct DecryptedSearchCipher {
id: String,
#[serde(rename = "type")]
entry_type: String,
folder: Option<String>,
name: String,
user: Option<String>,
uris: Vec<(String, Option<bwx::api::UriMatchType>)>,
fields: Vec<String>,
notes: Option<String>,
}
impl DecryptedSearchCipher {
fn display_name(&self) -> String {
self.user.as_ref().map_or_else(
|| self.name.clone(),
|user| format!("{user}@{}", self.name),
)
}
fn matches(
&self,
needle: &Needle,
username: Option<&str>,
folder: Option<&str>,
ignore_case: bool,
strict_username: bool,
strict_folder: bool,
exact: bool,
) -> bool {
let match_str = match (ignore_case, exact) {
(true, true) => |field: &str, search_term: &str| {
field.to_lowercase() == search_term.to_lowercase()
},
(true, false) => |field: &str, search_term: &str| {
field.to_lowercase().contains(&search_term.to_lowercase())
},
(false, true) => {
|field: &str, search_term: &str| field == search_term
}
(false, false) => {
|field: &str, search_term: &str| field.contains(search_term)
}
};
match (self.folder.as_deref(), folder) {
(Some(folder), Some(given_folder)) => {
if !match_str(folder, given_folder) {
return false;
}
}
(Some(_), None) => {
if strict_folder {
return false;
}
}
(None, Some(_)) => {
return false;
}
(None, None) => {}
}
match (&self.user, username) {
(Some(username), Some(given_username)) => {
if !match_str(username, given_username) {
return false;
}
}
(Some(_), None) => {
if strict_username {
return false;
}
}
(None, Some(_)) => {
return false;
}
(None, None) => {}
}
match needle {
Needle::Uuid(uuid, s) => {
if self.id.parse::<bwx::uuid::Uuid>() != Ok(*uuid)
&& !match_str(&self.name, s)
{
return false;
}
}
Needle::Name(name) => {
if !match_str(&self.name, name) {
return false;
}
}
Needle::Uri(given_uri) => {
if self.uris.iter().all(|(uri, match_type)| {
!matches_url(uri, *match_type, given_uri)
}) {
return false;
}
}
}
true
}
fn search_match(&self, term: &str, folder: Option<&str>) -> bool {
if let Some(folder) = folder {
if self.folder.as_deref() != Some(folder) {
return false;
}
}
let mut fields = vec![self.name.clone()];
if let Some(notes) = &self.notes {
fields.push(notes.clone());
}
if let Some(user) = &self.user {
fields.push(user.clone());
}
fields.extend(self.uris.iter().map(|(uri, _)| uri).cloned());
fields.extend(self.fields.iter().cloned());
for field in fields {
if field.to_lowercase().contains(&term.to_lowercase()) {
return true;
}
}
false
}
}
impl From<DecryptedSearchCipher> for DecryptedListCipher {
fn from(value: DecryptedSearchCipher) -> Self {
Self {
id: value.id,
entry_type: Some(value.entry_type),
name: Some(value.name),
user: value.user,
folder: value.folder,
uris: Some(value.uris.into_iter().map(|(s, _)| s).collect()),
}
}
}
#[derive(Debug, Clone, serde::Serialize)]
#[cfg_attr(test, derive(Eq, PartialEq))]
struct DecryptedCipher {
id: String,
folder: Option<String>,
name: String,
data: DecryptedData,
fields: Vec<DecryptedField>,
notes: Option<String>,
history: Vec<DecryptedHistoryEntry>,
}
impl DecryptedCipher {
fn display_short(&self, desc: &str, clipboard: bool) -> bool {
match &self.data {
DecryptedData::Login { password, .. } => {
password.as_ref().map_or_else(
|| {
eprintln!("entry for '{desc}' had no password");
false
},
|password| val_display_or_store(clipboard, password),
)
}
DecryptedData::Card { number, .. } => {
number.as_ref().map_or_else(
|| {
eprintln!("entry for '{desc}' had no card number");
false
},
|number| val_display_or_store(clipboard, number),
)
}
DecryptedData::Identity {
title,
first_name,
middle_name,
last_name,
..
} => {
let names: Vec<_> =
[title, first_name, middle_name, last_name]
.iter()
.copied()
.flatten()
.cloned()
.collect();
if names.is_empty() {
eprintln!("entry for '{desc}' had no name");
false
} else {
val_display_or_store(clipboard, &names.join(" "))
}
}
DecryptedData::SecureNote => self.notes.as_ref().map_or_else(
|| {
eprintln!("entry for '{desc}' had no notes");
false
},
|notes| val_display_or_store(clipboard, notes),
),
DecryptedData::SshKey { public_key, .. } => {
public_key.as_ref().map_or_else(
|| {
eprintln!("entry for '{desc}' had no public key");
false
},
|public_key| val_display_or_store(clipboard, public_key),
)
}
}
}
fn display_field(&self, desc: &str, field: &str, clipboard: bool) {
let field = field.to_lowercase();
let field = field.as_str();
match &self.data {
DecryptedData::Login {
username,
totp,
uris,
..
} => match field.parse() {
Ok(Field::Notes) => {
if let Some(notes) = &self.notes {
val_display_or_store(clipboard, notes);
}
}
Ok(Field::Username) => {
if let Some(username) = &username {
val_display_or_store(clipboard, username);
}
}
Ok(Field::Totp) => {
if let Some(totp) = totp {
match generate_totp(totp) {
Ok(code) => {
val_display_or_store(clipboard, &code);
}
Err(e) => {
eprintln!("{e}");
}
}
}
}
Ok(Field::Uris) => {
if let Some(uris) = uris {
let uri_strs: Vec<_> =
uris.iter().map(|uri| uri.uri.clone()).collect();
val_display_or_store(clipboard, &uri_strs.join("\n"));
}
}
Ok(Field::Password) => {
self.display_short(desc, clipboard);
}
_ => {
for f in &self.fields {
if let Some(name) = &f.name {
if name.to_lowercase().as_str().contains(field) {
val_display_or_store(
clipboard,
f.value.as_deref().unwrap_or(""),
);
break;
}
}
}
}
},
DecryptedData::Card {
cardholder_name,
brand,
exp_month,
exp_year,
code,
..
} => match field.parse() {
Ok(Field::CardNumber) => {
self.display_short(desc, clipboard);
}
Ok(Field::Expiration) => {
if let (Some(month), Some(year)) = (exp_month, exp_year) {
val_display_or_store(
clipboard,
&format!("{month}/{year}"),
);
}
}
Ok(Field::ExpMonth) => {
if let Some(exp_month) = exp_month {
val_display_or_store(clipboard, exp_month);
}
}
Ok(Field::ExpYear) => {
if let Some(exp_year) = exp_year {
val_display_or_store(clipboard, exp_year);
}
}
Ok(Field::Cvv) => {
if let Some(code) = code {
val_display_or_store(clipboard, code);
}
}
Ok(Field::Name | Field::Cardholder) => {
if let Some(cardholder_name) = cardholder_name {
val_display_or_store(clipboard, cardholder_name);
}
}
Ok(Field::Brand) => {
if let Some(brand) = brand {
val_display_or_store(clipboard, brand);
}
}
Ok(Field::Notes) => {
if let Some(notes) = &self.notes {
val_display_or_store(clipboard, notes);
}
}
_ => {
for f in &self.fields {
if let Some(name) = &f.name {
if name.to_lowercase().as_str().contains(field) {
val_display_or_store(
clipboard,
f.value.as_deref().unwrap_or(""),
);
break;
}
}
}
}
},
DecryptedData::Identity {
address1,
address2,
address3,
city,
state,
postal_code,
country,
phone,
email,
ssn,
license_number,
passport_number,
username,
..
} => match field.parse() {
Ok(Field::Name) => {
self.display_short(desc, clipboard);
}
Ok(Field::Email) => {
if let Some(email) = email {
val_display_or_store(clipboard, email);
}
}
Ok(Field::Address) => {
let mut strs = vec![];
if let Some(address1) = address1 {
strs.push(address1.clone());
}
if let Some(address2) = address2 {
strs.push(address2.clone());
}
if let Some(address3) = address3 {
strs.push(address3.clone());
}
if !strs.is_empty() {
val_display_or_store(clipboard, &strs.join("\n"));
}
}
Ok(Field::City) => {
if let Some(city) = city {
val_display_or_store(clipboard, city);
}
}
Ok(Field::State) => {
if let Some(state) = state {
val_display_or_store(clipboard, state);
}
}
Ok(Field::PostalCode) => {
if let Some(postal_code) = postal_code {
val_display_or_store(clipboard, postal_code);
}
}
Ok(Field::Country) => {
if let Some(country) = country {
val_display_or_store(clipboard, country);
}
}
Ok(Field::Phone) => {
if let Some(phone) = phone {
val_display_or_store(clipboard, phone);
}
}
Ok(Field::Ssn) => {
if let Some(ssn) = ssn {
val_display_or_store(clipboard, ssn);
}
}
Ok(Field::License) => {
if let Some(license_number) = license_number {
val_display_or_store(clipboard, license_number);
}
}
Ok(Field::Passport) => {
if let Some(passport_number) = passport_number {
val_display_or_store(clipboard, passport_number);
}
}
Ok(Field::Username) => {
if let Some(username) = username {
val_display_or_store(clipboard, username);
}
}
Ok(Field::Notes) => {
if let Some(notes) = &self.notes {
val_display_or_store(clipboard, notes);
}
}
_ => {
for f in &self.fields {
if let Some(name) = &f.name {
if name.to_lowercase().as_str().contains(field) {
val_display_or_store(
clipboard,
f.value.as_deref().unwrap_or(""),
);
break;
}
}
}
}
},
DecryptedData::SecureNote => match field.parse() {
Ok(Field::Notes) => {
self.display_short(desc, clipboard);
}
_ => {
for f in &self.fields {
if let Some(name) = &f.name {
if name.to_lowercase().as_str().contains(field) {
val_display_or_store(
clipboard,
f.value.as_deref().unwrap_or(""),
);
break;
}
}
}
}
},
DecryptedData::SshKey {
fingerprint,
private_key,
..
} => match field.parse() {
Ok(Field::Fingerprint) => {
if let Some(fingerprint) = fingerprint {
val_display_or_store(clipboard, fingerprint);
}
}
Ok(Field::PublicKey) => {
self.display_short(desc, clipboard);
}
Ok(Field::PrivateKey) => {
if let Some(private_key) = private_key {
val_display_or_store(clipboard, private_key);
}
}
Ok(Field::Notes) => {
if let Some(notes) = &self.notes {
val_display_or_store(clipboard, notes);
}
}
_ => {
for f in &self.fields {
if let Some(name) = &f.name {
if name.to_lowercase().as_str().contains(field) {
val_display_or_store(
clipboard,
f.value.as_deref().unwrap_or(""),
);
break;
}
}
}
}
},
}
}
fn display_long(&self, desc: &str, clipboard: bool) {
match &self.data {
DecryptedData::Login {
username,
totp,
uris,
..
} => {
let mut displayed = self.display_short(desc, clipboard);
displayed |=
display_field("Username", username.as_deref(), clipboard);
displayed |=
display_field("TOTP Secret", totp.as_deref(), clipboard);
if let Some(uris) = uris {
for uri in uris {
displayed |=
display_field("URI", Some(&uri.uri), clipboard);
let match_type =
uri.match_type.map(|ty| format!("{ty}"));
displayed |= display_field(
"Match type",
match_type.as_deref(),
clipboard,
);
}
}
for field in &self.fields {
displayed |= display_field(
field.name.as_deref().unwrap_or("(null)"),
Some(field.value.as_deref().unwrap_or("")),
clipboard,
);
}
if let Some(notes) = &self.notes {
if displayed {
println!();
}
println!("{notes}");
}
}
DecryptedData::Card {
cardholder_name,
brand,
exp_month,
exp_year,
code,
..
} => {
let mut displayed = false;
displayed |= self.display_short(desc, clipboard);
if let (Some(exp_month), Some(exp_year)) =
(exp_month, exp_year)
{
println!("Expiration: {exp_month}/{exp_year}");
displayed = true;
}
displayed |= display_field("CVV", code.as_deref(), clipboard);
displayed |= display_field(
"Name",
cardholder_name.as_deref(),
clipboard,
);
displayed |=
display_field("Brand", brand.as_deref(), clipboard);
if let Some(notes) = &self.notes {
if displayed {
println!();
}
println!("{notes}");
}
}
DecryptedData::Identity {
address1,
address2,
address3,
city,
state,
postal_code,
country,
phone,
email,
ssn,
license_number,
passport_number,
username,
..
} => {
let mut displayed = self.display_short(desc, clipboard);
displayed |=
display_field("Address", address1.as_deref(), clipboard);
displayed |=
display_field("Address", address2.as_deref(), clipboard);
displayed |=
display_field("Address", address3.as_deref(), clipboard);
displayed |=
display_field("City", city.as_deref(), clipboard);
displayed |=
display_field("State", state.as_deref(), clipboard);
displayed |= display_field(
"Postcode",
postal_code.as_deref(),
clipboard,
);
displayed |=
display_field("Country", country.as_deref(), clipboard);
displayed |=
display_field("Phone", phone.as_deref(), clipboard);
displayed |=
display_field("Email", email.as_deref(), clipboard);
displayed |= display_field("SSN", ssn.as_deref(), clipboard);
displayed |= display_field(
"License",
license_number.as_deref(),
clipboard,
);
displayed |= display_field(
"Passport",
passport_number.as_deref(),
clipboard,
);
displayed |=
display_field("Username", username.as_deref(), clipboard);
if let Some(notes) = &self.notes {
if displayed {
println!();
}
println!("{notes}");
}
}
DecryptedData::SecureNote => {
self.display_short(desc, clipboard);
}
DecryptedData::SshKey { fingerprint, .. } => {
let mut displayed = self.display_short(desc, clipboard);
displayed |= display_field(
"Fingerprint",
fingerprint.as_deref(),
clipboard,
);
for field in &self.fields {
displayed |= display_field(
field.name.as_deref().unwrap_or("(null)"),
Some(field.value.as_deref().unwrap_or("")),
clipboard,
);
}
if let Some(notes) = &self.notes {
if displayed {
println!();
}
println!("{notes}");
}
}
}
}
fn display_fields_list(&self) {
match &self.data {
DecryptedData::Login {
username,
password,
totp,
uris,
..
} => {
if username.is_some() {
println!("{}", Field::Username);
}
if totp.is_some() {
println!("{}", Field::Totp);
}
if uris.is_some() {
println!("{}", Field::Uris);
}
if password.is_some() {
println!("{}", Field::Password);
}
}
DecryptedData::Card {
cardholder_name,
number,
brand,
exp_month,
exp_year,
code,
..
} => {
if number.is_some() {
println!("{}", Field::CardNumber);
}
if exp_month.is_some() {
println!("{}", Field::ExpMonth);
}
if exp_year.is_some() {
println!("{}", Field::ExpYear);
}
if code.is_some() {
println!("{}", Field::Cvv);
}
if cardholder_name.is_some() {
println!("{}", Field::Cardholder);
}
if brand.is_some() {
println!("{}", Field::Brand);
}
}
DecryptedData::Identity {
address1,
address2,
address3,
city,
state,
postal_code,
country,
phone,
email,
ssn,
license_number,
passport_number,
username,
title,
first_name,
middle_name,
last_name,
..
} => {
if [title, first_name, middle_name, last_name]
.iter()
.any(|f| f.is_some())
{
println!("name");
}
if email.is_some() {
println!("{}", Field::Email);
}
if [address1, address2, address3].iter().any(|f| f.is_some())
{
println!("address");
}
if city.is_some() {
println!("{}", Field::City);
}
if state.is_some() {
println!("{}", Field::State);
}
if postal_code.is_some() {
println!("{}", Field::PostalCode);
}
if country.is_some() {
println!("{}", Field::Country);
}
if phone.is_some() {
println!("{}", Field::Phone);
}
if ssn.is_some() {
println!("{}", Field::Ssn);
}
if license_number.is_some() {
println!("{}", Field::License);
}
if passport_number.is_some() {
println!("{}", Field::Passport);
}
if username.is_some() {
println!("{}", Field::Username);
}
}
DecryptedData::SecureNote => (), DecryptedData::SshKey {
fingerprint,
public_key,
..
} => {
if fingerprint.is_some() {
println!("{}", Field::Fingerprint);
}
if public_key.is_some() {
println!("{}", Field::PublicKey);
}
}
}
if self.notes.is_some() {
println!("{}", Field::Notes);
}
for f in &self.fields {
if let Some(name) = &f.name {
println!("{name}");
}
}
}
fn display_json(&self, desc: &str) -> bin_error::Result<()> {
serde_json::to_writer_pretty(std::io::stdout(), &self)
.context(format!("failed to write entry '{desc}' to stdout"))?;
println!();
Ok(())
}
}
fn val_display_or_store(clipboard: bool, password: &str) -> bool {
if clipboard {
match clipboard_store(password) {
Ok(()) => true,
Err(e) => {
eprintln!("{e}");
false
}
}
} else {
println!("{password}");
true
}
}
#[derive(Debug, Clone, serde::Serialize)]
#[serde(untagged)]
#[cfg_attr(test, derive(Eq, PartialEq))]
enum DecryptedData {
Login {
username: Option<String>,
password: Option<String>,
totp: Option<String>,
uris: Option<Vec<DecryptedUri>>,
},
Card {
cardholder_name: Option<String>,
number: Option<String>,
brand: Option<String>,
exp_month: Option<String>,
exp_year: Option<String>,
code: Option<String>,
},
Identity {
title: Option<String>,
first_name: Option<String>,
middle_name: Option<String>,
last_name: Option<String>,
address1: Option<String>,
address2: Option<String>,
address3: Option<String>,
city: Option<String>,
state: Option<String>,
postal_code: Option<String>,
country: Option<String>,
phone: Option<String>,
email: Option<String>,
ssn: Option<String>,
license_number: Option<String>,
passport_number: Option<String>,
username: Option<String>,
},
SecureNote,
SshKey {
public_key: Option<String>,
fingerprint: Option<String>,
private_key: Option<String>,
},
}
#[derive(Debug, Clone, serde::Serialize)]
#[cfg_attr(test, derive(Eq, PartialEq))]
struct DecryptedField {
name: Option<String>,
value: Option<String>,
#[serde(serialize_with = "serialize_field_type", rename = "type")]
ty: Option<bwx::api::FieldType>,
}
#[allow(clippy::trivially_copy_pass_by_ref, clippy::ref_option)]
fn serialize_field_type<S>(
ty: &Option<bwx::api::FieldType>,
serializer: S,
) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
match ty {
Some(ty) => {
let s = match ty {
bwx::api::FieldType::Text => "text",
bwx::api::FieldType::Hidden => "hidden",
bwx::api::FieldType::Boolean => "boolean",
bwx::api::FieldType::Linked => "linked",
};
serializer.serialize_some(&Some(s))
}
None => serializer.serialize_none(),
}
}
#[derive(Debug, Clone, serde::Serialize)]
#[cfg_attr(test, derive(Eq, PartialEq))]
struct DecryptedHistoryEntry {
last_used_date: String,
password: String,
}
#[derive(Debug, Clone, serde::Serialize)]
#[cfg_attr(test, derive(Eq, PartialEq))]
struct DecryptedUri {
uri: String,
match_type: Option<bwx::api::UriMatchType>,
}
fn matches_url(
url: &str,
match_type: Option<bwx::api::UriMatchType>,
given_url: &url::Url,
) -> bool {
match match_type.unwrap_or(bwx::api::UriMatchType::Domain) {
bwx::api::UriMatchType::Domain => {
let Some(given_host_port) = host_port(given_url) else {
return false;
};
if let Ok(self_url) = url::Url::parse(url) {
if let Some(self_host_port) = host_port(&self_url) {
if self_url.scheme() == given_url.scheme()
&& (self_host_port == given_host_port
|| given_host_port
.ends_with(&format!(".{self_host_port}")))
{
return true;
}
}
}
url == given_host_port
|| given_host_port.ends_with(&format!(".{url}"))
}
bwx::api::UriMatchType::Host => {
let Some(given_host_port) = host_port(given_url) else {
return false;
};
if let Ok(self_url) = url::Url::parse(url) {
if let Some(self_host_port) = host_port(&self_url) {
if self_url.scheme() == given_url.scheme()
&& self_host_port == given_host_port
{
return true;
}
}
}
url == given_host_port
}
bwx::api::UriMatchType::StartsWith => {
given_url.to_string().starts_with(url)
}
bwx::api::UriMatchType::Exact => {
if given_url.path() == "/" {
given_url.to_string().trim_end_matches('/')
== url.trim_end_matches('/')
} else {
given_url.to_string() == url
}
}
bwx::api::UriMatchType::RegularExpression => {
let Ok(rx) = regex::Regex::new(url) else {
return false;
};
rx.is_match(given_url.as_ref())
}
bwx::api::UriMatchType::Never => false,
}
}
fn host_port(url: &url::Url) -> Option<String> {
let host = url.host_str()?;
Some(
url.port().map_or_else(
|| host.to_string(),
|port| format!("{host}:{port}"),
),
)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ListField {
Id,
Name,
User,
Folder,
Uri,
EntryType,
}
impl ListField {
fn all() -> Vec<Self> {
vec![
Self::Id,
Self::Name,
Self::User,
Self::Folder,
Self::Uri,
Self::EntryType,
]
}
}
impl std::convert::TryFrom<&String> for ListField {
type Error = crate::bin_error::Error;
fn try_from(s: &String) -> bin_error::Result<Self> {
Ok(match s.as_str() {
"name" => Self::Name,
"id" => Self::Id,
"user" => Self::User,
"folder" => Self::Folder,
"type" => Self::EntryType,
_ => return Err(crate::bin_error::err!("unknown field {s}")),
})
}
}
const HELP_PW: &str = r"
# The first line of this file will be the password, and the remainder of the
# file (after any blank lines after the password) will be stored as a note.
# Lines with leading # will be ignored.
";
const HELP_NOTES: &str = r"
# The content of this file will be stored as a note.
# Lines with leading # will be ignored.
";
pub fn config_show(key: Option<&str>) -> bin_error::Result<()> {
let config = bwx::config::Config::load()?;
let Some(key) = key else {
serde_json::to_writer_pretty(std::io::stdout(), &config)
.context("failed to write config to stdout")?;
println!();
return Ok(());
};
match key {
"email" => print_opt(config.email.as_deref()),
"sso_id" => print_opt(config.sso_id.as_deref()),
"base_url" => print_opt(config.base_url.as_deref()),
"identity_url" => print_opt(config.identity_url.as_deref()),
"ui_url" => print_opt(config.ui_url.as_deref()),
"notifications_url" => print_opt(config.notifications_url.as_deref()),
"client_cert_path" => print_opt(
config.client_cert_path.as_deref().and_then(|p| p.to_str()),
),
"lock_timeout" => println!("{}", config.lock_timeout),
"sync_interval" => println!("{}", config.sync_interval),
"pinentry" => println!("{}", config.pinentry),
"ssh_confirm_sign" => println!("{}", config.ssh_confirm_sign),
"macos_unlock_dialog" => println!("{}", config.macos_unlock_dialog),
"touchid_gate" => println!("{}", config.touchid_gate),
other => {
return Err(crate::bin_error::err!(
"invalid config key: {other}"
));
}
}
Ok(())
}
fn print_opt(v: Option<&str>) {
if let Some(s) = v {
println!("{s}");
}
}
pub fn config_set(key: &str, value: &str) -> bin_error::Result<()> {
let mut config = bwx::config::Config::load()
.unwrap_or_else(|_| bwx::config::Config::new());
match key {
"email" => config.email = Some(value.to_string()),
"sso_id" => config.sso_id = Some(value.to_string()),
"base_url" => config.base_url = Some(value.to_string()),
"identity_url" => config.identity_url = Some(value.to_string()),
"ui_url" => config.ui_url = Some(value.to_string()),
"notifications_url" => {
config.notifications_url = Some(value.to_string());
}
"client_cert_path" => {
config.client_cert_path =
Some(std::path::PathBuf::from(value.to_string()));
}
"lock_timeout" => {
let timeout = value
.parse()
.context("failed to parse value for lock_timeout")?;
if timeout == 0 {
log::error!("lock_timeout must be greater than 0");
} else {
config.lock_timeout = timeout;
}
}
"sync_interval" => {
let interval = value
.parse()
.context("failed to parse value for sync_interval")?;
config.sync_interval = interval;
}
"pinentry" => config.pinentry = value.to_string(),
"ssh_confirm_sign" => {
config.ssh_confirm_sign = value
.parse()
.context("ssh_confirm_sign must be 'true' or 'false'")?;
}
"macos_unlock_dialog" => {
config.macos_unlock_dialog = value
.parse()
.context("macos_unlock_dialog must be 'true' or 'false'")?;
}
"touchid_gate" => {
let gate: bwx::touchid::Gate =
value.parse().map_err(bin_error::Error::msg)?;
#[cfg(not(target_os = "macos"))]
if !matches!(gate, bwx::touchid::Gate::Off) {
return Err(bin_error::Error::msg(
"touchid_gate is only supported on macOS; the only \
accepted value on this platform is 'off'",
));
}
config.touchid_gate = gate;
}
_ => return Err(crate::bin_error::err!("invalid config key: {key}")),
}
config.save()?;
stop_agent()?;
Ok(())
}
pub fn config_unset(key: &str) -> bin_error::Result<()> {
let mut config = bwx::config::Config::load()
.unwrap_or_else(|_| bwx::config::Config::new());
match key {
"email" => config.email = None,
"sso_id" => config.sso_id = None,
"base_url" => config.base_url = None,
"identity_url" => config.identity_url = None,
"ui_url" => config.ui_url = None,
"notifications_url" => config.notifications_url = None,
"client_cert_path" => config.client_cert_path = None,
"lock_timeout" => {
config.lock_timeout = bwx::config::default_lock_timeout();
}
"pinentry" => config.pinentry = bwx::config::default_pinentry(),
"ssh_confirm_sign" => config.ssh_confirm_sign = false,
"macos_unlock_dialog" => {
config.macos_unlock_dialog =
bwx::config::default_macos_unlock_dialog();
}
"touchid_gate" => config.touchid_gate = bwx::touchid::Gate::Off,
_ => return Err(crate::bin_error::err!("invalid config key: {key}")),
}
config.save()?;
stop_agent()?;
Ok(())
}
fn clipboard_store(val: &str) -> bin_error::Result<()> {
ensure_agent()?;
crate::actions::clipboard_store(val)?;
Ok(())
}
pub fn register() -> bin_error::Result<()> {
ensure_agent()?;
crate::actions::register()?;
Ok(())
}
pub fn login() -> bin_error::Result<()> {
ensure_agent()?;
crate::actions::login()?;
Ok(())
}
pub fn unlock() -> bin_error::Result<()> {
ensure_agent()?;
crate::actions::login()?;
crate::actions::unlock()?;
Ok(())
}
pub fn unlocked() -> bin_error::Result<()> {
let _ = check_agent_version();
crate::actions::unlocked()?;
Ok(())
}
pub fn sync() -> bin_error::Result<()> {
ensure_agent()?;
crate::actions::login()?;
crate::actions::sync()?;
Ok(())
}
pub fn list(fields: &[String], raw: bool) -> bin_error::Result<()> {
let fields: Vec<ListField> = if raw {
ListField::all()
} else {
fields
.iter()
.map(std::convert::TryFrom::try_from)
.collect::<bin_error::Result<_>>()?
};
unlock()?;
let db = load_db()?;
let mut entries: Vec<DecryptedListCipher> = db
.entries
.iter()
.map(|entry| decrypt_list_cipher(entry, &fields))
.collect::<bin_error::Result<_>>()?;
entries.sort_unstable_by(|a, b| a.name.cmp(&b.name));
print_entry_list(&entries, &fields, raw)?;
Ok(())
}
#[allow(clippy::fn_params_excessive_bools)]
pub fn get(
needle: Needle,
user: Option<&str>,
folder: Option<&str>,
field: Option<&str>,
full: bool,
raw: bool,
clipboard: bool,
ignore_case: bool,
list_fields: bool,
) -> bin_error::Result<()> {
unlock()?;
let db = load_db()?;
let desc = format!(
"{}{}",
user.map_or_else(String::new, |s| format!("{s}@")),
needle
);
let (_, decrypted) =
find_entry(&db, needle, user, folder, ignore_case)
.with_context(|| format!("couldn't find entry for '{desc}'"))?;
if list_fields {
decrypted.display_fields_list();
} else if raw {
decrypted.display_json(&desc)?;
} else if full {
decrypted.display_long(&desc, clipboard);
} else if let Some(field) = field {
decrypted.display_field(&desc, field, clipboard);
} else {
decrypted.display_short(&desc, clipboard);
}
Ok(())
}
fn print_entry_list(
entries: &[DecryptedListCipher],
fields: &[ListField],
raw: bool,
) -> bin_error::Result<()> {
if raw {
serde_json::to_writer_pretty(std::io::stdout(), &entries)
.context("failed to write entries to stdout".to_string())?;
println!();
} else {
for entry in entries {
let values: Vec<String> = fields
.iter()
.map(|field| match field {
ListField::Id => entry.id.clone(),
ListField::Name => entry.name.as_ref().map_or_else(
String::new,
std::string::ToString::to_string,
),
ListField::User => entry.user.as_ref().map_or_else(
String::new,
std::string::ToString::to_string,
),
ListField::Folder => entry.folder.as_ref().map_or_else(
String::new,
std::string::ToString::to_string,
),
ListField::Uri => {
unreachable!()
}
ListField::EntryType => {
entry.entry_type.as_ref().map_or_else(
String::new,
std::string::ToString::to_string,
)
}
})
.collect();
match writeln!(&mut std::io::stdout(), "{}", values.join("\t")) {
Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe => {
Ok(())
}
res => res,
}?;
}
}
Ok(())
}
pub fn search(
term: &str,
fields: &[String],
folder: Option<&str>,
raw: bool,
) -> bin_error::Result<()> {
let fields: Vec<ListField> = if raw {
ListField::all()
} else {
fields
.iter()
.map(std::convert::TryFrom::try_from)
.collect::<bin_error::Result<_>>()?
};
unlock()?;
let db = load_db()?;
let mut entries: Vec<DecryptedListCipher> = db
.entries
.iter()
.map(decrypt_search_cipher)
.filter(|entry| {
entry
.as_ref()
.map_or(true, |entry| entry.search_match(term, folder))
})
.map(|entry| entry.map(std::convert::Into::into))
.collect::<Result<_, crate::bin_error::Error>>()?;
entries.sort_unstable_by(|a, b| a.name.cmp(&b.name));
print_entry_list(&entries, &fields, raw)?;
Ok(())
}
pub fn code(
needle: Needle,
user: Option<&str>,
folder: Option<&str>,
clipboard: bool,
ignore_case: bool,
) -> bin_error::Result<()> {
unlock()?;
let db = load_db()?;
let desc = format!(
"{}{}",
user.map_or_else(String::new, |s| format!("{s}@")),
needle
);
let (_, decrypted) =
find_entry(&db, needle, user, folder, ignore_case)
.with_context(|| format!("couldn't find entry for '{desc}'"))?;
if let DecryptedData::Login { totp, .. } = decrypted.data {
if let Some(totp) = totp {
val_display_or_store(clipboard, &generate_totp(&totp)?);
} else {
return Err(crate::bin_error::err!(
"entry does not contain a totp secret"
));
}
} else {
return Err(crate::bin_error::err!("not a login entry"));
}
Ok(())
}
pub fn add(
name: &str,
username: Option<&str>,
uris: &[(String, Option<bwx::api::UriMatchType>)],
folder: Option<&str>,
) -> bin_error::Result<()> {
unlock()?;
let mut db = load_db()?;
let mut access_token = db.access_token.as_ref().unwrap().clone();
let refresh_token = db.refresh_token.as_ref().unwrap();
let name = crate::actions::encrypt(name, None)?;
let username = username
.map(|username| crate::actions::encrypt(username, None))
.transpose()?;
let contents = bwx::edit::edit("", HELP_PW)?;
let (password, notes) = parse_editor(&contents);
let password = password
.map(|password| crate::actions::encrypt(&password, None))
.transpose()?;
let notes = notes
.map(|notes| crate::actions::encrypt(¬es, None))
.transpose()?;
let uris: Vec<_> = uris
.iter()
.map(|uri| {
Ok(bwx::db::Uri {
uri: crate::actions::encrypt(&uri.0, None)?,
match_type: uri.1,
})
})
.collect::<bin_error::Result<_>>()?;
let mut folder_id = None;
if let Some(folder_name) = folder {
let (new_access_token, folders) =
bwx::actions::list_folders(&access_token, refresh_token)?;
if let Some(new_access_token) = new_access_token {
access_token.clone_from(&new_access_token);
db.access_token = Some(new_access_token);
save_db(&db)?;
}
let folders: Vec<(String, String)> = folders
.iter()
.cloned()
.map(|(id, name)| {
Ok((id, crate::actions::decrypt(&name, None, None)?))
})
.collect::<bin_error::Result<_>>()?;
for (id, name) in folders {
if name == folder_name {
folder_id = Some(id);
}
}
if folder_id.is_none() {
let (new_access_token, id) = bwx::actions::create_folder(
&access_token,
refresh_token,
&crate::actions::encrypt(folder_name, None)?,
)?;
if let Some(new_access_token) = new_access_token {
access_token.clone_from(&new_access_token);
db.access_token = Some(new_access_token);
save_db(&db)?;
}
folder_id = Some(id);
}
}
if let (Some(access_token), ()) = bwx::actions::add(
&access_token,
refresh_token,
&name,
&bwx::db::EntryData::Login {
username,
password,
uris,
totp: None,
},
notes.as_deref(),
folder_id.as_deref(),
)? {
db.access_token = Some(access_token);
save_db(&db)?;
}
crate::actions::sync()?;
Ok(())
}
pub fn generate(
name: Option<&str>,
username: Option<&str>,
uris: &[(String, Option<bwx::api::UriMatchType>)],
folder: Option<&str>,
len: usize,
ty: bwx::pwgen::Type,
) -> bin_error::Result<()> {
let password = bwx::pwgen::pwgen(ty, len);
let password_str = std::str::from_utf8(password.password()).unwrap();
println!("{password_str}");
if let Some(name) = name {
unlock()?;
let mut db = load_db()?;
let mut access_token = db.access_token.as_ref().unwrap().clone();
let refresh_token = db.refresh_token.as_ref().unwrap();
let name = crate::actions::encrypt(name, None)?;
let username = username
.map(|username| crate::actions::encrypt(username, None))
.transpose()?;
let password = crate::actions::encrypt(password_str, None)?;
let uris: Vec<_> = uris
.iter()
.map(|uri| {
Ok(bwx::db::Uri {
uri: crate::actions::encrypt(&uri.0, None)?,
match_type: uri.1,
})
})
.collect::<bin_error::Result<_>>()?;
let mut folder_id = None;
if let Some(folder_name) = folder {
let (new_access_token, folders) =
bwx::actions::list_folders(&access_token, refresh_token)?;
if let Some(new_access_token) = new_access_token {
access_token.clone_from(&new_access_token);
db.access_token = Some(new_access_token);
save_db(&db)?;
}
let folders: Vec<(String, String)> = folders
.iter()
.cloned()
.map(|(id, name)| {
Ok((id, crate::actions::decrypt(&name, None, None)?))
})
.collect::<bin_error::Result<_>>()?;
for (id, name) in folders {
if name == folder_name {
folder_id = Some(id);
}
}
if folder_id.is_none() {
let (new_access_token, id) = bwx::actions::create_folder(
&access_token,
refresh_token,
&crate::actions::encrypt(folder_name, None)?,
)?;
if let Some(new_access_token) = new_access_token {
access_token.clone_from(&new_access_token);
db.access_token = Some(new_access_token);
save_db(&db)?;
}
folder_id = Some(id);
}
}
if let (Some(access_token), ()) = bwx::actions::add(
&access_token,
refresh_token,
&name,
&bwx::db::EntryData::Login {
username,
password: Some(password),
uris,
totp: None,
},
None,
folder_id.as_deref(),
)? {
db.access_token = Some(access_token);
save_db(&db)?;
}
crate::actions::sync()?;
}
Ok(())
}
pub fn edit(
name: Needle,
username: Option<&str>,
folder: Option<&str>,
ignore_case: bool,
) -> bin_error::Result<()> {
unlock()?;
let mut db = load_db()?;
let access_token = db.access_token.as_ref().unwrap();
let refresh_token = db.refresh_token.as_ref().unwrap();
let desc = format!(
"{}{}",
username.map_or_else(String::new, |s| format!("{s}@")),
name
);
let (entry, decrypted) =
find_entry(&db, name, username, folder, ignore_case)
.with_context(|| format!("couldn't find entry for '{desc}'"))?;
let (data, fields, notes, history) = match &decrypted.data {
DecryptedData::Login { password, .. } => {
let mut contents =
format!("{}\n", password.as_deref().unwrap_or(""));
if let Some(notes) = decrypted.notes {
write!(contents, "\n{notes}\n").unwrap();
}
let contents = bwx::edit::edit(&contents, HELP_PW)?;
let (password, notes) = parse_editor(&contents);
let password = password
.map(|password| {
crate::actions::encrypt(
&password,
entry.org_id.as_deref(),
)
})
.transpose()?;
let notes = notes
.map(|notes| {
crate::actions::encrypt(¬es, entry.org_id.as_deref())
})
.transpose()?;
let mut history = entry.history.clone();
let bwx::db::EntryData::Login {
username: entry_username,
password: entry_password,
uris: entry_uris,
totp: entry_totp,
} = &entry.data
else {
unreachable!();
};
if let Some(prev_password) = entry_password.clone() {
let new_history_entry = bwx::db::HistoryEntry {
last_used_date: format_rfc3339(
std::time::SystemTime::now(),
),
password: prev_password,
};
history.insert(0, new_history_entry);
}
let data = bwx::db::EntryData::Login {
username: entry_username.clone(),
password,
uris: entry_uris.clone(),
totp: entry_totp.clone(),
};
(data, entry.fields, notes, history)
}
DecryptedData::SecureNote => {
let data = bwx::db::EntryData::SecureNote {};
let editor_content = decrypted.notes.map_or_else(
|| "\n".to_string(),
|notes| format!("{notes}\n"),
);
let contents = bwx::edit::edit(&editor_content, HELP_NOTES)?;
let (_, notes) = parse_editor(&format!("\n{contents}\n"));
let notes = notes
.map(|notes| {
crate::actions::encrypt(¬es, entry.org_id.as_deref())
})
.transpose()?;
(data, entry.fields, notes, entry.history)
}
_ => {
return Err(crate::bin_error::err!(
"modifications are only supported for login and note entries"
));
}
};
if let (Some(access_token), ()) = bwx::actions::edit(
access_token,
refresh_token,
&entry.id,
entry.org_id.as_deref(),
&entry.name,
&data,
&fields,
notes.as_deref(),
entry.folder_id.as_deref(),
&history,
)? {
db.access_token = Some(access_token);
save_db(&db)?;
}
crate::actions::sync()?;
Ok(())
}
pub fn remove(
name: Needle,
username: Option<&str>,
folder: Option<&str>,
ignore_case: bool,
) -> bin_error::Result<()> {
unlock()?;
let mut db = load_db()?;
let access_token = db.access_token.as_ref().unwrap();
let refresh_token = db.refresh_token.as_ref().unwrap();
let desc = format!(
"{}{}",
username.map_or_else(String::new, |s| format!("{s}@")),
name
);
let (entry, _) = find_entry(&db, name, username, folder, ignore_case)
.with_context(|| format!("couldn't find entry for '{desc}'"))?;
if let (Some(access_token), ()) =
bwx::actions::remove(access_token, refresh_token, &entry.id)?
{
db.access_token = Some(access_token);
save_db(&db)?;
}
crate::actions::sync()?;
Ok(())
}
pub fn history(
name: Needle,
username: Option<&str>,
folder: Option<&str>,
ignore_case: bool,
) -> bin_error::Result<()> {
unlock()?;
let db = load_db()?;
let desc = format!(
"{}{}",
username.map_or_else(String::new, |s| format!("{s}@")),
name
);
let (_, decrypted) = find_entry(&db, name, username, folder, ignore_case)
.with_context(|| format!("couldn't find entry for '{desc}'"))?;
for history in decrypted.history {
println!("{}: {}", history.last_used_date, history.password);
}
Ok(())
}
pub fn lock() -> bin_error::Result<()> {
ensure_agent()?;
crate::actions::lock()?;
Ok(())
}
pub fn ssh_public_key(
name: Needle,
username: Option<&str>,
folder: Option<&str>,
ignore_case: bool,
) -> bin_error::Result<()> {
unlock()?;
let db = load_db()?;
let desc = format!(
"{}{}",
username.map_or_else(String::new, |s| format!("{s}@")),
name
);
let (_, decrypted) = find_entry(&db, name, username, folder, ignore_case)
.with_context(|| format!("couldn't find entry for '{desc}'"))?;
match decrypted.data {
DecryptedData::SshKey {
public_key: Some(pk),
..
} => {
println!("{pk}");
Ok(())
}
DecryptedData::SshKey {
public_key: None, ..
} => Err(bin_error::Error::msg(format!(
"entry '{desc}' has no stored public key"
))),
_ => Err(bin_error::Error::msg(format!(
"entry '{desc}' is not an SSH key"
))),
}
}
pub fn ssh_socket() {
println!("{}", bwx::dirs::ssh_agent_socket_file().display());
}
#[cfg(not(target_os = "macos"))]
pub fn touchid_enroll() -> bin_error::Result<()> {
Err(bin_error::Error::msg("touchid is only supported on macOS"))
}
#[cfg(target_os = "macos")]
pub fn touchid_enroll() -> bin_error::Result<()> {
unlock()?;
crate::actions::touchid_enroll()?;
println!(
"Touch ID enrollment active. Set `touchid_gate` to \
'signing' or 'all' to require a Touch ID prompt on \
sensitive operations."
);
Ok(())
}
pub fn touchid_disable() -> bin_error::Result<()> {
crate::actions::touchid_disable()?;
println!("Touch ID enrollment removed.");
let gate = bwx::config::Config::load()
.map(|c| c.touchid_gate)
.unwrap_or_default();
if !matches!(gate, bwx::touchid::Gate::Off) {
println!(
"\nNote: `touchid_gate` is still '{gate}'; bwx will keep \
prompting for Touch ID on sensitive operations. Run \
`bwx config unset touchid_gate` to stop those prompts."
);
}
Ok(())
}
#[cfg(target_os = "macos")]
const LAUNCHAGENT_LABEL: &str = "drews.website.bwx.ssh-auth-sock";
#[cfg(target_os = "macos")]
const AGENT_LAUNCHAGENT_LABEL: &str = "drews.website.bwx.agent";
#[cfg(not(target_os = "macos"))]
pub fn setup_macos(_force: bool) -> bin_error::Result<()> {
Err(bin_error::Error::msg(
"setup-macos is only supported on macOS",
))
}
#[cfg(target_os = "macos")]
pub fn setup_macos(force: bool) -> bin_error::Result<()> {
do_setup_macos(force)
}
#[cfg(not(target_os = "macos"))]
pub fn teardown_macos() -> bin_error::Result<()> {
Err(bin_error::Error::msg(
"teardown-macos is only supported on macOS",
))
}
#[cfg(target_os = "macos")]
pub fn teardown_macos() -> bin_error::Result<()> {
do_teardown_macos()
}
#[cfg(target_os = "macos")]
fn do_setup_macos(force: bool) -> bin_error::Result<()> {
let home = std::env::var_os("HOME")
.map(std::path::PathBuf::from)
.ok_or_else(|| bin_error::Error::msg("$HOME not set"))?;
let bwx_bin = std::env::current_exe()
.map_err(|e| bin_error::Error::msg(format!("current_exe: {e}")))?;
let helper_dir = home.join("bin");
let helper = helper_dir.join("bwx-set-ssh-sock");
let launch_agents = home.join("Library/LaunchAgents");
let plist = launch_agents.join(format!("{LAUNCHAGENT_LABEL}.plist"));
let agent_plist =
launch_agents.join(format!("{AGENT_LAUNCHAGENT_LABEL}.plist"));
let agent_bin = bwx_bin
.parent()
.map(|d| d.join("bwx-agent"))
.ok_or_else(|| {
bin_error::Error::msg("couldn't resolve bwx-agent path")
})?;
if (helper.exists() || plist.exists() || agent_plist.exists()) && !force {
return Err(bin_error::Error::msg(format!(
"setup already exists ({} / {} / {}); pass --force to overwrite",
helper.display(),
plist.display(),
agent_plist.display(),
)));
}
std::fs::create_dir_all(&helper_dir).map_err(|e| {
bin_error::Error::msg(format!("mkdir {}: {e}", helper_dir.display()))
})?;
std::fs::create_dir_all(&launch_agents).map_err(|e| {
bin_error::Error::msg(format!(
"mkdir {}: {e}",
launch_agents.display()
))
})?;
let helper_body = format!(
"#!/bin/sh\n\
# Managed by `bwx setup-macos`. Edit the bwx binary path if \
you move it.\n\
exec /bin/launchctl setenv SSH_AUTH_SOCK \"$({bwx} ssh-socket)\"\n",
bwx = bwx_bin.display(),
);
std::fs::write(&helper, helper_body).map_err(|e| {
bin_error::Error::msg(format!("write {}: {e}", helper.display()))
})?;
{
use std::os::unix::fs::PermissionsExt as _;
let mut perms = std::fs::metadata(&helper)
.map_err(|e| bin_error::Error::msg(e.to_string()))?
.permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&helper, perms)
.map_err(|e| bin_error::Error::msg(e.to_string()))?;
}
let plist_body = format!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\"\n \
\"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n\
<plist version=\"1.0\">\n\
<dict>\n \
<key>Label</key><string>{LAUNCHAGENT_LABEL}</string>\n \
<key>RunAtLoad</key><true/>\n \
<key>ProgramArguments</key>\n \
<array>\n \
<string>{helper}</string>\n \
</array>\n\
</dict>\n\
</plist>\n",
helper = helper.display(),
);
std::fs::write(&plist, plist_body).map_err(|e| {
bin_error::Error::msg(format!("write {}: {e}", plist.display()))
})?;
let data_dir = bwx::dirs::agent_stdout_file().parent().map_or_else(
|| home.join(".cache/bwx"),
std::path::Path::to_path_buf,
);
std::fs::create_dir_all(&data_dir).ok();
let agent_stdout = data_dir.join("launchd-agent.out");
let agent_stderr = data_dir.join("launchd-agent.err");
let agent_plist_body = format!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\"\n \
\"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n\
<plist version=\"1.0\">\n\
<dict>\n \
<key>Label</key><string>{AGENT_LAUNCHAGENT_LABEL}</string>\n \
<key>RunAtLoad</key><true/>\n \
<key>KeepAlive</key><true/>\n \
<key>StandardOutPath</key><string>{stdout}</string>\n \
<key>StandardErrorPath</key><string>{stderr}</string>\n \
<key>ProgramArguments</key>\n \
<array>\n \
<string>{agent}</string>\n \
<string>--no-daemonize</string>\n \
</array>\n\
</dict>\n\
</plist>\n",
agent = agent_bin.display(),
stdout = agent_stdout.display(),
stderr = agent_stderr.display(),
);
std::fs::write(&agent_plist, agent_plist_body).map_err(|e| {
bin_error::Error::msg(format!("write {}: {e}", agent_plist.display()))
})?;
let uid = rustix::process::getuid().as_raw();
for label in [LAUNCHAGENT_LABEL, AGENT_LAUNCHAGENT_LABEL] {
let _ = std::process::Command::new("/bin/launchctl")
.args(["bootout", &format!("gui/{uid}/{label}")])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
}
for pl in [&plist, &agent_plist] {
let status = std::process::Command::new("/bin/launchctl")
.args(["bootstrap", &format!("gui/{uid}"), &pl.to_string_lossy()])
.status()
.map_err(|e| {
bin_error::Error::msg(format!("launchctl bootstrap: {e}"))
})?;
if !status.success() {
return Err(bin_error::Error::msg(format!(
"launchctl bootstrap {} exited {status}",
pl.display()
)));
}
}
let socket = std::process::Command::new(&bwx_bin)
.arg("ssh-socket")
.output()
.map_err(|e| bin_error::Error::msg(format!("bwx ssh-socket: {e}")))?;
let socket = String::from_utf8_lossy(&socket.stdout).trim().to_string();
let _ = std::process::Command::new("/bin/launchctl")
.args(["setenv", "SSH_AUTH_SOCK", &socket])
.status();
println!("Installed LaunchAgents:");
println!(" {} (sets SSH_AUTH_SOCK)", plist.display());
println!(" {} (keeps bwx-agent running)", agent_plist.display());
println!("Helper script: {}", helper.display());
println!("SSH_AUTH_SOCK: {socket}");
println!();
println!(
"GUI apps that were already running won't pick this up until \
they are fully quit (Cmd-Q) and relaunched. Terminal sessions \
started after this point will see SSH_AUTH_SOCK automatically."
);
println!("Append to your bashrc/zshrc:\n\n export SSH_AUTH_SOCK=\"$(bwx ssh-socket)\"");
Ok(())
}
#[cfg(target_os = "macos")]
fn do_teardown_macos() -> bin_error::Result<()> {
let home = std::env::var_os("HOME")
.map(std::path::PathBuf::from)
.ok_or_else(|| bin_error::Error::msg("$HOME not set"))?;
let helper = home.join("bin/bwx-set-ssh-sock");
let plist =
home.join(format!("Library/LaunchAgents/{LAUNCHAGENT_LABEL}.plist"));
let agent_plist = home.join(format!(
"Library/LaunchAgents/{AGENT_LAUNCHAGENT_LABEL}.plist"
));
let uid = rustix::process::getuid().as_raw();
for label in [LAUNCHAGENT_LABEL, AGENT_LAUNCHAGENT_LABEL] {
let _ = std::process::Command::new("/bin/launchctl")
.args(["bootout", &format!("gui/{uid}/{label}")])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
}
let _ = std::process::Command::new("/bin/launchctl")
.args(["unsetenv", "SSH_AUTH_SOCK"])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
let mut removed = Vec::new();
for path in [&plist, &agent_plist, &helper] {
match std::fs::remove_file(path) {
Ok(()) => removed.push(path.display().to_string()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => {
return Err(bin_error::Error::msg(format!(
"remove {}: {e}",
path.display()
)));
}
}
}
if removed.is_empty() {
println!("nothing to remove — `bwx setup-macos` wasn't active");
} else {
println!("removed:");
for p in removed {
println!(" {p}");
}
}
Ok(())
}
pub fn touchid_status() -> bin_error::Result<()> {
let (enrolled, gate, label) = crate::actions::touchid_status()?;
println!("enrolled: {}", if enrolled { "yes" } else { "no" });
println!("gate: {gate}");
if let Some(label) = label {
println!("keychain_label: {label}");
}
Ok(())
}
pub fn ssh_allowed_signers() -> bin_error::Result<()> {
unlock()?;
let db = load_db()?;
let config = bwx::config::Config::load()?;
let email = config.email.as_deref().ok_or_else(|| {
bin_error::Error::msg(
"no email configured; run `bwx config set email`",
)
})?;
for entry in &db.entries {
let bwx::db::EntryData::SshKey {
public_key: Some(pk_enc),
..
} = &entry.data
else {
continue;
};
let pk = crate::actions::decrypt(
pk_enc,
entry.key.as_deref(),
entry.org_id.as_deref(),
)?;
println!("{email} {}", pk.trim());
}
Ok(())
}
pub fn purge() -> bin_error::Result<()> {
stop_agent()?;
remove_db()?;
Ok(())
}
pub fn stop_agent() -> bin_error::Result<()> {
crate::actions::quit()?;
Ok(())
}
fn ensure_agent() -> bin_error::Result<()> {
check_config()?;
if matches!(check_agent_version(), Ok(())) {
return Ok(());
}
run_agent()?;
check_agent_version()?;
Ok(())
}
fn run_agent() -> bin_error::Result<()> {
let agent_path = std::env::var_os("BWX_AGENT");
let agent_path = agent_path
.as_deref()
.unwrap_or_else(|| std::ffi::OsStr::from_bytes(b"bwx-agent"));
let status = std::process::Command::new(agent_path)
.status()
.context("failed to run bwx-agent")?;
if !status.success() {
if let Some(code) = status.code() {
if code != 23 {
return Err(crate::bin_error::err!(
"failed to run bwx-agent: {status}"
));
}
}
}
Ok(())
}
fn check_config() -> bin_error::Result<()> {
bwx::config::Config::validate().map_err(|e| {
log::error!("{MISSING_CONFIG_HELP}");
crate::bin_error::Error::new(e)
})
}
fn check_agent_version() -> bin_error::Result<()> {
let client_version = bwx::protocol::VERSION;
let agent_version = version_or_quit()?;
if agent_version != client_version {
crate::actions::quit()?;
return Err(crate::bin_error::err!(
"client protocol version is {client_version} but agent protocol version is {agent_version}"
));
}
Ok(())
}
fn version_or_quit() -> bin_error::Result<u32> {
crate::actions::version().inspect_err(|_| {
let _ = crate::actions::quit();
})
}
fn find_entry(
db: &bwx::db::Db,
mut needle: Needle,
username: Option<&str>,
folder: Option<&str>,
ignore_case: bool,
) -> bin_error::Result<(bwx::db::Entry, DecryptedCipher)> {
if let Needle::Uuid(uuid, s) = needle {
for cipher in &db.entries {
if cipher.id.parse::<bwx::uuid::Uuid>() == Ok(uuid) {
return Ok((cipher.clone(), decrypt_cipher(cipher)?));
}
}
needle = Needle::Name(s);
}
let ciphers: Vec<(bwx::db::Entry, DecryptedSearchCipher)> = db
.entries
.iter()
.map(|entry| {
decrypt_search_cipher(entry)
.map(|decrypted| (entry.clone(), decrypted))
})
.collect::<bin_error::Result<_>>()?;
let (entry, _) =
find_entry_raw(&ciphers, &needle, username, folder, ignore_case)?;
let decrypted_entry = decrypt_cipher(&entry)?;
Ok((entry, decrypted_entry))
}
fn find_entry_raw(
entries: &[(bwx::db::Entry, DecryptedSearchCipher)],
needle: &Needle,
username: Option<&str>,
folder: Option<&str>,
ignore_case: bool,
) -> bin_error::Result<(bwx::db::Entry, DecryptedSearchCipher)> {
let mut matches: Vec<(bwx::db::Entry, DecryptedSearchCipher)> = vec![];
let find_matches = |strict_username, strict_folder, exact| {
entries
.iter()
.filter(|&(_, decrypted_cipher)| {
decrypted_cipher.matches(
needle,
username,
folder,
ignore_case,
strict_username,
strict_folder,
exact,
)
})
.cloned()
.collect()
};
for exact in [true, false] {
matches = find_matches(true, true, exact);
if matches.len() == 1 {
return Ok(matches[0].clone());
}
let strict_folder_matches = find_matches(false, true, exact);
let strict_username_matches = find_matches(true, false, exact);
if strict_folder_matches.len() == 1
&& strict_username_matches.len() != 1
{
return Ok(strict_folder_matches[0].clone());
} else if strict_folder_matches.len() != 1
&& strict_username_matches.len() == 1
{
return Ok(strict_username_matches[0].clone());
}
matches = find_matches(false, false, exact);
if matches.len() == 1 {
return Ok(matches[0].clone());
}
}
if matches.is_empty() {
Err(crate::bin_error::err!("no entry found"))
} else {
let entries: Vec<String> = matches
.iter()
.map(|(_, decrypted)| decrypted.display_name())
.collect();
let entries = entries.join(", ");
Err(crate::bin_error::err!("multiple entries found: {entries}"))
}
}
fn decrypt_field(
name: Field,
field: Option<&str>,
entry_key: Option<&str>,
org_id: Option<&str>,
) -> Option<String> {
let field = field
.as_ref()
.map(|field| crate::actions::decrypt(field, entry_key, org_id))
.transpose();
match field {
Ok(field) => field,
Err(e) => {
log::warn!("failed to decrypt {name}: {e}");
None
}
}
}
fn decrypt_list_cipher(
entry: &bwx::db::Entry,
fields: &[ListField],
) -> bin_error::Result<DecryptedListCipher> {
let id = entry.id.clone();
let name = if fields.contains(&ListField::Name) {
Some(crate::actions::decrypt(
&entry.name,
entry.key.as_deref(),
entry.org_id.as_deref(),
)?)
} else {
None
};
let user = if fields.contains(&ListField::User) {
match &entry.data {
bwx::db::EntryData::Login { username, .. } => decrypt_field(
Field::Username,
username.as_deref(),
entry.key.as_deref(),
entry.org_id.as_deref(),
),
_ => None,
}
} else {
None
};
let folder = if fields.contains(&ListField::Folder) {
entry
.folder
.as_ref()
.map(|folder| crate::actions::decrypt(folder, None, None))
.transpose()?
} else {
None
};
let uris = if fields.contains(&ListField::Uri) {
match &entry.data {
bwx::db::EntryData::Login { uris, .. } => Some(
uris.iter()
.filter_map(|s| {
decrypt_field(
Field::Uris,
Some(&s.uri),
entry.key.as_deref(),
entry.org_id.as_deref(),
)
})
.collect(),
),
_ => None,
}
} else {
None
};
let entry_type = fields
.contains(&ListField::EntryType)
.then_some(match &entry.data {
bwx::db::EntryData::Login { .. } => "Login",
bwx::db::EntryData::Identity { .. } => "Identity",
bwx::db::EntryData::SshKey { .. } => "SSH Key",
bwx::db::EntryData::SecureNote => "Note",
bwx::db::EntryData::Card { .. } => "Card",
})
.map(str::to_string);
Ok(DecryptedListCipher {
id,
name,
user,
folder,
uris,
entry_type,
})
}
fn decrypt_search_cipher(
entry: &bwx::db::Entry,
) -> bin_error::Result<DecryptedSearchCipher> {
let id = entry.id.clone();
let name = crate::actions::decrypt(
&entry.name,
entry.key.as_deref(),
entry.org_id.as_deref(),
)?;
let user = match &entry.data {
bwx::db::EntryData::Login { username, .. } => decrypt_field(
Field::Username,
username.as_deref(),
entry.key.as_deref(),
entry.org_id.as_deref(),
),
_ => None,
};
let folder = entry
.folder
.as_ref()
.map(|folder| crate::actions::decrypt(folder, None, None))
.transpose()?;
let notes = entry
.notes
.as_ref()
.map(|notes| {
crate::actions::decrypt(
notes,
entry.key.as_deref(),
entry.org_id.as_deref(),
)
})
.transpose();
let uris = if let bwx::db::EntryData::Login { uris, .. } = &entry.data {
uris.iter()
.filter_map(|s| {
decrypt_field(
Field::Uris,
Some(&s.uri),
entry.key.as_deref(),
entry.org_id.as_deref(),
)
.map(|uri| (uri, s.match_type))
})
.collect()
} else {
vec![]
};
let fields = entry
.fields
.iter()
.filter_map(|field| {
if field.ty == Some(bwx::api::FieldType::Hidden) {
None
} else {
field.value.as_ref()
}
})
.map(|value| {
crate::actions::decrypt(
value,
entry.key.as_deref(),
entry.org_id.as_deref(),
)
})
.collect::<bin_error::Result<_>>()?;
let notes = match notes {
Ok(notes) => notes,
Err(e) => {
log::warn!("failed to decrypt notes: {e}");
None
}
};
let entry_type = (match &entry.data {
bwx::db::EntryData::Login { .. } => "Login",
bwx::db::EntryData::Identity { .. } => "Identity",
bwx::db::EntryData::SshKey { .. } => "SSH Key",
bwx::db::EntryData::SecureNote => "Note",
bwx::db::EntryData::Card { .. } => "Card",
})
.to_string();
Ok(DecryptedSearchCipher {
id,
entry_type,
folder,
name,
user,
uris,
fields,
notes,
})
}
fn decrypt_cipher(
entry: &bwx::db::Entry,
) -> bin_error::Result<DecryptedCipher> {
let folder = entry
.folder
.as_ref()
.map(|folder| crate::actions::decrypt(folder, None, None))
.transpose();
let folder = match folder {
Ok(folder) => folder,
Err(e) => {
log::warn!("failed to decrypt folder name: {e}");
None
}
};
let fields = entry
.fields
.iter()
.map(|field| {
Ok(DecryptedField {
name: field
.name
.as_ref()
.map(|name| {
crate::actions::decrypt(
name,
entry.key.as_deref(),
entry.org_id.as_deref(),
)
})
.transpose()?,
value: field
.value
.as_ref()
.map(|value| {
crate::actions::decrypt(
value,
entry.key.as_deref(),
entry.org_id.as_deref(),
)
})
.transpose()?,
ty: field.ty,
})
})
.collect::<bin_error::Result<_>>()?;
let notes = entry
.notes
.as_ref()
.map(|notes| {
crate::actions::decrypt(
notes,
entry.key.as_deref(),
entry.org_id.as_deref(),
)
})
.transpose();
let notes = match notes {
Ok(notes) => notes,
Err(e) => {
log::warn!("failed to decrypt notes: {e}");
None
}
};
let history = entry
.history
.iter()
.map(|history_entry| {
Ok(DecryptedHistoryEntry {
last_used_date: history_entry.last_used_date.clone(),
password: crate::actions::decrypt(
&history_entry.password,
entry.key.as_deref(),
entry.org_id.as_deref(),
)?,
})
})
.collect::<bin_error::Result<_>>()?;
let data = match &entry.data {
bwx::db::EntryData::Login {
username,
password,
totp,
uris,
} => DecryptedData::Login {
username: decrypt_field(
Field::Username,
username.as_deref(),
entry.key.as_deref(),
entry.org_id.as_deref(),
),
password: decrypt_field(
Field::Password,
password.as_deref(),
entry.key.as_deref(),
entry.org_id.as_deref(),
),
totp: decrypt_field(
Field::Totp,
totp.as_deref(),
entry.key.as_deref(),
entry.org_id.as_deref(),
),
uris: uris
.iter()
.map(|s| {
decrypt_field(
Field::Uris,
Some(&s.uri),
entry.key.as_deref(),
entry.org_id.as_deref(),
)
.map(|uri| DecryptedUri {
uri,
match_type: s.match_type,
})
})
.collect(),
},
bwx::db::EntryData::Card {
cardholder_name,
number,
brand,
exp_month,
exp_year,
code,
} => DecryptedData::Card {
cardholder_name: decrypt_field(
Field::Cardholder,
cardholder_name.as_deref(),
entry.key.as_deref(),
entry.org_id.as_deref(),
),
number: decrypt_field(
Field::CardNumber,
number.as_deref(),
entry.key.as_deref(),
entry.org_id.as_deref(),
),
brand: decrypt_field(
Field::Brand,
brand.as_deref(),
entry.key.as_deref(),
entry.org_id.as_deref(),
),
exp_month: decrypt_field(
Field::ExpMonth,
exp_month.as_deref(),
entry.key.as_deref(),
entry.org_id.as_deref(),
),
exp_year: decrypt_field(
Field::ExpYear,
exp_year.as_deref(),
entry.key.as_deref(),
entry.org_id.as_deref(),
),
code: decrypt_field(
Field::Cvv,
code.as_deref(),
entry.key.as_deref(),
entry.org_id.as_deref(),
),
},
bwx::db::EntryData::Identity {
title,
first_name,
middle_name,
last_name,
address1,
address2,
address3,
city,
state,
postal_code,
country,
phone,
email,
ssn,
license_number,
passport_number,
username,
} => DecryptedData::Identity {
title: decrypt_field(
Field::Title,
title.as_deref(),
entry.key.as_deref(),
entry.org_id.as_deref(),
),
first_name: decrypt_field(
Field::FirstName,
first_name.as_deref(),
entry.key.as_deref(),
entry.org_id.as_deref(),
),
middle_name: decrypt_field(
Field::MiddleName,
middle_name.as_deref(),
entry.key.as_deref(),
entry.org_id.as_deref(),
),
last_name: decrypt_field(
Field::LastName,
last_name.as_deref(),
entry.key.as_deref(),
entry.org_id.as_deref(),
),
address1: decrypt_field(
Field::Address1,
address1.as_deref(),
entry.key.as_deref(),
entry.org_id.as_deref(),
),
address2: decrypt_field(
Field::Address2,
address2.as_deref(),
entry.key.as_deref(),
entry.org_id.as_deref(),
),
address3: decrypt_field(
Field::Address3,
address3.as_deref(),
entry.key.as_deref(),
entry.org_id.as_deref(),
),
city: decrypt_field(
Field::City,
city.as_deref(),
entry.key.as_deref(),
entry.org_id.as_deref(),
),
state: decrypt_field(
Field::State,
state.as_deref(),
entry.key.as_deref(),
entry.org_id.as_deref(),
),
postal_code: decrypt_field(
Field::PostalCode,
postal_code.as_deref(),
entry.key.as_deref(),
entry.org_id.as_deref(),
),
country: decrypt_field(
Field::Country,
country.as_deref(),
entry.key.as_deref(),
entry.org_id.as_deref(),
),
phone: decrypt_field(
Field::Phone,
phone.as_deref(),
entry.key.as_deref(),
entry.org_id.as_deref(),
),
email: decrypt_field(
Field::Email,
email.as_deref(),
entry.key.as_deref(),
entry.org_id.as_deref(),
),
ssn: decrypt_field(
Field::Ssn,
ssn.as_deref(),
entry.key.as_deref(),
entry.org_id.as_deref(),
),
license_number: decrypt_field(
Field::License,
license_number.as_deref(),
entry.key.as_deref(),
entry.org_id.as_deref(),
),
passport_number: decrypt_field(
Field::Passport,
passport_number.as_deref(),
entry.key.as_deref(),
entry.org_id.as_deref(),
),
username: decrypt_field(
Field::Username,
username.as_deref(),
entry.key.as_deref(),
entry.org_id.as_deref(),
),
},
bwx::db::EntryData::SecureNote => DecryptedData::SecureNote {},
bwx::db::EntryData::SshKey {
public_key,
fingerprint,
private_key,
} => DecryptedData::SshKey {
public_key: decrypt_field(
Field::PublicKey,
public_key.as_deref(),
entry.key.as_deref(),
entry.org_id.as_deref(),
),
fingerprint: decrypt_field(
Field::Fingerprint,
fingerprint.as_deref(),
entry.key.as_deref(),
entry.org_id.as_deref(),
),
private_key: decrypt_field(
Field::PrivateKey,
private_key.as_deref(),
entry.key.as_deref(),
entry.org_id.as_deref(),
),
},
};
Ok(DecryptedCipher {
id: entry.id.clone(),
folder,
name: crate::actions::decrypt(
&entry.name,
entry.key.as_deref(),
entry.org_id.as_deref(),
)?,
data,
fields,
notes,
history,
})
}
fn parse_editor(contents: &str) -> (Option<String>, Option<String>) {
let mut lines = contents.lines();
let password = lines.next().map(std::string::ToString::to_string);
let mut notes: String = lines
.skip_while(|line| line.is_empty())
.filter(|line| !line.starts_with('#'))
.fold(String::new(), |mut notes, line| {
notes.push_str(line);
notes.push('\n');
notes
});
while notes.ends_with('\n') {
notes.pop();
}
let notes = if notes.is_empty() { None } else { Some(notes) };
(password, notes)
}
fn load_db() -> bin_error::Result<bwx::db::Db> {
let config = bwx::config::Config::load()?;
config.email.as_ref().map_or_else(
|| {
Err(crate::bin_error::err!(
"failed to find email address in config"
))
},
|email| {
bwx::db::Db::load(&config.server_name(), email)
.map_err(crate::bin_error::Error::new)
},
)
}
fn save_db(db: &bwx::db::Db) -> bin_error::Result<()> {
let config = bwx::config::Config::load()?;
config.email.as_ref().map_or_else(
|| {
Err(crate::bin_error::err!(
"failed to find email address in config"
))
},
|email| {
db.save(&config.server_name(), email)
.map_err(crate::bin_error::Error::new)
},
)
}
fn remove_db() -> bin_error::Result<()> {
let config = bwx::config::Config::load()?;
config.email.as_ref().map_or_else(
|| {
Err(crate::bin_error::err!(
"failed to find email address in config"
))
},
|email| {
bwx::db::Db::remove(&config.server_name(), email)
.map_err(crate::bin_error::Error::new)
},
)
}
struct TotpParams {
secret: Vec<u8>,
algorithm: String,
digits: usize,
period: u64,
}
fn decode_totp_secret(secret: &str) -> bin_error::Result<Vec<u8>> {
bwx::totp::decode_base32(secret).ok_or_else(|| {
crate::bin_error::err!("totp secret was not valid base32")
})
}
fn parse_totp_secret(secret: &str) -> bin_error::Result<TotpParams> {
if let Ok(u) = url::Url::parse(secret) {
match u.scheme() {
"otpauth" => {
if u.host_str() != Some("totp") {
return Err(crate::bin_error::err!(
"totp secret url must have totp host"
));
}
let query: std::collections::HashMap<_, _> =
u.query_pairs().collect();
let secret = decode_totp_secret(
query.get("secret").ok_or_else(|| {
crate::bin_error::err!(
"totp secret url must have secret"
)
})?,
)?;
let algorithm = query.get("algorithm").map_or_else(
|| String::from("SHA1"),
std::string::ToString::to_string,
);
let digits = match query.get("digits") {
Some(dig) => dig
.parse::<usize>()
.map_err(|_| crate::bin_error::err!("digits parameter in totp url must be a valid integer."))?,
None => 6,
};
let period = match query.get("period") {
Some(dig) => {
dig.parse::<u64>().map_err(|_| crate::bin_error::err!("period parameter in totp url must be a valid integer."))?
}
None => TOTP_DEFAULT_STEP,
};
Ok(TotpParams {
secret,
algorithm,
digits,
period,
})
}
"steam" => {
let steam_secret = u.host_str().unwrap();
Ok(TotpParams {
secret: decode_totp_secret(steam_secret)?,
algorithm: String::from("STEAM"),
digits: 5,
period: TOTP_DEFAULT_STEP,
})
}
_ => Err(crate::bin_error::err!(
"totp secret url must have 'otpauth' or 'steam' scheme"
)),
}
} else {
Ok(TotpParams {
secret: decode_totp_secret(secret)?,
algorithm: String::from("SHA1"),
digits: 6,
period: TOTP_DEFAULT_STEP,
})
}
}
fn generate_totp(secret: &str) -> bin_error::Result<String> {
let totp_params = parse_totp_secret(secret)?;
let algorithm = match totp_params.algorithm.as_str() {
"SHA1" => bwx::totp::Algorithm::Sha1,
"SHA256" => bwx::totp::Algorithm::Sha256,
"SHA512" => bwx::totp::Algorithm::Sha512,
"STEAM" => bwx::totp::Algorithm::Steam,
other => {
return Err(crate::bin_error::err!(
"{other} is not a valid totp algorithm"
));
}
};
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_err(|e| crate::bin_error::err!("system time error: {e}"))?
.as_secs();
let digits = u32::try_from(totp_params.digits)
.map_err(|_| crate::bin_error::err!("digits value out of range"))?;
bwx::totp::generate(
&totp_params.secret,
now,
totp_params.period,
digits,
&algorithm,
)
.map_err(bin_error::Error::new)
}
fn display_field(name: &str, field: Option<&str>, clipboard: bool) -> bool {
field.map_or_else(
|| false,
|field| val_display_or_store(clipboard, &format!("{name}: {field}")),
)
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn format_rfc3339_epoch() {
let out = format_rfc3339(std::time::UNIX_EPOCH);
assert_eq!(out, "1970-01-01T00:00:00.000000000Z");
}
#[test]
fn format_rfc3339_known_dates() {
let cases = &[
(946_684_800_u64, "2000-01-01T00:00:00.000000000Z"),
(1_709_210_096_u64, "2024-02-29T12:34:56.000000000Z"),
(2_147_483_647_u64, "2038-01-19T03:14:07.000000000Z"),
(4_107_542_400_u64, "2100-03-01T00:00:00.000000000Z"),
];
for (secs, expected) in cases {
let t =
std::time::UNIX_EPOCH + std::time::Duration::from_secs(*secs);
assert_eq!(&format_rfc3339(t), expected, "secs={secs}");
}
}
#[test]
fn format_rfc3339_preserves_subsec_nanos() {
let t = std::time::UNIX_EPOCH
+ std::time::Duration::new(1_700_000_000, 123_456_789);
assert_eq!(format_rfc3339(t), "2023-11-14T22:13:20.123456789Z");
}
#[test]
fn test_find_entry() {
let entries = &[
make_entry("github", Some("foo"), None, &[]),
make_entry("gitlab", Some("foo"), None, &[]),
make_entry("gitlab", Some("bar"), None, &[]),
make_entry("gitter", Some("baz"), None, &[]),
make_entry("git", Some("foo"), None, &[]),
make_entry("bitwarden", None, None, &[]),
make_entry("github", Some("foo"), Some("websites"), &[]),
make_entry("github", Some("foo"), Some("ssh"), &[]),
make_entry("github", Some("root"), Some("ssh"), &[]),
make_entry("codeberg", Some("foo"), None, &[]),
make_entry("codeberg", None, None, &[]),
make_entry("1password", Some("foo"), None, &[]),
make_entry("1password", None, Some("foo"), &[]),
];
assert!(
one_match(entries, "github", Some("foo"), None, 0, false),
"foo@github"
);
assert!(
one_match(entries, "GITHUB", Some("foo"), None, 0, true),
"foo@GITHUB"
);
assert!(one_match(entries, "github", None, None, 0, false), "github");
assert!(one_match(entries, "GITHUB", None, None, 0, true), "GITHUB");
assert!(
one_match(entries, "gitlab", Some("foo"), None, 1, false),
"foo@gitlab"
);
assert!(
one_match(entries, "GITLAB", Some("foo"), None, 1, true),
"foo@GITLAB"
);
assert!(
one_match(entries, "git", Some("bar"), None, 2, false),
"bar@git"
);
assert!(
one_match(entries, "GIT", Some("bar"), None, 2, true),
"bar@GIT"
);
assert!(
one_match(entries, "gitter", Some("ba"), None, 3, false),
"ba@gitter"
);
assert!(
one_match(entries, "GITTER", Some("ba"), None, 3, true),
"ba@GITTER"
);
assert!(
one_match(entries, "git", Some("foo"), None, 4, false),
"foo@git"
);
assert!(
one_match(entries, "GIT", Some("foo"), None, 4, true),
"foo@GIT"
);
assert!(one_match(entries, "git", None, None, 4, false), "git");
assert!(one_match(entries, "GIT", None, None, 4, true), "GIT");
assert!(
one_match(entries, "bitwarden", None, None, 5, false),
"bitwarden"
);
assert!(
one_match(entries, "BITWARDEN", None, None, 5, true),
"BITWARDEN"
);
assert!(
one_match(
entries,
"github",
Some("foo"),
Some("websites"),
6,
false
),
"websites/foo@github"
);
assert!(
one_match(
entries,
"GITHUB",
Some("foo"),
Some("websites"),
6,
true
),
"websites/foo@GITHUB"
);
assert!(
one_match(entries, "github", Some("foo"), Some("ssh"), 7, false),
"ssh/foo@github"
);
assert!(
one_match(entries, "GITHUB", Some("foo"), Some("ssh"), 7, true),
"ssh/foo@GITHUB"
);
assert!(
one_match(entries, "github", Some("root"), None, 8, false),
"ssh/root@github"
);
assert!(
one_match(entries, "GITHUB", Some("root"), None, 8, true),
"ssh/root@GITHUB"
);
assert!(
no_matches(entries, "gitlab", Some("baz"), None, false),
"baz@gitlab"
);
assert!(
no_matches(entries, "GITLAB", Some("baz"), None, true),
"baz@"
);
assert!(
no_matches(entries, "bitbucket", Some("foo"), None, false),
"foo@bitbucket"
);
assert!(
no_matches(entries, "BITBUCKET", Some("foo"), None, true),
"foo@BITBUCKET"
);
assert!(
no_matches(entries, "github", Some("foo"), Some("bar"), false),
"bar/foo@github"
);
assert!(
no_matches(entries, "GITHUB", Some("foo"), Some("bar"), true),
"bar/foo@"
);
assert!(
no_matches(entries, "gitlab", Some("foo"), Some("bar"), false),
"bar/foo@gitlab"
);
assert!(
no_matches(entries, "GITLAB", Some("foo"), Some("bar"), true),
"bar/foo@GITLAB"
);
assert!(many_matches(entries, "gitlab", None, None, false), "gitlab");
assert!(many_matches(entries, "gitlab", None, None, true), "GITLAB");
assert!(
many_matches(entries, "gi", Some("foo"), None, false),
"foo@gi"
);
assert!(
many_matches(entries, "GI", Some("foo"), None, true),
"foo@GI"
);
assert!(
many_matches(entries, "git", Some("ba"), None, false),
"ba@git"
);
assert!(
many_matches(entries, "GIT", Some("ba"), None, true),
"ba@GIT"
);
assert!(
many_matches(entries, "github", Some("foo"), Some("s"), false),
"s/foo@github"
);
assert!(
many_matches(entries, "GITHUB", Some("foo"), Some("s"), true),
"s/foo@GITHUB"
);
assert!(
one_match(entries, "codeberg", Some("foo"), None, 9, false),
"foo@codeberg"
);
assert!(
one_match(entries, "codeberg", None, None, 10, false),
"codeberg"
);
assert!(
no_matches(entries, "codeberg", Some("bar"), None, false),
"bar@codeberg"
);
assert!(
many_matches(entries, "1password", None, None, false),
"1password"
);
}
#[test]
fn test_find_by_uuid() {
let entries = &[
make_entry("github", Some("foo"), None, &[]),
make_entry("gitlab", Some("foo"), None, &[]),
make_entry("gitlab", Some("bar"), None, &[]),
make_entry(
"12345678-1234-1234-1234-1234567890ab",
None,
None,
&[],
),
make_entry(
"12345678-1234-1234-1234-1234567890AC",
None,
None,
&[],
),
make_entry("123456781234123412341234567890AD", None, None, &[]),
];
assert!(
one_match(entries, &entries[0].0.id, None, None, 0, false),
"foo@github"
);
assert!(
one_match(entries, &entries[1].0.id, None, None, 1, false),
"foo@gitlab"
);
assert!(
one_match(entries, &entries[2].0.id, None, None, 2, false),
"bar@gitlab"
);
assert!(
one_match(
entries,
&entries[0].0.id.to_uppercase(),
None,
None,
0,
false
),
"foo@github"
);
assert!(
one_match(
entries,
&entries[0].0.id.to_lowercase(),
None,
None,
0,
false
),
"foo@github"
);
assert!(one_match(entries, &entries[3].0.id, None, None, 3, false));
assert!(one_match(
entries,
"12345678-1234-1234-1234-1234567890ab",
None,
None,
3,
false
));
assert!(no_matches(
entries,
"12345678-1234-1234-1234-1234567890AB",
None,
None,
false
));
assert!(one_match(
entries,
"12345678-1234-1234-1234-1234567890AB",
None,
None,
3,
true
));
assert!(one_match(entries, &entries[4].0.id, None, None, 4, false));
assert!(one_match(
entries,
"12345678-1234-1234-1234-1234567890AC",
None,
None,
4,
false
));
assert!(one_match(entries, &entries[5].0.id, None, None, 5, false));
assert!(one_match(
entries,
"123456781234123412341234567890AD",
None,
None,
5,
false
));
}
#[test]
fn test_find_by_url_default() {
let entries = &[
make_entry("one", None, None, &[("https://one.com/", None)]),
make_entry("two", None, None, &[("https://two.com/login", None)]),
make_entry(
"three",
None,
None,
&[("https://login.three.com/", None)],
),
make_entry("four", None, None, &[("four.com", None)]),
make_entry(
"five",
None,
None,
&[("https://five.com:8080/", None)],
),
make_entry("six", None, None, &[("six.com:8080", None)]),
make_entry("seven", None, None, &[("192.168.0.128:8080", None)]),
];
assert!(
one_match(entries, "https://one.com/", None, None, 0, false),
"one"
);
assert!(
one_match(
entries,
"https://login.one.com/",
None,
None,
0,
false
),
"one"
);
assert!(
one_match(entries, "https://one.com:443/", None, None, 0, false),
"one"
);
assert!(no_matches(entries, "one.com", None, None, false), "one");
assert!(no_matches(entries, "https", None, None, false), "one");
assert!(no_matches(entries, "com", None, None, false), "one");
assert!(
no_matches(entries, "https://com/", None, None, false),
"one"
);
assert!(
one_match(entries, "https://two.com/", None, None, 1, false),
"two"
);
assert!(
one_match(
entries,
"https://two.com/other-page",
None,
None,
1,
false
),
"two"
);
assert!(
one_match(
entries,
"https://login.three.com/",
None,
None,
2,
false
),
"three"
);
assert!(
no_matches(entries, "https://three.com/", None, None, false),
"three"
);
assert!(
one_match(entries, "https://four.com/", None, None, 3, false),
"four"
);
assert!(
one_match(
entries,
"https://five.com:8080/",
None,
None,
4,
false
),
"five"
);
assert!(
no_matches(entries, "https://five.com/", None, None, false),
"five"
);
assert!(
one_match(entries, "https://six.com:8080/", None, None, 5, false),
"six"
);
assert!(
no_matches(entries, "https://six.com/", None, None, false),
"six"
);
assert!(
one_match(
entries,
"https://192.168.0.128:8080/",
None,
None,
6,
false
),
"seven"
);
assert!(
no_matches(entries, "https://192.168.0.128/", None, None, false),
"seven"
);
}
#[test]
fn test_find_by_url_domain() {
let entries = &[
make_entry(
"one",
None,
None,
&[("https://one.com/", Some(bwx::api::UriMatchType::Domain))],
),
make_entry(
"two",
None,
None,
&[(
"https://two.com/login",
Some(bwx::api::UriMatchType::Domain),
)],
),
make_entry(
"three",
None,
None,
&[(
"https://login.three.com/",
Some(bwx::api::UriMatchType::Domain),
)],
),
make_entry(
"four",
None,
None,
&[("four.com", Some(bwx::api::UriMatchType::Domain))],
),
make_entry(
"five",
None,
None,
&[(
"https://five.com:8080/",
Some(bwx::api::UriMatchType::Domain),
)],
),
make_entry(
"six",
None,
None,
&[("six.com:8080", Some(bwx::api::UriMatchType::Domain))],
),
make_entry(
"seven",
None,
None,
&[(
"192.168.0.128:8080",
Some(bwx::api::UriMatchType::Domain),
)],
),
];
assert!(
one_match(entries, "https://one.com/", None, None, 0, false),
"one"
);
assert!(
one_match(
entries,
"https://login.one.com/",
None,
None,
0,
false
),
"one"
);
assert!(
one_match(entries, "https://one.com:443/", None, None, 0, false),
"one"
);
assert!(no_matches(entries, "one.com", None, None, false), "one");
assert!(no_matches(entries, "https", None, None, false), "one");
assert!(no_matches(entries, "com", None, None, false), "one");
assert!(
no_matches(entries, "https://com/", None, None, false),
"one"
);
assert!(
one_match(entries, "https://two.com/", None, None, 1, false),
"two"
);
assert!(
one_match(
entries,
"https://two.com/other-page",
None,
None,
1,
false
),
"two"
);
assert!(
one_match(
entries,
"https://login.three.com/",
None,
None,
2,
false
),
"three"
);
assert!(
no_matches(entries, "https://three.com/", None, None, false),
"three"
);
assert!(
one_match(entries, "https://four.com/", None, None, 3, false),
"four"
);
assert!(
one_match(
entries,
"https://five.com:8080/",
None,
None,
4,
false
),
"five"
);
assert!(
no_matches(entries, "https://five.com/", None, None, false),
"five"
);
assert!(
one_match(entries, "https://six.com:8080/", None, None, 5, false),
"six"
);
assert!(
no_matches(entries, "https://six.com/", None, None, false),
"six"
);
assert!(
one_match(
entries,
"https://192.168.0.128:8080/",
None,
None,
6,
false
),
"seven"
);
assert!(
no_matches(entries, "https://192.168.0.128/", None, None, false),
"seven"
);
}
#[test]
fn test_find_by_url_host() {
let entries = &[
make_entry(
"one",
None,
None,
&[("https://one.com/", Some(bwx::api::UriMatchType::Host))],
),
make_entry(
"two",
None,
None,
&[(
"https://two.com/login",
Some(bwx::api::UriMatchType::Host),
)],
),
make_entry(
"three",
None,
None,
&[(
"https://login.three.com/",
Some(bwx::api::UriMatchType::Host),
)],
),
make_entry(
"four",
None,
None,
&[("four.com", Some(bwx::api::UriMatchType::Host))],
),
make_entry(
"five",
None,
None,
&[(
"https://five.com:8080/",
Some(bwx::api::UriMatchType::Host),
)],
),
make_entry(
"six",
None,
None,
&[("six.com:8080", Some(bwx::api::UriMatchType::Host))],
),
make_entry(
"seven",
None,
None,
&[("192.168.0.128:8080", Some(bwx::api::UriMatchType::Host))],
),
];
assert!(
one_match(entries, "https://one.com/", None, None, 0, false),
"one"
);
assert!(
no_matches(entries, "https://login.one.com/", None, None, false),
"one"
);
assert!(
one_match(entries, "https://one.com:443/", None, None, 0, false),
"one"
);
assert!(no_matches(entries, "one.com", None, None, false), "one");
assert!(no_matches(entries, "https", None, None, false), "one");
assert!(no_matches(entries, "com", None, None, false), "one");
assert!(
no_matches(entries, "https://com/", None, None, false),
"one"
);
assert!(
one_match(entries, "https://two.com/", None, None, 1, false),
"two"
);
assert!(
one_match(
entries,
"https://two.com/other-page",
None,
None,
1,
false
),
"two"
);
assert!(
one_match(
entries,
"https://login.three.com/",
None,
None,
2,
false
),
"three"
);
assert!(
no_matches(entries, "https://three.com/", None, None, false),
"three"
);
assert!(
one_match(entries, "https://four.com/", None, None, 3, false),
"four"
);
assert!(
one_match(
entries,
"https://five.com:8080/",
None,
None,
4,
false
),
"five"
);
assert!(
no_matches(entries, "https://five.com/", None, None, false),
"five"
);
assert!(
one_match(entries, "https://six.com:8080/", None, None, 5, false),
"six"
);
assert!(
no_matches(entries, "https://six.com/", None, None, false),
"six"
);
assert!(
one_match(
entries,
"https://192.168.0.128:8080/",
None,
None,
6,
false
),
"seven"
);
assert!(
no_matches(entries, "https://192.168.0.128/", None, None, false),
"seven"
);
}
#[test]
fn test_find_by_url_starts_with() {
let entries = &[
make_entry(
"one",
None,
None,
&[(
"https://one.com/",
Some(bwx::api::UriMatchType::StartsWith),
)],
),
make_entry(
"two",
None,
None,
&[(
"https://two.com/login",
Some(bwx::api::UriMatchType::StartsWith),
)],
),
make_entry(
"three",
None,
None,
&[(
"https://login.three.com/",
Some(bwx::api::UriMatchType::StartsWith),
)],
),
];
assert!(
one_match(entries, "https://one.com/", None, None, 0, false),
"one"
);
assert!(
no_matches(entries, "https://login.one.com/", None, None, false),
"one"
);
assert!(
one_match(entries, "https://one.com:443/", None, None, 0, false),
"one"
);
assert!(no_matches(entries, "one.com", None, None, false), "one");
assert!(no_matches(entries, "https", None, None, false), "one");
assert!(no_matches(entries, "com", None, None, false), "one");
assert!(
no_matches(entries, "https://com/", None, None, false),
"one"
);
assert!(
one_match(entries, "https://two.com/login", None, None, 1, false),
"two"
);
assert!(
one_match(
entries,
"https://two.com/login/sso",
None,
None,
1,
false
),
"two"
);
assert!(
no_matches(entries, "https://two.com/", None, None, false),
"two"
);
assert!(
no_matches(
entries,
"https://two.com/other-page",
None,
None,
false
),
"two"
);
assert!(
one_match(
entries,
"https://login.three.com/",
None,
None,
2,
false
),
"three"
);
assert!(
no_matches(entries, "https://three.com/", None, None, false),
"three"
);
}
#[test]
fn test_find_by_url_exact() {
let entries = &[
make_entry(
"one",
None,
None,
&[("https://one.com/", Some(bwx::api::UriMatchType::Exact))],
),
make_entry(
"two",
None,
None,
&[(
"https://two.com/login",
Some(bwx::api::UriMatchType::Exact),
)],
),
make_entry(
"three",
None,
None,
&[(
"https://login.three.com/",
Some(bwx::api::UriMatchType::Exact),
)],
),
make_entry(
"four",
None,
None,
&[("https://four.com", Some(bwx::api::UriMatchType::Exact))],
),
];
assert!(
one_match(entries, "https://one.com/", None, None, 0, false),
"one"
);
assert!(
one_match(entries, "https://one.com", None, None, 0, false),
"one"
);
assert!(
no_matches(entries, "https://one.com/foo", None, None, false),
"one"
);
assert!(
no_matches(entries, "https://login.one.com/", None, None, false),
"one"
);
assert!(
one_match(entries, "https://one.com:443/", None, None, 0, false),
"one"
);
assert!(no_matches(entries, "one.com", None, None, false), "one");
assert!(no_matches(entries, "https", None, None, false), "one");
assert!(no_matches(entries, "com", None, None, false), "one");
assert!(
no_matches(entries, "https://com/", None, None, false),
"one"
);
assert!(
one_match(entries, "https://two.com/login", None, None, 1, false),
"two"
);
assert!(
no_matches(
entries,
"https://two.com/login/sso",
None,
None,
false
),
"two"
);
assert!(
no_matches(entries, "https://two.com/", None, None, false),
"two"
);
assert!(
no_matches(
entries,
"https://two.com/other-page",
None,
None,
false
),
"two"
);
assert!(
one_match(
entries,
"https://login.three.com/",
None,
None,
2,
false
),
"three"
);
assert!(
no_matches(entries, "https://three.com/", None, None, false),
"three"
);
assert!(
one_match(entries, "https://four.com/", None, None, 3, false),
"four"
);
assert!(
one_match(entries, "https://four.com", None, None, 3, false),
"four"
);
assert!(
no_matches(entries, "https://four.com/foo", None, None, false),
"four"
);
}
#[test]
fn test_find_by_url_regex() {
let entries = &[
make_entry(
"one",
None,
None,
&[(
r"^https://one\.com/$",
Some(bwx::api::UriMatchType::RegularExpression),
)],
),
make_entry(
"two",
None,
None,
&[(
r"^https://two\.com/(login|start)",
Some(bwx::api::UriMatchType::RegularExpression),
)],
),
make_entry(
"three",
None,
None,
&[(
r"^https://(login\.)?three\.com/$",
Some(bwx::api::UriMatchType::RegularExpression),
)],
),
];
assert!(
one_match(entries, "https://one.com/", None, None, 0, false),
"one"
);
assert!(
no_matches(entries, "https://login.one.com/", None, None, false),
"one"
);
assert!(
one_match(entries, "https://one.com:443/", None, None, 0, false),
"one"
);
assert!(no_matches(entries, "one.com", None, None, false), "one");
assert!(no_matches(entries, "https", None, None, false), "one");
assert!(no_matches(entries, "com", None, None, false), "one");
assert!(
no_matches(entries, "https://com/", None, None, false),
"one"
);
assert!(
one_match(entries, "https://two.com/login", None, None, 1, false),
"two"
);
assert!(
one_match(entries, "https://two.com/start", None, None, 1, false),
"two"
);
assert!(
one_match(
entries,
"https://two.com/login/sso",
None,
None,
1,
false
),
"two"
);
assert!(
no_matches(entries, "https://two.com/", None, None, false),
"two"
);
assert!(
no_matches(
entries,
"https://two.com/other-page",
None,
None,
false
),
"two"
);
assert!(
one_match(
entries,
"https://login.three.com/",
None,
None,
2,
false
),
"three"
);
assert!(
one_match(entries, "https://three.com/", None, None, 2, false),
"three"
);
assert!(
no_matches(entries, "https://www.three.com/", None, None, false),
"three"
);
}
#[test]
fn test_find_by_url_never() {
let entries = &[
make_entry(
"one",
None,
None,
&[("https://one.com/", Some(bwx::api::UriMatchType::Never))],
),
make_entry(
"two",
None,
None,
&[(
"https://two.com/login",
Some(bwx::api::UriMatchType::Never),
)],
),
make_entry(
"three",
None,
None,
&[(
"https://login.three.com/",
Some(bwx::api::UriMatchType::Never),
)],
),
make_entry(
"four",
None,
None,
&[("four.com", Some(bwx::api::UriMatchType::Never))],
),
make_entry(
"five",
None,
None,
&[(
"https://five.com:8080/",
Some(bwx::api::UriMatchType::Never),
)],
),
make_entry(
"six",
None,
None,
&[("six.com:8080", Some(bwx::api::UriMatchType::Never))],
),
];
assert!(
no_matches(entries, "https://one.com/", None, None, false),
"one"
);
assert!(
no_matches(entries, "https://login.one.com/", None, None, false),
"one"
);
assert!(
no_matches(entries, "https://one.com:443/", None, None, false),
"one"
);
assert!(no_matches(entries, "one.com", None, None, false), "one");
assert!(no_matches(entries, "https", None, None, false), "one");
assert!(no_matches(entries, "com", None, None, false), "one");
assert!(
no_matches(entries, "https://com/", None, None, false),
"one"
);
assert!(
no_matches(entries, "https://two.com/", None, None, false),
"two"
);
assert!(
no_matches(
entries,
"https://two.com/other-page",
None,
None,
false
),
"two"
);
assert!(
no_matches(
entries,
"https://login.three.com/",
None,
None,
false
),
"three"
);
assert!(
no_matches(entries, "https://three.com/", None, None, false),
"three"
);
assert!(
no_matches(entries, "https://four.com/", None, None, false),
"four"
);
assert!(
no_matches(entries, "https://five.com:8080/", None, None, false),
"five"
);
assert!(
no_matches(entries, "https://five.com/", None, None, false),
"five"
);
assert!(
no_matches(entries, "https://six.com:8080/", None, None, false),
"six"
);
assert!(
no_matches(entries, "https://six.com/", None, None, false),
"six"
);
}
#[test]
fn test_find_with_multiple_urls() {
let entries = &[
make_entry(
"one",
None,
None,
&[
(
"https://one.com/",
Some(bwx::api::UriMatchType::Domain),
),
(
"https://two.com/",
Some(bwx::api::UriMatchType::Domain),
),
],
),
make_entry(
"two",
None,
None,
&[(
"https://two.com/login",
Some(bwx::api::UriMatchType::Domain),
)],
),
];
assert!(
no_matches(entries, "https://zero.com/", None, None, false),
"zero"
);
assert!(
one_match(entries, "https://one.com/", None, None, 0, false),
"one"
);
assert!(
many_matches(entries, "https://two.com/", None, None, false),
"two"
);
}
#[test]
fn test_decode_totp_secret() {
let decoded = decode_totp_secret("NBSW Y3DP EB3W 64TM MQQQ").unwrap();
let want = b"hello world!".to_vec();
assert!(decoded == want, "strips spaces");
}
#[track_caller]
fn one_match(
entries: &[(bwx::db::Entry, DecryptedSearchCipher)],
needle: &str,
username: Option<&str>,
folder: Option<&str>,
idx: usize,
ignore_case: bool,
) -> bool {
entries_eq(
&find_entry_raw(
entries,
&parse_needle(needle).unwrap(),
username,
folder,
ignore_case,
)
.unwrap(),
&entries[idx],
)
}
#[track_caller]
fn no_matches(
entries: &[(bwx::db::Entry, DecryptedSearchCipher)],
needle: &str,
username: Option<&str>,
folder: Option<&str>,
ignore_case: bool,
) -> bool {
let res = find_entry_raw(
entries,
&parse_needle(needle).unwrap(),
username,
folder,
ignore_case,
);
if let Err(e) = res {
format!("{e}").contains("no entry found")
} else {
false
}
}
#[track_caller]
fn many_matches(
entries: &[(bwx::db::Entry, DecryptedSearchCipher)],
needle: &str,
username: Option<&str>,
folder: Option<&str>,
ignore_case: bool,
) -> bool {
let res = find_entry_raw(
entries,
&parse_needle(needle).unwrap(),
username,
folder,
ignore_case,
);
if let Err(e) = res {
format!("{e}").contains("multiple entries found")
} else {
false
}
}
#[track_caller]
fn entries_eq(
a: &(bwx::db::Entry, DecryptedSearchCipher),
b: &(bwx::db::Entry, DecryptedSearchCipher),
) -> bool {
a.0 == b.0 && a.1 == b.1
}
fn make_entry(
name: &str,
username: Option<&str>,
folder: Option<&str>,
uris: &[(&str, Option<bwx::api::UriMatchType>)],
) -> (bwx::db::Entry, DecryptedSearchCipher) {
let id = bwx::uuid::new_v4();
(
bwx::db::Entry {
id: id.to_string(),
org_id: None,
folder: folder.map(|_| "encrypted folder name".to_string()),
folder_id: None,
name: "this is the encrypted name".to_string(),
data: bwx::db::EntryData::Login {
username: username.map(|_| {
"this is the encrypted username".to_string()
}),
password: None,
uris: uris
.iter()
.map(|(_, match_type)| bwx::db::Uri {
uri: "this is the encrypted uri".to_string(),
match_type: *match_type,
})
.collect(),
totp: None,
},
fields: vec![],
notes: None,
history: vec![],
key: None,
master_password_reprompt: bwx::api::CipherRepromptType::None,
},
DecryptedSearchCipher {
id: id.to_string(),
entry_type: "Login".to_string(),
folder: folder.map(std::string::ToString::to_string),
name: name.to_string(),
user: username.map(std::string::ToString::to_string),
uris: uris
.iter()
.map(|(uri, match_type)| {
((*uri).to_string(), *match_type)
})
.collect(),
fields: vec![],
notes: None,
},
)
}
}