alpine-protocol-sdk 0.2.4

High-level SDK on top of the ALPINE protocol layer.
Documentation
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};

use alpine::attestation::{
    verify_attester_bundle, AttesterBundleError, AttesterRegistry, VerifiedAttesterBundle,
};
use base64::{engine::general_purpose, Engine as _};
use directories::ProjectDirs;
use reqwest::StatusCode;
use thiserror::Error;
use tracing::warn;

use crate::discovery::DeviceTrustState;
use crate::error::AlpineSdkError;

const DEFAULT_TIMEOUT: Duration = Duration::from_secs(5);

#[derive(Debug, Clone)]
pub enum TrustSource {
    Fetched,
    Cached,
    Override,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TrustPolicy {
    Strict,
    WarnOnly,
    AllowUntrusted,
}

impl TrustPolicy {
    /// Alias for strict attestation enforcement (rejects untrusted identities).
    #[allow(non_upper_case_globals)]
    pub const RequireAttestation: TrustPolicy = TrustPolicy::Strict;
}

#[must_use]
#[derive(Debug, Clone)]
pub struct TrustView {
    pub bundle: VerifiedAttesterBundle,
    pub registry: AttesterRegistry,
    pub source: TrustSource,
    pub warnings: Vec<String>,
}

#[derive(Debug, Clone)]
pub struct TrustConfig {
    pub bundle_url: String,
    pub cache_path: PathBuf,
    pub root_pubkey: Option<[u8; 32]>,
    pub override_path: Option<PathBuf>,
    pub timeout: Duration,
    pub pinned_bundle_issued_at: Option<u64>,
    pub pinned_bundle_signer_kid: Option<String>,
}

#[derive(Debug, Error)]
pub enum TrustError {
    #[error("root pubkey not configured")]
    MissingRootKey,
    #[error("bundle fetch failed: {0}")]
    Fetch(String),
    #[error("bundle cache missing or unreadable: {0}")]
    CacheRead(String),
    #[error("bundle cache write failed: {0}")]
    CacheWrite(String),
    #[error("bundle verification failed: {0}")]
    Verify(String),
    #[error("bundle version pin mismatch: {0}")]
    PinMismatch(String),
}

pub fn enforce_trust_policy(
    trust_state: DeviceTrustState,
    policy: TrustPolicy,
) -> Result<(), AlpineSdkError> {
    match policy {
        TrustPolicy::AllowUntrusted => Ok(()),
        TrustPolicy::WarnOnly => {
            if trust_state != DeviceTrustState::Trusted {
                warn!(
                    "[ALPINE][TRUST][WARN] device trust policy warn_only state={}",
                    trust_state.as_str()
                );
            }
            Ok(())
        }
        TrustPolicy::Strict => match trust_state {
            DeviceTrustState::Trusted => Ok(()),
            DeviceTrustState::UntrustedNoAttestation => Err(AlpineSdkError::UntrustedDevice(
                "device identity attestation missing".into(),
            )),
            DeviceTrustState::UntrustedNoRegistry => Err(AlpineSdkError::UntrustedDevice(
                "attester registry not configured".into(),
            )),
            DeviceTrustState::UntrustedInvalid(reason) => {
                Err(AlpineSdkError::UntrustedDevice(reason))
            }
        },
    }
}

impl TrustConfig {
    pub fn new(bundle_url: impl Into<String>) -> Self {
        Self {
            bundle_url: bundle_url.into(),
            cache_path: default_cache_path(),
            root_pubkey: None,
            override_path: None,
            timeout: DEFAULT_TIMEOUT,
            pinned_bundle_issued_at: None,
            pinned_bundle_signer_kid: None,
        }
    }

    pub fn with_root_pubkey(mut self, root_pubkey: [u8; 32]) -> Self {
        self.root_pubkey = Some(root_pubkey);
        self
    }

    pub fn with_cache_path(mut self, cache_path: PathBuf) -> Self {
        self.cache_path = cache_path;
        self
    }

    pub fn with_override_path(mut self, override_path: PathBuf) -> Self {
        self.override_path = Some(override_path);
        self
    }

    pub fn with_timeout(mut self, timeout: Duration) -> Self {
        self.timeout = timeout;
        self
    }

    pub fn with_pinned_bundle_issued_at(mut self, issued_at: u64) -> Self {
        self.pinned_bundle_issued_at = Some(issued_at);
        self
    }

    pub fn with_pinned_bundle_signer_kid(mut self, signer_kid: impl Into<String>) -> Self {
        self.pinned_bundle_signer_kid = Some(signer_kid.into());
        self
    }
}

pub fn parse_root_pubkey_base64(value: &str) -> Result<[u8; 32], TrustError> {
    let bytes = general_purpose::STANDARD
        .decode(value.as_bytes())
        .map_err(|err| TrustError::Verify(err.to_string()))?;
    bytes
        .try_into()
        .map_err(|_| TrustError::Verify("root pubkey must be 32 bytes".into()))
}

pub async fn load_or_fetch_trust_view(config: &TrustConfig) -> Result<TrustView, TrustError> {
    let root_pubkey = config.root_pubkey.ok_or(TrustError::MissingRootKey)?;
    let now = SystemTime::now();
    let mut warnings = Vec::new();

    if let Some(override_path) = &config.override_path {
        return load_override_trust_view(override_path, root_pubkey, warnings, config);
    }

    let cached = match fs::read(&config.cache_path) {
        Ok(bytes) => match verify_attester_bundle(&bytes, &root_pubkey, now) {
            Ok(bundle) => {
                if enforce_bundle_pinning(&bundle, config).is_ok() {
                    Some(bundle)
                } else {
                    warnings.push("cached bundle rejected: pin mismatch".into());
                    None
                }
            }
            Err(err) => {
                warnings.push(format!("cached bundle rejected: {}", err));
                None
            }
        },
        Err(err) => {
            warnings.push(format!("cached bundle unavailable: {}", err));
            None
        }
    };

    match fetch_latest_bundle(&config.bundle_url, config.timeout).await {
        Ok(bytes) => {
            let bundle = verify_attester_bundle(&bytes, &root_pubkey, now)
                .map_err(|err| TrustError::Verify(err.to_string()))?;
            enforce_bundle_pinning(&bundle, config)?;
            cache_bundle(&config.cache_path, &bytes)?;
            Ok(TrustView {
                registry: bundle.registry.clone(),
                bundle,
                source: TrustSource::Fetched,
                warnings,
            })
        }
        Err(fetch_err) => {
            warnings.push(fetch_err.to_string());
            if let Some(bundle) = cached {
                enforce_bundle_pinning(&bundle, config)?;
                Ok(TrustView {
                    registry: bundle.registry.clone(),
                    bundle,
                    source: TrustSource::Cached,
                    warnings,
                })
            } else {
                Err(TrustError::Fetch(fetch_err.to_string()))
            }
        }
    }
}

pub fn load_cached_trust_view(config: &TrustConfig) -> Result<TrustView, TrustError> {
    let root_pubkey = config.root_pubkey.ok_or(TrustError::MissingRootKey)?;
    let now = SystemTime::now();
    if let Some(override_path) = &config.override_path {
        return load_override_trust_view(override_path, root_pubkey, Vec::new(), config);
    }
    let bytes =
        fs::read(&config.cache_path).map_err(|err| TrustError::CacheRead(err.to_string()))?;
    let bundle = verify_attester_bundle(&bytes, &root_pubkey, now)
        .map_err(|err| TrustError::Verify(err.to_string()))?;
    enforce_bundle_pinning(&bundle, config)?;
    Ok(TrustView {
        registry: bundle.registry.clone(),
        bundle,
        source: TrustSource::Cached,
        warnings: Vec::new(),
    })
}

async fn fetch_latest_bundle(url: &str, timeout: Duration) -> Result<Vec<u8>, TrustError> {
    let client = reqwest::Client::builder()
        .timeout(timeout)
        .build()
        .map_err(|err| TrustError::Fetch(err.to_string()))?;
    let resp = client
        .get(url)
        .send()
        .await
        .map_err(|err| TrustError::Fetch(err.to_string()))?;
    if resp.status() != StatusCode::OK {
        return Err(TrustError::Fetch(format!(
            "unexpected status {}",
            resp.status()
        )));
    }
    resp.bytes()
        .await
        .map(|b| b.to_vec())
        .map_err(|err| TrustError::Fetch(err.to_string()))
}

fn cache_bundle(path: &Path, bytes: &[u8]) -> Result<(), TrustError> {
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent).map_err(|err| TrustError::CacheWrite(err.to_string()))?;
    }
    fs::write(path, bytes).map_err(|err| TrustError::CacheWrite(err.to_string()))
}

fn default_cache_path() -> PathBuf {
    if let Some(proj) = ProjectDirs::from("io", "alpine", "alpine-protocol-sdk") {
        proj.cache_dir().join("attesters.bundle.cbor")
    } else {
        PathBuf::from(".").join("attesters.bundle.cbor")
    }
}

fn load_override_trust_view(
    override_path: &Path,
    root_pubkey: [u8; 32],
    mut warnings: Vec<String>,
    config: &TrustConfig,
) -> Result<TrustView, TrustError> {
    let now = SystemTime::now();
    let override_bytes =
        fs::read(override_path).map_err(|err| TrustError::CacheRead(err.to_string()))?;
    let bundle = verify_attester_bundle(&override_bytes, &root_pubkey, now)
        .map_err(|err| TrustError::Verify(err.to_string()))?;
    enforce_bundle_pinning(&bundle, config)?;
    warnings.push(format!(
        "attesters override in use: {}",
        override_path.display()
    ));
    Ok(TrustView {
        registry: bundle.registry.clone(),
        bundle,
        source: TrustSource::Override,
        warnings,
    })
}

pub fn verify_cached_bundle(
    cache_path: &Path,
    root_pubkey: &[u8; 32],
    now: SystemTime,
) -> Result<VerifiedAttesterBundle, AttesterBundleError> {
    let bytes = fs::read(cache_path).map_err(|err| AttesterBundleError::Decode(err.to_string()))?;
    verify_attester_bundle(&bytes, root_pubkey, now)
}

fn enforce_bundle_pinning(
    bundle: &VerifiedAttesterBundle,
    config: &TrustConfig,
) -> Result<(), TrustError> {
    if let Some(expected) = config.pinned_bundle_issued_at {
        if bundle.issued_at != expected {
            return Err(TrustError::PinMismatch(format!(
                "issued_at expected {} got {}",
                expected, bundle.issued_at
            )));
        }
    }
    if let Some(expected) = config.pinned_bundle_signer_kid.as_ref() {
        if bundle.signer_kid.as_ref() != Some(expected) {
            return Err(TrustError::PinMismatch(format!(
                "signer_kid expected {} got {:?}",
                expected, bundle.signer_kid
            )));
        }
    }
    Ok(())
}