diff --git a/Cargo.lock b/Cargo.lock index 2f4c89b1703..b45ed61909d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6070,7 +6070,9 @@ dependencies = [ name = "zebra-rpc" version = "1.0.0-beta.40" dependencies = [ + "base64 0.22.1", "chrono", + "color-eyre", "futures", "hex", "indexmap 2.5.0", diff --git a/zebra-rpc/Cargo.toml b/zebra-rpc/Cargo.toml index 517c565574c..d3976e15e1e 100644 --- a/zebra-rpc/Cargo.toml +++ b/zebra-rpc/Cargo.toml @@ -33,7 +33,6 @@ indexer-rpcs = [ # Mining RPC support getblocktemplate-rpcs = [ - "rand", "zcash_address", "zebra-consensus/getblocktemplate-rpcs", "zebra-state/getblocktemplate-rpcs", @@ -68,6 +67,13 @@ jsonrpc-http-server = "18.0.0" serde_json = { version = "1.0.128", features = ["preserve_order"] } indexmap = { version = "2.5.0", features = ["serde"] } +# RPC endpoint basic auth +base64 = "0.22.1" +rand = "0.8.5" + +# Error handling +color-eyre = "0.6.3" + tokio = { version = "1.40.0", features = [ "time", "rt-multi-thread", @@ -92,8 +98,6 @@ nix = { version = "0.29.0", features = ["signal"] } zcash_primitives = { workspace = true, features = ["transparent-inputs"] } -# Experimental feature getblocktemplate-rpcs -rand = { version = "0.8.5", optional = true } # ECC deps used by getblocktemplate-rpcs feature zcash_address = { workspace = true, optional = true} diff --git a/zebra-rpc/qa/base_config.toml b/zebra-rpc/qa/base_config.toml index c0cc5391f4b..58bf9b6b6f6 100644 --- a/zebra-rpc/qa/base_config.toml +++ b/zebra-rpc/qa/base_config.toml @@ -7,6 +7,7 @@ network = "Regtest" [rpc] listen_addr = "127.0.0.1:0" +enable_cookie_auth = false [state] cache_dir = "" diff --git a/zebra-rpc/src/config.rs b/zebra-rpc/src/config.rs index 8dc675b2034..57187163e55 100644 --- a/zebra-rpc/src/config.rs +++ b/zebra-rpc/src/config.rs @@ -1,9 +1,11 @@ //! User-configurable RPC settings. -use std::net::SocketAddr; +use std::{net::SocketAddr, path::PathBuf}; use serde::{Deserialize, Serialize}; +use zebra_chain::common::default_cache_dir; + pub mod mining; /// RPC configuration section. @@ -71,6 +73,12 @@ pub struct Config { /// Test-only option that makes Zebra say it is at the chain tip, /// no matter what the estimated height or local clock is. pub debug_force_finished_sync: bool, + + /// The directory where Zebra stores RPC cookies. + pub cookie_dir: PathBuf, + + /// Enable cookie-based authentication for RPCs. + pub enable_cookie_auth: bool, } // This impl isn't derivable because it depends on features. @@ -94,6 +102,12 @@ impl Default for Config { // Debug options are always off by default. debug_force_finished_sync: false, + + // Use the default cache dir for the auth cookie. + cookie_dir: default_cache_dir(), + + // Enable cookie-based authentication by default. + enable_cookie_auth: true, } } } diff --git a/zebra-rpc/src/server.rs b/zebra-rpc/src/server.rs index b87068ef8f0..73fcde65f6b 100644 --- a/zebra-rpc/src/server.rs +++ b/zebra-rpc/src/server.rs @@ -9,6 +9,8 @@ use std::{fmt, panic, thread::available_parallelism}; +use cookie::Cookie; +use http_request_compatibility::With; use jsonrpc_core::{Compatibility, MetaIoHandler}; use jsonrpc_http_server::{CloseHandle, ServerBuilder}; use tokio::task::JoinHandle; @@ -25,7 +27,7 @@ use crate::{ config::Config, methods::{Rpc, RpcImpl}, server::{ - http_request_compatibility::FixHttpRequestMiddleware, + http_request_compatibility::HttpRequestMiddleware, rpc_call_compatibility::FixRpcResponseMiddleware, }, }; @@ -33,6 +35,7 @@ use crate::{ #[cfg(feature = "getblocktemplate-rpcs")] use crate::methods::{GetBlockTemplateRpc, GetBlockTemplateRpcImpl}; +pub mod cookie; pub mod http_request_compatibility; pub mod rpc_call_compatibility; @@ -199,13 +202,22 @@ impl RpcServer { let span = Span::current(); let start_server = move || { span.in_scope(|| { + let middleware = if config.enable_cookie_auth { + let cookie = Cookie::default(); + cookie::write_to_disk(&cookie, &config.cookie_dir) + .expect("Zebra must be able to write the auth cookie to the disk"); + HttpRequestMiddleware::default().with(cookie) + } else { + HttpRequestMiddleware::default() + }; + // Use a different tokio executor from the rest of Zebra, // so that large RPCs and any task handling bugs don't impact Zebra. let server_instance = ServerBuilder::new(io) .threads(parallel_cpu_threads) // TODO: disable this security check if we see errors from lightwalletd //.allowed_hosts(DomainsValidation::Disabled) - .request_middleware(FixHttpRequestMiddleware) + .request_middleware(middleware) .start_http(&listen_addr) .expect("Unable to start RPC server"); @@ -274,29 +286,39 @@ impl RpcServer { /// This method can be called from within a tokio executor without panicking. /// But it is blocking, so `shutdown()` should be used instead. pub fn shutdown_blocking(&self) { - Self::shutdown_blocking_inner(self.close_handle.clone()) + Self::shutdown_blocking_inner(self.close_handle.clone(), self.config.clone()) } /// Shut down this RPC server asynchronously. /// Returns a task that completes when the server is shut down. pub fn shutdown(&self) -> JoinHandle<()> { let close_handle = self.close_handle.clone(); - + let config = self.config.clone(); let span = Span::current(); + tokio::task::spawn_blocking(move || { - span.in_scope(|| Self::shutdown_blocking_inner(close_handle)) + span.in_scope(|| Self::shutdown_blocking_inner(close_handle, config)) }) } /// Shuts down this RPC server using its `close_handle`. /// /// See `shutdown_blocking()` for details. - fn shutdown_blocking_inner(close_handle: CloseHandle) { + fn shutdown_blocking_inner(close_handle: CloseHandle, config: Config) { // The server is a blocking task, so it can't run inside a tokio thread. // See the note at wait_on_server. let span = Span::current(); let wait_on_shutdown = move || { span.in_scope(|| { + if config.enable_cookie_auth { + if let Err(err) = cookie::remove_from_disk(&config.cookie_dir) { + warn!( + ?err, + "unexpectedly could not remove the rpc auth cookie from the disk" + ) + } + } + info!("Stopping RPC server"); close_handle.clone().close(); debug!("Stopped RPC server"); diff --git a/zebra-rpc/src/server/cookie.rs b/zebra-rpc/src/server/cookie.rs new file mode 100644 index 00000000000..a90961d6448 --- /dev/null +++ b/zebra-rpc/src/server/cookie.rs @@ -0,0 +1,54 @@ +//! Cookie-based authentication for the RPC server. + +use base64::{engine::general_purpose::URL_SAFE, Engine as _}; +use color_eyre::Result; +use rand::RngCore; + +use std::{ + fs::{remove_file, File}, + io::Write, + path::Path, +}; + +/// The name of the cookie file on the disk +const FILE: &str = ".cookie"; + +/// If the RPC authentication is enabled, all requests must contain this cookie. +#[derive(Clone, Debug)] +pub struct Cookie(String); + +impl Cookie { + /// Checks if the given passwd matches the contents of the cookie. + pub fn authenticate(&self, passwd: String) -> bool { + *passwd == self.0 + } +} + +impl Default for Cookie { + fn default() -> Self { + let mut bytes = [0u8; 32]; + rand::thread_rng().fill_bytes(&mut bytes); + + Self(URL_SAFE.encode(bytes)) + } +} + +/// Writes the given cookie to the given dir. +pub fn write_to_disk(cookie: &Cookie, dir: &Path) -> Result<()> { + // Create the directory if needed. + std::fs::create_dir_all(dir)?; + File::create(dir.join(FILE))?.write_all(format!("__cookie__:{}", cookie.0).as_bytes())?; + + tracing::info!("RPC auth cookie written to disk"); + + Ok(()) +} + +/// Removes a cookie from the given dir. +pub fn remove_from_disk(dir: &Path) -> Result<()> { + remove_file(dir.join(FILE))?; + + tracing::info!("RPC auth cookie removed from disk"); + + Ok(()) +} diff --git a/zebra-rpc/src/server/http_request_compatibility.rs b/zebra-rpc/src/server/http_request_compatibility.rs index fede0e2bef0..89925c229b8 100644 --- a/zebra-rpc/src/server/http_request_compatibility.rs +++ b/zebra-rpc/src/server/http_request_compatibility.rs @@ -2,24 +2,27 @@ //! //! These fixes are applied at the HTTP level, before the RPC request is parsed. +use base64::{engine::general_purpose::URL_SAFE, Engine as _}; use futures::TryStreamExt; use jsonrpc_http_server::{ hyper::{body::Bytes, header, Body, Request}, RequestMiddleware, RequestMiddlewareAction, }; +use super::cookie::Cookie; + /// HTTP [`RequestMiddleware`] with compatibility workarounds. /// /// This middleware makes the following changes to HTTP requests: /// -/// ## Remove `jsonrpc` field in JSON RPC 1.0 +/// ### Remove `jsonrpc` field in JSON RPC 1.0 /// /// Removes "jsonrpc: 1.0" fields from requests, /// because the "jsonrpc" field was only added in JSON-RPC 2.0. /// /// /// -/// ## Add missing `content-type` HTTP header +/// ### Add missing `content-type` HTTP header /// /// Some RPC clients don't include a `content-type` HTTP header. /// But unlike web browsers, [`jsonrpc_http_server`] does not do content sniffing. @@ -27,6 +30,11 @@ use jsonrpc_http_server::{ /// If there is no `content-type` header, we assume the content is JSON, /// and let the parser error if we are incorrect. /// +/// ### Authenticate incoming requests +/// +/// If the cookie-based RPC authentication is enabled, check that the incoming request contains the +/// authentication cookie. +/// /// This enables compatibility with `zcash-cli`. /// /// ## Security @@ -34,15 +42,47 @@ use jsonrpc_http_server::{ /// Any user-specified data in RPC requests is hex or base58check encoded. /// We assume lightwalletd validates data encodings before sending it on to Zebra. /// So any fixes Zebra performs won't change user-specified data. -#[derive(Copy, Clone, Debug)] -pub struct FixHttpRequestMiddleware; +#[derive(Clone, Debug, Default)] +pub struct HttpRequestMiddleware { + cookie: Option, +} -impl RequestMiddleware for FixHttpRequestMiddleware { +/// A trait for updating an object, consuming it and returning the updated version. +pub trait With { + /// Updates `self` with an instance of type `T` and returns the updated version of `self`. + fn with(self, _: T) -> Self; +} + +impl With for HttpRequestMiddleware { + fn with(mut self, cookie: Cookie) -> Self { + self.cookie = Some(cookie); + self + } +} + +impl RequestMiddleware for HttpRequestMiddleware { fn on_request(&self, mut request: Request) -> RequestMiddlewareAction { tracing::trace!(?request, "original HTTP request"); + // Check if the request is authenticated + if !self.check_credentials(request.headers_mut()) { + let error = jsonrpc_core::Error { + code: jsonrpc_core::ErrorCode::ServerError(401), + message: "unauthenticated method".to_string(), + data: None, + }; + return jsonrpc_http_server::Response { + code: jsonrpc_http_server::hyper::StatusCode::from_u16(401) + .expect("hard-coded status code should be valid"), + content_type: header::HeaderValue::from_static("application/json; charset=utf-8"), + content: serde_json::to_string(&jsonrpc_core::Response::from(error, None)) + .expect("hard-coded result should serialize"), + } + .into(); + } + // Fix the request headers if needed and we can do so. - FixHttpRequestMiddleware::insert_or_replace_content_type_header(request.headers_mut()); + HttpRequestMiddleware::insert_or_replace_content_type_header(request.headers_mut()); // Fix the request body let request = request.map(|body| { @@ -80,7 +120,7 @@ impl RequestMiddleware for FixHttpRequestMiddleware { } } -impl FixHttpRequestMiddleware { +impl HttpRequestMiddleware { /// Remove any "jsonrpc: 1.0" fields in `data`, and return the resulting string. pub fn remove_json_1_fields(data: String) -> String { // Replace "jsonrpc = 1.0": @@ -141,4 +181,18 @@ impl FixHttpRequestMiddleware { ); } } + + /// Check if the request is authenticated. + pub fn check_credentials(&self, headers: &header::HeaderMap) -> bool { + self.cookie.as_ref().map_or(true, |internal_cookie| { + headers + .get(header::AUTHORIZATION) + .and_then(|auth_header| auth_header.to_str().ok()) + .and_then(|auth_header| auth_header.split_whitespace().nth(1)) + .and_then(|encoded| URL_SAFE.decode(encoded).ok()) + .and_then(|decoded| String::from_utf8(decoded).ok()) + .and_then(|request_cookie| request_cookie.split(':').nth(1).map(String::from)) + .map_or(false, |passwd| internal_cookie.authenticate(passwd)) + }) + } } diff --git a/zebra-rpc/src/server/tests/vectors.rs b/zebra-rpc/src/server/tests/vectors.rs index 9c50ecc7c35..8ffc3386a0d 100644 --- a/zebra-rpc/src/server/tests/vectors.rs +++ b/zebra-rpc/src/server/tests/vectors.rs @@ -46,6 +46,8 @@ fn rpc_server_spawn(parallel_cpu_threads: bool) { indexer_listen_addr: None, parallel_cpu_threads: if parallel_cpu_threads { 2 } else { 1 }, debug_force_finished_sync: false, + cookie_dir: Default::default(), + enable_cookie_auth: false, }; let rt = tokio::runtime::Runtime::new().unwrap(); @@ -134,6 +136,8 @@ fn rpc_server_spawn_unallocated_port(parallel_cpu_threads: bool, do_shutdown: bo indexer_listen_addr: None, parallel_cpu_threads: if parallel_cpu_threads { 0 } else { 1 }, debug_force_finished_sync: false, + cookie_dir: Default::default(), + enable_cookie_auth: false, }; let rt = tokio::runtime::Runtime::new().unwrap(); @@ -215,6 +219,8 @@ fn rpc_server_spawn_port_conflict() { indexer_listen_addr: None, parallel_cpu_threads: 1, debug_force_finished_sync: false, + cookie_dir: Default::default(), + enable_cookie_auth: false, }; let rt = tokio::runtime::Runtime::new().unwrap(); @@ -326,6 +332,8 @@ fn rpc_server_spawn_port_conflict_parallel_auto() { indexer_listen_addr: None, parallel_cpu_threads: 2, debug_force_finished_sync: false, + cookie_dir: Default::default(), + enable_cookie_auth: false, }; let rt = tokio::runtime::Runtime::new().unwrap(); diff --git a/zebrad/tests/common/config.rs b/zebrad/tests/common/config.rs index d6ee45f52ee..4b3f86201fd 100644 --- a/zebrad/tests/common/config.rs +++ b/zebrad/tests/common/config.rs @@ -166,6 +166,7 @@ pub fn rpc_port_config( // Default config, users who want to detect port conflicts configure this config.rpc.parallel_cpu_threads = 1; } + config.rpc.enable_cookie_auth = false; Ok(config) } diff --git a/zebrad/tests/common/configs/v2.0.0-rc.0.toml b/zebrad/tests/common/configs/v2.0.0-rc.0.toml new file mode 100644 index 00000000000..7f2276e63aa --- /dev/null +++ b/zebrad/tests/common/configs/v2.0.0-rc.0.toml @@ -0,0 +1,84 @@ +# Default configuration for zebrad. +# +# This file can be used as a skeleton for custom configs. +# +# Unspecified fields use default values. Optional fields are Some(field) if the +# field is present and None if it is absent. +# +# This file is generated as an example using zebrad's current defaults. +# You should set only the config options you want to keep, and delete the rest. +# Only a subset of fields are present in the skeleton, since optional values +# whose default is None are omitted. +# +# The config format (including a complete list of sections and fields) is +# documented here: +# https://docs.rs/zebrad/latest/zebrad/config/struct.ZebradConfig.html +# +# zebrad attempts to load configs in the following order: +# +# 1. The -c flag on the command line, e.g., `zebrad -c myconfig.toml start`; +# 2. The file `zebrad.toml` in the users's preference directory (platform-dependent); +# 3. The default config. +# +# The user's preference directory and the default path to the `zebrad` config are platform dependent, +# based on `dirs::preference_dir`, see https://docs.rs/dirs/latest/dirs/fn.preference_dir.html : +# +# | Platform | Value | Example | +# | -------- | ------------------------------------- | ---------------------------------------------- | +# | Linux | `$XDG_CONFIG_HOME` or `$HOME/.config` | `/home/alice/.config/zebrad.toml` | +# | macOS | `$HOME/Library/Preferences` | `/Users/Alice/Library/Preferences/zebrad.toml` | +# | Windows | `{FOLDERID_RoamingAppData}` | `C:\Users\Alice\AppData\Local\zebrad.toml` | + +[consensus] +checkpoint_sync = true + +[mempool] +eviction_memory_time = "1h" +tx_cost_limit = 80000000 + +[metrics] + +[mining] +debug_like_zcashd = true + +[network] +cache_dir = true +crawl_new_peer_interval = "1m 1s" +initial_mainnet_peers = [ + "dnsseed.z.cash:8233", + "dnsseed.str4d.xyz:8233", + "mainnet.seeder.zfnd.org:8233", + "mainnet.is.yolo.money:8233", +] +initial_testnet_peers = [ + "dnsseed.testnet.z.cash:18233", + "testnet.seeder.zfnd.org:18233", + "testnet.is.yolo.money:18233", +] +listen_addr = "0.0.0.0:8233" +max_connections_per_ip = 1 +network = "Mainnet" +peerset_initial_target_size = 25 + +[rpc] +cookie_dir = "cache_dir" +debug_force_finished_sync = false +enable_cookie_auth = true +parallel_cpu_threads = 0 + +[state] +cache_dir = "cache_dir" +delete_old_database = true +ephemeral = false + +[sync] +checkpoint_verify_concurrency_limit = 1000 +download_concurrency_limit = 50 +full_verify_concurrency_limit = 20 +parallel_cpu_threads = 0 + +[tracing] +buffer_limit = 128000 +force_use_color = false +use_color = true +use_journald = false