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 {
#[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(())
}