Skip to content

Commit b8808cc

Browse files
committed
JWT authorization header based on LNURL Auth
1 parent 59769c9 commit b8808cc

File tree

5 files changed

+429
-3
lines changed

5 files changed

+429
-3
lines changed

Cargo.toml

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

1212
build = "build.rs"
1313

14+
[features]
15+
lnurl-auth = ["dep:bitcoin", "dep:url", "dep:base64", "dep:serde", "dep:serde_json"]
16+
1417
[dependencies]
1518
prost = "0.11.6"
16-
reqwest = { version = "0.11.13", default-features = false, features = ["rustls-tls"] }
19+
reqwest = { version = "0.11.13", default-features = false, features = ["rustls-tls", "json"] }
1720
tokio = { version = "1", default-features = false, features = ["time"] }
1821
rand = "0.8.5"
1922
async-trait = "0.1.77"
23+
bitcoin = { version = "0.30.2", optional = true }
24+
url = { version = "2.5.0", optional = true }
25+
base64 = { version = "0.21.7", optional = true }
26+
serde = { version = "1.0.196", features = ["serde_derive"], optional = true }
27+
serde_json = { version = "1.0.113", optional = true }
2028

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

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::LnurlAuthJwt;
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)