Skip to content

Commit d51d85e

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 d51d85e

File tree

5 files changed

+265
-0
lines changed

5 files changed

+265
-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::from_keys_manager(&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/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 = self.lnurl_auth.clone();
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: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
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+
pub(crate) const LNURL_AUTH_TIMEOUT_SECS: u64 = 15;
23+
24+
#[derive(Debug, Clone, Serialize, Deserialize)]
25+
struct LnurlAuthResponse {
26+
status: String,
27+
#[serde(skip_serializing_if = "Option::is_none")]
28+
reason: Option<String>,
29+
}
30+
31+
/// An LNURL-auth handler providing authentication with LNURL-auth compatible services.
32+
///
33+
/// LNURL-auth allows secure, privacy-preserving authentication using domain-specific keys
34+
/// derived from the node's master key. Each domain gets a unique key, ensuring privacy
35+
/// while allowing consistent authentication across sessions.
36+
#[derive(Clone)]
37+
pub struct LnurlAuth {
38+
hashing_key: SecretKey,
39+
client: Client,
40+
logger: Arc<Logger>,
41+
}
42+
43+
impl LnurlAuth {
44+
pub(crate) fn new(hashing_key: SecretKey, logger: Arc<Logger>) -> Self {
45+
let client = Client::new();
46+
Self { hashing_key, client, logger }
47+
}
48+
49+
pub(crate) fn from_keys_manager(keys_manager: &KeysManager, logger: Arc<Logger>) -> Self {
50+
let hash = sha256::Hash::hash(LUD13_MESSAGE.as_bytes());
51+
let sig = keys_manager.sign_message(hash.as_byte_array());
52+
let hashed_sig = sha256::Hash::hash(sig.as_bytes());
53+
let hashing_key = SecretKey::from_slice(hashed_sig.as_byte_array())
54+
.expect("32 bytes, within curve order");
55+
Self::new(hashing_key, logger)
56+
}
57+
58+
/// Authenticates with an LNURL-auth compatible service using the provided URL.
59+
///
60+
/// The authentication process involves:
61+
/// 1. Fetching the challenge from the service
62+
/// 2. Deriving a domain-specific linking key
63+
/// 3. Signing the challenge with the linking key
64+
/// 4. Submitting the signed response to complete authentication
65+
///
66+
/// Returns `Ok(())` if authentication succeeds, or an error if the process fails.
67+
pub async fn authenticate(&self, lnurl: &str) -> Result<(), Error> {
68+
let (hrp, bytes) = bech32::decode(lnurl).map_err(|e| {
69+
log_error!(self.logger, "Failed to decode LNURL: {e}");
70+
Error::InvalidLnurl
71+
})?;
72+
73+
if hrp.to_lowercase() != "lnurl" {
74+
log_error!(self.logger, "Invalid LNURL prefix: {hrp}");
75+
return Err(Error::InvalidLnurl);
76+
}
77+
78+
let lnurl_auth_url = String::from_utf8(bytes).map_err(|e| {
79+
log_error!(self.logger, "Failed to convert LNURL bytes to string: {e}");
80+
Error::InvalidLnurl
81+
})?;
82+
83+
log_debug!(self.logger, "Starting LNURL-auth process for URL: {lnurl_auth_url}");
84+
85+
// Parse the URL to extract domain and parameters
86+
let url = reqwest::Url::parse(&lnurl_auth_url).map_err(|e| {
87+
log_error!(self.logger, "Invalid LNURL-auth URL: {e}");
88+
Error::InvalidLnurl
89+
})?;
90+
91+
let domain = url.host_str().ok_or_else(|| {
92+
log_error!(self.logger, "No domain found in LNURL-auth URL");
93+
Error::InvalidLnurl
94+
})?;
95+
96+
// get query parameters for k1 and tag
97+
let query_params: std::collections::HashMap<_, _> =
98+
url.query_pairs().into_owned().collect();
99+
100+
let tag = query_params.get("tag").ok_or_else(|| {
101+
log_error!(self.logger, "No tag parameter found in LNURL-auth URL");
102+
Error::InvalidLnurl
103+
})?;
104+
105+
if tag != "login" {
106+
log_error!(self.logger, "Invalid tag parameter in LNURL-auth URL: {tag}");
107+
return Err(Error::InvalidLnurl);
108+
}
109+
110+
let k1 = query_params.get("k1").ok_or_else(|| {
111+
log_error!(self.logger, "No k1 parameter found in LNURL-auth URL");
112+
Error::InvalidLnurl
113+
})?;
114+
115+
let k1_bytes: [u8; 32] = FromHex::from_hex(k1).map_err(|e| {
116+
log_error!(self.logger, "Invalid k1 hex in challenge: {e}");
117+
Error::LnurlAuthFailed
118+
})?;
119+
120+
// Derive domain-specific linking key
121+
let linking_secret_key = self.derive_linking_key(domain)?;
122+
let secp = Secp256k1::signing_only();
123+
let linking_public_key = linking_secret_key.public_key(&secp);
124+
125+
// Sign the challenge
126+
let message = Message::from_digest_slice(&k1_bytes).map_err(|e| {
127+
log_error!(self.logger, "Failed to create message from k1: {e}");
128+
Error::LnurlAuthFailed
129+
})?;
130+
131+
let signature = secp.sign_ecdsa(&message, &linking_secret_key);
132+
133+
// Submit authentication response
134+
let auth_url = format!("{lnurl_auth_url}&sig={signature}&key={linking_public_key}");
135+
136+
log_debug!(self.logger, "Submitting LNURL-auth response");
137+
let auth_response = self.client.get(&auth_url).send().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().await.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 hashing_key = SecretKey::from_slice(&hashing_key).unwrap();
177+
let logger = Arc::new(Logger::new_log_facade());
178+
LnurlAuth::new(hashing_key, logger)
179+
}
180+
181+
#[test]
182+
fn test_deterministic_key_derivation() {
183+
let auth = build_auth([42u8; 32]);
184+
let domain = "example.com";
185+
186+
// Keys should be identical for the same inputs
187+
let key1 = auth.derive_linking_key(domain).unwrap();
188+
let key2 = auth.derive_linking_key(domain).unwrap();
189+
assert_eq!(key1, key2);
190+
191+
// Keys should be different for different domains
192+
let key3 = auth.derive_linking_key("different.com").unwrap();
193+
assert_ne!(key1, key3);
194+
195+
// Keys should be different for different master keys
196+
let different_master = build_auth([24u8; 32]);
197+
let key4 = different_master.derive_linking_key(domain).unwrap();
198+
assert_ne!(key1, key4);
199+
}
200+
201+
#[test]
202+
fn test_domain_isolation() {
203+
let auth = build_auth([42u8; 32]);
204+
let domains = ["example.com", "test.org", "service.net"];
205+
let mut keys = Vec::with_capacity(domains.len());
206+
207+
for domain in &domains {
208+
keys.push(auth.derive_linking_key(domain).unwrap());
209+
}
210+
211+
for i in 0..keys.len() {
212+
for j in 0..keys.len() {
213+
if i == j {
214+
continue;
215+
}
216+
assert_ne!(
217+
keys[i], keys[j],
218+
"Keys for {} and {} should be different",
219+
domains[i], domains[j]
220+
);
221+
}
222+
}
223+
}
224+
}

0 commit comments

Comments
 (0)