lfsx-server 1.23.3

A fast, lightweight, secure Git LFS server
Documentation
use std::time::Duration;

use base64::Engine;
use percent_encoding::{AsciiSet, NON_ALPHANUMERIC, utf8_percent_encode};
use sha2::{Digest, Sha256};

use super::credential::ServiceAccount;

const UNRESERVED: &AsciiSet = &NON_ALPHANUMERIC
    .remove(b'-')
    .remove(b'.')
    .remove(b'_')
    .remove(b'~');
const PATH: &AsciiSet = &UNRESERVED.remove(b'/');

pub(crate) struct Canonical {
    pub(crate) request: String,
    datetime: String,
    scope: String,
    url: String,
}

impl Canonical {
    pub(crate) fn to_sign(&self) -> String {
        [
            "GOOG4-RSA-SHA256",
            &self.datetime,
            &self.scope,
            &hex::encode(Sha256::digest(self.request.as_bytes())),
        ]
        .join("\n")
    }
}

fn stamp(at: time::OffsetDateTime, pattern: &str) -> String {
    time::format_description::parse_borrowed::<1>(pattern)
        .ok()
        .and_then(|format| at.format(&format).ok())
        .unwrap_or_default()
}

pub(crate) fn canonical(
    endpoint: &reqwest::Url,
    bucket: &str,
    object: &str,
    email: &str,
    at: time::OffsetDateTime,
    expires: Duration,
) -> Canonical {
    let date = stamp(at, "[year][month][day]");
    let datetime = stamp(at, "[year][month][day]T[hour][minute][second]Z");
    let scope = format!("{date}/auto/storage/goog4_request");

    let host = match endpoint.port() {
        Some(port) => format!("{}:{port}", endpoint.host_str().unwrap_or_default()),
        None => endpoint.host_str().unwrap_or_default().to_owned(),
    };
    let path = format!("/{bucket}/{}", utf8_percent_encode(object, PATH));

    let query = [
        ("X-Goog-Algorithm", "GOOG4-RSA-SHA256".to_owned()),
        ("X-Goog-Credential", format!("{email}/{scope}")),
        ("X-Goog-Date", datetime.clone()),
        ("X-Goog-Expires", expires.as_secs().to_string()),
        ("X-Goog-SignedHeaders", "host".to_owned()),
    ]
    .iter()
    .map(|(name, value)| format!("{name}={}", utf8_percent_encode(value, UNRESERVED)))
    .collect::<Vec<_>>()
    .join("&");

    let request = [
        "GET",
        &path,
        &query,
        &format!("host:{host}\n"),
        "host",
        "UNSIGNED-PAYLOAD",
    ]
    .join("\n");

    Canonical {
        request,
        datetime,
        scope,
        url: format!("{}://{host}{path}?{query}", endpoint.scheme()),
    }
}

pub(crate) fn signed_download(
    endpoint: &reqwest::Url,
    bucket: &str,
    object: &str,
    account: &ServiceAccount,
    lifetime: Duration,
) -> Option<String> {
    signed(
        &canonical(
            endpoint,
            bucket,
            object,
            &account.email,
            time::OffsetDateTime::now_utc(),
            lifetime,
        ),
        account,
    )
}

fn signed(canonical: &Canonical, account: &ServiceAccount) -> Option<String> {
    let signature = jsonwebtoken::crypto::sign(
        canonical.to_sign().as_bytes(),
        &account.key,
        jsonwebtoken::Algorithm::RS256,
    )
    .ok()?;
    let signature = base64::engine::general_purpose::URL_SAFE_NO_PAD
        .decode(signature)
        .ok()?;

    Some(format!(
        "{}&X-Goog-Signature={}",
        canonical.url,
        hex::encode(signature)
    ))
}

#[cfg(test)]
mod tests;