Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions crates/cashu/src/nuts/nut17/ws.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,14 @@ pub struct RawNotificationInner<I> {
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")]
Expand All @@ -78,6 +86,12 @@ pub enum WsResponseResult<I> {
Subscribe(WsSubscribeResponse<I>),
/// Unsubscribe
Unsubscribe(WsUnsubscribeResponse<I>),
/// 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<I> From<WsSubscribeResponse<I>> for WsResponseResult<I> {
Expand All @@ -92,6 +106,12 @@ impl<I> From<WsUnsubscribeResponse<I>> for WsResponseResult<I> {
}
}

impl<I> From<WsAuthenticateResponse> for WsResponseResult<I> {
fn from(response: WsAuthenticateResponse) -> Self {
WsResponseResult::Authenticate(response)
}
}

/// The request to unsubscribe
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(bound = "I: Serialize + DeserializeOwned")]
Expand All @@ -101,6 +121,17 @@ pub struct WsUnsubscribeRequest<I> {
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")]
Expand All @@ -110,6 +141,8 @@ pub enum WsMethodRequest<I> {
Subscribe(Params<I>),
/// Unsubscribe method
Unsubscribe(WsUnsubscribeRequest<I>),
/// Authenticate method (NUT-22)
Authenticate(WsAuthenticateRequest),
}

/// Websocket request
Expand Down Expand Up @@ -258,4 +291,47 @@ mod tests {
other => panic!("expected notification, got {:?}", other),
}
}

#[test]
fn authenticate_request_round_trips() {
let request: WsRequest<String> = (
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<String> =
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<String> =
serde_json::from_str(r#"{"status":"OK"}"#).expect("authenticate response");
assert!(matches!(decoded, WsResponseResult::Authenticate(_)));

let decoded: WsResponseResult<String> =
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<String> = WsAuthenticateResponse::Ok.into();
let json = serde_json::to_value(&result).expect("serialize authenticate response");
assert_eq!(json, serde_json::json!({ "status": "OK" }));
}
}
33 changes: 25 additions & 8 deletions crates/cdk-axum/src/router_handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -129,16 +129,33 @@ pub(crate) async fn ws_handler(
State(state): State<MintState>,
ws: WebSocketUpgrade,
) -> Result<impl IntoResponse, Response> {
state
let endpoint = ProtectedEndpoint::new(Method::Get, RoutePath::Ws);
let token: Option<AuthToken> = 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
Expand Down
47 changes: 47 additions & 0 deletions crates/cdk-axum/src/ws/authenticate.rs
Original file line number Diff line number Diff line change
@@ -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<WsResponseResult, WsError> {
// 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(),
)
}
Loading