Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add attachment #236

Draft
wants to merge 6 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -277,7 +277,7 @@ fn create_folder_once(access_token: &str, name: &str) -> Result<String> {
client.create_folder(access_token, name)
}

fn with_exchange_refresh_token<F, T>(
pub fn with_exchange_refresh_token<F, T>(
access_token: &str,
refresh_token: &str,
f: F,
Expand Down Expand Up @@ -332,7 +332,7 @@ async fn exchange_refresh_token_async(refresh_token: &str) -> Result<String> {
client.exchange_refresh_token_async(refresh_token).await
}

fn api_client() -> Result<(crate::api::Client, crate::config::Config)> {
pub fn api_client() -> Result<(crate::api::Client, crate::config::Config)> {
let config = crate::config::Config::load()?;
let client = crate::api::Client::new(
&config.base_url(),
Expand Down
106 changes: 106 additions & 0 deletions src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,21 @@ struct SyncRes {
#[serde(rename = "Folders", alias = "folders")]
folders: Vec<SyncResFolder>,
}
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
struct SyncResAttachment {
#[serde(rename = "Id", alias = "id")]
id: String,
#[serde(rename = "FileName", alias = "fileName")]
file_name: String,
#[serde(rename = "Size", alias = "size")]
size: String,
#[serde(rename = "SizeName", alias = "sizeName")]
size_name: String,
#[serde(rename = "Url", alias = "url")]
url: Option<String>,
#[serde(rename = "Key", alias = "key")]
key: Option<String>,
}

#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
struct SyncResCipher {
Expand All @@ -388,6 +403,8 @@ struct SyncResCipher {
password_history: Option<Vec<SyncResPasswordHistory>>,
#[serde(rename = "Fields", alias = "fields")]
fields: Option<Vec<CipherField>>,
#[serde(rename = "Attachments", alias = "attachments")]
attachments: Option<Vec<SyncResAttachment>>,
#[serde(rename = "DeletedDate", alias = "deletedDate")]
deleted_date: Option<String>,
#[serde(rename = "Key", alias = "key")]
Expand Down Expand Up @@ -497,6 +514,22 @@ impl SyncResCipher {
})
.collect()
});
let attachments =
self.attachments
.as_ref()
.map_or_else(Vec::new, |attachments| {
attachments
.iter()
.map(|attachment| crate::db::Attachment {
id: attachment.id.clone(),
file_name: attachment.file_name.clone(),
size: attachment.size.clone(),
size_name: attachment.size_name.clone(),
url: attachment.url.clone(),
key: attachment.key.clone(),
})
.collect()
});
Some(crate::db::Entry {
id: self.id.clone(),
org_id: self.organization_id.clone(),
Expand All @@ -508,6 +541,7 @@ impl SyncResCipher {
notes: self.notes.clone(),
history,
key: self.key.clone(),
attachments,
})
}
}
Expand Down Expand Up @@ -1504,6 +1538,78 @@ impl Client {
fn identity_url(&self, path: &str) -> String {
format!("{}{}", self.identity_url, path)
}
pub fn get_attachment_file(
&self,
access_token: &str,
attachment_url: &str,
) -> Result<Vec<u8>> {
let client = reqwest::blocking::Client::new();

let parts: Vec<&str> = attachment_url
.split('/')
.filter(|s| !s.is_empty())
.collect();

log::debug!("URL parts: {:?}", parts);

if parts.len() != 2 {
return Err(Error::InvalidCipherString {
reason: "invalid attachment URL format".to_string(),
});
}

let (cipher_id, attachment_id) = (parts[0], parts[1]);
let url = format!("/ciphers/{cipher_id}/attachment/{attachment_id}");

log::debug!("Using attachment_id: {}", attachment_id);
log::debug!("Using cipher_id: {}", cipher_id);

// First request to get download URL and key
let res = client
.get(self.api_url(&url))
.header("Authorization", format!("Bearer {access_token}"))
.send()
.map_err(|source| Error::Reqwest { source })?;

let attachment_info: SyncResAttachment = match res.status() {
reqwest::StatusCode::OK => res.json_with_path()?,
reqwest::StatusCode::UNAUTHORIZED => {
return Err(Error::RequestUnauthorized);
}
_ => {
return Err(Error::RequestFailed {
status: res.status().as_u16(),
});
}
};

let download_url = attachment_info.url.ok_or_else(|| {
Error::InvalidCipherString {
reason: "missing download URL".to_string(),
}
})?;

log::debug!("Download URL: {}", download_url);

// Second request to actually download the file
let res = client
.get(&download_url)
.send()
.map_err(|source| Error::Reqwest { source })?;

match res.status() {
reqwest::StatusCode::OK => Ok(res
.bytes()
.map_err(|source| Error::Reqwest { source })?
.to_vec()),
reqwest::StatusCode::UNAUTHORIZED => {
Err(Error::RequestUnauthorized)
}
_ => Err(Error::RequestFailed {
status: res.status().as_u16(),
}),
}
}
}

async fn find_free_port(bottom: u16, top: u16) -> Result<u16> {
Expand Down
120 changes: 119 additions & 1 deletion src/bin/rbw/commands.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
use std::{io::Write as _, os::unix::ffi::OsStrExt as _};

use anyhow::Context as _;
use rbw::actions::api_client;
use rbw::actions::with_exchange_refresh_token;

const MISSING_CONFIG_HELP: &str =
"Before using rbw, you must configure the email address you would like to \
Expand Down Expand Up @@ -54,6 +56,9 @@ struct DecryptedCipher {
fields: Vec<DecryptedField>,
notes: Option<String>,
history: Vec<DecryptedHistoryEntry>,
// Only include if there are attachments
// #[serde(skip_serializing_if = "Vec::is_empty")]
attachments: Vec<rbw::db::Attachment>,
}

impl DecryptedCipher {
Expand Down Expand Up @@ -420,6 +425,20 @@ impl DecryptedCipher {
);
displayed |=
display_field("Brand", brand.as_deref(), clipboard);
// Display attachments section if any exist
if !self.attachments.is_empty() {
if displayed {
println!();
}
println!("Attachments:");
for attachment in &self.attachments {
println!(" ID: {}", attachment.id);
println!(" Name: {}", attachment.file_name);
println!(" Size: {}", attachment.size_name);
println!();
}
displayed = true;
}

if let Some(notes) = &self.notes {
if displayed {
Expand Down Expand Up @@ -1063,6 +1082,8 @@ pub fn get(
folder: Option<&str>,
field: Option<&str>,
full: bool,
attachment: Option<&str>,
output: Option<&str>,
raw: bool,
clipboard: bool,
ignore_case: bool,
Expand All @@ -1077,13 +1098,87 @@ pub fn get(
needle
);

let (_, decrypted) =
let (entry, decrypted) =
find_entry(&db, needle, user, folder, ignore_case)
.with_context(|| format!("couldn't find entry for '{desc}'"))?;

if raw {
decrypted.display_json(&desc)?;
} else if full {
decrypted.display_long(&desc, clipboard);
} else if let Some(attachment_name) = attachment {
// Find the attachment
let attachment = decrypted
.attachments
.iter()
.find(|a| a.file_name == attachment_name)
.ok_or_else(|| {
anyhow::anyhow!("attachment not found: {}", attachment_name)
})?;

// Get output path
let output_path = if let Some(output_dir) = output {
let mut path = std::path::PathBuf::from(output_dir);
std::fs::create_dir_all(&path)?;
path.push(&attachment.file_name);
path
} else {
std::path::PathBuf::from(&attachment.file_name)
};

// Get attachment data from server
let mut db = load_db()?;
let access_token = db.access_token.as_ref().unwrap();
let refresh_token = db.refresh_token.as_ref().unwrap();

// Download encrypted data with token refresh handling
let (client, _) = rbw::actions::api_client()?;
let url = attachment
.url
.as_ref()
.ok_or_else(|| anyhow::anyhow!("attachment URL not found"))?;

let (new_token, encrypted_data) =
rbw::actions::with_exchange_refresh_token(
access_token,
refresh_token,
|token| client.get_attachment_file(token, url),
)?;

if let Some(new_token) = new_token {
db.access_token = Some(new_token);
save_db(&db)?;
}

// First decrypt the attachment key
let key = crate::actions::decrypt(
attachment
.key
.as_ref()
.ok_or_else(|| anyhow::anyhow!("attachment key not found"))?,
entry.key.as_deref(),
entry.org_id.as_deref(),
)?;

// Create a CipherString for the encrypted attachment data
let encrypted_str =
format!("2.{}", rbw::base64::encode(&encrypted_data));
let cipher = rbw::cipherstring::CipherString::new(&encrypted_str)?;

// Create a locked Vec and Keys for decryption
let mut key_bytes = rbw::locked::Vec::new();
key_bytes.extend(key.as_bytes().iter().copied());
let keys = rbw::locked::Keys::new(key_bytes);

// Decrypt the attachment data
let decrypted_data = cipher.decrypt_symmetric(&keys, None)?;

// Save the decrypted data
std::fs::write(&output_path, &decrypted_data).with_context(|| {
format!("failed to write attachment to {}", output_path.display())
})?;

println!("Downloaded attachment to {}", output_path.display());
} else if let Some(field) = field {
decrypted.display_field(&desc, field, clipboard);
} else {
Expand Down Expand Up @@ -1867,6 +1962,26 @@ fn decrypt_cipher(entry: &rbw::db::Entry) -> anyhow::Result<DecryptedCipher> {
})
})
.collect::<anyhow::Result<_>>()?;
// Decrypt attachment filenames
let attachments = entry
.attachments
.iter()
.map(|attachment| {
let file_name = crate::actions::decrypt(
&attachment.file_name,
entry.key.as_deref(),
entry.org_id.as_deref(),
)?;
Ok(rbw::db::Attachment {
id: attachment.id.clone(),
file_name,
size: attachment.size.clone(),
size_name: attachment.size_name.clone(),
url: attachment.url.clone(),
key: attachment.key.clone(),
})
})
.collect::<anyhow::Result<_>>()?;

let data = match &entry.data {
rbw::db::EntryData::Login {
Expand Down Expand Up @@ -2091,6 +2206,7 @@ fn decrypt_cipher(entry: &rbw::db::Entry) -> anyhow::Result<DecryptedCipher> {
fields,
notes,
history,
attachments,
})
}

Expand Down Expand Up @@ -3475,6 +3591,7 @@ mod test {
totp: None,
},
fields: vec![],
attachments: vec![],
notes: None,
history: vec![],
key: None,
Expand All @@ -3497,6 +3614,7 @@ mod test {
),
},
fields: vec![],
attachments: vec![],
notes: None,
history: vec![],
},
Expand Down
11 changes: 11 additions & 0 deletions src/bin/rbw/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,13 @@ enum Opt {
#[cfg(feature = "clipboard")]
#[structopt(long, help = "Copy result to clipboard")]
clipboard: bool,
#[arg(
long = "attachment",
help = "Download specific attachment by filename"
)]
attachment: Option<String>,
#[arg(long, help = "Output directory for attachments")]
output: Option<std::path::PathBuf>,
#[structopt(short, long, help = "Ignore case")]
ignorecase: bool,
},
Expand Down Expand Up @@ -335,6 +342,8 @@ fn main() {
folder,
field,
full,
attachment,
output,
raw,
#[cfg(feature = "clipboard")]
clipboard,
Expand All @@ -345,6 +354,8 @@ fn main() {
folder.as_deref(),
field.as_deref(),
*full,
attachment.as_deref(),
output.as_ref().map(|p| p.to_str().unwrap_or("")),
*raw,
#[cfg(feature = "clipboard")]
*clipboard,
Expand Down
13 changes: 13 additions & 0 deletions src/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,19 @@ pub struct Entry {
pub notes: Option<String>,
pub history: Vec<HistoryEntry>,
pub key: Option<String>,
pub attachments: Vec<Attachment>,
}

#[derive(
serde::Serialize, serde::Deserialize, Debug, Clone, Eq, PartialEq,
)]
pub struct Attachment {
pub id: String,
pub file_name: String,
pub size: String,
pub size_name: String,
pub url: Option<String>,
pub key: Option<String>,
}

#[derive(serde::Serialize, Debug, Clone, Eq, PartialEq)]
Expand Down
Loading