Skip to main content

vss_client_ng/headers/
lnurl_auth_jwt.rs

1use crate::headers::{VssHeaderProvider, VssHeaderProviderError};
2use async_trait::async_trait;
3use base64::engine::general_purpose::URL_SAFE_NO_PAD;
4use base64::Engine;
5use bitcoin::bip32::{ChildNumber, DerivationPath, Xpriv};
6use bitcoin::hashes::hex::FromHex;
7use bitcoin::hashes::sha256;
8use bitcoin::hashes::{Hash, HashEngine, Hmac, HmacEngine};
9use bitcoin::secp256k1::{Message, Secp256k1, SignOnly};
10use bitcoin::PrivateKey;
11use bitreq::Url;
12use serde::Deserialize;
13use std::collections::HashMap;
14use std::sync::RwLock;
15use std::time::{Duration, SystemTime};
16
17// Derivation index of the hashing private key as defined by LUD-05.
18const HASHING_DERIVATION_INDEX: u32 = 0;
19// The JWT token will be refreshed by the given amount before its expiry.
20const EXPIRY_BUFFER: Duration = Duration::from_secs(60);
21// The key of the LNURL k1 query parameter.
22const K1_QUERY_PARAM: &str = "k1";
23// The key of the LNURL sig query parameter.
24const SIG_QUERY_PARAM: &str = "sig";
25// The key of the LNURL key query parameter.
26const KEY_QUERY_PARAM: &str = "key";
27// The authorization header name.
28const AUTHORIZATION: &str = "Authorization";
29// The maximum body size we allow for requests.
30const MAX_RESPONSE_BODY_SIZE: usize = 16 * 1024 * 1024; // 16 KB
31
32#[derive(Debug, Clone)]
33struct JwtToken {
34	token_str: String,
35	expiry: Option<SystemTime>,
36}
37
38impl JwtToken {
39	fn is_expired(&self) -> bool {
40		self.expiry
41			.and_then(|expiry| {
42				SystemTime::now()
43					.checked_add(EXPIRY_BUFFER)
44					.map(|now_with_buffer| now_with_buffer > expiry)
45			})
46			.unwrap_or(false)
47	}
48}
49
50const DEFAULT_TIMEOUT_SECS: u64 = 10;
51
52/// Provides a JWT token based on LNURL Auth.
53pub struct LnurlAuthToJwtProvider {
54	engine: Secp256k1<SignOnly>,
55	parent_key: Xpriv,
56	url: String,
57	default_headers: HashMap<String, String>,
58	cached_jwt_token: RwLock<Option<JwtToken>>,
59}
60
61impl LnurlAuthToJwtProvider {
62	/// Creates a new JWT provider based on LNURL Auth.
63	///
64	/// The LNURL Auth keys are derived as children from a hardened parent key,
65	/// following [LUD-05](https://github.com/lnurl/luds/blob/luds/05.md).
66	/// The hardened parent extended key is given here as an argument, and is suggested to be the
67	/// `m/138'` derivation from the wallet master key as in the specification.
68	/// However, users are free to choose a consistent hardened derivation path.
69	///
70	/// The LNURL with the challenge will be retrieved by making a request to the given URL.
71	/// The JWT token will be returned in response to the signed LNURL request under a token field.
72	/// The given set of headers will be used for LNURL requests, and will also be returned together
73	/// with the JWT authorization header for VSS requests.
74	pub fn new(
75		parent_key: Xpriv, url: String, default_headers: HashMap<String, String>,
76	) -> LnurlAuthToJwtProvider {
77		let engine = Secp256k1::signing_only();
78
79		LnurlAuthToJwtProvider {
80			engine,
81			parent_key,
82			url,
83			default_headers,
84			cached_jwt_token: RwLock::new(None),
85		}
86	}
87
88	async fn fetch_jwt_token(&self) -> Result<JwtToken, VssHeaderProviderError> {
89		let client = bitreq::Client::new(1);
90		// Fetch the LNURL.
91		let lnurl_request = bitreq::get(&self.url)
92			.with_headers(self.default_headers.clone())
93			.with_timeout(DEFAULT_TIMEOUT_SECS)
94			.with_max_body_size(Some(MAX_RESPONSE_BODY_SIZE));
95		let lnurl_response =
96			client.send_async(lnurl_request).await.map_err(VssHeaderProviderError::from)?;
97		let lnurl_str = String::from_utf8(lnurl_response.into_bytes()).map_err(|e| {
98			VssHeaderProviderError::InvalidData {
99				error: format!("LNURL response is not valid UTF-8: {}", e),
100			}
101		})?;
102
103		// Sign the LNURL and perform the request.
104		let signed_lnurl = sign_lnurl(&self.engine, &self.parent_key, &lnurl_str)?;
105		let auth_request = bitreq::get(&signed_lnurl)
106			.with_headers(self.default_headers.clone())
107			.with_timeout(DEFAULT_TIMEOUT_SECS)
108			.with_max_body_size(Some(MAX_RESPONSE_BODY_SIZE));
109		let auth_response =
110			client.send_async(auth_request).await.map_err(VssHeaderProviderError::from)?;
111		let lnurl_auth_response: LnurlAuthResponse =
112			serde_json::from_slice(&auth_response.into_bytes()).map_err(|e| {
113				VssHeaderProviderError::InvalidData {
114					error: format!("Failed to parse LNURL Auth response as JSON: {}", e),
115				}
116			})?;
117
118		let untrusted_token = match lnurl_auth_response {
119			LnurlAuthResponse { token: Some(token), .. } => token,
120			LnurlAuthResponse { reason: Some(reason), .. } => {
121				return Err(VssHeaderProviderError::AuthorizationError {
122					error: format!("LNURL Auth failed, reason is: {}", reason.escape_debug()),
123				});
124			},
125			_ => {
126				return Err(VssHeaderProviderError::InvalidData {
127					error: "LNURL Auth response did not contain a token nor an error".to_string(),
128				});
129			},
130		};
131		parse_jwt_token(untrusted_token)
132	}
133
134	async fn get_jwt_token(&self) -> Result<String, VssHeaderProviderError> {
135		let cached_token_str = self
136			.cached_jwt_token
137			.read()
138			.unwrap()
139			.as_ref()
140			.filter(|t| !t.is_expired())
141			.map(|t| t.token_str.clone());
142		if let Some(token_str) = cached_token_str {
143			Ok(token_str)
144		} else {
145			let jwt_token = self.fetch_jwt_token().await?;
146			*self.cached_jwt_token.write().unwrap() = Some(jwt_token.clone());
147			Ok(jwt_token.token_str)
148		}
149	}
150}
151
152#[async_trait]
153impl VssHeaderProvider for LnurlAuthToJwtProvider {
154	async fn get_headers(
155		&self, _request: &[u8],
156	) -> Result<HashMap<String, String>, VssHeaderProviderError> {
157		let jwt_token = self.get_jwt_token().await?;
158		let mut headers = self.default_headers.clone();
159		headers.insert(AUTHORIZATION.to_string(), format!("Bearer {}", jwt_token));
160		Ok(headers)
161	}
162}
163
164fn hashing_key(
165	engine: &Secp256k1<SignOnly>, parent_key: &Xpriv,
166) -> Result<PrivateKey, VssHeaderProviderError> {
167	let hashing_child_number = ChildNumber::from_normal_idx(HASHING_DERIVATION_INDEX)
168		.map_err(VssHeaderProviderError::from)?;
169	parent_key
170		.derive_priv(engine, &vec![hashing_child_number])
171		.map(|xpriv| xpriv.to_priv())
172		.map_err(VssHeaderProviderError::from)
173}
174
175fn linking_key_path(
176	hashing_key: &PrivateKey, domain_name: &str,
177) -> Result<DerivationPath, VssHeaderProviderError> {
178	let mut engine = HmacEngine::<sha256::Hash>::new(&hashing_key.inner[..]);
179	engine.input(domain_name.as_bytes());
180	let result = Hmac::<sha256::Hash>::from_engine(engine).to_byte_array();
181	// unwrap safety: We take 4-byte chunks, so TryInto for [u8; 4] never fails.
182	let children = result
183		.chunks_exact(4)
184		.take(4)
185		.map(|i| u32::from_be_bytes(i.try_into().unwrap()))
186		.map(ChildNumber::from);
187	Ok(DerivationPath::from_iter(children))
188}
189
190fn sign_lnurl(
191	engine: &Secp256k1<SignOnly>, parent_key: &Xpriv, lnurl_str: &str,
192) -> Result<String, VssHeaderProviderError> {
193	// Parse k1 parameter to sign.
194	let invalid_lnurl = || VssHeaderProviderError::InvalidData {
195		error: format!("invalid lnurl: {}", lnurl_str.escape_debug()),
196	};
197	let mut lnurl = Url::parse(lnurl_str).map_err(|_| invalid_lnurl())?;
198	let domain = lnurl.base_url();
199	let k1_str = lnurl
200		.query_pairs()
201		.find(|(k, _)| k == K1_QUERY_PARAM)
202		.ok_or(invalid_lnurl())?
203		.1
204		.to_string();
205	let k1: [u8; 32] = FromHex::from_hex(&k1_str).map_err(|_| invalid_lnurl())?;
206
207	// Sign k1 parameter with linking private key.
208	let hashing_private_key = hashing_key(engine, parent_key)?;
209	let linking_key_path = linking_key_path(&hashing_private_key, domain)?;
210	let linking_private_key = parent_key
211		.derive_priv(engine, &linking_key_path)
212		.map_err(VssHeaderProviderError::from)?
213		.to_priv();
214	let linking_public_key = linking_private_key.public_key(engine);
215	let message = Message::from_digest_slice(&k1).map_err(|_| {
216		VssHeaderProviderError::InvalidData { error: format!("invalid k1: {:?}", k1) }
217	})?;
218	let sig = engine.sign_ecdsa(&message, &linking_private_key.inner);
219
220	// Compose LNURL with signature and linking public key.
221	let serialized_sig = sig.serialize_der().to_string();
222	let serialized_pubkey = linking_public_key.to_string();
223	let query_params =
224		[(SIG_QUERY_PARAM, serialized_sig.as_str()), (KEY_QUERY_PARAM, serialized_pubkey.as_str())];
225	lnurl.append_query_params(query_params.into_iter());
226	Ok(lnurl.to_string())
227}
228
229#[derive(Deserialize, Debug, Clone)]
230struct LnurlAuthResponse {
231	reason: Option<String>,
232	token: Option<String>,
233}
234
235#[derive(Deserialize, Debug, Clone)]
236struct ExpiryClaim {
237	#[serde(rename = "exp")]
238	expiry_secs: Option<u64>,
239}
240
241fn parse_jwt_token(jwt_token: String) -> Result<JwtToken, VssHeaderProviderError> {
242	let parts: Vec<&str> = jwt_token.split('.').collect();
243	let invalid = || VssHeaderProviderError::InvalidData {
244		error: format!("invalid JWT token: {}", jwt_token.escape_debug()),
245	};
246	if parts.len() != 3 {
247		return Err(invalid());
248	}
249	let _ = URL_SAFE_NO_PAD.decode(parts[0]).map_err(|_| invalid())?;
250	let bytes = URL_SAFE_NO_PAD.decode(parts[1]).map_err(|_| invalid())?;
251	let _ = URL_SAFE_NO_PAD.decode(parts[2]).map_err(|_| invalid())?;
252	let claim: ExpiryClaim = serde_json::from_slice(&bytes).map_err(|_| invalid())?;
253	let expiry =
254		claim.expiry_secs.and_then(|e| SystemTime::UNIX_EPOCH.checked_add(Duration::from_secs(e)));
255	Ok(JwtToken { token_str: jwt_token, expiry })
256}
257
258impl From<bitcoin::bip32::Error> for VssHeaderProviderError {
259	fn from(e: bitcoin::bip32::Error) -> VssHeaderProviderError {
260		VssHeaderProviderError::InternalError { error: e.to_string() }
261	}
262}
263
264impl From<bitreq::Error> for VssHeaderProviderError {
265	fn from(e: bitreq::Error) -> VssHeaderProviderError {
266		VssHeaderProviderError::RequestError { error: e.to_string() }
267	}
268}
269
270#[cfg(test)]
271mod test {
272	use crate::headers::lnurl_auth_jwt::{linking_key_path, sign_lnurl};
273	use bitcoin::bip32::Xpriv;
274	use bitcoin::hashes::hex::FromHex;
275	use bitcoin::secp256k1::Secp256k1;
276	use bitcoin::secp256k1::SecretKey;
277	use bitcoin::Network;
278	use bitcoin::PrivateKey;
279	use std::str::FromStr;
280
281	#[test]
282	fn test_linking_key_path() {
283		// Test vector from:
284		// https://github.com/lnurl/luds/blob/43cf7754de2033987a7661afc8b4a3998914a536/05.md
285		let hashing_key = PrivateKey::new(
286			SecretKey::from_str("7d417a6a5e9a6a4a879aeaba11a11838764c8fa2b959c242d43dea682b3e409b")
287				.unwrap(),
288			Network::Testnet, // The network only matters for serialization.
289		);
290		let path = linking_key_path(&hashing_key, "site.com").unwrap();
291		let numbers: Vec<u32> = path.into_iter().map(|c| u32::from(c.clone())).collect();
292		assert_eq!(numbers, vec![1588488367, 2659270754, 38110259, 4136336762]);
293	}
294
295	#[test]
296	fn test_sign_lnurl() {
297		let engine = Secp256k1::signing_only();
298		let parent_key_bytes: [u8; 32] =
299			FromHex::from_hex("abababababababababababababababababababababababababababababababab")
300				.unwrap();
301		let parent_key = Xpriv::new_master(Network::Testnet, &parent_key_bytes).unwrap();
302		let signed = sign_lnurl(
303			&engine,
304			&parent_key,
305			"https://example.com/path?tag=login&k1=e2af6254a8df433264fa23f67eb8188635d15ce883e8fc020989d5f82ae6f11e",
306		)
307		.unwrap();
308		assert_eq!(
309			signed,
310			"https://example.com/path?tag=login&k1=e2af6254a8df433264fa23f67eb8188635d15ce883e8fc020989d5f82ae6f11e&sig=3045022100a75df468de452e618edb8030016eb0894204655c7d93ece1be007fcf36843522022048bc2f00a0a5a30601d274b49cfaf9ef4c76176e5401d0dfb195f5d6ab8ab4c4&key=02d9eb1b467517d685e3b5439082c14bb1a2c9ae672df4d9046d208c193a5846e0",
311		);
312	}
313}