Skip to content

Commit 35a9bc9

Browse files
committed
JWT authorization header based on LNURL Auth
1 parent 44b0787 commit 35a9bc9

File tree

7 files changed

+508
-2
lines changed

7 files changed

+508
-2
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
/target
22
/src/proto/
3+
/Cargo.lock

Cargo.toml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,15 @@ build = "build.rs"
1313

1414
[dependencies]
1515
prost = "0.11.6"
16-
reqwest = { version = "0.11.13", default-features = false, features = ["rustls-tls"] }
16+
reqwest = { version = "0.11.13", default-features = false, features = ["rustls-tls", "json"] }
1717
tokio = { version = "1", default-features = false, features = ["time"] }
1818
rand = "0.8.5"
19+
bitcoin = "0.30.2"
20+
async-trait = "0.1.77"
21+
url = "2.5.0"
22+
base64 = "0.21.7"
23+
serde = { version = "1.0.196", features = ["serde_derive"] }
24+
serde_json = "1.0.113"
1925

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

src/client.rs

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
use prost::Message;
22
use reqwest;
3+
use reqwest::header::HeaderMap;
34
use reqwest::header::CONTENT_TYPE;
45
use reqwest::Client;
56
use std::default::Default;
7+
use std::sync::Arc;
68

79
use crate::error::VssError;
10+
use crate::headers::FixedHeaders;
11+
use crate::headers::HeaderProvider;
812
use crate::types::{
913
DeleteObjectRequest, DeleteObjectResponse, GetObjectRequest, GetObjectResponse, ListKeyVersionsRequest,
1014
ListKeyVersionsResponse, PutObjectRequest, PutObjectResponse,
@@ -23,6 +27,7 @@ where
2327
base_url: String,
2428
client: Client,
2529
retry_policy: R,
30+
header_provider: Arc<dyn HeaderProvider>,
2631
}
2732

2833
impl<R: RetryPolicy<E = VssError>> VssClient<R> {
@@ -34,7 +39,19 @@ impl<R: RetryPolicy<E = VssError>> VssClient<R> {
3439

3540
/// Constructs a [`VssClient`] from a given [`reqwest::Client`], using `base_url` as the VSS server endpoint.
3641
pub fn from_client(base_url: &str, client: Client, retry_policy: R) -> Self {
37-
Self { base_url: String::from(base_url), client, retry_policy }
42+
Self {
43+
base_url: String::from(base_url),
44+
client,
45+
retry_policy,
46+
header_provider: Arc::new(FixedHeaders::new(HeaderMap::new())),
47+
}
48+
}
49+
50+
/// Constructs a [`VssClient`] using `base_url` as the VSS server endpoint.
51+
/// HTTP headers will be provided by the given `header_provider`.
52+
pub fn new_with_headers(base_url: &str, retry_policy: R, header_provider: Arc<dyn HeaderProvider>) -> Self {
53+
let client = Client::new();
54+
Self { base_url: String::from(base_url), client, retry_policy, header_provider }
3855
}
3956

4057
/// Returns the underlying base URL.
@@ -111,10 +128,16 @@ impl<R: RetryPolicy<E = VssError>> VssClient<R> {
111128

112129
async fn post_request<Rq: Message, Rs: Message + Default>(&self, request: &Rq, url: &str) -> Result<Rs, VssError> {
113130
let request_body = request.encode_to_vec();
131+
let headers = self
132+
.header_provider
133+
.get_headers()
134+
.await
135+
.map_err(|e| VssError::InternalError(e.to_string()))?;
114136
let response_raw = self
115137
.client
116138
.post(url)
117139
.header(CONTENT_TYPE, APPLICATION_OCTET_STREAM)
140+
.headers(headers)
118141
.body(request_body)
119142
.send()
120143
.await?;

src/headers/lnurl_auth_jwt.rs

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

0 commit comments

Comments
 (0)