Skip to content

Commit 3c0497e

Browse files
committed
Add LNURL-auth support
Implements LNURL-auth (LUD-04) specification for secure, privacy-preserving authentication with Lightning services using domain-specific key derivation. I used LUD-13 for deriving the keys as this is what most wallets use today.
1 parent 9e0cfc5 commit 3c0497e

7 files changed

Lines changed: 270 additions & 2 deletions

File tree

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ bdk_esplora = { version = "0.22.0", default-features = false, features = ["async
5656
bdk_electrum = { version = "0.23.0", default-features = false, features = ["use-rustls-ring"]}
5757
bdk_wallet = { version = "2.3.0", default-features = false, features = ["std", "keys-bip39"]}
5858

59-
bitreq = { version = "0.3", default-features = false, features = ["async-https"] }
59+
bitreq = { version = "0.3", default-features = false, features = ["async-https", "json-using-serde"] }
6060
rustls = { version = "0.23", default-features = false }
6161
rusqlite = { version = "0.31.0", features = ["bundled"] }
6262
bitcoin = "0.32.7"

bindings/ldk_node.udl

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,8 @@ interface Node {
166166
UnifiedPayment unified_payment();
167167
LSPS1Liquidity lsps1_liquidity();
168168
[Throws=NodeError]
169+
void lnurl_auth(string lnurl);
170+
[Throws=NodeError]
169171
void connect(PublicKey node_id, SocketAddress address, boolean persist);
170172
[Throws=NodeError]
171173
void disconnect(PublicKey node_id);
@@ -364,6 +366,9 @@ enum NodeError {
364366
"InvalidBlindedPaths",
365367
"AsyncPaymentServicesDisabled",
366368
"HrnParsingFailed",
369+
"LnurlAuthFailed",
370+
"LnurlAuthTimeout",
371+
"InvalidLnurl",
367372
};
368373

369374
dictionary NodeStatus {

src/builder.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ use crate::io::{
6868
use crate::liquidity::{
6969
LSPS1ClientConfig, LSPS2ClientConfig, LSPS2ServiceConfig, LiquiditySourceBuilder,
7070
};
71+
use crate::lnurl_auth::LnurlAuth;
7172
use crate::logger::{log_error, LdkLogger, LogLevel, LogWriter, Logger};
7273
use crate::message_handler::NodeCustomMessageHandler;
7374
use crate::payment::asynchronous::om_mailbox::OnionMessageMailbox;
@@ -1765,6 +1766,8 @@ fn build_with_store_internal(
17651766
None
17661767
};
17671768

1769+
let lnurl_auth = Arc::new(LnurlAuth::new(&keys_manager, Arc::clone(&logger)));
1770+
17681771
let (stop_sender, _) = tokio::sync::watch::channel(());
17691772
let (background_processor_stop_sender, _) = tokio::sync::watch::channel(());
17701773
let is_running = Arc::new(RwLock::new(false));
@@ -1810,6 +1813,7 @@ fn build_with_store_internal(
18101813
scorer,
18111814
peer_store,
18121815
payment_store,
1816+
lnurl_auth,
18131817
is_running,
18141818
node_metrics,
18151819
om_mailbox,

src/config.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,9 @@ pub(crate) const EXTERNAL_PATHFINDING_SCORES_SYNC_TIMEOUT_SECS: u64 = 5;
107107
// The timeout after which we abort a parsing/looking up an HRN resolution.
108108
pub(crate) const HRN_RESOLUTION_TIMEOUT_SECS: u64 = 5;
109109

110+
// The timeout after which we abort an LNURL-auth operation.
111+
pub(crate) const LNURL_AUTH_TIMEOUT_SECS: u64 = 15;
112+
110113
#[derive(Debug, Clone)]
111114
/// Represents the configuration of an [`Node`] instance.
112115
///

src/error.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,12 @@ pub enum Error {
131131
AsyncPaymentServicesDisabled,
132132
/// Parsing a Human-Readable Name has failed.
133133
HrnParsingFailed,
134+
/// LNURL-auth authentication failed.
135+
LnurlAuthFailed,
136+
/// LNURL-auth authentication timed out.
137+
LnurlAuthTimeout,
138+
/// The provided lnurl is invalid.
139+
InvalidLnurl,
134140
}
135141

136142
impl fmt::Display for Error {
@@ -213,6 +219,9 @@ impl fmt::Display for Error {
213219
Self::HrnParsingFailed => {
214220
write!(f, "Failed to parse a human-readable name.")
215221
},
222+
Self::LnurlAuthFailed => write!(f, "LNURL-auth authentication failed."),
223+
Self::LnurlAuthTimeout => write!(f, "LNURL-auth authentication timed out."),
224+
Self::InvalidLnurl => write!(f, "The provided lnurl is invalid."),
216225
}
217226
}
218227
}

src/lib.rs

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ pub mod graph;
9696
mod hex_utils;
9797
pub mod io;
9898
pub mod liquidity;
99+
mod lnurl_auth;
99100
pub mod logger;
100101
mod message_handler;
101102
pub mod payment;
@@ -124,7 +125,8 @@ pub use builder::NodeBuilder as Builder;
124125
use chain::ChainSource;
125126
use config::{
126127
default_user_config, may_announce_channel, AsyncPaymentsRole, ChannelConfig, Config,
127-
NODE_ANN_BCAST_INTERVAL, PEER_RECONNECTION_INTERVAL, RGS_SYNC_INTERVAL,
128+
LNURL_AUTH_TIMEOUT_SECS, NODE_ANN_BCAST_INTERVAL, PEER_RECONNECTION_INTERVAL,
129+
RGS_SYNC_INTERVAL,
128130
};
129131
use connection::ConnectionManager;
130132
pub use error::Error as NodeError;
@@ -149,6 +151,7 @@ use lightning::routing::gossip::NodeAlias;
149151
use lightning::util::persist::KVStoreSync;
150152
use lightning_background_processor::process_events_async;
151153
use liquidity::{LSPS1Liquidity, LiquiditySource};
154+
use lnurl_auth::LnurlAuth;
152155
use logger::{log_debug, log_error, log_info, log_trace, LdkLogger, Logger};
153156
use payment::asynchronous::om_mailbox::OnionMessageMailbox;
154157
use payment::asynchronous::static_invoice_store::StaticInvoiceStore;
@@ -222,6 +225,7 @@ pub struct Node {
222225
scorer: Arc<Mutex<Scorer>>,
223226
peer_store: Arc<PeerStore<Arc<Logger>>>,
224227
payment_store: Arc<PaymentStore>,
228+
lnurl_auth: Arc<LnurlAuth>,
225229
is_running: Arc<RwLock<bool>>,
226230
node_metrics: Arc<RwLock<NodeMetrics>>,
227231
om_mailbox: Option<Arc<OnionMessageMailbox>>,
@@ -1004,6 +1008,26 @@ impl Node {
10041008
))
10051009
}
10061010

1011+
/// Authenticates the user via [LNURL-auth] for the given LNURL string.
1012+
///
1013+
/// [LNURL-auth]: https://github.com/lnurl/luds/blob/luds/04.md
1014+
pub fn lnurl_auth(&self, lnurl: String) -> Result<(), Error> {
1015+
let auth = Arc::clone(&self.lnurl_auth);
1016+
self.runtime.block_on(async move {
1017+
let res = tokio::time::timeout(
1018+
Duration::from_secs(LNURL_AUTH_TIMEOUT_SECS),
1019+
auth.authenticate(&lnurl),
1020+
)
1021+
.await;
1022+
1023+
match res {
1024+
Ok(Ok(())) => Ok(()),
1025+
Ok(Err(e)) => Err(e),
1026+
Err(_) => Err(Error::LnurlAuthTimeout),
1027+
}
1028+
})
1029+
}
1030+
10071031
/// Returns a liquidity handler allowing to request channels via the [bLIP-51 / LSPS1] protocol.
10081032
///
10091033
/// [bLIP-51 / LSPS1]: https://github.com/lightning/blips/blob/master/blip-0051.md

src/lnurl_auth.rs

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
// This file is Copyright its original authors, visible in version control history.
2+
//
3+
// This file is licensed under the Apache License, Version 2.0 <LICENSE-APACHE or
4+
// http://www.apache.org/licenses/LICENSE-2.0> or the MIT license <LICENSE-MIT or
5+
// http://opensource.org/licenses/MIT>, at your option. You may not use this file except in
6+
// accordance with one or both of these licenses.
7+
8+
use crate::logger::{log_debug, log_error, Logger};
9+
use crate::types::KeysManager;
10+
use crate::Error;
11+
12+
use bitcoin::hashes::{hex::FromHex, sha256, Hash, HashEngine, Hmac, HmacEngine};
13+
use bitcoin::secp256k1::{Message, Secp256k1, SecretKey};
14+
use lightning::util::logger::Logger as LdkLogger;
15+
16+
use bitcoin::bech32;
17+
use bitreq::Client;
18+
use serde::{Deserialize, Serialize};
19+
use std::sync::Arc;
20+
21+
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!";
22+
23+
#[derive(Debug, Clone, Serialize, Deserialize)]
24+
struct LnurlAuthResponse {
25+
status: String,
26+
#[serde(skip_serializing_if = "Option::is_none")]
27+
reason: Option<String>,
28+
}
29+
30+
/// An LNURL-auth handler providing authentication with LNURL-auth compatible services.
31+
///
32+
/// LNURL-auth allows secure, privacy-preserving authentication using domain-specific keys
33+
/// derived from the node's master key. Each domain gets a unique key, ensuring privacy
34+
/// while allowing consistent authentication across sessions.
35+
#[derive(Clone)]
36+
pub struct LnurlAuth {
37+
hashing_key: SecretKey,
38+
client: Client,
39+
logger: Arc<Logger>,
40+
}
41+
42+
impl LnurlAuth {
43+
pub(crate) fn new(keys_manager: &KeysManager, logger: Arc<Logger>) -> Self {
44+
let hash = sha256::Hash::hash(LUD13_MESSAGE.as_bytes());
45+
let sig = keys_manager.sign_message(hash.as_byte_array());
46+
let hashed_sig = sha256::Hash::hash(sig.as_bytes());
47+
let hashing_key = SecretKey::from_slice(hashed_sig.as_byte_array())
48+
.expect("32 bytes, within curve order");
49+
let client = Client::new(2);
50+
Self { hashing_key, client, logger }
51+
}
52+
53+
#[cfg(test)]
54+
pub(crate) fn from_parts(hashing_key: [u8; 32], logger: Arc<Logger>) -> Self {
55+
let hashing_key =
56+
SecretKey::from_slice(&hashing_key).expect("32 bytes, within curve order");
57+
let client = Client::new(2);
58+
Self { hashing_key, client, logger }
59+
}
60+
61+
/// Authenticates with an LNURL-auth compatible service using the provided URL.
62+
///
63+
/// The authentication process involves:
64+
/// 1. Fetching the challenge from the service
65+
/// 2. Deriving a domain-specific linking key
66+
/// 3. Signing the challenge with the linking key
67+
/// 4. Submitting the signed response to complete authentication
68+
///
69+
/// Returns `Ok(())` if authentication succeeds, or an error if the process fails.
70+
pub async fn authenticate(&self, lnurl: &str) -> Result<(), Error> {
71+
let (hrp, bytes) = bech32::decode(lnurl).map_err(|e| {
72+
log_error!(self.logger, "Failed to decode LNURL: {e}");
73+
Error::InvalidLnurl
74+
})?;
75+
76+
if hrp.to_lowercase() != "lnurl" {
77+
log_error!(self.logger, "Invalid LNURL prefix: {hrp}");
78+
return Err(Error::InvalidLnurl);
79+
}
80+
81+
let lnurl_auth_url = String::from_utf8(bytes).map_err(|e| {
82+
log_error!(self.logger, "Failed to convert LNURL bytes to string: {e}");
83+
Error::InvalidLnurl
84+
})?;
85+
86+
log_debug!(self.logger, "Starting LNURL-auth process for URL: {lnurl_auth_url}");
87+
88+
// Parse the URL to extract domain and parameters
89+
let url = bitreq::Url::parse(&lnurl_auth_url).map_err(|e| {
90+
log_error!(self.logger, "Invalid LNURL-auth URL: {e}");
91+
Error::InvalidLnurl
92+
})?;
93+
94+
let domain = url.base_url();
95+
96+
// get query parameters for k1 and tag
97+
let query_params: std::collections::HashMap<_, _> = url.query_pairs().collect();
98+
99+
let tag = query_params.get("tag").ok_or_else(|| {
100+
log_error!(self.logger, "No tag parameter found in LNURL-auth URL");
101+
Error::InvalidLnurl
102+
})?;
103+
104+
if tag != "login" {
105+
log_error!(self.logger, "Invalid tag parameter in LNURL-auth URL: {tag}");
106+
return Err(Error::InvalidLnurl);
107+
}
108+
109+
let k1 = query_params.get("k1").ok_or_else(|| {
110+
log_error!(self.logger, "No k1 parameter found in LNURL-auth URL");
111+
Error::InvalidLnurl
112+
})?;
113+
114+
let k1_bytes: [u8; 32] = FromHex::from_hex(k1).map_err(|e| {
115+
log_error!(self.logger, "Invalid k1 hex in challenge: {e}");
116+
Error::LnurlAuthFailed
117+
})?;
118+
119+
// Derive domain-specific linking key
120+
let linking_secret_key = self.derive_linking_key(domain)?;
121+
let secp = Secp256k1::signing_only();
122+
let linking_public_key = linking_secret_key.public_key(&secp);
123+
124+
// Sign the challenge
125+
let message = Message::from_digest_slice(&k1_bytes).map_err(|e| {
126+
log_error!(self.logger, "Failed to create message from k1: {e}");
127+
Error::LnurlAuthFailed
128+
})?;
129+
130+
let signature = secp.sign_ecdsa(&message, &linking_secret_key);
131+
132+
// Submit authentication response
133+
let auth_url = format!("{lnurl_auth_url}&sig={signature}&key={linking_public_key}");
134+
135+
log_debug!(self.logger, "Submitting LNURL-auth response");
136+
let request = bitreq::get(&auth_url);
137+
let auth_response = self.client.send_async(request).await.map_err(|e| {
138+
log_error!(self.logger, "Failed to submit LNURL-auth response: {e}");
139+
Error::LnurlAuthFailed
140+
})?;
141+
142+
let response: LnurlAuthResponse = auth_response.json().map_err(|e| {
143+
log_error!(self.logger, "Failed to parse LNURL-auth response: {e}");
144+
Error::LnurlAuthFailed
145+
})?;
146+
147+
if response.status == "OK" {
148+
log_debug!(self.logger, "LNURL-auth authentication successful");
149+
Ok(())
150+
} else {
151+
let reason = response.reason.unwrap_or_else(|| "Unknown error".to_string());
152+
log_error!(self.logger, "LNURL-auth authentication failed: {reason}");
153+
Err(Error::LnurlAuthFailed)
154+
}
155+
}
156+
157+
fn derive_linking_key(&self, domain: &str) -> Result<SecretKey, Error> {
158+
// Create HMAC-SHA256 of the domain using node secret as key
159+
let mut hmac_engine = HmacEngine::<sha256::Hash>::new(&self.hashing_key[..]);
160+
hmac_engine.input(domain.as_bytes());
161+
let hmac_result = Hmac::from_engine(hmac_engine);
162+
163+
// Use HMAC result as the linking private key
164+
SecretKey::from_slice(hmac_result.as_byte_array()).map_err(|e| {
165+
log_error!(self.logger, "Failed to derive linking key: {e}");
166+
Error::LnurlAuthFailed
167+
})
168+
}
169+
}
170+
171+
#[cfg(test)]
172+
mod tests {
173+
use super::*;
174+
175+
fn build_auth(hashing_key: [u8; 32]) -> LnurlAuth {
176+
let logger = Arc::new(Logger::new_log_facade());
177+
LnurlAuth::from_parts(hashing_key, logger)
178+
}
179+
180+
#[test]
181+
fn test_deterministic_key_derivation() {
182+
let auth = build_auth([42u8; 32]);
183+
let domain = "example.com";
184+
185+
// Keys should be identical for the same inputs
186+
let key1 = auth.derive_linking_key(domain).unwrap();
187+
let key2 = auth.derive_linking_key(domain).unwrap();
188+
assert_eq!(key1, key2);
189+
190+
// Keys should be different for different domains
191+
let key3 = auth.derive_linking_key("different.com").unwrap();
192+
assert_ne!(key1, key3);
193+
194+
// Keys should be different for different master keys
195+
let different_master = build_auth([24u8; 32]);
196+
let key4 = different_master.derive_linking_key(domain).unwrap();
197+
assert_ne!(key1, key4);
198+
}
199+
200+
#[test]
201+
fn test_domain_isolation() {
202+
let auth = build_auth([42u8; 32]);
203+
let domains = ["example.com", "test.org", "service.net"];
204+
let mut keys = Vec::with_capacity(domains.len());
205+
206+
for domain in &domains {
207+
keys.push(auth.derive_linking_key(domain).unwrap());
208+
}
209+
210+
for i in 0..keys.len() {
211+
for j in 0..keys.len() {
212+
if i == j {
213+
continue;
214+
}
215+
assert_ne!(
216+
keys[i], keys[j],
217+
"Keys for {} and {} should be different",
218+
domains[i], domains[j]
219+
);
220+
}
221+
}
222+
}
223+
}

0 commit comments

Comments
 (0)