azure.rs (7653B)
1 /* 2 * This file is part of LibEuFin. 3 * Copyright (C) 2026 Taler Systems S.A. 4 5 * LibEuFin is free software; you can redistribute it and/or modify 6 * it under the terms of the GNU Affero General Public License as 7 * published by the Free Software Foundation; either version 3, or 8 * (at your option) any later version. 9 10 * LibEuFin is distributed in the hope that it will be useful, but 11 * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY 12 * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General 13 * Public License for more details. 14 15 * You should have received a copy of the GNU Affero General Public 16 * License along with LibEuFin; see the file COPYING. If not, see 17 * <http://www.gnu.org/licenses/> 18 */ 19 20 use std::str::FromStr; 21 22 use aws_lc_rs::hmac::{self, HMAC_SHA256}; 23 use axum::{ 24 body::Bytes, 25 http::{HeaderName, HeaderValue}, 26 }; 27 use compact_str::{CompactString, ToCompactString}; 28 use http_client::{ApiErr, Client, ClientErr, builder::Req}; 29 use jiff::{Timestamp, Zoned, tz::TimeZone}; 30 use reqwest::{ 31 Method, StatusCode, Url, 32 header::{ 33 AUTHORIZATION, CONTENT_ENCODING, CONTENT_LANGUAGE, CONTENT_TYPE, IF_MATCH, 34 IF_MODIFIED_SINCE, IF_NONE_MATCH, IF_RANGE, IF_UNMODIFIED_SINCE, 35 }, 36 }; 37 use taler_common::encoding::base64; 38 use thiserror::Error; 39 40 const API_VERSION: &str = "2025-11-05"; 41 42 #[derive(Debug, Error)] 43 44 pub enum AzureErr { 45 #[error("{status} {code}")] 46 Api { 47 status: StatusCode, 48 code: CompactString, 49 }, 50 #[error(transparent)] 51 Client(#[from] ClientErr), 52 } 53 54 pub type AzureResult = Result<(), ApiErr<AzureErr>>; 55 56 pub struct AzureBlobStorage<'a> { 57 base_url: Url, 58 account: &'a str, 59 key: hmac::Key, 60 client: &'a Client, 61 } 62 63 impl<'a> AzureBlobStorage<'a> { 64 pub fn new( 65 base_url: &'a str, 66 account: &'a str, 67 key: &str, 68 client: &'a Client, 69 ) -> anyhow::Result<Self> { 70 let decoded = base64::decode(key)?; 71 Ok(Self { 72 base_url: Url::from_str(base_url)?, 73 account, 74 key: hmac::Key::new(HMAC_SHA256, &decoded), 75 client, 76 }) 77 } 78 79 async fn req(&self, req: Req) -> AzureResult { 80 // Set required headers (x-ms-date and x-ms-version) 81 // Azure uses x-ms-date instead of the standard Date header for signing 82 let date = Zoned::new(Timestamp::now(), TimeZone::UTC) 83 .strftime("%a, %d %b %Y %H:%M:%S GMT") 84 .to_string(); 85 let req = req 86 .header("x-ms-date", date) 87 .header("x-ms-version", API_VERSION); 88 89 // Calculate the HMAC-SHA256 signature 90 let signature = { 91 let mut ctx = aws_lc_rs::hmac::Context::with_key(&self.key); 92 93 let add_str = |sign: &mut aws_lc_rs::hmac::Context, value: &str| { 94 sign.update(value.as_bytes()); 95 sign.update(b"\n"); 96 }; 97 let add_header = |sign: &mut aws_lc_rs::hmac::Context, name: &HeaderName| { 98 sign.update( 99 req.headers() 100 .get(name) 101 .map(|it| it.as_bytes()) 102 .unwrap_or_default(), 103 ); 104 sign.update(b"\n"); 105 }; 106 // 1. VERB 107 add_str(&mut ctx, req.method().as_str()); 108 // 2. Content-Encoding 109 add_header(&mut ctx, &CONTENT_ENCODING); 110 // 3. Content-Language 111 add_header(&mut ctx, &CONTENT_LANGUAGE); 112 // 4. Content-Length (empty string if zero for modern versions) 113 let len = req.body().len(); 114 if len != 0 { 115 add_str(&mut ctx, &len.to_compact_string()); 116 } else { 117 add_str(&mut ctx, ""); 118 } 119 // 5. Content-MD5 120 add_header(&mut ctx, &HeaderName::from_static("content-md5")); 121 // 6. Content-Type 122 add_header(&mut ctx, &CONTENT_TYPE); 123 // 7. Date 124 add_str(&mut ctx, ""); // Must be empty as x-ms-date is used) 125 // 8. If-Modified-Since 126 add_header(&mut ctx, &IF_MODIFIED_SINCE); 127 // 9. If-Match 128 add_header(&mut ctx, &IF_MATCH); 129 // 10. If-None-Match 130 add_header(&mut ctx, &IF_NONE_MATCH); 131 // 11. If-Unmodified-Since 132 add_header(&mut ctx, &IF_UNMODIFIED_SINCE); 133 // 12. Range 134 add_header(&mut ctx, &IF_RANGE); 135 // 13. CanonicalizedHeaders 136 // This includes all x-ms- headers, converted to lowercase, sorted, and concatenated. 137 let mut headers: Vec<_> = req 138 .headers() 139 .iter() 140 .filter(|(n, _)| n.as_str().starts_with("x-ms-")) 141 .collect(); 142 headers.sort_unstable_by_key(|(n, _)| n.as_str()); 143 // TODO should we group them ? 144 for (n, v) in headers { 145 ctx.update(n.as_str().as_bytes()); 146 ctx.update(b":"); 147 ctx.update(v.as_bytes()); // TODO replace linear whitesoace ? 148 ctx.update(b"\n"); 149 } 150 151 // 14. CanonicalizedResource 152 // This includes the account name, the path, and canonicalized query parameters. 153 ctx.update(b"/"); 154 ctx.update(self.account.as_bytes()); 155 ctx.update(req.url().path().trim_end_matches('/').as_bytes()); 156 let mut params: Vec<_> = req.url().query_pairs().collect(); 157 params.sort_unstable(); 158 for (key, value) in params { 159 ctx.update(b"\n"); 160 ctx.update(key.as_bytes()); 161 ctx.update(b":"); 162 ctx.update(value.as_bytes()); 163 } 164 ctx.sign() 165 }; 166 167 // Add the Authorization header 168 let req = req.header( 169 AUTHORIZATION, 170 format!("SharedKey {}:{}", self.account, base64::fmt(signature)), 171 ); 172 173 // Send it and handle error 174 let (ctx, res) = req.send().await.map_err(|(ctx, e)| ctx.wrap(e.into()))?; 175 if !res.status().is_success() { 176 Err(ctx.wrap(AzureErr::Api { 177 status: res.status(), 178 code: res 179 .headers() 180 .get(HeaderName::from_static("x-ms-error-code")) 181 .and_then(|it| it.to_str().ok()) 182 .unwrap_or_default() 183 .into(), 184 })) 185 } else { 186 Ok(()) 187 } 188 } 189 190 pub async fn create_container(&self, name: &str) -> AzureResult { 191 self.req( 192 Req::new(self.client, Method::PUT, &self.base_url, name.to_owned()) 193 .query("restype", "container"), 194 ) 195 .await 196 } 197 198 pub async fn container_metadata(&self, name: &str) -> AzureResult { 199 self.req( 200 Req::new(self.client, Method::GET, &self.base_url, name.to_owned()) 201 .query("restype", "container") 202 .query("comp", "metadata"), 203 ) 204 .await 205 } 206 207 pub async fn put_blob( 208 &self, 209 container: &str, 210 name: &str, 211 content: Bytes, 212 ty: HeaderValue, 213 ) -> AzureResult { 214 self.req( 215 Req::new( 216 self.client, 217 Method::PUT, 218 &self.base_url, 219 format!("{container}/{name}"), 220 ) 221 .content(content, ty) 222 .header( 223 HeaderName::from_static("x-ms-blob-type"), 224 HeaderValue::from_static("BlockBlob"), 225 ), 226 ) 227 .await 228 } 229 }