vss_client/headers/
lnurl_auth_jwt.rs

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