1
0
Fork 0
mirror of https://github.com/dani-garcia/vaultwarden.git synced 2025-07-30 15:49:08 +00:00

First working version

This commit is contained in:
Daniel García 2018-02-10 01:00:55 +01:00
commit 5cd40c63ed
172 changed files with 17903 additions and 0 deletions

149
src/api/core/accounts.rs Normal file
View file

@ -0,0 +1,149 @@
use rocket::Route;
use rocket::response::status::BadRequest;
use rocket_contrib::{Json, Value};
use db::DbConn;
use db::models::*;
use util;
use auth::Headers;
use CONFIG;
#[derive(Deserialize, Debug)]
#[allow(non_snake_case)]
struct RegisterData {
email: String,
key: String,
keys: Option<KeysData>,
masterPasswordHash: String,
masterPasswordHint: Option<String>,
name: Option<String>,
}
#[derive(Deserialize, Debug)]
#[allow(non_snake_case)]
struct KeysData {
encryptedPrivateKey: String,
publicKey: String,
}
#[post("/accounts/register", data = "<data>")]
fn register(data: Json<RegisterData>, conn: DbConn) -> Result<(), BadRequest<Json>> {
if CONFIG.signups_allowed {
err!(format!("Signups not allowed"))
}
println!("DEBUG - {:#?}", data);
if let Some(_) = User::find_by_mail(&data.email, &conn) {
err!("Email already exists")
}
let mut user = User::new(data.email.clone(),
data.key.clone(),
data.masterPasswordHash.clone());
// Add extra fields if present
if let Some(name) = data.name.clone() {
user.name = name;
}
if let Some(hint) = data.masterPasswordHint.clone() {
user.password_hint = Some(hint);
}
if let Some(ref keys) = data.keys {
user.private_key = Some(keys.encryptedPrivateKey.clone());
user.public_key = Some(keys.publicKey.clone());
}
user.save(&conn);
Ok(())
}
#[get("/accounts/profile")]
fn profile(headers: Headers, conn: DbConn) -> Result<Json, BadRequest<Json>> {
Ok(Json(headers.user.to_json()))
}
#[post("/accounts/keys", data = "<data>")]
fn post_keys(data: Json<KeysData>, headers: Headers, conn: DbConn) -> Result<Json, BadRequest<Json>> {
let mut user = headers.user;
user.private_key = Some(data.encryptedPrivateKey.clone());
user.public_key = Some(data.publicKey.clone());
user.save(&conn);
Ok(Json(user.to_json()))
}
#[post("/accounts/password", data = "<data>")]
fn post_password(data: Json<Value>, headers: Headers, conn: DbConn) -> Result<Json, BadRequest<Json>> {
let key = data["key"].as_str().unwrap();
let password_hash = data["masterPasswordHash"].as_str().unwrap();
let new_password_hash = data["newMasterPasswordHash"].as_str().unwrap();
let mut user = headers.user;
if !user.check_valid_password(password_hash) {
err!("Invalid password")
}
user.set_password(new_password_hash);
user.key = key.to_string();
user.save(&conn);
Ok(Json(json!({})))
}
#[post("/accounts/security-stamp", data = "<data>")]
fn post_sstamp(data: Json<Value>, headers: Headers, conn: DbConn) -> Result<Json, BadRequest<Json>> {
let password_hash = data["masterPasswordHash"].as_str().unwrap();
let mut user = headers.user;
if !user.check_valid_password(password_hash) {
err!("Invalid password")
}
user.reset_security_stamp();
Ok(Json(json!({})))
}
#[post("/accounts/email-token", data = "<data>")]
fn post_email(data: Json<Value>, headers: Headers, conn: DbConn) -> Result<Json, BadRequest<Json>> {
println!("{:#?}", data);
let password_hash = data["masterPasswordHash"].as_str().unwrap();
let mut user = headers.user;
if !user.check_valid_password(password_hash) {
err!("Invalid password")
}
err!("Not implemented")
}
#[post("/accounts/delete", data = "<data>")]
fn delete_account(data: Json<Value>, headers: Headers, conn: DbConn) -> Result<Json, BadRequest<Json>> {
let password_hash = data["masterPasswordHash"].as_str().unwrap();
let mut user = headers.user;
if !user.check_valid_password(password_hash) {
err!("Invalid password")
}
err!("Not implemented")
}
#[get("/accounts/revision-date")]
fn revision_date(headers: Headers, conn: DbConn) -> Result<String, BadRequest<Json>> {
let revision_date = headers.user.updated_at.timestamp();
Ok(revision_date.to_string())
}

251
src/api/core/ciphers.rs Normal file
View file

@ -0,0 +1,251 @@
use std::io::{Cursor, Read};
use rocket::{Route, Data};
use rocket::http::ContentType;
use rocket::response::status::BadRequest;
use rocket_contrib::{Json, Value};
use multipart::server::Multipart;
use db::DbConn;
use db::models::*;
use util;
use auth::Headers;
#[get("/sync")]
fn sync(headers: Headers, conn: DbConn) -> Result<Json, BadRequest<Json>> {
let user = headers.user;
let folders = Folder::find_by_user(&user.uuid, &conn);
let folders_json: Vec<Value> = folders.iter().map(|c| c.to_json()).collect();
let ciphers = Cipher::find_by_user(&user.uuid, &conn);
let ciphers_json: Vec<Value> = ciphers.iter().map(|c| c.to_json()).collect();
Ok(Json(json!({
"Profile": user.to_json(),
"Folders": folders_json,
"Ciphers": ciphers_json,
"Domains": {
"EquivalentDomains": [],
"GlobalEquivalentDomains": [],
"Object": "domains",
},
"Object": "sync"
})))
}
#[get("/ciphers")]
fn get_ciphers(headers: Headers, conn: DbConn) -> Result<Json, BadRequest<Json>> {
let ciphers = Cipher::find_by_user(&headers.user.uuid, &conn);
let ciphers_json: Vec<Value> = ciphers.iter().map(|c| c.to_json()).collect();
Ok(Json(json!({
"Data": ciphers_json,
"Object": "list",
})))
}
#[get("/ciphers/<uuid>")]
fn get_cipher(uuid: String, headers: Headers, conn: DbConn) -> Result<Json, BadRequest<Json>> {
let cipher = match Cipher::find_by_uuid(&uuid, &conn) {
Some(cipher) => cipher,
None => err!("Cipher doesn't exist")
};
if cipher.user_uuid != headers.user.uuid {
err!("Cipher is now owned by user")
}
Ok(Json(cipher.to_json()))
}
#[derive(Deserialize, Debug)]
#[allow(non_snake_case)]
struct CipherData {
#[serde(rename = "type")]
type_: i32,
folderId: Option<String>,
organizationId: Option<String>,
name: Option<String>,
notes: Option<String>,
favorite: Option<bool>,
login: Option<Value>,
card: Option<Value>,
fields: Option<Vec<Value>>,
}
#[post("/ciphers", data = "<data>")]
fn post_ciphers(data: Json<CipherData>, headers: Headers, conn: DbConn) -> Result<Json, BadRequest<Json>> {
let mut cipher = Cipher::new(headers.user.uuid.clone(),
data.type_,
data.favorite.unwrap_or(false));
if let Some(ref folder_id) = data.folderId {
// TODO: Validate folder is owned by user
cipher.folder_uuid = Some(folder_id.clone());
}
if let Some(ref org_id) = data.organizationId {
cipher.organization_uuid = Some(org_id.clone());
}
cipher.data = match value_from_data(&data) {
Ok(value) => {
use serde_json;
println!("--- {:?}", serde_json::to_string(&value));
println!("--- {:?}", value.to_string());
value.to_string()
}
Err(msg) => err!(msg)
};
cipher.save(&conn);
Ok(Json(cipher.to_json()))
}
fn value_from_data(data: &CipherData) -> Result<Value, &'static str> {
let mut values = json!({
"Name": data.name,
"Notes": data.notes
});
match data.type_ {
1 /*Login*/ => {
let login_data = match data.login {
Some(ref login) => login.clone(),
None => return Err("Login data missing")
};
if !copy_values(&login_data, &mut values) {
return Err("Login data invalid");
}
}
3 /*Card*/ => {
let card_data = match data.card {
Some(ref card) => card.clone(),
None => return Err("Card data missing")
};
if !copy_values(&card_data, &mut values) {
return Err("Card data invalid");
}
}
_ => return Err("Unknown type")
}
if let Some(ref fields) = data.fields {
values["Fields"] = Value::Array(fields.iter().map(|f| {
use std::collections::BTreeMap;
use serde_json;
let empty_map: BTreeMap<String, Value> = BTreeMap::new();
let mut value = serde_json::to_value(empty_map).unwrap();
copy_values(&f, &mut value);
value
}).collect());
} else {
values["Fields"] = Value::Null;
}
Ok(values)
}
fn copy_values(from: &Value, to: &mut Value) -> bool {
let map = match from.as_object() {
Some(map) => map,
None => return false
};
for (key, val) in map {
to[util::upcase_first(key)] = val.clone();
}
true
}
#[post("/ciphers/import", data = "<data>")]
fn post_ciphers_import(data: Json<Value>, headers: Headers, conn: DbConn) -> Result<Json, BadRequest<Json>> {
println!("{:#?}", data);
err!("Not implemented")
}
#[post("/ciphers/<uuid>/attachment", format = "multipart/form-data", data = "<data>")]
fn post_attachment(uuid: String, data: Data, content_type: &ContentType, headers: Headers, conn: DbConn) -> Result<Json, BadRequest<Json>> {
// TODO: Check if cipher exists
let mut params = content_type.params();
let boundary_pair = params.next().expect("No boundary provided"); // ("boundary", "----WebKitFormBoundary...")
let boundary = boundary_pair.1;
use data_encoding::BASE64URL;
use crypto;
use CONFIG;
// TODO: Maybe use the same format as the official server?
let attachment_id = BASE64URL.encode(&crypto::get_random_64());
let path = format!("{}/{}/{}", CONFIG.attachments_folder,
headers.user.uuid, attachment_id);
println!("Path {:#?}", path);
let mut mp = Multipart::with_body(data.open(), boundary);
match mp.save().with_dir(path).into_entries() {
Some(entries) => {
println!("Entries {:#?}", entries);
let saved_file = &entries.files["data"][0]; // Only one file at a time
let file_name = &saved_file.filename; // This is provided by the client, don't trust it
let file_size = &saved_file.size;
}
None => err!("No data entries")
}
err!("Not implemented")
}
#[delete("/ciphers/<uuid>/attachment/<attachment_id>")]
fn delete_attachment(uuid: String, attachment_id: String, headers: Headers, conn: DbConn) -> Result<Json, BadRequest<Json>> {
if uuid != headers.user.uuid {
err!("Permission denied")
}
// Delete file
// Delete entry in cipher
err!("Not implemented")
}
#[post("/ciphers/<uuid>")]
fn post_cipher(uuid: String, headers: Headers, conn: DbConn) -> Result<Json, BadRequest<Json>> {
put_cipher(uuid, headers, conn)
}
#[put("/ciphers/<uuid>")]
fn put_cipher(uuid: String, headers: Headers, conn: DbConn) -> Result<Json, BadRequest<Json>> { err!("Not implemented") }
#[delete("/ciphers/<uuid>")]
fn delete_cipher(uuid: String, headers: Headers, conn: DbConn) -> Result<Json, BadRequest<Json>> { err!("Not implemented") }
#[post("/ciphers/delete", data = "<data>")]
fn delete_all(data: Json<Value>, headers: Headers, conn: DbConn) -> Result<Json, BadRequest<Json>> {
let password_hash = data["masterPasswordHash"].as_str().unwrap();
let user = headers.user;
if !user.check_valid_password(password_hash) {
err!("Invalid password")
}
// Cipher::delete_from_user(&conn);
err!("Not implemented")
}

102
src/api/core/folders.rs Normal file
View file

@ -0,0 +1,102 @@
use rocket::Route;
use rocket::response::status::BadRequest;
use rocket_contrib::{Json, Value};
use db::DbConn;
use db::models::*;
use util;
use auth::Headers;
#[get("/folders")]
fn get_folders(headers: Headers, conn: DbConn) -> Result<Json, BadRequest<Json>> {
let folders = Folder::find_by_user(&headers.user.uuid, &conn);
let folders_json: Vec<Value> = folders.iter().map(|c| c.to_json()).collect();
Ok(Json(json!({
"Data": folders_json,
"Object": "list",
})))
}
#[get("/folders/<uuid>")]
fn get_folder(uuid: String, headers: Headers, conn: DbConn) -> Result<Json, BadRequest<Json>> {
let mut folder = match Folder::find_by_uuid(&uuid, &conn) {
Some(folder) => folder,
_ => err!("Invalid folder")
};
if folder.user_uuid != headers.user.uuid {
err!("Folder belongs to another user")
}
Ok(Json(folder.to_json()))
}
#[post("/folders", data = "<data>")]
fn post_folders(data: Json<Value>, headers: Headers, conn: DbConn) -> Result<Json, BadRequest<Json>> {
let name = &data["name"].as_str();
if name.is_none() {
err!("Invalid name")
}
let folder = Folder::new(headers.user.uuid.clone(), name.unwrap().into());
folder.save(&conn);
Ok(Json(folder.to_json()))
}
#[post("/folders/<uuid>", data = "<data>")]
fn post_folder(uuid: String, data: Json<Value>, headers: Headers, conn: DbConn) -> Result<Json, BadRequest<Json>> {
put_folder(uuid, data, headers, conn)
}
#[put("/folders/<uuid>", data = "<data>")]
fn put_folder(uuid: String, data: Json<Value>, headers: Headers, conn: DbConn) -> Result<Json, BadRequest<Json>> {
let mut folder = match Folder::find_by_uuid(&uuid, &conn) {
Some(folder) => folder,
_ => err!("Invalid folder")
};
if folder.user_uuid != headers.user.uuid {
err!("Folder belongs to another user")
}
let name = &data["name"].as_str();
if name.is_none() {
err!("Invalid name")
}
folder.name = name.unwrap().into();
folder.save(&conn);
Ok(Json(folder.to_json()))
}
#[post("/folders/<uuid>/delete", data = "<data>")]
fn delete_folder_post(uuid: String, data: Json<Value>, headers: Headers, conn: DbConn) -> Result<(), BadRequest<Json>> {
// Data contains a json object with the id, but we don't need it
delete_folder(uuid, headers, conn)
}
#[delete("/folders/<uuid>")]
fn delete_folder(uuid: String, headers: Headers, conn: DbConn) -> Result<(), BadRequest<Json>> {
let folder = match Folder::find_by_uuid(&uuid, &conn) {
Some(folder) => folder,
_ => err!("Invalid folder")
};
if folder.user_uuid != headers.user.uuid {
err!("Folder belongs to another user")
}
folder.delete(&conn);
Ok(())
}

100
src/api/core/mod.rs Normal file
View file

@ -0,0 +1,100 @@
mod accounts;
mod ciphers;
mod folders;
mod two_factor;
use self::accounts::*;
use self::ciphers::*;
use self::folders::*;
use self::two_factor::*;
pub fn routes() -> Vec<Route> {
routes![
register,
profile,
post_keys,
post_password,
post_sstamp,
post_email,
delete_account,
revision_date,
sync,
get_ciphers,
get_cipher,
post_ciphers,
post_ciphers_import,
post_attachment,
delete_attachment,
post_cipher,
put_cipher,
delete_cipher,
delete_all,
get_folders,
get_folder,
post_folders,
post_folder,
put_folder,
delete_folder_post,
delete_folder,
get_twofactor,
get_recover,
generate_authenticator,
activate_authenticator,
disable_authenticator,
get_collections,
clear_device_token,
put_device_token,
get_eq_domains,
post_eq_domains
]
}
///
/// Move this somewhere else
///
use rocket::Route;
use rocket::response::status::BadRequest;
use rocket_contrib::{Json, Value};
use db::DbConn;
use db::models::*;
use util;
use auth::Headers;
// GET /api/collections?writeOnly=false
#[get("/collections")]
fn get_collections() -> Result<Json, BadRequest<Json>> {
Ok(Json(json!({
"Data": [],
"Object": "list"
})))
}
#[put("/devices/identifier/<uuid>/clear-token")]
fn clear_device_token(uuid: String) -> Result<Json, BadRequest<Json>> { err!("Not implemented") }
#[put("/devices/identifier/<uuid>/token")]
fn put_device_token(uuid: String) -> Result<Json, BadRequest<Json>> { err!("Not implemented") }
#[get("/settings/domains")]
fn get_eq_domains() -> Result<Json, BadRequest<Json>> {
err!("Not implemented")
}
#[post("/settings/domains")]
fn post_eq_domains() -> Result<Json, BadRequest<Json>> {
err!("Not implemented")
}

131
src/api/core/two_factor.rs Normal file
View file

@ -0,0 +1,131 @@
use rocket::Route;
use rocket::response::status::BadRequest;
use rocket_contrib::{Json, Value};
use data_encoding::BASE32;
use db::DbConn;
use db::models::*;
use util;
use crypto;
use auth::Headers;
#[get("/two-factor")]
fn get_twofactor(headers: Headers) -> Result<Json, BadRequest<Json>> {
let data = if headers.user.totp_secret.is_none() {
Value::Null
} else {
json!([{
"Enabled": true,
"Type": 0,
"Object": "twoFactorProvider"
}])
};
Ok(Json(json!({
"Data": data,
"Object": "list"
})))
}
#[post("/two-factor/get-recover", data = "<data>")]
fn get_recover(data: Json<Value>, headers: Headers) -> Result<Json, BadRequest<Json>> {
let password_hash = data["masterPasswordHash"].as_str().unwrap();
if !headers.user.check_valid_password(password_hash) {
err!("Invalid password");
}
Ok(Json(json!({
"Code": headers.user.totp_recover,
"Object": "twoFactorRecover"
})))
}
#[post("/two-factor/get-authenticator", data = "<data>")]
fn generate_authenticator(data: Json<Value>, headers: Headers) -> Result<Json, BadRequest<Json>> {
let password_hash = data["masterPasswordHash"].as_str().unwrap();
if !headers.user.check_valid_password(password_hash) {
err!("Invalid password");
}
let (enabled, key) = match headers.user.totp_secret {
Some(secret) => (true, secret),
_ => (false, BASE32.encode(&crypto::get_random(vec![0u8; 20])))
};
Ok(Json(json!({
"Enabled": enabled,
"Key": key,
"Object": "twoFactorAuthenticator"
})))
}
#[post("/two-factor/authenticator", data = "<data>")]
fn activate_authenticator(data: Json<Value>, headers: Headers, conn: DbConn) -> Result<Json, BadRequest<Json>> {
let password_hash = data["masterPasswordHash"].as_str().unwrap();
if !headers.user.check_valid_password(password_hash) {
err!("Invalid password");
}
let token = data["token"].as_str(); // 123456
let key = data["key"].as_str().unwrap(); // YI4SKBIXG32LOA6VFKH2NI25VU3E4QML
// Validate key as base32 and 20 bytes length
let decoded_key: Vec<u8> = match BASE32.decode(key.as_bytes()) {
Ok(decoded) => decoded,
_ => err!("Invalid totp secret")
};
if decoded_key.len() != 20 {
err!("Invalid key length")
}
// Set key in user.totp_secret
let mut user = headers.user;
user.totp_secret = Some(key.to_uppercase());
// Validate the token provided with the key
if !user.check_totp_code(util::parse_option_string(token)) {
err!("Invalid totp code")
}
// Generate totp_recover
let totp_recover = BASE32.encode(&crypto::get_random(vec![0u8; 20]));
user.totp_recover = Some(totp_recover);
user.save(&conn);
Ok(Json(json!({
"Enabled": true,
"Key": key,
"Object": "twoFactorAuthenticator"
})))
}
#[post("/two-factor/disable", data = "<data>")]
fn disable_authenticator(data: Json<Value>, headers: Headers, conn: DbConn) -> Result<Json, BadRequest<Json>> {
let _type = &data["type"];
let password_hash = data["masterPasswordHash"].as_str().unwrap();
if !headers.user.check_valid_password(password_hash) {
err!("Invalid password");
}
let mut user = headers.user;
user.totp_secret = None;
user.totp_recover = None;
user.save(&conn);
Ok(Json(json!({
"Enabled": false,
"Type": 0,
"Object": "twoFactorProvider"
})))
}

85
src/api/icons.rs Normal file
View file

@ -0,0 +1,85 @@
use std::io;
use std::io::prelude::*;
use std::fs::{create_dir_all, File};
use std::path::Path;
use rocket::Route;
use rocket::response::Content;
use rocket::http::ContentType;
use reqwest;
use CONFIG;
pub fn routes() -> Vec<Route> {
routes![icon]
}
#[get("/<domain>/icon.png")]
fn icon(domain: String) -> Content<Vec<u8>> {
// Validate the domain to avoid directory traversal attacks
if domain.contains("/") || domain.contains("..") {
return Content(ContentType::PNG, get_fallback_icon());
}
let url = format!("https://icons.bitwarden.com/{}/icon.png", domain);
// Get the icon, or fallback in case of error
let icon = match get_icon_cached(&domain, &url) {
Ok(icon) => icon,
Err(e) => return Content(ContentType::PNG, get_fallback_icon())
};
Content(ContentType::PNG, icon)
}
fn get_icon(url: &str) -> Result<Vec<u8>, reqwest::Error> {
let mut res = reqwest::get(url)?;
res = match res.error_for_status() {
Err(e) => return Err(e),
Ok(res) => res
};
let mut buffer: Vec<u8> = vec![];
res.copy_to(&mut buffer)?;
Ok(buffer)
}
fn get_icon_cached(key: &str, url: &str) -> io::Result<Vec<u8>> {
create_dir_all(&CONFIG.icon_cache_folder)?;
let path = &format!("{}/{}.png", CONFIG.icon_cache_folder, key);
/// Try to read the cached icon, and return it if it exists
match File::open(path) {
Ok(mut f) => {
let mut buffer = Vec::new();
if f.read_to_end(&mut buffer).is_ok() {
return Ok(buffer);
}
/* If error reading file continue */
}
Err(_) => { /* Continue */ }
}
println!("Downloading icon for {}...", key);
let icon = match get_icon(url) {
Ok(icon) => icon,
Err(_) => return Err(io::Error::new(io::ErrorKind::NotFound, ""))
};
/// Save the currently downloaded icon
match File::create(path) {
Ok(mut f) => { f.write_all(&icon); }
Err(_) => { /* Continue */ }
};
Ok(icon)
}
fn get_fallback_icon() -> Vec<u8> {
let fallback_icon = "https://raw.githubusercontent.com/bitwarden/web/master/src/images/fa-globe.png";
get_icon_cached("default", fallback_icon).unwrap()
}

225
src/api/identity.rs Normal file
View file

@ -0,0 +1,225 @@
use std::collections::HashMap;
use rocket::Route;
use rocket::request::{Form, FormItems, FromForm};
use rocket::response::status::BadRequest;
use rocket_contrib::Json;
use db::DbConn;
use db::models::*;
use util;
pub fn routes() -> Vec<Route> {
routes![ login]
}
#[post("/connect/token", data = "<connect_data>")]
fn login(connect_data: Form<ConnectData>, conn: DbConn) -> Result<Json, BadRequest<Json>> {
let data = connect_data.get();
println!("{:#?}", data);
let mut device = match data.grant_type {
GrantType::RefreshToken => {
// Extract token
let token = data.get("refresh_token").unwrap();
// Get device by refresh token
match Device::find_by_refresh_token(token, &conn) {
Some(device) => device,
None => err!("Invalid refresh token")
}
}
GrantType::Password => {
// Validate scope
let scope = data.get("scope").unwrap();
if scope != "api offline_access" {
err!("Scope not supported")
}
// Get the user
let username = data.get("username").unwrap();
let user = match User::find_by_mail(username, &conn) {
Some(user) => user,
None => err!("Invalid username or password")
};
// Check password
let password = data.get("password").unwrap();
if !user.check_valid_password(password) {
err!("Invalid username or password")
}
/*
//TODO: When invalid username or password, return this with a 400 BadRequest:
{
"error": "invalid_grant",
"error_description": "invalid_username_or_password",
"ErrorModel": {
"Message": "Username or password is incorrect. Try again.",
"ValidationErrors": null,
"ExceptionMessage": null,
"ExceptionStackTrace": null,
"InnerExceptionMessage": null,
"Object": "error"
}
}
*/
// Check if totp code is required and the value is correct
let totp_code = util::parse_option_string(data.get("twoFactorToken").map(String::as_ref));
if !user.check_totp_code(totp_code) {
// Return error 400
return err_json!(json!({
"error" : "invalid_grant",
"error_description" : "Two factor required.",
"TwoFactorProviders" : [ 0 ],
"TwoFactorProviders2" : { "0" : null }
}));
}
// Let's only use the header and ignore the 'devicetype' parameter
// TODO Get header Device-Type
let device_type_num = 0;// headers.device_type;
let (device_id, device_name) = match data.get("client_id").unwrap().as_ref() {
"web" => { (format!("web-{}", user.uuid), String::from("web")) }
"browser" | "mobile" => {
(
data.get("deviceidentifier").unwrap().clone(),
data.get("devicename").unwrap().clone(),
)
}
_ => err!("Invalid client id")
};
// Find device or create new
let device = match Device::find_by_uuid(&device_id, &conn) {
Some(device) => {
// Check if valid device
if device.user_uuid != user.uuid {
device.delete(&conn);
err!("Device is not owned by user")
}
device
}
None => {
// Create new device
Device::new(device_id, user.uuid, device_name, device_type_num)
}
};
device
}
};
let user = User::find_by_uuid(&device.user_uuid, &conn).unwrap();
let (access_token, expires_in) = device.refresh_tokens(&user);
device.save(&conn);
// TODO: when to include :privateKey and :TwoFactorToken?
Ok(Json(json!({
"access_token": access_token,
"expires_in": expires_in,
"token_type": "Bearer",
"refresh_token": device.refresh_token,
"Key": user.key,
"PrivateKey": user.private_key
})))
}
#[derive(Debug)]
struct ConnectData {
grant_type: GrantType,
data: HashMap<String, String>,
}
impl ConnectData {
fn get(&self, key: &str) -> Option<&String> {
self.data.get(&key.to_lowercase())
}
}
#[derive(Debug, Copy, Clone)]
enum GrantType { RefreshToken, Password }
const VALUES_REFRESH: [&str; 1] = ["refresh_token"];
const VALUES_PASSWORD: [&str; 5] = ["client_id",
"grant_type", "password", "scope", "username"];
const VALUES_DEVICE: [&str; 3] = ["deviceidentifier",
"devicename", "devicetype"];
impl<'f> FromForm<'f> for ConnectData {
type Error = String;
fn from_form(items: &mut FormItems<'f>, strict: bool) -> Result<Self, Self::Error> {
let mut data = HashMap::new();
// Insert data into map
for (key, value) in items {
let decoded_key: String = match key.url_decode() {
Ok(decoded) => decoded,
Err(e) => return Err(format!("Error decoding key: {}", value)),
};
let decoded_value: String = match value.url_decode() {
Ok(decoded) => decoded,
Err(e) => return Err(format!("Error decoding value: {}", value)),
};
data.insert(decoded_key.to_lowercase(), decoded_value);
}
// Validate needed values
let grant_type =
match data.get("grant_type").map(|s| &s[..]) {
Some("refresh_token") => {
// Check if refresh token is proviced
if let Err(msg) = check_values(&data, &VALUES_REFRESH) {
return Err(msg);
}
GrantType::RefreshToken
}
Some("password") => {
// Check if basic values are provided
if let Err(msg) = check_values(&data, &VALUES_PASSWORD) {
return Err(msg);
}
// Check that device values are present on device
match data.get("client_id").unwrap().as_ref() {
"browser" | "mobile" => {
if let Err(msg) = check_values(&data, &VALUES_DEVICE) {
return Err(msg);
}
}
_ => {}
}
GrantType::Password
}
_ => return Err(format!("Grant type not supported"))
};
Ok(ConnectData { grant_type, data })
}
}
fn check_values(map: &HashMap<String, String>, values: &[&str]) -> Result<(), String> {
for value in values {
if !map.contains_key(*value) {
return Err(format!("{} cannot be blank", value));
}
}
Ok(())
}

9
src/api/mod.rs Normal file
View file

@ -0,0 +1,9 @@
mod core;
mod icons;
mod identity;
mod web;
pub use self::core::routes as core_routes;
pub use self::icons::routes as icons_routes;
pub use self::identity::routes as identity_routes;
pub use self::web::routes as web_routes;

43
src/api/web.rs Normal file
View file

@ -0,0 +1,43 @@
use std::io;
use std::path::{Path, PathBuf};
use rocket::Route;
use rocket::response::NamedFile;
use rocket_contrib::{Json, Value};
use auth::Headers;
use CONFIG;
pub fn routes() -> Vec<Route> {
routes![index, files, attachments, alive]
}
// TODO: Might want to use in memory cache: https://github.com/hgzimmerman/rocket-file-cache
#[get("/")]
fn index() -> io::Result<NamedFile> {
NamedFile::open(Path::new(&CONFIG.web_vault_folder).join("index.html"))
}
#[get("/<p..>")] // Only match this if the other routes don't match
fn files(p: PathBuf) -> io::Result<NamedFile> {
NamedFile::open(Path::new(&CONFIG.web_vault_folder).join(p))
}
#[get("/attachments/<uuid>/<file..>")]
fn attachments(uuid: String, file: PathBuf, headers: Headers) -> io::Result<NamedFile> {
if uuid != headers.user.uuid {
return Err(io::Error::new(io::ErrorKind::PermissionDenied, "Permission denied"));
}
NamedFile::open(Path::new(&CONFIG.attachments_folder).join(file))
}
#[get("/alive")]
fn alive() -> Json<String> {
use util::format_date;
use chrono::{NaiveDateTime, Utc};
Json(format_date(&Utc::now().naive_utc()))
}

164
src/auth.rs Normal file
View file

@ -0,0 +1,164 @@
///
/// JWT Handling
///
use util::read_file;
use std::path::Path;
use time::Duration;
use jwt;
use serde::ser::Serialize;
use serde::de::Deserialize;
use CONFIG;
const JWT_ALGORITHM: jwt::Algorithm = jwt::Algorithm::RS256;
pub const JWT_ISSUER: &'static str = "localhost:8000/identity";
lazy_static! {
pub static ref DEFAULT_VALIDITY: Duration = Duration::hours(2);
static ref JWT_HEADER: jwt::Header = jwt::Header::new(JWT_ALGORITHM);
static ref PRIVATE_RSA_KEY: Vec<u8> = match read_file(&CONFIG.private_rsa_key) {
Ok(key) => key,
Err(e) => panic!("Error loading private RSA Key from {}\n Error: {}", CONFIG.private_rsa_key, e)
};
static ref PUBLIC_RSA_KEY: Vec<u8> = match read_file(&CONFIG.public_rsa_key) {
Ok(key) => key,
Err(e) => panic!("Error loading public RSA Key from {}\n Error: {}", CONFIG.public_rsa_key, e)
};
}
pub fn encode_jwt<T: Serialize>(claims: &T) -> String {
match jwt::encode(&JWT_HEADER, claims, &PRIVATE_RSA_KEY) {
Ok(token) => return token,
Err(e) => panic!("Error encoding jwt {}", e)
};
}
pub fn decode_jwt(token: &str) -> Result<JWTClaims, String> {
let validation = jwt::Validation {
leeway: 30, // 30 seconds
validate_exp: true,
validate_iat: true,
validate_nbf: true,
aud: None,
iss: Some(JWT_ISSUER.into()),
sub: None,
algorithms: vec![JWT_ALGORITHM],
};
match jwt::decode(token, &PUBLIC_RSA_KEY, &validation) {
Ok(decoded) => Ok(decoded.claims),
Err(msg) => {
println!("Error validating jwt - {:#?}", msg);
Err(msg.to_string())
}
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct JWTClaims {
// Not before
pub nbf: i64,
// Expiration time
pub exp: i64,
// Issuer
pub iss: String,
// Subject
pub sub: String,
pub premium: bool,
pub name: String,
pub email: String,
pub email_verified: bool,
// user security_stamp
pub sstamp: String,
// device uuid
pub device: String,
// [ "api", "offline_access" ]
pub scope: Vec<String>,
// [ "Application" ]
pub amr: Vec<String>,
}
///
/// Bearer token authentication
///
use rocket::Outcome;
use rocket::http::Status;
use rocket::request::{self, Request, FromRequest};
use db::DbConn;
use db::models::{User, Device};
pub struct Headers {
pub device_type: i32,
pub device: Device,
pub user: User,
}
impl<'a, 'r> FromRequest<'a, 'r> for Headers {
type Error = &'static str;
fn from_request(request: &'a Request<'r>) -> request::Outcome<Self, Self::Error> {
let headers = request.headers();
/// Get device type
let device_type = match headers.get_one("Device-Type")
.map(|s| s.parse::<i32>()) {
Some(Ok(dt)) => dt,
_ => return err_handler!("Device-Type is invalid or missing")
};
/// Get access_token
let access_token: &str = match request.headers().get_one("Authorization") {
Some(a) => {
let split: Option<&str> = a.rsplit("Bearer ").next();
if split.is_none() {
err_handler!("No access token provided")
}
split.unwrap()
}
None => err_handler!("No access token provided")
};
/// Check JWT token is valid and get device and user from it
let claims: JWTClaims = match decode_jwt(access_token) {
Ok(claims) => claims,
Err(msg) => {
println!("Invalid claim: {}", msg);
err_handler!("Invalid claim")
}
};
let device_uuid = claims.device;
let user_uuid = claims.sub;
let conn = match request.guard::<DbConn>() {
Outcome::Success(conn) => conn,
_ => err_handler!("Error getting DB")
};
let device = match Device::find_by_uuid(&device_uuid, &conn) {
Some(device) => device,
None => err_handler!("Invalid device id")
};
let user = match User::find_by_uuid(&user_uuid, &conn) {
Some(user) => user,
None => err_handler!("Device has no user associated")
};
if user.security_stamp != claims.sstamp {
err_handler!("Invalid security stamp")
}
Outcome::Success(Headers { device_type, device, user })
}
}

168
src/bin/proxy.rs Normal file
View file

@ -0,0 +1,168 @@
#![feature(plugin)]
#![plugin(rocket_codegen)]
extern crate rocket;
extern crate rocket_contrib;
extern crate reqwest;
use std::io::{self, Cursor};
use std::str::FromStr;
use std::path::{Path, PathBuf};
use rocket::{Request, Response};
use rocket::config::Config;
use rocket::fairing::{Fairing, Info, Kind};
use rocket::http;
use rocket::response::NamedFile;
use reqwest::header::{self, Headers};
/**
** These routes are here to avoid showing errors in the console,
** redirect the body data to the fairing and show the web vault.
**/
#[get("/")]
fn index() -> io::Result<NamedFile> {
NamedFile::open(Path::new("web-vault").join("index.html"))
}
#[get("/<p..>")] // Only match this if the other routes don't match
fn get(p: PathBuf) -> io::Result<NamedFile> {
NamedFile::open(Path::new("web-vault").join(p))
}
#[delete("/<_p..>")]
fn delete(_p: PathBuf) {}
#[put("/<_p..>", data = "<d>")]
fn put(_p: PathBuf, d: Vec<u8>) -> Vec<u8> { d }
#[post("/<_p..>", data = "<d>")]
fn post(_p: PathBuf, d: Vec<u8>) -> Vec<u8> { d }
fn main() {
let config = Config::development().unwrap();
rocket::custom(config, false)
.mount("/", routes![get, put, post, delete, index])
.attach(ProxyFairing { client: reqwest::Client::new() })
.launch();
}
struct ProxyFairing {
client: reqwest::Client
}
impl Fairing for ProxyFairing {
fn info(&self) -> Info {
Info {
name: "Proxy Fairing",
kind: Kind::Launch | Kind::Response,
}
}
fn on_launch(&self, _rocket: &rocket::Rocket) {
println!("Started proxy on locahost:8000");
}
fn on_response(&self, req: &Request, res: &mut Response) {
// Prepare the data to make the request
// -------------------------------------
let url = {
let url = req.uri().as_str();
// Check if we are outside the API paths
if !url.starts_with("/api/")
&& !url.starts_with("/identity/") {
return;
}
// Replace the path with the real server URL
url.replacen("/api/", "https://api.bitwarden.com/", 1)
.replacen("/identity/", "https://identity.bitwarden.com/", 1)
};
let host = url.split("/").collect::<Vec<_>>()[2];
let headers = headers_rocket_to_reqwest(req.headers(), host);
let method = reqwest::Method::from_str(req.method().as_str()).unwrap();
let body = res.body_bytes();
println!("\n\nREQ. {} {}", req.method().as_str(), url);
println!("HEADERS. {:#?}", headers);
if let Some(ref body) = body {
let body_string = String::from_utf8_lossy(body);
if !body_string.contains("<!DOCTYPE html>") {
println!("BODY. {:?}", body_string);
}
}
// Execute the request
// -------------------------------------
let mut client = self.client.request(method, &url);
let request_builder = client.headers(headers);
if let Some(body_vec) = body {
request_builder.body(body_vec);
}
let mut server_res = match request_builder.send() {
Ok(response) => response,
Err(e) => {
res.set_status(http::Status::BadRequest);
res.set_sized_body(Cursor::new(e.to_string()));
return;
}
};
// Get the response values
// -------------------------------------
let mut res_body: Vec<u8> = vec![];
server_res.copy_to(&mut res_body).unwrap();
let res_status = server_res.status().as_u16();
let mut res_headers = server_res.headers().clone();
// These headers break stuff
res_headers.remove::<header::TransferEncoding>();
res_headers.remove::<header::ContentLength>();
println!("\n\nRES. {} {}", res_status, url);
// Nothing interesting here
// println!("HEADERS. {:#?}", res_headers);
println!("BODY. {:?}", String::from_utf8_lossy(&res_body));
// Prepare the response
// -------------------------------------
res.set_status(http::Status::from_code(res_status).unwrap_or(http::Status::BadRequest));
headers_reqwest_to_rocket(&res_headers, res);
res.set_sized_body(Cursor::new(res_body));
}
}
fn headers_rocket_to_reqwest(headers: &http::HeaderMap, host: &str) -> Headers {
let mut new_headers = Headers::new();
for header in headers.iter() {
let name = header.name().to_string();
let value = if name.to_lowercase() != "host" {
header.value().to_string()
} else {
host.to_string()
};
new_headers.set_raw(name, value);
}
new_headers
}
fn headers_reqwest_to_rocket(headers: &Headers, res: &mut Response) {
for header in headers.iter() {
res.set_raw_header(header.name().to_string(), header.value_string());
}
}

36
src/crypto.rs Normal file
View file

@ -0,0 +1,36 @@
///
/// PBKDF2 derivation
///
use ring::{digest, pbkdf2};
static DIGEST_ALG: &digest::Algorithm = &digest::SHA256;
const OUTPUT_LEN: usize = digest::SHA256_OUTPUT_LEN;
pub fn hash_password(secret: &[u8], salt: &[u8], iterations: u32) -> Vec<u8> {
let mut out = vec![0u8; OUTPUT_LEN]; // Initialize array with zeros
pbkdf2::derive(DIGEST_ALG, iterations, salt, secret, &mut out);
out
}
pub fn verify_password_hash(secret: &[u8], salt: &[u8], previous: &[u8], iterations: u32) -> bool {
pbkdf2::verify(DIGEST_ALG, iterations, salt, secret, previous).is_ok()
}
///
/// Random values
///
pub fn get_random_64() -> Vec<u8> {
get_random(vec![0u8; 64])
}
pub fn get_random(mut array: Vec<u8>) -> Vec<u8> {
use ring::rand::{SecureRandom, SystemRandom};
SystemRandom::new().fill(&mut array);
array
}

60
src/db/mod.rs Normal file
View file

@ -0,0 +1,60 @@
use std::ops::Deref;
use diesel::{Connection as DieselConnection, ConnectionError};
use diesel::sqlite::SqliteConnection;
use r2d2;
use r2d2_diesel::ConnectionManager;
use rocket::http::Status;
use rocket::request::{self, FromRequest};
use rocket::{Outcome, Request, State};
use CONFIG;
/// An alias to the database connection used
type Connection = SqliteConnection;
/// An alias to the type for a pool of Diesel SQLite connections.
type Pool = r2d2::Pool<ConnectionManager<Connection>>;
/// Connection request guard type: a wrapper around an r2d2 pooled connection.
pub struct DbConn(pub r2d2::PooledConnection<ConnectionManager<Connection>>);
pub mod schema;
pub mod models;
/// Initializes a database pool.
pub fn init_pool() -> Pool {
let manager = ConnectionManager::new(&*CONFIG.database_url);
r2d2::Pool::builder()
.build(manager)
.expect("Failed to create pool")
}
pub fn get_connection() -> Result<Connection, ConnectionError> {
Connection::establish(&CONFIG.database_url)
}
/// Attempts to retrieve a single connection from the managed database pool. If
/// no pool is currently managed, fails with an `InternalServerError` status. If
/// no connections are available, fails with a `ServiceUnavailable` status.
impl<'a, 'r> FromRequest<'a, 'r> for DbConn {
type Error = ();
fn from_request(request: &'a Request<'r>) -> request::Outcome<DbConn, ()> {
let pool = request.guard::<State<Pool>>()?;
match pool.get() {
Ok(conn) => Outcome::Success(DbConn(conn)),
Err(_) => Outcome::Failure((Status::ServiceUnavailable, ()))
}
}
}
// For the convenience of using an &DbConn as a &Database.
impl Deref for DbConn {
type Target = Connection;
fn deref(&self) -> &Self::Target {
&self.0
}
}

112
src/db/models/cipher.rs Normal file
View file

@ -0,0 +1,112 @@
use chrono::{NaiveDate, NaiveDateTime, Utc};
use time::Duration;
use serde_json::Value as JsonValue;
use uuid::Uuid;
#[derive(Queryable, Insertable, Identifiable)]
#[table_name = "ciphers"]
#[primary_key(uuid)]
pub struct Cipher {
pub uuid: String,
pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime,
pub user_uuid: String,
pub folder_uuid: Option<String>,
pub organization_uuid: Option<String>,
// Login = 1,
// SecureNote = 2,
// Card = 3,
// Identity = 4
pub type_: i32,
pub data: String,
pub favorite: bool,
pub attachments: Option<Vec<u8>>,
}
/// Local methods
impl Cipher {
pub fn new(user_uuid: String, type_: i32, favorite: bool) -> Cipher {
let now = Utc::now().naive_utc();
Cipher {
uuid: Uuid::new_v4().to_string(),
created_at: now,
updated_at: now,
user_uuid,
folder_uuid: None,
organization_uuid: None,
type_,
favorite,
data: String::new(),
attachments: None,
}
}
pub fn to_json(&self) -> JsonValue {
use serde_json;
use util::format_date;
let data: JsonValue = serde_json::from_str(&self.data).unwrap();
json!({
"Id": self.uuid,
"Type": self.type_,
"RevisionDate": format_date(&self.updated_at),
"FolderId": self.folder_uuid,
"Favorite": self.favorite,
"OrganizationId": "",
"Attachments": self.attachments,
"OrganizationUseTotp": false,
"Data": data,
"Object": "cipher",
"Edit": true,
})
}
}
use diesel;
use diesel::prelude::*;
use db::DbConn;
use db::schema::ciphers;
/// Database methods
impl Cipher {
pub fn save(&self, conn: &DbConn) -> bool {
// TODO: Update modified date
match diesel::replace_into(ciphers::table)
.values(self)
.execute(&**conn) {
Ok(1) => true, // One row inserted
_ => false,
}
}
pub fn delete(self, conn: &DbConn) -> bool {
match diesel::delete(ciphers::table.filter(
ciphers::uuid.eq(self.uuid)))
.execute(&**conn) {
Ok(1) => true, // One row deleted
_ => false,
}
}
pub fn find_by_uuid(uuid: &str, conn: &DbConn) -> Option<Cipher> {
ciphers::table
.filter(ciphers::uuid.eq(uuid))
.first::<Cipher>(&**conn).ok()
}
pub fn find_by_user(user_uuid: &str, conn: &DbConn) -> Vec<Cipher> {
ciphers::table
.filter(ciphers::user_uuid.eq(user_uuid))
.load::<Cipher>(&**conn).expect("Error loading ciphers")
}
}

117
src/db/models/device.rs Normal file
View file

@ -0,0 +1,117 @@
use chrono::{NaiveDate, NaiveDateTime, Utc};
use time::Duration;
use serde_json::Value as JsonValue;
use uuid::Uuid;
#[derive(Queryable, Insertable, Identifiable)]
#[table_name = "devices"]
#[primary_key(uuid)]
pub struct Device {
pub uuid: String,
pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime,
pub user_uuid: String,
pub name: String,
/// https://github.com/bitwarden/core/tree/master/src/Core/Enums
pub type_: i32,
pub push_token: Option<String>,
pub refresh_token: String,
}
/// Local methods
impl Device {
pub fn new(uuid: String, user_uuid: String, name: String, type_: i32) -> Device {
let now = Utc::now().naive_utc();
Device {
uuid,
created_at: now,
updated_at: now,
user_uuid,
name,
type_,
push_token: None,
refresh_token: String::new(),
}
}
pub fn refresh_tokens(&mut self, user: &super::User) -> (String, i64) {
// If there is no refresh token, we create one
if self.refresh_token.is_empty() {
use data_encoding::BASE64URL;
use crypto;
self.refresh_token = BASE64URL.encode(&crypto::get_random_64());
}
// Update the expiration of the device and the last update date
let time_now = Utc::now().naive_utc();
self.updated_at = time_now;
// Create the JWT claims struct, to send to the client
use auth::{encode_jwt, JWTClaims, DEFAULT_VALIDITY, JWT_ISSUER};
let claims = JWTClaims {
nbf: time_now.timestamp(),
exp: (time_now + *DEFAULT_VALIDITY).timestamp(),
iss: JWT_ISSUER.to_string(),
sub: user.uuid.to_string(),
premium: true,
name: user.name.to_string(),
email: user.email.to_string(),
email_verified: true,
sstamp: user.security_stamp.to_string(),
device: self.uuid.to_string(),
scope: vec!["api".into(), "offline_access".into()],
amr: vec!["Application".into()],
};
(encode_jwt(&claims), DEFAULT_VALIDITY.num_seconds())
}
}
use diesel;
use diesel::prelude::*;
use db::DbConn;
use db::schema::devices;
/// Database methods
impl Device {
pub fn save(&self, conn: &DbConn) -> bool {
// TODO: Update modified date
match diesel::replace_into(devices::table)
.values(self)
.execute(&**conn) {
Ok(1) => true, // One row inserted
_ => false,
}
}
pub fn delete(self, conn: &DbConn) -> bool {
match diesel::delete(devices::table.filter(
devices::uuid.eq(self.uuid)))
.execute(&**conn) {
Ok(1) => true, // One row deleted
_ => false,
}
}
pub fn find_by_uuid(uuid: &str, conn: &DbConn) -> Option<Device> {
devices::table
.filter(devices::uuid.eq(uuid))
.first::<Device>(&**conn).ok()
}
pub fn find_by_refresh_token(refresh_token: &str, conn: &DbConn) -> Option<Device> {
devices::table
.filter(devices::refresh_token.eq(refresh_token))
.first::<Device>(&**conn).ok()
}
}

83
src/db/models/folder.rs Normal file
View file

@ -0,0 +1,83 @@
use chrono::{NaiveDate, NaiveDateTime, Utc};
use time::Duration;
use serde_json::Value as JsonValue;
use uuid::Uuid;
#[derive(Queryable, Insertable, Identifiable)]
#[table_name = "folders"]
#[primary_key(uuid)]
pub struct Folder {
pub uuid: String,
pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime,
pub user_uuid: String,
pub name: String,
}
/// Local methods
impl Folder {
pub fn new(user_uuid: String, name: String) -> Folder {
let now = Utc::now().naive_utc();
Folder {
uuid: Uuid::new_v4().to_string(),
created_at: now,
updated_at: now,
user_uuid,
name,
}
}
pub fn to_json(&self) -> JsonValue {
use util::format_date;
json!({
"Id": self.uuid,
"RevisionDate": format_date(&self.updated_at),
"Name": self.name,
"Object": "folder",
})
}
}
use diesel;
use diesel::prelude::*;
use db::DbConn;
use db::schema::folders;
/// Database methods
impl Folder {
pub fn save(&self, conn: &DbConn) -> bool {
// TODO: Update modified date
match diesel::replace_into(folders::table)
.values(self)
.execute(&**conn) {
Ok(1) => true, // One row inserted
_ => false,
}
}
pub fn delete(self, conn: &DbConn) -> bool {
match diesel::delete(folders::table.filter(
folders::uuid.eq(self.uuid)))
.execute(&**conn) {
Ok(1) => true, // One row deleted
_ => false,
}
}
pub fn find_by_uuid(uuid: &str, conn: &DbConn) -> Option<Folder> {
folders::table
.filter(folders::uuid.eq(uuid))
.first::<Folder>(&**conn).ok()
}
pub fn find_by_user(user_uuid: &str, conn: &DbConn) -> Vec<Folder> {
folders::table
.filter(folders::user_uuid.eq(user_uuid))
.load::<Folder>(&**conn).expect("Error loading folders")
}
}

9
src/db/models/mod.rs Normal file
View file

@ -0,0 +1,9 @@
mod cipher;
mod device;
mod folder;
mod user;
pub use self::cipher::Cipher;
pub use self::device::Device;
pub use self::folder::Folder;
pub use self::user::User;

159
src/db/models/user.rs Normal file
View file

@ -0,0 +1,159 @@
use chrono::{NaiveDate, NaiveDateTime, Utc};
use time::Duration;
use serde_json::Value as JsonValue;
use uuid::Uuid;
use CONFIG;
#[derive(Queryable, Insertable, Identifiable)]
#[table_name = "users"]
#[primary_key(uuid)]
pub struct User {
pub uuid: String,
pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime,
pub email: String,
pub name: String,
pub password_hash: Vec<u8>,
pub salt: Vec<u8>,
pub password_iterations: i32,
pub password_hint: Option<String>,
pub key: String,
pub private_key: Option<String>,
pub public_key: Option<String>,
pub totp_secret: Option<String>,
pub totp_recover: Option<String>,
pub security_stamp: String,
}
/// Local methods
impl User {
pub fn new(mail: String, key: String, password: String) -> User {
let now = Utc::now().naive_utc();
let email = mail.to_lowercase();
use crypto;
let iterations = CONFIG.password_iterations;
let salt = crypto::get_random_64();
let password_hash = crypto::hash_password(password.as_bytes(), &salt, iterations as u32);
User {
uuid: Uuid::new_v4().to_string(),
created_at: now,
updated_at: now,
name: email.clone(),
email,
key,
password_hash,
salt,
password_iterations: iterations,
security_stamp: Uuid::new_v4().to_string(),
password_hint: None,
private_key: None,
public_key: None,
totp_secret: None,
totp_recover: None,
}
}
pub fn check_valid_password(&self, password: &str) -> bool {
use crypto;
crypto::verify_password_hash(password.as_bytes(),
&self.salt,
&self.password_hash,
self.password_iterations as u32)
}
pub fn set_password(&mut self, password: &str) {
use crypto;
self.password_hash = crypto::hash_password(password.as_bytes(),
&self.salt,
self.password_iterations as u32);
self.reset_security_stamp();
}
pub fn reset_security_stamp(&mut self) {
self.security_stamp = Uuid::new_v4().to_string();
}
pub fn check_totp_code(&self, totp_code: Option<u64>) -> bool {
if let Some(ref totp_secret) = self.totp_secret {
if let Some(code) = totp_code {
// Validate totp
use data_encoding::BASE32;
use oath::{totp_raw_now, HashType};
let decoded_secret = match BASE32.decode(totp_secret.as_bytes()) {
Ok(s) => s,
Err(e) => return false
};
let generated = totp_raw_now(&decoded_secret, 6, 0, 30, &HashType::SHA1);
generated == code
} else {
false
}
} else {
true
}
}
pub fn to_json(&self) -> JsonValue {
json!({
"Id": self.uuid,
"Name": self.name,
"Email": self.email,
"EmailVerified": true,
"Premium": true,
"MasterPasswordHint": self.password_hint,
"Culture": "en-US",
"TwoFactorEnabled": self.totp_secret.is_some(),
"Key": self.key,
"PrivateKey": self.private_key,
"SecurityStamp": self.security_stamp,
"Organizations": [],
"Object": "profile"
})
}
}
use diesel;
use diesel::prelude::*;
use db::DbConn;
use db::schema::users;
/// Database methods
impl User {
pub fn save(&self, conn: &DbConn) -> bool {
// TODO: Update modified date
match diesel::replace_into(users::table) // Insert or update
.values(self)
.execute(&**conn) {
Ok(1) => true, // One row inserted
_ => false,
}
}
pub fn find_by_mail(mail: &str, conn: &DbConn) -> Option<User> {
let lower_mail = mail.to_lowercase();
users::table
.filter(users::email.eq(lower_mail))
.first::<User>(&**conn).ok()
}
pub fn find_by_uuid(uuid: &str, conn: &DbConn) -> Option<User> {
users::table
.filter(users::uuid.eq(uuid))
.first::<User>(&**conn).ok()
}
}

71
src/db/schema.rs Normal file
View file

@ -0,0 +1,71 @@
table! {
ciphers (uuid) {
uuid -> Text,
created_at -> Timestamp,
updated_at -> Timestamp,
user_uuid -> Text,
folder_uuid -> Nullable<Text>,
organization_uuid -> Nullable<Text>,
#[sql_name = "type"]
type_ -> Integer,
data -> Text,
favorite -> Bool,
attachments -> Nullable<Binary>,
}
}
table! {
devices (uuid) {
uuid -> Text,
created_at -> Timestamp,
updated_at -> Timestamp,
user_uuid -> Text,
name -> Text,
#[sql_name = "type"]
type_ -> Integer,
push_token -> Nullable<Text>,
refresh_token -> Text,
}
}
table! {
folders (uuid) {
uuid -> Text,
created_at -> Timestamp,
updated_at -> Timestamp,
user_uuid -> Text,
name -> Text,
}
}
table! {
users (uuid) {
uuid -> Text,
created_at -> Timestamp,
updated_at -> Timestamp,
email -> Text,
name -> Text,
password_hash -> Binary,
salt -> Binary,
password_iterations -> Integer,
password_hint -> Nullable<Text>,
key -> Text,
private_key -> Nullable<Text>,
public_key -> Nullable<Text>,
totp_secret -> Nullable<Text>,
totp_recover -> Nullable<Text>,
security_stamp -> Text,
}
}
joinable!(ciphers -> folders (folder_uuid));
joinable!(ciphers -> users (user_uuid));
joinable!(devices -> users (user_uuid));
joinable!(folders -> users (user_uuid));
allow_tables_to_appear_in_same_query!(
ciphers,
devices,
folders,
users,
);

151
src/main.rs Normal file
View file

@ -0,0 +1,151 @@
#![allow(dead_code, unused_variables, unused, unused_mut)]
#![feature(plugin, custom_derive)]
#![cfg_attr(test, plugin(stainless))]
#![plugin(rocket_codegen)]
extern crate rocket;
#[macro_use]
extern crate rocket_contrib;
extern crate reqwest;
extern crate multipart;
extern crate serde;
#[macro_use]
extern crate serde_derive;
#[macro_use]
extern crate serde_json;
#[macro_use]
extern crate diesel;
#[macro_use]
extern crate diesel_migrations;
extern crate r2d2_diesel;
extern crate r2d2;
extern crate ring;
extern crate uuid;
extern crate chrono;
extern crate time;
extern crate oath;
extern crate data_encoding;
extern crate jsonwebtoken as jwt;
extern crate dotenv;
#[macro_use]
extern crate lazy_static;
use std::{io, env};
use rocket::{Data, Request, Rocket};
use rocket::fairing::{Fairing, Info, Kind};
#[macro_use]
mod util;
#[cfg(test)]
mod tests;
mod api;
mod db;
mod crypto;
mod auth;
fn init_rocket() -> Rocket {
rocket::ignite()
.mount("/", api::web_routes())
.mount("/api", api::core_routes())
.mount("/identity", api::identity_routes())
.mount("/icons", api::icons_routes())
.manage(db::init_pool())
.attach(DebugFairing)
}
// Embed the migrations from the migrations folder into the application
// This way, the program automatically migrates the database to the latest version
// https://docs.rs/diesel_migrations/*/diesel_migrations/macro.embed_migrations.html
embed_migrations!();
fn main() {
println!("{:#?}", *CONFIG);
// Make sure the database is up to date (create if it doesn't exist, or run the migrations)
let connection = db::get_connection().expect("Can't conect to DB");
embedded_migrations::run_with_output(&connection, &mut io::stdout());
// Validate location of rsa keys
if !util::file_exists(&CONFIG.private_rsa_key) {
panic!("private_rsa_key doesn't exist");
}
if !util::file_exists(&CONFIG.public_rsa_key) {
panic!("public_rsa_key doesn't exist");
}
init_rocket().launch();
}
lazy_static! {
// Load the config from .env or from environment variables
static ref CONFIG: Config = Config::load();
}
#[derive(Debug)]
pub struct Config {
database_url: String,
private_rsa_key: String,
public_rsa_key: String,
icon_cache_folder: String,
attachments_folder: String,
web_vault_folder: String,
signups_allowed: bool,
password_iterations: i32,
}
impl Config {
fn load() -> Self {
dotenv::dotenv().ok();
Config {
database_url: env::var("DATABASE_URL").unwrap_or("data/db.sqlite3".into()),
private_rsa_key: env::var("PRIVATE_RSA_KEY").unwrap_or("data/private_rsa_key.der".into()),
public_rsa_key: env::var("PUBLIC_RSA_KEY").unwrap_or("data/public_rsa_key.der".into()),
icon_cache_folder: env::var("ICON_CACHE_FOLDER").unwrap_or("data/icon_cache".into()),
attachments_folder: env::var("ATTACHMENTS_FOLDER").unwrap_or("data/attachments".into()),
web_vault_folder: env::var("WEB_VAULT_FOLDER").unwrap_or("web-vault/".into()),
signups_allowed: util::parse_option_string(env::var("SIGNUPS_ALLOWED").ok()).unwrap_or(false),
password_iterations: util::parse_option_string(env::var("PASSWORD_ITERATIONS").ok()).unwrap_or(100_000),
}
}
}
struct DebugFairing;
impl Fairing for DebugFairing {
fn info(&self) -> Info {
Info {
name: "Request Debugger",
kind: Kind::Request,
}
}
fn on_request(&self, req: &mut Request, data: &Data) {
let uri_string = req.uri().to_string();
// Ignore web requests
if !uri_string.starts_with("/api") &&
!uri_string.starts_with("/identity") {
return;
}
/*
for header in req.headers().iter() {
println!("DEBUG- {:#?} {:#?}", header.name(), header.value());
}
*/
/*let body_data = data.peek();
if body_data.len() > 0 {
println!("DEBUG- Body Complete: {}", data.peek_complete());
println!("DEBUG- {}", String::from_utf8_lossy(body_data));
}*/
}
}

49
src/tests.rs Normal file
View file

@ -0,0 +1,49 @@
use super::init_rocket;
use rocket::local::Client;
use rocket::http::Status;
#[test]
fn hello_world() {
let client = Client::new(init_rocket()).expect("valid rocket instance");
let mut response = client.get("/alive").dispatch();
assert_eq!(response.status(), Status::Ok);
// assert_eq!(response.body_string(), Some("Hello, world!".into()));
}
// TODO: For testing, we can use either a test_transaction, or an in-memory database
// TODO: test_transaction http://docs.diesel.rs/diesel/connection/trait.Connection.html#method.begin_test_transaction
// TODO: in-memory database https://github.com/diesel-rs/diesel/issues/419 (basically use ":memory:" as the connection string
describe! route_tests {
before_each {
let rocket = init_rocket();
let client = Client::new(rocket).expect("valid rocket instance");
}
describe! alive {
before_each {
let mut res = client.get("/alive").dispatch();
let body_str = res.body().and_then(|b| b.into_string()).unwrap();
}
it "responds with status OK 200" {
assert_eq!(res.status(), Status::Ok);
}
it "responds with current year" {
assert!(body_str.contains("2018"));
}
}
describe! nested_example {
ignore "this is ignored" {
assert_eq!(1, 2);
}
failing "this fails" {
assert_eq!(1, 2);
}
}
}

84
src/util.rs Normal file
View file

@ -0,0 +1,84 @@
///
/// Macros
///
#[macro_export]
macro_rules! err {
($expr:expr) => {{
err_json!(json!($expr));
}}
}
#[macro_export]
macro_rules! err_json {
($expr:expr) => {{
println!("ERROR: {}", $expr);
return Err($crate::rocket::response::status::BadRequest(Some($crate::rocket_contrib::Json($expr))));
}}
}
#[macro_export]
macro_rules! err_handler {
($expr:expr) => {{
println!("ERROR: {}", $expr);
return $crate::rocket::Outcome::Failure(($crate::rocket::http::Status::Unauthorized, $expr));
}}
}
///
/// File handling
///
use std::path::Path;
use std::io::Read;
use std::fs::File;
pub fn file_exists(path: &str) -> bool {
Path::new(path).exists()
}
pub fn read_file(path: &str) -> Result<Vec<u8>, String> {
let mut file = File::open(Path::new(path))
.map_err(|e| format!("Error opening file: {}", e))?;
let mut contents: Vec<u8> = Vec::new();
file.read_to_end(&mut contents)
.map_err(|e| format!("Error reading file: {}", e))?;
Ok(contents)
}
///
/// String util methods
///
use std::str::FromStr;
pub fn upcase_first(s: &str) -> String {
let mut c = s.chars();
match c.next() {
None => String::new(),
Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
}
}
pub fn parse_option_string<S, T>(string: Option<S>) -> Option<T> where S: Into<String>, T: FromStr {
if let Some(Ok(value)) = string.map(|s| s.into().parse::<T>()) {
Some(value)
} else {
None
}
}
///
/// Date util methods
///
use chrono::NaiveDateTime;
const DATETIME_FORMAT: &'static str = "%Y-%m-%dT%H:%M:%S%.6fZ";
pub fn format_date(date: &NaiveDateTime) -> String {
date.format(DATETIME_FORMAT).to_string()
}