Skip to content
Open
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
21 changes: 21 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,12 @@ members = [
"crates/breez-sdk/bindings",
"crates/breez-sdk/cli",
"crates/breez-sdk/common",
"crates/breez-sdk/core",
"crates/breez-sdk/core",
"crates/breez-sdk/lnurl-models",
"crates/breez-sdk/breez-itest",
"crates/breez-sdk/wasm",
"crates/domain-validator",
"crates/fly-api",
"crates/macros",
"crates/macro_test",
"crates/spark",
Expand Down
23 changes: 23 additions & 0 deletions crates/breez-sdk/lnurl/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions crates/breez-sdk/lnurl/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ bitcoin = { version = "0.32.6", features = ["serde"] }
clap = { version = "4.5.40", features = ["derive"] }
figment = { version = "0.10.19", features = ["env", "toml"] }
hex = "0.4.3"
domain-validator = { path = "../../domain-validator" }
fly-api = { path = "../../fly-api" }
lightning-invoice = { version = "0.33.2", features = ["std"] }
lnurl-models = { path = "../lnurl-models" }
nostr = { version = "0.43.1", default-features = false, features = ["std", "nip57"] }
Expand Down
31 changes: 24 additions & 7 deletions crates/breez-sdk/lnurl/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,12 @@ use axum::{
};
use base64::{Engine, prelude::BASE64_STANDARD};
use clap::Parser;
use domain_validator::ListDomainValidator;
use figment::{
Figment,
providers::{Env, Format, Serialized, Toml},
};
use fly_api::FlyDomainValidator;
use serde::{Deserialize, Serialize};
use spark::operator::rpc::DefaultConnectionManager;
use spark::session_manager::InMemorySessionManager;
Expand Down Expand Up @@ -97,6 +99,16 @@ struct Args {
/// If set, the server will use this certificate to validate api keys.
#[arg(long)]
pub ca_cert: Option<String>,

/// Fly.io app name for certificate-based domain validation.
/// If set along with --fly-api-token, enables Fly.io certificate validation.
#[arg(long)]
pub fly_app_name: Option<String>,

/// Fly.io API token for certificate-based domain validation.
/// If set along with --fly-app-name, enables Fly.io certificate validation.
#[arg(long)]
pub fly_api_token: Option<String>,
}

#[tokio::main]
Expand Down Expand Up @@ -191,12 +203,17 @@ where
)
.await?,
);

let domains = args
.domains
.split(',')
.map(|d| d.trim().to_lowercase())
.collect();
let domain_validator: Arc<dyn domain_validator::DomainValidator> =
if let (Some(app_name), Some(api_token)) = (args.fly_app_name, args.fly_api_token) {
Arc::new(FlyDomainValidator::new(app_name, api_token))
} else {
let domains = args
.domains
.split(',')
.map(|d| d.trim().to_lowercase())
.collect();
Arc::new(ListDomainValidator::new(domains))
};

let ca_cert = args
.ca_cert
Expand Down Expand Up @@ -250,7 +267,7 @@ where
min_sendable: args.min_sendable,
max_sendable: args.max_sendable,
include_spark_address: args.include_spark_address,
domains,
domain_validator: domain_validator,
Copy link

Copilot AI Nov 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant use of variable name in field initialization. This can be simplified from domain_validator: domain_validator, to just domain_validator, using Rust's field init shorthand.

Suggested change
domain_validator: domain_validator,
domain_validator,

Copilot uses AI. Check for mistakes.
nostr_keys,
ca_cert,
connection_manager,
Expand Down
35 changes: 17 additions & 18 deletions crates/breez-sdk/lnurl/src/routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -95,9 +95,10 @@ where
Extension(state): Extension<State<DB>>,
) -> Result<Json<CheckUsernameAvailableResponse>, (StatusCode, Json<Value>)> {
let username = sanitize_username(&identifier);
let domain = host.trim().to_lowercase();
Copy link

Copilot AI Nov 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The domain sanitization logic host.trim().to_lowercase() is duplicated across multiple functions (lines 98, 131, 178, 202, 242, 287). Consider extracting this into a helper function like sanitize_domain_input(host: &str) -> String to improve maintainability and ensure consistent behavior.

Suggested change
let domain = host.trim().to_lowercase();
let domain = sanitize_domain_input(&host);

Copilot uses AI. Check for mistakes.
Copy link

Copilot AI Nov 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The check_username_available function does not validate the domain against allowed domains before querying the database. Unlike the register function (lines 134-137), this allows unauthorized domains to check username availability. Consider adding domain validation here to prevent information disclosure about existing usernames on unauthorized domains.

Suggested change
let domain = host.trim().to_lowercase();
let domain = host.trim().to_lowercase();
// Domain validation: only allow allowed domains to check username availability
if !state.allowed_domains.contains(&domain) {
return Err((
StatusCode::FORBIDDEN,
Json(json!({"error": "domain not allowed"})),
));
}

Copilot uses AI. Check for mistakes.
Copy link

Copilot AI Nov 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The domain sanitization logic (host.trim().to_lowercase()) is duplicated across multiple route handlers (lines 98, 131, 178, 202, 242, 287). Consider extracting this into a helper function to improve maintainability and consistency.

Copilot uses AI. Check for mistakes.
let user = state
.db
.get_user_by_name(&sanitize_domain(&state, &host)?, &username)
.get_user_by_name(&domain, &username)
.await
.map_err(|e| {
error!("failed to execute query: {}", e);
Expand Down Expand Up @@ -127,8 +128,15 @@ where
Json(Value::String("description too long".into())),
));
}
let domain = host.trim().to_lowercase();

// Use domain validator from state
if let Err(e) = state.domain_validator.validate_domain(&domain).await {
warn!("domain not allowed for registration: {} - {}", domain, e);
return Err((StatusCode::NOT_FOUND, Json(Value::String(String::new()))));
}
let user = User {
domain: sanitize_domain(&state, &host)?,
domain,
pubkey: pubkey.to_string(),
name: username,
description: payload.description,
Expand Down Expand Up @@ -167,9 +175,10 @@ where
let username = sanitize_username(&payload.username);
let pubkey = validate(&pubkey, &payload.signature, &username, &state).await?;

let domain = host.trim().to_lowercase();
state
.db
.delete_user(&sanitize_domain(&state, &host)?, &pubkey.to_string())
.delete_user(&domain, &pubkey.to_string())
Comment on lines +178 to +181
Copy link

Copilot AI Nov 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Domain validation is missing in the delete_lnurl_pay endpoint. The old sanitize_domain function validated the domain before allowing deletion, but the new implementation directly uses the domain without validation. This could allow unauthorized deletions on invalid domains. Consider adding domain validation similar to the register_lnurl_pay endpoint.

Copilot uses AI. Check for mistakes.
.await
.map_err(|e| {
error!("failed to execute query: {}", e);
Expand All @@ -190,9 +199,10 @@ where
) -> Result<Json<RecoverLnurlPayResponse>, (StatusCode, Json<Value>)> {
let pubkey = validate(&pubkey, &payload.signature, &pubkey, &state).await?;

let domain = host.trim().to_lowercase();
let user = state
.db
.get_user_by_pubkey(&sanitize_domain(&state, &host)?, &pubkey.to_string())
.get_user_by_pubkey(&domain, &pubkey.to_string())
Comment on lines +202 to +205
Copy link

Copilot AI Nov 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Domain validation is missing in the recover_lnurl_pay endpoint. The old sanitize_domain function validated the domain before allowing recovery operations, but the new implementation directly uses the domain without validation. This could allow unauthorized operations on invalid domains. Consider adding domain validation similar to the register_lnurl_pay endpoint.

Copilot uses AI. Check for mistakes.
.await
.map_err(|e| {
error!("failed to execute query: {}", e);
Expand Down Expand Up @@ -229,9 +239,10 @@ where
}

let username = sanitize_username(&identifier);
let domain = host.trim().to_lowercase();
Copy link

Copilot AI Nov 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The request_invoice function does not validate the domain before creating invoices. Unlike the register function (lines 134-137), this allows invoice generation for unauthorized domains. Consider adding domain validation here to ensure only allowed domains can request invoices.

Suggested change
let domain = host.trim().to_lowercase();
let domain = host.trim().to_lowercase();
// Domain validation: ensure only allowed domains can request invoices
if let Some(allowed_domains) = &state.allowed_domains {
if !allowed_domains.contains(&domain) {
return Err((
StatusCode::FORBIDDEN,
Json(Value::String("domain not allowed".into())),
));
}
}

Copilot uses AI. Check for mistakes.
let user = state
.db
.get_user_by_name(&sanitize_domain(&state, &host)?, &username)
.get_user_by_name(&domain, &username)
Comment on lines +242 to +245
Copy link

Copilot AI Nov 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Domain validation is missing in the get_lnurl_pay endpoint. The old sanitize_domain function validated the domain, but the new implementation directly uses the domain without validation. This could expose data for invalid domains. Consider adding domain validation similar to the register_lnurl_pay endpoint.

Copilot uses AI. Check for mistakes.
.await
.map_err(|e| {
error!("failed to execute query: {}", e);
Expand Down Expand Up @@ -273,7 +284,7 @@ where
}

let username = sanitize_username(&identifier);
let domain = sanitize_domain(&state, &host)?;
let domain = host.trim().to_lowercase();
let user = state
.db
.get_user_by_name(&domain, &username)
Comment on lines +287 to 290
Copy link

Copilot AI Nov 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Domain validation is missing in the get_lnurl_pay_callback endpoint. The old sanitize_domain function validated the domain, but the new implementation directly uses the domain without validation. This could allow unauthorized payment callbacks for invalid domains. Consider adding domain validation similar to the register_lnurl_pay endpoint.

Copilot uses AI. Check for mistakes.
Expand Down Expand Up @@ -583,15 +594,3 @@ fn lnurl_error(message: &str) -> (StatusCode, Json<Value>) {
)),
)
}

fn sanitize_domain<DB>(
state: &State<DB>,
domain: &str,
) -> Result<String, (StatusCode, Json<Value>)> {
let domain = domain.trim().to_lowercase();
if !state.domains.contains(&domain) {
warn!("domain not allowed: {}", domain);
return Err((StatusCode::NOT_FOUND, Json(Value::String(String::new()))));
}
Ok(domain)
}
10 changes: 5 additions & 5 deletions crates/breez-sdk/lnurl/src/state.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
use spark::operator::OperatorConfig;
use spark::operator::rpc::ConnectionManager;
use spark::operator::OperatorConfig;
use spark::session_manager::InMemorySessionManager;
use spark::ssp::ServiceProvider;
use spark_wallet::DefaultSigner;
use std::{collections::HashSet, sync::Arc};
use std::sync::Arc;
Copy link

Copilot AI Nov 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicate import of std::sync::Arc. Line 6 imports Arc alone, but line 7 imports it again as part of a group import with HashSet. Remove the duplicate import on line 6.

Suggested change
use std::sync::Arc;

Copilot uses AI. Check for mistakes.
use tokio::sync::Mutex;

pub struct State<DB> {
Expand All @@ -13,15 +13,15 @@ pub struct State<DB> {
pub min_sendable: u64,
pub max_sendable: u64,
pub include_spark_address: bool,
pub domains: HashSet<String>,
pub domain_validator: Arc<dyn domain_validator::DomainValidator>,
pub nostr_keys: Option<nostr::Keys>,
pub ca_cert: Option<Vec<u8>>,
pub connection_manager: Arc<dyn ConnectionManager>,
pub coordinator: OperatorConfig,
pub signer: Arc<DefaultSigner>,
pub session_manager: Arc<InMemorySessionManager>,
pub service_provider: Arc<ServiceProvider>,
pub subscribed_keys: Arc<Mutex<HashSet<String>>>,
pub subscribed_keys: Arc<Mutex<std::collections::HashSet<String>>>,
Copy link

Copilot AI Nov 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The use of std::collections::HashSet here is inconsistent with the rest of the file where HashSet was previously imported. Since std::collections::HashSet was removed from imports (line 6), this should use the fully qualified path consistently or add back the import. The inconsistency makes the code harder to maintain.

Copilot uses AI. Check for mistakes.
}

impl<DB> Clone for State<DB>
Expand All @@ -36,7 +36,7 @@ where
min_sendable: self.min_sendable,
max_sendable: self.max_sendable,
include_spark_address: self.include_spark_address,
domains: self.domains.clone(),
domain_validator: Arc::clone(&self.domain_validator),
nostr_keys: self.nostr_keys.clone(),
ca_cert: self.ca_cert.clone(),
connection_manager: self.connection_manager.clone(),
Expand Down
21 changes: 21 additions & 0 deletions crates/domain-validator/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
[package]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it is better to move the domain-validator and fly-api crates under the breez sdk folder where lnurl crate is located.
Also would consider moving the domain-validator content into lnurl-models crate and get rid of the domain-validtor crate (I am not a fan of one trait crate).

name = "domain-validator"
edition = "2024"
version = "0.1.0"

[dependencies]
async-trait = "0.1.88"

thiserror = "2.0.12"

[lints]
clippy.suspicious = { level = "warn", priority = -1 }
clippy.complexity = { level = "warn", priority = -1 }
clippy.perf = { level = "warn", priority = -1 }
clippy.style = { level = "warn", priority = -1 }
clippy.pedantic = { level = "warn", priority = -1 }
clippy.missing_errors_doc = "allow"
clippy.missing_panics_doc = "allow"
clippy.must_use_candidate = "allow"
clippy.struct_field_names = "allow"
clippy.arithmetic_side_effects = "warn"
54 changes: 54 additions & 0 deletions crates/domain-validator/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
use std::collections::HashSet;

use thiserror::Error;

#[derive(Debug, Error)]
pub enum DomainValidatorError {
#[error("Domain {0} is not allowed")]
DomainNotAllowed(String),
}

#[async_trait::async_trait]
pub trait DomainValidator: Send + Sync {
Comment on lines +11 to +12
Copy link

Copilot AI Nov 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The DomainValidator trait lacks documentation. Add doc comments explaining its purpose, the expected behavior of validate_domain(), and when DomainNotAllowed error should be returned. This is a public trait that will be implemented by external consumers.

Suggested change
#[async_trait::async_trait]
pub trait DomainValidator: Send + Sync {
#[async_trait::async_trait]
/// A trait for validating whether a domain is allowed.
///
/// This trait should be implemented by external consumers to provide custom domain validation logic.
/// The `validate_domain` method checks if the given domain is permitted according to the implementation's rules.
/// If the domain is not allowed, it should return `Err(DomainValidatorError::DomainNotAllowed(domain.to_string()))`.
/// Otherwise, it should return `Ok(())`.
pub trait DomainValidator: Send + Sync {
/// Asynchronously validates whether the provided domain is allowed.
///
/// # Arguments
///
/// * `domain` - The domain name to validate.
///
/// # Returns
///
/// * `Ok(())` if the domain is allowed.
/// * `Err(DomainValidatorError::DomainNotAllowed)` if the domain is not permitted.
///
/// Implementors should return `DomainNotAllowed` only when the domain fails validation according to their rules.

Copilot uses AI. Check for mistakes.
async fn validate_domain(&self, domain: &str) -> Result<(), DomainValidatorError>;
}
Comment on lines +12 to +14
Copy link

Copilot AI Nov 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The DomainValidator trait lacks documentation explaining its purpose, expected behavior, and usage. Add doc comments to describe what domain validation means in this context and how implementers should handle the validation.

Copilot uses AI. Check for mistakes.

Copy link

Copilot AI Nov 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ListDomainValidator struct lacks documentation explaining its purpose and usage. Add doc comments to describe that this is a simple implementation that validates against a predefined list of allowed domains.

Suggested change
/// A simple implementation of the `DomainValidator` trait that validates domains
/// against a predefined list of allowed domains.

Copilot uses AI. Check for mistakes.
pub struct ListDomainValidator {
allowed_domains: HashSet<String>,
}

impl ListDomainValidator {
pub fn new(domains: HashSet<String>) -> Self {
Self {
allowed_domains: domains,
}
}
}

#[async_trait::async_trait]
impl DomainValidator for ListDomainValidator {
async fn validate_domain(&self, domain: &str) -> Result<(), DomainValidatorError> {
let domain_lower = domain.to_lowercase();
if self.allowed_domains.contains(&domain_lower) {
Ok(())
} else {
Err(DomainValidatorError::DomainNotAllowed(domain.to_string()))
}
}
}

#[cfg(test)]
mod tests {
use super::*;

#[tokio::test]
async fn test_list_domain_validator() {
let domains = HashSet::from(["example.com".to_string(), "test.org".to_string()]);
let validator = ListDomainValidator::new(domains);

assert!(validator.validate_domain("example.com").await.is_ok());
assert!(validator.validate_domain("EXAMPLE.COM").await.is_ok());
assert!(validator.validate_domain("test.org").await.is_ok());
assert!(validator.validate_domain("invalid.com").await.is_err());
}
}
25 changes: 25 additions & 0 deletions crates/fly-api/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
[package]
name = "fly-api"
edition = "2024"
version = "0.1.0"

[dependencies]
reqwest = { version = "0.12.10", features = ["json"] }
serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.140"
thiserror = "2.0.12"
tokio = { version = "1.45.1", features = ["rt-multi-thread", "macros", "signal"] }
domain-validator = { path = "../domain-validator" }
async-trait = "0.1.88"

[lints]
clippy.suspicious = { level = "warn", priority = -1 }
clippy.complexity = { level = "warn", priority = -1 }
clippy.perf = { level = "warn", priority = -1 }
clippy.style = { level = "warn", priority = -1 }
clippy.pedantic = { level = "warn", priority = -1 }
clippy.missing_errors_doc = "allow"
clippy.missing_panics_doc = "allow"
clippy.must_use_candidate = "allow"
clippy.struct_field_names = "allow"
clippy.arithmetic_side_effects = "warn"
Loading
Loading