diff --git a/bindings/ldk_node.udl b/bindings/ldk_node.udl index a6d867e5a..58e182618 100644 --- a/bindings/ldk_node.udl +++ b/bindings/ldk_node.udl @@ -132,6 +132,8 @@ interface Node { UnifiedQrPayment unified_qr_payment(); LSPS1Liquidity lsps1_liquidity(); [Throws=NodeError] + void lnurl_auth(string lnurl); + [Throws=NodeError] void connect(PublicKey node_id, SocketAddress address, boolean persist); [Throws=NodeError] void disconnect(PublicKey node_id); @@ -321,6 +323,8 @@ enum NodeError { "LiquidityFeeTooHigh", "InvalidBlindedPaths", "AsyncPaymentServicesDisabled", + "LnurlAuthFailed", + "InvalidLnurl", }; dictionary NodeStatus { diff --git a/src/builder.rs b/src/builder.rs index 7bca0c2c6..11d076797 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -25,6 +25,7 @@ use crate::io::{ use crate::liquidity::{ LSPS1ClientConfig, LSPS2ClientConfig, LSPS2ServiceConfig, LiquiditySourceBuilder, }; +use crate::lnurl_auth::LnurlAuth; use crate::logger::{log_error, LdkLogger, LogLevel, LogWriter, Logger}; use crate::message_handler::NodeCustomMessageHandler; use crate::payment::asynchronous::om_mailbox::OnionMessageMailbox; @@ -1712,6 +1713,8 @@ fn build_with_store_internal( None }; + let lnurl_auth = LnurlAuth::from_keys_manager(&keys_manager, Arc::clone(&logger)); + let (stop_sender, _) = tokio::sync::watch::channel(()); let (background_processor_stop_sender, _) = tokio::sync::watch::channel(()); let is_running = Arc::new(RwLock::new(false)); @@ -1741,6 +1744,7 @@ fn build_with_store_internal( scorer, peer_store, payment_store, + lnurl_auth, is_running, is_listening, node_metrics, diff --git a/src/error.rs b/src/error.rs index eaa022e56..27132ad1d 100644 --- a/src/error.rs +++ b/src/error.rs @@ -124,6 +124,10 @@ pub enum Error { InvalidBlindedPaths, /// Asynchronous payment services are disabled. AsyncPaymentServicesDisabled, + /// LNURL-auth authentication failed. + LnurlAuthFailed, + /// The provided lnurl is invalid. + InvalidLnurl, } impl fmt::Display for Error { @@ -201,6 +205,8 @@ impl fmt::Display for Error { Self::AsyncPaymentServicesDisabled => { write!(f, "Asynchronous payment services are disabled.") }, + Self::LnurlAuthFailed => write!(f, "LNURL-auth authentication failed."), + Self::InvalidLnurl => write!(f, "The provided lnurl is invalid."), } } } diff --git a/src/lib.rs b/src/lib.rs index 046343231..c3f5a0d46 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -90,6 +90,7 @@ pub mod graph; mod hex_utils; pub mod io; pub mod liquidity; +mod lnurl_auth; pub mod logger; mod message_handler; pub mod payment; @@ -136,6 +137,7 @@ use gossip::GossipSource; use graph::NetworkGraph; use io::utils::write_node_metrics; use liquidity::{LSPS1Liquidity, LiquiditySource}; +use lnurl_auth::LnurlAuth; use payment::asynchronous::om_mailbox::OnionMessageMailbox; use payment::asynchronous::static_invoice_store::StaticInvoiceStore; use payment::{ @@ -203,6 +205,7 @@ pub struct Node { scorer: Arc>, peer_store: Arc>>, payment_store: Arc, + lnurl_auth: LnurlAuth, is_running: Arc>, is_listening: Arc, node_metrics: Arc>, @@ -936,6 +939,14 @@ impl Node { )) } + /// Authenticates the user via [LNURL-auth] for the given LNURL string. + /// + /// [LNURL-auth]: https://github.com/lnurl/luds/blob/luds/04.md + pub fn lnurl_auth(&self, lnurl: String) -> Result<(), Error> { + let auth = self.lnurl_auth.clone(); + self.runtime.block_on(async move { auth.authenticate(&lnurl).await }) + } + /// Returns a liquidity handler allowing to request channels via the [bLIP-51 / LSPS1] protocol. /// /// [bLIP-51 / LSPS1]: https://github.com/lightning/blips/blob/master/blip-0051.md diff --git a/src/lnurl_auth.rs b/src/lnurl_auth.rs new file mode 100644 index 000000000..81d542fe9 --- /dev/null +++ b/src/lnurl_auth.rs @@ -0,0 +1,223 @@ +// This file is Copyright its original authors, visible in version control history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license , at your option. You may not use this file except in +// accordance with one or both of these licenses. + +use crate::logger::{log_debug, log_error, Logger}; +use crate::types::KeysManager; +use crate::Error; + +use bitcoin::hashes::{hex::FromHex, sha256, Hash, HashEngine, Hmac, HmacEngine}; +use bitcoin::secp256k1::{Message, Secp256k1, SecretKey}; +use lightning::util::logger::Logger as LdkLogger; + +use bitcoin::bech32; +use reqwest::Client; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; + +const LUD13_MESSAGE: &str = "DO NOT EVER SIGN THIS TEXT WITH YOUR PRIVATE KEYS! IT IS ONLY USED FOR DERIVATION OF LNURL-AUTH HASHING-KEY, DISCLOSING ITS SIGNATURE WILL COMPROMISE YOUR LNURL-AUTH IDENTITY AND MAY LEAD TO LOSS OF FUNDS!"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct LnurlAuthResponse { + status: String, + #[serde(skip_serializing_if = "Option::is_none")] + reason: Option, +} + +/// An LNURL-auth handler providing authentication with LNURL-auth compatible services. +/// +/// LNURL-auth allows secure, privacy-preserving authentication using domain-specific keys +/// derived from the node's master key. Each domain gets a unique key, ensuring privacy +/// while allowing consistent authentication across sessions. +#[derive(Clone)] +pub struct LnurlAuth { + hashing_key: SecretKey, + client: Client, + logger: Arc, +} + +impl LnurlAuth { + pub(crate) fn new(hashing_key: SecretKey, logger: Arc) -> Self { + let client = Client::new(); + Self { hashing_key, client, logger } + } + + pub(crate) fn from_keys_manager(keys_manager: &KeysManager, logger: Arc) -> Self { + let hash = sha256::Hash::hash(LUD13_MESSAGE.as_bytes()); + let sig = keys_manager.sign_message(hash.as_byte_array()); + let hashed_sig = sha256::Hash::hash(sig.as_bytes()); + let hashing_key = SecretKey::from_slice(hashed_sig.as_byte_array()) + .expect("32 bytes, within curve order"); + Self::new(hashing_key, logger) + } + + /// Authenticates with an LNURL-auth compatible service using the provided URL. + /// + /// The authentication process involves: + /// 1. Fetching the challenge from the service + /// 2. Deriving a domain-specific linking key + /// 3. Signing the challenge with the linking key + /// 4. Submitting the signed response to complete authentication + /// + /// Returns `Ok(())` if authentication succeeds, or an error if the process fails. + pub async fn authenticate(&self, lnurl: &str) -> Result<(), Error> { + let (hrp, bytes) = bech32::decode(lnurl).map_err(|e| { + log_error!(self.logger, "Failed to decode LNURL: {e}"); + Error::InvalidLnurl + })?; + + if hrp.to_lowercase() != "lnurl" { + log_error!(self.logger, "Invalid LNURL prefix: {hrp}"); + return Err(Error::InvalidLnurl); + } + + let lnurl_auth_url = String::from_utf8(bytes).map_err(|e| { + log_error!(self.logger, "Failed to convert LNURL bytes to string: {e}"); + Error::InvalidLnurl + })?; + + log_debug!(self.logger, "Starting LNURL-auth process for URL: {lnurl_auth_url}"); + + // Parse the URL to extract domain and parameters + let url = reqwest::Url::parse(&lnurl_auth_url).map_err(|e| { + log_error!(self.logger, "Invalid LNURL-auth URL: {e}"); + Error::InvalidLnurl + })?; + + let domain = url.host_str().ok_or_else(|| { + log_error!(self.logger, "No domain found in LNURL-auth URL"); + Error::InvalidLnurl + })?; + + // get query parameters for k1 and tag + let query_params: std::collections::HashMap<_, _> = + url.query_pairs().into_owned().collect(); + + let tag = query_params.get("tag").ok_or_else(|| { + log_error!(self.logger, "No tag parameter found in LNURL-auth URL"); + Error::InvalidLnurl + })?; + + if tag != "login" { + log_error!(self.logger, "Invalid tag parameter in LNURL-auth URL: {tag}"); + return Err(Error::InvalidLnurl); + } + + let k1 = query_params.get("k1").ok_or_else(|| { + log_error!(self.logger, "No k1 parameter found in LNURL-auth URL"); + Error::InvalidLnurl + })?; + + let k1_bytes: [u8; 32] = FromHex::from_hex(k1).map_err(|e| { + log_error!(self.logger, "Invalid k1 hex in challenge: {e}"); + Error::LnurlAuthFailed + })?; + + // Derive domain-specific linking key + let linking_secret_key = self.derive_linking_key(domain)?; + let secp = Secp256k1::signing_only(); + let linking_public_key = linking_secret_key.public_key(&secp); + + // Sign the challenge + let message = Message::from_digest_slice(&k1_bytes).map_err(|e| { + log_error!(self.logger, "Failed to create message from k1: {e}"); + Error::LnurlAuthFailed + })?; + + let signature = secp.sign_ecdsa(&message, &linking_secret_key); + + // Submit authentication response + let auth_url = format!("{lnurl_auth_url}&sig={signature}&key={linking_public_key}"); + + log_debug!(self.logger, "Submitting LNURL-auth response"); + let auth_response = self.client.get(&auth_url).send().await.map_err(|e| { + log_error!(self.logger, "Failed to submit LNURL-auth response: {e}"); + Error::LnurlAuthFailed + })?; + + let response: LnurlAuthResponse = auth_response.json().await.map_err(|e| { + log_error!(self.logger, "Failed to parse LNURL-auth response: {e}"); + Error::LnurlAuthFailed + })?; + + if response.status == "OK" { + log_debug!(self.logger, "LNURL-auth authentication successful"); + Ok(()) + } else { + let reason = response.reason.unwrap_or_else(|| "Unknown error".to_string()); + log_error!(self.logger, "LNURL-auth authentication failed: {reason}"); + Err(Error::LnurlAuthFailed) + } + } + + fn derive_linking_key(&self, domain: &str) -> Result { + // Create HMAC-SHA256 of the domain using node secret as key + let mut hmac_engine = HmacEngine::::new(&self.hashing_key[..]); + hmac_engine.input(domain.as_bytes()); + let hmac_result = Hmac::from_engine(hmac_engine); + + // Use HMAC result as the linking private key + SecretKey::from_slice(hmac_result.as_byte_array()).map_err(|e| { + log_error!(self.logger, "Failed to derive linking key: {e}"); + Error::LnurlAuthFailed + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn build_auth(hashing_key: [u8; 32]) -> LnurlAuth { + let hashing_key = SecretKey::from_slice(&hashing_key).unwrap(); + let logger = Arc::new(Logger::new_log_facade()); + LnurlAuth::new(hashing_key, logger) + } + + #[test] + fn test_deterministic_key_derivation() { + let auth = build_auth([42u8; 32]); + let domain = "example.com"; + + // Keys should be identical for the same inputs + let key1 = auth.derive_linking_key(domain).unwrap(); + let key2 = auth.derive_linking_key(domain).unwrap(); + assert_eq!(key1, key2); + + // Keys should be different for different domains + let key3 = auth.derive_linking_key("different.com").unwrap(); + assert_ne!(key1, key3); + + // Keys should be different for different master keys + let different_master = build_auth([24u8; 32]); + let key4 = different_master.derive_linking_key(domain).unwrap(); + assert_ne!(key1, key4); + } + + #[test] + fn test_domain_isolation() { + let auth = build_auth([42u8; 32]); + let domains = ["example.com", "test.org", "service.net"]; + let mut keys = Vec::with_capacity(domains.len()); + + for domain in &domains { + keys.push(auth.derive_linking_key(domain).unwrap()); + } + + for i in 0..keys.len() { + for j in 0..keys.len() { + if i == j { + continue; + } + assert_ne!( + keys[i], keys[j], + "Keys for {} and {} should be different", + domains[i], domains[j] + ); + } + } + } +}