Skip to content

Commit 655c1a2

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 f0296d2 commit 655c1a2

File tree

5 files changed

+246
-0
lines changed

5 files changed

+246
-0
lines changed

bindings/ldk_node.udl

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,8 @@ enum NodeError {
320320
"LiquidityFeeTooHigh",
321321
"InvalidBlindedPaths",
322322
"AsyncPaymentServicesDisabled",
323+
"LnurlAuthFailed",
324+
"InvalidLnurl",
323325
};
324326

325327
dictionary NodeStatus {

src/builder.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ use crate::io::{
2525
use crate::liquidity::{
2626
LSPS1ClientConfig, LSPS2ClientConfig, LSPS2ServiceConfig, LiquiditySourceBuilder,
2727
};
28+
use crate::lnurl_auth::LnurlAuth;
2829
use crate::logger::{log_error, LdkLogger, LogLevel, LogWriter, Logger};
2930
use crate::message_handler::NodeCustomMessageHandler;
3031
use crate::peer_store::PeerStore;
@@ -1645,6 +1646,8 @@ fn build_with_store_internal(
16451646
},
16461647
};
16471648

1649+
let lnurl_auth = LnurlAuth::from_keys_manager(&keys_manager, Arc::clone(&logger));
1650+
16481651
let (stop_sender, _) = tokio::sync::watch::channel(());
16491652
let (background_processor_stop_sender, _) = tokio::sync::watch::channel(());
16501653
let is_running = Arc::new(RwLock::new(false));
@@ -1674,6 +1677,7 @@ fn build_with_store_internal(
16741677
scorer,
16751678
peer_store,
16761679
payment_store,
1680+
lnurl_auth,
16771681
is_running,
16781682
is_listening,
16791683
node_metrics,

src/error.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,10 @@ pub enum Error {
124124
InvalidBlindedPaths,
125125
/// Asynchronous payment services are disabled.
126126
AsyncPaymentServicesDisabled,
127+
/// LNURL-auth authentication failed.
128+
LnurlAuthFailed,
129+
/// The provided lnurl is invalid.
130+
InvalidLnurl,
127131
}
128132

129133
impl fmt::Display for Error {
@@ -201,6 +205,8 @@ impl fmt::Display for Error {
201205
Self::AsyncPaymentServicesDisabled => {
202206
write!(f, "Asynchronous payment services are disabled.")
203207
},
208+
Self::LnurlAuthFailed => write!(f, "LNURL-auth authentication failed."),
209+
Self::InvalidLnurl => write!(f, "The provided lnurl is invalid."),
204210
}
205211
}
206212
}

src/lib.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ pub mod graph;
9090
mod hex_utils;
9191
pub mod io;
9292
pub mod liquidity;
93+
mod lnurl_auth;
9394
pub mod logger;
9495
mod message_handler;
9596
pub mod payment;
@@ -136,6 +137,7 @@ use gossip::GossipSource;
136137
use graph::NetworkGraph;
137138
use io::utils::write_node_metrics;
138139
use liquidity::{LSPS1Liquidity, LiquiditySource};
140+
use lnurl_auth::LnurlAuth;
139141
use payment::asynchronous::static_invoice_store::StaticInvoiceStore;
140142
use payment::{
141143
Bolt11Payment, Bolt12Payment, OnchainPayment, PaymentDetails, SpontaneousPayment,
@@ -202,6 +204,7 @@ pub struct Node {
202204
scorer: Arc<Mutex<Scorer>>,
203205
peer_store: Arc<PeerStore<Arc<Logger>>>,
204206
payment_store: Arc<PaymentStore>,
207+
lnurl_auth: LnurlAuth,
205208
is_running: Arc<RwLock<bool>>,
206209
is_listening: Arc<AtomicBool>,
207210
node_metrics: Arc<RwLock<NodeMetrics>>,
@@ -930,6 +933,14 @@ impl Node {
930933
))
931934
}
932935

936+
/// Authenticates the user via [LNURL-auth] for the given LNURL string.
937+
///
938+
/// [LNURL-auth]: https://github.com/lnurl/luds/blob/luds/04.md
939+
pub fn lnurl_auth(&self, lnurl: &str) -> Result<(), Error> {
940+
let auth = self.lnurl_auth.clone();
941+
self.runtime.block_on(async move { auth.authenticate(lnurl).await })
942+
}
943+
933944
/// Returns a liquidity handler allowing to request channels via the [bLIP-51 / LSPS1] protocol.
934945
///
935946
/// [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 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(hashing_key: SecretKey, logger: Arc<Logger>) -> Self {
44+
let client = Client::new();
45+
Self { hashing_key, client, logger }
46+
}
47+
48+
pub(crate) fn from_keys_manager(keys_manager: &KeysManager, logger: Arc<Logger>) -> Self {
49+
let hash = sha256::Hash::hash(LUD13_MESSAGE.as_bytes());
50+
let sig = keys_manager.sign_message(hash.as_byte_array());
51+
let hashed_sig = sha256::Hash::hash(sig.as_bytes());
52+
let hashing_key = SecretKey::from_slice(hashed_sig.as_byte_array())
53+
.expect("32 bytes, within curve order");
54+
Self::new(hashing_key, logger)
55+
}
56+
57+
/// Authenticates with an LNURL-auth compatible service using the provided URL.
58+
///
59+
/// The authentication process involves:
60+
/// 1. Fetching the challenge from the service
61+
/// 2. Deriving a domain-specific linking key
62+
/// 3. Signing the challenge with the linking key
63+
/// 4. Submitting the signed response to complete authentication
64+
///
65+
/// Returns `Ok(())` if authentication succeeds, or an error if the process fails.
66+
pub async fn authenticate(&self, lnurl: &str) -> Result<(), Error> {
67+
let (hrp, bytes) = bech32::decode(lnurl).map_err(|e| {
68+
log_error!(self.logger, "Failed to decode LNURL: {e}");
69+
Error::InvalidLnurl
70+
})?;
71+
72+
if hrp.to_lowercase() != "lnurl" {
73+
log_error!(self.logger, "Invalid LNURL prefix: {hrp}");
74+
return Err(Error::InvalidLnurl);
75+
}
76+
77+
let lnurl_auth_url = String::from_utf8(bytes).map_err(|e| {
78+
log_error!(self.logger, "Failed to convert LNURL bytes to string: {e}");
79+
Error::InvalidLnurl
80+
})?;
81+
82+
log_debug!(self.logger, "Starting LNURL-auth process for URL: {lnurl_auth_url}");
83+
84+
// Parse the URL to extract domain and parameters
85+
let url = reqwest::Url::parse(&lnurl_auth_url).map_err(|e| {
86+
log_error!(self.logger, "Invalid LNURL-auth URL: {e}");
87+
Error::InvalidLnurl
88+
})?;
89+
90+
let domain = url.host_str().ok_or_else(|| {
91+
log_error!(self.logger, "No domain found in LNURL-auth URL");
92+
Error::InvalidLnurl
93+
})?;
94+
95+
// get query parameters for k1 and tag
96+
let query_params: std::collections::HashMap<_, _> =
97+
url.query_pairs().into_owned().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 auth_response = self.client.get(&auth_url).send().await.map_err(|e| {
137+
log_error!(self.logger, "Failed to submit LNURL-auth response: {e}");
138+
Error::LnurlAuthFailed
139+
})?;
140+
141+
let response: LnurlAuthResponse = auth_response.json().await.map_err(|e| {
142+
log_error!(self.logger, "Failed to parse LNURL-auth response: {e}");
143+
Error::LnurlAuthFailed
144+
})?;
145+
146+
if response.status == "OK" {
147+
log_debug!(self.logger, "LNURL-auth authentication successful");
148+
Ok(())
149+
} else {
150+
let reason = response.reason.unwrap_or_else(|| "Unknown error".to_string());
151+
log_error!(self.logger, "LNURL-auth authentication failed: {reason}");
152+
Err(Error::LnurlAuthFailed)
153+
}
154+
}
155+
156+
fn derive_linking_key(&self, domain: &str) -> Result<SecretKey, Error> {
157+
// Get the master key from the KeysManager
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+
// Generate keys for different domains
208+
for domain in &domains {
209+
keys.push(auth.derive_linking_key(domain).unwrap());
210+
}
211+
212+
// All keys should be different (domain isolation)
213+
for i in 0..keys.len() {
214+
for j in (i + 1)..keys.len() {
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)