add new proxmox-subscription crate
taking over slighlty generified helpers and types: - subscription info and status - checking subscription status with shop - reading/writing local subscription-related files the perl-based code uses base64 with newlines for the data, and base64 without padding for the checksum. accordingly, calculate the checksum with and without newlines, and compare the decoded checksum instead of the encoded one. furthermore, the perl-based code encodes the subscription status using Capitalized values instead of lowercase, so alias those for the time being. PVE also stores the serverid as 'validdirectory', so add that as alias as well. Signed-off-by: Fabian Grünbichler <f.gruenbichler@proxmox.com>
This commit is contained in:
parent
6e4a43d683
commit
4ec9a8183d
@ -14,6 +14,7 @@ members = [
|
||||
"proxmox-shared-memory",
|
||||
"proxmox-section-config",
|
||||
"proxmox-sortable-macro",
|
||||
"proxmox-subscription",
|
||||
"proxmox-sys",
|
||||
"proxmox-tfa",
|
||||
"proxmox-time",
|
||||
|
1
Makefile
1
Makefile
@ -14,6 +14,7 @@ CRATES = \
|
||||
proxmox-shared-memory \
|
||||
proxmox-section-config \
|
||||
proxmox-sortable-macro \
|
||||
proxmox-subscription \
|
||||
proxmox-sys \
|
||||
proxmox-tfa \
|
||||
proxmox-time \
|
||||
|
30
proxmox-subscription/Cargo.toml
Normal file
30
proxmox-subscription/Cargo.toml
Normal file
@ -0,0 +1,30 @@
|
||||
[package]
|
||||
name = "proxmox-subscription"
|
||||
version = "0.1.0"
|
||||
authors = ["Proxmox Support Team <support@proxmox.com>"]
|
||||
edition = "2018"
|
||||
license = "AGPL-3"
|
||||
description = "Proxmox subscription utilitites"
|
||||
|
||||
exclude = [ "debian" ]
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0"
|
||||
base64 = "0.13"
|
||||
hex = "0.4"
|
||||
lazy_static = "1.4"
|
||||
openssl = "0.10"
|
||||
regex = "1.5"
|
||||
serde = "1.0"
|
||||
serde_json = "1.0"
|
||||
|
||||
proxmox-http = { path = "../proxmox-http", version = "0.6.3", default_features = false, features = ["client-trait", "http-helpers"] }
|
||||
proxmox-serde = { path = "../proxmox-serde", version = "0.1.1", features = ["serde_json"]}
|
||||
proxmox-sys = "0.3"
|
||||
proxmox-time = "1.1"
|
||||
|
||||
proxmox-schema = { path = "../proxmox-schema", version = "1.3.3", features = ["api-macro"], optional = true }
|
||||
|
||||
[features]
|
||||
default = []
|
||||
api-types = ["proxmox-schema"]
|
7
proxmox-subscription/debian/changelog
Normal file
7
proxmox-subscription/debian/changelog
Normal file
@ -0,0 +1,7 @@
|
||||
rust-proxmox-subscription (0.1.0-1) unstable; urgency=medium
|
||||
|
||||
* initial package
|
||||
|
||||
* extend subscription code to handle signed SubscriptionInfo
|
||||
|
||||
-- Proxmox Support Team <support@proxmox.com> Mon, 20 Jun 2022 16:26:02 +0200
|
16
proxmox-subscription/debian/copyright
Normal file
16
proxmox-subscription/debian/copyright
Normal file
@ -0,0 +1,16 @@
|
||||
Copyright (C) 2022 Proxmox Server Solutions GmbH
|
||||
|
||||
This software is written by Proxmox Server Solutions GmbH <support@proxmox.com>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
7
proxmox-subscription/debian/debcargo.toml
Normal file
7
proxmox-subscription/debian/debcargo.toml
Normal file
@ -0,0 +1,7 @@
|
||||
overlay = "."
|
||||
crate_src_path = ".."
|
||||
maintainer = "Proxmox Support Team <support@proxmox.com>"
|
||||
|
||||
[source]
|
||||
vcs_git = "git://git.proxmox.com/git/proxmox.git"
|
||||
vcs_browser = "https://git.proxmox.com/?p=proxmox.git"
|
178
proxmox-subscription/src/check.rs
Normal file
178
proxmox-subscription/src/check.rs
Normal file
@ -0,0 +1,178 @@
|
||||
use anyhow::{bail, format_err, Error};
|
||||
|
||||
use lazy_static::lazy_static;
|
||||
use regex::Regex;
|
||||
use serde_json::json;
|
||||
|
||||
use proxmox_http::{uri::json_object_to_query, HttpClient};
|
||||
|
||||
use crate::{
|
||||
subscription_info::{md5sum, SHARED_KEY_DATA},
|
||||
SubscriptionInfo, SubscriptionStatus,
|
||||
};
|
||||
|
||||
lazy_static! {
|
||||
static ref ATTR_RE: Regex = Regex::new(r"<([^>]+)>([^<]+)</[^>]+>").unwrap();
|
||||
}
|
||||
|
||||
const SHOP_URI: &str = "https://shop.proxmox.com/modules/servers/licensing/verify.php";
|
||||
|
||||
/// (Re)-register a subscription key with the WHMCS server.
|
||||
fn register_subscription<C: HttpClient<String>>(
|
||||
key: &str,
|
||||
server_id: &str,
|
||||
checktime: i64,
|
||||
client: C,
|
||||
) -> Result<(String, String), Error> {
|
||||
// WHCMS sample code feeds the key into this, but it's just a challenge, so keep it simple
|
||||
let rand = hex::encode(&proxmox_sys::linux::random_data(16)?);
|
||||
let challenge = format!("{}{}", checktime, rand);
|
||||
|
||||
let params = json!({
|
||||
"licensekey": key,
|
||||
"dir": server_id,
|
||||
"domain": "www.proxmox.com",
|
||||
"ip": "localhost",
|
||||
"check_token": challenge,
|
||||
});
|
||||
|
||||
let query = json_object_to_query(params)?;
|
||||
let response = client.post(
|
||||
SHOP_URI,
|
||||
Some(&query),
|
||||
Some("application/x-www-form-urlencoded"),
|
||||
)?;
|
||||
let body = response.into_body();
|
||||
|
||||
Ok((body, challenge))
|
||||
}
|
||||
|
||||
fn parse_status(value: &str) -> SubscriptionStatus {
|
||||
match value.to_lowercase().as_str() {
|
||||
"active" => SubscriptionStatus::ACTIVE,
|
||||
"new" => SubscriptionStatus::NEW,
|
||||
"notfound" => SubscriptionStatus::NOTFOUND,
|
||||
"invalid" => SubscriptionStatus::INVALID,
|
||||
_ => SubscriptionStatus::INVALID,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_register_response(
|
||||
body: &str,
|
||||
key: String,
|
||||
server_id: String,
|
||||
checktime: i64,
|
||||
challenge: &str,
|
||||
product_url: String,
|
||||
) -> Result<SubscriptionInfo, Error> {
|
||||
let mut info = SubscriptionInfo {
|
||||
key: Some(key),
|
||||
status: SubscriptionStatus::NOTFOUND,
|
||||
checktime: Some(checktime),
|
||||
url: Some(product_url),
|
||||
..Default::default()
|
||||
};
|
||||
let mut md5hash = String::new();
|
||||
let is_server_id = |id: &&str| *id == server_id;
|
||||
|
||||
for caps in ATTR_RE.captures_iter(body) {
|
||||
let (key, value) = (&caps[1], &caps[2]);
|
||||
match key {
|
||||
"status" => info.status = parse_status(value),
|
||||
"productname" => info.productname = Some(value.into()),
|
||||
"regdate" => info.regdate = Some(value.into()),
|
||||
"nextduedate" => info.nextduedate = Some(value.into()),
|
||||
"message" if value == "Directory Invalid" => {
|
||||
info.message = Some("Invalid Server ID".into())
|
||||
}
|
||||
"message" => info.message = Some(value.into()),
|
||||
"validdirectory" => {
|
||||
if value.split(',').find(is_server_id) == None {
|
||||
bail!("Server ID does not match");
|
||||
}
|
||||
info.serverid = Some(server_id.to_owned());
|
||||
}
|
||||
"md5hash" => md5hash = value.to_owned(),
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
|
||||
if let SubscriptionStatus::ACTIVE = info.status {
|
||||
let response_raw = format!("{}{}", SHARED_KEY_DATA, challenge);
|
||||
let expected = hex::encode(md5sum(response_raw.as_bytes())?);
|
||||
|
||||
if expected != md5hash {
|
||||
bail!(
|
||||
"Subscription API challenge failed, expected {} != got {}",
|
||||
expected,
|
||||
md5hash
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_register_response() -> Result<(), Error> {
|
||||
let response = r#"
|
||||
<status>Active</status>
|
||||
<companyname>Proxmox</companyname>
|
||||
<serviceid>41108</serviceid>
|
||||
<productid>71</productid>
|
||||
<productname>Proxmox Backup Server Test Subscription -1 year</productname>
|
||||
<regdate>2020-09-19 00:00:00</regdate>
|
||||
<nextduedate>2021-09-19</nextduedate>
|
||||
<billingcycle>Annually</billingcycle>
|
||||
<validdomain>proxmox.com,www.proxmox.com</validdomain>
|
||||
<validdirectory>830000000123456789ABCDEF00000042</validdirectory>
|
||||
<customfields>Notes=Test Key!</customfields>
|
||||
<addons></addons>
|
||||
<md5hash>969f4df84fe157ee4f5a2f71950ad154</md5hash>
|
||||
"#;
|
||||
let key = "pbst-123456789a".to_string();
|
||||
let server_id = "830000000123456789ABCDEF00000042".to_string();
|
||||
let checktime = 1600000000;
|
||||
let salt = "cf44486bddb6ad0145732642c45b2957";
|
||||
|
||||
let info = parse_register_response(
|
||||
response,
|
||||
key.to_owned(),
|
||||
server_id.to_owned(),
|
||||
checktime,
|
||||
salt,
|
||||
"https://www.proxmox.com/en/proxmox-backup-server/pricing".to_string(),
|
||||
)?;
|
||||
|
||||
assert_eq!(
|
||||
info,
|
||||
SubscriptionInfo {
|
||||
key: Some(key),
|
||||
serverid: Some(server_id),
|
||||
status: SubscriptionStatus::ACTIVE,
|
||||
checktime: Some(checktime),
|
||||
url: Some("https://www.proxmox.com/en/proxmox-backup-server/pricing".into()),
|
||||
message: None,
|
||||
nextduedate: Some("2021-09-19".into()),
|
||||
regdate: Some("2020-09-19 00:00:00".into()),
|
||||
productname: Some("Proxmox Backup Server Test Subscription -1 year".into()),
|
||||
signature: None,
|
||||
}
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Queries the WHMCS server to register/update the subscription key information, parsing the response into a [SubscriptionInfo].
|
||||
pub fn check_subscription<C: HttpClient<String>>(
|
||||
key: String,
|
||||
server_id: String,
|
||||
product_url: String,
|
||||
http_client: C,
|
||||
) -> Result<SubscriptionInfo, Error> {
|
||||
let now = proxmox_time::epoch_i64();
|
||||
|
||||
let (response, challenge) = register_subscription(&key, &server_id, now, http_client)
|
||||
.map_err(|err| format_err!("Error checking subscription: {}", err))?;
|
||||
|
||||
parse_register_response(&response, key, server_id, now, &challenge, product_url)
|
||||
.map_err(|err| format_err!("Error parsing subscription check response: {}", err))
|
||||
}
|
206
proxmox-subscription/src/files.rs
Normal file
206
proxmox-subscription/src/files.rs
Normal file
@ -0,0 +1,206 @@
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{format_err, Error};
|
||||
use proxmox_sys::fs::{replace_file, CreateOptions};
|
||||
|
||||
use crate::{
|
||||
subscription_info::{md5sum, SHARED_KEY_DATA},
|
||||
SubscriptionInfo, SubscriptionStatus,
|
||||
};
|
||||
|
||||
fn parse_subscription_file(raw: &str) -> Result<Option<SubscriptionInfo>, Error> {
|
||||
let mut cfg = raw.lines();
|
||||
|
||||
// first line is key in plain
|
||||
let key = if let Some(key) = cfg.next() {
|
||||
key
|
||||
} else {
|
||||
return Ok(None);
|
||||
};
|
||||
// second line is checksum of encoded data
|
||||
let checksum = if let Some(csum) = cfg.next() {
|
||||
base64::decode(csum)?
|
||||
} else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
// TODO convert to simple collect with PVE 8.0
|
||||
let encoded_with_newlines = cfg.fold(String::new(), |mut s, line| {
|
||||
s.push_str(line);
|
||||
s.push('\n');
|
||||
s
|
||||
});
|
||||
let mut encoded = encoded_with_newlines.clone();
|
||||
encoded.retain(|c| c != '\n');
|
||||
|
||||
let decoded = base64::decode(&encoded)?;
|
||||
let decoded = std::str::from_utf8(&decoded)?;
|
||||
|
||||
let info: SubscriptionInfo = serde_json::from_str(decoded)?;
|
||||
|
||||
let calc_csum = |encoded: &str| {
|
||||
let csum = format!(
|
||||
"{}{}{}",
|
||||
info.checktime.unwrap_or(0),
|
||||
encoded,
|
||||
SHARED_KEY_DATA,
|
||||
);
|
||||
md5sum(csum.as_bytes())
|
||||
};
|
||||
|
||||
// TODO drop PVE compat csum with PVE 8.0
|
||||
let pve_csum = calc_csum(&encoded_with_newlines)?;
|
||||
let pbs_csum = calc_csum(&encoded)?;
|
||||
if checksum != pbs_csum.as_ref() && checksum != pve_csum.as_ref() {
|
||||
return Ok(Some(SubscriptionInfo {
|
||||
status: SubscriptionStatus::INVALID,
|
||||
message: Some("checksum mismatch".to_string()),
|
||||
..info
|
||||
}));
|
||||
}
|
||||
|
||||
match info.key {
|
||||
Some(ref info_key) if info_key != key => {
|
||||
return Ok(Some(SubscriptionInfo {
|
||||
status: SubscriptionStatus::INVALID,
|
||||
message: Some("subscription key mismatch".to_string()),
|
||||
..info
|
||||
}))
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
Ok(Some(info))
|
||||
}
|
||||
|
||||
/// Reads in subscription information and does a basic integrity verification.
|
||||
///
|
||||
/// The expected format consists of three lines:
|
||||
/// - subscription key
|
||||
/// - checksum of encoded data
|
||||
/// - encoded data
|
||||
///
|
||||
/// Legacy variants of this format as used by older versions of PVE/PMG are supported.
|
||||
pub fn read_subscription<P: AsRef<Path>>(path: P) -> Result<Option<SubscriptionInfo>, Error> {
|
||||
match proxmox_sys::fs::file_read_optional_string(path)? {
|
||||
Some(raw) => {
|
||||
let mut info = parse_subscription_file(&raw)?;
|
||||
info.as_mut().map(|info| info.check_age(false));
|
||||
Ok(info)
|
||||
}
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes out subscription status in the format parsed by [`read_subscription`].
|
||||
pub fn write_subscription<P: AsRef<Path>>(
|
||||
path: P,
|
||||
file_opts: CreateOptions,
|
||||
info: &SubscriptionInfo,
|
||||
) -> Result<(), Error> {
|
||||
let raw = if info.key == None || info.checktime == None {
|
||||
String::new()
|
||||
} else if let SubscriptionStatus::NEW = info.status {
|
||||
format!("{}\n", info.key.as_ref().unwrap())
|
||||
} else {
|
||||
let encoded = base64::encode(serde_json::to_string(&info)?);
|
||||
let csum = format!(
|
||||
"{}{}{}",
|
||||
info.checktime.unwrap_or(0),
|
||||
encoded,
|
||||
SHARED_KEY_DATA
|
||||
);
|
||||
let csum = base64::encode(md5sum(csum.as_bytes())?);
|
||||
format!("{}\n{}\n{}\n", info.key.as_ref().unwrap(), csum, encoded)
|
||||
};
|
||||
|
||||
replace_file(path, raw.as_bytes(), file_opts, true)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Deletes the subscription info file.
|
||||
pub fn delete_subscription<P: AsRef<Path>>(path: P) -> Result<(), Error> {
|
||||
std::fs::remove_file(path)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Updates apt authentication config for repo access.
|
||||
pub fn update_apt_auth<P: AsRef<Path>>(
|
||||
path: P,
|
||||
file_opts: CreateOptions,
|
||||
url: &str,
|
||||
key: Option<String>,
|
||||
password: Option<String>,
|
||||
) -> Result<(), Error> {
|
||||
match (key, password) {
|
||||
(Some(key), Some(password)) => {
|
||||
let conf = format!("machine {url}\n login {}\n password {}\n", key, password,);
|
||||
|
||||
// we use a namespaced .conf file, so just overwrite..
|
||||
replace_file(path, conf.as_bytes(), file_opts, true)
|
||||
.map_err(|e| format_err!("Error saving apt auth config - {}", e))?;
|
||||
}
|
||||
_ => match std::fs::remove_file(path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()), // ignore not existing
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
.map_err(|e| format_err!("Error clearing apt auth config - {}", e))?,
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pve_compat() {
|
||||
// generated with PVE::Subscription::write_subscription() based on a real test subscription
|
||||
let content = "pve4t-123456789a\nNx5qaBSAwkhF/o39/zPAeA\neyJrZXkiOiJwdmU0dC0xMjM0NTY3ODlhIiwibmV4dGR1ZWRhdGUiOiIwMDAwLTAwLTAwIiwic3Rh\ndHVzIjoiQWN0aXZlIiwidmFsaWRkaXJlY3RvcnkiOiI4MzAwMDAwMDAxMjM0NTY3ODlBQkNERUYw\nMDAwMDA0MiIsImNoZWNrdGltZSI6MTYwMDAwMDAwMCwicHJvZHVjdG5hbWUiOiJQcm94bW94IFZF\nIEZyZWUgVHJpYWwgU3Vic2NyaXB0aW9uIDEyIE1vbnRocyAoNCBDUFVzKSIsInJlZ2RhdGUiOiIy\nMDIyLTA0LTA3IDAwOjAwOjAwIn0=";
|
||||
|
||||
let expected = SubscriptionInfo {
|
||||
status: SubscriptionStatus::ACTIVE,
|
||||
serverid: Some("830000000123456789ABCDEF00000042".to_string()),
|
||||
checktime: Some(1600000000),
|
||||
key: Some("pve4t-123456789a".to_string()),
|
||||
message: None,
|
||||
productname: Some("Proxmox VE Free Trial Subscription 12 Months (4 CPUs)".to_string()),
|
||||
regdate: Some("2022-04-07 00:00:00".to_string()),
|
||||
nextduedate: Some("0000-00-00".to_string()),
|
||||
url: None,
|
||||
signature: None,
|
||||
};
|
||||
|
||||
let parsed = parse_subscription_file(content);
|
||||
assert!(parsed.is_ok());
|
||||
let parsed = parsed.unwrap();
|
||||
assert!(parsed.is_some());
|
||||
let parsed = parsed.unwrap();
|
||||
|
||||
assert_eq!(parsed, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pbs_compat() {
|
||||
let content = "pbst-123456789a\n//6dnM9V6nNmSh2GbQfZDA==\neyJzdGF0dXMiOiJhY3RpdmUiLCJzZXJ2ZXJpZCI6IjgzMDAwMDAwMDEyMzQ1Njc4OUFCQ0RFRjAwMDAwMDQyIiwiY2hlY2t0aW1lIjoxNjAwMDAwMDAwLCJrZXkiOiJwYnN0LTEyMzQ1Njc4OWEiLCJwcm9kdWN0bmFtZSI6IlByb3htb3ggQmFja3VwIFNlcnZlciBUZXN0IFN1YnNjcmlwdGlvbiAtMSB5ZWFyIiwicmVnZGF0ZSI6IjIwMjAtMDktMTkgMDA6MDA6MDAiLCJuZXh0ZHVlZGF0ZSI6IjIwMjEtMDktMTkiLCJ1cmwiOiJodHRwczovL3d3dy5wcm94bW94LmNvbS9lbi9wcm94bW94LWJhY2t1cC1zZXJ2ZXIvcHJpY2luZyJ9\n";
|
||||
|
||||
let expected = SubscriptionInfo {
|
||||
key: Some("pbst-123456789a".to_string()),
|
||||
serverid: Some("830000000123456789ABCDEF00000042".to_string()),
|
||||
status: SubscriptionStatus::ACTIVE,
|
||||
checktime: Some(1600000000),
|
||||
url: Some("https://www.proxmox.com/en/proxmox-backup-server/pricing".into()),
|
||||
message: None,
|
||||
nextduedate: Some("2021-09-19".into()),
|
||||
regdate: Some("2020-09-19 00:00:00".into()),
|
||||
productname: Some("Proxmox Backup Server Test Subscription -1 year".into()),
|
||||
signature: None,
|
||||
};
|
||||
|
||||
let parsed = parse_subscription_file(content);
|
||||
assert!(parsed.is_ok());
|
||||
let parsed = parsed.unwrap();
|
||||
assert!(parsed.is_some());
|
||||
let parsed = parsed.unwrap();
|
||||
|
||||
assert_eq!(parsed, expected);
|
||||
}
|
6
proxmox-subscription/src/lib.rs
Normal file
6
proxmox-subscription/src/lib.rs
Normal file
@ -0,0 +1,6 @@
|
||||
mod subscription_info;
|
||||
pub use subscription_info::{get_hardware_address, SubscriptionInfo, SubscriptionStatus};
|
||||
|
||||
pub mod check;
|
||||
pub mod files;
|
||||
pub mod sign;
|
97
proxmox-subscription/src/sign.rs
Normal file
97
proxmox-subscription/src/sign.rs
Normal file
@ -0,0 +1,97 @@
|
||||
use anyhow::{bail, Error};
|
||||
|
||||
use openssl::{hash::MessageDigest, pkey::Public};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::SubscriptionInfo;
|
||||
|
||||
#[derive(PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
/// Input for offline key signing requests
|
||||
pub struct ServerBlob {
|
||||
/// Server ID generated with [[crate::get_hardware_address()]].
|
||||
pub serverid: String,
|
||||
/// Subscription key
|
||||
pub key: String,
|
||||
}
|
||||
|
||||
/// Common abstraction for signing operations returning [String]-representation of signature.
|
||||
pub trait Signer {
|
||||
fn sign(&self, data: &[u8]) -> Result<String, Error>;
|
||||
}
|
||||
|
||||
impl Signer for openssl::pkey::PKey<openssl::pkey::Private> {
|
||||
fn sign(&self, data: &[u8]) -> Result<String, Error> {
|
||||
use openssl::pkey;
|
||||
|
||||
// see Signer docs - different algorithms require different constructors
|
||||
let mut signer = match self.id() {
|
||||
pkey::Id::ED25519 | pkey::Id::ED448 => {
|
||||
openssl::sign::Signer::new_without_digest(self.as_ref())?
|
||||
}
|
||||
pkey::Id::RSA | pkey::Id::EC => {
|
||||
openssl::sign::Signer::new(MessageDigest::sha512(), self.as_ref())?
|
||||
}
|
||||
id => bail!("Unsupported key type '{id:?}'"),
|
||||
};
|
||||
|
||||
Ok(hex::encode(signer.sign_oneshot_to_vec(data)?))
|
||||
}
|
||||
}
|
||||
|
||||
/// Common verifier abstraction for signatures created by [Signer]s.
|
||||
pub trait Verifier {
|
||||
fn verify(&self, data: &[u8], signature: &str) -> Result<(), Error>;
|
||||
}
|
||||
|
||||
impl Verifier for openssl::pkey::PKey<Public> {
|
||||
fn verify(&self, data: &[u8], signature: &str) -> Result<(), Error> {
|
||||
use openssl::pkey;
|
||||
|
||||
// see Verifier docs - different algorithms require different constructors
|
||||
let mut verifier = match self.id() {
|
||||
pkey::Id::ED25519 | pkey::Id::ED448 => {
|
||||
openssl::sign::Verifier::new_without_digest(self.as_ref())?
|
||||
}
|
||||
pkey::Id::RSA | pkey::Id::EC => {
|
||||
openssl::sign::Verifier::new(MessageDigest::sha512(), self.as_ref())?
|
||||
}
|
||||
id => bail!("Unsupported key type '{id:?}'"),
|
||||
};
|
||||
if !verifier.verify_oneshot(&hex::decode(signature)?, data)? {
|
||||
bail!("Signature mismatch.");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
/// A signed response to a signature request for offline keys.
|
||||
pub struct SignedResponse {
|
||||
/// Signature of response
|
||||
pub signature: String,
|
||||
/// Payload (signed [SubscriptionInfo]s)
|
||||
pub blobs: Vec<SubscriptionInfo>,
|
||||
}
|
||||
|
||||
impl SignedResponse {
|
||||
/// Verify outer signature (of server response)
|
||||
pub fn verify(self, key: &openssl::pkey::PKey<Public>) -> Result<Vec<SubscriptionInfo>, Error> {
|
||||
let canonical =
|
||||
proxmox_serde::json::to_canonical_json(&serde_json::to_value(&self.blobs)?)?;
|
||||
|
||||
match key.verify(&canonical, &self.signature) {
|
||||
Ok(()) => Ok(self.blobs),
|
||||
Err(_) => todo!(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
/// A sign request for offline keys
|
||||
pub struct SignRequest {
|
||||
/// Subscription key of `proxmox-offline-mirror` instance issuing this request (must be [crate::SubscriptionStatus::ACTIVE]).
|
||||
pub mirror_key: ServerBlob,
|
||||
/// Offline keys that should be signed by server.
|
||||
pub blobs: Vec<ServerBlob>,
|
||||
}
|
215
proxmox-subscription/src/subscription_info.rs
Normal file
215
proxmox-subscription/src/subscription_info.rs
Normal file
@ -0,0 +1,215 @@
|
||||
use anyhow::{bail, format_err, Error};
|
||||
use openssl::{
|
||||
hash::{hash, DigestBytes, MessageDigest},
|
||||
pkey::Public,
|
||||
};
|
||||
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_KEY_CHECK_FAILURE_AGE: i64 = 5 * 24 * 3600;
|
||||
|
||||
// Aliases are needed for PVE compat!
|
||||
#[cfg_attr(feature = "api-types", api())]
|
||||
#[derive(Debug, Copy, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
/// Subscription status
|
||||
pub enum SubscriptionStatus {
|
||||
// FIXME: remove?
|
||||
/// newly set subscription, not yet checked
|
||||
#[serde(alias = "New")]
|
||||
NEW,
|
||||
/// no subscription set
|
||||
#[serde(alias = "Notfound")]
|
||||
NOTFOUND,
|
||||
/// subscription set and active
|
||||
#[serde(alias = "Active")]
|
||||
ACTIVE,
|
||||
/// subscription set but invalid for this server
|
||||
#[serde(alias = "Invalid")]
|
||||
INVALID,
|
||||
}
|
||||
impl Default for SubscriptionStatus {
|
||||
fn default() -> Self {
|
||||
SubscriptionStatus::NOTFOUND
|
||||
}
|
||||
}
|
||||
impl std::fmt::Display for SubscriptionStatus {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
SubscriptionStatus::NEW => write!(f, "New"),
|
||||
SubscriptionStatus::NOTFOUND => write!(f, "NotFound"),
|
||||
SubscriptionStatus::ACTIVE => write!(f, "Active"),
|
||||
SubscriptionStatus::INVALID => write!(f, "Invalid"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "api-types", api(
|
||||
properties: {
|
||||
status: {
|
||||
type: SubscriptionStatus,
|
||||
},
|
||||
},
|
||||
))]
|
||||
#[cfg_attr(feature = "api-types", derive(Updater))]
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
/// Proxmox subscription information
|
||||
pub struct SubscriptionInfo {
|
||||
/// Subscription status from the last check
|
||||
pub status: SubscriptionStatus,
|
||||
/// the server ID, if permitted to access
|
||||
#[serde(skip_serializing_if = "Option::is_none", alias = "validdirectory")]
|
||||
pub serverid: Option<String>,
|
||||
/// timestamp of the last check done
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub checktime: Option<i64>,
|
||||
/// the subscription key, if set and permitted to access
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub key: Option<String>,
|
||||
/// a more human readable status message
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub message: Option<String>,
|
||||
/// human readable productname of the set subscription
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub productname: Option<String>,
|
||||
/// register date of the set subscription
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub regdate: Option<String>,
|
||||
/// next due date of the set subscription
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub nextduedate: Option<String>,
|
||||
/// URL to the web shop
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub url: Option<String>,
|
||||
/// Signature for offline keys
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub signature: Option<String>,
|
||||
}
|
||||
|
||||
impl SubscriptionInfo {
|
||||
/// Returns the canonicalized signed data and, if available, signature contained in `self`.
|
||||
pub fn signed_data(&self) -> Result<(Vec<u8>, Option<String>), 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")
|
||||
.map(|v| v.as_str().map(|v| v.to_owned()))
|
||||
.flatten();
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
/// Whether a signature exists - *this does not check the signature's validity!*
|
||||
///
|
||||
/// Use [SubscriptionInfo::verify()] to verify the signature.
|
||||
pub fn is_signed(&self) -> bool {
|
||||
self.signature.is_some()
|
||||
}
|
||||
|
||||
/// Verify signature, if `self` is signed. Returns `false` if `self` is unsigned, `true` if signature is valid for `key`.
|
||||
pub fn verify(&self, key: &openssl::pkey::PKey<Public>) -> Result<bool, Error> {
|
||||
if self.signature.is_none() {
|
||||
return Ok(false);
|
||||
}
|
||||
let (signed, signature) = self.signed_data()?;
|
||||
let signature = match signature {
|
||||
None => bail!("Failed to extract signature value."),
|
||||
Some(sig) => sig,
|
||||
};
|
||||
|
||||
match key.verify(&signed, &signature) {
|
||||
Ok(()) => Ok(true),
|
||||
Err(err) => Err(format_err!("Signature verification failed - {err}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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(format!("Missing server ID."));
|
||||
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(_)) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Shortcut for md5 sums.
|
||||
pub(crate) fn md5sum(data: &[u8]) -> Result<DigestBytes, Error> {
|
||||
hash(MessageDigest::md5(), data).map_err(Error::from)
|
||||
}
|
||||
|
||||
/// Generate the current system's "server ID".
|
||||
pub fn get_hardware_address() -> Result<String, Error> {
|
||||
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())
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user