mirror of
https://github.com/dani-garcia/vaultwarden.git
synced 2025-05-23 21:09:21 +00:00
First working version
This commit is contained in:
22
.dockerignore
Normal file
22
.dockerignore
Normal file
@ -0,0 +1,22 @@
|
||||
# Local build artifacts
|
||||
target
|
||||
|
||||
# Data folder
|
||||
data
|
||||
|
||||
# IDE files
|
||||
.vscode
|
||||
.idea
|
||||
*.iml
|
||||
|
||||
# Git and Docker files
|
||||
.git
|
||||
.gitignore
|
||||
.gitmodules
|
||||
Dockerfile
|
||||
docker-compose.yml
|
||||
.dockerignore
|
||||
|
||||
# Documentation
|
||||
*.md
|
||||
|
13
.env
Normal file
13
.env
Normal file
@ -0,0 +1,13 @@
|
||||
# DATABASE_URL=data/db.sqlite3
|
||||
# PRIVATE_RSA_KEY=data/private_rsa_key.der
|
||||
# PUBLIC_RSA_KEY=data/public_rsa_key.der
|
||||
# ICON_CACHE_FOLDER=data/icon_cache
|
||||
# ATTACHMENTS_FOLDER=data/attachments
|
||||
|
||||
# true for yes, anything else for no
|
||||
SIGNUPS_ALLOWED=true
|
||||
|
||||
# ROCKET_ENV=production
|
||||
# ROCKET_ADDRESS=0.0.0.0 # Enable this to test mobile app
|
||||
# ROCKET_PORT=8000
|
||||
# ROCKET_TLS={certs="/path/to/certs.pem",key="/path/to/key.pem"}
|
13
.gitignore
vendored
Normal file
13
.gitignore
vendored
Normal file
@ -0,0 +1,13 @@
|
||||
# Local build artifacts
|
||||
target
|
||||
|
||||
# Data folder
|
||||
data
|
||||
|
||||
# IDE files
|
||||
.vscode
|
||||
.idea
|
||||
*.iml
|
||||
|
||||
# Environment file
|
||||
# .env
|
1884
Cargo.lock
generated
Normal file
1884
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
62
Cargo.toml
Normal file
62
Cargo.toml
Normal file
@ -0,0 +1,62 @@
|
||||
[package]
|
||||
name = "bitwarden_rs"
|
||||
version = "0.1.0"
|
||||
authors = ["Daniel García <dani-garcia@users.noreply.github.com>"]
|
||||
|
||||
[dependencies]
|
||||
|
||||
# Test framework, similar to rspec
|
||||
stainless = "0.1.12"
|
||||
|
||||
# Web framework for nightly with a focus on ease-of-use, expressibility, and speed.
|
||||
rocket = { version = "0.3.6", features = ["tls"] }
|
||||
rocket_codegen = "0.3.6"
|
||||
rocket_contrib = "0.3.6"
|
||||
|
||||
# HTTP client
|
||||
reqwest = "0.8.4"
|
||||
|
||||
# multipart/form-data support
|
||||
multipart = "0.13.6"
|
||||
|
||||
# A generic serialization/deserialization framework
|
||||
serde = "1.0.27"
|
||||
serde_derive = "1.0.27"
|
||||
serde_json = "1.0.9"
|
||||
|
||||
# A safe, extensible ORM and Query builder
|
||||
# If tables need more than 16 columns, add feature "large-tables"
|
||||
diesel = { version = "1.1.1", features = ["sqlite", "chrono"] }
|
||||
diesel_migrations = {version = "1.1.0", features = ["sqlite"] }
|
||||
|
||||
# A generic connection pool
|
||||
r2d2 = "0.8.2"
|
||||
r2d2-diesel = "1.0.0"
|
||||
|
||||
# Crypto library
|
||||
ring = { version = "0.11.0", features = ["rsa_signing"]}
|
||||
|
||||
# UUID generation
|
||||
uuid = { version = "0.5.1", features = ["v4"] }
|
||||
|
||||
# Date and time library for Rust
|
||||
chrono = "0.4.0"
|
||||
time = "0.1.39"
|
||||
|
||||
# TOTP library
|
||||
oath = "0.10.2"
|
||||
|
||||
# Data encoding library
|
||||
data-encoding = "2.1.1"
|
||||
|
||||
# JWT library
|
||||
jsonwebtoken = "4.0.0"
|
||||
|
||||
# A `dotenv` implementation for Rust
|
||||
dotenv = { version = "0.10.1", default-features = false }
|
||||
|
||||
# Lazy static macro
|
||||
lazy_static = "1.0.0"
|
||||
|
||||
[patch.crates-io]
|
||||
jsonwebtoken = { path = "libs/jsonwebtoken" } # Make jwt use ring 0.11, to match rocket
|
62
Dockerfile
Normal file
62
Dockerfile
Normal file
@ -0,0 +1,62 @@
|
||||
# Using multistage build:
|
||||
# https://docs.docker.com/develop/develop-images/multistage-build/
|
||||
# https://whitfin.io/speeding-up-rust-docker-builds/
|
||||
########################## BUILD IMAGE ##########################
|
||||
# We need to use the Rust build image, because
|
||||
# we need the Rust compiler and Cargo tooling
|
||||
FROM rustlang/rust:nightly as build
|
||||
|
||||
# Install the database libraries, in this case just sqlite3
|
||||
RUN apt-get update && \
|
||||
apt-get install -y sqlite3
|
||||
|
||||
# Install the diesel_cli tool, to manage migrations
|
||||
# RUN cargo install diesel_cli --no-default-features --features sqlite
|
||||
|
||||
# Creates a dummy project used to grab dependencies
|
||||
RUN USER=root cargo new --bin app
|
||||
WORKDIR /app
|
||||
|
||||
# Copies over *only* your manifests and vendored dependencies
|
||||
COPY ./Cargo.* ./
|
||||
COPY ./_libs ./_libs
|
||||
|
||||
# Builds your dependencies and removes the
|
||||
# dummy project, except the target folder
|
||||
RUN cargo build --release
|
||||
RUN find . -not -path "./target*" -delete
|
||||
|
||||
# Copies the complete project
|
||||
# To avoid copying unneeded files, use .dockerignore
|
||||
COPY . .
|
||||
|
||||
# Builds again, this time it'll just be
|
||||
# your actual source files being built
|
||||
RUN cargo build --release
|
||||
|
||||
######################## RUNTIME IMAGE ########################
|
||||
# Create a new stage with a minimal image
|
||||
# because we already have a binary built
|
||||
FROM debian:stretch-slim
|
||||
|
||||
# Install needed libraries
|
||||
RUN apt-get update && \
|
||||
apt-get install -y sqlite3 openssl libssl-dev
|
||||
|
||||
RUN mkdir /data
|
||||
VOLUME /data
|
||||
EXPOSE 80
|
||||
|
||||
# Copies the files from the context (migrations, web-vault, ...)
|
||||
# and the binary from the "build" stage to the current stage
|
||||
|
||||
# TODO Only needs web-vault and .env
|
||||
# COPY . .
|
||||
COPY .env .
|
||||
COPY web-vault ./web-vault
|
||||
COPY --from=build app/target/release/bitwarden_rs .
|
||||
|
||||
# Configures the startup!
|
||||
# Use production to disable Rocket logging
|
||||
#CMD ROCKET_ENV=production ./bitwarden_rs
|
||||
CMD ROCKET_ENV=staging ./bitwarden_rs
|
97
README.md
Normal file
97
README.md
Normal file
@ -0,0 +1,97 @@
|
||||
## Easy setup (Docker)
|
||||
Install Docker to your system and then, from the project root, run:
|
||||
```
|
||||
# Build the docker image:
|
||||
docker build -t dani/bitwarden_rs .
|
||||
|
||||
# Run the docker image with a docker volume:
|
||||
docker volume create bw_data
|
||||
docker run --name bitwarden_rs -it --init --rm --mount source=bw_data,target=/data -p 8000:80 dani/bitwarden_rs
|
||||
|
||||
# OR, Run the docker image with a host bind, where <absolute_path> is the absolute path to a folder in the host:
|
||||
docker run --name bitwarden_rs -it --init --rm --mount type=bind,source=<absolute_path>,target=/data -p 8000:80 dani/bitwarden_rs
|
||||
```
|
||||
|
||||
## How to compile bitwarden_rs
|
||||
Install `rust nightly`, in Windows the recommended way is through `rustup`.
|
||||
|
||||
Install the `sqlite3`, and `openssl` libraries, in Windows the best option is Microsoft's `vcpkg`,
|
||||
on other systems use their respective package managers.
|
||||
|
||||
Then run:
|
||||
```
|
||||
cargo run
|
||||
# or
|
||||
cargo build
|
||||
```
|
||||
|
||||
## How to update the web-vault used
|
||||
Install `node.js` and either `yarn` or `npm` (usually included with node)
|
||||
Clone the web-vault outside the project:
|
||||
```
|
||||
git clone https://github.com/bitwarden/web.git web-vault
|
||||
```
|
||||
|
||||
Modify `web-vault/settings.json` to look like this:
|
||||
```json
|
||||
{
|
||||
"appSettings": {
|
||||
"apiUri": "/api",
|
||||
"identityUri": "/identity",
|
||||
"iconsUri": "/icons",
|
||||
"stripeKey": "",
|
||||
"braintreeKey": ""
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Then, run the following from the `web-vault` dir:
|
||||
```
|
||||
# With yarn (recommended)
|
||||
yarn
|
||||
yarn gulp dist:selfHosted
|
||||
|
||||
# With npm
|
||||
npm install
|
||||
npx gulp dist:selfHosted
|
||||
```
|
||||
|
||||
Finally copy the contents of the `web-vault/dist` folder into the `bitwarden_rs/web-vault` folder.
|
||||
|
||||
## How to create the RSA signing key for JWT
|
||||
Generate the RSA key:
|
||||
```
|
||||
openssl genrsa -out data/private_rsa_key.pem
|
||||
```
|
||||
|
||||
Convert the generated key to .DER:
|
||||
```
|
||||
openssl rsa -in data/private_rsa_key.pem -outform DER -out data/private_rsa_key.der
|
||||
```
|
||||
|
||||
And generate the public key:
|
||||
```
|
||||
openssl rsa -in data/private_rsa_key.der -inform DER -RSAPublicKey_out -outform DER -out data/public_rsa_key.der
|
||||
```
|
||||
|
||||
## How to recreate database schemas
|
||||
Install diesel-cli with cargo:
|
||||
```
|
||||
cargo install diesel_cli --no-default-features --features sqlite
|
||||
```
|
||||
|
||||
Make sure that the correct path to the database is in the `.env` file.
|
||||
|
||||
If you want to modify the schemas, create a new migration with:
|
||||
```
|
||||
diesel migration generate <name>
|
||||
```
|
||||
|
||||
Modify the *.sql files, making sure that any changes are reverted
|
||||
in the down.sql file.
|
||||
|
||||
Apply the migrations and save the generated schemas as follows:
|
||||
```
|
||||
diesel migration redo
|
||||
diesel print-schema > src/db/schema.rs
|
||||
```
|
10
docker-compose.yml
Normal file
10
docker-compose.yml
Normal file
@ -0,0 +1,10 @@
|
||||
## Docker Compose file, experimental and untested
|
||||
# Run 'docker compose up' to start the service
|
||||
version: '3'
|
||||
services:
|
||||
web:
|
||||
build: .
|
||||
ports:
|
||||
- "8000:80"
|
||||
volumes:
|
||||
- ./data:/data
|
20
libs/jsonwebtoken/Cargo.toml
Normal file
20
libs/jsonwebtoken/Cargo.toml
Normal file
@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "jsonwebtoken"
|
||||
version = "4.0.0"
|
||||
authors = ["Vincent Prouillet <prouillet.vincent@gmail.com>"]
|
||||
license = "MIT"
|
||||
readme = "README.md"
|
||||
description = "Create and parse JWT in a strongly typed way."
|
||||
homepage = "https://github.com/Keats/rust-jwt"
|
||||
repository = "https://github.com/Keats/rust-jwt"
|
||||
keywords = ["jwt", "web", "api", "token", "json"]
|
||||
|
||||
[dependencies]
|
||||
error-chain = { version = "0.11", default-features = false }
|
||||
serde_json = "1.0"
|
||||
serde_derive = "1.0"
|
||||
serde = "1.0"
|
||||
ring = { version = "0.11.0", features = ["rsa_signing", "dev_urandom_fallback"] }
|
||||
base64 = "0.8"
|
||||
untrusted = "0.5"
|
||||
chrono = "0.4"
|
21
libs/jsonwebtoken/LICENSE
Normal file
21
libs/jsonwebtoken/LICENSE
Normal file
@ -0,0 +1,21 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2015 Vincent Prouillet
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
120
libs/jsonwebtoken/src/crypto.rs
Normal file
120
libs/jsonwebtoken/src/crypto.rs
Normal file
@ -0,0 +1,120 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use base64;
|
||||
use ring::{rand, digest, hmac, signature};
|
||||
use ring::constant_time::verify_slices_are_equal;
|
||||
use untrusted;
|
||||
|
||||
use errors::{Result, ErrorKind};
|
||||
|
||||
|
||||
/// The algorithms supported for signing/verifying
|
||||
#[derive(Debug, PartialEq, Copy, Clone, Serialize, Deserialize)]
|
||||
pub enum Algorithm {
|
||||
/// HMAC using SHA-256
|
||||
HS256,
|
||||
/// HMAC using SHA-384
|
||||
HS384,
|
||||
/// HMAC using SHA-512
|
||||
HS512,
|
||||
|
||||
/// RSASSA-PKCS1-v1_5 using SHA-256
|
||||
RS256,
|
||||
/// RSASSA-PKCS1-v1_5 using SHA-384
|
||||
RS384,
|
||||
/// RSASSA-PKCS1-v1_5 using SHA-512
|
||||
RS512,
|
||||
}
|
||||
|
||||
/// The actual HS signing + encoding
|
||||
fn sign_hmac(alg: &'static digest::Algorithm, key: &[u8], signing_input: &str) -> Result<String> {
|
||||
let signing_key = hmac::SigningKey::new(alg, key);
|
||||
let digest = hmac::sign(&signing_key, signing_input.as_bytes());
|
||||
|
||||
Ok(
|
||||
base64::encode_config::<hmac::Signature>(&digest, base64::URL_SAFE_NO_PAD)
|
||||
)
|
||||
}
|
||||
|
||||
/// The actual RSA signing + encoding
|
||||
/// Taken from Ring doc https://briansmith.org/rustdoc/ring/signature/index.html
|
||||
fn sign_rsa(alg: Algorithm, key: &[u8], signing_input: &str) -> Result<String> {
|
||||
let ring_alg = match alg {
|
||||
Algorithm::RS256 => &signature::RSA_PKCS1_SHA256,
|
||||
Algorithm::RS384 => &signature::RSA_PKCS1_SHA384,
|
||||
Algorithm::RS512 => &signature::RSA_PKCS1_SHA512,
|
||||
_ => unreachable!(),
|
||||
};
|
||||
|
||||
let key_pair = Arc::new(
|
||||
signature::RSAKeyPair::from_der(untrusted::Input::from(key))
|
||||
.map_err(|_| ErrorKind::InvalidKey)?
|
||||
);
|
||||
let mut signing_state = signature::RSASigningState::new(key_pair)
|
||||
.map_err(|_| ErrorKind::InvalidKey)?;
|
||||
let mut signature = vec![0; signing_state.key_pair().public_modulus_len()];
|
||||
let rng = rand::SystemRandom::new();
|
||||
signing_state.sign(ring_alg, &rng, signing_input.as_bytes(), &mut signature)
|
||||
.map_err(|_| ErrorKind::InvalidKey)?;
|
||||
|
||||
Ok(
|
||||
base64::encode_config::<[u8]>(&signature, base64::URL_SAFE_NO_PAD)
|
||||
)
|
||||
}
|
||||
|
||||
/// Take the payload of a JWT, sign it using the algorithm given and return
|
||||
/// the base64 url safe encoded of the result.
|
||||
///
|
||||
/// Only use this function if you want to do something other than JWT.
|
||||
pub fn sign(signing_input: &str, key: &[u8], algorithm: Algorithm) -> Result<String> {
|
||||
match algorithm {
|
||||
Algorithm::HS256 => sign_hmac(&digest::SHA256, key, signing_input),
|
||||
Algorithm::HS384 => sign_hmac(&digest::SHA384, key, signing_input),
|
||||
Algorithm::HS512 => sign_hmac(&digest::SHA512, key, signing_input),
|
||||
|
||||
Algorithm::RS256 | Algorithm::RS384 | Algorithm::RS512 => sign_rsa(algorithm, key, signing_input),
|
||||
// TODO: if PKCS1 is made prublic, remove the line above and uncomment below
|
||||
// Algorithm::RS256 => sign_rsa(&signature::RSA_PKCS1_SHA256, key, signing_input),
|
||||
// Algorithm::RS384 => sign_rsa(&signature::RSA_PKCS1_SHA384, key, signing_input),
|
||||
// Algorithm::RS512 => sign_rsa(&signature::RSA_PKCS1_SHA512, key, signing_input),
|
||||
}
|
||||
}
|
||||
|
||||
/// See Ring RSA docs for more details
|
||||
fn verify_rsa(alg: &signature::RSAParameters, signature: &str, signing_input: &str, key: &[u8]) -> Result<bool> {
|
||||
let signature_bytes = base64::decode_config(signature, base64::URL_SAFE_NO_PAD)?;
|
||||
let public_key_der = untrusted::Input::from(key);
|
||||
let message = untrusted::Input::from(signing_input.as_bytes());
|
||||
let expected_signature = untrusted::Input::from(signature_bytes.as_slice());
|
||||
|
||||
let res = signature::verify(alg, public_key_der, message, expected_signature);
|
||||
|
||||
Ok(res.is_ok())
|
||||
}
|
||||
|
||||
/// Compares the signature given with a re-computed signature for HMAC or using the public key
|
||||
/// for RSA.
|
||||
///
|
||||
/// Only use this function if you want to do something other than JWT.
|
||||
///
|
||||
/// `signature` is the signature part of a jwt (text after the second '.')
|
||||
///
|
||||
/// `signing_input` is base64(header) + "." + base64(claims)
|
||||
pub fn verify(signature: &str, signing_input: &str, key: &[u8], algorithm: Algorithm) -> Result<bool> {
|
||||
match algorithm {
|
||||
Algorithm::HS256 | Algorithm::HS384 | Algorithm::HS512 => {
|
||||
// we just re-sign the data with the key and compare if they are equal
|
||||
let signed = sign(signing_input, key, algorithm)?;
|
||||
Ok(verify_slices_are_equal(signature.as_ref(), signed.as_ref()).is_ok())
|
||||
},
|
||||
Algorithm::RS256 => verify_rsa(&signature::RSA_PKCS1_2048_8192_SHA256, signature, signing_input, key),
|
||||
Algorithm::RS384 => verify_rsa(&signature::RSA_PKCS1_2048_8192_SHA384, signature, signing_input, key),
|
||||
Algorithm::RS512 => verify_rsa(&signature::RSA_PKCS1_2048_8192_SHA512, signature, signing_input, key),
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Algorithm {
|
||||
fn default() -> Self {
|
||||
Algorithm::HS256
|
||||
}
|
||||
}
|
68
libs/jsonwebtoken/src/errors.rs
Normal file
68
libs/jsonwebtoken/src/errors.rs
Normal file
@ -0,0 +1,68 @@
|
||||
use base64;
|
||||
use serde_json;
|
||||
use ring;
|
||||
|
||||
error_chain! {
|
||||
errors {
|
||||
/// When a token doesn't have a valid JWT shape
|
||||
InvalidToken {
|
||||
description("invalid token")
|
||||
display("Invalid token")
|
||||
}
|
||||
/// When the signature doesn't match
|
||||
InvalidSignature {
|
||||
description("invalid signature")
|
||||
display("Invalid signature")
|
||||
}
|
||||
/// When the secret given is not a valid RSA key
|
||||
InvalidKey {
|
||||
description("invalid key")
|
||||
display("Invalid Key")
|
||||
}
|
||||
|
||||
// Validation error
|
||||
|
||||
/// When a token’s `exp` claim indicates that it has expired
|
||||
ExpiredSignature {
|
||||
description("expired signature")
|
||||
display("Expired Signature")
|
||||
}
|
||||
/// When a token’s `iss` claim does not match the expected issuer
|
||||
InvalidIssuer {
|
||||
description("invalid issuer")
|
||||
display("Invalid Issuer")
|
||||
}
|
||||
/// When a token’s `aud` claim does not match one of the expected audience values
|
||||
InvalidAudience {
|
||||
description("invalid audience")
|
||||
display("Invalid Audience")
|
||||
}
|
||||
/// When a token’s `aud` claim does not match one of the expected audience values
|
||||
InvalidSubject {
|
||||
description("invalid subject")
|
||||
display("Invalid Subject")
|
||||
}
|
||||
/// When a token’s `iat` claim is in the future
|
||||
InvalidIssuedAt {
|
||||
description("invalid issued at")
|
||||
display("Invalid Issued At")
|
||||
}
|
||||
/// When a token’s nbf claim represents a time in the future
|
||||
ImmatureSignature {
|
||||
description("immature signature")
|
||||
display("Immature Signature")
|
||||
}
|
||||
/// When the algorithm in the header doesn't match the one passed to `decode`
|
||||
InvalidAlgorithm {
|
||||
description("Invalid algorithm")
|
||||
display("Invalid Algorithm")
|
||||
}
|
||||
}
|
||||
|
||||
foreign_links {
|
||||
Unspecified(ring::error::Unspecified) #[doc = "An error happened while signing/verifying a token with RSA"];
|
||||
Base64(base64::DecodeError) #[doc = "An error happened while decoding some base64 text"];
|
||||
Json(serde_json::Error) #[doc = "An error happened while serializing/deserializing JSON"];
|
||||
Utf8(::std::string::FromUtf8Error) #[doc = "An error happened while trying to convert the result of base64 decoding to a String"];
|
||||
}
|
||||
}
|
64
libs/jsonwebtoken/src/header.rs
Normal file
64
libs/jsonwebtoken/src/header.rs
Normal file
@ -0,0 +1,64 @@
|
||||
use crypto::Algorithm;
|
||||
|
||||
|
||||
/// A basic JWT header, the alg defaults to HS256 and typ is automatically
|
||||
/// set to `JWT`. All the other fields are optional.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Header {
|
||||
/// The type of JWS: it can only be "JWT" here
|
||||
///
|
||||
/// Defined in [RFC7515#4.1.9](https://tools.ietf.org/html/rfc7515#section-4.1.9).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub typ: Option<String>,
|
||||
/// The algorithm used
|
||||
///
|
||||
/// Defined in [RFC7515#4.1.1](https://tools.ietf.org/html/rfc7515#section-4.1.1).
|
||||
pub alg: Algorithm,
|
||||
/// Content type
|
||||
///
|
||||
/// Defined in [RFC7519#5.2](https://tools.ietf.org/html/rfc7519#section-5.2).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cty: Option<String>,
|
||||
/// JSON Key URL
|
||||
///
|
||||
/// Defined in [RFC7515#4.1.2](https://tools.ietf.org/html/rfc7515#section-4.1.2).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub jku: Option<String>,
|
||||
/// Key ID
|
||||
///
|
||||
/// Defined in [RFC7515#4.1.4](https://tools.ietf.org/html/rfc7515#section-4.1.4).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub kid: Option<String>,
|
||||
/// X.509 URL
|
||||
///
|
||||
/// Defined in [RFC7515#4.1.5](https://tools.ietf.org/html/rfc7515#section-4.1.5).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub x5u: Option<String>,
|
||||
/// X.509 certificate thumbprint
|
||||
///
|
||||
/// Defined in [RFC7515#4.1.7](https://tools.ietf.org/html/rfc7515#section-4.1.7).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub x5t: Option<String>,
|
||||
}
|
||||
|
||||
impl Header {
|
||||
/// Returns a JWT header with the algorithm given
|
||||
pub fn new(algorithm: Algorithm) -> Header {
|
||||
Header {
|
||||
typ: Some("JWT".to_string()),
|
||||
alg: algorithm,
|
||||
cty: None,
|
||||
jku: None,
|
||||
kid: None,
|
||||
x5u: None,
|
||||
x5t: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Header {
|
||||
/// Returns a JWT header using the default Algorithm, HS256
|
||||
fn default() -> Self {
|
||||
Header::new(Algorithm::default())
|
||||
}
|
||||
}
|
140
libs/jsonwebtoken/src/lib.rs
Normal file
140
libs/jsonwebtoken/src/lib.rs
Normal file
@ -0,0 +1,140 @@
|
||||
//! Create and parses JWT (JSON Web Tokens)
|
||||
//!
|
||||
//! Documentation: [stable](https://docs.rs/jsonwebtoken/)
|
||||
#![recursion_limit = "300"]
|
||||
#![deny(missing_docs)]
|
||||
|
||||
#[macro_use]
|
||||
extern crate error_chain;
|
||||
#[macro_use]
|
||||
extern crate serde_derive;
|
||||
extern crate serde_json;
|
||||
extern crate serde;
|
||||
extern crate base64;
|
||||
extern crate ring;
|
||||
extern crate untrusted;
|
||||
extern crate chrono;
|
||||
|
||||
/// All the errors, generated using error-chain
|
||||
pub mod errors;
|
||||
mod header;
|
||||
mod crypto;
|
||||
mod serialization;
|
||||
mod validation;
|
||||
|
||||
pub use header::Header;
|
||||
pub use crypto::{
|
||||
Algorithm,
|
||||
sign,
|
||||
verify,
|
||||
};
|
||||
pub use validation::Validation;
|
||||
pub use serialization::TokenData;
|
||||
|
||||
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::ser::Serialize;
|
||||
|
||||
use errors::{Result, ErrorKind};
|
||||
use serialization::{from_jwt_part, from_jwt_part_claims, to_jwt_part};
|
||||
use validation::{validate};
|
||||
|
||||
|
||||
/// Encode the header and claims given and sign the payload using the algorithm from the header and the key
|
||||
///
|
||||
/// ```rust,ignore
|
||||
/// #[macro_use]
|
||||
/// extern crate serde_derive;
|
||||
/// use jsonwebtoken::{encode, Algorithm, Header};
|
||||
///
|
||||
/// /// #[derive(Debug, Serialize, Deserialize)]
|
||||
/// struct Claims {
|
||||
/// sub: String,
|
||||
/// company: String
|
||||
/// }
|
||||
///
|
||||
/// let my_claims = Claims {
|
||||
/// sub: "b@b.com".to_owned(),
|
||||
/// company: "ACME".to_owned()
|
||||
/// };
|
||||
///
|
||||
/// // my_claims is a struct that implements Serialize
|
||||
/// // This will create a JWT using HS256 as algorithm
|
||||
/// let token = encode(&Header::default(), &my_claims, "secret".as_ref()).unwrap();
|
||||
/// ```
|
||||
pub fn encode<T: Serialize>(header: &Header, claims: &T, key: &[u8]) -> Result<String> {
|
||||
let encoded_header = to_jwt_part(&header)?;
|
||||
let encoded_claims = to_jwt_part(&claims)?;
|
||||
let signing_input = [encoded_header.as_ref(), encoded_claims.as_ref()].join(".");
|
||||
let signature = sign(&*signing_input, key.as_ref(), header.alg)?;
|
||||
|
||||
Ok([signing_input, signature].join("."))
|
||||
}
|
||||
|
||||
/// Used in decode: takes the result of a rsplit and ensure we only get 2 parts
|
||||
/// Errors if we don't
|
||||
macro_rules! expect_two {
|
||||
($iter:expr) => {{
|
||||
let mut i = $iter;
|
||||
match (i.next(), i.next(), i.next()) {
|
||||
(Some(first), Some(second), None) => (first, second),
|
||||
_ => return Err(ErrorKind::InvalidToken.into())
|
||||
}
|
||||
}}
|
||||
}
|
||||
|
||||
/// Decode a token into a struct containing 2 fields: `claims` and `header`.
|
||||
///
|
||||
/// If the token or its signature is invalid or the claims fail validation, it will return an error.
|
||||
///
|
||||
/// ```rust,ignore
|
||||
/// #[macro_use]
|
||||
/// extern crate serde_derive;
|
||||
/// use jsonwebtoken::{decode, Validation, Algorithm};
|
||||
///
|
||||
/// #[derive(Debug, Serialize, Deserialize)]
|
||||
/// struct Claims {
|
||||
/// sub: String,
|
||||
/// company: String
|
||||
/// }
|
||||
///
|
||||
/// let token = "a.jwt.token".to_string();
|
||||
/// // Claims is a struct that implements Deserialize
|
||||
/// let token_data = decode::<Claims>(&token, "secret", &Validation::new(Algorithm::HS256));
|
||||
/// ```
|
||||
pub fn decode<T: DeserializeOwned>(token: &str, key: &[u8], validation: &Validation) -> Result<TokenData<T>> {
|
||||
let (signature, signing_input) = expect_two!(token.rsplitn(2, '.'));
|
||||
let (claims, header) = expect_two!(signing_input.rsplitn(2, '.'));
|
||||
let header: Header = from_jwt_part(header)?;
|
||||
|
||||
if !verify(signature, signing_input, key, header.alg)? {
|
||||
return Err(ErrorKind::InvalidSignature.into());
|
||||
}
|
||||
|
||||
if !validation.algorithms.contains(&header.alg) {
|
||||
return Err(ErrorKind::InvalidAlgorithm.into());
|
||||
}
|
||||
|
||||
let (decoded_claims, claims_map): (T, _) = from_jwt_part_claims(claims)?;
|
||||
|
||||
validate(&claims_map, validation)?;
|
||||
|
||||
Ok(TokenData { header: header, claims: decoded_claims })
|
||||
}
|
||||
|
||||
/// Decode a token and return the Header. This is not doing any kind of validation: it is meant to be
|
||||
/// used when you don't know which `alg` the token is using and want to find out.
|
||||
///
|
||||
/// If the token has an invalid format, it will return an error.
|
||||
///
|
||||
/// ```rust,ignore
|
||||
/// use jsonwebtoken::decode_header;
|
||||
///
|
||||
/// let token = "a.jwt.token".to_string();
|
||||
/// let header = decode_header(&token);
|
||||
/// ```
|
||||
pub fn decode_header(token: &str) -> Result<Header> {
|
||||
let (_, signing_input) = expect_two!(token.rsplitn(2, '.'));
|
||||
let (_, header) = expect_two!(signing_input.rsplitn(2, '.'));
|
||||
from_jwt_part(header)
|
||||
}
|
42
libs/jsonwebtoken/src/serialization.rs
Normal file
42
libs/jsonwebtoken/src/serialization.rs
Normal file
@ -0,0 +1,42 @@
|
||||
use base64;
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::ser::Serialize;
|
||||
use serde_json::{from_str, to_string, Value};
|
||||
use serde_json::map::Map;
|
||||
|
||||
use errors::{Result};
|
||||
use header::Header;
|
||||
|
||||
|
||||
/// The return type of a successful call to decode
|
||||
#[derive(Debug)]
|
||||
pub struct TokenData<T> {
|
||||
/// The decoded JWT header
|
||||
pub header: Header,
|
||||
/// The decoded JWT claims
|
||||
pub claims: T
|
||||
}
|
||||
|
||||
/// Serializes to JSON and encodes to base64
|
||||
pub fn to_jwt_part<T: Serialize>(input: &T) -> Result<String> {
|
||||
let encoded = to_string(input)?;
|
||||
Ok(base64::encode_config(encoded.as_bytes(), base64::URL_SAFE_NO_PAD))
|
||||
}
|
||||
|
||||
/// Decodes from base64 and deserializes from JSON to a struct
|
||||
pub fn from_jwt_part<B: AsRef<str>, T: DeserializeOwned>(encoded: B) -> Result<T> {
|
||||
let decoded = base64::decode_config(encoded.as_ref(), base64::URL_SAFE_NO_PAD)?;
|
||||
let s = String::from_utf8(decoded)?;
|
||||
|
||||
Ok(from_str(&s)?)
|
||||
}
|
||||
|
||||
/// Decodes from base64 and deserializes from JSON to a struct AND a hashmap
|
||||
pub fn from_jwt_part_claims<B: AsRef<str>, T: DeserializeOwned>(encoded: B) -> Result<(T, Map<String, Value>)> {
|
||||
let decoded = base64::decode_config(encoded.as_ref(), base64::URL_SAFE_NO_PAD)?;
|
||||
let s = String::from_utf8(decoded)?;
|
||||
|
||||
let claims: T = from_str(&s)?;
|
||||
let map: Map<_,_> = from_str(&s)?;
|
||||
Ok((claims, map))
|
||||
}
|
377
libs/jsonwebtoken/src/validation.rs
Normal file
377
libs/jsonwebtoken/src/validation.rs
Normal file
File diff suppressed because it is too large
Load Diff
7
migrations/2018-01-14-171611_create_tables/down.sql
Normal file
7
migrations/2018-01-14-171611_create_tables/down.sql
Normal file
@ -0,0 +1,7 @@
|
||||
DROP TABLE users;
|
||||
|
||||
DROP TABLE devices;
|
||||
|
||||
DROP TABLE ciphers;
|
||||
|
||||
DROP TABLE folders;
|
50
migrations/2018-01-14-171611_create_tables/up.sql
Normal file
50
migrations/2018-01-14-171611_create_tables/up.sql
Normal file
@ -0,0 +1,50 @@
|
||||
CREATE TABLE users (
|
||||
uuid TEXT NOT NULL PRIMARY KEY,
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
password_hash BLOB NOT NULL,
|
||||
salt BLOB NOT NULL,
|
||||
password_iterations INTEGER NOT NULL,
|
||||
password_hint TEXT,
|
||||
key TEXT NOT NULL,
|
||||
private_key TEXT,
|
||||
public_key TEXT,
|
||||
totp_secret TEXT,
|
||||
totp_recover TEXT,
|
||||
security_stamp TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE devices (
|
||||
uuid TEXT NOT NULL PRIMARY KEY,
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL,
|
||||
user_uuid TEXT NOT NULL REFERENCES users (uuid),
|
||||
name TEXT NOT NULL,
|
||||
type INTEGER NOT NULL,
|
||||
push_token TEXT UNIQUE,
|
||||
refresh_token TEXT UNIQUE NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE ciphers (
|
||||
uuid TEXT NOT NULL PRIMARY KEY,
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL,
|
||||
user_uuid TEXT NOT NULL REFERENCES users (uuid),
|
||||
folder_uuid TEXT REFERENCES folders (uuid),
|
||||
organization_uuid TEXT,
|
||||
type INTEGER NOT NULL,
|
||||
data TEXT NOT NULL,
|
||||
favorite BOOLEAN NOT NULL,
|
||||
attachments BLOB
|
||||
);
|
||||
|
||||
CREATE TABLE folders (
|
||||
uuid TEXT NOT NULL PRIMARY KEY,
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL,
|
||||
user_uuid TEXT NOT NULL REFERENCES users (uuid),
|
||||
name TEXT NOT NULL
|
||||
);
|
||||
|
149
src/api/core/accounts.rs
Normal file
149
src/api/core/accounts.rs
Normal 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
251
src/api/core/ciphers.rs
Normal 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
102
src/api/core/folders.rs
Normal 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
100
src/api/core/mod.rs
Normal 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
131
src/api/core/two_factor.rs
Normal 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
85
src/api/icons.rs
Normal 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
225
src/api/identity.rs
Normal 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
9
src/api/mod.rs
Normal 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
43
src/api/web.rs
Normal 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
164
src/auth.rs
Normal 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
168
src/bin/proxy.rs
Normal 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
36
src/crypto.rs
Normal 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
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user