diff --git a/high-storm/README.md b/high-storm/README.md index bb77739..5225087 100644 --- a/high-storm/README.md +++ b/high-storm/README.md @@ -17,7 +17,7 @@ high-storm/docker.sh connections 1 # List node 1's active connections. ``` The Storm listeners are exposed on host ports `9000`, `9001`, and `9002`; their -REST APIs are exposed on `9100`, `9101`, and `9102`. +external APIs are exposed on `9100`, `9101`, and `9102`. Each node has its own operator platform at `http://127.0.0.1:9200`, `http://127.0.0.1:9201`, and `http://127.0.0.1:9202`, respectively. For local development, log in with the matching deterministic secret from @@ -39,8 +39,8 @@ The checked-in identities and database password are for local development only. Copy `config.example.toml` to `config.toml`, then set the listener port, signer key, and PostgreSQL connection fields. The `service.db.url` value is the database host and optional port, for example `localhost:5432`. Set `service.ipc_path` to a unique -Unix socket path for each high-storm process running on the same host. The REST API -binds to `service.rest_address`, which defaults to `127.0.0.1:9001`. +Unix socket path for each high-storm process running on the same host. The external API +binds to `service.external_api_address`, which defaults to `127.0.0.1:9001`. ## Initialize a network @@ -87,7 +87,7 @@ user that started the node and the root user are authorized to use the socket. When high-storm uses a non-default `service.ipc_path`, pass the same path to the client with `--socket `. -## REST API +## External API Operator identities are compressed secp256k1 public keys. High-storm derives each key's mainnet P2WPKH address and verifies BIP322-simple signatures against it. diff --git a/high-storm/config.example.toml b/high-storm/config.example.toml index 73e1665..5ab9f4a 100644 --- a/high-storm/config.example.toml +++ b/high-storm/config.example.toml @@ -2,7 +2,7 @@ [service] port = 9000 ipc_path = "/tmp/high-storm.sock" -rest_address = "127.0.0.1:9001" +external_api_address = "127.0.0.1:9001" [service.signer] # secpr256k1 32-byte hex-encoded private key, used for signing, diff --git a/high-storm/docker/node-1.toml b/high-storm/docker/node-1.toml index 74b43a9..10caadb 100644 --- a/high-storm/docker/node-1.toml +++ b/high-storm/docker/node-1.toml @@ -1,6 +1,6 @@ [service] port = 9000 -rest_address = "0.0.0.0:9100" +external_api_address = "0.0.0.0:9100" [service.signer] # Development-only deterministic key. Do not use in production. diff --git a/high-storm/docker/node-2.toml b/high-storm/docker/node-2.toml index 8090ee9..129a7a6 100644 --- a/high-storm/docker/node-2.toml +++ b/high-storm/docker/node-2.toml @@ -1,6 +1,6 @@ [service] port = 9000 -rest_address = "0.0.0.0:9100" +external_api_address = "0.0.0.0:9100" [service.signer] # Development-only deterministic key. Do not use in production. diff --git a/high-storm/docker/node-3.toml b/high-storm/docker/node-3.toml index 6db8109..cd7b93e 100644 --- a/high-storm/docker/node-3.toml +++ b/high-storm/docker/node-3.toml @@ -1,6 +1,6 @@ [service] port = 9000 -rest_address = "0.0.0.0:9100" +external_api_address = "0.0.0.0:9100" [service.signer] # Development-only deterministic key. Do not use in production. diff --git a/high-storm/src/config.rs b/high-storm/src/config.rs index f3b4a6a..e945018 100644 --- a/high-storm/src/config.rs +++ b/high-storm/src/config.rs @@ -22,8 +22,8 @@ pub struct ServiceConfig { pub port: u16, #[serde(default = "default_ipc_path")] pub ipc_path: PathBuf, - #[serde(default = "default_rest_address")] - pub rest_address: SocketAddr, + #[serde(default = "default_external_api_address")] + pub external_api_address: SocketAddr, pub signer: SignerConfig, pub db: DbConfig, } @@ -32,10 +32,10 @@ fn default_ipc_path() -> PathBuf { "/tmp/high-storm.sock".into() } -fn default_rest_address() -> SocketAddr { +fn default_external_api_address() -> SocketAddr { "127.0.0.1:9001" .parse() - .expect("the default REST address is valid") + .expect("the default external API address is valid") } #[derive(Clone, Debug, Deserialize)] diff --git a/high-storm/src/external_api/mod.rs b/high-storm/src/external_api/mod.rs new file mode 100644 index 0000000..147e611 --- /dev/null +++ b/high-storm/src/external_api/mod.rs @@ -0,0 +1,165 @@ +mod operators; +#[cfg(test)] +mod tests; + +use std::net::SocketAddr; + +use axum::{ + Json, Router, + http::StatusCode, + response::{IntoResponse, Response}, + routing::any, +}; +use serde::Serialize; + +use crate::{HighStormHandle, VotingError, db::node_operator::NodeOperatorStore}; +use operators::{AuthError, AuthService}; + +#[derive(Clone)] +pub(super) struct ExternalApiState { + pub(super) node: HighStormHandle, + pub(super) auth: AuthService, +} + +pub struct ExternalApiServer { + listener: tokio::net::TcpListener, + router: Router, +} + +impl ExternalApiServer { + pub async fn bind( + address: SocketAddr, + node: HighStormHandle, + operators: NodeOperatorStore, + ) -> std::io::Result { + let listener = tokio::net::TcpListener::bind(address).await?; + Ok(Self { + listener, + router: router(node, operators), + }) + } + + pub fn local_addr(&self) -> std::io::Result { + self.listener.local_addr() + } + + pub async fn run(self) -> std::io::Result<()> { + axum::serve(self.listener, self.router).await + } +} + +pub fn router(node: HighStormHandle, operators: NodeOperatorStore) -> Router { + let state = ExternalApiState { + node, + auth: AuthService::new(operators), + }; + Router::new() + .nest( + "/users", + Router::new() + .route("/", any(not_implemented)) + .route("/{*path}", any(not_implemented)), + ) + .nest("/operators", operators::router()) + .with_state(state) +} + +#[derive(Serialize)] +struct ErrorBody { + error: String, +} + +pub(super) struct ApiError { + status: StatusCode, + message: String, +} + +async fn not_implemented() -> ApiError { + ApiError { + status: StatusCode::NOT_IMPLEMENTED, + message: "user API is not implemented".to_string(), + } +} + +impl ApiError { + pub(super) fn bad_request(message: impl ToString) -> Self { + Self { + status: StatusCode::BAD_REQUEST, + message: message.to_string(), + } + } + + pub(super) fn unauthorized(message: impl ToString) -> Self { + Self { + status: StatusCode::UNAUTHORIZED, + message: message.to_string(), + } + } + + pub(super) fn not_found(message: impl ToString) -> Self { + Self { + status: StatusCode::NOT_FOUND, + message: message.to_string(), + } + } + + pub(super) fn internal(message: impl ToString) -> Self { + tracing::error!(error = %message.to_string(), "external API request failed"); + Self { + status: StatusCode::INTERNAL_SERVER_ERROR, + message: "internal server error".to_string(), + } + } +} + +impl From for ApiError { + fn from(error: AuthError) -> Self { + let status = match error { + AuthError::InvalidPublicKey + | AuthError::InvalidChallenge + | AuthError::InvalidTimestamp + | AuthError::InvalidNonce => StatusCode::BAD_REQUEST, + AuthError::Unauthorized => StatusCode::FORBIDDEN, + AuthError::ReplayedNonce => StatusCode::CONFLICT, + AuthError::InvalidToken | AuthError::InvalidSignature => StatusCode::UNAUTHORIZED, + AuthError::Clock | AuthError::Random | AuthError::Store(_) => { + return Self::internal(error); + } + }; + Self { + status, + message: error.to_string(), + } + } +} + +impl From for ApiError { + fn from(error: VotingError) -> Self { + let status = match error { + VotingError::InvalidRequest(_) | VotingError::InvalidApproval(_) => { + StatusCode::BAD_REQUEST + } + VotingError::UnknownRequest(_) => StatusCode::NOT_FOUND, + VotingError::DuplicateRequest(_) | VotingError::DuplicateApproval(_) => { + StatusCode::CONFLICT + } + _ => return Self::internal(error), + }; + Self { + status, + message: error.to_string(), + } + } +} + +impl IntoResponse for ApiError { + fn into_response(self) -> Response { + ( + self.status, + Json(ErrorBody { + error: self.message, + }), + ) + .into_response() + } +} diff --git a/high-storm/src/rest/auth.rs b/high-storm/src/external_api/operators/auth.rs similarity index 89% rename from high-storm/src/rest/auth.rs rename to high-storm/src/external_api/operators/auth.rs index 316d6f4..518a9f4 100644 --- a/high-storm/src/rest/auth.rs +++ b/high-storm/src/external_api/operators/auth.rs @@ -4,12 +4,16 @@ use std::{ time::{Duration, SystemTime, UNIX_EPOCH}, }; +use axum::{Json, extract::State, http::HeaderMap}; use bitcoin::{Address, CompressedPublicKey, address::KnownHrp}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use tokio::sync::Mutex; -use crate::db::node_operator::NodeOperatorStore; +use crate::{ + db::node_operator::NodeOperatorStore, + external_api::{ApiError, ExternalApiState}, +}; const CHALLENGE_TTL: Duration = Duration::from_secs(5 * 60); const TOKEN_TTL: Duration = Duration::from_secs(60 * 60); @@ -53,6 +57,18 @@ pub struct AccessToken { pub expires_at: u64, } +#[derive(Deserialize)] +pub(super) struct ChallengeRequest { + public_key: String, +} + +#[derive(Deserialize)] +pub(super) struct TokenRequest { + public_key: String, + message: String, + signature: String, +} + #[derive(Deserialize)] pub(super) struct SignedRequest { pub(super) public_key: String, @@ -126,7 +142,8 @@ impl AuthService { .await } - pub fn write_message( + #[cfg(test)] + pub(crate) fn write_message( method: &str, path: &str, timestamp: u64, @@ -264,6 +281,43 @@ impl AuthService { } } +pub(super) async fn issue_challenge( + State(state): State, + Json(request): Json, +) -> Result, ApiError> { + state + .auth + .issue_challenge(&request.public_key) + .await + .map(Json) + .map_err(Into::into) +} + +pub(super) async fn exchange_token( + State(state): State, + Json(request): Json, +) -> Result, ApiError> { + state + .auth + .exchange_token(&request.public_key, &request.message, &request.signature) + .await + .map(Json) + .map_err(Into::into) +} + +pub(super) async fn authenticate_bearer( + auth: &AuthService, + headers: &HeaderMap, +) -> Result<(), ApiError> { + let token = headers + .get(axum::http::header::AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.strip_prefix("Bearer ")) + .ok_or_else(|| ApiError::unauthorized("missing bearer token"))?; + auth.authenticate_token(token).await?; + Ok(()) +} + impl AuthState { fn cleanup(&mut self, now: u64) { self.challenges.retain(|_, value| value.expires_at > now); diff --git a/high-storm/src/external_api/operators/mod.rs b/high-storm/src/external_api/operators/mod.rs new file mode 100644 index 0000000..3bec86a --- /dev/null +++ b/high-storm/src/external_api/operators/mod.rs @@ -0,0 +1,25 @@ +pub(super) mod auth; +mod state; +mod voting; + +use axum::{ + Router, + routing::{get, post}, +}; + +use super::ExternalApiState; +pub(super) use auth::{AuthError, AuthService}; + +pub(super) fn router() -> Router { + Router::new() + .route("/auth/challenge", post(auth::issue_challenge)) + .route("/auth/token", post(auth::exchange_token)) + .route("/state", get(state::get_network_state)) + .route("/state/peers", get(state::get_network_peers)) + .route( + "/voting", + get(voting::list_votings).post(voting::create_voting), + ) + .route("/voting/{hash}", get(voting::get_voting)) + .route("/voting/{hash}/approve", post(voting::approve_voting)) +} diff --git a/high-storm/src/external_api/operators/state.rs b/high-storm/src/external_api/operators/state.rs new file mode 100644 index 0000000..d9736e5 --- /dev/null +++ b/high-storm/src/external_api/operators/state.rs @@ -0,0 +1,108 @@ +use axum::{Json, extract::State, http::HeaderMap}; +use serde::Serialize; +use storm::PeerStatus; + +use super::auth::authenticate_bearer; +use crate::{ + VotingStatus, + external_api::{ApiError, ExternalApiState}, +}; + +#[derive(Serialize)] +pub(super) struct NetworkStateResponse { + block_height: u64, + local_public_key: String, + coordinator_public_key: String, + is_coordinator: bool, + total_peers: usize, + online_peers: usize, + inactive_peers: usize, + banned_peers: usize, + pending_votings: usize, + approved_votings: usize, +} + +#[derive(Serialize)] +pub(super) struct NetworkPeerResponse { + public_key: String, + socket_address: Option, + last_seen: Option, + status: &'static str, + is_local: bool, + is_coordinator: bool, +} + +pub(super) async fn get_network_state( + State(state): State, + headers: HeaderMap, +) -> Result, ApiError> { + authenticate_bearer(&state.auth, &headers).await?; + let peers = state.node.peers().await; + let votings = state.node.voting_requests().await?; + let coordinator_public_key = state.node.coordinator_public_key(); + let local_public_key = peers + .iter() + .find(|peer| peer.status == PeerStatus::Controlled) + .map(|peer| peer.compressed_public_key) + .ok_or_else(|| ApiError::internal("local peer is missing from the peer table"))?; + let online_peers = peers + .iter() + .filter(|peer| matches!(peer.status, PeerStatus::Controlled | PeerStatus::Active)) + .count(); + let pending_votings = votings + .iter() + .filter(|voting| voting.status == VotingStatus::Pending) + .count(); + + Ok(Json(NetworkStateResponse { + block_height: state.node.block_height(), + local_public_key: hex::encode(local_public_key), + coordinator_public_key: hex::encode(coordinator_public_key), + is_coordinator: local_public_key == coordinator_public_key, + total_peers: peers.len(), + online_peers, + inactive_peers: peers + .iter() + .filter(|peer| peer.status == PeerStatus::Inactive) + .count(), + banned_peers: peers + .iter() + .filter(|peer| peer.status == PeerStatus::Banned) + .count(), + pending_votings, + approved_votings: votings.len() - pending_votings, + })) +} + +pub(super) async fn get_network_peers( + State(state): State, + headers: HeaderMap, +) -> Result>, ApiError> { + authenticate_bearer(&state.auth, &headers).await?; + let coordinator_public_key = state.node.coordinator_public_key(); + Ok(Json( + state + .node + .peers() + .await + .into_iter() + .map(|peer| NetworkPeerResponse { + public_key: hex::encode(peer.compressed_public_key), + socket_address: peer.socket_address, + last_seen: peer.last_seen, + status: peer_status_name(peer.status), + is_local: peer.status == PeerStatus::Controlled, + is_coordinator: peer.compressed_public_key == coordinator_public_key, + }) + .collect(), + )) +} + +fn peer_status_name(status: PeerStatus) -> &'static str { + match status { + PeerStatus::Controlled => "controlled", + PeerStatus::Active => "active", + PeerStatus::Inactive => "inactive", + PeerStatus::Banned => "banned", + } +} diff --git a/high-storm/src/rest/dto.rs b/high-storm/src/external_api/operators/voting.rs similarity index 72% rename from high-storm/src/rest/dto.rs rename to high-storm/src/external_api/operators/voting.rs index 6f6ac77..b0f936b 100644 --- a/high-storm/src/rest/dto.rs +++ b/high-storm/src/external_api/operators/voting.rs @@ -1,11 +1,101 @@ +use axum::{ + Json, + extract::{Path, State}, + http::{HeaderMap, StatusCode}, +}; use secp256k1::XOnlyPublicKey; use serde::{Deserialize, Serialize}; +use super::auth::{SignedRequest, authenticate_bearer}; use crate::{ MergeStormEyes, NetworkVoteKind, NetworkVoteRequest, SplitStormEye, StormEyeUtxo, UpdateNetworkMembers, VotingRequest, VotingStatus, + external_api::{ApiError, ExternalApiState}, }; +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +pub(super) struct EmptyPayload {} + +#[derive(Serialize)] +pub(super) struct CreatedVoting { + message_hash: String, +} + +pub(super) async fn list_votings( + State(state): State, + headers: HeaderMap, +) -> Result>, ApiError> { + authenticate_bearer(&state.auth, &headers).await?; + state + .node + .voting_requests() + .await? + .into_iter() + .map(VotingResponse::try_from) + .collect::, _>>() + .map(Json) + .map_err(ApiError::internal) +} + +pub(super) async fn get_voting( + State(state): State, + headers: HeaderMap, + Path(hash): Path, +) -> Result, ApiError> { + authenticate_bearer(&state.auth, &headers).await?; + let hash = parse_hash(&hash)?; + let voting = state + .node + .voting_request(hash) + .await? + .ok_or_else(|| ApiError::not_found("voting request does not exist"))?; + VotingResponse::try_from(voting) + .map(Json) + .map_err(ApiError::internal) +} + +pub(super) async fn create_voting( + State(state): State, + Json(request): Json>, +) -> Result<(StatusCode, Json), ApiError> { + state + .auth + .verify_write(&request, "POST", "/operators/voting") + .await?; + let voting = request + .payload + .into_request() + .map_err(ApiError::bad_request)?; + let message_hash = state.node.create_voting_request(voting).await?; + Ok(( + StatusCode::CREATED, + Json(CreatedVoting { + message_hash: hex::encode(message_hash), + }), + )) +} + +pub(super) async fn approve_voting( + State(state): State, + Path(hash): Path, + Json(request): Json>, +) -> Result { + let path = format!("/operators/voting/{hash}/approve"); + state.auth.verify_write(&request, "POST", &path).await?; + state + .node + .approve_voting_request(parse_hash(&hash)?) + .await?; + Ok(StatusCode::NO_CONTENT) +} + +fn parse_hash(encoded: &str) -> Result<[u8; 32], ApiError> { + hex::decode(encoded) + .map_err(|_| ApiError::bad_request("invalid voting request hash"))? + .try_into() + .map_err(|_| ApiError::bad_request("invalid voting request hash")) +} + #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum VotingProposal { diff --git a/high-storm/src/external_api/tests.rs b/high-storm/src/external_api/tests.rs new file mode 100644 index 0000000..363b5cd --- /dev/null +++ b/high-storm/src/external_api/tests.rs @@ -0,0 +1,244 @@ +use axum::{ + Router, + body::Body, + http::{Request, StatusCode, header}, + response::Response, +}; +use bitcoin::{Address, CompressedPublicKey, Network, PrivateKey, address::KnownHrp, secp256k1}; +use http_body_util::BodyExt; +use secp256k1_zkp::{Secp256k1, SecretKey}; +use storm::{Peer, Storm}; +use tower::ServiceExt; + +use crate::{HighStorm, db::Database}; + +use super::{operators::AuthService, router}; + +#[tokio::test] +async fn authenticates_operator_reads_with_a_real_bip322_signature() { + let (app, private_key, public_key) = setup().await; + + let unauthorized = app + .clone() + .oneshot( + Request::builder() + .uri("/operators/voting") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(unauthorized.status(), StatusCode::UNAUTHORIZED); + + let challenge = app + .clone() + .oneshot(json_request( + "/operators/auth/challenge", + serde_json::json!({"public_key": public_key}), + )) + .await + .unwrap(); + assert_eq!(challenge.status(), StatusCode::OK); + let challenge: serde_json::Value = response_json(challenge).await; + let message = challenge["message"].as_str().unwrap(); + let signature = sign(&private_key, message); + + let token = app + .clone() + .oneshot(json_request( + "/operators/auth/token", + serde_json::json!({ + "public_key": public_key, + "message": message, + "signature": signature, + }), + )) + .await + .unwrap(); + assert_eq!(token.status(), StatusCode::OK); + let token: serde_json::Value = response_json(token).await; + + let voting = app + .clone() + .oneshot( + Request::builder() + .uri("/operators/voting") + .header( + header::AUTHORIZATION, + format!("Bearer {}", token["token"].as_str().unwrap()), + ) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(voting.status(), StatusCode::OK); + assert_eq!(response_json(voting).await, serde_json::json!([])); + + let authorization = format!("Bearer {}", token["token"].as_str().unwrap()); + let network = app + .clone() + .oneshot( + Request::builder() + .uri("/operators/state") + .header(header::AUTHORIZATION, &authorization) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(network.status(), StatusCode::OK); + let network = response_json(network).await; + assert_eq!(network["total_peers"], 1); + assert_eq!(network["online_peers"], 1); + assert_eq!(network["is_coordinator"], true); + + let peers = app + .clone() + .oneshot( + Request::builder() + .uri("/operators/state/peers") + .header(header::AUTHORIZATION, authorization) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(peers.status(), StatusCode::OK); + let peers = response_json(peers).await; + assert_eq!(peers.as_array().unwrap().len(), 1); + assert_eq!(peers[0]["status"], "controlled"); + assert_eq!(peers[0]["is_local"], true); + + let users = app + .oneshot( + Request::builder() + .uri("/users/pending") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(users.status(), StatusCode::NOT_IMPLEMENTED); +} + +#[tokio::test] +async fn creates_and_approves_voting_with_signed_requests() { + let (app, private_key, public_key) = setup().await; + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + let proposal = serde_json::json!({ + "kind": "split_storm_eye", + "utxo_to_split": { + "txid": hex::encode([7; 32]), + "output_index": 1 + }, + "number_of_splits": 2 + }); + let create = app + .clone() + .oneshot(signed_request( + &private_key, + &public_key, + "/operators/voting", + timestamp, + "create-voting", + proposal, + )) + .await + .unwrap(); + assert_eq!(create.status(), StatusCode::CREATED); + let created: serde_json::Value = response_json(create).await; + let hash = created["message_hash"].as_str().unwrap(); + let approval_path = format!("/operators/voting/{hash}/approve"); + + let approve = app + .oneshot(signed_request( + &private_key, + &public_key, + &approval_path, + timestamp, + "approve-voting", + serde_json::json!({}), + )) + .await + .unwrap(); + assert_eq!(approve.status(), StatusCode::NO_CONTENT); +} + +async fn setup() -> (Router, PrivateKey, String) { + let database = Database::connect("sqlite::memory:", 1).await.unwrap(); + let operators = database.node_operators(); + let operator_secret = secp256k1::SecretKey::from_slice(&[42; 32]).unwrap(); + let operator_private_key = PrivateKey::new(operator_secret, Network::Bitcoin); + let operator_public_key = operator_private_key + .public_key(&secp256k1::Secp256k1::new()) + .inner + .serialize(); + operators.add(operator_public_key).await.unwrap(); + + let node_secret = SecretKey::from_slice(&[21; 32]).unwrap(); + let node_public_key = node_secret.public_key(&Secp256k1::new()).serialize(); + let storm = Storm::from_peers(node_secret, vec![Peer::new(node_public_key)]); + let node = HighStorm::new( + storm, + node_secret.secret_bytes(), + node_public_key, + database.voting(), + ) + .await; + ( + router(node.handle(), operators), + operator_private_key, + hex::encode(operator_public_key), + ) +} + +fn json_request(uri: &str, body: serde_json::Value) -> Request { + Request::builder() + .method("POST") + .uri(uri) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(serde_json::to_vec(&body).unwrap())) + .unwrap() +} + +fn signed_request( + private_key: &PrivateKey, + public_key: &str, + path: &str, + timestamp: u64, + nonce: &str, + payload: serde_json::Value, +) -> Request { + let message = AuthService::write_message("POST", path, timestamp, nonce, &payload).unwrap(); + json_request( + path, + serde_json::json!({ + "public_key": public_key, + "timestamp": timestamp, + "nonce": nonce, + "signature": sign(private_key, &message), + "payload": payload, + }), + ) +} + +async fn response_json(response: Response) -> serde_json::Value { + let body = response.into_body().collect().await.unwrap().to_bytes(); + serde_json::from_slice(&body).unwrap() +} + +fn sign(private_key: &PrivateKey, message: &str) -> String { + let public_key = + CompressedPublicKey::from_private_key(&secp256k1::Secp256k1::new(), private_key).unwrap(); + bip322::sign_simple_encoded( + &Address::p2wpkh(&public_key, KnownHrp::Mainnet).to_string(), + message, + &[private_key.to_wif()], + None, + ) + .unwrap() +} diff --git a/high-storm/src/lib.rs b/high-storm/src/lib.rs index 14ffd16..9f4669f 100644 --- a/high-storm/src/lib.rs +++ b/high-storm/src/lib.rs @@ -1,9 +1,9 @@ pub mod cli; pub mod config; pub mod db; +pub mod external_api; pub mod high_storm; pub mod ipc; -pub mod rest; use std::{collections::HashSet, net::SocketAddr, time::Duration}; diff --git a/high-storm/src/main.rs b/high-storm/src/main.rs index c27e76e..148dc2e 100644 --- a/high-storm/src/main.rs +++ b/high-storm/src/main.rs @@ -4,8 +4,8 @@ use high_storm::{ cli::{Cli, Commands, InitializeCommands}, config::Config, db::{Database, network::NetworkStore}, + external_api::ExternalApiServer, ipc::IpcServer, - rest::RestServer, }; use tokio::time::{Duration, Instant, MissedTickBehavior}; use tracing_subscriber::EnvFilter; @@ -58,15 +58,15 @@ async fn main() -> Result<(), Box> { high_storm::initialize_join(&config, &store, &public_key, &address).await? } }; - let rest = RestServer::bind( - config.service.rest_address, + let external_api = ExternalApiServer::bind( + config.service.external_api_address, storm.handle(), database.node_operators(), ) .await?; - tracing::info!(address = %rest.local_addr()?, "REST API is listening"); + tracing::info!(address = %external_api.local_addr()?, "external API is listening"); let ipc = IpcServer::bind(&config.service.ipc_path, database.node_operators()).await?; - run_until_shutdown(storm, &store, ipc, rest).await?; + run_until_shutdown(storm, &store, ipc, external_api).await?; Ok(()) } @@ -91,7 +91,7 @@ async fn run_until_shutdown( mut storm: HighStorm, store: &NetworkStore, ipc: IpcServer, - rest: RestServer, + external_api: ExternalApiServer, ) -> Result<(), Box> { let mut reconnect = tokio::time::interval(Duration::from_secs(3)); reconnect.set_missed_tick_behavior(MissedTickBehavior::Skip); @@ -105,8 +105,8 @@ async fn run_until_shutdown( tokio::pin!(shutdown); let ipc_task = tokio::spawn(ipc.run()); tokio::pin!(ipc_task); - let rest_task = tokio::spawn(rest.run()); - tokio::pin!(rest_task); + let external_api_task = tokio::spawn(external_api.run()); + tokio::pin!(external_api_task); loop { tokio::select! { _ = &mut shutdown => { @@ -128,16 +128,16 @@ async fn run_until_shutdown( result??; return Err("operator IPC listener stopped unexpectedly".into()); } - result = &mut rest_task => { + result = &mut external_api_task => { result??; - return Err("REST API listener stopped unexpectedly".into()); + return Err("external API listener stopped unexpectedly".into()); } } } ipc_task.abort(); let _ = ipc_task.await; - rest_task.abort(); - let _ = rest_task.await; + external_api_task.abort(); + let _ = external_api_task.await; let peers = storm.peers().await; tracing::info!( peer_count = peers.len(), diff --git a/high-storm/src/rest/mod.rs b/high-storm/src/rest/mod.rs deleted file mode 100644 index 680d7a1..0000000 --- a/high-storm/src/rest/mod.rs +++ /dev/null @@ -1,650 +0,0 @@ -pub mod auth; -mod dto; - -use std::net::SocketAddr; - -use auth::{AccessToken, AuthError, AuthService, Challenge, SignedRequest}; -use axum::{ - Json, Router, - extract::{Path, State}, - http::{HeaderMap, StatusCode}, - response::{IntoResponse, Response}, - routing::{any, get, post}, -}; -use dto::{VotingProposal, VotingResponse}; -use serde::{Deserialize, Serialize}; -use storm::PeerStatus; - -use crate::{HighStormHandle, VotingError, db::node_operator::NodeOperatorStore}; - -#[derive(Clone)] -struct RestState { - node: HighStormHandle, - auth: AuthService, -} - -pub struct RestServer { - listener: tokio::net::TcpListener, - router: Router, -} - -impl RestServer { - pub async fn bind( - address: SocketAddr, - node: HighStormHandle, - operators: NodeOperatorStore, - ) -> std::io::Result { - let listener = tokio::net::TcpListener::bind(address).await?; - Ok(Self { - listener, - router: router(node, operators), - }) - } - - pub fn local_addr(&self) -> std::io::Result { - self.listener.local_addr() - } - - pub async fn run(self) -> std::io::Result<()> { - axum::serve(self.listener, self.router).await - } -} - -pub fn router(node: HighStormHandle, operators: NodeOperatorStore) -> Router { - let state = RestState { - node, - auth: AuthService::new(operators), - }; - Router::new() - .nest( - "/users", - Router::new() - .route("/", any(not_implemented)) - .route("/{*path}", any(not_implemented)), - ) - .route("/operators/auth/challenge", post(issue_challenge)) - .route("/operators/auth/token", post(exchange_token)) - .route("/operators/state", get(get_network_state)) - .route("/operators/state/peers", get(get_network_peers)) - .route("/operators/voting", get(list_votings).post(create_voting)) - .route("/operators/voting/{hash}", get(get_voting)) - .route("/operators/voting/{hash}/approve", post(approve_voting)) - .with_state(state) -} - -#[derive(Deserialize)] -struct ChallengeRequest { - public_key: String, -} - -#[derive(Deserialize)] -struct TokenRequest { - public_key: String, - message: String, - signature: String, -} - -#[derive(Clone, Debug, Default, Deserialize, Serialize)] -struct EmptyPayload {} - -#[derive(Serialize)] -struct CreatedVoting { - message_hash: String, -} - -#[derive(Serialize)] -struct NetworkStateResponse { - block_height: u64, - local_public_key: String, - coordinator_public_key: String, - is_coordinator: bool, - total_peers: usize, - online_peers: usize, - inactive_peers: usize, - banned_peers: usize, - pending_votings: usize, - approved_votings: usize, -} - -#[derive(Serialize)] -struct NetworkPeerResponse { - public_key: String, - socket_address: Option, - last_seen: Option, - status: &'static str, - is_local: bool, - is_coordinator: bool, -} - -#[derive(Serialize)] -struct ErrorBody { - error: String, -} - -struct ApiError { - status: StatusCode, - message: String, -} - -async fn issue_challenge( - State(state): State, - Json(request): Json, -) -> Result, ApiError> { - state - .auth - .issue_challenge(&request.public_key) - .await - .map(Json) - .map_err(Into::into) -} - -async fn exchange_token( - State(state): State, - Json(request): Json, -) -> Result, ApiError> { - state - .auth - .exchange_token(&request.public_key, &request.message, &request.signature) - .await - .map(Json) - .map_err(Into::into) -} - -async fn get_network_state( - State(state): State, - headers: HeaderMap, -) -> Result, ApiError> { - authenticate_bearer(&state.auth, &headers).await?; - let peers = state.node.peers().await; - let votings = state.node.voting_requests().await?; - let coordinator_public_key = state.node.coordinator_public_key(); - let local_public_key = peers - .iter() - .find(|peer| peer.status == PeerStatus::Controlled) - .map(|peer| peer.compressed_public_key) - .ok_or_else(|| ApiError::internal("local peer is missing from the peer table"))?; - let online_peers = peers - .iter() - .filter(|peer| matches!(peer.status, PeerStatus::Controlled | PeerStatus::Active)) - .count(); - let pending_votings = votings - .iter() - .filter(|voting| voting.status == crate::VotingStatus::Pending) - .count(); - - Ok(Json(NetworkStateResponse { - block_height: state.node.block_height(), - local_public_key: hex::encode(local_public_key), - coordinator_public_key: hex::encode(coordinator_public_key), - is_coordinator: local_public_key == coordinator_public_key, - total_peers: peers.len(), - online_peers, - inactive_peers: peers - .iter() - .filter(|peer| peer.status == PeerStatus::Inactive) - .count(), - banned_peers: peers - .iter() - .filter(|peer| peer.status == PeerStatus::Banned) - .count(), - pending_votings, - approved_votings: votings.len() - pending_votings, - })) -} - -async fn get_network_peers( - State(state): State, - headers: HeaderMap, -) -> Result>, ApiError> { - authenticate_bearer(&state.auth, &headers).await?; - let coordinator_public_key = state.node.coordinator_public_key(); - Ok(Json( - state - .node - .peers() - .await - .into_iter() - .map(|peer| NetworkPeerResponse { - public_key: hex::encode(peer.compressed_public_key), - socket_address: peer.socket_address, - last_seen: peer.last_seen, - status: peer_status_name(peer.status), - is_local: peer.status == PeerStatus::Controlled, - is_coordinator: peer.compressed_public_key == coordinator_public_key, - }) - .collect(), - )) -} - -async fn list_votings( - State(state): State, - headers: HeaderMap, -) -> Result>, ApiError> { - authenticate_bearer(&state.auth, &headers).await?; - state - .node - .voting_requests() - .await? - .into_iter() - .map(VotingResponse::try_from) - .collect::, _>>() - .map(Json) - .map_err(ApiError::internal) -} - -async fn get_voting( - State(state): State, - headers: HeaderMap, - Path(hash): Path, -) -> Result, ApiError> { - authenticate_bearer(&state.auth, &headers).await?; - let hash = parse_hash(&hash)?; - let voting = state - .node - .voting_request(hash) - .await? - .ok_or_else(|| ApiError::not_found("voting request does not exist"))?; - VotingResponse::try_from(voting) - .map(Json) - .map_err(ApiError::internal) -} - -async fn create_voting( - State(state): State, - Json(request): Json>, -) -> Result<(StatusCode, Json), ApiError> { - state - .auth - .verify_write(&request, "POST", "/operators/voting") - .await?; - let voting = request - .payload - .into_request() - .map_err(ApiError::bad_request)?; - let message_hash = state.node.create_voting_request(voting).await?; - Ok(( - StatusCode::CREATED, - Json(CreatedVoting { - message_hash: hex::encode(message_hash), - }), - )) -} - -async fn approve_voting( - State(state): State, - Path(hash): Path, - Json(request): Json>, -) -> Result { - let path = format!("/operators/voting/{hash}/approve"); - state.auth.verify_write(&request, "POST", &path).await?; - state - .node - .approve_voting_request(parse_hash(&hash)?) - .await?; - Ok(StatusCode::NO_CONTENT) -} - -async fn authenticate_bearer(auth: &AuthService, headers: &HeaderMap) -> Result<(), ApiError> { - let token = headers - .get(axum::http::header::AUTHORIZATION) - .and_then(|value| value.to_str().ok()) - .and_then(|value| value.strip_prefix("Bearer ")) - .ok_or_else(|| ApiError::unauthorized("missing bearer token"))?; - auth.authenticate_token(token).await?; - Ok(()) -} - -fn parse_hash(encoded: &str) -> Result<[u8; 32], ApiError> { - hex::decode(encoded) - .map_err(|_| ApiError::bad_request("invalid voting request hash"))? - .try_into() - .map_err(|_| ApiError::bad_request("invalid voting request hash")) -} - -fn peer_status_name(status: PeerStatus) -> &'static str { - match status { - PeerStatus::Controlled => "controlled", - PeerStatus::Active => "active", - PeerStatus::Inactive => "inactive", - PeerStatus::Banned => "banned", - } -} - -async fn not_implemented() -> ApiError { - ApiError { - status: StatusCode::NOT_IMPLEMENTED, - message: "user API is not implemented".to_string(), - } -} - -impl ApiError { - fn bad_request(message: impl ToString) -> Self { - Self { - status: StatusCode::BAD_REQUEST, - message: message.to_string(), - } - } - - fn unauthorized(message: impl ToString) -> Self { - Self { - status: StatusCode::UNAUTHORIZED, - message: message.to_string(), - } - } - - fn not_found(message: impl ToString) -> Self { - Self { - status: StatusCode::NOT_FOUND, - message: message.to_string(), - } - } - - fn internal(message: impl ToString) -> Self { - tracing::error!(error = %message.to_string(), "REST request failed"); - Self { - status: StatusCode::INTERNAL_SERVER_ERROR, - message: "internal server error".to_string(), - } - } -} - -impl From for ApiError { - fn from(error: AuthError) -> Self { - let status = match error { - AuthError::InvalidPublicKey - | AuthError::InvalidChallenge - | AuthError::InvalidTimestamp - | AuthError::InvalidNonce => StatusCode::BAD_REQUEST, - AuthError::Unauthorized => StatusCode::FORBIDDEN, - AuthError::ReplayedNonce => StatusCode::CONFLICT, - AuthError::InvalidToken | AuthError::InvalidSignature => StatusCode::UNAUTHORIZED, - AuthError::Clock | AuthError::Random | AuthError::Store(_) => { - return Self::internal(error); - } - }; - Self { - status, - message: error.to_string(), - } - } -} - -impl From for ApiError { - fn from(error: VotingError) -> Self { - let status = match error { - VotingError::InvalidRequest(_) | VotingError::InvalidApproval(_) => { - StatusCode::BAD_REQUEST - } - VotingError::UnknownRequest(_) => StatusCode::NOT_FOUND, - VotingError::DuplicateRequest(_) | VotingError::DuplicateApproval(_) => { - StatusCode::CONFLICT - } - _ => return Self::internal(error), - }; - Self { - status, - message: error.to_string(), - } - } -} - -impl IntoResponse for ApiError { - fn into_response(self) -> Response { - ( - self.status, - Json(ErrorBody { - error: self.message, - }), - ) - .into_response() - } -} - -#[cfg(test)] -mod tests { - use axum::{ - body::Body, - http::{Request, header}, - }; - use bitcoin::{ - Address, CompressedPublicKey, Network, PrivateKey, address::KnownHrp, secp256k1, - }; - use http_body_util::BodyExt; - use secp256k1_zkp::{Secp256k1, SecretKey}; - use storm::{Peer, Storm}; - use tower::ServiceExt; - - use crate::{HighStorm, db::Database}; - - use super::*; - - #[tokio::test] - async fn authenticates_operator_reads_with_a_real_bip322_signature() { - let (app, private_key, public_key) = setup().await; - - let unauthorized = app - .clone() - .oneshot( - Request::builder() - .uri("/operators/voting") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(unauthorized.status(), StatusCode::UNAUTHORIZED); - - let challenge = app - .clone() - .oneshot(json_request( - "/operators/auth/challenge", - serde_json::json!({"public_key": public_key}), - )) - .await - .unwrap(); - assert_eq!(challenge.status(), StatusCode::OK); - let challenge: serde_json::Value = response_json(challenge).await; - let message = challenge["message"].as_str().unwrap(); - let signature = sign(&private_key, message); - - let token = app - .clone() - .oneshot(json_request( - "/operators/auth/token", - serde_json::json!({ - "public_key": public_key, - "message": message, - "signature": signature, - }), - )) - .await - .unwrap(); - assert_eq!(token.status(), StatusCode::OK); - let token: serde_json::Value = response_json(token).await; - - let voting = app - .clone() - .oneshot( - Request::builder() - .uri("/operators/voting") - .header( - header::AUTHORIZATION, - format!("Bearer {}", token["token"].as_str().unwrap()), - ) - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(voting.status(), StatusCode::OK); - assert_eq!(response_json(voting).await, serde_json::json!([])); - - let authorization = format!("Bearer {}", token["token"].as_str().unwrap()); - let network = app - .clone() - .oneshot( - Request::builder() - .uri("/operators/state") - .header(header::AUTHORIZATION, &authorization) - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(network.status(), StatusCode::OK); - let network = response_json(network).await; - assert_eq!(network["total_peers"], 1); - assert_eq!(network["online_peers"], 1); - assert_eq!(network["is_coordinator"], true); - - let peers = app - .clone() - .oneshot( - Request::builder() - .uri("/operators/state/peers") - .header(header::AUTHORIZATION, authorization) - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(peers.status(), StatusCode::OK); - let peers = response_json(peers).await; - assert_eq!(peers.as_array().unwrap().len(), 1); - assert_eq!(peers[0]["status"], "controlled"); - assert_eq!(peers[0]["is_local"], true); - - let users = app - .oneshot( - Request::builder() - .uri("/users/pending") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(users.status(), StatusCode::NOT_IMPLEMENTED); - } - - #[tokio::test] - async fn creates_and_approves_voting_with_signed_requests() { - let (app, private_key, public_key) = setup().await; - let timestamp = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs(); - let proposal = serde_json::json!({ - "kind": "split_storm_eye", - "utxo_to_split": { - "txid": hex::encode([7; 32]), - "output_index": 1 - }, - "number_of_splits": 2 - }); - let create = app - .clone() - .oneshot(signed_request( - &private_key, - &public_key, - "/operators/voting", - timestamp, - "create-voting", - proposal, - )) - .await - .unwrap(); - assert_eq!(create.status(), StatusCode::CREATED); - let created: serde_json::Value = response_json(create).await; - let hash = created["message_hash"].as_str().unwrap(); - let approval_path = format!("/operators/voting/{hash}/approve"); - - let approve = app - .oneshot(signed_request( - &private_key, - &public_key, - &approval_path, - timestamp, - "approve-voting", - serde_json::json!({}), - )) - .await - .unwrap(); - assert_eq!(approve.status(), StatusCode::NO_CONTENT); - } - - async fn setup() -> (Router, PrivateKey, String) { - let database = Database::connect("sqlite::memory:", 1).await.unwrap(); - let operators = database.node_operators(); - let operator_secret = secp256k1::SecretKey::from_slice(&[42; 32]).unwrap(); - let operator_private_key = PrivateKey::new(operator_secret, Network::Bitcoin); - let operator_public_key = operator_private_key - .public_key(&secp256k1::Secp256k1::new()) - .inner - .serialize(); - operators.add(operator_public_key).await.unwrap(); - - let node_secret = SecretKey::from_slice(&[21; 32]).unwrap(); - let node_public_key = node_secret.public_key(&Secp256k1::new()).serialize(); - let storm = Storm::from_peers(node_secret, vec![Peer::new(node_public_key)]); - let node = HighStorm::new( - storm, - node_secret.secret_bytes(), - node_public_key, - database.voting(), - ) - .await; - ( - router(node.handle(), operators), - operator_private_key, - hex::encode(operator_public_key), - ) - } - - fn json_request(uri: &str, body: serde_json::Value) -> Request { - Request::builder() - .method("POST") - .uri(uri) - .header(header::CONTENT_TYPE, "application/json") - .body(Body::from(serde_json::to_vec(&body).unwrap())) - .unwrap() - } - - fn signed_request( - private_key: &PrivateKey, - public_key: &str, - path: &str, - timestamp: u64, - nonce: &str, - payload: serde_json::Value, - ) -> Request { - let message = AuthService::write_message("POST", path, timestamp, nonce, &payload).unwrap(); - json_request( - path, - serde_json::json!({ - "public_key": public_key, - "timestamp": timestamp, - "nonce": nonce, - "signature": sign(private_key, &message), - "payload": payload, - }), - ) - } - - async fn response_json(response: Response) -> serde_json::Value { - let body = response.into_body().collect().await.unwrap().to_bytes(); - serde_json::from_slice(&body).unwrap() - } - - fn sign(private_key: &PrivateKey, message: &str) -> String { - let public_key = - CompressedPublicKey::from_private_key(&secp256k1::Secp256k1::new(), private_key) - .unwrap(); - bip322::sign_simple_encoded( - &Address::p2wpkh(&public_key, KnownHrp::Mainnet).to_string(), - message, - &[private_key.to_wif()], - None, - ) - .unwrap() - } -} diff --git a/high-storm/tests/common/mod.rs b/high-storm/tests/common/mod.rs index f3cb4c4..2e2ab07 100644 --- a/high-storm/tests/common/mod.rs +++ b/high-storm/tests/common/mod.rs @@ -21,7 +21,7 @@ impl TestNode { service: ServiceConfig { port, ipc_path: std::env::temp_dir().join(format!("high-storm-{port}.sock")), - rest_address: "127.0.0.1:0".parse().unwrap(), + external_api_address: "127.0.0.1:0".parse().unwrap(), signer: SignerConfig { private_key: hex::encode(secret.secret_bytes()), }, diff --git a/web/operator/README.md b/web/operator/README.md index a6994dd..d4cd31c 100644 --- a/web/operator/README.md +++ b/web/operator/README.md @@ -11,7 +11,7 @@ bun install bun run dev ``` -Vite proxies `/operators/*` to Compose node 1 at `http://127.0.0.1:9100`. Set `OPERATOR_API_TARGET` to use another high-storm REST address. +Vite proxies `/operators/*` to Compose node 1 at `http://127.0.0.1:9100`. Set `OPERATOR_API_TARGET` to use another high-storm external API address. ```sh OPERATOR_API_TARGET=http://127.0.0.1:9100 bun run dev