diff --git a/proxmox-subscription/Cargo.toml b/proxmox-subscription/Cargo.toml index cb5fd512..045201d6 100644 --- a/proxmox-subscription/Cargo.toml +++ b/proxmox-subscription/Cargo.toml @@ -13,20 +13,21 @@ rust-version.workspace = true [dependencies] anyhow.workspace = true -base64.workspace = true -hex.workspace = true -openssl.workspace = true +base64 = { workspace = true, optional = true } +hex = { workspace = true, optional = true } +openssl = { workspace = true, optional = true } regex.workspace = true serde.workspace = true serde_json.workspace = true -proxmox-http = { workspace = true, features = ["client-trait", "http-helpers"] } +proxmox-http = { workspace = true, optional = true, features = ["client-trait", "http-helpers"] } proxmox-serde.workspace = true -proxmox-sys.workspace = true -proxmox-time.workspace = true +proxmox-sys = { workspace = true, optional = true } +proxmox-time = { workspace = true, optional = true } proxmox-schema = { workspace = true, features = ["api-macro"], optional = true } [features] -default = [] +default = ["impl"] +impl = [ "dep:base64", "dep:hex", "dep:openssl", "dep:proxmox-http", "dep:proxmox-sys", "dep:proxmox-time"] api-types = ["dep:proxmox-schema"] diff --git a/proxmox-subscription/src/lib.rs b/proxmox-subscription/src/lib.rs index bcc10721..2ed96903 100644 --- a/proxmox-subscription/src/lib.rs +++ b/proxmox-subscription/src/lib.rs @@ -1,10 +1,17 @@ #![cfg_attr(docsrs, feature(doc_cfg, doc_auto_cfg))] mod subscription_info; +#[cfg(feature = "impl")] pub use subscription_info::{ get_hardware_address, ProductType, SubscriptionInfo, SubscriptionStatus, }; +#[cfg(not(feature = "impl"))] +pub use subscription_info::{ProductType, SubscriptionInfo, SubscriptionStatus}; + +#[cfg(feature = "impl")] pub mod check; +#[cfg(feature = "impl")] pub mod files; +#[cfg(feature = "impl")] pub mod sign; diff --git a/proxmox-subscription/src/subscription_info.rs b/proxmox-subscription/src/subscription_info.rs index a86572d0..f53b3ce3 100644 --- a/proxmox-subscription/src/subscription_info.rs +++ b/proxmox-subscription/src/subscription_info.rs @@ -1,23 +1,11 @@ -use std::{fmt::Display, path::Path, str::FromStr}; +use std::{fmt::Display, str::FromStr}; -use anyhow::{bail, format_err, Error}; -use openssl::hash::{hash, DigestBytes, MessageDigest}; -use proxmox_sys::fs::file_get_contents; -use proxmox_time::TmEditor; +use anyhow::{bail, Error}; use serde::{Deserialize, Serialize}; #[cfg(feature = "api-types")] use proxmox_schema::{api, Updater}; -use crate::sign::Verifier; - -pub(crate) const SHARED_KEY_DATA: &str = "kjfdlskfhiuewhfk947368"; - -/// How long the local key is valid for in between remote checks -pub(crate) const SUBSCRIPTION_MAX_LOCAL_KEY_AGE: i64 = 15 * 24 * 3600; -pub(crate) const SUBSCRIPTION_MAX_LOCAL_SIGNED_KEY_AGE: i64 = 365 * 24 * 3600; -pub(crate) const SUBSCRIPTION_MAX_KEY_CHECK_FAILURE_AGE: i64 = 5 * 24 * 3600; - // Aliases are needed for PVE compat! #[cfg_attr(feature = "api-types", api())] #[derive(Default, Debug, Copy, Clone, Eq, PartialEq, Serialize, Deserialize)] @@ -144,196 +132,226 @@ pub struct SubscriptionInfo { pub signature: Option, } -impl SubscriptionInfo { - /// Returns the canonicalized signed data and, if available, signature contained in `self`. - pub fn signed_data(&self) -> Result<(Vec, Option), Error> { - let mut data = serde_json::to_value(self)?; - let signature = data - .as_object_mut() - .ok_or_else(|| format_err!("subscription info not a JSON object"))? - .remove("signature") - .and_then(|v| v.as_str().map(|v| v.to_owned())); +#[cfg(feature = "impl")] +pub use _impl::get_hardware_address; - if self.is_signed() && signature.is_none() { - bail!("Failed to extract signature value!"); - } +#[cfg(feature = "impl")] +pub(crate) use _impl::{md5sum, SHARED_KEY_DATA}; - let data = proxmox_serde::json::to_canonical_json(&data)?; - Ok((data, signature)) - } +#[cfg(feature = "impl")] +mod _impl { - /// Whether a signature exists - *this does not check the signature's validity!* - /// - /// Use [SubscriptionInfo::check_signature()] to verify the - /// signature. - pub fn is_signed(&self) -> bool { - self.signature.is_some() - } + use std::path::Path; - /// Checks whether a [SubscriptionInfo]'s `checktime` matches the age criteria: - /// - /// - Instances generated (more than 1.5h) in the future are invalid - /// - Signed instances are valid for up to a year, clamped by the next due date - /// - Unsigned instances are valid for 30+5 days - /// - If `recheck` is set to `true`, unsigned instances are only treated as valid for 5 days - /// (this mode is used to decide whether to refresh the subscription information) - /// - /// If the criteria are not met, `status` is set to [SubscriptionStatus::Invalid] and `message` - /// to a human-readable error message. - pub fn check_age(&mut self, recheck: bool) { - let now = proxmox_time::epoch_i64(); - let age = now - self.checktime.unwrap_or(0); + use anyhow::format_err; + use anyhow::{bail, Error}; + use openssl::hash::{hash, DigestBytes, MessageDigest}; + use proxmox_sys::fs::file_get_contents; + use proxmox_time::TmEditor; - let cutoff = if self.is_signed() { - SUBSCRIPTION_MAX_LOCAL_SIGNED_KEY_AGE - } else if recheck { - SUBSCRIPTION_MAX_KEY_CHECK_FAILURE_AGE - } else { - SUBSCRIPTION_MAX_LOCAL_KEY_AGE + SUBSCRIPTION_MAX_KEY_CHECK_FAILURE_AGE - }; + use crate::sign::Verifier; - // allow some delta for DST changes or time syncs, 1.5h - if age < -5400 { - self.status = SubscriptionStatus::Invalid; - self.message = Some("last check date too far in the future".to_string()); - self.signature = None; - } else if age > cutoff { - if let SubscriptionStatus::Active = self.status { - self.status = SubscriptionStatus::Invalid; - self.message = Some("subscription information too old".to_string()); - self.signature = None; + pub(crate) const SHARED_KEY_DATA: &str = "kjfdlskfhiuewhfk947368"; + + /// How long the local key is valid for in between remote checks + pub(crate) const SUBSCRIPTION_MAX_LOCAL_KEY_AGE: i64 = 15 * 24 * 3600; + pub(crate) const SUBSCRIPTION_MAX_LOCAL_SIGNED_KEY_AGE: i64 = 365 * 24 * 3600; + pub(crate) const SUBSCRIPTION_MAX_KEY_CHECK_FAILURE_AGE: i64 = 5 * 24 * 3600; + + use super::{ProductType, SubscriptionInfo, SubscriptionStatus}; + + impl SubscriptionInfo { + /// Returns the canonicalized signed data and, if available, signature contained in `self`. + pub fn signed_data(&self) -> Result<(Vec, Option), Error> { + let mut data = serde_json::to_value(self)?; + let signature = data + .as_object_mut() + .ok_or_else(|| format_err!("subscription info not a JSON object"))? + .remove("signature") + .and_then(|v| v.as_str().map(|v| v.to_owned())); + + if self.is_signed() && signature.is_none() { + bail!("Failed to extract signature value!"); } + + let data = proxmox_serde::json::to_canonical_json(&data)?; + Ok((data, signature)) } - if self.is_signed() && self.status == SubscriptionStatus::Active { - if let Some(next_due) = self.nextduedate.as_ref() { - match parse_next_due(next_due.as_str()) { - Ok(next_due) if now > next_due => { - self.status = SubscriptionStatus::Invalid; - self.message = Some("subscription information too old".to_string()); - self.signature = None; - } - Ok(_) => {} - Err(err) => { - self.status = SubscriptionStatus::Invalid; - self.message = Some(format!("Failed parsing 'nextduedate' - {err}")); - self.signature = None; + /// Whether a signature exists - *this does not check the signature's validity!* + /// + /// Use [SubscriptionInfo::check_signature()] to verify the + /// signature. + pub fn is_signed(&self) -> bool { + self.signature.is_some() + } + + /// Checks whether a [SubscriptionInfo]'s `checktime` matches the age criteria: + /// + /// - Instances generated (more than 1.5h) in the future are invalid + /// - Signed instances are valid for up to a year, clamped by the next due date + /// - Unsigned instances are valid for 30+5 days + /// - If `recheck` is set to `true`, unsigned instances are only treated as valid for 5 days + /// (this mode is used to decide whether to refresh the subscription information) + /// + /// If the criteria are not met, `status` is set to [SubscriptionStatus::Invalid] and `message` + /// to a human-readable error message. + pub fn check_age(&mut self, recheck: bool) { + let now = proxmox_time::epoch_i64(); + let age = now - self.checktime.unwrap_or(0); + + let cutoff = if self.is_signed() { + SUBSCRIPTION_MAX_LOCAL_SIGNED_KEY_AGE + } else if recheck { + SUBSCRIPTION_MAX_KEY_CHECK_FAILURE_AGE + } else { + SUBSCRIPTION_MAX_LOCAL_KEY_AGE + SUBSCRIPTION_MAX_KEY_CHECK_FAILURE_AGE + }; + + // allow some delta for DST changes or time syncs, 1.5h + if age < -5400 { + self.status = SubscriptionStatus::Invalid; + self.message = Some("last check date too far in the future".to_string()); + self.signature = None; + } else if age > cutoff { + if let SubscriptionStatus::Active = self.status { + self.status = SubscriptionStatus::Invalid; + self.message = Some("subscription information too old".to_string()); + self.signature = None; + } + } + + if self.is_signed() && self.status == SubscriptionStatus::Active { + if let Some(next_due) = self.nextduedate.as_ref() { + match parse_next_due(next_due.as_str()) { + Ok(next_due) if now > next_due => { + self.status = SubscriptionStatus::Invalid; + self.message = Some("subscription information too old".to_string()); + self.signature = None; + } + Ok(_) => {} + Err(err) => { + self.status = SubscriptionStatus::Invalid; + self.message = Some(format!("Failed parsing 'nextduedate' - {err}")); + self.signature = None; + } } } } } - } - /// Check that server ID contained in [SubscriptionInfo] matches that of current system. - /// - /// `status` is set to [SubscriptionStatus::Invalid] and `message` to a human-readable - /// message in case it does not. - pub fn check_server_id(&mut self) { - match (self.serverid.as_ref(), get_hardware_address()) { - (_, Err(err)) => { - self.status = SubscriptionStatus::Invalid; - self.message = Some(format!("Failed to obtain server ID - {err}.")); - self.signature = None; + /// Check that server ID contained in [SubscriptionInfo] matches that of current system. + /// + /// `status` is set to [SubscriptionStatus::Invalid] and `message` to a human-readable + /// message in case it does not. + pub fn check_server_id(&mut self) { + match (self.serverid.as_ref(), get_hardware_address()) { + (_, Err(err)) => { + self.status = SubscriptionStatus::Invalid; + self.message = Some(format!("Failed to obtain server ID - {err}.")); + self.signature = None; + } + (None, _) => { + self.status = SubscriptionStatus::Invalid; + self.message = Some("Missing server ID.".to_string()); + self.signature = None; + } + (Some(contained), Ok(expected)) if &expected != contained => { + self.status = SubscriptionStatus::Invalid; + self.message = Some("Server ID mismatch.".to_string()); + self.signature = None; + } + (Some(_), Ok(_)) => {} } - (None, _) => { - self.status = SubscriptionStatus::Invalid; - self.message = Some("Missing server ID.".to_string()); - self.signature = None; - } - (Some(contained), Ok(expected)) if &expected != contained => { - self.status = SubscriptionStatus::Invalid; - self.message = Some("Server ID mismatch.".to_string()); - self.signature = None; - } - (Some(_), Ok(_)) => {} } - } - /// Check a [SubscriptionInfo]'s signature, if one is available. - /// - /// `status` is set to [SubscriptionStatus::Invalid] and `message` to a human-readable error - /// message in case a signature is available but not valid for the given `key`. - pub fn check_signature>(&mut self, keys: &[P]) { - let verify = |info: &SubscriptionInfo, path: &P| -> Result<(), Error> { - let raw = file_get_contents(path)?; + /// Check a [SubscriptionInfo]'s signature, if one is available. + /// + /// `status` is set to [SubscriptionStatus::Invalid] and `message` to a human-readable error + /// message in case a signature is available but not valid for the given `key`. + pub fn check_signature>(&mut self, keys: &[P]) { + let verify = |info: &SubscriptionInfo, path: &P| -> Result<(), Error> { + let raw = file_get_contents(path)?; - let key = openssl::pkey::PKey::public_key_from_pem(&raw)?; + let key = openssl::pkey::PKey::public_key_from_pem(&raw)?; - let (signed, signature) = info.signed_data()?; - let signature = match signature { - None => bail!("Failed to extract signature value."), - Some(sig) => sig, + let (signed, signature) = info.signed_data()?; + let signature = match signature { + None => bail!("Failed to extract signature value."), + Some(sig) => sig, + }; + + key.verify(&signed, &signature) + .map_err(|err| format_err!("Signature verification failed - {err}")) }; - key.verify(&signed, &signature) - .map_err(|err| format_err!("Signature verification failed - {err}")) - }; - - if self.is_signed() { - if keys.is_empty() { - self.status = SubscriptionStatus::Invalid; - self.message = Some("Signature exists, but no key available.".to_string()); - } else if !keys.iter().any(|key| verify(self, key).is_ok()) { - self.status = SubscriptionStatus::Invalid; - self.message = Some("Signature validation failed".to_string()); + if self.is_signed() { + if keys.is_empty() { + self.status = SubscriptionStatus::Invalid; + self.message = Some("Signature exists, but no key available.".to_string()); + } else if !keys.iter().any(|key| verify(self, key).is_ok()) { + self.status = SubscriptionStatus::Invalid; + self.message = Some("Signature validation failed".to_string()); + } } } + + pub fn get_product_type(&self) -> Result { + self.key + .as_ref() + .ok_or_else(|| format_err!("no product key set")) + .map(|key| key[..3].parse::())? + } + + pub fn get_next_due_date(&self) -> Result { + self.nextduedate + .as_ref() + .ok_or_else(|| format_err!("no next due date set")) + .map(|e| parse_next_due(e))? + } } - pub fn get_product_type(&self) -> Result { - self.key - .as_ref() - .ok_or_else(|| format_err!("no product key set")) - .map(|key| key[..3].parse::())? + /// Shortcut for md5 sums. + pub(crate) fn md5sum(data: &[u8]) -> Result { + hash(MessageDigest::md5(), data).map_err(Error::from) } - pub fn get_next_due_date(&self) -> Result { - self.nextduedate - .as_ref() - .ok_or_else(|| format_err!("no next due date set")) - .map(|e| parse_next_due(e))? - } -} + /// Generate the current system's "server ID". + pub fn get_hardware_address() -> Result { + static FILENAME: &str = "/etc/ssh/ssh_host_rsa_key.pub"; -/// Shortcut for md5 sums. -pub(crate) fn md5sum(data: &[u8]) -> Result { - hash(MessageDigest::md5(), data).map_err(Error::from) -} + let contents = proxmox_sys::fs::file_get_contents(FILENAME) + .map_err(|e| format_err!("Error getting host key - {}", e))?; + let digest = + md5sum(&contents).map_err(|e| format_err!("Error digesting host key - {}", e))?; -/// Generate the current system's "server ID". -pub fn get_hardware_address() -> Result { - static FILENAME: &str = "/etc/ssh/ssh_host_rsa_key.pub"; - - let contents = proxmox_sys::fs::file_get_contents(FILENAME) - .map_err(|e| format_err!("Error getting host key - {}", e))?; - let digest = md5sum(&contents).map_err(|e| format_err!("Error digesting host key - {}", e))?; - - Ok(hex::encode(digest).to_uppercase()) -} - -fn parse_next_due(value: &str) -> Result { - let mut components = value.split('-'); - let year = components - .next() - .ok_or_else(|| format_err!("missing year component."))? - .parse::()?; - let month = components - .next() - .ok_or_else(|| format_err!("missing month component."))? - .parse::()?; - let day = components - .next() - .ok_or_else(|| format_err!("missing day component."))? - .parse::()?; - - if components.next().is_some() { - bail!("cannot parse 'nextduedate' value '{value}'"); + Ok(hex::encode(digest).to_uppercase()) } - let mut tm = TmEditor::new(true); - tm.set_year(year)?; - tm.set_mon(month)?; - tm.set_mday(day)?; + fn parse_next_due(value: &str) -> Result { + let mut components = value.split('-'); + let year = components + .next() + .ok_or_else(|| format_err!("missing year component."))? + .parse::()?; + let month = components + .next() + .ok_or_else(|| format_err!("missing month component."))? + .parse::()?; + let day = components + .next() + .ok_or_else(|| format_err!("missing day component."))? + .parse::()?; - tm.into_epoch() + if components.next().is_some() { + bail!("cannot parse 'nextduedate' value '{value}'"); + } + + let mut tm = TmEditor::new(true); + tm.set_year(year)?; + tm.set_mon(month)?; + tm.set_mday(day)?; + + tm.into_epoch() + } }