Skip to content

Commit ca9813f

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 1fbc4ed commit ca9813f

File tree

6 files changed

+263
-0
lines changed

6 files changed

+263
-0
lines changed

bindings/ldk_node.udl

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,8 @@ interface Node {
155155
UnifiedPayment unified_payment();
156156
LSPS1Liquidity lsps1_liquidity();
157157
[Throws=NodeError]
158+
void lnurl_auth(string lnurl);
159+
[Throws=NodeError]
158160
void connect(PublicKey node_id, SocketAddress address, boolean persist);
159161
[Throws=NodeError]
160162
void disconnect(PublicKey node_id);
@@ -351,6 +353,9 @@ enum NodeError {
351353
"InvalidBlindedPaths",
352354
"AsyncPaymentServicesDisabled",
353355
"HrnParsingFailed",
356+
"LnurlAuthFailed",
357+
"LnurlAuthTimeout",
358+
"InvalidLnurl",
354359
};
355360

356361
dictionary NodeStatus {

src/builder.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ use crate::io::{
6565
use crate::liquidity::{
6666
LSPS1ClientConfig, LSPS2ClientConfig, LSPS2ServiceConfig, LiquiditySourceBuilder,
6767
};
68+
use crate::lnurl_auth::LnurlAuth;
6869
use crate::logger::{log_error, LdkLogger, LogLevel, LogWriter, Logger};
6970
use crate::message_handler::NodeCustomMessageHandler;
7071
use crate::payment::asynchronous::om_mailbox::OnionMessageMailbox;
@@ -1717,6 +1718,8 @@ fn build_with_store_internal(
17171718
None
17181719
};
17191720

1721+
let lnurl_auth = Arc::new(LnurlAuth::new(&keys_manager, Arc::clone(&logger)));
1722+
17201723
let (stop_sender, _) = tokio::sync::watch::channel(());
17211724
let (background_processor_stop_sender, _) = tokio::sync::watch::channel(());
17221725
let is_running = Arc::new(RwLock::new(false));
@@ -1762,6 +1765,7 @@ fn build_with_store_internal(
17621765
scorer,
17631766
peer_store,
17641767
payment_store,
1768+
lnurl_auth,
17651769
is_running,
17661770
node_metrics,
17671771
om_mailbox,

src/config.rs

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

102+
// The timeout after which we abort an LNURL-auth operation.
103+
pub(crate) const LNURL_AUTH_TIMEOUT_SECS: u64 = 15;
104+
102105
#[derive(Debug, Clone)]
103106
/// Represents the configuration of an [`Node`] instance.
104107
///

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: 23 additions & 0 deletions
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;
@@ -149,6 +150,7 @@ use lightning::routing::gossip::NodeAlias;
149150
use lightning::util::persist::KVStoreSync;
150151
use lightning_background_processor::process_events_async;
151152
use liquidity::{LSPS1Liquidity, LiquiditySource};
153+
use lnurl_auth::{LnurlAuth, LNURL_AUTH_TIMEOUT_SECS};
152154
use logger::{log_debug, log_error, log_info, log_trace, LdkLogger, Logger};
153155
use payment::asynchronous::om_mailbox::OnionMessageMailbox;
154156
use payment::asynchronous::static_invoice_store::StaticInvoiceStore;
@@ -222,6 +224,7 @@ pub struct Node {
222224
scorer: Arc<Mutex<Scorer>>,
223225
peer_store: Arc<PeerStore<Arc<Logger>>>,
224226
payment_store: Arc<PaymentStore>,
227+
lnurl_auth: Arc<LnurlAuth>,
225228
is_running: Arc<RwLock<bool>>,
226229
node_metrics: Arc<RwLock<NodeMetrics>>,
227230
om_mailbox: Option<Arc<OnionMessageMailbox>>,
@@ -1004,6 +1007,26 @@ impl Node {
10041007
))
10051008
}
10061009

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

src/lnurl_auth.rs

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
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 reqwest::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();
50+
Self { hashing_key, client, logger }
51+
}
52+
53+
/// Authenticates with an LNURL-auth compatible service using the provided URL.
54+
///
55+
/// The authentication process involves:
56+
/// 1. Fetching the challenge from the service
57+
/// 2. Deriving a domain-specific linking key
58+
/// 3. Signing the challenge with the linking key
59+
/// 4. Submitting the signed response to complete authentication
60+
///
61+
/// Returns `Ok(())` if authentication succeeds, or an error if the process fails.
62+
pub async fn authenticate(&self, lnurl: &str) -> Result<(), Error> {
63+
let (hrp, bytes) = bech32::decode(lnurl).map_err(|e| {
64+
log_error!(self.logger, "Failed to decode LNURL: {e}");
65+
Error::InvalidLnurl
66+
})?;
67+
68+
if hrp.to_lowercase() != "lnurl" {
69+
log_error!(self.logger, "Invalid LNURL prefix: {hrp}");
70+
return Err(Error::InvalidLnurl);
71+
}
72+
73+
let lnurl_auth_url = String::from_utf8(bytes).map_err(|e| {
74+
log_error!(self.logger, "Failed to convert LNURL bytes to string: {e}");
75+
Error::InvalidLnurl
76+
})?;
77+
78+
log_debug!(self.logger, "Starting LNURL-auth process for URL: {lnurl_auth_url}");
79+
80+
// Parse the URL to extract domain and parameters
81+
let url = reqwest::Url::parse(&lnurl_auth_url).map_err(|e| {
82+
log_error!(self.logger, "Invalid LNURL-auth URL: {e}");
83+
Error::InvalidLnurl
84+
})?;
85+
86+
let domain = url.host_str().ok_or_else(|| {
87+
log_error!(self.logger, "No domain found in LNURL-auth URL");
88+
Error::InvalidLnurl
89+
})?;
90+
91+
// get query parameters for k1 and tag
92+
let query_params: std::collections::HashMap<_, _> =
93+
url.query_pairs().into_owned().collect();
94+
95+
let tag = query_params.get("tag").ok_or_else(|| {
96+
log_error!(self.logger, "No tag parameter found in LNURL-auth URL");
97+
Error::InvalidLnurl
98+
})?;
99+
100+
if tag != "login" {
101+
log_error!(self.logger, "Invalid tag parameter in LNURL-auth URL: {tag}");
102+
return Err(Error::InvalidLnurl);
103+
}
104+
105+
let k1 = query_params.get("k1").ok_or_else(|| {
106+
log_error!(self.logger, "No k1 parameter found in LNURL-auth URL");
107+
Error::InvalidLnurl
108+
})?;
109+
110+
let k1_bytes: [u8; 32] = FromHex::from_hex(k1).map_err(|e| {
111+
log_error!(self.logger, "Invalid k1 hex in challenge: {e}");
112+
Error::LnurlAuthFailed
113+
})?;
114+
115+
// Derive domain-specific linking key
116+
let linking_secret_key = self.derive_linking_key(domain)?;
117+
let secp = Secp256k1::signing_only();
118+
let linking_public_key = linking_secret_key.public_key(&secp);
119+
120+
// Sign the challenge
121+
let message = Message::from_digest_slice(&k1_bytes).map_err(|e| {
122+
log_error!(self.logger, "Failed to create message from k1: {e}");
123+
Error::LnurlAuthFailed
124+
})?;
125+
126+
let signature = secp.sign_ecdsa(&message, &linking_secret_key);
127+
128+
// Submit authentication response
129+
let auth_url = format!("{lnurl_auth_url}&sig={signature}&key={linking_public_key}");
130+
131+
log_debug!(self.logger, "Submitting LNURL-auth response");
132+
let auth_response = self.client.get(&auth_url).send().await.map_err(|e| {
133+
log_error!(self.logger, "Failed to submit LNURL-auth response: {e}");
134+
Error::LnurlAuthFailed
135+
})?;
136+
137+
let response: LnurlAuthResponse = auth_response.json().await.map_err(|e| {
138+
log_error!(self.logger, "Failed to parse LNURL-auth response: {e}");
139+
Error::LnurlAuthFailed
140+
})?;
141+
142+
if response.status == "OK" {
143+
log_debug!(self.logger, "LNURL-auth authentication successful");
144+
Ok(())
145+
} else {
146+
let reason = response.reason.unwrap_or_else(|| "Unknown error".to_string());
147+
log_error!(self.logger, "LNURL-auth authentication failed: {reason}");
148+
Err(Error::LnurlAuthFailed)
149+
}
150+
}
151+
152+
fn derive_linking_key(&self, domain: &str) -> Result<SecretKey, Error> {
153+
// Create HMAC-SHA256 of the domain using node secret as key
154+
let mut hmac_engine = HmacEngine::<sha256::Hash>::new(&self.hashing_key[..]);
155+
hmac_engine.input(domain.as_bytes());
156+
let hmac_result = Hmac::from_engine(hmac_engine);
157+
158+
// Use HMAC result as the linking private key
159+
SecretKey::from_slice(hmac_result.as_byte_array()).map_err(|e| {
160+
log_error!(self.logger, "Failed to derive linking key: {e}");
161+
Error::LnurlAuthFailed
162+
})
163+
}
164+
}
165+
166+
#[cfg(test)]
167+
mod tests {
168+
use super::*;
169+
170+
fn build_auth(hashing_key: [u8; 32]) -> LnurlAuth {
171+
let hashing_key = SecretKey::from_slice(&hashing_key).unwrap();
172+
let logger = Arc::new(Logger::new_log_facade());
173+
LnurlAuth::new(hashing_key, logger)
174+
}
175+
176+
#[test]
177+
fn test_deterministic_key_derivation() {
178+
let auth = build_auth([42u8; 32]);
179+
let domain = "example.com";
180+
181+
// Keys should be identical for the same inputs
182+
let key1 = auth.derive_linking_key(domain).unwrap();
183+
let key2 = auth.derive_linking_key(domain).unwrap();
184+
assert_eq!(key1, key2);
185+
186+
// Keys should be different for different domains
187+
let key3 = auth.derive_linking_key("different.com").unwrap();
188+
assert_ne!(key1, key3);
189+
190+
// Keys should be different for different master keys
191+
let different_master = build_auth([24u8; 32]);
192+
let key4 = different_master.derive_linking_key(domain).unwrap();
193+
assert_ne!(key1, key4);
194+
}
195+
196+
#[test]
197+
fn test_domain_isolation() {
198+
let auth = build_auth([42u8; 32]);
199+
let domains = ["example.com", "test.org", "service.net"];
200+
let mut keys = Vec::with_capacity(domains.len());
201+
202+
for domain in &domains {
203+
keys.push(auth.derive_linking_key(domain).unwrap());
204+
}
205+
206+
for i in 0..keys.len() {
207+
for j in 0..keys.len() {
208+
if i == j {
209+
continue;
210+
}
211+
assert_ne!(
212+
keys[i], keys[j],
213+
"Keys for {} and {} should be different",
214+
domains[i], domains[j]
215+
);
216+
}
217+
}
218+
}
219+
}

0 commit comments

Comments
 (0)