Skip to main content

mtv_crypto_middleware/
signed_fetch.rs

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