dcl_crypto_middleware_rs/
signed_fetch.rs

1// This was built following the https://adr.decentraland.org/adr/ADR-44
2use dcl_crypto::{
3    authenticator::WithoutTransport, Address, AuthChain, AuthLink, Authenticator, Web3Transport,
4};
5use std::{
6    collections::HashMap,
7    time::{SystemTime, UNIX_EPOCH},
8};
9
10const AUTH_CHAIN_HEADER_PREFIX: &str = "x-identity-auth-chain-";
11const AUTH_TIMESTAMP_HEADER: &str = "x-identity-timestamp";
12const AUTH_METADATA_HEADER: &str = "x-identity-metadata";
13const DEFAULT_EXPIRATION: u32 = 1000 * 60;
14
15/// Errors returned by [`verify`]
16#[derive(Debug)]
17pub enum AuthMiddlewareError {
18    /// A provided header doesn't meet the requirements to be a valid AuthLink of the Authchain
19    InvalidMessage,
20    /// The provided timestamp within headers is not valid. It's empty or not a number
21    InvalidTimestamp,
22    /// The provided metadata within headers is not valid. It's empty.
23    InvalidMetadata,
24    /// The request is unauthorized because the signature is not valid
25    Unauthotized,
26    /// The request's timestamp expired so the request is unauthorized
27    Expired,
28}
29
30/// Options that must be provided to [`verify`] function
31pub struct VerificationOptions<T> {
32    /// Authenticator must be provided by the crate's user
33    authenticator: Authenticator<T>,
34    /// Optional expiration time. The default is `1000 * 60` ms
35    expirtation: Option<u32>,
36}
37
38impl Default for VerificationOptions<WithoutTransport> {
39    fn default() -> Self {
40        Self {
41            authenticator: Authenticator::new(),
42            expirtation: None,
43        }
44    }
45}
46
47impl<T> VerificationOptions<T> {
48    pub fn with_authenticator(authenticator: Authenticator<T>) -> Self {
49        Self {
50            authenticator,
51            expirtation: None,
52        }
53    }
54
55    pub fn authenticator<U>(self, authenticator: Authenticator<U>) -> VerificationOptions<U> {
56        VerificationOptions {
57            authenticator,
58            expirtation: self.expirtation,
59        }
60    }
61
62    pub fn expiration(self, exp: u32) -> Self {
63        Self {
64            authenticator: self.authenticator,
65            expirtation: Some(exp),
66        }
67    }
68}
69
70/// Verify the Authchain headers provided within request to identify a Decentraland user
71///
72/// The function will extract the authchain from the headers and verify them to get the user who sent the request
73///
74/// ## Arguments
75/// * method: the request's HTTP method
76/// * path: the request's path
77/// * headers: the request's headers mapped as a `HashMap<String, String>`
78/// * options: [`VerificationOptions`]
79///
80pub async fn verify<T: Web3Transport>(
81    method: &str,
82    path: &str,
83    headers: HashMap<String, String>,
84    options: VerificationOptions<T>,
85) -> Result<Address, AuthMiddlewareError> {
86    let headers = normalize_headers(headers);
87
88    let auth_chain = extract_auth_chain(&headers)?;
89
90    let timestamp = if let Some(ts) = headers.get(AUTH_TIMESTAMP_HEADER) {
91        ts
92    } else {
93        return Err(AuthMiddlewareError::InvalidTimestamp);
94    };
95
96    let ts_number = verify_ts(timestamp)?;
97
98    let metadata = if let Some(metadata) = headers.get(AUTH_METADATA_HEADER) {
99        metadata
100    } else {
101        return Err(AuthMiddlewareError::InvalidMetadata);
102    };
103
104    let payload = create_payload(method, path, timestamp, metadata);
105
106    let exp = options.expirtation.unwrap_or(DEFAULT_EXPIRATION);
107
108    verify_expiration(ts_number, exp)?;
109
110    verify_sign(options.authenticator, auth_chain, &payload).await
111}
112
113fn extract_auth_chain(headers: &HashMap<String, String>) -> Result<AuthChain, AuthMiddlewareError> {
114    let mut index = 0;
115
116    let mut auth_links = vec![];
117    while let Some(header) = headers.get(&format!("{}{}", AUTH_CHAIN_HEADER_PREFIX, index)) {
118        if let Ok(auth_link) = AuthLink::parse(header) {
119            auth_links.push(auth_link);
120        } else {
121            return Err(AuthMiddlewareError::InvalidMessage);
122        }
123
124        index += 1;
125    }
126
127    Ok(AuthChain::from(auth_links))
128}
129
130fn normalize_headers(headers: HashMap<String, String>) -> HashMap<String, String> {
131    headers
132        .iter()
133        .map(|(key, val)| (key.to_ascii_lowercase(), val.clone()))
134        .collect::<HashMap<String, String>>()
135}
136
137fn verify_ts(ts: &str) -> Result<u128, AuthMiddlewareError> {
138    ts.parse::<u128>()
139        .map_err(|_| AuthMiddlewareError::InvalidTimestamp)
140}
141
142fn create_payload(method: &str, path: &str, timestamp: &str, metadata: &str) -> String {
143    [method, path, timestamp, metadata].join(":").to_lowercase()
144}
145
146async fn verify_sign<T: Web3Transport>(
147    authenticator: Authenticator<T>,
148    auth_chain: AuthChain,
149    payload: &str,
150) -> Result<Address, AuthMiddlewareError> {
151    Ok(authenticator
152        .verify_signature(&auth_chain, payload)
153        .await
154        .map_err(|_| AuthMiddlewareError::Unauthotized)?
155        .to_owned())
156}
157
158fn verify_expiration(ts: u128, expiration: u32) -> Result<(), AuthMiddlewareError> {
159    let now = SystemTime::now()
160        .duration_since(UNIX_EPOCH)
161        .expect("not unix epoch time")
162        .as_millis();
163
164    let expected = ts + expiration as u128;
165
166    if expected < now {
167        return Err(AuthMiddlewareError::Expired);
168    }
169
170    Ok(())
171}
172
173#[cfg(test)]
174mod tests {
175    use std::time::Duration;
176
177    use crate::test_utils::create_test_identity;
178
179    use super::*;
180
181    #[tokio::test]
182    async fn verify_should_return_ok() {
183        let identity = create_test_identity();
184        let now = SystemTime::now()
185            .duration_since(UNIX_EPOCH)
186            .unwrap()
187            .as_millis();
188        let chain = identity.sign_payload(format!("get:/:{}:{}", now, "{}"));
189
190        // Should return OK if the headers are not lowercased
191        let mapped_headers = HashMap::from([
192            (
193                "X-Identity-Auth-Chain-0".to_string(),
194                serde_json::to_string(chain.get(0).unwrap()).unwrap(),
195            ),
196            (
197                "X-Identity-Auth-Chain-1".to_string(),
198                serde_json::to_string(chain.get(1).unwrap()).unwrap(),
199            ),
200            (
201                "X-Identity-Auth-Chain-2".to_string(),
202                serde_json::to_string(chain.get(2).unwrap()).unwrap(),
203            ),
204            ("X-Identity-Timestamp".to_string(), format!("{}", now)),
205            ("X-Identity-Metadata".to_string(), "{}".to_string()),
206        ]);
207
208        verify(
209            "GET",
210            "/",
211            mapped_headers,
212            VerificationOptions {
213                authenticator: Authenticator::new(),
214                expirtation: None,
215            },
216        )
217        .await
218        .unwrap();
219    }
220
221    #[tokio::test]
222    async fn verify_should_return_err() {
223        let mapped_headers = HashMap::from([
224            (
225                "x-identity-auth-chain-0".to_string(),
226                r#"{"type": "SIGNER", "payload": "0x7949f9F239D1a0816ce5Eb364A1F588AE9Cc1Bf5","signature": ""}"#.to_string(),
227            ),
228            (
229                "x-identity-auth-chain-1".to_string(),
230                r#"{"type":"ECDSA_EPHEMERAL","payload":"Decentraland Login\nEphemeral address: 0x84452bbFA4ca14B7828e2F3BBd106A2bD495CD34\nExpiration: 3021-10-16T22:32:29.626Z","signature":"0x39dd4ddf131ad2435d56c81c994c4417daef5cf5998258027ef8a1401470876a1365a6b79810dc0c4a2e9352befb63a9e4701d67b38007d83ffc4cd2b7a38ad51b"}"#.to_string(),
231            ),
232            (
233                "x-identity-auth-chain-2".to_string(),
234                r#"{"type":"ECDSA_SIGNED_ENTITY","payload":"get:/api/events:1684936391789:{}","signature":"0xc1511b724b986925896fa7f67f1004b1dbca331f32bea806456ea205904a70f723d1ecb9c0f8c52a930fccb2d2eb61ca715120d57b3226d66d8ce5e63567f27c1c"}"#.to_string(),
235            ),
236            ("x-identity-timestamp".to_string(), "".to_string()),
237            ("x-identity-metadata".to_string(), "{}".to_string()),
238        ]);
239
240        assert!(matches!(
241            verify(
242                "GET",
243                "/",
244                mapped_headers,
245                VerificationOptions {
246                    authenticator: Authenticator::new(),
247                    expirtation: None,
248                },
249            )
250            .await
251            .unwrap_err(),
252            AuthMiddlewareError::InvalidTimestamp
253        ));
254
255        let mapped_headers = HashMap::from([
256            (
257                "x-identity-auth-chain-0".to_string(),
258                r#"{"type": "SIGNER", "payload": "0x7949f9F239D1a0816ce5Eb364A1F588AE9Cc1Bf5","signature": ""}"#.to_string(),
259            ),
260            (
261                "x-identity-auth-chain-1".to_string(),
262                r#"{"type":"ECDSA_EPHEMERAL","payload":"Decentraland Login\nEphemeral address: 0x84452bbFA4ca14B7828e2F3BBd106A2bD495CD34\nExpiration: 3021-10-16T22:32:29.626Z","signature":"0x39dd4ddf131ad2435d56c81c994c4417daef5cf5998258027ef8a1401470876a1365a6b79810dc0c4a2e9352befb63a9e4701d67b38007d83ffc4cd2b7a38ad51b"}"#.to_string(),
263            ),
264            (
265                "x-identity-auth-chain-2".to_string(),
266                r#"{"type":"ECDSA_SIGNED_ENTITY","payload":"get:/api/events:1684936391789:{}","signature":"0xc1511b724b986925896fa7f67f1004b1dbca331f32bea806456ea205904a70f723d1ecb9c0f8c52a930fccb2d2eb61ca715120d57b3226d66d8ce5e63567f27c1c"}"#.to_string(),
267            ),
268            ("x-identity-metadata".to_string(), "{}".to_string()),
269        ]);
270
271        assert!(matches!(
272            verify(
273                "GET",
274                "/",
275                mapped_headers,
276                VerificationOptions {
277                    authenticator: Authenticator::new(),
278                    expirtation: None,
279                },
280            )
281            .await
282            .unwrap_err(),
283            AuthMiddlewareError::InvalidTimestamp
284        ));
285
286        let mapped_headers = HashMap::from([
287            (
288                "x-identity-auth-chain-0".to_string(),
289                r#"{"type": "SIGNER", "payload": "0x7949f9F239D1a0816ce5Eb364A1F588AE9Cc1Bf5","signature": ""}"#.to_string(),
290            ),
291            (
292                "x-identity-auth-chain-1".to_string(),
293                r#"{"type":"ECDSA_EPHEMERAL","payload":"Decentraland Login\nEphemeral address: 0x84452bbFA4ca14B7828e2F3BBd106A2bD495CD34\nExpiration: 3021-10-16T22:32:29.626Z","signature":"0x39dd4ddf131ad2435d56c81c994c4417daef5cf5998258027ef8a1401470876a1365a6b79810dc0c4a2e9352befb63a9e4701d67b38007d83ffc4cd2b7a38ad51b"}"#.to_string(),
294            ),
295            (
296                "x-identity-auth-chain-2".to_string(),
297                r#"{"type":"ECDSA_SIGNED_ENTITY","payload":"get:/api/events:1684936391789:{}","signature":"0xc1511b724b986925896fa7f67f1004b1dbca331f32bea806456ea205904a70f723d1ecb9c0f8c52a930fccb2d2eb61ca715120d57b3226d66d8ce5e63567f27c1c"}"#.to_string(),
298            ),
299            ("x-identity-timestamp".to_string(), "1684937236359".to_string()),
300        ]);
301
302        assert!(matches!(
303            verify(
304                "GET",
305                "/",
306                mapped_headers,
307                VerificationOptions {
308                    authenticator: Authenticator::new(),
309                    expirtation: None,
310                },
311            )
312            .await
313            .unwrap_err(),
314            AuthMiddlewareError::InvalidMetadata
315        ));
316
317        let past_timestamp = SystemTime::now()
318            .duration_since(UNIX_EPOCH)
319            .unwrap()
320            .checked_sub(Duration::from_secs(120))
321            .unwrap()
322            .as_millis();
323
324        let mapped_headers = HashMap::from([
325                (
326                    "x-identity-auth-chain-0".to_string(),
327                    r#"{"type": "SIGNER", "payload": "0x7949f9F239D1a0816ce5Eb364A1F588AE9Cc1Bf5","signature": ""}"#.to_string(),
328                ),
329                (
330                    "x-identity-auth-chain-1".to_string(),
331                    r#"{"type":"ECDSA_EPHEMERAL","payload":"Decentraland Login\nEphemeral address: 0x84452bbFA4ca14B7828e2F3BBd106A2bD495CD34\nExpiration: 3021-10-16T22:32:29.626Z","signature":"0x39dd4ddf131ad2435d56c81c994c4417daef5cf5998258027ef8a1401470876a1365a6b79810dc0c4a2e9352befb63a9e4701d67b38007d83ffc4cd2b7a38ad51b"}"#.to_string(),
332                ),
333                (
334                    "x-identity-auth-chain-2".to_string(),
335                    r#"{"type":"ECDSA_SIGNED_ENTITY","payload":"get:/api/events:1684936391789:{}","signature":"0xc1511b724b986925896fa7f67f1004b1dbca331f32bea806456ea205904a70f723d1ecb9c0f8c52a930fccb2d2eb61ca715120d57b3226d66d8ce5e63567f27c1c"}"#.to_string(),
336                ),
337                ("x-identity-timestamp".to_string(), format!("{}", past_timestamp)),
338                ("x-identity-metadata".to_string(), "{}".to_string()),
339            ]);
340
341        assert!(matches!(
342            verify(
343                "GET",
344                "/",
345                mapped_headers,
346                VerificationOptions {
347                    authenticator: Authenticator::new(),
348                    expirtation: None,
349                },
350            )
351            .await
352            .unwrap_err(),
353            AuthMiddlewareError::Expired
354        ));
355
356        let identity = create_test_identity();
357        let now = SystemTime::now()
358            .duration_since(UNIX_EPOCH)
359            .unwrap()
360            .as_millis();
361        let chain = identity.sign_payload(format!("get:/api/events:{}:{}", now, "{}"));
362
363        // Should return OK if the headers are not lowercased
364        let mapped_headers = HashMap::from([
365            (
366                "X-Identity-Auth-Chain-0".to_string(),
367                serde_json::to_string(chain.get(0).unwrap()).unwrap(),
368            ),
369            (
370                "X-Identity-Auth-Chain-1".to_string(),
371                serde_json::to_string(chain.get(1).unwrap()).unwrap(),
372            ),
373            (
374                "X-Identity-Auth-Chain-2".to_string(),
375                serde_json::to_string(chain.get(2).unwrap()).unwrap(),
376            ),
377            ("X-Identity-Timestamp".to_string(), format!("{}", now)),
378            ("X-Identity-Metadata".to_string(), "{}".to_string()),
379        ]);
380
381        assert!(matches!(
382            verify(
383                "GET",
384                "/",
385                mapped_headers,
386                VerificationOptions {
387                    authenticator: Authenticator::new(),
388                    expirtation: None,
389                },
390            )
391            .await
392            .unwrap_err(),
393            AuthMiddlewareError::Unauthotized
394        ));
395    }
396
397    #[test]
398    fn extract_authchain_should_return_ok() {
399        let mapped_headers = HashMap::from([
400            (
401                "x-identity-auth-chain-0".to_string(),
402                r#"{"type": "SIGNER", "payload": "0x7949f9F239D1a0816ce5Eb364A1F588AE9Cc1Bf5","signature": ""}"#.to_string(),
403            ),
404            (
405                "x-identity-auth-chain-1".to_string(),
406                r#"{"type":"ECDSA_EPHEMERAL","payload":"Decentraland Login\nEphemeral address: 0x84452bbFA4ca14B7828e2F3BBd106A2bD495CD34\nExpiration: 3021-10-16T22:32:29.626Z","signature":"0x39dd4ddf131ad2435d56c81c994c4417daef5cf5998258027ef8a1401470876a1365a6b79810dc0c4a2e9352befb63a9e4701d67b38007d83ffc4cd2b7a38ad51b"}"#.to_string(),
407            ),
408            (
409                "x-identity-auth-chain-2".to_string(),
410                r#"{"type":"ECDSA_SIGNED_ENTITY","payload":"get:/api/events:1684936391789:{}","signature":"0xc1511b724b986925896fa7f67f1004b1dbca331f32bea806456ea205904a70f723d1ecb9c0f8c52a930fccb2d2eb61ca715120d57b3226d66d8ce5e63567f27c1c"}"#.to_string(),
411            ),
412            ("x-identity-timestamp".to_string(), "1684937236359".to_string()),
413            ("x-identity-metadata".to_string(), "{}".to_string()),
414        ]);
415
416        assert!(extract_auth_chain(&mapped_headers).is_ok())
417    }
418
419    #[test]
420    fn extract_authchain_should_return_err() {
421        let mapped_headers = HashMap::from([
422            (
423                "x-identity-auth-chain-0".to_string(),
424                r#"{"type": "SIGNER", "payload": "0x7949f9F239D1a0816ce5Eb364A1F588AE9Cc1Bf5","signature": ""}"#.to_string(),
425            ),
426            (
427                "x-identity-auth-chain-1".to_string(),
428                r#"{}"#.to_string(),
429            ),
430            (
431                "x-identity-auth-chain-2".to_string(),
432                r#"{"type":"ECDSA_SIGNED_ENTITY","payload":"get:/api/events:1684936391789:{}","signature":"0xc1511b724b986925896fa7f67f1004b1dbca331f32bea806456ea205904a70f723d1ecb9c0f8c52a930fccb2d2eb61ca715120d57b3226d66d8ce5e63567f27c1c"}"#.to_string(),
433            ),
434            ("x-identity-timestamp".to_string(), "1684937236359".to_string()),
435            ("x-identity-metadata".to_string(), "{}".to_string()),
436        ]);
437
438        assert!(matches!(
439            extract_auth_chain(&mapped_headers).unwrap_err(),
440            AuthMiddlewareError::InvalidMessage
441        ))
442    }
443
444    #[test]
445    fn verify_ts_should_return_ok() {
446        let ts = "1684869538587";
447
448        assert_eq!(verify_ts(ts).unwrap(), 1684869538587)
449    }
450
451    #[test]
452    fn verify_ts_should_return_err() {
453        let ts = "1684869538d587";
454
455        assert!(matches!(
456            verify_ts(ts).unwrap_err(),
457            AuthMiddlewareError::InvalidTimestamp
458        ));
459    }
460
461    #[tokio::test]
462    async fn verify_sign_should_return_ok() {
463        let identity = create_test_identity();
464        let signed_fetch = identity.sign_payload("get:/api/events:1684869538587:{}");
465
466        let address = verify_sign(
467            Authenticator::new(),
468            signed_fetch,
469            "get:/api/events:1684869538587:{}",
470        )
471        .await
472        .unwrap();
473
474        assert_eq!(
475            address.to_string(),
476            "0x7949f9f239d1a0816ce5eb364a1f588ae9cc1bf5"
477        )
478    }
479
480    #[tokio::test]
481    async fn verify_sign_should_return_err() {
482        let identity = create_test_identity();
483        let signed_fetch = identity.sign_payload("get:/api/events:1684869538587:{}");
484
485        assert!(matches!(
486            verify_sign(
487                Authenticator::new(),
488                signed_fetch,
489                "get:/api/events:1684869538687:{}",
490            )
491            .await
492            .unwrap_err(),
493            AuthMiddlewareError::Unauthotized
494        ));
495    }
496
497    #[test]
498    fn expiration_should_return_ok() {
499        let now = SystemTime::now()
500            .duration_since(UNIX_EPOCH)
501            .unwrap()
502            .as_millis();
503
504        assert!(verify_expiration(now, DEFAULT_EXPIRATION).is_ok());
505    }
506
507    #[test]
508    fn expiration_should_return_error() {
509        let past = SystemTime::now()
510            .duration_since(UNIX_EPOCH)
511            .unwrap()
512            .checked_sub(Duration::from_secs(120))
513            .unwrap()
514            .as_millis();
515
516        assert!(verify_expiration(past, DEFAULT_EXPIRATION).is_err());
517    }
518}