cloud-sdk 0.42.0

no_std-first provider-neutral cloud SDK foundations.
Documentation
use crate::authentication::{
    RequestBodyHasher, ScopeValue, SigningAlgorithm, SigningContext, SigningDigestAlgorithm,
    SigningHeaders, SigningKeyId,
};
use crate::transport::{ContentType, EndpointIdentity, EndpointScheme, RequestHeader};
use crate::{ProviderId, ServiceId};

use super::{MAX_SIGNING_BODY_DIGEST_BYTES, canonical_with_hasher, request};

struct NamedHasher(&'static str);

impl RequestBodyHasher for NamedHasher {
    type Error = ();

    fn digest_algorithm(&self) -> SigningDigestAlgorithm<'_> {
        SigningDigestAlgorithm::new(self.0).unwrap_or_else(|_| unreachable!())
    }

    fn hash_body(&self, _body: &[u8], output: &mut [u8]) -> Result<usize, Self::Error> {
        let byte = output.first_mut().ok_or(())?;
        *byte = 0x42;
        Ok(1)
    }
}

#[derive(Clone, Copy)]
struct ContextParts {
    provider: &'static str,
    service: &'static str,
    scheme: EndpointScheme,
    host: &'static str,
    port: u16,
    base_path: &'static str,
    audience: Option<&'static str>,
    account: Option<&'static str>,
    tenant: Option<&'static str>,
    key_id: &'static str,
    digest_algorithm: &'static str,
    signature_algorithm: &'static str,
}

const BASE: ContextParts = ContextParts {
    provider: "hetzner",
    service: "robot",
    scheme: EndpointScheme::Https,
    host: "one.example.test",
    port: 443,
    base_path: "/api",
    audience: None,
    account: None,
    tenant: None,
    key_id: "key-1",
    digest_algorithm: "body-sha256",
    signature_algorithm: "hmac-sha256",
};

fn signing_context(parts: ContextParts) -> Option<SigningContext<'static>> {
    let provider = ProviderId::new(parts.provider).ok()?;
    let service = ServiceId::new(parts.service).ok()?;
    let endpoint =
        EndpointIdentity::new(parts.scheme, parts.host, parts.port, parts.base_path).ok()?;
    let key_id = SigningKeyId::new(parts.key_id).ok()?;
    let digest_algorithm = SigningDigestAlgorithm::new(parts.digest_algorithm).ok()?;
    let signature_algorithm = SigningAlgorithm::new(parts.signature_algorithm).ok()?;
    let mut context = SigningContext::new(
        provider,
        service,
        endpoint,
        key_id,
        digest_algorithm,
        signature_algorithm,
    );
    if let Some(value) = parts.audience {
        context = context.with_audience(ScopeValue::new(value).ok()?);
    }
    if let Some(value) = parts.account {
        context = context.with_account(ScopeValue::new(value).ok()?);
    }
    if let Some(value) = parts.tenant {
        context = context.with_tenant(ScopeValue::new(value).ok()?);
    }
    Some(context)
}

#[test]
fn every_security_domain_field_changes_the_canonical_input() {
    let entries = [RequestHeader::content_type(ContentType::JSON)];
    let Some(request) = request("/objects", &entries, b"{}") else {
        unreachable!("valid transport request must construct");
    };
    let Ok(headers) = SigningHeaders::new(&entries) else {
        unreachable!("valid signing headers must construct");
    };
    let contexts = [
        BASE,
        ContextParts {
            provider: "scaleway",
            ..BASE
        },
        ContextParts {
            service: "cloud",
            ..BASE
        },
        ContextParts {
            scheme: EndpointScheme::Http,
            ..BASE
        },
        ContextParts {
            host: "two.example.test",
            ..BASE
        },
        ContextParts { port: 444, ..BASE },
        ContextParts {
            base_path: "/other",
            ..BASE
        },
        ContextParts {
            audience: Some("audience"),
            ..BASE
        },
        ContextParts {
            account: Some("account"),
            ..BASE
        },
        ContextParts {
            tenant: Some("tenant"),
            ..BASE
        },
        ContextParts {
            key_id: "key-2",
            ..BASE
        },
        ContextParts {
            digest_algorithm: "body-sha512",
            ..BASE
        },
        ContextParts {
            signature_algorithm: "ed25519",
            ..BASE
        },
    ];
    let mut captured = [[0_u8; 512]; 13];
    for (parts, destination) in contexts.into_iter().zip(captured.iter_mut()) {
        let Some(context) = signing_context(parts) else {
            unreachable!("valid signing context must construct");
        };
        let mut digest = [0_u8; MAX_SIGNING_BODY_DIGEST_BYTES];
        let mut output = [0_u8; 512];
        let hasher = NamedHasher(parts.digest_algorithm);
        let Ok(canonical) =
            canonical_with_hasher(request, context, headers, &hasher, &mut digest, &mut output)
        else {
            unreachable!("canonical signing input must construct");
        };
        let source = canonical.as_bytes();
        let Some(target) = destination.get_mut(..source.len()) else {
            unreachable!("capture buffer must fit canonical signing input");
        };
        target.copy_from_slice(source);
    }
    for left in 0..captured.len() {
        for right in left.saturating_add(1)..captured.len() {
            assert_ne!(captured.get(left), captured.get(right));
        }
    }
}

#[test]
fn equivalent_ipv6_endpoint_spellings_have_one_canonical_input() {
    let compact = ContextParts {
        host: "[2001:db8::1]",
        ..BASE
    };
    let expanded = ContextParts {
        host: "[2001:0db8:0:0:0:0:0:1]",
        ..BASE
    };
    let (Some(compact_context), Some(expanded_context)) =
        (signing_context(compact), signing_context(expanded))
    else {
        unreachable!("equivalent IPv6 contexts must construct");
    };
    assert_eq!(compact_context.endpoint(), expanded_context.endpoint());

    let entries = [RequestHeader::content_type(ContentType::JSON)];
    let Some(request) = request("/objects", &entries, b"{}") else {
        unreachable!("valid transport request must construct");
    };
    let Ok(headers) = SigningHeaders::new(&entries) else {
        unreachable!("valid signing headers must construct");
    };
    let mut compact_digest = [0_u8; MAX_SIGNING_BODY_DIGEST_BYTES];
    let mut expanded_digest = [0_u8; MAX_SIGNING_BODY_DIGEST_BYTES];
    let mut compact_output = [0_u8; 512];
    let mut expanded_output = [0_u8; 512];
    let Ok(compact_canonical) = canonical_with_hasher(
        request,
        compact_context,
        headers,
        &NamedHasher(BASE.digest_algorithm),
        &mut compact_digest,
        &mut compact_output,
    ) else {
        unreachable!("compact IPv6 canonical input must construct");
    };
    let Ok(expanded_canonical) = canonical_with_hasher(
        request,
        expanded_context,
        headers,
        &NamedHasher(BASE.digest_algorithm),
        &mut expanded_digest,
        &mut expanded_output,
    ) else {
        unreachable!("expanded IPv6 canonical input must construct");
    };
    assert_eq!(compact_canonical.as_bytes(), expanded_canonical.as_bytes());
    let canonical_host = [
        2, 0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1,
    ];
    assert!(
        compact_canonical
            .as_bytes()
            .windows(canonical_host.len())
            .any(|window| window == canonical_host)
    );
    assert!(
        !compact_canonical
            .as_bytes()
            .windows(compact.host.len())
            .any(|window| window == compact.host.as_bytes())
    );
}