Skip to main content

reqsign_volcengine_tos/
sign_request.rs

1// Licensed to the Apache Software Foundation (ASF) under one
2// or more contributor license agreements.  See the NOTICE file
3// distributed with this work for additional information
4// regarding copyright ownership.  The ASF licenses this file
5// to you under the Apache License, Version 2.0 (the
6// "License"); you may not use this file except in compliance
7// with the License.  You may obtain a copy of the License at
8//
9//   http://www.apache.org/licenses/LICENSE-2.0
10//
11// Unless required by applicable law or agreed to in writing,
12// software distributed under the License is distributed on an
13// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14// KIND, either express or implied.  See the License for the
15// specific language governing permissions and limitations
16// under the License.
17
18use http::header::AUTHORIZATION;
19use http::{HeaderName, HeaderValue, header};
20use log::debug;
21use percent_encoding::percent_decode_str;
22use reqsign_core::hash::{hex_hmac_sha256, hex_sha256, hmac_sha256};
23use reqsign_core::time::Timestamp;
24use reqsign_core::{Context, Result, SignRequest, SigningRequest};
25use std::fmt::Write;
26use std::sync::LazyLock;
27
28use crate::constants::*;
29use crate::credential::Credential;
30use crate::uri::{percent_encode_path, percent_encode_query};
31
32static HEADER_TOS_DATE: LazyLock<HeaderName> =
33    LazyLock::new(|| HeaderName::from_static("x-tos-date"));
34static HEADER_TOS_SECURITY_TOKEN: LazyLock<HeaderName> =
35    LazyLock::new(|| HeaderName::from_static("x-tos-security-token"));
36
37/// RequestSigner that implements Volcengine TOS signing.
38///
39/// - [Volcengine TOS Signature](https://www.volcengine.com/docs/6349/1747874)
40#[derive(Debug)]
41pub struct RequestSigner {
42    region: String,
43    time: Option<Timestamp>,
44}
45
46impl RequestSigner {
47    /// Create a new RequestSigner for the given region.
48    pub fn new(region: &str) -> Self {
49        Self {
50            region: region.to_string(),
51            time: None,
52        }
53    }
54
55    /// Specify the signing time.
56    ///
57    /// # Note
58    ///
59    /// We should always take current time to sign requests.
60    /// Only use this function for testing.
61    #[cfg(test)]
62    pub fn with_time(mut self, time: Timestamp) -> Self {
63        self.time = Some(time);
64        self
65    }
66}
67impl SignRequest for RequestSigner {
68    type Credential = Credential;
69
70    async fn sign_request(
71        &self,
72        _ctx: &Context,
73        req: &mut http::request::Parts,
74        credential: Option<&Self::Credential>,
75        _expires_in: Option<std::time::Duration>,
76    ) -> Result<()> {
77        let Some(cred) = credential else {
78            return Ok(());
79        };
80
81        let now = self.time.unwrap_or_else(Timestamp::now);
82
83        let mut signing_req = SigningRequest::build(req)?;
84
85        // Insert HOST header if not present.
86        if signing_req.headers.get(header::HOST).is_none() {
87            signing_req.headers.insert(
88                header::HOST,
89                signing_req.authority.as_str().parse().map_err(|e| {
90                    reqsign_core::Error::unexpected(format!(
91                        "failed to parse authority as header value: {e}"
92                    ))
93                })?,
94            );
95        }
96
97        let date_str = now.format_iso8601();
98        let date_only = now.format_date();
99
100        signing_req
101            .headers
102            .insert(&*HEADER_TOS_DATE, date_str.parse()?);
103
104        if let Some(token) = &cred.session_token {
105            signing_req
106                .headers
107                .insert(&*HEADER_TOS_SECURITY_TOKEN, token.parse()?);
108        }
109
110        canonicalize_query(&mut signing_req);
111        let (canonical_request_hash, _) = canonical_request_hash(&mut signing_req)?;
112
113        // Scope: "<date>/<region>/tos/request"
114        let credential_scope = format!("{}/{}/tos/request", date_only, self.region);
115
116        // StringToSign:
117        //
118        // TOS4-HMAC-SHA256
119        // <iso8601_date>
120        // <scope>
121        // <hashed_canonical_request>
122        let string_to_sign = {
123            let mut s = String::new();
124            writeln!(s, "TOS4-HMAC-SHA256")?;
125            writeln!(s, "{}", date_str)?;
126            writeln!(s, "{}", credential_scope)?;
127            s.push_str(&canonical_request_hash);
128            s
129        };
130
131        debug!("string to sign: {}", &string_to_sign);
132
133        let signed_headers_str = signing_req.header_name_to_vec_sorted().join(";");
134
135        let signing_key = generate_signing_key(&cred.secret_access_key, &date_only, &self.region);
136        let signature = hex_hmac_sha256(&signing_key, string_to_sign.as_bytes());
137
138        let authorization = format!(
139            "TOS4-HMAC-SHA256 Credential={}/{}, SignedHeaders={}, Signature={}",
140            cred.access_key_id, credential_scope, signed_headers_str, signature
141        );
142
143        debug!("authorization: {}", &authorization);
144
145        let mut auth_value: HeaderValue = authorization.parse()?;
146        auth_value.set_sensitive(true);
147        signing_req.headers.insert(AUTHORIZATION, auth_value);
148
149        signing_req.apply(req)
150    }
151}
152
153fn canonicalize_query(ctx: &mut SigningRequest) {
154    ctx.query = ctx
155        .query
156        .iter()
157        .map(|(k, v)| (percent_encode_query(k), percent_encode_query(v)))
158        .collect();
159    // Sort by param name
160    ctx.query.sort();
161}
162
163fn canonical_request_hash(ctx: &mut SigningRequest) -> Result<(String, String)> {
164    let mut canonical_request = String::with_capacity(256);
165
166    // Insert method
167    canonical_request.push_str(ctx.method.as_str());
168    canonical_request.push('\n');
169
170    // Insert encoded path
171    let path = percent_decode_str(&ctx.path)
172        .decode_utf8()
173        .map_err(|e| reqsign_core::Error::unexpected(format!("failed to decode path: {e}")))?;
174    let canonical_path = percent_encode_path(&path);
175    canonical_request.push_str(&canonical_path);
176    canonical_request.push('\n');
177
178    // Insert encoded query
179    let query_string = ctx
180        .query
181        .iter()
182        .map(|(k, v)| format!("{}={}", k, v))
183        .collect::<Vec<_>>()
184        .join("&");
185
186    canonical_request.push_str(&query_string);
187    canonical_request.push('\n');
188
189    // Insert signed headers
190    let signed_headers = ctx.header_name_to_vec_sorted();
191
192    for header in &signed_headers {
193        let value = &ctx.headers[*header];
194        canonical_request.push_str(header);
195        canonical_request.push(':');
196        if let Ok(value_str) = value.to_str() {
197            canonical_request.push_str(value_str.trim());
198        }
199        canonical_request.push('\n');
200    }
201
202    canonical_request.push('\n');
203    canonical_request.push_str(signed_headers.join(";").as_str());
204    canonical_request.push('\n');
205
206    canonical_request.push_str(EMPTY_PAYLOAD_SHA256);
207
208    let hash = hex_sha256(canonical_request.as_bytes());
209
210    Ok((hash, canonical_request))
211}
212
213fn generate_signing_key(secret: &str, date: &str, region: &str) -> Vec<u8> {
214    // Sign date
215    let sign_date = hmac_sha256(secret.as_bytes(), date.as_bytes());
216    // Sign region
217    let sign_region = hmac_sha256(sign_date.as_slice(), region.as_bytes());
218    // Sign service
219    let sign_service = hmac_sha256(sign_region.as_slice(), "tos".as_bytes());
220    // Sign request
221    hmac_sha256(sign_service.as_slice(), "request".as_bytes())
222}
223
224#[cfg(test)]
225mod tests {
226    use std::str::FromStr;
227
228    use http::Uri;
229
230    use super::*;
231    use crate::provide_credential::StaticCredentialProvider;
232    use reqsign_core::{Context, OsEnv, Signer};
233    use reqsign_file_read_tokio::TokioFileRead;
234    use reqsign_http_send_reqwest::ReqwestHttpSend;
235
236    #[tokio::test]
237    async fn test_sign_request() -> Result<()> {
238        let _ = env_logger::builder().is_test(true).try_init();
239
240        let loader = StaticCredentialProvider::new("testAK", "testSK");
241        let signer = RequestSigner::new("cn-beijing")
242            .with_time(Timestamp::parse_rfc2822("Sat, 1 Jan 2022 00:00:00 GMT")?);
243
244        let ctx = Context::new()
245            .with_file_read(TokioFileRead)
246            .with_http_send(ReqwestHttpSend::default())
247            .with_env(OsEnv);
248
249        let signer = Signer::new(ctx, loader, signer);
250
251        let get_req = "https://examplebucket.tos-cn-beijing.volces.com/exampleobject";
252        let mut req = http::Request::get(Uri::from_str(get_req)?).body(())?;
253        req.headers_mut().insert(
254            HeaderName::from_str("x-tos-content-sha256")?,
255            HeaderValue::from_str(
256                "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
257            )?,
258        );
259
260        let (mut parts, _) = req.into_parts();
261        signer.sign(&mut parts, None).await?;
262
263        let headers = parts.headers;
264        let tos_date = headers.get("x-tos-date").unwrap();
265        let auth = headers.get("Authorization").unwrap();
266
267        assert!(
268            tos_date.to_str()?.starts_with("2022"),
269            "x-tos-date should be in ISO8601 format"
270        );
271        assert_eq!(
272            "TOS4-HMAC-SHA256 Credential=testAK/20220101/cn-beijing/tos/request, SignedHeaders=host;x-tos-content-sha256;x-tos-date, Signature=d40b66cf0054d1642843670d10fa095e1609c7896f25df217770b0abe717693b",
273            auth.to_str()?
274        );
275
276        Ok(())
277    }
278
279    #[tokio::test]
280    async fn test_sign_list_objects() -> Result<()> {
281        let _ = env_logger::builder().is_test(true).try_init();
282        let loader = StaticCredentialProvider::new("testAK", "testSK");
283
284        let signer = RequestSigner::new("cn-beijing").with_time("2026-02-03T12:24:12Z".parse()?);
285
286        let ctx = Context::new()
287            .with_file_read(TokioFileRead)
288            .with_http_send(ReqwestHttpSend::default())
289            .with_env(OsEnv);
290        let signer = Signer::new(ctx, loader, signer);
291
292        let req = http::Request::get("https://bucket.tos-cn-beijing.volces.com?list-type=2&prefix=abc&delimiter=%2F&max-keys=5&continuation-token=whvFnl2rE5vm9cWvQSgxwpc7QXHY7dgUGQ7nxlsVxFymg2%2BK227j5IHQZ32h").body(())?;
293        let (mut parts, _) = req.into_parts();
294
295        signer.sign(&mut parts, None).await?;
296
297        let headers = parts.headers;
298        let auth = headers.get("Authorization").unwrap();
299
300        assert_eq!(
301            "TOS4-HMAC-SHA256 Credential=testAK/20260203/cn-beijing/tos/request, SignedHeaders=host;x-tos-date, Signature=db01ee877fa24847ec042703353a76a0e11bd9b6ce68eabe5ccb2924420156b0",
302            auth.to_str()?
303        );
304        Ok(())
305    }
306}