From f2445ee8e534bc4b5823a66205568a957df96eb9 Mon Sep 17 00:00:00 2001 From: Thomas Eizinger Date: Mon, 20 Sep 2021 16:00:40 +1000 Subject: [PATCH 1/5] Reformat impl of `derive_extended_priv_key` --- daemon/src/seed.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/daemon/src/seed.rs b/daemon/src/seed.rs index a79c17d..1c70e31 100644 --- a/daemon/src/seed.rs +++ b/daemon/src/seed.rs @@ -52,12 +52,13 @@ impl Seed { } pub fn derive_extended_priv_key(&self, network: Network) -> Result { - let h = Hkdf::::new(None, &self.0); - let mut okm = [0u8; 64]; - h.expand(b"BITCOIN_WALLET_SEED", &mut okm) + let mut ext_priv_key_seed = [0u8; 64]; + + Hkdf::::new(None, &self.0) + .expand(b"BITCOIN_WALLET_SEED", &mut ext_priv_key_seed) .expect("okm array is of correct length"); - let ext_priv_key = ExtendedPrivKey::new_master(network, &okm)?; + let ext_priv_key = ExtendedPrivKey::new_master(network, &ext_priv_key_seed)?; Ok(ext_priv_key) } From 3cd28e92e02f8e6c84363ccdccab1dbfedd716aa Mon Sep 17 00:00:00 2001 From: Thomas Eizinger Date: Mon, 20 Sep 2021 18:27:20 +1000 Subject: [PATCH 2/5] Remove dead code --- daemon/src/routes_maker.rs | 3 --- 1 file changed, 3 deletions(-) diff --git a/daemon/src/routes_maker.rs b/daemon/src/routes_maker.rs index 9075fe7..0c6407f 100644 --- a/daemon/src/routes_maker.rs +++ b/daemon/src/routes_maker.rs @@ -108,9 +108,6 @@ pub async fn post_sell_order( #[rocket::get("/alive")] pub fn get_health_check() {} -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -struct RetrieveCurrentOrder; - #[derive(RustEmbed)] #[folder = "../frontend/dist/maker"] struct Asset; From 47c2fee8c631dcf9fd67559b774b777b9f34330c Mon Sep 17 00:00:00 2001 From: Thomas Eizinger Date: Mon, 20 Sep 2021 19:15:38 +1000 Subject: [PATCH 3/5] Replace axios with fetch No need for a dependency for simple HTTP requests. --- frontend/package.json | 1 - frontend/src/Maker.tsx | 3 +-- frontend/src/Taker.tsx | 7 +++---- frontend/yarn.lock | 9 +-------- 4 files changed, 5 insertions(+), 15 deletions(-) diff --git a/frontend/package.json b/frontend/package.json index d0d5671..c992357 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -24,7 +24,6 @@ "@types/react-table": "^7.7.2", "@typescript-eslint/eslint-plugin": "^4.30.0", "@typescript-eslint/parser": "^4.30.0", - "axios": "^0.21.1", "babel-eslint": "^10.1.0", "eslint": "^7.32.0", "eslint-config-react-app": "^6.0.0", diff --git a/frontend/src/Maker.tsx b/frontend/src/Maker.tsx index d9694c4..544199e 100644 --- a/frontend/src/Maker.tsx +++ b/frontend/src/Maker.tsx @@ -11,7 +11,6 @@ import { useToast, VStack, } from "@chakra-ui/react"; -import axios from "axios"; import React, { useState } from "react"; import { useAsync } from "react-async"; import { Route, Routes } from "react-router-dom"; @@ -32,7 +31,7 @@ interface CfdSellOrderPayload { } async function postCfdSellOrderRequest(payload: CfdSellOrderPayload) { - let res = await axios.post(`/api/order/sell`, JSON.stringify(payload)); + let res = await fetch(`/api/order/sell`, { method: "POST", body: JSON.stringify(payload) }); if (!res.status.toString().startsWith("2")) { console.log("Status: " + res.status + ", " + res.statusText); diff --git a/frontend/src/Taker.tsx b/frontend/src/Taker.tsx index b01beab..545c17a 100644 --- a/frontend/src/Taker.tsx +++ b/frontend/src/Taker.tsx @@ -1,5 +1,4 @@ import { Box, Button, Center, Flex, HStack, SimpleGrid, StackDivider, Text, useToast, VStack } from "@chakra-ui/react"; -import axios from "axios"; import React, { useState } from "react"; import { useAsync } from "react-async"; import { Route, Routes } from "react-router-dom"; @@ -28,7 +27,7 @@ interface MarginResponse { } async function postCfdTakeRequest(payload: CfdTakeRequestPayload) { - let res = await axios.post(`/api/cfd`, JSON.stringify(payload)); + let res = await fetch(`/api/cfd`, { method: "POST", body: JSON.stringify(payload) }); if (!res.status.toString().startsWith("2")) { throw new Error("failed to create new CFD take request: " + res.status + ", " + res.statusText); @@ -36,13 +35,13 @@ async function postCfdTakeRequest(payload: CfdTakeRequestPayload) { } async function getMargin(payload: MarginRequestPayload): Promise { - let res = await axios.post(`/api/calculate/margin`, JSON.stringify(payload)); + let res = await fetch(`/api/calculate/margin`, { method: "POST", body: JSON.stringify(payload) }); if (!res.status.toString().startsWith("2")) { throw new Error("failed to create new CFD take request: " + res.status + ", " + res.statusText); } - return res.data; + return res.json(); } export default function App() { diff --git a/frontend/yarn.lock b/frontend/yarn.lock index d7080b4..65989a8 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -3562,13 +3562,6 @@ axe-core@^4.0.2: resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.3.3.tgz#b55cd8e8ddf659fe89b064680e1c6a4dceab0325" integrity sha512-/lqqLAmuIPi79WYfRpy2i8z+x+vxU3zX2uAm0gs1q52qTuKwolOj1P8XbufpXcsydrpKx2yGn2wzAnxCMV86QA== -axios@^0.21.1: - version "0.21.4" - resolved "https://registry.yarnpkg.com/axios/-/axios-0.21.4.tgz#c67b90dc0568e5c1cf2b0b858c43ba28e2eda575" - integrity sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg== - dependencies: - follow-redirects "^1.14.0" - axobject-query@^2.2.0: version "2.2.0" resolved "https://registry.yarnpkg.com/axobject-query/-/axobject-query-2.2.0.tgz#943d47e10c0b704aa42275e20edf3722648989be" @@ -6214,7 +6207,7 @@ focus-lock@^0.8.1: dependencies: tslib "^1.9.3" -follow-redirects@^1.0.0, follow-redirects@^1.14.0: +follow-redirects@^1.0.0: version "1.14.3" resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.3.tgz#6ada78118d8d24caee595595accdc0ac6abd022e" integrity sha512-3MkHxknWMUtb23apkgz/83fDoe+y+qr0TdgacGIA7bew+QLBo3vdgEN2xEsuXNivpFy4CyDhBBZnNZOtalmenw== From dda021b37c3ce6c825bae109b413888533973544 Mon Sep 17 00:00:00 2001 From: Thomas Eizinger Date: Mon, 20 Sep 2021 18:55:15 +1000 Subject: [PATCH 4/5] Password protect the maker's routes --- Cargo.lock | 12 ++++ Cargo.toml | 3 + daemon/Cargo.toml | 4 +- daemon/src/auth.rs | 84 ++++++++++++++++++++++++++ daemon/src/maker.rs | 8 +++ daemon/src/routes_maker.rs | 117 ++++++++++++++++++++++++++++++++++++- daemon/src/seed.rs | 11 ++++ 7 files changed, 235 insertions(+), 4 deletions(-) create mode 100644 daemon/src/auth.rs diff --git a/Cargo.lock b/Cargo.lock index 7cf43e4..453ee62 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -423,9 +423,11 @@ dependencies = [ "cfd_protocol", "clap", "futures", + "hex", "hkdf", "rand 0.6.5", "rocket", + "rocket-basicauth", "rocket_db_pools", "rust-embed", "rust_decimal", @@ -1659,6 +1661,16 @@ dependencies = [ "yansi", ] +[[package]] +name = "rocket-basicauth" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79128c0f55b7bc6785c13816d71af4baee156bd615b09468800edaaa7da56a08" +dependencies = [ + "base64 0.13.0", + "rocket", +] + [[package]] name = "rocket_codegen" version = "0.5.0-rc.1" diff --git a/Cargo.toml b/Cargo.toml index 62bbe01..1f18ee5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,3 +1,6 @@ [workspace] members = ["cfd_protocol", "daemon"] resolver = "2" + +[patch.crates-io] +rocket = { git = "https://github.com/SergioBenitez/Rocket" } # Need to patch rocket dependency of `rocket_basicauth` until there is an official release. diff --git a/daemon/Cargo.toml b/daemon/Cargo.toml index eb8be33..25e1e20 100644 --- a/daemon/Cargo.toml +++ b/daemon/Cargo.toml @@ -9,9 +9,11 @@ bdk = { git = "https://github.com/bitcoindevkit/bdk/" } cfd_protocol = { path = "../cfd_protocol" } clap = "3.0.0-beta.4" futures = { version = "0.3", default-features = false } +hex = "0.4" hkdf = "0.11" rand = "0.6" -rocket = { git = "https://github.com/SergioBenitez/Rocket", features = ["json"] } +rocket = { version = "0.5.0-rc.1", features = ["json"] } +rocket-basicauth = { version = "2", default-features = false } rocket_db_pools = { git = "https://github.com/SergioBenitez/Rocket", features = ["sqlx_sqlite"] } rust-embed = "6.2" rust_decimal = { version = "1.16", features = ["serde-float", "serde-arbitrary-precision"] } diff --git a/daemon/src/auth.rs b/daemon/src/auth.rs new file mode 100644 index 0000000..3dca757 --- /dev/null +++ b/daemon/src/auth.rs @@ -0,0 +1,84 @@ +use hex::FromHexError; +use rocket::http::Status; +use rocket::outcome::{try_outcome, IntoOutcome}; +use rocket::request::{FromRequest, Outcome}; +use rocket::{Request, State}; +use rocket_basicauth::{BasicAuth, BasicAuthError}; +use std::fmt; +use std::str::FromStr; + +/// A request guard that can be included in handler definitions to enforce authentication. +pub struct Authenticated {} + +#[derive(Debug)] +pub enum Error { + UnknownUser(String), + BadPassword, + InvalidEncoding(FromHexError), + BadBasicAuthHeader(BasicAuthError), + /// The auth password was not configured in Rocket's state. + MissingPassword, + NoAuthHeader, +} + +#[derive(PartialEq)] +pub struct Password([u8; 32]); + +impl From<[u8; 32]> for Password { + fn from(bytes: [u8; 32]) -> Self { + Self(bytes) + } +} + +impl fmt::Display for Password { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", hex::encode(self.0)) + } +} + +impl FromStr for Password { + type Err = FromHexError; + + fn from_str(s: &str) -> Result { + let mut bytes = [0u8; 32]; + hex::decode_to_slice(s, &mut bytes)?; + + Ok(Self(bytes)) + } +} + +#[rocket::async_trait] +impl<'r> FromRequest<'r> for Authenticated { + type Error = Error; + + async fn from_request(req: &'r Request<'_>) -> Outcome { + let basic_auth = try_outcome!(req + .guard::() + .await + .map_failure(|(status, error)| (status, Error::BadBasicAuthHeader(error))) + .forward_then(|()| Outcome::Failure((Status::Unauthorized, Error::NoAuthHeader)))); + let password = try_outcome!(req + .guard::<&'r State>() + .await + .map_failure(|(status, _)| (status, Error::MissingPassword))); + + if basic_auth.username != "maker" { + return Outcome::Failure(( + Status::Unauthorized, + Error::UnknownUser(basic_auth.username), + )); + } + + if &try_outcome!(basic_auth + .password + .parse::() + .map_err(Error::InvalidEncoding) + .into_outcome(Status::BadRequest)) + != password.inner() + { + return Outcome::Failure((Status::Unauthorized, Error::BadPassword)); + } + + Outcome::Success(Authenticated {}) + } +} diff --git a/daemon/src/maker.rs b/daemon/src/maker.rs index 4fd6240..b555987 100644 --- a/daemon/src/maker.rs +++ b/daemon/src/maker.rs @@ -13,6 +13,7 @@ use std::path::PathBuf; use std::time::Duration; use tokio::sync::{mpsc, watch}; +mod auth; mod db; mod keypair; mod maker_cfd_actor; @@ -78,6 +79,10 @@ async fn main() -> Result<()> { .await?; let wallet_info = wallet.sync().unwrap(); + let auth_password = seed.derive_auth_password::(); + + println!("Auth password: {}", auth_password); + let oracle = schnorrsig::KeyPair::new(SECP256K1, &mut rand::thread_rng()); // TODO: Fetch oracle public key from oracle. let (cfd_feed_sender, cfd_feed_receiver) = watch::channel::>(vec![]); @@ -97,6 +102,7 @@ async fn main() -> Result<()> { .manage(cfd_feed_receiver) .manage(order_feed_receiver) .manage(wallet_feed_receiver) + .manage(auth_password) .attach(Db::init()) .attach(AdHoc::try_on_ignite( "SQL migrations", @@ -163,10 +169,12 @@ async fn main() -> Result<()> { routes_maker::get_health_check ], ) + .register("/api", rocket::catchers![routes_maker::unauthorized]) .mount( "/", rocket::routes![routes_maker::dist, routes_maker::index], ) + .register("/", rocket::catchers![routes_maker::unauthorized]) .launch() .await?; diff --git a/daemon/src/routes_maker.rs b/daemon/src/routes_maker.rs index 0c6407f..79883e5 100644 --- a/daemon/src/routes_maker.rs +++ b/daemon/src/routes_maker.rs @@ -1,10 +1,11 @@ +use crate::auth::Authenticated; use crate::maker_cfd_actor; use crate::model::cfd::{Cfd, Order, Origin}; use crate::model::{Usd, WalletInfo}; use crate::routes::EmbeddedFileExt; use crate::to_sse_event::ToSseEvent; use anyhow::Result; -use rocket::http::{ContentType, Status}; +use rocket::http::{ContentType, Header, Status}; use rocket::response::stream::EventStream; use rocket::response::{status, Responder}; use rocket::serde::json::Json; @@ -21,6 +22,7 @@ pub async fn maker_feed( rx_cfds: &State>>, rx_order: &State>>, rx_wallet: &State>, + _auth: Authenticated, ) -> EventStream![] { let mut rx_cfds = rx_cfds.inner().clone(); let mut rx_order = rx_order.inner().clone(); @@ -70,6 +72,7 @@ pub struct CfdNewOrderRequest { pub async fn post_sell_order( order: Json, cfd_actor_inbox: &State>, + _auth: Authenticated, ) -> Result, status::BadRequest> { let order = Order::from_default_with_price(order.price, Origin::Ours) .map_err(|e| status::BadRequest(Some(e.to_string())))? @@ -83,6 +86,23 @@ pub async fn post_sell_order( Ok(status::Accepted(None)) } +/// A "catcher" for all 401 responses, triggers the browser's basic auth implementation. +#[rocket::catch(401)] +pub fn unauthorized() -> PromptAuthentication { + PromptAuthentication { + inner: (), + www_authenticate: Header::new("WWW-Authenticate", r#"Basic charset="UTF-8"#), + } +} + +/// A rocket responder that prompts the user to sign in to access the API. +#[derive(rocket::Responder)] +#[response(status = 401)] +pub struct PromptAuthentication { + inner: (), + www_authenticate: Header<'static>, +} + // // TODO: Shall we use a simpler struct for verification? AFAICT quantity is not // // needed, no need to send the whole CFD either as the other fields can be generated from the // order #[rocket::post("/order/confirm", data = "")] @@ -113,13 +133,104 @@ pub fn get_health_check() {} struct Asset; #[rocket::get("/assets/")] -pub fn dist<'r>(file: PathBuf) -> impl Responder<'r, 'static> { +pub fn dist<'r>(file: PathBuf, _auth: Authenticated) -> impl Responder<'r, 'static> { let filename = format!("assets/{}", file.display().to_string()); Asset::get(&filename).into_response(file) } #[rocket::get("/<_paths..>", format = "text/html")] -pub fn index<'r>(_paths: PathBuf) -> impl Responder<'r, 'static> { +pub fn index<'r>(_paths: PathBuf, _auth: Authenticated) -> impl Responder<'r, 'static> { let asset = Asset::get("index.html").ok_or(Status::NotFound)?; Ok::<(ContentType, Cow<[u8]>), Status>((ContentType::HTML, asset.data)) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::auth::Password; + use bdk::bitcoin::{Address, Amount, Network, PublicKey}; + use rocket::http::{Header, Status}; + use rocket::local::blocking::Client; + use rocket::{Build, Rocket}; + use std::time::SystemTime; + use tokio::sync::mpsc; + + #[test] + fn routes_are_password_protected() { + let client = Client::tracked(rocket()).unwrap(); + + let feed_response = client.get("/feed").dispatch(); + let new_sell_order_response = client + .post("/order/sell") + .body(r#"{"price":"40000", "min_quantity":"100", "max_quantity":"10000"}"#) + .dispatch(); + let index_response = client.get("/").header(ContentType::HTML).dispatch(); + + assert_eq!(feed_response.status(), Status::Unauthorized); + assert_eq!(new_sell_order_response.status(), Status::Unauthorized); + assert_eq!(index_response.status(), Status::Unauthorized); + } + + #[test] + fn correct_password_grants_access() { + let client = Client::tracked(rocket()).unwrap(); + + let feed_response = client.get("/feed").header(auth_header()).dispatch(); + let new_sell_order_response = client + .post("/order/sell") + .body(r#"{"price":"40000", "min_quantity":"100", "max_quantity":"10000"}"#) + .header(auth_header()) + .dispatch(); + let index_response = client + .get("/") + .header(ContentType::HTML) + .header(auth_header()) + .dispatch(); + + assert_eq!(feed_response.status(), Status::Ok); + assert_eq!(new_sell_order_response.status(), Status::Accepted); + assert_eq!(index_response.status(), Status::NotFound); // we don't embed the files in the + // tests + } + + /// Constructs a Rocket instance for testing. + fn rocket() -> Rocket { + let (_, state1) = watch::channel::>(vec![]); + let (_, state2) = watch::channel::>(None); + let (_, state3) = watch::channel::(WalletInfo { + balance: Amount::ZERO, + address: Address::p2wpkh( + &PublicKey::new( + "0286cd889349ebc06b3165505b9c083df0a4147f554614ff207c10f16ff509578c" + .parse() + .unwrap(), + ), + Network::Regtest, + ) + .unwrap(), + last_updated_at: SystemTime::now(), + }); + let (state4, actor) = mpsc::unbounded_channel::(); + std::mem::forget(actor); // pretend the actor is running so we can don't panic in the route handler + + rocket::build() + .manage(state1) + .manage(state2) + .manage(state3) + .manage(state4) + .manage(Password::from(*b"Now I'm feelin' so fly like a G6")) + .mount("/", rocket::routes![maker_feed, post_sell_order, index]) + } + + /// Creates an "Authorization" header that matches the password above, + /// in particular it has been created through: + /// ``` + /// base64(maker:hex("Now I'm feelin' so fly like a G6")) + /// ``` + fn auth_header() -> Header<'static> { + Header::new( + "Authorization", + "Basic bWFrZXI6NGU2Zjc3MjA0OTI3NmQyMDY2NjU2NTZjNjk2ZTI3MjA3MzZmMjA2NjZjNzkyMDZjNjk2YjY1MjA2MTIwNDczNg==", + ) + } +} diff --git a/daemon/src/seed.rs b/daemon/src/seed.rs index 1c70e31..e14787e 100644 --- a/daemon/src/seed.rs +++ b/daemon/src/seed.rs @@ -62,6 +62,17 @@ impl Seed { Ok(ext_priv_key) } + + #[allow(dead_code)] // Not used by all binaries. + pub fn derive_auth_password>(&self) -> P { + let mut password = [0u8; 32]; + + Hkdf::::new(None, &self.0) + .expand(b"HTTP_AUTH_PASSWORD", &mut password) + .expect("okm array is of correct length"); + + P::from(password) + } } impl Default for Seed { From c5c6f0df694b44ea89e32db70629b7712a70d810 Mon Sep 17 00:00:00 2001 From: Thomas Eizinger Date: Mon, 20 Sep 2021 20:00:25 +1000 Subject: [PATCH 5/5] Ensure credentials are passed along with API requests --- frontend/src/Maker.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/Maker.tsx b/frontend/src/Maker.tsx index 544199e..f737215 100644 --- a/frontend/src/Maker.tsx +++ b/frontend/src/Maker.tsx @@ -31,7 +31,7 @@ interface CfdSellOrderPayload { } async function postCfdSellOrderRequest(payload: CfdSellOrderPayload) { - let res = await fetch(`/api/order/sell`, { method: "POST", body: JSON.stringify(payload) }); + let res = await fetch(`/api/order/sell`, { method: "POST", body: JSON.stringify(payload), credentials: "include" }); if (!res.status.toString().startsWith("2")) { console.log("Status: " + res.status + ", " + res.statusText); @@ -40,7 +40,7 @@ async function postCfdSellOrderRequest(payload: CfdSellOrderPayload) { } export default function App() { - let source = useEventSource({ source: "/api/feed" }); + let source = useEventSource({ source: "/api/feed", options: { withCredentials: true } }); const cfds = useLatestEvent(source, "cfds"); const order = useLatestEvent(source, "order");