diff --git a/crates/cashu/src/nuts/nut17/ws.rs b/crates/cashu/src/nuts/nut17/ws.rs index 30b89ebb6..1d26dd72c 100644 --- a/crates/cashu/src/nuts/nut17/ws.rs +++ b/crates/cashu/src/nuts/nut17/ws.rs @@ -69,6 +69,14 @@ pub struct RawNotificationInner { pub payload: serde_json::Value, } +/// The response to an authenticate request (NUT-22) +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "status", rename_all = "UPPERCASE")] +pub enum WsAuthenticateResponse { + /// Authentication succeeded + Ok, +} + /// Responses from the web socket server #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(bound = "I: Serialize + DeserializeOwned")] @@ -78,6 +86,12 @@ pub enum WsResponseResult { Subscribe(WsSubscribeResponse), /// Unsubscribe Unsubscribe(WsUnsubscribeResponse), + /// A response to an authenticate request + /// + /// Declared last so untagged deserialization tries the subscribe and + /// unsubscribe variants first: both require a `subId`, so a body without + /// one only matches here. + Authenticate(WsAuthenticateResponse), } impl From> for WsResponseResult { @@ -92,6 +106,12 @@ impl From> for WsResponseResult { } } +impl From for WsResponseResult { + fn from(response: WsAuthenticateResponse) -> Self { + WsResponseResult::Authenticate(response) + } +} + /// The request to unsubscribe #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(bound = "I: Serialize + DeserializeOwned")] @@ -101,6 +121,17 @@ pub struct WsUnsubscribeRequest { pub sub_id: I, } +/// The request to authenticate a connection (NUT-22) +/// +/// Carries a blind authentication token (BAT), the serialized `authA...` +/// string, so browser wallets can authenticate a protected connection in-band +/// (the WebSocket API cannot set the `Blind-auth` header). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WsAuthenticateRequest { + /// The blind authentication token + pub token: String, +} + /// The inner method of the websocket request #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "snake_case", tag = "method", content = "params")] @@ -110,6 +141,8 @@ pub enum WsMethodRequest { Subscribe(Params), /// Unsubscribe method Unsubscribe(WsUnsubscribeRequest), + /// Authenticate method (NUT-22) + Authenticate(WsAuthenticateRequest), } /// Websocket request @@ -258,4 +291,47 @@ mod tests { other => panic!("expected notification, got {:?}", other), } } + + #[test] + fn authenticate_request_round_trips() { + let request: WsRequest = ( + WsMethodRequest::Authenticate(WsAuthenticateRequest { + token: "authAeyJ0ZXN0IjoxfQ".to_string(), + }), + 0, + ) + .into(); + + let json = serde_json::to_value(&request).expect("serialize authenticate"); + assert_eq!(json["method"], "authenticate"); + assert_eq!(json["params"]["token"], "authAeyJ0ZXN0IjoxfQ"); + assert_eq!(json["id"], 0); + + let decoded: WsRequest = + serde_json::from_value(json).expect("deserialize authenticate"); + match decoded.method { + WsMethodRequest::Authenticate(req) => assert_eq!(req.token, "authAeyJ0ZXN0IjoxfQ"), + other => panic!("expected authenticate, got {:?}", other), + } + } + + #[test] + fn authenticate_response_is_distinct_from_subscribe() { + // An authenticate OK body has no subId, so untagged decoding must not + // mistake it for a subscribe/unsubscribe response. + let decoded: WsResponseResult = + serde_json::from_str(r#"{"status":"OK"}"#).expect("authenticate response"); + assert!(matches!(decoded, WsResponseResult::Authenticate(_))); + + let decoded: WsResponseResult = + serde_json::from_str(r#"{"status":"OK","subId":"sub-1"}"#).expect("subscribe response"); + assert!(matches!(decoded, WsResponseResult::Subscribe(_))); + } + + #[test] + fn authenticate_response_serializes_with_status_ok() { + let result: WsResponseResult = WsAuthenticateResponse::Ok.into(); + let json = serde_json::to_value(&result).expect("serialize authenticate response"); + assert_eq!(json, serde_json::json!({ "status": "OK" })); + } } diff --git a/crates/cdk-axum/src/router_handlers.rs b/crates/cdk-axum/src/router_handlers.rs index c5837393f..b3dbaec55 100644 --- a/crates/cdk-axum/src/router_handlers.rs +++ b/crates/cdk-axum/src/router_handlers.rs @@ -6,7 +6,7 @@ use axum::response::{IntoResponse, Response}; use cdk::error::ErrorResponse; use cdk::nuts::nut21::{Method, ProtectedEndpoint, RoutePath}; use cdk::nuts::{ - CheckStateRequest, CheckStateResponse, Id, KeysResponse, KeysetResponse, MintInfo, + AuthToken, CheckStateRequest, CheckStateResponse, Id, KeysResponse, KeysetResponse, MintInfo, RestoreRequest, RestoreResponse, SwapRequest, SwapResponse, }; use cdk::util::unix_time; @@ -129,16 +129,33 @@ pub(crate) async fn ws_handler( State(state): State, ws: WebSocketUpgrade, ) -> Result { - state + let endpoint = ProtectedEndpoint::new(Method::Get, RoutePath::Ws); + let token: Option = auth.into(); + + // A browser WebSocket cannot set the `Blind-auth` header, so a header-less + // upgrade to a protected endpoint is allowed and deferred to the in-band + // NUT-22 `authenticate` command instead of being rejected here. + let authenticated = match state .mint - .verify_auth( - auth.into(), - &ProtectedEndpoint::new(Method::Get, RoutePath::Ws), - ) + .is_protected(&endpoint) .await - .map_err(into_response)?; + .map_err(into_response)? + { + None => true, + Some(_) => match token { + Some(token) => { + state + .mint + .verify_auth(Some(token), &endpoint) + .await + .map_err(into_response)?; + true + } + None => false, + }, + }; - Ok(ws.on_upgrade(|ws| main_websocket(ws, state))) + Ok(ws.on_upgrade(move |ws| main_websocket(ws, state, authenticated))) } /// Check whether a proof is spent already or is pending in a transaction diff --git a/crates/cdk-axum/src/ws/authenticate.rs b/crates/cdk-axum/src/ws/authenticate.rs new file mode 100644 index 000000000..f20c018d9 --- /dev/null +++ b/crates/cdk-axum/src/ws/authenticate.rs @@ -0,0 +1,47 @@ +use std::str::FromStr; + +use cdk::error::ErrorCode; +use cdk::nuts::nut21::{Method, ProtectedEndpoint, RoutePath}; +use cdk::nuts::{AuthToken, BlindAuthToken}; +use cdk::ws::{WsAuthenticateRequest, WsAuthenticateResponse, WsResponseResult}; + +use super::{WsContext, WsError}; + +/// Handle a NUT-22 `authenticate` command. +/// +/// Verifies and spends the blind authentication token, then marks the whole +/// connection authenticated for its lifetime. A single BAT authenticates the +/// connection; later commands do not consume additional tokens. +pub(crate) async fn handle( + context: &mut WsContext, + req: WsAuthenticateRequest, +) -> Result { + // A single BAT authenticates the connection for its lifetime (NUT-22), so a + // repeat authenticate is a no-op and must not spend another token. + if context.authenticated { + return Ok(WsAuthenticateResponse::Ok.into()); + } + + let token = BlindAuthToken::from_str(&req.token).map_err(|_| blind_auth_failed())?; + + context + .state + .mint + .verify_auth( + Some(AuthToken::BlindAuth(token)), + &ProtectedEndpoint::new(Method::Get, RoutePath::Ws), + ) + .await + .map_err(|_| blind_auth_failed())?; + + context.authenticated = true; + + Ok(WsAuthenticateResponse::Ok.into()) +} + +fn blind_auth_failed() -> WsError { + WsError::ServerError( + ErrorCode::BlindAuthFailed.to_code() as i32, + "Blind authentication failed".to_string(), + ) +} diff --git a/crates/cdk-axum/src/ws/mod.rs b/crates/cdk-axum/src/ws/mod.rs index 1cb8e111b..95f93faf5 100644 --- a/crates/cdk-axum/src/ws/mod.rs +++ b/crates/cdk-axum/src/ws/mod.rs @@ -1,7 +1,9 @@ use std::collections::HashMap; use std::sync::Arc; +use std::time::Duration; use axum::extract::ws::{CloseFrame, Message, WebSocket}; +use cdk::error::ErrorCode; use cdk::mint::QuoteId; use cdk::nuts::nut17::NotificationPayload; use cdk::subscription::SubId; @@ -14,6 +16,7 @@ use tokio::sync::mpsc; use crate::MintState; +mod authenticate; mod error; mod subscribe; mod unsubscribe; @@ -21,13 +24,37 @@ mod unsubscribe; pub(crate) const MAX_SUBSCRIPTIONS_PER_CONNECTION: usize = 100; pub(crate) const MAX_FILTERS_PER_SUBSCRIPTION: usize = 1000; +/// How long a connection that requires blind auth may stay open without +/// authenticating before the mint closes it (NUT-22 SHOULD). +const AUTH_TIMEOUT: Duration = Duration::from_secs(30); + +fn blind_auth_required() -> WsError { + WsError::ServerError( + ErrorCode::BlindAuthRequired.to_code() as i32, + "Endpoint requires blind auth".to_string(), + ) +} + async fn process( context: &mut WsContext, body: WsRequest, ) -> Result { let response = match body.method { - WsMethodRequest::Subscribe(sub) => subscribe::handle(context, sub).await, - WsMethodRequest::Unsubscribe(unsub) => unsubscribe::handle(context, unsub).await, + WsMethodRequest::Authenticate(req) => authenticate::handle(context, req).await, + WsMethodRequest::Subscribe(sub) => { + if context.authenticated { + subscribe::handle(context, sub).await + } else { + Err(blind_auth_required()) + } + } + WsMethodRequest::Unsubscribe(unsub) => { + if context.authenticated { + unsubscribe::handle(context, unsub).await + } else { + Err(blind_auth_required()) + } + } } .map_err(WsErrorBody::from); @@ -42,6 +69,10 @@ pub struct WsContext { state: MintState, subscriptions: HashMap, tokio::task::JoinHandle<()>>, publisher: mpsc::Sender<(Arc, NotificationPayload)>, + /// Whether the connection may subscribe. Set at upgrade time for open + /// endpoints and header-authenticated connections, or by a successful + /// in-band `authenticate` command. + authenticated: bool, } impl Drop for WsContext { @@ -58,16 +89,31 @@ impl Drop for WsContext { /// /// For simplicity sake this function will spawn tasks for each subscription and /// keep them in a hashmap, and will have a single subscriber for all of them. -pub async fn main_websocket(mut socket: WebSocket, state: MintState) { +pub async fn main_websocket(mut socket: WebSocket, state: MintState, authenticated: bool) { let (publisher, mut subscriber) = mpsc::channel(100); let mut context = WsContext { state, subscriptions: HashMap::new(), publisher, + authenticated, }; + let auth_timeout = tokio::time::sleep(AUTH_TIMEOUT); + tokio::pin!(auth_timeout); + loop { tokio::select! { + // Close connections that never authenticate. The guard disables + // this branch once the connection is authenticated, so open and + // authenticated connections are never closed by it. + () = &mut auth_timeout, if !context.authenticated => { + tracing::info!("Closing websocket: no authentication within timeout"); + let _ = socket.send(Message::Close(Some(CloseFrame { + code: axum::extract::ws::close_code::POLICY, + reason: "authentication required".into(), + }))).await; + break; + } Some((sub_id, payload)) = subscriber.recv() => { if !context.subscriptions.contains_key(&sub_id) { // It may be possible an incoming message has come from a dropped Subscriptions that has not yet been @@ -241,6 +287,10 @@ mod tests { } fn make_context(mint: Arc) -> WsContext { + make_context_with_auth(mint, true) + } + + fn make_context_with_auth(mint: Arc, authenticated: bool) -> WsContext { let state = MintState { mint, cache: Arc::new(HttpCache::default()), @@ -250,6 +300,7 @@ mod tests { state, subscriptions: HashMap::new(), publisher, + authenticated, } } @@ -395,4 +446,103 @@ mod tests { result.as_ref().err() ); } + + fn subscribe_request(sub_id: &str) -> WsRequest { + WsRequest { + jsonrpc: "2.0".to_string(), + method: WsMethodRequest::Subscribe(make_params(sub_id)), + id: 1, + } + } + + #[tokio::test] + async fn unauthenticated_subscribe_is_rejected_with_31001() { + let mint = create_test_mint().await; + let mut context = make_context_with_auth(mint, false); + + let response = process(&mut context, subscribe_request("sub-unauth")) + .await + .expect("process serializes"); + + assert_eq!(response["error"]["code"], 31001); + assert!( + context.subscriptions.is_empty(), + "a rejected subscribe must not register a subscription" + ); + } + + #[tokio::test] + async fn unauthenticated_unsubscribe_is_rejected_with_31001() { + let mint = create_test_mint().await; + let mut context = make_context_with_auth(mint, false); + + let request = WsRequest { + jsonrpc: "2.0".to_string(), + method: WsMethodRequest::Unsubscribe(WsUnsubscribeRequest { + sub_id: Arc::new(SubId::from("sub-unauth")), + }), + id: 2, + }; + + let response = process(&mut context, request) + .await + .expect("process serializes"); + + assert_eq!(response["error"]["code"], 31001); + } + + #[tokio::test] + async fn authenticate_with_garbage_token_returns_31002() { + let mint = create_test_mint().await; + let mut context = make_context_with_auth(mint, false); + + let err = authenticate::handle( + &mut context, + cdk::ws::WsAuthenticateRequest { + token: "not-a-valid-bat".to_string(), + }, + ) + .await + .expect_err("garbage token must fail"); + + let body = cdk::ws::WsErrorBody::from(err); + assert_eq!(body.code, 31002); + assert!( + !context.authenticated, + "a failed authenticate must not authenticate the connection" + ); + } + + #[tokio::test] + async fn authenticated_subscribe_is_accepted() { + let mint = create_test_mint().await; + let mut context = make_context_with_auth(mint, true); + + let response = process(&mut context, subscribe_request("sub-authed")) + .await + .expect("process serializes"); + + assert_eq!(response["result"]["status"], "OK"); + } + + #[tokio::test] + async fn authenticate_is_idempotent_when_already_authenticated() { + let mint = create_test_mint().await; + let mut context = make_context_with_auth(mint, true); + + // Even an invalid token succeeds: an already-authenticated connection + // must short-circuit before any parse/verify/burn, so a repeat + // authenticate never spends a second BAT. + let result = authenticate::handle( + &mut context, + cdk::ws::WsAuthenticateRequest { + token: "not-a-valid-bat".to_string(), + }, + ) + .await + .expect("repeat authenticate on an authenticated connection is a no-op"); + + assert!(matches!(result, cdk::ws::WsResponseResult::Authenticate(_))); + assert!(context.authenticated); + } } diff --git a/crates/cdk-common/src/ws.rs b/crates/cdk-common/src/ws.rs index 1b9eab57d..6704a6e14 100644 --- a/crates/cdk-common/src/ws.rs +++ b/crates/cdk-common/src/ws.rs @@ -38,6 +38,11 @@ pub type WsResponse = nut17::ws::WsResponse; /// Method-specific websocket request pub type WsMethodRequest = nut17::ws::WsMethodRequest; +/// Request to authenticate a connection (NUT-22) +pub use nut17::ws::WsAuthenticateRequest; +/// Response to an authenticate request (NUT-22) +pub use nut17::ws::WsAuthenticateResponse; + /// Error body for websocket responses pub type WsErrorBody = nut17::ws::WsErrorBody; diff --git a/crates/cdk-integration-tests/src/init_auth_mint.rs b/crates/cdk-integration-tests/src/init_auth_mint.rs index 5fdf9b718..6b0f58765 100644 --- a/crates/cdk-integration-tests/src/init_auth_mint.rs +++ b/crates/cdk-integration-tests/src/init_auth_mint.rs @@ -86,6 +86,7 @@ where ProtectedEndpoint::new(Method::Post, RoutePath::Swap), ProtectedEndpoint::new(Method::Post, RoutePath::Checkstate), ProtectedEndpoint::new(Method::Post, RoutePath::Restore), + ProtectedEndpoint::new(Method::Get, RoutePath::Ws), ]; let blind_auth_endpoints = diff --git a/crates/cdk-integration-tests/tests/fake_auth.rs b/crates/cdk-integration-tests/tests/fake_auth.rs index f942472df..6f537b1f9 100644 --- a/crates/cdk-integration-tests/tests/fake_auth.rs +++ b/crates/cdk-integration-tests/tests/fake_auth.rs @@ -368,6 +368,72 @@ async fn test_mint_with_auth() { assert!(proofs.total_amount().expect("Could not get proofs amount") == mint_amount); } +/// The `/v1/ws` endpoint is blind-auth protected. A browser cannot set the +/// `Blind-auth` header, so the wallet authenticates the connection in-band with +/// the NUT-22 `authenticate` command. This exercises that end-to-end: a +/// subscription only receives notifications once the connection is +/// authenticated. +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_websocket_subscription_with_auth() { + use cdk::nuts::{MintQuoteState, NotificationPayload}; + use cdk::wallet::WalletSubscription; + + let db = Arc::new(memory::empty().await.unwrap()); + + let wallet = WalletBuilder::new() + .mint_url(MintUrl::from_str(MINT_URL).expect("Valid mint url")) + .unit(CurrencyUnit::Sat) + .localstore(db.clone()) + .seed(Mnemonic::generate(12).unwrap().to_seed_normalized("")) + .build() + .expect("Wallet"); + + let mint_info = wallet + .fetch_mint_info() + .await + .expect("mint info") + .expect("could not get mint info"); + + let (access_token, _) = get_tokens(&mint_info, false) + .await + .expect("could not get access token"); + + wallet.set_cat(access_token).await.unwrap(); + wallet + .mint_blind_auth(10.into()) + .await + .expect("Could not mint blind auth"); + + let wallet = Arc::new(wallet); + + let mint_quote = wallet + .mint_quote(PaymentMethod::BOLT11, Some(10.into()), None, None) + .await + .expect("mint quote"); + + // Opening this subscription connects to the protected `/v1/ws`, so the + // wallet must authenticate in-band before the mint accepts the subscribe. + let mut subscription = wallet + .subscribe(WalletSubscription::Bolt11MintQuoteState(vec![mint_quote + .id + .clone()])) + .await + .expect("failed to subscribe over authenticated websocket"); + + let msg = tokio::time::timeout(tokio::time::Duration::from_secs(10), subscription.recv()) + .await + .expect("timeout waiting for notification") + .expect("no notification received"); + + match msg.into_inner() { + NotificationPayload::MintQuoteBolt11Response(response) => { + assert_eq!(response.quote.to_string(), mint_quote.id); + assert_eq!(response.state, MintQuoteState::Unpaid); + } + other => panic!("unexpected notification: {other:?}"), + } +} + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn test_swap_with_auth() { let db = Arc::new(memory::empty().await.unwrap()); diff --git a/crates/cdk-integration-tests/tests/websocket_auth.rs b/crates/cdk-integration-tests/tests/websocket_auth.rs new file mode 100644 index 000000000..9fa34ba75 --- /dev/null +++ b/crates/cdk-integration-tests/tests/websocket_auth.rs @@ -0,0 +1,212 @@ +//! Self-contained NUT-22 WebSocket authentication test. +//! +//! Stands up an in-process mint whose NUT-17 `/v1/ws` endpoint is protected by +//! blind auth, serves it over a real TCP socket, and verifies that a `Wallet` +//! connects over the WebSocket and authenticates in-band (the NUT-22 +//! `authenticate` command) before its subscription is accepted. Blind auth is +//! fully offline, so no OIDC server is needed: the BAT is minted directly from +//! the mint and seeded into the wallet's store. + +use std::collections::{HashMap, HashSet}; +use std::str::FromStr; +use std::sync::Arc; + +use bip39::Mnemonic; +use cdk::amount::{Amount, SplitTarget}; +use cdk::cdk_database::WalletDatabase; +use cdk::dhke::construct_proofs; +use cdk::mint::{Mint, MintBuilder, MintMeltLimits}; +use cdk::mint_url::MintUrl; +use cdk::nuts::nut00::KnownMethod; +use cdk::nuts::nut21::{Method, ProtectedEndpoint, RoutePath}; +use cdk::nuts::{ + AuthProof, CurrencyUnit, MintQuoteState, NotificationPayload, PaymentMethod, PreMintSecrets, + State, +}; +use cdk::types::FeeReserve; +use cdk::wallet::{WalletBuilder, WalletSubscription}; +use cdk_common::wallet::ProofInfo; +use cdk_fake_wallet::FakeWallet; +use cdk_sqlite::wallet::memory; +use tokio::time::{timeout, Duration}; + +/// Build an auth-enabled mint whose only blind-auth-protected endpoint is +/// `/v1/ws`. `/v1/auth/blind/mint` is left unprotected (no clear auth), so no +/// OIDC/CAT is involved anywhere in this test. +async fn build_ws_protected_mint() -> Mint { + let db = Arc::new(cdk_sqlite::mint::memory::empty().await.expect("mint db")); + let auth_db = Arc::new( + cdk_sqlite::mint::MintSqliteAuthDatabase::new(":memory:") + .await + .expect("auth db"), + ); + + let mut mint_builder = MintBuilder::new(db.clone()); + + let fee_reserve = FeeReserve { + min_fee_reserve: 1.into(), + percent_fee_reserve: 1.0, + }; + let ln_fake_backend = FakeWallet::new( + fee_reserve, + HashMap::default(), + HashSet::default(), + 2, + CurrencyUnit::Sat, + ); + + mint_builder + .add_payment_processor( + CurrencyUnit::Sat, + PaymentMethod::Known(KnownMethod::Bolt11), + MintMeltLimits::new(1, 10_000), + Arc::new(ln_fake_backend), + ) + .await + .expect("payment processor"); + + let mnemonic = Mnemonic::generate(12).expect("mnemonic"); + let mint = mint_builder + .with_auth( + auth_db, + "https://example.com/.well-known/openid-configuration".to_string(), + "test-client".to_string(), + vec![], + ) + .with_blind_auth(50, vec![ProtectedEndpoint::new(Method::Get, RoutePath::Ws)]) + .build_with_seed(db, &mnemonic.to_seed_normalized("")) + .await + .expect("mint"); + + mint.start().await.expect("start mint"); + mint +} + +/// Mint one valid blind auth proof (BAT) directly from the mint's auth keyset, +/// without going through the OIDC-gated HTTP mint endpoint. +async fn mint_bat(mint: &Mint) -> cdk::nuts::Proof { + let auth_keyset_id = *mint + .get_active_keysets() + .get(&CurrencyUnit::Auth) + .expect("auth keyset active"); + + let keys = mint + .auth_pubkeys() + .expect("auth pubkeys") + .keysets + .into_iter() + .next() + .expect("one auth keyset") + .keys; + + // The auth keyset supports only amount 1. + let fee_and_amounts = (0u64, vec![1u64]).into(); + let premint = PreMintSecrets::random( + auth_keyset_id, + Amount::from(1), + &SplitTarget::Value(1.into()), + &fee_and_amounts, + ) + .expect("premint secrets"); + + let mut signatures = Vec::new(); + for message in premint.blinded_messages() { + signatures.push(mint.auth_blind_sign(&message).await.expect("blind sign")); + } + + construct_proofs(signatures, premint.rs(), premint.secrets(), &keys) + .expect("construct proofs") + .into_iter() + .next() + .expect("one auth proof") +} + +/// Serve a mint on an ephemeral local port and return its URL. +async fn serve(mint: Arc) -> MintUrl { + let router = cdk_axum::create_mint_router(mint, vec!["bolt11".to_string()]) + .await + .expect("mint router"); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind"); + let addr = listener.local_addr().expect("local addr"); + tokio::spawn(async move { + axum::serve(listener, router).await.expect("serve mint"); + }); + MintUrl::from_str(&format!("http://{addr}")).expect("mint url") +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn websocket_connects_and_authenticates_in_band() { + let mint = Arc::new(build_ws_protected_mint().await); + let mint_url = serve(mint.clone()).await; + + let db = Arc::new(memory::empty().await.expect("wallet db")); + let wallet = WalletBuilder::new() + .mint_url(mint_url.clone()) + .unit(CurrencyUnit::Sat) + .localstore(db.clone()) + .seed(Mnemonic::generate(12).unwrap().to_seed_normalized("")) + .build() + .expect("wallet"); + + // Fetch mint info so the wallet learns `/v1/ws` needs blind auth and builds + // its auth wallet (backed by the same localstore we seed below). + wallet + .fetch_mint_info() + .await + .expect("mint info") + .expect("mint info present"); + + // Seed one BAT into the wallet's store as an unspent Auth proof. The wallet + // turns it into the `authenticate` token on its own. Keep an `AuthProof` + // copy so we can later assert the mint actually spent it. + let bat = mint_bat(&mint).await; + let auth_proof: AuthProof = bat.clone().try_into().expect("auth proof"); + let bat_info = ProofInfo::new(bat, mint_url.clone(), State::Unspent, CurrencyUnit::Auth) + .expect("proof info"); + db.update_proofs(vec![bat_info], vec![]) + .await + .expect("seed bat"); + + // Something to subscribe to. The mint quote endpoint is not protected. + let quote = wallet + .mint_quote(PaymentMethod::BOLT11, Some(10.into()), None, None) + .await + .expect("mint quote"); + + // Opening this subscription connects to the protected `/v1/ws`. If the + // in-band authentication did not succeed, the mint would reject the + // subscribe with error 31001 and no notification would arrive. + let mut subscription = wallet + .subscribe(WalletSubscription::Bolt11MintQuoteState(vec![quote + .id + .clone()])) + .await + .expect("subscribe over authenticated websocket"); + + let msg = timeout(Duration::from_secs(15), subscription.recv()) + .await + .expect("timed out waiting for a notification") + .expect("subscription closed without a notification"); + + match msg.into_inner() { + NotificationPayload::MintQuoteBolt11Response(response) => { + assert_eq!(response.quote.to_string(), quote.id); + assert_eq!(response.state, MintQuoteState::Unpaid); + } + other => panic!("unexpected notification: {other:?}"), + } + + // The notification alone does not prove the websocket authenticated: the + // wallet falls back to HTTP polling of the (unprotected) quote-status + // endpoint if the WS stream fails. Prove the in-band `authenticate` actually + // ran by checking the mint spent the BAT (polling never touches it). A + // freshly spent proof is no longer spendable. + let spendable = mint.check_blind_auth_proof_spendable(auth_proof).await; + assert!( + spendable.is_err(), + "expected the BAT to be spent by in-band websocket auth, but it was still \ + spendable (was the subscription served over the HTTP poll fallback?): {spendable:?}" + ); +} diff --git a/crates/cdk/src/wallet/subscription.rs b/crates/cdk/src/wallet/subscription.rs index 0381a8d31..f243df6a1 100644 --- a/crates/cdk/src/wallet/subscription.rs +++ b/crates/cdk/src/wallet/subscription.rs @@ -12,7 +12,7 @@ use std::sync::Arc; use cdk_common::nut00::KnownMethod; use cdk_common::nut17::ws::{ - RawWsMessageOrResponse, WsMethodRequest, WsRequest, WsUnsubscribeRequest, + RawWsMessageOrResponse, WsAuthenticateRequest, WsMethodRequest, WsRequest, WsUnsubscribeRequest, }; use cdk_common::nut17::{deserialize_payload_for_kind, Kind, NotificationId}; use cdk_common::parking_lot::RwLock; @@ -22,12 +22,13 @@ use cdk_common::pub_sub::remote_consumer::{ use cdk_common::pub_sub::{Error as PubsubError, Spec, Subscriber}; use cdk_common::subscription::WalletParams; use cdk_common::ws_client::WsError; -use cdk_common::{CheckStateRequest, Method, PaymentMethod, RoutePath}; +use cdk_common::{AuthRequired, CheckStateRequest, Method, PaymentMethod, RoutePath}; use tokio::sync::mpsc; use uuid::Uuid; use crate::event::MintEvent; use crate::mint_url::MintUrl; +use crate::wallet::auth::AuthWallet; use crate::wallet::MintConnector; /// Notification Payload @@ -218,6 +219,24 @@ impl SubscriptionClient { } } } + + /// Build a NUT-22 `authenticate` command carrying the serialized BAT. + fn get_auth_request(&self, token: String) -> Option { + let request: WsRequest = ( + WsMethodRequest::Authenticate(WsAuthenticateRequest { token }), + self.req_id + .fetch_add(1, std::sync::atomic::Ordering::Relaxed), + ) + .into(); + + match serde_json::to_string(&request) { + Ok(json) => Some(json), + Err(err) => { + tracing::error!("Could not serialize authenticate message: {:?}", err); + None + } + } + } } fn decode_notification_payload( @@ -476,6 +495,70 @@ impl Transport for SubscriptionClient { } } +/// Authenticate the connection with a blind auth token, once, just before the +/// first subscribe. +/// +/// A single BAT authenticates the connection for its lifetime, so this fetches +/// and spends a token only on the first call and only when the endpoint needs +/// blind auth. Because it runs after a successful connect and only when a +/// subscribe is about to be sent, a failed connect never burns a BAT. +async fn ensure_authenticated( + client: &SubscriptionClient, + sender: &mut cdk_common::ws_client::WsSender, + auth_wallet: Option<&AuthWallet>, + endpoint: &cdk_common::ProtectedEndpoint, + needs_blind_auth: bool, + authenticated: &mut bool, +) -> Result<(), PubsubError> { + if !needs_blind_auth || *authenticated { + return Ok(()); + } + + let wallet = auth_wallet.ok_or_else(|| { + PubsubError::InternalStr("blind auth required but no auth wallet".to_string()) + })?; + + let token = wallet + .get_auth_for_request(endpoint) + .await + .map_err(|err| { + PubsubError::InternalStr(format!("failed to get blind auth token: {err:?}")) + })? + .ok_or_else(|| { + PubsubError::InternalStr("blind auth required but no token available".to_string()) + })?; + + let req = client.get_auth_request(token.to_string()).ok_or_else(|| { + PubsubError::InternalStr("failed to build authenticate request".to_string()) + })?; + + // Only mark the connection authenticated once the command is actually on the + // wire. The BAT has already been spent from the wallet store, so a failed + // send must surface as an error (reconnect) rather than silently proceeding + // as if authenticated. + sender + .send(req) + .await + .map_err(|err| PubsubError::InternalStr(format!("failed to send authenticate: {err:?}")))?; + *authenticated = true; + Ok(()) +} + +/// Send a subscribe request and record its kind for notification decoding. +async fn send_subscribe( + client: &SubscriptionClient, + sender: &mut cdk_common::ws_client::WsSender, + sub_id_to_kind: &mut HashMap, + name: String, + index: NotificationId, +) { + let kind = SubscriptionClient::subscription_kind(&index); + if let Some((_, req)) = client.get_sub_request(name.clone(), index) { + sub_id_to_kind.insert(name, kind); + let _ = sender.send(req).await; + } +} + async fn stream_client( client: &SubscriptionClient, mut ctrl: mpsc::Receiver>, @@ -495,35 +578,31 @@ async fn stream_client( url.set_scheme("ws").expect("Could not set scheme"); } - let mut headers: Vec<(&str, String)> = Vec::new(); - - { - let auth_wallet = client.http_client.get_auth_wallet().await; - let token = match auth_wallet.as_ref() { - Some(auth_wallet) => { - let endpoint = cdk_common::ProtectedEndpoint::new(Method::Get, RoutePath::Ws); - match auth_wallet.get_auth_for_request(&endpoint).await { - Ok(token) => token, - Err(err) => { - tracing::warn!("Failed to get auth token: {:?}", err); - None - } - } - } - None => None, - }; + let endpoint = cdk_common::ProtectedEndpoint::new(Method::Get, RoutePath::Ws); + let auth_wallet = client.http_client.get_auth_wallet().await; - if let Some(auth_token) = token { - let header_key = match &auth_token { - cdk_common::AuthToken::ClearAuth(_) => "Clear-auth", - cdk_common::AuthToken::BlindAuth(_) => "Blind-auth", - }; + // Learn the auth requirement without consuming a token. Only clear auth + // travels in a header; blind auth is done in-band, and the BAT is fetched + // lazily just before the first subscribe (see `ensure_authenticated`), so a + // failed connect never burns a single-use BAT. + let auth_required = match auth_wallet.as_ref() { + Some(wallet) => wallet.is_protected(&endpoint).await, + None => None, + }; - let header_value = auth_token.to_string(); - headers.push((header_key, header_value)); + let mut headers: Vec<(&str, String)> = Vec::new(); + if matches!(auth_required, Some(AuthRequired::Clear)) { + if let Some(wallet) = auth_wallet.as_ref() { + match wallet.get_auth_for_request(&endpoint).await { + Ok(Some(token)) => headers.push(("Clear-auth", token.to_string())), + Ok(None) => {} + Err(err) => tracing::warn!("Failed to get clear auth token: {:?}", err), + } } } + let needs_blind_auth = matches!(auth_required, Some(AuthRequired::Blind)); + let url_str = url.to_string(); let header_refs: Vec<(&str, &str)> = headers.iter().map(|(k, v)| (*k, v.as_str())).collect(); @@ -539,16 +618,24 @@ async fn stream_client( tracing::debug!("Connected to {}", url); - for (name, index) in topics { - let kind = SubscriptionClient::subscription_kind(&index); - let (_, req) = if let Some(req) = client.get_sub_request(name.clone(), index) { - req - } else { - continue; - }; + // Whether `authenticate` has been sent on this connection. A single BAT + // authenticates the connection for its lifetime, so we send it once, lazily, + // just before the first subscribe (a connection with no subscriptions never + // authenticates and the mint closes it after its auth timeout, which is + // fine). + let mut authenticated = false; - sub_id_to_kind.insert(name, kind); - let _ = sender.send(req).await; + for (name, index) in topics { + ensure_authenticated( + client, + &mut sender, + auth_wallet.as_ref(), + &endpoint, + needs_blind_auth, + &mut authenticated, + ) + .await?; + send_subscribe(client, &mut sender, &mut sub_id_to_kind, name, index).await; } loop { @@ -556,14 +643,16 @@ async fn stream_client( Some(msg) = ctrl.recv() => { match msg { StreamCtrl::Subscribe(msg) => { - let kind = SubscriptionClient::subscription_kind(&msg.1); - let (_, req) = if let Some(req) = client.get_sub_request(msg.0.clone(), msg.1) { - req - } else { - continue; - }; - sub_id_to_kind.insert(msg.0, kind); - let _ = sender.send(req).await; + ensure_authenticated( + client, + &mut sender, + auth_wallet.as_ref(), + &endpoint, + needs_blind_auth, + &mut authenticated, + ) + .await?; + send_subscribe(client, &mut sender, &mut sub_id_to_kind, msg.0, msg.1).await; } StreamCtrl::Unsubscribe(msg) => { sub_id_to_kind.remove(&msg);