pub mod audit;
pub mod broadcast_follow_up;
pub mod handlers;
pub mod types;
use anyhow::Result;
use axum::extract::{DefaultBodyLimit, Request};
use axum::http::{HeaderMap, StatusCode};
use axum::middleware::{self, Next};
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post};
use axum::{Json, Router};
use bsv_wallet_toolbox::{Chain, Services, StorageSqlx, Wallet};
use serde_json::json;
use std::net::SocketAddr;
use std::sync::Arc;
use tower_http::cors::CorsLayer;
use tower_http::trace::TraceLayer;
use handlers::WalletState;
pub type SpendingLock = Arc<tokio::sync::Mutex<()>>;
#[derive(Clone)]
#[allow(dead_code)]
pub struct TlsConfig {
pub cert_path: String,
pub key_path: String,
}
#[derive(Clone)]
pub struct ServerConfig {
pub auth_token: Option<String>,
pub tls: Option<TlsConfig>,
pub chain: Chain,
pub bind_addr: std::net::IpAddr,
pub callback_token: Option<String>,
}
impl Default for ServerConfig {
fn default() -> Self {
Self {
auth_token: None,
tls: None,
chain: Chain::Main,
bind_addr: std::net::IpAddr::from([127, 0, 0, 1]),
callback_token: None,
}
}
}
async fn auth_middleware(headers: HeaderMap, request: Request, next: Next) -> Response {
if request.uri().path() == "/arc-callback" {
return next.run(request).await;
}
let token = request
.extensions()
.get::<ServerConfig>()
.and_then(|c| c.auth_token.clone());
if let Some(expected) = token {
let provided = headers
.get("authorization")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "));
match provided {
Some(t) if t == expected => {}
_ => {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"code": "UNAUTHORIZED", "message": "Invalid or missing bearer token"})),
)
.into_response();
}
}
}
next.run(request).await
}
pub fn make_router(wallet: WalletState, config: ServerConfig) -> Router {
let cors = CorsLayer::very_permissive();
let cfg = config.clone();
let spending_lock: SpendingLock = Arc::new(tokio::sync::Mutex::new(()));
let verifier = crate::broadcast_verify::BroadcastVerifier::from_env(config.chain);
Router::new()
.route(
"/isAuthenticated",
get(handlers::is_authenticated).post(handlers::is_authenticated),
)
.route("/getPublicKey", post(handlers::get_public_key))
.route("/permission-audit", get(permission_audit))
.route("/permission-audit/reset", post(permission_audit_reset))
.route("/createSignature", post(handlers::create_signature))
.route("/createAction", post(handlers::create_action))
.route("/internalizeAction", post(handlers::internalize_action))
.route(
"/getHeight",
get(handlers::get_height).post(handlers::get_height),
)
.route(
"/getNetwork",
get(handlers::get_network).post(handlers::get_network),
)
.route(
"/getVersion",
get(handlers::get_version).post(handlers::get_version),
)
.route(
"/waitForAuthentication",
get(handlers::wait_for_authentication).post(handlers::wait_for_authentication),
)
.route("/getHeaderForHeight", post(handlers::get_header_for_height))
.route("/verifySignature", post(handlers::verify_signature))
.route("/encrypt", post(handlers::encrypt))
.route("/decrypt", post(handlers::decrypt))
.route("/createHmac", post(handlers::create_hmac))
.route("/verifyHmac", post(handlers::verify_hmac))
.route("/signAction", post(handlers::sign_action))
.route("/abortAction", post(handlers::abort_action))
.route("/listActions", post(handlers::list_actions))
.route("/listOutputs", post(handlers::list_outputs))
.route("/relinquishOutput", post(handlers::relinquish_output))
.route("/acquireCertificate", post(handlers::acquire_certificate))
.route("/listCertificates", post(handlers::list_certificates))
.route("/proveCertificate", post(handlers::prove_certificate))
.route(
"/relinquishCertificate",
post(handlers::relinquish_certificate),
)
.route(
"/discoverByIdentityKey",
post(handlers::discover_by_identity_key),
)
.route(
"/discoverByAttributes",
post(handlers::discover_by_attributes),
)
.route(
"/revealCounterpartyKeyLinkage",
post(handlers::reveal_counterparty_key_linkage),
)
.route(
"/revealSpecificKeyLinkage",
post(handlers::reveal_specific_key_linkage),
)
.route("/arc-callback", post(arc_callback))
.layer(cors)
.layer(middleware::from_fn(auth_middleware))
.layer(middleware::from_fn(lenient_json_body))
.layer(axum::Extension(cfg))
.layer(axum::Extension(spending_lock))
.layer(axum::Extension(verifier))
.layer(TraceLayer::new_for_http())
.layer(DefaultBodyLimit::max(50 * 1024 * 1024))
.with_state(wallet)
}
async fn arc_callback(
axum::extract::State(wallet): axum::extract::State<WalletState>,
request: Request,
) -> Response {
let expected = request
.extensions()
.get::<ServerConfig>()
.and_then(|c| c.callback_token.clone());
let Some(expected) = expected else {
return (
StatusCode::NOT_FOUND,
Json(json!({"code": "CALLBACK_DISABLED", "message": "no callback token configured"})),
)
.into_response();
};
let headers = request.headers().clone();
let provided = headers
.get("authorization")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.map(str::to_string)
.or_else(|| {
headers
.get("x-callbacktoken")
.and_then(|v| v.to_str().ok())
.map(str::to_string)
});
if provided.as_deref() != Some(expected.as_str()) {
tracing::warn!(
has_authorization = headers.contains_key("authorization"),
has_x_callbacktoken = headers.contains_key("x-callbacktoken"),
"arc-callback: rejected POST with invalid/missing callback token"
);
return (
StatusCode::UNAUTHORIZED,
Json(json!({"code": "UNAUTHORIZED", "message": "invalid or missing callback token"})),
)
.into_response();
}
let bytes = match axum::body::to_bytes(request.into_body(), 1_000_000).await {
Ok(b) => b,
Err(_) => {
return (
StatusCode::PAYLOAD_TOO_LARGE,
Json(json!({"code": "TOO_LARGE", "message": "payload too large"})),
)
.into_response();
}
};
let payload: serde_json::Value = match serde_json::from_slice(&bytes) {
Ok(v) => v,
Err(e) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"code": "BAD_JSON", "message": e.to_string()})),
)
.into_response();
}
};
match crate::arc_ingest::ingest_arc_payload(wallet.storage(), &payload).await {
Ok(action) => (
StatusCode::OK,
Json(json!({"ok": true, "action": format!("{:?}", action)})),
)
.into_response(),
Err(e) => (
StatusCode::BAD_REQUEST,
Json(json!({"code": "BAD_PAYLOAD", "message": e.to_string()})),
)
.into_response(),
}
}
pub fn sanitize_lone_surrogates(input: &[u8]) -> std::borrow::Cow<'_, [u8]> {
if !input.windows(2).any(|w| w == b"\\u") {
return std::borrow::Cow::Borrowed(input);
}
let hex4 = |b: &[u8]| -> Option<u32> {
if b.len() < 4 {
return None;
}
std::str::from_utf8(&b[..4])
.ok()
.and_then(|h| u32::from_str_radix(h, 16).ok())
};
let mut out = Vec::with_capacity(input.len());
let mut i = 0;
let mut changed = false;
while i < input.len() {
if input[i] == b'\\' && i + 1 < input.len() && input[i + 1] == b'u' {
if let Some(cu) = hex4(&input[i + 2..]) {
let is_lead = (0xD800..=0xDBFF).contains(&cu);
let is_trail = (0xDC00..=0xDFFF).contains(&cu);
if is_lead {
let next = &input[i + 6..];
let paired = next.len() >= 6
&& next[0] == b'\\'
&& next[1] == b'u'
&& hex4(&next[2..]).is_some_and(|t| (0xDC00..=0xDFFF).contains(&t));
if paired {
out.extend_from_slice(&input[i..i + 12]);
i += 12;
continue;
}
out.extend_from_slice(b"\\ufffd");
changed = true;
i += 6;
continue;
}
if is_trail {
out.extend_from_slice(b"\\ufffd");
changed = true;
i += 6;
continue;
}
out.extend_from_slice(&input[i..i + 6]);
i += 6;
continue;
}
}
out.push(input[i]);
i += 1;
}
if changed {
std::borrow::Cow::Owned(out)
} else {
std::borrow::Cow::Borrowed(input)
}
}
async fn lenient_json_body(request: Request, next: Next) -> Response {
let is_json = request
.headers()
.get(axum::http::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.is_some_and(|ct| ct.to_ascii_lowercase().contains("json"));
if !is_json {
return next.run(request).await;
}
let (parts, body) = request.into_parts();
let bytes = match axum::body::to_bytes(body, 50 * 1024 * 1024).await {
Ok(b) => b,
Err(_) => {
return (
StatusCode::PAYLOAD_TOO_LARGE,
Json(json!({"code": "TOO_LARGE", "message": "payload too large"})),
)
.into_response();
}
};
let body = match sanitize_lone_surrogates(&bytes) {
std::borrow::Cow::Borrowed(_) => axum::body::Body::from(bytes),
std::borrow::Cow::Owned(fixed) => {
tracing::warn!(
path = %parts.uri.path(),
"json body carried a lone UTF-16 surrogate escape; substituted U+FFFD"
);
axum::body::Body::from(fixed)
}
};
next.run(Request::from_parts(parts, body)).await
}
pub async fn run(wallet: WalletState, port: u16, config: ServerConfig) -> Result<()> {
let tls = config.tls.clone();
let bind_addr = config.bind_addr;
let app = make_router(wallet, config);
let addr = SocketAddr::from((bind_addr, port));
#[cfg(feature = "tls")]
if let Some(tls_cfg) = tls {
use axum_server::tls_rustls::RustlsConfig;
let rustls = RustlsConfig::from_pem_file(&tls_cfg.cert_path, &tls_cfg.key_path).await?;
tracing::info!("HTTPS server listening on {addr}");
eprintln!("HTTPS server listening on {addr}");
axum_server::bind_rustls(addr, rustls)
.serve(app.into_make_service())
.await?;
return Ok(());
}
#[cfg(not(feature = "tls"))]
if tls.is_some() {
anyhow::bail!("TLS requested but binary was built without `--features tls`");
}
tracing::info!("HTTP server listening on {addr}");
eprintln!("HTTP server listening on {addr}");
let listener = tokio::net::TcpListener::bind(addr).await?;
axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal())
.await?;
Ok(())
}
pub fn make_wallet_state(wallet: Wallet<StorageSqlx, Services>) -> WalletState {
Arc::new(wallet)
}
#[allow(dead_code)] pub fn make_wallet_state_from_arc(wallet: Arc<Wallet<StorageSqlx, Services>>) -> WalletState {
wallet
}
async fn shutdown_signal() {
tokio::signal::ctrl_c()
.await
.expect("failed to listen for ctrl-c");
eprintln!("\nShutting down...");
}
async fn permission_audit() -> Json<serde_json::Value> {
let entries = audit::snapshot();
Json(json!({ "count": entries.len(), "entries": entries }))
}
async fn permission_audit_reset() -> Json<serde_json::Value> {
audit::reset();
Json(json!({ "ok": true }))
}
#[cfg(test)]
mod lenient_json_tests {
use super::sanitize_lone_surrogates;
#[test]
fn lone_lead_and_lone_trail_become_fffd_and_pairs_survive() {
let lead = br#"{"keyID":"ab \ud83d cd"}"#;
assert_eq!(
&*sanitize_lone_surrogates(lead),
br#"{"keyID":"ab \ufffd cd"}"#
);
let trail = br#"{"keyID":"\udc00"}"#;
assert_eq!(&*sanitize_lone_surrogates(trail), br#"{"keyID":"\ufffd"}"#);
let pair = br#"{"keyID":"\ud83d\ude00"}"#;
assert!(matches!(
sanitize_lone_surrogates(pair),
std::borrow::Cow::Borrowed(_)
));
let plain = br#"{"keyID":"plain \\u0041"}"#;
assert!(matches!(
sanitize_lone_surrogates(plain),
std::borrow::Cow::Borrowed(_)
));
let fixed = sanitize_lone_surrogates(lead);
let v: serde_json::Value = serde_json::from_slice(&fixed).unwrap();
assert_eq!(v["keyID"], "ab \u{fffd} cd");
}
}