Skip to content

Commit c1f06c7

Browse files
authored
Merge pull request #26 from wvanlint/lnurl_auth
JWT authorization header based on LNURL Auth
2 parents 59769c9 + 9a5a77f commit c1f06c7

File tree

5 files changed

+445
-2
lines changed

5 files changed

+445
-2
lines changed

Cargo.toml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,21 @@ categories = ["web-programming::http-client", "cryptography::cryptocurrencies"]
1111

1212
build = "build.rs"
1313

14+
[features]
15+
default = ["lnurl-auth"]
16+
lnurl-auth = ["dep:bitcoin", "dep:url", "dep:base64", "dep:serde", "dep:serde_json", "reqwest/json"]
17+
1418
[dependencies]
1519
prost = "0.11.6"
1620
reqwest = { version = "0.11.13", default-features = false, features = ["rustls-tls"] }
1721
tokio = { version = "1", default-features = false, features = ["time"] }
1822
rand = "0.8.5"
1923
async-trait = "0.1.77"
24+
bitcoin = { version = "0.32.2", default-features = false, features = ["std", "rand-std"], optional = true }
25+
url = { version = "2.5.0", default-features = false, optional = true }
26+
base64 = { version = "0.21.7", default-features = false, optional = true }
27+
serde = { version = "1.0.196", default-features = false, features = ["serde_derive"], optional = true }
28+
serde_json = { version = "1.0.113", default-features = false, optional = true }
2029

2130
[target.'cfg(genproto)'.build-dependencies]
2231
prost-build = { version = "0.11.3" }

src/client.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ impl<R: RetryPolicy<E = VssError>> VssClient<R> {
126126
.header_provider
127127
.get_headers(&request_body)
128128
.await
129-
.and_then(get_headermap)
129+
.and_then(|h| get_headermap(&h))
130130
.map_err(|e| VssError::AuthError(e.to_string()))?;
131131
let response_raw = self
132132
.client

src/headers/lnurl_auth_jwt.rs

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

src/headers/mod.rs

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@ use std::fmt::Display;
66
use std::fmt::Formatter;
77
use std::str::FromStr;
88

9+
#[cfg(feature = "lnurl-auth")]
10+
mod lnurl_auth_jwt;
11+
12+
#[cfg(feature = "lnurl-auth")]
13+
pub use lnurl_auth_jwt::LnurlAuthToJwtProvider;
14+
915
/// Defines a trait around how headers are provided for each VSS request.
1016
#[async_trait]
1117
pub trait VssHeaderProvider {
@@ -25,6 +31,21 @@ pub enum VssHeaderProviderError {
2531
/// The error message.
2632
error: String,
2733
},
34+
/// An external request failed.
35+
RequestError {
36+
/// The error message.
37+
error: String,
38+
},
39+
/// Authorization was refused.
40+
AuthorizationError {
41+
/// The error message.
42+
error: String,
43+
},
44+
/// An application-level error occurred specific to the header provider functionality.
45+
InternalError {
46+
/// The error message.
47+
error: String,
48+
},
2849
}
2950

3051
impl Display for VssHeaderProviderError {
@@ -33,6 +54,15 @@ impl Display for VssHeaderProviderError {
3354
Self::InvalidData { error } => {
3455
write!(f, "invalid data: {}", error)
3556
}
57+
Self::RequestError { error } => {
58+
write!(f, "error performing external request: {}", error)
59+
}
60+
Self::AuthorizationError { error } => {
61+
write!(f, "authorization was refused: {}", error)
62+
}
63+
Self::InternalError { error } => {
64+
write!(f, "internal error: {}", error)
65+
}
3666
}
3767
}
3868
}
@@ -58,7 +88,7 @@ impl VssHeaderProvider for FixedHeaders {
5888
}
5989
}
6090

61-
pub(crate) fn get_headermap(headers: HashMap<String, String>) -> Result<HeaderMap, VssHeaderProviderError> {
91+
pub(crate) fn get_headermap(headers: &HashMap<String, String>) -> Result<HeaderMap, VssHeaderProviderError> {
6292
let mut headermap = HeaderMap::new();
6393
for (name, value) in headers {
6494
headermap.insert(

0 commit comments

Comments
 (0)