libeufin

Integration and sandbox testing for FinTech APIs and data formats
Log | Files | Refs | Submodules | README | LICENSE

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 }