messagesign/
lib.rs

1//! This crate provides a [signature] and [verification] functions that can be used to
2//! sign requests and verify Signatures using Mehal signing algorithm.
3//!  
4//! It's based on <https://github.com/uv-rust/s3v4>
5//!
6//! The function returns an [Error] generated by the [::error_chain] crate which can be
7//! converted to a `String` or accessed through the `description` method or the
8//! `display_chain` and `backtrace` methods in case a full backtrace is needed.
9//!
10//! # Examples
11//!
12//! ## Signing a request
13//! ```
14//!    use error_chain::ChainedError;
15//!    let url = url::Url::parse("https://mehal.tech/endpoint").unwrap();
16//!    let signature: messagesign::Signature = messagesign::signature(
17//!        &url, // The endpoint of the mehel services
18//!        "GET",   // The http Method  
19//!        "ivegotthekey",  // the access key provided with your secret
20//!        "ivegotthesecret", // The secret provided for your project
21//!        "global", // A supported region See mehal.tech docs
22//!        &"brog",
23//!        "hostname", // the name of the machine  
24//!        "machineid", // The data in /etc/machine-id
25//!        "UNSIGNED-PAYLOAD", //payload hash, or "UNSIGNED-PAYLOAD"
26//!        "", // An empty string or a random u32
27//!    ).map_err(|err| format!("Signature error: {}", err.display_chain())).unwrap();
28//!     
29//!```
30//!
31//! ### Using the signature data to make a request
32//!
33//! #### Hyper
34//! ```ignore
35//!    let req = Request::builder()
36//!        .method(Method::GET)
37//!        .header("x-mhl-content-sha256", "UNSIGNED-PAYLOAD")
38//!        .header("x-mhl-date", &signature.date_time)
39//!        .header("x-mhl-mid", &machineid)
40//!        .header("x-mhl-hostname", &hostname)
41//!        .header("authorization", &signature.auth_header)
42//! ```
43//! #### Ureq
44//! ```ignore
45//!    let agent = AgentBuilder::new().build();
46//!    let response = agent
47//!        .put(&uri)
48//!        .set("x-mhl-content-sha256", "UNSIGNED-PAYLOAD")
49//!        .set("x-mhl-date", &signature.date_time)
50//!        .set("x-mhl-mid", &machineid)
51//!        .set("x-mhl-hostname", &hostname)
52//!        .set("authorization", &signature.auth_header)
53//!
54
55use chrono::{DateTime, Utc};
56use hmac::{Hmac, Mac};
57use sha2::{Digest, Sha256};
58use std::{collections::BTreeMap, str};
59use url::Url;
60use urlencoding::encode as url_encode;
61
62type HeadersMap = BTreeMap<String, String>;
63
64type HmacSha256 = Hmac<Sha256>;
65
66const LONG_DATETIME_FMT: &str = "%Y%m%dT%H%M%SZ";
67const SHORT_DATE_FMT: &str = "%Y%m%d";
68
69#[macro_use]
70extern crate error_chain;
71
72#[allow(unexpected_cfgs)]
73mod errors {
74    error_chain! {}
75}
76
77pub use errors::*;
78
79// -----------------------------------------------------------------------------
80/// Generate a canonical query string from the query pairs in the given URL.
81/// The current implementation does not support repeated keys, which should not
82/// be a problem for the query string used in the request.
83fn canonical_query_string(uri: &Url) -> String {
84    let mut qs = BTreeMap::new();
85    uri.query_pairs().for_each(|(k, v)| {
86        qs.insert(
87            url_encode(k.as_ref()).to_string(),
88            url_encode(&v).to_string(),
89        );
90    });
91    let kv: Vec<String> = qs.iter().map(|(k, v)| format!("{}={}", k, v)).collect();
92    kv.join("&")
93}
94
95// -----------------------------------------------------------------------------
96/// Generate a canonical header string using only x-mhl-, host and content-length headers.
97fn canonical_header_string(headers: &HeadersMap) -> String {
98    let key_values = headers
99        .iter()
100        .filter_map(|(key, value)| {
101            let k = key.as_str().to_lowercase();
102            if k.starts_with("x-mhl-") || k == "host" {
103                Some(k + ":" + value.as_str().trim())
104            } else {
105                None
106            }
107        })
108        .collect::<Vec<String>>();
109    key_values.join("\n")
110}
111
112// -----------------------------------------------------------------------------
113/// Generate a signed header string using only x-mhl-, host and content-length headers.
114fn signed_header_string(headers: &HeadersMap) -> String {
115    let keys = headers
116        .keys()
117        .filter_map(|key| {
118            let k = key.as_str().to_lowercase();
119            if k.starts_with("x-mhl-") || k == "host" {
120                Some(k)
121            } else {
122                None
123            }
124        })
125        .collect::<Vec<String>>();
126    keys.join(";")
127}
128
129// -----------------------------------------------------------------------------
130/// Generate a canonical request.
131fn canonical_request(
132    method: &str,
133    url: &Url,
134    headers: &HeadersMap,
135    payload_sha256: &str,
136) -> String {
137    format!(
138        "{method}\n{uri}\n{query_string}\n{headers}\n\n{signed}\n{sha256}",
139        method = method,
140        uri = url.path().to_ascii_lowercase(),
141        query_string = canonical_query_string(url),
142        headers = canonical_header_string(headers),
143        signed = signed_header_string(headers),
144        sha256 = payload_sha256
145    )
146}
147
148// -----------------------------------------------------------------------------
149/// Generate a scope string.
150fn scope_string(date_time: &DateTime<Utc>, region: &str, service: &str) -> String {
151    format!(
152        "{date}/{region}/{service}",
153        date = date_time.format(SHORT_DATE_FMT),
154        region = region,
155        service = service
156    )
157}
158
159// -----------------------------------------------------------------------------
160/// Generate the "string to sign" - the value to which the HMAC signing is
161/// applied to sign requests.
162fn string_to_sign(
163    date_time: &DateTime<Utc>,
164    region: &str,
165    canonical_req: &str,
166    service: &str,
167) -> String {
168    //println!("{}", service);
169
170    let mut hasher = Sha256::default();
171    hasher.update(canonical_req.as_bytes());
172    let string_to = format!(
173        "MHL4-HMAC-SHA256\n{timestamp}\n{scope}\n{hash}",
174        timestamp = date_time.format(LONG_DATETIME_FMT),
175        scope = scope_string(date_time, region, service),
176        hash = hex::encode(hasher.finalize().as_slice())
177    );
178    string_to
179}
180
181// -----------------------------------------------------------------------------
182/// Generate the Mehal signing key, derived from the secret key, date, region,
183/// and service name.
184fn signing_key(
185    date_time: &DateTime<Utc>,
186    secret_key: &str,
187    region: &str,
188    service: &str,
189) -> Result<Vec<u8>> {
190    let secret = format!("MHL{}", secret_key);
191    let mut date_hmac =
192        HmacSha256::new_from_slice(secret.as_bytes()).chain_err(|| "error hashing secret")?;
193    date_hmac.update(date_time.format(SHORT_DATE_FMT).to_string().as_bytes());
194    let mut region_hmac = HmacSha256::new_from_slice(&date_hmac.finalize().into_bytes())
195        .chain_err(|| "error hashing date")?;
196    region_hmac.update(region.to_string().as_bytes());
197    let mut service_hmac = HmacSha256::new_from_slice(&region_hmac.finalize().into_bytes())
198        .chain_err(|| "error hashing region")?;
199    service_hmac.update(service.as_bytes());
200    let mut signing_hmac = HmacSha256::new_from_slice(&service_hmac.finalize().into_bytes())
201        .chain_err(|| "error hashing service")?;
202    signing_hmac.update(b"mhl_request");
203    Ok(signing_hmac.finalize().into_bytes().to_vec())
204}
205
206// -----------------------------------------------------------------------------
207/// Generate the Mehal authorization header.
208fn authorization_header(
209    access_key: &str,
210    date_time: &DateTime<Utc>,
211    region: &str,
212    signed_headers: &str,
213    signature: &str,
214    service: &str,
215) -> String {
216    format!(
217        "MHL-HMAC-SHA256 Credential={access_key}/{scope},\
218            SignedHeaders={signed_headers},Signature={signature}",
219        access_key = access_key,
220        scope = scope_string(date_time, region, service),
221        signed_headers = signed_headers,
222        signature = signature
223    )
224}
225
226#[allow(clippy::too_many_arguments)]
227// -----------------------------------------------------------------------------
228pub fn verification(
229    method: &str,
230    payload_hash: &str,
231    url_string: &str,
232    headers: &HeadersMap,
233    date_time: &DateTime<Utc>,
234    secret: &str,
235    region: &str,
236    service: &str,
237) -> Result<String> {
238    let url = Url::parse(url_string).chain_err(|| "error parsing url")?;
239    let canonical = canonical_request(&method.to_uppercase(), &url, headers, payload_hash);
240
241    let string_to_sign = string_to_sign(date_time, region, &canonical, service);
242    let signing_key = signing_key(date_time, secret, region, service)?;
243    let mut hmac =
244        Hmac::<Sha256>::new_from_slice(&signing_key).chain_err(|| "error hashing signing key")?;
245    hmac.update(string_to_sign.as_bytes());
246    Ok(hex::encode(hmac.finalize().into_bytes()))
247}
248// -----------------------------------------------------------------------------
249/// Struct containing authorisation header and timestamp. Returned by `sign_request`.
250pub struct Signature {
251    pub auth_header: String,
252    pub date_time: String,
253}
254
255#[allow(clippy::too_many_arguments)]
256/// Return signed header and timestamp.
257pub fn signature(
258    url: &url::Url,
259    method: &str,
260    access: &str,
261    secret: &str,
262    region: &str,
263    service: &str,
264    machineid: &str,
265    hostname: &str,
266    payload_hash: &str,
267    nonce: &str,
268) -> Result<Signature> {
269    const LONG_DATE_TIME: &str = "%Y%m%dT%H%M%SZ";
270    let host_port = url
271        .host()
272        .chain_err(|| "Error parsing host from url")?
273        .to_string()
274        + &if let Some(port) = url.port() {
275            format!(":{}", port)
276        } else {
277            "".to_string()
278        };
279    let uri = url.as_str().trim_end_matches('/');
280    let mut headers = HeadersMap::new();
281
282    headers.insert("host".to_string(), host_port);
283
284    headers.insert("x-mhl-content-sha256".to_string(), payload_hash.to_string());
285    let date_time = Utc::now();
286    let date_time_string = date_time.format(LONG_DATE_TIME).to_string();
287    headers.insert("x-mhl-hostname".to_string(), hostname.to_string());
288    headers.insert("x-mhl-date".to_string(), date_time_string.clone());
289    headers.insert("x-mhl-nonce".to_string(), nonce.to_string());
290    headers.insert("x-mhl-mid".to_string(), machineid.to_string());
291
292    let signature = verification(
293        method,
294        payload_hash,
295        uri,
296        &headers,
297        &date_time,
298        secret,
299        region,
300        service,
301    )?;
302    let auth = authorization_header(
303        access,
304        &date_time,
305        region,
306        &signed_header_string(&headers),
307        &signature,
308        service,
309    );
310    Ok(Signature {
311        auth_header: auth,
312        date_time: date_time_string,
313    })
314}
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319    use chrono::{NaiveDateTime, TimeZone, Utc};
320
321    #[test]
322    fn test_signature() -> Result<()> {
323        let method = "GET";
324        let payload_hash = "UNSIGNED-PAYLOAD";
325        let region = "global";
326        let service = "test";
327        let url = url::Url::parse("https://ubifaq.com/endpoint").unwrap();
328        let secret = "ivegotthesecret";
329        let nonce = "1234567";
330        let machineid = "mid";
331        let hostname = "hostname";
332
333        let sig = signature(
334            &url,
335            method,
336            "ivegotthekey",
337            secret,
338            region,
339            service,
340            machineid,
341            hostname,
342            payload_hash,
343            nonce,
344        )
345        .unwrap();
346
347        let fixdate =
348            NaiveDateTime::parse_from_str(sig.date_time.as_str(), "%Y%m%dT%H%M%SZ").unwrap();
349        let parsedate = DateTime::<Utc>::from_naive_utc_and_offset(fixdate, Utc);
350
351        println!("parsed   {}", parsedate);
352        println!("sig date {}", &sig.date_time);
353
354        let mut headers = HeadersMap::new();
355        headers.insert("host".to_string(), "ubifaq.com".to_string());
356        headers.insert("x-mhl-content-sha256".to_string(), payload_hash.to_string());
357        headers.insert("x-mhl-date".to_string(), sig.date_time);
358        headers.insert("x-mhl-nonce".to_string(), nonce.to_string());
359        headers.insert("x-mhl-mid".to_string(), machineid.to_string());
360        headers.insert("x-mhl-hostname".to_string(), hostname.to_string());
361
362        let verification = verification(
363            method,
364            payload_hash,
365            url.as_str(),
366            &headers,
367            &parsedate,
368            secret,
369            region,
370            service,
371        )?;
372
373        println!("auth header{}", sig.auth_header);
374        println!("verified sig{}", verification.as_str());
375
376        assert!(sig.auth_header.to_string().contains(verification.as_str()));
377
378        Ok(())
379    }
380    #[test]
381    fn test_verification() -> Result<()> {
382        const EXPECTED_SIGNATURE: &str =
383            "ef3abe3ccf173e7e6374faf6ae74fba2149e29343bc635e976c4e9a47d534c92";
384        let url = "https://mehal.tech";
385        let method = "GET";
386        let payload_hash = "UNSIGNED-PAYLOAD";
387        let date_time = Utc.with_ymd_and_hms(2022, 2, 2, 0, 0, 0).unwrap();
388        let secret = "ivegotthesecret";
389        let region = "global";
390        let service = "brog";
391        let mut headers = HeadersMap::new();
392        headers.insert("host".to_string(), "aws.com".to_string());
393        headers.insert("x-mhl-content-sha256".to_string(), payload_hash.to_string());
394
395        let signature = verification(
396            method,
397            payload_hash,
398            url,
399            &headers,
400            &date_time,
401            secret,
402            region,
403            service,
404        )?;
405        assert_eq!(EXPECTED_SIGNATURE, signature);
406        Ok(())
407    }
408}