Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(rpc): Cookie auth system for the RPC endpoint #8900

Open
wants to merge 13 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Cargo.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
10 changes: 7 additions & 3 deletions zebra-rpc/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@ indexer-rpcs = [

# Mining RPC support
getblocktemplate-rpcs = [
"rand",
"zcash_address",
"zebra-consensus/getblocktemplate-rpcs",
"zebra-state/getblocktemplate-rpcs",
Expand Down Expand Up @@ -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",
Expand All @@ -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}

Expand Down
1 change: 1 addition & 0 deletions zebra-rpc/qa/base_config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ network = "Regtest"

[rpc]
listen_addr = "127.0.0.1:0"
enable_cookie_auth = false

[state]
cache_dir = ""
Expand Down
16 changes: 15 additions & 1 deletion zebra-rpc/src/config.rs
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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.
Expand All @@ -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,
}
}
}
9 changes: 8 additions & 1 deletion zebra-rpc/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,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;

Expand Down Expand Up @@ -193,6 +194,11 @@ impl RpcServer {
parallel_cpu_threads = available_parallelism().map(usize::from).unwrap_or(1);
}

// Generate a RPC authentication cookie
if config.enable_cookie_auth {
let _ = cookie::generate(config.cookie_dir.clone());
}

// The server is a blocking task, which blocks on executor shutdown.
// So we need to start it in a std::thread.
// (Otherwise tokio panics on RPC port conflict, which shuts down the RPC server.)
Expand All @@ -205,7 +211,7 @@ impl RpcServer {
.threads(parallel_cpu_threads)
// TODO: disable this security check if we see errors from lightwalletd
//.allowed_hosts(DomainsValidation::Disabled)
.request_middleware(FixHttpRequestMiddleware)
.request_middleware(FixHttpRequestMiddleware(config.clone()))
.start_http(&listen_addr)
.expect("Unable to start RPC server");

Expand Down Expand Up @@ -299,6 +305,7 @@ impl RpcServer {
span.in_scope(|| {
info!("Stopping RPC server");
close_handle.clone().close();
let _ = cookie::delete(); // delete the auth cookie if exists
debug!("Stopped RPC server");
})
};
Expand Down
48 changes: 48 additions & 0 deletions zebra-rpc/src/server/cookie.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
//! 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::{Read, Write},
path::PathBuf,
};

/// The user field in the cookie (arbitrary, only for recognizability in debugging/logging purposes)
pub const COOKIEAUTH_USER: &str = "__cookie__";
/// Default name for auth cookie file */
pub const COOKIEAUTH_FILE: &str = ".cookie";

/// Generate a new auth cookie and store it in the given `cookie_dir`.
pub fn generate(cookie_dir: PathBuf) -> Result<()> {
let mut data = [0u8; 32];
rand::thread_rng().fill_bytes(&mut data);
let encoded_password = URL_SAFE.encode(data);
let cookie_content = format!("{}:{}", COOKIEAUTH_USER, encoded_password);

let mut file = File::create(cookie_dir.join(COOKIEAUTH_FILE))?;
file.write_all(cookie_content.as_bytes())?;

tracing::info!("RPC auth cookie generated successfully");

Ok(())
}

/// Get the encoded password from the auth cookie.
pub fn get(cookie_dir: PathBuf) -> Result<String> {
let mut file = File::open(cookie_dir.join(COOKIEAUTH_FILE))?;
let mut contents = String::new();
file.read_to_string(&mut contents)?;

let parts: Vec<&str> = contents.split(":").collect();
Ok(parts[1].to_string())
}

/// Delete the auth cookie.
pub fn delete() -> Result<()> {
remove_file(COOKIEAUTH_FILE)?;
Copy link
Member

@upbqdn upbqdn Oct 15, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think the removal works because we're using only the file name without the path to it.

tracing::info!("RPC auth cookie deleted successfully");
Ok(())
}
50 changes: 48 additions & 2 deletions zebra-rpc/src/server/http_request_compatibility.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,15 @@
//!
//! 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 crate::server::cookie;

/// HTTP [`RequestMiddleware`] with compatibility workarounds.
///
/// This middleware makes the following changes to HTTP requests:
Expand All @@ -34,13 +37,30 @@ 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)]
pub struct FixHttpRequestMiddleware(pub crate::config::Config);

impl RequestMiddleware for FixHttpRequestMiddleware {
fn on_request(&self, mut request: Request<Body>) -> 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());

Expand Down Expand Up @@ -141,4 +161,30 @@ impl FixHttpRequestMiddleware {
);
}
}

/// Check if the request is authenticated.
pub fn check_credentials(&self, headers: &header::HeaderMap) -> bool {
if !self.0.enable_cookie_auth {
return true;
}
headers
.get(header::AUTHORIZATION)
.and_then(|auth_header| auth_header.to_str().ok())
.and_then(|auth| auth.split_whitespace().nth(1))
.and_then(|token| URL_SAFE.decode(token).ok())
.and_then(|decoded| String::from_utf8(decoded).ok())
.and_then(|decoded_str| {
decoded_str
.split(':')
.nth(1)
.map(|password| password.to_string())
})
.map_or(false, |password| {
if let Ok(cookie_password) = cookie::get(self.0.cookie_dir.clone()) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We're still reading the cookie from the disk on each RPC request.

cookie_password == password
} else {
false
}
})
}
}
8 changes: 8 additions & 0 deletions zebra-rpc/src/server/tests/vectors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand Down
1 change: 1 addition & 0 deletions zebrad/tests/common/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
84 changes: 84 additions & 0 deletions zebrad/tests/common/configs/v2.0.0-rc.0.toml
Original file line number Diff line number Diff line change
@@ -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
Loading