8 Commits
6 changed files with 214 additions and 131 deletions
+2 -2
View File
@@ -5,7 +5,7 @@ This is a simple CLI tool to sync Bitwarden logins to Proton Pass. It can be set
### Runtime requirements:
- `pass-cli` - Proton Pass CLI (must be logged in)
- `rbw` - Unofficial Bitwarden CLI (must be logged in)
- `bw` - Bitwarden CLI (must be logged in - BW_SESSION must be set)
### Installing
@@ -23,7 +23,7 @@ This will automatically start the syncing process.
### Limitations
Currently, syncing is only possible for user and password of logins, not for notes, identities, cards, etc. URLs, TOTP, and notes are also not synced.
Currently, syncing is only possible for username, password, note, and URLs. TOTP needs to be synced manually. Other items are not synced (e.g. cards, identities, notes, etc.).
### License
+111
View File
@@ -0,0 +1,111 @@
use crate::sh;
use serde::Deserialize;
use std::io::{Error, ErrorKind};
const EXECUTABLE: &str = "bw";
#[derive(Deserialize, Clone)]
pub struct Item {
pub id: String,
pub name: String,
#[serde(rename = "passwordHistory")]
pub password_history: Vec<PasswordHistoryItem>,
#[serde(rename = "revisionDate")]
pub revision_date: String,
#[serde(rename = "creationDate")]
pub creation_date: String,
pub object: String,
#[serde(rename = "type")]
pub type_: u16,
pub notes: Option<String>,
pub favorite: bool,
pub fields: Vec<ItemField>,
pub login: Option<Login>,
}
#[derive(Deserialize, Clone)]
pub struct PasswordHistoryItem {
#[serde(rename = "lastUsedDate")]
pub last_used_date: String,
pub password: String,
}
#[derive(Deserialize, Clone)]
pub struct ItemField {
pub name: String,
pub value: String,
#[serde(rename = "type")]
pub type_: u16,
}
#[derive(Deserialize, Clone)]
pub struct Login {
pub uris: Vec<LoginUri>,
pub username: Option<String>,
pub password: Option<String>,
pub totp: Option<String>,
#[serde(rename = "passwordRevisionDate")]
pub password_revision_date: Option<String>,
#[serde(rename = "fido2Credentials")]
pub fido2_credentials: Vec<Fido2Credential>,
}
#[derive(Deserialize, Clone)]
pub struct LoginUri {
pub uri: String,
}
#[derive(Deserialize, Clone)]
pub struct Fido2Credential {
#[serde(rename = "credentialId")]
pub credential_id: String,
#[serde(rename = "keyType")]
pub key_type: String,
#[serde(rename = "keyAlgorithm")]
pub key_algorithm: String,
#[serde(rename = "keyCurve")]
pub key_curve: String,
#[serde(rename = "keyValue")]
pub key_value: String,
#[serde(rename = "rpId")]
pub rp_id: String,
#[serde(rename = "userHandle")]
pub user_handle: String,
#[serde(rename = "userName")]
pub user_name: String,
pub counter: String,
#[serde(rename = "rpName")]
pub rp_name: String,
#[serde(rename = "userDisplayName")]
pub user_display_name: Option<String>,
pub discoverable: String,
#[serde(rename = "creationDate")]
pub creation_date: String,
}
pub fn check_bw() -> Result<(), Error> {
let which = sh::sh(format!("which {}", EXECUTABLE));
if which.is_empty() {
return Err(Error::new(
ErrorKind::Other,
format!("{} is not installed", EXECUTABLE),
));
}
Ok(())
}
pub fn get_items() -> Result<Vec<Item>, Error> {
let items_raw = sh::sh(format!("{} list items --raw", EXECUTABLE));
match serde_json::from_str::<Vec<Item>>(items_raw.as_str()) {
Ok(val) => Ok(val
.iter()
.filter(|x| x.type_ == 1)
.cloned()
.collect::<Vec<Item>>()),
Err(e) => Err(Error::new(
ErrorKind::Other,
format!("Couldn't get items: {e}"),
)),
}
}
+69 -35
View File
@@ -1,11 +1,11 @@
mod bw;
mod pass;
mod rbw;
mod sh;
use clap::Parser;
use std::env;
use std::io;
use std::io::{Error, ErrorKind, Write};
use clap::Parser;
const ENV_VAR_DEFAULT_VAULT: &str = "PASS_VAULT";
@@ -13,21 +13,27 @@ const ENV_VAR_DEFAULT_VAULT: &str = "PASS_VAULT";
#[command(version, about, long_about = None)]
struct Cli {}
fn ask_consent(question: String, line1: String, line2: String) -> Result<bool, Error> {
fn ask_consent(
question: String,
line1: String,
line2: String,
line3: String,
) -> Result<bool, Error> {
println!("{}", question);
println!("\t{}", line1);
println!("\t{}", line2);
println!("\t{}", line3);
print!("Proceed? [y/N] ");
io::stdout().flush()?;
let mut input = String::new();
io::stdin().read_line(&mut input)?;
return Ok(input.to_lowercase() == "y\n");
Ok(input.to_lowercase() == "y\n")
}
fn sync() -> Result<(), Error> {
pass::check_pass()?;
rbw::check_rbw()?;
bw::check_bw()?;
let vaults = pass::get_vaults()?.vaults;
if vaults.len() <= 0 {
@@ -62,21 +68,25 @@ fn sync() -> Result<(), Error> {
let pass_items = pass::get_items(&vault)?;
let mut pass_logins = pass::get_logins(pass_items);
let rbw_items = rbw::get_items()?;
let mut rbw_logins = rbw::get_logins(rbw_items)?;
let mut bw_items = bw::get_items()?;
println!();
println!(
"Got {} rbw logins and {} pass logins...",
rbw_logins.len(),
"Got {} bw logins and {} pass logins...",
bw_items.len(),
pass_logins.len()
);
for rbw_login in rbw_logins.clone().iter() {
for bw_item in bw_items.clone().iter() {
if let None = bw_item.login {
continue;
}
let bw_login = bw_item.login.clone().unwrap();
let mut pass_login: Option<&pass::LoginItem> = None;
'inner: for pass_login_iter in pass_logins.iter() {
if rbw_login.name == pass_login_iter.title {
if bw_item.name == pass_login_iter.title {
pass_login = Some(pass_login_iter);
break 'inner;
}
@@ -87,39 +97,55 @@ fn sync() -> Result<(), Error> {
}
let pass_login = pass_login.unwrap();
let rbw_password = rbw_login.password.clone();
let bw_password = bw_login.password.clone().unwrap_or("".to_string());
let pass_password = pass_login.password.clone();
let rbw_user = rbw_login.user.clone();
let bw_user = bw_login.username.clone().unwrap_or("".to_string());
let pass_user = match pass_login.username.is_empty() {
true => pass_login.email.clone(),
false => pass_login.username.clone(),
};
let pass_user_is_actually_email = pass_user == pass_login.email;
if rbw_user != pass_user || rbw_password != pass_password {
let bw_notes = bw_item.notes.clone().unwrap_or("".to_string());
let pass_notes = pass_login.notes.clone();
if bw_user != pass_user
|| (bw_password != pass_password && bw_password != "".to_string())
|| bw_notes != pass_notes
{
let consent = ask_consent(
format!("Attempting to update {}:", rbw_login.name),
format!("{} -> {}", pass_user, rbw_user.clone()),
format!("{} -> {}", pass_password, rbw_password.clone()),
format!("Attempting to update {}:", bw_item.name),
format!("{} -> {}", pass_user, bw_user.clone()),
format!("{} -> {}", pass_password, bw_password.clone()),
format!("{} -> {}", bw_notes, pass_notes),
)?;
if consent {
let updated_pass_login = pass::LoginItem {
id: pass_login.id.clone(),
title: pass_login.title.clone(),
username: rbw_user.clone(),
email: rbw_user.clone(),
password: rbw_password,
username: bw_user.clone(),
email: bw_user.clone(),
password: bw_password,
notes: bw_notes,
urls: Some(bw_login.uris.iter().map(|x| x.uri.clone()).collect()),
totp: pass_login.totp.clone(),
};
pass::update(&vault, updated_pass_login, pass_user_is_actually_email);
}
}
let rbw_index = rbw_logins
.iter()
.position(|x| x.id == rbw_login.id)
.unwrap();
rbw_logins.remove(rbw_index);
let bw_totp = bw_login.totp.clone().unwrap_or("".to_string());
let pass_totp = pass_login.totp.clone().unwrap_or("".to_string());
if bw_totp != "".to_string() && pass_totp == "".to_string() {
println!(
"TOTP found for {} but not in pass, you need to manually change it to {}",
bw_item.name, bw_totp
);
}
let bw_index = bw_items.iter().position(|x| x.id == bw_item.id).unwrap();
bw_items.remove(bw_index);
let pass_index = pass_logins
.iter()
@@ -128,22 +154,29 @@ fn sync() -> Result<(), Error> {
pass_logins.remove(pass_index);
}
// TODO: need to delete these
println!();
println!(
"Remaining {} rbw logins and {} pass logins...",
rbw_logins.len(),
"Remaining {} bw logins and {} pass logins...",
bw_items.len(),
pass_logins.len()
);
for rbw_login in rbw_logins {
for bw_item in bw_items {
let login = bw_item.login.unwrap();
let consent = ask_consent(
format!("Attempting to create {}:", rbw_login.name),
format!("{}", rbw_login.user),
format!("{}", rbw_login.password),
format!("Attempting to create {}:", bw_item.name),
format!("{}", login.username.clone().unwrap_or("".to_string())),
format!("{}", login.password.clone().unwrap_or("".to_string())),
format!("{}", bw_item.notes.clone().unwrap_or("".to_string())),
)?;
if consent {
pass::create(&vault, rbw_login.name, rbw_login.user, rbw_login.password);
pass::create(
&vault,
bw_item.name,
login.username.unwrap(),
login.password.unwrap_or("".to_string()),
);
}
}
@@ -159,17 +192,18 @@ fn sync() -> Result<(), Error> {
}
),
format!("{}", pass_login.password),
format!("{}", pass_login.notes),
)?;
if consent {
pass::trash(&vault, pass_login.id);
}
}
return Ok(());
Ok(())
}
fn main() -> Result<(), Error> {
Cli::parse();
sync()?;
return Ok(());
Ok(())
}
+31 -10
View File
@@ -31,6 +31,7 @@ pub struct Item {
#[derive(Deserialize, Debug, Clone)]
pub struct ItemContent {
pub title: String,
pub note: String,
pub content: ItemContentContent,
}
@@ -46,6 +47,8 @@ pub struct ItemLogin {
pub email: String,
pub username: String,
pub password: String,
pub urls: Vec<String>,
pub totp_uri: Option<String>,
}
// our own custom structure, a counterpart to rbw::LoginItem
@@ -56,6 +59,9 @@ pub struct LoginItem {
pub username: String,
pub email: String,
pub password: String,
pub urls: Option<Vec<String>>,
pub notes: String,
pub totp: Option<String>,
}
pub fn check_pass() -> Result<(), Error> {
@@ -75,32 +81,32 @@ pub fn check_pass() -> Result<(), Error> {
));
}
return Ok(());
Ok(())
}
pub fn get_vaults() -> Result<Vaults, Error> {
let vaults_raw = sh::sh(format!("{} vault list --output json", EXECUTABLE));
return match serde_json::from_str(vaults_raw.as_str()) {
match serde_json::from_str(vaults_raw.as_str()) {
Ok(val) => Ok(val),
Err(e) => Err(Error::new(
ErrorKind::Other,
format!("Couldn't get vaults: {e}"),
)),
};
}
}
pub fn get_items(vault: &String) -> Result<Items, Error> {
let items_raw = sh::sh(format!(
"{} item list '{}' --output json",
"{} item list '{}' --output json --show-secrets",
EXECUTABLE, vault
));
return match serde_json::from_str(items_raw.as_str()) {
match serde_json::from_str(items_raw.as_str()) {
Ok(val) => Ok(val),
Err(e) => Err(Error::new(
ErrorKind::Other,
format!("Couldn't get items: {e}"),
)),
};
}
}
pub fn get_logins(items: Items) -> Vec<LoginItem> {
@@ -124,6 +130,9 @@ pub fn get_logins(items: Items) -> Vec<LoginItem> {
username: login.username,
email: login.email,
password: login.password,
notes: item.content.note.clone(),
urls: Some(login.urls),
totp: login.totp_uri,
}
})
.collect()
@@ -135,10 +144,22 @@ pub fn update(vault: &String, item: LoginItem, user_is_actually_email: bool) {
} else {
format!("usename={}", item.username)
};
let output = sh::sh(format!(
"{} item update --vault-name '{}' --item-id '{}' --field 'password={}' --field '{}'",
EXECUTABLE, vault, item.id, item.password, user_field_update
));
let maybe_urls = if let Some(urls) = item.urls {
let mut it = "".to_string();
for url in urls {
it = format!("{} --field 'urls={}'", it, url);
}
it
} else {
"".to_string()
};
let cmd = format!(
"{} item update --vault-name '{}' --item-id '{}' --field 'password={}' --field '{}' --field 'note={}' {}",
EXECUTABLE, vault, item.id, item.password, user_field_update, item.notes, maybe_urls
);
let output = sh::sh(cmd.clone());
println!("> {}", output);
}
-83
View File
@@ -1,83 +0,0 @@
use crate::sh;
use serde::Deserialize;
use std::io::{Error, ErrorKind};
const EXECUTABLE: &str = "rbw";
#[derive(Deserialize, Debug)]
pub struct Item {
pub id: String,
pub name: String,
pub user: Option<String>,
#[serde(rename = "type")]
pub type_: String,
}
#[derive(Deserialize, Debug, Clone)]
pub struct LoginItem {
pub id: String,
pub name: String,
pub user: String,
pub password: String,
}
pub fn check_rbw() -> Result<(), Error> {
let which = sh::sh(format!("which {}", EXECUTABLE));
if which.is_empty() {
return Err(Error::new(
ErrorKind::Other,
format!("{} is not installed", EXECUTABLE),
));
}
let unlocked = sh::sh(format!("{} unlocked", EXECUTABLE));
if !unlocked.is_empty() {
return Err(Error::new(
ErrorKind::Other,
format!("{} database not unlocked", EXECUTABLE),
));
}
return Ok(());
}
pub fn get_items() -> Result<Vec<Item>, Error> {
let items_raw = sh::sh(format!("{} list --raw", EXECUTABLE));
return match serde_json::from_str(items_raw.as_str()) {
Ok(val) => Ok(val),
Err(e) => Err(Error::new(
ErrorKind::Other,
format!("Couldn't get items: {e}"),
)),
};
}
pub fn get_logins(items: Vec<Item>) -> Result<Vec<LoginItem>, Error> {
let items = items
.iter()
.filter(|item| item.type_.to_lowercase() == "login")
.collect::<Vec<&Item>>();
let mut login_items = Vec::<LoginItem>::new();
for item in items {
let password = sh::sh(format!("{} get '{}'", EXECUTABLE, item.id));
if let None = item.user {
println!("WARNING: user not found for {}", item.name);
}
login_items.push(LoginItem {
id: item.id.clone(),
name: item.name.clone(),
user: match item.user.clone() {
Some(val) => val,
None => "".to_string(),
},
password: match password.strip_suffix("\n") {
Some(s) => s.to_string(),
None => password,
},
});
}
return Ok(login_items);
}
+1 -1
View File
@@ -9,5 +9,5 @@ pub fn sh(command: impl Into<String>) -> String {
.output()
.expect("Failed to execute command");
return String::from_utf8(output.stdout).expect("Invalid UTF-8 sequence");
String::from_utf8(output.stdout).expect("Invalid UTF-8 sequence")
}