mirror of
https://github.com/timvisee/ffsend.git
synced 2025-10-03 09:39:15 +02:00
408 lines
11 KiB
Rust
408 lines
11 KiB
Rust
extern crate base64;
|
|
extern crate clap;
|
|
extern crate crypto;
|
|
extern crate hyper;
|
|
extern crate mime_guess;
|
|
extern crate open;
|
|
extern crate openssl;
|
|
extern crate rand;
|
|
extern crate reqwest;
|
|
#[macro_use]
|
|
extern crate serde_derive;
|
|
extern crate serde_json;
|
|
|
|
use std::fmt;
|
|
use std::fs::File;
|
|
use std::io::{self, BufReader, Cursor, Read};
|
|
use std::path::Path;
|
|
|
|
use clap::{App, Arg};
|
|
use crypto::digest::Digest;
|
|
use crypto::hkdf::{hkdf_extract, hkdf_expand};
|
|
use crypto::sha2::Sha256;
|
|
use hyper::error::Error as HyperError;
|
|
use mime_guess::Mime;
|
|
use openssl::symm::{
|
|
Cipher,
|
|
Crypter,
|
|
encrypt_aead,
|
|
Mode as CrypterMode,
|
|
};
|
|
use rand::{Rng, thread_rng};
|
|
use reqwest::header::{
|
|
Authorization,
|
|
Formatter as HeaderFormatter,
|
|
Header,
|
|
Raw
|
|
};
|
|
use reqwest::mime::APPLICATION_OCTET_STREAM;
|
|
use reqwest::multipart::Part;
|
|
|
|
const TAG_LEN: usize = 16;
|
|
|
|
fn main() {
|
|
// Handle CLI arguments
|
|
let matches = App::new("ffsend")
|
|
.version("0.1.0")
|
|
.author("Tim Visee <timvisee@gmail.com>")
|
|
.about("A simple Firefox Send CLI client")
|
|
.arg(Arg::with_name("file")
|
|
.short("f")
|
|
.long("file")
|
|
.value_name("PATH")
|
|
.help("The file to upload")
|
|
.required(true)
|
|
.multiple(false))
|
|
.get_matches();
|
|
|
|
// Get the path
|
|
let path = Path::new(matches.value_of("file").unwrap());
|
|
|
|
// Make sure the path is a file
|
|
if !path.is_file() {
|
|
panic!("The selected path is not a file");
|
|
}
|
|
|
|
// TODO: a fixed path for now, as upload test
|
|
let file_ext = path.extension().unwrap().to_str().unwrap();
|
|
let file_name = path.file_name().unwrap().to_str().unwrap().to_owned();
|
|
|
|
// Create a new reqwest client
|
|
let client = reqwest::Client::new();
|
|
|
|
// Generate a secret and iv
|
|
let mut secret = [0u8; 16];
|
|
let mut iv = [0u8; 12];
|
|
thread_rng().fill_bytes(&mut secret);
|
|
thread_rng().fill_bytes(&mut iv);
|
|
|
|
// Derive keys
|
|
let encrypt_key = derive_file_key(&secret);
|
|
let auth_key = derive_auth_key(&secret, None, None);
|
|
let meta_key = derive_meta_key(&secret);
|
|
|
|
// Guess the mimetype of the file
|
|
let file_mime = mime_guess::get_mime_type(file_ext);
|
|
|
|
// Construct the metadata
|
|
let metadata = Metadata::from(&iv, file_name.clone(), file_mime);
|
|
|
|
// Convert the metadata to JSON bytes
|
|
let metadata = metadata.to_json().into_bytes();
|
|
|
|
// Choose a file and meta cipher type
|
|
let cipher = Cipher::aes_128_gcm();
|
|
|
|
// Encrypt the metadata, and append the tag to it
|
|
let mut metadata_tag = vec![0u8; 16];
|
|
let mut metadata = encrypt_aead(
|
|
cipher,
|
|
&meta_key,
|
|
Some(&[0u8; 12]),
|
|
&[],
|
|
&metadata,
|
|
&mut metadata_tag,
|
|
).unwrap();
|
|
metadata.append(&mut metadata_tag);
|
|
|
|
// Open the file and create an encrypted file reader
|
|
let file = File::open(path).unwrap();
|
|
let reader = EncryptedFileReaderTagged::new(
|
|
file,
|
|
cipher,
|
|
&encrypt_key,
|
|
&iv,
|
|
);
|
|
|
|
// Buffer the encrypted reader
|
|
let reader = BufReader::new(reader);
|
|
|
|
// Build the file part, configure the form to send
|
|
let part = Part::reader(reader)
|
|
.file_name(file_name)
|
|
.mime(APPLICATION_OCTET_STREAM);
|
|
let form = reqwest::multipart::Form::new()
|
|
.part("data", part);
|
|
|
|
// Make the request
|
|
let mut res = client.post("http://localhost:8080/api/upload")
|
|
.header(Authorization(format!("send-v1 {}", base64_encode(&auth_key))))
|
|
.header(XFileMetadata::from(&metadata))
|
|
.multipart(form)
|
|
.send()
|
|
.unwrap();
|
|
|
|
// Parse the response
|
|
let upload_res: UploadResponse = res.json().unwrap();
|
|
|
|
// Print the response
|
|
let url = upload_res.download_url(&secret);
|
|
println!("Response: {:#?}", upload_res);
|
|
println!("Secret key: {}", base64_encode(&secret));
|
|
println!("Download URL: {}", url);
|
|
|
|
// Open the URL in the browser
|
|
open::that(url).expect("failed to open URL");
|
|
}
|
|
|
|
// TODO: implement this some other way
|
|
unsafe impl Send for EncryptedFileReaderTagged {}
|
|
|
|
/// Run HKDF crypto.
|
|
///
|
|
/// # Arguments
|
|
/// * length - Length of the derived key value that is returned.
|
|
/// * ikm - The input keying material.
|
|
/// * info - Optional context and application specific information to use.
|
|
///
|
|
/// # Returns
|
|
/// The output keying material, with the length as as specified in the `length`
|
|
/// argument.
|
|
fn hkdf<'a>(
|
|
length: usize,
|
|
ikm: &[u8],
|
|
info: Option<&[u8]>
|
|
) -> Vec<u8> {
|
|
// Unwrap info or use empty info
|
|
let info = info.unwrap_or(b"");
|
|
|
|
// Construct the digest to use
|
|
let digest = Sha256::new();
|
|
|
|
// Invoke HKDF extract, create a pseudo random key
|
|
let mut pkr: Vec<u8> = vec![0u8; digest.output_bytes()];
|
|
hkdf_extract(digest, b"", ikm, &mut pkr);
|
|
|
|
// Invoke HKDF expand, create the output keying material
|
|
let mut okm: Vec<u8> = vec![0u8; length];
|
|
hkdf_expand(digest, &pkr, info, &mut okm);
|
|
|
|
okm
|
|
}
|
|
|
|
fn derive_file_key(secret: &[u8]) -> Vec<u8> {
|
|
hkdf(16, secret, Some(b"encryption"))
|
|
}
|
|
|
|
fn derive_auth_key(secret: &[u8], password: Option<String>, _url: Option<String>) -> Vec<u8> {
|
|
if password.is_none() {
|
|
hkdf(64, secret, Some(b"authentication"))
|
|
} else {
|
|
// TODO: implement this
|
|
unimplemented!();
|
|
}
|
|
}
|
|
|
|
fn derive_meta_key(secret: &[u8]) -> Vec<u8> {
|
|
hkdf(16, secret, Some(b"metadata"))
|
|
}
|
|
|
|
/// File metadata, which is send to the server.
|
|
#[derive(Serialize)]
|
|
struct Metadata {
|
|
/// The input vector.
|
|
iv: String,
|
|
|
|
/// The file name.
|
|
name: String,
|
|
|
|
/// The file mimetype.
|
|
#[serde(rename="type")]
|
|
mime: String,
|
|
}
|
|
|
|
impl Metadata {
|
|
/// Construct metadata from the given properties.
|
|
///
|
|
/// Parameters:
|
|
/// * iv: initialisation vector
|
|
/// * name: file name
|
|
/// * mime: file mimetype
|
|
pub fn from(iv: &[u8], name: String, mime: Mime) -> Self {
|
|
Metadata {
|
|
iv: base64_encode(iv),
|
|
name,
|
|
mime: mime.to_string(),
|
|
}
|
|
}
|
|
|
|
/// Convert this structure to a JSON string.
|
|
pub fn to_json(&self) -> String {
|
|
serde_json::to_string(&self).unwrap()
|
|
}
|
|
}
|
|
|
|
/// A X-File-Metadata header for reqwest, that is used to pass encrypted
|
|
/// metadata to the server.
|
|
///
|
|
/// The encrypted metadata (bytes) is base64 encoded when constructing this
|
|
/// header using `from`.
|
|
#[derive(Clone)]
|
|
struct XFileMetadata {
|
|
/// The metadata, as a base64 encoded string.
|
|
metadata: String,
|
|
}
|
|
|
|
impl XFileMetadata {
|
|
/// Construct the header from the given encrypted metadata.
|
|
pub fn from(bytes: &[u8]) -> Self {
|
|
XFileMetadata {
|
|
metadata: base64_encode(bytes),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Header for XFileMetadata {
|
|
fn header_name() -> &'static str {
|
|
"X-File-Metadata"
|
|
}
|
|
|
|
fn parse_header(_raw: &Raw) -> Result<Self, HyperError> {
|
|
// TODO: implement this some time
|
|
unimplemented!();
|
|
}
|
|
|
|
fn fmt_header(&self, f: &mut HeaderFormatter) -> fmt::Result {
|
|
// TODO: is this encoding base64 for us?
|
|
f.fmt_line(&self.metadata)
|
|
}
|
|
}
|
|
|
|
/// A lazy file reader, that encrypts the file with the given `cipher`
|
|
/// and appends the GCM tag to the end of it.
|
|
///
|
|
/// This reader is lazy because the file data loaded from the system
|
|
/// and encrypted when it is read from the reader.
|
|
/// This greatly reduces memory usage for large files.
|
|
///
|
|
/// This reader encrypts the file data with an appended GCM tag.
|
|
struct EncryptedFileReaderTagged {
|
|
/// The file to read.
|
|
file: File,
|
|
|
|
/// The cipher that is used for decryption.
|
|
cipher: Cipher,
|
|
|
|
/// The crypter used to encrypt the file data.
|
|
crypter: Crypter,
|
|
|
|
/// A tag cursor that reads the tag to append,
|
|
/// when the file is fully read and the tag is known.
|
|
tag: Option<Cursor<Vec<u8>>>,
|
|
}
|
|
|
|
impl EncryptedFileReaderTagged {
|
|
/// Construct a new reader for the given `file` with the given `cipher`.
|
|
///
|
|
/// This method consumes twice the size of the file in memory while
|
|
/// constructing, and constructs a reader that has a size similar to the
|
|
/// file.
|
|
pub fn new(file: File, cipher: Cipher, key: &[u8], iv: &[u8]) -> Self {
|
|
// TODO: return proper errors from crypter
|
|
EncryptedFileReaderTagged {
|
|
file,
|
|
cipher,
|
|
crypter: Crypter::new(
|
|
cipher,
|
|
CrypterMode::Encrypt,
|
|
key,
|
|
Some(iv),
|
|
).unwrap(),
|
|
tag: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Read for EncryptedFileReaderTagged {
|
|
fn read(&mut self, buf: &mut [u8]) -> Result<usize, io::Error> {
|
|
// If the tag reader has been created, read from it
|
|
if let Some(ref mut tag) = self.tag {
|
|
return tag.read(buf);
|
|
}
|
|
|
|
// Get the block size
|
|
let block_size = self.cipher.block_size();
|
|
|
|
// Create a raw file buffer
|
|
let mut raw = vec![0u8; buf.len() - block_size];
|
|
|
|
// TODO: remove after debugging
|
|
println!("DEBUG: Reading raw: {} (buf size: {})", raw.len(), buf.len());
|
|
|
|
// Read from the file, and truncate the buffer
|
|
let len = self.file.read(&mut raw)?;
|
|
raw.truncate(len);
|
|
|
|
// Encrypt raw data if if something was read
|
|
if len > 0 {
|
|
// Encrypt the raw data
|
|
// TODO: store raw bytes that were not encrypted yet
|
|
let len_enc = self.crypter.update(&raw, buf).unwrap();
|
|
|
|
// TODO: remove after debugging
|
|
println!("DEBUG: Read: {}; Encrypted: {}", len, len_enc);
|
|
|
|
// Return the number of encrypted bytes
|
|
return Ok(len_enc);
|
|
}
|
|
|
|
// Create a buffer for data that might be returned when finalizing
|
|
let mut output = vec![0u8; block_size];
|
|
|
|
// Finalize the crypter, truncate the output
|
|
let len = self.crypter.finalize(&mut output).unwrap();
|
|
//output.truncate(len);
|
|
|
|
// TODO: remove after debugging
|
|
if len > 0 {
|
|
println!("DEBUG: Read {} more bytes when finalized!", len);
|
|
}
|
|
|
|
// Create a buffer for the tag
|
|
let mut tag = vec![0u8; TAG_LEN];
|
|
|
|
// Get the tag
|
|
self.crypter.get_tag(&mut tag).unwrap();
|
|
|
|
// Set the tag
|
|
self.tag = Some(Cursor::new(tag));
|
|
|
|
// Read again, to start reading the tag
|
|
self.read(buf)
|
|
}
|
|
}
|
|
|
|
/// The response from the server after a file has been uploaded.
|
|
/// This response contains the file ID and owner key, to manage the file.
|
|
///
|
|
/// It also contains the download URL, although an additional secret is
|
|
/// required.
|
|
///
|
|
/// The download URL can be generated using `download_url()` which will
|
|
/// include the required secret in the URL.
|
|
#[derive(Debug, Deserialize)]
|
|
struct UploadResponse {
|
|
/// unkhe URL the file is reachable at.
|
|
/// This includes the file ID, but does not include the secret.
|
|
url: String,
|
|
|
|
/// The owner key, used to do further file modifications.
|
|
owner: String,
|
|
|
|
/// The file ID.
|
|
id: String,
|
|
}
|
|
|
|
impl UploadResponse {
|
|
/// Get the download URL, including the secret.
|
|
///
|
|
/// The secret bytes must be passed to `secret`.
|
|
pub fn download_url(&self, secret: &[u8]) -> String {
|
|
format!("{}#{}", self.url, base64_encode(secret))
|
|
}
|
|
}
|
|
|
|
/// Encode the given byte slice using base64, in an URL-safe manner.
|
|
fn base64_encode(input: &[u8]) -> String {
|
|
base64::encode_config(input, base64::URL_SAFE_NO_PAD)
|
|
}
|