1pub mod audit;
2pub mod broadcast_follow_up;
3pub mod handlers;
4pub mod post_word;
5pub mod types;
6
7use anyhow::Result;
8use axum::extract::{DefaultBodyLimit, Request};
9use axum::http::{HeaderMap, StatusCode};
10use axum::middleware::{self, Next};
11use axum::response::{IntoResponse, Response};
12use axum::routing::{get, post};
13use axum::{Json, Router};
14use bsv_wallet_toolbox::{Chain, Services, StorageSqlx, Wallet};
15use serde_json::json;
16use std::net::SocketAddr;
17use std::sync::Arc;
18use tower_http::cors::CorsLayer;
19use tower_http::trace::TraceLayer;
20
21use handlers::WalletState;
22
23pub type SpendingLock = Arc<tokio::sync::Mutex<()>>;
30
31#[derive(Clone)]
33#[allow(dead_code)]
34pub struct TlsConfig {
35 pub cert_path: String,
36 pub key_path: String,
37}
38
39#[derive(Clone)]
41pub struct ServerConfig {
42 pub auth_token: Option<String>,
45 pub tls: Option<TlsConfig>,
47 pub chain: Chain,
50 pub bind_addr: std::net::IpAddr,
54 pub callback_token: Option<String>,
58}
59
60impl Default for ServerConfig {
61 fn default() -> Self {
62 Self {
63 auth_token: None,
64 tls: None,
65 chain: Chain::Main,
66 bind_addr: std::net::IpAddr::from([127, 0, 0, 1]),
67 callback_token: None,
68 }
69 }
70}
71
72pub fn bind_addr_from_env() -> std::net::IpAddr {
75 std::env::var("BIND_ADDR")
76 .ok()
77 .and_then(|v| v.parse().ok())
78 .unwrap_or_else(|| std::net::IpAddr::from([127, 0, 0, 1]))
79}
80
81pub fn refuse_open_bind(
87 bind_addr: std::net::IpAddr,
88 auth_token: Option<&str>,
89 allow_no_token: bool,
90) -> Result<()> {
91 let has_token = auth_token.is_some_and(|t| !t.is_empty());
92 if has_token || allow_no_token || bind_addr.to_canonical().is_loopback() {
93 return Ok(());
94 }
95 anyhow::bail!(
96 "refusing to bind {bind_addr}: no bearer token is configured, and a wallet served \
97 beyond loopback without one answers anyone who can reach it. Set AUTH_TOKEN, or pass \
98 --allow-no-token to bind it open deliberately."
99 )
100}
101
102pub fn refuse_open_bind_from_env(allow_no_token: bool) -> Result<()> {
105 let token = std::env::var("AUTH_TOKEN").ok();
106 refuse_open_bind(bind_addr_from_env(), token.as_deref(), allow_no_token)
107}
108
109async fn auth_middleware(headers: HeaderMap, request: Request, next: Next) -> Response {
111 if request.uri().path() == "/arc-callback" {
115 return next.run(request).await;
116 }
117
118 let token = request
120 .extensions()
121 .get::<ServerConfig>()
122 .and_then(|c| c.auth_token.clone());
123
124 if let Some(expected) = token {
125 let provided = headers
126 .get("authorization")
127 .and_then(|v| v.to_str().ok())
128 .and_then(|v| v.strip_prefix("Bearer "));
129
130 match provided {
131 Some(t) if t == expected => {}
132 _ => {
133 return (
134 StatusCode::UNAUTHORIZED,
135 Json(json!({"code": "UNAUTHORIZED", "message": "Invalid or missing bearer token"})),
136 )
137 .into_response();
138 }
139 }
140 }
141
142 next.run(request).await
143}
144
145pub fn make_router(wallet: WalletState, config: ServerConfig) -> Router {
147 let cors = CorsLayer::very_permissive();
148 let cfg = config.clone();
149 let spending_lock: SpendingLock = Arc::new(tokio::sync::Mutex::new(()));
150 let verifier = crate::broadcast_verify::BroadcastVerifier::from_env(config.chain);
154
155 Router::new()
156 .route(
161 "/isAuthenticated",
162 get(handlers::is_authenticated).post(handlers::is_authenticated),
163 )
164 .route("/getPublicKey", post(handlers::get_public_key))
165 .route("/permission-audit", get(permission_audit))
169 .route("/permission-audit/reset", post(permission_audit_reset))
170 .route("/chain-feed", get(chain_feed_status))
173 .route("/tx/{txid}", get(tx_word))
177 .route("/createSignature", post(handlers::create_signature))
178 .route(
182 "/createAction",
183 post(handlers::create_action).layer(DefaultBodyLimit::disable()),
184 )
185 .route(
186 "/internalizeAction",
187 post(handlers::internalize_action).layer(DefaultBodyLimit::disable()),
188 )
189 .route(
191 "/getHeight",
192 get(handlers::get_height).post(handlers::get_height),
193 )
194 .route(
195 "/getNetwork",
196 get(handlers::get_network).post(handlers::get_network),
197 )
198 .route(
199 "/getVersion",
200 get(handlers::get_version).post(handlers::get_version),
201 )
202 .route(
203 "/waitForAuthentication",
204 get(handlers::wait_for_authentication).post(handlers::wait_for_authentication),
205 )
206 .route("/getHeaderForHeight", post(handlers::get_header_for_height))
208 .route("/verifySignature", post(handlers::verify_signature))
210 .route("/encrypt", post(handlers::encrypt))
211 .route("/decrypt", post(handlers::decrypt))
212 .route("/createHmac", post(handlers::create_hmac))
213 .route("/verifyHmac", post(handlers::verify_hmac))
214 .route("/signAction", post(handlers::sign_action))
216 .route("/abortAction", post(handlers::abort_action))
217 .route("/listActions", post(handlers::list_actions))
218 .route("/listOutputs", post(handlers::list_outputs))
219 .route("/relinquishOutput", post(handlers::relinquish_output))
220 .route("/acquireCertificate", post(handlers::acquire_certificate))
222 .route("/listCertificates", post(handlers::list_certificates))
223 .route("/proveCertificate", post(handlers::prove_certificate))
224 .route(
225 "/relinquishCertificate",
226 post(handlers::relinquish_certificate),
227 )
228 .route(
230 "/discoverByIdentityKey",
231 post(handlers::discover_by_identity_key),
232 )
233 .route(
234 "/discoverByAttributes",
235 post(handlers::discover_by_attributes),
236 )
237 .route(
238 "/revealCounterpartyKeyLinkage",
239 post(handlers::reveal_counterparty_key_linkage),
240 )
241 .route(
242 "/revealSpecificKeyLinkage",
243 post(handlers::reveal_specific_key_linkage),
244 )
245 .route("/arc-callback", post(arc_callback))
248 .layer(cors)
252 .layer(middleware::from_fn(lenient_json_body))
253 .layer(middleware::from_fn(auth_middleware))
254 .layer(axum::Extension(cfg))
255 .layer(axum::Extension(spending_lock))
256 .layer(axum::Extension(verifier))
257 .layer(TraceLayer::new_for_http())
258 .layer(DefaultBodyLimit::max(BODY_CAP))
259 .with_state(wallet)
260}
261
262async fn arc_callback(
270 axum::extract::State(wallet): axum::extract::State<WalletState>,
271 request: Request,
272) -> Response {
273 let expected = request
274 .extensions()
275 .get::<ServerConfig>()
276 .and_then(|c| c.callback_token.clone());
277 let Some(expected) = expected else {
278 return (
279 StatusCode::NOT_FOUND,
280 Json(json!({"code": "CALLBACK_DISABLED", "message": "no callback token configured"})),
281 )
282 .into_response();
283 };
284
285 let headers = request.headers().clone();
286 let provided = headers
287 .get("authorization")
288 .and_then(|v| v.to_str().ok())
289 .and_then(|v| v.strip_prefix("Bearer "))
290 .map(str::to_string)
291 .or_else(|| {
292 headers
293 .get("x-callbacktoken")
294 .and_then(|v| v.to_str().ok())
295 .map(str::to_string)
296 });
297
298 if provided.as_deref() != Some(expected.as_str()) {
299 tracing::warn!(
303 has_authorization = headers.contains_key("authorization"),
304 has_x_callbacktoken = headers.contains_key("x-callbacktoken"),
305 "arc-callback: rejected POST with invalid/missing callback token"
306 );
307 return (
308 StatusCode::UNAUTHORIZED,
309 Json(json!({"code": "UNAUTHORIZED", "message": "invalid or missing callback token"})),
310 )
311 .into_response();
312 }
313
314 let bytes = match axum::body::to_bytes(request.into_body(), 1_000_000).await {
315 Ok(b) => b,
316 Err(_) => {
317 return (
318 StatusCode::PAYLOAD_TOO_LARGE,
319 Json(json!({"code": "TOO_LARGE", "message": "payload too large"})),
320 )
321 .into_response();
322 }
323 };
324 let payload: serde_json::Value = match serde_json::from_slice(&bytes) {
325 Ok(v) => v,
326 Err(e) => {
327 return (
328 StatusCode::BAD_REQUEST,
329 Json(json!({"code": "BAD_JSON", "message": e.to_string()})),
330 )
331 .into_response();
332 }
333 };
334
335 match crate::arc_ingest::ingest_arc_payload(wallet.storage(), &payload).await {
336 Ok(action) => (
337 StatusCode::OK,
338 Json(json!({"ok": true, "action": format!("{:?}", action)})),
339 )
340 .into_response(),
341 Err(e) => (
342 StatusCode::BAD_REQUEST,
343 Json(json!({"code": "BAD_PAYLOAD", "message": e.to_string()})),
344 )
345 .into_response(),
346 }
347}
348
349pub fn sanitize_lone_surrogates(input: &[u8]) -> std::borrow::Cow<'_, [u8]> {
357 if !input.windows(2).any(|w| w == b"\\u") {
358 return std::borrow::Cow::Borrowed(input);
359 }
360 let hex4 = |b: &[u8]| -> Option<u32> {
361 if b.len() < 4 {
362 return None;
363 }
364 std::str::from_utf8(&b[..4])
365 .ok()
366 .and_then(|h| u32::from_str_radix(h, 16).ok())
367 };
368 let mut out = Vec::with_capacity(input.len());
369 let mut i = 0;
370 let mut changed = false;
371 while i < input.len() {
372 if input[i] == b'\\' && i + 1 < input.len() && input[i + 1] == b'u' {
373 if let Some(cu) = hex4(&input[i + 2..]) {
374 let is_lead = (0xD800..=0xDBFF).contains(&cu);
375 let is_trail = (0xDC00..=0xDFFF).contains(&cu);
376 if is_lead {
377 let next = &input[i + 6..];
378 let paired = next.len() >= 6
379 && next[0] == b'\\'
380 && next[1] == b'u'
381 && hex4(&next[2..]).is_some_and(|t| (0xDC00..=0xDFFF).contains(&t));
382 if paired {
383 out.extend_from_slice(&input[i..i + 12]);
384 i += 12;
385 continue;
386 }
387 out.extend_from_slice(b"\\ufffd");
388 changed = true;
389 i += 6;
390 continue;
391 }
392 if is_trail {
393 out.extend_from_slice(b"\\ufffd");
394 changed = true;
395 i += 6;
396 continue;
397 }
398 out.extend_from_slice(&input[i..i + 6]);
399 i += 6;
400 continue;
401 }
402 }
403 out.push(input[i]);
404 i += 1;
405 }
406 if changed {
407 std::borrow::Cow::Owned(out)
408 } else {
409 std::borrow::Cow::Borrowed(input)
410 }
411}
412
413const BEEF_DOORS: [&str; 2] = ["/createAction", "/internalizeAction"];
420
421const BODY_CAP: usize = 50 * 1024 * 1024;
423
424async fn lenient_json_body(request: Request, next: Next) -> Response {
427 let is_json = request
428 .headers()
429 .get(axum::http::header::CONTENT_TYPE)
430 .and_then(|v| v.to_str().ok())
431 .is_some_and(|ct| ct.to_ascii_lowercase().contains("json"));
432 if !is_json {
433 return next.run(request).await;
434 }
435 let (parts, body) = request.into_parts();
436 let cap = if BEEF_DOORS.contains(&parts.uri.path()) {
437 usize::MAX
438 } else {
439 BODY_CAP
440 };
441 let bytes = match axum::body::to_bytes(body, cap).await {
442 Ok(b) => b,
443 Err(_) => {
444 return (
445 StatusCode::PAYLOAD_TOO_LARGE,
446 Json(json!({"code": "TOO_LARGE", "message": "payload too large"})),
447 )
448 .into_response();
449 }
450 };
451 let body = match sanitize_lone_surrogates(&bytes) {
452 std::borrow::Cow::Borrowed(_) => axum::body::Body::from(bytes),
453 std::borrow::Cow::Owned(fixed) => {
454 tracing::warn!(
455 path = %parts.uri.path(),
456 "json body carried a lone UTF-16 surrogate escape; substituted U+FFFD"
457 );
458 axum::body::Body::from(fixed)
459 }
460 };
461 next.run(Request::from_parts(parts, body)).await
462}
463
464pub async fn run(wallet: WalletState, port: u16, config: ServerConfig) -> Result<()> {
465 let tls = config.tls.clone();
466 let bind_addr = config.bind_addr;
467 let app = make_router(wallet, config);
468 let addr = SocketAddr::from((bind_addr, port));
469
470 #[cfg(feature = "tls")]
471 if let Some(tls_cfg) = tls {
472 use axum_server::tls_rustls::RustlsConfig;
473 let rustls = RustlsConfig::from_pem_file(&tls_cfg.cert_path, &tls_cfg.key_path).await?;
474 tracing::info!("HTTPS server listening on {addr}");
475 eprintln!("HTTPS server listening on {addr}");
476 axum_server::bind_rustls(addr, rustls)
477 .serve(app.into_make_service())
478 .await?;
479 return Ok(());
480 }
481
482 #[cfg(not(feature = "tls"))]
483 if tls.is_some() {
484 anyhow::bail!("TLS requested but binary was built without `--features tls`");
485 }
486
487 tracing::info!("HTTP server listening on {addr}");
488 eprintln!("HTTP server listening on {addr}");
489
490 let listener = tokio::net::TcpListener::bind(addr).await?;
491 axum::serve(listener, app)
492 .with_graceful_shutdown(shutdown_signal())
493 .await?;
494
495 Ok(())
496}
497
498pub fn make_wallet_state(wallet: Wallet<StorageSqlx, Services>) -> WalletState {
499 Arc::new(wallet)
500}
501
502#[allow(dead_code)] pub fn make_wallet_state_from_arc(wallet: Arc<Wallet<StorageSqlx, Services>>) -> WalletState {
511 wallet
512}
513
514async fn shutdown_signal() {
515 tokio::signal::ctrl_c()
516 .await
517 .expect("failed to listen for ctrl-c");
518 eprintln!("\nShutting down...");
519}
520
521async fn permission_audit() -> Json<serde_json::Value> {
523 let entries = audit::snapshot();
524 Json(json!({ "count": entries.len(), "entries": entries }))
525}
526
527async fn chain_feed_status(
530 axum::extract::State(wallet): axum::extract::State<WalletState>,
531) -> Response {
532 let storage = wallet.storage();
533 let status = crate::chain_feed::read_status(storage).await;
534 let cursor = crate::tracker_host::feed_cursor(storage).await;
535 let frozen = crate::tracker_host::frozen_count(storage.pool()).await;
536 match (status, cursor, frozen) {
537 (Ok(status), Ok(cursor), Ok(frozen)) => Json(json!({
538 "status": status,
539 "cursor": cursor.to_string(),
540 "frozenCount": frozen,
541 }))
542 .into_response(),
543 (Err(e), _, _) | (_, Err(e), _) | (_, _, Err(e)) => (
544 StatusCode::INTERNAL_SERVER_ERROR,
545 Json(json!({ "code": "ERR_CHAIN_FEED", "message": e.to_string() })),
546 )
547 .into_response(),
548 }
549}
550
551async fn tx_word(
558 axum::extract::State(wallet): axum::extract::State<WalletState>,
559 axum::extract::Path(txid): axum::extract::Path<String>,
560) -> Response {
561 let txid = txid.trim().to_ascii_lowercase();
562 if txid.len() != 64 || !txid.bytes().all(|b| b.is_ascii_hexdigit()) {
563 return (
564 StatusCode::BAD_REQUEST,
565 Json(
566 json!({ "code": "VALIDATION_ERROR", "message": "txid must be 64 hex characters" }),
567 ),
568 )
569 .into_response();
570 }
571 let storage = wallet.storage();
572 let fault = |e: anyhow::Error| {
573 (
574 StatusCode::INTERNAL_SERVER_ERROR,
575 Json(json!({ "code": "ERR_TX_WORD", "message": e.to_string() })),
576 )
577 .into_response()
578 };
579 let (word, height, reask) =
580 match crate::tracker_host::stored_word(storage, &txid).await {
581 Ok(Some(view)) => view,
582 Ok(None) => return (
583 StatusCode::NOT_FOUND,
584 Json(
585 json!({ "code": "NOT_FOUND", "message": format!("no word stored for {txid}") }),
586 ),
587 )
588 .into_response(),
589 Err(e) => return fault(e),
590 };
591 let evidence = if word == "mined" {
592 let row: Result<Option<(String, i64)>, sqlx::Error> =
593 sqlx::query_as("SELECT merkle_root, height FROM proven_txs WHERE lower(txid) = ?")
594 .bind(&txid)
595 .fetch_optional(storage.pool())
596 .await;
597 match row {
598 Ok(row) => row.map(|(root, height)| json!({ "root": root, "height": height })),
599 Err(e) => return fault(e.into()),
600 }
601 } else {
602 None
603 };
604 Json(json!({
605 "txid": txid,
606 "word": word,
607 "height": height,
608 "reask": reask,
609 "evidence": evidence,
610 }))
611 .into_response()
612}
613
614async fn permission_audit_reset() -> Json<serde_json::Value> {
616 audit::reset();
617 Json(json!({ "ok": true }))
618}
619
620#[cfg(test)]
621mod lenient_json_tests {
622 use super::sanitize_lone_surrogates;
623
624 #[test]
625 fn lone_lead_and_lone_trail_become_fffd_and_pairs_survive() {
626 let lead = br#"{"keyID":"ab \ud83d cd"}"#;
627 assert_eq!(
628 &*sanitize_lone_surrogates(lead),
629 br#"{"keyID":"ab \ufffd cd"}"#
630 );
631 let trail = br#"{"keyID":"\udc00"}"#;
632 assert_eq!(&*sanitize_lone_surrogates(trail), br#"{"keyID":"\ufffd"}"#);
633 let pair = br#"{"keyID":"\ud83d\ude00"}"#;
634 assert!(matches!(
635 sanitize_lone_surrogates(pair),
636 std::borrow::Cow::Borrowed(_)
637 ));
638 let plain = br#"{"keyID":"plain \\u0041"}"#;
639 assert!(matches!(
640 sanitize_lone_surrogates(plain),
641 std::borrow::Cow::Borrowed(_)
642 ));
643 let fixed = sanitize_lone_surrogates(lead);
644 let v: serde_json::Value = serde_json::from_slice(&fixed).unwrap();
645 assert_eq!(v["keyID"], "ab \u{fffd} cd");
646 }
647}
648
649#[cfg(test)]
650mod bind_guard_tests {
651 use super::refuse_open_bind;
652 use std::net::IpAddr;
653
654 fn ip(s: &str) -> IpAddr {
655 s.parse().unwrap()
656 }
657
658 #[test]
659 fn beyond_loopback_needs_a_token_or_the_flag() {
660 for open in ["0.0.0.0", "::", "192.168.1.10", "10.0.0.1"] {
661 let err = refuse_open_bind(ip(open), None, false).unwrap_err();
662 assert!(
663 err.to_string().contains("--allow-no-token"),
664 "{open}: {err}"
665 );
666 assert!(
667 refuse_open_bind(ip(open), Some(""), false).is_err(),
668 "{open}"
669 );
670 assert!(
671 refuse_open_bind(ip(open), Some("t"), false).is_ok(),
672 "{open}"
673 );
674 assert!(refuse_open_bind(ip(open), None, true).is_ok(), "{open}");
675 }
676 }
677
678 #[test]
679 fn loopback_is_unchanged() {
680 for lo in ["127.0.0.1", "127.0.0.2", "::1", "::ffff:127.0.0.1"] {
681 assert!(refuse_open_bind(ip(lo), None, false).is_ok(), "{lo}");
682 }
683 }
684}